@switchbot/openapi-cli 2.6.4 → 3.0.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 (67) hide show
  1. package/README.md +385 -103
  2. package/dist/api/client.js +13 -12
  3. package/dist/commands/agent-bootstrap.js +67 -16
  4. package/dist/commands/auth.js +354 -0
  5. package/dist/commands/batch.js +26 -21
  6. package/dist/commands/capabilities.js +29 -21
  7. package/dist/commands/catalog.js +4 -3
  8. package/dist/commands/config.js +57 -37
  9. package/dist/commands/devices.js +63 -37
  10. package/dist/commands/doctor.js +539 -26
  11. package/dist/commands/events.js +115 -26
  12. package/dist/commands/expand.js +7 -15
  13. package/dist/commands/explain.js +10 -7
  14. package/dist/commands/history.js +12 -18
  15. package/dist/commands/identity.js +59 -0
  16. package/dist/commands/install.js +246 -0
  17. package/dist/commands/mcp.js +895 -15
  18. package/dist/commands/plan.js +111 -15
  19. package/dist/commands/policy.js +469 -0
  20. package/dist/commands/rules.js +657 -0
  21. package/dist/commands/schema.js +20 -12
  22. package/dist/commands/status-sync.js +131 -0
  23. package/dist/commands/uninstall.js +237 -0
  24. package/dist/commands/watch.js +15 -2
  25. package/dist/config.js +14 -0
  26. package/dist/credentials/backends/file.js +101 -0
  27. package/dist/credentials/backends/linux.js +129 -0
  28. package/dist/credentials/backends/macos.js +129 -0
  29. package/dist/credentials/backends/windows.js +215 -0
  30. package/dist/credentials/keychain.js +88 -0
  31. package/dist/credentials/prime.js +52 -0
  32. package/dist/devices/catalog.js +118 -11
  33. package/dist/devices/resources.js +270 -0
  34. package/dist/index.js +39 -4
  35. package/dist/install/default-steps.js +257 -0
  36. package/dist/install/preflight.js +212 -0
  37. package/dist/install/steps.js +67 -0
  38. package/dist/lib/command-keywords.js +17 -0
  39. package/dist/lib/devices.js +15 -5
  40. package/dist/policy/add-rule.js +124 -0
  41. package/dist/policy/diff.js +91 -0
  42. package/dist/policy/examples/policy.example.yaml +99 -0
  43. package/dist/policy/format.js +57 -0
  44. package/dist/policy/load.js +61 -0
  45. package/dist/policy/migrate.js +67 -0
  46. package/dist/policy/schema/v0.2.json +302 -0
  47. package/dist/policy/schema.js +18 -0
  48. package/dist/policy/validate.js +262 -0
  49. package/dist/rules/action.js +205 -0
  50. package/dist/rules/audit-query.js +89 -0
  51. package/dist/rules/cron-scheduler.js +186 -0
  52. package/dist/rules/destructive.js +52 -0
  53. package/dist/rules/engine.js +567 -0
  54. package/dist/rules/matcher.js +230 -0
  55. package/dist/rules/pid-file.js +95 -0
  56. package/dist/rules/quiet-hours.js +45 -0
  57. package/dist/rules/suggest.js +95 -0
  58. package/dist/rules/throttle.js +78 -0
  59. package/dist/rules/types.js +34 -0
  60. package/dist/rules/webhook-listener.js +223 -0
  61. package/dist/rules/webhook-token.js +90 -0
  62. package/dist/schema/field-aliases.js +95 -0
  63. package/dist/status-sync/manager.js +268 -0
  64. package/dist/utils/audit.js +12 -2
  65. package/dist/utils/help-json.js +54 -0
  66. package/dist/utils/output.js +17 -0
  67. package/package.json +12 -4
@@ -1,36 +1,134 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import { printJson, isJsonMode } from '../utils/output.js';
4
+ import { printJson, isJsonMode, exitWithError } from '../utils/output.js';
5
5
  import { getEffectiveCatalog } from '../devices/catalog.js';
6
6
  import { configFilePath, listProfiles, readProfileMeta } from '../config.js';
