create-ailk 0.19.0 → 0.20.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 (197) hide show
  1. package/README.md +64 -7
  2. package/dist/add-module.js +7 -2
  3. package/dist/cli.js +35 -0
  4. package/dist/copy.d.ts +41 -0
  5. package/dist/copy.js +87 -21
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  9. package/dist/lib/fetch-template-snapshot.js +205 -0
  10. package/dist/lib/merge-three-way.d.ts +31 -0
  11. package/dist/lib/merge-three-way.js +65 -0
  12. package/dist/lib/scaffold-render-sweep.d.ts +23 -0
  13. package/dist/lib/scaffold-render-sweep.js +52 -0
  14. package/dist/module-architecture.js +64 -5
  15. package/dist/programmatic.d.ts +7 -1
  16. package/dist/programmatic.js +40 -3
  17. package/dist/site-roster.js +21 -4
  18. package/dist/spoke-checkout.d.ts +7 -0
  19. package/dist/spoke-checkout.js +8 -1
  20. package/dist/surfaces.d.ts +14 -0
  21. package/dist/surfaces.js +98 -3
  22. package/dist/template-record.d.ts +59 -0
  23. package/dist/template-record.js +183 -0
  24. package/dist/topology.js +29 -1
  25. package/dist/update.d.ts +48 -0
  26. package/dist/update.js +504 -0
  27. package/package.json +3 -3
  28. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  29. package/templates/.claude/hooks/architecture-check.sh +8 -0
  30. package/templates/.claude/rules/loop-contract.md +129 -0
  31. package/templates/.claude/settings.json +3 -2
  32. package/templates/.env.example +13 -0
  33. package/templates/CLAUDE.md +1 -1
  34. package/templates/CONVENTIONS.md +3 -2
  35. package/templates/_gitignore +162 -0
  36. package/templates/apps/api/.env.example +17 -1
  37. package/templates/apps/api/CLAUDE.md +18 -8
  38. package/templates/apps/api/package.json +2 -1
  39. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
  40. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  41. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  42. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  43. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  44. package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
  45. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  46. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  47. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  48. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  49. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  50. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  51. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  52. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  53. package/templates/apps/api/src/lib/stripe.ts +97 -5
  54. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
  55. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  56. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  57. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  58. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
  59. package/templates/apps/api/src/middleware/auth.ts +16 -4
  60. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  61. package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
  62. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  63. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  64. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  65. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  67. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  68. package/templates/apps/api/src/routes/leads/index.ts +15 -0
  69. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  70. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  71. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  73. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  74. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  75. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  76. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  77. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  78. package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
  79. package/templates/apps/api/src/server.ts +125 -18
  80. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  81. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  82. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  83. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
  84. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  85. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  86. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  87. package/templates/apps/api/src/services/audit-log.ts +72 -10
  88. package/templates/apps/api/src/services/consent.ts +488 -3
  89. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  90. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  91. package/templates/apps/api/src/services/lead-routing.ts +105 -12
  92. package/templates/apps/api/src/services/person.ts +501 -1
  93. package/templates/apps/api/src/services/project-listings.ts +7 -2
  94. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  95. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  96. package/templates/apps/api/src/services/revalidation.ts +11 -1
  97. package/templates/apps/api/vercel.json +1 -1
  98. package/templates/apps/mcp/CLAUDE.md +19 -7
  99. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
  100. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  101. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  102. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  103. package/templates/apps/web/_gitignore +6 -0
  104. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  105. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  106. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  107. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
  117. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
  120. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
  122. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  129. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  132. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
  134. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
  135. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
  137. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
  138. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
  139. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  141. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  142. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  143. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
  146. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  148. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
  149. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
  150. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  151. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  152. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
  153. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  154. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  155. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  156. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  157. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  158. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  159. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  160. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  161. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  162. package/templates/apps/web/auth.ts +20 -1
  163. package/templates/apps/web/jest.config.cjs +3 -0
  164. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  165. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  166. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  167. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
  168. package/templates/apps/web/lib/account-config.ts +31 -16
  169. package/templates/apps/web/lib/auth-email.ts +45 -10
  170. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  171. package/templates/apps/web/lib/relay.ts +20 -0
  172. package/templates/apps/web/lib/topology.generated.ts +9 -0
  173. package/templates/apps/web/messages/en.json +121 -18
  174. package/templates/apps/web/next.config.mjs +8 -2
  175. package/templates/database/CHANGELOG.md +55 -0
  176. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  177. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  178. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  179. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  180. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  181. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  182. package/templates/database/__tests__/person.db.test.ts +3 -1
  183. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  184. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  185. package/templates/database/_gitignore +8 -0
  186. package/templates/database/inbox/schema.prisma +114 -22
  187. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  188. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  189. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  190. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  191. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  192. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  193. package/templates/database/ops/schema.prisma +16 -0
  194. package/templates/database/package.json +1 -1
  195. package/templates/project.yaml +6 -3
  196. package/versions.json +35 -35
  197. package/templates/.claude/rules/preserve-existing-style.md +0 -51
