diff --git a/apps/cinc/cmd/common.go b/apps/cinc/cmd/common.go index 75be842..28b9482 100644 --- a/apps/cinc/cmd/common.go +++ b/apps/cinc/cmd/common.go @@ -182,7 +182,44 @@ func resolveSupermarketProfile(cmd *cobra.Command) (config.Profile, error) { if err != nil { return config.Profile{}, err } + return selectSupermarketProfile(cmd, cfg) +} + +// resolveSupermarketSite returns the Supermarket a command should talk to: +// the --supermarket-site flag, then the resolved profile's supermarket_site, +// then "" for the caller's default (the public Supermarket). +// +// The read-only Supermarket commands need no credentials, so this never +// triggers the first-run flow and never fails: an absent or unreadable +// credentials file simply means no configured preference. Without this, a +// private supermarket_site was honored by `supermarket share` and ignored by +// every command that reads. +func resolveSupermarketSite(cmd *cobra.Command, siteFlag string) string { + if siteFlag != "" { + return siteFlag + } + path := resolveConfigPath(cmd) + if path == "" { + return "" + } + if _, err := os.Stat(path); err != nil { + return "" + } + cfg, err := config.Load(path) + if err != nil { + return "" + } + profile, err := selectSupermarketProfile(cmd, cfg) + if err != nil { + return "" + } + return profile.SupermarketSite +} +// selectSupermarketProfile picks which profile carries the Supermarket +// settings: an explicit --profile or environment profile wins, otherwise the +// conventional [supermarket] section, falling back to [default]. +func selectSupermarketProfile(cmd *cobra.Command, cfg *config.Config) (config.Profile, error) { if profileName, _ := cmd.Flags().GetString("profile"); profileName != "" { return cfg.Profile(profileName) } diff --git a/apps/cinc/cmd/supermarket.go b/apps/cinc/cmd/supermarket.go index 60d9b64..49e2e6c 100644 --- a/apps/cinc/cmd/supermarket.go +++ b/apps/cinc/cmd/supermarket.go @@ -53,7 +53,7 @@ cinc supermarket list`, if err != nil { return err } - client, err := supermarket.NewAnonymous(site) + client, err := supermarket.NewAnonymous(resolveSupermarketSite(cmd, site)) if err != nil { return err } @@ -69,7 +69,7 @@ cinc supermarket list`, return printSupermarketEntries(cmd.OutOrStdout(), result.Entries, verbose) }, } - cmd.Flags().StringVar(&site, "supermarket-site", "", "URL of the Chef Supermarket site (default: https://supermarket.chef.io)") + cmd.Flags().StringVar(&site, "supermarket-site", "", "URL of the Chef Supermarket site (default: profile supermarket_site, then https://supermarket.chef.io)") cmd.Flags().StringVar(&order, "order", "", "sort order: recently_updated, recently_added, most_downloaded, most_followed") cmd.Flags().StringVar(&user, "user", "", "only show cookbooks owned by this Supermarket username") cmd.Flags().IntVar(&limit, "limit", 0, "cap the number of entries returned (default: all)") @@ -98,7 +98,7 @@ cinc supermarket search nginx`, if err != nil { return err } - client, err := supermarket.NewAnonymous(site) + client, err := supermarket.NewAnonymous(resolveSupermarketSite(cmd, site)) if err != nil { return err } @@ -114,7 +114,7 @@ cinc supermarket search nginx`, return printSupermarketEntries(cmd.OutOrStdout(), result.Entries, verbose) }, } - cmd.Flags().StringVar(&site, "supermarket-site", "", "URL of the Chef Supermarket site (default: https://supermarket.chef.io)") + cmd.Flags().StringVar(&site, "supermarket-site", "", "URL of the Chef Supermarket site (default: profile supermarket_site, then https://supermarket.chef.io)") cmd.Flags().IntVar(&limit, "limit", 0, "cap the number of entries returned (default: all matches)") cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "include maintainer and latest version per cookbook") return cmd @@ -140,7 +140,7 @@ cinc supermarket show nginx 1.2.0`, if err != nil { return err } - client, err := supermarket.NewAnonymous(site) + client, err := supermarket.NewAnonymous(resolveSupermarketSite(cmd, site)) if err != nil { return err } @@ -158,7 +158,7 @@ cinc supermarket show nginx 1.2.0`, return printSupermarketShow(cmd.OutOrStdout(), result) }, } - cmd.Flags().StringVar(&site, "supermarket-site", "", "URL of the Chef Supermarket site (default: https://supermarket.chef.io)") + cmd.Flags().StringVar(&site, "supermarket-site", "", "URL of the Chef Supermarket site (default: profile supermarket_site, then https://supermarket.chef.io)") return cmd } @@ -288,8 +288,9 @@ func formatBytes(n int64) string { } // newSupermarketDownloadCmd builds `cinc supermarket download`. -// Like `explore`, this hits only anonymous endpoints, so we never -// load a profile or key here. +// Like `explore`, this hits only anonymous endpoints, so no key is loaded +// and a missing credentials file is fine. An existing profile is still read +// for supermarket_site so downloads come from the configured Supermarket. func newSupermarketDownloadCmd() *cobra.Command { var ( file string @@ -319,7 +320,7 @@ cinc supermarket download nginx`, if len(args) == 2 { opts.Version = args[1] } - client, err := supermarket.NewAnonymous(site) + client, err := supermarket.NewAnonymous(resolveSupermarketSite(cmd, site)) if err != nil { return err } @@ -336,7 +337,7 @@ cinc supermarket download nginx`, } cmd.Flags().StringVar(&file, "file", "", "output file or directory (default: ./-.tar.gz)") cmd.Flags().BoolVar(&force, "force", false, "overwrite the output file if it already exists") - cmd.Flags().StringVar(&site, "supermarket-site", "", "URL of the Chef Supermarket site (default: https://supermarket.chef.io)") + cmd.Flags().StringVar(&site, "supermarket-site", "", "URL of the Chef Supermarket site (default: profile supermarket_site, then https://supermarket.chef.io)") return cmd } @@ -373,7 +374,7 @@ cinc supermarket install nginx 1.2.0`, if err != nil { return err } - client, err := supermarket.NewAnonymous(site) + client, err := supermarket.NewAnonymous(resolveSupermarketSite(cmd, site)) if err != nil { return err } @@ -392,13 +393,15 @@ cinc supermarket install nginx 1.2.0`, return nil }, } - cmd.Flags().StringVar(&site, "supermarket-site", "", "URL of the Chef Supermarket site (default: https://supermarket.chef.io)") + cmd.Flags().StringVar(&site, "supermarket-site", "", "URL of the Chef Supermarket site (default: profile supermarket_site, then https://supermarket.chef.io)") return cmd } // newSupermarketExploreCmd builds the `cinc supermarket explore` TUI. -// It needs no credentials — every endpoint it touches is anonymous — -// so we never run the first-run flow or load a profile here. +// It needs no credentials — every endpoint it touches is anonymous — so it +// never runs the first-run flow. An existing profile is still consulted for +// supermarket_site, so `explore` browses the same Supermarket the rest of +// the commands use, but a missing credentials file is not an error. func newSupermarketExploreCmd() *cobra.Command { var site string cmd := &cobra.Command{ @@ -413,24 +416,25 @@ cinc supermarket explore`, "quit.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + resolved := resolveSupermarketSite(cmd, site) return explore.Run(cmd.Context(), explore.Options{ - Site: site, + Site: resolved, Stdin: cmd.InOrStdin(), Stdout: cmd.OutOrStdout(), Stderr: cmd.ErrOrStderr(), - Install: supermarketInstaller(cmd, site), + Install: supermarketInstaller(cmd, resolved), }) }, } - cmd.Flags().StringVar(&site, "supermarket-site", "", "URL of the Chef Supermarket site (default: https://supermarket.chef.io)") + cmd.Flags().StringVar(&site, "supermarket-site", "", "URL of the Chef Supermarket site (default: profile supermarket_site, then https://supermarket.chef.io)") return cmd } // supermarketInstaller returns the closure the explore TUI calls when the // user installs a cookbook. Credentials are resolved lazily — only when // the closure runs — so launching `cinc supermarket explore` stays -// credential-free. Any credential or upload failure flows back to the -// TUI footer. +// credential-free. site is already resolved by the caller. Any credential +// or upload failure flows back to the TUI footer. func supermarketInstaller(cmd *cobra.Command, site string) func(context.Context, string, string) error { return func(ctx context.Context, name, version string) error { server, err := resolveClient(cmd) diff --git a/apps/cinc/cmd/supermarket_site_test.go b/apps/cinc/cmd/supermarket_site_test.go new file mode 100644 index 0000000..8664717 --- /dev/null +++ b/apps/cinc/cmd/supermarket_site_test.go @@ -0,0 +1,164 @@ +package cmd + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// newSiteRecordingServer stands in for a private Supermarket. It records the +// first path it is asked for, so a test can tell whether a command talked to +// it at all, and answers the handful of endpoints the read commands use. +func newSiteRecordingServer(t *testing.T, got *string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if *got == "" { + *got = r.URL.Path + } + w.Header().Set("Content-Type", "application/json") + switch { + case strings.HasSuffix(r.URL.Path, "/download"): + w.Header().Set("Content-Type", "application/gzip") + _, _ = w.Write([]byte("tar-body")) + case strings.Contains(r.URL.Path, "/search"): + _, _ = w.Write([]byte(`{"start":0,"total":0,"items":[]}`)) + case strings.HasSuffix(r.URL.Path, "/cookbooks"): + _, _ = w.Write([]byte(`{"start":0,"total":0,"items":[]}`)) + default: + _, _ = w.Write([]byte(`{"name":"nginx","latest_version":"` + srvVersionURL(r) + `","versions":[]}`)) + } + })) + t.Cleanup(srv.Close) + return srv +} + +func srvVersionURL(r *http.Request) string { + return "http://" + r.Host + "/api/v1/cookbooks/nginx/versions/1_2_0" +} + +// writeSupermarketSiteConfig writes a credentials file whose profile points at +// a private Supermarket, the way `cinc config create --supermarket-site` does. +func writeSupermarketSiteConfig(t *testing.T, profile, site string) string { + t.Helper() + cfgPath := filepath.Join(t.TempDir(), "credentials") + cfg := fmt.Sprintf(`[%s] +supermarket_site = %q +client_name = "tim" +client_key = %q +`, profile, site, writeTestKey(t)) + if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + return cfgPath +} + +// TestSupermarketReadCommandsUseProfileSite covers the split where +// `supermarket share` honored a profile's supermarket_site while every +// read-only command silently queried the public Supermarket instead. A user +// with a private Supermarket configured should not have to repeat +// --supermarket-site on each command, and `install` in particular should not +// pull a public cookbook onto their server when they meant their own. +func TestSupermarketReadCommandsUseProfileSite(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + for _, tc := range []struct { + name string + args func(site string) []string + }{ + {"download", func(string) []string { return []string{"supermarket", "download", "nginx"} }}, + {"show", func(string) []string { return []string{"supermarket", "show", "nginx"} }}, + {"search", func(string) []string { return []string{"supermarket", "search", "nginx"} }}, + {"list", func(string) []string { return []string{"supermarket", "list"} }}, + } { + t.Run(tc.name, func(t *testing.T) { + var got string + srv := newSiteRecordingServer(t, &got) + cfgPath := writeSupermarketSiteConfig(t, "default", srv.URL) + + root := newRootCmd() + root.SetOut(new(bytes.Buffer)) + root.SetErr(new(bytes.Buffer)) + args := append(tc.args(srv.URL), "--config", cfgPath) + if tc.name == "download" { + args = append(args, "--file", filepath.Join(t.TempDir(), "out.tgz")) + } + root.SetArgs(args) + _ = root.Execute() + + if got == "" { + t.Fatalf("%s never reached the configured Supermarket at %s; it used the public default", tc.name, srv.URL) + } + }) + } +} + +// The flag still wins over the profile. +func TestSupermarketSiteFlagBeatsProfile(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + cfgPath := writeSupermarketSiteConfig(t, "default", "https://profile.example.test") + + cmd := fakeCmd(cfgPath, "", "", new(bytes.Buffer)) + if got := resolveSupermarketSite(cmd, "https://flag.example.test"); got != "https://flag.example.test" { + t.Errorf("site = %q, want the --supermarket-site flag to win", got) + } +} + +// With no credentials file at all the read commands must still work, falling +// back to the public Supermarket rather than erroring or prompting for setup. +func TestSupermarketSiteFallsBackWithoutCredentials(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + missing := filepath.Join(t.TempDir(), "does-not-exist") + + cmd := fakeCmd(missing, "", "", new(bytes.Buffer)) + if got := resolveSupermarketSite(cmd, ""); got != "" { + t.Errorf("site = %q, want \"\" so the caller uses the public default", got) + } +} + +// A profile with no supermarket_site leaves the default in place. +func TestSupermarketSiteEmptyProfileKeyFallsBack(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + cfgPath := filepath.Join(t.TempDir(), "credentials") + cfg := fmt.Sprintf(`[default] +cinc_server_url = "https://cinc.example.test/organizations/acme" +client_name = "tim" +client_key = %q +`, writeTestKey(t)) + if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + + cmd := fakeCmd(cfgPath, "", "", new(bytes.Buffer)) + if got := resolveSupermarketSite(cmd, ""); got != "" { + t.Errorf("site = %q, want \"\" when the profile sets no supermarket_site", got) + } +} + +// The conventional [supermarket] profile is preferred over [default], the +// same precedence `supermarket share` already uses. +func TestSupermarketSitePrefersSupermarketProfile(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + cfgPath := filepath.Join(t.TempDir(), "credentials") + cfg := fmt.Sprintf(`[default] +supermarket_site = "https://default.example.test" +client_name = "tim" +client_key = %q + +[supermarket] +supermarket_site = "https://supermarket-profile.example.test" +client_name = "tim" +client_key = %q +`, writeTestKey(t), writeTestKey(t)) + if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + + cmd := fakeCmd(cfgPath, "", "", new(bytes.Buffer)) + if got := resolveSupermarketSite(cmd, ""); !strings.Contains(got, "supermarket-profile") { + t.Errorf("site = %q, want the [supermarket] profile to win over [default]", got) + } +} diff --git a/docs/commands/cinc_supermarket_download.md b/docs/commands/cinc_supermarket_download.md index c602870..b9fef11 100644 --- a/docs/commands/cinc_supermarket_download.md +++ b/docs/commands/cinc_supermarket_download.md @@ -27,7 +27,7 @@ cinc supermarket download nginx --file string output file or directory (default: ./-.tar.gz) --force overwrite the output file if it already exists -h, --help help for download - --supermarket-site string URL of the Chef Supermarket site (default: https://supermarket.chef.io) + --supermarket-site string URL of the Chef Supermarket site (default: profile supermarket_site, then https://supermarket.chef.io) ``` ### Options inherited from parent commands diff --git a/docs/commands/cinc_supermarket_explore.md b/docs/commands/cinc_supermarket_explore.md index 376ffb8..bd4870f 100644 --- a/docs/commands/cinc_supermarket_explore.md +++ b/docs/commands/cinc_supermarket_explore.md @@ -26,7 +26,7 @@ cinc supermarket explore ``` -h, --help help for explore - --supermarket-site string URL of the Chef Supermarket site (default: https://supermarket.chef.io) + --supermarket-site string URL of the Chef Supermarket site (default: profile supermarket_site, then https://supermarket.chef.io) ``` ### Options inherited from parent commands diff --git a/docs/commands/cinc_supermarket_install.md b/docs/commands/cinc_supermarket_install.md index 48b7cd6..2402dce 100644 --- a/docs/commands/cinc_supermarket_install.md +++ b/docs/commands/cinc_supermarket_install.md @@ -31,7 +31,7 @@ cinc supermarket install nginx 1.2.0 ``` -h, --help help for install - --supermarket-site string URL of the Chef Supermarket site (default: https://supermarket.chef.io) + --supermarket-site string URL of the Chef Supermarket site (default: profile supermarket_site, then https://supermarket.chef.io) ``` ### Options inherited from parent commands diff --git a/docs/commands/cinc_supermarket_list.md b/docs/commands/cinc_supermarket_list.md index 3e02436..69462d6 100644 --- a/docs/commands/cinc_supermarket_list.md +++ b/docs/commands/cinc_supermarket_list.md @@ -26,7 +26,7 @@ cinc supermarket list -h, --help help for list --limit int cap the number of entries returned (default: all) --order string sort order: recently_updated, recently_added, most_downloaded, most_followed - --supermarket-site string URL of the Chef Supermarket site (default: https://supermarket.chef.io) + --supermarket-site string URL of the Chef Supermarket site (default: profile supermarket_site, then https://supermarket.chef.io) --user string only show cookbooks owned by this Supermarket username -v, --verbose include maintainer and latest version per cookbook ``` diff --git a/docs/commands/cinc_supermarket_search.md b/docs/commands/cinc_supermarket_search.md index 2f24773..63b48c9 100644 --- a/docs/commands/cinc_supermarket_search.md +++ b/docs/commands/cinc_supermarket_search.md @@ -25,7 +25,7 @@ cinc supermarket search nginx ``` -h, --help help for search --limit int cap the number of entries returned (default: all matches) - --supermarket-site string URL of the Chef Supermarket site (default: https://supermarket.chef.io) + --supermarket-site string URL of the Chef Supermarket site (default: profile supermarket_site, then https://supermarket.chef.io) -v, --verbose include maintainer and latest version per cookbook ``` diff --git a/docs/commands/cinc_supermarket_show.md b/docs/commands/cinc_supermarket_show.md index 0f72afb..8538b8d 100644 --- a/docs/commands/cinc_supermarket_show.md +++ b/docs/commands/cinc_supermarket_show.md @@ -31,7 +31,7 @@ cinc supermarket show nginx 1.2.0 ``` -h, --help help for show - --supermarket-site string URL of the Chef Supermarket site (default: https://supermarket.chef.io) + --supermarket-site string URL of the Chef Supermarket site (default: profile supermarket_site, then https://supermarket.chef.io) ``` ### Options inherited from parent commands