From e6d5fe577397ac840c5e01a3994e2fcedbe1d2e3 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sat, 4 Apr 2026 20:42:18 -0500 Subject: [PATCH] fix: retarget updater to @mosaic/mosaic --- .../update-checker-package-20260404.md | 34 ++++ packages/cli/package.json | 2 +- packages/cli/src/cli.ts | 5 +- .../mosaic/__tests__/update-checker.test.ts | 145 +++++++++++++++++- packages/mosaic/package.json | 4 +- packages/mosaic/src/cli.ts | 3 +- packages/mosaic/src/commands/launch.ts | 14 +- packages/mosaic/src/index.ts | 10 ++ packages/mosaic/src/runtime/update-checker.ts | 97 +++++++++--- 9 files changed, 280 insertions(+), 34 deletions(-) create mode 100644 docs/scratchpads/update-checker-package-20260404.md diff --git a/docs/scratchpads/update-checker-package-20260404.md b/docs/scratchpads/update-checker-package-20260404.md new file mode 100644 index 0000000..2543603 --- /dev/null +++ b/docs/scratchpads/update-checker-package-20260404.md @@ -0,0 +1,34 @@ +# Scratchpad — updater package target fix (#382) + +- Objective: Fix `mosaic update` so modern installs query `@mosaic/mosaic` instead of stale `@mosaic/cli`. +- Scope: updater logic, user-facing update/install hints, tests, package version bump(s). +- Constraints: preserve backward compatibility for older `@mosaic/cli` installs if practical. +- Acceptance: + - fresh installs using `@mosaic/mosaic` report latest correctly + - older installs do not regress unnecessarily + - tests cover package lookup behavior + - release version bumped for changed package(s) + +## Decisions + +- Prefer `@mosaic/mosaic` when both modern and legacy packages are installed globally. +- For legacy `@mosaic/cli` installs, query `@mosaic/cli` first, then fall back to `@mosaic/mosaic` if the legacy package is not published. +- Share install-target selection from `packages/mosaic` so both the consolidated CLI and the legacy `packages/cli` entrypoint print/install the same package target. +- Extend the update cache to persist the resolved target package as well as the version so cached checks preserve the migration target. + +## Validation + +- `pnpm install` +- `pnpm --filter @mosaic/mosaic test -- __tests__/update-checker.test.ts` +- `pnpm exec eslint --no-warn-ignored packages/mosaic/src/runtime/update-checker.ts packages/mosaic/src/cli.ts packages/mosaic/src/index.ts packages/mosaic/__tests__/update-checker.test.ts packages/cli/src/cli.ts` +- `pnpm --filter @mosaic/mosaic lint` +- pre-push hooks: `typecheck`, `lint`, `format:check` + +## Review + +- Manual review of the updater diff caught and fixed a cache regression where fallback results would lose the resolved package target on subsequent cached checks. + +## Risks / Notes + +- Direct `pnpm --filter @mosaic/mosaic typecheck` and `pnpm --filter @mosaic/cli ...` checks were not representative in this worktree because `packages/cli` is excluded from `pnpm-workspace.yaml` and the standalone package check lacked the built workspace dependency graph. +- The repo's pre-push hooks provided the authoritative validation path here and passed: root `typecheck`, `lint`, and `format:check`. diff --git a/packages/cli/package.json b/packages/cli/package.json index 19dd021..9091746 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@mosaic/cli", - "version": "0.0.16", + "version": "0.0.17", "repository": { "type": "git", "url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 0619037..8630989 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -314,7 +314,8 @@ program .description('Check for and install Mosaic CLI updates') .option('--check', 'Check only, do not install') .action(async (opts: { check?: boolean }) => { - const { checkForUpdate, formatUpdateNotice } = await import('@mosaic/mosaic'); + const { checkForUpdate, formatUpdateNotice, getInstallCommand } = + await import('@mosaic/mosaic'); const { execSync } = await import('node:child_process'); console.log('Checking for updates…'); @@ -344,7 +345,7 @@ program try { // Relies on @mosaic:registry in ~/.npmrc — do NOT pass --registry // globally or non-@mosaic deps will 404 against the Gitea registry. - execSync('npm install -g @mosaic/cli@latest', { + execSync(getInstallCommand(result), { stdio: 'inherit', timeout: 60_000, }); diff --git a/packages/mosaic/__tests__/update-checker.test.ts b/packages/mosaic/__tests__/update-checker.test.ts index 1bf25e2..6eaaa8e 100644 --- a/packages/mosaic/__tests__/update-checker.test.ts +++ b/packages/mosaic/__tests__/update-checker.test.ts @@ -1,7 +1,40 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { semverLt, formatUpdateNotice } from '../src/runtime/update-checker.js'; import type { UpdateCheckResult } from '../src/runtime/update-checker.js'; +const { execSyncMock, cacheFiles } = vi.hoisted(() => ({ + execSyncMock: vi.fn(), + cacheFiles: new Map(), +})); + +vi.mock('node:child_process', () => ({ + execSync: execSyncMock, +})); + +vi.mock('node:fs', () => ({ + existsSync: vi.fn((path: string) => cacheFiles.has(path)), + mkdirSync: vi.fn(), + readFileSync: vi.fn((path: string) => { + const value = cacheFiles.get(path); + if (value === undefined) { + throw new Error(`ENOENT: ${path}`); + } + return value; + }), + writeFileSync: vi.fn((path: string, content: string) => { + cacheFiles.set(path, content); + }), +})); + +vi.mock('node:os', () => ({ + homedir: vi.fn(() => '/mock-home'), +})); + +async function importUpdateChecker() { + vi.resetModules(); + return import('../src/runtime/update-checker.js'); +} + describe('semverLt', () => { it('returns true when a < b', () => { expect(semverLt('0.0.1', '0.0.2')).toBe(true); @@ -25,6 +58,11 @@ describe('semverLt', () => { }); describe('formatUpdateNotice', () => { + beforeEach(() => { + execSyncMock.mockReset(); + cacheFiles.clear(); + }); + it('returns empty string when up to date', () => { const result: UpdateCheckResult = { current: '1.0.0', @@ -49,4 +87,109 @@ describe('formatUpdateNotice', () => { expect(notice).toContain('0.1.0'); expect(notice).toContain('Update available'); }); + + it('uses @mosaic/mosaic hints for modern installs', async () => { + execSyncMock.mockImplementation((command: string) => { + if (command.includes('ls -g --depth=0 --json')) { + return JSON.stringify({ + dependencies: { + '@mosaic/mosaic': { version: '0.0.17' }, + '@mosaic/cli': { version: '0.0.16' }, + }, + }); + } + + if (command.includes('view @mosaic/mosaic version')) { + return '0.0.18'; + } + + throw new Error(`Unexpected command: ${command}`); + }); + + const { checkForUpdate } = await importUpdateChecker(); + const result = checkForUpdate({ skipCache: true }); + const notice = formatUpdateNotice(result); + + expect(result.current).toBe('0.0.17'); + expect(result.latest).toBe('0.0.18'); + expect(notice).toContain('@mosaic/mosaic@latest'); + expect(notice).not.toContain('@mosaic/cli@latest'); + }); + + it('falls back to @mosaic/mosaic for legacy @mosaic/cli installs when cli is unavailable', async () => { + execSyncMock.mockImplementation((command: string) => { + if (command.includes('ls -g --depth=0 --json')) { + return JSON.stringify({ + dependencies: { + '@mosaic/cli': { version: '0.0.16' }, + }, + }); + } + + if (command.includes('view @mosaic/cli version')) { + throw new Error('not found'); + } + + if (command.includes('view @mosaic/mosaic version')) { + return '0.0.17'; + } + + throw new Error(`Unexpected command: ${command}`); + }); + + const { checkForUpdate } = await importUpdateChecker(); + const result = checkForUpdate({ skipCache: true }); + const notice = formatUpdateNotice(result); + + expect(result.current).toBe('0.0.16'); + expect(result.latest).toBe('0.0.17'); + expect(notice).toContain('@mosaic/mosaic@latest'); + }); + + it('does not reuse a cached modern-package result for a legacy install', async () => { + let installedPackage = '@mosaic/mosaic'; + + execSyncMock.mockImplementation((command: string) => { + if (command.includes('ls -g --depth=0 --json')) { + return JSON.stringify({ + dependencies: + installedPackage === '@mosaic/mosaic' + ? { '@mosaic/mosaic': { version: '0.0.17' } } + : { '@mosaic/cli': { version: '0.0.16' } }, + }); + } + + if (command.includes('view @mosaic/mosaic version')) { + return installedPackage === '@mosaic/mosaic' ? '0.0.18' : '0.0.17'; + } + + if (command.includes('view @mosaic/cli version')) { + throw new Error('not found'); + } + + throw new Error(`Unexpected command: ${command}`); + }); + + const { checkForUpdate } = await importUpdateChecker(); + + const modernResult = checkForUpdate(); + installedPackage = '@mosaic/cli'; + const legacyResult = checkForUpdate(); + + expect(modernResult.currentPackage).toBe('@mosaic/mosaic'); + expect(modernResult.targetPackage).toBe('@mosaic/mosaic'); + expect(modernResult.latest).toBe('0.0.18'); + + expect(legacyResult.currentPackage).toBe('@mosaic/cli'); + expect(legacyResult.targetPackage).toBe('@mosaic/mosaic'); + expect(legacyResult.latest).toBe('0.0.17'); + expect(execSyncMock).toHaveBeenCalledWith( + expect.stringContaining('view @mosaic/cli version'), + expect.any(Object), + ); + expect(execSyncMock).toHaveBeenCalledWith( + expect.stringContaining('view @mosaic/mosaic version'), + expect.any(Object), + ); + }); }); diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index d4ee0d4..89f8666 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -18,9 +18,7 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" - }, - "./package.json": "./package.json", - "./framework/*": "./framework/*" + } }, "scripts": { "build": "tsc", diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index 9d773dd..17011f0 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -12,6 +12,7 @@ import { backgroundUpdateCheck, checkForUpdate, formatUpdateNotice, + getInstallCommand, } from './runtime/update-checker.js'; import { runWizard } from './wizard.js'; import { ClackPrompter } from './prompter/clack-prompter.js'; @@ -354,7 +355,7 @@ program try { // Relies on @mosaic:registry in ~/.npmrc — do NOT pass --registry // globally or non-@mosaic deps will 404 against the Gitea registry. - execSync('npm install -g @mosaic/cli@latest', { + execSync(getInstallCommand(result), { stdio: 'inherit', timeout: 60_000, }); diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index 8183a0a..1c8a57c 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -7,9 +7,9 @@ import { execFileSync, execSync, spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs'; +import { createRequire } from 'node:module'; import { homedir } from 'node:os'; import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; import type { Command } from 'commander'; const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); @@ -498,10 +498,14 @@ function delegateToScript(scriptPath: string, args: string[], env?: Record CACHE_TTL_MS) return null; + if ((raw.currentPackage || '') !== currentPackage) return null; return raw; } catch { return null; @@ -154,10 +163,10 @@ function writeCache(entry: RegistryCache): void { // ─── Public API ───────────────────────────────────────────────────────────── /** - * Get the currently installed version of @mosaic/cli. - * Returns empty string if not installed. + * Get the currently installed Mosaic package version. + * Prefers the consolidated @mosaic/mosaic package over legacy @mosaic/cli. */ -export function getInstalledVersion(): string { +export function getInstalledVersion(): { name: string; version: string } { // Fast path: check via package.json require chain try { const raw = npmExec(`ls -g --depth=0 --json 2>/dev/null`, 3000); @@ -165,20 +174,41 @@ export function getInstalledVersion(): string { const data = JSON.parse(raw) as { dependencies?: Record; }; - return data?.dependencies?.[CLI_PKG]?.version ?? ''; + for (const pkg of INSTALLED_PACKAGE_ORDER) { + const version = data?.dependencies?.[pkg]?.version; + if (version) { + return { name: pkg, version }; + } + } } } catch { // fall through } - return ''; + return { name: '', version: '' }; } /** * Fetch the latest published version from the Gitea npm registry. + * For legacy @mosaic/cli installs, try the legacy package first and fall back + * to @mosaic/mosaic to support the CLI -> mosaic package consolidation. * Returns empty string on failure. */ -export function getLatestVersion(): string { - return npmExec(`view ${CLI_PKG} version --registry=${REGISTRY}`); +export function getLatestVersion(installedPackage = ''): { name: string; version: string } { + const candidates = + installedPackage === LEGACY_PKG ? [LEGACY_PKG, MODERN_PKG] : [MODERN_PKG, LEGACY_PKG]; + + for (const pkg of candidates) { + const version = npmExec(`view ${pkg} version --registry=${REGISTRY}`); + if (version) { + return { name: pkg, version }; + } + } + + return { name: '', version: '' }; +} + +export function getInstallCommand(result: Pick): string { + return `npm i -g ${result.targetPackage || MODERN_PKG}@latest`; } /** @@ -188,31 +218,49 @@ export function getLatestVersion(): string { * Never throws. */ export function checkForUpdate(options?: { skipCache?: boolean }): UpdateCheckResult { - const current = getInstalledVersion(); + const currentInfo = getInstalledVersion(); + const current = currentInfo.version; - let latest: string; + let latestInfo: { name: string; version: string }; let checkedAt: string; if (!options?.skipCache) { - const cached = readCache(); + const cached = readCache(currentInfo.name); if (cached) { - latest = cached.latest; + latestInfo = { + name: cached.targetPackage || MODERN_PKG, + version: cached.latest, + }; checkedAt = cached.checkedAt; } else { - latest = getLatestVersion(); + latestInfo = getLatestVersion(currentInfo.name); checkedAt = new Date().toISOString(); - writeCache({ latest, checkedAt, registry: REGISTRY }); + writeCache({ + currentPackage: currentInfo.name, + latest: latestInfo.version, + targetPackage: latestInfo.name, + checkedAt, + registry: REGISTRY, + }); } } else { - latest = getLatestVersion(); + latestInfo = getLatestVersion(currentInfo.name); checkedAt = new Date().toISOString(); - writeCache({ latest, checkedAt, registry: REGISTRY }); + writeCache({ + currentPackage: currentInfo.name, + latest: latestInfo.version, + targetPackage: latestInfo.name, + checkedAt, + registry: REGISTRY, + }); } return { current, - latest, - updateAvailable: !!(current && latest && semverLt(current, latest)), + currentPackage: currentInfo.name, + latest: latestInfo.version, + targetPackage: latestInfo.name || MODERN_PKG, + updateAvailable: !!(current && latestInfo.version && semverLt(current, latestInfo.version)), checkedAt, registry: REGISTRY, }; @@ -224,14 +272,21 @@ export function checkForUpdate(options?: { skipCache?: boolean }): UpdateCheckRe export function formatUpdateNotice(result: UpdateCheckResult): string { if (!result.updateAvailable) return ''; + const installCommand = getInstallCommand(result); + const targetChanged = + result.currentPackage && result.targetPackage && result.currentPackage !== result.targetPackage; + const lines = [ '', '╭─────────────────────────────────────────────────╮', '│ │', `│ Update available: ${result.current} → ${result.latest}`.padEnd(50) + '│', + ...(targetChanged + ? [`│ Package target: ${result.currentPackage} → ${result.targetPackage}`.padEnd(50) + '│'] + : []), '│ │', '│ Run: bash tools/install.sh │', - '│ Or: npm i -g @mosaic/cli@latest │', + `│ Or: ${installCommand}`.padEnd(50) + '│', '│ │', '╰─────────────────────────────────────────────────╯', '', -- 2.49.1