feat(cli): carlos apps delete and restore — signed-in via the console, headless via the bucket
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+561
−0
apimode.go+72 −0apps.go+260 −0apps_test.go+217 −0main.go+12 −0
diff --git a/apimode.go b/apimode.go| index f50e08cd7..e9d8ac367 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -392,6 +392,78 @@ func (s *apiSession) channels(ctx context.Context, a apiAcct, app string) ([]api |
| return out, nil |
| } |
| +// apiTrashResult is handleAPITrashApp's success shape: the confirmed cooloff |
| +// deadline. The SERVER computes it (it re-reads the marker's own DeletedAt |
| +// after writing — TrashApp is create-only, so an app already in the trash |
| +// keeps its ORIGINAL clock), so this client prints exactly what the server |
| +// confirmed rather than a locally-guessed window. |
| +type apiTrashResult struct { |
| + RestorableUntil string |
| +} |
| + |
| +// apiRestoreResult is handleAPIRestoreApp's success shape: the server's own |
| +// reminder that restoring resurrects no routes (spec §3), carried verbatim |
| +// so the signed-in and headless paths do not drift apart on wording by |
| +// accident — one is the console's copy, the other this binary's own. |
| +type apiRestoreResult struct { |
| + Note string |
| +} |
| + |
| +// trashApp asks the console to move app to the Trash (carlos apps delete, |
| +// signed-in mode). Mirrors promote/rollback's shape exactly: postJSON, a 200 |
| +// decodes, anything else is apiErrorFromBody's job — trash's 409 (production |
| +// on or unreadable) and 502 (trash window unconfirmed; retrying is safe, |
| +// since TrashApp is create-only and idempotent) both carry a real |
| +// {"error": ...} body, so the json-first decode surfaces them verbatim. |
| +func (s *apiSession) trashApp(ctx context.Context, a apiAcct, app string) (apiTrashResult, error) { |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/delete"), struct{}{}) |
| + if err != nil { |
| + return apiTrashResult{}, err |
| + } |
| + if status != http.StatusOK { |
| + return apiTrashResult{}, apiErrorFromBody(status, body) |
| + } |
| + var res struct { |
| + RestorableUntil string `json:"restorable_until"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return apiTrashResult{}, fmt.Errorf("apps delete: unreadable response: %w", err) |
| + } |
| + return apiTrashResult{RestorableUntil: res.RestorableUntil}, nil |
| +} |
| + |
| +// restoreApp asks the console to restore app from the Trash (carlos apps |
| +// restore, signed-in mode). It resolves through apiTrashedApp server-side, so |
| +// an app that is not (or no longer) in the trash gets the same |
| +// byte-identical 404 apiAccountApp's gate answers for an unknown account or |
| +// app — notFoundErr's generic membership text. That 404 is ALSO what a |
| +// headlessly-trashed app produces while this console's own row has not yet |
| +// materialized from the bucket (the KNOWN WINDOW the brief calls out), so |
| +// this is the one call in this file that widens a 404 rather than passing |
| +// notFoundErr through unchanged — naming the headless retry as a second way |
| +// through, not just "try again". |
| +func (s *apiSession) restoreApp(ctx context.Context, a apiAcct, app string) (apiRestoreResult, error) { |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/restore"), struct{}{}) |
| + if err != nil { |
| + return apiRestoreResult{}, err |
| + } |
| + if status != http.StatusOK { |
| + if status == http.StatusNotFound { |
| + return apiRestoreResult{}, fmt.Errorf("%w — if %s was trashed headlessly (bucket-direct, no console), "+ |
| + "this console's own record of it may not have caught up yet; try again shortly, or restore it "+ |
| + "headlessly instead (CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR and -account-id)", notFoundErr(), app) |
| + } |
| + return apiRestoreResult{}, apiErrorFromBody(status, body) |
| + } |
| + var res struct { |
| + Note string `json:"note"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return apiRestoreResult{}, fmt.Errorf("apps restore: unreadable response: %w", err) |
| + } |
| + return apiRestoreResult{Note: res.Note}, nil |
| +} |
| + |
| // ledgerAppend asks the console to CAS-append one entry to name's chain, |
| // stamping writer from the caller's own authenticated identity server-side |
| // (never client-supplied — apiledger.go). A 503 here is contention (too many |
diff --git a/apps.go b/apps.go| new file mode 100644 |
| index 000000000..e963fb353 |
| --- /dev/null |
| +++ b/apps.go |
| @@ -0,0 +1,260 @@ |
| +// The `carlos apps` verb: delete (trash) and restore an app from the command |
| +// line, in the same two modes every other write verb runs in — signed-in |
| +// through the console API, or headless straight against the deployment |
| +// bucket. See docs/superpowers/specs/2026-08-04-deletion-policy-design.md. |
| +// |
| +// There is deliberately no `create` here: draft PR #52 owns `apps create`. |
| +// Whichever of the two branches merges second rebases its own case onto this |
| +// file's shared `case "apps":` dispatch in main.go — see the comment there. |
| +// |
| +// 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" |
| + "errors" |
| + "flag" |
| + "fmt" |
| + "log" |
| + "os" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const appsUsage = `usage: carlos apps <delete|restore> <app> [flags] |
| + |
| + delete <app> move an app to the Trash: every route drops (custom |
| + domains included), its name stays reserved, and it is |
| + restorable for 30 days. 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 <app> undo a delete within the 30-day window. Releases, |
| + channels and the production flag are exactly as they were |
| + — but NO routes come back, not even the platform apex: |
| + run "carlos add <host>" afterwards to serve it again. |
| + |
| + -account <sqid|name> account the app belongs to (default: ops) — resolved |
| + by the console against your own memberships; signed-in |
| + mode only. |
| + -account-id <sqid> 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 <origin> 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/<account>/<app>/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 30 days. |
| +` |
| + |
| +// RunApps dispatches the apps sub-verbs: delete and restore ONLY. See this |
| +// file's header comment for why "create" is not one of them. |
| +func RunApps(args []string) { |
| + if len(args) == 0 { |
| + fmt.Fprint(os.Stderr, appsUsage) |
| + os.Exit(1) |
| + } |
| + switch args[0] { |
| + case "delete": |
| + runAppsDelete(args[1:]) |
| + case "restore": |
| + runAppsRestore(args[1:]) |
| + default: |
| + fmt.Fprint(os.Stderr, appsUsage) |
| + os.Exit(1) |
| + } |
| +} |
| + |
| +// 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 <sqid> — 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 <url> 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 := fs.String("console", "", "console origin to delete through (signed-in mode only; default: the only logged-in one)") |
| + fs.Parse(args) |
| + if fs.NArg() != 1 { |
| + log.Fatal("usage: carlos apps delete <app> [-account <sqid|name>] [-account-id <sqid>] [-console <origin>]") |
| + } |
| + app := fs.Arg(0) |
| + 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" { |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + res, err := sess.trashApp(context.Background(), acct, app) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + printTrashed(app, res.RestorableUntil) |
| + return |
| + } |
| + |
| + // Headless: this box has no console to enforce the production gate, so |
| + // this verb enforces it itself, the same direction and the same |
| + // wording as handleAPITrashApp — an unreadable flag is treated as |
| + // production and refused, never as "probably fine". |
| + store := openStore() |
| + pubRaw := strings.TrimSpace(os.Getenv("CARLOS_RELEASE_PUBKEY")) |
| + if pubRaw == "" { |
| + log.Fatal("apps delete (headless): 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") |
| + } |
| + pub, err := release.ParsePublicKey(pubRaw, "CARLOS_RELEASE_PUBKEY") |
| + if err != nil { |
| + log.Fatalf("apps delete: %v", err) |
| + } |
| + ctx := context.Background() |
| + prod, err := release.GetProduction(ctx, store, pub, acctID, app) |
| + if err != nil { |
| + log.Fatalf("apps delete: could not read the production flag for %s/%s — an unreadable flag is treated as "+ |
| + "production, refusing: %v", acctID, app, err) |
| + } |
| + if prod != nil && prod.Production { |
| + log.Fatalf("apps delete: %s/%s is flagged production, so deleting is refused — turn production off first, "+ |
| + "then try again", acctID, app) |
| + } |
| + if err := release.TrashApp(ctx, store, acctID, app, defaultLabel(), time.Now()); err != nil { |
| + log.Fatalf("apps delete: %v", err) |
| + } |
| + // Re-read for the SAME reason handleAPITrashApp does: TrashApp is |
| + // create-only, so an app already in the trash (trashed by a racing |
| + // headless caller, or previously) keeps its ORIGINAL clock — the marker |
| + // is the only clock this feature has, and printing this call's own |
| + // time would over-promise a deadline nothing will honour. |
| + tomb, terr := release.ReadAppTombstone(ctx, store, acctID, app) |
| + if terr != nil { |
| + log.Fatalf("apps delete: %s/%s is trashed, but the restore window could not be confirmed — retry is safe "+ |
| + "(the marker is create-only): %v", acctID, app, terr) |
| + } |
| + if tomb == nil { |
| + log.Fatalf("apps delete: %s/%s is trashed, but its marker vanished before the window could be confirmed "+ |
| + "(a restore may have raced this) — retry is safe", acctID, app) |
| + } |
| + printTrashed(app, tomb.DeletedAt.Add(release.TrashCooloff).UTC().Format(time.RFC3339)) |
| +} |
| + |
| +// printTrashed is the one shape both delete paths print in, so signed-in and |
| +// headless never drift apart on wording: the server's confirmed |
| +// restorable_until (or, headless, this box's own re-read of it), the restore |
| +// command, and the anti-ghost reminder from spec §3 that restoring brings |
| +// back releases and channels but no routes — repeated at delete time as well |
| +// as restore time because nothing else in the terminal will say it twice. |
| +func printTrashed(app, restorableUntil string) { |
| + fmt.Fprintf(stdout, "trashed %s — restorable until %s (carlos apps restore %s)\n", app, restorableUntil, app) |
| + fmt.Fprintf(stdout, "restoring brings back its releases and channels, but NO routes — "+ |
| + "carlos add <host> to serve it again\n") |
| +} |
| + |
| +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)") |
| + fs.Parse(args) |
| + if fs.NArg() != 1 { |
| + log.Fatal("usage: carlos apps restore <app> [-account <sqid|name>] [-account-id <sqid>] [-console <origin>]") |
| + } |
| + app := fs.Arg(0) |
| + 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" { |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + res, err := sess.restoreApp(context.Background(), acct, app) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Fprintf(stdout, "restored %s\n", 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 { |
| + fmt.Fprintf(stdout, "%s has NO routes — carlos add <host> 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, "restored %s\n", app) |
| + fmt.Fprintf(stdout, "%s is restored with its releases and channels intact, and with NO routes — "+ |
| + "carlos add <host> to serve it again\n", app) |
| +} |
diff --git a/apps_test.go b/apps_test.go| new file mode 100644 |
| index 000000000..e9ba50318 |
| --- /dev/null |
| +++ b/apps_test.go |
| @@ -0,0 +1,217 @@ |
| +// Tests for the `carlos apps` verb: the pure mode-selection decision |
| +// (appsMode), plus the wire shape of the two session methods (apimode.go) |
| +// runAppsDelete/runAppsRestore build on. No shelling out to the carlos |
| +// binary here — everything below drives Go functions directly, so there is |
| +// no CARLOS_CREDENTIALS-isolation hazard to guard against (the Kass rule). |
| +package main |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// TestAppsMode is Step 1's failing-test-first table: signed-in wins |
| +// outright; headless without -account-id errors mentioning -account-id; |
| +// neither configured errors mentioning both carlos auth login and the store |
| +// env vars. |
| +func TestAppsMode(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + loggedIn bool |
| + bucketConfigured bool |
| + accountID string |
| + wantMode string |
| + wantErrContains []string |
| + }{ |
| + { |
| + name: "signed-in wins", |
| + loggedIn: true, |
| + wantMode: "signed-in", |
| + }, |
| + { |
| + name: "signed-in wins even with an account-id also given", |
| + loggedIn: true, |
| + accountID: "bab", |
| + wantMode: "signed-in", |
| + }, |
| + { |
| + name: "headless with account-id", |
| + bucketConfigured: true, |
| + accountID: "bab", |
| + wantMode: "headless", |
| + }, |
| + { |
| + name: "headless without account-id errors", |
| + bucketConfigured: true, |
| + wantErrContains: []string{"-account-id"}, |
| + }, |
| + { |
| + name: "headless with only whitespace account-id errors", |
| + bucketConfigured: true, |
| + accountID: " ", |
| + wantErrContains: []string{"-account-id"}, |
| + }, |
| + { |
| + name: "neither configured errors two ways", |
| + wantErrContains: []string{"carlos auth login", "CARLOS_DEPLOYMENT_BUCKET", "CARLOS_DEPLOYMENT_DIR"}, |
| + }, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + mode, err := appsMode(tc.loggedIn, tc.bucketConfigured, tc.accountID) |
| + if tc.wantErrContains == nil { |
| + if err != nil { |
| + t.Fatalf("appsMode(%v, %v, %q) = err %v, want nil", tc.loggedIn, tc.bucketConfigured, tc.accountID, err) |
| + } |
| + if mode != tc.wantMode { |
| + t.Fatalf("appsMode(%v, %v, %q) = mode %q, want %q", tc.loggedIn, tc.bucketConfigured, tc.accountID, mode, tc.wantMode) |
| + } |
| + return |
| + } |
| + if err == nil { |
| + t.Fatalf("appsMode(%v, %v, %q) = nil error, want one mentioning %v", tc.loggedIn, tc.bucketConfigured, tc.accountID, tc.wantErrContains) |
| + } |
| + if mode != "" { |
| + t.Fatalf("appsMode(%v, %v, %q) returned mode %q alongside an error, want \"\"", tc.loggedIn, tc.bucketConfigured, tc.accountID, mode) |
| + } |
| + for _, want := range tc.wantErrContains { |
| + if !strings.Contains(err.Error(), want) { |
| + t.Fatalf("appsMode error %q does not contain %q", err.Error(), want) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// ---- apiSession.trashApp / restoreApp -------------------------------------- |
| + |
| +// TestAPISessionTrashApp pins the success wire shape handleAPITrashApp sends: |
| +// {"result": "trashed", "restorable_until": "..."}. |
| +func TestAPISessionTrashApp(t *testing.T) { |
| + var gotPath, gotAuth string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath = r.URL.Path |
| + gotAuth = r.Header.Get("Authorization") |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "result": "trashed", "restorable_until": "2026-09-03T16:00:00Z", |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + res, err := s.trashApp(context.Background(), apiAcct{Seg: "acct"}, "app") |
| + if err != nil { |
| + t.Fatalf("trashApp: %v", err) |
| + } |
| + if res.RestorableUntil != "2026-09-03T16:00:00Z" { |
| + t.Fatalf("RestorableUntil = %q, want the server's value", res.RestorableUntil) |
| + } |
| + if gotPath != "/api/cli/apps/acct/app/delete" { |
| + t.Fatalf("path = %q, want the delete door", gotPath) |
| + } |
| + if gotAuth != "Bearer cli-token" { |
| + t.Fatalf("Authorization = %q, want the bearer token", gotAuth) |
| + } |
| +} |
| + |
| +// TestAPISessionTrashAppProductionRefusal pins the 409 case (handleAPITrashApp: |
| +// production on, or unreadable) surfacing the server's own text verbatim, |
| +// exactly like promote's stable refusal surfaces the console's own message. |
| +func TestAPISessionTrashAppProductionRefusal(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusConflict) |
| + json.NewEncoder(w).Encode(map[string]string{ |
| + "error": "app is flagged production, so deleting is refused", |
| + "console_url": "http://x/a/b", |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + _, err := s.trashApp(context.Background(), apiAcct{Seg: "acct"}, "app") |
| + if err == nil || !strings.Contains(err.Error(), "flagged production") { |
| + t.Fatalf("trashApp against a 409 = %v, want the server's production-refusal text", err) |
| + } |
| +} |
| + |
| +// TestAPISessionTrashAppNotFound pins the byte-identical-404 case: an |
| +// unknown account/app or one the caller doesn't belong to. |
| +func TestAPISessionTrashAppNotFound(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + http.NotFound(w, r) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + _, err := s.trashApp(context.Background(), apiAcct{Seg: "nope"}, "app") |
| + if err == nil || !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "-account") { |
| + t.Fatalf("trashApp against the account/app gate 404 = %v, want the membership message", err) |
| + } |
| +} |
| + |
| +// TestAPISessionRestoreApp pins the success wire shape handleAPIRestoreApp |
| +// sends: {"result": "restored", "note": "..."}, note surfaced verbatim. |
| +func TestAPISessionRestoreApp(t *testing.T) { |
| + var gotPath string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath = r.URL.Path |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "result": "restored", "note": "app is restored with NO routes: run `carlos add <host>`", |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + res, err := s.restoreApp(context.Background(), apiAcct{Seg: "acct"}, "app") |
| + if err != nil { |
| + t.Fatalf("restoreApp: %v", err) |
| + } |
| + if !strings.Contains(res.Note, "carlos add") { |
| + t.Fatalf("Note = %q, want the server's routing reminder", res.Note) |
| + } |
| + if gotPath != "/api/cli/apps/acct/app/restore" { |
| + t.Fatalf("path = %q, want the restore door", gotPath) |
| + } |
| +} |
| + |
| +// TestAPISessionRestoreAppNotFoundNamesHeadlessRetry pins the KNOWN WINDOW |
| +// advice: a 404 (apiTrashedApp's gate — including a headlessly-trashed app |
| +// this console has not yet materialized) must name the headless retry as a |
| +// second way through, not just the bare membership message. |
| +func TestAPISessionRestoreAppNotFoundNamesHeadlessRetry(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + http.NotFound(w, r) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + _, err := s.restoreApp(context.Background(), apiAcct{Seg: "acct"}, "app") |
| + if err == nil { |
| + t.Fatal("restoreApp against a 404 must error") |
| + } |
| + if !strings.Contains(err.Error(), "not found") { |
| + t.Fatalf("restoreApp 404 error = %q, want the membership message", err) |
| + } |
| + if !strings.Contains(err.Error(), "headless") { |
| + t.Fatalf("restoreApp 404 error = %q, want it to name the headless retry", err) |
| + } |
| +} |
| + |
| +func TestAPISessionRestoreAppServerError(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusBadGateway) |
| + json.NewEncoder(w).Encode(map[string]string{ |
| + "error": "could not remove the trash marker — nothing was restored; try again", |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + _, err := s.restoreApp(context.Background(), apiAcct{Seg: "acct"}, "app") |
| + if err == nil || !strings.Contains(err.Error(), "nothing was restored") { |
| + t.Fatalf("restoreApp against a 502 = %v, want the server's own text", err) |
| + } |
| +} |
diff --git a/main.go b/main.go| index 1ad7f2335..e510544aa 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -47,6 +47,11 @@ func main() { |
| RunAuth(args) |
| case "accounts": |
| RunAccounts(args) |
| + // "apps" is shared: this branch adds delete/restore (apps.go); draft PR |
| + // #52 adds create. Whichever of the two merges second rebases its own |
| + // case onto this one rather than adding a second "case \"apps\":". |
| + case "apps": |
| + RunApps(args) |
| case "env": |
| RunEnv(args) |
| case "secrets": |
| @@ -95,6 +100,13 @@ commands: |
| auth login | whoami | logout — who this terminal is |
| (headless? don't log in: pass -owner instead) |
| accounts create | list | migrate — the tenancy primitive |
| + apps delete | restore — trash an app for 30 days |
| + (routes drop everywhere; refused while flagged |
| + production) or undo that within the window |
| + (no routes come back; carlos add <host> after); |
| + signed-in via the console or headless against |
| + the bucket (-account-id required headless); no |
| + purge here — "Delete now" is console-only |
| env set | unset | list — plain per-app config vars |
| secrets set | unset | list | genkey — sealed per-app secrets |
| ship | promote | channels publish and promote releases (-account <sqid|name>) |