@switchbot/openapi-cli 3.0.0 → 3.1.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 +108 -12
- package/dist/api/client.js +23 -1
- package/dist/commands/batch.js +20 -4
- package/dist/commands/capabilities.js +155 -65
- package/dist/commands/config.js +79 -0
- package/dist/commands/daemon.js +367 -0
- package/dist/commands/devices.js +62 -10
- package/dist/commands/doctor.js +233 -1
- package/dist/commands/health.js +113 -0
- package/dist/commands/mcp.js +93 -5
- package/dist/commands/plan.js +310 -130
- package/dist/commands/policy.js +120 -3
- package/dist/commands/rules.js +220 -2
- package/dist/commands/scenes.js +140 -0
- package/dist/commands/upgrade-check.js +88 -0
- package/dist/index.js +7 -0
- package/dist/lib/daemon-state.js +46 -0
- package/dist/lib/destructive-mode.js +12 -0
- package/dist/lib/devices.js +1 -0
- package/dist/lib/plan-store.js +68 -0
- package/dist/policy/schema/v0.2.json +29 -0
- package/dist/rules/conflict-analyzer.js +203 -0
- package/dist/rules/engine.js +195 -5
- package/dist/rules/throttle.js +42 -4
- package/dist/utils/health.js +101 -0
- package/dist/utils/output.js +72 -23
- package/dist/utils/retry.js +81 -0
- package/package.json +1 -1
package/dist/commands/config.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
2
4
|
import readline from 'node:readline';
|
|
3
5
|
import { execFileSync } from 'node:child_process';
|
|
4
6
|
import { stringArg } from '../utils/arg-parsers.js';
|
|
@@ -251,6 +253,24 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
|
|
|
251
253
|
}
|
|
252
254
|
else {
|
|
253
255
|
console.log(chalk.green('✓ Credentials saved'));
|
|
256
|
+
// Keychain-first hint: proactively suggest storing in OS keychain when
|
|
257
|
+
// the user is on a supported platform and saved to file (default path).
|
|
258
|
+
try {
|
|
259
|
+
const { selectCredentialStore } = await import('../credentials/keychain.js');
|
|
260
|
+
const store = await selectCredentialStore();
|
|
261
|
+
if (store.describe().backend === 'file') {
|
|
262
|
+
const platform = process.platform;
|
|
263
|
+
if (platform === 'darwin' || platform === 'win32') {
|
|
264
|
+
console.error(chalk.grey('Tip: Your OS supports a native keychain. Run `switchbot auth keychain store` to move credentials off the plain config file for better security.'));
|
|
265
|
+
}
|
|
266
|
+
else if (platform === 'linux') {
|
|
267
|
+
console.error(chalk.grey('Tip: If you have GNOME Keyring (secret-tool) installed, run `switchbot auth keychain store` to store credentials more securely.'));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
// Keychain probe failed — silently skip the tip
|
|
273
|
+
}
|
|
254
274
|
}
|
|
255
275
|
});
|
|
256
276
|
config
|
|
@@ -294,4 +314,63 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
|
|
|
294
314
|
console.log(bits.join(' '));
|
|
295
315
|
}
|
|
296
316
|
});
|
|
317
|
+
// switchbot config agent-profile [--write]
|
|
318
|
+
config
|
|
319
|
+
.command('agent-profile')
|
|
320
|
+
.description('Emit (or write) an agent-safe profile template with conservative rate limits and audit logging.')
|
|
321
|
+
.option('--write', 'Write the template to ~/.switchbot/profiles/agent.json (requires --profile agent config set-token to add credentials).')
|
|
322
|
+
.option('--force', 'Overwrite an existing agent.json when used with --write.')
|
|
323
|
+
.addHelpText('after', `
|
|
324
|
+
Outputs a starter profile.json suitable for AI agent / MCP integration:
|
|
325
|
+
- dailyCap: 100 (conservative; prevents runaway automation)
|
|
326
|
+
- label: "agent"
|
|
327
|
+
- description: "AI agent profile — conservative limits + audit enabled"
|
|
328
|
+
- defaults: { auditLog: true } (enables audit logging by default)
|
|
329
|
+
|
|
330
|
+
After writing, add credentials:
|
|
331
|
+
$ switchbot --profile agent config set-token <token> <secret>
|
|
332
|
+
|
|
333
|
+
Then use the profile:
|
|
334
|
+
$ switchbot --profile agent devices list
|
|
335
|
+
`)
|
|
336
|
+
.action((opts) => {
|
|
337
|
+
const template = {
|
|
338
|
+
label: 'agent',
|
|
339
|
+
description: 'AI agent profile — conservative limits + audit enabled',
|
|
340
|
+
limits: {
|
|
341
|
+
dailyCap: 100,
|
|
342
|
+
},
|
|
343
|
+
defaults: {
|
|
344
|
+
auditLog: true,
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
if (opts.write) {
|
|
348
|
+
const dir = path.join(os.homedir(), '.switchbot', 'profiles');
|
|
349
|
+
const dest = path.join(dir, 'agent.json');
|
|
350
|
+
if (!opts.force && fs.existsSync(dest)) {
|
|
351
|
+
exitWithError({ code: 2, kind: 'usage', message: `Agent profile already exists: ${dest}. Use --force to overwrite.` });
|
|
352
|
+
}
|
|
353
|
+
if (!fs.existsSync(dir)) {
|
|
354
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
355
|
+
}
|
|
356
|
+
fs.writeFileSync(dest, JSON.stringify(template, null, 2), { mode: 0o600 });
|
|
357
|
+
if (isJsonMode()) {
|
|
358
|
+
printJson({ ok: true, path: dest, template });
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
console.log(`Agent profile written: ${dest}`);
|
|
362
|
+
console.log(`Next: switchbot --profile agent config set-token <token> <secret>`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
if (isJsonMode()) {
|
|
367
|
+
printJson(template);
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
console.log(JSON.stringify(template, null, 2));
|
|
371
|
+
console.log('');
|
|
372
|
+
console.log('Write with: switchbot config agent-profile --write');
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
});
|
|
297
376
|
}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { isJsonMode, printJson, exitWithError } from '../utils/output.js';
|
|
6
|
+
import { readPidFile, writePidFile, isPidAlive, getDefaultPidFilePaths, writeReloadSentinel, sighupSupported } from '../rules/pid-file.js';
|
|
7
|
+
import { stringArg } from '../utils/arg-parsers.js';
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import { DAEMON_LOG_FILE, DAEMON_PID_FILE, DAEMON_STATE_FILE, HEALTHZ_PID_FILE, readDaemonState, writeDaemonState, } from '../lib/daemon-state.js';
|
|
10
|
+
function readDaemonPid() {
|
|
11
|
+
return readPidFile(DAEMON_PID_FILE);
|
|
12
|
+
}
|
|
13
|
+
function readHealthPid() {
|
|
14
|
+
return readPidFile(HEALTHZ_PID_FILE);
|
|
15
|
+
}
|
|
16
|
+
function killIfAlive(pid, signal = 'SIGTERM') {
|
|
17
|
+
if (!pid)
|
|
18
|
+
return;
|
|
19
|
+
if (!isPidAlive(pid))
|
|
20
|
+
return;
|
|
21
|
+
process.kill(pid, signal);
|
|
22
|
+
}
|
|
23
|
+
function buildHealthSummary(state) {
|
|
24
|
+
const healthPid = readHealthPid();
|
|
25
|
+
const healthRunning = healthPid !== null && isPidAlive(healthPid);
|
|
26
|
+
const port = state?.healthzPort ?? null;
|
|
27
|
+
return {
|
|
28
|
+
configured: port !== null,
|
|
29
|
+
pid: healthRunning ? healthPid : null,
|
|
30
|
+
pidFile: HEALTHZ_PID_FILE,
|
|
31
|
+
port,
|
|
32
|
+
running: healthRunning,
|
|
33
|
+
url: port !== null ? `http://127.0.0.1:${port}/healthz` : null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function getDaemonStatus() {
|
|
37
|
+
const state = readDaemonState();
|
|
38
|
+
const pid = readDaemonPid();
|
|
39
|
+
const running = pid !== null && isPidAlive(pid);
|
|
40
|
+
return {
|
|
41
|
+
status: running ? 'running' : 'stopped',
|
|
42
|
+
pid: running ? pid : null,
|
|
43
|
+
pidFile: DAEMON_PID_FILE,
|
|
44
|
+
logFile: DAEMON_LOG_FILE,
|
|
45
|
+
stateFile: DAEMON_STATE_FILE,
|
|
46
|
+
health: buildHealthSummary(state),
|
|
47
|
+
lastReloadAt: state?.lastReloadAt ?? null,
|
|
48
|
+
lastReloadStatus: state?.lastReloadStatus ?? null,
|
|
49
|
+
lastReloadMessage: state?.lastReloadMessage ?? null,
|
|
50
|
+
startedAt: state?.startedAt ?? null,
|
|
51
|
+
stoppedAt: state?.stoppedAt ?? null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function persistState(partial) {
|
|
55
|
+
const previous = readDaemonState();
|
|
56
|
+
const next = {
|
|
57
|
+
...(previous ?? {}),
|
|
58
|
+
status: partial.status ?? previous?.status ?? 'stopped',
|
|
59
|
+
pid: partial.pid ?? previous?.pid ?? null,
|
|
60
|
+
...partial,
|
|
61
|
+
logFile: DAEMON_LOG_FILE,
|
|
62
|
+
pidFile: DAEMON_PID_FILE,
|
|
63
|
+
stateFile: DAEMON_STATE_FILE,
|
|
64
|
+
};
|
|
65
|
+
writeDaemonState(next);
|
|
66
|
+
return next;
|
|
67
|
+
}
|
|
68
|
+
function renderHumanStatus(status) {
|
|
69
|
+
if (status.status === 'running' && status.pid !== null) {
|
|
70
|
+
console.log(`${chalk.green('●')} Daemon is running (pid ${status.pid}).`);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
console.log(`${chalk.grey('○')} Daemon is not running.`);
|
|
74
|
+
}
|
|
75
|
+
if (status.startedAt)
|
|
76
|
+
console.log(` Started: ${status.startedAt}`);
|
|
77
|
+
if (status.stoppedAt)
|
|
78
|
+
console.log(` Stopped: ${status.stoppedAt}`);
|
|
79
|
+
console.log(` Log: ${status.logFile}`);
|
|
80
|
+
console.log(` PID: ${status.pidFile}`);
|
|
81
|
+
console.log(` State: ${status.stateFile}`);
|
|
82
|
+
if (status.lastReloadAt) {
|
|
83
|
+
console.log(` Reload: ${status.lastReloadAt} (${status.lastReloadStatus ?? 'unknown'})`);
|
|
84
|
+
if (status.lastReloadMessage)
|
|
85
|
+
console.log(` ${status.lastReloadMessage}`);
|
|
86
|
+
}
|
|
87
|
+
if (status.health.configured) {
|
|
88
|
+
if (status.health.running) {
|
|
89
|
+
console.log(`${chalk.green('✓')} Health server running (pid ${status.health.pid})`);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
console.log(`${chalk.yellow('!')} Health server configured but not running`);
|
|
93
|
+
}
|
|
94
|
+
if (status.health.url)
|
|
95
|
+
console.log(` Health: ${status.health.url}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export function registerDaemonCommand(program) {
|
|
99
|
+
const daemon = program
|
|
100
|
+
.command('daemon')
|
|
101
|
+
.description('Manage the background SwitchBot rules daemon and its health endpoint.')
|
|
102
|
+
.addHelpText('after', `
|
|
103
|
+
The daemon runs \`switchbot rules run\` as a detached background process,
|
|
104
|
+
tracks runtime metadata in ~/.switchbot/daemon.state.json, and can optionally
|
|
105
|
+
co-launch a health endpoint via \`switchbot health serve\`.
|
|
106
|
+
|
|
107
|
+
Subcommands:
|
|
108
|
+
start [--policy <path>] Start the daemon (no-op if already running).
|
|
109
|
+
stop Stop the daemon and any co-launched health server.
|
|
110
|
+
status Report the daemon state, log path, and health summary.
|
|
111
|
+
reload Trigger a hot reload for the running rules engine.
|
|
112
|
+
|
|
113
|
+
The daemon reads the same policy file as \`switchbot rules run\`.
|
|
114
|
+
`);
|
|
115
|
+
daemon
|
|
116
|
+
.command('start')
|
|
117
|
+
.description('Start the rules-engine daemon in the background.')
|
|
118
|
+
.option('--policy <path>', 'Policy file path (default: auto-detected)', stringArg('--policy'))
|
|
119
|
+
.option('--force', 'Restart even if the daemon appears to be running.')
|
|
120
|
+
.option('--healthz-port <n>', 'Also start a health HTTP server on this port (default: disabled).')
|
|
121
|
+
.action((opts) => {
|
|
122
|
+
const current = getDaemonStatus();
|
|
123
|
+
if (current.status === 'running' && !opts.force) {
|
|
124
|
+
if (isJsonMode()) {
|
|
125
|
+
printJson({ result: 'already-running', ...current });
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
console.log(`Daemon is already running (pid ${current.pid}). Use --force to restart.`);
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (opts.force) {
|
|
133
|
+
try {
|
|
134
|
+
killIfAlive(current.pid);
|
|
135
|
+
killIfAlive(current.health.pid);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// best effort
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
142
|
+
const cliEntry = path.resolve(path.dirname(thisFile), 'index.js');
|
|
143
|
+
const args = ['rules', 'run'];
|
|
144
|
+
if (opts.policy)
|
|
145
|
+
args.push(opts.policy);
|
|
146
|
+
fs.mkdirSync(path.dirname(DAEMON_PID_FILE), { recursive: true, mode: 0o700 });
|
|
147
|
+
persistState({
|
|
148
|
+
status: 'starting',
|
|
149
|
+
pid: null,
|
|
150
|
+
startedAt: new Date().toISOString(),
|
|
151
|
+
stoppedAt: undefined,
|
|
152
|
+
failedAt: undefined,
|
|
153
|
+
failureReason: undefined,
|
|
154
|
+
healthzPort: opts.healthzPort ? Number.parseInt(opts.healthzPort, 10) : null,
|
|
155
|
+
healthzPid: null,
|
|
156
|
+
healthzPidFile: HEALTHZ_PID_FILE,
|
|
157
|
+
});
|
|
158
|
+
const logFd = fs.openSync(DAEMON_LOG_FILE, 'a');
|
|
159
|
+
const child = spawn(process.execPath, [cliEntry, ...args], {
|
|
160
|
+
detached: true,
|
|
161
|
+
stdio: ['ignore', logFd, logFd],
|
|
162
|
+
env: { ...process.env },
|
|
163
|
+
});
|
|
164
|
+
child.unref();
|
|
165
|
+
fs.closeSync(logFd);
|
|
166
|
+
const newPid = child.pid;
|
|
167
|
+
if (!newPid) {
|
|
168
|
+
persistState({
|
|
169
|
+
status: 'failed',
|
|
170
|
+
pid: null,
|
|
171
|
+
failedAt: new Date().toISOString(),
|
|
172
|
+
failureReason: 'Failed to spawn daemon process.',
|
|
173
|
+
});
|
|
174
|
+
exitWithError({ code: 1, kind: 'runtime', message: 'Failed to spawn daemon process.' });
|
|
175
|
+
}
|
|
176
|
+
writePidFile(DAEMON_PID_FILE, newPid);
|
|
177
|
+
let healthzPid = null;
|
|
178
|
+
let healthzPort = opts.healthzPort ? Number.parseInt(opts.healthzPort, 10) : null;
|
|
179
|
+
if (healthzPort !== null) {
|
|
180
|
+
const healthArgs = ['health', 'serve', '--port', String(healthzPort)];
|
|
181
|
+
const healthLogFd = fs.openSync(DAEMON_LOG_FILE, 'a');
|
|
182
|
+
const healthChild = spawn(process.execPath, [cliEntry, ...healthArgs], {
|
|
183
|
+
detached: true,
|
|
184
|
+
stdio: ['ignore', healthLogFd, healthLogFd],
|
|
185
|
+
env: { ...process.env },
|
|
186
|
+
});
|
|
187
|
+
healthChild.unref();
|
|
188
|
+
fs.closeSync(healthLogFd);
|
|
189
|
+
if (healthChild.pid) {
|
|
190
|
+
healthzPid = healthChild.pid;
|
|
191
|
+
writePidFile(HEALTHZ_PID_FILE, healthzPid);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
persistState({
|
|
195
|
+
status: 'running',
|
|
196
|
+
pid: newPid,
|
|
197
|
+
startedAt: new Date().toISOString(),
|
|
198
|
+
stoppedAt: undefined,
|
|
199
|
+
failedAt: undefined,
|
|
200
|
+
failureReason: undefined,
|
|
201
|
+
healthzPort,
|
|
202
|
+
healthzPid,
|
|
203
|
+
healthzPidFile: HEALTHZ_PID_FILE,
|
|
204
|
+
});
|
|
205
|
+
const status = getDaemonStatus();
|
|
206
|
+
if (isJsonMode()) {
|
|
207
|
+
printJson({ result: 'started', ...status });
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
console.log(`${chalk.green('✓')} Daemon started (pid ${newPid})`);
|
|
211
|
+
console.log(` Log: ${DAEMON_LOG_FILE}`);
|
|
212
|
+
console.log(` PID: ${DAEMON_PID_FILE}`);
|
|
213
|
+
console.log(` State: ${DAEMON_STATE_FILE}`);
|
|
214
|
+
console.log(` Reload: switchbot daemon reload`);
|
|
215
|
+
if (status.health.running && status.health.url) {
|
|
216
|
+
console.log(`${chalk.green('✓')} Health server started (pid ${status.health.pid})`);
|
|
217
|
+
console.log(` Health: ${status.health.url}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
daemon
|
|
222
|
+
.command('stop')
|
|
223
|
+
.description('Stop the background daemon by sending SIGTERM.')
|
|
224
|
+
.action(() => {
|
|
225
|
+
const status = getDaemonStatus();
|
|
226
|
+
if (status.status !== 'running' || status.pid === null) {
|
|
227
|
+
persistState({ status: 'stopped', pid: null, stoppedAt: new Date().toISOString() });
|
|
228
|
+
if (isJsonMode()) {
|
|
229
|
+
printJson({ result: 'not-running', ...getDaemonStatus() });
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
console.log(`No running daemon found (pid file: ${DAEMON_PID_FILE}).`);
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
killIfAlive(status.pid);
|
|
238
|
+
killIfAlive(status.health.pid);
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
persistState({
|
|
242
|
+
status: 'failed',
|
|
243
|
+
pid: status.pid,
|
|
244
|
+
failedAt: new Date().toISOString(),
|
|
245
|
+
failureReason: err instanceof Error ? err.message : String(err),
|
|
246
|
+
});
|
|
247
|
+
exitWithError({
|
|
248
|
+
code: 1,
|
|
249
|
+
kind: 'runtime',
|
|
250
|
+
message: `Failed to stop daemon (pid ${status.pid}): ${err instanceof Error ? err.message : String(err)}`,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
fs.unlinkSync(DAEMON_PID_FILE);
|
|
255
|
+
}
|
|
256
|
+
catch { /* best effort */ }
|
|
257
|
+
try {
|
|
258
|
+
fs.unlinkSync(HEALTHZ_PID_FILE);
|
|
259
|
+
}
|
|
260
|
+
catch { /* best effort */ }
|
|
261
|
+
persistState({
|
|
262
|
+
status: 'stopped',
|
|
263
|
+
pid: null,
|
|
264
|
+
healthzPid: null,
|
|
265
|
+
stoppedAt: new Date().toISOString(),
|
|
266
|
+
});
|
|
267
|
+
if (isJsonMode()) {
|
|
268
|
+
printJson({ result: 'stopped', ...getDaemonStatus() });
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
console.log(`${chalk.green('✓')} Daemon stopped (pid ${status.pid}).`);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
daemon
|
|
275
|
+
.command('status')
|
|
276
|
+
.description('Report whether the daemon is currently running.')
|
|
277
|
+
.action(() => {
|
|
278
|
+
const status = getDaemonStatus();
|
|
279
|
+
if (isJsonMode()) {
|
|
280
|
+
printJson(status);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
renderHumanStatus(status);
|
|
284
|
+
});
|
|
285
|
+
daemon
|
|
286
|
+
.command('reload')
|
|
287
|
+
.description('Trigger a hot reload on the running rules-engine daemon.')
|
|
288
|
+
.action(() => {
|
|
289
|
+
const daemonStatus = getDaemonStatus();
|
|
290
|
+
if (daemonStatus.status !== 'running' || daemonStatus.pid === null) {
|
|
291
|
+
persistState({
|
|
292
|
+
status: 'failed',
|
|
293
|
+
failedAt: new Date().toISOString(),
|
|
294
|
+
failureReason: 'No running daemon to reload.',
|
|
295
|
+
lastReloadAt: new Date().toISOString(),
|
|
296
|
+
lastReloadStatus: 'failed',
|
|
297
|
+
lastReloadMessage: 'No running daemon to reload.',
|
|
298
|
+
});
|
|
299
|
+
exitWithError({
|
|
300
|
+
code: 2,
|
|
301
|
+
kind: 'usage',
|
|
302
|
+
message: `No running daemon found (pid file: ${DAEMON_PID_FILE}).`,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
const pidPaths = getDefaultPidFilePaths();
|
|
306
|
+
const rulesPid = readPidFile(pidPaths.pidFile);
|
|
307
|
+
if (rulesPid === null || !isPidAlive(rulesPid)) {
|
|
308
|
+
persistState({
|
|
309
|
+
status: 'failed',
|
|
310
|
+
failedAt: new Date().toISOString(),
|
|
311
|
+
failureReason: 'Rules engine PID is missing or stale.',
|
|
312
|
+
lastReloadAt: new Date().toISOString(),
|
|
313
|
+
lastReloadStatus: 'failed',
|
|
314
|
+
lastReloadMessage: 'Rules engine PID is missing or stale.',
|
|
315
|
+
});
|
|
316
|
+
exitWithError({
|
|
317
|
+
code: 2,
|
|
318
|
+
kind: 'usage',
|
|
319
|
+
message: `No running rules engine found for daemon reload (pid file: ${pidPaths.pidFile}).`,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
let method;
|
|
323
|
+
try {
|
|
324
|
+
if (sighupSupported()) {
|
|
325
|
+
process.kill(rulesPid, 'SIGHUP');
|
|
326
|
+
method = 'SIGHUP';
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
writeReloadSentinel(pidPaths.reloadFile);
|
|
330
|
+
method = 'sentinel';
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
335
|
+
persistState({
|
|
336
|
+
status: 'failed',
|
|
337
|
+
failedAt: new Date().toISOString(),
|
|
338
|
+
failureReason: message,
|
|
339
|
+
lastReloadAt: new Date().toISOString(),
|
|
340
|
+
lastReloadStatus: 'failed',
|
|
341
|
+
lastReloadMessage: message,
|
|
342
|
+
});
|
|
343
|
+
exitWithError({
|
|
344
|
+
code: 1,
|
|
345
|
+
kind: 'runtime',
|
|
346
|
+
message: `Failed to reload daemon: ${message}`,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
persistState({
|
|
350
|
+
status: 'running',
|
|
351
|
+
lastReloadAt: new Date().toISOString(),
|
|
352
|
+
lastReloadStatus: 'ok',
|
|
353
|
+
lastReloadMessage: method === 'SIGHUP'
|
|
354
|
+
? `Sent SIGHUP to rules engine pid ${rulesPid}.`
|
|
355
|
+
: `Wrote reload sentinel ${pidPaths.reloadFile}.`,
|
|
356
|
+
});
|
|
357
|
+
const status = getDaemonStatus();
|
|
358
|
+
if (isJsonMode()) {
|
|
359
|
+
printJson({ result: 'reloaded', method, ...status });
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
console.log(`${chalk.green('✓')} Reload requested via ${method}.`);
|
|
363
|
+
if (status.lastReloadMessage)
|
|
364
|
+
console.log(` ${status.lastReloadMessage}`);
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
}
|
package/dist/commands/devices.js
CHANGED
|
@@ -16,6 +16,7 @@ import { registerDevicesMetaCommand } from './device-meta.js';
|
|
|
16
16
|
import { isDryRun } from '../utils/flags.js';
|
|
17
17
|
import { DryRunSignal } from '../api/client.js';
|
|
18
18
|
import { resolveField, resolveFieldList, listSupportedFieldInputs } from '../schema/field-aliases.js';
|
|
19
|
+
import { allowsDirectDestructiveExecution, destructiveExecutionHint } from '../lib/destructive-mode.js';
|
|
19
20
|
const EXPAND_HINTS = {
|
|
20
21
|
'Air Conditioner': { command: 'setAll', flags: '--temp 26 --mode cool --fan low --power on' },
|
|
21
22
|
'Curtain': { command: 'setPosition', flags: '--position 50 --mode silent' },
|
|
@@ -355,7 +356,8 @@ Examples:
|
|
|
355
356
|
.option('--name-category <cat>', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir']))
|
|
356
357
|
.option('--name-room <room>', 'Narrow --name by room name (substring match)', stringArg('--name-room'))
|
|
357
358
|
.option('--type <commandType>', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
|
|
358
|
-
.option('--yes', 'Confirm a destructive command
|
|
359
|
+
.option('--yes', 'Confirm a destructive command in an explicit dev profile. --dry-run is always allowed without --yes.')
|
|
360
|
+
.option('--explain', 'Print a human-readable summary of what this command would do (risk level, device type, idempotency) then exit without executing.')
|
|
359
361
|
.option('--allow-unknown-device', 'Allow targeting a deviceId that is not in the local cache. By default unknown IDs exit 2 so --dry-run is a reliable pre-flight gate; use this flag for scripted pass-through.')
|
|
360
362
|
.option('--skip-param-validation', 'Skip client-side parameter validation (escape hatch — prefer fixing the argument over using this).')
|
|
361
363
|
.option('--idempotency-key <key>', 'Client-supplied key to dedupe retries. process-local 60s window; cache is per Node process (MCP session, batch run, plan run). Independent CLI invocations do not share cache.', stringArg('--idempotency-key'))
|
|
@@ -393,8 +395,8 @@ Common errors:
|
|
|
393
395
|
|
|
394
396
|
Safety:
|
|
395
397
|
Destructive commands (Smart Lock unlock, Garage Door Opener turnOn/turnOff,
|
|
396
|
-
Keypad createKey/deleteKey, …) are blocked by default.
|
|
397
|
-
or --dry-run to preview without sending.
|
|
398
|
+
Keypad createKey/deleteKey, …) are blocked by default. Use the reviewed plan
|
|
399
|
+
flow instead, or --dry-run to preview without sending.
|
|
398
400
|
|
|
399
401
|
Examples:
|
|
400
402
|
$ switchbot devices command ABC123 turnOn
|
|
@@ -402,7 +404,7 @@ Examples:
|
|
|
402
404
|
$ switchbot devices command ABC123 setAll "26,1,3,on"
|
|
403
405
|
$ switchbot devices command ABC123 startClean '{"action":"sweep","param":{"fanLevel":2,"times":1}}'
|
|
404
406
|
$ switchbot devices command ABC123 "MyButton" --type customize
|
|
405
|
-
$ switchbot devices command <lockId> unlock --
|
|
407
|
+
$ switchbot devices command <lockId> unlock --dry-run
|
|
406
408
|
`)
|
|
407
409
|
.action(async (deviceIdArg, cmdArg, parameter, options) => {
|
|
408
410
|
// Declared outside try so the DryRunSignal catch branch can reference them.
|
|
@@ -507,22 +509,72 @@ Examples:
|
|
|
507
509
|
parameter = paramCheck.normalized;
|
|
508
510
|
}
|
|
509
511
|
const cachedForGuard = getCachedDevice(deviceId);
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
isDestructiveCommand(cachedForGuard?.type, cmd, options.type)
|
|
512
|
+
// --explain: print intent + risk metadata without executing
|
|
513
|
+
if (options.explain) {
|
|
514
|
+
const isDestructive = isDestructiveCommand(cachedForGuard?.type, cmd, options.type);
|
|
515
|
+
const reason = getDestructiveReason(cachedForGuard?.type, cmd, options.type);
|
|
516
|
+
const riskLevel = isDestructive ? 'high' : options.type === 'command' ? 'medium' : 'low';
|
|
517
|
+
const recommendedMode = isDestructive ? 'review-before-execute' : 'direct';
|
|
518
|
+
if (isJsonMode()) {
|
|
519
|
+
printJson({
|
|
520
|
+
intent: `Send command "${cmd}" to device ${deviceId}`,
|
|
521
|
+
deviceType: cachedForGuard?.type ?? 'unknown',
|
|
522
|
+
deviceName: cachedForGuard?.name ?? null,
|
|
523
|
+
command: cmd,
|
|
524
|
+
parameter: parameter ?? null,
|
|
525
|
+
commandType: options.type,
|
|
526
|
+
riskLevel,
|
|
527
|
+
requiresConfirmation: isDestructive,
|
|
528
|
+
safetyReason: reason ?? null,
|
|
529
|
+
recommendedMode,
|
|
530
|
+
note: 'This is a dry explanation only — command was NOT executed.',
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
else {
|
|
534
|
+
console.log(`Command: ${cmd} on device ${deviceId}`);
|
|
535
|
+
console.log(`Device type: ${cachedForGuard?.type ?? 'unknown'}${cachedForGuard?.name ? ` (${cachedForGuard.name})` : ''}`);
|
|
536
|
+
console.log(`Parameter: ${parameter ?? '(none)'}`);
|
|
537
|
+
console.log(`Risk level: ${riskLevel}`);
|
|
538
|
+
if (reason)
|
|
539
|
+
console.log(`Safety reason: ${reason}`);
|
|
540
|
+
if (isDestructive)
|
|
541
|
+
console.log(`Requires plan approval by default. ${destructiveExecutionHint()}`);
|
|
542
|
+
console.log('(not executed — remove --explain to run)');
|
|
543
|
+
}
|
|
544
|
+
process.exit(0);
|
|
545
|
+
}
|
|
546
|
+
const destructive = isDestructiveCommand(cachedForGuard?.type, cmd, options.type);
|
|
547
|
+
if (!isDryRun() && destructive && !options.yes && !allowsDirectDestructiveExecution()) {
|
|
548
|
+
const typeLabel = cachedForGuard?.type ?? 'unknown';
|
|
549
|
+
const reason = getDestructiveReason(cachedForGuard?.type, cmd, options.type);
|
|
550
|
+
exitWithError({
|
|
551
|
+
kind: 'guard',
|
|
552
|
+
message: `Direct destructive execution disabled — destructive command "${cmd}" on ${typeLabel}.`,
|
|
553
|
+
hint: reason ? `${destructiveExecutionHint()} Reason: ${reason}` : destructiveExecutionHint(),
|
|
554
|
+
context: {
|
|
555
|
+
command: cmd,
|
|
556
|
+
deviceType: typeLabel,
|
|
557
|
+
deviceId,
|
|
558
|
+
directExecutionAllowed: false,
|
|
559
|
+
requiredWorkflow: 'plan-approval',
|
|
560
|
+
...(reason ? { safetyReason: reason, destructiveReason: reason } : {}),
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
if (!options.yes && !isDryRun() && destructive) {
|
|
513
565
|
const typeLabel = cachedForGuard?.type ?? 'unknown';
|
|
514
566
|
const reason = getDestructiveReason(cachedForGuard?.type, cmd, options.type);
|
|
515
567
|
exitWithError({
|
|
516
568
|
kind: 'guard',
|
|
517
569
|
message: `Refusing to run destructive command "${cmd}" on ${typeLabel} without --yes.`,
|
|
518
570
|
hint: reason
|
|
519
|
-
? `Re-run with --yes
|
|
520
|
-
:
|
|
571
|
+
? `Re-run with --yes only from an explicit dev profile, or use the reviewed plan flow. Reason: ${reason}`
|
|
572
|
+
: `Re-run with --yes only from an explicit dev profile, use the reviewed plan flow, or --dry-run to preview without sending.`,
|
|
521
573
|
context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { safetyReason: reason, destructiveReason: reason } : {}) },
|
|
522
574
|
});
|
|
523
575
|
}
|
|
524
576
|
// Warn when --yes is given but the command is not destructive (no-op flag)
|
|
525
|
-
if (options.yes && !
|
|
577
|
+
if (options.yes && !destructive && !isDryRun()) {
|
|
526
578
|
console.error(`Note: --yes has no effect; "${cmd}" is not a destructive command.`);
|
|
527
579
|
}
|
|
528
580
|
// parameter may be a JSON object string (e.g. S10 startClean) or a plain string
|