create-ailk 0.15.0 → 0.16.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 (127) hide show
  1. package/README.md +124 -0
  2. package/dist/add-module.js +52 -7
  3. package/dist/add-route.js +8 -0
  4. package/dist/adopt.d.ts +74 -0
  5. package/dist/adopt.js +314 -0
  6. package/dist/cli.js +82 -2
  7. package/dist/derive-architecture.d.ts +22 -0
  8. package/dist/derive-architecture.js +58 -1
  9. package/dist/lib/declared-env.d.ts +142 -0
  10. package/dist/lib/declared-env.js +985 -0
  11. package/dist/lib/env-catalog.d.ts +17 -0
  12. package/dist/lib/env-catalog.js +234 -0
  13. package/dist/lib/extract-module-bundle.d.ts +9 -0
  14. package/dist/lib/extract-module-bundle.js +18 -1
  15. package/dist/lib/module-tier.d.ts +2 -0
  16. package/dist/lib/module-tier.js +4 -0
  17. package/dist/module-architecture.js +76 -0
  18. package/dist/parse-args.d.ts +5 -0
  19. package/dist/parse-args.js +17 -1
  20. package/dist/programmatic.d.ts +8 -0
  21. package/dist/programmatic.js +78 -9
  22. package/dist/seed-flows.d.ts +65 -0
  23. package/dist/seed-flows.js +350 -0
  24. package/dist/site-manifest.d.ts +43 -0
  25. package/dist/site-manifest.js +98 -3
  26. package/dist/site-roster.d.ts +13 -0
  27. package/dist/site-roster.js +22 -8
  28. package/dist/spoke-checkout.d.ts +42 -0
  29. package/dist/spoke-checkout.js +359 -0
  30. package/dist/surfaces.js +7 -0
  31. package/dist/sync-routes.js +23 -8
  32. package/dist/topology.d.ts +124 -9
  33. package/dist/topology.js +449 -40
  34. package/package.json +3 -3
  35. package/templates/.env.example +10 -0
  36. package/templates/apps/api/CLAUDE.md +19 -4
  37. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
  38. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
  39. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
  40. package/templates/apps/api/src/bin/spokes.ts +62 -1
  41. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
  42. package/templates/apps/api/src/config/index.ts +35 -0
  43. package/templates/apps/api/src/lib/__mocks__/prisma.ts +89 -0
  44. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
  45. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
  46. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
  47. package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
  48. package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
  49. package/templates/apps/api/src/lib/spoke-store.ts +25 -3
  50. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
  51. package/templates/apps/api/src/middleware/auth.ts +126 -0
  52. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
  53. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
  54. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +7 -0
  55. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
  56. package/templates/apps/api/src/routes/webhooks/stripe.ts +72 -1
  57. package/templates/apps/api/src/server.ts +105 -0
  58. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
  59. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
  60. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +293 -0
  61. package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
  62. package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
  63. package/templates/apps/api/src/services/lead-routing.ts +131 -2
  64. package/templates/apps/api/src/services/person.ts +541 -0
  65. package/templates/apps/web/.env.example +58 -5
  66. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
  69. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
  70. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
  72. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
  73. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
  74. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
  75. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
  76. package/templates/apps/web/app/[locale]/layout.tsx +28 -15
  77. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
  78. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
  79. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
  80. package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
  81. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
  82. package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
  83. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +15 -0
  84. package/templates/apps/web/app/openapi.json/route.ts +3 -6
  85. package/templates/apps/web/auth.ts +29 -2
  86. package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
  87. package/templates/apps/web/components/signed-in-nav.tsx +17 -3
  88. package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
  89. package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
  90. package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
  91. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
  92. package/templates/apps/web/lib/__tests__/relay.test.ts +27 -0
  93. package/templates/apps/web/lib/account-config.ts +130 -1
  94. package/templates/apps/web/lib/api-origin.generated.ts +11 -0
  95. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
  96. package/templates/apps/web/lib/hub-session/config.ts +104 -0
  97. package/templates/apps/web/lib/hub-session/index.ts +23 -0
  98. package/templates/apps/web/lib/hub-session/seal.ts +82 -0
  99. package/templates/apps/web/lib/hub-session/session.ts +180 -0
  100. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
  101. package/templates/apps/web/lib/listings-config.ts +31 -0
  102. package/templates/apps/web/lib/listings.tsx +7 -3
  103. package/templates/apps/web/lib/meeting-slugs.ts +11 -1
  104. package/templates/apps/web/lib/next-path.ts +35 -0
  105. package/templates/apps/web/lib/relay.ts +3 -1
  106. package/templates/apps/web/lib/server-api.ts +11 -1
  107. package/templates/apps/web/lib/topology.generated.ts +15 -5
  108. package/templates/apps/web/messages/en.json +32 -3
  109. package/templates/database/CHANGELOG.md +26 -0
  110. package/templates/database/__tests__/agents.db.test.ts +25 -5
  111. package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
  112. package/templates/database/__tests__/member-area.db.test.ts +245 -0
  113. package/templates/database/__tests__/person.db.test.ts +584 -0
  114. package/templates/database/auth/schema.prisma +3 -2
  115. package/templates/database/config/schema.prisma +13 -0
  116. package/templates/database/inbox/schema.prisma +98 -0
  117. package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
  118. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
  119. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
  120. package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
  121. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
  122. package/templates/database/package.json +1 -1
  123. package/templates/database/scripts/person-backfill.sql +132 -0
  124. package/templates/database/tenancy.ts +53 -0
  125. package/templates/tools/check-declared-env.ts +37 -0
  126. package/templates/tools/declared-env.ts +1163 -0
  127. package/versions.json +35 -35
