@switchbot/openapi-cli 3.2.1 → 3.3.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 (332) hide show
  1. package/README.md +3 -1
  2. package/dist/index.js +57419 -170
  3. package/package.json +9 -5
  4. package/dist/api/client.d.ts +0 -31
  5. package/dist/api/client.js +0 -236
  6. package/dist/api/client.js.map +0 -1
  7. package/dist/auth.d.ts +0 -1
  8. package/dist/auth.js +0 -21
  9. package/dist/auth.js.map +0 -1
  10. package/dist/commands/agent-bootstrap.d.ts +0 -10
  11. package/dist/commands/agent-bootstrap.js +0 -200
  12. package/dist/commands/agent-bootstrap.js.map +0 -1
  13. package/dist/commands/auth.d.ts +0 -18
  14. package/dist/commands/auth.js +0 -355
  15. package/dist/commands/auth.js.map +0 -1
  16. package/dist/commands/batch.d.ts +0 -2
  17. package/dist/commands/batch.js +0 -414
  18. package/dist/commands/batch.js.map +0 -1
  19. package/dist/commands/cache.d.ts +0 -2
  20. package/dist/commands/cache.js +0 -127
  21. package/dist/commands/cache.js.map +0 -1
  22. package/dist/commands/capabilities.d.ts +0 -31
  23. package/dist/commands/capabilities.js +0 -383
  24. package/dist/commands/capabilities.js.map +0 -1
  25. package/dist/commands/catalog.d.ts +0 -2
  26. package/dist/commands/catalog.js +0 -360
  27. package/dist/commands/catalog.js.map +0 -1
  28. package/dist/commands/completion.d.ts +0 -2
  29. package/dist/commands/completion.js +0 -386
  30. package/dist/commands/completion.js.map +0 -1
  31. package/dist/commands/config.d.ts +0 -21
  32. package/dist/commands/config.js +0 -377
  33. package/dist/commands/config.js.map +0 -1
  34. package/dist/commands/daemon.d.ts +0 -2
  35. package/dist/commands/daemon.js +0 -411
  36. package/dist/commands/daemon.js.map +0 -1
  37. package/dist/commands/device-meta.d.ts +0 -2
  38. package/dist/commands/device-meta.js +0 -160
  39. package/dist/commands/device-meta.js.map +0 -1
  40. package/dist/commands/devices.d.ts +0 -2
  41. package/dist/commands/devices.js +0 -949
  42. package/dist/commands/devices.js.map +0 -1
  43. package/dist/commands/doctor.d.ts +0 -3
  44. package/dist/commands/doctor.js +0 -1016
  45. package/dist/commands/doctor.js.map +0 -1
  46. package/dist/commands/events.d.ts +0 -31
  47. package/dist/commands/events.js +0 -564
  48. package/dist/commands/events.js.map +0 -1
  49. package/dist/commands/expand.d.ts +0 -2
  50. package/dist/commands/expand.js +0 -131
  51. package/dist/commands/expand.js.map +0 -1
  52. package/dist/commands/explain.d.ts +0 -2
  53. package/dist/commands/explain.js +0 -140
  54. package/dist/commands/explain.js.map +0 -1
  55. package/dist/commands/health.d.ts +0 -8
  56. package/dist/commands/health.js +0 -114
  57. package/dist/commands/health.js.map +0 -1
  58. package/dist/commands/history.d.ts +0 -2
  59. package/dist/commands/history.js +0 -321
  60. package/dist/commands/history.js.map +0 -1
  61. package/dist/commands/identity.d.ts +0 -45
  62. package/dist/commands/identity.js +0 -60
  63. package/dist/commands/identity.js.map +0 -1
  64. package/dist/commands/install.d.ts +0 -20
  65. package/dist/commands/install.js +0 -247
  66. package/dist/commands/install.js.map +0 -1
  67. package/dist/commands/mcp.d.ts +0 -14
  68. package/dist/commands/mcp.js +0 -2018
  69. package/dist/commands/mcp.js.map +0 -1
  70. package/dist/commands/plan.d.ts +0 -51
  71. package/dist/commands/plan.js +0 -654
  72. package/dist/commands/plan.js.map +0 -1
  73. package/dist/commands/policy.d.ts +0 -24
  74. package/dist/commands/policy.js +0 -587
  75. package/dist/commands/policy.js.map +0 -1
  76. package/dist/commands/quota.d.ts +0 -2
  77. package/dist/commands/quota.js +0 -79
  78. package/dist/commands/quota.js.map +0 -1
  79. package/dist/commands/rules.d.ts +0 -2
  80. package/dist/commands/rules.js +0 -876
  81. package/dist/commands/rules.js.map +0 -1
  82. package/dist/commands/scenes.d.ts +0 -2
  83. package/dist/commands/scenes.js +0 -265
  84. package/dist/commands/scenes.js.map +0 -1
  85. package/dist/commands/schema.d.ts +0 -2
  86. package/dist/commands/schema.js +0 -185
  87. package/dist/commands/schema.js.map +0 -1
  88. package/dist/commands/status-sync.d.ts +0 -2
  89. package/dist/commands/status-sync.js +0 -132
  90. package/dist/commands/status-sync.js.map +0 -1
  91. package/dist/commands/uninstall.d.ts +0 -20
  92. package/dist/commands/uninstall.js +0 -238
  93. package/dist/commands/uninstall.js.map +0 -1
  94. package/dist/commands/upgrade-check.d.ts +0 -2
  95. package/dist/commands/upgrade-check.js +0 -107
  96. package/dist/commands/upgrade-check.js.map +0 -1
  97. package/dist/commands/watch.d.ts +0 -2
  98. package/dist/commands/watch.js +0 -195
  99. package/dist/commands/watch.js.map +0 -1
  100. package/dist/commands/webhook.d.ts +0 -2
  101. package/dist/commands/webhook.js +0 -183
  102. package/dist/commands/webhook.js.map +0 -1
  103. package/dist/config.d.ts +0 -57
  104. package/dist/config.js +0 -259
  105. package/dist/config.js.map +0 -1
  106. package/dist/credentials/backends/file.d.ts +0 -18
  107. package/dist/credentials/backends/file.js +0 -102
  108. package/dist/credentials/backends/file.js.map +0 -1
  109. package/dist/credentials/backends/linux.d.ts +0 -16
  110. package/dist/credentials/backends/linux.js +0 -130
  111. package/dist/credentials/backends/linux.js.map +0 -1
  112. package/dist/credentials/backends/macos.d.ts +0 -18
  113. package/dist/credentials/backends/macos.js +0 -130
  114. package/dist/credentials/backends/macos.js.map +0 -1
  115. package/dist/credentials/backends/windows.d.ts +0 -23
  116. package/dist/credentials/backends/windows.js +0 -216
  117. package/dist/credentials/backends/windows.js.map +0 -1
  118. package/dist/credentials/keychain.d.ts +0 -83
  119. package/dist/credentials/keychain.js +0 -89
  120. package/dist/credentials/keychain.js.map +0 -1
  121. package/dist/credentials/prime.d.ts +0 -32
  122. package/dist/credentials/prime.js +0 -53
  123. package/dist/credentials/prime.js.map +0 -1
  124. package/dist/devices/cache.d.ts +0 -79
  125. package/dist/devices/cache.js +0 -294
  126. package/dist/devices/cache.js.map +0 -1
  127. package/dist/devices/catalog.d.ts +0 -138
  128. package/dist/devices/catalog.js +0 -768
  129. package/dist/devices/catalog.js.map +0 -1
  130. package/dist/devices/device-meta.d.ts +0 -15
  131. package/dist/devices/device-meta.js +0 -57
  132. package/dist/devices/device-meta.js.map +0 -1
  133. package/dist/devices/history-agg.d.ts +0 -37
  134. package/dist/devices/history-agg.js +0 -139
  135. package/dist/devices/history-agg.js.map +0 -1
  136. package/dist/devices/history-query.d.ts +0 -45
  137. package/dist/devices/history-query.js +0 -182
  138. package/dist/devices/history-query.js.map +0 -1
  139. package/dist/devices/param-validator.d.ts +0 -40
  140. package/dist/devices/param-validator.js +0 -434
  141. package/dist/devices/param-validator.js.map +0 -1
  142. package/dist/devices/resources.d.ts +0 -74
  143. package/dist/devices/resources.js +0 -271
  144. package/dist/devices/resources.js.map +0 -1
  145. package/dist/index.d.ts +0 -1
  146. package/dist/index.js.map +0 -1
  147. package/dist/install/default-steps.d.ts +0 -66
  148. package/dist/install/default-steps.js +0 -258
  149. package/dist/install/default-steps.js.map +0 -1
  150. package/dist/install/preflight.d.ts +0 -60
  151. package/dist/install/preflight.js +0 -213
  152. package/dist/install/preflight.js.map +0 -1
  153. package/dist/install/steps.d.ts +0 -61
  154. package/dist/install/steps.js +0 -68
  155. package/dist/install/steps.js.map +0 -1
  156. package/dist/lib/command-keywords.d.ts +0 -5
  157. package/dist/lib/command-keywords.js +0 -18
  158. package/dist/lib/command-keywords.js.map +0 -1
  159. package/dist/lib/daemon-state.d.ts +0 -24
  160. package/dist/lib/daemon-state.js +0 -47
  161. package/dist/lib/daemon-state.js.map +0 -1
  162. package/dist/lib/destructive-mode.d.ts +0 -2
  163. package/dist/lib/destructive-mode.js +0 -13
  164. package/dist/lib/destructive-mode.js.map +0 -1
  165. package/dist/lib/devices.d.ts +0 -151
  166. package/dist/lib/devices.js +0 -383
  167. package/dist/lib/devices.js.map +0 -1
  168. package/dist/lib/idempotency.d.ts +0 -46
  169. package/dist/lib/idempotency.js +0 -107
  170. package/dist/lib/idempotency.js.map +0 -1
  171. package/dist/lib/plan-store.d.ts +0 -19
  172. package/dist/lib/plan-store.js +0 -69
  173. package/dist/lib/plan-store.js.map +0 -1
  174. package/dist/lib/request-context.d.ts +0 -7
  175. package/dist/lib/request-context.js +0 -13
  176. package/dist/lib/request-context.js.map +0 -1
  177. package/dist/lib/scenes.d.ts +0 -7
  178. package/dist/lib/scenes.js +0 -11
  179. package/dist/lib/scenes.js.map +0 -1
  180. package/dist/logger.d.ts +0 -4
  181. package/dist/logger.js +0 -17
  182. package/dist/logger.js.map +0 -1
  183. package/dist/mcp/device-history.d.ts +0 -36
  184. package/dist/mcp/device-history.js +0 -146
  185. package/dist/mcp/device-history.js.map +0 -1
  186. package/dist/mcp/events-subscription.d.ts +0 -45
  187. package/dist/mcp/events-subscription.js +0 -214
  188. package/dist/mcp/events-subscription.js.map +0 -1
  189. package/dist/mqtt/client.d.ts +0 -25
  190. package/dist/mqtt/client.js +0 -181
  191. package/dist/mqtt/client.js.map +0 -1
  192. package/dist/mqtt/credential.d.ts +0 -16
  193. package/dist/mqtt/credential.js +0 -31
  194. package/dist/mqtt/credential.js.map +0 -1
  195. package/dist/policy/add-rule.d.ts +0 -21
  196. package/dist/policy/add-rule.js +0 -125
  197. package/dist/policy/add-rule.js.map +0 -1
  198. package/dist/policy/diff.d.ts +0 -21
  199. package/dist/policy/diff.js +0 -92
  200. package/dist/policy/diff.js.map +0 -1
  201. package/dist/policy/format.d.ts +0 -6
  202. package/dist/policy/format.js +0 -58
  203. package/dist/policy/format.js.map +0 -1
  204. package/dist/policy/load.d.ts +0 -32
  205. package/dist/policy/load.js +0 -62
  206. package/dist/policy/load.js.map +0 -1
  207. package/dist/policy/migrate.d.ts +0 -21
  208. package/dist/policy/migrate.js +0 -68
  209. package/dist/policy/migrate.js.map +0 -1
  210. package/dist/policy/schema.d.ts +0 -5
  211. package/dist/policy/schema.js +0 -19
  212. package/dist/policy/schema.js.map +0 -1
  213. package/dist/policy/validate.d.ts +0 -19
  214. package/dist/policy/validate.js +0 -263
  215. package/dist/policy/validate.js.map +0 -1
  216. package/dist/rules/action.d.ts +0 -65
  217. package/dist/rules/action.js +0 -217
  218. package/dist/rules/action.js.map +0 -1
  219. package/dist/rules/audit-query.d.ts +0 -51
  220. package/dist/rules/audit-query.js +0 -90
  221. package/dist/rules/audit-query.js.map +0 -1
  222. package/dist/rules/conflict-analyzer.d.ts +0 -57
  223. package/dist/rules/conflict-analyzer.js +0 -215
  224. package/dist/rules/conflict-analyzer.js.map +0 -1
  225. package/dist/rules/cron-scheduler.d.ts +0 -62
  226. package/dist/rules/cron-scheduler.js +0 -187
  227. package/dist/rules/cron-scheduler.js.map +0 -1
  228. package/dist/rules/destructive.d.ts +0 -20
  229. package/dist/rules/destructive.js +0 -53
  230. package/dist/rules/destructive.js.map +0 -1
  231. package/dist/rules/engine.d.ts +0 -193
  232. package/dist/rules/engine.js +0 -758
  233. package/dist/rules/engine.js.map +0 -1
  234. package/dist/rules/matcher.d.ts +0 -56
  235. package/dist/rules/matcher.js +0 -231
  236. package/dist/rules/matcher.js.map +0 -1
  237. package/dist/rules/pid-file.d.ts +0 -43
  238. package/dist/rules/pid-file.js +0 -96
  239. package/dist/rules/pid-file.js.map +0 -1
  240. package/dist/rules/quiet-hours.d.ts +0 -26
  241. package/dist/rules/quiet-hours.js +0 -46
  242. package/dist/rules/quiet-hours.js.map +0 -1
  243. package/dist/rules/suggest.d.ts +0 -20
  244. package/dist/rules/suggest.js +0 -96
  245. package/dist/rules/suggest.js.map +0 -1
  246. package/dist/rules/throttle.d.ts +0 -61
  247. package/dist/rules/throttle.js +0 -117
  248. package/dist/rules/throttle.js.map +0 -1
  249. package/dist/rules/types.d.ts +0 -117
  250. package/dist/rules/types.js +0 -35
  251. package/dist/rules/types.js.map +0 -1
  252. package/dist/rules/webhook-listener.d.ts +0 -63
  253. package/dist/rules/webhook-listener.js +0 -224
  254. package/dist/rules/webhook-listener.js.map +0 -1
  255. package/dist/rules/webhook-token.d.ts +0 -50
  256. package/dist/rules/webhook-token.js +0 -91
  257. package/dist/rules/webhook-token.js.map +0 -1
  258. package/dist/schema/field-aliases.d.ts +0 -34
  259. package/dist/schema/field-aliases.js +0 -132
  260. package/dist/schema/field-aliases.js.map +0 -1
  261. package/dist/sinks/dispatcher.d.ts +0 -7
  262. package/dist/sinks/dispatcher.js +0 -13
  263. package/dist/sinks/dispatcher.js.map +0 -1
  264. package/dist/sinks/file.d.ts +0 -6
  265. package/dist/sinks/file.js +0 -20
  266. package/dist/sinks/file.js.map +0 -1
  267. package/dist/sinks/format.d.ts +0 -20
  268. package/dist/sinks/format.js +0 -57
  269. package/dist/sinks/format.js.map +0 -1
  270. package/dist/sinks/homeassistant.d.ts +0 -18
  271. package/dist/sinks/homeassistant.js +0 -45
  272. package/dist/sinks/homeassistant.js.map +0 -1
  273. package/dist/sinks/openclaw.d.ts +0 -13
  274. package/dist/sinks/openclaw.js +0 -34
  275. package/dist/sinks/openclaw.js.map +0 -1
  276. package/dist/sinks/stdout.d.ts +0 -4
  277. package/dist/sinks/stdout.js +0 -6
  278. package/dist/sinks/stdout.js.map +0 -1
  279. package/dist/sinks/telegram.d.ts +0 -11
  280. package/dist/sinks/telegram.js +0 -29
  281. package/dist/sinks/telegram.js.map +0 -1
  282. package/dist/sinks/types.d.ts +0 -13
  283. package/dist/sinks/types.js +0 -2
  284. package/dist/sinks/types.js.map +0 -1
  285. package/dist/sinks/webhook.d.ts +0 -6
  286. package/dist/sinks/webhook.js +0 -23
  287. package/dist/sinks/webhook.js.map +0 -1
  288. package/dist/status-sync/manager.d.ts +0 -48
  289. package/dist/status-sync/manager.js +0 -269
  290. package/dist/status-sync/manager.js.map +0 -1
  291. package/dist/utils/arg-parsers.d.ts +0 -16
  292. package/dist/utils/arg-parsers.js +0 -67
  293. package/dist/utils/arg-parsers.js.map +0 -1
  294. package/dist/utils/audit.d.ts +0 -69
  295. package/dist/utils/audit.js +0 -122
  296. package/dist/utils/audit.js.map +0 -1
  297. package/dist/utils/filter.d.ts +0 -81
  298. package/dist/utils/filter.js +0 -190
  299. package/dist/utils/filter.js.map +0 -1
  300. package/dist/utils/flags.d.ts +0 -72
  301. package/dist/utils/flags.js +0 -187
  302. package/dist/utils/flags.js.map +0 -1
  303. package/dist/utils/format.d.ts +0 -9
  304. package/dist/utils/format.js +0 -118
  305. package/dist/utils/format.js.map +0 -1
  306. package/dist/utils/health.d.ts +0 -48
  307. package/dist/utils/health.js +0 -102
  308. package/dist/utils/health.js.map +0 -1
  309. package/dist/utils/help-json.d.ts +0 -39
  310. package/dist/utils/help-json.js +0 -55
  311. package/dist/utils/help-json.js.map +0 -1
  312. package/dist/utils/name-resolver.d.ts +0 -26
  313. package/dist/utils/name-resolver.js +0 -138
  314. package/dist/utils/name-resolver.js.map +0 -1
  315. package/dist/utils/output.d.ts +0 -73
  316. package/dist/utils/output.js +0 -405
  317. package/dist/utils/output.js.map +0 -1
  318. package/dist/utils/quota.d.ts +0 -61
  319. package/dist/utils/quota.js +0 -228
  320. package/dist/utils/quota.js.map +0 -1
  321. package/dist/utils/redact.d.ts +0 -23
  322. package/dist/utils/redact.js +0 -69
  323. package/dist/utils/redact.js.map +0 -1
  324. package/dist/utils/retry.d.ts +0 -72
  325. package/dist/utils/retry.js +0 -141
  326. package/dist/utils/retry.js.map +0 -1
  327. package/dist/utils/string.d.ts +0 -2
  328. package/dist/utils/string.js +0 -23
  329. package/dist/utils/string.js.map +0 -1
  330. package/dist/version.d.ts +0 -2
  331. package/dist/version.js +0 -5
  332. package/dist/version.js.map +0 -1
