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
package/README.md CHANGED
@@ -262,6 +262,130 @@ the route-surface registry in the same file.
262
262
  `ROUTE_SINCE` entry — `__tests__/sync-routes.test.ts` fails otherwise (and it
263
263
  fails on a stale entry for a route the template no longer ships).
264
264
 
265
+ ### Adopting an existing site
266
+
267
+ A site that create-ailk did not scaffold, or that was scaffolded before
268
+ `--topology` existed, does not record its topology. Every create-ailk command
269
+ then treats it as a hub — wrong for a site that was trimmed to a spoke by hand.
270
+ `adopt` records it, once:
271
+
272
+ ```bash
273
+ # a site with a .ailk-site-manifest.json (default target: cwd)
274
+ create-ailk adopt ./my-site
275
+ # a site with none: name its template, and its modules when
276
+ # architecture.yaml's "# Modules:" header is missing or out of date
277
+ create-ailk adopt ./my-site --template person-service --modules marketing,docs-blog
278
+ ```
279
+
280
+ It writes two fields into `.ailk-site-manifest.json` (and, on a spoke, the
281
+ `env_contract` rows below):
282
+
283
+ - `topology` is read from the tree. No `apps/api` and no `database/` is a
284
+ spoke; both is a hub; one without the other is refused.
285
+ - `patternVersion` is the create-ailk version you ran — the version the site
286
+ was adopted against.
287
+
288
+ A site with a manifest keeps every other byte of it. A site with none gets one,
289
+ built the way a scaffold builds it, from `--template` and the module set
290
+ (`--modules`, else the `architecture.yaml` header). `--template` and
291
+ `--modules` are refused on a site that already has a manifest, because the
292
+ manifest already records both. A root `TEMPLATE-MANIFEST.json` (the roster's
293
+ older name) is refused until you rename it to `.ailk-site-manifest.json`.
294
+
295
+ The report lists each generated seam file (`apps/web/lib/topology.generated.ts`,
296
+ and the `apps/mcp` one when `apps/mcp` exists) as `present`, `differs` or
297
+ `absent` against the render for the site's topology. On a spoke it also warns
298
+ about each recorded module a spoke cannot carry. Neither changes the exit code:
299
+ adopt records the site, it does not admit modules — `add-module` does that.
300
+
301
+ On a spoke with a `project.yaml`, adopt also appends an `env_contract` row
302
+ for every environment variable the site's code reads that has none, so the
303
+ spoke's `pnpm env:declared` check has a contract to read. It never edits or
304
+ removes a row, and a block it cannot read is refused with exit 2 before
305
+ anything is written. A hub's `project.yaml` is never touched, and adopt does
306
+ not install the check itself.
307
+
308
+ Beyond that, adopt never writes a seam file, `architecture.yaml` or anything
309
+ under `apps/api`, and never runs another command (`sync-routes --apply`
310
+ included). A second run is refused with exit 2 and the manifest untouched.
311
+
312
+ ### Keeping a spoke current
313
+
314
+ A spoke's scaffold-written files are few: the generated seams, the
315
+ declared-env check (`tools/declared-env.ts`, `tools/check-declared-env.ts`)
316
+ and the one-line route re-exports; everything else is content, or code that arrives
317
+ through a `@working-theory/*` package bump. create-ailk does not re-scaffold a
318
+ site in place, and there is no re-sync command: `add-module` never overwrites
319
+ an edited file, so a re-sync could only report. Moving a spoke to a new
320
+ create-ailk version is a per-version review instead:
321
+
322
+ 1. **Read the changesets.** A create-ailk changeset that changes what
323
+ create-ailk writes into a scaffold-written spoke file names each such file
324
+ on a line beginning `Scaffold-written files:`. Search the CHANGELOG between
325
+ your site's `patternVersion` and the new version for that line.
326
+ 2. **Check the routes.** `create-ailk sync-routes ./my-site` lists missing and
327
+ locally modified route directories without writing anything.
328
+ 3. **Apply each named change by hand**, then set `patternVersion` in
329
+ `.ailk-site-manifest.json` to the version the changes came from. It is a
330
+ one-value edit; `adopt` will not do it, because it refuses a second run.
331
+
332
+ **Maintainer rule:** a create-ailk changeset that changes the content of a file
333
+ create-ailk writes into a spoke scaffold — a seam render, a route re-export, a
334
+ root-file transform — MUST carry a `Scaffold-written files:` line naming each
335
+ such file, site-relative. This is a convention, not a lint.
336
+
337
+ ### Seeding a spoke's flows
338
+
339
+ A spoke's flows live in its own repo as **declared data**: `flows/<slug>.json`,
340
+ each one flow config exactly as the hub stores it — `slug`, `defaultLocale`,
341
+ `steps`, and optionally `thankYou` and `flags`. The file name is the config's
342
+ `slug` plus `.json`. A spoke carrying `signal-collectors` gets the module's
343
+ `referral` and `fit-check` flows there (a hub gets them under
344
+ `database/flows/`), plus a `flows/README.md`.
345
+
346
+ `seed-flows` writes them into the spoke's hub workspace, as one rollout step:
347
+
348
+ ```bash
349
+ AILK_SPOKE_KEY=<the spoke's key> create-ailk seed-flows ./my-spoke
350
+ create-ailk seed-flows ./my-spoke --dry-run # check the files, send nothing
351
+ ```
352
+
353
+ - The key is read from `AILK_SPOKE_KEY` only — never a flag or a file — and
354
+ never printed.
355
+ - The hub origin is `--hub-url`, else `AILK_API_URL`, else the `AILK_API_URL=`
356
+ line of `apps/web/.env.local`; it must be `https:` (or `http:` on a loopback
357
+ host). The first line printed names it and its source, before any request.
358
+ - Each flow is sent to `PUT <hub>/v1/flows/<slug>/definition` and prints
359
+ `created`, `updated` or `unchanged`, so a re-run is safe. A failure prints
360
+ only its status and code, and the rest still run.
361
+ - Exit 0 when every flow was accepted, 1 when any failed, 2 on a refusal (a hub
362
+ site, a bad file, no key, no valid hub origin), which sends nothing.
363
+
364
+ The hub must have `AILK_SPOKE_FLOW_SEED=true`; until it does and this has run,
365
+ the spoke's `/flows/<slug>` links reach no flow. The repo owns each flow's
366
+ config: the next seed that differs overwrites an Admin edit to it. The hub
367
+ refuses any block it acts on outside the spoke's own pages — emails,
368
+ deliverables, pricing, registers, listings — so a flow that needs one is
369
+ authored in the hub's Admin; once it carries one, the hub refuses a seed of
370
+ that flow (`409 slug_owned`) rather than delete the block.
371
+
372
+ ### A spoke's declared environment
373
+
374
+ A spoke scaffold's `pnpm preflight` and CI run `pnpm env:declared`: every
375
+ literal `process.env` read in the site's code needs a row in `project.yaml`'s
376
+ `env_contract` block (`name`, a one-line `description`, `secret: true` or
377
+ `false`, and optionally `scope: web | api | mcp`). Platform names (`NODE_ENV`,
378
+ `CI`, `PORT`, `VERCEL` and `VERCEL_*`, `NEXT_RUNTIME`, `NEXT_PHASE`) need
379
+ none. `NEXT_PUBLIC_AILK_API_URL` is refused on a spoke, read or declared: a
380
+ spoke reaches its hub through the server-only `AILK_API_URL`.
381
+
382
+ create-ailk writes the rows for what it ships — at scaffold time, and again
383
+ whenever `add-module`, `add-route` or `sync-routes --apply` adds code to a
384
+ spoke — and only ever appends: a row you edit is yours. Add a row by hand for
385
+ a variable your own code reads. `env_contract` is its own key; it does not
386
+ change what `required_secrets` means. A hub scaffold carries no block, no
387
+ check and no step.
388
+
265
389
  ## Paid modules require `AILK_LICENSE_KEY`
