create-ailk 0.3.0 → 0.5.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 (155) hide show
  1. package/component-catalog.json +11939 -0
  2. package/component-catalog.md +176 -64
  3. package/dist/cli.js +48 -10
  4. package/dist/derive-architecture.js +5 -2
  5. package/dist/lib/extract-module-bundle.d.ts +88 -1
  6. package/dist/lib/extract-module-bundle.js +375 -80
  7. package/dist/lib/fetch-guard.d.ts +28 -0
  8. package/dist/lib/fetch-guard.js +101 -0
  9. package/dist/lib/fetch-module.js +4 -0
  10. package/dist/lib/fetch-template.d.ts +20 -0
  11. package/dist/lib/fetch-template.js +133 -0
  12. package/dist/lib/module-license-gate.d.ts +8 -0
  13. package/dist/lib/module-license-gate.js +12 -4
  14. package/dist/module-architecture.d.ts +116 -7
  15. package/dist/module-architecture.js +465 -94
  16. package/dist/parse-args.d.ts +8 -1
  17. package/dist/parse-args.js +49 -2
  18. package/dist/programmatic.d.ts +36 -1
  19. package/dist/programmatic.js +216 -5
  20. package/dist/site-chrome.d.ts +80 -0
  21. package/dist/site-chrome.js +135 -0
  22. package/dist/surfaces.d.ts +21 -6
  23. package/dist/surfaces.js +140 -23
  24. package/dist/template-manifest.d.ts +144 -0
  25. package/dist/template-manifest.js +337 -0
  26. package/dist/template-names.d.ts +41 -0
  27. package/dist/template-names.js +45 -0
  28. package/package.json +10 -2
  29. package/site-templates/_base/ailk-template.json +6 -0
  30. package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
  31. package/site-templates/_base/content/en/PrivacyPage/privacy.mdx +79 -0
  32. package/site-templates/_base/content/en/TermsPage/terms.mdx +80 -0
  33. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +331 -0
  34. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +393 -0
  35. package/site-templates/marketing-site/ailk-template.json +11 -0
  36. package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
  37. package/site-templates/marketing-site/provenance.json +4 -0
  38. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
  39. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +285 -0
  40. package/site-templates/marketing-site-free/ailk-template.json +9 -0
  41. package/site-templates/marketing-site-free/provenance.json +4 -0
  42. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +271 -0
  43. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +324 -0
  44. package/site-templates/minimalism-site/ailk-template.json +21 -0
  45. package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
  46. package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
  47. package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
  48. package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
  49. package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
  50. package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
  51. package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
  52. package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
  53. package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
  54. package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
  55. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
  56. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
  57. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
  58. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +214 -0
  59. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
  60. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
  61. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
  62. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
  63. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
  64. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
  65. package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
  66. package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
  67. package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
  68. package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
  69. package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
  70. package/site-templates/minimalism-site/provenance.json +4 -0
  71. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
  72. package/site-templates/saas-product/ailk-template.json +16 -0
  73. package/site-templates/saas-product/provenance.json +10 -0
  74. package/templates/.claude/skills/README.md +16 -25
  75. package/templates/.env.example +32 -7
  76. package/templates/.github/workflows/ci.yml +48 -0
  77. package/templates/README.md +3 -1
  78. package/templates/apps/api/CLAUDE.md +4 -1
  79. package/templates/apps/api/eslint.config.mjs +69 -0
  80. package/templates/apps/api/package.json +6 -2
  81. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +6 -2
  82. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
  83. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +173 -0
  84. package/templates/apps/api/src/config/modules.ts +1 -1
  85. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  86. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +339 -0
  87. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +108 -0
  88. package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
  89. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
  90. package/templates/apps/api/src/routes/leads/list.ts +104 -0
  91. package/templates/apps/api/src/server.ts +122 -3
  92. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
  93. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
  94. package/templates/apps/api/src/services/default-tenant.ts +261 -0
  95. package/templates/apps/api/src/services/lead-promotion.ts +161 -0
  96. package/templates/apps/api/src/services/leads-inbox.ts +263 -0
  97. package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
  98. package/templates/apps/mcp/CLAUDE.md +1 -1
  99. package/templates/apps/mcp/__tests__/parity.test.ts +2 -2
  100. package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
  101. package/templates/apps/mcp/eslint.config.mjs +69 -0
  102. package/templates/apps/mcp/package.json +6 -2
  103. package/templates/apps/mcp/src/server.ts +2 -2
  104. package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
  105. package/templates/apps/mcp/src/tools/index.ts +2 -2
  106. package/templates/apps/web/.env.example +3 -3
  107. package/templates/apps/web/CLAUDE.md +2 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +4 -6
  109. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +1 -1
  110. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
  111. package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
  112. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
  113. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
  114. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
  115. package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
  116. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
  117. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
  118. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
  119. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
  120. package/templates/apps/web/app/[locale]/layout.tsx +214 -17
  121. package/templates/apps/web/app/[locale]/pricing/page.tsx +40 -0
  122. package/templates/apps/web/app/[locale]/privacy/page.tsx +40 -0
  123. package/templates/apps/web/app/[locale]/terms/page.tsx +40 -0
  124. package/templates/apps/web/components/google-analytics.tsx +18 -1
  125. package/templates/apps/web/eslint.config.mjs +69 -0
  126. package/templates/apps/web/jest.config.cjs +12 -15
  127. package/templates/apps/web/lib/__tests__/server-api.test.ts +61 -0
  128. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +32 -5
  129. package/templates/apps/web/lib/nav-config.ts +27 -7
  130. package/templates/apps/web/lib/server-api.ts +29 -13
  131. package/templates/apps/web/lib/site-chrome.ts +29 -0
  132. package/templates/apps/web/lib/telemetry-scrub.ts +46 -0
  133. package/templates/apps/web/messages/en.json +86 -1
  134. package/templates/apps/web/next.config.mjs +20 -0
  135. package/templates/apps/web/package.json +6 -2
  136. package/templates/apps/web/sentry.client.config.ts +131 -0
  137. package/templates/apps/web/sentry.edge.config.ts +72 -0
  138. package/templates/apps/web/sentry.server.config.ts +72 -0
  139. package/templates/apps/web/src/lib/routes.ts +13 -18
  140. package/templates/brand_assets/README.md +24 -0
  141. package/templates/content/en/AboutPage/about.mdx +160 -15
  142. package/templates/content/en/ContactPage/contact.mdx +90 -13
  143. package/templates/content/en/FAQPage/faq.mdx +62 -22
  144. package/templates/content/en/HomePage/home.mdx +244 -20
  145. package/templates/content/en/LandingPage/blog.mdx +83 -0
  146. package/templates/content/en/LandingPage/features.mdx +103 -0
  147. package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
  148. package/templates/database/CHANGELOG.md +21 -0
  149. package/templates/database/__tests__/seed.test.ts +1 -1
  150. package/templates/database/eslint.config.mjs +69 -0
  151. package/templates/database/package.json +7 -3
  152. package/templates/database/seed.ts +1 -1
  153. package/templates/package.json +1 -14
  154. package/versions.json +52 -0
  155. /package/templates/apps/web/{src/components → components}/SiteNav.tsx +0 -0
