create-ailk 0.15.0 → 0.17.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 (128) 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 +67 -0
  29. package/dist/spoke-checkout.js +562 -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 +451 -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 +57 -24
  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 +33 -0
  93. package/templates/apps/web/lib/__tests__/server-api.test.ts +37 -28
  94. package/templates/apps/web/lib/account-config.ts +130 -1
  95. package/templates/apps/web/lib/api-origin.generated.ts +11 -0
  96. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
  97. package/templates/apps/web/lib/hub-session/config.ts +104 -0
  98. package/templates/apps/web/lib/hub-session/index.ts +23 -0
  99. package/templates/apps/web/lib/hub-session/seal.ts +82 -0
  100. package/templates/apps/web/lib/hub-session/session.ts +180 -0
  101. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
  102. package/templates/apps/web/lib/listings-config.ts +31 -0
  103. package/templates/apps/web/lib/listings.tsx +7 -3
  104. package/templates/apps/web/lib/meeting-slugs.ts +11 -1
  105. package/templates/apps/web/lib/next-path.ts +35 -0
  106. package/templates/apps/web/lib/relay.ts +3 -1
  107. package/templates/apps/web/lib/server-api.ts +11 -1
  108. package/templates/apps/web/lib/topology.generated.ts +15 -5
  109. package/templates/apps/web/messages/en.json +32 -3
  110. package/templates/database/CHANGELOG.md +26 -0
  111. package/templates/database/__tests__/agents.db.test.ts +25 -5
  112. package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
  113. package/templates/database/__tests__/member-area.db.test.ts +245 -0
  114. package/templates/database/__tests__/person.db.test.ts +584 -0
  115. package/templates/database/auth/schema.prisma +3 -2
  116. package/templates/database/config/schema.prisma +13 -0
  117. package/templates/database/inbox/schema.prisma +98 -0
  118. package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
  119. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
  120. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
  121. package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
  122. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
  123. package/templates/database/package.json +1 -1
  124. package/templates/database/scripts/person-backfill.sql +132 -0
  125. package/templates/database/tenancy.ts +53 -0
  126. package/templates/tools/check-declared-env.ts +37 -0
  127. package/templates/tools/declared-env.ts +1163 -0
  128. package/versions.json +35 -35
package/dist/cli.js CHANGED
@@ -7,6 +7,12 @@
7
7
  * addModule(), print the report, exit (2 on a refusal it names)
8
8
  * on an `add-route` subcommand: add one route surface to an existing