package/README.md CHANGED
@@ -262,6 +262,62 @@ 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
+ ### Update the copied app code
266
+
267
+ A fresh scaffold records every `apps/web`, `apps/api` and `apps/mcp` file the
268
+ base template copy wrote — and no later layer (a module bundle's patch, a
269
+ generator) rewrote — in `.ailk-template-files.json`, beside
270
+ `.ailk-site-manifest.json`: each file's content hash and the create-ailk
271
+ version it came from. `update` reads that record to bring a template fix
272
+ into a site that already exists, instead of a per-site porting issue:
273
+
274
+ ```bash
275
+ create-ailk update ./my-site
276
+ ```
277
+
278
+ For each recorded file, `update` compares the site's current bytes to the
279
+ recorded hash:
280
+
281
+ - **Untouched** (the bytes still match the record) and the running
282
+ version's copy differs: the file is overwritten, and the record's hash
283
+ and version move forward. Reported as **Updated**.
284
+ - **Untouched** and the running version's copy is gone: the file is
285
+ deleted and dropped from the record. Reported as **Removed**.
286
+ - **Edited** (the bytes differ from the record) and the running version's
287
+ copy also changed: the two changes are three-way merged with `git
288
+ merge-file` against the recorded version's own copy as the base. A clean
289
+ merge keeps your edit AND the fix, and is reported as **Merged**. A
290
+ conflict leaves the file exactly as it was — never written with conflict
291
+ markers — and is reported under **Needs review** with both versions
292
+ named; nothing else in the run is held back by it.
293
+ - A recordable file the running version adds that the site lacks is
294
+ **Added**, unless it sits under a route or directory the site deliberately
295
+ does not carry (a gated-off surface, a spoke exclusion, or a directory you
296
+ deleted entirely) — those are left alone.
297
+
298
+ A file outside the gated-off surfaces and spoke exclusions the site's scope
299
+ no longer covers is **Skipped**, as is one that exists on disk but was never
300
+ recorded (a module bundle's own file, say) when its bytes differ from the
301
+ running copy. Nothing the base scaffold did not write is ever touched. No
302
+ template change at all means `Already up to date` and zero files written.
303
+
304
+ **A site scaffolded before this record existed** gets one built on its first
305
+ `update` run, from the `patternVersion` `.ailk-site-manifest.json` already
306
+ records — no separate command, no per-site setup. If that version predates
307
+ the site's actual files (an adopted site, or one whose `patternVersion` was
308
+ bumped by hand per "Keeping a spoke current" below), a file `update` cannot
309
+ tell was edited may show up under Needs review even though you never touched
310
+ it; the merge itself still never loses content.
311
+
312
+ **Requirements.** `git` must be on `PATH` — the merge runs `git merge-file`,
313
+ never a shell. Fetching an old template version (when it is not the running
314
+ package's own) reads the public npm registry at
315
+ `https://registry.npmjs.org`, overridable with `AILK_NPM_REGISTRY_URL` for a
316
+ mirror. `update` never touches content, env files, `.claude/`, lockfiles or
317
+ any `package.json` — those stay `add-module`'s and the consumer version
318
+ bump's job. Commit before running `update`, and review the diff after: a
319
+ clean merge is still worth reading.
320
+
265
321
  ### Adopting an existing site
266
322
 
267
323
  A site that create-ailk did not scaffold, or that was scaffolded before
@@ -311,13 +367,14 @@ included). A second run is refused with exit 2 and the manifest untouched.
311
367
 
312
368
  ### Keeping a spoke current
313
369
 
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:
370
+ `create-ailk update` (above) brings the copied `apps/web`, `apps/api` and
371
+ `apps/mcp` code forward on its own. What it leaves alone still needs a
372
+ per-version review: the generated seams, the declared-env check
373
+ (`tools/declared-env.ts`, `tools/check-declared-env.ts`) and the one-line
374
+ route re-exports are regenerated, not recorded, so `update` never touches
375
+ them; everything else is content, or code that arrives through a
376
+ `@working-theory/*` package bump. Moving a spoke to a new create-ailk
377
+ version still means:
321
378
 
322
379
  1. **Read the changesets.** A create-ailk changeset that changes what
323
380
  create-ailk writes into a scaffold-written spoke file names each such file
@@ -40,7 +40,7 @@ import { ParseError, parseModules } from "./parse-args.js";
40
40
  import { writeSpokeFlowsReadme } from "./seed-flows.js";
41
41
  import { SITE_ROSTER_PATH, moduleRosterRows, planRosterAppend, readSiteRoster, siteRosterProblems, } from "./site-roster.js";
42
42
  import { spokeCheckoutSite, spokeEnvRowOptions, writeSpokeCheckoutSlice, } from "./spoke-checkout.js";
43
- import { MODULE_SURFACES, NAV_CONFIG_PATH, readNavConfigStamp, renderNavConfig, resolveSurfaces, } from "./surfaces.js";
43
+ import { MODULE_SURFACES, NAV_CONFIG_PATH, isUnservedSurfaceContent, readNavConfigStamp, renderNavConfig, resolveSurfaces, } from "./surfaces.js";
44
44
  import { syncRoutes } from "./sync-routes.js";
45
45
  import { TopologyError, isSpokeExcluded, readSiteTopology, readSpokeShape, spokeDestination, spokeModuleRefusal, } from "./topology.js";
