2439 lines
88 KiB
TypeScript
2439 lines
88 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 { 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";
|
|
|
|
/** 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;
|
|
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;
|
|
}
|
|
|
|
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))
|
|
.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);
|
|
}
|
|
|
|
function updateV060Config(ctx: MigrationCtx): void {
|
|
const candidates = ["wrnexus.config.ts", "wrnexus.config.js", "wrnexus.config.mjs"];
|
|
const file = candidates.map((name) => join(ctx.appRoot, name)).find(existsSync);
|
|
if (!file) return;
|
|
const current = readFileSync(file, "utf8");
|
|
if (/\bimports\s*:/.test(current) && /\bcompatibility\s*:/.test(current)) return;
|
|
const insertion = `\n imports: { mode: "compatible", autoImport: true, aliases: { "@": "./app" } },\n types: { strict: false, noImplicitAny: false, strictNullChecks: true, checkTemplates: true, checkComponentProps: true, generateDeclarations: true, globalTypes: "./app/types/global.d.ts" },\n functions: { legacyDefaultRuntime: "current" },\n stores: { strictMutations: true, persistence: true },\n compatibility: { legacyEmit: true, legacyEventProps: true, legacyComponentDiscovery: true, stringLayouts: true },`;
|
|
const index = current.lastIndexOf("}");
|
|
if (index < 0) return;
|
|
const next =
|
|
current.slice(0, index).replace(/,?\s*$/, "") + "," + insertion + "\n" + current.slice(index);
|
|
ctx.log(`~ ${file.slice(ctx.appRoot.length + 1)}: v0.6 compatibility configuration`);
|
|
if (!ctx.dryRun) writeFileSync(file, next, "utf8");
|
|
}
|
|
|
|
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]);
|
|
}
|
|
|
|
function writeMigrationReport(ctx: MigrationCtx): void {
|
|
const file = join(ctx.appRoot, ".wrnexus", "migrations", "0.6.0-report.json");
|
|
ctx.log(`+ .wrnexus/migrations/0.6.0-report.json`);
|
|
if (ctx.dryRun) return;
|
|
mkdirSync(dirname(file), { recursive: true });
|
|
writeFileSync(
|
|
file,
|
|
JSON.stringify(
|
|
{
|
|
version: "0.6.0",
|
|
from: ctx.from,
|
|
to: ctx.to,
|
|
appliedAt: new Date().toISOString(),
|
|
...ctx.report,
|
|
},
|
|
null,
|
|
2,
|
|
) + "\n",
|
|
"utf8",
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Versioned, idempotent upgrade steps. Each MUST be safe to re-run. Source
|
|
* migrations must preserve semantics and are protected by update backups.
|
|
*/
|
|
|
|
function updateV070Config(ctx: MigrationCtx): void {
|
|
const candidates = ["wrnexus.config.ts", "wrnexus.config.js", "wrnexus.config.mjs"];
|
|
const file = candidates.map((name) => join(ctx.appRoot, name)).find(existsSync);
|
|
if (!file) return;
|
|
const current = readFileSync(file, "utf8");
|
|
const additions: string[] = [];
|
|
if (!/\bnavigation\s*:/.test(current)) {
|
|
additions.push(` navigation: {
|
|
mode: "auto",
|
|
}`);
|
|
}
|
|
if (!/\bsecurity\s*:/.test(current)) {
|
|
additions.push(` security: {
|
|
headers: true,
|
|
requestLimits: {
|
|
maxUrlLength: 8192,
|
|
maxHeaderCount: 100,
|
|
maxHeaderBytes: 32768,
|
|
maxQueryParameters: 100,
|
|
maxBodyBytes: 10485760,
|
|
timeoutMs: 30000,
|
|
maxConcurrent: 1000,
|
|
fetchMetadata: true,
|
|
},
|
|
contentSecurityPolicy: { enabled: true, useDefaults: true },
|
|
trustedTypes: { enabled: true, requireForScript: true },
|
|
hsts: { enabled: true, maxAge: 63072000, includeSubDomains: true, preload: true },
|
|
}`);
|
|
}
|
|
if (!/\bperformance\s*:/.test(current)) {
|
|
additions.push(` performance: {
|
|
enforcement: "warn",
|
|
analyze: true,
|
|
budgets: {
|
|
routeJsBytes: 51200,
|
|
routeCssBytes: 25600,
|
|
htmlBytes: 204800,
|
|
hydrationMs: 200,
|
|
serverRenderMs: 500,
|
|
lcpMs: 2500,
|
|
inpMs: 200,
|
|
cls: 0.1,
|
|
ttfbMs: 800,
|
|
},
|
|
}`);
|
|
}
|
|
if (!/\bobservability\s*:/.test(current)) {
|
|
additions.push(` observability: {
|
|
enabled: true,
|
|
serverTiming: true,
|
|
sampleRate: 0.1,
|
|
exporter: "none",
|
|
webVitals: true,
|
|
}`);
|
|
}
|
|
if (!additions.length) return;
|
|
const index = current.lastIndexOf("}");
|
|
if (index < 0) return;
|
|
const before = current.slice(0, index).replace(/,?\s*$/, "");
|
|
const next = `${before},\n${additions.join(",\n")}\n${current.slice(index)}`;
|
|
ctx.log(`~ ${file.slice(ctx.appRoot.length + 1)}: security/performance production defaults`);
|
|
if (!ctx.dryRun) writeFileSync(file, next, "utf8");
|
|
}
|
|
|
|
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",
|
|
);
|
|
}
|
|
},
|
|
},
|
|
{
|
|
version: "0.5.0",
|
|
id: "custom-ui-components-and-auth-platform",
|
|
description:
|
|
"Adds the authentication package, custom form controls, expanded UI component events, and reliable development hot reloading.",
|
|
apply() {
|
|
// Package, component, and development-runtime improvements apply automatically.
|
|
},
|
|
},
|
|
{
|
|
version: "0.5.1",
|
|
id: "native-wrn-json-expressions",
|
|
description:
|
|
"Adds native array and object state initializers plus JSX-style expression props in WRN component markup.",
|
|
apply() {
|
|
// Syntax and compiler support are package-managed; existing applications remain compatible.
|
|
},
|
|
},
|
|
{
|
|
version: "0.5.2",
|
|
id: "multiline-wrn-json-and-stable-gateway-runtime",
|
|
description:
|
|
"Supports formatted multiline array and object state initializers and keeps gateway children on the matching framework runtime.",
|
|
apply() {
|
|
// Syntax, compiler, and gateway fixes are package-managed; no application rewrite is required.
|
|
},
|
|
},
|
|
{
|
|
version: "0.5.3",
|
|
id: "public-workspace-origins-and-schema-backed-auth-forms",
|
|
description:
|
|
"Keeps cross-app redirects on configured public origins and enables schema-backed auth forms with active navigation states.",
|
|
apply() {
|
|
// Gateway, helper, validation, and UI improvements are package-managed.
|
|
},
|
|
},
|
|
{
|
|
version: "0.5.10",
|
|
id: "latest-release-after-upgrade",
|
|
description: "Latest New Packages and release ready packages",
|
|
apply() {
|
|
// Gateway, helper, validation, and UI improvements are package-managed.
|
|
},
|
|
},
|
|
{
|
|
version: "0.5.11",
|
|
id: "latest-release-after-upgrade",
|
|
description: "Latest New Packages and release ready packages",
|
|
apply() {
|
|
// Gateway, helper, validation, and UI improvements are package-managed.
|
|
},
|
|
},
|
|
{
|
|
version: "0.5.11",
|
|
id: "latest-release-after-upgrade",
|
|
description: "Latest New Packages and release ready packages",
|
|
apply() {
|
|
// Gateway, helper, validation, and UI improvements are package-managed.
|
|
},
|
|
},
|
|
{
|
|
version: "0.5.12",
|
|
id: "latest-release-after-upgrade",
|
|
description: "Latest New Packages and release ready packages",
|
|
apply() {
|
|
// Gateway, helper, validation, and UI improvements are package-managed.
|
|
},
|
|
},
|
|
{
|
|
version: "0.5.13",
|
|
id: "latest-release-after-upgrade",
|
|
description: "Latest New Packages and release ready packages",
|
|
apply() {
|
|
// Gateway, helper, validation, and UI improvements are package-managed.
|
|
},
|
|
},
|
|
{
|
|
version: "0.5.13",
|
|
id: "preserve-theme-accent-during-hmr",
|
|
description: "Preserves the user's active theme and accent palette during HMR morph updates.",
|
|
apply() {
|
|
// Runtime-only framework fix. No project files require migration.
|
|
},
|
|
},
|
|
{
|
|
version: "0.5.14",
|
|
id: "preserve-theme-accent-during-hmr",
|
|
description: "Preserves the user's active theme and accent palette during HMR morph updates.",
|
|
apply() {
|
|
// Runtime-only framework fix. No project files require migration.
|
|
},
|
|
},
|
|
{
|
|
version: "0.5.15",
|
|
id: "wrn-style-block-head-pipeline",
|
|
description:
|
|
"Promotes page, layout, and component style blocks into the document head after global CSS with CSP, HMR, and CSR navigation support.",
|
|
apply() {
|
|
// Compiler, SSR, dev-server, and CSR runtime changes apply automatically.
|
|
},
|
|
},
|
|
{
|
|
version: "0.6.0",
|
|
id: "0.6.0-01-parser-and-language",
|
|
description:
|
|
"Enables the v0.6 parser, AST, typed state, imports, stores, outputs, and runtime function grammar.",
|
|
apply() {},
|
|
},
|
|
{
|
|
version: "0.6.0",
|
|
id: "0.6.0-02-function-runtime",
|
|
description:
|
|
"Classifies unambiguous legacy functions as client, server, or shared while preserving ambiguous behavior.",
|
|
apply(ctx) {
|
|
for (const file of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) {
|
|
const before = readFileSync(file, "utf8");
|
|
const relative = file.slice(ctx.appRoot.length + 1).replace(/\\/g, "/");
|
|
const after = classifyLegacyFunctions(before, ctx.report, relative);
|
|
if (after === before) continue;
|
|
ctx.log(`~ ${relative}: runtime function modifiers`);
|
|
ctx.report.changedAutomatically.push(relative);
|
|
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
|
|
}
|
|
},
|
|
},
|
|
{
|
|
version: "0.6.0",
|
|
id: "0.6.0-03-typed-outputs",
|
|
description:
|
|
"Converts legacy @event declarations to typed outputs with safe unknown payload fallbacks.",
|
|
apply(ctx) {
|
|
for (const file of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) {
|
|
const before = readFileSync(file, "utf8");
|
|
const relative = file.slice(ctx.appRoot.length + 1).replace(/\\/g, "/");
|
|
const legacy = collectLegacyEvents(before);
|
|
const after = ensureOutputsBlock(legacy.source, legacy.outputs, ctx.report, relative);
|
|
if (after === before) continue;
|
|
ctx.log(`~ ${relative}: typed outputs`);
|
|
ctx.report.changedAutomatically.push(relative);
|
|
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
|
|
}
|
|
},
|
|
},
|
|
{
|
|
version: "0.6.0",
|
|
id: "0.6.0-04-remove-emit",
|
|
description:
|
|
"Migrates static $emit calls and event.detail consumers to output.name(payload) and payload.",
|
|
apply(ctx) {
|
|
for (const file of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) {
|
|
const before = readFileSync(file, "utf8");
|
|
const relative = file.slice(ctx.appRoot.length + 1).replace(/\\/g, "/");
|
|
const after = convertLegacyEmit(before, ctx.report, relative).replace(
|
|
/\bevent\.detail\b/g,
|
|
"payload",
|
|
);
|
|
if (after === before) continue;
|
|
ctx.log(`~ ${relative}: callable outputs`);
|
|
ctx.report.changedAutomatically.push(relative);
|
|
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
|
|
}
|
|
},
|
|
},
|
|
{
|
|
version: "0.6.0",
|
|
id: "0.6.0-05-explicit-imports",
|
|
description:
|
|
"Enables compatible explicit imports and records unresolved symbols without guessing.",
|
|
apply(ctx) {
|
|
if (!ctx.explicitImports) return;
|
|
const index = buildV060SymbolIndex(ctx.appRoot);
|
|
for (const path of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) {
|
|
const relativeFile = path.slice(ctx.appRoot.length + 1).replace(/\\/g, "/");
|
|
const before = readFileSync(path, "utf8");
|
|
const after = addExplicitImportsToSource(
|
|
before,
|
|
relativeFile,
|
|
ctx.appRoot,
|
|
index,
|
|
ctx.report,
|
|
);
|
|
if (after === before) continue;
|
|
ctx.log(`~ ${relativeFile}: explicit imports`);
|
|
if (!ctx.report.changedAutomatically.includes(relativeFile))
|
|
ctx.report.changedAutomatically.push(relativeFile);
|
|
if (!ctx.dryRun) writeFileSync(path, after, "utf8");
|
|
}
|
|
},
|
|
},
|
|
{
|
|
version: "0.6.0",
|
|
id: "0.6.0-06-layout-imports",
|
|
description:
|
|
"Converts safely resolvable string layouts to imported symbols while retaining compatibility.",
|
|
apply(ctx) {
|
|
if (!ctx.explicitImports) return;
|
|
const index = buildV060SymbolIndex(ctx.appRoot);
|
|
for (const path of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) {
|
|
const relativeFile = path.slice(ctx.appRoot.length + 1).replace(/\\/g, "/");
|
|
const before = readFileSync(path, "utf8");
|
|
const after = migrateImportedLayout(before, relativeFile, ctx.appRoot, index, ctx.report);
|
|
if (after === before) continue;
|
|
ctx.log(`~ ${relativeFile}: imported layout`);
|
|
if (!ctx.report.changedAutomatically.includes(relativeFile))
|
|
ctx.report.changedAutomatically.push(relativeFile);
|
|
if (!ctx.dryRun) writeFileSync(path, after, "utf8");
|
|
}
|
|
},
|
|
},
|
|
{
|
|
version: "0.6.0",
|
|
id: "0.6.0-07-component-contracts",
|
|
description: "Enables generated component, output, function, and declaration contracts.",
|
|
apply() {},
|
|
},
|
|
{
|
|
version: "0.6.0",
|
|
id: "0.6.0-08-store-support",
|
|
description: "Adds @wrnexus/store and @wrnexus/typecheck to runnable applications.",
|
|
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 ??= {});
|
|
const added: string[] = [];
|
|
for (const name of ["@wrnexus/store", "@wrnexus/typecheck"]) {
|
|
if (dependencies[name] === `^${ctx.to}`) continue;
|
|
dependencies[name] = `^${ctx.to}`;
|
|
added.push(name);
|
|
}
|
|
if (added.length) ctx.log(`+ dependencies: ${added.join(", ")}`);
|
|
if (!ctx.dryRun && added.length)
|
|
writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
|
},
|
|
},
|
|
{
|
|
version: "0.6.0",
|
|
id: "0.6.0-09-type-config",
|
|
description:
|
|
"Adds compatible imports, type checking, stores, and legacy behavior configuration.",
|
|
apply(ctx) {
|
|
updateV060Config(ctx);
|
|
const types = join(ctx.appRoot, "app", "types");
|
|
if (!existsSync(types)) {
|
|
ctx.log("+ app/types/global.d.ts");
|
|
if (!ctx.dryRun) {
|
|
mkdirSync(types, { recursive: true });
|
|
writeFileSync(
|
|
join(types, "global.d.ts"),
|
|
"// Application-wide ambient WRNexusJS types.\nexport {};\n",
|
|
"utf8",
|
|
);
|
|
}
|
|
}
|
|
},
|
|
},
|
|
{
|
|
version: "0.6.0",
|
|
id: "0.6.0-10-showcase-and-ui",
|
|
description: "Finalizes v0.6 source normalization and writes the detailed migration report.",
|
|
apply(ctx) {
|
|
for (const file of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) {
|
|
const before = readFileSync(file, "utf8");
|
|
const relative = file.slice(ctx.appRoot.length + 1).replace(/\\/g, "/");
|
|
const after = migrateWrnSource(before);
|
|
if (after === before) continue;
|
|
ctx.log(`~ ${relative}: final v0.6 normalization`);
|
|
if (!ctx.report.changedAutomatically.includes(relative))
|
|
ctx.report.changedAutomatically.push(relative);
|
|
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
|
|
}
|
|
writeMigrationReport(ctx);
|
|
},
|
|
},
|
|
{
|
|
version: "0.7.0",
|
|
id: "0.7.0-01-security-performance-foundation",
|
|
description:
|
|
"Adds secure-by-default request limits, caching, image optimization, observability, benchmarks, and production hardening packages.",
|
|
apply(ctx) {
|
|
const file = join(ctx.appRoot, "package.json");
|
|
if (existsSync(file)) {
|
|
const pkg = JSON.parse(readFileSync(file, "utf8")) as Record<string, any>;
|
|
const dependencies = (pkg.dependencies ??= {});
|
|
const devDependencies = (pkg.devDependencies ??= {});
|
|
const added: string[] = [];
|
|
for (const name of [
|
|
"@wrnexus/security",
|
|
"@wrnexus/cache",
|
|
"@wrnexus/image",
|
|
"@wrnexus/observability",
|
|
]) {
|
|
if (dependencies[name] === `^${ctx.to}`) continue;
|
|
dependencies[name] = `^${ctx.to}`;
|
|
added.push(name);
|
|
}
|
|
if (devDependencies["@wrnexus/benchmark"] !== `^${ctx.to}`) {
|
|
devDependencies["@wrnexus/benchmark"] = `^${ctx.to}`;
|
|
added.push("@wrnexus/benchmark (dev)");
|
|
}
|
|
if (added.length) {
|
|
ctx.log(`+ security/performance dependencies: ${added.join(", ")}`);
|
|
if (!ctx.dryRun) writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
|
}
|
|
}
|
|
updateV070Config(ctx);
|
|
const gitignore = join(ctx.appRoot, ".gitignore");
|
|
const currentIgnore = existsSync(gitignore) ? readFileSync(gitignore, "utf8") : "";
|
|
const ignoreEntries = [".env", ".env.*", "!.env.example", "!.env.*.example"];
|
|
const knownIgnore = new Set(currentIgnore.split(/\r?\n/).map((line) => line.trim()));
|
|
const missingIgnore = ignoreEntries.filter((entry) => !knownIgnore.has(entry));
|
|
if (missingIgnore.length) {
|
|
ctx.log(`+ .gitignore secure environment templates: ${missingIgnore.join(", ")}`);
|
|
if (!ctx.dryRun) {
|
|
writeFileSync(
|
|
gitignore,
|
|
`${currentIgnore.replace(/\s*$/, "")}\n${missingIgnore.join("\n")}\n`.replace(
|
|
/^\n+/,
|
|
"",
|
|
),
|
|
"utf8",
|
|
);
|
|
}
|
|
}
|
|
const review =
|
|
"Review WRNexusJS 0.7 security policy, CSP allowlists, tenant authorization, upload scanners, cache adapters, and production performance budgets.";
|
|
if (!ctx.report.needsReview.includes(review)) ctx.report.needsReview.push(review);
|
|
},
|
|
},
|
|
{
|
|
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 wire-* 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:
|
|
// wire-sidebar-shell, -items, -group, -toggle, -backdrop, -panel and
|
|
// -layout became wire-sidebar__*. Nesting via children still works.
|
|
//
|
|
// The layout, page and section components moved from Tailwind utility
|
|
// classes to wire-* 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: wire-catalog-*, wire-page-shell, wire-product-card,
|
|
// wire-legal-toc, wire-sdk-tabs, wire-cookie-* and wire-analytics-preview.
|
|
// Anything hand-written against those needs its own styles.
|
|
//
|
|
// Themes gain tokens that were referenced but never defined, including
|
|
// --wire-color-focus, --wire-color-surface-soft, --wire-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 wire-* 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 wire-* BEM
|
|
// classes or its data-size/data-variant attributes. List now emits
|
|
// select for non-link items as well as navigation items.
|
|
},
|
|
},
|
|
];
|
|
|
|
/** 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,
|
|
options: { explicitImports?: boolean; report?: MigrationReport } = {},
|
|
): 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}`);
|
|
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);
|
|
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);
|
|
}
|
|
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 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;
|
|
}
|
|
|
|
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 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 });
|
|
})
|
|
.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} package(s) at ${target}.`);
|
|
console.log(` Backups are under .wrnexus/update-backups/. Review and redeploy.\n`);
|
|
}
|