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
44 changes: 42 additions & 2 deletions src/cli/upgrade.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@
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 { createProject, planUpgrade, isUpgradeEmpty, buildUpgradeWrite, summarizeUpgrade } from '../embedded/index.js';
import { writeGeneratedProject } from '../embedded/writer.js';

// Bumped if the --json output shape changes incompatibly.
const JSON_SCHEMA_VERSION = 1;

const DEP_SECTIONS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];

const UPGRADE_HELP = `
Expand All @@ -32,6 +35,7 @@ Options:
--replace-files With --apply, replace files that differ
--update-scripts With --apply, replace scripts that differ
--update-deps With --apply, replace dependency versions that differ
--json Print the plan as JSON (machine-readable; no other output)
-h, --help Show this help

Without --apply this is a dry run — it only reports.
Expand All @@ -49,6 +53,7 @@ export async function runUpgrade(argv) {
'replace-files': { type: 'boolean' },
'update-scripts': { type: 'boolean' },
'update-deps': { type: 'boolean' },
json: { type: 'boolean' },
help: { type: 'boolean', short: 'h' },
},
});
Expand All @@ -57,7 +62,9 @@ export async function runUpgrade(argv) {
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.`);
const msg = `No packkit.json in "${dir}". Upgrade only works on a project Packkit scaffolded.`;
if (values.json) { console.log(JSON.stringify({ schemaVersion: JSON_SCHEMA_VERSION, error: msg }, null, 2)); return; }
console.error(msg);
process.exit(1);
}

