create-packkit 3.0.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.
@@ -13,15 +13,17 @@
13
13
  export function deriveDeploymentContract(cfg) {
14
14
  const run = (script) => (cfg.packageManager === 'npm' ? `npm run ${script}` : `${cfg.packageManager} ${script}`);
15
15
  const start = cfg.packageManager === 'npm' ? 'npm start' : `${cfg.packageManager} start`;
16
+ // Read the structured resolved view rather than re-interpreting raw selections.
17
+ const { targets, build } = cfg.resolved;
16
18
 
17
- if (cfg.hasService) {
19
+ if (targets.service) {
18
20
  // The generated server reads PORT but defaults to 3000, so PORT is optional,
19
21
  // not required — `port` communicates the default and the `PORT` env var
20
22
  // overrides it. `healthCheckPath` is only asserted because every service
21
23
  // framework Packkit generates (Hono/Fastify/Express) defines /health.
22
24
  return prune({
23
25
  type: 'node-service',
24
- buildCommand: cfg.hasBuild ? run('build') : undefined,
26
+ buildCommand: build.has ? run('build') : undefined,
25
27
  startCommand: start,
26
28
  port: 3000,
27
29
  healthCheckPath: '/health',
@@ -29,7 +31,7 @@ export function deriveDeploymentContract(cfg) {
29
31
  });
30
32
  }
31
33
 
32
- if (cfg.hasApp) {
34
+ if (targets.app) {
33
35
  return prune({
34
36
  type: 'static',
35
37
  buildCommand: run('build'),
@@ -38,16 +40,16 @@ export function deriveDeploymentContract(cfg) {
38
40
  });
39
41
  }
40
42
 
41
- if (cfg.hasCli) {
43
+ if (targets.cli) {
42
44
  return prune({
43
45
  type: 'cli',
44
- buildCommand: cfg.hasBuild ? run('build') : undefined,
46
+ buildCommand: build.has ? run('build') : undefined,
45
47
  });
46
48
  }
47
49
 
48
50
  return prune({
49
51
  type: 'library',
50
- buildCommand: cfg.hasBuild ? run('build') : undefined,
52
+ buildCommand: build.has ? run('build') : undefined,
51
53
  });
52
54
  }
53
55
 
@@ -24,6 +24,7 @@ import { analyzePkgFragments } from './pkg-merge.js';
24
24
  import { deriveDeploymentContract } from './contract.js';
25
25
 
26
26
  export { deriveDeploymentContract };
27
+ export { planUpgrade, isUpgradeEmpty, buildUpgradeWrite } from './upgrade.js';
27
28
 
28
29
  // Bumped when the shape of PackkitProjectDefinition changes incompatibly.
29
30
  export const SCHEMA_VERSION = 2;
@@ -37,7 +38,7 @@ const MAX_DEFINITION_BYTES = 50_000_000;
37
38
  const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
38
39
 
39
40
  /** Non-OPTIONS config keys the pipeline sets itself; not "unknown". */
40
- const KNOWN_EXTRA_KEYS = new Set(['preset', 'generatorVersion']);
41
+ const KNOWN_EXTRA_KEYS = new Set(['preset', 'generatorVersion', 'resolved']);
41
42
 
42
43
  // Fields where a host override silently changing an existing value is worth a
43
44
  // diagnostic (matches pkg-merge's protected set + dependency maps).
@@ -72,12 +73,18 @@ function packkitVersion() {
72
73
  }
73
74
 
74
75
  /**
75
- * Generate a complete project in memory. No files are written, nothing is
76
- * installed, no commands run. Returns a GeneratedProject with diagnostics.
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.
77
82
  * Throws PackkitValidationError if the config is fatally invalid.
83
+ *
84
+ * @returns {{ config: object, diagnostics: object[] }}
78
85
  */
79
- export function createProject(input = {}) {
80
- if (!input || typeof input !== 'object') throw new TypeError('createProject expects an input object.');
86
+ export function resolveProjectConfig(input = {}) {
87
+ if (!input || typeof input !== 'object') throw new TypeError('resolveProjectConfig expects an input object.');
81
88
 
82
89
  const merged = { ...(input.config || {}), ...(input.overrides || {}) };
83
90
  if (input.name != null) merged.name = input.name;
@@ -106,7 +113,36 @@ export function createProject(input = {}) {
106
113
  const raw = canonicalPreset
107
114
  ? { ...PRESETS[canonicalPreset], ...merged, preset: canonicalPreset }
108
115
  : merged;
109
- const { config, files, summary, fileSources, fragments } = generateTracked({ ...raw, generatorVersion: packkitVersion() }, diagnostics);
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);
110
146
 
111
147
  for (const [path, sources] of Object.entries(fileSources)) {
112
148
  if (sources.length > 1) {
@@ -236,8 +272,19 @@ export function exportProjectDefinition(project) {
236
272
  };
237
273
  }
238
274
 
239
- /** Rebuild a project from a stored definition, re-applying its extensions. */
240
- export function createProjectFromDefinition(definition) {
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
+ }
241
288
  validateDefinition(definition);
242
289
  const current = packkitVersion();
243
290
  const base = createProject({ preset: definition.preset, config: definition.config });
@@ -278,6 +325,14 @@ export function createProjectFromDefinition(definition) {
278
325
  }
279
326
  }
280
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
+
281
336
  const hasExt = Object.keys(plainFiles).length || Object.keys(ext.packageJson || {}).length;
282
337
  // Carry the stored modes so re-exporting the replayed project preserves the
283
338
  // original add/replace intent rather than re-deriving it against this base.
@@ -0,0 +1,148 @@
1
+ // Upgrade planning.
2
+ //
3
+ // A project scaffolded by Packkit records what it came from in packkit.json.
4
+ // That lets us regenerate the *current* recommended output and compare it to
5
+ // what's on disk — so a project can be told what has drifted from Packkit's
6
+ // current templates and choose what to pull in.
7
+ //
8
+ // This module is pure: it takes two file maps (freshly generated vs. on disk)
9
+ // and returns a classified plan. Reading the disk and applying the plan live in
10
+ // the CLI; keeping the decision logic here makes it testable and reusable.
11
+
12
+ import { finalizePackageJson } from '../core/pkg.js';
13
+ import { deepMerge, toJson } from '../core/render.js';
14
+
15
+ // Files Packkit owns but that get structural, not whole-file, treatment.
16
+ // package.json is co-owned (the host adds its own deps/scripts); packkit.json
17
+ // is expected to change every version (it records the generator version).
18
+ const STRUCTURAL = new Set(['package.json', 'packkit.json']);
19
+ const DEP_MAPS = ['dependencies', 'devDependencies', 'peerDependencies'];
20
+
21
+ /**
22
+ * Classify how a freshly-generated project differs from what's on disk.
23
+ *
24
+ * @param {object} input
25
+ * @param {Record<string,string>} input.generated the current createProject().files
26
+ * @param {Record<string,string|undefined>} input.onDisk the on-disk content for
27
+ * each generated path (undefined when the file doesn't exist)
28
+ * @returns an upgrade plan: which files are new/changed/current, and the
29
+ * structural package.json delta.
30
+ */
31
+ export function planUpgrade({ generated, onDisk }) {
32
+ const added = [];
33
+ const changed = [];
34
+ const unchanged = [];
35
+
36
+ for (const [path, content] of Object.entries(generated)) {
37
+ if (STRUCTURAL.has(path)) continue;
38
+ const disk = onDisk[path];
39
+ if (disk === undefined) added.push(path);
40
+ else if (disk === content) unchanged.push(path);
41
+ else changed.push(path);
42
+ }
43
+
44
+ return {
45
+ files: {
46
+ added: added.sort(),
47
+ // "changed" means differs — could be a Packkit template change or the
48
+ // user's own edit. Without a stored baseline we can't tell which, so these
49
+ // are surfaced for review, never overwritten automatically.
50
+ changed: changed.sort(),
51
+ unchanged: unchanged.sort(),
52
+ },
53
+ packageJson: diffPackageJson(onDisk['package.json'], generated['package.json']),
54
+ // packkit.json records the generator version, so it always "changes" on an
55
+ // upgrade; applying the upgrade refreshes it.
56
+ provenanceOutdated: onDisk['packkit.json'] !== generated['packkit.json'],
57
+ };
58
+ }
59
+
60
+ /** True when a plan found nothing to bring in. */
61
+ export function isUpgradeEmpty(plan) {
62
+ const p = plan.packageJson;
63
+ return (
64
+ plan.files.added.length === 0 &&
65
+ plan.files.changed.length === 0 &&
66
+ Object.keys(p.addedDependencies).length === 0 &&
67
+ Object.keys(p.updatedDependencies).length === 0 &&
68
+ Object.keys(p.addedScripts).length === 0 &&
69
+ Object.keys(p.changedScripts).length === 0 &&
70
+ !plan.provenanceOutdated
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Build the { path: content } map to write for a plan. Always includes new
76
+ * files, the refreshed package.json (deps bumped / added, scripts added or
77
+ * updated — the user's own extras preserved), and packkit.json. Changed files
78
+ * are included only when `includeChanged` is set (an explicit overwrite).
79
+ */
80
+ export function buildUpgradeWrite({ generated, onDisk, plan, includeChanged = false }) {
81
+ const out = {};
82
+ for (const path of plan.files.added) out[path] = generated[path];
83
+ if (includeChanged) for (const path of plan.files.changed) out[path] = generated[path];
84
+
85
+ if (onDisk['package.json'] && generated['package.json']) {
86
+ const merged = mergePackageJson(onDisk['package.json'], generated['package.json'], plan.packageJson);
87
+ if (merged !== onDisk['package.json']) out['package.json'] = merged;
88
+ }
89
+ if (plan.provenanceOutdated && generated['packkit.json']) out['packkit.json'] = generated['packkit.json'];
90
+ return out;
91
+ }
92
+
93
+ // Apply only the deps/scripts the plan flagged onto the on-disk package.json,
94
+ // so a bump lands but the user's own additions and field ordering survive.
95
+ function mergePackageJson(diskStr, genStr, pkgPlan) {
96
+ const disk = JSON.parse(diskStr);
97
+ const gen = JSON.parse(genStr);
98
+
99
+ const patch = {};
100
+ for (const [name, { map }] of Object.entries(pkgPlan.addedDependencies)) {
101
+ (patch[map] ||= {})[name] = gen[map][name];
102
+ }
103
+ for (const [name, { map }] of Object.entries(pkgPlan.updatedDependencies)) {
104
+ (patch[map] ||= {})[name] = gen[map][name];
105
+ }
106
+ const scripts = {};
107
+ for (const name of Object.keys(pkgPlan.addedScripts)) scripts[name] = gen.scripts[name];
108
+ for (const name of Object.keys(pkgPlan.changedScripts)) scripts[name] = gen.scripts[name];
109
+ if (Object.keys(scripts).length) patch.scripts = scripts;
110
+
111
+ return toJson(finalizePackageJson(deepMerge(disk, patch)));
112
+ }
113
+
114
+ // Structural package.json diff: what Packkit would add or bump, without ever
115
+ // treating the user's own extra deps/scripts as "removed".
116
+ function diffPackageJson(diskStr, genStr) {
117
+ const empty = { addedDependencies: {}, updatedDependencies: {}, addedScripts: {}, changedScripts: {} };
118
+ if (!diskStr || !genStr) return empty;
119
+
120
+ let disk;
121
+ let gen;
122
+ try {
123
+ disk = JSON.parse(diskStr);
124
+ gen = JSON.parse(genStr);
125
+ } catch {
126
+ return empty; // a hand-broken package.json — leave it alone
127
+ }
128
+
129
+ const addedDependencies = {};
130
+ const updatedDependencies = {};
131
+ for (const map of DEP_MAPS) {
132
+ for (const [name, version] of Object.entries(gen[map] || {})) {
133
+ const current = disk[map]?.[name];
134
+ if (current === undefined) addedDependencies[name] = { map, version };
135
+ else if (current !== version) updatedDependencies[name] = { map, from: current, to: version };
136
+ }
137
+ }
138
+
139
+ const addedScripts = {};
140
+ const changedScripts = {};
141
+ for (const [name, cmd] of Object.entries(gen.scripts || {})) {
142
+ const current = disk.scripts?.[name];
143
+ if (current === undefined) addedScripts[name] = cmd;
144
+ else if (current !== cmd) changedScripts[name] = { from: current, to: cmd };
145
+ }
146
+
147
+ return { addedDependencies, updatedDependencies, addedScripts, changedScripts };
148
+ }
@@ -85,8 +85,28 @@ export class PackkitValidationError extends Error {
85
85
  export const SCHEMA_VERSION: number;
86
86
 
87
87
  export function createProject(input?: CreateProjectInput): GeneratedProject;
88
+ export function resolveProjectConfig(input?: CreateProjectInput): { config: ResolvedPackkitConfig; diagnostics: Diagnostic[] };
89
+ export function createProjectFromResolvedConfig(config: ResolvedPackkitConfig, options?: { diagnostics?: Diagnostic[] }): GeneratedProject;
88
90
  export function extendProject(project: GeneratedProject, extension?: ProjectExtension): GeneratedProject;
89
91
  export function exportProjectDefinition(project: GeneratedProject): PackkitProjectDefinition;
90
- export function createProjectFromDefinition(definition: PackkitProjectDefinition): GeneratedProject;
92
+ export function createProjectFromDefinition(
93
+ definition: PackkitProjectDefinition,
94
+ options?: { driftPolicy?: 'report' | 'error' },
95
+ ): GeneratedProject;
91
96
  export function calculateProjectDigest(project: GeneratedProject): string;
92
97
  export function deriveDeploymentContract(config: ResolvedPackkitConfig): DeploymentContract;
98
+
99
+ export interface UpgradePlan {
100
+ files: { added: string[]; changed: string[]; unchanged: string[] };
101
+ packageJson: {
102
+ addedDependencies: Record<string, { map: string; version: string }>;
103
+ updatedDependencies: Record<string, { map: string; from: string; to: string }>;
104
+ addedScripts: Record<string, string>;
105
+ changedScripts: Record<string, { from: string; to: string }>;
106
+ };
107
+ provenanceOutdated: boolean;
108
+ }
109
+
110
+ export function planUpgrade(input: { generated: Record<string, string>; onDisk: Record<string, string | undefined> }): UpgradePlan;
111
+ export function isUpgradeEmpty(plan: UpgradePlan): boolean;
112
+ export function buildUpgradeWrite(input: { generated: Record<string, string>; onDisk: Record<string, string | undefined>; plan: UpgradePlan; includeChanged?: boolean }): Record<string, string>;