create-packkit 2.9.0 → 3.1.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.
@@ -0,0 +1,506 @@
1
+ // Embedded API — the supported entry point for a Node application that wants
2
+ // to use Packkit as a project-generation engine.
3
+ //
4
+ // Everything here is pure and side-effect free except the writer (separate
5
+ // module). No prompts, no installs, no git, no network, no command execution.
6
+ // A host calls createProject() to generate in memory, extendProject() to add
7
+ // its own deployment files, and writeGeneratedProject() when it wants disk.
8
+
9
+ import { readFileSync } from 'node:fs';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { createHash } from 'node:crypto';
12
+
13
+ import {
14
+ generateTracked,
15
+ normalizeConfig,
16
+ resolvePreset,
17
+ PRESETS,
18
+ OPTIONS,
19
+ } from '../core/index.js';
20
+ import { finalizePackageJson } from '../core/pkg.js';
21
+ import { deepMerge, toJson } from '../core/render.js';
22
+ import { validateRelativePath, validatePathMap } from './paths.js';
23
+ import { analyzePkgFragments } from './pkg-merge.js';
24
+ import { deriveDeploymentContract } from './contract.js';
25
+
26
+ export { deriveDeploymentContract };
27
+ export { planUpgrade, isUpgradeEmpty, buildUpgradeWrite } from './upgrade.js';
28
+
29
+ // Bumped when the shape of PackkitProjectDefinition changes incompatibly.
30
+ export const SCHEMA_VERSION = 2;
31
+
32
+ // Resource ceilings for definitions loaded from untrusted stores (a database,
33
+ // an upload, a queue). Generous enough for any real project, small enough to
34
+ // refuse a hostile blob before it reaches the filesystem.
35
+ const MAX_DEFINITION_FILES = 5000;
36
+ const MAX_DEFINITION_BYTES = 50_000_000;
37
+
38
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
39
+
40
+ /** Non-OPTIONS config keys the pipeline sets itself; not "unknown". */
41
+ const KNOWN_EXTRA_KEYS = new Set(['preset', 'generatorVersion', 'resolved']);
42
+
43
+ // Fields where a host override silently changing an existing value is worth a
44
+ // diagnostic (matches pkg-merge's protected set + dependency maps).
45
+ const PROTECTED_PKG_FIELDS = new Set(['scripts', 'exports', 'bin', 'main', 'module', 'types', 'files', 'engines', 'packageManager']);
46
+ const DEP_MAPS = new Set(['dependencies', 'devDependencies', 'peerDependencies']);
47
+
48
+ // Replay state lives off to the side, keyed by the project object, so the
49
+ // public GeneratedProject stays clean (no leaking `_extensions`), immutable to
50
+ // consumers, and never accidentally serialized into logs or API responses.
51
+ const extensionState = new WeakMap();
52
+
53
+ /** Thrown when a config or definition cannot produce a valid project. */
54
+ export class PackkitValidationError extends Error {
55
+ constructor(message, diagnostics) {
56
+ super(message);
57
+ this.name = 'PackkitValidationError';
58
+ this.code = 'PACKKIT_VALIDATION_FAILED';
59
+ this.diagnostics = diagnostics;
60
+ }
61
+ }
62
+
63
+ let cachedVersion;
64
+ function packkitVersion() {
65
+ if (cachedVersion) return cachedVersion;
66
+ try {
67
+ const url = new URL('../../package.json', import.meta.url);
68
+ cachedVersion = JSON.parse(readFileSync(fileURLToPath(url), 'utf8')).version;
69
+ } catch {
70
+ cachedVersion = '0.0.0';
71
+ }
72
+ return cachedVersion;
73
+ }
74
+
75
+ /**
76
+ * Resolve a preset + overrides into a complete, validated config, collecting
77
+ * the diagnostics normalization produced — without generating anything.
78
+ *
79
+ * Generation and resolution are split so a trusted caller (the CLI) can make
80
+ * decisions on the resolved config — a Node-floor check, creating the remote —
81
+ * and then generate, while a host app just uses createProject() which does both.
82
+ * Throws PackkitValidationError if the config is fatally invalid.
83
+ *
84
+ * @returns {{ config: object, diagnostics: object[] }}
85
+ */
86
+ export function resolveProjectConfig(input = {}) {
87
+ if (!input || typeof input !== 'object') throw new TypeError('resolveProjectConfig expects an input object.');
88
+
89
+ const merged = { ...(input.config || {}), ...(input.overrides || {}) };
90
+ if (input.name != null) merged.name = input.name;
91
+
92
+ const preErrors = validateInput(merged);
93
+ if (preErrors.length) {
94
+ throw new PackkitValidationError('The configuration is not valid; see error.diagnostics.', preErrors);
95
+ }
96
+
97
+ const diagnostics = [...unknownOptionDiagnostics(merged)];
98
+
99
+ let canonicalPreset;
100
+ if (input.preset) {
101
+ canonicalPreset = resolvePreset(input.preset);
102
+ if (!canonicalPreset) {
103
+ throw new PackkitValidationError(`Unknown preset "${input.preset}".`, [
104
+ { severity: 'error', code: 'UNKNOWN_PRESET', field: 'preset', message: `Unknown preset "${input.preset}".`, source: 'validate' },
105
+ ]);
106
+ }
107
+ }
108
+ // Normalize ONCE, with the collector, over the raw preset+overrides — a second
109
+ // pass would see values already settled and report nothing. The preset is
110
+ // folded into `raw` (not set afterwards) so it reaches provenance during this
111
+ // pass; otherwise a replayed definition, whose config carries the preset, would
112
+ // bake it into packkit.json while the original did not — and the digests diverge.
113
+ const raw = canonicalPreset
114
+ ? { ...PRESETS[canonicalPreset], ...merged, preset: canonicalPreset }
115
+ : merged;
116
+ const config = normalizeConfig({ ...raw, generatorVersion: packkitVersion() }, diagnostics);
117
+ return { config, diagnostics };
118
+ }
119
+
120
+ /**
121
+ * Generate a complete project in memory. No files are written, nothing is
122
+ * installed, no commands run. Returns a GeneratedProject with diagnostics.
123
+ * Throws PackkitValidationError if the config is fatally invalid.
124
+ */
125
+ export function createProject(input = {}) {
126
+ const { config, diagnostics } = resolveProjectConfig(input);
127
+ return assembleProject(config, diagnostics);
128
+ }
129
+
130
+ /**
131
+ * Build a project from an already-resolved config (from resolveProjectConfig,
132
+ * possibly with a field like `repo` set since). For trusted callers only — the
133
+ * config is assumed valid; pass its resolution diagnostics through so they
134
+ * appear on the project. Re-normalizes idempotently.
135
+ */
136
+ export function createProjectFromResolvedConfig(config, { diagnostics = [] } = {}) {
137
+ const resolved = normalizeConfig({ generatorVersion: packkitVersion(), ...config });
138
+ return assembleProject(resolved, [...diagnostics]);
139
+ }
140
+
141
+ // Shared generation core: turn a resolved config into a GeneratedProject,
142
+ // appending collision and package-conflict diagnostics to whatever the caller
143
+ // already collected during resolution.
144
+ function assembleProject(config, diagnostics) {
145
+ const { files, summary, fileSources, fragments } = generateTracked(config);
146
+
147
+ for (const [path, sources] of Object.entries(fileSources)) {
148
+ if (sources.length > 1) {
149
+ diagnostics.push({
150
+ severity: 'warning',
151
+ code: 'FEATURE_FILE_COLLISION',
152
+ field: path,
153
+ message: `"${path}" was written by more than one feature (${sources.join(', ')}); the last one wins.`,
154
+ source: 'assemble',
155
+ });
156
+ }
157
+ }
158
+ diagnostics.push(...analyzePkgFragments(fragments).diagnostics);
159
+
160
+ return finish({
161
+ config,
162
+ files,
163
+ summary,
164
+ diagnostics,
165
+ metadata: { packkitVersion: packkitVersion(), schemaVersion: SCHEMA_VERSION, preset: config.preset },
166
+ deploymentContract: deriveDeploymentContract(config),
167
+ }, { files: {}, packageJson: {} });
168
+ }
169
+
170
+ /**
171
+ * Return a NEW project with the extension's files and package.json fields
172
+ * layered on. Never mutates `project`. Extension file paths and contents are
173
+ * validated; collisions with existing files follow collisionPolicy ('error'
174
+ * by default), and host overrides of existing package fields are reported.
175
+ */
176
+ export function extendProject(project, extension = {}, internal = {}) {
177
+ assertProject(project);
178
+ // Internal: a path→mode map lets definition replay preserve the ORIGINAL
179
+ // add/replace intent instead of re-deriving it against the current base.
180
+ // Without it, an `add` that now collides would be re-recorded as `replace`,
181
+ // and the drift warning would vanish after one load-and-save cycle.
182
+ const storedModes = internal.fileModes || null;
183
+ const policy = extension.collisionPolicy || 'error';
184
+ if (!['error', 'skip', 'overwrite'].includes(policy)) {
185
+ throw new TypeError(`Unknown collisionPolicy "${policy}".`);
186
+ }
187
+
188
+ const files = { ...project.files };
189
+ const diagnostics = [...project.diagnostics];
190
+ const extFiles = extension.files || {};
191
+
192
+ // Validate paths AND content up front: an invalid path or a non-string value
193
+ // anywhere means the whole extension is rejected, not half-applied.
194
+ const { diagnostics: pathDiag } = validatePathMap(extFiles);
195
+ const fatal = [...pathDiag.filter((d) => d.severity === 'error')];
196
+ for (const [path, contents] of Object.entries(extFiles)) {
197
+ if (typeof contents !== 'string') {
198
+ fatal.push({ severity: 'error', code: 'INVALID_FILE_CONTENT', field: path, message: `Contents of "${path}" must be a string.`, source: 'extend' });
199
+ }
200
+ }
201
+ if (fatal.length) throw new PackkitValidationError('Extension files are not valid; see error.diagnostics.', fatal);
202
+
203
+ const prevState = extensionState.get(project) || { files: {}, packageJson: {} };
204
+ const stateFiles = { ...prevState.files };
205
+
206
+ for (const [path, contents] of Object.entries(extFiles)) {
207
+ const target = validateRelativePath(path).normalized;
208
+ const collides = target in files;
209
+ if (collides) {
210
+ if (policy === 'error') {
211
+ throw new PackkitValidationError(`Extension file "${path}" collides with a generated file.`, [
212
+ { severity: 'error', code: 'EXTENSION_FILE_COLLISION', field: path, message: `"${path}" already exists in the generated project.`, source: 'extend' },
213
+ ]);
214
+ }
215
+ diagnostics.push({
216
+ severity: 'info',
217
+ code: policy === 'skip' ? 'EXTENSION_FILE_SKIPPED' : 'EXTENSION_FILE_OVERWRITTEN',
218
+ field: path,
219
+ message: policy === 'skip' ? `"${path}" was kept from the generated project.` : `"${path}" was replaced by the extension.`,
220
+ source: 'extend',
221
+ });
222
+ if (policy === 'skip') continue;
223
+ }
224
+ files[target] = contents;
225
+ // "add" = the host introduced a path Packkit didn't generate; "replace" =
226
+ // deliberately overriding generated output. Recorded so a stored definition
227
+ // can tell, on replay under a newer Packkit, whether an add now collides
228
+ // with a file that version started generating. A stored mode (from replay)
229
+ // wins over the freshly-computed one, so the original intent survives a
230
+ // load-and-save round-trip.
231
+ const mode = storedModes && target in storedModes ? storedModes[target] : collides ? 'replace' : 'add';
232
+ stateFiles[target] = { content: contents, mode };
233
+ }
234
+
235
+ let packageJson = prevState.packageJson;
236
+ if (extension.packageJson && Object.keys(extension.packageJson).length) {
237
+ const current = JSON.parse(files['package.json']);
238
+ diagnostics.push(...extensionPackageDiagnostics(current, extension.packageJson));
239
+ const mergedPkg = finalizePackageJson(deepMerge(current, extension.packageJson));
240
+ files['package.json'] = toJson(mergedPkg);
241
+ // Keep the raw override for definition export, minus prototype-poisoning keys.
242
+ packageJson = deepMerge(packageJson, extension.packageJson);
243
+ }
244
+
245
+ return finish({
246
+ config: project.config,
247
+ files,
248
+ summary: { ...project.summary, fileCount: Object.keys(files).length },
249
+ diagnostics,
250
+ metadata: { ...project.metadata, ...(extension.metadata ? { extension: extension.metadata } : {}) },
251
+ deploymentContract: project.deploymentContract,
252
+ }, { files: stateFiles, packageJson });
253
+ }
254
+
255
+ /**
256
+ * A serializable definition that reproduces this project later. Contains no
257
+ * secrets and no absolute paths — the config, preset, and the extension
258
+ * material the host layered on (each file tagged add/replace).
259
+ */
260
+ export function exportProjectDefinition(project) {
261
+ assertProject(project);
262
+ const state = extensionState.get(project) || { files: {}, packageJson: {} };
263
+ return {
264
+ schemaVersion: SCHEMA_VERSION,
265
+ packkitVersion: project.metadata.packkitVersion,
266
+ preset: project.metadata.preset,
267
+ config: serializableConfig(project.config),
268
+ extensions: {
269
+ files: Object.fromEntries(Object.entries(state.files).map(([p, e]) => [p, { content: e.content, mode: e.mode }])),
270
+ packageJson: { ...state.packageJson },
271
+ },
272
+ };
273
+ }
274
+
275
+ /**
276
+ * Rebuild a project from a stored definition, re-applying its extensions.
277
+ *
278
+ * If a file the host originally *added* now collides with one the current
279
+ * Packkit generates, that's surfaced as an error-severity diagnostic. With
280
+ * `driftPolicy: 'error'` that condition throws instead — for a caller that
281
+ * wants an unreconciled definition to fail loudly rather than reproduce
282
+ * silently. The default, `'report'`, returns the project with the diagnostic.
283
+ */
284
+ export function createProjectFromDefinition(definition, { driftPolicy = 'report' } = {}) {
285
+ if (!['report', 'error'].includes(driftPolicy)) {
286
+ throw new TypeError(`Unknown driftPolicy "${driftPolicy}".`);
287
+ }
288
+ validateDefinition(definition);
289
+ const current = packkitVersion();
290
+ const base = createProject({ preset: definition.preset, config: definition.config });
291
+
292
+ const drift = [];
293
+ if (definition.packkitVersion && definition.packkitVersion !== current) {
294
+ drift.push({
295
+ severity: 'warning',
296
+ code: 'PACKKIT_VERSION_DRIFT',
297
+ field: 'packkitVersion',
298
+ message: `Definition was created with Packkit ${definition.packkitVersion}; this is ${current}. Output may differ.`,
299
+ source: 'definition',
300
+ previousValue: definition.packkitVersion,
301
+ resolvedValue: current,
302
+ });
303
+ }
304
+
305
+ const ext = definition.extensions || {};
306
+ const extFiles = ext.files || {};
307
+ const plainFiles = {};
308
+ const storedModes = {};
309
+ for (const [path, entry] of Object.entries(extFiles)) {
310
+ const { content, mode } = normalizeDefinitionFile(entry);
311
+ plainFiles[path] = content;
312
+ storedModes[validateRelativePath(path).normalized] = mode;
313
+ // A file the host originally *added* now collides with something this
314
+ // Packkit version generates: surface it loudly instead of silently taking
315
+ // the stored copy. The definition still reproduces (the host file wins, as
316
+ // it did originally), but the drift is now visible for reconciliation.
317
+ if (mode === 'add' && base.files[path] !== undefined) {
318
+ drift.push({
319
+ severity: 'error',
320
+ code: 'EXTENSION_ADD_COLLIDES_WITH_NEW_BASE',
321
+ field: path,
322
+ message: `"${path}" was originally added by the host, but Packkit ${current} now generates it too. The stored copy was used; review the difference.`,
323
+ source: 'definition',
324
+ });
325
+ }
326
+ }
327
+
328
+ // With driftPolicy: 'error', an unreconciled add-collision fails the rebuild
329
+ // rather than reproducing silently. Version drift alone (a warning) never
330
+ // throws — only the error-severity collision does.
331
+ const fatalDrift = drift.filter((d) => d.severity === 'error');
332
+ if (driftPolicy === 'error' && fatalDrift.length) {
333
+ throw new PackkitValidationError('The definition no longer reconciles with this Packkit version; see error.diagnostics.', fatalDrift);
334
+ }
335
+
336
+ const hasExt = Object.keys(plainFiles).length || Object.keys(ext.packageJson || {}).length;
337
+ // Carry the stored modes so re-exporting the replayed project preserves the
338
+ // original add/replace intent rather than re-deriving it against this base.
339
+ const result = hasExt
340
+ ? extendProject(base, { files: plainFiles, packageJson: ext.packageJson || {}, collisionPolicy: 'overwrite' }, { fileModes: storedModes })
341
+ : base;
342
+ result.diagnostics.push(...drift);
343
+ return result;
344
+ }
345
+
346
+ /**
347
+ * A stable digest of the project's config and file contents. Two projects with
348
+ * the same Packkit version, config, and extensions produce the same digest;
349
+ * nondeterministic metadata (timestamps) is deliberately excluded.
350
+ */
351
+ export function calculateProjectDigest(project) {
352
+ assertProject(project);
353
+ const h = createHash('sha256');
354
+ h.update('config\0');
355
+ h.update(JSON.stringify(serializableConfig(project.config)));
356
+ for (const path of Object.keys(project.files).sort()) {
357
+ h.update(`\0file\0${path}\0`);
358
+ h.update(project.files[path]);
359
+ }
360
+ return h.digest('hex');
361
+ }
362
+
363
+ // ---- internals -------------------------------------------------------------
364
+
365
+ function finish(project, state) {
366
+ extensionState.set(project, state);
367
+ return project;
368
+ }
369
+
370
+ function assertProject(project) {
371
+ if (!project || typeof project !== 'object' || !project.files || !project.config) {
372
+ throw new TypeError('Expected a GeneratedProject from createProject().');
373
+ }
374
+ }
375
+
376
+ function serializableConfig(config) {
377
+ const out = {};
378
+ for (const key of Object.keys(OPTIONS)) {
379
+ if (config[key] !== undefined) out[key] = config[key];
380
+ }
381
+ if (config.preset) out.preset = config.preset;
382
+ return sortObject(out);
383
+ }
384
+
385
+ function sortObject(obj) {
386
+ return Object.fromEntries(Object.keys(obj).sort().map((k) => [k, obj[k]]));
387
+ }
388
+
389
+ function validateInput(config) {
390
+ const errors = [];
391
+ if (config.name != null && (typeof config.name !== 'string' || config.name.trim() === '')) {
392
+ errors.push({ severity: 'error', code: 'INVALID_NAME', field: 'name', message: 'name must be a non-empty string.', source: 'validate' });
393
+ }
394
+ for (const [key, value] of Object.entries(config)) {
395
+ const opt = OPTIONS[key];
396
+ if (!opt) continue;
397
+ if (opt.type === 'boolean' && typeof value !== 'boolean') {
398
+ errors.push(valueError(key, value, 'must be true or false'));
399
+ } else if (opt.choices) {
400
+ const allowed = opt.choices.map((c) => c.value);
401
+ const values = opt.type === 'multiselect' ? (Array.isArray(value) ? value : [value]) : [value];
402
+ for (const v of values) {
403
+ if (!allowed.includes(v)) errors.push(valueError(key, v, `must be one of: ${allowed.join(', ')}`));
404
+ }
405
+ }
406
+ }
407
+ return errors;
408
+ }
409
+
410
+ function valueError(field, value, detail) {
411
+ return { severity: 'error', code: 'VALUE_NOT_ALLOWED', field, previousValue: value, message: `${field} ${detail}.`, source: 'validate' };
412
+ }
413
+
414
+ function unknownOptionDiagnostics(config) {
415
+ const out = [];
416
+ for (const key of Object.keys(config)) {
417
+ if (OPTIONS[key] || KNOWN_EXTRA_KEYS.has(key)) continue;
418
+ out.push({ severity: 'warning', code: 'UNKNOWN_OPTION', field: key, message: `"${key}" is not a Packkit option and was ignored.`, source: 'validate' });
419
+ }
420
+ return out;
421
+ }
422
+
423
+ // Report host package overrides that change an existing generated value, so the
424
+ // host still wins but the change is visible (it can invalidate the deployment
425
+ // contract — e.g. redefining scripts.build).
426
+ function extensionPackageDiagnostics(current, override) {
427
+ const out = [];
428
+ for (const [topKey, value] of Object.entries(override)) {
429
+ if (DEP_MAPS.has(topKey) && current[topKey]) {
430
+ for (const [dep, version] of Object.entries(value || {})) {
431
+ const prev = current[topKey][dep];
432
+ if (prev !== undefined && prev !== version) {
433
+ out.push({ severity: 'warning', code: 'EXTENSION_DEPENDENCY_VERSION_OVERRIDE', field: `${topKey}.${dep}`, message: `The extension changed ${topKey}.${dep} from ${prev} to ${version}.`, source: 'extend', previousValue: prev, resolvedValue: version });
434
+ }
435
+ }
436
+ } else if (PROTECTED_PKG_FIELDS.has(topKey) && current[topKey] !== undefined) {
437
+ if (value && typeof value === 'object' && !Array.isArray(value) && typeof current[topKey] === 'object') {
438
+ for (const [k, v] of Object.entries(value)) {
439
+ const prev = current[topKey][k];
440
+ if (prev !== undefined && JSON.stringify(prev) !== JSON.stringify(v)) {
441
+ out.push({ severity: 'warning', code: 'EXTENSION_PACKAGE_FIELD_OVERRIDE', field: `${topKey}.${k}`, message: `The extension changed ${topKey}.${k}.`, source: 'extend', previousValue: prev, resolvedValue: v });
442
+ }
443
+ }
444
+ } else if (JSON.stringify(current[topKey]) !== JSON.stringify(value)) {
445
+ out.push({ severity: 'warning', code: 'EXTENSION_PACKAGE_FIELD_OVERRIDE', field: topKey, message: `The extension changed ${topKey}.`, source: 'extend', previousValue: current[topKey], resolvedValue: value });
446
+ }
447
+ }
448
+ }
449
+ return out;
450
+ }
451
+
452
+ function normalizeDefinitionFile(entry) {
453
+ // v2 stores { content, mode }; tolerate a bare string (mode unknown → treat as
454
+ // a deliberate replace so it never falsely reports an add-collision).
455
+ if (typeof entry === 'string') return { content: entry, mode: 'replace' };
456
+ return { content: entry.content, mode: entry.mode === 'add' ? 'add' : 'replace' };
457
+ }
458
+
459
+ // Definitions can arrive from untrusted stores, so validate structure, guard
460
+ // against prototype-pollution keys, cap resource use, and re-check every path
461
+ // before any of it can reach generation or disk.
462
+ function validateDefinition(definition) {
463
+ const errs = [];
464
+ const fail = (code, message, field) => errs.push({ severity: 'error', code, message, field, source: 'definition' });
465
+
466
+ if (!isPlainObject(definition)) throw new PackkitValidationError('A definition object is required.', [{ severity: 'error', code: 'INVALID_DEFINITION', message: 'Definition must be a plain object.', source: 'definition' }]);
467
+ if (definition.schemaVersion !== SCHEMA_VERSION) fail('SCHEMA_VERSION_MISMATCH', `Definition schemaVersion ${definition.schemaVersion} is not supported (expected ${SCHEMA_VERSION}).`, 'schemaVersion');
468
+ if (definition.config !== undefined && !isPlainObject(definition.config)) fail('INVALID_DEFINITION', 'config must be a plain object.', 'config');
469
+ if (definition.config) assertNoUnsafeKeys(definition.config, 'config', fail);
470
+
471
+ const ext = definition.extensions;
472
+ if (ext !== undefined) {
473
+ if (!isPlainObject(ext)) fail('INVALID_DEFINITION', 'extensions must be a plain object.', 'extensions');
474
+ else {
475
+ const files = ext.files || {};
476
+ if (!isPlainObject(files)) fail('INVALID_DEFINITION', 'extensions.files must be an object.', 'extensions.files');
477
+ else {
478
+ const paths = Object.keys(files);
479
+ if (paths.length > MAX_DEFINITION_FILES) fail('DEFINITION_TOO_LARGE', `Definition has ${paths.length} files (max ${MAX_DEFINITION_FILES}).`, 'extensions.files');
480
+ let total = 0;
481
+ for (const [p, entry] of Object.entries(files)) {
482
+ const content = typeof entry === 'string' ? entry : entry && entry.content;
483
+ if (typeof content !== 'string') fail('INVALID_FILE_CONTENT', `extensions.files["${p}"] must have string content.`, p);
484
+ else total += Buffer.byteLength(content, 'utf8'); // count UTF-8 bytes, matching the "bytes" limit
485
+ }
486
+ if (total > MAX_DEFINITION_BYTES) fail('DEFINITION_TOO_LARGE', `Definition content is ${total} bytes (max ${MAX_DEFINITION_BYTES}).`, 'extensions.files');
487
+ for (const d of validatePathMap(Object.fromEntries(paths.map((p) => [p, '']))).diagnostics) errs.push({ ...d, source: 'definition' });
488
+ }
489
+ if (ext.packageJson !== undefined && !isPlainObject(ext.packageJson)) fail('INVALID_DEFINITION', 'extensions.packageJson must be an object.', 'extensions.packageJson');
490
+ if (isPlainObject(ext.packageJson)) assertNoUnsafeKeys(ext.packageJson, 'extensions.packageJson', fail);
491
+ }
492
+ }
493
+
494
+ if (errs.length) throw new PackkitValidationError('The project definition is not valid; see error.diagnostics.', errs);
495
+ }
496
+
497
+ function assertNoUnsafeKeys(obj, path, fail) {
498
+ for (const key of Object.keys(obj)) {
499
+ if (UNSAFE_KEYS.has(key)) fail('UNSAFE_KEY', `"${path}.${key}" is not an allowed key.`, `${path}.${key}`);
500
+ else if (isPlainObject(obj[key])) assertNoUnsafeKeys(obj[key], `${path}.${key}`, fail);
501
+ }
502
+ }
503
+
504
+ function isPlainObject(v) {
505
+ return v != null && typeof v === 'object' && !Array.isArray(v);
506
+ }
@@ -0,0 +1,88 @@
1
+ // Path validation for generated and extension-supplied files.
2
+ //
3
+ // Every path that could reach the filesystem passes through here — once when a
4
+ // project is assembled, and again at the writer boundary. A path that escapes
5
+ // the destination, is absolute, or is otherwise unsafe is rejected rather than
6
+ // written, so a host application embedding Packkit can accept file maps from
7
+ // less-trusted sources (extensions, stored definitions) without opening a
8
+ // path-traversal hole.
9
+
10
+ import { posix } from 'node:path';
11
+
12
+ // Windows reserves these device names regardless of extension (CON.txt is still
13
+ // CON). Rejecting them keeps generated projects writable on Windows.
14
+ const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i;
15
+
16
+ /**
17
+ * Validate a repo-relative file path. Returns { ok: true, normalized } or
18
+ * { ok: false, code, message }. Pure — never touches the filesystem.
19
+ */
20
+ export function validateRelativePath(path) {
21
+ if (typeof path !== 'string' || path.length === 0) {
22
+ return fail('EMPTY_PATH', 'A file path must be a non-empty string.');
23
+ }
24
+ if (path.includes('\0')) {
25
+ return fail('NULL_BYTE', `Path contains a null byte: ${JSON.stringify(path)}`);
26
+ }
27
+ // Treat backslashes as separators so a Windows-style path can't smuggle a
28
+ // segment past the checks below on a POSIX host.
29
+ const unified = path.replace(/\\/g, '/');
30
+ if (/^[a-zA-Z]:/.test(unified) || unified.startsWith('/')) {
31
+ return fail('ABSOLUTE_PATH', `Path must be relative, not absolute: ${path}`);
32
+ }
33
+
34
+ const normalized = posix.normalize(unified);
35
+ if (normalized === '.' || normalized === '' || normalized.endsWith('/')) {
36
+ return fail('NOT_A_FILE', `Path does not name a file: ${path}`);
37
+ }
38
+ // normalize() collapses interior `..`; anything that still starts with `..`
39
+ // (or is exactly `..`) escapes the destination.
40
+ if (normalized === '..' || normalized.startsWith('../')) {
41
+ return fail('PATH_ESCAPE', `Path escapes the destination directory: ${path}`);
42
+ }
43
+ for (const segment of normalized.split('/')) {
44
+ if (WINDOWS_RESERVED.test(segment)) {
45
+ return fail('WINDOWS_RESERVED', `Path uses a Windows-reserved name: ${path}`);
46
+ }
47
+ }
48
+ return { ok: true, normalized };
49
+ }
50
+
51
+ /**
52
+ * Validate a whole file map. Returns { paths, diagnostics } where `paths` maps
53
+ * each original key to its normalized form, and diagnostics carries one error
54
+ * per invalid path plus one per case-insensitive collision (two distinct paths
55
+ * that would be the same file on a case-insensitive filesystem).
56
+ */
57
+ export function validatePathMap(files) {
58
+ const paths = {};
59
+ const diagnostics = [];
60
+ const lowered = new Map(); // lowercased normalized path -> first original key
61
+
62
+ for (const original of Object.keys(files)) {
63
+ const res = validateRelativePath(original);
64
+ if (!res.ok) {
65
+ diagnostics.push({ severity: 'error', code: res.code, message: res.message, source: 'path', field: original });
66
+ continue;
67
+ }
68
+ paths[original] = res.normalized;
69
+
70
+ const key = res.normalized.toLowerCase();
71
+ if (lowered.has(key) && lowered.get(key) !== res.normalized) {
72
+ diagnostics.push({
73
+ severity: 'error',
74
+ code: 'CASE_INSENSITIVE_COLLISION',
75
+ message: `"${original}" and "${lowered.get(key)}" are the same file on a case-insensitive filesystem.`,
76
+ source: 'path',
77
+ field: original,
78
+ });
79
+ } else {
80
+ lowered.set(key, res.normalized);
81
+ }
82
+ }
83
+ return { paths, diagnostics };
84
+ }
85
+
86
+ function fail(code, message) {
87
+ return { ok: false, code, message };
88
+ }
@@ -0,0 +1,89 @@
1
+ // Provenance-tracked package.json analysis.
2
+ //
3
+ // The core already merges feature fragments (deepMerge, last writer wins) to
4
+ // produce the actual package.json. This module does not replace that — it runs
5
+ // alongside it to answer "who set this field, and did two contributors
6
+ // disagree?", so the embedded API can report conflicts instead of letting a
7
+ // silent overwrite hide a bug.
8
+
9
+ // Fields where two contributors setting the *same leaf* to *different values*
10
+ // is a real conflict a host should hear about. Dependency maps are handled
11
+ // separately (version-aware, per section); everything else deep-merges.
12
+ const PROTECTED = new Set(['scripts', 'exports', 'bin', 'main', 'module', 'types', 'files', 'engines', 'packageManager']);
13
+ const DEP_MAPS = new Set(['dependencies', 'devDependencies', 'peerDependencies']);
14
+
15
+ /**
16
+ * Analyze an ordered list of { source, pkg } fragments.
17
+ * Returns { diagnostics } describing every protected-field leaf that two
18
+ * sources set to conflicting values, and every dependency pinned to two
19
+ * different versions *within the same section*. Does not produce package.json.
20
+ */
21
+ export function analyzePkgFragments(fragments) {
22
+ const diagnostics = [];
23
+ const leaves = new Map(); // rendered leaf path -> { value, source }
24
+ // Keyed by section + name, so `dependencies.react` and `peerDependencies.react`
25
+ // are distinct — differing versions across those sections is normal, not a bug.
26
+ const deps = new Map();
27
+
28
+ for (const { source, pkg } of fragments) {
29
+ for (const [topKey, value] of Object.entries(pkg)) {
30
+ if (DEP_MAPS.has(topKey)) {
31
+ for (const [dep, version] of Object.entries(value || {})) {
32
+ const key = `${topKey}:${dep}`;
33
+ const prev = deps.get(key);
34
+ if (prev && prev.version !== version) {
35
+ diagnostics.push({
36
+ severity: 'warning',
37
+ code: 'DEPENDENCY_VERSION_CONFLICT',
38
+ field: `${topKey}.${dep}`,
39
+ message: `"${dep}" in ${topKey} is requested at ${prev.version} (by ${prev.source}) and ${version} (by ${source}).`,
40
+ source: 'package-merge',
41
+ previousValue: prev.version,
42
+ resolvedValue: version,
43
+ });
44
+ }
45
+ deps.set(key, { version, source });
46
+ }
47
+ continue;
48
+ }
49
+ if (PROTECTED.has(topKey)) {
50
+ collectLeaves([topKey], value, (segments, leafValue) => {
51
+ const rendered = renderPath(segments);
52
+ const prev = leaves.get(rendered);
53
+ if (prev && JSON.stringify(prev.value) !== JSON.stringify(leafValue)) {
54
+ diagnostics.push({
55
+ severity: 'warning',
56
+ code: 'PACKAGE_FIELD_CONFLICT',
57
+ field: rendered,
58
+ message: `"${rendered}" is set to different values by ${prev.source} and ${source}; the later one wins.`,
59
+ source: 'package-merge',
60
+ previousValue: prev.value,
61
+ resolvedValue: leafValue,
62
+ });
63
+ }
64
+ leaves.set(rendered, { value: leafValue, source });
65
+ });
66
+ }
67
+ }
68
+ }
69
+ return { diagnostics };
70
+ }
71
+
72
+ // Recursively flatten a protected field to its leaf values, carrying the key
73
+ // path as an array so a key that itself contains a dot (e.g. exports './sub.js')
74
+ // isn't mistaken for two segments.
75
+ function collectLeaves(segments, value, emit) {
76
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
77
+ for (const [k, v] of Object.entries(value)) collectLeaves([...segments, k], v, emit);
78
+ } else {
79
+ emit(segments, value);
80
+ }
81
+ }
82
+
83
+ // Render a segment path for human-facing diagnostics. Dotted segments get
84
+ // bracket notation so the field reads unambiguously.
85
+ function renderPath(segments) {
86
+ return segments
87
+ .map((s, i) => (i > 0 && s.includes('.') ? `['${s}']` : i > 0 ? `.${s}` : s))
88
+ .join('');
89
+ }