Files
WRNexusJS/packages/cli/src/update.ts
T
2026-08-20 11:45:40 +05:30

1233 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. 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 }`).
*
* 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).
*
* 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 { 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";
import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts";
import { inspectProject, type DoctorCheck } from "./doctor.ts";
import { generateApplicationTypes } from "./types.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;
}
}
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);
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;
explicitImports: boolean;
report: MigrationReport;
log: (msg: string) => void;
}
export interface MigrationReport {
changedAutomatically: string[];
needsReview: string[];
unresolvedImports: string[];
ambiguousFunctions: string[];
legacyOutputPayloads: string[];
parseFailures: string[];
}
interface Migration {
/** Framework version that introduced this change. Runs when `from < version <= to`. */
version: string;
id: string;
description: string;
apply: (ctx: MigrationCtx) => void;
}
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))
.replace(/[ \t]+$/gm, "")
.replace(/\r\n?/g, "\n")
.replace(/\n{3,}$/g, "\n\n")
.replace(/\s*$/, "\n");
}
export function formatCurrentWrnSource(source: string): string {
return formatWrn(migrateWrnSource(source), {
insertSpaces: true,
tabSize: 2,
printWidth: 100,
multilineAttributes: true,
});
}
function findMatching(source: string, open: number, openChar = "{", closeChar = "}"): number {
let depth = 0;
let quote = "";
for (let index = open; index < source.length; index++) {
const char = source[index]!;
if (quote) {
if (char === "\\") index++;
else if (char === quote) quote = "";
continue;
}
if (char === '"' || char === "'" || char === "`") quote = char;
else if (char === openChar) depth++;
else if (char === closeChar && --depth === 0) return index;
}
return -1;
}
function convertLegacyEmit(source: string, report: MigrationReport, file: string): string {
let output = "";
let cursor = 0;
while (true) {
const start = source.indexOf("$emit(", cursor);
if (start < 0) return output + source.slice(cursor);
output += source.slice(cursor, start);
const end = findMatching(source, start + 5, "(", ")");
if (end < 0) {
report.needsReview.push(`${file}: unterminated $emit call`);
return output + source.slice(start);
}
const args = source.slice(start + 6, end);
const match = args.match(/^\s*(["'])([A-Za-z_$][\w$:-]*)\1\s*(?:,\s*([\s\S]*))?$/);
if (!match) {
report.needsReview.push(`${file}: dynamic $emit requires manual review`);
output += source.slice(start, end + 1);
} else {
const payload = (match[3] ?? "").trim();
output += payload ? `output.${match[2]}(${payload})` : `output.${match[2]}()`;
}
cursor = end + 1;
}
}
function collectLegacyEvents(source: string): { source: string; outputs: string[] } {
const outputs: string[] = [];
const next = source.replace(
/(^|[\s;])@event\s+([A-Za-z_$][\w$]*)\s*=\s*function\b[ \t]*(?:\r?\n)?/gm,
(_whole, prefix: string, name: string) => {
outputs.push(name);
return prefix;
},
);
return { source: next, outputs };
}
function ensureOutputsBlock(
source: string,
names: string[],
report: MigrationReport,
file: string,
): string {
if (!names.length) return source;
const unique = [...new Set(names)];
const blockMatch = /\boutputs\s*\{/.exec(source);
if (blockMatch) {
const open = source.indexOf("{", blockMatch.index);
const close = findMatching(source, open);
if (close < 0) return source;
const body = source.slice(open + 1, close);
const missing = unique.filter((name) => !new RegExp(`\\b${name}\\s*\\(`).test(body));
if (!missing.length) return source;
const indent = source.slice(source.lastIndexOf("\n", blockMatch.index) + 1, blockMatch.index);
const additions = missing.map((name) => `${indent} ${name}(payload: unknown)`).join("\n");
for (const name of missing)
report.legacyOutputPayloads.push(`${file}: ${name}(payload: unknown)`);
return source.slice(0, close) + `\n${additions}\n${indent}` + source.slice(close);
}
const rootOpen = source.indexOf("{");
if (rootOpen < 0) return source;
const lineStart = source.lastIndexOf("\n", rootOpen) + 1;
const nonWhitespace = source.slice(lineStart).search(/\S/);
const rootIndent = nonWhitespace < 0 ? "" : source.slice(lineStart, lineStart + nonWhitespace);
const indent = `${rootIndent} `;
const block = `\n${indent}outputs {\n${unique.map((name) => `${indent} ${name}(payload: unknown)`).join("\n")}\n${indent}}\n`;
for (const name of unique) report.legacyOutputPayloads.push(`${file}: ${name}(payload: unknown)`);
return source.slice(0, rootOpen + 1) + block + source.slice(rootOpen + 1);
}
function classifyLegacyFunctions(source: string, report: MigrationReport, file: string): string {
const functionsMatch = /\bfunctions\s*\{/.exec(source);
if (!functionsMatch) return source;
const open = source.indexOf("{", functionsMatch.index);
const close = findMatching(source, open);
if (close < 0) return source;
const body = source.slice(open + 1, close);
let output = "";
let cursor = 0;
const header =
/(^|\n)([ \t]*)(?!client\s|server\s|shared\s)(async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)(?:\s*:\s*[^{\n]+)?\s*\{/g;
for (let match; (match = header.exec(body));) {
const bodyOpen = body.indexOf("{", match.index + match[0].length - 1);
const bodyClose = findMatching(body, bodyOpen);
if (bodyClose < 0) break;
const functionBody = body.slice(bodyOpen + 1, bodyClose);
const client =
/\b(window|document|localStorage|sessionStorage|navigator|HTMLElement|MouseEvent|KeyboardEvent|output\.|refs\.)\b/.test(
functionBody,
);
const server =
/\b(ctx|Bun|process|database|db\.|serverService|authService|request|response)\b/.test(
functionBody,
);
let runtime = "";
if (client && !server) runtime = "client ";
else if (server && !client) runtime = "server ";
else if (!client && !server && !/\b(fetch|Date\.now|Math\.random)\b/.test(functionBody))
runtime = "shared ";
else report.ambiguousFunctions.push(`${file}: ${match[4]}`);
output +=
body.slice(cursor, match.index) +
match[1] +
match[2] +
runtime +
(match[3] ?? "") +
`function ${match[4]}`;
const consumedHeader =
match[0].lastIndexOf(`function ${match[4]}`) + `function ${match[4]}`.length;
output += match[0].slice(consumedHeader);
cursor = match.index + match[0].length;
header.lastIndex = bodyClose + 1;
}
output += body.slice(cursor);
return source.slice(0, open + 1) + output + source.slice(close);
}
export function migrateV060WrnSource(
source: string,
report: MigrationReport,
file = "<inline>",
): string {
const legacy = collectLegacyEvents(source);
let next = ensureOutputsBlock(legacy.source, legacy.outputs, report, file);
next = convertLegacyEmit(next, report, file);
next = next.replace(/\bevent\.detail\b/g, "payload");
next = classifyLegacyFunctions(next, report, file);
return migrateWrnSource(next);
}
interface V060SymbolIndex {
components: Map<string, string[]>;
layouts: Map<string, string[]>;
ui: Set<string>;
}
function declarationName(file: string): string {
const source = readFileSync(file, "utf8");
return (
/\b(?:component|layout)\s+([A-Za-z_$][\w$]*)\s*\{/.exec(source)?.[1] ?? basename(file, ".wrn")
);
}
function addSymbol(index: Map<string, string[]>, name: string, file: string): void {
const current = index.get(name) ?? [];
current.push(file);
index.set(name, current);
}
function loadUiComponentNames(appRoot: string): Set<string> {
const candidates = [
join(appRoot, "node_modules", "@wrnexus", "ui", "component-reference.json"),
join(appRoot, "packages", "ui", "component-reference.json"),
];
for (const candidate of candidates) {
if (!existsSync(candidate)) continue;
try {
const parsed = JSON.parse(readFileSync(candidate, "utf8")) as {
components?: Array<{ name?: string }>;
};
return new Set(
(parsed.components ?? [])
.map((entry) => entry.name)
.filter((name): name is string => Boolean(name)),
);
} catch {
// Continue to an empty package index. The migration reports unresolved tags.
}
}
return new Set();
}
function buildV060SymbolIndex(appRoot: string): V060SymbolIndex {
const components = new Map<string, string[]>();
const layouts = new Map<string, string[]>();
for (const file of walkProjectFiles(join(appRoot, "app", "components"), ".wrn")) {
addSymbol(components, declarationName(file), file);
}
for (const file of walkProjectFiles(join(appRoot, "app", "layouts"), ".wrn")) {
const name = declarationName(file);
addSymbol(layouts, name, file);
addSymbol(layouts, basename(file, ".wrn"), file);
}
return { components, layouts, ui: loadUiComponentNames(appRoot) };
}
function importedLocalNames(source: string): Set<string> {
const names = new Set<string>();
for (const statement of source.matchAll(
/^import\s+(?:type\s+)?([\s\S]*?)\s+from\s+["'][^"']+["']\s*;?/gm,
)) {
const clause = statement[1]!.trim();
if (!clause.startsWith("{") && !clause.startsWith("*")) names.add(clause.split(",")[0]!.trim());
for (const named of clause.matchAll(
/(?:\{|,)\s*(?:type\s+)?([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?/g,
)) {
names.add(named[2] ?? named[1]!);
}
}
return names;
}
function importPathFor(appRoot: string, file: string): string {
return `@/${relative(join(appRoot, "app"), file).replace(/\\/g, "/")}`;
}
function insertImports(source: string, statements: string[]): string {
if (!statements.length) return source;
const unique = [...new Set(statements)].filter((statement) => !source.includes(statement));
if (!unique.length) return source;
const imports = Array.from(
source.matchAll(
/^import\s+[\s\S]*?(?:;\s*|\n(?=import|\s*(?:page|component|layout|global\s+store|page\s+store)\b))/gm,
),
);
const insertion = imports.length ? imports.at(-1)!.index! + imports.at(-1)![0].length : 0;
const prefix = insertion ? "\n" : "";
return (
source.slice(0, insertion) +
prefix +
unique.join("\n") +
"\n\n" +
source.slice(insertion).replace(/^\s*/, "")
);
}
function addExplicitImportsToSource(
source: string,
file: string,
appRoot: string,
index: V060SymbolIndex,
report: MigrationReport,
): string {
const imported = importedLocalNames(source);
const rootName =
/\b(?:page|component|layout|global\s+store|page\s+store)\s+([A-Za-z_$][\w$]*)/.exec(
source,
)?.[1];
const applicationImports: string[] = [];
const uiImports = new Set<string>();
const tags = new Set(
Array.from(source.matchAll(/<([A-Z][A-Za-z0-9_$]*)\b/g), (match) => match[1]!),
);
for (const name of tags) {
if (name === rootName || imported.has(name)) continue;
const candidates = index.components.get(name) ?? [];
if (candidates.length === 1) {
applicationImports.push(
`import ${name} from ${JSON.stringify(importPathFor(appRoot, candidates[0]!))}`,
);
imported.add(name);
} else if (candidates.length > 1) {
report.unresolvedImports.push(
`${file}: component '${name}' has ${candidates.length} application matches`,
);
} else if (index.ui.has(name)) {
uiImports.add(name);
imported.add(name);
} else {
report.unresolvedImports.push(`${file}: component '${name}' could not be resolved`);
}
}
if (uiImports.size) {
const existingUi = /import\s*\{([\s\S]*?)\}\s*from\s*["']@wrnexus\/ui["']\s*;?/.exec(source);
if (existingUi) {
const names = new Set([
...Array.from(
existingUi[1]!.matchAll(/(?:^|,)\s*([A-Za-z_$][\w$]*)/g),
(match) => match[1]!,
),
...uiImports,
]);
source = source.replace(
existingUi[0],
`import {\n${[...names]
.sort()
.map((name) => ` ${name},`)
.join("\n")}\n} from "@wrnexus/ui"`,
);
} else {
applicationImports.push(
`import {\n${[...uiImports]
.sort()
.map((name) => ` ${name},`)
.join("\n")}\n} from "@wrnexus/ui"`,
);
}
}
return insertImports(source, applicationImports);
}
function migrateImportedLayout(
source: string,
file: string,
appRoot: string,
index: V060SymbolIndex,
report: MigrationReport,
): string {
const match = /\blayout\s*=\s*["']([^"']+)["']/.exec(source);
if (!match || match[1] === "none") return source;
const candidates = index.layouts.get(match[1]) ?? [];
const unique = [...new Set(candidates)];
if (unique.length !== 1) {
report.unresolvedImports.push(
`${file}: layout '${match[1]}' ${unique.length ? "is ambiguous" : "was not found"}`,
);
return source;
}
const symbol = declarationName(unique[0]!);
const statement = `import ${symbol} from ${JSON.stringify(importPathFor(appRoot, unique[0]!))}`;
return insertImports(source.replace(match[0], `layout = ${symbol}`), [statement]);
}
/**
* Versioned, idempotent upgrade steps. Each MUST be safe to re-run. Source
* migrations must preserve semantics and are protected by update backups.
*/
/** Remove a `key: <scalar>` line from a config source, e.g. `compatibilityDate: "..."`. */
function removeScalarConfigKey(source: string, key: string): string {
return source.replace(new RegExp(`^[ \\t]*${key}:.*\\n`, "m"), "");
}
/**
* Remove a `key: { ... }` object property from a config source, using balanced-brace
* scanning so a nested `{ ... }` inside the value can't truncate the removal early.
*/
function removeObjectConfigKey(source: string, key: string): string {
const match = new RegExp(`^[ \\t]*${key}\\s*:\\s*\\{`, "m").exec(source);
if (!match) return source;
const lineStart = match.index;
const openBrace = match.index + match[0].length - 1;
const closeBrace = findMatching(source, openBrace);
if (closeBrace < 0) return source;
let end = closeBrace + 1;
while (source[end] === " " || source[end] === "\t") end++;
if (source[end] === ",") end++;
while (source[end] === " " || source[end] === "\t") end++;
if (source[end] === "\r") end++;
if (source[end] === "\n") end++;
return source.slice(0, lineStart) + source.slice(end);
}
const MIGRATIONS: Migration[] = [
{
version: "0.8.0",
id: "0.8.0-01-package-kits",
description:
"Adds package-owned helper kits, reusable UI blocks, standalone realtime rooms, repaired i18n, image helpers, JWT utilities, and encrypted HTTP envelopes.",
apply(ctx) {
const file = join(ctx.appRoot, "package.json");
if (!existsSync(file)) return;
const pkg = JSON.parse(readFileSync(file, "utf8")) as Record<string, any>;
const dependencies = (pkg.dependencies ??= {});
const additions = ["@wrnexus/realtime", "@wrnexus/csr"];
const added: string[] = [];
for (const name of additions) {
if (dependencies[name] === `^${ctx.to}`) continue;
dependencies[name] = `^${ctx.to}`;
added.push(name);
}
if (added.length) {
ctx.log(`+ package-kit dependencies: ${added.join(", ")}`);
if (!ctx.dryRun) writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n", "utf8");
}
const review =
"Review package-owned components and helpers, i18n locale layout, encrypted HTTP trust boundaries, and realtime room authorization before enabling them in production.";
if (!ctx.report.needsReview.includes(review)) ctx.report.needsReview.push(review);
},
},
{
version: "0.8.0",
id: "0.8.0-02-current-wrn-source",
description:
"Upgrades every application WRN source to current syntax, adds resolvable imports, normalizes formatting, and records unresolved work.",
apply(ctx) {
const appDirectory = join(ctx.appRoot, "app");
if (!existsSync(appDirectory)) return;
const index = buildV060SymbolIndex(ctx.appRoot);
const changedFiles: string[] = [];
for (const file of walkProjectFiles(appDirectory, ".wrn")) {
const relativeFile = relative(ctx.appRoot, file).replace(/\\/g, "/");
const before = readFileSync(file, "utf8");
let after = migrateV060WrnSource(before, ctx.report, relativeFile);
after = migrateImportedLayout(after, relativeFile, ctx.appRoot, index, ctx.report);
after = addExplicitImportsToSource(after, relativeFile, ctx.appRoot, index, ctx.report);
after = formatCurrentWrnSource(after);
if (after === before) continue;
try {
parse(after);
} catch (error) {
const message = error instanceof Error ? error.message.split("\n", 1)[0] : String(error);
ctx.report.parseFailures.push(`${relativeFile}: ${message}`);
ctx.report.needsReview.push(
`${relativeFile}: automatic modernization was skipped because the migrated source did not parse`,
);
ctx.log(`! ${relativeFile}: left unchanged because migrated source did not parse`);
continue;
}
changedFiles.push(relativeFile);
if (!ctx.report.changedAutomatically.includes(relativeFile)) {
ctx.report.changedAutomatically.push(relativeFile);
}
ctx.log(`~ ${relativeFile}: current WRN syntax, imports, and formatting`);
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
}
const reportFile = join(
ctx.appRoot,
".wrnexus",
"migrations",
"0.8.0-source-modernization.json",
);
ctx.log(
`+ .wrnexus/migrations/0.8.0-source-modernization.json (${changedFiles.length} WRN files updated)`,
);
if (!ctx.dryRun) {
mkdirSync(dirname(reportFile), { recursive: true });
writeFileSync(
reportFile,
JSON.stringify(
{
version: "0.8.0",
from: ctx.from,
to: ctx.to,
appliedAt: new Date().toISOString(),
changedFiles,
unresolvedImports: ctx.report.unresolvedImports,
ambiguousFunctions: ctx.report.ambiguousFunctions,
legacyOutputPayloads: ctx.report.legacyOutputPayloads,
parseFailures: ctx.report.parseFailures,
needsReview: ctx.report.needsReview,
},
null,
2,
) + "\n",
"utf8",
);
}
},
},
{
version: "0.8.1",
id: "0.8.1-production-performance",
description:
"Applies production build, CSS delivery, service-worker lifecycle, asset caching, compression, and server request-path optimizations.",
apply() {
// Package-managed production optimizations require no application source rewrite.
},
},
{
version: "0.8.2",
id: "0.8.2-navigation-and-vitals-stability",
description:
"Stops scroll-driven route prefetch storms, batches browser vitals, and supports telemetry behind trusted reverse proxies.",
apply() {
// Runtime stability fixes require no application source rewrite.
},
},
{
version: "0.8.3",
id: "0.8.3-client-ssr-realtime-stability",
description:
"Fixes browser function bundling, SSR computed values, Async aliases, typed loop props, realtime identity isolation, loader invalidation, and strict package imports.",
apply() {
// Framework runtime and compiler fixes require no application source rewrite.
// The compile-cache generation changes automatically invalidate old artifacts.
},
},
{
version: "0.8.4",
id: "0.8.4-editor-language-support-stability",
description:
"Fixes WRNexus Language Server crashes, WRN formatting stability, false closing-tag diagnostics, typed event inference, dynamic component prop validation, and VS Code extension reliability.",
apply() {
// Compiler, diagnostics, formatter, language-server, and VS Code
// extension fixes require no application source migration.
//
// Existing projects receive the framework package changes through the
// dependency update. Developers must separately update/reinstall the
// WRNexus Language Support VS Code extension.
},
},
{
version: "0.8.5",
id: "0.8.5-datatable-toaster-overlay-dialogs",
description:
"Adds the DataTable and Toaster components, removes the Table scaffold, and gives modal dialogs focus management, a Tab trap and a scroll lock.",
apply() {
// Applications using <Table> must move to <DataTable>. The two are not
// prop-compatible -- Table took `columns`/`rows` and rendered them as
// plain text, while DataTable owns sorting, filtering, paging and
// selection -- so this is a source change no codemod can make safely.
//
// The `@wrnexus/ui` main entry no longer re-exports the filesystem
// helpers; import them from `@wrnexus/ui/registry` instead. Everything
// else arrives through the dependency update.
},
},
{
version: "0.8.6",
id: "0.8.6-navigation-and-layout-groups",
description:
"Rebuilds the navigation and layout component groups, moves them off Tailwind utilities onto wrn-* classes, defines theme tokens that components referenced but nothing declared, and makes component outputs actually reach parent bindings.",
apply() {
// Source changes no codemod can make safely, so they are listed rather
// than attempted.
//
// OUTPUTS NOW ARRIVE. Two faults kept declared outputs from reaching a
// parent @binding, and both are fixed. Expect handlers that never ran
// before to start running -- this is the intended repair, but it is a
// behaviour change in code you may have written around.
//
// 1. Every camelCase output was undeliverable. HTML lowercases
// attribute names, so @sizeChange registered as "sizechange" while
// the component emitted "sizeChange" and the lookup missed. That
// covered all 17 camelCase outputs, including DataTable.pageChange
// and .rowClick, Map.markerClick, ChatBubble.messageClick and
// LayoutSplitter.sizeChange. The runtime now matches case
// insensitively.
//
// 2. Eighteen components dispatched hand-built CustomEvents instead of
// calling output.*, which never reaches a binding. Card, Footer,
// Breadcrumb, Accordion, alert, Badge, AnnouncementBar, AvatarGroup,
// ToggleCount and InputNumber now emit properly.
//
// If you worked around the old silence by listening for the raw DOM
// event on the element, that listener still fires for cases where no
// binding is registered, but the supported route is the @binding.
//
// Marquee, Map, Timeline, List and SearchBox now DECLARE the outputs
// they were already firing: pause/resume, markerClick/select/zoom,
// select, select and search/clear respectively.
//
// Tabs replaced its raw CustomEvents with declared outputs. Code
// listening for the old change and select events on the element must
// move to the @change and @select bindings.
//
// Sidebar renamed its classes to the BEM form used everywhere else:
// wrn-sidebar-shell, -items, -group, -toggle, -backdrop, -panel and
// -layout became wrn-sidebar__*. Nesting via children still works.
//
// The layout, page and section components moved from Tailwind utility
// classes to wrn-* classes with their own styles. Application CSS
// selecting on the utility classes they used to render -- max-w-7xl,
// gap-5, sm:grid-cols-2 and the rest -- no longer matches. Variants are
// data attributes now, so target [data-variant] and friends instead.
//
// LayoutSplitter and CustomScrollbar previously declared outputs they
// never emitted. LayoutSplitter now resizes and emits sizeChange rather
// than resizeStart, resize and resizeEnd, and its props are size,
// minSize, step and orientation rather than columns, gap and maxWidth.
// CustomScrollbar dropped its scroll output; listen for the plain scroll
// event on the element. Its props are axis, thickness, maxHeight and
// radius.
//
// ui.css lost several application-pattern class families that nothing
// referenced: wrn-catalog-*, wrn-page-shell, wrn-product-card,
// wrn-legal-toc, wrn-sdk-tabs, wrn-cookie-* and wrn-analytics-preview.
// Anything hand-written against those needs its own styles.
//
// Themes gain tokens that were referenced but never defined, including
// --wrn-color-focus, --wrn-color-surface-soft, --wrn-color-on-danger
// and the input-* family. A custom theme that declared these itself
// keeps winning; one that did not will see focus rings and soft surfaces
// start painting where they previously rendered as nothing.
},
},
{
version: "0.8.7",
id: "0.8.7-ui-component-depth",
description:
"Removes superseded UI scaffolds and moves the remaining utility-styled components to self-contained wrn-* BEM styles.",
apply() {
// AdvancedDatePicker -> DatePicker; AdvancedRangeSlider -> RangeSlider;
// FileUpload -> FileInput + FileUploadProgress (or @wrnexus/uploader);
// Toast and ToastNotifications -> Toaster.
//
// List, InputNumber, Marquee, TextLink, Map, SearchBox and Timeline no
// longer render Tailwind utility class names. Application CSS selecting
// those internal utilities must move to the component's wrn-* BEM
// classes or its data-size/data-variant attributes. List now emits
// select for non-link items as well as navigation items.
},
},
{
version: "0.8.8",
id: "0.8.8-editor-types-and-update-verification",
description:
"Adds typed page context support and regenerates lint-safe application declarations before update verification.",
apply() {
// No source rewrite is required. The 0.8.8 CLI regenerates application
// declarations immediately before verification, repairing stale empty
// 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.
},
},
{
version: "0.9.0",
id: "remove-dead-config-keys",
description: "Delete compatibilityDate, frameworkBehaviour, functions, and compatibility",
apply(ctx) {
const file = join(ctx.appRoot, "wrnexus.config.ts");
if (!existsSync(file)) return;
const before = readFileSync(file, "utf8");
let after = before;
after = removeScalarConfigKey(after, "compatibilityDate");
after = removeScalarConfigKey(after, "frameworkBehaviour");
after = removeObjectConfigKey(after, "functions");
after = removeObjectConfigKey(after, "compatibility");
if (after === before) return;
ctx.report.changedAutomatically.push(`${file}: removed dead compatibility keys`);
if (!ctx.dryRun) writeFileSync(file, after, "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 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[] = [];
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/")) 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;
}
}
}
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,
options: {
explicitImports?: boolean;
report?: MigrationReport;
packageVersions?: ReadonlyMap<string, string>;
} = {},
): 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}`);
if (cmp(from, "0.8.0") < 0) {
log(
`⚠ detected version ${from}: automated migration from below 0.8.0 is no longer supported. ` +
`Only 0.8.x+ migrations will run; review the project manually before relying on this update.`,
);
}
const report =
options.report ??
({
changedAutomatically: [],
needsReview: [],
unresolvedImports: [],
ambiguousFunctions: [],
legacyOutputPayloads: [],
parseFailures: [],
} satisfies MigrationReport);
console.log(` ▸ ${pkg.name ?? appRoot} (${from}${target})`);
if (!dryRun) log(`backup: ${backupProjectFiles(appRoot, from, target)}`);
const changes = bumpDeps(pkg, target, options.packageVersions);
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,
explicitImports: options.explicitImports ?? false,
report,
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, options.packageVersions);
}
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 {
// Generated declarations must reflect the newly installed CLI before the
// application's lint/typecheck scripts inspect them. This also repairs stale
// generated output left by an older framework version.
if (existsSync(join(app.root, "app", "pages"))) generateApplicationTypes(app.root);
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 explicitImports = args.includes("--explicit-imports");
const reportRequested = args.includes("--report");
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;
}
// 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) => {
const report: MigrationReport = {
changedAutomatically: [],
needsReview: [],
unresolvedImports: [],
ambiguousFunctions: [],
legacyOutputPayloads: [],
parseFailures: [],
};
reports.set(targetRoot, report);
return updateApp(targetRoot, target, dryRun, { explicitImports, report, packageVersions });
})
.filter(Boolean) as UpdatedApp[];
if (reportRequested || dryRun) {
for (const [targetRoot, report] of reports) {
console.log(`
Migration report: ${targetRoot}`);
console.log(` Changed automatically: ${new Set(report.changedAutomatically).size}`);
console.log(` Needs review: ${report.needsReview.length}`);
console.log(` Unresolved imports: ${report.unresolvedImports.length}`);
console.log(` Ambiguous function runtime: ${report.ambiguousFunctions.length}`);
console.log(
` Legacy output payloads using unknown: ${report.legacyOutputPayloads.length}`,
);
console.log(` Parse failures: ${report.parseFailures.length}`);
}
}
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} 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`);
}