@saastemly/voidcommerce 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 (62) hide show
  1. package/README.md +271 -0
  2. package/bin/vc +2 -0
  3. package/dist/catalog.d.ts +69 -0
  4. package/dist/catalog.js +34 -0
  5. package/dist/cli.d.ts +24 -0
  6. package/dist/cli.js +544 -0
  7. package/dist/deploy/cloudflare.d.ts +25 -0
  8. package/dist/deploy/index.d.ts +16 -0
  9. package/dist/deploy/jsonc.d.ts +8 -0
  10. package/dist/deploy/preflight.d.ts +29 -0
  11. package/dist/deploy/wrangler.d.ts +37 -0
  12. package/dist/dist.d.ts +22 -0
  13. package/dist/generate/auth.d.ts +2 -0
  14. package/dist/generate/ci.d.ts +24 -0
  15. package/dist/generate/env.d.ts +13 -0
  16. package/dist/generate/frontend.d.ts +47 -0
  17. package/dist/generate/index.d.ts +28 -0
  18. package/dist/generate/requirements.d.ts +13 -0
  19. package/dist/generate/strict.d.ts +72 -0
  20. package/dist/generate/support.d.ts +23 -0
  21. package/dist/help.d.ts +31 -0
  22. package/dist/import.d.ts +2 -0
  23. package/dist/index-s7sq41qs.js +590 -0
  24. package/dist/index-ssv3a6wc.js +172 -0
  25. package/dist/index-wzy1xtr1.js +3155 -0
  26. package/dist/index.d.ts +24 -0
  27. package/dist/index.js +190 -0
  28. package/dist/init.d.ts +1 -0
  29. package/dist/manifest.d.ts +131 -0
  30. package/dist/manifest.js +41 -0
  31. package/dist/project.d.ts +20 -0
  32. package/dist/regenerate.d.ts +9 -0
  33. package/dist/scripts.d.ts +12 -0
  34. package/dist/void.d.ts +30 -0
  35. package/dist/wizard.d.ts +7 -0
  36. package/package.json +50 -0
  37. package/src/catalog.ts +673 -0
  38. package/src/cli.ts +78 -0
  39. package/src/deploy/cloudflare.ts +166 -0
  40. package/src/deploy/index.ts +101 -0
  41. package/src/deploy/jsonc.ts +148 -0
  42. package/src/deploy/preflight.ts +137 -0
  43. package/src/deploy/wrangler.ts +111 -0
  44. package/src/dist.ts +157 -0
  45. package/src/generate/auth.ts +386 -0
  46. package/src/generate/ci.ts +208 -0
  47. package/src/generate/env.ts +164 -0
  48. package/src/generate/frontend.ts +275 -0
  49. package/src/generate/index.ts +390 -0
  50. package/src/generate/requirements.ts +48 -0
  51. package/src/generate/strict.ts +692 -0
  52. package/src/generate/support.ts +252 -0
  53. package/src/help.ts +172 -0
  54. package/src/import.ts +237 -0
  55. package/src/index.ts +37 -0
  56. package/src/init.ts +187 -0
  57. package/src/manifest.ts +303 -0
  58. package/src/project.ts +63 -0
  59. package/src/regenerate.ts +51 -0
  60. package/src/scripts.ts +53 -0
  61. package/src/void.ts +115 -0
  62. package/src/wizard.ts +234 -0
