@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.
@@ -1,11 +1,29 @@
1
1
  import fs from 'node:fs';
2
2
  import readline from 'node:readline';
3
- import { printJson, isJsonMode, handleError } from '../utils/output.js';
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 run my-plan.json --yes # execute destructive steps
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 --yes')
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 out = {
333
- plan: v.plan,
334
- results: [],
335
- summary: { total: v.plan.steps.length, ok: 0, error: 0, skipped: 0 },
336
- };
337
- try {
338
- for (let i = 0; i < v.plan.steps.length; i++) {
339
- const step = v.plan.steps[i];
340
- const idx = i + 1;
341
- if (step.type === 'wait') {
342
- await new Promise((r) => setTimeout(r, step.ms));
343
- out.results.push({ step: idx, type: 'wait', ms: step.ms, status: 'ok' });
344
- out.summary.ok++;
345
- if (!isJsonMode())
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
- status: 'ok',
423
- ...(approvalDecision ? { decision: approvalDecision } : {}),
424
- });
425
- out.summary.ok++;
426
- if (!isJsonMode())
427
- console.log(` ${idx}. ✓ ${step.command} on ${resolvedDeviceId}`);
428
- }
429
- catch (err) {
430
- if (err instanceof Error && err.name === 'DryRunSignal') {
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
  }
@@ -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) => {