@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/doctor.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
4
5
|
import { printJson, isJsonMode, exitWithError } from '../utils/output.js';
|
|
5
6
|
import { getEffectiveCatalog } from '../devices/catalog.js';
|
|
6
7
|
import { configFilePath, listProfiles, readProfileMeta } from '../config.js';
|
|
@@ -13,6 +14,8 @@ import { resolvePolicyPath, loadPolicyFile, PolicyFileNotFoundError, PolicyYamlP
|
|
|
13
14
|
import { validateLoadedPolicy } from '../policy/validate.js';
|
|
14
15
|
import { selectCredentialStore } from '../credentials/keychain.js';
|
|
15
16
|
import { getActiveProfile } from '../lib/request-context.js';
|
|
17
|
+
import { readDaemonState } from '../lib/daemon-state.js';
|
|
18
|
+
import { isPidAlive } from '../rules/pid-file.js';
|
|
16
19
|
export const DOCTOR_SCHEMA_VERSION = 1;
|
|
17
20
|
async function checkCredentials() {
|
|
18
21
|
const envOk = Boolean(process.env.SWITCHBOT_TOKEN && process.env.SWITCHBOT_SECRET);
|
|
@@ -480,6 +483,46 @@ function checkPolicy() {
|
|
|
480
483
|
};
|
|
481
484
|
}
|
|
482
485
|
}
|
|
486
|
+
async function checkKeychain() {
|
|
487
|
+
try {
|
|
488
|
+
const { selectCredentialStore } = await import('../credentials/keychain.js');
|
|
489
|
+
const store = await selectCredentialStore();
|
|
490
|
+
const desc = store.describe();
|
|
491
|
+
const isNative = desc.backend !== 'file';
|
|
492
|
+
if (!isNative) {
|
|
493
|
+
// Native keychain not available or not detected
|
|
494
|
+
return {
|
|
495
|
+
name: 'keychain',
|
|
496
|
+
status: 'warn',
|
|
497
|
+
detail: {
|
|
498
|
+
backend: desc.backend,
|
|
499
|
+
message: 'OS native keychain not detected — credentials stored in plain file (~/.switchbot/config.json). Consider installing a keychain backend for better security.',
|
|
500
|
+
hint: process.platform === 'linux'
|
|
501
|
+
? 'Install libsecret (secret-tool) for GNOME Keyring support.'
|
|
502
|
+
: process.platform === 'darwin'
|
|
503
|
+
? 'macOS Keychain is available — re-run `switchbot config set-token` to store credentials there.'
|
|
504
|
+
: 'Windows Credential Manager is available — re-run `switchbot config set-token` to use it.',
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
return {
|
|
509
|
+
name: 'keychain',
|
|
510
|
+
status: 'ok',
|
|
511
|
+
detail: {
|
|
512
|
+
backend: desc.backend,
|
|
513
|
+
writable: desc.writable,
|
|
514
|
+
message: `Credentials stored in OS keychain (${desc.backend}).`,
|
|
515
|
+
},
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
catch (err) {
|
|
519
|
+
return {
|
|
520
|
+
name: 'keychain',
|
|
521
|
+
status: 'warn',
|
|
522
|
+
detail: { message: `Keychain probe failed: ${err instanceof Error ? err.message : String(err)}` },
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
}
|
|
483
526
|
function checkNodeVersion() {
|
|
484
527
|
const major = Number(process.versions.node.split('.')[0]);
|
|
485
528
|
if (Number.isFinite(major) && major < 18) {
|
|
@@ -487,6 +530,181 @@ function checkNodeVersion() {
|
|
|
487
530
|
}
|
|
488
531
|
return { name: 'node', status: 'ok', detail: `Node ${process.versions.node}` };
|
|
489
532
|
}
|
|
533
|
+
function detectShellFlavor() {
|
|
534
|
+
const shell = (process.env.SHELL ?? '').toLowerCase();
|
|
535
|
+
const comspec = (process.env.COMSPEC ?? '').toLowerCase();
|
|
536
|
+
if (shell.includes('pwsh') || shell.includes('powershell'))
|
|
537
|
+
return 'powershell';
|
|
538
|
+
if (comspec.includes('powershell') || comspec.includes('pwsh'))
|
|
539
|
+
return 'powershell';
|
|
540
|
+
if (comspec.endsWith('cmd.exe'))
|
|
541
|
+
return 'cmd';
|
|
542
|
+
if (shell.endsWith('/fish') || shell === 'fish')
|
|
543
|
+
return 'fish';
|
|
544
|
+
if (shell.endsWith('/zsh') || shell === 'zsh')
|
|
545
|
+
return 'zsh';
|
|
546
|
+
if (shell.endsWith('/bash') || shell === 'bash')
|
|
547
|
+
return 'bash';
|
|
548
|
+
return process.platform === 'win32' ? 'powershell' : 'unknown';
|
|
549
|
+
}
|
|
550
|
+
function buildPathFix(shell, missingSegment, npmBinDir) {
|
|
551
|
+
if (!npmBinDir) {
|
|
552
|
+
return process.platform === 'win32'
|
|
553
|
+
? 'Run: npm prefix -g and add that directory to your PATH.'
|
|
554
|
+
: 'Run: npm prefix -g and add <prefix>/bin to your PATH.';
|
|
555
|
+
}
|
|
556
|
+
switch (shell) {
|
|
557
|
+
case 'powershell':
|
|
558
|
+
return `$env:Path = "${missingSegment};" + $env:Path # persist via your PowerShell profile or System Properties`;
|
|
559
|
+
case 'cmd':
|
|
560
|
+
return `set PATH=${missingSegment};%PATH% && setx PATH "${missingSegment};%PATH%"`;
|
|
561
|
+
case 'fish':
|
|
562
|
+
return `fish_add_path "${missingSegment}"`;
|
|
563
|
+
case 'zsh':
|
|
564
|
+
return `export PATH="${missingSegment}:$PATH" # add to ~/.zshrc`;
|
|
565
|
+
case 'bash':
|
|
566
|
+
return `export PATH="${missingSegment}:$PATH" # add to ~/.bashrc`;
|
|
567
|
+
default:
|
|
568
|
+
return `export PATH="${missingSegment}:$PATH"`;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
function checkPathDiscoverability() {
|
|
572
|
+
// Detect whether the `switchbot` binary is reachable on PATH.
|
|
573
|
+
// This catches the common "npm install -g worked but PATH not updated" failure.
|
|
574
|
+
const isWindows = process.platform === 'win32';
|
|
575
|
+
const binaryName = isWindows ? 'switchbot.cmd' : 'switchbot';
|
|
576
|
+
// Find where npm puts global bins.
|
|
577
|
+
let npmBinDir = null;
|
|
578
|
+
try {
|
|
579
|
+
const prefix = execSync('npm prefix -g', { timeout: 4000, encoding: 'utf-8' }).trim();
|
|
580
|
+
npmBinDir = isWindows ? prefix : path.join(prefix, 'bin');
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
// npm not on PATH or other error; fall through.
|
|
584
|
+
}
|
|
585
|
+
// Check whether `switchbot` resolves via PATH.
|
|
586
|
+
let binaryOnPath = false;
|
|
587
|
+
let resolvedPath = null;
|
|
588
|
+
try {
|
|
589
|
+
const which = execSync(isWindows ? `where ${binaryName}` : `which ${binaryName}`, { timeout: 3000, encoding: 'utf-8' }).trim().split(/\r?\n/)[0];
|
|
590
|
+
if (which) {
|
|
591
|
+
binaryOnPath = true;
|
|
592
|
+
resolvedPath = which;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
binaryOnPath = false;
|
|
597
|
+
}
|
|
598
|
+
if (binaryOnPath) {
|
|
599
|
+
return {
|
|
600
|
+
name: 'path',
|
|
601
|
+
status: 'ok',
|
|
602
|
+
detail: {
|
|
603
|
+
binaryOnPath: true,
|
|
604
|
+
resolvedPath,
|
|
605
|
+
npmBinDir,
|
|
606
|
+
message: `switchbot is reachable at ${resolvedPath}`,
|
|
607
|
+
},
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
// Not on PATH — figure out what the user should add.
|
|
611
|
+
const currentPath = process.env.PATH ?? '';
|
|
612
|
+
const missingSegment = npmBinDir && !currentPath.split(path.delimiter).includes(npmBinDir)
|
|
613
|
+
? npmBinDir
|
|
614
|
+
: null;
|
|
615
|
+
const currentShell = detectShellFlavor();
|
|
616
|
+
const shellFix = buildPathFix(currentShell, missingSegment, npmBinDir);
|
|
617
|
+
return {
|
|
618
|
+
name: 'path',
|
|
619
|
+
status: 'warn',
|
|
620
|
+
detail: {
|
|
621
|
+
binaryOnPath: false,
|
|
622
|
+
resolvedPath: null,
|
|
623
|
+
npmBinDir,
|
|
624
|
+
missingPathSegment: missingSegment,
|
|
625
|
+
currentShell,
|
|
626
|
+
fix: shellFix,
|
|
627
|
+
message: `'switchbot' is not on PATH. ${shellFix}`,
|
|
628
|
+
},
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
function checkDaemon() {
|
|
632
|
+
const state = readDaemonState();
|
|
633
|
+
if (!state) {
|
|
634
|
+
return {
|
|
635
|
+
name: 'daemon',
|
|
636
|
+
status: 'warn',
|
|
637
|
+
detail: {
|
|
638
|
+
present: false,
|
|
639
|
+
message: 'No daemon state file found. Start one with `switchbot daemon start` if you want long-running automation.',
|
|
640
|
+
},
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
const pid = state.pid;
|
|
644
|
+
const running = pid !== null && (pid === process.pid || isPidAlive(pid));
|
|
645
|
+
return {
|
|
646
|
+
name: 'daemon',
|
|
647
|
+
status: running ? 'ok' : 'warn',
|
|
648
|
+
detail: {
|
|
649
|
+
present: true,
|
|
650
|
+
status: running ? 'running' : state.status,
|
|
651
|
+
pid: running ? pid : null,
|
|
652
|
+
stateFile: state.stateFile,
|
|
653
|
+
pidFile: state.pidFile,
|
|
654
|
+
logFile: state.logFile,
|
|
655
|
+
startedAt: state.startedAt ?? null,
|
|
656
|
+
stoppedAt: state.stoppedAt ?? null,
|
|
657
|
+
lastReloadAt: state.lastReloadAt ?? null,
|
|
658
|
+
lastReloadStatus: state.lastReloadStatus ?? null,
|
|
659
|
+
healthConfigured: typeof state.healthzPort === 'number',
|
|
660
|
+
healthzPort: state.healthzPort ?? null,
|
|
661
|
+
message: running
|
|
662
|
+
? 'daemon running'
|
|
663
|
+
: 'daemon not running; use `switchbot daemon start` for long-running automation',
|
|
664
|
+
},
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
async function checkHealthEndpoint() {
|
|
668
|
+
const state = readDaemonState();
|
|
669
|
+
if (!state || typeof state.healthzPort !== 'number') {
|
|
670
|
+
return {
|
|
671
|
+
name: 'health',
|
|
672
|
+
status: 'warn',
|
|
673
|
+
detail: {
|
|
674
|
+
present: false,
|
|
675
|
+
message: 'No health endpoint configured. Start the daemon with `--healthz-port <n>` to enable it.',
|
|
676
|
+
},
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
const url = `http://127.0.0.1:${state.healthzPort}/healthz`;
|
|
680
|
+
try {
|
|
681
|
+
const res = await fetch(url);
|
|
682
|
+
const body = await res.json();
|
|
683
|
+
const overall = body?.data?.overall ?? 'unknown';
|
|
684
|
+
return {
|
|
685
|
+
name: 'health',
|
|
686
|
+
status: res.ok && overall !== 'down' ? 'ok' : 'warn',
|
|
687
|
+
detail: {
|
|
688
|
+
present: true,
|
|
689
|
+
url,
|
|
690
|
+
httpStatus: res.status,
|
|
691
|
+
overall,
|
|
692
|
+
message: res.ok ? `health endpoint reachable at ${url}` : `health endpoint returned HTTP ${res.status}`,
|
|
693
|
+
},
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
catch (err) {
|
|
697
|
+
return {
|
|
698
|
+
name: 'health',
|
|
699
|
+
status: 'warn',
|
|
700
|
+
detail: {
|
|
701
|
+
present: true,
|
|
702
|
+
url,
|
|
703
|
+
message: `health endpoint probe failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
704
|
+
},
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
}
|
|
490
708
|
function checkMqtt() {
|
|
491
709
|
// MQTT credentials are auto-provisioned from the SwitchBot API using the
|
|
492
710
|
// account's token+secret — no extra env vars needed. Report availability
|
|
@@ -603,7 +821,9 @@ function checkMcp() {
|
|
|
603
821
|
}
|
|
604
822
|
const CHECK_REGISTRY = [
|
|
605
823
|
{ name: 'node', description: 'Node.js version compatibility', run: () => checkNodeVersion() },
|
|
824
|
+
{ name: 'path', description: 'switchbot binary reachable on PATH', run: () => checkPathDiscoverability() },
|
|
606
825
|
{ name: 'credentials', description: 'credentials file present and parseable', run: () => checkCredentials() },
|
|
826
|
+
{ name: 'keychain', description: 'OS keychain backend availability and usage', run: () => checkKeychain() },
|
|
607
827
|
{ name: 'profiles', description: 'profile definitions valid', run: () => checkProfiles() },
|
|
608
828
|
{ name: 'catalog', description: 'catalog loads', run: () => checkCatalog() },
|
|
609
829
|
{ name: 'catalog-schema', description: 'catalog vs agent-bootstrap version aligned', run: () => checkCatalogSchema() },
|
|
@@ -618,6 +838,8 @@ const CHECK_REGISTRY = [
|
|
|
618
838
|
{ name: 'mcp', description: 'MCP server instantiable + tool count', run: () => checkMcp() },
|
|
619
839
|
{ name: 'policy', description: 'policy.yaml present + schema-valid (if configured)', run: () => checkPolicy() },
|
|
620
840
|
{ name: 'audit', description: 'recent command errors (last 24h)', run: () => checkAudit() },
|
|
841
|
+
{ name: 'daemon', description: 'daemon state file + runtime status', run: () => checkDaemon() },
|
|
842
|
+
{ name: 'health', description: 'health endpoint availability (daemon --healthz-port)', run: () => checkHealthEndpoint() },
|
|
621
843
|
];
|
|
622
844
|
function applyFixes(checks, writeOk) {
|
|
623
845
|
const results = [];
|
|
@@ -668,7 +890,7 @@ function applyFixes(checks, writeOk) {
|
|
|
668
890
|
export function registerDoctorCommand(program) {
|
|
669
891
|
program
|
|
670
892
|
.command('doctor')
|
|
671
|
-
.description('Self-check the SwitchBot CLI setup: credentials, catalog, cache, quota, MQTT, MCP')
|
|
893
|
+
.description('Self-check the SwitchBot CLI setup: credentials, catalog, cache, quota, MQTT, daemon, health, MCP')
|
|
672
894
|
.option('--section <names>', 'Comma-separated list of checks to run (see --list for names)')
|
|
673
895
|
.option('--list', 'Print the registered check names and exit 0 without running any check')
|
|
674
896
|
.option('--fix', 'Apply safe, reversible remediations for failing checks (e.g. clear stale cache)')
|
|
@@ -683,6 +905,7 @@ Examples:
|
|
|
683
905
|
$ switchbot --json doctor | jq '.checks[] | select(.status != "ok")'
|
|
684
906
|
$ switchbot doctor --list
|
|
685
907
|
$ switchbot doctor --section credentials,mcp --json
|
|
908
|
+
$ switchbot doctor --section daemon,health --json
|
|
686
909
|
$ switchbot doctor --probe --json
|
|
687
910
|
$ switchbot doctor --fix --yes --json
|
|
688
911
|
`)
|
|
@@ -734,6 +957,13 @@ Examples:
|
|
|
734
957
|
};
|
|
735
958
|
const overallFail = summary.fail > 0;
|
|
736
959
|
const overall = overallFail ? 'fail' : summary.warn > 0 ? 'warn' : 'ok';
|
|
960
|
+
const total = summary.ok + summary.warn + summary.fail;
|
|
961
|
+
const rawScore = total > 0 ? Math.round(((summary.ok + summary.warn * 0.5) / total) * 100) : 100;
|
|
962
|
+
const maturityScore = Math.min(100, Math.max(0, rawScore));
|
|
963
|
+
const maturityLabel = maturityScore >= 90 ? 'production-ready'
|
|
964
|
+
: maturityScore >= 70 ? 'mostly-ready'
|
|
965
|
+
: maturityScore >= 40 ? 'needs-work'
|
|
966
|
+
: 'not-ready';
|
|
737
967
|
let fixes;
|
|
738
968
|
if (opts.fix) {
|
|
739
969
|
fixes = applyFixes(checks, Boolean(opts.yes));
|
|
@@ -747,6 +977,8 @@ Examples:
|
|
|
747
977
|
const payload = {
|
|
748
978
|
ok: overall === 'ok',
|
|
749
979
|
overall,
|
|
980
|
+
maturityScore,
|
|
981
|
+
maturityLabel,
|
|
750
982
|
generatedAt: new Date().toISOString(),
|
|
751
983
|
schemaVersion: DOCTOR_SCHEMA_VERSION,
|
|
752
984
|
summary,
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { printJson, isJsonMode, printTable, handleError } from '../utils/output.js';
|
|
3
|
+
import { getHealthReport, toPrometheusText } from '../utils/health.js';
|
|
4
|
+
import { intArg } from '../utils/arg-parsers.js';
|
|
5
|
+
const HEALTHZ_SCHEMA_VERSION = '1.1';
|
|
6
|
+
/**
|
|
7
|
+
* Create an HTTP request handler for the health endpoints. Exposed separately
|
|
8
|
+
* so integration tests can call it directly without binding a port.
|
|
9
|
+
*/
|
|
10
|
+
export function createHealthHandler(auditLogPath) {
|
|
11
|
+
return (req, res) => {
|
|
12
|
+
const url = (req.url ?? '/').split('?')[0];
|
|
13
|
+
if (url === '/healthz') {
|
|
14
|
+
const report = getHealthReport(auditLogPath);
|
|
15
|
+
const statusCode = report.overall === 'down' ? 503 : 200;
|
|
16
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
17
|
+
res.end(JSON.stringify({ schemaVersion: HEALTHZ_SCHEMA_VERSION, data: report }));
|
|
18
|
+
}
|
|
19
|
+
else if (url === '/metrics') {
|
|
20
|
+
const report = getHealthReport(auditLogPath);
|
|
21
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' });
|
|
22
|
+
res.end(toPrometheusText(report));
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
26
|
+
res.end(JSON.stringify({ error: 'Not found', paths: ['/healthz', '/metrics'] }));
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function registerHealthCommand(program) {
|
|
31
|
+
const health = program
|
|
32
|
+
.command('health')
|
|
33
|
+
.description('Report process health: quota, audit error rate, circuit breaker state.');
|
|
34
|
+
health
|
|
35
|
+
.command('check')
|
|
36
|
+
.description('Print a one-shot health report.')
|
|
37
|
+
.option('--prometheus', 'Emit Prometheus text format.')
|
|
38
|
+
.option('--audit-log <path>', 'Audit log path (default: ~/.switchbot/audit.log).')
|
|
39
|
+
.action((opts) => {
|
|
40
|
+
const report = getHealthReport(opts.auditLog);
|
|
41
|
+
if (opts.prometheus) {
|
|
42
|
+
process.stdout.write(toPrometheusText(report));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (isJsonMode()) {
|
|
46
|
+
printJson(report);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const statusEmoji = report.overall === 'ok' ? '✓' : report.overall === 'degraded' ? '⚠' : '✗';
|
|
50
|
+
console.log(`${statusEmoji} overall: ${report.overall} (${report.generatedAt})`);
|
|
51
|
+
console.log('');
|
|
52
|
+
printTable(['Component', 'Status', 'Detail'], [
|
|
53
|
+
['quota', report.quota.status,
|
|
54
|
+
`${report.quota.used}/${report.quota.limit} (${report.quota.percentUsed}% used, ${report.quota.remaining} remaining)`],
|
|
55
|
+
['audit', report.audit.status,
|
|
56
|
+
report.audit.present
|
|
57
|
+
? `${report.audit.recentErrors}/${report.audit.recentTotal} errors in 24h (${report.audit.errorRatePercent}%)`
|
|
58
|
+
: 'log not present'],
|
|
59
|
+
['circuit', report.circuit.status,
|
|
60
|
+
`${report.circuit.name}: ${report.circuit.state} (failures: ${report.circuit.failures})`],
|
|
61
|
+
['process', 'ok',
|
|
62
|
+
`pid ${report.process.pid} · uptime ${report.process.uptimeSeconds}s · mem ${report.process.memoryMb}MB`],
|
|
63
|
+
]);
|
|
64
|
+
if (report.overall !== 'ok')
|
|
65
|
+
process.exit(1);
|
|
66
|
+
});
|
|
67
|
+
// switchbot health serve [--port <n>]
|
|
68
|
+
health
|
|
69
|
+
.command('serve')
|
|
70
|
+
.description('Start an HTTP server exposing /healthz (JSON) and /metrics (Prometheus).')
|
|
71
|
+
.option('--port <n>', 'Port to listen on.', intArg('--port'), '3100')
|
|
72
|
+
.option('--host <host>', 'Bind address.', '127.0.0.1')
|
|
73
|
+
.option('--audit-log <path>', 'Audit log path.')
|
|
74
|
+
.addHelpText('after', `
|
|
75
|
+
Endpoints:
|
|
76
|
+
GET /healthz JSON health report (HTTP 200 ok/degraded, 503 when circuit is open).
|
|
77
|
+
GET /metrics Prometheus text metrics.
|
|
78
|
+
|
|
79
|
+
Example:
|
|
80
|
+
$ switchbot health serve --port 3100
|
|
81
|
+
$ curl http://127.0.0.1:3100/healthz
|
|
82
|
+
`)
|
|
83
|
+
.action((opts) => {
|
|
84
|
+
const port = parseInt(opts.port, 10);
|
|
85
|
+
const handler = createHealthHandler(opts.auditLog);
|
|
86
|
+
const server = http.createServer(handler);
|
|
87
|
+
server.on('error', (err) => {
|
|
88
|
+
if (err.code === 'EADDRINUSE') {
|
|
89
|
+
handleError(Object.assign(new Error(`Port ${port} is already in use. Choose a different port with --port.`), { code: err.code }));
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
handleError(err);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
server.listen(port, opts.host, () => {
|
|
96
|
+
const addr = server.address();
|
|
97
|
+
const boundPort = typeof addr === 'object' && addr !== null ? addr.port : port;
|
|
98
|
+
if (isJsonMode()) {
|
|
99
|
+
printJson({ status: 'listening', host: opts.host, port: boundPort, endpoints: ['/healthz', '/metrics'] });
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
console.log(`health server listening on ${opts.host}:${boundPort}`);
|
|
103
|
+
console.log(' GET /healthz — JSON health report');
|
|
104
|
+
console.log(' GET /metrics — Prometheus text metrics');
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
function shutdown() {
|
|
108
|
+
server.close(() => process.exit(0));
|
|
109
|
+
}
|
|
110
|
+
process.on('SIGTERM', shutdown);
|
|
111
|
+
process.on('SIGINT', shutdown);
|
|
112
|
+
});
|
|
113
|
+
}
|
package/dist/commands/mcp.js
CHANGED
|
@@ -25,6 +25,7 @@ import { planMigration } from '../policy/migrate.js';
|
|
|
25
25
|
import { suggestPlan } from './plan.js';
|
|
26
26
|
import { suggestRule } from '../rules/suggest.js';
|
|
27
27
|
import { addRuleToPolicyFile, AddRuleError } from '../policy/add-rule.js';
|
|
28
|
+
import { allowsDirectDestructiveExecution, destructiveExecutionHint } from '../lib/destructive-mode.js';
|
|
28
29
|
import { writeFileSync } from 'node:fs';
|
|
29
30
|
import { readAudit } from '../utils/audit.js';
|
|
30
31
|
import { parseDurationToMs } from '../utils/flags.js';
|
|
@@ -135,6 +136,33 @@ function topNFromMap(counts, n) {
|
|
|
135
136
|
.slice(0, n)
|
|
136
137
|
.map(([key, count]) => ({ key, count }));
|
|
137
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Compute per-action risk metadata from the device catalog.
|
|
141
|
+
* `idempotencyHint` is sourced from CommandSpec.idempotent when available;
|
|
142
|
+
* falls back to "safe" only for unknown commands on non-destructive paths.
|
|
143
|
+
*/
|
|
144
|
+
function buildRiskProfile(typeName, command, commandType, isDestructive) {
|
|
145
|
+
// Look up the catalog spec to get the authoritative idempotent flag.
|
|
146
|
+
let idempotencyHint = isDestructive ? 'non-idempotent' : 'safe';
|
|
147
|
+
if (typeName && commandType === 'command') {
|
|
148
|
+
const entry = findCatalogEntry(typeName);
|
|
149
|
+
const entries = Array.isArray(entry) ? entry : entry ? [entry] : [];
|
|
150
|
+
for (const e of entries) {
|
|
151
|
+
const spec = e.commands.find((c) => c.command === command);
|
|
152
|
+
if (spec !== undefined) {
|
|
153
|
+
idempotencyHint = spec.idempotent === true ? 'safe' : 'non-idempotent';
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
riskLevel: isDestructive ? 'high' : commandType === 'command' ? 'medium' : 'low',
|
|
160
|
+
requiresConfirmation: isDestructive,
|
|
161
|
+
supportsDryRun: true,
|
|
162
|
+
idempotencyHint,
|
|
163
|
+
recommendedMode: isDestructive ? 'review-before-execute' : 'plan',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
138
166
|
export function createSwitchBotMcpServer(options) {
|
|
139
167
|
const eventManager = options?.eventManager;
|
|
140
168
|
const server = new McpServer({
|
|
@@ -301,7 +329,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
301
329
|
// ---- send_command ---------------------------------------------------------
|
|
302
330
|
server.registerTool('send_command', {
|
|
303
331
|
title: 'Send a control command to a device',
|
|
304
|
-
description: 'Execute a control command on a device (turnOn, setColor, startClean, unlock, openDoor, createKey, etc.). Destructive commands
|
|
332
|
+
description: 'Execute a control command on a device (turnOn, setColor, startClean, unlock, openDoor, createKey, etc.). Destructive commands require confirm:true and are still blocked in the default safety profile; use the reviewed plan workflow unless an explicit dev profile allows direct execution. Commands are validated offline against the device catalog. Use idempotencyKey to safely deduplicate retries within 60 seconds.',
|
|
305
333
|
_meta: { agentSafetyTier: 'action' },
|
|
306
334
|
inputSchema: z.object({
|
|
307
335
|
deviceId: z.string().describe('Device ID from list_devices'),
|
|
@@ -334,6 +362,16 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
334
362
|
command: z.string().optional(),
|
|
335
363
|
deviceId: z.string().optional(),
|
|
336
364
|
result: z.unknown().optional().describe('API response body from SwitchBot (absent on dryRun)'),
|
|
365
|
+
riskProfile: z
|
|
366
|
+
.object({
|
|
367
|
+
riskLevel: z.enum(['high', 'medium', 'low']),
|
|
368
|
+
requiresConfirmation: z.boolean(),
|
|
369
|
+
supportsDryRun: z.literal(true),
|
|
370
|
+
idempotencyHint: z.enum(['safe', 'non-idempotent']),
|
|
371
|
+
recommendedMode: z.enum(['review-before-execute', 'plan', 'direct']),
|
|
372
|
+
})
|
|
373
|
+
.optional()
|
|
374
|
+
.describe('Device+command-specific risk metadata. riskLevel:"high" means confirm:true was required. Always present on dryRun responses so agents can preview risk before committing.'),
|
|
337
375
|
verification: z
|
|
338
376
|
.object({
|
|
339
377
|
verifiable: z.boolean(),
|
|
@@ -404,7 +442,9 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
404
442
|
parameter: effectiveParameter ?? 'default',
|
|
405
443
|
commandType: effectiveType,
|
|
406
444
|
};
|
|
407
|
-
const
|
|
445
|
+
const dryIsDestructive = isDestructiveCommand(cached.type, effectiveCommand, effectiveType);
|
|
446
|
+
const dryRiskProfile = buildRiskProfile(cached.type, effectiveCommand, effectiveType, dryIsDestructive);
|
|
447
|
+
const structured = { ok: true, dryRun: true, riskProfile: dryRiskProfile, wouldSend };
|
|
408
448
|
return {
|
|
409
449
|
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
410
450
|
structuredContent: structured,
|
|
@@ -425,7 +465,21 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
425
465
|
}
|
|
426
466
|
typeName = physical ? physical.deviceType : ir.remoteType;
|
|
427
467
|
}
|
|
428
|
-
|
|
468
|
+
const destructive = isDestructiveCommand(typeName, effectiveCommand, effectiveType);
|
|
469
|
+
if (destructive && !allowsDirectDestructiveExecution()) {
|
|
470
|
+
const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType);
|
|
471
|
+
return mcpError('guard', 3, `Direct destructive execution is disabled for command "${effectiveCommand}" on device type "${typeName}".`, {
|
|
472
|
+
hint: reason ? `${destructiveExecutionHint()} Reason: ${reason}` : destructiveExecutionHint(),
|
|
473
|
+
context: {
|
|
474
|
+
command: effectiveCommand,
|
|
475
|
+
deviceType: typeName,
|
|
476
|
+
directExecutionAllowed: false,
|
|
477
|
+
requiredWorkflow: 'plan-approval',
|
|
478
|
+
...(reason ? { safetyReason: reason, destructiveReason: reason } : {}),
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
if (destructive && !confirm) {
|
|
429
483
|
const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType);
|
|
430
484
|
const entry = typeName ? findCatalogEntry(typeName) : null;
|
|
431
485
|
const spec = entry && !Array.isArray(entry)
|
|
@@ -490,7 +544,9 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
490
544
|
return apiErrorToMcpError(err);
|
|
491
545
|
}
|
|
492
546
|
const isIr = getCachedDevice(deviceId)?.category === 'ir';
|
|
493
|
-
const
|
|
547
|
+
const liveIsDestructive = destructive;
|
|
548
|
+
const riskProfile = buildRiskProfile(typeName, effectiveCommand, effectiveType, liveIsDestructive);
|
|
549
|
+
const structured = { ok: true, command: effectiveCommand, deviceId, result, riskProfile };
|
|
494
550
|
if (isIr) {
|
|
495
551
|
structured.verification = {
|
|
496
552
|
verifiable: false,
|
|
@@ -1254,7 +1310,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
1254
1310
|
// ---- plan_run -------------------------------------------------------------
|
|
1255
1311
|
server.registerTool('plan_run', {
|
|
1256
1312
|
title: 'Validate and execute a SwitchBot plan',
|
|
1257
|
-
description: 'Execute a Plan JSON object (version 1.0). Destructive command steps are skipped unless yes=true. ' +
|
|
1313
|
+
description: 'Execute a Plan JSON object (version 1.0). Destructive command steps are skipped unless yes=true, and the default safety profile still refuses direct destructive execution in favor of the reviewed plan workflow. ' +
|
|
1258
1314
|
'Scene and wait steps run in order. Returns per-step results and a summary.',
|
|
1259
1315
|
_meta: { agentSafetyTier: 'action' },
|
|
1260
1316
|
inputSchema: z.object({
|
|
@@ -1289,6 +1345,38 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
1289
1345
|
};
|
|
1290
1346
|
const continueOnError = continue_on_error === true;
|
|
1291
1347
|
const allowDestructive = yes === true;
|
|
1348
|
+
const destructiveSteps = validated.plan.steps
|
|
1349
|
+
.map((step, index) => ({ step, index }))
|
|
1350
|
+
.filter((entry) => entry.step.type === 'command')
|
|
1351
|
+
.map(({ step, index }) => {
|
|
1352
|
+
const resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
|
|
1353
|
+
const commandType = step.commandType ?? 'command';
|
|
1354
|
+
const deviceType = getCachedDevice(resolvedDeviceId)?.type;
|
|
1355
|
+
return {
|
|
1356
|
+
index: index + 1,
|
|
1357
|
+
deviceId: resolvedDeviceId,
|
|
1358
|
+
command: step.command,
|
|
1359
|
+
commandType,
|
|
1360
|
+
deviceType: deviceType ?? null,
|
|
1361
|
+
destructive: isDestructiveCommand(deviceType, step.command, commandType),
|
|
1362
|
+
};
|
|
1363
|
+
})
|
|
1364
|
+
.filter((step) => step.destructive);
|
|
1365
|
+
if (allowDestructive && destructiveSteps.length > 0 && !allowsDirectDestructiveExecution()) {
|
|
1366
|
+
return mcpError('guard', 3, 'Direct destructive execution is disabled for plan_run.', {
|
|
1367
|
+
hint: destructiveExecutionHint(),
|
|
1368
|
+
context: {
|
|
1369
|
+
destructiveSteps: destructiveSteps.map((step) => ({
|
|
1370
|
+
step: step.index,
|
|
1371
|
+
deviceId: step.deviceId,
|
|
1372
|
+
deviceType: step.deviceType,
|
|
1373
|
+
command: step.command,
|
|
1374
|
+
commandType: step.commandType,
|
|
1375
|
+
})),
|
|
1376
|
+
requiredWorkflow: 'plan-approval',
|
|
1377
|
+
},
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1292
1380
|
for (let i = 0; i < validated.plan.steps.length; i++) {
|
|
1293
1381
|
const step = validated.plan.steps[i];
|
|
1294
1382
|
const idx = i + 1;
|