@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.
@@ -1,11 +1,12 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import { isJsonMode, printJson, exitWithError } from '../utils/output.js';
4
+ import { isJsonMode, printJson, exitWithError, printTable } from '../utils/output.js';
5
5
  import { loadPolicyFile, resolvePolicyPath, DEFAULT_POLICY_PATH, PolicyFileNotFoundError, PolicyYamlParseError, } from '../policy/load.js';
6
6
  import { validateLoadedPolicy } from '../policy/validate.js';
7
7
  import { isWebhookTrigger } from '../rules/types.js';
8
8
  import { lintRules, RulesEngine } from '../rules/engine.js';
9
+ import { analyzeConflicts, } from '../rules/conflict-analyzer.js';
9
10
  import { tryLoadConfig } from '../config.js';
10
11
  import { fetchMqttCredential } from '../mqtt/credential.js';
11
12
  import { SwitchBotMqttClient } from '../mqtt/client.js';
@@ -61,7 +62,11 @@ function loadAutomation(policyPathFlag) {
61
62
  aliases[k] = v;
62
63
  }
63
64
  }
64
- return { path, automation, aliases, schemaVersion: result.schemaVersion };
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 };
65
70
  }
66
71
  function describeTrigger(rule) {
67
72
  const t = rule.when;
@@ -615,6 +620,209 @@ function registerSuggest(rules) {
615
620
  }
616
621
  });
617
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
+ }
618
826
  export function registerRulesCommand(program) {
619
827
  const rules = program
620
828
  .command('rules')
@@ -627,11 +835,16 @@ Subcommands:
627
835
  suggest Generate a candidate rule YAML from intent (heuristic, no LLM).
628
836
  lint [path] Static-check rule definitions; no MQTT, no API calls.
629
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.
630
839
  run [path] Subscribe to MQTT (+ cron/webhook) and execute matching rules.
631
840
  reload Hot-reload the running engine's policy (SIGHUP on Unix,
632
841
  pid-file sentinel on Windows).
633
842
  tail Stream rule-* entries from the audit log (--follow tails).
634
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.
635
848
  webhook-rotate-token Rotate the bearer token used for webhook triggers.
636
849
  webhook-show-token Print the current bearer token (creating one if absent).
637
850
 
@@ -648,10 +861,15 @@ Exit codes (lint):
648
861
  registerSuggest(rules);
649
862
  registerLint(rules);
650
863
  registerList(rules);
864
+ registerExplain(rules);
651
865
  registerRun(rules);
652
866
  registerReload(rules);
653
867
  registerTail(rules);
654
868
  registerReplay(rules);
869
+ registerConflicts(rules);
870
+ registerDoctor(rules);
871
+ registerSummary(rules);
872
+ registerLastFired(rules);
655
873
  registerWebhookRotateToken(rules);
656
874
  registerWebhookShowToken(rules);
657
875
  }
@@ -121,4 +121,144 @@ Example:
121
121
  handleError(error);
122
122
  }
123
123
  });
