carlos / platform Public

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>
Paul Campbell 21e8ab1640cc6333b08d81a07245b53deab2b25e parent be90582
7 files changed, +320 −20
  • apimode.go +16 −1
  • config.go +6 −0
  • deploy.go +84 −13
  • deploy_wizard_test.go +176 −0
  • docs/superpowers/specs/2026-08-13-project-defaults-design.md +24 −0
  • domains_cli.go +6 −6
  • domains_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)