@switchbot/openapi-cli 2.2.1 → 2.4.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 +2 -0
- package/dist/api/client.js +33 -1
- package/dist/commands/agent-bootstrap.js +125 -0
- package/dist/commands/batch.js +108 -14
- package/dist/commands/capabilities.js +200 -62
- package/dist/commands/config.js +132 -9
- package/dist/commands/devices.js +85 -12
- package/dist/commands/doctor.js +105 -14
- package/dist/commands/events.js +53 -4
- package/dist/commands/expand.js +1 -77
- package/dist/commands/history.js +124 -2
- package/dist/commands/mcp.js +81 -2
- package/dist/commands/quota.js +4 -2
- package/dist/commands/schema.js +95 -5
- package/dist/config.js +71 -2
- package/dist/devices/history-query.js +181 -0
- package/dist/devices/param-validator.js +263 -0
- package/dist/index.js +12 -2
- package/dist/lib/devices.js +34 -15
- package/dist/lib/idempotency.js +56 -22
- package/dist/mcp/device-history.js +75 -7
- package/dist/utils/arg-parsers.js +4 -1
- package/dist/utils/audit.js +66 -1
- package/dist/utils/flags.js +18 -0
- package/dist/utils/name-resolver.js +76 -28
- package/dist/utils/output.js +115 -19
- package/dist/utils/quota.js +14 -0
- package/dist/utils/redact.js +68 -0
- package/package.json +1 -1
|
@@ -1,5 +1,65 @@
|
|
|
1
1
|
import { getEffectiveCatalog } from '../devices/catalog.js';
|
|
2
2
|
import { printJson } from '../utils/output.js';
|
|
3
|
+
import { enumArg, stringArg } from '../utils/arg-parsers.js';
|
|
4
|
+
const AGENT_GUIDE = {
|
|
5
|
+
safetyTiers: {
|
|
6
|
+
read: 'No state mutation; safe to call freely — does not consume quota unless noted.',
|
|
7
|
+
action: 'Mutates device or cloud state but is reversible and routine (turnOn, setColor).',
|
|
8
|
+
destructive: 'Hard to reverse / physical-world side effects (unlock, garage open, delete key). Requires explicit user confirmation.',
|
|
9
|
+
},
|
|
10
|
+
verifiability: {
|
|
11
|
+
local: 'Result is fully verifiable from the CLI return value itself.',
|
|
12
|
+
deviceConfirmed: 'Device returns an ack with an observable state field.',
|
|
13
|
+
deviceDependent: 'Verifiability depends on the specific device (IR is never verifiable).',
|
|
14
|
+
none: 'No feedback — e.g. IR transmission. Pair with an external sensor to confirm.',
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
const COMMAND_META = {
|
|
18
|
+
// devices: reads
|
|
19
|
+
'devices list': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 600 },
|
|
20
|
+
'devices status': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
|
|
21
|
+
'devices describe': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 600 },
|
|
22
|
+
'devices types': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
|
|
23
|
+
'devices commands': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
|
|
24
|
+
'devices watch': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
|
|
25
|
+
// devices: actions
|
|
26
|
+
'devices command': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 800 },
|
|
27
|
+
'devices batch': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1200 },
|
|
28
|
+
// scenes
|
|
29
|
+
'scenes list': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
|
|
30
|
+
'scenes execute': { mutating: true, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1500 },
|
|
31
|
+
// webhook
|
|
32
|
+
'webhook setup': { mutating: true, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 500 },
|
|
33
|
+
'webhook query': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
|
|
34
|
+
'webhook delete': { mutating: true, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'destructive', verifiability: 'local', typicalLatencyMs: 500 },
|
|
35
|
+
// quota
|
|
36
|
+
'quota status': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
|
|
37
|
+
'quota show': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
|
|
38
|
+
'quota reset': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 10 },
|
|
39
|
+
// doctor / schema / capabilities / catalog / config / cache / events / history / plan
|
|
40
|
+
'doctor': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 900 },
|
|
41
|
+
'schema export': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
|
|
42
|
+
'capabilities': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 15 },
|
|
43
|
+
'catalog': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 15 },
|
|
44
|
+
'config set-token': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'destructive', verifiability: 'local', typicalLatencyMs: 5 },
|
|
45
|
+
'config show': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
|
|
46
|
+
'config list-profiles': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
|
|
47
|
+
'cache status': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
|
|
48
|
+
'cache clear': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 5 },
|
|
49
|
+
'events mqtt-tail': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
|
|
50
|
+
'history show': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
|
|
51
|
+
'history replay': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1000 },
|
|
52
|
+
'history range': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 50 },
|
|
53
|
+
'history stats': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
|
|
54
|
+
'plan run': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 2000 },
|
|
55
|
+
'plan validate': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
|
|
56
|
+
'plan schema': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
|
|
57
|
+
'completion': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
|
|
58
|
+
'mcp serve': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
|
|
59
|
+
};
|
|
60
|
+
function metaFor(command) {
|
|
61
|
+
return COMMAND_META[command] ?? null;
|
|
62
|
+
}
|
|
3
63
|
const IDENTITY = {
|
|
4
64
|
product: 'SwitchBot',
|
|
5
65
|
domain: 'IoT smart home device control',
|
|
@@ -27,84 +87,162 @@ const MCP_TOOLS = [
|
|
|
27
87
|
'search_catalog',
|
|
28
88
|
'account_overview',
|
|
29
89
|
'get_device_history',
|
|
90
|
+
'query_device_history',
|
|
30
91
|
];
|
|
92
|
+
const IDEMPOTENCY_CONTRACT = {
|
|
93
|
+
flag: '--idempotency-key <key>',
|
|
94
|
+
windowSeconds: 60,
|
|
95
|
+
replayBehavior: 'Same (command, parameter, deviceId) within window → returns cached result with replayed:true.',
|
|
96
|
+
conflictBehavior: 'Same key + different (command, parameter) within window → exit 2, error:"idempotency_conflict".',
|
|
97
|
+
keyStorage: 'In-memory SHA-256 fingerprint; raw key never stored, no disk persistence.',
|
|
98
|
+
scope: 'Process-local. Replay + conflict apply within a single long-lived process (MCP session, devices batch, plan run, history replay). Independent CLI invocations do NOT share cache — each fresh `node` process starts empty.',
|
|
99
|
+
mcp: 'MCP send_command accepts the same idempotencyKey field with identical semantics.',
|
|
100
|
+
};
|
|
101
|
+
function enumerateLeaves(program, prefix = '') {
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const cmd of program.commands) {
|
|
104
|
+
const full = prefix ? `${prefix} ${cmd.name()}` : cmd.name();
|
|
105
|
+
if (cmd.commands.length === 0) {
|
|
106
|
+
const meta = metaFor(full);
|
|
107
|
+
if (meta) {
|
|
108
|
+
out.push({ name: full, ...meta });
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
// Unknown leaf → default to read-safe with a warning flag so agents notice.
|
|
112
|
+
out.push({
|
|
113
|
+
name: full,
|
|
114
|
+
mutating: false,
|
|
115
|
+
consumesQuota: false,
|
|
116
|
+
idempotencySupported: false,
|
|
117
|
+
agentSafetyTier: 'read',
|
|
118
|
+
verifiability: 'local',
|
|
119
|
+
typicalLatencyMs: 50,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
out.push(...enumerateLeaves(cmd, full));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
function projectObject(obj, fields) {
|
|
130
|
+
const out = {};
|
|
131
|
+
for (const f of fields) {
|
|
132
|
+
if (f in obj)
|
|
133
|
+
out[f] = obj[f];
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
31
137
|
export function registerCapabilitiesCommand(program) {
|
|
138
|
+
const SURFACES = ['cli', 'mcp', 'plan', 'mqtt', 'all'];
|
|
32
139
|
program
|
|
33
140
|
.command('capabilities')
|
|
34
141
|
.description('Print a machine-readable manifest of CLI capabilities (for agent bootstrap)')
|
|
35
|
-
.option('--minimal', 'Omit per-subcommand flag details to reduce output size')
|
|
142
|
+
.option('--minimal', 'Omit per-subcommand flag details to reduce output size (alias of --compact)')
|
|
143
|
+
.option('--compact', 'Emit a compact summary: identity + leaf command list with safety metadata only')
|
|
144
|
+
.option('--surface <s>', 'Restrict surfaces block to one of: cli, mcp, plan, mqtt, all (default: all)', enumArg('--surface', SURFACES))
|
|
145
|
+
.option('--project <csv>', 'Project top-level fields (e.g. --project identity,commands,agentGuide)', stringArg('--project'))
|
|
36
146
|
.action((opts) => {
|
|
147
|
+
const compact = Boolean(opts.minimal || opts.compact);
|
|
37
148
|
const catalog = getEffectiveCatalog();
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
149
|
+
const leaves = enumerateLeaves(program);
|
|
150
|
+
const fullCommands = compact
|
|
151
|
+
? undefined
|
|
152
|
+
: [
|
|
153
|
+
...program.commands,
|
|
154
|
+
{ name: () => 'help', description: () => 'Display help for a command', commands: [], options: [], registeredArguments: [] },
|
|
155
|
+
].map((c) => {
|
|
156
|
+
const full = c.name();
|
|
157
|
+
const entry = {
|
|
158
|
+
name: full,
|
|
159
|
+
description: c.description(),
|
|
160
|
+
};
|
|
161
|
+
entry.subcommands = c.commands.map((s) => {
|
|
162
|
+
const leafName = `${full} ${s.name()}`;
|
|
163
|
+
const meta = metaFor(leafName);
|
|
164
|
+
return {
|
|
165
|
+
name: s.name(),
|
|
166
|
+
description: s.description(),
|
|
167
|
+
args: s.registeredArguments.map((a) => ({
|
|
168
|
+
name: a.name(),
|
|
169
|
+
required: a.required,
|
|
170
|
+
variadic: a.variadic,
|
|
171
|
+
})),
|
|
172
|
+
flags: s.options.map((o) => ({
|
|
173
|
+
flags: o.flags,
|
|
174
|
+
description: o.description,
|
|
175
|
+
})),
|
|
176
|
+
...(meta ?? {}),
|
|
177
|
+
};
|
|
178
|
+
});
|
|
179
|
+
const selfMeta = metaFor(full);
|
|
180
|
+
if (selfMeta)
|
|
181
|
+
Object.assign(entry, selfMeta);
|
|
182
|
+
return entry;
|
|
183
|
+
});
|
|
184
|
+
const globalFlags = compact
|
|
185
|
+
? undefined
|
|
186
|
+
: program.options.map((opt) => ({ flags: opt.flags, description: opt.description }));
|
|
187
|
+
const surfaces = {
|
|
188
|
+
mcp: {
|
|
189
|
+
entry: 'mcp serve',
|
|
190
|
+
protocol: 'stdio (default) or --port <n> for HTTP',
|
|
191
|
+
tools: MCP_TOOLS,
|
|
192
|
+
resources: ['switchbot://events'],
|
|
193
|
+
toolMeta: 'Each MCP tool mirrors the CLI leaf command metadata (mutating, consumesQuota, agentSafetyTier, idempotencySupported).',
|
|
194
|
+
},
|
|
195
|
+
mqtt: {
|
|
196
|
+
mode: 'consumer',
|
|
197
|
+
authSource: 'SWITCHBOT_TOKEN + SWITCHBOT_SECRET (auto-provisioned via POST /v1.1/iot/credential)',
|
|
198
|
+
cliCmd: 'events mqtt-tail',
|
|
199
|
+
mcpResource: 'switchbot://events',
|
|
200
|
+
protocol: 'MQTTS with TLS client certificates (AWS IoT)',
|
|
201
|
+
},
|
|
202
|
+
plan: {
|
|
203
|
+
schemaCmd: 'plan schema',
|
|
204
|
+
validateCmd: 'plan validate -',
|
|
205
|
+
runCmd: 'plan run -',
|
|
206
|
+
},
|
|
207
|
+
cli: {
|
|
208
|
+
catalogCmd: 'schema export',
|
|
209
|
+
discoveryCmd: 'capabilities',
|
|
210
|
+
healthCmd: 'doctor --json',
|
|
211
|
+
healthCmdSchemaVersion: 1,
|
|
212
|
+
helpFlag: '--help',
|
|
213
|
+
idempotencyContract: IDEMPOTENCY_CONTRACT,
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
const filteredSurfaces = (() => {
|
|
217
|
+
if (!opts.surface || opts.surface === 'all')
|
|
218
|
+
return surfaces;
|
|
219
|
+
const picked = {};
|
|
220
|
+
if (opts.surface in surfaces) {
|
|
221
|
+
picked[opts.surface] = surfaces[opts.surface];
|
|
62
222
|
}
|
|
63
|
-
return
|
|
64
|
-
});
|
|
65
|
-
const globalFlags = program.options.map((opt) => ({
|
|
66
|
-
flags: opt.flags,
|
|
67
|
-
description: opt.description,
|
|
68
|
-
}));
|
|
223
|
+
return picked;
|
|
224
|
+
})();
|
|
69
225
|
const roles = [...new Set(catalog.map((e) => e.role ?? 'other'))].sort();
|
|
70
|
-
|
|
226
|
+
const payload = {
|
|
71
227
|
version: program.version(),
|
|
72
|
-
|
|
228
|
+
schemaVersion: '2',
|
|
229
|
+
agentGuide: AGENT_GUIDE,
|
|
73
230
|
identity: IDENTITY,
|
|
74
|
-
surfaces:
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
protocol: 'stdio (default) or --port <n> for HTTP',
|
|
78
|
-
tools: MCP_TOOLS,
|
|
79
|
-
resources: ['switchbot://events'],
|
|
80
|
-
},
|
|
81
|
-
mqtt: {
|
|
82
|
-
mode: 'consumer',
|
|
83
|
-
authSource: 'SWITCHBOT_TOKEN + SWITCHBOT_SECRET (auto-provisioned via POST /v1.1/iot/credential)',
|
|
84
|
-
cliCmd: 'events mqtt-tail',
|
|
85
|
-
mcpResource: 'switchbot://events',
|
|
86
|
-
protocol: 'MQTTS with TLS client certificates (AWS IoT)',
|
|
87
|
-
},
|
|
88
|
-
plan: {
|
|
89
|
-
schemaCmd: 'plan schema',
|
|
90
|
-
validateCmd: 'plan validate -',
|
|
91
|
-
runCmd: 'plan run -',
|
|
92
|
-
},
|
|
93
|
-
cli: {
|
|
94
|
-
catalogCmd: 'schema export',
|
|
95
|
-
discoveryCmd: 'capabilities',
|
|
96
|
-
healthCmd: 'doctor --json',
|
|
97
|
-
helpFlag: '--help',
|
|
98
|
-
},
|
|
99
|
-
},
|
|
100
|
-
commands,
|
|
101
|
-
globalFlags,
|
|
231
|
+
surfaces: filteredSurfaces,
|
|
232
|
+
commands: compact ? leaves : fullCommands,
|
|
233
|
+
...(globalFlags ? { globalFlags } : {}),
|
|
102
234
|
catalog: {
|
|
103
235
|
typeCount: catalog.length,
|
|
104
236
|
roles,
|
|
105
237
|
destructiveCommandCount: catalog.reduce((n, e) => n + e.commands.filter((c) => c.destructive).length, 0),
|
|
106
238
|
readOnlyTypeCount: catalog.filter((e) => e.readOnly).length,
|
|
107
239
|
},
|
|
108
|
-
}
|
|
240
|
+
};
|
|
241
|
+
if (!compact)
|
|
242
|
+
payload.generatedAt = new Date().toISOString();
|
|
243
|
+
const projected = opts.project
|
|
244
|
+
? projectObject(payload, opts.project.split(',').map((s) => s.trim()).filter(Boolean))
|
|
245
|
+
: payload;
|
|
246
|
+
printJson(projected);
|
|
109
247
|
});
|
|
110
248
|
}
|
package/dist/commands/config.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import readline from 'node:readline';
|
|
2
3
|
import { execFileSync } from 'node:child_process';
|
|
3
4
|
import { stringArg } from '../utils/arg-parsers.js';
|
|
4
|
-
import {
|
|
5
|
+
import { intArg } from '../utils/arg-parsers.js';
|
|
6
|
+
import { saveConfig, showConfig, listProfiles, readProfileMeta } from '../config.js';
|
|
5
7
|
import { isJsonMode, printJson } from '../utils/output.js';
|
|
6
8
|
import chalk from 'chalk';
|
|
7
9
|
function parseEnvFile(file) {
|
|
@@ -31,6 +33,62 @@ function readFromOp(ref) {
|
|
|
31
33
|
const stdout = execFileSync('op', ['read', ref], { encoding: 'utf-8' });
|
|
32
34
|
return stdout.trim();
|
|
33
35
|
}
|
|
36
|
+
// Replace raw token/secret positional slots in process.argv with "***" so
|
|
37
|
+
// neither verbose traces nor crash dumps nor any later inspector observe them.
|
|
38
|
+
function scrubArgvCredentials() {
|
|
39
|
+
const argv = process.argv;
|
|
40
|
+
for (let i = 2; i < argv.length - 2; i++) {
|
|
41
|
+
if (argv[i] === 'config' && argv[i + 1] === 'set-token') {
|
|
42
|
+
// Slots i+2 and i+3 (if not option flags) are token/secret.
|
|
43
|
+
for (const off of [2, 3]) {
|
|
44
|
+
const slot = i + off;
|
|
45
|
+
if (slot < argv.length && !argv[slot].startsWith('-')) {
|
|
46
|
+
argv[slot] = '***';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function promptSecret(question) {
|
|
54
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: true });
|
|
55
|
+
const stdoutAny = process.stdout;
|
|
56
|
+
const mutableStdout = process.stderr;
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
process.stderr.write(question);
|
|
59
|
+
const stdin = process.stdin;
|
|
60
|
+
let answer = '';
|
|
61
|
+
const onData = (chunk) => {
|
|
62
|
+
const s = chunk.toString('utf-8');
|
|
63
|
+
for (const ch of s) {
|
|
64
|
+
if (ch === '\r' || ch === '\n') {
|
|
65
|
+
stdin.removeListener('data', onData);
|
|
66
|
+
if (stdin.setRawMode)
|
|
67
|
+
stdin.setRawMode(false);
|
|
68
|
+
stdin.pause();
|
|
69
|
+
process.stderr.write('\n');
|
|
70
|
+
rl.close();
|
|
71
|
+
resolve(answer);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (ch === '\u0003') {
|
|
75
|
+
process.exit(130);
|
|
76
|
+
}
|
|
77
|
+
if (ch === '\u007f' || ch === '\b') {
|
|
78
|
+
answer = answer.slice(0, -1);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
answer += ch;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
if (stdin.setRawMode)
|
|
85
|
+
stdin.setRawMode(true);
|
|
86
|
+
stdin.resume();
|
|
87
|
+
stdin.on('data', onData);
|
|
88
|
+
void stdoutAny;
|
|
89
|
+
void mutableStdout;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
34
92
|
export function registerConfigCommand(program) {
|
|
35
93
|
const config = program
|
|
36
94
|
.command('config')
|
|
@@ -53,18 +111,38 @@ Obtain your token/secret from the SwitchBot mobile app:
|
|
|
53
111
|
.option('--from-env-file <path>', 'Read SWITCHBOT_TOKEN and SWITCHBOT_SECRET from a dotenv file', stringArg('--from-env-file'))
|
|
54
112
|
.option('--from-op <tokenRef>', 'Read token via 1Password CLI (op read). Pair with --op-secret <ref>', stringArg('--from-op'))
|
|
55
113
|
.option('--op-secret <secretRef>', '1Password reference for the secret, used with --from-op', stringArg('--op-secret'))
|
|
114
|
+
.option('--label <text>', 'Human-friendly label for this profile (shown in config show / list-profiles)', stringArg('--label'))
|
|
115
|
+
.option('--description <text>', 'Longer description, e.g. "home account" or "work devices"', stringArg('--description'))
|
|
116
|
+
.option('--daily-cap <n>', 'Local cap on SwitchBot API calls per UTC day for this profile', intArg('--daily-cap', { min: 1 }))
|
|
117
|
+
.option('--default-flags <csv>', 'Comma-separated flags auto-applied for this profile (e.g. "--audit-log")', stringArg('--default-flags'))
|
|
56
118
|
.addHelpText('after', `
|
|
57
119
|
Examples:
|
|
58
|
-
|
|
59
|
-
$ switchbot
|
|
120
|
+
# Interactive (recommended) — credentials never touch shell history / ps listing
|
|
121
|
+
$ switchbot config set-token
|
|
122
|
+
Token: ****
|
|
123
|
+
Secret: ****
|
|
124
|
+
|
|
125
|
+
# Import from dotenv / 1Password (non-interactive, still safe)
|
|
60
126
|
$ switchbot config set-token --from-env-file ./.env
|
|
61
127
|
$ switchbot config set-token --from-op op://vault/switchbot/token --op-secret op://vault/switchbot/secret
|
|
62
128
|
|
|
129
|
+
# Advanced / non-interactive (DISCOURAGED — leaks to shell history)
|
|
130
|
+
$ switchbot config set-token <token> <secret>
|
|
131
|
+
$ switchbot --profile work config set-token <token> <secret>
|
|
132
|
+
|
|
63
133
|
Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<name>.json.
|
|
64
134
|
`)
|
|
65
135
|
.action(async (tokenArg, secretArg, options) => {
|
|
66
136
|
let token = tokenArg;
|
|
67
137
|
let secret = secretArg;
|
|
138
|
+
const hadPositional = tokenArg !== undefined && secretArg !== undefined;
|
|
139
|
+
// Scrub early: commander has already parsed the values, so we can safely
|
|
140
|
+
// rewrite argv before anything else (verbose trace, crash dumps, …) sees it.
|
|
141
|
+
if (hadPositional) {
|
|
142
|
+
scrubArgvCredentials();
|
|
143
|
+
console.error('⚠ Passing token/secret as positional arguments is discouraged — they may be persisted in shell history, process listings, and agent logs.');
|
|
144
|
+
console.error(' Prefer: switchbot config set-token (interactive), --from-env-file, or --from-op.');
|
|
145
|
+
}
|
|
68
146
|
if (options.fromEnvFile) {
|
|
69
147
|
if (!fs.existsSync(options.fromEnvFile)) {
|
|
70
148
|
const msg = `--from-env-file: file not found: ${options.fromEnvFile}`;
|
|
@@ -107,8 +185,26 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
|
|
|
107
185
|
process.exit(1);
|
|
108
186
|
}
|
|
109
187
|
}
|
|
188
|
+
// No credentials yet and stdin is a TTY → interactive prompt (safest path).
|
|
189
|
+
if ((!token || !secret) && !options.fromEnvFile && !options.fromOp && process.stdin.isTTY) {
|
|
190
|
+
if (isJsonMode()) {
|
|
191
|
+
const msg = 'Interactive mode cannot run under --json. Provide token/secret via --from-env-file, --from-op, or positional args.';
|
|
192
|
+
console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
|
|
193
|
+
process.exit(2);
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
if (!token)
|
|
197
|
+
token = (await promptSecret('Token: ')).trim();
|
|
198
|
+
if (!secret)
|
|
199
|
+
secret = (await promptSecret('Secret: ')).trim();
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
console.error('Interactive prompt failed.');
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
110
206
|
if (!token || !secret) {
|
|
111
|
-
const msg = 'Missing token/secret.
|
|
207
|
+
const msg = 'Missing token/secret. Run interactively, or use --from-env-file / --from-op, or pass positional arguments (discouraged).';
|
|
112
208
|
if (isJsonMode()) {
|
|
113
209
|
console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
|
|
114
210
|
}
|
|
@@ -117,7 +213,19 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
|
|
|
117
213
|
}
|
|
118
214
|
process.exit(2);
|
|
119
215
|
}
|
|
120
|
-
saveConfig(token, secret
|
|
216
|
+
saveConfig(token, secret, {
|
|
217
|
+
label: options.label,
|
|
218
|
+
description: options.description,
|
|
219
|
+
limits: options.dailyCap ? { dailyCap: Number.parseInt(options.dailyCap, 10) } : undefined,
|
|
220
|
+
defaults: options.defaultFlags
|
|
221
|
+
? {
|
|
222
|
+
flags: options.defaultFlags
|
|
223
|
+
.split(',')
|
|
224
|
+
.map((s) => s.trim())
|
|
225
|
+
.filter(Boolean),
|
|
226
|
+
}
|
|
227
|
+
: undefined,
|
|
228
|
+
});
|
|
121
229
|
if (isJsonMode()) {
|
|
122
230
|
printJson({ ok: true, message: 'credentials saved' });
|
|
123
231
|
}
|
|
@@ -133,18 +241,33 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
|
|
|
133
241
|
});
|
|
134
242
|
config
|
|
135
243
|
.command('list-profiles')
|
|
136
|
-
.description('List named profiles under ~/.switchbot/profiles/')
|
|
244
|
+
.description('List named profiles under ~/.switchbot/profiles/ (with labels and daily caps)')
|
|
137
245
|
.action(() => {
|
|
138
246
|
const profiles = listProfiles();
|
|
247
|
+
const enriched = profiles.map((p) => {
|
|
248
|
+
const meta = readProfileMeta(p);
|
|
249
|
+
return {
|
|
250
|
+
name: p,
|
|
251
|
+
label: meta?.label,
|
|
252
|
+
description: meta?.description,
|
|
253
|
+
dailyCap: meta?.limits?.dailyCap,
|
|
254
|
+
};
|
|
255
|
+
});
|
|
139
256
|
if (isJsonMode()) {
|
|
140
|
-
printJson({ profiles });
|
|
257
|
+
printJson({ profiles: enriched });
|
|
141
258
|
return;
|
|
142
259
|
}
|
|
143
260
|
if (profiles.length === 0) {
|
|
144
261
|
console.log('No profiles. Create one with: switchbot --profile <name> config set-token ...');
|
|
145
262
|
return;
|
|
146
263
|
}
|
|
147
|
-
for (const p of
|
|
148
|
-
|
|
264
|
+
for (const p of enriched) {
|
|
265
|
+
const bits = [p.name];
|
|
266
|
+
if (p.label)
|
|
267
|
+
bits.push(`— ${p.label}`);
|
|
268
|
+
if (p.dailyCap)
|
|
269
|
+
bits.push(`[dailyCap=${p.dailyCap}]`);
|
|
270
|
+
console.log(bits.join(' '));
|
|
271
|
+
}
|
|
149
272
|
});
|
|
150
273
|
}
|