fix: retarget updater to @mosaic/mosaic
This commit is contained in:
34
docs/scratchpads/update-checker-package-20260404.md
Normal file
34
docs/scratchpads/update-checker-package-20260404.md
Normal file
@@ -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`.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaic/cli",
|
"name": "@mosaic/cli",
|
||||||
"version": "0.0.16",
|
"version": "0.0.17",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
|
||||||
|
|||||||
@@ -314,7 +314,8 @@ program
|
|||||||
.description('Check for and install Mosaic CLI updates')
|
.description('Check for and install Mosaic CLI updates')
|
||||||
.option('--check', 'Check only, do not install')
|
.option('--check', 'Check only, do not install')
|
||||||
.action(async (opts: { check?: boolean }) => {
|
.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');
|
const { execSync } = await import('node:child_process');
|
||||||
|
|
||||||
console.log('Checking for updates…');
|
console.log('Checking for updates…');
|
||||||
@@ -344,7 +345,7 @@ program
|
|||||||
try {
|
try {
|
||||||
// Relies on @mosaic:registry in ~/.npmrc — do NOT pass --registry
|
// Relies on @mosaic:registry in ~/.npmrc — do NOT pass --registry
|
||||||
// globally or non-@mosaic deps will 404 against the Gitea registry.
|
// globally or non-@mosaic deps will 404 against the Gitea registry.
|
||||||
execSync('npm install -g @mosaic/cli@latest', {
|
execSync(getInstallCommand(result), {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { semverLt, formatUpdateNotice } from '../src/runtime/update-checker.js';
|
||||||
import type { UpdateCheckResult } 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<string, string>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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', () => {
|
describe('semverLt', () => {
|
||||||
it('returns true when a < b', () => {
|
it('returns true when a < b', () => {
|
||||||
expect(semverLt('0.0.1', '0.0.2')).toBe(true);
|
expect(semverLt('0.0.1', '0.0.2')).toBe(true);
|
||||||
@@ -25,6 +58,11 @@ describe('semverLt', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('formatUpdateNotice', () => {
|
describe('formatUpdateNotice', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
execSyncMock.mockReset();
|
||||||
|
cacheFiles.clear();
|
||||||
|
});
|
||||||
|
|
||||||
it('returns empty string when up to date', () => {
|
it('returns empty string when up to date', () => {
|
||||||
const result: UpdateCheckResult = {
|
const result: UpdateCheckResult = {
|
||||||
current: '1.0.0',
|
current: '1.0.0',
|
||||||
@@ -49,4 +87,109 @@ describe('formatUpdateNotice', () => {
|
|||||||
expect(notice).toContain('0.1.0');
|
expect(notice).toContain('0.1.0');
|
||||||
expect(notice).toContain('Update available');
|
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,9 +18,7 @@
|
|||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
},
|
}
|
||||||
"./package.json": "./package.json",
|
|
||||||
"./framework/*": "./framework/*"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
backgroundUpdateCheck,
|
backgroundUpdateCheck,
|
||||||
checkForUpdate,
|
checkForUpdate,
|
||||||
formatUpdateNotice,
|
formatUpdateNotice,
|
||||||
|
getInstallCommand,
|
||||||
} from './runtime/update-checker.js';
|
} from './runtime/update-checker.js';
|
||||||
import { runWizard } from './wizard.js';
|
import { runWizard } from './wizard.js';
|
||||||
import { ClackPrompter } from './prompter/clack-prompter.js';
|
import { ClackPrompter } from './prompter/clack-prompter.js';
|
||||||
@@ -354,7 +355,7 @@ program
|
|||||||
try {
|
try {
|
||||||
// Relies on @mosaic:registry in ~/.npmrc — do NOT pass --registry
|
// Relies on @mosaic:registry in ~/.npmrc — do NOT pass --registry
|
||||||
// globally or non-@mosaic deps will 404 against the Gitea registry.
|
// globally or non-@mosaic deps will 404 against the Gitea registry.
|
||||||
execSync('npm install -g @mosaic/cli@latest', {
|
execSync(getInstallCommand(result), {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
|
|
||||||
import { execFileSync, execSync, spawnSync } from 'node:child_process';
|
import { execFileSync, execSync, spawnSync } from 'node:child_process';
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
|
|
||||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||||
@@ -498,10 +498,14 @@ function delegateToScript(scriptPath: string, args: string[], env?: Record<strin
|
|||||||
* CLI version) over the deployed copy in ~/.config/mosaic/ (may be stale).
|
* CLI version) over the deployed copy in ~/.config/mosaic/ (may be stale).
|
||||||
*/
|
*/
|
||||||
function resolveTool(...segments: string[]): string {
|
function resolveTool(...segments: string[]): string {
|
||||||
// Resolve relative to the built file: dist/commands/launch.js → ../../framework/tools/...
|
try {
|
||||||
const thisFile = fileURLToPath(import.meta.url);
|
const req = createRequire(import.meta.url);
|
||||||
const bundled = join(dirname(thisFile), '..', '..', 'framework', 'tools', ...segments);
|
const mosaicPkg = dirname(req.resolve('@mosaic/mosaic/package.json'));
|
||||||
if (existsSync(bundled)) return bundled;
|
const bundled = join(mosaicPkg, 'framework', 'tools', ...segments);
|
||||||
|
if (existsSync(bundled)) return bundled;
|
||||||
|
} catch {
|
||||||
|
// Fall through to deployed copy
|
||||||
|
}
|
||||||
return join(MOSAIC_HOME, 'tools', ...segments);
|
return join(MOSAIC_HOME, 'tools', ...segments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,11 @@
|
|||||||
export const VERSION = '0.0.0';
|
export const VERSION = '0.0.0';
|
||||||
|
|
||||||
|
export {
|
||||||
|
backgroundUpdateCheck,
|
||||||
|
checkForUpdate,
|
||||||
|
formatUpdateNotice,
|
||||||
|
getInstallCommand,
|
||||||
|
getInstalledVersion,
|
||||||
|
getLatestVersion,
|
||||||
|
semverLt,
|
||||||
|
} from './runtime/update-checker.js';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Mosaic update checker — compares installed @mosaic/cli version against the
|
* Mosaic update checker — compares the installed Mosaic package against the
|
||||||
* Gitea npm registry and reports when an upgrade is available.
|
* Gitea npm registry and reports when an upgrade is available.
|
||||||
*
|
*
|
||||||
* Used by:
|
* Used by:
|
||||||
@@ -23,8 +23,12 @@ import { join } from 'node:path';
|
|||||||
export interface UpdateCheckResult {
|
export interface UpdateCheckResult {
|
||||||
/** Currently installed version (empty if not found) */
|
/** Currently installed version (empty if not found) */
|
||||||
current: string;
|
current: string;
|
||||||
|
/** Currently installed package name */
|
||||||
|
currentPackage?: string;
|
||||||
/** Latest published version (empty if check failed) */
|
/** Latest published version (empty if check failed) */
|
||||||
latest: string;
|
latest: string;
|
||||||
|
/** Package that should be installed for the latest version */
|
||||||
|
targetPackage?: string;
|
||||||
/** True when a newer version is available */
|
/** True when a newer version is available */
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
/** ISO timestamp of this check */
|
/** ISO timestamp of this check */
|
||||||
@@ -36,7 +40,9 @@ export interface UpdateCheckResult {
|
|||||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaic/npm/';
|
const REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaic/npm/';
|
||||||
const CLI_PKG = '@mosaic/cli';
|
const MODERN_PKG = '@mosaic/mosaic';
|
||||||
|
const LEGACY_PKG = '@mosaic/cli';
|
||||||
|
const INSTALLED_PACKAGE_ORDER = [MODERN_PKG, LEGACY_PKG] as const;
|
||||||
const CACHE_DIR = join(homedir(), '.cache', 'mosaic');
|
const CACHE_DIR = join(homedir(), '.cache', 'mosaic');
|
||||||
const CACHE_FILE = join(CACHE_DIR, 'update-check.json');
|
const CACHE_FILE = join(CACHE_DIR, 'update-check.json');
|
||||||
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||||
@@ -125,17 +131,20 @@ export function semverLt(a: string, b: string): boolean {
|
|||||||
/** Cache stores only the latest registry version (the expensive network call).
|
/** Cache stores only the latest registry version (the expensive network call).
|
||||||
* The installed version is always checked fresh — it's a local `npm ls`. */
|
* The installed version is always checked fresh — it's a local `npm ls`. */
|
||||||
interface RegistryCache {
|
interface RegistryCache {
|
||||||
|
currentPackage?: string;
|
||||||
latest: string;
|
latest: string;
|
||||||
|
targetPackage?: string;
|
||||||
checkedAt: string;
|
checkedAt: string;
|
||||||
registry: string;
|
registry: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readCache(): RegistryCache | null {
|
function readCache(currentPackage: string): RegistryCache | null {
|
||||||
try {
|
try {
|
||||||
if (!existsSync(CACHE_FILE)) return null;
|
if (!existsSync(CACHE_FILE)) return null;
|
||||||
const raw = JSON.parse(readFileSync(CACHE_FILE, 'utf-8')) as RegistryCache;
|
const raw = JSON.parse(readFileSync(CACHE_FILE, 'utf-8')) as RegistryCache;
|
||||||
const age = Date.now() - new Date(raw.checkedAt).getTime();
|
const age = Date.now() - new Date(raw.checkedAt).getTime();
|
||||||
if (age > CACHE_TTL_MS) return null;
|
if (age > CACHE_TTL_MS) return null;
|
||||||
|
if ((raw.currentPackage || '') !== currentPackage) return null;
|
||||||
return raw;
|
return raw;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -154,10 +163,10 @@ function writeCache(entry: RegistryCache): void {
|
|||||||
// ─── Public API ─────────────────────────────────────────────────────────────
|
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the currently installed version of @mosaic/cli.
|
* Get the currently installed Mosaic package version.
|
||||||
* Returns empty string if not installed.
|
* 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
|
// Fast path: check via package.json require chain
|
||||||
try {
|
try {
|
||||||
const raw = npmExec(`ls -g --depth=0 --json 2>/dev/null`, 3000);
|
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 {
|
const data = JSON.parse(raw) as {
|
||||||
dependencies?: Record<string, { version?: string }>;
|
dependencies?: Record<string, { version?: string }>;
|
||||||
};
|
};
|
||||||
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 {
|
} catch {
|
||||||
// fall through
|
// fall through
|
||||||
}
|
}
|
||||||
return '';
|
return { name: '', version: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the latest published version from the Gitea npm registry.
|
* 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.
|
* Returns empty string on failure.
|
||||||
*/
|
*/
|
||||||
export function getLatestVersion(): string {
|
export function getLatestVersion(installedPackage = ''): { name: string; version: string } {
|
||||||
return npmExec(`view ${CLI_PKG} version --registry=${REGISTRY}`);
|
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<UpdateCheckResult, 'targetPackage'>): string {
|
||||||
|
return `npm i -g ${result.targetPackage || MODERN_PKG}@latest`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -188,31 +218,49 @@ export function getLatestVersion(): string {
|
|||||||
* Never throws.
|
* Never throws.
|
||||||
*/
|
*/
|
||||||
export function checkForUpdate(options?: { skipCache?: boolean }): UpdateCheckResult {
|
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;
|
let checkedAt: string;
|
||||||
|
|
||||||
if (!options?.skipCache) {
|
if (!options?.skipCache) {
|
||||||
const cached = readCache();
|
const cached = readCache(currentInfo.name);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
latest = cached.latest;
|
latestInfo = {
|
||||||
|
name: cached.targetPackage || MODERN_PKG,
|
||||||
|
version: cached.latest,
|
||||||
|
};
|
||||||
checkedAt = cached.checkedAt;
|
checkedAt = cached.checkedAt;
|
||||||
} else {
|
} else {
|
||||||
latest = getLatestVersion();
|
latestInfo = getLatestVersion(currentInfo.name);
|
||||||
checkedAt = new Date().toISOString();
|
checkedAt = new Date().toISOString();
|
||||||
writeCache({ latest, checkedAt, registry: REGISTRY });
|
writeCache({
|
||||||
|
currentPackage: currentInfo.name,
|
||||||
|
latest: latestInfo.version,
|
||||||
|
targetPackage: latestInfo.name,
|
||||||
|
checkedAt,
|
||||||
|
registry: REGISTRY,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
latest = getLatestVersion();
|
latestInfo = getLatestVersion(currentInfo.name);
|
||||||
checkedAt = new Date().toISOString();
|
checkedAt = new Date().toISOString();
|
||||||
writeCache({ latest, checkedAt, registry: REGISTRY });
|
writeCache({
|
||||||
|
currentPackage: currentInfo.name,
|
||||||
|
latest: latestInfo.version,
|
||||||
|
targetPackage: latestInfo.name,
|
||||||
|
checkedAt,
|
||||||
|
registry: REGISTRY,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
current,
|
current,
|
||||||
latest,
|
currentPackage: currentInfo.name,
|
||||||
updateAvailable: !!(current && latest && semverLt(current, latest)),
|
latest: latestInfo.version,
|
||||||
|
targetPackage: latestInfo.name || MODERN_PKG,
|
||||||
|
updateAvailable: !!(current && latestInfo.version && semverLt(current, latestInfo.version)),
|
||||||
checkedAt,
|
checkedAt,
|
||||||
registry: REGISTRY,
|
registry: REGISTRY,
|
||||||
};
|
};
|
||||||
@@ -224,14 +272,21 @@ export function checkForUpdate(options?: { skipCache?: boolean }): UpdateCheckRe
|
|||||||
export function formatUpdateNotice(result: UpdateCheckResult): string {
|
export function formatUpdateNotice(result: UpdateCheckResult): string {
|
||||||
if (!result.updateAvailable) return '';
|
if (!result.updateAvailable) return '';
|
||||||
|
|
||||||
|
const installCommand = getInstallCommand(result);
|
||||||
|
const targetChanged =
|
||||||
|
result.currentPackage && result.targetPackage && result.currentPackage !== result.targetPackage;
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
'',
|
'',
|
||||||
'╭─────────────────────────────────────────────────╮',
|
'╭─────────────────────────────────────────────────╮',
|
||||||
'│ │',
|
'│ │',
|
||||||
`│ Update available: ${result.current} → ${result.latest}`.padEnd(50) + '│',
|
`│ Update available: ${result.current} → ${result.latest}`.padEnd(50) + '│',
|
||||||
|
...(targetChanged
|
||||||
|
? [`│ Package target: ${result.currentPackage} → ${result.targetPackage}`.padEnd(50) + '│']
|
||||||
|
: []),
|
||||||
'│ │',
|
'│ │',
|
||||||
'│ Run: bash tools/install.sh │',
|
'│ Run: bash tools/install.sh │',
|
||||||
'│ Or: npm i -g @mosaic/cli@latest │',
|
`│ Or: ${installCommand}`.padEnd(50) + '│',
|
||||||
'│ │',
|
'│ │',
|
||||||
'╰─────────────────────────────────────────────────╯',
|
'╰─────────────────────────────────────────────────╯',
|
||||||
'',
|
'',
|
||||||
|
|||||||
Reference in New Issue
Block a user