@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,875 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { isJsonMode, printJson, exitWithError, printTable } from '../utils/output.js';
5
+ import { loadPolicyFile, resolvePolicyPath, DEFAULT_POLICY_PATH, PolicyFileNotFoundError, PolicyYamlParseError, } from '../policy/load.js';
6
+ import { validateLoadedPolicy } from '../policy/validate.js';
7
+ import { isWebhookTrigger } from '../rules/types.js';
8
+ import { lintRules, RulesEngine } from '../rules/engine.js';
9
+ import { analyzeConflicts, } from '../rules/conflict-analyzer.js';
10
+ import { tryLoadConfig } from '../config.js';
11
+ import { fetchMqttCredential } from '../mqtt/credential.js';
12
+ import { SwitchBotMqttClient } from '../mqtt/client.js';
13
+ import { WebhookTokenStore } from '../rules/webhook-token.js';
14
+ import { suggestRule } from '../rules/suggest.js';
15
+ import { getCachedDevice } from '../devices/cache.js';
16
+ import { getDefaultPidFilePaths, writePidFile, clearPidFile, consumeReloadSentinel, writeReloadSentinel, readPidFile, sighupSupported, isPidAlive, } from '../rules/pid-file.js';
17
+ import { readAudit } from '../utils/audit.js';
18
+ import { aggregateRuleAudits, filterRuleAudits, RULE_AUDIT_KINDS, } from '../rules/audit-query.js';
19
+ import { parseDurationToMs } from '../devices/history-query.js';
20
+ const DEFAULT_AUDIT_PATH = path.join(os.homedir(), '.switchbot', 'audit.log');
21
+ function loadAutomation(policyPathFlag) {
22
+ const path = resolvePolicyPath({ flag: policyPathFlag });
23
+ let loaded;
24
+ try {
25
+ loaded = loadPolicyFile(path);
26
+ }
27
+ catch (err) {
28
+ if (err instanceof PolicyFileNotFoundError) {
29
+ exitWithError({
30
+ code: 2,
31
+ kind: 'usage',
32
+ message: `policy file not found: ${path}`,
33
+ extra: { subKind: 'file-not-found' },
34
+ });
35
+ }
36
+ if (err instanceof PolicyYamlParseError) {
37
+ exitWithError({
38
+ code: 3,
39
+ kind: 'runtime',
40
+ message: `YAML parse error in ${path}: ${err.message}`,
41
+ extra: { subKind: 'yaml-parse', errors: err.yamlErrors },
42
+ });
43
+ }
44
+ throw err;
45
+ }
46
+ const result = validateLoadedPolicy(loaded);
47
+ if (!result.valid) {
48
+ exitWithError({
49
+ code: 4,
50
+ kind: 'runtime',
51
+ message: 'policy file failed schema validation. Run `switchbot policy validate` for details.',
52
+ extra: { subKind: 'invalid-policy', path },
53
+ });
54
+ }
55
+ const data = (loaded.data ?? {});
56
+ const automation = (data.automation ?? null);
57
+ const aliases = {};
58
+ const rawAliases = data.aliases;
59
+ if (rawAliases && typeof rawAliases === 'object') {
60
+ for (const [k, v] of Object.entries(rawAliases)) {
61
+ if (typeof v === 'string')
62
+ aliases[k] = v;
63
+ }
64
+ }
65
+ const rawQH = data.quiet_hours;
66
+ const quietHours = rawQH && typeof rawQH.start === 'string' && typeof rawQH.end === 'string'
67
+ ? { start: rawQH.start, end: rawQH.end }
68
+ : null;
69
+ return { path, automation, aliases, schemaVersion: result.schemaVersion, quietHours };
70
+ }
71
+ function describeTrigger(rule) {
72
+ const t = rule.when;
73
+ if (t.source === 'mqtt')
74
+ return t.device ? `mqtt:${t.event}@${t.device}` : `mqtt:${t.event}`;
75
+ if (t.source === 'cron') {
76
+ const base = `cron:${t.schedule}`;
77
+ return t.days && t.days.length > 0 ? `${base} [${t.days.join(',')}]` : base;
78
+ }
79
+ return `webhook:${t.path}`;
80
+ }
81
+ function formatLintHuman(result, schemaVersion) {
82
+ const lines = [];
83
+ lines.push(`policy schema: v${schemaVersion ?? '?'}`);
84
+ lines.push(`rules: ${result.rules.length} valid: ${result.valid} unsupported: ${result.unsupportedCount}`);
85
+ for (const r of result.rules) {
86
+ lines.push(` [${r.status}] ${r.name}`);
87
+ for (const i of r.issues) {
88
+ lines.push(` ${i.severity}/${i.code}: ${i.message}`);
89
+ }
90
+ }
91
+ return lines.join('\n');
92
+ }
93
+ function registerLint(rules) {
94
+ rules
95
+ .command('lint [path]')
96
+ .description('Static-check automation.rules — no MQTT, no API calls.')
97
+ .action((pathArg) => {
98
+ const loaded = loadAutomation(pathArg);
99
+ if (!loaded)
100
+ return;
101
+ const result = lintRules(loaded.automation);
102
+ if (isJsonMode()) {
103
+ printJson({
104
+ policyPath: loaded.path,
105
+ policySchemaVersion: loaded.schemaVersion,
106
+ automationEnabled: loaded.automation?.enabled === true,
107
+ ...result,
108
+ });
109
+ }
110
+ else {
111
+ console.log(formatLintHuman(result, loaded.schemaVersion));
112
+ }
113
+ process.exit(result.valid ? 0 : 1);
114
+ });
115
+ }
116
+ function registerList(rules) {
117
+ rules
118
+ .command('list [path]')
119
+ .description('List the rules declared in a policy file, with trigger / throttle / dry_run summary.')
120
+ .action((pathArg) => {
121
+ const loaded = loadAutomation(pathArg);
122
+ if (!loaded)
123
+ return;
124
+ const ruleEntries = (loaded.automation?.rules ?? []).map((r) => ({
125
+ name: r.name,
126
+ enabled: r.enabled !== false,
127
+ trigger: describeTrigger(r),
128
+ conditions: r.conditions?.length ?? 0,
129
+ actions: r.then.length,
130
+ throttle: r.throttle?.max_per ?? null,
131
+ dry_run: r.dry_run === true,
132
+ }));
133
+ if (isJsonMode()) {
134
+ printJson({
135
+ policyPath: loaded.path,
136
+ automationEnabled: loaded.automation?.enabled === true,
137
+ rules: ruleEntries,
138
+ });
139
+ }
140
+ else if (ruleEntries.length === 0) {
141
+ console.log('No rules in this policy file.');
142
+ }
143
+ else {
144
+ console.log(`automation.enabled: ${loaded.automation?.enabled === true}`);
145
+ console.log('name | enabled | trigger | conds | actions | throttle | dry');
146
+ for (const r of ruleEntries) {
147
+ console.log(`${r.name} | ${r.enabled} | ${r.trigger} | ${r.conditions} | ${r.actions} | ${r.throttle ?? '-'} | ${r.dry_run}`);
148
+ }
149
+ }
150
+ });
151
+ }
152
+ function registerRun(rules) {
153
+ rules
154
+ .command('run [path]')
155
+ .description('Start the rules engine: subscribe to MQTT and execute matching rules (long-running).')
156
+ .option('--dry-run', 'Force every action into dry-run mode, overriding rule-level dry_run=false.')
157
+ .option('--token <token>', 'SwitchBot API token (falls back to env / config).')
158
+ .option('--secret <secret>', 'SwitchBot API secret (falls back to env / config).')
159
+ .option('--max-firings <n>', 'Stop after this many successful fires (test / demo use).', (v) => Number.parseInt(v, 10))
160
+ .option('--webhook-port <n>', 'Webhook listener port (default 18790). Pass 0 for an auto-allocated port.', (v) => Number.parseInt(v, 10))
161
+ .option('--webhook-host <host>', 'Webhook listener bind address (default 127.0.0.1; set 0.0.0.0 to expose beyond loopback).')
162
+ .action(async (pathArg, opts) => {
163
+ const loaded = loadAutomation(pathArg);
164
+ if (!loaded)
165
+ return;
166
+ if (loaded.automation?.enabled !== true) {
167
+ const msg = 'automation.enabled is not true — nothing to run.';
168
+ if (isJsonMode()) {
169
+ printJson({ kind: 'control', controlKind: 'disabled', message: msg });
170
+ }
171
+ else {
172
+ console.error(msg);
173
+ }
174
+ process.exit(0);
175
+ }
176
+ const lint = lintRules(loaded.automation);
177
+ if (!lint.valid) {
178
+ if (!isJsonMode()) {
179
+ console.error('rules lint failed:');
180
+ console.error(formatLintHuman(lint, loaded.schemaVersion));
181
+ }
182
+ exitWithError({
183
+ code: 1,
184
+ kind: 'runtime',
185
+ message: 'rules lint failed — fix errors before running',
186
+ extra: { subKind: 'lint-failed', ...lint },
187
+ });
188
+ }
189
+ // Resolve credentials: CLI flags > env (via tryLoadConfig) > config file.
190
+ let token = opts.token;
191
+ let secret = opts.secret;
192
+ if (!token || !secret) {
193
+ const cfg = tryLoadConfig();
194
+ if (cfg) {
195
+ token = token ?? cfg.token;
196
+ secret = secret ?? cfg.secret;
197
+ }
198
+ }
199
+ if (!token || !secret) {
200
+ exitWithError({
201
+ code: 2,
202
+ kind: 'usage',
203
+ message: 'SwitchBot token + secret are required. Set SWITCHBOT_TOKEN / SWITCHBOT_SECRET or use `switchbot config set-token`.',
204
+ extra: { subKind: 'missing-credentials' },
205
+ });
206
+ }
207
+ const needsWebhook = (loaded.automation?.rules ?? []).some((r) => isWebhookTrigger(r.when) && r.enabled !== false);
208
+ const webhookTokenStore = new WebhookTokenStore();
209
+ const webhookToken = needsWebhook ? webhookTokenStore.getOrCreate() : undefined;
210
+ if (!isJsonMode())
211
+ console.error('Fetching MQTT credentials…');
212
+ const credential = await fetchMqttCredential(token, secret);
213
+ const client = new SwitchBotMqttClient(credential, () => fetchMqttCredential(token, secret));
214
+ const engine = new RulesEngine({
215
+ automation: loaded.automation,
216
+ aliases: loaded.aliases,
217
+ mqttClient: client,
218
+ mqttCredential: credential,
219
+ globalDryRun: opts.dryRun === true,
220
+ maxFirings: opts.maxFirings,
221
+ webhookToken,
222
+ webhookPort: opts.webhookPort,
223
+ webhookHost: opts.webhookHost,
224
+ });
225
+ let stopping = false;
226
+ const pidPaths = getDefaultPidFilePaths();
227
+ writePidFile(pidPaths.pidFile);
228
+ const cleanup = () => {
229
+ clearPidFile(pidPaths.pidFile);
230
+ // Drop any stale reload sentinel too — this process won't see it.
231
+ consumeReloadSentinel(pidPaths.reloadFile);
232
+ };
233
+ const stop = async (code) => {
234
+ if (stopping)
235
+ return;
236
+ stopping = true;
237
+ try {
238
+ await engine.stop();
239
+ await client.disconnect();
240
+ }
241
+ finally {
242
+ cleanup();
243
+ process.exit(code);
244
+ }
245
+ };
246
+ process.once('SIGINT', () => { stop(0).catch(() => process.exit(1)); });
247
+ process.once('SIGTERM', () => { stop(0).catch(() => process.exit(1)); });
248
+ await client.connect();
249
+ await engine.start();
250
+ const doReload = async (trigger) => {
251
+ try {
252
+ const fresh = loadAutomation(pathArg);
253
+ if (!fresh)
254
+ return;
255
+ const result = await engine.reload(fresh.automation, fresh.aliases);
256
+ if (result.changed) {
257
+ if (!isJsonMode()) {
258
+ console.error(`rules: reloaded (${trigger}) — ${engine.getStats().rulesActive} active rule(s)`);
259
+ for (const w of result.warnings)
260
+ console.error(` warning: ${w}`);
261
+ }
262
+ else {
263
+ printJson({
264
+ kind: 'control',
265
+ controlKind: 'reloaded',
266
+ t: new Date().toISOString(),
267
+ trigger,
268
+ rulesActive: engine.getStats().rulesActive,
269
+ warnings: result.warnings,
270
+ });
271
+ }
272
+ }
273
+ else {
274
+ const msg = `rules: reload refused — ${result.errors.join(', ')}`;
275
+ if (!isJsonMode())
276
+ console.error(msg);
277
+ else
278
+ printJson({ kind: 'control', controlKind: 'reload-refused', errors: result.errors });
279
+ }
280
+ }
281
+ catch (err) {
282
+ const msg = `rules: reload failed — ${err instanceof Error ? err.message : String(err)}`;
283
+ if (!isJsonMode())
284
+ console.error(msg);
285
+ else
286
+ printJson({ kind: 'control', controlKind: 'reload-failed', error: msg });
287
+ }
288
+ };
289
+ if (sighupSupported()) {
290
+ process.on('SIGHUP', () => { doReload('signal').catch(() => undefined); });
291
+ }
292
+ const reloadPoll = setInterval(() => {
293
+ if (consumeReloadSentinel(pidPaths.reloadFile)) {
294
+ doReload('sentinel').catch(() => undefined);
295
+ }
296
+ }, 2000);
297
+ reloadPoll.unref();
298
+ if (!isJsonMode()) {
299
+ console.error(`Rules engine started — ${engine.getStats().rulesActive} active rule(s), ${opts.dryRun ? 'global dry-run' : 'live'}.`);
300
+ console.error(`pid ${process.pid} (${pidPaths.pidFile}); reload: \`switchbot rules reload\`.`);
301
+ if (needsWebhook) {
302
+ const boundPort = engine.getWebhookPort();
303
+ console.error(`Webhook listener on ${opts.webhookHost ?? '127.0.0.1'}:${boundPort ?? '?'} (bearer file: ${webhookTokenStore.getFilePath()}).`);
304
+ }
305
+ }
306
+ else {
307
+ printJson({
308
+ kind: 'control',
309
+ controlKind: 'session_start',
310
+ t: new Date().toISOString(),
311
+ pid: process.pid,
312
+ pidFile: pidPaths.pidFile,
313
+ rulesActive: engine.getStats().rulesActive,
314
+ globalDryRun: opts.dryRun === true,
315
+ webhookPort: needsWebhook ? engine.getWebhookPort() : null,
316
+ });
317
+ }
318
+ // Keep the process alive until SIGINT/SIGTERM or maxFirings stops the
319
+ // engine. Poll the engine state rather than blocking forever — a
320
+ // long-running process with zero wake-ups is still cheap.
321
+ await new Promise((resolve) => {
322
+ const tick = setInterval(() => {
323
+ const s = engine.getStats();
324
+ if (!s.started) {
325
+ clearInterval(tick);
326
+ clearInterval(reloadPoll);
327
+ resolve();
328
+ }
329
+ }, 1000);
330
+ });
331
+ await stop(0);
332
+ });
333
+ }
334
+ function resolveSinceMs(since) {
335
+ if (since === undefined)
336
+ return undefined;
337
+ const durMs = parseDurationToMs(since);
338
+ if (durMs === null) {
339
+ exitWithError({
340
+ code: 2,
341
+ kind: 'usage',
342
+ message: `Invalid --since value "${since}". Expected e.g. "30s", "15m", "1h", "7d".`,
343
+ extra: { subKind: 'invalid-since' },
344
+ });
345
+ }
346
+ return Date.now() - durMs;
347
+ }
348
+ function formatAuditLine(e) {
349
+ const rule = e.rule?.name ?? '(no-rule)';
350
+ const trigger = e.rule?.triggerSource ?? '?';
351
+ const device = e.rule?.matchedDevice ?? e.deviceId ?? '-';
352
+ const status = e.kind === 'rule-fire'
353
+ ? e.result === 'error'
354
+ ? 'error'
355
+ : 'fire'
356
+ : e.kind === 'rule-fire-dry'
357
+ ? 'dry'
358
+ : e.kind === 'rule-throttled'
359
+ ? 'throttled'
360
+ : 'rejected';
361
+ const reason = e.rule?.reason ?? e.error ?? '';
362
+ const reasonSuffix = reason ? ` ${reason}` : '';
363
+ return `${e.t} ${status.padEnd(9)} ${rule} [${trigger}:${device}]${reasonSuffix}`;
364
+ }
365
+ function registerTail(rules) {
366
+ rules
367
+ .command('tail')
368
+ .description('Stream rule-* entries from the audit log.')
369
+ .option('--file <path>', `Audit log path (default ${DEFAULT_AUDIT_PATH})`)
370
+ .option('--since <duration>', 'Only entries newer than this window (e.g. 1h, 30m, 7d).')
371
+ .option('--rule <name>', 'Filter to a single rule name.')
372
+ .option('-f, --follow', 'Keep the process open and stream new lines as they arrive.')
373
+ .action(async (opts) => {
374
+ const file = opts.file ?? DEFAULT_AUDIT_PATH;
375
+ const sinceMs = resolveSinceMs(opts.since);
376
+ const existing = fs.existsSync(file) ? readAudit(file) : [];
377
+ const filtered = filterRuleAudits(existing, { sinceMs, ruleName: opts.rule });
378
+ if (isJsonMode()) {
379
+ for (const e of filtered)
380
+ console.log(JSON.stringify(e));
381
+ }
382
+ else if (filtered.length === 0 && !opts.follow) {
383
+ console.log(`(no rule-* entries in ${file}${opts.rule ? ` for rule "${opts.rule}"` : ''})`);
384
+ }
385
+ else {
386
+ for (const e of filtered)
387
+ console.log(formatAuditLine(e));
388
+ }
389
+ if (!opts.follow)
390
+ return;
391
+ // Follow: poll the file size and parse only newly appended bytes.
392
+ // Audit writes are append-only and infrequent, so 500 ms is plenty.
393
+ let offset = fs.existsSync(file) ? fs.statSync(file).size : 0;
394
+ let buffer = '';
395
+ const emit = (line) => {
396
+ const trimmed = line.trim();
397
+ if (!trimmed)
398
+ return;
399
+ let entry;
400
+ try {
401
+ entry = JSON.parse(trimmed);
402
+ }
403
+ catch {
404
+ return;
405
+ }
406
+ const kept = filterRuleAudits([entry], { sinceMs, ruleName: opts.rule });
407
+ if (kept.length === 0)
408
+ return;
409
+ if (isJsonMode())
410
+ console.log(JSON.stringify(entry));
411
+ else
412
+ console.log(formatAuditLine(entry));
413
+ };
414
+ const poll = setInterval(() => {
415
+ if (!fs.existsSync(file))
416
+ return;
417
+ const size = fs.statSync(file).size;
418
+ if (size < offset) {
419
+ // Log was truncated / rotated — restart from the top.
420
+ offset = 0;
421
+ buffer = '';
422
+ }
423
+ if (size === offset)
424
+ return;
425
+ const fd = fs.openSync(file, 'r');
426
+ try {
427
+ const chunk = Buffer.alloc(size - offset);
428
+ fs.readSync(fd, chunk, 0, chunk.length, offset);
429
+ offset = size;
430
+ buffer += chunk.toString('utf-8');
431
+ }
432
+ finally {
433
+ fs.closeSync(fd);
434
+ }
435
+ let newline = buffer.indexOf('\n');
436
+ while (newline !== -1) {
437
+ emit(buffer.slice(0, newline));
438
+ buffer = buffer.slice(newline + 1);
439
+ newline = buffer.indexOf('\n');
440
+ }
441
+ }, 500);
442
+ await new Promise((resolve) => {
443
+ const onStop = () => {
444
+ clearInterval(poll);
445
+ resolve();
446
+ };
447
+ process.once('SIGINT', onStop);
448
+ process.once('SIGTERM', onStop);
449
+ });
450
+ });
451
+ }
452
+ function formatReplayTable(report) {
453
+ const lines = [];
454
+ lines.push(`total rule-entries: ${report.total}`);
455
+ if (report.webhookRejectedCount > 0) {
456
+ lines.push(`webhook-rejected (no rule): ${report.webhookRejectedCount}`);
457
+ }
458
+ if (report.summaries.length === 0) {
459
+ lines.push('(no rules recorded in the audit window)');
460
+ return lines.join('\n');
461
+ }
462
+ lines.push('rule | trigger | fires | dries | throttled | errors | error% | first | last');
463
+ for (const s of report.summaries) {
464
+ lines.push(`${s.rule} | ${s.triggerSource ?? '-'} | ${s.fires} | ${s.driesFires} | ${s.throttled} | ${s.errors} | ${(s.errorRate * 100).toFixed(1)}% | ${s.firstAt ?? '-'} | ${s.lastAt ?? '-'}`);
465
+ }
466
+ return lines.join('\n');
467
+ }
468
+ function registerReplay(rules) {
469
+ rules
470
+ .command('replay')
471
+ .description('Aggregate rule-* audit entries per rule (fire/throttle/error counts).')
472
+ .option('--file <path>', `Audit log path (default ${DEFAULT_AUDIT_PATH})`)
473
+ .option('--since <duration>', 'Only entries newer than this window (e.g. 1h, 7d).')
474
+ .option('--rule <name>', 'Filter to a single rule name.')
475
+ .action((opts) => {
476
+ const file = opts.file ?? DEFAULT_AUDIT_PATH;
477
+ const entries = fs.existsSync(file) ? readAudit(file) : [];
478
+ const sinceMs = resolveSinceMs(opts.since);
479
+ const filtered = filterRuleAudits(entries, {
480
+ sinceMs,
481
+ ruleName: opts.rule,
482
+ kinds: RULE_AUDIT_KINDS,
483
+ });
484
+ const report = aggregateRuleAudits(filtered);
485
+ if (isJsonMode()) {
486
+ printJson({
487
+ file,
488
+ sinceMs: sinceMs ?? null,
489
+ ruleFilter: opts.rule ?? null,
490
+ ...report,
491
+ });
492
+ }
493
+ else {
494
+ console.log(formatReplayTable(report));
495
+ }
496
+ });
497
+ }
498
+ function registerReload(rules) {
499
+ rules
500
+ .command('reload')
501
+ .description('Trigger a policy hot-reload on the running `rules run` process.')
502
+ .action(() => {
503
+ const pidPaths = getDefaultPidFilePaths();
504
+ const pid = readPidFile(pidPaths.pidFile);
505
+ if (pid === null || !isPidAlive(pid)) {
506
+ exitWithError({
507
+ code: 2,
508
+ kind: 'usage',
509
+ message: `no running rules engine (pid file: ${pidPaths.pidFile}).`,
510
+ extra: { subKind: 'no-engine', pidFile: pidPaths.pidFile },
511
+ });
512
+ }
513
+ if (sighupSupported()) {
514
+ try {
515
+ process.kill(pid, 'SIGHUP');
516
+ }
517
+ catch (err) {
518
+ exitWithError({
519
+ code: 1,
520
+ kind: 'runtime',
521
+ message: `failed to send SIGHUP to pid ${pid}: ${err instanceof Error ? err.message : String(err)}`,
522
+ extra: { subKind: 'signal-failed', pid },
523
+ });
524
+ }
525
+ if (isJsonMode()) {
526
+ printJson({ status: 'signalled', pid, method: 'SIGHUP' });
527
+ }
528
+ else {
529
+ console.log(`Sent SIGHUP to pid ${pid}.`);
530
+ }
531
+ }
532
+ else {
533
+ writeReloadSentinel(pidPaths.reloadFile);
534
+ if (isJsonMode()) {
535
+ printJson({
536
+ status: 'signalled',
537
+ pid,
538
+ method: 'sentinel',
539
+ file: pidPaths.reloadFile,
540
+ });
541
+ }
542
+ else {
543
+ console.log(`Wrote reload sentinel ${pidPaths.reloadFile}; engine polls every 2 s.`);
544
+ }
545
+ }
546
+ });
547
+ }
548
+ function registerWebhookRotateToken(rules) {
549
+ rules
550
+ .command('webhook-rotate-token')
551
+ .description('Generate and persist a fresh webhook bearer token.')
552
+ .action(() => {
553
+ const store = new WebhookTokenStore();
554
+ const fresh = store.rotate();
555
+ if (isJsonMode()) {
556
+ printJson({ status: 'rotated', filePath: store.getFilePath(), tokenLength: fresh.length });
557
+ }
558
+ else {
559
+ console.log(`Webhook bearer rotated. Token written to ${store.getFilePath()}.`);
560
+ console.log('New token (copy now — it is not shown again):');
561
+ console.log(fresh);
562
+ }
563
+ });
564
+ }
565
+ function registerWebhookShowToken(rules) {
566
+ rules
567
+ .command('webhook-show-token')
568
+ .description('Print the current webhook bearer token (creating one if absent).')
569
+ .action(() => {
570
+ const store = new WebhookTokenStore();
571
+ const token = store.getOrCreate();
572
+ if (isJsonMode()) {
573
+ printJson({ filePath: store.getFilePath(), tokenLength: token.length });
574
+ }
575
+ else {
576
+ console.log(token);
577
+ }
578
+ });
579
+ }
580
+ function registerSuggest(rules) {
581
+ rules
582
+ .command('suggest')
583
+ .description('Generate a candidate rule YAML from intent + devices (heuristic, no LLM)')
584
+ .requiredOption('--intent <text>', 'Natural language description of the automation')
585
+ .option('--trigger <type>', 'mqtt | cron | webhook (inferred from intent if omitted)')
586
+ .option('--device <id>', 'Device ID or alias to include (repeatable)', (v, prev) => [...prev, v], [])
587
+ .option('--event <type>', 'MQTT event name override (e.g. motion.detected)')
588
+ .option('--schedule <cron>', '5-field cron expression override')
589
+ .option('--days <days>', 'Weekday filter, comma-separated (e.g. mon,tue,wed,thu,fri)')
590
+ .option('--webhook-path <path>', 'Webhook path override (default: /action)')
591
+ .option('--out <file>', 'Write YAML to file instead of stdout')
592
+ .action((opts) => {
593
+ const trigger = opts.trigger;
594
+ const days = opts.days ? opts.days.split(',').map((d) => d.trim()) : undefined;
595
+ const devices = opts.device.map((ref) => {
596
+ const cached = getCachedDevice(ref);
597
+ return { id: ref, name: cached?.name, type: cached?.type };
598
+ });
599
+ const { rule, ruleYaml, warnings } = suggestRule({
600
+ intent: opts.intent,
601
+ trigger,
602
+ devices,
603
+ event: opts.event,
604
+ schedule: opts.schedule,
605
+ days,
606
+ webhookPath: opts.webhookPath,
607
+ });
608
+ for (const w of warnings)
609
+ process.stderr.write(`warning: ${w}\n`);
610
+ if (opts.out) {
611
+ fs.writeFileSync(opts.out, ruleYaml, 'utf8');
612
+ if (!isJsonMode())
613
+ console.log(`✓ rule YAML written to ${opts.out}`);
614
+ }
615
+ else if (isJsonMode()) {
616
+ printJson({ rule, rule_yaml: ruleYaml, warnings });
617
+ }
618
+ else {
619
+ process.stdout.write(ruleYaml);
620
+ }
621
+ });
622
+ }
623
+ function formatConflictReport(report) {
624
+ const lines = [];
625
+ lines.push(`findings: ${report.findings.length} errors: ${report.counts.error} warnings: ${report.counts.warning} info: ${report.counts.info}`);
626
+ if (report.findings.length === 0) {
627
+ lines.push('No conflicts detected.');
628
+ return lines.join('\n');
629
+ }
630
+ for (const f of report.findings) {
631
+ lines.push(` [${f.severity}] ${f.code}: ${f.message}`);
632
+ if (f.hint)
633
+ lines.push(` hint: ${f.hint}`);
634
+ }
635
+ return lines.join('\n');
636
+ }
637
+ function registerConflicts(rules) {
638
+ rules
639
+ .command('conflicts [path]')
640
+ .description('Detect conflicting or risky rule patterns (opposing actions, high-frequency catch-all, destructive commands).')
641
+ .action((pathArg) => {
642
+ const loaded = loadAutomation(pathArg);
643
+ if (!loaded)
644
+ return;
645
+ const allRules = loaded.automation?.rules ?? [];
646
+ const report = analyzeConflicts(allRules, loaded.quietHours);
647
+ if (isJsonMode()) {
648
+ printJson({
649
+ policyPath: loaded.path,
650
+ ruleCount: allRules.length,
651
+ ...report,
652
+ });
653
+ }
654
+ else {
655
+ console.log(formatConflictReport(report));
656
+ }
657
+ process.exit(report.clean ? 0 : 1);
658
+ });
659
+ }
660
+ function registerDoctor(rules) {
661
+ rules
662
+ .command('doctor [path]')
663
+ .description('Combined health check: lint + conflict analysis + operational guidance.')
664
+ .action((pathArg) => {
665
+ const loaded = loadAutomation(pathArg);
666
+ if (!loaded)
667
+ return;
668
+ const allRules = loaded.automation?.rules ?? [];
669
+ const lintResult = lintRules(loaded.automation);
670
+ const conflictReport = analyzeConflicts(allRules, loaded.quietHours);
671
+ const overall = lintResult.valid && conflictReport.clean;
672
+ if (isJsonMode()) {
673
+ printJson({
674
+ policyPath: loaded.path,
675
+ policySchemaVersion: loaded.schemaVersion,
676
+ automationEnabled: loaded.automation?.enabled === true,
677
+ overall,
678
+ lint: lintResult,
679
+ conflicts: conflictReport,
680
+ });
681
+ }
682
+ else {
683
+ console.log('=== Lint ===');
684
+ console.log(formatLintHuman(lintResult, loaded.schemaVersion));
685
+ console.log('\n=== Conflicts ===');
686
+ console.log(formatConflictReport(conflictReport));
687
+ console.log(`\noverall: ${overall ? 'ok' : 'issues found'}`);
688
+ }
689
+ process.exit(overall ? 0 : 1);
690
+ });
691
+ }
692
+ function registerSummary(rules) {
693
+ rules
694
+ .command('summary')
695
+ .description('Aggregate rule-* audit entries per rule over a time window (fires, throttled, errors).')
696
+ .option('--file <path>', `Audit log path (default ${DEFAULT_AUDIT_PATH})`)
697
+ .option('--since <duration>', 'Only entries newer than this window (default: 24h). E.g. 1h, 7d.')
698
+ .option('--rule <name>', 'Filter to a single rule name.')
699
+ .action((opts) => {
700
+ const file = opts.file ?? DEFAULT_AUDIT_PATH;
701
+ const entries = fs.existsSync(file) ? readAudit(file) : [];
702
+ const sinceMs = resolveSinceMs(opts.since ?? '24h');
703
+ const filtered = filterRuleAudits(entries, { sinceMs, ruleName: opts.rule });
704
+ const report = aggregateRuleAudits(filtered);
705
+ if (isJsonMode()) {
706
+ printJson({ file, window: opts.since ?? '24h', ruleFilter: opts.rule ?? null, ...report });
707
+ return;
708
+ }
709
+ console.log(`Rule summary (${opts.since ?? '24h'} window, ${report.total} entries)`);
710
+ if (report.summaries.length === 0) {
711
+ console.log('(no rule activity in this window)');
712
+ return;
713
+ }
714
+ printTable(['Rule', 'Trigger', 'Fires', 'Throttled', 'Errors', 'Error%', 'Last fired'], report.summaries.map((s) => [
715
+ s.rule,
716
+ s.triggerSource ?? '-',
717
+ String(s.fires),
718
+ String(s.throttled),
719
+ String(s.errors),
720
+ `${(s.errorRate * 100).toFixed(1)}%`,
721
+ s.lastAt ?? '-',
722
+ ]));
723
+ });
724
+ }
725
+ function registerLastFired(rules) {
726
+ rules
727
+ .command('last-fired')
728
+ .description('Show the N most recently fired rule-fire entries from the audit log.')
729
+ .option('--file <path>', `Audit log path (default ${DEFAULT_AUDIT_PATH})`)
730
+ .option('--rule <name>', 'Filter to a single rule name.')
731
+ .option('-n <count>', 'Number of entries to show (default: 10).', (v) => Number.parseInt(v, 10))
732
+ .action((opts) => {
733
+ const file = opts.file ?? DEFAULT_AUDIT_PATH;
734
+ const n = opts.n ?? 10;
735
+ const entries = fs.existsSync(file) ? readAudit(file) : [];
736
+ const fires = filterRuleAudits(entries, {
737
+ ruleName: opts.rule,
738
+ kinds: ['rule-fire', 'rule-fire-dry'],
739
+ });
740
+ const recent = fires.slice(-n).reverse();
741
+ if (isJsonMode()) {
742
+ printJson({ file, ruleFilter: opts.rule ?? null, count: recent.length, entries: recent });
743
+ return;
744
+ }
745
+ if (recent.length === 0) {
746
+ console.log(`(no rule-fire entries in ${file}${opts.rule ? ` for rule "${opts.rule}"` : ''})`);
747
+ return;
748
+ }
749
+ for (const e of recent) {
750
+ const parts = [e.t, e.kind, e.rule?.name ?? '-'];
751
+ if (e.deviceId)
752
+ parts.push(`device=${e.deviceId}`);
753
+ if (e.command)
754
+ parts.push(`cmd=${e.command}`);
755
+ if (e.result)
756
+ parts.push(`result=${e.result}`);
757
+ console.log(parts.join(' '));
758
+ }
759
+ });
760
+ }
761
+ function registerExplain(rules) {
762
+ rules
763
+ .command('explain <name> [path]')
764
+ .description('Show full detail for a named rule: trigger, conditions, actions, and last-fired time.')
765
+ .option('--file <path>', `Audit log path (default ${DEFAULT_AUDIT_PATH}).`)
766
+ .action((name, pathArg, opts) => {
767
+ const loaded = loadAutomation(pathArg);
768
+ if (!loaded)
769
+ return;
770
+ const allRules = loaded.automation?.rules ?? [];
771
+ const rule = allRules.find((r) => r.name === name);
772
+ if (!rule) {
773
+ exitWithError({
774
+ code: 1,
775
+ kind: 'usage',
776
+ message: `Rule "${name}" not found in policy.`,
777
+ extra: {
778
+ subKind: 'rule-not-found',
779
+ available: allRules.map((r) => r.name),
780
+ },
781
+ });
782
+ return;
783
+ }
784
+ const auditFile = opts.file ?? DEFAULT_AUDIT_PATH;
785
+ const entries = fs.existsSync(auditFile) ? readAudit(auditFile) : [];
786
+ const fires = filterRuleAudits(entries, { ruleName: name, kinds: ['rule-fire', 'rule-fire-dry'] });
787
+ const lastFired = fires.length > 0 ? fires[fires.length - 1].t : null;
788
+ const detail = {
789
+ name: rule.name,
790
+ enabled: rule.enabled !== false,
791
+ trigger: describeTrigger(rule),
792
+ conditions: rule.conditions ?? [],
793
+ actions: rule.then,
794
+ cooldown: rule.cooldown ?? rule.throttle?.max_per ?? null,
795
+ hysteresis: rule.hysteresis ?? rule.requires_stable_for ?? null,
796
+ maxFiringsPerHour: rule.maxFiringsPerHour ?? null,
797
+ suppressIfAlreadyDesired: rule.suppressIfAlreadyDesired ?? false,
798
+ dryRun: rule.dry_run === true,
799
+ lastFired,
800
+ };
801
+ if (isJsonMode()) {
802
+ printJson(detail);
803
+ return;
804
+ }
805
+ console.log(`name: ${detail.name}`);
806
+ console.log(`enabled: ${detail.enabled}`);
807
+ console.log(`trigger: ${detail.trigger}`);
808
+ console.log(`conditions: ${detail.conditions.length === 0 ? '(none)' : JSON.stringify(detail.conditions)}`);
809
+ console.log(`actions: ${detail.actions.length}`);
810
+ for (const a of detail.actions) {
811
+ console.log(` - ${a.command}${a.device ? ` [${a.device}]` : ''}${a.on_error ? ` on_error=${a.on_error}` : ''}`);
812
+ }
813
+ if (detail.cooldown)
814
+ console.log(`cooldown: ${detail.cooldown}`);
815
+ if (detail.hysteresis)
816
+ console.log(`hysteresis: ${detail.hysteresis}`);
817
+ if (detail.maxFiringsPerHour !== null)
818
+ console.log(`maxFiringsPerHour: ${detail.maxFiringsPerHour}`);
819
+ if (detail.suppressIfAlreadyDesired)
820
+ console.log(`suppressIfAlreadyDesired: true`);
821
+ if (detail.dryRun)
822
+ console.log(`dry_run: true`);
823
+ console.log(`last fired: ${detail.lastFired ?? '(never)'}`);
824
+ });
825
+ }
826
+ export function registerRulesCommand(program) {
827
+ const rules = program
828
+ .command('rules')
829
+ .description('Run, list, and lint automation rules declared in policy.yaml (v0.2, preview).')
830
+ .addHelpText('after', `
831
+ Reads the same policy file as \`switchbot policy\` (${DEFAULT_POLICY_PATH} by
832
+ default; override with --policy or $SWITCHBOT_POLICY_PATH).
833
+
834
+ Subcommands:
835
+ suggest Generate a candidate rule YAML from intent (heuristic, no LLM).
836
+ lint [path] Static-check rule definitions; no MQTT, no API calls.
837
+ list [path] Print a human/JSON summary of each rule's trigger + actions.
838
+ explain <name> Show full detail for a rule: trigger, conditions, actions, last-fired.
839
+ run [path] Subscribe to MQTT (+ cron/webhook) and execute matching rules.
840
+ reload Hot-reload the running engine's policy (SIGHUP on Unix,
841
+ pid-file sentinel on Windows).
842
+ tail Stream rule-* entries from the audit log (--follow tails).
843
+ replay Per-rule aggregate: fires/dries/throttled/errors + window.
844
+ conflicts [path] Detect conflicting or risky rule patterns.
845
+ doctor [path] Combined health check: lint + conflict analysis + summary.
846
+ summary Aggregate rule-fire counts per rule over a time window.
847
+ last-fired Show the N most recently fired rule-fire audit entries.
848
+ webhook-rotate-token Rotate the bearer token used for webhook triggers.
849
+ webhook-show-token Print the current bearer token (creating one if absent).
850
+
851
+ MQTT, cron, and webhook triggers are all wired. Destructive commands (lock /
852
+ unlock / deleteWebhook / deleteScene / factoryReset) are rejected at lint.
853
+
854
+ Exit codes (lint):
855
+ 0 valid
856
+ 1 one or more rules have errors
857
+ 2 policy file not found
858
+ 3 YAML parse error
859
+ 4 internal / schema validation failed
860
+ `);
861
+ registerSuggest(rules);
862
+ registerLint(rules);
863
+ registerList(rules);
864
+ registerExplain(rules);
865
+ registerRun(rules);
866
+ registerReload(rules);
867
+ registerTail(rules);
868
+ registerReplay(rules);
869
+ registerConflicts(rules);
870
+ registerDoctor(rules);
871
+ registerSummary(rules);
872
+ registerLastFired(rules);
873
+ registerWebhookRotateToken(rules);
874
+ registerWebhookShowToken(rules);
875
+ }