// The `carlos apps` verbs: create an app, and delete (trash) / restore one. // // create is the CLI door onto the same app-name claim the console's New app // form makes, console API ONLY — the claims table lives in the console's // database, which direct bucket mode never opens; in direct mode an app // comes into existence at its first ship, or box-side via `console grant` // (docs/superpowers/specs/2026-08-04-cli-app-create-design.md). // // delete/restore run in the same two modes every other write verb does — // signed-in through the console API, or headless straight against the // deployment bucket. See // docs/superpowers/specs/2026-08-04-deletion-policy-design.md. // // Deleting is reversible for 30 days (release.TrashCooloff) and restoring is // the undo button — that asymmetry is why this file has two verbs and no // third: there is no purge here. "Delete now" is console-only, gated behind // a passkey step-up this CLI cannot perform; a headless deployment either // waits out the cooloff or an operator deletes bucket keys by hand, which is // outside the platform's promises (spec §6). package main import ( "context" "crypto/ed25519" "errors" "flag" "fmt" "log" "os" "strings" "time" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) const appsUsage = `usage: carlos apps [flags] create --app [flags] claim a new app name in an account, through the console API — the same claim the console's New app form makes, so a name the form would refuse is refused here too. Rate limited per address per day; the console form is not, and is the fallback. Signed-in mode only: in direct bucket mode the first ship brings an app into existence, and "console grant" is the box-side path. --place fleet/ place the app on a customer fleet at the same instant it is created — equivalent to running "apps place" right after, but works even though the fresh app has zero routes yet (the console stores placement on the app record itself, not only on its routes). Validated the same way --target below is: the grammar here, ownership server-side; an invalid or foreign fleet refuses the WHOLE create. --control-plane mark the app as the control plane: it then refuses trash, purge, rename and transfer, and refuses a service credential's promote — owner only. --build-probe /path the path whose response body names the running build, e.g. "/healthz" serving "ok abc1234". "carlos deploy" polls it instead of the X-Carlos-Version header, which the edge stamps from the route row and which therefore reports the new build before the process has actually restarted. Both flags are stamped at CLAIM time and verified against what the console echoes back, so a store write that silently failed is a loud error rather than a quiet lie. place --app --target fleet/|"" repoint where an app's routes — and its already-provisioned instances — should run: onto a customer fleet ("fleet/", a fleet your account owns), or "" to clear back to platform-served. Every NEW instance created afterwards inherits this target automatically; every instance that already exists is repointed by this call itself. --target is MANDATORY, not optional despite the "" spelling — leaving it off is a usage error, never a silent clear (a fleet box retires an app's routes on its next reconcile the moment placement reads empty). build-probe --app set (or clear, with "-") the path whose response body names the RUNNING build, on an app that already exists. "carlos deploy" polls it instead of the X-Carlos-Version header, which the edge stamps from the route row and which therefore reports the new build before the process has actually restarted — the whole reason a deploy can print "live" over an old binary. Settable only at claim time until now, which meant the fix was out of reach of every app that already had the problem. set [--swap overlap|hold] [--swap-concurrency ] how the app's running instances take a new build: see carlos apps set -h. delete [flags] move an app to the Trash: every route drops (custom domains included), its name stays reserved, and it is restorable for 30 days, or 3 for a temporary app. Refused while the app is flagged production — turn production off first, then delete — or when that flag cannot be read at all: an unreadable flag is treated as production, same direction "bake" fails. restore [flags] undo a delete restorable for 30 days, or 3 for a temporary app. Releases, channels and the production flag are exactly as they were — but NO routes come back, not even the platform apex: run "carlos add " afterwards to serve it again. A tmp- app needs none of that: its instance records are kept in the Trash, so hosts bring its routes back on their next pass. ttl --app <1h to 30d> move a temporary app's expiry to that long from now, e.g. 5d or 36h. Temporary apps only. keep --app make a temporary app permanent. It keeps its tmp- name, and everything a temporary app cannot do (custom domains, binaries, storage, email) opens up. list every app in the account, the Trash included: live or trashed, and when it expires or is purged. --account account the app belongs to (default: your only account, or the configured/chosen one) — resolved by the console against your own memberships; signed-in mode only. --account-id account sqid, matched as a sqid ONLY — the only reference headless mode accepts (a name has no console here to resolve it against), and supersedes --account in signed-in mode too. --console which logged-in console to use (only needed with several) Signed-in (carlos auth login): both verbs go through the console API — any member of the account, bearer token alone, POSTing to /api/cli/apps///delete|restore. Headless (CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR set, no login): both verbs write or remove the trash marker bucket-direct. --account-id is required — there is no console here to resolve a name against. delete also needs CARLOS_RELEASE_PUBKEY, to prove the app is not flagged production. There is no purge verb here: "Delete now" is console-only, behind its own passkey step-up. Headless deployments wait out the window: 30 days, or 3 for a temporary app. ` // RunApps dispatches the apps sub-verbs. func RunApps(args []string) { if len(args) == 0 { fmt.Fprint(os.Stderr, appsUsage) os.Exit(1) } switch args[0] { case "create": runAppsCreate(args[1:]) case "place": runAppsPlace(args[1:]) case "build-probe": runAppsBuildProbe(args[1:]) case "set": runAppsSet(args[1:]) case "delete": runAppsDelete(args[1:]) case "restore": runAppsRestore(args[1:]) case "ttl": runAppsTTL(args[1:]) case "keep": runAppsKeep(args[1:]) case "list": runAppsList(args[1:]) default: fmt.Fprint(os.Stderr, appsUsage) os.Exit(1) } } // validAppName is release.ValidApp's CLI-facing gate, checked BEFORE mode // selection in both verbs (review finding: the --account-id "ops" phantom // trap, one level down). Neither TrashApp nor RestoreApp validates their app // argument — they trust the caller — and the signed-in path's 404 // (apiAccountApp's HasAppIn) only catches a bad name because the CONSOLE // happens to keep a row per real app. Headless mode has no such row: nothing // stops `carlos apps delete --account-id bab MyApp` from writing // apps/bab/MyApp/deleted.json — a path release.ValidApp accepts as a bucket // key but that no real app can ever have (Ship/SetProduction both require // ValidApp, so a real app's own name always passes) — printing "trashed" // while the actual "myapp" keeps serving, untouched, forever. Refusing here, // before either mode runs, keeps both paths refusing the same typo instead // of only the lucky one. func validAppName(app string) error { if release.ValidApp(app) { return nil } return fmt.Errorf("%q is not a usable app name — 1-32 characters, lowercase a-z, 0-9 and hyphen, "+ "starting with a letter", app) } // appsMode is the pure decision behind both apps verbs' mode selection, // extracted so it can be table-tested without a credentials file or a // bucket on disk. loggedIn and bucketConfigured come straight off // resolveMode's own result — sess != nil, and sess == nil && err == nil, // respectively — which resolveMode's own env check already keeps mutually // exclusive whenever it returns no error at all. accountID is the trimmed // --account-id value, already shape-checked by checkAccountID (accounts.go), // so a malshaped one (a name, or "ops") never reaches here at all. // // - signed-in wins outright: it needs no local account-id, since the // console resolves --account (sqid or name) against the caller's own // memberships server-side. // - headless requires an explicit --account-id: a name has no console here // to resolve it against, and the flag that never guesses is the only // safe reference on a box (accounts.go's checkAccountID doc comment). // - neither configured is fatal, with both fixes named — the same // two-way guidance accounts.go:112-118 uses for an ownerless account. func appsMode(loggedIn, bucketConfigured bool, accountID string) (mode string, err error) { switch { case loggedIn: return "signed-in", nil case bucketConfigured: if strings.TrimSpace(accountID) == "" { return "", errors.New("headless mode (CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR, no login) needs " + "--account-id — a name has no console here to resolve it against") } return "headless", nil default: return "", errors.New("neither a console login nor a deployment store is configured:\n\n" + " carlos auth login --console log this terminal in; use the console API\n" + " CARLOS_DEPLOYMENT_BUCKET=... (or CARLOS_DEPLOYMENT_DIR=...) work bucket-direct instead\n\n" + "apps delete/restore need one or the other") } } func runAppsDelete(args []string) { fs := flag.NewFlagSet("apps delete", flag.ExitOnError) acctRaw, acctIDRaw, _ := accountFlags(fs) 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 { log.Fatal("usage: carlos apps delete [--account ] [--account-id ] [--console ] ") } app := fs.Arg(0) if err := validAppName(app); err != nil { log.Fatalf("apps delete: %v", err) } acctID := checkAccountID(*acctIDRaw) sess, err := resolveMode(*consoleFlag) if err != nil { log.Fatal(err) } mode, err := appsMode(sess != nil, sess == nil, acctID) if err != nil { log.Fatal(err) } if mode == "signed-in" { printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) res, err := sess.trashApp(context.Background(), acct, app) if err != nil { log.Fatal(err) } printTrashed(app, res.RestorableUntil, time.Now()) return } // Headless: this box has no console to enforce the production gate, so // headlessTrash enforces it itself — pulled out into its own function // (review finding: the destructive gate order needs direct test // coverage, not only exercise-through-the-CLI). store := openStore() var pub ed25519.PublicKey if pubRaw := strings.TrimSpace(os.Getenv("CARLOS_RELEASE_PUBKEY")); pubRaw != "" { p, perr := release.ParsePublicKey(pubRaw, "CARLOS_RELEASE_PUBKEY") if perr != nil { log.Fatalf("apps delete: %v", perr) } pub = p } deletedAt, err := headlessTrash(context.Background(), store, pub, acctID, app, time.Now()) if err != nil { log.Fatalf("apps delete: %v", err) } fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("trashed"), app) for _, line := range headlessTrashLines(app, deletedAt, time.Now()) { fmt.Fprintln(stdout, line) } mutedf(stdout, "%s", restoreHint(app)) } // headlessTrash is the destructive headless-mode gate: no console in the // loop to enforce anything, so this is the whole check, pulled out of // runAppsDelete so it can be driven directly against a real store in tests // rather than only through the CLI's os.Exit-on-refusal plumbing (review // finding — this gate had zero test coverage). Gate order matches // handleAPITrashApp's (internal/console/apicli.go) exactly: // // - a malshaped app name refuses before anything else (validAppName) — // defense in depth, since runAppsDelete already checks this before mode // selection, but this function must not trust its caller either. // - no usable public key refuses before the store is even asked anything: // with nothing to verify a signature against, an unreadable flag and an // absent one are indistinguishable, so this is the same "unreadable // fails toward refusal" rule one step earlier. // - the production flag ON, or genuinely UNREADABLE, refuses — NOTHING is // written on this path. // - only then TrashApp, followed by the SAME re-read handleAPITrashApp // does: TrashApp is create-only, so an app already in the trash (a // racing headless caller, or a previous run) keeps its ORIGINAL clock, // and printing this call's own `now` would over-promise a deadline // nothing will honour. The function returns the marker's own DeletedAt // (not DeletedAt + TrashCooloff): there is no console row here to say // whether the app was temporary, so the caller (headlessTrashLines) // works out both possible windows from that one clock. func headlessTrash(ctx context.Context, store release.Store, pub ed25519.PublicKey, acct, app string, now time.Time) (time.Time, error) { if err := validAppName(app); err != nil { return time.Time{}, err } if len(pub) != ed25519.PublicKeySize { return time.Time{}, errors.New("CARLOS_RELEASE_PUBKEY is not set — deleting must be able to prove the app " + "is not flagged production, and a flag that cannot be proven is treated as production and refused") } prod, err := release.GetProduction(ctx, store, pub, acct, app) if err != nil { return time.Time{}, fmt.Errorf("could not read the production flag for %s/%s — an unreadable flag is "+ "treated as production, refusing: %w", acct, app, err) } if prod != nil && prod.Production { return time.Time{}, fmt.Errorf("%s/%s is flagged production, so deleting is refused — turn production off "+ "first, then try again", acct, app) } if err := release.TrashApp(ctx, store, acct, app, defaultLabel(), now); err != nil { return time.Time{}, err } tomb, err := release.ReadAppTombstone(ctx, store, acct, app) if err != nil { return time.Time{}, fmt.Errorf("%s/%s is trashed, but the restore window could not be confirmed — retry is "+ "safe (the marker is create-only): %w", acct, app, err) } if tomb == nil { return time.Time{}, fmt.Errorf("%s/%s is trashed, but its marker vanished before the window could be "+ "confirmed (a restore may have raced this) — retry is safe", acct, app) } return tomb.DeletedAt, nil } // printTrashed is the signed-in delete's output: the console's own deadline // (effectivePurgeAt), how far off it is, and the restore command. func printTrashed(app, restorableUntil string, now time.Time) { when, rel := restorableUntil, "" if t, err := time.Parse(time.RFC3339, restorableUntil); err == nil { when, rel = t.UTC().Format("2006-01-02 15:04 UTC"), inDuration(t.Sub(now)) } fmt.Fprintf(stdout, "%s %s. It is purged %s, at %s. Until then, carlos apps restore %s brings it back.\n", colorFor(stdout).ok("trashed"), app, rel, when, app) mutedf(stdout, "%s", restoreHint(app)) } // headlessTrashLines is direct mode's delete output (spec §5, astra 7.4 and // 8.2). There is no console row here, so it cannot know whether the app is // temporary, and the tmp- prefix does not say (a kept app keeps it). It names // both windows from the marker's own clock, and a date already passed as // "may be purged at any time". func headlessTrashLines(app string, deletedAt, now time.Time) []string { when := func(t time.Time) string { if !t.After(now) { return "may be purged at any time" } return "is purged at " + t.UTC().Format("2006-01-02 15:04 UTC") } return []string{ fmt.Sprintf("%s has been in the Trash since %s.", app, deletedAt.UTC().Format("2006-01-02 15:04 UTC")), fmt.Sprintf("If it is a temporary app it %s. If not, it %s.", when(deletedAt.Add(release.TemporaryCooloff)), when(deletedAt.Add(release.TrashCooloff))), "carlos apps list against the console shows which.", } } func runAppsRestore(args []string) { fs := flag.NewFlagSet("apps restore", flag.ExitOnError) acctRaw, acctIDRaw, _ := accountFlags(fs) 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 { log.Fatal("usage: carlos apps restore [--account ] [--account-id ] [--console ] ") } app := fs.Arg(0) if err := validAppName(app); err != nil { log.Fatalf("apps restore: %v", err) } acctID := checkAccountID(*acctIDRaw) sess, err := resolveMode(*consoleFlag) if err != nil { log.Fatal(err) } mode, err := appsMode(sess != nil, sess == nil, acctID) if err != nil { log.Fatal(err) } if mode == "signed-in" { printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) res, err := sess.restoreApp(context.Background(), acct, app) if err != nil { log.Fatal(err) } fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("restored"), app) if res.Note != "" { // The server's own wording (handleAPIRestoreApp) — printed // verbatim rather than reworded here, so the two modes cannot // drift apart on what they tell someone about routing. fmt.Fprintln(stdout, res.Note) } else { mutedf(stdout, "%s has NO routes — carlos add to serve it again\n", app) } return } // Headless: no production gate to re-check — an app in the trash cannot // be flagged production (ship/promote/SetProduction all refuse a // trashed app), and refusing to undo a deletion protects nothing. // release.RestoreApp is Store.Delete under the hood, which tolerates an // already-gone marker, so restoring an app that was never trashed is a // harmless no-op rather than an error. store := openStore() if err := release.RestoreApp(context.Background(), store, acctID, app); err != nil { log.Fatalf("apps restore: %v", err) } fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("restored"), app) if release.IsTemporaryName(app) { mutedf(stdout, "%s is restored with its releases and channels intact. Its instance records were kept, "+ "so hosts bring its routes back on their next pass", app) return } mutedf(stdout, "%s is restored with its releases and channels intact, and with NO routes — "+ "carlos add to serve it again", app) } // validPlaceTarget is the client-side grammar gate for a fleet placement // target, shared by --place (apps create) and --target (apps place): checked // BEFORE any API call, on the same "a shape the server is certain to refuse // should cost nothing" principle validAppName's own doc comment states — // for --place at create time this matters doubly, since a wasted round trip // there would also spend the create verb's own scarce per-day budget on a // typo. "" is always valid (no placement, or clearing one); anything else // must parse as "fleet/" per registry.FleetPlacement — the exact // grammar the server re-checks itself. This is belt only, never the buckle: // whether the named fleet exists and is owned by the caller's account is a // check only the server can make, and both server doors (handleAPICreateApp, // handleAPIPlace) still make it themselves. func validPlaceTarget(target string) error { if target == "" { return nil } if _, ok := registry.FleetPlacement(target); !ok { return fmt.Errorf(`%q is not a usable placement target — want "" or "fleet/" `+ "(1-32 characters of a-z, 0-9 and hyphen)", target) } return nil } // validBuildProbe is the client-side grammar gate for --build-probe, checked // BEFORE the API call on the same principle validAppName and // validPlaceTarget already state: a shape the server is certain to refuse // should cost nothing against the create verb's own scarce per-day budget. // "" is always valid (no probe). The grammar must stay BYTE-IDENTICAL to the // server's own validBuildProbe (internal/console/server.go): an absolute // path, not protocol-relative ("//host/..."), no query, no fragment, and // printable ASCII only (no control characters) — the same rule, checked // twice, never merely "similar" rules that could quietly drift apart. // // It is no longer only a create-time gate: this is also what keeps the deploy // watch on-host. runDeploy re-checks the probe the console ECHOES with this // function before dialing it (deploy.go), so loosening the grammar "just for // creates" silently loosens the watch too — and the shape that matters there // is a value not starting with "/": "https://" + host + "@evil.example/x" // parses host as USERINFO and evil.example as the HOST, which is how a watch // ends up reporting a DIFFERENT deployment's build as this one's. func validBuildProbe(probe string) error { if probe == "" { return nil } bad := !strings.HasPrefix(probe, "/") || strings.HasPrefix(probe, "//") || strings.ContainsAny(probe, "?#") || len(probe) > 256 if !bad { for i := 0; i < len(probe); i++ { if probe[i] < 0x21 || probe[i] > 0x7e { bad = true break } } } if bad { return fmt.Errorf(`%q is not a usable build probe — want an absolute URL path with no query, `+ `fragment, or protocol-relative prefix, e.g. "/healthz"`, probe) } return nil } func runAppsCreate(args []string) { fs := flag.NewFlagSet("apps create", flag.ExitOnError) app := appFlag(fs, "app name to claim (lowercase letters, digits, dashes)") place := fs.String("place", "", `place the app on a customer fleet at create time — "" (default) or "fleet/"`) controlPlane := fs.Bool("control-plane", false, "mark the app as the control plane: it refuses trash, purge, rename and transfer, "+ "and refuses a service credential's promote (owner only)") buildProbe := fs.String("build-probe", "", "`path` whose response body carries the running build, e.g. \"/healthz\" — "+ "carlos deploy polls it instead of the X-Carlos-Version header, which the edge "+ "stamps from the route row and which therefore lies until the process restarts; "+ `the build must appear as a whole whitespace-delimited word in the body, e.g. "ok abc1234"`) region := fs.String("region", "", flagRegionAppsCreate) // Not accountFlags: that helper also registers --registry for direct-mode // resolution, and this verb never resolves locally — the console resolves // 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 := consoleOption(fs, "console to create it through (default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) if strings.TrimSpace(*app) == "" { log.Fatal("usage: carlos apps create --app [--place fleet/] [--control-plane] " + "[--build-probe /path] [--region ] [--account ] [--account-id ] [--console ]") } // Client-side gates before the server's: the create limiter charges // ATTEMPTS, so a name — or a place or build-probe value — the server is // certain to refuse should cost a typo nothing. if err := validAppName(strings.TrimSpace(*app)); err != nil { log.Fatalf("apps create: %v", err) } if release.IsTemporaryName(strings.TrimSpace(*app)) { log.Fatal("apps create: names starting with tmp- are kept for temporary sites. Use carlos deploy --temporary, or choose another name") } place2 := strings.TrimSpace(*place) if err := validPlaceTarget(place2); err != nil { log.Fatalf("apps create: %v", err) } probe2 := strings.TrimSpace(*buildProbe) if err := validBuildProbe(probe2); err != nil { log.Fatalf("apps create: %v", err) } sess, err := resolveMode(*consoleFlag) if err != nil { log.Fatal(err) } if sess == nil { // Direct mode. The claim lives in the console's database — there is // nothing in the bucket to write it to, and pretending otherwise // re-creates the ownerless-resource failure accounts create solved. log.Fatal("apps create works through a console, and this shell is in direct bucket mode\n" + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Direct mode needs no claim:\n" + "the first `carlos ship` brings the app into existence, and `console grant` is the\n" + "box-side way to hand a name to an address. To claim through a console instead,\n" + "unset the store env and run: carlos auth login --console ") } printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) region2 := strings.TrimSpace(*region) res, err := sess.createApp(context.Background(), acct, strings.TrimSpace(*app), place2, *controlPlane, probe2, region2, detectTimezone()) if err != nil { log.Fatalf("apps create: %v", err) } c := colorFor(stdout) fmt.Fprintf(stdout, "%s %s in account %s — it's yours\n", c.ok("created app"), res.App, res.Account) fmt.Fprintf(stdout, " open: %s\n", c.link(res.URL)) fmt.Fprintf(stdout, " ship: carlos ship --account %s --app %s ...\n", res.Account, res.App) printAppRegion(stdout, res, region2, place2 != "") // The app WAS created (everything above already printed and is true) — // what's still unverified is whether --place actually stuck. The server // echoes back what it really stored (handleAPICreateApp's "placement" // field), and this is the one place that gets checked: silently // trusting the request would let a stolen best-effort store write // (claimApp's, internal/console/server.go) OR an older console version // (whose readJSON ignores an unknown "place" key and returns 200 with // no such field at all — which unmarshals to the same "" zero value) // report success for an app that is NOT actually placed. if place2 != "" { if err := placementMismatchErr(res.App, place2, res.Placement); err != nil { log.Fatalf("apps create: %v", err) } fmt.Fprintf(stdout, " placed on %s\n", place2) } // Same principle for the two claim-time flags: print what the console // actually stamped (res.ControlPlane / res.BuildProbe), not what was // asked for, so a silently-swallowed store-write error is visible here // rather than trusted away. if *controlPlane { if !res.ControlPlane { log.Fatalf("apps create: --control-plane did not stick (the console did not confirm it) — "+ "the app %s was created, but is NOT flagged as the control plane", res.App) } fmt.Fprintf(stdout, " control plane: yes\n") } if probe2 != "" { if res.BuildProbe != probe2 { log.Fatalf("apps create: --build-probe did not stick (console echoed %q, want %q) — "+ "the app %s was created, but has no build probe set", res.BuildProbe, probe2, res.App) } fmt.Fprintf(stdout, " build probe: %s\n", res.BuildProbe) } } // placementMismatchErr compares what --place asked for (requested) against // what the console's create response actually echoed back (echoed), and // builds the loud, remedy-naming error runAppsCreate exits on when they // disagree. Pulled out as its own pure function — no log.Fatal, no I/O — so // both the ordinary "the write silently failed" case and the "an OLDER // console never echoes placement at all" case (readJSON there ignores the // unknown request field and its response JSON simply has no "placement" // key, which encoding/json leaves at the same "" zero value an explicit // empty echo would) are covered by ONE string comparison and are directly // testable without a subprocess. func placementMismatchErr(app, requested, echoed string) error { if requested == echoed { return nil } return fmt.Errorf("the app was created, but its placement did NOT stick (the console echoed placement=%q, "+ "want %q — either the store write silently failed, or this console version is too old to echo "+ "placement at all). Run this to place it now:\n\n carlos apps place --app %s --target %s\n", echoed, requested, app, requested) } // runAppsPlace repoints where app's routes and already-provisioned // instances should run: `carlos apps place --app --target // fleet/|""`. Every field is client-side validated before the API call // exactly the way the server itself validates them, so a typo's cost is a // local refusal, not a spent write-rate-limit slot. func runAppsPlace(args []string) { fs := flag.NewFlagSet("apps place", flag.ExitOnError) app := appFlag(fs, "app to place") target := fs.String("target", "", `fleet target — "" clears placement, or "fleet/"`) // Not accountFlags: that helper also registers --registry for direct-mode // resolution, and this verb never resolves locally — placement is // 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 := consoleOption(fs, "console to place through (default: the only logged-in one)") const placeUsage = `usage: carlos apps place --app --target fleet/|"" [--account ] ` + "[--account-id ] [--console ]" fs.Usage = flagUsage(fs, placeUsage) fs.Parse(args) if strings.TrimSpace(*app) == "" { log.Fatal(placeUsage) } // --target is TRI-state, the same reason route's --backing is (cli.go's // --hibernate/--backing precedent, ~line 355): "" is a LEGAL value here — // it CLEARS placement — so a flag left unmentioned and a flag explicitly // set to "" cannot be told apart by the value alone. They must not be // treated the same: an omitted --target silently reading as "clear" would // retire every route of a fleet-placed app on that fleet box's very next // reconcile pass. fs.Visit reports only flags actually present on the // command line, so this is the one reliable way to tell "not named" from // "named, empty". targetSet := false fs.Visit(func(f *flag.Flag) { if f.Name == "target" { targetSet = true } }) if !targetSet { log.Fatal(placeUsage) } if err := validAppName(strings.TrimSpace(*app)); err != nil { log.Fatalf("apps place: %v", err) } target2 := strings.TrimSpace(*target) if err := validPlaceTarget(target2); err != nil { log.Fatalf("apps place: %v", err) } sess, err := resolveMode(*consoleFlag) if err != nil { log.Fatal(err) } if sess == nil { log.Fatal("apps place works through a console, and this shell is in direct bucket mode\n" + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Placement is console-tracked\n" + "state: log in instead — carlos auth login --console ") } acct := resolvedAccount(sess, acctRaw, acctIDRaw) fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) if err := sess.place(context.Background(), acct, strings.TrimSpace(*app), target2); err != nil { log.Fatalf("apps place: %v", err) } if target2 == "" { fmt.Fprintf(stdout, "cleared placement for %s\n", strings.TrimSpace(*app)) return } fmt.Fprintf(stdout, "placed %s on %s\n", strings.TrimSpace(*app), target2) }