Merge pull request #185 from carlosframework/worktree-project-defaults
Project defaults: zero-arg carlos deploy, resolved -app, saved state in .carlos
20 files changed,
+1424
−256
account_choose.go+36 −0apimode.go+155 −0apimode_test.go+96 −0appconfig_cli.go+88 −47cli.go+115 −57config.go+72 −28deploy.go+315 −15deploy_wizard_test.go+293 −0docs/superpowers/specs/2026-08-13-project-defaults-design.md+141 −0domains_cli.go+15 −13domains_cli_test.go+15 −3instances_cli.go+21 −15logs.go+3 −5restart.go+9 −8restart_test.go+6 −2retention.go+5 −5retention_cli_test.go+5 −2store.go+24 −20store_test.go+8 −14update.go+2 −22
diff --git a/account_choose.go b/account_choose.go| index 185083a39..e62b3ba46 100644 |
| --- a/account_choose.go |
| +++ b/account_choose.go |
| @@ -22,6 +22,9 @@ type apiAccountEntry struct { |
| Name string `json:"name"` |
| Sqid string `json:"sqid"` |
| Role string `json:"role"` |
| + // Apps rides the same response (handleAPIApps) — resolvedApp reads it, |
| + // so account AND app resolution share one fetch per invocation. |
| + Apps []string `json:"apps"` |
| } |
| // severalAccountsErr refuses an ambiguous omitted -account, naming every |
| @@ -80,6 +83,39 @@ func promptYes(out io.Writer, prompt string) bool { |
| return strings.EqualFold(promptLine(out, prompt), "y") |
| } |
| +// chooseOne asks which of items an omitted flag means: intro, a numbered |
| +// list, `<noun> [1-N]:`. A junk answer re-prompts once; two misses or EOF |
| +// return refuse — the same non-interactive refusal the caller would have |
| +// used without a terminal, so both paths error identically. |
| +func chooseOne(in io.Reader, out io.Writer, intro, noun string, items []string, refuse error) (string, error) { |
| + fmt.Fprintln(out, intro) |
| + for i, it := range items { |
| + fmt.Fprintf(out, " %d. %s\n", i+1, it) |
| + } |
| + sc := bufio.NewScanner(in) |
| + if in == chooserIn { |
| + sc = chooserScanner() |
| + } |
| + for attempt := 0; attempt < 2; attempt++ { |
| + fmt.Fprintf(out, "%s [1-%d]: ", noun, len(items)) |
| + if !sc.Scan() { |
| + return "", refuse |
| + } |
| + if n, err := strconv.Atoi(strings.TrimSpace(sc.Text())); err == nil && n >= 1 && n <= len(items) { |
| + return items[n-1], nil |
| + } |
| + } |
| + return "", refuse |
| +} |
| + |
| +// severalAppsErr refuses an ambiguous omitted -app, naming every app so |
| +// the refusal itself answers "what could I have said" — severalAccountsErr |
| +// for the sibling flag. |
| +func severalAppsErr(account string, apps []string) error { |
| + return fmt.Errorf("account %s has several apps — say which with -app: %s", |
| + account, strings.Join(apps, ", ")) |
| +} |
| + |
| // chooseAccount asks which account an omitted -account means, then — when |
| // saveDest names a file — whether to remember the answer there (the caller |
| // writes the config; this function only asks). An empty saveDest skips the |
diff --git a/apimode.go b/apimode.go| index a5ede5b1b..77a214cac 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -50,6 +50,23 @@ type apiSession struct { |
| // once for the seal key, once for the write; two chooser prompts there |
| // once sealed a value with one account's key and wrote it to another). |
| defAcct *apiAcct |
| + // The memberships response, fetched at most once (listAccounts): |
| + // account resolution reads the accounts, app resolution reads each |
| + // account's apps — same wire bytes, one request. |
| + accts []apiAccountEntry |
| + acctsSupported bool |
| + acctsFetched bool |
| + // 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 |
| @@ -383,11 +400,17 @@ func apiPath(a apiAcct, app, tail string) string { |
| // a real answer (401 expired token, 500) and surfaces via apiErrorFromBody |
| // — never swallowed into a fallback. |
| func (s *apiSession) listAccounts(ctx context.Context) (accts []apiAccountEntry, supported bool, err error) { |
| + // Memoized: account resolution and app resolution both read this, and |
| + // one invocation makes at most one fetch, however many of them run. |
| + if s.acctsFetched { |
| + return s.accts, s.acctsSupported, nil |
| + } |
| status, body, err := s.getJSON(ctx, "/api/cli/apps") |
| if err != nil { |
| return nil, false, err |
| } |
| if status == http.StatusNotFound { |
| + s.accts, s.acctsSupported, s.acctsFetched = nil, false, true |
| return nil, false, nil |
| } |
| if status != http.StatusOK { |
| @@ -399,6 +422,7 @@ func (s *apiSession) listAccounts(ctx context.Context) (accts []apiAccountEntry, |
| if err := json.Unmarshal(body, &out); err != nil { |
| return nil, false, fmt.Errorf("unreadable accounts response from %s: %w", s.console, err) |
| } |
| + s.accts, s.acctsSupported, s.acctsFetched = out.Accounts, true, true |
| return out.Accounts, true, nil |
| } |
| @@ -473,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 |
| @@ -490,6 +520,131 @@ func (s *apiSession) defaultAccountOnce(ctx context.Context) (apiAcct, error) { |
| return apiAcct{Seg: picked.Sqid, Exact: true}, nil |
| } |
| +// noAppsError is defaultApp's "this account owns nothing yet" refusal — |
| +// typed so zero-arg deploy can turn it into the create-an-app offer while |
| +// every other verb just prints it. |
| +type noAppsError struct{ Account string } |
| + |
| +func (e *noAppsError) Error() string { |
| + return fmt.Sprintf("account %s has no apps — create one with: carlos apps create -app <name>", e.Account) |
| +} |
| + |
| +// defaultApp answers an omitted -app (2026-08-13 project-defaults spec): |
| +// the config default when its file's console pairs with this session, |
| +// else the resolved account's one app, else the chooser (terminal) or the |
| +// naming refusal (script). "" with a nil error means this console cannot |
| +// answer (pre-listing console, or an account the memberships response |
| +// does not name) — the caller falls back to its own usage error, exactly |
| +// the pre-spec behavior. offerSave=false lets zero-arg deploy fold the |
| +// answer into its one combined save instead of prompting twice. |
| +func (s *apiSession) defaultApp(ctx context.Context, acct apiAcct, offerSave bool) (string, error) { |
| + if s.defApp != "" { |
| + return s.defApp, nil |
| + } |
| + name, src, fileConsole, err := configApp() |
| + if err != nil { |
| + return "", err |
| + } |
| + if name != "" && fileConsole == s.console { |
| + mutedf(stdout, "app: %s (the default from %s)", name, src) |
| + s.defApp = name |
| + return name, nil |
| + } |
| + accts, supported, err := s.listAccounts(ctx) |
| + if err != nil { |
| + return "", err |
| + } |
| + if !supported { |
| + return "", nil |
| + } |
| + var entry *apiAccountEntry |
| + for i := range accts { |
| + if accts[i].Sqid == acct.Seg || accts[i].Name == acct.Seg { |
| + entry = &accts[i] |
| + break |
| + } |
| + } |
| + if entry == nil { |
| + // An explicit -account this token's memberships don't name: the |
| + // verb's own request will answer that (the membership 404) — app |
| + // resolution has nothing to add. |
| + return "", nil |
| + } |
| + switch len(entry.Apps) { |
| + case 0: |
| + return "", &noAppsError{Account: entry.Name} |
| + case 1: |
| + mutedf(stdout, "app: %s", entry.Apps[0]) |
| + s.defApp = entry.Apps[0] |
| + return entry.Apps[0], nil |
| + } |
| + if !interactiveTerminal() { |
| + return "", severalAppsErr(entry.Name, entry.Apps) |
| + } |
| + picked, err := chooseOne(chooserIn, stdout, |
| + fmt.Sprintf("account %s has several apps:", entry.Name), "app", |
| + entry.Apps, severalAppsErr(entry.Name, entry.Apps)) |
| + if err != nil { |
| + return "", err |
| + } |
| + s.defApp = picked |
| + 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)) |
| + } |
| + return picked, nil |
| +} |
| + |
| +// offerSaveProjectDefaults is the one save prompt every resolution path |
| +// shares: console and account always land with whatever extra keys the |
| +// caller resolved (the file is read as pairs, and a default without its |
| +// console would silently stop applying). Skipped from $HOME (the |
| +// "project" file would be the global config) and never fatal — the |
| +// answers are already in hand, and bookkeeping must not kill the verb. |
| +func offerSaveProjectDefaults(console, account string, extra map[string]any, question string) { |
| + dest := filepath.Join(".carlos", "config") |
| + if abs, err := filepath.Abs(dest); err == nil { |
| + if gp, gerr := globalConfigPath(); gerr == nil && abs == gp { |
| + return |
| + } |
| + } |
| + if !promptYes(stdout, question+" ("+dest+")? [y/N] ") { |
| + return |
| + } |
| + set := map[string]any{"console": console} |
| + if account != "" { |
| + set["account"] = account |
| + } |
| + for k, v := range extra { |
| + set[k] = v |
| + } |
| + if err := writeConfigKeys(dest, set, 0o755); err != nil { |
| + warnf(stdout, "not saved (%s): %v", dest, err) |
| + } else { |
| + mutedf(stdout, "saved: project defaults in %s", dest) |
| + } |
| +} |
| + |
| +// resolvedApp is defaultApp's verb-facing wrapper, resolvedAccount's rule |
| +// for the sibling flag: explicit -app travels verbatim; an empty one |
| +// resolves or dies on the verb's own usage message when this console |
| +// cannot answer. Fatal for the same reason resolvedAccount is — every |
| +// caller's next line is the request itself. |
| +func resolvedApp(sess *apiSession, acct apiAcct, app, usage string) string { |
| + if app != "" { |
| + return app |
| + } |
| + r, err := sess.defaultApp(context.Background(), acct, true) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if r == "" { |
| + log.Fatal(usage) |
| + } |
| + return r |
| +} |
| + |
| // resolvedAccount is apiAccount plus the omitted-flag default: the seam |
| // every API-mode verb reaches through. Explicit flags travel verbatim and |
| // never touch the network; only an empty pair asks defaultAccount. Fatal |
diff --git a/apimode_test.go b/apimode_test.go| index b8b1c85aa..e0bfd941a 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -8,6 +8,7 @@ import ( |
| "bytes" |
| "context" |
| "encoding/json" |
| + "errors" |
| "flag" |
| "io" |
| "net/http" |
| @@ -3486,6 +3487,101 @@ func TestResolveModeUnconfiguredPromptsForURL(t *testing.T) { |
| } |
| } |
| +func TestDefaultAppSingleSharesTheFetch(t *testing.T) { |
| + useTempCredentials(t) |
| + out := captureStdout(t) |
| + s, hits := accountsConsole(t, http.StatusOK, |
| + `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","apps":["taskmonger"]}]}`) |
| + |
| + // Account then app: one invocation, ONE fetch answering both. |
| + acct, err := s.defaultAccount(context.Background()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + app, err := s.defaultApp(context.Background(), acct, false) |
| + if err != nil || app != "taskmonger" { |
| + t.Fatalf("one app = %q, %v", app, err) |
| + } |
| + if *hits != 1 { |
| + t.Fatalf("endpoint hits = %d, want 1 shared fetch", *hits) |
| + } |
| + if !strings.Contains(out.String(), "app: taskmonger") { |
| + t.Fatalf("the resolved app must be printed:\n%s", out.String()) |
| + } |
| +} |
| + |
| +func TestDefaultAppSeveralNonTTY(t *testing.T) { |
| + useTempCredentials(t) |
| + captureStdout(t) |
| + forceTerminal(t, false) |
| + s, _ := accountsConsole(t, http.StatusOK, |
| + `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","apps":["shop","blog"]}]}`) |
| + |
| + _, err := s.defaultApp(context.Background(), apiAcct{Seg: "bdk", Exact: true}, false) |
| + want := "account tom@keymail.dev has several apps — say which with -app: shop, blog" |
| + if err == nil || err.Error() != want { |
| + t.Fatalf("several = %v\nwant %q", err, want) |
| + } |
| +} |
| + |
| +func TestDefaultAppChooserSaves(t *testing.T) { |
| + useTempCredentials(t) |
| + captureStdout(t) |
| + forceTerminal(t, true) |
| + oldIn := chooserIn |
| + chooserIn = strings.NewReader("2\ny\n") |
| + t.Cleanup(func() { chooserIn = oldIn }) |
| + s, _ := accountsConsole(t, http.StatusOK, |
| + `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","apps":["shop","blog"]}]}`) |
| + |
| + app, err := s.defaultApp(context.Background(), apiAcct{Seg: "bdk", Exact: true}, true) |
| + if err != nil || app != "blog" { |
| + t.Fatalf("chooser pick = %q, %v", app, err) |
| + } |
| + cfg, err := loadConfig(filepath.Join(".carlos", "config")) |
| + if err != nil || cfg.App != "blog" || cfg.Console != s.console || cfg.Account != "tom@keymail.dev" { |
| + t.Fatalf("saved config = %+v, %v; want console+account+app together", cfg, err) |
| + } |
| +} |
| + |
| +func TestDefaultAppZeroIsTyped(t *testing.T) { |
| + useTempCredentials(t) |
| + captureStdout(t) |
| + s, _ := accountsConsole(t, http.StatusOK, |
| + `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","apps":[]}]}`) |
| + |
| + _, err := s.defaultApp(context.Background(), apiAcct{Seg: "bdk", Exact: true}, false) |
| + var na *noAppsError |
| + if err == nil || !errors.As(err, &na) || na.Account != "tom@keymail.dev" || |
| + !strings.Contains(err.Error(), "carlos apps create") { |
| + t.Fatalf("zero apps = %v, want the typed no-apps refusal naming the fix", err) |
| + } |
| +} |
| + |
| +func TestDefaultAppConfigPairing(t *testing.T) { |
| + useTempCredentials(t) |
| + captureStdout(t) |
| + s, hits := accountsConsole(t, http.StatusOK, |
| + `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","apps":["shop","blog"]}]}`) |
| + |
| + // Paired with THIS console: honored without any fetch. |
| + writeConfig(t, ".", `{"console": "`+s.console+`", "app": "shop"}`) |
| + app, err := s.defaultApp(context.Background(), apiAcct{Seg: "bdk", Exact: true}, false) |
| + if err != nil || app != "shop" || *hits != 0 { |
| + t.Fatalf("config app = %q, %v, hits=%d", app, err, *hits) |
| + } |
| + |
| + // Paired with ANOTHER console: ignored — falls through to the account's |
| + // apps, which are ambiguous here, proving the fall-through. |
| + writeConfig(t, ".", `{"console": "https://other.example", "app": "shop"}`) |
| + forceTerminal(t, false) |
| + s2 := newTestSession(s.console) |
| + if _, err := s2.defaultApp(context.Background(), apiAcct{Seg: "bdk", Exact: true}, false); err == nil || |
| + !strings.Contains(err.Error(), "several apps") { |
| + t.Fatalf("mismatched config console = %v, want fall-through", err) |
| + } |
| +} |
| + |
| func TestResolvedAccountExplicitFlagsBypass(t *testing.T) { |
| useTempCredentials(t) |
| captureStdout(t) |
diff --git a/appconfig_cli.go b/appconfig_cli.go| index e9b382c98..4dc3facc0 100644 |
| --- a/appconfig_cli.go |
| +++ b/appconfig_cli.go |
| @@ -196,9 +196,7 @@ func runEnvSet(args []string) { |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| - if *app == "" { |
| - log.Fatal("usage: carlos env set -app <a> [-environment <name>] [-console <origin>] KEY=value [KEY2=value2 ...]") |
| - } |
| + usage := "usage: carlos env set -app <a> [-environment <name>] [-console <origin>] KEY=value [KEY2=value2 ...]" |
| checkEnvironmentFlag(*environment) |
| kv, err := parseKV(fs.Args()) |
| if err != nil { |
| @@ -211,18 +209,23 @@ func runEnvSet(args []string) { |
| } |
| if sess != nil { |
| printUsing(os.Stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, usage) |
| set := make(map[string]appconfig.Var, len(kv)) |
| for k, v := range kv { |
| set[k] = appconfig.Var{Kind: "env", Value: v} |
| } |
| - if _, err := sess.configApply(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, |
| + if _, err := sess.configApply(context.Background(), acct, app2, |
| apiConfigApply{Environment: *environment, Set: set}); err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Printf("%s %d env var(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), *app, bundleLabel(*environment)) |
| + fmt.Printf("%s %d env var(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), app2, bundleLabel(*environment)) |
| return |
| } |
| + if *app == "" { |
| + log.Fatal(usage) |
| + } |
| if err := saveEnvVars(context.Background(), openStore(), acctOf(), *app, *environment, kv); err != nil { |
| log.Fatal(err) |
| } |
| @@ -236,8 +239,9 @@ func runEnvUnset(args []string) { |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| - if *app == "" || fs.NArg() == 0 { |
| - log.Fatal("usage: carlos env unset -app <a> [-environment <name>] [-console <origin>] KEY [KEY2 ...]") |
| + usage := "usage: carlos env unset -app <a> [-environment <name>] [-console <origin>] KEY [KEY2 ...]" |
| + if fs.NArg() == 0 { |
| + log.Fatal(usage) |
| } |
| checkEnvironmentFlag(*environment) |
| @@ -247,15 +251,20 @@ func runEnvUnset(args []string) { |
| } |
| if sess != nil { |
| printUsing(os.Stdout, sess) |
| - removed, err := sess.configApply(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, usage) |
| + removed, err := sess.configApply(context.Background(), acct, app2, |
| apiConfigApply{Environment: *environment, Unset: fs.Args()}) |
| if err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Printf("%s %d of %d var(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), removed, fs.NArg(), *app, bundleLabel(*environment)) |
| + fmt.Printf("%s %d of %d var(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), removed, fs.NArg(), app2, bundleLabel(*environment)) |
| return |
| } |
| + if *app == "" { |
| + log.Fatal(usage) |
| + } |
| removed, err := unsetVars(context.Background(), openStore(), acctOf(), *app, *environment, fs.Args()) |
| if err != nil { |
| log.Fatal(err) |
| @@ -270,9 +279,7 @@ func runEnvList(args []string) { |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| - if *app == "" { |
| - log.Fatal("usage: carlos env list -app <a> [-environment <name>] [-console <origin>]") |
| - } |
| + usage := "usage: carlos env list -app <a> [-environment <name>] [-console <origin>]" |
| checkEnvironmentFlag(*environment) |
| sess, err := resolveMode(*consoleFlag) |
| @@ -281,14 +288,19 @@ func runEnvList(args []string) { |
| } |
| if sess != nil { |
| printUsing(os.Stdout, sess) |
| - cfg, err := sess.configGet(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, *environment) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, usage) |
| + cfg, err := sess.configGet(context.Background(), acct, app2, *environment) |
| if err != nil { |
| log.Fatal(err) |
| } |
| - printEnvList(*app, *environment, cfg.ExecDelivery, envVarsFromAPI(cfg.Vars)) |
| + printEnvList(app2, *environment, cfg.ExecDelivery, envVarsFromAPI(cfg.Vars)) |
| return |
| } |
| + if *app == "" { |
| + log.Fatal(usage) |
| + } |
| m, err := loadBundle(context.Background(), openStore(), acctOf(), *app, *environment) |
| if err != nil { |
| log.Fatal(err) |
| @@ -362,8 +374,9 @@ func runEnvExecDelivery(args []string) { |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| - if *app == "" || fs.NArg() != 1 { |
| - log.Fatal("usage: carlos env exec-delivery -app <a> [-console <origin>] file|off") |
| + usage := "usage: carlos env exec-delivery -app <a> [-console <origin>] file|off" |
| + if fs.NArg() != 1 { |
| + log.Fatal(usage) |
| } |
| if *environment != "" { |
| log.Fatal(`carlos env exec-delivery: -environment is refused — exec_delivery is APP-LEVEL ONLY, read from the ` + |
| @@ -387,8 +400,11 @@ func runEnvExecDelivery(args []string) { |
| if err != nil { |
| log.Fatal(err) |
| } |
| + appName := *app |
| if sess != nil { |
| printUsing(os.Stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + appName = resolvedApp(sess, acct, *app, usage) |
| // The wire vocabulary is "file"/"off", never "" — value is "" for |
| // the local SetExecDelivery convention (clearing the field), but |
| // the console's PUT decodes ExecDelivery as a pointer where nil |
| @@ -397,17 +413,22 @@ func runEnvExecDelivery(args []string) { |
| if value != "" { |
| wire = value |
| } |
| - if _, err := sess.configApply(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, |
| + if _, err := sess.configApply(context.Background(), acct, appName, |
| apiConfigApply{ExecDelivery: &wire}); err != nil { |
| log.Fatal(err) |
| } |
| - } else if err := appconfig.SetExecDelivery(context.Background(), openStore(), acctOf(), *app, value); err != nil { |
| - log.Fatal(err) |
| + } else { |
| + if *app == "" { |
| + log.Fatal(usage) |
| + } |
| + if err := appconfig.SetExecDelivery(context.Background(), openStore(), acctOf(), *app, value); err != nil { |
| + log.Fatal(err) |
| + } |
| } |
| if value == "" { |
| - fmt.Printf("%s off for %s\n", colorFor(os.Stdout).ok("exec-delivery"), *app) |
| + fmt.Printf("%s off for %s\n", colorFor(os.Stdout).ok("exec-delivery"), appName) |
| } else { |
| - fmt.Printf("%s %s for %s\n", colorFor(os.Stdout).ok("exec-delivery"), value, *app) |
| + fmt.Printf("%s %s for %s\n", colorFor(os.Stdout).ok("exec-delivery"), value, appName) |
| } |
| } |
| @@ -417,9 +438,7 @@ func runEnvEnvironments(args []string) { |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| - if *app == "" { |
| - log.Fatal("usage: carlos env environments -app <a> [-console <origin>]") |
| - } |
| + usage := "usage: carlos env environments -app <a> [-console <origin>]" |
| sess, err := resolveMode(*consoleFlag) |
| if err != nil { |
| @@ -427,7 +446,9 @@ func runEnvEnvironments(args []string) { |
| } |
| if sess != nil { |
| printUsing(os.Stdout, sess) |
| - cfg, err := sess.configGet(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, "") |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, usage) |
| + cfg, err := sess.configGet(context.Background(), acct, app2, "") |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -437,6 +458,9 @@ func runEnvEnvironments(args []string) { |
| return |
| } |
| + if *app == "" { |
| + log.Fatal(usage) |
| + } |
| names, err := appconfig.ListEnvironments(context.Background(), openStore(), acctOf(), *app) |
| if err != nil { |
| log.Fatal(err) |
| @@ -467,25 +491,31 @@ func runEnvSync(args []string) { |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| - if *app == "" { |
| - log.Fatal("usage: carlos env sync -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]") |
| - } |
| + usage := "usage: carlos env sync -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]" |
| sess, err := resolveMode(*consoleFlag) |
| if err != nil { |
| log.Fatal(err) |
| } |
| + appName := *app |
| if sess != nil { |
| printUsing(os.Stdout, sess) |
| - if err := sess.configSync(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app); err != nil { |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + appName = resolvedApp(sess, acct, *app, usage) |
| + if err := sess.configSync(context.Background(), acct, appName); err != nil { |
| + log.Fatal(err) |
| + } |
| + } else { |
| + if *app == "" { |
| + log.Fatal(usage) |
| + } |
| + if err := appconfig.TouchGeneration(context.Background(), openStore(), acctOf(), *app); err != nil { |
| log.Fatal(err) |
| } |
| - } else if err := appconfig.TouchGeneration(context.Background(), openStore(), acctOf(), *app); err != nil { |
| - log.Fatal(err) |
| } |
| fmt.Printf("%s for %s — exec-backed instances converge within seconds; "+ |
| "unit-backed routes within seconds where the box's carlos-adopt.path trigger is armed, "+ |
| - "else on the next hourly root pass\n", colorFor(os.Stdout).ok("config sync requested"), *app) |
| + "else on the next hourly root pass\n", colorFor(os.Stdout).ok("config sync requested"), appName) |
| } |
| const secretsUsage = `usage: carlos secrets <command> |
| @@ -583,9 +613,7 @@ func runSecretsSet(args []string) { |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| - if *app == "" { |
| - log.Fatal("usage: carlos secrets set -app <a> [-environment <name>] [-console <origin>] KEY=value [KEY2=value2 ...]") |
| - } |
| + usage := "usage: carlos secrets set -app <a> [-environment <name>] [-console <origin>] KEY=value [KEY2=value2 ...]" |
| checkEnvironmentFlag(*environment) |
| kv, err := parseKV(fs.Args()) |
| if err != nil { |
| @@ -614,7 +642,8 @@ func runSecretsSet(args []string) { |
| // writing it into another's config — an undecryptable secret whose |
| // only symptom is a render failure in the hourly adopt log. |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| - pub := secretsPubKeyAPI(sess, acct, *app) |
| + app2 := resolvedApp(sess, acct, *app, usage) |
| + pub := secretsPubKeyAPI(sess, acct, app2) |
| set := make(map[string]appconfig.Var, len(kv)) |
| for k, v := range kv { |
| env, err := appconfig.Seal(pub, []byte(v)) |
| @@ -623,14 +652,17 @@ func runSecretsSet(args []string) { |
| } |
| set[k] = appconfig.Var{Kind: "secret", Envelope: env} |
| } |
| - if _, err := sess.configApply(context.Background(), acct, *app, |
| + if _, err := sess.configApply(context.Background(), acct, app2, |
| apiConfigApply{Environment: *environment, Set: set}); err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Printf("%s %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), *app, bundleLabel(*environment)) |
| + fmt.Printf("%s %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), app2, bundleLabel(*environment)) |
| return |
| } |
| + if *app == "" { |
| + log.Fatal(usage) |
| + } |
| pub := secretsPubKey() |
| sealed := make(map[string]string, len(kv)) |
| for k, v := range kv { |
| @@ -653,8 +685,9 @@ func runSecretsUnset(args []string) { |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| - if *app == "" || fs.NArg() == 0 { |
| - log.Fatal("usage: carlos secrets unset -app <a> [-environment <name>] [-console <origin>] KEY [KEY2 ...]") |
| + usage := "usage: carlos secrets unset -app <a> [-environment <name>] [-console <origin>] KEY [KEY2 ...]" |
| + if fs.NArg() == 0 { |
| + log.Fatal(usage) |
| } |
| checkEnvironmentFlag(*environment) |
| @@ -664,15 +697,20 @@ func runSecretsUnset(args []string) { |
| } |
| if sess != nil { |
| printUsing(os.Stdout, sess) |
| - removed, err := sess.configApply(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, usage) |
| + removed, err := sess.configApply(context.Background(), acct, app2, |
| apiConfigApply{Environment: *environment, Unset: fs.Args()}) |
| if err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Printf("%s %d of %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), removed, fs.NArg(), *app, bundleLabel(*environment)) |
| + fmt.Printf("%s %d of %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), removed, fs.NArg(), app2, bundleLabel(*environment)) |
| return |
| } |
| + if *app == "" { |
| + log.Fatal(usage) |
| + } |
| removed, err := unsetVars(context.Background(), openStore(), acctOf(), *app, *environment, fs.Args()) |
| if err != nil { |
| log.Fatal(err) |
| @@ -699,9 +737,7 @@ func runSecretsList(args []string) { |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| - if *app == "" { |
| - log.Fatal("usage: carlos secrets list -app <a> [-environment <name>] [-console <origin>]") |
| - } |
| + usage := "usage: carlos secrets list -app <a> [-environment <name>] [-console <origin>]" |
| checkEnvironmentFlag(*environment) |
| sess, err := resolveMode(*consoleFlag) |
| @@ -710,7 +746,9 @@ func runSecretsList(args []string) { |
| } |
| if sess != nil { |
| printUsing(os.Stdout, sess) |
| - cfg, err := sess.configGet(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, *environment) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, usage) |
| + cfg, err := sess.configGet(context.Background(), acct, app2, *environment) |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -720,10 +758,13 @@ func runSecretsList(args []string) { |
| keys = append(keys, v.Key) |
| } |
| } |
| - printSecretsList(*app, *environment, keys) |
| + printSecretsList(app2, *environment, keys) |
| return |
| } |
| + if *app == "" { |
| + log.Fatal(usage) |
| + } |
| m, err := loadBundle(context.Background(), openStore(), acctOf(), *app, *environment) |
| if err != nil { |
| log.Fatal(err) |
diff --git a/cli.go b/cli.go| index 8963af6d5..482a64298 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -1710,8 +1710,9 @@ func runShip(args []string) { |
| // the target (release versioning spec, 2026-08-08). Everything else about |
| // this check is unchanged — an app and at least one file are still the |
| // minimum a ship can mean. |
| - if *app == "" || fs.NArg() == 0 { |
| - log.Fatal("usage: carlos ship -app <a> [-version <sha>] [-label <one line>] [-notes <text> | -notes-file <file>] [-kind binary|static] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>] [-capabilities <file.json>] <files-or-site-dir>") |
| + usage := "usage: carlos ship -app <a> [-version <sha>] [-label <one line>] [-notes <text> | -notes-file <file>] [-kind binary|static] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>] [-capabilities <file.json>] <files-or-site-dir>" |
| + if fs.NArg() == 0 { |
| + log.Fatal(usage) |
| } |
| // -notes and -notes-file are two spellings of ONE manifest field, so both |
| @@ -1743,9 +1744,18 @@ func runShip(args []string) { |
| // for anything semantically wrong. Direct mode would find out again in |
| // release.Ship and API mode again in the console, but neither of those is |
| // where a human is standing. |
| - capsSet, err := readCapabilities(*capsFile, *app) |
| - if err != nil { |
| - log.Fatal(err) |
| + // Validated against the app NAME, which may still be empty here when |
| + // -app is left to resolution — so with an omitted -app the read happens |
| + // again after resolution (below), and this pre-split pass only runs |
| + // when the name is already known. Both doors still refuse the same |
| + // bytes with the same message; an omitted -app just moves the moment. |
| + var capsSet *caps.Set |
| + if *app != "" { |
| + var err error |
| + capsSet, err = readCapabilities(*capsFile, *app) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| } |
| sess, err := resolveMode(*consoleFlag) |
| @@ -1765,12 +1775,24 @@ func runShip(args []string) { |
| // runShipAPI's body has registered a defer at all. |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| - if err := runShipAPI(sess, acct, fs, *app, *ver, *kind, *target, *label, noteText, capsSet, false); err != nil { |
| + app2 := resolvedApp(sess, acct, *app, usage) |
| + if *app == "" { |
| + // -app came from resolution: the capability block is validated |
| + // now, against the real name. |
| + capsSet, err = readCapabilities(*capsFile, app2) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + } |
| + if err := runShipAPI(sess, acct, fs, app2, *ver, *kind, *target, *label, noteText, capsSet, false); err != nil { |
| log.Fatal(err) |
| } |
| return |
| } |
| + if *app == "" { |
| + log.Fatal(usage) |
| + } |
| files := map[string][]byte{} |
| arts := map[string]release.Artifact{} |
| addFile := func(name string, data []byte) { |
| @@ -2010,8 +2032,9 @@ func runPromote(args []string) { |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to promote through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| - if *app == "" || fs.NArg() != 2 { |
| - log.Fatal("usage: carlos promote -app <a> [-hotfix] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>] <version> <channel>") |
| + usage := "usage: carlos promote -app <a> [-hotfix] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>] <version> <channel>" |
| + if fs.NArg() != 2 { |
| + log.Fatal(usage) |
| } |
| version, channel := fs.Arg(0), fs.Arg(1) |
| @@ -2031,18 +2054,22 @@ func runPromote(args []string) { |
| } |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| - res, err := sess.promote(context.Background(), acct, *app, version, channel) |
| + app2 := resolvedApp(sess, acct, *app, usage) |
| + res, err := sess.promote(context.Background(), acct, app2, version, channel) |
| if err != nil { |
| log.Fatal(err) |
| } |
| if res.NoOp { |
| - mutedf(stdout, "%s %s is already on %s — nothing to do\n", *app, version, channel) |
| + mutedf(stdout, "%s %s is already on %s — nothing to do\n", app2, version, channel) |
| } else { |
| - fmt.Fprintf(stdout, "%s %s %s -> %s\n", colorFor(stdout).ok("promoted"), *app, version, channel) |
| + fmt.Fprintf(stdout, "%s %s %s -> %s\n", colorFor(stdout).ok("promoted"), app2, version, channel) |
| } |
| return |
| } |
| + if *app == "" { |
| + log.Fatal(usage) |
| + } |
| res, err := releaseSigner().PromoteOnLadder(context.Background(), openStore(), |
| acctOf(), *app, channel, version, |
| release.PromoteOptions{Hotfix: *hotfix, Actor: promoteActor()}) |
| @@ -2096,9 +2123,7 @@ func runChannels(args []string) { |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to read through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| - if *app == "" { |
| - log.Fatal("usage: carlos channels -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]") |
| - } |
| + usage := "usage: carlos channels -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]" |
| sess, err := resolveMode(*consoleFlag) |
| if err != nil { |
| @@ -2107,7 +2132,8 @@ func runChannels(args []string) { |
| if sess != nil { |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| - chans, err := sess.channels(context.Background(), acct, *app) |
| + app2 := resolvedApp(sess, acct, *app, usage) |
| + chans, err := sess.channels(context.Background(), acct, app2) |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -2117,7 +2143,7 @@ func runChannels(args []string) { |
| // fails degrades to a tag-less table — the question this verb answers |
| // is "what is each channel serving", and no tag has ever been part of |
| // that answer. |
| - tags, err := sess.tags(context.Background(), acct, *app) |
| + tags, err := sess.tags(context.Background(), acct, app2) |
| if err != nil { |
| mutedf(stdout, "(tags unreadable: %v)\n", err) |
| } |
| @@ -2138,6 +2164,9 @@ func runChannels(args []string) { |
| return |
| } |
| + if *app == "" { |
| + log.Fatal(usage) |
| + } |
| acct := acctOf() |
| s := openStore() |
| prefix := release.ChannelsPrefix(acct, *app) |
| @@ -2185,16 +2214,9 @@ func runVersionTarget(args []string) { |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to read or set through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| - if *app == "" || fs.NArg() > 1 { |
| - log.Fatal("usage: carlos version target -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>] [<semver>]") |
| - } |
| - |
| - // unset is what both modes print when no target has ever been set, and it |
| - // names the way to set one: an operator meeting this line is one command |
| - // away from the feature, and the command is not guessable from "none". |
| - unset := func() { |
| - mutedf(stdout, "no version target set\n") |
| - mutedf(stdout, " set one with: carlos version target -app %s 0.1.0\n", *app) |
| + usage := "usage: carlos version target -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>] [<semver>]" |
| + if fs.NArg() > 1 { |
| + log.Fatal(usage) |
| } |
| sess, err := resolveMode(*consoleFlag) |
| @@ -2204,8 +2226,17 @@ func runVersionTarget(args []string) { |
| if sess != nil { |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, usage) |
| + // unset is what both modes print when no target has ever been set, and |
| + // it names the way to set one: an operator meeting this line is one |
| + // command away from the feature, and the command is not guessable |
| + // from "none". |
| + unset := func() { |
| + mutedf(stdout, "no version target set\n") |
| + mutedf(stdout, " set one with: carlos version target -app %s 0.1.0\n", app2) |
| + } |
| if fs.NArg() == 0 { |
| - vt, err := sess.versionTarget(context.Background(), acct, *app) |
| + vt, err := sess.versionTarget(context.Background(), acct, app2) |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -2219,7 +2250,7 @@ func runVersionTarget(args []string) { |
| } |
| return |
| } |
| - stored, err := sess.setVersionTarget(context.Background(), acct, *app, fs.Arg(0)) |
| + stored, err := sess.setVersionTarget(context.Background(), acct, app2, fs.Arg(0)) |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -2227,6 +2258,13 @@ func runVersionTarget(args []string) { |
| return |
| } |
| + if *app == "" { |
| + log.Fatal(usage) |
| + } |
| + unset := func() { |
| + mutedf(stdout, "no version target set\n") |
| + mutedf(stdout, " set one with: carlos version target -app %s 0.1.0\n", *app) |
| + } |
| acct := acctOf() |
| s := openStore() |
| if fs.NArg() == 0 { |
| @@ -2337,19 +2375,19 @@ func runReleases(args []string) { |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to read through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| - if *app == "" { |
| - log.Fatal("usage: carlos releases -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]") |
| - } |
| + usage := "usage: carlos releases -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]" |
| sess, err := resolveMode(*consoleFlag) |
| if err != nil { |
| log.Fatal(err) |
| } |
| var rows []releaseRow |
| + appName := *app |
| if sess != nil { |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| - rels, err := sess.releases(context.Background(), acct, *app) |
| + appName = resolvedApp(sess, acct, *app, usage) |
| + rels, err := sess.releases(context.Background(), acct, appName) |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -2357,14 +2395,17 @@ func runReleases(args []string) { |
| rows = append(rows, releaseRow{Version: r.Version, Label: r.Label, Kind: r.Kind, BuiltAt: r.BuiltAt, Tag: r.Tag}) |
| } |
| } else { |
| - rows, err = localReleases(context.Background(), openStore(), acctOf(), *app) |
| + if appName == "" { |
| + log.Fatal(usage) |
| + } |
| + rows, err = localReleases(context.Background(), openStore(), acctOf(), appName) |
| if err != nil { |
| log.Fatal(err) |
| } |
| } |
| if len(rows) == 0 { |
| - mutedf(stdout, "no releases yet — carlos ship -app %s <files>\n", *app) |
| + mutedf(stdout, "no releases yet — carlos ship -app %s <files>\n", appName) |
| return |
| } |
| w := newTable(stdout) |
| @@ -2385,8 +2426,9 @@ func runRollback(args []string) { |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to roll back through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| - if *app == "" || fs.NArg() != 1 { |
| - log.Fatal("usage: carlos rollback -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>] <channel>") |
| + usage := "usage: carlos rollback -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>] <channel>" |
| + if fs.NArg() != 1 { |
| + log.Fatal(usage) |
| } |
| sess, err := resolveMode(*consoleFlag) |
| @@ -2396,13 +2438,17 @@ func runRollback(args []string) { |
| if sess != nil { |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| - if err := sess.rollback(context.Background(), acct, *app, fs.Arg(0)); err != nil { |
| + app2 := resolvedApp(sess, acct, *app, usage) |
| + if err := sess.rollback(context.Background(), acct, app2, fs.Arg(0)); err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Fprintf(stdout, "%s %s %s\n", colorFor(stdout).ok("rolled back"), *app, fs.Arg(0)) |
| + fmt.Fprintf(stdout, "%s %s %s\n", colorFor(stdout).ok("rolled back"), app2, fs.Arg(0)) |
| return |
| } |
| + if *app == "" { |
| + log.Fatal(usage) |
| + } |
| if err := releaseSigner().Rollback(context.Background(), openStore(), acctOf(), *app, fs.Arg(0)); err != nil { |
| log.Fatal(err) |
| } |
| @@ -2525,8 +2571,9 @@ func runLedgerAppend(args []string) { |
| acctRaw, acctIDRaw := ledgerAccountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to append through (default: the only logged-in one)") |
| fs.Parse(args) |
| - if *app == "" || fs.NArg() != 2 { |
| - log.Fatal("usage: carlos ledger append -app <a> [-account <acct>] [-account-id <sqid>] <ledger> <entry.json|->") |
| + usage := "usage: carlos ledger append -app <a> [-account <acct>] [-account-id <sqid>] <ledger> <entry.json|->" |
| + if fs.NArg() != 2 { |
| + log.Fatal(usage) |
| } |
| name, src := fs.Arg(0), fs.Arg(1) |
| if !ledger.ValidName(name) { |
| @@ -2552,7 +2599,9 @@ func runLedgerAppend(args []string) { |
| sess := ledgerAPISession(*consoleFlag) |
| printUsing(stdout, sess) |
| - a, err := sess.ledgerAppend(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, name, json.RawMessage(raw)) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, usage) |
| + a, err := sess.ledgerAppend(context.Background(), acct, app2, name, json.RawMessage(raw)) |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -2569,8 +2618,9 @@ func runLedgerHead(args []string) { |
| acctRaw, acctIDRaw := ledgerAccountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to read through (default: the only logged-in one)") |
| fs.Parse(args) |
| - if *app == "" || fs.NArg() != 1 { |
| - log.Fatal("usage: carlos ledger head -app <a> [-account <acct>] [-account-id <sqid>] <ledger>") |
| + usage := "usage: carlos ledger head -app <a> [-account <acct>] [-account-id <sqid>] <ledger>" |
| + if fs.NArg() != 1 { |
| + log.Fatal(usage) |
| } |
| name := fs.Arg(0) |
| if !ledger.ValidName(name) { |
| @@ -2578,7 +2628,9 @@ func runLedgerHead(args []string) { |
| } |
| sess := ledgerAPISession(*consoleFlag) |
| - h, err := sess.ledgerHead(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, name) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, usage) |
| + h, err := sess.ledgerHead(context.Background(), acct, app2, name) |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -2595,13 +2647,13 @@ func runLedgerList(args []string) { |
| acctRaw, acctIDRaw := ledgerAccountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to read through (default: the only logged-in one)") |
| fs.Parse(args) |
| - if *app == "" { |
| - log.Fatal("usage: carlos ledger list -app <a> [-account <acct>] [-account-id <sqid>]") |
| - } |
| + usage := "usage: carlos ledger list -app <a> [-account <acct>] [-account-id <sqid>]" |
| sess := ledgerAPISession(*consoleFlag) |
| printUsing(stdout, sess) |
| - body, err := sess.ledgerList(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, usage) |
| + body, err := sess.ledgerList(context.Background(), acct, app2) |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -2619,7 +2671,7 @@ func runLedgerList(args []string) { |
| case res.Manifest.Public: |
| title := res.Manifest.Title |
| if title == "" { |
| - title = *app |
| + title = app2 |
| } |
| fmt.Fprintf(stdout, "published %q: %s\n", title, strings.Join(res.Manifest.Ledgers, ", ")) |
| default: |
| @@ -2645,8 +2697,9 @@ func runLedgerPublish(args []string) { |
| title := fs.String("title", "", "display title for the published set") |
| consoleFlag := fs.String("console", "", "console origin to publish through (default: the only logged-in one)") |
| fs.Parse(args) |
| - if *app == "" || fs.NArg() == 0 { |
| - log.Fatal("usage: carlos ledger publish -app <a> [-account <acct>] [-account-id <sqid>] [-public=true|false] [-title <t>] <ledger>...") |
| + usage := "usage: carlos ledger publish -app <a> [-account <acct>] [-account-id <sqid>] [-public=true|false] [-title <t>] <ledger>..." |
| + if fs.NArg() == 0 { |
| + log.Fatal(usage) |
| } |
| names := fs.Args() |
| for _, n := range names { |
| @@ -2657,8 +2710,10 @@ func runLedgerPublish(args []string) { |
| sess := ledgerAPISession(*consoleFlag) |
| printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, usage) |
| m := ledger.Manifest{Public: *public, Ledgers: names, Title: *title} |
| - got, err := sess.ledgerPublish(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, m) |
| + got, err := sess.ledgerPublish(context.Background(), acct, app2, m) |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -2673,10 +2728,10 @@ func runLedgerPublish(args []string) { |
| verb = "unpublished" |
| } |
| if len(got.Ledgers) == 0 { |
| - fmt.Fprintf(stdout, "%s %s: no public ledgers\n", verb, *app) |
| + fmt.Fprintf(stdout, "%s %s: no public ledgers\n", verb, app2) |
| return |
| } |
| - fmt.Fprintf(stdout, "%s %s: public ledgers now [%s]\n", verb, *app, strings.Join(got.Ledgers, " ")) |
| + fmt.Fprintf(stdout, "%s %s: public ledgers now [%s]\n", verb, app2, strings.Join(got.Ledgers, " ")) |
| } |
| func runLedgerBlob(args []string) { |
| @@ -2685,8 +2740,9 @@ func runLedgerBlob(args []string) { |
| acctRaw, acctIDRaw := ledgerAccountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to upload through (default: the only logged-in one)") |
| fs.Parse(args) |
| - if *app == "" || fs.NArg() < 2 { |
| - log.Fatal("usage: carlos ledger blob -app <a> [-account <acct>] [-account-id <sqid>] <ledger> <file>...") |
| + usage := "usage: carlos ledger blob -app <a> [-account <acct>] [-account-id <sqid>] <ledger> <file>..." |
| + if fs.NArg() < 2 { |
| + log.Fatal(usage) |
| } |
| name := fs.Arg(0) |
| if !ledger.ValidName(name) { |
| @@ -2696,6 +2752,8 @@ func runLedgerBlob(args []string) { |
| sess := ledgerAPISession(*consoleFlag) |
| printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, usage) |
| for _, path := range files { |
| data, err := os.ReadFile(path) |
| if err != nil { |
| @@ -2704,7 +2762,7 @@ func runLedgerBlob(args []string) { |
| if len(data) > ledger.MaxBlob { |
| log.Fatalf("ledger blob: %s is %d bytes, exceeds the %d byte cap", path, len(data), ledger.MaxBlob) |
| } |
| - sha, err := sess.ledgerBlob(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, name, data) |
| + sha, err := sess.ledgerBlob(context.Background(), acct, app2, name, data) |
| if err != nil { |
| log.Fatal(err) |
| } |
diff --git a/config.go b/config.go| index 4ea31d193..08986df5f 100644 |
| --- a/config.go |
| +++ b/config.go |
| @@ -42,11 +42,74 @@ const defaultConsole = "https://platform.carlosframework.com" |
| type cliConfig struct { |
| Console string `json:"console"` |
| Account string `json:"account"` |
| + // The project-defaults block (2026-08-13 spec): what this checkout |
| + // deploys as. App is honored by every app-scoped verb; Kind/Artifact/ |
| + // Target answer zero-arg `carlos deploy`. All console-paired like |
| + // Account: a file's defaults mean THIS stack. |
| + App string `json:"app"` |
| + Kind string `json:"kind"` |
| + Artifact string `json:"artifact"` |
| + Target string `json:"target"` |
| // UpdateCheck: "off" disables the once-a-day newer-release hint |
| // (update.go). Any other value — including absent — leaves it on. |
| UpdateCheck string `json:"update_check"` |
| } |
| +// walkConfig finds the nearest .carlos/config (cwd upward, then global) |
| +// that pick() accepts, returning it with its path. The zero config and "" |
| +// mean "no file said anything". Every per-key default walk (account, app, |
| +// update_check) is this walk with a different pick. |
| +func walkConfig(pick func(cliConfig) bool) (cliConfig, string, error) { |
| + if dir, werr := os.Getwd(); werr == nil { |
| + for { |
| + path := filepath.Join(dir, ".carlos", "config") |
| + cfg, err := loadConfig(path) |
| + if err != nil { |
| + return cliConfig{}, "", err |
| + } |
| + if pick(cfg) { |
| + return cfg, path, nil |
| + } |
| + parent := filepath.Dir(dir) |
| + if parent == dir { |
| + break |
| + } |
| + dir = parent |
| + } |
| + } |
| + path, perr := globalConfigPath() |
| + if perr != nil { |
| + return cliConfig{}, "", nil |
| + } |
| + cfg, err := loadConfig(path) |
| + if err != nil { |
| + return cliConfig{}, "", err |
| + } |
| + if !pick(cfg) { |
| + return cliConfig{}, "", nil |
| + } |
| + return cfg, path, nil |
| +} |
| + |
| +// configApp resolves the configured default APP: the nearest file whose |
| +// app key is set, with that same file's console for the pairing rule — |
| +// exactly configAccount's contract, for the sibling key. |
| +func configApp() (app, source, fileConsole string, err error) { |
| + cfg, src, err := walkConfig(func(c cliConfig) bool { return c.App != "" }) |
| + if err != nil || src == "" { |
| + return "", "", "", err |
| + } |
| + return cfg.App, src, cfg.Console, nil |
| +} |
| + |
| +// configDeploy resolves the project's deploy block: the nearest file that |
| +// names an artifact, whole — kind/artifact/target travel together, and |
| +// mixing halves of two files would ship one project's bytes with |
| +// another's shape. |
| +func configDeploy() (cfg cliConfig, source string, err error) { |
| + return walkConfig(func(c cliConfig) bool { return c.Artifact != "" }) |
| +} |
| + |
| // loadConfig reads one .carlos/config whole. A missing file is a zero |
| // config, not an error. A file that cannot be parsed, or names a console |
| // normalizeConsole refuses, is an ERROR — silently ignoring a typo'd |
| @@ -75,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 |
| } |
| @@ -139,36 +208,11 @@ func configDefault() (console, source string, err error) { |
| // stack, and -console to a different stack must not drag the other stack's |
| // account along. |
| func configAccount() (account, source, fileConsole string, err error) { |
| - if dir, werr := os.Getwd(); werr == nil { |
| - for { |
| - path := filepath.Join(dir, ".carlos", "config") |
| - cfg, err := loadConfig(path) |
| - if err != nil { |
| - return "", "", "", err |
| - } |
| - if cfg.Account != "" { |
| - return cfg.Account, path, cfg.Console, nil |
| - } |
| - parent := filepath.Dir(dir) |
| - if parent == dir { |
| - break |
| - } |
| - dir = parent |
| - } |
| - } |
| - path, perr := globalConfigPath() |
| - if perr != nil { |
| - // No home directory means no global config — a state, not an error. |
| - return "", "", "", nil |
| - } |
| - cfg, err := loadConfig(path) |
| - if err != nil { |
| + cfg, src, err := walkConfig(func(c cliConfig) bool { return c.Account != "" }) |
| + if err != nil || src == "" { |
| return "", "", "", err |
| } |
| - if cfg.Account == "" { |
| - return "", "", "", nil |
| - } |
| - return cfg.Account, path, cfg.Console, nil |
| + return cfg.Account, src, cfg.Console, nil |
| } |
| // globalConfigPath is ~/.carlos/config — the same directory that holds |
diff --git a/deploy.go b/deploy.go| index aea3d8402..19a872778 100644 |
| --- a/deploy.go |
| +++ b/deploy.go |
| @@ -15,12 +15,16 @@ import ( |
| "log" |
| "net/http" |
| "net/url" |
| + "os" |
| "os/exec" |
| + "path/filepath" |
| "sort" |
| "strings" |
| "sync" |
| "sync/atomic" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/caps" |
| ) |
| // runDeploy is `carlos deploy`. API-mode only (spec §6): direct-bucket |
| @@ -41,33 +45,149 @@ func runDeploy(args []string) { |
| hostFlag := fs.String("host", "", "scope channel resolution and the watch to one instance host (required for an app with no instances, e.g. a static site)") |
| timeout := fs.Duration("timeout", defaultDeployTimeout, "how long to watch the URL before failing") |
| fs.Parse(args) |
| - if *app == "" || fs.NArg() == 0 { |
| - log.Fatal("usage: carlos deploy -app <a> [-version <sha>] [-kind binary|static] [-target linux-arm64] " + |
| - "[-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>] [-capabilities <file.json>] " + |
| - "[-channel <ch>] [-host <host>] [-timeout 120s] <files-or-site-dir>") |
| - } |
| + usage := "usage: carlos deploy -app <a> [-version <sha>] [-kind binary|static] [-target linux-arm64] " + |
| + "[-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>] [-capabilities <file.json>] " + |
| + "[-channel <ch>] [-host <host>] [-timeout 120s] <files-or-site-dir>\n" + |
| + "or, in a project with saved defaults (or at a terminal, to create them): carlos deploy" |
| if *ver == "" { |
| *ver = gitShortHead() |
| } |
| - |
| - // Read and decode the capability declaration before ever dialing the |
| - // console, exactly like runShip does — a bad file is refused at the |
| - // operator's terminal, not after the ship half of this verb already ran. |
| - capsSet, err := readCapabilities(*capsFile, *app) |
| - if err != nil { |
| - log.Fatal(err) |
| + // A bad capability file is still refused before anything ships — but the |
| + // app name may be left to resolution now, so with an omitted -app the |
| + // read happens again once the name is known (ship's rule). |
| + var capsSet *caps.Set |
| + var err error |
| + if *app != "" { |
| + if capsSet, err = readCapabilities(*capsFile, *app); err != nil { |
| + log.Fatal(err) |
| + } |
| } |
| 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() |
| + |
| + // What was resolved interactively THIS run — the one combined save |
| + // offer at the end covers exactly these, never values that came from |
| + // flags or from config (project-defaults spec, 2026-08-13). |
| + newly := map[string]any{} |
| + |
| + if *app == "" { |
| + r, aerr := sess.defaultApp(ctx, acct, false) |
| + var na *noAppsError |
| + switch { |
| + case aerr == nil && r != "": |
| + *app = r |
| + 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(): |
| + // The account owns nothing yet: zero-arg deploy is allowed to |
| + // finish the onboarding — offer the claim, named after this |
| + // directory, through the same door as `carlos apps create` |
| + // (rate limiter and server validation unchanged). |
| + *app = offerCreateApp(ctx, sess, acct, na) |
| + newly["app"] = *app |
| + case aerr != nil: |
| + log.Fatal(aerr) |
| + default: |
| + log.Fatal(usage) |
| + } |
| + } |
| + if capsSet == nil { |
| + if capsSet, err = readCapabilities(*capsFile, *app); err != nil { |
| + log.Fatal(err) |
| + } |
| + } |
| + |
| + if fs.NArg() == 0 { |
| + // Zero-arg: the saved deploy block answers, else the first-run |
| + // wizard names the artifact once — never a silent guess. |
| + artifact := "" |
| + dcfg, dsrc, derr := configDeploy() |
| + if derr != nil { |
| + log.Fatal(derr) |
| + } |
| + if dsrc != "" && dcfg.Console == sess.console { |
| + // 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 |
| + } |
| + if !flagWasSet(fs, "target") && dcfg.Target != "" { |
| + *target = dcfg.Target |
| + } |
| + mutedf(stdout, "deploying %s (%s — the defaults from %s)", artifact, *kind, dsrc) |
| + } else if interactiveTerminal() { |
| + var k, tgt string |
| + artifact, k, tgt, err = deployArtifactWizard() |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if !flagWasSet(fs, "kind") { |
| + *kind = k |
| + } |
| + if !flagWasSet(fs, "target") && tgt != "" { |
| + *target = tgt |
| + } |
| + newly["artifact"] = artifact |
| + newly["kind"] = *kind |
| + if *kind == "binary" { |
| + newly["target"] = *target |
| + } |
| + } else { |
| + 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") |
| + } |
| + 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) |
| + } |
| + // 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() { |
| + offerSaveProjectDefaults(sess.console, sess.accountName(acct), newly, |
| + "save these as this project's deploy defaults") |
| + } |
| + |
| recs, _, _, err := sess.instanceList(ctx, acct, *app, "", "") |
| if err != nil { |
| log.Fatalf("deploy: list instances: %v", err) |
| } |
| channel, hosts, staticDeploy, err := deployTargets(recs, *kind, *channelFlag, *hostFlag, |
| func() ([]apiChannel, error) { return sess.channels(ctx, acct, *app) }) |
| + 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. 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) }) |
| + } |
| + } |
| if err != nil { |
| log.Fatalf("deploy: %v", err) |
| } |
| @@ -169,6 +289,179 @@ 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 { |
| + set := false |
| + fs.Visit(func(f *flag.Flag) { |
| + if f.Name == name { |
| + set = true |
| + } |
| + }) |
| + return set |
| +} |
| + |
| +// deployArtifactWizard is first-run zero-arg deploy's one real question: |
| +// WHAT ships. A conventional site directory (public/, dist/, _site/) only |
| +// ever pre-fills the prompt — the human confirms; detection never decides. |
| +// A directory answers static; a regular file answers binary, with the |
| +// target prompted (linux-arm64 pre-filled, the fleet's shape). |
| +func deployArtifactWizard() (artifact, kind, target string, err error) { |
| + suggestion := "" |
| + for _, d := range []string{"public", "dist", "_site"} { |
| + if fi, err := os.Stat(d); err == nil && fi.IsDir() { |
| + if suggestion != "" { |
| + suggestion = "" // two candidates: suggest neither |
| + break |
| + } |
| + suggestion = "./" + d |
| + } |
| + } |
| + prompt := "artifact to deploy (binary or site directory): " |
| + if suggestion != "" { |
| + prompt = fmt.Sprintf("artifact to deploy [%s]: ", suggestion) |
| + } |
| + for attempt := 0; attempt < 2; attempt++ { |
| + ans := promptLine(stdout, prompt) |
| + if ans == "" { |
| + ans = suggestion |
| + } |
| + if ans == "" { |
| + // An accidental Enter with nothing pre-filled is a miss, not |
| + // the end — the retry below asks once more. |
| + continue |
| + } |
| + fi, serr := os.Stat(ans) |
| + if serr != nil { |
| + fmt.Fprintf(stdout, "%s does not exist\n", ans) |
| + continue |
| + } |
| + if fi.IsDir() { |
| + return ans, "static", "", nil |
| + } |
| + target = promptLine(stdout, "built for (GOOS-GOARCH) [linux-arm64]: ") |
| + if target == "" { |
| + target = "linux-arm64" |
| + } |
| + return ans, "binary", target, nil |
| + } |
| + return "", "", "", errors.New("deploy: nothing to ship — build the project first, then name its binary or site directory") |
| +} |
| + |
| +// offerCreateApp turns defaultApp's no-apps refusal into zero-arg deploy's |
| +// onboarding step: claim a name — this directory's, when it is a valid app |
| +// name — through the createApp door, or die with the refusal the other |
| +// verbs print. Declining is the refusal too: deploy cannot proceed without |
| +// an app, and inventing one silently is not on the table. |
| +func offerCreateApp(ctx context.Context, sess *apiSession, acct apiAcct, na *noAppsError) string { |
| + name := "" |
| + if cwd, err := os.Getwd(); err == nil { |
| + base := filepath.Base(cwd) |
| + if validAppName(base) == nil { |
| + name = base |
| + } |
| + } |
| + fmt.Fprintf(stdout, "account %s has no apps yet.\n", na.Account) |
| + if name != "" { |
| + if !promptYes(stdout, fmt.Sprintf("create %q (this directory's name)? [y/N] ", name)) { |
| + log.Fatal(na) |
| + } |
| + } else { |
| + name = promptLine(stdout, "app name to create (lowercase letters, digits, dashes): ") |
| + 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 { |
| + log.Fatalf("deploy: create app: %v", err) |
| + } |
| + fmt.Fprintf(stdout, "%s %s in account %s\n", colorFor(stdout).ok("created app"), res.App, res.Account) |
| + return res.App |
| +} |
| + |
| +// chooseDeployChannel is the interactive form of "say which with -channel": |
| +// the app's verified channels, numbered. ok=false (no terminal answer, or |
| +// fewer than two clean channels to choose between) sends the caller back |
| +// to the original refusal — the chooser must never invent an option the |
| +// flag could not have named. |
| +func chooseDeployChannel(ctx context.Context, sess *apiSession, acct apiAcct, app string) (string, bool) { |
| + chans, err := sess.channels(ctx, acct, app) |
| + if err != nil { |
| + return "", false |
| + } |
| + names := make([]string, 0, len(chans)) |
| + for _, ch := range chans { |
| + if ch.Err == "" { |
| + names = append(names, ch.Channel) |
| + } |
| + } |
| + if len(names) < 2 { |
| + return "", false |
| + } |
| + picked, err := chooseOne(chooserIn, stdout, "this app releases onto several channels:", "channel", |
| + names, errors.New("no channel chosen")) |
| + if err != nil { |
| + return "", false |
| + } |
| + return picked, true |
| +} |
| + |
| +// accountName answers what to CALL acct in a saved config: the memoized |
| +// memberships entry's name when the fetch has happened, else the segment |
| +// as given — a name resolves on any future run, and a sqid still works. |
| +func (s *apiSession) accountName(acct apiAcct) string { |
| + for i := range s.accts { |
| + if s.accts[i].Sqid == acct.Seg || s.accts[i].Name == acct.Seg { |
| + return s.accts[i].Name |
| + } |
| + } |
| + return acct.Seg |
| +} |
| + |
| // deployTargets decides what this deploy promotes to and what it then watches, |
| // and reports whether it took the static path. It is a function, not a stretch |
| // of runDeploy, so the decision can be tested without a console: `channels` is |
| @@ -346,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 |
| @@ -653,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| new file mode 100644 |
| index 000000000..9fe54ed9d |
| --- /dev/null |
| +++ b/deploy_wizard_test.go |
| @@ -0,0 +1,293 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "flag" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestFlagWasSet(t *testing.T) { |
| + fs := flag.NewFlagSet("t", flag.ContinueOnError) |
| + fs.String("kind", "binary", "") |
| + fs.String("target", "linux-arm64", "") |
| + if err := fs.Parse([]string{"-kind", "static"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !flagWasSet(fs, "kind") || flagWasSet(fs, "target") { |
| + t.Fatal("parsed -kind must report set; untyped -target must not") |
| + } |
| +} |
| + |
| +func TestDeployArtifactWizard(t *testing.T) { |
| + useTempCredentials(t) |
| + captureStdout(t) |
| + |
| + // A lone conventional dir pre-fills; an empty answer takes it. |
| + if err := os.MkdirAll("public", 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + swapChooserIn(t, "\n") |
| + artifact, kind, _, err := deployArtifactWizard() |
| + if err != nil || artifact != "./public" || kind != "static" { |
| + t.Fatalf("suggested dir = %q %q %v", artifact, kind, err) |
| + } |
| + |
| + // Two conventional dirs: no suggestion, the empty answer is a miss and |
| + // a real answer on the retry lands. |
| + if err := os.MkdirAll("dist", 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + swapChooserIn(t, "\npublic\n") |
| + if artifact, kind, _, err = deployArtifactWizard(); err != nil || artifact != "public" || kind != "static" { |
| + t.Fatalf("ambiguous dirs = %q %q %v", artifact, kind, err) |
| + } |
| + |
| + // A regular file is a binary, target pre-filled. |
| + if err := os.WriteFile("server", []byte("bin"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + swapChooserIn(t, "server\n\n") |
| + artifact, kind, target, err := deployArtifactWizard() |
| + if err != nil || artifact != "server" || kind != "binary" || target != "linux-arm64" { |
| + t.Fatalf("binary = %q %q %q %v", artifact, kind, target, err) |
| + } |
| + |
| + // A missing path re-prompts once, then the wizard gives up with the |
| + // build-first message. |
| + swapChooserIn(t, "nope\nalso-nope\n") |
| + if _, _, _, err = deployArtifactWizard(); err == nil || |
| + !strings.Contains(err.Error(), "nothing to ship") { |
| + t.Fatalf("two misses = %v", err) |
| + } |
| +} |
| + |
| +// swapChooserIn points the shared prompt reader at a script for one test. |
| +func swapChooserIn(t *testing.T, script string) { |
| + t.Helper() |
| + old := chooserIn |
| + chooserIn = strings.NewReader(script) |
| + t.Cleanup(func() { chooserIn = old }) |
| +} |
| + |
| +func TestOfferCreateAppUsesDirectoryName(t *testing.T) { |
| + useTempCredentials(t) |
| + captureStdout(t) |
| + forceTerminal(t, true) |
| + var gotName string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + var in struct { |
| + Name string `json:"name"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&in) |
| + gotName = in.Name |
| + json.NewEncoder(w).Encode(map[string]any{"app": in.Name, "account": "bdk", |
| + "url": "https://x/a/bdk/" + in.Name}) |
| + })) |
| + t.Cleanup(ts.Close) |
| + |
| + // cwd's base name is the suggestion — make it a valid app name. |
| + dir := filepath.Join(t.TempDir(), "taskmonger") |
| + if err := os.MkdirAll(dir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Chdir(dir) |
| + swapChooserIn(t, "y\n") |
| + s := newTestSession(ts.URL) |
| + app := offerCreateApp(context.Background(), s, apiAcct{Seg: "bdk", Exact: true}, |
| + &noAppsError{Account: "tom@keymail.dev"}) |
| + if app != "taskmonger" || gotName != "taskmonger" { |
| + t.Fatalf("created %q (server saw %q), want the directory's name", app, gotName) |
| + } |
| +} |
| + |
| +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" { |
| + t.Fatalf("by sqid = %q", got) |
| + } |
| + if got := s.accountName(apiAcct{Seg: "acme"}); got != "acme" { |
| + t.Fatalf("unknown segment must pass through, got %q", got) |
| + } |
| +} |
diff --git a/docs/superpowers/specs/2026-08-13-project-defaults-design.md b/docs/superpowers/specs/2026-08-13-project-defaults-design.md| new file mode 100644 |
| index 000000000..fc14898c3 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-13-project-defaults-design.md |
| @@ -0,0 +1,141 @@ |
| +# Project defaults: zero-arg deploy, resolved -app, saved project state |
| + |
| +**Date:** 2026-08-13 · **Status:** approved (Paul, in-session; all three |
| +open calls answered: prompt-once-save-suggest artifacts, offer app create, |
| +app resolution on every app-scoped member verb) |
| + |
| +The capstone of the proactive-CLI direction (#157, #160–#162): a command |
| +run with no arguments resolves what it can, asks about what is genuinely |
| +ambiguous, and offers to remember the answers as project state in |
| +`./.carlos/config` — so `carlos deploy`, alone, in a fresh checkout, is |
| +the entire onboarding: login offered, account resolved, app chosen or |
| +created, artifact named once, and every later `carlos deploy` is |
| +zero-question. Scripts and non-TTY callers keep exact current behavior; |
| +explicit flags always win and never touch a prompt. |
| + |
| +## Config schema (all additive; rewrites already preserve unknown keys) |
| + |
| +`cliConfig` gains: |
| + |
| + app — the project's app name |
| + kind — "binary" | "static" |
| + artifact — project-relative path deploy ships (dir for static, |
| + file for binary) |
| + target — GOOS-GOARCH, binary kind only |
| + |
| +The pairing rule extends to the triple: `app` (and the deploy block) are |
| +honored only when the SAME file's `console` matches the session's console |
| +— the file means "this checkout deploys as this app on this console(/ |
| +account)", and `-console` to another stack must drag none of it along. |
| +The `account` key keeps its existing rule unchanged. |
| + |
| +## Omitted `-app` (API mode, every app-scoped member verb) |
| + |
| +deploy, ship, promote, rollback, channels, releases (+ retention), |
| +restart, logs, env, secrets, instances (signed-in verbs), store, domains, |
| +ledger, version target — one seam (`resolvedApp`, sibling of |
| +`resolvedAccount`), in order: |
| + |
| +1. **`-app` flag** — verbatim, exactly as today. |
| +2. **Config `app`** (nearest walk, console-paired as above) — muted |
| + `app: <name> (the default from <file>)` line. |
| +3. **The resolved account's apps**: `GET /api/cli/apps` already returns |
| + each membership's `apps` array — the SAME single fetch defaultAccount |
| + memoizes, now kept whole on the session (accounts + apps; one request |
| + per invocation total, however many of account/app resolve). |
| + - exactly one app → use it, muted `app: <name>`; |
| + - several + TTY → numbered chooser (the #159 chooser generalized), |
| + then ONE save offer covering the resolution (below); |
| + - several + non-TTY → refusal listing the apps (discovery, like |
| + `severalAccountsErr`); |
| + - zero → **deploy offers to create** (next section); every other verb |
| + errors naming the fix: `account <name> has no apps — create one |
| + with: carlos apps create -app <name>`. |
| +4. Old console (endpoint 404) → today's "usage: … -app required" error; |
| + nothing new to fall back on. |
| + |
| +## Zero-arg `carlos deploy` |
| + |
| +With no `-app` and no file arguments, deploy becomes the wizard; each |
| +step is skipped when config or flags already answer it: |
| + |
| +1. Account, app: the seams above. **Zero apps in the account** → offer: |
| + `no apps in <account> yet — create "<dir>" (this directory's name)? |
| + [y/N]` where `<dir>` is the cwd base name, client-validated by |
| + `validAppName` first (an invalid name prompts for one instead). y runs |
| + the existing `createApp` door (rate limiter and server validation |
| + unchanged). |
| +2. Artifact: config `artifact`/`kind`/`target`, else prompt with a |
| + detected SUGGESTION — never a silent guess: if exactly one of |
| + `public/`, `dist/`, `_site/` exists it pre-fills the prompt |
| + (`artifact to deploy [./public]:`); an empty answer takes the |
| + suggestion; no suggestion means no pre-fill. The answer must exist on |
| + disk: a directory means `kind: static`, an executable file means |
| + `kind: binary` (target prompted with `linux-arm64` pre-filled). |
| + Shipping stays byte-identical to `ship` with the same values — |
| + detection only ever fills a PROMPT, the human always confirms. |
| +3. **One save offer at the end of first-run resolution**, before the |
| + ship: `save these as this project's deploy defaults (.carlos/config)? |
| + [y/N]` — writes console + account + app + kind + artifact (+ target), |
| + one write, `saveConfigAccount`'s rules (skipped from `$HOME`, |
| + warn-and-continue on failure). Values that came FROM config are not |
| + re-offered. |
| +4. Version, channel, host, watch: deploy's existing logic untouched |
| + (`gitShortHead`, channel from instances or the one channel, `-host` |
| + for instance-less apps). One addition: where channel resolution |
| + already errors as ambiguous, a TTY gets a chooser over the app's |
| + channels instead of the error (no save — channels are per-run state). |
| + The instance-less `-host` requirement keeps its current error; hosts |
| + move per-deploy and a stale saved host would ship a lie. |
| + |
| +Non-TTY zero-arg deploy with no config: one error naming both remedies |
| +(`-app <a> <artifact> …, or run carlos deploy once at a terminal to save |
| +project defaults`). |
| + |
| +## Testing |
| + |
| +- resolvedApp table: flag bypass, config honored/console-mismatch |
| + ignored, one-app auto (+muted line), several TTY chooser + non-TTY |
| + refusal naming apps, zero-apps error naming apps create, single fetch |
| + shared with account resolution (hit counter stays 1). |
| +- Wizard: artifact suggestion detection (each candidate dir, ambiguity = |
| + no pre-fill, missing path re-prompts), kind/target inference, create |
| + offer (y creates via a stub console, n stops with the named error, |
| + invalid dir name prompts instead), the one combined save write, config |
| + round-trip making the second run zero-question (assert NO prompts by |
| + giving the chooser an empty reader). |
| +- Non-TTY: exact errors; explicit flags never prompt (hit counter + |
| + 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 |
| + handed, it does not make them). |
| +- Saving channel/host (per-run state; a stale saved value ships lies). |
| +- `ship` zero-file form (deploy is the porcelain; ship stays explicit). |
| +- Registry-mode/direct-mode verbs (unchanged throughout). |
diff --git a/domains_cli.go b/domains_cli.go| index b046ab640..38514d743 100644 |
| --- a/domains_cli.go |
| +++ b/domains_cli.go |
| @@ -76,11 +76,11 @@ 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)) |
| - if *app == "" || host == "" || fs.NArg() > 1 { |
| + if host == "" || fs.NArg() > 1 { |
| log.Fatal(domainsAttachUsage) |
| } |
| // -wildcard/-catchall are tri-state, the same reason apps place's |
| @@ -102,9 +102,10 @@ 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, *app, host, strings.TrimSpace(*route), "", "", wildcardArg, catchallArg) |
| + created, resolvedRoute, err := sess.domainAttach(ctx, acct, app2, host, strings.TrimSpace(*route), "", "", wildcardArg, catchallArg) |
| if err != nil { |
| log.Fatalf("domains attach: %v", err) |
| } |
| @@ -113,7 +114,7 @@ func runDomainsAttach(args []string) { |
| } else { |
| fmt.Fprintf(stdout, "%s %s -> %s\n", colorFor(stdout).ok("already attached"), host, resolvedRoute) |
| } |
| - printDomainDNSLine(ctx, sess, acct, *app, host) |
| + printDomainDNSLine(ctx, sess, acct, app2, host) |
| } |
| // printDomainDNSLine completes the job `carlos domains attach` promises |
| @@ -156,16 +157,18 @@ 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)) |
| - if *app == "" || host == "" || fs.NArg() > 1 { |
| + if host == "" || fs.NArg() > 1 { |
| log.Fatal(domainsDetachUsage) |
| } |
| sess := requireAPISession("detach", *consoleFlag) |
| printUsing(stdout, sess) |
| - removed, err := sess.domainDetach(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, host) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, domainsDetachUsage) |
| + removed, err := sess.domainDetach(context.Background(), acct, app2, host) |
| if err != nil { |
| log.Fatalf("domains detach: %v", err) |
| } |
| @@ -180,15 +183,14 @@ 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) |
| - if *app == "" { |
| - log.Fatal(domainsListUsage) |
| - } |
| sess := requireAPISession("list", *consoleFlag) |
| printUsing(stdout, sess) |
| - recs, err := sess.domainList(context.Background(), apiAccount(acctRaw, acctIDRaw), *app) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, domainsListUsage) |
| + recs, err := sess.domainList(context.Background(), acct, app2) |
| if err != nil { |
| log.Fatalf("domains list: %v", err) |
| } |
diff --git a/domains_cli_test.go b/domains_cli_test.go| index 693e88b3a..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) |
| @@ -543,11 +551,15 @@ func TestDomainsCLIUsageErrors(t *testing.T) { |
| }{ |
| {"domains no args", []string{"domains"}, "usage: carlos domains"}, |
| {"domains bogus verb", []string{"domains", "bogus"}, "usage: carlos domains"}, |
| - {"attach no app", []string{"domains", "attach", "tickets.example.com"}, "usage: carlos domains attach"}, |
| + // The no-app cases stop on the missing SESSION since the |
| + // project-defaults spec (2026-08-13): -app resolves once a session |
| + // exists, so with nothing configured the real blocker is named — |
| + // not a usage line for a flag that is no longer required. |
| + {"attach no app", []string{"domains", "attach", "tickets.example.com"}, "no deployment target configured"}, |
| {"attach no positional host", []string{"domains", "attach", "-app", "tito"}, "usage: carlos domains attach"}, |
| - {"detach no app", []string{"domains", "detach", "tickets.example.com"}, "usage: carlos domains detach"}, |
| + {"detach no app", []string{"domains", "detach", "tickets.example.com"}, "no deployment target configured"}, |
| {"detach no positional host", []string{"domains", "detach", "-app", "tito"}, "usage: carlos domains detach"}, |
| - {"list no app", []string{"domains", "list"}, "usage: carlos domains list"}, |
| + {"list no app", []string{"domains", "list"}, "no deployment target configured"}, |
| } { |
| t.Run(tc.name, func(t *testing.T) { |
| out, err := run(tc.args...) |
diff --git a/instances_cli.go b/instances_cli.go| index d5054e56b..b7a1f63f1 100644 |
| --- a/instances_cli.go |
| +++ b/instances_cli.go |
| @@ -86,10 +86,7 @@ func runInstancesEnable(args []string) { |
| consoleFlag := fs.String("console", "", "console to enable through (signed-in mode only; default: the only logged-in one)") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| fs.Parse(args) |
| - if *app == "" { |
| - log.Fatal(instancesEnableUsage) |
| - } |
| - if !release.ValidApp(*app) { |
| + if *app != "" && !release.ValidApp(*app) { |
| log.Fatalf("carlos instances enable: %q is not a valid app name", *app) |
| } |
| set := map[string]bool{} |
| @@ -125,7 +122,8 @@ func runInstancesEnable(args []string) { |
| } |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| - m, err := sess.instancesEnable(context.Background(), acct, *app, channelReq, seedReq) |
| + app2 := resolvedApp(sess, acct, *app, instancesEnableUsage) |
| + m, err := sess.instancesEnable(context.Background(), acct, app2, channelReq, seedReq) |
| if err != nil { |
| log.Fatalf("instances enable: %v", err) |
| } |
| @@ -139,6 +137,9 @@ func runInstancesEnable(args []string) { |
| // the "not a valid channel" refusal it has always been (the flag's |
| // default moved from "stable" to "" for the signed-in mode's |
| // preserve-vs-replace split; set[] is what keeps the two cases apart). |
| + if *app == "" { |
| + log.Fatal(instancesEnableUsage) |
| + } |
| if *domain == "" { |
| log.Fatal(instancesEnableUsage) |
| } |
| @@ -288,7 +289,7 @@ func runInstancesCreate(args []string) { |
| 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) |
| - if *app == "" || *host == "" { |
| + if *host == "" { |
| log.Fatal(instancesRecordsUsage) |
| } |
| set := map[string]bool{} |
| @@ -297,7 +298,9 @@ func runInstancesCreate(args []string) { |
| probe := parseProbeFlags(*healthPath, *healthStatus, set["health-path"], set["health-status"]) |
| sess := requireAPISession("create", *consoleFlag) |
| printUsing(stdout, sess) |
| - rec, err := sess.instanceCreate(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage) |
| + rec, err := sess.instanceCreate(context.Background(), acct, app2, |
| strings.TrimSpace(*host), strings.TrimSpace(*region), strings.TrimSpace(*channel), addrs, probe) |
| if err != nil { |
| log.Fatalf("instances create: %v", err) |
| @@ -365,11 +368,10 @@ func runInstancesList(args []string) { |
| 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) |
| - if *app == "" { |
| - log.Fatal(instancesRecordsUsage) |
| - } |
| sess := requireAPISession("list", *consoleFlag) |
| - recs, truncated, total, err := sess.instanceList(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, *match, *health) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage) |
| + recs, truncated, total, err := sess.instanceList(context.Background(), acct, app2, *match, *health) |
| if err != nil { |
| log.Fatalf("instances list: %v", err) |
| } |
| @@ -405,12 +407,14 @@ func runInstancesDelete(args []string) { |
| 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) |
| - if *app == "" || *host == "" { |
| + if *host == "" { |
| log.Fatal(instancesRecordsUsage) |
| } |
| sess := requireAPISession("delete", *consoleFlag) |
| printUsing(stdout, sess) |
| - if err := sess.instanceDelete(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, strings.TrimSpace(*host)); err != nil { |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage) |
| + if err := sess.instanceDelete(context.Background(), acct, app2, strings.TrimSpace(*host)); err != nil { |
| log.Fatalf("instances delete: %v", err) |
| } |
| fmt.Fprintf(stdout, "%s %s — its route retires on the next reconcile pass; parked data stays\n", |
| @@ -442,7 +446,7 @@ func runInstancesSetUpstreams(args []string) { |
| 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) |
| - if *app == "" || *host == "" { |
| + if *host == "" { |
| log.Fatal(instancesRecordsUsage) |
| } |
| set := map[string]bool{} |
| @@ -464,7 +468,9 @@ func runInstancesSetUpstreams(args []string) { |
| probe := parseProbeFlags(*healthPath, *healthStatus, set["health-path"], set["health-status"]) |
| sess := requireAPISession("set-upstreams", *consoleFlag) |
| printUsing(stdout, sess) |
| - rec, err := sess.instanceSetUpstreams(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage) |
| + rec, err := sess.instanceSetUpstreams(context.Background(), acct, app2, |
| strings.TrimSpace(*host), addrs, probe) |
| if err != nil { |
| log.Fatalf("instances set-upstreams: %v", err) |
diff --git a/logs.go b/logs.go| index dd267d1db..5f338fc89 100644 |
| --- a/logs.go |
| +++ b/logs.go |
| @@ -71,9 +71,6 @@ func runLogs(args []string) { |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| fs.Usage = func() { fmt.Fprint(os.Stderr, logsUsage) } |
| fs.Parse(args) |
| - if *app == "" { |
| - log.Fatal(logsUsage) |
| - } |
| q := url.Values{} |
| for k, v := range map[string]string{ |
| @@ -93,8 +90,9 @@ func runLogs(args []string) { |
| sess := requireAPISession("logs", *consoleFlag) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, logsUsage) |
| ctx := context.Background() |
| - res, err := sess.logsGet(ctx, acct, *app, q) |
| + res, err := sess.logsGet(ctx, acct, app2, q) |
| if err != nil { |
| log.Fatalf("logs: %v", err) |
| } |
| @@ -127,7 +125,7 @@ func runLogs(args []string) { |
| if cursor != "" { |
| fq.Set("cursor", cursor) |
| } |
| - res, err := sess.logsGet(ctx, acct, *app, fq) |
| + res, err := sess.logsGet(ctx, acct, app2, fq) |
| if err != nil { |
| // A follow rides through a blip; the next poll answers or the |
| // member interrupts. Say it once per failure, quietly. |
diff --git a/restart.go b/restart.go| index c9d628b9c..213705f78 100644 |
| --- a/restart.go |
| +++ b/restart.go |
| @@ -27,19 +27,20 @@ func runRestart(args []string) { |
| consoleFlag := fs.String("console", "", "console origin to restart through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| - if name == "" { |
| - log.Fatal("usage: carlos restart -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]") |
| - } |
| - // Client-side gate before the server's, the same one `carlos apps create` |
| - // applies: a name the console is certain to refuse should cost a typo |
| - // nothing, and the shared write budget is spent per attempt. |
| - if err := validAppName(name); err != nil { |
| - log.Fatalf("restart: %v", err) |
| + usage := "usage: carlos restart -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]" |
| + if name != "" { |
| + // Client-side gate before the server's, the same one `carlos apps create` |
| + // applies: a name the console is certain to refuse should cost a typo |
| + // nothing, and the shared write budget is spent per attempt. |
| + if err := validAppName(name); err != nil { |
| + log.Fatalf("restart: %v", err) |
| + } |
| } |
| sess := requireRestartAPISession(*consoleFlag) |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + name = resolvedApp(sess, acct, name, usage) |
| res, err := sess.restart(context.Background(), acct, name) |
| if err != nil { |
| log.Fatalf("restart: %v", err) |
diff --git a/restart_test.go b/restart_test.go| index 8232da794..7469888ff 100644 |
| --- a/restart_test.go |
| +++ b/restart_test.go |
| @@ -122,12 +122,16 @@ func TestRestartVerbRefusals(t *testing.T) { |
| return string(out), err |
| } |
| + // -app is resolvable since the project-defaults spec (2026-08-13), so a |
| + // bare `carlos restart` with nothing configured stops on the REAL |
| + // blocker — no session — not on a usage line for a flag that is no |
| + // longer required. |
| out, err := run(nil, "restart") |
| if err == nil { |
| t.Fatalf("restart with no -app exited 0:\n%s", out) |
| } |
| - if !strings.Contains(out, "usage: carlos restart -app") { |
| - t.Fatalf("restart with no -app = %q, want the usage line", out) |
| + if !strings.Contains(out, "no deployment target configured") { |
| + t.Fatalf("bare restart = %q, want the no-target refusal", out) |
| } |
| // Direct bucket mode: refused in restart's OWN words, naming the login |
diff --git a/retention.go b/retention.go| index f4de48820..d31cdfc66 100644 |
| --- a/retention.go |
| +++ b/retention.go |
| @@ -30,11 +30,10 @@ func runReleasesRetention(args []string) { |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| usage := "usage: carlos releases retention -app <a> [-keep <n> [-dry-run] | -off] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]" |
| - if name == "" { |
| - log.Fatal(usage) |
| - } |
| - if err := validAppName(name); err != nil { |
| - log.Fatalf("releases retention: %v", err) |
| + if name != "" { |
| + if err := validAppName(name); err != nil { |
| + log.Fatalf("releases retention: %v", err) |
| + } |
| } |
| // Client-side gates before the server's: a request the console is |
| // certain to refuse should cost a typo nothing. |
| @@ -53,6 +52,7 @@ func runReleasesRetention(args []string) { |
| sess := requireRetentionAPISession(*consoleFlag) |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + name = resolvedApp(sess, acct, name, usage) |
| ctx := context.Background() |
| switch { |
| case *off: |
diff --git a/retention_cli_test.go b/retention_cli_test.go| index c015e73e2..5ffe26ffa 100644 |
| --- a/retention_cli_test.go |
| +++ b/retention_cli_test.go |
| @@ -47,11 +47,14 @@ func TestReleasesRetentionRefusesOffPlusKeep(t *testing.T) { |
| } |
| func TestReleasesRetentionNeedsApp(t *testing.T) { |
| + // -app resolves since the project-defaults spec (2026-08-13): with |
| + // nothing configured the refusal names the missing SESSION, the real |
| + // blocker — not a usage line for a flag that is no longer required. |
| out, err := runRetentionCLI(t, "releases", "retention") |
| if err == nil { |
| t.Fatalf("no -app exited 0: %s", out) |
| } |
| - if !strings.Contains(out, "usage:") { |
| - t.Fatalf("want usage line, got: %s", out) |
| + if !strings.Contains(out, "no deployment target configured") { |
| + t.Fatalf("want the no-target refusal, got: %s", out) |
| } |
| } |
diff --git a/store.go b/store.go| index 05075cefe..bdca699e5 100644 |
| --- a/store.go |
| +++ b/store.go |
| @@ -132,19 +132,20 @@ func runStoreCreate(args []string) { |
| consoleFlag := fs.String("console", "", "console origin to declare through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| - if name == "" { |
| - log.Fatal("usage: carlos store create -app <a> [-env-prefix <PREFIX>] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]") |
| - } |
| + usage := "usage: carlos store create -app <a> [-env-prefix <PREFIX>] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]" |
| // Client-side gate before the server's, the same one every write verb in |
| // this file applies: a name the console is certain to refuse should cost |
| // a typo nothing. |
| - if err := validAppName(name); err != nil { |
| - log.Fatalf("store create: %v", err) |
| + if name != "" { |
| + if err := validAppName(name); err != nil { |
| + log.Fatalf("store create: %v", err) |
| + } |
| } |
| sess := requireStoreAPISession(*consoleFlag) |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + name = resolvedApp(sess, acct, name, usage) |
| res, err := sess.storeCreate(context.Background(), acct, name, strings.TrimSpace(*envPrefix)) |
| if err != nil { |
| log.Fatalf("store create: %v", err) |
| @@ -159,16 +160,17 @@ func runStoreStatus(args []string) { |
| consoleFlag := fs.String("console", "", "console origin to read through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| - if name == "" { |
| - log.Fatal("usage: carlos store status -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]") |
| - } |
| - if err := validAppName(name); err != nil { |
| - log.Fatalf("store status: %v", err) |
| + usage := "usage: carlos store status -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]" |
| + if name != "" { |
| + if err := validAppName(name); err != nil { |
| + log.Fatalf("store status: %v", err) |
| + } |
| } |
| sess := requireStoreAPISession(*consoleFlag) |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + name = resolvedApp(sess, acct, name, usage) |
| res, err := sess.storeGet(context.Background(), acct, name) |
| if err != nil { |
| log.Fatalf("store status: %v", err) |
| @@ -184,16 +186,17 @@ func runStoreGrant(args []string) { |
| consoleFlag := fs.String("console", "", "console origin to grant through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| - if name == "" { |
| - log.Fatal("usage: carlos store grant -app <a> [-bucket <name>] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]") |
| - } |
| - if err := validAppName(name); err != nil { |
| - log.Fatalf("store grant: %v", err) |
| + usage := "usage: carlos store grant -app <a> [-bucket <name>] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]" |
| + if name != "" { |
| + if err := validAppName(name); err != nil { |
| + log.Fatalf("store grant: %v", err) |
| + } |
| } |
| sess := requireStoreAPISession(*consoleFlag) |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + name = resolvedApp(sess, acct, name, usage) |
| res, err := sess.storeGrant(context.Background(), acct, name, strings.TrimSpace(*bucket)) |
| if err != nil { |
| log.Fatalf("store grant: %v", err) |
| @@ -209,16 +212,17 @@ func runStoreRotate(args []string) { |
| consoleFlag := fs.String("console", "", "console origin to rotate through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| - if name == "" { |
| - log.Fatal("usage: carlos store rotate -app <a> [-finish] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]") |
| - } |
| - if err := validAppName(name); err != nil { |
| - log.Fatalf("store rotate: %v", err) |
| + usage := "usage: carlos store rotate -app <a> [-finish] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]" |
| + if name != "" { |
| + if err := validAppName(name); err != nil { |
| + log.Fatalf("store rotate: %v", err) |
| + } |
| } |
| sess := requireStoreAPISession(*consoleFlag) |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + name = resolvedApp(sess, acct, name, usage) |
| res, err := sess.storeRotate(context.Background(), acct, name, *finish) |
| if err != nil { |
| log.Fatalf("store rotate: %v", err) |
diff --git a/store_test.go b/store_test.go| index db046ab76..cf1f6af97 100644 |
| --- a/store_test.go |
| +++ b/store_test.go |
| @@ -526,22 +526,16 @@ func TestStoreVerbDispatchAndRefusals(t *testing.T) { |
| } |
| } |
| - // Missing -app, one representative per sub-verb (each builds its own |
| - // usage line — the shape restart.go's own missing-app test pins). |
| - for _, tc := range []struct { |
| - sub, want string |
| - }{ |
| - {"create", "usage: carlos store create -app"}, |
| - {"status", "usage: carlos store status -app"}, |
| - {"grant", "usage: carlos store grant -app"}, |
| - {"rotate", "usage: carlos store rotate -app"}, |
| - } { |
| - out, err := run(nil, "store", tc.sub) |
| + // -app is resolvable since the project-defaults spec (2026-08-13), so a |
| + // missing -app with nothing configured stops on the REAL blocker — no |
| + // session — not on a usage line for a flag that is no longer required. |
| + for _, sub := range []string{"create", "status", "grant", "rotate"} { |
| + out, err := run(nil, "store", sub) |
| if err == nil { |
| - t.Fatalf("carlos store %s with no -app exited 0:\n%s", tc.sub, out) |
| + t.Fatalf("carlos store %s with no -app exited 0:\n%s", sub, out) |
| } |
| - if !strings.Contains(out, tc.want) { |
| - t.Fatalf("carlos store %s with no -app = %q, want %q", tc.sub, out, tc.want) |
| + if !strings.Contains(out, "no deployment target configured") { |
| + t.Fatalf("carlos store %s with no -app = %q, want the no-target refusal", sub, out) |
| } |
| } |
diff --git a/update.go b/update.go| index 357b8e16f..31b1d198c 100644 |
| --- a/update.go |
| +++ b/update.go |
| @@ -172,28 +172,8 @@ func maybeUpdateHint(cmd string) { |
| // nearest .carlos/config with an update_check key, else the global. Only |
| // the literal "off" disables. |
| func configUpdateCheckOff() (bool, error) { |
| - if dir, werr := os.Getwd(); werr == nil { |
| - for { |
| - cfg, err := loadConfig(filepath.Join(dir, ".carlos", "config")) |
| - if err != nil { |
| - return false, err |
| - } |
| - if cfg.UpdateCheck != "" { |
| - return cfg.UpdateCheck == "off", nil |
| - } |
| - parent := filepath.Dir(dir) |
| - if parent == dir { |
| - break |
| - } |
| - dir = parent |
| - } |
| - } |
| - path, perr := globalConfigPath() |
| - if perr != nil { |
| - return false, nil |
| - } |
| - cfg, err := loadConfig(path) |
| - if err != nil { |
| + cfg, src, err := walkConfig(func(c cliConfig) bool { return c.UpdateCheck != "" }) |
| + if err != nil || src == "" { |
| return false, err |
| } |
| return cfg.UpdateCheck == "off", nil |