266
390
 
267
391
  `src/lib/module-tier.ts` splits a requested module set into its free and paid
@@ -32,14 +32,17 @@ import { AddModuleRefusedError, readArchitectureHeader, } from "./add-module-cor
32
32
  import { deriveArchitecture } from "./derive-architecture.js";
33
33
  import { LEGACY_NAV_RENDERERS } from "./legacy-nav-renderers.js";
34
34
  import { writeAuthSecretFile } from "./lib/auth-secret-file.js";
35
+ import { appendEnvRows } from "./lib/declared-env.js";
35
36
  import { diffTwoStrings, extractModuleBundle, } from "./lib/extract-module-bundle.js";
36
37
  import { normalizeModules } from "./module-architecture.js";
37
38
  import { PAID_SECTIONS_PATH, writePaidSections } from "./paid-sections.js";
38
39
  import { ParseError, parseModules } from "./parse-args.js";
40
+ import { writeSpokeFlowsReadme } from "./seed-flows.js";
39
41
  import { SITE_ROSTER_PATH, moduleRosterRows, planRosterAppend, readSiteRoster, siteRosterProblems, } from "./site-roster.js";
42
+ import { spokeCheckoutSite, spokeEnvRowOptions, writeSpokeCheckoutSlice, } from "./spoke-checkout.js";
40
43
  import { MODULE_SURFACES, NAV_CONFIG_PATH, readNavConfigStamp, renderNavConfig, resolveSurfaces, } from "./surfaces.js";
