@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
package/dist/config.js
CHANGED
|
@@ -3,6 +3,12 @@ import path from 'node:path';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import { getConfigPath } from './utils/flags.js';
|
|
5
5
|
import { getActiveProfile } from './lib/request-context.js';
|
|
6
|
+
function sanitizeOptionalString(v) {
|
|
7
|
+
if (typeof v !== 'string')
|
|
8
|
+
return undefined;
|
|
9
|
+
const trimmed = v.trim();
|
|
10
|
+
return trimmed ? trimmed : undefined;
|
|
11
|
+
}
|
|
6
12
|
/**
|
|
7
13
|
* Credential file resolution priority:
|
|
8
14
|
* 1. --config <path> (absolute override — wins over everything)
|
|
@@ -85,15 +91,70 @@ export function tryLoadConfig() {
|
|
|
85
91
|
return null;
|
|
86
92
|
}
|
|
87
93
|
}
|
|
88
|
-
export function saveConfig(token, secret) {
|
|
94
|
+
export function saveConfig(token, secret, extras) {
|
|
89
95
|
const file = configFilePath();
|
|
90
96
|
const dir = path.dirname(file);
|
|
91
97
|
if (!fs.existsSync(dir)) {
|
|
92
98
|
fs.mkdirSync(dir, { recursive: true });
|
|
93
99
|
}
|
|
94
|
-
|
|
100
|
+
// Merge with existing file so label/limits/defaults aren't dropped when the
|
|
101
|
+
// user just rotates the token.
|
|
102
|
+
let existing = {};
|
|
103
|
+
if (fs.existsSync(file)) {
|
|
104
|
+
try {
|
|
105
|
+
existing = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
existing = {};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const cfg = {
|
|
112
|
+
token,
|
|
113
|
+
secret,
|
|
114
|
+
...(existing.label ? { label: existing.label } : {}),
|
|
115
|
+
...(existing.description ? { description: existing.description } : {}),
|
|
116
|
+
...(existing.limits ? { limits: existing.limits } : {}),
|
|
117
|
+
...(existing.defaults ? { defaults: existing.defaults } : {}),
|
|
118
|
+
};
|
|
119
|
+
if (extras) {
|
|
120
|
+
const label = sanitizeOptionalString(extras.label);
|
|
121
|
+
const description = sanitizeOptionalString(extras.description);
|
|
122
|
+
if (label !== undefined)
|
|
123
|
+
cfg.label = label;
|
|
124
|
+
if (description !== undefined)
|
|
125
|
+
cfg.description = description;
|
|
126
|
+
if (extras.limits)
|
|
127
|
+
cfg.limits = { ...(cfg.limits ?? {}), ...extras.limits };
|
|
128
|
+
if (extras.defaults)
|
|
129
|
+
cfg.defaults = { ...(cfg.defaults ?? {}), ...extras.defaults };
|
|
130
|
+
}
|
|
95
131
|
fs.writeFileSync(file, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
96
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Read a profile's metadata (label / description / limits / defaults) without
|
|
135
|
+
* exposing the token/secret. Returns null when the file is missing or invalid.
|
|
136
|
+
*/
|
|
137
|
+
export function readProfileMeta(profile) {
|
|
138
|
+
const file = profile
|
|
139
|
+
? profileFilePath(profile)
|
|
140
|
+
: path.join(os.homedir(), '.switchbot', 'config.json');
|
|
141
|
+
if (!fs.existsSync(file))
|
|
142
|
+
return null;
|
|
143
|
+
try {
|
|
144
|
+
const raw = fs.readFileSync(file, 'utf-8');
|
|
145
|
+
const cfg = JSON.parse(raw);
|
|
146
|
+
return {
|
|
147
|
+
label: cfg.label,
|
|
148
|
+
description: cfg.description,
|
|
149
|
+
limits: cfg.limits,
|
|
150
|
+
defaults: cfg.defaults,
|
|
151
|
+
path: file,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
97
158
|
export function showConfig() {
|
|
98
159
|
const envToken = process.env.SWITCHBOT_TOKEN;
|
|
99
160
|
const envSecret = process.env.SWITCHBOT_SECRET;
|
|
@@ -112,8 +173,16 @@ export function showConfig() {
|
|
|
112
173
|
const raw = fs.readFileSync(file, 'utf-8');
|
|
113
174
|
const cfg = JSON.parse(raw);
|
|
114
175
|
console.log(`Credential source: ${file}`);
|
|
176
|
+
if (cfg.label)
|
|
177
|
+
console.log(`label : ${cfg.label}`);
|
|
178
|
+
if (cfg.description)
|
|
179
|
+
console.log(`desc : ${cfg.description}`);
|
|
115
180
|
console.log(`token : ${maskCredential(cfg.token)}`);
|
|
116
181
|
console.log(`secret: ${maskSecret(cfg.secret)}`);
|
|
182
|
+
if (cfg.limits?.dailyCap)
|
|
183
|
+
console.log(`limits: dailyCap=${cfg.limits.dailyCap}`);
|
|
184
|
+
if (cfg.defaults?.flags?.length)
|
|
185
|
+
console.log(`defaults: ${cfg.defaults.flags.join(' ')}`);
|
|
117
186
|
}
|
|
118
187
|
catch {
|
|
119
188
|
console.error('Failed to read config file');
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import readline from 'node:readline';
|
|
5
|
+
const DEFAULT_LIMIT = 1000;
|
|
6
|
+
function historyDir() {
|
|
7
|
+
return path.join(os.homedir(), '.switchbot', 'device-history');
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Parse a duration shortcut like "7d", "12h", "30m", "45s" into milliseconds.
|
|
11
|
+
* Returns null on malformed input (caller throws UsageError).
|
|
12
|
+
*/
|
|
13
|
+
export function parseDurationToMs(spec) {
|
|
14
|
+
const m = spec.trim().match(/^(\d+)(ms|s|m|h|d)$/i);
|
|
15
|
+
if (!m)
|
|
16
|
+
return null;
|
|
17
|
+
const n = Number(m[1]);
|
|
18
|
+
const unit = m[2].toLowerCase();
|
|
19
|
+
const factor = unit === 'ms' ? 1 : unit === 's' ? 1_000 : unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : 86_400_000;
|
|
20
|
+
return n * factor;
|
|
21
|
+
}
|
|
22
|
+
/** Parse ISO-8601 (with Z or offset) or Date-parseable string → ms, else null. */
|
|
23
|
+
export function parseInstantToMs(spec) {
|
|
24
|
+
const ms = Date.parse(spec);
|
|
25
|
+
return Number.isFinite(ms) ? ms : null;
|
|
26
|
+
}
|
|
27
|
+
export function resolveRange(opts) {
|
|
28
|
+
let fromMs = Number.NEGATIVE_INFINITY;
|
|
29
|
+
let toMs = Number.POSITIVE_INFINITY;
|
|
30
|
+
if (opts.since && (opts.from || opts.to)) {
|
|
31
|
+
throw new Error('--since is mutually exclusive with --from/--to.');
|
|
32
|
+
}
|
|
33
|
+
if (opts.since) {
|
|
34
|
+
const durMs = parseDurationToMs(opts.since);
|
|
35
|
+
if (durMs === null) {
|
|
36
|
+
throw new Error(`Invalid --since value "${opts.since}". Expected e.g. "30s", "15m", "1h", "7d".`);
|
|
37
|
+
}
|
|
38
|
+
fromMs = Date.now() - durMs;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
if (opts.from) {
|
|
42
|
+
const parsed = parseInstantToMs(opts.from);
|
|
43
|
+
if (parsed === null)
|
|
44
|
+
throw new Error(`Invalid --from value "${opts.from}". Expected ISO-8601 timestamp.`);
|
|
45
|
+
fromMs = parsed;
|
|
46
|
+
}
|
|
47
|
+
if (opts.to) {
|
|
48
|
+
const parsed = parseInstantToMs(opts.to);
|
|
49
|
+
if (parsed === null)
|
|
50
|
+
throw new Error(`Invalid --to value "${opts.to}". Expected ISO-8601 timestamp.`);
|
|
51
|
+
toMs = parsed;
|
|
52
|
+
}
|
|
53
|
+
if (fromMs > toMs)
|
|
54
|
+
throw new Error('--from must be <= --to.');
|
|
55
|
+
}
|
|
56
|
+
return { fromMs, toMs };
|
|
57
|
+
}
|
|
58
|
+
/** Return jsonl candidate files for a device, oldest-first. */
|
|
59
|
+
export function jsonlFilesForDevice(deviceId, baseDir = historyDir()) {
|
|
60
|
+
const out = [];
|
|
61
|
+
if (!fs.existsSync(baseDir))
|
|
62
|
+
return out;
|
|
63
|
+
// Oldest-first so range walks can bail early once a line overshoots `toMs`.
|
|
64
|
+
for (let i = 3; i >= 1; i--) {
|
|
65
|
+
const p = path.join(baseDir, `${deviceId}.jsonl.${i}`);
|
|
66
|
+
if (fs.existsSync(p))
|
|
67
|
+
out.push(p);
|
|
68
|
+
}
|
|
69
|
+
const current = path.join(baseDir, `${deviceId}.jsonl`);
|
|
70
|
+
if (fs.existsSync(current))
|
|
71
|
+
out.push(current);
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
function projectFields(record, fields) {
|
|
75
|
+
if (fields.length === 0)
|
|
76
|
+
return record;
|
|
77
|
+
const projected = {};
|
|
78
|
+
const payload = (record.payload ?? {});
|
|
79
|
+
for (const f of fields) {
|
|
80
|
+
if (f in payload)
|
|
81
|
+
projected[f] = payload[f];
|
|
82
|
+
}
|
|
83
|
+
return { t: record.t, topic: record.topic, deviceType: record.deviceType, payload: projected };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Stream-read the JSONL rotation files for `deviceId` and return records
|
|
87
|
+
* within [fromMs, toMs]. Parse failures are silently dropped (best-effort).
|
|
88
|
+
*
|
|
89
|
+
* Files whose mtime < fromMs are skipped whole (coarse but sound: the newest
|
|
90
|
+
* record in the file is <= mtime, so nothing in it can match).
|
|
91
|
+
*/
|
|
92
|
+
export async function queryDeviceHistory(deviceId, opts = {}) {
|
|
93
|
+
const { fromMs, toMs } = resolveRange(opts);
|
|
94
|
+
const limit = Math.max(0, opts.limit ?? DEFAULT_LIMIT);
|
|
95
|
+
const fields = opts.fields ?? [];
|
|
96
|
+
const files = jsonlFilesForDevice(deviceId);
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const file of files) {
|
|
99
|
+
try {
|
|
100
|
+
const stat = fs.statSync(file);
|
|
101
|
+
if (stat.mtimeMs < fromMs)
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const stream = fs.createReadStream(file, { encoding: 'utf-8' });
|
|
108
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
109
|
+
for await (const line of rl) {
|
|
110
|
+
if (!line)
|
|
111
|
+
continue;
|
|
112
|
+
let rec;
|
|
113
|
+
try {
|
|
114
|
+
rec = JSON.parse(line);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const tMs = Date.parse(rec.t);
|
|
120
|
+
if (!Number.isFinite(tMs))
|
|
121
|
+
continue;
|
|
122
|
+
if (tMs < fromMs || tMs > toMs)
|
|
123
|
+
continue;
|
|
124
|
+
out.push(projectFields(rec, fields));
|
|
125
|
+
if (out.length >= limit) {
|
|
126
|
+
rl.close();
|
|
127
|
+
stream.destroy();
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
export function queryDeviceHistoryStats(deviceId) {
|
|
135
|
+
const dir = historyDir();
|
|
136
|
+
const files = jsonlFilesForDevice(deviceId);
|
|
137
|
+
let totalBytes = 0;
|
|
138
|
+
let oldest = null;
|
|
139
|
+
let newest = null;
|
|
140
|
+
let count = 0;
|
|
141
|
+
for (const file of files) {
|
|
142
|
+
try {
|
|
143
|
+
totalBytes += fs.statSync(file).size;
|
|
144
|
+
}
|
|
145
|
+
catch { /* */ }
|
|
146
|
+
}
|
|
147
|
+
// Walk the oldest file's head + current file's tail for oldest/newest + count.
|
|
148
|
+
// Counting is O(records) here, acceptable for "stats" which isn't a hot path.
|
|
149
|
+
for (const file of files) {
|
|
150
|
+
try {
|
|
151
|
+
const lines = fs.readFileSync(file, 'utf-8').split('\n');
|
|
152
|
+
for (const line of lines) {
|
|
153
|
+
if (!line)
|
|
154
|
+
continue;
|
|
155
|
+
count += 1;
|
|
156
|
+
try {
|
|
157
|
+
const rec = JSON.parse(line);
|
|
158
|
+
const tMs = Date.parse(rec.t);
|
|
159
|
+
if (Number.isFinite(tMs)) {
|
|
160
|
+
if (oldest === null || tMs < oldest)
|
|
161
|
+
oldest = tMs;
|
|
162
|
+
if (newest === null || tMs > newest)
|
|
163
|
+
newest = tMs;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch { /* */ }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch { /* */ }
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
deviceId,
|
|
173
|
+
fileCount: files.length,
|
|
174
|
+
totalBytes,
|
|
175
|
+
recordCount: count,
|
|
176
|
+
oldest: oldest !== null ? new Date(oldest).toISOString() : undefined,
|
|
177
|
+
newest: newest !== null ? new Date(newest).toISOString() : undefined,
|
|
178
|
+
jsonlFiles: files.map((f) => path.basename(f)),
|
|
179
|
+
historyDir: dir,
|
|
180
|
+
};
|
|
181
|
+
}
|
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';
|
|
@@ -18,15 +19,21 @@ import { registerSchemaCommand } from './commands/schema.js';
|
|
|
18
19
|
import { registerHistoryCommand } from './commands/history.js';
|
|
19
20
|
import { registerPlanCommand } from './commands/plan.js';
|
|
20
21
|
import { registerCapabilitiesCommand } from './commands/capabilities.js';
|
|
22
|
+
import { registerAgentBootstrapCommand } from './commands/agent-bootstrap.js';
|
|
21
23
|
const require = createRequire(import.meta.url);
|
|
22
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
|
+
}
|
|
23
30
|
const program = new Command();
|
|
24
31
|
// Top-level subcommand names. Used by stringArg to produce clearer errors when
|
|
25
32
|
// a value is omitted and the next argv token turns out to be a subcommand name.
|
|
26
33
|
const TOP_LEVEL_COMMANDS = [
|
|
27
34
|
'config', 'devices', 'scenes', 'webhook', 'completion', 'mcp',
|
|
28
35
|
'quota', 'catalog', 'cache', 'events', 'doctor', 'schema',
|
|
29
|
-
'history', 'plan', 'capabilities',
|
|
36
|
+
'history', 'plan', 'capabilities', 'agent-bootstrap',
|
|
30
37
|
];
|
|
31
38
|
const cacheModeArg = (value) => {
|
|
32
39
|
if (value.startsWith('-')) {
|
|
@@ -43,9 +50,11 @@ program
|
|
|
43
50
|
.name('switchbot')
|
|
44
51
|
.description('Command-line tool for SwitchBot API v1.1')
|
|
45
52
|
.version(pkgVersion)
|
|
53
|
+
.option('--no-color', 'Disable ANSI colors in output')
|
|
46
54
|
.option('--json', 'Output raw JSON response (disables tables; useful for pipes/scripts)')
|
|
47
|
-
.option('--format <type>', 'Output format: table (default), json, jsonl, tsv, yaml, id', enumArg('--format', ['table', 'json', 'jsonl', 'tsv', 'yaml', 'id']))
|
|
55
|
+
.option('--format <type>', 'Output format: table (default), json, jsonl, tsv, yaml, id, markdown', enumArg('--format', ['table', 'json', 'jsonl', 'tsv', 'yaml', 'id', 'markdown']))
|
|
48
56
|
.option('--fields <csv>', 'Comma-separated list of columns to include (e.g. --fields=id,name,type)', stringArg('--fields', { disallow: TOP_LEVEL_COMMANDS }))
|
|
57
|
+
.option('--table-style <style>', 'Table rendering style: unicode (default on TTY), ascii (default on pipes), simple, markdown', enumArg('--table-style', ['unicode', 'ascii', 'simple', 'markdown']))
|
|
49
58
|
.option('-v, --verbose', 'Log HTTP request/response details to stderr')
|
|
50
59
|
.option('--dry-run', 'Print mutating requests without sending them (GETs still execute)')
|
|
51
60
|
.option('--timeout <ms>', 'HTTP request timeout in milliseconds (default: 30000)', intArg('--timeout', { min: 1 }))
|
|
@@ -76,6 +85,7 @@ registerSchemaCommand(program);
|
|
|
76
85
|
registerHistoryCommand(program);
|
|
77
86
|
registerPlanCommand(program);
|
|
78
87
|
registerCapabilitiesCommand(program);
|
|
88
|
+
registerAgentBootstrapCommand(program);
|
|
79
89
|
program.addHelpText('after', `
|
|
80
90
|
Credentials:
|
|
81
91
|
Provide SwitchBot API v1.1 credentials via either:
|
|
@@ -130,6 +140,13 @@ function applyExitOverride(cmd) {
|
|
|
130
140
|
cmd.commands.forEach(applyExitOverride);
|
|
131
141
|
}
|
|
132
142
|
applyExitOverride(program);
|
|
143
|
+
// Enable "did you mean" suggestions across every subcommand, not just the root.
|
|
144
|
+
// Without this, `switchbot devices lst` fails without suggesting `list`.
|
|
145
|
+
function enableSuggestions(cmd) {
|
|
146
|
+
cmd.showSuggestionAfterError(true);
|
|
147
|
+
cmd.commands.forEach(enableSuggestions);
|
|
148
|
+
}
|
|
149
|
+
enableSuggestions(program);
|
|
133
150
|
try {
|
|
134
151
|
await program.parseAsync();
|
|
135
152
|
}
|
package/dist/lib/devices.js
CHANGED
|
@@ -124,7 +124,14 @@ export async function executeCommand(deviceId, cmd, parameter, commandType, clie
|
|
|
124
124
|
throw err;
|
|
125
125
|
}
|
|
126
126
|
};
|
|
127
|
-
|
|
127
|
+
const { result, replayed } = await idempotencyCache.run(options?.idempotencyKey, execute, { command: cmd, parameter });
|
|
128
|
+
if (!replayed)
|
|
129
|
+
return result;
|
|
130
|
+
// Cached hit — attach replayed marker without mutating the original.
|
|
131
|
+
if (result && typeof result === 'object') {
|
|
132
|
+
return { ...result, replayed: true };
|
|
133
|
+
}
|
|
134
|
+
return { value: result, replayed: true };
|
|
128
135
|
}
|
|
129
136
|
/**
|
|
130
137
|
* Validate a command against the locally-cached device → catalog mapping.
|
package/dist/lib/idempotency.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
28
|
-
|
|
70
|
+
const cached = this.cache.get(hashed);
|
|
71
|
+
const currentShape = shape ? shapeSignature(shape.command, shape.parameter) : '*';
|
|
29
72
|
if (cached && cached.expiresAt > now) {
|
|
30
|
-
|
|
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);
|
|
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
|
-
|
|
55
|
-
|
|
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();
|