@switchbot/openapi-cli 3.1.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 CHANGED
@@ -73,6 +73,7 @@ Under the hood every surface shares the same catalog, cache, and HMAC client —
73
73
  - [`capabilities`](#capabilities--cli-manifest)
74
74
  - [`cache`](#cache--inspect-and-clear-local-cache)
75
75
  - [`policy`](#policy--validate-scaffold-and-migrate-policyyaml)
76
+ - [`daemon`](#daemon--background-rules-engine-process)
76
77
  - [`completion`](#completion--shell-tab-completion)
77
78
  - [Output modes](#output-modes)
78
79
  - [Cache](#cache)
@@ -80,8 +81,6 @@ Under the hood every surface shares the same catalog, cache, and HMAC client —
80
81
  - [Environment variables](#environment-variables)
81
82
  - [Scripting examples](#scripting-examples)
82
83
  - [Development](#development)
83
- - [Contributing](#contributing)
84
- - [Roadmap](#roadmap)
85
84
  - [License](#license)
86
85
  - [References](#references)
87
86
 
@@ -94,7 +93,7 @@ Under the hood every surface shares the same catalog, cache, and HMAC client —
94
93
  - 🎨 **Dual output modes** — colorized tables by default; `--json` passthrough for `jq` and scripting
95
94
  - 🔐 **Secure credentials** — HMAC-SHA256 signed requests; config file written with `0600`; env-var override for CI
96
95
  - 🔍 **Dry-run mode** — preview every mutating request before it hits the API
97
- - 🧪 **Fully tested** — 1856 Vitest tests, mocked axios, zero network in CI
96
+ - 🧪 **Fully tested** — 1882 Vitest tests, mocked axios, zero network in CI
98
97
  - ⚡ **Shell completion** — Bash / Zsh / Fish / PowerShell
99
98
 
100
99
  ## Requirements
@@ -743,6 +742,34 @@ switchbot events mqtt-tail --sink homeassistant --ha-url http://homeassistant.lo
743
742
 
744
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.
745
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
+
746
773
  ### `completion` — shell tab-completion
747
774
 
748
775
  ```bash
@@ -862,7 +889,7 @@ switchbot upgrade-check --json # structured JSON output
862
889
  switchbot upgrade-check --timeout 5000 # custom registry timeout (ms)
863
890
  ```
864
891
 
865
- Queries the npm registry for the latest published version and compares it against the running version.
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.
866
893
  `--json` output:
867
894
 
868
895
  ```json
@@ -1096,7 +1123,7 @@ npm install
1096
1123
 
1097
1124
  npm run dev -- <args> # Run from TypeScript sources via tsx
1098
1125
  npm run build # Compile to dist/
1099
- npm test # Run the Vitest suite (1856 tests)
1126
+ npm test # Run the Vitest suite (1882 tests)
1100
1127
  npm run test:watch # Watch mode
1101
1128
  npm run test:coverage # Coverage report (v8, HTML + text)
1102
1129
  ```
@@ -1152,7 +1179,7 @@ src/
1152
1179
  │ ├── explain.ts # `devices explain` — one-shot device summary
1153
1180
  │ ├── device-meta.ts # `devices meta` — local aliases / hide flags
1154
1181
  │ ├── install.ts # `switchbot install` / `uninstall`
1155
- │ ├── policy.ts # `policy validate/new/migrate/diff/add-rule`
1182
+ │ ├── policy.ts # `policy validate/new/migrate/diff/add-rule/backup/restore`
1156
1183
  │ ├── rules.ts # `rules suggest/lint/list/explain/run/reload/tail/replay/
1157
1184
  │ │ # conflicts/doctor/summary/last-fired/webhook-*`
1158
1185
  │ ├── scenes.ts
@@ -1178,7 +1205,7 @@ src/
1178
1205
  ├── format.ts # renderRows / filterFields / output-format dispatch
1179
1206
  ├── audit.ts # JSONL audit log writer
1180
1207
  └── quota.ts # Local daily-quota counter
1181
- tests/ # Vitest suite (1856 tests, mocked axios, no network)
1208
+ tests/ # Vitest suite (1882 tests, mocked axios, no network)
1182
1209
  ```
1183
1210
 
1184
1211
  ### Release flow
@@ -1192,41 +1219,6 @@ git push --follow-tags
1192
1219
 
1193
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).
1194
1221
 
1195
- ## Contributing
1196
-
1197
- Bug reports, feature requests, and PRs are welcome.
1198
-
1199
- 1. Fork the repo and create a topic branch.
1200
- 2. Keep changes small and focused; add or update Vitest cases for any behavior change.
1201
- 3. Run `npm test` and `npm run build` locally — both must pass.
1202
- 4. Open a pull request against `main`. CI runs on Node 18/20/22; all three must stay green.
1203
-
1204
- ## Roadmap
1205
-
1206
- Phase 1 through Phase 4 are shipped. The authoritative phase/track table
1207
- (including skill-side `autonomyLevel` L1/L2/L3 mapping) lives in
1208
- [`docs/design/roadmap.md`](./docs/design/roadmap.md).
1209
-
1210
- Shipped tracks summary:
1211
-
1212
- - **Track β**: one-command install/uninstall surface (`switchbot install` / `switchbot uninstall`).
1213
- - **Track γ**: rules v0.2 runtime increment (`days` + `all`/`any`/`not`).
1214
- - **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`).
1215
- - **Track ζ (L3)**: autonomous rule authoring (`rules suggest`, `policy add-rule`) with MCP parity (`rules_suggest`, `policy_add_rule`).
1216
- - **Track ε**: cross-OS keychain CI matrix (macOS + Linux libsecret + Windows Credential Manager).
1217
-
1218
- Backlog tracks still open:
1219
-
1220
- 1. **Daemon mode** — long-running local process with Unix/named-pipe
1221
- transport so repeated MCP or plan invocations avoid fresh-process
1222
- startup cost.
1223
- 2. **`npx @switchbot/mcp-server`** — split the MCP server into a tiny
1224
- package so non-CLI users can run it directly with `npx`.
1225
- 3. **`switchbot self-test`** — scripted end-to-end go/no-go checks for
1226
- token/secret validity plus representative device control.
1227
- 4. **Record / replay** — capture request/response fixtures and replay
1228
- offline for deterministic integration tests and CI.
1229
-
1230
1222
  ## License
1231
1223
 
1232
1224
  [MIT](./LICENSE) © chenliuyun
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { isJsonMode, printJson, exitWithError } from '../utils/output.js';
6
- import { readPidFile, writePidFile, isPidAlive, getDefaultPidFilePaths, writeReloadSentinel, sighupSupported } from '../rules/pid-file.js';
6
+ import { readPidFile, writePidFile, clearPidFile, isPidAlive, getDefaultPidFilePaths, writeReloadSentinel, sighupSupported } from '../rules/pid-file.js';
7
7
  import { stringArg } from '../utils/arg-parsers.js';
8
8
  import chalk from 'chalk';
9
9
  import { DAEMON_LOG_FILE, DAEMON_PID_FILE, DAEMON_STATE_FILE, HEALTHZ_PID_FILE, readDaemonState, writeDaemonState, } from '../lib/daemon-state.js';
@@ -65,6 +65,39 @@ function persistState(partial) {
65
65
  writeDaemonState(next);
66
66
  return next;
67
67
  }
68
+ function readLastLines(filePath, n = 20) {
69
+ try {
70
+ const content = fs.readFileSync(filePath, 'utf-8');
71
+ const lines = content.split('\n');
72
+ return lines.slice(Math.max(0, lines.length - n)).join('\n').trim();
73
+ }
74
+ catch {
75
+ return '';
76
+ }
77
+ }
78
+ async function probeLiveness(opts) {
79
+ await new Promise((resolve) => setTimeout(resolve, opts.delayMs));
80
+ const dead = opts.child.exitCode !== null || opts.child.killed;
81
+ if (!dead)
82
+ return true;
83
+ if (opts.fatal) {
84
+ if (opts.pidFile)
85
+ clearPidFile(opts.pidFile);
86
+ const exitCode = opts.child.exitCode ?? 'unknown';
87
+ const logSnippet = opts.logFile ? readLastLines(opts.logFile) : '';
88
+ const trailingLog = logSnippet ? `\n\nLast log lines:\n${logSnippet}` : '';
89
+ const logRef = opts.logFile ?? 'daemon log';
90
+ persistState({
91
+ status: 'failed', pid: null, failedAt: new Date().toISOString(),
92
+ failureReason: `Daemon exited immediately (code ${exitCode}). Check ${logRef}.`,
93
+ });
94
+ exitWithError({
95
+ code: 1, kind: 'runtime',
96
+ message: `Daemon process exited immediately (code ${exitCode}). Check ${logRef} for details.${trailingLog}`,
97
+ });
98
+ }
99
+ return false;
100
+ }
68
101
  function renderHumanStatus(status) {
69
102
  if (status.status === 'running' && status.pid !== null) {
70
103
  console.log(`${chalk.green('●')} Daemon is running (pid ${status.pid}).`);
@@ -118,7 +151,7 @@ The daemon reads the same policy file as \`switchbot rules run\`.
118
151
  .option('--policy <path>', 'Policy file path (default: auto-detected)', stringArg('--policy'))
119
152
  .option('--force', 'Restart even if the daemon appears to be running.')
120
153
  .option('--healthz-port <n>', 'Also start a health HTTP server on this port (default: disabled).')
121
- .action((opts) => {
154
+ .action(async (opts) => {
122
155
  const current = getDaemonStatus();
123
156
  if (current.status === 'running' && !opts.force) {
124
157
  if (isJsonMode()) {
@@ -139,7 +172,7 @@ The daemon reads the same policy file as \`switchbot rules run\`.
139
172
  }
140
173
  }
141
174
  const thisFile = fileURLToPath(import.meta.url);
142
- const cliEntry = path.resolve(path.dirname(thisFile), 'index.js');
175
+ const cliEntry = path.resolve(path.dirname(thisFile), '..', 'index.js');
143
176
  const args = ['rules', 'run'];
144
177
  if (opts.policy)
145
178
  args.push(opts.policy);
@@ -163,6 +196,11 @@ The daemon reads the same policy file as \`switchbot rules run\`.
163
196
  });
164
197
  child.unref();
165
198
  fs.closeSync(logFd);
199
+ // Liveness probe: wait 300 ms then verify the child is still alive.
200
+ await probeLiveness({
201
+ child, delayMs: 300, fatal: true,
202
+ pidFile: DAEMON_PID_FILE, logFile: DAEMON_LOG_FILE,
203
+ });
166
204
  const newPid = child.pid;
167
205
  if (!newPid) {
168
206
  persistState({
@@ -187,8 +225,13 @@ The daemon reads the same policy file as \`switchbot rules run\`.
187
225
  healthChild.unref();
188
226
  fs.closeSync(healthLogFd);
189
227
  if (healthChild.pid) {
190
- healthzPid = healthChild.pid;
191
- writePidFile(HEALTHZ_PID_FILE, healthzPid);
228
+ // Brief liveness probe for the health server process.
229
+ const healthAlive = await probeLiveness({ child: healthChild, delayMs: 200, fatal: false });
230
+ if (healthAlive) {
231
+ healthzPid = healthChild.pid;
232
+ writePidFile(HEALTHZ_PID_FILE, healthzPid);
233
+ }
234
+ // Non-fatal if health server dies — daemon itself is still running.
192
235
  }
193
236
  }
194
237
  persistState({
@@ -6,6 +6,8 @@ const require = createRequire(import.meta.url);
6
6
  const { name: pkgName, version: currentVersion } = require('../../package.json');
7
7
  function fetchLatestVersion(packageName, timeoutMs = 8000) {
8
8
  const encoded = packageName.replace('/', '%2F');
9
+ // /latest is shorthand for dist-tags.latest — always the current stable
10
+ // release tag, never a prerelease unless accidentally published as such.
9
11
  const url = `https://registry.npmjs.org/${encoded}/latest`;
10
12
  return new Promise((resolve, reject) => {
11
13
  const req = https.get(url, { timeout: timeoutMs }, (res) => {
@@ -28,6 +30,9 @@ function fetchLatestVersion(packageName, timeoutMs = 8000) {
28
30
  req.on('error', reject);
29
31
  });
30
32
  }
33
+ // Intentionally avoids the `semver` npm package (YAGNI): comparing two
34
+ // well-formed registry version strings needs only these 10 lines, and adding
35
+ // a runtime dep solely for version comparison would bloat install footprint.
31
36
  function semverGt(a, b) {
32
37
  const numParts = (v) => v.replace(/-.*$/, '').split('.').map((n) => Number.parseInt(n, 10));
33
38
  const [aMaj, aMin, aPat] = numParts(a);
@@ -64,6 +69,20 @@ export function registerUpgradeCheckCommand(program) {
64
69
  const upToDate = !semverGt(latestVersion, currentVersion);
65
70
  const currentMajor = Number.parseInt(currentVersion.split('.')[0], 10);
66
71
  const latestMajor = Number.parseInt(latestVersion.split('.')[0], 10);
72
+ if (latestVersion.includes('-')) {
73
+ const msg = `Latest registry version (${latestVersion}) is a prerelease — skipping update check.`;
74
+ if (isJsonMode()) {
75
+ printJson({
76
+ current: currentVersion, latest: latestVersion, upToDate: true,
77
+ updateAvailable: false, breakingChange: false, installCommand: null,
78
+ note: msg,
79
+ });
80
+ }
81
+ else {
82
+ console.log(`${chalk.green('✓')} You are running the latest stable version (${currentVersion}). Registry latest (${latestVersion}) is a prerelease — skipping.`);
83
+ }
84
+ return;
85
+ }
67
86
  const result = {
68
87
  current: currentVersion,
69
88
  latest: latestVersion,
@@ -203,3 +203,14 @@ export async function executeRuleAction(action, ctx) {
203
203
  return { ok: false, error: msg, deviceId, verb: parsed.verb };
204
204
  }
205
205
  }
206
+ /**
207
+ * Extract the raw deviceId from an action object without alias resolution.
208
+ * Prefers `action.device` over the deviceId embedded in the command string.
209
+ * Use resolveActionDevice() when alias resolution is needed.
210
+ */
211
+ export function extractDeviceIdFromAction(action) {
212
+ if (action.device)
213
+ return action.device;
214
+ const m = /\bdevices\s+command\s+(\S+)/.exec(action.command ?? '');
215
+ return m ? m[1] : null;
216
+ }
@@ -25,6 +25,7 @@
25
25
  import { isTimeBetween, isAllCondition, isAnyCondition, isNotCondition } from './types.js';
26
26
  import { parseMaxPerMs } from './throttle.js';
27
27
  import { isDestructiveCommand } from './destructive.js';
28
+ import { extractDeviceIdFromAction } from './action.js';
28
29
  /** Known opposing command pairs (order-independent). */
29
30
  const OPPOSING_PAIRS = [
30
31
  ['turnOn', 'turnOff'],
@@ -37,6 +38,19 @@ const OPPOSING_PAIRS = [
37
38
  ['volumeUp', 'volumeDown'],
38
39
  ['fanSpeedUp', 'fanSpeedDown'],
39
40
  ];
41
+ /**
42
+ * MQTT events that fire on every device state push and can rapidly exhaust
43
+ * the daily API quota when a rule has no throttle.
44
+ *
45
+ * Conditional events like `motion.detected` are intentionally excluded —
46
+ * they fire discretely, not at continuous high frequency. Extend this set
47
+ * when new catch-all or near-continuous event types are added to the classifier.
48
+ */
49
+ export const HIGH_FREQ_EVENTS = ['device.shadow', '*'];
50
+ /** Returns true when an MQTT event is known to fire at high frequency. */
51
+ export function isHighFreqEvent(event) {
52
+ return HIGH_FREQ_EVENTS.includes(event);
53
+ }
40
54
  function commandsAreOpposing(a, b) {
41
55
  for (const [x, y] of OPPOSING_PAIRS) {
42
56
  if ((a === x && b === y) || (a === y && b === x))
@@ -44,9 +58,6 @@ function commandsAreOpposing(a, b) {
44
58
  }
45
59
  return false;
46
60
  }
47
- function extractDeviceFromAction(action) {
48
- return action.device ?? null;
49
- }
50
61
  function extractCommandVerb(command) {
51
62
  // command strings are like "devices command <id> turnOn" — extract last token
52
63
  const parts = command.trim().split(/\s+/);
@@ -108,8 +119,8 @@ export function analyzeConflicts(rules, quietHours) {
108
119
  continue;
109
120
  for (const actionA of a.then) {
110
121
  for (const actionB of b.then) {
111
- const deviceA = extractDeviceFromAction(actionA);
112
- const deviceB = extractDeviceFromAction(actionB);
122
+ const deviceA = extractDeviceIdFromAction(actionA);
123
+ const deviceB = extractDeviceIdFromAction(actionB);
113
124
  // Skip if devices can't be compared.
114
125
  if (!deviceA || !deviceB || deviceA !== deviceB)
115
126
  continue;
@@ -136,7 +147,7 @@ export function analyzeConflicts(rules, quietHours) {
136
147
  if (rule.when.source !== 'mqtt')
137
148
  continue;
138
149
  const event = rule.when.event;
139
- const isHighFreq = event === 'device.shadow' || event === '*';
150
+ const isHighFreq = isHighFreqEvent(event);
140
151
  if (!isHighFreq)
141
152
  continue;
142
153
  const cooldown = effectiveCooldownMs(rule);
@@ -80,7 +80,7 @@ export function suggestRule(opts) {
80
80
  const then = actionDevices.length > 0
81
81
  ? actionDevices.map((d) => ({
82
82
  command: `devices command <id> ${command}`,
83
- device: d.name ?? d.id,
83
+ device: d.id,
84
84
  }))
85
85
  : [{ command: `devices command <id> ${command}` }];
86
86
  const rule = {
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs';
2
+ import os from 'node:os';
2
3
  import path from 'node:path';
3
4
  import { getAuditLog } from './flags.js';
5
+ export const DEFAULT_AUDIT_PATH = path.join(os.homedir(), '.switchbot', 'audit.log');
4
6
  /**
5
7
  * Bump when breaking changes to the audit line shape land.
6
8
  *
@@ -20,7 +22,9 @@ function resolveAuditPath() {
20
22
  return path.resolve(flag);
21
23
  }
22
24
  export function writeAudit(entry) {
23
- const file = resolveAuditPath();
25
+ let file = resolveAuditPath();
26
+ if (!file && entry.planId)
27
+ file = DEFAULT_AUDIT_PATH;
24
28
  if (!file)
25
29
  return;
26
30
  const dir = path.dirname(file);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchbot/openapi-cli",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.",
5
5
  "keywords": [
6
6
  "switchbot",