41
44
  import { syncRoutes } from "./sync-routes.js";
42
- import { TopologyError, isSpokeExcluded, readSiteTopology, spokeModuleRefusal, } from "./topology.js";
45
+ import { TopologyError, isSpokeExcluded, readSiteTopology, readSpokeShape, spokeDestination, spokeModuleRefusal, } from "./topology.js";
43
46
  /**
44
47
  * The site's topology for add-module (#7426 D13): an unreadable manifest is a
45
48
  * refusal this command names (exit 2), before the first write — the same
@@ -55,6 +58,20 @@ export function addModuleSiteTopology(targetDir) {
55
58
  throw err;
56
59
  }
57
60
  }
61
+ /**
62
+ * The site's spoke options for add-module (#7894 D1) — `readSpokeShape`, with
63
+ * an unreadable manifest refused the way `addModuleSiteTopology` refuses it.
64
+ */
65
+ function addModuleSpokeShape(targetDir) {
66
+ try {
67
+ return readSpokeShape(targetDir);
68
+ }
69
+ catch (err) {
70
+ if (err instanceof TopologyError)
71
+ throw new AddModuleRefusedError(err.message);
72
+ throw err;
73
+ }
74
+ }
58
75
  export { AddModuleRefusedError, readArchitectureHeader };
59
76
  /** `package.json` paths a `json` patch may write under the plus-config policy. */
