@switchbot/openapi-cli 2.7.2 → 3.1.0

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.
Files changed (69) hide show
  1. package/README.md +481 -103
  2. package/dist/api/client.js +23 -1
  3. package/dist/commands/agent-bootstrap.js +47 -2
  4. package/dist/commands/auth.js +354 -0
  5. package/dist/commands/batch.js +20 -4
  6. package/dist/commands/capabilities.js +155 -65
  7. package/dist/commands/config.js +109 -0
  8. package/dist/commands/daemon.js +367 -0
  9. package/dist/commands/devices.js +62 -11
  10. package/dist/commands/doctor.js +417 -8
  11. package/dist/commands/events.js +3 -3
  12. package/dist/commands/explain.js +1 -2
  13. package/dist/commands/health.js +113 -0
  14. package/dist/commands/install.js +246 -0
  15. package/dist/commands/mcp.js +888 -7
  16. package/dist/commands/plan.js +379 -103
  17. package/dist/commands/policy.js +586 -0
  18. package/dist/commands/rules.js +875 -0
  19. package/dist/commands/scenes.js +140 -0
  20. package/dist/commands/schema.js +0 -2
  21. package/dist/commands/status-sync.js +131 -0
  22. package/dist/commands/uninstall.js +237 -0
  23. package/dist/commands/upgrade-check.js +88 -0
  24. package/dist/config.js +14 -0
  25. package/dist/credentials/backends/file.js +101 -0
  26. package/dist/credentials/backends/linux.js +129 -0
  27. package/dist/credentials/backends/macos.js +129 -0
  28. package/dist/credentials/backends/windows.js +215 -0
  29. package/dist/credentials/keychain.js +88 -0
  30. package/dist/credentials/prime.js +52 -0
  31. package/dist/devices/catalog.js +4 -10
  32. package/dist/index.js +30 -1
  33. package/dist/install/default-steps.js +257 -0
  34. package/dist/install/preflight.js +212 -0
  35. package/dist/install/steps.js +67 -0
  36. package/dist/lib/command-keywords.js +17 -0
  37. package/dist/lib/daemon-state.js +46 -0
  38. package/dist/lib/destructive-mode.js +12 -0
  39. package/dist/lib/devices.js +1 -1
  40. package/dist/lib/plan-store.js +68 -0
  41. package/dist/policy/add-rule.js +124 -0
  42. package/dist/policy/diff.js +91 -0
  43. package/dist/policy/examples/policy.example.yaml +99 -0
  44. package/dist/policy/format.js +57 -0
  45. package/dist/policy/load.js +61 -0
  46. package/dist/policy/migrate.js +67 -0
  47. package/dist/policy/schema/v0.2.json +331 -0
  48. package/dist/policy/schema.js +18 -0
  49. package/dist/policy/validate.js +262 -0
  50. package/dist/rules/action.js +205 -0
  51. package/dist/rules/audit-query.js +89 -0
  52. package/dist/rules/conflict-analyzer.js +203 -0
  53. package/dist/rules/cron-scheduler.js +186 -0
  54. package/dist/rules/destructive.js +52 -0
  55. package/dist/rules/engine.js +757 -0
  56. package/dist/rules/matcher.js +230 -0
  57. package/dist/rules/pid-file.js +95 -0
  58. package/dist/rules/quiet-hours.js +45 -0
  59. package/dist/rules/suggest.js +95 -0
  60. package/dist/rules/throttle.js +116 -0
  61. package/dist/rules/types.js +34 -0
  62. package/dist/rules/webhook-listener.js +223 -0
  63. package/dist/rules/webhook-token.js +90 -0
  64. package/dist/status-sync/manager.js +268 -0
  65. package/dist/utils/audit.js +12 -2
  66. package/dist/utils/health.js +101 -0
  67. package/dist/utils/output.js +72 -23
  68. package/dist/utils/retry.js +81 -0
  69. package/package.json +12 -4
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Health report utilities — collects process, quota, audit, and circuit
3
+ * breaker state into a single snapshot suitable for /health-style checks
4
+ * and Prometheus-compatible metrics export.
5
+ *
6
+ * No side effects: reading is safe to call from any context.
7
+ */
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { todayUsage, DAILY_QUOTA } from './quota.js';
12
+ import { readAudit } from './audit.js';
13
+ import { apiCircuitBreaker } from '../api/client.js';
14
+ const DEFAULT_AUDIT_PATH = path.join(os.homedir(), '.switchbot', 'audit.log');
15
+ const AUDIT_ERROR_WINDOW_MS = 24 * 60 * 60 * 1000; // 24h
16
+ export function getHealthReport(auditPath = DEFAULT_AUDIT_PATH) {
17
+ const now = new Date();
18
+ // Process info
19
+ const procHealth = {
20
+ pid: process.pid,
21
+ uptimeSeconds: Math.floor(process.uptime()),
22
+ platform: process.platform,
23
+ nodeVersion: process.version,
24
+ memoryMb: Math.round(process.memoryUsage().rss / 1024 / 1024),
25
+ };
26
+ // Quota
27
+ const { total: used } = todayUsage(now);
28
+ const pct = Math.round((used / DAILY_QUOTA) * 100);
29
+ const quotaHealth = {
30
+ used,
31
+ limit: DAILY_QUOTA,
32
+ percentUsed: pct,
33
+ remaining: Math.max(0, DAILY_QUOTA - used),
34
+ status: pct >= 90 ? 'critical' : pct >= 70 ? 'warn' : 'ok',
35
+ };
36
+ // Audit error rate (last 24h)
37
+ let auditHealth;
38
+ if (!fs.existsSync(auditPath)) {
39
+ auditHealth = { present: false, recentErrors: 0, recentTotal: 0, errorRatePercent: 0, status: 'ok' };
40
+ }
41
+ else {
42
+ const entries = readAudit(auditPath);
43
+ const windowStart = now.getTime() - AUDIT_ERROR_WINDOW_MS;
44
+ const recent = entries.filter((e) => new Date(e.t).getTime() >= windowStart);
45
+ const errors = recent.filter((e) => e.result === 'error').length;
46
+ const total = recent.length;
47
+ const errorRate = total > 0 ? Math.round((errors / total) * 100) : 0;
48
+ auditHealth = {
49
+ present: true,
50
+ recentErrors: errors,
51
+ recentTotal: total,
52
+ errorRatePercent: errorRate,
53
+ status: errorRate >= 30 ? 'warn' : 'ok',
54
+ };
55
+ }
56
+ // Circuit breaker
57
+ const cbStats = apiCircuitBreaker.getStats();
58
+ const circuitHealth = {
59
+ name: apiCircuitBreaker.name,
60
+ state: cbStats.state,
61
+ failures: cbStats.failures,
62
+ status: cbStats.state === 'open' ? 'open' : 'ok',
63
+ };
64
+ // Overall
65
+ const degraded = quotaHealth.status !== 'ok' ||
66
+ auditHealth.status !== 'ok' ||
67
+ circuitHealth.status !== 'ok';
68
+ const down = circuitHealth.status === 'open';
69
+ const overall = down ? 'down' : degraded ? 'degraded' : 'ok';
70
+ return {
71
+ generatedAt: now.toISOString(),
72
+ overall,
73
+ process: procHealth,
74
+ quota: quotaHealth,
75
+ audit: auditHealth,
76
+ circuit: circuitHealth,
77
+ };
78
+ }
79
+ /**
80
+ * Render a minimal Prometheus-compatible text metrics export.
81
+ * Only includes the most actionable gauges.
82
+ */
83
+ export function toPrometheusText(report) {
84
+ const lines = [];
85
+ const push = (name, value, help) => {
86
+ if (help)
87
+ lines.push(`# HELP ${name} ${help}`);
88
+ lines.push(`# TYPE ${name} gauge`);
89
+ lines.push(`${name} ${value}`);
90
+ };
91
+ push('switchbot_quota_used_total', report.quota.used, 'SwitchBot API requests used today');
92
+ push('switchbot_quota_remaining', report.quota.remaining, 'SwitchBot API quota remaining today');
93
+ push('switchbot_quota_percent_used', report.quota.percentUsed, 'SwitchBot API quota percent used today');
94
+ push('switchbot_audit_recent_errors', report.audit.recentErrors, 'Audit log errors in the last 24h');
95
+ push('switchbot_audit_error_rate_percent', report.audit.errorRatePercent, 'Audit error rate percent (last 24h)');
96
+ push('switchbot_circuit_open', report.circuit.state === 'open' ? 1 : 0, 'API circuit breaker open (1=open, 0=closed/half-open)');
97
+ push('switchbot_circuit_failures', report.circuit.failures, 'Consecutive API failures recorded by circuit breaker');
98
+ push('switchbot_process_uptime_seconds', report.process.uptimeSeconds, 'Process uptime in seconds');
99
+ push('switchbot_process_memory_mb', report.process.memoryMb, 'Process RSS memory usage in MB');
100
+ return lines.join('\n') + '\n';
101
+ }
@@ -196,10 +196,40 @@ export function buildErrorPayload(error) {
196
196
  kind: 'usage',
197
197
  message: error.message,
198
198
  errorClass: 'usage',
199
- transient: false
199
+ transient: false,
200
200
  };
