feat: add safe project upgrades and production performance fixes

This commit is contained in:
2026-07-12 16:54:22 +05:30
parent a524c08126
commit 935b3103dd
68 changed files with 555 additions and 107 deletions
+1
View File
@@ -252,6 +252,7 @@ wrnexus dev . # dev server + HMR
wrnexus build . # production build → dist/server.js
bun dist/server.js # run the production server (or npm start)
wrnexus create <name> # scaffold a new app
wrnexus update --latest # deps + syntax/config migrations + verification
wrnexus generate page <Name> # scaffold a page (aliases: g p)
wrnexus generate component <name> | api <path> | schema <name>
wrnexus db migrate | rollback | status | new [--from-models] | generate | seed
+6 -3
View File
@@ -19,7 +19,7 @@ import { compileWireFile } from "@wrnexus/compiler";
import {
loadAppConfig,
headToString,
renderFontHead,
renderProductionFontHead,
findStyleEntry,
renderStyles,
resolveThemeConfig,
@@ -124,8 +124,10 @@ export async function runBuild(appRoot: string): Promise<void> {
// 1a2) Wire UI stylesheet (all component classes, themed via tokens).
const uiStyles = uiCss();
writeFileSync(join(distDir, "ui.css"), uiStyles, "utf8");
const frameworkStyles = `${themeCss}\n${uiStyles}`;
writeFileSync(join(distDir, "framework.css"), frameworkStyles, "utf8");
assetHash.update(uiStyles);
console.log(`✓ UI: dist/ui.css`);
console.log(`✓ UI: dist/ui.css + framework.css`);
// 1a3) Validation: bake schema descriptors into the client script.
const descriptors: Record<string, SchemaDescriptor> = {};
@@ -164,7 +166,7 @@ export async function runBuild(appRoot: string): Promise<void> {
console.log(`✓ Styles: ${join(distDir, "styles.css")}`);
}
const assetVersion = assetHash.digest("hex").slice(0, 12);
const headStr = [renderFontHead(config.fonts), headToString(config.head)]
const headStr = [await renderProductionFontHead(config.fonts), headToString(config.head)]
.filter(Boolean)
.join("\n ");
@@ -232,6 +234,7 @@ await createProductionServer(
themeJsPath: join(import.meta.dir, "theme.js"),
theme: ${JSON.stringify(theme)},
uiCssPath: join(import.meta.dir, "ui.css"),
frameworkCssPath: join(import.meta.dir, "framework.css"),
schemasJs: ${JSON.stringify(schemasJs)},
i18n: ${i18n ? JSON.stringify(i18n) : "undefined"},
db: ${config.db ? JSON.stringify(config.db) : "undefined"},
+6 -1
View File
@@ -13,6 +13,7 @@ import { join, resolve } from "node:path";
import { resolveProfile, loadEnv } from "@wrnexus/styles";
import { runDev } from "./dev.ts";
import { createApp } from "./create.ts";
import { currentCliVersion, notifyIfUpdateAvailable } from "./update-notifier.ts";
/**
* Resolve the active profile from `--profile=<name>` (or WRNEXUS_PROFILE / mode),
@@ -51,7 +52,7 @@ Usage:
wrnexus native list List cross-platform native capabilities
wrnexus native add <capability...> Install capability packages for the configured mobile mode
wrnexus eject <name...> Copy a Wire UI component into app/components
wrnexus update [dir] [--latest] Upgrade @wrnexus/* deps + apply config/file migrations
wrnexus update [dir] [--latest] Upgrade deps, migrate project files, and verify the app
wrnexus db <cmd> Migrations: migrate | rollback | status | seed | generate | new
wrnexus test [app-dir] [--watch] Run the app's tests (bun test, 'test' profile)
wrnexus profiles [app-dir] List config profiles (dev/prod/uat/…) and their env files
@@ -59,12 +60,16 @@ Usage:
Profiles: pass --profile=<name> to dev/build/db (or set WRNEXUS_PROFILE) to load
that profile's .env cascade and config overrides. e.g. wrnexus dev --profile=uat
Update options: --dry-run previews changes; --no-verify skips post-update check/build.
`);
}
async function main(): Promise<void> {
const [command, ...rest] = process.argv.slice(2);
notifyIfUpdateAvailable(currentCliVersion(), command);
switch (command) {
case "dev": {
const appRoot = rest.find((a) => !a.startsWith("--")) ?? ".";
+83
View File
@@ -0,0 +1,83 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { spawnSync } from "node:child_process";
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
interface UpdateCache {
checkedAt: number;
latest: string;
}
export function currentCliVersion(): string {
try {
return JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")).version;
} catch {
return "0.0.0";
}
}
export function compareVersions(a: string, b: string): number {
const left = a.split("-")[0]!.split(".").map(Number);
const right = b.split("-")[0]!.split(".").map(Number);
for (let i = 0; i < 3; i++) {
const difference = (left[i] || 0) - (right[i] || 0);
if (difference) return difference > 0 ? 1 : -1;
}
return 0;
}
function cachePath(): string {
return process.env.WRNEXUS_UPDATE_CACHE ?? join(homedir(), ".wrnexus", "update-check.json");
}
function cachedLatest(now: number): string | null {
try {
const cached = JSON.parse(readFileSync(cachePath(), "utf8")) as UpdateCache;
return now - cached.checkedAt < CHECK_INTERVAL_MS ? cached.latest : null;
} catch {
return null;
}
}
function registryLatest(): string | null {
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
const result = spawnSync(npm, ["view", "@wrnexus/cli", "version"], {
encoding: "utf8",
timeout: 5000,
windowsHide: true,
});
const version = (result.stdout ?? "").trim();
return result.status === 0 && /^\d+\.\d+\.\d+/.test(version) ? version : null;
}
/** Check at most once per day and print a non-blocking upgrade suggestion. */
export function notifyIfUpdateAvailable(current: string, command?: string): void {
if (
process.env.WRNEXUS_NO_UPDATE_CHECK === "1" ||
command === "update" ||
command === "upgrade"
) {
return;
}
const now = Date.now();
let latest = cachedLatest(now);
if (!latest) {
latest = registryLatest();
if (!latest) return;
try {
const file = cachePath();
if (!existsSync(dirname(file))) mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, JSON.stringify({ checkedAt: now, latest }) + "\n", "utf8");
} catch {
// A read-only home directory must never break the CLI.
}
}
if (compareVersions(latest, current) > 0) {
console.log(
`\n Update available: WRNexusJS ${current}${latest}\n` +
` Run: wrnexus update --latest\n`,
);
}
}
+169 -10
View File
@@ -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`);
}