feat: publish changed packages independently
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.8.8",
|
||||
"version": "0.8.9",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -79,24 +79,7 @@ export function repairProject(appRoot: string): DoctorRepair[] {
|
||||
try {
|
||||
const source = readFileSync(pkgPath, "utf8");
|
||||
const pkg = JSON.parse(source) as Record<string, unknown>;
|
||||
const ranges = frameworkRanges(pkg);
|
||||
const preferred = [...ranges.keys()].sort((left, right) => {
|
||||
const a = parseVersion(left);
|
||||
const b = parseVersion(right);
|
||||
return b[0] - a[0] || b[1] - a[1] || b[2] - a[2];
|
||||
})[0];
|
||||
let changed = false;
|
||||
if (preferred && ranges.size > 1) {
|
||||
for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
|
||||
const dependencies = pkg[field] as Record<string, string> | undefined;
|
||||
for (const name of Object.keys(dependencies ?? {})) {
|
||||
if (name.startsWith("@wrnexus/") && dependencies![name] !== preferred) {
|
||||
dependencies![name] = preferred;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const marker = (pkg.wrnexus as Record<string, unknown> | undefined) ?? {};
|
||||
if (!marker.version || !versionAtLeast(String(marker.version), "0.8.0")) {
|
||||
marker.version = "0.8.0";
|
||||
@@ -108,9 +91,7 @@ export function repairProject(appRoot: string): DoctorRepair[] {
|
||||
repairs.push({
|
||||
name: "package.json",
|
||||
changed: true,
|
||||
detail: preferred
|
||||
? `aligned framework packages to ${preferred}`
|
||||
: "recorded 0.8.0 marker",
|
||||
detail: "recorded 0.8.0 migration marker",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -154,15 +135,13 @@ export function inspectProject(appRoot: string): DoctorCheck[] {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
|
||||
const ranges = frameworkRanges(pkg);
|
||||
const invalid = [...ranges.keys()].filter((range) => !/[~^]?\d+\.\d+\.\d+/.test(range));
|
||||
checks.push({
|
||||
name: "framework package versions",
|
||||
ok: ranges.size <= 1,
|
||||
detail:
|
||||
ranges.size <= 1
|
||||
? ([...ranges.keys()][0] ?? "No @wrnexus packages declared")
|
||||
: `version skew: ${[...ranges.entries()]
|
||||
.map(([range, names]) => `${range} (${names.join(", ")})`)
|
||||
.join("; ")}`,
|
||||
ok: invalid.length === 0,
|
||||
detail: invalid.length
|
||||
? `invalid ranges: ${invalid.join(", ")}`
|
||||
: `${[...ranges.values()].reduce((count, names) => count + names.length, 0)} independently versioned package(s)`,
|
||||
});
|
||||
const marker = (pkg.wrnexus as { version?: string } | undefined)?.version;
|
||||
checks.push({
|
||||
|
||||
+81
-14
@@ -2,13 +2,13 @@
|
||||
* `wrnexus update [dir] [--version=x.y.z | --latest] [--dry-run]`
|
||||
*
|
||||
* Upgrade an app (or every app in a workspace) to a WrNexus release:
|
||||
* 1. Bump every `@wrnexus/*` dependency to the target version.
|
||||
* 1. Resolve and bump every installed `@wrnexus/*` dependency independently.
|
||||
* 2. `bun install`.
|
||||
* 3. Refresh framework-owned reference files (public/llms.txt) and apply any
|
||||
* versioned, idempotent migrations that newer releases introduce.
|
||||
* 4. Record the applied version in package.json (`"wrnexus": { version }`).
|
||||
*
|
||||
* Target version resolution: `--version=x.y.z` > `--latest` (queries npm) >
|
||||
* Migration version resolution: `--version=x.y.z` > `--latest` (queries npm) >
|
||||
* the running CLI's own version (the default — pair with `bunx @wrnexus/cli@latest
|
||||
* update` to jump to the newest release with no network guesswork).
|
||||
*
|
||||
@@ -27,7 +27,8 @@ import {
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { execFile, spawnSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { basename, dirname, join, relative, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { formatWrn, parse } from "@wrnexus/syntax";
|
||||
@@ -56,6 +57,31 @@ function latestPublished(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Resolve every installed WRNexus dependency independently and in parallel. */
|
||||
export async function latestPackageVersions(names: string[]): Promise<Map<string, string>> {
|
||||
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const unique = [...new Set(names.filter((name) => name.startsWith("@wrnexus/")))];
|
||||
const entries = await Promise.all(
|
||||
unique.map(async (name) => {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(npm, ["view", name, "version", "--json"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
const parsed = JSON.parse(stdout.trim()) as string | string[];
|
||||
const version = Array.isArray(parsed) ? parsed.at(-1) : parsed;
|
||||
return typeof version === "string" && /^\d+\.\d+\.\d+/.test(version)
|
||||
? ([name, version] as const)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return new Map(entries.filter((entry): entry is readonly [string, string] => entry !== null));
|
||||
}
|
||||
|
||||
/** Numeric compare of `x.y.z` (pre-release/build tags ignored). */
|
||||
function cmp(a: string, b: string): number {
|
||||
const pa = a.split("-")[0]!.split(".").map(Number);
|
||||
@@ -2131,6 +2157,16 @@ const MIGRATIONS: Migration[] = [
|
||||
// interface contracts produced by earlier releases.
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.8.9",
|
||||
id: "0.8.9-independent-package-updates",
|
||||
description:
|
||||
"Resolves and updates each installed WRNexus package to its independently published npm version.",
|
||||
apply() {
|
||||
// Dependency resolution is handled by the update command before install;
|
||||
// no application source rewrite is required.
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Release tooling uses this to require an explicit migration entry per version. */
|
||||
@@ -2138,15 +2174,22 @@ export function updateMigrationVersions(): string[] {
|
||||
return [...new Set(MIGRATIONS.map((migration) => migration.version))];
|
||||
}
|
||||
|
||||
/** Bump every `@wrnexus/*` range to `^target`. Returns the human-readable changes. */
|
||||
function bumpDeps(pkg: Record<string, unknown>, target: string): string[] {
|
||||
/** Bump each `@wrnexus/*` range to its independently resolved target. */
|
||||
export function bumpDeps(
|
||||
pkg: Record<string, unknown>,
|
||||
target: string,
|
||||
packageVersions?: ReadonlyMap<string, string>,
|
||||
): string[] {
|
||||
const changed: string[] = [];
|
||||
const next = `^${target}`;
|
||||
for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
|
||||
const deps = pkg[field] as Record<string, string> | undefined;
|
||||
if (!deps) continue;
|
||||
for (const name of Object.keys(deps)) {
|
||||
if (name.startsWith("@wrnexus/") && deps[name] !== next) {
|
||||
if (!name.startsWith("@wrnexus/")) continue;
|
||||
const resolved = packageVersions ? packageVersions.get(name) : target;
|
||||
if (!resolved) continue;
|
||||
const next = `^${resolved}`;
|
||||
if (deps[name] !== next) {
|
||||
changed.push(`${name} ${deps[name]} → ${next}`);
|
||||
deps[name] = next;
|
||||
}
|
||||
@@ -2240,7 +2283,11 @@ export function updateApp(
|
||||
appRoot: string,
|
||||
target: string,
|
||||
dryRun: boolean,
|
||||
options: { explicitImports?: boolean; report?: MigrationReport } = {},
|
||||
options: {
|
||||
explicitImports?: boolean;
|
||||
report?: MigrationReport;
|
||||
packageVersions?: ReadonlyMap<string, string>;
|
||||
} = {},
|
||||
): UpdatedApp | null {
|
||||
const pkgPath = join(appRoot, "package.json");
|
||||
if (!existsSync(pkgPath)) {
|
||||
@@ -2264,7 +2311,7 @@ export function updateApp(
|
||||
|
||||
if (!dryRun) log(`backup: ${backupProjectFiles(appRoot, from, target)}`);
|
||||
|
||||
const changes = bumpDeps(pkg, target);
|
||||
const changes = bumpDeps(pkg, target, options.packageVersions);
|
||||
changes.forEach((c) => log(c));
|
||||
if (!changes.length) log("dependencies already current");
|
||||
|
||||
@@ -2288,7 +2335,7 @@ export function updateApp(
|
||||
// changes back before writing the dependency bump so neither side is lost.
|
||||
if (!dryRun) {
|
||||
Object.assign(pkg, JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>);
|
||||
bumpDeps(pkg, target);
|
||||
bumpDeps(pkg, target, options.packageVersions);
|
||||
}
|
||||
if (!dryRun) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
||||
return { root: appRoot, packagePath: pkgPath, pkg };
|
||||
@@ -2386,11 +2433,30 @@ export async function runUpdate(dir: string, args: string[]): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n ⚡ wrnexus update → ${target}${dryRun ? " (dry run)" : ""}\n`);
|
||||
|
||||
// Workspace → update the root manifest + every app; else just this app.
|
||||
const apps = await workspaceApps(root);
|
||||
const targets = apps ? [root, ...apps] : [root];
|
||||
const dependencyNames = targets.flatMap((targetRoot) => {
|
||||
const file = join(targetRoot, "package.json");
|
||||
if (!existsSync(file)) return [];
|
||||
const manifest = JSON.parse(readFileSync(file, "utf8")) as Record<string, any>;
|
||||
return ["dependencies", "devDependencies", "peerDependencies"].flatMap((field) =>
|
||||
Object.keys(manifest[field] ?? {}).filter((name) => name.startsWith("@wrnexus/")),
|
||||
);
|
||||
});
|
||||
const packageVersions = versionArg
|
||||
? new Map<string, string>()
|
||||
: await latestPackageVersions(dependencyNames);
|
||||
|
||||
console.log(`\n ⚡ wrnexus update → migrations ${target}${dryRun ? " (dry run)" : ""}`);
|
||||
if (!versionArg) {
|
||||
console.log(
|
||||
` Resolved ${packageVersions.size} installed WRNexus package version(s) from npm.\n`,
|
||||
);
|
||||
} else {
|
||||
console.log("");
|
||||
}
|
||||
|
||||
const reports = new Map<string, MigrationReport>();
|
||||
const updated = targets
|
||||
.map((targetRoot) => {
|
||||
@@ -2403,7 +2469,7 @@ export async function runUpdate(dir: string, args: string[]): Promise<void> {
|
||||
parseFailures: [],
|
||||
};
|
||||
reports.set(targetRoot, report);
|
||||
return updateApp(targetRoot, target, dryRun, { explicitImports, report });
|
||||
return updateApp(targetRoot, target, dryRun, { explicitImports, report, packageVersions });
|
||||
})
|
||||
.filter(Boolean) as UpdatedApp[];
|
||||
|
||||
@@ -2453,6 +2519,7 @@ export async function runUpdate(dir: string, args: string[]): Promise<void> {
|
||||
writeFileSync(app.packagePath, JSON.stringify(app.pkg, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
console.log(`\n ✓ Updated and verified ${updated.length} package(s) at ${target}.`);
|
||||
console.log(`\n ✓ Updated and verified ${updated.length} project manifest(s).`);
|
||||
console.log(` Migration checkpoint: ${target}; dependencies use their own npm versions.`);
|
||||
console.log(` Backups are under .wrnexus/update-backups/. Review and redeploy.\n`);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ test("doctor --fix applies safe repairs and is idempotent", () => {
|
||||
expect(repairs.map(({ name }) => name)).toContain("configuration");
|
||||
expect(existsSync(join(root, "wrnexus.config.ts"))).toBe(true);
|
||||
const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
||||
expect(manifest.dependencies["@wrnexus/router"]).toBe("^0.8.0");
|
||||
expect(manifest.dependencies["@wrnexus/router"]).toBe("^0.7.0");
|
||||
expect(manifest.wrnexus.version).toBe("0.8.0");
|
||||
expect(repairProject(root)).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,38 @@ import { expect, test } from "bun:test";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { blockingVerificationChecks, updateApp, verificationCommands } from "../src/update.ts";
|
||||
import {
|
||||
blockingVerificationChecks,
|
||||
bumpDeps,
|
||||
updateApp,
|
||||
verificationCommands,
|
||||
} from "../src/update.ts";
|
||||
|
||||
test("dependency updates use each package's independently published version", () => {
|
||||
const pkg = {
|
||||
dependencies: {
|
||||
"@wrnexus/ui": "^0.8.8",
|
||||
"@wrnexus/core": "^0.8.8",
|
||||
"@wrnexus/unavailable": "^0.8.7",
|
||||
},
|
||||
devDependencies: { "@wrnexus/cli": "^0.8.8" },
|
||||
};
|
||||
const changes = bumpDeps(
|
||||
pkg,
|
||||
"0.8.9",
|
||||
new Map([
|
||||
["@wrnexus/ui", "0.8.10"],
|
||||
["@wrnexus/core", "0.8.8"],
|
||||
["@wrnexus/cli", "0.8.9"],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(pkg.dependencies["@wrnexus/ui"]).toBe("^0.8.10");
|
||||
expect(pkg.dependencies["@wrnexus/core"]).toBe("^0.8.8");
|
||||
expect(pkg.dependencies["@wrnexus/unavailable"]).toBe("^0.8.7");
|
||||
expect(pkg.devDependencies["@wrnexus/cli"]).toBe("^0.8.9");
|
||||
expect(changes).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("updateApp migrates project files without marking an unverified update complete", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-update-"));
|
||||
|
||||
Reference in New Issue
Block a user