alpha-gate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +21 -0
  3. package/README.md +138 -0
  4. package/bin/alpha-gate.mjs +75 -0
  5. package/deploy/backup.sh +45 -0
  6. package/deploy/deploy.sh +21 -0
  7. package/deploy/dev.sh +56 -0
  8. package/deploy/lib/statedir.sh +11 -0
  9. package/deploy/teardown.sh +17 -0
  10. package/docs/ONBOARDING.md +16 -0
  11. package/docs/PRINCIPLES.md +195 -0
  12. package/docs/README.md +54 -0
  13. package/docs/UPLOADING.md +14 -0
  14. package/docs/integrate/activation.md +56 -0
  15. package/docs/integrate/sparkle-go.md +106 -0
  16. package/docs/integrate/sparkle-swift.md +63 -0
  17. package/docs/maintain/backup.md +52 -0
  18. package/docs/maintain/migrate-account.md +95 -0
  19. package/docs/maintain/teardown.md +46 -0
  20. package/docs/maintain/troubleshooting.md +102 -0
  21. package/docs/maintain/updating.md +92 -0
  22. package/docs/operate/add-users.md +55 -0
  23. package/docs/operate/channels.md +54 -0
  24. package/docs/operate/email.md +51 -0
  25. package/docs/operate/monitoring.md +56 -0
  26. package/docs/operate/publish.md +90 -0
  27. package/docs/operate/remove-users.md +47 -0
  28. package/docs/setup/cloudflare-account.md +41 -0
  29. package/docs/setup/deploy.md +84 -0
  30. package/docs/setup/install.md +65 -0
  31. package/migrations/0001_clients.sql +12 -0
  32. package/migrations/0002_builds_streams.sql +30 -0
  33. package/migrations/0003_access_log.sql +14 -0
  34. package/migrations/0004_meta.sql +5 -0
  35. package/migrations/0005_admin_audit.sql +14 -0
  36. package/migrations/0006_build_dmg.sql +6 -0
  37. package/migrations/0007_access_requests.sql +12 -0
  38. package/migrations/0008_build_rollback_target.sql +7 -0
  39. package/migrations/0009_hidden.sql +6 -0
  40. package/migrations/0010_purged_at.sql +5 -0
  41. package/package.json +65 -0
  42. package/publish.sh +302 -0
  43. package/release.json +7 -0
  44. package/src/auth/access-jwt.ts +210 -0
  45. package/src/auth/token-gate.ts +27 -0
  46. package/src/core/appcast.ts +107 -0
  47. package/src/core/audit-chain.ts +145 -0
  48. package/src/core/invite-template.ts +127 -0
  49. package/src/core/no-build.ts +160 -0
  50. package/src/core/resolver.ts +60 -0
  51. package/src/core/tokens.ts +51 -0
  52. package/src/core/types.ts +76 -0
  53. package/src/core/validation.ts +60 -0
  54. package/src/core/verdict.ts +195 -0
  55. package/src/core/version.ts +113 -0
  56. package/src/cron.ts +37 -0
  57. package/src/db/access-log.ts +168 -0
  58. package/src/db/access-requests.ts +90 -0
  59. package/src/db/admin-audit.ts +101 -0
  60. package/src/db/builds.ts +169 -0
  61. package/src/db/client.ts +80 -0
  62. package/src/db/clients.ts +103 -0
  63. package/src/db/meta.ts +30 -0
  64. package/src/db/streams.ts +85 -0
  65. package/src/deploy/cli.ts +181 -0
  66. package/src/deploy/commands/deploy.ts +392 -0
  67. package/src/deploy/commands/dev.ts +190 -0
  68. package/src/deploy/commands/teardown.ts +159 -0
  69. package/src/deploy/core/args.ts +205 -0
  70. package/src/deploy/core/colors.ts +49 -0
  71. package/src/deploy/core/config.ts +65 -0
  72. package/src/deploy/core/parse.ts +51 -0
  73. package/src/deploy/core/paths.ts +27 -0
  74. package/src/deploy/core/plan.ts +138 -0
  75. package/src/deploy/core/result.ts +13 -0
  76. package/src/deploy/core/state.ts +64 -0
  77. package/src/deploy/core/table.ts +49 -0
  78. package/src/deploy/core/types.ts +39 -0
  79. package/src/deploy/core/ui.ts +107 -0
  80. package/src/deploy/seams/clock.ts +10 -0
  81. package/src/deploy/seams/files.ts +52 -0
  82. package/src/deploy/seams/io.ts +56 -0
  83. package/src/deploy/seams/wrangler.ts +100 -0
  84. package/src/deploy/ui-preview.ts +112 -0
  85. package/src/deps.ts +41 -0
  86. package/src/dev/admin-entry.ts +104 -0
  87. package/src/env.ts +47 -0
  88. package/src/lib/clock.ts +17 -0
  89. package/src/lib/hosts.ts +27 -0
  90. package/src/lib/text.ts +6 -0
  91. package/src/r2/builds-bucket.ts +50 -0
  92. package/src/r2/keys.ts +27 -0
  93. package/src/routes/admin/admin-context.ts +9 -0
  94. package/src/routes/admin/audit-fields.ts +22 -0
  95. package/src/routes/admin/branding.tsx +161 -0
  96. package/src/routes/admin/builds.tsx +388 -0
  97. package/src/routes/admin/clients.tsx +396 -0
  98. package/src/routes/admin/confirm.tsx +80 -0
  99. package/src/routes/admin/flash.ts +72 -0
  100. package/src/routes/admin/form.ts +43 -0
  101. package/src/routes/admin/index.ts +146 -0
  102. package/src/routes/admin/invite.ts +57 -0
  103. package/src/routes/admin/middleware.ts +52 -0
  104. package/src/routes/admin/negotiate.ts +13 -0
  105. package/src/routes/admin/pending.tsx +73 -0
  106. package/src/routes/admin/read-model.ts +518 -0
  107. package/src/routes/admin/streams.tsx +182 -0
  108. package/src/routes/admin/theme.ts +34 -0
  109. package/src/routes/admin/upload.tsx +320 -0
  110. package/src/routes/admin/views.tsx +293 -0
  111. package/src/routes/app/access.tsx +38 -0
  112. package/src/routes/app/app-context.ts +8 -0
  113. package/src/routes/app/appcast.ts +68 -0
  114. package/src/routes/app/assets.ts +25 -0
  115. package/src/routes/app/download.ts +45 -0
  116. package/src/routes/app/get.tsx +35 -0
  117. package/src/routes/app/index.ts +31 -0
  118. package/src/routes/app/resolve.ts +16 -0
  119. package/src/services/anchor.ts +54 -0
  120. package/src/services/audit.ts +19 -0
  121. package/src/services/branding.ts +51 -0
  122. package/src/services/email-cloudflare.ts +80 -0
  123. package/src/services/email.ts +68 -0
  124. package/src/services/self-update.ts +62 -0
  125. package/src/views/access-page.tsx +39 -0
  126. package/src/views/admin/ci-page.tsx +76 -0
  127. package/src/views/admin/combobox.tsx +191 -0
  128. package/src/views/admin/forms.tsx +37 -0
  129. package/src/views/admin/layout.tsx +496 -0
  130. package/src/views/admin/manage-pages.tsx +223 -0
  131. package/src/views/admin/manage.tsx +1120 -0
  132. package/src/views/admin/plist-extract.ts +233 -0
  133. package/src/views/admin/read-pages.tsx +1098 -0
  134. package/src/views/admin/setup-page.tsx +108 -0
  135. package/src/views/admin/table-enhance.ts +176 -0
  136. package/src/views/admin/ui.tsx +159 -0
  137. package/src/views/get-page.tsx +39 -0
  138. package/src/views/layout.tsx +57 -0
  139. package/src/worker.ts +23 -0
  140. package/tsconfig.json +35 -0
