@switchbot/openapi-cli 2.6.4 → 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.
Files changed (67) hide show
  1. package/README.md +385 -103
  2. package/dist/api/client.js +13 -12
  3. package/dist/commands/agent-bootstrap.js +67 -16
  4. package/dist/commands/auth.js +354 -0
  5. package/dist/commands/batch.js +26 -21
  6. package/dist/commands/capabilities.js +29 -21
  7. package/dist/commands/catalog.js +4 -3
  8. package/dist/commands/config.js +57 -37
  9. package/dist/commands/devices.js +63 -37
  10. package/dist/commands/doctor.js +539 -26
  11. package/dist/commands/events.js +115 -26
  12. package/dist/commands/expand.js +7 -15
  13. package/dist/commands/explain.js +10 -7
  14. package/dist/commands/history.js +12 -18
  15. package/dist/commands/identity.js +59 -0
  16. package/dist/commands/install.js +246 -0
  17. package/dist/commands/mcp.js +895 -15
  18. package/dist/commands/plan.js +111 -15
  19. package/dist/commands/policy.js +469 -0
  20. package/dist/commands/rules.js +657 -0
  21. package/dist/commands/schema.js +20 -12
  22. package/dist/commands/status-sync.js +131 -0
  23. package/dist/commands/uninstall.js +237 -0
  24. package/dist/commands/watch.js +15 -2
  25. package/dist/config.js +14 -0
  26. package/dist/credentials/backends/file.js +101 -0
  27. package/dist/credentials/backends/linux.js +129 -0
  28. package/dist/credentials/backends/macos.js +129 -0
  29. package/dist/credentials/backends/windows.js +215 -0
  30. package/dist/credentials/keychain.js +88 -0
  31. package/dist/credentials/prime.js +52 -0
  32. package/dist/devices/catalog.js +118 -11
  33. package/dist/devices/resources.js +270 -0
  34. package/dist/index.js +39 -4
  35. package/dist/install/default-steps.js +257 -0
  36. package/dist/install/preflight.js +212 -0
  37. package/dist/install/steps.js +67 -0
  38. package/dist/lib/command-keywords.js +17 -0
  39. package/dist/lib/devices.js +15 -5
  40. package/dist/policy/add-rule.js +124 -0
  41. package/dist/policy/diff.js +91 -0
  42. package/dist/policy/examples/policy.example.yaml +99 -0
  43. package/dist/policy/format.js +57 -0
  44. package/dist/policy/load.js +61 -0
  45. package/dist/policy/migrate.js +67 -0
  46. package/dist/policy/schema/v0.2.json +302 -0
  47. package/dist/policy/schema.js +18 -0
  48. package/dist/policy/validate.js +262 -0
  49. package/dist/rules/action.js +205 -0
  50. package/dist/rules/audit-query.js +89 -0
  51. package/dist/rules/cron-scheduler.js +186 -0
  52. package/dist/rules/destructive.js +52 -0
  53. package/dist/rules/engine.js +567 -0
  54. package/dist/rules/matcher.js +230 -0
  55. package/dist/rules/pid-file.js +95 -0
  56. package/dist/rules/quiet-hours.js +45 -0
  57. package/dist/rules/suggest.js +95 -0
  58. package/dist/rules/throttle.js +78 -0
  59. package/dist/rules/types.js +34 -0
  60. package/dist/rules/webhook-listener.js +223 -0
  61. package/dist/rules/webhook-token.js +90 -0
  62. package/dist/schema/field-aliases.js +95 -0
  63. package/dist/status-sync/manager.js +268 -0
  64. package/dist/utils/audit.js +12 -2
  65. package/dist/utils/help-json.js +54 -0
  66. package/dist/utils/output.js +17 -0
  67. package/package.json +12 -4
@@ -87,6 +87,15 @@ export function createClient() {
87
87
  }
88
88
  throw new DryRunSignal(method, url);
89
89
  }
