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,80 @@
1
+ // The thin seam over the D1 binding: typed prepared-statement helpers with parameter binding and
2
+ // error normalization. NO ORM — every query module writes raw SQL and uses these three primitives.
3
+
4
+ export class DbError extends Error {
5
+ constructor(
6
+ readonly sql: string,
7
+ cause: unknown,
8
+ ) {
9
+ super(`D1 query failed: ${sql}`, { cause });
10
+ this.name = "DbError";
11
+ }
12
+ }
13
+
14
+ /** First matching row, or null. */
15
+ export async function queryOne<T>(
16
+ db: D1Database,
17
+ sql: string,
18
+ params: readonly unknown[] = [],
19
+ ): Promise<T | null> {
20
+ try {
21
+ return (
22
+ (await db
23
+ .prepare(sql)
24
+ .bind(...params)
25
+ .first<T>()) ?? null
26
+ );
27
+ } catch (cause) {
28
+ throw new DbError(sql, cause);
29
+ }
30
+ }
31
+
32
+ /** All matching rows. */
33
+ export async function queryAll<T>(
34
+ db: D1Database,
35
+ sql: string,
36
+ params: readonly unknown[] = [],
37
+ ): Promise<T[]> {
38
+ try {
39
+ const { results } = await db
40
+ .prepare(sql)
41
+ .bind(...params)
42
+ .all<T>();
43
+ return results;
44
+ } catch (cause) {
45
+ throw new DbError(sql, cause);
46
+ }
47
+ }
48
+
49
+ /** A statement run for its effect (INSERT/UPDATE/DELETE without a returned row). */
50
+ export async function execute(
51
+ db: D1Database,
52
+ sql: string,
53
+ params: readonly unknown[] = [],
54
+ ): Promise<void> {
55
+ try {
56
+ await db
57
+ .prepare(sql)
58
+ .bind(...params)
59
+ .run();
60
+ } catch (cause) {
61
+ throw new DbError(sql, cause);
62
+ }
63
+ }
64
+
65
+ /** Like execute, but returns the number of rows changed — for conditional/optimistic writes. */
66
+ export async function executeWithChanges(
67
+ db: D1Database,
68
+ sql: string,
69
+ params: readonly unknown[] = [],
70
+ ): Promise<number> {
71
+ try {
72
+ const result = await db
73
+ .prepare(sql)
74
+ .bind(...params)
75
+ .run();
76
+ return result.meta.changes ?? 0;
77
+ } catch (cause) {
78
+ throw new DbError(sql, cause);
79
+ }
80
+ }
@@ -0,0 +1,103 @@
1
+ import type { Client, ClientStatus } from "../core/types";
2
+ import { execute, queryAll, queryOne } from "./client";
3
+
4
+ // Raw prepared statements for the `clients` table. Returns plain Client domain objects (camelCase)
5
+ // for the pure core; maps the snake_case D1 row here so no other layer sees column names.
6
+
7
+ interface ClientRow {
8
+ id: number;
9
+ email: string;
10
+ token: string;
11
+ status: string;
12
+ pinned_build_id: number | null;
13
+ label: string | null;
14
+ hidden: number;
15
+ created_at: string;
16
+ updated_at: string;
17
+ }
18
+
19
+ function toClient(row: ClientRow): Client {
20
+ return {
21
+ id: row.id,
22
+ email: row.email,
23
+ token: row.token,
24
+ status: row.status as ClientStatus,
25
+ pinnedBuildId: row.pinned_build_id,
26
+ label: row.label,
27
+ hidden: row.hidden !== 0,
28
+ createdAt: row.created_at,
29
+ updatedAt: row.updated_at,
30
+ };
31
+ }
32
+
33
+ export interface NewClient {
34
+ email: string;
35
+ token: string;
36
+ label?: string | null;
37
+ }
38
+
39
+ /** Token lookup for the public gate — by the UNIQUE-indexed column so the DB does the comparison. */
40
+ export async function findByToken(db: D1Database, token: string): Promise<Client | null> {
41
+ const row = await queryOne<ClientRow>(db, "SELECT * FROM clients WHERE token = ?", [token]);
42
+ return row ? toClient(row) : null;
43
+ }
44
+
45
+ export async function getById(db: D1Database, id: number): Promise<Client | null> {
46
+ const row = await queryOne<ClientRow>(db, "SELECT * FROM clients WHERE id = ?", [id]);
47
+ return row ? toClient(row) : null;
48
+ }
49
+
50
+ export async function findByEmail(db: D1Database, email: string): Promise<Client | null> {
51
+ const row = await queryOne<ClientRow>(db, "SELECT * FROM clients WHERE email = ?", [email]);
52
+ return row ? toClient(row) : null;
53
+ }
54
+
55
+ export async function list(db: D1Database): Promise<Client[]> {
56
+ const rows = await queryAll<ClientRow>(db, "SELECT * FROM clients ORDER BY id");
57
+ return rows.map(toClient);
58
+ }
59
+
60
+ export async function insert(db: D1Database, input: NewClient): Promise<Client> {
61
+ const row = await queryOne<ClientRow>(
62
+ db,
63
+ "INSERT INTO clients (email, token, label) VALUES (?, ?, ?) RETURNING *",
64
+ [input.email, input.token, input.label ?? null],
65
+ );
66
+ if (row === null) throw new Error("INSERT clients returned no row");
67
+ return toClient(row);
68
+ }
69
+
70
+ export async function setStatus(db: D1Database, id: number, status: ClientStatus): Promise<void> {
71
+ await execute(db, "UPDATE clients SET status = ?, updated_at = datetime('now') WHERE id = ?", [
72
+ status,
73
+ id,
74
+ ]);
75
+ }
76
+
77
+ /** Re-issue: replace the token (§12 journey 5). */
78
+ export async function setToken(db: D1Database, id: number, token: string): Promise<void> {
79
+ await execute(db, "UPDATE clients SET token = ?, updated_at = datetime('now') WHERE id = ?", [
80
+ token,
81
+ id,
82
+ ]);
83
+ }
84
+
85
+ /** Admin-list visibility (declutter only; does not affect resolution). */
86
+ export async function setHidden(db: D1Database, id: number, hidden: boolean): Promise<void> {
87
+ await execute(db, "UPDATE clients SET hidden = ?, updated_at = datetime('now') WHERE id = ?", [
88
+ hidden ? 1 : 0,
89
+ id,
90
+ ]);
91
+ }
92
+
93
+ export async function setPinnedBuild(
94
+ db: D1Database,
95
+ id: number,
96
+ buildId: number | null,
97
+ ): Promise<void> {
98
+ await execute(
99
+ db,
100
+ "UPDATE clients SET pinned_build_id = ?, updated_at = datetime('now') WHERE id = ?",
101
+ [buildId, id],
102
+ );
103
+ }
package/src/db/meta.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { execute, queryAll, queryOne } from "./client";
2
+
3
+ // §22/§13 — the meta key-value table: branding text, the email template, and self-update bookkeeping.
4
+
5
+ export async function get(db: D1Database, key: string): Promise<string | null> {
6
+ const row = await queryOne<{ value: string | null }>(db, "SELECT value FROM meta WHERE key = ?", [
7
+ key,
8
+ ]);
9
+ return row?.value ?? null;
10
+ }
11
+
12
+ export async function set(db: D1Database, key: string, value: string): Promise<void> {
13
+ await execute(
14
+ db,
15
+ "INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
16
+ [key, value],
17
+ );
18
+ }
19
+
20
+ export async function getAll(db: D1Database): Promise<Record<string, string>> {
21
+ const rows = await queryAll<{ key: string; value: string | null }>(
22
+ db,
23
+ "SELECT key, value FROM meta",
24
+ );
25
+ const out: Record<string, string> = {};
26
+ for (const row of rows) {
27
+ if (row.value !== null) out[row.key] = row.value;
28
+ }
29
+ return out;
30
+ }
@@ -0,0 +1,85 @@
1
+ import type { Stream, UserStreamLink } from "../core/types";
2
+ import { execute, queryAll, queryOne } from "./client";
3
+
4
+ // Raw prepared statements for `streams` and the `user_streams` join (release channels and the
5
+ // per-user assignments the resolver reads).
6
+
7
+ interface StreamRow {
8
+ id: number;
9
+ name: string;
10
+ }
11
+
12
+ function toStream(row: StreamRow): Stream {
13
+ return { id: row.id, name: row.name };
14
+ }
15
+
16
+ export async function create(db: D1Database, name: string): Promise<Stream> {
17
+ const row = await queryOne<StreamRow>(db, "INSERT INTO streams (name) VALUES (?) RETURNING *", [
18
+ name,
19
+ ]);
20
+ if (row === null) throw new Error("INSERT streams returned no row");
21
+ return toStream(row);
22
+ }
23
+
24
+ /** Deletes a channel and its links first (FK-safe): build_streams + user_streams, then the row. */
25
+ export async function remove(db: D1Database, id: number): Promise<void> {
26
+ await execute(db, "DELETE FROM build_streams WHERE stream_id = ?", [id]);
27
+ await execute(db, "DELETE FROM user_streams WHERE stream_id = ?", [id]);
28
+ await execute(db, "DELETE FROM streams WHERE id = ?", [id]);
29
+ }
30
+
31
+ export async function list(db: D1Database): Promise<Stream[]> {
32
+ const rows = await queryAll<StreamRow>(db, "SELECT * FROM streams ORDER BY id");
33
+ return rows.map(toStream);
34
+ }
35
+
36
+ export async function getByName(db: D1Database, name: string): Promise<Stream | null> {
37
+ const row = await queryOne<StreamRow>(db, "SELECT * FROM streams WHERE name = ?", [name]);
38
+ return row ? toStream(row) : null;
39
+ }
40
+
41
+ export async function getById(db: D1Database, id: number): Promise<Stream | null> {
42
+ const row = await queryOne<StreamRow>(db, "SELECT * FROM streams WHERE id = ?", [id]);
43
+ return row ? toStream(row) : null;
44
+ }
45
+
46
+ export async function assignUser(
47
+ db: D1Database,
48
+ clientId: number,
49
+ streamId: number,
50
+ ): Promise<void> {
51
+ await execute(db, "INSERT OR IGNORE INTO user_streams (client_id, stream_id) VALUES (?, ?)", [
52
+ clientId,
53
+ streamId,
54
+ ]);
55
+ }
56
+
57
+ export async function unassignUser(
58
+ db: D1Database,
59
+ clientId: number,
60
+ streamId: number,
61
+ ): Promise<void> {
62
+ await execute(db, "DELETE FROM user_streams WHERE client_id = ? AND stream_id = ?", [
63
+ clientId,
64
+ streamId,
65
+ ]);
66
+ }
67
+
68
+ /** All user→stream links (the resolver/no-build World input). */
69
+ export async function listUserStreams(db: D1Database): Promise<UserStreamLink[]> {
70
+ const rows = await queryAll<{ client_id: number; stream_id: number }>(
71
+ db,
72
+ "SELECT client_id, stream_id FROM user_streams",
73
+ );
74
+ return rows.map((row) => ({ clientId: row.client_id, streamId: row.stream_id }));
75
+ }
76
+
77
+ /** The stream ids one client is assigned to — the slice /appcast needs for a single token. */
78
+ export async function streamIdsForClient(db: D1Database, clientId: number): Promise<number[]> {
79
+ const rows = await queryAll<{ stream_id: number }>(
80
+ db,
81
+ "SELECT stream_id FROM user_streams WHERE client_id = ?",
82
+ [clientId],
83
+ );
84
+ return rows.map((row) => row.stream_id);
85
+ }
@@ -0,0 +1,181 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import net from "node:net";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { type DeployEnv, runDeploy } from "./commands/deploy";
7
+ import { type DevEnv, runDev } from "./commands/dev";
8
+ import { runTeardown, type TeardownEnv } from "./commands/teardown";
9
+ import { selectPalette, shouldColor } from "./core/colors";
10
+ import { resolveStateDir } from "./core/paths";
11
+ import { nowStamp } from "./seams/clock";
12
+ import { createFileSystem } from "./seams/files";
13
+ import { createPrompt } from "./seams/io";
14
+ import { createWrangler } from "./seams/wrangler";
15
+
16
+ // The deploy CLI entry: assembles the real seams (wrangler/prompt/fs/clock), picks the color palette
17
+ // from the terminal, and dispatches to a command. Run via tsx from the thin deploy/*.sh wrappers OR
18
+ // from the npm `bin` (npx alpha-gate …).
19
+
20
+ const here = path.dirname(fileURLToPath(import.meta.url));
21
+ const ROOT = path.resolve(here, "../.."); // package root (src/deploy → ../../)
22
+
23
+ // State lives at <root>/.deploy for a git checkout, ~/.alpha-gate for an npm install (the package
24
+ // files are in the ephemeral npm cache; state there would vanish on the next version). See core/paths.
25
+ const STATE_DIR = resolveStateDir({
26
+ packageRoot: ROOT,
27
+ home: process.env.ALPHA_GATE_HOME,
28
+ userHome: homedir(),
29
+ isGitCheckout: existsSync(path.join(ROOT, ".git")),
30
+ });
31
+
32
+ // The self-update manifest the daily cron polls: the npm registry's `/latest` endpoint for THIS
33
+ // package, so the deployed Worker's banner tracks whatever version is published to npm. Derived from
34
+ // package.json's `name`; $UPDATE_MANIFEST_URL overrides (e.g. to point at a fork's own package or a
35
+ // static release.json). Until the package is published to npm, the fetch just 404s and the banner
36
+ // stays quiet — a graceful no-op, same as before.
37
+ function readPkg(): { name?: string; version?: string } {
38
+ try {
39
+ return JSON.parse(readFileSync(path.join(ROOT, "package.json"), "utf8"));
40
+ } catch {
41
+ return {};
42
+ }
43
+ }
44
+
45
+ function npmManifestUrl(): string {
46
+ if (process.env.UPDATE_MANIFEST_URL) return process.env.UPDATE_MANIFEST_URL;
47
+ const name = readPkg().name ?? "alpha-gate";
48
+ return `https://registry.npmjs.org/${name}/latest`;
49
+ }
50
+
51
+ // TOOL_VERSION baked into the deployed Worker = the version that deployed it, so the banner compares
52
+ // like-for-like against npm's latest. package.json's `version` is the single source of truth.
53
+ function toolVersion(): string {
54
+ const fromPkg = readPkg().version;
55
+ return typeof fromPkg === "string" && fromPkg.length > 0 ? fromPkg : "0.0.0";
56
+ }
57
+
58
+ const HELP: Record<string, string> = {
59
+ deploy:
60
+ "usage: deploy --instance <slug> [options] (./deploy/deploy.sh or: alpha-gate deploy)\n" +
61
+ " Provision D1 + R2, apply migrations, deploy both Workers (idempotent — re-run to update).\n" +
62
+ " --instance <slug> required; namespaces everything (lowercase, digits, hyphens)\n" +
63
+ " --app-name / --activate-scheme / --blurb / --accent first-init branding (prompted if unset)\n" +
64
+ " --access-team-domain / --access-aud wire Cloudflare Access (both together)\n" +
65
+ " --email-provider none|cloudflare / --email-from <addr> automated invites (remembered)\n" +
66
+ " --dry-run rehearse with wrangler mocked (touches nothing)\n" +
67
+ " --yes skip the confirm prompt (for non-interactive runs)",
68
+ dev:
69
+ "usage: dev [options] (./deploy/dev.sh or: alpha-gate dev)\n" +
70
+ " Run Alpha Gate locally on Miniflare (no Cloudflare account). Starts BOTH Workers by default.\n" +
71
+ " --role app|admin start only one Worker (default: both)\n" +
72
+ " --port <n> app port (admin is port+1 when both run; default 8787)\n" +
73
+ " --no-seed skip seeding the demo client/build\n" +
74
+ " --reset wipe local D1/R2 state first",
75
+ teardown:
76
+ "usage: teardown --instance <slug> [options] (./deploy/teardown.sh or: alpha-gate teardown)\n" +
77
+ " Archive D1, then destroy both Workers + D1 (R2 bucket + Access app are removed manually).\n" +
78
+ " --instance <slug> required\n" +
79
+ " --no-archive skip the D1 backup dump\n" +
80
+ " --yes skip the type-the-name confirmation\n" +
81
+ " --dry-run rehearse (touches nothing)",
82
+ };
83
+
84
+ // Probes whether Cloudflare Access is enabled on the admin URL. When it is, the origin 302-redirects
85
+ // an unauthenticated request to `https://<team>.cloudflareaccess.com/cdn-cgi/access/login/…` — so one
86
+ // GET both CONFIRMS Access is on and reveals the team domain, saving the operator a copy step and
87
+ // catching "you pressed Enter but didn't actually enable it". Injected so the deploy flow stays
88
+ // unit-testable offline.
89
+ async function probeAccess(
90
+ adminUrl: string,
91
+ ): Promise<{ enabled: boolean; teamDomain: string | null }> {
92
+ try {
93
+ const res = await fetch(adminUrl, { method: "GET", redirect: "manual" });
94
+ const location = res.headers.get("location") ?? "";
95
+ const m = /^https?:\/\/([^/]+\.cloudflareaccess\.com)\//.exec(location);
96
+ if (m?.[1]) return { enabled: true, teamDomain: m[1] };
97
+ // A 200 (or a redirect elsewhere) means Access isn't gating this hostname yet.
98
+ return { enabled: false, teamDomain: null };
99
+ } catch {
100
+ return { enabled: false, teamDomain: null };
101
+ }
102
+ }
103
+
104
+ // True if something already listens on 127.0.0.1:port (a successful TCP connect). Used by `dev` to
105
+ // catch a stale/orphaned server before wrangler falsely reports "Ready" on a port it doesn't own.
106
+ function portInUse(port: number): Promise<boolean> {
107
+ return new Promise((resolve) => {
108
+ const socket = net.connect({ port, host: "127.0.0.1" });
109
+ const done = (busy: boolean) => {
110
+ socket.destroy();
111
+ resolve(busy);
112
+ };
113
+ socket.setTimeout(1000);
114
+ socket.once("connect", () => done(true));
115
+ socket.once("timeout", () => done(false));
116
+ socket.once("error", () => done(false));
117
+ });
118
+ }
119
+
120
+ const shared = (rest: readonly string[]) => ({
121
+ // cwd = the package root so `npx wrangler` finds the bundled wrangler even from an npx install.
122
+ wrangler: createWrangler({ dryRun: rest.includes("--dry-run"), cwd: ROOT }),
123
+ prompt: createPrompt(),
124
+ fs: createFileSystem(),
125
+ palette: selectPalette(shouldColor(process.env, process.stdout.isTTY === true)),
126
+ out: (line: string) => console.log(line),
127
+ rootDir: ROOT,
128
+ stateDir: STATE_DIR,
129
+ interactive: process.stdin.isTTY === true,
130
+ });
131
+
132
+ async function main(): Promise<number> {
133
+ const [command, ...rest] = process.argv.slice(2);
134
+
135
+ // Help is a first-class exit, not an "unknown flag" error (the old circular-hint bug).
136
+ if (command === undefined || command === "--help" || command === "-h") {
137
+ console.log("usage: <deploy|dev|teardown> [options] — run with a command + --help for details");
138
+ return 0;
139
+ }
140
+ if ((rest.includes("--help") || rest.includes("-h")) && HELP[command]) {
141
+ console.log(HELP[command]);
142
+ return 0;
143
+ }
144
+
145
+ if (command === "deploy") {
146
+ const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
147
+ const env: DeployEnv = {
148
+ ...shared(rest),
149
+ toolVersion: toolVersion(),
150
+ updateManifestUrl: npmManifestUrl(),
151
+ nodeMajor,
152
+ probeAccess,
153
+ };
154
+ return runDeploy(rest, env);
155
+ }
156
+
157
+ if (command === "teardown") {
158
+ const env: TeardownEnv = { ...shared(rest), nowStamp };
159
+ return runTeardown(rest, env);
160
+ }
161
+
162
+ if (command === "dev") {
163
+ const env: DevEnv = {
164
+ ...shared(rest),
165
+ toolVersion: toolVersion(),
166
+ updateManifestUrl: npmManifestUrl(),
167
+ portInUse,
168
+ };
169
+ return runDev(rest, env);
170
+ }
171
+
172
+ console.error("usage: cli.ts <deploy|teardown|dev> [flags]");
173
+ return 1;
174
+ }
175
+
176
+ main()
177
+ .then((code) => process.exit(code))
178
+ .catch((error) => {
179
+ console.error(error);
180
+ process.exit(1);
181
+ });