saasaloy 0.5.2 → 0.6.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 (83) hide show
  1. package/README.md +7 -7
  2. package/dist/index.js +637 -522
  3. package/dist/index.js.map +1 -1
  4. package/package.json +1 -1
  5. package/schemas/registry-item.schema.json +31 -1
  6. package/templates/base/AGENTS.md +180 -53
  7. package/templates/base/README.md +20 -8
  8. package/templates/base/_agents/skills/saasaloy-design/SKILL.md +26 -15
  9. package/templates/base/_agents/skills/saasaloy-env/SKILL.md +130 -0
  10. package/templates/base/_agents/skills/saasaloy-landing-copy/SKILL.md +63 -46
  11. package/templates/base/_agents/skills/saasaloy-setup/SKILL.md +1 -1
  12. package/templates/base/_gitignore +0 -3
  13. package/templates/base/_saasaloy-base.json +8 -1
  14. package/templates/base/{DESIGN.md → apps/web/DESIGN.md} +36 -12
  15. package/templates/base/apps/web/astro.config.mjs +36 -6
  16. package/templates/base/apps/web/package.json +2 -1
  17. package/templates/base/apps/web/src/env.ts +41 -0
  18. package/templates/base/apps/web/src/layouts/Layout.astro +39 -11
  19. package/templates/base/apps/web/src/pages/index.astro +33 -24
  20. package/templates/base/apps/web/src/pages/privacy.astro +10 -2
  21. package/templates/base/apps/web/src/pages/terms.astro +10 -2
  22. package/templates/base/oxlint.config.mjs +91 -4
  23. package/templates/base/package.json +7 -3
  24. package/templates/base/packages/env/_env.example +54 -0
  25. package/templates/base/packages/env/package.json +35 -0
  26. package/templates/base/packages/env/scripts/env-setup.ts +87 -0
  27. package/templates/base/packages/env/src/check.test.ts +41 -0
  28. package/templates/base/packages/env/src/check.ts +50 -0
  29. package/templates/base/packages/env/src/define.test.ts +206 -0
  30. package/templates/base/packages/env/src/define.ts +315 -0
  31. package/templates/base/packages/env/src/deploy.test.ts +438 -0
  32. package/templates/base/packages/env/src/deploy.ts +518 -0
  33. package/templates/base/packages/env/src/dotenv.test.ts +179 -0
  34. package/templates/base/packages/env/src/dotenv.ts +173 -0
  35. package/templates/base/packages/env/src/env-file.ts +51 -0
  36. package/templates/base/packages/env/src/error.ts +38 -0
  37. package/templates/base/packages/env/src/example.test.ts +86 -0
  38. package/templates/base/packages/env/src/example.ts +90 -0
  39. package/templates/base/packages/env/src/index.ts +15 -0
  40. package/templates/base/packages/env/src/schema.ts +63 -0
  41. package/templates/base/packages/env/src/services.test.ts +83 -0
  42. package/templates/base/packages/env/src/services.ts +110 -0
  43. package/templates/base/packages/env/src/setup.test.ts +254 -0
  44. package/templates/base/packages/env/src/setup.ts +177 -0
  45. package/templates/base/packages/env/src/source.ts +20 -0
  46. package/templates/base/packages/env/src/sources.test.ts +137 -0
  47. package/templates/base/packages/env/src/sources.ts +93 -0
  48. package/templates/base/packages/env/src/zod.ts +8 -0
  49. package/templates/base/packages/env/tsconfig.json +13 -0
  50. package/templates/base/packages/tsconfig/base.json +7 -0
  51. package/templates/base/packages/ui/components.json +1 -1
  52. package/templates/base/packages/ui/package.json +1 -0
  53. package/templates/base/packages/ui/src/blocks/audience.tsx +73 -0
  54. package/templates/base/packages/ui/src/blocks/cta.tsx +41 -30
  55. package/templates/base/packages/ui/src/blocks/error-state.tsx +2 -0
  56. package/templates/base/packages/ui/src/blocks/faq.tsx +64 -36
  57. package/templates/base/packages/ui/src/blocks/feature-grid.tsx +48 -146
  58. package/templates/base/packages/ui/src/blocks/footer.tsx +129 -70
  59. package/templates/base/packages/ui/src/blocks/hero.tsx +183 -26
  60. package/templates/base/packages/ui/src/blocks/how-it-works.tsx +75 -0
  61. package/templates/base/packages/ui/src/blocks/logo-cloud.tsx +118 -0
  62. package/templates/base/packages/ui/src/blocks/navbar.tsx +204 -88
  63. package/templates/base/packages/ui/src/blocks/pain-points.tsx +131 -0
  64. package/templates/base/packages/ui/src/blocks/pricing-table.tsx +112 -94
  65. package/templates/base/packages/ui/src/blocks/showcase.tsx +185 -0
  66. package/templates/base/packages/ui/src/blocks/testimonials.tsx +85 -0
  67. package/templates/base/packages/ui/src/blocks/theme-toggle.tsx +16 -12
  68. package/templates/base/packages/ui/src/components/badge.tsx +1 -1
  69. package/templates/base/packages/ui/src/components/button.tsx +2 -2
  70. package/templates/base/packages/ui/src/components/tabs.tsx +1 -1
  71. package/templates/base/packages/ui/src/content/landing.ts +590 -125
  72. package/templates/base/packages/ui/src/index.ts +6 -2
  73. package/templates/base/packages/ui/src/lib/external-link.ts +36 -0
  74. package/templates/base/packages/ui/src/lib/icons.ts +131 -0
  75. package/templates/base/packages/ui/src/lib/inline-code.ts +29 -0
  76. package/templates/base/packages/ui/src/lib/theme.ts +140 -56
  77. package/templates/base/packages/ui/src/styles/globals.css +47 -125
  78. package/templates/base/packages/ui/src/styles/web.css +359 -0
  79. package/templates/base/pnpm-workspace.yaml +0 -6
  80. package/templates/base/saasaloy.json +1 -0
  81. package/templates/base/stylelint.config.js +3 -3
  82. package/templates/base/turbo.json +13 -0
  83. package/templates/base/vitest.config.ts +21 -0