9
9
  * site, print the report, exit (2 on a refusal it names) (#6947)
10
+ * on an `adopt` subcommand: record an existing site's topology and
11
+ * pattern version in its manifest, print the report, exit (2 on a
12
+ * refusal it names) (#7875)
13
+ * on a `seed-flows` subcommand: send a spoke's declared flows/*.json to
14
+ * its hub with the key from AILK_SPOKE_KEY, print one line per flow,
15
+ * exit 1 if any failed (2 on a refusal it names, nothing sent) (#7876)
10
16
  * on a `sync-routes` subcommand: parse its own argv, run syncRoutesSync(),
11
17
  * print the report, exit (1 iff routes are missing in check mode)
12
18
  * on a `license set` subcommand: write the key into the scaffold, push it
@@ -40,6 +46,7 @@ import { basename, dirname, resolve } from "node:path";
40
46
  import { fileURLToPath } from "node:url";
41
47
  import { ADD_MODULE_HELP, AddModuleRefusedError, formatAddModuleReport, parseAddModuleArgs, } from "./add-module.js";
42
48
  import { ADD_ROUTE_HELP, AddRouteRefusedError, addRouteToScaffold, formatAddRouteReport, parseAddRouteArgs, } from "./add-route.js";
49
+ import { ADOPT_HELP, AdoptRefusedError, adoptSite, formatAdoptReport, parseAdoptArgs, } from "./adopt.js";
43
50
  import { APPLY_DESIGN_HELP, applyDesign, formatApplyDesignReport, parseApplyDesignArgs, } from "./apply-design.js";
44
51
  import { CLAUDE_MD_CHECK_PATH } from "./claude-md.js";
45
52
  import { buildScaffoldMarkers } from "./copy.js";
@@ -48,6 +55,7 @@ import { FREE_PREREQUISITES_NOTE, LICENSED_PREREQUISITES_NOTE, isLicensedInstall
48
55
  import { LICENSE_SET_HELP, runLicenseSet } from "./license-set.js";
49
56
  import { PLUGIN_INSTALL_LINES, helpText, parseCliArgs, parseModules, resolveCliShape, toScaffoldOptions, } from "./parse-args.js";
50
57
  import { addModule, formatSurfaceDrift, scaffold, syncArchitectureSync, syncCatalogSync, syncClaudeMdSync, syncRoutesSync, } from "./programmatic.js";
58
+ import { SEED_FLOWS_HELP, SeedFlowsRefusedError, parseSeedFlowsArgs, seedFlows, } from "./seed-flows.js";
51
59
  import { CORE_SURFACE_PATHS, SURFACE_ALIASES } from "./surfaces.js";
52
60
  import { SYNC_ROUTES_HELP, formatSyncRoutesReport, parseSyncRoutesArgs, } from "./sync-routes.js";
53
61
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -161,6 +169,62 @@ async function main() {
161
169
  }
162
170
  process.exit(0);
163
171
  }
172
+ // `adopt [dir]` (#7875): record an existing site's topology and pattern
173
+ // version in its manifest, once. Writes that one file and nothing else. A
174
+ // refusal it names (a half-trimmed tree, a second run, a missing or
175
+ // contradicting --template / --modules, a legacy roster) exits 2.
176
+ if (argv[0] === "adopt") {
177
+ const args = parseAdoptArgs(argv.slice(1));
178
+ if (args.help) {
179
+ process.stdout.write(ADOPT_HELP);
180
+ process.exit(0);
181
+ }
182
+ try {
183
+ const result = adoptSite({
184
+ targetDir: args.target ?? process.cwd(),
185
+ templatesDir: resolve(PKG_ROOT, "templates"),
186
+ siteTemplatesDir: resolve(PKG_ROOT, "site-templates"),
187
+ template: args.template,
188
+ modules: args.modules,
189
+ });
190
+ process.stdout.write(formatAdoptReport(result));
191
+ }
192
+ catch (err) {
193
+ if (!(err instanceof AdoptRefusedError) &&
194
+ !(err instanceof AddModuleRefusedError)) {
195
+ throw err;
196
+ }
197
+ process.stderr.write(`[create-ailk] ${err.message}\n`);
198
+ process.exit(err.exitCode);
199
+ }
200
+ process.exit(0);
201
+ }
202
+ // `seed-flows [dir]` (#7876): send a spoke's declared flows/*.json to its
203
+ // hub with the spoke key from AILK_SPOKE_KEY. Exit 1 when any flow failed;
204
+ // a refusal it names (a hub, a bad file, no key, no valid hub origin)
205
+ // exits 2 before anything is sent.
206
+ if (argv[0] === "seed-flows") {
207
+ const args = parseSeedFlowsArgs(argv.slice(1));
208
+ if (args.help) {
209
+ process.stdout.write(SEED_FLOWS_HELP);
210
+ process.exit(0);
211
+ }
212
+ try {
213
+ const result = await seedFlows({
214
+ targetDir: args.target ?? process.cwd(),
215
+ hubUrl: args.hubUrl,
216
+ dryRun: args.dryRun,
217
+ onLine: (line) => process.stdout.write(`${line}\n`),
218
+ });
219
+ process.exit(result.exitCode);
220
+ }
221
+ catch (err) {
222
+ if (!(err instanceof SeedFlowsRefusedError))
223
+ throw err;
224
+ process.stderr.write(`[create-ailk] ${err.message}\n`);
225
+ process.exit(err.exitCode);
226
+ }
227
+ }
164
228
  // Subcommand dispatch, ahead of the flag parser: `sync-routes` has its own
165
229
  // argv shape (#4986). Check mode exits 1 iff a template route is missing;
166
230
  // locally modified routes are reported but do not fail; --apply exits 0.
@@ -247,6 +311,10 @@ async function main() {
247
311
  "\n" +
248
312
  ADD_ROUTE_HELP +
249
313
  "\n" +
314
+ ADOPT_HELP +
315
+ "\n" +
316
+ SEED_FLOWS_HELP +
317
+ "\n" +
250
318
  APPLY_DESIGN_HELP +
251
319
  "\n" +
252
320
  LICENSE_SET_HELP);
@@ -319,7 +387,7 @@ async function main() {
319
387
  // marketing-site-free` still rides the no-key case (record D11): its
320
388
  // `requires` is all-free, so it installs from the shipped
321
389
  // `site-templates/` tree with no key and no request.
322
- const { targetDir: targetAbs, surfaces, starter, modules: effectiveModules, droppedModules, hubOrigin, } = await scaffold(toScaffoldOptions(opts));
390
+ const { targetDir: targetAbs, surfaces, starter, modules: effectiveModules, droppedModules, hubOrigin, hubSignInOrigin, } = await scaffold(toScaffoldOptions(opts));
323
391
  const isSpoke = opts.topology === "spoke";
324
392
  // The starter scaffold() resolved — from --template, or from --shape (#6754 D4).
325
393
  const templateName = starter?.template ?? opts.template;
@@ -351,7 +419,7 @@ async function main() {
351
419
  // #7426 — a spoke says so, and names what its hosts must carry (D3).
352
420
  ...(isSpoke
353
421
  ? [
354
- `Topology: spoke — no apps/api, no database, no sign-in${opts.mcp === false ? ", no apps/mcp" : "; apps/mcp is a stateless front"}.`,
422
+ `Topology: spoke — no apps/api, no database, ${hubSignInOrigin !== undefined ? "sign-in through the hub" : "no sign-in"}${opts.mcp === false ? ", no apps/mcp" : "; apps/mcp is a stateless front"}.`,
355
423
  ...(droppedModules !== undefined && droppedModules.length > 0
356
424
  ? [
357
425
  `Not carried on a spoke: ${droppedModules.join(", ")} (your licence includes ${droppedModules.length === 1 ? "it" : "them"}; a spoke does not admit ${droppedModules.length === 1 ? "it" : "them"} yet).`,
@@ -362,6 +430,18 @@ async function main() {
362
430
  ` AILK_API_URL=${hubOrigin ?? "<your hub origin>"} # written to apps/web/.env.local`,
363
431
  " AILK_SPOKE_KEY # issued by the hub; never written here",
364
432
  " AILK_VISITOR_FORWARD_KEY # issued by the hub; never written here",
433
+ // #7894 D11 — the hub sign-in mode's client, issued on the hub.
434
+ ...(hubSignInOrigin !== undefined
435
+ ? [
436
+ ` AILK_HUB_SIGN_IN_URL=${hubSignInOrigin} # written to apps/web/.env.local`,
437
+ " AILK_HUB_SESSION_SECRET # generated into apps/web/.env.local; keep it secret",
438
+ " AILK_HUB_CLIENT_ID # this spoke's id on the hub; never written here",
439
+ " AILK_HUB_CLIENT_SECRET # issued by the hub; never written here",
440
+ "",
441
+ "On the hub, issue the client id and secret with:",
442
+ " pnpm --filter @working-theory/api spokes sign-in-client --id <spoke_id> --redirect-uri <this site's origin>/api/hub-session/callback",
443
+ ]
444
+ : []),
365
445
  ]
366
446
  : []),
367
447
  "",
@@ -1,3 +1,19 @@
1
+ /**
2
+ * Derive a scaffolded site's `architecture.yaml` from its selected module set.
3
+ *
4
+ * `deriveArchitecture(modules)` composes the module-independent BASE
5
+ * (module-architecture.ts `ARCHITECTURE_BASE`) with the selected module slices
6
+ * into ONE deterministic `architecture.yaml` document string. The module set is
7
+ * the single source of truth for the result — same set → byte-identical output
8
+ * (no timestamps, stable key/array ordering), so the derivation doubles as the
9
+ * re-runnable sync path (add a module, re-derive, the file updates; re-deriving
10
+ * an unchanged set is a no-op).
11
+ *
12
+ * Zero runtime dependencies (create-ailk is a published, standalone CLI): the
13
+ * YAML is emitted by a small, purpose-built deterministic serializer over the
14
+ * fixed document shape this module produces — no `yaml`/`js-yaml` dependency.
15
+ */
16
+ import { type ForbiddenPattern } from "./module-architecture.js";
1
17
  import type { Topology } from "./topology.js";
2
18
  /** The topology a document is derived for (#7426 D8). */
3
19
  export interface DeriveArchitectureOptions {
@@ -6,6 +22,12 @@ export interface DeriveArchitectureOptions {
6
22
  /** Spoke only: whether the spoke keeps `apps/mcp` (default true). */
7
23
  mcp?: boolean;
8
24
  }
25
+ /** The only spoke files that may import @working-theory/spoke-checkout (#7873). */
26
+ export declare const SPOKE_CHECKOUT_ROUTE_FILES: readonly string[];
27
+ /** The forbidden patterns a spoke's apps/web carries (#7873, brief Part 2 §2). */
28
+ export declare const SPOKE_WEB_FORBIDDEN_PATTERNS: readonly ForbiddenPattern[];
29
+ /** The same three for a spoke's kept apps/mcp — no MCP file may import the package (#7873). */
30
+ export declare const SPOKE_MCP_FORBIDDEN_PATTERNS: readonly ForbiddenPattern[];
9
31
  /**
10
32
  * Derive the `architecture.yaml` document for a module set.
11
33
  *
@@ -21,6 +21,60 @@ const SPOKE_TIERS = {
21
21
  api: "the hub at AILK_API_URL (apps/mcp is a stateless front)",
22
22
  presentation: "apps/web",
23
23
  };
24
+ // ── The spoke checkout boundary (#7873, brief Part 2 §2) ───────────────────
25
+ //
26
+ // A scaffold's lint (template-overlay/tools/lint-architecture.ts) resolves an
27
+ // import only to a workspace on disk, so the one way to ban an npm package is
28
+ // a line-level forbidden pattern. These go under the spoke's own `apps.web`
29
+ // and `apps.mcp` only — never top level, and never on a hub derivation.
30
+ /** The only spoke files that may import @working-theory/spoke-checkout (#7873). */
31
+ export const SPOKE_CHECKOUT_ROUTE_FILES = Object.freeze([
32
+ "apps/web/app/api/checkout/[offering]/route.ts",
33
+ "apps/web/app/api/webhooks/stripe/route.ts",
34
+ ]);
35
+ /**
36
+ * A line-level regex for an import of `specifier` or one of its subpaths, in
37
+ * every form the line can take: `from "…"` (a static or re-export, including
38
+ * the closing line of a multi-line import), `import "…"`, `import("…")` and
39
+ * `require("…")`. The leading `\b` keeps `myrequire(…)` / `datafrom(…)` out;
40
+ * the quote set takes a backtick so `import(\`…\`)` is not a way round it.
41
+ * A plain string (`"stripe"`) and a lookalike name (`…-extra`) do not match.
42
+ */
43
+ function specifierForm(specifier) {
44
+ const q = "['\"`]";
45
+ const literal = specifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
46
+ return `\\b(?:from|import|require)\\s*\\(?\\s*${q}${literal}(?:/[^'"\`]*)?${q}`;
47
+ }
48
+ /** P1 with no exclusions — a kept apps/mcp's form; apps/web adds the two routes. */
49
+ const SPOKE_CHECKOUT_PATTERN = Object.freeze({
50
+ pattern: specifierForm("@working-theory/spoke-checkout"),
51
+ reason: "Spoke checkout: only the buy route and the webhook route may import @working-theory/spoke-checkout (brief Part 2 §2, #7873)",
52
+ });
53
+ const STRIPE_CLIENT_PATTERN = Object.freeze({
54
+ pattern: specifierForm("@working-theory/stripe-client"),
55
+ reason: "A spoke never imports stripe-client directly — it reaches Stripe only through @working-theory/spoke-checkout (#7873)",
56
+ });
57
+ const STRIPE_SDK_PATTERN = Object.freeze({
58
+ pattern: specifierForm("stripe"),
59
+ reason: "A spoke never imports the stripe SDK directly — it reaches Stripe only through @working-theory/spoke-checkout (#7873)",
60
+ });
61
+ /** The forbidden patterns a spoke's apps/web carries (#7873, brief Part 2 §2). */
62
+ export const SPOKE_WEB_FORBIDDEN_PATTERNS = Object.freeze([
63
+ Object.freeze({ ...SPOKE_CHECKOUT_PATTERN, paths_excluded: [...SPOKE_CHECKOUT_ROUTE_FILES] }),
64
+ STRIPE_CLIENT_PATTERN,
65
+ STRIPE_SDK_PATTERN,
66
+ ]);
67
+ /** The same three for a spoke's kept apps/mcp — no MCP file may import the package (#7873). */
68
+ export const SPOKE_MCP_FORBIDDEN_PATTERNS = Object.freeze([
69
+ SPOKE_CHECKOUT_PATTERN,
70
+ STRIPE_CLIENT_PATTERN,
71
+ STRIPE_SDK_PATTERN,
72
+ ]);
73
+ /** Per-app extra patterns on the spoke branch only, keyed by the app's key. */
74
+ const SPOKE_APP_FORBIDDEN = {
75
+ web: SPOKE_WEB_FORBIDDEN_PATTERNS,
76
+ mcp: SPOKE_MCP_FORBIDDEN_PATTERNS,
77
+ };
24
78
  function isScalar(v) {
25
79
  return typeof v === "string" || typeof v === "number";
26
80
  }
@@ -223,11 +277,14 @@ export function deriveArchitecture(modules = ["marketing"], surfaces, options =
223
277
  const a = declared[key];
224
278
  if (!a)
225
279
  continue;
280
+ // The checkout boundary is keyed on the topology the caller passed, never
281
+ // a module or a path glob alone, so a hub derivation is unchanged (#7873).
282
+ const extra = spoke ? (SPOKE_APP_FORBIDDEN[key] ?? []) : [];
226
283
  apps[key] = appToYaml({
227
284
  ...a,
228
285
  may_import_from: a.may_import_from.filter(inScaffold),
229
286
  may_not_import_from: a.may_not_import_from.filter(inScaffold),
230
- forbidden_patterns: mergeForbidden([...ARCHITECTURE_BASE.appForbiddenPatterns], a.forbidden_patterns ?? []).map(scoped),
287
+ forbidden_patterns: mergeForbidden([...ARCHITECTURE_BASE.appForbiddenPatterns], [...(a.forbidden_patterns ?? []), ...extra]).map(scoped),
231
288
  });
232
289
  }
233
290
  // namespaces — present only if a data-tier module contributes one, and
@@ -0,0 +1,142 @@
1
+ /** Names the platform sets, which need no row (D4). Fixed: a site cannot extend it. */
2
+ export declare const PLATFORM_ENV_NAMES: readonly string[];
3
+ /** Every name with one of these prefixes is a platform name too (D4). */
4
+ export declare const PLATFORM_ENV_PREFIXES: readonly string[];
5
+ /** Names a spoke may neither read nor declare (D5): its hub origin is server-only. */
6
+ export declare const SPOKE_REFUSED_ENV: readonly string[];
7
+ /** Whether `name` is set by the platform and so needs no row. */
8
+ export declare function isPlatformEnvName(name: string): boolean;
9
+ /** A site's topology. Restated from `topology.ts`: the scaffold copy cannot import it. */
10
+ export type SiteTopology = "hub" | "spoke";
11
+ /** One literal env read: the variable name and its 1-based line. */
12
+ export interface EnvRead {
13
+ name: string;
14
+ line: number;
15
+ }
16
+ /**
17
+ * Every literal env read in `source` (D3): `process.env.NAME`,
18
+ * `process.env["NAME"]` / `['NAME']`, either with `?.` in place of a `.`,
19
+ * and each key of an object pattern destructured from `process.env`. A
20
+ * computed `process.env[expr]` is skipped. In source order.
21
+ */
22
+ export declare function findEnvReads(source: string): EnvRead[];
23
+ /**
24
+ * The site-relative (`/`-separated) source files under `root` whose reads
25
+ * the check collects, sorted. Symbolic links are not followed.
26
+ */
27
+ export declare function listScannedFiles(root: string): string[];
28
+ /** A read located in the site: the file it sits in and its line. */
29
+ export interface SiteEnvRead extends EnvRead {
30
+ file: string;
31
+ }
32
+ /** Every read in the site at `root`, and every file that could not be read. */
33
+ export declare function collectSiteReads(root: string): {
34
+ reads: SiteEnvRead[];
35
+ unreadable: Array<{
36
+ file: string;
37
+ reason: string;
38
+ }>;
39
+ };
40
+ /** One `env_contract` row as written. */
41
+ export interface EnvContractRow {
42
+ name: string;
43
+ description: string;
44
+ secret: boolean;
45
+ scope?: EnvScope;
46
+ /** The 1-based `project.yaml` line the row's item starts on. */
47
+ line: number;
48
+ }
49
+ /** A fault in the block: the 1-based `project.yaml` line and what is wrong. */
50
+ export interface EnvContractFault {
51
+ line: number;
52
+ message: string;
53
+ }
54
+ export type EnvContract = {
55
+ present: false;
56
+ } | {
57
+ present: true;
58
+ rows: EnvContractRow[];
59
+ faults: EnvContractFault[];
60
+ };
61
+ export declare const ENV_SCOPES: readonly ["web", "api", "mcp"];
62
+ export type EnvScope = (typeof ENV_SCOPES)[number];
63
+ /**
64
+ * The top-level `env_contract:` block of `projectYamlText` (D2): absent, or
65
+ * its rows and every fault in it. A row whose name is valid is in `rows`
66
+ * even when another of its keys is at fault, so its name still counts as
67
+ * declared; the fault is reported either way.
68
+ */
69
+ export declare function readEnvContract(projectYamlText: string): EnvContract;
70
+ /**
71
+ * The topology `.ailk-site-manifest.json` records under `root` (D8): a
72
+ * missing file or key reads `"hub"`; an unknown value or a file that is not
73
+ * JSON is a fault. The `readSiteTopology` rule in create-ailk's `topology.ts`.
74
+ */
75
+ export declare function readManifestTopology(root: string): {
76
+ topology: SiteTopology;
77
+ } | {
78
+ fault: string;
79
+ };
80
+ /** The check's result: one line per finding, and the line to print on a pass. */
81
+ export interface DeclaredEnvResult {
82
+ findings: string[];
83
+ summary: string;
84
+ }
85
+ /**
86
+ * Check the site at `root` (D5): every literal env read has an
87
+ * `env_contract` row, every row is well-formed, and on a spoke
88
+ * `NEXT_PUBLIC_AILK_API_URL` is neither read nor declared. A hub with no
89
+ * block has nothing to check; a spoke with no block is a finding.
90
+ */
91
+ export declare function checkDeclaredEnv(root: string): DeclaredEnvResult;
92
+ /** A block the writer cannot read, so it never appends to it (D7). */
93
+ export declare class EnvContractError extends Error {
94
+ constructor(message: string);
95
+ }
96
+ /** What a row says about a name the writer knows (create-ailk's catalog). */
97
+ export interface EnvCatalogEntry {
98
+ description: string;
99
+ secret: boolean;
100
+ }
101
+ export interface PlanEnvRowsOptions {
102
+ /** The site's topology — passed by the caller, never read from the manifest. */
103
+ topology: SiteTopology;
104
+ /** Described rows for known names; any other name gets a placeholder row. */
105
+ catalog: Readonly<Record<string, EnvCatalogEntry>>;
106
+ /**
107
+ * Names the site depends on that an installed package reads on its behalf,
108
+ * so the scan never sees them (#7838). Each gets a row when it has none,
109
+ * with no reader and so no `scope`; a platform or refused name is skipped.
110
+ */
111
+ declare?: readonly string[];
112
+ }
113
+ /**
114
+ * `projectYamlText` with a row appended for every name the site at `root`
115
+ * reads that has none (D7), sorted by name, after the existing rows. Platform
116
+ * names, and on a spoke `SPOKE_REFUSED_ENV`, get no row. Pure: it reads the
117
+ * tree, never writes. `added` names the rows it wrote.
118
+ *
119
+ * @throws EnvContractError when the existing block has a fault — the writer
120
+ * never appends to a block it cannot read.
121
+ */
122
+ export declare function planEnvRows(root: string, projectYamlText: string, opts: PlanEnvRowsOptions): {
123
+ text: string;
124
+ added: string[];
125
+ };
126
+ /** What `appendEnvRows` did to `<root>/project.yaml`. */
127
+ export type AppendEnvRowsResult = {
128
+ state: "written";
129
+ added: string[];
130
+ } | {
131
+ state: "unchanged";
132
+ } | {
133
+ state: "no-project-yaml";
134
+ };
135
+ /**
136
+ * Plan and write `<root>/project.yaml`'s new rows (D7). Writes only when the
137
+ * text changed, so a second run changes nothing; never edits or removes a
138
+ * row. A site with no `project.yaml` is left alone.
139
+ *
140
+ * @throws EnvContractError on a block it cannot read (nothing is written).
141
+ */
142
+ export declare function appendEnvRows(root: string, opts: PlanEnvRowsOptions): AppendEnvRowsResult;