@switchbot/openapi-cli 2.4.0 → 2.5.1

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.
@@ -1,4 +1,4 @@
1
- import { printJson, isJsonMode, handleError } from '../utils/output.js';
1
+ import { printJson, isJsonMode, handleError, StructuredUsageError } from '../utils/output.js';
2
2
  import { resolveFormat, resolveFields, renderRows } from '../utils/format.js';
3
3
  import { fetchScenes, executeScene } from '../lib/scenes.js';
4
4
  export function registerScenesCommand(program) {
@@ -47,6 +47,15 @@ Example:
47
47
  `)
48
48
  .action(async (sceneId) => {
49
49
  try {
50
+ const sceneList = await fetchScenes();
51
+ const found = sceneList.find((s) => s.sceneId === sceneId);
52
+ if (!found) {
53
+ throw new StructuredUsageError(`scene not found: ${sceneId}`, {
54
+ error: 'scene_not_found',
55
+ sceneId,
56
+ candidates: sceneList.map((s) => ({ sceneId: s.sceneId, sceneName: s.sceneName })),
57
+ });
58
+ }
50
59
  await executeScene(sceneId);
51
60
  if (isJsonMode()) {
52
61
  printJson({ ok: true, sceneId });
@@ -59,4 +68,46 @@ Example:
59
68
  handleError(error);
60
69
  }
61
70
  });
71
+ // switchbot scenes describe <sceneId>
72
+ scenes
73
+ .command('describe')
74
+ .description('Show metadata for a scene by its ID (SwitchBot API v1.1 does not expose step detail)')
75
+ .argument('<sceneId>', 'Scene ID from "scenes list"')
76
+ .addHelpText('after', `
77
+ Note: SwitchBot API v1.1 does not return scene step detail. Only the scene name is available.
78
+
79
+ Example:
80
+ $ switchbot scenes describe T12345678
81
+ `)
82
+ .action(async (sceneId) => {
83
+ try {
84
+ const sceneList = await fetchScenes();
85
+ const found = sceneList.find((s) => s.sceneId === sceneId);
86
+ if (!found) {
87
+ throw new StructuredUsageError(`scene not found: ${sceneId}`, {
88
+ error: 'scene_not_found',
89
+ sceneId,
90
+ candidates: sceneList.map((s) => ({ sceneId: s.sceneId, sceneName: s.sceneName })),
91
+ });
92
+ }
93
+ const result = {
94
+ sceneId: found.sceneId,
95
+ sceneName: found.sceneName,
96
+ stepCount: null,
97
+ note: 'SwitchBot API v1.1 does not expose scene steps — displayed name only',
98
+ };
99
+ if (isJsonMode()) {
100
+ printJson(result);
101
+ }
102
+ else {
103
+ console.log(`sceneId: ${result.sceneId}`);
104
+ console.log(`sceneName: ${result.sceneName}`);
105
+ console.log(`stepCount: (not available)`);
106
+ console.log(`note: ${result.note}`);
107
+ }
108
+ }
109
+ catch (error) {
110
+ handleError(error);
111
+ }
112
+ });
62
113
  }
@@ -156,6 +156,12 @@ Examples:
156
156
  type: 'object',
157
157
  description: 'CLI-synthesized receipt-acknowledgment metadata. For IR devices, verifiable:false signals that no device-side confirmation is possible.',
158
158
  },
159
+ {
160
+ field: 'hints',
161
+ appliesTo: ['agent-bootstrap'],
162
+ type: 'string[]',
163
+ description: 'CLI-synthesized advisory messages for the calling agent. Always emitted; empty array ([]) means no hints to report — never null and not a disabled-field signal.',
164
+ },
159
165
  ];
160
166
  }
161
167
  printJson(payload);
@@ -61,6 +61,7 @@ export function registerWatchCommand(devices) {
61
61
  .option('--name <query>', 'Resolve one device by fuzzy name (combined with any positional IDs)', stringArg('--name'))
62
62
  .option('--interval <dur>', `Polling interval: "30s", "1m", "500ms", ... (default 30s, min ${MIN_INTERVAL_MS / 1000}s)`, durationArg('--interval'), '30s')
63
63
  .option('--max <n>', 'Stop after N ticks (default: run until Ctrl-C)', intArg('--max', { min: 1 }))
64
+ .option('--for <dur>', 'Stop after elapsed time (e.g. "5m", "30s"). Combines with --max: first limit wins.', durationArg('--for'))
64
65
  .option('--include-unchanged', 'Emit a tick even when no field changed')
65
66
  .addHelpText('after', `
66
67
  Each poll emits one JSON line per deviceId with the shape:
@@ -99,11 +100,15 @@ Examples:
99
100
  }
