@switchbot/openapi-cli 2.3.0 → 2.5.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.
@@ -3,8 +3,9 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { printJson, isJsonMode } from '../utils/output.js';
5
5
  import { getEffectiveCatalog } from '../devices/catalog.js';
6
- import { configFilePath, listProfiles } from '../config.js';
6
+ import { configFilePath, listProfiles, readProfileMeta } from '../config.js';
7
7
  import { describeCache } from '../devices/cache.js';
8
+ export const DOCTOR_SCHEMA_VERSION = 1;
8
9
  async function checkCredentials() {
9
10
  const envOk = Boolean(process.env.SWITCHBOT_TOKEN && process.env.SWITCHBOT_SECRET);
10
11
  if (envOk)
@@ -39,21 +40,97 @@ function checkProfiles() {
39
40
  return { name: 'profiles', status: 'ok', detail: 'no profile dir (default profile only)' };
40
41
  }
41
42
  const profiles = listProfiles();
43
+ if (profiles.length === 0) {
44
+ return { name: 'profiles', status: 'ok', detail: 'profile dir empty' };
45
+ }
46
+ const labelled = profiles.map((p) => {
47
+ const meta = readProfileMeta(p);
48
+ if (meta?.label)
49
+ return `${p} (${meta.label})`;
50
+ return p;
51
+ });
42
52
  return {
43
53
  name: 'profiles',
44
54
  status: 'ok',
45
- detail: profiles.length ? `found ${profiles.length}: ${profiles.join(', ')}` : 'profile dir empty',
55
+ detail: `found ${profiles.length}: ${labelled.join(', ')}`,
46
56
  };
47
57
  }
