diff --git a/README.md b/README.md index 7234773..7720660 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,22 @@ npx create-packkit ts-lib my-lib --here --merge **Existing files are never overwritten.** Anything that collides is left alone and reported, so you can diff at your leisure. (A directory containing only `.git` counts as empty — a fresh clone scaffolds without needing `--merge` at all.) +## Keep a project current + +Every scaffolded project records what it came from in `packkit.json`. Later, from +inside the project: + +```sh +npx create-packkit upgrade # dry run: what's changed since you scaffolded +npx create-packkit upgrade --apply # add new files, bump deps, keep your edits +``` + +Upgrade regenerates the project Packkit would produce today and reports the +difference — new files, added or bumped dependencies, new scripts. `--apply` +brings in the safe changes (your own files, deps, and scripts are preserved); +files you've edited are listed for review and never overwritten unless you pass +`--force`. + ## Or configure it on the web No install needed: **[danmat.github.io/create-packkit](https://danmat.github.io/create-packkit/)** — tick the options, preview the file tree, and **download a zip** (or copy the equivalent `npx create-packkit` command). Everything runs in your browser. diff --git a/ROADMAP.md b/ROADMAP.md index 546d51a..edca31d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -114,7 +114,7 @@ _Second external review, this one of the codebase rather than the experience. Ra ### The strategic one -- [ ] **`packkit upgrade`** — read `packkit.json`, generate the current recommended output in memory, diff it against the repo, and classify each difference: safe auto-update, user-modified, deprecated dependency, changed best practice, manual migration. Then emit a patch or a PR. **Both reviews independently landed on this**, and 2.9's provenance file was the prerequisite. It's what turns a one-time generator into something with a durable relationship to the repos it creates — one-time scaffolders are easy to replace; a tool that keeps repos current isn't. +- [x] ~~**`packkit upgrade`**~~ — **Shipped (3.1).** `npx create-packkit upgrade [dir]` reads `packkit.json`, regenerates the current recommended output in memory (faithful reconstruction verified across every preset type), and diffs it against disk. Classifies each difference — new files, added/bumped dependencies, new/changed scripts, files that differ — and `--apply` brings in the safe changes while preserving the user's own files, deps, and scripts; edited files are surfaced for review and never overwritten without `--force`. Pure planner (`planUpgrade` / `buildUpgradeWrite`) lives in the embedded API and is fully tested. **Both reviews independently landed on this**, and 2.9's provenance file was the prerequisite. Turns a one-time generator into something with a durable relationship to the repos it creates. _(Follow-up: per-file baseline hashes in packkit.json would let it distinguish "user edited" from "template changed" precisely, rather than surfacing all differing files for review.)_ ### Checked and not acted on diff --git a/src/cli/index.js b/src/cli/index.js index 455cb89..c896047 100644 --- a/src/cli/index.js +++ b/src/cli/index.js @@ -39,6 +39,7 @@ packkit — scaffold a modern npm package, CLI, service, or app Usage: npm create packkit@latest [name] [options] npx packkit [preset] [name] [options] + npx packkit upgrade [dir] Pull a scaffolded project up to current templates Run with no options for an interactive wizard, or add -y for recommended defaults in one shot. Every option is documented (with why-you'd-use-it) at: @@ -114,6 +115,13 @@ Examples: `; export async function run(argv = process.argv.slice(2)) { + // Subcommands take the first positional. `upgrade` is a distinct flow (it + // reads an existing project rather than scaffolding one), so route it early. + if (argv[0] === 'upgrade') { + const { runUpgrade } = await import('./upgrade.js'); + return runUpgrade(argv.slice(1)); + } + const args = parseCliArgs(argv); if (args.help) return void console.log(HELP); if (args.version) return void console.log(pkgVersion()); diff --git a/src/cli/upgrade.js b/src/cli/upgrade.js new file mode 100644 index 0000000..62c94c0 --- /dev/null +++ b/src/cli/upgrade.js @@ -0,0 +1,147 @@ +// `packkit upgrade` — regenerate a scaffolded project's current recommended +// output and report (or apply) what has drifted from Packkit's templates. +// +// It reads packkit.json to learn the preset + settings the project came from, +// regenerates in memory through the embedded API, and diffs against disk. New +// files and package.json dep/script updates apply cleanly; files that differ +// are surfaced for review, never overwritten unless explicitly forced. + +import { parseArgs } from 'node:util'; +import { readFileSync, existsSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { createProject, planUpgrade, isUpgradeEmpty, buildUpgradeWrite } from '../embedded/index.js'; +import { writeGeneratedProject } from '../embedded/writer.js'; + +const UPGRADE_HELP = ` +packkit upgrade — pull your project up to Packkit's current templates + +Usage: + packkit upgrade [directory] [options] + +Reads packkit.json in the directory (default: current), regenerates the project +Packkit would produce today, and shows what changed since you scaffolded. + +Options: + --apply Write new files and update package.json / packkit.json + --force With --apply, also overwrite files that differ (review first!) + -h, --help Show this help + +Without --apply this is a dry run — it only reports. +`; + +export async function runUpgrade(argv) { + const { values, positionals } = parseArgs({ + args: argv, + allowPositionals: true, + options: { + apply: { type: 'boolean' }, + force: { type: 'boolean' }, + help: { type: 'boolean', short: 'h' }, + }, + }); + if (values.help) return void console.log(UPGRADE_HELP); + + const dir = resolve(positionals[0] || '.'); + const provPath = join(dir, 'packkit.json'); + if (!existsSync(provPath)) { + console.error(`No packkit.json in "${dir}". Upgrade only works on a project Packkit scaffolded.`); + process.exit(1); + } + + let provenance; + try { + provenance = JSON.parse(readFileSync(provPath, 'utf8')); + } catch (err) { + console.error(`Could not read packkit.json: ${err.message}`); + process.exit(1); + } + + // The project name isn't a "setting", so it lives in package.json, not + // packkit.json — read it back so regeneration reproduces the same names. + const name = readName(dir) || provenance.name || 'my-package'; + const fromVersion = provenance.version || 'unknown'; + + let project; + try { + project = createProject({ preset: provenance.preset, name, config: provenance.settings || {} }); + } catch (err) { + console.error(`Could not regenerate from packkit.json: ${err.message}`); + process.exit(1); + } + const toVersion = project.metadata.packkitVersion; + + const onDisk = {}; + for (const path of Object.keys(project.files)) { + const full = join(dir, path); + onDisk[path] = existsSync(full) ? readFileSync(full, 'utf8') : undefined; + } + + const plan = planUpgrade({ generated: project.files, onDisk }); + + if (isUpgradeEmpty(plan)) { + console.log(`Already current with Packkit ${toVersion}. Nothing to upgrade.`); + return; + } + + report(plan, fromVersion, toVersion); + + if (!values.apply) { + console.log('\nThis was a dry run. Re-run with --apply to bring in the safe changes.'); + return; + } + + const writeMap = buildUpgradeWrite({ generated: project.files, onDisk, plan, includeChanged: !!values.force }); + const paths = Object.keys(writeMap); + if (paths.length) { + await writeGeneratedProject({ + project: { config: project.config, files: writeMap }, + destination: dir, + collisionPolicy: 'overwrite', + }); + console.log(`\n✓ Applied ${paths.length} file update${paths.length > 1 ? 's' : ''}.`); + } + const leftover = values.force ? [] : plan.files.changed; + if (leftover.length) { + const plural = leftover.length > 1; + console.log( + `\n${leftover.length} file${plural ? 's' : ''} ${plural ? 'differ' : 'differs'} from the current template and ` + + `${plural ? 'were' : 'was'} left untouched (they may be your edits):\n ` + + leftover.join('\n ') + + `\nReview them, then re-run with --force to overwrite the ones you want Packkit's version of.`, + ); + } +} + +function readName(dir) { + try { + return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).name; + } catch { + return null; + } +} + +function report(plan, fromVersion, toVersion) { + console.log(`Packkit ${fromVersion} → ${toVersion}\n`); + const p = plan.packageJson; + + if (plan.files.added.length) { + console.log(`New files (${plan.files.added.length}):\n ` + plan.files.added.join('\n ')); + } + const addedDeps = Object.entries(p.addedDependencies); + const bumpedDeps = Object.entries(p.updatedDependencies); + if (addedDeps.length) { + console.log(`\nNew dependencies (${addedDeps.length}):\n ` + addedDeps.map(([n, d]) => `${n}@${d.version} (${d.map})`).join('\n ')); + } + if (bumpedDeps.length) { + console.log(`\nDependency updates (${bumpedDeps.length}):\n ` + bumpedDeps.map(([n, d]) => `${n}: ${d.from} → ${d.to}`).join('\n ')); + } + const addedScripts = Object.keys(p.addedScripts); + const changedScripts = Object.entries(p.changedScripts); + if (addedScripts.length) console.log(`\nNew scripts (${addedScripts.length}):\n ` + addedScripts.join('\n ')); + if (changedScripts.length) { + console.log(`\nScript changes (${changedScripts.length}):\n ` + changedScripts.map(([n, d]) => `${n}: ${d.from} → ${d.to}`).join('\n ')); + } + if (plan.files.changed.length) { + console.log(`\nFiles that differ — review before overwriting (${plan.files.changed.length}):\n ` + plan.files.changed.join('\n ')); + } +} diff --git a/src/embedded/index.js b/src/embedded/index.js index 4e859ad..b9e7af8 100644 --- a/src/embedded/index.js +++ b/src/embedded/index.js @@ -24,6 +24,7 @@ import { analyzePkgFragments } from './pkg-merge.js'; import { deriveDeploymentContract } from './contract.js'; export { deriveDeploymentContract }; +export { planUpgrade, isUpgradeEmpty, buildUpgradeWrite } from './upgrade.js'; // Bumped when the shape of PackkitProjectDefinition changes incompatibly. export const SCHEMA_VERSION = 2; diff --git a/src/embedded/upgrade.js b/src/embedded/upgrade.js new file mode 100644 index 0000000..c87f5c8 --- /dev/null +++ b/src/embedded/upgrade.js @@ -0,0 +1,148 @@ +// Upgrade planning. +// +// A project scaffolded by Packkit records what it came from in packkit.json. +// That lets us regenerate the *current* recommended output and compare it to +// what's on disk — so a project can be told what has drifted from Packkit's +// current templates and choose what to pull in. +// +// This module is pure: it takes two file maps (freshly generated vs. on disk) +// and returns a classified plan. Reading the disk and applying the plan live in +// the CLI; keeping the decision logic here makes it testable and reusable. + +import { finalizePackageJson } from '../core/pkg.js'; +import { deepMerge, toJson } from '../core/render.js'; + +// Files Packkit owns but that get structural, not whole-file, treatment. +// package.json is co-owned (the host adds its own deps/scripts); packkit.json +// is expected to change every version (it records the generator version). +const STRUCTURAL = new Set(['package.json', 'packkit.json']); +const DEP_MAPS = ['dependencies', 'devDependencies', 'peerDependencies']; + +/** + * Classify how a freshly-generated project differs from what's on disk. + * + * @param {object} input + * @param {Record} input.generated the current createProject().files + * @param {Record} input.onDisk the on-disk content for + * each generated path (undefined when the file doesn't exist) + * @returns an upgrade plan: which files are new/changed/current, and the + * structural package.json delta. + */ +export function planUpgrade({ generated, onDisk }) { + const added = []; + const changed = []; + const unchanged = []; + + for (const [path, content] of Object.entries(generated)) { + if (STRUCTURAL.has(path)) continue; + const disk = onDisk[path]; + if (disk === undefined) added.push(path); + else if (disk === content) unchanged.push(path); + else changed.push(path); + } + + return { + files: { + added: added.sort(), + // "changed" means differs — could be a Packkit template change or the + // user's own edit. Without a stored baseline we can't tell which, so these + // are surfaced for review, never overwritten automatically. + changed: changed.sort(), + unchanged: unchanged.sort(), + }, + packageJson: diffPackageJson(onDisk['package.json'], generated['package.json']), + // packkit.json records the generator version, so it always "changes" on an + // upgrade; applying the upgrade refreshes it. + provenanceOutdated: onDisk['packkit.json'] !== generated['packkit.json'], + }; +} + +/** True when a plan found nothing to bring in. */ +export function isUpgradeEmpty(plan) { + const p = plan.packageJson; + return ( + plan.files.added.length === 0 && + plan.files.changed.length === 0 && + Object.keys(p.addedDependencies).length === 0 && + Object.keys(p.updatedDependencies).length === 0 && + Object.keys(p.addedScripts).length === 0 && + Object.keys(p.changedScripts).length === 0 && + !plan.provenanceOutdated + ); +} + +/** + * Build the { path: content } map to write for a plan. Always includes new + * files, the refreshed package.json (deps bumped / added, scripts added or + * updated — the user's own extras preserved), and packkit.json. Changed files + * are included only when `includeChanged` is set (an explicit overwrite). + */ +export function buildUpgradeWrite({ generated, onDisk, plan, includeChanged = false }) { + const out = {}; + for (const path of plan.files.added) out[path] = generated[path]; + if (includeChanged) for (const path of plan.files.changed) out[path] = generated[path]; + + if (onDisk['package.json'] && generated['package.json']) { + const merged = mergePackageJson(onDisk['package.json'], generated['package.json'], plan.packageJson); + if (merged !== onDisk['package.json']) out['package.json'] = merged; + } + if (plan.provenanceOutdated && generated['packkit.json']) out['packkit.json'] = generated['packkit.json']; + return out; +} + +// Apply only the deps/scripts the plan flagged onto the on-disk package.json, +// so a bump lands but the user's own additions and field ordering survive. +function mergePackageJson(diskStr, genStr, pkgPlan) { + const disk = JSON.parse(diskStr); + const gen = JSON.parse(genStr); + + const patch = {}; + for (const [name, { map }] of Object.entries(pkgPlan.addedDependencies)) { + (patch[map] ||= {})[name] = gen[map][name]; + } + for (const [name, { map }] of Object.entries(pkgPlan.updatedDependencies)) { + (patch[map] ||= {})[name] = gen[map][name]; + } + const scripts = {}; + for (const name of Object.keys(pkgPlan.addedScripts)) scripts[name] = gen.scripts[name]; + for (const name of Object.keys(pkgPlan.changedScripts)) scripts[name] = gen.scripts[name]; + if (Object.keys(scripts).length) patch.scripts = scripts; + + return toJson(finalizePackageJson(deepMerge(disk, patch))); +} + +// Structural package.json diff: what Packkit would add or bump, without ever +// treating the user's own extra deps/scripts as "removed". +function diffPackageJson(diskStr, genStr) { + const empty = { addedDependencies: {}, updatedDependencies: {}, addedScripts: {}, changedScripts: {} }; + if (!diskStr || !genStr) return empty; + + let disk; + let gen; + try { + disk = JSON.parse(diskStr); + gen = JSON.parse(genStr); + } catch { + return empty; // a hand-broken package.json — leave it alone + } + + const addedDependencies = {}; + const updatedDependencies = {}; + for (const map of DEP_MAPS) { + for (const [name, version] of Object.entries(gen[map] || {})) { + const current = disk[map]?.[name]; + if (current === undefined) addedDependencies[name] = { map, version }; + else if (current !== version) updatedDependencies[name] = { map, from: current, to: version }; + } + } + + const addedScripts = {}; + const changedScripts = {}; + for (const [name, cmd] of Object.entries(gen.scripts || {})) { + const current = disk.scripts?.[name]; + if (current === undefined) addedScripts[name] = cmd; + else if (current !== cmd) changedScripts[name] = { from: current, to: cmd }; + } + + return { addedDependencies, updatedDependencies, addedScripts, changedScripts }; +} diff --git a/test/upgrade.test.js b/test/upgrade.test.js new file mode 100644 index 0000000..a961401 --- /dev/null +++ b/test/upgrade.test.js @@ -0,0 +1,73 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { planUpgrade, isUpgradeEmpty, buildUpgradeWrite, createProject } from '../src/embedded/index.js'; + +const pkg = (obj) => JSON.stringify(obj, null, 2) + '\n'; + +test('planUpgrade classifies new / changed / unchanged files', () => { + const generated = { 'a.txt': 'A', 'b.txt': 'B', 'c.txt': 'C' }; + const onDisk = { 'a.txt': 'A', 'b.txt': 'edited', 'c.txt': undefined }; + const plan = planUpgrade({ generated, onDisk }); + assert.deepEqual(plan.files.added, ['c.txt']); + assert.deepEqual(plan.files.changed, ['b.txt']); + assert.deepEqual(plan.files.unchanged, ['a.txt']); +}); + +test('planUpgrade: package.json is structural — adds and bumps, never removes user deps', () => { + const generated = { + 'package.json': pkg({ dependencies: { hono: '^4.5.0' }, devDependencies: { tsup: '^8.0.0' }, scripts: { build: 'tsup', test: 'vitest' } }), + }; + const onDisk = { + 'package.json': pkg({ dependencies: { hono: '^4.0.0', 'my-lib': '^1.0.0' }, devDependencies: {}, scripts: { build: 'tsup', deploy: 'mine' } }), + }; + const p = planUpgrade({ generated, onDisk }).packageJson; + assert.deepEqual(p.updatedDependencies.hono, { map: 'dependencies', from: '^4.0.0', to: '^4.5.0' }); + assert.deepEqual(p.addedDependencies.tsup, { map: 'devDependencies', version: '^8.0.0' }); + assert.equal(p.addedScripts.test, 'vitest'); + // the user's own dep and script are not reported as removed/changed + assert.equal(p.addedDependencies['my-lib'], undefined); + assert.equal(p.changedScripts.deploy, undefined); +}); + +test('isUpgradeEmpty is true only when nothing drifted', () => { + const files = { 'a.txt': 'A', 'package.json': pkg({}), 'packkit.json': '{}' }; + assert.equal(isUpgradeEmpty(planUpgrade({ generated: files, onDisk: files })), true); + assert.equal(isUpgradeEmpty(planUpgrade({ generated: files, onDisk: { ...files, 'a.txt': 'edited' } })), false); +}); + +test('buildUpgradeWrite: new files + merged package.json, changed excluded by default', () => { + const generated = { + 'new.txt': 'fresh', + 'edited.txt': 'packkit version', + 'package.json': pkg({ devDependencies: { tsup: '^8.0.0' }, scripts: { build: 'tsup' } }), + 'packkit.json': pkg({ version: '3.0.0' }), + }; + const onDisk = { + 'new.txt': undefined, + 'edited.txt': 'user edited', + 'package.json': pkg({ devDependencies: { tsup: '^7.0.0' }, scripts: { build: 'tsup', mine: 'x' } }), + 'packkit.json': pkg({ version: '2.0.0' }), + }; + const plan = planUpgrade({ generated, onDisk }); + + const write = buildUpgradeWrite({ generated, onDisk, plan }); + assert.equal(write['new.txt'], 'fresh', 'new file written'); + assert.equal(write['edited.txt'], undefined, 'changed file left alone'); + assert.equal(write['packkit.json'], generated['packkit.json'], 'provenance refreshed'); + const merged = JSON.parse(write['package.json']); + assert.equal(merged.devDependencies.tsup, '^8.0.0', 'bump applied'); + assert.equal(merged.scripts.mine, 'x', 'user script preserved'); + + // --force also brings the changed file + const forced = buildUpgradeWrite({ generated, onDisk, plan, includeChanged: true }); + assert.equal(forced['edited.txt'], 'packkit version'); +}); + +test('end-to-end: regenerate from packkit.json reproduces the project (nothing to upgrade)', () => { + const project = createProject({ preset: 'ts-lib', name: 'lib' }); + const provenance = JSON.parse(project.files['packkit.json']); + const rebuilt = createProject({ preset: provenance.preset, name: 'lib', config: provenance.settings }); + // on disk == freshly generated → an empty plan + const plan = planUpgrade({ generated: rebuilt.files, onDisk: project.files }); + assert.equal(isUpgradeEmpty(plan), true); +}); diff --git a/types/embedded.d.ts b/types/embedded.d.ts index aca2524..6ca58bf 100644 --- a/types/embedded.d.ts +++ b/types/embedded.d.ts @@ -95,3 +95,18 @@ export function createProjectFromDefinition( ): GeneratedProject; export function calculateProjectDigest(project: GeneratedProject): string; export function deriveDeploymentContract(config: ResolvedPackkitConfig): DeploymentContract; + +export interface UpgradePlan { + files: { added: string[]; changed: string[]; unchanged: string[] }; + packageJson: { + addedDependencies: Record; + updatedDependencies: Record; + addedScripts: Record; + changedScripts: Record; + }; + provenanceOutdated: boolean; +} + +export function planUpgrade(input: { generated: Record; onDisk: Record }): UpgradePlan; +export function isUpgradeEmpty(plan: UpgradePlan): boolean; +export function buildUpgradeWrite(input: { generated: Record; onDisk: Record; plan: UpgradePlan; includeChanged?: boolean }): Record;