@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.
- package/README.md +385 -103
- package/dist/api/client.js +13 -12
- package/dist/commands/agent-bootstrap.js +67 -16
- package/dist/commands/auth.js +354 -0
- package/dist/commands/batch.js +26 -21
- package/dist/commands/capabilities.js +29 -21
- package/dist/commands/catalog.js +4 -3
- package/dist/commands/config.js +57 -37
- package/dist/commands/devices.js +63 -37
- package/dist/commands/doctor.js +539 -26
- package/dist/commands/events.js +115 -26
- package/dist/commands/expand.js +7 -15
- package/dist/commands/explain.js +10 -7
- package/dist/commands/history.js +12 -18
- package/dist/commands/identity.js +59 -0
- package/dist/commands/install.js +246 -0
- package/dist/commands/mcp.js +895 -15
- package/dist/commands/plan.js +111 -15
- package/dist/commands/policy.js +469 -0
- package/dist/commands/rules.js +657 -0
- package/dist/commands/schema.js +20 -12
- package/dist/commands/status-sync.js +131 -0
- package/dist/commands/uninstall.js +237 -0
- package/dist/commands/watch.js +15 -2
- package/dist/config.js +14 -0
- package/dist/credentials/backends/file.js +101 -0
- package/dist/credentials/backends/linux.js +129 -0
- package/dist/credentials/backends/macos.js +129 -0
- package/dist/credentials/backends/windows.js +215 -0
- package/dist/credentials/keychain.js +88 -0
- package/dist/credentials/prime.js +52 -0
- package/dist/devices/catalog.js +118 -11
- package/dist/devices/resources.js +270 -0
- package/dist/index.js +39 -4
- package/dist/install/default-steps.js +257 -0
- package/dist/install/preflight.js +212 -0
- package/dist/install/steps.js +67 -0
- package/dist/lib/command-keywords.js +17 -0
- package/dist/lib/devices.js +15 -5
- package/dist/policy/add-rule.js +124 -0
- package/dist/policy/diff.js +91 -0
- package/dist/policy/examples/policy.example.yaml +99 -0
- package/dist/policy/format.js +57 -0
- package/dist/policy/load.js +61 -0
- package/dist/policy/migrate.js +67 -0
- package/dist/policy/schema/v0.2.json +302 -0
- package/dist/policy/schema.js +18 -0
- package/dist/policy/validate.js +262 -0
- package/dist/rules/action.js +205 -0
- package/dist/rules/audit-query.js +89 -0
- package/dist/rules/cron-scheduler.js +186 -0
- package/dist/rules/destructive.js +52 -0
- package/dist/rules/engine.js +567 -0
- package/dist/rules/matcher.js +230 -0
- package/dist/rules/pid-file.js +95 -0
- package/dist/rules/quiet-hours.js +45 -0
- package/dist/rules/suggest.js +95 -0
- package/dist/rules/throttle.js +78 -0
- package/dist/rules/types.js +34 -0
- package/dist/rules/webhook-listener.js +223 -0
- package/dist/rules/webhook-token.js +90 -0
- package/dist/schema/field-aliases.js +95 -0
- package/dist/status-sync/manager.js +268 -0
- package/dist/utils/audit.js +12 -2
- package/dist/utils/help-json.js +54 -0
- package/dist/utils/output.js +17 -0
- package/package.json +12 -4
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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'
|
|
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);
|