@@ -0,0 +1,138 @@
1
+ import type { DeployArgs } from "./args";
2
+ import type { ApplyStep, Finding, InspectStep } from "./types";
3
+
4
+ // Pure planning: from the parsed args + what INSPECT learned, compute the read-only inspect commands,
5
+ // the findings to echo, and the APPLY steps (with skips). This is the brain the orchestration (#28)
6
+ // renders + executes; keeping it pure means idempotency and the skip logic are unit-tested with no I/O.
7
+
8
+ export interface Inspection {
9
+ /** Existing D1 uuid, or null when it must be created. */
10
+ d1Id: string | null;
11
+ bucketExists: boolean;
12
+ accessConfigured: boolean;
13
+ /** Whether app config (meta.app_name) is already set — we don't reseed over admin edits. */
14
+ seeded: boolean;
15
+ }
16
+
17
+ export function resourceName(instance: string): string {
18
+ return `alpha-gate-${instance}`;
19
+ }
20
+
21
+ export function inspectSteps(args: DeployArgs): InspectStep[] {
22
+ const res = resourceName(args.instance);
23
+ return [
24
+ { why: "account & login", command: "wrangler whoami" },
25
+ { why: "database exists?", command: "wrangler d1 list --json" },
26
+ { why: "bucket exists?", command: `wrangler r2 bucket info ${res}` },
27
+ {
28
+ why: "Access wired?",
29
+ command: `wrangler secret list --config .deploy/${args.instance}.admin.toml --format json`,
30
+ },
31
+ ];
32
+ }
33
+
34
+ export function inspectionFindings(ins: Inspection): Finding[] {
35
+ return [
36
+ { label: "database", value: ins.d1Id ? `exists (${ins.d1Id.slice(0, 8)}…)` : "not found" },
37
+ { label: "bucket", value: ins.bucketExists ? "exists" : "not found" },
38
+ { label: "Access", value: ins.accessConfigured ? "configured" : "not configured" },
39
+ ];
40
+ }
41
+
42
+ // The app-config values seeded into `meta` on a first init (only the ones actually provided).
43
+ const SEED_KEYS: ReadonlyArray<readonly [keyof DeployArgs, string]> = [
44
+ ["appName", "app_name"],
45
+ ["activateScheme", "activate_scheme"],
46
+ ["blurb", "blurb"],
47
+ ["accent", "accent"],
48
+ ];
49
+
50
+ export function seedValues(args: DeployArgs): { key: string; value: string }[] {
51
+ const out: { key: string; value: string }[] = [];
52
+ for (const [argKey, metaKey] of SEED_KEYS) {
53
+ const value = args[argKey];
54
+ if (typeof value === "string" && value.length > 0) out.push({ key: metaKey, value });
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /** The seed SQL (INSERT OR IGNORE — never clobbers admin edits), or null when there's nothing to seed. */
60
+ export function buildSeedSql(args: DeployArgs): string | null {
61
+ const values = seedValues(args);
62
+ if (values.length === 0) return null;
63
+ return values
64
+ .map(({ key, value }) => {
65
+ const escaped = value.replace(/'/g, "''");
66
+ return `INSERT OR IGNORE INTO meta (key, value) VALUES ('${key}', '${escaped}');`;
67
+ })
68
+ .join("");
69
+ }
70
+
71
+ /** True when the operator must enable Cloudflare Access by hand (no creds given, none configured yet). */
72
+ export function accessManualNeeded(args: DeployArgs, ins: Inspection): boolean {
73
+ const haveCreds = args.accessTeamDomain !== null && args.accessAud !== null;
74
+ return !haveCreds && !ins.accessConfigured;
75
+ }
76
+
77
+ export function buildApplyPlan(args: DeployArgs, ins: Inspection): ApplyStep[] {
78
+ const res = resourceName(args.instance);
79
+ const steps: ApplyStep[] = [];
80
+
81
+ steps.push(
82
+ ins.d1Id === null
83
+ ? { kind: "create", what: "database", why: "", command: `wrangler d1 create ${res}` }
84
+ : { kind: "skip", what: "database", why: "exists — skipping", command: "" },
85
+ );
86
+ steps.push(
87
+ ins.bucketExists
88
+ ? { kind: "skip", what: "bucket", why: "exists — skipping", command: "" }
89
+ : { kind: "create", what: "bucket", why: "", command: `wrangler r2 bucket create ${res}` },
90
+ );
91
+ steps.push({
92
+ kind: "update",
93
+ what: "migrations",
94
+ why: "",
95
+ command: `wrangler d1 migrations apply ${res} --remote`,
96
+ });
97
+
98
+ const seeds = seedValues(args);
99
+ if (ins.seeded || seeds.length === 0) {
100
+ steps.push({
101
+ kind: "skip",
102
+ what: "app config",
103
+ why: ins.seeded ? "already set — skipping" : "nothing to seed",
104
+ command: "",
105
+ });
106
+ } else {
107
+ steps.push({
108
+ kind: "create",
109
+ what: "app config",
110
+ why: "",
111
+ command: `wrangler d1 execute ${res} --remote (seed: ${seeds.map((s) => s.key).join(", ")})`,
112
+ });
113
+ }
114
+
115
+ steps.push({
116
+ kind: "update",
117
+ what: "deploy app",
118
+ why: "",
119
+ command: `wrangler deploy -c .deploy/${args.instance}.app.toml`,
120
+ });
121
+ steps.push({
122
+ kind: "update",
123
+ what: "deploy admin",
124
+ why: "",
125
+ command: `wrangler deploy -c .deploy/${args.instance}.admin.toml`,
126
+ });
127
+
128
+ if (args.accessTeamDomain !== null && args.accessAud !== null) {
129
+ steps.push({
130
+ kind: "update",
131
+ what: "Access secrets",
132
+ why: "",
133
+ command: "wrangler deploy admin --secrets-file (ACCESS_TEAM_DOMAIN, ACCESS_AUD)",
134
+ });
135
+ }
136
+
137
+ return steps;
138
+ }
@@ -0,0 +1,13 @@
1
+ // A tiny Result type so the pure validators/parsers report failure as data (with a fix-it hint) rather
2
+ // than throwing — the command layer turns an error Result into the same "→ what to do" output as the
3
+ // preflight, and tests assert on it directly.
4
+
5
+ export type Result<T> = { ok: true; value: T } | { ok: false; error: string; hint?: string };
6
+
7
+ export function ok<T>(value: T): Result<T> {
8
+ return { ok: true, value };
9
+ }
10
+
11
+ export function err<T>(error: string, hint?: string): Result<T> {
12
+ return hint === undefined ? { ok: false, error } : { ok: false, error, hint };
13
+ }
@@ -0,0 +1,64 @@
1
+ // The per-instance state ledger (.deploy/<instance>.state.json). It records the resource id + URLs
2
+ // plus the remembered inputs. The ON-DISK shape keeps deploy.sh's snake_case keys
3
+ // (instance/app_url/admin_url/d1_id) so the existing publish.sh/teardown.sh keep reading it unchanged.
4
+ // parseState is tolerant: a missing/corrupt file (or unknown keys, e.g. the retired `phases` array
5
+ // from older versions) yields a clean state rather than throwing.
6
+
7
+ export interface DeployState {
8
+ instance: string;
9
+ d1Id: string | null;
10
+ appUrl: string | null;
11
+ adminUrl: string | null;
12
+ // Remembered inputs so a bare re-run (`deploy --instance X`) preserves them instead of silently
13
+ // reverting to defaults — the classic "email turned itself off on the next deploy" bug.
14
+ emailProvider: string | null;
15
+ emailFrom: string | null;
16
+ accessTeamDomain: string | null;
17
+ accessAud: string | null;
18
+ }
19
+
20
+ export function emptyState(instance: string): DeployState {
21
+ return {
22
+ instance,
23
+ d1Id: null,
24
+ appUrl: null,
25
+ adminUrl: null,
26
+ emailProvider: null,
27
+ emailFrom: null,
28
+ accessTeamDomain: null,
29
+ accessAud: null,
30
+ };
31
+ }
32
+
33
+ export function serializeState(state: DeployState): string {
34
+ const onDisk = {
35
+ instance: state.instance,
36
+ app_url: state.appUrl,
37
+ admin_url: state.adminUrl,
38
+ d1_id: state.d1Id,
39
+ email_provider: state.emailProvider,
40
+ email_from: state.emailFrom,
41
+ access_team_domain: state.accessTeamDomain,
42
+ access_aud: state.accessAud,
43
+ };
44
+ return `${JSON.stringify(onDisk, null, 2)}\n`;
45
+ }
46
+
47
+ export function parseState(json: string, instance: string): DeployState {
48
+ try {
49
+ const obj = JSON.parse(json) as Record<string, unknown>;
50
+ const str = (v: unknown): string | null => (typeof v === "string" && v.length > 0 ? v : null);
51
+ return {
52
+ instance: str(obj.instance) ?? instance,
53
+ d1Id: str(obj.d1_id),
54
+ appUrl: str(obj.app_url),
55
+ adminUrl: str(obj.admin_url),
56
+ emailProvider: str(obj.email_provider),
57
+ emailFrom: str(obj.email_from),
58
+ accessTeamDomain: str(obj.access_team_domain),
59
+ accessAud: str(obj.access_aud),
60
+ };
61
+ } catch {
62
+ return emptyState(instance);
63
+ }
64
+ }
@@ -0,0 +1,49 @@
1
+ import type { Palette } from "./colors";
2
+
3
+ // A pure box-drawing table renderer. Column widths are measured on PLAIN text and the cell is padded
4
+ // BEFORE any color style is applied, so ANSI codes never throw the alignment off. (Our cell text is
5
+ // all single-width — box chars, ✓/✗/·, …, ASCII — so String.length is the right measure.)
6
+
7
+ export interface Cell {
8
+ text: string;
9
+ /** Applied after padding (e.g. palette.green); alignment is preserved. */
10
+ style?: (s: string) => string;
11
+ }
12
+
13
+ export interface TableOptions {
14
+ head?: string[];
15
+ }
16
+
17
+ export function renderTable(rows: Cell[][], palette: Palette, options: TableOptions = {}): string {
18
+ const cols = rows.reduce((max, row) => Math.max(max, row.length), options.head?.length ?? 0);
19
+ const widths: number[] = [];
20
+ for (let c = 0; c < cols; c++) {
21
+ let width = options.head?.[c]?.length ?? 0;
22
+ for (const row of rows) width = Math.max(width, row[c]?.text.length ?? 0);
23
+ widths.push(width);
24
+ }
25
+
26
+ const bar = (left: string, mid: string, right: string): string =>
27
+ palette.dim(left + widths.map((w) => "─".repeat(w + 2)).join(mid) + right);
28
+
29
+ const sep = palette.dim("│");
30
+ const renderRow = (cells: Cell[]): string => {
31
+ const inner = widths
32
+ .map((w, i) => {
33
+ const cell = cells[i] ?? { text: "" };
34
+ const padded = ` ${cell.text}${" ".repeat(w - cell.text.length)} `;
35
+ return cell.style ? cell.style(padded) : padded;
36
+ })
37
+ .join(sep);
38
+ return `${sep}${inner}${sep}`;
39
+ };
40
+
41
+ const lines = [bar("┌", "┬", "┐")];
42
+ if (options.head) {
43
+ lines.push(renderRow(options.head.map((h) => ({ text: h, style: palette.bold }))));
44
+ lines.push(bar("├", "┼", "┤"));
45
+ }
46
+ for (const row of rows) lines.push(renderRow(row));
47
+ lines.push(bar("└", "┴", "┘"));
48
+ return lines.join("\n");
49
+ }
@@ -0,0 +1,39 @@
1
+ // Shared data shapes for the deploy CLI. Kept free of I/O so the planner, the renderer, and the
2
+ // validators are all pure functions over plain data (mirrors the app's pure-core discipline).
3
+
4
+ export type Role = "app" | "admin";
5
+
6
+ /** A read-only command the CLI will run during the INSPECT phase, with the reason it's needed. */
7
+ export interface InspectStep {
8
+ /** Short, human reason shown to the operator (the "why"). */
9
+ why: string;
10
+ /** The exact command, shown verbatim so nothing runs unseen. */
11
+ command: string;
12
+ }
13
+
14
+ /** A fact learned during INSPECT, echoed back before planning the APPLY phase. */
15
+ export interface Finding {
16
+ label: string;
17
+ value: string;
18
+ }
19
+
20
+ export type ApplyKind = "create" | "update" | "skip" | "delete";
21
+
22
+ /** A single change the APPLY phase will make (or skip), with its reason and exact command. */
23
+ export interface ApplyStep {
24
+ kind: ApplyKind;
25
+ /** Short label for the thing being changed, e.g. "database". */
26
+ what: string;
27
+ /** The reason / detail (also the displayed text for a `skip`). */
28
+ why: string;
29
+ /** The exact command; empty for a `skip` (nothing runs). */
30
+ command: string;
31
+ }
32
+
33
+ /** Result of a single preflight tool/auth check. */
34
+ export interface PreflightItem {
35
+ name: string;
36
+ ok: boolean;
37
+ /** What to show — the version/account on success, or the fix-it hint on failure. */
38
+ detail: string;
39
+ }
@@ -0,0 +1,107 @@
1
+ import type { Palette } from "./colors";
2
+ import { type Cell, renderTable } from "./table";
3
+ import type { ApplyStep, Finding, InspectStep, PreflightItem } from "./types";
4
+
5
+ // The "grouped panels" console UI: phased, table-formatted, color when the terminal supports it. Pure
6
+ // — every function returns the string to print and takes the Palette in, so wording/alignment/markers
7
+ // are unit-tested and color is a presentation detail decided at the edge. Transparency contract holds:
8
+ // the operator always sees the phase, the reason ("why"), and the exact command before anything runs.
9
+
10
+ export function renderHeader(instance: string, palette: Palette): string {
11
+ return palette.bold(`Alpha Gate deploy · ${instance}`);
12
+ }
13
+
14
+ /** Preflight tool/auth checks as a table: ✓ green / ✗ red with the detail or fix-it hint. */
15
+ export function renderPreflight(items: readonly PreflightItem[], palette: Palette): string {
16
+ const rows: Cell[][] = items.map((item) => [
17
+ { text: item.name },
18
+ {
19
+ text: `${item.ok ? "✓" : "✗"} ${item.detail}`,
20
+ style: item.ok ? palette.green : palette.red,
21
+ },
22
+ ]);
23
+ return renderTable(rows, palette, { head: ["Check", "Result"] });
24
+ }
25
+
26
+ /** Read-only INSPECT phase: a Why | Command table; commands dimmed. */
27
+ export function renderInspect(steps: readonly InspectStep[], palette: Palette): string {
28
+ const rows: Cell[][] = steps.map((s) => [
29
+ { text: s.why },
30
+ { text: s.command, style: palette.dim },
31
+ ]);
32
+ return [
33
+ palette.bold("1 · INSPECT") + palette.dim(" read-only — learn current state"),
34
+ renderTable(rows, palette, { head: ["Why", "Command"] }),
35
+ ].join("\n");
36
+ }
37
+
38
+ /** The facts learned during INSPECT, echoed before the APPLY plan. */
39
+ export function renderFindings(findings: readonly Finding[], palette: Palette): string {
40
+ const rows: Cell[][] = findings.map((f) => [
41
+ { text: f.label },
42
+ { text: f.value, style: palette.cyan },
43
+ ]);
44
+ return renderTable(rows, palette, { head: ["Resource", "State"] });
45
+ }
46
+
47
+ const MARK: Record<ApplyStep["kind"], string> = {
48
+ create: "+",
49
+ update: "~",
50
+ delete: "-",
51
+ skip: "·",
52
+ };
53
+
54
+ /** A Δ | Resource | Command-or-reason table under a heading; marker colored by change kind. Shared by
55
+ * the deploy APPLY phase and the teardown DESTROY plan. */
56
+ function renderStepTable(heading: string, steps: readonly ApplyStep[], palette: Palette): string {
57
+ const markStyle: Record<ApplyStep["kind"], (s: string) => string> = {
58
+ create: palette.green,
59
+ update: palette.cyan,
60
+ delete: palette.red,
61
+ skip: palette.dim,
62
+ };
63
+ const rows: Cell[][] = steps.map((s) => [
64
+ { text: MARK[s.kind], style: markStyle[s.kind] },
65
+ { text: s.what },
66
+ { text: s.kind === "skip" ? s.why : s.command, style: palette.dim },
67
+ ]);
68
+ return [
69
+ heading,
70
+ renderTable(rows, palette, { head: ["Δ", "Resource", "Command / reason"] }),
71
+ ].join("\n");
72
+ }
73
+
74
+ /** Mutating APPLY phase (deploy). */
75
+ export function renderApply(steps: readonly ApplyStep[], palette: Palette): string {
76
+ return renderStepTable(
77
+ palette.bold("2 · APPLY") + palette.dim(" creates/changes resources"),
78
+ steps,
79
+ palette,
80
+ );
81
+ }
82
+
83
+ /** Destructive plan (teardown). */
84
+ export function renderDestroy(steps: readonly ApplyStep[], palette: Palette): string {
85
+ return renderStepTable(
86
+ palette.bold("DESTROY") + palette.dim(" this permanently deletes resources"),
87
+ steps,
88
+ palette,
89
+ );
90
+ }
91
+
92
+ /**
93
+ * A step only the operator can do (e.g. enabling Cloudflare Access in the dashboard). The CLI shows
94
+ * this, then BLOCKS on a prompt seam until the operator confirms it's done — see seams/io waitForDone.
95
+ */
96
+ export function renderManualStep(
97
+ title: string,
98
+ steps: readonly string[],
99
+ palette: Palette,
100
+ ): string {
101
+ const lines = [
102
+ palette.yellow(`⚙ MANUAL STEP — only you can do this`),
103
+ title,
104
+ ...steps.map((s, i) => ` ${i + 1}. ${s}`),
105
+ ];
106
+ return lines.join("\n");
107
+ }
@@ -0,0 +1,10 @@
1
+ // The deploy CLI's one sanctioned use of Date (allow-listed in biome.json, like src/lib/clock.ts):
2
+ // a filesystem-safe UTC stamp for the teardown archive filename, e.g. "20260614T120000Z". Injected as
3
+ // a seam so tests pass a fixed value.
4
+
5
+ export function nowStamp(): string {
6
+ return new Date()
7
+ .toISOString()
8
+ .replace(/[-:]/g, "")
9
+ .replace(/\.\d+Z$/, "Z");
10
+ }
@@ -0,0 +1,52 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+
3
+ // The filesystem seam — rendering configs, the state ledger, and the temp secrets file. Behind an
4
+ // interface so the command orchestration is tested against an in-memory fake. read() returns null for
5
+ // a missing file (callers treat that as "first run") rather than throwing.
6
+
7
+ export interface FileSystem {
8
+ read(path: string): Promise<string | null>;
9
+ write(path: string, data: string): Promise<void>;
10
+ mkdirp(path: string): Promise<void>;
11
+ remove(path: string): Promise<void>;
12
+ }
13
+
14
+ export function createFileSystem(): FileSystem {
15
+ return {
16
+ async read(path) {
17
+ try {
18
+ return await readFile(path, "utf8");
19
+ } catch {
20
+ return null;
21
+ }
22
+ },
23
+ async write(path, data) {
24
+ await writeFile(path, data, "utf8");
25
+ },
26
+ async mkdirp(path) {
27
+ await mkdir(path, { recursive: true });
28
+ },
29
+ async remove(path) {
30
+ await rm(path, { force: true, recursive: true });
31
+ },
32
+ };
33
+ }
34
+
35
+ export function createFakeFileSystem(
36
+ seed: Record<string, string> = {},
37
+ ): FileSystem & { files: Map<string, string> } {
38
+ const files = new Map<string, string>(Object.entries(seed));
39
+ return {
40
+ files,
41
+ read: (path) => Promise.resolve(files.get(path) ?? null),
42
+ write: (path, data) => {
43
+ files.set(path, data);
44
+ return Promise.resolve();
45
+ },
46
+ mkdirp: () => Promise.resolve(),
47
+ remove: (path) => {
48
+ files.delete(path);
49
+ return Promise.resolve();
50
+ },
51
+ };
52
+ }
@@ -0,0 +1,56 @@
1
+ import { createInterface } from "node:readline/promises";
2
+
3
+ // The interactive seam. Kept behind an interface so the command orchestration is testable with a fake
4
+ // (canned answers) — the real implementation uses node:readline. This is where requirement #3 lives:
5
+ // a manual step the CLI can't perform is shown, then waitForDone BLOCKS until the operator confirms.
6
+
7
+ export interface Prompt {
8
+ /** Ask a free-text question; returns the trimmed answer ("" if the operator just pressed Enter). */
9
+ ask(question: string): Promise<string>;
10
+ /** Yes/No, defaulting to NO — used for the APPLY confirmation gate. */
11
+ confirm(question: string): Promise<boolean>;
12
+ /** Block until the operator acknowledges a manual step is finished (Enter to continue). */
13
+ waitForDone(question: string): Promise<void>;
14
+ }
15
+
16
+ export function createPrompt(): Prompt {
17
+ async function ask(question: string): Promise<string> {
18
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
19
+ try {
20
+ return (await rl.question(question)).trim();
21
+ } finally {
22
+ rl.close();
23
+ }
24
+ }
25
+ return {
26
+ ask,
27
+ async confirm(question) {
28
+ const answer = (await ask(`${question} [y/N] `)).toLowerCase();
29
+ return answer === "y" || answer === "yes";
30
+ },
31
+ async waitForDone(question) {
32
+ await ask(`${question} `);
33
+ },
34
+ };
35
+ }
36
+
37
+ /** A scripted prompt for tests: each call consumes the next canned answer (default "" when exhausted). */
38
+ export function createFakePrompt(answers: readonly string[]): Prompt & { asked: string[] } {
39
+ const queue = [...answers];
40
+ const asked: string[] = [];
41
+ async function ask(question: string): Promise<string> {
42
+ asked.push(question);
43
+ return queue.shift() ?? "";
44
+ }
45
+ return {
46
+ asked,
47
+ ask,
48
+ async confirm(question) {
49
+ const answer = (await ask(question)).toLowerCase();
50
+ return answer === "y" || answer === "yes";
51
+ },
52
+ async waitForDone(question) {
53
+ await ask(question);
54
+ },
55
+ };
56
+ }
@@ -0,0 +1,100 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+
3
+ // The wrangler seam: the ONLY place the CLI shells out. Commands are passed as an argv array (never a
4
+ // shell string), so there's no quoting/injection surface. run() resolves with the exit code + captured
5
+ // output — it does NOT reject on a non-zero exit, so the command layer decides what a failure means
6
+ // and can surface a clear message. A dry-run variant logs the intended command and no-ops; tests use a
7
+ // programmable fake.
8
+
9
+ export interface RunResult {
10
+ ok: boolean;
11
+ code: number;
12
+ stdout: string;
13
+ stderr: string;
14
+ }
15
+
16
+ export interface RunOptions {
17
+ /** Written to the process stdin (used for `wrangler secret put`). */
18
+ input?: string;
19
+ }
20
+
21
+ export interface Wrangler {
22
+ run(args: readonly string[], opts?: RunOptions): Promise<RunResult>;
23
+ /** Spawn a long-running command with inherited stdio (e.g. `wrangler dev`); resolves on exit. */
24
+ exec(args: readonly string[]): Promise<number>;
25
+ }
26
+
27
+ export interface WranglerOptions {
28
+ /** When true, log the command and return success without running anything. */
29
+ dryRun?: boolean;
30
+ /** Sink for the dry-run echo / live command echo (defaults to console.error). */
31
+ log?: (line: string) => void;
32
+ /** Directory to run `npx wrangler` from — set to the package root so the bundled wrangler (a
33
+ dependency) resolves even when the user's CWD has no node_modules (an npx install). */
34
+ cwd?: string;
35
+ }
36
+
37
+ export function createWrangler(options: WranglerOptions = {}): Wrangler {
38
+ const log = options.log ?? ((line) => console.error(line));
39
+ const cwd = options.cwd;
40
+ return {
41
+ run(args, opts = {}) {
42
+ if (options.dryRun) {
43
+ log(`[dry-run] wrangler ${args.join(" ")}`);
44
+ return Promise.resolve({ ok: true, code: 0, stdout: "", stderr: "" });
45
+ }
46
+ return new Promise<RunResult>((resolve) => {
47
+ const child = execFile(
48
+ "npx",
49
+ ["wrangler", ...args],
50
+ { maxBuffer: 64 * 1024 * 1024, cwd },
51
+ (error, stdout, stderr) => {
52
+ const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
53
+ resolve({ ok: code === 0, code, stdout, stderr });
54
+ },
55
+ );
56
+ if (opts.input !== undefined) {
57
+ child.stdin?.end(opts.input);
58
+ }
59
+ });
60
+ },
61
+ exec(args) {
62
+ if (options.dryRun) {
63
+ log(`[dry-run] wrangler ${args.join(" ")}`);
64
+ return Promise.resolve(0);
65
+ }
66
+ return new Promise<number>((resolve) => {
67
+ const child = spawn("npx", ["wrangler", ...args], { stdio: "inherit", cwd });
68
+ child.on("exit", (code) => resolve(code ?? 0));
69
+ child.on("error", () => resolve(1));
70
+ });
71
+ },
72
+ };
73
+ }
74
+
75
+ /**
76
+ * A programmable fake for orchestration tests: `handler` returns the result for a given argv (defaults
77
+ * to success). Every call is recorded in `calls` so tests assert the command sequence.
78
+ */
79
+ export function createFakeWrangler(
80
+ handler: (args: readonly string[], input?: string) => Partial<RunResult> = () => ({}),
81
+ ): Wrangler & { calls: string[][] } {
82
+ const calls: string[][] = [];
83
+ return {
84
+ calls,
85
+ run(args, opts = {}) {
86
+ calls.push([...args]);
87
+ const partial = handler(args, opts.input);
88
+ return Promise.resolve({
89
+ ok: partial.ok ?? (partial.code === undefined || partial.code === 0),
90
+ code: partial.code ?? 0,
91
+ stdout: partial.stdout ?? "",
92
+ stderr: partial.stderr ?? "",
93
+ });
94
+ },
95
+ exec(args) {
96
+ calls.push([...args]);
97
+ return Promise.resolve(0);
98
+ },
99
+ };
100
+ }