carlos / platform Public

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>
Paul Campbell d23c4ab19a7d8e215bce17ff12547258d20eb375 parent 06fc6ab
4 files changed, +561 −0
  • apimode.go +72 −0
  • apps.go +260 −0
  • apps_test.go +217 −0
  • main.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>)