@switchbot/openapi-cli 2.6.4 → 2.7.2
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 +2 -2
- package/dist/api/client.js +13 -12
- package/dist/commands/agent-bootstrap.js +21 -15
- 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 +27 -37
- package/dist/commands/devices.js +64 -37
- package/dist/commands/doctor.js +355 -19
- package/dist/commands/events.js +112 -23
- package/dist/commands/expand.js +7 -15
- package/dist/commands/explain.js +10 -6
- package/dist/commands/history.js +12 -18
- package/dist/commands/identity.js +59 -0
- package/dist/commands/mcp.js +100 -13
- package/dist/commands/plan.js +1 -1
- package/dist/commands/schema.js +22 -12
- package/dist/commands/watch.js +15 -2
- package/dist/devices/catalog.js +124 -11
- package/dist/devices/resources.js +270 -0
- package/dist/index.js +16 -3
- package/dist/lib/devices.js +16 -5
- package/dist/schema/field-aliases.js +95 -0
- package/dist/utils/help-json.js +54 -0
- package/dist/utils/output.js +17 -0
- package/package.json +1 -1
package/dist/commands/devices.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { enumArg, stringArg } from '../utils/arg-parsers.js';
|
|
2
|
-
import { printTable, printKeyValue, printJson, isJsonMode, handleError, UsageError, StructuredUsageError,
|
|
2
|
+
import { printTable, printKeyValue, printJson, isJsonMode, handleError, UsageError, StructuredUsageError, exitWithError } from '../utils/output.js';
|
|
3
3
|
import { resolveFormat, resolveFields, renderRows } from '../utils/format.js';
|
|
4
|
-
import { findCatalogEntry, getEffectiveCatalog } from '../devices/catalog.js';
|
|
4
|
+
import { findCatalogEntry, getEffectiveCatalog, deriveSafetyTier, getCommandSafetyReason, } from '../devices/catalog.js';
|
|
5
5
|
import { getCachedDevice, loadCache } from '../devices/cache.js';
|
|
6
6
|
import { loadDeviceMeta } from '../devices/device-meta.js';
|
|
7
7
|
import { resolveDeviceId, ALL_STRATEGIES } from '../utils/name-resolver.js';
|
|
@@ -15,7 +15,7 @@ import { registerExpandCommand } from './expand.js';
|
|
|
15
15
|
import { registerDevicesMetaCommand } from './device-meta.js';
|
|
16
16
|
import { isDryRun } from '../utils/flags.js';
|
|
17
17
|
import { DryRunSignal } from '../api/client.js';
|
|
18
|
-
import { resolveField, listSupportedFieldInputs } from '../schema/field-aliases.js';
|
|
18
|
+
import { resolveField, resolveFieldList, listSupportedFieldInputs } from '../schema/field-aliases.js';
|
|
19
19
|
const EXPAND_HINTS = {
|
|
20
20
|
'Air Conditioner': { command: 'setAll', flags: '--temp 26 --mode cool --fan low --power on' },
|
|
21
21
|
'Curtain': { command: 'setPosition', flags: '--position 50 --mode silent' },
|
|
@@ -88,7 +88,7 @@ Examples:
|
|
|
88
88
|
`)
|
|
89
89
|
.option('--wide', 'Show all columns (controlType, family, roomID, room, hub, cloud)')
|
|
90
90
|
.option('--show-hidden', 'Include devices hidden via "devices meta set --hide"')
|
|
91
|
-
.option('--filter <expr>', 'Filter devices: comma-separated clauses. Each clause is "key=value" (substring; exact for category), "key!=value" (negated substring), "key~value" (explicit substring), or "key=/regex/" (case-insensitive regex). Supported keys: deviceId/id, deviceName/name, deviceType/type, controlType, roomName/room, category.', stringArg('--filter'))
|
|
91
|
+
.option('--filter <expr>', 'Filter devices: comma-separated clauses. Each clause is "key=value" (substring; exact for category), "key!=value" (negated substring), "key~value" (explicit substring), or "key=/regex/" (case-insensitive regex). Supported keys: deviceId/id, deviceName/name, deviceType/type, controlType, roomName/room, category, familyName/family, hubDeviceId/hub, roomID/roomid, enableCloudService/cloud, alias.', stringArg('--filter'))
|
|
92
92
|
.action(async (options) => {
|
|
93
93
|
try {
|
|
94
94
|
const body = await fetchDeviceList();
|
|
@@ -98,8 +98,11 @@ Examples:
|
|
|
98
98
|
const hubLocation = buildHubLocationMap(deviceList);
|
|
99
99
|
// Parse --filter into a list of clauses. Shared grammar across
|
|
100
100
|
// `devices list`, `devices batch`, and `events tail` / `mqtt-tail`.
|
|
101
|
-
const LIST_KEYS = ['deviceId', 'type', 'name', 'category', 'room', 'controlType'
|
|
102
|
-
|
|
101
|
+
const LIST_KEYS = ['deviceId', 'type', 'name', 'category', 'room', 'controlType',
|
|
102
|
+
'family', 'hub', 'roomID', 'cloud', 'alias'];
|
|
103
|
+
const LIST_FILTER_CANONICAL = ['deviceId', 'deviceName', 'deviceType', 'controlType',
|
|
104
|
+
'roomName', 'category', 'familyName', 'hubDeviceId', 'roomID',
|
|
105
|
+
'enableCloudService', 'alias'];
|
|
103
106
|
const LIST_FILTER_TO_RUNTIME = {
|
|
104
107
|
deviceId: 'deviceId',
|
|
105
108
|
deviceName: 'name',
|
|
@@ -107,6 +110,11 @@ Examples:
|
|
|
107
110
|
controlType: 'controlType',
|
|
108
111
|
roomName: 'room',
|
|
109
112
|
category: 'category',
|
|
113
|
+
familyName: 'family',
|
|
114
|
+
hubDeviceId: 'hub',
|
|
115
|
+
roomID: 'roomID',
|
|
116
|
+
enableCloudService: 'cloud',
|
|
117
|
+
alias: 'alias',
|
|
110
118
|
};
|
|
111
119
|
let listClauses = null;
|
|
112
120
|
if (options.filter) {
|
|
@@ -137,10 +145,10 @@ Examples:
|
|
|
137
145
|
};
|
|
138
146
|
if (fmt === 'json' && process.argv.includes('--json')) {
|
|
139
147
|
if (listClauses) {
|
|
140
|
-
const filteredDeviceList = deviceList.filter((d) => matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '' }));
|
|
148
|
+
const filteredDeviceList = deviceList.filter((d) => matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '', family: d.familyName || '', hub: d.hubDeviceId || '', roomID: d.roomID || '', cloud: String(d.enableCloudService), alias: deviceMeta.devices[d.deviceId]?.alias || '' }));
|
|
141
149
|
const filteredIrList = infraredRemoteList.filter((d) => {
|
|
142
150
|
const inherited = hubLocation.get(d.hubDeviceId);
|
|
143
|
-
return matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '' });
|
|
151
|
+
return matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '', family: inherited?.family || '', hub: d.hubDeviceId || '', roomID: inherited?.roomID || '', cloud: '', alias: deviceMeta.devices[d.deviceId]?.alias || '' });
|
|
144
152
|
});
|
|
145
153
|
printJson({ ok: true, deviceList: filteredDeviceList, infraredRemoteList: filteredIrList });
|
|
146
154
|
}
|
|
@@ -157,7 +165,7 @@ Examples:
|
|
|
157
165
|
for (const d of deviceList) {
|
|
158
166
|
if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden)
|
|
159
167
|
continue;
|
|
160
|
-
if (!matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '' }))
|
|
168
|
+
if (!matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '', family: d.familyName || '', hub: d.hubDeviceId || '', roomID: d.roomID || '', cloud: String(d.enableCloudService), alias: deviceMeta.devices[d.deviceId]?.alias || '' }))
|
|
161
169
|
continue;
|
|
162
170
|
rows.push([
|
|
163
171
|
d.deviceId,
|
|
@@ -177,7 +185,7 @@ Examples:
|
|
|
177
185
|
if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden)
|
|
178
186
|
continue;
|
|
179
187
|
const inherited = hubLocation.get(d.hubDeviceId);
|
|
180
|
-
if (!matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '' }))
|
|
188
|
+
if (!matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '', family: inherited?.family || '', hub: d.hubDeviceId || '', roomID: inherited?.roomID || '', cloud: '', alias: deviceMeta.devices[d.deviceId]?.alias || '' }))
|
|
181
189
|
continue;
|
|
182
190
|
rows.push([
|
|
183
191
|
d.deviceId,
|
|
@@ -280,7 +288,7 @@ Examples:
|
|
|
280
288
|
}
|
|
281
289
|
}
|
|
282
290
|
else {
|
|
283
|
-
const
|
|
291
|
+
const rawFields = resolveFields();
|
|
284
292
|
for (const entry of batch) {
|
|
285
293
|
const { deviceId, ok, error, _fetchedAt: ts, ...status } = entry;
|
|
286
294
|
console.log(`\n─── ${String(deviceId)} ───`);
|
|
@@ -288,9 +296,13 @@ Examples:
|
|
|
288
296
|
console.error(` error: ${String(error)}`);
|
|
289
297
|
}
|
|
290
298
|
else {
|
|
299
|
+
const statusMap = status;
|
|
300
|
+
const fields = rawFields
|
|
301
|
+
? resolveFieldList(rawFields, Object.keys(statusMap))
|
|
302
|
+
: undefined;
|
|
291
303
|
const displayStatus = fields
|
|
292
|
-
? Object.fromEntries(fields.map((f) => [f,
|
|
293
|
-
:
|
|
304
|
+
? Object.fromEntries(fields.map((f) => [f, statusMap[f] ?? null]))
|
|
305
|
+
: statusMap;
|
|
294
306
|
printKeyValue(displayStatus);
|
|
295
307
|
console.error(` fetched at ${String(ts)}`);
|
|
296
308
|
}
|
|
@@ -315,7 +327,10 @@ Examples:
|
|
|
315
327
|
const statusWithTs = { ...body, _fetchedAt: fetchedAt };
|
|
316
328
|
const allHeaders = Object.keys(statusWithTs);
|
|
317
329
|
const allRows = [Object.values(statusWithTs)];
|
|
318
|
-
const
|
|
330
|
+
const rawFields = resolveFields();
|
|
331
|
+
const fields = rawFields
|
|
332
|
+
? resolveFieldList(rawFields, allHeaders)
|
|
333
|
+
: undefined;
|
|
319
334
|
renderRows(allHeaders, allRows, fmt, fields);
|
|
320
335
|
return;
|
|
321
336
|
}
|
|
@@ -453,26 +468,22 @@ Examples:
|
|
|
453
468
|
const validation = validateCommand(deviceId, cmd, parameter, options.type);
|
|
454
469
|
if (!validation.ok) {
|
|
455
470
|
const err = validation.error;
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
else {
|
|
464
|
-
console.error(`Error: ${err.message}`);
|
|
465
|
-
if (err.hint)
|
|
466
|
-
console.error(err.hint);
|
|
467
|
-
if (err.kind === 'unknown-command') {
|
|
468
|
-
const cached = getCachedDevice(deviceId);
|
|
469
|
-
if (cached) {
|
|
470
|
-
console.error(`Run 'switchbot devices commands ${JSON.stringify(cached.type)}' for parameter formats and descriptions.`);
|
|
471
|
-
console.error(`(If the catalog is out of date, run 'switchbot devices list' to refresh the local cache, or pass --type customize for custom IR buttons.)`);
|
|
472
|
-
}
|
|
471
|
+
let hint = err.hint;
|
|
472
|
+
if (err.kind === 'unknown-command') {
|
|
473
|
+
const cached = getCachedDevice(deviceId);
|
|
474
|
+
if (cached) {
|
|
475
|
+
const extra = `Run 'switchbot devices commands ${JSON.stringify(cached.type)}' for parameter formats and descriptions.\n` +
|
|
476
|
+
`(If the catalog is out of date, run 'switchbot devices list' to refresh the local cache, or pass --type customize for custom IR buttons.)`;
|
|
477
|
+
hint = hint ? `${hint}\n${extra}` : extra;
|
|
473
478
|
}
|
|
474
479
|
}
|
|
475
|
-
|
|
480
|
+
exitWithError({
|
|
481
|
+
code: 2,
|
|
482
|
+
kind: 'usage',
|
|
483
|
+
message: err.message,
|
|
484
|
+
hint,
|
|
485
|
+
context: { validationKind: err.kind },
|
|
486
|
+
});
|
|
476
487
|
}
|
|
477
488
|
// Case-only mismatch: emit a warning and continue with the canonical name.
|
|
478
489
|
if (validation.caseNormalizedFrom && validation.normalized) {
|
|
@@ -507,7 +518,7 @@ Examples:
|
|
|
507
518
|
hint: reason
|
|
508
519
|
? `Re-run with --yes to confirm. Reason: ${reason}`
|
|
509
520
|
: 'Re-run with --yes to confirm, or --dry-run to preview without sending.',
|
|
510
|
-
context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { destructiveReason: reason } : {}) },
|
|
521
|
+
context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { safetyReason: reason, destructiveReason: reason } : {}) },
|
|
511
522
|
});
|
|
512
523
|
}
|
|
513
524
|
// Warn when --yes is given but the command is not destructive (no-op flag)
|
|
@@ -645,7 +656,7 @@ Examples:
|
|
|
645
656
|
const joinedMatch = findCatalogEntry(joined);
|
|
646
657
|
if (joinedMatch && !Array.isArray(joinedMatch)) {
|
|
647
658
|
if (isJsonMode()) {
|
|
648
|
-
printJson(joinedMatch);
|
|
659
|
+
printJson(normalizeCatalogForJson(joinedMatch));
|
|
649
660
|
}
|
|
650
661
|
else {
|
|
651
662
|
renderCatalogEntry(joinedMatch);
|
|
@@ -664,7 +675,7 @@ Examples:
|
|
|
664
675
|
}
|
|
665
676
|
if (individualMatches.length === typeParts.length) {
|
|
666
677
|
if (isJsonMode()) {
|
|
667
|
-
printJson(individualMatches);
|
|
678
|
+
printJson(individualMatches.map(normalizeCatalogForJson));
|
|
668
679
|
}
|
|
669
680
|
else {
|
|
670
681
|
individualMatches.forEach((entry, i) => {
|
|
@@ -824,6 +835,21 @@ Examples:
|
|
|
824
835
|
// switchbot devices meta set/get/list/clear
|
|
825
836
|
registerDevicesMetaCommand(devices);
|
|
826
837
|
}
|
|
838
|
+
function normalizeCatalogForJson(entry) {
|
|
839
|
+
return {
|
|
840
|
+
...entry,
|
|
841
|
+
commands: entry.commands.map((c) => {
|
|
842
|
+
const tier = deriveSafetyTier(c, entry);
|
|
843
|
+
const reason = getCommandSafetyReason(c);
|
|
844
|
+
return {
|
|
845
|
+
...c,
|
|
846
|
+
safetyTier: tier,
|
|
847
|
+
destructive: tier === 'destructive',
|
|
848
|
+
...(reason ? { safetyReason: reason } : {}),
|
|
849
|
+
};
|
|
850
|
+
}),
|
|
851
|
+
};
|
|
852
|
+
}
|
|
827
853
|
function renderCatalogEntry(entry) {
|
|
828
854
|
console.log(`Type: ${entry.type}`);
|
|
829
855
|
console.log(`Category: ${entry.category === 'ir' ? 'IR remote' : 'Physical device'}`);
|
|
@@ -841,10 +867,11 @@ function renderCatalogEntry(entry) {
|
|
|
841
867
|
console.log('\nCommands:');
|
|
842
868
|
const hasExamples = entry.commands.some((c) => c.exampleParams && c.exampleParams.length > 0);
|
|
843
869
|
const rows = entry.commands.map((c) => {
|
|
870
|
+
const tier = deriveSafetyTier(c, entry);
|
|
844
871
|
const flags = [];
|
|
845
872
|
if (c.commandType === 'customize')
|
|
846
873
|
flags.push('customize');
|
|
847
|
-
if (
|
|
874
|
+
if (tier === 'destructive')
|
|
848
875
|
flags.push('!destructive');
|
|
849
876
|
const label = flags.length > 0 ? `${c.command} [${flags.join(', ')}]` : c.command;
|
|
850
877
|
const base = [label, c.parameter, c.description];
|
|
@@ -854,7 +881,7 @@ function renderCatalogEntry(entry) {
|
|
|
854
881
|
? ['command', 'parameter', 'description', 'example']
|
|
855
882
|
: ['command', 'parameter', 'description'];
|
|
856
883
|
printTable(tableHeaders, rows);
|
|
857
|
-
const hasDestructive = entry.commands.some((c) => c
|
|
884
|
+
const hasDestructive = entry.commands.some((c) => deriveSafetyTier(c, entry) === 'destructive');
|
|
858
885
|
if (hasDestructive) {
|
|
859
886
|
console.log('\n[!destructive] commands have hard-to-reverse real-world effects — confirm before issuing.');
|
|
860
887
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
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';
|
|
8
12
|
export const DOCTOR_SCHEMA_VERSION = 1;
|
|
9
13
|
async function checkCredentials() {
|
|
10
14
|
const envOk = Boolean(process.env.SWITCHBOT_TOKEN && process.env.SWITCHBOT_SECRET);
|
|
@@ -160,15 +164,148 @@ function checkCache() {
|
|
|
160
164
|
function checkQuotaFile() {
|
|
161
165
|
const p = path.join(os.homedir(), '.switchbot', 'quota.json');
|
|
162
166
|
if (!fs.existsSync(p)) {
|
|
163
|
-
return {
|
|
167
|
+
return {
|
|
168
|
+
name: 'quota',
|
|
169
|
+
status: 'ok',
|
|
170
|
+
detail: {
|
|
171
|
+
path: p,
|
|
172
|
+
percentUsed: 0,
|
|
173
|
+
remaining: DAILY_QUOTA,
|
|
174
|
+
message: 'no quota file yet (will be created on first call)',
|
|
175
|
+
},
|
|
176
|
+
};
|
|
164
177
|
}
|
|
165
178
|
try {
|
|
166
179
|
const raw = fs.readFileSync(p, 'utf-8');
|
|
167
180
|
JSON.parse(raw);
|
|
168
|
-
return { name: 'quota', status: 'ok', detail: p };
|
|
169
181
|
}
|
|
170
182
|
catch {
|
|
171
|
-
return {
|
|
183
|
+
return {
|
|
184
|
+
name: 'quota',
|
|
185
|
+
status: 'warn',
|
|
186
|
+
detail: { path: p, message: `unreadable/malformed — run 'switchbot quota reset'` },
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
// P9: surface headroom so agents can decide when to slow down or pause.
|
|
190
|
+
// Quota resets at local midnight (the quota counter buckets by local
|
|
191
|
+
// date), so project the next reset to the next 00:00:00 local.
|
|
192
|
+
const usage = todayUsage();
|
|
193
|
+
const percentUsed = Math.round((usage.total / DAILY_QUOTA) * 100);
|
|
194
|
+
const now = new Date();
|
|
195
|
+
const reset = new Date(now);
|
|
196
|
+
reset.setHours(24, 0, 0, 0); // next local midnight
|
|
197
|
+
const status = percentUsed > 80 ? 'warn' : 'ok';
|
|
198
|
+
const recommendation = percentUsed > 90
|
|
199
|
+
? 'over 90% used — consider --no-quota for read-only triage or rescheduling work after the reset'
|
|
200
|
+
: percentUsed > 80
|
|
201
|
+
? 'over 80% used — avoid bulk operations until the daily reset'
|
|
202
|
+
: 'headroom available';
|
|
203
|
+
return {
|
|
204
|
+
name: 'quota',
|
|
205
|
+
status,
|
|
206
|
+
detail: {
|
|
207
|
+
path: p,
|
|
208
|
+
percentUsed,
|
|
209
|
+
remaining: usage.remaining,
|
|
210
|
+
total: usage.total,
|
|
211
|
+
dailyCap: DAILY_QUOTA,
|
|
212
|
+
projectedResetTime: reset.toISOString(),
|
|
213
|
+
recommendation,
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function checkCatalogSchema() {
|
|
218
|
+
// P9: sentinel against silent drift between the catalog shape and the
|
|
219
|
+
// agent-bootstrap payload. Both constants are exported from their
|
|
220
|
+
// respective modules; if a future refactor changes one without the
|
|
221
|
+
// other, this check fails so consumers (agents) learn before the
|
|
222
|
+
// mismatch corrupts their mental model.
|
|
223
|
+
const match = CATALOG_SCHEMA_VERSION === AGENT_BOOTSTRAP_SCHEMA_VERSION;
|
|
224
|
+
return {
|
|
225
|
+
name: 'catalog-schema',
|
|
226
|
+
status: match ? 'ok' : 'fail',
|
|
227
|
+
detail: {
|
|
228
|
+
catalogSchemaVersion: CATALOG_SCHEMA_VERSION,
|
|
229
|
+
bootstrapExpectsVersion: AGENT_BOOTSTRAP_SCHEMA_VERSION,
|
|
230
|
+
match,
|
|
231
|
+
message: match
|
|
232
|
+
? 'catalog and agent-bootstrap schemaVersion aligned'
|
|
233
|
+
: 'catalog and agent-bootstrap schemaVersion have drifted — bump in lockstep',
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
function checkAudit() {
|
|
238
|
+
// P9: surface recent command failures so agents / ops can spot problems
|
|
239
|
+
// before they page. When --audit-log was never enabled, the file won't
|
|
240
|
+
// exist — report that cleanly rather than as an error.
|
|
241
|
+
const p = path.join(os.homedir(), '.switchbot', 'audit.log');
|
|
242
|
+
if (!fs.existsSync(p)) {
|
|
243
|
+
return {
|
|
244
|
+
name: 'audit',
|
|
245
|
+
status: 'ok',
|
|
246
|
+
detail: {
|
|
247
|
+
path: p,
|
|
248
|
+
enabled: false,
|
|
249
|
+
message: 'audit log not present (enable with --audit-log)',
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
const raw = fs.readFileSync(p, 'utf-8');
|
|
255
|
+
const since = Date.now() - 24 * 60 * 60 * 1000;
|
|
256
|
+
const recent = [];
|
|
257
|
+
let total = 0;
|
|
258
|
+
for (const line of raw.split('\n')) {
|
|
259
|
+
const trimmed = line.trim();
|
|
260
|
+
if (!trimmed)
|
|
261
|
+
continue;
|
|
262
|
+
let rec;
|
|
263
|
+
try {
|
|
264
|
+
rec = JSON.parse(trimmed);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (rec.result !== 'error')
|
|
270
|
+
continue;
|
|
271
|
+
total += 1;
|
|
272
|
+
const ts = rec.t ? Date.parse(rec.t) : NaN;
|
|
273
|
+
if (Number.isFinite(ts) && ts >= since) {
|
|
274
|
+
recent.push({
|
|
275
|
+
t: rec.t,
|
|
276
|
+
command: rec.command ?? '?',
|
|
277
|
+
deviceId: rec.deviceId,
|
|
278
|
+
error: rec.error ?? 'unknown',
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Cap the report to the 10 most recent so the doctor payload stays
|
|
283
|
+
// bounded even on a log with thousands of errors.
|
|
284
|
+
recent.sort((a, b) => (a.t < b.t ? 1 : -1));
|
|
285
|
+
const clipped = recent.slice(0, 10);
|
|
286
|
+
const status = recent.length > 0 ? 'warn' : 'ok';
|
|
287
|
+
return {
|
|
288
|
+
name: 'audit',
|
|
289
|
+
status,
|
|
290
|
+
detail: {
|
|
291
|
+
path: p,
|
|
292
|
+
enabled: true,
|
|
293
|
+
totalErrors: total,
|
|
294
|
+
errorsLast24h: recent.length,
|
|
295
|
+
recent: clipped,
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
return {
|
|
301
|
+
name: 'audit',
|
|
302
|
+
status: 'warn',
|
|
303
|
+
detail: {
|
|
304
|
+
path: p,
|
|
305
|
+
enabled: true,
|
|
306
|
+
message: `could not read audit log: ${err instanceof Error ? err.message : String(err)}`,
|
|
307
|
+
},
|
|
308
|
+
};
|
|
172
309
|
}
|
|
173
310
|
}
|
|
174
311
|
function checkNodeVersion() {
|
|
@@ -210,10 +347,160 @@ function checkMqtt() {
|
|
|
210
347
|
detail: "unavailable — configure credentials first (see credentials check above)",
|
|
211
348
|
};
|
|
212
349
|
}
|
|
350
|
+
async function checkMqttProbe() {
|
|
351
|
+
// P10: live-probe the MQTT broker. Only runs when --probe is passed.
|
|
352
|
+
// Does not subscribe — just connects + disconnects to verify the
|
|
353
|
+
// credential + TLS handshake works end-to-end. Hard 5s timeout so
|
|
354
|
+
// a misbehaving broker never wedges the doctor command.
|
|
355
|
+
const { fetchMqttCredential } = await import('../mqtt/credential.js');
|
|
356
|
+
const { SwitchBotMqttClient } = await import('../mqtt/client.js');
|
|
357
|
+
const token = process.env.SWITCHBOT_TOKEN;
|
|
358
|
+
const secret = process.env.SWITCHBOT_SECRET;
|
|
359
|
+
let creds = null;
|
|
360
|
+
if (token && secret) {
|
|
361
|
+
creds = { token, secret };
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
const file = configFilePath();
|
|
365
|
+
if (fs.existsSync(file)) {
|
|
366
|
+
try {
|
|
367
|
+
const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
368
|
+
if (cfg.token && cfg.secret) {
|
|
369
|
+
creds = { token: cfg.token, secret: cfg.secret };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
catch { /* fall through */ }
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (!creds) {
|
|
376
|
+
return {
|
|
377
|
+
name: 'mqtt',
|
|
378
|
+
status: 'warn',
|
|
379
|
+
detail: { probe: 'skipped', reason: 'no credentials configured' },
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
const deadline = new Promise((_, reject) => setTimeout(() => reject(new Error('probe timeout after 5000ms')), 5000));
|
|
383
|
+
try {
|
|
384
|
+
const cred = await Promise.race([fetchMqttCredential(creds.token, creds.secret), deadline]);
|
|
385
|
+
const client = new SwitchBotMqttClient(cred);
|
|
386
|
+
await Promise.race([client.connect(), deadline]);
|
|
387
|
+
await client.disconnect();
|
|
388
|
+
return {
|
|
389
|
+
name: 'mqtt',
|
|
390
|
+
status: 'ok',
|
|
391
|
+
detail: { probe: 'connected', brokerUrl: cred.brokerUrl, region: cred.region },
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
catch (err) {
|
|
395
|
+
return {
|
|
396
|
+
name: 'mqtt',
|
|
397
|
+
status: 'warn',
|
|
398
|
+
detail: { probe: 'failed', reason: err instanceof Error ? err.message : String(err) },
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function checkMcp() {
|
|
403
|
+
// P10: dry-run instantiation of the MCP server to catch tool-registration
|
|
404
|
+
// regressions. No network I/O, no token needed. If createSwitchBotMcpServer
|
|
405
|
+
// throws (e.g. duplicate tool name, schema build error) the check fails.
|
|
406
|
+
try {
|
|
407
|
+
const server = createSwitchBotMcpServer();
|
|
408
|
+
const tools = listRegisteredTools(server);
|
|
409
|
+
return {
|
|
410
|
+
name: 'mcp',
|
|
411
|
+
status: 'ok',
|
|
412
|
+
detail: {
|
|
413
|
+
serverInstantiated: true,
|
|
414
|
+
toolCount: tools.length,
|
|
415
|
+
tools,
|
|
416
|
+
transportsAvailable: ['stdio', 'http'],
|
|
417
|
+
message: `${tools.length} tools registered; no network probe`,
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
catch (err) {
|
|
422
|
+
return {
|
|
423
|
+
name: 'mcp',
|
|
424
|
+
status: 'fail',
|
|
425
|
+
detail: {
|
|
426
|
+
serverInstantiated: false,
|
|
427
|
+
error: err instanceof Error ? err.message : String(err),
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const CHECK_REGISTRY = [
|
|
433
|
+
{ name: 'node', description: 'Node.js version compatibility', run: () => checkNodeVersion() },
|
|
434
|
+
{ name: 'credentials', description: 'credentials file present and parseable', run: () => checkCredentials() },
|
|
435
|
+
{ name: 'profiles', description: 'profile definitions valid', run: () => checkProfiles() },
|
|
436
|
+
{ name: 'catalog', description: 'catalog loads', run: () => checkCatalog() },
|
|
437
|
+
{ name: 'catalog-schema', description: 'catalog vs agent-bootstrap version aligned', run: () => checkCatalogSchema() },
|
|
438
|
+
{ name: 'cache', description: 'device cache state', run: () => checkCache() },
|
|
439
|
+
{ name: 'quota', description: 'API quota headroom', run: () => checkQuotaFile() },
|
|
440
|
+
{ name: 'clock', description: 'system clock skew', run: () => checkClockSkew() },
|
|
441
|
+
{
|
|
442
|
+
name: 'mqtt',
|
|
443
|
+
description: 'MQTT credentials (+ --probe for live broker handshake)',
|
|
444
|
+
run: ({ probe }) => (probe ? checkMqttProbe() : checkMqtt()),
|
|
445
|
+
},
|
|
446
|
+
{ name: 'mcp', description: 'MCP server instantiable + tool count', run: () => checkMcp() },
|
|
447
|
+
{ name: 'audit', description: 'recent command errors (last 24h)', run: () => checkAudit() },
|
|
448
|
+
];
|
|
449
|
+
function applyFixes(checks, writeOk) {
|
|
450
|
+
const results = [];
|
|
451
|
+
for (const c of checks) {
|
|
452
|
+
if (c.name === 'cache' && c.status !== 'ok') {
|
|
453
|
+
if (writeOk) {
|
|
454
|
+
try {
|
|
455
|
+
resetListCache();
|
|
456
|
+
results.push({ check: 'cache', action: 'cache-cleared', applied: true });
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
results.push({
|
|
460
|
+
check: 'cache',
|
|
461
|
+
action: 'cache-clear',
|
|
462
|
+
applied: false,
|
|
463
|
+
message: err instanceof Error ? err.message : String(err),
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
results.push({
|
|
469
|
+
check: 'cache',
|
|
470
|
+
action: 'cache-clear',
|
|
471
|
+
applied: false,
|
|
472
|
+
message: 'pass --yes to apply',
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
else if (c.name === 'catalog-schema' && c.status !== 'ok') {
|
|
477
|
+
results.push({
|
|
478
|
+
check: 'catalog-schema',
|
|
479
|
+
action: 'manual',
|
|
480
|
+
applied: false,
|
|
481
|
+
message: "drift detected — run 'switchbot capabilities --reload' to refresh overlay",
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
else if (c.name === 'credentials' && c.status === 'fail') {
|
|
485
|
+
results.push({
|
|
486
|
+
check: 'credentials',
|
|
487
|
+
action: 'manual',
|
|
488
|
+
applied: false,
|
|
489
|
+
message: "run 'switchbot config set-token' to configure credentials",
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return results;
|
|
494
|
+
}
|
|
213
495
|
export function registerDoctorCommand(program) {
|
|
214
496
|
program
|
|
215
497
|
.command('doctor')
|
|
216
|
-
.description('Self-check: credentials, catalog, cache, quota,
|
|
498
|
+
.description('Self-check the SwitchBot CLI setup: credentials, catalog, cache, quota, MQTT, MCP')
|
|
499
|
+
.option('--section <names>', 'Comma-separated list of checks to run (see --list for names)')
|
|
500
|
+
.option('--list', 'Print the registered check names and exit 0 without running any check')
|
|
501
|
+
.option('--fix', 'Apply safe, reversible remediations for failing checks (e.g. clear stale cache)')
|
|
502
|
+
.option('--yes', 'Required together with --fix to confirm write actions')
|
|
503
|
+
.option('--probe', 'Perform live-probe variant of checks that support it (mqtt)')
|
|
217
504
|
.addHelpText('after', `
|
|
218
505
|
Runs a battery of local sanity checks and exits with code 0 only when every
|
|
219
506
|
check is 'ok'. 'warn' → exit 0 (informational); 'fail' → exit 1.
|
|
@@ -221,18 +508,52 @@ check is 'ok'. 'warn' → exit 0 (informational); 'fail' → exit 1.
|
|
|
221
508
|
Examples:
|
|
222
509
|
$ switchbot doctor
|
|
223
510
|
$ switchbot --json doctor | jq '.checks[] | select(.status != "ok")'
|
|
511
|
+
$ switchbot doctor --list
|
|
512
|
+
$ switchbot doctor --section credentials,mcp --json
|
|
513
|
+
$ switchbot doctor --probe --json
|
|
514
|
+
$ switchbot doctor --fix --yes --json
|
|
224
515
|
`)
|
|
225
|
-
.action(async () => {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
516
|
+
.action(async (opts) => {
|
|
517
|
+
// --list: print the registry and exit 0.
|
|
518
|
+
if (opts.list) {
|
|
519
|
+
if (isJsonMode()) {
|
|
520
|
+
printJson({
|
|
521
|
+
checks: CHECK_REGISTRY.map((c) => ({ name: c.name, description: c.description })),
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
else {
|
|
525
|
+
console.log('Available checks:');
|
|
526
|
+
for (const c of CHECK_REGISTRY) {
|
|
527
|
+
console.log(` ${c.name.padEnd(16)} ${c.description}`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
// --section: run only the named subset, dedup and validate.
|
|
533
|
+
let selected = CHECK_REGISTRY;
|
|
534
|
+
if (opts.section) {
|
|
535
|
+
const raw = opts.section.split(',').map((s) => s.trim()).filter(Boolean);
|
|
536
|
+
const names = Array.from(new Set(raw));
|
|
537
|
+
const known = new Set(CHECK_REGISTRY.map((c) => c.name));
|
|
538
|
+
const unknown = names.filter((n) => !known.has(n));
|
|
539
|
+
if (unknown.length > 0) {
|
|
540
|
+
exitWithError({
|
|
541
|
+
code: 2,
|
|
542
|
+
kind: 'usage',
|
|
543
|
+
message: `Unknown check name(s): ${unknown.join(', ')}. Valid: ${CHECK_REGISTRY.map((c) => c.name).join(', ')}`,
|
|
544
|
+
});
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const order = new Map(CHECK_REGISTRY.map((c, i) => [c.name, i]));
|
|
548
|
+
selected = names
|
|
549
|
+
.map((n) => CHECK_REGISTRY.find((c) => c.name === n))
|
|
550
|
+
.sort((a, b) => (order.get(a.name) - order.get(b.name)));
|
|
551
|
+
}
|
|
552
|
+
const runOpts = { probe: Boolean(opts.probe) };
|
|
553
|
+
const checks = [];
|
|
554
|
+
for (const def of selected) {
|
|
555
|
+
checks.push(await def.run(runOpts));
|
|
556
|
+
}
|
|
236
557
|
const summary = {
|
|
237
558
|
ok: checks.filter((c) => c.status === 'ok').length,
|
|
238
559
|
warn: checks.filter((c) => c.status === 'warn').length,
|
|
@@ -240,20 +561,27 @@ Examples:
|
|
|
240
561
|
};
|
|
241
562
|
const overallFail = summary.fail > 0;
|
|
242
563
|
const overall = overallFail ? 'fail' : summary.warn > 0 ? 'warn' : 'ok';
|
|
564
|
+
let fixes;
|
|
565
|
+
if (opts.fix) {
|
|
566
|
+
fixes = applyFixes(checks, Boolean(opts.yes));
|
|
567
|
+
}
|
|
243
568
|
if (isJsonMode()) {
|
|
244
569
|
// Stable contract (locked as doctor.schemaVersion=1):
|
|
245
570
|
// { ok: boolean, overall: 'ok'|'warn'|'fail', generatedAt, schemaVersion,
|
|
246
571
|
// summary: { ok, warn, fail }, checks: [{ name, status, detail }] }
|
|
247
572
|
// `ok` is an alias of (overall === 'ok') — agents prefer the boolean,
|
|
248
573
|
// humans prefer the string; both are provided.
|
|
249
|
-
|
|
574
|
+
const payload = {
|
|
250
575
|
ok: overall === 'ok',
|
|
251
576
|
overall,
|
|
252
577
|
generatedAt: new Date().toISOString(),
|
|
253
578
|
schemaVersion: DOCTOR_SCHEMA_VERSION,
|
|
254
579
|
summary,
|
|
255
580
|
checks,
|
|
256
|
-
}
|
|
581
|
+
};
|
|
582
|
+
if (fixes !== undefined)
|
|
583
|
+
payload.fixes = fixes;
|
|
584
|
+
printJson(payload);
|
|
257
585
|
}
|
|
258
586
|
else {
|
|
259
587
|
for (const c of checks) {
|
|
@@ -263,6 +591,14 @@ Examples:
|
|
|
263
591
|
}
|
|
264
592
|
console.log('');
|
|
265
593
|
console.log(`${summary.ok} ok, ${summary.warn} warn, ${summary.fail} fail`);
|
|
594
|
+
if (fixes && fixes.length > 0) {
|
|
595
|
+
console.log('');
|
|
596
|
+
console.log('Fixes:');
|
|
597
|
+
for (const f of fixes) {
|
|
598
|
+
const marker = f.applied ? '✓' : '-';
|
|
599
|
+
console.log(` ${marker} ${f.check}: ${f.action}${f.message ? ' — ' + f.message : ''}`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
266
602
|
}
|
|
267
603
|
if (overallFail)
|
|
268
604
|
process.exit(1);
|