46
46
  /**
@@ -155,9 +155,14 @@ export async function applyModuleToScaffold(opts) {
155
155
  markers: opts.markers,
156
156
  existingFilePolicy: "skip-existing",
157
157
  checkPatches: checkPlusConfigPatches,
158
+ // #8427 D4 — composed with the spoke filter: a module bundle never
159
+ // writes a seed under a gateable surface this site's own
160
+ // `architecture.yaml` header (resolved above, into `surfaces`) does
161
+ // not enable.
162
+ skip: (destination) => (spoke !== undefined && isSpokeExcluded(destination, spoke)) ||
163
+ isUnservedSurfaceContent(destination, surfaces),
158
164
  ...(spoke !== undefined
159
165
  ? {
160
- skip: (destination) => isSpokeExcluded(destination, spoke),
161
166
  // #7876 D10 — a module flow file lands in flows/ as declared data.
162
167
  remapDestination: spokeDestination,
163
168
  }
package/dist/cli.js CHANGED
@@ -19,6 +19,9 @@
19
19
  * to a linked Vercel project and redeploy, exit (#6683 D9)
20
20
  * on an `apply-design` subcommand: rewrite an existing site's look,
21
21
  * print the changed-file report, exit (#6819)
22
+ * on an `update` subcommand: bring template fixes into a site's
23
+ * already-copied apps/web, apps/api and apps/mcp code, print the
24
+ * per-file report, exit (2 on a refusal it names) (#8522)
22
25
  * 1. parse argv → CliOptions
23
26
  * 2. on --help: print helpText, exit 0
24
27
  * 3. on --version: print version, exit 0
@@ -58,6 +61,7 @@ import { addModule, formatSurfaceDrift, scaffold, syncArchitectureSync, syncCata
58
61
  import { SEED_FLOWS_HELP, SeedFlowsRefusedError, parseSeedFlowsArgs, seedFlows, } from "./seed-flows.js";
59
62
  import { CORE_SURFACE_PATHS, SURFACE_ALIASES } from "./surfaces.js";
60
63
  import { SYNC_ROUTES_HELP, formatSyncRoutesReport, parseSyncRoutesArgs, } from "./sync-routes.js";
64
+ import { UPDATE_HELP, UpdateRefusedError, formatUpdateReport, parseUpdateArgs, updateSite, } from "./update.js";
61
65
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
62
66
  const PKG_JSON = resolve(PKG_ROOT, "package.json");
63
67
  function readPackageVersion() {
@@ -272,6 +276,35 @@ async function main() {
272
276
  process.stdout.write(formatApplyDesignReport(result));
273
277
  process.exit(0);
274
278
  }
279
+ // `update [<target-dir>]` (#8522): bring template fixes into a site's
280
+ // already-copied apps/web, apps/api and apps/mcp code. A refusal it names
281
+ // (no record and no patternVersion, an unparseable record, a downgrade, an
282
+ // unreadable architecture.yaml or manifest topology, a backfill whose
283
+ // fetch fails) exits 2 before anything is written. A clean run exits 0
284
+ // even when it lists files under "Needs review" — the review list is the
285
+ // hand-off, not a failure.
286
+ if (argv[0] === "update") {
287
+ const args = parseUpdateArgs(argv.slice(1));
288
+ if (args.help) {
289
+ process.stdout.write(UPDATE_HELP);
290
+ process.exit(0);
291
+ }
292
+ try {
293
+ const result = await updateSite({
294
+ targetDir: args.target ?? process.cwd(),
295
+ templatesDir: resolve(PKG_ROOT, "templates"),
296
+ version: readPackageVersion(),
297
+ });
298
+ process.stdout.write(formatUpdateReport(result));
299
+ }
300
+ catch (err) {
301
+ if (!(err instanceof UpdateRefusedError))
302
+ throw err;
303
+ process.stderr.write(`[create-ailk] ${err.message}\n`);
304
+ process.exit(err.exitCode);
305
+ }
306
+ process.exit(0);
307
+ }
275
308
  // No silent default (#6754 D2/D3): a scaffold run naming neither --shape
276
309
  // nor --template asks for a shape on a terminal and is refused without one.
277
310
  // A --design or --modules outside the release scope is refused before that
@@ -317,6 +350,8 @@ async function main() {
317
350
  "\n" +
318
351
  APPLY_DESIGN_HELP +
319
352
  "\n" +
353
+ UPDATE_HELP +
354
+ "\n" +
320
355
  LICENSE_SET_HELP);
321
356
  process.exit(0);
322
357
  }
package/dist/copy.d.ts CHANGED
@@ -19,11 +19,52 @@ export interface CopyTemplateOptions {
19
19
  * `.../services` never excludes a sibling `.../services-extra`.
20
20
  */
21
21
  excludePaths?: string[];
22
+ /**
23
+ * Called once per file actually written, with its `/`-separated
24
+ * destination path (relative to `targetDir`) and the exact bytes written
25
+ * (#8522 D2) — `create-ailk update`'s per-file record is built from these
26
+ * calls. No existing caller passes this, so their behaviour is unchanged.
27
+ */
28
+ onWrite?: (relPath: string, bytes: Uint8Array) => void;
22
29
  }
30
+ /**
31
+ * Whether a walked template-relative path is inside (or equal to) an excluded
32
+ * directory. Compares `/`-normalized paths so the check holds on Windows, and
33
+ * only at a directory boundary — a prefix that stops mid-segment is no match.
34
+ * Exported (#8522 D3) so `create-ailk update` applies the same surface-gating
35
+ * boundary to a candidate new file.
36
+ */
37
+ export declare function isExcluded(relativePath: string, excluded: readonly string[]): boolean;
38
+ /**
39
+ * The pack-safe name `build-templates.mjs` ships each snapshot `.gitignore`
40
+ * under (#7366 D1) — npm and pnpm strip a file literally named `.gitignore`
41
+ * from a packed tarball, but keep one named `_gitignore`. One definition,
42
+ * imported by `build-templates.mjs` (from the compiled `dist/copy.js`) and
43
+ * by `copyTemplate` below, so the two can never spell it differently.
44
+ */
45
+ export declare const PACKED_GITIGNORE_NAME = "_gitignore";
23
46
  /**
24
47
  * Whether a scaffolded path's content is text we run markers over. Exported
25
48
  * because the template/base layer (`installBundleTree`) substitutes with the
26
49
  * same classifier — one definition, not two (#6460).
27
50
  */
