@switchbot/openapi-cli 2.2.1 → 2.4.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.
@@ -1,11 +1,46 @@
1
1
  /**
2
2
  * In-memory LRU cache for idempotent request deduplication.
3
3
  * Caches the outcome of a keyed operation for 60 seconds;
4
- * duplicate keys within the window return the cached result without re-executing.
4
+ * duplicate keys within the window return the cached result (with a
5
+ * `replayed: true` marker). Duplicate keys within the window for a DIFFERENT
6
+ * (command, parameter) shape raise {@link IdempotencyConflictError}.
7
+ *
8
+ * Keys are stored in-memory as a SHA-256 fingerprint of the user-provided
9
+ * key — the original string never touches the Map keys, so a later heap dump
10
+ * or inadvertent log capture does not leak the raw token.
11
+ *
5
12
  * Process-local only — not shared across replicas.
6
13
  */
14
+ import crypto from 'node:crypto';
7
15
  const DEFAULT_TTL_MS = 60000; // 60 seconds
8
16
  const DEFAULT_MAX_ENTRIES = 1024;
17
+ export class IdempotencyConflictError extends Error {
18
+ key;
19
+ existingShape;
20
+ newShape;
21
+ constructor(message, key, existingShape, newShape) {
22
+ super(message);
23
+ this.key = key;
24
+ this.existingShape = existingShape;
25
+ this.newShape = newShape;
26
+ this.name = 'IdempotencyConflictError';
27
+ }
28
+ }
29
+ function hashKey(key) {
30
+ return crypto.createHash('sha256').update(key).digest('hex');
31
+ }
32
+ function shapeSignature(command, parameter) {
33
+ // Canonical-ish JSON — stable enough for object equality with no nested sort
34
+ // (callers can pass primitives or small objects).
35
+ let parm;
36
+ try {
37
+ parm = JSON.stringify(parameter ?? 'default');
38
+ }
39
+ catch {
40
+ parm = String(parameter);
41
+ }
42
+ return `${command}::${parm}`;
43
+ }
9
44
  export class IdempotencyCache {
10
45
  cache = new Map();
11
46
  ttlMs;
@@ -17,56 +52,55 @@ export class IdempotencyCache {
17
52
  /**
18
53
  * Execute fn if the key is not cached, or return the cached result if it is.
19
54
  * On new execution, caches the result for ttlMs.
55
+ *
56
+ * When `shape` is provided, a cached hit is validated against the original
57
+ * (command, parameter) fingerprint; mismatched shape raises
58
+ * {@link IdempotencyConflictError}.
59
+ *
60
+ * Returns a tuple-esque object with `replayed: true` when the cached
61
+ * result is served. The `result` field is the original cached value.
20
62
  */
21
- async run(key, fn) {
22
- // No key = always execute (not cached)
63
+ async run(key, fn, shape) {
23
64
  if (!key) {
24
- return fn();
65
+ const result = await fn();
66
+ return { result, replayed: false };
25
67
  }
68
+ const hashed = hashKey(key);
26
69
  const now = Date.now();
27
- const cached = this.cache.get(key);
28
- // Cached and not expired
70
+ const cached = this.cache.get(hashed);
71
+ const currentShape = shape ? shapeSignature(shape.command, shape.parameter) : '*';
29
72
  if (cached && cached.expiresAt > now) {
30
- return cached.result;
73
+ if (shape && cached.shape !== '*' && cached.shape !== currentShape) {
74
+ throw new IdempotencyConflictError(`idempotency_conflict: key was first used for ${cached.shape.replace('::', ' ')}; refusing new shape ${currentShape.replace('::', ' ')}`, '<redacted>', cached.shape, currentShape);
75
+ }
76
+ return { result: cached.result, replayed: true };
31
77
  }
32
- // Expired or uncached: execute
33
78
  const result = await fn();
34
- // Prune if over capacity (LRU: remove oldest entries)
35
79
  if (this.cache.size >= this.maxEntries) {
36
- const toRemove = Math.ceil(this.maxEntries * 0.1); // Remove 10%
80
+ const toRemove = Math.ceil(this.maxEntries * 0.1);
37
81
  let removed = 0;
38
82
  for (const [k, v] of this.cache.entries()) {
39
83
  if (removed >= toRemove)
40
84
  break;
41
- // Remove expired entries first, then oldest
42
85
  if (v.expiresAt <= now) {
43
86
  this.cache.delete(k);
44
87
  removed++;
45
88
  }
46
89
  }
47
- // If still over capacity, remove oldest insertion (Map is insertion-ordered)
48
90
  if (this.cache.size >= this.maxEntries) {
49
91
  const firstKey = this.cache.keys().next().value;
50
92
  if (firstKey)
51
93
  this.cache.delete(firstKey);
52
94
  }
53
95
  }
54
- // Cache the result
55
- this.cache.set(key, { result, expiresAt: now + this.ttlMs });
56
- return result;
96
+ this.cache.set(hashed, { result, expiresAt: now + this.ttlMs, shape: currentShape });
97
+ return { result, replayed: false };
57
98
  }
58
- /**
59
- * Clear all cached entries (mainly for testing).
60
- */
61
99
  clear() {
62
100
  this.cache.clear();
63
101
  }
64
- /**
65
- * Return the number of cached entries.
66
- */
67
102
  size() {
68
103
  return this.cache.size;
69
104
  }
70
105
  }
71
- // Global shared instance for the process
72
106
  export const idempotencyCache = new IdempotencyCache();
@@ -2,31 +2,91 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  const MAX_HISTORY = 100;
5
+ const JSONL_ROTATE_BYTES = 50 * 1024 * 1024; // 50 MB
6
+ const JSONL_KEEP_ROTATIONS = 3; // .1 .2 .3
5
7
  function historyDir() {
6
8
  return path.join(os.homedir(), '.switchbot', 'device-history');
7
9
  }
8
10
  export class DeviceHistoryStore {
9
- dir;
10
- constructor() {
11
- this.dir = historyDir();
11
+ // In-memory size counter so we don't stat() on every append.
12
+ jsonlSizes = new Map();
13
+ /** Reset the in-memory size counter. Tests use this between runs. */
14
+ resetSizes() {
15
+ this.jsonlSizes.clear();
16
+ }
17
+ get dir() {
18
+ return historyDir();
12
19
  }
13
20
  record(deviceId, topic, deviceType, payload, t) {
21
+ const entry = { t: t ?? new Date().toISOString(), topic, deviceType, payload };
14
22
  try {
15
23
  if (!fs.existsSync(this.dir))
16
24
  fs.mkdirSync(this.dir, { recursive: true });
25
+ // 1. Ring-buffer JSON (back-compat with existing consumers).
17
26
  const file = path.join(this.dir, `${deviceId}.json`);
18
27
  const existing = fs.existsSync(file)
19
28
  ? JSON.parse(fs.readFileSync(file, 'utf-8'))
20
29
  : { latest: null, history: [] };
21
- const entry = { t: t ?? new Date().toISOString(), topic, deviceType, payload };
22
30
  existing.latest = entry;
23
31
  existing.history = [entry, ...existing.history].slice(0, MAX_HISTORY);
24
32
  fs.writeFileSync(file, JSON.stringify(existing, null, 2), { mode: 0o600 });
33
+ // 2. Append-only JSONL for range queries.
34
+ this.appendJsonl(deviceId, entry);
25
35
  }
26
36
  catch {
27
37
  // best-effort — history loss is non-fatal
28
38
  }
29
39
  }
40
+ appendJsonl(deviceId, entry) {
41
+ try {
42
+ const jsonlPath = path.join(this.dir, `${deviceId}.jsonl`);
43
+ const line = JSON.stringify(entry) + '\n';
44
+ const lineBytes = Buffer.byteLength(line, 'utf-8');
45
+ // Seed size counter from disk on first touch (avoids drift across restarts).
46
+ let size = this.jsonlSizes.get(deviceId);
47
+ if (size === undefined) {
48
+ try {
49
+ size = fs.existsSync(jsonlPath) ? fs.statSync(jsonlPath).size : 0;
50
+ }
51
+ catch {
52
+ size = 0;
53
+ }
54
+ }
55
+ if (size + lineBytes > JSONL_ROTATE_BYTES) {
56
+ this.rotateJsonl(deviceId);
57
+ size = 0;
58
+ }
59
+ fs.appendFileSync(jsonlPath, line, { mode: 0o600 });
60
+ this.jsonlSizes.set(deviceId, size + lineBytes);
61
+ }
62
+ catch {
63
+ // best-effort
64
+ }
65
+ }
66
+ rotateJsonl(deviceId) {
67
+ const base = path.join(this.dir, `${deviceId}.jsonl`);
68
+ // .jsonl.3 is dropped; .2 → .3, .1 → .2, current → .1
69
+ try {
70
+ const oldest = `${base}.${JSONL_KEEP_ROTATIONS}`;
71
+ if (fs.existsSync(oldest))
72
+ fs.rmSync(oldest);
73
+ }
74
+ catch { /* swallow */ }
75
+ for (let i = JSONL_KEEP_ROTATIONS - 1; i >= 1; i--) {
76
+ const from = `${base}.${i}`;
77
+ const to = `${base}.${i + 1}`;
78
+ try {
79
+ if (fs.existsSync(from))
80
+ fs.renameSync(from, to);
81
+ }
82
+ catch { /* swallow */ }
83
+ }
84
+ try {
85
+ if (fs.existsSync(base))
86
+ fs.renameSync(base, `${base}.1`);
87
+ }
88
+ catch { /* swallow */ }
89
+ }
30
90
  getLatest(deviceId) {
31
91
  try {
32
92
  const file = path.join(this.dir, `${deviceId}.json`);
@@ -54,13 +114,21 @@ export class DeviceHistoryStore {
54
114
  try {
55
115
  if (!fs.existsSync(this.dir))
56
116
  return [];
57
- return fs.readdirSync(this.dir)
58
- .filter((f) => f.endsWith('.json'))
59
- .map((f) => f.slice(0, -5));
117
+ const seen = new Set();
118
+ for (const f of fs.readdirSync(this.dir)) {
119
+ if (f.endsWith('.json'))
120
+ seen.add(f.slice(0, -5));
121
+ else if (f.endsWith('.jsonl'))
122
+ seen.add(f.slice(0, -6));
123
+ }
124
+ return Array.from(seen);
60
125
  }
61
126
  catch {
62
127
  return [];
63
128
  }
64
129
  }
130
+ getHistoryDir() {
131
+ return this.dir;
132
+ }
65
133
  }
66
134
  export const deviceHistoryStore = new DeviceHistoryStore();
@@ -9,7 +9,10 @@ import { parseDurationToMs } from './flags.js';
9
9
  */
10
10
  export function intArg(flagName, opts) {
11
11
  return (value) => {
12
- if (value.startsWith('-')) {
12
+ // Flag-like tokens (`--something`, `-x`) are rejected up-front.
13
+ // Pure negative integers (`-1`, `-42`) fall through to min/max so the
14
+ // error classifies as a range error rather than "requires a numeric value".
15
+ if (value.startsWith('-') && !/^-\d+$/.test(value)) {
13
16
  throw new InvalidArgumentError(`${flagName} requires a numeric value, got "${value}". ` +
14
17
  `Did you forget a value? Use ${flagName}=<n> if the value really starts with "-".`);
15
18
  }
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { getAuditLog } from './flags.js';
4
+ /** Bump when breaking changes to the audit line shape land. */
5
+ export const AUDIT_VERSION = 1;
4
6
  function resolveAuditPath() {
5
7
  const flag = getAuditLog();
6
8
  if (flag === null)
@@ -16,7 +18,8 @@ export function writeAudit(entry) {
16
18
  if (!fs.existsSync(dir)) {
17
19
  fs.mkdirSync(dir, { recursive: true });
18
20
  }
19
- fs.appendFileSync(file, JSON.stringify(entry) + '\n');
21
+ const stamped = { auditVersion: AUDIT_VERSION, ...entry };
22
+ fs.appendFileSync(file, JSON.stringify(stamped) + '\n');
20
23
  }
21
24
  catch {
22
25
  // Best-effort — never let audit failures break the actual command.
@@ -40,3 +43,65 @@ export function readAudit(file) {
40
43
  }
41
44
  return out;
42
45
  }
46
+ export function verifyAudit(file) {
47
+ const report = {
48
+ file,
49
+ totalLines: 0,
50
+ parsedLines: 0,
51
+ skippedBlankLines: 0,
52
+ malformedLines: 0,
53
+ unversionedEntries: 0,
54
+ versionCounts: {},
55
+ problems: [],
56
+ };
57
+ if (!fs.existsSync(file)) {
58
+ report.problems.push({ line: 0, reason: 'audit log file does not exist' });
59
+ return report;
60
+ }
61
+ const raw = fs.readFileSync(file, 'utf-8');
62
+ const lines = raw.split(/\r?\n/);
63
+ let minT;
64
+ let maxT;
65
+ for (let i = 0; i < lines.length; i++) {
66
+ const trimmed = lines[i].trim();
67
+ if (!trimmed) {
68
+ report.skippedBlankLines++;
69
+ continue;
70
+ }
71
+ report.totalLines++;
72
+ let entry = null;
73
+ try {
74
+ entry = JSON.parse(trimmed);
75
+ }
76
+ catch {
77
+ report.malformedLines++;
78
+ report.problems.push({
79
+ line: i + 1,
80
+ reason: 'JSON parse failed',
81
+ preview: trimmed.slice(0, 80),
82
+ });
83
+ continue;
84
+ }
85
+ report.parsedLines++;
86
+ const v = entry.auditVersion;
87
+ if (v === undefined) {
88
+ report.unversionedEntries++;
89
+ report.versionCounts['unversioned'] = (report.versionCounts['unversioned'] ?? 0) + 1;
90
+ }
91
+ else {
92
+ const key = String(v);
93
+ report.versionCounts[key] = (report.versionCounts[key] ?? 0) + 1;
94
+ }
95
+ if (entry.t) {
96
+ if (!minT || entry.t < minT)
97
+ minT = entry.t;
98
+ if (!maxT || entry.t > maxT)
99
+ maxT = entry.t;
100
+ }
101
+ }
102
+ if (minT)
103
+ report.earliest = minT;
104
+ if (maxT)
105
+ report.latest = maxT;
106
+ return report;
107
+ }
@@ -14,6 +14,14 @@ function getFlagValue(...flagNames) {
14
14
  export function isVerbose() {
15
15
  return process.argv.includes('--verbose') || process.argv.includes('-v');
16
16
  }
17
+ /**
18
+ * Opt-in: disable header redaction in verbose traces. Adds a big warning on
19
+ * stderr. Use only when actively debugging an auth issue — never in logs or
20
+ * CI output.
21
+ */
22
+ export function isTraceUnsafe() {
23
+ return process.argv.includes('--trace-unsafe');
24
+ }
17
25
  export function isDryRun() {
18
26
  return process.argv.includes('--dry-run');
19
27
  }
@@ -111,6 +119,16 @@ export function getFields() {
111
119
  return undefined;
112
120
  return v.split(',').map((f) => f.trim()).filter(Boolean);
113
121
  }
122
+ export function getTableStyle() {
123
+ const v = getFlagValue('--table-style');
124
+ if (v === 'unicode' || v === 'ascii' || v === 'simple' || v === 'markdown')
125
+ return v;
126
+ if (getFormat() === 'markdown')
127
+ return 'markdown';
128
+ // TTY → pretty unicode borders. Non-TTY (pipe/redirect) → ascii to avoid
129
+ // mojibake in consumer logs.
130
+ return process.stdout.isTTY ? 'unicode' : 'ascii';
131
+ }
114
132
  export function getCacheMode() {
115
133
  if (process.argv.includes('--no-cache')) {
116
134
  return { listTtlMs: 0, statusTtlMs: 0 };
@@ -2,7 +2,14 @@ import { loadCache } from '../devices/cache.js';
2
2
  import { loadDeviceMeta } from '../devices/device-meta.js';
3
3
  import { levenshtein, normalizeDeviceName } from './string.js';
4
4
  import { UsageError, StructuredUsageError } from './output.js';
5
- function resolveDeviceByName(query) {
5
+ const ALL_STRATEGIES = [
6
+ 'exact', 'prefix', 'substring', 'fuzzy', 'first', 'require-unique',
7
+ ];
8
+ export function isValidStrategy(s) {
9
+ return ALL_STRATEGIES.includes(s);
10
+ }
11
+ function resolveDeviceByName(query, opts = {}) {
12
+ const strategy = opts.strategy ?? 'fuzzy';
6
13
  const cache = loadCache();
7
14
  if (!cache || Object.keys(cache.devices).length === 0) {
8
15
  return { ok: false, ambiguous: false };
@@ -10,52 +17,71 @@ function resolveDeviceByName(query) {
10
17
  const meta = loadDeviceMeta();
11
18
  const q = normalizeDeviceName(query);
12
19
  const threshold = Math.min(3, Math.floor(q.length * 0.3));
20
+ const typeFilter = opts.type ? opts.type.toLowerCase() : null;
21
+ const roomFilter = opts.room ? opts.room.toLowerCase() : null;
13
22
  const candidates = [];
14
23
  for (const [deviceId, device] of Object.entries(cache.devices)) {
15
- // alias exact match (highest priority)
16
- const alias = meta.devices[deviceId]?.alias;
17
- if (alias && normalizeDeviceName(alias) === q) {
18
- return { ok: true, deviceId };
24
+ // narrow filters first
25
+ if (opts.category && device.category !== opts.category)
26
+ continue;
27
+ if (typeFilter && device.type.toLowerCase() !== typeFilter)
28
+ continue;
29
+ if (roomFilter) {
30
+ const rn = (device.roomName ?? '').toLowerCase();
31
+ if (rn !== roomFilter && !rn.includes(roomFilter))
32
+ continue;
19
33
  }
34
+ const alias = meta.devices[deviceId]?.alias;
20
35
  const rawName = normalizeDeviceName(device.name);
21
- // exact match
22
- if (rawName === q)
36
+ const normAlias = alias ? normalizeDeviceName(alias) : null;
37
+ // exact alias/name wins regardless of strategy
38
+ if ((normAlias && normAlias === q) || rawName === q) {
23
39
  return { ok: true, deviceId };
24
- // alias substring/fuzzy (only query ⊂ alias, not the reverse)
25
- if (alias) {
26
- const normAlias = normalizeDeviceName(alias);
27
- if (normAlias.includes(q)) {
40
+ }
41
+ if (strategy === 'exact')
42
+ continue;
43
+ if (strategy === 'prefix') {
44
+ if ((normAlias && normAlias.startsWith(q)) || rawName.startsWith(q)) {
28
45
  candidates.push({ deviceId, name: device.name, score: 1 });
29
- continue;
30
- }
31
- const dist = levenshtein(normAlias, q);
32
- if (dist <= threshold) {
33
- candidates.push({ deviceId, name: device.name, score: dist + 1 });
34
- continue;
35
46
  }
47
+ continue;
36
48
  }
37
- // name substring (only query name, not the reverse avoids long queries
38
- // collapsing onto short device names)
39
- if (rawName.includes(q)) {
49
+ // substring + fuzzy + require-unique + first all share substring match
50
+ if ((normAlias && normAlias.includes(q)) || rawName.includes(q)) {
40
51
  candidates.push({ deviceId, name: device.name, score: 1 });
41
52
  continue;
42
53
  }
43
- // levenshtein
44
- const dist = levenshtein(rawName, q);
45
- if (dist <= threshold) {
46
- candidates.push({ deviceId, name: device.name, score: dist + 1 });
54
+ if (strategy === 'substring')
55
+ continue;
56
+ // fuzzy / require-unique / first → also levenshtein
57
+ if (strategy === 'fuzzy' || strategy === 'require-unique' || strategy === 'first') {
58
+ const distName = levenshtein(rawName, q);
59
+ const distAlias = normAlias ? levenshtein(normAlias, q) : Number.POSITIVE_INFINITY;
60
+ const dist = Math.min(distName, distAlias);
61
+ if (dist <= threshold) {
62
+ candidates.push({ deviceId, name: device.name, score: dist + 1 });
63
+ }
47
64
  }
48
65
  }
49
66
  if (candidates.length === 0)
50
67
  return { ok: false, ambiguous: false };
51
68
  candidates.sort((a, b) => a.score - b.score);
69
+ if (strategy === 'first') {
70
+ return { ok: true, deviceId: candidates[0].deviceId };
71
+ }
72
+ if (strategy === 'require-unique') {
73
+ if (candidates.length === 1)
74
+ return { ok: true, deviceId: candidates[0].deviceId };
75
+ return { ok: false, ambiguous: true, candidates: candidates.slice(0, 4) };
76
+ }
77
+ // fuzzy / substring / prefix: collapse cluster of near-ties
52
78
  const best = candidates[0].score;
53
79
  const top = candidates.filter((c) => c.score <= best + 1);
54
80
  if (top.length === 1)
55
81
  return { ok: true, deviceId: top[0].deviceId };
56
82
  return { ok: false, ambiguous: true, candidates: top.slice(0, 4) };
57
83
  }
58
- export function resolveDeviceId(deviceId, nameQuery) {
84
+ export function resolveDeviceId(deviceId, nameQuery, opts = {}) {
59
85
  if (deviceId && nameQuery) {
60
86
  throw new UsageError('Provide either a deviceId argument or --name, not both.');
61
87
  }
@@ -64,15 +90,37 @@ export function resolveDeviceId(deviceId, nameQuery) {
64
90
  if (!nameQuery) {
65
91
  throw new UsageError('A deviceId argument or --name flag is required.');
66
92
  }
93
+ if (opts.strategy && !isValidStrategy(opts.strategy)) {
94
+ throw new UsageError(`--name-strategy must be one of: ${ALL_STRATEGIES.join(', ')} (got "${opts.strategy}")`);
95
+ }
67
96
  const cache = loadCache();
68
97
  if (!cache) {
69
98
  throw new UsageError(`--name requires the device cache. Run 'switchbot devices list' first to populate it.`);
70
99
  }
71
- const result = resolveDeviceByName(nameQuery);
100
+ const result = resolveDeviceByName(nameQuery, opts);
72
101
  if (result.ok)
73
102
  return result.deviceId;
74
103
  if (result.ambiguous) {
75
- throw new StructuredUsageError(`"${nameQuery}" is ambiguous — be more specific or use the deviceId directly.`, { candidates: result.candidates.map((c) => ({ deviceId: c.deviceId, name: c.name })) });
104
+ const candidates = result.candidates.map((c) => ({ deviceId: c.deviceId, name: c.name }));
105
+ const narrow = [];
106
+ if (!opts.type)
107
+ narrow.push('--type');
108
+ if (!opts.category)
109
+ narrow.push('--category');
110
+ if (!opts.room)
111
+ narrow.push('--room');
112
+ const hint = narrow.length > 0
113
+ ? `Narrow with ${narrow.join(' / ')} or use the deviceId directly, or pass --name-strategy first to pick the best match.`
114
+ : `Use the deviceId directly, or pass --name-strategy first to pick the best match.`;
115
+ throw new StructuredUsageError(`"${nameQuery}" is ambiguous — ${candidates.length} devices match.`, {
116
+ error: 'ambiguous_name_match',
117
+ query: nameQuery,
118
+ candidates,
119
+ hint,
120
+ });
76
121
  }
77
- throw new UsageError(`No device matches "${nameQuery}". Run 'switchbot devices list' to see device names.`);
122
+ const noMatchNarrow = opts.type || opts.category || opts.room
123
+ ? ' after applying --type/--category/--room filters'
124
+ : '';
125
+ throw new UsageError(`No device matches "${nameQuery}"${noMatchNarrow}. Run 'switchbot devices list' to see device names.`);
78
126
  }