@switchbot/openapi-cli 2.6.3 → 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 +28 -35
- package/dist/commands/capabilities.js +29 -21
- package/dist/commands/catalog.js +12 -3
- package/dist/commands/config.js +32 -38
- package/dist/commands/devices.js +124 -83
- 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 +168 -73
- package/dist/commands/plan.js +1 -1
- package/dist/commands/schema.js +22 -12
- package/dist/commands/watch.js +15 -2
- package/dist/config.js +65 -21
- package/dist/devices/catalog.js +125 -12
- package/dist/devices/resources.js +270 -0
- package/dist/index.js +25 -6
- package/dist/lib/devices.js +22 -7
- package/dist/schema/field-aliases.js +131 -0
- package/dist/utils/filter.js +17 -4
- package/dist/utils/help-json.js +54 -0
- package/dist/utils/output.js +37 -0
- package/package.json +1 -1
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);
|
package/dist/commands/events.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
2
|
import crypto from 'node:crypto';
|
|
3
|
-
import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
|
|
3
|
+
import { printJson, isJsonMode, handleError, UsageError, emitStreamHeader } from '../utils/output.js';
|
|
4
4
|
import { intArg, stringArg, durationArg } from '../utils/arg-parsers.js';
|
|
5
5
|
import { parseDurationToMs } from '../utils/flags.js';
|
|
6
6
|
import { parseFilterExpr, matchClause, FilterSyntaxError } from '../utils/filter.js';
|
|
@@ -19,6 +19,16 @@ import { deviceHistoryStore } from '../mcp/device-history.js';
|
|
|
19
19
|
const DEFAULT_PORT = 3000;
|
|
20
20
|
const DEFAULT_PATH = '/';
|
|
21
21
|
const MAX_BODY_BYTES = 1_000_000;
|
|
22
|
+
/**
|
|
23
|
+
* P6: unified-envelope schema version shared by webhook and MQTT event output.
|
|
24
|
+
*
|
|
25
|
+
* The same key set now appears on both `events tail` (webhook) and
|
|
26
|
+
* `events mqtt-tail` (MQTT) output lines so downstream JSONL consumers can
|
|
27
|
+
* use a single parser regardless of source. Old fields are kept for one
|
|
28
|
+
* minor window so existing consumers keep working — see README and
|
|
29
|
+
* CHANGELOG for the deprecation schedule.
|
|
30
|
+
*/
|
|
31
|
+
export const EVENTS_SCHEMA_VERSION = '1';
|
|
22
32
|
function extractEventId(parsed) {
|
|
23
33
|
if (!parsed || typeof parsed !== 'object')
|
|
24
34
|
return null;
|
|
@@ -30,13 +40,27 @@ function extractEventId(parsed) {
|
|
|
30
40
|
return ctx.eventId;
|
|
31
41
|
return null;
|
|
32
42
|
}
|
|
33
|
-
function
|
|
43
|
+
function extractDeviceId(parsed) {
|
|
44
|
+
if (!parsed || typeof parsed !== 'object')
|
|
45
|
+
return null;
|
|
46
|
+
const p = parsed;
|
|
47
|
+
const ctx = p.context ?? p;
|
|
48
|
+
const mac = ctx.deviceMac;
|
|
49
|
+
if (typeof mac === 'string' && mac.length > 0)
|
|
50
|
+
return mac;
|
|
51
|
+
const id = ctx.deviceId;
|
|
52
|
+
if (typeof id === 'string' && id.length > 0)
|
|
53
|
+
return id;
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
function matchFilterDetail(body, clauses) {
|
|
34
57
|
if (!clauses || clauses.length === 0)
|
|
35
|
-
return true;
|
|
58
|
+
return { matched: true, matchedKeys: [] };
|
|
36
59
|
if (!body || typeof body !== 'object')
|
|
37
|
-
return false;
|
|
60
|
+
return { matched: false, matchedKeys: [] };
|
|
38
61
|
const b = body;
|
|
39
62
|
const ctx = (b.context ?? b);
|
|
63
|
+
const hitKeys = [];
|
|
40
64
|
for (const c of clauses) {
|
|
41
65
|
let candidate;
|
|
42
66
|
if (c.key === 'deviceId') {
|
|
@@ -49,9 +73,10 @@ function matchFilter(body, clauses) {
|
|
|
49
73
|
candidate = typeof t === 'string' ? t : '';
|
|
50
74
|
}
|
|
51
75
|
if (!matchClause(candidate, c))
|
|
52
|
-
return false;
|
|
76
|
+
return { matched: false, matchedKeys: [] };
|
|
77
|
+
hitKeys.push(c.key);
|
|
53
78
|
}
|
|
54
|
-
return true;
|
|
79
|
+
return { matched: true, matchedKeys: hitKeys };
|
|
55
80
|
}
|
|
56
81
|
const EVENT_FILTER_KEYS = ['deviceId', 'type'];
|
|
57
82
|
function parseFilter(flag) {
|
|
@@ -108,11 +133,22 @@ export function startReceiver(port, pathMatch, filter, onEvent) {
|
|
|
108
133
|
catch {
|
|
109
134
|
// keep raw
|
|
110
135
|
}
|
|
111
|
-
const matched =
|
|
136
|
+
const { matched, matchedKeys } = matchFilterDetail(body, filter);
|
|
137
|
+
const t = new Date().toISOString();
|
|
138
|
+
const urlPath = req.url ?? '/';
|
|
112
139
|
onEvent({
|
|
113
|
-
|
|
140
|
+
schemaVersion: EVENTS_SCHEMA_VERSION,
|
|
141
|
+
source: 'webhook',
|
|
142
|
+
kind: 'event',
|
|
143
|
+
t,
|
|
144
|
+
eventId: extractEventId(body),
|
|
145
|
+
deviceId: extractDeviceId(body),
|
|
146
|
+
topic: urlPath,
|
|
147
|
+
payload: body,
|
|
148
|
+
matchedKeys,
|
|
149
|
+
// Legacy mirror:
|
|
114
150
|
remote: `${req.socket.remoteAddress ?? ''}:${req.socket.remotePort ?? ''}`,
|
|
115
|
-
path:
|
|
151
|
+
path: urlPath,
|
|
116
152
|
body,
|
|
117
153
|
matched,
|
|
118
154
|
});
|
|
@@ -143,9 +179,14 @@ SwitchBot posts events to a single webhook URL configured via:
|
|
|
143
179
|
the port to the internet yourself (ngrok/cloudflared/reverse proxy) and
|
|
144
180
|
point the SwitchBot webhook at that public URL.
|
|
145
181
|
|
|
146
|
-
Output (JSONL, one event per line):
|
|
147
|
-
{ "
|
|
148
|
-
"
|
|
182
|
+
Output (JSONL, one event per line; P6 unified envelope v2.7+):
|
|
183
|
+
{ "schemaVersion": "1", "source": "webhook", "kind": "event",
|
|
184
|
+
"t": "<ISO>", "eventId": <string|null>, "deviceId": <string|null>,
|
|
185
|
+
"topic": "/", // = path
|
|
186
|
+
"payload": <parsed JSON or raw string>,
|
|
187
|
+
"matchedKeys": ["deviceId"], // which filter clauses matched
|
|
188
|
+
// Legacy fields kept for one minor window (removed in v3.0):
|
|
189
|
+
"remote": "<ip:port>", "path": "/", "body": <payload>, "matched": true }
|
|
149
190
|
|
|
150
191
|
Filter grammar: comma-separated clauses (AND-ed). Each clause is one of
|
|
151
192
|
key=value — case-insensitive substring
|
|
@@ -179,6 +220,10 @@ Examples:
|
|
|
179
220
|
const forTimer = forMs !== null && forMs > 0
|
|
180
221
|
? setTimeout(() => ac.abort(), forMs)
|
|
181
222
|
: null;
|
|
223
|
+
// P7: streaming JSON contract — first line under --json is the
|
|
224
|
+
// stream header (webhook events arrive via push cadence).
|
|
225
|
+
if (isJsonMode())
|
|
226
|
+
emitStreamHeader({ eventKind: 'event', cadence: 'push' });
|
|
182
227
|
await new Promise((resolve, reject) => {
|
|
183
228
|
let server = null;
|
|
184
229
|
try {
|
|
@@ -244,14 +289,20 @@ Connects to the SwitchBot MQTT service using your existing credentials
|
|
|
244
289
|
(SWITCHBOT_TOKEN + SWITCHBOT_SECRET or ~/.switchbot/config.json).
|
|
245
290
|
No additional MQTT configuration required.
|
|
246
291
|
|
|
247
|
-
Output (JSONL, one event per line):
|
|
248
|
-
{ "
|
|
292
|
+
Output (JSONL, one event per line; P6 unified envelope v2.7+):
|
|
293
|
+
{ "schemaVersion": "1", "source": "mqtt", "kind": "event",
|
|
294
|
+
"t": "<ISO>", "eventId": "<uuid>", "deviceId": <string|null>,
|
|
295
|
+
"topic": "<mqtt-topic>",
|
|
296
|
+
"payload": <parsed JSON or raw string> }
|
|
249
297
|
|
|
250
|
-
Control records (interleaved,
|
|
251
|
-
{ "
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
298
|
+
Control records (interleaved, kind: "control" — filter by the "kind" field):
|
|
299
|
+
{ "schemaVersion": "1", "source": "mqtt", "kind": "control",
|
|
300
|
+
"controlKind": "session_start"|"connect"|"reconnect"|"disconnect"|"heartbeat",
|
|
301
|
+
"t": "<ISO>", "eventId": "<uuid>",
|
|
302
|
+
"state": "connecting" // present on session_start only
|
|
303
|
+
// Legacy fields kept for one minor window (removed in v3.0):
|
|
304
|
+
"type": "__session_start"|"__connect"|"__reconnect"|"__disconnect",
|
|
305
|
+
"at": "<ISO>" }
|
|
255
306
|
|
|
256
307
|
Reconnect policy: the MQTT client retries with exponential backoff
|
|
257
308
|
(1s → 30s capped, forever) while the credential is still valid; if the
|
|
@@ -346,15 +397,27 @@ Examples:
|
|
|
346
397
|
if (!isJsonMode()) {
|
|
347
398
|
console.error('Fetching MQTT credentials from SwitchBot service…');
|
|
348
399
|
}
|
|
400
|
+
// P7: streaming JSON contract — first line under --json is the stream
|
|
401
|
+
// header (mqtt events arrive via push cadence). Must emit BEFORE
|
|
402
|
+
// __session_start so header is always the very first line.
|
|
403
|
+
if (isJsonMode())
|
|
404
|
+
emitStreamHeader({ eventKind: 'event', cadence: 'push' });
|
|
349
405
|
// Emit a __session_start envelope immediately (before any credential
|
|
350
406
|
// fetch) so JSON consumers can distinguish "connecting" from "never
|
|
351
407
|
// connected" even when mqtt-tail exits before the broker connects.
|
|
352
408
|
if (isJsonMode()) {
|
|
409
|
+
const sessionStartAt = new Date().toISOString();
|
|
353
410
|
printJson({
|
|
354
|
-
|
|
355
|
-
|
|
411
|
+
schemaVersion: EVENTS_SCHEMA_VERSION,
|
|
412
|
+
source: 'mqtt',
|
|
413
|
+
kind: 'control',
|
|
414
|
+
controlKind: 'session_start',
|
|
415
|
+
t: sessionStartAt,
|
|
356
416
|
eventId: crypto.randomUUID(),
|
|
357
417
|
state: 'connecting',
|
|
418
|
+
// Legacy (deprecated as of v2.7; removed in v3.0):
|
|
419
|
+
type: '__session_start',
|
|
420
|
+
at: sessionStartAt,
|
|
358
421
|
});
|
|
359
422
|
}
|
|
360
423
|
const credential = await fetchMqttCredential(loaded.token, loaded.secret);
|
|
@@ -389,7 +452,16 @@ Examples:
|
|
|
389
452
|
// Default behavior: record history + print to stdout
|
|
390
453
|
const { deviceId, deviceType } = parseSinkEvent(parsed);
|
|
391
454
|
deviceHistoryStore.record(deviceId, msgTopic, deviceType, parsed, t);
|
|
392
|
-
const record = {
|
|
455
|
+
const record = {
|
|
456
|
+
schemaVersion: EVENTS_SCHEMA_VERSION,
|
|
457
|
+
source: 'mqtt',
|
|
458
|
+
kind: 'event',
|
|
459
|
+
t,
|
|
460
|
+
eventId,
|
|
461
|
+
deviceId: deviceId ?? null,
|
|
462
|
+
topic: msgTopic,
|
|
463
|
+
payload: parsed,
|
|
464
|
+
};
|
|
393
465
|
if (isJsonMode()) {
|
|
394
466
|
printJson(record);
|
|
395
467
|
}
|
|
@@ -405,7 +477,24 @@ Examples:
|
|
|
405
477
|
let mqttFailed = false;
|
|
406
478
|
let hasConnectedBefore = false;
|
|
407
479
|
const emitControl = (kind) => {
|
|
408
|
-
const
|
|
480
|
+
const at = new Date().toISOString();
|
|
481
|
+
const controlKindMap = {
|
|
482
|
+
__connect: 'connect',
|
|
483
|
+
__reconnect: 'reconnect',
|
|
484
|
+
__disconnect: 'disconnect',
|
|
485
|
+
__heartbeat: 'heartbeat',
|
|
486
|
+
};
|
|
487
|
+
const ctl = {
|
|
488
|
+
schemaVersion: EVENTS_SCHEMA_VERSION,
|
|
489
|
+
source: 'mqtt',
|
|
490
|
+
kind: 'control',
|
|
491
|
+
controlKind: controlKindMap[kind],
|
|
492
|
+
t: at,
|
|
493
|
+
eventId: crypto.randomUUID(),
|
|
494
|
+
// Legacy (deprecated as of v2.7; removed in v3.0):
|
|
495
|
+
type: kind,
|
|
496
|
+
at,
|
|
497
|
+
};
|
|
409
498
|
// Control events always go to stdout as JSONL so consumers that
|
|
410
499
|
// filter real events by presence of `payload` can skip them.
|
|
411
500
|
if (isJsonMode()) {
|