feat: add safe project upgrades and production performance fixes
This commit is contained in:
+169
-10
@@ -17,11 +17,12 @@
|
||||
* as the framework evolves — that is how "new things" reach existing apps.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { cpSync, existsSync, mkdirSync, 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";
|
||||
import { inspectProject } from "./doctor.ts";
|
||||
|
||||
/** The version of the CLI currently running (its own package.json). */
|
||||
function cliVersion(): string {
|
||||
@@ -35,7 +36,8 @@ function cliVersion(): string {
|
||||
/** 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 npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const out = spawnSync(npm, ["view", "@wrnexus/cli", "version"], { encoding: "utf8" });
|
||||
const v = (out.stdout ?? "").trim();
|
||||
return /^\d+\.\d+\.\d+/.test(v) ? v : null;
|
||||
} catch {
|
||||
@@ -92,6 +94,68 @@ const MIGRATIONS: Migration[] = [
|
||||
writeFileSync(file, next.replace(/^\n+/, ""), "utf8");
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.2.14",
|
||||
id: "complete-gitignore",
|
||||
description: "Add the complete WRNexusJS generated-project ignore set",
|
||||
apply(ctx) {
|
||||
const file = join(ctx.appRoot, ".gitignore");
|
||||
const want = [
|
||||
"node_modules/",
|
||||
"dist/",
|
||||
".wrnexus/",
|
||||
"**/.wrnexus/",
|
||||
"coverage/",
|
||||
".env",
|
||||
".env.*",
|
||||
"!.env.example",
|
||||
"*.log",
|
||||
"*.db",
|
||||
"*.db-shm",
|
||||
"*.db-wal",
|
||||
"*.sqlite",
|
||||
"*.sqlite3",
|
||||
"mobile/android/",
|
||||
"mobile/ios/",
|
||||
"mobile/.expo/",
|
||||
".idea/",
|
||||
".vscode/",
|
||||
".DS_Store",
|
||||
"Thumbs.db",
|
||||
"*.tsbuildinfo",
|
||||
".eslintcache",
|
||||
];
|
||||
const current = existsSync(file) ? readFileSync(file, "utf8") : "";
|
||||
const have = new Set(current.split(/\r?\n/).map((line) => line.trim()));
|
||||
const missing = want.filter((entry) => !have.has(entry));
|
||||
if (!missing.length) return;
|
||||
ctx.log(`+ .gitignore: ${missing.join(", ")}`);
|
||||
if (!ctx.dryRun) {
|
||||
const next = `${current.replace(/\s*$/, "")}\n${missing.join("\n")}\n`.replace(/^\n+/, "");
|
||||
writeFileSync(file, next, "utf8");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.2.14",
|
||||
id: "production-scripts",
|
||||
description: "Add standard production build and start scripts",
|
||||
apply(ctx) {
|
||||
// Workspace roots are manifests, not runnable applications.
|
||||
if (!existsSync(join(ctx.appRoot, "app", "pages"))) return;
|
||||
const file = join(ctx.appRoot, "package.json");
|
||||
const pkg = JSON.parse(readFileSync(file, "utf8")) as Record<string, any>;
|
||||
const scripts = (pkg.scripts ??= {});
|
||||
const additions: Record<string, string> = {};
|
||||
if (!scripts.build) additions.build = "wrnexus build .";
|
||||
if (!scripts.start) additions.start = "bun dist/server.js";
|
||||
if (!scripts.production) additions.production = "bun run build && bun run start";
|
||||
if (!Object.keys(additions).length) return;
|
||||
Object.assign(scripts, additions);
|
||||
ctx.log(`+ package scripts: ${Object.keys(additions).join(", ")}`);
|
||||
if (!ctx.dryRun) writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Bump every `@wrnexus/*` range to `^target`. Returns the human-readable changes. */
|
||||
@@ -141,17 +205,46 @@ function refreshFrameworkFiles(appRoot: string, dryRun: boolean, log: (m: string
|
||||
}
|
||||
|
||||
/** Update a single app dir (bump its package.json, refresh files, run migrations). */
|
||||
function updateApp(appRoot: string, target: string, dryRun: boolean): boolean {
|
||||
interface UpdatedApp {
|
||||
root: string;
|
||||
packagePath: string;
|
||||
pkg: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function backupProjectFiles(appRoot: string, from: string, target: string): string {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const backup = join(appRoot, ".wrnexus", "update-backups", `${stamp}-${from}-to-${target}`);
|
||||
mkdirSync(backup, { recursive: true });
|
||||
for (const name of [
|
||||
"package.json",
|
||||
"wrnexus.config.ts",
|
||||
"wrnexus.config.js",
|
||||
"wrnexus.config.mjs",
|
||||
"tsconfig.json",
|
||||
".gitignore",
|
||||
"CLAUDE.md",
|
||||
"llms.txt",
|
||||
]) {
|
||||
const source = join(appRoot, name);
|
||||
if (existsSync(source)) cpSync(source, join(backup, name));
|
||||
}
|
||||
return backup;
|
||||
}
|
||||
|
||||
/** Update one app without marking success until install and verification pass. */
|
||||
export function updateApp(appRoot: string, target: string, dryRun: boolean): UpdatedApp | null {
|
||||
const pkgPath = join(appRoot, "package.json");
|
||||
if (!existsSync(pkgPath)) {
|
||||
console.log(` ⚠ ${appRoot}: no package.json — skipped`);
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
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})`);
|
||||
|
||||
if (!dryRun) log(`backup: ${backupProjectFiles(appRoot, from, target)}`);
|
||||
|
||||
const changes = bumpDeps(pkg, target);
|
||||
changes.forEach((c) => log(c));
|
||||
if (!changes.length) log("dependencies already current");
|
||||
@@ -164,9 +257,32 @@ function updateApp(appRoot: string, target: string, dryRun: boolean): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Record the applied version so the next update knows where it started.
|
||||
pkg.wrnexus = { ...(pkg.wrnexus as object), version: target };
|
||||
// A migration may have conservatively edited package.json. Merge those
|
||||
// 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);
|
||||
}
|
||||
if (!dryRun) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
||||
return { root: appRoot, packagePath: pkgPath, pkg };
|
||||
}
|
||||
|
||||
function verifyApp(app: UpdatedApp): boolean {
|
||||
const health = inspectProject(app.root);
|
||||
const failed = health.filter((check) => !check.ok && check.name !== "configuration");
|
||||
if (failed.length) {
|
||||
for (const check of failed) console.error(` ✗ ${check.name}: ${check.detail}`);
|
||||
return false;
|
||||
}
|
||||
const scripts = (app.pkg.scripts ?? {}) as Record<string, string>;
|
||||
const commands: string[][] = [];
|
||||
if (scripts.check) commands.push(["run", "check"]);
|
||||
if (scripts.build) commands.push(["run", "build"]);
|
||||
for (const args of commands) {
|
||||
console.log(`\n Verifying ${app.pkg.name ?? app.root}: bun ${args.join(" ")}…`);
|
||||
const result = spawnSync(process.execPath, args, { cwd: app.root, stdio: "inherit" });
|
||||
if (result.status !== 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -186,17 +302,42 @@ async function workspaceApps(root: string): Promise<string[] | null> {
|
||||
export async function runUpdate(dir: string, args: string[]): Promise<void> {
|
||||
const root = resolve(dir);
|
||||
const dryRun = args.includes("--dry-run");
|
||||
const verify = !args.includes("--no-verify");
|
||||
const versionArg = args.find((a) => a.startsWith("--version="))?.split("=")[1];
|
||||
const target =
|
||||
versionArg ?? (args.includes("--latest") ? latestPublished() : null) ?? cliVersion();
|
||||
|
||||
// Always let the target CLI apply its own migrations. Without this handoff,
|
||||
// an older installed CLI could bump dependency versions but would not know
|
||||
// about syntax/config migrations shipped by the newer release.
|
||||
if (!args.includes("--delegated") && cmp(target, cliVersion()) > 0) {
|
||||
console.log(`\n Fetching WRNexusJS CLI ${target} to run its project migrations…`);
|
||||
const forwarded = args.filter(
|
||||
(arg) => !arg.startsWith("--version=") && arg !== "--latest" && arg !== "--delegated",
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"x",
|
||||
`@wrnexus/cli@${target}`,
|
||||
"update",
|
||||
dir,
|
||||
`--version=${target}`,
|
||||
"--delegated",
|
||||
...forwarded,
|
||||
],
|
||||
{ cwd: root, stdio: "inherit" },
|
||||
);
|
||||
if (result.status !== 0) process.exitCode = result.status ?? 1;
|
||||
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];
|
||||
let updated = 0;
|
||||
for (const t of targets) if (updateApp(t, target, dryRun)) updated++;
|
||||
const updated = targets.map((t) => updateApp(t, target, dryRun)).filter(Boolean) as UpdatedApp[];
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n Dry run — no files written. Re-run without --dry-run to apply.\n`);
|
||||
@@ -211,6 +352,24 @@ export async function runUpdate(dir: string, args: string[]): Promise<void> {
|
||||
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`);
|
||||
if (verify) {
|
||||
const appsToVerify = apps ? updated.filter((app) => app.root !== root) : updated;
|
||||
for (const app of appsToVerify) {
|
||||
if (!verifyApp(app)) {
|
||||
console.error(
|
||||
`\n ✗ Update files were applied, but verification failed. Fix the reported error and re-run wrnexus update.`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const app of updated) {
|
||||
app.pkg.wrnexus = { ...(app.pkg.wrnexus as object), version: target };
|
||||
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(` Backups are under .wrnexus/update-backups/. Review and redeploy.\n`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user