Expand Down Expand Up @@ -91,6 +98,12 @@ export async function runUpgrade(argv) {

const plan = planUpgrade({ generated: project.files, onDisk });

// Machine-readable mode: emit only JSON, never a human log line.
if (values.json) {
console.log(JSON.stringify(jsonReport(plan, fromVersion, toVersion), null, 2));
return;
}

if (isUpgradeEmpty(plan)) {
console.log(`Already current with Packkit ${toVersion}. Nothing to upgrade.`);
return;
Expand Down Expand Up @@ -133,6 +146,33 @@ export async function runUpgrade(argv) {
}
}

// A versioned, machine-readable view of the plan for portals/automation.
function jsonReport(plan, fromVersion, toVersion) {
const files = [
...plan.files.added.map((path) => ({ path, status: 'new-generated-file', safeToApply: true })),
...plan.files.changed.map((path) => ({ path, status: 'changed', safeToApply: false })),
];
return {
schemaVersion: JSON_SCHEMA_VERSION,
fromPackkitVersion: fromVersion,
toPackkitVersion: toVersion,
// Baseline (three-way) metadata isn't stored yet, so changed values can't be
// classified as user-vs-template.
baselineAvailable: false,
summary: summarizeUpgrade(plan),
files,
packageJson: plan.packageJson,
diagnostics: [
{
severity: 'warning',
code: 'UPGRADE_BASELINE_UNAVAILABLE',
message: 'This project has no baseline metadata; values that differ are preserved and need manual review.',
source: 'upgrade',
},
],
};
}

function readName(dir) {
try {
return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).name;
Expand Down
65 changes: 64 additions & 1 deletion src/embedded/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ import { deepMerge, toJson } from '../core/render.js';
import { validateRelativePath, validatePathMap } from './paths.js';
import { analyzePkgFragments } from './pkg-merge.js';
import { deriveDeploymentContract } from './contract.js';
import { planUpgrade, isUpgradeEmpty, buildUpgradeWrite, DEFAULT_UPGRADE_POLICY, summarizeUpgrade } from './upgrade.js';

export { deriveDeploymentContract };
export { planUpgrade, isUpgradeEmpty, buildUpgradeWrite, DEFAULT_UPGRADE_POLICY } from './upgrade.js';
export { planUpgrade, isUpgradeEmpty, buildUpgradeWrite, DEFAULT_UPGRADE_POLICY, summarizeUpgrade };

// Bumped when the shape of PackkitProjectDefinition changes incompatibly.
export const SCHEMA_VERSION = 2;
Expand Down Expand Up @@ -343,6 +344,68 @@ export function createProjectFromDefinition(definition, { driftPolicy = 'report'
return result;
}

/**
* High-level upgrade orchestration for a host application. Takes a stored
* definition and the current repository files, regenerates with this Packkit
* version, and returns a classified plan plus a write patch — entirely in
* memory. Writes nothing, runs no git/commands, makes no network calls. The
* host decides whether to write the patch, commit it, or open a pull request.
*
* @param {object} input
* @param {object} input.definition a PackkitProjectDefinition (from exportProjectDefinition)
* @param {Record<string,string>} input.currentFiles the repo's current file contents
* @param {object} [input.currentPackageJson] the current package.json, if not in currentFiles
* @param {object} [input.policy] UpgradeApplyPolicy (default: non-destructive add-only)
*/
export function upgradeProject(input = {}) {
const { definition, currentFiles, currentPackageJson, policy } = input;
if (!currentFiles || typeof currentFiles !== 'object') {
throw new TypeError('upgradeProject needs currentFiles: a { path: contents } map of the repository.');
}

// Recreate with the current Packkit version (validates the definition and
// preserves extension add/replace semantics).
const generatedProject = createProjectFromDefinition(definition);

// Compare only the paths Packkit generates against what the repo has there.
const onDisk = {};
for (const path of Object.keys(generatedProject.files)) onDisk[path] = currentFiles[path];
if (onDisk['package.json'] === undefined && currentPackageJson && typeof currentPackageJson === 'object') {
onDisk['package.json'] = JSON.stringify(currentPackageJson);
}

const plan = planUpgrade({ generated: generatedProject.files, onDisk });
const patch = buildUpgradeWrite({ generated: generatedProject.files, onDisk, plan, policy });
const summary = summarizeUpgrade(plan);

// Baseline metadata (three-way diff) isn't stored yet, so changed values can't
// be classified as user-vs-template — surface that so a host doesn't assume a
// differing value is safe.
const baselineAvailable = false;
const diagnostics = [
{
severity: 'warning',
code: 'UPGRADE_BASELINE_UNAVAILABLE',
message: 'This project has no baseline metadata; values that differ are preserved and need manual review.',
source: 'upgrade',
},
];

return {
generatedProject,
plan,
patch,
diagnostics,
metadata: {
fromPackkitVersion: definition?.packkitVersion,
toPackkitVersion: generatedProject.metadata.packkitVersion,
baselineAvailable,
hasConflicts: summary.conflicts > 0,
hasSafeChanges: summary.safeChanges > 0,
},
};
}

/**
* A stable digest of the project's config and file contents. Two projects with
* the same Packkit version, config, and extensions produce the same digest;
Expand Down
21 changes: 21 additions & 0 deletions src/embedded/upgrade.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,27 @@ export function planUpgrade({ generated, onDisk }) {
};
}

/**
* Count a plan into safe (additive, applied by default), review (differs,
* preserved by default), and conflict (both-changed; only detectable with a
* baseline, so 0 today). Used for the metadata summary and --json output.
*/
export function summarizeUpgrade(plan) {
const p = plan.packageJson;
const depCount = (m) => DEP_SECTIONS.reduce((n, s) => n + Object.keys(m[s]).length, 0);
const safeChanges =
plan.files.added.length +
Object.keys(p.addedScripts).length +
depCount(p.addedDependencies) +
p.addedFields.length;
const reviewChanges =
plan.files.changed.length +
Object.keys(p.changedScripts).length +
depCount(p.changedDependencies) +
p.changedFields.length;
return { safeChanges, reviewChanges, conflicts: 0 };
}

/** True when a plan found nothing to bring in. */
export function isUpgradeEmpty(plan) {
const p = plan.packageJson;
Expand Down
42 changes: 42 additions & 0 deletions test/upgrade.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,45 @@ test('end-to-end: regenerate from packkit.json reproduces the project (nothing t
const plan = planUpgrade({ generated: rebuilt.files, onDisk: project.files });
assert.equal(isUpgradeEmpty(plan), true);
});

// ---- upgradeProject (embedded orchestration) -------------------------------

import { upgradeProject, exportProjectDefinition, summarizeUpgrade } from '../src/embedded/index.js';

test('upgradeProject: recreates, diffs, and builds a patch — purely in memory', () => {
const project = createProject({ preset: 'ts-lib', name: 'lib' });
const definition = exportProjectDefinition(project);
// simulate a repo missing one generated file and with an edited README
const currentFiles = { ...project.files };
delete currentFiles['.editorconfig'];
currentFiles['README.md'] = 'my edited readme\n';

const result = upgradeProject({ definition, currentFiles });
assert.ok(result.generatedProject.files['package.json']);
assert.equal(result.patch['.editorconfig'], project.files['.editorconfig'], 'missing file in the patch');
assert.equal(result.patch['README.md'], undefined, 'edited file preserved (default policy)');
assert.equal(result.metadata.baselineAvailable, false);
assert.ok(result.diagnostics.some((d) => d.code === 'UPGRADE_BASELINE_UNAVAILABLE'));
assert.equal(result.metadata.hasSafeChanges, true);
});

test('upgradeProject: an up-to-date repo yields an empty patch', () => {
const project = createProject({ preset: 'node-service', name: 'svc' });
const result = upgradeProject({ definition: exportProjectDefinition(project), currentFiles: project.files });
assert.deepEqual(result.patch, {});
assert.equal(result.metadata.hasSafeChanges, false);
});

test('upgradeProject: requires currentFiles', () => {
const project = createProject({ preset: 'ts-lib', name: 'lib' });
assert.throws(() => upgradeProject({ definition: exportProjectDefinition(project) }), /currentFiles/);
});

test('summarizeUpgrade counts additive vs review changes', () => {
const generated = { 'a.txt': 'A', 'b.txt': 'B', 'package.json': pkg({ scripts: { test: 'vitest' } }) };
const onDisk = { 'a.txt': undefined, 'b.txt': 'edited', 'package.json': pkg({ scripts: {} }) };
const s = summarizeUpgrade(planUpgrade({ generated, onDisk }));
assert.equal(s.safeChanges, 2); // a.txt added + test script added
assert.equal(s.reviewChanges, 1); // b.txt changed
assert.equal(s.conflicts, 0);
});
32 changes: 32 additions & 0 deletions types/embedded.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,39 @@ export interface UpgradeApplyPolicy {

export const DEFAULT_UPGRADE_POLICY: Readonly<UpgradeApplyPolicy>;

export interface UpgradeSummary {
safeChanges: number;
reviewChanges: number;
conflicts: number;
}

export interface UpgradeProjectInput {
definition: PackkitProjectDefinition;
currentFiles: Record<string, string>;
currentPackageJson?: Record<string, unknown>;
policy?: Partial<UpgradeApplyPolicy>;
}

export interface ProjectUpgradeResult {
generatedProject: GeneratedProject;
plan: UpgradePlan;
patch: Record<string, string>;
diagnostics: Diagnostic[];
metadata: {
fromPackkitVersion?: string;
toPackkitVersion: string;
baselineAvailable: boolean;
hasConflicts: boolean;
hasSafeChanges: boolean;
};
}

/** In-memory upgrade orchestration for host apps: recreate, diff, and build a
* patch under a policy. No filesystem, git, network, or command execution. */
export function upgradeProject(input: UpgradeProjectInput): ProjectUpgradeResult;

export function planUpgrade(input: { generated: Record<string, string>; onDisk: Record<string, string | undefined> }): UpgradePlan;
export function summarizeUpgrade(plan: UpgradePlan): UpgradeSummary;
export function isUpgradeEmpty(plan: UpgradePlan): boolean;
export function buildUpgradeWrite(input: {
generated: Record<string, string>;
Expand Down