@@ -1,1016 +0,0 @@
1
- import fs from 'node:fs';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
- import { execSync } from 'node:child_process';
5
- import { printJson, isJsonMode, exitWithError } from '../utils/output.js';
6
- import { getEffectiveCatalog } from '../devices/catalog.js';
7
- import { configFilePath, listProfiles, readProfileMeta } from '../config.js';
8
- import { describeCache, resetListCache } from '../devices/cache.js';
9
- import { DAILY_QUOTA, todayUsage } from '../utils/quota.js';
10
- import { AGENT_BOOTSTRAP_SCHEMA_VERSION } from './agent-bootstrap.js';
11
- import { CATALOG_SCHEMA_VERSION } from '../devices/catalog.js';
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';
19
- export const DOCTOR_SCHEMA_VERSION = 1;
20
- async function checkCredentials() {
21
- const envOk = Boolean(process.env.SWITCHBOT_TOKEN && process.env.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
- }
73
- const file = configFilePath();
74
- if (!fs.existsSync(file)) {
75
- return {
76
- name: 'credentials',
77
- status: 'fail',
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
- },
86
- };
87
- }
88
- try {
89
- const raw = fs.readFileSync(file, 'utf-8');
90
- const cfg = JSON.parse(raw);
91
- if (!cfg.token || !cfg.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
- };
104
- }
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
- };
122
- }
123
- catch (err) {
124
- return {
125
- name: 'credentials',
126
- status: 'fail',
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
- },
135
- };
136
- }
137
- }
138
- function checkProfiles() {
139
- const dir = path.join(os.homedir(), '.switchbot', 'profiles');
140
- if (!fs.existsSync(dir)) {
141
- return { name: 'profiles', status: 'ok', detail: 'no profile dir (default profile only)' };
142
- }
143
- const profiles = listProfiles();
144
- if (profiles.length === 0) {
145
- return { name: 'profiles', status: 'ok', detail: 'profile dir empty' };
146
- }
147
- const labelled = profiles.map((p) => {
148
- const meta = readProfileMeta(p);
149
- if (meta?.label)
150
- return `${p} (${meta.label})`;
151
- return p;
152
- });
153
- return {
154
- name: 'profiles',
155
- status: 'ok',
156
- detail: `found ${profiles.length}: ${labelled.join(', ')}`,
157
- };
158
- }
159
- async function checkClockSkew() {
160
- // Real probe: HEAD the SwitchBot API endpoint and compare the server's Date
161
- // header against local time. No auth required for the Date header — the API
162
- // returns 401 but still stamps the response. Gracefully degrades to
163
- // probeSource:'none' if offline / no network reachable.
164
- //
165
- // Under vitest, only run the probe if fetch has been stubbed (detected via
166
- // vi.fn marker) — otherwise skip network I/O to keep unrelated tests fast.
167
- const underVitest = Boolean(process.env.VITEST);
168
- const fetchFn = globalThis.fetch;
169
- const fetchIsMocked = Boolean(fetchFn && typeof fetchFn === 'function' && 'mock' in fetchFn);
170
- if (underVitest && !fetchIsMocked) {
171
- return {
172
- name: 'clock',
173
- status: 'warn',
174
- detail: { probeSource: 'none', skewMs: null, message: 'skipped: test environment' },
175
- };
176
- }
177
- const localBefore = Date.now();
178
- const ctrl = new AbortController();
179
- const timer = setTimeout(() => ctrl.abort(), 2500);
180
- try {
181
- const res = await fetch('https://api.switch-bot.com/v1.1/devices', {
182
- method: 'HEAD',
183
- signal: ctrl.signal,
184
- });
185
- const localAfter = Date.now();
186
- const dateHeader = res.headers.get('date');
187
- if (!dateHeader) {
188
- return {
189
- name: 'clock',
190
- status: 'warn',
191
- detail: { probeSource: 'api', skewMs: null, message: 'server returned no Date header' },
192
- };
193
- }
194
- const serverMs = Date.parse(dateHeader);
195
- if (!Number.isFinite(serverMs)) {
196
- return {
197
- name: 'clock',
198
- status: 'warn',
199
- detail: { probeSource: 'api', skewMs: null, message: `unparseable Date header: ${dateHeader}` },
200
- };
201
- }
202
- // Split the round-trip in half to estimate the local instant that matches
203
- // the server's Date header. HTTP Date resolution is 1s, so treat anything
204
- // under 2000ms as ok, 2000–60000ms as warn, beyond that as fail (HMAC
205
- // auth rejects requests with skew > 5 minutes anyway).
206
- const midpoint = (localBefore + localAfter) / 2;
207
- const skewMs = Math.round(midpoint - serverMs);
208
- const absSkew = Math.abs(skewMs);
209
- const status = absSkew < 2000 ? 'ok' : absSkew < 60_000 ? 'warn' : 'fail';
210
- return {
211
- name: 'clock',
212
- status,
213
- detail: {
214
- probeSource: 'api',
215
- skewMs,
216
- localIso: new Date(midpoint).toISOString(),
217
- serverIso: new Date(serverMs).toISOString(),
218
- },
219
- };
220
- }
221
- catch (err) {
222
- return {
223
- name: 'clock',
224
- status: 'warn',
225
- detail: {
226
- probeSource: 'none',
227
- skewMs: null,
228
- message: `probe failed: ${err instanceof Error ? err.message : String(err)}`,
229
- },
230
- };
231
- }
232
- finally {
233
- clearTimeout(timer);
234
- }
235
- }
236
- function checkCatalog() {
237
- const catalog = getEffectiveCatalog();
238
- const missingRole = catalog.filter((e) => !e.role).length;
239
- if (catalog.length === 0) {
240
- return { name: 'catalog', status: 'fail', detail: 'catalog empty — package corrupt?' };
241
- }
242
- const status = missingRole > 0 ? 'warn' : 'ok';
243
- return {
244
- name: 'catalog',
245
- status,
246
- detail: `${catalog.length} types loaded${missingRole > 0 ? `, ${missingRole} missing role` : ''}`,
247
- };
248
- }
249
- function checkCache() {
250
- try {
251
- const info = describeCache();
252
- const parts = [];
253
- parts.push(info.list.exists ? `list: ${info.list.path}` : 'list: (none)');
254
- parts.push(info.status.exists ? `status: ${info.status.entryCount} entries` : 'status: (none)');
255
- return { name: 'cache', status: 'ok', detail: parts.join(' | ') };
256
- }
257
- catch (err) {
258
- return { name: 'cache', status: 'warn', detail: `cache inspect failed: ${err instanceof Error ? err.message : String(err)}` };
259
- }
260
- }
261
- function checkQuotaFile() {
262
- const p = path.join(os.homedir(), '.switchbot', 'quota.json');
263
- if (!fs.existsSync(p)) {
264
- return {
265
- name: 'quota',
266
- status: 'ok',
267
- detail: {
268
- path: p,
269
- percentUsed: 0,
270
- remaining: DAILY_QUOTA,
271
- message: 'no quota file yet (will be created on first call)',
272
- },
273
- };
274
- }
275
- try {
276
- const raw = fs.readFileSync(p, 'utf-8');
277
- JSON.parse(raw);
278
- }
279
- catch {
280
- return {
281
- name: 'quota',
282
- status: 'warn',
283
- detail: { path: p, message: `unreadable/malformed — run 'switchbot quota reset'` },
284
- };
285
- }
286
- // P9: surface headroom so agents can decide when to slow down or pause.
287
- // Quota resets at local midnight (the quota counter buckets by local
288
- // date), so project the next reset to the next 00:00:00 local.
289
- const usage = todayUsage();
290
- const percentUsed = Math.round((usage.total / DAILY_QUOTA) * 100);
291
- const now = new Date();
292
- const reset = new Date(now);
293
- reset.setHours(24, 0, 0, 0); // next local midnight
294
- const status = percentUsed > 80 ? 'warn' : 'ok';
295
- const recommendation = percentUsed > 90
296
- ? 'over 90% used — consider --no-quota for read-only triage or rescheduling work after the reset'
297
- : percentUsed > 80
298
- ? 'over 80% used — avoid bulk operations until the daily reset'
299
- : 'headroom available';
300
- return {
301
- name: 'quota',
302
- status,
303
- detail: {
304
- path: p,
305
- percentUsed,
306
- remaining: usage.remaining,
307
- total: usage.total,
308
- dailyCap: DAILY_QUOTA,
309
- projectedResetTime: reset.toISOString(),
310
- recommendation,
311
- },
312
- };
313
- }
314
- function checkCatalogSchema() {
315
- // P9: sentinel against silent drift between the catalog shape and the
316
- // agent-bootstrap payload. Both constants are exported from their
317
- // respective modules; if a future refactor changes one without the
318
- // other, this check fails so consumers (agents) learn before the
319
- // mismatch corrupts their mental model.
320
- const match = CATALOG_SCHEMA_VERSION === AGENT_BOOTSTRAP_SCHEMA_VERSION;
321
- return {
322
- name: 'catalog-schema',
323
- status: match ? 'ok' : 'fail',
324
- detail: {
325
- catalogSchemaVersion: CATALOG_SCHEMA_VERSION,
326
- bootstrapExpectsVersion: AGENT_BOOTSTRAP_SCHEMA_VERSION,
327
- match,
328
- message: match
329
- ? 'catalog and agent-bootstrap schemaVersion aligned'
330
- : 'catalog and agent-bootstrap schemaVersion have drifted — bump in lockstep',
331
- },
332
- };
333
- }
334
- function checkAudit() {
335
- // P9: surface recent command failures so agents / ops can spot problems
336
- // before they page. When --audit-log was never enabled, the file won't
337
- // exist — report that cleanly rather than as an error.
338
- const p = path.join(os.homedir(), '.switchbot', 'audit.log');
339
- if (!fs.existsSync(p)) {
340
- return {
341
- name: 'audit',
342
- status: 'ok',
343
- detail: {
344
- path: p,
345
- enabled: false,
346
- message: 'audit log not present (enable with --audit-log)',
347
- },
348
- };
349
- }
350
- try {
351
- const raw = fs.readFileSync(p, 'utf-8');
352
- const since = Date.now() - 24 * 60 * 60 * 1000;
353
- const recent = [];
354
- let total = 0;
355
- for (const line of raw.split('\n')) {
356
- const trimmed = line.trim();
357
- if (!trimmed)
358
- continue;
359
- let rec;
360
- try {
361
- rec = JSON.parse(trimmed);
362
- }
363
- catch {
364
- continue;
365
- }
366
- if (rec.result !== 'error')
367
- continue;
368
- total += 1;
369
- const ts = rec.t ? Date.parse(rec.t) : NaN;
370
- if (Number.isFinite(ts) && ts >= since) {
371
- recent.push({
372
- t: rec.t,
373
- command: rec.command ?? '?',
374
- deviceId: rec.deviceId,
375
- error: rec.error ?? 'unknown',
376
- });
377
- }
378
- }
379
- // Cap the report to the 10 most recent so the doctor payload stays
380
- // bounded even on a log with thousands of errors.
381
- recent.sort((a, b) => (a.t < b.t ? 1 : -1));
382
- const clipped = recent.slice(0, 10);
383
- const status = recent.length > 0 ? 'warn' : 'ok';
384
- return {
385
- name: 'audit',
386
- status,
387
- detail: {
388
- path: p,
389
- enabled: true,
390
- totalErrors: total,
391
- errorsLast24h: recent.length,
392
- recent: clipped,
393
- },
394
- };
395
- }
396
- catch (err) {
397
- return {
398
- name: 'audit',
399
- status: 'warn',
400
- detail: {
401
- path: p,
402
- enabled: true,
403
- message: `could not read audit log: ${err instanceof Error ? err.message : String(err)}`,
404
- },
405
- };
406
- }
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
- }
526
- function checkNodeVersion() {
527
- const major = Number(process.versions.node.split('.')[0]);
528
- if (Number.isFinite(major) && major < 18) {
529
- return { name: 'node', status: 'fail', detail: `Node ${process.versions.node} — minimum is 18` };
530
- }
531
- return { name: 'node', status: 'ok', detail: `Node ${process.versions.node}` };
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
- }
708
- function checkMqtt() {
709
- // MQTT credentials are auto-provisioned from the SwitchBot API using the
710
- // account's token+secret — no extra env vars needed. Report availability
711
- // based on whether REST credentials are configured (no network call).
712
- const hasEnvCreds = Boolean(process.env.SWITCHBOT_TOKEN && process.env.SWITCHBOT_SECRET);
713
- if (hasEnvCreds) {
714
- return {
715
- name: 'mqtt',
716
- status: 'ok',
717
- detail: "auto-provisioned from credentials — run 'switchbot events mqtt-tail' to test live connectivity",
718
- };
719
- }
720
- const file = configFilePath();
721
- if (fs.existsSync(file)) {
722
- try {
723
- const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
724
- if (cfg.token && cfg.secret) {
725
- return {
726
- name: 'mqtt',
727
- status: 'ok',
728
- detail: "auto-provisioned from credentials — run 'switchbot events mqtt-tail' to test live connectivity",
729
- };
730
- }
731
- }
732
- catch { /* fall through */ }
733
- }
734
- return {
735
- name: 'mqtt',
736
- status: 'warn',
737
- detail: "unavailable — configure credentials first (see credentials check above)",
738
- };
739
- }
740
- async function checkMqttProbe() {
741
- // P10: live-probe the MQTT broker. Only runs when --probe is passed.
742
- // Does not subscribe — just connects + disconnects to verify the
743
- // credential + TLS handshake works end-to-end. Hard 5s timeout so
744
- // a misbehaving broker never wedges the doctor command.
745
- const { fetchMqttCredential } = await import('../mqtt/credential.js');
746
- const { SwitchBotMqttClient } = await import('../mqtt/client.js');
747
- const token = process.env.SWITCHBOT_TOKEN;
748
- const secret = process.env.SWITCHBOT_SECRET;
749
- let creds = null;
750
- if (token && secret) {
751
- creds = { token, secret };
752
- }
753
- else {
754
- const file = configFilePath();
755
- if (fs.existsSync(file)) {
756
- try {
757
- const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
758
- if (cfg.token && cfg.secret) {
759
- creds = { token: cfg.token, secret: cfg.secret };
760
- }
761
- }
762
- catch { /* fall through */ }
763
- }
764
- }
765
- if (!creds) {
766
- return {
767
- name: 'mqtt',
768
- status: 'warn',
769
- detail: { probe: 'skipped', reason: 'no credentials configured' },
770
- };
771
- }
772
- const deadline = new Promise((_, reject) => setTimeout(() => reject(new Error('probe timeout after 5000ms')), 5000));
773
- try {
774
- const cred = await Promise.race([fetchMqttCredential(creds.token, creds.secret), deadline]);
775
- const client = new SwitchBotMqttClient(cred);
776
- await Promise.race([client.connect(), deadline]);
777
- await client.disconnect();
778
- return {
779
- name: 'mqtt',
780
- status: 'ok',
781
- detail: { probe: 'connected', brokerUrl: cred.brokerUrl, region: cred.region },
782
- };
783
- }
784
- catch (err) {
785
- return {
786
- name: 'mqtt',
787
- status: 'warn',
788
- detail: { probe: 'failed', reason: err instanceof Error ? err.message : String(err) },
789
- };
790
- }
791
- }
792
- function checkMcp() {
793
- // P10: dry-run instantiation of the MCP server to catch tool-registration
794
- // regressions. No network I/O, no token needed. If createSwitchBotMcpServer
795
- // throws (e.g. duplicate tool name, schema build error) the check fails.
796
- try {
797
- const server = createSwitchBotMcpServer();
798
- const tools = listRegisteredTools(server);
799
- return {
800
- name: 'mcp',
801
- status: 'ok',
802
- detail: {
803
- serverInstantiated: true,
804
- toolCount: tools.length,
805
- tools,
806
- transportsAvailable: ['stdio', 'http'],
807
- message: `${tools.length} tools registered; no network probe`,
808
- },
809
- };
810
- }
811
- catch (err) {
812
- return {
813
- name: 'mcp',
814
- status: 'fail',
815
- detail: {
816
- serverInstantiated: false,
817
- error: err instanceof Error ? err.message : String(err),
818
- },
819
- };
820
- }
821
- }
822
- const CHECK_REGISTRY = [
823
- { name: 'node', description: 'Node.js version compatibility', run: () => checkNodeVersion() },
824
- { name: 'path', description: 'switchbot binary reachable on PATH', run: () => checkPathDiscoverability() },
825
- { name: 'credentials', description: 'credentials file present and parseable', run: () => checkCredentials() },
826
- { name: 'keychain', description: 'OS keychain backend availability and usage', run: () => checkKeychain() },
827
- { name: 'profiles', description: 'profile definitions valid', run: () => checkProfiles() },
828
- { name: 'catalog', description: 'catalog loads', run: () => checkCatalog() },
829
- { name: 'catalog-schema', description: 'catalog vs agent-bootstrap version aligned', run: () => checkCatalogSchema() },
830
- { name: 'cache', description: 'device cache state', run: () => checkCache() },
831
- { name: 'quota', description: 'API quota headroom', run: () => checkQuotaFile() },
832
- { name: 'clock', description: 'system clock skew', run: () => checkClockSkew() },
833
- {
834
- name: 'mqtt',
835
- description: 'MQTT credentials (+ --probe for live broker handshake)',
836
- run: ({ probe }) => (probe ? checkMqttProbe() : checkMqtt()),
837
- },
838
- { name: 'mcp', description: 'MCP server instantiable + tool count', run: () => checkMcp() },
839
- { name: 'policy', description: 'policy.yaml present + schema-valid (if configured)', run: () => checkPolicy() },
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() },
843
- ];
844
- function applyFixes(checks, writeOk) {
845
- const results = [];
846
- for (const c of checks) {
847
- if (c.name === 'cache' && c.status !== 'ok') {
848
- if (writeOk) {
849
- try {
850
- resetListCache();
851
- results.push({ check: 'cache', action: 'cache-cleared', applied: true });
852
- }
853
- catch (err) {
854
- results.push({
855
- check: 'cache',
856
- action: 'cache-clear',
857
- applied: false,
858
- message: err instanceof Error ? err.message : String(err),
859
- });
860
- }
861
- }
862
- else {
863
- results.push({
864
- check: 'cache',
865
- action: 'cache-clear',
866
- applied: false,
867
- message: 'pass --yes to apply',
868
- });
869
- }
870
- }
871
- else if (c.name === 'catalog-schema' && c.status !== 'ok') {
872
- results.push({
873
- check: 'catalog-schema',
874
- action: 'manual',
875
- applied: false,
876
- message: "drift detected — run 'switchbot capabilities --reload' to refresh overlay",
877
- });
878
- }
879
- else if (c.name === 'credentials' && c.status === 'fail') {
880
- results.push({
881
- check: 'credentials',
882
- action: 'manual',
883
- applied: false,
884
- message: "run 'switchbot config set-token' to configure credentials",
885
- });
886
- }
887
- }
888
- return results;
889
- }
890
- export function registerDoctorCommand(program) {
891
- program
892
- .command('doctor')
893
- .description('Self-check the SwitchBot CLI setup: credentials, catalog, cache, quota, MQTT, daemon, health, MCP')
894
- .option('--section <names>', 'Comma-separated list of checks to run (see --list for names)')
895
- .option('--list', 'Print the registered check names and exit 0 without running any check')
896
- .option('--fix', 'Apply safe, reversible remediations for failing checks (e.g. clear stale cache)')
897
- .option('--yes', 'Required together with --fix to confirm write actions')
898
- .option('--probe', 'Perform live-probe variant of checks that support it (mqtt)')
899
- .addHelpText('after', `
900
- Runs a battery of local sanity checks and exits with code 0 only when every
901
- check is 'ok'. 'warn' → exit 0 (informational); 'fail' → exit 1.
902
-
903
- Examples:
904
- $ switchbot doctor
905
- $ switchbot --json doctor | jq '.checks[] | select(.status != "ok")'
906
- $ switchbot doctor --list
907
- $ switchbot doctor --section credentials,mcp --json
908
- $ switchbot doctor --section daemon,health --json
909
- $ switchbot doctor --probe --json
910
- $ switchbot doctor --fix --yes --json
911
- `)
912
- .action(async (opts) => {
913
- // --list: print the registry and exit 0.
914
- if (opts.list) {
915
- if (isJsonMode()) {
916
- printJson({
917
- checks: CHECK_REGISTRY.map((c) => ({ name: c.name, description: c.description })),
918
- });
919
- }
920
- else {
921
- console.log('Available checks:');
922
- for (const c of CHECK_REGISTRY) {
923
- console.log(` ${c.name.padEnd(16)} ${c.description}`);
924
- }
925
- }
926
- return;
927
- }
928
- // --section: run only the named subset, dedup and validate.
929
- let selected = CHECK_REGISTRY;
930
- if (opts.section) {
931
- const raw = opts.section.split(',').map((s) => s.trim()).filter(Boolean);
932
- const names = Array.from(new Set(raw));
933
- const known = new Set(CHECK_REGISTRY.map((c) => c.name));
934
- const unknown = names.filter((n) => !known.has(n));
935
- if (unknown.length > 0) {
936
- exitWithError({
937
- code: 2,
938
- kind: 'usage',
939
- message: `Unknown check name(s): ${unknown.join(', ')}. Valid: ${CHECK_REGISTRY.map((c) => c.name).join(', ')}`,
940
- });
941
- return;
942
- }
943
- const order = new Map(CHECK_REGISTRY.map((c, i) => [c.name, i]));
944
- selected = names
945
- .map((n) => CHECK_REGISTRY.find((c) => c.name === n))
946
- .sort((a, b) => (order.get(a.name) - order.get(b.name)));
947
- }
948
- const runOpts = { probe: Boolean(opts.probe) };
949
- const checks = [];
950
- for (const def of selected) {
951
- checks.push(await def.run(runOpts));
952
- }
953
- const summary = {
954
- ok: checks.filter((c) => c.status === 'ok').length,
955
- warn: checks.filter((c) => c.status === 'warn').length,
956
- fail: checks.filter((c) => c.status === 'fail').length,
957
- };
958
- const overallFail = summary.fail > 0;
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';
967
- let fixes;
968
- if (opts.fix) {
969
- fixes = applyFixes(checks, Boolean(opts.yes));
970
- }
971
- if (isJsonMode()) {
972
- // Stable contract (locked as doctor.schemaVersion=1):
973
- // { ok: boolean, overall: 'ok'|'warn'|'fail', generatedAt, schemaVersion,
974
- // summary: { ok, warn, fail }, checks: [{ name, status, detail }] }
975
- // `ok` is an alias of (overall === 'ok') — agents prefer the boolean,
976
- // humans prefer the string; both are provided.
977
- const payload = {
978
- ok: overall === 'ok',
979
- overall,
980
- maturityScore,
981
- maturityLabel,
982
- generatedAt: new Date().toISOString(),
983
- schemaVersion: DOCTOR_SCHEMA_VERSION,
984
- summary,
985
- checks,
986
- };
987
- if (fixes !== undefined)
988
- payload.fixes = fixes;
989
- printJson(payload);
990
- }
991
- else {
992
- for (const c of checks) {
993
- const icon = c.status === 'ok' ? '✓' : c.status === 'warn' ? '!' : '✗';
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));
999
- console.log(`${icon} ${c.name.padEnd(12)} ${detailStr}`);
1000
- }
1001
- console.log('');
1002
- console.log(`${summary.ok} ok, ${summary.warn} warn, ${summary.fail} fail`);
1003
- if (fixes && fixes.length > 0) {
1004
- console.log('');
1005
- console.log('Fixes:');
1006
- for (const f of fixes) {
1007
- const marker = f.applied ? '✓' : '-';
1008
- console.log(` ${marker} ${f.check}: ${f.action}${f.message ? ' — ' + f.message : ''}`);
1009
- }
1010
- }
1011
- }
1012
- if (overallFail)
1013
- process.exit(1);
1014
- });
1015
- }
1016
- //# sourceMappingURL=doctor.js.map