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.
- package/dist/cli.js +19 -9
- package/dist/lib/extract-module-bundle.d.ts +39 -0
- package/dist/lib/extract-module-bundle.js +165 -78
- package/dist/lib/fetch-template.d.ts +20 -0
- package/dist/lib/fetch-template.js +129 -0
- package/dist/lib/module-license-gate.d.ts +8 -0
- package/dist/lib/module-license-gate.js +12 -4
- package/dist/module-architecture.d.ts +58 -0
- package/dist/module-architecture.js +156 -4
- package/dist/parse-args.d.ts +8 -1
- package/dist/parse-args.js +43 -0
- package/dist/programmatic.d.ts +26 -1
- package/dist/programmatic.js +100 -4
- package/dist/surfaces.js +42 -0
- package/dist/template-manifest.d.ts +92 -0
- package/dist/template-manifest.js +168 -0
- package/dist/template-names.d.ts +41 -0
- package/dist/template-names.js +44 -0
- package/package.json +2 -1
- package/site-templates/marketing-site-free/ailk-template.json +8 -0
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +103 -0
- package/site-templates/marketing-site-free/content/en/PrivacyPage/privacy.mdx +79 -0
- package/site-templates/marketing-site-free/content/en/TermsPage/terms.mdx +80 -0
- package/site-templates/marketing-site-free/provenance.json +4 -0
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +166 -0
- package/templates/apps/api/src/config/modules.ts +1 -1
- package/templates/apps/api/src/server.ts +46 -3
- package/templates/apps/web/app/[locale]/pricing/page.tsx +40 -0
- package/templates/apps/web/app/[locale]/privacy/page.tsx +40 -0
- package/templates/apps/web/app/[locale]/terms/page.tsx +40 -0
- package/templates/apps/web/jest.config.cjs +5 -0
- package/templates/apps/web/lib/__tests__/server-api.test.ts +61 -0
- package/templates/apps/web/lib/server-api.ts +28 -12
- package/templates/apps/web/messages/en.json +58 -0
- package/templates/database/CHANGELOG.md +14 -0
- package/templates/database/package.json +1 -1
- 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)
|
|
13
|
-
*
|
|
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)
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
233
|
+
function validatePatches(patches, targetAbs, subject) {
|
|
174
234
|
for (const patch of patches) {
|
|
175
235
|
if (hasUnsafeSegment(patch.target)) {
|
|
176
|
-
throw new Error(
|
|
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(
|
|
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,
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|