60
77
  const PACKAGE_JSON_PATCH_KEYS = new Set([
@@ -104,10 +121,9 @@ export async function applyModuleToScaffold(opts) {
104
121
  const archPath = join(targetAbs, "architecture.yaml");
105
122
  // #7426 D13 — a spoke stays a spoke: a module it does not admit is refused
106
123
  // before the first write, and an admitted one lands through the spoke
107
- // filter. `mcp` follows whether the spoke still carries apps/mcp.
108
- const spoke = addModuleSiteTopology(targetAbs) === "spoke"
109
- ? { mcp: existsSync(join(targetAbs, "apps", "mcp")) }
110
- : undefined;
124
+ // filter. `mcp` follows whether the spoke still carries apps/mcp, and the
125
+ // sign-in mode is the manifest's (#7894 D1).
126
+ const spoke = addModuleSpokeShape(targetAbs);
111
127
  if (spoke !== undefined) {
112
128
  const refusal = spokeModuleRefusal(opts.moduleName);
113
129
  if (refusal !== undefined)
@@ -140,9 +156,28 @@ export async function applyModuleToScaffold(opts) {
140
156
  existingFilePolicy: "skip-existing",
141
157
  checkPatches: checkPlusConfigPatches,
142
158
  ...(spoke !== undefined
143
- ? { skip: (destination) => isSpokeExcluded(destination, spoke) }
159
+ ? {
160
+ skip: (destination) => isSpokeExcluded(destination, spoke),
161
+ // #7876 D10 — a module flow file lands in flows/ as declared data.
162
+ remapDestination: spokeDestination,
163
+ }
144
164
  : {}),
145
165
  });
166
+ // #7838 D8 — commerce on a spoke brings the spoke's own checkout, stamped
167
+ // from the site directory's name (the `projectName` scaffold() defaults to).
168
+ // A file the site already has is left as it is.
169
+ const spokeCommerce = spoke !== undefined && opts.moduleName === "commerce";
170
+ const checkoutRows = spokeCommerce
171
+ ? writeSpokeCheckoutSlice(targetAbs, {
172
+ site: spokeCheckoutSite(basename(targetAbs)),
173
+ versions: opts.versions,
174
+ })
175
+ : [];
176
+ // #7876 D9 — a spoke that just gained the flow stepper says what its
177
+ // flows/ is and how its rollout seeds it; an edited README is kept.
178
+ if (spoke !== undefined && opts.moduleName === "signal-collectors") {
179
+ writeSpokeFlowsReadme(targetAbs);
180
+ }
146
181
  const routes = syncRoutes({
147
182
  targetDir: targetAbs,
148
183
  templatesDir: opts.templatesDir,
@@ -169,7 +204,7 @@ export async function applyModuleToScaffold(opts) {
169
204
  writePaidSections(targetAbs);
170
205
  const registryChanged = existsSync(registryPath) &&
171
206
  readFileSync(registryPath, "utf8") !== registryBefore;
172
- const rows = [...report];
207
+ const rows = [...report, ...checkoutRows];
173
208
  if (registryChanged)
174
209
  rows.push({ path: PAID_SECTIONS_PATH, action: "written" });
175
210
  const navRow = syncNavConfig(targetAbs, current.surfaces, surfaces);
@@ -223,6 +258,16 @@ export async function applyModuleToScaffold(opts) {
223
258
  templatesDir: opts.templatesDir,
224
259
  });
225
260
  }
261
+ // #7874 D9 — a spoke declares what the bundle reads, as the last write. A
262
+ // malformed env_contract block throws EnvContractError naming its line;
263
+ // the files written above stay, and `pnpm env:declared` names the same fault.
264
+ // A commerce spoke also declares what the checkout package reads (#7838 D9).
265
+ if (spoke !== undefined) {
266
+ appendEnvRows(targetAbs, {
267
+ topology: "spoke",
268
+ ...spokeEnvRowOptions(spokeCommerce),
269
+ });
270
+ }
226
271
  return { targetDir: targetAbs, modules, report: rows, routes, roster };
227
272
  }
228
273
  /**
package/dist/add-route.js CHANGED
@@ -33,6 +33,8 @@ import { parseArgs } from "node:util";
33
33
  import { AddModuleRefusedError, readArchitectureHeader, syncNavConfig, } from "./add-module.js";
34
34
  import { isTextFile } from "./copy.js";
35
35
  import { deriveArchitecture } from "./derive-architecture.js";
36
+ import { appendEnvRows } from "./lib/declared-env.js";
37
+ import { ENV_CATALOG } from "./lib/env-catalog.js";
36
38
  import { diffTwoStrings, } from "./lib/extract-module-bundle.js";
37
39
  import { ParseError } from "./parse-args.js";
38
40
  import { substituteMarkers } from "./substitute.js";
@@ -124,6 +126,12 @@ export function addRouteToScaffold(opts) {
124
126
  // written only once every other step has landed. A spoke's `syncRoutes`
125
127
  // call above already skipped what it lacks (#7426 D13).
126
128
  writeFileSync(archPath, deriveArchitecture(current.modules, surfaces, spoke !== undefined ? { topology: "spoke", mcp: spoke.mcp } : {}));
129
+ // #7874 D9 — a spoke declares what the route reads. A malformed
130
+ // env_contract block throws EnvContractError naming its line; the files
131
+ // written above stay, and `pnpm env:declared` names the same fault.
132
+ if (spoke !== undefined) {
133
+ appendEnvRows(targetAbs, { topology: "spoke", catalog: ENV_CATALOG });
134
+ }
127
135
  return { targetDir: targetAbs, route, surfaces, report, routes };
128
136
  }
129
137
  /** The canonical id for one route argument, refusing an unknown one. */
@@ -0,0 +1,74 @@
1
+ import { type Topology } from "./topology.js";
2
+ /** A refusal the CLI exits 2 on, thrown before anything is written. */
3
+ export declare class AdoptRefusedError extends Error {
4
+ readonly exitCode = 2;
5
+ constructor(message: string);
6
+ }
7
+ export interface AdoptOptions {
8
+ /** The existing site. */
9
+ targetDir: string;
10
+ /** The package's `templates/` dir, for the manifest writer. */
11
+ templatesDir: string;
12
+ /** The package's `site-templates/` dir, for the manifest writer. */
13
+ siteTemplatesDir: string;
14
+ /** `--template` — required, and only allowed, when the site has no manifest. */
15
+ template?: string | undefined;
16
+ /** `--modules`, comma-separated — only allowed when the site has no manifest. */
17
+ modules?: string | undefined;
18
+ }
19
+ /** A generated seam file's state against the render for the derived topology. */
20
+ export interface AdoptSeam {
21
+ path: string;
22
+ state: "present" | "differs" | "absent";
23
+ }
24
+ export interface AdoptResult {
25
+ /** Resolved absolute path to the site. */
26
+ targetDir: string;
27
+ /** The site-relative manifest written. */
28
+ path: string;
29
+ topology: Topology;
30
+ patternVersion: string;
31
+ /** Whether the manifest was created (no manifest before) or gained the two fields. */
32
+ created: boolean;
33
+ /** One per seam file whose app directory exists (D8) — information only. */
34
+ seams: AdoptSeam[];
35
+ /** One per recorded module a spoke cannot carry (D6) — never a refusal. */
36
+ warnings: string[];
37
+ /** What adopt did to a spoke's `project.yaml` `env_contract` (#7874 D9). */
38
+ env: AdoptEnvResult;
39
+ }
40
+ /** What adopt did to `project.yaml` (#7874 D9): only a spoke's is written. */
41
+ export type AdoptEnvResult = {
42
+ state: "written";
43
+ added: string[];
44
+ } | {
45
+ state: "unchanged";
46
+ } | {
47
+ state: "no-project-yaml";
48
+ } | {
49
+ state: "hub";
50
+ };
51
+ /**
52
+ * Adopt the site at `opts.targetDir`: write the manifest recording its
53
+ * topology and pattern version. Every refusal throws before the one write.
54
+ *
55
+ * @throws AdoptRefusedError (exit 2) on a refusal this command names, and
56
+ * the `AddModuleRefusedError` (exit 2) the roster readers and manifest
57
+ * writer throw on a manifest they cannot read or extend.
58
+ */
59
+ export declare function adoptSite(opts: AdoptOptions): AdoptResult;
60
+ /** The printed report: the path written, the record, the seams, the warnings. */
61
+ export declare function formatAdoptReport(result: AdoptResult): string;
62
+ export interface AdoptArgs {
63
+ /** Positional target directory; undefined = the caller's cwd. */
64
+ target: string | undefined;
65
+ template: string | undefined;
66
+ modules: string | undefined;
67
+ help: boolean;
68
+ }
69
+ export declare const ADOPT_HELP = "create-ailk adopt \u2014 record what an existing site is, once.\n\nUsage:\n create-ailk adopt [<target-dir>] [--template <name>] [--modules <a,b>]\n\nArguments:\n <target-dir> The existing site. Defaults to the current dir.\n\nOptions:\n --template <name> The template the site was built from. Required\n when the site has no .ailk-site-manifest.json;\n refused when it has one.\n --modules <a,b> The site's module set, when it has no manifest.\n Overrides architecture.yaml's \"# Modules:\" header.\n --help, -h Print this message.\n\nWrites \"topology\" and \"patternVersion\" into .ailk-site-manifest.json. On a\nspoke it also appends an env_contract row to project.yaml for every\nenvironment variable the site's code reads that has none, so pnpm\nenv:declared has a contract to check; it never edits or removes a row, and\nit never touches a hub's project.yaml. Nothing else is written. The\ntopology is read from the tree: no apps/api and no database/ is a spoke,\nboth is a hub, one without the other is refused.\npatternVersion is this create-ailk's version. A site with no manifest gets\none, built the way a scaffold builds it. A manifest that already records\neither field is refused: adopt runs once. A recorded module a spoke cannot\ncarry is a warning, not a refusal.\n";
70
+ /**
71
+ * Parse the argv AFTER the `adopt` word. Strict `node:util` parseArgs, like
72
+ * `parseAddRouteArgs`; the values themselves are validated by `adoptSite`.
73
+ */
74
+ export declare function parseAdoptArgs(argv: string[]): AdoptArgs;
package/dist/adopt.js ADDED
@@ -0,0 +1,314 @@
1
+ /**
2
+ * `create-ailk adopt [<target-dir>]` — record, once, what an existing site
3
+ * is (#7875).
4
+ *
5
+ * A site scaffolded before #7426, or not scaffolded by create-ailk at all,
6
+ * has no `topology` in its `.ailk-site-manifest.json`, so every create-ailk
7
+ * command that asks `readSiteTopology` treats it as a hub — wrong for a site
8
+ * trimmed to a spoke by hand. Adopt writes the two fields the keystone added
9
+ * (D10 of spec 7426), and nothing else:
10
+ *
11
+ * - `topology` is derived from the tree against `SPOKE_REMOVED_ROOTS`
12
+ * (D1) — all removed roots absent is a spoke, all present a hub, a mix
13
+ * is refused. Never a flag: the tree is the evidence;
14
+ * - `patternVersion` is the running create-ailk version, the value a
15
+ * scaffold records (D8 — on an adopted site, the version it was adopted
16
+ * against);
17
+ * - an existing manifest gains the two fields, every other byte kept
18
+ * (`planTopologyRecord`, D3); a site with none gets one built by the
19
+ * scaffold's own writer from `--template` and the module set
20
+ * (`planAdoptedSiteManifest`, D4/D5);
21
+ * - a manifest already recording either field is refused (D2): adopt runs
22
+ * once. A recorded module a spoke cannot carry is a warning, never a
23
+ * refusal (D6) — `add-module` is the admission gate, not adopt.
24
+ *
25
+ * It writes the manifest — never a seam file, `architecture.yaml`, anything
26
+ * under `apps/api`, and never another subcommand (`sync-routes --apply`
27
+ * included). The one exception is a spoke's `project.yaml` (#7874 D9): on a
28
+ * site it derives as a spoke, adopt also appends an `env_contract` row for
29
+ * every env var the site's code reads that has none, so its `pnpm
30
+ * env:declared` check has a contract to read. A hub's `project.yaml` is never
31
+ * touched. Every refusal throws before any write.
32
+ *
33
+ * Pure module: no `import.meta.url`, so jest can import it directly — the
34
+ * package-rooted directories are supplied by the CLI, the `add-route.ts`
35
+ * split. Spec: docs/specs/7875-create-ailk-adopt-spec.md.
36
+ */
37
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
38
+ import { basename, join, resolve } from "node:path";
39
+ import { parseArgs } from "node:util";
40
+ import { readArchitectureHeader } from "./add-module-core.js";
41
+ import { buildScaffoldMarkers } from "./copy.js";
42
+ import { EnvContractError, planEnvRows } from "./lib/declared-env.js";
43
+ import { ENV_CATALOG } from "./lib/env-catalog.js";
44
+ import { normalizeModules } from "./module-architecture.js";
45
+ import { ParseError, RECOGNIZED_MODULES, } from "./parse-args.js";
46
+ import { planAdoptedSiteManifest, planTopologyRecord, } from "./site-manifest.js";
47
+ import { LEGACY_SITE_ROSTER_PATH, SITE_ROSTER_PATH, readSiteRoster, resolveRosterTemplate, } from "./site-roster.js";
48
+ import { SHAPE_STARTERS } from "./template-manifest.js";
49
+ import { MCP_SEAM_PATH, SPOKE_REMOVED_ROOTS, WEB_SEAM_PATH, renderMcpSeam, renderWebSeam, spokeModuleRefusal, } from "./topology.js";
50
+ /** A refusal the CLI exits 2 on, thrown before anything is written. */
51
+ export class AdoptRefusedError extends Error {
52
+ exitCode = 2;
53
+ constructor(message) {
54
+ super(message);
55
+ this.name = "AdoptRefusedError";
56
+ }
57
+ }
58
+ /** The generated seam files, by the app directory each lives in, with their renders. */
59
+ const SEAMS = [
60
+ { app: "apps/web", path: WEB_SEAM_PATH, render: renderWebSeam },
61
+ { app: "apps/mcp", path: MCP_SEAM_PATH, render: renderMcpSeam },
62
+ ];
63
+ function isDirectory(path) {
64
+ return existsSync(path) && statSync(path).isDirectory();
65
+ }
66
+ /**
67
+ * D1 — the topology the tree shows: every `SPOKE_REMOVED_ROOTS` entry absent
68
+ * is a spoke, every one present a hub. Anything else is refused.
69
+ */
70
+ function deriveTopology(targetDir) {
71
+ const present = SPOKE_REMOVED_ROOTS.filter((root) => isDirectory(join(targetDir, root)));
72
+ if (present.length === 0)
73
+ return "spoke";
74
+ if (present.length === SPOKE_REMOVED_ROOTS.length)
75
+ return "hub";
76
+ const absent = SPOKE_REMOVED_ROOTS.filter((root) => !present.includes(root));
77
+ throw new AdoptRefusedError(`adopt: ${targetDir} has ${present.join(", ")} but not ${absent.join(", ")}, so it is neither a hub nor a spoke`);
78
+ }
79
+ /** D5 — the module set for a site with no manifest: `--modules`, else the header. */
80
+ function moduleSet(targetDir, flag) {
81
+ if (flag !== undefined) {
82
+ try {
83
+ return normalizeModules(flag.split(","));
84
+ }
85
+ catch (err) {
86
+ throw new AdoptRefusedError(`--modules is not a module list: ${err.message}`);
87
+ }
88
+ }
89
+ const archPath = join(targetDir, "architecture.yaml");
90
+ if (!existsSync(archPath)) {
91
+ throw new AdoptRefusedError(`${targetDir} has no architecture.yaml to read its module set from; name it with --modules <a,b>`);
92
+ }
93
+ try {
94
+ return readArchitectureHeader(readFileSync(archPath, "utf8")).modules;
95
+ }
96
+ catch (err) {
97
+ throw new AdoptRefusedError(`${err.message.replace(/\.$/, "")}. Name the module set with --modules <a,b> instead.`);
98
+ }
99
+ }
100
+ /** The recorded module names this package recognizes; anything else is ignored. */
101
+ function recordedModules(value) {
102
+ if (!Array.isArray(value))
103
+ return [];
104
+ return value.filter((m) => RECOGNIZED_MODULES.includes(m));
105
+ }
106
+ /**
107
+ * Adopt the site at `opts.targetDir`: write the manifest recording its
108
+ * topology and pattern version. Every refusal throws before the one write.
109
+ *
110
+ * @throws AdoptRefusedError (exit 2) on a refusal this command names, and
111
+ * the `AddModuleRefusedError` (exit 2) the roster readers and manifest
112
+ * writer throw on a manifest they cannot read or extend.
113
+ */
114
+ export function adoptSite(opts) {
115
+ const targetDir = resolve(opts.targetDir);
116
+ if (!isDirectory(targetDir)) {
117
+ throw new Error(`adopt target is not a directory: ${targetDir}. Point adopt at an existing site.`);
118
+ }
119
+ const topology = deriveTopology(targetDir);
120
+ const markers = buildScaffoldMarkers(basename(targetDir));
121
+ const patternVersion = markers["CREATE_AILK_VERSION"];
122
+ if (patternVersion === undefined || patternVersion === "") {
123
+ // A once-only field: never record an empty version a second run cannot fix.
124
+ throw new Error("adopt: this create-ailk has no version to record");
125
+ }
126
+ const record = { topology, patternVersion };
127
+ const roster = readSiteRoster(targetDir);
128
+ let text;
129
+ let modules;
130
+ if (roster !== undefined) {
131
+ if (roster.path === LEGACY_SITE_ROSTER_PATH) {
132
+ throw new AdoptRefusedError(`${LEGACY_SITE_ROSTER_PATH} is the pre-#6658 roster name; rename it to ${SITE_ROSTER_PATH}, then re-run adopt.`);
133
+ }
134
+ for (const key of ["topology", "patternVersion"]) {
135
+ if (key in roster.doc) {
136
+ throw new AdoptRefusedError(`${roster.path} already records "${key}", so this site is already adopted. To move it to a new create-ailk version, see the README § Keeping a spoke current.`);
137
+ }
138
+ }
139
+ for (const [flag, value] of [
140
+ ["--template", opts.template],
141
+ ["--modules", opts.modules],
142
+ ]) {
143
+ if (value !== undefined) {
144
+ throw new AdoptRefusedError(`${roster.path} already records the site's template and modules; ${flag} would contradict it. Re-run adopt without ${flag}.`);
145
+ }
146
+ }
147
+ text = planTopologyRecord(roster.text, record, roster.path);
148
+ modules = recordedModules(roster.doc["modules"]);
149
+ }
150
+ else {
151
+ if (opts.template === undefined) {
152
+ throw new AdoptRefusedError(`${targetDir} has no ${SITE_ROSTER_PATH}; name the template it was built from with --template <name> (one of: ${Object.keys(SHAPE_STARTERS).join(", ")}).`);
153
+ }
154
+ try {
155
+ resolveRosterTemplate(opts.template);
156
+ }
157
+ catch (err) {
158
+ throw new AdoptRefusedError(`--template "${opts.template}" names no declared template: ${err.message}`);
159
+ }
160
+ modules = moduleSet(targetDir, opts.modules);
161
+ text = planAdoptedSiteManifest({
162
+ targetDir,
163
+ template: opts.template,
164
+ modules,
165
+ ...record,
166
+ siteTemplatesDir: opts.siteTemplatesDir,
167
+ templatesDir: opts.templatesDir,
168
+ markers,
169
+ });
170
+ }
171
+ const warnings = topology === "spoke"
172
+ ? modules.flatMap((m) => spokeModuleRefusal(m) ?? [])
173
+ : [];
174
+ const seams = SEAMS.filter(({ app }) => isDirectory(join(targetDir, app))).map(({ path, render }) => {
175
+ const abs = join(targetDir, path);
176
+ if (!existsSync(abs))
177
+ return { path, state: "absent" };
178
+ return {
179
+ path,
180
+ state: readFileSync(abs, "utf8") === render(topology) ? "present" : "differs",
181
+ };
182
+ });
183
+ // #7874 D9 — a spoke's env_contract rows, planned before any write: a
184
+ // block adopt cannot read is a refusal, and nothing is written.
185
+ const yamlPath = join(targetDir, "project.yaml");
186
+ let env = { state: "hub" };
187
+ let yamlNext;
188
+ if (topology === "spoke") {
189
+ if (!existsSync(yamlPath)) {
190
+ env = { state: "no-project-yaml" };
191
+ }
192
+ else {
193
+ const yamlText = readFileSync(yamlPath, "utf8");
194
+ try {
195
+ const plan = planEnvRows(targetDir, yamlText, {
196
+ topology,
197
+ catalog: ENV_CATALOG,
198
+ });
199
+ if (plan.text === yamlText) {
200
+ env = { state: "unchanged" };
201
+ }
202
+ else {
203
+ env = { state: "written", added: plan.added };
204
+ yamlNext = plan.text;
205
+ }
206
+ }
207
+ catch (err) {
208
+ if (err instanceof EnvContractError) {
209
+ throw new AdoptRefusedError(`adopt: ${err.message}`);
210
+ }
211
+ throw err;
212
+ }
213
+ }
214
+ }
215
+ writeFileSync(join(targetDir, SITE_ROSTER_PATH), text);
216
+ if (yamlNext !== undefined)
217
+ writeFileSync(yamlPath, yamlNext);
218
+ return {
219
+ targetDir,
220
+ path: SITE_ROSTER_PATH,
221
+ topology,
222
+ patternVersion,
223
+ created: roster === undefined,
224
+ seams,
225
+ warnings,
226
+ env,
227
+ };
228
+ }
229
+ /** The printed report: the path written, the record, the seams, the warnings. */
230
+ export function formatAdoptReport(result) {
231
+ const lines = [
232
+ `[create-ailk] adopt → ${result.targetDir}`,
233
+ `${result.created ? "Wrote" : "Updated"} ${result.path}`,
234
+ ` topology: ${result.topology}`,
235
+ ` patternVersion: ${result.patternVersion}`,
236
+ ];
237
+ if (result.seams.length > 0) {
238
+ lines.push(`Seam files (against the ${result.topology} render):`);
239
+ const width = Math.max(...result.seams.map((s) => s.path.length));
240
+ lines.push(...result.seams.map((s) => ` ${s.path.padEnd(width)} ${s.state}`));
241
+ }
242
+ const env = formatAdoptEnv(result.env);
243
+ if (env !== undefined)
244
+ lines.push(env);
245
+ lines.push(...result.warnings.map((w) => `warning: ${w}`));
246
+ return lines.join("\n") + "\n";
247
+ }
248
+ /** The report's one env_contract line (#7874 D9); none on a hub. */
249
+ function formatAdoptEnv(env) {
250
+ switch (env.state) {
251
+ case "written":
252
+ return `env_contract: ${env.added.length} rows added (${env.added.join(", ")})`;
253
+ case "unchanged":
254
+ return "env_contract: every read already declared";
255
+ case "no-project-yaml":
256
+ return "env_contract: no project.yaml, nothing written";
257
+ case "hub":
258
+ return undefined;
259
+ }
260
+ }
261
+ export const ADOPT_HELP = `create-ailk adopt — record what an existing site is, once.
262
+
263
+ Usage:
264
+ create-ailk adopt [<target-dir>] [--template <name>] [--modules <a,b>]
265
+
266
+ Arguments:
267
+ <target-dir> The existing site. Defaults to the current dir.
268
+
269
+ Options:
270
+ --template <name> The template the site was built from. Required
271
+ when the site has no .ailk-site-manifest.json;
272
+ refused when it has one.
273
+ --modules <a,b> The site's module set, when it has no manifest.
274
+ Overrides architecture.yaml's "# Modules:" header.
275
+ --help, -h Print this message.
276
+
277
+ Writes "topology" and "patternVersion" into .ailk-site-manifest.json. On a
278
+ spoke it also appends an env_contract row to project.yaml for every
279
+ environment variable the site's code reads that has none, so pnpm
280
+ env:declared has a contract to check; it never edits or removes a row, and
281
+ it never touches a hub's project.yaml. Nothing else is written. The
282
+ topology is read from the tree: no apps/api and no database/ is a spoke,
283
+ both is a hub, one without the other is refused.
284
+ patternVersion is this create-ailk's version. A site with no manifest gets
285
+ one, built the way a scaffold builds it. A manifest that already records
286
+ either field is refused: adopt runs once. A recorded module a spoke cannot
287
+ carry is a warning, not a refusal.
288
+ `;
289
+ /**
290
+ * Parse the argv AFTER the `adopt` word. Strict `node:util` parseArgs, like
291
+ * `parseAddRouteArgs`; the values themselves are validated by `adoptSite`.
292
+ */
293
+ export function parseAdoptArgs(argv) {
294
+ const parsed = parseArgs({
295
+ args: argv,
296
+ strict: true,
297
+ allowPositionals: true,
298
+ options: {
299
+ template: { type: "string" },
300
+ modules: { type: "string" },
301
+ help: { type: "boolean", default: false, short: "h" },
302
+ },
303
+ });
304
+ const [target, ...rest] = parsed.positionals;
305
+ if (rest.length > 0) {
306
+ throw new ParseError(`adopt takes at most one target directory, got ${parsed.positionals.length} arguments`);
307
+ }
308
+ return {
309
+ target,
310
+ template: parsed.values.template,
311
+ modules: parsed.values.modules,
312
+ help: parsed.values.help === true,
313
+ };
314
+ }