@@ -0,0 +1,518 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
3
+ import {
4
+ existsSync,
5
+ mkdtempSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import path from "node:path";
13
+ import { incompleteKeys } from "./check.ts";
14
+ import { parseValues } from "./dotenv.ts";
15
+ import { readExample } from "./example.ts";
16
+ import { findService, SERVICES, serviceNames } from "./services.ts";
17
+ import type { Service } from "./services.ts";
18
+ import { sources } from "./sources.ts";
19
+ import type { EnvSource } from "./sources.ts";
20
+
21
+ // `env-deploy`: deploy a Worker with its production values, which never touch a file under
22
+ // `apps/`.
23
+ //
24
+ // env-deploy --check every service's production values, or exit 1
25
+ // env-deploy <service> [-- <wrangler args>] build and deploy the workspace it runs in
26
+ // env-deploy --run <service> -- <command> run a command with those values
27
+ //
28
+ // The values come from the selected value source (`ENV_SOURCE`), loaded in memory with the
29
+ // `deploy` row's Cloudflare credentials beside them. A variable the shell already exports
30
+ // wins, so CI passes values as environment variables. The build gets the values in its
31
+ // environment, where Vite gives them priority over the dev `.env`, so the bundle inlines
32
+ // the production `PUBLIC_*` values. The Worker's secrets travel with its code in
33
+ // one `wrangler deploy --secrets-file`, written under the OS temp directory and deleted
34
+ // whether the deploy passes or fails.
35
+ //
36
+ // Two things keep the dev `.env` under `apps/` out of a production build. A key the
37
+ // value source leaves out is set to an empty string rather than left unset, and the build
38
+ // runs with `CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV=false`. That file stays untouched.
39
+
40
+ /** The `SERVICES` row that holds the Cloudflare credentials wrangler reads. */
41
+ export const DEPLOY_SERVICE = "deploy";
42
+
43
+ /**
44
+ * The deploy tooling's own keys. They never reach a Worker's secrets, whichever row a
45
+ * value source filed them under: every deployed Worker would otherwise hold an API token
46
+ * that can redeploy the account.
47
+ */
48
+ const CREDENTIAL_PREFIX = "CLOUDFLARE_";
49
+
50
+ export type ProcessEnv = Record<string, string | undefined>;
51
+
52
+ /** Run a command to completion and answer with its exit code. */
53
+ export type Runner = (
54
+ command: string,
55
+ args: string[],
56
+ options: { cwd: string; env: ProcessEnv }
57
+ ) => Promise<number>;
58
+
59
+ /** Whether wrangler holds a `wrangler login` session under `env`. */
60
+ export type LoginCheck = (root: string, env: ProcessEnv) => boolean;
61
+
62
+ export interface Production {
63
+ values: Map<string, string>;
64
+ /** Keys the example lists for the service that hold no value. */
65
+ missing: string[];
66
+ }
67
+
68
+ /** The values under the current environment. A blank credential stays out. */
69
+ export function childEnv(
70
+ values: Map<string, string>,
71
+ env: ProcessEnv
72
+ ): ProcessEnv {
73
+ const carried = [...values].filter(
74
+ ([key, value]) => !(key.startsWith(CREDENTIAL_PREFIX) && value === "")
75
+ );
76
+ return { ...Object.fromEntries(carried), ...env };
77
+ }
78
+
79
+ function requireService(name: string): Service {
80
+ const service = findService(name);
81
+ if (!service) {
82
+ throw new Error(
83
+ `${name} is not a service. Choose ${serviceNames().join(", ")}.`
84
+ );
85
+ }
86
+ return service;
87
+ }
88
+
89
+ /** One service's production values, with every key the shell exports laid over them. */
90
+ export async function loadProduction(options: {
91
+ root: string;
92
+ service: Service;
93
+ source: EnvSource;
94
+ processEnv: ProcessEnv;
95
+ }): Promise<Production> {
96
+ const { root, service, source, processEnv } = options;
97
+ const example = readExample(root, service.name);
98
+ const values = new Map(
99
+ await source.load({ root, service, environment: "prod", processEnv })
100
+ );
101
+ for (const key of parseValues(example).keys()) {
102
+ const exported = processEnv[key];
103
+ if (exported !== undefined) {
104
+ values.set(key, exported);
105
+ } else if (!values.has(key)) {
106
+ // An empty value, not a missing one: Vite fills a key the environment lacks from
107
+ // the dev `.env` beside the workspace, which would ship a `localhost` URL.
108
+ values.set(key, "");
109
+ }
110
+ }
111
+ return {
112
+ values,
113
+ missing: incompleteKeys(example, values, service.omit),
114
+ };
115
+ }
116
+
117
+ /** A service's values and the deploy credentials, checked together. */
118
+ async function loadWithCredentials(options: {
119
+ root: string;
120
+ name: string;
121
+ source: EnvSource;
122
+ processEnv: ProcessEnv;
123
+ loggedIn: LoginCheck;
124
+ }): Promise<Map<string, string>> {
125
+ const { root, name, source, processEnv, loggedIn } = options;
126
+ const names = name === DEPLOY_SERVICE ? [name] : [DEPLOY_SERVICE, name];
127
+ const values = new Map<string, string>();
128
+ const problems: string[] = [];
129
+ for (const each of names) {
130
+ const loaded = await loadProduction({
131
+ root,
132
+ service: requireService(each),
133
+ source,
134
+ processEnv,
135
+ });
136
+ for (const [key, value] of loaded.values) {
137
+ values.set(key, value);
138
+ }
139
+ if (loaded.missing.length > 0) {
140
+ problems.push(missingMessage(each, loaded.missing));
141
+ }
142
+ }
143
+ const login = loginProblem(root, values, processEnv, loggedIn);
144
+ if (login !== undefined) {
145
+ problems.push(login);
146
+ }
147
+ if (problems.length > 0) {
148
+ throw new Error(`${problems.join("\n")}\nNothing was built or deployed.`);
149
+ }
150
+ return values;
151
+ }
152
+
153
+ function missingMessage(service: string, keys: string[]): string {
154
+ return `${service} has no production value for ${keys.join(", ")}.`;
155
+ }
156
+
157
+ /**
158
+ * With no API token, wrangler falls back to a `wrangler login` session. With neither, it
159
+ * opens a browser to log in, which hangs a headless run, so stop first and say why.
160
+ */
161
+ function loginProblem(
162
+ root: string,
163
+ values: Map<string, string>,
164
+ processEnv: ProcessEnv,
165
+ loggedIn: LoginCheck
166
+ ): string | undefined {
167
+ const token = values.get("CLOUDFLARE_API_TOKEN") ?? "";
168
+ if (token !== "" || loggedIn(root, childEnv(values, processEnv))) {
169
+ return undefined;
170
+ }
171
+ return "CLOUDFLARE_API_TOKEN has no value and wrangler holds no login. Set the token at the value source, or run wrangler login.";
172
+ }
173
+
174
+ /**
175
+ * Every service's production values, before anything builds. An empty result means a
176
+ * deploy can start. `pnpm deploy:production` runs this ahead of `turbo run deploy`, so a
177
+ * blank key stops the run before the first Worker uploads.
178
+ */
179
+ export async function checkProduction(options: {
180
+ root: string;
181
+ source: EnvSource;
182
+ processEnv: ProcessEnv;
183
+ loggedIn: LoginCheck;
184
+ }): Promise<string[]> {
185
+ const { root, source, processEnv, loggedIn } = options;
186
+ const problems: string[] = [];
187
+ let credentials = new Map<string, string>();
188
+ for (const service of SERVICES) {
189
+ const loaded = await loadProduction({ root, service, source, processEnv });
190
+ if (loaded.missing.length > 0) {
191
+ problems.push(missingMessage(service.name, loaded.missing));
192
+ }
193
+ if (service.name === DEPLOY_SERVICE) {
194
+ credentials = loaded.values;
195
+ }
196
+ }
197
+ const login = loginProblem(root, credentials, processEnv, loggedIn);
198
+ if (login !== undefined) {
199
+ problems.push(login);
200
+ }
201
+ return problems;
202
+ }
203
+
204
+ export interface WorkerConfig {
205
+ main?: string;
206
+ vars?: Record<string, unknown>;
207
+ }
208
+
209
+ function isRecord(value: unknown): value is Record<string, unknown> {
210
+ return typeof value === "object" && value !== null && !Array.isArray(value);
211
+ }
212
+
213
+ /**
214
+ * The secrets a Worker receives: its values minus the deploy credentials, minus a key its
215
+ * config already sets in `vars`, minus an empty value. An assets-only Worker, one with no
216
+ * `main`, runs no code to read a secret, so it gets none.
217
+ */
218
+ export function secretsFor(
219
+ values: Map<string, string>,
220
+ config: WorkerConfig
221
+ ): Record<string, string> {
222
+ if (config.main === undefined) {
223
+ return {};
224
+ }
225
+ const vars = new Set(Object.keys(config.vars ?? {}));
226
+ return Object.fromEntries(
227
+ [...values].filter(
228
+ ([key, value]) =>
229
+ value !== "" && !key.startsWith(CREDENTIAL_PREFIX) && !vars.has(key)
230
+ )
231
+ );
232
+ }
233
+
234
+ /** JSON with its comments and trailing commas removed, which is all `wrangler.jsonc` adds. */
235
+ export function parseJsonc(text: string): unknown {
236
+ let out = "";
237
+ let index = 0;
238
+ while (index < text.length) {
239
+ const char = text[index];
240
+ if (char === '"') {
241
+ const start = index;
242
+ index += 1;
243
+ while (index < text.length && text[index] !== '"') {
244
+ index += text[index] === "\\" ? 2 : 1;
245
+ }
246
+ index += 1;
247
+ out += text.slice(start, index);
248
+ } else if (char === "/" && text[index + 1] === "/") {
249
+ while (index < text.length && text[index] !== "\n") {
250
+ index += 1;
251
+ }
252
+ } else if (char === "/" && text[index + 1] === "*") {
253
+ const end = text.indexOf("*/", index + 2);
254
+ index = end === -1 ? text.length : end + 2;
255
+ } else {
256
+ out += char;
257
+ index += 1;
258
+ }
259
+ }
260
+ return JSON.parse(out.replaceAll(/,(\s*[}\]])/g, "$1"));
261
+ }
262
+
263
+ /**
264
+ * The config `wrangler deploy` reads in `cwd`. A build that redirects it, as the Astro
265
+ * adapter and `@cloudflare/vite-plugin` both do through `.wrangler/deploy/config.json`,
266
+ * decides which file that is, so read it after the build.
267
+ */
268
+ export function readWorkerConfig(cwd: string): WorkerConfig {
269
+ const redirect = path.join(cwd, ".wrangler/deploy/config.json");
270
+ let file: string | undefined;
271
+ if (existsSync(redirect)) {
272
+ const parsed: unknown = JSON.parse(readFileSync(redirect, "utf-8"));
273
+ const configPath = isRecord(parsed) ? parsed.configPath : undefined;
274
+ if (typeof configPath === "string") {
275
+ file = path.resolve(path.dirname(redirect), configPath);
276
+ }
277
+ }
278
+ file ??= ["wrangler.jsonc", "wrangler.json"]
279
+ .map((name) => path.join(cwd, name))
280
+ .find((candidate) => existsSync(candidate));
281
+ if (file === undefined) {
282
+ throw new Error(
283
+ `${cwd} has no wrangler.jsonc or wrangler.json, so there is no Worker to deploy.`
284
+ );
285
+ }
286
+ const config = parseJsonc(readFileSync(file, "utf-8"));
287
+ if (!isRecord(config)) {
288
+ throw new Error(`${file} does not hold a JSON object.`);
289
+ }
290
+ return {
291
+ ...(typeof config.main === "string" ? { main: config.main } : {}),
292
+ ...(isRecord(config.vars) ? { vars: config.vars } : {}),
293
+ };
294
+ }
295
+
296
+ /**
297
+ * Build the workspace in `cwd` with `name`'s production values and deploy it. The secrets
298
+ * file lives only as long as the `wrangler deploy` that reads it.
299
+ */
300
+ export async function deployService(options: {
301
+ root: string;
302
+ name: string;
303
+ cwd: string;
304
+ source: EnvSource;
305
+ processEnv: ProcessEnv;
306
+ run: Runner;
307
+ loggedIn: LoginCheck;
308
+ wranglerArgs?: string[];
309
+ }): Promise<void> {
310
+ const { cwd, processEnv, run } = options;
311
+ const values = await loadWithCredentials(options);
312
+ const env = childEnv(values, processEnv);
313
+ // The Cloudflare adapters prerender inside workerd, and wrangler hands workerd the dev
314
+ // `.env` beside the workspace as its vars, ahead of the environment. This turns that
315
+ // off for the build, so the production values are the only ones it sees.
316
+ const buildEnv = { ...env, CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV: "false" };
317
+
318
+ if ((await run("pnpm", ["run", "build"], { cwd, env: buildEnv })) !== 0) {
319
+ throw new Error(`The ${options.name} build failed. Nothing was deployed.`);
320
+ }
321
+
322
+ const secrets = secretsFor(values, readWorkerConfig(cwd));
323
+ const args = ["deploy"];
324
+ let directory: string | undefined;
325
+ try {
326
+ if (Object.keys(secrets).length > 0) {
327
+ directory = mkdtempSync(path.join(tmpdir(), "env-deploy-"));
328
+ const file = path.join(directory, "secrets.json");
329
+ writeFileSync(file, JSON.stringify(secrets), { mode: 0o600 });
330
+ args.push("--secrets-file", file);
331
+ }
332
+ args.push(...(options.wranglerArgs ?? []));
333
+ if ((await run("wrangler", args, { cwd, env })) !== 0) {
334
+ throw new Error(`wrangler deploy failed for ${options.name}.`);
335
+ }
336
+ } finally {
337
+ if (directory !== undefined) {
338
+ rmSync(directory, { recursive: true, force: true });
339
+ }
340
+ }
341
+ }
342
+
343
+ /** Run one command with `name`'s production values and the deploy credentials. */
344
+ export async function runWithProduction(options: {
345
+ root: string;
346
+ name: string;
347
+ cwd: string;
348
+ source: EnvSource;
349
+ processEnv: ProcessEnv;
350
+ run: Runner;
351
+ loggedIn: LoginCheck;
352
+ command: string;
353
+ args: string[];
354
+ }): Promise<number> {
355
+ const values = await loadWithCredentials(options);
356
+ return await options.run(options.command, options.args, {
357
+ cwd: options.cwd,
358
+ env: childEnv(values, options.processEnv),
359
+ });
360
+ }
361
+
362
+ export type Invocation =
363
+ | { kind: "check" }
364
+ | { kind: "deploy"; service: string; wranglerArgs: string[] }
365
+ | { kind: "run"; service: string; command: string; args: string[] };
366
+
367
+ const USAGE =
368
+ "Usage: env-deploy --check | env-deploy <service> [-- <wrangler args>] | env-deploy --run <service> -- <command> [args...]";
369
+
370
+ export function parseArgs(argv: string[]): Invocation {
371
+ const [first, ...rest] = argv;
372
+ if (first === "--check" && rest.length === 0) {
373
+ return { kind: "check" };
374
+ }
375
+ if (first === "--run") {
376
+ const [service, separator, command, ...args] = rest;
377
+ if (
378
+ service !== undefined &&
379
+ separator === "--" &&
380
+ command !== undefined &&
381
+ command !== ""
382
+ ) {
383
+ return { kind: "run", service, command, args };
384
+ }
385
+ throw new Error(USAGE);
386
+ }
387
+ if (first !== undefined && !first.startsWith("-")) {
388
+ const [separator, ...wranglerArgs] = rest;
389
+ if (separator === undefined) {
390
+ return { kind: "deploy", service: first, wranglerArgs: [] };
391
+ }
392
+ if (separator === "--") {
393
+ return { kind: "deploy", service: first, wranglerArgs };
394
+ }
395
+ }
396
+ throw new Error(USAGE);
397
+ }
398
+
399
+ /** A workspace with wrangler installed, so `pnpm exec wrangler` resolves from the root. */
400
+ function wranglerWorkspace(root: string): string {
401
+ const has = (dir: string): boolean =>
402
+ existsSync(path.join(dir, "node_modules/.bin/wrangler"));
403
+ if (has(process.cwd())) {
404
+ return process.cwd();
405
+ }
406
+ const apps = path.join(root, "apps");
407
+ const found = existsSync(apps)
408
+ ? readdirSync(apps)
409
+ .map((name) => path.join(apps, name))
410
+ .find((dir) => has(dir))
411
+ : undefined;
412
+ if (found === undefined) {
413
+ throw new Error(
414
+ "No workspace under apps/ has wrangler installed. Run pnpm install."
415
+ );
416
+ }
417
+ return found;
418
+ }
419
+
420
+ /** `wrangler whoami --json` reports `loggedIn`, and exits 1 when it is false. */
421
+ const wranglerLoggedIn: LoginCheck = (root, env) => {
422
+ const result = spawnSync("pnpm", ["exec", "wrangler", "whoami", "--json"], {
423
+ cwd: wranglerWorkspace(root),
424
+ env,
425
+ encoding: "utf-8",
426
+ });
427
+ try {
428
+ const parsed: unknown = JSON.parse(result.stdout);
429
+ return isRecord(parsed) && parsed.loggedIn === true;
430
+ } catch {
431
+ return false;
432
+ }
433
+ };
434
+
435
+ const spawnRunner: Runner = async (command, args, { cwd, env }) =>
436
+ // `spawn` reports through events, not a promise, so this wraps it in one.
437
+ // oxlint-disable-next-line promise/avoid-new
438
+ await new Promise((resolve, reject) => {
439
+ const child = spawn(command, args, { cwd, env, stdio: "inherit" });
440
+ const forward = (signal: NodeJS.Signals): void => {
441
+ child.kill(signal);
442
+ };
443
+ process.on("SIGINT", forward);
444
+ process.on("SIGTERM", forward);
445
+ child.on("error", (error) => {
446
+ reject(
447
+ new Error(`env-deploy: could not run ${command}: ${error.message}`)
448
+ );
449
+ });
450
+ child.on("exit", (code, signal) => {
451
+ process.off("SIGINT", forward);
452
+ process.off("SIGTERM", forward);
453
+ resolve(code ?? (signal ? 1 : 0));
454
+ });
455
+ });
456
+
457
+ async function main(): Promise<void> {
458
+ const invocation = parseArgs(process.argv.slice(2));
459
+ const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
460
+ encoding: "utf-8",
461
+ }).trim();
462
+ const source = sources.select(process.env.ENV_SOURCE);
463
+ const common = {
464
+ root,
465
+ source,
466
+ processEnv: process.env,
467
+ loggedIn: wranglerLoggedIn,
468
+ };
469
+
470
+ if (invocation.kind === "check") {
471
+ const problems = await checkProduction(common);
472
+ if (problems.length > 0) {
473
+ const hint = problems.some((problem) =>
474
+ problem.includes("has no production value")
475
+ )
476
+ ? `Set each missing key at the value source (${source.name}), or mark it "# Blank on purpose" in packages/env/.env.example.\n`
477
+ : "";
478
+ throw new Error(
479
+ `${problems.join("\n")}\n${hint}Nothing was built or deployed.`
480
+ );
481
+ }
482
+ process.stdout.write(
483
+ `Every service has its production values (source: ${source.name}).\n`
484
+ );
485
+ return;
486
+ }
487
+
488
+ if (invocation.kind === "run") {
489
+ process.exitCode = await runWithProduction({
490
+ ...common,
491
+ name: invocation.service,
492
+ cwd: process.cwd(),
493
+ run: spawnRunner,
494
+ command: invocation.command,
495
+ args: invocation.args,
496
+ });
497
+ return;
498
+ }
499
+
500
+ await deployService({
501
+ ...common,
502
+ name: invocation.service,
503
+ cwd: process.cwd(),
504
+ run: spawnRunner,
505
+ wranglerArgs: invocation.wranglerArgs,
506
+ });
507
+ }
508
+
509
+ if (import.meta.main) {
510
+ try {
511
+ await main();
512
+ } catch (error) {
513
+ process.stderr.write(
514
+ `${error instanceof Error ? error.message : String(error)}\n`
515
+ );
516
+ process.exitCode = 1;
517
+ }
518
+ }
@@ -0,0 +1,179 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ addExampleKeys,
5
+ formatEnv,
6
+ formatLine,
7
+ parseValues,
8
+ planEnv,
9
+ removeKey,
10
+ setKey,
11
+ splitLines,
12
+ } from "./dotenv.ts";
13
+
14
+ describe("parseValues", () => {
15
+ it("reads plain, quoted and exported lines", () => {
16
+ const values = parseValues([
17
+ "A=1",
18
+ 'B="two"',
19
+ "C='three'",
20
+ "export D=4",
21
+ "# E=5",
22
+ "",
23
+ ]);
24
+ assert.deepEqual(
25
+ [...values],
26
+ [
27
+ ["A", "1"],
28
+ ["B", "two"],
29
+ ["C", "three"],
30
+ ["D", "4"],
31
+ ]
32
+ );
33
+ });
34
+
35
+ it("keeps the last value of a repeated key", () => {
36
+ assert.equal(parseValues(["A=1", "A=2"]).get("A"), "2");
37
+ });
38
+ });
39
+
40
+ describe("splitLines and formatEnv", () => {
41
+ it("round-trips a file without growing a blank line", () => {
42
+ const text = "# c\nA=1\n";
43
+ assert.equal(formatEnv(splitLines(text)), text);
44
+ });
45
+ });
46
+
47
+ describe("setKey and removeKey", () => {
48
+ it("writes the first line and drops the later ones", () => {
49
+ assert.deepEqual(setKey(["A=1", "B=2", "A=3"], "A", "9"), ["A=9", "B=2"]);
50
+ });
51
+
52
+ it("appends a key the file has never heard of", () => {
53
+ assert.deepEqual(setKey(["A=1"], "B", "2"), ["A=1", "B=2"]);
54
+ });
55
+
56
+ it("removes every line for a key", () => {
57
+ assert.deepEqual(removeKey(["A=1", "B=2", "A=3"], "A"), ["B=2"]);
58
+ });
59
+ });
60
+
61
+ describe("addExampleKeys", () => {
62
+ it("keeps a value the target set", () => {
63
+ assert.deepEqual(addExampleKeys(["A=mine"], ["A=default"]), ["A=mine"]);
64
+ });
65
+
66
+ it("fills a blank the example has a default for", () => {
67
+ assert.deepEqual(addExampleKeys(["A="], ["A=default"]), ["A=default"]);
68
+ });
69
+
70
+ it("leaves a blank alone when the example is blank too", () => {
71
+ assert.deepEqual(addExampleKeys(["A="], ["A="]), ["A="]);
72
+ });
73
+
74
+ it("appends a key the target lacks", () => {
75
+ assert.deepEqual(addExampleKeys(["A=1"], ["B=2"]), ["A=1", "B=2"]);
76
+ });
77
+ });
78
+
79
+ describe("formatLine", () => {
80
+ it("writes a plain value bare", () => {
81
+ assert.equal(formatLine("A", "one"), "A=one");
82
+ });
83
+
84
+ it("single-quotes a value with a space or a hash", () => {
85
+ assert.equal(formatLine("A", "a b"), "A='a b'");
86
+ assert.equal(formatLine("A", "a#b"), "A='a#b'");
87
+ });
88
+
89
+ it("refuses a value no quoting can carry", () => {
90
+ assert.throws(() => formatLine("A", "it's"), /single quote/);
91
+ assert.throws(() => formatLine("A", "a\nb"), /line break/);
92
+ });
93
+ });
94
+
95
+ describe("planEnv", () => {
96
+ const example = ["# what A is", "A=local", "", "# what B is", "B="];
97
+
98
+ it("keeps the example's comments and fills the values", () => {
99
+ const plan = planEnv({
100
+ example,
101
+ values: new Map([
102
+ ["A", "one"],
103
+ ["B", "two"],
104
+ ]),
105
+ blankOnPurpose: new Set(),
106
+ keep: new Map(),
107
+ omit: [],
108
+ });
109
+ assert.deepEqual(plan.lines, [
110
+ "# what A is",
111
+ "A=one",
112
+ "",
113
+ "# what B is",
114
+ "B=two",
115
+ ]);
116
+ assert.deepEqual(plan.missingKeys, []);
117
+ assert.deepEqual(plan.extraKeys, []);
118
+ });
119
+
120
+ it("names a key with no value and no blank-on-purpose marker", () => {
121
+ const plan = planEnv({
122
+ example,
123
+ values: new Map([["A", "one"]]),
124
+ blankOnPurpose: new Set(),
125
+ keep: new Map(),
126
+ omit: [],
127
+ });
128
+ assert.deepEqual(plan.missingKeys, ["B"]);
129
+ });
130
+
131
+ it("accepts a blank-on-purpose key with no value", () => {
132
+ const plan = planEnv({
133
+ example,
134
+ values: new Map([["A", "one"]]),
135
+ blankOnPurpose: new Set(["B"]),
136
+ keep: new Map(),
137
+ omit: [],
138
+ });
139
+ assert.deepEqual(plan.missingKeys, []);
140
+ });
141
+
142
+ it("lets a kept value win over the source", () => {
143
+ const plan = planEnv({
144
+ example: ["A=local"],
145
+ values: new Map([["A", "source"]]),
146
+ blankOnPurpose: new Set(),
147
+ keep: new Map([["A", "mine"]]),
148
+ omit: [],
149
+ });
150
+ assert.deepEqual(plan.lines, ["A=mine"]);
151
+ });
152
+
153
+ it("blanks an omitted key and never calls it missing", () => {
154
+ const plan = planEnv({
155
+ example: ["A=local"],
156
+ values: new Map([["A", "source"]]),
157
+ blankOnPurpose: new Set(),
158
+ keep: new Map(),
159
+ omit: ["A"],
160
+ });
161
+ assert.deepEqual(plan.lines, ["A="]);
162
+ assert.deepEqual(plan.missingKeys, []);
163
+ });
164
+
165
+ it("appends a key only the source holds and reports it", () => {
166
+ const plan = planEnv({
167
+ example: ["A=local"],
168
+ values: new Map([
169
+ ["A", "one"],
170
+ ["Z", "extra"],
171
+ ]),
172
+ blankOnPurpose: new Set(),
173
+ keep: new Map(),
174
+ omit: [],
175
+ });
176
+ assert.deepEqual(plan.lines, ["A=one", "Z=extra"]);
177
+ assert.deepEqual(plan.extraKeys, ["Z"]);
178
+ });
179
+ });