- generate-complete-framework-report.mjs no longer claims legacyEmit/ legacyEventProps/legacyComponentDiscovery/stringLayouts/ functions.legacyDefaultRuntime are usable compatibility flags; they were removed before the first public release and a config setting them is now rejected. Regenerated docs/WRNEXUS-COMPLETE-FEATURE-REPORT.md. - Restored the update.test.ts coverage lost in the sub-0.8.0 migration cleanup: a 0.8.x fixture now asserts refreshFrameworkFiles' still-live behaviour (public/llms.txt and CLAUDE.md creation) and that pkg.wrnexus.version stays at its old value after an unverified update. The .gitignore refresh and build/start/production script backfill were themselves removed as part of dropping the sub-0.8.0 migrations that implemented them, so there is nothing left to cover for those two. - updateApp now logs a clear warning when the detected project version is below 0.8.0, naming the version and stating that automated migration from below 0.8.0 is no longer supported, without failing the command (a marker-less app, like examples/basic-app, is benign and must still upgrade cleanly). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1185 lines
44 KiB
TypeScript
1185 lines
44 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.
|
|
*/
|
|
|
|
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.
|
|
},
|
|
},
|
|
];
|
|
|
|
/** 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`);
|
|
}
|