From 36f03d9b780f01f48e5a7ef14374ad4ef91b4f1f Mon Sep 17 00:00:00 2001 From: "Dina Berry (She/her)" Date: Tue, 31 Mar 2026 21:32:24 -0700 Subject: [PATCH] fix(cli): add npm version check to squad upgrade command When squad upgrade reports project files are up to date, also check npm for a newer CLI version and display a nudge if available. - Extract checkForNewerCLI() from self-update.ts for reuse - Wire npm check into upgrade command path (cli-entry.ts) - Change 'Already up to date' -> 'Project files up to date' for clarity - Refactor notifyIfUpdateAvailable() to use shared check function - Add ./self-update export to package.json (required for test imports) - Add CHANGELOG entry under [Unreleased] (single entry, removed stale duplicate section) Closes #46 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/fix-cli-upgrade-version-check.md | 5 + CHANGELOG.md | 6 +- docs/src/content/docs/scenarios/upgrading.md | 2 +- packages/squad-cli/package.json | 4 + packages/squad-cli/src/cli-entry.ts | 8 + packages/squad-cli/src/cli/core/upgrade.ts | 2 +- packages/squad-cli/src/cli/self-update.ts | 60 ++++-- test/cli/self-update-integration.test.ts | 200 +++++++++++++++++++ test/version-stamping.test.js | 14 +- 9 files changed, 273 insertions(+), 28 deletions(-) create mode 100644 .changeset/fix-cli-upgrade-version-check.md create mode 100644 test/cli/self-update-integration.test.ts diff --git a/.changeset/fix-cli-upgrade-version-check.md b/.changeset/fix-cli-upgrade-version-check.md new file mode 100644 index 000000000..50b4a8139 --- /dev/null +++ b/.changeset/fix-cli-upgrade-version-check.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-cli": patch +--- + +Add npm version check to squad upgrade command to detect available updates diff --git a/CHANGELOG.md b/CHANGELOG.md index a89b4ad11..e7ffadafc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Fixed — npm version check for squad upgrade (#46) +- `squad upgrade` now checks npm registry for available updates before proceeding +- Adds self-update detection to the upgrade command + ## [0.9.0] - 2026-03-23 ### Added — Personal Squad Governance Layer @@ -250,8 +254,6 @@ All notable changes to this project will be documented in this file. - 1 critical crash fix (OTel dependency) - 25 regression tests fixed -## [Unreleased] - ## [0.8.20] - 2025-01-08 ### Fixed diff --git a/docs/src/content/docs/scenarios/upgrading.md b/docs/src/content/docs/scenarios/upgrading.md index c29acde17..bd99f5b1d 100644 --- a/docs/src/content/docs/scenarios/upgrading.md +++ b/docs/src/content/docs/scenarios/upgrading.md @@ -130,7 +130,7 @@ squad upgrade ``` ``` -✅ Already up to date (v0.2.0) +✅ Project files up to date (v0.2.0) ``` Squad still runs any missing migrations in case a prior upgrade was interrupted. diff --git a/packages/squad-cli/package.json b/packages/squad-cli/package.json index cd94a9821..b9c183942 100644 --- a/packages/squad-cli/package.json +++ b/packages/squad-cli/package.json @@ -16,6 +16,10 @@ "types": "./dist/cli/upgrade.d.ts", "import": "./dist/cli/upgrade.js" }, + "./self-update": { + "types": "./dist/cli/self-update.d.ts", + "import": "./dist/cli/self-update.js" + }, "./copilot-install": { "types": "./dist/cli/copilot-install.d.ts", "import": "./dist/cli/copilot-install.js" diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 77df6b9d7..125a49d38 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -305,6 +305,14 @@ async function main(): Promise { force: forceUpgrade }); + // Check for newer CLI version after upgrade + try { + const { notifyIfUpdateAvailable } = await import('./cli/self-update.js'); + await notifyIfUpdateAvailable(VERSION, { bypassCache: true }); + } catch { + // Never fail the upgrade command for an update check + } + return; } diff --git a/packages/squad-cli/src/cli/core/upgrade.ts b/packages/squad-cli/src/cli/core/upgrade.ts index 441c570d6..30334bde2 100644 --- a/packages/squad-cli/src/cli/core/upgrade.ts +++ b/packages/squad-cli/src/cli/core/upgrade.ts @@ -458,7 +458,7 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr const projectType = detectProjectType(dest); if (isAlreadyCurrent) { - info(`Already up to date (v${cliVersion})`); + info(`Project files up to date (v${cliVersion})`); // Still run missing migrations const migrationsApplied = await runMigrations(squadDirInfo.path, oldVersion, cliVersion); diff --git a/packages/squad-cli/src/cli/self-update.ts b/packages/squad-cli/src/cli/self-update.ts index f99253067..acf3f2452 100644 --- a/packages/squad-cli/src/cli/self-update.ts +++ b/packages/squad-cli/src/cli/self-update.ts @@ -65,7 +65,7 @@ function writeCache(data: CacheData): void { } /** Fetch latest version from npm registry with timeout. */ -async function fetchLatestVersion(): Promise { +export async function fetchLatestVersion(): Promise { try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); @@ -83,40 +83,66 @@ async function fetchLatestVersion(): Promise { } } +export interface CLIUpdateCheck { + available: boolean; + latest: string; + current: string; +} + /** - * Check for updates and print a banner if a newer version is available. - * - * This function is designed to be fire-and-forget: it never throws, - * never blocks the shell, and silently no-ops on any failure. - * - * @param currentVersion - The currently running CLI version + * Check if a newer CLI version exists on npm. + * Returns null if check fails or is disabled. Never throws. */ -export async function notifyIfUpdateAvailable(currentVersion: string): Promise { +export async function checkForNewerCLI( + currentVersion: string, + options?: { bypassCache?: boolean } +): Promise { try { - // Respect opt-out - if (process.env.SQUAD_NO_UPDATE_CHECK === '1') return; + if (process.env.SQUAD_NO_UPDATE_CHECK === '1') return null; - // Check cache first - const cached = readCache(); + const cached = options?.bypassCache ? null : readCache(); let latest: string; if (cached) { latest = cached.latestVersion; } else { const fetched = await fetchLatestVersion(); - if (!fetched) return; + if (!fetched) return null; latest = fetched; writeCache({ latestVersion: latest, checkedAt: Date.now() }); } - // Only notify if strictly newer - if (compareVersions(latest, currentVersion) > 0) { + return { + available: compareVersions(latest, currentVersion) > 0, + latest, + current: currentVersion, + }; + } catch { + return null; + } +} + +/** + * Check for updates and print a banner if a newer version is available. + * + * This function is designed to be fire-and-forget: it never throws, + * never blocks the shell, and silently no-ops on any failure. + * + * @param currentVersion - The currently running CLI version + */ +export async function notifyIfUpdateAvailable( + currentVersion: string, + options?: { bypassCache?: boolean } +): Promise { + try { + const result = await checkForNewerCLI(currentVersion, options); + if (result?.available) { console.log( - `\n${YELLOW}⚡${RESET} ${BOLD}Squad v${latest}${RESET} available ${DIM}(you have v${currentVersion})${RESET}` + + `\n${YELLOW}⚡${RESET} ${BOLD}Squad v${result.latest}${RESET} available ${DIM}(you have v${result.current})${RESET}` + `\n Run: ${BOLD}npm install -g @bradygaster/squad-cli@latest${RESET}\n`, ); } } catch { - // Absolute safety net — never crash the CLI for an update check + // Absolute safety net } } diff --git a/test/cli/self-update-integration.test.ts b/test/cli/self-update-integration.test.ts new file mode 100644 index 000000000..d27f5e3be --- /dev/null +++ b/test/cli/self-update-integration.test.ts @@ -0,0 +1,200 @@ +/** + * Self-Update Integration Tests + * Tests for checkForNewerCLI and upgrade messaging + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { join } from 'path'; +import { mkdir, rm } from 'fs/promises'; +import { existsSync, mkdirSync, writeFileSync } from 'fs'; +import { randomBytes } from 'crypto'; + +import { checkForNewerCLI } from '@bradygaster/squad-cli/self-update'; +import { runUpgrade } from '@bradygaster/squad-cli/core/upgrade'; +import { runInit } from '@bradygaster/squad-cli/core/init'; + +const TEST_ROOT = join(process.cwd(), `.test-self-update-${randomBytes(4).toString('hex')}`); + +describe('self-update integration: checkForNewerCLI', () => { + let savedNoUpdate: string | undefined; + let savedAppData: string | undefined; + const fakeCacheDir = join(process.cwd(), `.test-cache-${randomBytes(4).toString('hex')}`); + + beforeEach(() => { + savedNoUpdate = process.env.SQUAD_NO_UPDATE_CHECK; + savedAppData = process.env.APPDATA; + delete process.env.SQUAD_NO_UPDATE_CHECK; + // Point cache to a non-existent directory so readCache always returns null + process.env.APPDATA = fakeCacheDir; + vi.restoreAllMocks(); + }); + + afterEach(async () => { + if (savedNoUpdate !== undefined) process.env.SQUAD_NO_UPDATE_CHECK = savedNoUpdate; + else delete process.env.SQUAD_NO_UPDATE_CHECK; + if (savedAppData !== undefined) process.env.APPDATA = savedAppData; + else delete process.env.APPDATA; + // Clean up any cache dir created by writeCache + if (existsSync(fakeCacheDir)) { + await rm(fakeCacheDir, { recursive: true, force: true }); + } + }); + + it('returns update info when npm has newer version', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ 'dist-tags': { latest: '99.0.0' } }), + } as Response); + + const result = await checkForNewerCLI('0.8.25'); + expect(result).toEqual({ available: true, latest: '99.0.0', current: '0.8.25' }); + }); + + it('returns not-available when already latest', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ 'dist-tags': { latest: '0.8.25' } }), + } as Response); + + const result = await checkForNewerCLI('0.8.25'); + expect(result).toEqual({ available: false, latest: '0.8.25', current: '0.8.25' }); + }); + + it('returns null on network failure', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network error')); + + const result = await checkForNewerCLI('0.8.25'); + expect(result).toBeNull(); + }); + + it('respects SQUAD_NO_UPDATE_CHECK', async () => { + process.env.SQUAD_NO_UPDATE_CHECK = '1'; + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const result = await checkForNewerCLI('0.8.25'); + expect(result).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + +describe('self-update integration: upgrade message', () => { + beforeEach(async () => { + if (existsSync(TEST_ROOT)) { + await rm(TEST_ROOT, { recursive: true, force: true }); + } + await mkdir(TEST_ROOT, { recursive: true }); + await runInit(TEST_ROOT); + // First upgrade ensures the project is at current version + await runUpgrade(TEST_ROOT); + }); + + afterEach(async () => { + if (existsSync(TEST_ROOT)) { + await rm(TEST_ROOT, { recursive: true, force: true }); + } + }); + + it('upgrade message says "Project files" not just "Already up to date"', async () => { + const logs: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => { + logs.push(args.map(String).join(' ')); + }; + + try { + // Second upgrade should hit the "already current" path + await runUpgrade(TEST_ROOT); + } finally { + console.log = origLog; + } + + const output = logs.join('\n'); + expect(output).toContain('Project files'); + expect(output).not.toContain('Already up to date'); + }); +}); + +describe('self-update integration: cache and edge cases', () => { + let savedNoUpdate: string | undefined; + let savedAppData: string | undefined; + const fakeCacheDir = join(process.cwd(), `.test-cache-edge-${randomBytes(4).toString('hex')}`); + + function seedCache(version: string): void { + const cacheDir = join(fakeCacheDir, 'squad-cli'); + mkdirSync(cacheDir, { recursive: true }); + writeFileSync( + join(cacheDir, 'update-check.json'), + JSON.stringify({ latestVersion: version, checkedAt: Date.now() }), + 'utf8', + ); + } + + beforeEach(() => { + savedNoUpdate = process.env.SQUAD_NO_UPDATE_CHECK; + savedAppData = process.env.APPDATA; + delete process.env.SQUAD_NO_UPDATE_CHECK; + process.env.APPDATA = fakeCacheDir; + vi.restoreAllMocks(); + }); + + afterEach(async () => { + if (savedNoUpdate !== undefined) process.env.SQUAD_NO_UPDATE_CHECK = savedNoUpdate; + else delete process.env.SQUAD_NO_UPDATE_CHECK; + if (savedAppData !== undefined) process.env.APPDATA = savedAppData; + else delete process.env.APPDATA; + if (existsSync(fakeCacheDir)) { + await rm(fakeCacheDir, { recursive: true, force: true }); + } + }); + + it('cache-hit path returns cached version without fetching', async () => { + seedCache('99.0.0'); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const result = await checkForNewerCLI('0.8.25'); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(result).toEqual({ available: true, latest: '99.0.0', current: '0.8.25' }); + }); + + it('prerelease current sees stable upgrade as available', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ 'dist-tags': { latest: '0.9.1' } }), + } as Response); + + const result = await checkForNewerCLI('0.9.1-build.4'); + expect(result).toEqual({ available: true, latest: '0.9.1', current: '0.9.1-build.4' }); + }); + + it('stable current does not upgrade to prerelease', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ 'dist-tags': { latest: '0.9.1-rc.1' } }), + } as Response); + + const result = await checkForNewerCLI('0.9.1'); + expect(result).toEqual({ available: false, latest: '0.9.1-rc.1', current: '0.9.1' }); + }); + + it('malformed version from npm returns null', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ 'dist-tags': { latest: 'not-a-version' } }), + } as Response); + + const result = await checkForNewerCLI('0.8.25'); + expect(result).toBeNull(); + }); + + it('bypassCache skips cache and fetches from npm', async () => { + seedCache('1.0.0'); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ 'dist-tags': { latest: '99.0.0' } }), + } as Response); + + const result = await checkForNewerCLI('0.8.25', { bypassCache: true }); + expect(fetchSpy).toHaveBeenCalled(); + expect(result).toEqual({ available: true, latest: '99.0.0', current: '0.8.25' }); + }); +}); diff --git a/test/version-stamping.test.js b/test/version-stamping.test.js index d287c1116..dd03a46a1 100644 --- a/test/version-stamping.test.js +++ b/test/version-stamping.test.js @@ -179,9 +179,9 @@ describe('compareSemver pre-release handling', () => { const result = runSquad(['upgrade'], tmpDir); assert.equal(result.exitCode, 0, `upgrade should succeed: ${result.stdout}`); - // Should NOT say "Already up to date" (meaning compareSemver detected older version) + // Should NOT say "Project files up to date" (meaning compareSemver detected older version) assert.ok( - !result.stdout.includes('Already up to date'), + !result.stdout.includes('Project files up to date'), 'upgrade should proceed when installed version (0.4.0) is older than current' ); @@ -205,10 +205,10 @@ describe('compareSemver pre-release handling', () => { const result = runSquad(['upgrade'], tmpDir); assert.equal(result.exitCode, 0, `upgrade should succeed: ${result.stdout}`); - // Should say "Already up to date" (compareSemver detected same version) + // Should say "Project files up to date" (compareSemver detected same version) assert.ok( - result.stdout.includes('Already up to date'), - 'upgrade should report "Already up to date" when versions match' + result.stdout.includes('Project files up to date'), + 'upgrade should report "Project files up to date" when versions match' ); }); @@ -235,7 +235,7 @@ describe('compareSemver pre-release handling', () => { const currentVersion = getPackageVersion(); if (currentVersion !== '0.5.2') { assert.ok( - !result.stdout.includes('Already up to date'), + !result.stdout.includes('Project files up to date'), `upgrade should proceed when upgrading from 0.5.2 to ${currentVersion}` ); } @@ -267,7 +267,7 @@ describe('compareSemver pre-release handling', () => { if (currentVersion === '0.5.3' || currentBase === '0.5.3') { // Pre-release should be considered less than release assert.ok( - !result.stdout.includes('Already up to date'), + !result.stdout.includes('Project files up to date'), 'upgrade should proceed when upgrading from 0.5.3-insiders to 0.5.3 release' ); }