package/src/cli.ts ADDED
@@ -0,0 +1,78 @@
1
+ import { deployCommand, deployHelp, preflightCommand, preflightHelp } from "./deploy/index";
2
+ import { distCommand, distHelp } from "./dist";
3
+ import { importCommand, importHelp } from "./import";
4
+ import { fullHelp, initHelp, version } from "./help";
5
+ import { init } from "./init";
6
+ import { ensureGenerated, findProject } from "./project";
7
+ import { generateCommand, generateHelp } from "./regenerate";
8
+ import { appScript, appScriptHelp } from "./scripts";
9
+ import { runVoid } from "./void";
10
+
11
+ /**
12
+ * `vc` — Void, with a shop in it.
13
+ *
14
+ * vc extends parts of void; it replaces none of it. Every command not named
15
+ * below goes to `void` verbatim — same arguments, same terminal, same exit
16
+ * code — so a person who knows Void already knows vc. The named ones run
17
+ * void's version first and add vc's part after: `vc init` is void init then
18
+ * the shop form; `vc --help` is void's help with a `shop` group merged in.
19
+ * `dev`, `build` and `preview` are not void's at all — they are the app's
20
+ * own scripts — so vc only decides where they run.
21
+ *
22
+ * The one thing vc changes about a pass-through is WHERE it runs: at a
23
+ * strict root the Void app is the artifact under .vc/app, so void's commands
24
+ * run there, after generating it if it is not there yet; at a monorepo root
25
+ * they run in api/.
26
+ */
27
+
28
+ interface Extended {
29
+ run: (args: string[]) => Promise<number>;
30
+ help: () => Promise<number>;
31
+ }
32
+
33
+ /** The commands vc extends or adds. Add one here and it is dispatched, helped, and tested. */
34
+ export const EXTENDED: Record<string, Extended> = {
35
+ init: { run: init, help: initHelp },
36
+ generate: { run: generateCommand, help: generateHelp },
37
+ dist: { run: distCommand, help: distHelp },
38
+ import: { run: importCommand, help: importHelp },
39
+ deploy: { run: deployCommand, help: deployHelp },
40
+ preflight: { run: preflightCommand, help: preflightHelp },
41
+ dev: { run: appScript("dev"), help: appScriptHelp("dev") },
42
+ build: { run: appScript("build"), help: appScriptHelp("build") },
43
+ preview: { run: appScript("preview"), help: appScriptHelp("preview") },
44
+ };
45
+
46
+ const HELP = new Set(["help", "--help", "-h"]);
47
+ const VERSION = new Set(["--version", "-v", "-V"]);
48
+
49
+ async function passthrough(argv: string[]): Promise<number> {
50
+ const project = await findProject();
51
+ if (!project) return runVoid(argv);
52
+ const code = await ensureGenerated(project);
53
+ if (code !== 0) return code;
54
+ return runVoid(argv, project.appDir);
55
+ }
56
+
57
+ export async function main(argv: string[]): Promise<number> {
58
+ const [command, ...rest] = argv;
59
+
60
+ if (command === undefined || (HELP.has(command) && rest.length === 0)) return fullHelp();
61
+ if (VERSION.has(command) && rest.length === 0) return version();
62
+
63
+ if (HELP.has(command)) {
64
+ // `vc help init` is vc's to answer; `vc help db execute` is void's.
65
+ const target = EXTENDED[rest[0] ?? ""];
66
+ return target ? target.help() : passthrough(argv);
67
+ }
68
+
69
+ const extended = EXTENDED[command];
70
+ if (extended) {
71
+ if (rest.some((arg) => arg === "--help" || arg === "-h")) return extended.help();
72
+ return extended.run(rest);
73
+ }
74
+
75
+ return passthrough(argv);
76
+ }
77
+
78
+ process.exit(await main(process.argv.slice(2)));
@@ -0,0 +1,166 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import color from "picocolors";
4
+ import { allEnvKeys } from "../generate/env";
5
+ import { hasFrontend, workerHosts, writeManifest } from "../manifest";
6
+ import type { Project } from "../project";
7
+ import { parseJsonc, upsertJsonc } from "./jsonc";
8
+ import { preflight, printPreflight } from "./preflight";
9
+ import { ensureD1, ensureQueue, findWrangler, whoami, wrangler } from "./wrangler";
10
+
11
+ /**
12
+ * `vc deploy --cloudflare` — the shop to your own Cloudflare account, with
13
+ * wrangler, and nothing else. No Void account, no linked project.
14
+ *
15
+ * It is Void's documented manual path, automated:
16
+ *
17
+ * 1. wrangler is installed and logged in; the account is pinned
18
+ * 2. preflight — every required key is a worker secret or a committed
19
+ * plaintext value; the hostnames are the shop's
20
+ * 3. --provision: the D1 database and the queue exist and are recorded
21
+ * 4. build
22
+ * 5. SCRUB the emitted config. Void's build bakes every .env* value into
23
+ * the worker's vars as plaintext — a dev cron secret, `unset` for each
24
+ * credential — and a var shadows the secret of the same name. So every
25
+ * secret-class key and every `unset` is removed from dist/ssr/wrangler.json
26
+ * before it is uploaded; the worker reads those from `wrangler secret`.
27
+ * 6. apply the committed migrations to the remote D1
28
+ * 7. wrangler deploy, on exactly the config that was scrubbed
29
+ */
30
+
31
+ export interface CloudflareOptions {
32
+ provision: boolean;
33
+ force: boolean;
34
+ }
35
+
36
+ interface WranglerConfig {
37
+ name?: string;
38
+ account_id?: string;
39
+ d1_databases?: Array<{ binding: string; database_name: string; database_id: string; migrations_dir?: string }>;
40
+ queues?: { producers?: Array<{ queue: string }>; consumers?: Array<{ queue: string }> };
41
+ vars?: Record<string, string>;
42
+ }
43
+
44
+ const fail = (message: string): number => {
45
+ console.error(`\n${color.red("✗")} ${message}\n`);
46
+ return 1;
47
+ };
48
+
49
+ function readConfig(path: string): WranglerConfig {
50
+ return parseJsonc<WranglerConfig>(readFileSync(path, "utf8"));
51
+ }
52
+
53
+ /** A build tool the app has: vite-plus first, then vite. */
54
+ function findBuilder(from: string): { cmd: string; label: string } | null {
55
+ let dir = from;
56
+ for (;;) {
57
+ for (const [bin, label] of [
58
+ ["vp", "vp build"],
59
+ ["vite", "vite build"],
60
+ ] as const) {
61
+ const path = join(dir, "node_modules", ".bin", bin);
62
+ if (existsSync(path)) return { cmd: path, label };
63
+ }
64
+ const parent = dirname(dir);
65
+ if (parent === dir) return null;
66
+ dir = parent;
67
+ }
68
+ }
69
+
70
+ export async function deployCloudflare(project: Project, opts: CloudflareOptions): Promise<number> {
71
+ const app = project.appDir;
72
+ const configPath = join(app, "wrangler.jsonc");
73
+ if (!existsSync(configPath)) return fail("wrangler.jsonc is missing — `vc generate` writes it.");
74
+
75
+ // 1. wrangler, logged in, account pinned.
76
+ const bin = findWrangler(app);
77
+ if (!bin) return fail("wrangler is not installed. `bun add -d wrangler`, then `wrangler login`.");
78
+ const who = await whoami(bin, app);
79
+ if (who.state === "logged-out") return fail("wrangler is not logged in. Run `wrangler login` (or export CLOUDFLARE_API_TOKEN).");
80
+ if (who.state === "unknown") return fail(`wrangler could not confirm who you are:\n${who.raw.trim().split("\n").slice(-4).join("\n")}`);
81
+ console.log(`${color.green("✓")} wrangler is logged in`);
82
+
83
+ let config = readConfig(configPath);
84
+ let accountId = config.account_id || process.env["CLOUDFLARE_ACCOUNT_ID"] || "";
85
+ if (!accountId) {
86
+ if (who.accounts.length === 1) {
87
+ accountId = who.accounts[0]!.id;
88
+ writeFileSync(configPath, upsertJsonc(readFileSync(configPath, "utf8"), "account_id", accountId));
89
+ project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId };
90
+ await writeManifest(project.root, project.manifest);
91
+ console.log(`${color.green("✓")} pinned the account "${who.accounts[0]!.name}" in wrangler.jsonc`);
92
+ } else {
93
+ return fail(
94
+ `the account is not pinned and wrangler sees ${who.accounts.length}. Set account_id in wrangler.jsonc to one of:\n${who.accounts.map((a) => ` ${a.id} ${a.name}`).join("\n")}`,
95
+ );
96
+ }
97
+ }
98
+
99
+ // 2. preflight.
100
+ const check = await preflight(project, "wrangler");
101
+ printPreflight(project, check, "wrangler");
102
+ if (!check.ready) {
103
+ if (!opts.force) return fail("refusing to deploy a shop that is not ready for customers. Fix the above, or pass --force for a deliberate partial deploy.");
104
+ console.error(color.yellow("--force: deploying a shop that is NOT ready for customers.\n"));
105
+ }
106
+
107
+ // 3. resources.
108
+ const worker = config.name || project.manifest.shop.domain.split(".")[0]!;
109
+ const d1 = config.d1_databases?.find((db) => db.binding === "DB");
110
+ const provisioned = Boolean(d1?.database_id && d1.database_id !== "local");
111
+ if (!provisioned) {
112
+ if (!opts.provision) return fail("the D1 database is not provisioned. Run once with --provision to create it and record its id.");
113
+ const db = await ensureD1(bin, app, `${worker}-db`);
114
+ const entry = { binding: "DB", database_name: db.name, database_id: db.uuid, migrations_dir: "./db/migrations" };
115
+ writeFileSync(configPath, upsertJsonc(readFileSync(configPath, "utf8"), "d1_databases", [entry]));
116
+ project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId, d1: { name: db.name, id: db.uuid } };
117
+ await writeManifest(project.root, project.manifest);
118
+ console.log(`${color.green("✓")} D1 "${db.name}" recorded in wrangler.jsonc and voidcommerce.json`);
119
+ config = readConfig(configPath);
120
+ }
121
+ if (opts.provision) {
122
+ // Void names the queue after the consumer file; a queue is account-wide.
123
+ await ensureQueue(bin, app, "commerce");
124
+ console.log(`${color.green("✓")} queue "commerce"`);
125
+ }
126
+
127
+ // 4. build.
128
+ const builder = findBuilder(app);
129
+ if (!builder) return fail("no build tool: neither vite-plus nor vite is installed.");
130
+ console.log(`\n▸ ${builder.label}`);
131
+ const built = await wrangler(builder.cmd, ["build"], app, true);
132
+ if (built.code !== 0) return fail(`the build failed (exit ${built.code}).`);
133
+
134
+ // 5. scrub.
135
+ const emittedPath = join(app, "dist", "ssr", "wrangler.json");
136
+ if (!existsSync(emittedPath)) return fail(`the build emitted no ${emittedPath} — is this a Void app on the Cloudflare target?`);
137
+ const emitted = JSON.parse(readFileSync(emittedPath, "utf8")) as WranglerConfig;
138
+ const secretKeys = new Set(allEnvKeys(project.manifest).filter((key) => !key.plaintext).map((key) => key.key));
139
+ const scrubbed: string[] = [];
140
+ for (const [key, value] of Object.entries(emitted.vars ?? {})) {
141
+ if (secretKeys.has(key) || value === "unset") {
142
+ delete emitted.vars![key];
143
+ scrubbed.push(key);
144
+ }
145
+ }
146
+ const emittedD1 = emitted.d1_databases?.find((db) => db.binding === "DB");
147
+ if (!emittedD1 || emittedD1.database_id === "local") return fail("the emitted config still carries a placeholder D1 id; wrangler.jsonc's DB binding was not picked up by the build.");
148
+ writeFileSync(emittedPath, JSON.stringify(emitted, null, 2));
149
+ console.log(`${color.green("✓")} scrubbed ${scrubbed.length} baked value${scrubbed.length === 1 ? "" : "s"} from the worker's vars${scrubbed.length ? `: ${scrubbed.join(", ")}` : ""}`);
150
+
151
+ // 6. migrations, with the root config — its migrations_dir is the committed one.
152
+ console.log(`\n▸ wrangler d1 migrations apply ${emittedD1.database_name} --remote`);
153
+ const migrated = await wrangler(bin, ["d1", "migrations", "apply", emittedD1.database_name, "--remote"], app, true);
154
+ if (migrated.code !== 0) return fail(`applying migrations failed (exit ${migrated.code}); nothing was deployed.`);
155
+
156
+ // 7. deploy exactly what was scrubbed.
157
+ console.log("\n▸ wrangler deploy -c dist/ssr/wrangler.json");
158
+ const deployed = await wrangler(bin, ["deploy", "-c", join("dist", "ssr", "wrangler.json")], app, true);
159
+ if (deployed.code !== 0) return fail(`wrangler deploy failed (exit ${deployed.code}).`);
160
+
161
+ const domain = project.manifest.shop.domain;
162
+ const url = `https://${workerHosts(project.manifest)[0]}`;
163
+ console.log(`\n${color.green("Live:")} ${url}`);
164
+ if (hasFrontend(project.manifest.layout)) console.log(color.dim("The storefront deploys itself from GitHub Actions on push."));
165
+ return 0;
166
+ }
@@ -0,0 +1,101 @@
1
+ import color from "picocolors";
2
+ import { box, line, row } from "../help";
3
+ import { findProject } from "../project";
4
+ import { captureVoid, runVoid } from "../void";
5
+ import { deployCloudflare } from "./cloudflare";
6
+ import { preflight, printPreflight } from "./preflight";
7
+
8
+ /**
9
+ * `vc deploy` extends `void deploy`: vc's preflight first, then void's
10
+ * deploy, verbatim. `--cloudflare` is the other road — wrangler, your own
11
+ * account, no Void login — and takes vc's own flags:
12
+ *
13
+ * --cloudflare deploy with wrangler to your Cloudflare account
14
+ * --provision create the D1 database and the queue on first deploy
15
+ * --force deploy even when preflight says the shop is not ready
16
+ *
17
+ * Everything else after `deploy` is void's, untouched.
18
+ */
19
+ export async function deployCommand(args: string[]): Promise<number> {
20
+ const own = new Set(["--cloudflare", "--provision", "--force"]);
21
+ const cloudflare = args.includes("--cloudflare");
22
+ const voids = args.filter((arg) => !own.has(arg));
23
+ const project = await findProject();
24
+ if (!project) {
25
+ if (cloudflare) {
26
+ console.error("vc: no voidcommerce.json here — the Cloudflare deploy needs the manifest to know what the shop requires.");
27
+ return 1;
28
+ }
29
+ return runVoid(["deploy", ...voids]);
30
+ }
31
+
32
+ if (cloudflare) {
33
+ if (voids.length > 0) {
34
+ console.error(`vc: --cloudflare takes only --provision and --force; ${voids.join(" ")} is void's and does not apply.`);
35
+ return 1;
36
+ }
37
+ return deployCloudflare(project, { provision: args.includes("--provision"), force: args.includes("--force") });
38
+ }
39
+
40
+ // void's own deploy — the managed platform, void's login — after vc's gate.
41
+ const check = await preflight(project, "void");
42
+ printPreflight(project, check, "void");
43
+ if (!check.ready && check.remote !== null && !args.includes("--force")) {
44
+ console.error(`${color.red("✗")} refusing to deploy a shop that is not ready for customers. Fix the above, or pass --force.\n`);
45
+ return 1;
46
+ }
47
+ if (!check.ready && check.remote === null) {
48
+ console.log(color.dim("Secrets could not be verified here; void's own deploy gate is the next check.\n"));
49
+ }
50
+ return runVoid(["deploy", ...voids], project.appDir);
51
+ }
52
+
53
+ export async function preflightCommand(args: string[]): Promise<number> {
54
+ const project = await findProject();
55
+ if (!project) {
56
+ console.error("vc: no voidcommerce.json here.");
57
+ return 1;
58
+ }
59
+ const source = args.includes("--cloudflare") ? "wrangler" : "void";
60
+ const result = await preflight(project, source);
61
+ printPreflight(project, result, source);
62
+ return result.ready ? 0 : 1;
63
+ }
64
+
65
+ /** void's deploy help, then what vc adds. */
66
+ export async function deployHelp(): Promise<number> {
67
+ const captured = await captureVoid(["deploy", "--help"]);
68
+ const width = 80;
69
+ const ours = box("vc deploy", [
70
+ line("vc's preflight — every required key set, the hostnames right — then", width),
71
+ line("void deploy, untouched. Or, with --cloudflare, your own account via wrangler.", width),
72
+ line("", width),
73
+ line(color.bold("Usage"), width),
74
+ ...row("vc deploy [void's flags]", "preflight, then void deploy — the Void platform, void's login", width, 2),
75
+ ...row("vc deploy --cloudflare", "wrangler, your Cloudflare account, no Void login: preflight, build, scrub baked secrets, migrate D1, deploy", width, 2),
76
+ ...row("vc deploy --cloudflare --provision", "first time: create the D1 database and the queue, record them", width, 2),
77
+ ...row("vc deploy --cloudflare --force", "deploy a shop preflight says is not ready — deliberately", width, 2),
78
+ line("", width),
79
+ line(color.bold("Needs, for --cloudflare"), width),
80
+ ...row("wrangler login", "or CLOUDFLARE_API_TOKEN; the account is pinned for you when there is one", width, 2),
81
+ ...row("wrangler secret put <KEY>", "each secret, on the worker — preflight lists them", width, 2),
82
+ ], width);
83
+ if (captured) process.stdout.write(captured.out.replace(/\n*$/, "\n"));
84
+ console.log(ours);
85
+ return captured?.code ?? 0;
86
+ }
87
+
88
+ export async function preflightHelp(): Promise<number> {
89
+ const width = 80;
90
+ console.log(
91
+ box("vc preflight", [
92
+ line("Is this shop ready to advertise on? Every required key, and what breaks without it.", width),
93
+ line("", width),
94
+ ...row("vc preflight", "secrets from the Void platform", width, 2),
95
+ ...row("vc preflight --cloudflare", "secrets from the worker, via wrangler", width, 2),
96
+ line("", width),
97
+ line("Exit 0 when ready. `vc deploy` runs this first and refuses otherwise.", width),
98
+ ], width),
99
+ );
100
+ return 0;
101
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Enough JSONC to read and edit a `wrangler.jsonc` without losing its
3
+ * comments. Wrangler's own edits normalise the whole file; these do not.
4
+ */
5
+
6
+ /** Parse JSON with `//` and `/* *\/` comments and trailing commas. */
7
+ export function parseJsonc<T = unknown>(text: string): T {
8
+ let out = "";
9
+ let inString = false;
10
+ for (let i = 0; i < text.length; i++) {
11
+ const ch = text[i]!;
12
+ const next = text[i + 1];
13
+ if (inString) {
14
+ out += ch;
15
+ if (ch === "\\") {
16
+ out += next ?? "";
17
+ i++;
18
+ } else if (ch === '"') inString = false;
19
+ continue;
20
+ }
21
+ if (ch === '"') {
22
+ inString = true;
23
+ out += ch;
24
+ } else if (ch === "/" && next === "/") {
25
+ while (i < text.length && text[i] !== "\n") i++;
26
+ out += "\n";
27
+ } else if (ch === "/" && next === "*") {
28
+ i += 2;
29
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
30
+ i++;
31
+ } else out += ch;
32
+ }
33
+ // Trailing commas, outside strings.
34
+ let cleaned = "";
35
+ inString = false;
36
+ for (let i = 0; i < out.length; i++) {
37
+ const ch = out[i]!;
38
+ if (inString) {
39
+ cleaned += ch;
40
+ if (ch === "\\") {
41
+ cleaned += out[i + 1] ?? "";
42
+ i++;
43
+ } else if (ch === '"') inString = false;
44
+ continue;
45
+ }
46
+ if (ch === '"') inString = true;
47
+ if (ch === ",") {
48
+ let j = i + 1;
49
+ while (j < out.length && /\s/.test(out[j]!)) j++;
50
+ if (out[j] === "}" || out[j] === "]") continue;
51
+ }
52
+ cleaned += ch;
53
+ }
54
+ return JSON.parse(cleaned) as T;
55
+ }
56
+
57
+ /** Positions in the ORIGINAL text of a top-level key's value, or null. */
58
+ function findTopLevel(text: string, key: string): { keyStart: number; valueStart: number; valueEnd: number } | null {
59
+ let depth = 0;
60
+ let inString = false;
61
+ let stringStart = -1;
62
+ let lastKey: { text: string; start: number } | null = null;
63
+ for (let i = 0; i < text.length; i++) {
64
+ const ch = text[i]!;
65
+ const next = text[i + 1];
66
+ if (inString) {
67
+ if (ch === "\\") i++;
68
+ else if (ch === '"') {
69
+ inString = false;
70
+ if (depth === 1) lastKey = { text: text.slice(stringStart + 1, i), start: stringStart };
71
+ }
72
+ continue;
73
+ }
74
+ if (ch === "/" && next === "/") {
75
+ while (i < text.length && text[i] !== "\n") i++;
76
+ continue;
77
+ }
78
+ if (ch === "/" && next === "*") {
79
+ i += 2;
80
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
81
+ i++;
82
+ continue;
83
+ }
84
+ if (ch === '"') {
85
+ inString = true;
86
+ stringStart = i;
87
+ continue;
88
+ }
89
+ if (ch === ":" && depth === 1 && lastKey?.text === key) {
90
+ let j = i + 1;
91
+ while (j < text.length && /\s/.test(text[j]!)) j++;
92
+ return { keyStart: lastKey.start, valueStart: j, valueEnd: valueEnd(text, j) };
93
+ }
94
+ if (ch === "{" || ch === "[") depth++;
95
+ else if (ch === "}" || ch === "]") depth--;
96
+ }
97
+ return null;
98
+ }
99
+
100
+ /** Where the value starting at `from` ends (exclusive). */
101
+ function valueEnd(text: string, from: number): number {
102
+ const open = text[from];
103
+ if (open === '"') {
104
+ for (let i = from + 1; i < text.length; i++) {
105
+ if (text[i] === "\\") i++;
106
+ else if (text[i] === '"') return i + 1;
107
+ }
108
+ return text.length;
109
+ }
110
+ if (open === "{" || open === "[") {
111
+ let depth = 0;
112
+ let inString = false;
113
+ for (let i = from; i < text.length; i++) {
114
+ const ch = text[i]!;
115
+ if (inString) {
116
+ if (ch === "\\") i++;
117
+ else if (ch === '"') inString = false;
118
+ continue;
119
+ }
120
+ if (ch === '"') inString = true;
121
+ else if (ch === "/" && text[i + 1] === "/") {
122
+ while (i < text.length && text[i] !== "\n") i++;
123
+ } else if (ch === "{" || ch === "[") depth++;
124
+ else if (ch === "}" || ch === "]") {
125
+ depth--;
126
+ if (depth === 0) return i + 1;
127
+ }
128
+ }
129
+ return text.length;
130
+ }
131
+ let i = from;
132
+ while (i < text.length && !/[,}\]\s]/.test(text[i]!)) i++;
133
+ return i;
134
+ }
135
+
136
+ /** Set a top-level key, replacing its value or adding it last. Comments and the rest of the file are kept. */
137
+ export function upsertJsonc(text: string, key: string, value: unknown, indent = " "): string {
138
+ const rendered = JSON.stringify(value, null, indent).replace(/\n/g, `\n${indent}`);
139
+ const found = findTopLevel(text, key);
140
+ if (found) return text.slice(0, found.valueStart) + rendered + text.slice(found.valueEnd);
141
+ const close = text.lastIndexOf("}");
142
+ if (close < 0) return `{\n${indent}${JSON.stringify(key)}: ${rendered}\n}\n`;
143
+ // Does a property precede the closing brace? Then a comma is needed.
144
+ let k = close - 1;
145
+ while (k >= 0 && /\s/.test(text[k]!)) k--;
146
+ const needsComma = text[k] !== "{" && text[k] !== ",";
147
+ return `${text.slice(0, close).replace(/\s*$/, "")}${needsComma ? "," : ""}\n${indent}${JSON.stringify(key)}: ${rendered}\n${text.slice(close)}`;
148
+ }
@@ -0,0 +1,137 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import color from "picocolors";
4
+ import type { EnvKey } from "../catalog";
5
+ import { allEnvKeys } from "../generate/env";
6
+ import { workerHosts } from "../manifest";
7
+ import type { Project } from "../project";
8
+ import { captureVoid } from "../void";
9
+ import { parseJsonc } from "./jsonc";
10
+ import { findWrangler, secretNames } from "./wrangler";
11
+
12
+ /**
13
+ * Is this deployment ready to advertise on?
14
+ *
15
+ * Every key in env.ts is required, which stops one being silently absent.
16
+ * It cannot stop one being PRESENT and meaningless: the `unset` sentinel
17
+ * lets a developer run the app without an Adyen account, and a deployment
18
+ * carrying it would start perfectly, take an order, and never charge
19
+ * anyone. This is the gate that says WHY each thing matters.
20
+ */
21
+
22
+ export const UNSET = "unset";
23
+
24
+ export interface Preflight {
25
+ /** What is set, from .env.production and the worker's secrets. */
26
+ present: Map<string, string>;
27
+ /** Secret names on the worker; null when they could not be read. */
28
+ remote: Set<string> | null;
29
+ missing: EnvKey[];
30
+ routeProblem: string | null;
31
+ ready: boolean;
32
+ }
33
+
34
+ export type SecretSource = "wrangler" | "void";
35
+
36
+ /** Keys committed in .env.production — safe values only, by construction. */
37
+ export function productionEnv(appDir: string): Map<string, string> {
38
+ const out = new Map<string, string>();
39
+ const path = join(appDir, ".env.production");
40
+ if (!existsSync(path)) return out;
41
+ for (const line of readFileSync(path, "utf8").split("\n")) {
42
+ const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line);
43
+ if (!match) continue;
44
+ const value = (match[2] ?? "").trim().replace(/^['"]|['"]$/g, "");
45
+ if (value) out.set(match[1]!, value);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /** Secret names from the Void platform's `void secret list`, best effort. */
51
+ async function voidSecretNames(appDir: string): Promise<Set<string> | null> {
52
+ const captured = await captureVoid(["secret", "list"], appDir);
53
+ if (!captured || captured.code !== 0) return null;
54
+ const names = new Set<string>();
55
+ for (const line of captured.out.split("\n")) {
56
+ const match = /\b([A-Z][A-Z0-9_]{2,})\b/.exec(line.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""));
57
+ if (match) names.add(match[1]!);
58
+ }
59
+ return names;
60
+ }
61
+
62
+ /** The worker's hostnames must be what the layout says. */
63
+ export function routeProblem(project: Project): string | null {
64
+ const path = join(project.appDir, "wrangler.jsonc");
65
+ if (!existsSync(path)) return "wrangler.jsonc is missing";
66
+ let routes: Array<{ pattern?: string }> = [];
67
+ try {
68
+ routes = parseJsonc<{ routes?: Array<{ pattern?: string }> }>(readFileSync(path, "utf8")).routes ?? [];
69
+ } catch {
70
+ return "wrangler.jsonc cannot be parsed";
71
+ }
72
+ const expected = workerHosts(project.manifest);
73
+ const found = routes.map((r) => r.pattern ?? "");
74
+ const absent = expected.filter((host) => !found.includes(host));
75
+ return absent.length === 0 ? null : `wrangler.jsonc routes to [${found.join(", ")}] but the shop needs ${absent.join(", ")}`;
76
+ }
77
+
78
+ export async function preflight(project: Project, source: SecretSource): Promise<Preflight> {
79
+ const present = productionEnv(project.appDir);
80
+ let remote: Set<string> | null = null;
81
+ if (source === "wrangler") {
82
+ const bin = findWrangler(project.appDir);
83
+ remote = bin ? await secretNames(bin, project.appDir) : null;
84
+ } else {
85
+ remote = await voidSecretNames(project.appDir);
86
+ }
87
+ for (const name of remote ?? []) present.set(name, "<secret>");
88
+
89
+ const missing = allEnvKeys(project.manifest).filter((key) => {
90
+ const value = present.get(key.key);
91
+ return value === undefined || value === "" || value === UNSET;
92
+ });
93
+ const problem = routeProblem(project);
94
+ return { present, remote, missing, routeProblem: problem, ready: missing.length === 0 && problem === null };
95
+ }
96
+
97
+ /** Print the report a person can act on. */
98
+ export function printPreflight(project: Project, result: Preflight, source: SecretSource): void {
99
+ const keys = allEnvKeys(project.manifest);
100
+ if (result.remote === null) {
101
+ console.log(
102
+ color.dim(
103
+ source === "wrangler"
104
+ ? "Could not read the worker's secrets — not deployed yet, or wrangler is not logged in.\nAnything not in .env.production is reported as missing.\n"
105
+ : "Could not read the project's secrets from Void — not logged in, or no linked project.\nAnything not in .env.production is reported as missing.\n",
106
+ ),
107
+ );
108
+ }
109
+ console.log("REQUIRED — the shop cannot trade without these");
110
+ for (const key of keys) {
111
+ const value = result.present.get(key.key);
112
+ const set = value !== undefined && value !== "" && value !== UNSET;
113
+ console.log(` ${set ? color.green("set ") : color.dim("unset")} ${key.key}${key.plaintext ? color.dim(" (plaintext)") : ""}`);
114
+ }
115
+ if (result.ready) {
116
+ console.log(`\n${color.green("Ready to go live.")}\n`);
117
+ return;
118
+ }
119
+ console.log(`\n${color.red("NOT ready to go live.")}\n`);
120
+ if (result.routeProblem) {
121
+ console.log(`${color.red("✗")} the worker's hostname\n ${result.routeProblem}\n`);
122
+ }
123
+ for (const key of result.missing) {
124
+ console.log(`${color.red("✗")} ${key.key}`);
125
+ console.log(` ${key.breaks}`);
126
+ if (key.where) console.log(` ${color.dim(`from: ${key.where}`)}`);
127
+ console.log("");
128
+ }
129
+ const secrets = result.missing.filter((key) => !key.plaintext);
130
+ if (secrets.length > 0) {
131
+ console.log(source === "wrangler" ? "Set each secret on the worker (this also creates the draft worker on a first deploy):" : "Set each secret on the project:");
132
+ for (const key of secrets) console.log(` ${source === "wrangler" ? "wrangler secret put" : "void secret put"} ${key.key}`);
133
+ }
134
+ const plain = result.missing.filter((key) => key.plaintext);
135
+ if (plain.length > 0) console.log(`Values safe to commit go in .env.production: ${plain.map((k) => k.key).join(", ")}`);
136
+ console.log("");
137
+ }