cli: named deploy destinations in .carlos/config
One checkout can now deploy to several stacks. `.carlos/config` takes a
"destinations" object of named blocks, each shaped like the top level
(console, account, app, kind, artifact, target), and a top-level
"default" naming the block used when nothing is selected:
{"default": "exciting",
"destinations": {"exciting": {...}, "tito": {...}}}
carlos deploy -d tito
carlos logs --destination tito
--destination / -d is registered beside --console on every verb that
takes one (consoleOption), and CARLOS_DESTINATION selects for a whole
shell. -d joins the claimed short-flag table; no verb used it.
Resolution lives in loadConfig, so console, account, app and the deploy
block all come from the same block. A block is laid over the file's top
level, so shared keys are written once. A file with several blocks and
no default refuses, as does a selected name no file defines — falling
through would reach the single-credential convenience and ship to the
wrong stack. Saves land in the block the read resolved to.
--target keeps its meaning (the GOOS-GOARCH a binary was built for);
"destinations" was chosen so the two never read alike.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
39 files changed,
+721
−187
AGENTS.md+2 −1accounts.go+2 −2analytics_cmd.go+1 −1appconfig_cli.go+12 −11apps.go+4 −4apps_buildprobe.go+1 −1auth.go+3 −3canary.go+3 −3cli.go+12 −12color.go+5 −1config.go+217 −68config_test.go+1 −0deploy.go+8 −4destinations_test.go+308 −0docs/site/cli.md+33 −0domains_cli.go+4 −4e2e_test.go+7 −0economics.go+2 −2errors_cmd.go+1 −1features_cli.go+2 −2flags.go+25 −2fleets.go+6 −6gate_cli.go+1 −1instances_cli.go+8 −8internal/cliflag/cliflag.go+4 −2logs.go+1 −1mail_cli.go+11 −11pipeline.go+1 −1relay.go+5 −5restart.go+1 −1retention.go+1 −1routes_cli.go+1 −1scan.go+1 −1schedule.go+4 −4scopedkeys_e2e_test.go+1 −0services.go+4 −4sidecars_cli.go+6 −6store.go+8 −8system.go+4 −4
CI passed — run details
diff --git a/AGENTS.md b/AGENTS.md| index f0e66f53e..78dc719c1 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -277,7 +277,8 @@ anything. |
| - Icons are vendored Lucide (in-code SVG constants, never CDN); emoji is |
| content, icons are chrome. |
| - **A test that shells out to the carlos binary must build its env through |
| - `carlosTestEnv`** (masks `CARLOS_CREDENTIALS`, `HOME`, `CARLOS_CONSOLE`) |
| + `carlosTestEnv`** (masks `CARLOS_CREDENTIALS`, `HOME`, `CARLOS_CONSOLE`, |
| + `CARLOS_DESTINATION`) |
| **and set `cmd.Dir` to a scratch dir** — the binary walks cwd ancestors for |
| `.carlos/config`. The fallback is the operator's real login and default |
| console; a logged-in laptop running `go test ./...` minted 40+ real accounts |
diff --git a/accounts.go b/accounts.go| index 2774d20ac..2da563e7c 100644 |
| --- a/accounts.go |
| +++ b/accounts.go |
| @@ -88,7 +88,7 @@ func runAccountsCreate(args []string) { |
| name := fs.String("name", "", "account name (a-z, 0-9, hyphen; need not be unique)") |
| owner := fs.String("owner", "", "keymail address that owns the account — the box-side/headless path, "+ |
| "and what to use when this terminal is not logged in") |
| - console := fs.String("console", "", "which logged-in console to create it through (only needed with several)") |
| + console := consoleOption(fs, "which logged-in console to create it through (only needed with several)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| *name = strings.TrimSpace(*name) |
| @@ -212,7 +212,7 @@ func createAccountViaConsole(cred *credential, regPath, name string) { |
| func runAccountsList(args []string) { |
| fs := flag.NewFlagSet("accounts list", flag.ExitOnError) |
| regPath := fs.String("registry", defaultRegistry(), "registry database (box mode)") |
| - consoleFlag := fs.String("console", "", "list through a console instead (default: the box registry when one exists, else your logged-in console)") |
| + consoleFlag := consoleOption(fs, "list through a console instead (default: the box registry when one exists, else your logged-in console)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| // Mode split by what exists, not by resolveMode: this verb predates the |
diff --git a/analytics_cmd.go b/analytics_cmd.go| index 27f781de9..fbbfc8161 100644 |
| --- a/analytics_cmd.go |
| +++ b/analytics_cmd.go |
| @@ -27,7 +27,7 @@ func runAnalytics(args []string) { |
| rangeFlag := fs.String("range", "today", "window: today | 7d | 30d") |
| setCounting := fs.String("set-count", "", "set analytics counting: edge | off") |
| setDoor := fs.String("set-door", "", "set door: on | off") |
| - consoleFlag := fs.String("console", "", "console to read through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to read through (default: the only logged-in one)") |
| 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.Usage = flagUsage(fs, analyticsUsage) |
diff --git a/appconfig_cli.go b/appconfig_cli.go| index f331a08b8..07e722bef 100644 |
| --- a/appconfig_cli.go |
| +++ b/appconfig_cli.go |
| @@ -344,7 +344,7 @@ func runEnvSet(args []string) { |
| app := appFlag(fs, "") |
| environment := environmentFlag(fs, "named environment bundle to set in (omit for the default bundle)") |
| acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs) |
| - consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos env set --app <a> [--environment <name>] [--console <origin>] KEY=value [KEY2=value2 ...]" |
| @@ -393,8 +393,9 @@ func runEnvSet(args []string) { |
| // mapKeys is the key set of a KEY=value map, for the writtenKeys argument |
| // AwaitingRestart narrows on. Sorted, so a notice is deterministic across |
| -// runs where Go's map order is not. |
| -func mapKeys(kv map[string]string) []string { |
| +// runs where Go's map order is not. Generic: config.go lists destination |
| +// names with it too. |
| +func mapKeys[V any](kv map[string]V) []string { |
| out := make([]string, 0, len(kv)) |
| for k := range kv { |
| out = append(out, k) |
| @@ -409,7 +410,7 @@ func runEnvUnset(args []string) { |
| app := appFlag(fs, "") |
| environment := environmentFlag(fs, "named environment bundle to remove from (omit for the default bundle)") |
| acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs) |
| - consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos env unset --app <a> [--environment <name>] [--console <origin>] KEY [KEY2 ...]" |
| @@ -462,7 +463,7 @@ func runEnvList(args []string) { |
| app := appFlag(fs, "") |
| environment := environmentFlag(fs, "named environment bundle to list (omit for the default bundle)") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos env list --app <a> [--environment <name>] [--console <origin>]" |
| @@ -567,7 +568,7 @@ func runEnvExecDelivery(args []string) { |
| environment := environmentFlag(fs, |
| "named environment bundle to set the flag in (omit for the app's default bundle)") |
| acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs) |
| - consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos env exec-delivery --app <a> [--console <origin>] file|off\n" + |
| @@ -669,7 +670,7 @@ func runEnvEnvironments(args []string) { |
| fs := flag.NewFlagSet("env environments", flag.ExitOnError) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos env environments --app <a> [--console <origin>]" |
| @@ -723,7 +724,7 @@ func runEnvSync(args []string) { |
| fs := flag.NewFlagSet("env sync", flag.ExitOnError) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos env sync --app <a> [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]" |
| @@ -852,7 +853,7 @@ func runSecretsSet(args []string) { |
| app := appFlag(fs, "") |
| environment := environmentFlag(fs, "named environment bundle to set in (omit for the default bundle)") |
| acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs) |
| - consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos secrets set --app <a> [--environment <name>] [--console <origin>] KEY=value [KEY2=value2 ...]" |
| @@ -953,7 +954,7 @@ func runSecretsUnset(args []string) { |
| app := appFlag(fs, "") |
| environment := environmentFlag(fs, "named environment bundle to remove from (omit for the default bundle)") |
| acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs) |
| - consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos secrets unset --app <a> [--environment <name>] [--console <origin>] KEY [KEY2 ...]" |
| @@ -1012,7 +1013,7 @@ func runSecretsList(args []string) { |
| app := appFlag(fs, "") |
| environment := environmentFlag(fs, "named environment bundle to list (omit for the default bundle)") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos secrets list --app <a> [--environment <name>] [--console <origin>]" |
diff --git a/apps.go b/apps.go| index 558e0f41e..8edeb97d6 100644 |
| --- a/apps.go |
| +++ b/apps.go |
| @@ -202,7 +202,7 @@ func appsMode(loggedIn, bucketConfigured bool, accountID string) (mode string, e |
| func runAppsDelete(args []string) { |
| fs := flag.NewFlagSet("apps delete", flag.ExitOnError) |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to delete through (signed-in mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to delete through (signed-in mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if fs.NArg() != 1 { |
| @@ -323,7 +323,7 @@ func printTrashed(app, restorableUntil string) { |
| func runAppsRestore(args []string) { |
| fs := flag.NewFlagSet("apps restore", flag.ExitOnError) |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to restore through (signed-in mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to restore through (signed-in mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if fs.NArg() != 1 { |
| @@ -456,7 +456,7 @@ func runAppsCreate(args []string) { |
| // the account against the caller's own memberships. |
| acctRaw := fs.String("account", "", "account (sqid or name) to claim the app in (default: your only account, or the configured/chosen one)") |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account; this is the flag an ambiguity error tells you to use") |
| - consoleFlag := fs.String("console", "", "console to create it through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to create it through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if strings.TrimSpace(*app) == "" { |
| @@ -571,7 +571,7 @@ func runAppsPlace(args []string) { |
| // console-tracked state, API mode only, same as apps create above. |
| 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; this is the flag an ambiguity error tells you to use") |
| - consoleFlag := fs.String("console", "", "console to place through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to place through (default: the only logged-in one)") |
| const placeUsage = `usage: carlos apps place --app <name> --target fleet/<f>|"" [--account <sqid|name>] ` + |
| "[--account-id <sqid>] [--console <origin>]" |
| fs.Usage = flagUsage(fs, placeUsage) |
diff --git a/apps_buildprobe.go b/apps_buildprobe.go| index 3bd7f9215..ebc1c8ca7 100644 |
| --- a/apps_buildprobe.go |
| +++ b/apps_buildprobe.go |
| @@ -44,7 +44,7 @@ func runAppsBuildProbe(args []string) { |
| app := appFlag(fs, "app whose build probe to set") |
| 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") |
| - consoleFlag := fs.String("console", "", "console to act through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, buildProbeUsage) |
| fs.Parse(args) |
| if strings.TrimSpace(*app) == "" || fs.NArg() != 1 { |
diff --git a/auth.go b/auth.go| index 2d5dcedab..d165c3f0f 100644 |
| --- a/auth.go |
| +++ b/auth.go |
| @@ -636,7 +636,7 @@ func loginConsole(flagValue string) (string, error) { |
| func authLogin(out io.Writer, args []string) error { |
| fs := flag.NewFlagSet("auth login", flag.ContinueOnError) |
| fs.SetOutput(out) |
| - console := fs.String("console", "", "the console to log in to (default: the configured default, else "+defaultConsole+")") |
| + console := consoleOption(fs, "the console to log in to (default: the configured default, else "+defaultConsole+")") |
| label := fs.String("label", "", "what to call this terminal on the console (default: user@host)") |
| fs.Usage = flagUsage(fs, "") |
| if err := fs.Parse(args); err != nil { |
| @@ -745,7 +745,7 @@ func authLogin(out io.Writer, args []string) error { |
| func authWhoami(out io.Writer, args []string) error { |
| fs := flag.NewFlagSet("auth whoami", flag.ContinueOnError) |
| fs.SetOutput(out) |
| - console := fs.String("console", "", "which console (only needed with several)") |
| + console := consoleOption(fs, "which console (only needed with several)") |
| fs.Usage = flagUsage(fs, "") |
| if err := fs.Parse(args); err != nil { |
| return err |
| @@ -800,7 +800,7 @@ func authWhoami(out io.Writer, args []string) error { |
| func authLogout(out io.Writer, args []string) error { |
| fs := flag.NewFlagSet("auth logout", flag.ContinueOnError) |
| fs.SetOutput(out) |
| - console := fs.String("console", "", "which console (only needed with several)") |
| + console := consoleOption(fs, "which console (only needed with several)") |
| fs.Usage = flagUsage(fs, "") |
| if err := fs.Parse(args); err != nil { |
| return err |
diff --git a/canary.go b/canary.go| index 8a33eaaa6..30c1309ce 100644 |
| --- a/canary.go |
| +++ b/canary.go |
| @@ -340,7 +340,7 @@ func runCanaryDeploy(args []string) { |
| app := appFlag(fs, "app to canary") |
| name := fs.String("name", "", "canary name (default: the current git branch, slugified)") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console to deploy through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to deploy through (default: the only logged-in one)") |
| kind := fs.String("kind", "binary", "binary | static") |
| environment := fs.String("environment", "", "named config bundle the canary binds to (default: the app's default bundle)") |
| ver := versionFlag(fs, "version (default: git rev-parse --short HEAD)") |
| @@ -483,7 +483,7 @@ func runCanaryList(args []string) { |
| fs := flag.NewFlagSet("canary ls", flag.ExitOnError) |
| app := appFlag(fs, "app whose canaries to list") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console to read through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to read through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, canaryUsage) |
| fs.Parse(args) |
| @@ -554,7 +554,7 @@ func runCanaryRemove(args []string) { |
| app := appFlag(fs, "app the canary belongs to") |
| name := fs.String("name", "", "canary name to remove") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console to remove through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to remove through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, canaryUsage) |
| fs.Parse(args) |
diff --git a/cli.go b/cli.go| index cb53a0d3b..ee9f8e20b 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -1991,7 +1991,7 @@ func runShip(args []string) { |
| messageFlags := addMutationMessageFlags(fs) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to ship through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to ship through (API mode only; default: the only logged-in one)") |
| kind := fs.String("kind", "binary", "binary | static") |
| ver := versionFlag(fs, "version (git short sha); omit to mint the next iteration under the app's version target") |
| target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)") |
| @@ -2336,7 +2336,7 @@ func runPromote(args []string) { |
| app := appFlag(fs, "") |
| hotfix := fs.Bool("hotfix", false, "bypass the channel ladder (recorded as a hotfix)") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to promote through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to promote through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos promote --app <a> [--hotfix] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>] <version> [channel]" |
| @@ -2505,7 +2505,7 @@ func runChannels(args []string) { |
| fs := flag.NewFlagSet("channels", flag.ExitOnError) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to read through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to read through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos channels --app <a> [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]" |
| @@ -2598,7 +2598,7 @@ func runVersionTarget(args []string) { |
| fs := flag.NewFlagSet("version target", flag.ExitOnError) |
| app := appFlag(fs, "") |
| 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)") |
| + consoleFlag := consoleOption(fs, "console origin to read or set through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos version target --app <a> [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>] [<semver>]" |
| @@ -2760,7 +2760,7 @@ func runReleases(args []string) { |
| fs := flag.NewFlagSet("releases", flag.ExitOnError) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to read through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to read through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos releases --app <a> [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]" |
| @@ -2813,7 +2813,7 @@ func runRollback(args []string) { |
| messageFlags := addMutationMessageFlags(fs) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to roll back through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to roll back through (API mode only; default: the only logged-in one)") |
| usage := "usage: carlos rollback --app <a> [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>] <channel>" |
| fs.Usage = flagUsage(fs, usage) |
| fs.Parse(args) |
| @@ -2968,7 +2968,7 @@ func runLedgerAppend(args []string) { |
| fs := flag.NewFlagSet("ledger append", flag.ExitOnError) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw := ledgerAccountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to append through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to append through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos ledger append --app <a> [--account <acct>] [--account-id <sqid>] <ledger> <entry.json|->" |
| @@ -3016,7 +3016,7 @@ func runLedgerHead(args []string) { |
| fs := flag.NewFlagSet("ledger head", flag.ExitOnError) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw := ledgerAccountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to read through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to read through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos ledger head --app <a> [--account <acct>] [--account-id <sqid>] <ledger>" |
| @@ -3046,7 +3046,7 @@ func runLedgerList(args []string) { |
| fs := flag.NewFlagSet("ledger list", flag.ExitOnError) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw := ledgerAccountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to read through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to read through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos ledger list --app <a> [--account <acct>] [--account-id <sqid>]" |
| @@ -3097,7 +3097,7 @@ func runLedgerPublish(args []string) { |
| acctRaw, acctIDRaw := ledgerAccountFlags(fs) |
| public := fs.Bool("public", true, "serve these ledgers publicly (--public=false unpublishes)") |
| 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)") |
| + consoleFlag := consoleOption(fs, "console origin to publish through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos ledger publish --app <a> [--account <acct>] [--account-id <sqid>] [--public=true|false] [--title <t>] <ledger>..." |
| @@ -3141,7 +3141,7 @@ func runLedgerBlob(args []string) { |
| fs := flag.NewFlagSet("ledger blob", flag.ExitOnError) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw := ledgerAccountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to upload through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to upload through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos ledger blob --app <a> [--account <acct>] [--account-id <sqid>] <ledger> <file>..." |
| @@ -3329,7 +3329,7 @@ func RunStatus(args []string) { |
| liveFlag := fs.Bool("live", false, "read status/boxes/*.json from the status bucket and print per-host metrics") |
| instancesFlag := fs.Bool("instances", false, "one row per awake instance across the fleet, with the account and app it belongs to (operator only, through the console)") |
| acctFilter := fs.String("account", "", "narrow --instances to one account (sqid)") |
| - consoleFlag := fs.String("console", "", "console to ask (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to ask (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, statusUsage) |
| fs.Parse(args) |
diff --git a/color.go b/color.go| index 7bd96b738..41abb6bb0 100644 |
| --- a/color.go |
| +++ b/color.go |
| @@ -147,7 +147,11 @@ func warnf(w io.Writer, format string, a ...any) { |
| // under it rather than the answer anybody typed the command to get. |
| func printUsing(w io.Writer, sess *apiSession) { |
| c := colorFor(w) |
| - fmt.Fprintln(w, c.muted(fmt.Sprintf("using %s as %s", sess.console, sess.address))) |
| + line := fmt.Sprintf("using %s as %s", sess.console, sess.address) |
| + if d := configDestination(sess.console); d != "" { |
| + line += " (destination " + d + ")" |
| + } |
| + fmt.Fprintln(w, c.muted(line)) |
| } |
| // printProposed is the one line every guarded write (promote, rollback, a |
diff --git a/config.go b/config.go| index 2718325b9..508a69f25 100644 |
| --- a/config.go |
| +++ b/config.go |
| @@ -18,6 +18,19 @@ |
| // (docs/superpowers/specs/2026-08-04-console-default-resolution-design.md) |
| // because per-origin credentials mean no token ever crosses consoles, and |
| // every verb prints the console it is talking to. |
| +// |
| +// Destinations: one checkout can deploy to several stacks. A file may hold |
| +// a "destinations" object of named blocks, each shaped like the top level |
| +// (console, account, app, kind, artifact, target), and --destination / -d |
| +// (or CARLOS_DESTINATION) picks one: |
| +// |
| +// {"destinations": {"exciting": {"console": "...", "app": "shop"}, |
| +// "tito": {"console": "...", "app": "shop"}}} |
| +// |
| +// The chosen block is laid over the file's top level, so a key shared by |
| +// every destination (say "artifact") can live once at the top. A |
| +// top-level "default" names the block used when nothing is selected. See |
| +// loadConfig for the resolution rules. |
| package main |
| import ( |
| @@ -56,6 +69,55 @@ type cliConfig struct { |
| // MessagePrompt: "off" suppresses the optional mutation-description |
| // question. Any other value, including absent, leaves it enabled. |
| MessagePrompt string `json:"message_prompt"` |
| + |
| + // Destination is the destinations block this view was resolved from |
| + // ("" when the file has none). Never read from or written to the file |
| + // as a key: it names what loadConfig did, for messages and the walk's |
| + // did-anything-define-it check. |
| + Destination string `json:"-"` |
| +} |
| + |
| +// configFile is the on-disk shape: the top level plus the named blocks. |
| +// Blocks are cliConfig too, so a "destinations" key nested inside one is |
| +// simply ignored rather than recursed into. |
| +type configFile struct { |
| + cliConfig |
| + Default string `json:"default"` |
| + Destinations map[string]cliConfig `json:"destinations"` |
| +} |
| + |
| +// destinationFlag is --destination / -d, bound by consoleOption on every |
| +// verb that talks to a console. A package variable rather than a value |
| +// threaded through: the config is read deep inside session resolution, a |
| +// long way from any flag set, and one process runs one verb. |
| +var destinationFlag string |
| + |
| +// selectedDestination is the destination this run asked for: the flag, |
| +// else CARLOS_DESTINATION, else "" (let the files decide). |
| +func selectedDestination() string { |
| + if d := strings.TrimSpace(destinationFlag); d != "" { |
| + return d |
| + } |
| + return strings.TrimSpace(os.Getenv("CARLOS_DESTINATION")) |
| +} |
| + |
| +// fileDefaultDestination is the block a file resolves to when nothing was |
| +// selected: its "default", else its only block. names is never empty. |
| +func fileDefaultDestination(path, def string, names []string) (string, error) { |
| + if def != "" { |
| + for _, n := range names { |
| + if n == def { |
| + return def, nil |
| + } |
| + } |
| + return "", fmt.Errorf("%s: the default destination %q is not one of its destinations (%s)", |
| + path, def, strings.Join(names, ", ")) |
| + } |
| + if len(names) == 1 { |
| + return names[0], nil |
| + } |
| + return "", fmt.Errorf("%s defines several destinations (%s) and no default — pick one with --destination <name> (-d), set CARLOS_DESTINATION, or add \"default\": \"<name>\"", |
| + path, strings.Join(names, ", ")) |
| } |
| func configMessagePrompt() (string, error) { |
| @@ -74,25 +136,49 @@ func configMessagePrompt() (string, error) { |
| // (auth.go credentialsPath) — for tests, and for anyone keeping their |
| // config somewhere other than $HOME's discovery path. |
| func walkConfig(pick func(cliConfig) bool) (cliConfig, string, error) { |
| - if p := os.Getenv("CARLOS_CONFIG"); p != "" { |
| - cfg, err := loadConfig(p) |
| + cfg, src, defined, err := walkConfigFiles(pick) |
| + if err != nil { |
| + return cliConfig{}, "", err |
| + } |
| + // A named destination that no file defines is a typo or a missing |
| + // block — never "no opinion". Falling through would hand the verb to |
| + // the single-credential convenience: a ship to whatever stack this |
| + // machine happens to be logged in to. Checked only when nothing was |
| + // picked, because only then was every file read: a stack key can only |
| + // be picked from a file that defines the destination anyway (loadConfig |
| + // strips it elsewhere), and a preference picked early says nothing |
| + // about the files past it. |
| + if d := selectedDestination(); d != "" && src == "" && !defined { |
| + return cliConfig{}, "", fmt.Errorf("no .carlos/config defines the destination %q", d) |
| + } |
| + return cfg, src, nil |
| +} |
| + |
| +// walkConfigFiles is walkConfig's discovery, also reporting whether any |
| +// file it read defined the selected destination. |
| +func walkConfigFiles(pick func(cliConfig) bool) (cfg cliConfig, src string, defined bool, err error) { |
| + visit := func(path string) (bool, error) { |
| + c, err := loadConfig(path) |
| if err != nil { |
| - return cliConfig{}, "", err |
| + return false, err |
| + } |
| + if c.Destination != "" { |
| + defined = true |
| } |
| - if !pick(cfg) { |
| - return cliConfig{}, "", nil |
| + if pick(c) { |
| + cfg, src = c, path |
| + return true, nil |
| } |
| - return cfg, p, nil |
| + return false, nil |
| + } |
| + if p := os.Getenv("CARLOS_CONFIG"); p != "" { |
| + _, err := visit(p) |
| + return cfg, src, defined, err |
| } |
| 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 |
| + if done, err := visit(filepath.Join(dir, ".carlos", "config")); err != nil || done { |
| + return cfg, src, defined, err |
| } |
| parent := filepath.Dir(dir) |
| if parent == dir { |
| @@ -103,16 +189,10 @@ func walkConfig(pick func(cliConfig) bool) (cliConfig, string, error) { |
| } |
| path, perr := globalConfigPath() |
| if perr != nil { |
| - return cliConfig{}, "", nil |
| - } |
| - cfg, err := loadConfig(path) |
| - if err != nil { |
| - return cliConfig{}, "", err |
| + return cfg, src, defined, nil |
| } |
| - if !pick(cfg) { |
| - return cliConfig{}, "", nil |
| - } |
| - return cfg, path, nil |
| + _, err = visit(path) |
| + return cfg, src, defined, err |
| } |
| // configApp resolves the configured default APP: the nearest file whose |
| @@ -126,6 +206,17 @@ func configApp() (app, source, fileConsole string, err error) { |
| return cfg.App, src, cfg.Console, nil |
| } |
| +// configDestination names the destination block the configured console |
| +// came from, when the session is using that console ("" otherwise: a |
| +// --console to somewhere else is not "destination X"). |
| +func configDestination(console string) string { |
| + cfg, src, err := walkConfig(func(c cliConfig) bool { return c.Console != "" }) |
| + if err != nil || src == "" || cfg.Console != console { |
| + return "" |
| + } |
| + return cfg.Destination |
| +} |
| + |
| // 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 |
| @@ -140,6 +231,21 @@ func configDeploy() (cfg cliConfig, source string, err error) { |
| // config would ship to the wrong console, the exact mistake this file |
| // exists to prevent. The console comes back normalized ("" when unset); |
| // the account comes back trimmed. |
| +// |
| +// A file with a "destinations" block resolves to one of them, laid over |
| +// its top level: |
| +// |
| +// - a destination was selected and the file defines it: that block; |
| +// - a destination was selected and the file does NOT define it: the |
| +// file's preferences only (update_check, message_prompt) — its |
| +// console/app/deploy keys describe some other stack. Same for a file |
| +// with no destinations block at all. walkConfig refuses when no file |
| +// defines the selection; |
| +// - nothing selected: the file's "default" (an error if it names no |
| +// block), else its only block; |
| +// - nothing selected, no default, several blocks: an error naming them. |
| +// Guessing, or quietly using the top level, is the wrong deployment |
| +// again. |
| func loadConfig(path string) (cliConfig, error) { |
| raw, err := os.ReadFile(path) |
| if errors.Is(err, os.ErrNotExist) { |
| @@ -148,10 +254,26 @@ func loadConfig(path string) (cliConfig, error) { |
| if err != nil { |
| return cliConfig{}, err |
| } |
| - var cfg cliConfig |
| - if err := json.Unmarshal(raw, &cfg); err != nil { |
| + var file configFile |
| + if err := json.Unmarshal(raw, &file); err != nil { |
| return cliConfig{}, fmt.Errorf("%s is not readable as a carlos config: %w", path, err) |
| } |
| + cfg := file.cliConfig |
| + if name := selectedDestination(); name != "" { |
| + if block, ok := file.Destinations[name]; ok { |
| + cfg = overlayDestination(cfg, block) |
| + cfg.Destination = name |
| + } else { |
| + cfg = cliConfig{UpdateCheck: cfg.UpdateCheck, MessagePrompt: cfg.MessagePrompt} |
| + } |
| + } else if len(file.Destinations) > 0 { |
| + name, err := fileDefaultDestination(path, strings.TrimSpace(file.Default), mapKeys(file.Destinations)) |
| + if err != nil { |
| + return cliConfig{}, err |
| + } |
| + cfg = overlayDestination(cfg, file.Destinations[name]) |
| + cfg.Destination = name |
| + } |
| if strings.TrimSpace(cfg.Console) == "" { |
| cfg.Console = "" |
| } else { |
| @@ -171,6 +293,28 @@ func loadConfig(path string) (cliConfig, error) { |
| return cfg, nil |
| } |
| +// overlayDestination lays a destination block over the file's top level: |
| +// every key the block sets wins. Preferences (update_check, |
| +// message_prompt) are machine-wide in spirit and stay top-level only. |
| +func overlayDestination(top, block cliConfig) cliConfig { |
| + for _, kv := range []struct { |
| + dst *string |
| + v string |
| + }{ |
| + {&top.Console, block.Console}, |
| + {&top.Account, block.Account}, |
| + {&top.App, block.App}, |
| + {&top.Kind, block.Kind}, |
| + {&top.Artifact, block.Artifact}, |
| + {&top.Target, block.Target}, |
| + } { |
| + if strings.TrimSpace(kv.v) != "" { |
| + *kv.dst = kv.v |
| + } |
| + } |
| + return top |
| +} |
| + |
| // loadConfigFile is loadConfig's console-only view — the shape every |
| // console-default caller predating the account key still reads. |
| func loadConfigFile(path string) (string, error) { |
| @@ -190,50 +334,11 @@ func loadConfigFile(path string) (string, error) { |
| // $HOME entirely. |
| // |
| // CARLOS_CONFIG, when set, names the one file consulted and skips discovery |
| -// entirely — see walkConfig's identical override, which every other |
| -// per-key default (account, app, update_check) already goes through. An |
| -// unset CARLOS_CONFIG leaves this walk exactly as it was. |
| +// entirely — walkConfig's override, since this is walkConfig picking on |
| +// the console key. |
| func configDefault() (console, source string, err error) { |
| - if p := os.Getenv("CARLOS_CONFIG"); p != "" { |
| - c, err := loadConfigFile(p) |
| - if err != nil { |
| - return "", "", err |
| - } |
| - if c == "" { |
| - return "", "", nil |
| - } |
| - return c, p, nil |
| - } |
| - if dir, werr := os.Getwd(); werr == nil { |
| - for { |
| - path := filepath.Join(dir, ".carlos", "config") |
| - c, err := loadConfigFile(path) |
| - if err != nil { |
| - return "", "", err |
| - } |
| - if c != "" { |
| - return c, path, 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 |
| - } |
| - c, err := loadConfigFile(path) |
| - if err != nil { |
| - return "", "", err |
| - } |
| - if c == "" { |
| - return "", "", nil |
| - } |
| - return c, path, nil |
| + cfg, src, err := walkConfig(func(c cliConfig) bool { return c.Console != "" }) |
| + return cfg.Console, src, err |
| } |
| // configAccount resolves the configured default ACCOUNT: the nearest |
| @@ -295,6 +400,11 @@ func saveConfigAccount(path, console, account string, dirPerm os.FileMode) error |
| // every future config key lossy against the binary that predates it. |
| // Temp-file-then-rename like credentialFile.save, so a crash cannot leave |
| // a truncated config — but 0644, not 0600: this file is not a secret. |
| +// |
| +// Keys land where loadConfig would read them back: inside the selected |
| +// destination's block (created if missing), else inside the file's |
| +// default or only block, else at the top level. A file that cannot say |
| +// which block is refused, as it is on read. |
| func writeConfigKeys(path string, set map[string]any, dirPerm os.FileMode) error { |
| dir := filepath.Dir(path) |
| if err := os.MkdirAll(dir, dirPerm); err != nil { |
| @@ -308,8 +418,12 @@ func writeConfigKeys(path string, set map[string]any, dirPerm os.FileMode) error |
| } else if !errors.Is(err, os.ErrNotExist) { |
| return err |
| } |
| + into, err := configWriteTarget(path, all) |
| + if err != nil { |
| + return err |
| + } |
| for k, v := range set { |
| - all[k] = v |
| + into[k] = v |
| } |
| body, err := json.MarshalIndent(all, "", " ") |
| if err != nil { |
| @@ -334,3 +448,38 @@ func writeConfigKeys(path string, set map[string]any, dirPerm os.FileMode) error |
| } |
| return os.Rename(tmp.Name(), path) |
| } |
| + |
| +// configWriteTarget picks the object inside a parsed config that |
| +// writeConfigKeys should set keys on — see its comment for the rule. |
| +func configWriteTarget(path string, all map[string]any) (map[string]any, error) { |
| + var dests map[string]any |
| + if raw, ok := all["destinations"]; ok { |
| + if dests, ok = raw.(map[string]any); !ok { |
| + return nil, fmt.Errorf("%s: \"destinations\" is not an object", path) |
| + } |
| + } |
| + name := selectedDestination() |
| + if name == "" { |
| + if len(dests) == 0 { |
| + return all, nil |
| + } |
| + def, _ := all["default"].(string) |
| + var err error |
| + if name, err = fileDefaultDestination(path, strings.TrimSpace(def), mapKeys(dests)); err != nil { |
| + return nil, err |
| + } |
| + } |
| + if dests == nil { |
| + dests = map[string]any{} |
| + all["destinations"] = dests |
| + } |
| + block, ok := dests[name].(map[string]any) |
| + if !ok { |
| + if _, exists := dests[name]; exists { |
| + return nil, fmt.Errorf("%s: destination %q is not an object", path, name) |
| + } |
| + block = map[string]any{} |
| + dests[name] = block |
| + } |
| + return block, nil |
| +} |
diff --git a/config_test.go b/config_test.go| index 1089631c0..0dc9e251a 100644 |
| --- a/config_test.go |
| +++ b/config_test.go |
| @@ -49,6 +49,7 @@ func useIsolatedConfig(t *testing.T) { |
| t.Setenv("CARLOS_CONFIG", filepath.Join(t.TempDir(), "config")) |
| t.Setenv("CARLOS_CONSOLE", "") |
| t.Setenv("CARLOS_CREDENTIALS", "") |
| + t.Setenv("CARLOS_DESTINATION", "") |
| } |
| func TestLoadConfigFile(t *testing.T) { |
diff --git a/deploy.go b/deploy.go| index fd6e090b4..c13a0ace3 100644 |
| --- a/deploy.go |
| +++ b/deploy.go |
| @@ -38,7 +38,7 @@ func runDeploy(args []string) { |
| messageFlags := addMutationMessageFlags(fs) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to deploy through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to deploy through (API mode only; default: the only logged-in one)") |
| kind := fs.String("kind", "binary", "binary | static") |
| ver := versionFlag(fs, "version (default: git rev-parse --short HEAD)") |
| target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)") |
| @@ -59,9 +59,9 @@ func runDeploy(args []string) { |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| 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>] " + |
| + "[--account <sqid-or-name>] [--account-id <sqid>] [--console <origin> | -d <destination>] [--capabilities <file.json>] " + |
| "[--channel <ch>] [--host <host>] [--label <text>] [--notes <text>] [--timeout 120s] <files-or-site-dir>\n" + |
| - "or, in a project with saved defaults (or at a terminal, to create them): carlos deploy" |
| + "or, in a project with saved defaults (or at a terminal, to create them): carlos deploy [-d <destination>]" |
| if *ver == "" { |
| *ver = gitShortHead() |
| } |
| @@ -149,7 +149,11 @@ func runDeploy(args []string) { |
| if !flagWasSet(fs, "target") && dcfg.Target != "" { |
| *target = dcfg.Target |
| } |
| - mutedf(stdout, "deploying %s (%s — the defaults from %s)", artifact, *kind, dsrc) |
| + from := dsrc |
| + if dcfg.Destination != "" { |
| + from = "the " + dcfg.Destination + " destination in " + dsrc |
| + } |
| + mutedf(stdout, "deploying %s (%s — the defaults from %s)", artifact, *kind, from) |
| } else if interactiveTerminal() { |
| var k, tgt string |
| artifact, k, tgt, err = deployArtifactWizard() |
diff --git a/destinations_test.go b/destinations_test.go| new file mode 100644 |
| index 000000000..14cbfa5c8 |
| --- /dev/null |
| +++ b/destinations_test.go |
| @@ -0,0 +1,308 @@ |
| +package main |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// selectDestination sets --destination for one test, and puts it back. |
| +func selectDestination(t *testing.T, name string) { |
| + t.Helper() |
| + prev := destinationFlag |
| + destinationFlag = name |
| + t.Cleanup(func() { destinationFlag = prev }) |
| +} |
| + |
| +const twoDestinations = `{ |
| + "artifact": "./bin/server", |
| + "update_check": "off", |
| + "destinations": { |
| + "exciting": {"console": "https://carlos.exciting.example/", "app": "shop"}, |
| + "tito": {"console": "https://tito.example", "app": "shop-tito", "artifact": "./dist"} |
| + } |
| +}` |
| + |
| +// Selection, the file's default, the only block, and the refusals — each |
| +// resolved through loadConfig, which every per-key default reads. |
| +func TestLoadConfigDestinations(t *testing.T) { |
| + useIsolatedConfig(t) |
| + dir := t.TempDir() |
| + p := writeConfig(t, dir, twoDestinations) |
| + |
| + // Several blocks, nothing selected, no default: refused, naming them. |
| + if _, err := loadConfig(p); err == nil || !strings.Contains(err.Error(), "exciting, tito") { |
| + t.Fatalf("ambiguous file = %v, want a refusal naming both destinations", err) |
| + } |
| + |
| + // Selected: the block wins, the top level fills what it leaves out, |
| + // and preferences stay top-level. |
| + selectDestination(t, "exciting") |
| + cfg, err := loadConfig(p) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if cfg.Console != "https://carlos.exciting.example" || cfg.App != "shop" || |
| + cfg.Artifact != "./bin/server" || cfg.UpdateCheck != "off" || cfg.Destination != "exciting" { |
| + t.Fatalf("exciting = %+v", cfg) |
| + } |
| + selectDestination(t, "tito") |
| + if cfg, _ = loadConfig(p); cfg.Artifact != "./dist" || cfg.App != "shop-tito" { |
| + t.Fatalf("tito's own artifact must beat the top level: %+v", cfg) |
| + } |
| + |
| + // Selected but not defined here: this file describes other stacks, so |
| + // only its preferences survive. |
| + selectDestination(t, "staging") |
| + if cfg, err = loadConfig(p); err != nil || cfg.Console != "" || cfg.App != "" || |
| + cfg.Artifact != "" || cfg.UpdateCheck != "off" || cfg.Destination != "" { |
| + t.Fatalf("undefined here = %+v, %v; want preferences only", cfg, err) |
| + } |
| + // Same for a flat file with no destinations at all. |
| + flat := writeConfig(t, t.TempDir(), `{"console": "https://flat.example", "app": "x"}`) |
| + if cfg, err = loadConfig(flat); err != nil || cfg.Console != "" || cfg.App != "" { |
| + t.Fatalf("flat file under a selection = %+v, %v; want nothing", cfg, err) |
| + } |
| + |
| + // Nothing selected: the file's default. |
| + selectDestination(t, "") |
| + var withDefault map[string]any |
| + json.Unmarshal([]byte(twoDestinations), &withDefault) |
| + withDefault["default"] = "tito" |
| + body, _ := json.Marshal(withDefault) |
| + os.WriteFile(p, body, 0o644) |
| + if cfg, err = loadConfig(p); err != nil || cfg.Destination != "tito" || cfg.Console != "https://tito.example" { |
| + t.Fatalf("default = %+v, %v", cfg, err) |
| + } |
| + // ...which the flag overrides. |
| + selectDestination(t, "exciting") |
| + if cfg, _ = loadConfig(p); cfg.Destination != "exciting" { |
| + t.Fatalf("flag over default = %+v", cfg) |
| + } |
| + // A default naming no block is an error, not a fall-through. |
| + selectDestination(t, "") |
| + withDefault["default"] = "tit" |
| + body, _ = json.Marshal(withDefault) |
| + os.WriteFile(p, body, 0o644) |
| + if _, err = loadConfig(p); err == nil || !strings.Contains(err.Error(), `"tit"`) { |
| + t.Fatalf("bad default = %v, want an error naming it", err) |
| + } |
| + |
| + // Exactly one block needs no default. |
| + os.WriteFile(p, []byte(`{"destinations": {"only": {"console": "https://only.example"}}}`), 0o644) |
| + if cfg, err = loadConfig(p); err != nil || cfg.Destination != "only" || cfg.Console != "https://only.example" { |
| + t.Fatalf("single block = %+v, %v", cfg, err) |
| + } |
| + |
| + // A block's console is validated like a top-level one. |
| + os.WriteFile(p, []byte(`{"destinations": {"bad": {"console": "ftp://nope"}}}`), 0o644) |
| + if _, err = loadConfig(p); err == nil || !strings.Contains(err.Error(), p) { |
| + t.Fatalf("bad block console = %v, want an error naming %s", err, p) |
| + } |
| +} |
| + |
| +// A selected destination that no file defines is refused by the walk — |
| +// never "no default", which would fall through to whatever single |
| +// credential this machine holds. The walk still finds a destination |
| +// defined only in the global file past a project file that lacks it. |
| +func TestWalkRefusesUndefinedDestination(t *testing.T) { |
| + home := t.TempDir() |
| + t.Setenv("HOME", home) |
| + t.Setenv("CARLOS_CONFIG", "") |
| + t.Setenv("CARLOS_CONSOLE", "") |
| + t.Setenv("CARLOS_DESTINATION", "") |
| + project := filepath.Join(home, "project") |
| + writeConfig(t, project, `{"destinations": {"tito": {"console": "https://tito.example"}}}`) |
| + t.Chdir(project) |
| + |
| + selectDestination(t, "titi") |
| + if _, _, err := configDefault(); err == nil || !strings.Contains(err.Error(), `"titi"`) { |
| + t.Fatalf("typo'd destination = %v, want a refusal naming it", err) |
| + } |
| + // The environment variable selects too. |
| + selectDestination(t, "") |
| + t.Setenv("CARLOS_DESTINATION", "titi") |
| + if _, _, err := configDefault(); err == nil { |
| + t.Fatal("CARLOS_DESTINATION typo must be refused too") |
| + } |
| + |
| + writeConfig(t, home, `{"destinations": {"titi": {"console": "https://global.example"}}}`) |
| + if c, _, err := configDefault(); err != nil || c != "https://global.example" { |
| + t.Fatalf("global-only destination = %q %v", c, err) |
| + } |
| + t.Setenv("CARLOS_DESTINATION", "") |
| + selectDestination(t, "tito") |
| + if c, _, err := configDefault(); err != nil || c != "https://tito.example" { |
| + t.Fatalf("project destination = %q %v", c, err) |
| + } |
| +} |
| + |
| +// Saves land where the read resolved: the selected block, else the |
| +// default/only block, else the top level. |
| +func TestWriteConfigKeysIntoDestination(t *testing.T) { |
| + useIsolatedConfig(t) |
| + p := writeConfig(t, t.TempDir(), twoDestinations) |
| + read := func() map[string]any { |
| + t.Helper() |
| + raw, _ := os.ReadFile(p) |
| + var m map[string]any |
| + if err := json.Unmarshal(raw, &m); err != nil { |
| + t.Fatal(err) |
| + } |
| + return m |
| + } |
| + block := func(m map[string]any, name string) map[string]any { |
| + return m["destinations"].(map[string]any)[name].(map[string]any) |
| + } |
| + |
| + if err := writeConfigKeys(p, map[string]any{"account": "a"}, 0o755); err == nil { |
| + t.Fatal("several blocks and no selection must refuse the write") |
| + } |
| + |
| + selectDestination(t, "tito") |
| + if err := saveConfigAccount(p, "https://tito.example", "tito-acct", 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + m := read() |
| + if block(m, "tito")["account"] != "tito-acct" || m["account"] != nil { |
| + t.Fatalf("selected write landed wrong: %v", m) |
| + } |
| + if block(m, "exciting")["app"] != "shop" || m["update_check"] != "off" { |
| + t.Fatalf("unrelated keys lost: %v", m) |
| + } |
| + |
| + // A new name creates its block. |
| + selectDestination(t, "staging") |
| + if err := writeConfigKeys(p, map[string]any{"app": "shop-staging"}, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if block(read(), "staging")["app"] != "shop-staging" { |
| + t.Fatal("new destination block not created") |
| + } |
| + |
| + // No selection, a default: into the default. |
| + selectDestination(t, "") |
| + m = read() |
| + m["default"] = "exciting" |
| + body, _ := json.Marshal(m) |
| + os.WriteFile(p, body, 0o644) |
| + if err := writeConfigKeys(p, map[string]any{"account": "ex"}, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if block(read(), "exciting")["account"] != "ex" { |
| + t.Fatal("default-block write landed wrong") |
| + } |
| + |
| + // A flat file stays flat. |
| + flat := writeConfig(t, t.TempDir(), `{"console": "https://flat.example"}`) |
| + if err := writeConfigKeys(flat, map[string]any{"app": "x"}, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _ := os.ReadFile(flat) |
| + if strings.Contains(string(raw), "destinations") || !strings.Contains(string(raw), `"app": "x"`) { |
| + t.Fatalf("flat write = %s", raw) |
| + } |
| +} |
| + |
| +// -d and --destination both select, and a second flag set built later in |
| +// the same run (deploy re-running auth login) does not clear the choice. |
| +func TestDestinationFlag(t *testing.T) { |
| + for _, args := range [][]string{{"-d", "tito"}, {"--destination", "tito"}, {"-destination=tito"}, {"--d=tito"}} { |
| + selectDestination(t, "") |
| + fs := quietFlagSet("t") |
| + consoleOption(fs, "") |
| + if err := fs.Parse(args); err != nil { |
| + t.Fatalf("%v: %v", args, err) |
| + } |
| + if selectedDestination() != "tito" { |
| + t.Fatalf("%v: selected %q", args, selectedDestination()) |
| + } |
| + consoleOption(quietFlagSet("again"), "") |
| + if selectedDestination() != "tito" { |
| + t.Fatalf("%v: a later flag set cleared the destination", args) |
| + } |
| + } |
| +} |
| + |
| +// End to end through the real binary: two consoles, one checkout. -d picks |
| +// the console, the default answers a bare run, and the "using" line names |
| +// the destination. Each console refuses the deploy at its instance list |
| +// (none), which is enough to prove which one was asked. |
| +func TestDeployDestinationsSubprocess(t *testing.T) { |
| + bin := buildCarlos(t) |
| + servers := map[string]string{} |
| + for _, name := range []string{"exciting", "tito"} { |
| + servers[name] = zeroInstanceConsole(t, "acct-"+name, "app-"+name) |
| + } |
| + project := t.TempDir() |
| + cfg := map[string]any{ |
| + "default": "exciting", |
| + "kind": "binary", |
| + "artifact": "./bin/server", |
| + "destinations": map[string]any{ |
| + "exciting": map[string]any{"console": servers["exciting"], "account": "acct-exciting", "app": "app-exciting"}, |
| + "tito": map[string]any{"console": servers["tito"], "account": "acct-tito", "app": "app-tito"}, |
| + }, |
| + } |
| + body, _ := json.Marshal(cfg) |
| + writeConfig(t, project, string(body)) |
| + os.MkdirAll(filepath.Join(project, "bin"), 0o755) |
| + os.WriteFile(filepath.Join(project, "bin", "server"), []byte("bin"), 0o755) |
| + credPath := filepath.Join(t.TempDir(), "credentials") |
| + creds := `{"version":1,"credentials":[` + |
| + `{"console":"` + servers["exciting"] + `","address":"me@exciting","token":"t1"},` + |
| + `{"console":"` + servers["tito"] + `","address":"me@tito","token":"t2"}]}` |
| + os.WriteFile(credPath, []byte(creds), 0o600) |
| + |
| + run := func(args ...string) string { |
| + t.Helper() |
| + cmd := exec.Command(bin, append([]string{"deploy", "-version", "x1"}, args...)...) |
| + cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+credPath) |
| + cmd.Dir = project |
| + out, err := cmd.CombinedOutput() |
| + if err == nil { |
| + t.Fatalf("%v: no-instances fixture must refuse, got success:\n%s", args, out) |
| + } |
| + return string(out) |
| + } |
| + for _, c := range []struct { |
| + args []string |
| + want string |
| + }{ |
| + {nil, "exciting"}, |
| + {[]string{"-d", "tito"}, "tito"}, |
| + {[]string{"--destination", "exciting"}, "exciting"}, |
| + } { |
| + out := run(c.args...) |
| + if !strings.Contains(out, "using "+servers[c.want]+" as me@"+c.want+" (destination "+c.want+")") || |
| + !strings.Contains(out, "the "+c.want+" destination in") || |
| + !strings.Contains(out, "no instances") { |
| + t.Fatalf("%v: want destination %s, got:\n%s", c.args, c.want, out) |
| + } |
| + } |
| + if out := run("-d", "staging"); !strings.Contains(out, `destination "staging"`) { |
| + t.Fatalf("undefined destination = %s", out) |
| + } |
| +} |
| + |
| +// zeroInstanceConsole is a fake console that knows one account owning one |
| +// app with no instances — deploy's first refusal after resolution. |
| +func zeroInstanceConsole(t *testing.T, account, app string) string { |
| + t.Helper() |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("GET /api/cli/apps", func(w http.ResponseWriter, r *http.Request) { |
| + fmt.Fprintf(w, `{"accounts":[{"sqid":"s-%s","name":%q,"apps":[%q]}]}`, account, account, app) |
| + }) |
| + mux.HandleFunc("GET /api/cli/apps/", func(w http.ResponseWriter, r *http.Request) { |
| + w.Write([]byte(`{"instances":[]}`)) |
| + }) |
| + ts := httptest.NewServer(mux) |
| + t.Cleanup(ts.Close) |
| + return ts.URL |
| +} |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index dba6553c5..1efd9a9f8 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -206,6 +206,39 @@ having no instances to resolve a channel from. |
| carlos deploy --app website --kind static --host www.example.com ./dist |
| ``` |
| +#### Deploying one checkout to several places |
| + |
| +When one project deploys to more than one console, or as more than one app, |
| +name each place in `.carlos/config` under `destinations` and pick one with |
| +`--destination` (`-d` for short): |
| + |
| +```json |
| +{ |
| + "default": "production", |
| + "artifact": "./bin/server", |
| + "destinations": { |
| + "production": {"console": "https://carlos.example.com", "account": "me@example.com", "app": "shop"}, |
| + "partner": {"console": "https://carlos.example.org", "app": "shop", "target": "linux-amd64"} |
| + } |
| +} |
| +``` |
| + |
| +```sh |
| +carlos deploy -d partner |
| +carlos logs -d partner |
| +``` |
| + |
| +Each destination takes the same keys as the top of the file, and any key it |
| +leaves out comes from the top of the file. Here both destinations ship |
| +`./bin/server`. Every command that takes `--console` also takes |
| +`--destination`, and `CARLOS_DESTINATION` does the same job for a whole |
| +shell. With no destination named, the file's `default` applies. A file with |
| +only one destination needs no default. A file with several destinations and |
| +no default refuses to guess, and so does a destination name that no config |
| +file defines. Saved answers go into the destination you are using. |
| +`--target` is unrelated: it names the platform (GOOS-GOARCH) a binary was |
| +built for. |
| + |
| ### carlos canary |
| Deploy onto a host and a channel belonging to this branch alone, so nothing |
diff --git a/domains_cli.go b/domains_cli.go| index 04b130ea1..569514ea0 100644 |
| --- a/domains_cli.go |
| +++ b/domains_cli.go |
| @@ -87,7 +87,7 @@ func runDomainsAttach(args []string) { |
| route := fs.String("route", "", "route hostname to attach to") |
| 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)") |
| + consoleFlag := consoleOption(fs, "console to attach through (default: the only logged-in one)") |
| 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.Usage = flagUsage(fs, domainsAttachUsage) |
| @@ -181,7 +181,7 @@ func printDomainDNSLine(ctx context.Context, sess *apiSession, acct apiAcct, app |
| func runDomainsDetach(args []string) { |
| fs := flag.NewFlagSet("domains detach", flag.ExitOnError) |
| app := appFlag(fs, "app the hostname belongs to") |
| - consoleFlag := fs.String("console", "", "console to detach through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to detach through (default: the only logged-in one)") |
| 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.Usage = flagUsage(fs, domainsDetachUsage) |
| @@ -208,7 +208,7 @@ func runDomainsDetach(args []string) { |
| func runDomainsList(args []string) { |
| fs := flag.NewFlagSet("domains list", flag.ExitOnError) |
| app := appFlag(fs, "app whose hostname claims to list") |
| - consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to list through (default: the only logged-in one)") |
| 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.Usage = flagUsage(fs, domainsListUsage) |
| @@ -291,7 +291,7 @@ func runDomainsList(args []string) { |
| func runDomainsPaths(args []string) { |
| fs := flag.NewFlagSet("domains paths", flag.ExitOnError) |
| app := appFlag(fs, "app the hostname belongs to") |
| - consoleFlag := fs.String("console", "", "console to read through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to read through (default: the only logged-in one)") |
| 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.Usage = flagUsage(fs, domainsPathsUsage) |
diff --git a/e2e_test.go b/e2e_test.go| index 1a30a9d32..abe518b90 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -86,6 +86,7 @@ func carlosTestEnv(t *testing.T, extra ...string) []string { |
| "CARLOS_CREDENTIALS="+filepath.Join(scratch, "credentials"), |
| "HOME="+scratch, |
| "CARLOS_CONSOLE=", |
| + "CARLOS_DESTINATION=", |
| ) |
| return append(env, extra...) |
| } |
| @@ -945,6 +946,7 @@ func TestAccountsMigrateTakesTheLiveObjects(t *testing.T) { |
| "CARLOS_RELEASE_PUBKEY="+pubB64, |
| "HOME="+tmp, |
| "CARLOS_CONSOLE=", |
| + "CARLOS_DESTINATION=", |
| ) |
| run := func(args ...string) (string, error) { |
| t.Helper() |
| @@ -1101,6 +1103,7 @@ func TestAccountsMigrateDoesNotCopyTheProductionFlag(t *testing.T) { |
| "CARLOS_RELEASE_PUBKEY="+pubB64, |
| "HOME="+tmp, |
| "CARLOS_CONSOLE=", |
| + "CARLOS_DESTINATION=", |
| ) |
| run := func(args ...string) (string, error) { |
| t.Helper() |
| @@ -1615,6 +1618,7 @@ func TestAccountsMigrateLegacyIntoOps(t *testing.T) { |
| "CARLOS_RELEASE_PUBKEY="+pubB64, |
| "HOME="+tmp, |
| "CARLOS_CONSOLE=", |
| + "CARLOS_DESTINATION=", |
| ) |
| run := func(args ...string) (string, error) { |
| t.Helper() |
| @@ -2243,6 +2247,7 @@ func TestRouteCLI(t *testing.T) { |
| "CARLOS_RELEASE_PUBKEY="+pubB64, |
| "HOME="+tmp, |
| "CARLOS_CONSOLE=", |
| + "CARLOS_DESTINATION=", |
| ) |
| run := func(args ...string) (string, error) { |
| t.Helper() |
| @@ -2569,6 +2574,7 @@ func (f *routeTestFixture) fullEnv() []string { |
| "CARLOS_RELEASE_PUBKEY="+f.pubB64, |
| "HOME="+f.tmpDir, |
| "CARLOS_CONSOLE=", |
| + "CARLOS_DESTINATION=", |
| ) |
| } |
| @@ -3556,6 +3562,7 @@ func TestAccountsMigrateLegacyKeepsNewerQualifiedPointer(t *testing.T) { |
| "CARLOS_RELEASE_PUBKEY="+pubB64, |
| "HOME="+tmp, |
| "CARLOS_CONSOLE=", |
| + "CARLOS_DESTINATION=", |
| ) |
| run := func(args ...string) (string, error) { |
| t.Helper() |
diff --git a/economics.go b/economics.go| index e80a62ba5..2129431bb 100644 |
| --- a/economics.go |
| +++ b/economics.go |
| @@ -190,7 +190,7 @@ func (s *apiSession) economicsBackhaul(ctx context.Context, period string) (back |
| func runEconomicsBackhaul(args []string) { |
| fs := flag.NewFlagSet("economics backhaul", flag.ExitOnError) |
| period := fs.String("period", "", "mtd (default), last, or 7d") |
| - consoleFlag := fs.String("console", "", "console to ask (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to ask (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, economicsBackhaulUsage) |
| fs.Parse(args) |
| sess, err := resolveMode(*consoleFlag) |
| @@ -278,7 +278,7 @@ func runEconomicsBill(args []string) { |
| total := fs.Float64("total", 0, "invoice total in US dollars (required)") |
| lines := serviceLines{} |
| fs.Var(lines, "line", "one line item, repeatable: Service=usd") |
| - consoleFlag := fs.String("console", "", "console to record through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to record through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, economicsUsage) |
| fs.Parse(args) |
diff --git a/errors_cmd.go b/errors_cmd.go| index 904f031dd..3b8991c08 100644 |
| --- a/errors_cmd.go |
| +++ b/errors_cmd.go |
| @@ -36,7 +36,7 @@ func runErrors(args []string) { |
| rel := fs.String("release", "", "only one release") |
| fp := fs.String("fp", "", "expand one group: its occurrences, oldest first") |
| setLevel := fs.String("set-level", "", "set the app's log level for errors: error | warn") |
| - consoleFlag := fs.String("console", "", "console to read through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to read through (default: the only logged-in one)") |
| 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.Usage = flagUsage(fs, errorsUsage) |
diff --git a/features_cli.go b/features_cli.go| index 5b9d9e133..b38115af1 100644 |
| --- a/features_cli.go |
| +++ b/features_cli.go |
| @@ -95,7 +95,7 @@ func runFeaturesSet(args []string) { |
| fs := flag.NewFlagSet("features set", flag.ExitOnError) |
| messageFlags := addMutationMessageFlags(fs) |
| app := fs.String("app", "", "app whose feature flags to set") |
| - consoleFlag := fs.String("console", "", "console to set through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to set through (default: the only logged-in one)") |
| 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) |
| @@ -129,7 +129,7 @@ func runFeaturesSet(args []string) { |
| func runFeaturesList(args []string) { |
| fs := flag.NewFlagSet("features list", flag.ExitOnError) |
| app := fs.String("app", "", "app whose feature flags to list") |
| - consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to list through (default: the only logged-in one)") |
| 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) |
diff --git a/flags.go b/flags.go| index 146f6bd26..b9495d604 100644 |
| --- a/flags.go |
| +++ b/flags.go |
| @@ -7,8 +7,8 @@ import ( |
| ) |
| // flags.go is the carlos CLI's face of internal/cliflag (the spec's |
| -// claimed short-flag namespace: -a app, -c channel, -e environment, |
| -// -v version). The four helpers below are the only way the aliased |
| +// claimed short-flag namespace: -a app, -c channel, -d destination, |
| +// -e environment, -v version). The four helpers below are the only way the aliased |
| // flags are registered in this package; sites with something bespoke to |
| // say pass it through, and an empty usage takes the flag's common |
| // wording. The table itself, the registrar, and the usage printer live |
| @@ -31,6 +31,29 @@ func flagUsage(fs *flag.FlagSet, header string) func() { |
| return cliflag.Usage(fs, header) |
| } |
| +// consoleOption registers --console, and with it --destination / -d: |
| +// every verb that can be pointed at a console can equally be pointed at a |
| +// named destination in .carlos/config, which is where its console comes |
| +// from. The destination lands in the package-level destinationFlag that |
| +// loadConfig reads (config.go). |
| +func consoleOption(fs *flag.FlagSet, usage string) *string { |
| + c := fs.String("console", "", usage) |
| + fs.Var(destinationValue{}, "destination", |
| + "use the `name`d block from .carlos/config's destinations (default: CARLOS_DESTINATION, else the file's default or only one)") |
| + fs.Var(destinationValue{}, cliAliases["destination"], "alias for --destination") |
| + return c |
| +} |
| + |
| +// destinationValue writes straight to destinationFlag. A flag.Value rather |
| +// than fs.StringVar because StringVar assigns the default at registration: |
| +// a verb that builds a second flag set mid-run (deploy re-running auth |
| +// login on a moved console) would otherwise wipe the destination the |
| +// operator chose. |
| +type destinationValue struct{} |
| + |
| +func (destinationValue) String() string { return destinationFlag } |
| +func (destinationValue) Set(v string) error { destinationFlag = v; return nil } |
| + |
| func appFlag(fs *flag.FlagSet, usage string) *string { |
| if usage == "" { |
| usage = "app name" |
diff --git a/fleets.go b/fleets.go| index b3e354928..aaa7c2423 100644 |
| --- a/fleets.go |
| +++ b/fleets.go |
| @@ -158,7 +158,7 @@ func runFleetsCreate(args []string) { |
| bucket := fs.String("bucket", "", "the customer's own data bucket (must already exist)") |
| acctRaw := fs.String("account", "", "account (sqid or name) to own the fleet (default: your only account, or the configured/chosen one)") |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| - consoleFlag := fs.String("console", "", "console to create it through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to create it through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if fs.NArg() != 1 || strings.TrimSpace(*bucket) == "" { |
| @@ -186,7 +186,7 @@ func runFleetsAddBox(args []string) { |
| fs := flag.NewFlagSet("fleets add-box", flag.ExitOnError) |
| acctRaw := fs.String("account", "", "account (sqid or name) the fleet 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") |
| - consoleFlag := fs.String("console", "", "console to add the box through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to add the box through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if fs.NArg() != 2 { |
| @@ -215,7 +215,7 @@ func runFleetsRotateToken(args []string) { |
| fs := flag.NewFlagSet("fleets rotate-token", flag.ExitOnError) |
| acctRaw := fs.String("account", "", "account (sqid or name) the fleet 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") |
| - consoleFlag := fs.String("console", "", "console to rotate through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to rotate through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if fs.NArg() != 2 { |
| @@ -279,7 +279,7 @@ func runFleetsTagBox(args []string) { |
| fs := flag.NewFlagSet("fleets tag-box", flag.ExitOnError) |
| acctRaw := fs.String("account", "", "account (sqid or name) the fleet 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") |
| - consoleFlag := fs.String("console", "", "console to tag through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to tag through (default: the only logged-in one)") |
| tags := fs.String("tags", "", `comma-separated tags, full replace ("" clears every tag) — required`) |
| const tagBoxUsage = "usage: carlos fleets tag-box --tags <tags> [--account <sqid|name>] [--account-id <sqid>] " + |
| "[--console <origin>] <fleet> <label>" |
| @@ -326,7 +326,7 @@ func runFleetsDetachBox(args []string) { |
| fs := flag.NewFlagSet("fleets detach-box", flag.ExitOnError) |
| acctRaw := fs.String("account", "", "account (sqid or name) the fleet 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") |
| - consoleFlag := fs.String("console", "", "console to detach through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to detach through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if fs.NArg() != 2 { |
| @@ -354,7 +354,7 @@ func runFleetsList(args []string) { |
| fs := flag.NewFlagSet("fleets ls", flag.ExitOnError) |
| acctRaw := fs.String("account", "", "account (sqid or name) to list fleets for (default: your only account, or the configured/chosen one)") |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| - consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to list through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
diff --git a/gate_cli.go b/gate_cli.go| index 612baed23..6179c6f26 100644 |
| --- a/gate_cli.go |
| +++ b/gate_cli.go |
| @@ -108,7 +108,7 @@ func gateFlags(name string) (*flag.FlagSet, *string, *string, *string, *string, |
| fs := flag.NewFlagSet("gate "+name, flag.ExitOnError) |
| app := appFlag(fs, "app whose instances to gate") |
| host := fs.String("host", "", "one instance host (default: every instance of the app)") |
| - consoleFlag := fs.String("console", "", "console to act through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)") |
| 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") |
| return fs, app, host, consoleFlag, acctRaw, acctIDRaw |
diff --git a/instances_cli.go b/instances_cli.go| index 80707979c..ad4fdc5f9 100644 |
| --- a/instances_cli.go |
| +++ b/instances_cli.go |
| @@ -59,7 +59,7 @@ func runInstancesEnable(args []string) { |
| channel := channelFlag(fs, "release channel stamped into every record at create (store-mode default stable; signed-in mode: omitted preserves the manifest's)") |
| regions := fs.String("regions", "", "comma-separated region allow-list (store mode only); empty = any shape-valid region") |
| seed := fs.String("seed", "", "argv of the app's first-boot init command, whitespace-split (strings.Fields)") |
| - consoleFlag := fs.String("console", "", "console to enable through (signed-in mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to enable through (signed-in mode only; default: the only logged-in one)") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| fs.Usage = flagUsage(fs, instancesEnableUsage) |
| fs.Parse(args) |
| @@ -271,7 +271,7 @@ func runInstancesCreate(args []string) { |
| addr := fs.String("addr", "", "comma-separated TCP upstream set, host:port each (e.g. 10.0.0.1:8090,10.0.0.2:8090); omitted means the platform's own default upstream") |
| healthPath := fs.String("health-path", "", "states the whole probe alongside --health-status: request path (default \"/\"); honoured for a single --addr too") |
| healthStatus := fs.Int("health-status", 0, "probe's expected response status (default 200); fields neither flag names take the platform's own defaults") |
| - consoleFlag := fs.String("console", "", "console to create through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to create through (default: the only logged-in one)") |
| 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.Usage = flagUsage(fs, instancesRecordsUsage) |
| @@ -357,7 +357,7 @@ func runInstancesList(args []string) { |
| app := appFlag(fs, "app whose instance records to list") |
| match := fs.String("match", "", "only hosts containing this substring") |
| health := fs.String("health", "", "only this state: running, serving, setting-up, asleep, no-readings, not-responding, failed") |
| - consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to list through (default: the only logged-in one)") |
| 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.Usage = flagUsage(fs, instancesRecordsUsage) |
| @@ -398,7 +398,7 @@ func runInstancesDelete(args []string) { |
| messageFlags := addMutationMessageFlags(fs) |
| app := appFlag(fs, "app the instance belongs to") |
| host := fs.String("host", "", "instance host whose record to retire") |
| - consoleFlag := fs.String("console", "", "console to delete through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to delete through (default: the only logged-in one)") |
| 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.Usage = flagUsage(fs, instancesRecordsUsage) |
| @@ -443,7 +443,7 @@ func runInstancesSetUpstreams(args []string) { |
| addrsFlag := fs.String("addrs", "", "comma-separated TCP upstream set, host:port each, replacing the record's whole upstream set; omitted leaves the existing set alone; an explicit --addrs \"\" clears it (400 for a kind:instance record — the DerivesPaths rail refuses that shape transition in place)") |
| healthPath := fs.String("health-path", "", "states the WHOLE probe block, replacing whatever was there — not a per-field update; a field neither flag names reverts to the platform's own default") |
| healthStatus := fs.Int("health-status", 0, "probe's expected response status; either --health-path or --health-status, given at all, replaces the whole probe") |
| - consoleFlag := fs.String("console", "", "console to update through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to update through (default: the only logged-in one)") |
| 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.Usage = flagUsage(fs, instancesRecordsUsage) |
| @@ -545,7 +545,7 @@ func runSteering(args []string) { |
| fs := flag.NewFlagSet("steering", flag.ExitOnError) |
| app := appFlag(fs, "app the instance belongs to") |
| host := fs.String("host", "", "instance host to steer") |
| - consoleFlag := fs.String("console", "", "console to act through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)") |
| 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.Usage = flagUsage(fs, steeringUsage) |
| @@ -593,7 +593,7 @@ func runInstancesSetChannel(args []string) { |
| app := appFlag(fs, "app the instance belongs to") |
| host := fs.String("host", "", "instance host whose channel to change") |
| channel := channelFlag(fs, "release channel the route should follow") |
| - consoleFlag := fs.String("console", "", "console to act through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)") |
| 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.Usage = flagUsage(fs, instancesRecordsUsage) |
| @@ -668,7 +668,7 @@ func runInstancesSetEnvironment(args []string) { |
| app := appFlag(fs, "app the instance belongs to") |
| host := fs.String("host", "", "instance host whose config binding to change") |
| environment := fs.String("environment", "", "named config bundle to bind to; empty returns the route to the app's default bundle") |
| - consoleFlag := fs.String("console", "", "console to act through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)") |
| 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.Usage = flagUsage(fs, instancesRecordsUsage) |
diff --git a/internal/cliflag/cliflag.go b/internal/cliflag/cliflag.go| index ccb98279f..43b019388 100644 |
| --- a/internal/cliflag/cliflag.go |
| +++ b/internal/cliflag/cliflag.go |
| @@ -13,14 +13,16 @@ import ( |
| "strings" |
| ) |
| -// Aliases maps a primary flag name to its short alias. Four letters, |
| -// and nothing else: -vv is reserved for a future verbose mode so it can |
| +// Aliases maps a primary flag name to its short alias. Five letters, |
| +// and nothing else (-d for --destination joined the original four |
| +// in 2026-09): -vv is reserved for a future verbose mode so it can |
| // never quietly claim -v and collide with --version; -console stays |
| // long-only by ruling; -f, -n, -y are claimed by logs/update as |
| // primaries and are not aliases. |
| var Aliases = map[string]string{ |
| "app": "a", |
| "channel": "c", |
| + "destination": "d", |
| "environment": "e", |
| "version": "v", |
| } |
diff --git a/logs.go b/logs.go| index ce149e6e4..6d62489de 100644 |
| --- a/logs.go |
| +++ b/logs.go |
| @@ -48,7 +48,7 @@ func runLogs(args []string) { |
| fs.Var(&fields, "field", "only structured lines with this k=v field (repeatable)") |
| n := fs.Int("n", 0, "at most this many lines (default 1000, max 5000)") |
| follow := fs.Bool("f", false, "follow: poll for new lines until interrupted") |
| - consoleFlag := fs.String("console", "", "console to read through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to read through (default: the only logged-in one)") |
| 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.Usage = flagUsage(fs, logsUsage) |
diff --git a/mail_cli.go b/mail_cli.go| index 2f9110ff2..7cc800ae1 100644 |
| --- a/mail_cli.go |
| +++ b/mail_cli.go |
| @@ -620,7 +620,7 @@ func runEmailEnable(args []string) { |
| domain := fs.String("domain", "", "sending domain to ensure and verify (default: the domain half of --from)") |
| timeout := fs.Duration("timeout", 10*time.Minute, "how long to wait for domain verification before giving up") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to enable through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to enable through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -733,7 +733,7 @@ func runEmailDomainsAdd(args []string) { |
| app := appFlag(fs, "app to add the domain for") |
| timeout := fs.Duration("timeout", 10*time.Minute, "how long to wait for verification before giving up") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos email domains add --app <a> [--timeout <dur>] [--account <sqid-or-name>] " + |
| @@ -795,7 +795,7 @@ func runEmailStatus(args []string) { |
| fs := flag.NewFlagSet("email status", flag.ExitOnError) |
| app := appFlag(fs, "app whose email to show") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -890,7 +890,7 @@ func runEmailEvents(args []string) { |
| to := fs.String("to", "", "only events about this recipient address") |
| n := fs.Int("n", 0, "at most this many events (default 100, max 1000)") |
| asJSON := fs.Bool("json", false, "print the events as JSON, one array, for a script") |
| - consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -999,7 +999,7 @@ func runEmailEventsPush(args []string) { |
| path := fs.String("path", "", "the path on the app's own host to POST each event to, e.g. /mail/events") |
| off := fs.Bool("off", false, "stop telling the app") |
| asJSON := fs.Bool("json", false, "print the reply as JSON") |
| - consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -1263,7 +1263,7 @@ func runEmailTest(args []string) { |
| app := appFlag(fs, "app to send a test message through") |
| to := fs.String("to", "", "address to send the test message to") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -1469,7 +1469,7 @@ func runEmailCredentialsCreate(args []string) { |
| app := appFlag(fs, "app to mint a standalone credential for") |
| credName := fs.String("name", "", "credential name (lowercase, digits, hyphens)") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos email credentials create --app <a> --name <n> [--account <sqid-or-name>] " + |
| @@ -1514,7 +1514,7 @@ func runEmailCredentialsList(args []string) { |
| fs := flag.NewFlagSet("email credentials list", flag.ExitOnError) |
| app := appFlag(fs, "app whose standalone credentials to list") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -1557,7 +1557,7 @@ func runEmailCredentialsRevoke(args []string) { |
| app := appFlag(fs, "app whose standalone credential to revoke") |
| credName := fs.String("name", "", "credential name to revoke") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos email credentials revoke --app <a> --name <n> [--account <sqid-or-name>] " + |
| @@ -1591,7 +1591,7 @@ func runEmailRotate(args []string) { |
| app := appFlag(fs, "app whose delivered email credential to rotate") |
| finish := fs.Bool("finish", false, "delete the previous key instead of minting a new one") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -1637,7 +1637,7 @@ func runEmailPause(args []string, paused bool) { |
| fs := flag.NewFlagSet("email "+verb, flag.ExitOnError) |
| app := appFlag(fs, "app to "+verb) |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
diff --git a/pipeline.go b/pipeline.go| index 8598d33e3..88889ea30 100644 |
| --- a/pipeline.go |
| +++ b/pipeline.go |
| @@ -120,7 +120,7 @@ func pipelineFlagSet(name string) (fs *flag.FlagSet, app, acctRaw, acctIDRaw, co |
| // API mode never opens the local registry — the third return |
| // (accountFlags' direct-mode resolver) has no use here and is dropped. |
| acctRaw, acctIDRaw, _ = accountFlags(fs) |
| - consoleFlag = fs.String("console", "", "console origin to work through (default: the only logged-in one)") |
| + consoleFlag = consoleOption(fs, "console origin to work through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, pipelineUsage) |
| return fs, app, acctRaw, acctIDRaw, consoleFlag |
| } |
diff --git a/relay.go b/relay.go| index a9b69efe8..f678ea8ca 100644 |
| --- a/relay.go |
| +++ b/relay.go |
| @@ -190,7 +190,7 @@ func runRelayEnable(args []string) { |
| app := appFlag(fs, "app to enable the relay for") |
| regions := fs.String("regions", "", "comma-separated regions to enable (default: every region this deployment arms)") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to enable through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to enable through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -222,7 +222,7 @@ func runRelayStatus(args []string) { |
| fs := flag.NewFlagSet("relay status", flag.ExitOnError) |
| app := appFlag(fs, "app whose relay to show") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to read through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to read through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -248,7 +248,7 @@ func runRelayDisable(args []string) { |
| fs := flag.NewFlagSet("relay disable", flag.ExitOnError) |
| app := appFlag(fs, "app to disable the relay for") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to disable through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to disable through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -275,7 +275,7 @@ func runRelayAdopted(args []string) { |
| app := appFlag(fs, "app the route belongs to") |
| host := fs.String("host", "", "the route that now runs the issued relay key") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to acknowledge through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to acknowledge through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -309,7 +309,7 @@ func runRelayAdopted(args []string) { |
| func runRelayRotate(args []string) { |
| fs := flag.NewFlagSet("relay rotate", flag.ExitOnError) |
| region := fs.String("region", "", "region whose issuer pointer to move onto the newly staged master") |
| - consoleFlag := fs.String("console", "", "console origin to rotate through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to rotate through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if strings.TrimSpace(*region) == "" { |
diff --git a/restart.go b/restart.go| index bb5f439a9..5e8744ec7 100644 |
| --- a/restart.go |
| +++ b/restart.go |
| @@ -55,7 +55,7 @@ func runRestart(args []string) { |
| environment := environmentFlag(fs, "cycle every route reading this named config bundle, instead of every instance of the app") |
| dryRun := fs.Bool("dry-run", false, "name the routes this would cycle and stop; nothing is restarted") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to restart through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to restart through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, restartUsage) |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
diff --git a/retention.go b/retention.go| index aec80e2a0..4b8920184 100644 |
| --- a/retention.go |
| +++ b/retention.go |
| @@ -26,7 +26,7 @@ func runReleasesRetention(args []string) { |
| off := fs.Bool("off", false, "clear the retention policy — keep releases forever again") |
| dryRun := fs.Bool("dry-run", false, "with --keep: list what would be pruned, write nothing") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
diff --git a/routes_cli.go b/routes_cli.go| index 6e5a3a9b7..e6ed64469 100644 |
| --- a/routes_cli.go |
| +++ b/routes_cli.go |
| @@ -44,7 +44,7 @@ func runRoutes(args []string) { |
| fs := flag.NewFlagSet("routes", flag.ExitOnError) |
| app := appFlag(fs, "app whose routes to list") |
| match := fs.String("match", "", "only hosts containing this substring") |
| - consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to list through (default: the only logged-in one)") |
| 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.Usage = flagUsage(fs, routesUsage) |
diff --git a/scan.go b/scan.go| index 61bfd65e6..e938cec45 100644 |
| --- a/scan.go |
| +++ b/scan.go |
| @@ -83,7 +83,7 @@ func runStoreScan(args []string) { |
| func scanTarget(fs *flag.FlagSet, args []string, verb, what string) (*apiSession, apiAcct, string) { |
| app := appFlag(fs, what) |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to act through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to act through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
diff --git a/schedule.go b/schedule.go| index 8d4af1f44..2c5ebcb30 100644 |
| --- a/schedule.go |
| +++ b/schedule.go |
| @@ -107,7 +107,7 @@ func runScheduleLs(args []string) { |
| fs := flag.NewFlagSet("schedule ls", flag.ExitOnError) |
| app := appFlag(fs, "app whose schedules to list") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to list through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, scheduleLsUsage) |
| fs.Parse(args) |
| @@ -130,7 +130,7 @@ func runScheduleSet(args []string) { |
| cron := fs.String("cron", "", `fire on a 5-field cron expression ("min hour dom mon dow") — give this or --every, not both`) |
| path := fs.String("path", "", "absolute path on the app's own instance to POST at each fire") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console to record through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to record through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, scheduleSetUsage) |
| fs.Parse(args) |
| @@ -176,7 +176,7 @@ func runScheduleRm(args []string) { |
| app := appFlag(fs, "app the schedule belongs to") |
| name := fs.String("name", "", "schedule name to remove") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console to remove through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to remove through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, scheduleRmUsage) |
| fs.Parse(args) |
| scheduleName := strings.TrimSpace(*name) |
| @@ -200,7 +200,7 @@ func runScheduleRun(args []string) { |
| app := appFlag(fs, "app the schedule belongs to") |
| name := fs.String("name", "", "schedule name to run now") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console to run through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to run through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, scheduleRunUsage) |
| fs.Parse(args) |
| scheduleName := strings.TrimSpace(*name) |
diff --git a/scopedkeys_e2e_test.go b/scopedkeys_e2e_test.go| index 56f27be90..eb7df661b 100644 |
| --- a/scopedkeys_e2e_test.go |
| +++ b/scopedkeys_e2e_test.go |
| @@ -33,6 +33,7 @@ func carlosTestEnv(t *testing.T, extra ...string) []string { |
| "CARLOS_CREDENTIALS="+filepath.Join(scratch, "credentials"), |
| "HOME="+scratch, |
| "CARLOS_CONSOLE=", |
| + "CARLOS_DESTINATION=", |
| ) |
| return append(env, extra...) |
| } |
diff --git a/services.go b/services.go| index 2b47884cb..a059d1b1e 100644 |
| --- a/services.go |
| +++ b/services.go |
| @@ -158,7 +158,7 @@ func runServicesCreate(args []string) { |
| app := appFlag(fs, "bind the credential to one app (default: account-wide)") |
| acctRaw := fs.String("account", "", "account (sqid or name) to mint the credential in (default: your only account, or the configured/chosen one)") |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| - consoleFlag := fs.String("console", "", "console to create it through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to create it through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| const usageMsg = "usage: carlos services create --role <publish|instance|operate|service|admin> [--app <app>] " + |
| @@ -273,7 +273,7 @@ func runServicesList(args []string) { |
| fs := flag.NewFlagSet("services ls", flag.ExitOnError) |
| acctRaw := fs.String("account", "", "account (sqid or name) to list credentials for (default: your only account, or the configured/chosen one)") |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| - consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to list through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| @@ -321,7 +321,7 @@ func runServicesRotate(args []string) { |
| fs := flag.NewFlagSet("services rotate", flag.ExitOnError) |
| acctRaw := fs.String("account", "", "account (sqid or name) the credential 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") |
| - consoleFlag := fs.String("console", "", "console to rotate through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to rotate through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if fs.NArg() != 1 { |
| @@ -370,7 +370,7 @@ func runServicesRevoke(args []string) { |
| fs := flag.NewFlagSet("services revoke", flag.ExitOnError) |
| acctRaw := fs.String("account", "", "account (sqid or name) the credential 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") |
| - consoleFlag := fs.String("console", "", "console to revoke through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to revoke through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if fs.NArg() != 1 { |
diff --git a/sidecars_cli.go b/sidecars_cli.go| index bc3d97ccd..ec7dc5e0c 100644 |
| --- a/sidecars_cli.go |
| +++ b/sidecars_cli.go |
| @@ -111,7 +111,7 @@ func runSidecarsEnable(args []string) { |
| idle := fs.String("idle", "", "idle timeout before a hibernating sidecar sleeps (default 5m; Go duration)") |
| maxRun := fs.String("max-run", "", "max run duration before the sweeper stops a sidecar (default 1h; Go duration)") |
| noHibernate := fs.Bool("no-hibernate", false, "this app's sidecars never hibernate (default: they do)") |
| - consoleFlag := fs.String("console", "", "console to enable through (signed-in mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to enable through (signed-in mode only; default: the only logged-in one)") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app != "" && !release.ValidApp(*app) { |
| @@ -341,7 +341,7 @@ func runSidecarsCreate(args []string) { |
| app := fs.String("app", "", "app the sidecar belongs to") |
| region := fs.String("region", "", "region code for the record (subject to the manifest's allow-list, or the app's own placement)") |
| channel := fs.String("channel", "", "release channel the sidecar follows (default: the manifest's, then stable)") |
| - consoleFlag := fs.String("console", "", "console to create through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to create through (default: the only logged-in one)") |
| 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) |
| @@ -394,7 +394,7 @@ func runSidecarsList(args []string) { |
| fs := flag.NewFlagSet("sidecars ls", flag.ExitOnError) |
| app := fs.String("app", "", "app whose sidecar records to list") |
| long := fs.Bool("long", false, "print the synthetic host instead of the short name") |
| - consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to list through (default: the only logged-in one)") |
| 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) |
| @@ -437,7 +437,7 @@ func runSidecarsList(args []string) { |
| func runSidecarsWake(args []string) { |
| fs := flag.NewFlagSet("sidecars wake", flag.ExitOnError) |
| app := fs.String("app", "", "app the sidecar belongs to") |
| - consoleFlag := fs.String("console", "", "console to wake through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to wake through (default: the only logged-in one)") |
| 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) |
| @@ -466,7 +466,7 @@ func runSidecarsWake(args []string) { |
| func runSidecarsRm(args []string) { |
| fs := flag.NewFlagSet("sidecars rm", flag.ExitOnError) |
| app := fs.String("app", "", "app the sidecar belongs to") |
| - consoleFlag := fs.String("console", "", "console to delete through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to delete through (default: the only logged-in one)") |
| 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) |
| @@ -493,7 +493,7 @@ func runSidecarsRm(args []string) { |
| func runSidecarsLogs(args []string) { |
| fs := flag.NewFlagSet("sidecars logs", flag.ExitOnError) |
| app := fs.String("app", "", "app the sidecar belongs to") |
| - consoleFlag := fs.String("console", "", "console to read through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to read through (default: the only logged-in one)") |
| 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) |
diff --git a/store.go b/store.go| index 7f09b4da0..0db093234 100644 |
| --- a/store.go |
| +++ b/store.go |
| @@ -165,7 +165,7 @@ func runStoreCreate(args []string) { |
| app := appFlag(fs, "app to declare a store for") |
| envPrefix := fs.String("env-prefix", "", "env var prefix to deliver under (default CARLOS_STORE)") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to declare through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to declare through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -209,7 +209,7 @@ func runStoreStatus(args []string) { |
| fs := flag.NewFlagSet("store status", flag.ExitOnError) |
| app := appFlag(fs, "app whose store to show") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to read through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to read through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -236,7 +236,7 @@ func runStoreGrant(args []string) { |
| app := appFlag(fs, "app to grant a store for") |
| bucket := fs.String("bucket", "", "adopt this existing bucket instead of minting one") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to grant through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to grant through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -263,7 +263,7 @@ func runStoreRotate(args []string) { |
| app := appFlag(fs, "app whose store credential to rotate") |
| finish := fs.Bool("finish", false, "delete the previous key instead of minting a new one") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to rotate through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to rotate through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -289,7 +289,7 @@ func runStoreApply(args []string) { |
| fs := flag.NewFlagSet("store apply", flag.ExitOnError) |
| note := fs.String("note", "", "what you plan to use object storage for") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to apply through") |
| + consoleFlag := consoleOption(fs, "console origin to apply through") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if fs.NArg() != 0 { |
| @@ -307,7 +307,7 @@ func runStoreApply(args []string) { |
| func runStoreApprovals(args []string) { |
| fs := flag.NewFlagSet("store approvals", flag.ExitOnError) |
| - consoleFlag := fs.String("console", "", "console origin to read through") |
| + consoleFlag := consoleOption(fs, "console origin to read through") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if fs.NArg() != 0 { |
| @@ -346,7 +346,7 @@ func runStoreApproveWithSession(ctx context.Context, sess *apiSession, args []st |
| fs := flag.NewFlagSet("store approve", flag.ContinueOnError) |
| fs.SetOutput(out) |
| accountFlag := fs.String("account", "", "account sqid to approve") |
| - consoleFlag := fs.String("console", "", "console origin to approve through") |
| + consoleFlag := consoleOption(fs, "console origin to approve through") |
| fs.Usage = flagUsage(fs, "") |
| if err := fs.Parse(args); err != nil { |
| return err |
| @@ -374,7 +374,7 @@ func runStoreDecline(args []string) { |
| fs := flag.NewFlagSet("store decline", flag.ExitOnError) |
| accountFlag := fs.String("account", "", "account sqid to decline") |
| reason := fs.String("reason", "", "reason for declining object storage") |
| - consoleFlag := fs.String("console", "", "console origin to decline through") |
| + consoleFlag := consoleOption(fs, "console origin to decline through") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if fs.NArg() != 0 { |
diff --git a/system.go b/system.go| index e2bad288c..1eb6ca91c 100644 |
| --- a/system.go |
| +++ b/system.go |
| @@ -175,7 +175,7 @@ type systemStatusResponse struct { |
| func runSystemUpdate(args []string) { |
| fs := flag.NewFlagSet("system update", flag.ExitOnError) |
| - consoleFlag := fs.String("console", "", "console origin (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (default: the only logged-in one)") |
| channel := fs.String("channel", "stable", "canary or stable") |
| version := fs.String("version", "", "release tag to promote (default: the latest public release); with --file, the version the binary reports") |
| file := fs.String("file", "", "a locally built binary instead of a public release (dev and pre-release)") |
| @@ -361,7 +361,7 @@ func (s *apiSession) uploadSystemBinary(ctx context.Context, path, version, plat |
| func runSystemStatus(args []string) { |
| fs := flag.NewFlagSet("system status", flag.ExitOnError) |
| - consoleFlag := fs.String("console", "", "console origin (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (default: the only logged-in one)") |
| channel := fs.String("channel", "", "only boxes on this channel") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| @@ -446,7 +446,7 @@ func printSystemStatus(w io.Writer, resp systemStatusResponse, channel string) { |
| func runSystemRollback(args []string) { |
| fs := flag.NewFlagSet("system rollback", flag.ExitOnError) |
| - consoleFlag := fs.String("console", "", "console origin (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (default: the only logged-in one)") |
| channel := fs.String("channel", "", "canary or stable (required)") |
| yes := fs.Bool("yes", false, "do not ask") |
| fs.Usage = flagUsage(fs, "") |
| @@ -500,7 +500,7 @@ func runSystemAntivirusVerb(rest []string) { |
| func runSystemAntivirus(args []string, verb string) { |
| fs := flag.NewFlagSet("system antivirus "+verb, flag.ExitOnError) |
| - consoleFlag := fs.String("console", "", "console to act through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |