@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.
- package/README.md +481 -103
- package/dist/api/client.js +23 -1
- package/dist/commands/agent-bootstrap.js +47 -2
- package/dist/commands/auth.js +354 -0
- package/dist/commands/batch.js +20 -4
- package/dist/commands/capabilities.js +155 -65
- package/dist/commands/config.js +109 -0
- package/dist/commands/daemon.js +367 -0
- package/dist/commands/devices.js +62 -11
- package/dist/commands/doctor.js +417 -8
- package/dist/commands/events.js +3 -3
- package/dist/commands/explain.js +1 -2
- package/dist/commands/health.js +113 -0
- package/dist/commands/install.js +246 -0
- package/dist/commands/mcp.js +888 -7
- package/dist/commands/plan.js +379 -103
- package/dist/commands/policy.js +586 -0
- package/dist/commands/rules.js +875 -0
- package/dist/commands/scenes.js +140 -0
- package/dist/commands/schema.js +0 -2
- package/dist/commands/status-sync.js +131 -0
- package/dist/commands/uninstall.js +237 -0
- package/dist/commands/upgrade-check.js +88 -0
- package/dist/config.js +14 -0
- package/dist/credentials/backends/file.js +101 -0
- package/dist/credentials/backends/linux.js +129 -0
- package/dist/credentials/backends/macos.js +129 -0
- package/dist/credentials/backends/windows.js +215 -0
- package/dist/credentials/keychain.js +88 -0
- package/dist/credentials/prime.js +52 -0
- package/dist/devices/catalog.js +4 -10
- package/dist/index.js +30 -1
- package/dist/install/default-steps.js +257 -0
- package/dist/install/preflight.js +212 -0
- package/dist/install/steps.js +67 -0
- package/dist/lib/command-keywords.js +17 -0
- package/dist/lib/daemon-state.js +46 -0
- package/dist/lib/destructive-mode.js +12 -0
- package/dist/lib/devices.js +1 -1
- package/dist/lib/plan-store.js +68 -0
- package/dist/policy/add-rule.js +124 -0
- package/dist/policy/diff.js +91 -0
- package/dist/policy/examples/policy.example.yaml +99 -0
- package/dist/policy/format.js +57 -0
- package/dist/policy/load.js +61 -0
- package/dist/policy/migrate.js +67 -0
- package/dist/policy/schema/v0.2.json +331 -0
- package/dist/policy/schema.js +18 -0
- package/dist/policy/validate.js +262 -0
- package/dist/rules/action.js +205 -0
- package/dist/rules/audit-query.js +89 -0
- package/dist/rules/conflict-analyzer.js +203 -0
- package/dist/rules/cron-scheduler.js +186 -0
- package/dist/rules/destructive.js +52 -0
- package/dist/rules/engine.js +757 -0
- package/dist/rules/matcher.js +230 -0
- package/dist/rules/pid-file.js +95 -0
- package/dist/rules/quiet-hours.js +45 -0
- package/dist/rules/suggest.js +95 -0
- package/dist/rules/throttle.js +116 -0
- package/dist/rules/types.js +34 -0
- package/dist/rules/webhook-listener.js +223 -0
- package/dist/rules/webhook-token.js +90 -0
- package/dist/status-sync/manager.js +268 -0
- package/dist/utils/audit.js +12 -2
- package/dist/utils/health.js +101 -0
- package/dist/utils/output.js +72 -23
- package/dist/utils/retry.js +81 -0
- package/package.json +12 -4
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';
|
|
@@ -9,32 +10,128 @@ import { DAILY_QUOTA, todayUsage } from '../utils/quota.js';
|
|
|
9
10
|
import { AGENT_BOOTSTRAP_SCHEMA_VERSION } from './agent-bootstrap.js';
|
|
10
11
|
import { CATALOG_SCHEMA_VERSION } from '../devices/catalog.js';
|
|
11
12
|
import { createSwitchBotMcpServer, listRegisteredTools } from './mcp.js';
|
|
13
|
+
import { resolvePolicyPath, loadPolicyFile, PolicyFileNotFoundError, PolicyYamlParseError, } from '../policy/load.js';
|
|
14
|
+
import { validateLoadedPolicy } from '../policy/validate.js';
|
|
15
|
+
import { selectCredentialStore } from '../credentials/keychain.js';
|
|
16
|
+
import { getActiveProfile } from '../lib/request-context.js';
|
|
17
|
+
import { readDaemonState } from '../lib/daemon-state.js';
|
|
18
|
+
import { isPidAlive } from '../rules/pid-file.js';
|
|
12
19
|
export const DOCTOR_SCHEMA_VERSION = 1;
|
|
13
20
|
async function checkCredentials() {
|
|
14
21
|
const envOk = Boolean(process.env.SWITCHBOT_TOKEN && process.env.SWITCHBOT_SECRET);
|
|
15
|
-
|
|
16
|
-
|
|
22
|
+
const profile = getActiveProfile() ?? 'default';
|
|
23
|
+
let backendName = 'file';
|
|
24
|
+
let backendLabel = 'file';
|
|
25
|
+
let writable = true;
|
|
26
|
+
let keychainHasProfile = false;
|
|
27
|
+
try {
|
|
28
|
+
const store = await selectCredentialStore();
|
|
29
|
+
const desc = store.describe();
|
|
30
|
+
backendName = store.name;
|
|
31
|
+
backendLabel = desc.backend;
|
|
32
|
+
writable = desc.writable;
|
|
33
|
+
try {
|
|
34
|
+
const creds = await store.get(profile);
|
|
35
|
+
keychainHasProfile = Boolean(creds && creds.token && creds.secret);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
keychainHasProfile = false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// selectCredentialStore falls back to file; a throw here is unexpected but
|
|
43
|
+
// non-fatal — downstream callers degrade to the file path.
|
|
44
|
+
}
|
|
45
|
+
if (envOk) {
|
|
46
|
+
return {
|
|
47
|
+
name: 'credentials',
|
|
48
|
+
status: 'ok',
|
|
49
|
+
detail: {
|
|
50
|
+
source: 'env',
|
|
51
|
+
backend: backendName,
|
|
52
|
+
backendLabel,
|
|
53
|
+
writable,
|
|
54
|
+
profile,
|
|
55
|
+
message: 'env: SWITCHBOT_TOKEN + SWITCHBOT_SECRET',
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (keychainHasProfile && backendName !== 'file') {
|
|
60
|
+
return {
|
|
61
|
+
name: 'credentials',
|
|
62
|
+
status: 'ok',
|
|
63
|
+
detail: {
|
|
64
|
+
source: 'keychain',
|
|
65
|
+
backend: backendName,
|
|
66
|
+
backendLabel,
|
|
67
|
+
writable,
|
|
68
|
+
profile,
|
|
69
|
+
message: `keychain (${backendLabel}) has credentials for profile "${profile}"`,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
17
73
|
const file = configFilePath();
|
|
18
74
|
if (!fs.existsSync(file)) {
|
|
19
75
|
return {
|
|
20
76
|
name: 'credentials',
|
|
21
77
|
status: 'fail',
|
|
22
|
-
detail:
|
|
78
|
+
detail: {
|
|
79
|
+
source: 'none',
|
|
80
|
+
backend: backendName,
|
|
81
|
+
backendLabel,
|
|
82
|
+
writable,
|
|
83
|
+
profile,
|
|
84
|
+
message: `No env vars, no keychain entry for profile "${profile}", and no config at ${file}. Run 'switchbot config set-token' or 'switchbot auth keychain set'.`,
|
|
85
|
+
},
|
|
23
86
|
};
|
|
24
87
|
}
|
|
25
88
|
try {
|
|
26
89
|
const raw = fs.readFileSync(file, 'utf-8');
|
|
27
90
|
const cfg = JSON.parse(raw);
|
|
28
91
|
if (!cfg.token || !cfg.secret) {
|
|
29
|
-
return {
|
|
92
|
+
return {
|
|
93
|
+
name: 'credentials',
|
|
94
|
+
status: 'fail',
|
|
95
|
+
detail: {
|
|
96
|
+
source: 'file',
|
|
97
|
+
backend: backendName,
|
|
98
|
+
backendLabel,
|
|
99
|
+
writable,
|
|
100
|
+
profile,
|
|
101
|
+
message: `Config ${file} missing token/secret.`,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
30
104
|
}
|
|
31
|
-
|
|
105
|
+
const status = writable && backendName !== 'file' ? 'warn' : 'ok';
|
|
106
|
+
const hint = status === 'warn'
|
|
107
|
+
? `Consider running 'switchbot auth keychain migrate' to move credentials into ${backendLabel}.`
|
|
108
|
+
: undefined;
|
|
109
|
+
return {
|
|
110
|
+
name: 'credentials',
|
|
111
|
+
status,
|
|
112
|
+
detail: {
|
|
113
|
+
source: 'file',
|
|
114
|
+
backend: backendName,
|
|
115
|
+
backendLabel,
|
|
116
|
+
writable,
|
|
117
|
+
profile,
|
|
118
|
+
message: `file: ${file}`,
|
|
119
|
+
...(hint ? { hint } : {}),
|
|
120
|
+
},
|
|
121
|
+
};
|
|
32
122
|
}
|
|
33
123
|
catch (err) {
|
|
34
124
|
return {
|
|
35
125
|
name: 'credentials',
|
|
36
126
|
status: 'fail',
|
|
37
|
-
detail:
|
|
127
|
+
detail: {
|
|
128
|
+
source: 'file',
|
|
129
|
+
backend: backendName,
|
|
130
|
+
backendLabel,
|
|
131
|
+
writable,
|
|
132
|
+
profile,
|
|
133
|
+
message: `Unreadable config ${file}: ${err instanceof Error ? err.message : String(err)}`,
|
|
134
|
+
},
|
|
38
135
|
};
|
|
39
136
|
}
|
|
40
137
|
}
|
|
@@ -308,6 +405,124 @@ function checkAudit() {
|
|
|
308
405
|
};
|
|
309
406
|
}
|
|
310
407
|
}
|
|
408
|
+
function checkPolicy() {
|
|
409
|
+
// A policy file is optional — many users run the CLI without one. Report
|
|
410
|
+
// `ok` with `present: false` so agents can tell the difference between
|
|
411
|
+
// "no policy configured" (fine) and "policy broken" (needs attention).
|
|
412
|
+
const policyPath = resolvePolicyPath();
|
|
413
|
+
try {
|
|
414
|
+
const loaded = loadPolicyFile(policyPath);
|
|
415
|
+
const result = validateLoadedPolicy(loaded);
|
|
416
|
+
if (result.valid) {
|
|
417
|
+
return {
|
|
418
|
+
name: 'policy',
|
|
419
|
+
status: 'ok',
|
|
420
|
+
detail: {
|
|
421
|
+
path: policyPath,
|
|
422
|
+
present: true,
|
|
423
|
+
valid: true,
|
|
424
|
+
schemaVersion: result.schemaVersion,
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
name: 'policy',
|
|
430
|
+
status: 'fail',
|
|
431
|
+
detail: {
|
|
432
|
+
path: policyPath,
|
|
433
|
+
present: true,
|
|
434
|
+
valid: false,
|
|
435
|
+
schemaVersion: result.schemaVersion,
|
|
436
|
+
errorCount: result.errors.length,
|
|
437
|
+
firstError: result.errors[0]
|
|
438
|
+
? {
|
|
439
|
+
path: result.errors[0].path,
|
|
440
|
+
line: result.errors[0].line,
|
|
441
|
+
message: result.errors[0].message,
|
|
442
|
+
}
|
|
443
|
+
: undefined,
|
|
444
|
+
message: "run 'switchbot policy validate' for full diagnostics",
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
catch (err) {
|
|
449
|
+
if (err instanceof PolicyFileNotFoundError) {
|
|
450
|
+
return {
|
|
451
|
+
name: 'policy',
|
|
452
|
+
status: 'ok',
|
|
453
|
+
detail: {
|
|
454
|
+
path: policyPath,
|
|
455
|
+
present: false,
|
|
456
|
+
message: "no policy file (optional — run 'switchbot policy new' to scaffold one)",
|
|
457
|
+
},
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
if (err instanceof PolicyYamlParseError) {
|
|
461
|
+
const first = err.yamlErrors[0];
|
|
462
|
+
return {
|
|
463
|
+
name: 'policy',
|
|
464
|
+
status: 'fail',
|
|
465
|
+
detail: {
|
|
466
|
+
path: policyPath,
|
|
467
|
+
present: true,
|
|
468
|
+
valid: false,
|
|
469
|
+
parseError: true,
|
|
470
|
+
line: first?.line,
|
|
471
|
+
col: first?.col,
|
|
472
|
+
message: first?.message ?? err.message,
|
|
473
|
+
},
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
return {
|
|
477
|
+
name: 'policy',
|
|
478
|
+
status: 'warn',
|
|
479
|
+
detail: {
|
|
480
|
+
path: policyPath,
|
|
481
|
+
message: `could not read policy file: ${err instanceof Error ? err.message : String(err)}`,
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
}
|
|
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
|
+
}
|
|
311
526
|
function checkNodeVersion() {
|
|
312
527
|
const major = Number(process.versions.node.split('.')[0]);
|
|
313
528
|
if (Number.isFinite(major) && major < 18) {
|
|
@@ -315,6 +530,181 @@ function checkNodeVersion() {
|
|
|
315
530
|
}
|
|
316
531
|
return { name: 'node', status: 'ok', detail: `Node ${process.versions.node}` };
|
|
317
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
|
+
}
|
|
318
708
|
function checkMqtt() {
|
|
319
709
|
// MQTT credentials are auto-provisioned from the SwitchBot API using the
|
|
320
710
|
// account's token+secret — no extra env vars needed. Report availability
|
|
@@ -431,7 +821,9 @@ function checkMcp() {
|
|
|
431
821
|
}
|
|
432
822
|
const CHECK_REGISTRY = [
|
|
433
823
|
{ name: 'node', description: 'Node.js version compatibility', run: () => checkNodeVersion() },
|
|
824
|
+
{ name: 'path', description: 'switchbot binary reachable on PATH', run: () => checkPathDiscoverability() },
|
|
434
825
|
{ name: 'credentials', description: 'credentials file present and parseable', run: () => checkCredentials() },
|
|
826
|
+
{ name: 'keychain', description: 'OS keychain backend availability and usage', run: () => checkKeychain() },
|
|
435
827
|
{ name: 'profiles', description: 'profile definitions valid', run: () => checkProfiles() },
|
|
436
828
|
{ name: 'catalog', description: 'catalog loads', run: () => checkCatalog() },
|
|
437
829
|
{ name: 'catalog-schema', description: 'catalog vs agent-bootstrap version aligned', run: () => checkCatalogSchema() },
|
|
@@ -444,7 +836,10 @@ const CHECK_REGISTRY = [
|
|
|
444
836
|
run: ({ probe }) => (probe ? checkMqttProbe() : checkMqtt()),
|
|
445
837
|
},
|
|
446
838
|
{ name: 'mcp', description: 'MCP server instantiable + tool count', run: () => checkMcp() },
|
|
839
|
+
{ name: 'policy', description: 'policy.yaml present + schema-valid (if configured)', run: () => checkPolicy() },
|
|
447
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() },
|
|
448
843
|
];
|
|
449
844
|
function applyFixes(checks, writeOk) {
|
|
450
845
|
const results = [];
|
|
@@ -495,7 +890,7 @@ function applyFixes(checks, writeOk) {
|
|
|
495
890
|
export function registerDoctorCommand(program) {
|
|
496
891
|
program
|
|
497
892
|
.command('doctor')
|
|
498
|
-
.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')
|
|
499
894
|
.option('--section <names>', 'Comma-separated list of checks to run (see --list for names)')
|
|
500
895
|
.option('--list', 'Print the registered check names and exit 0 without running any check')
|
|
501
896
|
.option('--fix', 'Apply safe, reversible remediations for failing checks (e.g. clear stale cache)')
|
|
@@ -510,6 +905,7 @@ Examples:
|
|
|
510
905
|
$ switchbot --json doctor | jq '.checks[] | select(.status != "ok")'
|
|
511
906
|
$ switchbot doctor --list
|
|
512
907
|
$ switchbot doctor --section credentials,mcp --json
|
|
908
|
+
$ switchbot doctor --section daemon,health --json
|
|
513
909
|
$ switchbot doctor --probe --json
|
|
514
910
|
$ switchbot doctor --fix --yes --json
|
|
515
911
|
`)
|
|
@@ -561,6 +957,13 @@ Examples:
|
|
|
561
957
|
};
|
|
562
958
|
const overallFail = summary.fail > 0;
|
|
563
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';
|
|
564
967
|
let fixes;
|
|
565
968
|
if (opts.fix) {
|
|
566
969
|
fixes = applyFixes(checks, Boolean(opts.yes));
|
|
@@ -574,6 +977,8 @@ Examples:
|
|
|
574
977
|
const payload = {
|
|
575
978
|
ok: overall === 'ok',
|
|
576
979
|
overall,
|
|
980
|
+
maturityScore,
|
|
981
|
+
maturityLabel,
|
|
577
982
|
generatedAt: new Date().toISOString(),
|
|
578
983
|
schemaVersion: DOCTOR_SCHEMA_VERSION,
|
|
579
984
|
summary,
|
|
@@ -586,7 +991,11 @@ Examples:
|
|
|
586
991
|
else {
|
|
587
992
|
for (const c of checks) {
|
|
588
993
|
const icon = c.status === 'ok' ? '✓' : c.status === 'warn' ? '!' : '✗';
|
|
589
|
-
const detailStr = typeof c.detail === 'string'
|
|
994
|
+
const detailStr = typeof c.detail === 'string'
|
|
995
|
+
? c.detail
|
|
996
|
+
: (typeof c.detail.message === 'string'
|
|
997
|
+
? (c.detail.message)
|
|
998
|
+
: JSON.stringify(c.detail));
|
|
590
999
|
console.log(`${icon} ${c.name.padEnd(12)} ${detailStr}`);
|
|
591
1000
|
}
|
|
592
1001
|
console.log('');
|
package/dist/commands/events.js
CHANGED
|
@@ -114,10 +114,10 @@ export function startReceiver(port, pathMatch, filter, onEvent) {
|
|
|
114
114
|
if (size > MAX_BODY_BYTES) {
|
|
115
115
|
bailed = true;
|
|
116
116
|
res.statusCode = 413;
|
|
117
|
-
res.setHeader('connection', 'close');
|
|
118
117
|
res.end('payload too large');
|
|
119
|
-
//
|
|
120
|
-
|
|
118
|
+
// Drain remaining upload so the client can read the 413 response before
|
|
119
|
+
// the connection closes naturally (avoids ECONNRESET racing the response).
|
|
120
|
+
req.resume();
|
|
121
121
|
return;
|
|
122
122
|
}
|
|
123
123
|
chunks.push(c);
|
package/dist/commands/explain.js
CHANGED
|
@@ -50,7 +50,6 @@ Examples:
|
|
|
50
50
|
parameter: c.parameter,
|
|
51
51
|
idempotent: c.idempotent,
|
|
52
52
|
...(tier ? { safetyTier: tier } : {}),
|
|
53
|
-
destructive: c.destructive,
|
|
54
53
|
};
|
|
55
54
|
})
|
|
56
55
|
: [];
|
|
@@ -114,7 +113,7 @@ function printHuman(r) {
|
|
|
114
113
|
if (r.commands.length) {
|
|
115
114
|
console.log('commands:');
|
|
116
115
|
for (const c of r.commands) {
|
|
117
|
-
const flags = [c.idempotent && 'idempotent', c.destructive && 'destructive']
|
|
116
|
+
const flags = [c.idempotent && 'idempotent', c.safetyTier === 'destructive' && 'destructive']
|
|
118
117
|
.filter(Boolean)
|
|
119
118
|
.join(', ');
|
|
120
119
|
const suffix = flags ? ` [${flags}]` : '';
|
|
@@ -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
|
+
}
|