From 1f375699c4d335585146b0d70a73ddbdbb4ea85e Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 23 May 2026 23:51:26 +0800 Subject: [PATCH 01/31] =?UTF-8?q?feat(codex):=20Route=20B=20=E2=80=94=20re?= =?UTF-8?q?gister=20plugin=20via=20git=20marketplace=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch codex plugin registration from npm-local path to OpenWonderLabs/switchbot-openapi-cli git marketplace source (--sparse packages/codex-plugin --ref main). Avoids the @-scoped path misclassification entirely and makes the plugin discoverable without a prior npm install. - Add CODEX_GIT_MARKETPLACE_REPO/SPARSE/REF constants - Add runCodexPluginRegistrationGit() and registerCodexPluginGit() - Update stepRegisterPluginShared (codex.ts) and stepRegisterCodexPlugin (default-steps.ts) to use the git path - Update tests: remove npm-root-g mock from stepRegisterCodexPlugin; add runCodexPluginRegistrationGit and registerCodexPluginGit suites --- src/commands/codex.ts | 3 +- src/install/codex-checks.ts | 65 ++++++++++--- src/install/default-steps.ts | 4 +- tests/commands/codex.test.ts | 1 + tests/install/codex-checks.test.ts | 148 ++++++++++++++++++++++++++--- 5 files changed, 194 insertions(+), 27 deletions(-) diff --git a/src/commands/codex.ts b/src/commands/codex.ts index 7cf3533..9f43c18 100644 --- a/src/commands/codex.ts +++ b/src/commands/codex.ts @@ -7,6 +7,7 @@ import { checkCodexPluginNpm, checkCodexPluginRegistered, registerCodexPlugin, + registerCodexPluginGit, resolvePluginId, resolveCodexPackageRoot, type Check, @@ -156,7 +157,7 @@ function repairStepRemovePlugin(ctx: RepairContext): RepairOutcome { } function stepRegisterPluginShared(stepName: string, ctx: { codexPluginId?: string; packageRoot?: string | null }): StepOutcome { - const r = registerCodexPlugin(); + const r = registerCodexPluginGit(); if (!r.ok) { return { step: stepName, status: 'failed', message: r.error }; } diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index c80ea71..ae74e5e 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -79,31 +79,41 @@ function computeAliasPath(): string { } export function resolveMarketplaceSourceRoot(packageRoot: string): string { - if (process.platform !== 'win32' || !/^[A-Za-z]:[\\/].*[\\/]@[^\\/]+[\\/]/.test(packageRoot)) { - return packageRoot; - } + // Codex misclassifies local paths containing `@`-scoped npm segments + // (e.g. `…/node_modules/@switchbot/codex-plugin`) as ref-bearing git sources, + // causing `marketplace add` to fail with "--ref is only supported for git + // marketplace sources". Affects Windows and Linux/macOS alike. Bridge through + // a symlink/junction at a stable `@`-free location. + const needsAlias = process.platform === 'win32' + ? /^[A-Za-z]:[\\/].*[\\/]@[^\\/]+[\\/]/.test(packageRoot) + : /\/@[^/]+\//.test(packageRoot); + + if (!needsAlias) return packageRoot; const aliasRoot = computeAliasPath(); fs.mkdirSync(path.dirname(aliasRoot), { recursive: true }); + const linkType = process.platform === 'win32' ? 'junction' : 'dir'; + const stat = fs.lstatSync(aliasRoot, { throwIfNoEntry: false }); if (!stat) { - fs.symlinkSync(packageRoot, aliasRoot, 'junction'); + fs.symlinkSync(packageRoot, aliasRoot, linkType); return aliasRoot; } if (stat.isSymbolicLink()) { const aliasReal = fs.realpathSync(aliasRoot); const packageReal = fs.realpathSync(packageRoot); - if (aliasReal.toLowerCase() === packageReal.toLowerCase()) { - return aliasRoot; - } + const pathsMatch = process.platform === 'win32' + ? aliasReal.toLowerCase() === packageReal.toLowerCase() + : aliasReal === packageReal; + if (pathsMatch) return aliasRoot; fs.unlinkSync(aliasRoot); - fs.symlinkSync(packageRoot, aliasRoot, 'junction'); + fs.symlinkSync(packageRoot, aliasRoot, linkType); return aliasRoot; } - throw new Error(`alias path ${aliasRoot} exists and is not a junction; remove it manually and retry`); + throw new Error(`alias path ${aliasRoot} exists and is not a symlink/junction; remove it manually and retry`); } /** Single authoritative plugin ID resolver. Mirrors install.js:resolvePluginIdentifier. */ @@ -242,8 +252,7 @@ export function resolveCodexPackageRoot(): { ok: true; packageRoot: string } | { /** * 共享注册 helper:封装 resolveCodexPackageRoot → resolvePluginId → runCodexPluginRegistration。 - * `install --agent codex`、`codex repair`、`codex setup` 三处注册步骤都通过此函数执行, - * 禁止再各自内联 `npm root -g` 或 pluginId 拼接。 + * 保留作为 npm-local 路径注册的后备;新路径请用 registerCodexPluginGit()。 */ export function registerCodexPlugin(): RegisterCodexPluginResult { const root = resolveCodexPackageRoot(); @@ -264,3 +273,37 @@ export function registerCodexPlugin(): RegisterCodexPluginResult { } return { ok: true, pluginId, packageRoot: root.packageRoot }; } + +// ─── Git-based marketplace registration (Route B) ──────────────────────────── + +export const CODEX_GIT_MARKETPLACE_REPO = 'OpenWonderLabs/switchbot-openapi-cli'; +export const CODEX_GIT_MARKETPLACE_SPARSE = 'packages/codex-plugin'; +export const CODEX_GIT_MARKETPLACE_REF = 'main'; + +export function runCodexPluginRegistrationGit(pluginId: string): RegistrationResult { + const mkt = spawnStr('codex', [ + 'plugin', 'marketplace', 'add', + CODEX_GIT_MARKETPLACE_REPO, + '--sparse', CODEX_GIT_MARKETPLACE_SPARSE, + '--ref', CODEX_GIT_MARKETPLACE_REF, + ]); + if (mkt.status !== 0) { + return { ok: false, exitCode: mkt.status, stderr: mkt.stderr, stage: 'marketplace-add' }; + } + spawnStr('codex', ['plugin', 'remove', pluginId]); + const add = spawnStr('codex', ['plugin', 'add', pluginId]); + return { ok: add.status === 0, exitCode: add.status, stderr: add.stderr, stage: 'plugin-add' }; +} + +export function registerCodexPluginGit(): RegisterCodexPluginResult { + const pluginId = 'switchbot@codex-plugin'; + const r = runCodexPluginRegistrationGit(pluginId); + if (!r.ok) { + return { + ok: false, pluginId, packageRoot: '', + error: `${r.stage} exit ${r.exitCode}: ${r.stderr}`, + exitCode: r.exitCode, stderr: r.stderr, + }; + } + return { ok: true, pluginId, packageRoot: '' }; +} diff --git a/src/install/default-steps.ts b/src/install/default-steps.ts index 0d9bd9e..aa8cfcd 100644 --- a/src/install/default-steps.ts +++ b/src/install/default-steps.ts @@ -24,7 +24,7 @@ import { } from '../commands/policy.js'; import { promptTokenAndSecret, readCredentialsFile } from '../commands/config.js'; import { selectCredentialStore, type CredentialStore, type CredentialBundle } from '../credentials/keychain.js'; -import { registerCodexPlugin } from './codex-checks.js'; +import { registerCodexPlugin, registerCodexPluginGit } from './codex-checks.js'; export type AgentName = 'claude-code' | 'cursor' | 'copilot' | 'codex' | 'none'; @@ -340,7 +340,7 @@ export function stepRegisterCodexPlugin(): InstallStep { name: 'register-codex-plugin', description: 'Register @switchbot/codex-plugin with the Codex CLI (marketplace add + plugin add)', async execute(ctx) { - const r = registerCodexPlugin(); + const r = registerCodexPluginGit(); if (!r.ok) { throw new Error(`Codex plugin registration failed: ${r.error}`); } diff --git a/tests/commands/codex.test.ts b/tests/commands/codex.test.ts index fa06195..d577bde 100644 --- a/tests/commands/codex.test.ts +++ b/tests/commands/codex.test.ts @@ -23,6 +23,7 @@ vi.mock('../../src/install/codex-checks.js', async (importOriginal) => { checkCodexPluginNpm: checkCodexPluginNpmMock, checkCodexPluginRegistered: checkCodexPluginRegisteredMock, registerCodexPlugin: registerCodexPluginMock, + registerCodexPluginGit: registerCodexPluginMock, }; }); diff --git a/tests/install/codex-checks.test.ts b/tests/install/codex-checks.test.ts index 006e0e2..69a0dcc 100644 --- a/tests/install/codex-checks.test.ts +++ b/tests/install/codex-checks.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { stepRegisterCodexPlugin } from '../../src/install/default-steps.js'; import type { InstallContext } from '../../src/install/default-steps.js'; @@ -36,9 +36,11 @@ import { checkCodexPluginNpm, checkCodexPluginRegistered, runCodexPluginRegistration, + runCodexPluginRegistrationGit, resolveMarketplaceSourceRoot, resolvePluginId, registerCodexPlugin, + registerCodexPluginGit, } from '../../src/install/codex-checks.js'; function makeSpawnResult(status: number, stdout: string, stderr = ''): ReturnType { @@ -301,12 +303,141 @@ describe('resolveMarketplaceSourceRoot', () => { it('throws when the alias path is a real directory', () => { if (process.platform !== 'win32') return; lstatSyncMock.mockReturnValue(makeStat(false)); - expect(() => resolveMarketplaceSourceRoot(SCOPED_ROOT)).toThrow(/exists and is not a junction/); + expect(() => resolveMarketplaceSourceRoot(SCOPED_ROOT)).toThrow(/not a.*junction/i); expect(unlinkSyncMock).not.toHaveBeenCalled(); expect(symlinkSyncMock).not.toHaveBeenCalled(); }); }); +// Codex misclassifies local paths containing `@`-scoped npm segments on all +// platforms, not just Windows. The following tests verify that Linux paths like +// `/home/user/.npm-global/lib/node_modules/@switchbot/codex-plugin` also get +// bridged through an alias symlink so the registered path contains no `@`. +describe('resolveMarketplaceSourceRoot — Linux @-scoped path handling', () => { + const LINUX_SCOPED_ROOT = '/home/user/.npm-global/lib/node_modules/@switchbot/codex-plugin'; + const LINUX_PLAIN_ROOT = '/home/user/.npm-global/lib/node_modules/switchbot-plugin'; + + const savedPlatform = process.platform; + function makeStat(isSymlink: boolean) { + return { isSymbolicLink: () => isSymlink } as unknown as ReturnType; + } + + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + }); + afterEach(() => { + Object.defineProperty(process, 'platform', { value: savedPlatform, configurable: true }); + }); + + it('returns plain Linux path unchanged (no @-scoped segment)', () => { + const result = resolveMarketplaceSourceRoot(LINUX_PLAIN_ROOT); + expect(result).toBe(LINUX_PLAIN_ROOT); + expect(symlinkSyncMock).not.toHaveBeenCalled(); + }); + + it('creates a symlink when alias is missing', () => { + lstatSyncMock.mockReturnValue(null); + const result = resolveMarketplaceSourceRoot(LINUX_SCOPED_ROOT); + expect(mkdirSyncMock).toHaveBeenCalledWith(expect.stringMatching(/switchbot$/), { recursive: true }); + expect(symlinkSyncMock).toHaveBeenCalledWith( + LINUX_SCOPED_ROOT, + expect.stringMatching(/codex-plugin-marketplace$/), + 'dir', + ); + expect(unlinkSyncMock).not.toHaveBeenCalled(); + expect(result).toMatch(/codex-plugin-marketplace$/); + expect(result).not.toContain('@'); + }); + + it('reuses an existing symlink pointing to current packageRoot', () => { + lstatSyncMock.mockReturnValue(makeStat(true)); + realpathSyncMock + .mockReturnValueOnce(LINUX_SCOPED_ROOT) + .mockReturnValueOnce(LINUX_SCOPED_ROOT); + const result = resolveMarketplaceSourceRoot(LINUX_SCOPED_ROOT); + expect(unlinkSyncMock).not.toHaveBeenCalled(); + expect(symlinkSyncMock).not.toHaveBeenCalled(); + expect(result).toMatch(/codex-plugin-marketplace$/); + }); + + it('repairs a stale symlink pointing elsewhere', () => { + lstatSyncMock.mockReturnValue(makeStat(true)); + realpathSyncMock + .mockReturnValueOnce('/old/path/@switchbot/codex-plugin') + .mockReturnValueOnce(LINUX_SCOPED_ROOT); + const result = resolveMarketplaceSourceRoot(LINUX_SCOPED_ROOT); + expect(unlinkSyncMock).toHaveBeenCalledWith(expect.stringMatching(/codex-plugin-marketplace$/)); + expect(symlinkSyncMock).toHaveBeenCalledWith( + LINUX_SCOPED_ROOT, + expect.stringMatching(/codex-plugin-marketplace$/), + 'dir', + ); + expect(result).toMatch(/codex-plugin-marketplace$/); + }); + + it('throws when alias path is a real directory (not a symlink)', () => { + lstatSyncMock.mockReturnValue(makeStat(false)); + expect(() => resolveMarketplaceSourceRoot(LINUX_SCOPED_ROOT)).toThrow(/not a.*symlink/i); + expect(unlinkSyncMock).not.toHaveBeenCalled(); + expect(symlinkSyncMock).not.toHaveBeenCalled(); + }); +}); + +describe('runCodexPluginRegistrationGit', () => { + it('returns ok when marketplace add and plugin add both succeed', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (git) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (pre-clean) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + const r = runCodexPluginRegistrationGit('switchbot@codex-plugin'); + expect(r.ok).toBe(true); + expect(r.exitCode).toBe(0); + }); + + it('returns failure when marketplace add exits non-zero', () => { + spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')); + const r = runCodexPluginRegistrationGit('switchbot@codex-plugin'); + expect(r.ok).toBe(false); + expect(r.stderr).toBe('git clone failed'); + expect(r.stage).toBe('marketplace-add'); + }); + + it('returns failure when plugin add exits non-zero', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove + .mockReturnValueOnce(makeSpawnResult(1, '', 'plugin add error')); + const r = runCodexPluginRegistrationGit('switchbot@codex-plugin'); + expect(r.ok).toBe(false); + expect(r.stderr).toBe('plugin add error'); + expect(r.stage).toBe('plugin-add'); + }); +}); + +describe('registerCodexPluginGit', () => { + it('returns ok with fixed pluginId and empty packageRoot', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + const r = registerCodexPluginGit(); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.pluginId).toBe('switchbot@codex-plugin'); + expect(r.packageRoot).toBe(''); + } + }); + + it('returns failure when marketplace add fails', () => { + spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '', 'git error')); + const r = registerCodexPluginGit(); + expect(r.ok).toBe(false); + expect(r.pluginId).toBe('switchbot@codex-plugin'); + expect(r.error).toMatch(/marketplace-add exit 1: git error/); + expect(r.exitCode).toBe(1); + }); +}); + describe('stepRegisterCodexPlugin', () => { function makeCtx(overrides: Partial = {}): InstallContext { return { @@ -320,8 +451,7 @@ describe('stepRegisterCodexPlugin', () => { it('sets codexPluginRegistered and codexPluginIdentifier on success', async () => { spawnSyncMock - .mockReturnValueOnce({ status: 0, stdout: '/usr/local/lib/node_modules\n', stderr: '' }) // npm root -g - .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // marketplace add + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // marketplace add (git) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // plugin remove (pre-clean) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); // plugin add const step = stepRegisterCodexPlugin(); @@ -331,17 +461,9 @@ describe('stepRegisterCodexPlugin', () => { expect(ctx.codexPluginIdentifier).toBe('switchbot@codex-plugin'); }); - it('throws when npm root -g fails', async () => { - spawnSyncMock.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'npm error' }); - const step = stepRegisterCodexPlugin(); - const ctx = makeCtx(); - await expect(step.execute(ctx)).rejects.toThrow('npm root -g failed'); - }); - it('throws when runCodexPluginRegistration fails', async () => { spawnSyncMock - .mockReturnValueOnce({ status: 0, stdout: '/usr/local/lib/node_modules\n', stderr: '' }) - .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'marketplace error' }); + .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'marketplace error' }); // marketplace add const step = stepRegisterCodexPlugin(); const ctx = makeCtx(); await expect(step.execute(ctx)).rejects.toThrow('Codex plugin registration failed'); From 2a10b1ee427ab3523b73fb7392752812f27e8b06 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 23 May 2026 23:29:29 +0800 Subject: [PATCH 02/31] fix(codex): extend @-scoped path alias to Linux/macOS, fix WSL auth crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveMarketplaceSourceRoot was gating the junction/symlink workaround behind process.platform === 'win32', so Linux paths containing a scoped npm segment (e.g. /@switchbot/codex-plugin) were passed raw to Codex CLI, which misclassifies any source path containing '@' as a ref-bearing git source and exits 1 with "--ref is only supported for git marketplace sources". - Detect '/@scope/' on Linux/macOS with /\/@[^/]+\//.test(packageRoot) - Create a 'dir' symlink (not a junction) on non-Windows platforms - Use case-sensitive realpath comparison on non-Windows - Update error message to "not a symlink/junction" to cover both cases - Add 5 new tests that mock process.platform='linux' to cover all alias branches (create, reuse, repair, plain-path, real-dir-throws) Also fix auth login crash in WSL: open v10 does not attach an error handler to the spawned ChildProcess when wait=false (the default), so ENOENT from a missing PowerShell fires via process.nextTick before await open() resolves — no try/catch can intercept it. Pre-check the WSL PowerShell path with existsSync and fall back to printing the URL instead of crashing. --- src/auth/browser-login.ts | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/auth/browser-login.ts b/src/auth/browser-login.ts index 0e22d9d..af03d72 100644 --- a/src/auth/browser-login.ts +++ b/src/auth/browser-login.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'fs'; import open from 'open'; import { generateState } from './csrf.js'; import { bindCallbackServer } from './oauth-callback.js'; @@ -38,11 +39,9 @@ export async function browserLogin(options: BrowserLoginOptions = {}): Promise { + if (process.platform === 'linux') { + // Default WSL mount; if the user has a custom root= in /etc/wsl.conf and + // PowerShell lives there, open() will work fine and this check is skipped. + const wslPsPath = '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'; + if (!existsSync(wslPsPath)) return false; + } + try { + await open(url); + return true; + } catch { + return false; + } +} + function startCountdown(deadline: number): { stop(): void } { if (!process.stderr.isTTY) return { stop() {} }; From 1e19940a5827b5bd881b78a6e2435486962c94ad Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 00:25:02 +0800 Subject: [PATCH 03/31] =?UTF-8?q?fix(codex):=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20regex=20scope,=20null=20packageRoot,=20WSL=20detect?= =?UTF-8?q?ion,=20env-override=20REF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/auth/browser-login.ts | 8 +++++--- src/install/codex-checks.ts | 12 ++++++------ tests/install/codex-checks.test.ts | 6 +++--- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/auth/browser-login.ts b/src/auth/browser-login.ts index af03d72..59ca807 100644 --- a/src/auth/browser-login.ts +++ b/src/auth/browser-login.ts @@ -75,9 +75,11 @@ function buildLoginUrl(params: { redirectUri: string; state: string }): string { // around `await open()` can intercept it and the process crashes. Pre-check the // executable path in WSL to avoid the unhandled 'error' event entirely. async function tryOpenBrowser(url: string): Promise { - if (process.platform === 'linux') { - // Default WSL mount; if the user has a custom root= in /etc/wsl.conf and - // PowerShell lives there, open() will work fine and this check is skipped. + if (process.platform === 'linux' && process.env['WSL_DISTRO_NAME'] !== undefined) { + // WSL: open delegates to PowerShell via wsl-utils. Check the default Windows + // mount path; if missing, fall back to printing the URL rather than crashing. + // Known limitation: non-default root= mounts in /etc/wsl.conf are not checked — + // if PS lives elsewhere open() will still be skipped here. const wslPsPath = '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'; if (!existsSync(wslPsPath)) return false; } diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index ae74e5e..5d71533 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -19,7 +19,7 @@ export interface RegistrationResult { export interface RegisterCodexPluginResult { ok: boolean; pluginId: string; - packageRoot: string; + packageRoot: string | null; error?: string; exitCode?: number; stderr?: string; @@ -86,7 +86,7 @@ export function resolveMarketplaceSourceRoot(packageRoot: string): string { // a symlink/junction at a stable `@`-free location. const needsAlias = process.platform === 'win32' ? /^[A-Za-z]:[\\/].*[\\/]@[^\\/]+[\\/]/.test(packageRoot) - : /\/@[^/]+\//.test(packageRoot); + : /\/node_modules\/@[^/]+\//.test(packageRoot); if (!needsAlias) return packageRoot; @@ -257,7 +257,7 @@ export function resolveCodexPackageRoot(): { ok: true; packageRoot: string } | { export function registerCodexPlugin(): RegisterCodexPluginResult { const root = resolveCodexPackageRoot(); if (!root.ok) { - return { ok: false, pluginId: '', packageRoot: '', error: root.error }; + return { ok: false, pluginId: '', packageRoot: null, error: root.error }; } const pluginId = resolvePluginId(root.packageRoot); const r = runCodexPluginRegistration(root.packageRoot, pluginId); @@ -278,7 +278,7 @@ export function registerCodexPlugin(): RegisterCodexPluginResult { export const CODEX_GIT_MARKETPLACE_REPO = 'OpenWonderLabs/switchbot-openapi-cli'; export const CODEX_GIT_MARKETPLACE_SPARSE = 'packages/codex-plugin'; -export const CODEX_GIT_MARKETPLACE_REF = 'main'; +export const CODEX_GIT_MARKETPLACE_REF = process.env['CODEX_GIT_MARKETPLACE_REF'] ?? 'main'; export function runCodexPluginRegistrationGit(pluginId: string): RegistrationResult { const mkt = spawnStr('codex', [ @@ -300,10 +300,10 @@ export function registerCodexPluginGit(): RegisterCodexPluginResult { const r = runCodexPluginRegistrationGit(pluginId); if (!r.ok) { return { - ok: false, pluginId, packageRoot: '', + ok: false, pluginId, packageRoot: null, error: `${r.stage} exit ${r.exitCode}: ${r.stderr}`, exitCode: r.exitCode, stderr: r.stderr, }; } - return { ok: true, pluginId, packageRoot: '' }; + return { ok: true, pluginId, packageRoot: null }; } diff --git a/tests/install/codex-checks.test.ts b/tests/install/codex-checks.test.ts index 69a0dcc..22c1e61 100644 --- a/tests/install/codex-checks.test.ts +++ b/tests/install/codex-checks.test.ts @@ -206,7 +206,7 @@ describe('registerCodexPlugin (shared helper)', () => { expect(r.ok).toBe(false); expect(r.error).toMatch(/npm root -g failed/); expect(r.pluginId).toBe(''); - expect(r.packageRoot).toBe(''); + expect(r.packageRoot).toBeNull(); }); it('returns failure with normalized error when registration step fails', () => { @@ -415,7 +415,7 @@ describe('runCodexPluginRegistrationGit', () => { }); describe('registerCodexPluginGit', () => { - it('returns ok with fixed pluginId and empty packageRoot', () => { + it('returns ok with fixed pluginId and null packageRoot', () => { spawnSyncMock .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove @@ -424,7 +424,7 @@ describe('registerCodexPluginGit', () => { expect(r.ok).toBe(true); if (r.ok) { expect(r.pluginId).toBe('switchbot@codex-plugin'); - expect(r.packageRoot).toBe(''); + expect(r.packageRoot).toBeNull(); } }); From a3d70ce25bcea44479fd370d74a1e2715f9e8fa2 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 00:34:27 +0800 Subject: [PATCH 04/31] fix(codex): fall back to local npm path when GitHub is unreachable (registerCodexPluginAuto) --- src/commands/codex.ts | 3 ++- src/install/codex-checks.ts | 11 +++++++++ src/install/default-steps.ts | 4 ++-- tests/commands/codex.test.ts | 1 + tests/install/codex-checks.test.ts | 38 +++++++++++++++++++----------- 5 files changed, 40 insertions(+), 17 deletions(-) diff --git a/src/commands/codex.ts b/src/commands/codex.ts index 9f43c18..194296f 100644 --- a/src/commands/codex.ts +++ b/src/commands/codex.ts @@ -8,6 +8,7 @@ import { checkCodexPluginRegistered, registerCodexPlugin, registerCodexPluginGit, + registerCodexPluginAuto, resolvePluginId, resolveCodexPackageRoot, type Check, @@ -157,7 +158,7 @@ function repairStepRemovePlugin(ctx: RepairContext): RepairOutcome { } function stepRegisterPluginShared(stepName: string, ctx: { codexPluginId?: string; packageRoot?: string | null }): StepOutcome { - const r = registerCodexPluginGit(); + const r = registerCodexPluginAuto(); if (!r.ok) { return { step: stepName, status: 'failed', message: r.error }; } diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index 5d71533..5560afb 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -307,3 +307,14 @@ export function registerCodexPluginGit(): RegisterCodexPluginResult { } return { ok: true, pluginId, packageRoot: null }; } + +/** + * Try Route B (git marketplace) first; fall back to local npm path if GitHub + * is unreachable or the clone fails. This preserves air-gapped / corporate + * environments where @switchbot/codex-plugin is already installed locally. + */ +export function registerCodexPluginAuto(): RegisterCodexPluginResult { + const git = registerCodexPluginGit(); + if (git.ok) return git; + return registerCodexPlugin(); +} diff --git a/src/install/default-steps.ts b/src/install/default-steps.ts index aa8cfcd..78154cd 100644 --- a/src/install/default-steps.ts +++ b/src/install/default-steps.ts @@ -24,7 +24,7 @@ import { } from '../commands/policy.js'; import { promptTokenAndSecret, readCredentialsFile } from '../commands/config.js'; import { selectCredentialStore, type CredentialStore, type CredentialBundle } from '../credentials/keychain.js'; -import { registerCodexPlugin, registerCodexPluginGit } from './codex-checks.js'; +import { registerCodexPlugin, registerCodexPluginGit, registerCodexPluginAuto } from './codex-checks.js'; export type AgentName = 'claude-code' | 'cursor' | 'copilot' | 'codex' | 'none'; @@ -340,7 +340,7 @@ export function stepRegisterCodexPlugin(): InstallStep { name: 'register-codex-plugin', description: 'Register @switchbot/codex-plugin with the Codex CLI (marketplace add + plugin add)', async execute(ctx) { - const r = registerCodexPluginGit(); + const r = registerCodexPluginAuto(); if (!r.ok) { throw new Error(`Codex plugin registration failed: ${r.error}`); } diff --git a/tests/commands/codex.test.ts b/tests/commands/codex.test.ts index d577bde..6c8a3a4 100644 --- a/tests/commands/codex.test.ts +++ b/tests/commands/codex.test.ts @@ -24,6 +24,7 @@ vi.mock('../../src/install/codex-checks.js', async (importOriginal) => { checkCodexPluginRegistered: checkCodexPluginRegisteredMock, registerCodexPlugin: registerCodexPluginMock, registerCodexPluginGit: registerCodexPluginMock, + registerCodexPluginAuto: registerCodexPluginMock, }; }); diff --git a/tests/install/codex-checks.test.ts b/tests/install/codex-checks.test.ts index 22c1e61..e2f8c21 100644 --- a/tests/install/codex-checks.test.ts +++ b/tests/install/codex-checks.test.ts @@ -41,6 +41,7 @@ import { resolvePluginId, registerCodexPlugin, registerCodexPluginGit, + registerCodexPluginAuto, } from '../../src/install/codex-checks.js'; function makeSpawnResult(status: number, stdout: string, stderr = ''): ReturnType { @@ -414,27 +415,36 @@ describe('runCodexPluginRegistrationGit', () => { }); }); -describe('registerCodexPluginGit', () => { - it('returns ok with fixed pluginId and null packageRoot', () => { +describe('registerCodexPluginAuto', () => { + it('returns git result when Route B succeeds', () => { spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (git) .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add - const r = registerCodexPluginGit(); + const r = registerCodexPluginAuto(); expect(r.ok).toBe(true); - if (r.ok) { - expect(r.pluginId).toBe('switchbot@codex-plugin'); - expect(r.packageRoot).toBeNull(); - } + expect(r.packageRoot).toBeNull(); }); - it('returns failure when marketplace add fails', () => { - spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '', 'git error')); - const r = registerCodexPluginGit(); + it('falls back to local npm path when Route B fails', () => { + existsSyncMock.mockReturnValue(true); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // marketplace add (git) — fails + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n', '')) // npm root -g + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + const r = registerCodexPluginAuto(); + expect(r.ok).toBe(true); + expect(r.packageRoot).toMatch(/codex-plugin/); + }); + + it('returns failure when both Route B and local path fail', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // marketplace add (git) — fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm error')); // npm root -g — fails + const r = registerCodexPluginAuto(); expect(r.ok).toBe(false); - expect(r.pluginId).toBe('switchbot@codex-plugin'); - expect(r.error).toMatch(/marketplace-add exit 1: git error/); - expect(r.exitCode).toBe(1); }); }); From 6da05387d98926d005c279a50144194cca27fb4f Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 00:44:04 +0800 Subject: [PATCH 05/31] fix(auth,codex): close OAuth server on error, preserve git error context, rm dead imports - browser-login: call close() in catch so the OAuth callback server port is always released when wait() rejects (timeout or cancel) - codex-checks: registerCodexPluginAuto now surfaces both git and npm error messages when both routes fail, instead of silently dropping the git failure - codex-checks: read CODEX_GIT_MARKETPLACE_REF at call time inside runCodexPluginRegistrationGit so env overrides applied after module load are respected - codex.ts / default-steps.ts: remove dead imports of registerCodexPlugin and registerCodexPluginGit (neither is called directly) - tests: add close mock to browser-login fixture and assert it fires on rejection; strengthen dual-failure test to verify combined error message --- src/auth/browser-login.ts | 1 + src/commands/codex.ts | 2 -- src/install/codex-checks.ts | 12 +++++++++--- src/install/default-steps.ts | 2 +- tests/auth/browser-login.test.ts | 4 +++- tests/install/codex-checks.test.ts | 2 ++ 6 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/auth/browser-login.ts b/src/auth/browser-login.ts index 59ca807..a384b9b 100644 --- a/src/auth/browser-login.ts +++ b/src/auth/browser-login.ts @@ -55,6 +55,7 @@ export async function browserLogin(options: BrowserLoginOptions = {}): Promise void; lines: string[] } { describe('browserLogin', () => { beforeEach(() => { vi.clearAllMocks(); - bindMock.mockResolvedValue({ port: 53245, wait: waitMock }); + bindMock.mockResolvedValue({ port: 53245, wait: waitMock, close: closeMock }); exchangeMock.mockResolvedValue({ token: 'tok', secret: 'sec' }); }); @@ -101,5 +102,6 @@ describe('browserLogin', () => { await expect(browserLogin({ noOpen: true, log: () => {} })) .rejects.toThrow('Login timed out'); expect(exchangeMock).not.toHaveBeenCalled(); + expect(closeMock).toHaveBeenCalledOnce(); }); }); diff --git a/tests/install/codex-checks.test.ts b/tests/install/codex-checks.test.ts index e2f8c21..1435458 100644 --- a/tests/install/codex-checks.test.ts +++ b/tests/install/codex-checks.test.ts @@ -445,6 +445,8 @@ describe('registerCodexPluginAuto', () => { .mockReturnValueOnce(makeSpawnResult(1, '', 'npm error')); // npm root -g — fails const r = registerCodexPluginAuto(); expect(r.ok).toBe(false); + expect(r.error).toMatch(/git clone failed/); + expect(r.error).toMatch(/npm error/); }); }); From cf8d0d40c9327d25bf8eaab9c85449e5d9c3c639 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 00:55:07 +0800 Subject: [PATCH 06/31] fix(codex): simplify plugin setup guidance --- README.md | 7 ++++-- packages/codex-plugin/README.md | 11 +++----- packages/codex-plugin/bin/install.js | 25 +++++++++++++------ packages/codex-plugin/tests/install.test.js | 24 ++++++++++-------- .../resolve-marketplace-source-root.test.js | 20 +++++++++++++++ src/commands/install.ts | 6 ++--- src/install/codex-checks.ts | 6 ++--- src/install/preflight.ts | 4 +-- tests/install/codex-checks.test.ts | 8 ++---- 9 files changed, 70 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index b81ea7f..fc44ccb 100644 --- a/README.md +++ b/README.md @@ -195,14 +195,17 @@ Codex will run the setup, walk you through signing in, and let you know when it' npx @switchbot/openapi-cli codex setup ``` -**Manual install** (if you prefer explicit control): +**Advanced manual registration** (only if you already installed both packages): ```bash npm install -g @switchbot/openapi-cli @switchbot/codex-plugin -switchbot install --agent codex # register-only; package must already be installed +switchbot install --agent codex # register-only fallback switchbot auth login ``` +For normal use, prefer `npx @switchbot/openapi-cli codex setup`; it handles +package install, plugin registration, auth, and verification together. + **Health check and repair:** ```bash diff --git a/packages/codex-plugin/README.md b/packages/codex-plugin/README.md index 5d427f9..00c0c71 100644 --- a/packages/codex-plugin/README.md +++ b/packages/codex-plugin/README.md @@ -8,7 +8,7 @@ Codex plugin for SwitchBot smart-home control through the authoritative - A Codex skill at `skills/switchbot/SKILL.md` - An MCP server definition that runs `switchbot mcp serve --tools all` - A best-effort `onInstall` hook that runs non-interactive setup when the CLI is present -- A bootstrap binary: `switchbot-codex-install` +- Legacy helper binaries: `switchbot-codex-auth` and `switchbot-codex-install` ## Requirements @@ -115,16 +115,14 @@ Expected result: ## Uninstall -Remove the plugin entry you installed. Common Codex plugin IDs are: +Remove the plugin entry: ```bash -codex plugin remove switchbot@switchbot-skill codex plugin remove switchbot@codex-plugin ``` -Repo-marketplace installs usually use `switchbot@switchbot-skill`. Package-local -marketplace installs use `switchbot@codex-plugin` (matches the package directory -name). +Older prerelease installs may have used `switchbot@switchbot-skill`; removing +that id is harmless if Codex reports it is not installed. If you installed the npm package globally and also want to remove the helper commands: @@ -138,7 +136,6 @@ npm uninstall -g @switchbot/codex-plugin To remove the plugin, local policy files, and stored login state: ```bash -codex plugin remove switchbot@switchbot-skill codex plugin remove switchbot@codex-plugin switchbot auth logout ``` diff --git a/packages/codex-plugin/bin/install.js b/packages/codex-plugin/bin/install.js index 8f91986..97e6fac 100644 --- a/packages/codex-plugin/bin/install.js +++ b/packages/codex-plugin/bin/install.js @@ -55,37 +55,46 @@ function computeAliasPath() { } export function resolveMarketplaceSourceRoot(packageRoot, deps = defaultFsDeps) { - if (process.platform !== 'win32' || !/^[A-Za-z]:[\\/].*[\\/]@[^\\/]+[\\/]/.test(packageRoot)) { + const needsAlias = process.platform === 'win32' + ? /^[A-Za-z]:[\\/].*[\\/]@[^\\/]+[\\/]/.test(packageRoot) + : /\/node_modules\/@[^/]+\//.test(packageRoot); + + if (!needsAlias) { return packageRoot; } const aliasRoot = computeAliasPath(); deps.mkdirSync(dirname(aliasRoot), { recursive: true }); + const linkType = process.platform === 'win32' ? 'junction' : 'dir'; const stat = deps.lstatSync(aliasRoot, { throwIfNoEntry: false }); if (!stat) { - deps.symlinkSync(packageRoot, aliasRoot, 'junction'); + deps.symlinkSync(packageRoot, aliasRoot, linkType); return aliasRoot; } if (stat.isSymbolicLink()) { const aliasReal = deps.realpathSync(aliasRoot); const packageReal = deps.realpathSync(packageRoot); - if (aliasReal.toLowerCase() === packageReal.toLowerCase()) { + const pathsMatch = process.platform === 'win32' + ? aliasReal.toLowerCase() === packageReal.toLowerCase() + : aliasReal === packageReal; + if (pathsMatch) { return aliasRoot; } deps.unlinkSync(aliasRoot); - deps.symlinkSync(packageRoot, aliasRoot, 'junction'); + deps.symlinkSync(packageRoot, aliasRoot, linkType); return aliasRoot; } - throw new Error(`alias path ${aliasRoot} exists and is not a junction; remove it manually and retry`); + const expected = process.platform === 'win32' ? 'junction' : 'symlink'; + throw new Error(`alias path ${aliasRoot} exists and is not a ${expected}; remove it manually and retry`); } function formatCodexFailure(step) { return [ `[switchbot-codex] Codex CLI not found while running ${step}.`, - '[switchbot-codex] Install or open Codex first, then re-run switchbot-codex-install.', + '[switchbot-codex] Install or open Codex first, then run: npx @switchbot/openapi-cli codex setup', ].join('\n'); } @@ -121,6 +130,8 @@ export function makeInstall({ checkCli, runInherit, packageRoot, runAuth }) { } const pluginName = resolvePluginIdentifier(packageRoot); + process.stderr.write(`[switchbot-codex] Removing stale plugin ${pluginName} if present...\n`); + await runInherit('codex', ['plugin', 'remove', pluginName]); process.stderr.write(`[switchbot-codex] Adding plugin ${pluginName}...\n`); const pluginCode = await runInherit('codex', ['plugin', 'add', pluginName]); if (pluginCode !== 0) { @@ -130,7 +141,7 @@ export function makeInstall({ checkCli, runInherit, packageRoot, runAuth }) { } process.stderr.write( '[switchbot-codex] "codex plugin add" failed — your Codex version may not support it.\n' + - '[switchbot-codex] Fallback: follow the legacy install steps in CODEX_INSTALL.md.\n' + '[switchbot-codex] Fallback: run npx @switchbot/openapi-cli codex setup after updating Codex.\n' ); return pluginCode; } diff --git a/packages/codex-plugin/tests/install.test.js b/packages/codex-plugin/tests/install.test.js index c0c76e8..21e4d5d 100644 --- a/packages/codex-plugin/tests/install.test.js +++ b/packages/codex-plugin/tests/install.test.js @@ -39,10 +39,11 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 0); - assert.equal(calls.length, 3); + assert.equal(calls.length, 4); assert.deepEqual(calls[0], { cmd: 'codex', args: ['plugin', 'marketplace', 'add', TEST_ROOT] }); - assert.deepEqual(calls[1], { cmd: 'codex', args: ['plugin', 'add', 'switchbot@codex-plugin'] }); - assert.deepEqual(calls[2], { cmd: 'switchbot', args: ['doctor'] }); + assert.deepEqual(calls[1], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@codex-plugin'] }); + assert.deepEqual(calls[2], { cmd: 'codex', args: ['plugin', 'add', 'switchbot@codex-plugin'] }); + assert.deepEqual(calls[3], { cmd: 'switchbot', args: ['doctor'] }); assert.equal(auth.calls.length, 1); }); @@ -57,11 +58,12 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 0); - assert.equal(calls.length, 4); + assert.equal(calls.length, 5); assert.deepEqual(calls[0], { cmd: 'npm', args: ['install', '-g', '@switchbot/openapi-cli@latest'] }); assert.deepEqual(calls[1], { cmd: 'codex', args: ['plugin', 'marketplace', 'add', TEST_ROOT] }); - assert.deepEqual(calls[2], { cmd: 'codex', args: ['plugin', 'add', 'switchbot@codex-plugin'] }); - assert.deepEqual(calls[3], { cmd: 'switchbot', args: ['doctor'] }); + assert.deepEqual(calls[2], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@codex-plugin'] }); + assert.deepEqual(calls[3], { cmd: 'codex', args: ['plugin', 'add', 'switchbot@codex-plugin'] }); + assert.deepEqual(calls[4], { cmd: 'switchbot', args: ['doctor'] }); assert.equal(auth.calls.length, 1); }); @@ -105,7 +107,7 @@ describe('makeInstall', () => { const auth = makeRunAuth(0); const spawn = (cmd, args) => { callCount++; - return Promise.resolve(callCount === 2 ? 3 : 0); + return Promise.resolve(callCount === 3 ? 3 : 0); }; const install = makeInstall({ checkCli: makeOkCliCheck(), @@ -115,7 +117,7 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 3); - assert.equal(callCount, 2); + assert.equal(callCount, 3); assert.equal(auth.calls.length, 0); }); @@ -130,7 +132,7 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 4); - assert.equal(calls.length, 2); + assert.equal(calls.length, 3); assert.equal(auth.calls.length, 1); }); @@ -149,8 +151,8 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 5); - assert.equal(calls.length, 3); - assert.deepEqual(calls[2], { cmd: 'switchbot', args: ['doctor'] }); + assert.equal(calls.length, 4); + assert.deepEqual(calls[3], { cmd: 'switchbot', args: ['doctor'] }); assert.equal(auth.calls.length, 1); }); diff --git a/packages/codex-plugin/tests/resolve-marketplace-source-root.test.js b/packages/codex-plugin/tests/resolve-marketplace-source-root.test.js index ed85fb9..8a264ba 100644 --- a/packages/codex-plugin/tests/resolve-marketplace-source-root.test.js +++ b/packages/codex-plugin/tests/resolve-marketplace-source-root.test.js @@ -81,4 +81,24 @@ describe('resolveMarketplaceSourceRoot', () => { }); assert.throws(() => resolveMarketplaceSourceRoot(SCOPED_ROOT, deps), /exists and is not a junction/); }); + + it('aliases Linux npm @scope package paths', () => { + const savedPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + try { + const target = '/home/me/.npm-global/lib/node_modules/@switchbot/codex-plugin'; + const created = []; + const deps = makeDeps({ + lstatSync: () => null, + mkdirSync: (p) => created.push(['mkdir', p]), + symlinkSync: (from, to, type) => created.push(['symlink', from, to, type]), + }); + const resolved = resolveMarketplaceSourceRoot(target, deps); + assert.match(resolved, /codex-plugin-marketplace$/); + assert.equal(created[1][1], target); + assert.equal(created[1][3], 'dir'); + } finally { + Object.defineProperty(process, 'platform', { value: savedPlatform, configurable: true }); + } + }); }); diff --git a/src/commands/install.ts b/src/commands/install.ts index de17f7b..56dba23 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -92,9 +92,9 @@ function printRecipe(ctx: InstallContext): void { break; case 'codex': lines.push( - ' # Prerequisite: npm install -g @switchbot/codex-plugin', - ' # Codex plugin was registered with the Codex CLI.', - ' # To re-register: switchbot install --agent codex', + ' # Recommended full bootstrap: npx @switchbot/openapi-cli codex setup', + ' # This command only re-registers an already-installed Codex plugin.', + ' # To repair an existing setup: switchbot codex repair', ); break; case 'none': diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index 3435793..5ded1b1 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -130,7 +130,7 @@ export function checkCodexCli(): Check { status: 'fail', detail: { message: 'codex CLI not found on PATH. Install from https://github.com/openai/codex', - hint: 'Install Codex, then re-run: switchbot install --agent codex', + hint: 'Install Codex, then re-run: npx @switchbot/openapi-cli codex setup', }, }; } @@ -157,7 +157,7 @@ export function checkCodexPluginNpm(): Check { return { name: 'codex-plugin-npm', status: 'warn', - detail: { message: 'not installed — run: npm install -g @switchbot/codex-plugin && switchbot install --agent codex' }, + detail: { message: 'npm fallback package not installed — run: npx @switchbot/openapi-cli codex setup' }, }; } let packageRoot: string | null = null; @@ -210,7 +210,7 @@ export function checkCodexPluginRegistered(): Check { return { name: 'codex-plugin-registered', status: 'warn', - detail: { message: 'switchbot not in codex plugin list — run: npm install -g @switchbot/codex-plugin && switchbot install --agent codex' }, + detail: { message: 'switchbot not in codex plugin list — run: switchbot codex repair' }, }; } if (/switchbot@/i.test(pluginName) && (/\bnot installed\b/i.test(pluginName) || !/\binstalled\b/i.test(pluginName))) { diff --git a/src/install/preflight.ts b/src/install/preflight.ts index 6f5b9b7..d205833 100644 --- a/src/install/preflight.ts +++ b/src/install/preflight.ts @@ -255,7 +255,7 @@ function checkCodexCliForPreflight(opts: PreflightOptions): PreflightCheck | nul name: 'codex-cli', status: 'fail', message: 'codex CLI not found on PATH', - hint: 'Install Codex (https://github.com/openai/codex), then re-run switchbot install --agent codex', + hint: 'Install Codex (https://github.com/openai/codex), then run: npx @switchbot/openapi-cli codex setup', }; } return { name: 'codex-cli', status: 'ok', message: 'codex CLI found on PATH' }; @@ -278,7 +278,7 @@ function checkCodexPluginForPreflight(opts: PreflightOptions): PreflightCheck | name: 'codex-plugin-npm', status: 'fail', message: '@switchbot/codex-plugin not installed globally', - hint: 'Run: npm install -g @switchbot/codex-plugin (this command only registers an already-installed package)', + hint: 'Run the full bootstrap instead: npx @switchbot/openapi-cli codex setup', }; } return { name: 'codex-plugin-npm', status: 'ok', message: '@switchbot/codex-plugin installed' }; diff --git a/tests/install/codex-checks.test.ts b/tests/install/codex-checks.test.ts index 1435458..51ea5a8 100644 --- a/tests/install/codex-checks.test.ts +++ b/tests/install/codex-checks.test.ts @@ -95,9 +95,7 @@ describe('checkCodexPluginNpm', () => { const result = checkCodexPluginNpm(); expect(result.status).toBe('warn'); const msg = String((result.detail as Record).message); - // A4: warning must include the full repair recipe (npm install + switchbot install) - expect(msg).toContain('npm install -g @switchbot/codex-plugin'); - expect(msg).toContain('switchbot install --agent codex'); + expect(msg).toContain('npx @switchbot/openapi-cli codex setup'); }); it('returns warn when npm list json is malformed', () => { @@ -133,9 +131,7 @@ describe('checkCodexPluginRegistered', () => { const result = checkCodexPluginRegistered(); expect(result.status).toBe('warn'); const msg = String((result.detail as Record).message); - // A4: warning must include the full repair recipe (npm install + switchbot install) - expect(msg).toContain('npm install -g @switchbot/codex-plugin'); - expect(msg).toContain('switchbot install --agent codex'); + expect(msg).toContain('switchbot codex repair'); }); it('returns warn with reason codex-cli-missing when codex is not on PATH', () => { From 58269449ba33d2e2f9fb60a8a185c8978af95521 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 01:03:27 +0800 Subject: [PATCH 07/31] feat(codex/setup): install @switchbot/codex-plugin on demand instead of as a fixed step The install-codex-plugin step ran unconditionally even when Route B (git marketplace) would succeed without any local npm package. Fold the npm install into register-plugin: try Route B first; only install the package and fall back to Route A when Route B fails. This keeps air- gapped environments working while avoiding a needless global npm install in the common case. SETUP_STEPS shrinks from 6 to 5. Dry-run output and --skip no longer expose install-codex-plugin as a standalone step. --- src/commands/codex.ts | 53 +++++++++++++------- tests/commands/codex.test.ts | 93 +++++++++++++++++------------------- 2 files changed, 80 insertions(+), 66 deletions(-) diff --git a/src/commands/codex.ts b/src/commands/codex.ts index 05b84c7..22e50b0 100644 --- a/src/commands/codex.ts +++ b/src/commands/codex.ts @@ -6,6 +6,8 @@ import { checkCodexCli, checkCodexPluginNpm, checkCodexPluginRegistered, + registerCodexPlugin, + registerCodexPluginGit, registerCodexPluginAuto, resolvePluginId, resolveCodexPackageRoot, @@ -331,12 +333,11 @@ interface SetupContext { 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: 'install-codex-plugin', description: 'Install @switchbot/codex-plugin if missing', skippable: true }, - { name: 'register-plugin', description: 'Register plugin via shared registerCodexPlugin()', 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 }, + { 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: '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 setupStepCheckCodexCli(): SetupOutcome { @@ -361,13 +362,6 @@ function setupStepInstallSwitchbotCli(): SetupOutcome { ); } -function setupStepInstallCodexPlugin(): SetupOutcome { - return setupStepInstallGlobalPackage( - 'install-codex-plugin', - CODEX_PLUGIN_PACKAGE, - ); -} - function setupStepInstallGlobalPackage(step: string, packageName: string): SetupOutcome { const list = spawnSync( 'npm', ['list', '-g', '--json', '--depth=0', packageName], @@ -396,7 +390,35 @@ function setupStepInstallGlobalPackage(step: string, packageName: string): Setup } function setupStepRegisterPlugin(ctx: SetupContext): SetupOutcome { - return stepRegisterPluginShared('register-plugin', ctx); + // Try Route B (git marketplace) — no npm package required + const gitResult = registerCodexPluginGit(); + if (gitResult.ok) { + ctx.codexPluginId = gitResult.pluginId; + ctx.packageRoot = gitResult.packageRoot; + return { step: 'register-plugin', status: 'ok', message: 'registered via git marketplace (Route B)' }; + } + + // Route B failed — install @switchbot/codex-plugin on demand, then try Route A + const installOutcome = setupStepInstallGlobalPackage('register-plugin', CODEX_PLUGIN_PACKAGE); + if (installOutcome.status === 'failed') { + return { + step: 'register-plugin', + status: 'failed', + message: `Route B failed (${gitResult.error}); npm install also failed: ${installOutcome.message ?? ''}`, + }; + } + + const npmResult = registerCodexPlugin(); + if (!npmResult.ok) { + return { + step: 'register-plugin', + status: 'failed', + message: `Route B failed (${gitResult.error}); Route A also failed: ${npmResult.error ?? ''}`, + }; + } + ctx.codexPluginId = npmResult.pluginId; + ctx.packageRoot = npmResult.packageRoot; + return { step: 'register-plugin', status: 'ok', message: 'registered via local npm (Route A fallback)' }; } async function setupStepAuth(ctx: SetupContext): Promise { @@ -447,7 +469,6 @@ async function runSetup( let outcome: SetupOutcome; if (step.name === 'check-codex-cli') outcome = setupStepCheckCodexCli(); else if (step.name === 'install-switchbot-cli') outcome = setupStepInstallSwitchbotCli(); - else if (step.name === 'install-codex-plugin') outcome = setupStepInstallCodexPlugin(); else if (step.name === 'register-plugin') outcome = setupStepRegisterPlugin(ctx); else if (step.name === 'auth') outcome = await setupStepAuth(ctx); else outcome = await setupStepDoctorVerify(); @@ -465,7 +486,7 @@ function registerCodexSetupSubcommand(codex: Command): void { codex .command('setup') .description('Bootstrap the Codex integration end-to-end: install packages if missing, register plugin, auth, verify') - .option('--skip ', 'Comma-separated step names to skip (only "install-switchbot-cli", "install-codex-plugin", or "auth" allowed)') + .option('--skip ', 'Comma-separated step names to skip (only "install-switchbot-cli" or "auth" allowed)') .option('--yes', 'Non-interactive mode: do not spawn auth login, fail fast if credentials missing') .action(async (opts: { skip?: string; yes?: boolean }, command: Command) => { const skip = new Set( diff --git a/tests/commands/codex.test.ts b/tests/commands/codex.test.ts index 6c8a3a4..d1e36b7 100644 --- a/tests/commands/codex.test.ts +++ b/tests/commands/codex.test.ts @@ -317,7 +317,7 @@ describe('switchbot codex setup', () => { tryLoadConfigMock.mockReset(); }); - it('--dry-run prints the 6-step list without mutating', async () => { + it('--dry-run prints the 5-step list without mutating', async () => { const { exitCode, stderr } = await runCli( registerCodexCommand, ['codex', 'setup', '--dry-run'], @@ -326,7 +326,7 @@ describe('switchbot codex setup', () => { const out = stderr.join('\n'); expect(out).toContain('check-codex-cli'); expect(out).toContain('install-switchbot-cli'); - expect(out).toContain('install-codex-plugin'); + expect(out).not.toContain('install-codex-plugin'); expect(out).toContain('register-plugin'); expect(out).toContain('auth'); expect(out).toContain('doctor-verify'); @@ -335,7 +335,7 @@ describe('switchbot codex setup', () => { expect(registerCodexPluginMock).not.toHaveBeenCalled(); }); - it('--dry-run --json emits 6 ordered steps with skippable flags', async () => { + it('--dry-run --json emits 5 ordered steps with skippable flags', async () => { const { exitCode, stdout } = await runCli( registerCodexCommand, ['codex', 'setup', '--dry-run', '--json'], @@ -348,13 +348,12 @@ describe('switchbot codex setup', () => { }; const data = parsed.data ?? parsed; expect(data.dryRun).toBe(true); - expect(data.steps).toHaveLength(6); + expect(data.steps).toHaveLength(5); expect(data.steps?.map((s) => s.name)).toEqual([ - 'check-codex-cli', 'install-switchbot-cli', 'install-codex-plugin', 'register-plugin', 'auth', 'doctor-verify', + 'check-codex-cli', 'install-switchbot-cli', 'register-plugin', 'auth', 'doctor-verify', ]); const skippable = Object.fromEntries(data.steps!.map((s) => [s.name, s.skippable])); expect(skippable['install-switchbot-cli']).toBe(true); - expect(skippable['install-codex-plugin']).toBe(true); expect(skippable['auth']).toBe(true); expect(skippable['check-codex-cli']).toBe(false); expect(skippable['register-plugin']).toBe(false); @@ -402,16 +401,9 @@ describe('switchbot codex setup', () => { stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: '1.0.0' } } }), stderr: '', }); - // install-codex-plugin step: npm list -g returns the package as already installed - spawnSyncRepairMock.mockReturnValueOnce({ - status: 0, - stdout: JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '0.1.0' } } }), - stderr: '', - }); - // register-plugin succeeds + // register-plugin: Route B (registerCodexPluginGit) succeeds — no npm install needed registerCodexPluginMock.mockReturnValueOnce({ - ok: true, pluginId: 'switchbot@codex-plugin', - packageRoot: '/usr/local/lib/node_modules/@switchbot/codex-plugin', + ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: null, }); // credentials missing tryLoadConfigMock.mockReturnValue(null); @@ -449,14 +441,9 @@ describe('switchbot codex setup', () => { stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: '1.0.0' } } }), stderr: '', }); - // install-codex-plugin: already installed - spawnSyncRepairMock.mockReturnValueOnce({ - status: 0, - stdout: JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '0.1.0' } } }), - stderr: '', - }); + // register-plugin: Route B succeeds registerCodexPluginMock.mockReturnValueOnce({ - ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: '/some/path', + ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: null, }); // credentials missing → spawn auth login tryLoadConfigMock.mockReturnValue(null); @@ -490,14 +477,9 @@ describe('switchbot codex setup', () => { checkCodexCliMock.mockReturnValueOnce({ name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, }); - // install-codex-plugin still runs when only install-switchbot-cli is skipped - spawnSyncRepairMock.mockReturnValueOnce({ - status: 0, - stdout: JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '0.1.0' } } }), - stderr: '', - }); + // register-plugin: Route B succeeds — no npm install needed registerCodexPluginMock.mockReturnValueOnce({ - ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: '/some/path', + ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: null, }); // credentials present → auth ok without spawn tryLoadConfigMock.mockReturnValue({ token: 't', secret: 's' }); @@ -516,9 +498,8 @@ describe('switchbot codex setup', () => { }; const step = parsed.data!.outcomes.find((o) => o.step === 'install-switchbot-cli'); expect(step?.status).toBe('skipped'); - // npm list -g was NOT spawned for install-switchbot-cli; the only npm call was the plugin check. - expect(spawnSyncRepairMock).toHaveBeenCalledTimes(1); - expect(spawnSyncRepairMock.mock.calls[0][1]).toContain('@switchbot/codex-plugin'); + // Route B succeeded — no npm calls at all + expect(spawnSyncRepairMock).not.toHaveBeenCalled(); }); it('install-switchbot-cli failure exits 1 (not 2 — only check-codex-cli is preflight)', async () => { @@ -529,15 +510,9 @@ describe('switchbot codex setup', () => { spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '{}', stderr: '' }); // npm install -g fails spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'EACCES' }); - // install-codex-plugin still runs - spawnSyncRepairMock.mockReturnValueOnce({ - status: 0, - stdout: JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '0.1.0' } } }), - stderr: '', - }); - // register-plugin still runs (continues after non-preflight failure) + // register-plugin: Route B succeeds (continues after non-preflight failure) registerCodexPluginMock.mockReturnValueOnce({ - ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: '/some/path', + ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: null, }); // auth: credentials present tryLoadConfigMock.mockReturnValue({ token: 't', secret: 's' }); @@ -556,25 +531,33 @@ describe('switchbot codex setup', () => { data?: { preflightFailed: boolean; outcomes: Array<{ step: string; status: string }> }; }; expect(parsed.data!.preflightFailed).toBe(false); - expect(parsed.data!.outcomes).toHaveLength(6); // all 6 steps ran (no preflight halt) + expect(parsed.data!.outcomes).toHaveLength(5); // all 5 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(); }); - it('installs @switchbot/codex-plugin before registering when missing', async () => { + it('installs @switchbot/codex-plugin on demand when Route B fails', async () => { checkCodexCliMock.mockReturnValueOnce({ name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, }); - // switchbot CLI already installed + // install-switchbot-cli: already installed spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: '1.0.0' } } }), stderr: '', }); - // codex plugin missing, then install succeeds + // register-plugin step: + // 1. registerCodexPluginGit (= registerCodexPluginMock #1) → Route B fails + registerCodexPluginMock.mockReturnValueOnce({ + ok: false, pluginId: 'switchbot@codex-plugin', packageRoot: null, + error: 'marketplace-add exit 1: git clone failed', + }); + // 2. npm list -g: codex-plugin not installed spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '{}', stderr: '' }); + // 3. npm install -g: succeeds spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); + // 4. registerCodexPlugin (= registerCodexPluginMock #2) → Route A ok registerCodexPluginMock.mockReturnValueOnce({ ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: '/some/path', }); @@ -592,11 +575,21 @@ describe('switchbot codex setup', () => { const parsed = JSON.parse(stdout.join('')) as { data?: { outcomes: Array<{ step: string; status: string; message?: string }> }; }; - const pluginStep = parsed.data!.outcomes.find((o) => o.step === 'install-codex-plugin'); - expect(pluginStep?.status).toBe('ok'); - expect(pluginStep?.message).toContain('installed @switchbot/codex-plugin@latest'); - expect(spawnSyncRepairMock.mock.calls[1][1]).toEqual(['list', '-g', '--json', '--depth=0', '@switchbot/codex-plugin']); - expect(spawnSyncRepairMock.mock.calls[2][1]).toEqual(['install', '-g', '@switchbot/codex-plugin@latest']); - expect(registerCodexPluginMock).toHaveBeenCalledOnce(); + // No standalone install-codex-plugin step — it happened inside register-plugin + expect(parsed.data!.outcomes.find((o) => o.step === 'install-codex-plugin')).toBeUndefined(); + const registerStep = parsed.data!.outcomes.find((o) => o.step === 'register-plugin'); + expect(registerStep?.status).toBe('ok'); + expect(registerStep?.message).toContain('Route A fallback'); + // Verify the on-demand npm calls were made inside register-plugin + const npmListCall = spawnSyncRepairMock.mock.calls.find( + (c) => Array.isArray(c[1]) && c[1].includes('@switchbot/codex-plugin'), + ); + expect(npmListCall).toBeDefined(); + const npmInstallCall = spawnSyncRepairMock.mock.calls.find( + (c) => Array.isArray(c[1]) && (c[1] as string[]).includes('@switchbot/codex-plugin@latest'), + ); + expect(npmInstallCall).toBeDefined(); + // registerCodexPluginGit + registerCodexPlugin = 2 calls to the shared mock + expect(registerCodexPluginMock).toHaveBeenCalledTimes(2); }); }); From c145cbc72bf45f79d975a1f8585ba7461d8d3299 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 01:10:11 +0800 Subject: [PATCH 08/31] docs(readme): document self-hosted git marketplace for Codex plugin Add direct git marketplace registration command so users can register the plugin without going through codex setup. Clarify that the plugin lives in packages/codex-plugin/ of this repo and no separate npm package install is required. --- README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index fc44ccb..b6bb683 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,8 @@ for the agent surface. Use SwitchBot with [OpenAI Codex CLI](https://github.com/openai/codex) to control your smart home devices through natural-language AI conversations. +The Codex plugin is **self-hosted in this repository** (`packages/codex-plugin/`) and registered directly via Codex's git marketplace — no separate plugin npm package required. + ### Quick start — just paste this into Codex Not sure how to run commands? Copy the block below and paste it directly into your Codex chat: @@ -183,28 +185,29 @@ npx @switchbot/openapi-cli codex setup Then restart Codex and confirm it's working. ``` -Codex will run the setup, walk you through signing in, and let you know when it's ready. +Codex will install the CLI, register the plugin from this repo's git marketplace, walk you through signing in, and let you know when it's ready. ### For developers **Requirements:** [Codex CLI](https://github.com/openai/codex) on `$PATH`, Node.js ≥ 18. -**One-command bootstrap** (installs CLI + plugin + auth + health check in one shot): +**One-command bootstrap** (installs CLI + registers plugin via git marketplace + auth + health check): ```bash npx @switchbot/openapi-cli codex setup ``` -**Advanced manual registration** (only if you already installed both packages): +**Direct git marketplace registration** (if the CLI is already installed): ```bash -npm install -g @switchbot/openapi-cli @switchbot/codex-plugin -switchbot install --agent codex # register-only fallback +# Register the plugin directly from this repo — no npm package install needed +codex plugin marketplace add OpenWonderLabs/switchbot-openapi-cli \ + --sparse packages/codex-plugin --ref main +codex plugin add switchbot@codex-plugin switchbot auth login ``` -For normal use, prefer `npx @switchbot/openapi-cli codex setup`; it handles -package install, plugin registration, auth, and verification together. +`packages/codex-plugin/` in this repo is the Codex marketplace source. Registration always pulls from `main`; set `CODEX_GIT_MARKETPLACE_REF` to pin a different branch or tag. **Health check and repair:** From 33129d031d8eea0e15ffc9bbf01b47672ab12a21 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 01:15:36 +0800 Subject: [PATCH 09/31] docs(readme): add codex command section, trim verbose descriptions --- README.md | 152 ++++++++++++++++++------------------------------------ 1 file changed, 50 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index b6bb683..96c077c 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Under the hood every surface shares the same catalog, cache, and HMAC client — - [Credentials](#credentials) - [Policy](#policy) · [Rules engine](#rules-engine) - [Global options](#global-options) -- [Commands](#commands): [auth](#auth--login-and-credential-management) · [config](#config--credential-management) · [devices](#devices--list-status-control) · [scenes](#scenes--run-manual-scenes) · [webhook](#webhook--receive-device-events-over-http) · [events](#events--receive-device-events) · [status-sync](#status-sync--mqttopenclaw-bridge) · [daemon](#daemon--background-rules-engine-process) · [plan](#plan--declarative-batch-operations) · [mcp](#mcp--model-context-protocol-server) · [doctor](#doctor--self-check) · [health](#health--runtime-health-report) · [upgrade-check](#upgrade-check--version-check) · [quota](#quota--api-request-counter) · [history](#history--audit-log) · [catalog](#catalog--device-type-catalog) · [schema](#schema--export-catalog-as-json) · [capabilities](#capabilities--cli-manifest) · [cache](#cache--inspect-and-clear-local-cache) · [reset](#reset--clear-local-data) · [policy cmd](#policy--validate-scaffold-and-migrate-policyyaml) · [completion](#completion--shell-tab-completion) +- [Commands](#commands): [auth](#auth--login-and-credential-management) · [config](#config--credential-management) · [devices](#devices--list-status-control) · [scenes](#scenes--run-manual-scenes) · [webhook](#webhook--receive-device-events-over-http) · [events](#events--receive-device-events) · [status-sync](#status-sync--mqttopenclaw-bridge) · [daemon](#daemon--background-rules-engine-process) · [plan](#plan--declarative-batch-operations) · [mcp](#mcp--model-context-protocol-server) · [doctor](#doctor--self-check) · [health](#health--runtime-health-report) · [upgrade-check](#upgrade-check--version-check) · [quota](#quota--api-request-counter) · [history](#history--audit-log) · [catalog](#catalog--device-type-catalog) · [schema](#schema--export-catalog-as-json) · [capabilities](#capabilities--cli-manifest) · [cache](#cache--inspect-and-clear-local-cache) · [reset](#reset--clear-local-data) · [policy cmd](#policy--validate-scaffold-and-migrate-policyyaml) · [completion](#completion--shell-tab-completion) · [codex](#codex--codex-cli-integration) - [Output modes](#output-modes) · [Cache](#cache) · [Exit codes](#exit-codes--error-codes) · [Environment variables](#environment-variables) - [Scripting examples](#scripting-examples) · [Development](#development) · [License](#license) @@ -314,23 +314,7 @@ counts over per-device history), and `llm` (AI decision — see below). Every fire is recorded in `~/.switchbot/audit.log`. `rules run` is long-running; use `daemon start` / `daemon reload` for the managed background mode. -**Actions** — each rule's `then` array accepts two action types: - -- `type: command` (default, no `type` field required) — sends a device command, e.g. `devices command turnOn` -- `type: notify` — delivers a payload to an external channel after the rule fires: - - `channel: webhook` — HTTP POST to a URL (only `http://` and `https://` schemes are accepted; `rules lint` rejects others) - - `channel: file` — appends a JSONL line to a local file. `to` must be an absolute path; relative or `~`-prefixed paths are rejected by `rules lint` (code `notify-relative-path`) and at runtime - - `channel: openclaw` — HTTP POST to an OpenClaw endpoint (same protocol restriction) - - Optional `template` field supports `{{ rule.name }}`, `{{ event.* }}`, `{{ device.id }}` placeholders. Nested fields use dot paths, e.g. `{{ event.context.deviceMac }}`; arrays index numerically, e.g. `{{ event.list.0 }}` - -```yaml -then: - - command: devices command AC_001 turnOn - - type: notify - channel: webhook - to: https://your.host/hook - template: '{"rule":"{{ rule.name }}","fired":"{{ rule.fired_at }}"}' -``` +**Triggers:** `mqtt` · `cron` · `webhook` — **Conditions:** `time_between` · `device_state` · `event_count` · `llm` — **Actions:** `command` · `notify` (webhook / file / openclaw, with `template` support) **LLM condition** — add an AI judgement step before actions fire: @@ -342,42 +326,28 @@ conditions: cache_ttl: 5m budget: max_calls_per_hour: 20 - max_tokens_per_hour: 100000 # optional rolling 1h token cap - max_cost_per_day_usd: 1.00 # optional rolling 24h USD cap + max_cost_per_day_usd: 1.00 on_error: pass # fail | pass | skip ``` -Set `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` for the cloud providers. -For `provider: local`, point `SWITCHBOT_LOCAL_LLM_URL` at any -OpenAI-compatible `/v1/chat/completions` endpoint (Ollama, llama.cpp, -vLLM, LM Studio); `SWITCHBOT_LOCAL_LLM_MODEL` picks the model and -`SWITCHBOT_LOCAL_LLM_TOOL_USE=1` opts into native tool-use when the -endpoint supports it (otherwise a structured-output fallback is used). -`rules lint` flags misconfigured LLM conditions. - -**Decision trace** — set `automation.audit.evaluate_trace: sampled` (or `full`) in `policy.yaml` to record every evaluation decision. +Set `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` for cloud providers; `SWITCHBOT_LOCAL_LLM_URL` for any OpenAI-compatible endpoint. ```bash -switchbot rules lint # static check: exit 0 valid, 1 error -switchbot rules list --json | jq . # structured rule summary -switchbot rules explain "motion on" # trigger, conditions, actions, last fired -switchbot rules run --dry-run --max-firings 5 # run engine; --dry-run = audit only -switchbot daemon reload # hot-reload policy without restart - -switchbot rules tail --follow # stream rule-* audit lines -switchbot rules replay --since 1h --json # per-rule fires/dries/throttled/errors -switchbot rules summary # aggregate fires/errors (24h) -switchbot rules conflicts # opposing actions, destructive cmds, quiet-hours gaps -switchbot rules doctor --json # lint + conflicts; exit 0 when clean - -switchbot rules suggest --intent "turn off AC at 11pm" -switchbot rules suggest --intent "..." --llm auto # LLM-backed (OPENAI_API_KEY or ANTHROPIC_API_KEY) - -switchbot rules trace-explain --rule "motion on" --last # why a rule fired/was blocked -switchbot rules simulate "motion on" --since 7d --json # replay without running the engine +switchbot rules lint # static validation +switchbot rules list --json # structured rule summary +switchbot rules explain "motion on" # trigger / conditions / actions / last fired +switchbot rules run --dry-run --max-firings 5 # run engine (audit-only) +switchbot rules tail --follow # stream rule-* audit lines +switchbot rules replay --since 1h --json # per-rule fires/throttled/errors +switchbot rules summary # aggregate fires (24h) +switchbot rules conflicts # opposing actions / quiet-hours gaps +switchbot rules doctor --json # lint + conflicts combined +switchbot rules suggest --intent "turn off AC at 11pm" --llm auto +switchbot rules trace-explain --rule "motion on" --last +switchbot rules simulate "motion on" --since 7d --json ``` -LLM-generated rules always have `dry_run: true` — flip it yourself after review. Notify URLs must be `http://` or `https://`. +LLM-generated rules always have `dry_run: true` — flip it yourself after review. ## Global options @@ -479,27 +449,11 @@ For per-device command and parameter details: `switchbot devices commands #### `devices expand` — named flags for packed parameters -Some commands require a packed string like `"26,2,2,on"`. `devices expand` builds it from readable flags: - ```bash -# Air Conditioner — setAll switchbot devices expand setAll --temp 26 --mode cool --fan low --power on -# Resolve by name switchbot devices expand --name "Living Room AC" setAll --temp 26 --mode cool --fan low --power on - -# Curtain / Roller Shade — setPosition switchbot devices expand setPosition --position 50 --mode silent - -# Blind Tilt — setPosition -switchbot devices expand setPosition --direction up --angle 50 - -# Relay Switch — setMode -switchbot devices expand setMode --channel 1 --mode edge - -# Color Bulb / Strip Light / Floor Lamp / Ceiling Light — setBrightness / setColor / setColorTemperature -switchbot devices expand setBrightness --brightness 80 switchbot devices expand setColor --color "#FF0000" -switchbot devices expand setColorTemperature --color-temp 4000 ``` Run `switchbot devices expand --help` to see the available flags for any device command. @@ -515,10 +469,8 @@ switchbot devices explain --no-live # catalog-only, no API call ```bash switchbot devices meta set --alias "Office Light" -switchbot devices meta set --hide # hide from `devices list` switchbot devices meta get switchbot devices meta list -switchbot devices meta clear ``` Stores local annotations in `~/.switchbot/device-meta.json`. `--show-hidden` on `devices list` reveals hidden devices. @@ -526,16 +478,11 @@ Stores local annotations in `~/.switchbot/device-meta.json`. `--show-hidden` on #### `devices batch` — bulk commands ```bash -# Same command to every matching device switchbot devices batch turnOff --filter 'type=Bot' switchbot devices batch setBrightness 50 --filter 'type~Light,family=Living' -switchbot devices batch turnOn --ids ID1,ID2,ID3 -switchbot devices list --format=id --filter 'type=Bot' | switchbot devices batch toggle - switchbot devices batch unlock --filter 'type=Smart Lock' --yes # destructive: requires --yes ``` -Filter keys: `type`, `family`, `room`, `category`. Skipped-offline devices appear under `summary.skipped` when `--skip-offline` is passed. - ### `scenes` — run manual scenes ```bash @@ -649,49 +596,53 @@ switchbot completion powershell >> $PROFILE Supported shells: `bash`, `zsh`, `fish`, `powershell` (`pwsh` is accepted as an alias). -### `plan` — declarative batch operations +### `codex` — Codex CLI integration ```bash -# Print the plan JSON Schema (give to your agent framework) -switchbot plan schema +# Full bootstrap: verify codex CLI, install packages if needed, register plugin, auth, verify +switchbot codex setup +switchbot codex setup --yes # non-interactive (skip auth login spawn) +switchbot codex setup --dry-run # preview steps without mutating +switchbot codex setup --json # machine-readable output -# Draft a candidate plan from natural language intent -switchbot plan suggest --intent "turn off all lights" --device --device +# Health check: 7 checks (node, path, credentials, mcp, codex-cli, npm-plugin, registered) +switchbot codex doctor +switchbot codex doctor --quiet # only show warn/fail +switchbot codex doctor --json -# Validate a plan file without running it -switchbot plan validate plan.json +# Repair: re-auth + re-register plugin + re-verify +switchbot codex repair +switchbot codex repair --skip re-auth # skip credential check +switchbot codex repair --skip remove-plugin # skip pre-clean step +switchbot codex repair --yes --dry-run --json +``` -# Preview — mutations skipped, GETs still execute -switchbot --dry-run plan run plan.json +All subcommands accept `--dry-run`, `--json`, `--yes`, and global `--profile` / `--config`. +See [Codex integration](#codex-integration) for setup instructions. -# Save / review / approve / execute for destructive plans -switchbot plan save plan.json +### `plan` — declarative batch operations + +```bash +switchbot plan schema # print JSON Schema for plan files +switchbot plan suggest --intent "turn off all lights" --device +switchbot plan validate plan.json +switchbot plan run plan.json # preview (--dry-run) or execute +switchbot plan run plan.json --require-approval # TTY confirmation for destructive steps +switchbot plan save plan.json # stage for review switchbot plan review switchbot plan approve switchbot plan execute -switchbot plan run plan.json --continue-on-error - -# Run with per-step TTY confirmation for destructive steps (human-in-the-loop) -switchbot plan run plan.json --require-approval ``` -A plan file is a JSON document with `version`, `description`, and a `steps` array of `command`, `scene`, or `wait` steps. Steps execute sequentially; a failed step stops the run unless `--continue-on-error` is set. `plan run` is the preview/direct path, but destructive steps are blocked by default and should go through `plan save` → `plan review` → `plan approve` → `plan execute`. See [`docs/agent-guide.md`](./docs/agent-guide.md) for the full schema and agent integration patterns. - ### `devices watch` — poll status ```bash -# Poll a device's status every 30 s until Ctrl-C -switchbot devices watch - -# Custom interval; emit every tick even when nothing changed -switchbot devices watch --interval 10s --include-unchanged --json - -# Time-bounded: stop after 5 minutes instead of a fixed tick count -switchbot devices watch --for 5m +switchbot devices watch # poll every 30s +switchbot devices watch --interval 10s --json # custom interval +switchbot devices watch --for 5m # time-bounded +switchbot devices watch --include-unchanged --max 20 # emit every tick ``` -Output is a JSONL stream of status-change events (with `--json`) or a refreshed table. Use `--max ` to stop after N ticks, or `--for ` to stop after an elapsed wall-clock window (e.g. `30s`, `1h`, `2d`). When both are set, whichever limit trips first wins. - ### `mcp` — Model Context Protocol server ```bash @@ -721,18 +672,15 @@ See [`docs/agent-guide.md`](./docs/agent-guide.md) for the full tool reference a ```bash switchbot doctor switchbot doctor --json +switchbot doctor --fix --yes # auto-apply safe fixes ``` -Runs local checks (Node version, credentials, profiles, catalog, catalog-schema, catalog-coverage, cache, quota, clock, MQTT, policy, MCP, keychain, path, inventory, audit, daemon, daemon-ipc, health, notify-connectivity, local-llm-reachable, release-notes) and exits 1 if any check fails. `warn` results exit 0. The MQTT check reports `ok` when REST credentials are configured (auto-provisioned on first use). The `notify-connectivity` check probes webhook URLs declared in `type: notify` actions. `daemon-ipc` round-trips the JSON-RPC socket when the daemon is running (silently skipped otherwise); `local-llm-reachable` only fires when policy uses `provider: local`. Use this to diagnose connectivity or config issues before running automation. - -`--json` output includes `maturityScore` (0–100) and `maturityLabel` (`production-ready` / `mostly-ready` / `needs-work` / `not-ready`) to give an at-a-glance readiness rating: +Checks Node version, credentials, profiles, catalog, cache, quota, clock, MQTT, policy, MCP, keychain, path, inventory, audit, daemon, and more — exits 1 on any failure (`warn` exits 0). ```bash switchbot doctor --json | jq '{score: .data.maturityScore, label: .data.maturityLabel}' ``` -Pass `--fix --yes` to auto-apply safe fixes (e.g. clear stale cache entries) without a prompt. - ### `health` — runtime health report ```bash From b9dd337c5f5d22f530d2cf0db202f37ef9516edf Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 01:18:58 +0800 Subject: [PATCH 10/31] docs(readme): rewrite as concise cheat-sheet (254 lines) --- README.md | 866 ++++++++---------------------------------------------- 1 file changed, 117 insertions(+), 749 deletions(-) diff --git a/README.md b/README.md index 96c077c..eedfa73 100644 --- a/README.md +++ b/README.md @@ -6,178 +6,41 @@ [![node](https://img.shields.io/node/v/@switchbot/openapi-cli.svg)](https://nodejs.org) [![CI](https://github.com/OpenWonderLabs/switchbot-openapi-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/OpenWonderLabs/switchbot-openapi-cli/actions/workflows/ci.yml) -**SwitchBot** smart home CLI — control lights, locks, curtains, sensors, plugs, and IR appliances (TV/AC/fan) via the [SwitchBot Cloud API v1.1](https://github.com/OpenWonderLabs/SwitchBotAPI). -Run scenes, stream real-time events over MQTT, and plug AI agents into your home via the built-in MCP server — all from your terminal or shell scripts. +SwitchBot smart home CLI — control devices, run scenes, stream events, and plug AI agents into your home via the built-in MCP server. -- **npm package:** [`@switchbot/openapi-cli`](https://www.npmjs.com/package/@switchbot/openapi-cli) -- **Source code:** [github.com/OpenWonderLabs/switchbot-openapi-cli](https://github.com/OpenWonderLabs/switchbot-openapi-cli) +- **npm:** [`@switchbot/openapi-cli`](https://www.npmjs.com/package/@switchbot/openapi-cli) +- **Source / issues:** [github.com/OpenWonderLabs/switchbot-openapi-cli](https://github.com/OpenWonderLabs/switchbot-openapi-cli) - **Releases / changelog:** [GitHub Releases](https://github.com/OpenWonderLabs/switchbot-openapi-cli/releases) -- **Issues / feature requests:** [GitHub Issues](https://github.com/OpenWonderLabs/switchbot-openapi-cli/issues) - -> Looking for the **conversational skill** that drives this CLI from a chat -> agent? A companion skill for third-party agent hosts is maintained in a -> separate repository. -> See [`docs/agent-guide.md`](./docs/agent-guide.md) for the authoritative -> surfaces (MCP, `agent-bootstrap`, `schema export`, `capabilities --json`) -> the skill consumes. - ---- - -## Who is this for? - -Three entry points, same binary — pick the one that matches how you use it: - -- **Human**: start with this README ([Quick start](#quick-start)). - You get colored tables, helpful error hints, shell completion, and - `switchbot doctor` self-check. -- **Script**: start with [Output modes](#output-modes) and - [Scripting examples](#scripting-examples). - You get `--json`, `--format=tsv/yaml/id`, `--fields`, stable exit codes, - `history replay`, and audit log support. -- **Agent**: start with [`docs/agent-guide.md`](./docs/agent-guide.md). - You get `switchbot mcp serve` (stdio MCP server), `schema export`, - `plan run`, and destructive-command guards. - -Under the hood every surface shares the same catalog, cache, and HMAC client — switching between them costs nothing. --- -## Table of contents - -- [Features](#features) · [Supported devices](#supported-devices) · [Requirements](#requirements) · [Installation](#installation) -- [Quick start](#quick-start) · [Codex integration](#codex-integration) -- [Credentials](#credentials) -- [Policy](#policy) · [Rules engine](#rules-engine) -- [Global options](#global-options) -- [Commands](#commands): [auth](#auth--login-and-credential-management) · [config](#config--credential-management) · [devices](#devices--list-status-control) · [scenes](#scenes--run-manual-scenes) · [webhook](#webhook--receive-device-events-over-http) · [events](#events--receive-device-events) · [status-sync](#status-sync--mqttopenclaw-bridge) · [daemon](#daemon--background-rules-engine-process) · [plan](#plan--declarative-batch-operations) · [mcp](#mcp--model-context-protocol-server) · [doctor](#doctor--self-check) · [health](#health--runtime-health-report) · [upgrade-check](#upgrade-check--version-check) · [quota](#quota--api-request-counter) · [history](#history--audit-log) · [catalog](#catalog--device-type-catalog) · [schema](#schema--export-catalog-as-json) · [capabilities](#capabilities--cli-manifest) · [cache](#cache--inspect-and-clear-local-cache) · [reset](#reset--clear-local-data) · [policy cmd](#policy--validate-scaffold-and-migrate-policyyaml) · [completion](#completion--shell-tab-completion) · [codex](#codex--codex-cli-integration) -- [Output modes](#output-modes) · [Cache](#cache) · [Exit codes](#exit-codes--error-codes) · [Environment variables](#environment-variables) -- [Scripting examples](#scripting-examples) · [Development](#development) · [License](#license) - ---- - -## Features - -- 🔌 **Complete API coverage** — every `/v1.1` endpoint (devices, scenes, webhooks) -- 📚 **Built-in catalog** — offline reference for every device type's supported commands, parameter formats, and status fields (no API call needed) -- 🎨 **Dual output modes** — colorized tables by default; `--json` passthrough for `jq` and scripting -- 🔐 **Secure credentials** — HMAC-SHA256 signed requests; config file written with `0600`; env-var override for CI -- 🔍 **Dry-run mode** — preview every mutating request before it hits the API -- 🧪 **Fully tested** — 2500+ Vitest tests, mocked axios, zero network in CI -- ⚡ **Shell completion** — Bash / Zsh / Fish / PowerShell - -## Supported devices - -The built-in catalog covers every device type in the [SwitchBot Cloud API v1.1](https://github.com/OpenWonderLabs/SwitchBotAPI). -Run `switchbot catalog list` to see the full list including aliases and per-command details. - -| Category | Devices | -|---|---| -| **Lighting** | Color Bulb · Strip Light · Strip Light 3 · RGBICWW Strip Light · Floor Lamp · RGBICWW Floor Lamp · Ceiling Light · Ceiling Light Pro · RGBIC Neon Rope Light · RGBIC Neon Wire Rope Light · Candle Warmer Lamp | -| **Climate** | Humidifier · Humidifier2 · Air Purifier VOC · Air Purifier Table VOC · Air Purifier PM2.5 · Air Purifier Table PM2.5 · Smart Radiator Thermostat | -| **Security** | Smart Lock · Smart Lock Pro · Smart Lock Pro Wifi · Smart Lock Ultra · Lock Lite · Lock Vision · Lock Vision Pro · Keypad · Keypad Touch · Keypad Vision · Keypad Vision Pro · Garage Door Opener · Video Doorbell | -| **Curtains & blinds** | Curtain · Curtain3 · Blind Tilt · Roller Shade | -| **Power** | Plug · Plug Mini (US) · Plug Mini (JP) · Plug Mini (EU) · Relay Switch 1 · Relay Switch 1PM · Relay Switch 2PM | -| **Fans** | Battery Circulator Fan · Circulator Fan · Standing Circulator Fan | -| **Cleaning** | Robot Vacuum Cleaner S1 · Robot Vacuum Cleaner S1 Plus · K10+ · K10+ Pro · Robot Vacuum Cleaner K10+ Pro Combo · Robot Vacuum Cleaner S10 · Robot Vacuum Cleaner S20 · Robot Vacuum Cleaner K11+ · Robot Vacuum Cleaner K20 Plus Pro | -| **Sensors** _(read-only)_ | Meter · MeterPlus · WoIOSensor · MeterPro · MeterPro(CO2) · WeatherStation · Motion Sensor · Presence Sensor · Contact Sensor · Water Detector · Wallet Finder Card | -| **Hubs** _(read-only)_ | Hub · Hub Plus · Hub Mini · Hub 2 · Hub 3 · AI Hub | -| **Cameras** _(status only)_ | Indoor Cam · Pan/Tilt Cam · Pan/Tilt Cam 2K · Pan/Tilt Cam Plus 2K · Pan/Tilt Cam Plus 3K · Outdoor Spotlight Cam | -| **Other** | Bot · AI Art Frame · Home Climate Panel · Remote | -| **IR virtual remotes** _(via Hub)_ | Air Conditioner · TV · Streamer · Set Top Box · DVD · Speaker · Fan · Light · Others | - -## Requirements - -- **Node.js ≥ 18** -- A SwitchBot account with **Developer Options** enabled (see [Credentials](#credentials)) - ## Installation -### From npm (recommended) - ```bash npm install -g @switchbot/openapi-cli ``` -This adds the `switchbot` binary to your `$PATH`. - -### From source - -```bash -git clone https://github.com/OpenWonderLabs/switchbot-openapi-cli.git -cd switchbot-openapi-cli -npm install -npm run build -npm link # optional — expose `switchbot` globally -``` - -Verify: +Requires Node.js ≥ 18 and a SwitchBot account with **Developer Options** enabled. -```bash -switchbot --version -switchbot --help -``` +--- ## Quick start -> **Using Codex?** Skip this section and jump to [Codex integration](#codex-integration) — Codex uses a separate plugin package, not the `--skill-path` link below. - -The fast path (credentials + policy + skill link, with rollback on failure): - ```bash -switchbot install --agent claude-code --skill-path ../switchbot-skill -# or preview first -switchbot install --dry-run +switchbot auth login # browser OAuth — saves to OS keychain +switchbot config set-token # or set credentials manually +switchbot devices list # list all devices +switchbot devices command turnOn +switchbot doctor # self-check ``` -Prefer the manual 4-step walk-through? Here it is: - -```bash -# 1. Save your credentials (one-time) -switchbot config set-token - -# 2. List every device on your account -switchbot devices list - -# 3. Control a device, writing a structured entry to the audit log -switchbot devices command turnOn --audit-log - -# 4. Confirm everything is healthy — network, catalog, credentials, cache. -# Any non-"ok" check prints with a hint; fix those first. -switchbot doctor --json | jq '.checks[] | select(.status!="ok")' -``` - -Adding an AI agent or declarative automation? A few more one-liners -round out the first-day path: - -```bash -# 5. Cold-start snapshot an LLM can read before its first tool call. -switchbot agent-bootstrap --compact | jq '.identity, .devices.total' - -# 6. Scaffold a policy.yaml (aliases, quiet hours, confirmations) and -# validate it. Safe to run — defaults apply if you never edit it. -switchbot policy new -switchbot policy validate - -# 7. Stream real-time device events over MQTT (events land as JSONL). -switchbot events mqtt-tail --max 3 --json - -# 8. Run the OpenClaw status bridge in the background. -switchbot status-sync start --openclaw-model home-agent -``` - -See [Policy](#policy) for the authoring flow, [Rules engine](#rules-engine) -for automations, and [`docs/agent-guide.md`](./docs/agent-guide.md) -for the agent surface. +--- ## Codex integration -Use SwitchBot with [OpenAI Codex CLI](https://github.com/openai/codex) to control your smart home devices through natural-language AI conversations. - -The Codex plugin is **self-hosted in this repository** (`packages/codex-plugin/`) and registered directly via Codex's git marketplace — no separate plugin npm package required. - -### Quick start — just paste this into Codex +The Codex plugin is self-hosted in this repo (`packages/codex-plugin/`) — no separate npm package required. -Not sure how to run commands? Copy the block below and paste it directly into your Codex chat: +**Paste into Codex chat:** ``` Please set up the SwitchBot integration for me by running: @@ -185,702 +48,207 @@ npx @switchbot/openapi-cli codex setup Then restart Codex and confirm it's working. ``` -Codex will install the CLI, register the plugin from this repo's git marketplace, walk you through signing in, and let you know when it's ready. - -### For developers - -**Requirements:** [Codex CLI](https://github.com/openai/codex) on `$PATH`, Node.js ≥ 18. - -**One-command bootstrap** (installs CLI + registers plugin via git marketplace + auth + health check): - -```bash -npx @switchbot/openapi-cli codex setup -``` - -**Direct git marketplace registration** (if the CLI is already installed): +**Or run directly (if CLI is already installed):** ```bash -# Register the plugin directly from this repo — no npm package install needed codex plugin marketplace add OpenWonderLabs/switchbot-openapi-cli \ --sparse packages/codex-plugin --ref main codex plugin add switchbot@codex-plugin switchbot auth login ``` -`packages/codex-plugin/` in this repo is the Codex marketplace source. Registration always pulls from `main`; set `CODEX_GIT_MARKETPLACE_REF` to pin a different branch or tag. - -**Health check and repair:** - -```bash -switchbot codex doctor # 7-check summary; exits 1 on any failure -switchbot codex repair # re-auth + re-register + re-check -``` - -Both `setup` and `repair` accept `--dry-run`, `--json`, `--yes`, and `--profile` / `--config` (global flags). Run `switchbot codex setup --help` for the full flag list. +--- ## Credentials -The CLI reads credentials in this order (first match wins): - -1. **Environment variables** — `SWITCHBOT_TOKEN` and `SWITCHBOT_SECRET` -2. **OS keychain** — native keychain (macOS Keychain / Windows Credential Manager / libsecret on Linux) when populated via `switchbot auth keychain set` -3. **Config file** — `~/.switchbot/config.json` (written by `config set-token`, mode `0600`) - -### Browser login (recommended) - -The fastest way to get started — sign in with your SwitchBot account in the browser: - -```bash -switchbot auth login # opens browser, saves credentials to OS keychain -switchbot auth login --no-open # print URL instead of auto-opening -switchbot auth login --timeout 60 # custom timeout (default 120s) -``` - -The flow uses OAuth 2.0 via `sp.oauth.switchbot.net`, decrypts the API token/secret from the response, verifies them against the SwitchBot API, and stores the result in the OS keychain. - -### Manual setup - -Alternatively, obtain the token and secret from the SwitchBot mobile app: -**Profile → Preferences → Developer Options → Get Token**. - -```bash -# One-time setup (writes ~/.switchbot/config.json) -switchbot config set-token - -# Or export environment variables (e.g. in CI) -export SWITCHBOT_TOKEN=... -export SWITCHBOT_SECRET=... - -# Confirm which source is active and see the masked secret -switchbot config show -``` - -### OS keychain - -Backends: `security(1)` on macOS, `libsecret` / `secret-tool` on Linux, -Credential Manager (via PowerShell + Win32 `CredReadW`/`CredWriteW`) on -Windows. If no native backend is available, the file backend takes -over transparently so the CLI keeps working. `switchbot doctor` -surfaces which backend is active and warns when file-stored credentials -could be moved into a writable keychain. See [`auth` command](#auth--login-and-credential-management) for usage. - -## Policy - -`policy.yaml` is an optional per-user file that declares preferences -the CLI (and any connected AI agent) should honour: device aliases, -quiet-hours, confirmation overrides, audit-log location, and CLI -profile. The file lives at: - -- Linux / macOS: default policy path resolved by the CLI -- Windows: default policy path resolved by the CLI - -Everything in it is optional — if the file is missing, safe defaults -apply. Scaffold, edit, and validate: - -```bash -switchbot policy new # write a commented starter template -$EDITOR -switchbot policy validate # exit 0 if OK, otherwise line-accurate error -``` - -Why most users want a policy file: it makes name resolution -deterministic. Without it, "turn on the bedroom light" falls through -the CLI's prefix/substring/fuzzy match strategies and can pick the -wrong device when two names collide. A one-line `aliases` entry -removes the ambiguity. - -**Schema version.** The CLI requires **policy v0.2**. If you have an existing -v0.1 file from an earlier release, migrate it first: - -```bash -switchbot policy migrate # in-place upgrade, preserves comments -``` - -The v0.2 schema adds a typed `automation.rules[]` block (triggers, conditions, -throttles, dry-run) used by the rules engine (see -[Rules engine](#rules-engine)). Full field-by-field reference, validation flow, -and error catalogue: [`docs/policy-reference.md`](./docs/policy-reference.md). -Five annotated starter files covering common setups live in -[`examples/policies/`](./examples/policies/). - -### Rules engine - -With a policy.yaml (v0.2) you can declare automations that the CLI -executes for you. Supported triggers: **MQTT** (device events), -**cron** (schedule-driven), and **webhook** (local HTTP POST). -Supported conditions: `time_between` (quiet hours), `device_state` -(live API check with per-tick dedup), `event_count` (rolling-window -counts over per-device history), and `llm` (AI decision — see -below). Every fire is recorded in `~/.switchbot/audit.log`. `rules run` is long-running; use -`daemon start` / `daemon reload` for the managed background mode. - -**Triggers:** `mqtt` · `cron` · `webhook` — **Conditions:** `time_between` · `device_state` · `event_count` · `llm` — **Actions:** `command` · `notify` (webhook / file / openclaw, with `template` support) - -**LLM condition** — add an AI judgement step before actions fire: - -```yaml -conditions: - - llm: - prompt: "Is the temperature above normal comfort range?" - provider: auto # auto | openai | anthropic | local - cache_ttl: 5m - budget: - max_calls_per_hour: 20 - max_cost_per_day_usd: 1.00 - on_error: pass # fail | pass | skip -``` - -Set `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` for cloud providers; `SWITCHBOT_LOCAL_LLM_URL` for any OpenAI-compatible endpoint. +Priority: env vars → OS keychain → `~/.switchbot/config.json` ```bash -switchbot rules lint # static validation -switchbot rules list --json # structured rule summary -switchbot rules explain "motion on" # trigger / conditions / actions / last fired -switchbot rules run --dry-run --max-firings 5 # run engine (audit-only) -switchbot rules tail --follow # stream rule-* audit lines -switchbot rules replay --since 1h --json # per-rule fires/throttled/errors -switchbot rules summary # aggregate fires (24h) -switchbot rules conflicts # opposing actions / quiet-hours gaps -switchbot rules doctor --json # lint + conflicts combined -switchbot rules suggest --intent "turn off AC at 11pm" --llm auto -switchbot rules trace-explain --rule "motion on" --last -switchbot rules simulate "motion on" --since 7d --json +switchbot auth login # browser OAuth (recommended) +switchbot config set-token # manual setup +export SWITCHBOT_TOKEN=... SWITCHBOT_SECRET=... # CI / env override +switchbot auth keychain set/get/delete # OS keychain management ``` -LLM-generated rules always have `dry_run: true` — flip it yourself after review. - -## Global options - -- `--json`: Print the raw JSON response instead of a formatted table. -- `--format `: Output format: `tsv`, `yaml`, `jsonl`, `json`, `id`. -- `--fields `: Comma-separated column names to include (for example `deviceId,type`). -- `-v`, `--verbose`: Log HTTP request/response details to stderr. -- `--dry-run`: Print mutating requests (POST/PUT/DELETE) without sending them. -- `--timeout `: HTTP request timeout in milliseconds (default `30000`). -- `--config `: Override credential file location (default `~/.switchbot/config.json`). -- `--profile `: Use a named credential profile (`~/.switchbot/profiles/.json`). -- `--cache `: Set list and status cache TTL, for example `5m`, `1h`, `off`, `auto` (default). -- `--cache-list `: Set list-cache TTL independently (overrides `--cache`). -- `--cache-status `: Set status-cache TTL independently (default off; overrides `--cache`). -- `--no-cache`: Disable all cache reads for this invocation. -- `--retry-on-429 `: Max 429 retry attempts (default `3`). -- `--no-retry`: Disable automatic 429 retries. -- `--backoff `: Retry backoff: `exponential` (default) or `linear`. -- `--no-quota`: Disable local request-quota tracking. -- `--audit-log`: Append mutating commands to a JSONL audit log (default path `~/.switchbot/audit.log`). -- `--audit-log-path `: Custom audit log path; use together with `--audit-log`. -- `-V`, `--version`: Print the CLI version. -- `-h`, `--help`: Show help for any command or subcommand. - -Every subcommand supports `--help`. Use `--flag=value` form when a flag takes a value and is followed by a subcommand (e.g. `switchbot --profile=home devices list`). - -### `--dry-run` - -Intercepts every non-GET request: prints the URL/body it would have sent, then exits `0`. GET requests still execute. Also validates command names against the device catalog (exit 2 on unknown commands or read-only sensors). - -```bash -switchbot devices command ABC123 turnOn --dry-run -# [dry-run] Would POST https://api.switch-bot.com/v1.1/devices/ABC123/commands -# [dry-run] body: {"command":"turnOn","parameter":"default","commandType":"command"} -``` +--- ## Commands -### `auth` — login and credential management - -```bash -# Browser-based OAuth login (recommended for first-time setup) -switchbot auth login # opens browser, saves to OS keychain -switchbot auth login --no-open # print URL instead of auto-opening -switchbot auth login --timeout 60 # custom timeout (default 120s) - -# OS keychain management -switchbot auth keychain describe # show active backend -switchbot auth keychain set # write credentials directly -switchbot auth keychain get # verify credentials exist -switchbot auth keychain migrate # move config.json into keychain -switchbot auth keychain delete # remove stored credentials -``` - -### `config` — credential management - -```bash -switchbot config set-token # Save to ~/.switchbot/config.json -switchbot config show # Print current source + masked secret -switchbot config list-profiles # List saved profiles -switchbot config agent-profile --write # write recommended AI-agent profile (mode 0600) -``` - -### `devices` — list, status, control - -```bash -# List all physical devices and IR remote devices -switchbot devices list # default 4 columns: deviceId, deviceName, type, category -switchbot devices list --wide # full 10-column operator view -switchbot devices list --json | jq '.deviceList[].deviceId' -switchbot devices list --format=tsv --fields=deviceId,type,category - -# Filter by type / name / category / room -# Operators: = (substring; exact for category), ~ (substring), =/regex/; clauses AND-ed -switchbot devices list --filter 'type=Bot' -switchbot devices list --filter 'name~living,type=/Bulb|Strip/' -switchbot devices list --filter 'category=physical' - -# Query real-time status -switchbot devices status -switchbot devices status --ids ABC,DEF,GHI # batch status -switchbot devices status --ids ABC,DEF --fields power,battery --format jsonl - -# Resolve device by fuzzy name instead of ID (status, command, describe, expand, watch) -switchbot devices status --name "Living Room AC" -switchbot devices command --name "Office Light" turnOn - -# Send a control command -switchbot devices command [parameter] [--type command|customize] - -# Offline reference (no API call) -switchbot devices types # all device types -switchbot devices commands # commands, parameter formats, status fields -``` - -Parameters for `setAll`, `setPosition`, `setMode`, `setBrightness`, and `setColor` are validated client-side (exit 2 on bad input). `setColor` accepts `R:G:B`, `#RRGGBB`, `#RGB`, and CSS names — all normalize to `R:G:B`. Pass `--skip-param-validation` to bypass. Unknown deviceIds exit 2 by default; pass `--allow-unknown-device` for scripted pass-through. - -For per-device command and parameter details: `switchbot devices commands ` or the [SwitchBot API docs](https://github.com/OpenWonderLabs/SwitchBotAPI#send-device-control-commands). - -#### `devices expand` — named flags for packed parameters - -```bash -switchbot devices expand setAll --temp 26 --mode cool --fan low --power on -switchbot devices expand --name "Living Room AC" setAll --temp 26 --mode cool --fan low --power on -switchbot devices expand setPosition --position 50 --mode silent -switchbot devices expand setColor --color "#FF0000" -``` - -Run `switchbot devices expand --help` to see the available flags for any device command. - -#### `devices explain` — one-shot device summary - -```bash -switchbot devices explain # metadata + commands + live status -switchbot devices explain --no-live # catalog-only, no API call -``` - -#### `devices meta` — local device metadata - -```bash -switchbot devices meta set --alias "Office Light" -switchbot devices meta get -switchbot devices meta list -``` - -Stores local annotations in `~/.switchbot/device-meta.json`. `--show-hidden` on `devices list` reveals hidden devices. - -#### `devices batch` — bulk commands +### `devices` ```bash +switchbot devices list [--wide] [--filter 'type=Bot'] [--json] +switchbot devices status [--ids A,B,C] +switchbot devices command [parameter] +switchbot devices expand setAll --temp 26 --mode cool # named flags for packed params +switchbot devices watch [--interval 10s] [--for 5m] switchbot devices batch turnOff --filter 'type=Bot' -switchbot devices batch setBrightness 50 --filter 'type~Light,family=Living' -switchbot devices batch unlock --filter 'type=Smart Lock' --yes # destructive: requires --yes +switchbot devices meta set --alias "Office Light" ``` -### `scenes` — run manual scenes +### `scenes` ```bash -switchbot scenes list # Columns: sceneId, sceneName +switchbot scenes list switchbot scenes execute - -# One-shot summary: risk profile, execution hint, estimated commands -switchbot scenes explain -switchbot scenes explain --json -``` - -### `webhook` — receive device events over HTTP - -```bash -# Register a receiver URL for events from ALL devices -switchbot webhook setup https://your.host/hook - -# Query what is currently configured -switchbot webhook query -switchbot webhook query --details https://your.host/hook - -# Enable / disable / re-submit the registered URL -switchbot webhook update https://your.host/hook --enable -switchbot webhook update https://your.host/hook --disable - -# Remove the configuration -switchbot webhook delete https://your.host/hook ``` -The CLI validates that `` is an absolute `http://` or `https://` URL before calling the API. `--enable` and `--disable` are mutually exclusive. - -### `events` — receive device events - -#### `events tail` — local webhook receiver +### `codex` ```bash -switchbot events tail # listen on port 3000 -switchbot events tail --filter deviceId=ABC123 # filter to one device -switchbot events tail --filter 'type=WoMeter' --max 5 --for 10m -switchbot events tail --port 8080 --path /hook --json +switchbot codex setup [--yes] [--dry-run] [--json] # full bootstrap +switchbot codex doctor [--quiet] [--json] # 7-check health summary +switchbot codex repair [--skip re-auth] [--yes] # re-register + re-verify ``` -Run `switchbot webhook setup https://your.host/hook` first. `events tail` only runs the local receiver — tunnelling (ngrok/cloudflared) is up to you. - -#### `events mqtt-tail` — real-time MQTT stream +### `auth` ```bash -switchbot events mqtt-tail # stream all shadow events (Ctrl-C to stop) -switchbot events mqtt-tail --topic 'switchbot/#' # filter to topic subtree -switchbot events mqtt-tail --max 10 --for 30s --json +switchbot auth login [--no-open] [--timeout 60] +switchbot auth keychain describe/set/get/migrate/delete ``` -Credentials are provisioned automatically from the REST API config. Use `--sink` to route events to external services (`file`, `webhook`, `telegram`, `homeassistant`, `openclaw`) — see `switchbot events mqtt-tail --help` for details. - -### `status-sync` — MQTT/OpenClaw bridge - -Forwards SwitchBot MQTT shadow events into an OpenClaw gateway with stable lifecycle management. +### `config` ```bash -switchbot status-sync run --openclaw-model home-agent # foreground (for supervisors) -switchbot status-sync start --openclaw-model home-agent # background -switchbot status-sync status --json -switchbot status-sync stop -``` - -Required: `OPENCLAW_MODEL` (or `--openclaw-model`) and `OPENCLAW_TOKEN`. Optional: `OPENCLAW_URL`, `--topic`, `--state-dir`. Background mode writes `state.json`, `stdout.log`, and `stderr.log` under the state directory. - -### `daemon` — background rules-engine process - -Runs `switchbot rules run` as a detached background process. Tracks runtime -metadata in `~/.switchbot/daemon.state.json` and can co-launch a health HTTP -server. - -```bash -# Start the daemon (no-op if already running) -switchbot daemon start -switchbot daemon start --policy ./my-policy.yaml -switchbot daemon start --healthz-port 3100 # also launch health serve on port 3100 -switchbot daemon start --force # restart even if already running - -# Inspect daemon state (pid, log path, health server, last reload) -switchbot daemon status -switchbot daemon status --json - -# Hot-reload policy without restarting (sends SIGHUP on Unix, writes sentinel on Windows) -switchbot daemon reload - -# Stop the daemon and any co-launched health server -switchbot daemon stop -``` - -Start prints the PID, log path, and state file location. If the process exits -within 300 ms of launch, start fails immediately and includes the last 20 lines -of the log in the error message for fast diagnosis. - -### `completion` — shell tab-completion - -```bash -# Bash: load on every new shell -echo 'source <(switchbot completion bash)' >> ~/.bashrc - -# Zsh -echo 'source <(switchbot completion zsh)' >> ~/.zshrc - -# Fish -switchbot completion fish > ~/.config/fish/completions/switchbot.fish - -# PowerShell (profile) -switchbot completion powershell >> $PROFILE -``` - -Supported shells: `bash`, `zsh`, `fish`, `powershell` (`pwsh` is accepted as an alias). - -### `codex` — Codex CLI integration - -```bash -# Full bootstrap: verify codex CLI, install packages if needed, register plugin, auth, verify -switchbot codex setup -switchbot codex setup --yes # non-interactive (skip auth login spawn) -switchbot codex setup --dry-run # preview steps without mutating -switchbot codex setup --json # machine-readable output - -# Health check: 7 checks (node, path, credentials, mcp, codex-cli, npm-plugin, registered) -switchbot codex doctor -switchbot codex doctor --quiet # only show warn/fail -switchbot codex doctor --json - -# Repair: re-auth + re-register plugin + re-verify -switchbot codex repair -switchbot codex repair --skip re-auth # skip credential check -switchbot codex repair --skip remove-plugin # skip pre-clean step -switchbot codex repair --yes --dry-run --json -``` - -All subcommands accept `--dry-run`, `--json`, `--yes`, and global `--profile` / `--config`. -See [Codex integration](#codex-integration) for setup instructions. - -### `plan` — declarative batch operations - -```bash -switchbot plan schema # print JSON Schema for plan files -switchbot plan suggest --intent "turn off all lights" --device -switchbot plan validate plan.json -switchbot plan run plan.json # preview (--dry-run) or execute -switchbot plan run plan.json --require-approval # TTY confirmation for destructive steps -switchbot plan save plan.json # stage for review -switchbot plan review -switchbot plan approve -switchbot plan execute -``` - -### `devices watch` — poll status - -```bash -switchbot devices watch # poll every 30s -switchbot devices watch --interval 10s --json # custom interval -switchbot devices watch --for 5m # time-bounded -switchbot devices watch --include-unchanged --max 20 # emit every tick +switchbot config set-token +switchbot config show +switchbot config list-profiles ``` -### `mcp` — Model Context Protocol server +### `mcp` ```bash -# Start the stdio MCP server (connect via Claude, Cursor, etc.) -switchbot mcp serve +switchbot mcp serve # stdio MCP server — 24 tools ``` -Exposes MCP tools (`list_devices`, `describe_device`, `get_device_status`, -`get_device_history`, `query_device_history`, `aggregate_device_history`, -`send_command`, `list_scenes`, `run_scene`, `search_catalog`, -`account_overview`, `plan_suggest`, `plan_run`, `audit_query`, -`audit_stats`, `policy_diff`, `policy_validate`, `policy_new`, -`policy_migrate`, `policy_add_rule`, `rules_suggest`, `rule_notifications`, -`rules_explain`, `rules_simulate`) plus a -`switchbot://events` resource for real-time shadow updates. -`rules_suggest` accepts an optional `llm` parameter (`openai | anthropic | auto`) -to generate YAML for complex intents via an LLM backend. -`rule_notifications` returns `rule-notify` audit entries, filterable by rule -name, time range, channel, and result. -`rules_explain` returns the decision trace for a specific evaluation (why a rule -fired or was blocked); `rules_simulate` replays historical events against a rule -and reports would-fire / blocked / throttled outcomes. -See [`docs/agent-guide.md`](./docs/agent-guide.md) for the full tool reference and safety rules (destructive-command guard). - -### `doctor` — self-check +### `webhook` ```bash -switchbot doctor -switchbot doctor --json -switchbot doctor --fix --yes # auto-apply safe fixes +switchbot webhook setup +switchbot webhook query [--details ] +switchbot webhook update --enable/--disable +switchbot webhook delete ``` -Checks Node version, credentials, profiles, catalog, cache, quota, clock, MQTT, policy, MCP, keychain, path, inventory, audit, daemon, and more — exits 1 on any failure (`warn` exits 0). +### `events` ```bash -switchbot doctor --json | jq '{score: .data.maturityScore, label: .data.maturityLabel}' +switchbot events tail [--filter deviceId=X] [--port 8080] +switchbot events mqtt-tail [--max 10] [--for 30s] [--json] ``` -### `health` — runtime health report +### `status-sync` ```bash -# One-shot report: quota, audit error rate, circuit-breaker state -switchbot health check -switchbot health check --prometheus # Prometheus text format -switchbot health check --json - -# Start a long-running HTTP server with /healthz and /metrics -switchbot health serve # default port 3100, bind 127.0.0.1 -switchbot health serve --port 8080 -switchbot health serve --json # print {"status":"listening",...} on start +switchbot status-sync start --openclaw-model home-agent +switchbot status-sync status --json +switchbot status-sync stop ``` -`/healthz` returns a JSON health report (HTTP 200 when `ok`/`degraded`, 503 when circuit is open). -`/metrics` returns Prometheus text metrics (`switchbot_quota_used_total`, `switchbot_circuit_open`, …). -Port conflicts are reported immediately with a clear hint to choose a different port via `--port`. +### `rules` / `daemon` -### `upgrade-check` — version check +Policy-driven automations. Triggers: `mqtt` · `cron` · `webhook`. Conditions: `time_between` · `device_state` · `event_count` · `llm`. Actions: `command` · `notify`. ```bash -switchbot upgrade-check # exits 1 when update available -switchbot upgrade-check --json # {current, latest, upToDate, updateAvailable, breakingChange, installCommand} +switchbot rules lint +switchbot rules list/explain/run/simulate +switchbot rules tail/replay/summary/conflicts/doctor +switchbot rules suggest --intent "turn off AC at 11pm" [--llm auto] +switchbot daemon start/stop/reload/status ``` -### `quota` — API request counter +### `plan` -```bash -switchbot quota status # today's usage + last 7 days (10,000/day limit) -switchbot quota reset -``` - -### `history` — audit log +Declarative batch operations. A plan file has `version`, `description`, and a `steps` array. ```bash -switchbot history show --limit 20 -switchbot history replay 7 # re-run entry #7 -switchbot --json history show --limit 50 | jq '.entries[] | select(.result=="error")' +switchbot plan schema/suggest/validate +switchbot plan run plan.json [--dry-run] [--require-approval] +switchbot plan save/review/approve/execute ``` -### `catalog` — device type catalog +### `policy` ```bash -switchbot catalog show # all built-in types -switchbot catalog show Bot # one type -switchbot catalog search Hub # fuzzy match -switchbot catalog diff # local overlay vs built-in +switchbot policy new/validate/migrate/backup/restore ``` -Create `~/.switchbot/catalog-overlay.json` to extend or override type definitions without modifying the package. - -### `schema` — export catalog as JSON +### `doctor` / `health` ```bash -switchbot schema export # all types -switchbot schema export --type 'Strip Light' -switchbot schema export --role sensor +switchbot doctor [--json] [--fix --yes] +switchbot health check [--json] [--prometheus] +switchbot health serve [--port 3100] ``` -### `capabilities` — CLI manifest +### Other ```bash +switchbot history show [--limit 20] +switchbot quota status/reset +switchbot upgrade-check [--json] +switchbot catalog show/search +switchbot schema export [--type 'Strip Light'] switchbot capabilities --json -switchbot capabilities --used --json # only types seen in the local cache -``` - -Prints a versioned manifest of surfaces, commands, and environment variables. Each command leaf includes `{mutating, consumesQuota, agentSafetyTier, typicalLatencyMs}`. - -### `cache` — inspect and clear local cache - -```bash -switchbot cache show # paths, age, entry counts -switchbot cache clear # clear everything -switchbot cache clear --key list # list cache only -switchbot cache clear --key status # status cache only -``` - -### `reset` — clear local data - -```bash -switchbot reset # interactive confirmation, then wipe everything -switchbot reset --yes # skip confirmation (for scripts) -switchbot reset --keep-credentials # only clear cache/quota/history, keep credentials -``` - -Removes credentials (keychain + config), device cache, quota counter, device history, device metadata, status cache, and audit log. Use `--keep-credentials` to preserve login state while clearing cached data. - -### `policy` — validate, scaffold, and migrate policy.yaml - -```bash -switchbot policy new # write a starter policy -switchbot policy validate # compiler-style errors (line:col + caret) -switchbot policy validate --json | jq '.data.errors' -switchbot policy migrate # upgrade v0.1 → v0.2 in-place -switchbot policy backup # timestamped backup -switchbot policy restore +switchbot cache show/clear +switchbot reset [--yes] [--keep-credentials] +switchbot completion bash/zsh/fish/powershell ``` -Path resolution: positional `[path]` > `SWITCHBOT_POLICY_PATH` > default. Exit codes: `0` valid / `1` invalid / `2` missing / `3` yaml-parse / `4` internal / `5` exists (use `--force`) / `6` unsupported version. - -## Output modes - -- **Default** — ANSI-colored tables for `list`/`status`, key-value tables for details. -- **`--json`** — raw API payload passthrough. Errors are also JSON on **stdout**: `{ "schemaVersion": "1.2", "error": { "code", "kind", "message", "hint?" } }`. -- **`--format=json`** — projected row view; `--fields` applies. -- **`--format=tsv|yaml|jsonl|id`** — tabular text formats. - -```bash -switchbot devices list --json | jq '.deviceList[] | {id: .deviceId, name: .deviceName}' -switchbot devices list --format tsv --fields deviceId,deviceName,type,cloud -switchbot devices list --format id # one deviceId per line -``` - -## Cache - -Two local disk caches under `~/.switchbot/`: +--- -| Cache | Default TTL | Purpose | -|---|---|---| -| `devices.json` | 1 hour | device metadata; powers offline validation | -| `status.json` | off | per-device status; GC'd after 24h | +## Global options -```bash -switchbot devices list --no-cache # bypass for one invocation -switchbot devices status --cache 5m # set list + status TTL -switchbot devices status --cache-list 2h --cache-status 30s -``` +| Flag | Description | +|---|---| +| `--json` | Raw JSON output | +| `--format ` | `tsv` / `yaml` / `jsonl` / `id` | +| `--fields ` | Comma-separated column filter | +| `--dry-run` | Preview mutating requests without sending | +| `--verbose` | Log HTTP request/response to stderr | +| `--timeout ` | HTTP timeout (default `30000`) | +| `--config ` | Override credential file location | +| `--profile ` | Named credential profile | +| `--cache ` | Cache TTL (`5m`, `1h`, `off`, `auto`) | +| `--no-cache` | Disable all cache reads | +| `--retry-on-429 ` | Max 429 retry attempts (default `3`) | +| `--audit-log` | Append mutating commands to audit log | -## Exit codes & error codes +--- -- `0`: Success (including `--dry-run` intercept when validation passes). -- `1`: Runtime error — API error, network failure, missing credentials. -- `2`: Usage error — bad flag, missing/invalid argument, unknown subcommand, - unknown device type, invalid URL, conflicting flags. +## Exit codes -Typical errors bubble up in the form `Error: ` on stderr. The -SwitchBot-specific error codes mapped to readable messages: +| Code | Meaning | +|---|---| +| `0` | Success | +| `1` | Runtime error (API / network / credentials) | +| `2` | Usage error (bad flag / unknown command / validation) | -- `151`: Device type error. -- `152`: Device not found. -- `160`: Command not supported by this device. -- `161`: Device offline (BLE devices need a Hub). -- `171`: Hub offline. -- `190`: Device internal error / server busy. -- `401`: Authentication failed (check token/secret). -- `429`: Request rate too high (10,000 req/day cap). +--- ## Environment variables -- `SWITCHBOT_TOKEN`: API token — takes priority over the config file. -- `SWITCHBOT_SECRET`: API secret — takes priority over the config file. -- `SWITCHBOT_OAUTH_CLIENT_SECRET`: Override the bundled OAuth client secret (advanced). -- `SWITCHBOT_TOKEN_AES_KEY`: Override the AES-128-CBC key used for token decryption (advanced). -- `SWITCHBOT_TOKEN_AES_IV`: Override the AES-128-CBC IV used for token decryption (advanced). -- `NO_COLOR`: Disable ANSI colors in all output (automatically respected). - -## Scripting examples - -```bash -# Turn off every Bot device -switchbot devices list --json \ - | jq -r '.deviceList[] | select(.deviceType == "Bot") | .deviceId' \ - | while read id; do switchbot devices command "$id" turnOff; done +| Variable | Description | +|---|---| +| `SWITCHBOT_TOKEN` | API token (overrides config file) | +| `SWITCHBOT_SECRET` | API secret (overrides config file) | +| `NO_COLOR` | Disable ANSI colors | -# Dump each scene as ` ` -switchbot scenes list --json | jq -r '.[] | "\(.sceneId) \(.sceneName)"' -``` +--- ## Development ```bash -git clone https://github.com/OpenWonderLabs/switchbot-openapi-cli.git -cd switchbot-openapi-cli -npm install - -npm run dev -- # Run from TypeScript sources via tsx -npm run build # Compile to dist/ -npm test # Run the Vitest suite -npm run test:watch # Watch mode -npm run test:coverage # Coverage report (v8, HTML + text) +npm install && npm run build +npm run dev -- # run from TypeScript via tsx +npm test # Vitest suite ``` -Source layout: `src/commands/` (one file per command group), `src/devices/` (catalog + cache), `src/rules/` (engine, matcher, throttle, audit), `src/policy/` (validate, migrate, schema), `src/llm/` (providers), `src/utils/` (output, format, flags). Tests are in `tests/` and mirror the `src/` structure. - -### Release flow - -```bash -npm version patch # bump + create git tag -git push --follow-tags -# then: GitHub → Releases → Draft → Publish -``` - -See [`docs/release-pipeline.md`](./docs/release-pipeline.md) for the full CI / publish verification flow. - ## License [MIT](./LICENSE) © chenliuyun -## References +--- -- [SwitchBot API v1.1 documentation](https://github.com/OpenWonderLabs/SwitchBotAPI) -- Base URL: `https://api.switch-bot.com` -- Rate limit: 10,000 requests/day per account +- [SwitchBot API v1.1](https://github.com/OpenWonderLabs/SwitchBotAPI) · Base URL: `https://api.switch-bot.com` · Rate limit: 10,000 req/day From a11a246fb5b61df6730e7384ba8d30ed547f27d7 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 01:22:31 +0800 Subject: [PATCH 11/31] docs(readme): restore 'who is this for' entry-point summary --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index eedfa73..680e345 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,14 @@ SwitchBot smart home CLI — control devices, run scenes, stream events, and plu --- +**Human** — start with [Quick start](#quick-start): colored tables, error hints, shell completion, `switchbot doctor`. +**Script** — start with [Global options](#global-options): `--json`, `--format tsv/yaml/id`, `--fields`, stable exit codes, audit log. +**Agent** — start with [`docs/agent-guide.md`](./docs/agent-guide.md): `mcp serve`, `schema export`, `plan run`, destructive-command guards. + +Every surface shares the same catalog, cache, and HMAC client. + +--- + ## Installation ```bash From ddef72a91b993d2330d302e675695534e40dce06 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 09:59:06 +0800 Subject: [PATCH 12/31] docs(help): update rules description and add missing trace-explain/simulate entries - rules: broaden parent description to reflect full command set (author/lint/run/debug/simulate) - rules: add trace-explain and simulate to the addHelpText subcommand list - auth: remove stale (preview) label, shipped in v3.7.1 --- src/commands/auth.ts | 2 +- src/commands/rules.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 643b920..900e666 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -138,7 +138,7 @@ function cleanupMigratedSourceFile(sourceFile: string, parsed: Record Replay historical events against a rule; reports would-fire / blocked outcomes. webhook-rotate-token Rotate the bearer token used for webhook triggers. webhook-show-token Print the current bearer token (creating one if absent). From 5acd9b2f5c28dd1d26818d417d0fa37143459074 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 11:16:18 +0800 Subject: [PATCH 13/31] fix(codex): address code review findings from PR #54 - Translate Chinese JSDoc comment in codex-checks.ts to English - Document intentional ignored exit code on `plugin remove` pre-clean - Move on-demand @switchbot/codex-plugin install into registerCodexPluginAuto so setup, repair, and install --agent codex all behave consistently - Simplify setupStepRegisterPlugin to delegate entirely to registerCodexPluginAuto; remove now-unused registerCodexPlugin/registerCodexPluginGit imports and CODEX_PLUGIN_PACKAGE constant from codex.ts - Add WSL context hint to browser-login fallback message when browser cannot open - Document CODEX_GIT_MARKETPLACE_REF env var in README environment variables table - Update tests: expand registerCodexPluginAuto coverage with on-demand install scenarios; fix stepRegisterCodexPlugin throws-test mock count; simplify codex setup on-demand test to match new single-call boundary --- README.md | 1 + src/auth/browser-login.ts | 4 ++- src/commands/codex.ts | 38 ++++---------------- src/install/codex-checks.ts | 57 +++++++++++++++++++++++++----- tests/commands/codex.test.ts | 28 +++------------ tests/install/codex-checks.test.ts | 43 +++++++++++++++++++--- 6 files changed, 104 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 680e345..cbc36c3 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,7 @@ switchbot completion bash/zsh/fish/powershell | `SWITCHBOT_TOKEN` | API token (overrides config file) | | `SWITCHBOT_SECRET` | API secret (overrides config file) | | `NO_COLOR` | Disable ANSI colors | +| `CODEX_GIT_MARKETPLACE_REF` | Git ref used when registering the Codex plugin via the git marketplace (default: `main`) | --- diff --git a/src/auth/browser-login.ts b/src/auth/browser-login.ts index a384b9b..d96d2a6 100644 --- a/src/auth/browser-login.ts +++ b/src/auth/browser-login.ts @@ -41,7 +41,9 @@ export async function browserLogin(options: BrowserLoginOptions = {}): Promise { const base = await runDoctorChecks(CODEX_BASE_SECTIONS); @@ -390,35 +387,14 @@ function setupStepInstallGlobalPackage(step: string, packageName: string): Setup } function setupStepRegisterPlugin(ctx: SetupContext): SetupOutcome { - // Try Route B (git marketplace) — no npm package required - const gitResult = registerCodexPluginGit(); - if (gitResult.ok) { - ctx.codexPluginId = gitResult.pluginId; - ctx.packageRoot = gitResult.packageRoot; - return { step: 'register-plugin', status: 'ok', message: 'registered via git marketplace (Route B)' }; - } - - // Route B failed — install @switchbot/codex-plugin on demand, then try Route A - const installOutcome = setupStepInstallGlobalPackage('register-plugin', CODEX_PLUGIN_PACKAGE); - if (installOutcome.status === 'failed') { - return { - step: 'register-plugin', - status: 'failed', - message: `Route B failed (${gitResult.error}); npm install also failed: ${installOutcome.message ?? ''}`, - }; - } - - const npmResult = registerCodexPlugin(); - if (!npmResult.ok) { - return { - step: 'register-plugin', - status: 'failed', - message: `Route B failed (${gitResult.error}); Route A also failed: ${npmResult.error ?? ''}`, - }; + const r = registerCodexPluginAuto(); + if (!r.ok) { + return { step: 'register-plugin', status: 'failed', message: r.error }; } - ctx.codexPluginId = npmResult.pluginId; - ctx.packageRoot = npmResult.packageRoot; - return { step: 'register-plugin', status: 'ok', message: 'registered via local npm (Route A fallback)' }; + ctx.codexPluginId = r.pluginId; + 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}` }; } async function setupStepAuth(ctx: SetupContext): Promise { diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index 5ded1b1..26f22c9 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -251,8 +251,8 @@ export function resolveCodexPackageRoot(): { ok: true; packageRoot: string } | { } /** - * 共享注册 helper:封装 resolveCodexPackageRoot → resolvePluginId → runCodexPluginRegistration。 - * 保留作为 npm-local 路径注册的后备;新路径请用 registerCodexPluginGit()。 + * Route A fallback: resolve the locally-installed npm package root and register it. + * For new installs, prefer registerCodexPluginGit() (Route B). */ export function registerCodexPlugin(): RegisterCodexPluginResult { const root = resolveCodexPackageRoot(); @@ -275,7 +275,6 @@ export function registerCodexPlugin(): RegisterCodexPluginResult { } // ─── Git-based marketplace registration (Route B) ──────────────────────────── - export const CODEX_GIT_MARKETPLACE_REPO = 'OpenWonderLabs/switchbot-openapi-cli'; export const CODEX_GIT_MARKETPLACE_SPARSE = 'packages/codex-plugin'; export const CODEX_GIT_MARKETPLACE_REF = 'main'; @@ -291,7 +290,7 @@ export function runCodexPluginRegistrationGit(pluginId: string): RegistrationRes if (mkt.status !== 0) { return { ok: false, exitCode: mkt.status, stderr: mkt.stderr, stage: 'marketplace-add' }; } - spawnStr('codex', ['plugin', 'remove', pluginId]); + spawnStr('codex', ['plugin', 'remove', pluginId]); // pre-clean; ignore exit code — plugin may not be registered yet const add = spawnStr('codex', ['plugin', 'add', pluginId]); return { ok: add.status === 0, exitCode: add.status, stderr: add.stderr, stage: 'plugin-add' }; } @@ -309,18 +308,60 @@ export function registerCodexPluginGit(): RegisterCodexPluginResult { return { ok: true, pluginId, packageRoot: null }; } +// Install @switchbot/codex-plugin globally if not already present. +// Used by registerCodexPluginAuto as a last resort before retrying Route A. +function installCodexPluginGlobally(): { ok: boolean; error?: string } { + const list = spawnSync( + 'npm', ['list', '-g', '--json', '--depth=0', '@switchbot/codex-plugin'], + { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 10000 }, + ); + if ((list.status ?? 1) === 0) { + try { + const parsed = JSON.parse(list.stdout ?? '') as Record; + const deps = (parsed.dependencies ?? {}) as Record; + if (deps['@switchbot/codex-plugin']) return { ok: true }; + } catch { /* fall through to install */ } + } + const install = spawnSync( + 'npm', ['install', '-g', '@switchbot/codex-plugin@latest'], + { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 120000 }, + ); + if ((install.status ?? 1) !== 0) { + return { ok: false, error: `npm install -g failed (exit ${install.status ?? 1}): ${install.stderr ?? ''}` }; + } + return { ok: true }; +} + /** * Try Route B (git marketplace) first; fall back to local npm path if GitHub * is unreachable or the clone fails. This preserves air-gapped / corporate * environments where @switchbot/codex-plugin is already installed locally. */ export function registerCodexPluginAuto(): RegisterCodexPluginResult { + // Route B: git marketplace — no local npm package required const git = registerCodexPluginGit(); if (git.ok) return git; + + // Route A: local npm path (fast path if already installed) const npm = registerCodexPlugin(); if (npm.ok) return npm; - return { - ...npm, - error: `Route B failed (${git.error}); local npm fallback also failed (${npm.error})`, - }; + + // On-demand install: @switchbot/codex-plugin may not be globally installed yet. + // Covers fresh repair/install scenarios where the npm package is absent. + const install = installCodexPluginGlobally(); + if (!install.ok) { + return { + ...npm, + error: `Route B failed (${git.error}); Route A failed (${npm.error}); on-demand install failed: ${install.error}`, + }; + } + + // Retry Route A after successful install + const retry = registerCodexPlugin(); + return retry.ok + ? retry + : { + ...retry, + error: `Route B failed (${git.error}); installed @switchbot/codex-plugin but Route A still failed: ${retry.error}`, + }; } diff --git a/tests/commands/codex.test.ts b/tests/commands/codex.test.ts index d1e36b7..54c867c 100644 --- a/tests/commands/codex.test.ts +++ b/tests/commands/codex.test.ts @@ -547,17 +547,8 @@ describe('switchbot codex setup', () => { stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: '1.0.0' } } }), stderr: '', }); - // register-plugin step: - // 1. registerCodexPluginGit (= registerCodexPluginMock #1) → Route B fails - registerCodexPluginMock.mockReturnValueOnce({ - ok: false, pluginId: 'switchbot@codex-plugin', packageRoot: null, - error: 'marketplace-add exit 1: git clone failed', - }); - // 2. npm list -g: codex-plugin not installed - spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '{}', stderr: '' }); - // 3. npm install -g: succeeds - spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); - // 4. registerCodexPlugin (= registerCodexPluginMock #2) → Route A ok + // register-plugin: registerCodexPluginAuto handles Route B failure + on-demand install internally. + // Non-null packageRoot signals Route A was used (tested thoroughly in codex-checks.test.ts). registerCodexPluginMock.mockReturnValueOnce({ ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: '/some/path', }); @@ -575,21 +566,12 @@ describe('switchbot codex setup', () => { const parsed = JSON.parse(stdout.join('')) as { data?: { outcomes: Array<{ step: string; status: string; message?: string }> }; }; - // No standalone install-codex-plugin step — it happened inside register-plugin + // No standalone install-codex-plugin step — on-demand install is inside registerCodexPluginAuto expect(parsed.data!.outcomes.find((o) => o.step === 'install-codex-plugin')).toBeUndefined(); const registerStep = parsed.data!.outcomes.find((o) => o.step === 'register-plugin'); expect(registerStep?.status).toBe('ok'); expect(registerStep?.message).toContain('Route A fallback'); - // Verify the on-demand npm calls were made inside register-plugin - const npmListCall = spawnSyncRepairMock.mock.calls.find( - (c) => Array.isArray(c[1]) && c[1].includes('@switchbot/codex-plugin'), - ); - expect(npmListCall).toBeDefined(); - const npmInstallCall = spawnSyncRepairMock.mock.calls.find( - (c) => Array.isArray(c[1]) && (c[1] as string[]).includes('@switchbot/codex-plugin@latest'), - ); - expect(npmInstallCall).toBeDefined(); - // registerCodexPluginGit + registerCodexPlugin = 2 calls to the shared mock - expect(registerCodexPluginMock).toHaveBeenCalledTimes(2); + // registerCodexPluginAuto called once; internal npm calls tested in codex-checks.test.ts + expect(registerCodexPluginMock).toHaveBeenCalledOnce(); }); }); diff --git a/tests/install/codex-checks.test.ts b/tests/install/codex-checks.test.ts index 51ea5a8..918d59f 100644 --- a/tests/install/codex-checks.test.ts +++ b/tests/install/codex-checks.test.ts @@ -435,14 +435,46 @@ describe('registerCodexPluginAuto', () => { expect(r.packageRoot).toMatch(/codex-plugin/); }); - it('returns failure when both Route B and local path fail', () => { + it('installs on demand and retries Route A when Route B and initial Route A both fail', () => { + existsSyncMock.mockReturnValue(true); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // marketplace add (git) — fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // npm root -g fails (Route A) + .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list -g: not installed + .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n', '')) // npm root -g (retry) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + const r = registerCodexPluginAuto(); + expect(r.ok).toBe(true); + expect(r.packageRoot).toMatch(/codex-plugin/); + }); + + it('returns failure when Route B fails, Route A fails, and on-demand install also fails', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // marketplace add (git) — fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // npm root -g fails (Route A) + .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list -g: not installed + .mockReturnValueOnce(makeSpawnResult(1, '', 'EACCES')); // npm install -g: fails + const r = registerCodexPluginAuto(); + expect(r.ok).toBe(false); + expect(r.error).toMatch(/git clone failed/); + expect(r.error).toMatch(/npm root error/); + expect(r.error).toMatch(/EACCES/); + }); + + it('returns failure when on-demand install succeeds but Route A retry still fails', () => { spawnSyncMock .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // marketplace add (git) — fails - .mockReturnValueOnce(makeSpawnResult(1, '', 'npm error')); // npm root -g — fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // npm root -g fails (Route A) + .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list -g: not installed + .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error 2')); // npm root -g fails (retry) const r = registerCodexPluginAuto(); expect(r.ok).toBe(false); expect(r.error).toMatch(/git clone failed/); - expect(r.error).toMatch(/npm error/); + expect(r.error).toMatch(/npm root error 2/); }); }); @@ -471,7 +503,10 @@ describe('stepRegisterCodexPlugin', () => { it('throws when runCodexPluginRegistration fails', async () => { spawnSyncMock - .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'marketplace error' }); // marketplace add + .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'marketplace error' }) // marketplace add (git) — Route B fails + .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'npm root error' }) // npm root -g — Route A fails + .mockReturnValueOnce({ status: 1, stdout: '{}', stderr: '' }) // npm list -g: not installed + .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'EACCES' }); // npm install -g: fails (on-demand) const step = stepRegisterCodexPlugin(); const ctx = makeCtx(); await expect(step.execute(ctx)).rejects.toThrow('Codex plugin registration failed'); From ead9f54f69ef36d23c1e11cb5d5ae0bb6837e538 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 11:34:21 +0800 Subject: [PATCH 14/31] fix(codex): allow Route B installs past preflight; restore README devices table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - preflight: downgrade codex-plugin-npm check from fail → warn so switchbot install --agent codex can reach stepRegisterCodexPlugin() and attempt Route B (git marketplace) when the npm package is absent - add preflight test covering agent: 'codex' without global npm package - registerCodexPluginAuto: append "Run: switchbot codex repair" hint to all-routes-failed error messages - install.js resolveMarketplaceSourceRoot: add comment noting intentional sync with src/install/codex-checks.ts - README: restore Supported devices compatibility table (removed in trim) - codex setup --help: surface CODEX_GIT_MARKETPLACE_REF env var --- README.md | 21 +++++++++++++++++++++ packages/codex-plugin/bin/install.js | 5 +++++ src/commands/codex.ts | 4 ++++ src/install/codex-checks.ts | 4 ++-- src/install/preflight.ts | 7 +++++-- tests/install/preflight.test.ts | 19 +++++++++++++++++++ 6 files changed, 56 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index cbc36c3..17f241c 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,27 @@ Requires Node.js ≥ 18 and a SwitchBot account with **Developer Options** enabl --- +## Supported devices + +Run `switchbot catalog list` to see the full list including aliases and per-command details. + +| Category | Devices | +|---|---| +| **Lighting** | Color Bulb · Strip Light · Strip Light 3 · RGBICWW Strip Light · Floor Lamp · RGBICWW Floor Lamp · Ceiling Light · Ceiling Light Pro · RGBIC Neon Rope Light · RGBIC Neon Wire Rope Light · Candle Warmer Lamp | +| **Climate** | Humidifier · Humidifier2 · Air Purifier VOC · Air Purifier Table VOC · Air Purifier PM2.5 · Air Purifier Table PM2.5 · Smart Radiator Thermostat | +| **Security** | Smart Lock · Smart Lock Pro · Smart Lock Pro Wifi · Smart Lock Ultra · Lock Lite · Lock Vision · Lock Vision Pro · Keypad · Keypad Touch · Keypad Vision · Keypad Vision Pro · Garage Door Opener · Video Doorbell | +| **Curtains & blinds** | Curtain · Curtain3 · Blind Tilt · Roller Shade | +| **Power** | Plug · Plug Mini (US) · Plug Mini (JP) · Plug Mini (EU) · Relay Switch 1 · Relay Switch 1PM · Relay Switch 2PM | +| **Fans** | Battery Circulator Fan · Circulator Fan · Standing Circulator Fan | +| **Cleaning** | Robot Vacuum Cleaner S1 · Robot Vacuum Cleaner S1 Plus · K10+ · K10+ Pro · Robot Vacuum Cleaner K10+ Pro Combo · Robot Vacuum Cleaner S10 · Robot Vacuum Cleaner S20 · Robot Vacuum Cleaner K11+ · Robot Vacuum Cleaner K20 Plus Pro | +| **Sensors** _(read-only)_ | Meter · MeterPlus · WoIOSensor · MeterPro · MeterPro(CO2) · WeatherStation · Motion Sensor · Presence Sensor · Contact Sensor · Water Detector · Wallet Finder Card | +| **Hubs** _(read-only)_ | Hub · Hub Plus · Hub Mini · Hub 2 · Hub 3 · AI Hub | +| **Cameras** _(status only)_ | Indoor Cam · Pan/Tilt Cam · Pan/Tilt Cam 2K · Pan/Tilt Cam Plus 2K · Pan/Tilt Cam Plus 3K · Outdoor Spotlight Cam | +| **Other** | Bot · AI Art Frame · Home Climate Panel · Remote | +| **IR virtual remotes** _(via Hub)_ | Air Conditioner · TV · Streamer · Set Top Box · DVD · Speaker · Fan · Light · Others | + +--- + ## Quick start ```bash diff --git a/packages/codex-plugin/bin/install.js b/packages/codex-plugin/bin/install.js index 97e6fac..e59ef48 100644 --- a/packages/codex-plugin/bin/install.js +++ b/packages/codex-plugin/bin/install.js @@ -55,6 +55,11 @@ function computeAliasPath() { } export function resolveMarketplaceSourceRoot(packageRoot, deps = defaultFsDeps) { + // NOTE: This function is intentionally kept in sync with + // src/install/codex-checks.ts:resolveMarketplaceSourceRoot. Both copies exist + // because this package is self-contained (no TypeScript build step) and must + // work when loaded directly by the Codex plugin machinery before the CLI is + // installed. When updating the logic, mirror the change in both files. const needsAlias = process.platform === 'win32' ? /^[A-Za-z]:[\\/].*[\\/]@[^\\/]+[\\/]/.test(packageRoot) : /\/node_modules\/@[^/]+\//.test(packageRoot); diff --git a/src/commands/codex.ts b/src/commands/codex.ts index 706ad5c..6169e2a 100644 --- a/src/commands/codex.ts +++ b/src/commands/codex.ts @@ -464,6 +464,10 @@ function registerCodexSetupSubcommand(codex: Command): void { .description('Bootstrap the Codex integration end-to-end: install packages if missing, register plugin, auth, verify') .option('--skip ', 'Comma-separated step names to skip (only "install-switchbot-cli" or "auth" allowed)') .option('--yes', 'Non-interactive mode: do not spawn auth login, fail fast if credentials missing') + .addHelpText('after', ` +Environment variables: + CODEX_GIT_MARKETPLACE_REF Git ref used when registering via git marketplace (default: main) +`) .action(async (opts: { skip?: string; yes?: boolean }, command: Command) => { const skip = new Set( (opts.skip ?? '').split(',').map((s) => s.trim()).filter(Boolean), diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index 26f22c9..503afe3 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -352,7 +352,7 @@ export function registerCodexPluginAuto(): RegisterCodexPluginResult { if (!install.ok) { return { ...npm, - error: `Route B failed (${git.error}); Route A failed (${npm.error}); on-demand install failed: ${install.error}`, + error: `Route B failed (${git.error}); Route A failed (${npm.error}); on-demand install failed: ${install.error}. Run: switchbot codex repair`, }; } @@ -362,6 +362,6 @@ export function registerCodexPluginAuto(): RegisterCodexPluginResult { ? retry : { ...retry, - error: `Route B failed (${git.error}); installed @switchbot/codex-plugin but Route A still failed: ${retry.error}`, + error: `Route B failed (${git.error}); installed @switchbot/codex-plugin but Route A still failed: ${retry.error}. Run: switchbot codex repair`, }; } diff --git a/src/install/preflight.ts b/src/install/preflight.ts index d205833..85dcc5f 100644 --- a/src/install/preflight.ts +++ b/src/install/preflight.ts @@ -274,10 +274,13 @@ function checkCodexPluginForPreflight(opts: PreflightOptions): PreflightCheck | installed = Boolean(parsed.dependencies?.['@switchbot/codex-plugin']); } catch { /* treat as not installed */ } if (!installed) { + // Route B (git marketplace) can register without the npm package, so this + // is not a hard failure — stepRegisterCodexPlugin → registerCodexPluginAuto + // will try git first and fall back to on-demand npm install if needed. return { name: 'codex-plugin-npm', - status: 'fail', - message: '@switchbot/codex-plugin not installed globally', + status: 'warn', + message: '@switchbot/codex-plugin not installed globally (will be fetched via git marketplace)', hint: 'Run the full bootstrap instead: npx @switchbot/openapi-cli codex setup', }; } diff --git a/tests/install/preflight.test.ts b/tests/install/preflight.test.ts index 568f711..9a718ad 100644 --- a/tests/install/preflight.test.ts +++ b/tests/install/preflight.test.ts @@ -170,4 +170,23 @@ describe('runPreflight', () => { existsSpy.mockRestore(); } }); + + it('codex agent: missing global npm package is warn (not fail) — Route B does not need it', async () => { + // npm list -g will return non-zero / empty in the test environment; that used + // to be a hard 'fail' blocking the entire install. With Route B it should + // only be a warning so registerCodexPluginAuto() gets a chance to run. + const res = await runPreflight({ agent: 'codex' }); + const npmCheck = res.checks.find((c) => c.name === 'codex-plugin-npm'); + if (npmCheck) { + // When the npm package is absent the check must be at most 'warn'. + expect(npmCheck.status).not.toBe('fail'); + } + // Overall preflight must not be blocked by this check alone. + const nonNpmFails = res.checks.filter( + (c) => c.status === 'fail' && c.name !== 'codex-plugin-npm', + ); + if (nonNpmFails.length === 0) { + expect(res.ok).toBe(true); + } + }); }); From 2d357bb818386d15b1854988f10ffe593bccf4ac Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 11:51:34 +0800 Subject: [PATCH 15/31] =?UTF-8?q?fix(codex):=20address=20code=20review=20f?= =?UTF-8?q?indings=20=E2=80=94=20timeouts,=20regex,=20legacy=20IDs,=20skip?= =?UTF-8?q?=20compat,=20pluginId?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Increase marketplace-add timeout from 10s to 60s to survive slow git clones - Broaden Linux @-scope regex in resolveMarketplaceSourceRoot to match paths without a node_modules segment (custom npm prefix layouts) - Remove legacy plugin IDs (switchbot@switchbot-skill) in Route B pre-clean step - validateSkip now silently ignores unknown/removed step names so --skip install-codex-plugin no longer exits 2 in existing automation scripts - registerCodexPluginAuto failure returns use CODEX_PLUGIN_DEFAULT_ID as fallback instead of spreading an empty pluginId from the npm result --- packages/codex-plugin/bin/install.js | 2 +- .../resolve-marketplace-source-root.test.js | 20 +++++++++++++++ src/commands/codex.ts | 5 +++- src/install/codex-checks.ts | 24 ++++++++++++------ tests/install/codex-checks.test.ts | 25 ++++++++++++++++--- 5 files changed, 63 insertions(+), 13 deletions(-) diff --git a/packages/codex-plugin/bin/install.js b/packages/codex-plugin/bin/install.js index e59ef48..45b90ac 100644 --- a/packages/codex-plugin/bin/install.js +++ b/packages/codex-plugin/bin/install.js @@ -62,7 +62,7 @@ export function resolveMarketplaceSourceRoot(packageRoot, deps = defaultFsDeps) // installed. When updating the logic, mirror the change in both files. const needsAlias = process.platform === 'win32' ? /^[A-Za-z]:[\\/].*[\\/]@[^\\/]+[\\/]/.test(packageRoot) - : /\/node_modules\/@[^/]+\//.test(packageRoot); + : /\/@[^/]+\//.test(packageRoot); if (!needsAlias) { return packageRoot; diff --git a/packages/codex-plugin/tests/resolve-marketplace-source-root.test.js b/packages/codex-plugin/tests/resolve-marketplace-source-root.test.js index 8a264ba..1e43c31 100644 --- a/packages/codex-plugin/tests/resolve-marketplace-source-root.test.js +++ b/packages/codex-plugin/tests/resolve-marketplace-source-root.test.js @@ -101,4 +101,24 @@ describe('resolveMarketplaceSourceRoot', () => { Object.defineProperty(process, 'platform', { value: savedPlatform, configurable: true }); } }); + + it('aliases Linux custom-prefix path with no node_modules segment', () => { + const savedPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + try { + const target = '/home/me/.local/lib/@switchbot/codex-plugin'; + const created = []; + const deps = makeDeps({ + lstatSync: () => null, + mkdirSync: (p) => created.push(['mkdir', p]), + symlinkSync: (from, to, type) => created.push(['symlink', from, to, type]), + }); + const resolved = resolveMarketplaceSourceRoot(target, deps); + assert.match(resolved, /codex-plugin-marketplace$/); + assert.equal(created[1][1], target); + assert.equal(created[1][3], 'dir'); + } finally { + Object.defineProperty(process, 'platform', { value: savedPlatform, configurable: true }); + } + }); }); diff --git a/src/commands/codex.ts b/src/commands/codex.ts index 6169e2a..e711323 100644 --- a/src/commands/codex.ts +++ b/src/commands/codex.ts @@ -198,8 +198,11 @@ const REPAIR_STEPS: readonly StepDef[] = [ function validateSkip(stepDefs: readonly StepDef[], skip: Set): { ok: true } | { ok: false; offending: string } { const skippableNames = new Set(stepDefs.filter((s) => s.skippable).map((s) => s.name)); + const allNames = new Set(stepDefs.map((s) => s.name)); for (const name of skip) { - if (!skippableNames.has(name)) { + // Only reject a name that exists as a step but is not skippable. + // Unknown names (e.g. removed steps like 'install-codex-plugin') are silently no-ops. + if (allNames.has(name) && !skippableNames.has(name)) { return { ok: false, offending: name }; } } diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index 503afe3..71380e8 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -25,11 +25,11 @@ export interface RegisterCodexPluginResult { stderr?: string; } -function spawnStr(cmd: string, args: string[]): { status: number; stdout: string; stderr: string } { +function spawnStr(cmd: string, args: string[], timeout = 10000): { status: number; stdout: string; stderr: string } { const r = spawnSync(cmd, args, { encoding: 'utf-8', shell: process.platform === 'win32', - timeout: 10000, + timeout, }); return { status: r.status ?? -1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }; } @@ -86,7 +86,7 @@ export function resolveMarketplaceSourceRoot(packageRoot: string): string { // a symlink/junction at a stable `@`-free location. const needsAlias = process.platform === 'win32' ? /^[A-Za-z]:[\\/].*[\\/]@[^\\/]+[\\/]/.test(packageRoot) - : /\/node_modules\/@[^/]+\//.test(packageRoot); + : /\/@[^/]+\//.test(packageRoot); if (!needsAlias) return packageRoot; @@ -278,25 +278,32 @@ export function registerCodexPlugin(): RegisterCodexPluginResult { export const CODEX_GIT_MARKETPLACE_REPO = 'OpenWonderLabs/switchbot-openapi-cli'; export const CODEX_GIT_MARKETPLACE_SPARSE = 'packages/codex-plugin'; export const CODEX_GIT_MARKETPLACE_REF = 'main'; +export const CODEX_PLUGIN_DEFAULT_ID = 'switchbot@codex-plugin'; +// Known IDs from pre-release installs; cleaned up during Route B pre-clean step. +const CODEX_PLUGIN_LEGACY_IDS = ['switchbot@switchbot-skill']; export function runCodexPluginRegistrationGit(pluginId: string): RegistrationResult { const ref = process.env['CODEX_GIT_MARKETPLACE_REF'] ?? CODEX_GIT_MARKETPLACE_REF; + // git clone via marketplace add can take >10 s on slow networks; use 60 s const mkt = spawnStr('codex', [ 'plugin', 'marketplace', 'add', CODEX_GIT_MARKETPLACE_REPO, '--sparse', CODEX_GIT_MARKETPLACE_SPARSE, '--ref', ref, - ]); + ], 60000); if (mkt.status !== 0) { return { ok: false, exitCode: mkt.status, stderr: mkt.stderr, stage: 'marketplace-add' }; } - spawnStr('codex', ['plugin', 'remove', pluginId]); // pre-clean; ignore exit code — plugin may not be registered yet + // Pre-clean: remove current ID and any known legacy IDs; ignore exit codes + for (const id of [pluginId, ...CODEX_PLUGIN_LEGACY_IDS]) { + spawnStr('codex', ['plugin', 'remove', id]); + } const add = spawnStr('codex', ['plugin', 'add', pluginId]); return { ok: add.status === 0, exitCode: add.status, stderr: add.stderr, stage: 'plugin-add' }; } export function registerCodexPluginGit(): RegisterCodexPluginResult { - const pluginId = 'switchbot@codex-plugin'; + const pluginId = CODEX_PLUGIN_DEFAULT_ID; const r = runCodexPluginRegistrationGit(pluginId); if (!r.ok) { return { @@ -351,7 +358,9 @@ export function registerCodexPluginAuto(): RegisterCodexPluginResult { const install = installCodexPluginGlobally(); if (!install.ok) { return { - ...npm, + ok: false, + pluginId: npm.pluginId || CODEX_PLUGIN_DEFAULT_ID, + packageRoot: null, error: `Route B failed (${git.error}); Route A failed (${npm.error}); on-demand install failed: ${install.error}. Run: switchbot codex repair`, }; } @@ -362,6 +371,7 @@ export function registerCodexPluginAuto(): RegisterCodexPluginResult { ? retry : { ...retry, + pluginId: retry.pluginId || CODEX_PLUGIN_DEFAULT_ID, error: `Route B failed (${git.error}); installed @switchbot/codex-plugin but Route A still failed: ${retry.error}. Run: switchbot codex repair`, }; } diff --git a/tests/install/codex-checks.test.ts b/tests/install/codex-checks.test.ts index 918d59f..6453355 100644 --- a/tests/install/codex-checks.test.ts +++ b/tests/install/codex-checks.test.ts @@ -378,13 +378,27 @@ describe('resolveMarketplaceSourceRoot — Linux @-scoped path handling', () => expect(unlinkSyncMock).not.toHaveBeenCalled(); expect(symlinkSyncMock).not.toHaveBeenCalled(); }); + + it('aliases a custom-prefix path that has no node_modules segment', () => { + lstatSyncMock.mockReturnValue(null); + const customPrefixRoot = '/home/user/.local/lib/@switchbot/codex-plugin'; + const result = resolveMarketplaceSourceRoot(customPrefixRoot); + expect(symlinkSyncMock).toHaveBeenCalledWith( + customPrefixRoot, + expect.stringMatching(/codex-plugin-marketplace$/), + 'dir', + ); + expect(result).toMatch(/codex-plugin-marketplace$/); + expect(result).not.toContain('@'); + }); }); describe('runCodexPluginRegistrationGit', () => { it('returns ok when marketplace add and plugin add both succeed', () => { spawnSyncMock .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (git) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (pre-clean) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = runCodexPluginRegistrationGit('switchbot@codex-plugin'); expect(r.ok).toBe(true); @@ -402,7 +416,8 @@ describe('runCodexPluginRegistrationGit', () => { it('returns failure when plugin add exits non-zero', () => { spawnSyncMock .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) .mockReturnValueOnce(makeSpawnResult(1, '', 'plugin add error')); const r = runCodexPluginRegistrationGit('switchbot@codex-plugin'); expect(r.ok).toBe(false); @@ -415,7 +430,8 @@ describe('registerCodexPluginAuto', () => { it('returns git result when Route B succeeds', () => { spawnSyncMock .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (git) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = registerCodexPluginAuto(); expect(r.ok).toBe(true); @@ -492,7 +508,8 @@ describe('stepRegisterCodexPlugin', () => { it('sets codexPluginRegistered and codexPluginIdentifier on success', async () => { spawnSyncMock .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // marketplace add (git) - .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // plugin remove (pre-clean) + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // plugin remove (current id) + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // plugin remove (legacy id) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); // plugin add const step = stepRegisterCodexPlugin(); const ctx = makeCtx(); From 89cac084a1a008220ad5a5eae7986b6f5eacfaf6 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 12:00:39 +0800 Subject: [PATCH 16/31] fix(codex): tighten validateSkip, add timeout env var, use canonical pluginId in errors - validateSkip now rejects unknown step names again (restores typo detection); only explicitly deprecated names like install-codex-plugin are silently no-ops - Add CODEX_MARKETPLACE_ADD_TIMEOUT env var to override the 60 s marketplace-add timeout, giving users on slow networks / Windows with AV scanning an escape hatch - Error return paths in registerCodexPluginAuto always report CODEX_PLUGIN_DEFAULT_ID instead of propagating a potentially stale/wrong ID from a corrupted manifest --- src/commands/codex.ts | 13 ++++++++----- src/install/codex-checks.ts | 9 +++++---- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/commands/codex.ts b/src/commands/codex.ts index e711323..d002f2d 100644 --- a/src/commands/codex.ts +++ b/src/commands/codex.ts @@ -196,13 +196,15 @@ const REPAIR_STEPS: readonly StepDef[] = [ { name: 'doctor-verify', description: 'Run Codex doctor checks and report health', skippable: false }, ]; +// Step names removed from SETUP_STEPS/REPAIR_STEPS in past releases; silently +// accepted by --skip for backward compatibility instead of exit 2. +const DEPRECATED_SKIP_NAMES = new Set(['install-codex-plugin']); + function validateSkip(stepDefs: readonly StepDef[], skip: Set): { ok: true } | { ok: false; offending: string } { const skippableNames = new Set(stepDefs.filter((s) => s.skippable).map((s) => s.name)); - const allNames = new Set(stepDefs.map((s) => s.name)); for (const name of skip) { - // Only reject a name that exists as a step but is not skippable. - // Unknown names (e.g. removed steps like 'install-codex-plugin') are silently no-ops. - if (allNames.has(name) && !skippableNames.has(name)) { + if (DEPRECATED_SKIP_NAMES.has(name)) continue; + if (!skippableNames.has(name)) { return { ok: false, offending: name }; } } @@ -469,7 +471,8 @@ function registerCodexSetupSubcommand(codex: Command): void { .option('--yes', 'Non-interactive mode: do not spawn auth login, fail fast if credentials missing') .addHelpText('after', ` Environment variables: - CODEX_GIT_MARKETPLACE_REF Git ref used when registering via git marketplace (default: main) + CODEX_GIT_MARKETPLACE_REF Git ref used when registering via git marketplace (default: main) + CODEX_MARKETPLACE_ADD_TIMEOUT Timeout in ms for "codex plugin marketplace add" (default: 60000) `) .action(async (opts: { skip?: string; yes?: boolean }, command: Command) => { const skip = new Set( diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index 71380e8..8776d00 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -283,14 +283,15 @@ export const CODEX_PLUGIN_DEFAULT_ID = 'switchbot@codex-plugin'; const CODEX_PLUGIN_LEGACY_IDS = ['switchbot@switchbot-skill']; export function runCodexPluginRegistrationGit(pluginId: string): RegistrationResult { - const ref = process.env['CODEX_GIT_MARKETPLACE_REF'] ?? CODEX_GIT_MARKETPLACE_REF; + const ref = process.env['CODEX_GIT_MARKETPLACE_REF'] ?? CODEX_GIT_MARKETPLACE_REF; + const timeout = Number(process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']) || 60000; // git clone via marketplace add can take >10 s on slow networks; use 60 s const mkt = spawnStr('codex', [ 'plugin', 'marketplace', 'add', CODEX_GIT_MARKETPLACE_REPO, '--sparse', CODEX_GIT_MARKETPLACE_SPARSE, '--ref', ref, - ], 60000); + ], timeout); if (mkt.status !== 0) { return { ok: false, exitCode: mkt.status, stderr: mkt.stderr, stage: 'marketplace-add' }; } @@ -359,7 +360,7 @@ export function registerCodexPluginAuto(): RegisterCodexPluginResult { if (!install.ok) { return { ok: false, - pluginId: npm.pluginId || CODEX_PLUGIN_DEFAULT_ID, + pluginId: CODEX_PLUGIN_DEFAULT_ID, packageRoot: null, error: `Route B failed (${git.error}); Route A failed (${npm.error}); on-demand install failed: ${install.error}. Run: switchbot codex repair`, }; @@ -371,7 +372,7 @@ export function registerCodexPluginAuto(): RegisterCodexPluginResult { ? retry : { ...retry, - pluginId: retry.pluginId || CODEX_PLUGIN_DEFAULT_ID, + pluginId: CODEX_PLUGIN_DEFAULT_ID, error: `Route B failed (${git.error}); installed @switchbot/codex-plugin but Route A still failed: ${retry.error}. Run: switchbot codex repair`, }; } From c6471723ea3650dad9b8a77c09259fa2475904b8 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 12:06:51 +0800 Subject: [PATCH 17/31] fix(codex): update --skip help text and harden timeout env var parsing - setup --skip option description now lists deprecated accepted names alongside the two skippable steps, so the help text matches actual behavior - CODEX_MARKETPLACE_ADD_TIMEOUT parsing uses Number.isFinite + >0 guard instead of || 60000, preventing falsy-zero or negative values from silently bypassing the env var or removing timeout protection --- src/commands/codex.ts | 2 +- src/install/codex-checks.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/commands/codex.ts b/src/commands/codex.ts index d002f2d..899583f 100644 --- a/src/commands/codex.ts +++ b/src/commands/codex.ts @@ -467,7 +467,7 @@ function registerCodexSetupSubcommand(codex: Command): void { codex .command('setup') .description('Bootstrap the Codex integration end-to-end: install packages if missing, register plugin, auth, verify') - .option('--skip ', 'Comma-separated step names to skip (only "install-switchbot-cli" or "auth" allowed)') + .option('--skip ', 'Comma-separated step names to skip (skippable: "install-switchbot-cli", "auth"; deprecated no-ops: "install-codex-plugin")') .option('--yes', 'Non-interactive mode: do not spawn auth login, fail fast if credentials missing') .addHelpText('after', ` Environment variables: diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index 8776d00..0d41eb6 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -284,7 +284,8 @@ const CODEX_PLUGIN_LEGACY_IDS = ['switchbot@switchbot-skill']; export function runCodexPluginRegistrationGit(pluginId: string): RegistrationResult { const ref = process.env['CODEX_GIT_MARKETPLACE_REF'] ?? CODEX_GIT_MARKETPLACE_REF; - const timeout = Number(process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']) || 60000; + const _parsedTimeout = Number(process.env['CODEX_MARKETPLACE_ADD_TIMEOUT'] ?? ''); + const timeout = Number.isFinite(_parsedTimeout) && _parsedTimeout > 0 ? _parsedTimeout : 60000; // git clone via marketplace add can take >10 s on slow networks; use 60 s const mkt = spawnStr('codex', [ 'plugin', 'marketplace', 'add', From 807928079806d4c793381b36d72591d5bd03eabc Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 13:03:52 +0800 Subject: [PATCH 18/31] fix(codex): warn to stderr when CODEX_MARKETPLACE_ADD_TIMEOUT is set but invalid --- src/install/codex-checks.ts | 11 +++++++++-- tests/install/codex-checks.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index 0d41eb6..63a68cd 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -284,8 +284,15 @@ const CODEX_PLUGIN_LEGACY_IDS = ['switchbot@switchbot-skill']; export function runCodexPluginRegistrationGit(pluginId: string): RegistrationResult { const ref = process.env['CODEX_GIT_MARKETPLACE_REF'] ?? CODEX_GIT_MARKETPLACE_REF; - const _parsedTimeout = Number(process.env['CODEX_MARKETPLACE_ADD_TIMEOUT'] ?? ''); - const timeout = Number.isFinite(_parsedTimeout) && _parsedTimeout > 0 ? _parsedTimeout : 60000; + const _envTimeout = process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; + const _parsedTimeout = Number(_envTimeout ?? ''); + const _timeoutValid = Number.isFinite(_parsedTimeout) && _parsedTimeout > 0; + if (_envTimeout !== undefined && _envTimeout !== '' && !_timeoutValid) { + process.stderr.write( + `[switchbot] CODEX_MARKETPLACE_ADD_TIMEOUT="${_envTimeout}" is not a valid positive integer; using default 60000 ms\n`, + ); + } + const timeout = _timeoutValid ? _parsedTimeout : 60000; // git clone via marketplace add can take >10 s on slow networks; use 60 s const mkt = spawnStr('codex', [ 'plugin', 'marketplace', 'add', diff --git a/tests/install/codex-checks.test.ts b/tests/install/codex-checks.test.ts index 6453355..8950fba 100644 --- a/tests/install/codex-checks.test.ts +++ b/tests/install/codex-checks.test.ts @@ -424,6 +424,29 @@ describe('runCodexPluginRegistrationGit', () => { expect(r.stderr).toBe('plugin add error'); expect(r.stage).toBe('plugin-add'); }); + + it('warns to stderr and falls back to 60000 ms when CODEX_MARKETPLACE_ADD_TIMEOUT is "0"', () => { + const origEnv = process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; + process.env['CODEX_MARKETPLACE_ADD_TIMEOUT'] = '0'; + const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '', 'fail')); + runCodexPluginRegistrationGit('switchbot@codex-plugin'); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('CODEX_MARKETPLACE_ADD_TIMEOUT')); + spy.mockRestore(); + if (origEnv === undefined) delete process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; + else process.env['CODEX_MARKETPLACE_ADD_TIMEOUT'] = origEnv; + }); + + it('does not warn when CODEX_MARKETPLACE_ADD_TIMEOUT is unset', () => { + const origEnv = process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; + delete process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; + const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '', 'fail')); + runCodexPluginRegistrationGit('switchbot@codex-plugin'); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + if (origEnv !== undefined) process.env['CODEX_MARKETPLACE_ADD_TIMEOUT'] = origEnv; + }); }); describe('registerCodexPluginAuto', () => { From f2345d652e6c0f9d610b02c17e35661776810d9a Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 13:04:27 +0800 Subject: [PATCH 19/31] fix(codex-plugin): catch resolveMarketplaceSourceRoot errors and emit actionable message --- packages/codex-plugin/bin/install.js | 10 +++++++-- packages/codex-plugin/tests/install.test.js | 25 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/codex-plugin/bin/install.js b/packages/codex-plugin/bin/install.js index 45b90ac..a6a4e32 100644 --- a/packages/codex-plugin/bin/install.js +++ b/packages/codex-plugin/bin/install.js @@ -103,7 +103,7 @@ function formatCodexFailure(step) { ].join('\n'); } -export function makeInstall({ checkCli, runInherit, packageRoot, runAuth }) { +export function makeInstall({ checkCli, runInherit, packageRoot, runAuth, resolveRoot = resolveMarketplaceSourceRoot }) { return async function install() { process.stderr.write( '[switchbot-codex] WARNING: switchbot-codex-install is deprecated.\n' + @@ -122,7 +122,13 @@ export function makeInstall({ checkCli, runInherit, packageRoot, runAuth }) { process.stderr.write(`[switchbot-codex] CLI ${cliCheck.version} detected.\n`); } - const marketplaceRoot = resolveMarketplaceSourceRoot(packageRoot); + let marketplaceRoot; + try { + marketplaceRoot = resolveRoot(packageRoot); + } catch (err) { + process.stderr.write(`[switchbot-codex] Cannot prepare marketplace path: ${err.message}\n`); + return 1; + } process.stderr.write(`[switchbot-codex] Registering plugin at ${marketplaceRoot}...\n`); const marketplaceCode = await runInherit('codex', ['plugin', 'marketplace', 'add', marketplaceRoot]); if (marketplaceCode !== 0) { diff --git a/packages/codex-plugin/tests/install.test.js b/packages/codex-plugin/tests/install.test.js index 21e4d5d..1b388c2 100644 --- a/packages/codex-plugin/tests/install.test.js +++ b/packages/codex-plugin/tests/install.test.js @@ -174,6 +174,31 @@ describe('makeInstall', () => { assert.equal(callCount, 1); assert.equal(auth.calls.length, 0); }); + + it('returns 1 with a prefixed message when resolveMarketplaceSourceRoot throws', async () => { + const auth = makeRunAuth(0); + const { spawn } = makeSpawn(0); + const errChunks = []; + const origWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (chunk, ...rest) => { errChunks.push(String(chunk)); return true; }; + + const install = makeInstall({ + checkCli: makeOkCliCheck(), + runInherit: spawn, + packageRoot: TEST_ROOT, + runAuth: auth.runAuth, + resolveRoot: () => { + throw new Error('alias path /home/user/.switchbot/codex-plugin-marketplace exists and is not a symlink/junction; remove it manually and retry'); + }, + }); + const code = await install(); + process.stderr.write = origWrite; + + assert.equal(code, 1); + const combined = errChunks.join(''); + assert.ok(combined.includes('[switchbot-codex]'), `expected [switchbot-codex] prefix in: ${combined}`); + assert.ok(combined.includes('codex-plugin-marketplace'), `expected alias path in: ${combined}`); + }); }); describe('resolvePluginIdentifier', () => { From ba10ce11aaa75b7c3d3820ffb9dbb3ec67844fd9 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 13:04:27 +0800 Subject: [PATCH 20/31] fix(codex): warn when deprecated --skip name is used (no-op instead of silent accept) --- src/commands/codex.ts | 5 ++++- tests/commands/codex.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/commands/codex.ts b/src/commands/codex.ts index 899583f..347ae14 100644 --- a/src/commands/codex.ts +++ b/src/commands/codex.ts @@ -203,7 +203,10 @@ const DEPRECATED_SKIP_NAMES = new Set(['install-codex-plugin']); function validateSkip(stepDefs: readonly StepDef[], skip: Set): { ok: true } | { ok: false; offending: string } { const skippableNames = new Set(stepDefs.filter((s) => s.skippable).map((s) => s.name)); for (const name of skip) { - if (DEPRECATED_SKIP_NAMES.has(name)) continue; + if (DEPRECATED_SKIP_NAMES.has(name)) { + console.error(`[switchbot] --skip "${name}" is no longer a valid step name and has no effect`); + continue; + } if (!skippableNames.has(name)) { return { ok: false, offending: name }; } diff --git a/tests/commands/codex.test.ts b/tests/commands/codex.test.ts index 54c867c..b75f838 100644 --- a/tests/commands/codex.test.ts +++ b/tests/commands/codex.test.ts @@ -302,6 +302,32 @@ describe('switchbot codex repair', () => { // default profile → also no --profile in argv expect(argv).not.toContain('--profile'); }); + + it('emits a warning when a deprecated step name is passed to --skip', async () => { + // verify-cli: node+path ok + runDoctorChecksMock.mockResolvedValueOnce([ + { name: 'node', status: 'ok', detail: 'ok' }, + { name: 'path', status: 'ok', detail: 'ok' }, + ]); + // re-auth step runs (not skipped) + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); + registerCodexPluginMock.mockReturnValueOnce({ + ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: '/some/path', + }); + 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, stderr } = await runCli( + registerCodexCommand, + ['codex', 'repair', '--skip', 'install-codex-plugin,remove-plugin'], + ); + expect(exitCode).toBe(0); + const errOut = stderr.join('\n'); + expect(errOut).toContain('install-codex-plugin'); + expect(errOut).toMatch(/no.*effect|deprecated|no longer/i); + }); }); // ─── codex setup (C5) ──────────────────────────────────────────────────────── From 3e0f06e076862a975b46ea3c91b5c259591fc2cb Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 13:05:25 +0800 Subject: [PATCH 21/31] fix(codex): strip npm warning prefix before JSON parse in installCodexPluginGlobally --- src/install/codex-checks.ts | 4 +++- tests/install/codex-checks.test.ts | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index 63a68cd..03189a3 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -333,7 +333,9 @@ function installCodexPluginGlobally(): { ok: boolean; error?: string } { ); if ((list.status ?? 1) === 0) { try { - const parsed = JSON.parse(list.stdout ?? '') as Record; + const raw = list.stdout ?? ''; + const jsonStart = raw.indexOf('{'); + const parsed = JSON.parse(jsonStart >= 0 ? raw.slice(jsonStart) : raw) as Record; const deps = (parsed.dependencies ?? {}) as Record; if (deps['@switchbot/codex-plugin']) return { ok: true }; } catch { /* fall through to install */ } diff --git a/tests/install/codex-checks.test.ts b/tests/install/codex-checks.test.ts index 8950fba..eca91d2 100644 --- a/tests/install/codex-checks.test.ts +++ b/tests/install/codex-checks.test.ts @@ -515,6 +515,27 @@ describe('registerCodexPluginAuto', () => { expect(r.error).toMatch(/git clone failed/); expect(r.error).toMatch(/npm root error 2/); }); + + it('skips npm install when npm list output has non-JSON warning prefix (Windows behavior)', () => { + const npmListWithWarnings = + 'npm warn config optional\nnpm warn config another\n' + + '{"dependencies":{"@switchbot/codex-plugin":{"version":"1.0.0"}}}'; + existsSyncMock.mockReturnValue(true); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A fails + .mockReturnValueOnce(makeSpawnResult(0, npmListWithWarnings, '')) // npm list → found + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g retry + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + const r = registerCodexPluginAuto(); + // npm install -g must NOT have been called + const calls = spawnSyncMock.mock.calls as [string, string[]][]; + const installCall = calls.find(([cmd, args]) => cmd === 'npm' && args.includes('install')); + expect(installCall).toBeUndefined(); + expect(r.ok).toBe(true); + }); }); describe('stepRegisterCodexPlugin', () => { From 92ccc6ccba825b831b4e69d3b0b67233320d752b Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 13:06:09 +0800 Subject: [PATCH 22/31] fix(codex-plugin): log warning when codex plugin remove exits non-zero --- packages/codex-plugin/bin/install.js | 5 +++- packages/codex-plugin/tests/install.test.js | 33 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/codex-plugin/bin/install.js b/packages/codex-plugin/bin/install.js index a6a4e32..edb2fb5 100644 --- a/packages/codex-plugin/bin/install.js +++ b/packages/codex-plugin/bin/install.js @@ -142,7 +142,10 @@ export function makeInstall({ checkCli, runInherit, packageRoot, runAuth, resolv const pluginName = resolvePluginIdentifier(packageRoot); process.stderr.write(`[switchbot-codex] Removing stale plugin ${pluginName} if present...\n`); - await runInherit('codex', ['plugin', 'remove', pluginName]); + const removeCode = await runInherit('codex', ['plugin', 'remove', pluginName]); + if (removeCode !== 0) { + process.stderr.write(`[switchbot-codex] Warning: plugin remove exited ${removeCode}; continuing.\n`); + } process.stderr.write(`[switchbot-codex] Adding plugin ${pluginName}...\n`); const pluginCode = await runInherit('codex', ['plugin', 'add', pluginName]); if (pluginCode !== 0) { diff --git a/packages/codex-plugin/tests/install.test.js b/packages/codex-plugin/tests/install.test.js index 1b388c2..a49aacd 100644 --- a/packages/codex-plugin/tests/install.test.js +++ b/packages/codex-plugin/tests/install.test.js @@ -199,6 +199,39 @@ describe('makeInstall', () => { assert.ok(combined.includes('[switchbot-codex]'), `expected [switchbot-codex] prefix in: ${combined}`); assert.ok(combined.includes('codex-plugin-marketplace'), `expected alias path in: ${combined}`); }); + + it('logs a warning and continues when plugin remove exits non-zero', async () => { + let callCount = 0; + const spawn = (cmd, args) => { + callCount++; + // call 1: marketplace add → 0 + // call 2: plugin remove → 1 (failure, best-effort) + // call 3: plugin add → 0 + // call 4: doctor → 0 + return Promise.resolve(callCount === 2 ? 1 : 0); + }; + const auth = makeRunAuth(0); + const errChunks = []; + const origWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (chunk, ...rest) => { errChunks.push(String(chunk)); return true; }; + + const install = makeInstall({ + checkCli: makeOkCliCheck(), + runInherit: spawn, + packageRoot: TEST_ROOT, + runAuth: auth.runAuth, + }); + const code = await install(); + process.stderr.write = origWrite; + + assert.equal(code, 0, 'install should still succeed'); + assert.equal(callCount, 4, 'all four spawn calls should be made'); + const combined = errChunks.join(''); + assert.ok( + combined.includes('Warning') && combined.includes('remove') && combined.includes('exited'), + `expected warning about remove exit code in: ${combined}`, + ); + }); }); describe('resolvePluginIdentifier', () => { From 371c0783b1c9969ac12ae199f9fef40d7806711e Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 13:10:22 +0800 Subject: [PATCH 23/31] fix(codex): use line-based JSON detection and fix timeout warning message wording --- src/install/codex-checks.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index 03189a3..4033184 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -289,7 +289,7 @@ export function runCodexPluginRegistrationGit(pluginId: string): RegistrationRes const _timeoutValid = Number.isFinite(_parsedTimeout) && _parsedTimeout > 0; if (_envTimeout !== undefined && _envTimeout !== '' && !_timeoutValid) { process.stderr.write( - `[switchbot] CODEX_MARKETPLACE_ADD_TIMEOUT="${_envTimeout}" is not a valid positive integer; using default 60000 ms\n`, + `[switchbot] CODEX_MARKETPLACE_ADD_TIMEOUT="${_envTimeout}" is not a valid positive number; using default 60000 ms\n`, ); } const timeout = _timeoutValid ? _parsedTimeout : 60000; @@ -334,8 +334,8 @@ function installCodexPluginGlobally(): { ok: boolean; error?: string } { if ((list.status ?? 1) === 0) { try { const raw = list.stdout ?? ''; - const jsonStart = raw.indexOf('{'); - const parsed = JSON.parse(jsonStart >= 0 ? raw.slice(jsonStart) : raw) as Record; + const jsonLine = raw.split('\n').find((l) => l.trimStart().startsWith('{')) ?? raw; + const parsed = JSON.parse(jsonLine) as Record; const deps = (parsed.dependencies ?? {}) as Record; if (deps['@switchbot/codex-plugin']) return { ok: true }; } catch { /* fall through to install */ } From 2059aabc3e3e4487eda07f255f2a5ce7b8af21b3 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 13:11:41 +0800 Subject: [PATCH 24/31] fix(codex): reconstruct full JSON from first JSON line in installCodexPluginGlobally --- src/install/codex-checks.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index 4033184..5809a76 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -334,8 +334,10 @@ function installCodexPluginGlobally(): { ok: boolean; error?: string } { if ((list.status ?? 1) === 0) { try { const raw = list.stdout ?? ''; - const jsonLine = raw.split('\n').find((l) => l.trimStart().startsWith('{')) ?? raw; - const parsed = JSON.parse(jsonLine) as Record; + const lines = raw.split('\n'); + const jsonStartIdx = lines.findIndex((l) => l.trimStart().startsWith('{')); + const jsonStr = jsonStartIdx >= 0 ? lines.slice(jsonStartIdx).join('\n') : raw; + const parsed = JSON.parse(jsonStr) as Record; const deps = (parsed.dependencies ?? {}) as Record; if (deps['@switchbot/codex-plugin']) return { ok: true }; } catch { /* fall through to install */ } From 27c896882e98c151015f2b62d332d5e163b1a108 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 13:16:05 +0800 Subject: [PATCH 25/31] chore: bump version to 3.7.3 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 26b5d5f..ba57817 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@switchbot/openapi-cli", - "version": "3.7.2", + "version": "3.7.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@switchbot/openapi-cli", - "version": "3.7.2", + "version": "3.7.3", "license": "MIT", "workspaces": [ "packages/*" diff --git a/package.json b/package.json index 211cf55..062285d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "3.7.2", + "version": "3.7.3", "description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.", "keywords": [ "switchbot", From 19d82002caed57add8ef5c7e461f4ac04c9e3b99 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 15:23:33 +0800 Subject: [PATCH 26/31] fix(codex): remove install-codex-plugin from smoke step list; clean legacy IDs in Route A Smoke test still asserted the removed install-codex-plugin step, causing CI to fail. Also extend runCodexPluginRegistration to clean CODEX_PLUGIN_LEGACY_IDS before re-adding, matching Route B behaviour so switchbot@switchbot-skill is removed even when the git-marketplace path is unavailable. --- scripts/smoke-codex-pack-install.mjs | 3 +-- src/install/codex-checks.ts | 9 +++++---- tests/install/codex-checks.test.ts | 18 ++++++++++++------ 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/scripts/smoke-codex-pack-install.mjs b/scripts/smoke-codex-pack-install.mjs index 924d07d..92a5184 100644 --- a/scripts/smoke-codex-pack-install.mjs +++ b/scripts/smoke-codex-pack-install.mjs @@ -115,7 +115,6 @@ try { for (const expected of [ 'check-codex-cli', 'install-switchbot-cli', - 'install-codex-plugin', 'register-plugin', 'auth', 'doctor-verify', @@ -141,7 +140,7 @@ try { throw new Error(`codex plugin onInstall hook must exit 0; got ${hook.status ?? 1}\nstderr:\n${hook.stderr}`); } - console.log('codex pack-install smoke ok: tarballs install, setup dry-run includes plugin install, hook is non-blocking'); + console.log('codex pack-install smoke ok: tarballs install, setup dry-run has 5 steps, hook is non-blocking'); } finally { for (const tarball of packed) { rmSync(tarball, { force: true }); diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index 5809a76..271631d 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -232,9 +232,10 @@ export function runCodexPluginRegistration(packageRoot: string, pluginId: string if (mkt.status !== 0) { return { ok: false, exitCode: mkt.status, stderr: mkt.stderr, stage: 'marketplace-add' }; } - // Remove any stale registration first so codex does a fresh install rather than - // an update-with-backup. The backup step hits ACCESS_DENIED on Windows junction paths. - spawnStr('codex', ['plugin', 'remove', pluginId]); + // Remove current and legacy IDs; ignore exit codes (best-effort pre-clean). + for (const id of [pluginId, ...CODEX_PLUGIN_LEGACY_IDS]) { + spawnStr('codex', ['plugin', 'remove', id]); + } const add = spawnStr('codex', ['plugin', 'add', pluginId]); return { ok: add.status === 0, exitCode: add.status, stderr: add.stderr, stage: 'plugin-add' }; } @@ -279,7 +280,7 @@ export const CODEX_GIT_MARKETPLACE_REPO = 'OpenWonderLabs/switchbot-openapi-cl export const CODEX_GIT_MARKETPLACE_SPARSE = 'packages/codex-plugin'; export const CODEX_GIT_MARKETPLACE_REF = 'main'; export const CODEX_PLUGIN_DEFAULT_ID = 'switchbot@codex-plugin'; -// Known IDs from pre-release installs; cleaned up during Route B pre-clean step. +// Known IDs from pre-release installs; cleaned up by both Route A and Route B. const CODEX_PLUGIN_LEGACY_IDS = ['switchbot@switchbot-skill']; export function runCodexPluginRegistrationGit(pluginId: string): RegistrationResult { diff --git a/tests/install/codex-checks.test.ts b/tests/install/codex-checks.test.ts index eca91d2..887d645 100644 --- a/tests/install/codex-checks.test.ts +++ b/tests/install/codex-checks.test.ts @@ -154,7 +154,8 @@ describe('runCodexPluginRegistration', () => { it('returns ok when both marketplace add and plugin add succeed', () => { spawnSyncMock .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (pre-clean) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const result = runCodexPluginRegistration('/some/path', 'switchbot@pkg'); expect(result.ok).toBe(true); @@ -172,7 +173,8 @@ describe('runCodexPluginRegistration', () => { it('returns failure when plugin add exits non-zero', () => { spawnSyncMock .mockReturnValueOnce(makeSpawnResult(0, '')) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (pre-clean) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) .mockReturnValueOnce(makeSpawnResult(1, '', 'plugin add error')); const result = runCodexPluginRegistration('/some/path', 'switchbot@pkg'); expect(result.ok).toBe(false); @@ -187,7 +189,8 @@ describe('registerCodexPlugin (shared helper)', () => { spawnSyncMock .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (pre-clean) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = registerCodexPlugin(); expect(r.ok).toBe(true); @@ -467,7 +470,8 @@ describe('registerCodexPluginAuto', () => { .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // marketplace add (git) — fails .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n', '')) // npm root -g .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = registerCodexPluginAuto(); expect(r.ok).toBe(true); @@ -483,7 +487,8 @@ describe('registerCodexPluginAuto', () => { .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n', '')) // npm root -g (retry) .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = registerCodexPluginAuto(); expect(r.ok).toBe(true); @@ -527,7 +532,8 @@ describe('registerCodexPluginAuto', () => { .mockReturnValueOnce(makeSpawnResult(0, npmListWithWarnings, '')) // npm list → found .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g retry .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = registerCodexPluginAuto(); // npm install -g must NOT have been called From 9851fc97ba0216e09826a51ff87084a424d05f64 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 15:48:27 +0800 Subject: [PATCH 27/31] =?UTF-8?q?fix(codex):=20address=20code-review=20fin?= =?UTF-8?q?dings=20=E2=80=94=20legacy=20IDs,=20npm=20list=20exit=20code,?= =?UTF-8?q?=20prefix=20verify,=20timeout=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - install.js: remove switchbot@switchbot-skill legacy ID before re-adding, mirroring the CODEX_PLUGIN_LEGACY_IDS loop already in codex-checks.ts - installCodexPluginGlobally: parse npm list JSON regardless of exit code so peer-dep warnings (npm exits 1) no longer trigger an unnecessary reinstall - installCodexPluginGlobally: verify package presence after npm install -g to catch npm prefix mismatches early with a clear diagnostic message - runCodexPluginRegistrationGit: warn when CODEX_MARKETPLACE_ADD_TIMEOUT is set to empty string (was silently ignored) - tests: wrap process.stderr.write monkey-patches in try/finally to prevent stderr corruption when assertions fail --- packages/codex-plugin/bin/install.js | 12 ++-- packages/codex-plugin/tests/install.test.js | 78 ++++++++++++--------- src/install/codex-checks.ts | 45 ++++++++---- tests/install/codex-checks.test.ts | 46 ++++++++++++ 4 files changed, 130 insertions(+), 51 deletions(-) diff --git a/packages/codex-plugin/bin/install.js b/packages/codex-plugin/bin/install.js index edb2fb5..63145c7 100644 --- a/packages/codex-plugin/bin/install.js +++ b/packages/codex-plugin/bin/install.js @@ -103,6 +103,8 @@ function formatCodexFailure(step) { ].join('\n'); } +const CODEX_PLUGIN_LEGACY_IDS = ['switchbot@switchbot-skill']; + export function makeInstall({ checkCli, runInherit, packageRoot, runAuth, resolveRoot = resolveMarketplaceSourceRoot }) { return async function install() { process.stderr.write( @@ -141,10 +143,12 @@ export function makeInstall({ checkCli, runInherit, packageRoot, runAuth, resolv } const pluginName = resolvePluginIdentifier(packageRoot); - process.stderr.write(`[switchbot-codex] Removing stale plugin ${pluginName} if present...\n`); - const removeCode = await runInherit('codex', ['plugin', 'remove', pluginName]); - if (removeCode !== 0) { - process.stderr.write(`[switchbot-codex] Warning: plugin remove exited ${removeCode}; continuing.\n`); + for (const id of [pluginName, ...CODEX_PLUGIN_LEGACY_IDS]) { + process.stderr.write(`[switchbot-codex] Removing stale plugin ${id} if present...\n`); + const removeCode = await runInherit('codex', ['plugin', 'remove', id]); + if (removeCode !== 0) { + process.stderr.write(`[switchbot-codex] Warning: plugin remove exited ${removeCode}; continuing.\n`); + } } process.stderr.write(`[switchbot-codex] Adding plugin ${pluginName}...\n`); const pluginCode = await runInherit('codex', ['plugin', 'add', pluginName]); diff --git a/packages/codex-plugin/tests/install.test.js b/packages/codex-plugin/tests/install.test.js index a49aacd..2c91c59 100644 --- a/packages/codex-plugin/tests/install.test.js +++ b/packages/codex-plugin/tests/install.test.js @@ -39,11 +39,12 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 0); - assert.equal(calls.length, 4); + assert.equal(calls.length, 5); assert.deepEqual(calls[0], { cmd: 'codex', args: ['plugin', 'marketplace', 'add', TEST_ROOT] }); assert.deepEqual(calls[1], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@codex-plugin'] }); - assert.deepEqual(calls[2], { cmd: 'codex', args: ['plugin', 'add', 'switchbot@codex-plugin'] }); - assert.deepEqual(calls[3], { cmd: 'switchbot', args: ['doctor'] }); + assert.deepEqual(calls[2], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@switchbot-skill'] }); + assert.deepEqual(calls[3], { cmd: 'codex', args: ['plugin', 'add', 'switchbot@codex-plugin'] }); + assert.deepEqual(calls[4], { cmd: 'switchbot', args: ['doctor'] }); assert.equal(auth.calls.length, 1); }); @@ -58,12 +59,13 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 0); - assert.equal(calls.length, 5); + assert.equal(calls.length, 6); assert.deepEqual(calls[0], { cmd: 'npm', args: ['install', '-g', '@switchbot/openapi-cli@latest'] }); assert.deepEqual(calls[1], { cmd: 'codex', args: ['plugin', 'marketplace', 'add', TEST_ROOT] }); assert.deepEqual(calls[2], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@codex-plugin'] }); - assert.deepEqual(calls[3], { cmd: 'codex', args: ['plugin', 'add', 'switchbot@codex-plugin'] }); - assert.deepEqual(calls[4], { cmd: 'switchbot', args: ['doctor'] }); + assert.deepEqual(calls[3], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@switchbot-skill'] }); + assert.deepEqual(calls[4], { cmd: 'codex', args: ['plugin', 'add', 'switchbot@codex-plugin'] }); + assert.deepEqual(calls[5], { cmd: 'switchbot', args: ['doctor'] }); assert.equal(auth.calls.length, 1); }); @@ -107,7 +109,8 @@ describe('makeInstall', () => { const auth = makeRunAuth(0); const spawn = (cmd, args) => { callCount++; - return Promise.resolve(callCount === 3 ? 3 : 0); + // calls: 1=marketplace add, 2=remove current, 3=remove legacy, 4=plugin add + return Promise.resolve(callCount === 4 ? 3 : 0); }; const install = makeInstall({ checkCli: makeOkCliCheck(), @@ -117,7 +120,7 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 3); - assert.equal(callCount, 3); + assert.equal(callCount, 4); assert.equal(auth.calls.length, 0); }); @@ -132,7 +135,7 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 4); - assert.equal(calls.length, 3); + assert.equal(calls.length, 4); assert.equal(auth.calls.length, 1); }); @@ -151,8 +154,8 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 5); - assert.equal(calls.length, 4); - assert.deepEqual(calls[3], { cmd: 'switchbot', args: ['doctor'] }); + assert.equal(calls.length, 5); + assert.deepEqual(calls[4], { cmd: 'switchbot', args: ['doctor'] }); assert.equal(auth.calls.length, 1); }); @@ -182,17 +185,21 @@ describe('makeInstall', () => { const origWrite = process.stderr.write.bind(process.stderr); process.stderr.write = (chunk, ...rest) => { errChunks.push(String(chunk)); return true; }; - const install = makeInstall({ - checkCli: makeOkCliCheck(), - runInherit: spawn, - packageRoot: TEST_ROOT, - runAuth: auth.runAuth, - resolveRoot: () => { - throw new Error('alias path /home/user/.switchbot/codex-plugin-marketplace exists and is not a symlink/junction; remove it manually and retry'); - }, - }); - const code = await install(); - process.stderr.write = origWrite; + let code; + try { + const install = makeInstall({ + checkCli: makeOkCliCheck(), + runInherit: spawn, + packageRoot: TEST_ROOT, + runAuth: auth.runAuth, + resolveRoot: () => { + throw new Error('alias path /home/user/.switchbot/codex-plugin-marketplace exists and is not a symlink/junction; remove it manually and retry'); + }, + }); + code = await install(); + } finally { + process.stderr.write = origWrite; + } assert.equal(code, 1); const combined = errChunks.join(''); @@ -204,10 +211,7 @@ describe('makeInstall', () => { let callCount = 0; const spawn = (cmd, args) => { callCount++; - // call 1: marketplace add → 0 - // call 2: plugin remove → 1 (failure, best-effort) - // call 3: plugin add → 0 - // call 4: doctor → 0 + // calls: 1=marketplace add, 2=remove current → failure, 3=remove legacy, 4=plugin add, 5=doctor return Promise.resolve(callCount === 2 ? 1 : 0); }; const auth = makeRunAuth(0); @@ -215,17 +219,21 @@ describe('makeInstall', () => { const origWrite = process.stderr.write.bind(process.stderr); process.stderr.write = (chunk, ...rest) => { errChunks.push(String(chunk)); return true; }; - const install = makeInstall({ - checkCli: makeOkCliCheck(), - runInherit: spawn, - packageRoot: TEST_ROOT, - runAuth: auth.runAuth, - }); - const code = await install(); - process.stderr.write = origWrite; + let code; + try { + const install = makeInstall({ + checkCli: makeOkCliCheck(), + runInherit: spawn, + packageRoot: TEST_ROOT, + runAuth: auth.runAuth, + }); + code = await install(); + } finally { + process.stderr.write = origWrite; + } assert.equal(code, 0, 'install should still succeed'); - assert.equal(callCount, 4, 'all four spawn calls should be made'); + assert.equal(callCount, 5, 'all five spawn calls should be made'); const combined = errChunks.join(''); assert.ok( combined.includes('Warning') && combined.includes('remove') && combined.includes('exited'), diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index 271631d..2152bdd 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -288,7 +288,7 @@ export function runCodexPluginRegistrationGit(pluginId: string): RegistrationRes const _envTimeout = process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; const _parsedTimeout = Number(_envTimeout ?? ''); const _timeoutValid = Number.isFinite(_parsedTimeout) && _parsedTimeout > 0; - if (_envTimeout !== undefined && _envTimeout !== '' && !_timeoutValid) { + if (_envTimeout !== undefined && !_timeoutValid) { process.stderr.write( `[switchbot] CODEX_MARKETPLACE_ADD_TIMEOUT="${_envTimeout}" is not a valid positive number; using default 60000 ms\n`, ); @@ -332,17 +332,18 @@ function installCodexPluginGlobally(): { ok: boolean; error?: string } { 'npm', ['list', '-g', '--json', '--depth=0', '@switchbot/codex-plugin'], { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 10000 }, ); - if ((list.status ?? 1) === 0) { - try { - const raw = list.stdout ?? ''; - const lines = raw.split('\n'); - const jsonStartIdx = lines.findIndex((l) => l.trimStart().startsWith('{')); - const jsonStr = jsonStartIdx >= 0 ? lines.slice(jsonStartIdx).join('\n') : raw; - const parsed = JSON.parse(jsonStr) as Record; - const deps = (parsed.dependencies ?? {}) as Record; - if (deps['@switchbot/codex-plugin']) return { ok: true }; - } catch { /* fall through to install */ } - } + // Parse JSON regardless of exit code: npm exits 1 on peer-dep warnings even + // when the package is present. Skip the install if the package shows up in the + // dependency tree either way. + try { + const raw = list.stdout ?? ''; + const lines = raw.split('\n'); + const jsonStartIdx = lines.findIndex((l) => l.trimStart().startsWith('{')); + const jsonStr = jsonStartIdx >= 0 ? lines.slice(jsonStartIdx).join('\n') : raw; + const parsed = JSON.parse(jsonStr) as Record; + const deps = (parsed.dependencies ?? {}) as Record; + if (deps['@switchbot/codex-plugin']) return { ok: true }; + } catch { /* fall through to install */ } const install = spawnSync( 'npm', ['install', '-g', '@switchbot/codex-plugin@latest'], { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 120000 }, @@ -350,6 +351,26 @@ function installCodexPluginGlobally(): { ok: boolean; error?: string } { if ((install.status ?? 1) !== 0) { return { ok: false, error: `npm install -g failed (exit ${install.status ?? 1}): ${install.stderr ?? ''}` }; } + // Verify the package now appears in npm list; a mismatch means npm installed + // to a different prefix than the active one (e.g. nvm switching, sudo vs user). + const verify = spawnSync( + 'npm', ['list', '-g', '--json', '--depth=0', '@switchbot/codex-plugin'], + { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 10000 }, + ); + try { + const vRaw = verify.stdout ?? ''; + const vLines = vRaw.split('\n'); + const vJsonIdx = vLines.findIndex((l) => l.trimStart().startsWith('{')); + const vJsonStr = vJsonIdx >= 0 ? vLines.slice(vJsonIdx).join('\n') : vRaw; + const vParsed = JSON.parse(vJsonStr) as Record; + const vDeps = (vParsed.dependencies ?? {}) as Record; + if (!vDeps['@switchbot/codex-plugin']) { + return { + ok: false, + error: 'npm install -g succeeded but @switchbot/codex-plugin not found in npm list (npm prefix mismatch? Run: npm root -g to verify prefix)', + }; + } + } catch { /* verification inconclusive — proceed and let registration catch the error */ } return { ok: true }; } diff --git a/tests/install/codex-checks.test.ts b/tests/install/codex-checks.test.ts index 887d645..f95e475 100644 --- a/tests/install/codex-checks.test.ts +++ b/tests/install/codex-checks.test.ts @@ -450,6 +450,18 @@ describe('runCodexPluginRegistrationGit', () => { spy.mockRestore(); if (origEnv !== undefined) process.env['CODEX_MARKETPLACE_ADD_TIMEOUT'] = origEnv; }); + + it('warns to stderr when CODEX_MARKETPLACE_ADD_TIMEOUT is empty string', () => { + const origEnv = process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; + process.env['CODEX_MARKETPLACE_ADD_TIMEOUT'] = ''; + const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '', 'fail')); + runCodexPluginRegistrationGit('switchbot@codex-plugin'); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('CODEX_MARKETPLACE_ADD_TIMEOUT')); + spy.mockRestore(); + if (origEnv === undefined) delete process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; + else process.env['CODEX_MARKETPLACE_ADD_TIMEOUT'] = origEnv; + }); }); describe('registerCodexPluginAuto', () => { @@ -480,11 +492,13 @@ describe('registerCodexPluginAuto', () => { it('installs on demand and retries Route A when Route B and initial Route A both fail', () => { existsSyncMock.mockReturnValue(true); + const installedJson = JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '1.0.0' } } }); spawnSyncMock .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // marketplace add (git) — fails .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // npm root -g fails (Route A) .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list -g: not installed .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds + .mockReturnValueOnce(makeSpawnResult(0, installedJson, '')) // post-install npm list: package found .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n', '')) // npm root -g (retry) .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) @@ -509,11 +523,13 @@ describe('registerCodexPluginAuto', () => { }); it('returns failure when on-demand install succeeds but Route A retry still fails', () => { + const installedJson = JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '1.0.0' } } }); spawnSyncMock .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // marketplace add (git) — fails .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // npm root -g fails (Route A) .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list -g: not installed .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds + .mockReturnValueOnce(makeSpawnResult(0, installedJson, '')) // post-install npm list: package found .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error 2')); // npm root -g fails (retry) const r = registerCodexPluginAuto(); expect(r.ok).toBe(false); @@ -542,6 +558,36 @@ describe('registerCodexPluginAuto', () => { expect(installCall).toBeUndefined(); expect(r.ok).toBe(true); }); + it('skips npm install when npm list exits non-zero but JSON output shows package is installed', () => { + const installedJson = JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '1.0.0' } } }); + existsSyncMock.mockReturnValue(true); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A fails + .mockReturnValueOnce(makeSpawnResult(1, installedJson, 'peer-dep warning')) // npm list exits 1 but JSON shows package + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g retry + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + const r = registerCodexPluginAuto(); + const calls = spawnSyncMock.mock.calls as [string, string[]][]; + const installCall = calls.find(([cmd, args]) => cmd === 'npm' && args.includes('install')); + expect(installCall).toBeUndefined(); + expect(r.ok).toBe(true); + }); + + it('returns npm-prefix-mismatch error when post-install npm list still shows package absent', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A fails + .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list: not installed + .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds + .mockReturnValueOnce(makeSpawnResult(1, '{}', 'peer-dep-warning')); // post-install npm list: still absent + const r = registerCodexPluginAuto(); + expect(r.ok).toBe(false); + expect(r.error).toMatch(/npm prefix mismatch/i); + }); }); describe('stepRegisterCodexPlugin', () => { From 10515c2afc83136def2c02049e2602a96d40019b Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 15:59:43 +0800 Subject: [PATCH 28/31] fix(codex-checks): return error when post-install verify spawnSync times out When spawnSync status is null (killed/timed out), stdout is also null. The previous code fell through the catch block and returned {ok:true}, masking a failed or unverifiable install. Now guard on status===null before the try/catch and return a descriptive error. --- src/install/codex-checks.ts | 6 ++++++ tests/install/codex-checks.test.ts | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index 2152bdd..bef4f3e 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -357,6 +357,12 @@ function installCodexPluginGlobally(): { ok: boolean; error?: string } { 'npm', ['list', '-g', '--json', '--depth=0', '@switchbot/codex-plugin'], { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 10000 }, ); + if (verify.status === null) { + return { + ok: false, + error: 'post-install npm list timed out; cannot verify @switchbot/codex-plugin was installed correctly', + }; + } try { const vRaw = verify.stdout ?? ''; const vLines = vRaw.split('\n'); diff --git a/tests/install/codex-checks.test.ts b/tests/install/codex-checks.test.ts index f95e475..c58891f 100644 --- a/tests/install/codex-checks.test.ts +++ b/tests/install/codex-checks.test.ts @@ -588,6 +588,18 @@ describe('registerCodexPluginAuto', () => { expect(r.ok).toBe(false); expect(r.error).toMatch(/npm prefix mismatch/i); }); + + it('returns error when post-install verify spawnSync times out (status null)', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A: npm root -g fails + .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list: not installed + .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds + .mockReturnValueOnce({ status: null, stdout: null, stderr: '', signal: 'SIGTERM' }); // verify times out + const r = registerCodexPluginAuto(); + expect(r.ok).toBe(false); + expect(r.error).toMatch(/timed out/i); + }); }); describe('stepRegisterCodexPlugin', () => { From e26ddea4db56491c949b0e9b302f856f4b9683b4 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 16:26:23 +0800 Subject: [PATCH 29/31] fix(codex): guard dangling symlinks, fix empty REF fallback, export legacy IDs, thread install flag - resolveMarketplaceSourceRoot: wrap realpathSync in try/catch so a dangling symlink (e.g. after nvm switch or npm uninstall) is silently recreated instead of crashing; mirrors the same fix in packages/codex-plugin/bin/install.js - runCodexPluginRegistrationGit: use || instead of ?? for CODEX_GIT_MARKETPLACE_REF env lookup so an empty string correctly falls back to the default "main" ref - installCodexPluginGlobally: return { installed: boolean } flag so the caller can distinguish "we just installed the package" from "it was already present"; registerCodexPluginAuto now emits "@switchbot/codex-plugin already present" instead of "installed @switchbot/codex-plugin" when no install was needed - CODEX_PLUGIN_LEGACY_IDS: exported so repairStepRemovePlugin in codex.ts can import and loop over [currentId, ...legacyIds] in a single pre-clean pass, matching the behavior already present in runCodexPluginRegistration and runCodexPluginRegistrationGit; non-zero exit codes are now logged as warnings rather than aborting the repair step - error message in resolveMarketplaceSourceRoot is now platform-split ("not a junction" on Windows, "not a symlink" on Linux/macOS) --- packages/codex-plugin/bin/install.js | 13 ++++++- src/commands/codex.ts | 15 +++++--- src/install/codex-checks.ts | 32 +++++++++++----- tests/commands/codex.test.ts | 31 ++++++++++++++++ tests/install/codex-checks.test.ts | 55 ++++++++++++++++++++++++++++ 5 files changed, 129 insertions(+), 17 deletions(-) diff --git a/packages/codex-plugin/bin/install.js b/packages/codex-plugin/bin/install.js index 63145c7..e9fdebe 100644 --- a/packages/codex-plugin/bin/install.js +++ b/packages/codex-plugin/bin/install.js @@ -79,8 +79,17 @@ export function resolveMarketplaceSourceRoot(packageRoot, deps = defaultFsDeps) } if (stat.isSymbolicLink()) { - const aliasReal = deps.realpathSync(aliasRoot); - const packageReal = deps.realpathSync(packageRoot); + let aliasReal; + let packageReal; + try { + aliasReal = deps.realpathSync(aliasRoot); + packageReal = deps.realpathSync(packageRoot); + } catch { + // Dangling symlink: target was deleted (e.g. nvm switch, npm uninstall). + deps.unlinkSync(aliasRoot); + deps.symlinkSync(packageRoot, aliasRoot, linkType); + return aliasRoot; + } const pathsMatch = process.platform === 'win32' ? aliasReal.toLowerCase() === packageReal.toLowerCase() : aliasReal === packageReal; diff --git a/src/commands/codex.ts b/src/commands/codex.ts index 347ae14..c4f24b0 100644 --- a/src/commands/codex.ts +++ b/src/commands/codex.ts @@ -9,6 +9,7 @@ import { registerCodexPluginAuto, resolvePluginId, resolveCodexPackageRoot, + CODEX_PLUGIN_LEGACY_IDS, type Check, } from '../install/codex-checks.js'; import { isJsonMode, printJson } from '../utils/output.js'; @@ -144,12 +145,14 @@ function repairStepRemovePlugin(ctx: RepairContext): RepairOutcome { pluginId = root.ok ? resolvePluginId(root.packageRoot) : 'switchbot@codex-plugin'; ctx.codexPluginId = pluginId; } - const r = spawnSync( - 'codex', ['plugin', 'remove', pluginId], - { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 15000 }, - ); - if ((r.status ?? 1) !== 0) { - return { step: 'remove-plugin', status: 'failed', message: `exit ${r.status ?? 1} (non-fatal)` }; + for (const id of [pluginId, ...CODEX_PLUGIN_LEGACY_IDS]) { + const r = spawnSync( + 'codex', ['plugin', 'remove', id], + { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 15000 }, + ); + if ((r.status ?? 1) !== 0) { + process.stderr.write(`[switchbot] Warning: codex plugin remove "${id}" exited ${r.status ?? 1} (non-fatal)\n`); + } } return { step: 'remove-plugin', status: 'ok' }; } diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index bef4f3e..6e3ad76 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -102,8 +102,18 @@ export function resolveMarketplaceSourceRoot(packageRoot: string): string { } if (stat.isSymbolicLink()) { - const aliasReal = fs.realpathSync(aliasRoot); - const packageReal = fs.realpathSync(packageRoot); + let aliasReal: string; + let packageReal: string; + try { + aliasReal = fs.realpathSync(aliasRoot); + packageReal = fs.realpathSync(packageRoot); + } catch { + // Dangling symlink: target was deleted (e.g. nvm switch, npm uninstall). + // Recreate it pointing at the current packageRoot. + fs.unlinkSync(aliasRoot); + fs.symlinkSync(packageRoot, aliasRoot, linkType); + return aliasRoot; + } const pathsMatch = process.platform === 'win32' ? aliasReal.toLowerCase() === packageReal.toLowerCase() : aliasReal === packageReal; @@ -113,7 +123,8 @@ export function resolveMarketplaceSourceRoot(packageRoot: string): string { return aliasRoot; } - throw new Error(`alias path ${aliasRoot} exists and is not a symlink/junction; remove it manually and retry`); + const expected = process.platform === 'win32' ? 'junction' : 'symlink'; + throw new Error(`alias path ${aliasRoot} exists and is not a ${expected}; remove it manually and retry`); } /** Single authoritative plugin ID resolver. Mirrors install.js:resolvePluginIdentifier. */ @@ -281,10 +292,10 @@ export const CODEX_GIT_MARKETPLACE_SPARSE = 'packages/codex-plugin'; export const CODEX_GIT_MARKETPLACE_REF = 'main'; export const CODEX_PLUGIN_DEFAULT_ID = 'switchbot@codex-plugin'; // Known IDs from pre-release installs; cleaned up by both Route A and Route B. -const CODEX_PLUGIN_LEGACY_IDS = ['switchbot@switchbot-skill']; +export const CODEX_PLUGIN_LEGACY_IDS = ['switchbot@switchbot-skill']; export function runCodexPluginRegistrationGit(pluginId: string): RegistrationResult { - const ref = process.env['CODEX_GIT_MARKETPLACE_REF'] ?? CODEX_GIT_MARKETPLACE_REF; + const ref = process.env['CODEX_GIT_MARKETPLACE_REF'] || CODEX_GIT_MARKETPLACE_REF; const _envTimeout = process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; const _parsedTimeout = Number(_envTimeout ?? ''); const _timeoutValid = Number.isFinite(_parsedTimeout) && _parsedTimeout > 0; @@ -327,7 +338,7 @@ export function registerCodexPluginGit(): RegisterCodexPluginResult { // Install @switchbot/codex-plugin globally if not already present. // Used by registerCodexPluginAuto as a last resort before retrying Route A. -function installCodexPluginGlobally(): { ok: boolean; error?: string } { +function installCodexPluginGlobally(): { ok: boolean; installed?: boolean; error?: string } { const list = spawnSync( 'npm', ['list', '-g', '--json', '--depth=0', '@switchbot/codex-plugin'], { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 10000 }, @@ -342,7 +353,7 @@ function installCodexPluginGlobally(): { ok: boolean; error?: string } { const jsonStr = jsonStartIdx >= 0 ? lines.slice(jsonStartIdx).join('\n') : raw; const parsed = JSON.parse(jsonStr) as Record; const deps = (parsed.dependencies ?? {}) as Record; - if (deps['@switchbot/codex-plugin']) return { ok: true }; + if (deps['@switchbot/codex-plugin']) return { ok: true, installed: false }; } catch { /* fall through to install */ } const install = spawnSync( 'npm', ['install', '-g', '@switchbot/codex-plugin@latest'], @@ -377,7 +388,7 @@ function installCodexPluginGlobally(): { ok: boolean; error?: string } { }; } } catch { /* verification inconclusive — proceed and let registration catch the error */ } - return { ok: true }; + return { ok: true, installed: true }; } /** @@ -408,11 +419,14 @@ export function registerCodexPluginAuto(): RegisterCodexPluginResult { // Retry Route A after successful install const retry = registerCodexPlugin(); + const installPhrase = install.installed + ? 'installed @switchbot/codex-plugin' + : '@switchbot/codex-plugin already present'; return retry.ok ? retry : { ...retry, pluginId: CODEX_PLUGIN_DEFAULT_ID, - error: `Route B failed (${git.error}); installed @switchbot/codex-plugin but Route A still failed: ${retry.error}. Run: switchbot codex repair`, + error: `Route B failed (${git.error}); ${installPhrase} but Route A still failed: ${retry.error}. Run: switchbot codex repair`, }; } diff --git a/tests/commands/codex.test.ts b/tests/commands/codex.test.ts index b75f838..5172471 100644 --- a/tests/commands/codex.test.ts +++ b/tests/commands/codex.test.ts @@ -328,6 +328,37 @@ describe('switchbot codex repair', () => { expect(errOut).toContain('install-codex-plugin'); expect(errOut).toMatch(/no.*effect|deprecated|no longer/i); }); + + it('remove-plugin step also removes legacy ID switchbot@switchbot-skill (Fix 4)', async () => { + // verify-cli passes + runDoctorChecksMock.mockResolvedValueOnce([ + { name: 'node', status: 'ok', detail: 'ok' }, + { name: 'path', status: 'ok', detail: 'ok' }, + ]); + // re-auth: credentials present → no spawn + tryLoadConfigMock.mockReturnValue({ token: 't', secret: 's' }); + // remove-plugin: resolveCodexPackageRoot npm root -g, then remove current id + legacy id + spawnSyncRepairMock + .mockReturnValueOnce({ status: 0, stdout: '/usr/local/lib/node_modules\n', stderr: '' }) // npm root -g + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // remove current id + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); // remove legacy id + // register-plugin: ok + registerCodexPluginMock.mockReturnValueOnce({ ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: null }); + // doctor-verify + 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 } = await runCli(registerCodexCommand, ['codex', 'repair', '--skip', 're-auth']); + expect(exitCode).toBe(0); + const removeCalls = spawnSyncRepairMock.mock.calls.filter( + (call) => (call[1] as string[]).includes('remove'), + ); + const removedIds = removeCalls.map((call) => (call[1] as string[])[2]); + expect(removedIds).toContain('switchbot@codex-plugin'); + expect(removedIds).toContain('switchbot@switchbot-skill'); + }); }); // ─── codex setup (C5) ──────────────────────────────────────────────────────── diff --git a/tests/install/codex-checks.test.ts b/tests/install/codex-checks.test.ts index c58891f..0486999 100644 --- a/tests/install/codex-checks.test.ts +++ b/tests/install/codex-checks.test.ts @@ -375,6 +375,30 @@ describe('resolveMarketplaceSourceRoot — Linux @-scoped path handling', () => expect(result).toMatch(/codex-plugin-marketplace$/); }); + it('recreates a dangling symlink when realpathSync throws ENOENT (Fix 1)', () => { + lstatSyncMock.mockReturnValue(makeStat(true)); + realpathSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }); + }); + const result = resolveMarketplaceSourceRoot(LINUX_SCOPED_ROOT); + expect(unlinkSyncMock).toHaveBeenCalledWith(expect.stringMatching(/codex-plugin-marketplace$/)); + expect(symlinkSyncMock).toHaveBeenCalledWith( + LINUX_SCOPED_ROOT, + expect.stringMatching(/codex-plugin-marketplace$/), + 'dir', + ); + expect(result).toMatch(/codex-plugin-marketplace$/); + }); + + it('throws "not a symlink" without "/junction" suffix on Linux (Fix 5)', () => { + lstatSyncMock.mockReturnValue(makeStat(false)); + let caught: Error | null = null; + try { resolveMarketplaceSourceRoot(LINUX_SCOPED_ROOT); } catch (e) { caught = e as Error; } + expect(caught).not.toBeNull(); + expect(caught!.message).toContain('not a symlink'); + expect(caught!.message).not.toContain('symlink/junction'); + }); + it('throws when alias path is a real directory (not a symlink)', () => { lstatSyncMock.mockReturnValue(makeStat(false)); expect(() => resolveMarketplaceSourceRoot(LINUX_SCOPED_ROOT)).toThrow(/not a.*symlink/i); @@ -462,6 +486,24 @@ describe('runCodexPluginRegistrationGit', () => { if (origEnv === undefined) delete process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; else process.env['CODEX_MARKETPLACE_ADD_TIMEOUT'] = origEnv; }); + + it('uses default ref "main" when CODEX_GIT_MARKETPLACE_REF is empty string (Fix 2)', () => { + const origEnv = process.env['CODEX_GIT_MARKETPLACE_REF']; + process.env['CODEX_GIT_MARKETPLACE_REF'] = ''; + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + runCodexPluginRegistrationGit('switchbot@codex-plugin'); + const calls = spawnSyncMock.mock.calls as [string, string[]][]; + const mktCall = calls.find(([cmd, args]) => cmd === 'codex' && args.includes('marketplace')); + const refIdx = mktCall?.[1].indexOf('--ref') ?? -1; + expect(refIdx).toBeGreaterThan(-1); + expect(mktCall?.[1][refIdx + 1]).toBe('main'); + if (origEnv === undefined) delete process.env['CODEX_GIT_MARKETPLACE_REF']; + else process.env['CODEX_GIT_MARKETPLACE_REF'] = origEnv; + }); }); describe('registerCodexPluginAuto', () => { @@ -589,6 +631,19 @@ describe('registerCodexPluginAuto', () => { expect(r.error).toMatch(/npm prefix mismatch/i); }); + it('error says "already present" (not "installed") when package existed before and Route A retry fails (Fix 3)', () => { + const installedJson = JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '1.0.0' } } }); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A: npm root -g fails + .mockReturnValueOnce(makeSpawnResult(0, installedJson, '')) // npm list: ALREADY installed (no install ran) + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error 2')); // npm root -g fails (retry) + const r = registerCodexPluginAuto(); + expect(r.ok).toBe(false); + expect(r.error).not.toMatch(/installed @switchbot/i); + expect(r.error).toMatch(/already present|was present/i); + }); + it('returns error when post-install verify spawnSync times out (status null)', () => { spawnSyncMock .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails From 6a100f6b1831820d54cf4807584ef23b35cc65a9 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 16:30:23 +0800 Subject: [PATCH 30/31] chore(codex-plugin): freeze resolveMarketplaceSourceRoot in install.js The switchbot-codex-install binary is deprecated in favour of 'switchbot codex setup'. Mark the local copy as frozen so it no longer needs to track changes in src/install/codex-checks.ts, eliminating the manual keep-in-sync contract that caused repeated review findings. --- packages/codex-plugin/bin/install.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/codex-plugin/bin/install.js b/packages/codex-plugin/bin/install.js index e9fdebe..a4f16e4 100644 --- a/packages/codex-plugin/bin/install.js +++ b/packages/codex-plugin/bin/install.js @@ -55,11 +55,9 @@ function computeAliasPath() { } export function resolveMarketplaceSourceRoot(packageRoot, deps = defaultFsDeps) { - // NOTE: This function is intentionally kept in sync with - // src/install/codex-checks.ts:resolveMarketplaceSourceRoot. Both copies exist - // because this package is self-contained (no TypeScript build step) and must - // work when loaded directly by the Codex plugin machinery before the CLI is - // installed. When updating the logic, mirror the change in both files. + // NOTE: This function is FROZEN. The canonical implementation lives in + // src/install/codex-checks.ts. Do NOT sync new changes here. + // The switchbot-codex-install binary is deprecated; use: switchbot codex setup const needsAlias = process.platform === 'win32' ? /^[A-Za-z]:[\\/].*[\\/]@[^\\/]+[\\/]/.test(packageRoot) : /\/@[^/]+\//.test(packageRoot); From 34783d26abd7b864e86e8f9b0798ee284528b488 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 24 May 2026 16:38:31 +0800 Subject: [PATCH 31/31] test(codex): fill coverage gaps identified by post-fix audit - checkCodexPluginNpm: verify packageRoot is null when npm root -g fails - runCodexPluginRegistrationGit: verify custom CODEX_GIT_MARKETPLACE_REF is forwarded as --ref (complements the empty-string guard added in Fix 2) - registerCodexPluginAuto: verify npm install is called when initial npm list stdout is invalid JSON (json parse falls through to install path); verify Route A retry proceeds when post-install verify output is unparseable (verification-inconclusive path) - repairStepRemovePlugin: verify fallback to default ID when npm root -g fails during resolveCodexPackageRoot - setupStepAuth: verify auth step returns failed when auth login spawn exits non-zero --- tests/commands/codex.test.ts | 61 +++++++++++++++++++++++++++ tests/install/codex-checks.test.ts | 67 ++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/tests/commands/codex.test.ts b/tests/commands/codex.test.ts index 5172471..612c4a1 100644 --- a/tests/commands/codex.test.ts +++ b/tests/commands/codex.test.ts @@ -359,6 +359,35 @@ describe('switchbot codex repair', () => { expect(removedIds).toContain('switchbot@codex-plugin'); expect(removedIds).toContain('switchbot@switchbot-skill'); }); + + it('remove-plugin falls back to default ID "switchbot@codex-plugin" when npm root -g fails', async () => { + // verify-cli passes + runDoctorChecksMock.mockResolvedValueOnce([ + { name: 'node', status: 'ok', detail: 'ok' }, + { name: 'path', status: 'ok', detail: 'ok' }, + ]); + // remove-plugin: npm root -g fails → fallback to 'switchbot@codex-plugin' + spawnSyncRepairMock + .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'npm error' }) // npm root -g fails + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // remove switchbot@codex-plugin + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); // remove legacy id + // register-plugin: ok + registerCodexPluginMock.mockReturnValueOnce({ ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: null }); + // doctor-verify + 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 } = await runCli(registerCodexCommand, ['codex', 'repair', '--skip', 're-auth']); + expect(exitCode).toBe(0); + const removeCalls = spawnSyncRepairMock.mock.calls.filter( + (call) => (call[1] as string[]).includes('remove'), + ); + const removedIds = removeCalls.map((call) => (call[1] as string[])[2]); + expect(removedIds).toContain('switchbot@codex-plugin'); + expect(removedIds).toContain('switchbot@switchbot-skill'); + }); }); // ─── codex setup (C5) ──────────────────────────────────────────────────────── @@ -631,4 +660,36 @@ describe('switchbot codex setup', () => { // registerCodexPluginAuto called once; internal npm calls tested in codex-checks.test.ts expect(registerCodexPluginMock).toHaveBeenCalledOnce(); }); + + it('auth step returns failed when auth login exits non-zero', async () => { + checkCodexCliMock.mockReturnValueOnce({ + name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, + }); + // install-switchbot-cli: already installed + spawnSyncRepairMock.mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: '1.0.0' } } }), + stderr: '', + }); + // register-plugin: ok + registerCodexPluginMock.mockReturnValueOnce({ ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: null }); + // credentials missing → spawn auth login + tryLoadConfigMock.mockReturnValue(null); + // auth login exits non-zero + spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'auth failed' }); + // doctor-verify still runs + 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(1); // anyFailed but not preflight + const parsed = JSON.parse(stdout.join('')) as { + data?: { outcomes: Array<{ step: string; status: string; message?: string }> }; + }; + const authStep = parsed.data!.outcomes.find((o) => o.step === 'auth'); + expect(authStep?.status).toBe('failed'); + expect(authStep?.message).toContain('auth login exited 1'); + }); }); diff --git a/tests/install/codex-checks.test.ts b/tests/install/codex-checks.test.ts index 0486999..ede9be5 100644 --- a/tests/install/codex-checks.test.ts +++ b/tests/install/codex-checks.test.ts @@ -103,6 +103,17 @@ describe('checkCodexPluginNpm', () => { const result = checkCodexPluginNpm(); expect(result.status).toBe('warn'); }); + + it('returns ok with packageRoot null when npm root -g exits non-zero', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, JSON.stringify({ + dependencies: { '@switchbot/codex-plugin': { version: '1.2.3' } }, + }))) + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm error')); // npm root -g fails + const result = checkCodexPluginNpm(); + expect(result.status).toBe('ok'); + expect((result.detail as Record).packageRoot).toBeNull(); + }); }); describe('checkCodexPluginRegistered', () => { @@ -504,6 +515,24 @@ describe('runCodexPluginRegistrationGit', () => { if (origEnv === undefined) delete process.env['CODEX_GIT_MARKETPLACE_REF']; else process.env['CODEX_GIT_MARKETPLACE_REF'] = origEnv; }); + + it('passes custom CODEX_GIT_MARKETPLACE_REF value as --ref arg', () => { + const origEnv = process.env['CODEX_GIT_MARKETPLACE_REF']; + process.env['CODEX_GIT_MARKETPLACE_REF'] = 'feat/my-branch'; + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + runCodexPluginRegistrationGit('switchbot@codex-plugin'); + const calls = spawnSyncMock.mock.calls as [string, string[]][]; + const mktCall = calls.find(([cmd, args]) => cmd === 'codex' && args.includes('marketplace')); + const refIdx = mktCall?.[1].indexOf('--ref') ?? -1; + expect(refIdx).toBeGreaterThan(-1); + expect(mktCall?.[1][refIdx + 1]).toBe('feat/my-branch'); + if (origEnv === undefined) delete process.env['CODEX_GIT_MARKETPLACE_REF']; + else process.env['CODEX_GIT_MARKETPLACE_REF'] = origEnv; + }); }); describe('registerCodexPluginAuto', () => { @@ -655,6 +684,44 @@ describe('registerCodexPluginAuto', () => { expect(r.ok).toBe(false); expect(r.error).toMatch(/timed out/i); }); + + it('falls through to npm install when initial npm list stdout is invalid JSON', () => { + const installedJson = JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '1.0.0' } } }); + existsSyncMock.mockReturnValue(true); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A: npm root -g fails + .mockReturnValueOnce(makeSpawnResult(0, 'npm warn something\nnot-json', '')) // npm list: invalid JSON → fall through + .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g succeeds + .mockReturnValueOnce(makeSpawnResult(0, installedJson, '')) // post-install npm list: ok + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n', '')) // npm root -g retry + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + const r = registerCodexPluginAuto(); + const calls = spawnSyncMock.mock.calls as [string, string[]][]; + const installCall = calls.find(([cmd, args]) => cmd === 'npm' && args.includes('install')); + expect(installCall).toBeDefined(); + expect(r.ok).toBe(true); + }); + + it('proceeds to Route A retry when post-install verify stdout is unparseable (inconclusive)', () => { + existsSyncMock.mockReturnValue(true); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A: npm root -g fails + .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list: not installed + .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g succeeds + .mockReturnValueOnce(makeSpawnResult(0, 'garbage\n', '')) // post-install verify: invalid JSON + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n', '')) // npm root -g retry + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + const r = registerCodexPluginAuto(); + expect(r.ok).toBe(true); + }); }); describe('stepRegisterCodexPlugin', () => {