201
- if (error.context)
202
- payload.context = error.context;
201
+ if (error.context) {
202
+ const ctx = error.context;
203
+ const { error: errorType, candidates, hint } = ctx;
204
+ if (errorType === 'ambiguous_name_match') {
205
+ payload.subKind = 'ambiguous-name-match';
206
+ }
207
+ if (Array.isArray(candidates) && candidates.length > 0) {
208
+ const normalized = candidates
209
+ .map((c) => {
210
+ if (typeof c !== 'object' || c === null)
211
+ return null;
212
+ const o = c;
213
+ const name = typeof o.name === 'string' ? o.name
214
+ : typeof o.sceneName === 'string' ? o.sceneName
215
+ : '';
216
+ const match = { name };
217
+ if (typeof o.deviceId === 'string')
218
+ match.deviceId = o.deviceId;
219
+ if (typeof o.sceneId === 'string')
220
+ match.sceneId = o.sceneId;
221
+ return match;
222
+ })
223
+ .filter((c) => c !== null && c.name.length > 0);
224
+ if (normalized.length > 0)
225
+ payload.candidateMatches = normalized;
226
+ }
227
+ if (typeof hint === 'string') {
228
+ payload.resolutionHint = hint;
229
+ }
230
+ // Preserve full context for backward compatibility (including candidates / hint).
231
+ payload.context = ctx;
232
+ }
203
233
  return payload;