28
51
  export declare function isTextFile(relativePath: string): boolean;
52
+ /**
53
+ * The destination path a template-relative path (`/`-separated) copies to —
54
+ * `undefined` for a packaging-only `.gitkeep` or `.npmignore` (never a
55
+ * scaffold file), and a final `PACKED_GITIGNORE_NAME` restored to
56
+ * `.gitignore` in the same directory otherwise (#7366 D3) — so a scaffold
57
+ * never carries the packaging-only name. Exported (#8522 D3) as the one
58
+ * renderer of "the template's copy", shared by `copyTemplate` and by
59
+ * `create-ailk update`'s render of a running or an old template version.
60
+ */
61
+ export declare function templateDestination(rel: string): string | undefined;
62
+ /**
63
+ * The bytes a template-relative file copies as, at its destination path
64
+ * (#8522 D3): workspace versions plus markers for `package.json`, markers
65
+ * for `isTextFile`, raw bytes otherwise. Exported so `copyTemplate`'s walk
66
+ * and `create-ailk update`'s render of a running or an old template version
67
+ * share the one renderer — nothing about the copy itself changes.
68
+ */
69
+ export declare function renderTemplateFile(bytes: Uint8Array, destRel: string, versions: WorkspaceVersionMap, markers: MarkerMap): Uint8Array;
29
70
  export declare function copyTemplate(opts: CopyTemplateOptions): void;
package/dist/copy.js CHANGED
@@ -17,8 +17,23 @@
17
17
  * alike. Symlinks are skipped outright (never followed, never copied) —
18
18
  * the snapshotted templates/ tree is built without symlinks; any symlink
19
19
  * present at scaffold time is treated as a packaging accident and ignored.
