@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.
Files changed (113) hide show
  1. package/README.md +34 -42
  2. package/dist/index.js +56945 -169
  3. package/dist/policy/schema/v0.2.json +1 -1
  4. package/package.json +3 -2
  5. package/dist/api/client.js +0 -235
  6. package/dist/auth.js +0 -20
  7. package/dist/commands/agent-bootstrap.js +0 -182
  8. package/dist/commands/auth.js +0 -354
  9. package/dist/commands/batch.js +0 -413
  10. package/dist/commands/cache.js +0 -126
  11. package/dist/commands/capabilities.js +0 -385
  12. package/dist/commands/catalog.js +0 -359
  13. package/dist/commands/completion.js +0 -385
  14. package/dist/commands/config.js +0 -376
  15. package/dist/commands/daemon.js +0 -367
  16. package/dist/commands/device-meta.js +0 -159
  17. package/dist/commands/devices.js +0 -948
  18. package/dist/commands/doctor.js +0 -1015
  19. package/dist/commands/events.js +0 -563
  20. package/dist/commands/expand.js +0 -130
  21. package/dist/commands/explain.js +0 -139
  22. package/dist/commands/health.js +0 -113
  23. package/dist/commands/history.js +0 -320
  24. package/dist/commands/identity.js +0 -59
  25. package/dist/commands/install.js +0 -246
  26. package/dist/commands/mcp.js +0 -2017
  27. package/dist/commands/plan.js +0 -653
  28. package/dist/commands/policy.js +0 -586
  29. package/dist/commands/quota.js +0 -78
  30. package/dist/commands/rules.js +0 -875
  31. package/dist/commands/scenes.js +0 -264
  32. package/dist/commands/schema.js +0 -177
  33. package/dist/commands/status-sync.js +0 -131
  34. package/dist/commands/uninstall.js +0 -237
  35. package/dist/commands/upgrade-check.js +0 -88
  36. package/dist/commands/watch.js +0 -194
  37. package/dist/commands/webhook.js +0 -182
  38. package/dist/config.js +0 -258
  39. package/dist/credentials/backends/file.js +0 -101
  40. package/dist/credentials/backends/linux.js +0 -129
  41. package/dist/credentials/backends/macos.js +0 -129
  42. package/dist/credentials/backends/windows.js +0 -215
  43. package/dist/credentials/keychain.js +0 -88
  44. package/dist/credentials/prime.js +0 -52
  45. package/dist/devices/cache.js +0 -293
  46. package/dist/devices/catalog.js +0 -767
  47. package/dist/devices/device-meta.js +0 -56
  48. package/dist/devices/history-agg.js +0 -138
  49. package/dist/devices/history-query.js +0 -181
  50. package/dist/devices/param-validator.js +0 -433
  51. package/dist/devices/resources.js +0 -270
  52. package/dist/install/default-steps.js +0 -257
  53. package/dist/install/preflight.js +0 -212
  54. package/dist/install/steps.js +0 -67
  55. package/dist/lib/command-keywords.js +0 -17
  56. package/dist/lib/daemon-state.js +0 -46
  57. package/dist/lib/destructive-mode.js +0 -12
  58. package/dist/lib/devices.js +0 -382
  59. package/dist/lib/idempotency.js +0 -106
  60. package/dist/lib/plan-store.js +0 -68
  61. package/dist/lib/request-context.js +0 -12
  62. package/dist/lib/scenes.js +0 -10
  63. package/dist/logger.js +0 -16
  64. package/dist/mcp/device-history.js +0 -145
  65. package/dist/mcp/events-subscription.js +0 -213
  66. package/dist/mqtt/client.js +0 -180
  67. package/dist/mqtt/credential.js +0 -30
  68. package/dist/policy/add-rule.js +0 -124
  69. package/dist/policy/diff.js +0 -91
  70. package/dist/policy/format.js +0 -57
  71. package/dist/policy/load.js +0 -61
  72. package/dist/policy/migrate.js +0 -67
  73. package/dist/policy/schema.js +0 -18
  74. package/dist/policy/validate.js +0 -262
  75. package/dist/rules/action.js +0 -205
  76. package/dist/rules/audit-query.js +0 -89
  77. package/dist/rules/conflict-analyzer.js +0 -203
  78. package/dist/rules/cron-scheduler.js +0 -186
  79. package/dist/rules/destructive.js +0 -52
  80. package/dist/rules/engine.js +0 -757
  81. package/dist/rules/matcher.js +0 -230
  82. package/dist/rules/pid-file.js +0 -95
  83. package/dist/rules/quiet-hours.js +0 -45
  84. package/dist/rules/suggest.js +0 -95
  85. package/dist/rules/throttle.js +0 -116
  86. package/dist/rules/types.js +0 -34
  87. package/dist/rules/webhook-listener.js +0 -223
  88. package/dist/rules/webhook-token.js +0 -90
  89. package/dist/schema/field-aliases.js +0 -131
  90. package/dist/sinks/dispatcher.js +0 -12
  91. package/dist/sinks/file.js +0 -19
  92. package/dist/sinks/format.js +0 -56
  93. package/dist/sinks/homeassistant.js +0 -44
  94. package/dist/sinks/openclaw.js +0 -33
  95. package/dist/sinks/stdout.js +0 -5
  96. package/dist/sinks/telegram.js +0 -28
  97. package/dist/sinks/types.js +0 -1
  98. package/dist/sinks/webhook.js +0 -22
  99. package/dist/status-sync/manager.js +0 -268
  100. package/dist/utils/arg-parsers.js +0 -66
  101. package/dist/utils/audit.js +0 -117
  102. package/dist/utils/filter.js +0 -189
  103. package/dist/utils/flags.js +0 -186
  104. package/dist/utils/format.js +0 -117
  105. package/dist/utils/health.js +0 -101
  106. package/dist/utils/help-json.js +0 -54
  107. package/dist/utils/name-resolver.js +0 -137
  108. package/dist/utils/output.js +0 -404
  109. package/dist/utils/quota.js +0 -227
  110. package/dist/utils/redact.js +0 -68
  111. package/dist/utils/retry.js +0 -140
  112. package/dist/utils/string.js +0 -22
  113. package/dist/version.js +0 -4
@@ -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
- }
@@ -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
- }
@@ -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
- }