@switchbot/openapi-cli 2.7.2 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +481 -103
  2. package/dist/api/client.js +23 -1
  3. package/dist/commands/agent-bootstrap.js +47 -2
  4. package/dist/commands/auth.js +354 -0
  5. package/dist/commands/batch.js +20 -4
  6. package/dist/commands/capabilities.js +155 -65
  7. package/dist/commands/config.js +109 -0
  8. package/dist/commands/daemon.js +367 -0
  9. package/dist/commands/devices.js +62 -11
  10. package/dist/commands/doctor.js +417 -8
  11. package/dist/commands/events.js +3 -3
  12. package/dist/commands/explain.js +1 -2
  13. package/dist/commands/health.js +113 -0
  14. package/dist/commands/install.js +246 -0
  15. package/dist/commands/mcp.js +888 -7
  16. package/dist/commands/plan.js +379 -103
  17. package/dist/commands/policy.js +586 -0
  18. package/dist/commands/rules.js +875 -0
  19. package/dist/commands/scenes.js +140 -0
  20. package/dist/commands/schema.js +0 -2
  21. package/dist/commands/status-sync.js +131 -0
  22. package/dist/commands/uninstall.js +237 -0
  23. package/dist/commands/upgrade-check.js +88 -0
  24. package/dist/config.js +14 -0
  25. package/dist/credentials/backends/file.js +101 -0
  26. package/dist/credentials/backends/linux.js +129 -0
  27. package/dist/credentials/backends/macos.js +129 -0
  28. package/dist/credentials/backends/windows.js +215 -0
  29. package/dist/credentials/keychain.js +88 -0
  30. package/dist/credentials/prime.js +52 -0
  31. package/dist/devices/catalog.js +4 -10
  32. package/dist/index.js +30 -1
  33. package/dist/install/default-steps.js +257 -0
  34. package/dist/install/preflight.js +212 -0
  35. package/dist/install/steps.js +67 -0
  36. package/dist/lib/command-keywords.js +17 -0
  37. package/dist/lib/daemon-state.js +46 -0
  38. package/dist/lib/destructive-mode.js +12 -0
  39. package/dist/lib/devices.js +1 -1
  40. package/dist/lib/plan-store.js +68 -0
  41. package/dist/policy/add-rule.js +124 -0
  42. package/dist/policy/diff.js +91 -0
  43. package/dist/policy/examples/policy.example.yaml +99 -0
  44. package/dist/policy/format.js +57 -0
  45. package/dist/policy/load.js +61 -0
  46. package/dist/policy/migrate.js +67 -0
  47. package/dist/policy/schema/v0.2.json +331 -0
  48. package/dist/policy/schema.js +18 -0
  49. package/dist/policy/validate.js +262 -0
  50. package/dist/rules/action.js +205 -0
  51. package/dist/rules/audit-query.js +89 -0
  52. package/dist/rules/conflict-analyzer.js +203 -0
  53. package/dist/rules/cron-scheduler.js +186 -0
  54. package/dist/rules/destructive.js +52 -0
  55. package/dist/rules/engine.js +757 -0
  56. package/dist/rules/matcher.js +230 -0
  57. package/dist/rules/pid-file.js +95 -0
  58. package/dist/rules/quiet-hours.js +45 -0
  59. package/dist/rules/suggest.js +95 -0
  60. package/dist/rules/throttle.js +116 -0
  61. package/dist/rules/types.js +34 -0
  62. package/dist/rules/webhook-listener.js +223 -0
  63. package/dist/rules/webhook-token.js +90 -0
  64. package/dist/status-sync/manager.js +268 -0
  65. package/dist/utils/audit.js +12 -2
  66. package/dist/utils/health.js +101 -0
  67. package/dist/utils/output.js +72 -23
  68. package/dist/utils/retry.js +81 -0
  69. package/package.json +12 -4
@@ -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
- if (envOk)
16
- return { name: 'credentials', status: 'ok', detail: 'env: SWITCHBOT_TOKEN + SWITCHBOT_SECRET' };
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: `No env vars and no config at ${file}. Run 'switchbot config set-token'.`,
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 { name: 'credentials', status: 'fail', detail: `Config ${file} missing token/secret.` };
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
- return { name: 'credentials', status: 'ok', detail: `file: ${file}` };
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: `Unreadable config ${file}: ${err instanceof Error ? err.message : String(err)}`,
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' ? c.detail : JSON.stringify(c.detail);
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('');
@@ -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
- // Drop remaining upload without destroying the socket mid-flush.
120
- req.on('data', () => { });
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);
@@ -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
+ }