124
+ // switchbot scenes validate [sceneId...]
125
+ scenes
126
+ .command('validate')
127
+ .description('Verify that one or more scenes exist. If no IDs are given, validates all scenes are reachable.')
128
+ .argument('[sceneId...]', 'Scene IDs to validate (default: all scenes)')
129
+ .addHelpText('after', `
130
+ Note: SwitchBot API v1.1 does not expose scene steps; validation only confirms
131
+ the scene IDs exist in your account.
132
+
133
+ Examples:
134
+ $ switchbot scenes validate
135
+ $ switchbot scenes validate T12345678 T87654321
136
+ `)
137
+ .action(async (sceneIds) => {
138
+ try {
139
+ const sceneList = await fetchScenes();
140
+ const sceneMap = new Map(sceneList.map((s) => [s.sceneId, s.sceneName]));
141
+ const targets = sceneIds.length > 0 ? sceneIds : sceneList.map((s) => s.sceneId);
142
+ const results = targets.map((id) => ({
143
+ sceneId: id,
144
+ sceneName: sceneMap.get(id) ?? null,
145
+ valid: sceneMap.has(id),
146
+ }));
147
+ const allValid = results.every((r) => r.valid);
148
+ if (isJsonMode()) {
149
+ printJson({ ok: allValid, results });
150
+ if (!allValid)
151
+ process.exit(1);
152
+ return;
153
+ }
154
+ for (const r of results) {
155
+ const icon = r.valid ? '✓' : '✗';
156
+ const label = r.valid ? r.sceneName : '(not found)';
157
+ console.log(`${icon} ${r.sceneId} ${label}`);
158
+ }
159
+ if (!allValid)
160
+ process.exit(1);
161
+ }
162
+ catch (error) {
163
+ handleError(error);
164
+ }
165
+ });
166
+ // switchbot scenes simulate <sceneId>
167
+ scenes
168
+ .command('simulate')
169
+ .description('Show what `scenes execute` would do without actually executing the scene.')
170
+ .argument('<sceneId>', 'Scene ID from "scenes list"')
171
+ .addHelpText('after', `
172
+ Note: SwitchBot API v1.1 does not expose scene step details. Simulation reports
173
+ the scene name, confirms it exists, and shows the POST that would be issued.
174
+
175
+ Example:
176
+ $ switchbot scenes simulate T12345678
177
+ `)
178
+ .action(async (sceneId) => {
179
+ try {
180
+ const sceneList = await fetchScenes();
181
+ const found = sceneList.find((s) => s.sceneId === sceneId);
182
+ if (!found) {
183
+ throw new StructuredUsageError(`scene not found: ${sceneId}`, {
184
+ error: 'scene_not_found',
185
+ sceneId,
186
+ candidates: sceneList.map((s) => ({ sceneId: s.sceneId, sceneName: s.sceneName })),
187
+ });
188
+ }
189
+ const simulation = {
190
+ sceneId: found.sceneId,
191
+ sceneName: found.sceneName,
192
+ wouldSend: { method: 'POST', url: `/v1.1/scenes/${sceneId}/execute` },
193
+ note: 'SwitchBot API v1.1 does not expose individual scene steps.',
194
+ };
195
+ if (isJsonMode()) {
196
+ printJson({ simulated: true, ...simulation });
197
+ return;
198
+ }
199
+ console.log(`sceneId: ${simulation.sceneId}`);
200
+ console.log(`sceneName: ${simulation.sceneName}`);
201
+ console.log(`wouldSend: ${simulation.wouldSend.method} ${simulation.wouldSend.url}`);
202
+ console.log(`note: ${simulation.note}`);
203
+ }
204
+ catch (error) {
205
+ handleError(error);
206
+ }
207
+ });
208
+ // switchbot scenes explain <sceneId>
209
+ scenes
210
+ .command('explain')
211
+ .description('Explain in plain language what a scene does and how to execute it safely.')
212
+ .argument('<sceneId>', 'Scene ID from "scenes list"')
213
+ .addHelpText('after', `
214
+ Shows the scene name, action description, risk level, and the exact command to
215
+ run. Unlike "simulate" (which shows raw HTTP detail), "explain" is aimed at a
216
+ human or agent deciding whether to proceed.
217
+
218
+ Note: SwitchBot API v1.1 does not expose scene step details; risk is reported
219
+ as "low" because scenes only trigger pre-configured automations in the app.
220
+
221
+ Example:
222
+ $ switchbot scenes explain T12345678
223
+ `)
224
+ .action(async (sceneId) => {
225
+ try {
226
+ const sceneList = await fetchScenes();
227
+ const found = sceneList.find((s) => s.sceneId === sceneId);
228
+ if (!found) {
229
+ throw new StructuredUsageError(`scene not found: ${sceneId}`, {
230
+ error: 'scene_not_found',
231
+ sceneId,
232
+ candidates: sceneList.map((s) => ({ sceneId: s.sceneId, sceneName: s.sceneName })),
233
+ });
234
+ }
235
+ const explanation = {
236
+ sceneId: found.sceneId,
237
+ sceneName: found.sceneName,
238
+ action: `Trigger scene (POST /v1.1/scenes/${found.sceneId}/execute)`,
239
+ riskLevel: 'low',
240
+ idempotent: null,
241
+ toExecute: `switchbot scenes execute ${found.sceneId}`,
242
+ dryRun: isDryRun(),
243
+ note: 'SwitchBot API v1.1 does not expose individual scene steps.',
244
+ };
245
+ if (isJsonMode()) {
246
+ printJson(explanation);
247
+ return;
248
+ }
249
+ console.log(`sceneId: ${explanation.sceneId}`);
250
+ console.log(`sceneName: ${explanation.sceneName}`);
251
+ console.log(`action: ${explanation.action}`);
252
+ console.log(`riskLevel: ${explanation.riskLevel}`);
253
+ console.log(`idempotent: unknown (scene steps not exposed by API)`);
254
+ console.log(`toExecute: ${explanation.toExecute}`);
255
+ if (explanation.dryRun) {
256
+ console.log(`dryRun: true (pass --dry-run to execute would be a no-op)`);
257
+ }
258
+ console.log(`note: ${explanation.note}`);
259
+ }
260
+ catch (error) {
261
+ handleError(error);
262
+ }
263
+ });
124
264
  }
