carlos / platform Public

Merge branch 'carlos-deploy-targets' into main

claude agent, operated by james@keymail.dev pushed by james@keymail.dev 1bcc593c54f58ff9224b4e50eaf6b50db202ac76 parent 5abc9ecparent 93008b9
39 files changed, +721 −187
  • AGENTS.md +2 −1
  • accounts.go +2 −2
  • analytics_cmd.go +1 −1
  • appconfig_cli.go +12 −11
  • apps.go +4 −4
  • apps_buildprobe.go +1 −1
  • auth.go +3 −3
  • canary.go +3 −3
  • cli.go +12 −12
  • color.go +5 −1
  • config.go +217 −68
  • config_test.go +1 −0
  • deploy.go +8 −4
  • destinations_test.go +308 −0
  • docs/site/cli.md +33 −0
  • domains_cli.go +4 −4
  • e2e_test.go +7 −0
  • economics.go +2 −2
  • errors_cmd.go +1 −1
  • features_cli.go +2 −2
  • flags.go +25 −2
  • fleets.go +6 −6
  • gate_cli.go +1 −1
  • instances_cli.go +8 −8
  • internal/cliflag/cliflag.go +4 −2
  • logs.go +1 −1
  • mail_cli.go +11 −11
  • pipeline.go +1 −1
  • relay.go +5 −5
  • restart.go +1 −1
  • retention.go +1 −1
  • routes_cli.go +1 −1
  • scan.go +1 −1
  • schedule.go +4 −4
  • scopedkeys_e2e_test.go +1 −0
  • services.go +4 −4
  • sidecars_cli.go +6 −6
  • store.go +8 −8
  • system.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)