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,985 @@
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
+ /** Names the platform sets, which need no row (D4). Fixed: a site cannot extend it. */
30
+ export const PLATFORM_ENV_NAMES = [
31
+ "NODE_ENV",
32
+ "NEXT_RUNTIME",
33
+ "NEXT_PHASE",
34
+ "CI",
35
+ "PORT",
36
+ "VERCEL",
37
+ ];
38
+ /** Every name with one of these prefixes is a platform name too (D4). */
39
+ export const PLATFORM_ENV_PREFIXES = ["VERCEL_"];
40
+ /** Names a spoke may neither read nor declare (D5): its hub origin is server-only. */
41
+ export const SPOKE_REFUSED_ENV = [
42
+ "NEXT_PUBLIC_AILK_API_URL",
43
+ ];
44
+ /** The server-only name a spoke reaches its hub through. */
45
+ const SPOKE_HUB_ORIGIN_ENV = "AILK_API_URL";
46
+ /** Whether `name` is set by the platform and so needs no row. */
47
+ export function isPlatformEnvName(name) {
48
+ return (PLATFORM_ENV_NAMES.includes(name) ||
49
+ PLATFORM_ENV_PREFIXES.some((prefix) => name.startsWith(prefix)));
50
+ }
51
+ /** Keywords after which a `/` starts a regex literal, not a division. */
52
+ const REGEX_AFTER_KEYWORDS = new Set([
53
+ "return",
54
+ "typeof",
55
+ "instanceof",
56
+ "case",
57
+ "in",
58
+ "of",
59
+ "new",
60
+ "delete",
61
+ "void",
62
+ "throw",
63
+ "yield",
64
+ "await",
65
+ "else",
66
+ "do",
67
+ ]);
68
+ const IDENT_START = /[A-Za-z_$]/;
69
+ const IDENT_PART = /[A-Za-z0-9_$]/;
70
+ /**
71
+ * The code tokens of `source`. Comments, string text (its decoded value is
72
+ * kept as one `string` token), template-literal text and regex literals are
73
+ * never tokenized as code; a template's `${…}` expressions are. A string or
74
+ * regex literal ends at a newline, so a mis-read apostrophe (JSX text) costs
75
+ * at most the rest of its own line.
76
+ */
77
+ function tokenize(source) {
78
+ const tokens = [];
79
+ /** One entry per open template literal: the brace depth its `${` opened at. */
80
+ const templates = [];
81
+ let depth = 0;
82
+ let line = 1;
83
+ let i = 0;
84
+ const n = source.length;
85
+ const push = (kind, value, at) => {
86
+ tokens.push({ kind, value, line: at });
87
+ };
88
+ const prevAllowsDivision = () => {
89
+ const prev = tokens[tokens.length - 1];
90
+ if (prev === undefined)
91
+ return false;
92
+ if (prev.kind === "ident")
93
+ return !REGEX_AFTER_KEYWORDS.has(prev.value);
94
+ if (prev.kind === "string" || prev.kind === "other")
95
+ return true;
96
+ // `<` before `/` is a JSX closing tag (`</a>`), never a regex.
97
+ return (prev.value === ")" ||
98
+ prev.value === "]" ||
99
+ prev.value === "}" ||
100
+ prev.value === "<");
101
+ };
102
+ /** Skip template text from `i` to the closing backtick or a `${`. */
103
+ const templateText = () => {
104
+ while (i < n) {
105
+ const c = source[i];
106
+ if (c === "\\") {
107
+ if (source[i + 1] === "\n")
108
+ line++;
109
+ i += 2;
110
+ continue;
111
+ }
112
+ if (c === "`") {
113
+ i++;
114
+ return;
115
+ }
116
+ if (c === "$" && source[i + 1] === "{") {
117
+ i += 2;
118
+ templates.push(depth);
119
+ depth++;
120
+ return;
121
+ }
122
+ if (c === "\n")
123
+ line++;
124
+ i++;
125
+ }
126
+ };
127
+ while (i < n) {
128
+ const c = source[i] ?? "";
129
+ const next = source[i + 1] ?? "";
130
+ if (c === "\n") {
131
+ line++;
132
+ i++;
133
+ continue;
134
+ }
135
+ if (c === " " || c === "\t" || c === "\r" || c === "\f" || c === "\v") {
136
+ i++;
137
+ continue;
138
+ }
139
+ // Comments.
140
+ if (c === "/" && next === "/") {
141
+ while (i < n && source[i] !== "\n")
142
+ i++;
143
+ continue;
144
+ }
145
+ if (c === "/" && next === "*") {
146
+ i += 2;
147
+ while (i < n && !(source[i] === "*" && source[i + 1] === "/")) {
148
+ if (source[i] === "\n")
149
+ line++;
150
+ i++;
151
+ }
152
+ i += 2;
153
+ continue;
154
+ }
155
+ // String literals — decoded, ending at a newline.
156
+ if (c === '"' || c === "'") {
157
+ const start = line;
158
+ let value = "";
159
+ i++;
160
+ while (i < n && source[i] !== c && source[i] !== "\n") {
161
+ if (source[i] === "\\" && i + 1 < n && source[i + 1] !== "\n") {
162
+ value += source[i + 1];
163
+ i += 2;
164
+ continue;
165
+ }
166
+ value += source[i];
167
+ i++;
168
+ }
169
+ if (source[i] === c)
170
+ i++;
171
+ push("string", value, start);
172
+ continue;
173
+ }
174
+ // Template literals — the text is skipped, `${…}` is code.
175
+ if (c === "`") {
176
+ i++;
177
+ templateText();
178
+ push("other", "`", line);
179
+ continue;
180
+ }
181
+ // Regex literals — ending at a newline.
182
+ if (c === "/" && !prevAllowsDivision()) {
183
+ i++;
184
+ let inClass = false;
185
+ while (i < n && source[i] !== "\n") {
186
+ const r = source[i];
187
+ if (r === "\\") {
188
+ i += 2;
189
+ continue;
190
+ }
191
+ if (r === "[")
192
+ inClass = true;
193
+ else if (r === "]")
194
+ inClass = false;
195
+ else if (r === "/" && !inClass) {
196
+ i++;
197
+ break;
198
+ }
199
+ i++;
200
+ }
201
+ while (i < n && IDENT_PART.test(source[i] ?? ""))
202
+ i++;
203
+ push("other", "/re/", line);
204
+ continue;
205
+ }
206
+ // Identifiers.
207
+ if (IDENT_START.test(c)) {
208
+ let j = i + 1;
209
+ while (j < n && IDENT_PART.test(source[j] ?? ""))
210
+ j++;
211
+ push("ident", source.slice(i, j), line);
212
+ i = j;
213
+ continue;
214
+ }
215
+ // Numbers.
216
+ if (/[0-9]/.test(c) || (c === "." && /[0-9]/.test(next))) {
217
+ let j = i + 1;
218
+ while (j < n && /[0-9A-Za-z_.]/.test(source[j] ?? ""))
219
+ j++;
220
+ push("other", source.slice(i, j), line);
221
+ i = j;
222
+ continue;
223
+ }
224
+ // Punctuators.
225
+ if (c === "?" && next === "." && !/[0-9]/.test(source[i + 2] ?? "")) {
226
+ push("punct", "?.", line);
227
+ i += 2;
228
+ continue;
229
+ }
230
+ if (c === "." && next === "." && source[i + 2] === ".") {
231
+ push("punct", "...", line);
232
+ i += 3;
233
+ continue;
234
+ }
235
+ if (c === "{") {
236
+ depth++;
237
+ push("punct", c, line);
238
+ i++;
239
+ continue;
240
+ }
241
+ if (c === "}") {
242
+ depth--;
243
+ if (templates.length > 0 && templates[templates.length - 1] === depth) {
244
+ templates.pop();
245
+ i++;
246
+ templateText();
247
+ continue;
248
+ }
249
+ push("punct", c, line);
250
+ i++;
251
+ continue;
252
+ }
253
+ if (c === "=" && next === "=") {
254
+ // `==` / `===` are not the destructuring `=`.
255
+ let j = i;
256
+ while (source[j] === "=")
257
+ j++;
258
+ push("punct", source.slice(i, j), line);
259
+ i = j;
260
+ continue;
261
+ }
262
+ if (c === "=" && next === ">") {
263
+ push("punct", "=>", line);
264
+ i += 2;
265
+ continue;
266
+ }
267
+ push("punct", c, line);
268
+ i++;
269
+ }
270
+ return tokens;
271
+ }
272
+ /** `process.env` / `process?.env` starting at `tokens[i]`: the index after `env`. */
273
+ function processEnvAt(tokens, i) {
274
+ const a = tokens[i];
275
+ const dot = tokens[i + 1];
276
+ const env = tokens[i + 2];
277
+ if (a?.kind !== "ident" || a.value !== "process")
278
+ return undefined;
279
+ if (dot?.kind !== "punct" || (dot.value !== "." && dot.value !== "?.")) {
280
+ return undefined;
281
+ }
282
+ if (env?.kind !== "ident" || env.value !== "env")
283
+ return undefined;
284
+ // `x.process.env` is some other object's `process` property.
285
+ const before = tokens[i - 1];
286
+ if (before?.kind === "punct" &&
287
+ (before.value === "." || before.value === "?.")) {
288
+ return undefined;
289
+ }
290
+ return i + 3;
291
+ }
292
+ /** The keys of the object pattern whose closing `}` is `tokens[close]`. */
293
+ function patternKeys(tokens, close) {
294
+ let depth = 0;
295
+ let open = -1;
296
+ for (let k = close; k >= 0; k--) {
297
+ const t = tokens[k];
298
+ if (t?.kind !== "punct")
299
+ continue;
300
+ if (t.value === "}" || t.value === "]" || t.value === ")")
301
+ depth++;
302
+ else if (t.value === "{" || t.value === "[" || t.value === "(") {
303
+ depth--;
304
+ if (depth === 0) {
305
+ open = k;
306
+ break;
307
+ }
308
+ }
309
+ }
310
+ if (open === -1 || tokens[open]?.value !== "{")
311
+ return [];
312
+ const keys = [];
313
+ let level = 0;
314
+ let expectKey = true;
315
+ for (let k = open + 1; k < close; k++) {
316
+ const t = tokens[k];
317
+ if (t === undefined)
318
+ break;
319
+ if (t.kind === "punct") {
320
+ if (t.value === "{" || t.value === "[" || t.value === "(")
321
+ level++;
322
+ else if (t.value === "}" || t.value === "]" || t.value === ")")
323
+ level--;
324
+ else if (t.value === "," && level === 0) {
325
+ expectKey = true;
326
+ continue;
327
+ }
328
+ else if (t.value === "..." && level === 0) {
329
+ expectKey = false;
330
+ continue;
331
+ }
332
+ }
333
+ if (level === 0 &&
334
+ expectKey &&
335
+ (t.kind === "ident" || t.kind === "string")) {
336
+ keys.push({ name: t.value, line: t.line });
337
+ }
338
+ expectKey = false;
339
+ }
340
+ return keys;
341
+ }
342
+ /**
343
+ * Every literal env read in `source` (D3): `process.env.NAME`,
344
+ * `process.env["NAME"]` / `['NAME']`, either with `?.` in place of a `.`,
345
+ * and each key of an object pattern destructured from `process.env`. A
346
+ * computed `process.env[expr]` is skipped. In source order.
347
+ */
348
+ export function findEnvReads(source) {
349
+ const tokens = tokenize(source);
350
+ const reads = [];
351
+ for (let i = 0; i < tokens.length; i++) {
352
+ const after = processEnvAt(tokens, i);
353
+ if (after === undefined)
354
+ continue;
355
+ const t = tokens[after];
356
+ const u = tokens[after + 1];
357
+ const v = tokens[after + 2];
358
+ const w = tokens[after + 3];
359
+ if (t?.kind === "punct" && (t.value === "." || t.value === "?.")) {
360
+ if (u?.kind === "ident") {
361
+ reads.push({ name: u.value, line: u.line });
362
+ continue;
363
+ }
364
+ if (t.value === "?." &&
365
+ u?.value === "[" &&
366
+ v?.kind === "string" &&
367
+ w?.value === "]") {
368
+ reads.push({ name: v.value, line: v.line });
369
+ continue;
370
+ }
371
+ }
372
+ if (t?.kind === "punct" &&
373
+ t.value === "[" &&
374
+ u?.kind === "string" &&
375
+ v?.value === "]") {
376
+ reads.push({ name: u.value, line: u.line });
377
+ continue;
378
+ }
379
+ // `{ A, B: b } = process.env` — the pattern ends just before the `=`.
380
+ const eq = tokens[i - 1];
381
+ const close = tokens[i - 2];
382
+ if (eq?.kind === "punct" && eq.value === "=" && close?.value === "}") {
383
+ reads.push(...patternKeys(tokens, i - 2));
384
+ }
385
+ }
386
+ return reads;
387
+ }
388
+ // ── D4 — what is scanned ────────────────────────────────────────────────────
389
+ const SCANNED_EXTENSIONS = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
390
+ const DECLARATION_FILE = /\.d\.[mc]?ts$/;
391
+ const SKIPPED_FILES = [
392
+ /\.test\.[^/]+$/,
393
+ /\.spec\.[^/]+$/,
394
+ /\.stories\.[^/]+$/,
395
+ /^jest\.config\.[^/]+$/,
396
+ /^jest\.setup\.[^/]+$/,
397
+ /^playwright\.config\.[^/]+$/,
398
+ ];
399
+ /** Directories never scanned, at any depth (D4). */
400
+ const SKIPPED_DIRECTORIES = [
401
+ "node_modules",
402
+ ".next",
403
+ ".turbo",
404
+ ".git",
405
+ ".claude",
406
+ "dist",
407
+ "build",
408
+ "out",
409
+ "coverage",
410
+ "__tests__",
411
+ "__mocks__",
412
+ "e2e",
413
+ ];
414
+ /** The root-level directory never scanned: the checks themselves. */
415
+ const SKIPPED_ROOT_DIRECTORY = "tools";
416
+ /**
417
+ * The site-relative (`/`-separated) source files under `root` whose reads
418
+ * the check collects, sorted. Symbolic links are not followed.
419
+ */
420
+ export function listScannedFiles(root) {
421
+ const out = [];
422
+ const walk = (dir, rel) => {
423
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
424
+ const childRel = rel === "" ? entry.name : `${rel}/${entry.name}`;
425
+ if (entry.isDirectory()) {
426
+ if (SKIPPED_DIRECTORIES.includes(entry.name))
427
+ continue;
428
+ if (rel === "" && entry.name === SKIPPED_ROOT_DIRECTORY)
429
+ continue;
430
+ walk(join(dir, entry.name), childRel);
431
+ }
432
+ else if (entry.isFile()) {
433
+ if (!SCANNED_EXTENSIONS.test(entry.name))
434
+ continue;
435
+ if (DECLARATION_FILE.test(entry.name))
436
+ continue;
437
+ if (SKIPPED_FILES.some((re) => re.test(entry.name)))
438
+ continue;
439
+ out.push(childRel);
440
+ }
441
+ }
442
+ };
443
+ walk(root, "");
444
+ return out.sort();
445
+ }
446
+ /** Every read in the site at `root`, and every file that could not be read. */
447
+ export function collectSiteReads(root) {
448
+ const reads = [];
449
+ const unreadable = [];
450
+ for (const file of listScannedFiles(root)) {
451
+ let source;
452
+ try {
453
+ source = readFileSync(join(root, file), "utf8");
454
+ }
455
+ catch (err) {
456
+ unreadable.push({ file, reason: err.message });
457
+ continue;
458
+ }
459
+ for (const read of findEnvReads(source))
460
+ reads.push({ ...read, file });
461
+ }
462
+ return { reads, unreadable };
463
+ }
464
+ export const ENV_SCOPES = ["web", "api", "mcp"];
465
+ const ROW_KEYS = ["name", "description", "secret", "scope"];
466
+ const NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
467
+ const KEY_LINE = /^env_contract:(.*)$/;
468
+ /** Strip a trailing ` # comment` from a plain scalar. */
469
+ function stripComment(text) {
470
+ const m = /(^|\s)#/.exec(text);
471
+ return (m === null ? text : text.slice(0, m.index)).trim();
472
+ }
473
+ /** Decode a scalar value; `undefined` is a fault named by `fault`. */
474
+ function decodeScalar(raw, fault) {
475
+ const text = raw.trim();
476
+ if (text.startsWith('"')) {
477
+ const end = text.lastIndexOf('"');
478
+ const rest = text.slice(end + 1).trim();
479
+ if (end === 0 || (rest !== "" && !rest.startsWith("#"))) {
480
+ fault("an unterminated double-quoted value");
481
+ return undefined;
482
+ }
483
+ try {
484
+ return {
485
+ text: JSON.parse(text.slice(0, end + 1)),
486
+ plain: false,
487
+ };
488
+ }
489
+ catch {
490
+ fault("a double-quoted value that is not a valid string");
491
+ return undefined;
492
+ }
493
+ }
494
+ if (text.startsWith("'")) {
495
+ let value = "";
496
+ let k = 1;
497
+ for (;;) {
498
+ if (k >= text.length) {
499
+ fault("an unterminated single-quoted value");
500
+ return undefined;
501
+ }
502
+ if (text[k] === "'") {
503
+ if (text[k + 1] === "'") {
504
+ value += "'";
505
+ k += 2;
506
+ continue;
507
+ }
508
+ break;
509
+ }
510
+ value += text[k];
511
+ k++;
512
+ }
513
+ const rest = text.slice(k + 1).trim();
514
+ if (rest !== "" && !rest.startsWith("#")) {
515
+ fault("text after a single-quoted value");
516
+ return undefined;
517
+ }
518
+ return { text: value, plain: false };
519
+ }
520
+ return { text: stripComment(text), plain: true };
521
+ }
522
+ function parseContract(text) {
523
+ const lines = text.split("\n").map((l) => l.replace(/\r$/, ""));
524
+ const keyIndexes = lines.flatMap((l, idx) => (KEY_LINE.test(l) ? [idx] : []));
525
+ const keyIndex = keyIndexes[0];
526
+ if (keyIndex === undefined)
527
+ return undefined;
528
+ const faults = [];
529
+ const rows = [];
530
+ for (const dup of keyIndexes.slice(1)) {
531
+ faults.push({ line: dup + 1, message: "a second env_contract key" });
532
+ }
533
+ const inline = stripComment(KEY_LINE.exec(lines[keyIndex] ?? "")?.[1] ?? "");
534
+ const parsed = {
535
+ keyIndex,
536
+ flowEmpty: inline === "[]",
537
+ itemIndent: undefined,
538
+ lastItemIndex: undefined,
539
+ rows,
540
+ faults,
541
+ };
542
+ if (inline !== "" && inline !== "[]") {
543
+ faults.push({
544
+ line: keyIndex + 1,
545
+ message: "env_contract must be a block list of rows (or [] when empty)",
546
+ });
547
+ return parsed;
548
+ }
549
+ let item;
550
+ const items = [];
551
+ const setKey = (it, idx, body) => {
552
+ const m = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:(?:\s+(.*)|\s*)$/.exec(body);
553
+ if (m === null) {
554
+ faults.push({
555
+ line: idx + 1,
556
+ message: `"${body.trim()}" is not a key: value pair`,
557
+ });
558
+ return;
559
+ }
560
+ const key = m[1] ?? "";
561
+ const rawValue = m[2] ?? "";
562
+ if (it.values.has(key)) {
563
+ faults.push({
564
+ line: idx + 1,
565
+ message: `the row repeats the key "${key}"`,
566
+ });
567
+ return;
568
+ }
569
+ const indicator = /^([|>])[+-]?\s*(#.*)?$/.exec(rawValue.trim());
570
+ if (indicator !== null) {
571
+ it.values.set(key, { text: "", plain: false });
572
+ it.scalar = {
573
+ key,
574
+ col: undefined,
575
+ parts: [],
576
+ fold: indicator[1] === ">",
577
+ };
578
+ return;
579
+ }
580
+ const value = decodeScalar(rawValue, (message) => faults.push({ line: idx + 1, message: `${key} has ${message}` }));
581
+ if (value !== undefined)
582
+ it.values.set(key, value);
583
+ };
584
+ const finishScalar = (it) => {
585
+ if (it.scalar === undefined)
586
+ return;
587
+ const joined = it.scalar.parts.join(it.scalar.fold ? " " : "\n").trim();
588
+ it.values.set(it.scalar.key, { text: joined, plain: false });
589
+ it.scalar = undefined;
590
+ };
591
+ for (let idx = keyIndex + 1; idx < lines.length; idx++) {
592
+ const line = lines[idx] ?? "";
593
+ const trimmed = line.trim();
594
+ const indent = line.length - line.trimStart().length;
595
+ if (indent === 0 && trimmed !== "" && !trimmed.startsWith("#")) {
596
+ if (trimmed.startsWith("-") && !parsed.flowEmpty) {
597
+ faults.push({
598
+ line: idx + 1,
599
+ message: "env_contract rows must be indented under the key",
600
+ });
601
+ }
602
+ break;
603
+ }
604
+ // A block scalar takes every more-indented line, blank ones included.
605
+ if (item?.scalar !== undefined) {
606
+ if (trimmed === "") {
607
+ item.scalar.parts.push("");
608
+ continue;
609
+ }
610
+ if (indent > item.keyCol) {
611
+ item.scalar.col ??= indent;
612
+ item.scalar.parts.push(line.slice(Math.min(indent, item.scalar.col)));
613
+ parsed.lastItemIndex = idx;
614
+ continue;
615
+ }
616
+ finishScalar(item);
617
+ }
618
+ if (trimmed === "" || trimmed.startsWith("#"))
619
+ continue;
620
+ if (parsed.flowEmpty) {
621
+ faults.push({
622
+ line: idx + 1,
623
+ message: "env_contract: [] cannot also have rows",
624
+ });
625
+ continue;
626
+ }
627
+ const dash = /^(\s*)-(?:(\s+)(.*))?$/.exec(line);
628
+ if (dash !== null &&
629
+ (parsed.itemIndent === undefined || indent === parsed.itemIndent)) {
630
+ parsed.itemIndent ??= indent;
631
+ const gap = dash[2] ?? " ";
632
+ item = {
633
+ line: idx + 1,
634
+ keyCol: indent + 1 + gap.length,
635
+ values: new Map(),
636
+ scalar: undefined,
637
+ };
638
+ items.push(item);
639
+ parsed.lastItemIndex = idx;
640
+ const body = dash[3] ?? "";
641
+ if (body.trim() !== "" && !body.trim().startsWith("#"))
642
+ setKey(item, idx, body);
643
+ continue;
644
+ }
645
+ if (item !== undefined && indent === item.keyCol) {
646
+ setKey(item, idx, line.slice(indent));
647
+ parsed.lastItemIndex = idx;
648
+ continue;
649
+ }
650
+ faults.push({
651
+ line: idx + 1,
652
+ message: `unexpected line "${trimmed}" — each row is "- name: …" with its keys aligned under it`,
653
+ });
654
+ }
655
+ if (item !== undefined)
656
+ finishScalar(item);
657
+ const seen = new Map();
658
+ for (const it of items) {
659
+ const fault = (message) => {
660
+ faults.push({ line: it.line, message });
661
+ };
662
+ for (const key of it.values.keys()) {
663
+ if (!ROW_KEYS.includes(key)) {
664
+ fault(`the row has an unknown key "${key}" (allowed: ${ROW_KEYS.join(", ")})`);
665
+ }
666
+ }
667
+ const name = it.values.get("name")?.text;
668
+ const description = it.values.get("description");
669
+ const secret = it.values.get("secret");
670
+ const scope = it.values.get("scope");
671
+ let valid = true;
672
+ if (name === undefined || name === "") {
673
+ fault("the row has no name");
674
+ valid = false;
675
+ }
676
+ else if (!NAME_RE.test(name)) {
677
+ fault(`the row's name "${name}" is not an env var name (A-Z, 0-9, _)`);
678
+ valid = false;
679
+ }
680
+ else if (seen.has(name)) {
681
+ fault(`${name} already has a row at project.yaml:${seen.get(name)}`);
682
+ valid = false;
683
+ }
684
+ if (description === undefined) {
685
+ fault(`the row${name !== undefined ? ` for ${name}` : ""} has no description`);
686
+ }
687
+ else if (description.text.trim() === "") {
688
+ fault(`the row${name !== undefined ? ` for ${name}` : ""} has an empty description`);
689
+ }
690
+ let secretValue = false;
691
+ if (secret === undefined) {
692
+ fault(`the row${name !== undefined ? ` for ${name}` : ""} has no secret: true or false`);
693
+ }
694
+ else if (!secret.plain ||
695
+ (secret.text !== "true" && secret.text !== "false")) {
696
+ fault(`secret must be true or false, got ${JSON.stringify(secret.text)}`);
697
+ }
698
+ else {
699
+ secretValue = secret.text === "true";
700
+ }
701
+ if (secretValue && name?.startsWith("NEXT_PUBLIC_") === true) {
702
+ fault(`${name} is browser-exposed, so it is never a secret: set secret: false`);
703
+ }
704
+ if (scope !== undefined &&
705
+ !ENV_SCOPES.includes(scope.text)) {
706
+ fault(`scope must be one of ${ENV_SCOPES.join(", ")}, got ${JSON.stringify(scope.text)}`);
707
+ }
708
+ if (valid && name !== undefined) {
709
+ seen.set(name, it.line);
710
+ const row = {
711
+ name,
712
+ description: description?.text ?? "",
713
+ secret: secretValue,
714
+ line: it.line,
715
+ };
716
+ if (scope !== undefined &&
717
+ ENV_SCOPES.includes(scope.text)) {
718
+ row.scope = scope.text;
719
+ }
720
+ rows.push(row);
721
+ }
722
+ }
723
+ faults.sort((a, b) => a.line - b.line);
724
+ return parsed;
725
+ }
726
+ /**
727
+ * The top-level `env_contract:` block of `projectYamlText` (D2): absent, or
728
+ * its rows and every fault in it. A row whose name is valid is in `rows`
729
+ * even when another of its keys is at fault, so its name still counts as
730
+ * declared; the fault is reported either way.
731
+ */
732
+ export function readEnvContract(projectYamlText) {
733
+ const parsed = parseContract(projectYamlText);
734
+ if (parsed === undefined)
735
+ return { present: false };
736
+ return { present: true, rows: parsed.rows, faults: parsed.faults };
737
+ }
738
+ // ── D8 — the site's topology ────────────────────────────────────────────────
739
+ /** The site manifest a templated scaffold writes. */
740
+ const SITE_MANIFEST_FILE = ".ailk-site-manifest.json";
741
+ /**
742
+ * The topology `.ailk-site-manifest.json` records under `root` (D8): a
743
+ * missing file or key reads `"hub"`; an unknown value or a file that is not
744
+ * JSON is a fault. The `readSiteTopology` rule in create-ailk's `topology.ts`.
745
+ */
746
+ export function readManifestTopology(root) {
747
+ const path = join(root, SITE_MANIFEST_FILE);
748
+ if (!existsSync(path))
749
+ return { topology: "hub" };
750
+ let doc;
751
+ try {
752
+ doc = JSON.parse(readFileSync(path, "utf8"));
753
+ }
754
+ catch (err) {
755
+ return {
756
+ fault: `${SITE_MANIFEST_FILE} is not JSON: ${err.message}`,
757
+ };
758
+ }
759
+ const value = typeof doc === "object" && doc !== null
760
+ ? doc["topology"]
761
+ : undefined;
762
+ if (value === undefined)
763
+ return { topology: "hub" };
764
+ if (value === "hub" || value === "spoke")
765
+ return { topology: value };
766
+ return {
767
+ fault: `${SITE_MANIFEST_FILE} records an unknown topology ${JSON.stringify(value)} (expected hub or spoke)`,
768
+ };
769
+ }
770
+ const PROJECT_YAML = "project.yaml";
771
+ /**
772
+ * Check the site at `root` (D5): every literal env read has an
773
+ * `env_contract` row, every row is well-formed, and on a spoke
774
+ * `NEXT_PUBLIC_AILK_API_URL` is neither read nor declared. A hub with no
775
+ * block has nothing to check; a spoke with no block is a finding.
776
+ */
777
+ export function checkDeclaredEnv(root) {
778
+ const findings = [];
779
+ const yamlPath = join(root, PROJECT_YAML);
780
+ if (!existsSync(yamlPath)) {
781
+ return { findings: [`env: ${PROJECT_YAML}: missing`], summary: "" };
782
+ }
783
+ let yamlText;
784
+ try {
785
+ yamlText = readFileSync(yamlPath, "utf8");
786
+ }
787
+ catch (err) {
788
+ return {
789
+ findings: [`env: ${PROJECT_YAML}: ${err.message}`],
790
+ summary: "",
791
+ };
792
+ }
793
+ const manifest = readManifestTopology(root);
794
+ if ("fault" in manifest) {
795
+ return {
796
+ findings: [`env: ${SITE_MANIFEST_FILE}: ${manifest.fault}`],
797
+ summary: "",
798
+ };
799
+ }
800
+ const contract = readEnvContract(yamlText);
801
+ if (!contract.present) {
802
+ if (manifest.topology === "hub") {
803
+ return {
804
+ findings: [],
805
+ summary: `env: no env_contract block in ${PROJECT_YAML} — nothing to check`,
806
+ };
807
+ }
808
+ return {
809
+ findings: [
810
+ `env: ${PROJECT_YAML} has no env_contract block; a spoke must declare the variables its code reads`,
811
+ ],
812
+ summary: "",
813
+ };
814
+ }
815
+ for (const fault of contract.faults) {
816
+ findings.push(`env: ${PROJECT_YAML}:${fault.line}: ${fault.message}`);
817
+ }
818
+ const { reads, unreadable } = collectSiteReads(root);
819
+ for (const { file, reason } of unreadable)
820
+ findings.push(`env: ${file}: ${reason}`);
821
+ const declared = new Map(contract.rows.map((row) => [row.name, row]));
822
+ const refused = new Set(manifest.topology === "spoke" ? SPOKE_REFUSED_ENV : []);
823
+ const readFindings = [];
824
+ for (const name of refused) {
825
+ const row = declared.get(name);
826
+ if (row !== undefined) {
827
+ readFindings.push({
828
+ name,
829
+ file: PROJECT_YAML,
830
+ line: row.line,
831
+ text: refusal(name, `declared in ${PROJECT_YAML}:${row.line}`),
832
+ });
833
+ }
834
+ }
835
+ const names = new Set();
836
+ for (const read of reads) {
837
+ if (isPlatformEnvName(read.name))
838
+ continue;
839
+ names.add(read.name);
840
+ if (refused.has(read.name)) {
841
+ readFindings.push({
842
+ ...read,
843
+ text: refusal(read.name, `read at ${read.file}:${read.line}`),
844
+ });
845
+ }
846
+ else if (!declared.has(read.name)) {
847
+ readFindings.push({
848
+ ...read,
849
+ text: `env: ${read.name} is read at ${read.file}:${read.line} but ${PROJECT_YAML} has no env_contract row for it`,
850
+ });
851
+ }
852
+ }
853
+ readFindings.sort((a, b) => compare(a.name, b.name) ||
854
+ compare(a.file === PROJECT_YAML ? "" : a.file, b.file === PROJECT_YAML ? "" : b.file) ||
855
+ a.line - b.line);
856
+ findings.push(...readFindings.map((f) => f.text));
857
+ return {
858
+ findings,
859
+ summary: `env: OK — ${names.size} names read, all declared`,
860
+ };
861
+ }
862
+ function refusal(name, where) {
863
+ return `env: ${name} is refused on a spoke (${where}) — a spoke's hub origin is server-only; use ${SPOKE_HUB_ORIGIN_ENV}`;
864
+ }
865
+ function compare(a, b) {
866
+ return a < b ? -1 : a > b ? 1 : 0;
867
+ }
868
+ // ── D7 — the writer ─────────────────────────────────────────────────────────
869
+ /** A block the writer cannot read, so it never appends to it (D7). */
870
+ export class EnvContractError extends Error {
871
+ constructor(message) {
872
+ super(message);
873
+ this.name = "EnvContractError";
874
+ }
875
+ }
876
+ /** The header the writer puts above a block it creates. */
877
+ const ENV_CONTRACT_HEADER = [
878
+ "# The environment variables this site's code reads. `pnpm env:declared`",
879
+ "# checks every literal process.env read against these rows; create-ailk",
880
+ "# appends rows and never edits one.",
881
+ ];
882
+ /** The site-root app directories a row's `scope` names. */
883
+ const SCOPE_DIRS = [
884
+ ["web", "apps/web/"],
885
+ ["api", "apps/api/"],
886
+ ["mcp", "apps/mcp/"],
887
+ ];
888
+ /**
889
+ * `projectYamlText` with a row appended for every name the site at `root`
890
+ * reads that has none (D7), sorted by name, after the existing rows. Platform
891
+ * names, and on a spoke `SPOKE_REFUSED_ENV`, get no row. Pure: it reads the
892
+ * tree, never writes. `added` names the rows it wrote.
893
+ *
894
+ * @throws EnvContractError when the existing block has a fault — the writer
895
+ * never appends to a block it cannot read.
896
+ */
897
+ export function planEnvRows(root, projectYamlText, opts) {
898
+ const parsed = parseContract(projectYamlText);
899
+ if (parsed !== undefined && parsed.faults.length > 0) {
900
+ const first = parsed.faults[0];
901
+ throw new EnvContractError(`project.yaml:${first?.line ?? 0}: ${first?.message ?? "a fault"} — fix the env_contract block, then re-run`);
902
+ }
903
+ const declared = new Set(parsed?.rows.map((row) => row.name) ?? []);
904
+ const refused = new Set(opts.topology === "spoke" ? SPOKE_REFUSED_ENV : []);
905
+ const files = new Map();
906
+ for (const read of collectSiteReads(root).reads) {
907
+ if (isPlatformEnvName(read.name) || refused.has(read.name))
908
+ continue;
909
+ if (declared.has(read.name))
910
+ continue;
911
+ const list = files.get(read.name) ?? [];
912
+ list.push(read.file);
913
+ files.set(read.name, list);
914
+ }
915
+ for (const name of opts.declare ?? []) {
916
+ if (isPlatformEnvName(name) || refused.has(name))
917
+ continue;
918
+ if (declared.has(name) || files.has(name))
919
+ continue;
920
+ files.set(name, []);
921
+ }
922
+ const added = [...files.keys()].sort(compare);
923
+ if (parsed !== undefined && added.length === 0) {
924
+ return { text: projectYamlText, added };
925
+ }
926
+ const itemIndent = parsed?.itemIndent ?? 2;
927
+ const pad = " ".repeat(itemIndent);
928
+ const rowLines = added.flatMap((name) => {
929
+ const readers = [...new Set(files.get(name) ?? [])].sort(compare);
930
+ const entry = Object.prototype.hasOwnProperty.call(opts.catalog, name)
931
+ ? opts.catalog[name]
932
+ : undefined;
933
+ const description = entry?.description ??
934
+ `Read by ${readers[0] ?? "this site"}. Describe what it sets.`;
935
+ const secret = entry?.secret ?? !name.startsWith("NEXT_PUBLIC_");
936
+ const scopes = new Set(readers.map((file) => SCOPE_DIRS.find(([, dir]) => file.startsWith(dir))?.[0]));
937
+ const scope = scopes.size === 1 ? [...scopes][0] : undefined;
938
+ return [
939
+ `${pad}- name: ${name}`,
940
+ `${pad} description: ${JSON.stringify(description)}`,
941
+ `${pad} secret: ${secret ? "true" : "false"}`,
942
+ ...(scope !== undefined ? [`${pad} scope: ${scope}`] : []),
943
+ ];
944
+ });
945
+ const lines = projectYamlText.split("\n");
946
+ if (parsed === undefined) {
947
+ const body = lines.length > 0 && lines[lines.length - 1] === ""
948
+ ? lines.slice(0, -1)
949
+ : lines;
950
+ const block = rowLines.length > 0
951
+ ? ["env_contract:", ...rowLines]
952
+ : ["env_contract: []"];
953
+ return {
954
+ text: [...body, "", ...ENV_CONTRACT_HEADER, ...block, ""].join("\n"),
955
+ added,
956
+ };
957
+ }
958
+ if (parsed.flowEmpty) {
959
+ const keyLine = lines[parsed.keyIndex] ?? "";
960
+ lines[parsed.keyIndex] = keyLine.replace(/^env_contract:\s*\[\s*\]/, "env_contract:");
961
+ lines.splice(parsed.keyIndex + 1, 0, ...rowLines);
962
+ return { text: lines.join("\n"), added };
963
+ }
964
+ const at = (parsed.lastItemIndex ?? parsed.keyIndex) + 1;
965
+ lines.splice(at, 0, ...rowLines);
966
+ return { text: lines.join("\n"), added };
967
+ }
968
+ /**
969
+ * Plan and write `<root>/project.yaml`'s new rows (D7). Writes only when the
970
+ * text changed, so a second run changes nothing; never edits or removes a
971
+ * row. A site with no `project.yaml` is left alone.
972
+ *
973
+ * @throws EnvContractError on a block it cannot read (nothing is written).
974
+ */
975
+ export function appendEnvRows(root, opts) {
976
+ const path = join(root, PROJECT_YAML);
977
+ if (!existsSync(path))
978
+ return { state: "no-project-yaml" };
979
+ const text = readFileSync(path, "utf8");
980
+ const plan = planEnvRows(root, text, opts);
981
+ if (plan.text === text)
982
+ return { state: "unchanged" };
983
+ writeFileSync(path, plan.text);
984
+ return { state: "written", added: plan.added };
985
+ }