Files
WRNexusJS/packages/cli/src/update.ts
T
2026-07-27 12:42:18 +05:30

1355 lines
46 KiB
TypeScript

/**
* `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 (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) >
* 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: source rewrites are syntax-aware, idempotent, and
* protected by a complete pre-update backup. User-owned files are never replaced wholesale. Add new ones to `MIGRATIONS`
* as the framework evolves — that is how "new things" reach existing apps.
*/
import {
cpSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { spawnSync } from "node:child_process";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts";
import { inspectProject, type DoctorCheck } from "./doctor.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 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 {
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;
}
const VSCODE_EXTENSIONS = `{
"recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
}
`;
const LEGACY_VSCODE_EXTENSIONS =
JSON.stringify(
{
recommendations: ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"],
},
null,
2,
) + "\n";
function walkProjectFiles(dir: string, extension: string): string[] {
if (!existsSync(dir)) return [];
const out: string[] = [];
for (const entry of readdirSync(dir)) {
if (["node_modules", "dist", ".git", ".wrnexus"].includes(entry)) continue;
const path = join(dir, entry);
const stat = statSync(path);
if (stat.isDirectory()) out.push(...walkProjectFiles(path, extension));
else if (stat.isFile() && path.endsWith(extension)) out.push(path);
}
return out;
}
function formatInlineProps(source: string): string {
return source.replace(
/(^[ \t]*)props\s*\{([^{}\n]*)\}/gm,
(whole, indent: string, body: string) => {
const declaration =
/([A-Za-z_$][\w$]*)(\s*:\s*[^=]+?)?\s*=\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\[\]|\{\}|true|false|null|undefined|-?\d+(?:\.\d+)?)/gy;
const values: string[] = [];
let offset = 0;
while (offset < body.length) {
while (/\s/.test(body[offset] ?? "")) offset++;
if (offset >= body.length) break;
declaration.lastIndex = offset;
const match = declaration.exec(body);
if (!match || match.index !== offset) return whole;
values.push(`${match[1]}${match[2] ?? ""} = ${match[3]}`);
offset = declaration.lastIndex;
}
if (values.length < 2) return whole;
return `${indent}props {\n${values.map((value) => `${indent} ${value}`).join("\n")}\n${indent}}`;
},
);
}
function quoteLegacyDynamicAttributes(source: string): string {
let output = "";
let index = 0;
while (index < source.length) {
const start = source.indexOf("<", index);
if (start < 0) return output + source.slice(index);
output += source.slice(index, start);
if (source.startsWith("<!--", start)) {
const end = source.indexOf("-->", start + 4);
if (end < 0) return output + source.slice(start);
output += source.slice(start, end + 3);
index = end + 3;
continue;
}
let end = start + 1;
let quote = "";
let braceDepth = 0;
for (; end < source.length; end++) {
const char = source[end]!;
if (quote) {
if (char === "\\") end++;
else if (char === quote) quote = "";
continue;
}
if (char === '"' || char === "'") quote = char;
else if (char === "{") braceDepth++;
else if (char === "}") braceDepth = Math.max(0, braceDepth - 1);
else if (char === ">" && braceDepth === 0) break;
}
if (end >= source.length) return output + source.slice(start);
let tag = source.slice(start, end + 1);
if (!/^<\/?[A-Za-z]/.test(tag) || /^<\//.test(tag)) {
output += tag;
index = end + 1;
continue;
}
tag = tag.replace(
/(\s[@:#A-Za-z_$][\w$:.-]*)\s*=\s*\{([^{}']+)\}/g,
(_all, name: string, expr: string) => `${name}='{${expr.trim()}}'`,
);
output += tag;
index = end + 1;
}
return output;
}
export function migrateWrnSource(source: string): string {
return formatInlineProps(quoteLegacyDynamicAttributes(source));
}
/**
* Versioned, idempotent upgrade steps. Each MUST be safe to re-run. Source
* migrations must preserve semantics and are protected by update backups.
*/
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");
},
},
{
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");
},
},
{
version: "0.2.18",
id: "add-helpers-package",
description: "Add @wrnexus/helpers to existing runnable applications",
apply(ctx) {
// Workspace roots do not consume application Context helpers directly.
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 dependencies = (pkg.dependencies ??= {} as Record<string, string>);
if (dependencies["@wrnexus/helpers"]) return;
dependencies["@wrnexus/helpers"] = `^${ctx.to}`;
ctx.log(`+ @wrnexus/helpers ^${ctx.to}`);
if (!ctx.dryRun) writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n", "utf8");
},
},
{
version: "0.2.19",
id: "vscode-formatting",
description: "Add safe VS Code format-on-save defaults and extension recommendations",
apply(ctx) {
const vscode = join(ctx.appRoot, ".vscode");
const settings = join(vscode, "settings.json");
const extensions = join(vscode, "extensions.json");
const prettierIgnore = join(ctx.appRoot, ".prettierignore");
const gitignore = join(ctx.appRoot, ".gitignore");
const files: Array<[string, string, string]> = [
[
settings,
JSON.stringify(
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" },
"prettier.requireConfig": true,
"[wrn]": {
"editor.defaultFormatter": "wrnexus.wrnexus",
"editor.formatOnSave": true,
},
},
null,
2,
) + "\n",
".vscode/settings.json",
],
[extensions, VSCODE_EXTENSIONS, ".vscode/extensions.json"],
];
for (const [file, contents, label] of files) {
if (existsSync(file)) continue;
ctx.log(`+ ${label}`);
if (!ctx.dryRun) {
mkdirSync(vscode, { recursive: true });
writeFileSync(file, contents, "utf8");
}
}
const prettier = existsSync(prettierIgnore) ? readFileSync(prettierIgnore, "utf8") : "";
if (!new Set(prettier.split(/\r?\n/).map((line) => line.trim())).has("CLAUDE.md")) {
ctx.log("+ .prettierignore: CLAUDE.md");
if (!ctx.dryRun) {
writeFileSync(prettierIgnore, `${prettier.replace(/\s*$/, "")}\nCLAUDE.md\n`, "utf8");
}
}
if (existsSync(gitignore)) {
const current = readFileSync(gitignore, "utf8");
const lines = current.split(/\r?\n/);
const vscodeIgnore = lines.findIndex((line) => line.trim() === ".vscode/");
const needed = [".vscode/*", "!.vscode/settings.json", "!.vscode/extensions.json"];
if (vscodeIgnore >= 0 || needed.some((entry) => !lines.includes(entry))) {
ctx.log("~ .gitignore: track shared VS Code configuration");
if (!ctx.dryRun) {
const filtered = lines.filter((line) => line.trim() !== ".vscode/");
const have = new Set(filtered.map((line) => line.trim()));
for (const entry of needed) if (!have.has(entry)) filtered.push(entry);
writeFileSync(gitignore, filtered.join("\n").replace(/\n*$/, "\n"), "utf8");
}
}
}
},
},
{
version: "0.2.20",
id: "normalize-vscode-extensions",
description: "Normalize the framework-generated VS Code recommendations file",
apply(ctx) {
const file = join(ctx.appRoot, ".vscode", "extensions.json");
if (!existsSync(file) || readFileSync(file, "utf8") !== LEGACY_VSCODE_EXTENSIONS) return;
ctx.log("~ .vscode/extensions.json formatting normalized");
if (!ctx.dryRun) writeFileSync(file, VSCODE_EXTENSIONS, "utf8");
},
},
{
version: "0.2.21",
id: "in-process-hmr-runtime",
description: "No project-file changes; the in-process HMR behavior is runtime-only",
apply() {
// Explicit no-op: updating @wrnexus/dev-server is sufficient.
},
},
{
version: "0.2.22",
id: "nested-forward-auth-origin",
description: "No project-file changes; nested forward-auth headers are fixed at runtime",
apply() {
// Explicit no-op: updating @wrnexus/dev-server and helpers is sufficient.
},
},
{
version: "0.2.23",
id: "custom-required-validation-message",
description: "No project-file changes; custom required messages are provided at runtime",
apply() {
// Explicit no-op: updating @wrnexus/validation is sufficient.
},
},
{
version: "0.2.24",
id: "package-usage-documentation",
description: "No project-file changes; package usage documentation is expanded",
apply() {
// Explicit no-op: this release updates package and portal documentation only.
},
},
{
version: "0.2.25",
id: "reactive-state-attributes",
description: "No project-file changes; state expressions in attributes are reactive at runtime",
apply() {
// Explicit no-op: updating @wrnexus/compiler and @wrnexus/csr is sufficient.
},
},
{
version: "0.2.26",
id: "class-improvements-attributes",
description:
"Adds conditional class directives, route state parameters, formatter improvements, and HMR fixes.",
apply() {
// No project file migration is required for this framework release.
},
},
{
version: "0.2.27",
id: "component-improvements-tag",
description: "Adds Component renders system allowed 2 ways.",
apply() {
// No project file migration is required for this framework release.
},
},
{
version: "0.2.28",
id: "layout-component-improvements",
description: "Added new block layout for component and improved component rendering.",
apply() {
// No project file migration is required for this framework release.
},
},
{
version: "0.2.29",
id: "workspace-config-improvements",
description:
"Adds environment-aware workspace domains, app URL helpers, component tag syntax, layouts, and editor improvements.",
apply() {
// No generated project files require automatic changes.
// Existing applications remain compatible with this release.
},
},
{
version: "0.2.30",
id: "workspace-config-auth-improvements",
description:
"Adds dynamic workspace origins, workspace forward-auth resolution, layout support, component tag syntax, and editor improvements.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.31",
id: "lifecycle-improvements",
description:
"Adds component functions, lifecycle hooks, state watchers, optimized behavior cleanup, and CSR navigation lifecycle support.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.32",
id: "fix-runtime-bug-0-2-32",
description:
"Fixes safe encoding and browser parsing of multiline component behavior metadata.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.33",
id: "fix-runtime-bug-0-2-33",
description:
"Fixes safe encoding and browser parsing of multiline component behavior metadata.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.34",
id: "reactive-bug",
description:
"Fixes component behavior encoding, lifecycle and watcher metadata parsing, CSR cleanup, and reactive form control property updates.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.35",
id: "class-bug",
description:
"Fixes component behavior encoding, lifecycle and watcher metadata parsing, CSR cleanup, and reactive form control property updates.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.36",
id: "attribute-bug",
description:
"Adds support for Tailwind arbitrary-value class directives and improves reactive class bindings.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.37",
id: "loop-condition-fix",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.38",
id: "loop-condition-bug",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.39",
id: "server-function-bug",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.40",
id: "conditions-bug-0-2-40",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.41",
id: "conditions-bug-0-2-41",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.42",
id: "conditions-bug-0-2-42",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.43",
id: "conditions-bug-0-2-43",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.44",
id: "conditions-bug-0-2-44",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.45",
id: "conditions-bug-0-2-45",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.46",
id: "conditions-bug-0-2-46",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.47",
id: "conditions-bug-0-2-47",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.48",
id: "components-update-0-2-48",
description: "Adds reactive hydration support for server-rendered each loop locals.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.49",
id: "components-update-0-2-49",
description: "Adds reactive hydration support for server-rendered each loop locals.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.50",
id: "components-update-0-2-50",
description: "Adds reactive hydration support for server-rendered each loop locals.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.51",
id: "ui-package-repair",
description: "Repairs bundled UI component syntax and app-local stylesheet processing.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.52",
id: "shared-marketing-components",
description: "Adds reusable FAQ, announcement, and back-to-top UI components.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.53",
id: "faq-accordion-tailwind-safelist",
description: "Ensures conditional FAQ accordion layout utilities are generated by Tailwind.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.54",
id: "essential-ui-component-system",
description: "Adds the PDF-defined minimum and essential shared UI component system.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.55",
id: "expanded-ui-foundation-catalog",
description:
"Adds the expanded typography, layout, action, surface, card, and indicator catalog.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.56",
id: "ui-motion-system",
description:
"Adds consistent, accessible motion and modern interactions across the shared UI catalog.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.57",
id: "semantic-theme-palettes",
description:
"Adds eight semantic UI palettes and validated complete custom palette configuration.",
apply() {
// Existing theme configuration remains compatible; blue is the default palette.
},
},
{
version: "0.2.58",
id: "complete-ui-component-catalog",
description:
"Completes the PDF-aligned UI catalog and ships generated props, slots, and events metadata.",
apply() {
// UI components remain auto-discovered; no application files require migration.
},
},
{
version: "0.2.59",
id: "typed-wrn-declarations",
description:
"Adds explicit prop and state types, local type declarations, and typed component runtime validation.",
apply() {
// Existing inferred declarations remain compatible; typed syntax is opt-in.
},
},
{
version: "0.2.60",
id: "page-owned-shell-backgrounds",
description: "Lets pages own their backgrounds by keeping shared page shells transparent.",
apply() {
// The UI stylesheet update applies automatically.
},
},
{
version: "0.2.61",
id: "spacing-neutral-component-boundaries",
description:
"Removes default outer component spacing and standardizes root class customization.",
apply() {
// The UI component and stylesheet updates apply automatically.
},
},
{
version: "0.2.62",
id: "application-theme-contract-and-public-ui",
description:
"Exposes semantic theme helpers to application UI and promotes the public-site components into @wrnexus/ui.",
apply() {
// Package auto-discovery and the root-level theme aliases apply automatically.
},
},
{
version: "0.2.63",
id: "polished-linked-catalog-cards",
description:
"Fixes visually hidden full-card links and improves channel, integration, and campaign card hierarchy and interaction.",
apply() {
// The shared UI stylesheet update applies automatically.
},
},
{
version: "0.2.64",
id: "production-workspace-orchestrator",
description:
"Adds a first-class production command that builds, migrates, and serves every registered workspace app.",
apply() {
// Existing workspaces can invoke `wrnexus production` without generated-file changes.
},
},
{
version: "0.2.65",
id: "private-production-app-listeners",
description:
"Restricts workspace-managed production app servers to loopback so external clients must use the gateway.",
apply() {
// The production gateway applies the private bind address automatically.
},
},
{
version: "0.2.66",
id: "named-workspace-environments-and-reusable-public-chrome",
description:
"Adds top-level named workspace environment commands and data-driven public announcement, header, and footer components.",
apply() {
// Existing workspaces gain the CLI command aliases and optional UI props automatically.
},
},
{
version: "0.2.67",
id: "configurable-workspace-environment-runtime",
description:
"Adds per-environment runtime, HMR, build, migration, profile, hostname, and port controls to workspace commands.",
apply() {
// Existing workspace configurations remain compatible; new controls are opt-in.
},
},
{
version: "0.2.68",
id: "production-request-error-logging",
description:
"Logs production request exceptions, forward-auth verifier failures, proxy failures, and unexpected app exits with actionable context.",
apply() {
// Runtime diagnostics improve automatically after updating and redeploying.
},
},
{
version: "0.2.69",
id: "centralized-production-error-diagnostics",
description:
"Relays bounded internal app error diagnostics to gateway logs and strips them from public responses.",
apply() {
// Runtime diagnostics improve automatically after updating and rebuilding.
},
},
{
version: "0.2.70",
id: "direct-production-runtime-dependency",
description:
"Declares @wrnexus/dev-server directly in runnable apps so production bundles resolve the matching runtime release.",
apply(ctx) {
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 dependencies = (pkg.dependencies ??= {} as Record<string, string>);
if (dependencies["@wrnexus/dev-server"] === `^${ctx.to}`) return;
dependencies["@wrnexus/dev-server"] = `^${ctx.to}`;
ctx.log(`+ @wrnexus/dev-server ^${ctx.to}`);
if (!ctx.dryRun) writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n", "utf8");
},
},
{
version: "0.2.71",
id: "public-forward-auth-redirect-origins",
description:
"Builds SSO redirects and returnTo URLs from configured public app origins instead of internal gateway listeners.",
apply() {
// Public URL handling improves automatically after updating and rebuilding.
},
},
{
version: "0.2.72",
id: "fresh-linked-ui-compilation",
description:
"Refreshes the shared UI catalog and makes WRN compilation caches follow source content across package updates and linked dependencies.",
apply() {
// Component and cache behavior improves automatically after updating.
},
},
{
version: "0.2.73",
id: "polished-auth-component-visuals",
description:
"Updates shared authentication actions and notices with reliable built-in icons, semantic color treatments, and polished sizing.",
apply() {
// Shared UI visuals improve automatically after updating and rebuilding.
},
},
{
version: "0.2.74",
id: "wrn-imports-and-dynamic-public-shell",
description:
"Adds top-level WRN imports and data-driven public header and footer navigation, actions, links, and labels.",
apply() {
// Language and shared UI behavior improve automatically after updating.
},
},
{
version: "0.2.75",
id: "dev-toolbar",
description: "Dev toolbar for optimization and developer.",
apply() {
// Language and shared UI behavior improve automatically after updating.
},
},
{
version: "0.2.76",
id: "dev-toolbar-fix",
description: "Dev toolbar for optimization and developer.",
apply() {
// Language and shared UI behavior improve automatically after updating.
},
},
{
version: "0.2.77",
id: "ui-components-fix",
description: "Dev toolbar for optimization and developer.",
apply() {
// Language and shared UI behavior improve automatically after updating.
},
},
{
version: "0.2.78",
id: "ui-components-fix-2",
description: "Dev toolbar for optimization and developer.",
apply() {
// Language and shared UI behavior improve automatically after updating.
},
},
{
version: "0.2.79",
id: "ui-components-fix-3",
description: "Dev toolbar for optimization and developer.",
apply() {
// Language and shared UI behavior improve automatically after updating.
},
},
{
version: "0.3.0",
id: "language-foundation-and-compatible-platform-upgrade",
description:
"Adds the shared syntax/AST package, stable diagnostics, plugins, partial hydration, advanced routing, build analysis, tracing, typed data APIs, and safe WRN source normalization.",
apply(ctx) {
const runnable = existsSync(join(ctx.appRoot, "app", "pages"));
const packageFile = join(ctx.appRoot, "package.json");
const pkg = JSON.parse(readFileSync(packageFile, "utf8")) as Record<string, any>;
const scripts = (pkg.scripts ??= {});
const dependencies = (pkg.dependencies ??= {});
const addedScripts: string[] = [];
const addedDependencies: string[] = [];
if (runnable) {
for (const [name, command] of Object.entries({
doctor: "wrnexus doctor .",
"config:explain": "wrnexus config . --explain",
analyze: "wrnexus analyze .",
"update:preview": "wrnexus update . --dry-run",
})) {
if (!scripts[name]) {
scripts[name] = command;
addedScripts.push(name);
}
}
for (const name of ["@wrnexus/syntax", "@wrnexus/plugin"]) {
if (!dependencies[name]) {
dependencies[name] = `^${ctx.to}`;
addedDependencies.push(name);
}
}
}
if (addedScripts.length) ctx.log(`+ package scripts: ${addedScripts.join(", ")}`);
if (addedDependencies.length) ctx.log(`+ dependencies: ${addedDependencies.join(", ")}`);
if (!ctx.dryRun && (addedScripts.length || addedDependencies.length)) {
writeFileSync(packageFile, JSON.stringify(pkg, null, 2) + "\n", "utf8");
}
const changedFiles: string[] = [];
for (const file of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) {
const before = readFileSync(file, "utf8");
const after = migrateWrnSource(before);
if (after === before) continue;
changedFiles.push(file.slice(ctx.appRoot.length + 1).replace(/\\/g, "/"));
ctx.log(`~ normalized ${changedFiles[changedFiles.length - 1]}`);
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
}
const reportFile = join(ctx.appRoot, ".wrnexus", "migrations", "0.3.0.json");
ctx.log(`+ .wrnexus/migrations/0.3.0.json (${changedFiles.length} source files normalized)`);
if (!ctx.dryRun) {
mkdirSync(dirname(reportFile), { recursive: true });
writeFileSync(
reportFile,
JSON.stringify(
{
version: "0.3.0",
from: ctx.from,
appliedAt: new Date().toISOString(),
changedFiles,
compatibility: {
legacyDataFor: true,
legacyEachBlocks: true,
legacyComponentMounts: true,
quotedDynamicAttributes: true,
},
},
null,
2,
) + "\n",
"utf8",
);
}
},
},
{
version: "0.3.1",
id: "pending-update-marker-verification-fix",
description:
"Allows warning-level doctor checks during pre-commit update verification so successfully verified migrations can commit their new version marker.",
apply() {
// CLI-only transaction fix. No generated application files require migration.
},
},
{
version: "0.3.2",
id: "ssr-page-state-control-scope",
description: "Makes page state variables available to server-rendered if and each expressions.",
apply() {
// Compiler-only fix. Existing WRN source files require no migration.
},
},
{
version: "0.3.3",
id: "component-fix-0-3-3",
description: "UI component fixes.",
apply() {
// Compiler-only fix. Existing WRN source files require no migration.
},
},
{
version: "0.3.4",
id: "component-fix-0-3-4",
description: "UI component fixes.",
apply() {
// Compiler-only fix. Existing WRN source files require no migration.
},
},
{
version: "0.3.5",
id: "new-component-added-0-3-5",
description: "UI component Added.",
apply() {
// Compiler-only fix. Existing WRN source files require no migration.
},
},
{
version: "0.3.6",
id: "new-component-added-0-3-6",
description: "UI component Added.",
apply() {
// Compiler-only fix. Existing WRN source files require no migration.
},
},
{
version: "0.4.0",
id: "package-runtime-and-asset-platform",
description:
"Enables automatic package discovery, package components/routes/migrations, and page-scoped client runtime injection without manually copied JavaScript assets.",
apply(ctx) {
const packageFile = join(ctx.appRoot, "package.json");
const pkg = JSON.parse(readFileSync(packageFile, "utf8")) as Record<string, any>;
const scripts = (pkg.scripts ??= {});
const additions: Record<string, string> = {
"inspect:plugins": "wrnexus inspect plugins .",
"inspect:runtimes": "wrnexus inspect runtimes .",
"inspect:assets": "wrnexus inspect assets .",
"inspect:routes": "wrnexus inspect routes .",
};
const addedScripts: string[] = [];
for (const [name, command] of Object.entries(additions)) {
if (!scripts[name]) {
scripts[name] = command;
addedScripts.push(name);
}
}
if (addedScripts.length) ctx.log(`+ package scripts: ${addedScripts.join(", ")}`);
if (!ctx.dryRun && addedScripts.length) {
writeFileSync(packageFile, JSON.stringify(pkg, null, 2) + "\n", "utf8");
}
const changedFiles: string[] = [];
const legacyCaptchaScript =
/\s*<script\b[^>]*\bsrc\s*=\s*["']\/(?:__wrnexus\/captcha|assets\/wrnexus\/captcha)\.js(?:\?[^"']*)?["'][^>]*>(?:\s*<\/script>)?\s*/gi;
for (const file of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) {
const before = readFileSync(file, "utf8");
const after = before.replace(legacyCaptchaScript, "\n");
if (after === before) continue;
const relativeFile = file.slice(ctx.appRoot.length + 1).replace(/\\/g, "/");
changedFiles.push(relativeFile);
ctx.log(`~ removed legacy CAPTCHA script tag from ${relativeFile}`);
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
}
const movedAssets: string[] = [];
for (const relativeFile of [
"public/assets/wrnexus/captcha.js",
"public/__wrnexus/captcha.js",
]) {
const source = join(ctx.appRoot, relativeFile);
if (!existsSync(source)) continue;
const destination = join(ctx.appRoot, ".wrnexus", "legacy-assets", "0.4.0", relativeFile);
movedAssets.push(relativeFile);
ctx.log(`~ archived legacy ${relativeFile}`);
if (!ctx.dryRun) {
mkdirSync(dirname(destination), { recursive: true });
cpSync(source, destination);
rmSync(source, { force: true });
}
}
const reportFile = join(ctx.appRoot, ".wrnexus", "migrations", "0.4.0.json");
ctx.log(
`+ .wrnexus/migrations/0.4.0.json (${changedFiles.length} source files, ${movedAssets.length} legacy assets)`,
);
if (!ctx.dryRun) {
mkdirSync(dirname(reportFile), { recursive: true });
writeFileSync(
reportFile,
JSON.stringify(
{
version: "0.4.0",
from: ctx.from,
appliedAt: new Date().toISOString(),
changedFiles,
movedAssets,
packageRuntimeDiscovery: true,
manualCaptchaRuntimeRequired: false,
},
null,
2,
) + "\n",
"utf8",
);
}
},
},
];
/** Release tooling uses this to require an explicit migration entry per version. */
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[] {
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: (message: string) => void,
): void {
const publicDir = join(appRoot, "public");
const llms = join(publicDir, "llms.txt");
if (!existsSync(llms)) {
log("+ public/llms.txt created");
if (!dryRun) {
mkdirSync(publicDir, {
recursive: true,
});
writeFileSync(llms, AI_GUIDE, "utf8");
}
}
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). */
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",
".prettierignore",
".vscode/settings.json",
".vscode/extensions.json",
"CLAUDE.md",
"public/llms.txt",
"app",
]) {
const source = join(appRoot, name);
if (existsSync(source)) {
const destination = join(backup, name);
mkdirSync(dirname(destination), { recursive: true });
cpSync(source, destination, { recursive: statSync(source).isDirectory() });
}
}
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 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");
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 });
}
}
// 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 };
}
export function verificationCommands(scripts: Record<string, string>): string[][] {
const commands: string[][] = [];
// Update migrations can rewrite TypeScript, JSON, and framework configuration
// files. Normalize those edits with the project's own formatter before running
// the read-only format check. This prevents a successful migration from failing
// only because wrnexus.config.ts or another touched file needs Prettier output.
if (scripts.format) commands.push(["run", "format"]);
if (scripts.check) commands.push(["run", "check"]);
if (scripts.build) commands.push(["run", "build"]);
return commands;
}
export function blockingVerificationChecks(checks: DoctorCheck[]): DoctorCheck[] {
return checks.filter((check) => !check.ok && check.level !== "warning");
}
function verifyApp(app: UpdatedApp): boolean {
const health = inspectProject(app.root);
const failed = blockingVerificationChecks(health);
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>;
for (const args of verificationCommands(scripts)) {
const action = args[1] === "format" ? "Formatting" : "Verifying";
console.log(`\n ${action} ${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;
}
/** 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 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];
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`);
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);
}
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`);
}