create-ailk 0.3.0 → 0.4.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 (37) hide show
  1. package/dist/cli.js +19 -9
  2. package/dist/lib/extract-module-bundle.d.ts +39 -0
  3. package/dist/lib/extract-module-bundle.js +165 -78
  4. package/dist/lib/fetch-template.d.ts +20 -0
  5. package/dist/lib/fetch-template.js +129 -0
  6. package/dist/lib/module-license-gate.d.ts +8 -0
  7. package/dist/lib/module-license-gate.js +12 -4
  8. package/dist/module-architecture.d.ts +58 -0
  9. package/dist/module-architecture.js +156 -4
  10. package/dist/parse-args.d.ts +8 -1
  11. package/dist/parse-args.js +43 -0
  12. package/dist/programmatic.d.ts +26 -1
  13. package/dist/programmatic.js +100 -4
  14. package/dist/surfaces.js +42 -0
  15. package/dist/template-manifest.d.ts +92 -0
  16. package/dist/template-manifest.js +168 -0
  17. package/dist/template-names.d.ts +41 -0
  18. package/dist/template-names.js +44 -0
  19. package/package.json +2 -1
  20. package/site-templates/marketing-site-free/ailk-template.json +8 -0
  21. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +103 -0
  22. package/site-templates/marketing-site-free/content/en/PrivacyPage/privacy.mdx +79 -0
  23. package/site-templates/marketing-site-free/content/en/TermsPage/terms.mdx +80 -0
  24. package/site-templates/marketing-site-free/provenance.json +4 -0
  25. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +166 -0
  26. package/templates/apps/api/src/config/modules.ts +1 -1
  27. package/templates/apps/api/src/server.ts +46 -3
  28. package/templates/apps/web/app/[locale]/pricing/page.tsx +40 -0
  29. package/templates/apps/web/app/[locale]/privacy/page.tsx +40 -0
  30. package/templates/apps/web/app/[locale]/terms/page.tsx +40 -0
  31. package/templates/apps/web/jest.config.cjs +5 -0
  32. package/templates/apps/web/lib/__tests__/server-api.test.ts +61 -0
  33. package/templates/apps/web/lib/server-api.ts +28 -12
  34. package/templates/apps/web/messages/en.json +58 -0
  35. package/templates/database/CHANGELOG.md +14 -0
  36. package/templates/database/package.json +1 -1
  37. package/templates/package.json +1 -1
package/dist/cli.js CHANGED
@@ -9,8 +9,9 @@
9
9
  * 2. on --help: print helpText, exit 0
10
10
  * 3. on --version: print version, exit 0
11
11
  * 4. delegate to scaffold() in programmatic.ts — gates + fetches any