90
+ // P8: record the quota attempt BEFORE the request is dispatched so
91
+ // failures (timeouts / DNS errors / 5xx / aborted) also count. Only
92
+ // pre-flight refusals (daily-cap, --dry-run) above skip recording
93
+ // since they never touch the network. Retries re-enter this
94
+ // interceptor and record again, which matches the SwitchBot API
95
+ // billing model (every dispatched HTTP request consumes quota).
96
+ if (quotaEnabled) {
97
+ recordRequest(method, url);
98
+ }
90
99
  return config;
91
100
  });
92
101
  // Handle API-level errors (HTTP 200 but statusCode !== 100)
@@ -94,11 +103,6 @@ export function createClient() {
94
103
  if (verbose) {
95
104
  process.stderr.write(chalk.grey(`[verbose] ${response.status} ${response.statusText}\n`));
96
105
  }
97
- if (quotaEnabled && response.config) {
98
- const method = (response.config.method ?? 'get').toUpperCase();
99
- const url = `${response.config.baseURL ?? ''}${response.config.url ?? ''}`;
100
- recordRequest(method, url);
101
- }
102
106
  const data = response.data;
103
107
  if (data.statusCode !== undefined && data.statusCode !== 100) {
104
108
  const msg = API_ERROR_MESSAGES[data.statusCode] ??
@@ -161,13 +165,10 @@ export function createClient() {
161
165
  return sleep(delay).then(() => client.request(config));
162
166
  }
163
167
  }
164
- // Record exhausted/non-retryable HTTP responses too they count
165
- // against the daily quota.
166
- if (quotaEnabled && error.response && config) {
167
- const method = (config.method ?? 'get').toUpperCase();
168
- const url = `${config.baseURL ?? ''}${config.url ?? ''}`;
169
- recordRequest(method, url);
170
- }
168
+ // P8: quota already recorded in the request interceptor before
169
+ // dispatch — no extra bookkeeping needed here on the error path.
170
+ // Timeouts, DNS failures, 5xx, and exhausted retries all counted
171
+ // when the attempt was first made.
171
172
  if (status === 401) {
172
173
  throw new ApiError('Authentication failed: invalid token or daily 10,000-request quota exceeded', 401, {
173
174
  transient: false,
@@ -1,19 +1,24 @@
1
1
  import { printJson } from '../utils/output.js';
2
2
  import { loadCache } from '../devices/cache.js';
3
- import { getEffectiveCatalog } from '../devices/catalog.js';
3
+ import { getEffectiveCatalog, deriveSafetyTier, CATALOG_SCHEMA_VERSION, } from '../devices/catalog.js';
4
4
  import { readProfileMeta } from '../config.js';
5
5
  import { todayUsage, DAILY_QUOTA } from '../utils/quota.js';
6
6
  import { ALL_STRATEGIES } from '../utils/name-resolver.js';
7
+ import { IDENTITY } from './identity.js';
8
+ import { resolvePolicyPath, loadPolicyFile, PolicyFileNotFoundError, PolicyYamlParseError, } from '../policy/load.js';
9
+ import { validateLoadedPolicy } from '../policy/validate.js';
10
+ import { selectCredentialStore } from '../credentials/keychain.js';
7
11
  import { createRequire } from 'node:module';
8
12
  const require = createRequire(import.meta.url);
9
13
  const { version: pkgVersion } = require('../../package.json');
10
- const IDENTITY = {
11
- product: 'SwitchBot',
12
- domain: 'IoT smart home device control',
13
- vendor: 'Wonderlabs, Inc.',
14
- apiVersion: 'v1.1',
15
- authMethod: 'HMAC-SHA256 token+secret',
16
- };
14
+ /**
15
+ * Schema version of the agent-bootstrap payload. Must stay in lockstep
16
+ * with the catalog schema — bootstrap consumers (AI agents) reason about
17
+ * catalog-derived fields (safetyTier, destructive flag), so a drift
18
+ * between the two would silently break their assumptions. `doctor`
19
+ * fails the `catalog-schema` check when these differ.
20
+ */
21
+ export const AGENT_BOOTSTRAP_SCHEMA_VERSION = CATALOG_SCHEMA_VERSION;
17
22
  const SAFETY_TIERS = {
18
23
  read: 'No state mutation; safe to call freely.',
19
24
  action: 'Mutates device/cloud state but reversible (turnOn, setColor).',
@@ -26,7 +31,47 @@ const QUICK_REFERENCE = {
26
31
  observability: ['doctor --json', 'quota status', 'cache status', 'events mqtt-tail'],
27
32
  history: ['history range <id> --since 7d', 'history stats <id>'],
28
33
  meta: ['devices meta set <id> --alias <name>', 'devices meta list', 'devices meta get <id>'],
34
+ policy: ['policy validate', 'policy new', 'policy migrate'],
35
+ auth: ['auth keychain describe', 'auth keychain migrate', 'auth keychain get'],
29
36
  };
37
+ function readPolicyStatus() {
38
+ // Lightweight read — used by the bootstrap payload so agents know whether
39
+ // a policy file exists and is healthy without shelling out to
40
+ // `switchbot policy validate`. Parallel to `checkPolicy` in doctor but
41
+ // returns a more compact shape (no first-error drill-down; agents who
42
+ // want that run the dedicated command).
43
+ const policyPath = resolvePolicyPath();
44
+ try {
45
+ const loaded = loadPolicyFile(policyPath);
46
+ const result = validateLoadedPolicy(loaded);
47
+ return {
48
+ present: true,
49
+ valid: result.valid,
50
+ path: policyPath,
51
+ schemaVersion: result.schemaVersion,
52
+ errorCount: result.valid ? 0 : result.errors.length,
53
+ };
54
+ }
55
+ catch (err) {
56
+ if (err instanceof PolicyFileNotFoundError) {
57
+ return { present: false, valid: null, path: policyPath };
58
+ }
59
+ if (err instanceof PolicyYamlParseError) {
60
+ return { present: true, valid: false, path: policyPath, errorCount: 1 };
61
+ }
62
+ return { present: false, valid: null, path: policyPath };
63
+ }
64
+ }
65
+ async function readCredentialsBackend() {
66
+ try {
67
+ const store = await selectCredentialStore();
68
+ const desc = store.describe();
69
+ return { name: store.name, label: desc.backend, writable: desc.writable };
70
+ }
71
+ catch {
72
+ return { name: 'file', label: 'File (~/.switchbot/config.json)', writable: true };
73
+ }
74
+ }
30
75
  export function registerAgentBootstrapCommand(program) {
31
76
  program
32
77
  .command('agent-bootstrap')
@@ -47,12 +92,13 @@ Examples:
47
92
  $ switchbot agent-bootstrap | jq '.devices | length'
48
93
  $ switchbot agent-bootstrap --compact | jq '.quickReference'
49
94
  `)
50
- .action((opts) => {
95
+ .action(async (opts) => {
51
96
  const compact = Boolean(opts.compact);
52
97
  const cache = loadCache();
53
98
  const catalog = getEffectiveCatalog();
54
99
  const usage = todayUsage();
55
100
  const meta = readProfileMeta(undefined);
101
+ const credentialsBackend = await readCredentialsBackend();
56
102
  const cachedDevices = cache
57
103
  ? Object.entries(cache.devices).map(([id, d]) => ({
58
104
  deviceId: id,
@@ -83,17 +129,20 @@ Examples:
83
129
  category: e.category,
84
130
  role: e.role ?? null,
85
131
  readOnly: e.readOnly ?? false,
86
- commands: e.commands.map((c) => ({
87
- command: c.command,
88
- parameter: c.parameter,
89
- destructive: Boolean(c.destructive),
90
- idempotent: Boolean(c.idempotent),
91
- })),
132
+ commands: e.commands.map((c) => {
133
+ const tier = deriveSafetyTier(c, e);
134
+ return {
135
+ command: c.command,
136
+ parameter: c.parameter,
137
+ safetyTier: tier,
138
+ idempotent: Boolean(c.idempotent),
139
+ };
140
+ }),
92
141
  statusFields: e.statusFields ?? [],
93
142
  };
94
143
  });
95
144
  const payload = {
96
- schemaVersion: '1.0',
145
+ schemaVersion: AGENT_BOOTSTRAP_SCHEMA_VERSION,
97
146
  generatedAt: new Date().toISOString(),
98
147
  cliVersion: pkgVersion,
99
148
  identity: IDENTITY,
@@ -114,6 +163,8 @@ Examples:
114
163
  remaining: usage.remaining,
115
164
  dailyLimit: DAILY_QUOTA,
116
165
  },
166
+ policyStatus: readPolicyStatus(),
167
+ credentialsBackend,
117
168
  devices: cachedDevices,
118
169
  catalog: {
119
170
  scope: cachedDevices.length > 0 ? 'used' : 'all',
@@ -0,0 +1,354 @@
1
+ /**
2
+ * `switchbot auth` command group (v2.9 preview, part of Phase 3A).
3
+ *
4
+ * Surfaces the credential store abstraction added in F1/F2 so users
5
+ * can introspect, write to, delete from, and migrate into the OS
6
+ * keychain without editing `~/.switchbot/config.json` by hand.
7
+ *
8
+ * All subcommands honour the active `--profile <name>` flag so a user
9
+ * who runs multiple accounts keeps the keychain entries cleanly
10
+ * partitioned.
11
+ *
12
+ * No credential material is ever printed in plain text. `get` emits
13
+ * a masked summary only; `set` reads via a TTY prompt (echo-off) or a
14
+ * file passed via `--stdin-file <path>`. `migrate` never touches the
15
+ * keychain unless the backend reports `writable: true`.
16
+ */
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import os from 'node:os';
20
+ import readline from 'node:readline';
21
+ import { exitWithError, isJsonMode, printJson } from '../utils/output.js';
22
+ import { stringArg } from '../utils/arg-parsers.js';
23
+ import { getActiveProfile } from '../lib/request-context.js';
24
+ import { selectCredentialStore, } from '../credentials/keychain.js';
25
+ function activeProfile() {
26
+ return getActiveProfile() ?? 'default';
27
+ }
28
+ function maskValue(value) {
29
+ if (value.length === 0)
30
+ return '';
31
+ if (value.length <= 4)
32
+ return '*'.repeat(value.length);
33
+ const head = value.slice(0, 2);
34
+ const tail = value.slice(-2);
35
+ return `${head}${'*'.repeat(Math.max(4, value.length - 4))}${tail}`;
36
+ }
37
+ async function promptSecret(question) {
38
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: true });
39
+ return new Promise((resolve) => {
40
+ process.stderr.write(question);
41
+ const stdin = process.stdin;
42
+ let answer = '';
43
+ const onData = (chunk) => {
44
+ const s = chunk.toString('utf-8');
45
+ for (const ch of s) {
46
+ if (ch === '\r' || ch === '\n') {
47
+ stdin.removeListener('data', onData);
48
+ if (stdin.setRawMode)
49
+ stdin.setRawMode(false);
50
+ stdin.pause();
51
+ process.stderr.write('\n');
52
+ rl.close();
53
+ resolve(answer);
54
+ return;
55
+ }
56
+ if (ch === '\u0003') {
57
+ process.exit(130);
58
+ }
59
+ if (ch === '\u007f' || ch === '\b') {
60
+ answer = answer.slice(0, -1);
61
+ continue;
62
+ }
63
+ answer += ch;
64
+ }
65
+ };
66
+ if (stdin.setRawMode)
67
+ stdin.setRawMode(true);
68
+ stdin.resume();
69
+ stdin.on('data', onData);
70
+ });
71
+ }
72
+ function readStdinFile(filePath) {
73
+ if (!fs.existsSync(filePath)) {
74
+ exitWithError({
75
+ code: 2,
76
+ kind: 'usage',
77
+ message: `--stdin-file: file not found: ${filePath}`,
78
+ });
79
+ }
80
+ let parsed;
81
+ try {
82
+ parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
83
+ }
84
+ catch (err) {
85
+ exitWithError({
86
+ code: 2,
87
+ kind: 'usage',
88
+ message: `--stdin-file: invalid JSON: ${err instanceof Error ? err.message : String(err)}`,
89
+ });
90
+ }
91
+ if (!parsed ||
92
+ typeof parsed !== 'object' ||
93
+ typeof parsed.token !== 'string' ||
94
+ typeof parsed.secret !== 'string') {
95
+ exitWithError({
96
+ code: 2,
97
+ kind: 'usage',
98
+ message: '--stdin-file must contain a JSON object with "token" and "secret" strings.',
99
+ });
100
+ }
101
+ const { token, secret } = parsed;
102
+ if (!token || !secret) {
103
+ exitWithError({
104
+ code: 2,
105
+ kind: 'usage',
106
+ message: '--stdin-file: token and secret must be non-empty.',
107
+ });
108
+ }
109
+ return { token, secret };
110
+ }
111
+ function cleanupMigratedSourceFile(sourceFile, parsed) {
112
+ const next = { ...parsed };
113
+ delete next.token;
114
+ delete next.secret;
115
+ if (Object.keys(next).length === 0) {
116
+ fs.unlinkSync(sourceFile);
117
+ return 'deleted';
118
+ }
119
+ fs.writeFileSync(sourceFile, JSON.stringify(next, null, 2), { mode: 0o600 });
120
+ return 'scrubbed';
121
+ }
122
+ export function registerAuthCommand(program) {
123
+ const auth = program
124
+ .command('auth')
125
+ .description('Manage SwitchBot credentials in the OS keychain (preview)');
126
+ const keychain = auth
127
+ .command('keychain')
128
+ .description('OS keychain backend (describe/get/set/delete/migrate)');
129
+ keychain
130
+ .command('describe')
131
+ .description('Show which credential backend is active on this machine')
132
+ .action(async () => {
133
+ const store = await selectCredentialStore();
134
+ const desc = store.describe();
135
+ if (isJsonMode()) {
136
+ printJson(desc);
137
+ return;
138
+ }
139
+ console.log(`backend : ${desc.backend}`);
140
+ console.log(`tag : ${desc.tag}`);
141
+ console.log(`writable: ${desc.writable ? 'yes' : 'no'}`);
142
+ if (desc.notes)
143
+ console.log(`notes : ${desc.notes}`);
144
+ });
145
+ keychain
146
+ .command('get')
147
+ .description('Check whether the active profile has credentials (masked output)')
148
+ .action(async () => {
149
+ const profile = activeProfile();
150
+ const store = await selectCredentialStore();
151
+ const creds = await store.get(profile);
152
+ if (!creds) {
153
+ if (isJsonMode()) {
154
+ printJson({ profile, backend: store.name, present: false });
155
+ return;
156
+ }
157
+ console.log(`No credentials found for profile "${profile}" in backend "${store.name}".`);
158
+ process.exit(1);
159
+ }
160
+ if (isJsonMode()) {
161
+ printJson({
162
+ profile,
163
+ backend: store.name,
164
+ present: true,
165
+ token: { length: creds.token.length, masked: maskValue(creds.token) },
166
+ secret: { length: creds.secret.length, masked: maskValue(creds.secret) },
167
+ });
168
+ return;
169
+ }
170
+ console.log(`profile : ${profile}`);
171
+ console.log(`backend : ${store.name}`);
172
+ console.log(`token : ${maskValue(creds.token)} (${creds.token.length} chars)`);
173
+ console.log(`secret : ${maskValue(creds.secret)} (${creds.secret.length} chars)`);
174
+ });
175
+ keychain
176
+ .command('set')
177
+ .description('Write token and secret to the keychain for the active profile')
178
+ .option('--stdin-file <path>', 'Read {"token","secret"} JSON from file (for non-TTY environments)', stringArg('--stdin-file'))
179
+ .action(async (options) => {
180
+ const profile = activeProfile();
181
+ const store = await selectCredentialStore();
182
+ if (!store.describe().writable) {
183
+ exitWithError({
184
+ code: 1,
185
+ kind: 'runtime',
186
+ message: `backend "${store.name}" is not writable on this machine`,
187
+ hint: 'Install the OS keychain helper or use ~/.switchbot/config.json directly.',
188
+ });
189
+ }
190
+ let bundle;
191
+ if (options.stdinFile) {
192
+ bundle = readStdinFile(options.stdinFile);
193
+ }
194
+ else if (process.stdin.isTTY) {
195
+ const token = (await promptSecret('Token : ')).trim();
196
+ const secret = (await promptSecret('Secret: ')).trim();
197
+ if (!token || !secret) {
198
+ exitWithError({
199
+ code: 2,
200
+ kind: 'usage',
201
+ message: 'Both token and secret are required.',
202
+ });
203
+ }
204
+ bundle = { token, secret };
205
+ }
206
+ else {
207
+ exitWithError({
208
+ code: 2,
209
+ kind: 'usage',
210
+ message: 'Non-TTY input requires --stdin-file <path>.',
211
+ });
212
+ }
213
+ try {
214
+ await store.set(profile, bundle);
215
+ }
216
+ catch (err) {
217
+ exitWithError({
218
+ code: 1,
219
+ kind: 'runtime',
220
+ message: `keychain write failed: ${err instanceof Error ? err.message : String(err)}`,
221
+ });
222
+ }
223
+ if (isJsonMode()) {
224
+ printJson({ profile, backend: store.name, written: true });
225
+ return;
226
+ }
227
+ console.log(`Stored credentials for profile "${profile}" in backend "${store.name}".`);
228
+ });
229
+ keychain
230
+ .command('delete')
231
+ .description('Remove credentials for the active profile from the keychain')
232
+ .option('--yes', 'Skip the interactive confirmation prompt')
233
+ .action(async (options) => {
234
+ const profile = activeProfile();
235
+ const store = await selectCredentialStore();
236
+ if (!options.yes && process.stdin.isTTY) {
237
+ const reply = (await promptSecret(`Delete credentials for profile "${profile}" from backend "${store.name}"? type DELETE to confirm: `)).trim();
238
+ if (reply !== 'DELETE') {
239
+ if (isJsonMode()) {
240
+ printJson({ profile, backend: store.name, deleted: false, reason: 'cancelled' });
241
+ return;
242
+ }
243
+ console.log('Aborted.');
244
+ process.exit(0);
245
+ }
246
+ }
247
+ try {
248
+ await store.delete(profile);
249
+ }
250
+ catch (err) {
251
+ exitWithError({
252
+ code: 1,
253
+ kind: 'runtime',
254
+ message: `keychain delete failed: ${err instanceof Error ? err.message : String(err)}`,
255
+ });
256
+ }
257
+ if (isJsonMode()) {
258
+ printJson({ profile, backend: store.name, deleted: true });
259
+ return;
260
+ }
261
+ console.log(`Deleted credentials for profile "${profile}" in backend "${store.name}".`);
262
+ });
263
+ keychain
264
+ .command('migrate')
265
+ .description('Copy credentials from ~/.switchbot/config.json (or --profile) into the keychain')
266
+ .option('--delete-file', 'Remove the source credential file when possible; otherwise scrub token/secret and keep metadata')
267
+ .action(async (options) => {
268
+ const profile = activeProfile();
269
+ const store = await selectCredentialStore();
270
+ if (!store.describe().writable) {
271
+ exitWithError({
272
+ code: 1,
273
+ kind: 'runtime',
274
+ message: `backend "${store.name}" is not writable on this machine`,
275
+ });
276
+ }
277
+ const sourceFile = profile === 'default'
278
+ ? path.join(os.homedir(), '.switchbot', 'config.json')
279
+ : path.join(os.homedir(), '.switchbot', 'profiles', `${profile}.json`);
280
+ if (!fs.existsSync(sourceFile)) {
281
+ exitWithError({
282
+ code: 2,
283
+ kind: 'usage',
284
+ message: `source file not found: ${sourceFile}`,
285
+ hint: 'Run "switchbot config set-token" first or use "switchbot auth keychain set" directly.',
286
+ });
287
+ }
288
+ let parsed;
289
+ try {
290
+ const raw = JSON.parse(fs.readFileSync(sourceFile, 'utf-8'));
291
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
292
+ throw new Error('expected a JSON object');
293
+ }
294
+ parsed = raw;
295
+ }
296
+ catch (err) {
297
+ exitWithError({
298
+ code: 1,
299
+ kind: 'runtime',
300
+ message: `failed to parse ${sourceFile}: ${err instanceof Error ? err.message : String(err)}`,
301
+ });
302
+ }
303
+ const token = typeof parsed.token === 'string' ? parsed.token : '';
304
+ const secret = typeof parsed.secret === 'string' ? parsed.secret : '';
305
+ if (!token || !secret) {
306
+ exitWithError({
307
+ code: 1,
308
+ kind: 'runtime',
309
+ message: `source file missing token or secret: ${sourceFile}`,
310
+ });
311
+ }
312
+ try {
313
+ await store.set(profile, { token, secret });
314
+ }
315
+ catch (err) {
316
+ exitWithError({
317
+ code: 1,
318
+ kind: 'runtime',
319
+ message: `keychain write failed: ${err instanceof Error ? err.message : String(err)}`,
320
+ });
321
+ }
322
+ let cleanup = 'kept';
323
+ if (options.deleteFile) {
324
+ try {
325
+ cleanup = cleanupMigratedSourceFile(sourceFile, parsed);
326
+ }
327
+ catch (err) {
328
+ // Non-fatal: migration succeeded, we just couldn't clean up.
329
+ console.error(`warning: could not remove ${sourceFile}: ${err instanceof Error ? err.message : String(err)}`);
330
+ }
331
+ }
332
+ if (isJsonMode()) {
333
+ printJson({
334
+ profile,
335
+ backend: store.name,
336
+ migrated: true,
337
+ sourceFile,
338
+ sourceDeleted: cleanup === 'deleted',
339
+ sourceScrubbed: cleanup === 'scrubbed',
340
+ });
341
+ return;
342
+ }
343
+ console.log(`Migrated profile "${profile}" to backend "${store.name}".`);
344
+ const cleanupNote = cleanup === 'deleted'
345
+ ? ' (deleted)'
346
+ : cleanup === 'scrubbed'
347
+ ? ' (credentials removed; metadata kept)'
348
+ : '';
349
+ console.log(`source: ${sourceFile}${cleanupNote}`);
350
+ if (!options.deleteFile) {
351
+ console.log('Source file kept — pass --delete-file on the next run to remove it.');
352
+ }
353
+ });
354
+ }
@@ -1,5 +1,5 @@
1
1
  import { intArg, enumArg, stringArg } from '../utils/arg-parsers.js';
2
- import { printJson, isJsonMode, handleError, buildErrorPayload, UsageError, emitJsonError, exitWithError } from '../utils/output.js';
2
+ import { printJson, isJsonMode, handleError, buildErrorPayload, UsageError, exitWithError } from '../utils/output.js';
3
3
  import { fetchDeviceList, executeCommand, isDestructiveCommand, buildHubLocationMap, } from '../lib/devices.js';
4
4
  import { createClient } from '../api/client.js';
5
5
  import { parseFilter, applyFilter, FilterSyntaxError } from '../utils/filter.js';
@@ -96,7 +96,8 @@ export function registerBatchCommand(devices) {
96
96
  .option('--concurrency <n>', 'Max parallel in-flight requests (default 5)', intArg('--concurrency', { min: 1 }), '5')
97
97
  .option('--max-concurrent <n>', 'Alias for --concurrency; takes priority when set', intArg('--max-concurrent', { min: 1 }))
98
98
  .option('--stagger <ms>', 'Fixed delay between task starts in ms (default 0 = random 20-60ms jitter)', intArg('--stagger', { min: 0 }), '0')
99
- .option('--plan', 'With --dry-run: emit a plan JSON document instead of executing anything')
99
+ .option('--plan', '[DEPRECATED, use --emit-plan] With --dry-run: emit a plan JSON document instead of executing anything')
100
+ .option('--emit-plan', 'With --dry-run: emit a plan JSON document instead of executing anything')
100
101
  .option('--yes', 'Allow destructive commands (Smart Lock unlock, garage open, ...)')
101
102
  .option('--type <commandType>', '"command" (default) or "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
102
103
  .option('--stdin', 'Read deviceIds from stdin, one per line (same as trailing "-")')
@@ -127,8 +128,9 @@ Concurrency & pacing:
127
128
  --stagger <ms> Fixed delay between task starts; default 0 uses random 20-60ms jitter.
128
129
 
129
130
  Planning:
130
- --dry-run --plan Print the plan JSON without executing anything. Useful
131
+ --dry-run --emit-plan Print the plan JSON without executing anything. Useful
131
132
  for agents that want to show the user what will run.
133
+ (--plan is the deprecated alias, removed in v3.0.)
132
134
 
133
135
  Safety:
134
136
  Destructive commands (Smart Lock unlock, Garage Door Opener turnOn/turnOff,
@@ -146,6 +148,17 @@ Examples:
146
148
  // Trailing "-" sentinel selects stdin mode.
147
149
  const extra = commandObj.args ?? [];
148
150
  const readStdin = Boolean(options.stdin) || extra.includes('-');
151
+ // P12: --plan is deprecated in favor of --emit-plan. Reject both
152
+ // together (conflicting) and warn when only the old flag is used.
153
+ if (options.plan && options.emitPlan) {
154
+ handleError(new UsageError('Use --emit-plan; --plan is deprecated and cannot be combined with --emit-plan.'));
155
+ return;
156
+ }
157
+ if (options.plan && !options.emitPlan) {
158
+ // Warning goes to stderr so it cannot corrupt --json output on stdout.
159
+ console.error('[WARN] --plan is deprecated; use --emit-plan. Will be removed in v3.0.');
160
+ }
161
+ const emitPlan = Boolean(options.emitPlan || options.plan);
149
162
  // Accept --idempotency-key as alias; reject when both forms are supplied.
150
163
  if (options.idempotencyKey !== undefined && options.idempotencyKeyPrefix !== undefined) {
151
164
  handleError(new UsageError('Use either --idempotency-key or --idempotency-key-prefix, not both.'));
@@ -216,22 +229,14 @@ Examples:
216
229
  }
217
230
  }
218
231
  if (blockedForDestructive.length > 0 && !options.yes) {
219
- if (isJsonMode()) {
220
- const deviceIds = blockedForDestructive.map((b) => b.deviceId);
221
- emitJsonError({
222
- code: 2,
223
- kind: 'guard',
224
- message: `Destructive command "${cmd}" requires --yes to run on ${blockedForDestructive.length} device(s).`,
225
- hint: 'Re-issue the call with --yes to proceed.',
226
- context: { command: cmd, deviceIds },
227
- });
228
- }
229
- else {
230
- console.error(`Refusing to run destructive command "${cmd}" on ${blockedForDestructive.length} device(s) without --yes:`);
231
- for (const b of blockedForDestructive)
232
- console.error(` ${b.deviceId}`);
233
- }
234
- process.exit(2);
232
+ const deviceIds = blockedForDestructive.map((b) => b.deviceId);
233
+ exitWithError({
234
+ code: 2,
235
+ kind: 'guard',
236
+ message: `Refusing to run destructive command "${cmd}" on ${blockedForDestructive.length} device(s) without --yes: ${deviceIds.join(', ')}`,
237
+ hint: 'Re-issue the call with --yes to proceed.',
238
+ context: { command: cmd, deviceIds },
239
+ });
235
240
  }
236
241
  // parameter may be a JSON object string; mirror the single-command action.
237
242
  let parsedParam = parameter ?? 'default';
@@ -247,8 +252,8 @@ Examples:
247
252
  const concurrency = Math.max(1, Number.parseInt(maxConcurrentRaw, 10) || DEFAULT_CONCURRENCY);
248
253
  const staggerMs = Math.max(0, Number.parseInt(options.stagger, 10) || 0);
249
254
  const dryRun = isDryRun();
250
- // --dry-run --plan: emit a plan document and return without executing.
251
- if (dryRun && options.plan) {
255
+ // --dry-run --emit-plan (or legacy --plan): emit a plan document and return without executing.
256
+ if (dryRun && emitPlan) {
252
257
  const steps = resolved.ids.map((id) => ({
253
258
  deviceId: id,
254
259
  command: cmd,