100
101
  maxTicks = Math.floor(n);
101
102
  }
103
+ const forMs = options.for ? parseDurationToMs(options.for) : null;
102
104
  const fields = getFields() ?? null;
103
105
  const ac = new AbortController();
104
106
  const onSig = () => ac.abort();
105
107
  process.on('SIGINT', onSig);
106
108
  process.on('SIGTERM', onSig);
109
+ const forTimer = forMs !== null && forMs > 0
110
+ ? setTimeout(() => ac.abort(), forMs)
111
+ : null;
107
112
  try {
108
113
  const prev = new Map();
109
114
  const client = createClient();
@@ -163,6 +168,8 @@ Examples:
163
168
  handleError(err);
164
169
  }
165
170
  finally {
171
+ if (forTimer)
172
+ clearTimeout(forTimer);
166
173
  process.off('SIGINT', onSig);
167
174
  process.off('SIGTERM', onSig);
168
175
  }
@@ -1,47 +1,78 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
+ import { createHash } from 'node:crypto';
4
5
  import { getConfigPath } from '../utils/flags.js';
6
+ import { getActiveProfile } from '../lib/request-context.js';
7
+ /**
8
+ * Returns the directory where cache files should be stored.
9
+ *
10
+ * - If a profile is active, scopes into a per-profile sub-directory so that
11
+ * rotating credentials or switching profiles never serves stale inventory
12
+ * from a prior session (Bug #37).
13
+ * - If no profile is active (unnamed / default), returns `baseDir` unchanged
14
+ * so the existing legacy path (~/.switchbot/devices.json) is preserved.
15
+ *
16
+ * Only called when `getConfigPath()` returns undefined — the --config-path
17
+ * override takes full precedence and bypasses this helper entirely.
18
+ */
19
+ function scopedCacheDir(baseDir) {
20
+ const profile = getActiveProfile();
21
+ if (profile === undefined)
22
+ return baseDir;
23
+ const hash = createHash('sha256').update(profile).digest('hex').slice(0, 8);
24
+ const dir = path.join(baseDir, 'cache', hash);
25
+ if (!fs.existsSync(dir))
26
+ fs.mkdirSync(dir, { recursive: true });
27
+ return dir;
28
+ }
5
29
  /** GC cutoff for status entries: evict anything older than this. */
6
30
  const DEFAULT_STATUS_GC_TTL_MS = 24 * 60 * 60 * 1000; // 24 h
7
31
  function cacheFilePath() {
8
32
  const override = getConfigPath();
9
33
  const dir = override
10
34
  ? path.dirname(path.resolve(override))
11
- : path.join(os.homedir(), '.switchbot');
35
+ : scopedCacheDir(path.join(os.homedir(), '.switchbot'));
12
36
  return path.join(dir, 'devices.json');
13
37
  }
14
- // In-memory hot-cache: undefined = not yet loaded, null = loaded but empty.
15
- let _listCache = undefined;
16
- let _statusCache = undefined;
38
+ // In-memory hot-cache keyed by active profile (or '__default__' for no profile).
39
+ // Using Maps instead of module-level singletons ensures that mcp serve, which
40
+ // rotates profiles per request via withRequestContext, never leaks inventory
41
+ // across profiles within the same process (Bug #37).
42
+ const _listCacheByProfile = new Map();
43
+ const _statusCacheByProfile = new Map();
44
+ function cacheKey() {
45
+ return getActiveProfile() ?? '__default__';
46
+ }
17
47
  /** Force the next loadCache() call to re-read from disk. Used in tests. */
18
48
  export function resetListCache() {
19
- _listCache = undefined;
49
+ _listCacheByProfile.clear();
20
50
  }
21
51
  /** Force the next loadStatusCache() call to re-read from disk. Used in tests. */
22
52
  export function resetStatusCache() {
23
- _statusCache = undefined;
53
+ _statusCacheByProfile.clear();
24
54
  }