12
- * requested paid module (K1, #5089 — Flavor 3), then validates + copies
13
- * the local template, then merges each fetched bundle in
12
+ * requested paid module (K1, #5089 — Flavor 3) and any named site
13
+ * template's own bundle (#5481), then validates + copies the local
14
+ * template, then merges each fetched bundle in, the site template last
14
15
  * 5. when --install: spawn `pnpm install` (cwd = target, hard-coded argv)
15
16
  * 6. print next-steps message
16
17
  *
@@ -100,17 +101,24 @@ async function main() {
100
101
  process.stdout.write(drift);
101
102
  process.exit(0);
102
103
  }
103
- // scaffold() gates + fetches any requested paid module (K1, #5089), then
104
- // validates and copies the local template — throws ValidationError or
105
- // LicenseRequiredError on rejection (caught at top level below). Neither
106
- // AILK_LICENSE_KEY nor AILK_MODULE_REGISTRY_URL is read, and no network
107
- // call is made, when `opts.modules` names no paid module — the regression
108
- // floor (see `resolvePaidModules` in `lib/module-license-gate.ts`).
104
+ // scaffold() gates + fetches any requested paid module (K1, #5089) and any
105
+ // named site template's own bundle (#5481), then validates and copies the
106
+ // local template — throws ValidationError or LicenseRequiredError on
107
+ // rejection (caught at top level below). Neither AILK_LICENSE_KEY nor
108
+ // either registry URL is read, and no network call is made, when the
109
+ // effective module set names no paid module — the regression floor, which
110
+ // `--template marketing-site-free` deliberately rides (record D11): its
111
+ // `requires` is all-free, so it installs from the shipped
112
+ // `site-templates/` tree with no key and no request.
109
113
  const { targetDir: targetAbs, surfaces } = await scaffold({
110
114
  targetDir: opts.target,
111
115
  modules: opts.modules,
112
116
  surfaces: opts.surfaces,
113
117
  withCatalog: opts.withCatalog,
118
+ // Spread rather than passed as `undefined` so a run naming no template
119
+ // hands `scaffold()` exactly the option object it got before the flag
120
+ // existed (spec 5481 § 3 DD3's byte-identity floor).
121
+ ...(opts.template !== undefined ? { template: opts.template } : {}),
114
122
  });
115
123
  if (opts.install) {
116
124
  // Hard-coded argv. User input only flows through `cwd`, never the command.
@@ -126,7 +134,9 @@ async function main() {
126
134
  // Next-steps message.
127
135
  const lines = [
128
136
  "",
129
- "Scaffolded AI Launch Kit starter.",
137
+ opts.template !== undefined
138
+ ? `Scaffolded AI Launch Kit starter from the "${opts.template}" template.`
139
+ : "Scaffolded AI Launch Kit starter.",
130
140
  "",
131
141
  "Next steps:",
132
142
  ` cd ${targetAbs}`,
@@ -13,6 +13,12 @@ export interface ExtractModuleBundleOptions {
13
13
  * (a bundle with no `patches`, or none using `workspace:*`, needs nothing).
14
14
  */
15
15
  versions?: WorkspaceVersionMap;
16
+ /**
17
+ * Present ⟹ `moduleName` names a SITE TEMPLATE, not a module, and the
18
+ * archive's root manifest is `ailk-template.json` cross-checked against
19
+ * this list (record D10, DD4). See `InstallBundleTreeOptions`.
20
+ */
21
+ templateRequires?: readonly string[];
16
22
  }
17
23
  /**
18
24
  * Extract a fetched module bundle into `targetDir`, merging its files onto
@@ -23,3 +29,36 @@ export interface ExtractModuleBundleOptions {
23
29
  * file is copied or written into place.
24
30
  */
25
31
  export declare function extractModuleBundle(options: ExtractModuleBundleOptions): Promise<void>;
32
+ export interface InstallBundleTreeOptions {
33
+ /**
34
+ * The validated tree whose files land in the scaffold. Two source roots,
35
+ * one install step (spec 5481 § 3 DD7): the staging dir
36
+ * `extractModuleBundle` above produced, or `site-templates/<name>/` read
37
+ * straight off disk on the keyless Free path.
38
+ */
39
+ sourceRoot: string;
40
+ /** The already-scaffolded target directory the files land in. */
41
+ targetDir: string;
42
+ /** The module or template name — error messages and nothing else. */
43
+ name: string;
44
+ /**
45
+ * Present ⟹ this tree is a SITE TEMPLATE's: the root manifest is
46
+ * `ailk-template.json`, it MUST be present and parseable, and its
47
+ * `requires` must agree with this list — the client registry's own
48
+ * (record D10, DD4). Absent ⟹ a module bundle, read exactly as before.
49
+ */
50
+ templateRequires?: readonly string[];
51
+ /** Canonical `@working-theory/*` versions for `workspace:*` substitution in patch targets. */
52
+ versions?: WorkspaceVersionMap;
53
+ }
54
+ /**
55
+ * Apply one resolved manifest's `installMap`, plain content and `patches`
56
+ * from `sourceRoot` into `targetDir`.
57
+ *
58
+ * Extracted from `extractModuleBundle` so the Free template path — which has
59
+ * no archive to unpack, only a local tree — runs the SAME step (DD7). The
60
+ * atomicity guarantee is this function's, not its callers': validation AND
61
+ * patch computation both complete before the first byte is written, so a
62
+ * rejected tree leaves `targetDir` exactly as it was.
63
+ */
64
+ export declare function installBundleTree(options: InstallBundleTreeOptions): void;
@@ -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";
@@ -75,6 +89,12 @@ const MAX_ENTRIES = 1000;
75
89
  const MAX_TOTAL_SIZE_BYTES = 50 * 1024 * 1024; // 50 MiB
76
90
  /** The bundle-root manifest file (spec § D7). Never copied into the scaffold. */
77
91
  const AILK_MODULE_JSON = "ailk-module.json";
92
+ /**
93
+ * A SITE TEMPLATE's root manifest (spec 5481 § 4; parent record D10) — the
94
+ * same shape as `ailk-module.json` plus one mandatory field, `requires`.
95
+ * Never copied into the scaffold either.
96
+ */
97
+ const AILK_TEMPLATE_JSON = "ailk-template.json";
78
98
  /** The op names valid for each patch `kind` (spec 5418 § 3 D1's ModulePatchOp union, partitioned). */
79
99
  const VALID_OPS_BY_KIND = {
80
100
  json: new Set(["merge-keys", "append-unique"]),
@@ -127,37 +147,77 @@ function readAilkModuleManifest(extractedRoot) {
127
147
  return null;
128
148
  }
129
149
  }
150
+ /**
151
+ * Read `ailk-template.json` from a template tree's root and cross-check its
152
+ * `requires` against the client registry's list for that template.
153
+ *
154
+ * REFUSES where `readAilkModuleManifest` degrades (spec 5481 § 3 DD4). A
155
+ * module's manifest fields are all optional, so an absent or unparseable one
156
+ * legitimately means "a plain content merge". A template's `requires` is
157
+ * mandatory (record D10) — inheriting treat-as-absent would make the
158
+ * cross-check optional at the bundle's own discretion, since a bundle
159
+ * carrying no manifest would never be checked at all.
160
+ *
161
+ * The disagreement check is a boundary check, and the boundary is the bundle:
162
+ * `expectedRequires` comes from the caller's own `TEMPLATE_MANIFESTS` entry
163
+ * (internal, compile-checked against `RECOGNIZED_MODULES`), while `requires`
164
+ * is whatever the archive says. Compared as SETS, not as sequences — the
165
+ * registry's declaration order is not part of the contract.
166
+ */
167
+ function readAilkTemplateManifest(sourceRoot, name, expectedRequires) {
168
+ const manifestPath = join(sourceRoot, AILK_TEMPLATE_JSON);
169
+ if (!existsSync(manifestPath)) {
170
+ throw new Error(`template "${name}": bundle carries no ${AILK_TEMPLATE_JSON} — a template manifest is mandatory`);
171
+ }
172
+ let manifest;
173
+ try {
174
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
175
+ }
176
+ catch {
177
+ throw new Error(`template "${name}": ${AILK_TEMPLATE_JSON} is not readable JSON`);
178
+ }
179
+ const declared = manifest.requires;
180
+ if (!Array.isArray(declared)) {
181
+ throw new Error(`template "${name}": ${AILK_TEMPLATE_JSON} declares no "requires" list`);
182
+ }
183
+ const expected = [...new Set(expectedRequires)].sort();
184
+ const actual = [...new Set(declared)].sort();
185
+ if (expected.join(",") !== actual.join(",")) {
186
+ throw new Error(`template "${name}": bundle requires [${actual.join(", ")}] but this create-ailk's registry requires [${expected.join(", ")}] — upgrade create-ailk`);
187
+ }
188
+ return manifest;
189
+ }
130
190
  /**
131
191
  * Validate every `installMap` entry against the staged tree and the target
132
192
  * scaffold, throwing on the first violation — called BEFORE any copy, so a
133
193
  * bad entry rejects the whole bundle with nothing written to `targetDir`.
134
194
  * Returns the map unchanged (validation only) for the caller to apply.
135
195
  */
136
- function validateInstallMap(installMap, extractedRoot, targetAbs, moduleName) {
196
+ function validateInstallMap(installMap, extractedRoot, targetAbs, subject) {
137
197
  for (const [source, destination] of Object.entries(installMap)) {
138
198
  if (hasUnsafeSegment(source)) {
139
- throw new Error(`module "${moduleName}": unsafe installMap source "${source}"`);
199
+ throw new Error(`${subject}: unsafe installMap source "${source}"`);
140
200
  }
141
201
  const sourceAbs = resolve(extractedRoot, source);
142
202
  if (!isPathInside(extractedRoot, sourceAbs)) {
143
- throw new Error(`module "${moduleName}": installMap source "${source}" resolves outside staging`);
203
+ throw new Error(`${subject}: installMap source "${source}" resolves outside staging`);
144
204
  }
145
205
  let sourceStat;
146
206
  try {
147
207
  sourceStat = statSync(sourceAbs);
148
208
  }
149
209
  catch {
150
- throw new Error(`module "${moduleName}": installMap source "${source}" is not a file the bundle produced`);
210
+ throw new Error(`${subject}: installMap source "${source}" is not a file the bundle produced`);
151
211
  }
152
212
  if (!sourceStat.isFile()) {
153
- throw new Error(`module "${moduleName}": installMap source "${source}" is not a file the bundle produced`);
213
+ throw new Error(`${subject}: installMap source "${source}" is not a file the bundle produced`);
154
214
  }
155
215
  if (hasUnsafeSegment(destination)) {
156
- throw new Error(`module "${moduleName}": unsafe installMap destination "${destination}"`);
216
+ throw new Error(`${subject}: unsafe installMap destination "${destination}"`);
157
217
  }
158
218
  const destAbs = resolve(targetAbs, destination);
159
219
  if (!isPathInside(targetAbs, destAbs)) {
160
- throw new Error(`module "${moduleName}": installMap destination "${destination}" resolves outside target`);
220
+ throw new Error(`${subject}: installMap destination "${destination}" resolves outside target`);
161
221
  }
162
222
  }
163
223
  }
@@ -170,14 +230,14 @@ function validateInstallMap(installMap, extractedRoot, targetAbs, moduleName) {
170
230
  * (unknown kind/op), resolved here at validation time, never inside
171
231
  * `applyModulePatches` (which never sees an invalid kind/op).
172
232
  */
173
- function validatePatches(patches, targetAbs, moduleName) {
233
+ function validatePatches(patches, targetAbs, subject) {
174
234
  for (const patch of patches) {
175
235
  if (hasUnsafeSegment(patch.target)) {
176
- throw new Error(`module "${moduleName}": unsafe patch target "${patch.target}"`);
236
+ throw new Error(`${subject}: unsafe patch target "${patch.target}"`);
177
237
  }
178
238
  const targetAbsPath = resolve(targetAbs, patch.target);
179
239
  if (!isPathInside(targetAbs, targetAbsPath)) {
180
- throw new Error(`module "${moduleName}": patch target "${patch.target}" resolves outside target`);
240
+ throw new Error(`${subject}: patch target "${patch.target}" resolves outside target`);
181
241
  }
182
242
  const validOps = VALID_OPS_BY_KIND[patch.kind];
183
243
  if (!validOps) {
@@ -204,7 +264,7 @@ function stripControlCharacters(text) {
204
264
  * file is copied or written into place.
205
265
  */
206
266
  export async function extractModuleBundle(options) {
207
- const { bytes, targetDir, moduleName } = options;
267
+ const { bytes, targetDir, moduleName, templateRequires } = options;
208
268
  const targetAbs = resolve(targetDir);
209
269
  if (!existsSync(targetAbs)) {
210
270
  throw new Error(`extractModuleBundle: target directory does not exist: ${targetAbs}`);
@@ -215,77 +275,104 @@ export async function extractModuleBundle(options) {
215
275
  writeFileSync(stagingTar, bytes);
216
276
  const extractedRoot = join(stagingDir, "extracted");
217
277
  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,
278
+ await safeExtractTarGz(stagingTar, extractedRoot, templateRequires !== undefined
279
+ ? `template "${moduleName}"`
280
+ : `module "${moduleName}"`);
281
+ installBundleTree({
282
+ sourceRoot: extractedRoot,
283
+ targetDir: targetAbs,
284
+ name: moduleName,
285
+ ...(templateRequires !== undefined ? { templateRequires } : {}),
286
+ ...(options.versions !== undefined ? { versions: options.versions } : {}),
257
287
  });
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
288
  }
285
289
  finally {
286
290
  rmSync(stagingDir, { recursive: true, force: true });
287
291
  }
288
292
  }
293
+ /**
294
+ * Apply one resolved manifest's `installMap`, plain content and `patches`
295
+ * from `sourceRoot` into `targetDir`.
296
+ *
297
+ * Extracted from `extractModuleBundle` so the Free template path — which has
298
+ * no archive to unpack, only a local tree — runs the SAME step (DD7). The
299
+ * atomicity guarantee is this function's, not its callers': validation AND
300
+ * patch computation both complete before the first byte is written, so a
301
+ * rejected tree leaves `targetDir` exactly as it was.
302
+ */
303
+ export function installBundleTree(options) {
304
+ const { sourceRoot, name, templateRequires } = options;
305
+ const targetAbs = resolve(options.targetDir);
306
+ const isTemplate = templateRequires !== undefined;
307
+ const subject = isTemplate ? `template "${name}"` : `module "${name}"`;
308
+ // installMap validation runs BEFORE any copy, so a bad entry rejects the
309
+ // whole tree with nothing written to targetDir (same posture as the
310
+ // tar-entry validation in safeExtractTarGz).
311
+ const manifest = isTemplate
312
+ ? readAilkTemplateManifest(sourceRoot, name, templateRequires)
313
+ : readAilkModuleManifest(sourceRoot);
314
+ const installMap = manifest?.installMap ?? {};
315
+ validateInstallMap(installMap, sourceRoot, targetAbs, subject);
316
+ // patches validation (spec 5418 § 3 D6) runs at the same point, before
317
+ // any copy — a bad target/kind/op rejects the whole tree with nothing
318
+ // written to targetDir, same posture as installMap and the tar-entry
319
+ // validation above.
320
+ const patches = manifest?.patches ?? [];
321
+ validatePatches(patches, targetAbs, subject);
322
+ // Compute every patch target's new content BEFORE any write to
323
+ // targetDir — including before copyTree/installMap below, not only
324
+ // before the patch step's own write (spec 5418 § 3 D4/§ 6.2): a
325
+ // conflict, missing-target, or unparseable-target failure must leave
326
+ // the scaffold exactly as it was, which includes the bundle's own
327
+ // plain content and installMap destinations never landing either. A
328
+ // patch target the bundle itself delivers (as plain content or as an
329
+ // installMap destination) is read from the STAGED tree here, since
330
+ // targetDir would not have it yet at this point.
331
+ const installMapDestinationToSource = new Map(Object.entries(installMap).map(([source, destination]) => [destination, source]));
332
+ const readPatchTarget = (target) => {
333
+ const mappedSource = installMapDestinationToSource.get(target);
334
+ if (mappedSource !== undefined) {
335
+ const sourceAbs = resolve(sourceRoot, mappedSource);
336
+ return existsSync(sourceAbs) ? readFileSync(sourceAbs, "utf8") : undefined;
337
+ }
338
+ const stagedAbs = resolve(sourceRoot, target);
339
+ if (existsSync(stagedAbs) && statSync(stagedAbs).isFile()) {
340
+ return readFileSync(stagedAbs, "utf8");
341
+ }
342
+ const targetAbsPath = resolve(targetAbs, target);
343
+ return existsSync(targetAbsPath) ? readFileSync(targetAbsPath, "utf8") : undefined;
344
+ };
345
+ const computedPatches = computeModulePatches(patches, {
346
+ versions: options.versions ?? {},
347
+ readTarget: readPatchTarget,
348
+ });
349
+ // Full validation AND patch computation passed — only now does anything
350
+ // get written to targetDir. Copies only (never deletes anything): a
351
+ // fetched module's or template's files ADD to the free scaffold, they
352
+ // never remove what the free copy already wrote. The root manifest and
353
+ // every installMap source are excluded from the whole-tree copy — the
354
+ // manifest never lands in the scaffold, and a mapped source lands ONLY
355
+ // at its mapped destination, never also at its original path.
356
+ const excluded = new Set([
357
+ isTemplate ? AILK_TEMPLATE_JSON : AILK_MODULE_JSON,
358
+ ...Object.keys(installMap),
359
+ ]);
360
+ copyTree(sourceRoot, targetAbs, excluded);
361
+ for (const [source, destination] of Object.entries(installMap)) {
362
+ const destAbs = resolve(targetAbs, destination);
363
+ mkdirSync(join(destAbs, ".."), { recursive: true });
364
+ copyFileSync(resolve(sourceRoot, source), destAbs);
365
+ }
366
+ // patches write AFTER every file the tree brought is in place (D5) —
367
+ // after installMap, before postInstall — so a patch can never race a
368
+ // file its own ownedFiles/installMap just delivered, and (since it was
369
+ // COMPUTED above, before either ran) overwrites rather than conflicts
370
+ // with content copyTree/installMap just placed at the same path.
371
+ writeModulePatches(computedPatches, targetAbs);
372
+ if (manifest?.postInstall) {
373
+ process.stdout.write(`${stripControlCharacters(manifest.postInstall)}\n`);
374
+ }
375
+ }
289
376
  /**
290
377
  * Recursively copy every file from `srcRoot` into `destRoot`, creating parent
291
378
  * directories as needed, except any path in `excluded` (relative to
@@ -324,14 +411,14 @@ function copyTree(srcRoot, destRoot, excluded = new Set()) {
324
411
  * file doc for why this is a port and not an import). Extracts `tarGzPath`
325
412
  * into `targetDir`, which the caller guarantees is empty.
326
413
  */
327
- async function safeExtractTarGz(tarGzPath, targetDir, moduleName) {
414
+ async function safeExtractTarGz(tarGzPath, targetDir, subject) {
328
415
  const resolvedTarget = resolve(targetDir);
329
416
  let entryCount = 0;
330
417
  let totalSize = 0;
331
418
  const captured = { error: null };
332
419
  const reject = (msg) => {
333
420
  if (captured.error === null) {
334
- captured.error = new Error(`module "${moduleName}": ${msg}`);
421
+ captured.error = new Error(`${subject}: ${msg}`);
335
422
  }
336
423
  return false;
337
424
  };
@@ -0,0 +1,20 @@
1
+ export declare const DEFAULT_AILK_TEMPLATE_REGISTRY_URL = "https://ailaunchkit.ai/api/templates";
2
+ export interface FetchTemplateOptions {
3
+ templateName: string;
4
+ /** JWT used as `Authorization: Bearer <token>`. Must be non-empty. */
5
+ token: string;
6
+ /** Registry root; `<name>/<version>` is appended as two path segments. */
7
+ registryUrl?: string;
8
+ /** The template version to request. Defaults to this package's own version. */
9
+ version?: string;
10
+ /** Override for tests; defaults to `globalThis.fetch`. Never a live call in CI. */
11
+ fetchImpl?: typeof fetch;
12
+ }
13
+ export declare class FetchTemplateError extends Error {
14
+ /** True iff the failure was a license-gating signal (HTTP 401 or 403). */
15
+ readonly isLicenseError: boolean;
16
+ constructor(message: string,
17
+ /** True iff the failure was a license-gating signal (HTTP 401 or 403). */
18
+ isLicenseError?: boolean);
19
+ }
20
+ export declare function fetchTemplate(options: FetchTemplateOptions): Promise<Uint8Array>;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Authenticated site-template-bundle fetch (spec 5481 § 4; parent record
3
+ * D1, § 6).
4
+ *
5
+ * The client half of `GET /v1/templates/:name/:version` (#5480's route),
6
+ * reached publicly at `/api/templates/:path*`. Posture is
7
+ * `fetch-module.ts`'s, mirrored one-for-one rather than generalised into a
8
+ * shared helper: bearer-JWT auth, `redirect: "error"` so a redirected
9
+ * response cannot drop the bearer token onto an unintended host, an
10
+ * empty-token guard before any network call, and the same status mapping —
11
+ * 401 → licence invalid/revoked/lapsed, 403 → not included / term ended,
12
+ * 404 → content not yet available, other non-2xx → the upstream body.
13
+ *
14
+ * ONE word differs, and it is the whole reason this is a sibling rather
15
+ * than a second call site of `fetchModule` (spec 5481 § 3 DD5): the 404
16
+ * sentence names its subject, and the subject here is a TEMPLATE. Parent
17
+ * record D17 makes that sentence a specified outcome rather than an
18
+ * incidental one, so the fourth refusal reuses it — with the noun
19
+ * parameterised, since left alone it would call the template a module.
20
+ *
21
+ * Deliberately depends on neither `@working-theory/license` nor
22
+ * `@working-theory/cli`, for the reason `fetch-module.ts`'s file doc states
23
+ * at length: both are `publishConfig.access: "restricted"` and this is the
24
+ * one package published to PUBLIC npm, so a runtime dependency on either
25
+ * would break `pnpm install` for every Free consumer rather than merely
26
+ * gate the paid path. The registry's own 401 IS the licence verdict.
27
+ */
28
+ import pkg from "../../package.json" with { type: "json" };
29
+ export const DEFAULT_AILK_TEMPLATE_REGISTRY_URL = "https://ailaunchkit.ai/api/templates";
30
+ /**
31
+ * This package's own `package.json` `"version"` — a static JSON import for
32
+ * the reason `fetch-module.ts`'s `CREATE_AILK_VERSION` doc gives: `import.meta`
33
+ * is a SyntaxError under ts-jest's CommonJS transform, and this file is
34
+ * reachable from jest. A template bundle overlays the scaffold this
35
+ * `create-ailk` just wrote, so the default version request is the running
36
+ * package's own.
37
+ */
38
+ const CREATE_AILK_VERSION = pkg.version;
39
+ export class FetchTemplateError extends Error {
40
+ isLicenseError;
41
+ constructor(message,
42
+ /** True iff the failure was a license-gating signal (HTTP 401 or 403). */
43
+ isLicenseError = false) {
44
+ super(message);
45
+ this.isLicenseError = isLicenseError;
46
+ this.name = "FetchTemplateError";
47
+ }
48
+ }
49
+ /**
50
+ * Join `registryUrl` with `templateName` and `version` as two trailing path
51
+ * segments. `encodeURIComponent` on each is defense in depth —
52
+ * `parse-args.ts` already restricts `templateName` to `TEMPLATE_MANIFESTS`'s
53
+ * keys, but a hostile value can never smuggle a path separator or query
54
+ * string here regardless.
55
+ */
56
+ function buildUrl(registryUrl, templateName, version) {
57
+ const trimmed = registryUrl.replace(/\/+$/, "");
58
+ return `${trimmed}/${encodeURIComponent(templateName)}/${encodeURIComponent(version)}`;
59
+ }
60
+ export async function fetchTemplate(options) {
61
+ const { templateName, token } = options;
62
+ if (token.length === 0) {
63
+ throw new FetchTemplateError("internal error: fetchTemplate called without a token");
64
+ }
65
+ const version = options.version ?? CREATE_AILK_VERSION;
66
+ const registryUrl = options.registryUrl ??
67
+ process.env["AILK_TEMPLATE_REGISTRY_URL"] ??
68
+ DEFAULT_AILK_TEMPLATE_REGISTRY_URL;
69
+ const url = buildUrl(registryUrl, templateName, version);
70
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
71
+ let response;
72
+ try {
73
+ response = await fetchImpl(url, {
74
+ method: "GET",
75
+ headers: {
76
+ Authorization: `Bearer ${token}`,
77
+ Accept: "application/gzip, application/octet-stream",
78
+ },
79
+ redirect: "error",
80
+ });
81
+ }
82
+ catch (err) {
83
+ const detail = err instanceof Error ? err.message : String(err);
84
+ throw new FetchTemplateError(`template registry unreachable: ${detail}`);
85
+ }
86
+ if (response.status === 401) {
87
+ throw new FetchTemplateError(`your licence does not cover the "${templateName}" template (invalid, revoked, or lapsed)`,
88
+ /* isLicenseError */ true);
89
+ }
90
+ if (response.status === 403) {
91
+ throw new FetchTemplateError(`your licence does not include the "${templateName}" template, or its term ended before create-ailk ${version} was released — use a create-ailk version released during your term`,
92
+ /* isLicenseError */ true);
93
+ }
94
+ if (response.status === 404) {
95
+ throw new FetchTemplateError(`"${templateName}" template content not yet available — ping us at hello@ailaunchkit.ai`);
96
+ }
97
+ if (!response.ok) {
98
+ const body = await safeReadBody(response);
99
+ throw new FetchTemplateError(`template registry returned ${response.status} for "${templateName}": ${body}`);
100
+ }
101
+ const buf = await response.arrayBuffer();
102
+ return new Uint8Array(buf);
103
+ }
104
+ /**
105
+ * Best-effort error body extraction — the registry uses `{ error: "..." }`
106
+ * shapes, falls back to text, and swallows any read failure so the caller's
107
+ * message stays useful. `fetch-module.ts`'s helper of the same name.
108
+ */
109
+ async function safeReadBody(response) {
110
+ try {
111
+ const text = await response.text();
112
+ try {
113
+ const parsed = JSON.parse(text);
114
+ if (parsed !== null &&
115
+ typeof parsed === "object" &&
116
+ "error" in parsed &&
117
+ typeof parsed.error === "string") {
118
+ return parsed.error;
119
+ }
120
+ }
121
+ catch {
122
+ /* not JSON — fall through to raw text */
123
+ }
124
+ return text || response.statusText;
125
+ }
126
+ catch {
127
+ return response.statusText;
128
+ }
129
+ }
@@ -39,6 +39,14 @@ export interface ResolvePaidModulesOptions {
39
39
  version?: string;
40
40
  /** Forwarded to `fetchModule`; defaults to `globalThis.fetch`. Never a live call in tests. */
41
41
  fetchImpl?: typeof fetch;
42
+ /**
43
+ * The site template whose `requires` put these modules in `paidModules`
44
+ * (spec 5481 § 3 DD5 rows 1-2). Present ⟹ every refusal below names it
45
+ * alongside the specific module, because the buyer asked for a TEMPLATE and
46
+ * a message naming only a module they never typed does not tell them what
47
+ * to do. Absent ⟹ a plain `--modules` run, whose messages are unchanged.
48
+ */
49
+ templateName?: string;
42
50
  }
43
51
  /**
44
52
  * Resolve every requested paid module to its fetched bundle bytes, or throw
@@ -28,7 +28,7 @@ export async function resolvePaidModules(options) {
28
28
  }
29
29
  const token = options.licenseKey ?? process.env["AILK_LICENSE_KEY"];
30
30
  if (token === undefined || token.length === 0) {
31
- throw new LicenseRequiredError(conversionHint(paidModules));
31
+ throw new LicenseRequiredError(conversionHint(paidModules, options.templateName));
32
32
  }
33
33
  const bundles = new Map();
34
34
  for (const moduleName of paidModules) {
@@ -49,7 +49,12 @@ export async function resolvePaidModules(options) {
49
49
  catch (err) {
50
50
  if (err instanceof FetchModuleError) {
51
51
  if (err.isLicenseError) {
52
- throw new LicenseRequiredError(`${err.message} → ${CONVERSION_HINT_URL}`);
52
+ // DD5 row 2: the mapped 401/403 already names the module the key
53
+ // does not cover; the template is appended so the buyer can see
54
+ // WHICH of the things they asked for needs it.
55
+ throw new LicenseRequiredError(options.templateName !== undefined
56
+ ? `${err.message}, required by template "${options.templateName}" → ${CONVERSION_HINT_URL}`
57
+ : `${err.message} → ${CONVERSION_HINT_URL}`);
53
58
  }
54
59
  // Not a license-gating signal (e.g. 404 "not yet available", or the
55
60
  // registry unreachable) — a generic failure, not the honest-denial
@@ -62,7 +67,10 @@ export async function resolvePaidModules(options) {
62
67
  }
63
68
  return bundles;
64
69
  }
65
- function conversionHint(paidModules) {
70
+ function conversionHint(paidModules, templateName) {
66
71
  const list = paidModules.join(", ");
67
- return `Pro module(s) requested (${list}) — set AILK_LICENSE_KEY first → ${CONVERSION_HINT_URL}`;
72
+ const requested = templateName !== undefined
73
+ ? `template "${templateName}" requires Pro module(s) (${list})`
74
+ : `Pro module(s) requested (${list})`;
75
+ return `${requested} — set AILK_LICENSE_KEY first → ${CONVERSION_HINT_URL}`;
68
76
  }