20
+ *
21
+ * Packed ignore files (#7366):
22
+ * - npm and pnpm strip every file literally named `.gitignore` from a
23
+ * packed tarball, so `build-templates.mjs` ships each snapshot
24
+ * `.gitignore` under `PACKED_GITIGNORE_NAME` (`_gitignore`) instead. A
25
+ * file named `PACKED_GITIGNORE_NAME` is restored to `.gitignore` in the
26
+ * same directory on the way into a scaffold, classified and
27
+ * marker-substituted exactly as a `.gitignore` is today (its destination
28
+ * relative path, not its source name, drives `isTextFile`). A directory
29
+ * carrying both `.gitignore` and `PACKED_GITIGNORE_NAME` is refused
30
+ * outright, before either is written, rather than resolved by
31
+ * `readdir()` order. `.npmignore` — the packaging-only sibling
32
+ * `build-templates.mjs` writes beside each `PACKED_GITIGNORE_NAME` to
33
+ * keep pack-time exclusions working — is skipped like `.gitkeep`: it
34
+ * never belongs in a scaffold.
20
35
  */
21
- import { copyFileSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from "node:fs";
36
+ import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
22
37
  import { extname, join, relative, resolve, sep } from "node:path";
23
38
  import pkg from "../package.json" with { type: "json" };
24
39
  import { substituteMarkers, substituteWorkspaceVersions, } from "./substitute.js";
@@ -57,8 +72,10 @@ export function buildScaffoldMarkers(projectName) {
57
72
  * Whether a walked template-relative path is inside (or equal to) an excluded
58
73
  * directory. Compares `/`-normalized paths so the check holds on Windows, and
59
74
  * only at a directory boundary — a prefix that stops mid-segment is no match.
75
+ * Exported (#8522 D3) so `create-ailk update` applies the same surface-gating
76
+ * boundary to a candidate new file.
60
77
  */
61
- function isExcluded(relativePath, excluded) {
78
+ export function isExcluded(relativePath, excluded) {
62
79
  if (excluded.length === 0)
63
80
  return false;
64
81
  const rel = relativePath.split(sep).join("/");
@@ -71,6 +88,14 @@ function isExcluded(relativePath, excluded) {
71
88
  }
72
89
  return false;
73
90
  }
91
+ /**
92
+ * The pack-safe name `build-templates.mjs` ships each snapshot `.gitignore`
93
+ * under (#7366 D1) — npm and pnpm strip a file literally named `.gitignore`
94
+ * from a packed tarball, but keep one named `_gitignore`. One definition,
95
+ * imported by `build-templates.mjs` (from the compiled `dist/copy.js`) and
96
+ * by `copyTemplate` below, so the two can never spell it differently.
97
+ */
98
+ export const PACKED_GITIGNORE_NAME = "_gitignore";
74
99
  /** File extensions whose content we treat as text (and run markers over). */
75
100
  const TEXT_EXTS = new Set([
76
101
  ".md",
@@ -113,6 +138,46 @@ function isPackageJson(relativePath) {
113
138
  const base = relativePath.split(/[\\/]/).pop() ?? "";
114
139
  return base === "package.json";
115
140
  }
141
+ /**
142
+ * The destination path a template-relative path (`/`-separated) copies to —
143
+ * `undefined` for a packaging-only `.gitkeep` or `.npmignore` (never a
144
+ * scaffold file), and a final `PACKED_GITIGNORE_NAME` restored to
145
+ * `.gitignore` in the same directory otherwise (#7366 D3) — so a scaffold
146
+ * never carries the packaging-only name. Exported (#8522 D3) as the one
147
+ * renderer of "the template's copy", shared by `copyTemplate` and by
148
+ * `create-ailk update`'s render of a running or an old template version.
149
+ */
150
+ export function templateDestination(rel) {
151
+ const parts = rel.split("/");
152
+ const last = parts.length - 1;
153
+ if (last < 0)
154
+ return undefined;
155
+ if (parts[last] === ".gitkeep" || parts[last] === ".npmignore") {
156
+ return undefined;
157
+ }
158
+ if (parts[last] === PACKED_GITIGNORE_NAME) {
159
+ parts[last] = ".gitignore";
160
+ }
161
+ return parts.join("/");
162
+ }
163
+ /**
164
+ * The bytes a template-relative file copies as, at its destination path
165
+ * (#8522 D3): workspace versions plus markers for `package.json`, markers
166
+ * for `isTextFile`, raw bytes otherwise. Exported so `copyTemplate`'s walk
167
+ * and `create-ailk update`'s render of a running or an old template version
168
+ * share the one renderer — nothing about the copy itself changes.
169
+ */
170
+ export function renderTemplateFile(bytes, destRel, versions, markers) {
171
+ if (isPackageJson(destRel)) {
172
+ const text = Buffer.from(bytes).toString("utf8");
173
+ return Buffer.from(substituteMarkers(substituteWorkspaceVersions(text, versions), markers), "utf8");
174
+ }
175
+ if (isTextFile(destRel)) {
176
+ const text = Buffer.from(bytes).toString("utf8");
177
+ return Buffer.from(substituteMarkers(text, markers), "utf8");
178
+ }
179
+ return Buffer.from(bytes);
180
+ }
116
181
  export function copyTemplate(opts) {
117
182
  const templatesAbs = resolve(opts.templatesDir);
118
183
  const targetAbs = resolve(opts.targetDir);
@@ -124,19 +189,30 @@ export function copyTemplate(opts) {
124
189
  if (dir === undefined)
125
190
  break;
126
191
  const entries = readdirSync(dir, { withFileTypes: true });
192
+ // A directory carrying both the live name and the packed name would
193
+ // otherwise resolve by `readdir()` order — refuse outright, before
194
+ // writing either (#7366 D3).
195
+ const names = new Set(entries.map((entry) => entry.name));
196
+ if (names.has(".gitignore") && names.has(PACKED_GITIGNORE_NAME)) {
197
+ throw new Error(`templates directory carries both .gitignore and ${PACKED_GITIGNORE_NAME}: ${dir}`);
198
+ }
127
199
  for (const entry of entries) {
128
- // .gitkeep is a packaging artifact for the empty-templates state in git;
129
- // never propagate it to scaffolder output.
130
- if (entry.name === ".gitkeep")
131
- continue;
132
200
  const srcPath = join(dir, entry.name);
133
- const rel = relative(templatesAbs, srcPath);
201
+ const rel = relative(templatesAbs, srcPath).split(sep).join("/");
134
202
  // Surface gating (#4154): a route directory belonging to a surface the
135
203
  // declared architecture does not carry is skipped outright — the route is
136
204
  // ABSENT from the scaffold, not present-and-serving-a-placeholder.
137
205
  if (isExcluded(rel, excluded))
138
206
  continue;
139
- const dstPath = join(targetAbs, rel);
207
+ // The destination path: `undefined` drops a packaging-only .gitkeep /
208
+ // .npmignore (#7366 D3); otherwise a packed .gitignore's name is
209
+ // restored. Everything below — the path-safety check, text
210
+ // classification, and marker substitution — runs on THIS path, never
211
+ // the source name.
212
+ const destRel = templateDestination(rel);
213
+ if (destRel === undefined)
214
+ continue;
215
+ const dstPath = join(targetAbs, destRel);
140
216
  // Path-safety guard: dstPath MUST be within targetAbs. Use `path.sep`
141
217
  // so the check holds on Windows (`\\`) as well as POSIX (`/`).
142
218
  const resolvedDst = resolve(dstPath);
@@ -152,19 +228,9 @@ export function copyTemplate(opts) {
152
228
  stack.push(srcPath);
153
229
  continue;
154
230
  }
155
- if (isPackageJson(rel)) {
156
- const text = readFileSync(srcPath, "utf8");
157
- const transformed = substituteMarkers(substituteWorkspaceVersions(text, opts.versions), opts.markers);
158
- writeFileSync(dstPath, transformed);
159
- }
160
- else if (isTextFile(rel)) {
161
- const text = readFileSync(srcPath, "utf8");
162
- writeFileSync(dstPath, substituteMarkers(text, opts.markers));
163
- }
164
- else {
165
- // Binary or unknown — copy verbatim.
166
- copyFileSync(srcPath, dstPath);
167
- }
231
+ const rendered = renderTemplateFile(readFileSync(srcPath), destRel, opts.versions, opts.markers);
232
+ writeFileSync(dstPath, rendered);
233
+ opts.onWrite?.(destRel, rendered);
168
234
  }
169
235
  }
170
236
  }
package/dist/index.d.ts CHANGED
@@ -14,6 +14,6 @@ export type { HomeSeedOutcome } from "./design-home.js";
14
14
  export { SITE_ROSTER_PATH, readSiteRoster, siteRosterProblems, } from "./site-roster.js";
15
15
  export { SYNC_ROUTES_HELP, formatSyncRoutesReport, listTemplateRouteDirs, parseSyncRoutesArgs, syncRoutes, type RouteReport, type SyncRoutesOptions, type SyncRoutesResult, } from "./sync-routes.js";
16
16
  export { deriveArchitecture } from "./derive-architecture.js";
17
- export { CORE_ROUTE_DIRS, CORE_SURFACE_PATHS, NAV_CONFIG_PATH, GATEABLE_SURFACES, MODULE_SURFACES, RECOGNIZED_SURFACES, ROUTE_SINCE, SurfaceError, excludedContentPaths, excludedRoutePaths, parseSurfaces, renderNavConfig, resolveSurfaces, surfaceForRouteDir, type Surface, } from "./surfaces.js";
17
+ export { CORE_ROUTE_DIRS, CORE_SURFACE_PATHS, NAV_CONFIG_PATH, GATEABLE_SURFACES, MODULE_SURFACES, RECOGNIZED_SURFACES, ROUTE_SINCE, SurfaceError, excludedContentPaths, excludedRoutePaths, isUnservedSurfaceContent, parseSurfaces, renderNavConfig, resolveSurfaces, surfaceForRouteDir, type Surface, } from "./surfaces.js";
18
18
  export { substituteMarkers, substituteWorkspaceVersions, type MarkerMap, type WorkspaceVersionMap, } from "./substitute.js";
19
19
  export { validateTarget, ValidationError } from "./validate.js";
package/dist/index.js CHANGED
@@ -30,6 +30,6 @@ export { deriveArchitecture } from "./derive-architecture.js";
30
30
  // The route-surface registry (#4154) — which public routes a scaffold carries.
31
31
  // Exported so tooling can answer "what surfaces does this site declare" without
32
32
  // re-parsing architecture.yaml.
33
- export { CORE_ROUTE_DIRS, CORE_SURFACE_PATHS, NAV_CONFIG_PATH, GATEABLE_SURFACES, MODULE_SURFACES, RECOGNIZED_SURFACES, ROUTE_SINCE, SurfaceError, excludedContentPaths, excludedRoutePaths, parseSurfaces, renderNavConfig, resolveSurfaces, surfaceForRouteDir, } from "./surfaces.js";
33
+ export { CORE_ROUTE_DIRS, CORE_SURFACE_PATHS, NAV_CONFIG_PATH, GATEABLE_SURFACES, MODULE_SURFACES, RECOGNIZED_SURFACES, ROUTE_SINCE, SurfaceError, excludedContentPaths, excludedRoutePaths, isUnservedSurfaceContent, parseSurfaces, renderNavConfig, resolveSurfaces, surfaceForRouteDir, } from "./surfaces.js";
34
34
  export { substituteMarkers, substituteWorkspaceVersions, } from "./substitute.js";
35
35
  export { validateTarget, ValidationError } from "./validate.js";
@@ -0,0 +1,21 @@
1
+ export declare const DEFAULT_AILK_NPM_REGISTRY_URL = "https://registry.npmjs.org";
2
+ export declare class FetchTemplateSnapshotError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ export interface FetchTemplateSnapshotOptions {
6
+ /** The create-ailk version to re-materialise, e.g. "0.19.1". */
7
+ version: string;
8
+ /** Registry root; defaults to `AILK_NPM_REGISTRY_URL`, else the public npm registry. */
9
+ registryUrl?: string;
10
+ /** Injected fetch for tests. Never a live network call in CI. */
11
+ fetchImpl?: typeof fetch;
12
+ }
13
+ /**
14
+ * Fetch and re-materialise create-ailk `version`'s `templates/apps/{web,api,mcp}/`
15
+ * tree from its published npm tarball.
16
+ *
17
+ * @throws FetchTemplateSnapshotError on an invalid version, an unreachable
18
+ * or malformed registry response, an integrity mismatch, or a symlink /
19
+ * hard-link entry under one of the three app prefixes.
20
+ */
21
+ export declare function fetchTemplateSnapshot(options: FetchTemplateSnapshotOptions): Promise<Map<string, Uint8Array>>;
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Re-materialise an old create-ailk's `templates/apps/{web,api,mcp}/` tree
3
+ * from its published npm tarball (#8522 D6) — the merge base
4
+ * `create-ailk update` needs for a recorded version no longer sitting in
5
+ * this package's own `templates/`.
6
+ *
7
+ * Reads the public, unauthenticated npm registry — no bearer token, unlike
8
+ * `fetch-template.ts` / `fetch-module.ts` — through the same safety guards
9
+ * those two share (`fetch-guard.ts`): an `https:` (or loopback, for the test
10
+ * mock) registry URL, a request timeout, and a declared-`Content-Length`
11
+ * cap before the body is ever buffered. The tarball's integrity is verified
12
+ * against the packument's `dist.integrity` before it is gunzipped, and the
13
+ * gunzip itself is capped at the same `MAX_TOTAL_SIZE_BYTES`
14
+ * `extract-module-bundle.ts` enforces for a module bundle, so the whole
15
+ * fetch is bounded in memory regardless of what the tarball's own headers
16
+ * claim. The gunzipped tar is parsed in memory with `tar`'s own `Parser` —
17
+ * never extracted to disk — and only regular files under the three app
18
+ * prefixes are kept.
19
+ */
20
+ import { createHash } from "node:crypto";
21
+ import { gunzipSync } from "node:zlib";
22
+ import * as tar from "tar";
23
+ import { templateDestination } from "../copy.js";
24
+ import { MAX_TOTAL_SIZE_BYTES } from "./extract-module-bundle.js";
25
+ import { assertContentLengthWithinLimit, assertHttpsRegistryUrl, fetchTimeoutSignal, } from "./fetch-guard.js";
26
+ export const DEFAULT_AILK_NPM_REGISTRY_URL = "https://registry.npmjs.org";
27
+ /** The same version shape `.ailk-template-files.json` rows are checked against (D1). */
28
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
29
+ /** The one tar path prefix a snapshot is read from, and the three app roots inside it. */
30
+ const TAR_ROOT = "package/templates/";
31
+ const APP_PREFIXES = ["apps/web/", "apps/api/", "apps/mcp/"];
32
+ export class FetchTemplateSnapshotError extends Error {
33
+ constructor(message) {
34
+ super(message);
35
+ this.name = "FetchTemplateSnapshotError";
36
+ }
37
+ }
38
+ function detail(err) {
39
+ return err instanceof Error ? err.message : String(err);
40
+ }
41
+ /** The one version's packument: `GET <registry>/create-ailk/<version>`. */
42
+ async function fetchPackument(registryUrl, version, fetchImpl) {
43
+ const url = `${registryUrl.replace(/\/+$/, "")}/create-ailk/${encodeURIComponent(version)}`;
44
+ let response;
45
+ try {
46
+ response = await fetchImpl(url, {
47
+ method: "GET",
48
+ headers: { Accept: "application/json" },
49
+ redirect: "error",
50
+ signal: fetchTimeoutSignal(),
51
+ });
52
+ }
53
+ catch (err) {
54
+ throw new FetchTemplateSnapshotError(`create-ailk ${version} packument unreachable: ${detail(err)}`);
55
+ }
56
+ if (!response.ok) {
57
+ throw new FetchTemplateSnapshotError(`create-ailk ${version} packument request failed: ${response.status}`);
58
+ }
59
+ let doc;
60
+ try {
61
+ doc = await response.json();
62
+ }
63
+ catch (err) {
64
+ throw new FetchTemplateSnapshotError(`create-ailk ${version} packument is not JSON: ${detail(err)}`);
65
+ }
66
+ if (typeof doc !== "object" || doc === null) {
67
+ throw new FetchTemplateSnapshotError(`create-ailk ${version} packument is not an object`);
68
+ }
69
+ const record = doc;
70
+ if (record["version"] !== version) {
71
+ throw new FetchTemplateSnapshotError(`create-ailk ${version} packument names a different version: ${JSON.stringify(record["version"])}`);
72
+ }
73
+ const dist = record["dist"];
74
+ if (typeof dist !== "object" || dist === null) {
75
+ throw new FetchTemplateSnapshotError(`create-ailk ${version} packument has no "dist"`);
76
+ }
77
+ const distRecord = dist;
78
+ const tarball = distRecord["tarball"];
79
+ if (typeof tarball !== "string" || tarball.length === 0) {
80
+ throw new FetchTemplateSnapshotError(`create-ailk ${version} packument has no "dist.tarball"`);
81
+ }
82
+ const integrity = distRecord["integrity"];
83
+ if (typeof integrity !== "string" || !integrity.startsWith("sha512-")) {
84
+ throw new FetchTemplateSnapshotError(`create-ailk ${version} packument has no "dist.integrity" starting "sha512-"`);
85
+ }
86
+ return { version, tarball, integrity };
87
+ }
88
+ async function fetchTarball(tarballUrl, integrity, version, fetchImpl) {
89
+ assertHttpsRegistryUrl(tarballUrl, FetchTemplateSnapshotError);
90
+ let response;
91
+ try {
92
+ response = await fetchImpl(tarballUrl, {
93
+ method: "GET",
94
+ headers: { Accept: "application/octet-stream" },
95
+ redirect: "error",
96
+ signal: fetchTimeoutSignal(),
97
+ });
98
+ }
99
+ catch (err) {
100
+ throw new FetchTemplateSnapshotError(`create-ailk ${version} tarball unreachable: ${detail(err)}`);
101
+ }
102
+ if (!response.ok) {
103
+ throw new FetchTemplateSnapshotError(`create-ailk ${version} tarball request failed: ${response.status}`);
104
+ }
105
+ assertContentLengthWithinLimit(response, FetchTemplateSnapshotError);
106
+ const buf = Buffer.from(await response.arrayBuffer());
107
+ const digest = `sha512-${createHash("sha512").update(buf).digest("base64")}`;
108
+ if (digest !== integrity) {
109
+ throw new FetchTemplateSnapshotError(`create-ailk ${version} tarball failed its integrity check`);
110
+ }
111
+ return buf;
112
+ }
113
+ /** The app-relative path (no `package/templates/` prefix) for a tar entry path, or undefined outside the three app trees. */
114
+ function appRelativePath(tarPath) {
115
+ if (!tarPath.startsWith(TAR_ROOT))
116
+ return undefined;
117
+ const rel = tarPath.slice(TAR_ROOT.length);
118
+ if (!APP_PREFIXES.some((prefix) => rel.startsWith(prefix)))
119
+ return undefined;
120
+ if (rel.length === 0)
121
+ return undefined;
122
+ const segments = rel.split("/");
123
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
124
+ return undefined;
125
+ }
126
+ return rel;
127
+ }
128
+ /**
129
+ * Parse the gunzipped tar in memory and return every regular file under the
130
+ * three app trees, keyed by its destination path (D3's `templateDestination`
131
+ * — `_gitignore` restored to `.gitignore`, `.gitkeep` / `.npmignore` dropped).
132
+ * A symlink or hard link under one of those prefixes refuses the WHOLE
133
+ * snapshot, never just that entry.
134
+ */
135
+ function parseSnapshot(gunzipped, version) {
136
+ const files = new Map();
137
+ let refusal;
138
+ const parser = new tar.Parser({
139
+ strict: true,
140
+ filter: (tarPath, rawEntry) => {
141
+ if (refusal !== undefined)
142
+ return false;
143
+ const rel = appRelativePath(tarPath);
144
+ if (rel === undefined)
145
+ return false;
146
+ const entryType = rawEntry.type;
147
+ if (entryType === "SymbolicLink" || entryType === "Link") {
148
+ refusal = `create-ailk ${version} snapshot: "${tarPath}" is a ${entryType} (refused)`;
149
+ return false;
150
+ }
151
+ return entryType === "File";
152
+ },
153
+ onReadEntry: (entry) => {
154
+ const rel = appRelativePath(entry.path);
155
+ const dest = rel !== undefined ? templateDestination(rel) : undefined;
156
+ if (dest === undefined) {
157
+ entry.resume();
158
+ return;
159
+ }
160
+ const chunks = [];
161
+ entry.on("data", (chunk) => chunks.push(chunk));
162
+ entry.on("end", () => files.set(dest, Buffer.concat(chunks)));
163
+ },
164
+ });
165
+ return new Promise((resolvePromise, rejectPromise) => {
166
+ parser.on("error", (err) => rejectPromise(err));
167
+ parser.on("end", () => {
168
+ if (refusal !== undefined) {
169
+ rejectPromise(new FetchTemplateSnapshotError(refusal));
170
+ return;
171
+ }
172
+ resolvePromise(files);
173
+ });
174
+ parser.end(gunzipped);
175
+ });
176
+ }
177
+ /**
178
+ * Fetch and re-materialise create-ailk `version`'s `templates/apps/{web,api,mcp}/`
179
+ * tree from its published npm tarball.
180
+ *
181
+ * @throws FetchTemplateSnapshotError on an invalid version, an unreachable
182
+ * or malformed registry response, an integrity mismatch, or a symlink /
183
+ * hard-link entry under one of the three app prefixes.
184
+ */
185
+ export async function fetchTemplateSnapshot(options) {
186
+ const { version } = options;
187
+ if (!VERSION_PATTERN.test(version)) {
188
+ throw new FetchTemplateSnapshotError(`refusing to fetch create-ailk "${version}": not a valid version`);
189
+ }
190
+ const registryUrl = options.registryUrl ??
191
+ process.env["AILK_NPM_REGISTRY_URL"] ??
192
+ DEFAULT_AILK_NPM_REGISTRY_URL;
193
+ assertHttpsRegistryUrl(registryUrl, FetchTemplateSnapshotError);
194
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
195
+ const packument = await fetchPackument(registryUrl, version, fetchImpl);
196
+ const tarball = await fetchTarball(packument.tarball, packument.integrity, version, fetchImpl);
197
+ let gunzipped;
198
+ try {
199
+ gunzipped = gunzipSync(tarball, { maxOutputLength: MAX_TOTAL_SIZE_BYTES });
200
+ }
201
+ catch (err) {
202
+ throw new FetchTemplateSnapshotError(`create-ailk ${version} tarball could not be decompressed: ${detail(err)}`);
203
+ }
204
+ return parseSnapshot(gunzipped, version);
205
+ }
@@ -0,0 +1,31 @@
1
+ export interface MergeThreeWayOptions {
2
+ /** The site's current (edited) text. */
3
+ ours: string;
4
+ /** The recorded version's rendered copy. */
5
+ base: string;
6
+ /** The running version's rendered copy. */
7
+ theirs: string;
8
+ oursLabel: string;
9
+ baseLabel: string;
10
+ theirsLabel: string;
11
+ }
12
+ export type MergeThreeWayResult = {
13
+ status: "merged";
14
+ text: string;
15
+ } | {
16
+ status: "conflict";
17
+ } | {
18
+ status: "error";
19
+ reason: string;
20
+ };
21
+ /**
22
+ * Run `git merge-file -p` over the three texts in a `mkdtemp` directory, no
23
+ * shell, argv holding only temp-file paths and the three labels (#8522 D5).
24
+ *
25
+ * Exit 0 is a clean merge (`merged`). Exit 1-127 is a conflicting hunk
26
+ * (`conflict`) — the file is never written with conflict markers by this
27
+ * function; the caller decides what to do with a conflict, and nothing here
28
+ * ever applies one. Any other exit, or a spawn failure (no `git` on `PATH`),
29
+ * is `error`.
30
+ */
31
+ export declare function mergeThreeWay(opts: MergeThreeWayOptions): MergeThreeWayResult;