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.
- package/component-catalog.json +11939 -0
- package/component-catalog.md +176 -64
- package/dist/cli.js +48 -10
- package/dist/derive-architecture.js +5 -2
- package/dist/lib/extract-module-bundle.d.ts +88 -1
- package/dist/lib/extract-module-bundle.js +375 -80
- package/dist/lib/fetch-guard.d.ts +28 -0
- package/dist/lib/fetch-guard.js +101 -0
- package/dist/lib/fetch-module.js +4 -0
- package/dist/lib/fetch-template.d.ts +20 -0
- package/dist/lib/fetch-template.js +133 -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 +116 -7
- package/dist/module-architecture.js +465 -94
- package/dist/parse-args.d.ts +8 -1
- package/dist/parse-args.js +49 -2
- package/dist/programmatic.d.ts +36 -1
- package/dist/programmatic.js +216 -5
- package/dist/site-chrome.d.ts +80 -0
- package/dist/site-chrome.js +135 -0
- package/dist/surfaces.d.ts +21 -6
- package/dist/surfaces.js +140 -23
- package/dist/template-manifest.d.ts +144 -0
- package/dist/template-manifest.js +337 -0
- package/dist/template-names.d.ts +41 -0
- package/dist/template-names.js +45 -0
- package/package.json +10 -2
- package/site-templates/_base/ailk-template.json +6 -0
- package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
- package/site-templates/_base/content/en/PrivacyPage/privacy.mdx +79 -0
- package/site-templates/_base/content/en/TermsPage/terms.mdx +80 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +331 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +393 -0
- package/site-templates/marketing-site/ailk-template.json +11 -0
- package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
- package/site-templates/marketing-site/provenance.json +4 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +285 -0
- package/site-templates/marketing-site-free/ailk-template.json +9 -0
- package/site-templates/marketing-site-free/provenance.json +4 -0
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +271 -0
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +324 -0
- package/site-templates/minimalism-site/ailk-template.json +21 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
- package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
- package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
- package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
- package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
- package/site-templates/minimalism-site/content/en/HomePage/home.mdx +214 -0
- package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
- package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
- package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
- package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
- package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
- package/site-templates/minimalism-site/provenance.json +4 -0
- package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
- package/site-templates/saas-product/ailk-template.json +16 -0
- package/site-templates/saas-product/provenance.json +10 -0
- package/templates/.claude/skills/README.md +16 -25
- package/templates/.env.example +32 -7
- package/templates/.github/workflows/ci.yml +48 -0
- package/templates/README.md +3 -1
- package/templates/apps/api/CLAUDE.md +4 -1
- package/templates/apps/api/eslint.config.mjs +69 -0
- package/templates/apps/api/package.json +6 -2
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +6 -2
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +173 -0
- package/templates/apps/api/src/config/modules.ts +1 -1
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +339 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +108 -0
- package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
- package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
- package/templates/apps/api/src/routes/leads/list.ts +104 -0
- package/templates/apps/api/src/server.ts +122 -3
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
- package/templates/apps/api/src/services/default-tenant.ts +261 -0
- package/templates/apps/api/src/services/lead-promotion.ts +161 -0
- package/templates/apps/api/src/services/leads-inbox.ts +263 -0
- package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
- package/templates/apps/mcp/CLAUDE.md +1 -1
- package/templates/apps/mcp/__tests__/parity.test.ts +2 -2
- package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
- package/templates/apps/mcp/eslint.config.mjs +69 -0
- package/templates/apps/mcp/package.json +6 -2
- package/templates/apps/mcp/src/server.ts +2 -2
- package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
- package/templates/apps/mcp/src/tools/index.ts +2 -2
- package/templates/apps/web/.env.example +3 -3
- package/templates/apps/web/CLAUDE.md +2 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +4 -6
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +1 -1
- package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
- package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
- package/templates/apps/web/app/[locale]/layout.tsx +214 -17
- 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/components/google-analytics.tsx +18 -1
- package/templates/apps/web/eslint.config.mjs +69 -0
- package/templates/apps/web/jest.config.cjs +12 -15
- package/templates/apps/web/lib/__tests__/server-api.test.ts +61 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +32 -5
- package/templates/apps/web/lib/nav-config.ts +27 -7
- package/templates/apps/web/lib/server-api.ts +29 -13
- package/templates/apps/web/lib/site-chrome.ts +29 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +46 -0
- package/templates/apps/web/messages/en.json +86 -1
- package/templates/apps/web/next.config.mjs +20 -0
- package/templates/apps/web/package.json +6 -2
- package/templates/apps/web/sentry.client.config.ts +131 -0
- package/templates/apps/web/sentry.edge.config.ts +72 -0
- package/templates/apps/web/sentry.server.config.ts +72 -0
- package/templates/apps/web/src/lib/routes.ts +13 -18
- package/templates/brand_assets/README.md +24 -0
- package/templates/content/en/AboutPage/about.mdx +160 -15
- package/templates/content/en/ContactPage/contact.mdx +90 -13
- package/templates/content/en/FAQPage/faq.mdx +62 -22
- package/templates/content/en/HomePage/home.mdx +244 -20
- package/templates/content/en/LandingPage/blog.mdx +83 -0
- package/templates/content/en/LandingPage/features.mdx +103 -0
- package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
- package/templates/database/CHANGELOG.md +21 -0
- package/templates/database/__tests__/seed.test.ts +1 -1
- package/templates/database/eslint.config.mjs +69 -0
- package/templates/database/package.json +7 -3
- package/templates/database/seed.ts +1 -1
- package/templates/package.json +1 -14
- package/versions.json +52 -0
- /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
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
272
|
+
function validatePatches(patches, targetAbs, subject) {
|
|
174
273
|
for (const patch of patches) {
|
|
175
274
|
if (hasUnsafeSegment(patch.target)) {
|
|
176
|
-
throw new Error(
|
|
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(
|
|
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,
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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,
|
|
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(
|
|
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;
|