@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.
- package/dist/api/client.js +33 -1
- package/dist/commands/agent-bootstrap.js +128 -0
- package/dist/commands/batch.js +109 -15
- package/dist/commands/cache.js +1 -0
- package/dist/commands/capabilities.js +203 -62
- package/dist/commands/config.js +132 -9
- package/dist/commands/devices.js +43 -5
- package/dist/commands/doctor.js +105 -14
- package/dist/commands/events.js +60 -4
- package/dist/commands/history.js +227 -2
- package/dist/commands/mcp.js +203 -35
- package/dist/commands/plan.js +6 -1
- package/dist/commands/quota.js +4 -2
- package/dist/commands/scenes.js +43 -1
- package/dist/commands/schema.js +101 -5
- package/dist/config.js +71 -2
- package/dist/devices/history-agg.js +138 -0
- package/dist/devices/history-query.js +181 -0
- package/dist/index.js +19 -2
- package/dist/lib/devices.js +8 -1
- package/dist/lib/idempotency.js +56 -22
- package/dist/mcp/device-history.js +86 -7
- package/dist/utils/audit.js +66 -1
- package/dist/utils/flags.js +18 -0
- package/dist/utils/format.js +9 -1
- package/dist/utils/name-resolver.js +85 -29
- package/dist/utils/output.js +116 -20
- package/dist/utils/quota.js +14 -0
- package/dist/utils/redact.js +68 -0
- package/dist/version.js +4 -0
- package/package.json +1 -1
|
@@ -2,31 +2,102 @@ 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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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.writeJsonl(deviceId, entry);
|
|
25
35
|
}
|
|
26
36
|
catch {
|
|
27
37
|
// best-effort — history loss is non-fatal
|
|
28
38
|
}
|
|
29
39
|
}
|
|
40
|
+
/** Append a mqtt control event (no deviceId) to the dedicated __control.jsonl file. */
|
|
41
|
+
recordControl(event) {
|
|
42
|
+
try {
|
|
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';
|
|
55
|
+
const lineBytes = Buffer.byteLength(line, 'utf-8');
|
|
56
|
+
// Seed size counter from disk on first touch (avoids drift across restarts).
|
|
57
|
+
let size = this.jsonlSizes.get(fileKey);
|
|
58
|
+
if (size === undefined) {
|
|
59
|
+
try {
|
|
60
|
+
size = fs.existsSync(jsonlPath) ? fs.statSync(jsonlPath).size : 0;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
size = 0;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (size + lineBytes > JSONL_ROTATE_BYTES) {
|
|
67
|
+
this.rotateJsonl(fileKey);
|
|
68
|
+
size = 0;
|
|
69
|
+
}
|
|
70
|
+
fs.appendFileSync(jsonlPath, line, { mode: 0o600 });
|
|
71
|
+
this.jsonlSizes.set(fileKey, size + lineBytes);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// best-effort
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
rotateJsonl(fileKey) {
|
|
78
|
+
const base = path.join(this.dir, `${fileKey}.jsonl`);
|
|
79
|
+
// .jsonl.3 is dropped; .2 → .3, .1 → .2, current → .1
|
|
80
|
+
try {
|
|
81
|
+
const oldest = `${base}.${JSONL_KEEP_ROTATIONS}`;
|
|
82
|
+
if (fs.existsSync(oldest))
|
|
83
|
+
fs.rmSync(oldest);
|
|
84
|
+
}
|
|
85
|
+
catch { /* swallow */ }
|
|
86
|
+
for (let i = JSONL_KEEP_ROTATIONS - 1; i >= 1; i--) {
|
|
87
|
+
const from = `${base}.${i}`;
|
|
88
|
+
const to = `${base}.${i + 1}`;
|
|
89
|
+
try {
|
|
90
|
+
if (fs.existsSync(from))
|
|
91
|
+
fs.renameSync(from, to);
|
|
92
|
+
}
|
|
93
|
+
catch { /* swallow */ }
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
if (fs.existsSync(base))
|
|
97
|
+
fs.renameSync(base, `${base}.1`);
|
|
98
|
+
}
|
|
99
|
+
catch { /* swallow */ }
|
|
100
|
+
}
|
|
30
101
|
getLatest(deviceId) {
|
|
31
102
|
try {
|
|
32
103
|
const file = path.join(this.dir, `${deviceId}.json`);
|
|
@@ -54,13 +125,21 @@ export class DeviceHistoryStore {
|
|
|
54
125
|
try {
|
|
55
126
|
if (!fs.existsSync(this.dir))
|
|
56
127
|
return [];
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
for (const f of fs.readdirSync(this.dir)) {
|
|
130
|
+
if (f.endsWith('.json'))
|
|
131
|
+
seen.add(f.slice(0, -5));
|
|
132
|
+
else if (f.endsWith('.jsonl'))
|
|
133
|
+
seen.add(f.slice(0, -6));
|
|
134
|
+
}
|
|
135
|
+
return Array.from(seen);
|
|
60
136
|
}
|
|
61
137
|
catch {
|
|
62
138
|
return [];
|
|
63
139
|
}
|
|
64
140
|
}
|
|
141
|
+
getHistoryDir() {
|
|
142
|
+
return this.dir;
|
|
143
|
+
}
|
|
65
144
|
}
|
|
66
145
|
export const deviceHistoryStore = new DeviceHistoryStore();
|
package/dist/utils/audit.js
CHANGED
|
@@ -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
|
-
|
|
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.fileMissing = true;
|
|
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
|
+
}
|
package/dist/utils/flags.js
CHANGED
|
@@ -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 };
|
package/dist/utils/format.js
CHANGED
|
@@ -12,8 +12,9 @@ export function parseFormat(flag) {
|
|
|
12
12
|
case 'tsv': return 'tsv';
|
|
13
13
|
case 'yaml': return 'yaml';
|
|
14
14
|
case 'id': return 'id';
|
|
15
|
+
case 'markdown': return 'markdown';
|
|
15
16
|
default: {
|
|
16
|
-
const msg = `Unknown --format "${flag}". Expected: table, json, jsonl, tsv, yaml, id.`;
|
|
17
|
+
const msg = `Unknown --format "${flag}". Expected: table, json, jsonl, tsv, yaml, id, markdown.`;
|
|
17
18
|
if (isJsonMode()) {
|
|
18
19
|
console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
|
|
19
20
|
}
|
|
@@ -67,6 +68,13 @@ export function renderRows(headers, rows, format, fields, aliases) {
|
|
|
67
68
|
const filtered = filterFields(headers, rows, fields, aliases);
|
|
68
69
|
const h = filtered.headers;
|
|
69
70
|
const r = filtered.rows;
|
|
71
|
+
// Markdown format is rendered as table with markdown style forced regardless
|
|
72
|
+
// of the user's --table-style, so `--format markdown` is a self-contained
|
|
73
|
+
// contract (bug #8).
|
|
74
|
+
if (format === 'markdown') {
|
|
75
|
+
printTable(h, r, 'markdown');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
70
78
|
switch (format) {
|
|
71
79
|
case 'table':
|
|
72
80
|
printTable(h, r);
|
|
@@ -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
|
-
|
|
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,79 @@ 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
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
continue;
|
|
36
|
+
const normAlias = alias ? normalizeDeviceName(alias) : null;
|
|
37
|
+
// exact alias/name wins immediately for lenient strategies.
|
|
38
|
+
// Under require-unique we must NOT short-circuit: there may be other devices
|
|
39
|
+
// that also match (e.g. via substring), making the result ambiguous. Collect
|
|
40
|
+
// the exact hit as a candidate and let the full ambiguity check decide below.
|
|
41
|
+
if ((normAlias && normAlias === q) || rawName === q) {
|
|
42
|
+
if (strategy !== 'require-unique') {
|
|
43
|
+
return { ok: true, deviceId };
|
|
30
44
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
45
|
+
// require-unique: treat exact match as a high-priority candidate (score 0)
|
|
46
|
+
candidates.push({ deviceId, name: device.name, score: 0 });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (strategy === 'exact')
|
|
50
|
+
continue;
|
|
51
|
+
if (strategy === 'prefix') {
|
|
52
|
+
if ((normAlias && normAlias.startsWith(q)) || rawName.startsWith(q)) {
|
|
53
|
+
candidates.push({ deviceId, name: device.name, score: 1 });
|
|
35
54
|
}
|
|
55
|
+
continue;
|
|
36
56
|
}
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
if (rawName.includes(q)) {
|
|
57
|
+
// substring + fuzzy + require-unique + first all share substring match
|
|
58
|
+
if ((normAlias && normAlias.includes(q)) || rawName.includes(q)) {
|
|
40
59
|
candidates.push({ deviceId, name: device.name, score: 1 });
|
|
41
60
|
continue;
|
|
42
61
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
62
|
+
if (strategy === 'substring')
|
|
63
|
+
continue;
|
|
64
|
+
// fuzzy / require-unique / first → also levenshtein
|
|
65
|
+
if (strategy === 'fuzzy' || strategy === 'require-unique' || strategy === 'first') {
|
|
66
|
+
const distName = levenshtein(rawName, q);
|
|
67
|
+
const distAlias = normAlias ? levenshtein(normAlias, q) : Number.POSITIVE_INFINITY;
|
|
68
|
+
const dist = Math.min(distName, distAlias);
|
|
69
|
+
if (dist <= threshold) {
|
|
70
|
+
candidates.push({ deviceId, name: device.name, score: dist + 1 });
|
|
71
|
+
}
|
|
47
72
|
}
|
|
48
73
|
}
|
|
49
74
|
if (candidates.length === 0)
|
|
50
75
|
return { ok: false, ambiguous: false };
|
|
51
76
|
candidates.sort((a, b) => a.score - b.score);
|
|
77
|
+
if (strategy === 'first') {
|
|
78
|
+
return { ok: true, deviceId: candidates[0].deviceId };
|
|
79
|
+
}
|
|
80
|
+
if (strategy === 'require-unique') {
|
|
81
|
+
if (candidates.length === 1)
|
|
82
|
+
return { ok: true, deviceId: candidates[0].deviceId };
|
|
83
|
+
return { ok: false, ambiguous: true, candidates: candidates.slice(0, 4) };
|
|
84
|
+
}
|
|
85
|
+
// fuzzy / substring / prefix: collapse cluster of near-ties
|
|
52
86
|
const best = candidates[0].score;
|
|
53
87
|
const top = candidates.filter((c) => c.score <= best + 1);
|
|
54
88
|
if (top.length === 1)
|
|
55
89
|
return { ok: true, deviceId: top[0].deviceId };
|
|
56
90
|
return { ok: false, ambiguous: true, candidates: top.slice(0, 4) };
|
|
57
91
|
}
|
|
58
|
-
export function resolveDeviceId(deviceId, nameQuery) {
|
|
92
|
+
export function resolveDeviceId(deviceId, nameQuery, opts = {}) {
|
|
59
93
|
if (deviceId && nameQuery) {
|
|
60
94
|
throw new UsageError('Provide either a deviceId argument or --name, not both.');
|
|
61
95
|
}
|
|
@@ -64,15 +98,37 @@ export function resolveDeviceId(deviceId, nameQuery) {
|
|
|
64
98
|
if (!nameQuery) {
|
|
65
99
|
throw new UsageError('A deviceId argument or --name flag is required.');
|
|
66
100
|
}
|
|
101
|
+
if (opts.strategy && !isValidStrategy(opts.strategy)) {
|
|
102
|
+
throw new UsageError(`--name-strategy must be one of: ${ALL_STRATEGIES.join(', ')} (got "${opts.strategy}")`);
|
|
103
|
+
}
|
|
67
104
|
const cache = loadCache();
|
|
68
105
|
if (!cache) {
|
|
69
106
|
throw new UsageError(`--name requires the device cache. Run 'switchbot devices list' first to populate it.`);
|
|
70
107
|
}
|
|
71
|
-
const result = resolveDeviceByName(nameQuery);
|
|
108
|
+
const result = resolveDeviceByName(nameQuery, opts);
|
|
72
109
|
if (result.ok)
|
|
73
110
|
return result.deviceId;
|
|
74
111
|
if (result.ambiguous) {
|
|
75
|
-
|
|
112
|
+
const candidates = result.candidates.map((c) => ({ deviceId: c.deviceId, name: c.name }));
|
|
113
|
+
const narrow = [];
|
|
114
|
+
if (!opts.type)
|
|
115
|
+
narrow.push('--type');
|
|
116
|
+
if (!opts.category)
|
|
117
|
+
narrow.push('--category');
|
|
118
|
+
if (!opts.room)
|
|
119
|
+
narrow.push('--room');
|
|
120
|
+
const hint = narrow.length > 0
|
|
121
|
+
? `Narrow with ${narrow.join(' / ')} or use the deviceId directly, or pass --name-strategy first to pick the best match.`
|
|
122
|
+
: `Use the deviceId directly, or pass --name-strategy first to pick the best match.`;
|
|
123
|
+
throw new StructuredUsageError(`"${nameQuery}" is ambiguous — ${candidates.length} devices match.`, {
|
|
124
|
+
error: 'ambiguous_name_match',
|
|
125
|
+
query: nameQuery,
|
|
126
|
+
candidates,
|
|
127
|
+
hint,
|
|
128
|
+
});
|
|
76
129
|
}
|
|
77
|
-
|
|
130
|
+
const noMatchNarrow = opts.type || opts.category || opts.room
|
|
131
|
+
? ' after applying --type/--category/--room filters'
|
|
132
|
+
: '';
|
|
133
|
+
throw new UsageError(`No device matches "${nameQuery}"${noMatchNarrow}. Run 'switchbot devices list' to see device names.`);
|
|
78
134
|
}
|
package/dist/utils/output.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import Table from 'cli-table3';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { ApiError, DryRunSignal } from '../api/client.js';
|
|
4
|
-
import { getFormat } from './flags.js';
|
|
4
|
+
import { getFormat, getTableStyle } from './flags.js';
|
|
5
5
|
export const SCHEMA_VERSION = '1.1';
|
|
6
6
|
export function isJsonMode() {
|
|
7
7
|
return process.argv.includes('--json') || getFormat() === 'json';
|
|
@@ -9,33 +9,96 @@ export function isJsonMode() {
|
|
|
9
9
|
export function printJson(data) {
|
|
10
10
|
console.log(JSON.stringify({ schemaVersion: SCHEMA_VERSION, data }, null, 2));
|
|
11
11
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
function escapeMarkdownCell(s) {
|
|
13
|
+
// Pipes break markdown table layout; backslash-escape them. Collapse
|
|
14
|
+
// newlines into <br> so each row stays on one line.
|
|
15
|
+
return s.replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
|
|
16
|
+
}
|
|
17
|
+
function formatCell(cell, style) {
|
|
18
|
+
if (cell === null || cell === undefined)
|
|
19
|
+
return style === 'markdown' ? '—' : chalk.grey('—');
|
|
20
|
+
if (typeof cell === 'boolean') {
|
|
21
|
+
if (style === 'markdown')
|
|
22
|
+
return cell ? 'Yes' : 'No';
|
|
23
|
+
return cell ? chalk.green('✓') : chalk.red('✗');
|
|
24
|
+
}
|
|
25
|
+
return String(cell);
|
|
26
|
+
}
|
|
27
|
+
function renderMarkdownTable(headers, rows) {
|
|
28
|
+
const head = `| ${headers.map(escapeMarkdownCell).join(' | ')} |`;
|
|
29
|
+
const sep = `| ${headers.map(() => '---').join(' | ')} |`;
|
|
30
|
+
const body = rows.map((r) => `| ${r
|
|
31
|
+
.map((c) => escapeMarkdownCell(formatCell(c, 'markdown')))
|
|
32
|
+
.join(' | ')} |`);
|
|
33
|
+
return [head, sep, ...body].join('\n');
|
|
34
|
+
}
|
|
35
|
+
function renderSimpleTable(headers, rows) {
|
|
36
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => String(formatCell(r[i], 'simple')).length)));
|
|
37
|
+
const fmt = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(' ').trimEnd();
|
|
38
|
+
return [
|
|
39
|
+
fmt(headers),
|
|
40
|
+
...rows.map((r) => fmt(r.map((c) => String(formatCell(c, 'simple'))))),
|
|
41
|
+
].join('\n');
|
|
42
|
+
}
|
|
43
|
+
const ASCII_BORDER_CHARS = {
|
|
44
|
+
top: '-', 'top-mid': '+', 'top-left': '+', 'top-right': '+',
|
|
45
|
+
bottom: '-', 'bottom-mid': '+', 'bottom-left': '+', 'bottom-right': '+',
|
|
46
|
+
left: '|', 'left-mid': '+', mid: '-', 'mid-mid': '+',
|
|
47
|
+
right: '|', 'right-mid': '+', middle: '|',
|
|
48
|
+
};
|
|
49
|
+
export function printTable(headers, rows, styleOverride) {
|
|
50
|
+
const style = styleOverride ?? getTableStyle();
|
|
51
|
+
if (style === 'markdown') {
|
|
52
|
+
console.log(renderMarkdownTable(headers, rows));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (style === 'simple') {
|
|
56
|
+
console.log(renderSimpleTable(headers, rows));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const tableOpts = {
|
|
60
|
+
head: headers.map((h) => (style === 'ascii' ? h : chalk.cyan(h))),
|
|
61
|
+
style: style === 'ascii' ? { border: [], head: [] } : { border: ['grey'] },
|
|
62
|
+
};
|
|
63
|
+
if (style === 'ascii') {
|
|
64
|
+
tableOpts.chars = ASCII_BORDER_CHARS;
|
|
65
|
+
}
|
|
66
|
+
const table = new Table(tableOpts);
|
|
17
67
|
for (const row of rows) {
|
|
18
|
-
table.push(row.map((cell) =>
|
|
19
|
-
if (cell === null || cell === undefined)
|
|
20
|
-
return chalk.grey('—');
|
|
21
|
-
if (typeof cell === 'boolean')
|
|
22
|
-
return cell ? chalk.green('✓') : chalk.red('✗');
|
|
23
|
-
return String(cell);
|
|
24
|
-
}));
|
|
68
|
+
table.push(row.map((cell) => formatCell(cell, style)));
|
|
25
69
|
}
|
|
26
70
|
console.log(table.toString());
|
|
27
71
|
}
|
|
28
72
|
export function printKeyValue(data) {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
73
|
+
const style = getTableStyle();
|
|
74
|
+
if (style === 'markdown') {
|
|
75
|
+
const entries = Object.entries(data).filter(([, v]) => v !== null && v !== undefined);
|
|
76
|
+
const rows = entries.map(([k, v]) => [k, typeof v === 'object' ? JSON.stringify(v) : String(v)]);
|
|
77
|
+
console.log(renderMarkdownTable(['Key', 'Value'], rows));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (style === 'simple') {
|
|
81
|
+
for (const [key, value] of Object.entries(data)) {
|
|
82
|
+
if (value === null || value === undefined)
|
|
83
|
+
continue;
|
|
84
|
+
const displayValue = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
85
|
+
console.log(`${key} ${displayValue}`);
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const tableOpts = {
|
|
90
|
+
style: style === 'ascii' ? { border: [], head: [] } : { border: ['grey'] },
|
|
91
|
+
};
|
|
92
|
+
if (style === 'ascii') {
|
|
93
|
+
tableOpts.chars = ASCII_BORDER_CHARS;
|
|
94
|
+
}
|
|
95
|
+
const table = new Table(tableOpts);
|
|
32
96
|
for (const [key, value] of Object.entries(data)) {
|
|
33
97
|
if (value === null || value === undefined)
|
|
34
98
|
continue;
|
|
35
|
-
const displayValue = typeof value === 'object'
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
table.push({ [chalk.cyan(key)]: displayValue });
|
|
99
|
+
const displayValue = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
100
|
+
const keyLabel = style === 'ascii' ? key : chalk.cyan(key);
|
|
101
|
+
table.push({ [keyLabel]: displayValue });
|
|
39
102
|
}
|
|
40
103
|
console.log(table.toString());
|
|
41
104
|
}
|
|
@@ -82,6 +145,35 @@ export function buildErrorPayload(error) {
|
|
|
82
145
|
if (error instanceof UsageError) {
|
|
83
146
|
return { code: 2, kind: 'usage', message: error.message, errorClass: 'usage', transient: false };
|
|
84
147
|
}
|
|
148
|
+
// Idempotency conflict → exit 2 with kind:guard so scripts can react.
|
|
149
|
+
if (error instanceof Error && error.name === 'IdempotencyConflictError') {
|
|
150
|
+
return {
|
|
151
|
+
code: 2,
|
|
152
|
+
kind: 'guard',
|
|
153
|
+
message: error.message,
|
|
154
|
+
errorClass: 'guard',
|
|
155
|
+
transient: false,
|
|
156
|
+
context: {
|
|
157
|
+
existingShape: error.existingShape,
|
|
158
|
+
newShape: error.newShape,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
// Local daily-cap refusal → exit 2 (usage-style refusal before touching net).
|
|
163
|
+
if (error instanceof Error && error.name === 'DailyCapExceededError') {
|
|
164
|
+
return {
|
|
165
|
+
code: 2,
|
|
166
|
+
kind: 'guard',
|
|
167
|
+
message: error.message,
|
|
168
|
+
errorClass: 'guard',
|
|
169
|
+
transient: false,
|
|
170
|
+
context: {
|
|
171
|
+
cap: error.cap,
|
|
172
|
+
total: error.total,
|
|
173
|
+
profile: error.profile,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
85
177
|
const code = error instanceof ApiError ? error.code : 1;
|
|
86
178
|
const kind = error instanceof ApiError ? 'api' : 'runtime';
|
|
87
179
|
const message = error instanceof Error ? error.message : 'An unknown error occurred';
|
|
@@ -127,6 +219,10 @@ export function handleError(error) {
|
|
|
127
219
|
console.error(payload.message);
|
|
128
220
|
process.exit(2);
|
|
129
221
|
}
|
|
222
|
+
if (payload.kind === 'guard') {
|
|
223
|
+
console.error(chalk.yellow(`Guard: ${payload.message}`));
|
|
224
|
+
process.exit(payload.code === 2 ? 2 : 1);
|
|
225
|
+
}
|
|
130
226
|
if (error instanceof ApiError) {
|
|
131
227
|
console.error(chalk.red(`Error (code ${error.code}): ${payload.message}`));
|
|
132
228
|
if (payload.hint)
|
package/dist/utils/quota.js
CHANGED
|
@@ -211,3 +211,17 @@ export function todayUsage(now = new Date()) {
|
|
|
211
211
|
endpoints: { ...bucket.endpoints },
|
|
212
212
|
};
|
|
213
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
|
+
}
|