@switchbot/openapi-cli 2.7.2 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +481 -103
- package/dist/api/client.js +23 -1
- package/dist/commands/agent-bootstrap.js +47 -2
- package/dist/commands/auth.js +354 -0
- package/dist/commands/batch.js +20 -4
- package/dist/commands/capabilities.js +155 -65
- package/dist/commands/config.js +109 -0
- package/dist/commands/daemon.js +367 -0
- package/dist/commands/devices.js +62 -11
- package/dist/commands/doctor.js +417 -8
- package/dist/commands/events.js +3 -3
- package/dist/commands/explain.js +1 -2
- package/dist/commands/health.js +113 -0
- package/dist/commands/install.js +246 -0
- package/dist/commands/mcp.js +888 -7
- package/dist/commands/plan.js +379 -103
- package/dist/commands/policy.js +586 -0
- package/dist/commands/rules.js +875 -0
- package/dist/commands/scenes.js +140 -0
- package/dist/commands/schema.js +0 -2
- package/dist/commands/status-sync.js +131 -0
- package/dist/commands/uninstall.js +237 -0
- package/dist/commands/upgrade-check.js +88 -0
- package/dist/config.js +14 -0
- package/dist/credentials/backends/file.js +101 -0
- package/dist/credentials/backends/linux.js +129 -0
- package/dist/credentials/backends/macos.js +129 -0
- package/dist/credentials/backends/windows.js +215 -0
- package/dist/credentials/keychain.js +88 -0
- package/dist/credentials/prime.js +52 -0
- package/dist/devices/catalog.js +4 -10
- package/dist/index.js +30 -1
- package/dist/install/default-steps.js +257 -0
- package/dist/install/preflight.js +212 -0
- package/dist/install/steps.js +67 -0
- package/dist/lib/command-keywords.js +17 -0
- package/dist/lib/daemon-state.js +46 -0
- package/dist/lib/destructive-mode.js +12 -0
- package/dist/lib/devices.js +1 -1
- package/dist/lib/plan-store.js +68 -0
- package/dist/policy/add-rule.js +124 -0
- package/dist/policy/diff.js +91 -0
- package/dist/policy/examples/policy.example.yaml +99 -0
- package/dist/policy/format.js +57 -0
- package/dist/policy/load.js +61 -0
- package/dist/policy/migrate.js +67 -0
- package/dist/policy/schema/v0.2.json +331 -0
- package/dist/policy/schema.js +18 -0
- package/dist/policy/validate.js +262 -0
- package/dist/rules/action.js +205 -0
- package/dist/rules/audit-query.js +89 -0
- package/dist/rules/conflict-analyzer.js +203 -0
- package/dist/rules/cron-scheduler.js +186 -0
- package/dist/rules/destructive.js +52 -0
- package/dist/rules/engine.js +757 -0
- package/dist/rules/matcher.js +230 -0
- package/dist/rules/pid-file.js +95 -0
- package/dist/rules/quiet-hours.js +45 -0
- package/dist/rules/suggest.js +95 -0
- package/dist/rules/throttle.js +116 -0
- package/dist/rules/types.js +34 -0
- package/dist/rules/webhook-listener.js +223 -0
- package/dist/rules/webhook-token.js +90 -0
- package/dist/status-sync/manager.js +268 -0
- package/dist/utils/audit.js +12 -2
- package/dist/utils/health.js +101 -0
- package/dist/utils/output.js +72 -23
- package/dist/utils/retry.js +81 -0
- package/package.json +12 -4
package/dist/api/client.js
CHANGED
|
@@ -3,7 +3,7 @@ import chalk from 'chalk';
|
|
|
3
3
|
import { buildAuthHeaders } from '../auth.js';
|
|
4
4
|
import { loadConfig } from '../config.js';
|
|
5
5
|
import { isVerbose, isDryRun, getTimeout, getRetryOn429, getRetryOn5xx, getBackoffStrategy, isQuotaDisabled, } from '../utils/flags.js';
|
|
6
|
-
import { nextRetryDelayMs, sleep } from '../utils/retry.js';
|
|
6
|
+
import { nextRetryDelayMs, sleep, CircuitBreaker, CircuitOpenError } from '../utils/retry.js';
|
|
7
7
|
import { recordRequest, checkDailyCap } from '../utils/quota.js';
|
|
8
8
|
import { readProfileMeta } from '../config.js';
|
|
9
9
|
import { getActiveProfile } from '../lib/request-context.js';
|
|
@@ -40,6 +40,17 @@ export class DryRunSignal extends Error {
|
|
|
40
40
|
this.name = 'DryRunSignal';
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Module-level circuit breaker for the SwitchBot API. Shared across all
|
|
45
|
+
* client instances in the process. Opens after 5 consecutive 5xx / network
|
|
46
|
+
* errors; resets to half-open after 60 s.
|
|
47
|
+
* Exported for health-check inspection.
|
|
48
|
+
*/
|
|
49
|
+
export const apiCircuitBreaker = new CircuitBreaker('switchbot-api', {
|
|
50
|
+
failureThreshold: 5,
|
|
51
|
+
resetTimeoutMs: 60_000,
|
|
52
|
+
});
|
|
53
|
+
export { CircuitOpenError };
|
|
43
54
|
export function createClient() {
|
|
44
55
|
const { token, secret } = loadConfig();
|
|
45
56
|
const verbose = isVerbose();
|
|
@@ -57,6 +68,8 @@ export function createClient() {
|
|
|
57
68
|
});
|
|
58
69
|
// Inject auth headers; optionally log the request; short-circuit on --dry-run.
|
|
59
70
|
client.interceptors.request.use((config) => {
|
|
71
|
+
// Circuit breaker check — fail fast when the API is consistently down.
|
|
72
|
+
apiCircuitBreaker.checkAndAllow();
|
|
60
73
|
// Pre-flight cap check: refuse the call before it touches the network.
|
|
61
74
|
if (dailyCap) {
|
|
62
75
|
const check = checkDailyCap(dailyCap);
|
|
@@ -110,6 +123,8 @@ export function createClient() {
|
|
|
110
123
|
`API error code: ${data.statusCode}`;
|
|
111
124
|
throw new ApiError(msg, data.statusCode);
|
|
112
125
|
}
|
|
126
|
+
// Successful HTTP response — record for circuit breaker.
|
|
127
|
+
apiCircuitBreaker.recordSuccess();
|
|
113
128
|
return response;
|
|
114
129
|
}, (error) => {
|
|
115
130
|
if (error instanceof DryRunSignal)
|
|
@@ -131,6 +146,8 @@ export function createClient() {
|
|
|
131
146
|
return sleep(delay).then(() => client.request(config));
|
|
132
147
|
}
|
|
133
148
|
}
|
|
149
|
+
// Network-level failure — record for circuit breaker.
|
|
150
|
+
apiCircuitBreaker.recordFailure();
|
|
134
151
|
throw new ApiError(`Request timed out after ${getTimeout()}ms (override with --timeout <ms>)`, 0, { transient: true, retryable: isIdempotentRead });
|
|
135
152
|
}
|
|
136
153
|
const status = error.response?.status;
|
|
@@ -165,6 +182,11 @@ export function createClient() {
|
|
|
165
182
|
return sleep(delay).then(() => client.request(config));
|
|
166
183
|
}
|
|
167
184
|
}
|
|
185
|
+
// Record 5xx and network errors for circuit breaker. 4xx errors are
|
|
186
|
+
// expected business responses — don't count toward circuit threshold.
|
|
187
|
+
if (status === undefined || status >= 500) {
|
|
188
|
+
apiCircuitBreaker.recordFailure();
|
|
189
|
+
}
|
|
168
190
|
// P8: quota already recorded in the request interceptor before
|
|
169
191
|
// dispatch — no extra bookkeeping needed here on the error path.
|
|
170
192
|
// Timeouts, DNS failures, 5xx, and exhausted retries all counted
|
|
@@ -5,6 +5,9 @@ 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
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';
|
|
8
11
|
import { createRequire } from 'node:module';
|
|
9
12
|
const require = createRequire(import.meta.url);
|
|
10
13
|
const { version: pkgVersion } = require('../../package.json');
|
|
@@ -28,7 +31,47 @@ const QUICK_REFERENCE = {
|
|
|
28
31
|
observability: ['doctor --json', 'quota status', 'cache status', 'events mqtt-tail'],
|
|
29
32
|
history: ['history range <id> --since 7d', 'history stats <id>'],
|
|
30
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'],
|
|
31
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
|
+
}
|
|
32
75
|
export function registerAgentBootstrapCommand(program) {
|
|
33
76
|
program
|
|
34
77
|
.command('agent-bootstrap')
|
|
@@ -49,12 +92,13 @@ Examples:
|
|
|
49
92
|
$ switchbot agent-bootstrap | jq '.devices | length'
|
|
50
93
|
$ switchbot agent-bootstrap --compact | jq '.quickReference'
|
|
51
94
|
`)
|
|
52
|
-
.action((opts) => {
|
|
95
|
+
.action(async (opts) => {
|
|
53
96
|
const compact = Boolean(opts.compact);
|
|
54
97
|
const cache = loadCache();
|
|
55
98
|
const catalog = getEffectiveCatalog();
|
|
56
99
|
const usage = todayUsage();
|
|
57
100
|
const meta = readProfileMeta(undefined);
|
|
101
|
+
const credentialsBackend = await readCredentialsBackend();
|
|
58
102
|
const cachedDevices = cache
|
|
59
103
|
? Object.entries(cache.devices).map(([id, d]) => ({
|
|
60
104
|
deviceId: id,
|
|
@@ -91,7 +135,6 @@ Examples:
|
|
|
91
135
|
command: c.command,
|
|
92
136
|
parameter: c.parameter,
|
|
93
137
|
safetyTier: tier,
|
|
94
|
-
destructive: tier === 'destructive',
|
|
95
138
|
idempotent: Boolean(c.idempotent),
|
|
96
139
|
};
|
|
97
140
|
}),
|
|
@@ -120,6 +163,8 @@ Examples:
|
|
|
120
163
|
remaining: usage.remaining,
|
|
121
164
|
dailyLimit: DAILY_QUOTA,
|
|
122
165
|
},
|
|
166
|
+
policyStatus: readPolicyStatus(),
|
|
167
|
+
credentialsBackend,
|
|
123
168
|
devices: cachedDevices,
|
|
124
169
|
catalog: {
|
|
125
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
|
+
}
|
package/dist/commands/batch.js
CHANGED
|
@@ -6,6 +6,7 @@ import { parseFilter, applyFilter, FilterSyntaxError } from '../utils/filter.js'
|
|
|
6
6
|
import { isDryRun } from '../utils/flags.js';
|
|
7
7
|
import { DryRunSignal } from '../api/client.js';
|
|
8
8
|
import { getCachedTypeMap, getCachedDevice, loadStatusCache } from '../devices/cache.js';
|
|
9
|
+
import { allowsDirectDestructiveExecution, destructiveExecutionHint } from '../lib/destructive-mode.js';
|
|
9
10
|
const DEFAULT_CONCURRENCY = 5;
|
|
10
11
|
const COMMAND_TYPES = ['command', 'customize'];
|
|
11
12
|
/**
|
|
@@ -98,7 +99,7 @@ export function registerBatchCommand(devices) {
|
|
|
98
99
|
.option('--stagger <ms>', 'Fixed delay between task starts in ms (default 0 = random 20-60ms jitter)', intArg('--stagger', { min: 0 }), '0')
|
|
99
100
|
.option('--plan', '[DEPRECATED, use --emit-plan] With --dry-run: emit a plan JSON document instead of executing anything')
|
|
100
101
|
.option('--emit-plan', 'With --dry-run: emit a plan JSON document instead of executing anything')
|
|
101
|
-
.option('--yes', 'Allow destructive commands
|
|
102
|
+
.option('--yes', 'Allow destructive commands only from an explicit dev profile')
|
|
102
103
|
.option('--type <commandType>', '"command" (default) or "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
|
|
103
104
|
.option('--stdin', 'Read deviceIds from stdin, one per line (same as trailing "-")')
|
|
104
105
|
.option('--idempotency-key-prefix <prefix>', 'Client-supplied prefix for idempotency keys (key per device: <prefix>-<deviceId>). process-local 60s window; cache is per Node process (MCP session, batch run, plan run). Independent CLI invocations do not share cache.', stringArg('--idempotency-key-prefix'))
|
|
@@ -134,7 +135,8 @@ Planning:
|
|
|
134
135
|
|
|
135
136
|
Safety:
|
|
136
137
|
Destructive commands (Smart Lock unlock, Garage Door Opener turnOn/turnOff,
|
|
137
|
-
Keypad createKey/deleteKey) are blocked by default.
|
|
138
|
+
Keypad createKey/deleteKey) are blocked by default. Use the reviewed plan
|
|
139
|
+
flow instead of direct execution.
|
|
138
140
|
--dry-run intercepts every POST and reports the intended calls without
|
|
139
141
|
hitting the API.
|
|
140
142
|
|
|
@@ -142,7 +144,7 @@ Examples:
|
|
|
142
144
|
$ switchbot devices batch turnOff --filter 'type~=Light,family=home'
|
|
143
145
|
$ switchbot devices batch turnOn --ids ID1,ID2,ID3
|
|
144
146
|
$ switchbot devices list --format=id --filter 'type=Bot' | switchbot devices batch toggle -
|
|
145
|
-
$ switchbot devices batch unlock --filter 'type=Smart Lock' --
|
|
147
|
+
$ switchbot devices batch unlock --filter 'type=Smart Lock' --dry-run --emit-plan
|
|
146
148
|
`)
|
|
147
149
|
.action(async (cmd, parameter, options, commandObj) => {
|
|
148
150
|
// Trailing "-" sentinel selects stdin mode.
|
|
@@ -234,10 +236,24 @@ Examples:
|
|
|
234
236
|
code: 2,
|
|
235
237
|
kind: 'guard',
|
|
236
238
|
message: `Refusing to run destructive command "${cmd}" on ${blockedForDestructive.length} device(s) without --yes: ${deviceIds.join(', ')}`,
|
|
237
|
-
hint: 'Re-issue the call with --yes
|
|
239
|
+
hint: 'Re-issue the call with --yes only from an explicit dev profile, or use the reviewed plan flow.',
|
|
238
240
|
context: { command: cmd, deviceIds },
|
|
239
241
|
});
|
|
240
242
|
}
|
|
243
|
+
if (blockedForDestructive.length > 0 && options.yes && !allowsDirectDestructiveExecution()) {
|
|
244
|
+
exitWithError({
|
|
245
|
+
code: 2,
|
|
246
|
+
kind: 'guard',
|
|
247
|
+
message: `Direct destructive execution is disabled for batch command "${cmd}".`,
|
|
248
|
+
hint: destructiveExecutionHint(),
|
|
249
|
+
context: {
|
|
250
|
+
command: cmd,
|
|
251
|
+
blockedCount: blockedForDestructive.length,
|
|
252
|
+
deviceIds: blockedForDestructive.map((item) => item.deviceId),
|
|
253
|
+
requiredWorkflow: 'plan-approval',
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
241
257
|
// parameter may be a JSON object string; mirror the single-command action.
|
|
242
258
|
let parsedParam = parameter ?? 'default';
|
|
243
259
|
if (parameter) {
|