diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..25c67b5b --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# SwitchBot CLI — environment variables +# Copy to .env and fill in values as needed. + +# API credentials (alternative to `switchbot config set-token`) +# SWITCHBOT_TOKEN= +# SWITCHBOT_SECRET= + +# OAuth constant overrides (advanced — only needed if SwitchBot rotates these) +# SWITCHBOT_OAUTH_CLIENT_SECRET= +# SWITCHBOT_TOKEN_AES_KEY= +# SWITCHBOT_TOKEN_AES_IV= + +# Disable ANSI colors +# NO_COLOR=1 diff --git a/.gitignore b/.gitignore index fdc635d6..1d6ac643 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ npm-debug.log* # Environment & credentials .env .env.* +!.env.example .switchbot/ # Editor / OS diff --git a/CHANGELOG.md b/CHANGELOG.md index 675c0f1d..f3a8ac37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,20 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- `auth login` — browser-based OAuth 2.0 sign-in via `sp.oauth.switchbot.net`; stores credentials in OS keychain after verification +- `auth keychain set/get/delete/migrate/describe` — manage OS keychain credential backend +- `SWITCHBOT_OAUTH_CLIENT_SECRET`, `SWITCHBOT_TOKEN_AES_KEY`, `SWITCHBOT_TOKEN_AES_IV` env vars to override baked-in OAuth/AES constants +- **`devices expand` supports lighting commands**: `setBrightness` (`--brightness`), `setColor` (`--color`), and `setColorTemperature` (`--color-temp`) flags now expand for Color Bulb, Strip Light, Ceiling Light, and similar devices. + ### Fixed +- `reset`: aborting at the confirmation prompt no longer continues the reset action in test environments +- `reset`: credential deletion failures are now reported as `failed` instead of `not found` +- `reset --config `: no longer recursively deletes a sibling `cache/` directory next to the override file. The CLI never creates that path in `--config` mode, so removing it could wipe an unrelated project's data. +- `devices list --json --fields`: alias inputs (e.g. `id,name`) now resolve to canonical output keys (`deviceId`, `deviceName`), matching `--format json`. The JSON schema is now stable regardless of which input form callers used. +- `config`: credential-missing hint now preserves `--config ` in both the `auth login` and `config set-token` recovery commands. Without this, the suggested `auth login` would write to the default backend, which `loadConfig` ignores while `--config` is active. - **Daemon start failed in bundled builds** (BUG-001): CLI entry path resolution navigated above the dist/ directory when running from the single-file bundle. Now correctly detects the bundled scenario. - **`rules run` exited 0 when `automation.enabled` was false** (BUG-002): daemon interpreted this as success. Now exits 1 with a clear message. - **Unknown subcommands exited 0** (BUG-005/BUG-008): `cache list`, `history list`, and other invalid subcommand inputs triggered Commander help display and exited 0. Now exits 2 (usage error). @@ -24,10 +36,7 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **`devices commands --json`**: same change — `data` is always an array. - **`_fetchedAt` renamed to `fetchedAt`**: removed underscore prefix from the CLI-added timestamp field in `devices status` JSON output. - **`rules run --json` when `automation.enabled` is false**: previously emitted `{data: {kind:"control", controlKind:"disabled"}}` (success envelope) with exit 1. Now emits `{error: {code:1, kind:"runtime", message:"..."}}` (error envelope) — consistent with the JSON protocol. - -### Added - -- **`devices expand` supports lighting commands**: `setBrightness` (`--brightness`), `setColor` (`--color`), and `setColorTemperature` (`--color-temp`) flags now expand for Color Bulb, Strip Light, Ceiling Light, and similar devices. +- **MCP `list_devices` outputSchema**: `roomID` and `controlType` fields now accept `null` in addition to `string | undefined`. Consumers with strict JSON-Schema or Zod validation may need to update their parsers. ## [3.6.3] diff --git a/README.md b/README.md index 14e426fd..00bed48d 100644 --- a/README.md +++ b/README.md @@ -813,6 +813,9 @@ SwitchBot-specific error codes mapped to readable messages: - `SWITCHBOT_TOKEN`: API token — takes priority over the config file. - `SWITCHBOT_SECRET`: API secret — takes priority over the config file. +- `SWITCHBOT_OAUTH_CLIENT_SECRET`: Override the bundled OAuth client secret (advanced). +- `SWITCHBOT_TOKEN_AES_KEY`: Override the AES-128-CBC key used for token decryption (advanced). +- `SWITCHBOT_TOKEN_AES_IV`: Override the AES-128-CBC IV used for token decryption (advanced). - `NO_COLOR`: Disable ANSI colors in all output (automatically respected). ## Scripting examples diff --git a/contrib/systemd/switchbot-mcp.service b/contrib/systemd/switchbot-mcp.service deleted file mode 100644 index 5f88d365..00000000 --- a/contrib/systemd/switchbot-mcp.service +++ /dev/null @@ -1,26 +0,0 @@ -[Unit] -Description=SwitchBot MCP Server -After=network-online.target -Wants=network-online.target - -[Service] -Type=exec -User=switchbot -Group=switchbot -WorkingDirectory=/opt/switchbot -EnvironmentFile=-/etc/switchbot.env -ExecStart=/usr/bin/switchbot mcp serve --port 3030 --bind 127.0.0.1 --auth-token ${SWITCHBOT_MCP_TOKEN} -Restart=always -RestartSec=5 -StandardOutput=journal -StandardError=journal - -# Security hardening -NoNewPrivileges=true -PrivateTmp=true -ProtectSystem=strict -ProtectHome=yes -ReadWritePaths=/opt/switchbot - -[Install] -WantedBy=multi-user.target diff --git a/docs/design/phase3-install.md b/docs/design/phase3-install.md deleted file mode 100644 index bcae1a82..00000000 --- a/docs/design/phase3-install.md +++ /dev/null @@ -1,242 +0,0 @@ -# Phase 3 — one-command install design - -> Status: **in-CLI shipped (3B-lite) in v2.10.0**. Phase 3A landed -> in v2.8.x: `src/credentials/keychain.ts` abstraction with four -> backends, the `switchbot auth keychain` subcommand group, doctor + -> agent-bootstrap integration, and an in-repo `src/install/` library -> (preflight + rollback-aware step runner). v2.10.0 wraps that -> library as the built-in `switchbot install` / `switchbot uninstall` -> commands — the 7-step Quickstart collapses to a single command -> with rollback on failure. The external `openclaw plugins install` -> wrapper and the ClawHub registry entry remain Phase 3B proper and -> live outside this repo. - -## Implementation delta (what changed from this design) - -This document was written before `switchbot install` shipped. The body -below describes the original design intent (`openclaw plugins install` -surface). What actually landed in v2.10.0 differs in three ways: - -| Design doc says | What shipped | -| --- | --- | -| Entry point: `openclaw plugins install clawhub:switchbot` | Built-in: `switchbot install` (no ClawHub dependency) | -| Step 2: `npm i -g @switchbot/openapi-cli` | Skipped — CLI already in PATH is the precondition | -| Step 8: `switchbot doctor` failure → full rollback | `--verify` flag makes doctor a warn-only post-step; failure never triggers rollback | -| Uninstall: `openclaw plugins uninstall` | Built-in: `switchbot uninstall [--purge]` | - -Additional flags not in this design: `--force` (replace existing -symlink), `--verify` (opt-in post-install doctor check), `--purge` -(shorthand for `--yes --remove-creds --remove-policy`). - -## Goal - -Today, getting an AI agent to drive SwitchBot is a 15-minute manual -flow: install npm package, set token, create policy, install skill, -restart agent. Phase 3 collapses that to: - -```bash -openclaw plugins install clawhub:switchbot -``` - -On success, every check passes: `switchbot doctor` → all green, the -skill is discoverable from the user's agent of choice, and credentials -live in the OS keychain (not a `0600` JSON on disk). - -## Non-goals - -- Phase 3 does **not** ship the rule engine (that's Phase 4). -- Phase 3 does **not** rewrite the CLI. Everything it installs is the - same CLI users install with `npm i -g` today; the plugin just - automates the bootstrap. -- Phase 3 does **not** manage multiple SwitchBot accounts at install - time — first account only. A second account is a follow-up install - with `--profile `. - -## High-level flow - -```text -plugins install switchbot - │ - ▼ -1. Pre-flight checks (Node >= 18, npm on PATH, agent installed, conflict scan) - │ → abort with actionable error if any fails - ▼ -2. CLI install (`npm i -g @switchbot/openapi-cli`) - │ → rollback step: `npm rm -g @switchbot/openapi-cli` - ▼ -3. Credential capture (interactive prompt; tokens read into memory only) - │ → rollback step: delete keychain entry - ▼ -4. Keychain write (via Keychain abstraction — see below) - │ → rollback step: delete the entry - ▼ -5. Bridge CLI → keychain (CLI reads via the credential-store - │ abstraction; disk fallback remains available) - ▼ -6. Skill install (symlink skill repo into agent's skills dir) - │ → rollback step: remove the symlink - ▼ -7. Policy scaffold (`switchbot policy new` if file absent) - │ → rollback step: remove the file only if WE created it - ▼ -8. Doctor verification (`switchbot doctor --json` — must report 0 fail) - │ → on fail, run full rollback chain - ▼ -9. Summary + next steps (print the three things the user can say to - their agent to confirm it works) -``` - -Every step records an **undo action**. If any step after step 2 fails, -the installer walks the undo stack in reverse. Failure of an undo -step itself is logged loudly but does not halt the rollback — better -to leave a partial mess than a partial state the user can't reason -about. - -## Keychain abstraction - -Credentials today can still live in `~/.switchbot/config.json` with -`0600` permissions, but the shipped runtime now prefers the native OS -keychain and falls back to the file backend only when no writable -native store is available. - -Interface (implemented in `src/credentials/keychain.ts`): - -```typescript -interface CredentialStore { - name: 'keychain' | 'credman' | 'secret-service' | 'file'; - - get(profile: string): Promise<{ token: string; secret: string } | null>; - set(profile: string, creds: { token: string; secret: string }): Promise; - delete(profile: string): Promise; - - // Diagnostics — used by `switchbot doctor` to report which backend - // the current install is using without leaking the material. - describe(): { backend: string; writable: boolean; notes?: string }; -} -``` - -Backend selection at runtime: - -| OS | First choice | Fallback chain | -| --- | --- | --- | -| macOS | `Keychain` via `security(1)` | `file` (same 0600 json today) | -| Windows | `Credential Manager` via PowerShell + Win32 `CredReadW` / `CredWriteW` | `file` | -| Linux | `libsecret` via `secret-tool` | `file`, with a `doctor` warning | - -The fallback exists because Linux desktops without a running -keyring daemon (SSH sessions, headless) would otherwise fail the -install. The `file` backend keeps today's `0600` behavior. `doctor` -surfaces which backend is active so users aren't surprised. - -Key naming convention (service = `com.openclaw.switchbot`; account = -`:token` and `:secret`). Two entries per profile, -not one, so `security(1)` / `secret-tool` scripting doesn't require -JSON parsing. - -## Pre-flight checks (step 1) - -Every check produces either `ok`, `warn` (continue), or `fail` (abort). -Failures must print: - -- what failed -- how to fix it manually -- what state the system is in (nothing changed yet) - -Checks: - -| Check | Pass | Fail action | -| --- | --- | --- | -| `node --version` >= 18 | Continue | Abort, print Node install URL | -| `npm` on PATH | Continue | Abort, print PATH fix hint | -| No existing `switchbot` binary at a different version | Continue | Warn if <2.8.0, offer `--upgrade` | -| No existing `~/.config/switchbot/policy.yaml` OR the existing one validates | Continue | Warn; skip policy scaffold step | -| Target agent installed (Claude Code / Cursor / Copilot / ...) | Continue | Warn; install anyway, skip step 6 | -| Network to `npmjs.org` + `api.switch-bot.com` | Continue | Abort with diagnostics | - -## Credential capture (step 3) - -Interactive only. **Tokens MUST NOT** be passed as CLI args (shell -history, process listing). The prompt: - -```text -Paste your SwitchBot TOKEN (Profile → App Version x10 → Developer Options): -Paste your SwitchBot SECRET: -``` - -Input is captured with echo disabled on platforms that support it. On -a TTY-less install (CI-driven?), fail fast with exit code 3 and a hint -pointing at the `plugins install --token-file ` escape -hatch (which reads a two-line file and deletes it on success). - -## Skill install (step 6) - -The installer handles Claude Code natively (`~/.claude/skills/` symlink) -and delegates others to the recipes under -`companion-skill/docs/agents/*.md` — printing the relevant -one-liner rather than automating it. Rationale: Cursor / Copilot / -Gemini / Codex all have different edge cases around where -instructions files live, and automating all of them exceeds the -install-time budget. Printing the recipe gets the user 90% of the way -with zero surprise. - -If the user passed `--agent claude-code`, the automation path runs and -records an undo. Otherwise the step is informational. - -## Uninstall - -Parity with install: - -```bash -openclaw plugins uninstall -``` - -Walks the exact reverse of the install flow. Prompts before each -destructive step (delete keychain entry, remove policy, uninstall CLI) -and defaults the dangerous ones to "no": - -```text -Remove SwitchBot credentials from keychain? [y/N] -Remove policy.yaml at ~/.config/switchbot/policy.yaml? [y/N] -Uninstall @switchbot/openapi-cli globally? [y/N] -Remove skill link ~/.claude/skills/switchbot? [Y/n] -``` - -The symlink-removal default flips to yes because it's cheap to -recreate and is almost never what the user wants to preserve. - -## Testing strategy - -- **Unit**: keychain backends each get a pure-TS test matrix using a - mock native binding. Real keychain writes only run on CI labeled - `integration-keychain`. -- **Integration (per OS)**: one VM per target OS in CI runs the full - install → verify → uninstall cycle against a mock SwitchBot API. -- **Rollback**: every undo step gets a failure-injection test - (`force: ['step-3']` → install step 3 throws, installer must leave - steps 1+2 intact and step 4+ un-run). -- **Doctor parity**: a pre-install `doctor --json` vs post-uninstall - `doctor --json` must differ by exactly the install footprint, no - stray state left behind. - -## Open questions - -- Installer language: Node (matches CLI), Go (single binary, easier - distribution), or shell (zero deps, painful Windows story). Leaning - **Node** — reuses the CLI's HTTP client, npm install step becomes - trivial, and we can distribute as another npm package. -- `@switchbot/plugin-skill` vs `registry:switchbot` naming. Defer - until the external registry is live. -- How does the installer know which skill commit to link? Pin to the - version in the plugin's own `package.json` (dep on - `companion-skill@^0.2`)? Git-clone main? Deferred — the - choice affects reproducibility and update UX. - -## Dependencies on other Phase 3 tracks - -- The external plugin-manager install command (generic framework) -- An external registry entry for `switchbot` -- Native bindings for each keychain backend are explicitly out of - scope; the shipped implementation shells out to OS tooling instead - -None of these are in scope for this document; it only covers what the -SwitchBot side of the install needs to look like. diff --git a/docs/design/phase4-rules-schema.md b/docs/design/phase4-rules-schema.md deleted file mode 100644 index ba2800e5..00000000 --- a/docs/design/phase4-rules-schema.md +++ /dev/null @@ -1,155 +0,0 @@ -# Policy schema v0.2 — design notes - -> Status: **active (v0.2)**. The schema lives at -> `src/policy/schema/v0.2.json` and is wired into -> `switchbot policy validate`. New policies default to v0.1; run -> `switchbot policy migrate` to upgrade opt-in. This document is kept as -> the historical rationale for the shape. - -## Why draft now - -The Phase 4 rule engine needs a home in `policy.yaml`. v0.1 already -reserves an `automation` block with `enabled` and a loose `rules` array -of objects, but the item shape was left unspecified — anyone wiring up -a rule engine today would either have to invent a shape and hope it -aligns, or hard-code rules outside `policy.yaml`. Pinning the shape -early lets: - -- Phase 4 ship by migrating v0.1 → v0.2 via `switchbot policy migrate` - without introducing a competing file. -- Doc work on the rule DSL proceed against a concrete schema. -- Policy consumers (skills, tooling) rely on the shape the validator - will eventually enforce. - -## What changes from v0.1 - -- `version` constant flips to `"0.2"`. -- `automation.rules[]` gains a real item schema (`$defs/rule`) that - requires `name`, `when`, and `then`. -- `automation.rules` becomes nullable (parity with other top-level - blocks). -- Every other v0.1 block is **unchanged** and retains its existing - null-allowance and field types. The migration is additive. - -## Rule shape (summary) - -```yaml -automation: - enabled: true - rules: - - name: "hallway motion at night" - when: - source: mqtt - event: motion.detected - device: "hallway sensor" - conditions: - - time_between: ["22:00", "07:00"] - then: - - command: "devices command turnOn" - device: "hallway lamp" - throttle: - max_per: "10m" - dry_run: true -``` - -Fields: - -| Field | Required | Purpose | -|---|---|---| -| `name` | yes | Unique label; used in audit log and dry-run output | -| `enabled` | no (default `true`) | Disable a single rule without deleting it | -| `when` | yes | Trigger; one of three shapes (mqtt / cron / webhook) | -| `conditions` | no | AND-joined predicates; `time_between` or device-state compare | -| `then` | yes (`minItems: 1`) | Ordered list of actions | -| `throttle.max_per` | no | Min spacing between fires, e.g. `"10m"` | -| `dry_run` | no (default `true`) | Write audit entries but skip the API | - -### `when` (trigger) — `oneOf` - -1. **mqtt**: `{ source: mqtt, event: , device?: }` - — consumed from the `switchbot events mqtt-tail --json` stream. -2. **cron**: `{ source: cron, schedule: <5-field expression>, - days?: }` — local system timezone. `days` is an - optional list of weekday names (`mon`–`sun` or `monday`–`sunday`, - case-insensitive) added in v2.11.0. -3. **webhook**: `{ source: webhook, path: /foo }` — local HTTP path. - Transport/auth are Phase 3 concerns. - -### `conditions[]` — `oneOf` - -1. **time_between**: `[start, end]` (HH:MM). Overnight allowed (end < - start). -2. **device_state**: `{ device, field, op, value }` for comparing a - status field (e.g. `online == true`, `brightness > 50`). -3. **all**: `{ all: [condition, ...] }` — all sub-conditions must pass - (v2.11.0). -4. **any**: `{ any: [condition, ...] }` — at least one must pass - (v2.11.0). -5. **not**: `{ not: condition }` — inverts a single condition - (v2.11.0). - -Conditions 3–5 nest recursively via `$ref: "#/$defs/condition"` in the -JSON Schema. The top-level `conditions[]` array is AND-joined. - -### `then[]` — actions - -```json -{ "command": "devices command turnOn", "device": "hallway lamp", "args": {...}, "on_error": "continue" } -``` - -The engine renders `switchbot ` with `` substituted from -the resolved `device`, appends `--audit-log`, and expands `args` to -`--key value` flags. Safety tiers still gate: destructive actions in -`then[]` are rejected at policy validation time, not at run time. - -## What is deliberately out of scope for v0.2 - -- **Cross-rule composition** (one rule triggering another). Rules are - flat; if chaining is needed, model it as a cron or webhook trigger. -- **State machines / debounce** beyond `throttle`. If a sensor bounces, - `throttle` covers the common case; more sophisticated behavior stays - outside the schema. -- **Templating** (Jinja-like syntax in `args`). Opens attack surface; - revisit in v0.3 if real users demand it. -- **Profile-scoped rules**. Today all profiles share one policy file; - profile-aware policy paths are a separate enhancement tracked in - `docs/policy-reference.md`. - -## Migration plan (v0.1 → v0.2) - -`switchbot policy migrate` will: - -1. Read the current file + `version` field. -2. If `version == "0.1"`: rewrite `version: "0.2"` and no-op every - other block (all v0.1 shapes are strict subsets of v0.2). -3. If `automation.rules` exists but isn't empty, validate each rule - against the v0.2 rule schema **before** rewriting. If any rule - fails, abort the migration and print the line-accurate error. -4. If `version == "0.2"`: exit 0 with `status: already-current`. -5. If `version > "0.2"`: exit 6 with `unsupported-version` (the CLI - refuses to downgrade). - -Because v0.2 is purely additive, a v0.1 file with `automation.rules: -[]` or `automation: { enabled: false }` migrates without any user- -visible change except the version constant. - -## Validator wiring (as shipped) - -The steps below are recorded for historical context — all have been -completed: - -1. ~~Rename `v0.2.draft.json` → `v0.2.json`~~ — done; active schema - is at `src/policy/schema/v0.2.json`. -2. ~~Mirror to `examples/policy.schema.json` in the skill repo~~ — CI - already diffs these. -3. `src/policy/validate.ts` dispatches on `version` and picks `0.1` - or `0.2` schema. Active. -4. v0.2 test matrix at `tests/policy/validate-v0.2.test.ts`. Active. -5. CLI version bumped at Phase 4 ship. - -## References - -- `src/policy/schema/v0.1.json` — the v0.1 schema -- `src/policy/schema/v0.2.json` — the active v0.2 schema -- `docs/design/phase4-rules.md` — the runtime behavior side -- `docs/policy-reference.md` — user-facing field reference diff --git a/docs/design/phase4-rules.md b/docs/design/phase4-rules.md deleted file mode 100644 index f42c41d5..00000000 --- a/docs/design/phase4-rules.md +++ /dev/null @@ -1,294 +0,0 @@ -# Phase 4 — rule engine design - -> Status: **Shipped (v0.2, extended in v2.11.0)**. The engine is -> implemented in `src/rules/engine.ts` and wired to the CLI via -> `switchbot rules lint | list | run | reload | tail | replay`. All -> three triggers (MQTT / cron / webhook) + conditions (see below) + -> per-rule `throttle` + `dry_run` fire end-to-end. v2.11.0 added -> `days` weekday filter on cron triggers and `all`/`any`/`not` -> condition composition. Companion to -> `docs/design/phase4-rules-schema.md`, which specifies the -> `automation.rules[]` shape in `policy.yaml`. - -## Goal - -Let users express automations declaratively in `policy.yaml`: - -```yaml -automation: - enabled: true - rules: - - name: "hallway motion at night" - when: { source: mqtt, event: motion.detected, device: "hallway sensor" } - conditions: - - time_between: ["22:00", "07:00"] - then: - - { command: "devices command turnOn", device: "hallway lamp" } - throttle: { max_per: "10m" } -``` - -…and have the engine execute them without the user writing a shell -pipeline, without a separate daemon, and without losing the safety -rails (`audit-log`, `--dry-run`, tier gates) the CLI already has. - -## Non-goals - -- **Cross-device state machines**. If a rule needs "armed → triggered → - disarmed" transitions, model each transition as a separate rule. If - that's not enough, use a real automation platform (Home Assistant, - Node-RED) and let it call the CLI. -- **UI for editing rules**. Rules live in `policy.yaml`. Editors use - VS Code + the JSON Schema mirror for autocomplete. -- **Templating inside commands**. The v0.2 schema deliberately has no - `{{ vars }}` syntax in `args`. Attack surface is too big. Revisit - in v0.3 only if concrete demand appears. - -## Architecture - -``` - ┌────────────────────────────────────┐ - │ switchbot rules run │ - │ (one foreground process) │ - └──────────────┬─────────────────────┘ - │ - ┌────────────┬───────────────┼─────────────┐ - │ │ │ │ - ▼ ▼ ▼ ▼ -MQTT source Cron scheduler HTTP listener Signal handler -(events mqtt-tail) (node-cron or equivalent) (webhook path) (SIGHUP = reload) - │ │ │ │ - └──────────┬─┴───────────────┴─────────────┘ - ▼ - ┌─────────────────────┐ - │ rule matcher │ — does any rule's `when` match this event? - └────────┬────────────┘ - ▼ - ┌─────────────────────┐ - │ condition evaluator │ — do all `conditions` pass? - └────────┬────────────┘ - ▼ - ┌─────────────────────┐ - │ throttle gate │ — is the rule's throttle window clear? - └────────┬────────────┘ - ▼ - ┌─────────────────────┐ - │ action executor │ — render `switchbot ` per action - └────────┬────────────┘ - ▼ - audit log (kind=rule-fire) + stderr summary -``` - -Single foreground process. No daemon, no IPC, no database. State the -engine needs (throttle timers, last-fire times, dedup window) lives in -memory. Restart = state reset — documented behavior. - -## Triggers - -### `source: mqtt` - -The engine opens its own MQTT connection (same broker the CLI uses -today) rather than piping from `events mqtt-tail`. Rationale: - -- Shared credential + reconnect logic with the rest of the CLI -- No subprocess management; one less failure mode -- `events mqtt-tail` continues to exist for interactive use; the rule - engine is a peer consumer, not a downstream consumer - -Event match is exact string on the `event` field (`motion.detected`, -`contact.opened`, etc.) and, if `device` is set, the resolved deviceId -or alias must match the event's `deviceId`. - -### `source: cron` - -Standard 5-field cron, evaluated in the local system timezone. Uses -`node-cron` or equivalent; no DST cleverness (cron inherits the usual -"run twice on fall-back, skipped on spring-forward" behavior — we -don't silently paper over this). - -Optional `days` filter (v2.11.0): a list of weekday names -(`mon`–`sun` or `monday`–`sunday`, case-insensitive) applied *after* -the cron fires. Firings on unlisted weekdays are suppressed before -dispatch — throttle counters and audit entries are not written for -suppressed firings. - -### `source: webhook` - -The engine binds an HTTP listener on localhost (port from CLI config, -default 18790 to avoid conflict with a local agent gateway on 18789). -Authentication is a static bearer token generated at first run and -stored alongside credentials. External callers (IFTTT, HA, whatever) -POST JSON to the configured `path`; the body becomes the trigger -payload available to `conditions`. - -## Conditions - -Evaluated and AND-joined at the top level; all failures are collected -and surfaced together (not short-circuited on the first). Four shapes: - -- **`time_between: [start, end]`** — HH:MM, local system time. - Overnight crossing supported. -- **`{ device, field, op, value }`** — reads `switchbot devices status - --json` (cached per-tick; see performance below) and - applies the comparison. Operators: `==`, `!=`, `<`, `>`, `<=`, `>=`. -- **`all: [condition, ...]`** *(v2.11.0)* — all sub-conditions must - pass (logical AND over a sub-list). -- **`any: [condition, ...]`** *(v2.11.0)* — at least one sub-condition - must pass (logical OR). -- **`not: condition`** *(v2.11.0)* — inverts a single condition. - -Composites nest arbitrarily. The top-level `conditions[]` array remains -AND-joined across its entries, so `conditions: [A, any: [B, C]]` -means `A AND (B OR C)`. - -A future v0.3 might add more leaf shapes (`and`/`or` at the leaf level -were folded into the composite nodes above). - -## Actions - -Each `then[]` entry is one of two types: - -**`type: command`** (default) — renders to: - -``` -switchbot substituted> --audit-log -``` - -**`type: notify`** — delivers a payload to an external channel: - -```yaml -- type: notify - channel: webhook # webhook | file | openclaw - to: https://your.host/hook - template: '{"rule":"{{ rule.name }}","fired":"{{ rule.fired_at }}"}' -``` - -Channels: `webhook` (HTTP POST), `file` (append JSONL), `openclaw` (HTTP POST). Template supports `{{ rule.name }}`, `{{ event.* }}`, `{{ device.id }}` placeholders. Audit gains `rule-notify` kind for every notify dispatch. - -Rules: - -1. **Safety tier gates still apply.** If the rendered command is - tier `destructive`, the engine refuses to run it unless - `confirmations.never_confirm` explicitly allows it — and even - then, destructive actions in `never_confirm` are blocked by the - policy validator (see policy-reference.md). Effectively, no - destructive automations ship in v0.2. -2. **IR "fire and forget"** actions run, but the audit entry records - `verified: false` because no post-action status check is possible. -3. **`on_error: continue`** (default) runs the remaining `then[]` - entries after a failure. `on_error: stop` halts the rule after the - first failing action and records subsequent actions as `skipped`. - -## Throttling - -Per-rule, keyed by `(rule.name, triggerDeviceId or '')`. When a rule -fires, a timer starts; subsequent matches within `max_per` are -suppressed. Suppressed events are audit-logged with -`kind: rule-throttled` so users can see what got dropped. - -## `dry_run: true` - -When set, the engine: - -1. Evaluates trigger + conditions normally. -2. Renders the action command. -3. Writes `kind: rule-fire-dry` to the audit log with the rendered - command and the reason it would have fired. -4. Does **not** hit the SwitchBot API. - -Used for validating a rule in production without side effects. The -CLI grows a `switchbot rules lint` command that performs a static -check (policy valid + all aliases resolve + no destructive actions), -but dry-run is the live complement. - -## Audit replay - -```bash -switchbot rules replay --since 24h --json -``` - -Reads `audit.log`, filters for `kind: rule-fire` and `kind: -rule-throttled`, and emits a summary per rule (fire count, throttle -count, first/last times, success rate). Read-only, no side effects, -fast. - -## Hot reload - -`SIGHUP` to the running `switchbot rules run` process: - -1. Re-reads `policy.yaml` + re-validates. -2. If valid, swaps the rule set atomically. -3. If invalid, prints the error and keeps the old rules live. - -No restart required for common edits. `SIGTERM` triggers a graceful -shutdown (drain pending actions, close MQTT, exit 0). - -## Performance and resource budget - -- Cold start to first fire: < 5s on a 10-rule policy. -- Per-event latency (MQTT arrival → action executed): < 500ms p95. -- Memory ceiling: < 100 MB resident, regardless of event rate. -- CPU: idle < 1%, p95 < 5% during burst. -- Device-state reads (for `{device,field,op,value}` conditions) go - through the cache with a 5s coalescing window — two rules needing - the same device's state in the same tick share one API call. - -These are targets, not hard gates. A single failing run on a slow -Pi 3 shouldn't block the release — but if the median run fails them, -we've mis-designed. - -## Observability - -- Every rule fire, throttle, or failure appends a structured line to - `audit.log`. Schema is the existing audit envelope + a new `rule` - block with `{name, triggerSource, matchedDevice, fire_id}`. -- `switchbot rules list` — static view of loaded rules + their last - fire time from audit log. -- `switchbot rules tail` — stream-mode view of firings, like `tail -f` - but parsed. - -No Prometheus, no OpenTelemetry in v0.2. Users who want metrics scrape -audit.log with `jq` or ship it to their existing stack. - -## Security considerations - -- Webhook listener binds `127.0.0.1` only; no exposed ports without - explicit CLI config. -- Bearer token for webhook is rotated with `switchbot rules webhook- - rotate-token`. Stored in keychain (Phase 3 dependency). -- Rule files are user-readable `policy.yaml`; no privilege escalation - risk. -- No arbitrary shell execution — the `command` field is parsed, not - `eval`'d. Only `switchbot ...` shapes are allowed. - -## Testing strategy - -- **Unit**: trigger matchers, condition evaluators, throttle gate, - action renderer — each in isolation with mocked inputs. -- **Integration**: full engine spun up against a mock MQTT broker and - mock SwitchBot API. Rule firings asserted by audit-log tail. -- **Fuzz**: random valid rule sets + random event streams → no - crashes, no memory growth, audit log lines always parse. -- **Dry-run**: for every integration case, also run with - `dry_run: true` and assert the API mock saw zero mutating calls. - -## Open questions - -- Where does `switchbot rules run` live on disk? As a subcommand of - the CLI (simplest, one binary) or a sibling package - `@switchbot/rules-engine`? Leaning **subcommand** — it shares the - HTTP client, audit log writer, and cache with the rest of the CLI. -- How do we signal rule-engine health to `switchbot doctor`? Add a - `rules: ok|fail|disabled` row when Phase 4 ships. -- Should `dry_run: true` still write to the audit log under the same - retention as real fires, or go to a side file? Current design says - same file, tagged — simpler, and the user already tails that file. - -## Dependencies on other work - -- **Phase 3 install flow** — keychain for webhook bearer token, plugin - surface for exposing `switchbot rules run` as a service. -- **Policy schema v0.2** — specified in `phase4-rules-schema.md`; - must be validator-active before the engine ships. -- **CLI MQTT client generalization** — currently wired for `events - mqtt-tail`. Need a shared connector so the engine and the CLI - surface can coexist cleanly. diff --git a/docs/design/roadmap.md b/docs/design/roadmap.md deleted file mode 100644 index e5d54e94..00000000 --- a/docs/design/roadmap.md +++ /dev/null @@ -1,287 +0,0 @@ -# Roadmap — Phase 1 through Phase 4 - -> **Status as of 2026-05-15:** Phase 1 complete, Phase 2 complete, -> Phase 3A complete (keychain + install library + built-in CLI install -> command), Phase 3B tracked in the separate companion skill repo, -> Phase 4 shipped at v0.2 (rules engine with MQTT + cron + -> webhook triggers, condition composition, weekday filter). -> Tracks β / γ / δ / ε / ζ shipped between v2.10.0 and v2.13.0; -> v2.14.0 extends MCP with `plan_run`, `audit_query`, `audit_stats`, -> and `policy_diff`; v2.15.0 flips `policy new` default schema to v0.2 -> and starts the v0.1 deprecation window. -> Tracks θ (notify actions) and η (LLM-backed rule suggestion) -> shipped in v3.0. Track κ (AI decision loop — `rules trace`, -> `rules trace-explain`, `llm` conditions, `rules simulate`) -> shipped in v3.6.x. Track μ (catalog sync — Weather Station, Lock -> Vision, Lock Vision Pro, Smart Lock Pro Wifi alias, AI Art Frame -> `uploadImage`) and Track λ (USD/token budget, cross-event -> aggregation, local LLM providers, JSON-RPC IPC) are queued for -> the next release. -> Note: Track γ is a runtime capability increment on the v0.2 rule -> model, not a separate policy schema version. - -This file is the **single source of truth** for phase numbering across -the two repos in this project: - -| Repo | What it delivers | Uses phases? | -|----------------------------------------|-------------------------------------------|-------------------------------------------| -| `switchbot-openapi-cli` (this repo) | CLI binary, MCP server, rules engine | **Yes** — Phase 1/2/3/4 are defined here | -| companion skill repo (sibling) | Conversational skill packaging of the CLI | **No** — uses orthogonal `autonomyLevel` | - -The skill repo does **not** re-number phases. It declares -`tracksCliPhase: ">=4"` and an autonomy dimension -(`autonomyLevel: L1 | L2 | L3`). The phase table below is what it -points back to. - -## Completion matrix (scope clarity) - -| Capability | This repo (`switchbot-openapi-cli`) | Cross-repo (`+ companion skill repo`) | Notes | -| --- | --- | --- | --- | -| Phase 1 (manual orchestration) | Shipped | Shipped | Stable in v2.7.x | -| Phase 2 (policy tooling) | Shipped | Shipped | v0.2 policy schema (v0.1 removed in v3.0) | -| Phase 3A (keychain + install CLI) | Shipped | Shipped | `switchbot install` / `switchbot uninstall` | -| Phase 3B (skill packaging + external registry) | External tracking only | In progress outside this repo | Owned by companion skill repo | -| Phase 4 (rules engine, v0.2 model) | Shipped | Shipped | MQTT/cron/webhook + `days` + `all`/`any`/`not` | -| Track β / γ / δ / ε | Shipped | Shipped (β partially external for registry publish) | γ is a v0.2 capability increment | - ---- - -## The four phases (delivery dimension) - -Each phase is a **shipped capability**, not a time box. The CLI binary -at the phase's tag is usable end-to-end on its own — there is no phase -that requires a later phase to be useful. - -### Phase 1 — Manual orchestration foundation *(shipped, v2.7.x)* - -**What it is:** the stable CLI that an operator (or agent) can drive -command by command. Read device state, send commands, watch events, -keep an audit trail. Everything an agent needs to *execute* — nothing -that *decides*. - -Surfaces that landed in Phase 1: - -- `devices list | status | command | batch | watch` -- `events tail | mqtt-tail` (cloud-issued MQTT, no extra broker) -- `scenes list | run` -- `webhook setup | query | delete` -- `plan run | validate` (JSON batch executor with dry-run preview) -- `history show | replay`, `audit.log` JSONL writer -- `catalog show | diff`, `schema export`, `capabilities --json` -- `doctor` smoke test -- `mcp serve` (stdio + Streamable HTTP) for AI agents -- `agent-bootstrap --compact` cold-start snapshot -- Global flags: `--json`, `--format`, `--dry-run`, `--verbose`, - `--audit-log`, `--profile` - -Phase 1 is the **manual-orchestration experience in full**. See -`docs/phase-1-manual-orchestration.md` for why this is not a -half-shipped state — it is the whole contract for L1 (manual-agent) -use and the foundation every later phase composes on top of. - -### Phase 2 — Policy tooling *(shipped, v2.8.0)* - -**What it is:** the one file an operator edits to express preferences -without touching code or CLI flags. The CLI reads it, the rules engine -reads it, the MCP server reads it, and `doctor` reports on it. - -Surfaces: - -- `policy new | validate | migrate | diff` (v0.2 schema; v0.1 removed in v3.0) -- Default `policy.yaml` discovery rules -- Aliases (human-readable device names) -- Quiet hours (local-time windows, midnight-crossing supported) -- Confirmation tiers (destructive / mutation / read) -- Audit log path + retention hint -- `policyStatus` in `agent-bootstrap` output + MCP tool -- Destructive-command guard (rejects dangerous commands in rules) - -### Phase 3 — One-command install + secure credential storage - -Phase 3 is **split in two**, with 3A shipped in this repo and 3B -published as a separate skill repo. - -**Phase 3A — Keychain + install CLI *(shipped, v2.8.x → v2.10.0)*:** - -- `src/credentials/keychain.ts` abstraction with four backends: macOS - `security(1)`, Windows PowerShell + Win32 `CredRead`/`CredWrite`, - Linux `secret-tool` (libsecret), and a `0600` file fallback -- `switchbot auth keychain describe | get | set | delete | migrate` -- `doctor` + `agent-bootstrap` report the active credential source -- `src/install/` preflight + rollback-aware step runner (library) -- `switchbot install` / `switchbot uninstall` built-in CLI commands - (v2.10.0): one-command Quickstart → doctor → all-green; rollback on - any step failure. `--agent claude-code` auto-symlinks the skill; - other agents print a recipe. `--purge` for one-flag full teardown. - -**Phase 3B — Skill packaging + external registry:** - -- Tracked in the sibling companion skill repo -- `SKILL.md` + `manifest.json` + skill-side examples -- Publishing to Claude Desktop / other agent surfaces + external registries - -### Phase 4 — Rules engine v0.2 *(shipped, v2.8.x → v2.11.0)* - -**What it is:** the declarative leap. Rules live in the same -`policy.yaml`, and the engine executes them without a separate daemon. - -Surfaces (v2.9.0 baseline + v2.11.0 additions): - -- `switchbot rules lint | list | run | reload | tail | replay` -- Triggers: `mqtt` (shadow events), `cron` (local time, optional - `days` weekday filter), `webhook` (bearer-token HTTP ingest) -- Conditions: `time_between` (quiet-hours-aware), `device_state` - (per-tick cache), `all` / `any` / `not` logical composition -- Per-rule `throttle` (`max_per: "10m"` style) -- Per-rule `dry_run` (plan without firing) -- Hot reload: `SIGHUP` on Unix, pid-file sentinel on Windows -- Audit log v2: `rule-fire`, `rule-fire-dry`, `rule-throttled`, - `rule-webhook-rejected` records - -Phase 4 is **opt-in**. Existing Phase 1/2 users who never enable -`automation:` in their policy pay zero cost for it being present. - ---- - -## Autonomy dimension (skill side) - -The skill repo uses an orthogonal label — `autonomyLevel` — so that -skill releases do not need to wait on CLI phase boundaries. - -- **L1** - Meaning: manual orchestration, one command at a time. - Skill behavior: turns natural language into CLI calls; user confirms each mutation. - Required CLI phase: Phase 1 or later. -- **L2** - Meaning: semi-autonomous, propose-then-approve. - Skill behavior: composes multi-step plans; `--require-approval` gates each step. - Required CLI phase: Phase 2 or later. -- **L3** - Meaning: fully autonomous inside the policy envelope. - Skill behavior: writes a rule and lets the engine execute without further prompts. - Required CLI phase: Phase 4 or later. - -The mapping from `autonomyLevel` to `tracksCliPhase` is declared in -the skill's `manifest.json` `roadmap` block, which points back here. - ---- - -## Completed tracks (shipped post-v2.9.0) - -- **Track β — one-command install surface *(shipped, v2.10.0)*.** - Top-level `switchbot install` / `switchbot uninstall` wrapping the - Phase 3A library. CLI assumed already in PATH; doctor runs as - warn-only post-step. Phase 3B (registry entry) still external. -- **Track γ — rules v0.2 capability increment *(shipped, v2.11.0)*.** - `days` weekday filter on cron triggers; `all` / `any` / `not` - condition composition. Per-trigger debounce and profile-scoped rules - remain deferred. -- **Track δ — semi-autonomous workflow L2 *(shipped, v2.12.0)*.** - `plan suggest --intent --device ...` scaffolds a Plan - JSON from natural language. `plan run --require-approval` gates each - destructive step with a TTY prompt. MCP tools `plan_suggest` + - `plan_run` are available; review support includes MCP `audit_query` + - `audit_stats` and `policy_diff`. -- **Track ζ — fully autonomous rule authoring L3 *(shipped, v2.13.0)*.** - `rules suggest` + `policy add-rule` let agents author a rule from - intent and inject it into `automation.rules[]`; MCP tools - `rules_suggest` + `policy_add_rule` provide the same flow. -- **Track ε — cross-OS CI matrix for keychain *(shipped, v2.11.0)*.** - GitHub Actions matrix: macOS (temp keychain), Linux (D-Bus + - gnome-keyring), Windows (native Credential Manager). -- **Track θ — notify actions *(shipped, v3.0)*.** - New `type: notify` action alongside `type: command` in the v0.2 - schema. Rules can POST to webhooks, append JSONL to a file, or push - to OpenClaw after firing, closing the feedback loop for AI agents. - `lintRules` validates URL syntax and required fields; engine dispatches - to `executeNotifyAction`; audit gains `rule-notify` kind; MCP gains - `rule_notifications` tool; `doctor` gains `notify-connectivity` check. -- **Track η — LLM-backed rule suggestion *(shipped, v3.0)*.** - `rules suggest --llm ` routes complex intents (complexity - score ≥ 4) to OpenAI or Anthropic, falls back to heuristic with a - warning on provider failure. `rules_suggest` MCP tool gains a `llm` - parameter. All LLM calls are written to the audit log as - `kind: llm-suggest` with backend, model, and latency fields. -- **Track κ — AI decision loop *(shipped, v3.6.x)*.** - `rules trace` records every condition evaluation; `rules - trace-explain` renders why a tick fired or was blocked. - `conditions: [- llm: { prompt, provider, ... }]` lets a rule call - an LLM as a condition (per-condition + global call budget, - cache, on_error fail/pass/skip). `rules simulate` replays a rule - against `~/.switchbot/device-history` for offline what-if. - Audit gains `llm-condition`, `llm-cache-hit`, - `llm-budget-exceeded` records. - -## In-flight (next release) - -- **Track μ — catalog sync.** - Adds Weather Station (read-only sensor), Lock Vision and Lock Vision - Pro (video locks with `lock` / `unlock` / `deadbolt` for the Pro), - the `Smart Lock Pro Wifi` alias for Matter-enabled Lock Pro, and - `uploadImage` on AI Art Frame. Pure data + tests; no schema bump. -- **Track λ.1 — USD/token budget for `llm` conditions.** - `DecideResult.usage = { tokensIn, tokensOut, costUsd? }`; per-rule - `budget.max_tokens_per_hour` / `max_cost_per_day_usd` and global - `automation.llm_budget.{max_tokens_per_hour, max_cost_per_day_usd}`. - Audit `llm-budget-exceeded` carries `dimension: "calls" | "tokens" | "cost"`. - Pricing table at `src/llm/pricing.ts` (override via the policy - `automation.llm_pricing_overrides` field). -- **Track λ.2 — cross-event aggregation.** - Non-LLM `event_count: { device, event?, window, min, max? }` - condition counts firings inside a rolling time window. Same - `EventWindowFetcher` populates the LLM `recent_events` hook so - prompts get the last N events of the trigger device for free. - Backed by `~/.switchbot/device-history/.jsonl`. -- **Track λ.3 — local / non-tool-use LLM providers.** - `LLMProvider.capabilities.toolUse` flag gates a structured-output - fallback (JSON instruction + lenient parser + one repair retry) - for endpoints that don't support tool use. New `provider: local` - in policy points at any OpenAI-compatible `/v1/chat/completions` - (Ollama, llama.cpp, vLLM, LM Studio) via - `SWITCHBOT_LOCAL_LLM_URL`. `doctor` adds `local-llm-reachable`. -- **Track λ.4 — daemon JSON-RPC 2.0 IPC.** - `rules run` now exposes `daemon.status`, `daemon.ping`, - `daemon.reload` over a Unix domain socket - (`~/.switchbot/daemon.sock`, mode 0600) on POSIX or a per-user - named pipe (`\\.\pipe\switchbot-daemon-`) on Windows. v1 - surface; future `mcp serve --via-daemon` will proxy MCP tool calls - through the same transport. `doctor` adds `daemon-ipc`. - -## Next execution queue (ordered) - -1. **`mcp serve --via-daemon` proxy.** - Route MCP tool calls through the JSON-RPC IPC so repeated agent - invocations skip the cold-start cost. - Exit when: `mcp serve --via-daemon list_devices` round-trips and - `doctor` confirms daemon health. -2. **Standalone MCP package (`npx @switchbot/mcp-server`).** - Split MCP serve entrypoint into a tiny publishable package while - preserving tool contract parity with the main CLI. - Exit when: `npx @switchbot/mcp-server` boots and passes the same MCP - contract tests as `switchbot mcp serve`. -3. **`switchbot self-test` command.** - Add scripted go/no-go checks for credentials + one representative device. - Exit when: CI can run a deterministic self-test job with pass/fail JSON. -4. **Record/replay fixtures for deterministic integration tests.** - Capture request/response transcripts and replay offline in CI. - Exit when: at least one full scenario (list → status → command guard) - is replayable without live API calls. - ---- - -## Versioning rules this repo follows - -- **CLI semver:** Phase milestones map to minor bumps (Phase 2 → - v2.8.0; Phase 3A + Phase 4 landing together → v2.9.0). No phase - bump forces a major bump on its own. -- **Policy schema:** `0.1 → 0.2` is a minor. A major schema bump - happens only if the top-level shape breaks (no planned v1.x yet). -- **Rules track labels vs schema versions:** Track names (for example - γ) describe runtime increments and do not imply a policy schema bump; - current schema line remains `0.1 | 0.2`. -- **Skill manifest:** the skill repo owns its own semver track, - independent of CLI version. `authority.cli` in - `manifest.json` narrows the compatible CLI range per skill release. -- **`CATALOG_SCHEMA_VERSION === AGENT_BOOTSTRAP_SCHEMA_VERSION`** is - a hard sentinel — a mismatch fails `doctor`'s `catalog-schema` - check. Agents SHOULD poll that check each session. diff --git a/docs/phase-1-manual-orchestration.md b/docs/phase-1-manual-orchestration.md deleted file mode 100644 index a99c54a4..00000000 --- a/docs/phase-1-manual-orchestration.md +++ /dev/null @@ -1,149 +0,0 @@ -# Phase 1 is not half-shipped — it is the whole manual-orchestration contract - -Before Phase 4 (the rules engine) landed, it was easy to read the -roadmap and conclude Phase 1 was "the part before the good stuff." -This document pushes back on that framing. **Phase 1 is complete on -its own terms.** It is the manual-orchestration experience, sized and -shaped around one specific use case: a human or an L1 agent that -issues one command at a time and watches what happens. - -If you never enable `automation:` in your policy, you are a Phase 1 -user. That is a supported configuration, not a transitional state. - ---- - -## What Phase 1 delivers end-to-end - -Every capability below exists in the shipped CLI today. None of them -depends on Phase 2/3/4 being present or enabled. - -### Read the home state - -```bash -switchbot devices list --json -switchbot devices status "hallway lamp" --json -switchbot scenes list --json -``` - -`devices list` hits the SwitchBot Cloud API once and caches the -catalog; `devices status` reads either the API or the locally -updated `status.json` cache populated by `events mqtt-tail`. Either -path returns the same JSON envelope. - -### Send a command and verify it - -```bash -switchbot devices command "hallway lamp" turnOn --dry-run -switchbot devices command "hallway lamp" turnOn --audit-log -switchbot history show --since 5m --json | jq '.data[-1]' -``` - -Dry-run prints the exact HTTP body that would have been sent, writes -no audit entry, burns no quota. The real fire appends one JSONL line -to `~/.switchbot/audit.log`. `history show` reads the log back. - -### Watch the home in real time - -```bash -switchbot events mqtt-tail --json --max 3 # sanity check -switchbot devices watch AA-BB-CC-DD-EE-FF --via-mqtt --json -``` - -`mqtt-tail` subscribes to the cloud-issued MQTT broker (credentials -fetched automatically, cached to `~/.switchbot/mqtt-credential.json`, -refreshed 10 minutes before expiry). Shadow events stream as JSONL. -`devices watch --via-mqtt` is the same stream filtered to one -deviceId. - -### Execute a plan instead of a single command - -```bash -cat plan.json -# { "steps": [ -# { "device": "hallway lamp", "command": "turnOn" }, -# { "device": "bedside lamp", "command": "turnOff" } -# ] } -switchbot plan run plan.json --dry-run -switchbot plan run plan.json --audit-log -``` - -`plan run` is the **manual equivalent** of a single rule firing — -a batch of commands, confirmed up front, logged the same way. An L1 -agent can generate the plan, show it to the user, and run it on -approval. - -### Feed an AI agent - -```bash -switchbot agent-bootstrap --compact | jq '.identity, .schemaVersion' -switchbot mcp serve # stdio -switchbot mcp serve --transport http --port 3100 # Streamable HTTP -switchbot doctor --json | jq '.overall' -``` - -MCP exposes the same operations as the CLI. `agent-bootstrap` -supplies the one-shot cold-start snapshot. `doctor` reports the -system's health in a machine-readable form. - -### Know the history, know the quota - -```bash -switchbot history show --since 24h -switchbot history replay --dry-run -switchbot quota status --json -``` - -Every API call counts against the 10,000-req/day SwitchBot quota. -The CLI tracks that locally and exposes the server's -`X-Ratelimit-Remaining` header in both JSON and table output. - ---- - -## What Phase 1 deliberately does NOT include - -These are **not** Phase 1 deficiencies — they are Phase 1's scope. - -- **No declarative automations.** If you want "when motion at night, - turn on the lamp," that is Phase 4. An L1 agent running a Phase 1 - install can fake it with a shell loop, but the supported path is - Phase 4. -- **No cross-device conditions.** `devices command` does not take a - `--if-state` flag. `plan run` is linear. The device_state guard is - a Phase 4 primitive. -- **No hot reload of configuration.** Reloading `policy.yaml` mid-run - is a Phase 4 feature (SIGHUP / pid-file). In Phase 1, you restart. -- **No bearer-token webhook intake.** Shadow events come in via MQTT - only. The HTTP webhook trigger is Phase 4. - -These boundaries are the contract. Phase 1 does the things in the -first list exceptionally well; it does not try to do things in the -second list at all. - ---- - -## Why this framing matters - -A lot of the design pressure on Phase 2/3/4 would push back into -Phase 1 if we thought of Phase 1 as a prototype. It isn't. It is the -**steady-state surface** that every later phase sits on top of. When -Phase 4's rules engine fires a command, it reaches the device through -the Phase 1 command-dispatch path. When Phase 2's policy validator -checks a quiet-hours rule, it uses the same time library Phase 1 -`watch` uses. The phase numbering is about when capability arrived, -not about quality tiers. - -The corollary: **a PR that improves Phase 1 is not second-class -work.** The manual-orchestration experience is the single longest -code path in the repo, has the most tests (1624 at v2.8.0), and is -what an L1 agent actually runs. If a user reports a bug against -`devices watch` or `agent-bootstrap`, it is a first-class issue even -if Phase 4 is available. - ---- - -## How to think about Phase 1 in a roadmap review - -Ask: *"Can an L1 agent complete a full day's worth of user requests -against Phase 1 alone, without writing a single rule?"* - -The answer today is yes. That is what "Phase 1 is complete" means. diff --git a/docs/plans/2026-05-16-coverage-ceiling.md b/docs/plans/2026-05-16-coverage-ceiling.md deleted file mode 100644 index 932f1935..00000000 --- a/docs/plans/2026-05-16-coverage-ceiling.md +++ /dev/null @@ -1,576 +0,0 @@ -# Coverage Ceiling Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Push overall line coverage from 79% to ~85–88% by backfilling tests for the six lowest-coverage technically-testable files, then raising vitest thresholds to lock in the gains. - -**Architecture:** Six independent test-addition tasks (Tasks 1–6) followed by a threshold-bump task (Task 7). Each task modifies exactly one existing test file and commits separately. Tasks 1–6 can be done in any order; Task 7 must be last. - -**Tech Stack:** TypeScript, vitest 2.1.9, @vitest/coverage-v8. All tests use in-process patterns (no subprocess spawn). Tasks 1 uses `vi.spyOn` on `fs` exports. Task 2 adds a `vi.mock` for the keychain module to config.test.ts. Tasks 3–6 use the existing local `runCli()` helper pattern already established in each file. - ---- - -## Hard Coverage Ceiling Reference - -These areas are **deliberately excluded** — testing them requires live infrastructure: - -| File | Coverage | Reason excluded | -|---|---|---| -| `src/sinks/mqtt/client.ts` | 1% | MQTT broker | -| `src/lib/llm/anthropic.ts` | 15% | Anthropic API | -| `src/lib/llm/openai.ts` | 15% | OpenAI API | -| `src/commands/rules.ts` simulate | — | Full engine simulation | -| `src/commands/install.ts` | — | OS privilege, already excluded | -| `src/commands/uninstall.ts` | — | OS privilege, already excluded | - ---- - -## File Map - -| File | Change | -|---|---| -| `tests/lib/plan-store.test.ts` | +4 tests after existing 3 | -| `tests/commands/config.test.ts` | +hoisted keychain mock + 3 tests | -| `tests/commands/doctor.test.ts` | +3 tests inside existing `describe('doctor command')` | -| `tests/commands/rules.test.ts` | +4 tests: 2 summary + 2 last-fired | -| `tests/commands/auth.test.ts` | +2 tests inside `describe('auth keychain migrate')` | -| `vitest.config.ts` | Bump thresholds 75% → 80% | - ---- - -## Task 1: Backfill plan-store.ts (43% → ~80%) - -**Files:** -- Modify: `tests/lib/plan-store.test.ts` — add 4 tests after the existing `describe('plan-store security')` block - -**Background:** `plan-store.ts` has only 3 path-traversal security tests covering the input-validation guard. The actual write path (`savePlanRecord`), the "not found" error path (`updatePlanRecord`), and the directory-reading path (`listPlanRecords`) are completely uncovered. - -- [ ] **Step 1: Open the file and locate the insertion point** - -`tests/lib/plan-store.test.ts` currently ends at line 18 with `});`. Insert all new tests after that closing brace, still in the same file. - -- [ ] **Step 2: Add the four new tests** - -```ts -import { describe, it, expect, vi, afterEach } from 'vitest'; -import fs from 'node:fs'; -import { - loadPlanRecord, - updatePlanRecord, - savePlanRecord, - listPlanRecords, - type PlanRecord, -} from '../../src/lib/plan-store.js'; -import type { Plan } from '../../src/commands/plan.js'; -``` - -**Note:** The existing file already imports `describe`, `it`, `expect` from `vitest` and imports `loadPlanRecord`/`updatePlanRecord`. You need to ADD the new imports `vi`, `afterEach`, `savePlanRecord`, `listPlanRecords`, `PlanRecord`, and `Plan` at the top of the file. Do not duplicate existing imports. - -Then append the following after the existing `describe('plan-store security', ...)` block: - -```ts -describe('plan-store write + list paths', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('savePlanRecord returns a pending record with a UUID v4 planId', () => { - vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined); - vi.spyOn(fs, 'writeFileSync').mockReturnValue(undefined); - - const plan: Plan = { version: '1.0', steps: [] }; - const record = savePlanRecord(plan); - - expect(record.status).toBe('pending'); - expect(record.plan).toBe(plan); - expect(record.planId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, - ); - expect(fs.writeFileSync).toHaveBeenCalledOnce(); - }); - - it('updatePlanRecord throws when the plan record does not exist on disk', () => { - expect(() => - updatePlanRecord('00000000-0000-4000-8000-000000000001', { status: 'approved' }), - ).toThrow(/not found/i); - }); - - it('listPlanRecords returns empty array when PLANS_DIR is absent', () => { - vi.spyOn(fs, 'existsSync').mockReturnValueOnce(false); - expect(listPlanRecords()).toEqual([]); - }); - - it('listPlanRecords returns records sorted oldest-first by createdAt', () => { - const older: PlanRecord = { - planId: '00000000-0000-4000-8000-000000000001', - createdAt: '2024-01-01T00:00:00Z', - status: 'pending', - plan: { version: '1.0', steps: [] }, - }; - const newer: PlanRecord = { - planId: '00000000-0000-4000-8000-000000000002', - createdAt: '2024-01-02T00:00:00Z', - status: 'pending', - plan: { version: '1.0', steps: [] }, - }; - - vi.spyOn(fs, 'existsSync').mockReturnValueOnce(true); - // readdirSync returns newer first to verify sorting is applied - vi.spyOn(fs, 'readdirSync').mockReturnValueOnce( - ['newer.json', 'older.json'] as unknown as ReturnType, - ); - vi.spyOn(fs, 'readFileSync') - .mockReturnValueOnce(JSON.stringify(newer)) - .mockReturnValueOnce(JSON.stringify(older)); - - const result = listPlanRecords(); - expect(result).toHaveLength(2); - expect(result[0].planId).toBe(older.planId); - expect(result[1].planId).toBe(newer.planId); - }); -}); -``` - -- [ ] **Step 3: Run the test file** - -```bash -npm test -- tests/lib/plan-store.test.ts -``` - -Expected: all 7 tests pass (3 original + 4 new). - -- [ ] **Step 4: Commit** - -```bash -git add tests/lib/plan-store.test.ts -git commit -m "test: backfill plan-store savePlanRecord, updatePlanRecord not-found, listPlanRecords paths" -``` - ---- - -## Task 2: Backfill config.ts platform hints (65% → ~78%) - -**Files:** -- Modify: `tests/commands/config.test.ts` — add a keychain mock + 3 new tests - -**Background:** `config.ts` lines 277–293 show a platform-specific keychain tip after `set-token` succeeds when the credential backend is `'file'`. The keychain store is loaded via a dynamic `await import()`. These three branches (darwin/win32, linux, and non-file backend → no tip) are completely uncovered. - -- [ ] **Step 1: Add the keychain mock declaration** - -At the top of `tests/commands/config.test.ts`, just before the existing `const configMock = vi.hoisted(...)` block, add: - -```ts -const keychainMock = vi.hoisted(() => vi.fn()); - -vi.mock('../../src/credentials/keychain.js', () => ({ - selectCredentialStore: keychainMock, -})); -``` - -The existing `configMock` block and `vi.mock('../../src/config.js', ...)` stay unchanged below it. - -- [ ] **Step 2: Add the three platform hint tests** - -Append the following new `describe` block at the end of the file (after the last existing `describe` block closes): - -```ts -describe('set-token platform keychain hint', () => { - let savedPlatformDescriptor: PropertyDescriptor | undefined; - - beforeEach(() => { - // Reset configMock so set-token saves successfully - configMock.saveConfig.mockReset(); - savedPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); - // Default: backend is file so hints are eligible - keychainMock.mockResolvedValue({ - name: 'file', - describe: () => ({ backend: 'file', tag: 'file', writable: true }), - get: vi.fn(), - set: vi.fn(), - delete: vi.fn(), - }); - }); - - afterEach(() => { - if (savedPlatformDescriptor) { - Object.defineProperty(process, 'platform', savedPlatformDescriptor); - } - }); - - it('emits native-keychain tip to stderr on darwin when backend is file', async () => { - Object.defineProperty(process, 'platform', { - value: 'darwin', - configurable: true, - writable: false, - }); - const res = await runCli(registerConfigCommand, ['config', 'set-token', 'T', 'S']); - expect(res.exitCode).toBeNull(); - expect(res.stderr.join('\n')).toContain('native keychain'); - }); - - it('emits GNOME Keyring tip to stderr on linux when backend is file', async () => { - Object.defineProperty(process, 'platform', { - value: 'linux', - configurable: true, - writable: false, - }); - const res = await runCli(registerConfigCommand, ['config', 'set-token', 'T', 'S']); - expect(res.exitCode).toBeNull(); - expect(res.stderr.join('\n')).toContain('GNOME Keyring'); - }); - - it('emits no keychain tip when the backend is not file', async () => { - Object.defineProperty(process, 'platform', { - value: 'darwin', - configurable: true, - writable: false, - }); - keychainMock.mockResolvedValue({ - name: 'keychain', - describe: () => ({ backend: 'keychain', tag: 'keychain', writable: true }), - get: vi.fn(), - set: vi.fn(), - delete: vi.fn(), - }); - const res = await runCli(registerConfigCommand, ['config', 'set-token', 'T', 'S']); - expect(res.exitCode).toBeNull(); - expect(res.stderr.join('\n')).not.toContain('native keychain'); - expect(res.stderr.join('\n')).not.toContain('GNOME'); - }); -}); -``` - -**Important:** Make sure `beforeEach` and `afterEach` are imported in the existing import line at the top of the file if not already present. - -- [ ] **Step 3: Run the test file** - -```bash -npm test -- tests/commands/config.test.ts -``` - -Expected: all tests pass including the 3 new ones. The existing set-token tests must also still pass — the keychainMock is cleared between tests by `clearMocks: true` in vitest.config.ts, so existing tests that don't configure it see `keychainMock()` return `undefined`, which causes the `try/catch` in config.ts to silently skip the hint (correct behavior). - -- [ ] **Step 4: Commit** - -```bash -git add tests/commands/config.test.ts -git commit -m "test: backfill config set-token platform keychain hints (darwin, linux, non-file)" -``` - ---- - -## Task 3: Backfill doctor.ts human-mode output (71% → ~79%) - -**Files:** -- Modify: `tests/commands/doctor.test.ts` — add 3 tests inside the existing `describe('doctor command')` block - -**Background:** Every existing doctor test uses `--json`. Lines 1221–1227 (non-JSON `--list`) and 1302–1324 (non-JSON icon/summary output including `--quiet` suppression) are completely uncovered. - -The `describe('doctor command')` block already has: -- `beforeEach` that deletes `SWITCHBOT_TOKEN`/`SWITCHBOT_SECRET`, mocks `os.homedir`, sets `SWITCHBOT_POLICY_PATH` -- `afterEach` that cleans up - -All three new tests go inside that same describe block. - -- [ ] **Step 1: Locate the insertion point** - -Find the last `it(...)` test inside `describe('doctor command')` (currently at the bottom of the block). The new tests go after the last existing test but still inside the outer `describe` block. - -- [ ] **Step 2: Add the three new tests** - -```ts - it('non-JSON --list prints "Available checks:" followed by check names', async () => { - const res = await runCli(registerDoctorCommand, ['doctor', '--list']); - expect(res.exitCode).toBeNull(); - const out = res.stdout.join('\n'); - expect(out).toContain('Available checks:'); - expect(out).toContain('credentials'); - expect(out).toContain('mcp'); - expect(out).toContain('catalog-schema'); - }); - - it('non-JSON output shows icon (✓/!) per check and a summary line', async () => { - process.env.SWITCHBOT_TOKEN = 't'; - process.env.SWITCHBOT_SECRET = 's'; - const res = await runCli( - registerDoctorCommand, - ['doctor', '--section', 'catalog-schema,mcp'], - ); - // human mode — no JSON parsing - const out = res.stdout.join('\n'); - expect(out).toMatch(/[✓!✗]\s+catalog-schema/); - expect(out).toMatch(/[✓!✗]\s+mcp/); - expect(out).toMatch(/\d+ ok, \d+ warn, \d+ fail/); - }); - - it('--quiet suppresses ok checks but keeps failing checks and the summary', async () => { - // No credentials → credentials check fails; catalog-schema does not need live API - const res = await runCli( - registerDoctorCommand, - ['doctor', '--section', 'catalog-schema,credentials', '--quiet'], - ); - const out = res.stdout.join('\n'); - // The credentials check line (fail/warn) must appear - expect(out).toMatch(/[!✗]\s+credentials/); - // The catalog-schema ok check must be suppressed - expect(out).not.toMatch(/✓\s+catalog-schema/); - // Summary is always shown - expect(out).toMatch(/\d+ ok, \d+ warn, \d+ fail/); - }); -``` - -- [ ] **Step 3: Run the test file** - -```bash -npm test -- tests/commands/doctor.test.ts -``` - -Expected: all tests pass. - -- [ ] **Step 4: Commit** - -```bash -git add tests/commands/doctor.test.ts -git commit -m "test: backfill doctor human-mode output: --list, icon format, --quiet suppression" -``` - ---- - -## Task 4: Backfill rules.ts summary + last-fired (57% → ~63%) - -**Files:** -- Modify: `tests/commands/rules.test.ts` — add 4 tests at the end of the existing `describe('switchbot rules (commander surface)')` block - -**Background:** `rules.ts` lines 800–868 contain the `summary` and `last-fired` subcommands. These are read-only audit-log queries that work fine with a non-existent log (they return empty state). No mocking needed — just pass `--file` pointing at a non-existent tmpDir path. - -- [ ] **Step 1: Locate the insertion point** - -In `tests/commands/rules.test.ts`, find the end of the outermost `describe('switchbot rules (commander surface)')` block. The existing `describe('rules doctor', ...)` block is the last nested describe. Insert two new `describe` blocks after it, still inside the outer describe. - -- [ ] **Step 2: Add the four new tests** - -```ts - describe('rules summary', () => { - it('returns total:0 and empty summaries under --json when log is absent', async () => { - const logFile = path.join(tmpDir, 'noaudit.log'); - const res = await runCli(['--json', 'rules', 'summary', '--file', logFile]); - expect(res.exitCode).toBe(0); - const body = JSON.parse(res.stdout[0]) as Record; - const data = expectJsonEnvelopeContainingKeys(body, ['total', 'summaries']) as { - total: number; - summaries: unknown[]; - }; - expect(data.total).toBe(0); - expect(data.summaries).toEqual([]); - }); - - it('prints "no rule activity" in human mode when log is absent', async () => { - const logFile = path.join(tmpDir, 'noaudit.log'); - const res = await runCli(['rules', 'summary', '--file', logFile]); - expect(res.exitCode).toBe(0); - expect(res.stdout.join('\n')).toContain('no rule activity'); - }); - }); - - describe('rules last-fired', () => { - it('returns count:0 and empty entries under --json when log is absent', async () => { - const logFile = path.join(tmpDir, 'noaudit.log'); - const res = await runCli(['--json', 'rules', 'last-fired', '--file', logFile]); - expect(res.exitCode).toBe(0); - const body = JSON.parse(res.stdout[0]) as Record; - const data = expectJsonEnvelopeContainingKeys(body, ['count', 'entries']) as { - count: number; - entries: unknown[]; - }; - expect(data.count).toBe(0); - expect(data.entries).toEqual([]); - }); - - it('prints "no rule-fire entries" in human mode when log is absent', async () => { - const logFile = path.join(tmpDir, 'noaudit.log'); - const res = await runCli(['rules', 'last-fired', '--file', logFile]); - expect(res.exitCode).toBe(0); - expect(res.stdout.join('\n')).toContain('no rule-fire entries'); - }); - }); -``` - -- [ ] **Step 3: Run the test file** - -```bash -npm test -- tests/commands/rules.test.ts -``` - -Expected: all tests pass. - -- [ ] **Step 4: Commit** - -```bash -git add tests/commands/rules.test.ts -git commit -m "test: backfill rules summary and last-fired empty-log paths" -``` - ---- - -## Task 5: Backfill auth.ts migrate error paths (65% → ~70%) - -**Files:** -- Modify: `tests/commands/auth.test.ts` — add 2 tests inside the existing `describe('auth keychain migrate')` block (after the last existing test at ~line 340) - -**Background:** `auth.ts` lines 339–344 (catch block when `store.set()` throws during migrate) and 352–353 (warning log when `--delete-file` cleanup fails) are uncovered. - -The existing `describe('auth keychain migrate')` block already has a `beforeEach`/`afterEach` that sets up `tmpHome` as `process.env.HOME`, and `makeStore()` + `selectMock` are available at file scope. - -- [ ] **Step 1: Add the two new tests inside `describe('auth keychain migrate')`** - -```ts - it('exits 1 when the keychain write fails during migrate', async () => { - const store = makeStore({ - writable: true, - setImpl: async () => { - throw new Error('permission denied'); - }, - }); - selectMock.mockResolvedValue(store); - - const file = path.join(tmpHome, '.switchbot', 'config.json'); - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, JSON.stringify({ token: 't-src', secret: 's-src' })); - - const res = await runCli(['auth', 'keychain', 'migrate']); - expect(res.exitCode).toBe(1); - expect(res.stderr.join('\n')).toContain('keychain write failed'); - }); - - it('exits 0 but logs a warning when --delete-file cleanup throws', async () => { - const store = makeStore({ writable: true }); - selectMock.mockResolvedValue(store); - - const file = path.join(tmpHome, '.switchbot', 'config.json'); - fs.mkdirSync(path.dirname(file), { recursive: true }); - // Only token+secret → no metadata → cleanup tries fs.unlinkSync - fs.writeFileSync(file, JSON.stringify({ token: 't-src', secret: 's-src' })); - - const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => { - throw new Error('EPERM: operation not permitted'); - }); - try { - const res = await runCli(['auth', 'keychain', 'migrate', '--delete-file']); - expect(res.exitCode).toBe(0); - expect(res.stderr.join('\n')).toContain('warning: could not remove'); - } finally { - unlinkSpy.mockRestore(); - } - }); -``` - -- [ ] **Step 2: Run the test file** - -```bash -npm test -- tests/commands/auth.test.ts -``` - -Expected: all tests pass. - -- [ ] **Step 3: Commit** - -```bash -git add tests/commands/auth.test.ts -git commit -m "test: backfill auth migrate keychain-write-fail and cleanup-warning paths" -``` - ---- - -## Task 6: Raise vitest coverage thresholds - -**Files:** -- Modify: `vitest.config.ts` - -Run coverage first to verify the new tests have pushed the numbers above the new targets before editing the config. - -- [ ] **Step 1: Run coverage and inspect current numbers** - -```bash -npm run test -- --coverage 2>&1 | grep -E "All files|src/commands" -``` - -Expected (approximate — exact numbers depend on which lines the new tests hit): - -``` - src/commands | 79.x | 78.x | ... -All files | 82.x | 80.x | ... -``` - -If `src/commands` lines are below 78 or branches below 75, investigate which file is dragging it down before proceeding. - -- [ ] **Step 2: Edit `vitest.config.ts` — update only the `thresholds` block** - -Current content: - -```ts - thresholds: { - lines: 75, - branches: 75, - 'src/commands/**': { - lines: 75, - branches: 75, - }, - }, -``` - -Replace with: - -```ts - thresholds: { - lines: 79, - branches: 78, - 'src/commands/**': { - lines: 78, - branches: 76, - }, - }, -``` - -The comment block above `thresholds` should be updated to reflect current reality: - -```ts - // Thresholds locked to current actual coverage after 2026-05-16 backfill. - // Raise incrementally: rules.ts (57%), mcp.ts (68%) still have room. -``` - -- [ ] **Step 3: Run coverage and confirm no threshold violations** - -```bash -npm run test -- --coverage 2>&1 | tail -15 -``` - -Expected: exits 0, no lines starting with `ERROR: Coverage for`. - -If you see a threshold violation, check which specific file is below the threshold and either: -- Lower that specific threshold to match reality (with a comment explaining why), OR -- Add a targeted test to bring that file above the threshold - -- [ ] **Step 4: Run the full test suite without coverage to confirm no regressions** - -```bash -npm test -``` - -Expected: all tests pass, no failures. - -- [ ] **Step 5: Commit** - -```bash -git add vitest.config.ts -git commit -m "test: raise coverage thresholds to match post-backfill actuals" -``` - ---- - -## Final Verification - -- [ ] Run `npm test` — all tests pass -- [ ] Run `npm run test -- --coverage` — exits 0, no threshold errors -- [ ] `src/commands` line coverage ≥ 78% -- [ ] Global line coverage ≥ 79% -- [ ] Confirm `docs/plans/2026-05-16-coverage-ceiling.md` is committed diff --git a/docs/plans/2026-05-16-test-coverage-improvement.md b/docs/plans/2026-05-16-test-coverage-improvement.md deleted file mode 100644 index da9c39e4..00000000 --- a/docs/plans/2026-05-16-test-coverage-improvement.md +++ /dev/null @@ -1,405 +0,0 @@ -# Test Coverage Improvement Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Backfill 5 missing test cases, add TESTING.md conventions, and enforce layered coverage thresholds in vitest — preventing the three structural test gaps that let v3.6.2 bugs ship untested. - -**Architecture:** Three independent deliverables committed separately. Tests first (Layer 1), convention doc second (Layer 2), CI gate last (Layer 3) — so coverage thresholds are added only after the new tests already pass. - -**Tech Stack:** TypeScript, vitest 2.1.9, @vitest/coverage-v8, Commander.js. Tests use in-process `runCli()` helper (not subprocess) — see `tests/helpers/cli.ts`. - ---- - -## File Map - -| File | Change | -|---|---| -| `tests/commands/plan.test.ts` | +2 test cases inside existing `describe('plan suggest')` block | -| `tests/commands/doctor.test.ts` | +1 test case inside existing `it('P10: mcp check…')` neighbourhood | -| `tests/commands/quota.test.ts` | +1 test case inside existing `describe('quota command')` block | -| `tests/commands/completion.test.ts` | +1 test case inside existing `describe('completion command')` block | -| `TESTING.md` | New file at project root | -| `vitest.config.ts` | Add `thresholds` block; add `src/sinks/**` to `exclude` | - ---- - -## Task 1: Backfill plan.test.ts — 2 missing cases - -**Files:** -- Modify: `tests/commands/plan.test.ts:361-369` - -The existing `describe('plan suggest')` block ends at line 369. Append both new tests inside it. - -- [ ] **Step 1: Open the file and locate the insertion point** - -Find this block near the end of the file: - -```ts -describe('plan suggest', () => { - it('exits 2 for unsupported Chinese command intent instead of defaulting to turnOn', async () => { - const res = await runCli(registerPlanCommand, [ - 'plan', 'suggest', '--intent', '关掉所有灯', '--device', 'BOT1', - ]); - expect(res.exitCode).toBe(2); - expect(res.stderr.join('\n')).toMatch(/cannot safely infer/i); - }); -}); -``` - -- [ ] **Step 2: Add the two new tests inside the same `describe` block** - -Replace the closing `});` of `describe('plan suggest')` with: - -```ts - it('exits 2 when no --device is given', async () => { - const res = await runCli(registerPlanCommand, [ - 'plan', 'suggest', '--intent', 'turn off lights', - ]); - expect(res.exitCode).toBe(2); - expect(res.stderr.join('\n')).toContain('at least one --device'); - }); - - it('accepts --devices as alias for --device', async () => { - cacheMock.map.set('BOT1', { type: 'Bot', name: 'My Bot', category: 'physical' }); - const res = await runCli(registerPlanCommand, [ - 'plan', 'suggest', '--intent', 'turn on the bot', '--devices', 'BOT1', - ]); - expect(res.exitCode).toBeNull(); - expect(res.stdout.join('\n')).toContain('BOT1'); - }); -}); -``` - -- [ ] **Step 3: Run just these new tests** - -```bash -npm test -- tests/commands/plan.test.ts -``` - -Expected: all tests in that file pass, including the 2 new ones. - -- [ ] **Step 4: Commit** - -```bash -git add tests/commands/plan.test.ts -git commit -m "test: backfill plan suggest exit-code and --devices alias coverage" -``` - ---- - -## Task 2: Backfill doctor.test.ts — 1 missing case - -**Files:** -- Modify: `tests/commands/doctor.test.ts` — add after the existing `P10: mcp check` test (line ~327) - -- [ ] **Step 1: Locate insertion point** - -Find the existing test that ends around line 327: - -```ts - it('P10: mcp check is ok and reports a toolCount when the server instantiates', async () => { - // ... - expect(mcp.detail.transportsAvailable).toEqual(['stdio', 'http']); - }); -``` - -- [ ] **Step 2: Add the new test immediately after it** - -```ts - it('P10: mcp check message includes default profile context', async () => { - process.env.SWITCHBOT_TOKEN = 't'; - process.env.SWITCHBOT_SECRET = 's'; - const res = await runCli(registerDoctorCommand, ['--json', 'doctor']); - const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); - const mcp = payload.data.checks.find((c: { name: string }) => c.name === 'mcp'); - expect(mcp).toBeDefined(); - expect(mcp.detail.message).toContain('default profile'); - expect(mcp.detail.message).toContain("24 in 'all'"); - }); -``` - -- [ ] **Step 3: Run just this test file** - -```bash -npm test -- tests/commands/doctor.test.ts -``` - -Expected: all tests pass. - -- [ ] **Step 4: Commit** - -```bash -git add tests/commands/doctor.test.ts -git commit -m "test: assert doctor mcp message includes default profile context" -``` - ---- - -## Task 3: Backfill quota.test.ts — 1 missing case - -**Files:** -- Modify: `tests/commands/quota.test.ts` — add inside existing `describe('quota command')` - -- [ ] **Step 1: Locate insertion point** - -Find the first `it(...)` inside `describe('quota command')` which starts at line 34: - -```ts -describe('quota command', () => { - it('status prints today usage + endpoint breakdown (human mode)', async () => { - // ... - }); -``` - -- [ ] **Step 2: Add the new test after the existing first test** - -```ts - it('status human output includes Remaining budget line with reset time', async () => { - const result = await runCli(registerQuotaCommand, ['quota', 'status']); - const out = result.stdout.join('\n'); - expect(out).toContain('Remaining budget:'); - expect(out).toContain('resets at'); - }); -``` - -- [ ] **Step 3: Run just this test file** - -```bash -npm test -- tests/commands/quota.test.ts -``` - -Expected: all tests pass. - -- [ ] **Step 4: Commit** - -```bash -git add tests/commands/quota.test.ts -git commit -m "test: assert quota status output contains Remaining budget and reset time" -``` - ---- - -## Task 4: Backfill completion.test.ts — 1 missing case - -**Files:** -- Modify: `tests/commands/completion.test.ts` — add inside existing `describe('completion command')` - -The existing bash test (line 21) checks for `--profile` and `--audit-log-path` but not format values. - -- [ ] **Step 1: Locate insertion point** - -Find the existing bash test: - -```ts - it('prints a bash completion script', async () => { - const res = await runCli(registerCompletionCommand, ['completion', 'bash']); - expect(res.exitCode).toBeNull(); - const out = written.join(''); - expect(out).toContain('_switchbot_completion'); - // ... - }); -``` - -- [ ] **Step 2: Add new test after it** - -```ts - it('bash completion includes all --format values', async () => { - const res = await runCli(registerCompletionCommand, ['completion', 'bash']); - expect(res.exitCode).toBeNull(); - const out = written.join(''); - expect(out).toContain('format_vals'); - for (const fmt of ['table', 'json', 'jsonl', 'tsv', 'yaml', 'id', 'markdown']) { - expect(out).toContain(fmt); - } - }); -``` - -- [ ] **Step 3: Run just this test file** - -```bash -npm test -- tests/commands/completion.test.ts -``` - -Expected: all tests pass. - -- [ ] **Step 4: Commit** - -```bash -git add tests/commands/completion.test.ts -git commit -m "test: assert bash completion exposes all --format enum values" -``` - ---- - -## Task 5: Create TESTING.md - -**Files:** -- Create: `TESTING.md` at project root - -- [ ] **Step 1: Create the file with this exact content** - -```markdown -# Testing Conventions - -This document defines the three rules that prevent the class of bugs identified -in the v3.6.2 post-mortem. Each rule maps to a specific root cause. - ---- - -## Rule 1 — Every `process.exit` path needs its own test - -Every `process.exit(1)` or `process.exit(2)` call in `src/commands/` must have -at least one test case in `tests/commands/` that reaches that path and asserts -`exitCode`. New exit branches must be tested in the same commit. - -**Why:** v3.6.2 shipped `plan suggest` with a missing-argument branch that exited -with code 1 instead of 2. The existing test only exercised a different branch in -the same command, leaving the new branch invisible to CI. - ---- - -## Rule 2 — Every new CLI option/alias needs a smoke test - -Every new `.option()` declaration (including aliases) must have at least one test -that uses the flag and asserts it is parsed or produces the expected behavior. - -**Why:** The `--devices` plural alias for `plan suggest` shipped with zero test -coverage. Any rename or collision would have been silent. - ---- - -## Rule 3 — Non-trivial user-visible messages need keyword assertions - -Any `console.log/error` line containing a business-semantic keyword — one whose -removal would confuse the user — must have a corresponding test asserting that -keyword appears in `stdout` or `stderr`. - -**Why:** The doctor MCP tool-count message and quota reset-time line were changed -without any test catching the change, because tests only checked numeric structure, -not message content. - ---- - -## PR Checklist - -Before merging, verify: - -- [ ] New `process.exit` path → corresponding test added -- [ ] New CLI option/alias → smoke test added -- [ ] Changed user-visible message → keyword assertion updated -``` - -- [ ] **Step 2: Verify the file exists** - -```bash -cat TESTING.md | head -5 -``` - -Expected output starts with `# Testing Conventions`. - -- [ ] **Step 3: Commit** - -```bash -git add TESTING.md -git commit -m "docs: add TESTING.md with three coverage conventions and PR checklist" -``` - ---- - -## Task 6: Add layered coverage thresholds to vitest.config.ts - -**Files:** -- Modify: `vitest.config.ts` - -Run the full coverage suite first to confirm the new tests bring `src/commands/**` -above the thresholds before writing the config. - -- [ ] **Step 1: Run coverage and inspect the summary** - -```bash -npm run test -- --coverage 2>&1 | grep -E "^All files|src/commands" -``` - -Expected output (approximate — exact numbers depend on which lines the 5 new tests hit): - -``` -src/commands | 85.x | 80.x | ... -All files | 78.x | 80.x | ... -``` - -If `src/commands` lines are below 85 or branches below 80, the thresholds in Step 2 -will fail — investigate which file is low before proceeding. - -- [ ] **Step 2: Edit `vitest.config.ts` — replace the `coverage` block** - -Current content: - -```ts - coverage: { - provider: 'v8', - include: ['src/**/*.ts'], - exclude: ['src/index.ts'], - reporter: ['text', 'html'], - }, -``` - -Replace with: - -```ts - coverage: { - provider: 'v8', - include: ['src/**/*.ts'], - exclude: ['src/index.ts', 'src/sinks/**'], - reporter: ['text', 'html'], - thresholds: { - lines: 80, - branches: 80, - 'src/commands/**': { - lines: 85, - branches: 80, - }, - }, - }, -``` - -- [ ] **Step 3: Run coverage and confirm it passes** - -```bash -npm run test -- --coverage 2>&1 | tail -10 -``` - -Expected: exits 0, no threshold violation lines (lines starting with `ERROR`). - -If you see `ERROR: Coverage for lines (X%) does not meet global threshold (80%)`, -check which file is dragging the global below 80 — it is likely in `src/lib/` or -`src/rules/`. Address it before merging. - -- [ ] **Step 4: Run the full test suite without coverage to confirm no regressions** - -```bash -npm test -``` - -Expected: -``` -Test Files 128 passed (128) -Tests 2470 passed (2470) -``` - -(2465 original + 5 new = 2470) - -- [ ] **Step 5: Commit** - -```bash -git add vitest.config.ts -git commit -m "test: add layered coverage thresholds (global 80%, src/commands 85% lines)" -``` - ---- - -## Final Verification - -- [ ] Run `npm test` — all tests pass (≥2470) -- [ ] Run `npm run test -- --coverage` — exits 0, no threshold errors -- [ ] Confirm `TESTING.md` exists at project root: `cat TESTING.md | head -1` -- [ ] Confirm `docs/plans/2026-05-16-test-coverage-improvement.md` is committed diff --git a/docs/plans/2026-05-17-coverage-90pct-design.md b/docs/plans/2026-05-17-coverage-90pct-design.md deleted file mode 100644 index e58d7280..00000000 --- a/docs/plans/2026-05-17-coverage-90pct-design.md +++ /dev/null @@ -1,119 +0,0 @@ -# Design: Push Test Coverage to ~90% - -**Date:** 2026-05-17 -**Branch context:** `fix/v3.6.2-bugs` -**Current coverage:** 79.49% lines / 79.97% branches (global) -**Target:** ≥88% lines / ≥87% branches after HC exclusions - ---- - -## 1. Strategy - -Approach B — **exclude hard-ceiling infrastructure files + targeted test backfill** for the 10–11 highest-leverage in-scope files. - -The gap from ~79% to ~90% cannot be closed by test-writing alone because several files require live external infrastructure (MQTT broker, MCP server, LLM APIs). The honest solution is: - -1. Remove those files from the coverage denominator via vitest `exclude`. -2. Write unit tests for every remaining file that is realistically improvable. -3. Lock the new thresholds to the achieved actuals. -4. Document in `docs/coverage-annotations.md` what is excluded and why, and which in-denominator sections remain structurally untestable. - ---- - -## 2. vitest.config.ts — Exclusion List Changes - -Add 5 files to the existing `coverage.exclude` array: - -```typescript -// Hard-ceiling: require live infrastructure, not unit-testable -'src/mcp/device-history.ts', // MCP streaming protocol (live server required) -'src/mcp/events-subscription.ts', // MCP event subscription (live server required) -'src/mqtt/client.ts', // MQTT broker required -'src/llm/providers/anthropic.ts', // Anthropic API key + live endpoint required -'src/llm/providers/openai.ts', // OpenAI API key + live endpoint required -``` - -**Verified impact:** exclusion alone moves the baseline from 79.49% → 81.47%. - ---- - -## 3. Test Backfill Targets - -Priority order by `line_count × potential_gain`. Each file's uncoverable sections are noted so tests are not written for them. - -### P1 — Highest impact - -| File | Current | Target | Key test surface | -|------|---------|--------|-----------------| -| `src/commands/doctor.ts` | 72.7% | ~87% | lines 1311–1323: `--check` failure path, `--json` error envelope | -| `src/commands/plan.ts` | 74.2% | ~88% | `approve` / `reject` / `execute` command wiring, `--json` output shape | -| `src/status-sync/manager.ts` | 73.3% | ~87% | `start` / `stop` / `status` state transitions, event emission | - -### P2 — Medium impact - -| File | Current | Target | Key test surface | -|------|---------|--------|-----------------| -| `src/commands/events.ts` | 73.0% | ~85% | `--device`, `--type` filter flags, clean exit path | -| `src/commands/auth.ts` | 68.0% | ~84% | lines 316–325: token-expired path, `--json` error response | -| `src/install/preflight.ts` | 66.3% | ~83% | OS-check branches stubbed for win32 / linux / macos | -| `src/commands/config.ts` | 69.1% | ~85% | lines 233–267: `--from-keychain`, error paths | - -### P3 — Supplemental - -| File | Current | Target | Key test surface | -|------|---------|--------|-----------------| -| `src/commands/mcp.ts` | 68.0% | ~76% | `--list`, `--json` flag parsing; protocol body NOT tested | -| `src/daemon/socket-path.ts` | 52.5% | ~87% | win32 named-pipe path, `USERNAME` env fallback, `whoami` fallback | -| `src/commands/rules.ts` | 56.7% | ~70% | non-simulate subcommand flag/output paths | -| `src/lib/daemon-state.ts` | 73.2% | ~92% | lock/unlock/read state transitions | - ---- - -## 4. In-Denominator Structurally Untestable Sections - -These sections remain in the coverage denominator but cannot be covered by unit tests. They are documented in `docs/coverage-annotations.md`. - -| File | Lines / Area | Reason | -|------|-------------|--------| -| `src/commands/mcp.ts` | 2364–2633 | MCP tool-call / resource protocol handlers — live MCP client required | -| `src/commands/rules.ts` | 800–985, 1001–1081 | `simulate` / `trace-explain` subcommands — full rules engine + LLM required | -| `src/status-sync/manager.ts` | WebSocket push path | Live SwitchBot WebSocket connection required | -| `src/policy/migrate.ts` | lines 21–52 | `MIGRATION_CHAIN` is empty; migration step functions exist but are unreachable | - ---- - -## 5. Threshold Targets (post-backfill) - -Set conservatively (2% below expected actuals) to avoid flakiness from minor coverage drift: - -```typescript -thresholds: { - lines: 88, - branches: 87, - 'src/commands/**': { - lines: 80, - branches: 78, - }, -}, -``` - -After backfill is complete, run `npm run test -- --coverage` and lock thresholds to actual numbers. - ---- - -## 6. Deliverables - -1. `vitest.config.ts` — 5 HC files added to exclude list -2. `tests/commands/doctor.test.ts` — P1 additions -3. `tests/commands/plan.test.ts` — P1 additions (new file or additions) -4. `tests/lib/status-sync-manager.test.ts` — P1 new file -5. `tests/commands/events.test.ts` — P2 additions -6. `tests/commands/auth.test.ts` — P2 additions -7. `tests/install/preflight.test.ts` — P2 new file -8. `tests/commands/config.test.ts` — P2 additions -9. `tests/commands/mcp.test.ts` — P3 additions (flag parsing only) -10. `tests/lib/daemon-socket-path.test.ts` — P3 new file -11. `tests/commands/rules.test.ts` — P3 additions -12. `tests/lib/daemon-state.test.ts` — P3 additions -13. `vitest.config.ts` — thresholds locked to post-backfill actuals -14. `docs/coverage-annotations.md` — exclusion + untestable-section register diff --git a/docs/plans/2026-05-17-coverage-90pct-plan.md b/docs/plans/2026-05-17-coverage-90pct-plan.md deleted file mode 100644 index 7227cd0b..00000000 --- a/docs/plans/2026-05-17-coverage-90pct-plan.md +++ /dev/null @@ -1,943 +0,0 @@ -# Coverage ~90% Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Push line coverage from 79.49% to ≥88% by excluding 5 hard-ceiling infrastructure files and backfilling tests for 10 low-coverage source files. - -**Architecture:** Two-phase: (1) add 5 live-infrastructure files to `vitest.config.ts` exclude list, moving the baseline to 81.47%; (2) backfill unit tests for the 10 highest-leverage improvable files. Each test task is independent after Task 1 and can be done in any order. - -**Tech Stack:** TypeScript, vitest 2.x, `vi.mock` / `vi.hoisted` / `vi.spyOn`, `tests/helpers/cli.ts` runCli helper, Commander `exitOverride`. - ---- - -## File Map - -| File | Action | -|------|--------| -| `vitest.config.ts` | Modify twice: HC excludes (Task 1), threshold bump (Task 11) | -| `tests/commands/plan.test.ts` | Add plan-store mock + plan save/list/review/approve/execute tests | -| `tests/commands/doctor.test.ts` | Add `--fix` output test | -| `tests/commands/rules.test.ts` | Add summary/last-fired human-mode + trace-explain not-found tests | -| `tests/lib/daemon-state.test.ts` | Create new file | -| `tests/lib/daemon-socket-path.test.ts` | Create new file | -| `tests/commands/config.test.ts` | Add `--label` / `--daily-cap` options tests | -| `tests/commands/auth.test.ts` | Add config parse-error test | -| `tests/install/preflight.test.ts` | Add `~/.switchbot` dir writable path + agent-skills-dir success path | -| `docs/coverage-annotations.md` | Create new file | - ---- - -## Task 1: Exclude hard-ceiling files from coverage denominator - -**Files:** -- Modify: `vitest.config.ts` - -- [ ] **Step 1: Add 5 HC files to the exclude array** - -Open `vitest.config.ts` and replace the existing `exclude` block with: - -```typescript -exclude: [ - 'src/index.ts', - 'src/sinks/**', // I/O adapters — require live integration, no unit tests - 'src/commands/install.ts', // system-level operations — require OS privilege - 'src/commands/uninstall.ts', // system-level operations — require OS privilege - // Hard-ceiling: require live infrastructure, not unit-testable - 'src/mcp/device-history.ts', // MCP streaming protocol (live server required) - 'src/mcp/events-subscription.ts', // MCP event subscription (live server required) - 'src/mqtt/client.ts', // MQTT broker required - 'src/llm/providers/anthropic.ts', // Anthropic API key + live endpoint required - 'src/llm/providers/openai.ts', // OpenAI API key + live endpoint required -], -``` - -- [ ] **Step 2: Verify baseline moves from 79.49% to ~81%** - -Run: `npm run test -- --coverage --reporter=dot 2>&1 | grep "^All files"` - -Expected output contains: `All files | 81.` (the exclusion should push global above 81%) - -- [ ] **Step 3: Commit** - -```bash -git add vitest.config.ts -git commit -m "test: exclude hard-ceiling infrastructure files from coverage denominator" -``` - ---- - -## Task 2: plan.test.ts — plan-store mock + save/list/review/approve tests - -**Files:** -- Modify: `tests/commands/plan.test.ts` - -- [ ] **Step 1: Add plan-store mock at the top of the file (after existing mocks)** - -In `tests/commands/plan.test.ts`, directly after the existing `vi.mock('../../src/devices/cache.js', ...)` block, add: - -```typescript -const planStoreMock = vi.hoisted(() => ({ - savePlanRecord: vi.fn(), - loadPlanRecord: vi.fn(() => null), - updatePlanRecord: vi.fn(), - listPlanRecords: vi.fn(() => []), - PLANS_DIR: '/mock/.switchbot/plans', -})); - -vi.mock('../../src/lib/plan-store.js', () => ({ - savePlanRecord: planStoreMock.savePlanRecord, - loadPlanRecord: planStoreMock.loadPlanRecord, - updatePlanRecord: planStoreMock.updatePlanRecord, - listPlanRecords: planStoreMock.listPlanRecords, - PLANS_DIR: planStoreMock.PLANS_DIR, -})); -``` - -- [ ] **Step 2: Reset plan-store mocks in the existing beforeEach** - -Inside the existing `beforeEach` in `describe('plan command', ...)`, append: - -```typescript -planStoreMock.savePlanRecord.mockReset(); -planStoreMock.loadPlanRecord.mockReset().mockReturnValue(null); -planStoreMock.updatePlanRecord.mockReset(); -planStoreMock.listPlanRecords.mockReset().mockReturnValue([]); -``` - -- [ ] **Step 3: Write failing tests for plan save/list/review/approve** - -Add a new `describe('plan save / list / review / approve', ...)` block at the end of `describe('plan command', ...)`: - -```typescript -describe('plan save / list / review / approve', () => { - const MOCK_ID = '00000000-0000-4000-8000-000000000001'; - - it('plan save writes a valid plan and prints planId', async () => { - planStoreMock.savePlanRecord.mockReturnValue({ - planId: MOCK_ID, - status: 'pending', - createdAt: '2024-01-01T00:00:00Z', - plan: { version: '1.0', steps: [] }, - }); - const file = writePlan({ version: '1.0', steps: [] }); - const res = await runCli(registerPlanCommand, ['plan', 'save', file]); - expect(res.exitCode).toBeNull(); - expect(res.stdout.join('\n')).toContain(MOCK_ID); - expect(planStoreMock.savePlanRecord).toHaveBeenCalledOnce(); - }); - - it('plan save --json returns saved:true with planId', async () => { - planStoreMock.savePlanRecord.mockReturnValue({ - planId: MOCK_ID, - status: 'pending', - createdAt: '2024-01-01T00:00:00Z', - plan: { version: '1.0', steps: [] }, - }); - const file = writePlan({ version: '1.0', steps: [] }); - const res = await runCli(registerPlanCommand, ['--json', 'plan', 'save', file]); - expect(res.exitCode).toBeNull(); - const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')) as Record; - const data = expectJsonEnvelopeContainingKeys(out, ['saved', 'planId']) as { saved: boolean; planId: string }; - expect(data.saved).toBe(true); - expect(data.planId).toBe(MOCK_ID); - }); - - it('plan list prints each plan on its own line', async () => { - planStoreMock.listPlanRecords.mockReturnValue([ - { planId: MOCK_ID, status: 'pending', createdAt: '2024-01-01T00:00:00Z', plan: { version: '1.0', steps: [] } }, - ]); - const res = await runCli(registerPlanCommand, ['plan', 'list']); - expect(res.exitCode).toBeNull(); - expect(res.stdout.join('\n')).toContain(MOCK_ID.slice(0, 8)); - }); - - it('plan list prints helpful message when no plans exist', async () => { - planStoreMock.listPlanRecords.mockReturnValue([]); - const res = await runCli(registerPlanCommand, ['plan', 'list']); - expect(res.exitCode).toBeNull(); - expect(res.stdout.join('\n')).toMatch(/no saved plans/i); - }); - - it('plan list --json returns plans array', async () => { - planStoreMock.listPlanRecords.mockReturnValue([ - { planId: MOCK_ID, status: 'pending', createdAt: '2024-01-01T00:00:00Z', plan: { version: '1.0', steps: [] } }, - ]); - const res = await runCli(registerPlanCommand, ['--json', 'plan', 'list']); - expect(res.exitCode).toBeNull(); - const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')) as Record; - const data = expectJsonEnvelopeContainingKeys(out, ['plans']) as { plans: Array<{ planId: string }> }; - expect(data.plans[0].planId).toBe(MOCK_ID); - }); - - it('plan review prints plan details', async () => { - planStoreMock.loadPlanRecord.mockReturnValue({ - planId: MOCK_ID, - status: 'pending', - createdAt: '2024-01-01T00:00:00Z', - plan: { version: '1.0', description: 'turn on lights', steps: [{ type: 'command', deviceId: 'D1', command: 'turnOn' }] }, - }); - const res = await runCli(registerPlanCommand, ['plan', 'review', MOCK_ID]); - expect(res.exitCode).toBeNull(); - const out = res.stdout.join('\n'); - expect(out).toContain(MOCK_ID); - expect(out).toContain('pending'); - expect(out).toContain('turn on lights'); - }); - - it('plan review exits 2 when planId not found', async () => { - planStoreMock.loadPlanRecord.mockReturnValue(null); - const res = await runCli(registerPlanCommand, ['plan', 'review', MOCK_ID]); - expect(res.exitCode).toBe(2); - }); - - it('plan approve transitions pending to approved', async () => { - planStoreMock.loadPlanRecord.mockReturnValue({ - planId: MOCK_ID, status: 'pending', createdAt: '2024-01-01T00:00:00Z', plan: { version: '1.0', steps: [] }, - }); - planStoreMock.updatePlanRecord.mockReturnValue({ - planId: MOCK_ID, status: 'approved', createdAt: '2024-01-01T00:00:00Z', approvedAt: '2024-01-01T01:00:00Z', plan: { version: '1.0', steps: [] }, - }); - const res = await runCli(registerPlanCommand, ['plan', 'approve', MOCK_ID]); - expect(res.exitCode).toBeNull(); - expect(planStoreMock.updatePlanRecord).toHaveBeenCalledWith(MOCK_ID, expect.objectContaining({ status: 'approved' })); - expect(res.stdout.join('\n')).toMatch(/approved/i); - }); - - it('plan approve exits 2 when plan is already executed', async () => { - planStoreMock.loadPlanRecord.mockReturnValue({ - planId: MOCK_ID, status: 'executed', createdAt: '2024-01-01T00:00:00Z', plan: { version: '1.0', steps: [] }, - }); - const res = await runCli(registerPlanCommand, ['plan', 'approve', MOCK_ID]); - expect(res.exitCode).toBe(2); - expect(res.stderr.join('\n')).toMatch(/already been executed/i); - }); - - it('plan approve exits 2 when plan was rejected', async () => { - planStoreMock.loadPlanRecord.mockReturnValue({ - planId: MOCK_ID, status: 'rejected', createdAt: '2024-01-01T00:00:00Z', plan: { version: '1.0', steps: [] }, - }); - const res = await runCli(registerPlanCommand, ['plan', 'approve', MOCK_ID]); - expect(res.exitCode).toBe(2); - expect(res.stderr.join('\n')).toMatch(/rejected/i); - }); - - it('plan approve --json returns ok:true', async () => { - planStoreMock.loadPlanRecord.mockReturnValue({ - planId: MOCK_ID, status: 'pending', createdAt: '2024-01-01T00:00:00Z', plan: { version: '1.0', steps: [] }, - }); - planStoreMock.updatePlanRecord.mockReturnValue({ - planId: MOCK_ID, status: 'approved', createdAt: '2024-01-01T00:00:00Z', approvedAt: '2024-01-01T01:00:00Z', plan: { version: '1.0', steps: [] }, - }); - const res = await runCli(registerPlanCommand, ['--json', 'plan', 'approve', MOCK_ID]); - expect(res.exitCode).toBeNull(); - const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')) as Record; - const data = expectJsonEnvelopeContainingKeys(out, ['ok', 'planId', 'status']) as { ok: boolean }; - expect(data.ok).toBe(true); - }); -}); -``` - -- [ ] **Step 4: Run new tests to verify they pass** - -Run: `npm run test -- tests/commands/plan.test.ts --reporter=verbose 2>&1 | tail -30` - -Expected: All new tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add tests/commands/plan.test.ts -git commit -m "test: add plan save/list/review/approve coverage with plan-store mock" -``` - ---- - -## Task 3: plan.test.ts — plan execute tests - -**Files:** -- Modify: `tests/commands/plan.test.ts` - -- [ ] **Step 1: Write failing tests for plan execute** - -Add a new `describe('plan execute', ...)` block inside `describe('plan command', ...)` (after the `plan save / list / review / approve` block): - -```typescript -describe('plan execute', () => { - const MOCK_ID = '00000000-0000-4000-8000-000000000002'; - - it('executes an approved plan and marks it executed', async () => { - planStoreMock.loadPlanRecord.mockReturnValue({ - planId: MOCK_ID, - status: 'approved', - createdAt: '2024-01-01T00:00:00Z', - approvedAt: '2024-01-01T01:00:00Z', - plan: { version: '1.0', steps: [{ type: 'command', deviceId: 'BOT1', command: 'turnOn' }] }, - }); - planStoreMock.updatePlanRecord.mockReturnValue({ - planId: MOCK_ID, status: 'executed', createdAt: '2024-01-01T00:00:00Z', plan: { version: '1.0', steps: [] }, - }); - apiMock.__instance.post.mockResolvedValue({ data: { statusCode: 100, body: {} } }); - - const res = await runCli(registerPlanCommand, ['plan', 'execute', MOCK_ID]); - expect(res.exitCode).toBeNull(); - expect(planStoreMock.updatePlanRecord).toHaveBeenCalledWith( - MOCK_ID, - expect.objectContaining({ status: 'executed' }), - ); - }); - - it('exits 2 when plan is not in approved status', async () => { - planStoreMock.loadPlanRecord.mockReturnValue({ - planId: MOCK_ID, status: 'pending', createdAt: '2024-01-01T00:00:00Z', plan: { version: '1.0', steps: [] }, - }); - const res = await runCli(registerPlanCommand, ['plan', 'execute', MOCK_ID]); - expect(res.exitCode).toBe(2); - expect(res.stderr.join('\n')).toMatch(/pending/i); - }); - - it('exits 2 when plan is not found', async () => { - planStoreMock.loadPlanRecord.mockReturnValue(null); - const res = await runCli(registerPlanCommand, ['plan', 'execute', MOCK_ID]); - expect(res.exitCode).toBe(2); - }); - - it('marks plan as failed when execution errors', async () => { - planStoreMock.loadPlanRecord.mockReturnValue({ - planId: MOCK_ID, - status: 'approved', - createdAt: '2024-01-01T00:00:00Z', - approvedAt: '2024-01-01T01:00:00Z', - plan: { version: '1.0', steps: [{ type: 'command', deviceId: 'BOT1', command: 'turnOn' }] }, - }); - planStoreMock.updatePlanRecord.mockReturnValue({ - planId: MOCK_ID, status: 'failed', createdAt: '2024-01-01T00:00:00Z', plan: { version: '1.0', steps: [] }, - }); - apiMock.__instance.post.mockRejectedValue(new Error('network error')); - - const res = await runCli(registerPlanCommand, ['plan', 'execute', MOCK_ID]); - expect(res.exitCode).toBe(1); - expect(planStoreMock.updatePlanRecord).toHaveBeenCalledWith( - MOCK_ID, - expect.objectContaining({ status: 'failed' }), - ); - }); - - it('plan execute --json returns ran:true on success', async () => { - planStoreMock.loadPlanRecord.mockReturnValue({ - planId: MOCK_ID, - status: 'approved', - createdAt: '2024-01-01T00:00:00Z', - approvedAt: '2024-01-01T01:00:00Z', - plan: { version: '1.0', steps: [{ type: 'command', deviceId: 'BOT1', command: 'turnOn' }] }, - }); - planStoreMock.updatePlanRecord.mockReturnValue({ - planId: MOCK_ID, status: 'executed', createdAt: '2024-01-01T00:00:00Z', plan: { version: '1.0', steps: [] }, - }); - apiMock.__instance.post.mockResolvedValue({ data: { statusCode: 100, body: {} } }); - - const res = await runCli(registerPlanCommand, ['--json', 'plan', 'execute', MOCK_ID]); - expect(res.exitCode).toBeNull(); - const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')) as Record; - const data = expectJsonEnvelopeContainingKeys(out, ['ran', 'planId', 'succeeded']) as { ran: boolean; succeeded: boolean }; - expect(data.ran).toBe(true); - expect(data.succeeded).toBe(true); - }); -}); -``` - -- [ ] **Step 2: Run new tests to verify they pass** - -Run: `npm run test -- tests/commands/plan.test.ts --reporter=verbose 2>&1 | tail -20` - -Expected: All new tests PASS. - -- [ ] **Step 3: Commit** - -```bash -git add tests/commands/plan.test.ts -git commit -m "test: add plan execute coverage" -``` - ---- - -## Task 4: doctor.test.ts — --fix output test (lines 1316–1323) - -**Files:** -- Modify: `tests/commands/doctor.test.ts` - -- [ ] **Step 1: Write failing test for --fix output** - -Add inside the existing `describe('doctor command', ...)` block: - -```typescript -it('--fix prints a Fixes: section with remediation entries', async () => { - // credentials check fails when no token/secret env vars are set (already - // cleared in beforeEach). Running --fix without --yes returns a "manual" - // or "pass --yes" fix entry, which exercises the fixes loop at lines 1316-1323. - delete process.env.SWITCHBOT_TOKEN; - delete process.env.SWITCHBOT_SECRET; - const res = await runCli(registerDoctorCommand, [ - 'doctor', '--section', 'credentials', '--fix', - ]); - const combined = res.stdout.join('\n'); - expect(combined).toContain('Fixes:'); - expect(combined).toMatch(/credentials/); -}); -``` - -- [ ] **Step 2: Run the test to verify it passes** - -Run: `npm run test -- tests/commands/doctor.test.ts --reporter=verbose 2>&1 | grep -E "✓|✗|FAIL|PASS|fixes"` - -Expected: New test PASSES. - -- [ ] **Step 3: Commit** - -```bash -git add tests/commands/doctor.test.ts -git commit -m "test: cover doctor --fix output path (lines 1316-1323)" -``` - ---- - -## Task 5: rules.test.ts — human-mode with data + trace-explain not-found - -**Files:** -- Modify: `tests/commands/rules.test.ts` - -- [ ] **Step 1: Add summary human-mode with data test** - -Inside `describe('rules summary', ...)` (after existing tests), add: - -```typescript -it('prints a table in human mode when entries exist', async () => { - const f = path.join(tmpDir, 'audit-human.log'); - const now = new Date().toISOString(); - fs.writeFileSync( - f, - [ - { t: now, kind: 'rule-fire', rule: { name: 'motion rule', triggerSource: 'mqtt', fireId: 'f1' }, result: 'ok', deviceId: 'D1', command: 'turnOn', parameter: null, commandType: 'command', dryRun: false }, - { t: now, kind: 'rule-fire', rule: { name: 'motion rule', triggerSource: 'mqtt', fireId: 'f2' }, result: 'ok', deviceId: 'D1', command: 'turnOn', parameter: null, commandType: 'command', dryRun: false }, - ] - .map((r) => JSON.stringify(r)) - .join('\n') + '\n', - ); - const { stdout, exitCode } = await runCli(['rules', 'summary', '--file', f]); - expect(exitCode).toBe(0); - const out = stdout.join('\n'); - expect(out).toContain('motion rule'); - expect(out).toContain('2'); // fires count -}); -``` - -- [ ] **Step 2: Add last-fired human-mode with data test** - -Inside `describe('rules last-fired', ...)` (after existing tests), add: - -```typescript -it('prints human-readable rows when entries exist', async () => { - const f = path.join(tmpDir, 'audit-lfhuman.log'); - const ts = '2026-04-25T10:00:00.000Z'; - fs.writeFileSync( - f, - JSON.stringify({ - t: ts, kind: 'rule-fire', rule: { name: 'night-motion', triggerSource: 'mqtt', fireId: 'f1' }, - result: 'ok', deviceId: 'LAMP', command: 'turnOn', parameter: null, commandType: 'command', dryRun: false, - }) + '\n', - ); - const { stdout, exitCode } = await runCli(['rules', 'last-fired', '--file', f]); - expect(exitCode).toBe(0); - const out = stdout.join('\n'); - expect(out).toContain('night-motion'); - expect(out).toContain(ts); -}); -``` - -- [ ] **Step 3: Add trace-explain audit-not-found test** - -At the end of `describe('switchbot rules (commander surface)', ...)`, add: - -```typescript -describe('rules trace-explain', () => { - it('exits 1 when audit log file does not exist', async () => { - const { exitCode, stderr } = await runCli([ - 'rules', 'trace-explain', '--file', path.join(tmpDir, 'no-such-audit.log'), - ]); - expect(exitCode).toBe(1); - expect(stderr.join('\n')).toMatch(/not found/i); - }); -}); -``` - -- [ ] **Step 4: Run new tests to verify they pass** - -Run: `npm run test -- tests/commands/rules.test.ts --reporter=verbose 2>&1 | tail -20` - -Expected: All 3 new tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add tests/commands/rules.test.ts -git commit -m "test: cover rules summary/last-fired human-mode with data, trace-explain not-found" -``` - ---- - -## Task 6: Create tests/lib/daemon-state.test.ts - -**Files:** -- Create: `tests/lib/daemon-state.test.ts` - -- [ ] **Step 1: Write the full test file** - -```typescript -import { describe, it, expect, vi, afterEach } from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { - writeDaemonState, - readDaemonState, - removeDaemonState, - type DaemonState, -} from '../../src/lib/daemon-state.js'; - -describe('daemon-state', () => { - let tmp: string; - let homedirSpy: ReturnType; - - beforeEach(() => { - tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-daemon-state-')); - homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp); - }); - - afterEach(() => { - homedirSpy.mockRestore(); - fs.rmSync(tmp, { recursive: true, force: true }); - }); - - const SAMPLE: DaemonState = { - status: 'running', - pid: 12345, - startedAt: '2024-01-01T00:00:00Z', - logFile: '/tmp/daemon.log', - pidFile: '/tmp/daemon.pid', - stateFile: '/tmp/daemon.state.json', - }; - - it('writeDaemonState creates the state file under ~/.switchbot', () => { - writeDaemonState(SAMPLE); - const stateFile = path.join(tmp, '.switchbot', 'daemon.state.json'); - expect(fs.existsSync(stateFile)).toBe(true); - const parsed = JSON.parse(fs.readFileSync(stateFile, 'utf-8')) as DaemonState; - expect(parsed.status).toBe('running'); - expect(parsed.pid).toBe(12345); - }); - - it('readDaemonState returns the persisted state', () => { - writeDaemonState(SAMPLE); - const result = readDaemonState(); - expect(result).not.toBeNull(); - expect(result!.status).toBe('running'); - expect(result!.pid).toBe(12345); - }); - - it('readDaemonState returns null when no state file exists', () => { - const result = readDaemonState(); - expect(result).toBeNull(); - }); - - it('removeDaemonState deletes the state file', () => { - writeDaemonState(SAMPLE); - const stateFile = path.join(tmp, '.switchbot', 'daemon.state.json'); - expect(fs.existsSync(stateFile)).toBe(true); - removeDaemonState(); - expect(fs.existsSync(stateFile)).toBe(false); - }); - - it('removeDaemonState is a no-op when the state file does not exist', () => { - expect(() => removeDaemonState()).not.toThrow(); - }); - - it('writeDaemonState creates the .switchbot directory if absent', () => { - const switchbotDir = path.join(tmp, '.switchbot'); - expect(fs.existsSync(switchbotDir)).toBe(false); - writeDaemonState(SAMPLE); - expect(fs.existsSync(switchbotDir)).toBe(true); - }); -}); -``` - -- [ ] **Step 2: Run the test file to verify it passes** - -Run: `npm run test -- tests/lib/daemon-state.test.ts --reporter=verbose 2>&1 | tail -15` - -Expected: All 6 tests PASS. - -- [ ] **Step 3: Commit** - -```bash -git add tests/lib/daemon-state.test.ts -git commit -m "test: add daemon-state read/write/remove coverage" -``` - ---- - -## Task 7: Create tests/lib/daemon-socket-path.test.ts - -**Files:** -- Create: `tests/lib/daemon-socket-path.test.ts` - -Note: `getCurrentUserKey()` has a module-level cache (`cachedUserKey`). Tests that need a fresh cache must run in the same describe block before the first platform test sets the cache, or they must reset the env vars to yield a consistent key. - -- [ ] **Step 1: Write the full test file** - -```typescript -import { describe, it, expect, vi, afterEach } from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; - -describe('getDaemonSocketPath', () => { - let savedDescriptor: PropertyDescriptor | undefined; - - afterEach(() => { - if (savedDescriptor) { - Object.defineProperty(process, 'platform', savedDescriptor); - savedDescriptor = undefined; - } - }); - - it('returns a named pipe path on win32', async () => { - savedDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); - Object.defineProperty(process, 'platform', { value: 'win32', configurable: true, writable: false }); - const { getDaemonSocketPath } = await import('../../src/daemon/socket-path.js'); - const p = getDaemonSocketPath(); - expect(p).toMatch(/^\\\\\.\\/); - expect(p).toContain('switchbot-daemon-'); - }); - - it('returns a POSIX socket path on linux', async () => { - savedDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); - Object.defineProperty(process, 'platform', { value: 'linux', configurable: true, writable: false }); - const homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue('/home/testuser'); - try { - const { getDaemonSocketPath } = await import('../../src/daemon/socket-path.js'); - const p = getDaemonSocketPath(); - expect(p).toBe(path.join('/home/testuser', '.switchbot', 'daemon.sock')); - } finally { - homedirSpy.mockRestore(); - } - }); -}); - -describe('isDaemonSocketAvailable', () => { - let savedDescriptor: PropertyDescriptor | undefined; - - afterEach(() => { - if (savedDescriptor) { - Object.defineProperty(process, 'platform', savedDescriptor); - savedDescriptor = undefined; - } - vi.restoreAllMocks(); - }); - - it('always returns true on win32', async () => { - savedDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); - Object.defineProperty(process, 'platform', { value: 'win32', configurable: true, writable: false }); - const { isDaemonSocketAvailable } = await import('../../src/daemon/socket-path.js'); - expect(isDaemonSocketAvailable('/any/path')).toBe(true); - }); - - it('returns true on POSIX when socket file exists', async () => { - savedDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); - Object.defineProperty(process, 'platform', { value: 'linux', configurable: true, writable: false }); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - const { isDaemonSocketAvailable } = await import('../../src/daemon/socket-path.js'); - expect(isDaemonSocketAvailable('/some/daemon.sock')).toBe(true); - }); - - it('returns false on POSIX when socket file does not exist', async () => { - savedDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); - Object.defineProperty(process, 'platform', { value: 'linux', configurable: true, writable: false }); - vi.spyOn(fs, 'existsSync').mockReturnValue(false); - const { isDaemonSocketAvailable } = await import('../../src/daemon/socket-path.js'); - expect(isDaemonSocketAvailable('/some/daemon.sock')).toBe(false); - }); -}); -``` - -- [ ] **Step 2: Run the test file to verify it passes** - -Run: `npm run test -- tests/lib/daemon-socket-path.test.ts --reporter=verbose 2>&1 | tail -15` - -Expected: All 5 tests PASS. (Note: dynamic `await import()` within tests re-reads the module per test, so platform checks are evaluated fresh each time.) - -- [ ] **Step 3: Commit** - -```bash -git add tests/lib/daemon-socket-path.test.ts -git commit -m "test: add daemon socket-path win32/POSIX coverage" -``` - ---- - -## Task 8: config.test.ts — --label / --daily-cap / --default-flags options - -**Files:** -- Modify: `tests/commands/config.test.ts` - -- [ ] **Step 1: Add tests for saveConfig option flags** - -Inside `describe('set-token', ...)` (after existing tests), add: - -```typescript -it('passes --label to saveConfig', async () => { - const res = await runCli(registerConfigCommand, [ - 'config', 'set-token', 'MY_T', 'MY_S', '--label', 'home', - ]); - expect(configMock.saveConfig).toHaveBeenCalledWith( - 'MY_T', 'MY_S', - expect.objectContaining({ label: 'home' }), - ); - expect(res.exitCode).toBeNull(); -}); - -it('passes --daily-cap as a numeric limit to saveConfig', async () => { - const res = await runCli(registerConfigCommand, [ - 'config', 'set-token', 'MY_T', 'MY_S', '--daily-cap', '200', - ]); - expect(configMock.saveConfig).toHaveBeenCalledWith( - 'MY_T', 'MY_S', - expect.objectContaining({ limits: { dailyCap: 200 } }), - ); - expect(res.exitCode).toBeNull(); -}); - -it('passes --default-flags as a split array to saveConfig', async () => { - const res = await runCli(registerConfigCommand, [ - 'config', 'set-token', 'MY_T', 'MY_S', '--default-flags', '--json,--verbose', - ]); - expect(configMock.saveConfig).toHaveBeenCalledWith( - 'MY_T', 'MY_S', - expect.objectContaining({ defaults: { flags: ['--json', '--verbose'] } }), - ); - expect(res.exitCode).toBeNull(); -}); -``` - -- [ ] **Step 2: Run tests to verify they pass** - -Run: `npm run test -- tests/commands/config.test.ts --reporter=verbose 2>&1 | tail -20` - -Expected: All new tests PASS. - -- [ ] **Step 3: Commit** - -```bash -git add tests/commands/config.test.ts -git commit -m "test: cover set-token --label/--daily-cap/--default-flags option paths" -``` - ---- - -## Task 9: auth.test.ts — config parse-error path (lines 312–325) - -**Files:** -- Modify: `tests/commands/auth.test.ts` - -- [ ] **Step 1: Add parse-error tests inside the existing migrate describe block** - -Add these tests inside `describe('auth keychain migrate', ...)`: - -```typescript -it('exits 1 when source config.json contains invalid JSON', async () => { - const file = path.join(tmpHome, '.switchbot', 'config.json'); - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, 'THIS IS NOT JSON'); - const store = makeStore({ writable: true }); - selectMock.mockResolvedValue(store); - const res = await runCli(['auth', 'keychain', 'migrate']); - expect(res.exitCode).toBe(1); - expect(res.stderr.join('\n')).toMatch(/failed to parse/i); -}); - -it('exits 1 when source config.json contains a non-object (array)', async () => { - const file = path.join(tmpHome, '.switchbot', 'config.json'); - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, JSON.stringify([1, 2, 3])); - const store = makeStore({ writable: true }); - selectMock.mockResolvedValue(store); - const res = await runCli(['auth', 'keychain', 'migrate']); - expect(res.exitCode).toBe(1); - expect(res.stderr.join('\n')).toMatch(/failed to parse/i); -}); -``` - -Note: `runCli` in auth.test.ts is the file-local helper (not the shared one from cli.ts). Verify how `runCli` is defined in auth.test.ts — it should accept `['auth', 'keychain', 'migrate']` directly. - -- [ ] **Step 2: Run the tests to verify they pass** - -Run: `npm run test -- tests/commands/auth.test.ts --reporter=verbose 2>&1 | tail -20` - -Expected: Both new tests PASS. - -- [ ] **Step 3: Commit** - -```bash -git add tests/commands/auth.test.ts -git commit -m "test: cover auth migrate config parse-error path" -``` - ---- - -## Task 10: preflight.test.ts — .switchbot dir writable path + nearestExistingPath null - -**Files:** -- Modify: `tests/install/preflight.test.ts` - -- [ ] **Step 1: Add tests for uncovered preflight paths** - -Add these tests inside `describe('runPreflight', ...)`: - -```typescript -it('home check reports ok when ~/.switchbot already exists and is writable', async () => { - const switchbotDir = path.join(tmp, '.switchbot'); - fs.mkdirSync(switchbotDir, { recursive: true }); - const res = await runPreflight(); - const home = res.checks.find((c) => c.name === 'home'); - expect(home?.status).toBe('ok'); - expect(home?.message).toContain(switchbotDir); -}); - -it('agent-skills-dir check is ok when ~/.claude/skills path ancestor is writable', async () => { - // Create ~/.claude so nearestExistingPath resolves to it. - const claudeDir = path.join(tmp, '.claude'); - fs.mkdirSync(claudeDir, { recursive: true }); - const res = await runPreflight({ agent: 'claude-code', expectSkillLink: true }); - const agent = res.checks.find((c) => c.name === 'agent-skills-dir'); - expect(agent?.status).toBe('ok'); -}); - -it('agent-skills-dir check fails when no ancestor of ~/.claude/skills exists', async () => { - // tmp dir does not have ~/.claude, so nearestExistingPath stops at tmp itself - // or returns null if tmp doesn't exist — but tmp is always real. In practice, - // nearestExistingPath will walk up to tmp which is a real dir and is writable, - // so this returns 'ok'. To force the null/not-dir path: mock fs.existsSync to - // always return false so nearestExistingPath returns null. - const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(false); - try { - const res = await runPreflight({ agent: 'claude-code', expectSkillLink: true }); - const agent = res.checks.find((c) => c.name === 'agent-skills-dir'); - expect(agent?.status).toBe('fail'); - expect(agent?.message).toMatch(/cannot resolve/i); - } finally { - existsSpy.mockRestore(); - } -}); -``` - -- [ ] **Step 2: Run the tests to verify they pass** - -Run: `npm run test -- tests/install/preflight.test.ts --reporter=verbose 2>&1 | tail -20` - -Expected: All 3 new tests PASS. - -- [ ] **Step 3: Commit** - -```bash -git add tests/install/preflight.test.ts -git commit -m "test: add preflight .switchbot dir writable + agent-skills-dir null-ancestor paths" -``` - ---- - -## Task 11: Lock thresholds + create coverage annotations doc - -**Files:** -- Modify: `vitest.config.ts` -- Create: `docs/coverage-annotations.md` - -- [ ] **Step 1: Run full coverage to see actual numbers** - -Run: `npm run test -- --coverage --reporter=dot 2>&1 | grep -E "^All files|src/commands"` - -Note the actual line/branch percentages to set the thresholds below them. - -- [ ] **Step 2: Update thresholds in vitest.config.ts** - -Replace the `thresholds` block with values 1-2 points below the actual measurements. Example (adjust to actuals): - -```typescript -// Thresholds locked to post-2026-05-17 backfill actuals. -// Hard ceiling: see docs/coverage-annotations.md for excluded + structurally untestable files. -thresholds: { - lines: 88, - branches: 87, - 'src/commands/**': { - lines: 80, - branches: 78, - }, -}, -``` - -If actual numbers are lower, set thresholds to actual − 1. If higher, set to actual − 1 for safety. - -- [ ] **Step 3: Run tests to confirm no threshold failures** - -Run: `npm run test -- --coverage --reporter=dot 2>&1 | tail -5` - -Expected: Exit 0, no "Coverage threshold not met" errors. - -- [ ] **Step 4: Create docs/coverage-annotations.md** - -```markdown -# Coverage Annotations - -This file documents why certain source files are excluded from the coverage -denominator, and which in-scope sections remain structurally untestable. - -## Hard-excluded from coverage denominator - -These files are in `vitest.config.ts` `coverage.exclude` because they require -live external infrastructure that cannot be mocked at unit-test level: - -| File | Reason | -|------|--------| -| `src/mcp/device-history.ts` | MCP streaming protocol — requires live MCP server | -| `src/mcp/events-subscription.ts` | MCP event subscription — requires live MCP server | -| `src/mqtt/client.ts` | MQTT broker required; class constructor immediately connects | -| `src/llm/providers/anthropic.ts` | Anthropic API key + live HTTPS endpoint required | -| `src/llm/providers/openai.ts` | OpenAI API key + live HTTPS endpoint required | - -## In-denominator but structurally untestable sections - -These sections remain in the coverage denominator but cannot be covered by -unit tests. They are accepted as permanent gaps. - -| File | Lines / Area | Reason | -|------|-------------|--------| -| `src/commands/mcp.ts` | ~2364–2633 | MCP tool-call / resource protocol handlers — live MCP client required | -| `src/commands/rules.ts` | 800–985, 1001–1081 | `simulate` and `trace-explain` subcommands — full rules engine + LLM required | -| `src/status-sync/manager.ts` | WebSocket push path | Live SwitchBot WebSocket connection required | -| `src/policy/migrate.ts` | lines 21–52 | `MIGRATION_CHAIN` is empty; migration step functions exist but are unreachable until v0.3 schema lands | -``` - -- [ ] **Step 5: Commit** - -```bash -git add vitest.config.ts docs/coverage-annotations.md -git commit -m "test: lock coverage thresholds to post-backfill actuals; add coverage-annotations doc" -``` - ---- - -## Self-Review Checklist - -- [x] **Spec coverage:** All 14 deliverables from the spec have a corresponding task -- [x] **No placeholders:** Every step includes the actual code/command -- [x] **Type consistency:** All mock return types match the interfaces imported from source files (`DaemonState`, `PlanRecord`, `PlanStatus`) -- [x] **Mock isolation:** Plan-store mock is declared with `vi.hoisted` and resets in `beforeEach` — won't leak into existing plan tests -- [x] **Task independence:** Tasks 2–10 are independent (can run in any order after Task 1) -- [x] Note: `daemon-socket-path.test.ts` uses dynamic `await import()` per test to re-evaluate the module against the mocked `process.platform`. This is the correct pattern but may log ESM re-import warnings — these are harmless. diff --git a/docs/specs/2026-05-16-test-coverage-improvement-design.md b/docs/specs/2026-05-16-test-coverage-improvement-design.md deleted file mode 100644 index 3f0452d2..00000000 --- a/docs/specs/2026-05-16-test-coverage-improvement-design.md +++ /dev/null @@ -1,94 +0,0 @@ -# Test Coverage Improvement Design - -**Date:** 2026-05-16 -**Branch:** fix/v3.6.2-bugs -**Status:** Approved - -## Problem - -Five UX bugs shipped in v3.6.2 that existing tests did not catch. Root cause analysis identified three structural gaps — not a lack of test files, but incomplete coverage within existing test files: - -1. **Partial exit-code coverage** — `process.exit(1|2)` calls in `src/commands/` have multiple branches; tests only exercised the most common path per command, leaving secondary branches untested. -2. **New options without tests** — `--devices` (plural alias) was added to `plan suggest` with no accompanying smoke test. -3. **Output text not asserted** — Tests verified command structure but not the content of user-visible message lines (e.g., `Remaining budget`, MCP tool count context, completion format values). - -Baseline coverage (2026-05-16): **lines 77.66%, branches 79.7%** overall; `src/sinks/**` drags the average down due to external-infrastructure dependencies. - -## Solution: Three-Layer Defence - -### Layer 1 — Backfill 5 Missing Test Cases - -Add one test per confirmed gap, directly in the existing test files. No new files. - -| File | Test name | Assertion | -|---|---|---| -| `tests/commands/plan.test.ts` | `plan suggest exits 2 when no --device given` | `exitCode === 2`; stderr contains "at least one --device" | -| `tests/commands/plan.test.ts` | `plan suggest accepts --devices as alias for --device` | `exitCode === 0`; stdout contains the deviceId | -| `tests/commands/doctor.test.ts` | `mcp check message includes default profile context` | `detail.message` contains "default profile" and "24 in 'all'" | -| `tests/commands/quota.test.ts` | `status human output includes Remaining budget line` | stdout contains "Remaining budget" | -| `tests/commands/completion.test.ts` | `bash completion includes all --format values` | stdout contains `format_vals` and each of the 7 format keywords | - -### Layer 2 — TESTING.md Convention - -Create `TESTING.md` at the project root. Three rules, each mapping directly to a root cause: - -**Rule 1: Every `process.exit` path needs its own test** -Every `process.exit(1)` or `process.exit(2)` in `src/commands/` must have at least one test case in `tests/commands/` that reaches that path and asserts `exitCode`. New branches must be tested in the same commit. - -**Rule 2: Every new CLI option/alias needs a smoke test** -Every new `.option()` declaration (including aliases) must have at least one test that uses the flag and asserts it is parsed or produces the expected behavior. - -**Rule 3: Non-trivial user-visible messages need keyword assertions** -Any `console.log/error` line containing business-semantic keywords (a word whose removal would confuse the user) must have a corresponding test asserting that keyword appears in stdout/stderr. - -**PR Checklist** (to be added at the end of TESTING.md): -``` -Before merging, verify: -- [ ] New process.exit path → corresponding test added -- [ ] New CLI option/alias → smoke test added -- [ ] Changed user-visible message → keyword assertion updated -``` - -### Layer 3 — Layered Coverage Threshold in vitest.config.ts - -```ts -coverage: { - provider: 'v8', - include: ['src/**/*.ts'], - exclude: ['src/index.ts', 'src/sinks/**'], - reporter: ['text', 'html'], - thresholds: { - lines: 80, // global: +2pp above current baseline - branches: 80, // global: +0.3pp above current baseline - '**': { - branches: 75, // per-file floor: prevents single-file collapse - }, - 'src/commands/**': { - lines: 85, // command layer: higher bar, where bugs occur - branches: 80, - }, - }, -}, -``` - -**Exclusion rationale:** `src/sinks/**` (homeassistant, telegram, openclaw, etc.) requires live external infrastructure and has ~5% coverage today. Including it in thresholds would cause constant false failures unrelated to code quality. - -**Threshold rationale:** After the 5 backfill tests are added, `src/commands/**` is expected to clear 85% lines / 80% branches. The global 80% threshold is set 2–3pp above current to block regression without requiring immediate fixes to unrelated low-coverage areas. - -## Files Changed - -| File | Change | -|---|---| -| `tests/commands/plan.test.ts` | +2 test cases | -| `tests/commands/doctor.test.ts` | +1 test case | -| `tests/commands/quota.test.ts` | +1 test case | -| `tests/commands/completion.test.ts` | +1 test case | -| `TESTING.md` | New file — testing conventions and PR checklist | -| `vitest.config.ts` | Add `thresholds` block; exclude `src/sinks/**` | - -## Success Criteria - -1. All 2465 existing tests continue to pass. -2. 5 new tests pass and cover the previously untested paths. -3. `npm run test -- --coverage` exits 0 with the new thresholds in place. -4. TESTING.md is present at project root and linked from the PR checklist. diff --git a/package-lock.json b/package-lock.json index 7809b30a..07dd5237 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "axios": "^1.7.9", "mqtt": "^5.3.0", + "open": "^10.2.0", "pino": "^9.0.0" }, "bin": { @@ -1618,6 +1619,21 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1982,6 +1998,46 @@ "node": ">=4.0.0" } }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2861,6 +2917,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2882,6 +2953,24 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -2889,6 +2978,21 @@ "dev": true, "license": "MIT" }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4017,6 +4121,24 @@ "wrappy": "1" } }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -4430,6 +4552,18 @@ "node": ">= 18" } }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/run-con": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/run-con/-/run-con-1.3.2.tgz", @@ -6391,6 +6525,21 @@ } } }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yaml": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", diff --git a/package.json b/package.json index 6734df60..a1716ac4 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "dependencies": { "axios": "^1.7.9", "mqtt": "^5.3.0", + "open": "^10.2.0", "pino": "^9.0.0" }, "devDependencies": { diff --git a/src/auth.ts b/src/auth.ts index 05a2b2da..bccda4de 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,5 +1,6 @@ import crypto from 'node:crypto'; import { v4 as uuidv4 } from 'uuid'; +import { VERSION } from './version.js'; export function buildAuthHeaders(token: string, secret: string): Record { const t = String(Date.now()); // 13-digit millisecond timestamp @@ -18,5 +19,6 @@ export function buildAuthHeaders(token: string, secret: string): Record void; +} + +/** + * Browser-based login flow. + * + * 1. Bind a one-shot local callback server. + * 2. Open sp.oauth.switchbot.net/login with client_id, redirect_uri, state. + * 3. User logs in (email/password or social — all handled by the hosted page). + * 4. Hosted page redirects back with code → exchange for credentials. + */ +export async function browserLogin(options: BrowserLoginOptions = {}): Promise { + const { + noOpen = false, + timeoutMs = LOGIN_TIMEOUT_MS, + log = console.log, + } = options; + + const state = generateState(); + const { port, wait } = await bindCallbackServer(state, timeoutMs); + const redirectUri = `http://127.0.0.1:${port}/callback`; + const loginUrl = buildLoginUrl({ redirectUri, state }); + + if (noOpen) { + log(`Open this URL in your browser to sign in:\n\n ${loginUrl}\n`); + } else { + log('Opening SwitchBot login page in your browser…'); + await open(loginUrl); + } + + log('Waiting for browser login to complete…'); + const deadline = Date.now() + timeoutMs; + const countdown = startCountdown(deadline); + try { + const { code } = await wait(); + countdown.stop(); + log('Exchanging authorization code for credentials…'); + return exchangeCodeForCredentials(code, redirectUri); + } catch (err) { + countdown.stop(); + throw err; + } +} + +function buildLoginUrl(params: { redirectUri: string; state: string }): string { + const url = new URL(SP_OAUTH_LOGIN_URL); + url.searchParams.set('client_id', OAUTH_CLIENT_ID); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', OAUTH_SCOPE); + url.searchParams.set('redirect_uri', params.redirectUri); + url.searchParams.set('state', params.state); + return url.toString(); +} + +function startCountdown(deadline: number): { stop(): void } { + if (!process.stderr.isTTY) return { stop() {} }; + + const write = (s: string) => process.stderr.write(s); + const tick = () => { + const remaining = Math.max(0, Math.ceil((deadline - Date.now()) / 1000)); + write(`\r ${remaining}s remaining… `); + }; + + tick(); + const id = setInterval(tick, 1000); + + return { + stop() { + clearInterval(id); + write('\r\x1b[K'); // erase countdown line + }, + }; +} diff --git a/src/auth/constants.ts b/src/auth/constants.ts new file mode 100644 index 00000000..0581eded --- /dev/null +++ b/src/auth/constants.ts @@ -0,0 +1,76 @@ +/** + * SwitchBot consumer app auth configuration. + * + * Email/password flow (customize-login page): + * 1. POST ACCOUNT_API_BASE/account/api/v2/user/login → access_token + * 2. POST WONDER_API_BASE/openapi/openUser/token { operation:"get", version:2 } + * → encrypted openToken + secretKey (AES-128-CBC, hex-encoded) + * + * Browser OAuth flow (sp.oauth.switchbot.net): + * 1. Open SP_OAUTH_LOGIN_URL with client_id, redirect_uri, scope, state → user logs in + * 2. SPA redirects back with code + * 3. POST ACCOUNT_API_BASE/merchant/v1/oauth/token → access_token + * 4. POST MOBILE_API_BASE/v2/mobile/management/login → plaintext openToken + secretKey + */ + +/** Direct consumer account API (customize-login page). */ +export const ACCOUNT_API_BASE = 'https://account.api.switchbot.net'; + +/** SwitchBot hosted OAuth login page. */ +export const SP_OAUTH_LOGIN_URL = 'https://sp.oauth.switchbot.net/login'; + +/** OAuth2 scope required by browser login. */ +export const OAUTH_SCOPE = 'api_login'; + +/** + * Consumer app client ID (from customize-login page). + * Used in the email/password login request body sent to account API. + */ +// client_id values are non-sensitive — publicly visible in any OAuth authorization URL +export const ACCOUNT_CLIENT_ID = 'emvg3hk2tqu3q37fcw6cwyl4bi'; + +/** + * Merchant OAuth2 client registered with sp.oauth.switchbot.net. + * Used as client_id when opening the hosted login page and exchanging codes. + */ +// client_id values are non-sensitive — publicly visible in any OAuth authorization URL +export const OAUTH_CLIENT_ID = 'wrZlijGQevZHVyGeINSQGUVEHw'; + +// Baked-in consumer app credentials from the SwitchBot mobile app. +// Override with SWITCHBOT_OAUTH_CLIENT_SECRET if you obtain fresh credentials +// from SwitchBot (the bundled value is embedded in the published open-source repo). +export const OAUTH_CLIENT_SECRET = + process.env.SWITCHBOT_OAUTH_CLIENT_SECRET ?? 'aFDbbDdGiUSGCgRbCvAHpMNokcQDnIDbDhaVYbWWpRaZxuuwugR'; + +/** Milliseconds the CLI waits for the user to complete browser login. */ +export const LOGIN_TIMEOUT_MS = 120_000; + +/** Fixed loopback port registered as redirect URI in the SwitchBot merchant OAuth client. */ +export const OAUTH_CALLBACK_PORT = 53245; + +// ── Mobile management API (OAuth fallback — plaintext token) ───────────────── + +export const MOBILE_API_BASE = 'https://wonderlabs.us.api.switchbot.net/homepage'; + +// ── Wonder OpenAPI (email/password flow — encrypted token) ─────────────────── + +export const WONDER_API_BASE = 'https://wonderlabs.us.api.switchbot.net/wonder'; + +// AES-128-CBC key/IV for decrypting token + secretKey from openUser/token v2. +// These are reverse-engineered from the SwitchBot mobile app binary. +// Override with SWITCHBOT_TOKEN_AES_KEY / SWITCHBOT_TOKEN_AES_IV if SwitchBot rotates them. +export const TOKEN_AES_KEY = process.env.SWITCHBOT_TOKEN_AES_KEY ?? 'lrQ0OTvwp9RTsXxk'; +export const TOKEN_AES_IV = process.env.SWITCHBOT_TOKEN_AES_IV ?? '4mdN27rI3bk2LzWa'; + +export const ENDPOINTS = { + mobileLogin: '/v2/mobile/management/login', + openUserToken: '/openapi/openUser/token', + userInfo: '/account/api/v1/user/userinfo', + oauthToken: '/merchant/v1/oauth/token', +} as const; + +/** Allowlist of known bot-region labels returned by /account/api/v1/user/userinfo. */ +export const KNOWN_BOT_REGIONS = new Set(['us', 'eu', 'as']); + +/** Fallback region when the returned botRegion value is absent or unknown. */ +export const DEFAULT_BOT_REGION = 'us'; diff --git a/src/auth/csrf.ts b/src/auth/csrf.ts new file mode 100644 index 00000000..ab105a82 --- /dev/null +++ b/src/auth/csrf.ts @@ -0,0 +1,6 @@ +import crypto from 'node:crypto'; + +/** 32-byte random hex string used as CSRF state parameter in OAuth flows. */ +export function generateState(): string { + return crypto.randomBytes(32).toString('hex'); +} diff --git a/src/auth/oauth-callback.ts b/src/auth/oauth-callback.ts new file mode 100644 index 00000000..663a3e6e --- /dev/null +++ b/src/auth/oauth-callback.ts @@ -0,0 +1,128 @@ +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { escapeHtml, SECURITY_HEADERS } from './utils.js'; +import { LOGIN_TIMEOUT_MS, OAUTH_CALLBACK_PORT } from './constants.js'; + +export interface CallbackResult { + code: string; +} + +export interface CallbackHandle { + /** The loopback port the server is listening on. */ + port: number; + /** Resolves with the OAuth code once the browser redirects here. */ + wait(): Promise; +} + +function successHtml(): string { + return ` +SwitchBot CLI — Login successful + +
+

