Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-cli-upgrade-version-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bradygaster/squad-cli": patch
---

Add npm version check to squad upgrade command to detect available updates
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/scenarios/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions packages/squad-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions packages/squad-cli/src/cli-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,14 @@ async function main(): Promise<void> {
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;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/squad-cli/src/cli/core/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
60 changes: 43 additions & 17 deletions packages/squad-cli/src/cli/self-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ function writeCache(data: CacheData): void {
}

/** Fetch latest version from npm registry with timeout. */
async function fetchLatestVersion(): Promise<string | null> {
export async function fetchLatestVersion(): Promise<string | null> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
Expand All @@ -83,40 +83,66 @@ async function fetchLatestVersion(): Promise<string | null> {
}
}

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<void> {
export async function checkForNewerCLI(
currentVersion: string,
options?: { bypassCache?: boolean }
): Promise<CLIUpdateCheck | null> {
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<void> {
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
}
}
200 changes: 200 additions & 0 deletions test/cli/self-update-integration.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
Loading