@@ -0,0 +1,107 @@
1
+ import { createRequire } from 'node:module';
2
+ import https from 'node:https';
3
+ import { isJsonMode, printJson } from '../utils/output.js';
4
+ import chalk from 'chalk';
5
+ const require = createRequire(import.meta.url);
6
+ const { name: pkgName, version: currentVersion } = require('../../package.json');
7
+ function fetchLatestVersion(packageName, timeoutMs = 8000) {
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.
11
+ const url = `https://registry.npmjs.org/${encoded}/latest`;
12
+ return new Promise((resolve, reject) => {
13
+ const req = https.get(url, { timeout: timeoutMs }, (res) => {
14
+ const chunks = [];
15
+ res.on('data', (c) => chunks.push(c));
16
+ res.on('end', () => {
17
+ try {
18
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
19
+ if (typeof body.version === 'string')
20
+ resolve(body.version);
21
+ else
22
+ reject(new Error('version field missing from registry response'));
23
+ }
24
+ catch (err) {
25
+ reject(err);
26
+ }
27
+ });
28
+ });
29
+ req.on('timeout', () => { req.destroy(); reject(new Error(`registry request timed out after ${timeoutMs}ms`)); });
30
+ req.on('error', reject);
31
+ });
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.
36
+ function semverGt(a, b) {
37
+ const numParts = (v) => v.replace(/-.*$/, '').split('.').map((n) => Number.parseInt(n, 10));
38
+ const [aMaj, aMin, aPat] = numParts(a);
39
+ const [bMaj, bMin, bPat] = numParts(b);
40
+ if (aMaj !== bMaj)
41
+ return aMaj > bMaj;
42
+ if (aMin !== bMin)
43
+ return aMin > bMin;
44
+ if (aPat !== bPat)
45
+ return aPat > bPat;
46
+ // Same numeric version: release (no prerelease) > prerelease
47
+ return !a.includes('-') && b.includes('-');
48
+ }
49
+ export function registerUpgradeCheckCommand(program) {
50
+ program
51
+ .command('upgrade-check')
52
+ .description('Check whether a newer version of this CLI is available on npm.')
53
+ .option('--timeout <ms>', 'Registry request timeout in milliseconds (default: 8000)', (v) => Number.parseInt(v, 10))
54
+ .action(async (opts) => {
55
+ let latestVersion;
56
+ try {
57
+ latestVersion = await fetchLatestVersion(pkgName, opts.timeout ?? 8000);
58
+ }
59
+ catch (err) {
60
+ const msg = err instanceof Error ? err.message : String(err);
61
+ if (isJsonMode()) {
62
+ printJson({ ok: false, error: msg, current: currentVersion });
63
+ }
64
+ else {
65
+ console.error(chalk.red(`upgrade-check failed: ${msg}`));
66
+ }
67
+ process.exit(1);
68
+ }
69
+ const upToDate = !semverGt(latestVersion, currentVersion);
70
+ const currentMajor = Number.parseInt(currentVersion.split('.')[0], 10);
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
+ }
86
+ const result = {
87
+ current: currentVersion,
88
+ latest: latestVersion,
89
+ upToDate,
90
+ updateAvailable: !upToDate,
91
+ breakingChange: latestMajor > currentMajor,
92
+ installCommand: upToDate ? null : `npm install -g ${pkgName}@${latestVersion}`,
93
+ };
94
+ if (isJsonMode()) {
95
+ printJson(result);
96
+ return;
97
+ }
98
+ if (upToDate) {
99
+ console.log(`${chalk.green('✓')} You are running the latest version (${currentVersion}).`);
100
+ }
101
+ else {
102
+ console.log(`${chalk.yellow('!')} Update available: ${chalk.bold(currentVersion)} → ${chalk.bold(latestVersion)}`);
103
+ console.log(` Run: ${chalk.cyan(`npm install -g ${pkgName}@${latestVersion}`)}`);
104
+ process.exit(1);
105
+ }
106
+ });
107
+ }
package/dist/index.js CHANGED
@@ -29,6 +29,9 @@ import { registerAuthCommand } from './commands/auth.js';
29
29
  import { registerInstallCommand } from './commands/install.js';
30
30
  import { registerUninstallCommand } from './commands/uninstall.js';
