carlos / platform Public

Merge pull request #185 from carlosframework/worktree-project-defaults

Project defaults: zero-arg carlos deploy, resolved -app, saved state in .carlos
Paul Campbell f906a303a0faa6ab9f456db048b376b03d9ffa32 parent c42ec0cparent 21e8ab1
20 files changed, +1424 −256
  • account_choose.go +36 −0
  • apimode.go +155 −0
  • apimode_test.go +96 −0
  • appconfig_cli.go +88 −47
  • cli.go +115 −57
  • config.go +72 −28
  • deploy.go +315 −15
  • deploy_wizard_test.go +293 −0
  • docs/superpowers/specs/2026-08-13-project-defaults-design.md +141 −0
  • domains_cli.go +15 −13
  • domains_cli_test.go +15 −3
  • instances_cli.go +21 −15
  • logs.go +3 −5
  • restart.go +9 −8
  • restart_test.go +6 −2
  • retention.go +5 −5
  • retention_cli_test.go +5 −2
  • store.go +24 −20
  • store_test.go +8 −14
  • update.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