diff --git a/CHANGELOG.md b/CHANGELOG.md index 9040109d..f657851a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Added + +- Added `xcodebuildmcp upgrade` command to check for updates and upgrade in place. Supports `--check` (report-only) and `--yes`/`-y` (skip confirmation). Detects install method (Homebrew, npm-global, npx) and queries the appropriate channel source (`brew info`, `npm view`, or GitHub Releases) for the latest version. Non-interactive environments exit 1 when an auto-upgrade is possible but `--yes` was not supplied. + ## [2.3.2] ### Fixed diff --git a/README.md b/README.md index bd681a5c..51870d19 100644 --- a/README.md +++ b/README.md @@ -352,6 +352,13 @@ xcodebuildmcp tools xcodebuildmcp simulator build --scheme MyApp --project-path ./MyApp.xcodeproj ``` +Check for updates and upgrade in place: + +```bash +xcodebuildmcp upgrade --check +xcodebuildmcp upgrade --yes +``` + The CLI uses a per-workspace daemon for stateful operations (log capture, debugging, etc.) that auto-starts when needed. See [docs/CLI.md](docs/CLI.md) for full documentation. ## Documentation diff --git a/docs/CLI.md b/docs/CLI.md index c7ab7388..47ecac93 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -28,8 +28,59 @@ xcodebuildmcp --help # Run interactive setup for .xcodebuildmcp/config.yaml xcodebuildmcp setup + +# Check for updates +xcodebuildmcp upgrade --check +``` + +## Upgrade + +`xcodebuildmcp upgrade` checks for a newer release and optionally runs the upgrade. + +```bash +# Check for updates without upgrading +xcodebuildmcp upgrade --check + +# Upgrade automatically (skip confirmation prompt) +xcodebuildmcp upgrade --yes ``` +### Flags + +| Flag | Description | +|------|-------------| +| `--check` | Report the latest version and exit. Never prompts or runs an upgrade. | +| `--yes` / `-y` | Skip the confirmation prompt and run the upgrade command automatically. | + +When both `--check` and `--yes` are supplied, `--check` wins. + +### Channel-aware version lookup + +The version check queries the source of truth for your install channel — `brew info` for Homebrew, `npm view` for npm/npx, or GitHub Releases for unknown installs. This avoids misleading results when release channels drift (e.g. GitHub may publish a version before the Homebrew tap bumps). If the channel-specific lookup fails, the command does not fall back to another source; it reports the error and exits 1. + +### Install method behavior + +The command detects how XcodeBuildMCP was installed and adapts accordingly: + +| Method | Auto-upgrade | Command | +|--------|--------------|----------| +| Homebrew | Yes | `brew update && brew upgrade xcodebuildmcp` | +| npm global | Yes | `npm install -g xcodebuildmcp@latest` | +| npx | No | npx resolves `@latest` on each run; update the pinned version in your client config if needed. | +| Unknown | No | Manual instructions for all supported channels are shown. | + +### Non-interactive mode + +When stdin is not a TTY (CI, pipes, scripts): + +- `--check` works normally and exits 0. +- `--yes` runs the upgrade for Homebrew and npm-global installs. +- Without `--check` or `--yes`, the command prints the manual upgrade command and exits 1 (it cannot prompt for confirmation). + +### Lookup failures + +If the channel-specific version check fails (network error, rate limit, timeout, missing formula), the command prints the detected install method and manual upgrade instructions, then exits 1. + ## Tool Options Each tool supports `--help` for detailed options: diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 23427f3a..e03a396d 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -62,6 +62,16 @@ Using `@latest` ensures clients resolve the newest version on each run. See [CLI.md](CLI.md) for full CLI documentation. +### Checking for updates + +After installing, check for newer releases at any time: + +```bash +xcodebuildmcp upgrade --check +``` + +Homebrew and npm-global installs can auto-upgrade with `xcodebuildmcp upgrade --yes`. npx users don't need to upgrade explicitly — `@latest` resolves the newest version on each run. If you pinned a specific version in your MCP client config, update the version there instead. + ## Project config (optional) For deterministic session defaults and runtime configuration, add a config file at: diff --git a/scripts/check-docs-cli-commands.js b/scripts/check-docs-cli-commands.js index 20515a4a..42a9222a 100755 --- a/scripts/check-docs-cli-commands.js +++ b/scripts/check-docs-cli-commands.js @@ -125,7 +125,7 @@ function extractCommandCandidates(content) { } function findInvalidCommands(files, validPairs, validWorkflows) { - const validTopLevel = new Set(['mcp', 'tools', 'daemon', 'init', 'setup']); + const validTopLevel = new Set(['mcp', 'tools', 'daemon', 'init', 'setup', 'upgrade']); const validDaemonActions = new Set(['status', 'start', 'stop', 'restart', 'list']); const findings = []; diff --git a/scripts/generate-version.ts b/scripts/generate-version.ts index f082d59e..1982a69d 100644 --- a/scripts/generate-version.ts +++ b/scripts/generate-version.ts @@ -2,9 +2,21 @@ import { readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; interface PackageJson { + name: string; version: string; iOSTemplateVersion: string; macOSTemplateVersion: string; + repository?: { + url?: string; + }; +} + +function parseGitHubOwnerAndName(url: string): { owner: string; name: string } { + const match = url.match(/github\.com[/:]([^/]+)\/([^/.]+)/); + if (!match) { + throw new Error(`Cannot parse GitHub owner/name from repository URL: ${url}`); + } + return { owner: match[1], name: match[2] }; } async function main(): Promise { @@ -15,10 +27,20 @@ async function main(): Promise { const raw = await readFile(packagePath, 'utf8'); const pkg = JSON.parse(raw) as PackageJson; + const repoUrl = pkg.repository?.url; + if (!repoUrl) { + throw new Error('package.json must have a repository.url field'); + } + + const repo = parseGitHubOwnerAndName(repoUrl); + const content = `export const version = '${pkg.version}';\n` + `export const iOSTemplateVersion = '${pkg.iOSTemplateVersion}';\n` + - `export const macOSTemplateVersion = '${pkg.macOSTemplateVersion}';\n`; + `export const macOSTemplateVersion = '${pkg.macOSTemplateVersion}';\n` + + `export const packageName = '${pkg.name}';\n` + + `export const repositoryOwner = '${repo.owner}';\n` + + `export const repositoryName = '${repo.name}';\n`; await writeFile(versionPath, content, 'utf8'); } diff --git a/src/cli.ts b/src/cli.ts index 9791e87a..0168b475 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -80,6 +80,13 @@ async function runSetupCommand(): Promise { await app.parseAsync(); } +async function runUpgradeCommand(): Promise { + const { registerUpgradeCommand } = await import('./cli/commands/upgrade.ts'); + const app = await buildLightweightYargsApp(); + registerUpgradeCommand(app); + await app.parseAsync(); +} + async function main(): Promise { const cliBootstrapStartedAt = Date.now(); const earlyCommand = findTopLevelCommand(process.argv.slice(2)); @@ -95,6 +102,10 @@ async function main(): Promise { await runSetupCommand(); return; } + if (earlyCommand === 'upgrade') { + await runUpgradeCommand(); + return; + } await hydrateSentryDisabledEnvFromProjectConfig(); initSentry({ mode: 'cli' }); diff --git a/src/cli/commands/__tests__/upgrade.test.ts b/src/cli/commands/__tests__/upgrade.test.ts new file mode 100644 index 00000000..7a6cfb91 --- /dev/null +++ b/src/cli/commands/__tests__/upgrade.test.ts @@ -0,0 +1,1054 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'; + +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + outro: vi.fn(), + spinner: vi.fn(() => ({ + start: vi.fn(), + stop: vi.fn(), + })), + log: { + info: vi.fn(), + error: vi.fn(), + success: vi.fn(), + step: vi.fn(), + message: vi.fn(), + warn: vi.fn(), + }, + note: vi.fn(), + confirm: vi.fn().mockResolvedValue(true), + isCancel: vi.fn(() => false), +})); + +import * as clack from '@clack/prompts'; +import { + runUpgradeCommand, + parseVersion, + compareVersions, + detectInstallMethodFromPaths, + truncateReleaseNotes, + type ReleaseNotes, + type ChannelLookupResult, + type UpgradeDependencies, + type InstallMethod, +} from '../upgrade.ts'; + +const mockedConfirm = vi.mocked(clack.confirm); +const mockedIsCancel = vi.mocked(clack.isCancel); + +// --- Fixtures --- + +function createMockReleaseNotes(overrides?: Partial): ReleaseNotes { + return { + body: 'Bug fixes and improvements.', + htmlUrl: 'https://github.com/getsentry/XcodeBuildMCP/releases/tag/v3.0.0', + name: 'Release 3.0.0', + publishedAt: '2025-01-15T12:00:00Z', + ...overrides, + }; +} + +function homebrewMethod(): InstallMethod { + return { + kind: 'homebrew', + manualCommand: 'brew update && brew upgrade xcodebuildmcp', + autoCommands: [ + ['brew', 'update'], + ['brew', 'upgrade', 'xcodebuildmcp'], + ], + }; +} + +function npmGlobalMethod(): InstallMethod { + return { + kind: 'npm-global', + manualCommand: 'npm install -g xcodebuildmcp@latest', + autoCommands: [['npm', 'install', '-g', 'xcodebuildmcp@latest']], + }; +} + +function npxMethod(): InstallMethod { + return { + kind: 'npx', + manualInstructions: [ + 'npx always fetches the latest version by default when using @latest.', + 'If you pinned a specific version, update the version in your MCP client config.', + ], + }; +} + +function unknownMethod(): InstallMethod { + return { + kind: 'unknown', + manualInstructions: [ + 'Homebrew: brew update && brew upgrade xcodebuildmcp', + 'npm: npm install -g xcodebuildmcp@latest', + 'npx: npx always fetches the latest when using @latest', + ], + }; +} + +function baseDeps(overrides?: Partial): Partial { + return { + currentVersion: '2.0.0', + packageName: 'xcodebuildmcp', + repositoryOwner: 'getsentry', + repositoryName: 'XcodeBuildMCP', + fetchLatestVersionForChannel: vi.fn(async () => '3.0.0'), + fetchReleaseNotesForTag: vi.fn(async () => createMockReleaseNotes()), + runChannelLookupCommand: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + detectInstallMethod: vi.fn(() => homebrewMethod()), + spawnUpgradeProcess: vi.fn(async () => 0), + isInteractive: vi.fn(() => false), + ...overrides, + }; +} + +/** + * Create deps that do NOT provide fetchLatestVersionForChannel so the default + * channel fetcher is rebuilt using the mocked runChannelLookupCommand. + */ +function channelDeps( + method: InstallMethod, + lookupResult: ChannelLookupResult, + overrides?: Partial, +): Partial { + return { + currentVersion: '2.0.0', + packageName: 'xcodebuildmcp', + repositoryOwner: 'getsentry', + repositoryName: 'XcodeBuildMCP', + runChannelLookupCommand: vi.fn(async () => lookupResult), + fetchReleaseNotesForTag: vi.fn(async () => createMockReleaseNotes()), + detectInstallMethod: vi.fn(() => method), + spawnUpgradeProcess: vi.fn(async () => 0), + isInteractive: vi.fn(() => false), + ...overrides, + }; +} + +function collectStdout(spy: MockInstance): string { + return spy.mock.calls.map((c) => String(c[0])).join(''); +} + +function collectStderr(spy: MockInstance): string { + return spy.mock.calls.map((c) => String(c[0])).join(''); +} + +// --- Tests --- + +describe('upgrade command', () => { + let stdoutSpy: MockInstance; + let stderrSpy: MockInstance; + + beforeEach(() => { + stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + mockedConfirm.mockResolvedValue(true); + mockedIsCancel.mockReturnValue(false); + }); + + afterEach(() => { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + vi.restoreAllMocks(); + }); + + // ── Pure helpers ────────────────────────────────────────────────────── + + describe('parseVersion', () => { + it('parses standard version', () => { + expect(parseVersion('1.2.3')).toEqual({ + major: 1, + minor: 2, + patch: 3, + prerelease: undefined, + }); + }); + + it('strips leading v', () => { + expect(parseVersion('v1.2.3')).toEqual({ + major: 1, + minor: 2, + patch: 3, + prerelease: undefined, + }); + }); + + it('parses prerelease suffix', () => { + expect(parseVersion('1.2.3-beta.1')).toEqual({ + major: 1, + minor: 2, + patch: 3, + prerelease: 'beta.1', + }); + }); + + it('ignores build metadata', () => { + expect(parseVersion('1.2.3+build.42')).toEqual({ + major: 1, + minor: 2, + patch: 3, + prerelease: undefined, + }); + }); + + it('parses prerelease with build metadata', () => { + expect(parseVersion('1.2.3-rc.1+build')).toEqual({ + major: 1, + minor: 2, + patch: 3, + prerelease: 'rc.1', + }); + }); + + it('parses prerelease with hyphenated identifier', () => { + expect(parseVersion('1.0.0-alpha-1')).toEqual({ + major: 1, + minor: 0, + patch: 0, + prerelease: 'alpha-1', + }); + }); + + it('parses hyphenated prerelease with hyphenated build metadata', () => { + expect(parseVersion('1.0.0-rc-1+build-hash')).toEqual({ + major: 1, + minor: 0, + patch: 0, + prerelease: 'rc-1', + }); + }); + + it.each([['not-a-version'], ['1.2'], [''], ['1.2.3.4'], ['abc.def.ghi']])( + 'returns undefined for malformed input %j', + (input) => { + expect(parseVersion(input)).toBeUndefined(); + }, + ); + }); + + describe('compareVersions', () => { + it('detects equal versions', () => { + expect( + compareVersions( + { major: 1, minor: 2, patch: 3, prerelease: undefined }, + { major: 1, minor: 2, patch: 3, prerelease: undefined }, + ), + ).toBe('equal'); + }); + + it('detects older by major', () => { + expect( + compareVersions( + { major: 1, minor: 0, patch: 0, prerelease: undefined }, + { major: 2, minor: 0, patch: 0, prerelease: undefined }, + ), + ).toBe('older'); + }); + + it('detects newer by minor', () => { + expect( + compareVersions( + { major: 1, minor: 5, patch: 0, prerelease: undefined }, + { major: 1, minor: 3, patch: 0, prerelease: undefined }, + ), + ).toBe('newer'); + }); + + it('detects older by patch', () => { + expect( + compareVersions( + { major: 1, minor: 2, patch: 3, prerelease: undefined }, + { major: 1, minor: 2, patch: 5, prerelease: undefined }, + ), + ).toBe('older'); + }); + + it('prerelease is older than release at same version', () => { + expect( + compareVersions( + { major: 1, minor: 0, patch: 0, prerelease: 'beta.1' }, + { major: 1, minor: 0, patch: 0, prerelease: undefined }, + ), + ).toBe('older'); + }); + + it('release is newer than prerelease at same version', () => { + expect( + compareVersions( + { major: 1, minor: 0, patch: 0, prerelease: undefined }, + { major: 1, minor: 0, patch: 0, prerelease: 'beta.1' }, + ), + ).toBe('newer'); + }); + + it('compares numeric prerelease identifiers numerically', () => { + expect( + compareVersions( + { major: 1, minor: 0, patch: 0, prerelease: 'beta.2' }, + { major: 1, minor: 0, patch: 0, prerelease: 'beta.10' }, + ), + ).toBe('older'); + }); + + it('compares string prerelease identifiers lexicographically', () => { + expect( + compareVersions( + { major: 1, minor: 0, patch: 0, prerelease: 'alpha' }, + { major: 1, minor: 0, patch: 0, prerelease: 'beta' }, + ), + ).toBe('older'); + }); + + it('compares prerelease identifiers in ASCII order (uppercase before lowercase)', () => { + expect( + compareVersions( + { major: 1, minor: 0, patch: 0, prerelease: 'Alpha' }, + { major: 1, minor: 0, patch: 0, prerelease: 'alpha' }, + ), + ).toBe('older'); + }); + + it('numeric prerelease identifier is less than string identifier', () => { + expect( + compareVersions( + { major: 1, minor: 0, patch: 0, prerelease: '1' }, + { major: 1, minor: 0, patch: 0, prerelease: 'alpha' }, + ), + ).toBe('older'); + }); + + it('fewer prerelease parts is less than more parts', () => { + expect( + compareVersions( + { major: 1, minor: 0, patch: 0, prerelease: 'beta' }, + { major: 1, minor: 0, patch: 0, prerelease: 'beta.1' }, + ), + ).toBe('older'); + }); + + it('equal prerelease identifiers are equal', () => { + expect( + compareVersions( + { major: 1, minor: 0, patch: 0, prerelease: 'rc.1' }, + { major: 1, minor: 0, patch: 0, prerelease: 'rc.1' }, + ), + ).toBe('equal'); + }); + }); + + describe('detectInstallMethodFromPaths', () => { + it('detects homebrew on Intel Mac (/usr/local/Cellar)', () => { + const method = detectInstallMethodFromPaths('xcodebuildmcp', [ + '/usr/local/Cellar/xcodebuildmcp/2.0.0/bin/xcodebuildmcp', + ]); + expect(method.kind).toBe('homebrew'); + }); + + it('detects homebrew on Apple Silicon (/opt/homebrew/Cellar)', () => { + const method = detectInstallMethodFromPaths('xcodebuildmcp', [ + '/opt/homebrew/Cellar/xcodebuildmcp/2.0.0/bin/xcodebuildmcp', + ]); + expect(method.kind).toBe('homebrew'); + }); + + it('produces correct homebrew auto commands', () => { + const method = detectInstallMethodFromPaths('xcodebuildmcp', [ + '/opt/homebrew/Cellar/xcodebuildmcp/2.0.0/bin/xcodebuildmcp', + ]); + expect(method.kind).toBe('homebrew'); + if (method.kind === 'homebrew') { + expect(method.autoCommands).toEqual([ + ['brew', 'update'], + ['brew', 'upgrade', 'xcodebuildmcp'], + ]); + } + }); + + it('detects npm-global install', () => { + const method = detectInstallMethodFromPaths('xcodebuildmcp', [ + '/usr/local/lib/node_modules/xcodebuildmcp/build/cli.js', + ]); + expect(method.kind).toBe('npm-global'); + if (method.kind === 'npm-global') { + expect(method.autoCommands).toEqual([['npm', 'install', '-g', 'xcodebuildmcp@latest']]); + } + }); + + it('detects npx from _npx cache path', () => { + const method = detectInstallMethodFromPaths('xcodebuildmcp', [ + '/Users/cam/.npm/_npx/abc123/node_modules/xcodebuildmcp/build/cli.js', + ]); + expect(method.kind).toBe('npx'); + }); + + it('classifies npx before npm-global when path contains _npx and node_modules', () => { + const method = detectInstallMethodFromPaths('xcodebuildmcp', [ + '/Users/cam/.npm/_npx/12345/node_modules/xcodebuildmcp/build/cli.js', + ]); + expect(method.kind).toBe('npx'); + }); + + it('returns unknown for unrecognized paths', () => { + const method = detectInstallMethodFromPaths('xcodebuildmcp', [ + '/some/custom/path/xcodebuildmcp', + ]); + expect(method.kind).toBe('unknown'); + }); + + it('returns unknown for empty candidate list', () => { + const method = detectInstallMethodFromPaths('xcodebuildmcp', []); + expect(method.kind).toBe('unknown'); + }); + + it('matches case-insensitively', () => { + const method = detectInstallMethodFromPaths('xcodebuildmcp', [ + '/opt/Homebrew/Cellar/XcodeBuildMCP/2.0.0/bin/xcodebuildmcp', + ]); + expect(method.kind).toBe('homebrew'); + }); + }); + + describe('truncateReleaseNotes', () => { + const url = 'https://example.com/release'; + + it('returns full text when under both limits', () => { + const body = 'Line 1\nLine 2\nLine 3'; + const result = truncateReleaseNotes(body, url); + expect(result).not.toContain('(truncated)'); + expect(result).toContain('Line 1\nLine 2\nLine 3'); + expect(result).toContain(`Full release notes: ${url}`); + }); + + it('truncates at 20 lines', () => { + const lines = Array.from({ length: 30 }, (_, i) => `Line ${i + 1}`); + const body = lines.join('\n'); + const result = truncateReleaseNotes(body, url); + expect(result).toContain('... (truncated)'); + expect(result).toContain('Line 20'); + expect(result).not.toContain('Line 21'); + }); + + it('truncates at 2000 characters', () => { + const longLine = 'x'.repeat(300); + const lines = Array.from({ length: 10 }, () => longLine); + const body = lines.join('\n'); + const result = truncateReleaseNotes(body, url); + expect(result).toContain('... (truncated)'); + const beforeMarker = result.split('\n\n... (truncated)')[0]; + expect(beforeMarker.length).toBeLessThanOrEqual(2000); + }); + + it('returns only the URL for empty body', () => { + const result = truncateReleaseNotes('', url); + expect(result).toBe(`Full release notes: ${url}`); + }); + + it('normalizes CRLF line endings', () => { + const body = 'Line 1\r\nLine 2\r\nLine 3'; + const result = truncateReleaseNotes(body, url); + expect(result).toContain('Line 1\nLine 2\nLine 3'); + expect(result).not.toContain('\r'); + }); + }); + + // ── Command flow ────────────────────────────────────────────────────── + + describe('runUpgradeCommand', () => { + describe('up to date', () => { + it('exits 0 when versions match (non-TTY)', async () => { + const deps = baseDeps({ + currentVersion: '3.0.0', + fetchLatestVersionForChannel: vi.fn(async () => '3.0.0'), + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(0); + expect(collectStdout(stdoutSpy)).toContain('Already up to date'); + }); + + it('exits 0 when versions match (TTY)', async () => { + const deps = baseDeps({ + currentVersion: '3.0.0', + fetchLatestVersionForChannel: vi.fn(async () => '3.0.0'), + isInteractive: vi.fn(() => true), + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(0); + expect(clack.log.success).toHaveBeenCalledWith( + expect.stringContaining('Already up to date'), + ); + }); + }); + + describe('local version newer', () => { + it('exits 0 without offering a downgrade', async () => { + const spawnMock = vi.fn(async () => 0); + const deps = baseDeps({ + currentVersion: '4.0.0', + fetchLatestVersionForChannel: vi.fn(async () => '3.0.0'), + spawnUpgradeProcess: spawnMock, + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(0); + expect(collectStdout(stdoutSpy)).toContain('ahead of latest'); + expect(spawnMock).not.toHaveBeenCalled(); + }); + }); + + describe('--check mode', () => { + it('exits 0 and shows update info without upgrading (non-TTY)', async () => { + const spawnMock = vi.fn(async () => 0); + const deps = baseDeps({ spawnUpgradeProcess: spawnMock }); + + const code = await runUpgradeCommand({ check: true, yes: false }, deps); + expect(code).toBe(0); + expect(collectStdout(stdoutSpy)).toContain('Update available'); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('exits 0 in TTY mode without prompting', async () => { + const deps = baseDeps({ isInteractive: vi.fn(() => true) }); + + const code = await runUpgradeCommand({ check: true, yes: false }, deps); + expect(code).toBe(0); + expect(clack.confirm).not.toHaveBeenCalled(); + }); + + it('--check overrides --yes', async () => { + const spawnMock = vi.fn(async () => 0); + const deps = baseDeps({ spawnUpgradeProcess: spawnMock }); + + const code = await runUpgradeCommand({ check: true, yes: true }, deps); + expect(code).toBe(0); + expect(spawnMock).not.toHaveBeenCalled(); + }); + }); + + describe('homebrew install method', () => { + it('runs upgrade with --yes and verifies exact argv arrays', async () => { + const spawnMock = vi.fn(async () => 0); + const deps = baseDeps({ + detectInstallMethod: vi.fn(() => homebrewMethod()), + spawnUpgradeProcess: spawnMock, + }); + + const code = await runUpgradeCommand({ check: false, yes: true }, deps); + expect(code).toBe(0); + expect(spawnMock).toHaveBeenCalledWith([ + ['brew', 'update'], + ['brew', 'upgrade', 'xcodebuildmcp'], + ]); + }); + + it('prompts in TTY and runs upgrade when confirmed', async () => { + mockedConfirm.mockResolvedValue(true); + const spawnMock = vi.fn(async () => 0); + const deps = baseDeps({ + isInteractive: vi.fn(() => true), + detectInstallMethod: vi.fn(() => homebrewMethod()), + spawnUpgradeProcess: spawnMock, + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(0); + expect(clack.confirm).toHaveBeenCalled(); + expect(spawnMock).toHaveBeenCalled(); + }); + + it('exits 0 without running when TTY confirm is declined', async () => { + mockedConfirm.mockResolvedValue(false); + const spawnMock = vi.fn(async () => 0); + const deps = baseDeps({ + isInteractive: vi.fn(() => true), + detectInstallMethod: vi.fn(() => homebrewMethod()), + spawnUpgradeProcess: spawnMock, + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(0); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('exits 0 without running when TTY confirm is cancelled', async () => { + mockedConfirm.mockResolvedValue(Symbol('cancel') as unknown as boolean); + mockedIsCancel.mockReturnValue(true); + const spawnMock = vi.fn(async () => 0); + const deps = baseDeps({ + isInteractive: vi.fn(() => true), + detectInstallMethod: vi.fn(() => homebrewMethod()), + spawnUpgradeProcess: spawnMock, + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(0); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('non-TTY without --yes exits 1 and suggests --yes', async () => { + const spawnMock = vi.fn(async () => 0); + const deps = baseDeps({ + detectInstallMethod: vi.fn(() => homebrewMethod()), + spawnUpgradeProcess: spawnMock, + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(1); + expect(spawnMock).not.toHaveBeenCalled(); + expect(collectStdout(stdoutSpy)).toContain('--yes'); + }); + + it('propagates non-zero exit code from upgrade process', async () => { + const spawnMock = vi.fn(async () => 42); + const deps = baseDeps({ + detectInstallMethod: vi.fn(() => homebrewMethod()), + spawnUpgradeProcess: spawnMock, + }); + + const code = await runUpgradeCommand({ check: false, yes: true }, deps); + expect(code).toBe(42); + }); + }); + + describe('npm-global install method', () => { + it('runs upgrade with --yes and verifies exact argv array', async () => { + const spawnMock = vi.fn(async () => 0); + const deps = baseDeps({ + detectInstallMethod: vi.fn(() => npmGlobalMethod()), + spawnUpgradeProcess: spawnMock, + }); + + const code = await runUpgradeCommand({ check: false, yes: true }, deps); + expect(code).toBe(0); + expect(spawnMock).toHaveBeenCalledWith([['npm', 'install', '-g', 'xcodebuildmcp@latest']]); + }); + + it('non-TTY without --yes exits 1', async () => { + const spawnMock = vi.fn(async () => 0); + const deps = baseDeps({ + detectInstallMethod: vi.fn(() => npmGlobalMethod()), + spawnUpgradeProcess: spawnMock, + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(1); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('prompts in TTY and runs upgrade when confirmed', async () => { + mockedConfirm.mockResolvedValue(true); + const spawnMock = vi.fn(async () => 0); + const deps = baseDeps({ + isInteractive: vi.fn(() => true), + detectInstallMethod: vi.fn(() => npmGlobalMethod()), + spawnUpgradeProcess: spawnMock, + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(0); + expect(spawnMock).toHaveBeenCalled(); + }); + }); + + describe('npx install method', () => { + it('exits 0 with --yes without spawning', async () => { + const spawnMock = vi.fn(async () => 0); + const deps = baseDeps({ + detectInstallMethod: vi.fn(() => npxMethod()), + spawnUpgradeProcess: spawnMock, + }); + + const code = await runUpgradeCommand({ check: false, yes: true }, deps); + expect(code).toBe(0); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('explains ephemeral install limitation (non-TTY)', async () => { + const deps = baseDeps({ + detectInstallMethod: vi.fn(() => npxMethod()), + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(0); + expect(collectStdout(stdoutSpy)).toContain('npx always fetches the latest'); + }); + + it('explains ephemeral install limitation (TTY)', async () => { + const deps = baseDeps({ + isInteractive: vi.fn(() => true), + detectInstallMethod: vi.fn(() => npxMethod()), + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(0); + expect(clack.log.info).toHaveBeenCalledWith( + expect.stringContaining('npx always fetches the latest'), + ); + }); + }); + + describe('unknown install method', () => { + it('exits 0 and shows manual options', async () => { + const spawnMock = vi.fn(async () => 0); + const deps = baseDeps({ + detectInstallMethod: vi.fn(() => unknownMethod()), + spawnUpgradeProcess: spawnMock, + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(0); + expect(spawnMock).not.toHaveBeenCalled(); + expect(collectStdout(stdoutSpy)).toContain('Could not detect install method'); + }); + + it('no auto-run even with --yes', async () => { + const spawnMock = vi.fn(async () => 0); + const deps = baseDeps({ + detectInstallMethod: vi.fn(() => unknownMethod()), + spawnUpgradeProcess: spawnMock, + }); + + const code = await runUpgradeCommand({ check: false, yes: true }, deps); + expect(code).toBe(0); + expect(spawnMock).not.toHaveBeenCalled(); + }); + }); + + describe('channel lookup failures', () => { + it('exits 1 on network error', async () => { + const deps = baseDeps({ + fetchLatestVersionForChannel: vi.fn(async () => { + throw new Error("couldn't determine latest version from npm: fetch failed"); + }), + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(1); + expect(collectStderr(stderrSpy)).toContain('fetch failed'); + }); + + it('exits 1 on timeout', async () => { + const deps = baseDeps({ + fetchLatestVersionForChannel: vi.fn(async () => { + throw new Error("couldn't determine latest version from npm: request timed out"); + }), + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(1); + expect(collectStderr(stderrSpy)).toContain('timed out'); + }); + + it('exits 1 on rate limit', async () => { + const deps = baseDeps({ + fetchLatestVersionForChannel: vi.fn(async () => { + throw new Error("couldn't determine latest version from GitHub: rate limit exceeded"); + }), + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(1); + expect(collectStderr(stderrSpy)).toContain('rate limit'); + }); + + it('exits 1 on HTTP error', async () => { + const deps = baseDeps({ + fetchLatestVersionForChannel: vi.fn(async () => { + throw new Error("couldn't determine latest version from GitHub: HTTP 500"); + }), + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(1); + expect(collectStderr(stderrSpy)).toContain('HTTP 500'); + }); + + it('exits 1 on missing tag_name', async () => { + const deps = baseDeps({ + fetchLatestVersionForChannel: vi.fn(async () => { + throw new Error("couldn't determine latest version from GitHub: missing tag_name"); + }), + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(1); + expect(collectStderr(stderrSpy)).toContain('missing tag_name'); + }); + + it('shows manual upgrade command on failure when install method is known', async () => { + const deps = baseDeps({ + fetchLatestVersionForChannel: vi.fn(async () => { + throw new Error("couldn't determine latest version from Homebrew: network error"); + }), + detectInstallMethod: vi.fn(() => homebrewMethod()), + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(1); + expect(collectStdout(stdoutSpy)).toContain('brew update && brew upgrade xcodebuildmcp'); + }); + + it('shows failure info via clack in TTY mode', async () => { + const deps = baseDeps({ + isInteractive: vi.fn(() => true), + fetchLatestVersionForChannel: vi.fn(async () => { + throw new Error("couldn't determine latest version from GitHub: missing tag_name"); + }), + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(1); + expect(clack.log.error).toHaveBeenCalledWith(expect.stringContaining('missing tag_name')); + }); + }); + + describe('version parse errors', () => { + it('exits 1 when current version is malformed', async () => { + const deps = baseDeps({ + currentVersion: 'bad', + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(1); + expect(collectStderr(stderrSpy)).toContain('Cannot compare versions'); + }); + + it('exits 1 when latest version is malformed', async () => { + const deps = baseDeps({ + fetchLatestVersionForChannel: vi.fn(async () => 'invalid'), + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(1); + expect(collectStderr(stderrSpy)).toContain('Cannot compare versions'); + }); + + it('exits 1 via clack in TTY mode for malformed versions', async () => { + const deps = baseDeps({ + isInteractive: vi.fn(() => true), + currentVersion: 'garbage', + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(1); + expect(clack.log.error).toHaveBeenCalledWith( + expect.stringContaining('Cannot compare versions'), + ); + }); + }); + + describe('release notes in output', () => { + it('includes release notes and URL when update is available (non-TTY)', async () => { + const deps = baseDeps({ + fetchReleaseNotesForTag: vi.fn(async () => + createMockReleaseNotes({ body: 'Fixed a critical bug.' }), + ), + }); + + await runUpgradeCommand({ check: true, yes: false }, deps); + const stdout = collectStdout(stdoutSpy); + expect(stdout).toContain('Fixed a critical bug.'); + expect(stdout).toContain('Full release notes:'); + }); + + it('includes published date when available', async () => { + const deps = baseDeps({ + fetchReleaseNotesForTag: vi.fn(async () => + createMockReleaseNotes({ publishedAt: '2025-06-01T10:00:00Z' }), + ), + }); + + await runUpgradeCommand({ check: true, yes: false }, deps); + expect(collectStdout(stdoutSpy)).toContain('Published: 2025-06-01'); + }); + + it('shows fallback URL when notes return null (tag not released)', async () => { + const deps = baseDeps({ + fetchReleaseNotesForTag: vi.fn(async () => null), + }); + + await runUpgradeCommand({ check: true, yes: false }, deps); + const stdout = collectStdout(stdoutSpy); + expect(stdout).toContain('Release notes:'); + expect(stdout).toContain('github.com'); + }); + + it('continues without notes when fetch throws (network failure)', async () => { + const deps = baseDeps({ + fetchReleaseNotesForTag: vi.fn(async () => { + throw new Error('network error'); + }), + }); + + const code = await runUpgradeCommand({ check: true, yes: false }, deps); + expect(code).toBe(0); + expect(collectStdout(stdoutSpy)).toContain('Update available'); + expect(collectStdout(stdoutSpy)).toContain('Release notes:'); + }); + }); + + // ── Channel-specific version lookup ─────────────────────────────── + + describe('channel-specific version lookup', () => { + it('npm-global: parses version from npm view JSON output', async () => { + const deps = channelDeps(npmGlobalMethod(), { + stdout: '"3.0.0"\n', + stderr: '', + exitCode: 0, + }); + + const code = await runUpgradeCommand({ check: true, yes: false }, deps); + expect(code).toBe(0); + expect(collectStdout(stdoutSpy)).toContain('Update available'); + expect(collectStdout(stdoutSpy)).toContain('3.0.0'); + }); + + it('npx: uses npm view for version lookup', async () => { + const runner = vi.fn(async () => ({ + stdout: '"3.0.0"\n', + stderr: '', + exitCode: 0, + })); + const deps = channelDeps( + npxMethod(), + { stdout: '', stderr: '', exitCode: 0 }, + { + runChannelLookupCommand: runner, + }, + ); + + const code = await runUpgradeCommand({ check: true, yes: false }, deps); + expect(code).toBe(0); + expect(runner).toHaveBeenCalledWith(expect.arrayContaining(['npm', 'view'])); + }); + + it('homebrew: parses version from brew info JSON output', async () => { + const brewOutput = JSON.stringify({ + formulae: [{ versions: { stable: '3.0.0' } }], + }); + const deps = channelDeps(homebrewMethod(), { + stdout: brewOutput, + stderr: '', + exitCode: 0, + }); + + const code = await runUpgradeCommand({ check: true, yes: false }, deps); + expect(code).toBe(0); + expect(collectStdout(stdoutSpy)).toContain('Update available'); + expect(collectStdout(stdoutSpy)).toContain('3.0.0'); + }); + + it('homebrew: exits 1 when formula is not found (empty formulae array)', async () => { + const brewOutput = JSON.stringify({ formulae: [] }); + const deps = channelDeps(homebrewMethod(), { + stdout: brewOutput, + stderr: 'Error: No available formula with the name "xcodebuildmcp"', + exitCode: 0, + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(1); + expect(collectStderr(stderrSpy)).toContain('Homebrew'); + expect(collectStderr(stderrSpy)).toContain('tap installed'); + }); + + it('homebrew: exits 1 on invalid JSON output', async () => { + const deps = channelDeps(homebrewMethod(), { + stdout: 'not valid json at all', + stderr: '', + exitCode: 0, + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(1); + expect(collectStderr(stderrSpy)).toContain('Homebrew'); + expect(collectStderr(stderrSpy)).toContain('invalid JSON'); + }); + + it('homebrew: exits 1 when brew info exits non-zero', async () => { + const deps = channelDeps(homebrewMethod(), { + stdout: '', + stderr: 'Error: Permission denied', + exitCode: 1, + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(1); + expect(collectStderr(stderrSpy)).toContain('Homebrew'); + expect(collectStderr(stderrSpy)).toContain('exited with code 1'); + }); + + it('npm-global: exits 1 when npm view exits non-zero', async () => { + const deps = channelDeps(npmGlobalMethod(), { + stdout: '', + stderr: 'npm ERR! 404 Not Found', + exitCode: 1, + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(1); + expect(collectStderr(stderrSpy)).toContain('npm'); + expect(collectStderr(stderrSpy)).toContain('exited with code 1'); + }); + + it('npm-global: exits 1 on invalid JSON output', async () => { + const deps = channelDeps(npmGlobalMethod(), { + stdout: 'not json', + stderr: '', + exitCode: 0, + }); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(1); + expect(collectStderr(stderrSpy)).toContain('npm'); + expect(collectStderr(stderrSpy)).toContain('invalid JSON'); + }); + + it('unknown: falls through to GitHub (mocked at fetchLatestVersionForChannel)', async () => { + const deps = baseDeps({ + detectInstallMethod: vi.fn(() => unknownMethod()), + fetchLatestVersionForChannel: vi.fn(async () => '3.0.0'), + }); + + const code = await runUpgradeCommand({ check: true, yes: false }, deps); + expect(code).toBe(0); + expect(collectStdout(stdoutSpy)).toContain('Update available'); + }); + + it('homebrew: exits 1 on lookup timeout', async () => { + const runner = vi + .fn() + .mockRejectedValue(new Error('Command timed out after 15 seconds: brew')); + const deps = channelDeps( + homebrewMethod(), + { stdout: '', stderr: '', exitCode: 0 }, + { runChannelLookupCommand: runner }, + ); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(1); + expect(collectStderr(stderrSpy)).toContain('Homebrew'); + expect(collectStderr(stderrSpy)).toContain('timed out'); + }); + + it('npm-global: exits 1 on lookup timeout', async () => { + const runner = vi + .fn() + .mockRejectedValue(new Error('Command timed out after 15 seconds: npm')); + const deps = channelDeps( + npmGlobalMethod(), + { stdout: '', stderr: '', exitCode: 0 }, + { runChannelLookupCommand: runner }, + ); + + const code = await runUpgradeCommand({ check: false, yes: false }, deps); + expect(code).toBe(1); + expect(collectStderr(stderrSpy)).toContain('npm'); + expect(collectStderr(stderrSpy)).toContain('timed out'); + }); + }); + }); +}); diff --git a/src/cli/commands/upgrade.ts b/src/cli/commands/upgrade.ts new file mode 100644 index 00000000..7af50ec8 --- /dev/null +++ b/src/cli/commands/upgrade.ts @@ -0,0 +1,847 @@ +import type { Argv } from 'yargs'; +import * as fs from 'node:fs'; +import { spawn } from 'node:child_process'; +import * as clack from '@clack/prompts'; +import { + version as currentVersion, + packageName, + repositoryOwner, + repositoryName, +} from '../../utils/version/index.ts'; +import { isInteractiveTTY } from '../interactive/prompts.ts'; + +// --- Types --- + +interface AutoUpgradeMethod { + kind: 'homebrew' | 'npm-global'; + manualCommand: string; + autoCommands: string[][]; +} + +interface ManualOnlyMethod { + kind: 'npx' | 'unknown'; + manualInstructions: string[]; +} + +export type InstallMethod = AutoUpgradeMethod | ManualOnlyMethod; + +export interface ParsedVersion { + major: number; + minor: number; + patch: number; + prerelease: string | undefined; +} + +export type VersionComparison = 'older' | 'equal' | 'newer'; + +export interface ReleaseNotes { + body: string; + htmlUrl: string; + name: string | undefined; + publishedAt: string | undefined; +} + +export interface ChannelLookupResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export interface UpgradeDependencies { + currentVersion: string; + packageName: string; + repositoryOwner: string; + repositoryName: string; + fetchLatestVersionForChannel: (channel: InstallMethod['kind']) => Promise; + fetchReleaseNotesForTag: (tag: string) => Promise; + runChannelLookupCommand: (argv: string[]) => Promise; + detectInstallMethod: () => InstallMethod; + spawnUpgradeProcess: (commands: string[][]) => Promise; + isInteractive: () => boolean; +} + +export interface UpgradeOptions { + check: boolean; + yes: boolean; +} + +// --- Version comparison --- + +export function parseVersion(raw: string): ParsedVersion | undefined { + const stripped = raw.startsWith('v') ? raw.slice(1) : raw; + const match = stripped.match(/^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9.-]+))?(?:\+[a-zA-Z0-9.-]+)?$/); + if (!match) return undefined; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4], + }; +} + +function comparePrereleaseIdentifiers(a: string, b: string): number { + const aParts = a.split('.'); + const bParts = b.split('.'); + const len = Math.max(aParts.length, bParts.length); + + for (let i = 0; i < len; i++) { + if (i >= aParts.length) return -1; + if (i >= bParts.length) return 1; + + const aIsNum = /^\d+$/.test(aParts[i]); + const bIsNum = /^\d+$/.test(bParts[i]); + + if (aIsNum && bIsNum) { + const diff = Number(aParts[i]) - Number(bParts[i]); + if (diff !== 0) return diff; + continue; + } + + if (aIsNum) return -1; + if (bIsNum) return 1; + + const cmp = aParts[i] < bParts[i] ? -1 : aParts[i] > bParts[i] ? 1 : 0; + if (cmp !== 0) return cmp; + } + + return 0; +} + +export function compareVersions(current: ParsedVersion, latest: ParsedVersion): VersionComparison { + for (const field of ['major', 'minor', 'patch'] as const) { + if (current[field] < latest[field]) return 'older'; + if (current[field] > latest[field]) return 'newer'; + } + + if (current.prerelease !== undefined && latest.prerelease !== undefined) { + const cmp = comparePrereleaseIdentifiers(current.prerelease, latest.prerelease); + if (cmp < 0) return 'older'; + if (cmp > 0) return 'newer'; + return 'equal'; + } + + if (current.prerelease !== undefined && latest.prerelease === undefined) return 'older'; + if (current.prerelease === undefined && latest.prerelease !== undefined) return 'newer'; + + return 'equal'; +} + +// --- Install method detection --- + +export function collectCandidatePaths(): string[] { + const candidates: string[] = []; + + if (process.argv[1]) { + candidates.push(process.argv[1]); + try { + candidates.push(fs.realpathSync(process.argv[1])); + } catch { + // Symlink resolution may fail + } + } + + if (process.execPath) { + candidates.push(process.execPath); + try { + candidates.push(fs.realpathSync(process.execPath)); + } catch { + // Skip + } + } + + return candidates; +} + +export function detectInstallMethodFromPaths( + pkgName: string, + candidatePaths: string[], +): InstallMethod { + const normalized = candidatePaths.map((p) => p.toLowerCase()); + + const isNpx = normalized.some( + (p) => p.includes('/_npx/') && p.includes(`/node_modules/${pkgName}/`), + ); + if (isNpx) { + return { + kind: 'npx', + manualInstructions: [ + 'npx always fetches the latest version by default when using @latest.', + 'If you pinned a specific version, update the version in your MCP client config.', + ], + }; + } + + const isHomebrew = normalized.some( + (p) => p.includes(`/cellar/${pkgName}/`) || p.includes(`/homebrew/cellar/${pkgName}/`), + ); + if (isHomebrew) { + return { + kind: 'homebrew', + manualCommand: `brew update && brew upgrade ${pkgName}`, + autoCommands: [ + ['brew', 'update'], + ['brew', 'upgrade', pkgName], + ], + }; + } + + const isNpmGlobal = normalized.some((p) => p.includes(`/node_modules/${pkgName}/`)); + if (isNpmGlobal) { + return { + kind: 'npm-global', + manualCommand: `npm install -g ${pkgName}@latest`, + autoCommands: [['npm', 'install', '-g', `${pkgName}@latest`]], + }; + } + + return { + kind: 'unknown', + manualInstructions: [ + `Homebrew: brew update && brew upgrade ${pkgName}`, + `npm: npm install -g ${pkgName}@latest`, + `npx: npx always fetches the latest when using @latest`, + ], + }; +} + +// --- Channel version lookup --- + +interface GitHubReleaseResponse { + tag_name?: string; + name?: string; + body?: string; + html_url?: string; + published_at?: string; +} + +async function fetchLatestVersionFromNpm( + pkgName: string, + run: (argv: string[]) => Promise, +): Promise { + let result: ChannelLookupResult; + try { + result = await run(['npm', 'view', `${pkgName}@latest`, 'version', '--json']); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`couldn't determine latest version from npm: ${reason}`); + } + + if (result.exitCode !== 0) { + throw new Error( + `couldn't determine latest version from npm: command exited with code ${result.exitCode}`, + ); + } + + let version: unknown; + try { + version = JSON.parse(result.stdout); + } catch { + throw new Error("couldn't determine latest version from npm: invalid JSON output"); + } + + if (typeof version !== 'string') { + throw new Error("couldn't determine latest version from npm: unexpected output format"); + } + + return version; +} + +async function fetchLatestVersionFromHomebrew( + pkgName: string, + run: (argv: string[]) => Promise, +): Promise { + let result: ChannelLookupResult; + try { + result = await run(['brew', 'info', '--json=v2', pkgName]); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`couldn't determine latest version from Homebrew: ${reason}`); + } + + if (result.exitCode !== 0) { + throw new Error( + `couldn't determine latest version from Homebrew: command exited with code ${result.exitCode}`, + ); + } + + let data: unknown; + try { + data = JSON.parse(result.stdout); + } catch { + throw new Error("couldn't determine latest version from Homebrew: invalid JSON output"); + } + + if (!data || typeof data !== 'object') { + throw new Error("couldn't determine latest version from Homebrew: unexpected output format"); + } + + const formulae = (data as Record).formulae; + if (!Array.isArray(formulae) || formulae.length === 0) { + throw new Error(`couldn't find ${pkgName} in Homebrew (is the tap installed?)`); + } + + const versions = (formulae[0] as Record)?.versions; + if (!versions || typeof versions !== 'object') { + throw new Error("couldn't determine latest version from Homebrew: missing versions field"); + } + + const stable = (versions as Record).stable; + if (typeof stable !== 'string') { + throw new Error("couldn't determine latest version from Homebrew: missing versions.stable"); + } + + return stable; +} + +async function fetchLatestVersionFromGitHub( + owner: string, + name: string, + pkgVersion: string, +): Promise { + const url = `https://api.github.com/repos/${owner}/${name}/releases/latest`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + + try { + let response: Response; + try { + response = await fetch(url, { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': `xcodebuildmcp/${pkgVersion}`, + }, + signal: controller.signal, + }); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error("couldn't determine latest version from GitHub: request timed out"); + } + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`couldn't determine latest version from GitHub: ${reason}`); + } + + if (response.status === 403 || response.status === 429) { + throw new Error("couldn't determine latest version from GitHub: rate limit exceeded"); + } + + if (!response.ok) { + throw new Error(`couldn't determine latest version from GitHub: HTTP ${response.status}`); + } + + const data = (await response.json()) as GitHubReleaseResponse; + + if (!data.tag_name) { + throw new Error("couldn't determine latest version from GitHub: missing tag_name"); + } + + return data.tag_name.startsWith('v') ? data.tag_name.slice(1) : data.tag_name; + } finally { + clearTimeout(timeout); + } +} + +interface ChannelFetcherDeps { + runChannelLookupCommand: (argv: string[]) => Promise; + packageName: string; + repositoryOwner: string; + repositoryName: string; + currentVersion: string; +} + +function defaultFetchLatestVersionForChannel( + channel: InstallMethod['kind'], + deps: ChannelFetcherDeps, +): Promise { + switch (channel) { + case 'npm-global': + case 'npx': + return fetchLatestVersionFromNpm(deps.packageName, deps.runChannelLookupCommand); + case 'homebrew': + return fetchLatestVersionFromHomebrew(deps.packageName, deps.runChannelLookupCommand); + case 'unknown': + return fetchLatestVersionFromGitHub( + deps.repositoryOwner, + deps.repositoryName, + deps.currentVersion, + ); + } +} + +// --- Release notes fetch --- + +interface NotesFetcherDeps { + repositoryOwner: string; + repositoryName: string; + currentVersion: string; +} + +async function defaultFetchReleaseNotesForTag( + tag: string, + deps: NotesFetcherDeps, +): Promise { + const url = `https://api.github.com/repos/${deps.repositoryOwner}/${deps.repositoryName}/releases/tags/${tag}`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + + try { + let response: Response; + try { + response = await fetch(url, { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': `xcodebuildmcp/${deps.currentVersion}`, + }, + signal: controller.signal, + }); + } catch { + return null; + } + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as GitHubReleaseResponse; + + return { + body: data.body ?? '', + htmlUrl: + data.html_url ?? + `https://github.com/${deps.repositoryOwner}/${deps.repositoryName}/releases/tag/${tag}`, + name: data.name ?? undefined, + publishedAt: data.published_at ?? undefined, + }; + } finally { + clearTimeout(timeout); + } +} + +// --- Release notes rendering --- + +export function truncateReleaseNotes(body: string, releaseUrl: string): string { + const MAX_LINES = 20; + const MAX_CHARS = 2000; + + const normalized = body.replace(/\r\n/g, '\n').trim(); + if (normalized.length === 0) { + return `Full release notes: ${releaseUrl}`; + } + + const lines = normalized.split('\n'); + const included: string[] = []; + let charCount = 0; + let truncated = false; + + for (const line of lines) { + if (included.length >= MAX_LINES) { + truncated = true; + break; + } + const nextCharCount = charCount + (included.length > 0 ? 1 : 0) + line.length; + if (nextCharCount > MAX_CHARS && included.length > 0) { + truncated = true; + break; + } + included.push(line); + charCount = nextCharCount; + } + + let result = included.join('\n'); + if (truncated) { + result += '\n\n... (truncated)'; + } + result += `\n\nFull release notes: ${releaseUrl}`; + return result; +} + +// --- Spawn runners --- + +function defaultRunChannelLookupCommand(argv: string[]): Promise { + return new Promise((resolve, reject) => { + const [cmd, ...args] = argv; + const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + child.kill('SIGTERM'); + }, 15_000); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + child.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + + child.on('close', (code) => { + clearTimeout(timeout); + if (timedOut) { + reject(new Error(`Command timed out after 15 seconds: ${cmd}`)); + return; + } + resolve({ stdout, stderr, exitCode: code ?? 1 }); + }); + }); +} + +function defaultSpawnUpgradeProcess(commands: string[][]): Promise { + return new Promise((resolve, reject) => { + let currentIndex = 0; + + function runNext(): void { + if (currentIndex >= commands.length) { + resolve(0); + return; + } + + const [cmd, ...args] = commands[currentIndex]; + const child = spawn(cmd, args, { stdio: 'inherit' }); + + const forwardSigint = (): void => { + child.kill('SIGINT'); + }; + const forwardSigterm = (): void => { + child.kill('SIGTERM'); + }; + + process.on('SIGINT', forwardSigint); + process.on('SIGTERM', forwardSigterm); + + const cleanup = (): void => { + process.removeListener('SIGINT', forwardSigint); + process.removeListener('SIGTERM', forwardSigterm); + }; + + child.on('error', (err) => { + cleanup(); + reject(err); + }); + + child.on('close', (code) => { + cleanup(); + if (code !== 0) { + resolve(code ?? 1); + return; + } + currentIndex++; + runNext(); + }); + } + + runNext(); + }); +} + +// --- Dependency factory --- + +function resolveDependencies(overrides?: Partial): UpgradeDependencies { + const base: UpgradeDependencies = { + currentVersion, + packageName, + repositoryOwner, + repositoryName, + runChannelLookupCommand: defaultRunChannelLookupCommand, + fetchLatestVersionForChannel: undefined!, + fetchReleaseNotesForTag: undefined!, + detectInstallMethod: () => detectInstallMethodFromPaths(packageName, collectCandidatePaths()), + spawnUpgradeProcess: defaultSpawnUpgradeProcess, + isInteractive: isInteractiveTTY, + }; + + if (overrides) { + Object.assign(base, overrides); + } + + if (!overrides?.fetchLatestVersionForChannel) { + base.fetchLatestVersionForChannel = (channel) => + defaultFetchLatestVersionForChannel(channel, base); + } + + if (!overrides?.fetchReleaseNotesForTag) { + base.fetchReleaseNotesForTag = (tag) => defaultFetchReleaseNotesForTag(tag, base); + } + + return base; +} + +// --- Helpers --- + +function isAutoUpgradeMethod(method: InstallMethod): method is AutoUpgradeMethod { + return method.kind === 'homebrew' || method.kind === 'npm-global'; +} + +function writeLine(text: string): void { + process.stdout.write(`${text}\n`); +} + +function writeError(text: string): void { + process.stderr.write(`${text}\n`); +} + +// --- Main command logic --- + +export async function runUpgradeCommand( + options: UpgradeOptions, + deps?: Partial, +): Promise { + const d = resolveDependencies(deps); + const isTTY = d.isInteractive(); + + if (isTTY) { + clack.intro('XcodeBuildMCP Upgrade'); + } + + const installMethod = d.detectInstallMethod(); + + let latestVersion: string; + try { + if (isTTY) { + const s = clack.spinner(); + s.start('Checking for updates...'); + try { + latestVersion = await d.fetchLatestVersionForChannel(installMethod.kind); + s.stop('Update check complete.'); + } catch (error) { + s.stop('Update check failed.'); + throw error; + } + } else { + latestVersion = await d.fetchLatestVersionForChannel(installMethod.kind); + } + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + + if (isTTY) { + clack.log.error(reason); + clack.log.info(`Current version: ${d.currentVersion}`); + clack.log.info(`Install method: ${installMethod.kind}`); + if (isAutoUpgradeMethod(installMethod)) { + clack.log.info(`Manual upgrade: ${installMethod.manualCommand}`); + } + clack.outro(''); + } else { + writeError(reason); + writeLine(`Current version: ${d.currentVersion}`); + writeLine(`Install method: ${installMethod.kind}`); + if (isAutoUpgradeMethod(installMethod)) { + writeLine(`Manual upgrade: ${installMethod.manualCommand}`); + } + } + + return 1; + } + + const parsedCurrent = parseVersion(d.currentVersion); + const parsedLatest = parseVersion(latestVersion); + + if (!parsedCurrent || !parsedLatest) { + const msg = `Cannot compare versions: current=${d.currentVersion}, latest=${latestVersion}`; + if (isTTY) { + clack.log.error(msg); + clack.outro(''); + } else { + writeError(msg); + } + return 1; + } + + const comparison = compareVersions(parsedCurrent, parsedLatest); + + if (comparison === 'equal') { + const msg = `Already up to date (${d.currentVersion}).`; + if (isTTY) { + clack.log.success(msg); + clack.outro(''); + } else { + writeLine(msg); + } + return 0; + } + + if (comparison === 'newer') { + const msg = `Local version (${d.currentVersion}) is ahead of latest release (${latestVersion}).`; + if (isTTY) { + clack.log.info(msg); + clack.outro(''); + } else { + writeLine(msg); + } + return 0; + } + + const releaseUrl = `https://github.com/${d.repositoryOwner}/${d.repositoryName}/releases/tag/v${latestVersion}`; + let releaseNotes: ReleaseNotes | null = null; + try { + releaseNotes = await d.fetchReleaseNotesForTag(`v${latestVersion}`); + } catch { + // Non-fatal — notes unavailable + } + + const versionLine = `${d.currentVersion} → ${latestVersion}`; + const releaseName = releaseNotes?.name ? ` — ${releaseNotes.name}` : ''; + const publishedLine = releaseNotes?.publishedAt + ? `Published: ${releaseNotes.publishedAt.split('T')[0]}` + : ''; + + if (isTTY) { + clack.log.step(`Update available: ${versionLine}${releaseName}`); + if (publishedLine) clack.log.info(publishedLine); + clack.log.info(`Install method: ${installMethod.kind}`); + + if (releaseNotes && releaseNotes.body.trim().length > 0) { + clack.note(truncateReleaseNotes(releaseNotes.body, releaseNotes.htmlUrl), 'Release Notes'); + } else { + clack.log.info(`Release notes: ${releaseUrl}`); + } + } else { + writeLine(`Update available: ${versionLine}${releaseName}`); + if (publishedLine) writeLine(publishedLine); + writeLine(`Install method: ${installMethod.kind}`); + writeLine(''); + + if (releaseNotes && releaseNotes.body.trim().length > 0) { + writeLine(truncateReleaseNotes(releaseNotes.body, releaseNotes.htmlUrl)); + } else { + writeLine(`Release notes: ${releaseUrl}`); + } + writeLine(''); + } + + if (options.check) { + if (isTTY) clack.outro(''); + return 0; + } + + if (installMethod.kind === 'npx') { + for (const instruction of installMethod.manualInstructions) { + if (isTTY) { + clack.log.info(instruction); + } else { + writeLine(instruction); + } + } + if (isTTY) clack.outro(''); + return 0; + } + + if (installMethod.kind === 'unknown') { + if (isTTY) { + clack.log.info('Could not detect install method. Upgrade manually:'); + for (const instruction of installMethod.manualInstructions) { + clack.log.message(` ${instruction}`); + } + clack.outro(''); + } else { + writeLine('Could not detect install method. Upgrade manually:'); + for (const instruction of installMethod.manualInstructions) { + writeLine(` ${instruction}`); + } + } + return 0; + } + + if (!isAutoUpgradeMethod(installMethod)) { + return 0; + } + + if (options.yes) { + return executeUpgrade(installMethod, d, isTTY); + } + + if (!isTTY) { + writeLine(`Run: ${installMethod.manualCommand}`); + writeLine('Or re-run with --yes to upgrade automatically.'); + return 1; + } + + const confirmed = await clack.confirm({ + message: `Upgrade via ${installMethod.kind}?`, + initialValue: true, + }); + + if (clack.isCancel(confirmed) || !confirmed) { + clack.log.info('Upgrade skipped.'); + clack.outro(''); + return 0; + } + + return executeUpgrade(installMethod, d, isTTY); +} + +async function executeUpgrade( + method: AutoUpgradeMethod, + deps: UpgradeDependencies, + isTTY: boolean, +): Promise { + if (isTTY) { + clack.log.step(`Running: ${method.manualCommand}`); + } else { + writeLine(`Running: ${method.manualCommand}`); + } + + const exitCode = await deps.spawnUpgradeProcess(method.autoCommands); + + if (exitCode !== 0) { + const msg = `Upgrade process exited with code ${exitCode}.`; + if (isTTY) { + clack.log.error(msg); + clack.outro(''); + } else { + writeError(msg); + } + return exitCode; + } + + if (isTTY) { + clack.log.success('Upgrade complete.'); + clack.outro(''); + } else { + writeLine('Upgrade complete.'); + } + + return 0; +} + +// --- Yargs registration --- + +export function registerUpgradeCommand(app: Argv): void { + app.command( + 'upgrade', + 'Check for updates and upgrade XcodeBuildMCP', + (yargs) => + yargs + .option('check', { + type: 'boolean', + default: false, + describe: 'Check for updates without upgrading', + }) + .option('yes', { + type: 'boolean', + alias: 'y', + default: false, + describe: 'Skip confirmation and upgrade automatically', + }), + async (argv) => { + const exitCode = await runUpgradeCommand({ + check: argv.check as boolean, + yes: argv.yes as boolean, + }); + if (exitCode !== 0) { + process.exit(exitCode); + } + }, + ); +} diff --git a/src/cli/yargs-app.ts b/src/cli/yargs-app.ts index 15b3ec0f..5a56816f 100644 --- a/src/cli/yargs-app.ts +++ b/src/cli/yargs-app.ts @@ -7,6 +7,7 @@ import { registerInitCommand } from './commands/init.ts'; import { registerMcpCommand } from './commands/mcp.ts'; import { registerSetupCommand } from './commands/setup.ts'; import { registerToolsCommand } from './commands/tools.ts'; +import { registerUpgradeCommand } from './commands/upgrade.ts'; import { registerToolCommands } from './register-tool-commands.ts'; import { version } from '../version.ts'; import { coerceLogLevel, setLogLevel, type LogLevel } from '../utils/logger.ts'; @@ -75,6 +76,7 @@ export function buildYargsApp(opts: YargsAppOptions): ReturnType { registerMcpCommand(app); registerInitCommand(app, { workspaceRoot: opts.workspaceRoot }); registerSetupCommand(app); + registerUpgradeCommand(app); registerToolsCommand(app); registerToolCommands(app, opts.catalog, { workspaceRoot: opts.workspaceRoot, diff --git a/src/utils/version/index.ts b/src/utils/version/index.ts index e5e1336f..996394f6 100644 --- a/src/utils/version/index.ts +++ b/src/utils/version/index.ts @@ -1 +1 @@ -export { version } from '../../version.ts'; +export { version, packageName, repositoryOwner, repositoryName } from '../../version.ts';