204
234
  }
205
235
  if (error instanceof UsageError) {
@@ -286,31 +316,50 @@ export function handleError(error) {
286
316
  }
287
317
  if (payload.kind === 'usage') {
288
318
  console.error(payload.message);
289
- const ctx = payload.context;
290
- if (ctx && Array.isArray(ctx.candidates) && ctx.candidates.length > 0) {
291
- const names = ctx.candidates
319
+ if (Array.isArray(payload.candidateMatches) && payload.candidateMatches.length > 0) {
320
+ const names = payload.candidateMatches
292
321
  .map((c) => {
293
- if (typeof c === 'string')
294
- return c;
295
- if (c && typeof c === 'object') {
296
- const o = c;
297
- const name = typeof o.name === 'string'
298
- ? o.name
299
- : typeof o.sceneName === 'string' ? o.sceneName : undefined;
300
- const id = typeof o.deviceId === 'string'
301
- ? o.deviceId
302
- : typeof o.sceneId === 'string' ? o.sceneId : typeof o.id === 'string' ? o.id : undefined;
303
- if (name && id)
304
- return `${name} (${id})`;
305
- return name ?? id ?? JSON.stringify(c);
306
- }
307
- return String(c);
322
+ const id = c.deviceId ?? c.sceneId;
323
+ if (c.name && id)
324
+ return `${c.name} (${id})`;
325
+ return c.name ?? id ?? JSON.stringify(c);
308
326
  })
309
327
  .slice(0, 6);
310
328
  console.error(`Did you mean: ${names.join(', ')}?`);
311
329
  }
312
- if (ctx && typeof ctx.hint === 'string') {
313
- console.error(ctx.hint);
330
+ else {
331
+ const ctx = payload.context;
332
+ if (ctx && Array.isArray(ctx.candidates) && ctx.candidates.length > 0) {
333
+ const names = ctx.candidates
334
+ .map((c) => {
335
+ if (typeof c === 'string')
336
+ return c;
337
+ if (c && typeof c === 'object') {
338
+ const o = c;
339
+ const name = typeof o.name === 'string'
340
+ ? o.name
341
+ : typeof o.sceneName === 'string' ? o.sceneName : undefined;
342
+ const id = typeof o.deviceId === 'string'
343
+ ? o.deviceId
344
+ : typeof o.sceneId === 'string' ? o.sceneId : typeof o.id === 'string' ? o.id : undefined;
345
+ if (name && id)
346
+ return `${name} (${id})`;
347
+ return name ?? id ?? JSON.stringify(c);
348
+ }
349
+ return String(c);
350
+ })
351
+ .slice(0, 6);
352
+ console.error(`Did you mean: ${names.join(', ')}?`);
353
+ }
354
+ }
355
+ if (payload.resolutionHint) {
356
+ console.error(payload.resolutionHint);
357
+ }
358
+ else {
359
+ const ctx = payload.context;
360
+ if (ctx && typeof ctx.hint === 'string') {
361
+ console.error(ctx.hint);
362
+ }
314
363
  }
315
364
  process.exit(2);
316
365
  }
@@ -8,6 +8,13 @@
8
8
  *
9
9
  * If the server returns a `Retry-After` header we always prefer it over our
10
10
  * own backoff — the API explicitly told us when to come back.
11
+ *
12
+ * Circuit breaker:
13
+ * Prevents hammering a consistently-failing endpoint. Tracks consecutive
14
+ * failures; when `failureThreshold` is exceeded the circuit opens and
15
+ * subsequent calls fail immediately (with CircuitOpenError). After
16
+ * `resetTimeoutMs` the circuit enters half-open state: the next call is
17
+ * allowed as a probe — success closes it, failure re-opens it.
11
18
  */
12
19
  const BASE_MS = 1_000;
13
20
  const MAX_MS = 30_000;
@@ -57,3 +64,77 @@ export function nextRetryDelayMs(attempt, strategy, retryAfterHeader, now = Date
57
64
  export function sleep(ms) {
58
65
  return new Promise((resolve) => setTimeout(resolve, ms));
59
66
  }
67
+ /**
68
+ * Thrown when a call is blocked because the circuit is open.
69
+ */
70
+ export class CircuitOpenError extends Error {
71
+ circuitName;
72
+ nextAttemptMs;
73
+ constructor(circuitName, nextAttemptMs) {
74
+ super(`Circuit "${circuitName}" is open — too many recent failures. Next probe allowed in ${Math.ceil((nextAttemptMs - Date.now()) / 1000)}s.`);
75
+ this.circuitName = circuitName;
76
+ this.nextAttemptMs = nextAttemptMs;
77
+ this.name = 'CircuitOpenError';
78
+ }
79
+ }
80
+ export class CircuitBreaker {
81
+ name;
82
+ state = 'closed';
83
+ failures = 0;
84
+ lastOpenedAt = 0;
85
+ failureThreshold;
86
+ resetTimeoutMs;
87
+ constructor(name, opts = {}) {
88
+ this.name = name;
89
+ this.failureThreshold = opts.failureThreshold ?? 5;
90
+ this.resetTimeoutMs = opts.resetTimeoutMs ?? 60_000;
91
+ }
92
+ getState() {
93
+ this._maybeHalfOpen();
94
+ return this.state;
95
+ }
96
+ getStats() {
97
+ this._maybeHalfOpen();
98
+ return {
99
+ state: this.state,
100
+ failures: this.failures,
101
+ lastOpenedAt: this.lastOpenedAt,
102
+ nextProbeMs: this.state === 'open' ? this.lastOpenedAt + this.resetTimeoutMs : 0,
103
+ };
104
+ }
105
+ /**
106
+ * Check if a call is allowed. Throws `CircuitOpenError` when the circuit
107
+ * is open and the reset timeout hasn't elapsed. Call `recordSuccess()` or
108
+ * `recordFailure()` after the operation completes.
109
+ */
110
+ checkAndAllow() {
111
+ this._maybeHalfOpen();
112
+ if (this.state === 'open') {
113
+ throw new CircuitOpenError(this.name, this.lastOpenedAt + this.resetTimeoutMs);
114
+ }
115
+ // closed or half-open: allow the call
116
+ }
117
+ recordSuccess() {
118
+ this.failures = 0;
119
+ this.state = 'closed';
120
+ }
121
+ recordFailure() {
122
+ this.failures++;
123
+ if (this.state === 'half-open' || this.failures >= this.failureThreshold) {
124
+ this.state = 'open';
125
+ this.lastOpenedAt = Date.now();
126
+ }
127
+ }
128
+ /** Reset to closed — useful for testing or manual recovery. */
129
+ reset() {
130
+ this.state = 'closed';
131
+ this.failures = 0;
132
+ this.lastOpenedAt = 0;
133
+ }
134
+ _maybeHalfOpen() {
135
+ if (this.state === 'open' &&
136
+ Date.now() >= this.lastOpenedAt + this.resetTimeoutMs) {
137
+ this.state = 'half-open';
138
+ }
139
+ }
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchbot/openapi-cli",
3
- "version": "2.7.2",
3
+ "version": "3.1.0",
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",
@@ -36,10 +36,12 @@
36
36
  "access": "public"
37
37
  },
38
38
  "scripts": {
39
- "build": "tsc",
40
- "build:prod": "tsc -p tsconfig.build.json",
39
+ "build": "tsc && node scripts/copy-assets.mjs",
40
+ "build:prod": "tsc -p tsconfig.build.json && node scripts/copy-assets.mjs",
41
41
  "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
42
42
  "dev": "tsx src/index.ts",
43
+ "lint:md": "markdownlint \"**/*.md\"",
44
+ "lint:md:changelog": "markdownlint CHANGELOG.md",
43
45
  "start": "node dist/index.js",
44
46
  "test": "vitest run",
45
47
  "test:watch": "vitest",
@@ -48,20 +50,26 @@
48
50
  },
49
51
  "dependencies": {
50
52
  "@modelcontextprotocol/sdk": "^1.29.0",
53
+ "ajv": "^8.18.0",
54
+ "ajv-formats": "^3.0.1",
51
55
  "axios": "^1.7.9",
52
56
  "chalk": "^5.4.1",
53
57
  "cli-table3": "^0.6.5",
54
58
  "commander": "^12.1.0",
59
+ "croner": "^10.0.1",
55
60
  "js-yaml": "^4.1.1",
56
61
  "mqtt": "^5.3.0",
57
62
  "pino": "^9.0.0",
58
- "uuid": "^11.0.5"
63
+ "uuid": "^11.0.5",
64
+ "yaml": "^2.8.3",
65
+ "zod": "^4.3.6"
59
66
  },
60
67
  "devDependencies": {
61
68
  "@types/js-yaml": "^4.0.9",
62
69
  "@types/node": "^22.10.7",
63
70
  "@types/uuid": "^10.0.0",
64
71
  "@vitest/coverage-v8": "^2.1.9",
72
+ "markdownlint-cli": "^0.48.0",
65
73
  "tsx": "^4.19.2",
66
74
  "typescript": "^5.7.3",
67
75
  "vitest": "^2.1.9"