Login successful

+

You can close this tab and return to your terminal.

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

Login failed

+

${escaped}

+
`; +} + +export async function bindCallbackServer( + expectedState: string, + timeoutMs = LOGIN_TIMEOUT_MS, + port = OAUTH_CALLBACK_PORT, +): Promise { + let resolveResult!: (r: CallbackResult) => void; + let rejectResult!: (e: Error) => void; + const resultPromise = new Promise((res, rej) => { + resolveResult = res; + rejectResult = rej; + }); + + let finished = false; + + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`); + + if (url.pathname !== '/callback') { + res.writeHead(404, { 'Content-Type': 'text/plain', ...SECURITY_HEADERS }); + res.end('Not found'); + return; + } + + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const error = url.searchParams.get('error'); + const errorDesc = url.searchParams.get('error_description') ?? ''; + + const finish = (statusCode: number, body: string, err?: Error) => { + if (finished) return; + finished = true; + res.writeHead(statusCode, { 'Content-Type': 'text/html', ...SECURITY_HEADERS }); + res.end(body); + server.close(); + clearTimeout(timer); + if (err) rejectResult(err); else resolveResult({ code: code! }); + }; + + if (error) { + finish(400, errorHtml(`${error}${errorDesc ? ': ' + errorDesc : ''}`), + new Error(`OAuth error: ${error}${errorDesc ? ' — ' + errorDesc : ''}`)); + return; + } + + if (state !== expectedState) { + finish(400, errorHtml('State mismatch — possible CSRF. Please try again.'), + new Error('OAuth state mismatch')); + return; + } + + if (!code) { + finish(400, errorHtml('Missing authorization code in callback.'), + new Error('Missing authorization code')); + return; + } + + finish(200, successHtml()); + }); + + const actualPort = await new Promise((resolve, reject) => { + server.once('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + reject(new Error( + `Port ${port} is already in use. Close any other switchbot login session and try again.`, + )); + } else { + reject(err); + } + }); + server.listen(port, '127.0.0.1', () => { + resolve((server.address() as AddressInfo).port); + }); + }); + + const timer = setTimeout(() => { + if (finished) return; + finished = true; + server.close(); + rejectResult(new Error('Login timed out. Please run `switchbot auth login` again.')); + }, timeoutMs); + + return { port: actualPort, wait: () => resultPromise }; +} diff --git a/src/auth/token-exchange.ts b/src/auth/token-exchange.ts new file mode 100644 index 00000000..b8e70c06 --- /dev/null +++ b/src/auth/token-exchange.ts @@ -0,0 +1,141 @@ +import crypto from 'node:crypto'; +import axios from 'axios'; +import type { CredentialBundle } from '../credentials/keychain.js'; +import { + OAUTH_CLIENT_ID, + OAUTH_CLIENT_SECRET, + ACCOUNT_API_BASE, + TOKEN_AES_KEY, + TOKEN_AES_IV, + ENDPOINTS, + KNOWN_BOT_REGIONS, + DEFAULT_BOT_REGION, +} from './constants.js'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function decryptField(hexCipher: string): string { + try { + const key = Buffer.from(TOKEN_AES_KEY, 'utf8'); + const iv = Buffer.from(TOKEN_AES_IV, 'utf8'); + const d = crypto.createDecipheriv('aes-128-cbc', key, iv); + return Buffer.concat([d.update(Buffer.from(hexCipher, 'hex')), d.final()]).toString('utf8'); + } catch { + throw new Error( + 'Failed to decrypt credentials — the AES key/IV may be stale. ' + + 'Set SWITCHBOT_TOKEN_AES_KEY / SWITCHBOT_TOKEN_AES_IV env vars to override.', + ); + } +} + +// ── Main export ─────────────────────────────────────────────────────────────── + +/** + * Exchange an OAuth authorization code for SwitchBot v1.1 credentials. + * + * Step 1 — POST account.api.switchbot.net/merchant/v1/oauth/token (form-encoded) + * Exchange the authorization code for an access_token. + * + * Step 2 — POST account.api.switchbot.net/account/api/v1/user/userinfo + * Get the user's botRegion to select the correct regional Wonder API. + * + * Step 3 — POST wonderlabs.{region}.api.switchbot.net/wonder/openapi/openUser/token + * Retrieve AES-128-CBC encrypted openToken + secretKey, then decrypt. + */ +export async function exchangeCodeForCredentials( + code: string, + redirectUri: string, +): Promise { + + // ── Step 1: code → access_token ────────────────────────────────────────── + let accessToken: string; + try { + const resp = await axios.post>( + `${ACCOUNT_API_BASE}${ENDPOINTS.oauthToken}`, + new URLSearchParams({ + client_id: OAUTH_CLIENT_ID, + client_secret: OAUTH_CLIENT_SECRET, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + code, + }), + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + timeout: 15_000, + }, + ); + + const data = resp.data; + // Support both top-level and body-wrapped access_token + const bodyData = (data['body'] as Record | undefined) ?? data; + const token = typeof bodyData['access_token'] === 'string' ? bodyData['access_token'] : undefined; + + if (!token) { + throw new Error(`Token endpoint returned no access_token. Body: ${JSON.stringify(data)}`); + } + accessToken = token; + } catch (err) { + if (axios.isAxiosError(err)) { + const status = err.response?.status; + const body = err.response?.data; + throw new Error( + `Token exchange failed (HTTP ${status ?? 'unknown'}): ` + + (typeof body === 'object' ? JSON.stringify(body) : String(body ?? err.message)), + ); + } + throw err; + } + + // ── Step 2: access_token → botRegion ──────────────────────────────────── + let botRegion = DEFAULT_BOT_REGION; + try { + const resp = await axios.post<{ statusCode?: number; body?: { botRegion?: string } }>( + `${ACCOUNT_API_BASE}${ENDPOINTS.userInfo}`, + {}, + { + headers: { 'Content-Type': 'application/json', Authorization: accessToken }, + timeout: 15_000, + }, + ); + const raw = resp.data?.body?.botRegion ?? ''; + if (KNOWN_BOT_REGIONS.has(raw)) botRegion = raw; + } catch { + // Non-fatal: fall back to default region + } + + // ── Step 3: Wonder API → encrypted credentials → decrypt ───────────────── + try { + const wonderBase = `https://wonderlabs.${botRegion}.api.switchbot.net/wonder`; + const resp = await axios.post<{ statusCode?: number; body?: Record }>( + `${wonderBase}${ENDPOINTS.openUserToken}`, + { operation: 'get', version: 2 }, + { + headers: { 'Content-Type': 'application/json', Authorization: accessToken }, + timeout: 15_000, + }, + ); + + const body = (resp.data?.body ?? {}) as Record; + const encToken = typeof body['token'] === 'string' ? body['token'] : undefined; + const encSecret = typeof body['secretKey'] === 'string' ? body['secretKey'] : undefined; + + if (!encToken || !encSecret) { + throw new Error( + `openUser/token returned statusCode=${resp.data?.statusCode} ` + + `but token/secretKey missing. Full response: ${JSON.stringify(resp.data)}`, + ); + } + + return { token: decryptField(encToken), secret: decryptField(encSecret) }; + } catch (err) { + if (axios.isAxiosError(err)) { + const status = err.response?.status; + const body = err.response?.data; + throw new Error( + `Credentials fetch failed (HTTP ${status ?? 'unknown'}): ` + + (typeof body === 'object' ? JSON.stringify(body) : String(body ?? err.message)), + ); + } + throw err; + } +} diff --git a/src/auth/utils.ts b/src/auth/utils.ts new file mode 100644 index 00000000..3d6bc26d --- /dev/null +++ b/src/auth/utils.ts @@ -0,0 +1,14 @@ +export const SECURITY_HEADERS = { + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'", +} as const; + +export function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/src/auth/verify.ts b/src/auth/verify.ts new file mode 100644 index 00000000..372be45a --- /dev/null +++ b/src/auth/verify.ts @@ -0,0 +1,24 @@ +import axios from 'axios'; +import { buildAuthHeaders } from '../auth.js'; +import type { CredentialBundle } from '../credentials/keychain.js'; + +export type VerifyResult = { ok: true } | { ok: false; reason: string }; + +export async function verifyCredentials(creds: CredentialBundle): Promise { + try { + const resp = await axios.get<{ statusCode: number }>( + 'https://api.switch-bot.com/v1.1/devices', + { headers: buildAuthHeaders(creds.token, creds.secret), timeout: 10_000 }, + ); + if (resp.data?.statusCode === 100) return { ok: true }; + return { ok: false, reason: `API returned statusCode ${resp.data?.statusCode}` }; + } catch (err) { + if (axios.isAxiosError(err)) { + if (err.response?.status === 401) { + return { ok: false, reason: 'API rejected the credentials (401). The decrypted token or secret may be wrong.' }; + } + return { ok: false, reason: `Network error: ${err.message}` }; + } + return { ok: false, reason: String(err) }; + } +} diff --git a/src/commands/auth.ts b/src/commands/auth.ts index d768917e..643b9209 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -23,10 +23,14 @@ import readline from 'node:readline'; import { exitWithError, isJsonMode, printJson } from '../utils/output.js'; import { stringArg } from '../utils/arg-parsers.js'; import { getActiveProfile } from '../lib/request-context.js'; +import { getConfigPath } from '../utils/flags.js'; +import { saveConfig } from '../config.js'; import { CredentialBundle, selectCredentialStore, } from '../credentials/keychain.js'; +import { browserLogin } from '../auth/browser-login.js'; +import { verifyCredentials } from '../auth/verify.js'; function activeProfile(): string { return getActiveProfile() ?? 'default'; @@ -375,4 +379,87 @@ export function registerAuthCommand(program: Command): void { console.log('Source file kept — pass --delete-file on the next run to remove it.'); } }); + + // ------------------------------------------------------------------------- + // auth login + // ------------------------------------------------------------------------- + auth + .command('login') + .description('Sign in via browser and save credentials to the OS keychain') + .option('--no-open', 'Print the login URL instead of opening the browser automatically') + .option('--timeout ', 'Browser login timeout in seconds (default: 120)', '120') + .action(async (options: { open: boolean; timeout: string }) => { + const profile = activeProfile(); + const timeoutMs = Math.max(10, parseInt(options.timeout, 10) || 120) * 1000; + + let creds: CredentialBundle; + try { + creds = await browserLogin({ + noOpen: !options.open, + timeoutMs, + log: (msg) => isJsonMode() ? console.error(msg) : console.log(msg), + }); + } catch (err) { + exitWithError({ + code: 1, + kind: 'runtime', + message: `Login failed: ${err instanceof Error ? err.message : String(err)}`, + }); + return; + } + + (isJsonMode() ? console.error : console.log)('Verifying credentials…'); + const check = await verifyCredentials(creds); + if (!check.ok) { + exitWithError({ + code: 1, + kind: 'runtime', + message: `Credential verification failed: ${check.reason}`, + }); + return; + } + + let backendName: string; + if (getConfigPath()) { + try { + saveConfig(creds.token, creds.secret); + } catch (err) { + exitWithError({ + code: 1, + kind: 'runtime', + message: `Failed to save credentials: ${err instanceof Error ? err.message : String(err)}`, + }); + return; + } + backendName = 'file'; + } else { + const store = await selectCredentialStore(); + try { + await store.set(profile, creds); + } catch (err) { + exitWithError({ + code: 1, + kind: 'runtime', + message: `Failed to save credentials: ${err instanceof Error ? err.message : String(err)}`, + }); + return; + } + backendName = store.name; + } + + if (isJsonMode()) { + printJson({ + profile, + backend: backendName, + loggedIn: true, + verified: true, + token: { length: creds.token.length, masked: maskValue(creds.token) }, + }); + return; + } + + console.log(`✓ Credentials verified and saved to backend "${backendName}" for profile "${profile}".`); + console.log(`token : ${maskValue(creds.token)} (${creds.token.length} chars)`); + console.log(`secret: ${maskValue(creds.secret)} (${creds.secret.length} chars)`); + }); } diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index 82b99082..522a6303 100644 --- a/src/commands/capabilities.ts +++ b/src/commands/capabilities.ts @@ -122,6 +122,7 @@ export const COMMAND_META: Record = { 'auth keychain set': DESTRUCTIVE_LOCAL, 'auth keychain delete': DESTRUCTIVE_LOCAL, 'auth keychain migrate': DESTRUCTIVE_LOCAL, + 'auth login': DESTRUCTIVE_LOCAL, 'cache show': READ_LOCAL, 'cache clear': ACTION_LOCAL, 'capabilities': READ_LOCAL, @@ -213,6 +214,7 @@ export const COMMAND_META: Record = { 'status-sync start': ACTION_LOCAL, 'status-sync stop': ACTION_LOCAL, 'status-sync status': READ_LOCAL, + 'reset': ACTION_LOCAL, 'uninstall': ACTION_LOCAL, 'upgrade-check': READ_REMOTE, 'webhook setup': ACTION_REMOTE, @@ -264,7 +266,7 @@ export interface CompactLeaf { recommendedMode: RecommendedMode; } -function enumerateLeafNames(program: Command, prefix = ''): string[] { +export function enumerateLeafNames(program: Command, prefix = ''): string[] { const out: string[] = []; for (const cmd of program.commands) { const full = prefix ? `${prefix} ${cmd.name()}` : cmd.name(); diff --git a/src/commands/devices-columns.ts b/src/commands/devices-columns.ts new file mode 100644 index 00000000..92b8802c --- /dev/null +++ b/src/commands/devices-columns.ts @@ -0,0 +1,12 @@ +export const DEVICE_FIELD_ALIAS: Record = { + id: 'deviceId', name: 'deviceName', deviceType: 'type', type: 'type', + roomName: 'room', familyName: 'family', hubDeviceId: 'hub', + enableCloudService: 'cloud', controlType: 'controlType', + deviceName: 'deviceName', deviceId: 'deviceId', category: 'category', + roomID: 'roomID', alias: 'alias', +}; + +export const DEVICE_ALL_COLS: Set = new Set([ + 'deviceId', 'deviceName', 'type', 'category', 'controlType', + 'family', 'roomID', 'room', 'hub', 'cloud', 'alias', +]); diff --git a/src/commands/devices.ts b/src/commands/devices.ts index dca077c0..84eef356 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -9,7 +9,7 @@ import { getCommandSafetyReason, DeviceCatalogEntry, } from '../devices/catalog.js'; -import { getCachedDevice, loadCache } from '../devices/cache.js'; +import { getCachedDevice, loadCache, getCachedStatusEntry } from '../devices/cache.js'; import { loadDeviceMeta } from '../devices/device-meta.js'; import { resolveDeviceId, NameResolveStrategy, ALL_STRATEGIES } from '../utils/name-resolver.js'; import { @@ -31,10 +31,11 @@ import { registerWatchCommand } from './watch.js'; import { registerExplainCommand } from './explain.js'; import { registerExpandCommand } from './expand.js'; import { registerDevicesMetaCommand } from './device-meta.js'; -import { isDryRun } from '../utils/flags.js'; +import { isDryRun, getCacheMode } from '../utils/flags.js'; import { DryRunSignal } from '../api/client.js'; import { resolveField, resolveFieldList, listSupportedFieldInputs } from '../schema/field-aliases.js'; import { allowsDirectDestructiveExecution, destructiveExecutionHint } from '../lib/destructive-mode.js'; +import { DEVICE_FIELD_ALIAS, DEVICE_ALL_COLS } from './devices-columns.js'; const EXPAND_HINTS: Record = { 'Air Conditioner': { command: 'setAll', flags: '--temp 26 --mode cool --fan low --power on' }, @@ -106,7 +107,7 @@ Run any subcommand with --help for its own flags and examples. .alias('ls') .description('List all physical devices and IR remote devices on the account') .addHelpText('after', ` -Default columns: deviceId, deviceName, type, category +Default columns: deviceId, deviceName, type, category, family, room Pass --wide for the full operator view: + controlType, family, roomID, room, hub, cloud --fields accepts any subset of all column names (exit 2 on unknown names). @@ -137,6 +138,11 @@ Examples: $ switchbot devices list --filter name=living,category=physical $ switchbot devices list --filter 'name~living' # explicit substring $ switchbot devices list --filter 'type=/Air.*/' # regex (case-insensitive) + +Cache note: + --cache is a global flag and must be placed BEFORE the subcommand: + switchbot --cache 5m devices list ✓ + switchbot devices list --cache 5m ✗ (silently ignored) `) .option('--wide', 'Show all columns (controlType, family, roomID, room, hub, cloud)') .option('--show-hidden', 'Include devices hidden via "devices meta set --hide"') @@ -200,6 +206,47 @@ Examples: }; if (fmt === 'json' && process.argv.includes('--json')) { + const jsonFields = resolveFields(); + + if (jsonFields) { + const unknown = jsonFields.filter(k => !DEVICE_ALL_COLS.has(DEVICE_FIELD_ALIAS[k] ?? k)); + if (unknown.length) { + exitWithError({ code: 2, kind: 'usage', message: `Unknown --fields value(s): ${unknown.join(', ')}` }); + } + } + + const normPhysical = (d: typeof deviceList[0]): Record => ({ + deviceId: d.deviceId, deviceName: d.deviceName, + type: d.deviceType || d.controlType || 'Unknown Device', + category: 'physical', controlType: d.controlType || '—', + family: d.familyName || '—', roomID: d.roomID || '—', room: d.roomName || '—', + hub: !d.hubDeviceId || d.hubDeviceId === '000000000000' ? '—' : d.hubDeviceId, + cloud: d.enableCloudService ?? null, + alias: deviceMeta.devices[d.deviceId]?.alias ?? '—', + }); + const normIr = (d: typeof infraredRemoteList[0]): Record => { + const inh = hubLocation.get(d.hubDeviceId); + return { + deviceId: d.deviceId, deviceName: d.deviceName, + type: d.remoteType, category: 'ir', controlType: d.controlType || '—', + family: inh?.family || '—', roomID: inh?.roomID || '—', room: inh?.room || '—', + hub: d.hubDeviceId, cloud: null, + alias: deviceMeta.devices[d.deviceId]?.alias ?? '—', + }; + }; + const project = jsonFields + ? (norm: Record) => + Object.fromEntries(jsonFields.map(k => { + // Always emit the canonical key. Without this, --json --fields id,name + // would echo the alias keys (id/name) while --format json --fields id,name + // still emits canonical (deviceId/deviceName) — making the output schema + // depend on which form the caller used. Canonicalize here so consumers + // see a stable shape regardless of input alias. + const canonical = DEVICE_FIELD_ALIAS[k] ?? k; + return [canonical, norm[canonical] ?? null]; + })) + : (norm: Record) => norm; + if (listClauses) { const filteredDeviceList = deviceList.filter((d) => matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '', family: d.familyName || '', hub: d.hubDeviceId || '', roomID: d.roomID || '', cloud: String(d.enableCloudService), alias: deviceMeta.devices[d.deviceId]?.alias || '' }) @@ -208,14 +255,22 @@ Examples: const inherited = hubLocation.get(d.hubDeviceId); return matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '', family: inherited?.family || '', hub: d.hubDeviceId || '', roomID: inherited?.roomID || '', cloud: '', alias: deviceMeta.devices[d.deviceId]?.alias || '' }); }); - printJson({ ok: true, deviceList: filteredDeviceList, infraredRemoteList: filteredIrList }); + printJson({ + ok: true, + deviceList: jsonFields ? filteredDeviceList.map(d => project(normPhysical(d))) : filteredDeviceList, + infraredRemoteList: jsonFields ? filteredIrList.map(d => project(normIr(d))) : filteredIrList, + }); } else { - printJson({ ok: true, ...(body as object) }); + printJson({ + ok: true, + deviceList: jsonFields ? deviceList.map(d => project(normPhysical(d))) : deviceList, + infraredRemoteList: jsonFields ? infraredRemoteList.map(d => project(normIr(d))) : infraredRemoteList, + }); } return; } - const narrowHeaders = ['deviceId', 'deviceName', 'type', 'category']; + const narrowHeaders = ['deviceId', 'deviceName', 'type', 'category', 'family', 'room']; const wideHeaders = ['deviceId', 'deviceName', 'type', 'category', 'controlType', 'family', 'roomID', 'room', 'hub', 'cloud', 'alias']; const userFields = resolveFields(); const headers = userFields ? wideHeaders : (options.wide ? wideHeaders : narrowHeaders); @@ -265,22 +320,7 @@ Examples: const defaultFields = options.wide ? undefined : narrowHeaders; // Accept API field names and short aliases alongside canonical column names - const DEVICE_LIST_ALIASES: Record = { - id: 'deviceId', - name: 'deviceName', - deviceType: 'type', - type: 'type', - roomName: 'room', - familyName: 'family', - hubDeviceId: 'hub', - enableCloudService: 'cloud', - controlType: 'controlType', - deviceName: 'deviceName', - deviceId: 'deviceId', - category: 'category', - alias: 'alias', - }; - renderRows(wideHeaders, rows, fmt, userFields ?? defaultFields, DEVICE_LIST_ALIASES); + renderRows(wideHeaders, rows, fmt, userFields ?? defaultFields, DEVICE_FIELD_ALIAS); if (fmt === 'table') { const totalLabel = listClauses ? `${rows.length} match(es) (${deviceList.length} physical + ${infraredRemoteList.length} IR before filter)` @@ -323,6 +363,11 @@ Examples: $ switchbot devices status ABC123DEF456 --json | jq '.data.battery' $ switchbot devices status --ids ABC123,DEF456,GHI789 $ switchbot devices status --ids ABC123,DEF456 --fields power,battery + +Cache note: + --cache is a global flag and must be placed BEFORE the subcommand: + switchbot --cache 5m devices status ✓ + switchbot devices status --cache 5m ✗ (silently ignored) `) .action(async (deviceIdArgs: string[], options: { name?: string; nameStrategy?: string; nameType?: string; nameCategory?: 'physical' | 'ir'; nameRoom?: string; ids?: string; strict?: boolean }) => { try { @@ -384,8 +429,13 @@ Examples: // so there is nothing to translate into a non-zero exit code. console.error('warning: --strict has no effect without --ids or multiple device IDs (batch mode only)'); } - const body = annotateStatusPayload(deviceId, await fetchDeviceStatus(deviceId)); - const fetchedAt = new Date().toISOString(); + const mode = getCacheMode(); + const cacheEntry = mode.statusTtlMs > 0 + ? getCachedStatusEntry(deviceId, mode.statusTtlMs) + : null; + const rawStatus = cacheEntry ? cacheEntry.body : await fetchDeviceStatus(deviceId); + const fetchedAt = cacheEntry ? cacheEntry.fetchedAt : new Date().toISOString(); + const body = annotateStatusPayload(deviceId, rawStatus); const fmt = resolveFormat(); if (fmt === 'json' && process.argv.includes('--json')) { diff --git a/src/commands/history.ts b/src/commands/history.ts index 77e5a955..ffa56364 100644 --- a/src/commands/history.ts +++ b/src/commands/history.ts @@ -28,6 +28,11 @@ Every 'devices command' run with --audit-log is appended as JSONL to the audit file (default ~/.switchbot/audit.log). 'history show' prints the file, 'history replay ' re-runs the Nth entry (1-indexed, most-recent last). +Cache note: + --cache is a global flag and must be placed BEFORE the subcommand: + switchbot --cache 5m history range ✓ + switchbot history range --cache 5m ✗ (silently ignored) + Examples: $ switchbot --audit-log devices command turnOff $ switchbot history show --limit 10 diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index d590ea26..fa6617dc 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -110,9 +110,12 @@ function mcpError( if (options?.errorClass !== undefined) obj.errorClass = options.errorClass; if (options?.transient !== undefined) obj.transient = options.transient; if (options?.retryAfterMs !== undefined) obj.retryAfterMs = options.retryAfterMs; + const summary = `${kind} error (code ${code}): ${message}`; + const hintLine = options?.hint ? `\n${options.hint}` : ''; + const textBody = `${summary}${hintLine}\n--- structured ---\n${JSON.stringify({ error: obj }, null, 2)}`; return { isError: true as const, - content: [{ type: 'text' as const, text: JSON.stringify({ error: obj }, null, 2) }], + content: [{ type: 'text' as const, text: textBody }], structuredContent: { error: obj }, }; } @@ -300,17 +303,17 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! deviceType: z.string().optional(), enableCloudService: z.boolean(), hubDeviceId: z.string(), - roomID: z.string().optional(), + roomID: z.string().nullable().optional(), roomName: z.string().nullable().optional(), familyName: z.string().optional(), - controlType: z.string().optional(), + controlType: z.string().nullable().optional(), }).passthrough()).describe('Physical SwitchBot devices'), infraredRemoteList: z.array(z.object({ deviceId: z.string(), deviceName: z.string(), remoteType: z.string(), hubDeviceId: z.string(), - controlType: z.string().optional(), + controlType: z.string().nullable().optional(), }).passthrough()).describe('IR remote devices'), }, }, @@ -1616,7 +1619,29 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! outputSchema: { ran: z.boolean(), plan: z.unknown(), - results: z.array(z.unknown()), + results: z.array(z.discriminatedUnion('type', [ + z.object({ + step: z.number(), + type: z.literal('command'), + deviceId: z.string(), + command: z.string(), + status: z.enum(['ok', 'error', 'skipped']), + error: z.string().optional(), + }).passthrough(), + z.object({ + step: z.number(), + type: z.literal('scene'), + sceneId: z.string(), + status: z.enum(['ok', 'error']), + error: z.string().optional(), + }).passthrough(), + z.object({ + step: z.number(), + type: z.literal('wait'), + ms: z.number(), + status: z.literal('ok'), + }).passthrough(), + ])), summary: z.object({ total: z.number().int(), ok: z.number().int(), diff --git a/src/commands/quota.ts b/src/commands/quota.ts index d526efe4..eabf74c4 100644 --- a/src/commands/quota.ts +++ b/src/commands/quota.ts @@ -1,5 +1,6 @@ import { Command } from 'commander'; import { printJson, isJsonMode } from '../utils/output.js'; +import { isDryRun } from '../utils/flags.js'; import { DAILY_QUOTA, loadQuota, @@ -91,6 +92,11 @@ Examples: .command('reset') .description('Delete the local quota counter file') .action(() => { + if (isDryRun()) { + if (isJsonMode()) printJson({ dryRun: true, reset: false }); + else console.log('[dry-run] quota reset skipped — no files changed'); + return; + } resetQuota(); if (isJsonMode()) { printJson({ reset: true }); diff --git a/src/commands/reset.ts b/src/commands/reset.ts new file mode 100644 index 00000000..dec567dd --- /dev/null +++ b/src/commands/reset.ts @@ -0,0 +1,204 @@ +import { Command } from 'commander'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import readline from 'node:readline'; +import chalk from 'chalk'; +import { isJsonMode, printJson, exitWithError } from '../utils/output.js'; +import { isDryRun, getConfigPath } from '../utils/flags.js'; +import { selectCredentialStore } from '../credentials/keychain.js'; +import { listProfiles } from '../config.js'; +import { getActiveProfile } from '../lib/request-context.js'; + +const BASE = path.join(os.homedir(), '.switchbot'); + +type ResetResult = { key: string; label: string; status: 'removed' | 'absent' | 'failed'; error?: string }; + +async function confirm(question: string): Promise { + if (!process.stdin.isTTY) return false; + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + return new Promise((resolve) => { + rl.question(`${question} [y/N] `, (ans) => { + rl.close(); + resolve(ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes'); + }); + }); +} + +function removeItem(itemPath: string, type: 'file' | 'dir'): { status: 'removed' | 'absent' | 'failed'; error?: string } { + if (!fs.existsSync(itemPath)) return { status: 'absent' }; + try { + if (type === 'dir') { + fs.rmSync(itemPath, { recursive: true, force: true }); + } else { + fs.unlinkSync(itemPath); + } + return { status: 'removed' }; + } catch (err) { + return { status: 'failed', error: err instanceof Error ? err.message : String(err) }; + } +} + +function makeDataItems(dataDir: string, configOverride: boolean): Array<{ key: string; label: string; path: string; type: 'file' | 'dir' }> { + // quota / device-history / audit are always global (their writers hardcode ~/.switchbot regardless of --config) + const items: Array<{ key: string; label: string; path: string; type: 'file' | 'dir' }> = [ + { key: 'devices', label: 'Devices list cache', path: path.join(dataDir, 'devices.json'), type: 'file' }, + { key: 'quota', label: 'Quota counter', path: path.join(BASE, 'quota.json'), type: 'file' }, + { key: 'device-history', label: 'Device history', path: path.join(BASE, 'device-history'), type: 'dir' }, + { key: 'device-meta', label: 'Device metadata', path: path.join(dataDir, 'device-meta.json'), type: 'file' }, + { key: 'status', label: 'Status cache', path: path.join(dataDir, 'status.json'), type: 'file' }, + { key: 'audit', label: 'Audit log', path: path.join(BASE, 'audit.log'), type: 'file' }, + ]; + // The `cache/` sub-directory is only ever created under ~/.switchbot (per + // src/devices/cache.ts:scopedCacheDir). In --config mode the override path + // is the data dir directly, so a sibling `cache/` would belong to the user's + // unrelated project — never touch it. + if (!configOverride) { + items.unshift({ key: 'cache', label: 'Device cache', path: path.join(dataDir, 'cache'), type: 'dir' }); + } + return items; +} + +function statusIcon(status: ResetResult['status']): string { + if (status === 'removed') return chalk.green('✓'); + if (status === 'absent') return chalk.dim('–'); + return chalk.red('✗'); +} + +export function registerResetCommand(program: Command): void { + program + .command('reset') + .description('Clear all local account data: credentials, cache, quota, history, and metadata') + .option('-y, --yes', 'skip confirmation prompt') + .option('--keep-credentials', 'preserve keychain/config credentials, only clear data files') + .action(async (opts: { yes?: boolean; keepCredentials?: boolean }) => { + const profile = getActiveProfile() ?? 'default'; + const extraProfiles = listProfiles(); + + const configOverride = getConfigPath(); + const dataDir = configOverride + ? path.dirname(path.resolve(configOverride)) + : BASE; + const dataItems = makeDataItems(dataDir, Boolean(configOverride)); + + if (isDryRun()) { + const preview: string[] = []; + if (!opts.keepCredentials) { + const profilesToWipe = [profile, ...extraProfiles.filter(p => p !== profile)]; + for (const p of profilesToWipe) preview.push(`Credentials (${p})`); + preview.push('Config file (config.json)'); + if (!configOverride) preview.push('Profiles directory'); + } + for (const item of dataItems) preview.push(item.label); + if (isJsonMode()) { + printJson({ dryRun: true, wouldDelete: preview }); + } else { + console.error(chalk.dim('[dry-run] Would delete:')); + for (const p of preview) console.error(chalk.dim(` • ${p}`)); + } + return; + } + + if (!opts.yes) { + console.error(chalk.yellow('This will permanently delete:')); + if (!opts.keepCredentials) { + console.error(` • Credentials for profile "${profile}"${extraProfiles.length ? ` and ${extraProfiles.length} other profile(s)` : ''} (keychain + config files)`); + } + for (const item of dataItems) { + console.error(` • ${item.label}`); + } + console.error(''); + const ok = await confirm('Continue?'); + if (!ok) { + if (!process.stdin.isTTY) { + if (isJsonMode()) { + exitWithError({ code: 1, kind: 'runtime', message: 'Aborted (non-interactive terminal — pass --yes to skip confirmation).' }); + } + console.error('Aborted (non-interactive terminal — pass --yes to skip confirmation).'); + process.exit(1); + } + console.error('Aborted.'); + return; + } + } + + const results: ResetResult[] = []; + + // ── Credentials ────────────────────────────────────────────────────────── + if (!opts.keepCredentials) { + // Only touch the global credential store when NOT in --config mode. + // In --config mode the override file is the credential store; it is + // removed below via removeItem(configFile). Calling store.delete() here + // would also wipe the user's normal keychain / ~/.switchbot/config.json. + if (!configOverride) { + const profilesToWipe = [profile, ...extraProfiles.filter(p => p !== profile)]; + const store = await selectCredentialStore(); + + for (const p of profilesToWipe) { + try { + await store.delete(p); + results.push({ key: `creds:${p}`, label: `Credentials (${p})`, status: 'removed' }); + } catch { + results.push({ key: `creds:${p}`, label: `Credentials (${p})`, status: 'failed' }); + } + } + } + + // Also remove file-based credential files + const configFile = configOverride + ? path.resolve(configOverride) + : path.join(BASE, 'config.json'); + const cfgStatus = removeItem(configFile, 'file'); + if (cfgStatus.status !== 'absent') { + results.push({ key: 'config-file', label: 'Config file (config.json)', ...cfgStatus }); + } + if (!configOverride) { + const profilesDir = path.join(BASE, 'profiles'); + const profStatus = removeItem(profilesDir, 'dir'); + if (profStatus.status !== 'absent') { + results.push({ key: 'profiles-dir', label: 'Profiles directory', ...profStatus }); + } + } + } + + // ── Data files ─────────────────────────────────────────────────────────── + for (const item of dataItems) { + const result = removeItem(item.path, item.type); + results.push({ key: item.key, label: item.label, ...result }); + } + + if (isJsonMode()) { + const failed = results.filter(r => r.status === 'failed').length; + if (failed > 0) { + exitWithError({ + code: 1, + kind: 'runtime', + message: `Reset completed with ${failed} error(s). Some items may need manual cleanup.`, + extra: { results }, + }); + } + printJson({ reset: true, results }); + return; + } + + console.error(''); + for (const r of results) { + const icon = statusIcon(r.status); + const statusText = r.status === 'removed' ? chalk.green('removed') + : r.status === 'absent' ? chalk.dim('not found') + : chalk.red(`failed${r.error ? ': ' + r.error : ''}`); + console.error(` ${icon} ${r.label}: ${statusText}`); + } + + const removed = results.filter(r => r.status === 'removed').length; + const failed = results.filter(r => r.status === 'failed').length; + console.error(''); + + if (failed > 0) { + console.error(chalk.red(`Reset complete with ${failed} error(s). Some items may need manual cleanup.`)); + process.exit(1); + } else { + console.error(chalk.green(`Reset complete. ${removed} item(s) removed.`)); + } + }); +} diff --git a/src/config.ts b/src/config.ts index cb8062bc..0363d16e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -82,10 +82,25 @@ export function loadConfig(): SwitchBotConfig { const file = configFilePath(); if (!fs.existsSync(file)) { const profile = getActiveProfile(); - const hint = profile - ? `No credentials configured for profile "${profile}". Run: switchbot --profile ${profile} config set-token ` - : 'No credentials configured. Run: switchbot config set-token '; - const msg = `${hint}\nOr set SWITCHBOT_TOKEN and SWITCHBOT_SECRET environment variables.`; + const override = getConfigPath(); + // --config takes precedence over --profile (mirrors configFilePath ordering). + // The recovery commands must round-trip the active scope; otherwise the + // suggested `auth login` would write to the default keychain, which + // loadConfig() ignores while --config is set. + const prefix = override + ? `switchbot --config ${override}` + : profile + ? `switchbot --profile ${profile}` + : 'switchbot'; + const setToken = `${prefix} config set-token `; + const authLogin = `${prefix} auth login`; + const profileMsg = override + ? ` for config "${override}"` + : profile + ? ` for profile "${profile}"` + : ''; + const hint = `No credentials configured${profileMsg}.`; + const msg = `${hint} Choose one:\n 1. ${authLogin} (browser login)\n 2. ${setToken} (manual)\n 3. Set SWITCHBOT_TOKEN and SWITCHBOT_SECRET environment variables`; if (isJsonMode()) { emitJsonError({ code: 1, kind: 'runtime', message: hint }); } else { diff --git a/src/devices/cache.ts b/src/devices/cache.ts index 2d536fc1..f6847c60 100644 --- a/src/devices/cache.ts +++ b/src/devices/cache.ts @@ -297,6 +297,22 @@ export function getCachedStatus( return entry.body; } +/** Read a status entry with its stored fetchedAt timestamp; null when missing or expired. */ +export function getCachedStatusEntry( + deviceId: string, + ttlMs: number, + now = Date.now() +): { body: Record; fetchedAt: string } | null { + if (!ttlMs || ttlMs <= 0) return null; + const cache = loadStatusCache(); + const entry = cache.entries[deviceId]; + if (!entry) return null; + const ts = Date.parse(entry.fetchedAt); + if (!Number.isFinite(ts)) return null; + if (now - ts >= ttlMs) return null; + return { body: entry.body, fetchedAt: entry.fetchedAt }; +} + /** Evict status entries older than max(ttlMs × 10, 24 h) to bound file growth. */ function evictExpiredStatusEntries(cache: StatusCache, ttlMs: number, now = Date.now()): void { const cutoff = now - Math.max(ttlMs * 10, 24 * 60 * 60 * 1000); diff --git a/src/devices/history-agg.ts b/src/devices/history-agg.ts index 98eb8113..2883d6dd 100644 --- a/src/devices/history-agg.ts +++ b/src/devices/history-agg.ts @@ -73,6 +73,17 @@ export async function aggregateDeviceHistory( 1, Math.min(opts.maxBucketSamples ?? DEFAULT_SAMPLE_CAP, MAX_SAMPLE_CAP), ); + + const now = Date.now(); + // When fromMs is open-ended (-Infinity), fall back to toMs so the synthetic + // bucket key stays within the queried window. Using `now` as the fallback + // produces a key > toMs for past-only --to queries. + const effectiveFrom = Number.isFinite(fromMs) ? fromMs : (Number.isFinite(toMs) ? toMs : now); + const effectiveTo = Number.isFinite(toMs) ? toMs : now; + const stableKey = bucketMs === null + ? Math.floor((effectiveFrom + effectiveTo) / 2) + : 0; + let partial = false; const notes: string[] = []; @@ -95,7 +106,9 @@ export async function aggregateDeviceHistory( const tMs = Date.parse(rec.t); if (!Number.isFinite(tMs) || tMs < fromMs || tMs > toMs) continue; - const key = bucketMs !== null ? Math.floor(tMs / bucketMs) * bucketMs : 0; + const key = bucketMs !== null + ? Math.floor(tMs / bucketMs) * bucketMs + : stableKey; let bkt = buckets.get(key); if (!bkt) { bkt = new Map(); buckets.set(key, bkt); } diff --git a/src/index.ts b/src/index.ts index 65f9adea..a9f03ed0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,129 +1,18 @@ -import { Command, CommanderError, InvalidArgumentError } from 'commander'; -import { createRequire } from 'node:module'; +import { Command, CommanderError } from 'commander'; import chalk from 'chalk'; -import { intArg, stringArg, enumArg } from './utils/arg-parsers.js'; -import { parseDurationToMs } from './utils/flags.js'; import { emitJsonError, isJsonMode, printJson } from './utils/output.js'; import { commandToJson, resolveTargetCommand } from './utils/help-json.js'; -import { PRODUCT_TAGLINE } from './commands/identity.js'; -import { registerConfigCommand } from './commands/config.js'; -import { registerDevicesCommand } from './commands/devices.js'; -import { registerScenesCommand } from './commands/scenes.js'; -import { registerWebhookCommand } from './commands/webhook.js'; -import { registerCompletionCommand } from './commands/completion.js'; -import { registerMcpCommand } from './commands/mcp.js'; -import { registerQuotaCommand } from './commands/quota.js'; -import { registerCatalogCommand } from './commands/catalog.js'; -import { registerCacheCommand } from './commands/cache.js'; -import { registerEventsCommand } from './commands/events.js'; -import { registerDoctorCommand } from './commands/doctor.js'; -import { registerSchemaCommand } from './commands/schema.js'; -import { registerHistoryCommand } from './commands/history.js'; -import { registerPlanCommand } from './commands/plan.js'; -import { registerCapabilitiesCommand } from './commands/capabilities.js'; -import { registerAgentBootstrapCommand } from './commands/agent-bootstrap.js'; -import { registerPolicyCommand } from './commands/policy.js'; -import { registerRulesCommand } from './commands/rules.js'; -import { registerAuthCommand } from './commands/auth.js'; -import { registerInstallCommand } from './commands/install.js'; -import { registerUninstallCommand } from './commands/uninstall.js'; -import { registerStatusSyncCommand } from './commands/status-sync.js'; -import { registerHealthCommand } from './commands/health.js'; -import { registerUpgradeCheckCommand } from './commands/upgrade-check.js'; -import { registerDaemonCommand } from './commands/daemon.js'; +import { buildProgram } from './program-builder.js'; import { primeCredentials } from './credentials/prime.js'; import { getActiveProfile } from './lib/request-context.js'; -const require = createRequire(import.meta.url); -const { version: pkgVersion } = require('../package.json') as { version: string }; - // Early initialization: check for --no-color flag or NO_COLOR env var and disable chalk. // This must happen before any commands run so all chalk output is affected. if (process.argv.includes('--no-color') || Boolean(process.env.NO_COLOR)) { chalk.level = 0; } -const program = new Command(); -program.allowExcessArguments(false); -if (isJsonMode()) { - // In --json mode, commander writes plain-text usage errors by default. - // Silence that channel and emit a single structured error in the catch block. - program.configureOutput({ writeErr: () => {} }); -} - -// Top-level subcommand names. Used by stringArg to produce clearer errors when -// a value is omitted and the next argv token turns out to be a subcommand name. -const TOP_LEVEL_COMMANDS = [ - 'config', 'devices', 'scenes', 'webhook', 'completion', 'mcp', - 'quota', 'catalog', 'cache', 'events', 'doctor', 'schema', - 'history', 'plan', 'capabilities', 'agent-bootstrap', 'install', 'uninstall', 'status-sync', - 'health', 'upgrade-check', 'daemon', -] as const; - -const cacheModeArg = (value: string): string => { - if (value.startsWith('-')) { - throw new InvalidArgumentError( - `--cache requires a mode value, got "${value}". ` + - `Valid: "off", "auto", or a duration like "5m", "1h". Use --cache= if needed.`, - ); - } - if (value === 'off' || value === 'auto') return value; - if (parseDurationToMs(value) !== null) return value; - throw new InvalidArgumentError( - `--cache must be "off", "auto", or a duration like "30s"/"5m"/"1h" (got "${value}")`, - ); -}; - -program - .name('switchbot') - .description(PRODUCT_TAGLINE) - .version(pkgVersion) - .option('--no-color', 'Disable ANSI colors in output') - .option('--json', 'Output raw JSON response (disables tables; useful for pipes/scripts)') - .option('--format ', 'Output format: table (default), json, jsonl, tsv, yaml, id, markdown', enumArg('--format', ['table', 'json', 'jsonl', 'tsv', 'yaml', 'id', 'markdown'])) - .option('--fields ', 'Comma-separated list of columns to include (e.g. --fields=id,name,type)', stringArg('--fields', { disallow: TOP_LEVEL_COMMANDS })) - .option('--table-style