@switchbot/openapi-cli 3.0.0 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -50
- package/dist/api/client.js +23 -1
- package/dist/commands/batch.js +20 -4
- package/dist/commands/capabilities.js +155 -65
- package/dist/commands/config.js +79 -0
- package/dist/commands/daemon.js +410 -0
- package/dist/commands/devices.js +62 -10
- package/dist/commands/doctor.js +233 -1
- package/dist/commands/health.js +113 -0
- package/dist/commands/mcp.js +93 -5
- package/dist/commands/plan.js +310 -130
- package/dist/commands/policy.js +120 -3
- package/dist/commands/rules.js +220 -2
- package/dist/commands/scenes.js +140 -0
- package/dist/commands/upgrade-check.js +107 -0
- package/dist/index.js +7 -0
- package/dist/lib/daemon-state.js +46 -0
- package/dist/lib/destructive-mode.js +12 -0
- package/dist/lib/devices.js +1 -0
- package/dist/lib/plan-store.js +68 -0
- package/dist/policy/schema/v0.2.json +29 -0
- package/dist/rules/action.js +11 -0
- package/dist/rules/conflict-analyzer.js +214 -0
- package/dist/rules/engine.js +195 -5
- package/dist/rules/suggest.js +1 -1
- package/dist/rules/throttle.js +42 -4
- package/dist/utils/audit.js +5 -1
- package/dist/utils/health.js +101 -0
- package/dist/utils/output.js +72 -23
- package/dist/utils/retry.js +81 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -64,6 +64,8 @@ Under the hood every surface shares the same catalog, cache, and HMAC client —
|
|
|
64
64
|
- [`plan`](#plan--declarative-batch-operations)
|
|
65
65
|
- [`mcp`](#mcp--model-context-protocol-server)
|
|
66
66
|
- [`doctor`](#doctor--self-check)
|
|
67
|
+
- [`health`](#health--runtime-health-report)
|
|
68
|
+
- [`upgrade-check`](#upgrade-check--version-check)
|
|
67
69
|
- [`quota`](#quota--api-request-counter)
|
|
68
70
|
- [`history`](#history--audit-log)
|
|
69
71
|
- [`catalog`](#catalog--device-type-catalog)
|
|
@@ -71,6 +73,7 @@ Under the hood every surface shares the same catalog, cache, and HMAC client —
|
|
|
71
73
|
- [`capabilities`](#capabilities--cli-manifest)
|
|
72
74
|
- [`cache`](#cache--inspect-and-clear-local-cache)
|
|
73
75
|
- [`policy`](#policy--validate-scaffold-and-migrate-policyyaml)
|
|
76
|
+
- [`daemon`](#daemon--background-rules-engine-process)
|
|
74
77
|
- [`completion`](#completion--shell-tab-completion)
|
|
75
78
|
- [Output modes](#output-modes)
|
|
76
79
|
- [Cache](#cache)
|
|
@@ -78,8 +81,6 @@ Under the hood every surface shares the same catalog, cache, and HMAC client —
|
|
|
78
81
|
- [Environment variables](#environment-variables)
|
|
79
82
|
- [Scripting examples](#scripting-examples)
|
|
80
83
|
- [Development](#development)
|
|
81
|
-
- [Contributing](#contributing)
|
|
82
|
-
- [Roadmap](#roadmap)
|
|
83
84
|
- [License](#license)
|
|
84
85
|
- [References](#references)
|
|
85
86
|
|
|
@@ -92,7 +93,7 @@ Under the hood every surface shares the same catalog, cache, and HMAC client —
|
|
|
92
93
|
- 🎨 **Dual output modes** — colorized tables by default; `--json` passthrough for `jq` and scripting
|
|
93
94
|
- 🔐 **Secure credentials** — HMAC-SHA256 signed requests; config file written with `0600`; env-var override for CI
|
|
94
95
|
- 🔍 **Dry-run mode** — preview every mutating request before it hits the API
|
|
95
|
-
- 🧪 **Fully tested** —
|
|
96
|
+
- 🧪 **Fully tested** — 1882 Vitest tests, mocked axios, zero network in CI
|
|
96
97
|
- ⚡ **Shell completion** — Bash / Zsh / Fish / PowerShell
|
|
97
98
|
|
|
98
99
|
## Requirements
|
|
@@ -275,7 +276,7 @@ executes for you. Supported triggers: **MQTT** (device events),
|
|
|
275
276
|
Supported conditions: `time_between` (quiet hours) and `device_state`
|
|
276
277
|
(live API check with per-tick dedup). Every fire is recorded in
|
|
277
278
|
`~/.switchbot/audit.log`. `rules run` is long-running; use
|
|
278
|
-
`
|
|
279
|
+
`daemon start` / `daemon reload` for the managed background mode.
|
|
279
280
|
|
|
280
281
|
```bash
|
|
281
282
|
# 1. Author rules under `automation.rules`. See examples/policies/automation.yaml
|
|
@@ -285,16 +286,37 @@ Supported conditions: `time_between` (quiet hours) and `device_state`
|
|
|
285
286
|
switchbot rules lint # exit 0 valid, 1 error
|
|
286
287
|
switchbot rules list --json | jq . # structured summary
|
|
287
288
|
|
|
288
|
-
# 3.
|
|
289
|
+
# 3. Inspect a single rule in full detail (trigger, conditions, actions,
|
|
290
|
+
# cooldown, hysteresis, maxFiringsPerHour, suppressIfAlreadyDesired, last fired).
|
|
291
|
+
switchbot rules explain "motion on"
|
|
292
|
+
switchbot rules explain "motion on" --json
|
|
293
|
+
|
|
294
|
+
# 4. Run the engine. --dry-run overrides every rule into audit-only mode;
|
|
289
295
|
# --max-firings bounds a demo session.
|
|
290
296
|
switchbot rules run --dry-run --max-firings 5
|
|
291
297
|
|
|
292
|
-
#
|
|
293
|
-
switchbot
|
|
298
|
+
# 5. Edit policy.yaml in another shell, then hot-reload without restart.
|
|
299
|
+
switchbot daemon reload # managed daemon reload
|
|
294
300
|
|
|
295
|
-
#
|
|
301
|
+
# 6. Review recorded fires.
|
|
296
302
|
switchbot rules tail --follow # stream rule-* audit lines
|
|
297
303
|
switchbot rules replay --since 1h --json # per-rule fires/dries/throttled/errors
|
|
304
|
+
switchbot rules summary # aggregate fires/errors per rule (24h window)
|
|
305
|
+
switchbot rules last-fired -n 20 # 20 most recent fire entries
|
|
306
|
+
|
|
307
|
+
# 7. Conflict and health analysis.
|
|
308
|
+
switchbot rules conflicts # opposing actions, high-frequency MQTT,
|
|
309
|
+
# destructive commands, quiet-hours gaps
|
|
310
|
+
switchbot rules doctor --json # lint + conflicts combined; exit 0 when clean
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
When `quiet_hours` is configured in `policy.yaml`, `rules conflicts` additionally flags event-driven (MQTT / webhook) rules that lack a `time_between` condition — they would fire uninhibited during the quiet window. The hint in each finding includes a ready-to-paste `time_between` condition to add.
|
|
314
|
+
|
|
315
|
+
Webhook trigger token management:
|
|
316
|
+
|
|
317
|
+
```bash
|
|
318
|
+
switchbot rules webhook-rotate-token # rotate the bearer token for webhook triggers
|
|
319
|
+
switchbot rules webhook-show-token # print current token (creates one if absent)
|
|
298
320
|
```
|
|
299
321
|
|
|
300
322
|
See [`docs/design/phase4-rules.md`](./docs/design/phase4-rules.md) for
|
|
@@ -356,6 +378,12 @@ switchbot devices command ABC123 turnOn --dry-run
|
|
|
356
378
|
switchbot config set-token <token> <secret> # Save to ~/.switchbot/config.json
|
|
357
379
|
switchbot config show # Print current source + masked secret
|
|
358
380
|
switchbot config list-profiles # List saved profiles
|
|
381
|
+
|
|
382
|
+
# Print (or write) the recommended AI-agent profile template
|
|
383
|
+
switchbot config agent-profile # print to stdout
|
|
384
|
+
switchbot config agent-profile --write # write to ~/.switchbot/profiles/agent.json (mode 0600)
|
|
385
|
+
switchbot config agent-profile --write --force # overwrite if it already exists
|
|
386
|
+
switchbot config agent-profile --json # structured JSON envelope
|
|
359
387
|
```
|
|
360
388
|
|
|
361
389
|
### `devices` — list, status, control
|
|
@@ -551,6 +579,10 @@ skipped devices appear under `summary.skipped` with `skippedReason:'offline'`.
|
|
|
551
579
|
```bash
|
|
552
580
|
switchbot scenes list # Columns: sceneId, sceneName
|
|
553
581
|
switchbot scenes execute <sceneId>
|
|
582
|
+
|
|
583
|
+
# One-shot summary: risk profile, execution hint, estimated commands
|
|
584
|
+
switchbot scenes explain <sceneId>
|
|
585
|
+
switchbot scenes explain <sceneId> --json
|
|
554
586
|
```
|
|
555
587
|
|
|
556
588
|
### `webhook` — receive device events over HTTP
|
|
@@ -710,6 +742,34 @@ switchbot events mqtt-tail --sink homeassistant --ha-url http://homeassistant.lo
|
|
|
710
742
|
|
|
711
743
|
Device state is also persisted to `~/.switchbot/device-history/<deviceId>.json` (latest + 100-entry ring buffer) regardless of sink configuration. This enables the `get_device_history` MCP tool to answer state queries without an API call.
|
|
712
744
|
|
|
745
|
+
### `daemon` — background rules-engine process
|
|
746
|
+
|
|
747
|
+
Runs `switchbot rules run` as a detached background process. Tracks runtime
|
|
748
|
+
metadata in `~/.switchbot/daemon.state.json` and can co-launch a health HTTP
|
|
749
|
+
server.
|
|
750
|
+
|
|
751
|
+
```bash
|
|
752
|
+
# Start the daemon (no-op if already running)
|
|
753
|
+
switchbot daemon start
|
|
754
|
+
switchbot daemon start --policy ./my-policy.yaml
|
|
755
|
+
switchbot daemon start --healthz-port 3100 # also launch health serve on port 3100
|
|
756
|
+
switchbot daemon start --force # restart even if already running
|
|
757
|
+
|
|
758
|
+
# Inspect daemon state (pid, log path, health server, last reload)
|
|
759
|
+
switchbot daemon status
|
|
760
|
+
switchbot daemon status --json
|
|
761
|
+
|
|
762
|
+
# Hot-reload policy without restarting (sends SIGHUP on Unix, writes sentinel on Windows)
|
|
763
|
+
switchbot daemon reload
|
|
764
|
+
|
|
765
|
+
# Stop the daemon and any co-launched health server
|
|
766
|
+
switchbot daemon stop
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
Start prints the PID, log path, and state file location. If the process exits
|
|
770
|
+
within 300 ms of launch, start fails immediately and includes the last 20 lines
|
|
771
|
+
of the log in the error message for fast diagnosis.
|
|
772
|
+
|
|
713
773
|
### `completion` — shell tab-completion
|
|
714
774
|
|
|
715
775
|
```bash
|
|
@@ -743,15 +803,18 @@ switchbot plan validate plan.json
|
|
|
743
803
|
# Preview — mutations skipped, GETs still execute
|
|
744
804
|
switchbot --dry-run plan run plan.json
|
|
745
805
|
|
|
746
|
-
#
|
|
747
|
-
switchbot plan
|
|
806
|
+
# Save / review / approve / execute for destructive plans
|
|
807
|
+
switchbot plan save plan.json
|
|
808
|
+
switchbot plan review <planId>
|
|
809
|
+
switchbot plan approve <planId>
|
|
810
|
+
switchbot plan execute <planId>
|
|
748
811
|
switchbot plan run plan.json --continue-on-error
|
|
749
812
|
|
|
750
813
|
# Run with per-step TTY confirmation for destructive steps (human-in-the-loop)
|
|
751
814
|
switchbot plan run plan.json --require-approval
|
|
752
815
|
```
|
|
753
816
|
|
|
754
|
-
A plan file is a JSON document with `version`, `description`, and a `steps` array of `command`, `scene`, or `wait` steps. Steps execute sequentially; a failed step stops the run unless `--continue-on-error` is set.
|
|
817
|
+
A plan file is a JSON document with `version`, `description`, and a `steps` array of `command`, `scene`, or `wait` steps. Steps execute sequentially; a failed step stops the run unless `--continue-on-error` is set. `plan run` is the preview/direct path, but destructive steps are blocked by default and should go through `plan save` → `plan review` → `plan approve` → `plan execute`. See [`docs/agent-guide.md`](./docs/agent-guide.md) for the full schema and agent integration patterns.
|
|
755
818
|
|
|
756
819
|
### `devices watch` — poll status
|
|
757
820
|
|
|
@@ -792,6 +855,56 @@ switchbot doctor --json
|
|
|
792
855
|
|
|
793
856
|
Runs local checks (Node version, credentials, profiles, catalog, cache, quota, clock, MQTT, policy, MCP) and exits 1 if any check fails. `warn` results exit 0. The MQTT check reports `ok` when REST credentials are configured (auto-provisioned on first use). Use this to diagnose connectivity or config issues before running automation.
|
|
794
857
|
|
|
858
|
+
`--json` output includes `maturityScore` (0–100) and `maturityLabel` (`production-ready` / `mostly-ready` / `needs-work` / `not-ready`) to give an at-a-glance readiness rating:
|
|
859
|
+
|
|
860
|
+
```bash
|
|
861
|
+
switchbot doctor --json | jq '{score: .data.maturityScore, label: .data.maturityLabel}'
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
Pass `--fix --yes` to auto-apply safe fixes (e.g. clear stale cache entries) without a prompt.
|
|
865
|
+
|
|
866
|
+
### `health` — runtime health report
|
|
867
|
+
|
|
868
|
+
```bash
|
|
869
|
+
# One-shot report: quota, audit error rate, circuit-breaker state
|
|
870
|
+
switchbot health check
|
|
871
|
+
switchbot health check --prometheus # Prometheus text format
|
|
872
|
+
switchbot health check --json
|
|
873
|
+
|
|
874
|
+
# Start a long-running HTTP server with /healthz and /metrics
|
|
875
|
+
switchbot health serve # default port 3100, bind 127.0.0.1
|
|
876
|
+
switchbot health serve --port 8080
|
|
877
|
+
switchbot health serve --json # print {"status":"listening",...} on start
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
`/healthz` returns a JSON health report (HTTP 200 when `ok`/`degraded`, 503 when circuit is open).
|
|
881
|
+
`/metrics` returns Prometheus text metrics (`switchbot_quota_used_total`, `switchbot_circuit_open`, …).
|
|
882
|
+
Port conflicts are reported immediately with a clear hint to choose a different port via `--port`.
|
|
883
|
+
|
|
884
|
+
### `upgrade-check` — version check
|
|
885
|
+
|
|
886
|
+
```bash
|
|
887
|
+
switchbot upgrade-check # human output; exits 1 when update available
|
|
888
|
+
switchbot upgrade-check --json # structured JSON output
|
|
889
|
+
switchbot upgrade-check --timeout 5000 # custom registry timeout (ms)
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
Queries the npm registry for the latest published version and compares it against the running version. When the registry's `dist-tags.latest` is itself a prerelease (e.g. `4.0.0-rc.1`), the check is skipped and the current version is treated as up-to-date — accidental prerelease tags don't trigger spurious upgrade prompts.
|
|
893
|
+
`--json` output:
|
|
894
|
+
|
|
895
|
+
```json
|
|
896
|
+
{
|
|
897
|
+
"current": "3.2.1",
|
|
898
|
+
"latest": "4.0.0",
|
|
899
|
+
"upToDate": false,
|
|
900
|
+
"updateAvailable": true,
|
|
901
|
+
"breakingChange": true,
|
|
902
|
+
"installCommand": "npm install -g @switchbot/openapi-cli@4.0.0"
|
|
903
|
+
}
|
|
904
|
+
```
|
|
905
|
+
|
|
906
|
+
`breakingChange` is `true` when the latest major version is higher than the current — useful for agents or CI that need to distinguish breaking upgrades from patch releases.
|
|
907
|
+
|
|
795
908
|
### `quota` — API request counter
|
|
796
909
|
|
|
797
910
|
```bash
|
|
@@ -876,6 +989,11 @@ switchbot policy validate --no-snippet # plain error list, no source
|
|
|
876
989
|
|
|
877
990
|
# Report the schema version the file declares
|
|
878
991
|
switchbot policy migrate
|
|
992
|
+
|
|
993
|
+
# Snapshot and restore the active policy
|
|
994
|
+
switchbot policy backup # write timestamped backup alongside policy file
|
|
995
|
+
switchbot policy backup --out ./backups/ # custom destination directory
|
|
996
|
+
switchbot policy restore <backup-file> # overwrite active policy from backup (auto-backups first)
|
|
879
997
|
```
|
|
880
998
|
|
|
881
999
|
Path resolution order: positional `[path]` > `SWITCHBOT_POLICY_PATH` env var > default policy path.
|
|
@@ -1005,7 +1123,7 @@ npm install
|
|
|
1005
1123
|
|
|
1006
1124
|
npm run dev -- <args> # Run from TypeScript sources via tsx
|
|
1007
1125
|
npm run build # Compile to dist/
|
|
1008
|
-
npm test # Run the Vitest suite (
|
|
1126
|
+
npm test # Run the Vitest suite (1882 tests)
|
|
1009
1127
|
npm run test:watch # Watch mode
|
|
1010
1128
|
npm run test:coverage # Coverage report (v8, HTML + text)
|
|
1011
1129
|
```
|
|
@@ -1045,6 +1163,8 @@ src/
|
|
|
1045
1163
|
│ ├── webhook-listener.ts # HTTP listener (bearer token, localhost-only)
|
|
1046
1164
|
│ ├── pid-file.ts # Hot-reload via SIGHUP or sentinel file
|
|
1047
1165
|
│ ├── audit-query.ts # Audit log filtering + aggregation
|
|
1166
|
+
│ ├── conflict-analyzer.ts # Static conflict detection (opposing actions,
|
|
1167
|
+
│ │ # high-freq MQTT, destructive cmds, quiet-hours gaps)
|
|
1048
1168
|
│ ├── suggest.ts # Heuristic-based rule YAML generation
|
|
1049
1169
|
│ └── types.ts # Shared rule/trigger/condition/action types
|
|
1050
1170
|
├── status-sync/
|
|
@@ -1059,9 +1179,12 @@ src/
|
|
|
1059
1179
|
│ ├── explain.ts # `devices explain` — one-shot device summary
|
|
1060
1180
|
│ ├── device-meta.ts # `devices meta` — local aliases / hide flags
|
|
1061
1181
|
│ ├── install.ts # `switchbot install` / `uninstall`
|
|
1062
|
-
│ ├── policy.ts # `policy validate/new/migrate/diff/add-rule`
|
|
1063
|
-
│ ├── rules.ts # `rules suggest/lint/list/run/reload/tail/replay
|
|
1182
|
+
│ ├── policy.ts # `policy validate/new/migrate/diff/add-rule/backup/restore`
|
|
1183
|
+
│ ├── rules.ts # `rules suggest/lint/list/explain/run/reload/tail/replay/
|
|
1184
|
+
│ │ # conflicts/doctor/summary/last-fired/webhook-*`
|
|
1064
1185
|
│ ├── scenes.ts
|
|
1186
|
+
│ ├── health.ts # `health check/serve` — report + HTTP endpoints
|
|
1187
|
+
│ ├── upgrade-check.ts # `upgrade-check` — npm registry version check
|
|
1065
1188
|
│ ├── status-sync.ts # `status-sync run/start/stop/status`
|
|
1066
1189
|
│ ├── webhook.ts
|
|
1067
1190
|
│ ├── watch.ts # `devices watch <deviceId>`
|
|
@@ -1082,7 +1205,7 @@ src/
|
|
|
1082
1205
|
├── format.ts # renderRows / filterFields / output-format dispatch
|
|
1083
1206
|
├── audit.ts # JSONL audit log writer
|
|
1084
1207
|
└── quota.ts # Local daily-quota counter
|
|
1085
|
-
tests/ # Vitest suite (
|
|
1208
|
+
tests/ # Vitest suite (1882 tests, mocked axios, no network)
|
|
1086
1209
|
```
|
|
1087
1210
|
|
|
1088
1211
|
### Release flow
|
|
@@ -1096,41 +1219,6 @@ git push --follow-tags
|
|
|
1096
1219
|
|
|
1097
1220
|
Then on GitHub → **Releases → Draft a new release → select tag → Publish**. The `publish.yml` workflow runs tests, verifies the tag matches `package.json`, and publishes `@switchbot/openapi-cli` to npm with [provenance](https://docs.npmjs.com/generating-provenance-statements).
|
|
1098
1221
|
|
|
1099
|
-
## Contributing
|
|
1100
|
-
|
|
1101
|
-
Bug reports, feature requests, and PRs are welcome.
|
|
1102
|
-
|
|
1103
|
-
1. Fork the repo and create a topic branch.
|
|
1104
|
-
2. Keep changes small and focused; add or update Vitest cases for any behavior change.
|
|
1105
|
-
3. Run `npm test` and `npm run build` locally — both must pass.
|
|
1106
|
-
4. Open a pull request against `main`. CI runs on Node 18/20/22; all three must stay green.
|
|
1107
|
-
|
|
1108
|
-
## Roadmap
|
|
1109
|
-
|
|
1110
|
-
Phase 1 through Phase 4 are shipped. The authoritative phase/track table
|
|
1111
|
-
(including skill-side `autonomyLevel` L1/L2/L3 mapping) lives in
|
|
1112
|
-
[`docs/design/roadmap.md`](./docs/design/roadmap.md).
|
|
1113
|
-
|
|
1114
|
-
Shipped tracks summary:
|
|
1115
|
-
|
|
1116
|
-
- **Track β**: one-command install/uninstall surface (`switchbot install` / `switchbot uninstall`).
|
|
1117
|
-
- **Track γ**: rules v0.2 runtime increment (`days` + `all`/`any`/`not`).
|
|
1118
|
-
- **Track δ (L2)**: plan authoring + guarded execution (`plan suggest`, `plan run --require-approval`) and MCP review/execute tools (`plan_suggest`, `plan_run`, `audit_query`, `audit_stats`, `policy_diff`).
|
|
1119
|
-
- **Track ζ (L3)**: autonomous rule authoring (`rules suggest`, `policy add-rule`) with MCP parity (`rules_suggest`, `policy_add_rule`).
|
|
1120
|
-
- **Track ε**: cross-OS keychain CI matrix (macOS + Linux libsecret + Windows Credential Manager).
|
|
1121
|
-
|
|
1122
|
-
Backlog tracks still open:
|
|
1123
|
-
|
|
1124
|
-
1. **Daemon mode** — long-running local process with Unix/named-pipe
|
|
1125
|
-
transport so repeated MCP or plan invocations avoid fresh-process
|
|
1126
|
-
startup cost.
|
|
1127
|
-
2. **`npx @switchbot/mcp-server`** — split the MCP server into a tiny
|
|
1128
|
-
package so non-CLI users can run it directly with `npx`.
|
|
1129
|
-
3. **`switchbot self-test`** — scripted end-to-end go/no-go checks for
|
|
1130
|
-
token/secret validity plus representative device control.
|
|
1131
|
-
4. **Record / replay** — capture request/response fixtures and replay
|
|
1132
|
-
offline for deterministic integration tests and CI.
|
|
1133
|
-
|
|
1134
1222
|
## License
|
|
1135
1223
|
|
|
1136
1224
|
[MIT](./LICENSE) © chenliuyun
|
package/dist/api/client.js
CHANGED
|
@@ -3,7 +3,7 @@ import chalk from 'chalk';
|
|
|
3
3
|
import { buildAuthHeaders } from '../auth.js';
|
|
4
4
|
import { loadConfig } from '../config.js';
|
|
5
5
|
import { isVerbose, isDryRun, getTimeout, getRetryOn429, getRetryOn5xx, getBackoffStrategy, isQuotaDisabled, } from '../utils/flags.js';
|
|
6
|
-
import { nextRetryDelayMs, sleep } from '../utils/retry.js';
|
|
6
|
+
import { nextRetryDelayMs, sleep, CircuitBreaker, CircuitOpenError } from '../utils/retry.js';
|
|
7
7
|
import { recordRequest, checkDailyCap } from '../utils/quota.js';
|
|
8
8
|
import { readProfileMeta } from '../config.js';
|
|
9
9
|
import { getActiveProfile } from '../lib/request-context.js';
|
|
@@ -40,6 +40,17 @@ export class DryRunSignal extends Error {
|
|
|
40
40
|
this.name = 'DryRunSignal';
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Module-level circuit breaker for the SwitchBot API. Shared across all
|
|
45
|
+
* client instances in the process. Opens after 5 consecutive 5xx / network
|
|
46
|
+
* errors; resets to half-open after 60 s.
|
|
47
|
+
* Exported for health-check inspection.
|
|
48
|
+
*/
|
|
49
|
+
export const apiCircuitBreaker = new CircuitBreaker('switchbot-api', {
|
|
50
|
+
failureThreshold: 5,
|
|
51
|
+
resetTimeoutMs: 60_000,
|
|
52
|
+
});
|
|
53
|
+
export { CircuitOpenError };
|
|
43
54
|
export function createClient() {
|
|
44
55
|
const { token, secret } = loadConfig();
|
|
45
56
|
const verbose = isVerbose();
|
|
@@ -57,6 +68,8 @@ export function createClient() {
|
|
|
57
68
|
});
|
|
58
69
|
// Inject auth headers; optionally log the request; short-circuit on --dry-run.
|
|
59
70
|
client.interceptors.request.use((config) => {
|
|
71
|
+
// Circuit breaker check — fail fast when the API is consistently down.
|
|
72
|
+
apiCircuitBreaker.checkAndAllow();
|
|
60
73
|
// Pre-flight cap check: refuse the call before it touches the network.
|
|
61
74
|
if (dailyCap) {
|
|
62
75
|
const check = checkDailyCap(dailyCap);
|
|
@@ -110,6 +123,8 @@ export function createClient() {
|
|
|
110
123
|
`API error code: ${data.statusCode}`;
|
|
111
124
|
throw new ApiError(msg, data.statusCode);
|
|
112
125
|
}
|
|
126
|
+
// Successful HTTP response — record for circuit breaker.
|
|
127
|
+
apiCircuitBreaker.recordSuccess();
|
|
113
128
|
return response;
|
|
114
129
|
}, (error) => {
|
|
115
130
|
if (error instanceof DryRunSignal)
|
|
@@ -131,6 +146,8 @@ export function createClient() {
|
|
|
131
146
|
return sleep(delay).then(() => client.request(config));
|
|
132
147
|
}
|
|
133
148
|
}
|
|
149
|
+
// Network-level failure — record for circuit breaker.
|
|
150
|
+
apiCircuitBreaker.recordFailure();
|
|
134
151
|
throw new ApiError(`Request timed out after ${getTimeout()}ms (override with --timeout <ms>)`, 0, { transient: true, retryable: isIdempotentRead });
|
|
135
152
|
}
|
|
136
153
|
const status = error.response?.status;
|
|
@@ -165,6 +182,11 @@ export function createClient() {
|
|
|
165
182
|
return sleep(delay).then(() => client.request(config));
|
|
166
183
|
}
|
|
167
184
|
}
|
|
185
|
+
// Record 5xx and network errors for circuit breaker. 4xx errors are
|
|
186
|
+
// expected business responses — don't count toward circuit threshold.
|
|
187
|
+
if (status === undefined || status >= 500) {
|
|
188
|
+
apiCircuitBreaker.recordFailure();
|
|
189
|
+
}
|
|
168
190
|
// P8: quota already recorded in the request interceptor before
|
|
169
191
|
// dispatch — no extra bookkeeping needed here on the error path.
|
|
170
192
|
// Timeouts, DNS failures, 5xx, and exhausted retries all counted
|
package/dist/commands/batch.js
CHANGED
|
@@ -6,6 +6,7 @@ import { parseFilter, applyFilter, FilterSyntaxError } from '../utils/filter.js'
|
|
|
6
6
|
import { isDryRun } from '../utils/flags.js';
|
|
7
7
|
import { DryRunSignal } from '../api/client.js';
|
|
8
8
|
import { getCachedTypeMap, getCachedDevice, loadStatusCache } from '../devices/cache.js';
|
|
9
|
+
import { allowsDirectDestructiveExecution, destructiveExecutionHint } from '../lib/destructive-mode.js';
|
|
9
10
|
const DEFAULT_CONCURRENCY = 5;
|
|
10
11
|
const COMMAND_TYPES = ['command', 'customize'];
|
|
11
12
|
/**
|
|
@@ -98,7 +99,7 @@ export function registerBatchCommand(devices) {
|
|
|
98
99
|
.option('--stagger <ms>', 'Fixed delay between task starts in ms (default 0 = random 20-60ms jitter)', intArg('--stagger', { min: 0 }), '0')
|
|
99
100
|
.option('--plan', '[DEPRECATED, use --emit-plan] With --dry-run: emit a plan JSON document instead of executing anything')
|
|
100
101
|
.option('--emit-plan', 'With --dry-run: emit a plan JSON document instead of executing anything')
|
|
101
|
-
.option('--yes', 'Allow destructive commands
|
|
102
|
+
.option('--yes', 'Allow destructive commands only from an explicit dev profile')
|
|
102
103
|
.option('--type <commandType>', '"command" (default) or "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
|
|
103
104
|
.option('--stdin', 'Read deviceIds from stdin, one per line (same as trailing "-")')
|
|
104
105
|
.option('--idempotency-key-prefix <prefix>', 'Client-supplied prefix for idempotency keys (key per device: <prefix>-<deviceId>). process-local 60s window; cache is per Node process (MCP session, batch run, plan run). Independent CLI invocations do not share cache.', stringArg('--idempotency-key-prefix'))
|
|
@@ -134,7 +135,8 @@ Planning:
|
|
|
134
135
|
|
|
135
136
|
Safety:
|
|
136
137
|
Destructive commands (Smart Lock unlock, Garage Door Opener turnOn/turnOff,
|
|
137
|
-
Keypad createKey/deleteKey) are blocked by default.
|
|
138
|
+
Keypad createKey/deleteKey) are blocked by default. Use the reviewed plan
|
|
139
|
+
flow instead of direct execution.
|
|
138
140
|
--dry-run intercepts every POST and reports the intended calls without
|
|
139
141
|
hitting the API.
|
|
140
142
|
|
|
@@ -142,7 +144,7 @@ Examples:
|
|
|
142
144
|
$ switchbot devices batch turnOff --filter 'type~=Light,family=home'
|
|
143
145
|
$ switchbot devices batch turnOn --ids ID1,ID2,ID3
|
|
144
146
|
$ switchbot devices list --format=id --filter 'type=Bot' | switchbot devices batch toggle -
|
|
145
|
-
$ switchbot devices batch unlock --filter 'type=Smart Lock' --
|
|
147
|
+
$ switchbot devices batch unlock --filter 'type=Smart Lock' --dry-run --emit-plan
|
|
146
148
|
`)
|
|
147
149
|
.action(async (cmd, parameter, options, commandObj) => {
|
|
148
150
|
// Trailing "-" sentinel selects stdin mode.
|
|
@@ -234,10 +236,24 @@ Examples:
|
|
|
234
236
|
code: 2,
|
|
235
237
|
kind: 'guard',
|
|
236
238
|
message: `Refusing to run destructive command "${cmd}" on ${blockedForDestructive.length} device(s) without --yes: ${deviceIds.join(', ')}`,
|
|
237
|
-
hint: 'Re-issue the call with --yes
|
|
239
|
+
hint: 'Re-issue the call with --yes only from an explicit dev profile, or use the reviewed plan flow.',
|
|
238
240
|
context: { command: cmd, deviceIds },
|
|
239
241
|
});
|
|
240
242
|
}
|
|
243
|
+
if (blockedForDestructive.length > 0 && options.yes && !allowsDirectDestructiveExecution()) {
|
|
244
|
+
exitWithError({
|
|
245
|
+
code: 2,
|
|
246
|
+
kind: 'guard',
|
|
247
|
+
message: `Direct destructive execution is disabled for batch command "${cmd}".`,
|
|
248
|
+
hint: destructiveExecutionHint(),
|
|
249
|
+
context: {
|
|
250
|
+
command: cmd,
|
|
251
|
+
blockedCount: blockedForDestructive.length,
|
|
252
|
+
deviceIds: blockedForDestructive.map((item) => item.deviceId),
|
|
253
|
+
requiredWorkflow: 'plan-approval',
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
241
257
|
// parameter may be a JSON object string; mirror the single-command action.
|
|
242
258
|
let parsedParam = parameter ?? 'default';
|
|
243
259
|
if (parameter) {
|