review fixes: confined artifacts, typed ambiguity, one save offer
Opus findings applied: config-sourced artifacts are confined to the project tree, symlinks included (a committed ../../.carlos would have published credentials); channel ambiguity is a typed sentinel so the no-instances refusal can never become a ship+promote via the chooser; '--' guards the artifact re-parse; save offers fire only for chosen values with deploy folding everything into one; domains verbs get real account resolution; kind inferred when a config names only an artifact; config values trimmed. New coverage: containment table, typed-ambiguity table, channel chooser, zero-arg e2e (saved-defaults run + non-TTY).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7 files changed,
+320
−20
apimode.go+16 −1config.go+6 −0deploy.go+84 −13deploy_wizard_test.go+176 −0docs/superpowers/specs/2026-08-13-project-defaults-design.md+24 −0domains_cli.go+6 −6domains_cli_test.go+8 −0
diff --git a/apimode.go b/apimode.go| index 40ac2cce4..77a214cac 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -59,6 +59,14 @@ type apiSession struct { |
| // defApp memoizes defaultApp's answer, defAcct's rule for the sibling |
| // flag: one resolution — and at most one chooser — per invocation. |
| defApp string |
| + // appPrompted records that defaultApp's answer came from the CHOOSER — |
| + // only interactively-resolved values belong in a save offer (a silent |
| + // single-app auto-resolve prompting "save?" on every run would nag). |
| + appPrompted bool |
| + // deferSaves suppresses the inline save offers (account chooser, app |
| + // chooser): set by zero-arg deploy, whose ONE combined offer at the end |
| + // of first-run resolution covers everything (spec: one save offer). |
| + deferSaves bool |
| } |
| // resolveMode decides which mode the CLI is in. (nil, nil) means direct mode |
| @@ -489,6 +497,12 @@ func (s *apiSession) defaultAccountOnce(ctx context.Context) (apiAcct, error) { |
| dest = "" |
| } |
| } |
| + if s.deferSaves { |
| + // Zero-arg deploy makes ONE combined save offer at the end of |
| + // first-run resolution — the chooser's own prompt would be a |
| + // second question about the same file. |
| + dest = "" |
| + } |
| picked, save, err := chooseAccount(chooserIn, stdout, s.console, accts, dest) |
| if err != nil { |
| return apiAcct{}, err |
| @@ -574,7 +588,8 @@ func (s *apiSession) defaultApp(ctx context.Context, acct apiAcct, offerSave boo |
| return "", err |
| } |
| s.defApp = picked |
| - if offerSave { |
| + s.appPrompted = true |
| + if offerSave && !s.deferSaves { |
| offerSaveProjectDefaults(s.console, entry.Name, map[string]any{"app": picked}, |
| fmt.Sprintf("save %s as this project's default app", picked)) |
| } |
diff --git a/config.go b/config.go| index 15814356a..08986df5f 100644 |
| --- a/config.go |
| +++ b/config.go |
| @@ -138,6 +138,12 @@ func loadConfig(path string) (cliConfig, error) { |
| cfg.Console = n |
| } |
| cfg.Account = strings.TrimSpace(cfg.Account) |
| + // Same trim for every defaults key: {"app": "shop "} would otherwise |
| + // travel as shop%20 and 404 confusingly. |
| + cfg.App = strings.TrimSpace(cfg.App) |
| + cfg.Kind = strings.TrimSpace(cfg.Kind) |
| + cfg.Artifact = strings.TrimSpace(cfg.Artifact) |
| + cfg.Target = strings.TrimSpace(cfg.Target) |
| return cfg, nil |
| } |
diff --git a/deploy.go b/deploy.go| index 0076b8ee6..19a872778 100644 |
| --- a/deploy.go |
| +++ b/deploy.go |
| @@ -64,6 +64,7 @@ func runDeploy(args []string) { |
| } |
| sess := requireDeployAPISession(*consoleFlag) // API-mode only: member verb |
| + sess.deferSaves = true // deploy makes ONE combined save offer, below |
| printUsing(stdout, sess) // printed HERE, once: runShipAPI's own banner is suppressed below |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| ctx := context.Background() |
| @@ -74,14 +75,14 @@ func runDeploy(args []string) { |
| newly := map[string]any{} |
| if *app == "" { |
| - cfgApp, _, cfgConsole, cerr := configApp() |
| - fromConfig := cerr == nil && cfgApp != "" && cfgConsole == sess.console |
| r, aerr := sess.defaultApp(ctx, acct, false) |
| var na *noAppsError |
| switch { |
| case aerr == nil && r != "": |
| *app = r |
| - if !fromConfig { |
| + if sess.appPrompted { |
| + // Only a CHOSEN app belongs in the save offer — the muted |
| + // single-app auto-resolve must not nag on every run. |
| newly["app"] = r |
| } |
| case errors.As(aerr, &na) && interactiveTerminal(): |
| @@ -112,7 +113,16 @@ func runDeploy(args []string) { |
| log.Fatal(derr) |
| } |
| if dsrc != "" && dcfg.Console == sess.console { |
| - artifact = dcfg.Artifact |
| + // The config's artifact is COMMITTED REPO INPUT and this run is |
| + // deliberately zero-question — so it is confined to the config's |
| + // own project tree before anything is packed. Without this, a |
| + // hostile checkout's {"artifact": "../../.carlos"} would tar the |
| + // operator's credentials and publish them to the edge on the very |
| + // flow this feature exists to sell (review 2026-08-13, finding 1). |
| + artifact, err = projectArtifact(dsrc, dcfg.Artifact) |
| + if err != nil { |
| + log.Fatalf("deploy: %v", err) |
| + } |
| if !flagWasSet(fs, "kind") && dcfg.Kind != "" { |
| *kind = dcfg.Kind |
| } |
| @@ -141,10 +151,20 @@ func runDeploy(args []string) { |
| log.Fatal("deploy: nothing to ship — pass -app and a binary or site directory, " + |
| "or run carlos deploy once at a terminal to save this project's defaults") |
| } |
| - if _, err := os.Stat(artifact); err != nil { |
| + fi, err := os.Stat(artifact) |
| + if err != nil { |
| log.Fatalf("deploy: artifact %s: %v (the saved default may be stale — edit .carlos/config)", artifact, err) |
| } |
| - fs.Parse([]string{artifact}) |
| + // A config that names an artifact but no kind must not ship a |
| + // directory as a "binary" (the flag's default): what the artifact IS |
| + // decides, exactly as the wizard infers it. |
| + if !flagWasSet(fs, "kind") && dcfg.Kind == "" && fi.IsDir() { |
| + *kind = "static" |
| + } |
| + // "--" so an artifact path starting with "-" is a PATH, never a |
| + // flag: without it a config value like "-kind=static" would mutate |
| + // the deploy's shape instead of being shipped (or refused). |
| + fs.Parse([]string{"--", artifact}) |
| } |
| if len(newly) > 0 && interactiveTerminal() { |
| @@ -158,10 +178,11 @@ func runDeploy(args []string) { |
| } |
| channel, hosts, staticDeploy, err := deployTargets(recs, *kind, *channelFlag, *hostFlag, |
| func() ([]apiChannel, error) { return sess.channels(ctx, acct, *app) }) |
| - if err != nil && *channelFlag == "" && interactiveTerminal() && |
| - strings.Contains(err.Error(), "-channel") { |
| + if errors.Is(err, errAmbiguousChannel) && *channelFlag == "" && interactiveTerminal() { |
| // The refusal's own advice is "name one with -channel" — at a |
| - // terminal, ask instead of quoting the flag back. |
| + // terminal, ask instead of quoting the flag back. Typed, never |
| + // text-matched: only a genuine several-channels ambiguity may be |
| + // answered this way (a no-instances refusal must stay a refusal). |
| if picked, ok := chooseDeployChannel(ctx, sess, acct, *app); ok { |
| channel, hosts, staticDeploy, err = deployTargets(recs, *kind, picked, *hostFlag, |
| func() ([]apiChannel, error) { return sess.channels(ctx, acct, *app) }) |
| @@ -268,6 +289,45 @@ func deployingLine(hosts []string, channel string) string { |
| return fmt.Sprintf("deploying %d instances on %s", len(hosts), channel) |
| } |
| +// projectArtifact confines a config-sourced artifact to the config's own |
| +// project tree: dsrc is <root>/.carlos/config, and the artifact must |
| +// resolve — symlinks included — to somewhere under <root>. Absolute paths |
| +// and ..-escapes are refused outright; the wizard path never comes here |
| +// (a human typed that answer at this terminal, and it is not committed |
| +// input). Returns the root-resolved path the deploy should actually use, |
| +// so a saved "./public" works from any subdirectory of the project. |
| +func projectArtifact(dsrc, artifact string) (string, error) { |
| + if artifact == "" { |
| + return "", fmt.Errorf("%s names no artifact", dsrc) |
| + } |
| + if filepath.IsAbs(artifact) { |
| + return "", fmt.Errorf("%s names an absolute artifact (%s) — refusing; a project default must be a path inside the checkout", dsrc, artifact) |
| + } |
| + root := filepath.Dir(filepath.Dir(dsrc)) |
| + resolved := filepath.Join(root, artifact) |
| + escapeErr := fmt.Errorf("%s names an artifact outside the project (%s) — refusing; a project default must be a path inside the checkout", dsrc, artifact) |
| + rel, err := filepath.Rel(root, resolved) |
| + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { |
| + return "", escapeErr |
| + } |
| + // Symlinks are part of the same escape surface: a committed "site" |
| + // symlink pointing at $HOME resolves inside the tree lexically and |
| + // outside it physically. |
| + realRoot, err := filepath.EvalSymlinks(root) |
| + if err != nil { |
| + return "", err |
| + } |
| + realArtifact, err := filepath.EvalSymlinks(resolved) |
| + if err != nil { |
| + return "", fmt.Errorf("artifact %s: %w (the saved default may be stale — edit %s)", artifact, err, dsrc) |
| + } |
| + rel, err = filepath.Rel(realRoot, realArtifact) |
| + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { |
| + return "", escapeErr |
| + } |
| + return resolved, nil |
| +} |
| + |
| // flagWasSet reports whether the user typed a flag — the wizard and the |
| // saved deploy block must never override an explicit -kind or -target. |
| func flagWasSet(fs *flag.FlagSet, name string) bool { |
| @@ -347,9 +407,13 @@ func offerCreateApp(ctx context.Context, sess *apiSession, acct apiAcct, na *noA |
| } |
| } else { |
| name = promptLine(stdout, "app name to create (lowercase letters, digits, dashes): ") |
| - if name == "" || validAppName(name) != nil { |
| + if name == "" { |
| log.Fatal(na) |
| } |
| + if verr := validAppName(name); verr != nil { |
| + // The typo is the story, not the empty account. |
| + log.Fatalf("deploy: %v", verr) |
| + } |
| } |
| res, err := sess.createApp(ctx, acct, name, "") |
| if err != nil { |
| @@ -575,10 +639,17 @@ func staticDeployChannel(chans []apiChannel) (string, error) { |
| "pass -channel to name the channel to promote onto") |
| } |
| sort.Strings(names) |
| - return "", fmt.Errorf("this app has several channels and no instances to resolve one from — "+ |
| - "pass -channel to pick one: %s", strings.Join(names, ", ")) |
| + return "", fmt.Errorf("%w: this app has several channels and no instances to resolve one from — "+ |
| + "pass -channel to pick one: %s", errAmbiguousChannel, strings.Join(names, ", ")) |
| } |
| +// errAmbiguousChannel marks the two refusals that mean "several channels, |
| +// pick one" — the ONLY refusals the interactive chooser may answer for. |
| +// Matching on error text once turned the no-instances refusal (which merely |
| +// MENTIONS -channel) into a ship+promote after an unrelated question |
| +// (review 2026-08-13, finding 2). |
| +var errAmbiguousChannel = errors.New("ambiguous channel") |
| + |
| // attachUnclaimedHost auto-attaches -host before shipping, when this deploy |
| // is targeting a fresh static host with no route of its own yet (self-serve |
| // custom domains, #82/#151): the app's first deploy to a brand-new hostname |
| @@ -882,7 +953,7 @@ func resolveDeployChannel(recs []apiInstanceRecord, hostFlag string) (channel st |
| } |
| fmt.Fprintf(&b, "%s: %s", ch, strings.Join(byChannel[ch], ", ")) |
| } |
| - return "", nil, errors.New(b.String()) |
| + return "", nil, fmt.Errorf("%w: %s", errAmbiguousChannel, b.String()) |
| } |
| // filterChannelHosts computes the hosts an EXPLICIT -channel deploy should |
diff --git a/deploy_wizard_test.go b/deploy_wizard_test.go| index 94f7e2446..9fe54ed9d 100644 |
| --- a/deploy_wizard_test.go |
| +++ b/deploy_wizard_test.go |
| @@ -3,10 +3,12 @@ package main |
| import ( |
| "context" |
| "encoding/json" |
| + "errors" |
| "flag" |
| "net/http" |
| "net/http/httptest" |
| "os" |
| + "os/exec" |
| "path/filepath" |
| "strings" |
| "testing" |
| @@ -106,6 +108,180 @@ func TestOfferCreateAppUsesDirectoryName(t *testing.T) { |
| } |
| } |
| +func TestProjectArtifactContainment(t *testing.T) { |
| + root := t.TempDir() |
| + dsrc := filepath.Join(root, ".carlos", "config") |
| + if err := os.MkdirAll(filepath.Join(root, "public"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Inside the tree: resolved against the config's root, so a saved |
| + // "./public" works from any subdirectory. |
| + got, err := projectArtifact(dsrc, "./public") |
| + if err != nil || got != filepath.Join(root, "public") { |
| + t.Fatalf("in-tree = %q, %v", got, err) |
| + } |
| + |
| + // The escape shapes a hostile committed config would use: every one a |
| + // refusal, never a pack (a config artifact of ../../.carlos would tar |
| + // the operator's credentials and PUBLISH them). |
| + for _, bad := range []string{"/etc", "../outside", "../../.carlos", "public/../../x"} { |
| + if _, err := projectArtifact(dsrc, bad); err == nil { |
| + t.Errorf("projectArtifact(%q) accepted an escape", bad) |
| + } |
| + } |
| + |
| + // A symlink inside the tree pointing outside it is the same escape. |
| + outside := t.TempDir() |
| + if err := os.Symlink(outside, filepath.Join(root, "site")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := projectArtifact(dsrc, "site"); err == nil { |
| + t.Error("a symlink escaping the tree was accepted") |
| + } |
| + |
| + // A stale saved path is an error naming the file to edit. |
| + if _, err := projectArtifact(dsrc, "gone"); err == nil || |
| + !strings.Contains(err.Error(), ".carlos/config") { |
| + t.Fatalf("missing artifact = %v, want an error naming the config", err) |
| + } |
| +} |
| + |
| +func TestDeployTargetsAmbiguityIsTyped(t *testing.T) { |
| + // No instances at all: a hard refusal that merely MENTIONS -channel — |
| + // the chooser must never answer it (it once converted this refusal |
| + // into a ship+promote). |
| + _, _, _, err := deployTargets(nil, "binary", "", "", func() ([]apiChannel, error) { return nil, nil }) |
| + if err == nil || errors.Is(err, errAmbiguousChannel) { |
| + t.Fatalf("no-instances = %v, want a NON-ambiguity refusal", err) |
| + } |
| + |
| + // Instances split across channels: genuine ambiguity, typed. |
| + recs := []apiInstanceRecord{{Host: "a.x", Channel: "canary/a"}, {Host: "b.x", Channel: "canary/b"}} |
| + _, _, _, err = deployTargets(recs, "binary", "", "", func() ([]apiChannel, error) { return nil, nil }) |
| + if !errors.Is(err, errAmbiguousChannel) { |
| + t.Fatalf("split channels = %v, want errAmbiguousChannel", err) |
| + } |
| + |
| + // Static app, no instances, several channels: ambiguity, typed. |
| + chans := []apiChannel{{Channel: "canary/site"}, {Channel: "stable"}} |
| + _, _, _, err = deployTargets(nil, "static", "", "h.example", func() ([]apiChannel, error) { return chans, nil }) |
| + if !errors.Is(err, errAmbiguousChannel) { |
| + t.Fatalf("static several channels = %v, want errAmbiguousChannel", err) |
| + } |
| +} |
| + |
| +func TestChooseDeployChannel(t *testing.T) { |
| + useTempCredentials(t) |
| + captureStdout(t) |
| + serve := func(body string) *apiSession { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Write([]byte(body)) |
| + })) |
| + t.Cleanup(ts.Close) |
| + return newTestSession(ts.URL) |
| + } |
| + |
| + // Fewer than two CLEAN channels: no chooser — back to the refusal. |
| + s := serve(`{"channels":[{"channel":"canary/a"},{"channel":"bad","error":"unsigned"}]}`) |
| + if _, ok := chooseDeployChannel(context.Background(), s, apiAcct{Seg: "bdk"}, "shop"); ok { |
| + t.Fatal("one clean channel must not offer a choice") |
| + } |
| + |
| + // Two clean ones: numbered pick. |
| + s = serve(`{"channels":[{"channel":"canary/a"},{"channel":"canary/b"}]}`) |
| + swapChooserIn(t, "2\n") |
| + picked, ok := chooseDeployChannel(context.Background(), s, apiAcct{Seg: "bdk"}, "shop") |
| + if !ok || picked != "canary/b" { |
| + t.Fatalf("pick = %q %v", picked, ok) |
| + } |
| +} |
| + |
| +func TestResolvedAppExplicitFlagBypassesTheFetch(t *testing.T) { |
| + useTempCredentials(t) |
| + captureStdout(t) |
| + s, hits := accountsConsole(t, http.StatusOK, `{"accounts":[]}`) |
| + if got := resolvedApp(s, apiAcct{Seg: "bdk", Exact: true}, "shop", "usage"); got != "shop" { |
| + t.Fatalf("explicit -app = %q", got) |
| + } |
| + if *hits != 0 { |
| + t.Fatalf("explicit -app must never consult the endpoint; hits = %d", *hits) |
| + } |
| +} |
| + |
| +// The second-run promise, end to end as a real subprocess: a checkout with |
| +// saved deploy defaults runs `carlos deploy` with NO arguments and NO |
| +// prompts (non-TTY here, so any prompt would hang or misroute) — account, |
| +// app, kind and artifact all come from .carlos/config, proven by the muted |
| +// "defaults from" line and by reaching deploy's own no-instances refusal |
| +// (the first thing after resolution for this fixture) rather than any |
| +// usage or login error. |
| +func TestZeroArgDeployRunsFromSavedDefaults(t *testing.T) { |
| + bin := buildCarlos(t) |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("GET /api/cli/apps", func(w http.ResponseWriter, r *http.Request) { |
| + w.Write([]byte(`{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","apps":["taskmonger"]}]}`)) |
| + }) |
| + mux.HandleFunc("GET /api/cli/apps/", func(w http.ResponseWriter, r *http.Request) { |
| + w.Write([]byte(`{"instances":[]}`)) // instance list: none |
| + }) |
| + ts := httptest.NewServer(mux) |
| + t.Cleanup(ts.Close) |
| + |
| + project := t.TempDir() |
| + if err := os.MkdirAll(filepath.Join(project, ".carlos"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + cfg := `{"console":"` + ts.URL + `","account":"tom@keymail.dev","app":"taskmonger","kind":"binary","artifact":"./bin/server","target":"linux-arm64"}` |
| + if err := os.WriteFile(filepath.Join(project, ".carlos", "config"), []byte(cfg), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.MkdirAll(filepath.Join(project, "bin"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(project, "bin", "server"), []byte("bin"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + credPath := filepath.Join(t.TempDir(), "credentials") |
| + creds := `{"version":1,"credentials":[{"console":"` + ts.URL + `","address":"tom@keymail.dev","token":"tok"}]}` |
| + if err := os.WriteFile(credPath, []byte(creds), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + cmd := exec.Command(bin, "deploy", "-version", "x1") // scratch dir: no git for gitShortHead |
| + cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+credPath) |
| + cmd.Dir = project |
| + out, err := cmd.CombinedOutput() |
| + if err == nil { |
| + t.Fatalf("no-instances fixture must refuse, got success:\n%s", out) |
| + } |
| + if !strings.Contains(string(out), "the defaults from") || |
| + !strings.Contains(string(out), "no instances — create one") { |
| + t.Fatalf("zero-arg deploy from saved defaults = %q\nwant the defaults line and the no-instances refusal", out) |
| + } |
| + for _, never := range []string{"[y/N]", "usage:", "not logged in", "artifact to deploy"} { |
| + if strings.Contains(string(out), never) { |
| + t.Fatalf("zero-question run printed %q:\n%s", never, out) |
| + } |
| + } |
| +} |
| + |
| +// And the same shape with NOTHING saved and no terminal: the one error that |
| +// names both remedies, before any network prompt could hang a script. |
| +func TestZeroArgDeployNonTTYWithoutDefaults(t *testing.T) { |
| + bin := buildCarlos(t) |
| + cmd := exec.Command(bin, "deploy", "-version", "x1") // scratch dir: no git for gitShortHead |
| + cmd.Env = carlosTestEnv(t) |
| + cmd.Dir = t.TempDir() |
| + out, err := cmd.CombinedOutput() |
| + if err == nil { |
| + t.Fatalf("bare deploy with nothing configured succeeded:\n%s", out) |
| + } |
| + if !strings.Contains(string(out), "no deployment target configured") { |
| + t.Fatalf("bare deploy = %q, want the no-target refusal (the session is the first blocker)", out) |
| + } |
| +} |
| + |
| func TestAccountName(t *testing.T) { |
| s := &apiSession{accts: []apiAccountEntry{{Name: "tom@keymail.dev", Sqid: "bdk"}}} |
| if got := s.accountName(apiAcct{Seg: "bdk", Exact: true}); got != "tom@keymail.dev" { |
diff --git a/docs/superpowers/specs/2026-08-13-project-defaults-design.md b/docs/superpowers/specs/2026-08-13-project-defaults-design.md| index 69c7cf26d..fc14898c3 100644 |
| --- a/docs/superpowers/specs/2026-08-13-project-defaults-design.md |
| +++ b/docs/superpowers/specs/2026-08-13-project-defaults-design.md |
| @@ -108,6 +108,30 @@ project defaults`). |
| empty chooser reader). |
| - `carlosTestEnv` rules for anything shelling the binary. |
| +## Review amendments (Opus, 2026-08-13 — all applied) |
| + |
| +- **Config artifacts are confined to the config's project tree** |
| + (`projectArtifact`): absolute paths, `..`-escapes and out-of-tree |
| + symlinks are refused — a committed `{"artifact": "../../.carlos"}` |
| + would otherwise pack the operator's credentials and PUBLISH them on |
| + the zero-question second run. Artifacts resolve against the config's |
| + root, so a saved `./public` works from any subdirectory; the wizard |
| + path is unconfined (a human typed it, uncommitted). |
| +- **Channel-ambiguity is typed** (`errAmbiguousChannel`), never |
| + text-matched: the no-instances refusal mentions `-channel` too, and a |
| + text match once turned that hard refusal into a ship+promote. |
| +- The artifact is injected as `fs.Parse([]string{"--", artifact})` — a |
| + `-`-prefixed config value is a path, never a flag. |
| +- **Save offers cover only interactively-resolved values**: the muted |
| + single-app auto-resolve never prompts, and deploy suppresses the |
| + account/app choosers' inline offers (`deferSaves`) so first-run makes |
| + exactly ONE combined offer. Other verbs may still ask once per chooser |
| + they actually ran. |
| +- A config with `artifact` but no `kind` infers kind from what the |
| + artifact is (dir → static), as the wizard does. |
| +- Config values are trimmed; a typo'd created-app name errors as a typo, |
| + not as "no apps". |
| + |
| ## Out of scope |
| - Building artifacts (rastrillo/CI's job — the CLI ships bytes it is |
diff --git a/domains_cli.go b/domains_cli.go| index 5583743a8..38514d743 100644 |
| --- a/domains_cli.go |
| +++ b/domains_cli.go |
| @@ -76,7 +76,7 @@ func runDomainsAttach(args []string) { |
| wildcard := fs.Bool("wildcard", false, "enable a delegated wildcard cert for this hostname (needs a CNAME — see the 422 message if refused)") |
| catchall := fs.Bool("catchall", false, "derive a *.<host> catch-all alias to the same route (needs -wildcard)") |
| consoleFlag := fs.String("console", "", "console to attach through (default: the only logged-in one)") |
| - acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to") |
| + acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| fs.Parse(args) |
| host := strings.TrimSpace(fs.Arg(0)) |
| @@ -102,7 +102,7 @@ func runDomainsAttach(args []string) { |
| }) |
| sess := requireAPISession("attach", *consoleFlag) |
| printUsing(stdout, sess) |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| app2 := resolvedApp(sess, acct, *app, domainsAttachUsage) |
| ctx := context.Background() |
| created, resolvedRoute, err := sess.domainAttach(ctx, acct, app2, host, strings.TrimSpace(*route), "", "", wildcardArg, catchallArg) |
| @@ -157,7 +157,7 @@ func runDomainsDetach(args []string) { |
| fs := flag.NewFlagSet("domains detach", flag.ExitOnError) |
| app := fs.String("app", "", "app the hostname belongs to") |
| consoleFlag := fs.String("console", "", "console to detach through (default: the only logged-in one)") |
| - acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to") |
| + acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| fs.Parse(args) |
| host := strings.TrimSpace(fs.Arg(0)) |
| @@ -166,7 +166,7 @@ func runDomainsDetach(args []string) { |
| } |
| sess := requireAPISession("detach", *consoleFlag) |
| printUsing(stdout, sess) |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| app2 := resolvedApp(sess, acct, *app, domainsDetachUsage) |
| removed, err := sess.domainDetach(context.Background(), acct, app2, host) |
| if err != nil { |
| @@ -183,12 +183,12 @@ func runDomainsList(args []string) { |
| fs := flag.NewFlagSet("domains list", flag.ExitOnError) |
| app := fs.String("app", "", "app whose hostname claims to list") |
| consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)") |
| - acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to") |
| + acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| fs.Parse(args) |
| sess := requireAPISession("list", *consoleFlag) |
| printUsing(stdout, sess) |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| app2 := resolvedApp(sess, acct, *app, domainsListUsage) |
| recs, err := sess.domainList(context.Background(), acct, app2) |
| if err != nil { |
diff --git a/domains_cli_test.go b/domains_cli_test.go| index b9ea8f14b..bdf969f1f 100644 |
| --- a/domains_cli_test.go |
| +++ b/domains_cli_test.go |
| @@ -278,6 +278,14 @@ func newFakeDomainsConsole(t *testing.T) *fakeDomainsConsole { |
| fc := &fakeDomainsConsole{attachCreated: true, detachRemoved: true} |
| fc.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| switch { |
| + case r.Method == http.MethodGet && r.URL.Path == "/api/cli/apps": |
| + // The memberships fetch resolvedAccount makes when -account is |
| + // omitted (project-defaults spec) — before this row existed the |
| + // catch-all GET below answered it with domains JSON, which |
| + // decodes to zero memberships and a fatal. |
| + json.NewEncoder(w).Encode(map[string]any{"accounts": []map[string]any{ |
| + {"sqid": "bab", "name": "ops", "role": "owner", "apps": []string{"tito"}}, |
| + }}) |
| case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/domains"): |
| fc.lastAttachPath = r.URL.Path |
| json.NewDecoder(r.Body).Decode(&fc.lastAttachBody) |