@@ -0,0 +1,1163 @@
1
+ /**
2
+ * The declared-env check (#7874) — every environment variable this site's
3
+ * code reads has a row in `project.yaml` `env_contract`, and a spoke reads
4
+ * no `NEXT_PUBLIC_AILK_API_URL` (a spoke's hub origin is server-only).
5
+ *
6
+ * ONE file, run in two places (spec D6): create-ailk imports it to write a
7
+ * spoke's rows, and a spoke scaffold carries a byte-identical copy as
8
+ * `tools/declared-env.ts`, which `tools/check-declared-env.ts` runs as
9
+ * `pnpm env:declared`. So it imports only Node built-ins, and nothing in it
10
+ * may contain a double open brace (the scaffold copy substitutes markers in
11
+ * `.ts` files).
12
+ *
13
+ * - `findEnvReads` — the literal `process.env` reads in one source file
14
+ * (D3), from a line-bounded tokenizer, never a regex over raw text;
15
+ * - `listScannedFiles` — the files a site's reads are collected from (D4);
16
+ * - `readEnvContract` — the `env_contract:` block, read without a YAML
17
+ * dependency (D1/D2);
18
+ * - `readManifestTopology` — the site's topology from its manifest (D8);
19
+ * - `checkDeclaredEnv` — the check itself (D5);
20
+ * - `planEnvRows` / `appendEnvRows` — the writer: appends a row for every
21
+ * undeclared read, never edits or removes one (D7).
22
+ *
23
+ * Spec: docs/specs/7874-declared-env-check-spec.md. Edit this file, never the
24
+ * scaffold copy: `scripts/build-templates.mjs` rewrites
25
+ * `template-overlay/tools/declared-env.ts` from it.
26
+ */
27
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
28
+ import { join } from "node:path";
29
+
30
+ /** Names the platform sets, which need no row (D4). Fixed: a site cannot extend it. */
31
+ export const PLATFORM_ENV_NAMES: readonly string[] = [
32
+ "NODE_ENV",
33
+ "NEXT_RUNTIME",
34
+ "NEXT_PHASE",
35
+ "CI",
36
+ "PORT",
37
+ "VERCEL",
38
+ ];
39
+
40
+ /** Every name with one of these prefixes is a platform name too (D4). */
41
+ export const PLATFORM_ENV_PREFIXES: readonly string[] = ["VERCEL_"];
42
+
43
+ /** Names a spoke may neither read nor declare (D5): its hub origin is server-only. */
44
+ export const SPOKE_REFUSED_ENV: readonly string[] = [
45
+ "NEXT_PUBLIC_AILK_API_URL",
46
+ ];
47
+
48
+ /** The server-only name a spoke reaches its hub through. */
49
+ const SPOKE_HUB_ORIGIN_ENV = "AILK_API_URL";
50
+
51
+ /** Whether `name` is set by the platform and so needs no row. */
52
+ export function isPlatformEnvName(name: string): boolean {
53
+ return (
54
+ PLATFORM_ENV_NAMES.includes(name) ||
55
+ PLATFORM_ENV_PREFIXES.some((prefix) => name.startsWith(prefix))
56
+ );
57
+ }
58
+
59
+ /** A site's topology. Restated from `topology.ts`: the scaffold copy cannot import it. */
60
+ export type SiteTopology = "hub" | "spoke";
61
+
62
+ // ── D3 — what counts as a read ──────────────────────────────────────────────
63
+
64
+ /** One literal env read: the variable name and its 1-based line. */
65
+ export interface EnvRead {
66
+ name: string;
67
+ line: number;
68
+ }
69
+
70
+ type TokenKind = "ident" | "string" | "punct" | "other";
71
+
72
+ interface Token {
73
+ kind: TokenKind;
74
+ /** The identifier, the punctuator, or a string literal's decoded value. */
75
+ value: string;
76
+ line: number;
77
+ }
78
+
79
+ /** Keywords after which a `/` starts a regex literal, not a division. */
80
+ const REGEX_AFTER_KEYWORDS = new Set([
81
+ "return",
82
+ "typeof",
83
+ "instanceof",
84
+ "case",
85
+ "in",
86
+ "of",
87
+ "new",
88
+ "delete",
89
+ "void",
90
+ "throw",
91
+ "yield",
92
+ "await",
93
+ "else",
94
+ "do",
95
+ ]);
96
+
97
+ const IDENT_START = /[A-Za-z_$]/;
98
+ const IDENT_PART = /[A-Za-z0-9_$]/;
99
+
100
+ /**
101
+ * The code tokens of `source`. Comments, string text (its decoded value is
102
+ * kept as one `string` token), template-literal text and regex literals are
103
+ * never tokenized as code; a template's `${…}` expressions are. A string or
104
+ * regex literal ends at a newline, so a mis-read apostrophe (JSX text) costs
105
+ * at most the rest of its own line.
106
+ */
107
+ function tokenize(source: string): Token[] {
108
+ const tokens: Token[] = [];
109
+ /** One entry per open template literal: the brace depth its `${` opened at. */
110
+ const templates: number[] = [];
111
+ let depth = 0;
112
+ let line = 1;
113
+ let i = 0;
114
+ const n = source.length;
115
+
116
+ const push = (kind: TokenKind, value: string, at: number): void => {
117
+ tokens.push({ kind, value, line: at });
118
+ };
119
+ const prevAllowsDivision = (): boolean => {
120
+ const prev = tokens[tokens.length - 1];
121
+ if (prev === undefined) return false;
122
+ if (prev.kind === "ident") return !REGEX_AFTER_KEYWORDS.has(prev.value);
123
+ if (prev.kind === "string" || prev.kind === "other") return true;
124
+ // `<` before `/` is a JSX closing tag (`</a>`), never a regex.
125
+ return (
126
+ prev.value === ")" ||
127
+ prev.value === "]" ||
128
+ prev.value === "}" ||
129
+ prev.value === "<"
130
+ );
131
+ };
132
+
133
+ /** Skip template text from `i` to the closing backtick or a `${`. */
134
+ const templateText = (): void => {
135
+ while (i < n) {
136
+ const c = source[i];
137
+ if (c === "\\") {
138
+ if (source[i + 1] === "\n") line++;
139
+ i += 2;
140
+ continue;
141
+ }
142
+ if (c === "`") {
143
+ i++;
144
+ return;
145
+ }
146
+ if (c === "$" && source[i + 1] === "{") {
147
+ i += 2;
148
+ templates.push(depth);
149
+ depth++;
150
+ return;
151
+ }
152
+ if (c === "\n") line++;
153
+ i++;
154
+ }
155
+ };
156
+
157
+ while (i < n) {
158
+ const c = source[i] ?? "";
159
+ const next = source[i + 1] ?? "";
160
+
161
+ if (c === "\n") {
162
+ line++;
163
+ i++;
164
+ continue;
165
+ }
166
+ if (c === " " || c === "\t" || c === "\r" || c === "\f" || c === "\v") {
167
+ i++;
168
+ continue;
169
+ }
170
+ // Comments.
171
+ if (c === "/" && next === "/") {
172
+ while (i < n && source[i] !== "\n") i++;
173
+ continue;
174
+ }
175
+ if (c === "/" && next === "*") {
176
+ i += 2;
177
+ while (i < n && !(source[i] === "*" && source[i + 1] === "/")) {
178
+ if (source[i] === "\n") line++;
179
+ i++;
180
+ }
181
+ i += 2;
182
+ continue;
183
+ }
184
+ // String literals — decoded, ending at a newline.
185
+ if (c === '"' || c === "'") {
186
+ const start = line;
187
+ let value = "";
188
+ i++;
189
+ while (i < n && source[i] !== c && source[i] !== "\n") {
190
+ if (source[i] === "\\" && i + 1 < n && source[i + 1] !== "\n") {
191
+ value += source[i + 1];
192
+ i += 2;
193
+ continue;
194
+ }
195
+ value += source[i];
196
+ i++;
197
+ }
198
+ if (source[i] === c) i++;
199
+ push("string", value, start);
200
+ continue;
201
+ }
202
+ // Template literals — the text is skipped, `${…}` is code.
203
+ if (c === "`") {
204
+ i++;
205
+ templateText();
206
+ push("other", "`", line);
207
+ continue;
208
+ }
209
+ // Regex literals — ending at a newline.
210
+ if (c === "/" && !prevAllowsDivision()) {
211
+ i++;
212
+ let inClass = false;
213
+ while (i < n && source[i] !== "\n") {
214
+ const r = source[i];
215
+ if (r === "\\") {
216
+ i += 2;
217
+ continue;
218
+ }
219
+ if (r === "[") inClass = true;
220
+ else if (r === "]") inClass = false;
221
+ else if (r === "/" && !inClass) {
222
+ i++;
223
+ break;
224
+ }
225
+ i++;
226
+ }
227
+ while (i < n && IDENT_PART.test(source[i] ?? "")) i++;
228
+ push("other", "/re/", line);
229
+ continue;
230
+ }
231
+ // Identifiers.
232
+ if (IDENT_START.test(c)) {
233
+ let j = i + 1;
234
+ while (j < n && IDENT_PART.test(source[j] ?? "")) j++;
235
+ push("ident", source.slice(i, j), line);
236
+ i = j;
237
+ continue;
238
+ }
239
+ // Numbers.
240
+ if (/[0-9]/.test(c) || (c === "." && /[0-9]/.test(next))) {
241
+ let j = i + 1;
242
+ while (j < n && /[0-9A-Za-z_.]/.test(source[j] ?? "")) j++;
243
+ push("other", source.slice(i, j), line);
244
+ i = j;
245
+ continue;
246
+ }
247
+ // Punctuators.
248
+ if (c === "?" && next === "." && !/[0-9]/.test(source[i + 2] ?? "")) {
249
+ push("punct", "?.", line);
250
+ i += 2;
251
+ continue;
252
+ }
253
+ if (c === "." && next === "." && source[i + 2] === ".") {
254
+ push("punct", "...", line);
255
+ i += 3;
256
+ continue;
257
+ }
258
+ if (c === "{") {
259
+ depth++;
260
+ push("punct", c, line);
261
+ i++;
262
+ continue;
263
+ }
264
+ if (c === "}") {
265
+ depth--;
266
+ if (templates.length > 0 && templates[templates.length - 1] === depth) {
267
+ templates.pop();
268
+ i++;
269
+ templateText();
270
+ continue;
271
+ }
272
+ push("punct", c, line);
273
+ i++;
274
+ continue;
275
+ }
276
+ if (c === "=" && next === "=") {
277
+ // `==` / `===` are not the destructuring `=`.
278
+ let j = i;
279
+ while (source[j] === "=") j++;
280
+ push("punct", source.slice(i, j), line);
281
+ i = j;
282
+ continue;
283
+ }
284
+ if (c === "=" && next === ">") {
285
+ push("punct", "=>", line);
286
+ i += 2;
287
+ continue;
288
+ }
289
+ push("punct", c, line);
290
+ i++;
291
+ }
292
+ return tokens;
293
+ }
294
+
295
+ /** `process.env` / `process?.env` starting at `tokens[i]`: the index after `env`. */
296
+ function processEnvAt(tokens: Token[], i: number): number | undefined {
297
+ const a = tokens[i];
298
+ const dot = tokens[i + 1];
299
+ const env = tokens[i + 2];
300
+ if (a?.kind !== "ident" || a.value !== "process") return undefined;
301
+ if (dot?.kind !== "punct" || (dot.value !== "." && dot.value !== "?.")) {
302
+ return undefined;
303
+ }
304
+ if (env?.kind !== "ident" || env.value !== "env") return undefined;
305
+ // `x.process.env` is some other object's `process` property.
306
+ const before = tokens[i - 1];
307
+ if (
308
+ before?.kind === "punct" &&
309
+ (before.value === "." || before.value === "?.")
310
+ ) {
311
+ return undefined;
312
+ }
313
+ return i + 3;
314
+ }
315
+
316
+ /** The keys of the object pattern whose closing `}` is `tokens[close]`. */
317
+ function patternKeys(tokens: Token[], close: number): EnvRead[] {
318
+ let depth = 0;
319
+ let open = -1;
320
+ for (let k = close; k >= 0; k--) {
321
+ const t = tokens[k];
322
+ if (t?.kind !== "punct") continue;
323
+ if (t.value === "}" || t.value === "]" || t.value === ")") depth++;
324
+ else if (t.value === "{" || t.value === "[" || t.value === "(") {
325
+ depth--;
326
+ if (depth === 0) {
327
+ open = k;
328
+ break;
329
+ }
330
+ }
331
+ }
332
+ if (open === -1 || tokens[open]?.value !== "{") return [];
333
+ const keys: EnvRead[] = [];
334
+ let level = 0;
335
+ let expectKey = true;
336
+ for (let k = open + 1; k < close; k++) {
337
+ const t = tokens[k];
338
+ if (t === undefined) break;
339
+ if (t.kind === "punct") {
340
+ if (t.value === "{" || t.value === "[" || t.value === "(") level++;
341
+ else if (t.value === "}" || t.value === "]" || t.value === ")") level--;
342
+ else if (t.value === "," && level === 0) {
343
+ expectKey = true;
344
+ continue;
345
+ } else if (t.value === "..." && level === 0) {
346
+ expectKey = false;
347
+ continue;
348
+ }
349
+ }
350
+ if (
351
+ level === 0 &&
352
+ expectKey &&
353
+ (t.kind === "ident" || t.kind === "string")
354
+ ) {
355
+ keys.push({ name: t.value, line: t.line });
356
+ }
357
+ expectKey = false;
358
+ }
359
+ return keys;
360
+ }
361
+
362
+ /**
363
+ * Every literal env read in `source` (D3): `process.env.NAME`,
364
+ * `process.env["NAME"]` / `['NAME']`, either with `?.` in place of a `.`,
365
+ * and each key of an object pattern destructured from `process.env`. A
366
+ * computed `process.env[expr]` is skipped. In source order.
367
+ */
368
+ export function findEnvReads(source: string): EnvRead[] {
369
+ const tokens = tokenize(source);
370
+ const reads: EnvRead[] = [];
371
+ for (let i = 0; i < tokens.length; i++) {
372
+ const after = processEnvAt(tokens, i);
373
+ if (after === undefined) continue;
374
+ const t = tokens[after];
375
+ const u = tokens[after + 1];
376
+ const v = tokens[after + 2];
377
+ const w = tokens[after + 3];
378
+ if (t?.kind === "punct" && (t.value === "." || t.value === "?.")) {
379
+ if (u?.kind === "ident") {
380
+ reads.push({ name: u.value, line: u.line });
381
+ continue;
382
+ }
383
+ if (
384
+ t.value === "?." &&
385
+ u?.value === "[" &&
386
+ v?.kind === "string" &&
387
+ w?.value === "]"
388
+ ) {
389
+ reads.push({ name: v.value, line: v.line });
390
+ continue;
391
+ }
392
+ }
393
+ if (
394
+ t?.kind === "punct" &&
395
+ t.value === "[" &&
396
+ u?.kind === "string" &&
397
+ v?.value === "]"
398
+ ) {
399
+ reads.push({ name: u.value, line: u.line });
400
+ continue;
401
+ }
402
+ // `{ A, B: b } = process.env` — the pattern ends just before the `=`.
403
+ const eq = tokens[i - 1];
404
+ const close = tokens[i - 2];
405
+ if (eq?.kind === "punct" && eq.value === "=" && close?.value === "}") {
406
+ reads.push(...patternKeys(tokens, i - 2));
407
+ }
408
+ }
409
+ return reads;
410
+ }
411
+
412
+ // ── D4 — what is scanned ────────────────────────────────────────────────────
413
+
414
+ const SCANNED_EXTENSIONS = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
415
+ const DECLARATION_FILE = /\.d\.[mc]?ts$/;
416
+ const SKIPPED_FILES = [
417
+ /\.test\.[^/]+$/,
418
+ /\.spec\.[^/]+$/,
419
+ /\.stories\.[^/]+$/,
420
+ /^jest\.config\.[^/]+$/,
421
+ /^jest\.setup\.[^/]+$/,
422
+ /^playwright\.config\.[^/]+$/,
423
+ ];
424
+
425
+ /** Directories never scanned, at any depth (D4). */
426
+ const SKIPPED_DIRECTORIES: readonly string[] = [
427
+ "node_modules",
428
+ ".next",
429
+ ".turbo",
430
+ ".git",
431
+ ".claude",
432
+ "dist",
433
+ "build",
434
+ "out",
435
+ "coverage",
436
+ "__tests__",
437
+ "__mocks__",
438
+ "e2e",
439
+ ];
440
+
441
+ /** The root-level directory never scanned: the checks themselves. */
442
+ const SKIPPED_ROOT_DIRECTORY = "tools";
443
+
444
+ /**
445
+ * The site-relative (`/`-separated) source files under `root` whose reads
446
+ * the check collects, sorted. Symbolic links are not followed.
447
+ */
448
+ export function listScannedFiles(root: string): string[] {
449
+ const out: string[] = [];
450
+ const walk = (dir: string, rel: string): void => {
451
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
452
+ const childRel = rel === "" ? entry.name : `${rel}/${entry.name}`;
453
+ if (entry.isDirectory()) {
454
+ if (SKIPPED_DIRECTORIES.includes(entry.name)) continue;
455
+ if (rel === "" && entry.name === SKIPPED_ROOT_DIRECTORY) continue;
456
+ walk(join(dir, entry.name), childRel);
457
+ } else if (entry.isFile()) {
458
+ if (!SCANNED_EXTENSIONS.test(entry.name)) continue;
459
+ if (DECLARATION_FILE.test(entry.name)) continue;
460
+ if (SKIPPED_FILES.some((re) => re.test(entry.name))) continue;
461
+ out.push(childRel);
462
+ }
463
+ }
464
+ };
465
+ walk(root, "");
466
+ return out.sort();
467
+ }
468
+
469
+ /** A read located in the site: the file it sits in and its line. */
470
+ export interface SiteEnvRead extends EnvRead {
471
+ file: string;
472
+ }
473
+
474
+ /** Every read in the site at `root`, and every file that could not be read. */
475
+ export function collectSiteReads(root: string): {
476
+ reads: SiteEnvRead[];
477
+ unreadable: Array<{ file: string; reason: string }>;
478
+ } {
479
+ const reads: SiteEnvRead[] = [];
480
+ const unreadable: Array<{ file: string; reason: string }> = [];
481
+ for (const file of listScannedFiles(root)) {
482
+ let source: string;
483
+ try {
484
+ source = readFileSync(join(root, file), "utf8");
485
+ } catch (err) {
486
+ unreadable.push({ file, reason: (err as Error).message });
487
+ continue;
488
+ }
489
+ for (const read of findEnvReads(source)) reads.push({ ...read, file });
490
+ }
491
+ return { reads, unreadable };
492
+ }
493
+
494
+ // ── D1/D2 — the env_contract block ──────────────────────────────────────────
495
+
496
+ /** One `env_contract` row as written. */
497
+ export interface EnvContractRow {
498
+ name: string;
499
+ description: string;
500
+ secret: boolean;
501
+ scope?: EnvScope;
502
+ /** The 1-based `project.yaml` line the row's item starts on. */
503
+ line: number;
504
+ }
505
+
506
+ /** A fault in the block: the 1-based `project.yaml` line and what is wrong. */
507
+ export interface EnvContractFault {
508
+ line: number;
509
+ message: string;
510
+ }
511
+
512
+ export type EnvContract =
513
+ | { present: false }
514
+ | { present: true; rows: EnvContractRow[]; faults: EnvContractFault[] };
515
+
516
+ export const ENV_SCOPES = ["web", "api", "mcp"] as const;
517
+ export type EnvScope = (typeof ENV_SCOPES)[number];
518
+
519
+ const ROW_KEYS = ["name", "description", "secret", "scope"] as const;
520
+ const NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
521
+ const KEY_LINE = /^env_contract:(.*)$/;
522
+
523
+ /** A raw value: its text, and whether it was quoted or a block scalar. */
524
+ interface RawValue {
525
+ text: string;
526
+ plain: boolean;
527
+ }
528
+
529
+ /** The parsed block, with the layout the writer needs. */
530
+ interface ParsedContract {
531
+ /** 0-based index of the `env_contract:` line. */
532
+ keyIndex: number;
533
+ /** Whether the key's value is the flow `[]`. */
534
+ flowEmpty: boolean;
535
+ /** Column items start at, when the block has one. */
536
+ itemIndent: number | undefined;
537
+ /** 0-based index of the block's last item-content line, when it has one. */
538
+ lastItemIndex: number | undefined;
539
+ rows: EnvContractRow[];
540
+ faults: EnvContractFault[];
541
+ }
542
+
543
+ /** Strip a trailing ` # comment` from a plain scalar. */
544
+ function stripComment(text: string): string {
545
+ const m = /(^|\s)#/.exec(text);
546
+ return (m === null ? text : text.slice(0, m.index)).trim();
547
+ }
548
+
549
+ /** Decode a scalar value; `undefined` is a fault named by `fault`. */
550
+ function decodeScalar(
551
+ raw: string,
552
+ fault: (message: string) => void,
553
+ ): RawValue | undefined {
554
+ const text = raw.trim();
555
+ if (text.startsWith('"')) {
556
+ const end = text.lastIndexOf('"');
557
+ const rest = text.slice(end + 1).trim();
558
+ if (end === 0 || (rest !== "" && !rest.startsWith("#"))) {
559
+ fault("an unterminated double-quoted value");
560
+ return undefined;
561
+ }
562
+ try {
563
+ return {
564
+ text: JSON.parse(text.slice(0, end + 1)) as string,
565
+ plain: false,
566
+ };
567
+ } catch {
568
+ fault("a double-quoted value that is not a valid string");
569
+ return undefined;
570
+ }
571
+ }
572
+ if (text.startsWith("'")) {
573
+ let value = "";
574
+ let k = 1;
575
+ for (;;) {
576
+ if (k >= text.length) {
577
+ fault("an unterminated single-quoted value");
578
+ return undefined;
579
+ }
580
+ if (text[k] === "'") {
581
+ if (text[k + 1] === "'") {
582
+ value += "'";
583
+ k += 2;
584
+ continue;
585
+ }
586
+ break;
587
+ }
588
+ value += text[k];
589
+ k++;
590
+ }
591
+ const rest = text.slice(k + 1).trim();
592
+ if (rest !== "" && !rest.startsWith("#")) {
593
+ fault("text after a single-quoted value");
594
+ return undefined;
595
+ }
596
+ return { text: value, plain: false };
597
+ }
598
+ return { text: stripComment(text), plain: true };
599
+ }
600
+
601
+ function parseContract(text: string): ParsedContract | undefined {
602
+ const lines = text.split("\n").map((l) => l.replace(/\r$/, ""));
603
+ const keyIndexes = lines.flatMap((l, idx) => (KEY_LINE.test(l) ? [idx] : []));
604
+ const keyIndex = keyIndexes[0];
605
+ if (keyIndex === undefined) return undefined;
606
+
607
+ const faults: EnvContractFault[] = [];
608
+ const rows: EnvContractRow[] = [];
609
+ for (const dup of keyIndexes.slice(1)) {
610
+ faults.push({ line: dup + 1, message: "a second env_contract key" });
611
+ }
612
+ const inline = stripComment(KEY_LINE.exec(lines[keyIndex] ?? "")?.[1] ?? "");
613
+ const parsed: ParsedContract = {
614
+ keyIndex,
615
+ flowEmpty: inline === "[]",
616
+ itemIndent: undefined,
617
+ lastItemIndex: undefined,
618
+ rows,
619
+ faults,
620
+ };
621
+ if (inline !== "" && inline !== "[]") {
622
+ faults.push({
623
+ line: keyIndex + 1,
624
+ message: "env_contract must be a block list of rows (or [] when empty)",
625
+ });
626
+ return parsed;
627
+ }
628
+
629
+ interface Item {
630
+ line: number;
631
+ keyCol: number;
632
+ values: Map<string, RawValue>;
633
+ /** The key whose block scalar is being read, and its indicator. */
634
+ scalar:
635
+ | { key: string; col: number | undefined; parts: string[]; fold: boolean }
636
+ | undefined;
637
+ }
638
+ let item: Item | undefined;
639
+ const items: Item[] = [];
640
+
641
+ const setKey = (it: Item, idx: number, body: string): void => {
642
+ const m = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:(?:\s+(.*)|\s*)$/.exec(body);
643
+ if (m === null) {
644
+ faults.push({
645
+ line: idx + 1,
646
+ message: `"${body.trim()}" is not a key: value pair`,
647
+ });
648
+ return;
649
+ }
650
+ const key = m[1] ?? "";
651
+ const rawValue = m[2] ?? "";
652
+ if (it.values.has(key)) {
653
+ faults.push({
654
+ line: idx + 1,
655
+ message: `the row repeats the key "${key}"`,
656
+ });
657
+ return;
658
+ }
659
+ const indicator = /^([|>])[+-]?\s*(#.*)?$/.exec(rawValue.trim());
660
+ if (indicator !== null) {
661
+ it.values.set(key, { text: "", plain: false });
662
+ it.scalar = {
663
+ key,
664
+ col: undefined,
665
+ parts: [],
666
+ fold: indicator[1] === ">",
667
+ };
668
+ return;
669
+ }
670
+ const value = decodeScalar(rawValue, (message) =>
671
+ faults.push({ line: idx + 1, message: `${key} has ${message}` }),
672
+ );
673
+ if (value !== undefined) it.values.set(key, value);
674
+ };
675
+
676
+ const finishScalar = (it: Item): void => {
677
+ if (it.scalar === undefined) return;
678
+ const joined = it.scalar.parts.join(it.scalar.fold ? " " : "\n").trim();
679
+ it.values.set(it.scalar.key, { text: joined, plain: false });
680
+ it.scalar = undefined;
681
+ };
682
+
683
+ for (let idx = keyIndex + 1; idx < lines.length; idx++) {
684
+ const line = lines[idx] ?? "";
685
+ const trimmed = line.trim();
686
+ const indent = line.length - line.trimStart().length;
687
+ if (indent === 0 && trimmed !== "" && !trimmed.startsWith("#")) {
688
+ if (trimmed.startsWith("-") && !parsed.flowEmpty) {
689
+ faults.push({
690
+ line: idx + 1,
691
+ message: "env_contract rows must be indented under the key",
692
+ });
693
+ }
694
+ break;
695
+ }
696
+ // A block scalar takes every more-indented line, blank ones included.
697
+ if (item?.scalar !== undefined) {
698
+ if (trimmed === "") {
699
+ item.scalar.parts.push("");
700
+ continue;
701
+ }
702
+ if (indent > item.keyCol) {
703
+ item.scalar.col ??= indent;
704
+ item.scalar.parts.push(line.slice(Math.min(indent, item.scalar.col)));
705
+ parsed.lastItemIndex = idx;
706
+ continue;
707
+ }
708
+ finishScalar(item);
709
+ }
710
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
711
+ if (parsed.flowEmpty) {
712
+ faults.push({
713
+ line: idx + 1,
714
+ message: "env_contract: [] cannot also have rows",
715
+ });
716
+ continue;
717
+ }
718
+ const dash = /^(\s*)-(?:(\s+)(.*))?$/.exec(line);
719
+ if (
720
+ dash !== null &&
721
+ (parsed.itemIndent === undefined || indent === parsed.itemIndent)
722
+ ) {
723
+ parsed.itemIndent ??= indent;
724
+ const gap = dash[2] ?? " ";
725
+ item = {
726
+ line: idx + 1,
727
+ keyCol: indent + 1 + gap.length,
728
+ values: new Map(),
729
+ scalar: undefined,
730
+ };
731
+ items.push(item);
732
+ parsed.lastItemIndex = idx;
733
+ const body = dash[3] ?? "";
734
+ if (body.trim() !== "" && !body.trim().startsWith("#"))
735
+ setKey(item, idx, body);
736
+ continue;
737
+ }
738
+ if (item !== undefined && indent === item.keyCol) {
739
+ setKey(item, idx, line.slice(indent));
740
+ parsed.lastItemIndex = idx;
741
+ continue;
742
+ }
743
+ faults.push({
744
+ line: idx + 1,
745
+ message: `unexpected line "${trimmed}" — each row is "- name: …" with its keys aligned under it`,
746
+ });
747
+ }
748
+ if (item !== undefined) finishScalar(item);
749
+
750
+ const seen = new Map<string, number>();
751
+ for (const it of items) {
752
+ const fault = (message: string): void => {
753
+ faults.push({ line: it.line, message });
754
+ };
755
+ for (const key of it.values.keys()) {
756
+ if (!(ROW_KEYS as readonly string[]).includes(key)) {
757
+ fault(
758
+ `the row has an unknown key "${key}" (allowed: ${ROW_KEYS.join(", ")})`,
759
+ );
760
+ }
761
+ }
762
+ const name = it.values.get("name")?.text;
763
+ const description = it.values.get("description");
764
+ const secret = it.values.get("secret");
765
+ const scope = it.values.get("scope");
766
+ let valid = true;
767
+ if (name === undefined || name === "") {
768
+ fault("the row has no name");
769
+ valid = false;
770
+ } else if (!NAME_RE.test(name)) {
771
+ fault(`the row's name "${name}" is not an env var name (A-Z, 0-9, _)`);
772
+ valid = false;
773
+ } else if (seen.has(name)) {
774
+ fault(`${name} already has a row at project.yaml:${seen.get(name)}`);
775
+ valid = false;
776
+ }
777
+ if (description === undefined) {
778
+ fault(
779
+ `the row${name !== undefined ? ` for ${name}` : ""} has no description`,
780
+ );
781
+ } else if (description.text.trim() === "") {
782
+ fault(
783
+ `the row${name !== undefined ? ` for ${name}` : ""} has an empty description`,
784
+ );
785
+ }
786
+ let secretValue = false;
787
+ if (secret === undefined) {
788
+ fault(
789
+ `the row${name !== undefined ? ` for ${name}` : ""} has no secret: true or false`,
790
+ );
791
+ } else if (
792
+ !secret.plain ||
793
+ (secret.text !== "true" && secret.text !== "false")
794
+ ) {
795
+ fault(`secret must be true or false, got ${JSON.stringify(secret.text)}`);
796
+ } else {
797
+ secretValue = secret.text === "true";
798
+ }
799
+ if (secretValue && name?.startsWith("NEXT_PUBLIC_") === true) {
800
+ fault(
801
+ `${name} is browser-exposed, so it is never a secret: set secret: false`,
802
+ );
803
+ }
804
+ if (
805
+ scope !== undefined &&
806
+ !(ENV_SCOPES as readonly string[]).includes(scope.text)
807
+ ) {
808
+ fault(
809
+ `scope must be one of ${ENV_SCOPES.join(", ")}, got ${JSON.stringify(scope.text)}`,
810
+ );
811
+ }
812
+ if (valid && name !== undefined) {
813
+ seen.set(name, it.line);
814
+ const row: EnvContractRow = {
815
+ name,
816
+ description: description?.text ?? "",
817
+ secret: secretValue,
818
+ line: it.line,
819
+ };
820
+ if (
821
+ scope !== undefined &&
822
+ (ENV_SCOPES as readonly string[]).includes(scope.text)
823
+ ) {
824
+ row.scope = scope.text as EnvScope;
825
+ }
826
+ rows.push(row);
827
+ }
828
+ }
829
+ faults.sort((a, b) => a.line - b.line);
830
+ return parsed;
831
+ }
832
+
833
+ /**
834
+ * The top-level `env_contract:` block of `projectYamlText` (D2): absent, or
835
+ * its rows and every fault in it. A row whose name is valid is in `rows`
836
+ * even when another of its keys is at fault, so its name still counts as
837
+ * declared; the fault is reported either way.
838
+ */
839
+ export function readEnvContract(projectYamlText: string): EnvContract {
840
+ const parsed = parseContract(projectYamlText);
841
+ if (parsed === undefined) return { present: false };
842
+ return { present: true, rows: parsed.rows, faults: parsed.faults };
843
+ }
844
+
845
+ // ── D8 — the site's topology ────────────────────────────────────────────────
846
+
847
+ /** The site manifest a templated scaffold writes. */
848
+ const SITE_MANIFEST_FILE = ".ailk-site-manifest.json";
849
+
850
+ /**
851
+ * The topology `.ailk-site-manifest.json` records under `root` (D8): a
852
+ * missing file or key reads `"hub"`; an unknown value or a file that is not
853
+ * JSON is a fault. The `readSiteTopology` rule in create-ailk's `topology.ts`.
854
+ */
855
+ export function readManifestTopology(
856
+ root: string,
857
+ ): { topology: SiteTopology } | { fault: string } {
858
+ const path = join(root, SITE_MANIFEST_FILE);
859
+ if (!existsSync(path)) return { topology: "hub" };
860
+ let doc: unknown;
861
+ try {
862
+ doc = JSON.parse(readFileSync(path, "utf8"));
863
+ } catch (err) {
864
+ return {
865
+ fault: `${SITE_MANIFEST_FILE} is not JSON: ${(err as Error).message}`,
866
+ };
867
+ }
868
+ const value =
869
+ typeof doc === "object" && doc !== null
870
+ ? (doc as Record<string, unknown>)["topology"]
871
+ : undefined;
872
+ if (value === undefined) return { topology: "hub" };
873
+ if (value === "hub" || value === "spoke") return { topology: value };
874
+ return {
875
+ fault: `${SITE_MANIFEST_FILE} records an unknown topology ${JSON.stringify(value)} (expected hub or spoke)`,
876
+ };
877
+ }
878
+
879
+ // ── D5 — the check ──────────────────────────────────────────────────────────
880
+
881
+ /** The check's result: one line per finding, and the line to print on a pass. */
882
+ export interface DeclaredEnvResult {
883
+ findings: string[];
884
+ summary: string;
885
+ }
886
+
887
+ const PROJECT_YAML = "project.yaml";
888
+
889
+ /**
890
+ * Check the site at `root` (D5): every literal env read has an
891
+ * `env_contract` row, every row is well-formed, and on a spoke
892
+ * `NEXT_PUBLIC_AILK_API_URL` is neither read nor declared. A hub with no
893
+ * block has nothing to check; a spoke with no block is a finding.
894
+ */
895
+ export function checkDeclaredEnv(root: string): DeclaredEnvResult {
896
+ const findings: string[] = [];
897
+ const yamlPath = join(root, PROJECT_YAML);
898
+ if (!existsSync(yamlPath)) {
899
+ return { findings: [`env: ${PROJECT_YAML}: missing`], summary: "" };
900
+ }
901
+ let yamlText: string;
902
+ try {
903
+ yamlText = readFileSync(yamlPath, "utf8");
904
+ } catch (err) {
905
+ return {
906
+ findings: [`env: ${PROJECT_YAML}: ${(err as Error).message}`],
907
+ summary: "",
908
+ };
909
+ }
910
+ const manifest = readManifestTopology(root);
911
+ if ("fault" in manifest) {
912
+ return {
913
+ findings: [`env: ${SITE_MANIFEST_FILE}: ${manifest.fault}`],
914
+ summary: "",
915
+ };
916
+ }
917
+ const contract = readEnvContract(yamlText);
918
+ if (!contract.present) {
919
+ if (manifest.topology === "hub") {
920
+ return {
921
+ findings: [],
922
+ summary: `env: no env_contract block in ${PROJECT_YAML} — nothing to check`,
923
+ };
924
+ }
925
+ return {
926
+ findings: [
927
+ `env: ${PROJECT_YAML} has no env_contract block; a spoke must declare the variables its code reads`,
928
+ ],
929
+ summary: "",
930
+ };
931
+ }
932
+
933
+ for (const fault of contract.faults) {
934
+ findings.push(`env: ${PROJECT_YAML}:${fault.line}: ${fault.message}`);
935
+ }
936
+ const { reads, unreadable } = collectSiteReads(root);
937
+ for (const { file, reason } of unreadable)
938
+ findings.push(`env: ${file}: ${reason}`);
939
+
940
+ const declared = new Map(contract.rows.map((row) => [row.name, row]));
941
+ const refused = new Set(
942
+ manifest.topology === "spoke" ? SPOKE_REFUSED_ENV : [],
943
+ );
944
+ const readFindings: Array<{
945
+ name: string;
946
+ file: string;
947
+ line: number;
948
+ text: string;
949
+ }> = [];
950
+ for (const name of refused) {
951
+ const row = declared.get(name);
952
+ if (row !== undefined) {
953
+ readFindings.push({
954
+ name,
955
+ file: PROJECT_YAML,
956
+ line: row.line,
957
+ text: refusal(name, `declared in ${PROJECT_YAML}:${row.line}`),
958
+ });
959
+ }
960
+ }
961
+ const names = new Set<string>();
962
+ for (const read of reads) {
963
+ if (isPlatformEnvName(read.name)) continue;
964
+ names.add(read.name);
965
+ if (refused.has(read.name)) {
966
+ readFindings.push({
967
+ ...read,
968
+ text: refusal(read.name, `read at ${read.file}:${read.line}`),
969
+ });
970
+ } else if (!declared.has(read.name)) {
971
+ readFindings.push({
972
+ ...read,
973
+ text: `env: ${read.name} is read at ${read.file}:${read.line} but ${PROJECT_YAML} has no env_contract row for it`,
974
+ });
975
+ }
976
+ }
977
+ readFindings.sort(
978
+ (a, b) =>
979
+ compare(a.name, b.name) ||
980
+ compare(
981
+ a.file === PROJECT_YAML ? "" : a.file,
982
+ b.file === PROJECT_YAML ? "" : b.file,
983
+ ) ||
984
+ a.line - b.line,
985
+ );
986
+ findings.push(...readFindings.map((f) => f.text));
987
+ return {
988
+ findings,
989
+ summary: `env: OK — ${names.size} names read, all declared`,
990
+ };
991
+ }
992
+
993
+ function refusal(name: string, where: string): string {
994
+ return `env: ${name} is refused on a spoke (${where}) — a spoke's hub origin is server-only; use ${SPOKE_HUB_ORIGIN_ENV}`;
995
+ }
996
+
997
+ function compare(a: string, b: string): number {
998
+ return a < b ? -1 : a > b ? 1 : 0;
999
+ }
1000
+
1001
+ // ── D7 — the writer ─────────────────────────────────────────────────────────
1002
+
1003
+ /** A block the writer cannot read, so it never appends to it (D7). */
1004
+ export class EnvContractError extends Error {
1005
+ constructor(message: string) {
1006
+ super(message);
1007
+ this.name = "EnvContractError";
1008
+ }
1009
+ }
1010
+
1011
+ /** What a row says about a name the writer knows (create-ailk's catalog). */
1012
+ export interface EnvCatalogEntry {
1013
+ description: string;
1014
+ secret: boolean;
1015
+ }
1016
+
1017
+ export interface PlanEnvRowsOptions {
1018
+ /** The site's topology — passed by the caller, never read from the manifest. */
1019
+ topology: SiteTopology;
1020
+ /** Described rows for known names; any other name gets a placeholder row. */
1021
+ catalog: Readonly<Record<string, EnvCatalogEntry>>;
1022
+ /**
1023
+ * Names the site depends on that an installed package reads on its behalf,
1024
+ * so the scan never sees them (#7838). Each gets a row when it has none,
1025
+ * with no reader and so no `scope`; a platform or refused name is skipped.
1026
+ */
1027
+ declare?: readonly string[];
1028
+ }
1029
+
1030
+ /** The header the writer puts above a block it creates. */
1031
+ const ENV_CONTRACT_HEADER: readonly string[] = [
1032
+ "# The environment variables this site's code reads. `pnpm env:declared`",
1033
+ "# checks every literal process.env read against these rows; create-ailk",
1034
+ "# appends rows and never edits one.",
1035
+ ];
1036
+
1037
+ /** The site-root app directories a row's `scope` names. */
1038
+ const SCOPE_DIRS: ReadonlyArray<[EnvScope, string]> = [
1039
+ ["web", "apps/web/"],
1040
+ ["api", "apps/api/"],
1041
+ ["mcp", "apps/mcp/"],
1042
+ ];
1043
+
1044
+ /**
1045
+ * `projectYamlText` with a row appended for every name the site at `root`
1046
+ * reads that has none (D7), sorted by name, after the existing rows. Platform
1047
+ * names, and on a spoke `SPOKE_REFUSED_ENV`, get no row. Pure: it reads the
1048
+ * tree, never writes. `added` names the rows it wrote.
1049
+ *
1050
+ * @throws EnvContractError when the existing block has a fault — the writer
1051
+ * never appends to a block it cannot read.
1052
+ */
1053
+ export function planEnvRows(
1054
+ root: string,
1055
+ projectYamlText: string,
1056
+ opts: PlanEnvRowsOptions,
1057
+ ): { text: string; added: string[] } {
1058
+ const parsed = parseContract(projectYamlText);
1059
+ if (parsed !== undefined && parsed.faults.length > 0) {
1060
+ const first = parsed.faults[0];
1061
+ throw new EnvContractError(
1062
+ `project.yaml:${first?.line ?? 0}: ${first?.message ?? "a fault"} — fix the env_contract block, then re-run`,
1063
+ );
1064
+ }
1065
+ const declared = new Set(parsed?.rows.map((row) => row.name) ?? []);
1066
+ const refused = new Set(opts.topology === "spoke" ? SPOKE_REFUSED_ENV : []);
1067
+ const files = new Map<string, string[]>();
1068
+ for (const read of collectSiteReads(root).reads) {
1069
+ if (isPlatformEnvName(read.name) || refused.has(read.name)) continue;
1070
+ if (declared.has(read.name)) continue;
1071
+ const list = files.get(read.name) ?? [];
1072
+ list.push(read.file);
1073
+ files.set(read.name, list);
1074
+ }
1075
+ for (const name of opts.declare ?? []) {
1076
+ if (isPlatformEnvName(name) || refused.has(name)) continue;
1077
+ if (declared.has(name) || files.has(name)) continue;
1078
+ files.set(name, []);
1079
+ }
1080
+ const added = [...files.keys()].sort(compare);
1081
+ if (parsed !== undefined && added.length === 0) {
1082
+ return { text: projectYamlText, added };
1083
+ }
1084
+
1085
+ const itemIndent = parsed?.itemIndent ?? 2;
1086
+ const pad = " ".repeat(itemIndent);
1087
+ const rowLines = added.flatMap((name) => {
1088
+ const readers = [...new Set(files.get(name) ?? [])].sort(compare);
1089
+ const entry = Object.prototype.hasOwnProperty.call(opts.catalog, name)
1090
+ ? opts.catalog[name]
1091
+ : undefined;
1092
+ const description =
1093
+ entry?.description ??
1094
+ `Read by ${readers[0] ?? "this site"}. Describe what it sets.`;
1095
+ const secret = entry?.secret ?? !name.startsWith("NEXT_PUBLIC_");
1096
+ const scopes = new Set(
1097
+ readers.map(
1098
+ (file) => SCOPE_DIRS.find(([, dir]) => file.startsWith(dir))?.[0],
1099
+ ),
1100
+ );
1101
+ const scope = scopes.size === 1 ? [...scopes][0] : undefined;
1102
+ return [
1103
+ `${pad}- name: ${name}`,
1104
+ `${pad} description: ${JSON.stringify(description)}`,
1105
+ `${pad} secret: ${secret ? "true" : "false"}`,
1106
+ ...(scope !== undefined ? [`${pad} scope: ${scope}`] : []),
1107
+ ];
1108
+ });
1109
+
1110
+ const lines = projectYamlText.split("\n");
1111
+ if (parsed === undefined) {
1112
+ const body =
1113
+ lines.length > 0 && lines[lines.length - 1] === ""
1114
+ ? lines.slice(0, -1)
1115
+ : lines;
1116
+ const block =
1117
+ rowLines.length > 0
1118
+ ? ["env_contract:", ...rowLines]
1119
+ : ["env_contract: []"];
1120
+ return {
1121
+ text: [...body, "", ...ENV_CONTRACT_HEADER, ...block, ""].join("\n"),
1122
+ added,
1123
+ };
1124
+ }
1125
+ if (parsed.flowEmpty) {
1126
+ const keyLine = lines[parsed.keyIndex] ?? "";
1127
+ lines[parsed.keyIndex] = keyLine.replace(
1128
+ /^env_contract:\s*\[\s*\]/,
1129
+ "env_contract:",
1130
+ );
1131
+ lines.splice(parsed.keyIndex + 1, 0, ...rowLines);
1132
+ return { text: lines.join("\n"), added };
1133
+ }
1134
+ const at = (parsed.lastItemIndex ?? parsed.keyIndex) + 1;
1135
+ lines.splice(at, 0, ...rowLines);
1136
+ return { text: lines.join("\n"), added };
1137
+ }
1138
+
1139
+ /** What `appendEnvRows` did to `<root>/project.yaml`. */
1140
+ export type AppendEnvRowsResult =
1141
+ | { state: "written"; added: string[] }
1142
+ | { state: "unchanged" }
1143
+ | { state: "no-project-yaml" };
1144
+
1145
+ /**
1146
+ * Plan and write `<root>/project.yaml`'s new rows (D7). Writes only when the
1147
+ * text changed, so a second run changes nothing; never edits or removes a
1148
+ * row. A site with no `project.yaml` is left alone.
1149
+ *
1150
+ * @throws EnvContractError on a block it cannot read (nothing is written).
1151
+ */
1152
+ export function appendEnvRows(
1153
+ root: string,
1154
+ opts: PlanEnvRowsOptions,
1155
+ ): AppendEnvRowsResult {
1156
+ const path = join(root, PROJECT_YAML);
1157
+ if (!existsSync(path)) return { state: "no-project-yaml" };
1158
+ const text = readFileSync(path, "utf8");
1159
+ const plan = planEnvRows(root, text, opts);
1160
+ if (plan.text === text) return { state: "unchanged" };
1161
+ writeFileSync(path, plan.text);
1162
+ return { state: "written", added: plan.added };
1163
+ }