@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
package/dist/utils/output.js
DELETED
|
@@ -1,404 +0,0 @@
|
|
|
1
|
-
import Table from 'cli-table3';
|
|
2
|
-
import chalk from 'chalk';
|
|
3
|
-
import { ApiError, DryRunSignal } from '../api/client.js';
|
|
4
|
-
import { getFormat, getTableStyle } from './flags.js';
|
|
5
|
-
export const SCHEMA_VERSION = '1.1';
|
|
6
|
-
export function isJsonMode() {
|
|
7
|
-
return process.argv.includes('--json') || getFormat() === 'json';
|
|
8
|
-
}
|
|
9
|
-
export function printJson(data) {
|
|
10
|
-
console.log(JSON.stringify({ schemaVersion: SCHEMA_VERSION, data }, null, 2));
|
|
11
|
-
}
|
|
12
|
-
/**
|
|
13
|
-
* Emit a structured JSON error envelope on stdout.
|
|
14
|
-
*
|
|
15
|
-
* Bug #SYS-1: Under `--json`, both success and error payloads must share
|
|
16
|
-
* the same output channel (stdout) so a single `cli --json ... | jq` pipe
|
|
17
|
-
* can decode either shape. Use this helper everywhere that previously
|
|
18
|
-
* called `console.error(JSON.stringify({ error: ... }))` in --json mode.
|
|
19
|
-
*
|
|
20
|
-
* The envelope is always `{ schemaVersion, error }` — callers pass only the
|
|
21
|
-
* error payload. Also emits a brief human-readable line on stderr when a
|
|
22
|
-
* TTY is attached, so interactive runs still see the failure.
|
|
23
|
-
*/
|
|
24
|
-
export function emitJsonError(errorPayload) {
|
|
25
|
-
console.log(JSON.stringify({ schemaVersion: SCHEMA_VERSION, error: errorPayload }));
|
|
26
|
-
if (process.stderr.isTTY) {
|
|
27
|
-
const msg = typeof errorPayload.message === 'string' ? errorPayload.message : 'Error';
|
|
28
|
-
console.error(chalk.red(msg));
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
/**
|
|
32
|
-
* P7: emit the stream-header first line for any NDJSON/streaming command
|
|
33
|
-
* running under `--json`. Downstream JSON consumers can key on
|
|
34
|
-
* `{ stream: true }` to distinguish the header from subsequent event
|
|
35
|
-
* lines, and on `eventKind` / `cadence` to pick a parser strategy.
|
|
36
|
-
*
|
|
37
|
-
* Non-streaming commands (single-object / array output) do NOT emit this
|
|
38
|
-
* header — only watch / events tail / events mqtt-tail.
|
|
39
|
-
*/
|
|
40
|
-
export function emitStreamHeader(opts) {
|
|
41
|
-
console.log(JSON.stringify({
|
|
42
|
-
schemaVersion: '1',
|
|
43
|
-
stream: true,
|
|
44
|
-
eventKind: opts.eventKind,
|
|
45
|
-
cadence: opts.cadence,
|
|
46
|
-
}));
|
|
47
|
-
}
|
|
48
|
-
export function exitWithError(messageOrOpts) {
|
|
49
|
-
const opts = typeof messageOrOpts === 'string' ? { message: messageOrOpts } : messageOrOpts;
|
|
50
|
-
const { message, kind = 'usage', code = 2, hint, context, extra } = opts;
|
|
51
|
-
if (isJsonMode()) {
|
|
52
|
-
const payload = { code, kind, message };
|
|
53
|
-
if (hint)
|
|
54
|
-
payload.hint = hint;
|
|
55
|
-
if (context)
|
|
56
|
-
payload.context = context;
|
|
57
|
-
if (extra)
|
|
58
|
-
Object.assign(payload, extra);
|
|
59
|
-
emitJsonError(payload);
|
|
60
|
-
}
|
|
61
|
-
else {
|
|
62
|
-
console.error(message);
|
|
63
|
-
if (hint)
|
|
64
|
-
console.error(hint);
|
|
65
|
-
}
|
|
66
|
-
process.exit(code);
|
|
67
|
-
}
|
|
68
|
-
function escapeMarkdownCell(s) {
|
|
69
|
-
// Pipes break markdown table layout; backslash-escape them. Collapse
|
|
70
|
-
// newlines into <br> so each row stays on one line.
|
|
71
|
-
return s.replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
|
|
72
|
-
}
|
|
73
|
-
function formatCell(cell, style) {
|
|
74
|
-
if (cell === null || cell === undefined)
|
|
75
|
-
return style === 'markdown' ? '—' : chalk.grey('—');
|
|
76
|
-
if (typeof cell === 'boolean') {
|
|
77
|
-
if (style === 'markdown')
|
|
78
|
-
return cell ? 'Yes' : 'No';
|
|
79
|
-
return cell ? chalk.green('✓') : chalk.red('✗');
|
|
80
|
-
}
|
|
81
|
-
return String(cell);
|
|
82
|
-
}
|
|
83
|
-
function renderMarkdownTable(headers, rows) {
|
|
84
|
-
if (rows.length === 0) {
|
|
85
|
-
return '_(empty)_';
|
|
86
|
-
}
|
|
87
|
-
const head = `| ${headers.map(escapeMarkdownCell).join(' | ')} |`;
|
|
88
|
-
const sep = `| ${headers.map(() => '---').join(' | ')} |`;
|
|
89
|
-
const body = rows.map((r) => `| ${r
|
|
90
|
-
.map((c) => escapeMarkdownCell(formatCell(c, 'markdown')))
|
|
91
|
-
.join(' | ')} |`);
|
|
92
|
-
return [head, sep, ...body].join('\n');
|
|
93
|
-
}
|
|
94
|
-
function renderSimpleTable(headers, rows) {
|
|
95
|
-
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => String(formatCell(r[i], 'simple')).length)));
|
|
96
|
-
const fmt = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(' ').trimEnd();
|
|
97
|
-
return [
|
|
98
|
-
fmt(headers),
|
|
99
|
-
...rows.map((r) => fmt(r.map((c) => String(formatCell(c, 'simple'))))),
|
|
100
|
-
].join('\n');
|
|
101
|
-
}
|
|
102
|
-
const ASCII_BORDER_CHARS = {
|
|
103
|
-
top: '-', 'top-mid': '+', 'top-left': '+', 'top-right': '+',
|
|
104
|
-
bottom: '-', 'bottom-mid': '+', 'bottom-left': '+', 'bottom-right': '+',
|
|
105
|
-
left: '|', 'left-mid': '+', mid: '-', 'mid-mid': '+',
|
|
106
|
-
right: '|', 'right-mid': '+', middle: '|',
|
|
107
|
-
};
|
|
108
|
-
export function printTable(headers, rows, styleOverride) {
|
|
109
|
-
const style = styleOverride ?? getTableStyle();
|
|
110
|
-
if (style === 'markdown') {
|
|
111
|
-
console.log(renderMarkdownTable(headers, rows));
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
if (style === 'simple') {
|
|
115
|
-
console.log(renderSimpleTable(headers, rows));
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
const tableOpts = {
|
|
119
|
-
head: headers.map((h) => (style === 'ascii' ? h : chalk.cyan(h))),
|
|
120
|
-
style: style === 'ascii' ? { border: [], head: [] } : { border: ['grey'] },
|
|
121
|
-
};
|
|
122
|
-
if (style === 'ascii') {
|
|
123
|
-
tableOpts.chars = ASCII_BORDER_CHARS;
|
|
124
|
-
}
|
|
125
|
-
const table = new Table(tableOpts);
|
|
126
|
-
for (const row of rows) {
|
|
127
|
-
table.push(row.map((cell) => formatCell(cell, style)));
|
|
128
|
-
}
|
|
129
|
-
console.log(table.toString());
|
|
130
|
-
}
|
|
131
|
-
export function printKeyValue(data) {
|
|
132
|
-
const style = getTableStyle();
|
|
133
|
-
if (style === 'markdown') {
|
|
134
|
-
const entries = Object.entries(data).filter(([, v]) => v !== null && v !== undefined);
|
|
135
|
-
const rows = entries.map(([k, v]) => [k, typeof v === 'object' ? JSON.stringify(v) : String(v)]);
|
|
136
|
-
console.log(renderMarkdownTable(['Key', 'Value'], rows));
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
if (style === 'simple') {
|
|
140
|
-
for (const [key, value] of Object.entries(data)) {
|
|
141
|
-
if (value === null || value === undefined)
|
|
142
|
-
continue;
|
|
143
|
-
const displayValue = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
144
|
-
console.log(`${key} ${displayValue}`);
|
|
145
|
-
}
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
const tableOpts = {
|
|
149
|
-
style: style === 'ascii' ? { border: [], head: [] } : { border: ['grey'] },
|
|
150
|
-
};
|
|
151
|
-
if (style === 'ascii') {
|
|
152
|
-
tableOpts.chars = ASCII_BORDER_CHARS;
|
|
153
|
-
}
|
|
154
|
-
const table = new Table(tableOpts);
|
|
155
|
-
for (const [key, value] of Object.entries(data)) {
|
|
156
|
-
if (value === null || value === undefined)
|
|
157
|
-
continue;
|
|
158
|
-
const displayValue = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
159
|
-
const keyLabel = style === 'ascii' ? key : chalk.cyan(key);
|
|
160
|
-
table.push({ [keyLabel]: displayValue });
|
|
161
|
-
}
|
|
162
|
-
console.log(table.toString());
|
|
163
|
-
}
|
|
164
|
-
export class UsageError extends Error {
|
|
165
|
-
constructor(message) {
|
|
166
|
-
super(message);
|
|
167
|
-
this.name = 'UsageError';
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
export class StructuredUsageError extends Error {
|
|
171
|
-
context;
|
|
172
|
-
constructor(message, context) {
|
|
173
|
-
super(message);
|
|
174
|
-
this.context = context;
|
|
175
|
-
this.name = 'StructuredUsageError';
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
function classifyApiError(code) {
|
|
179
|
-
switch (code) {
|
|
180
|
-
case 151:
|
|
181
|
-
case 160:
|
|
182
|
-
case 3005: return 'command-not-supported';
|
|
183
|
-
case 152: return 'device-not-found';
|
|
184
|
-
case 161:
|
|
185
|
-
case 171: return 'device-offline';
|
|
186
|
-
case 190: return 'device-internal-error';
|
|
187
|
-
case 401: return 'auth-failed';
|
|
188
|
-
case 429: return 'quota-exceeded';
|
|
189
|
-
default: return 'unknown-api-error';
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
export function buildErrorPayload(error) {
|
|
193
|
-
if (error instanceof StructuredUsageError) {
|
|
194
|
-
const payload = {
|
|
195
|
-
code: 2,
|
|
196
|
-
kind: 'usage',
|
|
197
|
-
message: error.message,
|
|
198
|
-
errorClass: 'usage',
|
|
199
|
-
transient: false,
|
|
200
|
-
};
|
|
201
|
-
if (error.context) {
|
|
202
|
-
const ctx = error.context;
|
|
203
|
-
const { error: errorType, candidates, hint } = ctx;
|
|
204
|
-
if (errorType === 'ambiguous_name_match') {
|
|
205
|
-
payload.subKind = 'ambiguous-name-match';
|
|
206
|
-
}
|
|
207
|
-
if (Array.isArray(candidates) && candidates.length > 0) {
|
|
208
|
-
const normalized = candidates
|
|
209
|
-
.map((c) => {
|
|
210
|
-
if (typeof c !== 'object' || c === null)
|
|
211
|
-
return null;
|
|
212
|
-
const o = c;
|
|
213
|
-
const name = typeof o.name === 'string' ? o.name
|
|
214
|
-
: typeof o.sceneName === 'string' ? o.sceneName
|
|
215
|
-
: '';
|
|
216
|
-
const match = { name };
|
|
217
|
-
if (typeof o.deviceId === 'string')
|
|
218
|
-
match.deviceId = o.deviceId;
|
|
219
|
-
if (typeof o.sceneId === 'string')
|
|
220
|
-
match.sceneId = o.sceneId;
|
|
221
|
-
return match;
|
|
222
|
-
})
|
|
223
|
-
.filter((c) => c !== null && c.name.length > 0);
|
|
224
|
-
if (normalized.length > 0)
|
|
225
|
-
payload.candidateMatches = normalized;
|
|
226
|
-
}
|
|
227
|
-
if (typeof hint === 'string') {
|
|
228
|
-
payload.resolutionHint = hint;
|
|
229
|
-
}
|
|
230
|
-
// Preserve full context for backward compatibility (including candidates / hint).
|
|
231
|
-
payload.context = ctx;
|
|
232
|
-
}
|
|
233
|
-
return payload;
|
|
234
|
-
}
|
|
235
|
-
if (error instanceof UsageError) {
|
|
236
|
-
return { code: 2, kind: 'usage', message: error.message, errorClass: 'usage', transient: false };
|
|
237
|
-
}
|
|
238
|
-
// Idempotency conflict → exit 2 with kind:guard so scripts can react.
|
|
239
|
-
if (error instanceof Error && error.name === 'IdempotencyConflictError') {
|
|
240
|
-
return {
|
|
241
|
-
code: 2,
|
|
242
|
-
kind: 'guard',
|
|
243
|
-
message: error.message,
|
|
244
|
-
errorClass: 'guard',
|
|
245
|
-
transient: false,
|
|
246
|
-
context: {
|
|
247
|
-
existingShape: error.existingShape,
|
|
248
|
-
newShape: error.newShape,
|
|
249
|
-
},
|
|
250
|
-
};
|
|
251
|
-
}
|
|
252
|
-
// Local daily-cap refusal → exit 2 (usage-style refusal before touching net).
|
|
253
|
-
if (error instanceof Error && error.name === 'DailyCapExceededError') {
|
|
254
|
-
return {
|
|
255
|
-
code: 2,
|
|
256
|
-
kind: 'guard',
|
|
257
|
-
message: error.message,
|
|
258
|
-
errorClass: 'guard',
|
|
259
|
-
transient: false,
|
|
260
|
-
context: {
|
|
261
|
-
cap: error.cap,
|
|
262
|
-
total: error.total,
|
|
263
|
-
profile: error.profile,
|
|
264
|
-
},
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
const code = error instanceof ApiError ? error.code : 1;
|
|
268
|
-
const kind = error instanceof ApiError ? 'api' : 'runtime';
|
|
269
|
-
const message = error instanceof Error ? error.message : 'An unknown error occurred';
|
|
270
|
-
const hint = error instanceof ApiError ? (error.hint ?? errorHint(error.code)) : null;
|
|
271
|
-
const retryable = error instanceof ApiError ? error.retryable : false;
|
|
272
|
-
const retryAfterMs = error instanceof ApiError ? error.retryAfterMs : undefined;
|
|
273
|
-
const transient = error instanceof ApiError ? error.transient : false;
|
|
274
|
-
// Classify error
|
|
275
|
-
let errorClass = 'api';
|
|
276
|
-
if (kind === 'runtime') {
|
|
277
|
-
errorClass = 'api';
|
|
278
|
-
}
|
|
279
|
-
else if (transient && code >= 500) {
|
|
280
|
-
errorClass = 'api';
|
|
281
|
-
}
|
|
282
|
-
else if (code === 0) {
|
|
283
|
-
errorClass = 'network';
|
|
284
|
-
}
|
|
285
|
-
else if (code >= 400) {
|
|
286
|
-
errorClass = 'api';
|
|
287
|
-
}
|
|
288
|
-
const payload = { code, kind, message, errorClass, transient };
|
|
289
|
-
if (error instanceof ApiError)
|
|
290
|
-
payload.subKind = classifyApiError(error.code);
|
|
291
|
-
if (hint)
|
|
292
|
-
payload.hint = hint;
|
|
293
|
-
if (retryable)
|
|
294
|
-
payload.retryable = true;
|
|
295
|
-
if (retryAfterMs !== undefined)
|
|
296
|
-
payload.retryAfterMs = retryAfterMs;
|
|
297
|
-
return payload;
|
|
298
|
-
}
|
|
299
|
-
export function handleError(error) {
|
|
300
|
-
if (error instanceof DryRunSignal) {
|
|
301
|
-
process.exit(0);
|
|
302
|
-
}
|
|
303
|
-
const payload = buildErrorPayload(error);
|
|
304
|
-
if (isJsonMode()) {
|
|
305
|
-
// Bug #SYS-1: Under --json, route the structured envelope to stdout so
|
|
306
|
-
// `cli --json ... | jq` pipelines can decode the error shape exactly
|
|
307
|
-
// the same way they decode success. Previously it went to stderr, which
|
|
308
|
-
// silently broke every error-path pipeline. TTY users still get a
|
|
309
|
-
// terse human-readable line on stderr so interactive runs don't look
|
|
310
|
-
// like the process simply exited.
|
|
311
|
-
console.log(JSON.stringify({ schemaVersion: SCHEMA_VERSION, error: payload }));
|
|
312
|
-
if (process.stderr.isTTY) {
|
|
313
|
-
console.error(chalk.red(payload.message));
|
|
314
|
-
}
|
|
315
|
-
process.exit(payload.code === 2 ? 2 : 1);
|
|
316
|
-
}
|
|
317
|
-
if (payload.kind === 'usage') {
|
|
318
|
-
console.error(payload.message);
|
|
319
|
-
if (Array.isArray(payload.candidateMatches) && payload.candidateMatches.length > 0) {
|
|
320
|
-
const names = payload.candidateMatches
|
|
321
|
-
.map((c) => {
|
|
322
|
-
const id = c.deviceId ?? c.sceneId;
|
|
323
|
-
if (c.name && id)
|
|
324
|
-
return `${c.name} (${id})`;
|
|
325
|
-
return c.name ?? id ?? JSON.stringify(c);
|
|
326
|
-
})
|
|
327
|
-
.slice(0, 6);
|
|
328
|
-
console.error(`Did you mean: ${names.join(', ')}?`);
|
|
329
|
-
}
|
|
330
|
-
else {
|
|
331
|
-
const ctx = payload.context;
|
|
332
|
-
if (ctx && Array.isArray(ctx.candidates) && ctx.candidates.length > 0) {
|
|
333
|
-
const names = ctx.candidates
|
|
334
|
-
.map((c) => {
|
|
335
|
-
if (typeof c === 'string')
|
|
336
|
-
return c;
|
|
337
|
-
if (c && typeof c === 'object') {
|
|
338
|
-
const o = c;
|
|
339
|
-
const name = typeof o.name === 'string'
|
|
340
|
-
? o.name
|
|
341
|
-
: typeof o.sceneName === 'string' ? o.sceneName : undefined;
|
|
342
|
-
const id = typeof o.deviceId === 'string'
|
|
343
|
-
? o.deviceId
|
|
344
|
-
: typeof o.sceneId === 'string' ? o.sceneId : typeof o.id === 'string' ? o.id : undefined;
|
|
345
|
-
if (name && id)
|
|
346
|
-
return `${name} (${id})`;
|
|
347
|
-
return name ?? id ?? JSON.stringify(c);
|
|
348
|
-
}
|
|
349
|
-
return String(c);
|
|
350
|
-
})
|
|
351
|
-
.slice(0, 6);
|
|
352
|
-
console.error(`Did you mean: ${names.join(', ')}?`);
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
if (payload.resolutionHint) {
|
|
356
|
-
console.error(payload.resolutionHint);
|
|
357
|
-
}
|
|
358
|
-
else {
|
|
359
|
-
const ctx = payload.context;
|
|
360
|
-
if (ctx && typeof ctx.hint === 'string') {
|
|
361
|
-
console.error(ctx.hint);
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
process.exit(2);
|
|
365
|
-
}
|
|
366
|
-
if (payload.kind === 'guard') {
|
|
367
|
-
console.error(chalk.yellow(`Guard: ${payload.message}`));
|
|
368
|
-
process.exit(payload.code === 2 ? 2 : 1);
|
|
369
|
-
}
|
|
370
|
-
if (error instanceof ApiError) {
|
|
371
|
-
console.error(chalk.red(`Error (code ${error.code}): ${payload.message}`));
|
|
372
|
-
if (payload.hint)
|
|
373
|
-
console.error(chalk.grey(`Hint: ${payload.hint}`));
|
|
374
|
-
}
|
|
375
|
-
else if (error instanceof Error) {
|
|
376
|
-
console.error(chalk.red(`Error: ${payload.message}`));
|
|
377
|
-
}
|
|
378
|
-
else {
|
|
379
|
-
console.error(chalk.red('An unknown error occurred'));
|
|
380
|
-
}
|
|
381
|
-
process.exit(1);
|
|
382
|
-
}
|
|
383
|
-
function errorHint(code) {
|
|
384
|
-
switch (code) {
|
|
385
|
-
case 152:
|
|
386
|
-
return "Check the deviceId with 'switchbot devices list' (IDs are case-sensitive).";
|
|
387
|
-
case 160:
|
|
388
|
-
return "Run 'switchbot devices describe <deviceId>' to see which commands this device supports.";
|
|
389
|
-
case 161:
|
|
390
|
-
return 'BLE-only devices require a Hub. Check the hub connection and Wi-Fi.';
|
|
391
|
-
case 171:
|
|
392
|
-
return 'The Hub itself is offline — check its power and Wi-Fi.';
|
|
393
|
-
case 190:
|
|
394
|
-
return 'SwitchBot API code 190 is a generic internal error. Common causes: invalid deviceId, unsupported command/parameter, or the endpoint does not apply (e.g., "webhook query" with no webhook configured). Verify with --verbose.';
|
|
395
|
-
case 401:
|
|
396
|
-
return "Re-run 'switchbot config set-token <token> <secret>', or verify SWITCHBOT_TOKEN / SWITCHBOT_SECRET.";
|
|
397
|
-
case 429:
|
|
398
|
-
return 'Daily quota is 10,000 requests/account — retry after midnight UTC.';
|
|
399
|
-
case 3005:
|
|
400
|
-
return "SwitchBot rejected the command as invalid for this specific device model. For IR remotes, this often means the command works only on --type customize (user-learned buttons). Try 'switchbot devices commands <type>' or check the device's capabilities.";
|
|
401
|
-
default:
|
|
402
|
-
return null;
|
|
403
|
-
}
|
|
404
|
-
}
|
package/dist/utils/quota.js
DELETED
|
@@ -1,227 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Local quota counter. Tracks the SwitchBot 10k/day request budget so the
|
|
3
|
-
* CLI (and any AI agent) can check "how many calls have I already burned?"
|
|
4
|
-
* without pinging the API.
|
|
5
|
-
*
|
|
6
|
-
* Shape (`~/.switchbot/quota.json`):
|
|
7
|
-
* {
|
|
8
|
-
* "days": {
|
|
9
|
-
* "2026-04-18": {
|
|
10
|
-
* "total": 42,
|
|
11
|
-
* "endpoints": {
|
|
12
|
-
* "GET /v1.1/devices": 3,
|
|
13
|
-
* "GET /v1.1/devices/:id/status": 27,
|
|
14
|
-
* "POST /v1.1/devices/:id/commands": 12
|
|
15
|
-
* }
|
|
16
|
-
* }
|
|
17
|
-
* }
|
|
18
|
-
* }
|
|
19
|
-
*
|
|
20
|
-
* We keep the last 7 days to bound the file size and give a short-term
|
|
21
|
-
* trend. Writes are fire-and-forget — a failed write never breaks the
|
|
22
|
-
* actual API call.
|
|
23
|
-
*/
|
|
24
|
-
import fs from 'node:fs';
|
|
25
|
-
import path from 'node:path';
|
|
26
|
-
import os from 'node:os';
|
|
27
|
-
export const DAILY_QUOTA = 10_000;
|
|
28
|
-
const MAX_RETAINED_DAYS = 7;
|
|
29
|
-
const FLUSH_DELAY_MS = 250;
|
|
30
|
-
let quotaCache = null;
|
|
31
|
-
let loadedPath = null;
|
|
32
|
-
let dirty = false;
|
|
33
|
-
let flushTimer = null;
|
|
34
|
-
let flushHooksRegistered = false;
|
|
35
|
-
function quotaFilePath() {
|
|
36
|
-
return path.join(os.homedir(), '.switchbot', 'quota.json');
|
|
37
|
-
}
|
|
38
|
-
function today(now = new Date()) {
|
|
39
|
-
// Local date, not UTC — SwitchBot's quota window is loose but users
|
|
40
|
-
// reason about "today" in their own timezone.
|
|
41
|
-
const y = now.getFullYear();
|
|
42
|
-
const m = String(now.getMonth() + 1).padStart(2, '0');
|
|
43
|
-
const d = String(now.getDate()).padStart(2, '0');
|
|
44
|
-
return `${y}-${m}-${d}`;
|
|
45
|
-
}
|
|
46
|
-
function emptyFile() {
|
|
47
|
-
return { days: {} };
|
|
48
|
-
}
|
|
49
|
-
function loadQuotaFromDisk(file) {
|
|
50
|
-
if (!fs.existsSync(file))
|
|
51
|
-
return emptyFile();
|
|
52
|
-
try {
|
|
53
|
-
const raw = fs.readFileSync(file, 'utf-8');
|
|
54
|
-
const parsed = JSON.parse(raw);
|
|
55
|
-
if (!parsed || typeof parsed !== 'object' || !parsed.days)
|
|
56
|
-
return emptyFile();
|
|
57
|
-
return parsed;
|
|
58
|
-
}
|
|
59
|
-
catch {
|
|
60
|
-
return emptyFile();
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
function saveQuota(data, file = quotaFilePath()) {
|
|
64
|
-
const dir = path.dirname(file);
|
|
65
|
-
try {
|
|
66
|
-
if (!fs.existsSync(dir))
|
|
67
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
68
|
-
fs.writeFileSync(file, JSON.stringify(data, null, 2));
|
|
69
|
-
}
|
|
70
|
-
catch {
|
|
71
|
-
// swallow: counting is best-effort, must not break a real API call
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
function clearScheduledFlush() {
|
|
75
|
-
if (flushTimer) {
|
|
76
|
-
clearTimeout(flushTimer);
|
|
77
|
-
flushTimer = null;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
function syncLoadedQuota() {
|
|
81
|
-
const file = quotaFilePath();
|
|
82
|
-
if (loadedPath !== file) {
|
|
83
|
-
clearScheduledFlush();
|
|
84
|
-
quotaCache = loadQuotaFromDisk(file);
|
|
85
|
-
loadedPath = file;
|
|
86
|
-
dirty = false;
|
|
87
|
-
}
|
|
88
|
-
if (!quotaCache) {
|
|
89
|
-
quotaCache = loadQuotaFromDisk(file);
|
|
90
|
-
loadedPath = file;
|
|
91
|
-
}
|
|
92
|
-
return quotaCache;
|
|
93
|
-
}
|
|
94
|
-
function ensureFlushHooks() {
|
|
95
|
-
if (flushHooksRegistered)
|
|
96
|
-
return;
|
|
97
|
-
flushHooksRegistered = true;
|
|
98
|
-
process.on('beforeExit', () => flushQuota());
|
|
99
|
-
process.on('exit', () => flushQuota());
|
|
100
|
-
// SIGINT/SIGTERM: attaching a listener suppresses Node's default terminate.
|
|
101
|
-
// Flush the counter, then re-raise the conventional exit code (128 + signo).
|
|
102
|
-
process.on('SIGINT', () => {
|
|
103
|
-
flushQuota();
|
|
104
|
-
process.exit(130);
|
|
105
|
-
});
|
|
106
|
-
process.on('SIGTERM', () => {
|
|
107
|
-
flushQuota();
|
|
108
|
-
process.exit(143);
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
function scheduleFlush() {
|
|
112
|
-
dirty = true;
|
|
113
|
-
ensureFlushHooks();
|
|
114
|
-
if (flushTimer)
|
|
115
|
-
return;
|
|
116
|
-
flushTimer = setTimeout(() => {
|
|
117
|
-
flushTimer = null;
|
|
118
|
-
flushQuota();
|
|
119
|
-
}, FLUSH_DELAY_MS);
|
|
120
|
-
flushTimer.unref?.();
|
|
121
|
-
}
|
|
122
|
-
export function loadQuota() {
|
|
123
|
-
return syncLoadedQuota();
|
|
124
|
-
}
|
|
125
|
-
function prune(data) {
|
|
126
|
-
const keys = Object.keys(data.days).sort();
|
|
127
|
-
if (keys.length <= MAX_RETAINED_DAYS)
|
|
128
|
-
return data;
|
|
129
|
-
const keep = keys.slice(keys.length - MAX_RETAINED_DAYS);
|
|
130
|
-
const next = { days: {} };
|
|
131
|
-
for (const k of keep)
|
|
132
|
-
next.days[k] = data.days[k];
|
|
133
|
-
return next;
|
|
134
|
-
}
|
|
135
|
-
/**
|
|
136
|
-
* Normalise a full URL into a SwitchBot-style endpoint pattern. The segment
|
|
137
|
-
* immediately after `devices` or `scenes` is collapsed to `:id` so we can
|
|
138
|
-
* bucket by endpoint shape rather than by specific deviceId/sceneId.
|
|
139
|
-
*/
|
|
140
|
-
export function normaliseEndpoint(method, url) {
|
|
141
|
-
const m = (method || 'GET').toUpperCase();
|
|
142
|
-
let pathOnly = url;
|
|
143
|
-
try {
|
|
144
|
-
const parsed = new URL(url);
|
|
145
|
-
pathOnly = parsed.pathname;
|
|
146
|
-
}
|
|
147
|
-
catch {
|
|
148
|
-
const q = url.indexOf('?');
|
|
149
|
-
if (q !== -1)
|
|
150
|
-
pathOnly = url.slice(0, q);
|
|
151
|
-
}
|
|
152
|
-
const segments = pathOnly.split('/');
|
|
153
|
-
for (let i = 0; i < segments.length - 1; i++) {
|
|
154
|
-
if (segments[i] === 'devices' || segments[i] === 'scenes') {
|
|
155
|
-
// Only collapse when the next segment looks like an id (not another
|
|
156
|
-
// API verb); the SwitchBot API uses lower-case keywords elsewhere,
|
|
157
|
-
// but guard against future collisions.
|
|
158
|
-
const next = segments[i + 1];
|
|
159
|
-
if (next && next.length > 0) {
|
|
160
|
-
segments[i + 1] = ':id';
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
return `${m} ${segments.join('/')}`;
|
|
165
|
-
}
|
|
166
|
-
/** Record a single request. Bucketed by local-date + endpoint pattern. */
|
|
167
|
-
export function recordRequest(method, url, now = new Date()) {
|
|
168
|
-
const key = today(now);
|
|
169
|
-
const endpoint = normaliseEndpoint(method, url);
|
|
170
|
-
const data = syncLoadedQuota();
|
|
171
|
-
const bucket = data.days[key] ?? { total: 0, endpoints: {} };
|
|
172
|
-
bucket.total += 1;
|
|
173
|
-
bucket.endpoints[endpoint] = (bucket.endpoints[endpoint] ?? 0) + 1;
|
|
174
|
-
data.days[key] = bucket;
|
|
175
|
-
quotaCache = prune(data);
|
|
176
|
-
scheduleFlush();
|
|
177
|
-
}
|
|
178
|
-
export function flushQuota() {
|
|
179
|
-
if (!dirty)
|
|
180
|
-
return;
|
|
181
|
-
const data = syncLoadedQuota();
|
|
182
|
-
saveQuota(prune(data));
|
|
183
|
-
dirty = false;
|
|
184
|
-
}
|
|
185
|
-
export function resetQuotaState() {
|
|
186
|
-
clearScheduledFlush();
|
|
187
|
-
quotaCache = null;
|
|
188
|
-
loadedPath = null;
|
|
189
|
-
dirty = false;
|
|
190
|
-
}
|
|
191
|
-
export function resetQuota() {
|
|
192
|
-
resetQuotaState();
|
|
193
|
-
const file = quotaFilePath();
|
|
194
|
-
try {
|
|
195
|
-
if (fs.existsSync(file))
|
|
196
|
-
fs.unlinkSync(file);
|
|
197
|
-
}
|
|
198
|
-
catch {
|
|
199
|
-
// ignore
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
/** Return today's usage (convenience for `quota status`). */
|
|
203
|
-
export function todayUsage(now = new Date()) {
|
|
204
|
-
const key = today(now);
|
|
205
|
-
const data = loadQuota();
|
|
206
|
-
const bucket = data.days[key] ?? { total: 0, endpoints: {} };
|
|
207
|
-
return {
|
|
208
|
-
date: key,
|
|
209
|
-
total: bucket.total,
|
|
210
|
-
remaining: Math.max(0, DAILY_QUOTA - bucket.total),
|
|
211
|
-
endpoints: { ...bucket.endpoints },
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
/**
|
|
215
|
-
* Check whether today's call count is at or over the given cap. Returns the
|
|
216
|
-
* current counter either way so callers can render a helpful refusal message.
|
|
217
|
-
* Undefined cap → returns { over: false } without loading anything.
|
|
218
|
-
*/
|
|
219
|
-
export function checkDailyCap(dailyCap, now = new Date()) {
|
|
220
|
-
const date = today(now);
|
|
221
|
-
if (!dailyCap || dailyCap <= 0) {
|
|
222
|
-
return { over: false, total: 0, date };
|
|
223
|
-
}
|
|
224
|
-
const data = loadQuota();
|
|
225
|
-
const total = data.days[date]?.total ?? 0;
|
|
226
|
-
return { over: total >= dailyCap, total, cap: dailyCap, date };
|
|
227
|
-
}
|
package/dist/utils/redact.js
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Header/value redaction utilities for verbose traces.
|
|
3
|
-
*
|
|
4
|
-
* C6 contract: any header whose name matches a sensitive pattern is mid-masked
|
|
5
|
-
* (first 2 chars + `*` run + last 2 chars) before it is written to stderr. The
|
|
6
|
-
* `--trace-unsafe` flag turns masking off — with a prominent one-time warning.
|
|
7
|
-
*/
|
|
8
|
-
import { isTraceUnsafe } from './flags.js';
|
|
9
|
-
const SENSITIVE_HEADER_PATTERNS = [
|
|
10
|
-
/^authorization$/i,
|
|
11
|
-
/^token$/i,
|
|
12
|
-
/^sign$/i,
|
|
13
|
-
/^nonce$/i,
|
|
14
|
-
/^x-api-key$/i,
|
|
15
|
-
/^cookie$/i,
|
|
16
|
-
/^set-cookie$/i,
|
|
17
|
-
/^x-auth-token$/i,
|
|
18
|
-
];
|
|
19
|
-
// The `t` header (timestamp) is treated as sensitive alongside sign because
|
|
20
|
-
// together they reconstruct the HMAC signature — anyone watching the logs
|
|
21
|
-
// shouldn't be able to replay the exact timestamp that was used.
|
|
22
|
-
const SENSITIVE_EXACT_KEYS = new Set(['t']);
|
|
23
|
-
export function isSensitiveHeader(name) {
|
|
24
|
-
if (SENSITIVE_EXACT_KEYS.has(name))
|
|
25
|
-
return true;
|
|
26
|
-
return SENSITIVE_HEADER_PATTERNS.some((re) => re.test(name));
|
|
27
|
-
}
|
|
28
|
-
export function maskValue(value) {
|
|
29
|
-
if (value.length <= 4)
|
|
30
|
-
return '****';
|
|
31
|
-
return `${value.slice(0, 2)}${'*'.repeat(Math.max(4, value.length - 4))}${value.slice(-2)}`;
|
|
32
|
-
}
|
|
33
|
-
/**
|
|
34
|
-
* Redact the sensitive entries of a headers object. Returns a new object
|
|
35
|
-
* alongside the count of entries that were masked.
|
|
36
|
-
*/
|
|
37
|
-
export function redactHeaders(headers) {
|
|
38
|
-
const safe = {};
|
|
39
|
-
let redactedCount = 0;
|
|
40
|
-
if (!headers)
|
|
41
|
-
return { safe, redactedCount };
|
|
42
|
-
const unsafe = isTraceUnsafe();
|
|
43
|
-
for (const [k, v] of Object.entries(headers)) {
|
|
44
|
-
const strVal = typeof v === 'string' ? v : v == null ? '' : String(v);
|
|
45
|
-
if (!unsafe && isSensitiveHeader(k)) {
|
|
46
|
-
safe[k] = maskValue(strVal);
|
|
47
|
-
redactedCount++;
|
|
48
|
-
}
|
|
49
|
-
else {
|
|
50
|
-
safe[k] = strVal;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return { safe, redactedCount };
|
|
54
|
-
}
|
|
55
|
-
let unsafeBannerShown = false;
|
|
56
|
-
/**
|
|
57
|
-
* Print the big "REDACTION DISABLED" banner once per process when
|
|
58
|
-
* --trace-unsafe is on. Callers should invoke this once before any
|
|
59
|
-
* header-spilling output.
|
|
60
|
-
*/
|
|
61
|
-
export function warnOnceIfUnsafe() {
|
|
62
|
-
if (unsafeBannerShown)
|
|
63
|
-
return;
|
|
64
|
-
if (!isTraceUnsafe())
|
|
65
|
-
return;
|
|
66
|
-
unsafeBannerShown = true;
|
|
67
|
-
process.stderr.write('⚠️ --trace-unsafe: sensitive headers will be printed UNMASKED. Do not share this output.\n');
|
|
68
|
-
}
|