first commit
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* `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.
|
||||
* 2. `bun install`.
|
||||
* 3. Refresh framework-owned reference files (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) >
|
||||
* 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).
|
||||
*
|
||||
* Migrations are CONSERVATIVE: they only add/refresh framework-owned things and
|
||||
* never clobber your own code or edited CLAUDE.md. Add new ones to `MIGRATIONS`
|
||||
* as the framework evolves — that is how "new things" reach existing apps.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts";
|
||||
|
||||
/** The version of the CLI currently running (its own package.json). */
|
||||
function cliVersion(): string {
|
||||
try {
|
||||
return JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")).version;
|
||||
} catch {
|
||||
return "0.0.0";
|
||||
}
|
||||
}
|
||||
|
||||
/** Query the registry for the latest published `@wrnexus/cli` version. */
|
||||
function latestPublished(): string | null {
|
||||
try {
|
||||
const out = spawnSync("npm", ["view", "@wrnexus/cli", "version"], { encoding: "utf8" });
|
||||
const v = (out.stdout ?? "").trim();
|
||||
return /^\d+\.\d+\.\d+/.test(v) ? v : null;
|
||||
} catch {
|
||||
return 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);
|
||||
const pb = b.split("-")[0]!.split(".").map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const d = (pa[i] || 0) - (pb[i] || 0);
|
||||
if (d) return d > 0 ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
interface MigrationCtx {
|
||||
appRoot: string;
|
||||
from: string;
|
||||
to: string;
|
||||
dryRun: boolean;
|
||||
log: (msg: string) => void;
|
||||
}
|
||||
|
||||
interface Migration {
|
||||
/** Framework version that introduced this change. Runs when `from < version <= to`. */
|
||||
version: string;
|
||||
id: string;
|
||||
description: string;
|
||||
apply: (ctx: MigrationCtx) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Versioned, idempotent upgrade steps. Each MUST be safe to re-run and MUST NOT
|
||||
* overwrite user code. Append new entries with the version that ships them.
|
||||
*/
|
||||
const MIGRATIONS: Migration[] = [
|
||||
{
|
||||
version: "0.2.8",
|
||||
id: "gitignore-artifacts",
|
||||
description: "Ensure .gitignore covers framework build artifacts",
|
||||
apply(ctx) {
|
||||
const file = join(ctx.appRoot, ".gitignore");
|
||||
const want = ["node_modules/", "dist/", ".wrnexus/", "*.db"];
|
||||
const current = existsSync(file) ? readFileSync(file, "utf8") : "";
|
||||
const have = new Set(current.split(/\r?\n/).map((l) => l.trim()));
|
||||
const missing = want.filter((w) => !have.has(w));
|
||||
if (missing.length === 0) return;
|
||||
ctx.log(`+ .gitignore: ${missing.join(", ")}`);
|
||||
if (ctx.dryRun) return;
|
||||
const next = current.replace(/\s*$/, "") + "\n" + missing.join("\n") + "\n";
|
||||
writeFileSync(file, next.replace(/^\n+/, ""), "utf8");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Bump every `@wrnexus/*` range to `^target`. Returns the human-readable changes. */
|
||||
function bumpDeps(pkg: Record<string, unknown>, target: 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) {
|
||||
changed.push(`${name} ${deps[name]} → ${next}`);
|
||||
deps[name] = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** The framework version an app was last updated to (or its installed CLI version). */
|
||||
function appVersion(appRoot: string, pkg: Record<string, unknown>): string {
|
||||
const marker = (pkg.wrnexus as { version?: string } | undefined)?.version;
|
||||
if (marker) return marker;
|
||||
try {
|
||||
const p = join(appRoot, "node_modules", "@wrnexus", "cli", "package.json");
|
||||
if (existsSync(p)) return JSON.parse(readFileSync(p, "utf8")).version;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return "0.0.0";
|
||||
}
|
||||
|
||||
/** Refresh pure framework-owned reference files. Never touches user-edited CLAUDE.md. */
|
||||
function refreshFrameworkFiles(appRoot: string, dryRun: boolean, log: (m: string) => void): void {
|
||||
// llms.txt is a generated reference — always safe to overwrite.
|
||||
const llms = join(appRoot, "llms.txt");
|
||||
if (!existsSync(llms) || readFileSync(llms, "utf8") !== AI_GUIDE) {
|
||||
log("~ llms.txt refreshed");
|
||||
if (!dryRun) writeFileSync(llms, AI_GUIDE, "utf8");
|
||||
}
|
||||
// CLAUDE.md is often user-edited — only create it when absent.
|
||||
const claude = join(appRoot, "CLAUDE.md");
|
||||
if (!existsSync(claude)) {
|
||||
log("+ CLAUDE.md created");
|
||||
if (!dryRun) writeFileSync(claude, CLAUDE_MD, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
/** Update a single app dir (bump its package.json, refresh files, run migrations). */
|
||||
function updateApp(appRoot: string, target: string, dryRun: boolean): boolean {
|
||||
const pkgPath = join(appRoot, "package.json");
|
||||
if (!existsSync(pkgPath)) {
|
||||
console.log(` ⚠ ${appRoot}: no package.json — skipped`);
|
||||
return false;
|
||||
}
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
|
||||
const from = appVersion(appRoot, pkg);
|
||||
const log = (m: string) => console.log(` ${m}`);
|
||||
console.log(` ▸ ${pkg.name ?? appRoot} (${from} → ${target})`);
|
||||
|
||||
const changes = bumpDeps(pkg, target);
|
||||
changes.forEach((c) => log(c));
|
||||
if (!changes.length) log("dependencies already current");
|
||||
|
||||
refreshFrameworkFiles(appRoot, dryRun, log);
|
||||
|
||||
for (const m of MIGRATIONS) {
|
||||
if (cmp(m.version, from) > 0 && cmp(m.version, target) <= 0) {
|
||||
m.apply({ appRoot, from, to: target, dryRun, log });
|
||||
}
|
||||
}
|
||||
|
||||
// Record the applied version so the next update knows where it started.
|
||||
pkg.wrnexus = { ...(pkg.wrnexus as object), version: target };
|
||||
if (!dryRun) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Load a `wrnexus.workspace.ts`, returning its app dirs (or null if not a workspace). */
|
||||
async function workspaceApps(root: string): Promise<string[] | null> {
|
||||
for (const f of ["wrnexus.workspace.ts", "wrnexus.workspace.js", "wrnexus.workspace.mjs"]) {
|
||||
const path = join(root, f);
|
||||
if (!existsSync(path)) continue;
|
||||
const mod = (await import(pathToFileURL(path).href)) as {
|
||||
default?: { apps?: { dir: string }[] };
|
||||
};
|
||||
return (mod.default?.apps ?? []).map((a) => resolve(root, a.dir));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function runUpdate(dir: string, args: string[]): Promise<void> {
|
||||
const root = resolve(dir);
|
||||
const dryRun = args.includes("--dry-run");
|
||||
const versionArg = args.find((a) => a.startsWith("--version="))?.split("=")[1];
|
||||
const target =
|
||||
versionArg ?? (args.includes("--latest") ? latestPublished() : null) ?? cliVersion();
|
||||
|
||||
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];
|
||||
let updated = 0;
|
||||
for (const t of targets) if (updateApp(t, target, dryRun)) updated++;
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n Dry run — no files written. Re-run without --dry-run to apply.\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
// One install at the top (Bun workspaces hoist), using the bun that's running us.
|
||||
console.log(`\n Installing…`);
|
||||
const res = spawnSync(process.execPath, ["install"], { cwd: root, stdio: "inherit" });
|
||||
if (res.status !== 0) {
|
||||
console.error(`\n ⚠ bun install exited with ${res.status}. Fix the error and re-run.`);
|
||||
process.exit(res.status ?? 1);
|
||||
}
|
||||
|
||||
console.log(`\n ✓ Updated ${updated} package(s) to ${target}.`);
|
||||
console.log(` Review the changes, then rebuild/redeploy (wrnexus build).\n`);
|
||||
}
|
||||
Reference in New Issue
Block a user