@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/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) {
|
|
@@ -89,10 +114,10 @@ export function startReceiver(port, pathMatch, filter, onEvent) {
|
|
|
89
114
|
if (size > MAX_BODY_BYTES) {
|
|
90
115
|
bailed = true;
|
|
91
116
|
res.statusCode = 413;
|
|
92
|
-
res.setHeader('connection', 'close');
|
|
93
117
|
res.end('payload too large');
|
|
94
|
-
//
|
|
95
|
-
|
|
118
|
+
// Drain remaining upload so the client can read the 413 response before
|
|
119
|
+
// the connection closes naturally (avoids ECONNRESET racing the response).
|
|
120
|
+
req.resume();
|
|
96
121
|
return;
|
|
97
122
|
}
|
|
98
123
|
chunks.push(c);
|
|
@@ -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()) {
|
package/dist/commands/expand.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { intArg, stringArg } from '../utils/arg-parsers.js';
|
|
2
|
-
import { handleError, isJsonMode, printJson, UsageError,
|
|
2
|
+
import { handleError, isJsonMode, printJson, UsageError, exitWithError } from '../utils/output.js';
|
|
3
3
|
import { getCachedDevice } from '../devices/cache.js';
|
|
4
4
|
import { executeCommand, isDestructiveCommand, getDestructiveReason } from '../lib/devices.js';
|
|
5
5
|
import { isDryRun } from '../utils/flags.js';
|
|
@@ -92,20 +92,12 @@ Examples:
|
|
|
92
92
|
}
|
|
93
93
|
if (!options.yes && !isDryRun() && isDestructiveCommand(deviceType, command, 'command')) {
|
|
94
94
|
const reason = getDestructiveReason(deviceType, command, 'command');
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
else {
|
|
104
|
-
console.error(`Refusing to run destructive command "${command}" without --yes.`);
|
|
105
|
-
if (reason)
|
|
106
|
-
console.error(`Reason: ${reason}`);
|
|
107
|
-
}
|
|
108
|
-
process.exit(2);
|
|
95
|
+
exitWithError({
|
|
96
|
+
code: 2,
|
|
97
|
+
kind: 'guard',
|
|
98
|
+
message: `"${command}" on ${deviceType || 'device'} is destructive and requires --yes.`,
|
|
99
|
+
hint: reason ? `Re-run with --yes. Reason: ${reason}` : 'Re-run with --yes to confirm.',
|
|
100
|
+
});
|
|
109
101
|
}
|
|
110
102
|
const body = await executeCommand(deviceId, command, parameter, 'command');
|
|
111
103
|
const isIr = cached?.category === 'ir';
|
package/dist/commands/explain.js
CHANGED
|
@@ -43,12 +43,15 @@ Examples:
|
|
|
43
43
|
}
|
|
44
44
|
const caps = desc.capabilities;
|
|
45
45
|
const commands = caps && 'commands' in caps
|
|
46
|
-
? caps.commands.map((c) =>
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
46
|
+
? caps.commands.map((c) => {
|
|
47
|
+
const tier = c.safetyTier;
|
|
48
|
+
return {
|
|
49
|
+
command: c.command,
|
|
50
|
+
parameter: c.parameter,
|
|
51
|
+
idempotent: c.idempotent,
|
|
52
|
+
...(tier ? { safetyTier: tier } : {}),
|
|
53
|
+
};
|
|
54
|
+
})
|
|
52
55
|
: [];
|
|
53
56
|
const statusFields = caps && 'statusFields' in caps ? caps.statusFields : [];
|
|
54
57
|
const liveStatus = caps && 'liveStatus' in caps ? caps.liveStatus : undefined;
|
|
@@ -110,7 +113,7 @@ function printHuman(r) {
|
|
|
110
113
|
if (r.commands.length) {
|
|
111
114
|
console.log('commands:');
|
|
112
115
|
for (const c of r.commands) {
|
|
113
|
-
const flags = [c.idempotent && 'idempotent', c.destructive && 'destructive']
|
|
116
|
+
const flags = [c.idempotent && 'idempotent', c.safetyTier === 'destructive' && 'destructive']
|
|
114
117
|
.filter(Boolean)
|
|
115
118
|
.join(', ');
|
|
116
119
|
const suffix = flags ? ` [${flags}]` : '';
|
package/dist/commands/history.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import { intArg, stringArg } from '../utils/arg-parsers.js';
|
|
4
|
-
import { printJson, isJsonMode, handleError, UsageError,
|
|
4
|
+
import { printJson, isJsonMode, handleError, UsageError, exitWithError } from '../utils/output.js';
|
|
5
5
|
import { readAudit, verifyAudit } from '../utils/audit.js';
|
|
6
6
|
import { executeCommand } from '../lib/devices.js';
|
|
7
7
|
import { queryDeviceHistory, queryDeviceHistoryStats, } from '../devices/history-query.js';
|
|
@@ -10,7 +10,7 @@ const DEFAULT_AUDIT = path.join(os.homedir(), '.switchbot', 'audit.log');
|
|
|
10
10
|
export function registerHistoryCommand(program) {
|
|
11
11
|
const history = program
|
|
12
12
|
.command('history')
|
|
13
|
-
.description('View and replay commands recorded via --audit-log')
|
|
13
|
+
.description('View and replay SwitchBot commands recorded via --audit-log')
|
|
14
14
|
.addHelpText('after', `
|
|
15
15
|
Every 'devices command' run with --audit-log is appended as JSONL to the
|
|
16
16
|
audit file (default ~/.switchbot/audit.log). 'history show' prints the file,
|
|
@@ -70,25 +70,19 @@ Examples:
|
|
|
70
70
|
const entries = readAudit(file);
|
|
71
71
|
const idx = Number(indexArg);
|
|
72
72
|
if (!Number.isInteger(idx) || idx < 1 || idx > entries.length) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
console.error(msg);
|
|
79
|
-
}
|
|
80
|
-
process.exit(2);
|
|
73
|
+
exitWithError({
|
|
74
|
+
code: 2,
|
|
75
|
+
kind: 'usage',
|
|
76
|
+
message: `Invalid index ${indexArg}. Log has ${entries.length} entries.`,
|
|
77
|
+
});
|
|
81
78
|
}
|
|
82
79
|
const entry = entries[idx - 1];
|
|
83
80
|
if (entry.kind !== 'command') {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
console.error(msg);
|
|
90
|
-
}
|
|
91
|
-
process.exit(2);
|
|
81
|
+
exitWithError({
|
|
82
|
+
code: 2,
|
|
83
|
+
kind: 'usage',
|
|
84
|
+
message: `Entry ${idx} is not a command (kind=${entry.kind}).`,
|
|
85
|
+
});
|
|
92
86
|
}
|
|
93
87
|
try {
|
|
94
88
|
const result = await executeCommand(entry.deviceId, entry.command, entry.parameter, entry.commandType);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for SwitchBot product identity.
|
|
3
|
+
*
|
|
4
|
+
* Consumed by:
|
|
5
|
+
* - `program.description()` / `--help` (via PRODUCT_TAGLINE in src/index.ts)
|
|
6
|
+
* - `--help --json` root (via src/utils/help-json.ts)
|
|
7
|
+
* - `switchbot capabilities` / `--json` (identity block)
|
|
8
|
+
* - `switchbot agent-bootstrap --json` (identity block)
|
|
9
|
+
*
|
|
10
|
+
* Keeping this in one file prevents drift between those four surfaces.
|
|
11
|
+
*
|
|
12
|
+
* IMPORTANT: the SwitchBot CLI only talks to the SwitchBot Cloud API over
|
|
13
|
+
* HTTPS. It does NOT drive BLE radios directly — BLE-only devices are
|
|
14
|
+
* reached by going through a SwitchBot Hub, which the Cloud API already
|
|
15
|
+
* handles transparently. Please do not reintroduce the word "BLE" into the
|
|
16
|
+
* tagline / README: it is misleading for AI agents reading `--help`.
|
|
17
|
+
*/
|
|
18
|
+
export const IDENTITY = {
|
|
19
|
+
product: 'SwitchBot',
|
|
20
|
+
domain: 'IoT smart home device control',
|
|
21
|
+
vendor: 'Wonderlabs, Inc.',
|
|
22
|
+
apiVersion: 'v1.1',
|
|
23
|
+
apiDocs: 'https://github.com/OpenWonderLabs/SwitchBotAPI',
|
|
24
|
+
// Product category keywords. AI agents scan these to judge scope
|
|
25
|
+
// ("does SwitchBot control door locks? air conditioners?") without
|
|
26
|
+
// parsing the full device catalog.
|
|
27
|
+
productCategories: [
|
|
28
|
+
'lights (bulbs / strips / color)',
|
|
29
|
+
'locks / keypads',
|
|
30
|
+
'curtains / blinds / shades',
|
|
31
|
+
'sensors (motion / contact / climate / water-leak)',
|
|
32
|
+
'plugs / strips',
|
|
33
|
+
'bots / mechanical pushers',
|
|
34
|
+
'robot vacuums',
|
|
35
|
+
'IR appliances via Hub (TV / AC / fan / projector)',
|
|
36
|
+
],
|
|
37
|
+
deviceCategories: {
|
|
38
|
+
physical: 'Wi-Fi-connected and Hub-mediated devices — controlled via Cloud API (CLI does not drive BLE directly)',
|
|
39
|
+
ir: 'IR remote devices learned by a SwitchBot Hub (TV, AC, fan, etc.)',
|
|
40
|
+
},
|
|
41
|
+
constraints: {
|
|
42
|
+
quotaPerDay: 10000,
|
|
43
|
+
hubRequiredForBle: true,
|
|
44
|
+
transport: 'Cloud API v1.1 (HTTPS)',
|
|
45
|
+
authMethod: 'HMAC-SHA256 token+secret',
|
|
46
|
+
},
|
|
47
|
+
agentGuide: 'docs/agent-guide.md',
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* One-line product description used for `program.description()` (the first
|
|
51
|
+
* line an AI agent sees when running `switchbot --help`).
|
|
52
|
+
*
|
|
53
|
+
* Structure: "SwitchBot smart home CLI — <product categories> via <transport>;
|
|
54
|
+
* <verbs: scenes, events, MCP>." Keep categories in sync with
|
|
55
|
+
* IDENTITY.productCategories above.
|
|
56
|
+
*/
|
|
57
|
+
export const PRODUCT_TAGLINE = 'SwitchBot smart home CLI — control lights, locks, curtains, sensors, plugs, ' +
|
|
58
|
+
'and IR appliances (TV/AC/fan) via Cloud API v1.1; run scenes, stream real-time ' +
|
|
59
|
+
'events, and integrate AI agents via MCP.';
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `switchbot install` — one-command bootstrap (Phase 3B in-repo).
|
|
3
|
+
*
|
|
4
|
+
* Collapses the 7-step Quickstart (credentials → policy → skill link →
|
|
5
|
+
* doctor verify) into a single orchestrated command with automatic
|
|
6
|
+
* rollback on any step failure. The step library
|
|
7
|
+
* (`src/install/default-steps.ts`) does the heavy lifting; this file
|
|
8
|
+
* composes the steps based on user flags, drives the step runner, and
|
|
9
|
+
* formats the outcome.
|
|
10
|
+
*
|
|
11
|
+
* Design notes:
|
|
12
|
+
* - `switchbot install` assumes the CLI is already on PATH (the user
|
|
13
|
+
* ran `npm i -g @switchbot/openapi-cli` to get here). We do not
|
|
14
|
+
* re-install the CLI from inside itself.
|
|
15
|
+
* - Doctor verification is NOT a step — if it failed, an automatic
|
|
16
|
+
* rollback would destroy good state. Instead we print a "next: run
|
|
17
|
+
* `switchbot doctor`" hint after success.
|
|
18
|
+
*/
|
|
19
|
+
import { InvalidArgumentError } from 'commander';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { resolvePolicyPath } from '../policy/load.js';
|
|
23
|
+
import { runInstall } from '../install/steps.js';
|
|
24
|
+
import { runPreflight } from '../install/preflight.js';
|
|
25
|
+
import { stepPromptCredentials, stepWriteKeychain, stepScaffoldPolicy, stepSymlinkSkill, stepDoctorVerify, } from '../install/default-steps.js';
|
|
26
|
+
import { isJsonMode, printJson } from '../utils/output.js';
|
|
27
|
+
import { getActiveProfile } from '../lib/request-context.js';
|
|
28
|
+
import chalk from 'chalk';
|
|
29
|
+
const AGENT_VALUES = ['claude-code', 'cursor', 'copilot', 'none'];
|
|
30
|
+
function parseAgent(value) {
|
|
31
|
+
if (!value)
|
|
32
|
+
return 'claude-code';
|
|
33
|
+
if (!AGENT_VALUES.includes(value)) {
|
|
34
|
+
throw new InvalidArgumentError(`--agent must be one of ${AGENT_VALUES.join(', ')} (got "${value}")`);
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
function parseSkipList(value) {
|
|
39
|
+
if (!value)
|
|
40
|
+
return new Set();
|
|
41
|
+
return new Set(value
|
|
42
|
+
.split(',')
|
|
43
|
+
.map((s) => s.trim())
|
|
44
|
+
.filter(Boolean));
|
|
45
|
+
}
|
|
46
|
+
function printRecipe(ctx) {
|
|
47
|
+
if (!ctx.skillRecipePrinted)
|
|
48
|
+
return;
|
|
49
|
+
const lines = [];
|
|
50
|
+
lines.push('');
|
|
51
|
+
lines.push(chalk.bold(`Skill-install recipe for agent=${ctx.agent}:`));
|
|
52
|
+
switch (ctx.agent) {
|
|
53
|
+
case 'claude-code':
|
|
54
|
+
lines.push(' # re-run with --skill-path pointing at your local clone of openclaw-switchbot-skill', ' switchbot install --agent claude-code --skill-path /path/to/openclaw-switchbot-skill');
|
|
55
|
+
break;
|
|
56
|
+
case 'cursor':
|
|
57
|
+
lines.push(' # Cursor expects a rules file, not a skill directory. See:', ' # openclaw-switchbot-skill/docs/agents/cursor.md');
|
|
58
|
+
break;
|
|
59
|
+
case 'copilot':
|
|
60
|
+
lines.push(' # Copilot merges instructions into .github/copilot-instructions.md. See:', ' # openclaw-switchbot-skill/docs/agents/copilot.md');
|
|
61
|
+
break;
|
|
62
|
+
case 'none':
|
|
63
|
+
lines.push(' (none — skill step skipped)');
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
console.error(lines.join('\n'));
|
|
67
|
+
}
|
|
68
|
+
function printDryRun(steps, ctx) {
|
|
69
|
+
if (isJsonMode()) {
|
|
70
|
+
printJson({
|
|
71
|
+
dryRun: true,
|
|
72
|
+
profile: ctx.profile,
|
|
73
|
+
agent: ctx.agent,
|
|
74
|
+
skillPath: ctx.skillPath ?? null,
|
|
75
|
+
policyPath: ctx.policyPath,
|
|
76
|
+
steps: steps.map((s) => ({ name: s.name, description: s.description })),
|
|
77
|
+
});
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
console.log(chalk.bold('switchbot install — dry run'));
|
|
81
|
+
console.log(` profile: ${ctx.profile}`);
|
|
82
|
+
console.log(` agent: ${ctx.agent}`);
|
|
83
|
+
console.log(` skill: ${ctx.skillPath ?? '(none — recipe will be printed)'}`);
|
|
84
|
+
console.log(` policy: ${ctx.policyPath}`);
|
|
85
|
+
console.log('');
|
|
86
|
+
console.log(chalk.bold('Steps that would run (in order):'));
|
|
87
|
+
for (const s of steps) {
|
|
88
|
+
console.log(` • ${s.name}${s.description ? ` — ${s.description}` : ''}`);
|
|
89
|
+
}
|
|
90
|
+
console.log('');
|
|
91
|
+
console.log(chalk.dim('No changes made. Re-run without --dry-run to apply.'));
|
|
92
|
+
}
|
|
93
|
+
export function registerInstallCommand(program) {
|
|
94
|
+
program
|
|
95
|
+
.command('install')
|
|
96
|
+
.description('One-command bootstrap: credentials + policy + skill link (rolls back on failure)')
|
|
97
|
+
.option('--agent <name>', `target agent: ${AGENT_VALUES.join(' | ')} (default: claude-code)`)
|
|
98
|
+
.option('--skill-path <dir>', 'local clone of openclaw-switchbot-skill (enables auto-link)')
|
|
99
|
+
.option('--token-file <path>', 'two-line credential file (token, secret); read once and deleted on success')
|
|
100
|
+
.option('--skip <names>', 'comma-separated list of step names to skip (e.g. "scaffold-policy,symlink-skill")')
|
|
101
|
+
.option('--force', 'replace an existing skill symlink pointing at a different path; allow link even without SKILL.md')
|
|
102
|
+
.option('--verify', 'after a successful install, run `switchbot doctor --json` as a warn-only post-check')
|
|
103
|
+
.addHelpText('after', `
|
|
104
|
+
The global --dry-run flag previews the step list without making changes.
|
|
105
|
+
Global --json emits the install report as JSON to stdout.
|
|
106
|
+
|
|
107
|
+
Exit codes:
|
|
108
|
+
0 success
|
|
109
|
+
2 preflight check failed (nothing changed)
|
|
110
|
+
3 step failed; rollback completed
|
|
111
|
+
4 step failed; rollback had residue (see output)
|
|
112
|
+
|
|
113
|
+
Examples:
|
|
114
|
+
# Interactive install, Claude Code skill not linked (recipe printed):
|
|
115
|
+
switchbot install
|
|
116
|
+
|
|
117
|
+
# Full install with skill link:
|
|
118
|
+
switchbot install --skill-path ../openclaw-switchbot-skill
|
|
119
|
+
|
|
120
|
+
# Non-interactive (CI) install:
|
|
121
|
+
printf '%s\\n%s\\n' "$TOKEN" "$SECRET" > /tmp/sb-creds
|
|
122
|
+
switchbot install --token-file /tmp/sb-creds --skill-path ./skill
|
|
123
|
+
`)
|
|
124
|
+
.action(async (opts, command) => {
|
|
125
|
+
const agent = parseAgent(opts.agent);
|
|
126
|
+
const profile = getActiveProfile() ?? 'default';
|
|
127
|
+
const skip = parseSkipList(opts.skip);
|
|
128
|
+
const skillPath = opts.skillPath ? path.resolve(opts.skillPath) : undefined;
|
|
129
|
+
const tokenFile = opts.tokenFile ? path.resolve(opts.tokenFile) : undefined;
|
|
130
|
+
const force = Boolean(opts.force);
|
|
131
|
+
const verify = Boolean(opts.verify);
|
|
132
|
+
const globalOpts = command.parent?.opts() ?? {};
|
|
133
|
+
const dryRun = Boolean(globalOpts.dryRun);
|
|
134
|
+
// Pre-flight: read-only checks, never mutate anything.
|
|
135
|
+
const pf = await runPreflight({
|
|
136
|
+
agent,
|
|
137
|
+
expectSkillLink: agent === 'claude-code' && Boolean(skillPath),
|
|
138
|
+
});
|
|
139
|
+
if (!pf.ok) {
|
|
140
|
+
if (isJsonMode()) {
|
|
141
|
+
printJson({ ok: false, stage: 'preflight', preflight: pf });
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
console.error(chalk.red('✗ preflight failed — nothing changed'));
|
|
145
|
+
for (const c of pf.checks) {
|
|
146
|
+
const mark = c.status === 'fail' ? chalk.red('✗') : c.status === 'warn' ? chalk.yellow('!') : chalk.green('✓');
|
|
147
|
+
console.error(` ${mark} ${c.name}: ${c.message}`);
|
|
148
|
+
if (c.hint)
|
|
149
|
+
console.error(` hint: ${c.hint}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
process.exit(2);
|
|
153
|
+
}
|
|
154
|
+
const ctx = {
|
|
155
|
+
profile,
|
|
156
|
+
agent,
|
|
157
|
+
skillPath,
|
|
158
|
+
tokenFile,
|
|
159
|
+
policyPath: resolvePolicyPath(),
|
|
160
|
+
nonInteractive: !process.stdin.isTTY && !tokenFile,
|
|
161
|
+
};
|
|
162
|
+
const allSteps = [
|
|
163
|
+
stepPromptCredentials(),
|
|
164
|
+
stepWriteKeychain(),
|
|
165
|
+
stepScaffoldPolicy(),
|
|
166
|
+
stepSymlinkSkill({ force }),
|
|
167
|
+
];
|
|
168
|
+
const steps = allSteps.filter((s) => !skip.has(s.name));
|
|
169
|
+
if (dryRun) {
|
|
170
|
+
printDryRun(steps, ctx);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const report = await runInstall(steps, { context: ctx });
|
|
174
|
+
// Delete the token file now that credentials are committed.
|
|
175
|
+
if (report.ok && tokenFile) {
|
|
176
|
+
try {
|
|
177
|
+
fs.unlinkSync(tokenFile);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// non-fatal: credentials are already in the keychain
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// A7: opt-in post-install verification. Doctor is NEVER part of the
|
|
184
|
+
// rollback chain — a failing doctor after a good install would
|
|
185
|
+
// destroy working state. So we run it AFTER runInstall resolves, as
|
|
186
|
+
// a warn-only check. The outcome is reported but never flips the
|
|
187
|
+
// command's exit code.
|
|
188
|
+
if (report.ok && verify) {
|
|
189
|
+
const cliPath = process.argv[1] ?? '';
|
|
190
|
+
const step = stepDoctorVerify({ cliPath });
|
|
191
|
+
await step.execute(ctx);
|
|
192
|
+
}
|
|
193
|
+
if (isJsonMode()) {
|
|
194
|
+
printJson({
|
|
195
|
+
ok: report.ok,
|
|
196
|
+
profile: ctx.profile,
|
|
197
|
+
agent: ctx.agent,
|
|
198
|
+
report,
|
|
199
|
+
preflight: pf,
|
|
200
|
+
policyPath: ctx.policyPath,
|
|
201
|
+
policyScaffolded: ctx.policyScaffoldResult && !ctx.policyScaffoldResult.skipped,
|
|
202
|
+
skillLinkPath: ctx.skillLinkPath,
|
|
203
|
+
skillLinkCreated: Boolean(ctx.skillLinkCreated),
|
|
204
|
+
verify: verify ? { ok: ctx.doctorOk ?? null, report: ctx.doctorReport ?? null } : undefined,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
else if (report.ok) {
|
|
208
|
+
console.log(chalk.green('✓ install complete'));
|
|
209
|
+
if (ctx.skillLinkCreated)
|
|
210
|
+
console.log(` linked skill: ${ctx.skillLinkPath}`);
|
|
211
|
+
if (ctx.policyScaffoldResult?.skipped === false)
|
|
212
|
+
console.log(` wrote policy: ${ctx.policyScaffoldResult.policyPath}`);
|
|
213
|
+
printRecipe(ctx);
|
|
214
|
+
if (verify) {
|
|
215
|
+
if (ctx.doctorOk) {
|
|
216
|
+
console.log(chalk.green('✓ doctor --json: all green'));
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
console.log(chalk.yellow('! doctor --json reported issues — install is committed; run `switchbot doctor` to inspect'));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
console.log('');
|
|
223
|
+
console.log(chalk.bold('Next:'));
|
|
224
|
+
console.log(' switchbot doctor # verify the setup');
|
|
225
|
+
console.log(' switchbot devices list # smoke test');
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
console.error(chalk.red(`✗ install failed at step: ${report.failedAt}`));
|
|
229
|
+
const residue = report.outcomes.some((o) => o.status === 'rollback-failed');
|
|
230
|
+
for (const o of report.outcomes) {
|
|
231
|
+
const tag = o.status === 'succeeded' ? chalk.green('✓') :
|
|
232
|
+
o.status === 'failed' ? chalk.red('✗') :
|
|
233
|
+
o.status === 'rolled-back' ? chalk.yellow('↺') :
|
|
234
|
+
o.status === 'rollback-failed' ? chalk.red('!!') :
|
|
235
|
+
chalk.dim('·');
|
|
236
|
+
const msg = o.status === 'failed' || o.status === 'rollback-failed' ? ` — ${o.error}` : '';
|
|
237
|
+
console.error(` ${tag} ${o.step} [${o.status}]${msg}`);
|
|
238
|
+
}
|
|
239
|
+
if (residue) {
|
|
240
|
+
console.error(chalk.red('Rollback left residue. Run `switchbot uninstall` to clean up or review output above.'));
|
|
241
|
+
process.exit(4);
|
|
242
|
+
}
|
|
243
|
+
process.exit(3);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|