diff --git a/README.md b/README.md index 17f241c..e3940f2 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,8 @@ npx @switchbot/openapi-cli codex setup Then restart Codex and confirm it's working. ``` +`codex setup` checks the npm registry for the latest CLI version and upgrades automatically if your global install is outdated — no manual `npm install -g` step needed. + **Or run directly (if CLI is already installed):** ```bash diff --git a/packages/codex-plugin/setup/check-credentials.js b/packages/codex-plugin/setup/check-credentials.js index 8020f86..802b4f8 100644 --- a/packages/codex-plugin/setup/check-credentials.js +++ b/packages/codex-plugin/setup/check-credentials.js @@ -85,10 +85,12 @@ async function tryDoctor(exec) { } } -async function tryKeychainDescribe(exec) { +async function tryKeychainGet(exec) { try { - await exec('switchbot', ['auth', 'keychain', 'describe', '--json'], { timeout: 8000 }); - return true; + const { stdout } = await exec('switchbot', ['auth', 'keychain', 'get', '--json'], { timeout: 8000 }); + const parsed = JSON.parse(stdout); + const data = parsed?.data ?? parsed; + return data?.present === true; } catch { return false; } @@ -104,7 +106,7 @@ export function makeCheckCredentials(exec) { // CLI missing — fall through to keychain } - const hasKeychainCredentials = await tryKeychainDescribe(exec); + const hasKeychainCredentials = await tryKeychainGet(exec); if (doctorResult?.reason === 'doctor-failed') { const errorKey = classifyDoctorFailure(doctorResult.detail ?? '', hasKeychainCredentials); diff --git a/packages/codex-plugin/skills/switchbot/SKILL.md b/packages/codex-plugin/skills/switchbot/SKILL.md index d1d2eea..3986b84 100644 --- a/packages/codex-plugin/skills/switchbot/SKILL.md +++ b/packages/codex-plugin/skills/switchbot/SKILL.md @@ -50,6 +50,14 @@ CLI doesn't know about it, refuse and explain — don't paper over it. --- +## Network requirements + +`codex setup` requires outbound internet access (npm registry + GitHub). Codex workspaces are offline by default. If setup reports a network error or the `check-network` step warns: + +→ Read `references/codex-network.md` for the exact `~/.codex/config.toml` fix. + +--- + ## Required bootstrap (run this first, every session) Before you take any action, establish context: diff --git a/packages/codex-plugin/skills/switchbot/references/codex-network.md b/packages/codex-plugin/skills/switchbot/references/codex-network.md new file mode 100644 index 0000000..cc69a80 --- /dev/null +++ b/packages/codex-plugin/skills/switchbot/references/codex-network.md @@ -0,0 +1,34 @@ +# Codex network access for SwitchBot setup + +Read this file when `switchbot codex setup` fails with a network error, or when the user asks why setup is failing or how to enable network access in Codex. + +## Why network access is required + +`switchbot codex setup` performs three network operations: + +1. **npm registry probe** — checks for the latest `@switchbot/openapi-cli` version +2. **npm install -g** — installs or upgrades the CLI if outdated +3. **codex plugin marketplace add** — clones the plugin from GitHub + +All three require outbound internet access. Codex workspaces are offline by default. + +## How to enable network access in Codex + +Add the following to `~/.codex/config.toml` (create the file if it does not exist): + +```toml +[sandbox_workspace_write] +network_access = true +``` + +Then **restart Codex** and re-run setup: + +``` +switchbot codex setup +``` + +## Notes + +- `network_access = true` enables outbound internet for `workspace-write` sandbox mode only. +- It does **not** reduce approval prompts on its own. Set `approval_policy = "on-request"` separately if you want fewer prompts. +- If setup still fails after enabling network, run `switchbot codex doctor` to see which checks are failing. diff --git a/packages/codex-plugin/tests/setup.test.js b/packages/codex-plugin/tests/setup.test.js index 180c6b7..2831e48 100644 --- a/packages/codex-plugin/tests/setup.test.js +++ b/packages/codex-plugin/tests/setup.test.js @@ -75,7 +75,7 @@ describe('checkCredentials', () => { err.stderr = 'HTTP 401 unauthorized'; throw err; } - if (args.includes('describe')) return { stdout: '{}' }; + if (args.includes('get')) return { stdout: JSON.stringify({ data: { present: true } }) }; throw new Error('unexpected'); }; const check = makeCheckCredentials(fakeExec); @@ -93,7 +93,7 @@ describe('checkCredentials', () => { err.stderr = 'connect ETIMEDOUT api.switch-bot.com'; throw err; } - if (args.includes('describe')) return { stdout: '{}' }; + if (args.includes('get')) return { stdout: JSON.stringify({ data: { present: true } }) }; throw new Error('unexpected'); }; const check = makeCheckCredentials(fakeExec); @@ -135,7 +135,7 @@ describe('checkCredentials', () => { if (args.includes('doctor')) { return { stdout: JSON.stringify({ data: { credentials: { configured: false } } }) }; } - if (args.includes('describe')) return { stdout: '{}' }; + if (args.includes('get')) return { stdout: JSON.stringify({ data: { present: true } }) }; throw new Error('unexpected'); }; const check = makeCheckCredentials(fakeExec); diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index 7bcbf86..24558d7 100644 --- a/src/commands/capabilities.ts +++ b/src/commands/capabilities.ts @@ -214,7 +214,7 @@ export const COMMAND_META: Record = { 'status-sync start': ACTION_LOCAL, 'status-sync stop': ACTION_LOCAL, 'status-sync status': READ_LOCAL, - 'reset': ACTION_LOCAL, + 'reset': DESTRUCTIVE_LOCAL, 'codex doctor': READ_LOCAL, 'codex repair': ACTION_LOCAL, 'codex setup': ACTION_LOCAL, diff --git a/src/commands/codex.ts b/src/commands/codex.ts index c4f24b0..129f769 100644 --- a/src/commands/codex.ts +++ b/src/commands/codex.ts @@ -15,17 +15,47 @@ import { import { isJsonMode, printJson } from '../utils/output.js'; import { getActiveProfile } from '../lib/request-context.js'; import { getConfigPath } from '../utils/flags.js'; +import { VERSION } from '../version.js'; + +export function compareVersions(a: string, b: string): -1 | 0 | 1 { + // Strip pre-release/build metadata (e.g. '3.8.0-rc.1+build' → '3.8.0') + const core = (v: string) => (v.split(/[-+]/)[0] ?? v).split('.').map(Number); + const pa = core(a); + const pb = core(b); + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i++) { + const na = pa[i] ?? 0; + const nb = pb[i] ?? 0; + if (na < nb) return -1; + if (na > nb) return 1; + } + return 0; +} + +function fetchLatestPublishedVersion(packageName: string): { version: string; fromRegistry: boolean } { + const r = spawnSync( + 'npm', ['view', packageName, 'version'], + { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 8000 }, + ); + if ((r.status ?? 1) === 0) { + const v = (r.stdout ?? '').trim(); + if (/^\d+\.\d+\.\d+/.test(v)) return { version: v, fromRegistry: true }; + } + // Offline or registry error: fall back to the running binary's own version. + // When invoked via npx, VERSION == latest, so the comparison still works. + return { version: VERSION, fromRegistry: false }; +} const CODEX_BASE_SECTIONS = ['node', 'path', 'credentials', 'mcp'] as const; const SWITCHBOT_CLI_PACKAGE = '@switchbot/openapi-cli'; async function runAllCodexDoctorChecks(): Promise { - const base = await runDoctorChecks(CODEX_BASE_SECTIONS); + const base = (await runDoctorChecks(CODEX_BASE_SECTIONS)) ?? []; const codexChecks: Check[] = [ checkCodexCli(), checkCodexPluginNpm(), checkCodexPluginRegistered(), - ]; + ].filter(Boolean) as Check[]; return [...base, ...codexChecks]; } @@ -81,7 +111,7 @@ function buildAuthLoginArgv(profile: string, configPath?: string): string[] { interface StepOutcome { step: string; - status: 'ok' | 'skipped' | 'failed'; + status: 'ok' | 'skipped' | 'failed' | 'warn'; message?: string; } @@ -304,12 +334,14 @@ function registerCodexRepairSubcommand(codex: Command): void { const { outcomes, anyFailed, preflightFailed } = await runRepair(skip, ctx); if (isJsonMode()) { - printJson({ ok: !anyFailed, preflightFailed, outcomes }); + const anyWarn = outcomes.some((o) => o.status === 'warn'); + printJson({ ok: !anyFailed, hasWarnings: anyWarn, preflightFailed, outcomes }); } else { for (const o of outcomes) { const icon = o.status === 'ok' ? chalk.green('✓') : o.status === 'skipped' ? chalk.dim('·') : + o.status === 'warn' ? chalk.yellow('⚠') : chalk.red('✗'); console.log(`${icon} ${o.step.padEnd(18)} ${o.message ?? ''}`); } @@ -342,12 +374,34 @@ type SetupOutcome = StepOutcome; const SETUP_STEPS: readonly StepDef[] = [ { name: 'check-codex-cli', description: 'Verify codex CLI on PATH', skippable: false }, - { name: 'install-switchbot-cli', description: 'Install @switchbot/openapi-cli if missing', skippable: true }, + { name: 'check-network', description: 'Probe npm registry; print Codex config hint if offline', skippable: true }, + { name: 'install-switchbot-cli', description: 'Install @switchbot/openapi-cli if missing or outdated', skippable: true }, { name: 'register-plugin', description: 'Register plugin (Route B git; npm install + Route A on fallback)', skippable: false }, { name: 'auth', description: 'Verify credentials; spawn auth login if missing', skippable: true }, { name: 'doctor-verify', description: 'Run 4 base + 3 Codex checks and report health', skippable: false }, ]; +function setupStepCheckNetwork(): SetupOutcome { + const r = spawnSync( + 'npm', ['ping'], + { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 5000 }, + ); + if ((r.status ?? 1) === 0) { + return { step: 'check-network', status: 'ok', message: 'npm registry reachable' }; + } + return { + step: 'check-network', + status: 'warn', + message: [ + 'npm registry unreachable — install and plugin registration require network access.', + 'To enable network in Codex, add to ~/.codex/config.toml:', + ' [sandbox_workspace_write]', + ' network_access = true', + 'Then restart Codex and re-run: switchbot codex setup', + ].join('\n'), + }; +} + function setupStepCheckCodexCli(): SetupOutcome { const c = checkCodexCli(); if (c.status === 'fail') { @@ -370,19 +424,56 @@ function setupStepInstallSwitchbotCli(): SetupOutcome { ); } +function resolveInstalledVersion(packageName: string): string | null { + const r = spawnSync( + 'npm', ['list', '-g', '--json', '--depth=0', packageName], + { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 15000 }, + ); + try { + const parsed = JSON.parse(r.stdout ?? '{}') as { + dependencies?: Record; + }; + return parsed?.dependencies?.[packageName]?.version ?? null; + } catch { + return null; + } +} + function setupStepInstallGlobalPackage(step: string, packageName: string): SetupOutcome { + const { version: latestVersion, fromRegistry } = fetchLatestPublishedVersion(packageName); + const registryNote = fromRegistry ? '' : ' (registry unreachable, used running version as reference)'; + const list = spawnSync( 'npm', ['list', '-g', '--json', '--depth=0', packageName], { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 15000 }, ); - let installed = false; + let installedVersion: string | null = null; try { - const parsed = JSON.parse(list.stdout ?? '{}') as { dependencies?: Record }; - installed = Boolean(parsed?.dependencies?.[packageName]); + const parsed = JSON.parse(list.stdout ?? '{}') as { + dependencies?: Record; + }; + installedVersion = parsed?.dependencies?.[packageName]?.version ?? null; } catch { /* treat as not installed */ } - if (installed) { - return { step, status: 'ok', message: 'already installed' }; + + if (installedVersion !== null) { + if (compareVersions(installedVersion, latestVersion) >= 0) { + return { step, status: 'ok', message: `already installed (${installedVersion})${registryNote}` }; + } + const upg = spawnSync( + 'npm', ['install', '-g', `${packageName}@latest`], + { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 120000 }, + ); + if ((upg.status ?? 1) !== 0) { + return { + step, + status: 'failed', + message: `npm install -g failed upgrading from ${installedVersion} (exit ${upg.status ?? 1}): ${upg.stderr ?? ''}`, + }; + } + const newVersion = resolveInstalledVersion(packageName) ?? latestVersion; + return { step, status: 'ok', message: `upgraded ${installedVersion} → ${newVersion}` }; } + const inst = spawnSync( 'npm', ['install', '-g', `${packageName}@latest`], { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 120000 }, @@ -394,7 +485,8 @@ function setupStepInstallGlobalPackage(step: string, packageName: string): Setup message: `npm install -g failed (exit ${inst.status ?? 1}): ${inst.stderr ?? ''}`, }; } - return { step, status: 'ok', message: `installed ${packageName}@latest` }; + const installedNow = resolveInstalledVersion(packageName) ?? latestVersion; + return { step, status: 'ok', message: `installed ${packageName}@${installedNow}` }; } function setupStepRegisterPlugin(ctx: SetupContext): SetupOutcome { @@ -447,14 +539,26 @@ async function runSetup( ): Promise<{ outcomes: SetupOutcome[]; anyFailed: boolean; preflightFailed: boolean }> { const outcomes: SetupOutcome[] = []; let preflightFailed = false; + let networkOffline = false; for (const step of SETUP_STEPS) { + // Auto-skip network-dependent steps when check-network warned + if (step.name === 'install-switchbot-cli' && networkOffline && !skip.has(step.name)) { + outcomes.push({ + step: step.name, + status: 'skipped', + message: 'skipped: npm registry unreachable (see check-network warning above)', + }); + continue; + } + if (skip.has(step.name)) { outcomes.push({ step: step.name, status: 'skipped' }); continue; } let outcome: SetupOutcome; if (step.name === 'check-codex-cli') outcome = setupStepCheckCodexCli(); + else if (step.name === 'check-network') outcome = setupStepCheckNetwork(); else if (step.name === 'install-switchbot-cli') outcome = setupStepInstallSwitchbotCli(); else if (step.name === 'register-plugin') outcome = setupStepRegisterPlugin(ctx); else if (step.name === 'auth') outcome = await setupStepAuth(ctx); @@ -464,6 +568,9 @@ async function runSetup( preflightFailed = true; break; } + if (step.name === 'check-network' && outcome.status === 'warn') { + networkOffline = true; + } } const anyFailed = outcomes.some((o) => o.status === 'failed'); return { outcomes, anyFailed, preflightFailed }; @@ -533,12 +640,14 @@ Environment variables: const { outcomes, anyFailed, preflightFailed } = await runSetup(skip, ctx); if (isJsonMode()) { - printJson({ ok: !anyFailed, preflightFailed, outcomes }); + const anyWarn = outcomes.some((o) => o.status === 'warn'); + printJson({ ok: !anyFailed, hasWarnings: anyWarn, preflightFailed, outcomes }); } else { for (const o of outcomes) { const icon = o.status === 'ok' ? chalk.green('✓') : o.status === 'skipped' ? chalk.dim('·') : + o.status === 'warn' ? chalk.yellow('⚠') : chalk.red('✗'); console.log(`${icon} ${o.step.padEnd(22)} ${o.message ?? ''}`); } diff --git a/tests/commands/codex.test.ts b/tests/commands/codex.test.ts index 612c4a1..de47bff 100644 --- a/tests/commands/codex.test.ts +++ b/tests/commands/codex.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { runCli } from '../helpers/cli.js'; -import { registerCodexCommand } from '../../src/commands/codex.js'; +import { registerCodexCommand, compareVersions } from '../../src/commands/codex.js'; +import { VERSION } from '../../src/version.js'; const spawnSyncRepairMock = vi.hoisted(() => vi.fn()); vi.mock('node:child_process', () => ({ spawnSync: spawnSyncRepairMock })); @@ -69,6 +70,27 @@ beforeEach(() => { tryLoadConfigMock.mockReset(); }); +describe('compareVersions (unit)', () => { + it('equal versions return 0', () => { + expect(compareVersions('3.7.3', '3.7.3')).toBe(0); + }); + it('older < newer returns -1', () => { + expect(compareVersions('3.7.0', '3.8.0')).toBe(-1); + }); + it('newer > older returns 1', () => { + expect(compareVersions('3.8.0', '3.7.0')).toBe(1); + }); + it('pre-release stripped: 3.7.3 vs 3.8.0-rc.1 returns -1', () => { + expect(compareVersions('3.7.3', '3.8.0-rc.1')).toBe(-1); + }); + it('pre-release stripped: 3.8.0-rc.1 vs 3.7.3 returns 1', () => { + expect(compareVersions('3.8.0-rc.1', '3.7.3')).toBe(1); + }); + it('same core version with pre-release returns 0', () => { + expect(compareVersions('3.8.0', '3.8.0-rc.1')).toBe(0); + }); +}); + describe('switchbot codex doctor', () => { function setupAllOk() { runDoctorChecksMock.mockResolvedValue(makeBaseChecks()); @@ -403,7 +425,165 @@ describe('switchbot codex setup', () => { tryLoadConfigMock.mockReset(); }); - it('--dry-run prints the 5-step list without mutating', async () => { + // ── version-aware install-switchbot-cli step ────────────────────────────── + + it('already at latest published version → skips npm install', async () => { + checkCodexCliMock.mockReturnValueOnce({ + name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, + }); + // npm ping succeeds (check-network step) + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: 'Ping success: ...', stderr: '' }); + // npm view returns VERSION as latest → installed version matches → no upgrade + spawnSyncRepairMock.mockReturnValueOnce({ + status: 0, stdout: VERSION + '\n', stderr: '', + }); + // npm list -g: installed at VERSION + spawnSyncRepairMock.mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: VERSION } } }), + stderr: '', + }); + registerCodexPluginMock.mockReturnValueOnce({ + ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: null, + }); + tryLoadConfigMock.mockReturnValue({ token: 't', secret: 's' }); + runDoctorChecksMock.mockResolvedValueOnce(makeBaseChecks()); + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'ok' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'ok' }); + + const { exitCode, stdout } = await runCli(registerCodexCommand, ['codex', 'setup', '--json']); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.join('')) as { + data?: { outcomes: Array<{ step: string; status: string; message?: string }> }; + }; + const step = parsed.data!.outcomes.find((o) => o.step === 'install-switchbot-cli')!; + expect(step.status).toBe('ok'); + expect(step.message).toMatch(/already installed/); + const installCalls = spawnSyncRepairMock.mock.calls.filter( + (c) => (c[1] as string[]).includes('install'), + ); + expect(installCalls).toHaveLength(0); + }); + + it('outdated version (npm view detects newer) → auto-upgrades and reports versions', async () => { + checkCodexCliMock.mockReturnValueOnce({ + name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, + }); + // npm ping succeeds (check-network step) + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: 'Ping success: ...', stderr: '' }); + // npm view: latest is 99.0.0 (simulates a future release) + spawnSyncRepairMock.mockReturnValueOnce({ + status: 0, stdout: '99.0.0\n', stderr: '', + }); + // npm list -g: installed at 1.0.0 + spawnSyncRepairMock.mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: '1.0.0' } } }), + stderr: '', + }); + // npm install -g succeeds + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); + // resolveInstalledVersion re-verify after upgrade + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: '99.0.0' } } }), stderr: '' }); + registerCodexPluginMock.mockReturnValueOnce({ + ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: null, + }); + tryLoadConfigMock.mockReturnValue({ token: 't', secret: 's' }); + runDoctorChecksMock.mockResolvedValueOnce(makeBaseChecks()); + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'ok' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'ok' }); + + const { exitCode, stdout } = await runCli(registerCodexCommand, ['codex', 'setup', '--json']); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.join('')) as { + data?: { outcomes: Array<{ step: string; status: string; message?: string }> }; + }; + const step = parsed.data!.outcomes.find((o) => o.step === 'install-switchbot-cli')!; + expect(step.status).toBe('ok'); + expect(step.message).toMatch(/upgraded/); + expect(step.message).toContain('1.0.0'); + expect(step.message).toContain('99.0.0'); + const installCalls = spawnSyncRepairMock.mock.calls.filter( + (c) => (c[1] as string[]).includes('install'), + ); + expect(installCalls).toHaveLength(1); + }); + + it('upgrade failure returns failed outcome with npm stderr', async () => { + checkCodexCliMock.mockReturnValueOnce({ + name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, + }); + // npm ping succeeds (check-network step) + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: 'Ping success: ...', stderr: '' }); + // npm view: latest is 99.0.0 + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: '99.0.0\n', stderr: '' }); + // npm list -g: installed at 1.0.0 + spawnSyncRepairMock.mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: '1.0.0' } } }), + stderr: '', + }); + // npm install -g fails + spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'EACCES permission denied' }); + // pipeline continues past the failed install step + registerCodexPluginMock.mockReturnValueOnce({ ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: null }); + tryLoadConfigMock.mockReturnValue({ token: 't', secret: 's' }); + runDoctorChecksMock.mockResolvedValueOnce(makeBaseChecks()); + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'ok' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'ok' }); + + const { exitCode, stdout } = await runCli(registerCodexCommand, ['codex', 'setup', '--yes', '--json']); + expect(exitCode).toBe(1); + const parsed = JSON.parse(stdout.join('')) as { + data?: { outcomes: Array<{ step: string; status: string; message?: string }> }; + }; + const step = parsed.data!.outcomes.find((o) => o.step === 'install-switchbot-cli')!; + expect(step.status).toBe('failed'); + expect(step.message).toContain('EACCES'); + }); + + it('npm view offline → falls back to VERSION as latest, still upgrades if installed is older', async () => { + checkCodexCliMock.mockReturnValueOnce({ + name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, + }); + // npm ping succeeds (check-network step) + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: 'Ping success: ...', stderr: '' }); + // npm view fails (offline) + spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'ENOTFOUND' }); + // npm list -g: installed at 1.0.0 (older than VERSION) + spawnSyncRepairMock.mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: '1.0.0' } } }), + stderr: '', + }); + // npm install -g succeeds + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); + // resolveInstalledVersion re-verify after upgrade + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: VERSION } } }), stderr: '' }); + registerCodexPluginMock.mockReturnValueOnce({ + ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: null, + }); + tryLoadConfigMock.mockReturnValue({ token: 't', secret: 's' }); + runDoctorChecksMock.mockResolvedValueOnce(makeBaseChecks()); + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'ok' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'ok' }); + + const { exitCode, stdout } = await runCli(registerCodexCommand, ['codex', 'setup', '--json']); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.join('')) as { + data?: { outcomes: Array<{ step: string; status: string; message?: string }> }; + }; + const step = parsed.data!.outcomes.find((o) => o.step === 'install-switchbot-cli')!; + expect(step.status).toBe('ok'); + expect(step.message).toMatch(/upgraded/); + expect(step.message).toContain(VERSION); + }); + + it('--dry-run prints the 6-step list without mutating', async () => { const { exitCode, stderr } = await runCli( registerCodexCommand, ['codex', 'setup', '--dry-run'], @@ -411,6 +591,7 @@ describe('switchbot codex setup', () => { expect(exitCode).toBe(0); const out = stderr.join('\n'); expect(out).toContain('check-codex-cli'); + expect(out).toContain('check-network'); expect(out).toContain('install-switchbot-cli'); expect(out).not.toContain('install-codex-plugin'); expect(out).toContain('register-plugin'); @@ -421,7 +602,7 @@ describe('switchbot codex setup', () => { expect(registerCodexPluginMock).not.toHaveBeenCalled(); }); - it('--dry-run --json emits 5 ordered steps with skippable flags', async () => { + it('--dry-run --json emits 6 ordered steps with skippable flags', async () => { const { exitCode, stdout } = await runCli( registerCodexCommand, ['codex', 'setup', '--dry-run', '--json'], @@ -434,11 +615,12 @@ describe('switchbot codex setup', () => { }; const data = parsed.data ?? parsed; expect(data.dryRun).toBe(true); - expect(data.steps).toHaveLength(5); + expect(data.steps).toHaveLength(6); expect(data.steps?.map((s) => s.name)).toEqual([ - 'check-codex-cli', 'install-switchbot-cli', 'register-plugin', 'auth', 'doctor-verify', + 'check-codex-cli', 'check-network', 'install-switchbot-cli', 'register-plugin', 'auth', 'doctor-verify', ]); const skippable = Object.fromEntries(data.steps!.map((s) => [s.name, s.skippable])); + expect(skippable['check-network']).toBe(true); expect(skippable['install-switchbot-cli']).toBe(true); expect(skippable['auth']).toBe(true); expect(skippable['check-codex-cli']).toBe(false); @@ -481,10 +663,16 @@ describe('switchbot codex setup', () => { checkCodexCliMock.mockReturnValueOnce({ name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex', version: 'codex 1.2.3' }, }); + // npm ping succeeds (check-network step) + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: 'Ping success: ...', stderr: '' }); + // npm view: returns current VERSION (no upgrade needed) + spawnSyncRepairMock.mockReturnValueOnce({ + status: 0, stdout: VERSION + '\n', stderr: '', + }); // install-switchbot-cli step: npm list -g returns the package as already installed spawnSyncRepairMock.mockReturnValueOnce({ status: 0, - stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: '1.0.0' } } }), + stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: VERSION } } }), stderr: '', }); // register-plugin: Route B (registerCodexPluginGit) succeeds — no npm install needed @@ -521,10 +709,16 @@ describe('switchbot codex setup', () => { checkCodexCliMock.mockReturnValueOnce({ name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, }); + // npm ping succeeds (check-network step) + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: 'Ping success: ...', stderr: '' }); + // npm view: returns current VERSION (no upgrade needed) + spawnSyncRepairMock.mockReturnValueOnce({ + status: 0, stdout: VERSION + '\n', stderr: '', + }); // install-switchbot-cli: already installed spawnSyncRepairMock.mockReturnValueOnce({ status: 0, - stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: '1.0.0' } } }), + stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: VERSION } } }), stderr: '', }); // register-plugin: Route B succeeds @@ -563,6 +757,8 @@ describe('switchbot codex setup', () => { checkCodexCliMock.mockReturnValueOnce({ name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, }); + // npm ping succeeds (check-network step runs even when install-switchbot-cli is skipped) + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: 'Ping success: ...', stderr: '' }); // register-plugin: Route B succeeds — no npm install needed registerCodexPluginMock.mockReturnValueOnce({ ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: null, @@ -584,14 +780,23 @@ describe('switchbot codex setup', () => { }; const step = parsed.data!.outcomes.find((o) => o.step === 'install-switchbot-cli'); expect(step?.status).toBe('skipped'); - // Route B succeeded — no npm calls at all - expect(spawnSyncRepairMock).not.toHaveBeenCalled(); + // check-network calls npm ping; no npm install calls + const installCalls = spawnSyncRepairMock.mock.calls.filter( + (c) => (c[1] as string[]).includes('install'), + ); + expect(installCalls).toHaveLength(0); }); it('install-switchbot-cli failure exits 1 (not 2 — only check-codex-cli is preflight)', async () => { checkCodexCliMock.mockReturnValueOnce({ name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, }); + // npm ping succeeds (check-network step) + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: 'Ping success: ...', stderr: '' }); + // npm view: returns current VERSION (no upgrade needed) + spawnSyncRepairMock.mockReturnValueOnce({ + status: 0, stdout: VERSION + '\n', stderr: '', + }); // npm list -g says not installed spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '{}', stderr: '' }); // npm install -g fails @@ -617,7 +822,7 @@ describe('switchbot codex setup', () => { data?: { preflightFailed: boolean; outcomes: Array<{ step: string; status: string }> }; }; expect(parsed.data!.preflightFailed).toBe(false); - expect(parsed.data!.outcomes).toHaveLength(5); // all 5 steps ran (no preflight halt) + expect(parsed.data!.outcomes).toHaveLength(6); // all 6 steps ran (no preflight halt) expect(parsed.data!.outcomes.find((o) => o.step === 'install-switchbot-cli')?.status).toBe('failed'); // register-plugin still got called despite the earlier failure expect(registerCodexPluginMock).toHaveBeenCalledOnce(); @@ -627,10 +832,16 @@ describe('switchbot codex setup', () => { checkCodexCliMock.mockReturnValueOnce({ name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, }); + // npm ping succeeds (check-network step) + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: 'Ping success: ...', stderr: '' }); + // npm view: returns current VERSION (no upgrade needed) + spawnSyncRepairMock.mockReturnValueOnce({ + status: 0, stdout: VERSION + '\n', stderr: '', + }); // install-switchbot-cli: already installed spawnSyncRepairMock.mockReturnValueOnce({ status: 0, - stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: '1.0.0' } } }), + stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: VERSION } } }), stderr: '', }); // register-plugin: registerCodexPluginAuto handles Route B failure + on-demand install internally. @@ -665,10 +876,16 @@ describe('switchbot codex setup', () => { checkCodexCliMock.mockReturnValueOnce({ name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, }); + // npm ping succeeds (check-network step) + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: 'Ping success: ...', stderr: '' }); + // npm view: returns current VERSION (no upgrade needed) + spawnSyncRepairMock.mockReturnValueOnce({ + status: 0, stdout: VERSION + '\n', stderr: '', + }); // install-switchbot-cli: already installed spawnSyncRepairMock.mockReturnValueOnce({ status: 0, - stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: '1.0.0' } } }), + stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: VERSION } } }), stderr: '', }); // register-plugin: ok @@ -692,4 +909,70 @@ describe('switchbot codex setup', () => { expect(authStep?.status).toBe('failed'); expect(authStep?.message).toContain('auth login exited 1'); }); + + it('check-network ok when npm ping succeeds', async () => { + checkCodexCliMock.mockReturnValueOnce({ + name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, + }); + // npm ping succeeds + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: 'Ping success: ...', stderr: '' }); + // npm view: current version (install-switchbot-cli step) + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: VERSION + '\n', stderr: '' }); + // npm list -g: already installed at VERSION + spawnSyncRepairMock.mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: VERSION } } }), + stderr: '', + }); + registerCodexPluginMock.mockReturnValueOnce({ ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: null }); + tryLoadConfigMock.mockReturnValue({ token: 't', secret: 's' }); + runDoctorChecksMock.mockResolvedValueOnce(makeBaseChecks()); + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'ok' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'ok' }); + + const { exitCode, stdout } = await runCli(registerCodexCommand, ['codex', 'setup', '--json']); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.join('')) as { + data?: { hasWarnings?: boolean; outcomes: Array<{ step: string; status: string; message?: string }> }; + }; + const step = parsed.data!.outcomes.find((o) => o.step === 'check-network')!; + expect(step.status).toBe('ok'); + expect(step.message).toContain('reachable'); + // Fix 3: no warnings → hasWarnings must be falsy + expect(parsed.data?.hasWarnings).toBeFalsy(); + }); + + it('check-network warn when npm ping fails, includes config.toml hint', async () => { + checkCodexCliMock.mockReturnValueOnce({ + name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, + }); + // npm ping fails (offline / sandboxed) + spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'ENOTFOUND' }); + // install-switchbot-cli is auto-skipped when check-network warns — no npm view or npm list -g mocks needed + registerCodexPluginMock.mockReturnValueOnce({ ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: null }); + tryLoadConfigMock.mockReturnValue({ token: 't', secret: 's' }); + runDoctorChecksMock.mockResolvedValueOnce(makeBaseChecks()); + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'ok' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'ok' }); + + const { exitCode, stdout } = await runCli(registerCodexCommand, ['codex', 'setup', '--json']); + // warn is non-blocking — overall should still be 0 if other steps succeed + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.join('')) as { + data?: { hasWarnings?: boolean; outcomes: Array<{ step: string; status: string; message?: string }> }; + }; + const step = parsed.data!.outcomes.find((o) => o.step === 'check-network')!; + expect(step.status).toBe('warn'); + expect(step.message).toContain('sandbox_workspace_write'); + expect(step.message).toContain('network_access = true'); + expect(step.message).toContain('~/.codex/config.toml'); + // Fix 1: install-switchbot-cli auto-skipped when network is offline + const installStep = parsed.data!.outcomes.find((o) => o.step === 'install-switchbot-cli')!; + expect(installStep.status).toBe('skipped'); + expect(installStep.message).toMatch(/skipped|unreachable/); + // Fix 3: hasWarnings must be true when any step returned warn + expect(parsed.data?.hasWarnings).toBe(true); + }); });