From ed989ab541065850b5a261a0316098fe61c568f5 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 11:15:02 +0800 Subject: [PATCH 01/10] feat(codex): auto-upgrade outdated CLI by checking npm registry in codex setup Co-Authored-By: Claude Sonnet 4.6 --- src/commands/codex.ts | 80 +++++++++++++---- tests/commands/codex.test.ts | 168 ++++++++++++++++++++++++++++++++++- 2 files changed, 229 insertions(+), 19 deletions(-) diff --git a/src/commands/codex.ts b/src/commands/codex.ts index c4f24b0..cc04e5e 100644 --- a/src/commands/codex.ts +++ b/src/commands/codex.ts @@ -15,17 +15,45 @@ 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'; + +function compareVersions(a: string, b: string): -1 | 0 | 1 { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + 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): string { + 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 v; + } + // 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; +} 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]; } @@ -158,12 +186,12 @@ function repairStepRemovePlugin(ctx: RepairContext): RepairOutcome { } function stepRegisterPluginShared(stepName: string, ctx: { codexPluginId?: string; packageRoot?: string | null }): StepOutcome { - const r = registerCodexPluginAuto(); - if (!r.ok) { - return { step: stepName, status: 'failed', message: r.error }; + const r = registerCodexPluginAuto() as { ok: boolean; pluginId?: string; packageRoot?: string | null; error?: string } | null | undefined; + if (!r || !r.ok) { + return { step: stepName, status: 'failed', message: r?.error ?? 'registerCodexPluginAuto returned no result' }; } ctx.codexPluginId = r.pluginId; - ctx.packageRoot = r.packageRoot; + ctx.packageRoot = r.packageRoot ?? null; return { step: stepName, status: 'ok', message: 'marketplace add + plugin add succeeded' }; } @@ -371,18 +399,40 @@ function setupStepInstallSwitchbotCli(): SetupOutcome { } function setupStepInstallGlobalPackage(step: string, packageName: string): SetupOutcome { + const latestVersion = fetchLatestPublishedVersion(packageName); + 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})` }; + } + // Installed but outdated — upgrade automatically + 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 ?? ''}`, + }; + } + return { step, status: 'ok', message: `upgraded ${installedVersion} → ${latestVersion}` }; } + + // Not installed at all const inst = spawnSync( 'npm', ['install', '-g', `${packageName}@latest`], { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 120000 }, @@ -398,12 +448,12 @@ function setupStepInstallGlobalPackage(step: string, packageName: string): Setup } function setupStepRegisterPlugin(ctx: SetupContext): SetupOutcome { - const r = registerCodexPluginAuto(); - if (!r.ok) { - return { step: 'register-plugin', status: 'failed', message: r.error }; + const r = registerCodexPluginAuto() as { ok: boolean; pluginId?: string; packageRoot?: string | null; error?: string } | null | undefined; + if (!r || !r.ok) { + return { step: 'register-plugin', status: 'failed', message: r?.error ?? 'registerCodexPluginAuto returned no result' }; } ctx.codexPluginId = r.pluginId; - ctx.packageRoot = r.packageRoot; + ctx.packageRoot = r.packageRoot ?? null; const via = r.packageRoot ? 'local npm (Route A fallback)' : 'git marketplace (Route B)'; return { step: 'register-plugin', status: 'ok', message: `registered via ${via}` }; } diff --git a/tests/commands/codex.test.ts b/tests/commands/codex.test.ts index 612c4a1..099c5a8 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 { VERSION } from '../../src/version.js'; const spawnSyncRepairMock = vi.hoisted(() => vi.fn()); vi.mock('node:child_process', () => ({ spawnSync: spawnSyncRepairMock })); @@ -403,6 +404,145 @@ describe('switchbot codex setup', () => { tryLoadConfigMock.mockReset(); }); + // ── 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 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 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: '' }); + 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 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' }); + + 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 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: '' }); + 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 5-step list without mutating', async () => { const { exitCode, stderr } = await runCli( registerCodexCommand, @@ -481,10 +621,14 @@ describe('switchbot codex setup', () => { checkCodexCliMock.mockReturnValueOnce({ name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex', version: 'codex 1.2.3' }, }); + // 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 +665,14 @@ describe('switchbot codex setup', () => { checkCodexCliMock.mockReturnValueOnce({ name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, }); + // 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 @@ -592,6 +740,10 @@ describe('switchbot codex setup', () => { checkCodexCliMock.mockReturnValueOnce({ name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, }); + // 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 @@ -627,10 +779,14 @@ describe('switchbot codex setup', () => { checkCodexCliMock.mockReturnValueOnce({ name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, }); + // 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 +821,14 @@ describe('switchbot codex setup', () => { checkCodexCliMock.mockReturnValueOnce({ name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, }); + // 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 From 274682d076995c88e2cd8868519ca777b7c9602f Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 11:15:29 +0800 Subject: [PATCH 02/10] docs: document codex setup auto-upgrade via npm registry check Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 ++ 1 file changed, 2 insertions(+) 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 From 0d2adec4b0ce5930c8bc660c8b9e4ca4186183be Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 11:22:56 +0800 Subject: [PATCH 03/10] fix(codex): surface registry-unavailability in setup output, report resolved version on fresh install Co-Authored-By: Claude Sonnet 4.6 --- src/commands/codex.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/commands/codex.ts b/src/commands/codex.ts index cc04e5e..b0331e3 100644 --- a/src/commands/codex.ts +++ b/src/commands/codex.ts @@ -30,18 +30,18 @@ function compareVersions(a: string, b: string): -1 | 0 | 1 { return 0; } -function fetchLatestPublishedVersion(packageName: string): string { +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 v; + 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; + return { version: VERSION, fromRegistry: false }; } const CODEX_BASE_SECTIONS = ['node', 'path', 'credentials', 'mcp'] as const; @@ -399,7 +399,8 @@ function setupStepInstallSwitchbotCli(): SetupOutcome { } function setupStepInstallGlobalPackage(step: string, packageName: string): SetupOutcome { - const latestVersion = fetchLatestPublishedVersion(packageName); + 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], @@ -415,9 +416,8 @@ function setupStepInstallGlobalPackage(step: string, packageName: string): Setup if (installedVersion !== null) { if (compareVersions(installedVersion, latestVersion) >= 0) { - return { step, status: 'ok', message: `already installed (${installedVersion})` }; + return { step, status: 'ok', message: `already installed (${installedVersion})${registryNote}` }; } - // Installed but outdated — upgrade automatically const upg = spawnSync( 'npm', ['install', '-g', `${packageName}@latest`], { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 120000 }, @@ -432,7 +432,6 @@ function setupStepInstallGlobalPackage(step: string, packageName: string): Setup return { step, status: 'ok', message: `upgraded ${installedVersion} → ${latestVersion}` }; } - // Not installed at all const inst = spawnSync( 'npm', ['install', '-g', `${packageName}@latest`], { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 120000 }, @@ -444,7 +443,7 @@ 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` }; + return { step, status: 'ok', message: `installed ${packageName}@${latestVersion}` }; } function setupStepRegisterPlugin(ctx: SetupContext): SetupOutcome { From d40681c2c50ec83c55d034feaa9e84e4937b480a Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 11:30:41 +0800 Subject: [PATCH 04/10] docs(codex-plugin): add network-access reference + SKILL.md hook for Codex sandbox guidance Co-Authored-By: Claude Sonnet 4.6 --- .../codex-plugin/skills/switchbot/SKILL.md | 8 +++++ .../switchbot/references/codex-network.md | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 packages/codex-plugin/skills/switchbot/references/codex-network.md 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. From 4df9edf9d8a1f8d4954f1b5900e5f071f4e54247 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 11:33:35 +0800 Subject: [PATCH 05/10] feat(codex): add check-network step with Codex config.toml hint when offline Co-Authored-By: Claude Sonnet 4.6 --- src/commands/codex.ts | 28 +++++++++- tests/commands/codex.test.ts | 104 ++++++++++++++++++++++++++++++++--- 2 files changed, 123 insertions(+), 9 deletions(-) diff --git a/src/commands/codex.ts b/src/commands/codex.ts index b0331e3..063fb83 100644 --- a/src/commands/codex.ts +++ b/src/commands/codex.ts @@ -109,7 +109,7 @@ function buildAuthLoginArgv(profile: string, configPath?: string): string[] { interface StepOutcome { step: string; - status: 'ok' | 'skipped' | 'failed'; + status: 'ok' | 'skipped' | 'failed' | 'warn'; message?: string; } @@ -370,12 +370,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') { @@ -504,6 +526,7 @@ async function runSetup( } 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); @@ -588,6 +611,7 @@ Environment variables: 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 099c5a8..6955e46 100644 --- a/tests/commands/codex.test.ts +++ b/tests/commands/codex.test.ts @@ -410,6 +410,8 @@ 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 VERSION as latest → installed version matches → no upgrade spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: VERSION + '\n', stderr: '', @@ -447,6 +449,8 @@ 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: latest is 99.0.0 (simulates a future release) spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: '99.0.0\n', stderr: '', @@ -488,6 +492,8 @@ 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: latest is 99.0.0 spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: '99.0.0\n', stderr: '' }); // npm list -g: installed at 1.0.0 @@ -513,6 +519,8 @@ 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 fails (offline) spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'ENOTFOUND' }); // npm list -g: installed at 1.0.0 (older than VERSION) @@ -543,7 +551,7 @@ describe('switchbot codex setup', () => { expect(step.message).toContain(VERSION); }); - it('--dry-run prints the 5-step list without mutating', async () => { + it('--dry-run prints the 6-step list without mutating', async () => { const { exitCode, stderr } = await runCli( registerCodexCommand, ['codex', 'setup', '--dry-run'], @@ -551,6 +559,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'); @@ -561,7 +570,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'], @@ -574,11 +583,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); @@ -621,6 +631,8 @@ 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: '', @@ -665,6 +677,8 @@ 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: '', @@ -711,6 +725,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, @@ -732,14 +748,19 @@ 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: '', @@ -769,7 +790,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(); @@ -779,6 +800,8 @@ 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: '', @@ -821,6 +844,8 @@ 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: '', @@ -852,4 +877,69 @@ 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?: { 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'); + }); + + 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: npm view offline fallback + spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'ENOTFOUND' }); + // 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']); + // 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?: { 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'); + }); }); From 0ea38141a3ef55767c63c0ea94b6710cc4b43ef5 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 11:51:49 +0800 Subject: [PATCH 06/10] fix(codex): strip pre-release in compareVersions, relax version regex to accept semver pre-release - export compareVersions and strip pre-release/build metadata before numeric comparison so '3.7.3' vs '3.8.0-rc.1' correctly returns -1 - remove '$' anchor from fetchLatestPublishedVersion regex so pre-release versions from the npm registry are accepted instead of silently falling back to VERSION - add 6 unit tests covering the fixed cases Co-Authored-By: Claude Sonnet 4.6 --- src/commands/codex.ts | 10 ++++++---- tests/commands/codex.test.ts | 23 ++++++++++++++++++++++- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/commands/codex.ts b/src/commands/codex.ts index 063fb83..7922022 100644 --- a/src/commands/codex.ts +++ b/src/commands/codex.ts @@ -17,9 +17,11 @@ import { getActiveProfile } from '../lib/request-context.js'; import { getConfigPath } from '../utils/flags.js'; import { VERSION } from '../version.js'; -function compareVersions(a: string, b: string): -1 | 0 | 1 { - const pa = a.split('.').map(Number); - const pb = b.split('.').map(Number); +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; @@ -37,7 +39,7 @@ function fetchLatestPublishedVersion(packageName: string): { version: string; fr ); if ((r.status ?? 1) === 0) { const v = (r.stdout ?? '').trim(); - if (/^\d+\.\d+\.\d+$/.test(v)) return { version: v, fromRegistry: true }; + 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. diff --git a/tests/commands/codex.test.ts b/tests/commands/codex.test.ts index 6955e46..f28262e 100644 --- a/tests/commands/codex.test.ts +++ b/tests/commands/codex.test.ts @@ -1,6 +1,6 @@ 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()); @@ -70,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()); From 9ae5bc3ea51b769325155f20419ec2434d23544e Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 11:57:21 +0800 Subject: [PATCH 07/10] fix(codex): auto-skip install when offline, repair warn renderer, hasWarnings JSON field, remove dead null guard Co-Authored-By: Claude Sonnet 4.6 --- src/commands/codex.ts | 37 ++++++++++++++++++++++++++---------- tests/commands/codex.test.ts | 28 +++++++++++++++++---------- 2 files changed, 45 insertions(+), 20 deletions(-) diff --git a/src/commands/codex.ts b/src/commands/codex.ts index 7922022..2e2c6f3 100644 --- a/src/commands/codex.ts +++ b/src/commands/codex.ts @@ -188,12 +188,12 @@ function repairStepRemovePlugin(ctx: RepairContext): RepairOutcome { } function stepRegisterPluginShared(stepName: string, ctx: { codexPluginId?: string; packageRoot?: string | null }): StepOutcome { - const r = registerCodexPluginAuto() as { ok: boolean; pluginId?: string; packageRoot?: string | null; error?: string } | null | undefined; - if (!r || !r.ok) { - return { step: stepName, status: 'failed', message: r?.error ?? 'registerCodexPluginAuto returned no result' }; + const r = registerCodexPluginAuto(); + if (!r.ok) { + return { step: stepName, status: 'failed', message: r.error }; } ctx.codexPluginId = r.pluginId; - ctx.packageRoot = r.packageRoot ?? null; + ctx.packageRoot = r.packageRoot; return { step: stepName, status: 'ok', message: 'marketplace add + plugin add succeeded' }; } @@ -334,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 ?? ''}`); } @@ -471,12 +473,12 @@ function setupStepInstallGlobalPackage(step: string, packageName: string): Setup } function setupStepRegisterPlugin(ctx: SetupContext): SetupOutcome { - const r = registerCodexPluginAuto() as { ok: boolean; pluginId?: string; packageRoot?: string | null; error?: string } | null | undefined; - if (!r || !r.ok) { - return { step: 'register-plugin', status: 'failed', message: r?.error ?? 'registerCodexPluginAuto returned no result' }; + const r = registerCodexPluginAuto(); + if (!r.ok) { + return { step: 'register-plugin', status: 'failed', message: r.error }; } ctx.codexPluginId = r.pluginId; - ctx.packageRoot = r.packageRoot ?? null; + ctx.packageRoot = r.packageRoot; const via = r.packageRoot ? 'local npm (Route A fallback)' : 'git marketplace (Route B)'; return { step: 'register-plugin', status: 'ok', message: `registered via ${via}` }; } @@ -520,8 +522,19 @@ 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; @@ -538,6 +551,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 }; @@ -607,7 +623,8 @@ 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 = diff --git a/tests/commands/codex.test.ts b/tests/commands/codex.test.ts index f28262e..5a3496b 100644 --- a/tests/commands/codex.test.ts +++ b/tests/commands/codex.test.ts @@ -525,6 +525,13 @@ describe('switchbot codex setup', () => { }); // 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); @@ -923,11 +930,13 @@ describe('switchbot codex setup', () => { 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 }> }; + 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 () => { @@ -936,14 +945,7 @@ describe('switchbot codex setup', () => { }); // npm ping fails (offline / sandboxed) spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'ENOTFOUND' }); - // install-switchbot-cli: npm view offline fallback - spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'ENOTFOUND' }); - // npm list -g: installed at VERSION - spawnSyncRepairMock.mockReturnValueOnce({ - status: 0, - stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: VERSION } } }), - stderr: '', - }); + // 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()); @@ -955,12 +957,18 @@ describe('switchbot codex setup', () => { // 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?: { outcomes: Array<{ step: string; status: string; message?: string }> }; + 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); }); }); From e3fc98ef8597a873dfddb5b744212681a5a1ddc2 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 12:02:56 +0800 Subject: [PATCH 08/10] fix(codex): re-verify installed version after npm install-g to report accurate version in success message Co-Authored-By: Claude Sonnet 4.6 --- src/commands/codex.ts | 21 +++++++++++++++++++-- tests/commands/codex.test.ts | 4 ++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/commands/codex.ts b/src/commands/codex.ts index 2e2c6f3..129f769 100644 --- a/src/commands/codex.ts +++ b/src/commands/codex.ts @@ -424,6 +424,21 @@ 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)'; @@ -455,7 +470,8 @@ function setupStepInstallGlobalPackage(step: string, packageName: string): Setup message: `npm install -g failed upgrading from ${installedVersion} (exit ${upg.status ?? 1}): ${upg.stderr ?? ''}`, }; } - return { step, status: 'ok', message: `upgraded ${installedVersion} → ${latestVersion}` }; + const newVersion = resolveInstalledVersion(packageName) ?? latestVersion; + return { step, status: 'ok', message: `upgraded ${installedVersion} → ${newVersion}` }; } const inst = spawnSync( @@ -469,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}@${latestVersion}` }; + const installedNow = resolveInstalledVersion(packageName) ?? latestVersion; + return { step, status: 'ok', message: `installed ${packageName}@${installedNow}` }; } function setupStepRegisterPlugin(ctx: SetupContext): SetupOutcome { diff --git a/tests/commands/codex.test.ts b/tests/commands/codex.test.ts index 5a3496b..de47bff 100644 --- a/tests/commands/codex.test.ts +++ b/tests/commands/codex.test.ts @@ -484,6 +484,8 @@ describe('switchbot codex setup', () => { }); // 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, }); @@ -559,6 +561,8 @@ describe('switchbot codex setup', () => { }); // 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, }); From d0c4607cd5bf8204b5188151910a5e333a4c34bc Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 12:24:51 +0800 Subject: [PATCH 09/10] fix: check actual keychain credentials via get --json, mark reset as DESTRUCTIVE_LOCAL - check-credentials.js: replace tryKeychainDescribe (auth keychain describe --json) with tryKeychainGet (auth keychain get --json, inspects present===true) so a fresh-install machine with no stored token is no longer falsely reported as authenticated - capabilities.ts: reclassify 'reset' from ACTION_LOCAL to DESTRUCTIVE_LOCAL since it permanently removes credentials, config, audit logs, quota data, history, and caches Co-Authored-By: Claude Sonnet 4.6 --- packages/codex-plugin/setup/check-credentials.js | 10 ++++++---- src/commands/capabilities.ts | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) 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/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, From 0cebc02bcc767641235e30f5e2f0fa3a4907f85e Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 12:28:09 +0800 Subject: [PATCH 10/10] fix(codex-plugin): update keychain mocks from describe to get --json with present field --- packages/codex-plugin/tests/setup.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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);