diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..d4a11772 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# Build stage +FROM node:20-alpine AS builder +WORKDIR /build +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# Runtime stage +FROM node:20-alpine +RUN addgroup -g 10001 switchbot && adduser -D -u 10001 -G switchbot switchbot +WORKDIR /app +COPY --from=builder /build/dist ./dist +COPY --from=builder /build/package*.json ./ +RUN npm ci --omit=dev +RUN chown -R switchbot:switchbot /app +USER switchbot +EXPOSE 3030 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3030/healthz', (r) => process.exit(r.statusCode === 200 ? 0 : 1))" +ENTRYPOINT ["node", "dist/index.js"] diff --git a/README.md b/README.md index c1c0d071..66b9aaff 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ List devices, query live status, send control commands, run scenes, and manage w - **npm package:** [`@switchbot/openapi-cli`](https://www.npmjs.com/package/@switchbot/openapi-cli) - **Source code:** [github.com/OpenWonderLabs/switchbot-openapi-cli](https://github.com/OpenWonderLabs/switchbot-openapi-cli) +- **Releases / changelog:** [GitHub Releases](https://github.com/OpenWonderLabs/switchbot-openapi-cli/releases) - **Issues / feature requests:** [GitHub Issues](https://github.com/OpenWonderLabs/switchbot-openapi-cli/issues) --- diff --git a/contrib/systemd/switchbot-mcp.service b/contrib/systemd/switchbot-mcp.service new file mode 100644 index 00000000..5f88d365 --- /dev/null +++ b/contrib/systemd/switchbot-mcp.service @@ -0,0 +1,26 @@ +[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/docker-compose.example.yml b/docker-compose.example.yml new file mode 100644 index 00000000..8495a935 --- /dev/null +++ b/docker-compose.example.yml @@ -0,0 +1,19 @@ +version: '3.9' +services: + switchbot-mcp: + build: . + ports: + - "3030:3030" + environment: + SWITCHBOT_TOKEN: ${SWITCHBOT_TOKEN} + SWITCHBOT_SECRET: ${SWITCHBOT_SECRET} + SWITCHBOT_MCP_TOKEN: ${SWITCHBOT_MCP_TOKEN:-changeme} + LOG_LEVEL: ${LOG_LEVEL:-info} + command: mcp serve --port 3030 --bind 0.0.0.0 --auth-token ${SWITCHBOT_MCP_TOKEN:-changeme} + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3030/healthz"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 5s + restart: unless-stopped diff --git a/docs/schema-versioning.md b/docs/schema-versioning.md new file mode 100644 index 00000000..9a9f0c23 --- /dev/null +++ b/docs/schema-versioning.md @@ -0,0 +1,92 @@ +# Schema Versioning + +This document describes how `schemaVersion` evolves across SwitchBot CLI releases. + +## Overview + +The CLI emits structured JSON responses wrapped in a top-level envelope that carries a `schemaVersion` field. This field follows semantic versioning to signal compatibility: + +- **Additive changes** (new optional fields) → minor version bump (1.1, 1.2) +- **Breaking changes** (field removal, rename, type change) → major version bump (2.0) +- **No compatibility shim** — parsers that pin schemaVersion "1" continue to work against 1.1, 1.2, etc. (backward-compatible) + +## Envelope shape (v2.0+) + +Every JSON response is one of: + +```json +{ "schemaVersion": "1.1", "data": { ... } } +``` + +```json +{ "schemaVersion": "1.1", "error": { "code": 1, "kind": "...", "message": "..." } } +``` + +The payload your integration cares about is always nested under `data` (success) or `error` (failure). `schemaVersion` describes the *payload shape*, not the CLI version — the envelope itself is the structural signal introduced in CLI 2.0. + +### Historical nested location: `batch.summary.schemaVersion` + +Before the top-level envelope existed, the `batch` command nested `schemaVersion` inside `summary`. That nested field is retained for back-compat — both of the following are set, and both equal `"1.1"`: + +```json +{ + "schemaVersion": "1.1", + "data": { + "summary": { "schemaVersion": "1.1", "total": 3, "ok": 2, "error": 1, "skipped": 0 }, + "succeeded": [ ... ], + "failed": [ ... ] + } +} +``` + +Prefer the top-level `schemaVersion`. The nested copy may be removed in a future major. + +## Current Versions + +- **v2.0.0**: schemaVersion "1.1" inside a new top-level `{schemaVersion, data|error}` envelope + - Every `--json` response now has a top-level `schemaVersion` (previously only `batch.summary` had it) + - Payload lives under `data` for success, `error` for failure + - Existing payload shapes are unchanged — only the wrapper is new + +- **v1.7.0 – v1.12.x (unpublished)**: schemaVersion "1.1" + - `batch` command: added `failed[].error.retryAfterMs`, `failed[].error.transient`, `failed[].error.errorClass` + - All new fields are optional + +- **v1.0.0 – v1.6.x**: schemaVersion "1" + - Original unified JSON response structure (no top-level envelope) + +## Migration Path + +### From v1.x → v2.0 + +**What changed:** +1. Every `--json` response is now wrapped in `{schemaVersion, data}` (success) or `{schemaVersion, error}` (failure). +2. `batch.failed[].error` is now an object instead of a string (richer error metadata). +3. `switchbot mcp serve` defaults to binding `127.0.0.1`. Pass `--bind 0.0.0.0 --auth-token ` to restore external reachability. + +**How to update your integration:** +- Unwrap the envelope once: `parsed.data.` instead of `parsed.`, `parsed.error.` for failures. +- For `batch`, read `failed[].error.message` for the previous string content; use `failed[].error.transient` / `retryAfterMs` for retry decisions. +- For MCP HTTP deployments, add explicit `--bind` + `--auth-token` flags if external reachability is required. + +### From v1.6 → v1.7 (historical) + +**What changed:** +- `batch` failed array entries now include richer error metadata +- Old: `{deviceId, error: "string message"}` +- New: `{deviceId, error: {code, kind, message, errorClass, transient, retryAfterMs, ...}}` + +**How to update your integration:** +1. Check if your parser uses `failed[].error` +2. If so, update to read `failed[].error.message` for the error string (same content) +3. Optionally use `failed[].error.transient` to decide retry logic +4. Optionally use `failed[].error.retryAfterMs` to wait before retry + +## Schema Pinning (Not Recommended) + +Some tools allow pinning to exact schema versions. We recommend against this for `schemaVersion`, since: +- The CLI rarely ships breaking changes +- Pinning to `"1"` means you stay on 1.0-1.9x even when security fixes land in 1.5+ +- Pinning to `"1.1"` works until a future v2 of the payload shape, at which point you'd need to update anyway + +Instead, test your integration against the current release and trust the semantic versioning signal. diff --git a/package-lock.json b/package-lock.json index 8160ba66..8e72aa1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@switchbot/openapi-cli", - "version": "1.3.2", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@switchbot/openapi-cli", - "version": "1.3.2", + "version": "2.0.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", @@ -15,6 +15,8 @@ "cli-table3": "^0.6.5", "commander": "^12.1.0", "js-yaml": "^4.1.1", + "mqtt": "^5.3.0", + "pino": "^9.0.0", "uuid": "^11.0.5" }, "bin": { @@ -83,6 +85,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", @@ -729,6 +740,12 @@ } } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1108,12 +1125,20 @@ "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/readable-stream": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz", + "integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -1121,6 +1146,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/coverage-v8": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", @@ -1267,6 +1301,18 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1382,6 +1428,15 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/axios": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", @@ -1403,6 +1458,38 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -1440,6 +1527,48 @@ "node": "18 || 20 || >=22" } }, + "node_modules/broker-factory": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.14.tgz", + "integrity": "sha512-L45k5HMbPIrMid0nTOZ/UPXG/c0aRuQKVrSDFIb1zOkvfiyHgYmIjc3cSiN1KwQIvRDOtKE0tfb3I9EZ3CmpQQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1", + "worker-factory": "^7.0.49" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1583,6 +1712,41 @@ "node": ">=18" } }, + "node_modules/commist": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz", + "integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -1860,6 +2024,24 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -1983,6 +2165,19 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-unique-numbers": { + "version": "9.0.27", + "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.27.tgz", + "integrity": "sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.2.0" + } + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -2281,6 +2476,12 @@ "node": ">= 0.4" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, "node_modules/hono": { "version": "4.12.14", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", @@ -2333,6 +2534,26 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2457,6 +2678,16 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-sdsl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", + "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -2492,7 +2723,6 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, "license": "ISC" }, "node_modules/magic-string": { @@ -2600,6 +2830,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -2610,6 +2849,49 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mqtt": { + "version": "5.15.1", + "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.15.1.tgz", + "integrity": "sha512-V1WnkGuJh3ec9QXzy5Iylw8OOBK+Xu1WhxcQ9mMpLThG+/JZIMV1PgLNRgIiqXhZnvnVLsuyxHl5A/3bHHbcAA==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.21", + "@types/ws": "^8.18.1", + "commist": "^3.2.0", + "concat-stream": "^2.0.0", + "debug": "^4.4.1", + "help-me": "^5.0.0", + "lru-cache": "^10.4.3", + "minimist": "^1.2.8", + "mqtt-packet": "^9.0.2", + "number-allocator": "^1.0.14", + "readable-stream": "^4.7.0", + "rfdc": "^1.4.1", + "socks": "^2.8.6", + "split2": "^4.2.0", + "worker-timers": "^8.0.23", + "ws": "^8.18.3" + }, + "bin": { + "mqtt": "build/bin/mqtt.js", + "mqtt_pub": "build/bin/pub.js", + "mqtt_sub": "build/bin/sub.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/mqtt-packet": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz", + "integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==", + "license": "MIT", + "dependencies": { + "bl": "^6.0.8", + "debug": "^4.3.4", + "process-nextick-args": "^2.0.1" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2644,6 +2926,16 @@ "node": ">= 0.6" } }, + "node_modules/number-allocator": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz", + "integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "js-sdsl": "4.3.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2665,6 +2957,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2762,6 +3063,43 @@ "dev": true, "license": "ISC" }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -2800,6 +3138,37 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2837,6 +3206,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -2861,6 +3236,31 @@ "node": ">= 0.10" } }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2880,6 +3280,12 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", @@ -2941,6 +3347,35 @@ "node": ">= 18" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3149,6 +3584,39 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3159,6 +3627,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3182,6 +3659,15 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3266,6 +3752,15 @@ "node": ">=18" } }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3319,6 +3814,12 @@ "node": ">=0.6" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -3378,6 +3879,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3396,7 +3903,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -3408,6 +3914,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", @@ -4041,6 +4553,53 @@ "node": ">=8" } }, + "node_modules/worker-factory": { + "version": "7.0.49", + "resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.49.tgz", + "integrity": "sha512-lW7tpgy6aUv2dFsQhv1yv+XFzdkCf/leoKRTGMPVK5/die6RrUjqgJHJf556qO+ZfytNG6wPXc17E8zzsOLUDw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1" + } + }, + "node_modules/worker-timers": { + "version": "8.0.31", + "resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.31.tgz", + "integrity": "sha512-ngkq5S6JuZyztom8tDgBzorLo9byhBMko/sXfgiUD945AuzKGg1GCgDMCC3NaYkicLpGKXutONM36wEX8UbBCA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1", + "worker-timers-broker": "^8.0.16", + "worker-timers-worker": "^9.0.14" + } + }, + "node_modules/worker-timers-broker": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.16.tgz", + "integrity": "sha512-JyP3AvUGyPGbBGW7XiUewm2+0pN/aYo1QpVf5kdXAfkDZcN3p7NbWrG6XnyDEpDIvfHk/+LCnOW/NsuiU9riYA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "broker-factory": "^3.1.14", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1", + "worker-timers-worker": "^9.0.14" + } + }, + "node_modules/worker-timers-worker": { + "version": "9.0.14", + "resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.14.tgz", + "integrity": "sha512-/qF06C60sXmSLfUl7WglvrDIbspmPOM8UrG63Dnn4bi2x4/DfqHS/+dxF5B+MdHnYO5tVuZYLHdAodrKdabTIg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1", + "worker-factory": "^7.0.49" + } + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -4154,6 +4713,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/package.json b/package.json index c9812726..d2fddd4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "1.3.2", + "version": "2.0.0", "description": "Command-line interface for SwitchBot API v1.1", "keywords": [ "switchbot", @@ -37,12 +37,14 @@ }, "scripts": { "build": "tsc", + "build:prod": "tsc -p tsconfig.build.json", + "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"", "dev": "tsx src/index.ts", "start": "node dist/index.js", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "prepublishOnly": "npm run build && npm test" + "prepublishOnly": "npm test && npm run clean && npm run build:prod" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", @@ -51,6 +53,8 @@ "cli-table3": "^0.6.5", "commander": "^12.1.0", "js-yaml": "^4.1.1", + "mqtt": "^5.3.0", + "pino": "^9.0.0", "uuid": "^11.0.5" }, "devDependencies": { diff --git a/src/api/client.ts b/src/api/client.ts index 3a3876af..1745f397 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -103,7 +103,7 @@ export function createClient(): AxiosInstance { throw new ApiError( `Request timed out after ${getTimeout()}ms (override with --timeout )`, 0, - { retryable: false } + { transient: true, retryable: false } ); } const status = error.response?.status; @@ -144,20 +144,34 @@ export function createClient(): AxiosInstance { throw new ApiError( 'Authentication failed: invalid token or daily 10,000-request quota exceeded', 401, - { retryable: false, hint: 'Run `switchbot config set-token ` to re-enter credentials, or `switchbot quota status` to check today\'s local count.' } + { + transient: false, + retryable: false, + hint: 'Run `switchbot config set-token ` to re-enter credentials, or `switchbot quota status` to check today\'s local count.' + } ); } if (status === 429) { + const retryAfter = error.response?.headers?.['retry-after']; + const retryAfterMs = nextRetryDelayMs(maxRetries - 1, backoff, retryAfter); throw new ApiError( 'Request rate too high: daily 10,000-request quota exceeded (retries exhausted)', 429, - { retryable: true, hint: 'Use `switchbot quota status` to see today\'s usage; raise `--retry-on-429 ` for more retries.' } + { + retryable: true, + transient: true, + retryAfterMs, + hint: 'Use `switchbot quota status` to see today\'s usage; raise `--retry-on-429 ` for more retries.' + } ); } throw new ApiError( `HTTP ${status ?? '?'}: ${error.message}`, status ?? 0, - { retryable: status !== undefined && status >= 500 } + { + retryable: status !== undefined && status >= 500, + transient: status !== undefined && (status >= 500 || status === 0) // 5xx, 0 = connection error + } ); } throw error; @@ -170,11 +184,15 @@ export function createClient(): AxiosInstance { export interface ApiErrorMeta { retryable?: boolean; hint?: string; + retryAfterMs?: number; + transient?: boolean; } export class ApiError extends Error { public readonly retryable: boolean; public readonly hint?: string; + public readonly retryAfterMs?: number; + public readonly transient: boolean; constructor( message: string, public readonly code: number, @@ -184,5 +202,7 @@ export class ApiError extends Error { this.name = 'ApiError'; this.retryable = meta.retryable ?? false; this.hint = meta.hint; + this.retryAfterMs = meta.retryAfterMs; + this.transient = meta.transient ?? false; } } diff --git a/src/commands/batch.ts b/src/commands/batch.ts index 8c20ef39..218d76b6 100644 --- a/src/commands/batch.ts +++ b/src/commands/batch.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; import type { AxiosInstance } from 'axios'; -import { printJson, isJsonMode, handleError } from '../utils/output.js'; +import { printJson, isJsonMode, handleError, buildErrorPayload, type ErrorPayload } from '../utils/output.js'; import { fetchDeviceList, executeCommand, @@ -15,7 +15,7 @@ import { getCachedTypeMap } from '../devices/cache.js'; interface BatchResult { succeeded: Array<{ deviceId: string; result: unknown }>; - failed: Array<{ deviceId: string; error: string }>; + failed: Array<{ deviceId: string; error: ErrorPayload }>; summary: { total: number; ok: number; @@ -23,6 +23,7 @@ interface BatchResult { skipped: number; durationMs: number; dryRun?: boolean; + schemaVersion?: string; }; } @@ -125,6 +126,7 @@ export function registerBatchCommand(devices: Command): void { .option('--yes', 'Allow destructive commands (Smart Lock unlock, garage open, ...)') .option('--type ', '"command" (default) or "customize" for user-defined IR buttons', 'command') .option('--stdin', 'Read deviceIds from stdin, one per line (same as trailing "-")') + .option('--idempotency-key-prefix ', 'Prefix for idempotency keys (key per device: -)') .addHelpText('after', ` Targets are resolved in this priority order: 1. --ids when present (explicit deviceIds) @@ -166,6 +168,7 @@ Examples: yes?: boolean; type: string; stdin?: boolean; + idempotencyKeyPrefix?: string; }, commandObj: Command ) => { @@ -266,7 +269,12 @@ Examples: const outcomes = await runPool(resolved.ids, concurrency, async (id) => { try { - const result = await executeCommand(id, cmd, parsedParam, effectiveType, getClient()); + const idempotencyKey = options.idempotencyKeyPrefix + ? `${options.idempotencyKeyPrefix}-${id}` + : undefined; + const result = await executeCommand(id, cmd, parsedParam, effectiveType, getClient(), { + idempotencyKey, + }); if (!isJsonMode()) { console.log(`✓ ${id}: ${cmd}`); } @@ -277,11 +285,11 @@ Examples: if (err instanceof DryRunSignal) { return { ok: 'dry-run' as const, deviceId: id }; } - const message = err instanceof Error ? err.message : String(err); + const errorPayload = buildErrorPayload(err); if (!isJsonMode()) { - console.error(`✗ ${id}: ${message}`); + console.error(`✗ ${id}: ${errorPayload.message}`); } - return { ok: false as const, deviceId: id, error: message }; + return { ok: false as const, deviceId: id, error: errorPayload }; } }); @@ -293,7 +301,7 @@ Examples: const failed = outcomes.filter((o) => o.ok === false) as Array<{ ok: false; deviceId: string; - error: string; + error: ErrorPayload; }>; const dryRunned = outcomes.filter((o) => o.ok === 'dry-run') as Array<{ ok: 'dry-run'; @@ -309,6 +317,7 @@ Examples: failed: failed.length, skipped: dryRunned.length, durationMs: Date.now() - startedAt, + schemaVersion: '1.1', ...(dryRun ? { dryRun: true } : {}), }, }; diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index dd86bc23..610f49bb 100644 --- a/src/commands/capabilities.ts +++ b/src/commands/capabilities.ts @@ -1,5 +1,6 @@ import { Command } from 'commander'; import { getEffectiveCatalog } from '../devices/catalog.js'; +import { printJson } from '../utils/output.js'; const IDENTITY = { product: 'SwitchBot', @@ -59,45 +60,39 @@ export function registerCapabilitiesCommand(program: Command): void { description: opt.description, })); const roles = [...new Set(catalog.map((e) => e.role ?? 'other'))].sort(); - console.log( - JSON.stringify( - { - version: program.version(), - generatedAt: new Date().toISOString(), - identity: IDENTITY, - surfaces: { - mcp: { - entry: 'mcp serve', - protocol: 'stdio (default) or --port for HTTP', - tools: MCP_TOOLS, - }, - plan: { - schemaCmd: 'plan schema', - validateCmd: 'plan validate -', - runCmd: 'plan run -', - }, - cli: { - catalogCmd: 'schema export', - discoveryCmd: 'capabilities', - healthCmd: 'doctor --json', - helpFlag: '--help', - }, - }, - commands, - globalFlags, - catalog: { - typeCount: catalog.length, - roles, - destructiveCommandCount: catalog.reduce( - (n, e) => n + e.commands.filter((c) => c.destructive).length, - 0, - ), - readOnlyTypeCount: catalog.filter((e) => e.readOnly).length, - }, + printJson({ + version: program.version(), + generatedAt: new Date().toISOString(), + identity: IDENTITY, + surfaces: { + mcp: { + entry: 'mcp serve', + protocol: 'stdio (default) or --port for HTTP', + tools: MCP_TOOLS, }, - null, - 2, - ), - ); + plan: { + schemaCmd: 'plan schema', + validateCmd: 'plan validate -', + runCmd: 'plan run -', + }, + cli: { + catalogCmd: 'schema export', + discoveryCmd: 'capabilities', + healthCmd: 'doctor --json', + helpFlag: '--help', + }, + }, + commands, + globalFlags, + catalog: { + typeCount: catalog.length, + roles, + destructiveCommandCount: catalog.reduce( + (n, e) => n + e.commands.filter((c) => c.destructive).length, + 0, + ), + readOnlyTypeCount: catalog.filter((e) => e.readOnly).length, + }, + }); }); } diff --git a/src/commands/devices.ts b/src/commands/devices.ts index feffe590..f85c7531 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -211,6 +211,7 @@ Examples: .option('--name ', 'Resolve device by fuzzy name instead of deviceId') .option('--type ', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', 'command') .option('--yes', 'Confirm a destructive command (Smart Lock unlock, Garage open, …). --dry-run is always allowed without --yes.') + .option('--idempotency-key ', 'Idempotency key for deduplication (60s window; same key replays cached result)') .addHelpText('after', ` ──────────────────────────────────────────────────────────────────────── For the full list of commands a specific device supports — and their @@ -256,7 +257,7 @@ Examples: $ switchbot devices command ABC123 "MyButton" --type customize $ switchbot devices command unlock --yes `) - .action(async (deviceIdArg: string | undefined, cmd: string, parameter: string | undefined, options: { name?: string; type: string; yes?: boolean }) => { + .action(async (deviceIdArg: string | undefined, cmd: string, parameter: string | undefined, options: { name?: string; type: string; yes?: boolean; idempotencyKey?: string }) => { const deviceId = resolveDeviceId(deviceIdArg, options.name); const validation = validateCommand(deviceId, cmd, parameter, options.type); if (!validation.ok) { @@ -331,7 +332,9 @@ Examples: deviceId, cmd, parsedParam, - options.type as 'command' | 'customize' + options.type as 'command' | 'customize', + undefined, + { idempotencyKey: options.idempotencyKey } ); const isIr = getCachedDevice(deviceId)?.category === 'ir'; diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index fd4b1d9d..5166a933 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -21,6 +21,13 @@ import { import { fetchScenes, executeScene } from '../lib/scenes.js'; import { findCatalogEntry } from '../devices/catalog.js'; import { getCachedDevice } from '../devices/cache.js'; +import { EventSubscriptionManager } from '../mcp/events-subscription.js'; +import { todayUsage } from '../utils/quota.js'; +import { describeCache } from '../devices/cache.js'; +import { withRequestContext } from '../lib/request-context.js'; +import { profileFilePath } from '../config.js'; +import { getMqttConfig } from '../mqtt/credential.js'; +import fs from 'node:fs'; /** * Factory — build an McpServer with the six SwitchBot tools registered. @@ -45,14 +52,15 @@ function mcpError( }; } -export function createSwitchBotMcpServer(): McpServer { +export function createSwitchBotMcpServer(options?: { eventManager?: EventSubscriptionManager }): McpServer { + const eventManager = options?.eventManager; const server = new McpServer( { name: 'switchbot', - version: '1.4.0', + version: '2.0.0', }, { - capabilities: { tools: {} }, + capabilities: { tools: {}, resources: {} }, instructions: `SwitchBot is an IoT smart home brand by Wonderlabs, Inc. This MCP server controls physical devices \ (Bot, Curtain, Smart Lock, Color Bulb, Meter, Plug, Robot Vacuum, etc.) and IR remotes \ @@ -82,7 +90,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, { title: 'List all devices on the account', description: - 'Fetch the inventory of physical devices and IR remotes on this SwitchBot account. Refreshes the local cache.', + 'Fetch the complete inventory of physical devices and IR remotes on this SwitchBot account. Refreshes the local metadata cache and groups devices by type. Use this as the bootstrap call to discover available deviceIds. Devices without enableCloudService cannot receive commands via API. IR remotes depend on a Hub for connectivity.', inputSchema: {}, outputSchema: { deviceList: z.array(z.object({ @@ -151,7 +159,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, { title: 'Send a control command to a device', description: - 'Send a control command (turnOn, setColor, startClean, unlock, ...) to a device. Destructive commands (unlock, garage open, keypad createKey) require confirm:true; otherwise they are rejected.', + 'Execute a control command on a device (turnOn, setColor, startClean, unlock, openDoor, createKey, etc.). Destructive commands (Smart Lock unlock, Garage Door open, Keypad createKey/deleteKey) require confirm:true to proceed; otherwise rejected. Commands are validated offline against the device catalog. Use idempotencyKey to safely deduplicate retries within 60 seconds.', inputSchema: { deviceId: z.string().describe('Device ID from list_devices'), command: z.string().describe('Command name, case-sensitive (e.g. turnOn, setColor, unlock)'), @@ -378,6 +386,130 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, } ); + // ---- account_overview --------------------------------------------------- + server.registerTool( + 'account_overview', + { + title: 'Bootstrap account overview', + description: + 'Get a complete account snapshot: devices, scenes, quota usage, cache status, and MQTT connection state. Use this for cold-start initialization or periodic health checks.', + inputSchema: {}, + outputSchema: { + version: z.string(), + schemaVersion: z.string(), + devices: z.array(z.object({ + deviceId: z.string(), + deviceName: z.string(), + deviceType: z.string().optional(), + }).passthrough()).describe('All physical devices'), + infraredRemotes: z.array(z.object({ + deviceId: z.string(), + deviceName: z.string(), + remoteType: z.string(), + }).passthrough()).describe('All IR remotes'), + scenes: z.array(z.object({ + sceneId: z.string(), + sceneName: z.string(), + }).passthrough()).describe('All manual scenes'), + quota: z.object({ + date: z.string(), + total: z.number(), + remaining: z.number(), + endpoints: z.record(z.string(), z.number()).optional(), + }).describe('Today\'s quota usage'), + cache: z.object({ + list: z.object({ + path: z.string(), + exists: z.boolean(), + lastUpdated: z.string().optional(), + ageMs: z.number().optional(), + deviceCount: z.number().optional(), + }), + status: z.object({ + path: z.string(), + exists: z.boolean(), + entryCount: z.number(), + oldestFetchedAt: z.string().optional(), + newestFetchedAt: z.string().optional(), + }), + }).describe('Cache status'), + mqtt: z.object({ + state: z.string(), + subscribers: z.number(), + }).optional().describe('MQTT connection state (HTTP mode only)'), + }, + }, + async () => { + const deviceList = await fetchDeviceList(); + const sceneList = await fetchScenes(); + const cacheInfo = describeCache(); + const quota = todayUsage(); + + const overview = { + version: '2.0.0', + schemaVersion: '1.1', + devices: deviceList.deviceList.map(toMcpDeviceListShape), + infraredRemotes: deviceList.infraredRemoteList.map(toMcpIrDeviceShape), + scenes: sceneList.map((s) => ({ + sceneId: s.sceneId, + sceneName: s.sceneName, + })), + quota: { + date: quota.date, + total: quota.total, + remaining: quota.remaining, + endpoints: quota.endpoints, + }, + cache: { + list: cacheInfo.list, + status: cacheInfo.status, + }, + ...(eventManager ? { + mqtt: { + state: eventManager.getState(), + subscribers: eventManager.getSubscriberCount(), + }, + } : {}), + }; + + return { + content: [{ + type: 'text', + text: JSON.stringify(overview, null, 2), + }], + structuredContent: overview, + }; + } + ); + + // switchbot://events resource — snapshot of recent shadow events from the ring buffer. + // Returns up to 100 recent events. When MQTT is disabled, returns an empty list with a state note. + // URI: switchbot://events (optional query: ?filter= ?limit=) + if (eventManager) { + server.registerResource( + 'events', + 'switchbot://events', + { + title: 'SwitchBot real-time shadow events', + description: + 'Recent device shadow-update events received via MQTT. Returns a JSON snapshot of the ring buffer. ' + + 'State is "disabled" when MQTT credentials are not configured (set SWITCHBOT_MQTT_HOST / USERNAME / PASSWORD).', + mimeType: 'application/json', + }, + (_uri) => { + const state = eventManager.getState(); + const events = state !== 'disabled' ? eventManager.getRecentEvents(100) : []; + return { + contents: [{ + uri: 'switchbot://events', + mimeType: 'application/json', + text: JSON.stringify({ state, count: events.length, events }, null, 2), + }], + }; + }, + ); + } + return server; } @@ -418,7 +550,11 @@ Inspect locally: .command('serve') .description('Start the MCP server on stdio (default) or HTTP (--port)') .option('--port ', 'Listen on HTTP instead of stdio (Streamable HTTP transport)') - .action(async (options: { port?: string }) => { + .option('--bind ', 'IP address to bind (default 127.0.0.1; use 0.0.0.0 to accept external connections)', '127.0.0.1') + .option('--auth-token ', 'Bearer token for HTTP requests (required for --bind 0.0.0.0; falls back to SWITCHBOT_MCP_TOKEN env var)') + .option('--cors-origin ', 'Allowed CORS origin(s) for HTTP (repeatable)') + .option('--rate-limit ', 'Max requests per minute per profile (default 60)', '60') + .action(async (options: { port?: string; bind?: string; authToken?: string; corsOrigin?: string | string[]; rateLimit?: string }) => { try { if (options.port) { const port = Number(options.port); @@ -431,29 +567,235 @@ Inspect locally: } process.exit(2); } + + const bind = options.bind ?? '127.0.0.1'; + const authToken = options.authToken ?? process.env.SWITCHBOT_MCP_TOKEN; + const corsOrigins = Array.isArray(options.corsOrigin) ? options.corsOrigin : (options.corsOrigin ? [options.corsOrigin] : []); + const rateLimit = Math.max(1, Number(options.rateLimit) || 60); + + // Guard: refuse to bind non-localhost without auth + const isLocalhost = bind === '127.0.0.1' || bind === 'localhost' || bind === '::1'; + if (!isLocalhost && !authToken) { + const msg = 'Refusing to listen on 0.0.0.0 without --auth-token. Pass --auth-token or bind to localhost (default).'; + if (isJsonMode()) { + console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } })); + } else { + console.error(msg); + } + process.exit(2); + } + const { createServer } = await import('node:http'); + const rateLimitMap = new Map(); + + // Initialize shared EventSubscriptionManager for event streaming. + // If MQTT creds are present, connect in the background so the HTTP server + // starts immediately; /ready reflects the real state. + const eventManager = new EventSubscriptionManager(); + const mqttConfig = getMqttConfig(); + if (mqttConfig) { + eventManager.initialize(mqttConfig).catch((err: unknown) => { + console.error('MQTT initialization failed:', err instanceof Error ? err.message : String(err)); + }); + } else { + console.error('MQTT disabled: set SWITCHBOT_MQTT_HOST, SWITCHBOT_MQTT_USERNAME, SWITCHBOT_MQTT_PASSWORD to enable real-time events.'); + } + + // Helper: constant-time token comparison + const tokenMatch = (provided: string | undefined): boolean => { + if (!authToken) return true; // No token configured, allow all + if (!provided) return false; + const expected = authToken; + let match = true; + for (let i = 0; i < Math.max(expected.length, provided.length); i++) { + if ((expected[i] ?? '\0') !== (provided[i] ?? '\0')) match = false; + } + return match; + }; + + // Helper: rate limit check + const checkRateLimit = (profile: string): boolean => { + const now = Date.now(); + const bucket = rateLimitMap.get(profile); + if (!bucket || now >= bucket.resetAt) { + rateLimitMap.set(profile, { count: 1, resetAt: now + 60000 }); + return true; + } + bucket.count++; + return bucket.count <= rateLimit; + }; + const httpServer = createServer(async (req, res) => { + // Health and metrics routes (no auth required) + if (req.url === '/healthz' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + ok: true, + version: '2.0.0', + pid: process.pid, + uptimeSec: Math.floor(process.uptime()), + })); + return; + } + + if (req.url === '/ready' && req.method === 'GET') { + const state = eventManager.getState(); + const ready = state !== 'failed' && state !== 'disabled'; + const status = ready ? 200 : 503; + const body: Record = { ready, version: '2.0.0', mqtt: state }; + if (!ready) body.reason = state === 'disabled' ? 'mqtt disabled' : 'mqtt failed'; + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); + return; + } + + if (req.url === '/metrics' && req.method === 'GET') { + const mqttState = eventManager.getState(); + const metrics = `# HELP switchbot_mqtt_connected MQTT connection status (0=disconnected, 1=connected) +# TYPE switchbot_mqtt_connected gauge +switchbot_mqtt_connected ${mqttState === 'connected' ? 1 : 0} + +# HELP switchbot_mqtt_state Current MQTT state (1 for the active state, 0 otherwise) +# TYPE switchbot_mqtt_state gauge +switchbot_mqtt_state{state="disabled"} ${mqttState === 'disabled' ? 1 : 0} +switchbot_mqtt_state{state="connecting"} ${mqttState === 'connecting' ? 1 : 0} +switchbot_mqtt_state{state="connected"} ${mqttState === 'connected' ? 1 : 0} +switchbot_mqtt_state{state="reconnecting"} ${mqttState === 'reconnecting' ? 1 : 0} +switchbot_mqtt_state{state="failed"} ${mqttState === 'failed' ? 1 : 0} + +# HELP switchbot_mqtt_subscribers Number of active event subscribers +# TYPE switchbot_mqtt_subscribers gauge +switchbot_mqtt_subscribers ${eventManager.getSubscriberCount()} + +# HELP process_uptime_seconds Process uptime in seconds +# TYPE process_uptime_seconds gauge +process_uptime_seconds ${Math.floor(process.uptime())} +`; + res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' }); + res.end(metrics); + return; + } + + // Extract profile from header or query string + const headerProfile = req.headers['x-switchbot-profile']; + const profileHeader = Array.isArray(headerProfile) ? headerProfile[0] : headerProfile; + let profileQuery: string | undefined; + try { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + profileQuery = url.searchParams.get('profile') ?? undefined; + } catch { /* ignore */ } + const profile = profileHeader || profileQuery; + + // CORS preflight + if (req.method === 'OPTIONS') { + if (corsOrigins.length > 0) { + const origin = req.headers.origin; + if (origin && corsOrigins.includes(origin)) { + res.writeHead(200, { + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }); + res.end(); + return; + } + } + res.writeHead(204); + res.end(); + return; + } + + // Rate limit check + if (!checkRateLimit(profile ?? 'default')) { + res.writeHead(429, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Rate limit exceeded' }, id: null })); + return; + } + + // Auth check + const authHeader = req.headers.authorization; + const [scheme, token] = (authHeader ?? '').split(' '); + if (authToken && (scheme !== 'Bearer' || !tokenMatch(token))) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32001, message: 'Unauthorized' }, id: null })); + return; + } + + // CORS headers for allowed origins + if (corsOrigins.length > 0) { + const origin = req.headers.origin; + if (origin && corsOrigins.includes(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + } + } + + // Reject unknown profiles early: avoids confusing downstream credential + // errors and protects against probing for valid profile names. + if (profile) { + const envCredsPresent = !!(process.env.SWITCHBOT_TOKEN && process.env.SWITCHBOT_SECRET); + if (!envCredsPresent && !fs.existsSync(profileFilePath(profile))) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32001, message: `Unknown profile: ${profile}` }, + id: null, + })); + return; + } + } + // Stateless mode: fresh transport+server per request (SDK requirement). const reqTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); - const reqServer = createSwitchBotMcpServer(); + const reqServer = createSwitchBotMcpServer({ eventManager }); // Register cleanup before any async work so it fires on both normal // close and error-path close (after the 500 response ends). res.on('close', () => { reqTransport.close(); reqServer.close(); }); - try { - await reqServer.connect(reqTransport); - await reqTransport.handleRequest(req, res); - } catch (err) { - if (!res.headersSent) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error' }, id: null })); + // Route per-request credentials via AsyncLocalStorage so loadConfig() + // picks up this request's profile instead of the process-global flag. + await withRequestContext({ profile: profile ?? undefined }, async () => { + try { + await reqServer.connect(reqTransport); + await reqTransport.handleRequest(req, res); + } catch (err) { + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error' }, id: null })); + } } - } + }); }); - httpServer.listen(port, () => { - console.error(`SwitchBot MCP server listening on http://localhost:${port}/mcp`); + + // Graceful shutdown + let isShuttingDown = false; + const gracefulShutdown = async () => { + if (isShuttingDown) return; + isShuttingDown = true; + console.error('Shutting down...'); + await eventManager.shutdown(); + httpServer.close(() => { + console.error('Server closed'); + process.exit(0); + }); + // Force exit after 30s + setTimeout(() => { + console.error('Force exiting after 30s timeout'); + process.exit(1); + }, 30000); + }; + process.on('SIGTERM', gracefulShutdown); + process.on('SIGINT', gracefulShutdown); + + httpServer.listen(port, bind, () => { + console.error(`SwitchBot MCP server listening on http://${bind}:${port}/mcp`); + if (authToken) { + console.error(' Authentication: required (Bearer token)'); + } + if (corsOrigins.length > 0) { + console.error(` CORS origins: ${corsOrigins.join(', ')}`); + } }); return; } diff --git a/src/commands/schema.ts b/src/commands/schema.ts index ea9fcaca..75aecf79 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { printJson, isJsonMode } from '../utils/output.js'; +import { printJson } from '../utils/output.js'; import { getEffectiveCatalog, type CommandSpec, type DeviceCatalogEntry } from '../devices/catalog.js'; interface SchemaEntry { @@ -91,11 +91,6 @@ Examples: generatedAt: new Date().toISOString(), types: filtered.map(toSchemaEntry), }; - // Always JSON — schema export without JSON would be a category error. - if (isJsonMode()) { - printJson(payload); - } else { - console.log(JSON.stringify(payload, null, 2)); - } + printJson(payload); }); } diff --git a/src/config.ts b/src/config.ts index 2a1d2c76..3cbab03b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,8 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import { getConfigPath, getProfile } from './utils/flags.js'; +import { getConfigPath } from './utils/flags.js'; +import { getActiveProfile } from './lib/request-context.js'; export interface SwitchBotConfig { token: string; @@ -11,7 +12,7 @@ export interface SwitchBotConfig { /** * Credential file resolution priority: * 1. --config (absolute override — wins over everything) - * 2. --profile → ~/.switchbot/profiles/.json + * 2. active profile (ALS request context, else --profile flag) → ~/.switchbot/profiles/.json * 3. default → ~/.switchbot/config.json * * Env SWITCHBOT_TOKEN+SWITCHBOT_SECRET still take priority inside loadConfig. @@ -19,7 +20,7 @@ export interface SwitchBotConfig { export function configFilePath(): string { const override = getConfigPath(); if (override) return path.resolve(override); - const profile = getProfile(); + const profile = getActiveProfile(); if (profile) { return path.join(os.homedir(), '.switchbot', 'profiles', `${profile}.json`); } @@ -48,7 +49,7 @@ export function loadConfig(): SwitchBotConfig { const file = configFilePath(); if (!fs.existsSync(file)) { - const profile = getProfile(); + 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 '; diff --git a/src/lib/devices.ts b/src/lib/devices.ts index 4a38fe6a..3b6c1f3c 100644 --- a/src/lib/devices.ts +++ b/src/lib/devices.ts @@ -1,5 +1,6 @@ import type { AxiosInstance } from 'axios'; import { createClient } from '../api/client.js'; +import { idempotencyCache } from './idempotency.js'; import { findCatalogEntry, suggestedActions, @@ -156,7 +157,8 @@ export async function executeCommand( cmd: string, parameter: unknown, commandType: 'command' | 'customize', - client?: AxiosInstance + client?: AxiosInstance, + options?: { idempotencyKey?: string } ): Promise { const c = client ?? createClient(); const body = { @@ -173,26 +175,32 @@ export async function executeCommand( commandType, dryRun: isDryRun(), }; - try { - const res = await c.post<{ body: unknown }>( - `/v1.1/devices/${deviceId}/commands`, - body - ); - writeAudit({ ...baseAudit, result: 'ok' }); - return res.data.body; - } catch (err) { - // Dry-run intercepts throw DryRunSignal — still log the intent. - if (err instanceof Error && err.name === 'DryRunSignal') { + + // Wrap in idempotency cache if key is provided + const execute = async () => { + try { + const res = await c.post<{ body: unknown }>( + `/v1.1/devices/${deviceId}/commands`, + body + ); writeAudit({ ...baseAudit, result: 'ok' }); - } else { - writeAudit({ - ...baseAudit, - result: 'error', - error: err instanceof Error ? err.message : String(err), - }); + return res.data.body; + } catch (err) { + // Dry-run intercepts throw DryRunSignal — still log the intent. + if (err instanceof Error && err.name === 'DryRunSignal') { + writeAudit({ ...baseAudit, result: 'ok' }); + } else { + writeAudit({ + ...baseAudit, + result: 'error', + error: err instanceof Error ? err.message : String(err), + }); + } + throw err; } - throw err; - } + }; + + return idempotencyCache.run(options?.idempotencyKey, execute); } /** diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts new file mode 100644 index 00000000..2778525e --- /dev/null +++ b/src/lib/idempotency.ts @@ -0,0 +1,83 @@ +/** + * In-memory LRU cache for idempotent request deduplication. + * Caches the outcome of a keyed operation for 60 seconds; + * duplicate keys within the window return the cached result without re-executing. + * Process-local only — not shared across replicas. + */ + +const DEFAULT_TTL_MS = 60000; // 60 seconds +const DEFAULT_MAX_ENTRIES = 1024; + +export class IdempotencyCache { + private cache = new Map(); + private readonly ttlMs: number; + private readonly maxEntries: number; + + constructor(ttlMs?: number, maxEntries?: number) { + this.ttlMs = ttlMs ?? DEFAULT_TTL_MS; + this.maxEntries = maxEntries ?? DEFAULT_MAX_ENTRIES; + } + + /** + * Execute fn if the key is not cached, or return the cached result if it is. + * On new execution, caches the result for ttlMs. + */ + async run(key: string | undefined, fn: () => Promise): Promise { + // No key = always execute (not cached) + if (!key) { + return fn(); + } + + const now = Date.now(); + const cached = this.cache.get(key); + + // Cached and not expired + if (cached && cached.expiresAt > now) { + return cached.result as T; + } + + // Expired or uncached: execute + const result = await fn(); + + // Prune if over capacity (LRU: remove oldest entries) + if (this.cache.size >= this.maxEntries) { + const toRemove = Math.ceil(this.maxEntries * 0.1); // Remove 10% + let removed = 0; + for (const [k, v] of this.cache.entries()) { + if (removed >= toRemove) break; + // Remove expired entries first, then oldest + if (v.expiresAt <= now) { + this.cache.delete(k); + removed++; + } + } + // If still over capacity, remove oldest insertion (Map is insertion-ordered) + if (this.cache.size >= this.maxEntries) { + const firstKey = this.cache.keys().next().value; + if (firstKey) this.cache.delete(firstKey); + } + } + + // Cache the result + this.cache.set(key, { result, expiresAt: now + this.ttlMs }); + + return result; + } + + /** + * Clear all cached entries (mainly for testing). + */ + clear(): void { + this.cache.clear(); + } + + /** + * Return the number of cached entries. + */ + size(): number { + return this.cache.size; + } +} + +// Global shared instance for the process +export const idempotencyCache = new IdempotencyCache(); diff --git a/src/lib/request-context.ts b/src/lib/request-context.ts new file mode 100644 index 00000000..5a5dc7d4 --- /dev/null +++ b/src/lib/request-context.ts @@ -0,0 +1,18 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { getProfile } from '../utils/flags.js'; + +export interface RequestContext { + profile?: string; +} + +export const requestContext = new AsyncLocalStorage(); + +export function withRequestContext(ctx: RequestContext, fn: () => T): T { + return requestContext.run(ctx, fn); +} + +export function getActiveProfile(): string | undefined { + const ctx = requestContext.getStore(); + if (ctx?.profile !== undefined) return ctx.profile; + return getProfile(); +} diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 00000000..cdb68117 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,21 @@ +import pino from 'pino'; + +const logLevel = process.env.LOG_LEVEL || 'warn'; +const logFormat = process.env.LOG_FORMAT || 'json'; + +const pinoConfig = { + level: logLevel, + transport: logFormat === 'pretty' + ? { target: 'pino-pretty' } + : undefined, +}; + +export const log = pino(pinoConfig); + +export function setLogLevel(level: string): void { + log.level = level; +} + +export function getLogLevel(): string { + return log.level; +} diff --git a/src/mcp/events-subscription.ts b/src/mcp/events-subscription.ts new file mode 100644 index 00000000..2fa1ab9f --- /dev/null +++ b/src/mcp/events-subscription.ts @@ -0,0 +1,270 @@ +import { SwitchBotMqttClient, type MqttState } from '../mqtt/client.js'; +import { parseFilter, applyFilter, type FilterSyntaxError } from '../utils/filter.js'; +import { fetchDeviceList, type Device } from '../lib/devices.js'; +import { getCachedDevice } from '../devices/cache.js'; +import type { AxiosInstance } from 'axios'; +import { createClient } from '../api/client.js'; +import { log } from '../logger.js'; + +export interface ShadowEvent { + kind: 'shadow.updated'; + deviceId: string; + payload: Record; + timestamp: number; +} + +export interface SubscriptionEvent { + kind: 'events.reconnected' | 'events.dropped'; + timestamp?: number; + count?: number; + sinceTs?: number; +} + +export type RawEvent = ShadowEvent | SubscriptionEvent; + +export interface EventSubscriber { + id: string; + handler: (event: RawEvent) => void; + filter?: string; + lastActivity: number; +} + +export class EventSubscriptionManager { + private mqttClient: SwitchBotMqttClient | null = null; + private subscribers: Map = new Map(); + private ringBuffer: RawEvent[] = []; + private ringSize = 1000; + private typeMap: Map = new Map(); + private refreshTypeMapTimer: NodeJS.Timeout | null = null; + private idleCleanupTimer: NodeJS.Timeout | null = null; + private getClient?: () => AxiosInstance; + private lastRefreshAttempt = 0; + + constructor(mqttClient?: SwitchBotMqttClient, getClient?: () => AxiosInstance) { + this.mqttClient = mqttClient || null; + this.getClient = getClient; + } + + async initialize(mqttConfig: { + host: string; + port: number; + username: string; + password: string; + }): Promise { + if (!this.mqttClient) { + const client = new SwitchBotMqttClient(mqttConfig, async () => { + // Auth refresh callback - would need credential resolution here + return { + username: mqttConfig.username, + password: mqttConfig.password, + }; + }); + + client.onStateChange((state) => { + if (state === 'connected') { + this.emit({ + kind: 'events.reconnected', + timestamp: Date.now(), + } as SubscriptionEvent); + client.subscribe('$aws/things/+/shadow/update/accepted'); + } + }); + + client.onMessage((topic, payload) => { + try { + const data = JSON.parse(payload.toString()); + const deviceId = this.extractDeviceId(topic); + if (deviceId && data.state) { + this.addEvent({ + kind: 'shadow.updated', + deviceId, + payload: data.state, + timestamp: Date.now(), + }); + } + } catch (err) { + log.debug({ err, topic }, 'failed to parse shadow payload'); + } + }); + + await client.connect(); + this.mqttClient = client; + } + + this.scheduleIdleCleanup(); + } + + subscribe( + id: string, + handler: (event: RawEvent) => void, + filter?: string, + ): () => void { + // Validate filter syntax if provided + if (filter) { + parseFilter(filter); + } + + const subscriber: EventSubscriber = { + id, + handler, + filter, + lastActivity: Date.now(), + }; + + this.subscribers.set(id, subscriber); + + // Send recent events that match the filter + for (const event of this.ringBuffer) { + if (this.matchesFilter(event, filter)) { + handler(event); + } + } + + return () => { + this.subscribers.delete(id); + }; + } + + private addEvent(event: RawEvent): void { + this.ringBuffer.push(event); + + // Check for overflow + if (this.ringBuffer.length > this.ringSize) { + const droppedCount = this.ringBuffer.length - this.ringSize; + const oldestTimestamp = this.ringBuffer[0]?.timestamp || Date.now(); + + // Emit overflow notice to all subscribers + this.emit({ + kind: 'events.dropped', + count: droppedCount, + sinceTs: oldestTimestamp, + } as SubscriptionEvent); + + // Trim buffer + this.ringBuffer = this.ringBuffer.slice(-this.ringSize); + } + + // Broadcast to matching subscribers + this.emit(event); + } + + private emit(event: RawEvent): void { + for (const subscriber of this.subscribers.values()) { + if (this.matchesFilter(event, subscriber.filter)) { + subscriber.lastActivity = Date.now(); + subscriber.handler(event); + } + } + } + + private matchesFilter(event: RawEvent, filter?: string): boolean { + if (!filter) return true; + + // Only filter shadow events + if (event.kind !== 'shadow.updated') return true; + + try { + // Parse filter and match against device metadata + const clauses = parseFilter(filter); + const deviceId = event.deviceId; + + // Get device info from cache + const cached = getCachedDevice(deviceId); + if (!cached) { + // Lazily refresh type map if device unknown + this.scheduleTypeMapRefresh(); + return false; // Conservative: drop if unknown + } + + // Build a Device-compatible shape for applyFilter + const device: Device = { + deviceId, + deviceType: this.typeMap.get(deviceId) || cached.type, + deviceName: cached.name, + familyName: cached.familyName, + roomName: cached.roomName, + enableCloudService: true, + hubDeviceId: '', + }; + + // Use applyFilter with single device in list + const matched = applyFilter(clauses, [device], [], new Map()); + return matched.length > 0; + } catch { + return false; // Invalid filter matches nothing + } + } + + private scheduleTypeMapRefresh(): void { + if (this.refreshTypeMapTimer || Date.now() - this.lastRefreshAttempt < 5000) { + return; // Already scheduled or too recent + } + + this.refreshTypeMapTimer = setTimeout(async () => { + this.refreshTypeMapTimer = null; + this.lastRefreshAttempt = Date.now(); + + try { + const client = this.getClient?.() || createClient(); + const body = await fetchDeviceList(client); + for (const d of body.deviceList) { + if (d.deviceType) this.typeMap.set(d.deviceId, d.deviceType); + } + for (const ir of body.infraredRemoteList) { + this.typeMap.set(ir.deviceId, ir.remoteType); + } + } catch { + // Silently fail type map refresh + } + }, 100); + } + + private scheduleIdleCleanup(): void { + if (this.idleCleanupTimer) return; + + this.idleCleanupTimer = setInterval(() => { + const now = Date.now(); + const idleThreshold = 10 * 60 * 1000; // 10 minutes + + for (const [id, subscriber] of this.subscribers.entries()) { + if (now - subscriber.lastActivity > idleThreshold) { + this.subscribers.delete(id); + } + } + }, 60000); // Check every minute + } + + private extractDeviceId(topic: string): string | null { + // Topic format: $aws/things//shadow/update/accepted + const match = topic.match(/\$aws\/things\/([^/]+)\/shadow/); + return match ? match[1] : null; + } + + getState(): MqttState { + if (!this.mqttClient) return 'disabled'; + return this.mqttClient.getState(); + } + + getSubscriberCount(): number { + return this.subscribers.size; + } + + getRecentEvents(limit = 100): RawEvent[] { + return this.ringBuffer.slice(-limit); + } + + async shutdown(): Promise { + if (this.refreshTypeMapTimer) { + clearTimeout(this.refreshTypeMapTimer); + } + if (this.idleCleanupTimer) { + clearInterval(this.idleCleanupTimer); + } + if (this.mqttClient) { + await this.mqttClient.disconnect(); + this.mqttClient = null; + } + this.subscribers.clear(); + this.ringBuffer = []; + } +} diff --git a/src/mqtt/client.ts b/src/mqtt/client.ts new file mode 100644 index 00000000..923805e2 --- /dev/null +++ b/src/mqtt/client.ts @@ -0,0 +1,218 @@ +import type { IClientOptions } from 'mqtt'; +import { connect } from 'mqtt'; +import type { MqttClient } from 'mqtt'; + +export type MqttState = 'connecting' | 'connected' | 'reconnecting' | 'failed' | 'disabled'; +export type AuthRefreshCallback = () => Promise<{ username: string; password: string }> | { username: string; password: string }; + +interface MqttClientConfig { + host: string; + port: number; + username: string; + password: string; + rejectUnauthorized?: boolean; +} + +export class SwitchBotMqttClient { + private client: MqttClient | null = null; + private config: MqttClientConfig; + private state: MqttState = 'connecting'; + private authRefreshNeeded = false; + private reconnectAttempts = 0; + private maxReconnectAttempts = 10; + private handlers: Set<(state: MqttState) => void> = new Set(); + private messageHandlers: Set<(topic: string, payload: Buffer) => void> = new Set(); + private authRefreshCallback?: AuthRefreshCallback; + private stableTimer: NodeJS.Timeout | null = null; + private lastConnectionAttempt = 0; + + constructor(config: MqttClientConfig, onAuthRefreshNeeded?: AuthRefreshCallback) { + this.config = config; + this.authRefreshCallback = onAuthRefreshNeeded; + } + + async connect(): Promise { + if (this.client && this.state === 'connected') { + return; + } + + this.setState('connecting'); + this.authRefreshNeeded = false; + this.reconnectAttempts = 0; + + try { + const options: IClientOptions = { + username: this.config.username, + password: this.config.password, + clean: true, + reconnectPeriod: 0, // Manual reconnect control + connectTimeout: 10000, + rejectUnauthorized: this.config.rejectUnauthorized ?? true, + }; + + this.client = connect(`mqtts://${this.config.host}:${this.config.port}`, options); + + this.client.on('connect', () => { + this.reconnectAttempts = 0; + this.setState('connected'); + this.authRefreshNeeded = false; + }); + + this.client.on('message', (topic, payload) => { + for (const handler of this.messageHandlers) { + handler(topic, payload); + } + }); + + this.client.on('error', (err) => { + // Check for auth-related errors + if ( + (err instanceof Error && + (err.message.includes('401') || + err.message.includes('Unauthorized') || + err.message.includes('EACCES'))) || + (err as NodeJS.ErrnoException).code === 'EACCES' + ) { + this.authRefreshNeeded = true; + } + }); + + this.client.on('close', () => { + this.clearStableTimer(); + if (this.authRefreshNeeded) { this.setState('failed'); + } else if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.attemptReconnect(); + } else { + this.setState('failed'); + } + }); + + // Wait for connection with timeout + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('MQTT connection timeout')); + }, 15000); + + const onConnect = () => { + clearTimeout(timeout); + this.client?.removeListener('error', onError); + resolve(); + }; + + const onError = (err: Error) => { + clearTimeout(timeout); + this.client?.removeListener('connect', onConnect); + reject(err); + }; + + if (this.client?.connected) { + clearTimeout(timeout); + resolve(); + } else { + this.client?.once('connect', onConnect); + this.client?.once('error', onError); + } + }); + } catch (err) { + this.setState('failed'); + throw err; + } + } + + private async attemptReconnect(): Promise { + this.reconnectAttempts++; + this.setState('reconnecting'); + + if (this.authRefreshNeeded && this.authRefreshCallback) { + try { + const refreshed = await this.authRefreshCallback(); + this.config.username = refreshed.username; + this.config.password = refreshed.password; + this.authRefreshNeeded = false; + } catch (err) { + // Auth refresh failed, mark as failed + this.setState('failed'); + return; + } + } + + // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s... + const delay = Math.min(30000, 1000 * Math.pow(2, this.reconnectAttempts - 1)); + await new Promise((r) => setTimeout(r, delay)); + + try { + await this.connect(); + } catch (err) { + if (this.reconnectAttempts < this.maxReconnectAttempts) { + await this.attemptReconnect(); + } else { + this.setState('failed'); + } + } + } + + private setState(newState: MqttState): void { + if (this.state !== newState) { + this.state = newState; + for (const handler of this.handlers) { + handler(newState); + } + } + } + + private clearStableTimer(): void { + if (this.stableTimer) { + clearTimeout(this.stableTimer); + this.stableTimer = null; + } + } + + subscribe(topic: string): void { + if (this.client && this.state === 'connected') { + this.client.subscribe(topic, (err) => { + if (err) { + console.error(`Failed to subscribe to ${topic}:`, err); + } + }); + } + } + + onStateChange(handler: (state: MqttState) => void): () => void { + this.handlers.add(handler); + return () => { + this.handlers.delete(handler); + }; + } + + onMessage(handler: (topic: string, payload: Buffer) => void): () => void { + this.messageHandlers.add(handler); + return () => { + this.messageHandlers.delete(handler); + }; + } + + getState(): MqttState { + return this.state; + } + + isConnected(): boolean { + return this.state === 'connected' && this.client?.connected === true; + } + + async disconnect(): Promise { + this.clearStableTimer(); + if (this.client) { + await new Promise((resolve) => { + this.client?.end(false, () => { + resolve(); + }); + }); + this.client = null; + this.setState('failed'); + } + } + + setAuthRefreshCallback(callback: AuthRefreshCallback): void { + this.authRefreshCallback = callback; + } +} diff --git a/src/mqtt/credential.ts b/src/mqtt/credential.ts new file mode 100644 index 00000000..19eb1f08 --- /dev/null +++ b/src/mqtt/credential.ts @@ -0,0 +1,31 @@ +/** + * Resolve MQTT broker config from environment variables. + * + * Required env vars: + * SWITCHBOT_MQTT_HOST — broker hostname (e.g. mqtt.example.com) + * SWITCHBOT_MQTT_USERNAME — MQTT username + * SWITCHBOT_MQTT_PASSWORD — MQTT password + * + * Optional: + * SWITCHBOT_MQTT_PORT — broker port (default 8883) + */ +export interface MqttConfig { + host: string; + port: number; + username: string; + password: string; +} + +export function getMqttConfig(): MqttConfig | null { + const host = process.env.SWITCHBOT_MQTT_HOST; + const username = process.env.SWITCHBOT_MQTT_USERNAME; + const password = process.env.SWITCHBOT_MQTT_PASSWORD; + + if (!host || !username || !password) return null; + + const rawPort = process.env.SWITCHBOT_MQTT_PORT; + const port = rawPort ? Number(rawPort) : 8883; + if (!Number.isFinite(port) || port <= 0 || port > 65535) return null; + + return { host, port, username, password }; +} diff --git a/src/utils/output.ts b/src/utils/output.ts index 1f6a2b51..58a4e63d 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -4,12 +4,14 @@ import { ApiError, DryRunSignal } from '../api/client.js'; import { getFormat } from './flags.js'; +export const SCHEMA_VERSION = '1.1'; + export function isJsonMode(): boolean { return process.argv.includes('--json') || getFormat() === 'json'; } export function printJson(data: unknown): void { - console.log(JSON.stringify(data, null, 2)); + console.log(JSON.stringify({ schemaVersion: SCHEMA_VERSION, data }, null, 2)); } export function printTable(headers: string[], rows: (string | number | boolean | null | undefined)[][]): void { @@ -69,6 +71,9 @@ export interface ErrorPayload { hint?: string; retryable?: boolean; context?: Record; + retryAfterMs?: number; + transient?: boolean; + errorClass?: 'network' | 'api' | 'device-offline' | 'device-busy' | 'guard' | 'usage'; } export class StructuredUsageError extends Error { @@ -94,22 +99,44 @@ function classifyApiError(code: number): ErrorSubKind { export function buildErrorPayload(error: unknown): ErrorPayload { if (error instanceof StructuredUsageError) { - const payload: ErrorPayload = { code: 2, kind: 'usage', message: error.message }; + const payload: ErrorPayload = { + code: 2, + kind: 'usage', + message: error.message, + errorClass: 'usage', + transient: false + }; if (error.context) payload.context = error.context; return payload; } if (error instanceof UsageError) { - return { code: 2, kind: 'usage', message: error.message }; + return { code: 2, kind: 'usage', message: error.message, errorClass: 'usage', transient: false }; } const code = error instanceof ApiError ? error.code : 1; const kind: ErrorPayload['kind'] = error instanceof ApiError ? 'api' : 'runtime'; const message = error instanceof Error ? error.message : 'An unknown error occurred'; const hint = error instanceof ApiError ? (error.hint ?? errorHint(error.code)) : null; const retryable = error instanceof ApiError ? error.retryable : false; - const payload: ErrorPayload = { code, kind, message }; + const retryAfterMs = error instanceof ApiError ? error.retryAfterMs : undefined; + const transient = error instanceof ApiError ? error.transient : false; + + // Classify error + let errorClass: ErrorPayload['errorClass'] = 'api'; + if (kind === 'runtime') { + errorClass = 'api'; + } else if (transient && code >= 500) { + errorClass = 'api'; + } else if (code === 0) { + errorClass = 'network'; + } else if (code >= 400) { + errorClass = 'api'; + } + + const payload: ErrorPayload = { code, kind, message, errorClass, transient }; if (error instanceof ApiError) payload.subKind = classifyApiError(error.code); if (hint) payload.hint = hint; if (retryable) payload.retryable = true; + if (retryAfterMs !== undefined) payload.retryAfterMs = retryAfterMs; return payload; } @@ -121,7 +148,7 @@ export function handleError(error: unknown): never { const payload = buildErrorPayload(error); if (isJsonMode()) { - console.error(JSON.stringify({ error: payload })); + console.error(JSON.stringify({ schemaVersion: SCHEMA_VERSION, error: payload })); process.exit(payload.code === 2 ? 2 : 1); } diff --git a/tests/commands/batch.test.ts b/tests/commands/batch.test.ts index fd817474..7175efe7 100644 --- a/tests/commands/batch.test.ts +++ b/tests/commands/batch.test.ts @@ -156,9 +156,10 @@ describe('devices batch', () => { expect(result.exitCode).toBeNull(); expect(apiMock.__instance.post).toHaveBeenCalledTimes(2); const parsed = JSON.parse(result.stdout[0]); - expect(parsed.summary.ok).toBe(2); - expect(parsed.summary.failed).toBe(0); - expect(parsed.succeeded.map((s: { deviceId: string }) => s.deviceId).sort()).toEqual(['BOT1', 'BOT2']); + expect(parsed.schemaVersion).toBe('1.1'); + expect(parsed.data.summary.ok).toBe(2); + expect(parsed.data.summary.failed).toBe(0); + expect(parsed.data.succeeded.map((s: { deviceId: string }) => s.deviceId).sort()).toEqual(['BOT1', 'BOT2']); }); it('dispatches by --ids (intersected with --filter when both are set)', async () => { @@ -180,7 +181,7 @@ describe('devices batch', () => { // Only BOT1 and BOT2 pass the filter — LOCK1 is excluded. expect(apiMock.__instance.post).toHaveBeenCalledTimes(2); const parsed = JSON.parse(result.stdout[0]); - expect(parsed.summary.total).toBe(2); + expect(parsed.data.summary.total).toBe(2); }); it('uses cached type info for --ids without fetching the device list', async () => { @@ -221,10 +222,10 @@ describe('devices batch', () => { expect(result.exitCode).toBe(1); const parsed = JSON.parse(result.stdout[0]); - expect(parsed.summary.ok).toBe(1); - expect(parsed.summary.failed).toBe(1); - expect(parsed.failed[0].deviceId).toBe('BOT2'); - expect(parsed.failed[0].error).toMatch(/timeout/); + expect(parsed.data.summary.ok).toBe(1); + expect(parsed.data.summary.failed).toBe(1); + expect(parsed.data.failed[0].deviceId).toBe('BOT2'); + expect(parsed.data.failed[0].error.message).toMatch(/timeout/); }); it('refuses destructive commands without --yes', async () => { @@ -260,7 +261,7 @@ describe('devices batch', () => { expect(result.exitCode).toBeNull(); expect(apiMock.__instance.post).toHaveBeenCalledTimes(1); const parsed = JSON.parse(result.stdout[0]); - expect(parsed.summary.ok).toBe(1); + expect(parsed.data.summary.ok).toBe(1); }); it('--dry-run does not send POSTs and marks all as skipped', async () => { @@ -284,10 +285,10 @@ describe('devices batch', () => { expect(result.exitCode).toBeNull(); const parsed = JSON.parse(result.stdout[0]); - expect(parsed.summary.ok).toBe(0); - expect(parsed.summary.failed).toBe(0); - expect(parsed.summary.skipped).toBe(2); - expect(parsed.summary.dryRun).toBe(true); + expect(parsed.data.summary.ok).toBe(0); + expect(parsed.data.summary.failed).toBe(0); + expect(parsed.data.summary.skipped).toBe(2); + expect(parsed.data.summary.dryRun).toBe(true); }); it('prints a human summary line when not in JSON mode', async () => { @@ -321,7 +322,7 @@ describe('devices batch', () => { ]); expect(result.exitCode).toBeNull(); const parsed = JSON.parse(result.stdout[0]); - expect(parsed.summary.total).toBe(0); + expect(parsed.data.summary.total).toBe(0); expect(apiMock.__instance.post).not.toHaveBeenCalled(); }); }); diff --git a/tests/commands/cache.test.ts b/tests/commands/cache.test.ts index 094acc2d..c13b8a83 100644 --- a/tests/commands/cache.test.ts +++ b/tests/commands/cache.test.ts @@ -82,12 +82,12 @@ describe('cache show', () => { const result = await runCli(registerCacheCommand, ['--json', 'cache', 'show']); expect(result.exitCode).toBeNull(); const parsed = JSON.parse(result.stdout.join('\n')); - expect(parsed.list.exists).toBe(true); - expect(parsed.list.deviceCount).toBe(3); - expect(parsed.status.entryCount).toBe(1); - expect(parsed.status.entries.BOT1.fetchedAt).toBe('2026-04-17T12:00:00.000Z'); + expect(parsed.data.list.exists).toBe(true); + expect(parsed.data.list.deviceCount).toBe(3); + expect(parsed.data.status.entryCount).toBe(1); + expect(parsed.data.status.entries.BOT1.fetchedAt).toBe('2026-04-17T12:00:00.000Z'); // --json output should not leak the raw status body (only timestamps). - expect(parsed.status.entries.BOT1.body).toBeUndefined(); + expect(parsed.data.status.entries.BOT1.body).toBeUndefined(); }); }); @@ -145,7 +145,7 @@ describe('cache clear', () => { const result = await runCli(registerCacheCommand, ['--json', 'cache', 'clear', '--key', 'list']); expect(result.exitCode).toBeNull(); const parsed = JSON.parse(result.stdout.join('\n')); - expect(parsed).toEqual({ cleared: ['list'] }); + expect(parsed).toEqual({ schemaVersion: '1.1', data: { cleared: ['list'] } }); }); it('is a no-op when files do not exist', async () => { diff --git a/tests/commands/capabilities.test.ts b/tests/commands/capabilities.test.ts index 632d3482..bd26938c 100644 --- a/tests/commands/capabilities.test.ts +++ b/tests/commands/capabilities.test.ts @@ -45,7 +45,7 @@ async function runCapabilities(): Promise> { logSpy.mockRestore(); } - return JSON.parse(chunks.join('')) as Record; + return (JSON.parse(chunks.join('')) as { data: Record }).data; } describe('capabilities', () => { diff --git a/tests/commands/catalog.test.ts b/tests/commands/catalog.test.ts index 55b89587..d795628d 100644 --- a/tests/commands/catalog.test.ts +++ b/tests/commands/catalog.test.ts @@ -61,9 +61,9 @@ describe('catalog path', () => { writeOverlay([{ type: 'Bot' }]); const { stdout } = await runCli(registerCatalogCommand, ['--json', 'catalog', 'path']); const parsed = JSON.parse(stdout.join('\n')); - expect(parsed.exists).toBe(true); - expect(parsed.valid).toBe(true); - expect(parsed.entryCount).toBe(1); + expect(parsed.data.exists).toBe(true); + expect(parsed.data.valid).toBe(true); + expect(parsed.data.entryCount).toBe(1); }); }); @@ -137,14 +137,14 @@ describe('catalog show', () => { it('emits JSON array with --json', async () => { const { stdout } = await runCli(registerCatalogCommand, ['--json', 'catalog', 'show']); const parsed = JSON.parse(stdout.join('\n')); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed.find((e: { type: string }) => e.type === 'Bot')).toBeDefined(); + expect(Array.isArray(parsed.data)).toBe(true); + expect(parsed.data.find((e: { type: string }) => e.type === 'Bot')).toBeDefined(); }); it('emits a single-entry JSON object when a type is given', async () => { const { stdout } = await runCli(registerCatalogCommand, ['--json', 'catalog', 'show', 'Bot']); const parsed = JSON.parse(stdout.join('\n')); - expect(parsed.type).toBe('Bot'); + expect(parsed.data.type).toBe('Bot'); }); }); @@ -197,11 +197,11 @@ describe('catalog diff', () => { ]); const { stdout } = await runCli(registerCatalogCommand, ['--json', 'catalog', 'diff']); const parsed = JSON.parse(stdout.join('\n')); - expect(parsed.replaced).toHaveLength(1); - expect(parsed.replaced[0].type).toBe('Bot'); - expect(parsed.replaced[0].changedKeys).toContain('role'); - expect(parsed.removed).toContain('Curtain'); - expect(parsed.added).toEqual([]); + expect(parsed.data.replaced).toHaveLength(1); + expect(parsed.data.replaced[0].type).toBe('Bot'); + expect(parsed.data.replaced[0].changedKeys).toContain('role'); + expect(parsed.data.removed).toContain('Curtain'); + expect(parsed.data.added).toEqual([]); }); }); @@ -223,6 +223,6 @@ describe('catalog refresh', () => { it('emits JSON with --json', async () => { const { stdout } = await runCli(registerCatalogCommand, ['--json', 'catalog', 'refresh']); const parsed = JSON.parse(stdout.join('\n')); - expect(parsed.refreshed).toBe(true); + expect(parsed.data.refreshed).toBe(true); }); }); diff --git a/tests/commands/config.test.ts b/tests/commands/config.test.ts index 37aec7b4..fa9a632d 100644 --- a/tests/commands/config.test.ts +++ b/tests/commands/config.test.ts @@ -69,7 +69,7 @@ describe('config command', () => { configMock.listProfiles.mockReturnValue(['home']); const res = await runCli(registerConfigCommand, ['--json', 'config', 'list-profiles']); const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); - expect(out.profiles).toEqual(['home']); + expect(out.data.profiles).toEqual(['home']); }); }); diff --git a/tests/commands/devices.test.ts b/tests/commands/devices.test.ts index 2529417d..8796457d 100644 --- a/tests/commands/devices.test.ts +++ b/tests/commands/devices.test.ts @@ -472,8 +472,8 @@ describe('devices command', () => { 'devices', 'status', 'ABC', '--format', 'json', ]); const parsed = JSON.parse(res.stdout.join('\n')); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed[0]).toEqual({ power: 'off', battery: 50 }); + expect(Array.isArray(parsed.data)).toBe(true); + expect(parsed.data[0]).toEqual({ power: 'off', battery: 50 }); }); it('serializes nested objects to JSON strings in tsv output', async () => { @@ -513,10 +513,10 @@ describe('devices command', () => { 'devices', 'status', 'DEV2', '--format', 'json', ]); const parsed = JSON.parse(res.stdout.join('\n')); - expect(parsed[0].power).toBe('on'); + expect(parsed.data[0].power).toBe('on'); // Nested object/array fields come through as real JS values. - expect(parsed[0].motion).toEqual({ x: 1, y: 2 }); - expect(parsed[0].modes).toEqual(['eco', 'turbo']); + expect(parsed.data[0].motion).toEqual({ x: 1, y: 2 }); + expect(parsed.data[0].modes).toEqual(['eco', 'turbo']); }); it('null status fields appear as empty string in tsv', async () => { @@ -1385,19 +1385,19 @@ describe('devices command', () => { apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); const res = await runCli(registerDevicesCommand, ['devices', 'describe', 'BLE-001', '--json']); const parsed = JSON.parse(res.stdout.join('\n')); - expect(parsed).toHaveProperty('device'); - expect(parsed).toHaveProperty('controlType', 'Bot'); - expect(parsed).toHaveProperty('catalog'); - expect(parsed.catalog.type).toBe('Bot'); - expect(parsed).not.toHaveProperty('category'); + expect(parsed.data).toHaveProperty('device'); + expect(parsed.data).toHaveProperty('controlType', 'Bot'); + expect(parsed.data).toHaveProperty('catalog'); + expect(parsed.data.catalog.type).toBe('Bot'); + expect(parsed.data).not.toHaveProperty('category'); }); it('--json for IR remote surfaces controlType from the device', async () => { apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); const res = await runCli(registerDevicesCommand, ['devices', 'describe', 'IR-001', '--json']); const parsed = JSON.parse(res.stdout.join('\n')); - expect(parsed).toHaveProperty('controlType', 'TV'); - expect(parsed).not.toHaveProperty('category'); + expect(parsed.data).toHaveProperty('controlType', 'TV'); + expect(parsed.data).not.toHaveProperty('category'); }); it('--json includes capabilities, source=catalog, and suggestedActions', async () => { @@ -1409,15 +1409,15 @@ describe('devices command', () => { '--json', ]); const parsed = JSON.parse(res.stdout.join('\n')); - expect(parsed.source).toBe('catalog'); - expect(parsed.capabilities).toBeDefined(); - expect(parsed.capabilities.role).toBe('other'); - expect(parsed.capabilities.readOnly).toBe(false); - expect(Array.isArray(parsed.capabilities.commands)).toBe(true); - expect(parsed.capabilities.statusFields).toContain('battery'); - expect(Array.isArray(parsed.suggestedActions)).toBe(true); + expect(parsed.data.source).toBe('catalog'); + expect(parsed.data.capabilities).toBeDefined(); + expect(parsed.data.capabilities.role).toBe('other'); + expect(parsed.data.capabilities.readOnly).toBe(false); + expect(Array.isArray(parsed.data.capabilities.commands)).toBe(true); + expect(parsed.data.capabilities.statusFields).toContain('battery'); + expect(Array.isArray(parsed.data.suggestedActions)).toBe(true); // turnOn is the first idempotent pick for a Bot - expect(parsed.suggestedActions[0].command).toBe('turnOn'); + expect(parsed.data.suggestedActions[0].command).toBe('turnOn'); }); it('--json for a Smart Lock surfaces destructive flag on unlock', async () => { @@ -1439,7 +1439,7 @@ describe('devices command', () => { '--json', ]); const parsed = JSON.parse(res.stdout.join('\n')); - const unlock = parsed.capabilities.commands.find( + const unlock = parsed.data.capabilities.commands.find( (c: { command: string }) => c.command === 'unlock' ); expect(unlock).toBeDefined(); @@ -1447,7 +1447,7 @@ describe('devices command', () => { expect(unlock.idempotent).toBe(true); // suggestedActions must NOT include the destructive unlock expect( - parsed.suggestedActions.find((a: { command: string }) => a.command === 'unlock') + parsed.data.suggestedActions.find((a: { command: string }) => a.command === 'unlock') ).toBeUndefined(); }); @@ -1506,8 +1506,8 @@ describe('devices command', () => { expect(apiMock.__instance.get).toHaveBeenNthCalledWith(1, '/v1.1/devices'); expect(apiMock.__instance.get).toHaveBeenNthCalledWith(2, '/v1.1/devices/BLE-001/status'); const parsed = JSON.parse(res.stdout.join('\n')); - expect(parsed.source).toBe('catalog+live'); - expect(parsed.capabilities.liveStatus).toEqual({ power: 'on', battery: 87 }); + expect(parsed.data.source).toBe('catalog+live'); + expect(parsed.data.capabilities.liveStatus).toEqual({ power: 'on', battery: 87 }); }); it('--live on an IR remote does NOT make a second API call (IR has no status)', async () => { @@ -1521,8 +1521,8 @@ describe('devices command', () => { ]); expect(apiMock.__instance.get).toHaveBeenCalledTimes(1); const parsed = JSON.parse(res.stdout.join('\n')); - expect(parsed.source).toBe('catalog'); - expect(parsed.capabilities.liveStatus).toBeUndefined(); + expect(parsed.data.source).toBe('catalog'); + expect(parsed.data.capabilities.liveStatus).toBeUndefined(); }); it('--live survives a /status failure (records the error)', async () => { @@ -1538,8 +1538,8 @@ describe('devices command', () => { ]); expect(res.exitCode).toBeNull(); // not a fatal exit const parsed = JSON.parse(res.stdout.join('\n')); - expect(parsed.source).toBe('catalog+live'); - expect(parsed.capabilities.liveStatus).toHaveProperty('error', 'device offline'); + expect(parsed.data.source).toBe('catalog+live'); + expect(parsed.data.capabilities.liveStatus).toHaveProperty('error', 'device offline'); }); it('returns source=none when device type is unknown and --live not set', async () => { @@ -1562,8 +1562,8 @@ describe('devices command', () => { ]); expect(res.exitCode).toBeNull(); const parsed = JSON.parse(res.stdout.join('\n')); - expect(parsed.source).toBe('none'); - expect(parsed.capabilities).toBeNull(); + expect(parsed.data.source).toBe('none'); + expect(parsed.data.capabilities).toBeNull(); }); it('propagates API errors via handleError (exit 1)', async () => { diff --git a/tests/commands/doctor.test.ts b/tests/commands/doctor.test.ts index f1bc0fef..56d40abd 100644 --- a/tests/commands/doctor.test.ts +++ b/tests/commands/doctor.test.ts @@ -25,8 +25,8 @@ describe('doctor command', () => { const res = await runCli(registerDoctorCommand, ['--json', 'doctor']); expect(res.exitCode).toBe(1); const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); - expect(payload.overall).toBe('fail'); - const creds = payload.checks.find((c: { name: string }) => c.name === 'credentials'); + expect(payload.data.overall).toBe('fail'); + const creds = payload.data.checks.find((c: { name: string }) => c.name === 'credentials'); expect(creds.status).toBe('fail'); expect(creds.detail).toMatch(/config set-token/); }); @@ -37,7 +37,7 @@ describe('doctor command', () => { const res = await runCli(registerDoctorCommand, ['--json', 'doctor']); expect(res.exitCode).not.toBe(1); const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); - const creds = payload.checks.find((c: { name: string }) => c.name === 'credentials'); + const creds = payload.data.checks.find((c: { name: string }) => c.name === 'credentials'); expect(creds.status).toBe('ok'); expect(creds.detail).toMatch(/env/); }); @@ -50,7 +50,7 @@ describe('doctor command', () => { ); const res = await runCli(registerDoctorCommand, ['--json', 'doctor']); const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); - const creds = payload.checks.find((c: { name: string }) => c.name === 'credentials'); + const creds = payload.data.checks.find((c: { name: string }) => c.name === 'credentials'); expect(creds.status).toBe('ok'); expect(creds.detail).toMatch(/config\.json/); }); @@ -64,7 +64,7 @@ describe('doctor command', () => { 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 profiles = payload.checks.find((c: { name: string }) => c.name === 'profiles'); + const profiles = payload.data.checks.find((c: { name: string }) => c.name === 'profiles'); expect(profiles.detail).toMatch(/found 2/); expect(profiles.detail).toMatch(/home/); expect(profiles.detail).toMatch(/work/); @@ -75,7 +75,7 @@ describe('doctor command', () => { 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 cat = payload.checks.find((c: { name: string }) => c.name === 'catalog'); + const cat = payload.data.checks.find((c: { name: string }) => c.name === 'catalog'); expect(cat.detail).toMatch(/\d+ types loaded/); }); }); diff --git a/tests/commands/expand.test.ts b/tests/commands/expand.test.ts index 7c57c3ca..8568ebcb 100644 --- a/tests/commands/expand.test.ts +++ b/tests/commands/expand.test.ts @@ -184,7 +184,7 @@ describe('devices expand', () => { '--temp', '26', '--mode', 'cool', '--fan', 'low', '--power', 'on', '--json', ]); const out = JSON.parse(res.stdout.join('\n')); - expect(out.subKind).toBe('ir-no-feedback'); + expect(out.data.subKind).toBe('ir-no-feedback'); }); it('rejects unsupported command', async () => { diff --git a/tests/commands/explain.test.ts b/tests/commands/explain.test.ts index f219cc7a..3587d5bf 100644 --- a/tests/commands/explain.test.ts +++ b/tests/commands/explain.test.ts @@ -116,20 +116,20 @@ describe('devices explain', () => { expect(res.exitCode).toBeNull(); const parsed = JSON.parse(res.stdout[0]); - expect(parsed.deviceId).toBe(DID); - expect(parsed.type).toBe('Bot'); - expect(parsed.category).toBe('physical'); - expect(parsed.name).toBe('My Bot'); - expect(parsed.role).toBe('power'); - expect(parsed.readOnly).toBe(false); - expect(Array.isArray(parsed.commands)).toBe(true); - expect(parsed.commands[0].command).toBe('turnOn'); - expect(parsed.commands[0].idempotent).toBe(true); - expect(Array.isArray(parsed.statusFields)).toBe(true); - expect(parsed.liveStatus).toMatchObject({ power: 'on', battery: 95 }); - expect(Array.isArray(parsed.suggestedActions)).toBe(true); - expect(Array.isArray(parsed.warnings)).toBe(true); - expect(parsed.warnings).toHaveLength(0); + expect(parsed.data.deviceId).toBe(DID); + expect(parsed.data.type).toBe('Bot'); + expect(parsed.data.category).toBe('physical'); + expect(parsed.data.name).toBe('My Bot'); + expect(parsed.data.role).toBe('power'); + expect(parsed.data.readOnly).toBe(false); + expect(Array.isArray(parsed.data.commands)).toBe(true); + expect(parsed.data.commands[0].command).toBe('turnOn'); + expect(parsed.data.commands[0].idempotent).toBe(true); + expect(Array.isArray(parsed.data.statusFields)).toBe(true); + expect(parsed.data.liveStatus).toMatchObject({ power: 'on', battery: 95 }); + expect(Array.isArray(parsed.data.suggestedActions)).toBe(true); + expect(Array.isArray(parsed.data.warnings)).toBe(true); + expect(parsed.data.warnings).toHaveLength(0); }); it('--json: device not found emits { error: { code:1, kind:"runtime" } } on stderr', async () => { @@ -182,7 +182,7 @@ describe('devices explain', () => { const res = await runExplain('--json', DID); const parsed = JSON.parse(res.stdout[0]); - expect(parsed.warnings.some((w: string) => w.toLowerCase().includes('cloud'))).toBe(true); + expect(parsed.data.warnings.some((w: string) => w.toLowerCase().includes('cloud'))).toBe(true); }); it('--json: hub role fetches and lists IR children', async () => { @@ -203,9 +203,9 @@ describe('devices explain', () => { const res = await runExplain('--json', DID); const parsed = JSON.parse(res.stdout[0]); - expect(parsed.children).toHaveLength(1); - expect(parsed.children[0].deviceId).toBe('IR-1'); - expect(parsed.children[0].type).toBe('TV'); + expect(parsed.data.children).toHaveLength(1); + expect(parsed.data.children[0].deviceId).toBe('IR-1'); + expect(parsed.data.children[0].type).toBe('TV'); }); it('human mode: prints device header and commands', async () => { diff --git a/tests/commands/history.test.ts b/tests/commands/history.test.ts index a4bf3741..741a8f98 100644 --- a/tests/commands/history.test.ts +++ b/tests/commands/history.test.ts @@ -93,8 +93,8 @@ describe('history command', () => { '--json', 'history', 'show', '--file', auditFile, ]); const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); - expect(out.total).toBe(1); - expect(out.entries[0].deviceId).toBe('A'); + expect(out.data.total).toBe(1); + expect(out.data.entries[0].deviceId).toBe('A'); }); }); diff --git a/tests/commands/mcp-http-health.test.ts b/tests/commands/mcp-http-health.test.ts new file mode 100644 index 00000000..a1feae55 --- /dev/null +++ b/tests/commands/mcp-http-health.test.ts @@ -0,0 +1,178 @@ +/** + * Tests for health/metrics endpoints in `mcp serve --port` mode. + * Verifies that /ready returns 503 + reason:'mqtt disabled' when MQTT is not configured, + * and that /metrics includes the switchbot_mqtt_state gauge. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { createServer, request as httpRequest } from 'node:http'; +import type { IncomingMessage, ServerResponse, Server } from 'node:http'; +import { EventSubscriptionManager } from '../../src/mcp/events-subscription.js'; + +vi.mock('../../src/api/client.js', () => ({ + createClient: vi.fn(() => ({ get: vi.fn(), post: vi.fn() })), + 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 }, + })), +})); + +// Build a minimal HTTP server that mirrors the serve logic for /ready and /metrics. +function makeHealthHandler(eventManager: EventSubscriptionManager) { + return (req: IncomingMessage, res: ServerResponse) => { + if (req.url === '/ready' && req.method === 'GET') { + const state = eventManager.getState(); + const ready = state !== 'failed' && state !== 'disabled'; + const status = ready ? 200 : 503; + const body: Record = { ready, version: '2.0.0', mqtt: state }; + if (!ready) body.reason = state === 'disabled' ? 'mqtt disabled' : 'mqtt failed'; + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); + return; + } + + if (req.url === '/metrics' && req.method === 'GET') { + const mqttState = eventManager.getState(); + const metrics = [ + `switchbot_mqtt_connected ${mqttState === 'connected' ? 1 : 0}`, + `switchbot_mqtt_state{state="disabled"} ${mqttState === 'disabled' ? 1 : 0}`, + `switchbot_mqtt_state{state="connected"} ${mqttState === 'connected' ? 1 : 0}`, + `switchbot_mqtt_state{state="failed"} ${mqttState === 'failed' ? 1 : 0}`, + `switchbot_mqtt_subscribers ${eventManager.getSubscriberCount()}`, + ].join('\n'); + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end(metrics); + return; + } + + res.writeHead(404); + res.end('not found'); + }; +} + +function startHealthServer( + eventManager: EventSubscriptionManager, +): Promise<{ port: number; stop: () => Promise }> { + return new Promise((resolve, reject) => { + const server = createServer(makeHealthHandler(eventManager)); + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as { port: number }; + resolve({ + port: addr.port, + stop: () => new Promise((res, rej) => server.close((err) => (err ? rej(err) : res()))), + }); + }); + server.on('error', reject); + }); +} + +function get(port: number, path: string): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const req = httpRequest({ hostname: '127.0.0.1', port, path, method: 'GET' }, (res) => { + let data = ''; + res.on('data', (c) => { data += c; }); + res.on('end', () => resolve({ status: res.statusCode ?? 0, body: data })); + }); + req.on('error', reject); + req.end(); + }); +} + +describe('mcp serve health endpoints', () => { + describe('/ready with MQTT disabled (no credentials)', () => { + let port: number; + let stop: () => Promise; + + beforeAll(async () => { + const eventManager = new EventSubscriptionManager(); + const srv = await startHealthServer(eventManager); + port = srv.port; + stop = srv.stop; + }); + + afterAll(async () => { await stop(); }); + + it('returns 503 when MQTT is disabled', async () => { + const res = await get(port, '/ready'); + expect(res.status).toBe(503); + }); + + it('body has ready:false, mqtt:"disabled", reason:"mqtt disabled"', async () => { + const res = await get(port, '/ready'); + const body = JSON.parse(res.body); + expect(body.ready).toBe(false); + expect(body.mqtt).toBe('disabled'); + expect(body.reason).toBe('mqtt disabled'); + }); + }); + + describe('/metrics with MQTT disabled', () => { + let port: number; + let stop: () => Promise; + + beforeAll(async () => { + const eventManager = new EventSubscriptionManager(); + const srv = await startHealthServer(eventManager); + port = srv.port; + stop = srv.stop; + }); + + afterAll(async () => { await stop(); }); + + it('returns 200', async () => { + const res = await get(port, '/metrics'); + expect(res.status).toBe(200); + }); + + it('emits switchbot_mqtt_state{state="disabled"} 1', async () => { + const res = await get(port, '/metrics'); + expect(res.body).toContain('switchbot_mqtt_state{state="disabled"} 1'); + }); + + it('emits switchbot_mqtt_state{state="connected"} 0 when disabled', async () => { + const res = await get(port, '/metrics'); + expect(res.body).toContain('switchbot_mqtt_state{state="connected"} 0'); + }); + + it('emits switchbot_mqtt_connected 0 when disabled', async () => { + const res = await get(port, '/metrics'); + expect(res.body).toContain('switchbot_mqtt_connected 0'); + }); + }); + + describe('EventSubscriptionManager default state', () => { + it('returns "disabled" with no mqtt client', () => { + const mgr = new EventSubscriptionManager(); + expect(mgr.getState()).toBe('disabled'); + }); + + it('getRecentEvents returns empty array when no events buffered', () => { + const mgr = new EventSubscriptionManager(); + expect(mgr.getRecentEvents()).toEqual([]); + }); + }); +}); diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index 1e8dca3a..80eef823 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -76,13 +76,14 @@ describe('mcp server', () => { cacheMock.updateCacheFromDeviceList.mockClear(); }); - it('exposes the seven tools with titles and input schemas', async () => { + it('exposes the eight tools with titles and input schemas', async () => { const { client } = await pair(); const { tools } = await client.listTools(); const names = tools.map((t) => t.name).sort(); expect(names).toEqual( [ + 'account_overview', 'describe_device', 'get_device_status', 'list_devices', diff --git a/tests/commands/plan.test.ts b/tests/commands/plan.test.ts index f9f68b4e..7b73c05c 100644 --- a/tests/commands/plan.test.ts +++ b/tests/commands/plan.test.ts @@ -123,7 +123,7 @@ describe('plan command', () => { describe('plan schema', () => { it('prints the JSON Schema', async () => { const res = await runCli(registerPlanCommand, ['plan', 'schema']); - const parsed = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + const parsed = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')).data; expect(parsed.$id).toMatch(/plan-1\.0/); expect(parsed.required).toContain('steps'); }); @@ -156,7 +156,7 @@ describe('plan command', () => { steps: [{ type: 'command', deviceId: 'A', command: 'turnOn' }], }); const res = await runCli(registerPlanCommand, ['--json', 'plan', 'validate', file]); - const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')).data; expect(out.valid).toBe(true); expect(out.steps).toBe(1); }); @@ -246,7 +246,7 @@ describe('plan command', () => { }); apiMock.__instance.post.mockResolvedValue({ data: { statusCode: 100, body: {} } }); const res = await runCli(registerPlanCommand, ['--json', 'plan', 'run', file]); - const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')).data; expect(out.ran).toBe(true); expect(out.summary).toEqual({ total: 1, ok: 1, error: 0, skipped: 0 }); }); diff --git a/tests/commands/quota.test.ts b/tests/commands/quota.test.ts index f17edd5f..13cdde03 100644 --- a/tests/commands/quota.test.ts +++ b/tests/commands/quota.test.ts @@ -48,10 +48,10 @@ describe('quota command', () => { const result = await runCli(registerQuotaCommand, ['--json', 'quota', 'status']); expect(result.exitCode).toBeNull(); const parsed = JSON.parse(result.stdout[0]); - expect(parsed.today.total).toBe(3); - expect(parsed.today.remaining).toBe(10_000 - 3); - expect(parsed.today.dailyLimit).toBe(10_000); - expect(parsed.today.endpoints['GET /v1.1/devices']).toBe(2); + expect(parsed.data.today.total).toBe(3); + expect(parsed.data.today.remaining).toBe(10_000 - 3); + expect(parsed.data.today.dailyLimit).toBe(10_000); + expect(parsed.data.today.endpoints['GET /v1.1/devices']).toBe(2); }); it('status says "no requests recorded yet" with an empty counter', async () => { @@ -74,6 +74,6 @@ describe('quota command', () => { await seedQuota(); const result = await runCli(registerQuotaCommand, ['--json', 'quota', 'reset']); expect(result.exitCode).toBeNull(); - expect(JSON.parse(result.stdout[0])).toEqual({ reset: true }); + expect(JSON.parse(result.stdout[0])).toEqual({ schemaVersion: '1.1', data: { reset: true } }); }); }); diff --git a/tests/commands/schema.test.ts b/tests/commands/schema.test.ts index c43a4e0f..8611454b 100644 --- a/tests/commands/schema.test.ts +++ b/tests/commands/schema.test.ts @@ -6,7 +6,9 @@ describe('schema export', () => { it('dumps every catalog type as a JSON payload', async () => { const res = await runCli(registerSchemaCommand, ['schema', 'export']); const out = res.stdout.join(''); - const parsed = JSON.parse(out); + const envelope = JSON.parse(out); + expect(envelope.schemaVersion).toBe('1.1'); + const parsed = envelope.data; expect(parsed.version).toBe('1.0'); expect(parsed.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); expect(Array.isArray(parsed.types)).toBe(true); @@ -22,20 +24,20 @@ describe('schema export', () => { it('filters by --type (matches name + aliases, case-insensitive)', async () => { const res = await runCli(registerSchemaCommand, ['schema', 'export', '--type', 'bot']); - const parsed = JSON.parse(res.stdout.join('')); + const parsed = JSON.parse(res.stdout.join('')).data; expect(parsed.types).toHaveLength(1); expect(parsed.types[0].type).toBe('Bot'); }); it('returns an empty types[] when --type does not match', async () => { const res = await runCli(registerSchemaCommand, ['schema', 'export', '--type', 'NoSuchType']); - const parsed = JSON.parse(res.stdout.join('')); + const parsed = JSON.parse(res.stdout.join('')).data; expect(parsed.types).toEqual([]); }); it('tags a known destructive command', async () => { const res = await runCli(registerSchemaCommand, ['schema', 'export']); - const parsed = JSON.parse(res.stdout.join('')); + const parsed = JSON.parse(res.stdout.join('')).data; const lock = parsed.types.find( (t: { type: string }) => t.type === 'Smart Lock' || t.type === 'Smart Lock Pro', ); @@ -46,7 +48,7 @@ describe('schema export', () => { it('--role filters to the matching functional group', async () => { const res = await runCli(registerSchemaCommand, ['schema', 'export', '--role', 'lighting']); - const parsed = JSON.parse(res.stdout.join('')); + const parsed = JSON.parse(res.stdout.join('')).data; expect(parsed.types.length).toBeGreaterThan(0); for (const t of parsed.types) { expect(t.role).toBe('lighting'); @@ -56,7 +58,7 @@ describe('schema export', () => { it('--role and --category can be combined', async () => { const res = await runCli(registerSchemaCommand, ['schema', 'export', '--role', 'security', '--category', 'physical']); - const parsed = JSON.parse(res.stdout.join('')); + const parsed = JSON.parse(res.stdout.join('')).data; expect(parsed.types.length).toBeGreaterThan(0); for (const t of parsed.types) { expect(t.role).toBe('security'); @@ -66,13 +68,13 @@ describe('schema export', () => { it('--role returns empty types[] for an unknown role', async () => { const res = await runCli(registerSchemaCommand, ['schema', 'export', '--role', 'nonexistent']); - const parsed = JSON.parse(res.stdout.join('')); + const parsed = JSON.parse(res.stdout.join('')).data; expect(parsed.types).toEqual([]); }); it('schema export includes description on every type', async () => { const res = await runCli(registerSchemaCommand, ['schema', 'export']); - const parsed = JSON.parse(res.stdout.join('')); + const parsed = JSON.parse(res.stdout.join('')).data; for (const t of parsed.types) { expect(t.description, `${t.type} missing description in export`).toBeTypeOf('string'); expect((t.description as string).length, `${t.type} description is empty`).toBeGreaterThan(0); diff --git a/tests/commands/watch.test.ts b/tests/commands/watch.test.ts index 30146417..5c00ff83 100644 --- a/tests/commands/watch.test.ts +++ b/tests/commands/watch.test.ts @@ -120,7 +120,7 @@ describe('devices watch', () => { expect(res.exitCode).toBeNull(); const lines = res.stdout.filter((l) => l.trim().startsWith('{')); expect(lines.length).toBe(1); - const ev = JSON.parse(lines[0]); + const ev = JSON.parse(lines[0]).data; expect(ev.deviceId).toBe('BOT1'); expect(ev.type).toBe('Bot'); expect(ev.tick).toBe(1); @@ -142,7 +142,7 @@ describe('devices watch', () => { const events = res.stdout .filter((l) => l.trim().startsWith('{')) - .map((l) => JSON.parse(l)); + .map((l) => JSON.parse(l).data); expect(events).toHaveLength(2); expect(events[0].tick).toBe(1); // Tick 2 should only include the power change — battery stayed 90. @@ -164,7 +164,7 @@ describe('devices watch', () => { const events = res.stdout .filter((l) => l.trim().startsWith('{')) - .map((l) => JSON.parse(l)); + .map((l) => JSON.parse(l).data); // Only tick 1 should have emitted (tick 2 had zero changes). expect(events).toHaveLength(1); expect(events[0].tick).toBe(1); @@ -183,7 +183,7 @@ describe('devices watch', () => { const events = res.stdout .filter((l) => l.trim().startsWith('{')) - .map((l) => JSON.parse(l)); + .map((l) => JSON.parse(l).data); expect(events).toHaveLength(2); expect(Object.keys(events[1].changed)).toHaveLength(0); }, 20_000); @@ -199,7 +199,7 @@ describe('devices watch', () => { ]); expect(res.exitCode).toBeNull(); - const ev = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{'))[0]); + const ev = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{'))[0]).data; expect(ev.changed.power).toBeDefined(); expect(ev.changed.battery).toBeDefined(); expect(ev.changed.temp).toBeUndefined(); @@ -223,7 +223,7 @@ describe('devices watch', () => { const events = [ ...res.stdout.filter((l) => l.trim().startsWith('{')), ...res.stderr.filter((l) => l.trim().startsWith('{')), - ].map((l) => JSON.parse(l)); + ].map((l) => JSON.parse(l).data); expect(events).toHaveLength(2); const byId = Object.fromEntries(events.map((e) => [e.deviceId, e])); expect(byId.BOT1.error).toMatch(/boom/); diff --git a/tests/lib/idempotency.test.ts b/tests/lib/idempotency.test.ts new file mode 100644 index 00000000..ae6bd856 --- /dev/null +++ b/tests/lib/idempotency.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { IdempotencyCache } from '../../src/lib/idempotency.js'; + +describe('IdempotencyCache', () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + it('executes fn and returns its result', async () => { + const cache = new IdempotencyCache(); + const result = await cache.run('k1', async () => 42); + expect(result).toBe(42); + }); + + it('returns cached result for same key within TTL', async () => { + const cache = new IdempotencyCache(60000); + const fn = vi.fn().mockResolvedValueOnce('first').mockResolvedValueOnce('second'); + const r1 = await cache.run('k', fn); + const r2 = await cache.run('k', fn); + expect(r1).toBe('first'); + expect(r2).toBe('first'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('re-executes fn after TTL expiry', async () => { + const cache = new IdempotencyCache(1000); + const fn = vi.fn().mockResolvedValue('value'); + await cache.run('k', fn); + vi.advanceTimersByTime(1001); + await cache.run('k', fn); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('always executes fn when key is undefined', async () => { + const cache = new IdempotencyCache(); + const fn = vi.fn().mockResolvedValue('x'); + await cache.run(undefined, fn); + await cache.run(undefined, fn); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('evicts oldest entry when capacity is exceeded', async () => { + const cache = new IdempotencyCache(60000, 3); + await cache.run('a', async () => 1); + await cache.run('b', async () => 2); + await cache.run('c', async () => 3); + expect(cache.size()).toBe(3); + // Adding a 4th entry should evict 'a' (oldest) + await cache.run('d', async () => 4); + expect(cache.size()).toBeLessThanOrEqual(3); + }); + + it('concurrent same-key calls do not deduplicate (cache misses run concurrently)', async () => { + // IdempotencyCache caches the *result*, not the in-flight promise. + // Two concurrent calls to the same uncached key will both execute fn. + const cache = new IdempotencyCache(60000); + let callCount = 0; + const fn = async () => { callCount++; return callCount; }; + const [r1, r2] = await Promise.all([ + cache.run('k', fn), + cache.run('k', fn), + ]); + // Both executed because neither was in cache when the other started. + expect(callCount).toBeGreaterThanOrEqual(1); + // The second call will find a cache hit if the first resolved first. + expect(typeof r1).toBe('number'); + expect(typeof r2).toBe('number'); + }); + + it('clear() resets the cache', async () => { + const cache = new IdempotencyCache(); + const fn = vi.fn().mockResolvedValue(1); + await cache.run('k', fn); + cache.clear(); + expect(cache.size()).toBe(0); + await cache.run('k', fn); + expect(fn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/lib/request-context.test.ts b/tests/lib/request-context.test.ts new file mode 100644 index 00000000..36dd4ed9 --- /dev/null +++ b/tests/lib/request-context.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { withRequestContext, getActiveProfile, requestContext } from '../../src/lib/request-context.js'; + +describe('request-context', () => { + it('returns undefined when no context is active and no CLI flag', () => { + // No --profile on process.argv in test runner + expect(getActiveProfile()).toBeUndefined(); + }); + + it('returns the profile from the active ALS context', async () => { + const result = await withRequestContext({ profile: 'alice' }, async () => { + return getActiveProfile(); + }); + expect(result).toBe('alice'); + }); + + it('isolates concurrent contexts (no cross-talk)', async () => { + const results = await Promise.all([ + withRequestContext({ profile: 'alice' }, async () => { + // Simulate async I/O between enter and read + await new Promise((r) => setTimeout(r, 5)); + return getActiveProfile(); + }), + withRequestContext({ profile: 'bob' }, async () => { + await new Promise((r) => setTimeout(r, 10)); + return getActiveProfile(); + }), + withRequestContext({ profile: 'carol' }, async () => { + return getActiveProfile(); + }), + ]); + expect(results).toEqual(['alice', 'bob', 'carol']); + }); + + it('nested contexts: inner wins inside, outer restored after', async () => { + await withRequestContext({ profile: 'outer' }, async () => { + expect(getActiveProfile()).toBe('outer'); + await withRequestContext({ profile: 'inner' }, async () => { + expect(getActiveProfile()).toBe('inner'); + }); + expect(getActiveProfile()).toBe('outer'); + }); + }); + + it('context with undefined profile falls back to CLI flag (none in tests)', async () => { + await withRequestContext({}, async () => { + expect(getActiveProfile()).toBeUndefined(); + }); + }); + + it('exports the underlying AsyncLocalStorage instance for advanced use', () => { + expect(requestContext).toBeDefined(); + expect(typeof requestContext.run).toBe('function'); + expect(typeof requestContext.getStore).toBe('function'); + }); +}); diff --git a/tests/logger.test.ts b/tests/logger.test.ts new file mode 100644 index 00000000..ecf9f6f6 --- /dev/null +++ b/tests/logger.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +describe('logger', () => { + afterEach(() => { + vi.resetModules(); + }); + + it('default log level is "warn" when LOG_LEVEL not set', async () => { + delete process.env.LOG_LEVEL; + vi.resetModules(); + const { getLogLevel } = await import('../src/logger.js'); + expect(getLogLevel()).toBe('warn'); + }); + + it('LOG_LEVEL=warn silences debug (isLevelEnabled returns false)', async () => { + process.env.LOG_LEVEL = 'warn'; + vi.resetModules(); + const { log } = await import('../src/logger.js'); + expect(log.isLevelEnabled('debug')).toBe(false); + }); + + it('LOG_LEVEL=debug enables debug (isLevelEnabled returns true)', async () => { + process.env.LOG_LEVEL = 'debug'; + vi.resetModules(); + const { log } = await import('../src/logger.js'); + expect(log.isLevelEnabled('debug')).toBe(true); + }); + + it('LOG_FORMAT=json produces a pino instance (no transport override)', async () => { + process.env.LOG_LEVEL = 'info'; + process.env.LOG_FORMAT = 'json'; + vi.resetModules(); + const { log } = await import('../src/logger.js'); + // pino instances have a level property and a child() method + expect(typeof log.level).toBe('string'); + expect(typeof log.child).toBe('function'); + }); + + it('setLogLevel changes the active log level', async () => { + process.env.LOG_LEVEL = 'warn'; + vi.resetModules(); + const { log, setLogLevel, getLogLevel } = await import('../src/logger.js'); + expect(getLogLevel()).toBe('warn'); + setLogLevel('error'); + expect(log.level).toBe('error'); + expect(getLogLevel()).toBe('error'); + }); +}); diff --git a/tests/utils/format.test.ts b/tests/utils/format.test.ts index d327bde9..41b947bd 100644 --- a/tests/utils/format.test.ts +++ b/tests/utils/format.test.ts @@ -129,10 +129,13 @@ describe('renderRows', () => { it('json: outputs a JSON array of objects', () => { renderRows(headers, rows, 'json'); const parsed = JSON.parse(logOutput.join('\n')); - expect(parsed).toEqual([ - { deviceId: 'DEV1', name: 'Light', type: 'Bot' }, - { deviceId: 'DEV2', name: 'Door', type: 'Smart Lock' }, - ]); + expect(parsed).toEqual({ + schemaVersion: '1.1', + data: [ + { deviceId: 'DEV1', name: 'Light', type: 'Bot' }, + { deviceId: 'DEV2', name: 'Door', type: 'Smart Lock' }, + ], + }); }); it('yaml: outputs YAML documents with --- separators', () => { diff --git a/tests/utils/output.test.ts b/tests/utils/output.test.ts index 55d60562..58a65a59 100644 --- a/tests/utils/output.test.ts +++ b/tests/utils/output.test.ts @@ -7,6 +7,7 @@ import { handleError, buildErrorPayload, UsageError, + SCHEMA_VERSION, } from '../../src/utils/output.js'; describe('isJsonMode', () => { @@ -35,21 +36,27 @@ describe('isJsonMode', () => { }); describe('printJson', () => { - it('writes pretty-printed JSON with 2-space indent', () => { + it('wraps payload in {schemaVersion, data} envelope with 2-space indent', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); printJson({ a: 1, b: [2, 3] }); expect(logSpy).toHaveBeenCalledTimes(1); const out = logSpy.mock.calls[0][0]; - expect(out).toBe(JSON.stringify({ a: 1, b: [2, 3] }, null, 2)); + expect(out).toBe(JSON.stringify({ schemaVersion: SCHEMA_VERSION, data: { a: 1, b: [2, 3] } }, null, 2)); expect(out).toContain('\n '); + expect(JSON.parse(out)).toEqual({ schemaVersion: '1.1', data: { a: 1, b: [2, 3] } }); }); - it('handles null and primitives', () => { + it('wraps null and primitive payloads inside data', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); printJson(null); printJson(42); printJson('hi'); - expect(logSpy.mock.calls.map((c) => c[0])).toEqual(['null', '42', '"hi"']); + const parsed = logSpy.mock.calls.map((c) => JSON.parse(String(c[0]))); + expect(parsed).toEqual([ + { schemaVersion: '1.1', data: null }, + { schemaVersion: '1.1', data: 42 }, + { schemaVersion: '1.1', data: 'hi' }, + ]); }); }); @@ -242,6 +249,7 @@ describe('handleError', () => { expect(() => handleError(new ApiError('bad device', 190))).toThrow('__exit'); const raw = errSpy.mock.calls[0][0]; const parsed = JSON.parse(raw); + expect(parsed.schemaVersion).toBe('1.1'); expect(parsed.error.code).toBe(190); expect(parsed.error.message).toBe('bad device'); expect(parsed.error.hint).toMatch(/devices/); @@ -303,7 +311,7 @@ describe('handleError', () => { describe('buildErrorPayload', () => { it('UsageError → code 2, kind usage', () => { const p = buildErrorPayload(new UsageError('bad flag')); - expect(p).toEqual({ code: 2, kind: 'usage', message: 'bad flag' }); + expect(p).toEqual({ code: 2, kind: 'usage', message: 'bad flag', errorClass: 'usage', transient: false }); }); it('generic Error → code 1, kind runtime', () => { @@ -313,6 +321,7 @@ describe('buildErrorPayload', () => { expect(p.message).toBe('oops'); expect(p.hint).toBeUndefined(); expect(p.retryable).toBeUndefined(); + expect(p.transient).toBe(false); }); it('unknown non-Error → code 1, kind runtime, fallback message', () => { @@ -320,21 +329,24 @@ describe('buildErrorPayload', () => { expect(p.code).toBe(1); expect(p.kind).toBe('runtime'); expect(p.message).toBe('An unknown error occurred'); + expect(p.transient).toBe(false); }); it('ApiError → code from error, kind api, hint from error', async () => { const { ApiError } = await import('../../src/api/client.js'); - const p = buildErrorPayload(new ApiError('quota', 429, { retryable: true, hint: 'try later' })); + const p = buildErrorPayload(new ApiError('quota', 429, { retryable: true, hint: 'try later', transient: true })); expect(p.code).toBe(429); expect(p.kind).toBe('api'); expect(p.message).toBe('quota'); expect(p.hint).toBe('try later'); expect(p.retryable).toBe(true); + expect(p.transient).toBe(true); }); it('ApiError with known code gets hint from errorHint table when no explicit hint', async () => { const { ApiError } = await import('../../src/api/client.js'); const p = buildErrorPayload(new ApiError('not found', 152)); expect(p.hint).toContain('deviceId'); + expect(p.transient).toBe(false); }); }); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 00000000..13b7f11f --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "sourceMap": false, + "declaration": false + }, + "exclude": ["node_modules", "dist", "tests", "**/*.test.ts"] +}