31
31
  import { registerStatusSyncCommand } from './commands/status-sync.js';
32
+ import { registerHealthCommand } from './commands/health.js';
33
+ import { registerUpgradeCheckCommand } from './commands/upgrade-check.js';
34
+ import { registerDaemonCommand } from './commands/daemon.js';
32
35
  import { primeCredentials } from './credentials/prime.js';
33
36
  import { getActiveProfile } from './lib/request-context.js';
34
37
  const require = createRequire(import.meta.url);
@@ -50,6 +53,7 @@ const TOP_LEVEL_COMMANDS = [
50
53
  'config', 'devices', 'scenes', 'webhook', 'completion', 'mcp',
51
54
  'quota', 'catalog', 'cache', 'events', 'doctor', 'schema',
52
55
  'history', 'plan', 'capabilities', 'agent-bootstrap', 'install', 'uninstall', 'status-sync',
56
+ 'health', 'upgrade-check', 'daemon',
53
57
  ];
54
58
  const cacheModeArg = (value) => {
55
59
  if (value.startsWith('-')) {
@@ -108,6 +112,9 @@ registerAuthCommand(program);
108
112
  registerInstallCommand(program);
109
113
  registerUninstallCommand(program);
110
114
  registerStatusSyncCommand(program);
115
+ registerHealthCommand(program);
116
+ registerUpgradeCheckCommand(program);
117
+ registerDaemonCommand(program);
111
118
  // Prime keychain-stored credentials before any command runs. This is a
112
119
  // best-effort probe: failures are silently swallowed inside primeCredentials,
113
120
  // so the existing file-based path remains the safety net. We probe once per
@@ -0,0 +1,46 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ function getStateDir() {
5
+ return path.join(os.homedir(), '.switchbot');
6
+ }
7
+ function getDaemonPidFile() {
8
+ return path.join(getStateDir(), 'daemon.pid');
9
+ }
10
+ function getDaemonLogFile() {
11
+ return path.join(getStateDir(), 'daemon.log');
12
+ }
13
+ function getDaemonStateFile() {
14
+ return path.join(getStateDir(), 'daemon.state.json');
15
+ }
16
+ function getHealthzPidFile() {
17
+ return path.join(getStateDir(), 'healthz.pid');
18
+ }
19
+ export const DAEMON_PID_FILE = getDaemonPidFile();
20
+ export const DAEMON_LOG_FILE = getDaemonLogFile();
21
+ export const DAEMON_STATE_FILE = getDaemonStateFile();
22
+ export const HEALTHZ_PID_FILE = getHealthzPidFile();
23
+ function ensureStateDir() {
24
+ fs.mkdirSync(getStateDir(), { recursive: true, mode: 0o700 });
25
+ }
26
+ export function writeDaemonState(state) {
27
+ ensureStateDir();
28
+ fs.writeFileSync(getDaemonStateFile(), JSON.stringify(state, null, 2), { mode: 0o600 });
29
+ }
30
+ export function readDaemonState() {
31
+ try {
32
+ const raw = fs.readFileSync(getDaemonStateFile(), 'utf-8');
33
+ return JSON.parse(raw);
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ export function removeDaemonState() {
40
+ try {
41
+ fs.unlinkSync(getDaemonStateFile());
42
+ }
43
+ catch {
44
+ // best effort
45
+ }
46
+ }
@@ -0,0 +1,12 @@
1
+ import { getActiveProfile } from './request-context.js';
2
+ const DIRECT_DESTRUCTIVE_PROFILES = new Set(['dev', 'development']);
3
+ export function allowsDirectDestructiveExecution(profile = getActiveProfile()) {
4
+ if (process.env.SWITCHBOT_ALLOW_DIRECT_DESTRUCTIVE === '1')
5
+ return true;
6
+ if (!profile)
7
+ return false;
8
+ return DIRECT_DESTRUCTIVE_PROFILES.has(profile.toLowerCase());
9
+ }
10
+ export function destructiveExecutionHint() {
11
+ return "Use 'switchbot plan save <file>' -> 'switchbot plan review <planId>' -> 'switchbot plan approve <planId>' -> 'switchbot plan execute <planId>' instead.";
12
+ }
@@ -101,6 +101,7 @@ export async function executeCommand(deviceId, cmd, parameter, commandType, clie
101
101
  parameter,
102
102
  commandType,
103
103
  dryRun: isDryRun(),
104
+ ...(options?.planId ? { planId: options.planId } : {}),
104
105
  };
105
106
  // Wrap in idempotency cache if key is provided
106
107
  const execute = async () => {