Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions src/cli/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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());
Expand Down
147 changes: 147 additions & 0 deletions src/cli/upgrade.js
Original file line number Diff line number Diff line change
@@ -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 '));
}
}
1 change: 1 addition & 0 deletions src/embedded/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
148 changes: 148 additions & 0 deletions src/embedded/upgrade.js
Original file line number Diff line number Diff line change
@@ -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<string,string>} input.generated the current createProject().files
* @param {Record<string,string|undefined>} 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 };
}
Loading