25
55
  export function loadCache() {
26
- if (_listCache !== undefined)
27
- return _listCache;
56
+ const key = cacheKey();
57
+ if (_listCacheByProfile.has(key))
58
+ return _listCacheByProfile.get(key);
28
59
  const file = cacheFilePath();
29
60
  if (!fs.existsSync(file)) {
30
- _listCache = null;
61
+ _listCacheByProfile.set(key, null);
31
62
  return null;
32
63
  }
33
64
  try {
34
65
  const raw = fs.readFileSync(file, 'utf-8');
35
66
  const cache = JSON.parse(raw);
36
67
  if (!cache || typeof cache.devices !== 'object' || cache.devices === null) {
37
- _listCache = null;
68
+ _listCacheByProfile.set(key, null);
38
69
  return null;
39
70
  }
40
- _listCache = cache;
71
+ _listCacheByProfile.set(key, cache);
41
72
  return cache;
42
73
  }
43
74
  catch {
44
- _listCache = null;
75
+ _listCacheByProfile.set(key, null);
45
76
  return null;
46
77
  }
47
78
  }
@@ -109,7 +140,7 @@ export function updateCacheFromDeviceList(body) {
109
140
  if (!fs.existsSync(dir))
110
141
  fs.mkdirSync(dir, { recursive: true });
111
142
  fs.writeFileSync(file, JSON.stringify(cache, null, 2), { mode: 0o600 });
112
- _listCache = cache;
143
+ _listCacheByProfile.set(cacheKey(), cache);
113
144
  }
