863 lines
30 KiB
TypeScript
863 lines
30 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: they only add/refresh framework-owned things and
|
|
* never clobber your own code or edited CLAUDE.md. Add new ones to `MIGRATIONS`
|
|
* as the framework evolves — that is how "new things" reach existing apps.
|
|
*/
|
|
|
|
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { spawnSync } from "node:child_process";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts";
|
|
import { inspectProject } from "./doctor.ts";
|
|
|
|
/** The version of the CLI currently running (its own package.json). */
|
|
function cliVersion(): string {
|
|
try {
|
|
return JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")).version;
|
|
} catch {
|
|
return "0.0.0";
|
|
}
|
|
}
|
|
|
|
/** Query the registry for the latest published `@wrnexus/cli` version. */
|
|
function latestPublished(): string | null {
|
|
try {
|
|
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
const out = spawnSync(npm, ["view", "@wrnexus/cli", "version"], { encoding: "utf8" });
|
|
const v = (out.stdout ?? "").trim();
|
|
return /^\d+\.\d+\.\d+/.test(v) ? v : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Numeric compare of `x.y.z` (pre-release/build tags ignored). */
|
|
function cmp(a: string, b: string): number {
|
|
const pa = a.split("-")[0]!.split(".").map(Number);
|
|
const pb = b.split("-")[0]!.split(".").map(Number);
|
|
for (let i = 0; i < 3; i++) {
|
|
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
if (d) return d > 0 ? 1 : -1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
interface MigrationCtx {
|
|
appRoot: string;
|
|
from: string;
|
|
to: string;
|
|
dryRun: boolean;
|
|
log: (msg: string) => void;
|
|
}
|
|
|
|
interface Migration {
|
|
/** Framework version that introduced this change. Runs when `from < version <= to`. */
|
|
version: string;
|
|
id: string;
|
|
description: string;
|
|
apply: (ctx: MigrationCtx) => void;
|
|
}
|
|
|
|
const VSCODE_EXTENSIONS = `{
|
|
"recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
|
|
}
|
|
`;
|
|
|
|
const LEGACY_VSCODE_EXTENSIONS =
|
|
JSON.stringify(
|
|
{
|
|
recommendations: ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"],
|
|
},
|
|
null,
|
|
2,
|
|
) + "\n";
|
|
|
|
/**
|
|
* Versioned, idempotent upgrade steps. Each MUST be safe to re-run and MUST NOT
|
|
* overwrite user code. Append new entries with the version that ships them.
|
|
*/
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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.
|
|
},
|
|
},
|
|
];
|
|
|
|
/** 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: (m: string) => void): void {
|
|
// Public llms.txt is generated and web-visible, so it is safe to refresh.
|
|
const llms = join(appRoot, "public", "llms.txt");
|
|
if (!existsSync(llms) || readFileSync(llms, "utf8") !== AI_GUIDE) {
|
|
log("~ public/llms.txt refreshed");
|
|
if (!dryRun) {
|
|
mkdirSync(join(appRoot, "public"), { recursive: true });
|
|
writeFileSync(llms, AI_GUIDE, "utf8");
|
|
}
|
|
}
|
|
// CLAUDE.md is often user-edited — only create it when absent.
|
|
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",
|
|
]) {
|
|
const source = join(appRoot, name);
|
|
if (existsSync(source)) {
|
|
const destination = join(backup, name);
|
|
mkdirSync(dirname(destination), { recursive: true });
|
|
cpSync(source, destination);
|
|
}
|
|
}
|
|
return backup;
|
|
}
|
|
|
|
/** Update one app without marking success until install and verification pass. */
|
|
export function updateApp(appRoot: string, target: string, dryRun: boolean): UpdatedApp | null {
|
|
const pkgPath = join(appRoot, "package.json");
|
|
if (!existsSync(pkgPath)) {
|
|
console.log(` ⚠ ${appRoot}: no package.json — skipped`);
|
|
return null;
|
|
}
|
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
|
|
const from = appVersion(appRoot, pkg);
|
|
const log = (m: string) => console.log(` ${m}`);
|
|
console.log(` ▸ ${pkg.name ?? appRoot} (${from} → ${target})`);
|
|
|
|
if (!dryRun) log(`backup: ${backupProjectFiles(appRoot, from, target)}`);
|
|
|
|
const changes = bumpDeps(pkg, target);
|
|
changes.forEach((c) => log(c));
|
|
if (!changes.length) log("dependencies already current");
|
|
|
|
refreshFrameworkFiles(appRoot, dryRun, log);
|
|
|
|
for (const m of MIGRATIONS) {
|
|
if (cmp(m.version, from) > 0 && cmp(m.version, target) <= 0) {
|
|
m.apply({ appRoot, from, to: target, dryRun, log });
|
|
}
|
|
}
|
|
|
|
// A migration may have conservatively edited package.json. Merge those
|
|
// changes back before writing the dependency bump so neither side is lost.
|
|
if (!dryRun) {
|
|
Object.assign(pkg, JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>);
|
|
bumpDeps(pkg, target);
|
|
}
|
|
if (!dryRun) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
|
return { root: appRoot, packagePath: pkgPath, pkg };
|
|
}
|
|
|
|
function verifyApp(app: UpdatedApp): boolean {
|
|
const health = inspectProject(app.root);
|
|
const failed = health.filter((check) => !check.ok && check.name !== "configuration");
|
|
if (failed.length) {
|
|
for (const check of failed) console.error(` ✗ ${check.name}: ${check.detail}`);
|
|
return false;
|
|
}
|
|
const scripts = (app.pkg.scripts ?? {}) as Record<string, string>;
|
|
const commands: string[][] = [];
|
|
if (scripts.check) commands.push(["run", "check"]);
|
|
if (scripts.build) commands.push(["run", "build"]);
|
|
for (const args of commands) {
|
|
console.log(`\n Verifying ${app.pkg.name ?? app.root}: bun ${args.join(" ")}…`);
|
|
const result = spawnSync(process.execPath, args, { cwd: app.root, stdio: "inherit" });
|
|
if (result.status !== 0) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Load a `wrnexus.workspace.ts`, returning its app dirs (or null if not a workspace). */
|
|
async function workspaceApps(root: string): Promise<string[] | null> {
|
|
for (const f of ["wrnexus.workspace.ts", "wrnexus.workspace.js", "wrnexus.workspace.mjs"]) {
|
|
const path = join(root, f);
|
|
if (!existsSync(path)) continue;
|
|
const mod = (await import(pathToFileURL(path).href)) as {
|
|
default?: { apps?: { dir: string }[] };
|
|
};
|
|
return (mod.default?.apps ?? []).map((a) => resolve(root, a.dir));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function runUpdate(dir: string, args: string[]): Promise<void> {
|
|
const root = resolve(dir);
|
|
const dryRun = args.includes("--dry-run");
|
|
const verify = !args.includes("--no-verify");
|
|
const versionArg = args.find((a) => a.startsWith("--version="))?.split("=")[1];
|
|
const target =
|
|
versionArg ?? (args.includes("--latest") ? latestPublished() : null) ?? cliVersion();
|
|
|
|
// Always let the target CLI apply its own migrations. Without this handoff,
|
|
// an older installed CLI could bump dependency versions but would not know
|
|
// about syntax/config migrations shipped by the newer release.
|
|
if (!args.includes("--delegated") && cmp(target, cliVersion()) > 0) {
|
|
console.log(`\n Fetching WRNexusJS CLI ${target} to run its project migrations…`);
|
|
const forwarded = args.filter(
|
|
(arg) => !arg.startsWith("--version=") && arg !== "--latest" && arg !== "--delegated",
|
|
);
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[
|
|
"x",
|
|
`@wrnexus/cli@${target}`,
|
|
"update",
|
|
dir,
|
|
`--version=${target}`,
|
|
"--delegated",
|
|
...forwarded,
|
|
],
|
|
{ cwd: root, stdio: "inherit" },
|
|
);
|
|
if (result.status !== 0) process.exitCode = result.status ?? 1;
|
|
return;
|
|
}
|
|
|
|
console.log(`\n ⚡ wrnexus update → ${target}${dryRun ? " (dry run)" : ""}\n`);
|
|
|
|
// Workspace → update the root manifest + every app; else just this app.
|
|
const apps = await workspaceApps(root);
|
|
const targets = apps ? [root, ...apps] : [root];
|
|
const updated = targets.map((t) => updateApp(t, target, dryRun)).filter(Boolean) as UpdatedApp[];
|
|
|
|
if (dryRun) {
|
|
console.log(`\n Dry run — no files written. Re-run without --dry-run to apply.\n`);
|
|
return;
|
|
}
|
|
|
|
// One install at the top (Bun workspaces hoist), using the bun that's running us.
|
|
console.log(`\n Installing…`);
|
|
const res = spawnSync(process.execPath, ["install"], { cwd: root, stdio: "inherit" });
|
|
if (res.status !== 0) {
|
|
console.error(`\n ⚠ bun install exited with ${res.status}. Fix the error and re-run.`);
|
|
process.exit(res.status ?? 1);
|
|
}
|
|
|
|
if (verify) {
|
|
const appsToVerify = apps ? updated.filter((app) => app.root !== root) : updated;
|
|
for (const app of appsToVerify) {
|
|
if (!verifyApp(app)) {
|
|
console.error(
|
|
`\n ✗ Update files were applied, but verification failed. Fix the reported error and re-run wrnexus update.`,
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const app of updated) {
|
|
app.pkg.wrnexus = { ...(app.pkg.wrnexus as object), version: target };
|
|
writeFileSync(app.packagePath, JSON.stringify(app.pkg, null, 2) + "\n", "utf8");
|
|
}
|
|
|
|
console.log(`\n ✓ Updated and verified ${updated.length} package(s) at ${target}.`);
|
|
console.log(` Backups are under .wrnexus/update-backups/. Review and redeploy.\n`);
|
|
}
|