@switchbot/openapi-cli 2.7.2 → 3.0.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 +383 -101
- package/dist/commands/agent-bootstrap.js +47 -2
- package/dist/commands/auth.js +354 -0
- package/dist/commands/config.js +30 -0
- package/dist/commands/devices.js +0 -1
- package/dist/commands/doctor.js +184 -7
- package/dist/commands/events.js +3 -3
- package/dist/commands/explain.js +1 -2
- package/dist/commands/install.js +246 -0
- package/dist/commands/mcp.js +796 -3
- package/dist/commands/plan.js +110 -14
- package/dist/commands/policy.js +469 -0
- package/dist/commands/rules.js +657 -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/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 +23 -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/devices.js +0 -1
- 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 +302 -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/cron-scheduler.js +186 -0
- package/dist/rules/destructive.js +52 -0
- package/dist/rules/engine.js +567 -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 +78 -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/package.json +12 -4
package/dist/commands/plan.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import readline from 'node:readline';
|
|
2
3
|
import { printJson, isJsonMode, handleError } from '../utils/output.js';
|
|
3
4
|
import { executeCommand, isDestructiveCommand } from '../lib/devices.js';
|
|
4
5
|
import { executeScene } from '../lib/scenes.js';
|
|
5
6
|
import { getCachedDevice } from '../devices/cache.js';
|
|
6
7
|
import { resolveDeviceId } from '../utils/name-resolver.js';
|
|
8
|
+
import { COMMAND_KEYWORDS } from '../lib/command-keywords.js';
|
|
7
9
|
const PLAN_JSON_SCHEMA = {
|
|
8
10
|
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
9
11
|
$id: 'https://switchbot.dev/plan-1.0.json',
|
|
@@ -132,6 +134,26 @@ export function validatePlan(raw) {
|
|
|
132
134
|
return { ok: false, issues };
|
|
133
135
|
return { ok: true, plan: raw };
|
|
134
136
|
}
|
|
137
|
+
export function suggestPlan(opts) {
|
|
138
|
+
const warnings = [];
|
|
139
|
+
let command = '';
|
|
140
|
+
for (const k of COMMAND_KEYWORDS) {
|
|
141
|
+
if (k.pattern.test(opts.intent)) {
|
|
142
|
+
command = k.command;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!command) {
|
|
147
|
+
command = 'turnOn';
|
|
148
|
+
warnings.push(`Could not infer command from intent "${opts.intent}" — defaulted to "turnOn". Edit the generated plan to set the correct command.`);
|
|
149
|
+
}
|
|
150
|
+
const steps = opts.devices.map((d) => ({
|
|
151
|
+
type: 'command',
|
|
152
|
+
deviceId: d.id,
|
|
153
|
+
command,
|
|
154
|
+
}));
|
|
155
|
+
return { plan: { version: '1.0', description: opts.intent, steps }, warnings };
|
|
156
|
+
}
|
|
135
157
|
async function readPlanSource(file) {
|
|
136
158
|
const text = file === undefined || file === '-'
|
|
137
159
|
? await readStdin()
|
|
@@ -157,6 +179,17 @@ function readStdin() {
|
|
|
157
179
|
process.stdin.on('error', reject);
|
|
158
180
|
});
|
|
159
181
|
}
|
|
182
|
+
async function promptApproval(stepIdx, command, deviceId) {
|
|
183
|
+
if (!process.stdin.isTTY)
|
|
184
|
+
return false;
|
|
185
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
186
|
+
return new Promise((resolve) => {
|
|
187
|
+
rl.question(` Approve step ${stepIdx} — ${command} on ${deviceId}? [y/N] `, (answer) => {
|
|
188
|
+
rl.close();
|
|
189
|
+
resolve(answer.trim().toLowerCase() === 'y');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
}
|
|
160
193
|
export function registerPlanCommand(program) {
|
|
161
194
|
const plan = program
|
|
162
195
|
.command('plan')
|
|
@@ -234,13 +267,49 @@ against the live API without executing any mutations.
|
|
|
234
267
|
}
|
|
235
268
|
}
|
|
236
269
|
});
|
|
270
|
+
plan
|
|
271
|
+
.command('suggest')
|
|
272
|
+
.description('Generate a candidate Plan JSON from intent + devices (heuristic, no LLM)')
|
|
273
|
+
.requiredOption('--intent <text>', 'Natural language description (e.g. "turn off all lights")')
|
|
274
|
+
.option('--device <id>', 'Device ID to include (repeatable)', (v, prev) => [...prev, v], [])
|
|
275
|
+
.option('--out <file>', 'Write plan JSON to file instead of stdout')
|
|
276
|
+
.action((opts) => {
|
|
277
|
+
if (opts.device.length === 0) {
|
|
278
|
+
console.error('error: at least one --device is required');
|
|
279
|
+
process.exit(1);
|
|
280
|
+
}
|
|
281
|
+
const devices = opts.device.map((ref) => {
|
|
282
|
+
const cached = getCachedDevice(ref);
|
|
283
|
+
return { id: ref, name: cached?.name, type: cached?.type };
|
|
284
|
+
});
|
|
285
|
+
const { plan: suggested, warnings } = suggestPlan({ intent: opts.intent, devices });
|
|
286
|
+
for (const w of warnings)
|
|
287
|
+
process.stderr.write(`warning: ${w}\n`);
|
|
288
|
+
const json = JSON.stringify(suggested, null, 2);
|
|
289
|
+
if (opts.out) {
|
|
290
|
+
fs.writeFileSync(opts.out, json + '\n', 'utf8');
|
|
291
|
+
if (!isJsonMode())
|
|
292
|
+
console.log(`✓ plan written to ${opts.out}`);
|
|
293
|
+
}
|
|
294
|
+
else if (isJsonMode()) {
|
|
295
|
+
printJson({ plan: suggested, warnings });
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
console.log(json);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
237
301
|
plan
|
|
238
302
|
.command('run')
|
|
239
303
|
.description('Validate + execute a plan. Respects --dry-run; destructive steps require --yes')
|
|
240
304
|
.argument('[file]', 'Path to plan.json, or "-" / omit to read stdin')
|
|
241
305
|
.option('--yes', 'Authorize destructive commands (e.g. Smart Lock unlock, Garage open)')
|
|
306
|
+
.option('--require-approval', 'Prompt for confirmation before each destructive step (TTY only; mutually exclusive with --json)')
|
|
242
307
|
.option('--continue-on-error', 'Keep running after a failed step (default: stop at first error)')
|
|
243
308
|
.action(async (file, options) => {
|
|
309
|
+
if (options.requireApproval && isJsonMode()) {
|
|
310
|
+
console.error('error: --require-approval cannot be used with --json (no TTY available for prompts)');
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
244
313
|
let raw;
|
|
245
314
|
try {
|
|
246
315
|
raw = await readPlanSource(file);
|
|
@@ -301,21 +370,47 @@ against the live API without executing any mutations.
|
|
|
301
370
|
const deviceType = getCachedDevice(resolvedDeviceId)?.type;
|
|
302
371
|
const commandType = step.commandType ?? 'command';
|
|
303
372
|
const destructive = isDestructiveCommand(deviceType, step.command, commandType);
|
|
373
|
+
let approvalDecision;
|
|
304
374
|
if (destructive && !options.yes) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
+
}
|
|
319
414
|
}
|
|
320
415
|
try {
|
|
321
416
|
await executeCommand(resolvedDeviceId, step.command, step.parameter, commandType);
|
|
@@ -325,6 +420,7 @@ against the live API without executing any mutations.
|
|
|
325
420
|
deviceId: resolvedDeviceId,
|
|
326
421
|
command: step.command,
|
|
327
422
|
status: 'ok',
|
|
423
|
+
...(approvalDecision ? { decision: approvalDecision } : {}),
|
|
328
424
|
});
|
|
329
425
|
out.summary.ok++;
|
|
330
426
|
if (!isJsonMode())
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { parse as yamlParse } from 'yaml';
|
|
5
|
+
import { printJson, emitJsonError, isJsonMode } from '../utils/output.js';
|
|
6
|
+
import { loadPolicyFile, resolvePolicyPath, DEFAULT_POLICY_PATH, PolicyFileNotFoundError, PolicyYamlParseError, } from '../policy/load.js';
|
|
7
|
+
import { validateLoadedPolicy } from '../policy/validate.js';
|
|
8
|
+
import { formatValidationResult } from '../policy/format.js';
|
|
9
|
+
import { CURRENT_POLICY_SCHEMA_VERSION, SUPPORTED_POLICY_SCHEMA_VERSIONS, } from '../policy/schema.js';
|
|
10
|
+
import { planMigration, PolicyMigrationError } from '../policy/migrate.js';
|
|
11
|
+
import { addRuleToPolicyFile, AddRuleError } from '../policy/add-rule.js';
|
|
12
|
+
import { diffPolicyValues } from '../policy/diff.js';
|
|
13
|
+
// Latest version the CLI knows how to migrate *to*.
|
|
14
|
+
// CURRENT_POLICY_SCHEMA_VERSION is the version `policy new` emits by default.
|
|
15
|
+
const LATEST_SUPPORTED_VERSION = SUPPORTED_POLICY_SCHEMA_VERSIONS[SUPPORTED_POLICY_SCHEMA_VERSIONS.length - 1];
|
|
16
|
+
function readEmbeddedTemplate() {
|
|
17
|
+
const url = new URL('../policy/examples/policy.example.yaml', import.meta.url);
|
|
18
|
+
return readFileSync(fileURLToPath(url), 'utf-8');
|
|
19
|
+
}
|
|
20
|
+
export class PolicyFileExistsError extends Error {
|
|
21
|
+
policyPath;
|
|
22
|
+
constructor(policyPath) {
|
|
23
|
+
super(`refusing to overwrite existing policy at ${policyPath}`);
|
|
24
|
+
this.policyPath = policyPath;
|
|
25
|
+
this.name = 'PolicyFileExistsError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Write the starter policy template to `policyPath`. Refuses to
|
|
30
|
+
* overwrite an existing file unless `opts.force === true` — the install
|
|
31
|
+
* orchestrator uses `skipExisting: true` instead, which returns
|
|
32
|
+
* `skipped: true` without touching the file.
|
|
33
|
+
*/
|
|
34
|
+
export function scaffoldPolicyFile(policyPath, opts = {}) {
|
|
35
|
+
const force = opts.force === true;
|
|
36
|
+
if (existsSync(policyPath)) {
|
|
37
|
+
if (opts.skipExisting) {
|
|
38
|
+
return { policyPath, schemaVersion: CURRENT_POLICY_SCHEMA_VERSION, bytesWritten: 0, overwritten: false, skipped: true };
|
|
39
|
+
}
|
|
40
|
+
if (!force)
|
|
41
|
+
throw new PolicyFileExistsError(policyPath);
|
|
42
|
+
}
|
|
43
|
+
const template = readEmbeddedTemplate();
|
|
44
|
+
mkdirSync(dirname(policyPath), { recursive: true });
|
|
45
|
+
writeFileSync(policyPath, template, { encoding: 'utf-8' });
|
|
46
|
+
return {
|
|
47
|
+
policyPath,
|
|
48
|
+
schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
|
|
49
|
+
bytesWritten: Buffer.byteLength(template, 'utf-8'),
|
|
50
|
+
overwritten: force,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function exitPolicyError(kind, message, extra = {}) {
|
|
54
|
+
const code = kind === 'file-not-found' ? 2 : kind === 'yaml-parse' ? 3 : 4;
|
|
55
|
+
if (isJsonMode()) {
|
|
56
|
+
emitJsonError({ code, kind, message, ...extra });
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
console.error(message);
|
|
60
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
61
|
+
if (typeof v === 'string')
|
|
62
|
+
console.error(` ${k}: ${v}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
process.exit(code);
|
|
66
|
+
}
|
|
67
|
+
function summarizeChangeValue(v) {
|
|
68
|
+
if (v === null)
|
|
69
|
+
return 'null';
|
|
70
|
+
if (v === undefined)
|
|
71
|
+
return 'undefined';
|
|
72
|
+
if (typeof v === 'string')
|
|
73
|
+
return JSON.stringify(v.length > 64 ? `${v.slice(0, 61)}...` : v);
|
|
74
|
+
if (typeof v === 'number' || typeof v === 'boolean')
|
|
75
|
+
return String(v);
|
|
76
|
+
if (Array.isArray(v))
|
|
77
|
+
return `[array:${v.length}]`;
|
|
78
|
+
if (typeof v === 'object')
|
|
79
|
+
return `{object:${Object.keys(v).length}}`;
|
|
80
|
+
return String(v);
|
|
81
|
+
}
|
|
82
|
+
export function registerPolicyCommand(program) {
|
|
83
|
+
const policy = program
|
|
84
|
+
.command('policy')
|
|
85
|
+
.description('Validate, scaffold, and migrate policy.yaml for the OpenClaw SwitchBot skill')
|
|
86
|
+
.addHelpText('after', `
|
|
87
|
+
The policy file tells an AI agent your device aliases, quiet hours,
|
|
88
|
+
audit log path, and which actions always or never need confirmation.
|
|
89
|
+
Default location: ${DEFAULT_POLICY_PATH}
|
|
90
|
+
|
|
91
|
+
Subcommands:
|
|
92
|
+
validate [path] Check a policy file against the embedded schema
|
|
93
|
+
new [path] Write a starter policy to the default location (or a given path)
|
|
94
|
+
migrate [path] Upgrade a policy file to the latest supported schema
|
|
95
|
+
(v${CURRENT_POLICY_SCHEMA_VERSION} → v${LATEST_SUPPORTED_VERSION} today; no-op if already current)
|
|
96
|
+
diff <left> <right>
|
|
97
|
+
Compare two policy files and print structural + line diff
|
|
98
|
+
add-rule Append a rule YAML (from stdin) into automation.rules[]
|
|
99
|
+
|
|
100
|
+
Exit codes (validate):
|
|
101
|
+
0 valid
|
|
102
|
+
1 invalid (schema violations)
|
|
103
|
+
2 file not found
|
|
104
|
+
3 YAML parse error
|
|
105
|
+
4 internal error
|
|
106
|
+
|
|
107
|
+
Exit codes (migrate):
|
|
108
|
+
0 no-op (already on the target version) or successful migration
|
|
109
|
+
2 file not found
|
|
110
|
+
3 YAML parse error
|
|
111
|
+
6 source version unsupported by this CLI
|
|
112
|
+
7 migration precheck failed (the upgraded file would not validate)
|
|
113
|
+
|
|
114
|
+
Examples:
|
|
115
|
+
$ switchbot policy validate
|
|
116
|
+
$ switchbot policy validate ./policy.yaml
|
|
117
|
+
$ switchbot policy validate --json | jq '.data.errors'
|
|
118
|
+
$ switchbot policy new
|
|
119
|
+
$ switchbot policy new ./policy.yaml --force
|
|
120
|
+
$ switchbot policy migrate
|
|
121
|
+
$ switchbot policy diff ./policy.before.yaml ./policy.after.yaml
|
|
122
|
+
`);
|
|
123
|
+
policy
|
|
124
|
+
.command('validate [path]')
|
|
125
|
+
.description(`Validate a policy.yaml against the embedded v${CURRENT_POLICY_SCHEMA_VERSION} schema`)
|
|
126
|
+
.option('--no-color', 'disable ANSI color in human output')
|
|
127
|
+
.option('--no-snippet', 'omit the source-line + caret preview')
|
|
128
|
+
.action((pathArg, opts) => {
|
|
129
|
+
const policyPath = resolvePolicyPath({ flag: pathArg });
|
|
130
|
+
let loaded;
|
|
131
|
+
try {
|
|
132
|
+
loaded = loadPolicyFile(policyPath);
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
if (err instanceof PolicyFileNotFoundError) {
|
|
136
|
+
exitPolicyError('file-not-found', `policy file not found: ${err.policyPath}`, {
|
|
137
|
+
hint: `run \`switchbot policy new\` to create one at the default location (${DEFAULT_POLICY_PATH})`,
|
|
138
|
+
policyPath: err.policyPath,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (err instanceof PolicyYamlParseError) {
|
|
142
|
+
exitPolicyError('yaml-parse', `YAML parse error in ${err.policyPath}: ${err.message}`, {
|
|
143
|
+
policyPath: err.policyPath,
|
|
144
|
+
yamlErrors: err.yamlErrors,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
exitPolicyError('internal', `unexpected error loading policy: ${String(err)}`);
|
|
148
|
+
}
|
|
149
|
+
const result = validateLoadedPolicy(loaded);
|
|
150
|
+
if (isJsonMode()) {
|
|
151
|
+
printJson(result);
|
|
152
|
+
process.exit(result.valid ? 0 : 1);
|
|
153
|
+
}
|
|
154
|
+
console.log(formatValidationResult(result, loaded.source, {
|
|
155
|
+
color: opts.color !== false,
|
|
156
|
+
noSnippet: opts.snippet === false,
|
|
157
|
+
}));
|
|
158
|
+
process.exit(result.valid ? 0 : 1);
|
|
159
|
+
});
|
|
160
|
+
policy
|
|
161
|
+
.command('new [path]')
|
|
162
|
+
.description('Write a starter policy.yaml (fails if the file exists unless --force)')
|
|
163
|
+
.option('-f, --force', 'overwrite an existing policy file')
|
|
164
|
+
.action((pathArg, opts) => {
|
|
165
|
+
const policyPath = resolvePolicyPath({ flag: pathArg });
|
|
166
|
+
const force = opts.force === true;
|
|
167
|
+
let result;
|
|
168
|
+
try {
|
|
169
|
+
result = scaffoldPolicyFile(policyPath, { force });
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
if (err instanceof PolicyFileExistsError) {
|
|
173
|
+
const message = err.message;
|
|
174
|
+
const hint = 'pass --force to overwrite, or choose a different path';
|
|
175
|
+
if (isJsonMode()) {
|
|
176
|
+
emitJsonError({ code: 5, kind: 'exists', message, hint, policyPath });
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
console.error(message);
|
|
180
|
+
console.error(`hint: ${hint}`);
|
|
181
|
+
}
|
|
182
|
+
process.exit(5);
|
|
183
|
+
}
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
186
|
+
if (isJsonMode()) {
|
|
187
|
+
printJson(result);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
console.log(`✓ wrote starter policy to ${result.policyPath}`);
|
|
191
|
+
console.log(` schema version: ${result.schemaVersion}`);
|
|
192
|
+
console.log(` next steps:`);
|
|
193
|
+
console.log(` 1. open the file and fill in the aliases block`);
|
|
194
|
+
console.log(` 2. run \`switchbot policy validate\``);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
policy
|
|
198
|
+
.command('migrate [path]')
|
|
199
|
+
.description(`Upgrade a policy file to the latest supported schema (currently v${LATEST_SUPPORTED_VERSION})`)
|
|
200
|
+
.option('--dry-run', 'show what would change without writing the file')
|
|
201
|
+
.option('--to <version>', `target schema version (default: ${LATEST_SUPPORTED_VERSION})`, LATEST_SUPPORTED_VERSION)
|
|
202
|
+
.action((pathArg, opts) => {
|
|
203
|
+
const policyPath = resolvePolicyPath({ flag: pathArg });
|
|
204
|
+
let loaded;
|
|
205
|
+
try {
|
|
206
|
+
loaded = loadPolicyFile(policyPath);
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
if (err instanceof PolicyFileNotFoundError) {
|
|
210
|
+
exitPolicyError('file-not-found', `policy file not found: ${err.policyPath}`, {
|
|
211
|
+
hint: 'run `switchbot policy new` first',
|
|
212
|
+
policyPath: err.policyPath,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (err instanceof PolicyYamlParseError) {
|
|
216
|
+
exitPolicyError('yaml-parse', `YAML parse error in ${err.policyPath}: ${err.message}`, {
|
|
217
|
+
policyPath: err.policyPath,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
exitPolicyError('internal', `unexpected error loading policy: ${String(err)}`);
|
|
221
|
+
}
|
|
222
|
+
const data = loaded.data;
|
|
223
|
+
const fileVersion = typeof data?.version === 'string' ? data.version : undefined;
|
|
224
|
+
const target = opts.to ?? LATEST_SUPPORTED_VERSION;
|
|
225
|
+
const basePayload = {
|
|
226
|
+
policyPath,
|
|
227
|
+
fileVersion,
|
|
228
|
+
targetVersion: target,
|
|
229
|
+
supportedVersions: SUPPORTED_POLICY_SCHEMA_VERSIONS,
|
|
230
|
+
};
|
|
231
|
+
if (!fileVersion) {
|
|
232
|
+
const message = `policy has no \`version\` field — add \`version: "${CURRENT_POLICY_SCHEMA_VERSION}"\` and run \`switchbot policy validate\``;
|
|
233
|
+
const payload = { ...basePayload, status: 'no-version-field', message };
|
|
234
|
+
if (isJsonMode())
|
|
235
|
+
printJson(payload);
|
|
236
|
+
else
|
|
237
|
+
console.log(`! ${message}`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (!SUPPORTED_POLICY_SCHEMA_VERSIONS.includes(fileVersion)) {
|
|
241
|
+
const message = `policy schema v${fileVersion} is not supported by this CLI (supports: ${SUPPORTED_POLICY_SCHEMA_VERSIONS.join(', ')})`;
|
|
242
|
+
const hint = 'upgrade @switchbot/openapi-cli, or downgrade the policy file to a supported version';
|
|
243
|
+
if (isJsonMode())
|
|
244
|
+
emitJsonError({ code: 6, kind: 'unsupported-version', ...basePayload, message, hint });
|
|
245
|
+
else {
|
|
246
|
+
console.error(message);
|
|
247
|
+
console.error(`hint: ${hint}`);
|
|
248
|
+
}
|
|
249
|
+
process.exit(6);
|
|
250
|
+
}
|
|
251
|
+
if (!SUPPORTED_POLICY_SCHEMA_VERSIONS.includes(target)) {
|
|
252
|
+
const message = `--to ${target}: unknown target version (supports: ${SUPPORTED_POLICY_SCHEMA_VERSIONS.join(', ')})`;
|
|
253
|
+
if (isJsonMode())
|
|
254
|
+
emitJsonError({ code: 6, kind: 'unsupported-target', ...basePayload, message });
|
|
255
|
+
else
|
|
256
|
+
console.error(message);
|
|
257
|
+
process.exit(6);
|
|
258
|
+
}
|
|
259
|
+
if (fileVersion === target) {
|
|
260
|
+
const message = `already on schema v${target}; no migration needed`;
|
|
261
|
+
const payload = { ...basePayload, status: 'already-current', message, bytesWritten: 0 };
|
|
262
|
+
if (isJsonMode())
|
|
263
|
+
printJson(payload);
|
|
264
|
+
else
|
|
265
|
+
console.log(`✓ ${message}`);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
let plan;
|
|
269
|
+
try {
|
|
270
|
+
plan = planMigration(loaded, fileVersion, target);
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
if (err instanceof PolicyMigrationError) {
|
|
274
|
+
const payload = { ...basePayload, status: 'migration-error', kind: err.code, message: err.message };
|
|
275
|
+
if (isJsonMode())
|
|
276
|
+
emitJsonError({ code: 4, ...payload });
|
|
277
|
+
else
|
|
278
|
+
console.error(err.message);
|
|
279
|
+
process.exit(4);
|
|
280
|
+
}
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
if (!plan.precheck.valid) {
|
|
284
|
+
const message = `migrated policy fails schema v${target} precheck; file not written`;
|
|
285
|
+
const payload = {
|
|
286
|
+
...basePayload,
|
|
287
|
+
status: 'precheck-failed',
|
|
288
|
+
message,
|
|
289
|
+
errors: plan.precheck.errors,
|
|
290
|
+
};
|
|
291
|
+
if (isJsonMode())
|
|
292
|
+
emitJsonError({ code: 7, kind: 'migration-precheck-failed', ...payload });
|
|
293
|
+
else {
|
|
294
|
+
console.error(message);
|
|
295
|
+
console.error(formatValidationResult(plan.precheck, plan.nextSource, { color: true }));
|
|
296
|
+
console.error('hint: fix the validation errors above in the current file, then re-run `switchbot policy migrate`.');
|
|
297
|
+
}
|
|
298
|
+
process.exit(7);
|
|
299
|
+
}
|
|
300
|
+
const bytesWritten = Buffer.byteLength(plan.nextSource, 'utf-8');
|
|
301
|
+
const finalPayload = {
|
|
302
|
+
...basePayload,
|
|
303
|
+
status: opts.dryRun ? 'dry-run' : 'migrated',
|
|
304
|
+
from: plan.fromVersion,
|
|
305
|
+
to: plan.toVersion,
|
|
306
|
+
bytesWritten: opts.dryRun ? 0 : bytesWritten,
|
|
307
|
+
};
|
|
308
|
+
if (opts.dryRun) {
|
|
309
|
+
if (isJsonMode())
|
|
310
|
+
printJson(finalPayload);
|
|
311
|
+
else {
|
|
312
|
+
console.log(`• dry-run: would upgrade ${policyPath} (v${plan.fromVersion} → v${plan.toVersion})`);
|
|
313
|
+
console.log(` bytes: ${bytesWritten}`);
|
|
314
|
+
console.log(` precheck: valid against v${target}`);
|
|
315
|
+
}
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
writeFileSync(policyPath, plan.nextSource, { encoding: 'utf-8' });
|
|
319
|
+
if (isJsonMode())
|
|
320
|
+
printJson(finalPayload);
|
|
321
|
+
else {
|
|
322
|
+
console.log(`✓ migrated ${policyPath} to schema v${plan.toVersion} (from v${plan.fromVersion})`);
|
|
323
|
+
console.log(` bytes written: ${bytesWritten}`);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
policy
|
|
327
|
+
.command('diff <left> <right>')
|
|
328
|
+
.description('Compare two policy files and print structural changes + line diff')
|
|
329
|
+
.action((leftPath, rightPath) => {
|
|
330
|
+
let leftSource = '';
|
|
331
|
+
let rightSource = '';
|
|
332
|
+
try {
|
|
333
|
+
leftSource = readFileSync(leftPath, 'utf-8');
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
if (err?.code === 'ENOENT') {
|
|
337
|
+
exitPolicyError('file-not-found', `policy file not found: ${leftPath}`, { policyPath: leftPath });
|
|
338
|
+
}
|
|
339
|
+
exitPolicyError('internal', `failed to read ${leftPath}: ${String(err)}`);
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
rightSource = readFileSync(rightPath, 'utf-8');
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
if (err?.code === 'ENOENT') {
|
|
346
|
+
exitPolicyError('file-not-found', `policy file not found: ${rightPath}`, { policyPath: rightPath });
|
|
347
|
+
}
|
|
348
|
+
exitPolicyError('internal', `failed to read ${rightPath}: ${String(err)}`);
|
|
349
|
+
}
|
|
350
|
+
let leftDoc;
|
|
351
|
+
let rightDoc;
|
|
352
|
+
try {
|
|
353
|
+
leftDoc = yamlParse(leftSource);
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
exitPolicyError('yaml-parse', `YAML parse error in ${leftPath}: ${err.message}`, {
|
|
357
|
+
policyPath: leftPath,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
rightDoc = yamlParse(rightSource);
|
|
362
|
+
}
|
|
363
|
+
catch (err) {
|
|
364
|
+
exitPolicyError('yaml-parse', `YAML parse error in ${rightPath}: ${err.message}`, {
|
|
365
|
+
policyPath: rightPath,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
const result = diffPolicyValues(leftDoc, rightDoc, leftSource, rightSource);
|
|
369
|
+
if (isJsonMode()) {
|
|
370
|
+
printJson({
|
|
371
|
+
leftPath,
|
|
372
|
+
rightPath,
|
|
373
|
+
...result,
|
|
374
|
+
});
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (result.equal) {
|
|
378
|
+
console.log(`✓ no structural differences between ${leftPath} and ${rightPath}`);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
console.log(`~ policy diff: ${leftPath} -> ${rightPath}`);
|
|
382
|
+
console.log(` changes: ${result.changeCount} (added=${result.stats.added}, removed=${result.stats.removed}, changed=${result.stats.changed})`);
|
|
383
|
+
if (result.truncated) {
|
|
384
|
+
console.log(' note: output truncated at max structural changes');
|
|
385
|
+
}
|
|
386
|
+
for (const c of result.changes) {
|
|
387
|
+
if (c.kind === 'added') {
|
|
388
|
+
console.log(` + ${c.path}: ${summarizeChangeValue(c.after)}`);
|
|
389
|
+
}
|
|
390
|
+
else if (c.kind === 'removed') {
|
|
391
|
+
console.log(` - ${c.path}: ${summarizeChangeValue(c.before)}`);
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
console.log(` ~ ${c.path}: ${summarizeChangeValue(c.before)} -> ${summarizeChangeValue(c.after)}`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
console.log('');
|
|
398
|
+
console.log(result.diff);
|
|
399
|
+
});
|
|
400
|
+
policy
|
|
401
|
+
.command('add-rule')
|
|
402
|
+
.description('Append a rule (read from stdin) into automation.rules[] in policy.yaml')
|
|
403
|
+
.option('--policy <path>', 'Path to policy.yaml (or set $SWITCHBOT_POLICY_PATH)')
|
|
404
|
+
.option('--enable', 'Set automation.enabled: true after inserting the rule')
|
|
405
|
+
.option('--force', 'Overwrite an existing rule with the same name')
|
|
406
|
+
.option('--dry-run', 'Print the diff without writing to disk')
|
|
407
|
+
.addHelpText('after', `
|
|
408
|
+
Reads rule YAML from stdin. Combine with 'rules suggest' for a full pipeline:
|
|
409
|
+
|
|
410
|
+
$ switchbot rules suggest --intent "turn off lights at 10pm" --trigger cron \\
|
|
411
|
+
--device <id> | switchbot policy add-rule --dry-run
|
|
412
|
+
$ switchbot rules suggest --intent "turn off lights at 10pm" --trigger cron \\
|
|
413
|
+
--device <id> | switchbot policy add-rule --enable
|
|
414
|
+
`)
|
|
415
|
+
.action(async (opts) => {
|
|
416
|
+
const policyPath = resolvePolicyPath({ flag: opts.policy });
|
|
417
|
+
let ruleYaml;
|
|
418
|
+
try {
|
|
419
|
+
ruleYaml = await readStdinText();
|
|
420
|
+
}
|
|
421
|
+
catch (err) {
|
|
422
|
+
exitPolicyError('internal', `failed to read stdin: ${err.message}`);
|
|
423
|
+
}
|
|
424
|
+
if (!ruleYaml.trim()) {
|
|
425
|
+
exitPolicyError('internal', 'no rule YAML received on stdin');
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
const result = addRuleToPolicyFile({
|
|
429
|
+
ruleYaml: ruleYaml,
|
|
430
|
+
policyPath,
|
|
431
|
+
enableAutomation: opts.enable,
|
|
432
|
+
force: opts.force,
|
|
433
|
+
dryRun: opts.dryRun,
|
|
434
|
+
});
|
|
435
|
+
if (isJsonMode()) {
|
|
436
|
+
printJson({
|
|
437
|
+
policyPath,
|
|
438
|
+
ruleName: result.ruleName,
|
|
439
|
+
written: result.written,
|
|
440
|
+
diff: result.diff,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
console.log(result.diff);
|
|
445
|
+
if (result.written) {
|
|
446
|
+
console.log(`✓ rule "${result.ruleName}" added to ${policyPath}`);
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
console.log(`• dry-run: rule "${result.ruleName}" not written`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
catch (err) {
|
|
454
|
+
if (err instanceof AddRuleError) {
|
|
455
|
+
exitPolicyError('internal', err.message, { kind: err.code });
|
|
456
|
+
}
|
|
457
|
+
throw err;
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
function readStdinText() {
|
|
462
|
+
return new Promise((resolve, reject) => {
|
|
463
|
+
let buf = '';
|
|
464
|
+
process.stdin.setEncoding('utf8');
|
|
465
|
+
process.stdin.on('data', (chunk) => (buf += chunk));
|
|
466
|
+
process.stdin.on('end', () => resolve(buf));
|
|
467
|
+
process.stdin.on('error', reject);
|
|
468
|
+
});
|
|
469
|
+
}
|