diff --git a/apps/cli/src/commands/global.ts b/apps/cli/src/commands/global.ts index 8804479..46c4b9d 100644 --- a/apps/cli/src/commands/global.ts +++ b/apps/cli/src/commands/global.ts @@ -148,8 +148,18 @@ function renderSelfUpdate(info: SelfUpdateInfo): void { } else if (info.installFailed) { console.log(`${warn()} self-update failed — try: ${bold(manual)}`); } else if (info.updated) { + // Report the version that actually landed on disk, not the requested target. + const to = info.installedVersion ?? info.latestInMajor; + console.log(`${check()} Updated mx to ${bold(`v${to}`)} ${dim(`(was v${info.current})`)}`); + } else if (info.staleRegistry) { + // A newer version was advertised but the install couldn't fetch it — almost + // always a lagging registry mirror. Say so and point at a direct install. + const got = info.installedVersion ?? info.current; console.log( - `${check()} Updated mx to ${bold(`v${info.latestInMajor}`)} ${dim(`(was v${info.current})`)}`, + `${warn()} Registry advertises ${bold(`v${info.latestInMajor}`)} but installed ${bold(`v${got}`)} — your npm registry may be lagging.`, + ); + console.log( + ` ${dim('Install it directly:')} ${bold(`npm i -g ${info.package}@${info.latestInMajor} --registry https://registry.npmjs.org`)}`, ); } else { console.log(`${check()} mx is up to date ${dim(`(v${info.current}, latest in v${curMajor}.x)`)}`); diff --git a/apps/cli/src/selfupdate.ts b/apps/cli/src/selfupdate.ts index 91efdf1..6fb2cef 100644 --- a/apps/cli/src/selfupdate.ts +++ b/apps/cli/src/selfupdate.ts @@ -70,6 +70,34 @@ function latestOverall(): string | null { return typeof v === 'string' ? v : null; } +/** + * The version of `@rousan/mx` **actually installed** in the global prefix right + * now, read from `npm ls -g` (not the registry). This is the ground truth after + * an install: `npm i` and `npm view` can disagree when a registry advertises a + * version whose tarball it can't yet serve (a lagging mirror / proxy), so we must + * re-read what really landed rather than trust the requested target. + * + * `npm ls -g` can exit non-zero for unrelated reasons (peer warnings, extraneous + * packages) while still printing valid JSON, so we parse stdout regardless of + * status. + * + * @returns The installed version string, or null if it can't be determined. + */ +function installedGlobalVersion(): string | null { + const r = spawnSync('npm', ['ls', '-g', PKG, '--json', '--depth=0'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (!r.stdout) return null; + try { + const parsed = JSON.parse(r.stdout) as { dependencies?: Record }; + const v = parsed.dependencies?.[PKG]?.version; + return typeof v === 'string' ? v : null; + } catch { + return null; + } +} + /** * Outcome of a self-update attempt, surfaced to the CLI renderer / porcelain. */ @@ -82,10 +110,23 @@ export interface SelfUpdateInfo { npmAvailable: boolean; /** Newest version within the current major, or null if unknown. */ latestInMajor: string | null; - /** True when an install ran and succeeded. */ + /** + * The version actually installed in the global prefix after the install ran + * (read from `npm ls -g`, not the requested target), or null if no install ran + * or it couldn't be determined. + */ + installedVersion: string | null; + /** True when an install ran and a genuinely newer version actually landed. */ updated: boolean; /** True when an install was attempted but failed. */ installFailed: boolean; + /** + * True when a newer in-major version was advertised and the install exited 0, + * but no newer version actually landed — the registry advertised a version it + * couldn't serve (typically a lagging mirror / virtual-repo proxy). Distinct + * from `updated` (real upgrade) and `installFailed` (npm errored). + */ + staleRegistry: boolean; /** A newer major version number available beyond the current one, or null. */ newMajor: number | null; } @@ -110,8 +151,10 @@ export function selfUpdate(porcelain: boolean): SelfUpdateInfo { current, npmAvailable: false, latestInMajor: null, + installedVersion: null, updated: false, installFailed: false, + staleRegistry: false, newMajor: null, }; @@ -130,8 +173,21 @@ export function selfUpdate(porcelain: boolean): SelfUpdateInfo { const r = spawnSync('npm', ['i', '-g', `${PKG}@^${curMajor}`], { stdio: porcelain ? ['ignore', 'pipe', 'pipe'] : 'inherit', }); - if (r.status === 0) info.updated = true; - else info.installFailed = true; + if (r.status === 0) { + // npm exited 0, but that alone doesn't prove the target landed — re-read + // the version actually installed. Only a genuinely newer version counts as + // an update; if the same (or older) version came back despite a newer one + // being advertised, the registry served something it couldn't fully deliver + // (a lagging mirror), which we flag separately instead of claiming success. + info.installedVersion = installedGlobalVersion(); + if (info.installedVersion && compareVersions(info.installedVersion, current) > 0) { + info.updated = true; + } else { + info.staleRegistry = true; + } + } else { + info.installFailed = true; + } } return info; } diff --git a/docs/commands.md b/docs/commands.md index 4e4e2c4..dfe6b3e 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -90,6 +90,8 @@ A newer major is available: @rousan/mx@3. Crossing a major is always a deliberate user action — `mx update` never does it automatically (a new major implies a runtime migration). `mx update` is **not** subject to the version gate: you may need it precisely because your runtime is a newer major than the CLI on `$PATH`. If `npm` is missing or the install fails, it prints the manual command for you to run instead. +**It reports the version that actually installed** — read back from `npm ls -g`, not the target it requested. This matters behind a lagging registry mirror or virtual-repo proxy, which can *advertise* a version (via `npm view`) whose tarball it can't yet serve: `npm i` then reinstalls the same version while still exiting 0. In that case `mx update` says the registry advertises a newer version but a newer one didn't install, and points you at a direct install (`npm i -g @rousan/mx@ --registry https://registry.npmjs.org`) — it does **not** falsely claim the upgrade, and the auto-sync only runs when a genuinely newer version landed. + **Auto-sync after update.** When an in-major update actually installs a newer version, `mx update` then runs **`mx sync`** automatically so the runtime picks up the new version's templates and scaffolding (the runtime `CLAUDE.md`, shipped `bin/` utilities, per-work/per-repo files). It does this by shelling out to the freshly-installed global `mx` — the running process is still the pre-update code, so an in-process sync would stamp the *old* templates. Since the update stays in-major, the runtime version still matches and sync isn't gated. It's best-effort: if the sync can't run (e.g. no runtime at the resolved path) `mx update` prints a hint rather than failing. In `--porcelain` mode the sync runs silently so the update's JSON stays the only object on stdout. (Nothing happens when you're already on the latest in-major version.) ### `mx migrate [--dry-run]` diff --git a/npm/package.json b/npm/package.json index ced79e7..54fcb71 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "@rousan/mx", - "version": "4.7.0", + "version": "4.7.1", "description": "mx — run several features in parallel across shared repos using git worktrees", "type": "module", "bin": {