@switchbot/openapi-cli 2.7.2 → 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 +481 -103
- package/dist/api/client.js +23 -1
- package/dist/commands/agent-bootstrap.js +47 -2
- package/dist/commands/auth.js +354 -0
- package/dist/commands/batch.js +20 -4
- package/dist/commands/capabilities.js +155 -65
- package/dist/commands/config.js +109 -0
- package/dist/commands/daemon.js +367 -0
- package/dist/commands/devices.js +62 -11
- package/dist/commands/doctor.js +417 -8
- package/dist/commands/events.js +3 -3
- package/dist/commands/explain.js +1 -2
- package/dist/commands/health.js +113 -0
- package/dist/commands/install.js +246 -0
- package/dist/commands/mcp.js +888 -7
- package/dist/commands/plan.js +379 -103
- package/dist/commands/policy.js +586 -0
- package/dist/commands/rules.js +875 -0
- package/dist/commands/scenes.js +140 -0
- package/dist/commands/schema.js +0 -2
- package/dist/commands/status-sync.js +131 -0
- package/dist/commands/uninstall.js +237 -0
- package/dist/commands/upgrade-check.js +88 -0
- 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 +4 -10
- package/dist/index.js +30 -1
- 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/daemon-state.js +46 -0
- package/dist/lib/destructive-mode.js +12 -0
- package/dist/lib/devices.js +1 -1
- package/dist/lib/plan-store.js +68 -0
- 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 +331 -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/conflict-analyzer.js +203 -0
- package/dist/rules/cron-scheduler.js +186 -0
- package/dist/rules/destructive.js +52 -0
- package/dist/rules/engine.js +757 -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 +116 -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/status-sync/manager.js +268 -0
- package/dist/utils/audit.js +12 -2
- package/dist/utils/health.js +101 -0
- package/dist/utils/output.js +72 -23
- package/dist/utils/retry.js +81 -0
- package/package.json +12 -4
package/dist/commands/plan.js
CHANGED
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import
|
|
2
|
+
import readline from 'node:readline';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { printJson, isJsonMode, handleError, exitWithError } from '../utils/output.js';
|
|
3
5
|
import { executeCommand, isDestructiveCommand } from '../lib/devices.js';
|
|
4
6
|
import { executeScene } from '../lib/scenes.js';
|
|
5
7
|
import { getCachedDevice } from '../devices/cache.js';
|
|
6
8
|
import { resolveDeviceId } from '../utils/name-resolver.js';
|
|
9
|
+
import { COMMAND_KEYWORDS } from '../lib/command-keywords.js';
|
|
10
|
+
import { savePlanRecord, loadPlanRecord, updatePlanRecord, listPlanRecords, PLANS_DIR, } from '../lib/plan-store.js';
|
|
11
|
+
import { allowsDirectDestructiveExecution, destructiveExecutionHint } from '../lib/destructive-mode.js';
|
|
12
|
+
function findDestructivePlanSteps(plan) {
|
|
13
|
+
const destructive = [];
|
|
14
|
+
for (let i = 0; i < plan.steps.length; i++) {
|
|
15
|
+
const step = plan.steps[i];
|
|
16
|
+
if (step.type !== 'command')
|
|
17
|
+
continue;
|
|
18
|
+
const resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
|
|
19
|
+
const deviceType = getCachedDevice(resolvedDeviceId)?.type;
|
|
20
|
+
const commandType = step.commandType ?? 'command';
|
|
21
|
+
if (isDestructiveCommand(deviceType, step.command, commandType)) {
|
|
22
|
+
destructive.push({ index: i + 1, deviceId: resolvedDeviceId, command: step.command, commandType, deviceType: deviceType ?? null });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return destructive;
|
|
26
|
+
}
|
|
7
27
|
const PLAN_JSON_SCHEMA = {
|
|
8
28
|
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
9
29
|
$id: 'https://switchbot.dev/plan-1.0.json',
|
|
@@ -132,6 +152,26 @@ export function validatePlan(raw) {
|
|
|
132
152
|
return { ok: false, issues };
|
|
133
153
|
return { ok: true, plan: raw };
|
|
134
154
|
}
|
|
155
|
+
export function suggestPlan(opts) {
|
|
156
|
+
const warnings = [];
|
|
157
|
+
let command = '';
|
|
158
|
+
for (const k of COMMAND_KEYWORDS) {
|
|
159
|
+
if (k.pattern.test(opts.intent)) {
|
|
160
|
+
command = k.command;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!command) {
|
|
165
|
+
command = 'turnOn';
|
|
166
|
+
warnings.push(`Could not infer command from intent "${opts.intent}" — defaulted to "turnOn". Edit the generated plan to set the correct command.`);
|
|
167
|
+
}
|
|
168
|
+
const steps = opts.devices.map((d) => ({
|
|
169
|
+
type: 'command',
|
|
170
|
+
deviceId: d.id,
|
|
171
|
+
command,
|
|
172
|
+
}));
|
|
173
|
+
return { plan: { version: '1.0', description: opts.intent, steps }, warnings };
|
|
174
|
+
}
|
|
135
175
|
async function readPlanSource(file) {
|
|
136
176
|
const text = file === undefined || file === '-'
|
|
137
177
|
? await readStdin()
|
|
@@ -157,6 +197,111 @@ function readStdin() {
|
|
|
157
197
|
process.stdin.on('error', reject);
|
|
158
198
|
});
|
|
159
199
|
}
|
|
200
|
+
async function promptApproval(stepIdx, command, deviceId) {
|
|
201
|
+
if (!process.stdin.isTTY)
|
|
202
|
+
return false;
|
|
203
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
204
|
+
return new Promise((resolve) => {
|
|
205
|
+
rl.question(` Approve step ${stepIdx} — ${command} on ${deviceId}? [y/N] `, (answer) => {
|
|
206
|
+
rl.close();
|
|
207
|
+
resolve(answer.trim().toLowerCase() === 'y');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
/** Shared plan-execution core used by both `plan run` and `plan execute`. */
|
|
212
|
+
async function executePlanSteps(plan, planId, options) {
|
|
213
|
+
const out = {
|
|
214
|
+
plan,
|
|
215
|
+
results: [],
|
|
216
|
+
summary: { total: plan.steps.length, ok: 0, error: 0, skipped: 0 },
|
|
217
|
+
};
|
|
218
|
+
for (let i = 0; i < plan.steps.length; i++) {
|
|
219
|
+
const step = plan.steps[i];
|
|
220
|
+
const idx = i + 1;
|
|
221
|
+
if (step.type === 'wait') {
|
|
222
|
+
await new Promise((r) => setTimeout(r, step.ms));
|
|
223
|
+
out.results.push({ step: idx, type: 'wait', ms: step.ms, status: 'ok' });
|
|
224
|
+
out.summary.ok++;
|
|
225
|
+
if (!isJsonMode())
|
|
226
|
+
console.log(` ${idx}. wait ${step.ms}ms`);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (step.type === 'scene') {
|
|
230
|
+
try {
|
|
231
|
+
await executeScene(step.sceneId);
|
|
232
|
+
out.results.push({ step: idx, type: 'scene', sceneId: step.sceneId, status: 'ok' });
|
|
233
|
+
out.summary.ok++;
|
|
234
|
+
if (!isJsonMode())
|
|
235
|
+
console.log(` ${idx}. ✓ scene ${step.sceneId}`);
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
239
|
+
out.results.push({ step: idx, type: 'scene', sceneId: step.sceneId, status: 'error', error: msg });
|
|
240
|
+
out.summary.error++;
|
|
241
|
+
if (!isJsonMode())
|
|
242
|
+
console.log(` ${idx}. ✗ scene ${step.sceneId}: ${msg}`);
|
|
243
|
+
if (!options.continueOnError)
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
|
|
249
|
+
const deviceType = getCachedDevice(resolvedDeviceId)?.type;
|
|
250
|
+
const commandType = step.commandType ?? 'command';
|
|
251
|
+
const destructive = isDestructiveCommand(deviceType, step.command, commandType);
|
|
252
|
+
let approvalDecision;
|
|
253
|
+
if (destructive && !options.yes) {
|
|
254
|
+
if (options.requireApproval) {
|
|
255
|
+
const approved = await promptApproval(idx, step.command, resolvedDeviceId);
|
|
256
|
+
if (approved) {
|
|
257
|
+
approvalDecision = 'approved';
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
out.results.push({ step: idx, type: 'command', deviceId: resolvedDeviceId, command: step.command, status: 'skipped', error: 'destructive — rejected at prompt', decision: 'rejected' });
|
|
261
|
+
out.summary.skipped++;
|
|
262
|
+
if (!isJsonMode())
|
|
263
|
+
console.log(` ${idx}. ✗ skipped ${step.command} on ${resolvedDeviceId} (rejected)`);
|
|
264
|
+
if (!options.continueOnError)
|
|
265
|
+
break;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
out.results.push({ step: idx, type: 'command', deviceId: resolvedDeviceId, command: step.command, status: 'skipped', error: 'destructive — rerun with --yes' });
|
|
271
|
+
out.summary.skipped++;
|
|
272
|
+
if (!isJsonMode())
|
|
273
|
+
console.log(` ${idx}. ⚠ skipped ${step.command} on ${resolvedDeviceId} (destructive — pass --yes)`);
|
|
274
|
+
if (!options.continueOnError)
|
|
275
|
+
break;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
await executeCommand(resolvedDeviceId, step.command, step.parameter, commandType, undefined, { planId });
|
|
281
|
+
out.results.push({ step: idx, type: 'command', deviceId: resolvedDeviceId, command: step.command, status: 'ok', ...(approvalDecision ? { decision: approvalDecision } : {}) });
|
|
282
|
+
out.summary.ok++;
|
|
283
|
+
if (!isJsonMode())
|
|
284
|
+
console.log(` ${idx}. ✓ ${step.command} on ${resolvedDeviceId}`);
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
if (err instanceof Error && err.name === 'DryRunSignal') {
|
|
288
|
+
out.results.push({ step: idx, type: 'command', deviceId: resolvedDeviceId, command: step.command, status: 'ok' });
|
|
289
|
+
out.summary.ok++;
|
|
290
|
+
if (!isJsonMode())
|
|
291
|
+
console.log(` ${idx}. ◦ dry-run ${step.command} on ${resolvedDeviceId}`);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
295
|
+
out.results.push({ step: idx, type: 'command', deviceId: resolvedDeviceId, command: step.command, status: 'error', error: msg });
|
|
296
|
+
out.summary.error++;
|
|
297
|
+
if (!isJsonMode())
|
|
298
|
+
console.log(` ${idx}. ✗ ${step.command} on ${resolvedDeviceId}: ${msg}`);
|
|
299
|
+
if (!options.continueOnError)
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
160
305
|
export function registerPlanCommand(program) {
|
|
161
306
|
const plan = program
|
|
162
307
|
.command('plan')
|
|
@@ -175,7 +320,10 @@ Workflow:
|
|
|
175
320
|
$ switchbot plan schema > plan.schema.json # export the contract
|
|
176
321
|
$ switchbot plan validate my-plan.json # check shape without running
|
|
177
322
|
$ switchbot --dry-run plan run my-plan.json # preview (mutations skipped)
|
|
178
|
-
$ switchbot plan
|
|
323
|
+
$ switchbot plan save my-plan.json # store a reviewed plan
|
|
324
|
+
$ switchbot plan review <planId>
|
|
325
|
+
$ switchbot plan approve <planId>
|
|
326
|
+
$ switchbot plan execute <planId>
|
|
179
327
|
$ cat plan.json | switchbot plan run - # or stream via stdin
|
|
180
328
|
`);
|
|
181
329
|
plan
|
|
@@ -234,13 +382,49 @@ against the live API without executing any mutations.
|
|
|
234
382
|
}
|
|
235
383
|
}
|
|
236
384
|
});
|
|
385
|
+
plan
|
|
386
|
+
.command('suggest')
|
|
387
|
+
.description('Generate a candidate Plan JSON from intent + devices (heuristic, no LLM)')
|
|
388
|
+
.requiredOption('--intent <text>', 'Natural language description (e.g. "turn off all lights")')
|
|
389
|
+
.option('--device <id>', 'Device ID to include (repeatable)', (v, prev) => [...prev, v], [])
|
|
390
|
+
.option('--out <file>', 'Write plan JSON to file instead of stdout')
|
|
391
|
+
.action((opts) => {
|
|
392
|
+
if (opts.device.length === 0) {
|
|
393
|
+
console.error('error: at least one --device is required');
|
|
394
|
+
process.exit(1);
|
|
395
|
+
}
|
|
396
|
+
const devices = opts.device.map((ref) => {
|
|
397
|
+
const cached = getCachedDevice(ref);
|
|
398
|
+
return { id: ref, name: cached?.name, type: cached?.type };
|
|
399
|
+
});
|
|
400
|
+
const { plan: suggested, warnings } = suggestPlan({ intent: opts.intent, devices });
|
|
401
|
+
for (const w of warnings)
|
|
402
|
+
process.stderr.write(`warning: ${w}\n`);
|
|
403
|
+
const json = JSON.stringify(suggested, null, 2);
|
|
404
|
+
if (opts.out) {
|
|
405
|
+
fs.writeFileSync(opts.out, json + '\n', 'utf8');
|
|
406
|
+
if (!isJsonMode())
|
|
407
|
+
console.log(`✓ plan written to ${opts.out}`);
|
|
408
|
+
}
|
|
409
|
+
else if (isJsonMode()) {
|
|
410
|
+
printJson({ plan: suggested, warnings });
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
console.log(json);
|
|
414
|
+
}
|
|
415
|
+
});
|
|
237
416
|
plan
|
|
238
417
|
.command('run')
|
|
239
|
-
.description('Validate + execute a plan. Respects --dry-run; destructive steps require
|
|
418
|
+
.description('Validate + preview/execute a plan. Respects --dry-run; destructive steps require the reviewed plan flow by default')
|
|
240
419
|
.argument('[file]', 'Path to plan.json, or "-" / omit to read stdin')
|
|
241
420
|
.option('--yes', 'Authorize destructive commands (e.g. Smart Lock unlock, Garage open)')
|
|
421
|
+
.option('--require-approval', 'Prompt for confirmation before each destructive step (TTY only; mutually exclusive with --json)')
|
|
242
422
|
.option('--continue-on-error', 'Keep running after a failed step (default: stop at first error)')
|
|
243
423
|
.action(async (file, options) => {
|
|
424
|
+
if (options.requireApproval && isJsonMode()) {
|
|
425
|
+
console.error('error: --require-approval cannot be used with --json (no TTY available for prompts)');
|
|
426
|
+
process.exit(1);
|
|
427
|
+
}
|
|
244
428
|
let raw;
|
|
245
429
|
try {
|
|
246
430
|
raw = await readPlanSource(file);
|
|
@@ -260,108 +444,32 @@ against the live API without executing any mutations.
|
|
|
260
444
|
}
|
|
261
445
|
process.exit(2);
|
|
262
446
|
}
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
console.log(` ${idx}. wait ${step.ms}ms`);
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
280
|
-
if (step.type === 'scene') {
|
|
281
|
-
try {
|
|
282
|
-
await executeScene(step.sceneId);
|
|
283
|
-
out.results.push({ step: idx, type: 'scene', sceneId: step.sceneId, status: 'ok' });
|
|
284
|
-
out.summary.ok++;
|
|
285
|
-
if (!isJsonMode())
|
|
286
|
-
console.log(` ${idx}. ✓ scene ${step.sceneId}`);
|
|
287
|
-
}
|
|
288
|
-
catch (err) {
|
|
289
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
290
|
-
out.results.push({ step: idx, type: 'scene', sceneId: step.sceneId, status: 'error', error: msg });
|
|
291
|
-
out.summary.error++;
|
|
292
|
-
if (!isJsonMode())
|
|
293
|
-
console.log(` ${idx}. ✗ scene ${step.sceneId}: ${msg}`);
|
|
294
|
-
if (!options.continueOnError)
|
|
295
|
-
break;
|
|
296
|
-
}
|
|
297
|
-
continue;
|
|
298
|
-
}
|
|
299
|
-
// command
|
|
300
|
-
const resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
|
|
301
|
-
const deviceType = getCachedDevice(resolvedDeviceId)?.type;
|
|
302
|
-
const commandType = step.commandType ?? 'command';
|
|
303
|
-
const destructive = isDestructiveCommand(deviceType, step.command, commandType);
|
|
304
|
-
if (destructive && !options.yes) {
|
|
305
|
-
out.results.push({
|
|
306
|
-
step: idx,
|
|
307
|
-
type: 'command',
|
|
308
|
-
deviceId: resolvedDeviceId,
|
|
309
|
-
command: step.command,
|
|
310
|
-
status: 'skipped',
|
|
311
|
-
error: 'destructive — rerun with --yes',
|
|
312
|
-
});
|
|
313
|
-
out.summary.skipped++;
|
|
314
|
-
if (!isJsonMode())
|
|
315
|
-
console.log(` ${idx}. ⚠ skipped ${step.command} on ${resolvedDeviceId} (destructive — pass --yes)`);
|
|
316
|
-
if (!options.continueOnError)
|
|
317
|
-
break;
|
|
318
|
-
continue;
|
|
319
|
-
}
|
|
320
|
-
try {
|
|
321
|
-
await executeCommand(resolvedDeviceId, step.command, step.parameter, commandType);
|
|
322
|
-
out.results.push({
|
|
323
|
-
step: idx,
|
|
324
|
-
type: 'command',
|
|
325
|
-
deviceId: resolvedDeviceId,
|
|
447
|
+
const planId = randomUUID();
|
|
448
|
+
const destructiveSteps = findDestructivePlanSteps(v.plan);
|
|
449
|
+
if (options.yes && destructiveSteps.length > 0 && !allowsDirectDestructiveExecution()) {
|
|
450
|
+
exitWithError({
|
|
451
|
+
code: 2,
|
|
452
|
+
kind: 'guard',
|
|
453
|
+
message: `Direct destructive execution is disabled for plan run (${destructiveSteps.length} destructive step${destructiveSteps.length === 1 ? '' : 's'}).`,
|
|
454
|
+
hint: destructiveExecutionHint(),
|
|
455
|
+
context: {
|
|
456
|
+
planId,
|
|
457
|
+
destructiveSteps: destructiveSteps.map((step) => ({
|
|
458
|
+
step: step.index,
|
|
459
|
+
deviceId: step.deviceId,
|
|
460
|
+
deviceType: step.deviceType,
|
|
326
461
|
command: step.command,
|
|
327
|
-
|
|
328
|
-
})
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
step: idx,
|
|
337
|
-
type: 'command',
|
|
338
|
-
deviceId: resolvedDeviceId,
|
|
339
|
-
command: step.command,
|
|
340
|
-
status: 'ok',
|
|
341
|
-
});
|
|
342
|
-
out.summary.ok++;
|
|
343
|
-
if (!isJsonMode())
|
|
344
|
-
console.log(` ${idx}. ◦ dry-run ${step.command} on ${resolvedDeviceId}`);
|
|
345
|
-
continue;
|
|
346
|
-
}
|
|
347
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
348
|
-
out.results.push({
|
|
349
|
-
step: idx,
|
|
350
|
-
type: 'command',
|
|
351
|
-
deviceId: resolvedDeviceId,
|
|
352
|
-
command: step.command,
|
|
353
|
-
status: 'error',
|
|
354
|
-
error: msg,
|
|
355
|
-
});
|
|
356
|
-
out.summary.error++;
|
|
357
|
-
if (!isJsonMode())
|
|
358
|
-
console.log(` ${idx}. ✗ ${step.command} on ${resolvedDeviceId}: ${msg}`);
|
|
359
|
-
if (!options.continueOnError)
|
|
360
|
-
break;
|
|
361
|
-
}
|
|
362
|
-
}
|
|
462
|
+
commandType: step.commandType,
|
|
463
|
+
})),
|
|
464
|
+
requiredWorkflow: 'plan-approval',
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
let out;
|
|
469
|
+
try {
|
|
470
|
+
out = await executePlanSteps(v.plan, planId, options);
|
|
363
471
|
if (isJsonMode()) {
|
|
364
|
-
printJson({ ran: true, ...out });
|
|
472
|
+
printJson({ ran: true, planId, ...out });
|
|
365
473
|
}
|
|
366
474
|
else {
|
|
367
475
|
const { ok, error, skipped, total } = out.summary;
|
|
@@ -370,8 +478,176 @@ against the live API without executing any mutations.
|
|
|
370
478
|
}
|
|
371
479
|
catch (err) {
|
|
372
480
|
handleError(err);
|
|
481
|
+
return;
|
|
373
482
|
}
|
|
374
483
|
if (out.summary.error > 0)
|
|
375
484
|
process.exit(1);
|
|
376
485
|
});
|
|
486
|
+
// ---- Plan resource-model subcommands (P0-3) --------------------------------
|
|
487
|
+
plan
|
|
488
|
+
.command('save')
|
|
489
|
+
.description('Save a plan JSON to ~/.switchbot/plans/ with status=pending (waiting for approval).')
|
|
490
|
+
.argument('[file]', 'Path to plan.json, or "-" / omit to read stdin')
|
|
491
|
+
.action(async (file) => {
|
|
492
|
+
let raw;
|
|
493
|
+
try {
|
|
494
|
+
raw = await readPlanSource(file);
|
|
495
|
+
}
|
|
496
|
+
catch (err) {
|
|
497
|
+
handleError(err);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const v = validatePlan(raw);
|
|
501
|
+
if (!v.ok) {
|
|
502
|
+
exitWithError({
|
|
503
|
+
code: 2, kind: 'usage',
|
|
504
|
+
message: `Plan is invalid (${v.issues.length} issue${v.issues.length > 1 ? 's' : ''})`,
|
|
505
|
+
context: { issues: v.issues },
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
const record = savePlanRecord(v.plan);
|
|
509
|
+
if (isJsonMode()) {
|
|
510
|
+
printJson({ saved: true, planId: record.planId, status: record.status, createdAt: record.createdAt, plansDir: PLANS_DIR });
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
console.log(`✓ Plan saved — planId: ${record.planId}`);
|
|
514
|
+
console.log(` Status: ${record.status}`);
|
|
515
|
+
console.log(` Path: ${PLANS_DIR}/${record.planId}.json`);
|
|
516
|
+
console.log(` Next: switchbot plan review ${record.planId}`);
|
|
517
|
+
console.log(` switchbot plan approve ${record.planId}`);
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
plan
|
|
521
|
+
.command('list')
|
|
522
|
+
.description('List saved plans in ~/.switchbot/plans/ with their approval status.')
|
|
523
|
+
.action(() => {
|
|
524
|
+
const records = listPlanRecords();
|
|
525
|
+
if (isJsonMode()) {
|
|
526
|
+
printJson({ plans: records.map((r) => ({ planId: r.planId, status: r.status, createdAt: r.createdAt, approvedAt: r.approvedAt ?? null, executedAt: r.executedAt ?? null, description: r.plan.description ?? null })) });
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (records.length === 0) {
|
|
530
|
+
console.log('No saved plans. Use: switchbot plan save <file>');
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
for (const r of records) {
|
|
534
|
+
const parts = [`${r.planId.slice(0, 8)}…`, r.status, r.createdAt.slice(0, 16)];
|
|
535
|
+
if (r.plan.description)
|
|
536
|
+
parts.push(`"${r.plan.description}"`);
|
|
537
|
+
console.log(parts.join(' '));
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
plan
|
|
541
|
+
.command('review')
|
|
542
|
+
.description('Show the details of a saved plan (steps, status, approval history).')
|
|
543
|
+
.argument('<planId>', 'Plan UUID from "plan list"')
|
|
544
|
+
.action((planId) => {
|
|
545
|
+
const record = loadPlanRecord(planId);
|
|
546
|
+
if (!record) {
|
|
547
|
+
exitWithError({ code: 2, kind: 'usage', message: `Plan ${planId} not found in ${PLANS_DIR}` });
|
|
548
|
+
}
|
|
549
|
+
if (isJsonMode()) {
|
|
550
|
+
printJson(record);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
console.log(`planId: ${record.planId}`);
|
|
554
|
+
console.log(`status: ${record.status}`);
|
|
555
|
+
console.log(`createdAt: ${record.createdAt}`);
|
|
556
|
+
if (record.approvedAt)
|
|
557
|
+
console.log(`approvedAt: ${record.approvedAt}`);
|
|
558
|
+
if (record.executedAt)
|
|
559
|
+
console.log(`executedAt: ${record.executedAt}`);
|
|
560
|
+
if (record.plan.description)
|
|
561
|
+
console.log(`description: ${record.plan.description}`);
|
|
562
|
+
console.log(`steps (${record.plan.steps.length}):`);
|
|
563
|
+
for (let i = 0; i < record.plan.steps.length; i++) {
|
|
564
|
+
const step = record.plan.steps[i];
|
|
565
|
+
if (step.type === 'command') {
|
|
566
|
+
const id = step.deviceId ?? step.deviceName ?? '?';
|
|
567
|
+
console.log(` ${i + 1}. command ${step.command} on ${id}${step.note ? ` # ${step.note}` : ''}`);
|
|
568
|
+
}
|
|
569
|
+
else if (step.type === 'scene') {
|
|
570
|
+
console.log(` ${i + 1}. scene ${step.sceneId}${step.note ? ` # ${step.note}` : ''}`);
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
console.log(` ${i + 1}. wait ${step.ms}ms`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
plan
|
|
578
|
+
.command('approve')
|
|
579
|
+
.description('Approve a saved plan, allowing `plan execute` to run it.')
|
|
580
|
+
.argument('<planId>', 'Plan UUID from "plan list"')
|
|
581
|
+
.action((planId) => {
|
|
582
|
+
const record = loadPlanRecord(planId);
|
|
583
|
+
if (!record) {
|
|
584
|
+
exitWithError({ code: 2, kind: 'usage', message: `Plan ${planId} not found in ${PLANS_DIR}` });
|
|
585
|
+
}
|
|
586
|
+
if (record.status === 'executed') {
|
|
587
|
+
exitWithError({ code: 2, kind: 'guard', message: `Plan ${planId} has already been executed.` });
|
|
588
|
+
}
|
|
589
|
+
if (record.status === 'rejected') {
|
|
590
|
+
exitWithError({ code: 2, kind: 'guard', message: `Plan ${planId} was rejected. Save a new plan to start fresh.` });
|
|
591
|
+
}
|
|
592
|
+
// 'failed' plans may be re-approved and retried — intentionally no block here.
|
|
593
|
+
const updated = updatePlanRecord(planId, { status: 'approved', approvedAt: new Date().toISOString() });
|
|
594
|
+
if (isJsonMode()) {
|
|
595
|
+
printJson({ ok: true, planId: updated.planId, status: updated.status, approvedAt: updated.approvedAt });
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
console.log(`✓ Plan ${planId.slice(0, 8)}… approved.`);
|
|
599
|
+
console.log(` Next: switchbot plan execute ${planId}`);
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
plan
|
|
603
|
+
.command('execute')
|
|
604
|
+
.description('Execute a pre-approved plan. Only runs if status=approved; audit entries are tagged with planId.')
|
|
605
|
+
.argument('<planId>', 'Plan UUID from "plan list" (must be in approved status)')
|
|
606
|
+
.option('--yes', 'Deprecated no-op: approved plans already authorize destructive steps')
|
|
607
|
+
.option('--require-approval', 'Prompt for each destructive step (TTY only)')
|
|
608
|
+
.option('--continue-on-error', 'Keep running after a failed step')
|
|
609
|
+
.action(async (planId, options) => {
|
|
610
|
+
if (options.requireApproval && isJsonMode()) {
|
|
611
|
+
exitWithError({ code: 1, kind: 'usage', message: '--require-approval cannot be used with --json' });
|
|
612
|
+
}
|
|
613
|
+
const record = loadPlanRecord(planId);
|
|
614
|
+
if (!record) {
|
|
615
|
+
exitWithError({ code: 2, kind: 'usage', message: `Plan ${planId} not found in ${PLANS_DIR}` });
|
|
616
|
+
}
|
|
617
|
+
if (record.status !== 'approved') {
|
|
618
|
+
exitWithError({
|
|
619
|
+
code: 2, kind: 'guard',
|
|
620
|
+
message: `Plan ${planId.slice(0, 8)}… cannot be executed: status is "${record.status}", expected "approved".`,
|
|
621
|
+
hint: record.status === 'pending' ? `Run: switchbot plan approve ${planId}` : record.status === 'failed' ? `Re-run: switchbot plan approve ${planId}` : undefined,
|
|
622
|
+
context: { planId, status: record.status },
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
let out;
|
|
626
|
+
try {
|
|
627
|
+
out = await executePlanSteps(record.plan, planId, { ...options, yes: true });
|
|
628
|
+
}
|
|
629
|
+
catch (err) {
|
|
630
|
+
handleError(err);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const { ok, error, skipped } = out.summary;
|
|
634
|
+
const succeeded = error === 0 && skipped === 0;
|
|
635
|
+
const failureReason = succeeded ? undefined : [error > 0 ? `${error} error${error > 1 ? 's' : ''}` : null, skipped > 0 ? `${skipped} skipped` : null].filter(Boolean).join(', ');
|
|
636
|
+
if (succeeded) {
|
|
637
|
+
updatePlanRecord(planId, { status: 'executed', executedAt: new Date().toISOString() });
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
updatePlanRecord(planId, { status: 'failed', failedAt: new Date().toISOString(), failureReason });
|
|
641
|
+
}
|
|
642
|
+
if (isJsonMode()) {
|
|
643
|
+
printJson({ ran: true, planId, succeeded, ...out });
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
console.log(`\nsummary: ok=${ok} error=${error} skipped=${skipped} total=${out.summary.total}`);
|
|
647
|
+
if (!succeeded)
|
|
648
|
+
console.error(`Plan marked as failed (${failureReason}). Re-run after fixing to retry.`);
|
|
649
|
+
}
|
|
650
|
+
if (!succeeded)
|
|
651
|
+
process.exit(1);
|
|
652
|
+
});
|
|
377
653
|
}
|