@switchbot/openapi-cli 3.0.0 → 3.1.1
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 +138 -50
- 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 +410 -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 +107 -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/action.js +11 -0
- package/dist/rules/conflict-analyzer.js +214 -0
- package/dist/rules/engine.js +195 -5
- package/dist/rules/suggest.js +1 -1
- package/dist/rules/throttle.js +42 -4
- package/dist/utils/audit.js +5 -1
- 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/plan.js
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import readline from 'node:readline';
|
|
3
|
-
import {
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { printJson, isJsonMode, handleError, exitWithError } from '../utils/output.js';
|
|
4
5
|
import { executeCommand, isDestructiveCommand } from '../lib/devices.js';
|
|
5
6
|
import { executeScene } from '../lib/scenes.js';
|
|
6
7
|
import { getCachedDevice } from '../devices/cache.js';
|
|
7
8
|
import { resolveDeviceId } from '../utils/name-resolver.js';
|
|
8
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
|
+
}
|
|
9
27
|
const PLAN_JSON_SCHEMA = {
|
|
10
28
|
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
11
29
|
$id: 'https://switchbot.dev/plan-1.0.json',
|
|
@@ -190,6 +208,100 @@ async function promptApproval(stepIdx, command, deviceId) {
|
|
|
190
208
|
});
|
|
191
209
|
});
|
|
192
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
|
+
}
|
|
193
305
|
export function registerPlanCommand(program) {
|
|
194
306
|
const plan = program
|
|
195
307
|
.command('plan')
|
|
@@ -208,7 +320,10 @@ Workflow:
|
|
|
208
320
|
$ switchbot plan schema > plan.schema.json # export the contract
|
|
209
321
|
$ switchbot plan validate my-plan.json # check shape without running
|
|
210
322
|
$ switchbot --dry-run plan run my-plan.json # preview (mutations skipped)
|
|
211
|
-
$ 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>
|
|
212
327
|
$ cat plan.json | switchbot plan run - # or stream via stdin
|
|
213
328
|
`);
|
|
214
329
|
plan
|
|
@@ -300,7 +415,7 @@ against the live API without executing any mutations.
|
|
|
300
415
|
});
|
|
301
416
|
plan
|
|
302
417
|
.command('run')
|
|
303
|
-
.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')
|
|
304
419
|
.argument('[file]', 'Path to plan.json, or "-" / omit to read stdin')
|
|
305
420
|
.option('--yes', 'Authorize destructive commands (e.g. Smart Lock unlock, Garage open)')
|
|
306
421
|
.option('--require-approval', 'Prompt for confirmation before each destructive step (TTY only; mutually exclusive with --json)')
|
|
@@ -329,135 +444,32 @@ against the live API without executing any mutations.
|
|
|
329
444
|
}
|
|
330
445
|
process.exit(2);
|
|
331
446
|
}
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
console.log(` ${idx}. wait ${step.ms}ms`);
|
|
347
|
-
continue;
|
|
348
|
-
}
|
|
349
|
-
if (step.type === 'scene') {
|
|
350
|
-
try {
|
|
351
|
-
await executeScene(step.sceneId);
|
|
352
|
-
out.results.push({ step: idx, type: 'scene', sceneId: step.sceneId, status: 'ok' });
|
|
353
|
-
out.summary.ok++;
|
|
354
|
-
if (!isJsonMode())
|
|
355
|
-
console.log(` ${idx}. ✓ scene ${step.sceneId}`);
|
|
356
|
-
}
|
|
357
|
-
catch (err) {
|
|
358
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
359
|
-
out.results.push({ step: idx, type: 'scene', sceneId: step.sceneId, status: 'error', error: msg });
|
|
360
|
-
out.summary.error++;
|
|
361
|
-
if (!isJsonMode())
|
|
362
|
-
console.log(` ${idx}. ✗ scene ${step.sceneId}: ${msg}`);
|
|
363
|
-
if (!options.continueOnError)
|
|
364
|
-
break;
|
|
365
|
-
}
|
|
366
|
-
continue;
|
|
367
|
-
}
|
|
368
|
-
// command
|
|
369
|
-
const resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
|
|
370
|
-
const deviceType = getCachedDevice(resolvedDeviceId)?.type;
|
|
371
|
-
const commandType = step.commandType ?? 'command';
|
|
372
|
-
const destructive = isDestructiveCommand(deviceType, step.command, commandType);
|
|
373
|
-
let approvalDecision;
|
|
374
|
-
if (destructive && !options.yes) {
|
|
375
|
-
if (options.requireApproval) {
|
|
376
|
-
const approved = await promptApproval(idx, step.command, resolvedDeviceId);
|
|
377
|
-
if (approved) {
|
|
378
|
-
approvalDecision = 'approved';
|
|
379
|
-
}
|
|
380
|
-
else {
|
|
381
|
-
out.results.push({
|
|
382
|
-
step: idx,
|
|
383
|
-
type: 'command',
|
|
384
|
-
deviceId: resolvedDeviceId,
|
|
385
|
-
command: step.command,
|
|
386
|
-
status: 'skipped',
|
|
387
|
-
error: 'destructive — rejected at prompt',
|
|
388
|
-
decision: 'rejected',
|
|
389
|
-
});
|
|
390
|
-
out.summary.skipped++;
|
|
391
|
-
if (!isJsonMode())
|
|
392
|
-
console.log(` ${idx}. ✗ skipped ${step.command} on ${resolvedDeviceId} (rejected)`);
|
|
393
|
-
if (!options.continueOnError)
|
|
394
|
-
break;
|
|
395
|
-
continue;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
else {
|
|
399
|
-
out.results.push({
|
|
400
|
-
step: idx,
|
|
401
|
-
type: 'command',
|
|
402
|
-
deviceId: resolvedDeviceId,
|
|
403
|
-
command: step.command,
|
|
404
|
-
status: 'skipped',
|
|
405
|
-
error: 'destructive — rerun with --yes',
|
|
406
|
-
});
|
|
407
|
-
out.summary.skipped++;
|
|
408
|
-
if (!isJsonMode())
|
|
409
|
-
console.log(` ${idx}. ⚠ skipped ${step.command} on ${resolvedDeviceId} (destructive — pass --yes)`);
|
|
410
|
-
if (!options.continueOnError)
|
|
411
|
-
break;
|
|
412
|
-
continue;
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
try {
|
|
416
|
-
await executeCommand(resolvedDeviceId, step.command, step.parameter, commandType);
|
|
417
|
-
out.results.push({
|
|
418
|
-
step: idx,
|
|
419
|
-
type: 'command',
|
|
420
|
-
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,
|
|
421
461
|
command: step.command,
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
out.results.push({
|
|
432
|
-
step: idx,
|
|
433
|
-
type: 'command',
|
|
434
|
-
deviceId: resolvedDeviceId,
|
|
435
|
-
command: step.command,
|
|
436
|
-
status: 'ok',
|
|
437
|
-
});
|
|
438
|
-
out.summary.ok++;
|
|
439
|
-
if (!isJsonMode())
|
|
440
|
-
console.log(` ${idx}. ◦ dry-run ${step.command} on ${resolvedDeviceId}`);
|
|
441
|
-
continue;
|
|
442
|
-
}
|
|
443
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
444
|
-
out.results.push({
|
|
445
|
-
step: idx,
|
|
446
|
-
type: 'command',
|
|
447
|
-
deviceId: resolvedDeviceId,
|
|
448
|
-
command: step.command,
|
|
449
|
-
status: 'error',
|
|
450
|
-
error: msg,
|
|
451
|
-
});
|
|
452
|
-
out.summary.error++;
|
|
453
|
-
if (!isJsonMode())
|
|
454
|
-
console.log(` ${idx}. ✗ ${step.command} on ${resolvedDeviceId}: ${msg}`);
|
|
455
|
-
if (!options.continueOnError)
|
|
456
|
-
break;
|
|
457
|
-
}
|
|
458
|
-
}
|
|
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);
|
|
459
471
|
if (isJsonMode()) {
|
|
460
|
-
printJson({ ran: true, ...out });
|
|
472
|
+
printJson({ ran: true, planId, ...out });
|
|
461
473
|
}
|
|
462
474
|
else {
|
|
463
475
|
const { ok, error, skipped, total } = out.summary;
|
|
@@ -466,8 +478,176 @@ against the live API without executing any mutations.
|
|
|
466
478
|
}
|
|
467
479
|
catch (err) {
|
|
468
480
|
handleError(err);
|
|
481
|
+
return;
|
|
469
482
|
}
|
|
470
483
|
if (out.summary.error > 0)
|
|
471
484
|
process.exit(1);
|
|
472
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
|
+
});
|
|
473
653
|
}
|
package/dist/commands/policy.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
-
import { dirname } from 'node:path';
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve as resolvePath } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { parse as yamlParse } from 'yaml';
|
|
5
|
-
import { printJson, emitJsonError, isJsonMode } from '../utils/output.js';
|
|
5
|
+
import { printJson, emitJsonError, isJsonMode, exitWithError } from '../utils/output.js';
|
|
6
6
|
import { loadPolicyFile, resolvePolicyPath, DEFAULT_POLICY_PATH, PolicyFileNotFoundError, PolicyYamlParseError, } from '../policy/load.js';
|
|
7
7
|
import { validateLoadedPolicy } from '../policy/validate.js';
|
|
8
8
|
import { formatValidationResult } from '../policy/format.js';
|
|
@@ -457,6 +457,123 @@ Reads rule YAML from stdin. Combine with 'rules suggest' for a full pipeline:
|
|
|
457
457
|
throw err;
|
|
458
458
|
}
|
|
459
459
|
});
|
|
460
|
+
// switchbot policy backup [file]
|
|
461
|
+
policy
|
|
462
|
+
.command('backup [file]')
|
|
463
|
+
.description('Copy the active policy to a backup file (default: <policy>.bak.yaml).')
|
|
464
|
+
.option('--force', 'Overwrite an existing backup file.')
|
|
465
|
+
.addHelpText('after', `
|
|
466
|
+
Creates a point-in-time snapshot of the active policy file so it can be
|
|
467
|
+
restored if a migration or manual edit breaks things.
|
|
468
|
+
|
|
469
|
+
Default backup path: same directory as the policy, with ".bak.yaml" suffix.
|
|
470
|
+
|
|
471
|
+
Examples:
|
|
472
|
+
$ switchbot policy backup
|
|
473
|
+
$ switchbot policy backup ./my-backup.yaml
|
|
474
|
+
$ switchbot policy backup --force
|
|
475
|
+
`)
|
|
476
|
+
.action((fileArg, opts) => {
|
|
477
|
+
const source = resolvePolicyPath({});
|
|
478
|
+
if (!existsSync(source)) {
|
|
479
|
+
exitPolicyError('file-not-found', `policy file not found: ${source}`, { path: source });
|
|
480
|
+
}
|
|
481
|
+
const dest = fileArg
|
|
482
|
+
? resolvePath(fileArg)
|
|
483
|
+
: source.replace(/\.yaml$/, '.bak.yaml').replace(/\.yml$/, '.bak.yml');
|
|
484
|
+
if (!opts.force && existsSync(dest)) {
|
|
485
|
+
exitWithError({ code: 2, kind: 'usage', message: `Backup file already exists: ${dest}. Use --force to overwrite.` });
|
|
486
|
+
}
|
|
487
|
+
try {
|
|
488
|
+
copyFileSync(source, dest);
|
|
489
|
+
}
|
|
490
|
+
catch (err) {
|
|
491
|
+
exitPolicyError('internal', `Failed to write backup: ${err instanceof Error ? err.message : String(err)}`, { source, dest });
|
|
492
|
+
}
|
|
493
|
+
const size = statSync(dest).size;
|
|
494
|
+
if (isJsonMode()) {
|
|
495
|
+
printJson({ ok: true, source, dest, sizeBytes: size });
|
|
496
|
+
}
|
|
497
|
+
else {
|
|
498
|
+
console.log(`Backup written: ${dest} (${size} bytes)`);
|
|
499
|
+
console.log(`Restore with: switchbot policy restore ${dest}`);
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
// switchbot policy restore <file>
|
|
503
|
+
policy
|
|
504
|
+
.command('restore <file>')
|
|
505
|
+
.description('Restore a policy backup, validating it before applying.')
|
|
506
|
+
.option('--no-validate', 'Skip schema validation before restoring (use if migrating manually).')
|
|
507
|
+
.addHelpText('after', `
|
|
508
|
+
Validates the backup file against the current schema before overwriting the
|
|
509
|
+
active policy. A .pre-restore.bak.yaml snapshot of the current policy is
|
|
510
|
+
automatically created before overwriting. Use --no-validate to skip
|
|
511
|
+
validation (e.g. when restoring an older version for manual migration).
|
|
512
|
+
|
|
513
|
+
Example:
|
|
514
|
+
$ switchbot policy restore ./policy.bak.yaml
|
|
515
|
+
`)
|
|
516
|
+
.action((fileArg, opts) => {
|
|
517
|
+
const source = resolvePath(fileArg);
|
|
518
|
+
if (!existsSync(source)) {
|
|
519
|
+
exitPolicyError('file-not-found', `restore source not found: ${source}`, { path: source });
|
|
520
|
+
}
|
|
521
|
+
// Validate before touching the active policy.
|
|
522
|
+
if (opts.validate !== false) {
|
|
523
|
+
let loaded;
|
|
524
|
+
try {
|
|
525
|
+
loaded = loadPolicyFile(source);
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
if (err instanceof PolicyFileNotFoundError) {
|
|
529
|
+
exitPolicyError('file-not-found', `restore source not found: ${err.policyPath}`, { path: err.policyPath });
|
|
530
|
+
}
|
|
531
|
+
if (err instanceof PolicyYamlParseError) {
|
|
532
|
+
exitPolicyError('yaml-parse', `YAML parse error in ${err.policyPath}: ${err.message}`, { path: err.policyPath });
|
|
533
|
+
}
|
|
534
|
+
throw err;
|
|
535
|
+
}
|
|
536
|
+
const vResult = validateLoadedPolicy(loaded);
|
|
537
|
+
if (!vResult.valid) {
|
|
538
|
+
const firstError = vResult.errors[0]?.message ?? 'schema validation failed';
|
|
539
|
+
exitWithError({
|
|
540
|
+
code: 1, kind: 'usage',
|
|
541
|
+
message: `Backup failed validation: ${firstError}`,
|
|
542
|
+
context: { errorCount: vResult.errors.length, hint: 'Use --no-validate to restore anyway.' },
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
const dest = resolvePolicyPath({});
|
|
547
|
+
const destDir = dirname(dest);
|
|
548
|
+
if (!existsSync(destDir)) {
|
|
549
|
+
mkdirSync(destDir, { recursive: true, mode: 0o700 });
|
|
550
|
+
}
|
|
551
|
+
// Take an auto-backup of the current policy before overwriting.
|
|
552
|
+
if (existsSync(dest)) {
|
|
553
|
+
const autoBackup = dest.replace(/\.yaml$/, '.pre-restore.bak.yaml').replace(/\.yml$/, '.pre-restore.bak.yml');
|
|
554
|
+
try {
|
|
555
|
+
copyFileSync(dest, autoBackup);
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
// best-effort — if it fails, proceed anyway
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
try {
|
|
562
|
+
copyFileSync(source, dest);
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
exitPolicyError('internal', `Failed to restore: ${err instanceof Error ? err.message : String(err)}`, { source, dest });
|
|
566
|
+
}
|
|
567
|
+
const size = statSync(dest).size;
|
|
568
|
+
if (isJsonMode()) {
|
|
569
|
+
printJson({ ok: true, restored: dest, from: source, sizeBytes: size });
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
console.log(`Policy restored from: ${source}`);
|
|
573
|
+
console.log(`Active policy: ${dest} (${size} bytes)`);
|
|
574
|
+
console.log('Run `switchbot policy validate` to confirm the restored file is valid.');
|
|
575
|
+
}
|
|
576
|
+
});
|
|
460
577
|
}
|
|
461
578
|
function readStdinText() {
|
|
462
579
|
return new Promise((resolve, reject) => {
|