superslate 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 (4) hide show
  1. package/LICENSE +5 -0
  2. package/README.md +62 -0
  3. package/dist/cli.js +2172 -0
  4. package/package.json +46 -0
package/dist/cli.js ADDED
@@ -0,0 +1,2172 @@
1
+ #!/usr/bin/env node
2
+ // @bun
3
+
4
+ // src/cli.ts
5
+ import * as prompts5 from "@clack/prompts";
6
+ import { mkdir as mkdir5, mkdtemp as mkdtemp2, readFile as readFile6, readdir as readdir2, rename as rename2, rm as rm4, rmdir, stat as stat5 } from "fs/promises";
7
+ import { createConnection, createServer } from "net";
8
+ import { fileURLToPath as fileURLToPath2 } from "url";
9
+ import { dirname as dirname2, join as join7, relative as relative2, resolve as resolve4, sep } from "path";
10
+
11
+ // src/constants.ts
12
+ var CLI_NAME = "superslate";
13
+
14
+ // src/constants.ts
15
+ var CLI_NAME2 = "superslate";
16
+ var CLI_PACKAGE_NAME = "superslate";
17
+ var BUYER_CLI_NAME = "create-app";
18
+ var BUYER_CLI_PACKAGE_NAME = "create-app";
19
+ var DEFAULT_SCOPE = "@app";
20
+ var SOURCE_BRAND_NAME = "superslate";
21
+ var TEMPLATE_REPOSITORY = "superslate-dev/superslate";
22
+
23
+ // src/core.ts
24
+ function validateAppName(value) {
25
+ if (!value)
26
+ return "App name is required.";
27
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)) {
28
+ return "App name must be kebab-case (lowercase letters, numbers, and single hyphens).";
29
+ }
30
+ return;
31
+ }
32
+ function normalizeScope(value) {
33
+ const scope = value.startsWith("@") ? value : `@${value}`;
34
+ if (/\s/.test(scope)) {
35
+ throw new Error("Use hyphens instead of spaces, for example test-package.");
36
+ }
37
+ if (!/^@[a-z0-9][a-z0-9._-]*$/.test(scope)) {
38
+ throw new Error("Use lowercase letters and numbers, with optional periods, underscores, or hyphens.");
39
+ }
40
+ return scope;
41
+ }
42
+ function validateBrandName(value) {
43
+ if (!value.trim())
44
+ return "Brand name cannot be empty.";
45
+ if (/\r|\n/.test(value))
46
+ return "Brand name must fit on one line.";
47
+ if (value !== value.trim())
48
+ return "Brand name cannot start or end with whitespace.";
49
+ if (value.length > 48)
50
+ return "Brand name must be 48 characters or less.";
51
+ if (!/^[\p{L}\p{N}](?:[\p{L}\p{N} ._-]*[\p{L}\p{N}])?$/u.test(value)) {
52
+ return "Brand name may contain letters, numbers, spaces, periods, underscores, and hyphens.";
53
+ }
54
+ return;
55
+ }
56
+ var SCOPE_EXTENSIONS = new Set([".json", ".jsonc", ".md", ".ts", ".tsx", ".yaml", ".yml"]);
57
+
58
+ // src/agent-setup.ts
59
+ import * as prompts from "@clack/prompts";
60
+ import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
61
+ import { dirname, join as join2, resolve } from "node:path";
62
+
63
+ // src/runtime.ts
64
+ import { spawn } from "node:child_process";
65
+ import { accessSync, constants } from "node:fs";
66
+ import { delimiter, join, resolve as resolvePath } from "node:path";
67
+ function runCaptured(command, options) {
68
+ const [file, ...args] = command;
69
+ if (!file)
70
+ throw new Error("runCaptured requires a command");
71
+ return new Promise((resolve, reject) => {
72
+ const child = spawn(file, args, {
73
+ cwd: options.cwd,
74
+ env: options.env ?? process.env,
75
+ stdio: ["ignore", "pipe", "pipe"]
76
+ });
77
+ let stdout = "";
78
+ let stderr = "";
79
+ child.stdout.setEncoding("utf8");
80
+ child.stderr.setEncoding("utf8");
81
+ child.stdout.on("data", (chunk) => {
82
+ stdout += chunk;
83
+ });
84
+ child.stderr.on("data", (chunk) => {
85
+ stderr += chunk;
86
+ });
87
+ child.on("error", reject);
88
+ child.on("close", (code, signal) => {
89
+ resolve({ exitCode: code ?? (signal ? 1 : 0), stdout, stderr });
90
+ });
91
+ });
92
+ }
93
+ function which(command) {
94
+ if (command.includes("/"))
95
+ return isExecutable(command) ? command : null;
96
+ const extensions = process.platform === "win32" ? (process.env.PATHEXT ?? "").split(";") : [""];
97
+ for (const directory of (process.env.PATH ?? "").split(delimiter)) {
98
+ if (!directory)
99
+ continue;
100
+ for (const extension of extensions) {
101
+ const candidate = join(directory, `${command}${extension}`);
102
+ if (isExecutable(candidate))
103
+ return candidate;
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+ function isExecutable(path) {
109
+ try {
110
+ accessSync(path, constants.X_OK);
111
+ return true;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ // src/agent-setup.ts
118
+ var CLIENTS = ["codex", "claude", "cursor"];
119
+ var SERVER_NAME = "superslate-docs";
120
+ var CODEX_START = "# superslate-agent-context:start";
121
+ var CODEX_END = "# superslate-agent-context:end";
122
+ function takeValue(args, index, flag) {
123
+ const value = args[index + 1];
124
+ if (!value || value.startsWith("-"))
125
+ throw new Error(`${flag} requires a value.`);
126
+ return value;
127
+ }
128
+ function parseAgentSetupArguments(argv) {
129
+ const parsed = { yes: false, dryRun: false, help: false };
130
+ for (let index = 0;index < argv.length; index += 1) {
131
+ const argument = argv[index];
132
+ if (argument === "--clients") {
133
+ const values = takeValue(argv, index++, argument).split(",").map((value) => value.trim()).filter(Boolean);
134
+ const invalid = values.filter((value) => !CLIENTS.includes(value));
135
+ if (invalid.length > 0)
136
+ throw new Error(`Unknown agent client: ${invalid.join(", ")}`);
137
+ parsed.clients = [...new Set(values)];
138
+ } else if (argument === "--yes" || argument === "-y")
139
+ parsed.yes = true;
140
+ else if (argument === "--dry-run")
141
+ parsed.dryRun = true;
142
+ else if (argument === "--help" || argument === "-h")
143
+ parsed.help = true;
144
+ else
145
+ throw new Error(`Unknown agent setup option: ${argument}`);
146
+ }
147
+ return parsed;
148
+ }
149
+ function agentSetupUsage(cliName) {
150
+ return `Usage: ${cliName} agent setup [options]
151
+
152
+ Connect local coding clients to this checkout's read-only superslate documentation server.
153
+
154
+ Options:
155
+ --clients <list> Comma-separated clients: codex,claude,cursor
156
+ --yes Configure every detected client without prompting
157
+ --dry-run Show the selected configuration without changing client settings
158
+ -h, --help Show this help`;
159
+ }
160
+ async function pathExists(path) {
161
+ return Boolean(await stat(path).catch(() => {
162
+ return;
163
+ }));
164
+ }
165
+ async function findAgentRepositoryRoot(start = process.cwd()) {
166
+ let current = resolve(start);
167
+ while (true) {
168
+ if (await pathExists(join2(current, "AGENTS.md")) && await pathExists(join2(current, "packages/agent-context/package.json")) && await pathExists(join2(current, "agent-tooling/manifest.json"))) {
169
+ return current;
170
+ }
171
+ const parent = dirname(current);
172
+ if (parent === current)
173
+ throw new Error("Run agent setup inside a superslate repository.");
174
+ current = parent;
175
+ }
176
+ }
177
+ function serverCommand(root) {
178
+ return { command: "pnpm", args: ["--dir", resolve(root), "superslate:mcp"] };
179
+ }
180
+ function codexManagedBlock(root) {
181
+ const server = serverCommand(root);
182
+ const args = server.args.map((argument) => JSON.stringify(argument)).join(", ");
183
+ return `${CODEX_START}
184
+ [mcp_servers.${SERVER_NAME}]
185
+ command = ${JSON.stringify(server.command)}
186
+ args = [${args}]
187
+ required = false
188
+ enabled_tools = ["search_docs", "get_repository_map", "plan_change", "get_invariants", "get_verification"]
189
+ ${CODEX_END}`;
190
+ }
191
+ function mergeCodexConfig(existing, root) {
192
+ const managed = codexManagedBlock(root);
193
+ const start = existing.indexOf(CODEX_START);
194
+ const end = existing.indexOf(CODEX_END);
195
+ if (start >= 0 || end >= 0) {
196
+ if (start < 0 || end < start)
197
+ throw new Error("Codex config has an incomplete superslate block.");
198
+ const after = end + CODEX_END.length;
199
+ return `${existing.slice(0, start).trimEnd()}
200
+
201
+ ${managed}${existing.slice(after)}`.trimStart();
202
+ }
203
+ if (/^\[mcp_servers\.(?:superslate-docs|superslate_docs|"superslate-docs")\]$/m.test(existing)) {
204
+ throw new Error("Codex already has an unmanaged superslate-docs server; remove or rename it first.");
205
+ }
206
+ return `${existing.trimEnd()}${existing.trim() ? `
207
+
208
+ ` : ""}${managed}
209
+ `;
210
+ }
211
+ function mergeCursorConfig(existing, root) {
212
+ let config = {};
213
+ if (existing?.trim()) {
214
+ try {
215
+ const parsed = JSON.parse(existing);
216
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
217
+ throw new Error("Cursor MCP configuration must be a JSON object.");
218
+ }
219
+ config = parsed;
220
+ } catch (error) {
221
+ if (error instanceof SyntaxError)
222
+ throw new Error("Cursor MCP configuration is invalid JSON.");
223
+ throw error;
224
+ }
225
+ }
226
+ const servers = config.mcpServers ?? {};
227
+ const current = servers[SERVER_NAME];
228
+ const desired = serverCommand(root);
229
+ if (current !== undefined && JSON.stringify(current) !== JSON.stringify(desired)) {
230
+ throw new Error("Cursor already has a different superslate-docs server; remove or rename it first.");
231
+ }
232
+ config.mcpServers = { ...servers, [SERVER_NAME]: desired };
233
+ return `${JSON.stringify(config, null, 2)}
234
+ `;
235
+ }
236
+ async function atomicWrite(path, content) {
237
+ await mkdir(dirname(path), { recursive: true });
238
+ const temporary = `${path}.${process.pid}.tmp`;
239
+ await writeFile(temporary, content, { mode: 384 });
240
+ await rename(temporary, path);
241
+ }
242
+ async function capture(command, cwd) {
243
+ return runCaptured(command, { cwd });
244
+ }
245
+ async function addGitInfoExcludes(root, paths) {
246
+ const result = await capture(["git", "rev-parse", "--git-path", "info/exclude"], root);
247
+ if (result.exitCode !== 0)
248
+ return false;
249
+ const rawPath = result.stdout.trim();
250
+ if (!rawPath)
251
+ return false;
252
+ const excludePath = resolve(root, rawPath);
253
+ const existing = await readFile(excludePath, "utf8").catch(() => "");
254
+ const missing = paths.filter((path) => !existing.split(/\r?\n/).includes(path));
255
+ if (missing.length === 0)
256
+ return true;
257
+ const prefix = existing && !existing.endsWith(`
258
+ `) ? `
259
+ ` : "";
260
+ const marker = "# superslate local agent configuration";
261
+ const heading = existing.split(/\r?\n/).includes(marker) ? "" : `${marker}
262
+ `;
263
+ await atomicWrite(excludePath, `${existing}${prefix}${heading}${missing.join(`
264
+ `)}
265
+ `);
266
+ return true;
267
+ }
268
+ async function configureCodex(root) {
269
+ const path = join2(root, ".codex/config.toml");
270
+ const existing = await readFile(path, "utf8").catch(() => "");
271
+ await atomicWrite(path, mergeCodexConfig(existing, root));
272
+ const excluded = await addGitInfoExcludes(root, [".codex/config.toml"]);
273
+ return excluded ? "Configured project-local .codex/config.toml (excluded from Git)." : "Configured project-local .codex/config.toml; review Git status before committing it.";
274
+ }
275
+ async function configureCursor(root) {
276
+ const path = join2(root, ".cursor/mcp.json");
277
+ const existing = await readFile(path, "utf8").catch(() => {
278
+ return;
279
+ });
280
+ await atomicWrite(path, mergeCursorConfig(existing, root));
281
+ const excluded = await addGitInfoExcludes(root, [".cursor/mcp.json"]);
282
+ return excluded ? "Configured project-local .cursor/mcp.json (excluded from Git)." : "Configured project-local .cursor/mcp.json; review Git status before committing it.";
283
+ }
284
+ async function configureClaude(root) {
285
+ if (!which("claude"))
286
+ throw new Error("Claude Code CLI is not installed or not on PATH.");
287
+ const existing = await capture(["claude", "mcp", "get", SERVER_NAME], root);
288
+ if (existing.exitCode === 0) {
289
+ const combined = `${existing.stdout}
290
+ ${existing.stderr}`;
291
+ if (combined.includes("superslate:mcp") && combined.includes(root)) {
292
+ return "Claude Code already has the matching local server.";
293
+ }
294
+ throw new Error("Claude Code already has a different superslate-docs server; remove it first.");
295
+ }
296
+ const server = serverCommand(root);
297
+ const result = await capture(["claude", "mcp", "add", SERVER_NAME, "--scope", "local", "--", server.command, ...server.args], root);
298
+ if (result.exitCode !== 0) {
299
+ const output = result.stderr.trim() || result.stdout.trim();
300
+ throw new Error(`Claude Code MCP setup failed${output ? `: ${output}` : "."}`);
301
+ }
302
+ return "Configured Claude Code local project scope.";
303
+ }
304
+ async function smokeServer(root) {
305
+ const result = await capture(["pnpm", "--dir", root, "superslate:mcp", "--check", "--root", root], root);
306
+ if (result.exitCode !== 0) {
307
+ const output = result.stderr.trim() || result.stdout.trim();
308
+ throw new Error(`superslate MCP self-check failed${output ? `: ${output}` : "."}`);
309
+ }
310
+ const report = JSON.parse(result.stdout);
311
+ if (!report.ok || report.tools !== 5)
312
+ throw new Error("superslate MCP self-check was incomplete.");
313
+ }
314
+ function detectedClients() {
315
+ return CLIENTS.filter((client) => {
316
+ if (client === "cursor")
317
+ return Boolean(which("cursor"));
318
+ return Boolean(which(client));
319
+ });
320
+ }
321
+ async function chooseClients(parsed) {
322
+ if (parsed.clients) {
323
+ if (parsed.clients.length === 0)
324
+ throw new Error("--clients must select at least one client.");
325
+ return parsed.clients;
326
+ }
327
+ const detected = detectedClients();
328
+ if (parsed.yes) {
329
+ if (detected.length === 0) {
330
+ throw new Error("No supported client was detected. Pass --clients codex,claude,cursor.");
331
+ }
332
+ return detected;
333
+ }
334
+ if (!process.stdin.isTTY) {
335
+ throw new Error("Interactive setup needs a terminal. Pass --clients and --yes instead.");
336
+ }
337
+ const selected = await prompts.multiselect({
338
+ message: "Connect which local coding clients?",
339
+ required: true,
340
+ options: CLIENTS.map((client) => ({
341
+ value: client,
342
+ label: client === "claude" ? "Claude Code" : `${client[0].toUpperCase()}${client.slice(1)}`,
343
+ hint: detected.includes(client) ? "detected" : "not detected on PATH"
344
+ })),
345
+ initialValues: detected
346
+ });
347
+ if (prompts.isCancel(selected)) {
348
+ prompts.cancel("No agent clients were configured.");
349
+ return;
350
+ }
351
+ return selected;
352
+ }
353
+ function dryRunDescription(client, root) {
354
+ if (client === "codex")
355
+ return mergeCodexConfig("", root).trim();
356
+ if (client === "cursor")
357
+ return mergeCursorConfig(undefined, root).trim();
358
+ const server = serverCommand(root);
359
+ return [
360
+ "claude",
361
+ "mcp",
362
+ "add",
363
+ SERVER_NAME,
364
+ "--scope",
365
+ "local",
366
+ "--",
367
+ server.command,
368
+ ...server.args
369
+ ].map((value) => value.includes(" ") ? JSON.stringify(value) : value).join(" ");
370
+ }
371
+ async function runAgentSetup(argv, cliName) {
372
+ const parsed = parseAgentSetupArguments(argv);
373
+ if (parsed.help) {
374
+ process.stdout.write(`${agentSetupUsage(cliName)}
375
+ `);
376
+ return;
377
+ }
378
+ const root = await findAgentRepositoryRoot();
379
+ const clients = await chooseClients(parsed);
380
+ if (!clients)
381
+ return;
382
+ prompts.intro("superslate local agent context");
383
+ if (parsed.dryRun) {
384
+ for (const client of clients) {
385
+ prompts.log.info(`${client}
386
+ ${dryRunDescription(client, root)}`);
387
+ }
388
+ prompts.outro("Dry run complete. No client configuration was changed.");
389
+ return;
390
+ }
391
+ const progress = prompts.spinner();
392
+ progress.start("Testing the local MCP server");
393
+ await smokeServer(root);
394
+ progress.stop("Local MCP server passed its protocol self-check");
395
+ for (const client of clients) {
396
+ const result = client === "codex" ? await configureCodex(root) : client === "cursor" ? await configureCursor(root) : await configureClaude(root);
397
+ prompts.log.success(`${client}: ${result}`);
398
+ }
399
+ prompts.log.info("Restart configured clients, then ask: “Use plan_change for my next feature.”");
400
+ prompts.outro("superslate agent context is ready.");
401
+ }
402
+
403
+ // src/deployment-cli.ts
404
+ import * as prompts3 from "@clack/prompts";
405
+ import { readFile as readFile3 } from "node:fs/promises";
406
+ import { resolve as resolve2 } from "node:path";
407
+
408
+ // src/deployment.ts
409
+ import { mkdir as mkdir2, readFile as readFile2, rm, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
410
+ import { join as join3 } from "node:path";
411
+ var deploymentClouds = ["aws", "gcp", "azure"];
412
+ var frontendTargets = ["native", "vercel", "cloudflare"];
413
+ var storageProviders = ["r2", "s3", "gcs"];
414
+ var defaultRegions = {
415
+ aws: "us-east-1",
416
+ gcp: "us-central1",
417
+ azure: "eastus2"
418
+ };
419
+ var defaultStorageProviders = {
420
+ aws: "s3",
421
+ gcp: "gcs",
422
+ azure: "r2"
423
+ };
424
+ var storageProvidersByCloud = {
425
+ aws: ["s3", "r2"],
426
+ gcp: ["gcs", "r2"],
427
+ azure: ["r2"]
428
+ };
429
+ function isDeploymentCloud(value) {
430
+ return deploymentClouds.some((cloud) => cloud === value);
431
+ }
432
+ function isFrontendTarget(value) {
433
+ return frontendTargets.some((target) => target === value);
434
+ }
435
+ function isStorageProvider(value) {
436
+ return storageProviders.some((provider) => provider === value);
437
+ }
438
+ function parseDeploymentCloud(value) {
439
+ if (!isDeploymentCloud(value))
440
+ throw new Error("Cloud must be aws, gcp, or azure.");
441
+ return value;
442
+ }
443
+ function parseFrontendTarget(value) {
444
+ if (!isFrontendTarget(value)) {
445
+ throw new Error("Frontend target must be native, vercel, or cloudflare.");
446
+ }
447
+ return value;
448
+ }
449
+ function parseStorageProvider(value) {
450
+ if (!isStorageProvider(value))
451
+ throw new Error("Storage provider must be r2, s3, or gcs.");
452
+ return value;
453
+ }
454
+ function validateDeploymentEnvironment(value) {
455
+ if (!/^[a-z][a-z0-9-]{1,30}$/.test(value)) {
456
+ return "Environment must use 2-31 lowercase letters, numbers, or hyphens.";
457
+ }
458
+ return;
459
+ }
460
+ function validateRegion(value) {
461
+ if (!/^[a-z0-9][a-z0-9-]{1,31}$/.test(value)) {
462
+ return "Region must use 2-32 lowercase letters, numbers, or hyphens.";
463
+ }
464
+ return;
465
+ }
466
+ function validateDomain(value) {
467
+ if (value.length > 80 || !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(value)) {
468
+ return "Enter a lowercase hostname such as api.example.com, without a protocol or path.";
469
+ }
470
+ return;
471
+ }
472
+ function validateDeploymentSelection(selection) {
473
+ if (!storageProvidersByCloud[selection.cloud].includes(selection.storageProvider)) {
474
+ throw new Error(`${selection.storageProvider} storage is not supported for ${selection.cloud} deployments.`);
475
+ }
476
+ const environmentError = validateDeploymentEnvironment(selection.environment);
477
+ if (environmentError)
478
+ throw new Error(environmentError);
479
+ const regionError = validateRegion(selection.region);
480
+ if (regionError)
481
+ throw new Error(regionError);
482
+ const apiError = validateDomain(selection.apiDomain);
483
+ if (apiError)
484
+ throw new Error(`API domain: ${apiError}`);
485
+ const frontendError = validateDomain(selection.frontendDomain);
486
+ if (frontendError)
487
+ throw new Error(`Frontend domain: ${frontendError}`);
488
+ if (selection.apiDomain === selection.frontendDomain) {
489
+ throw new Error("API and frontend domains must be different hostnames.");
490
+ }
491
+ const apiSite = selection.apiDomain.split(".").slice(1).join(".");
492
+ const frontendSite = selection.frontendDomain.split(".").slice(1).join(".");
493
+ if (apiSite !== frontendSite) {
494
+ throw new Error("API and frontend domains must be sibling hosts under the same site.");
495
+ }
496
+ }
497
+ function createDeploymentConfig(projectName, selection) {
498
+ validateDeploymentSelection(selection);
499
+ return {
500
+ schema_version: 2,
501
+ cloud: selection.cloud,
502
+ storage_provider: selection.storageProvider,
503
+ project_name: projectName,
504
+ environment: selection.environment,
505
+ region: selection.region,
506
+ frontend_target: selection.frontendTarget,
507
+ external_frontend_origin: selection.frontendTarget === "native" ? null : `https://${selection.frontendDomain}`,
508
+ api_domain: selection.apiDomain,
509
+ frontend_domain: selection.frontendDomain,
510
+ ha_enabled: selection.haEnabled
511
+ };
512
+ }
513
+ function deploymentReadme(config) {
514
+ const identity = config.cloud === "aws" ? "`AWS_ROLE_ARN` secret" : config.cloud === "gcp" ? "`GCP_WORKLOAD_IDENTITY_PROVIDER` and `GCP_SERVICE_ACCOUNT` secrets" : "`AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, and `AZURE_SUBSCRIPTION_ID` secrets";
515
+ const providerInputs = config.cloud === "aws" ? "Route 53 zone or certificate identifiers and runtime Secrets Manager ARNs" : config.cloud === "gcp" ? "GCP project, Cloud DNS zone, and runtime Secret Manager names" : "Azure DNS zone ID and runtime Key Vault secret identifiers";
516
+ const storageSetup = config.storage_provider === "r2" ? `Create private staging and public Cloudflare R2 buckets, then store the required storage
517
+ variables in the runtime secret container.` : `${config.storage_provider.toUpperCase()} application buckets, lifecycle policy, CORS, public access, and workload permissions are
518
+ managed by the selected OpenTofu root.`;
519
+ return `# Deployment setup
520
+
521
+ The deployment target is locked:
522
+
523
+ - backend: **${config.cloud.toUpperCase()}**;
524
+ - storage: **${config.storage_provider}**;
525
+ - frontend: **${config.frontend_target}**;
526
+ - environment: **${config.environment}**.
527
+
528
+ ## Protected GitHub environment
529
+
530
+ Create a GitHub environment named \`${config.environment}\` and configure:
531
+
532
+ - ${identity};
533
+ - a \`BACKEND_HCL\` secret containing the selected remote-state backend configuration;
534
+ - a \`TFVARS_JSON\` secret containing only:
535
+ - ${providerInputs};
536
+ - \`VITE_TERMS_URL\` and \`VITE_PRIVACY_URL\`.
537
+
538
+ ## Cloud preparation
539
+
540
+ 1. Run \`infra/bootstrap/${config.cloud}\` once with an administrator identity.
541
+ 2. Configure OIDC trust for this repository and the \`${config.environment}\` GitHub environment.
542
+ 3. Create the DNS zone or supply the existing zone identifier required by the selected root.
543
+ 4. ${storageSetup}
544
+ 5. Create application secret entries for Better Auth and enabled email, billing, monitoring, OAuth,
545
+ and credential-based providers.
546
+ 6. Put only secret identifiers in \`TFVARS_JSON\`; write secret values directly through the cloud
547
+ provider.
548
+ 7. Dispatch \`.github/workflows/deploy-cloud.yml\` with:
549
+ - cloud: \`${config.cloud}\`;
550
+ - environment: \`${config.environment}\`;
551
+ - frontend: \`${config.frontend_target}\`.
552
+
553
+ The workflow creates the registry first, pushes an immutable image, provisions the API with public
554
+ traffic disabled, runs migrations, and admits traffic only after migration succeeds.
555
+
556
+ The provider lock is deliberate. Restore the deployment directories from a fresh licensed release
557
+ before changing cloud providers.
558
+ `;
559
+ }
560
+ async function removeIfPresent(target) {
561
+ if (!await stat2(target).catch(() => {
562
+ return;
563
+ }))
564
+ return false;
565
+ await rm(target, { recursive: true, force: true });
566
+ return true;
567
+ }
568
+ async function applyDeploymentSelection(repositoryRoot, projectName, selection) {
569
+ const config = createDeploymentConfig(projectName, selection);
570
+ const configPath = join3(repositoryRoot, "deployment/config.json");
571
+ if (await stat2(configPath).catch(() => {
572
+ return;
573
+ })) {
574
+ throw new Error("Deployment is already locked. Restore a fresh release to change providers.");
575
+ }
576
+ await mkdir2(join3(repositoryRoot, "deployment"), { recursive: true });
577
+ await writeFile2(configPath, `${JSON.stringify(config, null, 2)}
578
+ `);
579
+ await writeFile2(join3(repositoryRoot, "deployment/README.md"), deploymentReadme(config));
580
+ const removed = [];
581
+ for (const cloud of deploymentClouds) {
582
+ if (cloud === selection.cloud)
583
+ continue;
584
+ for (const relativePath of [
585
+ `infra/bootstrap/${cloud}`,
586
+ `infra/modules/${cloud}`,
587
+ `infra/roots/${cloud}`,
588
+ `scripts/deploy/release-${cloud}.sh`,
589
+ `scripts/deploy/frontend-${cloud}.sh`
590
+ ]) {
591
+ if (await removeIfPresent(join3(repositoryRoot, relativePath)))
592
+ removed.push(relativePath);
593
+ }
594
+ }
595
+ const adapters = [
596
+ { target: "vercel", path: "apps/web/vercel.json" },
597
+ { target: "cloudflare", path: "apps/web/wrangler.toml" },
598
+ {
599
+ target: "azure-native",
600
+ path: "apps/web/public/staticwebapp.config.json"
601
+ }
602
+ ];
603
+ const selectedAdapter = selection.frontendTarget === "native" ? selection.cloud === "azure" ? "azure-native" : undefined : selection.frontendTarget;
604
+ for (const adapter of adapters) {
605
+ if (adapter.target === selectedAdapter)
606
+ continue;
607
+ if (await removeIfPresent(join3(repositoryRoot, adapter.path)))
608
+ removed.push(adapter.path);
609
+ }
610
+ if (selection.frontendTarget !== "native") {
611
+ const nativeScript = `scripts/deploy/frontend-${selection.cloud}.sh`;
612
+ if (await removeIfPresent(join3(repositoryRoot, nativeScript)))
613
+ removed.push(nativeScript);
614
+ }
615
+ return removed;
616
+ }
617
+
618
+ // src/deployment-prompts.ts
619
+ import * as prompts2 from "@clack/prompts";
620
+ class DeploymentPromptCancelled extends Error {
621
+ }
622
+ async function unwrap(value) {
623
+ if (prompts2.isCancel(value)) {
624
+ prompts2.cancel("Deployment configuration was not changed.");
625
+ throw new DeploymentPromptCancelled;
626
+ }
627
+ return value;
628
+ }
629
+ async function collectDeploymentSelection(defaults = {}, nonInteractive = false) {
630
+ if (nonInteractive) {
631
+ if (!defaults.cloud || !defaults.apiDomain || !defaults.frontendDomain) {
632
+ throw new Error("Non-interactive deployment setup requires --cloud, --api-domain, and --frontend-domain.");
633
+ }
634
+ return {
635
+ cloud: defaults.cloud,
636
+ storageProvider: defaults.storageProvider ?? defaultStorageProviders[defaults.cloud],
637
+ frontendTarget: defaults.frontendTarget ?? "native",
638
+ environment: defaults.environment ?? "production",
639
+ region: defaults.region ?? defaultRegions[defaults.cloud],
640
+ apiDomain: defaults.apiDomain,
641
+ frontendDomain: defaults.frontendDomain,
642
+ haEnabled: defaults.haEnabled ?? false
643
+ };
644
+ }
645
+ const cloud = defaults.cloud ?? parseDeploymentCloud(await unwrap(await prompts2.select({
646
+ message: "Backend cloud",
647
+ options: [
648
+ { value: "aws", label: "AWS", hint: "ECS Fargate + RDS PostgreSQL" },
649
+ { value: "gcp", label: "Google Cloud", hint: "Cloud Run + Cloud SQL" },
650
+ { value: "azure", label: "Azure", hint: "Container Apps + Flexible Server" }
651
+ ]
652
+ })));
653
+ const frontendTarget = defaults.frontendTarget ?? parseFrontendTarget(await unwrap(await prompts2.select({
654
+ message: "Frontend host",
655
+ options: [
656
+ { value: "native", label: "Cloud native", hint: "Managed with the backend" },
657
+ { value: "vercel", label: "Vercel SPA", hint: "Static frontend only" },
658
+ {
659
+ value: "cloudflare",
660
+ label: "Cloudflare static assets",
661
+ hint: "No API Worker"
662
+ }
663
+ ]
664
+ })));
665
+ const storageProvider = defaults.storageProvider ?? (storageProvidersByCloud[cloud].length === 1 ? storageProvidersByCloud[cloud][0] ?? defaultStorageProviders[cloud] : parseStorageProvider(await unwrap(await prompts2.select({
666
+ message: "Object storage",
667
+ options: storageProvidersByCloud[cloud].map((provider) => ({
668
+ value: provider,
669
+ label: provider === "s3" ? "Amazon S3" : provider === "gcs" ? "Google Cloud Storage" : "Cloudflare R2",
670
+ hint: provider === defaultStorageProviders[cloud] ? "Cloud-native default" : ""
671
+ }))
672
+ }))));
673
+ const environment = defaults.environment ?? await unwrap(await prompts2.text({
674
+ message: "Deployment environment",
675
+ placeholder: "production",
676
+ defaultValue: "production",
677
+ validate: (value) => validateDeploymentEnvironment(value || "production")
678
+ }));
679
+ const region = defaults.region ?? await unwrap(await prompts2.text({
680
+ message: `${cloud.toUpperCase()} region`,
681
+ placeholder: defaultRegions[cloud],
682
+ defaultValue: defaultRegions[cloud],
683
+ validate: (value) => validateRegion(value || defaultRegions[cloud])
684
+ }));
685
+ const apiDomain = defaults.apiDomain ?? await unwrap(await prompts2.text({
686
+ message: "Production API domain",
687
+ placeholder: "api.example.com",
688
+ validate: (value) => validateDomain(value ?? "")
689
+ }));
690
+ const frontendDomain = defaults.frontendDomain ?? await unwrap(await prompts2.text({
691
+ message: "Production frontend domain",
692
+ placeholder: "app.example.com",
693
+ validate: (value) => validateDomain(value ?? "")
694
+ }));
695
+ const haEnabled = defaults.haEnabled ?? await unwrap(await prompts2.confirm({
696
+ message: "Enable the higher-availability profile?",
697
+ initialValue: false
698
+ }));
699
+ return {
700
+ cloud,
701
+ storageProvider,
702
+ frontendTarget,
703
+ environment,
704
+ region,
705
+ apiDomain,
706
+ frontendDomain,
707
+ haEnabled
708
+ };
709
+ }
710
+
711
+ // src/deployment-cli.ts
712
+ function takeValue2(args, index, flag) {
713
+ const value = args[index + 1];
714
+ if (!value || value.startsWith("--"))
715
+ throw new Error(`${flag} requires a value.`);
716
+ return value;
717
+ }
718
+ function parseDeploymentArguments(argv) {
719
+ const parsed = { yes: false };
720
+ for (let index = 0;index < argv.length; index += 1) {
721
+ const argument = argv[index];
722
+ if (argument === "--cloud") {
723
+ parsed.cloud = parseDeploymentCloud(takeValue2(argv, index++, argument));
724
+ } else if (argument === "--frontend") {
725
+ parsed.frontendTarget = parseFrontendTarget(takeValue2(argv, index++, argument));
726
+ } else if (argument === "--storage") {
727
+ parsed.storageProvider = parseStorageProvider(takeValue2(argv, index++, argument));
728
+ } else if (argument === "--environment") {
729
+ parsed.environment = takeValue2(argv, index++, argument);
730
+ } else if (argument === "--region") {
731
+ parsed.region = takeValue2(argv, index++, argument);
732
+ } else if (argument === "--api-domain") {
733
+ parsed.apiDomain = takeValue2(argv, index++, argument);
734
+ } else if (argument === "--frontend-domain") {
735
+ parsed.frontendDomain = takeValue2(argv, index++, argument);
736
+ } else if (argument === "--ha")
737
+ parsed.haEnabled = true;
738
+ else if (argument === "--no-ha")
739
+ parsed.haEnabled = false;
740
+ else if (argument === "--yes" || argument === "-y")
741
+ parsed.yes = true;
742
+ else
743
+ throw new Error(`Unknown deployment option: ${argument}`);
744
+ }
745
+ return parsed;
746
+ }
747
+ async function runDeploymentConfigure(argv, productName, repositoryRoot = process.cwd()) {
748
+ const parsed = parseDeploymentArguments(argv);
749
+ const root = resolve2(repositoryRoot);
750
+ const rootPackage = JSON.parse(await readFile3(`${root}/package.json`, "utf8"));
751
+ if (!rootPackage.name)
752
+ throw new Error("Root package name is required for deployment setup.");
753
+ prompts3.intro(`${productName} deployment setup`);
754
+ try {
755
+ const selection = await collectDeploymentSelection(parsed, parsed.yes);
756
+ const removed = await applyDeploymentSelection(root, rootPackage.name, selection);
757
+ prompts3.log.success(`${selection.cloud.toUpperCase()} + ${selection.storageProvider} + ${selection.frontendTarget} is locked for ${selection.environment}.`);
758
+ prompts3.log.info(`${removed.length} unused deployment paths removed.`);
759
+ prompts3.log.step("Next");
760
+ prompts3.log.message(`Review deployment/config.json
761
+ Configure the protected GitHub environment
762
+ Dispatch .github/workflows/deploy-cloud.yml`);
763
+ prompts3.outro("Deployment configuration is ready to commit.");
764
+ } catch (error) {
765
+ if (error instanceof DeploymentPromptCancelled)
766
+ return;
767
+ throw error;
768
+ }
769
+ }
770
+
771
+ // src/runtime.ts
772
+ import { spawn as spawn2 } from "node:child_process";
773
+ import { delimiter as delimiter2, join as join4, resolve as resolvePath2 } from "node:path";
774
+ import { fileURLToPath } from "node:url";
775
+ function runCaptured2(command, options) {
776
+ const [file, ...args] = command;
777
+ if (!file)
778
+ throw new Error("runCaptured requires a command");
779
+ return new Promise((resolve3, reject) => {
780
+ const child = spawn2(file, args, {
781
+ cwd: options.cwd,
782
+ env: options.env ?? process.env,
783
+ stdio: ["ignore", "pipe", "pipe"]
784
+ });
785
+ let stdout = "";
786
+ let stderr = "";
787
+ child.stdout.setEncoding("utf8");
788
+ child.stderr.setEncoding("utf8");
789
+ child.stdout.on("data", (chunk) => {
790
+ stdout += chunk;
791
+ });
792
+ child.stderr.on("data", (chunk) => {
793
+ stderr += chunk;
794
+ });
795
+ child.on("error", reject);
796
+ child.on("close", (code, signal) => {
797
+ resolve3({ exitCode: code ?? (signal ? 1 : 0), stdout, stderr });
798
+ });
799
+ });
800
+ }
801
+ function runInherited(command, options) {
802
+ const [file, ...args] = command;
803
+ if (!file)
804
+ throw new Error("runInherited requires a command");
805
+ return new Promise((resolve3, reject) => {
806
+ const child = spawn2(file, args, {
807
+ cwd: options.cwd,
808
+ env: options.env ?? process.env,
809
+ stdio: "inherit"
810
+ });
811
+ child.on("error", reject);
812
+ child.on("close", (code, signal) => {
813
+ resolve3(code ?? (signal === "SIGINT" ? 130 : 1));
814
+ });
815
+ });
816
+ }
817
+ function isEntrypoint(moduleUrl) {
818
+ const entry = process.argv[1];
819
+ if (!entry)
820
+ return false;
821
+ try {
822
+ return resolvePath2(fileURLToPath(moduleUrl)) === resolvePath2(entry);
823
+ } catch {
824
+ return false;
825
+ }
826
+ }
827
+
828
+ // src/deployment-prompts.ts
829
+ import * as prompts4 from "@clack/prompts";
830
+ class DeploymentPromptCancelled2 extends Error {
831
+ }
832
+ async function unwrap2(value) {
833
+ if (prompts4.isCancel(value)) {
834
+ prompts4.cancel("Deployment configuration was not changed.");
835
+ throw new DeploymentPromptCancelled2;
836
+ }
837
+ return value;
838
+ }
839
+ async function collectDeploymentSelection2(defaults = {}, nonInteractive = false) {
840
+ if (nonInteractive) {
841
+ if (!defaults.cloud || !defaults.apiDomain || !defaults.frontendDomain) {
842
+ throw new Error("Non-interactive deployment setup requires --cloud, --api-domain, and --frontend-domain.");
843
+ }
844
+ return {
845
+ cloud: defaults.cloud,
846
+ storageProvider: defaults.storageProvider ?? defaultStorageProviders[defaults.cloud],
847
+ frontendTarget: defaults.frontendTarget ?? "native",
848
+ environment: defaults.environment ?? "production",
849
+ region: defaults.region ?? defaultRegions[defaults.cloud],
850
+ apiDomain: defaults.apiDomain,
851
+ frontendDomain: defaults.frontendDomain,
852
+ haEnabled: defaults.haEnabled ?? false
853
+ };
854
+ }
855
+ const cloud = defaults.cloud ?? parseDeploymentCloud(await unwrap2(await prompts4.select({
856
+ message: "Backend cloud",
857
+ options: [
858
+ { value: "aws", label: "AWS", hint: "ECS Fargate + RDS PostgreSQL" },
859
+ { value: "gcp", label: "Google Cloud", hint: "Cloud Run + Cloud SQL" },
860
+ { value: "azure", label: "Azure", hint: "Container Apps + Flexible Server" }
861
+ ]
862
+ })));
863
+ const frontendTarget = defaults.frontendTarget ?? parseFrontendTarget(await unwrap2(await prompts4.select({
864
+ message: "Frontend host",
865
+ options: [
866
+ { value: "native", label: "Cloud native", hint: "Managed with the backend" },
867
+ { value: "vercel", label: "Vercel SPA", hint: "Static frontend only" },
868
+ {
869
+ value: "cloudflare",
870
+ label: "Cloudflare static assets",
871
+ hint: "No API Worker"
872
+ }
873
+ ]
874
+ })));
875
+ const storageProvider = defaults.storageProvider ?? (storageProvidersByCloud[cloud].length === 1 ? storageProvidersByCloud[cloud][0] ?? defaultStorageProviders[cloud] : parseStorageProvider(await unwrap2(await prompts4.select({
876
+ message: "Object storage",
877
+ options: storageProvidersByCloud[cloud].map((provider) => ({
878
+ value: provider,
879
+ label: provider === "s3" ? "Amazon S3" : provider === "gcs" ? "Google Cloud Storage" : "Cloudflare R2",
880
+ hint: provider === defaultStorageProviders[cloud] ? "Cloud-native default" : ""
881
+ }))
882
+ }))));
883
+ const environment = defaults.environment ?? await unwrap2(await prompts4.text({
884
+ message: "Deployment environment",
885
+ placeholder: "production",
886
+ defaultValue: "production",
887
+ validate: (value) => validateDeploymentEnvironment(value || "production")
888
+ }));
889
+ const region = defaults.region ?? await unwrap2(await prompts4.text({
890
+ message: `${cloud.toUpperCase()} region`,
891
+ placeholder: defaultRegions[cloud],
892
+ defaultValue: defaultRegions[cloud],
893
+ validate: (value) => validateRegion(value || defaultRegions[cloud])
894
+ }));
895
+ const apiDomain = defaults.apiDomain ?? await unwrap2(await prompts4.text({
896
+ message: "Production API domain",
897
+ placeholder: "api.example.com",
898
+ validate: (value) => validateDomain(value ?? "")
899
+ }));
900
+ const frontendDomain = defaults.frontendDomain ?? await unwrap2(await prompts4.text({
901
+ message: "Production frontend domain",
902
+ placeholder: "app.example.com",
903
+ validate: (value) => validateDomain(value ?? "")
904
+ }));
905
+ const haEnabled = defaults.haEnabled ?? await unwrap2(await prompts4.confirm({
906
+ message: "Enable the higher-availability profile?",
907
+ initialValue: false
908
+ }));
909
+ return {
910
+ cloud,
911
+ storageProvider,
912
+ frontendTarget,
913
+ environment,
914
+ region,
915
+ apiDomain,
916
+ frontendDomain,
917
+ haEnabled
918
+ };
919
+ }
920
+
921
+ // src/deployment.ts
922
+ import { mkdir as mkdir3, readFile as readFile4, rm as rm2, stat as stat3, writeFile as writeFile3 } from "node:fs/promises";
923
+ import { join as join5 } from "node:path";
924
+ var deploymentClouds2 = ["aws", "gcp", "azure"];
925
+ var frontendTargets2 = ["native", "vercel", "cloudflare"];
926
+ var storageProviders2 = ["r2", "s3", "gcs"];
927
+ var storageProvidersByCloud2 = {
928
+ aws: ["s3", "r2"],
929
+ gcp: ["gcs", "r2"],
930
+ azure: ["r2"]
931
+ };
932
+ function isDeploymentCloud2(value) {
933
+ return deploymentClouds2.some((cloud) => cloud === value);
934
+ }
935
+ function isFrontendTarget2(value) {
936
+ return frontendTargets2.some((target) => target === value);
937
+ }
938
+ function isStorageProvider2(value) {
939
+ return storageProviders2.some((provider) => provider === value);
940
+ }
941
+ function parseDeploymentCloud2(value) {
942
+ if (!isDeploymentCloud2(value))
943
+ throw new Error("Cloud must be aws, gcp, or azure.");
944
+ return value;
945
+ }
946
+ function parseFrontendTarget2(value) {
947
+ if (!isFrontendTarget2(value)) {
948
+ throw new Error("Frontend target must be native, vercel, or cloudflare.");
949
+ }
950
+ return value;
951
+ }
952
+ function parseStorageProvider2(value) {
953
+ if (!isStorageProvider2(value))
954
+ throw new Error("Storage provider must be r2, s3, or gcs.");
955
+ return value;
956
+ }
957
+ function validateDeploymentEnvironment2(value) {
958
+ if (!/^[a-z][a-z0-9-]{1,30}$/.test(value)) {
959
+ return "Environment must use 2-31 lowercase letters, numbers, or hyphens.";
960
+ }
961
+ return;
962
+ }
963
+ function validateRegion2(value) {
964
+ if (!/^[a-z0-9][a-z0-9-]{1,31}$/.test(value)) {
965
+ return "Region must use 2-32 lowercase letters, numbers, or hyphens.";
966
+ }
967
+ return;
968
+ }
969
+ function validateDomain2(value) {
970
+ if (value.length > 80 || !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(value)) {
971
+ return "Enter a lowercase hostname such as api.example.com, without a protocol or path.";
972
+ }
973
+ return;
974
+ }
975
+ function validateDeploymentSelection2(selection) {
976
+ if (!storageProvidersByCloud2[selection.cloud].includes(selection.storageProvider)) {
977
+ throw new Error(`${selection.storageProvider} storage is not supported for ${selection.cloud} deployments.`);
978
+ }
979
+ const environmentError = validateDeploymentEnvironment2(selection.environment);
980
+ if (environmentError)
981
+ throw new Error(environmentError);
982
+ const regionError = validateRegion2(selection.region);
983
+ if (regionError)
984
+ throw new Error(regionError);
985
+ const apiError = validateDomain2(selection.apiDomain);
986
+ if (apiError)
987
+ throw new Error(`API domain: ${apiError}`);
988
+ const frontendError = validateDomain2(selection.frontendDomain);
989
+ if (frontendError)
990
+ throw new Error(`Frontend domain: ${frontendError}`);
991
+ if (selection.apiDomain === selection.frontendDomain) {
992
+ throw new Error("API and frontend domains must be different hostnames.");
993
+ }
994
+ const apiSite = selection.apiDomain.split(".").slice(1).join(".");
995
+ const frontendSite = selection.frontendDomain.split(".").slice(1).join(".");
996
+ if (apiSite !== frontendSite) {
997
+ throw new Error("API and frontend domains must be sibling hosts under the same site.");
998
+ }
999
+ }
1000
+ function createDeploymentConfig2(projectName, selection) {
1001
+ validateDeploymentSelection2(selection);
1002
+ return {
1003
+ schema_version: 2,
1004
+ cloud: selection.cloud,
1005
+ storage_provider: selection.storageProvider,
1006
+ project_name: projectName,
1007
+ environment: selection.environment,
1008
+ region: selection.region,
1009
+ frontend_target: selection.frontendTarget,
1010
+ external_frontend_origin: selection.frontendTarget === "native" ? null : `https://${selection.frontendDomain}`,
1011
+ api_domain: selection.apiDomain,
1012
+ frontend_domain: selection.frontendDomain,
1013
+ ha_enabled: selection.haEnabled
1014
+ };
1015
+ }
1016
+ function deploymentReadme2(config) {
1017
+ const identity = config.cloud === "aws" ? "`AWS_ROLE_ARN` secret" : config.cloud === "gcp" ? "`GCP_WORKLOAD_IDENTITY_PROVIDER` and `GCP_SERVICE_ACCOUNT` secrets" : "`AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, and `AZURE_SUBSCRIPTION_ID` secrets";
1018
+ const providerInputs = config.cloud === "aws" ? "Route 53 zone or certificate identifiers and runtime Secrets Manager ARNs" : config.cloud === "gcp" ? "GCP project, Cloud DNS zone, and runtime Secret Manager names" : "Azure DNS zone ID and runtime Key Vault secret identifiers";
1019
+ const storageSetup = config.storage_provider === "r2" ? `Create private staging and public Cloudflare R2 buckets, then store the required storage
1020
+ variables in the runtime secret container.` : `${config.storage_provider.toUpperCase()} application buckets, lifecycle policy, CORS, public access, and workload permissions are
1021
+ managed by the selected OpenTofu root.`;
1022
+ return `# Deployment setup
1023
+
1024
+ The deployment target is locked:
1025
+
1026
+ - backend: **${config.cloud.toUpperCase()}**;
1027
+ - storage: **${config.storage_provider}**;
1028
+ - frontend: **${config.frontend_target}**;
1029
+ - environment: **${config.environment}**.
1030
+
1031
+ ## Protected GitHub environment
1032
+
1033
+ Create a GitHub environment named \`${config.environment}\` and configure:
1034
+
1035
+ - ${identity};
1036
+ - a \`BACKEND_HCL\` secret containing the selected remote-state backend configuration;
1037
+ - a \`TFVARS_JSON\` secret containing only:
1038
+ - ${providerInputs};
1039
+ - \`VITE_TERMS_URL\` and \`VITE_PRIVACY_URL\`.
1040
+
1041
+ ## Cloud preparation
1042
+
1043
+ 1. Run \`infra/bootstrap/${config.cloud}\` once with an administrator identity.
1044
+ 2. Configure OIDC trust for this repository and the \`${config.environment}\` GitHub environment.
1045
+ 3. Create the DNS zone or supply the existing zone identifier required by the selected root.
1046
+ 4. ${storageSetup}
1047
+ 5. Create application secret entries for Better Auth and enabled email, billing, monitoring, OAuth,
1048
+ and credential-based providers.
1049
+ 6. Put only secret identifiers in \`TFVARS_JSON\`; write secret values directly through the cloud
1050
+ provider.
1051
+ 7. Dispatch \`.github/workflows/deploy-cloud.yml\` with:
1052
+ - cloud: \`${config.cloud}\`;
1053
+ - environment: \`${config.environment}\`;
1054
+ - frontend: \`${config.frontend_target}\`.
1055
+
1056
+ The workflow creates the registry first, pushes an immutable image, provisions the API with public
1057
+ traffic disabled, runs migrations, and admits traffic only after migration succeeds.
1058
+
1059
+ The provider lock is deliberate. Restore the deployment directories from a fresh licensed release
1060
+ before changing cloud providers.
1061
+ `;
1062
+ }
1063
+ async function removeIfPresent2(target) {
1064
+ if (!await stat3(target).catch(() => {
1065
+ return;
1066
+ }))
1067
+ return false;
1068
+ await rm2(target, { recursive: true, force: true });
1069
+ return true;
1070
+ }
1071
+ async function applyDeploymentSelection2(repositoryRoot, projectName, selection) {
1072
+ const config = createDeploymentConfig2(projectName, selection);
1073
+ const configPath = join5(repositoryRoot, "deployment/config.json");
1074
+ if (await stat3(configPath).catch(() => {
1075
+ return;
1076
+ })) {
1077
+ throw new Error("Deployment is already locked. Restore a fresh release to change providers.");
1078
+ }
1079
+ await mkdir3(join5(repositoryRoot, "deployment"), { recursive: true });
1080
+ await writeFile3(configPath, `${JSON.stringify(config, null, 2)}
1081
+ `);
1082
+ await writeFile3(join5(repositoryRoot, "deployment/README.md"), deploymentReadme2(config));
1083
+ const removed = [];
1084
+ for (const cloud of deploymentClouds2) {
1085
+ if (cloud === selection.cloud)
1086
+ continue;
1087
+ for (const relativePath of [
1088
+ `infra/bootstrap/${cloud}`,
1089
+ `infra/modules/${cloud}`,
1090
+ `infra/roots/${cloud}`,
1091
+ `scripts/deploy/release-${cloud}.sh`,
1092
+ `scripts/deploy/frontend-${cloud}.sh`
1093
+ ]) {
1094
+ if (await removeIfPresent2(join5(repositoryRoot, relativePath)))
1095
+ removed.push(relativePath);
1096
+ }
1097
+ }
1098
+ const adapters = [
1099
+ { target: "vercel", path: "apps/web/vercel.json" },
1100
+ { target: "cloudflare", path: "apps/web/wrangler.toml" },
1101
+ {
1102
+ target: "azure-native",
1103
+ path: "apps/web/public/staticwebapp.config.json"
1104
+ }
1105
+ ];
1106
+ const selectedAdapter = selection.frontendTarget === "native" ? selection.cloud === "azure" ? "azure-native" : undefined : selection.frontendTarget;
1107
+ for (const adapter of adapters) {
1108
+ if (adapter.target === selectedAdapter)
1109
+ continue;
1110
+ if (await removeIfPresent2(join5(repositoryRoot, adapter.path)))
1111
+ removed.push(adapter.path);
1112
+ }
1113
+ if (selection.frontendTarget !== "native") {
1114
+ const nativeScript = `scripts/deploy/frontend-${selection.cloud}.sh`;
1115
+ if (await removeIfPresent2(join5(repositoryRoot, nativeScript)))
1116
+ removed.push(nativeScript);
1117
+ }
1118
+ return removed;
1119
+ }
1120
+
1121
+ // src/scaffold.ts
1122
+ import { execFile } from "node:child_process";
1123
+ import { createHash } from "node:crypto";
1124
+ import { cp, lstat, mkdir as mkdir4, mkdtemp, readFile as readFile5, readdir, rm as rm3, stat as stat4, writeFile as writeFile4 } from "node:fs/promises";
1125
+ import { tmpdir } from "node:os";
1126
+ import { basename, join as join6, relative, resolve as resolve3 } from "node:path";
1127
+ import { promisify } from "node:util";
1128
+
1129
+ // src/core.ts
1130
+ import { randomBytes } from "node:crypto";
1131
+ import { extname } from "node:path";
1132
+ var BRAND_MANIFEST = [
1133
+ { path: "apps/web/src/configs/config.ts", occurrences: 2 },
1134
+ { path: "apps/web/index.html", occurrences: 4 },
1135
+ { path: "apps/web/public/favicon/site.webmanifest", occurrences: 2 },
1136
+ { path: "apps/web/public/offline.html", occurrences: 2 },
1137
+ { path: "apps/docs/lib/shared.ts", occurrences: 1 },
1138
+ { path: "apps/server/.env.example", occurrences: 1 },
1139
+ { path: "apps/server/src/config/env.ts", occurrences: 2 },
1140
+ { path: "apps/server/src/config/env.test.ts", occurrences: 1 },
1141
+ { path: "packages/email/src/billing-payment-failed.tsx", occurrences: 2 },
1142
+ { path: "packages/email/src/login-email.tsx", occurrences: 3 },
1143
+ { path: "packages/email/src/verify-email.tsx", occurrences: 1 },
1144
+ { path: "packages/email/src/reset-password.tsx", occurrences: 1 },
1145
+ { path: "packages/email/src/security-alert.tsx", occurrences: 1 },
1146
+ { path: "packages/email/src/components/EmailSignature.tsx", occurrences: 1 },
1147
+ { path: "packages/email/src/components/EmailLayout.tsx", occurrences: 1 },
1148
+ { path: "packages/email/src/components/EmailCopyright.tsx", occurrences: 1 },
1149
+ { path: "packages/email/src/welcome-email.tsx", occurrences: 4 },
1150
+ { path: "packages/email/src/billing-payment-successful.tsx", occurrences: 1 },
1151
+ { path: "packages/email/src/billing-subscription-created.tsx", occurrences: 2 },
1152
+ { path: "packages/email/src/billing-subscription-canceled.tsx", occurrences: 2 },
1153
+ { path: "apps/server/email-templates/billing-payment-failed.html", occurrences: 6 },
1154
+ { path: "apps/server/email-templates/billing-payment-successful.html", occurrences: 5 },
1155
+ { path: "apps/server/email-templates/billing-subscription-canceled.html", occurrences: 6 },
1156
+ { path: "apps/server/email-templates/billing-subscription-created.html", occurrences: 6 },
1157
+ { path: "apps/server/email-templates/login-email.html", occurrences: 7 },
1158
+ { path: "apps/server/email-templates/security-alert.html", occurrences: 4 },
1159
+ { path: "apps/server/email-templates/verify-email.html", occurrences: 5 },
1160
+ { path: "apps/server/email-templates/reset-password.html", occurrences: 5 },
1161
+ { path: "apps/server/email-templates/welcome-email.html", occurrences: 8 }
1162
+ ];
1163
+ function validateAppName2(value) {
1164
+ if (!value)
1165
+ return "App name is required.";
1166
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)) {
1167
+ return "App name must be kebab-case (lowercase letters, numbers, and single hyphens).";
1168
+ }
1169
+ return;
1170
+ }
1171
+ function normalizeScope2(value) {
1172
+ const scope = value.startsWith("@") ? value : `@${value}`;
1173
+ if (/\s/.test(scope)) {
1174
+ throw new Error("Use hyphens instead of spaces, for example test-package.");
1175
+ }
1176
+ if (!/^@[a-z0-9][a-z0-9._-]*$/.test(scope)) {
1177
+ throw new Error("Use lowercase letters and numbers, with optional periods, underscores, or hyphens.");
1178
+ }
1179
+ return scope;
1180
+ }
1181
+ function validateBrandName2(value) {
1182
+ if (!value.trim())
1183
+ return "Brand name cannot be empty.";
1184
+ if (/\r|\n/.test(value))
1185
+ return "Brand name must fit on one line.";
1186
+ if (value !== value.trim())
1187
+ return "Brand name cannot start or end with whitespace.";
1188
+ if (value.length > 48)
1189
+ return "Brand name must be 48 characters or less.";
1190
+ if (!/^[\p{L}\p{N}](?:[\p{L}\p{N} ._-]*[\p{L}\p{N}])?$/u.test(value)) {
1191
+ return "Brand name may contain letters, numbers, spaces, periods, underscores, and hyphens.";
1192
+ }
1193
+ return;
1194
+ }
1195
+ function replaceLiteral(content, search, replacement) {
1196
+ if (search === replacement || !content.includes(search))
1197
+ return { content, replacements: 0 };
1198
+ const parts = content.split(search);
1199
+ return { content: parts.join(replacement), replacements: parts.length - 1 };
1200
+ }
1201
+ function rewriteScopeContent(content, scope, originalScope = DEFAULT_SCOPE) {
1202
+ return replaceLiteral(content, `${originalScope}/`, `${normalizeScope2(scope)}/`);
1203
+ }
1204
+ function rewriteBrandContent(content, brandName) {
1205
+ const error = validateBrandName2(brandName);
1206
+ if (error)
1207
+ throw new Error(error);
1208
+ const escapedSourceBrand = SOURCE_BRAND_NAME.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1209
+ const sourceBrandPattern = new RegExp(`\\b${escapedSourceBrand}\\b`, "g");
1210
+ const matches = content.match(sourceBrandPattern);
1211
+ if (brandName === SOURCE_BRAND_NAME || !matches)
1212
+ return { content, replacements: 0 };
1213
+ return {
1214
+ content: content.replace(sourceBrandPattern, () => brandName),
1215
+ replacements: matches.length
1216
+ };
1217
+ }
1218
+ function formatRewrittenPackageJson(content) {
1219
+ const parsed = JSON.parse(content);
1220
+ for (const key of [
1221
+ "dependencies",
1222
+ "devDependencies",
1223
+ "optionalDependencies",
1224
+ "peerDependencies"
1225
+ ]) {
1226
+ const values = parsed[key];
1227
+ if (values && typeof values === "object" && !Array.isArray(values)) {
1228
+ parsed[key] = Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right)));
1229
+ }
1230
+ }
1231
+ return `${JSON.stringify(parsed, null, 2)}
1232
+ `;
1233
+ }
1234
+ function wrapLongNamedImports(content, width = 100) {
1235
+ return content.split(`
1236
+ `).map((line) => {
1237
+ if (line.length <= width)
1238
+ return line;
1239
+ const match = line.match(/^(\s*)(import(?:\s+type)?\s+)\{([^}]+)\}(\s+from\s+.+)$/);
1240
+ if (!match)
1241
+ return line;
1242
+ const [, indentation = "", prefix = "", names = "", suffix = ""] = match;
1243
+ const members = names.split(",").map((name) => name.trim());
1244
+ return [
1245
+ `${indentation}${prefix}{`,
1246
+ ...members.map((name) => `${indentation} ${name},`),
1247
+ `${indentation}}${suffix}`
1248
+ ].join(`
1249
+ `);
1250
+ }).join(`
1251
+ `);
1252
+ }
1253
+ function wrapBrandedJsxText(content, brandName, width = 100) {
1254
+ const lines = content.split(`
1255
+ `);
1256
+ const output = [];
1257
+ for (let index = 0;index < lines.length; ) {
1258
+ const line = lines[index] ?? "";
1259
+ const text3 = line.trim();
1260
+ if (!text3 || text3.startsWith("<") || text3.startsWith("{") || text3.startsWith("//") || text3.includes("<") || text3.includes(">")) {
1261
+ output.push(line);
1262
+ index += 1;
1263
+ continue;
1264
+ }
1265
+ const paragraph = [];
1266
+ const originalParagraph = [];
1267
+ const indentation = line.slice(0, line.length - line.trimStart().length);
1268
+ while (index < lines.length) {
1269
+ const candidate = lines[index] ?? "";
1270
+ const candidateText = candidate.trim();
1271
+ if (!candidateText || candidateText.startsWith("<") || candidateText.startsWith("{"))
1272
+ break;
1273
+ paragraph.push(candidateText);
1274
+ originalParagraph.push(candidate);
1275
+ index += 1;
1276
+ }
1277
+ const joined = paragraph.join(" ");
1278
+ if (!joined.includes(brandName)) {
1279
+ output.push(...originalParagraph);
1280
+ continue;
1281
+ }
1282
+ let current = indentation;
1283
+ for (const word of joined.split(/\s+/)) {
1284
+ const candidate = current === indentation ? `${indentation}${word}` : `${current} ${word}`;
1285
+ if (candidate.length > width && current !== indentation) {
1286
+ output.push(current);
1287
+ current = `${indentation}${word}`;
1288
+ } else
1289
+ current = candidate;
1290
+ }
1291
+ output.push(current);
1292
+ }
1293
+ return output.join(`
1294
+ `);
1295
+ }
1296
+ function rewriteRootPackageName(content, appName) {
1297
+ const error = validateAppName2(appName);
1298
+ if (error)
1299
+ throw new Error(error);
1300
+ const parsed = JSON.parse(content);
1301
+ if (parsed.name === appName)
1302
+ return { content, replacements: 0 };
1303
+ parsed.name = appName;
1304
+ return { content: `${JSON.stringify(parsed, null, 2)}
1305
+ `, replacements: 1 };
1306
+ }
1307
+ function replaceMarkdownSection(content, heading, replacement) {
1308
+ const marker = `## ${heading}
1309
+ `;
1310
+ const start = content.indexOf(marker);
1311
+ if (start === -1)
1312
+ throw new Error(`Agent instructions are missing the "${heading}" section.`);
1313
+ const end = content.indexOf(`
1314
+ ## `, start + marker.length);
1315
+ if (end === -1)
1316
+ throw new Error(`Agent instructions cannot delimit the "${heading}" section.`);
1317
+ const sections = [content.slice(0, start).trim(), replacement.trim(), content.slice(end).trim()];
1318
+ return `${sections.filter(Boolean).join(`
1319
+
1320
+ `)}
1321
+ `;
1322
+ }
1323
+ function removeParagraphContaining(content, fragment) {
1324
+ const paragraphs = content.split(`
1325
+
1326
+ `);
1327
+ const index = paragraphs.findIndex((paragraph) => paragraph.includes(fragment));
1328
+ if (index === -1)
1329
+ throw new Error(`Agent instructions are missing expected text: ${fragment}`);
1330
+ paragraphs.splice(index, 1);
1331
+ return paragraphs.join(`
1332
+
1333
+ `);
1334
+ }
1335
+ function rewriteBuyerAgentInstructions(content) {
1336
+ if (content.includes("Audit and remediate the complete buyer-owned SaaS application") && !/landing\/|seller-surface|studio-owned/i.test(content)) {
1337
+ return content;
1338
+ }
1339
+ let rewritten = removeParagraphContaining(content, "`landing/` is the source repository's commercial sales application");
1340
+ rewritten = removeParagraphContaining(rewritten, "The root pnpm workspace includes only `apps/*` and `packages/*`.");
1341
+ rewritten = replaceMarkdownSection(rewritten, "Product security audit scope", `## Product security audit scope
1342
+
1343
+ Audit and remediate the complete buyer-owned SaaS application: \`apps/\`, \`packages/\`, buyer
1344
+ documentation, and the configuration and deployment files that ship with it. \`agent-tooling/\` and
1345
+ \`packages/agent-context\` are optional, buyer-distributed development tooling; review them when agent
1346
+ behavior or distribution changes, but do not treat them as application runtime code. Do not vendor
1347
+ machine-local installed copies, compatibility symlinks, client configuration, or skill lock records
1348
+ into this repository.
1349
+ `);
1350
+ rewritten = replaceMarkdownSection(rewritten, "Landing boundary", "");
1351
+ rewritten = rewritten.split(`
1352
+ `).filter((line) => !line.includes("| `landing`")).join(`
1353
+ `).replace("real env files, `landing/`, or\n `bun.lock`", "real env files, or `bun.lock`");
1354
+ if (/landing\/|seller-surface|studio-owned/i.test(rewritten)) {
1355
+ throw new Error("Buyer agent instructions still contain seller-only guidance.");
1356
+ }
1357
+ return rewritten;
1358
+ }
1359
+ function rewriteBuyerClaudeInstructions(content) {
1360
+ if (!/landing\/|studio-owned/i.test(content))
1361
+ return content;
1362
+ const rewritten = removeParagraphContaining(content, "`landing/AGENTS.md`");
1363
+ if (/landing\/|studio-owned/i.test(rewritten)) {
1364
+ throw new Error("Buyer Claude instructions still contain seller-only guidance.");
1365
+ }
1366
+ return rewritten;
1367
+ }
1368
+ function rewriteBuyerSetupInstructions(content) {
1369
+ if (!content.includes("## Create a product repository"))
1370
+ return content;
1371
+ const rewritten = replaceMarkdownSection(content, "Create a product repository", "");
1372
+ const escapedCliName = CLI_NAME2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1373
+ if (new RegExp(`pnpm dlx ${escapedCliName}|bunx ${escapedCliName}|${escapedCliName} repository`, "i").test(rewritten)) {
1374
+ throw new Error("Buyer setup instructions still contain source-delivery guidance.");
1375
+ }
1376
+ return rewritten;
1377
+ }
1378
+ function rewriteBuyerReleaseInstructions(content) {
1379
+ if (!content.includes("## npm bootstrapper"))
1380
+ return content;
1381
+ const rewritten = replaceMarkdownSection(content, "npm bootstrapper", "");
1382
+ const escapedCliName = CLI_NAME2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1383
+ if (new RegExp(`${escapedCliName}@\\d|public \`${escapedCliName}\` npm package`, "i").test(rewritten)) {
1384
+ throw new Error("Buyer release instructions still contain vendor bootstrapper guidance.");
1385
+ }
1386
+ return rewritten;
1387
+ }
1388
+ function generateServerEnv(example, secret = randomBytes(32).toString("hex"), databasePort = 5432) {
1389
+ if (!/^[a-f0-9]{64}$/.test(secret)) {
1390
+ throw new Error("Better Auth secret must be 32 bytes encoded as hex.");
1391
+ }
1392
+ if (!/^DATABASE_URL=.*$/m.test(example) || !/^BETTER_AUTH_SECRET=.*$/m.test(example)) {
1393
+ throw new Error("Server .env.example must contain DATABASE_URL and BETTER_AUTH_SECRET entries.");
1394
+ }
1395
+ if (!Number.isInteger(databasePort) || databasePort < 1 || databasePort > 65535) {
1396
+ throw new Error("Database port must be an integer between 1 and 65535.");
1397
+ }
1398
+ const databaseUrl = `postgresql://app:app@localhost:${databasePort}/app?sslmode=disable`;
1399
+ return example.replace(/^DATABASE_URL=.*$/m, `DATABASE_URL=${databaseUrl}`).replace(/^BETTER_AUTH_SECRET=.*$/m, `BETTER_AUTH_SECRET=${secret}`);
1400
+ }
1401
+ var SCOPE_EXTENSIONS2 = new Set([".json", ".jsonc", ".md", ".ts", ".tsx", ".yaml", ".yml"]);
1402
+ function isScopeRewriteFile(path) {
1403
+ return SCOPE_EXTENSIONS2.has(extname(path)) || path.replaceAll("\\", "/").endsWith("/.vite-hooks/pre-commit");
1404
+ }
1405
+ function isTemplatePathExcluded(relativePath) {
1406
+ const normalized = relativePath.replaceAll("\\", "/");
1407
+ const parts = normalized.split("/").filter(Boolean);
1408
+ const basename = parts.at(-1) ?? "";
1409
+ if (parts.includes(".git") || parts.includes(".worktrees") || parts.includes("node_modules"))
1410
+ return true;
1411
+ if (parts.includes(".terraform"))
1412
+ return true;
1413
+ if (parts.includes(".impeccable"))
1414
+ return true;
1415
+ if (parts.includes(".agent-evals") || parts.includes(".agents") || parts.includes(".claude"))
1416
+ return true;
1417
+ if (normalized === "skills-lock.json")
1418
+ return true;
1419
+ if (normalized === "operations" || normalized.startsWith("operations/"))
1420
+ return true;
1421
+ if (normalized === "apps/worker" || normalized.startsWith("apps/worker/"))
1422
+ return true;
1423
+ if (parts.includes("dist") || parts.includes("out"))
1424
+ return true;
1425
+ if (normalized === "landing" || normalized.startsWith("landing/"))
1426
+ return true;
1427
+ if (normalized === "bun.lock")
1428
+ return true;
1429
+ if (normalized === ".vercelignore")
1430
+ return true;
1431
+ if (basename.endsWith(".tfvars") || basename === "backend.hcl")
1432
+ return true;
1433
+ if (basename.includes(".tfstate") || /^gha-creds-.*\.json$/.test(basename))
1434
+ return true;
1435
+ if (normalized === "PLAN.md" || normalized === "comments.md")
1436
+ return true;
1437
+ if (normalized === "packages/create-app/src/cli-brand-contract.test.ts")
1438
+ return true;
1439
+ if (normalized === "packages/create-app/LICENSE")
1440
+ return true;
1441
+ if (normalized === "packages/create-app/README.md")
1442
+ return true;
1443
+ if (normalized === "docs/phase2" || normalized.startsWith("docs/phase2/"))
1444
+ return true;
1445
+ if (/^docs\/evaluations\/(?:tec|pro2)-[^/]+\.md$/.test(normalized))
1446
+ return true;
1447
+ if (normalized === "apps/server/server")
1448
+ return true;
1449
+ if (basename.endsWith(".tsbuildinfo"))
1450
+ return true;
1451
+ if (basename === ".env" || basename.startsWith(".env.") && basename !== ".env.example")
1452
+ return true;
1453
+ return false;
1454
+ }
1455
+
1456
+ // src/scaffold.ts
1457
+ var execFileAsync = promisify(execFile);
1458
+ async function assertEmptyTarget(targetDirectory) {
1459
+ try {
1460
+ const targetStat = await stat4(targetDirectory);
1461
+ if (!targetStat.isDirectory())
1462
+ throw new Error(`Target exists and is not a directory: ${targetDirectory}`);
1463
+ if ((await readdir(targetDirectory)).length > 0) {
1464
+ throw new Error(`Refusing to overwrite non-empty directory: ${targetDirectory}`);
1465
+ }
1466
+ } catch (error) {
1467
+ if (error.code !== "ENOENT")
1468
+ throw error;
1469
+ }
1470
+ }
1471
+ async function copyLocalTemplate(source, target) {
1472
+ const sourceDirectory = resolve3(source);
1473
+ const sourceStat = await lstat(sourceDirectory).catch(() => {
1474
+ return;
1475
+ });
1476
+ if (!sourceStat?.isDirectory())
1477
+ throw new Error(`Template path is not a directory: ${sourceDirectory}`);
1478
+ await assertNoSymbolicLinks(sourceDirectory, sourceDirectory, true);
1479
+ await mkdir4(target, { recursive: true });
1480
+ await cp(sourceDirectory, target, {
1481
+ recursive: true,
1482
+ filter: (path) => {
1483
+ const relativePath = relative(sourceDirectory, path);
1484
+ return relativePath === "" || !isTemplatePathExcluded(relativePath);
1485
+ }
1486
+ });
1487
+ await assertNoSymbolicLinks(target);
1488
+ }
1489
+ async function assertNoSymbolicLinks(directory, root = directory, skipExcludedPaths = false) {
1490
+ const entries = await readdir(directory, { withFileTypes: true });
1491
+ for (const entry of entries) {
1492
+ const path = join6(directory, entry.name);
1493
+ const relativePath = relative(root, path);
1494
+ if (skipExcludedPaths && isTemplatePathExcluded(relativePath))
1495
+ continue;
1496
+ if (entry.isSymbolicLink()) {
1497
+ throw new Error(`Template contains a symbolic link, which is not supported: ${path}`);
1498
+ }
1499
+ if (entry.isDirectory())
1500
+ await assertNoSymbolicLinks(path, root, skipExcludedPaths);
1501
+ }
1502
+ }
1503
+ function verifyReleaseMetadata(checksum, manifest, artifactName, digest, templateRef) {
1504
+ const checksumMatch = checksum.trim().match(/^([a-f0-9]{64})\s+\*?([^\s]+)$/i);
1505
+ if (!checksumMatch || checksumMatch[2] !== artifactName) {
1506
+ throw new Error(`${CLI_NAME2} release checksum metadata is malformed.`);
1507
+ }
1508
+ if (checksumMatch[1]?.toLowerCase() !== digest) {
1509
+ throw new Error(`${CLI_NAME2} release checksum verification failed.`);
1510
+ }
1511
+ if (String(manifest.schemaVersion) !== "1" || manifest.tag !== templateRef || manifest.artifact.file !== artifactName || manifest.artifact.sha256.toLowerCase() !== digest) {
1512
+ throw new Error(`${CLI_NAME2} release manifest does not match the downloaded source artifact.`);
1513
+ }
1514
+ }
1515
+ async function downloadReleaseAsset(templateRef, fileName, directory) {
1516
+ try {
1517
+ await execFileAsync("gh", [
1518
+ "release",
1519
+ "download",
1520
+ templateRef,
1521
+ "--repo",
1522
+ TEMPLATE_REPOSITORY,
1523
+ "--pattern",
1524
+ fileName,
1525
+ "--dir",
1526
+ directory
1527
+ ]);
1528
+ } catch {
1529
+ throw new Error(`Unable to download ${templateRef}. Confirm that GitHub CLI is installed, run ` + `\`gh auth login\`, and accept your ${CLI_NAME2} repository invitation.`);
1530
+ }
1531
+ }
1532
+ async function downloadGitHubTemplate(target, templateRef) {
1533
+ if (!/^v\d+\.\d+\.\d+(?:-rc\.\d+)?$/.test(templateRef)) {
1534
+ throw new Error(`${CLI_NAME2} template reference must be an immutable release tag: ${templateRef}`);
1535
+ }
1536
+ const temporaryDirectory = await mkdtemp(join6(tmpdir(), `${CLI_NAME2}-`));
1537
+ const artifactName = `foundation-source-${templateRef}.tar.gz`;
1538
+ const checksumName = `${artifactName}.sha256`;
1539
+ const manifestName = `foundation-source-${templateRef}.manifest.json`;
1540
+ const archivePath = join6(temporaryDirectory, artifactName);
1541
+ const extractedPath = join6(temporaryDirectory, "template");
1542
+ try {
1543
+ await execFileAsync("gh", ["auth", "status", "--hostname", "github.com"]).catch(() => {
1544
+ throw new Error(`${CLI_NAME2} source access requires GitHub CLI authentication. Run \`gh auth login\` first.`);
1545
+ });
1546
+ await downloadReleaseAsset(templateRef, artifactName, temporaryDirectory);
1547
+ await downloadReleaseAsset(templateRef, checksumName, temporaryDirectory);
1548
+ await downloadReleaseAsset(templateRef, manifestName, temporaryDirectory);
1549
+ const archive = await readFile5(archivePath);
1550
+ const digest = createHash("sha256").update(archive).digest("hex");
1551
+ const checksum = await readFile5(join6(temporaryDirectory, checksumName), "utf8");
1552
+ const manifest = JSON.parse(await readFile5(join6(temporaryDirectory, manifestName), "utf8"));
1553
+ verifyReleaseMetadata(checksum, manifest, artifactName, digest, templateRef);
1554
+ await mkdir4(extractedPath);
1555
+ await execFileAsync("tar", ["-xzf", archivePath, "-C", extractedPath, "--strip-components=1"]);
1556
+ await copyLocalTemplate(extractedPath, target);
1557
+ } finally {
1558
+ await rm3(temporaryDirectory, { recursive: true, force: true });
1559
+ }
1560
+ }
1561
+ async function walk(directory) {
1562
+ const entries = await readdir(directory, { withFileTypes: true });
1563
+ const paths = [];
1564
+ for (const entry of entries) {
1565
+ const path = join6(directory, entry.name);
1566
+ if (entry.isSymbolicLink()) {
1567
+ throw new Error(`Generated template contains a symbolic link: ${path}`);
1568
+ }
1569
+ if (entry.isDirectory())
1570
+ paths.push(...await walk(path));
1571
+ else if (entry.isFile())
1572
+ paths.push(path);
1573
+ }
1574
+ return paths;
1575
+ }
1576
+ async function rewriteFile(path, rewrite) {
1577
+ const content = await readFile5(path, "utf8");
1578
+ const result = rewrite(content);
1579
+ if (result.replacements > 0)
1580
+ await writeFile4(path, result.content);
1581
+ return result.replacements;
1582
+ }
1583
+ async function applyScopeRewrite(target, scope) {
1584
+ const report = { files: 0, replacements: 0 };
1585
+ for (const path of await walk(target)) {
1586
+ if (!isScopeRewriteFile(path))
1587
+ continue;
1588
+ const replacements = await rewriteFile(path, (content) => {
1589
+ const result = rewriteScopeContent(content, scope);
1590
+ if (result.replacements > 0 && path.endsWith("/package.json")) {
1591
+ result.content = formatRewrittenPackageJson(result.content);
1592
+ }
1593
+ if (result.replacements > 0 && (path.endsWith(".ts") || path.endsWith(".tsx"))) {
1594
+ result.content = wrapLongNamedImports(result.content);
1595
+ }
1596
+ if (result.replacements > 0 && path.endsWith("/apps/docs/README.md")) {
1597
+ result.content = result.content.replace("root workspace filter: `pnpm", "root workspace filter:\n`pnpm").replace("<command>`.\nThe product deployment", "<command>`. The product deployment").replace("not deploy this Next.js application. See `docs/docs-site.md`\nfor publication", "not deploy this\nNext.js application. See `docs/docs-site.md` for publication");
1598
+ }
1599
+ return result;
1600
+ });
1601
+ if (replacements > 0)
1602
+ report.files += 1;
1603
+ report.replacements += replacements;
1604
+ }
1605
+ return report;
1606
+ }
1607
+ async function applyBrandRewrite(target, brandName) {
1608
+ const report = { files: 0, replacements: 0 };
1609
+ for (const entry of BRAND_MANIFEST) {
1610
+ const path = join6(target, entry.path);
1611
+ const replacements = await rewriteFile(path, (content) => {
1612
+ const result = rewriteBrandContent(content, brandName);
1613
+ if (result.replacements > 0 && path.endsWith(".tsx")) {
1614
+ result.content = wrapBrandedJsxText(result.content, brandName);
1615
+ }
1616
+ return result;
1617
+ });
1618
+ if (replacements > 0)
1619
+ report.files += 1;
1620
+ report.replacements += replacements;
1621
+ }
1622
+ return report;
1623
+ }
1624
+ async function generateEnvironmentFiles(target, databasePort = 5432) {
1625
+ const serverExample = await readFile5(join6(target, "apps/server/.env.example"), "utf8");
1626
+ await writeFile4(join6(target, "apps/server/.env"), generateServerEnv(serverExample, undefined, databasePort));
1627
+ const webExample = await readFile5(join6(target, "apps/web/.env.example"), "utf8");
1628
+ await writeFile4(join6(target, "apps/web/.env"), webExample);
1629
+ const docsExample = await readFile5(join6(target, "apps/docs/.env.example"), "utf8");
1630
+ await writeFile4(join6(target, "apps/docs/.env"), docsExample);
1631
+ await writeFile4(join6(target, ".env"), `POSTGRES_PORT=${databasePort}
1632
+ `);
1633
+ }
1634
+ async function neutralizeAgentInstructions(target) {
1635
+ const agentPath = join6(target, "AGENTS.md");
1636
+ const claudePath = join6(target, "CLAUDE.md");
1637
+ await writeFile4(agentPath, rewriteBuyerAgentInstructions(await readFile5(agentPath, "utf8")));
1638
+ await writeFile4(claudePath, rewriteBuyerClaudeInstructions(await readFile5(claudePath, "utf8")));
1639
+ }
1640
+ async function neutralizeSourceCli(target, scope, brandName) {
1641
+ if (String(CLI_NAME2) === BUYER_CLI_NAME && String(CLI_PACKAGE_NAME) === BUYER_CLI_PACKAGE_NAME)
1642
+ return;
1643
+ const rootPackagePath = join6(target, "package.json");
1644
+ const rootPackage = JSON.parse(await readFile5(rootPackagePath, "utf8"));
1645
+ const sourceScript = rootPackage.scripts?.[CLI_NAME2];
1646
+ if (!sourceScript)
1647
+ throw new Error(`Root package is missing the ${CLI_NAME2} script.`);
1648
+ rootPackage.scripts[BUYER_CLI_NAME] = sourceScript;
1649
+ delete rootPackage.scripts[CLI_NAME2];
1650
+ await writeFile4(rootPackagePath, `${JSON.stringify(rootPackage, null, 2)}
1651
+ `);
1652
+ const cliPackagePath = join6(target, "packages/create-app/package.json");
1653
+ const cliPackage = JSON.parse(await readFile5(cliPackagePath, "utf8"));
1654
+ if (cliPackage.name !== CLI_PACKAGE_NAME || !cliPackage.bin?.[CLI_NAME2]) {
1655
+ throw new Error(`Source CLI package must expose ${CLI_PACKAGE_NAME} as ${CLI_NAME2}.`);
1656
+ }
1657
+ const executable = cliPackage.bin[CLI_NAME2];
1658
+ cliPackage.name = BUYER_CLI_PACKAGE_NAME;
1659
+ cliPackage.private = true;
1660
+ cliPackage.bin = { [BUYER_CLI_NAME]: executable };
1661
+ delete cliPackage.description;
1662
+ delete cliPackage.homepage;
1663
+ delete cliPackage.repository;
1664
+ delete cliPackage.keywords;
1665
+ delete cliPackage.files;
1666
+ delete cliPackage.publishConfig;
1667
+ await writeFile4(cliPackagePath, `${JSON.stringify(cliPackage, null, 2)}
1668
+ `);
1669
+ const constantsPath = join6(target, "packages/create-app/src/constants.ts");
1670
+ const constants2 = await readFile5(constantsPath, "utf8");
1671
+ const cliDeclaration = `export const CLI_NAME = '${CLI_NAME2}'`;
1672
+ const packageDeclaration = `export const CLI_PACKAGE_NAME = '${CLI_PACKAGE_NAME}'`;
1673
+ const scopeDeclaration = `export const DEFAULT_SCOPE = '@app'`;
1674
+ const repositoryDeclaration = `export const TEMPLATE_REPOSITORY = '${TEMPLATE_REPOSITORY}'`;
1675
+ const brandDeclaration = `export const SOURCE_BRAND_NAME = '${SOURCE_BRAND_NAME}'`;
1676
+ if (!constants2.includes(cliDeclaration) || !constants2.includes(packageDeclaration) || !constants2.includes(scopeDeclaration) || !constants2.includes(repositoryDeclaration) || !constants2.includes(brandDeclaration)) {
1677
+ throw new Error("Source CLI constants do not match the package identity.");
1678
+ }
1679
+ await writeFile4(constantsPath, constants2.replace(cliDeclaration, `export const CLI_NAME = '${BUYER_CLI_NAME}'`).replace(packageDeclaration, `export const CLI_PACKAGE_NAME = '${BUYER_CLI_PACKAGE_NAME}'`).replace(scopeDeclaration, `export const DEFAULT_SCOPE = '${normalizeScope2(scope)}'`).replace(repositoryDeclaration, "export const TEMPLATE_REPOSITORY = 'OWNER/REPOSITORY'").replace(brandDeclaration, `export const SOURCE_BRAND_NAME = '${brandName}'`));
1680
+ const brandingPath = join6(target, "docs/golden-paths/customize-branding-design-tokens.md");
1681
+ const branding = await readFile5(brandingPath, "utf8");
1682
+ await writeFile4(brandingPath, branding.replaceAll(`pnpm ${CLI_NAME2}`, `pnpm ${BUYER_CLI_NAME}`));
1683
+ const setupPath = join6(target, "docs/setup.md");
1684
+ await writeFile4(setupPath, rewriteBuyerSetupInstructions(await readFile5(setupPath, "utf8")));
1685
+ const releasesPath = join6(target, "docs/releases.md");
1686
+ await writeFile4(releasesPath, rewriteBuyerReleaseInstructions(await readFile5(releasesPath, "utf8")));
1687
+ }
1688
+ async function initializeRepository(target) {
1689
+ await execFileAsync("git", ["init"], { cwd: target });
1690
+ await execFileAsync("git", ["add", "-A"], { cwd: target });
1691
+ await execFileAsync("git", [
1692
+ "-c",
1693
+ `user.name=${CLI_NAME2}`,
1694
+ "-c",
1695
+ `user.email=${CLI_NAME2}@localhost`,
1696
+ "commit",
1697
+ "-m",
1698
+ "Initial commit"
1699
+ ], { cwd: target });
1700
+ }
1701
+ async function scaffold(options) {
1702
+ const target = resolve3(options.targetDirectory);
1703
+ await assertEmptyTarget(target);
1704
+ try {
1705
+ if (options.from)
1706
+ await copyLocalTemplate(options.from, target);
1707
+ else if (options.templateRef)
1708
+ await downloadGitHubTemplate(target, options.templateRef);
1709
+ else {
1710
+ throw new Error(`A local source path or immutable ${CLI_NAME2} release is required.`);
1711
+ }
1712
+ const packagePath = join6(target, "package.json");
1713
+ const packageReplacements = await rewriteFile(packagePath, (content) => rewriteRootPackageName(content, options.appName));
1714
+ const scope = await applyScopeRewrite(target, options.scope);
1715
+ await neutralizeAgentInstructions(target);
1716
+ await neutralizeSourceCli(target, options.scope, options.brandName);
1717
+ const brand = await applyBrandRewrite(target, options.brandName);
1718
+ await generateEnvironmentFiles(target, options.databasePort);
1719
+ if (options.initializeGit)
1720
+ await initializeRepository(target);
1721
+ return {
1722
+ targetDirectory: target,
1723
+ packageName: { files: packageReplacements > 0 ? 1 : 0, replacements: packageReplacements },
1724
+ scope,
1725
+ brand
1726
+ };
1727
+ } catch (error) {
1728
+ if ((await stat4(target).catch(() => {
1729
+ return;
1730
+ }))?.isDirectory()) {
1731
+ const entries = await readdir(target);
1732
+ if (entries.length === 0)
1733
+ await rm3(target, { recursive: true, force: true });
1734
+ }
1735
+ throw error;
1736
+ }
1737
+ }
1738
+
1739
+ // src/cli.ts
1740
+ class CliCancelled extends Error {
1741
+ }
1742
+ function usage() {
1743
+ return `Usage: ${CLI_NAME} [app-name] [options]
1744
+ ${CLI_NAME} agent setup [options]
1745
+ ${CLI_NAME} deploy configure [options]
1746
+
1747
+ Create a buyer-owned SaaS application from your licensed ${suggestBrandName(CLI_NAME)} release.
1748
+
1749
+ Options:
1750
+ --from <path> Use a local source checkout (maintainer/testing)
1751
+ --scope <namespace> Advanced workspace namespace override (default: derived from brand)
1752
+ --brand <name> Display name (default: derived from app name)
1753
+ --yes Accept recommended defaults for omitted options
1754
+ --git / --no-git Enable or disable Git initialization (default: enabled)
1755
+ --install Install dependencies (default: enabled)
1756
+ --no-install Skip dependency installation
1757
+ --start Start Postgres and development servers after creation
1758
+ --no-start Create the application without starting it
1759
+ --cloud <provider> Configure AWS, GCP, or Azure deployment during setup
1760
+ --storage <provider> Select r2, s3, or gcs object storage
1761
+ --no-deploy Keep every cloud option for later deployment setup
1762
+ --frontend <target> Select native, vercel, or cloudflare frontend hosting
1763
+ --deploy-environment <name> Deployment environment (default: production)
1764
+ --region <name> Cloud region
1765
+ --api-domain <host> Stable production API hostname
1766
+ --frontend-domain <host> Stable production frontend hostname
1767
+ --ha / --no-ha Enable or disable the higher-availability profile
1768
+ -v, --version Print the CLI version
1769
+ -h, --help Show this help
1770
+
1771
+ Agent tooling:
1772
+ ${agentSetupUsage(CLI_NAME)}
1773
+
1774
+ Deployment:
1775
+ Run pnpm deploy:configure inside a generated app to make a deferred cloud selection.`;
1776
+ }
1777
+ function takeValue3(args, index, flag) {
1778
+ const value = args[index + 1];
1779
+ if (!value || value.startsWith("--"))
1780
+ throw new Error(`${flag} requires a value.`);
1781
+ return value;
1782
+ }
1783
+ function parseArguments(argv) {
1784
+ const parsed = {
1785
+ yes: false,
1786
+ deployment: {},
1787
+ help: false,
1788
+ version: false
1789
+ };
1790
+ for (let index = 0;index < argv.length; index += 1) {
1791
+ const argument = argv[index];
1792
+ if (argument === "--from")
1793
+ parsed.from = takeValue3(argv, index++, argument);
1794
+ else if (argument === "--scope")
1795
+ parsed.scope = takeValue3(argv, index++, argument);
1796
+ else if (argument === "--brand")
1797
+ parsed.brandName = takeValue3(argv, index++, argument);
1798
+ else if (argument === "--yes" || argument === "-y")
1799
+ parsed.yes = true;
1800
+ else if (argument === "--git")
1801
+ parsed.initializeGit = true;
1802
+ else if (argument === "--no-git")
1803
+ parsed.initializeGit = false;
1804
+ else if (argument === "--install")
1805
+ parsed.installDependencies = true;
1806
+ else if (argument === "--no-install")
1807
+ parsed.installDependencies = false;
1808
+ else if (argument === "--start")
1809
+ parsed.startDevelopment = true;
1810
+ else if (argument === "--no-start")
1811
+ parsed.startDevelopment = false;
1812
+ else if (argument === "--cloud") {
1813
+ parsed.deployment.cloud = parseDeploymentCloud2(takeValue3(argv, index++, argument));
1814
+ parsed.setupDeployment = true;
1815
+ } else if (argument === "--no-deploy")
1816
+ parsed.setupDeployment = false;
1817
+ else if (argument === "--storage") {
1818
+ parsed.deployment.storageProvider = parseStorageProvider2(takeValue3(argv, index++, argument));
1819
+ } else if (argument === "--frontend") {
1820
+ parsed.deployment.frontendTarget = parseFrontendTarget2(takeValue3(argv, index++, argument));
1821
+ } else if (argument === "--deploy-environment") {
1822
+ parsed.deployment.environment = takeValue3(argv, index++, argument);
1823
+ } else if (argument === "--region")
1824
+ parsed.deployment.region = takeValue3(argv, index++, argument);
1825
+ else if (argument === "--api-domain") {
1826
+ parsed.deployment.apiDomain = takeValue3(argv, index++, argument);
1827
+ } else if (argument === "--frontend-domain") {
1828
+ parsed.deployment.frontendDomain = takeValue3(argv, index++, argument);
1829
+ } else if (argument === "--ha")
1830
+ parsed.deployment.haEnabled = true;
1831
+ else if (argument === "--no-ha")
1832
+ parsed.deployment.haEnabled = false;
1833
+ else if (argument === "--version" || argument === "-v")
1834
+ parsed.version = true;
1835
+ else if (argument === "--help" || argument === "-h")
1836
+ parsed.help = true;
1837
+ else if (argument?.startsWith("-"))
1838
+ throw new Error(`Unknown option: ${argument}`);
1839
+ else if (!parsed.appName)
1840
+ parsed.appName = argument;
1841
+ else
1842
+ throw new Error(`Unexpected argument: ${argument}`);
1843
+ }
1844
+ return parsed;
1845
+ }
1846
+ function suggestBrandName(appName) {
1847
+ const productAcronyms = {
1848
+ ai: "AI",
1849
+ api: "API",
1850
+ b2b: "B2B",
1851
+ b2c: "B2C",
1852
+ crm: "CRM",
1853
+ saas: "SaaS"
1854
+ };
1855
+ return appName.split("-").map((segment) => productAcronyms[segment] ?? `${segment.charAt(0).toUpperCase()}${segment.slice(1)}`).join(" ");
1856
+ }
1857
+ function suggestScope(brandName, fallbackAppName) {
1858
+ const namespace = brandName.normalize("NFKD").replace(/\p{Mark}/gu, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1859
+ return namespace || fallbackAppName;
1860
+ }
1861
+ async function unwrapPrompt(value) {
1862
+ if (prompts5.isCancel(value)) {
1863
+ prompts5.cancel("No project was created.");
1864
+ throw new CliCancelled;
1865
+ }
1866
+ return value;
1867
+ }
1868
+ async function collectOptions(parsed) {
1869
+ if (parsed.yes && !parsed.appName)
1870
+ throw new Error("Pass an app name when using --yes.");
1871
+ if (!parsed.yes && !process.stdin.isTTY && !parsed.appName) {
1872
+ throw new Error("Interactive prompts need a terminal. Pass an app name and --yes instead.");
1873
+ }
1874
+ const appName = parsed.appName ?? await unwrapPrompt(await prompts5.text({
1875
+ message: "App name",
1876
+ placeholder: "my-saas",
1877
+ validate: (value) => validateAppName(value ?? "")
1878
+ }));
1879
+ const appNameError = validateAppName(appName);
1880
+ if (appNameError)
1881
+ throw new Error(appNameError);
1882
+ const suggestedBrand = suggestBrandName(appName);
1883
+ const brandName = parsed.brandName ?? (parsed.yes ? suggestedBrand : await unwrapPrompt(await prompts5.text({
1884
+ message: "Brand name",
1885
+ placeholder: suggestedBrand,
1886
+ defaultValue: suggestedBrand,
1887
+ validate: (value) => validateBrandName(value || suggestedBrand)
1888
+ })));
1889
+ const brandError = validateBrandName(brandName);
1890
+ if (brandError)
1891
+ throw new Error(brandError);
1892
+ const scope = normalizeScope(parsed.scope ?? suggestScope(brandName, appName));
1893
+ const initializeGit = parsed.initializeGit ?? (parsed.yes ? true : await unwrapPrompt(await prompts5.confirm({
1894
+ message: "Initialize a Git repository?",
1895
+ initialValue: true
1896
+ })));
1897
+ const installDependencies = parsed.installDependencies ?? (parsed.yes ? true : await unwrapPrompt(await prompts5.confirm({
1898
+ message: "Install dependencies with pnpm?",
1899
+ initialValue: true
1900
+ })));
1901
+ if (parsed.startDevelopment && !installDependencies) {
1902
+ throw new Error("--start requires dependency installation. Remove --no-install.");
1903
+ }
1904
+ const startDevelopment = parsed.startDevelopment ?? (parsed.yes || !installDependencies ? false : await unwrapPrompt(await prompts5.confirm({
1905
+ message: "Start local development now?",
1906
+ initialValue: true
1907
+ })));
1908
+ const setupDeployment = parsed.setupDeployment ?? (parsed.yes ? false : await unwrapPrompt(await prompts5.confirm({
1909
+ message: "Set up cloud deployment now?",
1910
+ initialValue: false
1911
+ })));
1912
+ const deployment = setupDeployment ? await collectDeploymentSelection2(parsed.deployment, parsed.yes) : undefined;
1913
+ return {
1914
+ appName,
1915
+ from: parsed.from ? resolve4(parsed.from) : undefined,
1916
+ scope,
1917
+ brandName,
1918
+ initializeGit,
1919
+ installDependencies,
1920
+ startDevelopment,
1921
+ deployment
1922
+ };
1923
+ }
1924
+ async function readCliVersion() {
1925
+ const packageJson = JSON.parse(await readFile6(resolve4(dirname2(fileURLToPath2(import.meta.url)), "../package.json"), "utf8"));
1926
+ if (!packageJson.version)
1927
+ throw new Error(`${CLI_NAME} package version is missing.`);
1928
+ return packageJson.version;
1929
+ }
1930
+ function templateRefForVersion(version) {
1931
+ if (!/^\d+\.\d+\.\d+(?:-rc\.\d+)?$/.test(version)) {
1932
+ throw new Error(`${CLI_NAME} package version is not release-compatible: ${version}`);
1933
+ }
1934
+ if (version === "0.0.0") {
1935
+ throw new Error(`This pre-release ${CLI_NAME} checkout requires --from <local-source-path>.`);
1936
+ }
1937
+ return `v${version}`;
1938
+ }
1939
+ async function assertTargetAvailable(target) {
1940
+ const targetStat = await stat5(target).catch(() => {
1941
+ return;
1942
+ });
1943
+ if (!targetStat)
1944
+ return;
1945
+ if (!targetStat.isDirectory())
1946
+ throw new Error(`Target exists and is not a directory: ${target}`);
1947
+ if ((await readdir2(target)).length > 0) {
1948
+ throw new Error(`Refusing to overwrite non-empty directory: ${target}`);
1949
+ }
1950
+ }
1951
+ async function hasListeningServiceOnHost(port, host) {
1952
+ return await new Promise((resolvePort) => {
1953
+ let settled = false;
1954
+ const socket = createConnection({ host, port });
1955
+ const finish = (listening) => {
1956
+ if (settled)
1957
+ return;
1958
+ settled = true;
1959
+ socket.destroy();
1960
+ resolvePort(listening);
1961
+ };
1962
+ socket.setTimeout(250);
1963
+ socket.once("connect", () => finish(true));
1964
+ socket.once("timeout", () => finish(false));
1965
+ socket.once("error", () => finish(false));
1966
+ });
1967
+ }
1968
+ async function hasListeningService(port) {
1969
+ const results = await Promise.all(["127.0.0.1", "::1"].map((host) => hasListeningServiceOnHost(port, host)));
1970
+ return results.some(Boolean);
1971
+ }
1972
+ async function isPortAvailable(port) {
1973
+ if (await hasListeningService(port))
1974
+ return false;
1975
+ return await new Promise((resolvePort) => {
1976
+ const server = createServer();
1977
+ server.unref();
1978
+ server.once("error", () => resolvePort(false));
1979
+ server.listen({ host: "127.0.0.1", port, exclusive: true }, () => {
1980
+ server.close(() => resolvePort(true));
1981
+ });
1982
+ });
1983
+ }
1984
+ async function findAvailablePort(preferredPort = 5432) {
1985
+ for (let port = preferredPort;port < preferredPort + 100; port += 1) {
1986
+ if (await isPortAvailable(port))
1987
+ return port;
1988
+ }
1989
+ throw new Error(`No available local Postgres port found from ${preferredPort}.`);
1990
+ }
1991
+ function assertSiblingTarget(source, target) {
1992
+ if (!source)
1993
+ return;
1994
+ const normalizedSource = resolve4(source);
1995
+ const normalizedTarget = resolve4(target);
1996
+ if (normalizedTarget.startsWith(`${normalizedSource}${sep}`)) {
1997
+ throw new Error(`Create the new app beside the ${CLI_NAME} source checkout, not inside it.`);
1998
+ }
1999
+ }
2000
+ function commandFailureOutput(stdout, stderr) {
2001
+ const output = (stderr.trim() || stdout.trim()).split(`
2002
+ `).slice(-12).join(`
2003
+ `);
2004
+ return output ? `
2005
+
2006
+ ${output}` : "";
2007
+ }
2008
+ async function installDependencies(target) {
2009
+ const { stdout, stderr, exitCode } = await runCaptured2(["pnpm", "install", "--frozen-lockfile"], {
2010
+ cwd: target,
2011
+ env: { ...process.env, CI: "1" }
2012
+ });
2013
+ if (exitCode !== 0) {
2014
+ throw new Error(`Dependency installation failed.${commandFailureOutput(stdout, stderr)}`);
2015
+ }
2016
+ }
2017
+ async function runCapturedCommand(command, target) {
2018
+ const { stdout, stderr, exitCode } = await runCaptured2(command, { cwd: target });
2019
+ if (exitCode !== 0) {
2020
+ throw new Error(`${command.join(" ")} failed.${commandFailureOutput(stdout, stderr)}`);
2021
+ }
2022
+ }
2023
+ async function startDevelopment(target) {
2024
+ const occupiedPorts = (await Promise.all([5173, 8000].map(async (port) => ({
2025
+ port,
2026
+ available: await isPortAvailable(port)
2027
+ })))).filter((result) => !result.available).map((result) => result.port);
2028
+ if (occupiedPorts.length > 0) {
2029
+ throw new Error(`Cannot start development because port ${occupiedPorts.join(", ")} is in use.`);
2030
+ }
2031
+ await runStep("Starting local Postgres", "Local Postgres is ready", () => runCapturedCommand(["docker", "compose", "up", "-d", "--wait", "postgres"], target));
2032
+ prompts5.log.info(`Web http://localhost:5173
2033
+ API http://localhost:8000`);
2034
+ prompts5.outro("Development servers are starting. Ctrl+C stops app servers; Postgres stays running.");
2035
+ const exitCode = await runInherited(["vp", "run", "-r", "--parallel", "dev"], { cwd: target });
2036
+ if (exitCode !== 0 && exitCode !== 130) {
2037
+ throw new Error(`Development servers exited with code ${exitCode}.`);
2038
+ }
2039
+ }
2040
+ async function runStep(pending, complete, action, indicator = "dots") {
2041
+ const progress = prompts5.spinner({ indicator });
2042
+ progress.start(pending);
2043
+ try {
2044
+ const result = await action();
2045
+ progress.stop(complete);
2046
+ return result;
2047
+ } catch (error) {
2048
+ progress.error(`${pending} failed`);
2049
+ throw error;
2050
+ }
2051
+ }
2052
+ async function createApplication(options, version) {
2053
+ const target = resolve4(process.cwd(), options.appName);
2054
+ await assertTargetAvailable(target);
2055
+ assertSiblingTarget(options.from, target);
2056
+ const databasePort = await findAvailablePort();
2057
+ await mkdir5(dirname2(target), { recursive: true });
2058
+ const staging = await mkdtemp2(join7(dirname2(target), `.${options.appName}-${CLI_NAME}-`));
2059
+ try {
2060
+ const templateRef = options.from ? undefined : templateRefForVersion(version);
2061
+ await runStep("Preparing your application", "Application source prepared", () => scaffold({
2062
+ appName: options.appName,
2063
+ scope: options.scope,
2064
+ brandName: options.brandName,
2065
+ targetDirectory: staging,
2066
+ from: options.from,
2067
+ templateRef,
2068
+ initializeGit: options.initializeGit,
2069
+ databasePort
2070
+ }));
2071
+ if (options.deployment) {
2072
+ await runStep("Configuring deployment", "Deployment target locked", () => applyDeploymentSelection2(staging, options.appName, options.deployment));
2073
+ }
2074
+ if (options.installDependencies) {
2075
+ await runStep("Installing dependencies with pnpm", "Dependencies installed with pnpm", () => installDependencies(staging), "timer");
2076
+ }
2077
+ if (await stat5(target).catch(() => {
2078
+ return;
2079
+ }))
2080
+ await rmdir(target);
2081
+ await rename2(staging, target);
2082
+ return { target, databasePort };
2083
+ } catch (error) {
2084
+ await rm4(staging, { recursive: true, force: true });
2085
+ throw error;
2086
+ }
2087
+ }
2088
+ function completionDetails(target, options, databasePort) {
2089
+ const targetName = relative2(process.cwd(), target) || options.appName;
2090
+ const install = options.installDependencies ? [] : ["vp install --frozen-lockfile"];
2091
+ return {
2092
+ directory: `./${targetName}`,
2093
+ commands: [
2094
+ `cd ${targetName}`,
2095
+ ...install,
2096
+ "docker compose up -d postgres",
2097
+ "vp run -r --parallel dev"
2098
+ ].join(`
2099
+ `),
2100
+ database: `Postgres localhost:${databasePort}`
2101
+ };
2102
+ }
2103
+ async function main() {
2104
+ const argv = process.argv.slice(2);
2105
+ if (argv[0] === "agent") {
2106
+ if (argv[1] !== "setup")
2107
+ throw new Error("Unknown agent command. Use `agent setup`.");
2108
+ await runAgentSetup(argv.slice(2), CLI_NAME);
2109
+ return;
2110
+ }
2111
+ if (argv[0] === "deploy") {
2112
+ if (argv[1] !== "configure")
2113
+ throw new Error("Unknown deploy command. Use `deploy configure`.");
2114
+ await runDeploymentConfigure(argv.slice(2), suggestBrandName(CLI_NAME));
2115
+ return;
2116
+ }
2117
+ const parsed = parseArguments(argv);
2118
+ const version = await readCliVersion();
2119
+ if (parsed.help) {
2120
+ console.log(usage());
2121
+ return;
2122
+ }
2123
+ if (parsed.version) {
2124
+ console.log(version);
2125
+ return;
2126
+ }
2127
+ prompts5.intro(`${suggestBrandName(CLI_NAME)} ${version} \xB7 production SaaS foundation`);
2128
+ const options = await collectOptions(parsed);
2129
+ const { target, databasePort } = await createApplication(options, version);
2130
+ const completion = completionDetails(target, options, databasePort);
2131
+ prompts5.log.success(`Created ${completion.directory}`);
2132
+ if (!options.startDevelopment) {
2133
+ prompts5.log.step("Run locally");
2134
+ prompts5.log.message(completion.commands);
2135
+ prompts5.log.info(`Web http://localhost:5173
2136
+ API http://localhost:8000
2137
+ ${completion.database}`);
2138
+ if (options.deployment) {
2139
+ prompts5.log.info(`Deployment ${options.deployment.cloud.toUpperCase()} + ${options.deployment.storageProvider} + ${options.deployment.frontendTarget} locked. Review deployment/README.md.`);
2140
+ } else {
2141
+ prompts5.log.info("Deployment deferred. Run `pnpm deploy:configure` when you are ready.");
2142
+ }
2143
+ prompts5.outro(`${options.brandName} is ready.`);
2144
+ return;
2145
+ }
2146
+ try {
2147
+ await startDevelopment(target);
2148
+ } catch (error) {
2149
+ prompts5.log.error(error instanceof Error ? error.message : String(error));
2150
+ prompts5.log.info(`The application was created at ${completion.directory}.`);
2151
+ prompts5.log.step("Start it manually");
2152
+ prompts5.log.message(completion.commands);
2153
+ process.exitCode = 1;
2154
+ }
2155
+ }
2156
+ if (isEntrypoint(import.meta.url)) {
2157
+ main().catch((error) => {
2158
+ if (error instanceof CliCancelled)
2159
+ return;
2160
+ prompts5.log.error(error instanceof Error ? error.message : String(error));
2161
+ prompts5.outro("No project was created.");
2162
+ process.exitCode = 1;
2163
+ });
2164
+ }
2165
+ export {
2166
+ usage,
2167
+ templateRefForVersion,
2168
+ suggestScope,
2169
+ suggestBrandName,
2170
+ parseArguments,
2171
+ findAvailablePort
2172
+ };