@@ -62,6 +62,20 @@
62
62
  * `copyTree`/`installMap` run, followed by one write pass for the computed
63
63
  * patches — so a patch failure leaves `targetDir` exactly as it was before
64
64
  * this call, including files THIS bundle would otherwise have added.
65
+ *
66
+ * SITE TEMPLATES (spec 5481; parent record D9, D10, DD4, DD7). A template's
67
+ * root manifest is `ailk-template.json` — `ailk-module.json`'s shape plus a
68
+ * mandatory `requires`, cross-checked against the client registry's list so a
69
+ * stale client cannot land a template built for a different module set. Two
70
+ * differences from the module path, both deliberate and both confined to
71
+ * reading the manifest: a template's manifest MUST be present and parseable
72
+ * (a module's absent-or-unparseable manifest legitimately degrades to a plain
73
+ * content merge; a template's `requires` is mandatory, so treat-as-absent
74
+ * would make the cross-check optional at the bundle's own discretion), and a
75
+ * `requires` disagreement rejects. Everything after that — traversal,
76
+ * containment, link rejection, the limits, the atomicity ordering — is the
77
+ * same code, reached through `installBundleTree`, which the keyless Free path
78
+ * calls directly with `site-templates/<name>/` as its source root.
65
79
  */
66
80
  import { copyFileSync, createReadStream, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from "node:fs";
67
81
  import { tmpdir } from "node:os";
@@ -69,12 +83,21 @@ import { isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
69
83
  import { pipeline } from "node:stream/promises";
70
84
  import { createGunzip } from "node:zlib";
71
85
  import * as tar from "tar";
86
+ import { coOwnedModuleDestinations, } from "../module-architecture.js";
87
+ import { validateTemplateLayout, } from "../site-chrome.js";
72
88
  import { computeModulePatches, writeModulePatches } from "./apply-module-patches.js";
73
89
  /** Soft limits to prevent decompression-bomb / zip-bomb attacks. Same values as extractBundle. */
74
90
  const MAX_ENTRIES = 1000;
75
- const MAX_TOTAL_SIZE_BYTES = 50 * 1024 * 1024; // 50 MiB
91
+ /** Exported for `fetch-template.ts`/`fetch-module.ts`'s Content-Length guard (#5514) — one cap, not two literals. */
92
+ export const MAX_TOTAL_SIZE_BYTES = 50 * 1024 * 1024; // 50 MiB
76
93
  /** The bundle-root manifest file (spec § D7). Never copied into the scaffold. */
77
94
  const AILK_MODULE_JSON = "ailk-module.json";
95
+ /**
96
+ * A SITE TEMPLATE's root manifest (spec 5481 § 4; parent record D10) — the
97
+ * same shape as `ailk-module.json` plus one mandatory field, `requires`.
98
+ * Never copied into the scaffold either.
99
+ */
100
+ const AILK_TEMPLATE_JSON = "ailk-template.json";
78
101
  /** The op names valid for each patch `kind` (spec 5418 § 3 D1's ModulePatchOp union, partitioned). */
79
102
  const VALID_OPS_BY_KIND = {
80
103
  json: new Set(["merge-keys", "append-unique"]),
@@ -127,37 +150,113 @@ function readAilkModuleManifest(extractedRoot) {
127
150
  return null;
128
151
  }
129
152
  }
153
+ /**
154
+ * Read `ailk-template.json` from a template tree's root and cross-check its
155
+ * `requires` against the client registry's list for that template.
156
+ *
157
+ * REFUSES where `readAilkModuleManifest` degrades (spec 5481 § 3 DD4). A
158
+ * module's manifest fields are all optional, so an absent or unparseable one
159
+ * legitimately means "a plain content merge". A template's `requires` is
160
+ * mandatory (record D10) — inheriting treat-as-absent would make the
161
+ * cross-check optional at the bundle's own discretion, since a bundle
162
+ * carrying no manifest would never be checked at all.
163
+ *
164
+ * The disagreement check is a boundary check, and the boundary is the bundle:
165
+ * `expectedRequires` comes from the caller's own `TEMPLATE_MANIFESTS` entry
166
+ * (internal, compile-checked against `RECOGNIZED_MODULES`), while `requires`
167
+ * is whatever the archive says. Compared as SETS, not as sequences — the
168
+ * registry's declaration order is not part of the contract.
169
+ */
170
+ function readAilkTemplateManifest(sourceRoot, name, expectedRequires, expectedExtends) {
171
+ const manifestPath = join(sourceRoot, AILK_TEMPLATE_JSON);
172
+ if (!existsSync(manifestPath)) {
173
+ throw new Error(`template "${name}": bundle carries no ${AILK_TEMPLATE_JSON} — a template manifest is mandatory`);
174
+ }
175
+ let manifest;
176
+ try {
177
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
178
+ }
179
+ catch {
180
+ throw new Error(`template "${name}": ${AILK_TEMPLATE_JSON} is not readable JSON`);
181
+ }
182
+ const declaredRaw = manifest.requires;
183
+ if (!Array.isArray(declaredRaw)) {
184
+ throw new Error(`template "${name}": ${AILK_TEMPLATE_JSON} declares no "requires" list`);
185
+ }
186
+ // #5514: every element must be a string BEFORE the comparison below — a
187
+ // prior `join(",")` comparison would let a non-string element (or one
188
+ // containing a comma) defeat the disagreement check by colliding with an
189
+ // unrelated element-set on the joined string.
190
+ for (const item of declaredRaw) {
191
+ if (typeof item !== "string") {
192
+ throw new Error(`template "${name}": ${AILK_TEMPLATE_JSON} "requires" contains a non-string entry`);
193
+ }
194
+ }
195
+ const declared = declaredRaw;
196
+ const expected = [...new Set(expectedRequires)].sort();
197
+ const actual = [...new Set(declared)].sort();
198
+ const disagrees = expected.length !== actual.length ||
199
+ expected.some((value, index) => value !== actual[index]);
200
+ if (disagrees) {
201
+ throw new Error(`template "${name}": bundle requires [${actual.join(", ")}] but this create-ailk's registry requires [${expected.join(", ")}] — upgrade create-ailk`);
202
+ }
203
+ // #5618 D2 — `extends` joins `requires` as the sibling cross-check, with
204
+ // the same "upgrade create-ailk" posture: the registry's value is the
205
+ // caller's compile-checked lineage, the bundle's is whatever the archive
206
+ // says. Compared exactly (a single parent, not a set). The base layer is
207
+ // the terminus and declares none — its caller passes none.
208
+ const declaredExtends = manifest.extends;
209
+ if (declaredExtends !== undefined && typeof declaredExtends !== "string") {
210
+ throw new Error(`template "${name}": ${AILK_TEMPLATE_JSON} "extends" is not a string`);
211
+ }
212
+ if (declaredExtends !== expectedExtends) {
213
+ const show = (value) => value === undefined ? "nothing" : `"${value}"`;
214
+ throw new Error(`template "${name}": bundle extends ${show(declaredExtends)} but this create-ailk's registry extends ${show(expectedExtends)} — upgrade create-ailk`);
215
+ }
216
+ // #5612 D1 — validated here (throws naming the field on an unrecognized
217
+ // value), never merely stored: a bundle's `layout` is untrusted JSON,
218
+ // unlike the code registry's compile-time-checked `TemplateManifest.layout`.
219
+ validateTemplateLayout(manifest.layout, `template "${name}"`);
220
+ return manifest;
221
+ }
130
222
  /**
131
223
  * Validate every `installMap` entry against the staged tree and the target
132
224
  * scaffold, throwing on the first violation — called BEFORE any copy, so a
133
225
  * bad entry rejects the whole bundle with nothing written to `targetDir`.
134
226
  * Returns the map unchanged (validation only) for the caller to apply.
135
227
  */
136
- function validateInstallMap(installMap, extractedRoot, targetAbs, moduleName) {
228
+ function validateInstallMap(installMap, extractedRoot, targetAbs, subject, isTemplate) {
137
229
  for (const [source, destination] of Object.entries(installMap)) {
138
230
  if (hasUnsafeSegment(source)) {
139
- throw new Error(`module "${moduleName}": unsafe installMap source "${source}"`);
231
+ throw new Error(`${subject}: unsafe installMap source "${source}"`);
140
232
  }
141
233
  const sourceAbs = resolve(extractedRoot, source);
142
234
  if (!isPathInside(extractedRoot, sourceAbs)) {
143
- throw new Error(`module "${moduleName}": installMap source "${source}" resolves outside staging`);
235
+ throw new Error(`${subject}: installMap source "${source}" resolves outside staging`);
144
236
  }
145
237
  let sourceStat;
146
238
  try {
147
239
  sourceStat = statSync(sourceAbs);
148
240
  }
149
241
  catch {
150
- throw new Error(`module "${moduleName}": installMap source "${source}" is not a file the bundle produced`);
242
+ throw new Error(`${subject}: installMap source "${source}" is not a file the bundle produced`);
151
243
  }
152
244
  if (!sourceStat.isFile()) {
153
- throw new Error(`module "${moduleName}": installMap source "${source}" is not a file the bundle produced`);
245
+ throw new Error(`${subject}: installMap source "${source}" is not a file the bundle produced`);
154
246
  }
155
247
  if (hasUnsafeSegment(destination)) {
156
- throw new Error(`module "${moduleName}": unsafe installMap destination "${destination}"`);
248
+ throw new Error(`${subject}: unsafe installMap destination "${destination}"`);
157
249
  }
158
250
  const destAbs = resolve(targetAbs, destination);
159
251
  if (!isPathInside(targetAbs, destAbs)) {
160
- throw new Error(`module "${moduleName}": installMap destination "${destination}" resolves outside target`);
252
+ throw new Error(`${subject}: installMap destination "${destination}" resolves outside target`);
253
+ }
254
+ // #5514 (Medium) — content, never code (spec 5462 D9/D16): a template's
255
+ // installMap can only ever ADD a file, so a code-extension destination
256
+ // is refused outright, same fail-closed point as the containment checks
257
+ // above.
258
+ if (isTemplate && hasTemplateForbiddenExtension(destination)) {
259
+ throw new Error(`${subject}: a template must not carry a code file — installMap destination "${destination}" (spec 5462 D9)`);
161
260
  }
162
261
  }
163
262
  }
@@ -170,14 +269,14 @@ function validateInstallMap(installMap, extractedRoot, targetAbs, moduleName) {
170
269
  * (unknown kind/op), resolved here at validation time, never inside
171
270
  * `applyModulePatches` (which never sees an invalid kind/op).
172
271
  */
173
- function validatePatches(patches, targetAbs, moduleName) {
272
+ function validatePatches(patches, targetAbs, subject) {
174
273
  for (const patch of patches) {
175
274
  if (hasUnsafeSegment(patch.target)) {
176
- throw new Error(`module "${moduleName}": unsafe patch target "${patch.target}"`);
275
+ throw new Error(`${subject}: unsafe patch target "${patch.target}"`);
177
276
  }
178
277
  const targetAbsPath = resolve(targetAbs, patch.target);
179
278
  if (!isPathInside(targetAbs, targetAbsPath)) {
180
- throw new Error(`module "${moduleName}": patch target "${patch.target}" resolves outside target`);
279
+ throw new Error(`${subject}: patch target "${patch.target}" resolves outside target`);
181
280
  }
182
281
  const validOps = VALID_OPS_BY_KIND[patch.kind];
183
282
  if (!validOps) {
@@ -190,11 +289,130 @@ function validatePatches(patches, targetAbs, moduleName) {
190
289
  }
191
290
  }
192
291
  }
292
+ /**
293
+ * TEMPLATE-only "content, never code" guard (spec 5462 § D9/D16; #5514). A
294
+ * template's manifest and tree are UNTRUSTED — fetched from the registry —
295
+ * so the rule that a template contributes content, never code, is enforced
296
+ * here, at the same fail-closed validation point as `installMap`/`patches`
297
+ * below, rather than only by the two jest cases that assert it against the
298
+ * `site-templates/` tree this create-ailk itself ships (those only prove
299
+ * OUR tree is honest, not that a hostile registry response is refused).
300
+ */
301
+ const TEMPLATE_FORBIDDEN_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
302
+ function hasTemplateForbiddenExtension(p) {
303
+ return TEMPLATE_FORBIDDEN_EXTENSIONS.some((ext) => p.toLowerCase().endsWith(ext));
304
+ }
305
+ /**
306
+ * Walks `srcRoot` and throws on the first file (not in `excluded`) whose
307
+ * name ends in a code extension — before any copy, so a violation rejects
308
+ * the whole tree with nothing written to `targetDir` (same posture as
309
+ * `validateInstallMap`/`validatePatches`). `excluded` is the SAME
310
+ * POSIX-normalized set `copyTree` below will use, so the manifest itself and
311
+ * any installMap source never trip this guard a second time.
312
+ */
313
+ function validateTemplateContentOnly(srcRoot, excluded, subject) {
314
+ const stack = [srcRoot];
315
+ while (stack.length > 0) {
316
+ const dir = stack.pop();
317
+ if (dir === undefined)
318
+ break;
319
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
320
+ const srcPath = join(dir, entry.name);
321
+ if (entry.isDirectory()) {
322
+ stack.push(srcPath);
323
+ continue;
324
+ }
325
+ if (!entry.isFile())
326
+ continue;
327
+ const rel = relative(srcRoot, srcPath).split(sep).join("/");
328
+ if (excluded.has(rel))
329
+ continue;
330
+ if (hasTemplateForbiddenExtension(rel)) {
331
+ throw new Error(`${subject}: a template must not carry a code file — "${rel}" (spec 5462 D9)`);
332
+ }
333
+ // symlinks: never present — safeExtractTarGz rejects them outright.
334
+ }
335
+ }
336
+ }
193
337
  /** C0 (0x00-0x1F) and C1 (0x7F-0x9F) control characters, newline excepted. */
194
338
  function stripControlCharacters(text) {
195
339
  // eslint-disable-next-line no-control-regex -- deliberately stripping control chars
196
340
  return text.replace(/[\x00-\x09\x0B-\x1F\x7F-\x9F]/g, "");
197
341
  }
342
+ /**
343
+ * The additive-overlap rule (#5618 D4), checked in the validation block
344
+ * before the first write so a refusal leaves `targetDir` exactly as it was:
345
+ *
346
+ * | incoming layer | destination owned by | outcome |
347
+ * | base | nothing (it is first) | records |
348
+ * | module | base or an earlier module | REFUSED — unless a declared |
349
+ * | | | `patches` target |
350
+ * | module | nothing recorded | allowed, records |
351
+ * | template | anything | allowed, records |
352
+ *
353
+ * The template layer may overwrite anything because it is the customer's
354
+ * copy — that is how a template overrides a module's seed page (D3), and
355
+ * refusing it would refuse `--template marketing-site` as shipped. The
356
+ * `patches` escape hatch is unambiguously module-only: a template carrying
357
+ * `patches` at all is refused outright above.
358
+ *
359
+ * The SECOND module-only escape hatch is DECLARED CO-OWNERSHIP
360
+ * (`coOwnedModuleDestinations()`): a repo file that more than one module's
361
+ * `ownedFiles` names. Both bundles stage it from the same source, so the
362
+ * second to land writes the bytes already there — no disagreement to
363
+ * resolve, which is the only thing this rule protects against. #5677 needs
364
+ * it: the review surface registers under `multi-tenant || signal-collectors`
365
+ * and so must ship in both bundles, and `saas-product` selects both. An
366
+ * overlap neither module declares is still refused.
367
+ *
368
+ * Mirrors `mergeRoster`'s `claimedBy` refusal one grain down: a collision is
369
+ * named with both claimants and the path, never resolved by a silent
370
+ * last-writer-wins.
371
+ */
372
+ function validateLayerOwnership(destinations, ownership, subject, isTemplate, patchTargets, coOwned) {
373
+ if (isTemplate)
374
+ return;
375
+ for (const destination of destinations) {
376
+ const owner = ownership.get(destination);
377
+ if (owner !== undefined &&
378
+ !patchTargets.has(destination) &&
379
+ !coOwned.has(destination)) {
380
+ throw new Error(`${subject}: destination "${destination}" is already owned by ${owner} — a module's contribution to a file another layer wrote must be a declared patch, never an overwrite (#5618 D4)`);
381
+ }
382
+ }
383
+ }
384
+ /**
385
+ * Every scaffold-relative destination this tree will write: each unmapped
386
+ * staged file at its own path (exactly the set `copyTree` lands, same walk,
387
+ * same exclusions) plus every `installMap` destination. Computed before any
388
+ * write, for `validateLayerOwnership` and then for the record.
389
+ */
390
+ function collectLayerDestinations(srcRoot, excluded, installMap) {
391
+ const out = [];
392
+ const stack = [srcRoot];
393
+ while (stack.length > 0) {
394
+ const dir = stack.pop();
395
+ if (dir === undefined)
396
+ break;
397
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
398
+ const srcPath = join(dir, entry.name);
399
+ if (entry.isDirectory()) {
400
+ stack.push(srcPath);
401
+ continue;
402
+ }
403
+ if (!entry.isFile())
404
+ continue;
405
+ const rel = relative(srcRoot, srcPath).split(sep).join("/");
406
+ if (excluded.has(rel))
407
+ continue;
408
+ out.push(rel);
409
+ }
410
+ }
411
+ for (const destination of Object.values(installMap)) {
412
+ out.push(normalize(destination).split(sep).join("/"));
413
+ }
414
+ return out;
415
+ }
198
416
  /**
199
417
  * Extract a fetched module bundle into `targetDir`, merging its files onto
200
418
  * the tree `copyTemplate` already wrote. Throws on any unsafe entry,
@@ -204,7 +422,7 @@ function stripControlCharacters(text) {
204
422
  * file is copied or written into place.
205
423
  */
206
424
  export async function extractModuleBundle(options) {
207
- const { bytes, targetDir, moduleName } = options;
425
+ const { bytes, targetDir, moduleName, templateRequires } = options;
208
426
  const targetAbs = resolve(targetDir);
209
427
  if (!existsSync(targetAbs)) {
210
428
  throw new Error(`extractModuleBundle: target directory does not exist: ${targetAbs}`);
@@ -215,77 +433,154 @@ export async function extractModuleBundle(options) {
215
433
  writeFileSync(stagingTar, bytes);
216
434
  const extractedRoot = join(stagingDir, "extracted");
217
435
  mkdirSync(extractedRoot, { recursive: true });
218
- await safeExtractTarGz(stagingTar, extractedRoot, moduleName);
219
- // installMap validation runs BEFORE any copy, so a bad entry rejects the
220
- // whole bundle with nothing written to targetDir (same posture as the
221
- // tar-entry validation above).
222
- const manifest = readAilkModuleManifest(extractedRoot);
223
- const installMap = manifest?.installMap ?? {};
224
- validateInstallMap(installMap, extractedRoot, targetAbs, moduleName);
225
- // patches validation (spec 5418 § 3 D6) runs at the same point, before
226
- // any copy — a bad target/kind/op rejects the whole bundle with nothing
227
- // written to targetDir, same posture as installMap and the tar-entry
228
- // validation above.
229
- const patches = manifest?.patches ?? [];
230
- validatePatches(patches, targetAbs, moduleName);
231
- // Compute every patch target's new content BEFORE any write to
232
- // targetDir — including before copyTree/installMap below, not only
233
- // before the patch step's own write (spec 5418 § 3 D4/§ 6.2): a
234
- // conflict, missing-target, or unparseable-target failure must leave
235
- // the scaffold exactly as it was, which includes the bundle's own
236
- // plain content and installMap destinations never landing either. A
237
- // patch target the bundle itself delivers (as plain content or as an
238
- // installMap destination) is read from the STAGED tree here, since
239
- // targetDir would not have it yet at this point.
240
- const installMapDestinationToSource = new Map(Object.entries(installMap).map(([source, destination]) => [destination, source]));
241
- const readPatchTarget = (target) => {
242
- const mappedSource = installMapDestinationToSource.get(target);
243
- if (mappedSource !== undefined) {
244
- const sourceAbs = resolve(extractedRoot, mappedSource);
245
- return existsSync(sourceAbs) ? readFileSync(sourceAbs, "utf8") : undefined;
246
- }
247
- const stagedAbs = resolve(extractedRoot, target);
248
- if (existsSync(stagedAbs) && statSync(stagedAbs).isFile()) {
249
- return readFileSync(stagedAbs, "utf8");
250
- }
251
- const targetAbsPath = resolve(targetAbs, target);
252
- return existsSync(targetAbsPath) ? readFileSync(targetAbsPath, "utf8") : undefined;
253
- };
254
- const computedPatches = computeModulePatches(patches, {
255
- versions: options.versions ?? {},
256
- readTarget: readPatchTarget,
436
+ await safeExtractTarGz(stagingTar, extractedRoot, templateRequires !== undefined
437
+ ? `template "${moduleName}"`
438
+ : `module "${moduleName}"`);
439
+ return installBundleTree({
440
+ sourceRoot: extractedRoot,
441
+ targetDir: targetAbs,
442
+ name: moduleName,
443
+ ...(templateRequires !== undefined ? { templateRequires } : {}),
444
+ ...(options.templateExtends !== undefined
445
+ ? { templateExtends: options.templateExtends }
446
+ : {}),
447
+ ...(options.ownership !== undefined ? { ownership: options.ownership } : {}),
448
+ ...(options.versions !== undefined ? { versions: options.versions } : {}),
257
449
  });
258
- // Full validation AND patch computation passed — only now does anything
259
- // get written to targetDir. Copies only (never deletes anything): a
260
- // fetched module's files ADD to the free scaffold, they never remove
261
- // what the free copy already wrote. ailk-module.json and every
262
- // installMap source are excluded from the whole-tree copy — the
263
- // manifest never lands in the scaffold, and a mapped source lands ONLY
264
- // at its mapped destination, never also at its original path.
265
- const excluded = new Set([
266
- AILK_MODULE_JSON,
267
- ...Object.keys(installMap),
268
- ]);
269
- copyTree(extractedRoot, targetAbs, excluded);
270
- for (const [source, destination] of Object.entries(installMap)) {
271
- const destAbs = resolve(targetAbs, destination);
272
- mkdirSync(join(destAbs, ".."), { recursive: true });
273
- copyFileSync(resolve(extractedRoot, source), destAbs);
274
- }
275
- // patches write AFTER every file the bundle brought is in place (D5) —
276
- // after installMap, before postInstall — so a patch can never race a
277
- // file its own ownedFiles/installMap just delivered, and (since it was
278
- // COMPUTED above, before either ran) overwrites rather than conflicts
279
- // with content copyTree/installMap just placed at the same path.
280
- writeModulePatches(computedPatches, targetAbs);
281
- if (manifest?.postInstall) {
282
- process.stdout.write(`${stripControlCharacters(manifest.postInstall)}\n`);
283
- }
284
450
  }
285
451
  finally {
286
452
  rmSync(stagingDir, { recursive: true, force: true });
287
453
  }
288
454
  }
455
+ /**
456
+ * Apply one resolved manifest's `installMap`, plain content and `patches`
457
+ * from `sourceRoot` into `targetDir`.
458
+ *
459
+ * Extracted from `extractModuleBundle` so the Free template path — which has
460
+ * no archive to unpack, only a local tree — runs the SAME step (DD7). The
461
+ * atomicity guarantee is this function's, not its callers': validation AND
462
+ * patch computation both complete before the first byte is written, so a
463
+ * rejected tree leaves `targetDir` exactly as it was.
464
+ */
465
+ export function installBundleTree(options) {
466
+ const { sourceRoot, name, templateRequires, templateExtends, ownership } = options;
467
+ const targetAbs = resolve(options.targetDir);
468
+ const isTemplate = templateRequires !== undefined;
469
+ const subject = isTemplate ? `template "${name}"` : `module "${name}"`;
470
+ // installMap validation runs BEFORE any copy, so a bad entry rejects the
471
+ // whole tree with nothing written to targetDir (same posture as the
472
+ // tar-entry validation in safeExtractTarGz).
473
+ const manifest = isTemplate
474
+ ? readAilkTemplateManifest(sourceRoot, name, templateRequires, templateExtends)
475
+ : readAilkModuleManifest(sourceRoot);
476
+ const installMap = manifest?.installMap ?? {};
477
+ validateInstallMap(installMap, sourceRoot, targetAbs, subject, isTemplate);
478
+ // patches validation (spec 5418 § 3 D6) runs at the same point, before
479
+ // any copy — a bad target/kind/op rejects the whole tree with nothing
480
+ // written to targetDir, same posture as installMap and the tar-entry
481
+ // validation above.
482
+ const patches = manifest?.patches ?? [];
483
+ // #5514 (Medium) — content, never code (spec 5462 D9/D16): every `patches`
484
+ // kind either edits a code file directly (`next-config`, `jest-config`)
485
+ // or a manifest a template has no legitimate reason to touch
486
+ // (`package.json`/`tsconfig.json` belong to the scaffold, not to a
487
+ // content template) — no shipped template uses it, so the simplest
488
+ // fail-closed reading refuses it outright rather than enumerating an
489
+ // allowed subset that has no current user.
490
+ if (isTemplate && patches.length > 0) {
491
+ throw new Error(`${subject}: a template must not carry patches — content, never code (spec 5462 D9)`);
492
+ }
493
+ validatePatches(patches, targetAbs, subject);
494
+ // The exclusion set copyTree/the content-only guard both use — built from
495
+ // NORMALIZED installMap keys (#5514) so a "./x" key excludes the same
496
+ // string `rel`'s traversal below computes, rather than colliding with it
497
+ // only by accident. Both manifest filenames are excluded unconditionally
498
+ // (previously a ternary picked one) — a module bundle carrying a stray
499
+ // ailk-template.json, or vice versa, should never land either.
500
+ const excluded = new Set([
501
+ AILK_TEMPLATE_JSON,
502
+ AILK_MODULE_JSON,
503
+ ...Object.keys(installMap).map((key) => normalize(key).split(sep).join("/")),
504
+ ]);
505
+ if (isTemplate) {
506
+ validateTemplateContentOnly(sourceRoot, excluded, subject);
507
+ }
508
+ // #5618 D4 — the additive-overlap check, at the same seam as the
509
+ // installMap/patches/content-only validation above: BEFORE the first write
510
+ // at copyTree below, so a refused layer leaves targetDir exactly as it
511
+ // was. The destination set is what copyTree + the installMap loop will
512
+ // land; the record happens after writeModulePatches, once it has.
513
+ const layerDestinations = ownership !== undefined
514
+ ? collectLayerDestinations(sourceRoot, excluded, installMap)
515
+ : [];
516
+ if (ownership !== undefined) {
517
+ validateLayerOwnership(layerDestinations, ownership, subject, isTemplate, new Set(patches.map((patch) => normalize(patch.target).split(sep).join("/"))), coOwnedModuleDestinations());
518
+ }
519
+ // Compute every patch target's new content BEFORE any write to
520
+ // targetDir — including before copyTree/installMap below, not only
521
+ // before the patch step's own write (spec 5418 § 3 D4/§ 6.2): a
522
+ // conflict, missing-target, or unparseable-target failure must leave
523
+ // the scaffold exactly as it was, which includes the bundle's own
524
+ // plain content and installMap destinations never landing either. A
525
+ // patch target the bundle itself delivers (as plain content or as an
526
+ // installMap destination) is read from the STAGED tree here, since
527
+ // targetDir would not have it yet at this point.
528
+ const installMapDestinationToSource = new Map(Object.entries(installMap).map(([source, destination]) => [destination, source]));
529
+ const readPatchTarget = (target) => {
530
+ const mappedSource = installMapDestinationToSource.get(target);
531
+ if (mappedSource !== undefined) {
532
+ const sourceAbs = resolve(sourceRoot, mappedSource);
533
+ return existsSync(sourceAbs) ? readFileSync(sourceAbs, "utf8") : undefined;
534
+ }
535
+ const stagedAbs = resolve(sourceRoot, target);
536
+ if (existsSync(stagedAbs) && statSync(stagedAbs).isFile()) {
537
+ return readFileSync(stagedAbs, "utf8");
538
+ }
539
+ const targetAbsPath = resolve(targetAbs, target);
540
+ return existsSync(targetAbsPath) ? readFileSync(targetAbsPath, "utf8") : undefined;
541
+ };
542
+ const computedPatches = computeModulePatches(patches, {
543
+ versions: options.versions ?? {},
544
+ readTarget: readPatchTarget,
545
+ });
546
+ // Full validation AND patch computation passed — only now does anything
547
+ // get written to targetDir. Copies only (never deletes anything): a
548
+ // fetched module's or template's files ADD to the free scaffold, they
549
+ // never remove what the free copy already wrote. The root manifest and
550
+ // every installMap source are excluded from the whole-tree copy — the
551
+ // manifest never lands in the scaffold, and a mapped source lands ONLY
552
+ // at its mapped destination, never also at its original path.
553
+ copyTree(sourceRoot, targetAbs, excluded);
554
+ for (const [source, destination] of Object.entries(installMap)) {
555
+ const destAbs = resolve(targetAbs, destination);
556
+ mkdirSync(join(destAbs, ".."), { recursive: true });
557
+ copyFileSync(resolve(sourceRoot, source), destAbs);
558
+ }
559
+ // patches write AFTER every file the tree brought is in place (D5) —
560
+ // after installMap, before postInstall — so a patch can never race a
561
+ // file its own ownedFiles/installMap just delivered, and (since it was
562
+ // COMPUTED above, before either ran) overwrites rather than conflicts
563
+ // with content copyTree/installMap just placed at the same path.
564
+ writeModulePatches(computedPatches, targetAbs);
565
+ // #5618 D4 — record this layer's destinations only now that every one of
566
+ // them is in place (a later layer's overwrite is judged against the layer
567
+ // that actually wrote the bytes). Patch targets are NOT recorded: a patch
568
+ // edits a file another layer (or the snapshot) owns and does not claim it.
569
+ if (ownership !== undefined) {
570
+ for (const destination of layerDestinations)
571
+ ownership.set(destination, subject);
572
+ }
573
+ if (manifest?.postInstall) {
574
+ process.stdout.write(`${stripControlCharacters(manifest.postInstall)}\n`);
575
+ }
576
+ // #5612 D1/D2 — already validated above (readAilkTemplateManifest calls
577
+ // validateTemplateLayout); returned as-is (defaults unfilled) for the
578
+ // caller to resolve alongside whatever the code registry's own
579
+ // `TemplateManifest.layout` says.
580
+ return isTemplate && manifest?.layout !== undefined
581
+ ? { layout: manifest.layout }
582
+ : {};
583
+ }
289
584
  /**
290
585
  * Recursively copy every file from `srcRoot` into `destRoot`, creating parent
291
586
  * directories as needed, except any path in `excluded` (relative to
@@ -324,14 +619,14 @@ function copyTree(srcRoot, destRoot, excluded = new Set()) {
324
619
  * file doc for why this is a port and not an import). Extracts `tarGzPath`
325
620
  * into `targetDir`, which the caller guarantees is empty.
326
621
  */
327
- async function safeExtractTarGz(tarGzPath, targetDir, moduleName) {
622
+ async function safeExtractTarGz(tarGzPath, targetDir, subject) {
328
623
  const resolvedTarget = resolve(targetDir);
329
624
  let entryCount = 0;
330
625
  let totalSize = 0;
331
626
  const captured = { error: null };
332
627
  const reject = (msg) => {
333
628
  if (captured.error === null) {
334
- captured.error = new Error(`module "${moduleName}": ${msg}`);
629
+ captured.error = new Error(`${subject}: ${msg}`);
335
630
  }
336
631
  return false;
337
632
  };
@@ -383,7 +678,7 @@ async function safeExtractTarGz(tarGzPath, targetDir, moduleName) {
383
678
  },
384
679
  });
385
680
  try {
386
- await pipeline(createReadStream(tarGzPath), createGunzip(), extractStream);
681
+ await pipeline(createReadStream(tarGzPath), createGunzip({ maxOutputLength: MAX_TOTAL_SIZE_BYTES }), extractStream);
387
682
  }
388
683
  catch (err) {
389
684
  if (captured.error !== null)
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Per-request timeout applied to every template/module registry fetch.
3
+ * 30s is generous for a small tarball fetch over a real network while still
4
+ * bounding a hung connection or slow-drip response.
5
+ */
6
+ export declare const REQUEST_TIMEOUT_MS = 30000;
7
+ /** `AbortSignal.timeout(REQUEST_TIMEOUT_MS)` — one call site, one constant. */
8
+ export declare function fetchTimeoutSignal(): AbortSignal;
9
+ /**
10
+ * Reject a registry URL whose origin is not `https:`, unless its host is
11
+ * loopback (above) — the mock registry the test suite spawns is deliberately
12
+ * plain `http://127.0.0.1:<port>`. `ErrorCtor` lets each
13
+ * caller throw its own error class (`FetchTemplateError` / `FetchModuleError`)
14
+ * so this is a new FAILURE MODE, never a new error type from the caller's
15
+ * point of view — and never a licence error (`isLicenseError` stays false).
16
+ */
17
+ export declare function assertHttpsRegistryUrl<E extends Error>(registryUrl: string, ErrorCtor: new (message: string) => E): void;
18
+ /**
19
+ * Reject a `Response` whose `Content-Length` header declares a size larger
20
+ * than `MAX_TOTAL_SIZE_BYTES` — called by the caller right after its
21
+ * existing status-code checks succeed, BEFORE `.arrayBuffer()` ever runs, so
22
+ * an oversized body is never buffered into memory. A missing or unparseable
23
+ * `Content-Length` is not itself a violation (a chunked/streamed response may
24
+ * omit it) — this is defense in depth against a declared size, not a
25
+ * replacement for the tar-entry cumulative-size cap `extract-module-bundle.ts`
26
+ * still enforces downstream.
27
+ */
28
+ export declare function assertContentLengthWithinLimit<E extends Error>(response: Response, ErrorCtor: new (message: string) => E): void;