feat: publish changed packages independently
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-11 15:32:36 +05:30
parent feff30a2b5
commit 8d044565e3
67 changed files with 10935 additions and 876 deletions
+81 -14
View File
@@ -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`);
}