Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/utils/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ export async function findCommandPath(command: string): Promise<string | null> {
const cmd = getPlatform() === 'windows' ? 'where' : 'which'
const res = await exec(cmd, [command])
if (res.exitCode === 0 && res.stdout) {
return res.stdout.trim().split('\n')[0]
return res.stdout.split(/\r?\n/, 1)[0].trim() || null
}
}
catch {
Expand Down
73 changes: 71 additions & 2 deletions src/utils/version-checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,79 @@ import { exec } from 'node:child_process'
import * as nodeFs from 'node:fs'
import process from 'node:process'
import { promisify } from 'node:util'
import { dirname, join } from 'pathe'
import semver from 'semver'
import { findCommandPath, getHomebrewCommandPaths, getPlatform } from './platform'

const execAsync = promisify(exec)
const CCR_PACKAGE_NAME = '@musistudio/claude-code-router'

interface PackageMetadata {
name?: string
version?: string
}

function readCcrPackageVersion(packageJsonPath: string): string | null {
try {
const metadata = JSON.parse(nodeFs.readFileSync(packageJsonPath, 'utf8')) as PackageMetadata
if (metadata.name !== CCR_PACKAGE_NAME || !metadata.version || !semver.valid(metadata.version))
return null

return metadata.version
}
catch {
return null
}
}

function getCcrPackageJsonPaths(commandPath: string): string[] {
const packageJsonPaths: string[] = []

try {
let currentDirectory = dirname(nodeFs.realpathSync(commandPath))
while (true) {
packageJsonPaths.push(join(currentDirectory, 'package.json'))
const parentDirectory = dirname(currentDirectory)
if (parentDirectory === currentDirectory)
break
currentDirectory = parentDirectory
}
}
catch {
// The standard npm prefix candidates below still cover non-symlink shims.
}

const commandDirectory = dirname(commandPath)
const packageSegments = ['@musistudio', 'claude-code-router', 'package.json']
packageJsonPaths.push(
join(commandDirectory, 'node_modules', ...packageSegments),
join(commandDirectory, '..', 'lib', 'node_modules', ...packageSegments),
)
Comment thread
WitMiao marked this conversation as resolved.

return [...new Set(packageJsonPaths)]
}

/**
* Get the installed CCR version without executing the CCR CLI.
*
* CCR 3.x treats version flags as profile names and may apply that profile before
* validation, so even a failed version probe can rewrite the user's Codex config.
* Resolve the active command to its npm package metadata instead. The direct npm
* prefix candidates cover Unix symlinks and Windows .cmd shims.
*/
export async function getCcrInstalledVersion(): Promise<string | null> {
const commandPath = await findCommandPath('ccr')
if (!commandPath)
return null

for (const packageJsonPath of getCcrPackageJsonPaths(commandPath)) {
const version = readCcrPackageVersion(packageJsonPath)
if (version)
return version
}

return null
}

/**
* Get installed version of a command-line tool
Expand Down Expand Up @@ -644,9 +713,9 @@ export async function checkCcrVersion(): Promise<{
latestVersion: string | null
needsUpdate: boolean
}> {
const currentVersion = await getInstalledVersion('ccr')
const currentVersion = await getCcrInstalledVersion()
// Get the latest version from npm
const latestVersion = await getLatestVersion('@musistudio/claude-code-router')
const latestVersion = await getLatestVersion(CCR_PACKAGE_NAME)

return {
installed: currentVersion !== null,
Expand Down
148 changes: 148 additions & 0 deletions tests/unit/utils/ccr-version-detection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import cac from 'cac'
import { dirname, join } from 'pathe'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setupCommands } from '../../../src/cli-setup'
import { checkCcrVersion } from '../../../src/utils/version-checker'
import { applyIsolatedHome, ISOLATED_HOME_ENV_KEYS, restoreProcessEnv, snapshotProcessEnv } from '../../helpers/isolated-home'

const executedCommands = vi.hoisted(() => [] as string[])
const mockExecAsync = vi.hoisted(() => vi.fn(async (command: string) => {
executedCommands.push(command)

if (command.startsWith('npm view '))
return { stdout: '3.0.21\n', stderr: '' }

if (command.startsWith('ccr'))
throw new Error('CCR CLI must not be executed during version detection')

if (command === 'claude -v' || command === 'ccline -v')
return { stdout: '3.0.21\n', stderr: '' }

throw new Error(`Unexpected command: ${command}`)
}))
const mockFindCommandPath = vi.hoisted(() => vi.fn())

vi.mock('node:child_process', () => ({
exec: vi.fn(),
}))

vi.mock('node:util', () => ({
promisify: () => mockExecAsync,
}))

vi.mock('../../../src/utils/platform', () => ({
commandExists: vi.fn(),
findCommandPath: mockFindCommandPath,
getHomebrewCommandPaths: vi.fn().mockResolvedValue([]),
getPlatform: vi.fn(() => 'linux'),
getTermuxPrefix: vi.fn(),
isTermux: vi.fn(() => false),
isWSL: vi.fn(() => false),
shouldUseSudoForGlobalInstall: vi.fn(() => false),
wrapCommandWithSudo: vi.fn((command: string, args: string[]) => ({ command, args, usedSudo: false })),
}))

const environmentKeys = [...ISOLATED_HOME_ENV_KEYS, 'PATH'] as const
let environmentSnapshot: ReadonlyMap<string, string | undefined>
let testRoot: string
let testHome: string

function createCcrPackage(packageRoot: string): void {
mkdirSync(join(packageRoot, 'dist', 'main'), { recursive: true })
writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({
name: '@musistudio/claude-code-router',
version: '3.0.21',
}))
writeFileSync(join(packageRoot, 'dist', 'main', 'cli.js'), '')
}

function createUnixCcrInstallation(): string {
const prefix = join(testRoot, 'fnm', 'node-versions', 'v24.6.0', 'installation')
const commandPath = join(prefix, 'bin', 'ccr')
const packageRoot = join(prefix, 'lib', 'node_modules', '@musistudio', 'claude-code-router')

createCcrPackage(packageRoot)
mkdirSync(dirname(commandPath), { recursive: true })
symlinkSync(join('..', 'lib', 'node_modules', '@musistudio', 'claude-code-router', 'dist', 'main', 'cli.js'), commandPath)

return commandPath
}

function createWindowsCcrInstallation(): string {
const prefix = join(testRoot, 'npm-prefix')
const commandPath = join(prefix, 'ccr.cmd')
const packageRoot = join(prefix, 'node_modules', '@musistudio', 'claude-code-router')

createCcrPackage(packageRoot)
writeFileSync(commandPath, '@ECHO off\r\nnode "%~dp0\\node_modules\\@musistudio\\claude-code-router\\dist\\main\\cli.js" %*\r\n')

return commandPath
}

beforeEach(() => {
environmentSnapshot = snapshotProcessEnv(environmentKeys)
testRoot = mkdtempSync(join(tmpdir(), 'zcf-ccr-version-'))
testHome = join(testRoot, 'home')
applyIsolatedHome(testHome)
executedCommands.length = 0
mockExecAsync.mockClear()
mockFindCommandPath.mockReset()
})

afterEach(() => {
restoreProcessEnv(environmentSnapshot)
rmSync(testRoot, { recursive: true, force: true })
})

describe('ccr version detection', () => {
it('reads CCR 3.x version from an fnm-style package without running the CLI', async () => {
const commandPath = createUnixCcrInstallation()
mockFindCommandPath.mockImplementation(async (command: string) => command === 'ccr' ? commandPath : null)

const result = await checkCcrVersion()

expect(result).toEqual({
installed: true,
currentVersion: '3.0.21',
latestVersion: '3.0.21',
needsUpdate: false,
})
expect(executedCommands).not.toContain('ccr -v')
expect(executedCommands).not.toContain('ccr --version')
expect(executedCommands.every(command => !command.startsWith('ccr'))).toBe(true)
})

it('reads CCR 3.x version from a Windows npm shim without running the CLI', async () => {
const commandPath = createWindowsCcrInstallation()
mockFindCommandPath.mockImplementation(async (command: string) => command === 'ccr' ? commandPath : null)

const result = await checkCcrVersion()

expect(result.installed).toBe(true)
expect(result.currentVersion).toBe('3.0.21')
expect(executedCommands.every(command => !command.startsWith('ccr'))).toBe(true)
})

it('keeps zcf check -s -T cc in an isolated HOME and reports installed CCR', async () => {
const commandPath = createUnixCcrInstallation()
mockFindCommandPath.mockImplementation(async (command: string) => command === 'ccr' ? commandPath : null)
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})

const cli = cac('zcf')
await setupCommands(cli)
cli.parse(['node', 'zcf', 'check', '-s', '-T', 'cc'], { run: false })
await cli.runMatchedCommand()

const output = consoleLog.mock.calls.flat().join('\n')
expect(output).toContain('CCR is up to date (v3.0.21)')
expect(output).not.toContain('CCR is not installed')
expect(executedCommands.every(command => !command.startsWith('ccr'))).toBe(true)
expect(existsSync(join(testHome, '.codex', 'config.toml'))).toBe(false)
expect(existsSync(join(testHome, '.codex', 'config.toml.ccr-original'))).toBe(false)
expect(existsSync(join(testHome, '.codex', 'ccr-model-catalog.json'))).toBe(false)

consoleLog.mockRestore()
})
})
4 changes: 2 additions & 2 deletions tests/unit/utils/platform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -736,11 +736,11 @@ ID=ubuntu`)
})

describe('findCommandPath - additional scenarios', () => {
it('should return first line when multiple paths returned by where', async () => {
it('should trim the first path when where returns multiple CRLF-separated results', async () => {
vi.mocked(platform).mockReturnValue('win32')
vi.mocked(exec).mockResolvedValue({
exitCode: 0,
stdout: 'C:\\path\\to\\claude.exe\nC:\\another\\path\\claude.exe',
stdout: 'C:\\path\\to\\claude.exe\r\nC:\\another\\path\\claude.exe\r\n',
stderr: '',
} as any)

Expand Down
30 changes: 24 additions & 6 deletions tests/unit/utils/version-checker.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { join } from 'pathe'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { compareVersions, shouldUpdate } from '../../../src/utils/version-checker'

Expand All @@ -17,6 +18,8 @@ const mockWrapCommandWithSudo = vi.hoisted(() => vi.fn((command: string, args: s
// Create hoisted mock for fs functions
const mockExistsSync = vi.hoisted(() => vi.fn())
const mockReaddirSync = vi.hoisted(() => vi.fn())
const mockReadFileSync = vi.hoisted(() => vi.fn())
const mockRealpathSync = vi.hoisted(() => vi.fn())

// Additional hoisted mocks
const mockTinyExec = vi.hoisted(() => vi.fn())
Expand All @@ -36,6 +39,8 @@ vi.mock('node:util', () => ({
vi.mock('node:fs', () => ({
existsSync: mockExistsSync,
readdirSync: mockReaddirSync,
readFileSync: mockReadFileSync,
realpathSync: mockRealpathSync,
}))

// Mock platform functions
Expand Down Expand Up @@ -136,6 +141,8 @@ describe('version-checker', () => {
mockGetHomebrewCommandPaths.mockReset()
mockExistsSync.mockReset()
mockReaddirSync.mockReset()
mockReadFileSync.mockReset()
mockRealpathSync.mockReset()
mockWrapCommandWithSudo.mockImplementation((command: string, args: string[]) => ({ command, args, usedSudo: false }))
mockTinyExec.mockReset()
mockTinyExec.mockResolvedValue({ stdout: '', stderr: '' })
Expand Down Expand Up @@ -478,11 +485,11 @@ describe('version-checker', () => {
})

const { getInstalledVersion } = await import('../../../src/utils/version-checker')
const result = await getInstalledVersion('ccr')
const result = await getInstalledVersion('tool')

expect(result).toBe('2.0.0')
expect(mockExecAsync).toHaveBeenCalledWith('ccr -v')
expect(mockExecAsync).toHaveBeenCalledWith('ccr --version')
expect(mockExecAsync).toHaveBeenCalledWith('tool -v')
expect(mockExecAsync).toHaveBeenCalledWith('tool --version')
})

it('should return null after max retries when command fails', async () => {
Expand Down Expand Up @@ -621,9 +628,20 @@ describe('version-checker', () => {
})

it('should return version info for installed CCR', async () => {
mockExecAsync
.mockResolvedValueOnce({ stdout: '1.0.0', stderr: '' }) // getInstalledVersion
.mockResolvedValueOnce({ stdout: '1.1.0\n', stderr: '' }) // getLatestVersion
const commandPath = join('/usr', 'local', 'bin', 'ccr')
const packageRoot = join('/usr', 'local', 'lib', 'node_modules', '@musistudio', 'claude-code-router')
mockFindCommandPath.mockResolvedValue(commandPath)
mockRealpathSync.mockReturnValue(join(packageRoot, 'dist', 'main', 'cli.js'))
mockReadFileSync.mockImplementation((path: string) => {
if (path === join(packageRoot, 'package.json')) {
return JSON.stringify({
name: '@musistudio/claude-code-router',
version: '1.0.0',
})
}
throw new Error('File not found')
})
mockExecAsync.mockResolvedValueOnce({ stdout: '1.1.0\n', stderr: '' }) // getLatestVersion

const { checkCcrVersion } = await import('../../../src/utils/version-checker')
const result = await checkCcrVersion()
Expand Down
Loading