48
- function checkClockSkew() {
49
- const now = Date.now();
50
- const drift = now - Math.floor(now / 1000) * 1000;
51
- // HMAC signing uses ms timestamps we can't detect remote skew without a
52
- // round-trip, but we can flag if the local clock has NTP issues via the
53
- // classic "jumps back" pattern. Best-effort: just report local time.
54
- const iso = new Date().toISOString();
55
- return { name: 'clock', status: 'ok', detail: `local time ${iso} (drift check needs API round-trip)` };
56
- void drift;
58
+ async function checkClockSkew() {
59
+ // Real probe: HEAD the SwitchBot API endpoint and compare the server's Date
60
+ // header against local time. No auth required for the Date header — the API
61
+ // returns 401 but still stamps the response. Gracefully degrades to
62
+ // probeSource:'none' if offline / no network reachable.
63
+ //
64
+ // Under vitest, only run the probe if fetch has been stubbed (detected via
65
+ // vi.fn marker) otherwise skip network I/O to keep unrelated tests fast.
66
+ const underVitest = Boolean(process.env.VITEST);
67
+ const fetchFn = globalThis.fetch;
68
+ const fetchIsMocked = Boolean(fetchFn && typeof fetchFn === 'function' && 'mock' in fetchFn);
69
+ if (underVitest && !fetchIsMocked) {
70
+ return {
71
+ name: 'clock',
72
+ status: 'warn',
73
+ detail: { probeSource: 'none', skewMs: null, message: 'skipped: test environment' },
74
+ };
75
+ }
76
+ const localBefore = Date.now();
77
+ const ctrl = new AbortController();
78
+ const timer = setTimeout(() => ctrl.abort(), 2500);
79
+ try {
80
+ const res = await fetch('https://api.switch-bot.com/v1.1/devices', {
81
+ method: 'HEAD',
82
+ signal: ctrl.signal,
83
+ });
84
+ const localAfter = Date.now();
85
+ const dateHeader = res.headers.get('date');
86
+ if (!dateHeader) {
87
+ return {
88
+ name: 'clock',
89
+ status: 'warn',
90
+ detail: { probeSource: 'api', skewMs: null, message: 'server returned no Date header' },
91
+ };
92
+ }
93
+ const serverMs = Date.parse(dateHeader);
94
+ if (!Number.isFinite(serverMs)) {
95
+ return {
96
+ name: 'clock',
97
+ status: 'warn',
98
+ detail: { probeSource: 'api', skewMs: null, message: `unparseable Date header: ${dateHeader}` },
99
+ };
100
+ }
101
+ // Split the round-trip in half to estimate the local instant that matches
102
+ // the server's Date header. HTTP Date resolution is 1s, so treat anything
103
+ // under 2000ms as ok, 2000–60000ms as warn, beyond that as fail (HMAC
104
+ // auth rejects requests with skew > 5 minutes anyway).
105
+ const midpoint = (localBefore + localAfter) / 2;
106
+ const skewMs = Math.round(midpoint - serverMs);
107
+ const absSkew = Math.abs(skewMs);
108
+ const status = absSkew < 2000 ? 'ok' : absSkew < 60_000 ? 'warn' : 'fail';
109
+ return {
110
+ name: 'clock',
111
+ status,
112
+ detail: {
113
+ probeSource: 'api',
114
+ skewMs,
115
+ localIso: new Date(midpoint).toISOString(),
116
+ serverIso: new Date(serverMs).toISOString(),
117
+ },
118
+ };
119
+ }
120
+ catch (err) {
121
+ return {
122
+ name: 'clock',
123
+ status: 'warn',
124
+ detail: {
125
+ probeSource: 'none',
126
+ skewMs: null,
127
+ message: `probe failed: ${err instanceof Error ? err.message : String(err)}`,
128
+ },
129
+ };
130
+ }
131
+ finally {
132
+ clearTimeout(timer);
133
+ }
57
134
  }
58
135
  function checkCatalog() {
59
136
  const catalog = getEffectiveCatalog();
@@ -153,7 +230,7 @@ Examples:
153
230
  checkCatalog(),
154
231
  checkCache(),
155
232
  checkQuotaFile(),
156
- checkClockSkew(),
233
+ await checkClockSkew(),
157
234
  checkMqtt(),
158
235
  ];
159
236
  const summary = {
@@ -162,13 +239,27 @@ Examples:
162
239
  fail: checks.filter((c) => c.status === 'fail').length,
163
240
  };
164
241
  const overallFail = summary.fail > 0;
242
+ const overall = overallFail ? 'fail' : summary.warn > 0 ? 'warn' : 'ok';
165
243
  if (isJsonMode()) {
166
- printJson({ overall: overallFail ? 'fail' : summary.warn > 0 ? 'warn' : 'ok', summary, checks });
244
+ // Stable contract (locked as doctor.schemaVersion=1):
245
+ // { ok: boolean, overall: 'ok'|'warn'|'fail', generatedAt, schemaVersion,
246
+ // summary: { ok, warn, fail }, checks: [{ name, status, detail }] }
247
+ // `ok` is an alias of (overall === 'ok') — agents prefer the boolean,
248
+ // humans prefer the string; both are provided.
249
+ printJson({
250
+ ok: overall === 'ok',
251
+ overall,
252
+ generatedAt: new Date().toISOString(),
253
+ schemaVersion: DOCTOR_SCHEMA_VERSION,
254
+ summary,
255
+ checks,
256
+ });
167
257
  }
168
258
  else {
169
259
  for (const c of checks) {
170
260
  const icon = c.status === 'ok' ? '✓' : c.status === 'warn' ? '!' : '✗';
171
- console.log(`${icon} ${c.name.padEnd(12)} ${c.detail}`);
261
+ const detailStr = typeof c.detail === 'string' ? c.detail : JSON.stringify(c.detail);
262
+ console.log(`${icon} ${c.name.padEnd(12)} ${detailStr}`);
172
263
  }
173
264
  console.log('');
174
265
  console.log(`${summary.ok} ok, ${summary.warn} warn, ${summary.fail} fail`);
@@ -1,4 +1,5 @@
1
1
  import http from 'node:http';
2
+ import crypto from 'node:crypto';
2
3
  import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
3
4
  import { intArg, stringArg } from '../utils/arg-parsers.js';
4
5
  import { SwitchBotMqttClient } from '../mqtt/client.js';
@@ -16,6 +17,17 @@ import { deviceHistoryStore } from '../mcp/device-history.js';
16
17
  const DEFAULT_PORT = 3000;
17
18
  const DEFAULT_PATH = '/';
18
19
  const MAX_BODY_BYTES = 1_000_000;
20
+ function extractEventId(parsed) {
21
+ if (!parsed || typeof parsed !== 'object')
22
+ return null;
23
+ const p = parsed;
24
+ if (typeof p.eventId === 'string' && p.eventId.length > 0)
25
+ return p.eventId;
26
+ const ctx = p.context;
27
+ if (ctx && typeof ctx.eventId === 'string' && ctx.eventId.length > 0)
28
+ return ctx.eventId;
29
+ return null;
30
+ }
19
31
  function matchFilter(body, filter) {
20
32
  if (!filter)
21
33
  return true;
@@ -220,7 +232,19 @@ Connects to the SwitchBot MQTT service using your existing credentials
220
232
  No additional MQTT configuration required.
221
233
 
222
234
  Output (JSONL, one event per line):
223
- { "t": "<ISO>", "topic": "<mqtt-topic>", "payload": <parsed JSON or raw string> }
235
+ { "t": "<ISO>", "eventId": "<uuid>", "topic": "<mqtt-topic>", "payload": <parsed JSON or raw string> }
236
+
237
+ Control records (interleaved, no "payload" field — use type-prefix to filter):
238
+ { "type": "__connect", "at": "<ISO>", "eventId": "<uuid>" } first successful connect
239
+ { "type": "__reconnect", "at": "<ISO>", "eventId": "<uuid>" } connect after a disconnect
240
+ { "type": "__disconnect", "at": "<ISO>", "eventId": "<uuid>" } reconnecting or failed
241
+
242
+ Reconnect policy: the MQTT client retries with exponential backoff
243
+ (1s → 30s capped, forever) while the credential is still valid; if the
244
+ credential is rejected or 5 consecutive reconnects fail, state goes to
245
+ 'failed' and the command exits non-zero so supervisors can restart it.
246
+ QoS is 0 (at-most-once); agents requiring at-least-once delivery should
247
+ fan-out via --sink file and deduplicate by eventId on the consumer side.
224
248
 
225
249
  Sink types (--sink, repeatable):
226
250
  stdout Print JSONL to stdout (default when no --sink given)
@@ -321,9 +345,14 @@ Examples:
321
345
  parsed = payload.toString('utf-8');
322
346
  }
323
347
  const t = new Date().toISOString();
348
+ // Every event carries an eventId so downstream sinks / replay tools
349
+ // can dedupe. If the broker supplied one (some providers do via a
350
+ // header), prefer that; otherwise synth a UUID locally.
351
+ const existingId = extractEventId(parsed);
352
+ const eventId = existingId ?? crypto.randomUUID();
324
353
  if (dispatcher) {
325
354
  const { deviceId, deviceType, text } = parseSinkEvent(parsed);
326
- const sinkEvent = { t, topic: msgTopic, deviceId, deviceType, payload: parsed, text };
355
+ const sinkEvent = { t, topic: msgTopic, deviceId, deviceType, payload: parsed, text, eventId };
327
356
  deviceHistoryStore.record(deviceId, msgTopic, deviceType, parsed, t);
328
357
  dispatcher.dispatch(sinkEvent).catch(() => { });
329
358
  }
@@ -331,7 +360,7 @@ Examples:
331
360
  // Default behavior: record history + print to stdout
332
361
  const { deviceId, deviceType } = parseSinkEvent(parsed);
333
362
  deviceHistoryStore.record(deviceId, msgTopic, deviceType, parsed, t);
334
- const record = { t, topic: msgTopic, payload: parsed };
363
+ const record = { t, eventId, topic: msgTopic, payload: parsed };
335
364
  if (isJsonMode()) {
336
365
  printJson(record);
337
366
  }
@@ -345,12 +374,39 @@ Examples:
345
374
  }
346
375
  });