114
145
  catch {
115
146
  // Cache write failures must not break the command that triggered them.
@@ -119,7 +150,7 @@ export function clearCache() {
119
150
  const file = cacheFilePath();
120
151
  if (fs.existsSync(file))
121
152
  fs.unlinkSync(file);
122
- _listCache = null;
153
+ _listCacheByProfile.set(cacheKey(), null);
123
154
  }
124
155
  // ---- Device list freshness -------------------------------------------------
125
156
  /** Age of the on-disk list cache in ms, or null if there is no cache. */
@@ -143,34 +174,38 @@ function statusCacheFilePath() {
143
174
  const override = getConfigPath();
144
175
  const dir = override
145
176
  ? path.dirname(path.resolve(override))
146
- : path.join(os.homedir(), '.switchbot');
177
+ : scopedCacheDir(path.join(os.homedir(), '.switchbot'));
147
178
  return path.join(dir, 'status.json');
148
179
  }
149
180
  export function loadStatusCache() {
150
- if (_statusCache !== undefined)
151
- return _statusCache;
181
+ const key = cacheKey();
182
+ if (_statusCacheByProfile.has(key))
183
+ return _statusCacheByProfile.get(key);
152
184
  const file = statusCacheFilePath();
153
185
  if (!fs.existsSync(file)) {
154
- _statusCache = { entries: {} };
155
- return _statusCache;
186
+ const empty = { entries: {} };
187
+ _statusCacheByProfile.set(key, empty);
188
+ return empty;
156
189
  }
157
190
  try {
158
191
  const raw = fs.readFileSync(file, 'utf-8');
159
192
  const parsed = JSON.parse(raw);
160
193
  if (!parsed || typeof parsed.entries !== 'object' || parsed.entries === null) {
161
- _statusCache = { entries: {} };
162
- return _statusCache;
194
+ const empty = { entries: {} };
195
+ _statusCacheByProfile.set(key, empty);
196
+ return empty;
163
197
  }
164
- _statusCache = parsed;
198
+ _statusCacheByProfile.set(key, parsed);
165
199
  return parsed;
166
200
  }
167
201
  catch {
168
- _statusCache = { entries: {} };
169
- return _statusCache;
202
+ const empty = { entries: {} };
203
+ _statusCacheByProfile.set(key, empty);
204
+ return empty;
170
205
  }
171
206
  }
172
207
  function saveStatusCache(cache) {
173
- _statusCache = cache;
208
+ _statusCacheByProfile.set(cacheKey(), cache);
174
209
  try {
175
210
  const file = statusCacheFilePath();
176
211
  const dir = path.dirname(file);
@@ -220,7 +255,7 @@ export function clearStatusCache() {
220
255
  const file = statusCacheFilePath();
221
256
  if (fs.existsSync(file))
222
257
  fs.unlinkSync(file);
223
- _statusCache = { entries: {} };
258
+ _statusCacheByProfile.set(cacheKey(), { entries: {} });
224
259
  }
225
260
  export function describeCache(now = Date.now()) {
226
261
  const listFile = cacheFilePath();
@@ -0,0 +1,138 @@
1
+ import fs from 'node:fs';
2
+ import readline from 'node:readline';
3
+ import { jsonlFilesForDevice, parseDurationToMs, resolveRange } from './history-query.js';
4
+ export const ALL_AGG_FNS = ['count', 'min', 'max', 'avg', 'sum', 'p50', 'p95'];
5
+ export const DEFAULT_AGGS = ['count', 'avg'];
6
+ export const DEFAULT_SAMPLE_CAP = 10_000;
7
+ export const MAX_SAMPLE_CAP = 100_000;
8
+ export async function aggregateDeviceHistory(deviceId, opts) {
9
+ const { fromMs, toMs } = resolveRange(opts);
10
+ const aggs = (opts.aggs && opts.aggs.length > 0) ? opts.aggs : [...DEFAULT_AGGS];
11
+ const needQuantile = aggs.includes('p50') || aggs.includes('p95');
12
+ let bucketMs = null;
13
+ if (opts.bucket !== undefined) {
14
+ bucketMs = parseDurationToMs(opts.bucket);
15
+ if (bucketMs === null) {
16
+ throw new Error(`Invalid --bucket "${opts.bucket}". Expected e.g. "15m", "1h", "1d".`);
17
+ }
18
+ }
19
+ const sampleCap = Math.max(1, Math.min(opts.maxBucketSamples ?? DEFAULT_SAMPLE_CAP, MAX_SAMPLE_CAP));
20
+ let partial = false;
21
+ const notes = [];
22
+ // bucketKey (epoch ms; 0 when no --bucket) → metric name → Acc
23
+ const buckets = new Map();
24
+ for (const file of jsonlFilesForDevice(deviceId)) {
25
+ try {
26
+ const st = fs.statSync(file);
27
+ if (st.mtimeMs < fromMs)
28
+ continue;
29
+ }
30
+ catch {
31
+ continue;
32
+ }
33
+ const stream = fs.createReadStream(file, { encoding: 'utf-8' });
34
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
35
+ for await (const line of rl) {
36
+ if (!line)
37
+ continue;
38
+ let rec;
39
+ try {
40
+ rec = JSON.parse(line);
41
+ }
42
+ catch {
43
+ continue;
44
+ }
45
+ const tMs = Date.parse(rec.t);
46
+ if (!Number.isFinite(tMs) || tMs < fromMs || tMs > toMs)
47
+ continue;
48
+ const key = bucketMs !== null ? Math.floor(tMs / bucketMs) * bucketMs : 0;
49
+ let bkt = buckets.get(key);
50
+ if (!bkt) {
51
+ bkt = new Map();
52
+ buckets.set(key, bkt);
53
+ }
54
+ for (const metric of opts.metrics) {
55
+ const v = rec.payload?.[metric];
56
+ if (typeof v !== 'number' || !Number.isFinite(v))
57
+ continue;
58
+ let acc = bkt.get(metric);
59
+ if (!acc) {
60
+ acc = {
61
+ min: v,
62
+ max: v,
63
+ sum: 0,
64
+ count: 0,
65
+ samples: needQuantile ? [] : null,
66
+ sampleCapHit: false,
67
+ };
68
+ bkt.set(metric, acc);
69
+ }
70
+ acc.min = Math.min(acc.min, v);
71
+ acc.max = Math.max(acc.max, v);
72
+ acc.sum += v;
73
+ acc.count += 1;
74
+ if (acc.samples) {
75
+ if (acc.samples.length < sampleCap) {
76
+ acc.samples.push(v);
77
+ }
78
+ else if (!acc.sampleCapHit) {
79
+ acc.sampleCapHit = true;
80
+ partial = true;
81
+ notes.push(`bucket ${new Date(key).toISOString()} metric ${metric}: sample cap ${sampleCap} reached, quantiles approximate`);
82
+ }
83
+ }
84
+ }
85
+ }
86
+ }
87
+ return finalize(deviceId, opts, aggs, buckets, partial, notes, fromMs, toMs);
88
+ }
89
+ function finalize(deviceId, opts, aggs, buckets, partial, notes, fromMs, toMs) {
90
+ const fromIso = Number.isFinite(fromMs) ? new Date(fromMs).toISOString() : new Date(0).toISOString();
91
+ const toIso = Number.isFinite(toMs) ? new Date(toMs).toISOString() : new Date(Date.now()).toISOString();
92
+ const keys = [...buckets.keys()].sort((a, b) => a - b);
93
+ const outBuckets = [];
94
+ for (const key of keys) {
95
+ const perMetric = buckets.get(key);
96
+ const metricsOut = {};
97
+ for (const [metric, acc] of perMetric.entries()) {
98
+ if (acc.count === 0)
99
+ continue;
100
+ const r = {};
101
+ if (aggs.includes('count'))
102
+ r.count = acc.count;
103
+ if (aggs.includes('min'))
104
+ r.min = acc.min;
105
+ if (aggs.includes('max'))
106
+ r.max = acc.max;
107
+ if (aggs.includes('avg'))
108
+ r.avg = acc.sum / acc.count;
109
+ if (aggs.includes('sum'))
110
+ r.sum = acc.sum;
111
+ if ((aggs.includes('p50') || aggs.includes('p95')) && acc.samples) {
112
+ const sorted = [...acc.samples].sort((a, b) => a - b);
113
+ if (aggs.includes('p50'))
114
+ r.p50 = sorted[Math.floor(0.5 * (sorted.length - 1))];
115
+ if (aggs.includes('p95'))
116
+ r.p95 = sorted[Math.floor(0.95 * (sorted.length - 1))];
117
+ }
118
+ metricsOut[metric] = r;
119
+ }
120
+ if (Object.keys(metricsOut).length === 0)
121
+ continue;
122
+ outBuckets.push({
123
+ t: new Date(key).toISOString(),
124
+ metrics: metricsOut,
125
+ });
126
+ }
127
+ return {
128
+ deviceId,
129
+ bucket: opts.bucket,
130
+ from: fromIso,
131
+ to: toIso,
132
+ metrics: [...opts.metrics],
133
+ aggs: [...aggs],
134
+ buckets: outBuckets,
135
+ partial,
136
+ notes,
137
+ };
138
+ }
@@ -24,7 +24,7 @@ export function parseInstantToMs(spec) {
24
24
  const ms = Date.parse(spec);
25
25
  return Number.isFinite(ms) ? ms : null;
26
26
  }
27
- function resolveRange(opts) {
27
+ export function resolveRange(opts) {
28
28
  let fromMs = Number.NEGATIVE_INFINITY;
29
29
  let toMs = Number.POSITIVE_INFINITY;
30
30
  if (opts.since && (opts.from || opts.to)) {
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command, CommanderError, InvalidArgumentError } from 'commander';
3
3
  import { createRequire } from 'node:module';
4
+ import chalk from 'chalk';
4
5
  import { intArg, stringArg, enumArg } from './utils/arg-parsers.js';
5
6
  import { parseDurationToMs } from './utils/flags.js';
6
7
  import { registerConfigCommand } from './commands/config.js';
@@ -21,6 +22,11 @@ import { registerCapabilitiesCommand } from './commands/capabilities.js';
21
22
  import { registerAgentBootstrapCommand } from './commands/agent-bootstrap.js';
22
23
  const require = createRequire(import.meta.url);
23
24
  const { version: pkgVersion } = require('../package.json');
25
+ // Early initialization: check for --no-color flag or NO_COLOR env var and disable chalk.
26
+ // This must happen before any commands run so all chalk output is affected.
27
+ if (process.argv.includes('--no-color') || Boolean(process.env.NO_COLOR)) {
28
+ chalk.level = 0;
29
+ }
24
30
  const program = new Command();
25
31
  // Top-level subcommand names. Used by stringArg to produce clearer errors when
26
32
  // a value is omitted and the next argv token turns out to be a subcommand name.
@@ -44,6 +50,7 @@ program
44
50
  .name('switchbot')
45
51
  .description('Command-line tool for SwitchBot API v1.1')
46
52
  .version(pkgVersion)
53
+ .option('--no-color', 'Disable ANSI colors in output')
47
54
  .option('--json', 'Output raw JSON response (disables tables; useful for pipes/scripts)')
48
55
  .option('--format <type>', 'Output format: table (default), json, jsonl, tsv, yaml, id, markdown', enumArg('--format', ['table', 'json', 'jsonl', 'tsv', 'yaml', 'id', 'markdown']))
49
56
  .option('--fields <csv>', 'Comma-separated list of columns to include (e.g. --fields=id,name,type)', stringArg('--fields', { disallow: TOP_LEVEL_COMMANDS }))
@@ -31,19 +31,30 @@ export class DeviceHistoryStore {
31
31
  existing.history = [entry, ...existing.history].slice(0, MAX_HISTORY);
32
32
  fs.writeFileSync(file, JSON.stringify(existing, null, 2), { mode: 0o600 });
33
33
  // 2. Append-only JSONL for range queries.
34
- this.appendJsonl(deviceId, entry);
34
+ this.writeJsonl(deviceId, entry);
35
35
  }
36
36
  catch {
37
37
  // best-effort — history loss is non-fatal
38
38
  }
39
39
  }
40
- appendJsonl(deviceId, entry) {
40
+ /** Append a mqtt control event (no deviceId) to the dedicated __control.jsonl file. */
41
+ recordControl(event) {
41
42
  try {
42
- const jsonlPath = path.join(this.dir, `${deviceId}.jsonl`);
43
- const line = JSON.stringify(entry) + '\n';
43
+ if (!fs.existsSync(this.dir))
44
+ fs.mkdirSync(this.dir, { recursive: true });
45
+ this.writeJsonl('__control', event);
46
+ }
47
+ catch {
48
+ // best-effort — never block the event stream
49
+ }
50
+ }
51
+ writeJsonl(fileKey, record) {
52
+ try {
53
+ const jsonlPath = path.join(this.dir, `${fileKey}.jsonl`);
54
+ const line = JSON.stringify(record) + '\n';
44
55
  const lineBytes = Buffer.byteLength(line, 'utf-8');
45
56
  // Seed size counter from disk on first touch (avoids drift across restarts).
46
- let size = this.jsonlSizes.get(deviceId);
57
+ let size = this.jsonlSizes.get(fileKey);
47
58
  if (size === undefined) {
48
59
  try {
49
60
  size = fs.existsSync(jsonlPath) ? fs.statSync(jsonlPath).size : 0;
@@ -53,18 +64,18 @@ export class DeviceHistoryStore {
53
64
  }
54
65
  }
55
66
  if (size + lineBytes > JSONL_ROTATE_BYTES) {
56
- this.rotateJsonl(deviceId);
67
+ this.rotateJsonl(fileKey);
57
68
  size = 0;
58
69
  }
59
70
  fs.appendFileSync(jsonlPath, line, { mode: 0o600 });
60
- this.jsonlSizes.set(deviceId, size + lineBytes);
71
+ this.jsonlSizes.set(fileKey, size + lineBytes);
61
72
  }
62
73
  catch {
63
74
  // best-effort
64
75
  }
65
76
  }
66
- rotateJsonl(deviceId) {
67
- const base = path.join(this.dir, `${deviceId}.jsonl`);
77
+ rotateJsonl(fileKey) {
78
+ const base = path.join(this.dir, `${fileKey}.jsonl`);
68
79
  // .jsonl.3 is dropped; .2 → .3, .1 → .2, current → .1
69
80
  try {
70
81
  const oldest = `${base}.${JSONL_KEEP_ROTATIONS}`;
@@ -37,7 +37,8 @@ export function durationArg(flagName) {
37
37
  }
38
38
  const ms = parseDurationToMs(value);
39
39
  if (ms === null) {
40
- throw new InvalidArgumentError(`${flagName} must look like "30s", "1m", "500ms", "1h" (got "${value}")`);
40
+ throw new InvalidArgumentError(`${flagName} must look like "30s", "1m", "500ms", "1h", "7d", "2w" ` +
41
+ `(supported units: ms, s, m, h, d, w — got "${value}")`);
41
42
  }
42
43
  return value;
43
44
  };
@@ -55,7 +55,7 @@ export function verifyAudit(file) {
55
55
  problems: [],
56
56
  };
57
57
  if (!fs.existsSync(file)) {
58
- report.problems.push({ line: 0, reason: 'audit log file does not exist' });
58
+ report.fileMissing = true;
59
59
  return report;
60
60
  }
61
61
  const raw = fs.readFileSync(file, 'utf-8');