7
- import { describeCache } from '../devices/cache.js';
7
+ import { describeCache, resetListCache } from '../devices/cache.js';
8
+ import { DAILY_QUOTA, todayUsage } from '../utils/quota.js';
9
+ import { AGENT_BOOTSTRAP_SCHEMA_VERSION } from './agent-bootstrap.js';
10
+ import { CATALOG_SCHEMA_VERSION } from '../devices/catalog.js';
11
+ import { createSwitchBotMcpServer, listRegisteredTools } from './mcp.js';
12
+ import { resolvePolicyPath, loadPolicyFile, PolicyFileNotFoundError, PolicyYamlParseError, } from '../policy/load.js';
13
+ import { validateLoadedPolicy } from '../policy/validate.js';
14
+ import { selectCredentialStore } from '../credentials/keychain.js';
15
+ import { getActiveProfile } from '../lib/request-context.js';
8
16
  export const DOCTOR_SCHEMA_VERSION = 1;
9
17
  async function checkCredentials() {
10
18
  const envOk = Boolean(process.env.SWITCHBOT_TOKEN && process.env.SWITCHBOT_SECRET);
11
- if (envOk)
12
- return { name: 'credentials', status: 'ok', detail: 'env: SWITCHBOT_TOKEN + SWITCHBOT_SECRET' };
19
+ const profile = getActiveProfile() ?? 'default';
20
+ let backendName = 'file';
21
+ let backendLabel = 'file';
22
+ let writable = true;
23
+ let keychainHasProfile = false;
24
+ try {
25
+ const store = await selectCredentialStore();
26
+ const desc = store.describe();
27
+ backendName = store.name;
28
+ backendLabel = desc.backend;
29
+ writable = desc.writable;
30
+ try {
31
+ const creds = await store.get(profile);
32
+ keychainHasProfile = Boolean(creds && creds.token && creds.secret);
33
+ }
34
+ catch {
35
+ keychainHasProfile = false;
36
+ }
37
+ }
38
+ catch {
39
+ // selectCredentialStore falls back to file; a throw here is unexpected but
40
+ // non-fatal — downstream callers degrade to the file path.
41
+ }
42
+ if (envOk) {
43
+ return {
44
+ name: 'credentials',
45
+ status: 'ok',
46
+ detail: {
47
+ source: 'env',
48
+ backend: backendName,
49
+ backendLabel,
50
+ writable,
51
+ profile,
52
+ message: 'env: SWITCHBOT_TOKEN + SWITCHBOT_SECRET',
53
+ },
54
+ };
55
+ }
56
+ if (keychainHasProfile && backendName !== 'file') {
57
+ return {
58
+ name: 'credentials',
59
+ status: 'ok',
60
+ detail: {
61
+ source: 'keychain',
62
+ backend: backendName,
63
+ backendLabel,
64
+ writable,
65
+ profile,
66
+ message: `keychain (${backendLabel}) has credentials for profile "${profile}"`,
67
+ },
68
+ };
69
+ }
13
70
  const file = configFilePath();
14
71
  if (!fs.existsSync(file)) {
15
72
  return {
16
73
  name: 'credentials',
17
74
  status: 'fail',
18
- detail: `No env vars and no config at ${file}. Run 'switchbot config set-token'.`,
75
+ detail: {
76
+ source: 'none',
77
+ backend: backendName,
78
+ backendLabel,
79
+ writable,
80
+ profile,
81
+ 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'.`,
82
+ },
19
83
  };
20
84
  }
21
85
  try {
22
86
  const raw = fs.readFileSync(file, 'utf-8');
23
87
  const cfg = JSON.parse(raw);
24
88
  if (!cfg.token || !cfg.secret) {
25
- return { name: 'credentials', status: 'fail', detail: `Config ${file} missing token/secret.` };
89
+ return {
90
+ name: 'credentials',
91
+ status: 'fail',
92
+ detail: {
93
+ source: 'file',
94
+ backend: backendName,
95
+ backendLabel,
96
+ writable,
97
+ profile,
98
+ message: `Config ${file} missing token/secret.`,
99
+ },
100
+ };
26
101
  }
27
- return { name: 'credentials', status: 'ok', detail: `file: ${file}` };
102
+ const status = writable && backendName !== 'file' ? 'warn' : 'ok';
103
+ const hint = status === 'warn'
104
+ ? `Consider running 'switchbot auth keychain migrate' to move credentials into ${backendLabel}.`
105
+ : undefined;
106
+ return {
107
+ name: 'credentials',
108
+ status,
109
+ detail: {
110
+ source: 'file',
111
+ backend: backendName,
112
+ backendLabel,
113
+ writable,
114
+ profile,
115
+ message: `file: ${file}`,
116
+ ...(hint ? { hint } : {}),
117
+ },
118
+ };
28
119
  }
29
120
  catch (err) {
30
121
  return {
31
122
  name: 'credentials',
32
123
  status: 'fail',
33
- detail: `Unreadable config ${file}: ${err instanceof Error ? err.message : String(err)}`,
124
+ detail: {
125
+ source: 'file',
126
+ backend: backendName,
127
+ backendLabel,
128
+ writable,
129
+ profile,
130
+ message: `Unreadable config ${file}: ${err instanceof Error ? err.message : String(err)}`,
131
+ },
34
132
  };
35
133
  }
36
134
  }