347
376
  let mqttFailed = false;
377
+ let hasConnectedBefore = false;
378
+ const emitControl = (kind) => {
379
+ const ctl = { type: kind, at: new Date().toISOString(), eventId: crypto.randomUUID() };
380
+ // Control events always go to stdout as JSONL so consumers that
381
+ // filter real events by presence of `payload` can skip them.
382
+ if (isJsonMode()) {
383
+ printJson(ctl);
384
+ }
385
+ else {
386
+ console.log(JSON.stringify(ctl));
387
+ }
388
+ // Persist to __control.jsonl — best-effort, never blocks the stream.
389
+ try {
390
+ deviceHistoryStore.recordControl(ctl);
391
+ }
392
+ catch {
393
+ // swallow
394
+ }
395
+ };
348
396
  const unsubState = client.onStateChange((state) => {
349
397
  if (!isJsonMode()) {
350
398
  console.error(`[${new Date().toLocaleTimeString()}] MQTT state: ${state}`);
351
399
  }
352
- if (state === 'failed') {
400
+ if (state === 'connected') {
401
+ emitControl(hasConnectedBefore ? '__reconnect' : '__connect');
402
+ hasConnectedBefore = true;
403
+ }
404
+ else if (state === 'reconnecting') {
405
+ emitControl('__disconnect');
406
+ }
407
+ else if (state === 'failed') {
353
408
  mqttFailed = true;
409
+ emitControl('__disconnect');
354
410
  if (!isJsonMode()) {
355
411
  console.error('MQTT connection failed permanently (credential expired or reconnect exhausted) — exiting.');
356
412
  }
@@ -1,9 +1,11 @@
1
1
  import path from 'node:path';
2
2
  import os from 'node:os';
3
3
  import { intArg, stringArg } from '../utils/arg-parsers.js';
4
- import { printJson, isJsonMode, handleError } from '../utils/output.js';
5
- import { readAudit } from '../utils/audit.js';
4
+ import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
5
+ import { readAudit, verifyAudit } from '../utils/audit.js';
6
6
  import { executeCommand } from '../lib/devices.js';
7
+ import { queryDeviceHistory, queryDeviceHistoryStats, } from '../devices/history-query.js';
8
+ import { aggregateDeviceHistory, ALL_AGG_FNS, } from '../devices/history-agg.js';
7
9
  const DEFAULT_AUDIT = path.join(os.homedir(), '.switchbot', 'audit.log');
8
10
  export function registerHistoryCommand(program) {
9
11
  const history = program
@@ -101,4 +103,227 @@ Examples:
101
103
  handleError(err);
102
104
  }
103
105
  });
106
+ history
107
+ .command('range')
108
+ .description('Query time-ranged device history from JSONL storage (populated by events mqtt-tail / MCP)')
109
+ .argument('<deviceId>', 'Device ID to query')
110
+ .option('--since <duration>', 'Relative window ending now, e.g. "30s", "15m", "1h", "7d" (mutually exclusive with --from/--to)', stringArg('--since'))
111
+ .option('--from <iso>', 'Range start (ISO-8601)', stringArg('--from'))
112
+ .option('--to <iso>', 'Range end (ISO-8601)', stringArg('--to'))
113
+ .option('--field <name>', 'Project a payload field (repeat to keep multiple)', (v, acc = []) => acc.concat(v), [])
114
+ .option('--limit <n>', 'Maximum records to return (default 1000)', intArg('--limit', { min: 1 }))
115
+ .addHelpText('after', `
116
+ History is the append-only JSONL mirror of the per-device ring buffer: every
117
+ 'events mqtt-tail' event and every MCP tool status-refresh is written to
118
+ ~/.switchbot/device-history/<deviceId>.jsonl (rotates at 50MB × 3 files).
119
+
120
+ Examples:
121
+ $ switchbot history range <id> --since 7d --json
122
+ $ switchbot history range <id> --since 1h --field temperature --field humidity
123
+ $ switchbot history range <id> --from 2026-04-18T00:00:00Z --to 2026-04-19T00:00:00Z
124
+ `)
125
+ .action(async (deviceId, options) => {
126
+ // Usage-level validation: keep synchronous and pre-query so handleError
127
+ // maps these to exit 2 (via UsageError) rather than runtime exit 1.
128
+ if (options.since && (options.from || options.to)) {
129
+ handleError(new UsageError('--since is mutually exclusive with --from/--to.'));
130
+ }
131
+ try {
132
+ const records = await queryDeviceHistory(deviceId, {
133
+ since: options.since,
134
+ from: options.from,
135
+ to: options.to,
136
+ fields: options.field ?? [],
137
+ limit: options.limit !== undefined ? Number(options.limit) : undefined,
138
+ });
139
+ if (isJsonMode()) {
140
+ printJson({ deviceId, count: records.length, records });
141
+ return;
142
+ }
143
+ if (records.length === 0) {
144
+ console.log(`(no history records for ${deviceId} in requested range)`);
145
+ return;
146
+ }
147
+ for (const r of records) {
148
+ const payloadStr = JSON.stringify(r.payload);
149
+ console.log(`${r.t} ${r.topic} ${payloadStr}`);
150
+ }
151
+ }
152
+ catch (err) {
153
+ // Convert history-query's plain Error range messages into UsageError so
154
+ // they exit 2 instead of 1.
155
+ if (err instanceof Error && /^(Invalid --|--from|--since)/i.test(err.message)) {
156
+ handleError(new UsageError(err.message));
157
+ }
158
+ handleError(err);
159
+ }
160
+ });
161
+ history
162
+ .command('stats')
163
+ .description('Show on-disk size + record counts for a device history')
164
+ .argument('<deviceId>', 'Device ID to inspect')
165
+ .action((deviceId) => {
166
+ try {
167
+ const stats = queryDeviceHistoryStats(deviceId);
168
+ if (isJsonMode()) {
169
+ printJson(stats);
170
+ return;
171
+ }
172
+ console.log(`Device: ${stats.deviceId}`);
173
+ console.log(`History dir: ${stats.historyDir}`);
174
+ console.log(`JSONL files: ${stats.fileCount} (${stats.jsonlFiles.join(', ') || '—'})`);
175
+ console.log(`Total size: ${stats.totalBytes.toLocaleString()} bytes`);
176
+ console.log(`Record count: ${stats.recordCount}`);
177
+ console.log(`Oldest: ${stats.oldest ?? '—'}`);
178
+ console.log(`Newest: ${stats.newest ?? '—'}`);
179
+ }
180
+ catch (err) {
181
+ handleError(err);
182
+ }
183
+ });
184
+ history
185
+ .command('verify')
186
+ .description('Check the audit log for malformed lines and schema-version drift')
187
+ .option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file'))
188
+ .addHelpText('after', `
189
+ See docs/audit-log.md for the audit log format. Exit code:
190
+ 0 every line parses and carries the current auditVersion, or file is missing (warn)
191
+ 1 one or more lines are malformed or schema drift detected
192
+ 2 (usage) — not emitted by this subcommand
193
+
194
+ Examples:
195
+ $ switchbot history verify
196
+ $ switchbot history verify --file ./custom.log --json
197
+ `)
198
+ .action((options) => {
199
+ const file = options.file ?? DEFAULT_AUDIT;
200
+ const report = verifyAudit(file);
201
+ // Determine status and exit code
202
+ let status = 'ok';
203
+ let exitCode = 0;
204
+ if (report.fileMissing) {
205
+ status = 'warn';
206
+ }
207
+ else if (report.malformedLines > 0 || report.unversionedEntries > 0) {
208
+ status = 'fail';
209
+ exitCode = 1;
210
+ }
211
+ if (isJsonMode()) {
212
+ const output = {
213
+ status,
214
+ fileMissing: report.fileMissing === true,
215
+ parsed: report.parsedLines,
216
+ malformed: report.malformedLines,
217
+ unversioned: report.unversionedEntries,
218
+ message: report.fileMissing
219
+ ? 'Audit log file not found (fresh install)'
220
+ : report.malformedLines > 0 || report.unversionedEntries > 0
221
+ ? 'Audit log has malformed or unversioned entries'
222
+ : 'Audit log is valid',
223
+ };
224
+ printJson(output);
225
+ }
226
+ else {
227
+ if (report.fileMissing) {
228
+ console.log(`Audit log: ${report.file} (missing — fresh install)`);
229
+ console.log(`Status: ✓ warn (expected for new accounts)`);
230
+ }
231
+ else {
232
+ console.log(`Audit log: ${report.file}`);
233
+ console.log(`Parsed lines: ${report.parsedLines} / ${report.totalLines}`);
234
+ console.log(`Malformed: ${report.malformedLines}`);
235
+ console.log(`Unversioned: ${report.unversionedEntries}`);
236
+ const versions = Object.entries(report.versionCounts)
237
+ .map(([v, n]) => `${v}:${n}`)
238
+ .join(', ');
239
+ console.log(`Version counts: ${versions || '—'}`);
240
+ if (report.earliest)
241
+ console.log(`Earliest: ${report.earliest}`);
242
+ if (report.latest)
243
+ console.log(`Latest: ${report.latest}`);
244
+ if (report.problems.length > 0) {
245
+ console.log('\nProblems:');
246
+ for (const p of report.problems) {
247
+ console.log(` line ${p.line}: ${p.reason}${p.preview ? ` — "${p.preview}"` : ''}`);
248
+ }
249
+ }
250
+ }
251
+ }
252
+ process.exit(exitCode);
253
+ });
254
+ history
255
+ .command('aggregate')
256
+ .description('Aggregate time-ranged device history metrics into buckets')
257
+ .argument('<deviceId>', 'Device ID to aggregate')
258
+ .option('--since <duration>', 'Relative window ending now, e.g. "1h", "7d" (mutually exclusive with --from/--to)', stringArg('--since'))
259
+ .option('--from <iso>', 'Range start (ISO-8601)', stringArg('--from'))
260
+ .option('--to <iso>', 'Range end (ISO-8601)', stringArg('--to'))
261
+ .option('--metric <name>', 'Payload field to aggregate (repeat for multiple)', (v, acc = []) => acc.concat(v), [])
262
+ .option('--agg <csv>', 'Comma-separated aggregation functions (count,min,max,avg,sum,p50,p95)', stringArg('--agg'))
263
+ .option('--bucket <duration>', 'Bucket width, e.g. "15m", "1h", "1d"', stringArg('--bucket'))
264
+ .option('--max-bucket-samples <n>', 'Max samples per bucket for quantiles (1–100000)', intArg('--max-bucket-samples', { min: 1, max: 100_000 }))
265
+ .action(async (deviceId, options) => {
266
+ const metrics = options.metric ?? [];
267
+ if (metrics.length === 0) {
268
+ handleError(new UsageError('at least one --metric is required.'));
269
+ }
270
+ if (options.since && (options.from || options.to)) {
271
+ handleError(new UsageError('--since is mutually exclusive with --from/--to.'));
272
+ }
273
+ let aggs;
274
+ if (options.agg !== undefined) {
275
+ const parts = options.agg.split(',').map((s) => s.trim()).filter(Boolean);
276
+ const unknown = parts.filter((p) => !ALL_AGG_FNS.includes(p));
277
+ if (unknown.length > 0) {
278
+ handleError(new UsageError(`Unknown aggregation function(s): ${unknown.join(', ')}. Legal values: ${ALL_AGG_FNS.join(', ')}.`));
279
+ }
280
+ aggs = parts;
281
+ }
282
+ const aggOpts = {
283
+ metrics,
284
+ aggs,
285
+ since: options.since,
286
+ from: options.from,
287
+ to: options.to,
288
+ bucket: options.bucket,
289
+ maxBucketSamples: options.maxBucketSamples !== undefined ? Number(options.maxBucketSamples) : undefined,
290
+ };
291
+ try {
292
+ const res = await aggregateDeviceHistory(deviceId, aggOpts);
293
+ if (isJsonMode()) {
294
+ printJson(res);
295
+ return;
296
+ }
297
+ if (res.buckets.length === 0) {
298
+ console.log(`(no history records for ${deviceId} in requested range)`);
299
+ return;
300
+ }
301
+ const aggCols = res.aggs;
302
+ const cols = ['t', ...res.metrics.flatMap((m) => aggCols.map((a) => `${m}.${a}`))];
303
+ console.log(cols.join('\t'));
304
+ for (const bkt of res.buckets) {
305
+ const row = cols.map((col) => {
306
+ if (col === 't')
307
+ return bkt.t;
308
+ const [metric, agg] = col.split('.');
309
+ const val = bkt.metrics[metric]?.[agg];
310
+ return val !== undefined ? String(val) : '\u2014';
311
+ });
312
+ console.log(row.join('\t'));
313
+ }
314
+ if (res.partial) {
315
+ for (const note of res.notes) {
316
+ console.error('note: ' + note);
317
+ }
318
+ }
319
+ }
320
+ catch (err) {
321
+ if (err instanceof Error) {
322
+ if (/bucket/i.test(err.message) || /--since/i.test(err.message) || /--from/i.test(err.message) || /--to/i.test(err.message)) {
323
+ handleError(new UsageError(err.message));
324
+ }
325
+ }
326
+ handleError(err);
327
+ }
328
+ });
104
329
  }