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,159 @@
1
+ import { parseTeardownArgs } from "../core/args";
2
+ import type { Palette } from "../core/colors";
3
+ import { resourceName } from "../core/plan";
4
+ import type { ApplyStep } from "../core/types";
5
+ import { renderDestroy, renderHeader } from "../core/ui";
6
+ import type { FileSystem } from "../seams/files";
7
+ import type { Prompt } from "../seams/io";
8
+ import type { Wrangler } from "../seams/wrangler";
9
+
10
+ // The `teardown` command (§21): archive D1 first (unless --no-archive), then delete both Workers + the
11
+ // database. The R2 bucket and the Cloudflare Access app can't be removed with pure wrangler (no bulk
12
+ // R2 list; no Access API), so it deletes the bucket only if already empty and prints the manual steps.
13
+ // Destructive — confirmed by typing the instance name (or --yes). All I/O via injected seams.
14
+
15
+ export interface TeardownEnv {
16
+ wrangler: Wrangler;
17
+ prompt: Prompt;
18
+ fs: FileSystem;
19
+ palette: Palette;
20
+ out: (line: string) => void;
21
+ rootDir: string;
22
+ /** Where this instance's state lives — must match deploy (see core/paths). */
23
+ stateDir: string;
24
+ nowStamp: () => string;
25
+ interactive: boolean;
26
+ }
27
+
28
+ function fail(env: TeardownEnv, reason: string, hint: string): number {
29
+ env.out(env.palette.red(`teardown: ${reason}`));
30
+ if (hint) env.out(` → ${hint}`);
31
+ return 1;
32
+ }
33
+
34
+ export async function runTeardown(argv: readonly string[], env: TeardownEnv): Promise<number> {
35
+ const parsed = parseTeardownArgs(argv);
36
+ if (!parsed.ok) return fail(env, parsed.error, parsed.hint ?? "");
37
+ const args = parsed.value;
38
+ const res = resourceName(args.instance);
39
+ const deployDir = env.stateDir;
40
+ const archiveDir = args.archiveDir ?? deployDir;
41
+ const archiveFile = `${archiveDir}/${args.instance}-${env.nowStamp()}.sql`;
42
+ const wr = env.wrangler;
43
+
44
+ env.out(renderHeader(args.instance, env.palette));
45
+
46
+ if (!args.dryRun) {
47
+ const who = await wr.run(["whoami"]);
48
+ if (!who.ok)
49
+ return fail(env, "not authenticated to Cloudflare", "run once: npx wrangler login");
50
+ }
51
+
52
+ // The destructive plan, shown before anything runs.
53
+ const plan: ApplyStep[] = [];
54
+ if (args.archive) {
55
+ plan.push({
56
+ kind: "create",
57
+ what: "archive D1",
58
+ why: "",
59
+ command: `wrangler d1 export ${res} --remote`,
60
+ });
61
+ }
62
+ plan.push({
63
+ kind: "delete",
64
+ what: "app Worker",
65
+ why: "",
66
+ command: `wrangler delete --name ${res}`,
67
+ });
68
+ plan.push({
69
+ kind: "delete",
70
+ what: "admin Worker",
71
+ why: "",
72
+ command: `wrangler delete --name ${res}-admin`,
73
+ });
74
+ plan.push({ kind: "delete", what: "database", why: "", command: `wrangler d1 delete ${res}` });
75
+ plan.push({
76
+ kind: "delete",
77
+ what: "R2 bucket",
78
+ why: "only if already empty",
79
+ command: `wrangler r2 bucket delete ${res}`,
80
+ });
81
+ env.out(renderDestroy(plan, env.palette));
82
+
83
+ // Confirm by typing the instance name (or --yes). Non-interactive without --yes → refuse, don't hang.
84
+ if (!args.dryRun && !args.yes) {
85
+ if (!env.interactive) {
86
+ return fail(env, "destructive run isn't interactive", "re-run with --yes to confirm");
87
+ }
88
+ const typed = await env.prompt.ask(`Type the instance name "${args.instance}" to confirm: `);
89
+ if (typed !== args.instance) {
90
+ env.out("aborted — nothing was deleted.");
91
+ return 1;
92
+ }
93
+ }
94
+
95
+ env.out("");
96
+ env.out(env.palette.bold("Tearing down…"));
97
+ const startStep = (label: string): void => env.out(env.palette.dim(` → ${label}…`));
98
+ const doneStep = (label: string, extra = ""): void =>
99
+ env.out(env.palette.green(` ✓ ${label}${extra === "" ? "" : ` ${extra}`}`));
100
+
101
+ // 1. Archive D1 BEFORE destroying (it must still exist). Abort on failure unless --no-archive.
102
+ if (args.archive) {
103
+ startStep("archive database");
104
+ const exported = await wr.run(["d1", "export", res, "--remote", "--output", archiveFile]);
105
+ if (!args.dryRun && !exported.ok) {
106
+ return fail(
107
+ env,
108
+ "D1 export failed — nothing was destroyed",
109
+ "fix the error above, or re-run with --no-archive to destroy without a backup",
110
+ );
111
+ }
112
+ doneStep("archive", archiveFile);
113
+ }
114
+
115
+ // 2. Delete both Workers (tolerate already-gone — re-runs/partial teardown are fine).
116
+ startStep("delete app Worker");
117
+ await wr.run(["delete", "--name", res]);
118
+ doneStep("app Worker");
119
+ startStep("delete admin Worker");
120
+ await wr.run(["delete", "--name", `${res}-admin`]);
121
+ doneStep("admin Worker");
122
+
123
+ // 3. R2 — deletes only if empty (pure wrangler can't list/empty a bucket); report if it survives.
124
+ startStep("delete R2 bucket");
125
+ const r2 = await wr.run(["r2", "bucket", "delete", res]);
126
+ const r2Left = !args.dryRun && !r2.ok;
127
+ if (r2Left) env.out(env.palette.yellow(` ! R2 bucket ${res} not deleted (likely non-empty)`));
128
+ else doneStep("R2 bucket");
129
+
130
+ // 4. D1 database.
131
+ startStep("delete database");
132
+ await wr.run(["d1", "delete", res, "--skip-confirmation"]);
133
+ doneStep("database");
134
+
135
+ // 5. Local config + state (real runs only — dry-run must not touch the filesystem).
136
+ if (!args.dryRun) {
137
+ await env.fs.remove(`${deployDir}/${args.instance}.app.toml`);
138
+ await env.fs.remove(`${deployDir}/${args.instance}.admin.toml`);
139
+ await env.fs.remove(`${deployDir}/${args.instance}.state.json`);
140
+ }
141
+
142
+ env.out("");
143
+ env.out(
144
+ env.palette.green(`Removed ${args.instance}: both Workers and the D1 database are gone.`),
145
+ );
146
+ if (args.archive) {
147
+ env.out(` Database archived → ${archiveFile} (contains live tokens — store it safely)`);
148
+ }
149
+ env.out("Finish by hand (pure wrangler can't):");
150
+ if (r2Left) {
151
+ env.out(
152
+ ` - Empty + delete the R2 bucket '${res}' in the dashboard (R2 → the bucket → delete).`,
153
+ );
154
+ }
155
+ env.out(
156
+ ` - Remove the Cloudflare Access app for '${res}-admin' (Zero Trust → Access → Applications).`,
157
+ );
158
+ return 0;
159
+ }
@@ -0,0 +1,205 @@
1
+ import { err, ok, type Result } from "./result";
2
+ import type { Role } from "./types";
3
+
4
+ // Pure flag parsing + validation for the `deploy` command — the same rules the bash deploy.sh enforced
5
+ // (slug charset, email pairing, Access pairing), now typed and unit-tested instead of scattered shell
6
+ // checks. Returns a Result; the command layer renders an error like the preflight (reason + hint).
7
+
8
+ export type EmailProvider = "none" | "cloudflare";
9
+
10
+ export interface DeployArgs {
11
+ instance: string;
12
+ appName: string | null;
13
+ activateScheme: string | null;
14
+ blurb: string | null;
15
+ accent: string | null;
16
+ accessTeamDomain: string | null;
17
+ accessAud: string | null;
18
+ emailProvider: EmailProvider;
19
+ emailFrom: string | null;
20
+ dryRun: boolean;
21
+ yes: boolean;
22
+ }
23
+
24
+ // Lowercase letters, digits and hyphens; no leading/trailing hyphen (doubles allowed, as in deploy.sh).
25
+ const SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
26
+
27
+ const VALUE_FLAGS = new Set([
28
+ "--instance",
29
+ "--app-name",
30
+ "--activate-scheme",
31
+ "--blurb",
32
+ "--accent",
33
+ "--access-team-domain",
34
+ "--access-aud",
35
+ "--email-provider",
36
+ "--email-from",
37
+ ]);
38
+
39
+ /** Strip a pasted scheme/trailing slash so the in-app issuer check can't silently fail on a URL form. */
40
+ export function normalizeTeamDomain(raw: string): string {
41
+ return raw.replace(/^https?:\/\//, "").replace(/\/+$/, "");
42
+ }
43
+
44
+ export function parseDeployArgs(argv: readonly string[]): Result<DeployArgs> {
45
+ const values: Record<string, string> = {};
46
+ let dryRun = false;
47
+ let yes = false;
48
+
49
+ for (let i = 0; i < argv.length; i++) {
50
+ const flag = argv[i];
51
+ if (flag === undefined) continue;
52
+ if (flag === "--dry-run") {
53
+ dryRun = true;
54
+ } else if (flag === "--yes") {
55
+ yes = true;
56
+ } else if (VALUE_FLAGS.has(flag)) {
57
+ const value = argv[i + 1];
58
+ if (value === undefined) return err(`${flag} needs a value`);
59
+ values[flag] = value;
60
+ i++;
61
+ } else {
62
+ return err(`unknown flag: ${flag}`, "run `deploy --help` for the supported flags");
63
+ }
64
+ }
65
+
66
+ const instance = values["--instance"];
67
+ if (instance === undefined || instance === "") {
68
+ return err("--instance is required", "e.g. --instance myalpha");
69
+ }
70
+ if (!SLUG.test(instance)) {
71
+ return err(
72
+ `invalid --instance '${instance}'`,
73
+ "lowercase letters, digits and hyphens only (no leading/trailing hyphen)",
74
+ );
75
+ }
76
+
77
+ const emailProvider = values["--email-provider"] ?? "none";
78
+ if (emailProvider !== "none" && emailProvider !== "cloudflare") {
79
+ return err(`invalid --email-provider '${emailProvider}'`, "expected 'none' or 'cloudflare'");
80
+ }
81
+ const emailFrom = values["--email-from"] ?? null;
82
+ if (emailProvider === "cloudflare" && (emailFrom === null || emailFrom === "")) {
83
+ return err(
84
+ "--email-from is required when --email-provider is cloudflare",
85
+ "pass --email-from alpha@<your-sending-domain>",
86
+ );
87
+ }
88
+
89
+ const rawTeam = values["--access-team-domain"] ?? null;
90
+ const accessAud = values["--access-aud"] ?? null;
91
+ if ((rawTeam === null) !== (accessAud === null)) {
92
+ return err(
93
+ "--access-team-domain and --access-aud must be provided together",
94
+ "both are on the Access app's Overview page in Cloudflare Zero Trust",
95
+ );
96
+ }
97
+
98
+ return ok({
99
+ instance,
100
+ appName: values["--app-name"] ?? null,
101
+ activateScheme: values["--activate-scheme"] ?? null,
102
+ blurb: values["--blurb"] ?? null,
103
+ accent: values["--accent"] ?? null,
104
+ accessTeamDomain: rawTeam === null ? null : normalizeTeamDomain(rawTeam),
105
+ accessAud,
106
+ emailProvider,
107
+ emailFrom,
108
+ dryRun,
109
+ yes,
110
+ });
111
+ }
112
+
113
+ export interface TeardownArgs {
114
+ instance: string;
115
+ archive: boolean;
116
+ archiveDir: string | null;
117
+ yes: boolean;
118
+ dryRun: boolean;
119
+ }
120
+
121
+ const TEARDOWN_VALUE_FLAGS = new Set(["--instance", "--archive-dir"]);
122
+
123
+ export function parseTeardownArgs(argv: readonly string[]): Result<TeardownArgs> {
124
+ const values: Record<string, string> = {};
125
+ let archive = true;
126
+ let yes = false;
127
+ let dryRun = false;
128
+
129
+ for (let i = 0; i < argv.length; i++) {
130
+ const flag = argv[i];
131
+ if (flag === undefined) continue;
132
+ if (flag === "--no-archive") {
133
+ archive = false;
134
+ } else if (flag === "--yes") {
135
+ yes = true;
136
+ } else if (flag === "--dry-run") {
137
+ dryRun = true;
138
+ } else if (TEARDOWN_VALUE_FLAGS.has(flag)) {
139
+ const value = argv[i + 1];
140
+ if (value === undefined) return err(`${flag} needs a value`);
141
+ values[flag] = value;
142
+ i++;
143
+ } else {
144
+ return err(`unknown flag: ${flag}`, "run `teardown --help` for the supported flags");
145
+ }
146
+ }
147
+
148
+ const instance = values["--instance"];
149
+ if (instance === undefined || instance === "") {
150
+ return err("--instance is required", "e.g. --instance myalpha");
151
+ }
152
+ if (!SLUG.test(instance)) {
153
+ return err(
154
+ `invalid --instance '${instance}'`,
155
+ "lowercase letters, digits and hyphens only (no leading/trailing hyphen)",
156
+ );
157
+ }
158
+
159
+ return ok({ instance, archive, archiveDir: values["--archive-dir"] ?? null, yes, dryRun });
160
+ }
161
+
162
+ export interface DevArgs {
163
+ role: Role;
164
+ port: number;
165
+ seed: boolean;
166
+ reset: boolean;
167
+ }
168
+
169
+ export function parseDevArgs(argv: readonly string[]): Result<DevArgs> {
170
+ let role: Role = "app";
171
+ let port = 8787;
172
+ let seed = true;
173
+ let reset = false;
174
+
175
+ for (let i = 0; i < argv.length; i++) {
176
+ const flag = argv[i];
177
+ if (flag === undefined) continue;
178
+ if (flag === "--no-seed") {
179
+ seed = false;
180
+ } else if (flag === "--reset") {
181
+ reset = true;
182
+ } else if (flag === "--role") {
183
+ const value = argv[i + 1];
184
+ i++;
185
+ if (value !== "app" && value !== "admin") {
186
+ return err(`invalid --role '${value ?? ""}'`, "expected 'app' or 'admin'");
187
+ }
188
+ role = value;
189
+ } else if (flag === "--port") {
190
+ const value = argv[i + 1];
191
+ i++;
192
+ const parsed = value === undefined ? Number.NaN : Number.parseInt(value, 10);
193
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
194
+ return err(`invalid --port '${value ?? ""}'`, "expected a port number 1–65535");
195
+ }
196
+ port = parsed;
197
+ } else {
198
+ return err(`unknown flag: ${flag}`, "run `dev --help` for the supported flags");
199
+ }
200
+ }
201
+
202
+ return ok({ role, port, seed, reset });
203
+ }
204
+
205
+ export type { Role };
@@ -0,0 +1,49 @@
1
+ // ANSI color, kept behind a Palette so the renderers stay pure and testable: callers pass a palette
2
+ // in, and the "should we actually emit color?" decision (TTY / NO_COLOR) lives at the edge (the CLI),
3
+ // never in a render function. Tests use plainPalette for stable, code-free assertions.
4
+
5
+ export interface Palette {
6
+ green(s: string): string;
7
+ red(s: string): string;
8
+ yellow(s: string): string;
9
+ cyan(s: string): string;
10
+ dim(s: string): string;
11
+ bold(s: string): string;
12
+ }
13
+
14
+ // Close with the attribute-specific reset (39 = default fg, 22 = normal intensity) rather than 0, so
15
+ // nesting one style inside another doesn't wipe the outer style.
16
+ function sgr(open: number, close: number): (s: string) => string {
17
+ return (s) => `\x1b[${open}m${s}\x1b[${close}m`;
18
+ }
19
+
20
+ export const colorPalette: Palette = {
21
+ green: sgr(32, 39),
22
+ red: sgr(31, 39),
23
+ yellow: sgr(33, 39),
24
+ cyan: sgr(36, 39),
25
+ dim: sgr(2, 22),
26
+ bold: sgr(1, 22),
27
+ };
28
+
29
+ const identity = (s: string): string => s;
30
+ export const plainPalette: Palette = {
31
+ green: identity,
32
+ red: identity,
33
+ yellow: identity,
34
+ cyan: identity,
35
+ dim: identity,
36
+ bold: identity,
37
+ };
38
+
39
+ export function selectPalette(useColor: boolean): Palette {
40
+ return useColor ? colorPalette : plainPalette;
41
+ }
42
+
43
+ /** Honor NO_COLOR / FORCE_COLOR, else color only when stdout is a TTY. Pure over its inputs (testable). */
44
+ export function shouldColor(env: NodeJS.ProcessEnv, isTty: boolean): boolean {
45
+ if (env.NO_COLOR !== undefined && env.NO_COLOR !== "") return false;
46
+ if (env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== "" && env.FORCE_COLOR !== "0")
47
+ return true;
48
+ return isTty;
49
+ }
@@ -0,0 +1,65 @@
1
+ import type { Role } from "./types";
2
+
3
+ // Renders a Worker's wrangler.toml in TS — replaces the old envsubst-over-a-template step (decision
4
+ // 0009). Building it directly (not string-substituting an external template) means there's no
5
+ // "unsubstituted ${VAR}" failure mode, values are escaped, and the output is unit-tested. This function
6
+ // is the single source of truth for the rendered wrangler config shape.
7
+
8
+ export interface ConfigVars {
9
+ instance: string;
10
+ d1Id: string;
11
+ role: Role;
12
+ name: string;
13
+ emailProvider: "none" | "cloudflare";
14
+ emailFrom: string;
15
+ toolVersion: string;
16
+ updateManifestUrl: string;
17
+ /** Worker entry — an ABSOLUTE path into the package src, so the config resolves it wherever the
18
+ rendered .toml lives (repo `.deploy/`, or `~/.alpha-gate` for an npm install). */
19
+ main: string;
20
+ /** ABSOLUTE path to the migrations dir (same reason as `main`). */
21
+ migrationsDir: string;
22
+ }
23
+
24
+ function tomlString(value: string): string {
25
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
26
+ }
27
+
28
+ export function renderConfig(v: ConfigVars): string {
29
+ const q = (s: string): string => `"${tomlString(s)}"`;
30
+ const lines: string[] = [
31
+ `name = ${q(v.name)}`,
32
+ `main = ${q(v.main)}`,
33
+ `compatibility_date = "2025-01-01"`,
34
+ `workers_dev = true`,
35
+ "",
36
+ "[[d1_databases]]",
37
+ `binding = "DB"`,
38
+ `database_name = ${q(`alpha-gate-${v.instance}`)}`,
39
+ `database_id = ${q(v.d1Id)}`,
40
+ // Absolute path into the package, so `wrangler d1 migrations apply` finds it no matter where the
41
+ // rendered config lives (repo `.deploy/` or the relocated `~/.alpha-gate`).
42
+ `migrations_dir = ${q(v.migrationsDir)}`,
43
+ "",
44
+ "[[r2_buckets]]",
45
+ `binding = "BUILDS"`,
46
+ `bucket_name = ${q(`alpha-gate-${v.instance}`)}`,
47
+ "",
48
+ "[vars]",
49
+ `INSTANCE = ${q(v.instance)}`,
50
+ `ROLE = ${q(v.role)}`,
51
+ `EMAIL_PROVIDER = ${q(v.emailProvider)}`,
52
+ `EMAIL_FROM = ${q(v.emailFrom)}`,
53
+ `TOOL_VERSION = ${q(v.toolVersion)}`,
54
+ `UPDATE_MANIFEST_URL = ${q(v.updateManifestUrl)}`,
55
+ ];
56
+
57
+ // The Cloudflare Email Service binding goes on the ADMIN Worker only, and only when email is on —
58
+ // the public app Worker never sends mail, so it must not carry the binding.
59
+ if (v.role === "admin" && v.emailProvider === "cloudflare") {
60
+ lines.push("", "[[send_email]]", `name = "EMAIL"`);
61
+ }
62
+
63
+ lines.push("", "[triggers]", `crons = ["0 12 * * *"]`, "");
64
+ return lines.join("\n");
65
+ }
@@ -0,0 +1,51 @@
1
+ // Pure parsers/validators for wrangler's output. Centralizing them (vs. inline grep/jq in bash) means
2
+ // every value pulled from a command is validated before use — an empty/garbled result becomes null/[]
3
+ // here and the command layer fails loudly rather than writing poison into the state file.
4
+
5
+ /** The D1 uuid for `dbName` from `wrangler d1 list --json`, or null if absent/garbled. */
6
+ export function parseD1Id(listJson: string, dbName: string): string | null {
7
+ try {
8
+ const list: unknown = JSON.parse(listJson);
9
+ if (!Array.isArray(list)) return null;
10
+ for (const item of list) {
11
+ if (
12
+ item !== null &&
13
+ typeof item === "object" &&
14
+ (item as { name?: unknown }).name === dbName
15
+ ) {
16
+ const id = (item as { uuid?: unknown }).uuid;
17
+ return typeof id === "string" && id.length > 0 ? id : null;
18
+ }
19
+ }
20
+ return null;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ /** Secret names from `wrangler secret list --format json` (e.g. to check ACCESS_TEAM_DOMAIN is set). */
27
+ export function secretNames(listJson: string): string[] {
28
+ try {
29
+ const list: unknown = JSON.parse(listJson);
30
+ if (!Array.isArray(list)) return [];
31
+ const names: string[] = [];
32
+ for (const item of list) {
33
+ const name =
34
+ item !== null && typeof item === "object" ? (item as { name?: unknown }).name : undefined;
35
+ if (typeof name === "string") names.push(name);
36
+ }
37
+ return names;
38
+ } catch {
39
+ return [];
40
+ }
41
+ }
42
+
43
+ export function accessConfigured(secretListJson: string): boolean {
44
+ return secretNames(secretListJson).includes("ACCESS_TEAM_DOMAIN");
45
+ }
46
+
47
+ /** The one unavoidable scrape: the workers.dev URL from `wrangler deploy` stdout (no --json for it). */
48
+ export function extractDeployUrl(stdout: string): string | null {
49
+ const match = stdout.match(/https:\/\/[a-z0-9.-]+\.workers\.dev/i);
50
+ return match ? match[0] : null;
51
+ }
@@ -0,0 +1,27 @@
1
+ // Where the per-instance deploy state (.deploy/<slug>.state.json + the rendered wrangler configs)
2
+ // lives. Two modes, so the CLI works whether it was `git clone`d or installed from npm:
3
+ //
4
+ // - a git checkout (contributor) → `<packageRoot>/.deploy`, exactly as before, so existing
5
+ // deployments keep finding their state.
6
+ // - an npm install / npx run → `~/.alpha-gate`, because the package files sit in the (versioned,
7
+ // ephemeral) npm cache — state written there would vanish on the next `npx alpha-gate@newer`.
8
+ //
9
+ // $ALPHA_GATE_HOME overrides both. Pure over its inputs (env + a "does <root>/.git exist" probe) so
10
+ // it's unit-testable without touching the filesystem.
11
+
12
+ export interface StateDirInputs {
13
+ packageRoot: string;
14
+ /** $ALPHA_GATE_HOME, if set. */
15
+ home: string | undefined;
16
+ /** $HOME (for the ~/.alpha-gate default). */
17
+ userHome: string | undefined;
18
+ /** Whether `<packageRoot>/.git` exists — the signal for "running from a checkout". */
19
+ isGitCheckout: boolean;
20
+ }
21
+
22
+ export function resolveStateDir(inputs: StateDirInputs): string {
23
+ if (inputs.home !== undefined && inputs.home !== "") return inputs.home;
24
+ if (inputs.isGitCheckout) return `${inputs.packageRoot}/.deploy`;
25
+ const base = inputs.userHome !== undefined && inputs.userHome !== "" ? inputs.userHome : ".";
26
+ return `${base}/.alpha-gate`;
27
+ }