@@ -160,15 +258,226 @@ function checkCache() {
160
258
  function checkQuotaFile() {
161
259
  const p = path.join(os.homedir(), '.switchbot', 'quota.json');
162
260
  if (!fs.existsSync(p)) {
163
- return { name: 'quota', status: 'ok', detail: 'no quota file yet (will be created on first call)' };
261
+ return {
262
+ name: 'quota',
263
+ status: 'ok',
264
+ detail: {
265
+ path: p,
266
+ percentUsed: 0,
267
+ remaining: DAILY_QUOTA,
268
+ message: 'no quota file yet (will be created on first call)',
269
+ },
270
+ };
164
271
  }
165
272
  try {
166
273
  const raw = fs.readFileSync(p, 'utf-8');
167
274
  JSON.parse(raw);
168
- return { name: 'quota', status: 'ok', detail: p };
169
275
  }
170
276
  catch {
171
- return { name: 'quota', status: 'warn', detail: `${p} unreadable/malformed — run 'switchbot quota reset'` };
277
+ return {
278
+ name: 'quota',
279
+ status: 'warn',
280
+ detail: { path: p, message: `unreadable/malformed — run 'switchbot quota reset'` },
281
+ };
282
+ }
283
+ // P9: surface headroom so agents can decide when to slow down or pause.
284
+ // Quota resets at local midnight (the quota counter buckets by local
285
+ // date), so project the next reset to the next 00:00:00 local.
286
+ const usage = todayUsage();
287
+ const percentUsed = Math.round((usage.total / DAILY_QUOTA) * 100);
288
+ const now = new Date();
289
+ const reset = new Date(now);
290
+ reset.setHours(24, 0, 0, 0); // next local midnight
291
+ const status = percentUsed > 80 ? 'warn' : 'ok';
292
+ const recommendation = percentUsed > 90
293
+ ? 'over 90% used — consider --no-quota for read-only triage or rescheduling work after the reset'
294
+ : percentUsed > 80
295
+ ? 'over 80% used — avoid bulk operations until the daily reset'
296
+ : 'headroom available';
297
+ return {
298
+ name: 'quota',
299
+ status,
300
+ detail: {
301
+ path: p,
302
+ percentUsed,
303
+ remaining: usage.remaining,
304
+ total: usage.total,
305
+ dailyCap: DAILY_QUOTA,
306
+ projectedResetTime: reset.toISOString(),
307
+ recommendation,
308
+ },
309
+ };
310
+ }
311
+ function checkCatalogSchema() {
312
+ // P9: sentinel against silent drift between the catalog shape and the
313
+ // agent-bootstrap payload. Both constants are exported from their
314
+ // respective modules; if a future refactor changes one without the
315
+ // other, this check fails so consumers (agents) learn before the
316
+ // mismatch corrupts their mental model.
317
+ const match = CATALOG_SCHEMA_VERSION === AGENT_BOOTSTRAP_SCHEMA_VERSION;
318
+ return {
319
+ name: 'catalog-schema',
320
+ status: match ? 'ok' : 'fail',
321
+ detail: {
322
+ catalogSchemaVersion: CATALOG_SCHEMA_VERSION,
323
+ bootstrapExpectsVersion: AGENT_BOOTSTRAP_SCHEMA_VERSION,
324
+ match,
325
+ message: match
326
+ ? 'catalog and agent-bootstrap schemaVersion aligned'
327
+ : 'catalog and agent-bootstrap schemaVersion have drifted — bump in lockstep',
328
+ },
329
+ };
330
+ }
331
+ function checkAudit() {
332
+ // P9: surface recent command failures so agents / ops can spot problems
333
+ // before they page. When --audit-log was never enabled, the file won't
334
+ // exist — report that cleanly rather than as an error.
335
+ const p = path.join(os.homedir(), '.switchbot', 'audit.log');
336
+ if (!fs.existsSync(p)) {
337
+ return {
338
+ name: 'audit',
339
+ status: 'ok',
340
+ detail: {
341
+ path: p,
342
+ enabled: false,
343
+ message: 'audit log not present (enable with --audit-log)',
344
+ },
345
+ };
346
+ }
347
+ try {
348
+ const raw = fs.readFileSync(p, 'utf-8');
349
+ const since = Date.now() - 24 * 60 * 60 * 1000;
350
+ const recent = [];
351
+ let total = 0;
352
+ for (const line of raw.split('\n')) {
353
+ const trimmed = line.trim();
354
+ if (!trimmed)
355
+ continue;
356
+ let rec;
357
+ try {
358
+ rec = JSON.parse(trimmed);
359
+ }
360
+ catch {
361
+ continue;
362
+ }
363
+ if (rec.result !== 'error')
364
+ continue;
365
+ total += 1;
366
+ const ts = rec.t ? Date.parse(rec.t) : NaN;
367
+ if (Number.isFinite(ts) && ts >= since) {
368
+ recent.push({
369
+ t: rec.t,
370
+ command: rec.command ?? '?',
371
+ deviceId: rec.deviceId,
372
+ error: rec.error ?? 'unknown',
373
+ });
374
+ }
375
+ }
376
+ // Cap the report to the 10 most recent so the doctor payload stays
377
+ // bounded even on a log with thousands of errors.
378
+ recent.sort((a, b) => (a.t < b.t ? 1 : -1));
379
+ const clipped = recent.slice(0, 10);
380
+ const status = recent.length > 0 ? 'warn' : 'ok';
381
+ return {
382
+ name: 'audit',
383
+ status,
384
+ detail: {
385
+ path: p,
386
+ enabled: true,
387
+ totalErrors: total,
388
+ errorsLast24h: recent.length,
389
+ recent: clipped,
390
+ },
391
+ };
392
+ }
393
+ catch (err) {
394
+ return {
395
+ name: 'audit',
396
+ status: 'warn',
397
+ detail: {
398
+ path: p,
399
+ enabled: true,
400
+ message: `could not read audit log: ${err instanceof Error ? err.message : String(err)}`,
401
+ },
402
+ };
403
+ }
404
+ }
405
+ function checkPolicy() {
406
+ // A policy file is optional — many users run the CLI without one. Report
407
+ // `ok` with `present: false` so agents can tell the difference between
408
+ // "no policy configured" (fine) and "policy broken" (needs attention).
409
+ const policyPath = resolvePolicyPath();
410
+ try {
411
+ const loaded = loadPolicyFile(policyPath);
412
+ const result = validateLoadedPolicy(loaded);
413
+ if (result.valid) {
414
+ return {
415
+ name: 'policy',
416
+ status: 'ok',
417
+ detail: {
418
+ path: policyPath,
419
+ present: true,
420
+ valid: true,
421
+ schemaVersion: result.schemaVersion,
422
+ },
423
+ };
424
+ }
425
+ return {
426
+ name: 'policy',
427
+ status: 'fail',
428
+ detail: {
429
+ path: policyPath,
430
+ present: true,
431
+ valid: false,
432
+ schemaVersion: result.schemaVersion,
433
+ errorCount: result.errors.length,
434
+ firstError: result.errors[0]
435
+ ? {
436
+ path: result.errors[0].path,
437
+ line: result.errors[0].line,
438
+ message: result.errors[0].message,
439
+ }
440
+ : undefined,
441
+ message: "run 'switchbot policy validate' for full diagnostics",
442
+ },
443
+ };
444
+ }
445
+ catch (err) {
446
+ if (err instanceof PolicyFileNotFoundError) {
447
+ return {
448
+ name: 'policy',
449
+ status: 'ok',
450
+ detail: {
451
+ path: policyPath,
452
+ present: false,
453
+ message: "no policy file (optional — run 'switchbot policy new' to scaffold one)",
454
+ },
455
+ };
456
+ }
457
+ if (err instanceof PolicyYamlParseError) {
458
+ const first = err.yamlErrors[0];
459
+ return {
460
+ name: 'policy',
461
+ status: 'fail',
462
+ detail: {
463
+ path: policyPath,
464
+ present: true,
465
+ valid: false,
466
+ parseError: true,
467
+ line: first?.line,
468
+ col: first?.col,
469
+ message: first?.message ?? err.message,
470
+ },
471
+ };
472
+ }
473
+ return {
474
+ name: 'policy',
475
+ status: 'warn',
476
+ detail: {
477
+ path: policyPath,
478
+ message: `could not read policy file: ${err instanceof Error ? err.message : String(err)}`,
479
+ },
480
+ };
172
481
  }
173
482
  }
174
483
  function checkNodeVersion() {
@@ -210,10 +519,161 @@ function checkMqtt() {
210
519
  detail: "unavailable — configure credentials first (see credentials check above)",
211
520
  };
212
521
  }
522
+ async function checkMqttProbe() {
523
+ // P10: live-probe the MQTT broker. Only runs when --probe is passed.
524
+ // Does not subscribe — just connects + disconnects to verify the
525
+ // credential + TLS handshake works end-to-end. Hard 5s timeout so
526
+ // a misbehaving broker never wedges the doctor command.
527
+ const { fetchMqttCredential } = await import('../mqtt/credential.js');
528
+ const { SwitchBotMqttClient } = await import('../mqtt/client.js');
529
+ const token = process.env.SWITCHBOT_TOKEN;
530
+ const secret = process.env.SWITCHBOT_SECRET;
531
+ let creds = null;
532
+ if (token && secret) {
533
+ creds = { token, secret };
534
+ }
535
+ else {
536
+ const file = configFilePath();
537
+ if (fs.existsSync(file)) {
538
+ try {
539
+ const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
540
+ if (cfg.token && cfg.secret) {
541
+ creds = { token: cfg.token, secret: cfg.secret };
542
+ }
543
+ }
544
+ catch { /* fall through */ }
545
+ }
546
+ }
547
+ if (!creds) {
548
+ return {
549
+ name: 'mqtt',
550
+ status: 'warn',
551
+ detail: { probe: 'skipped', reason: 'no credentials configured' },
552
+ };
553
+ }
554
+ const deadline = new Promise((_, reject) => setTimeout(() => reject(new Error('probe timeout after 5000ms')), 5000));
555
+ try {
556
+ const cred = await Promise.race([fetchMqttCredential(creds.token, creds.secret), deadline]);
557
+ const client = new SwitchBotMqttClient(cred);
558
+ await Promise.race([client.connect(), deadline]);
559
+ await client.disconnect();
560
+ return {
561
+ name: 'mqtt',
562
+ status: 'ok',
563
+ detail: { probe: 'connected', brokerUrl: cred.brokerUrl, region: cred.region },
564
+ };
565
+ }
566
+ catch (err) {
567
+ return {
568
+ name: 'mqtt',
569
+ status: 'warn',
570
+ detail: { probe: 'failed', reason: err instanceof Error ? err.message : String(err) },
571
+ };
572
+ }
573
+ }
574
+ function checkMcp() {
575
+ // P10: dry-run instantiation of the MCP server to catch tool-registration
576
+ // regressions. No network I/O, no token needed. If createSwitchBotMcpServer
577
+ // throws (e.g. duplicate tool name, schema build error) the check fails.
578
+ try {
579
+ const server = createSwitchBotMcpServer();
580
+ const tools = listRegisteredTools(server);
581
+ return {
582
+ name: 'mcp',
583
+ status: 'ok',
584
+ detail: {
585
+ serverInstantiated: true,
586
+ toolCount: tools.length,
587
+ tools,
588
+ transportsAvailable: ['stdio', 'http'],
589
+ message: `${tools.length} tools registered; no network probe`,
590
+ },
591
+ };
592
+ }
593
+ catch (err) {
594
+ return {
595
+ name: 'mcp',
596
+ status: 'fail',
597
+ detail: {
598
+ serverInstantiated: false,
599
+ error: err instanceof Error ? err.message : String(err),
600
+ },
601
+ };
602
+ }
603
+ }
604
+ const CHECK_REGISTRY = [
605
+ { name: 'node', description: 'Node.js version compatibility', run: () => checkNodeVersion() },
606
+ { name: 'credentials', description: 'credentials file present and parseable', run: () => checkCredentials() },
607
+ { name: 'profiles', description: 'profile definitions valid', run: () => checkProfiles() },
608
+ { name: 'catalog', description: 'catalog loads', run: () => checkCatalog() },
609
+ { name: 'catalog-schema', description: 'catalog vs agent-bootstrap version aligned', run: () => checkCatalogSchema() },
610
+ { name: 'cache', description: 'device cache state', run: () => checkCache() },
611
+ { name: 'quota', description: 'API quota headroom', run: () => checkQuotaFile() },
612
+ { name: 'clock', description: 'system clock skew', run: () => checkClockSkew() },
613
+ {
614
+ name: 'mqtt',
615
+ description: 'MQTT credentials (+ --probe for live broker handshake)',
616
+ run: ({ probe }) => (probe ? checkMqttProbe() : checkMqtt()),
617
+ },
618
+ { name: 'mcp', description: 'MCP server instantiable + tool count', run: () => checkMcp() },
619
+ { name: 'policy', description: 'policy.yaml present + schema-valid (if configured)', run: () => checkPolicy() },
620
+ { name: 'audit', description: 'recent command errors (last 24h)', run: () => checkAudit() },
621
+ ];
622
+ function applyFixes(checks, writeOk) {
623
+ const results = [];
624
+ for (const c of checks) {
625
+ if (c.name === 'cache' && c.status !== 'ok') {
626
+ if (writeOk) {
627
+ try {
628
+ resetListCache();
629
+ results.push({ check: 'cache', action: 'cache-cleared', applied: true });
630
+ }
631
+ catch (err) {
632
+ results.push({
633
+ check: 'cache',
634
+ action: 'cache-clear',
635
+ applied: false,
636
+ message: err instanceof Error ? err.message : String(err),
637
+ });
638
+ }
639
+ }
640
+ else {
641
+ results.push({
642
+ check: 'cache',
643
+ action: 'cache-clear',
644
+ applied: false,
645
+ message: 'pass --yes to apply',
646
+ });
647
+ }
648
+ }
649
+ else if (c.name === 'catalog-schema' && c.status !== 'ok') {
650
+ results.push({
651
+ check: 'catalog-schema',
652
+ action: 'manual',
653
+ applied: false,
654
+ message: "drift detected — run 'switchbot capabilities --reload' to refresh overlay",
655
+ });
656
+ }
657
+ else if (c.name === 'credentials' && c.status === 'fail') {
658
+ results.push({
659
+ check: 'credentials',
660
+ action: 'manual',
661
+ applied: false,
662
+ message: "run 'switchbot config set-token' to configure credentials",
663
+ });
664
+ }
665
+ }
666
+ return results;
667
+ }
213
668
  export function registerDoctorCommand(program) {
214
669
  program
215
670
  .command('doctor')
216
- .description('Self-check: credentials, catalog, cache, quota, profiles, Node version')
671
+ .description('Self-check the SwitchBot CLI setup: credentials, catalog, cache, quota, MQTT, MCP')
672
+ .option('--section <names>', 'Comma-separated list of checks to run (see --list for names)')
673
+ .option('--list', 'Print the registered check names and exit 0 without running any check')
674
+ .option('--fix', 'Apply safe, reversible remediations for failing checks (e.g. clear stale cache)')
675
+ .option('--yes', 'Required together with --fix to confirm write actions')
676
+ .option('--probe', 'Perform live-probe variant of checks that support it (mqtt)')
217
677
  .addHelpText('after', `
218
678
  Runs a battery of local sanity checks and exits with code 0 only when every
219
679
  check is 'ok'. 'warn' → exit 0 (informational); 'fail' → exit 1.
@@ -221,18 +681,52 @@ check is 'ok'. 'warn' → exit 0 (informational); 'fail' → exit 1.
221
681
  Examples:
222
682
  $ switchbot doctor
223
683
  $ switchbot --json doctor | jq '.checks[] | select(.status != "ok")'
684
+ $ switchbot doctor --list
685
+ $ switchbot doctor --section credentials,mcp --json
686
+ $ switchbot doctor --probe --json
687
+ $ switchbot doctor --fix --yes --json
224
688
  `)
225
- .action(async () => {
226
- const checks = [
227
- checkNodeVersion(),
228
- await checkCredentials(),
229
- checkProfiles(),
230
- checkCatalog(),
231
- checkCache(),
232
- checkQuotaFile(),
233
- await checkClockSkew(),
234
- checkMqtt(),
235
- ];
689
+ .action(async (opts) => {
690
+ // --list: print the registry and exit 0.
691
+ if (opts.list) {
692
+ if (isJsonMode()) {
693
+ printJson({
694
+ checks: CHECK_REGISTRY.map((c) => ({ name: c.name, description: c.description })),
695
+ });
696
+ }
697
+ else {
698
+ console.log('Available checks:');
699
+ for (const c of CHECK_REGISTRY) {
700
+ console.log(` ${c.name.padEnd(16)} ${c.description}`);
701
+ }
702
+ }
703
+ return;
704
+ }
705
+ // --section: run only the named subset, dedup and validate.
706
+ let selected = CHECK_REGISTRY;
707
+ if (opts.section) {
708
+ const raw = opts.section.split(',').map((s) => s.trim()).filter(Boolean);
709
+ const names = Array.from(new Set(raw));
710
+ const known = new Set(CHECK_REGISTRY.map((c) => c.name));
711
+ const unknown = names.filter((n) => !known.has(n));
712
+ if (unknown.length > 0) {
713
+ exitWithError({
714
+ code: 2,
715
+ kind: 'usage',
716
+ message: `Unknown check name(s): ${unknown.join(', ')}. Valid: ${CHECK_REGISTRY.map((c) => c.name).join(', ')}`,
717
+ });
718
+ return;
719
+ }
720
+ const order = new Map(CHECK_REGISTRY.map((c, i) => [c.name, i]));
721
+ selected = names
722
+ .map((n) => CHECK_REGISTRY.find((c) => c.name === n))
723
+ .sort((a, b) => (order.get(a.name) - order.get(b.name)));
724
+ }
725
+ const runOpts = { probe: Boolean(opts.probe) };
726
+ const checks = [];
727
+ for (const def of selected) {
728
+ checks.push(await def.run(runOpts));
729
+ }
236
730
  const summary = {
237
731
  ok: checks.filter((c) => c.status === 'ok').length,
238
732
  warn: checks.filter((c) => c.status === 'warn').length,
@@ -240,29 +734,48 @@ Examples:
240
734
  };
241
735
  const overallFail = summary.fail > 0;
242
736
  const overall = overallFail ? 'fail' : summary.warn > 0 ? 'warn' : 'ok';
737
+ let fixes;
738
+ if (opts.fix) {
739
+ fixes = applyFixes(checks, Boolean(opts.yes));
740
+ }
243
741
  if (isJsonMode()) {
244
742
  // Stable contract (locked as doctor.schemaVersion=1):
245
743
  // { ok: boolean, overall: 'ok'|'warn'|'fail', generatedAt, schemaVersion,
246
744
  // summary: { ok, warn, fail }, checks: [{ name, status, detail }] }
247
745
  // `ok` is an alias of (overall === 'ok') — agents prefer the boolean,
248
746
  // humans prefer the string; both are provided.
249
- printJson({
747
+ const payload = {
250
748
  ok: overall === 'ok',
251
749
  overall,
252
750
  generatedAt: new Date().toISOString(),
253
751
  schemaVersion: DOCTOR_SCHEMA_VERSION,
254
752
  summary,
255
753
  checks,
256
- });
754
+ };
755
+ if (fixes !== undefined)
756
+ payload.fixes = fixes;
757
+ printJson(payload);
257
758
  }
258
759
  else {
259
760
  for (const c of checks) {
260
761
  const icon = c.status === 'ok' ? '✓' : c.status === 'warn' ? '!' : '✗';
261
- const detailStr = typeof c.detail === 'string' ? c.detail : JSON.stringify(c.detail);
762
+ const detailStr = typeof c.detail === 'string'
763
+ ? c.detail
764
+ : (typeof c.detail.message === 'string'
765
+ ? (c.detail.message)
766
+ : JSON.stringify(c.detail));
262
767
  console.log(`${icon} ${c.name.padEnd(12)} ${detailStr}`);
263
768
  }
264
769
  console.log('');
265
770
  console.log(`${summary.ok} ok, ${summary.warn} warn, ${summary.fail} fail`);
771
+ if (fixes && fixes.length > 0) {
772
+ console.log('');
773
+ console.log('Fixes:');
774
+ for (const f of fixes) {
775
+ const marker = f.applied ? '✓' : '-';
776
+ console.log(` ${marker} ${f.check}: ${f.action}${f.message ? ' — ' + f.message : ''}`);
777
+ }
778
+ }
266
779
  }
267
780
  if (overallFail)
268
781
  process.exit(1);