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.
- package/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/bin/alpha-gate.mjs +75 -0
- package/deploy/backup.sh +45 -0
- package/deploy/deploy.sh +21 -0
- package/deploy/dev.sh +56 -0
- package/deploy/lib/statedir.sh +11 -0
- package/deploy/teardown.sh +17 -0
- package/docs/ONBOARDING.md +16 -0
- package/docs/PRINCIPLES.md +195 -0
- package/docs/README.md +54 -0
- package/docs/UPLOADING.md +14 -0
- package/docs/integrate/activation.md +56 -0
- package/docs/integrate/sparkle-go.md +106 -0
- package/docs/integrate/sparkle-swift.md +63 -0
- package/docs/maintain/backup.md +52 -0
- package/docs/maintain/migrate-account.md +95 -0
- package/docs/maintain/teardown.md +46 -0
- package/docs/maintain/troubleshooting.md +102 -0
- package/docs/maintain/updating.md +92 -0
- package/docs/operate/add-users.md +55 -0
- package/docs/operate/channels.md +54 -0
- package/docs/operate/email.md +51 -0
- package/docs/operate/monitoring.md +56 -0
- package/docs/operate/publish.md +90 -0
- package/docs/operate/remove-users.md +47 -0
- package/docs/setup/cloudflare-account.md +41 -0
- package/docs/setup/deploy.md +84 -0
- package/docs/setup/install.md +65 -0
- package/migrations/0001_clients.sql +12 -0
- package/migrations/0002_builds_streams.sql +30 -0
- package/migrations/0003_access_log.sql +14 -0
- package/migrations/0004_meta.sql +5 -0
- package/migrations/0005_admin_audit.sql +14 -0
- package/migrations/0006_build_dmg.sql +6 -0
- package/migrations/0007_access_requests.sql +12 -0
- package/migrations/0008_build_rollback_target.sql +7 -0
- package/migrations/0009_hidden.sql +6 -0
- package/migrations/0010_purged_at.sql +5 -0
- package/package.json +65 -0
- package/publish.sh +302 -0
- package/release.json +7 -0
- package/src/auth/access-jwt.ts +210 -0
- package/src/auth/token-gate.ts +27 -0
- package/src/core/appcast.ts +107 -0
- package/src/core/audit-chain.ts +145 -0
- package/src/core/invite-template.ts +127 -0
- package/src/core/no-build.ts +160 -0
- package/src/core/resolver.ts +60 -0
- package/src/core/tokens.ts +51 -0
- package/src/core/types.ts +76 -0
- package/src/core/validation.ts +60 -0
- package/src/core/verdict.ts +195 -0
- package/src/core/version.ts +113 -0
- package/src/cron.ts +37 -0
- package/src/db/access-log.ts +168 -0
- package/src/db/access-requests.ts +90 -0
- package/src/db/admin-audit.ts +101 -0
- package/src/db/builds.ts +169 -0
- package/src/db/client.ts +80 -0
- package/src/db/clients.ts +103 -0
- package/src/db/meta.ts +30 -0
- package/src/db/streams.ts +85 -0
- package/src/deploy/cli.ts +181 -0
- package/src/deploy/commands/deploy.ts +392 -0
- package/src/deploy/commands/dev.ts +190 -0
- package/src/deploy/commands/teardown.ts +159 -0
- package/src/deploy/core/args.ts +205 -0
- package/src/deploy/core/colors.ts +49 -0
- package/src/deploy/core/config.ts +65 -0
- package/src/deploy/core/parse.ts +51 -0
- package/src/deploy/core/paths.ts +27 -0
- package/src/deploy/core/plan.ts +138 -0
- package/src/deploy/core/result.ts +13 -0
- package/src/deploy/core/state.ts +64 -0
- package/src/deploy/core/table.ts +49 -0
- package/src/deploy/core/types.ts +39 -0
- package/src/deploy/core/ui.ts +107 -0
- package/src/deploy/seams/clock.ts +10 -0
- package/src/deploy/seams/files.ts +52 -0
- package/src/deploy/seams/io.ts +56 -0
- package/src/deploy/seams/wrangler.ts +100 -0
- package/src/deploy/ui-preview.ts +112 -0
- package/src/deps.ts +41 -0
- package/src/dev/admin-entry.ts +104 -0
- package/src/env.ts +47 -0
- package/src/lib/clock.ts +17 -0
- package/src/lib/hosts.ts +27 -0
- package/src/lib/text.ts +6 -0
- package/src/r2/builds-bucket.ts +50 -0
- package/src/r2/keys.ts +27 -0
- package/src/routes/admin/admin-context.ts +9 -0
- package/src/routes/admin/audit-fields.ts +22 -0
- package/src/routes/admin/branding.tsx +161 -0
- package/src/routes/admin/builds.tsx +388 -0
- package/src/routes/admin/clients.tsx +396 -0
- package/src/routes/admin/confirm.tsx +80 -0
- package/src/routes/admin/flash.ts +72 -0
- package/src/routes/admin/form.ts +43 -0
- package/src/routes/admin/index.ts +146 -0
- package/src/routes/admin/invite.ts +57 -0
- package/src/routes/admin/middleware.ts +52 -0
- package/src/routes/admin/negotiate.ts +13 -0
- package/src/routes/admin/pending.tsx +73 -0
- package/src/routes/admin/read-model.ts +518 -0
- package/src/routes/admin/streams.tsx +182 -0
- package/src/routes/admin/theme.ts +34 -0
- package/src/routes/admin/upload.tsx +320 -0
- package/src/routes/admin/views.tsx +293 -0
- package/src/routes/app/access.tsx +38 -0
- package/src/routes/app/app-context.ts +8 -0
- package/src/routes/app/appcast.ts +68 -0
- package/src/routes/app/assets.ts +25 -0
- package/src/routes/app/download.ts +45 -0
- package/src/routes/app/get.tsx +35 -0
- package/src/routes/app/index.ts +31 -0
- package/src/routes/app/resolve.ts +16 -0
- package/src/services/anchor.ts +54 -0
- package/src/services/audit.ts +19 -0
- package/src/services/branding.ts +51 -0
- package/src/services/email-cloudflare.ts +80 -0
- package/src/services/email.ts +68 -0
- package/src/services/self-update.ts +62 -0
- package/src/views/access-page.tsx +39 -0
- package/src/views/admin/ci-page.tsx +76 -0
- package/src/views/admin/combobox.tsx +191 -0
- package/src/views/admin/forms.tsx +37 -0
- package/src/views/admin/layout.tsx +496 -0
- package/src/views/admin/manage-pages.tsx +223 -0
- package/src/views/admin/manage.tsx +1120 -0
- package/src/views/admin/plist-extract.ts +233 -0
- package/src/views/admin/read-pages.tsx +1098 -0
- package/src/views/admin/setup-page.tsx +108 -0
- package/src/views/admin/table-enhance.ts +176 -0
- package/src/views/admin/ui.tsx +159 -0
- package/src/views/get-page.tsx +39 -0
- package/src/views/layout.tsx +57 -0
- package/src/worker.ts +23 -0
- package/tsconfig.json +35 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { normalizeTeamDomain, parseDeployArgs } from "../core/args";
|
|
2
|
+
import type { Palette } from "../core/colors";
|
|
3
|
+
import { type ConfigVars, renderConfig } from "../core/config";
|
|
4
|
+
import { accessConfigured, extractDeployUrl, parseD1Id } from "../core/parse";
|
|
5
|
+
import {
|
|
6
|
+
accessManualNeeded,
|
|
7
|
+
buildApplyPlan,
|
|
8
|
+
buildSeedSql,
|
|
9
|
+
type Inspection,
|
|
10
|
+
inspectionFindings,
|
|
11
|
+
inspectSteps,
|
|
12
|
+
resourceName,
|
|
13
|
+
} from "../core/plan";
|
|
14
|
+
import { parseState, serializeState } from "../core/state";
|
|
15
|
+
import type { Role } from "../core/types";
|
|
16
|
+
import {
|
|
17
|
+
renderApply,
|
|
18
|
+
renderFindings,
|
|
19
|
+
renderHeader,
|
|
20
|
+
renderInspect,
|
|
21
|
+
renderManualStep,
|
|
22
|
+
renderPreflight,
|
|
23
|
+
} from "../core/ui";
|
|
24
|
+
import type { FileSystem } from "../seams/files";
|
|
25
|
+
import type { Prompt } from "../seams/io";
|
|
26
|
+
import type { Wrangler } from "../seams/wrangler";
|
|
27
|
+
|
|
28
|
+
// The `deploy` command: preflight → INSPECT (read-only, learn state) → APPLY (create/migrate/seed/
|
|
29
|
+
// deploy/wire-Access). It renders the transparent grouped-panels UI, confirms before mutating, and
|
|
30
|
+
// fails loudly (clear reason) rather than writing poison. All I/O is via injected seams, so the whole
|
|
31
|
+
// flow is unit-tested with a fake wrangler/prompt/fs.
|
|
32
|
+
|
|
33
|
+
export interface DeployEnv {
|
|
34
|
+
wrangler: Wrangler;
|
|
35
|
+
prompt: Prompt;
|
|
36
|
+
fs: FileSystem;
|
|
37
|
+
palette: Palette;
|
|
38
|
+
out: (line: string) => void;
|
|
39
|
+
/** The PACKAGE root — where src/ and migrations/ live (the repo, or the npm install dir). */
|
|
40
|
+
rootDir: string;
|
|
41
|
+
/** Where per-instance state (.deploy configs + state.json) is written — `<root>/.deploy` for a
|
|
42
|
+
checkout, `~/.alpha-gate` for an npm install (see core/paths). */
|
|
43
|
+
stateDir: string;
|
|
44
|
+
toolVersion: string;
|
|
45
|
+
updateManifestUrl: string;
|
|
46
|
+
nodeMajor: number;
|
|
47
|
+
/** Whether stdin is a TTY — gates interactive prompts (the manual-Access wait + first-init branding). */
|
|
48
|
+
interactive: boolean;
|
|
49
|
+
/** Probes the admin URL for Cloudflare Access (confirms enabled + derives the team domain). */
|
|
50
|
+
probeAccess?:
|
|
51
|
+
| ((adminUrl: string) => Promise<{ enabled: boolean; teamDomain: string | null }>)
|
|
52
|
+
| undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// An Access AUD tag is a 32–64 char hex string. Catch a fat-fingered paste here rather than as a
|
|
56
|
+
// mystery 403 days later.
|
|
57
|
+
function looksLikeAud(value: string): boolean {
|
|
58
|
+
return /^[0-9a-f]{32,64}$/i.test(value.trim());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function fail(env: DeployEnv, reason: string, hint: string): number {
|
|
62
|
+
env.out(env.palette.red(`deploy: ${reason}`));
|
|
63
|
+
if (hint) env.out(` → ${hint}`);
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function askNonEmpty(prompt: Prompt, question: string): Promise<string> {
|
|
68
|
+
let answer = "";
|
|
69
|
+
while (answer === "") {
|
|
70
|
+
answer = (await prompt.ask(` ${question}: `)).trim();
|
|
71
|
+
}
|
|
72
|
+
return answer;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function askWithDefault(prompt: Prompt, label: string, fallback: string): Promise<string> {
|
|
76
|
+
const suffix = fallback === "" ? "" : ` [${fallback}]`;
|
|
77
|
+
const answer = (await prompt.ask(` ${label}${suffix}: `)).trim();
|
|
78
|
+
return answer === "" ? fallback : answer;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function runDeploy(argv: readonly string[], env: DeployEnv): Promise<number> {
|
|
82
|
+
const parsed = parseDeployArgs(argv);
|
|
83
|
+
if (!parsed.ok) return fail(env, parsed.error, parsed.hint ?? "");
|
|
84
|
+
const args = parsed.value;
|
|
85
|
+
const res = resourceName(args.instance);
|
|
86
|
+
const deployDir = env.stateDir;
|
|
87
|
+
const appCfg = `${deployDir}/${args.instance}.app.toml`;
|
|
88
|
+
const adminCfg = `${deployDir}/${args.instance}.admin.toml`;
|
|
89
|
+
const wr = env.wrangler;
|
|
90
|
+
|
|
91
|
+
env.out(renderHeader(args.instance, env.palette));
|
|
92
|
+
|
|
93
|
+
// PREFLIGHT — tools + Cloudflare auth (auth skipped in dry-run).
|
|
94
|
+
const preflight = [
|
|
95
|
+
{
|
|
96
|
+
name: "node",
|
|
97
|
+
ok: env.nodeMajor >= 20,
|
|
98
|
+
detail:
|
|
99
|
+
env.nodeMajor >= 20 ? `node ${env.nodeMajor}` : "Node ≥ 20 required (https://nodejs.org)",
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
if (!args.dryRun) {
|
|
103
|
+
const who = await wr.run(["whoami"]);
|
|
104
|
+
preflight.push({
|
|
105
|
+
name: "cloudflare",
|
|
106
|
+
ok: who.ok,
|
|
107
|
+
detail: who.ok ? "authenticated" : "not logged in → run: npx wrangler login",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
env.out(renderPreflight(preflight, env.palette));
|
|
111
|
+
if (preflight.some((p) => !p.ok))
|
|
112
|
+
return fail(env, "preflight failed", "fix the items above, then re-run");
|
|
113
|
+
|
|
114
|
+
// INSPECT — read-only.
|
|
115
|
+
env.out(renderInspect(inspectSteps(args), env.palette));
|
|
116
|
+
let inspection: Inspection;
|
|
117
|
+
if (args.dryRun) {
|
|
118
|
+
inspection = { d1Id: null, bucketExists: false, accessConfigured: false, seeded: false };
|
|
119
|
+
} else {
|
|
120
|
+
const list = await wr.run(["d1", "list", "--json"]);
|
|
121
|
+
const d1Id = parseD1Id(list.stdout, res);
|
|
122
|
+
const bucket = await wr.run(["r2", "bucket", "info", res]);
|
|
123
|
+
const secrets = await wr.run(["secret", "list", "--config", adminCfg, "--format", "json"]);
|
|
124
|
+
inspection = {
|
|
125
|
+
d1Id,
|
|
126
|
+
bucketExists: bucket.ok,
|
|
127
|
+
accessConfigured: accessConfigured(secrets.stdout),
|
|
128
|
+
seeded: d1Id !== null,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
env.out(renderFindings(inspectionFindings(inspection), env.palette));
|
|
132
|
+
|
|
133
|
+
const freshDb = inspection.d1Id === null;
|
|
134
|
+
|
|
135
|
+
// Load prior state up front so a bare re-run reuses remembered inputs. `deploy --instance X` with no
|
|
136
|
+
// --email-provider must NOT silently turn email off, and no --access-* must not drop the Access
|
|
137
|
+
// wiring — the #1 "it broke on the next deploy" surprise. Explicit flags always win.
|
|
138
|
+
await env.fs.mkdirp(deployDir);
|
|
139
|
+
const statePath = `${deployDir}/${args.instance}.state.json`;
|
|
140
|
+
let state = parseState((await env.fs.read(statePath)) ?? "", args.instance);
|
|
141
|
+
|
|
142
|
+
// First-init branding (parity with the old deploy.sh): on a fresh instance + an interactive TTY,
|
|
143
|
+
// prompt for anything not passed as a flag, so /get + the activate link are correct immediately. The
|
|
144
|
+
// values are seeded with INSERT OR IGNORE, so the admin Settings page owns them thereafter.
|
|
145
|
+
let effective = {
|
|
146
|
+
...args,
|
|
147
|
+
// Remembered email/access default the args when the flags are absent (flags still override).
|
|
148
|
+
emailProvider: (args.emailProvider === "none" && state.emailProvider !== null
|
|
149
|
+
? state.emailProvider
|
|
150
|
+
: args.emailProvider) as typeof args.emailProvider,
|
|
151
|
+
emailFrom: args.emailFrom ?? state.emailFrom,
|
|
152
|
+
accessTeamDomain: args.accessTeamDomain ?? state.accessTeamDomain,
|
|
153
|
+
accessAud: args.accessAud ?? state.accessAud,
|
|
154
|
+
};
|
|
155
|
+
if (
|
|
156
|
+
state.emailProvider !== null &&
|
|
157
|
+
args.emailProvider === "none" &&
|
|
158
|
+
state.emailProvider !== "none"
|
|
159
|
+
) {
|
|
160
|
+
env.out(env.palette.dim(` (reusing email: ${state.emailProvider} ${state.emailFrom ?? ""})`));
|
|
161
|
+
}
|
|
162
|
+
if (freshDb && env.interactive && !args.yes && !args.dryRun) {
|
|
163
|
+
effective = {
|
|
164
|
+
...effective,
|
|
165
|
+
appName: args.appName ?? (await askWithDefault(env.prompt, "App name", "Your App")),
|
|
166
|
+
activateScheme:
|
|
167
|
+
args.activateScheme ?? (await askWithDefault(env.prompt, "Activate URL scheme", "myapp")),
|
|
168
|
+
blurb: args.blurb ?? (await askWithDefault(env.prompt, "Short blurb (optional)", "")),
|
|
169
|
+
accent: args.accent ?? (await askWithDefault(env.prompt, "Accent colour", "#0A84FF")),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// APPLY — show the plan, confirm, then mutate.
|
|
174
|
+
env.out(renderApply(buildApplyPlan(effective, inspection), env.palette));
|
|
175
|
+
if (!args.dryRun && !args.yes) {
|
|
176
|
+
if (!env.interactive) {
|
|
177
|
+
return fail(
|
|
178
|
+
env,
|
|
179
|
+
"this run would change resources but isn't interactive",
|
|
180
|
+
"re-run with --yes to proceed",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (!(await env.prompt.confirm("Apply these changes?"))) {
|
|
184
|
+
env.out("aborted — nothing changed.");
|
|
185
|
+
return 1;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Remember the effective email settings so the next bare re-run keeps them.
|
|
190
|
+
state = {
|
|
191
|
+
...state,
|
|
192
|
+
emailProvider: effective.emailProvider,
|
|
193
|
+
emailFrom: effective.emailFrom,
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// Live progress: a dim "→ …" as each step starts, a green "✓" (with the deploy URL) when it lands.
|
|
197
|
+
// Failures print via fail() instead. Skipped steps were already shown in the plan above.
|
|
198
|
+
env.out("");
|
|
199
|
+
env.out(env.palette.bold("Applying…"));
|
|
200
|
+
const startStep = (label: string): void => env.out(env.palette.dim(` → ${label}…`));
|
|
201
|
+
const doneStep = (label: string, extra = ""): void =>
|
|
202
|
+
env.out(env.palette.green(` ✓ ${label}${extra === "" ? "" : ` ${extra}`}`));
|
|
203
|
+
|
|
204
|
+
// 1. D1 (create if absent, then resolve its id).
|
|
205
|
+
let d1Id = inspection.d1Id;
|
|
206
|
+
if (d1Id === null) {
|
|
207
|
+
startStep("create database");
|
|
208
|
+
const created = await wr.run(["d1", "create", res]);
|
|
209
|
+
if (!args.dryRun && !created.ok) {
|
|
210
|
+
return fail(env, "could not create the D1 database", created.stderr.trim());
|
|
211
|
+
}
|
|
212
|
+
d1Id = args.dryRun
|
|
213
|
+
? "dry-run-d1-id"
|
|
214
|
+
: parseD1Id((await wr.run(["d1", "list", "--json"])).stdout, res);
|
|
215
|
+
if (d1Id === null) {
|
|
216
|
+
return fail(env, "D1 created but no id was returned", "check your account quota");
|
|
217
|
+
}
|
|
218
|
+
doneStep("database");
|
|
219
|
+
}
|
|
220
|
+
const databaseId: string = d1Id;
|
|
221
|
+
state = { ...state, d1Id: databaseId };
|
|
222
|
+
|
|
223
|
+
// 2. Render both configs (needs the resolved d1Id; written before migrate/deploy use --config).
|
|
224
|
+
const configVars = (role: Role): ConfigVars => ({
|
|
225
|
+
instance: args.instance,
|
|
226
|
+
d1Id: databaseId,
|
|
227
|
+
role,
|
|
228
|
+
name: role === "admin" ? `${res}-admin` : res,
|
|
229
|
+
emailProvider: effective.emailProvider,
|
|
230
|
+
emailFrom: effective.emailFrom ?? "",
|
|
231
|
+
toolVersion: env.toolVersion,
|
|
232
|
+
updateManifestUrl: env.updateManifestUrl,
|
|
233
|
+
// Absolute paths into the package, so the rendered config resolves them whether it lives in the
|
|
234
|
+
// repo `.deploy/` or the relocated `~/.alpha-gate` (which isn't a sibling of src/migrations).
|
|
235
|
+
main: `${env.rootDir}/src/worker.ts`,
|
|
236
|
+
migrationsDir: `${env.rootDir}/migrations`,
|
|
237
|
+
});
|
|
238
|
+
if (!args.dryRun) {
|
|
239
|
+
await env.fs.write(appCfg, renderConfig(configVars("app")));
|
|
240
|
+
await env.fs.write(adminCfg, renderConfig(configVars("admin")));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 3. R2 (create if absent).
|
|
244
|
+
if (!inspection.bucketExists) {
|
|
245
|
+
startStep("create bucket");
|
|
246
|
+
const r2 = await wr.run(["r2", "bucket", "create", res]);
|
|
247
|
+
if (!args.dryRun && !r2.ok)
|
|
248
|
+
return fail(env, "could not create the R2 bucket", r2.stderr.trim());
|
|
249
|
+
doneStep("bucket");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 4. Migrations (idempotent).
|
|
253
|
+
startStep("apply migrations");
|
|
254
|
+
const mig = await wr.run(["d1", "migrations", "apply", res, "--config", appCfg, "--remote"]);
|
|
255
|
+
if (!args.dryRun && !mig.ok) return fail(env, "migrations failed", mig.stderr.trim());
|
|
256
|
+
doneStep("migrations");
|
|
257
|
+
|
|
258
|
+
// 5. Seed app config — first init only (INSERT OR IGNORE never clobbers admin edits).
|
|
259
|
+
const seedSql = buildSeedSql(effective);
|
|
260
|
+
if (freshDb && seedSql !== null) {
|
|
261
|
+
startStep("seed app config");
|
|
262
|
+
const seed = await wr.run([
|
|
263
|
+
"d1",
|
|
264
|
+
"execute",
|
|
265
|
+
res,
|
|
266
|
+
"--config",
|
|
267
|
+
appCfg,
|
|
268
|
+
"--remote",
|
|
269
|
+
"--command",
|
|
270
|
+
seedSql,
|
|
271
|
+
]);
|
|
272
|
+
if (!args.dryRun && !seed.ok) return fail(env, "seeding app config failed", seed.stderr.trim());
|
|
273
|
+
doneStep("app config");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 6. Deploy both Workers; capture + validate each URL.
|
|
277
|
+
startStep("deploy app Worker");
|
|
278
|
+
const appDeploy = await wr.run(["deploy", "--config", appCfg]);
|
|
279
|
+
const appUrl = args.dryRun
|
|
280
|
+
? `https://${res}.<account>.workers.dev`
|
|
281
|
+
: extractDeployUrl(appDeploy.stdout);
|
|
282
|
+
if (appUrl === null) {
|
|
283
|
+
return fail(
|
|
284
|
+
env,
|
|
285
|
+
"the app Worker deployed but no URL was found",
|
|
286
|
+
"check `wrangler deploy` output",
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
doneStep("app Worker", appUrl);
|
|
290
|
+
|
|
291
|
+
startStep("deploy admin Worker");
|
|
292
|
+
const adminDeploy = await wr.run(["deploy", "--config", adminCfg]);
|
|
293
|
+
const adminUrl = args.dryRun
|
|
294
|
+
? `https://${res}-admin.<account>.workers.dev`
|
|
295
|
+
: extractDeployUrl(adminDeploy.stdout);
|
|
296
|
+
if (adminUrl === null) {
|
|
297
|
+
return fail(
|
|
298
|
+
env,
|
|
299
|
+
"the admin Worker deployed but no URL was found",
|
|
300
|
+
"check `wrangler deploy` output",
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
doneStep("admin Worker", adminUrl);
|
|
304
|
+
state = { ...state, appUrl, adminUrl };
|
|
305
|
+
|
|
306
|
+
// 7. Cloudflare Access. With creds → set them via --secrets-file (one deploy). Without → show the
|
|
307
|
+
// manual dashboard step, WAIT, then POLL the admin URL: enabling Access makes it redirect to the
|
|
308
|
+
// team's login, which both confirms it's really on and hands us the team domain — so the operator
|
|
309
|
+
// copies only the AUD, and a "pressed Enter but didn't enable it" mistake is caught here, not later.
|
|
310
|
+
let teamDomain = effective.accessTeamDomain;
|
|
311
|
+
let aud = effective.accessAud;
|
|
312
|
+
if (accessManualNeeded(effective, inspection)) {
|
|
313
|
+
// One instruction set, matching docs/setup/deploy.md §4 (the easy path — Cloudflare fills the hostname).
|
|
314
|
+
env.out(
|
|
315
|
+
renderManualStep(
|
|
316
|
+
"Enable Cloudflare Access on the admin Worker, then come back:",
|
|
317
|
+
[
|
|
318
|
+
"Cloudflare dashboard → Workers & Pages → the -admin Worker",
|
|
319
|
+
"Settings → Domains & Routes → enable Cloudflare Access",
|
|
320
|
+
"Edit the policy: Allow → your email (identity: One-time PIN)",
|
|
321
|
+
"Copy the Application Audience (AUD): Access → Applications → your app → Overview",
|
|
322
|
+
],
|
|
323
|
+
env.palette,
|
|
324
|
+
),
|
|
325
|
+
);
|
|
326
|
+
if (!args.dryRun && !args.yes && env.interactive) {
|
|
327
|
+
await env.prompt.waitForDone("Press Enter once Access is enabled and you have the AUD …");
|
|
328
|
+
// Confirm it's on and derive the team domain from the redirect.
|
|
329
|
+
const probe = env.probeAccess ? await env.probeAccess(adminUrl) : null;
|
|
330
|
+
if (probe && !probe.enabled) {
|
|
331
|
+
env.out(
|
|
332
|
+
env.palette.yellow(
|
|
333
|
+
" Access doesn't seem enabled yet — the admin URL isn't redirecting to a login.",
|
|
334
|
+
),
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
if (probe?.teamDomain) {
|
|
338
|
+
teamDomain = probe.teamDomain;
|
|
339
|
+
env.out(env.palette.green(` ✓ detected team domain: ${teamDomain}`));
|
|
340
|
+
} else {
|
|
341
|
+
teamDomain = normalizeTeamDomain(await askNonEmpty(env.prompt, "Access team domain"));
|
|
342
|
+
}
|
|
343
|
+
// Only the AUD is hand-copied; sanity-check its shape and warn on an obvious typo (the admin's
|
|
344
|
+
// reason-bearing 403 catches a genuinely wrong one, so we accept rather than block).
|
|
345
|
+
aud = (await askNonEmpty(env.prompt, "Access AUD tag")).trim();
|
|
346
|
+
if (!looksLikeAud(aud)) {
|
|
347
|
+
env.out(
|
|
348
|
+
env.palette.yellow(" Heads up: that doesn't look like an AUD (expect 32–64 hex)."),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// Remember the wired Access values so a later bare re-run keeps them.
|
|
354
|
+
state = { ...state, accessTeamDomain: teamDomain, accessAud: aud };
|
|
355
|
+
if (teamDomain !== null && teamDomain !== "" && aud !== null && aud !== "") {
|
|
356
|
+
startStep("wire Cloudflare Access");
|
|
357
|
+
const secretsFile = `${deployDir}/${args.instance}.secrets.json`;
|
|
358
|
+
if (!args.dryRun) {
|
|
359
|
+
await env.fs.write(
|
|
360
|
+
secretsFile,
|
|
361
|
+
JSON.stringify({ ACCESS_TEAM_DOMAIN: teamDomain, ACCESS_AUD: aud }),
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
const wired = await wr.run(["deploy", "--config", adminCfg, "--secrets-file", secretsFile]);
|
|
365
|
+
if (!args.dryRun) await env.fs.remove(secretsFile);
|
|
366
|
+
if (!args.dryRun && !wired.ok)
|
|
367
|
+
return fail(env, "setting Access secrets failed", wired.stderr.trim());
|
|
368
|
+
doneStep("Access");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 8. Persist state (real runs only) and print the summary.
|
|
372
|
+
if (!args.dryRun) await env.fs.write(statePath, serializeState(state));
|
|
373
|
+
|
|
374
|
+
env.out("");
|
|
375
|
+
env.out(env.palette.green("Deployed."));
|
|
376
|
+
env.out(` App (public) → ${appUrl}`);
|
|
377
|
+
env.out(` Admin (gated) → ${adminUrl}`);
|
|
378
|
+
if (accessManualNeeded(effective, inspection) && (teamDomain === null || teamDomain === "")) {
|
|
379
|
+
env.out(
|
|
380
|
+
" Access not wired — re-run with --access-team-domain/--access-aud once it's enabled.",
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
env.out(
|
|
384
|
+
" Publish your first build: ./publish.sh MyApp.dmg --channel <name> (see /admin/setup)",
|
|
385
|
+
);
|
|
386
|
+
env.out(
|
|
387
|
+
env.palette.dim(
|
|
388
|
+
" Publishing from CI? Create an Access service token + Service-Auth policy — see /admin/ci.",
|
|
389
|
+
),
|
|
390
|
+
);
|
|
391
|
+
return 0;
|
|
392
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { parseDevArgs } from "../core/args";
|
|
2
|
+
import type { Palette } from "../core/colors";
|
|
3
|
+
import { renderConfig } from "../core/config";
|
|
4
|
+
import { resourceName } from "../core/plan";
|
|
5
|
+
import { renderHeader } from "../core/ui";
|
|
6
|
+
import type { FileSystem } from "../seams/files";
|
|
7
|
+
import type { Wrangler } from "../seams/wrangler";
|
|
8
|
+
|
|
9
|
+
// The `dev` command (§23 local surface): run a Worker locally on Miniflare (no Cloudflare account).
|
|
10
|
+
// Renders a local wrangler config, applies migrations to a LOCAL D1, optionally seeds a demo
|
|
11
|
+
// client/build, then hands off to `wrangler dev`. The admin role points main at the dev-only entry
|
|
12
|
+
// (src/dev/admin-entry.ts) + DEV_ADMIN=1 so the gated UI is browser-usable on localhost.
|
|
13
|
+
|
|
14
|
+
export interface DevEnv {
|
|
15
|
+
wrangler: Wrangler;
|
|
16
|
+
fs: FileSystem;
|
|
17
|
+
palette: Palette;
|
|
18
|
+
out: (line: string) => void;
|
|
19
|
+
rootDir: string;
|
|
20
|
+
toolVersion: string;
|
|
21
|
+
updateManifestUrl: string;
|
|
22
|
+
/** Probe whether something already listens on a TCP port (injected; real net check in cli.ts). */
|
|
23
|
+
portInUse?: (port: number) => Promise<boolean>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const INSTANCE = "local";
|
|
27
|
+
const DEMO_TOKEN = "DEV0DEV0DEV0DEV0DEV0DEV0DEV0DEV0"; // valid Crockford base32 (no I/L/O/U)
|
|
28
|
+
|
|
29
|
+
function fail(env: DevEnv, reason: string, hint: string): number {
|
|
30
|
+
env.out(env.palette.red(`dev: ${reason}`));
|
|
31
|
+
if (hint) env.out(` → ${hint}`);
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function runDev(argv: readonly string[], env: DevEnv): Promise<number> {
|
|
36
|
+
const parsed = parseDevArgs(argv);
|
|
37
|
+
if (!parsed.ok) return fail(env, parsed.error, parsed.hint ?? "");
|
|
38
|
+
const args = parsed.value;
|
|
39
|
+
|
|
40
|
+
const res = resourceName(INSTANCE); // alpha-gate-local
|
|
41
|
+
const deployDir = `${env.rootDir}/.deploy`;
|
|
42
|
+
const stateDir = `${env.rootDir}/.wrangler/state`; // gitignored; shared by both roles
|
|
43
|
+
const cfg = `${deployDir}/${INSTANCE}.${args.role}.toml`;
|
|
44
|
+
const wr = env.wrangler;
|
|
45
|
+
|
|
46
|
+
env.out(renderHeader(`${INSTANCE} (${args.role})`, env.palette));
|
|
47
|
+
|
|
48
|
+
// Fail fast if the port is already taken. wrangler 4.x does NOT report EADDRINUSE — it prints
|
|
49
|
+
// "Ready on http://localhost:PORT" while a stale/orphaned listener actually owns the port, so the
|
|
50
|
+
// browser hits a dead server. An orphaned workerd from a prior Ctrl-C'd run is the usual cause.
|
|
51
|
+
if (env.portInUse && (await env.portInUse(args.port))) {
|
|
52
|
+
return fail(
|
|
53
|
+
env,
|
|
54
|
+
`port ${args.port} is already in use`,
|
|
55
|
+
`another dev server or an orphaned workerd holds it — free it (pkill -f workerd) or use --port N`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (args.reset) {
|
|
60
|
+
await env.fs.remove(stateDir);
|
|
61
|
+
env.out(env.palette.dim(" reset local D1/R2 state"));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Render the local config. The admin role uses the dev-only entry so its gated UI opens on localhost.
|
|
65
|
+
await env.fs.mkdirp(deployDir);
|
|
66
|
+
await env.fs.write(
|
|
67
|
+
cfg,
|
|
68
|
+
renderConfig({
|
|
69
|
+
instance: INSTANCE,
|
|
70
|
+
d1Id: "local", // ignored by `wrangler dev --local`
|
|
71
|
+
role: args.role,
|
|
72
|
+
name: args.role === "admin" ? `${res}-admin` : res,
|
|
73
|
+
emailProvider: "none",
|
|
74
|
+
emailFrom: "",
|
|
75
|
+
toolVersion: env.toolVersion,
|
|
76
|
+
updateManifestUrl: env.updateManifestUrl,
|
|
77
|
+
main:
|
|
78
|
+
args.role === "admin"
|
|
79
|
+
? `${env.rootDir}/src/dev/admin-entry.ts`
|
|
80
|
+
: `${env.rootDir}/src/worker.ts`,
|
|
81
|
+
migrationsDir: `${env.rootDir}/migrations`,
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// Migrations + seed use the captured run() (NOT exec): these are short `npx wrangler` commands, and
|
|
86
|
+
// spawning npx with inherited stdio from inside this npx/tsx process deadlocks on npm's lock — only
|
|
87
|
+
// the final, long-running `wrangler dev` gets inherited stdio. We print a progress line before each
|
|
88
|
+
// step (so a slow/blocked step isn't a silent hang) and surface wrangler's stderr on failure.
|
|
89
|
+
env.out(env.palette.dim(" → applying migrations to the local database…"));
|
|
90
|
+
const mig = await wr.run([
|
|
91
|
+
"d1",
|
|
92
|
+
"migrations",
|
|
93
|
+
"apply",
|
|
94
|
+
res,
|
|
95
|
+
"--config",
|
|
96
|
+
cfg,
|
|
97
|
+
"--local",
|
|
98
|
+
"--persist-to",
|
|
99
|
+
stateDir,
|
|
100
|
+
]);
|
|
101
|
+
if (!mig.ok) {
|
|
102
|
+
if (mig.stderr.trim()) env.out(mig.stderr.trim());
|
|
103
|
+
return fail(
|
|
104
|
+
env,
|
|
105
|
+
"local migrations failed",
|
|
106
|
+
"retry with --reset (a stuck local state can block it)",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Seed a demo world (idempotent) so /get, /appcast, /download return real data. Best-effort: a seed
|
|
111
|
+
// failure is reported but does not stop the server (the schema is already migrated).
|
|
112
|
+
if (args.seed) {
|
|
113
|
+
env.out(env.palette.dim(" → seeding a demo client + build…"));
|
|
114
|
+
const archive = `${deployDir}/${INSTANCE}-dev-archive.zip`;
|
|
115
|
+
await env.fs.write(archive, "ALPHA-GATE-DEV-ARCHIVE");
|
|
116
|
+
const put = await wr.run([
|
|
117
|
+
"r2",
|
|
118
|
+
"object",
|
|
119
|
+
"put",
|
|
120
|
+
`${res}/build/1000/App.zip`,
|
|
121
|
+
"--file",
|
|
122
|
+
archive,
|
|
123
|
+
"--content-type",
|
|
124
|
+
"application/zip",
|
|
125
|
+
"--local",
|
|
126
|
+
"--persist-to",
|
|
127
|
+
stateDir,
|
|
128
|
+
]);
|
|
129
|
+
if (!put.ok && put.stderr.trim()) {
|
|
130
|
+
env.out(env.palette.yellow(` seed (r2) skipped: ${put.stderr.trim()}`));
|
|
131
|
+
}
|
|
132
|
+
const sql = [
|
|
133
|
+
"INSERT OR IGNORE INTO streams (name) VALUES ('local');",
|
|
134
|
+
`INSERT OR IGNORE INTO clients (email, token, status) VALUES ('dev@example.test', '${DEMO_TOKEN}', 'active');`,
|
|
135
|
+
"INSERT OR IGNORE INTO builds (short_version, build_number, object_key, ed_signature, length, status)" +
|
|
136
|
+
" VALUES ('1.0.0-dev', 1000, 'build/1000/App.zip', 'DEVSIG==', 22, 'available');",
|
|
137
|
+
"INSERT OR IGNORE INTO build_streams (build_id, stream_id)" +
|
|
138
|
+
" SELECT b.id, s.id FROM builds b JOIN streams s ON s.name='local' WHERE b.build_number=1000;",
|
|
139
|
+
"INSERT OR IGNORE INTO user_streams (client_id, stream_id)" +
|
|
140
|
+
" SELECT c.id, s.id FROM clients c JOIN streams s ON s.name='local' WHERE c.email='dev@example.test';",
|
|
141
|
+
].join("");
|
|
142
|
+
const seeded = await wr.run([
|
|
143
|
+
"d1",
|
|
144
|
+
"execute",
|
|
145
|
+
res,
|
|
146
|
+
"--config",
|
|
147
|
+
cfg,
|
|
148
|
+
"--local",
|
|
149
|
+
"--persist-to",
|
|
150
|
+
stateDir,
|
|
151
|
+
"--command",
|
|
152
|
+
sql,
|
|
153
|
+
]);
|
|
154
|
+
if (!seeded.ok && seeded.stderr.trim()) {
|
|
155
|
+
env.out(env.palette.yellow(` seed (d1) skipped: ${seeded.stderr.trim()}`));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const base = `http://localhost:${args.port}`;
|
|
160
|
+
env.out("");
|
|
161
|
+
if (args.role === "app") {
|
|
162
|
+
env.out(env.palette.green(`App Worker → ${base}`));
|
|
163
|
+
if (args.seed) env.out(` ${base}/get?token=${DEMO_TOKEN}`);
|
|
164
|
+
} else {
|
|
165
|
+
env.out(env.palette.green(`Admin Worker → ${base}/admin`));
|
|
166
|
+
env.out(
|
|
167
|
+
env.palette.yellow(
|
|
168
|
+
" LOCAL-DEV auth shim — every request is admin 'dev@local'. localhost only.",
|
|
169
|
+
),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
env.out(env.palette.dim(" Ctrl-C to stop."));
|
|
173
|
+
env.out(
|
|
174
|
+
env.palette.dim(" → starting wrangler dev (the first run downloads the workerd runtime)…"),
|
|
175
|
+
);
|
|
176
|
+
env.out("");
|
|
177
|
+
|
|
178
|
+
const devArgs = [
|
|
179
|
+
"dev",
|
|
180
|
+
"--config",
|
|
181
|
+
cfg,
|
|
182
|
+
"--port",
|
|
183
|
+
String(args.port),
|
|
184
|
+
"--local",
|
|
185
|
+
"--persist-to",
|
|
186
|
+
stateDir,
|
|
187
|
+
];
|
|
188
|
+
if (args.role === "admin") devArgs.push("--var", "DEV_ADMIN:1");
|
|
189
|
+
return wr.exec(devArgs);
|
|
190
|
+
}
|