@switchbot/openapi-cli 3.0.0 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -50
- package/dist/api/client.js +23 -1
- package/dist/commands/batch.js +20 -4
- package/dist/commands/capabilities.js +155 -65
- package/dist/commands/config.js +79 -0
- package/dist/commands/daemon.js +410 -0
- package/dist/commands/devices.js +62 -10
- package/dist/commands/doctor.js +233 -1
- package/dist/commands/health.js +113 -0
- package/dist/commands/mcp.js +93 -5
- package/dist/commands/plan.js +310 -130
- package/dist/commands/policy.js +120 -3
- package/dist/commands/rules.js +220 -2
- package/dist/commands/scenes.js +140 -0
- package/dist/commands/upgrade-check.js +107 -0
- package/dist/index.js +7 -0
- package/dist/lib/daemon-state.js +46 -0
- package/dist/lib/destructive-mode.js +12 -0
- package/dist/lib/devices.js +1 -0
- package/dist/lib/plan-store.js +68 -0
- package/dist/policy/schema/v0.2.json +29 -0
- package/dist/rules/action.js +11 -0
- package/dist/rules/conflict-analyzer.js +214 -0
- package/dist/rules/engine.js +195 -5
- package/dist/rules/suggest.js +1 -1
- package/dist/rules/throttle.js +42 -4
- package/dist/utils/audit.js +5 -1
- package/dist/utils/health.js +101 -0
- package/dist/utils/output.js +72 -23
- package/dist/utils/retry.js +81 -0
- package/package.json +1 -1
package/dist/commands/rules.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/commands/scenes.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/lib/devices.js
CHANGED
|
@@ -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 () => {
|