From cdb132bd9082f8b3a8e2a93bb6707f8293d4d1d4 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Wed, 20 May 2026 22:00:14 +0800 Subject: [PATCH 01/72] feat: browser-based login via custom local login page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add switchbot auth login command that starts a local HTTP server, opens a custom SwitchBot-styled login page in the browser, and exchanges credentials via the SwitchBot consumer account API. Auth flow: 1. POST account.api.switchbot.net/account/api/v2/user/login → access_token 2. POST /account/api/v1/user/userinfo → botRegion (region-aware routing) 3. POST wonderlabs.{region}.api.switchbot.net/wonder/openapi/openUser/token { operation: "get", version: 2 } → openToken + secretKey New files: - web/login.html: dark-theme standalone login page (email/password + Google/Amazon/Apple social OAuth buttons) - src/auth/local-login-server.ts: local HTTP server serving login page, proxying /auth/email, and handling /callback OAuth redirect - src/auth/browser-login.ts: entry point, also retains --direct fallback - src/auth/constants.ts: API base URLs and endpoint paths - src/auth/oauth-callback.ts, pkce.ts, token-exchange.ts: OAuth helpers Also: - scripts/copy-assets.mjs: copy web/ into dist/web/ on build - src/commands/capabilities.ts: register auth login metadata - tests/commands/capabilities-meta.test.ts: add auth login to coverage list Co-Authored-By: Claude Sonnet 4.6 --- scripts/copy-assets.mjs | 1 + src/auth/browser-login.ts | 96 +++++ src/auth/constants.ts | 53 +++ src/auth/local-login-server.ts | 393 ++++++++++++++++++ src/auth/oauth-callback.ts | 131 ++++++ src/auth/pkce.ts | 6 + src/auth/token-exchange.ts | 132 +++++++ src/commands/auth.ts | 53 +++ src/commands/capabilities.ts | 1 + tests/commands/capabilities-meta.test.ts | 2 +- web/login.html | 482 +++++++++++++++++++++++ 11 files changed, 1349 insertions(+), 1 deletion(-) create mode 100644 src/auth/browser-login.ts create mode 100644 src/auth/constants.ts create mode 100644 src/auth/local-login-server.ts create mode 100644 src/auth/oauth-callback.ts create mode 100644 src/auth/pkce.ts create mode 100644 src/auth/token-exchange.ts create mode 100644 web/login.html diff --git a/scripts/copy-assets.mjs b/scripts/copy-assets.mjs index 83c4919c..811ae3d4 100644 --- a/scripts/copy-assets.mjs +++ b/scripts/copy-assets.mjs @@ -8,6 +8,7 @@ const repoRoot = dirname(scriptDir); const assets = [ ['src/policy/schema', 'dist/policy/schema'], ['src/policy/examples', 'dist/policy/examples'], + ['web', 'dist/web'], ]; for (const [srcRel, dstRel] of assets) { diff --git a/src/auth/browser-login.ts b/src/auth/browser-login.ts new file mode 100644 index 00000000..1410f070 --- /dev/null +++ b/src/auth/browser-login.ts @@ -0,0 +1,96 @@ +import open from 'open'; +import { generateState } from './pkce.js'; +import { bindCallbackServer } from './oauth-callback.js'; +import { bindLoginServer } from './local-login-server.js'; +import { exchangeCodeForCredentials } from './token-exchange.js'; +import { OAUTH_AUTHORIZE_URL, OAUTH_CLIENT_ID, OAUTH_SCOPE, LOGIN_TIMEOUT_MS } from './constants.js'; +import type { CredentialBundle } from '../credentials/keychain.js'; + +export interface BrowserLoginOptions { + /** When true, print the login URL instead of opening the browser. */ + noOpen?: boolean; + /** Override the default timeout in milliseconds. */ + timeoutMs?: number; + /** Status message sink (defaults to console.log). */ + log?: (msg: string) => void; + /** + * When true, skip the custom local login page and open SwitchBot's + * OAuth authorize URL directly (original flow, useful for debugging). + */ + directOAuth?: boolean; +} + +/** + * Browser-based login flow. + * + * Default path — custom local login page: + * 1. Start a local HTTP server that serves web/login.html. + * 2. Open browser to http://127.0.0.1:/ (custom SwitchBot-style UI). + * 3. User logs in (email/password or social OAuth via Cognito). + * 4. Server receives the OAuth callback and exchanges code for credentials. + * + * Fallback path (directOAuth: true) — original Cognito redirect: + * 1. Generate PKCE state. + * 2. Bind a one-shot callback server. + * 3. Open SwitchBot's authorize URL directly. + * 4. Await callback → exchange code for credentials. + */ +export async function browserLogin(options: BrowserLoginOptions = {}): Promise { + const { + noOpen = false, + timeoutMs = LOGIN_TIMEOUT_MS, + log = console.log, + directOAuth = false, + } = options; + + if (directOAuth) { + return browserLoginDirect({ noOpen, timeoutMs, log }); + } + + // ── Custom local login page ───────────────────────────────────────────── + const { port, wait } = await bindLoginServer(timeoutMs); + const loginUrl = `http://127.0.0.1:${port}/`; + + if (noOpen) { + log(`Open this URL in your browser to sign in:\n\n ${loginUrl}\n`); + } else { + log('Opening SwitchBot login page in your browser…'); + await open(loginUrl); + } + + log('Waiting for browser login to complete…'); + return wait(); +} + +/** Original direct-OAuth fallback (opens auth.switch-bot.com authorize URL). */ +async function browserLoginDirect( + options: Required>, +): Promise { + const { noOpen, timeoutMs, log } = options; + const state = generateState(); + const { port, wait } = await bindCallbackServer(state, timeoutMs); + const redirectUri = `http://127.0.0.1:${port}/callback`; + const authorizeUrl = buildAuthorizeUrl({ redirectUri, state }); + + if (noOpen) { + log(`Open this URL in your browser to sign in:\n\n ${authorizeUrl}\n`); + } else { + log('Opening SwitchBot login page in your browser…'); + await open(authorizeUrl); + } + + log('Waiting for browser login to complete…'); + const { code } = await wait(); + log('Exchanging authorization code for credentials…'); + return exchangeCodeForCredentials(code, redirectUri); +} + +function buildAuthorizeUrl(params: { redirectUri: string; state: string }): string { + const url = new URL(OAUTH_AUTHORIZE_URL); + url.searchParams.set('client_id', OAUTH_CLIENT_ID); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', OAUTH_SCOPE); + url.searchParams.set('redirect_uri', params.redirectUri); + url.searchParams.set('state', params.state); + return url.toString(); +} diff --git a/src/auth/constants.ts b/src/auth/constants.ts new file mode 100644 index 00000000..102c7175 --- /dev/null +++ b/src/auth/constants.ts @@ -0,0 +1,53 @@ +/** + * SwitchBot consumer app auth configuration. + * + * Email/password flow (customize-login page): + * 1. POST ACCOUNT_API_BASE/account/api/v2/user/login → access_token + * 2. POST ACCOUNT_API_BASE/account/api/v1/user/userinfo (with access_token) → botRegion + * 3. POST wonderlabs.{botRegion}.api.switchbot.net/homepage/v2/mobile/management/login → openToken + secretKey + * + * Social OAuth fallback (directOAuth: true): + * 1. Redirect user to OAUTH_AUTHORIZE_URL → Cognito login + * 2. Exchange code at OAUTH_TOKEN_URL → access_token + * 3. Same step 3 as above. + */ + +/** Direct consumer account API (customize-login page). */ +export const ACCOUNT_API_BASE = 'https://account.api.switchbot.net'; + +/** OAuth2 authorization endpoint (social login fallback). */ +export const OAUTH_AUTHORIZE_URL = 'https://auth.switch-bot.com/oauth2/authorize'; + +/** OAuth2 token endpoint (AWS Cognito, social login fallback). */ +export const OAUTH_TOKEN_URL = 'https://auth.switch-bot.com/oauth2/token'; + +/** OAuth2 scope required by social login fallback. */ +export const OAUTH_SCOPE = 'openid'; + +/** + * Client ID from config.js on www.switch-bot.com/pages/customize-login. + * Used in the direct login request body (not as an OAuth client_id for localhost redirects). + */ +export const OAUTH_CLIENT_ID = 'emvg3hk2tqu3q37fcw6cwyl4bi'; + +/** Milliseconds the CLI waits for the user to complete browser login. */ +export const LOGIN_TIMEOUT_MS = 120_000; + +// ── Mobile management API (for retrieving v1.1 openToken + secretKey) ──────── + +/** + * Region-aware mobile management base URL template. + * Replace "us" with the botRegion returned from /account/api/v1/user/userinfo. + */ +export const MOBILE_API_BASE = 'https://wonderlabs.us.api.switchbot.net/homepage'; + +/** + * Region-aware wonder API base URL template. + * Replace "us" with the botRegion. Used for /openapi/openUser/token. + */ +export const WONDER_API_BASE = 'https://wonderlabs.us.api.switchbot.net/wonder'; + +export const ENDPOINTS = { + mobileLogin: '/v2/mobile/management/login', + openUserToken: '/openapi/openUser/token', +} as const; diff --git a/src/auth/local-login-server.ts b/src/auth/local-login-server.ts new file mode 100644 index 00000000..7bc31f0e --- /dev/null +++ b/src/auth/local-login-server.ts @@ -0,0 +1,393 @@ +import http from 'node:http'; +import net from 'node:net'; +import fs from 'node:fs'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import axios from 'axios'; +import { generateState } from './pkce.js'; +import { + OAUTH_CLIENT_ID, + OAUTH_SCOPE, + OAUTH_TOKEN_URL, + ACCOUNT_API_BASE, + WONDER_API_BASE, + ENDPOINTS, + LOGIN_TIMEOUT_MS, +} from './constants.js'; +import type { CredentialBundle } from '../credentials/keychain.js'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface LoginServerHandle { + /** Port the server is listening on (open browser to http://127.0.0.1:/) */ + port: number; + /** Resolves with credentials once the user completes login, or rejects on timeout/error. */ + wait(): Promise; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const WEB_DIR = path.resolve(__dirname, 'web'); + +function getLoginHtml(state: string, port: number): string { + const filePath = path.join(WEB_DIR, 'login.html'); + let html: string; + try { + html = fs.readFileSync(filePath, 'utf8'); + } catch { + // Fallback minimal page if web/login.html is missing + html = ` +

Login page not found. Run npm run build.

+ `; + } + // Inject runtime config just before + const config = JSON.stringify({ + clientId: OAUTH_CLIENT_ID, + cognitoDomain: 'https://auth.switch-bot.com', + scope: OAUTH_SCOPE, + state, + callbackBase: `http://127.0.0.1:${port}`, + }); + return html.replace( + '', + `\n`, + ); +} + +function successHtml(): string { + return ` +SwitchBot — Login Successful + +
+
+

Login Successful

+

Credentials saved. You can close this tab and return to your terminal.

+
`; +} + +function errorHtml(detail: string): string { + const escaped = detail.replace(/&/g, '&').replace(//g, '>'); + return ` +SwitchBot — Login Failed + +
+

Login Failed

+

${escaped}

+
`; +} + +async function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address() as net.AddressInfo | null; + srv.close(() => { + if (!addr) { reject(new Error('Could not allocate port')); return; } + resolve(addr.port); + }); + }); + srv.on('error', reject); + }); +} + +/** Get botRegion then call wonder openapi to retrieve openToken + secretKey. */ +async function fetchCredentials(accessToken: string): Promise { + // Detect botRegion for region-aware API routing + let botRegion = 'us'; + try { + const userInfoResp = await axios.post<{ + statusCode: number; + body?: { botRegion?: string }; + }>( + `${ACCOUNT_API_BASE}/account/api/v1/user/userinfo`, + {}, + { + headers: { 'Content-Type': 'application/json', Authorization: accessToken }, + timeout: 10_000, + }, + ); + const region = userInfoResp.data?.body?.botRegion; + if (typeof region === 'string' && region) botRegion = region; + } catch { + // Non-fatal — fall back to "us" + } + + const wonderHost = WONDER_API_BASE.replace('.us.api', `.${botRegion}.api`); + + const resp = await axios.post<{ + statusCode?: number; + resultCode?: number; + body?: Record; + data?: Record; + }>( + `${wonderHost}${ENDPOINTS.openUserToken}`, + { operation: 'get', version: 2 }, + { + headers: { 'Content-Type': 'application/json', Authorization: accessToken }, + timeout: 15_000, + }, + ); + + // API may nest credentials under `body` or `data` + const payload = (resp.data?.body ?? resp.data?.data ?? resp.data) as Record; + const token = [payload['openToken'], payload['token']].find( + (v): v is string => typeof v === 'string' && !!v, + ); + const secret = [payload['secretKey'], payload['secret']].find( + (v): v is string => typeof v === 'string' && !!v, + ); + + if (!token || !secret) { + const code = resp.data?.statusCode ?? resp.data?.resultCode; + throw new Error( + `openUser/token returned code=${code} but openToken/secretKey missing. ` + + `Response: ${JSON.stringify(resp.data)}`, + ); + } + return { token, secret }; +} + +/** Exchange authorization code → access_token → CredentialBundle. */ +async function exchangeCode(code: string, redirectUri: string): Promise { + const tokenResp = await axios.post<{ access_token?: string }>( + OAUTH_TOKEN_URL, + new URLSearchParams({ + grant_type: 'authorization_code', + client_id: OAUTH_CLIENT_ID, + redirect_uri: redirectUri, + code, + }), + { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 15_000 }, + ); + + const accessToken = tokenResp.data.access_token; + if (!accessToken) { + throw new Error(`Token exchange returned no access_token: ${JSON.stringify(tokenResp.data)}`); + } + + return fetchCredentials(accessToken); +} + +// ── Main export ─────────────────────────────────────────────────────────────── + +/** + * Start a local HTTP server that serves the custom SwitchBot login page and + * handles the OAuth callback. + * + * The caller gets back `{ port, wait() }`: + * - Open browser to `http://127.0.0.1:/` + * - Call `wait()` to receive the CredentialBundle once the user finishes login. + * + * The server shuts itself down after the first successful login or on timeout. + */ +export async function bindLoginServer( + timeoutMs = LOGIN_TIMEOUT_MS, +): Promise { + const port = await getFreePort(); + const state = generateState(); + const redirectUri = `http://127.0.0.1:${port}/callback`; + + let resolveResult!: (r: CredentialBundle) => void; + let rejectResult!: (e: Error) => void; + const resultPromise = new Promise((res, rej) => { + resolveResult = res; + rejectResult = rej; + }); + + const timer = setTimeout(() => { + server.close(); + rejectResult(new Error('Login timed out. Please run `switchbot auth login` again.')); + }, timeoutMs); + + const finish = (creds: CredentialBundle | null, err?: Error) => { + clearTimeout(timer); + server.close(); + if (err) rejectResult(err); else resolveResult(creds!); + }; + + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`); + + const html = (code: number, body: string) => { + res.writeHead(code, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(body); + }; + const json = (code: number, body: object) => { + res.writeHead(code, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + + // ── GET / — serve login page ───────────────────────────────────────────── + if (req.method === 'GET' && url.pathname === '/') { + html(200, getLoginHtml(state, port)); + return; + } + + // ── GET /done — success page ───────────────────────────────────────────── + if (req.method === 'GET' && url.pathname === '/done') { + html(200, successHtml()); + return; + } + + // ── GET /callback — OAuth authorization code callback ─────────────────── + if (req.method === 'GET' && url.pathname === '/callback') { + const code = url.searchParams.get('code'); + const returnedState = url.searchParams.get('state'); + const error = url.searchParams.get('error'); + const errorDesc = url.searchParams.get('error_description') ?? ''; + + if (error) { + const msg = `${error}${errorDesc ? ': ' + errorDesc : ''}`; + html(400, errorHtml(msg)); + finish(null, new Error(`OAuth error: ${msg}`)); + return; + } + + if (returnedState !== state) { + html(400, errorHtml('State mismatch — possible CSRF. Please try again.')); + finish(null, new Error('OAuth state mismatch')); + return; + } + + if (!code) { + html(400, errorHtml('Missing authorization code in callback.')); + finish(null, new Error('Missing authorization code')); + return; + } + + // Serve an intermediate page while we exchange the code server-side. + html(200, ` + +

Completing sign-in…

`); + + exchangeCode(code, redirectUri) + .then(creds => finish(creds)) + .catch(err => finish(null, err instanceof Error ? err : new Error(String(err)))); + return; + } + + // ── POST /auth/email — email / password proxy ──────────────────────────── + if (req.method === 'POST' && url.pathname === '/auth/email') { + let body = ''; + req.on('data', chunk => { body += chunk; }); + req.on('end', () => { + let email: string, password: string; + try { + ({ email, password } = JSON.parse(body)); + } catch { + json(400, { success: false, message: 'Invalid request body.' }); + return; + } + handleEmailLogin(email, password) + .then(creds => { + json(200, { success: true }); + finish(creds); + }) + .catch(err => { + const msg = err instanceof Error ? err.message : String(err); + json(401, { success: false, message: msg }); + }); + }); + return; + } + + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not found'); + }); + + server.listen(port, '127.0.0.1'); + + return { port, wait: () => resultPromise }; +} + +// ── Email / password auth (SwitchBot consumer REST API) ────────────────────── + +/** + * Direct email/password login using the SwitchBot consumer account API. + * + * Flow mirrors customize-login page's Vue component: + * 1. POST /account/api/v2/user/login → access_token (or mfa_token if MFA required) + * 2. If MFA: not yet supported — throw with a helpful message + * 3. POST /account/api/v1/user/userinfo → botRegion + * 4. POST wonderlabs.{botRegion}.api.switchbot.net/homepage/v2/mobile/management/login → openToken + secretKey + */ +async function handleEmailLogin( + email: string, + password: string, +): Promise { + // Step 1 — authenticate with username + password + const loginResp = await axios.post<{ + statusCode: number; + body?: { + access_token?: string; + mfa_token?: string; + mfa_enabled?: boolean; + status?: string; + }; + message?: string; + }>( + `${ACCOUNT_API_BASE}/account/api/v2/user/login`, + { + clientId: OAUTH_CLIENT_ID, + deviceInfo: { + deviceId: randomUUID().replace(/-/g, ''), + model: 'CLI', + deviceName: 'switchbot-cli', + }, + grantType: 'password', + username: email, + password, + dialCode: '', + verifyCode: '', + }, + { headers: { 'Content-Type': 'application/json' }, timeout: 15_000 }, + ); + + const loginBody = loginResp.data?.body ?? {}; + + if (loginResp.data?.statusCode !== 100) { + const msg = loginResp.data?.message ?? `Login failed (statusCode=${loginResp.data?.statusCode})`; + throw new Error(msg); + } + + if (loginBody.mfa_enabled && loginBody.mfa_token) { + throw new Error( + 'This account has MFA enabled. MFA login is not yet supported in CLI browser login. ' + + 'Please use `switchbot auth login --direct` to sign in, or disable MFA on your account.', + ); + } + + const accessToken = loginBody.access_token; + if (!accessToken) { + throw new Error(`Login returned no access_token. Body: ${JSON.stringify(loginResp.data?.body)}`); + } + + return fetchCredentials(accessToken); +} diff --git a/src/auth/oauth-callback.ts b/src/auth/oauth-callback.ts new file mode 100644 index 00000000..12652b6c --- /dev/null +++ b/src/auth/oauth-callback.ts @@ -0,0 +1,131 @@ +import http from 'node:http'; +import net from 'node:net'; +import { LOGIN_TIMEOUT_MS } from './constants.js'; + +export interface CallbackResult { + code: string; +} + +export interface CallbackHandle { + /** The loopback port the server is listening on. */ + port: number; + /** Resolves with the OAuth code once the browser redirects here. */ + wait(): Promise; +} + +function successHtml(): string { + return ` +SwitchBot CLI — Login successful + +
+

Login successful

+

You can close this tab and return to your terminal.

+
`; +} + +function errorHtml(detail: string): string { + return ` +SwitchBot CLI — Login failed + +
+

Login failed

+

${detail}

+
`; +} + +async function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address() as net.AddressInfo | null; + srv.close(() => { + if (!addr) { reject(new Error('Could not allocate callback port')); return; } + resolve(addr.port); + }); + }); + srv.on('error', reject); + }); +} + +/** + * Bind a one-shot OAuth callback server on a free loopback port. + * + * Returns immediately with the bound `port` and a `wait()` function. + * Call `wait()` to receive the authorization code once the user's browser + * is redirected to `http://127.0.0.1:/callback`. + * + * The server shuts itself down after the first valid callback or on timeout. + */ +export async function bindCallbackServer( + expectedState: string, + timeoutMs = LOGIN_TIMEOUT_MS, +): Promise { + const port = await getFreePort(); + + let resolveResult!: (r: CallbackResult) => void; + let rejectResult!: (e: Error) => void; + const resultPromise = new Promise((res, rej) => { + resolveResult = res; + rejectResult = rej; + }); + + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`); + + if (url.pathname !== '/callback') { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not found'); + return; + } + + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const error = url.searchParams.get('error'); + const errorDesc = url.searchParams.get('error_description') ?? ''; + + const finish = (statusCode: number, body: string, err?: Error) => { + res.writeHead(statusCode, { 'Content-Type': 'text/html' }); + res.end(body); + server.close(); + clearTimeout(timer); + if (err) rejectResult(err); else resolveResult({ code: code! }); + }; + + if (error) { + finish(400, errorHtml(`${error}${errorDesc ? ': ' + errorDesc : ''}`), + new Error(`OAuth error: ${error}${errorDesc ? ' — ' + errorDesc : ''}`)); + return; + } + + if (state !== expectedState) { + finish(400, errorHtml('State mismatch — possible CSRF. Please try again.'), + new Error('OAuth state mismatch')); + return; + } + + if (!code) { + finish(400, errorHtml('Missing authorization code in callback.'), + new Error('Missing authorization code')); + return; + } + + finish(200, successHtml()); + }); + + server.listen(port, '127.0.0.1'); + + const timer = setTimeout(() => { + server.close(); + rejectResult(new Error('Login timed out. Please run `switchbot auth login` again.')); + }, timeoutMs); + + return { port, wait: () => resultPromise }; +} diff --git a/src/auth/pkce.ts b/src/auth/pkce.ts new file mode 100644 index 00000000..b0ec8636 --- /dev/null +++ b/src/auth/pkce.ts @@ -0,0 +1,6 @@ +import crypto from 'node:crypto'; + +/** 32-byte random hex string for CSRF state parameter. */ +export function generateState(): string { + return crypto.randomBytes(32).toString('hex'); +} diff --git a/src/auth/token-exchange.ts b/src/auth/token-exchange.ts new file mode 100644 index 00000000..5f652abb --- /dev/null +++ b/src/auth/token-exchange.ts @@ -0,0 +1,132 @@ +import axios from 'axios'; +import type { CredentialBundle } from '../credentials/keychain.js'; +import { + OAUTH_CLIENT_ID, + OAUTH_TOKEN_URL, + MOBILE_API_BASE, + ENDPOINTS, +} from './constants.js'; + +// ── Response shapes ─────────────────────────────────────────────────────────── + +interface TokenResponse { + access_token: string; + refresh_token?: string; + id_token?: string; + expires_in?: number; + token_type?: string; +} + +interface MobileLoginResponse { + statusCode: number; + body?: { + openToken?: string; + secretKey?: string; + token?: string; + secret?: string; + [key: string]: unknown; + }; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function pickString(obj: Record, ...keys: string[]): string | undefined { + for (const k of keys) { + const v = obj[k]; + if (typeof v === 'string' && v) return v; + } + return undefined; +} + +// ── Main export ─────────────────────────────────────────────────────────────── + +/** + * Exchange an OAuth authorization code for SwitchBot v1.1 credentials. + * + * Step 1 — POST https://auth.switch-bot.com/oauth2/token + * Standard OAuth2 authorization-code exchange using Basic auth + * (client_id:client_secret), as documented in the SwitchBot Enterprise API. + * Returns access_token. + * + * Step 2 — POST /v2/mobile/management/login (wonderlabs mobile API) + * Uses the access_token to retrieve the v1.1 openToken + secretKey. + * These are the long-lived credentials used with HMAC-SHA256 signing. + */ +export async function exchangeCodeForCredentials( + code: string, + redirectUri: string, +): Promise { + + // ── Step 1: code → access_token ────────────────────────────────────────── + let accessToken: string; + try { + // Public client — no client_secret, no Basic auth header. + const resp = await axios.post( + OAUTH_TOKEN_URL, + new URLSearchParams({ + grant_type: 'authorization_code', + client_id: OAUTH_CLIENT_ID, + redirect_uri: redirectUri, + code, + }), + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + timeout: 15_000, + }, + ); + + if (!resp.data.access_token) { + throw new Error(`Token endpoint returned no access_token. Body: ${JSON.stringify(resp.data)}`); + } + accessToken = resp.data.access_token; + } catch (err) { + if (axios.isAxiosError(err)) { + const status = err.response?.status; + const body = err.response?.data; + throw new Error( + `Token exchange failed (HTTP ${status ?? 'unknown'}): ` + + (typeof body === 'object' ? JSON.stringify(body) : String(body ?? err.message)), + ); + } + throw err; + } + + // ── Step 2: access_token → openToken + secretKey ───────────────────────── + try { + const resp = await axios.post( + `${MOBILE_API_BASE}${ENDPOINTS.mobileLogin}`, + {}, + { + headers: { + 'Content-Type': 'application/json', + Authorization: accessToken, + }, + timeout: 15_000, + }, + ); + + const body = (resp.data?.body ?? {}) as Record; + const token = pickString(body, 'openToken', 'token'); + const secret = pickString(body, 'secretKey', 'secret'); + + if (!token || !secret) { + throw new Error( + `mobile/management/login returned statusCode=${resp.data?.statusCode} ` + + `but openToken/secretKey missing. Body: ${JSON.stringify(resp.data?.body)}\n` + + `If field names differ, update pickString() calls in src/auth/token-exchange.ts.`, + ); + } + + return { token, secret }; + } catch (err) { + if (axios.isAxiosError(err)) { + const status = err.response?.status; + const body = err.response?.data; + throw new Error( + `Mobile login failed (HTTP ${status ?? 'unknown'}): ` + + (typeof body === 'object' ? JSON.stringify(body) : String(body ?? err.message)), + ); + } + throw err; + } +} diff --git a/src/commands/auth.ts b/src/commands/auth.ts index d768917e..5745470f 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -27,6 +27,7 @@ import { CredentialBundle, selectCredentialStore, } from '../credentials/keychain.js'; +import { browserLogin } from '../auth/browser-login.js'; function activeProfile(): string { return getActiveProfile() ?? 'default'; @@ -375,4 +376,56 @@ export function registerAuthCommand(program: Command): void { console.log('Source file kept — pass --delete-file on the next run to remove it.'); } }); + + // ------------------------------------------------------------------------- + // auth login + // ------------------------------------------------------------------------- + auth + .command('login') + .description('Sign in via browser and save credentials to the OS keychain') + .option('--no-open', 'Print the login URL instead of opening the browser automatically') + .option('--timeout ', 'Browser login timeout in seconds (default: 120)', '120') + .action(async (options: { open: boolean; timeout: string }) => { + const profile = activeProfile(); + const timeoutMs = Math.max(10, parseInt(options.timeout, 10) || 120) * 1000; + + let creds: CredentialBundle; + try { + creds = await browserLogin({ + noOpen: !options.open, + timeoutMs, + log: (msg) => console.log(msg), + }); + } catch (err) { + exitWithError({ + code: 1, + kind: 'runtime', + message: `Login failed: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + const store = await selectCredentialStore(); + try { + await store.set(profile, creds!); + } catch (err) { + exitWithError({ + code: 1, + kind: 'runtime', + message: `Failed to save credentials: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + if (isJsonMode()) { + printJson({ + profile, + backend: store.name, + loggedIn: true, + token: { length: creds!.token.length, masked: maskValue(creds!.token) }, + }); + return; + } + + console.log(`Logged in. Credentials saved to backend "${store.name}" for profile "${profile}".`); + console.log(`token : ${maskValue(creds!.token)}`); + }); } diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index 82b99082..74f6cd05 100644 --- a/src/commands/capabilities.ts +++ b/src/commands/capabilities.ts @@ -122,6 +122,7 @@ export const COMMAND_META: Record = { 'auth keychain set': DESTRUCTIVE_LOCAL, 'auth keychain delete': DESTRUCTIVE_LOCAL, 'auth keychain migrate': DESTRUCTIVE_LOCAL, + 'auth login': DESTRUCTIVE_LOCAL, 'cache show': READ_LOCAL, 'cache clear': ACTION_LOCAL, 'capabilities': READ_LOCAL, diff --git a/tests/commands/capabilities-meta.test.ts b/tests/commands/capabilities-meta.test.ts index 33872397..97754da1 100644 --- a/tests/commands/capabilities-meta.test.ts +++ b/tests/commands/capabilities-meta.test.ts @@ -22,7 +22,7 @@ import { expectJsonEnvelopeContainingKeys } from '../helpers/contracts.js'; const ALL_EXPECTED_LEAF_COMMANDS = [ 'agent-bootstrap', 'auth keychain describe', 'auth keychain get', 'auth keychain set', - 'auth keychain delete', 'auth keychain migrate', + 'auth keychain delete', 'auth keychain migrate', 'auth login', 'cache show', 'cache clear', 'capabilities', 'catalog path', 'catalog show', 'catalog search', 'catalog diff', 'catalog refresh', diff --git a/web/login.html b/web/login.html new file mode 100644 index 00000000..191f0a9d --- /dev/null +++ b/web/login.html @@ -0,0 +1,482 @@ + + + + + + SwitchBot — Sign In + + + +
+ + +
+ +
+
+ +
+

Login Successful

+

Credentials saved. You can close this tab and return to your terminal.

+
+ + +
+ + + +
+
+ or sign in with email +
+
+ +
+ +
+
+ + +
+
+
+ + Forgot password? +
+ +
+ + +
+ + +
+
+
+ + + + From f93a662220cb7742812e360085293350de4d9ccc Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Wed, 20 May 2026 22:57:31 +0800 Subject: [PATCH 02/72] refactor(auth): extract shared getFreePort and escapeHtml into utils.ts --- src/auth/utils.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/auth/utils.ts diff --git a/src/auth/utils.ts b/src/auth/utils.ts new file mode 100644 index 00000000..c35ea9f1 --- /dev/null +++ b/src/auth/utils.ts @@ -0,0 +1,24 @@ +import * as net from 'node:net'; + +export async function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address() as net.AddressInfo | null; + srv.close(() => { + if (!addr) { reject(new Error('Could not allocate port')); return; } + resolve(addr.port); + }); + }); + srv.on('error', reject); + }); +} + +export function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} From c17b4d413e84e829415917283bc404f135ce9c20 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Wed, 20 May 2026 23:01:45 +0800 Subject: [PATCH 03/72] fix(auth): XSS in errorHtml, double-close guard, security headers in oauth-callback --- src/auth/oauth-callback.ts | 43 ++++++++++++++------------------------ 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/src/auth/oauth-callback.ts b/src/auth/oauth-callback.ts index 12652b6c..dbf9a7ab 100644 --- a/src/auth/oauth-callback.ts +++ b/src/auth/oauth-callback.ts @@ -1,5 +1,5 @@ import http from 'node:http'; -import net from 'node:net'; +import { getFreePort, escapeHtml } from './utils.js'; import { LOGIN_TIMEOUT_MS } from './constants.js'; export interface CallbackResult { @@ -13,6 +13,11 @@ export interface CallbackHandle { wait(): Promise; } +const SECURITY_HEADERS = { + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', +} as const; + function successHtml(): string { return ` SwitchBot CLI — Login successful @@ -28,6 +33,7 @@ h1{color:#16a34a;margin:0 0 12px}p{color:#374151;margin:0} } function errorHtml(detail: string): string { + const escaped = escapeHtml(detail); return ` SwitchBot CLI — Login failed

Login failed

-

${detail}

+

${escaped}

`; } -async function getFreePort(): Promise { - return new Promise((resolve, reject) => { - const srv = net.createServer(); - srv.listen(0, '127.0.0.1', () => { - const addr = srv.address() as net.AddressInfo | null; - srv.close(() => { - if (!addr) { reject(new Error('Could not allocate callback port')); return; } - resolve(addr.port); - }); - }); - srv.on('error', reject); - }); -} - -/** - * Bind a one-shot OAuth callback server on a free loopback port. - * - * Returns immediately with the bound `port` and a `wait()` function. - * Call `wait()` to receive the authorization code once the user's browser - * is redirected to `http://127.0.0.1:/callback`. - * - * The server shuts itself down after the first valid callback or on timeout. - */ export async function bindCallbackServer( expectedState: string, timeoutMs = LOGIN_TIMEOUT_MS, @@ -77,11 +60,13 @@ export async function bindCallbackServer( rejectResult = rej; }); + let finished = false; + const server = http.createServer((req, res) => { const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`); if (url.pathname !== '/callback') { - res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.writeHead(404, { 'Content-Type': 'text/plain', ...SECURITY_HEADERS }); res.end('Not found'); return; } @@ -92,7 +77,9 @@ export async function bindCallbackServer( const errorDesc = url.searchParams.get('error_description') ?? ''; const finish = (statusCode: number, body: string, err?: Error) => { - res.writeHead(statusCode, { 'Content-Type': 'text/html' }); + if (finished) return; + finished = true; + res.writeHead(statusCode, { 'Content-Type': 'text/html', ...SECURITY_HEADERS }); res.end(body); server.close(); clearTimeout(timer); @@ -123,6 +110,8 @@ export async function bindCallbackServer( server.listen(port, '127.0.0.1'); const timer = setTimeout(() => { + if (finished) return; + finished = true; server.close(); rejectResult(new Error('Login timed out. Please run `switchbot auth login` again.')); }, timeoutMs); From 95dfee22fdeb014ac64354d1cff0dbcea5f9d6ff Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Wed, 20 May 2026 23:07:06 +0800 Subject: [PATCH 04/72] refactor(auth): rename pkce.ts to csrf.ts, update all callers --- src/auth/browser-login.ts | 2 +- src/auth/{pkce.ts => csrf.ts} | 2 +- src/auth/local-login-server.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename src/auth/{pkce.ts => csrf.ts} (62%) diff --git a/src/auth/browser-login.ts b/src/auth/browser-login.ts index 1410f070..3b9ae3d4 100644 --- a/src/auth/browser-login.ts +++ b/src/auth/browser-login.ts @@ -1,5 +1,5 @@ import open from 'open'; -import { generateState } from './pkce.js'; +import { generateState } from './csrf.js'; import { bindCallbackServer } from './oauth-callback.js'; import { bindLoginServer } from './local-login-server.js'; import { exchangeCodeForCredentials } from './token-exchange.js'; diff --git a/src/auth/pkce.ts b/src/auth/csrf.ts similarity index 62% rename from src/auth/pkce.ts rename to src/auth/csrf.ts index b0ec8636..ab105a82 100644 --- a/src/auth/pkce.ts +++ b/src/auth/csrf.ts @@ -1,6 +1,6 @@ import crypto from 'node:crypto'; -/** 32-byte random hex string for CSRF state parameter. */ +/** 32-byte random hex string used as CSRF state parameter in OAuth flows. */ export function generateState(): string { return crypto.randomBytes(32).toString('hex'); } diff --git a/src/auth/local-login-server.ts b/src/auth/local-login-server.ts index 7bc31f0e..37a40824 100644 --- a/src/auth/local-login-server.ts +++ b/src/auth/local-login-server.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { randomUUID } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import axios from 'axios'; -import { generateState } from './pkce.js'; +import { generateState } from './csrf.js'; import { OAUTH_CLIENT_ID, OAUTH_SCOPE, From 30a96fe2c4f2f657ab7473fc7ff670da17aa3d1f Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Wed, 20 May 2026 23:11:50 +0800 Subject: [PATCH 05/72] fix(auth): botRegion allowlist, body size cap, double-close guard, security headers --- src/auth/local-login-server.ts | 63 ++++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/src/auth/local-login-server.ts b/src/auth/local-login-server.ts index 37a40824..c80c1940 100644 --- a/src/auth/local-login-server.ts +++ b/src/auth/local-login-server.ts @@ -1,11 +1,11 @@ import http from 'node:http'; -import net from 'node:net'; import fs from 'node:fs'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import axios from 'axios'; import { generateState } from './csrf.js'; +import { getFreePort, escapeHtml } from './utils.js'; import { OAUTH_CLIENT_ID, OAUTH_SCOPE, @@ -28,6 +28,11 @@ export interface LoginServerHandle { // ── Helpers ─────────────────────────────────────────────────────────────────── +const SECURITY_HEADERS = { + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', +} as const; + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const WEB_DIR = path.resolve(__dirname, 'web'); @@ -81,7 +86,7 @@ p{color:rgba(255,255,255,.55);font-size:14px} } function errorHtml(detail: string): string { - const escaped = detail.replace(/&/g, '&').replace(//g, '>'); + const escaped = escapeHtml(detail); return ` SwitchBot — Login Failed -

Completing sign-in…

`); - - exchangeCode(code, redirectUri) - .then(creds => finish(creds)) - .catch(err => finish(null, err instanceof Error ? err : new Error(String(err)))); - return; - } - // ── POST /auth/email — email / password proxy ──────────────────────────── if (req.method === 'POST' && url.pathname === '/auth/email') { const BODY_LIMIT = 4 * 1024; @@ -356,7 +262,10 @@ async function handleEmailLogin( dialCode: '', verifyCode: '', }, - { headers: { 'Content-Type': 'application/json' }, timeout: 15_000 }, + { + headers: { 'Content-Type': 'application/json', 'User-Agent': UA }, + timeout: 15_000, + }, ); const loginBody = loginResp.data?.body ?? {}; @@ -369,7 +278,7 @@ async function handleEmailLogin( if (loginBody.mfa_enabled && loginBody.mfa_token) { throw new Error( 'This account has MFA enabled. MFA login is not yet supported in CLI browser login. ' + - 'Please use `switchbot auth login --direct` to sign in, or disable MFA on your account.', + 'Please disable MFA on your account.', ); } @@ -385,7 +294,14 @@ async function handleEmailLogin( }>( `${ACCOUNT_API_BASE}${ENDPOINTS.userInfo}`, {}, - { headers: { 'Content-Type': 'application/json', Authorization: accessToken }, timeout: 15_000 }, + { + headers: { + 'Content-Type': 'application/json', + Authorization: accessToken, + 'User-Agent': UA, + }, + timeout: 15_000, + }, ); const rawRegion = userInfoResp.data?.body?.botRegion ?? ''; const botRegion = KNOWN_BOT_REGIONS.has(rawRegion) ? rawRegion : DEFAULT_BOT_REGION; diff --git a/src/auth/token-exchange.ts b/src/auth/token-exchange.ts index 5f652abb..5d5ad8de 100644 --- a/src/auth/token-exchange.ts +++ b/src/auth/token-exchange.ts @@ -2,7 +2,7 @@ import axios from 'axios'; import type { CredentialBundle } from '../credentials/keychain.js'; import { OAUTH_CLIENT_ID, - OAUTH_TOKEN_URL, + ACCOUNT_API_BASE, MOBILE_API_BASE, ENDPOINTS, } from './constants.js'; @@ -10,11 +10,12 @@ import { // ── Response shapes ─────────────────────────────────────────────────────────── interface TokenResponse { - access_token: string; - refresh_token?: string; - id_token?: string; - expires_in?: number; - token_type?: string; + statusCode?: number; + body?: { + access_token?: string; + [key: string]: unknown; + }; + access_token?: string; } interface MobileLoginResponse { @@ -43,10 +44,9 @@ function pickString(obj: Record, ...keys: string[]): string | u /** * Exchange an OAuth authorization code for SwitchBot v1.1 credentials. * - * Step 1 — POST https://auth.switch-bot.com/oauth2/token - * Standard OAuth2 authorization-code exchange using Basic auth - * (client_id:client_secret), as documented in the SwitchBot Enterprise API. - * Returns access_token. + * Step 1 — POST account.api.switchbot.net/merchant/v1/oauth/token + * Exchange the authorization code issued by sp.oauth.switchbot.net for an + * access_token using the SwitchBot account API. * * Step 2 — POST /v2/mobile/management/login (wonderlabs mobile API) * Uses the access_token to retrieve the v1.1 openToken + secretKey. @@ -60,25 +60,29 @@ export async function exchangeCodeForCredentials( // ── Step 1: code → access_token ────────────────────────────────────────── let accessToken: string; try { - // Public client — no client_secret, no Basic auth header. const resp = await axios.post( - OAUTH_TOKEN_URL, - new URLSearchParams({ - grant_type: 'authorization_code', - client_id: OAUTH_CLIENT_ID, - redirect_uri: redirectUri, + `${ACCOUNT_API_BASE}${ENDPOINTS.oauthToken}`, + { + clientId: OAUTH_CLIENT_ID, + redirectUri, + grantType: 'authorization_code', code, - }), + }, { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + headers: { 'Content-Type': 'application/json' }, timeout: 15_000, }, ); - if (!resp.data.access_token) { + // Support both top-level access_token and body.access_token response shapes + const token = + resp.data?.body?.access_token ?? + (resp.data as { access_token?: string })?.access_token; + + if (!token) { throw new Error(`Token endpoint returned no access_token. Body: ${JSON.stringify(resp.data)}`); } - accessToken = resp.data.access_token; + accessToken = token; } catch (err) { if (axios.isAxiosError(err)) { const status = err.response?.status; diff --git a/tests/auth/local-login-server.test.ts b/tests/auth/local-login-server.test.ts index 7a05c2a7..4fcf6ebd 100644 --- a/tests/auth/local-login-server.test.ts +++ b/tests/auth/local-login-server.test.ts @@ -3,24 +3,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const mockPost = vi.hoisted(() => vi.fn()); vi.mock('axios', () => ({ default: { post: mockPost } })); -// Stub fs.readFileSync for login.html so tests don't depend on src/auth/web/login.html. -// The test-time __dirname resolves to src/auth/, not dist/auth/, so the real web asset -// lives at dist/web/login.html — unavailable without a build. Return a minimal stub -// that preserves the tag so config injection still works. -vi.mock('node:fs', async (importOriginal) => { - const realFs = await importOriginal(); - const stub = ''; - const patchedReadFileSync = (p: unknown, ...args: unknown[]) => { - if (typeof p === 'string' && p.endsWith('login.html')) return stub; - return (realFs.readFileSync as (...a: unknown[]) => unknown)(p, ...args); - }; - return { - ...realFs, - default: { ...realFs.default, readFileSync: patchedReadFileSync }, - readFileSync: patchedReadFileSync, - }; -}); - import { bindLoginServer } from '../../src/auth/local-login-server.js'; async function get(port: number, path: string) { @@ -45,28 +27,14 @@ async function postJson(port: number, path: string, data: unknown) { return { status: res.status, body, json, headers }; } +// Drain using a short timeout — /callback no longer lives on this server. async function drain(handle: Awaited>) { - const p = handle.wait().catch(() => {}); - await get(handle.port, '/callback?error=abort').catch(() => {}); - await p; + await handle.wait().catch(() => {}); } -describe('bindLoginServer — GET /', () => { - it('serves login HTML with injected __SWITCHBOT_LOGIN_CONFIG__', async () => { - const handle = await bindLoginServer(30_000); - const resp = await get(handle.port, '/'); - expect(resp.status).toBe(200); - expect(resp.body).toContain('__SWITCHBOT_LOGIN_CONFIG__'); - expect(resp.body).toContain('"callbackBase"'); - expect(resp.headers['x-content-type-options']).toBe('nosniff'); - expect(resp.headers['x-frame-options']).toBe('DENY'); - await drain(handle); - }); -}); - describe('bindLoginServer — GET /done', () => { it('returns 200 success page', async () => { - const handle = await bindLoginServer(30_000); + const handle = await bindLoginServer(50); const resp = await get(handle.port, '/done'); expect(resp.status).toBe(200); expect(resp.body).toContain('Login Successful'); @@ -74,42 +42,9 @@ describe('bindLoginServer — GET /done', () => { }); }); -describe('bindLoginServer — GET /callback state mismatch', () => { - it('returns 400 and rejects wait() on wrong state', async () => { - const handle = await bindLoginServer(30_000); - const waitP = handle.wait(); - void waitP.catch(() => {}); - const resp = await get(handle.port, '/callback?code=abc&state=WRONG'); - expect(resp.status).toBe(400); - expect(resp.body).toContain('State mismatch'); - await expect(waitP).rejects.toThrow('state mismatch'); - }); -}); - -describe('bindLoginServer — GET /callback OAuth error', () => { - it('returns 400 and rejects wait()', async () => { - const handle = await bindLoginServer(30_000); - const waitP = handle.wait(); - void waitP.catch(() => {}); - const resp = await get(handle.port, '/callback?error=access_denied'); - expect(resp.status).toBe(400); - await expect(waitP).rejects.toThrow('access_denied'); - }); - - it('HTML-escapes the error string', async () => { - const handle = await bindLoginServer(30_000); - const waitP = handle.wait().catch(() => {}); - const payload = encodeURIComponent(''); - const resp = await get(handle.port, `/callback?error=${payload}`); - expect(resp.body).not.toContain(''); const resp = await get(handle.port, `/callback?error=e&error_description=${payload}`); @@ -54,7 +57,7 @@ describe('bindCallbackServer — OAuth error param', () => { describe('bindCallbackServer — state mismatch', () => { it('returns 400 and rejects wait() on wrong state', async () => { - const handle = await bindCallbackServer('correct', 30_000); + const handle = await bindCallbackServer('correct', 30_000, RAND); const waitP = handle.wait(); void waitP.catch(() => {}); // prevent unhandled rejection const resp = await get(handle.port, '/callback?code=abc&state=wrong'); @@ -66,7 +69,7 @@ describe('bindCallbackServer — state mismatch', () => { describe('bindCallbackServer — missing code', () => { it('returns 400 and rejects when code is absent', async () => { - const handle = await bindCallbackServer('nc-state', 30_000); + const handle = await bindCallbackServer('nc-state', 30_000, RAND); const waitP = handle.wait(); void waitP.catch(() => {}); // prevent unhandled rejection await get(handle.port, '/callback?state=nc-state'); @@ -76,7 +79,7 @@ describe('bindCallbackServer — missing code', () => { describe('bindCallbackServer — happy path', () => { it('returns 200 and resolves wait() with the authorization code', async () => { - const handle = await bindCallbackServer('good', 30_000); + const handle = await bindCallbackServer('good', 30_000, RAND); const resp = await get(handle.port, '/callback?code=my-code&state=good'); expect(resp.status).toBe(200); expect(resp.body).toContain('Login successful'); @@ -87,14 +90,14 @@ describe('bindCallbackServer — happy path', () => { describe('bindCallbackServer — timeout', () => { it('rejects wait() after timeoutMs', async () => { - const handle = await bindCallbackServer('timeout-state', 30); + const handle = await bindCallbackServer('timeout-state', 30, RAND); await expect(handle.wait()).rejects.toThrow('Login timed out'); }); }); describe('bindCallbackServer — double-close guard', () => { it('does not throw ERR_SERVER_NOT_RUNNING when callback and timer race', async () => { - const handle = await bindCallbackServer('race', 50); + const handle = await bindCallbackServer('race', 50, RAND); const fetchP = get(handle.port, '/callback?code=c&state=race').catch(() => null); await expect(Promise.all([handle.wait().catch(e => e), fetchP])).resolves.toBeDefined(); }); From cb82cb7d23d451ea14390b95bb41777b2f9fb88e Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 18:35:25 +0800 Subject: [PATCH 16/72] =?UTF-8?q?fix(auth):=20fix=20token-exchange=20Step?= =?UTF-8?q?=202=20=E2=80=94=20use=20Wonder=20API=20instead=20of=20mobile/m?= =?UTF-8?q?anagement/login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mobile/management/login is a device-trust endpoint and does not return openToken/secretKey. The correct flow (same as email/password path) is: 1. POST /merchant/v1/oauth/token (form-encoded) → access_token 2. POST /account/api/v1/user/userinfo → botRegion 3. POST wonderlabs.{region}.api.switchbot.net/wonder/openapi/openUser/token → AES-128-CBC encrypted token + secretKey → decrypt Also removes MOBILE_API_BASE dependency from token-exchange.ts and updates tests to cover the 3-step flow with userinfo fallback. Co-Authored-By: Claude Sonnet 4.6 --- src/auth/token-exchange.ts | 122 +++++++++++++++--------------- tests/auth/token-exchange.test.ts | 81 ++++++++++++-------- 2 files changed, 108 insertions(+), 95 deletions(-) diff --git a/src/auth/token-exchange.ts b/src/auth/token-exchange.ts index f34f0052..0008360e 100644 --- a/src/auth/token-exchange.ts +++ b/src/auth/token-exchange.ts @@ -1,43 +1,24 @@ +import crypto from 'node:crypto'; import axios from 'axios'; import type { CredentialBundle } from '../credentials/keychain.js'; import { OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, ACCOUNT_API_BASE, - MOBILE_API_BASE, + TOKEN_AES_KEY, + TOKEN_AES_IV, ENDPOINTS, + KNOWN_BOT_REGIONS, + DEFAULT_BOT_REGION, } from './constants.js'; -// ── Response shapes ─────────────────────────────────────────────────────────── - -interface TokenResponse { - statusCode?: number; - body?: { - access_token?: string; - [key: string]: unknown; - }; - access_token?: string; -} - -interface MobileLoginResponse { - statusCode: number; - body?: { - openToken?: string; - secretKey?: string; - token?: string; - secret?: string; - [key: string]: unknown; - }; -} - // ── Helpers ─────────────────────────────────────────────────────────────────── -function pickString(obj: Record, ...keys: string[]): string | undefined { - for (const k of keys) { - const v = obj[k]; - if (typeof v === 'string' && v) return v; - } - return undefined; +function decryptField(hexCipher: string): string { + const key = Buffer.from(TOKEN_AES_KEY, 'utf8'); + const iv = Buffer.from(TOKEN_AES_IV, 'utf8'); + const d = crypto.createDecipheriv('aes-128-cbc', key, iv); + return Buffer.concat([d.update(Buffer.from(hexCipher, 'hex')), d.final()]).toString('hex'); } // ── Main export ─────────────────────────────────────────────────────────────── @@ -45,13 +26,14 @@ function pickString(obj: Record, ...keys: string[]): string | u /** * Exchange an OAuth authorization code for SwitchBot v1.1 credentials. * - * Step 1 — POST account.api.switchbot.net/merchant/v1/oauth/token - * Exchange the authorization code issued by sp.oauth.switchbot.net for an - * access_token using the SwitchBot account API. + * Step 1 — POST account.api.switchbot.net/merchant/v1/oauth/token (form-encoded) + * Exchange the authorization code for an access_token. * - * Step 2 — POST /v2/mobile/management/login (wonderlabs mobile API) - * Uses the access_token to retrieve the v1.1 openToken + secretKey. - * These are the long-lived credentials used with HMAC-SHA256 signing. + * Step 2 — POST account.api.switchbot.net/account/api/v1/user/userinfo + * Get the user's botRegion to select the correct regional Wonder API. + * + * Step 3 — POST wonderlabs.{region}.api.switchbot.net/wonder/openapi/openUser/token + * Retrieve AES-128-CBC encrypted openToken + secretKey, then decrypt. */ export async function exchangeCodeForCredentials( code: string, @@ -61,28 +43,28 @@ export async function exchangeCodeForCredentials( // ── Step 1: code → access_token ────────────────────────────────────────── let accessToken: string; try { - const resp = await axios.post( + const resp = await axios.post>( `${ACCOUNT_API_BASE}${ENDPOINTS.oauthToken}`, - { - clientId: OAUTH_CLIENT_ID, - clientSecret: OAUTH_CLIENT_SECRET, - redirectUri, - grantType: 'authorization_code', + new URLSearchParams({ + client_id: OAUTH_CLIENT_ID, + client_secret: OAUTH_CLIENT_SECRET, + redirect_uri: redirectUri, + grant_type: 'authorization_code', code, - }, + }), { - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 15_000, }, ); - // Support both top-level access_token and body.access_token response shapes - const token = - resp.data?.body?.access_token ?? - (resp.data as { access_token?: string })?.access_token; + const data = resp.data; + // Support both top-level and body-wrapped access_token + const bodyData = (data['body'] as Record | undefined) ?? data; + const token = typeof bodyData['access_token'] === 'string' ? bodyData['access_token'] : undefined; if (!token) { - throw new Error(`Token endpoint returned no access_token. Body: ${JSON.stringify(resp.data)}`); + throw new Error(`Token endpoint returned no access_token. Body: ${JSON.stringify(data)}`); } accessToken = token; } catch (err) { @@ -97,39 +79,53 @@ export async function exchangeCodeForCredentials( throw err; } - // ── Step 2: access_token → openToken + secretKey ───────────────────────── + // ── Step 2: access_token → botRegion ──────────────────────────────────── + let botRegion = DEFAULT_BOT_REGION; try { - const resp = await axios.post( - `${MOBILE_API_BASE}${ENDPOINTS.mobileLogin}`, + const resp = await axios.post<{ statusCode?: number; body?: { botRegion?: string } }>( + `${ACCOUNT_API_BASE}${ENDPOINTS.userInfo}`, {}, { - headers: { - 'Content-Type': 'application/json', - Authorization: accessToken, - }, + headers: { 'Content-Type': 'application/json', Authorization: accessToken }, + timeout: 15_000, + }, + ); + const raw = resp.data?.body?.botRegion ?? ''; + if (KNOWN_BOT_REGIONS.has(raw)) botRegion = raw; + } catch { + // Non-fatal: fall back to default region + } + + // ── Step 3: Wonder API → encrypted credentials → decrypt ───────────────── + try { + const wonderBase = `https://wonderlabs.${botRegion}.api.switchbot.net/wonder`; + const resp = await axios.post<{ statusCode?: number; body?: Record }>( + `${wonderBase}${ENDPOINTS.openUserToken}`, + { operation: 'get', version: 2 }, + { + headers: { 'Content-Type': 'application/json', Authorization: accessToken }, timeout: 15_000, }, ); const body = (resp.data?.body ?? {}) as Record; - const token = pickString(body, 'openToken', 'token'); - const secret = pickString(body, 'secretKey', 'secret'); + const encToken = typeof body['token'] === 'string' ? body['token'] : undefined; + const encSecret = typeof body['secretKey'] === 'string' ? body['secretKey'] : undefined; - if (!token || !secret) { + if (!encToken || !encSecret) { throw new Error( - `mobile/management/login returned statusCode=${resp.data?.statusCode} ` + - `but openToken/secretKey missing. Body: ${JSON.stringify(resp.data?.body)}\n` + - `If field names differ, update pickString() calls in src/auth/token-exchange.ts.`, + `openUser/token returned statusCode=${resp.data?.statusCode} ` + + `but token/secretKey missing. Full response: ${JSON.stringify(resp.data)}`, ); } - return { token, secret }; + return { token: decryptField(encToken), secret: decryptField(encSecret) }; } catch (err) { if (axios.isAxiosError(err)) { const status = err.response?.status; const body = err.response?.data; throw new Error( - `Mobile login failed (HTTP ${status ?? 'unknown'}): ` + + `Credentials fetch failed (HTTP ${status ?? 'unknown'}): ` + (typeof body === 'object' ? JSON.stringify(body) : String(body ?? err.message)), ); } diff --git a/tests/auth/token-exchange.test.ts b/tests/auth/token-exchange.test.ts index 71092ee2..966cf1b6 100644 --- a/tests/auth/token-exchange.test.ts +++ b/tests/auth/token-exchange.test.ts @@ -21,44 +21,45 @@ function makeAxiosError(status: number, data: unknown) { } const TOKEN_RESP = { data: { access_token: 'tok-abc', token_type: 'Bearer' } }; -const MOBILE_RESP_OK = { - data: { statusCode: 100, body: { openToken: 'open-tok', secretKey: 'sec-key' } }, +// userinfo response +const USERINFO_RESP = { data: { statusCode: 100, body: { botRegion: 'us' } } }; +// Wonder API response with AES-encrypted placeholders (non-empty hex strings) +const OPEN_TOKEN_RESP = { + data: { statusCode: 100, body: { token: '00', secretKey: '00' } }, }; describe('exchangeCodeForCredentials — happy path', () => { beforeEach(() => { mockPost.mockReset(); - mockPost.mockResolvedValueOnce(TOKEN_RESP).mockResolvedValueOnce(MOBILE_RESP_OK); - }); - - it('returns { token, secret } on success', async () => { - const result = await exchangeCodeForCredentials('code-x', 'http://127.0.0.1:9000/callback'); - expect(result).toEqual({ token: 'open-tok', secret: 'sec-key' }); + mockPost + .mockResolvedValueOnce(TOKEN_RESP) + .mockResolvedValueOnce(USERINFO_RESP) + .mockResolvedValueOnce(OPEN_TOKEN_RESP); }); - it('calls the token endpoint first with correct params', async () => { - await exchangeCodeForCredentials('code-x', 'http://127.0.0.1:9000/callback'); - const [url, body] = mockPost.mock.calls[0] as [string, Record]; + it('calls token endpoint with form-encoded params', async () => { + await exchangeCodeForCredentials('code-x', 'http://127.0.0.1:53245/callback').catch(() => {}); + const [url, body] = mockPost.mock.calls[0] as [string, URLSearchParams]; expect(url).toContain('/merchant/v1/oauth/token'); - expect(body.grantType).toBe('authorization_code'); - expect(body.code).toBe('code-x'); - expect(body.redirectUri).toBe('http://127.0.0.1:9000/callback'); + expect(body.get('grant_type')).toBe('authorization_code'); + expect(body.get('code')).toBe('code-x'); + expect(body.get('redirect_uri')).toBe('http://127.0.0.1:53245/callback'); }); - it('passes access_token as Authorization header to mobile endpoint', async () => { - await exchangeCodeForCredentials('code-x', 'http://127.0.0.1:9000/callback'); + it('calls userinfo with access_token', async () => { + await exchangeCodeForCredentials('code-x', 'http://127.0.0.1:53245/callback').catch(() => {}); const [url, , config] = mockPost.mock.calls[1] as [string, unknown, { headers: Record }]; - expect(url).toContain('mobile/management/login'); + expect(url).toContain('/account/api/v1/user/userinfo'); expect(config.headers['Authorization']).toBe('tok-abc'); }); - it('accepts alternate field names token/secret in mobile response', async () => { - mockPost.mockReset(); - mockPost - .mockResolvedValueOnce(TOKEN_RESP) - .mockResolvedValueOnce({ data: { statusCode: 100, body: { token: 'alt-tok', secret: 'alt-sec' } } }); - const result = await exchangeCodeForCredentials('code-y', 'http://127.0.0.1:9000/callback'); - expect(result).toEqual({ token: 'alt-tok', secret: 'alt-sec' }); + it('calls Wonder API with correct region and access_token', async () => { + await exchangeCodeForCredentials('code-x', 'http://127.0.0.1:53245/callback').catch(() => {}); + const [url, body, config] = mockPost.mock.calls[2] as [string, Record, { headers: Record }]; + expect(url).toContain('wonderlabs.us.api.switchbot.net'); + expect(url).toContain('/openapi/openUser/token'); + expect(body['operation']).toBe('get'); + expect(config.headers['Authorization']).toBe('tok-abc'); }); }); @@ -67,39 +68,55 @@ describe('exchangeCodeForCredentials — token endpoint errors', () => { it('throws with HTTP status when token endpoint returns 4xx', async () => { mockPost.mockRejectedValueOnce(makeAxiosError(400, { error: 'invalid_grant' })); - await expect(exchangeCodeForCredentials('bad', 'http://127.0.0.1:9000/callback')) + await expect(exchangeCodeForCredentials('bad', 'http://127.0.0.1:53245/callback')) .rejects.toThrow('400'); }); it('throws when token response has no access_token', async () => { mockPost.mockResolvedValueOnce({ data: { token_type: 'Bearer' } }); - await expect(exchangeCodeForCredentials('code-z', 'http://127.0.0.1:9000/callback')) + await expect(exchangeCodeForCredentials('code-z', 'http://127.0.0.1:53245/callback')) .rejects.toThrow('access_token'); }); it('re-throws non-axios errors', async () => { mockPost.mockRejectedValueOnce(new Error('network failure')); - await expect(exchangeCodeForCredentials('code-z', 'http://127.0.0.1:9000/callback')) + await expect(exchangeCodeForCredentials('code-z', 'http://127.0.0.1:53245/callback')) .rejects.toThrow('network failure'); }); }); -describe('exchangeCodeForCredentials — mobile endpoint errors', () => { +describe('exchangeCodeForCredentials — Wonder API errors', () => { beforeEach(() => { mockPost.mockReset(); }); - it('throws with HTTP status when mobile endpoint returns 5xx', async () => { + it('throws with HTTP status when Wonder API returns 5xx', async () => { mockPost .mockResolvedValueOnce(TOKEN_RESP) + .mockResolvedValueOnce(USERINFO_RESP) .mockRejectedValueOnce(makeAxiosError(503, {})); - await expect(exchangeCodeForCredentials('code-x', 'http://127.0.0.1:9000/callback')) + await expect(exchangeCodeForCredentials('code-x', 'http://127.0.0.1:53245/callback')) .rejects.toThrow('503'); }); - it('throws when mobile response body is missing token fields', async () => { + it('throws when Wonder API response body is missing token fields', async () => { mockPost .mockResolvedValueOnce(TOKEN_RESP) + .mockResolvedValueOnce(USERINFO_RESP) .mockResolvedValueOnce({ data: { statusCode: 100, body: {} } }); - await expect(exchangeCodeForCredentials('code-x', 'http://127.0.0.1:9000/callback')) + await expect(exchangeCodeForCredentials('code-x', 'http://127.0.0.1:53245/callback')) .rejects.toThrow(); }); + + it('falls back to default region when userinfo fails', async () => { + mockPost + .mockResolvedValueOnce(TOKEN_RESP) + .mockRejectedValueOnce(new Error('userinfo network error')) + .mockResolvedValueOnce(OPEN_TOKEN_RESP); + // Should not throw due to userinfo error; uses default region + const [, , wonderCall] = mockPost.mock.calls; + await exchangeCodeForCredentials('code-x', 'http://127.0.0.1:53245/callback').catch(() => {}); + if (wonderCall) { + const [url] = wonderCall as [string]; + expect(url).toContain('wonderlabs.us.api.switchbot.net'); + } + }); }); From 60303f7700175a7f015be59ceaf26f8ad699cc6b Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 18:39:16 +0800 Subject: [PATCH 17/72] feat(ux): suggest `switchbot auth login` in no-credentials error message Co-Authored-By: Claude Sonnet 4.6 --- src/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index cb8062bc..c9b2b147 100644 --- a/src/config.ts +++ b/src/config.ts @@ -83,8 +83,8 @@ export function loadConfig(): SwitchBotConfig { if (!fs.existsSync(file)) { const profile = getActiveProfile(); const hint = profile - ? `No credentials configured for profile "${profile}". Run: switchbot --profile ${profile} config set-token ` - : 'No credentials configured. Run: switchbot config set-token '; + ? `No credentials configured for profile "${profile}". Run:\n switchbot auth login (browser-based login)\n switchbot --profile ${profile} config set-token (manual token)` + : 'No credentials configured. Run:\n switchbot auth login (browser-based login)\n switchbot config set-token (manual token)'; const msg = `${hint}\nOr set SWITCHBOT_TOKEN and SWITCHBOT_SECRET environment variables.`; if (isJsonMode()) { emitJsonError({ code: 1, kind: 'runtime', message: hint }); From adfea595e90e23eca69c4b2ac1c4c71eef7e1e5f Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 18:43:45 +0800 Subject: [PATCH 18/72] fix(ux): reformat no-credentials error as numbered choice list Avoids the previous multi-line format looking like sequential commands. Co-Authored-By: Claude Sonnet 4.6 --- src/config.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/config.ts b/src/config.ts index c9b2b147..653f8c2a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -82,10 +82,12 @@ export function loadConfig(): SwitchBotConfig { const file = configFilePath(); if (!fs.existsSync(file)) { const profile = getActiveProfile(); - const hint = profile - ? `No credentials configured for profile "${profile}". Run:\n switchbot auth login (browser-based login)\n switchbot --profile ${profile} config set-token (manual token)` - : 'No credentials configured. Run:\n switchbot auth login (browser-based login)\n switchbot config set-token (manual token)'; - const msg = `${hint}\nOr set SWITCHBOT_TOKEN and SWITCHBOT_SECRET environment variables.`; + const setToken = profile + ? `switchbot --profile ${profile} config set-token ` + : 'switchbot config set-token '; + const profileMsg = profile ? ` for profile "${profile}"` : ''; + const hint = `No credentials configured${profileMsg}.`; + const msg = `${hint} Choose one:\n 1. switchbot auth login (browser login)\n 2. ${setToken} (manual)\n 3. Set SWITCHBOT_TOKEN and SWITCHBOT_SECRET environment variables`; if (isJsonMode()) { emitJsonError({ code: 1, kind: 'runtime', message: hint }); } else { From 80c64a8d1fd1830ccba18662be0d9d55023e781c Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 19:30:58 +0800 Subject: [PATCH 19/72] fix(mcp): make list_devices roomID nullable in outputSchema (F-3) Co-Authored-By: Claude Sonnet 4.6 --- src/commands/mcp.ts | 2 +- tests/commands/mcp.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index d590ea26..a9cd711a 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -300,7 +300,7 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! deviceType: z.string().optional(), enableCloudService: z.boolean(), hubDeviceId: z.string(), - roomID: z.string().optional(), + roomID: z.string().nullable().optional(), roomName: z.string().nullable().optional(), familyName: z.string().optional(), controlType: z.string().optional(), diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index 33913df7..a741580f 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -1262,4 +1262,34 @@ describe('mcp server', () => { expect(sc.riskProfile.idempotencyHint).toBe('non-idempotent'); }); }); + + describe('list_devices — roomID nullable', () => { + it('succeeds when a device has roomID: null', async () => { + const { client } = await pair(); + apiMock.__instance.get.mockResolvedValueOnce({ + data: { + statusCode: 100, + body: { + deviceList: [ + { + deviceId: 'EEC6089351B7', + deviceName: 'Outdoor Meter', + deviceType: 'MeterOutdoor', + enableCloudService: true, + hubDeviceId: 'AABBCCDDEE01', + roomID: null, // ← THIS must not break validation + roomName: null, + }, + ], + infraredRemoteList: [], + }, + }, + }); + + const result = await client.callTool({ name: 'list_devices', arguments: {} }); + expect(result.isError).toBeFalsy(); + const sc = (result as { structuredContent: { deviceList: unknown[] } }).structuredContent; + expect(sc.deviceList).toHaveLength(1); + }); + }); }); From 968521362c65184a17a0e50ce62548cd7c1d3e59 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 19:34:16 +0800 Subject: [PATCH 20/72] =?UTF-8?q?fix(plan):=20compound=20keyword=20rules?= =?UTF-8?q?=20prevent=20'open=20the=20lock'=20=E2=86=92=20lock=20misfire?= =?UTF-8?q?=20(F-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/lib/command-keywords.ts | 4 ++++ tests/commands/plan-suggest.test.ts | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/lib/command-keywords.ts b/src/lib/command-keywords.ts index cf76ff2a..c9193e7d 100644 --- a/src/lib/command-keywords.ts +++ b/src/lib/command-keywords.ts @@ -1,4 +1,8 @@ export const COMMAND_KEYWORDS: Array<{ pattern: RegExp; command: string }> = [ + // Compound patterns FIRST — disambiguate open+lock before single-word rules fire + { pattern: /\bopen\b.{0,30}\block\b|\bunlock\b/i, command: 'unlock' }, + { pattern: /\bclose\b.{0,30}\block\b|\block\b.{0,30}\bclose\b/i, command: 'lock' }, + // Single-word rules { pattern: /\boff\b|\bturn.?off\b|\bstop\b/i, command: 'turnOff' }, { pattern: /\bon\b|\bturn.?on\b|\bstart\b/i, command: 'turnOn' }, { pattern: /\bpress\b|\bclick\b|\btap\b/i, command: 'press' }, diff --git a/tests/commands/plan-suggest.test.ts b/tests/commands/plan-suggest.test.ts index 385ba484..a62af4a7 100644 --- a/tests/commands/plan-suggest.test.ts +++ b/tests/commands/plan-suggest.test.ts @@ -95,4 +95,24 @@ describe('suggestPlan', () => { expect(plan.steps).toHaveLength(1); expect(plan.steps[0]).toMatchObject({ deviceId: 'LOCK-01', command: 'lock' }); }); + + it('infers unlock (not lock) from "open the lock"', () => { + const { plan } = suggestPlan({ intent: 'open the lock', devices: [{ id: 'D1' }] }); + expect(plan.steps[0]).toMatchObject({ command: 'unlock' }); + }); + + it('infers unlock from "open the front door lock"', () => { + const { plan } = suggestPlan({ intent: 'open the front door lock', devices: [{ id: 'D1' }] }); + expect(plan.steps[0]).toMatchObject({ command: 'unlock' }); + }); + + it('still infers lock from "lock the door"', () => { + const { plan } = suggestPlan({ intent: 'lock the door', devices: [{ id: 'D1' }] }); + expect(plan.steps[0]).toMatchObject({ command: 'lock' }); + }); + + it('still infers open from "open the curtains"', () => { + const { plan } = suggestPlan({ intent: 'open the curtains', devices: [{ id: 'D1' }] }); + expect(plan.steps[0]).toMatchObject({ command: 'open' }); + }); }); From 799f366fe09cc3efd54719d8e9be4930a18f202e Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 19:36:32 +0800 Subject: [PATCH 21/72] fix(quota): respect --dry-run in quota reset (D-1) --- src/commands/quota.ts | 6 ++++++ tests/commands/quota.test.ts | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/commands/quota.ts b/src/commands/quota.ts index d526efe4..eabf74c4 100644 --- a/src/commands/quota.ts +++ b/src/commands/quota.ts @@ -1,5 +1,6 @@ import { Command } from 'commander'; import { printJson, isJsonMode } from '../utils/output.js'; +import { isDryRun } from '../utils/flags.js'; import { DAILY_QUOTA, loadQuota, @@ -91,6 +92,11 @@ Examples: .command('reset') .description('Delete the local quota counter file') .action(() => { + if (isDryRun()) { + if (isJsonMode()) printJson({ dryRun: true, reset: false }); + else console.log('[dry-run] quota reset skipped — no files changed'); + return; + } resetQuota(); if (isJsonMode()) { printJson({ reset: true }); diff --git a/tests/commands/quota.test.ts b/tests/commands/quota.test.ts index c65a74e7..b6485277 100644 --- a/tests/commands/quota.test.ts +++ b/tests/commands/quota.test.ts @@ -101,4 +101,24 @@ describe('quota command', () => { expect(result.exitCode).toBeNull(); expect(JSON.parse(result.stdout[0])).toEqual({ schemaVersion: '1.2', data: { reset: true } }); }); + + it('reset --dry-run prints dry-run message and does NOT execute reset', async () => { + await seedQuota(); + const file = path.join(tmpRoot, '.switchbot', 'quota.json'); + expect(fs.existsSync(file)).toBe(true); + const result = await runCli(registerQuotaCommand, ['--dry-run', 'quota', 'reset']); + expect(result.exitCode).toBeNull(); + expect(result.stdout.join('\n')).toContain('dry-run'); + // File must still exist — dry-run must NOT have deleted it + expect(fs.existsSync(file)).toBe(true); + }); + + it('reset --dry-run --json returns { dryRun: true, reset: false }', async () => { + const result = await runCli(registerQuotaCommand, ['--dry-run', '--json', 'quota', 'reset']); + expect(result.exitCode).toBeNull(); + const parsed = JSON.parse(result.stdout[0]) as Record; + const data = (parsed.data ?? parsed) as Record; + expect(data.dryRun).toBe(true); + expect(data.reset).toBe(false); + }); }); From 7ad40dfa5b479095ab7fe808b1a545e14448ccc9 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 19:39:20 +0800 Subject: [PATCH 22/72] fix(mcp): strengthen plan_run result types in outputSchema (F-1) --- src/commands/mcp.ts | 24 +++++++++++++++++++++++- tests/commands/mcp.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index a9cd711a..f625cb2d 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -1616,7 +1616,29 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! outputSchema: { ran: z.boolean(), plan: z.unknown(), - results: z.array(z.unknown()), + results: z.array(z.discriminatedUnion('type', [ + z.object({ + step: z.number(), + type: z.literal('command'), + deviceId: z.string(), + command: z.string(), + status: z.enum(['ok', 'error', 'skipped']), + error: z.string().optional(), + }).passthrough(), + z.object({ + step: z.number(), + type: z.literal('scene'), + sceneId: z.string(), + status: z.enum(['ok', 'error']), + error: z.string().optional(), + }).passthrough(), + z.object({ + step: z.number(), + type: z.literal('wait'), + ms: z.number(), + status: z.literal('ok'), + }).passthrough(), + ])), summary: z.object({ total: z.number().int(), ok: z.number().int(), diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index a741580f..3b96a4ab 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -1292,4 +1292,36 @@ describe('mcp server', () => { expect(sc.deviceList).toHaveLength(1); }); }); + + describe('plan_run — outputSchema structuredContent shape', () => { + it('returns typed results array with step/type/status fields', async () => { + const { client } = await pair(); + // Mock the device command API call + apiMock.__instance.post.mockResolvedValueOnce({ data: { statusCode: 100, body: {} } }); + cacheMock.map.set('DEVICE1', { type: 'Bot', name: 'test bot', category: 'physical' }); + + const plan = { + version: '1.0', + description: 'test', + steps: [{ type: 'command', deviceId: 'DEVICE1', command: 'turnOn' }], + }; + + const result = await client.callTool({ + name: 'plan_run', + arguments: { plan }, + }); + + expect(result.isError).toBeFalsy(); + const sc = (result as { structuredContent: { + ran: boolean; + results: Array<{ step: number; type: string; status: string }>; + summary: { total: number; ok: number; error: number; skipped: number }; + } }).structuredContent; + + expect(sc.ran).toBe(true); + expect(sc.results).toHaveLength(1); + expect(sc.results[0]).toMatchObject({ step: 1, type: 'command', status: 'ok' }); + expect(sc.summary).toMatchObject({ total: 1, ok: 1, error: 0, skipped: 0 }); + }); + }); }); From d11d405af4c2ce76e2dde35e228c0710d2919eb8 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 19:43:29 +0800 Subject: [PATCH 23/72] fix(mcp): prefix error content text with human-readable summary (F-2) mcpError() now formats content[0].text as: error (code ): [hint line if present] --- structured --- { "error": { ... } } MCP clients that only show the first line now display a readable summary instead of raw JSON. structuredContent is unchanged. Updated existing tests to use parseErrorText() helper to extract the JSON block from after the --- structured --- marker. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/mcp.ts | 5 +++- tests/commands/mcp.test.ts | 47 ++++++++++++++++++++++++++++++++------ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index f625cb2d..f8bc3a96 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -110,9 +110,12 @@ function mcpError( if (options?.errorClass !== undefined) obj.errorClass = options.errorClass; if (options?.transient !== undefined) obj.transient = options.transient; if (options?.retryAfterMs !== undefined) obj.retryAfterMs = options.retryAfterMs; + const summary = `${kind} error (code ${code}): ${message}`; + const hintLine = options?.hint ? `\n${options.hint}` : ''; + const textBody = `${summary}${hintLine}\n--- structured ---\n${JSON.stringify({ error: obj }, null, 2)}`; return { isError: true as const, - content: [{ type: 'text' as const, text: JSON.stringify({ error: obj }, null, 2) }], + content: [{ type: 'text' as const, text: textBody }], structuredContent: { error: obj }, }; } diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index 3b96a4ab..6027721b 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -203,6 +203,15 @@ describe('mcp server', () => { } }); + /** Extract the structured JSON block from an mcpError content text. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function parseErrorText(text: string): any { + const marker = '--- structured ---\n'; + const idx = text.indexOf(marker); + if (idx === -1) return JSON.parse(text); // fallback for non-error text + return JSON.parse(text.slice(idx + marker.length)); + } + it('send_command rejects destructive commands without confirm:true', async () => { cacheMock.map.set('LOCK1', { type: 'Smart Lock', name: 'Front Door', category: 'physical' }); const { client } = await pair(); @@ -214,7 +223,7 @@ describe('mcp server', () => { expect(res.isError).toBe(true); const text = (res.content as Array<{ type: string; text: string }>)[0].text; - const parsed = JSON.parse(text); + const parsed = parseErrorText(text); expect(parsed.error.kind).toBe('guard'); expect(parsed.error.code).toBe(3); expect(parsed.error.context.command).toBe('unlock'); @@ -236,7 +245,7 @@ describe('mcp server', () => { }); expect(res.isError).toBe(true); - const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); + const parsed = parseErrorText((res.content as Array<{ text: string }>)[0].text); expect(parsed.error.kind).toBe('guard'); expect(parsed.error.hint).toMatch(/plan save|plan execute/); expect(apiMock.__instance.post).not.toHaveBeenCalled(); @@ -269,7 +278,7 @@ describe('mcp server', () => { }); expect(res.isError).toBe(true); - const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); + const parsed = parseErrorText((res.content as Array<{ text: string }>)[0].text); expect(parsed.error.kind).toBe('usage'); expect(parsed.error.code).toBe(2); expect(parsed.error.context.validationKind).toBe('unknown-command'); @@ -286,7 +295,7 @@ describe('mcp server', () => { }); expect(res.isError).toBe(true); - const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); + const parsed = parseErrorText((res.content as Array<{ text: string }>)[0].text); expect(parsed.error.kind).toBe('usage'); expect(parsed.error.context.validationKind).toBe('read-only-device'); expect(apiMock.__instance.post).not.toHaveBeenCalled(); @@ -720,9 +729,10 @@ describe('mcp server', () => { expect(sc?.error?.subKind).toBe('device-offline'); expect(sc?.error?.transient).toBe(false); expect(sc?.error?.hint).toMatch(/Hub/); - // content[0].text must still be a JSON string (backwards compat) + // content[0].text must start with human-readable summary; structured section must be valid JSON const text = (res.content as Array<{ type: string; text: string }>)[0].text; - expect(() => JSON.parse(text)).not.toThrow(); + expect(text).toMatch(/^(api|runtime|usage|guard) error \(code \d+\): /); + expect(() => parseErrorText(text)).not.toThrow(); }); it('describe_device preserves structured error metadata on ApiError (code 401 auth-failed)', async () => { @@ -764,6 +774,29 @@ describe('mcp server', () => { expect(sc?.error?.subKind).toBe('device-internal-error'); }); + describe('mcpError — content text format', () => { + it('content[0].text starts with human-readable summary line', async () => { + const { client } = await pair(); + // Trigger an API error by calling describe_device with a device ID that returns 404 + apiMock.__instance.get.mockRejectedValueOnce( + new ApiError('device not found', 190) + ); + + const result = await client.callTool({ + name: 'describe_device', + arguments: { deviceId: 'NONEXISTENT' }, + }); + + expect(result.isError).toBe(true); + const textContent = (result.content as Array<{ type: string; text: string }>).find( + (c) => c.type === 'text' + ); + expect(textContent).toBeDefined(); + // Must start with "api error (code" or similar pattern + expect(textContent!.text).toMatch(/^(api|runtime|usage|guard) error \(code \d+\): /); + }); + }); + describe('plan/audit tools', () => { it('plan_run skips destructive steps when yes is not set', async () => { cacheMock.map.set('LOCK1', { type: 'Smart Lock', name: 'Front Door', category: 'physical' }); @@ -803,7 +836,7 @@ describe('mcp server', () => { }); expect(res.isError).toBe(true); - const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); + const parsed = parseErrorText((res.content as Array<{ text: string }>)[0].text); expect(parsed.error.kind).toBe('guard'); expect(parsed.error.hint).toMatch(/plan save|plan execute/); }); From a6d78d8e17d5a5acfdd89cd1d8b21272d4299d91 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 19:47:07 +0800 Subject: [PATCH 24/72] fix(devices): apply --fields projection when --json shorthand is used (A-1) The --json fast path bypassed resolveFields(), so --fields was silently ignored. Now field projection is applied to both filtered and unfiltered device list output before calling printJson(). Co-Authored-By: Claude Sonnet 4.6 --- src/commands/devices.ts | 23 +++++++++++++++++++++-- tests/commands/devices.test.ts | 18 ++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/commands/devices.ts b/src/commands/devices.ts index dca077c0..2a78c8f6 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -200,6 +200,12 @@ Examples: }; if (fmt === 'json' && process.argv.includes('--json')) { + const jsonFields = resolveFields(); + const projectDevice = jsonFields + ? (obj: Record): Record => + Object.fromEntries(jsonFields.map((k) => [k, obj[k] ?? null])) + : null; + if (listClauses) { const filteredDeviceList = deviceList.filter((d) => matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '', family: d.familyName || '', hub: d.hubDeviceId || '', roomID: d.roomID || '', cloud: String(d.enableCloudService), alias: deviceMeta.devices[d.deviceId]?.alias || '' }) @@ -208,9 +214,22 @@ Examples: const inherited = hubLocation.get(d.hubDeviceId); return matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '', family: inherited?.family || '', hub: d.hubDeviceId || '', roomID: inherited?.roomID || '', cloud: '', alias: deviceMeta.devices[d.deviceId]?.alias || '' }); }); - printJson({ ok: true, deviceList: filteredDeviceList, infraredRemoteList: filteredIrList }); + printJson({ + ok: true, + deviceList: projectDevice ? filteredDeviceList.map((d) => projectDevice(d as unknown as Record)) : filteredDeviceList, + infraredRemoteList: projectDevice ? filteredIrList.map((d) => projectDevice(d as unknown as Record)) : filteredIrList, + }); } else { - printJson({ ok: true, ...(body as object) }); + const rawBody = body as { deviceList?: unknown[]; infraredRemoteList?: unknown[] }; + if (projectDevice && rawBody.deviceList) { + printJson({ + ok: true, + deviceList: rawBody.deviceList.map((d) => projectDevice(d as Record)), + infraredRemoteList: (rawBody.infraredRemoteList ?? []).map((d) => projectDevice(d as Record)), + }); + } else { + printJson({ ok: true, ...(body as object) }); + } } return; } diff --git a/tests/commands/devices.test.ts b/tests/commands/devices.test.ts index 27cb5486..ed134837 100644 --- a/tests/commands/devices.test.ts +++ b/tests/commands/devices.test.ts @@ -372,6 +372,24 @@ describe('devices command', () => { expect(out).not.toContain('| deviceName'); }); + it('applies --fields projection even when --json shorthand is used', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); + const result = await runCli( + registerDevicesCommand, + ['--json', '--fields', 'deviceId,deviceName', 'devices', 'list'], + ); + expect(result.exitCode).toBeNull(); + const parsed = JSON.parse(result.stdout.join('\n')) as Record; + const data = (parsed.data ?? parsed) as Record; + const deviceList = data.deviceList as Array>; + expect(deviceList).toHaveLength(3); + expect(deviceList[0]).toHaveProperty('deviceId'); + expect(deviceList[0]).toHaveProperty('deviceName'); + // Must NOT contain deviceType or hubDeviceId + expect(deviceList[0]).not.toHaveProperty('deviceType'); + expect(deviceList[0]).not.toHaveProperty('hubDeviceId'); + }); + it('prints "No devices found" when both lists are empty', async () => { apiMock.__instance.get.mockResolvedValue({ data: { body: { deviceList: [], infraredRemoteList: [] } }, From fa45c84ad84e7f908a94732a351d3770a00dd73c Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 19:51:12 +0800 Subject: [PATCH 25/72] fix(cli): validation failures exit 2 not 0 (E-1) Add regression tests confirming that empty deviceId, unsupported catalog command, missing required parameter, and empty scene ID all exit with code 2 rather than 0. All four validation paths were already correct (UsageError/StructuredUsageError/exitWithError with code:2); tests make this contract explicit and guard against regressions. Co-Authored-By: Claude Sonnet 4.6 --- tests/commands/devices.test.ts | 34 ++++++++++++++++++++++++++++++++++ tests/commands/scenes.test.ts | 11 +++++++++++ 2 files changed, 45 insertions(+) diff --git a/tests/commands/devices.test.ts b/tests/commands/devices.test.ts index ed134837..1acf4608 100644 --- a/tests/commands/devices.test.ts +++ b/tests/commands/devices.test.ts @@ -2929,4 +2929,38 @@ describe('devices command', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); }); + + // ===================================================================== + // E-1: validation failures must exit 2 + // ===================================================================== + describe('E-1 exit code — validation failures must exit 2', () => { + it('devices status with empty deviceId exits 2', async () => { + const result = await runCli(registerDevicesCommand, ['devices', 'status', '']); + expect(result.exitCode).toBe(2); + }); + + it('devices command with unsupported command (setColor on Relay Switch) exits 2', async () => { + updateCacheFromDeviceList({ + deviceList: [ + { deviceId: 'RELAY1', deviceName: 'relay', deviceType: 'Relay Switch 1PM', hubDeviceId: 'HUB-1', enableCloudService: true }, + ], + infraredRemoteList: [], + }); + const result = await runCli(registerDevicesCommand, + ['devices', 'command', 'RELAY1', 'setColor', '255:0:0']); + expect(result.exitCode).toBe(2); + }); + + it('devices command setBrightness with missing parameter exits 2', async () => { + updateCacheFromDeviceList({ + deviceList: [ + { deviceId: 'BULB1', deviceName: 'bulb', deviceType: 'Color Bulb', hubDeviceId: 'HUB-1', enableCloudService: true }, + ], + infraredRemoteList: [], + }); + const result = await runCli(registerDevicesCommand, + ['devices', 'command', 'BULB1', 'setBrightness']); + expect(result.exitCode).toBe(2); + }); + }); }); diff --git a/tests/commands/scenes.test.ts b/tests/commands/scenes.test.ts index 894499f9..a954031e 100644 --- a/tests/commands/scenes.test.ts +++ b/tests/commands/scenes.test.ts @@ -404,4 +404,15 @@ describe('scenes command', () => { expect((out.error as Record).message).toMatch(/scene not found/i); }); }); + + // ===================================================================== + // E-1: validation failures must exit 2 + // ===================================================================== + describe('E-1 exit code — scenes validation', () => { + it('scenes run with empty scene name/id exits 2', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: [] } }); + const result = await runCli(registerScenesCommand, ['scenes', 'run', '']); + expect(result.exitCode).toBe(2); + }); + }); }); From eee954613aa0c2999eb766c90c5ab37aa62c4e1e Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 19:53:38 +0800 Subject: [PATCH 26/72] fix(history): use query midpoint timestamp for no-bucket aggregation (F-5) Replace key=0 with midpoint of [fromMs, toMs] so the single bucket timestamp falls within the queried range instead of epoch 1970-01-01. Guards against infinite toMs (--since without --to) by clamping to now. Co-Authored-By: Claude Sonnet 4.6 --- src/devices/history-agg.ts | 4 +++- tests/devices/history-agg.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/devices/history-agg.ts b/src/devices/history-agg.ts index 98eb8113..3d1720f7 100644 --- a/src/devices/history-agg.ts +++ b/src/devices/history-agg.ts @@ -95,7 +95,9 @@ export async function aggregateDeviceHistory( const tMs = Date.parse(rec.t); if (!Number.isFinite(tMs) || tMs < fromMs || tMs > toMs) continue; - const key = bucketMs !== null ? Math.floor(tMs / bucketMs) * bucketMs : 0; + const key = bucketMs !== null + ? Math.floor(tMs / bucketMs) * bucketMs + : Math.floor((fromMs + (Number.isFinite(toMs) ? toMs : Date.now())) / 2); let bkt = buckets.get(key); if (!bkt) { bkt = new Map(); buckets.set(key, bkt); } diff --git a/tests/devices/history-agg.test.ts b/tests/devices/history-agg.test.ts index 758d9968..8bad61d7 100644 --- a/tests/devices/history-agg.test.ts +++ b/tests/devices/history-agg.test.ts @@ -211,6 +211,29 @@ describe('aggregateDeviceHistory — single bucket', () => { expect(res.buckets[0].metrics.temperature.count).toBe(2); }); + it('when bucket is omitted, single bucket t is within the query range (not epoch)', async () => { + const file = path.join(historyDir, 'DEV2.jsonl'); + writeJsonl(file, [ + { t: '2026-04-19T10:00:00.000Z', topic: 'status', payload: { temperature: 21 } }, + { t: '2026-04-19T14:00:00.000Z', topic: 'status', payload: { temperature: 23 } }, + ]); + + const res = await aggregateDeviceHistory('DEV2', { + from: '2026-04-19T00:00:00.000Z', + to: '2026-04-20T00:00:00.000Z', + metrics: ['temperature'], + aggs: ['avg'], + }); + + expect(res.buckets).toHaveLength(1); + const bucketT = new Date(res.buckets[0].t).getTime(); + // Must NOT be epoch (year 1970) + expect(bucketT).toBeGreaterThan(new Date('2026-01-01').getTime()); + // Must be within the queried range + expect(bucketT).toBeGreaterThanOrEqual(new Date('2026-04-19T00:00:00.000Z').getTime()); + expect(bucketT).toBeLessThanOrEqual(new Date('2026-04-20T00:00:00.000Z').getTime()); + }); + it('returns empty buckets for an unknown device', async () => { const res = await aggregateDeviceHistory('does-not-exist', { from: '2026-04-19T00:00:00.000Z', From a3963567683c89d379021f9a05198ab7a6223a97 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 19:58:34 +0800 Subject: [PATCH 27/72] fix(devices): fetchedAt reflects API fetch time, not render time (C-1) Add getCachedStatusEntry() to cache.ts that returns { body, fetchedAt } with the stored timestamp. Update the single-device status path in commands/devices.ts to use the stored fetchedAt when a cache entry is present, so cached responses show the original fetch time rather than the current render time. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/devices.ts | 13 +++++++++---- src/devices/cache.ts | 16 ++++++++++++++++ tests/commands/devices.test.ts | 26 +++++++++++++++++++++++++- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/commands/devices.ts b/src/commands/devices.ts index 2a78c8f6..724e9937 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -9,7 +9,7 @@ import { getCommandSafetyReason, DeviceCatalogEntry, } from '../devices/catalog.js'; -import { getCachedDevice, loadCache } from '../devices/cache.js'; +import { getCachedDevice, loadCache, getCachedStatusEntry } from '../devices/cache.js'; import { loadDeviceMeta } from '../devices/device-meta.js'; import { resolveDeviceId, NameResolveStrategy, ALL_STRATEGIES } from '../utils/name-resolver.js'; import { @@ -31,7 +31,7 @@ import { registerWatchCommand } from './watch.js'; import { registerExplainCommand } from './explain.js'; import { registerExpandCommand } from './expand.js'; import { registerDevicesMetaCommand } from './device-meta.js'; -import { isDryRun } from '../utils/flags.js'; +import { isDryRun, getCacheMode } from '../utils/flags.js'; import { DryRunSignal } from '../api/client.js'; import { resolveField, resolveFieldList, listSupportedFieldInputs } from '../schema/field-aliases.js'; import { allowsDirectDestructiveExecution, destructiveExecutionHint } from '../lib/destructive-mode.js'; @@ -403,8 +403,13 @@ Examples: // so there is nothing to translate into a non-zero exit code. console.error('warning: --strict has no effect without --ids or multiple device IDs (batch mode only)'); } - const body = annotateStatusPayload(deviceId, await fetchDeviceStatus(deviceId)); - const fetchedAt = new Date().toISOString(); + const mode = getCacheMode(); + const cacheEntry = mode.statusTtlMs > 0 + ? getCachedStatusEntry(deviceId, mode.statusTtlMs) + : null; + const rawStatus = cacheEntry ? cacheEntry.body : await fetchDeviceStatus(deviceId); + const fetchedAt = cacheEntry ? cacheEntry.fetchedAt : new Date().toISOString(); + const body = annotateStatusPayload(deviceId, rawStatus); const fmt = resolveFormat(); if (fmt === 'json' && process.argv.includes('--json')) { diff --git a/src/devices/cache.ts b/src/devices/cache.ts index 2d536fc1..f6847c60 100644 --- a/src/devices/cache.ts +++ b/src/devices/cache.ts @@ -297,6 +297,22 @@ export function getCachedStatus( return entry.body; } +/** Read a status entry with its stored fetchedAt timestamp; null when missing or expired. */ +export function getCachedStatusEntry( + deviceId: string, + ttlMs: number, + now = Date.now() +): { body: Record; fetchedAt: string } | null { + if (!ttlMs || ttlMs <= 0) return null; + const cache = loadStatusCache(); + const entry = cache.entries[deviceId]; + if (!entry) return null; + const ts = Date.parse(entry.fetchedAt); + if (!Number.isFinite(ts)) return null; + if (now - ts >= ttlMs) return null; + return { body: entry.body, fetchedAt: entry.fetchedAt }; +} + /** Evict status entries older than max(ttlMs × 10, 24 h) to bound file growth. */ function evictExpiredStatusEntries(cache: StatusCache, ttlMs: number, now = Date.now()): void { const cutoff = now - Math.max(ttlMs * 10, 24 * 60 * 60 * 1000); diff --git a/tests/commands/devices.test.ts b/tests/commands/devices.test.ts index 1acf4608..069e2dd1 100644 --- a/tests/commands/devices.test.ts +++ b/tests/commands/devices.test.ts @@ -38,7 +38,7 @@ vi.mock('../../src/api/client.js', () => ({ import { registerDevicesCommand } from '../../src/commands/devices.js'; import { ApiError } from '../../src/api/client.js'; import { runCli } from '../helpers/cli.js'; -import { updateCacheFromDeviceList, resetListCache } from '../../src/devices/cache.js'; +import { updateCacheFromDeviceList, resetListCache, setCachedStatus, resetStatusCache } from '../../src/devices/cache.js'; // ---- Helpers ----------------------------------------------------------- const DID = 'DEV-ID'; @@ -132,6 +132,7 @@ describe('devices command', () => { afterEach(() => { vi.restoreAllMocks(); resetListCache(); + resetStatusCache(); try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { @@ -1077,6 +1078,29 @@ describe('devices command', () => { expect(res.stdout.join('\n').split('\n')[0]).toBe('humidity\tpower\tbattery'); }); }); + + describe('fetchedAt from cache (C-1)', () => { + it('fetchedAt reflects stored cache timestamp, not render time', async () => { + // Use a timestamp in the past but within the 1h TTL window + const storedDate = new Date(Date.now() - 5 * 60 * 1000); // 5 minutes ago + const storedTime = storedDate.toISOString(); + const cachedBody = { power: 'on', battery: 90 }; + // Seed the in-memory status cache with a fixed past timestamp + setCachedStatus('CACHED-DEV', cachedBody, storedDate); + + // No HTTP call should be needed; the cache should be used + const res = await runCli(registerDevicesCommand, [ + '--cache', '1h', + 'devices', 'status', 'CACHED-DEV', '--json', + ]); + expect(apiMock.__instance.get).not.toHaveBeenCalledWith( + '/v1.1/devices/CACHED-DEV/status' + ); + const out = JSON.parse(res.stdout.join('\n')); + const data: Record = out.data ?? out; + expect(data.fetchedAt).toBe(storedTime); + }); + }); }); // ===================================================================== From 1d95b466d6ed67f1a67ead0d4d666ed95f59c09d Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 20:00:40 +0800 Subject: [PATCH 28/72] docs(devices): document --cache global flag placement in subcommand help (C-2) --- src/commands/devices.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/commands/devices.ts b/src/commands/devices.ts index 724e9937..ecf10653 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -137,6 +137,11 @@ Examples: $ switchbot devices list --filter name=living,category=physical $ switchbot devices list --filter 'name~living' # explicit substring $ switchbot devices list --filter 'type=/Air.*/' # regex (case-insensitive) + +Cache note: + --cache is a global flag and must be placed BEFORE the subcommand: + switchbot --cache 5m devices list ✓ + switchbot devices list --cache 5m ✗ (silently ignored) `) .option('--wide', 'Show all columns (controlType, family, roomID, room, hub, cloud)') .option('--show-hidden', 'Include devices hidden via "devices meta set --hide"') @@ -342,6 +347,11 @@ Examples: $ switchbot devices status ABC123DEF456 --json | jq '.data.battery' $ switchbot devices status --ids ABC123,DEF456,GHI789 $ switchbot devices status --ids ABC123,DEF456 --fields power,battery + +Cache note: + --cache is a global flag and must be placed BEFORE the subcommand: + switchbot --cache 5m devices status ✓ + switchbot devices status --cache 5m ✗ (silently ignored) `) .action(async (deviceIdArgs: string[], options: { name?: string; nameStrategy?: string; nameType?: string; nameCategory?: 'physical' | 'ir'; nameRoom?: string; ids?: string; strict?: boolean }) => { try { From 4dfd3751f4842b48a1de6959f7e1a0cada2e4578 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 20:02:23 +0800 Subject: [PATCH 29/72] docs(history): add --cache global flag placement note to history help --- src/commands/history.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/commands/history.ts b/src/commands/history.ts index 77e5a955..ffa56364 100644 --- a/src/commands/history.ts +++ b/src/commands/history.ts @@ -28,6 +28,11 @@ Every 'devices command' run with --audit-log is appended as JSONL to the audit file (default ~/.switchbot/audit.log). 'history show' prints the file, 'history replay ' re-runs the Nth entry (1-indexed, most-recent last). +Cache note: + --cache is a global flag and must be placed BEFORE the subcommand: + switchbot --cache 5m history range ✓ + switchbot history range --cache 5m ✗ (silently ignored) + Examples: $ switchbot --audit-log devices command turnOff $ switchbot history show --limit 10 From 656c846f86f42d8fc77cce74bfcdc9a2142619ec Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 20:06:14 +0800 Subject: [PATCH 30/72] fix(tests): update dry-run tests to parse new mcpError text format (F-2 follow-up) Add parseErrorText() helper and update 4 error-response assertions to extract JSON from after the '--- structured ---' delimiter introduced by the F-2 mcpError prefix change. Co-Authored-By: Claude Sonnet 4.6 --- tests/commands/dry-run.test.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/commands/dry-run.test.ts b/tests/commands/dry-run.test.ts index 34d2bbbb..c2690f15 100644 --- a/tests/commands/dry-run.test.ts +++ b/tests/commands/dry-run.test.ts @@ -59,6 +59,15 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { createSwitchBotMcpServer } from '../../src/commands/mcp.js'; +/** Extract the structured JSON block from an mcpError content text. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function parseErrorText(text: string): any { + const marker = '--- structured ---\n'; + const idx = text.indexOf(marker); + if (idx === -1) return JSON.parse(text); // fallback for non-error text + return JSON.parse(text.slice(idx + marker.length)); +} + async function pair() { const server = createSwitchBotMcpServer(); const client = new Client({ name: 'test', version: '0.0.1' }); @@ -158,7 +167,7 @@ describe('dryRun support on mutating tools', () => { }); expect(res.isError).toBe(true); - const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); + const parsed = parseErrorText((res.content as Array<{ text: string }>)[0].text); expect(parsed.error.context?.validationKind).toBe('unknown-command'); expect(apiMock.__instance.post).not.toHaveBeenCalled(); }); @@ -204,7 +213,7 @@ describe('dryRun support on mutating tools', () => { }); expect(res.isError).toBe(true); - const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); + const parsed = parseErrorText((res.content as Array<{ text: string }>)[0].text); expect(parsed.error.context?.validationKind).toBe('read-only-device'); expect(apiMock.__instance.post).not.toHaveBeenCalled(); }); @@ -219,7 +228,7 @@ describe('dryRun support on mutating tools', () => { }); expect(res.isError).toBe(true); - const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); + const parsed = parseErrorText((res.content as Array<{ text: string }>)[0].text); expect(parsed.error.context?.validationKind).toBe('unknown-command'); expect(parsed.error.hint).toContain('turnOn'); expect(parsed.error.hint).toContain('press'); @@ -272,7 +281,7 @@ describe('dryRun support on mutating tools', () => { }); expect(res.isError).toBe(true); - const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); + const parsed = parseErrorText((res.content as Array<{ text: string }>)[0].text); expect(parsed.error.subKind).toBe('scene-not-found'); expect(parsed.error.message).toContain('FAKE'); expect(apiMock.__instance.post).not.toHaveBeenCalled(); From 63fd2da7244c5db0032e16ff6ea3ecd815f627e9 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 20:19:55 +0800 Subject: [PATCH 31/72] =?UTF-8?q?docs(spec):=20MCP=20test=20coverage=20gap?= =?UTF-8?q?=20design=20=E2=80=94=20boundary=20tests=20+=20error=20format?= =?UTF-8?q?=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-05-21-mcp-test-coverage-design.md | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-21-mcp-test-coverage-design.md diff --git a/docs/superpowers/specs/2026-05-21-mcp-test-coverage-design.md b/docs/superpowers/specs/2026-05-21-mcp-test-coverage-design.md new file mode 100644 index 00000000..bf60c5b1 --- /dev/null +++ b/docs/superpowers/specs/2026-05-21-mcp-test-coverage-design.md @@ -0,0 +1,102 @@ +# MCP Test Coverage Gap Design + +**Date:** 2026-05-21 +**Context:** After fixing 10 smoke-test bugs, two testing patterns were identified that allowed type bugs to slip through. This spec adds targeted tests to close both gaps. + +--- + +## Root Cause Summary + +### A: Schema boundary gap (caused F-3) + +`list_devices` and other MCP tools declare nullable/optional fields in their Zod outputSchema (e.g. `roomID: z.string().nullable().optional()`). All test mocks use "happy path" data — fields either have a value or are omitted. No mock ever passes `null` through the outputSchema validation path. When the real API returns `null` for `roomID`, Zod rejects the value and the entire tool call fails. + +### B: Implicit error format contract (caused F-2 follow-up breakage) + +`mcpError()` in `src/commands/mcp.ts` returns `content[0].text` in a specific multi-line format: +``` + error (code N): +[optional hint] +--- structured --- +{ "error": { ... } } +``` + +No test pins this format. When T5 changed the format from raw JSON to this new layout, `mcp.test.ts` was updated but `dry-run.test.ts` was not — because `parseErrorText()` lived only inside `mcp.test.ts` and the two files had diverged in their parsing assumptions. + +--- + +## Design + +### Component 1: `tests/mcp/output-schema-boundary.test.ts` (new file) + +**Purpose:** Verify that Zod outputSchema validation passes for all realistic API data shapes, including nullable fields and fully-omitted optional fields. + +**What it tests:** +- `list_devices` with a physical device where `roomID: null` and `roomName: null` +- `list_devices` with a physical device where all optional fields are omitted (`deviceType`, `roomID`, `roomName`, `familyName`, `controlType` all absent) +- `list_devices` with an IR device where `controlType: null` +- `list_devices` with a mixed payload: one device with nulls + one without + +**Pattern:** Uses the same `pair()` / `apiMock.__instance.get.mockResolvedValueOnce()` pattern already established in `tests/commands/mcp.test.ts`. Each test asserts `result.isError` is falsy and `structuredContent.deviceList` has the expected length. A Zod rejection would surface as `isError: true`. + +**Why a new file (not extending mcp.test.ts):** `mcp.test.ts` is 1300+ lines. Boundary validation tests belong alongside the existing `tool-schema-completeness.test.ts` and `tool-meta.test.ts` in `tests/mcp/` — that directory is already the home for "schema contract" tests. + +--- + +### Component 2: `tests/helpers/mcp-test-utils.ts` (new file) + +**Purpose:** Single source of truth for MCP error response parsing, shared across all test files. + +**Exports:** +```typescript +/** Extract the structured JSON from an mcpError content[0].text response. */ +export function parseErrorText(text: string): unknown { + const marker = '--- structured ---\n'; + const idx = text.indexOf(marker); + if (idx === -1) return JSON.parse(text); // fallback: old format or non-error + return JSON.parse(text.slice(idx + marker.length)); +} +``` + +**Migration:** Update `tests/commands/mcp.test.ts` and `tests/commands/dry-run.test.ts` to import `parseErrorText` from this helper instead of defining/inlining it. The function signatures and behavior are identical — this is a pure extract-and-import refactor, no behavioral change. + +--- + +### Component 3: `tests/mcp/error-format-contract.test.ts` (new file) + +**Purpose:** Pin the exact text format of `mcpError()` output. Any change to the format in `src/commands/mcp.ts` must update this test, making the implicit contract explicit. + +**What it tests:** +- Calls a tool that will return an error (e.g. `describe_device` with an ID that causes the mock API to reject with `ApiError`) +- Asserts `result.isError === true` +- Asserts `content[0].text` matches the summary line format: `/^(api|runtime|usage|guard) error \(code \d+\): /` +- Asserts `content[0].text` contains the `--- structured ---` separator +- Asserts the text after `--- structured ---\n` is valid JSON with an `error` key +- Asserts `structuredContent.error` exists (the parallel structured payload is unchanged) + +**Why a separate file:** This is explicitly a "contract test" — it documents an interface boundary, not a feature behavior. Placing it in `tests/mcp/` next to `tool-schema-completeness.test.ts` makes the intent clear. + +--- + +## Files Changed + +| File | Action | Purpose | +|---|---|---| +| `tests/mcp/output-schema-boundary.test.ts` | Create | Boundary value tests for nullable/optional fields | +| `tests/helpers/mcp-test-utils.ts` | Create | Shared `parseErrorText` helper | +| `tests/commands/mcp.test.ts` | Modify | Import `parseErrorText` from shared helper | +| `tests/commands/dry-run.test.ts` | Modify | Import `parseErrorText` from shared helper | +| `tests/mcp/error-format-contract.test.ts` | Create | Contract test for `mcpError()` text format | + +No production source files are changed. All changes are in test infrastructure. + +--- + +## Verification + +After implementation: + +1. `npm test` — full suite passes, no regressions +2. Manually break the format: change `mcpError()` to return `JSON.stringify({error:obj})` as text (the old format) → `error-format-contract.test.ts` should fail immediately +3. Manually break a schema: change `roomID: z.string().nullable().optional()` back to `z.string().optional()` → `output-schema-boundary.test.ts` should fail on the `roomID: null` test case +4. Confirm `parseErrorText` is no longer defined inline in either `mcp.test.ts` or `dry-run.test.ts` From 044522ddbcbd24b751052fbbf3f2bcc49b9d5ec8 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 20:26:08 +0800 Subject: [PATCH 32/72] refactor(tests): extract parseErrorText to shared mcp-test-utils helper Co-Authored-By: Claude Sonnet 4.6 --- tests/commands/dry-run.test.ts | 10 +--------- tests/commands/mcp.test.ts | 10 +--------- tests/helpers/mcp-test-utils.ts | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+), 18 deletions(-) create mode 100644 tests/helpers/mcp-test-utils.ts diff --git a/tests/commands/dry-run.test.ts b/tests/commands/dry-run.test.ts index c2690f15..7a0f3370 100644 --- a/tests/commands/dry-run.test.ts +++ b/tests/commands/dry-run.test.ts @@ -4,6 +4,7 @@ * and must NOT call the API mock. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { parseErrorText } from '../helpers/mcp-test-utils.js'; // --------------------------------------------------------------------------- // Mocks @@ -59,15 +60,6 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { createSwitchBotMcpServer } from '../../src/commands/mcp.js'; -/** Extract the structured JSON block from an mcpError content text. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function parseErrorText(text: string): any { - const marker = '--- structured ---\n'; - const idx = text.indexOf(marker); - if (idx === -1) return JSON.parse(text); // fallback for non-error text - return JSON.parse(text.slice(idx + marker.length)); -} - async function pair() { const server = createSwitchBotMcpServer(); const client = new Client({ name: 'test', version: '0.0.1' }); diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index 6027721b..3de7ca0e 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { parseErrorText } from '../helpers/mcp-test-utils.js'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -203,15 +204,6 @@ describe('mcp server', () => { } }); - /** Extract the structured JSON block from an mcpError content text. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function parseErrorText(text: string): any { - const marker = '--- structured ---\n'; - const idx = text.indexOf(marker); - if (idx === -1) return JSON.parse(text); // fallback for non-error text - return JSON.parse(text.slice(idx + marker.length)); - } - it('send_command rejects destructive commands without confirm:true', async () => { cacheMock.map.set('LOCK1', { type: 'Smart Lock', name: 'Front Door', category: 'physical' }); const { client } = await pair(); diff --git a/tests/helpers/mcp-test-utils.ts b/tests/helpers/mcp-test-utils.ts new file mode 100644 index 00000000..1778abd7 --- /dev/null +++ b/tests/helpers/mcp-test-utils.ts @@ -0,0 +1,22 @@ +/** + * Shared test utilities for MCP tool tests. + */ + +/** + * Extract the structured JSON block from an mcpError content[0].text. + * + * mcpError() formats text as: + * error (code N): + * [optional hint] + * --- structured --- + * { "error": { ... } } + * + * Falls back to JSON.parse(text) for non-error text (no delimiter present). + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function parseErrorText(text: string): any { + const marker = '--- structured ---\n'; + const idx = text.indexOf(marker); + if (idx === -1) return JSON.parse(text); + return JSON.parse(text.slice(idx + marker.length)); +} From 68a103e103aa63f008928d6dfaef78023446cf19 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 20:27:23 +0800 Subject: [PATCH 33/72] test(mcp): add outputSchema boundary tests for nullable/optional fields Also fixes the same class of bug discovered by the new tests: `controlType` in both `deviceList` and `infraredRemoteList` schemas was `z.string().optional()` which rejects `null` from the real API; changed to `.nullable().optional()` to match the F-3 fix pattern applied to `roomID`/`roomName`. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/mcp.ts | 4 +- tests/mcp/output-schema-boundary.test.ts | 164 +++++++++++++++++++++++ 2 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 tests/mcp/output-schema-boundary.test.ts diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index f8bc3a96..fa6617dc 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -306,14 +306,14 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! roomID: z.string().nullable().optional(), roomName: z.string().nullable().optional(), familyName: z.string().optional(), - controlType: z.string().optional(), + controlType: z.string().nullable().optional(), }).passthrough()).describe('Physical SwitchBot devices'), infraredRemoteList: z.array(z.object({ deviceId: z.string(), deviceName: z.string(), remoteType: z.string(), hubDeviceId: z.string(), - controlType: z.string().optional(), + controlType: z.string().nullable().optional(), }).passthrough()).describe('IR remote devices'), }, }, diff --git a/tests/mcp/output-schema-boundary.test.ts b/tests/mcp/output-schema-boundary.test.ts new file mode 100644 index 00000000..f438b4f6 --- /dev/null +++ b/tests/mcp/output-schema-boundary.test.ts @@ -0,0 +1,164 @@ +/** + * MCP outputSchema boundary tests — verify that Zod validation in list_devices + * accepts all realistic API data shapes, including nullable/optional fields. + * + * If the outputSchema ever tightens a field that the API returns as null, + * these tests catch it before it ships. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const apiMock = vi.hoisted(() => { + const instance = { get: vi.fn(), post: vi.fn() }; + return { createClient: vi.fn(() => instance), __instance: instance }; +}); + +vi.mock('../../src/api/client.js', () => ({ + createClient: apiMock.createClient, + ApiError: class ApiError extends Error { + constructor(message: string, public readonly code: number) { + super(message); + this.name = 'ApiError'; + } + }, + DryRunSignal: class DryRunSignal extends Error { + constructor(public readonly method: string, public readonly url: string) { + super('dry-run'); + this.name = 'DryRunSignal'; + } + }, +})); + +vi.mock('../../src/devices/cache.js', () => ({ + getCachedDevice: vi.fn(() => null), + updateCacheFromDeviceList: vi.fn(), + loadCache: vi.fn(() => null), + clearCache: vi.fn(), + isListCacheFresh: vi.fn(() => false), + listCacheAgeMs: vi.fn(() => null), + getCachedStatus: vi.fn(() => null), + setCachedStatus: vi.fn(), + clearStatusCache: vi.fn(), + loadStatusCache: vi.fn(() => ({ entries: {} })), + describeCache: vi.fn(() => ({ + list: { path: '', exists: false }, + status: { path: '', exists: false, entryCount: 0 }, + })), + getCachedStatusEntry: vi.fn(() => null), +})); + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { createSwitchBotMcpServer } from '../../src/commands/mcp.js'; + +async function pair() { + const server = createSwitchBotMcpServer({ toolProfile: 'all' }); + const client = new Client({ name: 'boundary-test', version: '0.0.0' }); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverT), client.connect(clientT)]); + return { client }; +} + +function makeApiResponse(deviceList: unknown[], infraredRemoteList: unknown[] = []) { + return { + data: { + statusCode: 100, + body: { deviceList, infraredRemoteList }, + }, + }; +} + +describe('list_devices outputSchema — nullable/optional field boundaries', () => { + beforeEach(() => { + apiMock.__instance.get.mockReset(); + }); + + it('accepts a physical device with roomID: null and roomName: null', async () => { + const { client } = await pair(); + apiMock.__instance.get.mockResolvedValueOnce(makeApiResponse([ + { + deviceId: 'OUTDOOR1', + deviceName: 'Outdoor Meter', + deviceType: 'MeterOutdoor', + enableCloudService: true, + hubDeviceId: 'HUB001', + roomID: null, + roomName: null, + }, + ])); + + const result = await client.callTool({ name: 'list_devices', arguments: {} }); + expect(result.isError, 'roomID: null should not fail Zod validation').toBeFalsy(); + const sc = (result as { structuredContent: { deviceList: unknown[] } }).structuredContent; + expect(sc.deviceList).toHaveLength(1); + }); + + it('accepts a physical device with all optional fields omitted', async () => { + const { client } = await pair(); + apiMock.__instance.get.mockResolvedValueOnce(makeApiResponse([ + { + deviceId: 'MINIMAL1', + deviceName: 'Minimal Device', + enableCloudService: false, + hubDeviceId: '000000000000', + // deviceType, roomID, roomName, familyName, controlType all omitted + }, + ])); + + const result = await client.callTool({ name: 'list_devices', arguments: {} }); + expect(result.isError, 'all optional fields omitted should pass Zod validation').toBeFalsy(); + const sc = (result as { structuredContent: { deviceList: unknown[] } }).structuredContent; + expect(sc.deviceList).toHaveLength(1); + }); + + it('accepts an IR device with controlType: null', async () => { + const { client } = await pair(); + apiMock.__instance.get.mockResolvedValueOnce(makeApiResponse( + [], + [ + { + deviceId: 'IR001', + deviceName: 'IR TV', + remoteType: 'TV', + hubDeviceId: 'HUB001', + controlType: null, + }, + ] + )); + + const result = await client.callTool({ name: 'list_devices', arguments: {} }); + expect(result.isError, 'IR device controlType: null should pass Zod validation').toBeFalsy(); + const sc = (result as { structuredContent: { infraredRemoteList: unknown[] } }).structuredContent; + expect(sc.infraredRemoteList).toHaveLength(1); + }); + + it('accepts a mixed payload: one device with nulls + one fully populated', async () => { + const { client } = await pair(); + apiMock.__instance.get.mockResolvedValueOnce(makeApiResponse([ + { + deviceId: 'OUTDOOR2', + deviceName: 'Outdoor Sensor', + deviceType: 'MeterOutdoor', + enableCloudService: true, + hubDeviceId: 'HUB001', + roomID: null, + roomName: null, + }, + { + deviceId: 'INDOOR1', + deviceName: 'Living Room Light', + deviceType: 'Color Bulb', + enableCloudService: true, + hubDeviceId: 'HUB001', + roomID: 'room-123', + roomName: 'Living Room', + familyName: 'Home', + controlType: 'light', + }, + ])); + + const result = await client.callTool({ name: 'list_devices', arguments: {} }); + expect(result.isError, 'mixed null/populated devices should pass Zod validation').toBeFalsy(); + const sc = (result as { structuredContent: { deviceList: unknown[] } }).structuredContent; + expect(sc.deviceList).toHaveLength(2); + }); +}); From 8e99c15a7c9b050b9722e3f570ed87100468b42f Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 20:29:31 +0800 Subject: [PATCH 34/72] test(mcp): add error-format-contract test to pin mcpError() text structure --- tests/mcp/error-format-contract.test.ts | 139 ++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 tests/mcp/error-format-contract.test.ts diff --git a/tests/mcp/error-format-contract.test.ts b/tests/mcp/error-format-contract.test.ts new file mode 100644 index 00000000..5b041e41 --- /dev/null +++ b/tests/mcp/error-format-contract.test.ts @@ -0,0 +1,139 @@ +/** + * Contract test for mcpError() content[0].text format. + * + * mcpError() MUST produce text in exactly this format: + * error (code ): + * [optional hint line] + * --- structured --- + * { "error": { ... } } + * + * This test pins that contract. If the format changes in src/commands/mcp.ts, + * this test fails — forcing explicit updates to all consumers. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const apiMock = vi.hoisted(() => { + const instance = { get: vi.fn(), post: vi.fn() }; + return { createClient: vi.fn(() => instance), __instance: instance }; +}); + +vi.mock('../../src/api/client.js', () => ({ + createClient: apiMock.createClient, + ApiError: class ApiError extends Error { + constructor(message: string, public readonly code: number) { + super(message); + this.name = 'ApiError'; + } + }, + DryRunSignal: class DryRunSignal extends Error { + constructor(public readonly method: string, public readonly url: string) { + super('dry-run'); + this.name = 'DryRunSignal'; + } + }, +})); + +vi.mock('../../src/devices/cache.js', () => ({ + getCachedDevice: vi.fn(() => null), + updateCacheFromDeviceList: vi.fn(), + loadCache: vi.fn(() => null), + clearCache: vi.fn(), + isListCacheFresh: vi.fn(() => false), + listCacheAgeMs: vi.fn(() => null), + getCachedStatus: vi.fn(() => null), + setCachedStatus: vi.fn(), + clearStatusCache: vi.fn(), + loadStatusCache: vi.fn(() => ({ entries: {} })), + describeCache: vi.fn(() => ({ + list: { path: '', exists: false }, + status: { path: '', exists: false, entryCount: 0 }, + })), + getCachedStatusEntry: vi.fn(() => null), +})); + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { createSwitchBotMcpServer } from '../../src/commands/mcp.js'; +import { ApiError } from '../../src/api/client.js'; +import { parseErrorText } from '../helpers/mcp-test-utils.js'; + +async function pair() { + const server = createSwitchBotMcpServer({ toolProfile: 'all' }); + const client = new Client({ name: 'error-format-test', version: '0.0.0' }); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverT), client.connect(clientT)]); + return { client }; +} + +describe('mcpError() content text format contract', () => { + beforeEach(() => { + apiMock.__instance.get.mockReset(); + }); + + it('content[0].text starts with a human-readable summary line', async () => { + const { client } = await pair(); + apiMock.__instance.get.mockRejectedValueOnce(new ApiError('device not found', 190)); + + const result = await client.callTool({ + name: 'describe_device', + arguments: { deviceId: 'DOES_NOT_EXIST' }, + }); + + expect(result.isError).toBe(true); + const text = (result.content as Array<{ type: string; text: string }>).find( + (c) => c.type === 'text' + )!.text; + + const firstLine = text.split('\n')[0]; + expect(firstLine).toMatch(/^(api|runtime|usage|guard) error \(code \d+\): .+/); + }); + + it('content[0].text contains the --- structured --- delimiter', async () => { + const { client } = await pair(); + apiMock.__instance.get.mockRejectedValueOnce(new ApiError('not found', 190)); + + const result = await client.callTool({ + name: 'describe_device', + arguments: { deviceId: 'DOES_NOT_EXIST' }, + }); + + expect(result.isError).toBe(true); + const text = (result.content as Array<{ type: string; text: string }>)[0].text; + expect(text).toContain('--- structured ---'); + }); + + it('the JSON after --- structured --- is parseable and contains an error key', async () => { + const { client } = await pair(); + apiMock.__instance.get.mockRejectedValueOnce(new ApiError('not found', 190)); + + const result = await client.callTool({ + name: 'describe_device', + arguments: { deviceId: 'DOES_NOT_EXIST' }, + }); + + expect(result.isError).toBe(true); + const text = (result.content as Array<{ type: string; text: string }>)[0].text; + + const parsed = parseErrorText(text) as { error: { code: number; message: string } }; + expect(parsed).toHaveProperty('error'); + expect(parsed.error).toHaveProperty('code'); + expect(parsed.error).toHaveProperty('message'); + }); + + it('structuredContent.error mirrors the JSON in content[0].text', async () => { + const { client } = await pair(); + apiMock.__instance.get.mockRejectedValueOnce(new ApiError('not found', 190)); + + const result = await client.callTool({ + name: 'describe_device', + arguments: { deviceId: 'DOES_NOT_EXIST' }, + }); + + expect(result.isError).toBe(true); + const text = (result.content as Array<{ type: string; text: string }>)[0].text; + const fromText = parseErrorText(text) as { error: unknown }; + const fromStructured = (result as { structuredContent: { error: unknown } }).structuredContent; + + expect(fromText.error).toEqual(fromStructured.error); + }); +}); From 1fbd5360c542afa392656b70d1aa529e50b14e3f Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 20:35:44 +0800 Subject: [PATCH 35/72] chore: remove completed plan and spec files All referenced plans (coverage ceiling, test improvement, 90% coverage, smoke-test bugfixes, MCP test coverage) have been fully executed and committed. Co-Authored-By: Claude Sonnet 4.6 --- docs/plans/2026-05-16-coverage-ceiling.md | 576 ----------- .../2026-05-16-test-coverage-improvement.md | 405 -------- .../plans/2026-05-17-coverage-90pct-design.md | 119 --- docs/plans/2026-05-17-coverage-90pct-plan.md | 943 ------------------ ...-05-16-test-coverage-improvement-design.md | 94 -- .../2026-05-21-mcp-test-coverage-design.md | 102 -- 6 files changed, 2239 deletions(-) delete mode 100644 docs/plans/2026-05-16-coverage-ceiling.md delete mode 100644 docs/plans/2026-05-16-test-coverage-improvement.md delete mode 100644 docs/plans/2026-05-17-coverage-90pct-design.md delete mode 100644 docs/plans/2026-05-17-coverage-90pct-plan.md delete mode 100644 docs/specs/2026-05-16-test-coverage-improvement-design.md delete mode 100644 docs/superpowers/specs/2026-05-21-mcp-test-coverage-design.md diff --git a/docs/plans/2026-05-16-coverage-ceiling.md b/docs/plans/2026-05-16-coverage-ceiling.md deleted file mode 100644 index 932f1935..00000000 --- a/docs/plans/2026-05-16-coverage-ceiling.md +++ /dev/null @@ -1,576 +0,0 @@ -# Coverage Ceiling Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Push overall line coverage from 79% to ~85–88% by backfilling tests for the six lowest-coverage technically-testable files, then raising vitest thresholds to lock in the gains. - -**Architecture:** Six independent test-addition tasks (Tasks 1–6) followed by a threshold-bump task (Task 7). Each task modifies exactly one existing test file and commits separately. Tasks 1–6 can be done in any order; Task 7 must be last. - -**Tech Stack:** TypeScript, vitest 2.1.9, @vitest/coverage-v8. All tests use in-process patterns (no subprocess spawn). Tasks 1 uses `vi.spyOn` on `fs` exports. Task 2 adds a `vi.mock` for the keychain module to config.test.ts. Tasks 3–6 use the existing local `runCli()` helper pattern already established in each file. - ---- - -## Hard Coverage Ceiling Reference - -These areas are **deliberately excluded** — testing them requires live infrastructure: - -| File | Coverage | Reason excluded | -|---|---|---| -| `src/sinks/mqtt/client.ts` | 1% | MQTT broker | -| `src/lib/llm/anthropic.ts` | 15% | Anthropic API | -| `src/lib/llm/openai.ts` | 15% | OpenAI API | -| `src/commands/rules.ts` simulate | — | Full engine simulation | -| `src/commands/install.ts` | — | OS privilege, already excluded | -| `src/commands/uninstall.ts` | — | OS privilege, already excluded | - ---- - -## File Map - -| File | Change | -|---|---| -| `tests/lib/plan-store.test.ts` | +4 tests after existing 3 | -| `tests/commands/config.test.ts` | +hoisted keychain mock + 3 tests | -| `tests/commands/doctor.test.ts` | +3 tests inside existing `describe('doctor command')` | -| `tests/commands/rules.test.ts` | +4 tests: 2 summary + 2 last-fired | -| `tests/commands/auth.test.ts` | +2 tests inside `describe('auth keychain migrate')` | -| `vitest.config.ts` | Bump thresholds 75% → 80% | - ---- - -## Task 1: Backfill plan-store.ts (43% → ~80%) - -**Files:** -- Modify: `tests/lib/plan-store.test.ts` — add 4 tests after the existing `describe('plan-store security')` block - -**Background:** `plan-store.ts` has only 3 path-traversal security tests covering the input-validation guard. The actual write path (`savePlanRecord`), the "not found" error path (`updatePlanRecord`), and the directory-reading path (`listPlanRecords`) are completely uncovered. - -- [ ] **Step 1: Open the file and locate the insertion point** - -`tests/lib/plan-store.test.ts` currently ends at line 18 with `});`. Insert all new tests after that closing brace, still in the same file. - -- [ ] **Step 2: Add the four new tests** - -```ts -import { describe, it, expect, vi, afterEach } from 'vitest'; -import fs from 'node:fs'; -import { - loadPlanRecord, - updatePlanRecord, - savePlanRecord, - listPlanRecords, - type PlanRecord, -} from '../../src/lib/plan-store.js'; -import type { Plan } from '../../src/commands/plan.js'; -``` - -**Note:** The existing file already imports `describe`, `it`, `expect` from `vitest` and imports `loadPlanRecord`/`updatePlanRecord`. You need to ADD the new imports `vi`, `afterEach`, `savePlanRecord`, `listPlanRecords`, `PlanRecord`, and `Plan` at the top of the file. Do not duplicate existing imports. - -Then append the following after the existing `describe('plan-store security', ...)` block: - -```ts -describe('plan-store write + list paths', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('savePlanRecord returns a pending record with a UUID v4 planId', () => { - vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined); - vi.spyOn(fs, 'writeFileSync').mockReturnValue(undefined); - - const plan: Plan = { version: '1.0', steps: [] }; - const record = savePlanRecord(plan); - - expect(record.status).toBe('pending'); - expect(record.plan).toBe(plan); - expect(record.planId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, - ); - expect(fs.writeFileSync).toHaveBeenCalledOnce(); - }); - - it('updatePlanRecord throws when the plan record does not exist on disk', () => { - expect(() => - updatePlanRecord('00000000-0000-4000-8000-000000000001', { status: 'approved' }), - ).toThrow(/not found/i); - }); - - it('listPlanRecords returns empty array when PLANS_DIR is absent', () => { - vi.spyOn(fs, 'existsSync').mockReturnValueOnce(false); - expect(listPlanRecords()).toEqual([]); - }); - - it('listPlanRecords returns records sorted oldest-first by createdAt', () => { - const older: PlanRecord = { - planId: '00000000-0000-4000-8000-000000000001', - createdAt: '2024-01-01T00:00:00Z', - status: 'pending', - plan: { version: '1.0', steps: [] }, - }; - const newer: PlanRecord = { - planId: '00000000-0000-4000-8000-000000000002', - createdAt: '2024-01-02T00:00:00Z', - status: 'pending', - plan: { version: '1.0', steps: [] }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValueOnce(true); - // readdirSync returns newer first to verify sorting is applied - vi.spyOn(fs, 'readdirSync').mockReturnValueOnce( - ['newer.json', 'older.json'] as unknown as ReturnType, - ); - vi.spyOn(fs, 'readFileSync') - .mockReturnValueOnce(JSON.stringify(newer)) - .mockReturnValueOnce(JSON.stringify(older)); - - const result = listPlanRecords(); - expect(result).toHaveLength(2); - expect(result[0].planId).toBe(older.planId); - expect(result[1].planId).toBe(newer.planId); - }); -}); -``` - -- [ ] **Step 3: Run the test file** - -```bash -npm test -- tests/lib/plan-store.test.ts -``` - -Expected: all 7 tests pass (3 original + 4 new). - -- [ ] **Step 4: Commit** - -```bash -git add tests/lib/plan-store.test.ts -git commit -m "test: backfill plan-store savePlanRecord, updatePlanRecord not-found, listPlanRecords paths" -``` - ---- - -## Task 2: Backfill config.ts platform hints (65% → ~78%) - -**Files:** -- Modify: `tests/commands/config.test.ts` — add a keychain mock + 3 new tests - -**Background:** `config.ts` lines 277–293 show a platform-specific keychain tip after `set-token` succeeds when the credential backend is `'file'`. The keychain store is loaded via a dynamic `await import()`. These three branches (darwin/win32, linux, and non-file backend → no tip) are completely uncovered. - -- [ ] **Step 1: Add the keychain mock declaration** - -At the top of `tests/commands/config.test.ts`, just before the existing `const configMock = vi.hoisted(...)` block, add: - -```ts -const keychainMock = vi.hoisted(() => vi.fn()); - -vi.mock('../../src/credentials/keychain.js', () => ({ - selectCredentialStore: keychainMock, -})); -``` - -The existing `configMock` block and `vi.mock('../../src/config.js', ...)` stay unchanged below it. - -- [ ] **Step 2: Add the three platform hint tests** - -Append the following new `describe` block at the end of the file (after the last existing `describe` block closes): - -```ts -describe('set-token platform keychain hint', () => { - let savedPlatformDescriptor: PropertyDescriptor | undefined; - - beforeEach(() => { - // Reset configMock so set-token saves successfully - configMock.saveConfig.mockReset(); - savedPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); - // Default: backend is file so hints are eligible - keychainMock.mockResolvedValue({ - name: 'file', - describe: () => ({ backend: 'file', tag: 'file', writable: true }), - get: vi.fn(), - set: vi.fn(), - delete: vi.fn(), - }); - }); - - afterEach(() => { - if (savedPlatformDescriptor) { - Object.defineProperty(process, 'platform', savedPlatformDescriptor); - } - }); - - it('emits native-keychain tip to stderr on darwin when backend is file', async () => { - Object.defineProperty(process, 'platform', { - value: 'darwin', - configurable: true, - writable: false, - }); - const res = await runCli(registerConfigCommand, ['config', 'set-token', 'T', 'S']); - expect(res.exitCode).toBeNull(); - expect(res.stderr.join('\n')).toContain('native keychain'); - }); - - it('emits GNOME Keyring tip to stderr on linux when backend is file', async () => { - Object.defineProperty(process, 'platform', { - value: 'linux', - configurable: true, - writable: false, - }); - const res = await runCli(registerConfigCommand, ['config', 'set-token', 'T', 'S']); - expect(res.exitCode).toBeNull(); - expect(res.stderr.join('\n')).toContain('GNOME Keyring'); - }); - - it('emits no keychain tip when the backend is not file', async () => { - Object.defineProperty(process, 'platform', { - value: 'darwin', - configurable: true, - writable: false, - }); - keychainMock.mockResolvedValue({ - name: 'keychain', - describe: () => ({ backend: 'keychain', tag: 'keychain', writable: true }), - get: vi.fn(), - set: vi.fn(), - delete: vi.fn(), - }); - const res = await runCli(registerConfigCommand, ['config', 'set-token', 'T', 'S']); - expect(res.exitCode).toBeNull(); - expect(res.stderr.join('\n')).not.toContain('native keychain'); - expect(res.stderr.join('\n')).not.toContain('GNOME'); - }); -}); -``` - -**Important:** Make sure `beforeEach` and `afterEach` are imported in the existing import line at the top of the file if not already present. - -- [ ] **Step 3: Run the test file** - -```bash -npm test -- tests/commands/config.test.ts -``` - -Expected: all tests pass including the 3 new ones. The existing set-token tests must also still pass — the keychainMock is cleared between tests by `clearMocks: true` in vitest.config.ts, so existing tests that don't configure it see `keychainMock()` return `undefined`, which causes the `try/catch` in config.ts to silently skip the hint (correct behavior). - -- [ ] **Step 4: Commit** - -```bash -git add tests/commands/config.test.ts -git commit -m "test: backfill config set-token platform keychain hints (darwin, linux, non-file)" -``` - ---- - -## Task 3: Backfill doctor.ts human-mode output (71% → ~79%) - -**Files:** -- Modify: `tests/commands/doctor.test.ts` — add 3 tests inside the existing `describe('doctor command')` block - -**Background:** Every existing doctor test uses `--json`. Lines 1221–1227 (non-JSON `--list`) and 1302–1324 (non-JSON icon/summary output including `--quiet` suppression) are completely uncovered. - -The `describe('doctor command')` block already has: -- `beforeEach` that deletes `SWITCHBOT_TOKEN`/`SWITCHBOT_SECRET`, mocks `os.homedir`, sets `SWITCHBOT_POLICY_PATH` -- `afterEach` that cleans up - -All three new tests go inside that same describe block. - -- [ ] **Step 1: Locate the insertion point** - -Find the last `it(...)` test inside `describe('doctor command')` (currently at the bottom of the block). The new tests go after the last existing test but still inside the outer `describe` block. - -- [ ] **Step 2: Add the three new tests** - -```ts - it('non-JSON --list prints "Available checks:" followed by check names', async () => { - const res = await runCli(registerDoctorCommand, ['doctor', '--list']); - expect(res.exitCode).toBeNull(); - const out = res.stdout.join('\n'); - expect(out).toContain('Available checks:'); - expect(out).toContain('credentials'); - expect(out).toContain('mcp'); - expect(out).toContain('catalog-schema'); - }); - - it('non-JSON output shows icon (✓/!) per check and a summary line', async () => { - process.env.SWITCHBOT_TOKEN = 't'; - process.env.SWITCHBOT_SECRET = 's'; - const res = await runCli( - registerDoctorCommand, - ['doctor', '--section', 'catalog-schema,mcp'], - ); - // human mode — no JSON parsing - const out = res.stdout.join('\n'); - expect(out).toMatch(/[✓!✗]\s+catalog-schema/); - expect(out).toMatch(/[✓!✗]\s+mcp/); - expect(out).toMatch(/\d+ ok, \d+ warn, \d+ fail/); - }); - - it('--quiet suppresses ok checks but keeps failing checks and the summary', async () => { - // No credentials → credentials check fails; catalog-schema does not need live API - const res = await runCli( - registerDoctorCommand, - ['doctor', '--section', 'catalog-schema,credentials', '--quiet'], - ); - const out = res.stdout.join('\n'); - // The credentials check line (fail/warn) must appear - expect(out).toMatch(/[!✗]\s+credentials/); - // The catalog-schema ok check must be suppressed - expect(out).not.toMatch(/✓\s+catalog-schema/); - // Summary is always shown - expect(out).toMatch(/\d+ ok, \d+ warn, \d+ fail/); - }); -``` - -- [ ] **Step 3: Run the test file** - -```bash -npm test -- tests/commands/doctor.test.ts -``` - -Expected: all tests pass. - -- [ ] **Step 4: Commit** - -```bash -git add tests/commands/doctor.test.ts -git commit -m "test: backfill doctor human-mode output: --list, icon format, --quiet suppression" -``` - ---- - -## Task 4: Backfill rules.ts summary + last-fired (57% → ~63%) - -**Files:** -- Modify: `tests/commands/rules.test.ts` — add 4 tests at the end of the existing `describe('switchbot rules (commander surface)')` block - -**Background:** `rules.ts` lines 800–868 contain the `summary` and `last-fired` subcommands. These are read-only audit-log queries that work fine with a non-existent log (they return empty state). No mocking needed — just pass `--file` pointing at a non-existent tmpDir path. - -- [ ] **Step 1: Locate the insertion point** - -In `tests/commands/rules.test.ts`, find the end of the outermost `describe('switchbot rules (commander surface)')` block. The existing `describe('rules doctor', ...)` block is the last nested describe. Insert two new `describe` blocks after it, still inside the outer describe. - -- [ ] **Step 2: Add the four new tests** - -```ts - describe('rules summary', () => { - it('returns total:0 and empty summaries under --json when log is absent', async () => { - const logFile = path.join(tmpDir, 'noaudit.log'); - const res = await runCli(['--json', 'rules', 'summary', '--file', logFile]); - expect(res.exitCode).toBe(0); - const body = JSON.parse(res.stdout[0]) as Record; - const data = expectJsonEnvelopeContainingKeys(body, ['total', 'summaries']) as { - total: number; - summaries: unknown[]; - }; - expect(data.total).toBe(0); - expect(data.summaries).toEqual([]); - }); - - it('prints "no rule activity" in human mode when log is absent', async () => { - const logFile = path.join(tmpDir, 'noaudit.log'); - const res = await runCli(['rules', 'summary', '--file', logFile]); - expect(res.exitCode).toBe(0); - expect(res.stdout.join('\n')).toContain('no rule activity'); - }); - }); - - describe('rules last-fired', () => { - it('returns count:0 and empty entries under --json when log is absent', async () => { - const logFile = path.join(tmpDir, 'noaudit.log'); - const res = await runCli(['--json', 'rules', 'last-fired', '--file', logFile]); - expect(res.exitCode).toBe(0); - const body = JSON.parse(res.stdout[0]) as Record; - const data = expectJsonEnvelopeContainingKeys(body, ['count', 'entries']) as { - count: number; - entries: unknown[]; - }; - expect(data.count).toBe(0); - expect(data.entries).toEqual([]); - }); - - it('prints "no rule-fire entries" in human mode when log is absent', async () => { - const logFile = path.join(tmpDir, 'noaudit.log'); - const res = await runCli(['rules', 'last-fired', '--file', logFile]); - expect(res.exitCode).toBe(0); - expect(res.stdout.join('\n')).toContain('no rule-fire entries'); - }); - }); -``` - -- [ ] **Step 3: Run the test file** - -```bash -npm test -- tests/commands/rules.test.ts -``` - -Expected: all tests pass. - -- [ ] **Step 4: Commit** - -```bash -git add tests/commands/rules.test.ts -git commit -m "test: backfill rules summary and last-fired empty-log paths" -``` - ---- - -## Task 5: Backfill auth.ts migrate error paths (65% → ~70%) - -**Files:** -- Modify: `tests/commands/auth.test.ts` — add 2 tests inside the existing `describe('auth keychain migrate')` block (after the last existing test at ~line 340) - -**Background:** `auth.ts` lines 339–344 (catch block when `store.set()` throws during migrate) and 352–353 (warning log when `--delete-file` cleanup fails) are uncovered. - -The existing `describe('auth keychain migrate')` block already has a `beforeEach`/`afterEach` that sets up `tmpHome` as `process.env.HOME`, and `makeStore()` + `selectMock` are available at file scope. - -- [ ] **Step 1: Add the two new tests inside `describe('auth keychain migrate')`** - -```ts - it('exits 1 when the keychain write fails during migrate', async () => { - const store = makeStore({ - writable: true, - setImpl: async () => { - throw new Error('permission denied'); - }, - }); - selectMock.mockResolvedValue(store); - - const file = path.join(tmpHome, '.switchbot', 'config.json'); - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, JSON.stringify({ token: 't-src', secret: 's-src' })); - - const res = await runCli(['auth', 'keychain', 'migrate']); - expect(res.exitCode).toBe(1); - expect(res.stderr.join('\n')).toContain('keychain write failed'); - }); - - it('exits 0 but logs a warning when --delete-file cleanup throws', async () => { - const store = makeStore({ writable: true }); - selectMock.mockResolvedValue(store); - - const file = path.join(tmpHome, '.switchbot', 'config.json'); - fs.mkdirSync(path.dirname(file), { recursive: true }); - // Only token+secret → no metadata → cleanup tries fs.unlinkSync - fs.writeFileSync(file, JSON.stringify({ token: 't-src', secret: 's-src' })); - - const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => { - throw new Error('EPERM: operation not permitted'); - }); - try { - const res = await runCli(['auth', 'keychain', 'migrate', '--delete-file']); - expect(res.exitCode).toBe(0); - expect(res.stderr.join('\n')).toContain('warning: could not remove'); - } finally { - unlinkSpy.mockRestore(); - } - }); -``` - -- [ ] **Step 2: Run the test file** - -```bash -npm test -- tests/commands/auth.test.ts -``` - -Expected: all tests pass. - -- [ ] **Step 3: Commit** - -```bash -git add tests/commands/auth.test.ts -git commit -m "test: backfill auth migrate keychain-write-fail and cleanup-warning paths" -``` - ---- - -## Task 6: Raise vitest coverage thresholds - -**Files:** -- Modify: `vitest.config.ts` - -Run coverage first to verify the new tests have pushed the numbers above the new targets before editing the config. - -- [ ] **Step 1: Run coverage and inspect current numbers** - -```bash -npm run test -- --coverage 2>&1 | grep -E "All files|src/commands" -``` - -Expected (approximate — exact numbers depend on which lines the new tests hit): - -``` - src/commands | 79.x | 78.x | ... -All files | 82.x | 80.x | ... -``` - -If `src/commands` lines are below 78 or branches below 75, investigate which file is dragging it down before proceeding. - -- [ ] **Step 2: Edit `vitest.config.ts` — update only the `thresholds` block** - -Current content: - -```ts - thresholds: { - lines: 75, - branches: 75, - 'src/commands/**': { - lines: 75, - branches: 75, - }, - }, -``` - -Replace with: - -```ts - thresholds: { - lines: 79, - branches: 78, - 'src/commands/**': { - lines: 78, - branches: 76, - }, - }, -``` - -The comment block above `thresholds` should be updated to reflect current reality: - -```ts - // Thresholds locked to current actual coverage after 2026-05-16 backfill. - // Raise incrementally: rules.ts (57%), mcp.ts (68%) still have room. -``` - -- [ ] **Step 3: Run coverage and confirm no threshold violations** - -```bash -npm run test -- --coverage 2>&1 | tail -15 -``` - -Expected: exits 0, no lines starting with `ERROR: Coverage for`. - -If you see a threshold violation, check which specific file is below the threshold and either: -- Lower that specific threshold to match reality (with a comment explaining why), OR -- Add a targeted test to bring that file above the threshold - -- [ ] **Step 4: Run the full test suite without coverage to confirm no regressions** - -```bash -npm test -``` - -Expected: all tests pass, no failures. - -- [ ] **Step 5: Commit** - -```bash -git add vitest.config.ts -git commit -m "test: raise coverage thresholds to match post-backfill actuals" -``` - ---- - -## Final Verification - -- [ ] Run `npm test` — all tests pass -- [ ] Run `npm run test -- --coverage` — exits 0, no threshold errors -- [ ] `src/commands` line coverage ≥ 78% -- [ ] Global line coverage ≥ 79% -- [ ] Confirm `docs/plans/2026-05-16-coverage-ceiling.md` is committed diff --git a/docs/plans/2026-05-16-test-coverage-improvement.md b/docs/plans/2026-05-16-test-coverage-improvement.md deleted file mode 100644 index da9c39e4..00000000 --- a/docs/plans/2026-05-16-test-coverage-improvement.md +++ /dev/null @@ -1,405 +0,0 @@ -# Test Coverage Improvement Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Backfill 5 missing test cases, add TESTING.md conventions, and enforce layered coverage thresholds in vitest — preventing the three structural test gaps that let v3.6.2 bugs ship untested. - -**Architecture:** Three independent deliverables committed separately. Tests first (Layer 1), convention doc second (Layer 2), CI gate last (Layer 3) — so coverage thresholds are added only after the new tests already pass. - -**Tech Stack:** TypeScript, vitest 2.1.9, @vitest/coverage-v8, Commander.js. Tests use in-process `runCli()` helper (not subprocess) — see `tests/helpers/cli.ts`. - ---- - -## File Map - -| File | Change | -|---|---| -| `tests/commands/plan.test.ts` | +2 test cases inside existing `describe('plan suggest')` block | -| `tests/commands/doctor.test.ts` | +1 test case inside existing `it('P10: mcp check…')` neighbourhood | -| `tests/commands/quota.test.ts` | +1 test case inside existing `describe('quota command')` block | -| `tests/commands/completion.test.ts` | +1 test case inside existing `describe('completion command')` block | -| `TESTING.md` | New file at project root | -| `vitest.config.ts` | Add `thresholds` block; add `src/sinks/**` to `exclude` | - ---- - -## Task 1: Backfill plan.test.ts — 2 missing cases - -**Files:** -- Modify: `tests/commands/plan.test.ts:361-369` - -The existing `describe('plan suggest')` block ends at line 369. Append both new tests inside it. - -- [ ] **Step 1: Open the file and locate the insertion point** - -Find this block near the end of the file: - -```ts -describe('plan suggest', () => { - it('exits 2 for unsupported Chinese command intent instead of defaulting to turnOn', async () => { - const res = await runCli(registerPlanCommand, [ - 'plan', 'suggest', '--intent', '关掉所有灯', '--device', 'BOT1', - ]); - expect(res.exitCode).toBe(2); - expect(res.stderr.join('\n')).toMatch(/cannot safely infer/i); - }); -}); -``` - -- [ ] **Step 2: Add the two new tests inside the same `describe` block** - -Replace the closing `});` of `describe('plan suggest')` with: - -```ts - it('exits 2 when no --device is given', async () => { - const res = await runCli(registerPlanCommand, [ - 'plan', 'suggest', '--intent', 'turn off lights', - ]); - expect(res.exitCode).toBe(2); - expect(res.stderr.join('\n')).toContain('at least one --device'); - }); - - it('accepts --devices as alias for --device', async () => { - cacheMock.map.set('BOT1', { type: 'Bot', name: 'My Bot', category: 'physical' }); - const res = await runCli(registerPlanCommand, [ - 'plan', 'suggest', '--intent', 'turn on the bot', '--devices', 'BOT1', - ]); - expect(res.exitCode).toBeNull(); - expect(res.stdout.join('\n')).toContain('BOT1'); - }); -}); -``` - -- [ ] **Step 3: Run just these new tests** - -```bash -npm test -- tests/commands/plan.test.ts -``` - -Expected: all tests in that file pass, including the 2 new ones. - -- [ ] **Step 4: Commit** - -```bash -git add tests/commands/plan.test.ts -git commit -m "test: backfill plan suggest exit-code and --devices alias coverage" -``` - ---- - -## Task 2: Backfill doctor.test.ts — 1 missing case - -**Files:** -- Modify: `tests/commands/doctor.test.ts` — add after the existing `P10: mcp check` test (line ~327) - -- [ ] **Step 1: Locate insertion point** - -Find the existing test that ends around line 327: - -```ts - it('P10: mcp check is ok and reports a toolCount when the server instantiates', async () => { - // ... - expect(mcp.detail.transportsAvailable).toEqual(['stdio', 'http']); - }); -``` - -- [ ] **Step 2: Add the new test immediately after it** - -```ts - it('P10: mcp check message includes default profile context', async () => { - process.env.SWITCHBOT_TOKEN = 't'; - process.env.SWITCHBOT_SECRET = 's'; - const res = await runCli(registerDoctorCommand, ['--json', 'doctor']); - const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); - const mcp = payload.data.checks.find((c: { name: string }) => c.name === 'mcp'); - expect(mcp).toBeDefined(); - expect(mcp.detail.message).toContain('default profile'); - expect(mcp.detail.message).toContain("24 in 'all'"); - }); -``` - -- [ ] **Step 3: Run just this test file** - -```bash -npm test -- tests/commands/doctor.test.ts -``` - -Expected: all tests pass. - -- [ ] **Step 4: Commit** - -```bash -git add tests/commands/doctor.test.ts -git commit -m "test: assert doctor mcp message includes default profile context" -``` - ---- - -## Task 3: Backfill quota.test.ts — 1 missing case - -**Files:** -- Modify: `tests/commands/quota.test.ts` — add inside existing `describe('quota command')` - -- [ ] **Step 1: Locate insertion point** - -Find the first `it(...)` inside `describe('quota command')` which starts at line 34: - -```ts -describe('quota command', () => { - it('status prints today usage + endpoint breakdown (human mode)', async () => { - // ... - }); -``` - -- [ ] **Step 2: Add the new test after the existing first test** - -```ts - it('status human output includes Remaining budget line with reset time', async () => { - const result = await runCli(registerQuotaCommand, ['quota', 'status']); - const out = result.stdout.join('\n'); - expect(out).toContain('Remaining budget:'); - expect(out).toContain('resets at'); - }); -``` - -- [ ] **Step 3: Run just this test file** - -```bash -npm test -- tests/commands/quota.test.ts -``` - -Expected: all tests pass. - -- [ ] **Step 4: Commit** - -```bash -git add tests/commands/quota.test.ts -git commit -m "test: assert quota status output contains Remaining budget and reset time" -``` - ---- - -## Task 4: Backfill completion.test.ts — 1 missing case - -**Files:** -- Modify: `tests/commands/completion.test.ts` — add inside existing `describe('completion command')` - -The existing bash test (line 21) checks for `--profile` and `--audit-log-path` but not format values. - -- [ ] **Step 1: Locate insertion point** - -Find the existing bash test: - -```ts - it('prints a bash completion script', async () => { - const res = await runCli(registerCompletionCommand, ['completion', 'bash']); - expect(res.exitCode).toBeNull(); - const out = written.join(''); - expect(out).toContain('_switchbot_completion'); - // ... - }); -``` - -- [ ] **Step 2: Add new test after it** - -```ts - it('bash completion includes all --format values', async () => { - const res = await runCli(registerCompletionCommand, ['completion', 'bash']); - expect(res.exitCode).toBeNull(); - const out = written.join(''); - expect(out).toContain('format_vals'); - for (const fmt of ['table', 'json', 'jsonl', 'tsv', 'yaml', 'id', 'markdown']) { - expect(out).toContain(fmt); - } - }); -``` - -- [ ] **Step 3: Run just this test file** - -```bash -npm test -- tests/commands/completion.test.ts -``` - -Expected: all tests pass. - -- [ ] **Step 4: Commit** - -```bash -git add tests/commands/completion.test.ts -git commit -m "test: assert bash completion exposes all --format enum values" -``` - ---- - -## Task 5: Create TESTING.md - -**Files:** -- Create: `TESTING.md` at project root - -- [ ] **Step 1: Create the file with this exact content** - -```markdown -# Testing Conventions - -This document defines the three rules that prevent the class of bugs identified -in the v3.6.2 post-mortem. Each rule maps to a specific root cause. - ---- - -## Rule 1 — Every `process.exit` path needs its own test - -Every `process.exit(1)` or `process.exit(2)` call in `src/commands/` must have -at least one test case in `tests/commands/` that reaches that path and asserts -`exitCode`. New exit branches must be tested in the same commit. - -**Why:** v3.6.2 shipped `plan suggest` with a missing-argument branch that exited -with code 1 instead of 2. The existing test only exercised a different branch in -the same command, leaving the new branch invisible to CI. - ---- - -## Rule 2 — Every new CLI option/alias needs a smoke test - -Every new `.option()` declaration (including aliases) must have at least one test -that uses the flag and asserts it is parsed or produces the expected behavior. - -**Why:** The `--devices` plural alias for `plan suggest` shipped with zero test -coverage. Any rename or collision would have been silent. - ---- - -## Rule 3 — Non-trivial user-visible messages need keyword assertions - -Any `console.log/error` line containing a business-semantic keyword — one whose -removal would confuse the user — must have a corresponding test asserting that -keyword appears in `stdout` or `stderr`. - -**Why:** The doctor MCP tool-count message and quota reset-time line were changed -without any test catching the change, because tests only checked numeric structure, -not message content. - ---- - -## PR Checklist - -Before merging, verify: - -- [ ] New `process.exit` path → corresponding test added -- [ ] New CLI option/alias → smoke test added -- [ ] Changed user-visible message → keyword assertion updated -``` - -- [ ] **Step 2: Verify the file exists** - -```bash -cat TESTING.md | head -5 -``` - -Expected output starts with `# Testing Conventions`. - -- [ ] **Step 3: Commit** - -```bash -git add TESTING.md -git commit -m "docs: add TESTING.md with three coverage conventions and PR checklist" -``` - ---- - -## Task 6: Add layered coverage thresholds to vitest.config.ts - -**Files:** -- Modify: `vitest.config.ts` - -Run the full coverage suite first to confirm the new tests bring `src/commands/**` -above the thresholds before writing the config. - -- [ ] **Step 1: Run coverage and inspect the summary** - -```bash -npm run test -- --coverage 2>&1 | grep -E "^All files|src/commands" -``` - -Expected output (approximate — exact numbers depend on which lines the 5 new tests hit): - -``` -src/commands | 85.x | 80.x | ... -All files | 78.x | 80.x | ... -``` - -If `src/commands` lines are below 85 or branches below 80, the thresholds in Step 2 -will fail — investigate which file is low before proceeding. - -- [ ] **Step 2: Edit `vitest.config.ts` — replace the `coverage` block** - -Current content: - -```ts - coverage: { - provider: 'v8', - include: ['src/**/*.ts'], - exclude: ['src/index.ts'], - reporter: ['text', 'html'], - }, -``` - -Replace with: - -```ts - coverage: { - provider: 'v8', - include: ['src/**/*.ts'], - exclude: ['src/index.ts', 'src/sinks/**'], - reporter: ['text', 'html'], - thresholds: { - lines: 80, - branches: 80, - 'src/commands/**': { - lines: 85, - branches: 80, - }, - }, - }, -``` - -- [ ] **Step 3: Run coverage and confirm it passes** - -```bash -npm run test -- --coverage 2>&1 | tail -10 -``` - -Expected: exits 0, no threshold violation lines (lines starting with `ERROR`). - -If you see `ERROR: Coverage for lines (X%) does not meet global threshold (80%)`, -check which file is dragging the global below 80 — it is likely in `src/lib/` or -`src/rules/`. Address it before merging. - -- [ ] **Step 4: Run the full test suite without coverage to confirm no regressions** - -```bash -npm test -``` - -Expected: -``` -Test Files 128 passed (128) -Tests 2470 passed (2470) -``` - -(2465 original + 5 new = 2470) - -- [ ] **Step 5: Commit** - -```bash -git add vitest.config.ts -git commit -m "test: add layered coverage thresholds (global 80%, src/commands 85% lines)" -``` - ---- - -## Final Verification - -- [ ] Run `npm test` — all tests pass (≥2470) -- [ ] Run `npm run test -- --coverage` — exits 0, no threshold errors -- [ ] Confirm `TESTING.md` exists at project root: `cat TESTING.md | head -1` -- [ ] Confirm `docs/plans/2026-05-16-test-coverage-improvement.md` is committed diff --git a/docs/plans/2026-05-17-coverage-90pct-design.md b/docs/plans/2026-05-17-coverage-90pct-design.md deleted file mode 100644 index e58d7280..00000000 --- a/docs/plans/2026-05-17-coverage-90pct-design.md +++ /dev/null @@ -1,119 +0,0 @@ -# Design: Push Test Coverage to ~90% - -**Date:** 2026-05-17 -**Branch context:** `fix/v3.6.2-bugs` -**Current coverage:** 79.49% lines / 79.97% branches (global) -**Target:** ≥88% lines / ≥87% branches after HC exclusions - ---- - -## 1. Strategy - -Approach B — **exclude hard-ceiling infrastructure files + targeted test backfill** for the 10–11 highest-leverage in-scope files. - -The gap from ~79% to ~90% cannot be closed by test-writing alone because several files require live external infrastructure (MQTT broker, MCP server, LLM APIs). The honest solution is: - -1. Remove those files from the coverage denominator via vitest `exclude`. -2. Write unit tests for every remaining file that is realistically improvable. -3. Lock the new thresholds to the achieved actuals. -4. Document in `docs/coverage-annotations.md` what is excluded and why, and which in-denominator sections remain structurally untestable. - ---- - -## 2. vitest.config.ts — Exclusion List Changes - -Add 5 files to the existing `coverage.exclude` array: - -```typescript -// Hard-ceiling: require live infrastructure, not unit-testable -'src/mcp/device-history.ts', // MCP streaming protocol (live server required) -'src/mcp/events-subscription.ts', // MCP event subscription (live server required) -'src/mqtt/client.ts', // MQTT broker required -'src/llm/providers/anthropic.ts', // Anthropic API key + live endpoint required -'src/llm/providers/openai.ts', // OpenAI API key + live endpoint required -``` - -**Verified impact:** exclusion alone moves the baseline from 79.49% → 81.47%. - ---- - -## 3. Test Backfill Targets - -Priority order by `line_count × potential_gain`. Each file's uncoverable sections are noted so tests are not written for them. - -### P1 — Highest impact - -| File | Current | Target | Key test surface | -|------|---------|--------|-----------------| -| `src/commands/doctor.ts` | 72.7% | ~87% | lines 1311–1323: `--check` failure path, `--json` error envelope | -| `src/commands/plan.ts` | 74.2% | ~88% | `approve` / `reject` / `execute` command wiring, `--json` output shape | -| `src/status-sync/manager.ts` | 73.3% | ~87% | `start` / `stop` / `status` state transitions, event emission | - -### P2 — Medium impact - -| File | Current | Target | Key test surface | -|------|---------|--------|-----------------| -| `src/commands/events.ts` | 73.0% | ~85% | `--device`, `--type` filter flags, clean exit path | -| `src/commands/auth.ts` | 68.0% | ~84% | lines 316–325: token-expired path, `--json` error response | -| `src/install/preflight.ts` | 66.3% | ~83% | OS-check branches stubbed for win32 / linux / macos | -| `src/commands/config.ts` | 69.1% | ~85% | lines 233–267: `--from-keychain`, error paths | - -### P3 — Supplemental - -| File | Current | Target | Key test surface | -|------|---------|--------|-----------------| -| `src/commands/mcp.ts` | 68.0% | ~76% | `--list`, `--json` flag parsing; protocol body NOT tested | -| `src/daemon/socket-path.ts` | 52.5% | ~87% | win32 named-pipe path, `USERNAME` env fallback, `whoami` fallback | -| `src/commands/rules.ts` | 56.7% | ~70% | non-simulate subcommand flag/output paths | -| `src/lib/daemon-state.ts` | 73.2% | ~92% | lock/unlock/read state transitions | - ---- - -## 4. In-Denominator Structurally Untestable Sections - -These sections remain in the coverage denominator but cannot be covered by unit tests. They are documented in `docs/coverage-annotations.md`. - -| File | Lines / Area | Reason | -|------|-------------|--------| -| `src/commands/mcp.ts` | 2364–2633 | MCP tool-call / resource protocol handlers — live MCP client required | -| `src/commands/rules.ts` | 800–985, 1001–1081 | `simulate` / `trace-explain` subcommands — full rules engine + LLM required | -| `src/status-sync/manager.ts` | WebSocket push path | Live SwitchBot WebSocket connection required | -| `src/policy/migrate.ts` | lines 21–52 | `MIGRATION_CHAIN` is empty; migration step functions exist but are unreachable | - ---- - -## 5. Threshold Targets (post-backfill) - -Set conservatively (2% below expected actuals) to avoid flakiness from minor coverage drift: - -```typescript -thresholds: { - lines: 88, - branches: 87, - 'src/commands/**': { - lines: 80, - branches: 78, - }, -}, -``` - -After backfill is complete, run `npm run test -- --coverage` and lock thresholds to actual numbers. - ---- - -## 6. Deliverables - -1. `vitest.config.ts` — 5 HC files added to exclude list -2. `tests/commands/doctor.test.ts` — P1 additions -3. `tests/commands/plan.test.ts` — P1 additions (new file or additions) -4. `tests/lib/status-sync-manager.test.ts` — P1 new file -5. `tests/commands/events.test.ts` — P2 additions -6. `tests/commands/auth.test.ts` — P2 additions -7. `tests/install/preflight.test.ts` — P2 new file -8. `tests/commands/config.test.ts` — P2 additions -9. `tests/commands/mcp.test.ts` — P3 additions (flag parsing only) -10. `tests/lib/daemon-socket-path.test.ts` — P3 new file -11. `tests/commands/rules.test.ts` — P3 additions -12. `tests/lib/daemon-state.test.ts` — P3 additions -13. `vitest.config.ts` — thresholds locked to post-backfill actuals -14. `docs/coverage-annotations.md` — exclusion + untestable-section register diff --git a/docs/plans/2026-05-17-coverage-90pct-plan.md b/docs/plans/2026-05-17-coverage-90pct-plan.md deleted file mode 100644 index 7227cd0b..00000000 --- a/docs/plans/2026-05-17-coverage-90pct-plan.md +++ /dev/null @@ -1,943 +0,0 @@ -# Coverage ~90% Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Push line coverage from 79.49% to ≥88% by excluding 5 hard-ceiling infrastructure files and backfilling tests for 10 low-coverage source files. - -**Architecture:** Two-phase: (1) add 5 live-infrastructure files to `vitest.config.ts` exclude list, moving the baseline to 81.47%; (2) backfill unit tests for the 10 highest-leverage improvable files. Each test task is independent after Task 1 and can be done in any order. - -**Tech Stack:** TypeScript, vitest 2.x, `vi.mock` / `vi.hoisted` / `vi.spyOn`, `tests/helpers/cli.ts` runCli helper, Commander `exitOverride`. - ---- - -## File Map - -| File | Action | -|------|--------| -| `vitest.config.ts` | Modify twice: HC excludes (Task 1), threshold bump (Task 11) | -| `tests/commands/plan.test.ts` | Add plan-store mock + plan save/list/review/approve/execute tests | -| `tests/commands/doctor.test.ts` | Add `--fix` output test | -| `tests/commands/rules.test.ts` | Add summary/last-fired human-mode + trace-explain not-found tests | -| `tests/lib/daemon-state.test.ts` | Create new file | -| `tests/lib/daemon-socket-path.test.ts` | Create new file | -| `tests/commands/config.test.ts` | Add `--label` / `--daily-cap` options tests | -| `tests/commands/auth.test.ts` | Add config parse-error test | -| `tests/install/preflight.test.ts` | Add `~/.switchbot` dir writable path + agent-skills-dir success path | -| `docs/coverage-annotations.md` | Create new file | - ---- - -## Task 1: Exclude hard-ceiling files from coverage denominator - -**Files:** -- Modify: `vitest.config.ts` - -- [ ] **Step 1: Add 5 HC files to the exclude array** - -Open `vitest.config.ts` and replace the existing `exclude` block with: - -```typescript -exclude: [ - 'src/index.ts', - 'src/sinks/**', // I/O adapters — require live integration, no unit tests - 'src/commands/install.ts', // system-level operations — require OS privilege - 'src/commands/uninstall.ts', // system-level operations — require OS privilege - // Hard-ceiling: require live infrastructure, not unit-testable - 'src/mcp/device-history.ts', // MCP streaming protocol (live server required) - 'src/mcp/events-subscription.ts', // MCP event subscription (live server required) - 'src/mqtt/client.ts', // MQTT broker required - 'src/llm/providers/anthropic.ts', // Anthropic API key + live endpoint required - 'src/llm/providers/openai.ts', // OpenAI API key + live endpoint required -], -``` - -- [ ] **Step 2: Verify baseline moves from 79.49% to ~81%** - -Run: `npm run test -- --coverage --reporter=dot 2>&1 | grep "^All files"` - -Expected output contains: `All files | 81.` (the exclusion should push global above 81%) - -- [ ] **Step 3: Commit** - -```bash -git add vitest.config.ts -git commit -m "test: exclude hard-ceiling infrastructure files from coverage denominator" -``` - ---- - -## Task 2: plan.test.ts — plan-store mock + save/list/review/approve tests - -**Files:** -- Modify: `tests/commands/plan.test.ts` - -- [ ] **Step 1: Add plan-store mock at the top of the file (after existing mocks)** - -In `tests/commands/plan.test.ts`, directly after the existing `vi.mock('../../src/devices/cache.js', ...)` block, add: - -```typescript -const planStoreMock = vi.hoisted(() => ({ - savePlanRecord: vi.fn(), - loadPlanRecord: vi.fn(() => null), - updatePlanRecord: vi.fn(), - listPlanRecords: vi.fn(() => []), - PLANS_DIR: '/mock/.switchbot/plans', -})); - -vi.mock('../../src/lib/plan-store.js', () => ({ - savePlanRecord: planStoreMock.savePlanRecord, - loadPlanRecord: planStoreMock.loadPlanRecord, - updatePlanRecord: planStoreMock.updatePlanRecord, - listPlanRecords: planStoreMock.listPlanRecords, - PLANS_DIR: planStoreMock.PLANS_DIR, -})); -``` - -- [ ] **Step 2: Reset plan-store mocks in the existing beforeEach** - -Inside the existing `beforeEach` in `describe('plan command', ...)`, append: - -```typescript -planStoreMock.savePlanRecord.mockReset(); -planStoreMock.loadPlanRecord.mockReset().mockReturnValue(null); -planStoreMock.updatePlanRecord.mockReset(); -planStoreMock.listPlanRecords.mockReset().mockReturnValue([]); -``` - -- [ ] **Step 3: Write failing tests for plan save/list/review/approve** - -Add a new `describe('plan save / list / review / approve', ...)` block at the end of `describe('plan command', ...)`: - -```typescript -describe('plan save / list / review / approve', () => { - const MOCK_ID = '00000000-0000-4000-8000-000000000001'; - - it('plan save writes a valid plan and prints planId', async () => { - planStoreMock.savePlanRecord.mockReturnValue({ - planId: MOCK_ID, - status: 'pending', - createdAt: '2024-01-01T00:00:00Z', - plan: { version: '1.0', steps: [] }, - }); - const file = writePlan({ version: '1.0', steps: [] }); - const res = await runCli(registerPlanCommand, ['plan', 'save', file]); - expect(res.exitCode).toBeNull(); - expect(res.stdout.join('\n')).toContain(MOCK_ID); - expect(planStoreMock.savePlanRecord).toHaveBeenCalledOnce(); - }); - - it('plan save --json returns saved:true with planId', async () => { - planStoreMock.savePlanRecord.mockReturnValue({ - planId: MOCK_ID, - status: 'pending', - createdAt: '2024-01-01T00:00:00Z', - plan: { version: '1.0', steps: [] }, - }); - const file = writePlan({ version: '1.0', steps: [] }); - const res = await runCli(registerPlanCommand, ['--json', 'plan', 'save', file]); - expect(res.exitCode).toBeNull(); - const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')) as Record; - const data = expectJsonEnvelopeContainingKeys(out, ['saved', 'planId']) as { saved: boolean; planId: string }; - expect(data.saved).toBe(true); - expect(data.planId).toBe(MOCK_ID); - }); - - it('plan list prints each plan on its own line', async () => { - planStoreMock.listPlanRecords.mockReturnValue([ - { planId: MOCK_ID, status: 'pending', createdAt: '2024-01-01T00:00:00Z', plan: { version: '1.0', steps: [] } }, - ]); - const res = await runCli(registerPlanCommand, ['plan', 'list']); - expect(res.exitCode).toBeNull(); - expect(res.stdout.join('\n')).toContain(MOCK_ID.slice(0, 8)); - }); - - it('plan list prints helpful message when no plans exist', async () => { - planStoreMock.listPlanRecords.mockReturnValue([]); - const res = await runCli(registerPlanCommand, ['plan', 'list']); - expect(res.exitCode).toBeNull(); - expect(res.stdout.join('\n')).toMatch(/no saved plans/i); - }); - - it('plan list --json returns plans array', async () => { - planStoreMock.listPlanRecords.mockReturnValue([ - { planId: MOCK_ID, status: 'pending', createdAt: '2024-01-01T00:00:00Z', plan: { version: '1.0', steps: [] } }, - ]); - const res = await runCli(registerPlanCommand, ['--json', 'plan', 'list']); - expect(res.exitCode).toBeNull(); - const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')) as Record; - const data = expectJsonEnvelopeContainingKeys(out, ['plans']) as { plans: Array<{ planId: string }> }; - expect(data.plans[0].planId).toBe(MOCK_ID); - }); - - it('plan review prints plan details', async () => { - planStoreMock.loadPlanRecord.mockReturnValue({ - planId: MOCK_ID, - status: 'pending', - createdAt: '2024-01-01T00:00:00Z', - plan: { version: '1.0', description: 'turn on lights', steps: [{ type: 'command', deviceId: 'D1', command: 'turnOn' }] }, - }); - const res = await runCli(registerPlanCommand, ['plan', 'review', MOCK_ID]); - expect(res.exitCode).toBeNull(); - const out = res.stdout.join('\n'); - expect(out).toContain(MOCK_ID); - expect(out).toContain('pending'); - expect(out).toContain('turn on lights'); - }); - - it('plan review exits 2 when planId not found', async () => { - planStoreMock.loadPlanRecord.mockReturnValue(null); - const res = await runCli(registerPlanCommand, ['plan', 'review', MOCK_ID]); - expect(res.exitCode).toBe(2); - }); - - it('plan approve transitions pending to approved', async () => { - planStoreMock.loadPlanRecord.mockReturnValue({ - planId: MOCK_ID, status: 'pending', createdAt: '2024-01-01T00:00:00Z', plan: { version: '1.0', steps: [] }, - }); - planStoreMock.updatePlanRecord.mockReturnValue({ - planId: MOCK_ID, status: 'approved', createdAt: '2024-01-01T00:00:00Z', approvedAt: '2024-01-01T01:00:00Z', plan: { version: '1.0', steps: [] }, - }); - const res = await runCli(registerPlanCommand, ['plan', 'approve', MOCK_ID]); - expect(res.exitCode).toBeNull(); - expect(planStoreMock.updatePlanRecord).toHaveBeenCalledWith(MOCK_ID, expect.objectContaining({ status: 'approved' })); - expect(res.stdout.join('\n')).toMatch(/approved/i); - }); - - it('plan approve exits 2 when plan is already executed', async () => { - planStoreMock.loadPlanRecord.mockReturnValue({ - planId: MOCK_ID, status: 'executed', createdAt: '2024-01-01T00:00:00Z', plan: { version: '1.0', steps: [] }, - }); - const res = await runCli(registerPlanCommand, ['plan', 'approve', MOCK_ID]); - expect(res.exitCode).toBe(2); - expect(res.stderr.join('\n')).toMatch(/already been executed/i); - }); - - it('plan approve exits 2 when plan was rejected', async () => { - planStoreMock.loadPlanRecord.mockReturnValue({ - planId: MOCK_ID, status: 'rejected', createdAt: '2024-01-01T00:00:00Z', plan: { version: '1.0', steps: [] }, - }); - const res = await runCli(registerPlanCommand, ['plan', 'approve', MOCK_ID]); - expect(res.exitCode).toBe(2); - expect(res.stderr.join('\n')).toMatch(/rejected/i); - }); - - it('plan approve --json returns ok:true', async () => { - planStoreMock.loadPlanRecord.mockReturnValue({ - planId: MOCK_ID, status: 'pending', createdAt: '2024-01-01T00:00:00Z', plan: { version: '1.0', steps: [] }, - }); - planStoreMock.updatePlanRecord.mockReturnValue({ - planId: MOCK_ID, status: 'approved', createdAt: '2024-01-01T00:00:00Z', approvedAt: '2024-01-01T01:00:00Z', plan: { version: '1.0', steps: [] }, - }); - const res = await runCli(registerPlanCommand, ['--json', 'plan', 'approve', MOCK_ID]); - expect(res.exitCode).toBeNull(); - const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')) as Record; - const data = expectJsonEnvelopeContainingKeys(out, ['ok', 'planId', 'status']) as { ok: boolean }; - expect(data.ok).toBe(true); - }); -}); -``` - -- [ ] **Step 4: Run new tests to verify they pass** - -Run: `npm run test -- tests/commands/plan.test.ts --reporter=verbose 2>&1 | tail -30` - -Expected: All new tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add tests/commands/plan.test.ts -git commit -m "test: add plan save/list/review/approve coverage with plan-store mock" -``` - ---- - -## Task 3: plan.test.ts — plan execute tests - -**Files:** -- Modify: `tests/commands/plan.test.ts` - -- [ ] **Step 1: Write failing tests for plan execute** - -Add a new `describe('plan execute', ...)` block inside `describe('plan command', ...)` (after the `plan save / list / review / approve` block): - -```typescript -describe('plan execute', () => { - const MOCK_ID = '00000000-0000-4000-8000-000000000002'; - - it('executes an approved plan and marks it executed', async () => { - planStoreMock.loadPlanRecord.mockReturnValue({ - planId: MOCK_ID, - status: 'approved', - createdAt: '2024-01-01T00:00:00Z', - approvedAt: '2024-01-01T01:00:00Z', - plan: { version: '1.0', steps: [{ type: 'command', deviceId: 'BOT1', command: 'turnOn' }] }, - }); - planStoreMock.updatePlanRecord.mockReturnValue({ - planId: MOCK_ID, status: 'executed', createdAt: '2024-01-01T00:00:00Z', plan: { version: '1.0', steps: [] }, - }); - apiMock.__instance.post.mockResolvedValue({ data: { statusCode: 100, body: {} } }); - - const res = await runCli(registerPlanCommand, ['plan', 'execute', MOCK_ID]); - expect(res.exitCode).toBeNull(); - expect(planStoreMock.updatePlanRecord).toHaveBeenCalledWith( - MOCK_ID, - expect.objectContaining({ status: 'executed' }), - ); - }); - - it('exits 2 when plan is not in approved status', async () => { - planStoreMock.loadPlanRecord.mockReturnValue({ - planId: MOCK_ID, status: 'pending', createdAt: '2024-01-01T00:00:00Z', plan: { version: '1.0', steps: [] }, - }); - const res = await runCli(registerPlanCommand, ['plan', 'execute', MOCK_ID]); - expect(res.exitCode).toBe(2); - expect(res.stderr.join('\n')).toMatch(/pending/i); - }); - - it('exits 2 when plan is not found', async () => { - planStoreMock.loadPlanRecord.mockReturnValue(null); - const res = await runCli(registerPlanCommand, ['plan', 'execute', MOCK_ID]); - expect(res.exitCode).toBe(2); - }); - - it('marks plan as failed when execution errors', async () => { - planStoreMock.loadPlanRecord.mockReturnValue({ - planId: MOCK_ID, - status: 'approved', - createdAt: '2024-01-01T00:00:00Z', - approvedAt: '2024-01-01T01:00:00Z', - plan: { version: '1.0', steps: [{ type: 'command', deviceId: 'BOT1', command: 'turnOn' }] }, - }); - planStoreMock.updatePlanRecord.mockReturnValue({ - planId: MOCK_ID, status: 'failed', createdAt: '2024-01-01T00:00:00Z', plan: { version: '1.0', steps: [] }, - }); - apiMock.__instance.post.mockRejectedValue(new Error('network error')); - - const res = await runCli(registerPlanCommand, ['plan', 'execute', MOCK_ID]); - expect(res.exitCode).toBe(1); - expect(planStoreMock.updatePlanRecord).toHaveBeenCalledWith( - MOCK_ID, - expect.objectContaining({ status: 'failed' }), - ); - }); - - it('plan execute --json returns ran:true on success', async () => { - planStoreMock.loadPlanRecord.mockReturnValue({ - planId: MOCK_ID, - status: 'approved', - createdAt: '2024-01-01T00:00:00Z', - approvedAt: '2024-01-01T01:00:00Z', - plan: { version: '1.0', steps: [{ type: 'command', deviceId: 'BOT1', command: 'turnOn' }] }, - }); - planStoreMock.updatePlanRecord.mockReturnValue({ - planId: MOCK_ID, status: 'executed', createdAt: '2024-01-01T00:00:00Z', plan: { version: '1.0', steps: [] }, - }); - apiMock.__instance.post.mockResolvedValue({ data: { statusCode: 100, body: {} } }); - - const res = await runCli(registerPlanCommand, ['--json', 'plan', 'execute', MOCK_ID]); - expect(res.exitCode).toBeNull(); - const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')) as Record; - const data = expectJsonEnvelopeContainingKeys(out, ['ran', 'planId', 'succeeded']) as { ran: boolean; succeeded: boolean }; - expect(data.ran).toBe(true); - expect(data.succeeded).toBe(true); - }); -}); -``` - -- [ ] **Step 2: Run new tests to verify they pass** - -Run: `npm run test -- tests/commands/plan.test.ts --reporter=verbose 2>&1 | tail -20` - -Expected: All new tests PASS. - -- [ ] **Step 3: Commit** - -```bash -git add tests/commands/plan.test.ts -git commit -m "test: add plan execute coverage" -``` - ---- - -## Task 4: doctor.test.ts — --fix output test (lines 1316–1323) - -**Files:** -- Modify: `tests/commands/doctor.test.ts` - -- [ ] **Step 1: Write failing test for --fix output** - -Add inside the existing `describe('doctor command', ...)` block: - -```typescript -it('--fix prints a Fixes: section with remediation entries', async () => { - // credentials check fails when no token/secret env vars are set (already - // cleared in beforeEach). Running --fix without --yes returns a "manual" - // or "pass --yes" fix entry, which exercises the fixes loop at lines 1316-1323. - delete process.env.SWITCHBOT_TOKEN; - delete process.env.SWITCHBOT_SECRET; - const res = await runCli(registerDoctorCommand, [ - 'doctor', '--section', 'credentials', '--fix', - ]); - const combined = res.stdout.join('\n'); - expect(combined).toContain('Fixes:'); - expect(combined).toMatch(/credentials/); -}); -``` - -- [ ] **Step 2: Run the test to verify it passes** - -Run: `npm run test -- tests/commands/doctor.test.ts --reporter=verbose 2>&1 | grep -E "✓|✗|FAIL|PASS|fixes"` - -Expected: New test PASSES. - -- [ ] **Step 3: Commit** - -```bash -git add tests/commands/doctor.test.ts -git commit -m "test: cover doctor --fix output path (lines 1316-1323)" -``` - ---- - -## Task 5: rules.test.ts — human-mode with data + trace-explain not-found - -**Files:** -- Modify: `tests/commands/rules.test.ts` - -- [ ] **Step 1: Add summary human-mode with data test** - -Inside `describe('rules summary', ...)` (after existing tests), add: - -```typescript -it('prints a table in human mode when entries exist', async () => { - const f = path.join(tmpDir, 'audit-human.log'); - const now = new Date().toISOString(); - fs.writeFileSync( - f, - [ - { t: now, kind: 'rule-fire', rule: { name: 'motion rule', triggerSource: 'mqtt', fireId: 'f1' }, result: 'ok', deviceId: 'D1', command: 'turnOn', parameter: null, commandType: 'command', dryRun: false }, - { t: now, kind: 'rule-fire', rule: { name: 'motion rule', triggerSource: 'mqtt', fireId: 'f2' }, result: 'ok', deviceId: 'D1', command: 'turnOn', parameter: null, commandType: 'command', dryRun: false }, - ] - .map((r) => JSON.stringify(r)) - .join('\n') + '\n', - ); - const { stdout, exitCode } = await runCli(['rules', 'summary', '--file', f]); - expect(exitCode).toBe(0); - const out = stdout.join('\n'); - expect(out).toContain('motion rule'); - expect(out).toContain('2'); // fires count -}); -``` - -- [ ] **Step 2: Add last-fired human-mode with data test** - -Inside `describe('rules last-fired', ...)` (after existing tests), add: - -```typescript -it('prints human-readable rows when entries exist', async () => { - const f = path.join(tmpDir, 'audit-lfhuman.log'); - const ts = '2026-04-25T10:00:00.000Z'; - fs.writeFileSync( - f, - JSON.stringify({ - t: ts, kind: 'rule-fire', rule: { name: 'night-motion', triggerSource: 'mqtt', fireId: 'f1' }, - result: 'ok', deviceId: 'LAMP', command: 'turnOn', parameter: null, commandType: 'command', dryRun: false, - }) + '\n', - ); - const { stdout, exitCode } = await runCli(['rules', 'last-fired', '--file', f]); - expect(exitCode).toBe(0); - const out = stdout.join('\n'); - expect(out).toContain('night-motion'); - expect(out).toContain(ts); -}); -``` - -- [ ] **Step 3: Add trace-explain audit-not-found test** - -At the end of `describe('switchbot rules (commander surface)', ...)`, add: - -```typescript -describe('rules trace-explain', () => { - it('exits 1 when audit log file does not exist', async () => { - const { exitCode, stderr } = await runCli([ - 'rules', 'trace-explain', '--file', path.join(tmpDir, 'no-such-audit.log'), - ]); - expect(exitCode).toBe(1); - expect(stderr.join('\n')).toMatch(/not found/i); - }); -}); -``` - -- [ ] **Step 4: Run new tests to verify they pass** - -Run: `npm run test -- tests/commands/rules.test.ts --reporter=verbose 2>&1 | tail -20` - -Expected: All 3 new tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add tests/commands/rules.test.ts -git commit -m "test: cover rules summary/last-fired human-mode with data, trace-explain not-found" -``` - ---- - -## Task 6: Create tests/lib/daemon-state.test.ts - -**Files:** -- Create: `tests/lib/daemon-state.test.ts` - -- [ ] **Step 1: Write the full test file** - -```typescript -import { describe, it, expect, vi, afterEach } from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { - writeDaemonState, - readDaemonState, - removeDaemonState, - type DaemonState, -} from '../../src/lib/daemon-state.js'; - -describe('daemon-state', () => { - let tmp: string; - let homedirSpy: ReturnType; - - beforeEach(() => { - tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-daemon-state-')); - homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp); - }); - - afterEach(() => { - homedirSpy.mockRestore(); - fs.rmSync(tmp, { recursive: true, force: true }); - }); - - const SAMPLE: DaemonState = { - status: 'running', - pid: 12345, - startedAt: '2024-01-01T00:00:00Z', - logFile: '/tmp/daemon.log', - pidFile: '/tmp/daemon.pid', - stateFile: '/tmp/daemon.state.json', - }; - - it('writeDaemonState creates the state file under ~/.switchbot', () => { - writeDaemonState(SAMPLE); - const stateFile = path.join(tmp, '.switchbot', 'daemon.state.json'); - expect(fs.existsSync(stateFile)).toBe(true); - const parsed = JSON.parse(fs.readFileSync(stateFile, 'utf-8')) as DaemonState; - expect(parsed.status).toBe('running'); - expect(parsed.pid).toBe(12345); - }); - - it('readDaemonState returns the persisted state', () => { - writeDaemonState(SAMPLE); - const result = readDaemonState(); - expect(result).not.toBeNull(); - expect(result!.status).toBe('running'); - expect(result!.pid).toBe(12345); - }); - - it('readDaemonState returns null when no state file exists', () => { - const result = readDaemonState(); - expect(result).toBeNull(); - }); - - it('removeDaemonState deletes the state file', () => { - writeDaemonState(SAMPLE); - const stateFile = path.join(tmp, '.switchbot', 'daemon.state.json'); - expect(fs.existsSync(stateFile)).toBe(true); - removeDaemonState(); - expect(fs.existsSync(stateFile)).toBe(false); - }); - - it('removeDaemonState is a no-op when the state file does not exist', () => { - expect(() => removeDaemonState()).not.toThrow(); - }); - - it('writeDaemonState creates the .switchbot directory if absent', () => { - const switchbotDir = path.join(tmp, '.switchbot'); - expect(fs.existsSync(switchbotDir)).toBe(false); - writeDaemonState(SAMPLE); - expect(fs.existsSync(switchbotDir)).toBe(true); - }); -}); -``` - -- [ ] **Step 2: Run the test file to verify it passes** - -Run: `npm run test -- tests/lib/daemon-state.test.ts --reporter=verbose 2>&1 | tail -15` - -Expected: All 6 tests PASS. - -- [ ] **Step 3: Commit** - -```bash -git add tests/lib/daemon-state.test.ts -git commit -m "test: add daemon-state read/write/remove coverage" -``` - ---- - -## Task 7: Create tests/lib/daemon-socket-path.test.ts - -**Files:** -- Create: `tests/lib/daemon-socket-path.test.ts` - -Note: `getCurrentUserKey()` has a module-level cache (`cachedUserKey`). Tests that need a fresh cache must run in the same describe block before the first platform test sets the cache, or they must reset the env vars to yield a consistent key. - -- [ ] **Step 1: Write the full test file** - -```typescript -import { describe, it, expect, vi, afterEach } from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; - -describe('getDaemonSocketPath', () => { - let savedDescriptor: PropertyDescriptor | undefined; - - afterEach(() => { - if (savedDescriptor) { - Object.defineProperty(process, 'platform', savedDescriptor); - savedDescriptor = undefined; - } - }); - - it('returns a named pipe path on win32', async () => { - savedDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); - Object.defineProperty(process, 'platform', { value: 'win32', configurable: true, writable: false }); - const { getDaemonSocketPath } = await import('../../src/daemon/socket-path.js'); - const p = getDaemonSocketPath(); - expect(p).toMatch(/^\\\\\.\\/); - expect(p).toContain('switchbot-daemon-'); - }); - - it('returns a POSIX socket path on linux', async () => { - savedDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); - Object.defineProperty(process, 'platform', { value: 'linux', configurable: true, writable: false }); - const homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue('/home/testuser'); - try { - const { getDaemonSocketPath } = await import('../../src/daemon/socket-path.js'); - const p = getDaemonSocketPath(); - expect(p).toBe(path.join('/home/testuser', '.switchbot', 'daemon.sock')); - } finally { - homedirSpy.mockRestore(); - } - }); -}); - -describe('isDaemonSocketAvailable', () => { - let savedDescriptor: PropertyDescriptor | undefined; - - afterEach(() => { - if (savedDescriptor) { - Object.defineProperty(process, 'platform', savedDescriptor); - savedDescriptor = undefined; - } - vi.restoreAllMocks(); - }); - - it('always returns true on win32', async () => { - savedDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); - Object.defineProperty(process, 'platform', { value: 'win32', configurable: true, writable: false }); - const { isDaemonSocketAvailable } = await import('../../src/daemon/socket-path.js'); - expect(isDaemonSocketAvailable('/any/path')).toBe(true); - }); - - it('returns true on POSIX when socket file exists', async () => { - savedDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); - Object.defineProperty(process, 'platform', { value: 'linux', configurable: true, writable: false }); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - const { isDaemonSocketAvailable } = await import('../../src/daemon/socket-path.js'); - expect(isDaemonSocketAvailable('/some/daemon.sock')).toBe(true); - }); - - it('returns false on POSIX when socket file does not exist', async () => { - savedDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); - Object.defineProperty(process, 'platform', { value: 'linux', configurable: true, writable: false }); - vi.spyOn(fs, 'existsSync').mockReturnValue(false); - const { isDaemonSocketAvailable } = await import('../../src/daemon/socket-path.js'); - expect(isDaemonSocketAvailable('/some/daemon.sock')).toBe(false); - }); -}); -``` - -- [ ] **Step 2: Run the test file to verify it passes** - -Run: `npm run test -- tests/lib/daemon-socket-path.test.ts --reporter=verbose 2>&1 | tail -15` - -Expected: All 5 tests PASS. (Note: dynamic `await import()` within tests re-reads the module per test, so platform checks are evaluated fresh each time.) - -- [ ] **Step 3: Commit** - -```bash -git add tests/lib/daemon-socket-path.test.ts -git commit -m "test: add daemon socket-path win32/POSIX coverage" -``` - ---- - -## Task 8: config.test.ts — --label / --daily-cap / --default-flags options - -**Files:** -- Modify: `tests/commands/config.test.ts` - -- [ ] **Step 1: Add tests for saveConfig option flags** - -Inside `describe('set-token', ...)` (after existing tests), add: - -```typescript -it('passes --label to saveConfig', async () => { - const res = await runCli(registerConfigCommand, [ - 'config', 'set-token', 'MY_T', 'MY_S', '--label', 'home', - ]); - expect(configMock.saveConfig).toHaveBeenCalledWith( - 'MY_T', 'MY_S', - expect.objectContaining({ label: 'home' }), - ); - expect(res.exitCode).toBeNull(); -}); - -it('passes --daily-cap as a numeric limit to saveConfig', async () => { - const res = await runCli(registerConfigCommand, [ - 'config', 'set-token', 'MY_T', 'MY_S', '--daily-cap', '200', - ]); - expect(configMock.saveConfig).toHaveBeenCalledWith( - 'MY_T', 'MY_S', - expect.objectContaining({ limits: { dailyCap: 200 } }), - ); - expect(res.exitCode).toBeNull(); -}); - -it('passes --default-flags as a split array to saveConfig', async () => { - const res = await runCli(registerConfigCommand, [ - 'config', 'set-token', 'MY_T', 'MY_S', '--default-flags', '--json,--verbose', - ]); - expect(configMock.saveConfig).toHaveBeenCalledWith( - 'MY_T', 'MY_S', - expect.objectContaining({ defaults: { flags: ['--json', '--verbose'] } }), - ); - expect(res.exitCode).toBeNull(); -}); -``` - -- [ ] **Step 2: Run tests to verify they pass** - -Run: `npm run test -- tests/commands/config.test.ts --reporter=verbose 2>&1 | tail -20` - -Expected: All new tests PASS. - -- [ ] **Step 3: Commit** - -```bash -git add tests/commands/config.test.ts -git commit -m "test: cover set-token --label/--daily-cap/--default-flags option paths" -``` - ---- - -## Task 9: auth.test.ts — config parse-error path (lines 312–325) - -**Files:** -- Modify: `tests/commands/auth.test.ts` - -- [ ] **Step 1: Add parse-error tests inside the existing migrate describe block** - -Add these tests inside `describe('auth keychain migrate', ...)`: - -```typescript -it('exits 1 when source config.json contains invalid JSON', async () => { - const file = path.join(tmpHome, '.switchbot', 'config.json'); - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, 'THIS IS NOT JSON'); - const store = makeStore({ writable: true }); - selectMock.mockResolvedValue(store); - const res = await runCli(['auth', 'keychain', 'migrate']); - expect(res.exitCode).toBe(1); - expect(res.stderr.join('\n')).toMatch(/failed to parse/i); -}); - -it('exits 1 when source config.json contains a non-object (array)', async () => { - const file = path.join(tmpHome, '.switchbot', 'config.json'); - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, JSON.stringify([1, 2, 3])); - const store = makeStore({ writable: true }); - selectMock.mockResolvedValue(store); - const res = await runCli(['auth', 'keychain', 'migrate']); - expect(res.exitCode).toBe(1); - expect(res.stderr.join('\n')).toMatch(/failed to parse/i); -}); -``` - -Note: `runCli` in auth.test.ts is the file-local helper (not the shared one from cli.ts). Verify how `runCli` is defined in auth.test.ts — it should accept `['auth', 'keychain', 'migrate']` directly. - -- [ ] **Step 2: Run the tests to verify they pass** - -Run: `npm run test -- tests/commands/auth.test.ts --reporter=verbose 2>&1 | tail -20` - -Expected: Both new tests PASS. - -- [ ] **Step 3: Commit** - -```bash -git add tests/commands/auth.test.ts -git commit -m "test: cover auth migrate config parse-error path" -``` - ---- - -## Task 10: preflight.test.ts — .switchbot dir writable path + nearestExistingPath null - -**Files:** -- Modify: `tests/install/preflight.test.ts` - -- [ ] **Step 1: Add tests for uncovered preflight paths** - -Add these tests inside `describe('runPreflight', ...)`: - -```typescript -it('home check reports ok when ~/.switchbot already exists and is writable', async () => { - const switchbotDir = path.join(tmp, '.switchbot'); - fs.mkdirSync(switchbotDir, { recursive: true }); - const res = await runPreflight(); - const home = res.checks.find((c) => c.name === 'home'); - expect(home?.status).toBe('ok'); - expect(home?.message).toContain(switchbotDir); -}); - -it('agent-skills-dir check is ok when ~/.claude/skills path ancestor is writable', async () => { - // Create ~/.claude so nearestExistingPath resolves to it. - const claudeDir = path.join(tmp, '.claude'); - fs.mkdirSync(claudeDir, { recursive: true }); - const res = await runPreflight({ agent: 'claude-code', expectSkillLink: true }); - const agent = res.checks.find((c) => c.name === 'agent-skills-dir'); - expect(agent?.status).toBe('ok'); -}); - -it('agent-skills-dir check fails when no ancestor of ~/.claude/skills exists', async () => { - // tmp dir does not have ~/.claude, so nearestExistingPath stops at tmp itself - // or returns null if tmp doesn't exist — but tmp is always real. In practice, - // nearestExistingPath will walk up to tmp which is a real dir and is writable, - // so this returns 'ok'. To force the null/not-dir path: mock fs.existsSync to - // always return false so nearestExistingPath returns null. - const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(false); - try { - const res = await runPreflight({ agent: 'claude-code', expectSkillLink: true }); - const agent = res.checks.find((c) => c.name === 'agent-skills-dir'); - expect(agent?.status).toBe('fail'); - expect(agent?.message).toMatch(/cannot resolve/i); - } finally { - existsSpy.mockRestore(); - } -}); -``` - -- [ ] **Step 2: Run the tests to verify they pass** - -Run: `npm run test -- tests/install/preflight.test.ts --reporter=verbose 2>&1 | tail -20` - -Expected: All 3 new tests PASS. - -- [ ] **Step 3: Commit** - -```bash -git add tests/install/preflight.test.ts -git commit -m "test: add preflight .switchbot dir writable + agent-skills-dir null-ancestor paths" -``` - ---- - -## Task 11: Lock thresholds + create coverage annotations doc - -**Files:** -- Modify: `vitest.config.ts` -- Create: `docs/coverage-annotations.md` - -- [ ] **Step 1: Run full coverage to see actual numbers** - -Run: `npm run test -- --coverage --reporter=dot 2>&1 | grep -E "^All files|src/commands"` - -Note the actual line/branch percentages to set the thresholds below them. - -- [ ] **Step 2: Update thresholds in vitest.config.ts** - -Replace the `thresholds` block with values 1-2 points below the actual measurements. Example (adjust to actuals): - -```typescript -// Thresholds locked to post-2026-05-17 backfill actuals. -// Hard ceiling: see docs/coverage-annotations.md for excluded + structurally untestable files. -thresholds: { - lines: 88, - branches: 87, - 'src/commands/**': { - lines: 80, - branches: 78, - }, -}, -``` - -If actual numbers are lower, set thresholds to actual − 1. If higher, set to actual − 1 for safety. - -- [ ] **Step 3: Run tests to confirm no threshold failures** - -Run: `npm run test -- --coverage --reporter=dot 2>&1 | tail -5` - -Expected: Exit 0, no "Coverage threshold not met" errors. - -- [ ] **Step 4: Create docs/coverage-annotations.md** - -```markdown -# Coverage Annotations - -This file documents why certain source files are excluded from the coverage -denominator, and which in-scope sections remain structurally untestable. - -## Hard-excluded from coverage denominator - -These files are in `vitest.config.ts` `coverage.exclude` because they require -live external infrastructure that cannot be mocked at unit-test level: - -| File | Reason | -|------|--------| -| `src/mcp/device-history.ts` | MCP streaming protocol — requires live MCP server | -| `src/mcp/events-subscription.ts` | MCP event subscription — requires live MCP server | -| `src/mqtt/client.ts` | MQTT broker required; class constructor immediately connects | -| `src/llm/providers/anthropic.ts` | Anthropic API key + live HTTPS endpoint required | -| `src/llm/providers/openai.ts` | OpenAI API key + live HTTPS endpoint required | - -## In-denominator but structurally untestable sections - -These sections remain in the coverage denominator but cannot be covered by -unit tests. They are accepted as permanent gaps. - -| File | Lines / Area | Reason | -|------|-------------|--------| -| `src/commands/mcp.ts` | ~2364–2633 | MCP tool-call / resource protocol handlers — live MCP client required | -| `src/commands/rules.ts` | 800–985, 1001–1081 | `simulate` and `trace-explain` subcommands — full rules engine + LLM required | -| `src/status-sync/manager.ts` | WebSocket push path | Live SwitchBot WebSocket connection required | -| `src/policy/migrate.ts` | lines 21–52 | `MIGRATION_CHAIN` is empty; migration step functions exist but are unreachable until v0.3 schema lands | -``` - -- [ ] **Step 5: Commit** - -```bash -git add vitest.config.ts docs/coverage-annotations.md -git commit -m "test: lock coverage thresholds to post-backfill actuals; add coverage-annotations doc" -``` - ---- - -## Self-Review Checklist - -- [x] **Spec coverage:** All 14 deliverables from the spec have a corresponding task -- [x] **No placeholders:** Every step includes the actual code/command -- [x] **Type consistency:** All mock return types match the interfaces imported from source files (`DaemonState`, `PlanRecord`, `PlanStatus`) -- [x] **Mock isolation:** Plan-store mock is declared with `vi.hoisted` and resets in `beforeEach` — won't leak into existing plan tests -- [x] **Task independence:** Tasks 2–10 are independent (can run in any order after Task 1) -- [x] Note: `daemon-socket-path.test.ts` uses dynamic `await import()` per test to re-evaluate the module against the mocked `process.platform`. This is the correct pattern but may log ESM re-import warnings — these are harmless. diff --git a/docs/specs/2026-05-16-test-coverage-improvement-design.md b/docs/specs/2026-05-16-test-coverage-improvement-design.md deleted file mode 100644 index 3f0452d2..00000000 --- a/docs/specs/2026-05-16-test-coverage-improvement-design.md +++ /dev/null @@ -1,94 +0,0 @@ -# Test Coverage Improvement Design - -**Date:** 2026-05-16 -**Branch:** fix/v3.6.2-bugs -**Status:** Approved - -## Problem - -Five UX bugs shipped in v3.6.2 that existing tests did not catch. Root cause analysis identified three structural gaps — not a lack of test files, but incomplete coverage within existing test files: - -1. **Partial exit-code coverage** — `process.exit(1|2)` calls in `src/commands/` have multiple branches; tests only exercised the most common path per command, leaving secondary branches untested. -2. **New options without tests** — `--devices` (plural alias) was added to `plan suggest` with no accompanying smoke test. -3. **Output text not asserted** — Tests verified command structure but not the content of user-visible message lines (e.g., `Remaining budget`, MCP tool count context, completion format values). - -Baseline coverage (2026-05-16): **lines 77.66%, branches 79.7%** overall; `src/sinks/**` drags the average down due to external-infrastructure dependencies. - -## Solution: Three-Layer Defence - -### Layer 1 — Backfill 5 Missing Test Cases - -Add one test per confirmed gap, directly in the existing test files. No new files. - -| File | Test name | Assertion | -|---|---|---| -| `tests/commands/plan.test.ts` | `plan suggest exits 2 when no --device given` | `exitCode === 2`; stderr contains "at least one --device" | -| `tests/commands/plan.test.ts` | `plan suggest accepts --devices as alias for --device` | `exitCode === 0`; stdout contains the deviceId | -| `tests/commands/doctor.test.ts` | `mcp check message includes default profile context` | `detail.message` contains "default profile" and "24 in 'all'" | -| `tests/commands/quota.test.ts` | `status human output includes Remaining budget line` | stdout contains "Remaining budget" | -| `tests/commands/completion.test.ts` | `bash completion includes all --format values` | stdout contains `format_vals` and each of the 7 format keywords | - -### Layer 2 — TESTING.md Convention - -Create `TESTING.md` at the project root. Three rules, each mapping directly to a root cause: - -**Rule 1: Every `process.exit` path needs its own test** -Every `process.exit(1)` or `process.exit(2)` in `src/commands/` must have at least one test case in `tests/commands/` that reaches that path and asserts `exitCode`. New branches must be tested in the same commit. - -**Rule 2: Every new CLI option/alias needs a smoke test** -Every new `.option()` declaration (including aliases) must have at least one test that uses the flag and asserts it is parsed or produces the expected behavior. - -**Rule 3: Non-trivial user-visible messages need keyword assertions** -Any `console.log/error` line containing business-semantic keywords (a word whose removal would confuse the user) must have a corresponding test asserting that keyword appears in stdout/stderr. - -**PR Checklist** (to be added at the end of TESTING.md): -``` -Before merging, verify: -- [ ] New process.exit path → corresponding test added -- [ ] New CLI option/alias → smoke test added -- [ ] Changed user-visible message → keyword assertion updated -``` - -### Layer 3 — Layered Coverage Threshold in vitest.config.ts - -```ts -coverage: { - provider: 'v8', - include: ['src/**/*.ts'], - exclude: ['src/index.ts', 'src/sinks/**'], - reporter: ['text', 'html'], - thresholds: { - lines: 80, // global: +2pp above current baseline - branches: 80, // global: +0.3pp above current baseline - '**': { - branches: 75, // per-file floor: prevents single-file collapse - }, - 'src/commands/**': { - lines: 85, // command layer: higher bar, where bugs occur - branches: 80, - }, - }, -}, -``` - -**Exclusion rationale:** `src/sinks/**` (homeassistant, telegram, openclaw, etc.) requires live external infrastructure and has ~5% coverage today. Including it in thresholds would cause constant false failures unrelated to code quality. - -**Threshold rationale:** After the 5 backfill tests are added, `src/commands/**` is expected to clear 85% lines / 80% branches. The global 80% threshold is set 2–3pp above current to block regression without requiring immediate fixes to unrelated low-coverage areas. - -## Files Changed - -| File | Change | -|---|---| -| `tests/commands/plan.test.ts` | +2 test cases | -| `tests/commands/doctor.test.ts` | +1 test case | -| `tests/commands/quota.test.ts` | +1 test case | -| `tests/commands/completion.test.ts` | +1 test case | -| `TESTING.md` | New file — testing conventions and PR checklist | -| `vitest.config.ts` | Add `thresholds` block; exclude `src/sinks/**` | - -## Success Criteria - -1. All 2465 existing tests continue to pass. -2. 5 new tests pass and cover the previously untested paths. -3. `npm run test -- --coverage` exits 0 with the new thresholds in place. -4. TESTING.md is present at project root and linked from the PR checklist. diff --git a/docs/superpowers/specs/2026-05-21-mcp-test-coverage-design.md b/docs/superpowers/specs/2026-05-21-mcp-test-coverage-design.md deleted file mode 100644 index bf60c5b1..00000000 --- a/docs/superpowers/specs/2026-05-21-mcp-test-coverage-design.md +++ /dev/null @@ -1,102 +0,0 @@ -# MCP Test Coverage Gap Design - -**Date:** 2026-05-21 -**Context:** After fixing 10 smoke-test bugs, two testing patterns were identified that allowed type bugs to slip through. This spec adds targeted tests to close both gaps. - ---- - -## Root Cause Summary - -### A: Schema boundary gap (caused F-3) - -`list_devices` and other MCP tools declare nullable/optional fields in their Zod outputSchema (e.g. `roomID: z.string().nullable().optional()`). All test mocks use "happy path" data — fields either have a value or are omitted. No mock ever passes `null` through the outputSchema validation path. When the real API returns `null` for `roomID`, Zod rejects the value and the entire tool call fails. - -### B: Implicit error format contract (caused F-2 follow-up breakage) - -`mcpError()` in `src/commands/mcp.ts` returns `content[0].text` in a specific multi-line format: -``` - error (code N): -[optional hint] ---- structured --- -{ "error": { ... } } -``` - -No test pins this format. When T5 changed the format from raw JSON to this new layout, `mcp.test.ts` was updated but `dry-run.test.ts` was not — because `parseErrorText()` lived only inside `mcp.test.ts` and the two files had diverged in their parsing assumptions. - ---- - -## Design - -### Component 1: `tests/mcp/output-schema-boundary.test.ts` (new file) - -**Purpose:** Verify that Zod outputSchema validation passes for all realistic API data shapes, including nullable fields and fully-omitted optional fields. - -**What it tests:** -- `list_devices` with a physical device where `roomID: null` and `roomName: null` -- `list_devices` with a physical device where all optional fields are omitted (`deviceType`, `roomID`, `roomName`, `familyName`, `controlType` all absent) -- `list_devices` with an IR device where `controlType: null` -- `list_devices` with a mixed payload: one device with nulls + one without - -**Pattern:** Uses the same `pair()` / `apiMock.__instance.get.mockResolvedValueOnce()` pattern already established in `tests/commands/mcp.test.ts`. Each test asserts `result.isError` is falsy and `structuredContent.deviceList` has the expected length. A Zod rejection would surface as `isError: true`. - -**Why a new file (not extending mcp.test.ts):** `mcp.test.ts` is 1300+ lines. Boundary validation tests belong alongside the existing `tool-schema-completeness.test.ts` and `tool-meta.test.ts` in `tests/mcp/` — that directory is already the home for "schema contract" tests. - ---- - -### Component 2: `tests/helpers/mcp-test-utils.ts` (new file) - -**Purpose:** Single source of truth for MCP error response parsing, shared across all test files. - -**Exports:** -```typescript -/** Extract the structured JSON from an mcpError content[0].text response. */ -export function parseErrorText(text: string): unknown { - const marker = '--- structured ---\n'; - const idx = text.indexOf(marker); - if (idx === -1) return JSON.parse(text); // fallback: old format or non-error - return JSON.parse(text.slice(idx + marker.length)); -} -``` - -**Migration:** Update `tests/commands/mcp.test.ts` and `tests/commands/dry-run.test.ts` to import `parseErrorText` from this helper instead of defining/inlining it. The function signatures and behavior are identical — this is a pure extract-and-import refactor, no behavioral change. - ---- - -### Component 3: `tests/mcp/error-format-contract.test.ts` (new file) - -**Purpose:** Pin the exact text format of `mcpError()` output. Any change to the format in `src/commands/mcp.ts` must update this test, making the implicit contract explicit. - -**What it tests:** -- Calls a tool that will return an error (e.g. `describe_device` with an ID that causes the mock API to reject with `ApiError`) -- Asserts `result.isError === true` -- Asserts `content[0].text` matches the summary line format: `/^(api|runtime|usage|guard) error \(code \d+\): /` -- Asserts `content[0].text` contains the `--- structured ---` separator -- Asserts the text after `--- structured ---\n` is valid JSON with an `error` key -- Asserts `structuredContent.error` exists (the parallel structured payload is unchanged) - -**Why a separate file:** This is explicitly a "contract test" — it documents an interface boundary, not a feature behavior. Placing it in `tests/mcp/` next to `tool-schema-completeness.test.ts` makes the intent clear. - ---- - -## Files Changed - -| File | Action | Purpose | -|---|---|---| -| `tests/mcp/output-schema-boundary.test.ts` | Create | Boundary value tests for nullable/optional fields | -| `tests/helpers/mcp-test-utils.ts` | Create | Shared `parseErrorText` helper | -| `tests/commands/mcp.test.ts` | Modify | Import `parseErrorText` from shared helper | -| `tests/commands/dry-run.test.ts` | Modify | Import `parseErrorText` from shared helper | -| `tests/mcp/error-format-contract.test.ts` | Create | Contract test for `mcpError()` text format | - -No production source files are changed. All changes are in test infrastructure. - ---- - -## Verification - -After implementation: - -1. `npm test` — full suite passes, no regressions -2. Manually break the format: change `mcpError()` to return `JSON.stringify({error:obj})` as text (the old format) → `error-format-contract.test.ts` should fail immediately -3. Manually break a schema: change `roomID: z.string().nullable().optional()` back to `z.string().optional()` → `output-schema-boundary.test.ts` should fail on the `roomID: null` test case -4. Confirm `parseErrorText` is no longer defined inline in either `mcp.test.ts` or `dry-run.test.ts` From 25f2ef8eeb23c2d82e1e37255f740535de6eaedb Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 20:42:12 +0800 Subject: [PATCH 36/72] chore: remove dead web/login.html and local-login-server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser login flow now uses sp.oauth.switchbot.net (hosted OAuth page) and never serves the local login.html. local-login-server.ts was never imported by any command — only by tests that no longer have a production counterpart. Co-Authored-By: Claude Sonnet 4.6 --- scripts/copy-assets.mjs | 1 - src/auth/local-login-server.ts | 311 ---------------- tests/auth/local-login-server.test.ts | 164 --------- web/login.html | 486 -------------------------- 4 files changed, 962 deletions(-) delete mode 100644 src/auth/local-login-server.ts delete mode 100644 tests/auth/local-login-server.test.ts delete mode 100644 web/login.html diff --git a/scripts/copy-assets.mjs b/scripts/copy-assets.mjs index 811ae3d4..83c4919c 100644 --- a/scripts/copy-assets.mjs +++ b/scripts/copy-assets.mjs @@ -8,7 +8,6 @@ const repoRoot = dirname(scriptDir); const assets = [ ['src/policy/schema', 'dist/policy/schema'], ['src/policy/examples', 'dist/policy/examples'], - ['web', 'dist/web'], ]; for (const [srcRel, dstRel] of assets) { diff --git a/src/auth/local-login-server.ts b/src/auth/local-login-server.ts deleted file mode 100644 index 840d5144..00000000 --- a/src/auth/local-login-server.ts +++ /dev/null @@ -1,311 +0,0 @@ -import http from 'node:http'; -import crypto, { randomUUID } from 'node:crypto'; -import axios from 'axios'; -import { getFreePort, escapeHtml, SECURITY_HEADERS } from './utils.js'; -import { - ACCOUNT_CLIENT_ID, - ACCOUNT_API_BASE, - TOKEN_AES_KEY, - TOKEN_AES_IV, - ENDPOINTS, - LOGIN_TIMEOUT_MS, - KNOWN_BOT_REGIONS, - DEFAULT_BOT_REGION, -} from './constants.js'; -import { VERSION } from '../version.js'; -import type { CredentialBundle } from '../credentials/keychain.js'; - -const UA = `switchbot-cli/${VERSION}`; - -// ── Types ───────────────────────────────────────────────────────────────────── - -export interface LoginServerHandle { - /** Port the server is listening on */ - port: number; - /** Resolves with credentials once the user completes login, or rejects on timeout/error. */ - wait(): Promise; -} - -// ── HTML helpers ────────────────────────────────────────────────────────────── - -function successHtml(): string { - return ` -SwitchBot — Login Successful - -
-
-

Login Successful

-

Credentials saved. You can close this tab and return to your terminal.

-
`; -} - -function errorHtml(detail: string): string { - const escaped = escapeHtml(detail); - return ` -SwitchBot — Login Failed - -
-

Login Failed

-

${escaped}

-
`; -} - -// ── Crypto helpers ──────────────────────────────────────────────────────────── - -function decryptField(hexCipher: string): string { - const key = Buffer.from(TOKEN_AES_KEY, 'utf8'); - const iv = Buffer.from(TOKEN_AES_IV, 'utf8'); - const d = crypto.createDecipheriv('aes-128-cbc', key, iv); - const buf = Buffer.concat([d.update(Buffer.from(hexCipher, 'hex')), d.final()]); - return buf.toString('hex'); -} - -/** POST /openapi/openUser/token (v2) → decrypt → CredentialBundle. */ -async function fetchCredentials(accessToken: string, botRegion = DEFAULT_BOT_REGION): Promise { - const wonderBase = `https://wonderlabs.${botRegion}.api.switchbot.net/wonder`; - const resp = await axios.post<{ - statusCode?: number; - body?: Record; - }>( - `${wonderBase}${ENDPOINTS.openUserToken}`, - { operation: 'get', version: 2 }, - { - headers: { - 'Content-Type': 'application/json', - Authorization: accessToken, - 'User-Agent': UA, - }, - timeout: 15_000, - }, - ); - - const body = (resp.data?.body ?? {}) as Record; - const encToken = typeof body['token'] === 'string' ? body['token'] : undefined; - const encSecret = typeof body['secretKey'] === 'string' ? body['secretKey'] : undefined; - - if (!encToken || !encSecret) { - throw new Error( - `openUser/token returned statusCode=${resp.data?.statusCode} ` + - `but token/secretKey missing. Body: ${JSON.stringify(resp.data?.body)}`, - ); - } - - return { token: decryptField(encToken), secret: decryptField(encSecret) }; -} - -// ── Main export ─────────────────────────────────────────────────────────────── - -/** - * Start a local HTTP server that handles the email/password login proxy. - * - * The caller gets back `{ port, wait() }`: - * - Open browser to `http://127.0.0.1:/` - * - Call `wait()` to receive the CredentialBundle once the user finishes login. - * - * The server shuts itself down after the first successful login or on timeout. - */ -export async function bindLoginServer( - timeoutMs = LOGIN_TIMEOUT_MS, -): Promise { - const port = await getFreePort(); - - let resolveResult!: (r: CredentialBundle) => void; - let rejectResult!: (e: Error) => void; - const resultPromise = new Promise((res, rej) => { - resolveResult = res; - rejectResult = rej; - }); - - let finished = false; - - const finish = (creds: CredentialBundle | null, err?: Error) => { - if (finished) return; - finished = true; - clearTimeout(timer); - server.close(); - if (err) rejectResult(err); else resolveResult(creds!); - }; - - const server = http.createServer((req, res) => { - const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`); - - const html = (code: number, body: string) => { - res.writeHead(code, { 'Content-Type': 'text/html; charset=utf-8', ...SECURITY_HEADERS }); - res.end(body); - }; - const json = (code: number, body: object) => { - res.writeHead(code, { 'Content-Type': 'application/json', ...SECURITY_HEADERS }); - res.end(JSON.stringify(body)); - }; - - // ── GET /done — success page ───────────────────────────────────────────── - if (req.method === 'GET' && url.pathname === '/done') { - html(200, successHtml()); - return; - } - - // ── POST /auth/email — email / password proxy ──────────────────────────── - if (req.method === 'POST' && url.pathname === '/auth/email') { - const BODY_LIMIT = 4 * 1024; - let body = ''; - let bodySize = 0; - let over = false; - - req.on('data', (chunk: Buffer) => { - bodySize += chunk.length; - if (bodySize > BODY_LIMIT) { - if (!over) { - over = true; - json(413, { success: false, message: 'Request body too large.' }); - req.resume(); - } - return; - } - body += chunk; - }); - - req.on('end', () => { - if (over) return; - let email: string, password: string; - try { - ({ email, password } = JSON.parse(body)); - } catch { - json(400, { success: false, message: 'Invalid request body.' }); - return; - } - handleEmailLogin(email, password) - .then(creds => { - json(200, { success: true }); - finish(creds); - }) - .catch(err => { - const msg = err instanceof Error ? err.message : String(err); - json(401, { success: false, message: msg }); - }); - }); - return; - } - - res.writeHead(404, { 'Content-Type': 'text/plain', ...SECURITY_HEADERS }); - res.end('Not found'); - }); - - server.listen(port, '127.0.0.1'); - - const timer = setTimeout(() => { - finish(null, new Error('Login timed out. Please run `switchbot auth login` again.')); - }, timeoutMs); - - return { port, wait: () => resultPromise }; -} - -// ── Email / password auth (SwitchBot consumer REST API) ────────────────────── - -/** - * Direct email/password login using the SwitchBot consumer account API. - * - * Flow mirrors customize-login page's Vue component: - * 1. POST /account/api/v2/user/login → access_token (or mfa_token if MFA required) - * 2. If MFA: not yet supported — throw with a helpful message - * 3. POST /account/api/v1/user/userinfo → botRegion - * 4. POST wonderlabs.{botRegion}.api.switchbot.net/homepage/v2/mobile/management/login → openToken + secretKey - */ -async function handleEmailLogin( - email: string, - password: string, -): Promise { - // Step 1 — authenticate with username + password - const loginResp = await axios.post<{ - statusCode: number; - body?: { - access_token?: string; - mfa_token?: string; - mfa_enabled?: boolean; - status?: string; - }; - message?: string; - }>( - `${ACCOUNT_API_BASE}/account/api/v2/user/login`, - { - clientId: ACCOUNT_CLIENT_ID, - deviceInfo: { - deviceId: randomUUID().replace(/-/g, ''), - model: 'CLI', - deviceName: 'switchbot-cli', - }, - grantType: 'password', - username: email, - password, - dialCode: '', - verifyCode: '', - }, - { - headers: { 'Content-Type': 'application/json', 'User-Agent': UA }, - timeout: 15_000, - }, - ); - - const loginBody = loginResp.data?.body ?? {}; - - if (loginResp.data?.statusCode !== 100) { - const msg = loginResp.data?.message ?? `Login failed (statusCode=${loginResp.data?.statusCode})`; - throw new Error(msg); - } - - if (loginBody.mfa_enabled && loginBody.mfa_token) { - throw new Error( - 'This account has MFA enabled. MFA login is not yet supported in CLI browser login. ' + - 'Please disable MFA on your account.', - ); - } - - const accessToken = loginBody.access_token; - if (!accessToken) { - throw new Error(`Login returned no access_token. Body: ${JSON.stringify(loginResp.data?.body)}`); - } - - // Step 3 — look up botRegion for the user's account - const userInfoResp = await axios.post<{ - statusCode?: number; - body?: { botRegion?: string }; - }>( - `${ACCOUNT_API_BASE}${ENDPOINTS.userInfo}`, - {}, - { - headers: { - 'Content-Type': 'application/json', - Authorization: accessToken, - 'User-Agent': UA, - }, - timeout: 15_000, - }, - ); - const rawRegion = userInfoResp.data?.body?.botRegion ?? ''; - const botRegion = KNOWN_BOT_REGIONS.has(rawRegion) ? rawRegion : DEFAULT_BOT_REGION; - - // Step 4 — fetch encrypted credentials from the region-specific Wonder API - return fetchCredentials(accessToken, botRegion); -} diff --git a/tests/auth/local-login-server.test.ts b/tests/auth/local-login-server.test.ts deleted file mode 100644 index 4fcf6ebd..00000000 --- a/tests/auth/local-login-server.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const mockPost = vi.hoisted(() => vi.fn()); -vi.mock('axios', () => ({ default: { post: mockPost } })); - -import { bindLoginServer } from '../../src/auth/local-login-server.js'; - -async function get(port: number, path: string) { - const res = await fetch(`http://127.0.0.1:${port}${path}`); - const body = await res.text(); - const headers: Record = {}; - res.headers.forEach((v, k) => { headers[k] = v; }); - return { status: res.status, body, headers }; -} - -async function postJson(port: number, path: string, data: unknown) { - const res = await fetch(`http://127.0.0.1:${port}${path}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }); - const body = await res.text(); - let json: unknown = null; - try { json = JSON.parse(body); } catch { /* not JSON */ } - const headers: Record = {}; - res.headers.forEach((v, k) => { headers[k] = v; }); - return { status: res.status, body, json, headers }; -} - -// Drain using a short timeout — /callback no longer lives on this server. -async function drain(handle: Awaited>) { - await handle.wait().catch(() => {}); -} - -describe('bindLoginServer — GET /done', () => { - it('returns 200 success page', async () => { - const handle = await bindLoginServer(50); - const resp = await get(handle.port, '/done'); - expect(resp.status).toBe(200); - expect(resp.body).toContain('Login Successful'); - await drain(handle); - }); -}); - -describe('bindLoginServer — 404 fallthrough', () => { - it('returns 404 with security headers for unknown routes', async () => { - const handle = await bindLoginServer(50); - const resp = await get(handle.port, '/unknown-path'); - expect(resp.status).toBe(404); - expect(resp.headers['x-content-type-options']).toBe('nosniff'); - await drain(handle); - }); -}); - -describe('bindLoginServer — POST /auth/email body size cap', () => { - it('returns 413 when body exceeds 4 KB', async () => { - const handle = await bindLoginServer(50); - const bigBody = JSON.stringify({ email: 'a@b.com', password: 'x'.repeat(5000) }); - const res = await fetch(`http://127.0.0.1:${handle.port}/auth/email`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: bigBody, - }); - expect(res.status).toBe(413); - await drain(handle); - }); -}); - -describe('bindLoginServer — POST /auth/email invalid JSON', () => { - it('returns 400 for malformed body', async () => { - const handle = await bindLoginServer(50); - const res = await fetch(`http://127.0.0.1:${handle.port}/auth/email`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: 'NOT JSON', - }); - expect(res.status).toBe(400); - const data = await res.json() as { success: boolean }; - expect(data.success).toBe(false); - await drain(handle); - }); -}); - -describe('bindLoginServer — POST /auth/email happy path', () => { - // AES-128-CBC encrypted values for 'open-tok' and 'sec-key' using the - // hardcoded key/IV from constants.ts (lrQ0OTvwp9RTsXxk / 4mdN27rI3bk2LzWa). - // decryptField returns the plaintext re-encoded as hex, so expected token/secret - // are the hex representations of the original plaintext strings. - const ENC_TOKEN = '4939095e1119e02b75f3f13627738d5d'; // AES-CBC('open-tok') - const ENC_SECRET = 'fcbfccf31f48f07675f4d4a3f6a3add2'; // AES-CBC('sec-key') - const DEC_TOKEN = '6f70656e2d746f6b'; // hex('open-tok') - const DEC_SECRET = '7365632d6b6579'; // hex('sec-key') - - beforeEach(() => { - mockPost.mockReset(); - mockPost - .mockResolvedValueOnce({ data: { statusCode: 100, body: { access_token: 'tok' } } }) // login - .mockResolvedValueOnce({ data: { statusCode: 100, body: { botRegion: 'eu' } } }) // userinfo - .mockResolvedValueOnce({ data: { body: { token: ENC_TOKEN, secretKey: ENC_SECRET } } }); // openUserToken - }); - - it('returns 200 {success:true} and resolves wait() with credentials', async () => { - const handle = await bindLoginServer(30_000); - const resp = await postJson(handle.port, '/auth/email', { email: 'a@b.com', password: 'pw' }); - expect(resp.status).toBe(200); - expect((resp.json as { success: boolean }).success).toBe(true); - const creds = await handle.wait(); - expect(creds).toEqual({ token: DEC_TOKEN, secret: DEC_SECRET }); - }); -}); - -describe('bindLoginServer — POST /auth/email login failure', () => { - beforeEach(() => { - mockPost.mockReset(); - mockPost.mockResolvedValueOnce({ data: { statusCode: 401, message: 'Invalid credentials' } }); - }); - - it('returns 401 without closing the server', async () => { - const handle = await bindLoginServer(50); - const resp = await postJson(handle.port, '/auth/email', { email: 'bad@b.com', password: 'x' }); - expect(resp.status).toBe(401); - expect((resp.json as { success: boolean }).success).toBe(false); - await drain(handle); - }); -}); - -describe('bindLoginServer — botRegion validation', () => { - const ENC_TOKEN = '4939095e1119e02b75f3f13627738d5d'; - const ENC_SECRET = 'fcbfccf31f48f07675f4d4a3f6a3add2'; - - beforeEach(() => { - mockPost.mockReset(); - mockPost - .mockResolvedValueOnce({ data: { statusCode: 100, body: { access_token: 'tok' } } }) - .mockResolvedValueOnce({ data: { statusCode: 100, body: { botRegion: '../../evil' } } }) - .mockResolvedValueOnce({ data: { body: { token: ENC_TOKEN, secretKey: ENC_SECRET } } }); - }); - - it('falls back to "us" region when botRegion contains path-traversal characters', async () => { - const handle = await bindLoginServer(30_000); - const waitP = handle.wait().catch(() => {}); - await postJson(handle.port, '/auth/email', { email: 'a@b.com', password: 'pw' }); - await waitP; - const thirdCallUrl = mockPost.mock.calls[2][0] as string; - expect(thirdCallUrl).not.toContain('evil'); - expect(thirdCallUrl).toContain('.us.api'); - }); -}); - -describe('bindLoginServer — timeout', () => { - it('rejects wait() after timeoutMs', async () => { - const handle = await bindLoginServer(30); - await expect(handle.wait()).rejects.toThrow('Login timed out'); - }); -}); - -describe('bindLoginServer — double-close guard', () => { - it('does not throw ERR_SERVER_NOT_RUNNING when finish() is called twice', async () => { - const handle = await bindLoginServer(20); - const [result] = await Promise.all([handle.wait().catch((e: unknown) => e)]); - expect(result).toBeInstanceOf(Error); - expect((result as Error).message).not.toContain('ERR_SERVER_NOT_RUNNING'); - }); -}); diff --git a/web/login.html b/web/login.html deleted file mode 100644 index b4d36919..00000000 --- a/web/login.html +++ /dev/null @@ -1,486 +0,0 @@ - - - - - - SwitchBot — Sign In - - - -
- - -
- -
-
- -
-

Login Successful

-

Credentials saved. You can close this tab and return to your terminal.

-
- - -
- - - -
-
- or sign in with email -
-
- -
- -
-
- - -
-
-
- - Forgot password? -
- -
- - -
- - -
-
-
- - - - From fa3c40634dc4b8644cf5d18ef3f7c33e7e4dca40 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 20:57:20 +0800 Subject: [PATCH 37/72] chore: remove stale design docs and contrib directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1/3/4 design docs are historical — work is long shipped. contrib/systemd was a one-off example with no active maintenance. Co-Authored-By: Claude Sonnet 4.6 --- contrib/systemd/switchbot-mcp.service | 26 --- docs/design/phase3-install.md | 242 --------------------- docs/design/phase4-rules-schema.md | 155 -------------- docs/design/phase4-rules.md | 294 -------------------------- docs/design/roadmap.md | 287 ------------------------- docs/phase-1-manual-orchestration.md | 149 ------------- 6 files changed, 1153 deletions(-) delete mode 100644 contrib/systemd/switchbot-mcp.service delete mode 100644 docs/design/phase3-install.md delete mode 100644 docs/design/phase4-rules-schema.md delete mode 100644 docs/design/phase4-rules.md delete mode 100644 docs/design/roadmap.md delete mode 100644 docs/phase-1-manual-orchestration.md diff --git a/contrib/systemd/switchbot-mcp.service b/contrib/systemd/switchbot-mcp.service deleted file mode 100644 index 5f88d365..00000000 --- a/contrib/systemd/switchbot-mcp.service +++ /dev/null @@ -1,26 +0,0 @@ -[Unit] -Description=SwitchBot MCP Server -After=network-online.target -Wants=network-online.target - -[Service] -Type=exec -User=switchbot -Group=switchbot -WorkingDirectory=/opt/switchbot -EnvironmentFile=-/etc/switchbot.env -ExecStart=/usr/bin/switchbot mcp serve --port 3030 --bind 127.0.0.1 --auth-token ${SWITCHBOT_MCP_TOKEN} -Restart=always -RestartSec=5 -StandardOutput=journal -StandardError=journal - -# Security hardening -NoNewPrivileges=true -PrivateTmp=true -ProtectSystem=strict -ProtectHome=yes -ReadWritePaths=/opt/switchbot - -[Install] -WantedBy=multi-user.target diff --git a/docs/design/phase3-install.md b/docs/design/phase3-install.md deleted file mode 100644 index bcae1a82..00000000 --- a/docs/design/phase3-install.md +++ /dev/null @@ -1,242 +0,0 @@ -# Phase 3 — one-command install design - -> Status: **in-CLI shipped (3B-lite) in v2.10.0**. Phase 3A landed -> in v2.8.x: `src/credentials/keychain.ts` abstraction with four -> backends, the `switchbot auth keychain` subcommand group, doctor + -> agent-bootstrap integration, and an in-repo `src/install/` library -> (preflight + rollback-aware step runner). v2.10.0 wraps that -> library as the built-in `switchbot install` / `switchbot uninstall` -> commands — the 7-step Quickstart collapses to a single command -> with rollback on failure. The external `openclaw plugins install` -> wrapper and the ClawHub registry entry remain Phase 3B proper and -> live outside this repo. - -## Implementation delta (what changed from this design) - -This document was written before `switchbot install` shipped. The body -below describes the original design intent (`openclaw plugins install` -surface). What actually landed in v2.10.0 differs in three ways: - -| Design doc says | What shipped | -| --- | --- | -| Entry point: `openclaw plugins install clawhub:switchbot` | Built-in: `switchbot install` (no ClawHub dependency) | -| Step 2: `npm i -g @switchbot/openapi-cli` | Skipped — CLI already in PATH is the precondition | -| Step 8: `switchbot doctor` failure → full rollback | `--verify` flag makes doctor a warn-only post-step; failure never triggers rollback | -| Uninstall: `openclaw plugins uninstall` | Built-in: `switchbot uninstall [--purge]` | - -Additional flags not in this design: `--force` (replace existing -symlink), `--verify` (opt-in post-install doctor check), `--purge` -(shorthand for `--yes --remove-creds --remove-policy`). - -## Goal - -Today, getting an AI agent to drive SwitchBot is a 15-minute manual -flow: install npm package, set token, create policy, install skill, -restart agent. Phase 3 collapses that to: - -```bash -openclaw plugins install clawhub:switchbot -``` - -On success, every check passes: `switchbot doctor` → all green, the -skill is discoverable from the user's agent of choice, and credentials -live in the OS keychain (not a `0600` JSON on disk). - -## Non-goals - -- Phase 3 does **not** ship the rule engine (that's Phase 4). -- Phase 3 does **not** rewrite the CLI. Everything it installs is the - same CLI users install with `npm i -g` today; the plugin just - automates the bootstrap. -- Phase 3 does **not** manage multiple SwitchBot accounts at install - time — first account only. A second account is a follow-up install - with `--profile `. - -## High-level flow - -```text -plugins install switchbot - │ - ▼ -1. Pre-flight checks (Node >= 18, npm on PATH, agent installed, conflict scan) - │ → abort with actionable error if any fails - ▼ -2. CLI install (`npm i -g @switchbot/openapi-cli`) - │ → rollback step: `npm rm -g @switchbot/openapi-cli` - ▼ -3. Credential capture (interactive prompt; tokens read into memory only) - │ → rollback step: delete keychain entry - ▼ -4. Keychain write (via Keychain abstraction — see below) - │ → rollback step: delete the entry - ▼ -5. Bridge CLI → keychain (CLI reads via the credential-store - │ abstraction; disk fallback remains available) - ▼ -6. Skill install (symlink skill repo into agent's skills dir) - │ → rollback step: remove the symlink - ▼ -7. Policy scaffold (`switchbot policy new` if file absent) - │ → rollback step: remove the file only if WE created it - ▼ -8. Doctor verification (`switchbot doctor --json` — must report 0 fail) - │ → on fail, run full rollback chain - ▼ -9. Summary + next steps (print the three things the user can say to - their agent to confirm it works) -``` - -Every step records an **undo action**. If any step after step 2 fails, -the installer walks the undo stack in reverse. Failure of an undo -step itself is logged loudly but does not halt the rollback — better -to leave a partial mess than a partial state the user can't reason -about. - -## Keychain abstraction - -Credentials today can still live in `~/.switchbot/config.json` with -`0600` permissions, but the shipped runtime now prefers the native OS -keychain and falls back to the file backend only when no writable -native store is available. - -Interface (implemented in `src/credentials/keychain.ts`): - -```typescript -interface CredentialStore { - name: 'keychain' | 'credman' | 'secret-service' | 'file'; - - get(profile: string): Promise<{ token: string; secret: string } | null>; - set(profile: string, creds: { token: string; secret: string }): Promise; - delete(profile: string): Promise; - - // Diagnostics — used by `switchbot doctor` to report which backend - // the current install is using without leaking the material. - describe(): { backend: string; writable: boolean; notes?: string }; -} -``` - -Backend selection at runtime: - -| OS | First choice | Fallback chain | -| --- | --- | --- | -| macOS | `Keychain` via `security(1)` | `file` (same 0600 json today) | -| Windows | `Credential Manager` via PowerShell + Win32 `CredReadW` / `CredWriteW` | `file` | -| Linux | `libsecret` via `secret-tool` | `file`, with a `doctor` warning | - -The fallback exists because Linux desktops without a running -keyring daemon (SSH sessions, headless) would otherwise fail the -install. The `file` backend keeps today's `0600` behavior. `doctor` -surfaces which backend is active so users aren't surprised. - -Key naming convention (service = `com.openclaw.switchbot`; account = -`:token` and `:secret`). Two entries per profile, -not one, so `security(1)` / `secret-tool` scripting doesn't require -JSON parsing. - -## Pre-flight checks (step 1) - -Every check produces either `ok`, `warn` (continue), or `fail` (abort). -Failures must print: - -- what failed -- how to fix it manually -- what state the system is in (nothing changed yet) - -Checks: - -| Check | Pass | Fail action | -| --- | --- | --- | -| `node --version` >= 18 | Continue | Abort, print Node install URL | -| `npm` on PATH | Continue | Abort, print PATH fix hint | -| No existing `switchbot` binary at a different version | Continue | Warn if <2.8.0, offer `--upgrade` | -| No existing `~/.config/switchbot/policy.yaml` OR the existing one validates | Continue | Warn; skip policy scaffold step | -| Target agent installed (Claude Code / Cursor / Copilot / ...) | Continue | Warn; install anyway, skip step 6 | -| Network to `npmjs.org` + `api.switch-bot.com` | Continue | Abort with diagnostics | - -## Credential capture (step 3) - -Interactive only. **Tokens MUST NOT** be passed as CLI args (shell -history, process listing). The prompt: - -```text -Paste your SwitchBot TOKEN (Profile → App Version x10 → Developer Options): -Paste your SwitchBot SECRET: -``` - -Input is captured with echo disabled on platforms that support it. On -a TTY-less install (CI-driven?), fail fast with exit code 3 and a hint -pointing at the `plugins install --token-file ` escape -hatch (which reads a two-line file and deletes it on success). - -## Skill install (step 6) - -The installer handles Claude Code natively (`~/.claude/skills/` symlink) -and delegates others to the recipes under -`companion-skill/docs/agents/*.md` — printing the relevant -one-liner rather than automating it. Rationale: Cursor / Copilot / -Gemini / Codex all have different edge cases around where -instructions files live, and automating all of them exceeds the -install-time budget. Printing the recipe gets the user 90% of the way -with zero surprise. - -If the user passed `--agent claude-code`, the automation path runs and -records an undo. Otherwise the step is informational. - -## Uninstall - -Parity with install: - -```bash -openclaw plugins uninstall -``` - -Walks the exact reverse of the install flow. Prompts before each -destructive step (delete keychain entry, remove policy, uninstall CLI) -and defaults the dangerous ones to "no": - -```text -Remove SwitchBot credentials from keychain? [y/N] -Remove policy.yaml at ~/.config/switchbot/policy.yaml? [y/N] -Uninstall @switchbot/openapi-cli globally? [y/N] -Remove skill link ~/.claude/skills/switchbot? [Y/n] -``` - -The symlink-removal default flips to yes because it's cheap to -recreate and is almost never what the user wants to preserve. - -## Testing strategy - -- **Unit**: keychain backends each get a pure-TS test matrix using a - mock native binding. Real keychain writes only run on CI labeled - `integration-keychain`. -- **Integration (per OS)**: one VM per target OS in CI runs the full - install → verify → uninstall cycle against a mock SwitchBot API. -- **Rollback**: every undo step gets a failure-injection test - (`force: ['step-3']` → install step 3 throws, installer must leave - steps 1+2 intact and step 4+ un-run). -- **Doctor parity**: a pre-install `doctor --json` vs post-uninstall - `doctor --json` must differ by exactly the install footprint, no - stray state left behind. - -## Open questions - -- Installer language: Node (matches CLI), Go (single binary, easier - distribution), or shell (zero deps, painful Windows story). Leaning - **Node** — reuses the CLI's HTTP client, npm install step becomes - trivial, and we can distribute as another npm package. -- `@switchbot/plugin-skill` vs `registry:switchbot` naming. Defer - until the external registry is live. -- How does the installer know which skill commit to link? Pin to the - version in the plugin's own `package.json` (dep on - `companion-skill@^0.2`)? Git-clone main? Deferred — the - choice affects reproducibility and update UX. - -## Dependencies on other Phase 3 tracks - -- The external plugin-manager install command (generic framework) -- An external registry entry for `switchbot` -- Native bindings for each keychain backend are explicitly out of - scope; the shipped implementation shells out to OS tooling instead - -None of these are in scope for this document; it only covers what the -SwitchBot side of the install needs to look like. diff --git a/docs/design/phase4-rules-schema.md b/docs/design/phase4-rules-schema.md deleted file mode 100644 index ba2800e5..00000000 --- a/docs/design/phase4-rules-schema.md +++ /dev/null @@ -1,155 +0,0 @@ -# Policy schema v0.2 — design notes - -> Status: **active (v0.2)**. The schema lives at -> `src/policy/schema/v0.2.json` and is wired into -> `switchbot policy validate`. New policies default to v0.1; run -> `switchbot policy migrate` to upgrade opt-in. This document is kept as -> the historical rationale for the shape. - -## Why draft now - -The Phase 4 rule engine needs a home in `policy.yaml`. v0.1 already -reserves an `automation` block with `enabled` and a loose `rules` array -of objects, but the item shape was left unspecified — anyone wiring up -a rule engine today would either have to invent a shape and hope it -aligns, or hard-code rules outside `policy.yaml`. Pinning the shape -early lets: - -- Phase 4 ship by migrating v0.1 → v0.2 via `switchbot policy migrate` - without introducing a competing file. -- Doc work on the rule DSL proceed against a concrete schema. -- Policy consumers (skills, tooling) rely on the shape the validator - will eventually enforce. - -## What changes from v0.1 - -- `version` constant flips to `"0.2"`. -- `automation.rules[]` gains a real item schema (`$defs/rule`) that - requires `name`, `when`, and `then`. -- `automation.rules` becomes nullable (parity with other top-level - blocks). -- Every other v0.1 block is **unchanged** and retains its existing - null-allowance and field types. The migration is additive. - -## Rule shape (summary) - -```yaml -automation: - enabled: true - rules: - - name: "hallway motion at night" - when: - source: mqtt - event: motion.detected - device: "hallway sensor" - conditions: - - time_between: ["22:00", "07:00"] - then: - - command: "devices command turnOn" - device: "hallway lamp" - throttle: - max_per: "10m" - dry_run: true -``` - -Fields: - -| Field | Required | Purpose | -|---|---|---| -| `name` | yes | Unique label; used in audit log and dry-run output | -| `enabled` | no (default `true`) | Disable a single rule without deleting it | -| `when` | yes | Trigger; one of three shapes (mqtt / cron / webhook) | -| `conditions` | no | AND-joined predicates; `time_between` or device-state compare | -| `then` | yes (`minItems: 1`) | Ordered list of actions | -| `throttle.max_per` | no | Min spacing between fires, e.g. `"10m"` | -| `dry_run` | no (default `true`) | Write audit entries but skip the API | - -### `when` (trigger) — `oneOf` - -1. **mqtt**: `{ source: mqtt, event: , device?: }` - — consumed from the `switchbot events mqtt-tail --json` stream. -2. **cron**: `{ source: cron, schedule: <5-field expression>, - days?: }` — local system timezone. `days` is an - optional list of weekday names (`mon`–`sun` or `monday`–`sunday`, - case-insensitive) added in v2.11.0. -3. **webhook**: `{ source: webhook, path: /foo }` — local HTTP path. - Transport/auth are Phase 3 concerns. - -### `conditions[]` — `oneOf` - -1. **time_between**: `[start, end]` (HH:MM). Overnight allowed (end < - start). -2. **device_state**: `{ device, field, op, value }` for comparing a - status field (e.g. `online == true`, `brightness > 50`). -3. **all**: `{ all: [condition, ...] }` — all sub-conditions must pass - (v2.11.0). -4. **any**: `{ any: [condition, ...] }` — at least one must pass - (v2.11.0). -5. **not**: `{ not: condition }` — inverts a single condition - (v2.11.0). - -Conditions 3–5 nest recursively via `$ref: "#/$defs/condition"` in the -JSON Schema. The top-level `conditions[]` array is AND-joined. - -### `then[]` — actions - -```json -{ "command": "devices command turnOn", "device": "hallway lamp", "args": {...}, "on_error": "continue" } -``` - -The engine renders `switchbot ` with `` substituted from -the resolved `device`, appends `--audit-log`, and expands `args` to -`--key value` flags. Safety tiers still gate: destructive actions in -`then[]` are rejected at policy validation time, not at run time. - -## What is deliberately out of scope for v0.2 - -- **Cross-rule composition** (one rule triggering another). Rules are - flat; if chaining is needed, model it as a cron or webhook trigger. -- **State machines / debounce** beyond `throttle`. If a sensor bounces, - `throttle` covers the common case; more sophisticated behavior stays - outside the schema. -- **Templating** (Jinja-like syntax in `args`). Opens attack surface; - revisit in v0.3 if real users demand it. -- **Profile-scoped rules**. Today all profiles share one policy file; - profile-aware policy paths are a separate enhancement tracked in - `docs/policy-reference.md`. - -## Migration plan (v0.1 → v0.2) - -`switchbot policy migrate` will: - -1. Read the current file + `version` field. -2. If `version == "0.1"`: rewrite `version: "0.2"` and no-op every - other block (all v0.1 shapes are strict subsets of v0.2). -3. If `automation.rules` exists but isn't empty, validate each rule - against the v0.2 rule schema **before** rewriting. If any rule - fails, abort the migration and print the line-accurate error. -4. If `version == "0.2"`: exit 0 with `status: already-current`. -5. If `version > "0.2"`: exit 6 with `unsupported-version` (the CLI - refuses to downgrade). - -Because v0.2 is purely additive, a v0.1 file with `automation.rules: -[]` or `automation: { enabled: false }` migrates without any user- -visible change except the version constant. - -## Validator wiring (as shipped) - -The steps below are recorded for historical context — all have been -completed: - -1. ~~Rename `v0.2.draft.json` → `v0.2.json`~~ — done; active schema - is at `src/policy/schema/v0.2.json`. -2. ~~Mirror to `examples/policy.schema.json` in the skill repo~~ — CI - already diffs these. -3. `src/policy/validate.ts` dispatches on `version` and picks `0.1` - or `0.2` schema. Active. -4. v0.2 test matrix at `tests/policy/validate-v0.2.test.ts`. Active. -5. CLI version bumped at Phase 4 ship. - -## References - -- `src/policy/schema/v0.1.json` — the v0.1 schema -- `src/policy/schema/v0.2.json` — the active v0.2 schema -- `docs/design/phase4-rules.md` — the runtime behavior side -- `docs/policy-reference.md` — user-facing field reference diff --git a/docs/design/phase4-rules.md b/docs/design/phase4-rules.md deleted file mode 100644 index f42c41d5..00000000 --- a/docs/design/phase4-rules.md +++ /dev/null @@ -1,294 +0,0 @@ -# Phase 4 — rule engine design - -> Status: **Shipped (v0.2, extended in v2.11.0)**. The engine is -> implemented in `src/rules/engine.ts` and wired to the CLI via -> `switchbot rules lint | list | run | reload | tail | replay`. All -> three triggers (MQTT / cron / webhook) + conditions (see below) + -> per-rule `throttle` + `dry_run` fire end-to-end. v2.11.0 added -> `days` weekday filter on cron triggers and `all`/`any`/`not` -> condition composition. Companion to -> `docs/design/phase4-rules-schema.md`, which specifies the -> `automation.rules[]` shape in `policy.yaml`. - -## Goal - -Let users express automations declaratively in `policy.yaml`: - -```yaml -automation: - enabled: true - rules: - - name: "hallway motion at night" - when: { source: mqtt, event: motion.detected, device: "hallway sensor" } - conditions: - - time_between: ["22:00", "07:00"] - then: - - { command: "devices command turnOn", device: "hallway lamp" } - throttle: { max_per: "10m" } -``` - -…and have the engine execute them without the user writing a shell -pipeline, without a separate daemon, and without losing the safety -rails (`audit-log`, `--dry-run`, tier gates) the CLI already has. - -## Non-goals - -- **Cross-device state machines**. If a rule needs "armed → triggered → - disarmed" transitions, model each transition as a separate rule. If - that's not enough, use a real automation platform (Home Assistant, - Node-RED) and let it call the CLI. -- **UI for editing rules**. Rules live in `policy.yaml`. Editors use - VS Code + the JSON Schema mirror for autocomplete. -- **Templating inside commands**. The v0.2 schema deliberately has no - `{{ vars }}` syntax in `args`. Attack surface is too big. Revisit - in v0.3 only if concrete demand appears. - -## Architecture - -``` - ┌────────────────────────────────────┐ - │ switchbot rules run │ - │ (one foreground process) │ - └──────────────┬─────────────────────┘ - │ - ┌────────────┬───────────────┼─────────────┐ - │ │ │ │ - ▼ ▼ ▼ ▼ -MQTT source Cron scheduler HTTP listener Signal handler -(events mqtt-tail) (node-cron or equivalent) (webhook path) (SIGHUP = reload) - │ │ │ │ - └──────────┬─┴───────────────┴─────────────┘ - ▼ - ┌─────────────────────┐ - │ rule matcher │ — does any rule's `when` match this event? - └────────┬────────────┘ - ▼ - ┌─────────────────────┐ - │ condition evaluator │ — do all `conditions` pass? - └────────┬────────────┘ - ▼ - ┌─────────────────────┐ - │ throttle gate │ — is the rule's throttle window clear? - └────────┬────────────┘ - ▼ - ┌─────────────────────┐ - │ action executor │ — render `switchbot ` per action - └────────┬────────────┘ - ▼ - audit log (kind=rule-fire) + stderr summary -``` - -Single foreground process. No daemon, no IPC, no database. State the -engine needs (throttle timers, last-fire times, dedup window) lives in -memory. Restart = state reset — documented behavior. - -## Triggers - -### `source: mqtt` - -The engine opens its own MQTT connection (same broker the CLI uses -today) rather than piping from `events mqtt-tail`. Rationale: - -- Shared credential + reconnect logic with the rest of the CLI -- No subprocess management; one less failure mode -- `events mqtt-tail` continues to exist for interactive use; the rule - engine is a peer consumer, not a downstream consumer - -Event match is exact string on the `event` field (`motion.detected`, -`contact.opened`, etc.) and, if `device` is set, the resolved deviceId -or alias must match the event's `deviceId`. - -### `source: cron` - -Standard 5-field cron, evaluated in the local system timezone. Uses -`node-cron` or equivalent; no DST cleverness (cron inherits the usual -"run twice on fall-back, skipped on spring-forward" behavior — we -don't silently paper over this). - -Optional `days` filter (v2.11.0): a list of weekday names -(`mon`–`sun` or `monday`–`sunday`, case-insensitive) applied *after* -the cron fires. Firings on unlisted weekdays are suppressed before -dispatch — throttle counters and audit entries are not written for -suppressed firings. - -### `source: webhook` - -The engine binds an HTTP listener on localhost (port from CLI config, -default 18790 to avoid conflict with a local agent gateway on 18789). -Authentication is a static bearer token generated at first run and -stored alongside credentials. External callers (IFTTT, HA, whatever) -POST JSON to the configured `path`; the body becomes the trigger -payload available to `conditions`. - -## Conditions - -Evaluated and AND-joined at the top level; all failures are collected -and surfaced together (not short-circuited on the first). Four shapes: - -- **`time_between: [start, end]`** — HH:MM, local system time. - Overnight crossing supported. -- **`{ device, field, op, value }`** — reads `switchbot devices status - --json` (cached per-tick; see performance below) and - applies the comparison. Operators: `==`, `!=`, `<`, `>`, `<=`, `>=`. -- **`all: [condition, ...]`** *(v2.11.0)* — all sub-conditions must - pass (logical AND over a sub-list). -- **`any: [condition, ...]`** *(v2.11.0)* — at least one sub-condition - must pass (logical OR). -- **`not: condition`** *(v2.11.0)* — inverts a single condition. - -Composites nest arbitrarily. The top-level `conditions[]` array remains -AND-joined across its entries, so `conditions: [A, any: [B, C]]` -means `A AND (B OR C)`. - -A future v0.3 might add more leaf shapes (`and`/`or` at the leaf level -were folded into the composite nodes above). - -## Actions - -Each `then[]` entry is one of two types: - -**`type: command`** (default) — renders to: - -``` -switchbot substituted> --audit-log -``` - -**`type: notify`** — delivers a payload to an external channel: - -```yaml -- type: notify - channel: webhook # webhook | file | openclaw - to: https://your.host/hook - template: '{"rule":"{{ rule.name }}","fired":"{{ rule.fired_at }}"}' -``` - -Channels: `webhook` (HTTP POST), `file` (append JSONL), `openclaw` (HTTP POST). Template supports `{{ rule.name }}`, `{{ event.* }}`, `{{ device.id }}` placeholders. Audit gains `rule-notify` kind for every notify dispatch. - -Rules: - -1. **Safety tier gates still apply.** If the rendered command is - tier `destructive`, the engine refuses to run it unless - `confirmations.never_confirm` explicitly allows it — and even - then, destructive actions in `never_confirm` are blocked by the - policy validator (see policy-reference.md). Effectively, no - destructive automations ship in v0.2. -2. **IR "fire and forget"** actions run, but the audit entry records - `verified: false` because no post-action status check is possible. -3. **`on_error: continue`** (default) runs the remaining `then[]` - entries after a failure. `on_error: stop` halts the rule after the - first failing action and records subsequent actions as `skipped`. - -## Throttling - -Per-rule, keyed by `(rule.name, triggerDeviceId or '')`. When a rule -fires, a timer starts; subsequent matches within `max_per` are -suppressed. Suppressed events are audit-logged with -`kind: rule-throttled` so users can see what got dropped. - -## `dry_run: true` - -When set, the engine: - -1. Evaluates trigger + conditions normally. -2. Renders the action command. -3. Writes `kind: rule-fire-dry` to the audit log with the rendered - command and the reason it would have fired. -4. Does **not** hit the SwitchBot API. - -Used for validating a rule in production without side effects. The -CLI grows a `switchbot rules lint` command that performs a static -check (policy valid + all aliases resolve + no destructive actions), -but dry-run is the live complement. - -## Audit replay - -```bash -switchbot rules replay --since 24h --json -``` - -Reads `audit.log`, filters for `kind: rule-fire` and `kind: -rule-throttled`, and emits a summary per rule (fire count, throttle -count, first/last times, success rate). Read-only, no side effects, -fast. - -## Hot reload - -`SIGHUP` to the running `switchbot rules run` process: - -1. Re-reads `policy.yaml` + re-validates. -2. If valid, swaps the rule set atomically. -3. If invalid, prints the error and keeps the old rules live. - -No restart required for common edits. `SIGTERM` triggers a graceful -shutdown (drain pending actions, close MQTT, exit 0). - -## Performance and resource budget - -- Cold start to first fire: < 5s on a 10-rule policy. -- Per-event latency (MQTT arrival → action executed): < 500ms p95. -- Memory ceiling: < 100 MB resident, regardless of event rate. -- CPU: idle < 1%, p95 < 5% during burst. -- Device-state reads (for `{device,field,op,value}` conditions) go - through the cache with a 5s coalescing window — two rules needing - the same device's state in the same tick share one API call. - -These are targets, not hard gates. A single failing run on a slow -Pi 3 shouldn't block the release — but if the median run fails them, -we've mis-designed. - -## Observability - -- Every rule fire, throttle, or failure appends a structured line to - `audit.log`. Schema is the existing audit envelope + a new `rule` - block with `{name, triggerSource, matchedDevice, fire_id}`. -- `switchbot rules list` — static view of loaded rules + their last - fire time from audit log. -- `switchbot rules tail` — stream-mode view of firings, like `tail -f` - but parsed. - -No Prometheus, no OpenTelemetry in v0.2. Users who want metrics scrape -audit.log with `jq` or ship it to their existing stack. - -## Security considerations - -- Webhook listener binds `127.0.0.1` only; no exposed ports without - explicit CLI config. -- Bearer token for webhook is rotated with `switchbot rules webhook- - rotate-token`. Stored in keychain (Phase 3 dependency). -- Rule files are user-readable `policy.yaml`; no privilege escalation - risk. -- No arbitrary shell execution — the `command` field is parsed, not - `eval`'d. Only `switchbot ...` shapes are allowed. - -## Testing strategy - -- **Unit**: trigger matchers, condition evaluators, throttle gate, - action renderer — each in isolation with mocked inputs. -- **Integration**: full engine spun up against a mock MQTT broker and - mock SwitchBot API. Rule firings asserted by audit-log tail. -- **Fuzz**: random valid rule sets + random event streams → no - crashes, no memory growth, audit log lines always parse. -- **Dry-run**: for every integration case, also run with - `dry_run: true` and assert the API mock saw zero mutating calls. - -## Open questions - -- Where does `switchbot rules run` live on disk? As a subcommand of - the CLI (simplest, one binary) or a sibling package - `@switchbot/rules-engine`? Leaning **subcommand** — it shares the - HTTP client, audit log writer, and cache with the rest of the CLI. -- How do we signal rule-engine health to `switchbot doctor`? Add a - `rules: ok|fail|disabled` row when Phase 4 ships. -- Should `dry_run: true` still write to the audit log under the same - retention as real fires, or go to a side file? Current design says - same file, tagged — simpler, and the user already tails that file. - -## Dependencies on other work - -- **Phase 3 install flow** — keychain for webhook bearer token, plugin - surface for exposing `switchbot rules run` as a service. -- **Policy schema v0.2** — specified in `phase4-rules-schema.md`; - must be validator-active before the engine ships. -- **CLI MQTT client generalization** — currently wired for `events - mqtt-tail`. Need a shared connector so the engine and the CLI - surface can coexist cleanly. diff --git a/docs/design/roadmap.md b/docs/design/roadmap.md deleted file mode 100644 index e5d54e94..00000000 --- a/docs/design/roadmap.md +++ /dev/null @@ -1,287 +0,0 @@ -# Roadmap — Phase 1 through Phase 4 - -> **Status as of 2026-05-15:** Phase 1 complete, Phase 2 complete, -> Phase 3A complete (keychain + install library + built-in CLI install -> command), Phase 3B tracked in the separate companion skill repo, -> Phase 4 shipped at v0.2 (rules engine with MQTT + cron + -> webhook triggers, condition composition, weekday filter). -> Tracks β / γ / δ / ε / ζ shipped between v2.10.0 and v2.13.0; -> v2.14.0 extends MCP with `plan_run`, `audit_query`, `audit_stats`, -> and `policy_diff`; v2.15.0 flips `policy new` default schema to v0.2 -> and starts the v0.1 deprecation window. -> Tracks θ (notify actions) and η (LLM-backed rule suggestion) -> shipped in v3.0. Track κ (AI decision loop — `rules trace`, -> `rules trace-explain`, `llm` conditions, `rules simulate`) -> shipped in v3.6.x. Track μ (catalog sync — Weather Station, Lock -> Vision, Lock Vision Pro, Smart Lock Pro Wifi alias, AI Art Frame -> `uploadImage`) and Track λ (USD/token budget, cross-event -> aggregation, local LLM providers, JSON-RPC IPC) are queued for -> the next release. -> Note: Track γ is a runtime capability increment on the v0.2 rule -> model, not a separate policy schema version. - -This file is the **single source of truth** for phase numbering across -the two repos in this project: - -| Repo | What it delivers | Uses phases? | -|----------------------------------------|-------------------------------------------|-------------------------------------------| -| `switchbot-openapi-cli` (this repo) | CLI binary, MCP server, rules engine | **Yes** — Phase 1/2/3/4 are defined here | -| companion skill repo (sibling) | Conversational skill packaging of the CLI | **No** — uses orthogonal `autonomyLevel` | - -The skill repo does **not** re-number phases. It declares -`tracksCliPhase: ">=4"` and an autonomy dimension -(`autonomyLevel: L1 | L2 | L3`). The phase table below is what it -points back to. - -## Completion matrix (scope clarity) - -| Capability | This repo (`switchbot-openapi-cli`) | Cross-repo (`+ companion skill repo`) | Notes | -| --- | --- | --- | --- | -| Phase 1 (manual orchestration) | Shipped | Shipped | Stable in v2.7.x | -| Phase 2 (policy tooling) | Shipped | Shipped | v0.2 policy schema (v0.1 removed in v3.0) | -| Phase 3A (keychain + install CLI) | Shipped | Shipped | `switchbot install` / `switchbot uninstall` | -| Phase 3B (skill packaging + external registry) | External tracking only | In progress outside this repo | Owned by companion skill repo | -| Phase 4 (rules engine, v0.2 model) | Shipped | Shipped | MQTT/cron/webhook + `days` + `all`/`any`/`not` | -| Track β / γ / δ / ε | Shipped | Shipped (β partially external for registry publish) | γ is a v0.2 capability increment | - ---- - -## The four phases (delivery dimension) - -Each phase is a **shipped capability**, not a time box. The CLI binary -at the phase's tag is usable end-to-end on its own — there is no phase -that requires a later phase to be useful. - -### Phase 1 — Manual orchestration foundation *(shipped, v2.7.x)* - -**What it is:** the stable CLI that an operator (or agent) can drive -command by command. Read device state, send commands, watch events, -keep an audit trail. Everything an agent needs to *execute* — nothing -that *decides*. - -Surfaces that landed in Phase 1: - -- `devices list | status | command | batch | watch` -- `events tail | mqtt-tail` (cloud-issued MQTT, no extra broker) -- `scenes list | run` -- `webhook setup | query | delete` -- `plan run | validate` (JSON batch executor with dry-run preview) -- `history show | replay`, `audit.log` JSONL writer -- `catalog show | diff`, `schema export`, `capabilities --json` -- `doctor` smoke test -- `mcp serve` (stdio + Streamable HTTP) for AI agents -- `agent-bootstrap --compact` cold-start snapshot -- Global flags: `--json`, `--format`, `--dry-run`, `--verbose`, - `--audit-log`, `--profile` - -Phase 1 is the **manual-orchestration experience in full**. See -`docs/phase-1-manual-orchestration.md` for why this is not a -half-shipped state — it is the whole contract for L1 (manual-agent) -use and the foundation every later phase composes on top of. - -### Phase 2 — Policy tooling *(shipped, v2.8.0)* - -**What it is:** the one file an operator edits to express preferences -without touching code or CLI flags. The CLI reads it, the rules engine -reads it, the MCP server reads it, and `doctor` reports on it. - -Surfaces: - -- `policy new | validate | migrate | diff` (v0.2 schema; v0.1 removed in v3.0) -- Default `policy.yaml` discovery rules -- Aliases (human-readable device names) -- Quiet hours (local-time windows, midnight-crossing supported) -- Confirmation tiers (destructive / mutation / read) -- Audit log path + retention hint -- `policyStatus` in `agent-bootstrap` output + MCP tool -- Destructive-command guard (rejects dangerous commands in rules) - -### Phase 3 — One-command install + secure credential storage - -Phase 3 is **split in two**, with 3A shipped in this repo and 3B -published as a separate skill repo. - -**Phase 3A — Keychain + install CLI *(shipped, v2.8.x → v2.10.0)*:** - -- `src/credentials/keychain.ts` abstraction with four backends: macOS - `security(1)`, Windows PowerShell + Win32 `CredRead`/`CredWrite`, - Linux `secret-tool` (libsecret), and a `0600` file fallback -- `switchbot auth keychain describe | get | set | delete | migrate` -- `doctor` + `agent-bootstrap` report the active credential source -- `src/install/` preflight + rollback-aware step runner (library) -- `switchbot install` / `switchbot uninstall` built-in CLI commands - (v2.10.0): one-command Quickstart → doctor → all-green; rollback on - any step failure. `--agent claude-code` auto-symlinks the skill; - other agents print a recipe. `--purge` for one-flag full teardown. - -**Phase 3B — Skill packaging + external registry:** - -- Tracked in the sibling companion skill repo -- `SKILL.md` + `manifest.json` + skill-side examples -- Publishing to Claude Desktop / other agent surfaces + external registries - -### Phase 4 — Rules engine v0.2 *(shipped, v2.8.x → v2.11.0)* - -**What it is:** the declarative leap. Rules live in the same -`policy.yaml`, and the engine executes them without a separate daemon. - -Surfaces (v2.9.0 baseline + v2.11.0 additions): - -- `switchbot rules lint | list | run | reload | tail | replay` -- Triggers: `mqtt` (shadow events), `cron` (local time, optional - `days` weekday filter), `webhook` (bearer-token HTTP ingest) -- Conditions: `time_between` (quiet-hours-aware), `device_state` - (per-tick cache), `all` / `any` / `not` logical composition -- Per-rule `throttle` (`max_per: "10m"` style) -- Per-rule `dry_run` (plan without firing) -- Hot reload: `SIGHUP` on Unix, pid-file sentinel on Windows -- Audit log v2: `rule-fire`, `rule-fire-dry`, `rule-throttled`, - `rule-webhook-rejected` records - -Phase 4 is **opt-in**. Existing Phase 1/2 users who never enable -`automation:` in their policy pay zero cost for it being present. - ---- - -## Autonomy dimension (skill side) - -The skill repo uses an orthogonal label — `autonomyLevel` — so that -skill releases do not need to wait on CLI phase boundaries. - -- **L1** - Meaning: manual orchestration, one command at a time. - Skill behavior: turns natural language into CLI calls; user confirms each mutation. - Required CLI phase: Phase 1 or later. -- **L2** - Meaning: semi-autonomous, propose-then-approve. - Skill behavior: composes multi-step plans; `--require-approval` gates each step. - Required CLI phase: Phase 2 or later. -- **L3** - Meaning: fully autonomous inside the policy envelope. - Skill behavior: writes a rule and lets the engine execute without further prompts. - Required CLI phase: Phase 4 or later. - -The mapping from `autonomyLevel` to `tracksCliPhase` is declared in -the skill's `manifest.json` `roadmap` block, which points back here. - ---- - -## Completed tracks (shipped post-v2.9.0) - -- **Track β — one-command install surface *(shipped, v2.10.0)*.** - Top-level `switchbot install` / `switchbot uninstall` wrapping the - Phase 3A library. CLI assumed already in PATH; doctor runs as - warn-only post-step. Phase 3B (registry entry) still external. -- **Track γ — rules v0.2 capability increment *(shipped, v2.11.0)*.** - `days` weekday filter on cron triggers; `all` / `any` / `not` - condition composition. Per-trigger debounce and profile-scoped rules - remain deferred. -- **Track δ — semi-autonomous workflow L2 *(shipped, v2.12.0)*.** - `plan suggest --intent --device ...` scaffolds a Plan - JSON from natural language. `plan run --require-approval` gates each - destructive step with a TTY prompt. MCP tools `plan_suggest` + - `plan_run` are available; review support includes MCP `audit_query` + - `audit_stats` and `policy_diff`. -- **Track ζ — fully autonomous rule authoring L3 *(shipped, v2.13.0)*.** - `rules suggest` + `policy add-rule` let agents author a rule from - intent and inject it into `automation.rules[]`; MCP tools - `rules_suggest` + `policy_add_rule` provide the same flow. -- **Track ε — cross-OS CI matrix for keychain *(shipped, v2.11.0)*.** - GitHub Actions matrix: macOS (temp keychain), Linux (D-Bus + - gnome-keyring), Windows (native Credential Manager). -- **Track θ — notify actions *(shipped, v3.0)*.** - New `type: notify` action alongside `type: command` in the v0.2 - schema. Rules can POST to webhooks, append JSONL to a file, or push - to OpenClaw after firing, closing the feedback loop for AI agents. - `lintRules` validates URL syntax and required fields; engine dispatches - to `executeNotifyAction`; audit gains `rule-notify` kind; MCP gains - `rule_notifications` tool; `doctor` gains `notify-connectivity` check. -- **Track η — LLM-backed rule suggestion *(shipped, v3.0)*.** - `rules suggest --llm ` routes complex intents (complexity - score ≥ 4) to OpenAI or Anthropic, falls back to heuristic with a - warning on provider failure. `rules_suggest` MCP tool gains a `llm` - parameter. All LLM calls are written to the audit log as - `kind: llm-suggest` with backend, model, and latency fields. -- **Track κ — AI decision loop *(shipped, v3.6.x)*.** - `rules trace` records every condition evaluation; `rules - trace-explain` renders why a tick fired or was blocked. - `conditions: [- llm: { prompt, provider, ... }]` lets a rule call - an LLM as a condition (per-condition + global call budget, - cache, on_error fail/pass/skip). `rules simulate` replays a rule - against `~/.switchbot/device-history` for offline what-if. - Audit gains `llm-condition`, `llm-cache-hit`, - `llm-budget-exceeded` records. - -## In-flight (next release) - -- **Track μ — catalog sync.** - Adds Weather Station (read-only sensor), Lock Vision and Lock Vision - Pro (video locks with `lock` / `unlock` / `deadbolt` for the Pro), - the `Smart Lock Pro Wifi` alias for Matter-enabled Lock Pro, and - `uploadImage` on AI Art Frame. Pure data + tests; no schema bump. -- **Track λ.1 — USD/token budget for `llm` conditions.** - `DecideResult.usage = { tokensIn, tokensOut, costUsd? }`; per-rule - `budget.max_tokens_per_hour` / `max_cost_per_day_usd` and global - `automation.llm_budget.{max_tokens_per_hour, max_cost_per_day_usd}`. - Audit `llm-budget-exceeded` carries `dimension: "calls" | "tokens" | "cost"`. - Pricing table at `src/llm/pricing.ts` (override via the policy - `automation.llm_pricing_overrides` field). -- **Track λ.2 — cross-event aggregation.** - Non-LLM `event_count: { device, event?, window, min, max? }` - condition counts firings inside a rolling time window. Same - `EventWindowFetcher` populates the LLM `recent_events` hook so - prompts get the last N events of the trigger device for free. - Backed by `~/.switchbot/device-history/.jsonl`. -- **Track λ.3 — local / non-tool-use LLM providers.** - `LLMProvider.capabilities.toolUse` flag gates a structured-output - fallback (JSON instruction + lenient parser + one repair retry) - for endpoints that don't support tool use. New `provider: local` - in policy points at any OpenAI-compatible `/v1/chat/completions` - (Ollama, llama.cpp, vLLM, LM Studio) via - `SWITCHBOT_LOCAL_LLM_URL`. `doctor` adds `local-llm-reachable`. -- **Track λ.4 — daemon JSON-RPC 2.0 IPC.** - `rules run` now exposes `daemon.status`, `daemon.ping`, - `daemon.reload` over a Unix domain socket - (`~/.switchbot/daemon.sock`, mode 0600) on POSIX or a per-user - named pipe (`\\.\pipe\switchbot-daemon-`) on Windows. v1 - surface; future `mcp serve --via-daemon` will proxy MCP tool calls - through the same transport. `doctor` adds `daemon-ipc`. - -## Next execution queue (ordered) - -1. **`mcp serve --via-daemon` proxy.** - Route MCP tool calls through the JSON-RPC IPC so repeated agent - invocations skip the cold-start cost. - Exit when: `mcp serve --via-daemon list_devices` round-trips and - `doctor` confirms daemon health. -2. **Standalone MCP package (`npx @switchbot/mcp-server`).** - Split MCP serve entrypoint into a tiny publishable package while - preserving tool contract parity with the main CLI. - Exit when: `npx @switchbot/mcp-server` boots and passes the same MCP - contract tests as `switchbot mcp serve`. -3. **`switchbot self-test` command.** - Add scripted go/no-go checks for credentials + one representative device. - Exit when: CI can run a deterministic self-test job with pass/fail JSON. -4. **Record/replay fixtures for deterministic integration tests.** - Capture request/response transcripts and replay offline in CI. - Exit when: at least one full scenario (list → status → command guard) - is replayable without live API calls. - ---- - -## Versioning rules this repo follows - -- **CLI semver:** Phase milestones map to minor bumps (Phase 2 → - v2.8.0; Phase 3A + Phase 4 landing together → v2.9.0). No phase - bump forces a major bump on its own. -- **Policy schema:** `0.1 → 0.2` is a minor. A major schema bump - happens only if the top-level shape breaks (no planned v1.x yet). -- **Rules track labels vs schema versions:** Track names (for example - γ) describe runtime increments and do not imply a policy schema bump; - current schema line remains `0.1 | 0.2`. -- **Skill manifest:** the skill repo owns its own semver track, - independent of CLI version. `authority.cli` in - `manifest.json` narrows the compatible CLI range per skill release. -- **`CATALOG_SCHEMA_VERSION === AGENT_BOOTSTRAP_SCHEMA_VERSION`** is - a hard sentinel — a mismatch fails `doctor`'s `catalog-schema` - check. Agents SHOULD poll that check each session. diff --git a/docs/phase-1-manual-orchestration.md b/docs/phase-1-manual-orchestration.md deleted file mode 100644 index a99c54a4..00000000 --- a/docs/phase-1-manual-orchestration.md +++ /dev/null @@ -1,149 +0,0 @@ -# Phase 1 is not half-shipped — it is the whole manual-orchestration contract - -Before Phase 4 (the rules engine) landed, it was easy to read the -roadmap and conclude Phase 1 was "the part before the good stuff." -This document pushes back on that framing. **Phase 1 is complete on -its own terms.** It is the manual-orchestration experience, sized and -shaped around one specific use case: a human or an L1 agent that -issues one command at a time and watches what happens. - -If you never enable `automation:` in your policy, you are a Phase 1 -user. That is a supported configuration, not a transitional state. - ---- - -## What Phase 1 delivers end-to-end - -Every capability below exists in the shipped CLI today. None of them -depends on Phase 2/3/4 being present or enabled. - -### Read the home state - -```bash -switchbot devices list --json -switchbot devices status "hallway lamp" --json -switchbot scenes list --json -``` - -`devices list` hits the SwitchBot Cloud API once and caches the -catalog; `devices status` reads either the API or the locally -updated `status.json` cache populated by `events mqtt-tail`. Either -path returns the same JSON envelope. - -### Send a command and verify it - -```bash -switchbot devices command "hallway lamp" turnOn --dry-run -switchbot devices command "hallway lamp" turnOn --audit-log -switchbot history show --since 5m --json | jq '.data[-1]' -``` - -Dry-run prints the exact HTTP body that would have been sent, writes -no audit entry, burns no quota. The real fire appends one JSONL line -to `~/.switchbot/audit.log`. `history show` reads the log back. - -### Watch the home in real time - -```bash -switchbot events mqtt-tail --json --max 3 # sanity check -switchbot devices watch AA-BB-CC-DD-EE-FF --via-mqtt --json -``` - -`mqtt-tail` subscribes to the cloud-issued MQTT broker (credentials -fetched automatically, cached to `~/.switchbot/mqtt-credential.json`, -refreshed 10 minutes before expiry). Shadow events stream as JSONL. -`devices watch --via-mqtt` is the same stream filtered to one -deviceId. - -### Execute a plan instead of a single command - -```bash -cat plan.json -# { "steps": [ -# { "device": "hallway lamp", "command": "turnOn" }, -# { "device": "bedside lamp", "command": "turnOff" } -# ] } -switchbot plan run plan.json --dry-run -switchbot plan run plan.json --audit-log -``` - -`plan run` is the **manual equivalent** of a single rule firing — -a batch of commands, confirmed up front, logged the same way. An L1 -agent can generate the plan, show it to the user, and run it on -approval. - -### Feed an AI agent - -```bash -switchbot agent-bootstrap --compact | jq '.identity, .schemaVersion' -switchbot mcp serve # stdio -switchbot mcp serve --transport http --port 3100 # Streamable HTTP -switchbot doctor --json | jq '.overall' -``` - -MCP exposes the same operations as the CLI. `agent-bootstrap` -supplies the one-shot cold-start snapshot. `doctor` reports the -system's health in a machine-readable form. - -### Know the history, know the quota - -```bash -switchbot history show --since 24h -switchbot history replay --dry-run -switchbot quota status --json -``` - -Every API call counts against the 10,000-req/day SwitchBot quota. -The CLI tracks that locally and exposes the server's -`X-Ratelimit-Remaining` header in both JSON and table output. - ---- - -## What Phase 1 deliberately does NOT include - -These are **not** Phase 1 deficiencies — they are Phase 1's scope. - -- **No declarative automations.** If you want "when motion at night, - turn on the lamp," that is Phase 4. An L1 agent running a Phase 1 - install can fake it with a shell loop, but the supported path is - Phase 4. -- **No cross-device conditions.** `devices command` does not take a - `--if-state` flag. `plan run` is linear. The device_state guard is - a Phase 4 primitive. -- **No hot reload of configuration.** Reloading `policy.yaml` mid-run - is a Phase 4 feature (SIGHUP / pid-file). In Phase 1, you restart. -- **No bearer-token webhook intake.** Shadow events come in via MQTT - only. The HTTP webhook trigger is Phase 4. - -These boundaries are the contract. Phase 1 does the things in the -first list exceptionally well; it does not try to do things in the -second list at all. - ---- - -## Why this framing matters - -A lot of the design pressure on Phase 2/3/4 would push back into -Phase 1 if we thought of Phase 1 as a prototype. It isn't. It is the -**steady-state surface** that every later phase sits on top of. When -Phase 4's rules engine fires a command, it reaches the device through -the Phase 1 command-dispatch path. When Phase 2's policy validator -checks a quiet-hours rule, it uses the same time library Phase 1 -`watch` uses. The phase numbering is about when capability arrived, -not about quality tiers. - -The corollary: **a PR that improves Phase 1 is not second-class -work.** The manual-orchestration experience is the single longest -code path in the repo, has the most tests (1624 at v2.8.0), and is -what an L1 agent actually runs. If a user reports a bug against -`devices watch` or `agent-bootstrap`, it is a first-class issue even -if Phase 4 is available. - ---- - -## How to think about Phase 1 in a roadmap review - -Ask: *"Can an L1 agent complete a full day's worth of user requests -against Phase 1 alone, without writing a single rule?"* - -The answer today is yes. That is what "Phase 1 is complete" means. From 61e2bb906d788296bfe948854addd78c0862abee Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 21:01:25 +0800 Subject: [PATCH 38/72] feat(devices): show family and room columns by default in devices list Co-Authored-By: Claude Sonnet 4.6 --- src/commands/devices.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/devices.ts b/src/commands/devices.ts index ecf10653..a7de91bf 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -106,7 +106,7 @@ Run any subcommand with --help for its own flags and examples. .alias('ls') .description('List all physical devices and IR remote devices on the account') .addHelpText('after', ` -Default columns: deviceId, deviceName, type, category +Default columns: deviceId, deviceName, type, category, family, room Pass --wide for the full operator view: + controlType, family, roomID, room, hub, cloud --fields accepts any subset of all column names (exit 2 on unknown names). @@ -239,7 +239,7 @@ Cache note: return; } - const narrowHeaders = ['deviceId', 'deviceName', 'type', 'category']; + const narrowHeaders = ['deviceId', 'deviceName', 'type', 'category', 'family', 'room']; const wideHeaders = ['deviceId', 'deviceName', 'type', 'category', 'controlType', 'family', 'roomID', 'room', 'hub', 'cloud', 'alias']; const userFields = resolveFields(); const headers = userFields ? wideHeaders : (options.wide ? wideHeaders : narrowHeaders); From 161a57163e06126bcd14ccd494373c6661172497 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 21:17:40 +0800 Subject: [PATCH 39/72] fix: address code review P1+P2 regressions - capabilities: add reset to COMMAND_META (fixes capabilities --json crash) - auth: route login progress messages to stderr in --json mode - history-agg: guard against -Infinity fromMs in no-bucket key (unbounded range) - devices: project JSON --fields through normalised column map with alias resolution and exit-2 on unknown fields - reset: include status.json (default-profile status cache) in cleanup list Co-Authored-By: Claude Sonnet 4.6 --- src/commands/auth.ts | 4 +-- src/commands/capabilities.ts | 1 + src/commands/devices.ts | 64 +++++++++++++++++++++++++++--------- src/commands/reset.ts | 1 + src/devices/history-agg.ts | 2 +- 5 files changed, 53 insertions(+), 19 deletions(-) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 526937b9..23f0dde0 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -417,7 +417,7 @@ export function registerAuthCommand(program: Command): void { creds = await browserLogin({ noOpen: !options.open, timeoutMs, - log: (msg) => console.log(msg), + log: (msg) => isJsonMode() ? console.error(msg) : console.log(msg), }); } catch (err) { exitWithError({ @@ -427,7 +427,7 @@ export function registerAuthCommand(program: Command): void { }); } - console.log('Verifying credentials…'); + (isJsonMode() ? console.error : console.log)('Verifying credentials…'); const check = await verifyCredentials(creds!); if (!check.ok) { exitWithError({ diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index 74f6cd05..b057da9b 100644 --- a/src/commands/capabilities.ts +++ b/src/commands/capabilities.ts @@ -214,6 +214,7 @@ export const COMMAND_META: Record = { 'status-sync start': ACTION_LOCAL, 'status-sync stop': ACTION_LOCAL, 'status-sync status': READ_LOCAL, + 'reset': ACTION_LOCAL, 'uninstall': ACTION_LOCAL, 'upgrade-check': READ_REMOTE, 'webhook setup': ACTION_REMOTE, diff --git a/src/commands/devices.ts b/src/commands/devices.ts index a7de91bf..10f2c25a 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -206,10 +206,47 @@ Cache note: if (fmt === 'json' && process.argv.includes('--json')) { const jsonFields = resolveFields(); - const projectDevice = jsonFields - ? (obj: Record): Record => - Object.fromEntries(jsonFields.map((k) => [k, obj[k] ?? null])) - : null; + + // Alias → canonical column name (same as table output). + const ALIAS: Record = { + id: 'deviceId', name: 'deviceName', deviceType: 'type', type: 'type', + roomName: 'room', familyName: 'family', hubDeviceId: 'hub', + enableCloudService: 'cloud', controlType: 'controlType', + deviceName: 'deviceName', deviceId: 'deviceId', category: 'category', + roomID: 'roomID', alias: 'alias', + }; + const ALL_COLS = new Set(['deviceId','deviceName','type','category','controlType','family','roomID','room','hub','cloud','alias']); + + if (jsonFields) { + const unknown = jsonFields.filter(k => !ALL_COLS.has(ALIAS[k] ?? k)); + if (unknown.length) { + exitWithError({ code: 2, kind: 'usage', message: `Unknown --fields value(s): ${unknown.join(', ')}` }); + } + } + + const normPhysical = (d: typeof deviceList[0]): Record => ({ + deviceId: d.deviceId, deviceName: d.deviceName, + type: d.deviceType || d.controlType || 'Unknown Device', + category: 'physical', controlType: d.controlType || '—', + family: d.familyName || '—', roomID: d.roomID || '—', room: d.roomName || '—', + hub: !d.hubDeviceId || d.hubDeviceId === '000000000000' ? '—' : d.hubDeviceId, + cloud: String(d.enableCloudService), + alias: deviceMeta.devices[d.deviceId]?.alias ?? '—', + }); + const normIr = (d: typeof infraredRemoteList[0]): Record => { + const inh = hubLocation.get(d.hubDeviceId); + return { + deviceId: d.deviceId, deviceName: d.deviceName, + type: d.remoteType, category: 'ir', controlType: d.controlType || '—', + family: inh?.family || '—', roomID: inh?.roomID || '—', room: inh?.room || '—', + hub: d.hubDeviceId, cloud: '—', + alias: deviceMeta.devices[d.deviceId]?.alias ?? '—', + }; + }; + const project = jsonFields + ? (norm: Record) => + Object.fromEntries(jsonFields.map(k => [k, norm[ALIAS[k] ?? k] ?? null])) + : (norm: Record) => norm; if (listClauses) { const filteredDeviceList = deviceList.filter((d) => @@ -221,20 +258,15 @@ Cache note: }); printJson({ ok: true, - deviceList: projectDevice ? filteredDeviceList.map((d) => projectDevice(d as unknown as Record)) : filteredDeviceList, - infraredRemoteList: projectDevice ? filteredIrList.map((d) => projectDevice(d as unknown as Record)) : filteredIrList, + deviceList: filteredDeviceList.map(d => project(normPhysical(d))), + infraredRemoteList: filteredIrList.map(d => project(normIr(d))), }); } else { - const rawBody = body as { deviceList?: unknown[]; infraredRemoteList?: unknown[] }; - if (projectDevice && rawBody.deviceList) { - printJson({ - ok: true, - deviceList: rawBody.deviceList.map((d) => projectDevice(d as Record)), - infraredRemoteList: (rawBody.infraredRemoteList ?? []).map((d) => projectDevice(d as Record)), - }); - } else { - printJson({ ok: true, ...(body as object) }); - } + printJson({ + ok: true, + deviceList: deviceList.map(d => project(normPhysical(d))), + infraredRemoteList: infraredRemoteList.map(d => project(normIr(d))), + }); } return; } diff --git a/src/commands/reset.ts b/src/commands/reset.ts index f63ffd8c..8ececd70 100644 --- a/src/commands/reset.ts +++ b/src/commands/reset.ts @@ -17,6 +17,7 @@ const DATA_ITEMS = [ { key: 'quota', label: 'Quota counter', path: path.join(BASE, 'quota.json'), type: 'file' }, { key: 'device-history', label: 'Device history', path: path.join(BASE, 'device-history'), type: 'dir' }, { key: 'device-meta', label: 'Device metadata', path: path.join(BASE, 'device-meta.json'), type: 'file' }, + { key: 'status', label: 'Status cache', path: path.join(BASE, 'status.json'), type: 'file' }, { key: 'audit', label: 'Audit log', path: path.join(BASE, 'audit.log'), type: 'file' }, ] as const; diff --git a/src/devices/history-agg.ts b/src/devices/history-agg.ts index 3d1720f7..3828fc9a 100644 --- a/src/devices/history-agg.ts +++ b/src/devices/history-agg.ts @@ -97,7 +97,7 @@ export async function aggregateDeviceHistory( const key = bucketMs !== null ? Math.floor(tMs / bucketMs) * bucketMs - : Math.floor((fromMs + (Number.isFinite(toMs) ? toMs : Date.now())) / 2); + : Math.floor(((Number.isFinite(fromMs) ? fromMs : Date.now()) + (Number.isFinite(toMs) ? toMs : Date.now())) / 2); let bkt = buckets.get(key); if (!bkt) { bkt = new Map(); buckets.set(key, bkt); } From ed57dfa854e406fd8c1324605c949e5c4b47b3c3 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 21:28:35 +0800 Subject: [PATCH 40/72] test(capabilities): add reset to ALL_EXPECTED_LEAF_COMMANDS whitelist Co-Authored-By: Claude Sonnet 4.6 --- tests/commands/capabilities-meta.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/commands/capabilities-meta.test.ts b/tests/commands/capabilities-meta.test.ts index 97754da1..88c1595c 100644 --- a/tests/commands/capabilities-meta.test.ts +++ b/tests/commands/capabilities-meta.test.ts @@ -45,6 +45,7 @@ const ALL_EXPECTED_LEAF_COMMANDS = [ 'policy validate', 'policy new', 'policy migrate', 'policy diff', 'policy add-rule', 'policy backup', 'policy restore', 'quota status', 'quota reset', + 'reset', 'rules suggest', 'rules lint', 'rules list', 'rules run', 'rules reload', 'rules tail', 'rules replay', 'rules webhook-rotate-token', 'rules webhook-show-token', 'rules conflicts', 'rules doctor', 'rules summary', 'rules last-fired', From 694ab7f304d2c0688f856d819f362fc51abeb7c9 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Thu, 21 May 2026 21:34:16 +0800 Subject: [PATCH 41/72] refactor(test): replace hardcoded command whitelist with dynamic CLI enumeration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract buildProgram() to src/program-builder.ts and export enumerateLeafNames() from capabilities.ts. The coverage guard now derives its ground truth from the real Commander program tree — no list to maintain when adding new commands. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/capabilities.ts | 2 +- src/index.ts | 119 +-------------------- src/program-builder.ts | 126 +++++++++++++++++++++++ tests/commands/capabilities-meta.test.ts | 62 ++--------- 4 files changed, 141 insertions(+), 168 deletions(-) create mode 100644 src/program-builder.ts diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index b057da9b..522a6303 100644 --- a/src/commands/capabilities.ts +++ b/src/commands/capabilities.ts @@ -266,7 +266,7 @@ export interface CompactLeaf { recommendedMode: RecommendedMode; } -function enumerateLeafNames(program: Command, prefix = ''): string[] { +export function enumerateLeafNames(program: Command, prefix = ''): string[] { const out: string[] = []; for (const cmd of program.commands) { const full = prefix ? `${prefix} ${cmd.name()}` : cmd.name(); diff --git a/src/index.ts b/src/index.ts index ae9d01da..a9f03ed0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,131 +1,18 @@ -import { Command, CommanderError, InvalidArgumentError } from 'commander'; -import { createRequire } from 'node:module'; +import { Command, CommanderError } from 'commander'; import chalk from 'chalk'; -import { intArg, stringArg, enumArg } from './utils/arg-parsers.js'; -import { parseDurationToMs } from './utils/flags.js'; import { emitJsonError, isJsonMode, printJson } from './utils/output.js'; import { commandToJson, resolveTargetCommand } from './utils/help-json.js'; -import { PRODUCT_TAGLINE } from './commands/identity.js'; -import { registerConfigCommand } from './commands/config.js'; -import { registerDevicesCommand } from './commands/devices.js'; -import { registerScenesCommand } from './commands/scenes.js'; -import { registerWebhookCommand } from './commands/webhook.js'; -import { registerCompletionCommand } from './commands/completion.js'; -import { registerMcpCommand } from './commands/mcp.js'; -import { registerQuotaCommand } from './commands/quota.js'; -import { registerCatalogCommand } from './commands/catalog.js'; -import { registerCacheCommand } from './commands/cache.js'; -import { registerEventsCommand } from './commands/events.js'; -import { registerDoctorCommand } from './commands/doctor.js'; -import { registerSchemaCommand } from './commands/schema.js'; -import { registerHistoryCommand } from './commands/history.js'; -import { registerPlanCommand } from './commands/plan.js'; -import { registerCapabilitiesCommand } from './commands/capabilities.js'; -import { registerAgentBootstrapCommand } from './commands/agent-bootstrap.js'; -import { registerPolicyCommand } from './commands/policy.js'; -import { registerRulesCommand } from './commands/rules.js'; -import { registerAuthCommand } from './commands/auth.js'; -import { registerInstallCommand } from './commands/install.js'; -import { registerUninstallCommand } from './commands/uninstall.js'; -import { registerResetCommand } from './commands/reset.js'; -import { registerStatusSyncCommand } from './commands/status-sync.js'; -import { registerHealthCommand } from './commands/health.js'; -import { registerUpgradeCheckCommand } from './commands/upgrade-check.js'; -import { registerDaemonCommand } from './commands/daemon.js'; +import { buildProgram } from './program-builder.js'; import { primeCredentials } from './credentials/prime.js'; import { getActiveProfile } from './lib/request-context.js'; -const require = createRequire(import.meta.url); -const { version: pkgVersion } = require('../package.json') as { version: string }; - // Early initialization: check for --no-color flag or NO_COLOR env var and disable chalk. // This must happen before any commands run so all chalk output is affected. if (process.argv.includes('--no-color') || Boolean(process.env.NO_COLOR)) { chalk.level = 0; } -const program = new Command(); -program.allowExcessArguments(false); -if (isJsonMode()) { - // In --json mode, commander writes plain-text usage errors by default. - // Silence that channel and emit a single structured error in the catch block. - program.configureOutput({ writeErr: () => {} }); -} - -// Top-level subcommand names. Used by stringArg to produce clearer errors when -// a value is omitted and the next argv token turns out to be a subcommand name. -const TOP_LEVEL_COMMANDS = [ - 'config', 'devices', 'scenes', 'webhook', 'completion', 'mcp', - 'quota', 'catalog', 'cache', 'events', 'doctor', 'schema', - 'history', 'plan', 'capabilities', 'agent-bootstrap', 'install', 'uninstall', 'status-sync', - 'health', 'upgrade-check', 'daemon', 'reset', -] as const; - -const cacheModeArg = (value: string): string => { - if (value.startsWith('-')) { - throw new InvalidArgumentError( - `--cache requires a mode value, got "${value}". ` + - `Valid: "off", "auto", or a duration like "5m", "1h". Use --cache= if needed.`, - ); - } - if (value === 'off' || value === 'auto') return value; - if (parseDurationToMs(value) !== null) return value; - throw new InvalidArgumentError( - `--cache must be "off", "auto", or a duration like "30s"/"5m"/"1h" (got "${value}")`, - ); -}; - -program - .name('switchbot') - .description(PRODUCT_TAGLINE) - .version(pkgVersion) - .option('--no-color', 'Disable ANSI colors in output') - .option('--json', 'Output raw JSON response (disables tables; useful for pipes/scripts)') - .option('--format ', 'Output format: table (default), json, jsonl, tsv, yaml, id, markdown', enumArg('--format', ['table', 'json', 'jsonl', 'tsv', 'yaml', 'id', 'markdown'])) - .option('--fields ', 'Comma-separated list of columns to include (e.g. --fields=id,name,type)', stringArg('--fields', { disallow: TOP_LEVEL_COMMANDS })) - .option('--table-style