@switchbot/openapi-cli 3.1.0 → 3.2.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 +34 -42
- package/dist/index.js +56945 -169
- package/dist/policy/schema/v0.2.json +1 -1
- package/package.json +3 -2
- package/dist/api/client.js +0 -235
- package/dist/auth.js +0 -20
- package/dist/commands/agent-bootstrap.js +0 -182
- package/dist/commands/auth.js +0 -354
- package/dist/commands/batch.js +0 -413
- package/dist/commands/cache.js +0 -126
- package/dist/commands/capabilities.js +0 -385
- package/dist/commands/catalog.js +0 -359
- package/dist/commands/completion.js +0 -385
- package/dist/commands/config.js +0 -376
- package/dist/commands/daemon.js +0 -367
- package/dist/commands/device-meta.js +0 -159
- package/dist/commands/devices.js +0 -948
- package/dist/commands/doctor.js +0 -1015
- package/dist/commands/events.js +0 -563
- package/dist/commands/expand.js +0 -130
- package/dist/commands/explain.js +0 -139
- package/dist/commands/health.js +0 -113
- package/dist/commands/history.js +0 -320
- package/dist/commands/identity.js +0 -59
- package/dist/commands/install.js +0 -246
- package/dist/commands/mcp.js +0 -2017
- package/dist/commands/plan.js +0 -653
- package/dist/commands/policy.js +0 -586
- package/dist/commands/quota.js +0 -78
- package/dist/commands/rules.js +0 -875
- package/dist/commands/scenes.js +0 -264
- package/dist/commands/schema.js +0 -177
- package/dist/commands/status-sync.js +0 -131
- package/dist/commands/uninstall.js +0 -237
- package/dist/commands/upgrade-check.js +0 -88
- package/dist/commands/watch.js +0 -194
- package/dist/commands/webhook.js +0 -182
- package/dist/config.js +0 -258
- package/dist/credentials/backends/file.js +0 -101
- package/dist/credentials/backends/linux.js +0 -129
- package/dist/credentials/backends/macos.js +0 -129
- package/dist/credentials/backends/windows.js +0 -215
- package/dist/credentials/keychain.js +0 -88
- package/dist/credentials/prime.js +0 -52
- package/dist/devices/cache.js +0 -293
- package/dist/devices/catalog.js +0 -767
- package/dist/devices/device-meta.js +0 -56
- package/dist/devices/history-agg.js +0 -138
- package/dist/devices/history-query.js +0 -181
- package/dist/devices/param-validator.js +0 -433
- package/dist/devices/resources.js +0 -270
- package/dist/install/default-steps.js +0 -257
- package/dist/install/preflight.js +0 -212
- package/dist/install/steps.js +0 -67
- package/dist/lib/command-keywords.js +0 -17
- package/dist/lib/daemon-state.js +0 -46
- package/dist/lib/destructive-mode.js +0 -12
- package/dist/lib/devices.js +0 -382
- package/dist/lib/idempotency.js +0 -106
- package/dist/lib/plan-store.js +0 -68
- package/dist/lib/request-context.js +0 -12
- package/dist/lib/scenes.js +0 -10
- package/dist/logger.js +0 -16
- package/dist/mcp/device-history.js +0 -145
- package/dist/mcp/events-subscription.js +0 -213
- package/dist/mqtt/client.js +0 -180
- package/dist/mqtt/credential.js +0 -30
- package/dist/policy/add-rule.js +0 -124
- package/dist/policy/diff.js +0 -91
- package/dist/policy/format.js +0 -57
- package/dist/policy/load.js +0 -61
- package/dist/policy/migrate.js +0 -67
- package/dist/policy/schema.js +0 -18
- package/dist/policy/validate.js +0 -262
- package/dist/rules/action.js +0 -205
- package/dist/rules/audit-query.js +0 -89
- package/dist/rules/conflict-analyzer.js +0 -203
- package/dist/rules/cron-scheduler.js +0 -186
- package/dist/rules/destructive.js +0 -52
- package/dist/rules/engine.js +0 -757
- package/dist/rules/matcher.js +0 -230
- package/dist/rules/pid-file.js +0 -95
- package/dist/rules/quiet-hours.js +0 -45
- package/dist/rules/suggest.js +0 -95
- package/dist/rules/throttle.js +0 -116
- package/dist/rules/types.js +0 -34
- package/dist/rules/webhook-listener.js +0 -223
- package/dist/rules/webhook-token.js +0 -90
- package/dist/schema/field-aliases.js +0 -131
- package/dist/sinks/dispatcher.js +0 -12
- package/dist/sinks/file.js +0 -19
- package/dist/sinks/format.js +0 -56
- package/dist/sinks/homeassistant.js +0 -44
- package/dist/sinks/openclaw.js +0 -33
- package/dist/sinks/stdout.js +0 -5
- package/dist/sinks/telegram.js +0 -28
- package/dist/sinks/types.js +0 -1
- package/dist/sinks/webhook.js +0 -22
- package/dist/status-sync/manager.js +0 -268
- package/dist/utils/arg-parsers.js +0 -66
- package/dist/utils/audit.js +0 -117
- package/dist/utils/filter.js +0 -189
- package/dist/utils/flags.js +0 -186
- package/dist/utils/format.js +0 -117
- package/dist/utils/health.js +0 -101
- package/dist/utils/help-json.js +0 -54
- package/dist/utils/name-resolver.js +0 -137
- package/dist/utils/output.js +0 -404
- package/dist/utils/quota.js +0 -227
- package/dist/utils/redact.js +0 -68
- package/dist/utils/retry.js +0 -140
- package/dist/utils/string.js +0 -22
- package/dist/version.js +0 -4
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `switchbot uninstall` — reverse of `switchbot install`.
|
|
3
|
-
*
|
|
4
|
-
* Unlike install, uninstall is not rollback-safe (there's nothing to
|
|
5
|
-
* roll back to). It removes individual pieces independently and keeps
|
|
6
|
-
* going if any single removal fails — the user gets a report and can
|
|
7
|
-
* clean up leftovers manually. Every destructive step defaults to
|
|
8
|
-
* confirmation; `--yes` skips the prompt.
|
|
9
|
-
*
|
|
10
|
-
* What it removes, from least to most destructive:
|
|
11
|
-
* 1. skill symlink (~/.claude/skills/switchbot) — default: yes
|
|
12
|
-
* 2. credentials (keychain entry for the profile) — default: yes (requires --remove-creds OR --yes)
|
|
13
|
-
* 3. policy.yaml (only on --remove-policy) — default: no (user edits may live here)
|
|
14
|
-
*
|
|
15
|
-
* The CLI itself is never uninstalled: install did not install it,
|
|
16
|
-
* and yanking your own binary mid-run is impolite. Users who want it
|
|
17
|
-
* gone run `npm rm -g @switchbot/openapi-cli`.
|
|
18
|
-
*/
|
|
19
|
-
import { InvalidArgumentError } from 'commander';
|
|
20
|
-
import fs from 'node:fs';
|
|
21
|
-
import readline from 'node:readline';
|
|
22
|
-
import { resolvePolicyPath } from '../policy/load.js';
|
|
23
|
-
import { skillLinkPathFor } from '../install/default-steps.js';
|
|
24
|
-
import { selectCredentialStore } from '../credentials/keychain.js';
|
|
25
|
-
import { isJsonMode, printJson } from '../utils/output.js';
|
|
26
|
-
import { getActiveProfile } from '../lib/request-context.js';
|
|
27
|
-
import chalk from 'chalk';
|
|
28
|
-
const AGENT_VALUES = ['claude-code', 'cursor', 'copilot', 'none'];
|
|
29
|
-
function parseAgent(value) {
|
|
30
|
-
if (!value)
|
|
31
|
-
return 'claude-code';
|
|
32
|
-
if (!AGENT_VALUES.includes(value)) {
|
|
33
|
-
throw new InvalidArgumentError(`--agent must be one of ${AGENT_VALUES.join(', ')} (got "${value}")`);
|
|
34
|
-
}
|
|
35
|
-
return value;
|
|
36
|
-
}
|
|
37
|
-
async function prompt(question, defaultYes) {
|
|
38
|
-
if (!process.stdin.isTTY)
|
|
39
|
-
return defaultYes;
|
|
40
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
41
|
-
return new Promise((resolve) => {
|
|
42
|
-
const suffix = defaultYes ? ' [Y/n] ' : ' [y/N] ';
|
|
43
|
-
rl.question(question + suffix, (ans) => {
|
|
44
|
-
rl.close();
|
|
45
|
-
const a = ans.trim().toLowerCase();
|
|
46
|
-
if (!a)
|
|
47
|
-
return resolve(defaultYes);
|
|
48
|
-
resolve(a === 'y' || a === 'yes');
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
export function registerUninstallCommand(program) {
|
|
53
|
-
program
|
|
54
|
-
.command('uninstall')
|
|
55
|
-
.description('Reverse of `switchbot install`: remove skill link, credentials, (optionally) policy')
|
|
56
|
-
.option('--agent <name>', `target agent: ${AGENT_VALUES.join(' | ')} (default: claude-code)`)
|
|
57
|
-
.option('--remove-creds', 'delete credentials from the OS keychain (default: prompt)')
|
|
58
|
-
.option('--remove-policy', 'also delete policy.yaml (default: keep — user edits may live there)')
|
|
59
|
-
.option('-y, --yes', 'assume yes to every confirmation prompt (non-interactive)')
|
|
60
|
-
.option('--purge', 'shorthand for --yes --remove-creds --remove-policy: remove everything without prompting')
|
|
61
|
-
.addHelpText('after', `
|
|
62
|
-
The global --dry-run flag previews what would be removed.
|
|
63
|
-
Global --json emits a structured removal report.
|
|
64
|
-
|
|
65
|
-
What is never removed here:
|
|
66
|
-
- the CLI itself (use: npm rm -g @switchbot/openapi-cli)
|
|
67
|
-
- audit.log (it's your receipt; delete by hand if you want)
|
|
68
|
-
|
|
69
|
-
Examples:
|
|
70
|
-
# Interactive: prompts before each destructive step
|
|
71
|
-
switchbot uninstall
|
|
72
|
-
|
|
73
|
-
# Non-interactive, remove everything including the policy
|
|
74
|
-
switchbot uninstall --yes --remove-policy
|
|
75
|
-
|
|
76
|
-
# One-shot: remove absolutely everything without prompting
|
|
77
|
-
switchbot uninstall --purge
|
|
78
|
-
`)
|
|
79
|
-
.action(async (opts, command) => {
|
|
80
|
-
const agent = parseAgent(opts.agent);
|
|
81
|
-
const profile = getActiveProfile() ?? 'default';
|
|
82
|
-
const purge = Boolean(opts.purge);
|
|
83
|
-
const yes = Boolean(opts.yes) || purge;
|
|
84
|
-
const removePolicy = Boolean(opts.removePolicy) || purge;
|
|
85
|
-
const removeCreds = Boolean(opts.removeCreds) || yes;
|
|
86
|
-
const globalOpts = command.parent?.opts() ?? {};
|
|
87
|
-
const dryRun = Boolean(globalOpts.dryRun);
|
|
88
|
-
const policyPath = resolvePolicyPath();
|
|
89
|
-
const skillLink = skillLinkPathFor(agent);
|
|
90
|
-
const plan = [];
|
|
91
|
-
// --- Plan: skill symlink removal (default yes) ---
|
|
92
|
-
if (skillLink) {
|
|
93
|
-
plan.push({
|
|
94
|
-
action: 'remove-skill-link',
|
|
95
|
-
detail: skillLink,
|
|
96
|
-
run: async () => {
|
|
97
|
-
if (!fs.existsSync(skillLink)) {
|
|
98
|
-
return { action: 'remove-skill-link', status: 'absent', detail: skillLink };
|
|
99
|
-
}
|
|
100
|
-
const stat = fs.lstatSync(skillLink);
|
|
101
|
-
if (!stat.isSymbolicLink()) {
|
|
102
|
-
return {
|
|
103
|
-
action: 'remove-skill-link',
|
|
104
|
-
status: 'skipped',
|
|
105
|
-
detail: `${skillLink} exists but is not a symlink — leaving it alone`,
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
const ok = yes ? true : await prompt(`Remove skill link ${skillLink}?`, true);
|
|
109
|
-
if (!ok)
|
|
110
|
-
return { action: 'remove-skill-link', status: 'skipped', detail: skillLink };
|
|
111
|
-
try {
|
|
112
|
-
fs.unlinkSync(skillLink);
|
|
113
|
-
return { action: 'remove-skill-link', status: 'removed', detail: skillLink };
|
|
114
|
-
}
|
|
115
|
-
catch (err) {
|
|
116
|
-
return {
|
|
117
|
-
action: 'remove-skill-link',
|
|
118
|
-
status: 'failed',
|
|
119
|
-
detail: skillLink,
|
|
120
|
-
error: err instanceof Error ? err.message : String(err),
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
},
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
// --- Plan: credential removal (requires --remove-creds OR --yes) ---
|
|
127
|
-
plan.push({
|
|
128
|
-
action: 'remove-credentials',
|
|
129
|
-
detail: `profile=${profile}`,
|
|
130
|
-
run: async () => {
|
|
131
|
-
if (!removeCreds) {
|
|
132
|
-
return {
|
|
133
|
-
action: 'remove-credentials',
|
|
134
|
-
status: 'skipped',
|
|
135
|
-
detail: 'pass --remove-creds to delete keychain entry',
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
const ok = yes ? true : await prompt(`Delete credentials for profile "${profile}" from the keychain?`, false);
|
|
139
|
-
if (!ok)
|
|
140
|
-
return { action: 'remove-credentials', status: 'skipped', detail: `profile=${profile}` };
|
|
141
|
-
try {
|
|
142
|
-
const store = await selectCredentialStore();
|
|
143
|
-
await store.delete(profile);
|
|
144
|
-
return {
|
|
145
|
-
action: 'remove-credentials',
|
|
146
|
-
status: 'removed',
|
|
147
|
-
detail: `profile=${profile} (backend=${store.describe().tag})`,
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
catch (err) {
|
|
151
|
-
return {
|
|
152
|
-
action: 'remove-credentials',
|
|
153
|
-
status: 'failed',
|
|
154
|
-
detail: `profile=${profile}`,
|
|
155
|
-
error: err instanceof Error ? err.message : String(err),
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
},
|
|
159
|
-
});
|
|
160
|
-
// --- Plan: policy.yaml removal (opt-in) ---
|
|
161
|
-
plan.push({
|
|
162
|
-
action: 'remove-policy',
|
|
163
|
-
detail: policyPath,
|
|
164
|
-
run: async () => {
|
|
165
|
-
if (!removePolicy) {
|
|
166
|
-
return {
|
|
167
|
-
action: 'remove-policy',
|
|
168
|
-
status: 'skipped',
|
|
169
|
-
detail: 'pass --remove-policy to delete policy.yaml',
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
if (!fs.existsSync(policyPath)) {
|
|
173
|
-
return { action: 'remove-policy', status: 'absent', detail: policyPath };
|
|
174
|
-
}
|
|
175
|
-
const ok = yes ? true : await prompt(`Delete policy file ${policyPath}?`, false);
|
|
176
|
-
if (!ok)
|
|
177
|
-
return { action: 'remove-policy', status: 'skipped', detail: policyPath };
|
|
178
|
-
try {
|
|
179
|
-
fs.unlinkSync(policyPath);
|
|
180
|
-
return { action: 'remove-policy', status: 'removed', detail: policyPath };
|
|
181
|
-
}
|
|
182
|
-
catch (err) {
|
|
183
|
-
return {
|
|
184
|
-
action: 'remove-policy',
|
|
185
|
-
status: 'failed',
|
|
186
|
-
detail: policyPath,
|
|
187
|
-
error: err instanceof Error ? err.message : String(err),
|
|
188
|
-
};
|
|
189
|
-
}
|
|
190
|
-
},
|
|
191
|
-
});
|
|
192
|
-
if (dryRun) {
|
|
193
|
-
if (isJsonMode()) {
|
|
194
|
-
printJson({
|
|
195
|
-
dryRun: true,
|
|
196
|
-
profile,
|
|
197
|
-
agent,
|
|
198
|
-
plan: plan.map(({ action, detail }) => ({ action, detail })),
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
else {
|
|
202
|
-
console.log(chalk.bold('switchbot uninstall — dry run'));
|
|
203
|
-
console.log(` profile: ${profile}`);
|
|
204
|
-
console.log(` agent: ${agent}`);
|
|
205
|
-
console.log('');
|
|
206
|
-
console.log(chalk.bold('Would run:'));
|
|
207
|
-
for (const p of plan)
|
|
208
|
-
console.log(` • ${p.action} — ${p.detail}`);
|
|
209
|
-
console.log('');
|
|
210
|
-
console.log(chalk.dim('No changes made. Re-run without --dry-run (add --yes to skip prompts).'));
|
|
211
|
-
}
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
const outcomes = [];
|
|
215
|
-
for (const p of plan) {
|
|
216
|
-
outcomes.push(await p.run());
|
|
217
|
-
}
|
|
218
|
-
const anyFailed = outcomes.some((o) => o.status === 'failed');
|
|
219
|
-
if (isJsonMode()) {
|
|
220
|
-
printJson({ ok: !anyFailed, profile, agent, outcomes });
|
|
221
|
-
}
|
|
222
|
-
else {
|
|
223
|
-
console.log(chalk.bold('switchbot uninstall'));
|
|
224
|
-
for (const o of outcomes) {
|
|
225
|
-
const tag = o.status === 'removed' ? chalk.green('✓') :
|
|
226
|
-
o.status === 'absent' ? chalk.dim('·') :
|
|
227
|
-
o.status === 'skipped' ? chalk.yellow('↷') :
|
|
228
|
-
chalk.red('✗');
|
|
229
|
-
console.log(` ${tag} ${o.action} [${o.status}] ${o.detail ?? ''}`);
|
|
230
|
-
if (o.error)
|
|
231
|
-
console.log(` ${chalk.red(o.error)}`);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
if (anyFailed)
|
|
235
|
-
process.exit(3);
|
|
236
|
-
});
|
|
237
|
-
}
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { createRequire } from 'node:module';
|
|
2
|
-
import https from 'node:https';
|
|
3
|
-
import { isJsonMode, printJson } from '../utils/output.js';
|
|
4
|
-
import chalk from 'chalk';
|
|
5
|
-
const require = createRequire(import.meta.url);
|
|
6
|
-
const { name: pkgName, version: currentVersion } = require('../../package.json');
|
|
7
|
-
function fetchLatestVersion(packageName, timeoutMs = 8000) {
|
|
8
|
-
const encoded = packageName.replace('/', '%2F');
|
|
9
|
-
const url = `https://registry.npmjs.org/${encoded}/latest`;
|
|
10
|
-
return new Promise((resolve, reject) => {
|
|
11
|
-
const req = https.get(url, { timeout: timeoutMs }, (res) => {
|
|
12
|
-
const chunks = [];
|
|
13
|
-
res.on('data', (c) => chunks.push(c));
|
|
14
|
-
res.on('end', () => {
|
|
15
|
-
try {
|
|
16
|
-
const body = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
|
|
17
|
-
if (typeof body.version === 'string')
|
|
18
|
-
resolve(body.version);
|
|
19
|
-
else
|
|
20
|
-
reject(new Error('version field missing from registry response'));
|
|
21
|
-
}
|
|
22
|
-
catch (err) {
|
|
23
|
-
reject(err);
|
|
24
|
-
}
|
|
25
|
-
});
|
|
26
|
-
});
|
|
27
|
-
req.on('timeout', () => { req.destroy(); reject(new Error(`registry request timed out after ${timeoutMs}ms`)); });
|
|
28
|
-
req.on('error', reject);
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
function semverGt(a, b) {
|
|
32
|
-
const numParts = (v) => v.replace(/-.*$/, '').split('.').map((n) => Number.parseInt(n, 10));
|
|
33
|
-
const [aMaj, aMin, aPat] = numParts(a);
|
|
34
|
-
const [bMaj, bMin, bPat] = numParts(b);
|
|
35
|
-
if (aMaj !== bMaj)
|
|
36
|
-
return aMaj > bMaj;
|
|
37
|
-
if (aMin !== bMin)
|
|
38
|
-
return aMin > bMin;
|
|
39
|
-
if (aPat !== bPat)
|
|
40
|
-
return aPat > bPat;
|
|
41
|
-
// Same numeric version: release (no prerelease) > prerelease
|
|
42
|
-
return !a.includes('-') && b.includes('-');
|
|
43
|
-
}
|
|
44
|
-
export function registerUpgradeCheckCommand(program) {
|
|
45
|
-
program
|
|
46
|
-
.command('upgrade-check')
|
|
47
|
-
.description('Check whether a newer version of this CLI is available on npm.')
|
|
48
|
-
.option('--timeout <ms>', 'Registry request timeout in milliseconds (default: 8000)', (v) => Number.parseInt(v, 10))
|
|
49
|
-
.action(async (opts) => {
|
|
50
|
-
let latestVersion;
|
|
51
|
-
try {
|
|
52
|
-
latestVersion = await fetchLatestVersion(pkgName, opts.timeout ?? 8000);
|
|
53
|
-
}
|
|
54
|
-
catch (err) {
|
|
55
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
56
|
-
if (isJsonMode()) {
|
|
57
|
-
printJson({ ok: false, error: msg, current: currentVersion });
|
|
58
|
-
}
|
|
59
|
-
else {
|
|
60
|
-
console.error(chalk.red(`upgrade-check failed: ${msg}`));
|
|
61
|
-
}
|
|
62
|
-
process.exit(1);
|
|
63
|
-
}
|
|
64
|
-
const upToDate = !semverGt(latestVersion, currentVersion);
|
|
65
|
-
const currentMajor = Number.parseInt(currentVersion.split('.')[0], 10);
|
|
66
|
-
const latestMajor = Number.parseInt(latestVersion.split('.')[0], 10);
|
|
67
|
-
const result = {
|
|
68
|
-
current: currentVersion,
|
|
69
|
-
latest: latestVersion,
|
|
70
|
-
upToDate,
|
|
71
|
-
updateAvailable: !upToDate,
|
|
72
|
-
breakingChange: latestMajor > currentMajor,
|
|
73
|
-
installCommand: upToDate ? null : `npm install -g ${pkgName}@${latestVersion}`,
|
|
74
|
-
};
|
|
75
|
-
if (isJsonMode()) {
|
|
76
|
-
printJson(result);
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
if (upToDate) {
|
|
80
|
-
console.log(`${chalk.green('✓')} You are running the latest version (${currentVersion}).`);
|
|
81
|
-
}
|
|
82
|
-
else {
|
|
83
|
-
console.log(`${chalk.yellow('!')} Update available: ${chalk.bold(currentVersion)} → ${chalk.bold(latestVersion)}`);
|
|
84
|
-
console.log(` Run: ${chalk.cyan(`npm install -g ${pkgName}@${latestVersion}`)}`);
|
|
85
|
-
process.exit(1);
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
}
|
package/dist/commands/watch.js
DELETED
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
import { printJson, isJsonMode, handleError, UsageError, emitStreamHeader } from '../utils/output.js';
|
|
2
|
-
import { fetchDeviceStatus } from '../lib/devices.js';
|
|
3
|
-
import { getCachedDevice } from '../devices/cache.js';
|
|
4
|
-
import { parseDurationToMs, getFields } from '../utils/flags.js';
|
|
5
|
-
import { intArg, durationArg, stringArg } from '../utils/arg-parsers.js';
|
|
6
|
-
import { createClient } from '../api/client.js';
|
|
7
|
-
import { resolveDeviceId } from '../utils/name-resolver.js';
|
|
8
|
-
import { resolveFieldList, listAllCanonical } from '../schema/field-aliases.js';
|
|
9
|
-
const DEFAULT_INTERVAL_MS = 30_000;
|
|
10
|
-
const MIN_INTERVAL_MS = 1_000;
|
|
11
|
-
function diff(prev, next, fields) {
|
|
12
|
-
const out = {};
|
|
13
|
-
const keys = fields ?? Object.keys(next);
|
|
14
|
-
for (const k of keys) {
|
|
15
|
-
const a = prev ? prev[k] : undefined;
|
|
16
|
-
const b = next[k];
|
|
17
|
-
if (JSON.stringify(a) !== JSON.stringify(b)) {
|
|
18
|
-
out[k] = { from: prev ? a : null, to: b };
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
return out;
|
|
22
|
-
}
|
|
23
|
-
function formatHumanLine(ev) {
|
|
24
|
-
const when = new Date(ev.t).toLocaleTimeString();
|
|
25
|
-
const head = `[${when}] ${ev.deviceId}${ev.type ? ` (${ev.type})` : ''}`;
|
|
26
|
-
if (ev.error)
|
|
27
|
-
return `${head}: error — ${ev.error}`;
|
|
28
|
-
const keys = Object.keys(ev.changed);
|
|
29
|
-
if (keys.length === 0)
|
|
30
|
-
return `${head}: no changes`;
|
|
31
|
-
const pairs = keys
|
|
32
|
-
.map((k) => {
|
|
33
|
-
const { from, to } = ev.changed[k];
|
|
34
|
-
if (from === null || from === undefined)
|
|
35
|
-
return `${k}=${JSON.stringify(to)}`;
|
|
36
|
-
return `${k}: ${JSON.stringify(from)} → ${JSON.stringify(to)}`;
|
|
37
|
-
})
|
|
38
|
-
.join(', ');
|
|
39
|
-
return `${head} ${pairs}`;
|
|
40
|
-
}
|
|
41
|
-
function sleep(ms, signal) {
|
|
42
|
-
return new Promise((resolve) => {
|
|
43
|
-
const t = setTimeout(() => resolve(), ms);
|
|
44
|
-
const onAbort = () => {
|
|
45
|
-
clearTimeout(t);
|
|
46
|
-
resolve();
|
|
47
|
-
};
|
|
48
|
-
if (signal.aborted) {
|
|
49
|
-
clearTimeout(t);
|
|
50
|
-
resolve();
|
|
51
|
-
}
|
|
52
|
-
else {
|
|
53
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
54
|
-
}
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
export function registerWatchCommand(devices) {
|
|
58
|
-
devices
|
|
59
|
-
.command('watch')
|
|
60
|
-
.description('Poll device status on an interval and emit field-level changes (JSONL)')
|
|
61
|
-
.argument('[deviceId...]', 'One or more deviceIds to watch (or use --name for one device)')
|
|
62
|
-
.option('--name <query>', 'Resolve one device by fuzzy name (combined with any positional IDs)', stringArg('--name'))
|
|
63
|
-
.option('--interval <dur>', `Polling interval: "30s", "1m", "500ms", ... (default 30s, min ${MIN_INTERVAL_MS / 1000}s)`, durationArg('--interval'), '30s')
|
|
64
|
-
.option('--max <n>', 'Stop after N ticks (default: run until Ctrl-C)', intArg('--max', { min: 1 }))
|
|
65
|
-
.option('--for <dur>', 'Stop after elapsed time (e.g. "5m", "30s"). Combines with --max: first limit wins.', durationArg('--for'))
|
|
66
|
-
.option('--include-unchanged', 'Emit a tick even when no field changed')
|
|
67
|
-
.addHelpText('after', `
|
|
68
|
-
Each poll emits one JSON line per deviceId with the shape:
|
|
69
|
-
{ "t": "<ISO>", "tick": <n>, "deviceId": "ID", "type": "Bot",
|
|
70
|
-
"changed": { "power": { "from": "off", "to": "on" } } }
|
|
71
|
-
|
|
72
|
-
The very first poll has "from": null for every field (seed).
|
|
73
|
-
|
|
74
|
-
Examples:
|
|
75
|
-
$ switchbot devices watch ABC123 --interval 10s
|
|
76
|
-
$ switchbot devices watch ABC123 --fields battery,power --interval 1m
|
|
77
|
-
$ switchbot devices watch ABC123 DEF456 --interval 30s --max 10
|
|
78
|
-
$ switchbot devices watch ABC123 --json | jq 'select(.changed.power)'
|
|
79
|
-
$ switchbot devices watch --name "Living Room AC" --interval 10s
|
|
80
|
-
`)
|
|
81
|
-
.action(async (deviceIds, options) => {
|
|
82
|
-
try {
|
|
83
|
-
const allIds = [...deviceIds];
|
|
84
|
-
if (options.name) {
|
|
85
|
-
const resolved = resolveDeviceId(undefined, options.name);
|
|
86
|
-
if (!allIds.includes(resolved))
|
|
87
|
-
allIds.push(resolved);
|
|
88
|
-
}
|
|
89
|
-
if (allIds.length === 0)
|
|
90
|
-
throw new UsageError('Provide at least one deviceId argument or --name.');
|
|
91
|
-
const parsed = parseDurationToMs(options.interval);
|
|
92
|
-
if (parsed === null || parsed < MIN_INTERVAL_MS) {
|
|
93
|
-
throw new UsageError(`Invalid --interval "${options.interval}". Minimum is ${MIN_INTERVAL_MS / 1000}s.`);
|
|
94
|
-
}
|
|
95
|
-
const intervalMs = parsed;
|
|
96
|
-
let maxTicks = null;
|
|
97
|
-
if (options.max !== undefined) {
|
|
98
|
-
const n = Number(options.max);
|
|
99
|
-
if (!Number.isFinite(n) || n < 1) {
|
|
100
|
-
throw new UsageError(`Invalid --max "${options.max}". Must be a positive integer.`);
|
|
101
|
-
}
|
|
102
|
-
maxTicks = Math.floor(n);
|
|
103
|
-
}
|
|
104
|
-
const forMs = options.for ? parseDurationToMs(options.for) : null;
|
|
105
|
-
const rawFields = getFields() ?? null;
|
|
106
|
-
// Resolve aliases upfront against the static canonical registry.
|
|
107
|
-
// Validating here lets UsageError exit the command before any
|
|
108
|
-
// polling starts, and keeps mid-loop error handling free of
|
|
109
|
-
// "misuse" concerns. Unknown fields that are not registered as
|
|
110
|
-
// aliases but happen to match an API key pass through unchanged.
|
|
111
|
-
const fields = rawFields
|
|
112
|
-
? resolveFieldList(rawFields, listAllCanonical())
|
|
113
|
-
: null;
|
|
114
|
-
const ac = new AbortController();
|
|
115
|
-
const onSig = () => ac.abort();
|
|
116
|
-
process.on('SIGINT', onSig);
|
|
117
|
-
process.on('SIGTERM', onSig);
|
|
118
|
-
const forTimer = forMs !== null && forMs > 0
|
|
119
|
-
? setTimeout(() => ac.abort(), forMs)
|
|
120
|
-
: null;
|
|
121
|
-
// P7: streaming JSON contract — first line under --json is the
|
|
122
|
-
// stream header so consumers can route by eventKind/cadence.
|
|
123
|
-
if (isJsonMode())
|
|
124
|
-
emitStreamHeader({ eventKind: 'tick', cadence: 'poll' });
|
|
125
|
-
try {
|
|
126
|
-
const prev = new Map();
|
|
127
|
-
const client = createClient();
|
|
128
|
-
let tick = 0;
|
|
129
|
-
while (!ac.signal.aborted) {
|
|
130
|
-
tick++;
|
|
131
|
-
const t = new Date().toISOString();
|
|
132
|
-
// Poll all devices in parallel; one failure per device doesn't stop
|
|
133
|
-
// the others.
|
|
134
|
-
await Promise.all(allIds.map(async (id) => {
|
|
135
|
-
const cached = getCachedDevice(id);
|
|
136
|
-
try {
|
|
137
|
-
const body = await fetchDeviceStatus(id, client);
|
|
138
|
-
const changed = diff(prev.get(id), body, fields);
|
|
139
|
-
prev.set(id, body);
|
|
140
|
-
if (Object.keys(changed).length === 0 && !options.includeUnchanged) {
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
const ev = {
|
|
144
|
-
t,
|
|
145
|
-
tick,
|
|
146
|
-
deviceId: id,
|
|
147
|
-
type: cached?.type,
|
|
148
|
-
changed,
|
|
149
|
-
};
|
|
150
|
-
if (isJsonMode()) {
|
|
151
|
-
// JSONL: one event per line (printJson with newline).
|
|
152
|
-
printJson(ev);
|
|
153
|
-
}
|
|
154
|
-
else {
|
|
155
|
-
console.log(formatHumanLine(ev));
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
catch (err) {
|
|
159
|
-
const ev = {
|
|
160
|
-
t,
|
|
161
|
-
tick,
|
|
162
|
-
deviceId: id,
|
|
163
|
-
type: cached?.type,
|
|
164
|
-
changed: {},
|
|
165
|
-
error: err instanceof Error ? err.message : String(err),
|
|
166
|
-
};
|
|
167
|
-
if (isJsonMode()) {
|
|
168
|
-
printJson(ev);
|
|
169
|
-
}
|
|
170
|
-
else {
|
|
171
|
-
console.error(formatHumanLine(ev));
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}));
|
|
175
|
-
if (maxTicks !== null && tick >= maxTicks)
|
|
176
|
-
break;
|
|
177
|
-
await sleep(intervalMs, ac.signal);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
catch (err) {
|
|
181
|
-
handleError(err);
|
|
182
|
-
}
|
|
183
|
-
finally {
|
|
184
|
-
if (forTimer)
|
|
185
|
-
clearTimeout(forTimer);
|
|
186
|
-
process.off('SIGINT', onSig);
|
|
187
|
-
process.off('SIGTERM', onSig);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
catch (error) {
|
|
191
|
-
handleError(error);
|
|
192
|
-
}
|
|
193
|
-
});
|
|
194
|
-
}
|