@switchbot/openapi-cli 3.1.0 → 3.2.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/README.md +34 -42
- package/dist/index.js +56945 -169
- package/dist/policy/schema/v0.2.json +1 -1
- package/package.json +3 -2
- package/dist/api/client.js +0 -235
- package/dist/auth.js +0 -20
- package/dist/commands/agent-bootstrap.js +0 -182
- package/dist/commands/auth.js +0 -354
- package/dist/commands/batch.js +0 -413
- package/dist/commands/cache.js +0 -126
- package/dist/commands/capabilities.js +0 -385
- package/dist/commands/catalog.js +0 -359
- package/dist/commands/completion.js +0 -385
- package/dist/commands/config.js +0 -376
- package/dist/commands/daemon.js +0 -367
- package/dist/commands/device-meta.js +0 -159
- package/dist/commands/devices.js +0 -948
- package/dist/commands/doctor.js +0 -1015
- package/dist/commands/events.js +0 -563
- package/dist/commands/expand.js +0 -130
- package/dist/commands/explain.js +0 -139
- package/dist/commands/health.js +0 -113
- package/dist/commands/history.js +0 -320
- package/dist/commands/identity.js +0 -59
- package/dist/commands/install.js +0 -246
- package/dist/commands/mcp.js +0 -2017
- package/dist/commands/plan.js +0 -653
- package/dist/commands/policy.js +0 -586
- package/dist/commands/quota.js +0 -78
- package/dist/commands/rules.js +0 -875
- package/dist/commands/scenes.js +0 -264
- package/dist/commands/schema.js +0 -177
- package/dist/commands/status-sync.js +0 -131
- package/dist/commands/uninstall.js +0 -237
- package/dist/commands/upgrade-check.js +0 -88
- package/dist/commands/watch.js +0 -194
- package/dist/commands/webhook.js +0 -182
- package/dist/config.js +0 -258
- package/dist/credentials/backends/file.js +0 -101
- package/dist/credentials/backends/linux.js +0 -129
- package/dist/credentials/backends/macos.js +0 -129
- package/dist/credentials/backends/windows.js +0 -215
- package/dist/credentials/keychain.js +0 -88
- package/dist/credentials/prime.js +0 -52
- package/dist/devices/cache.js +0 -293
- package/dist/devices/catalog.js +0 -767
- package/dist/devices/device-meta.js +0 -56
- package/dist/devices/history-agg.js +0 -138
- package/dist/devices/history-query.js +0 -181
- package/dist/devices/param-validator.js +0 -433
- package/dist/devices/resources.js +0 -270
- package/dist/install/default-steps.js +0 -257
- package/dist/install/preflight.js +0 -212
- package/dist/install/steps.js +0 -67
- package/dist/lib/command-keywords.js +0 -17
- package/dist/lib/daemon-state.js +0 -46
- package/dist/lib/destructive-mode.js +0 -12
- package/dist/lib/devices.js +0 -382
- package/dist/lib/idempotency.js +0 -106
- package/dist/lib/plan-store.js +0 -68
- package/dist/lib/request-context.js +0 -12
- package/dist/lib/scenes.js +0 -10
- package/dist/logger.js +0 -16
- package/dist/mcp/device-history.js +0 -145
- package/dist/mcp/events-subscription.js +0 -213
- package/dist/mqtt/client.js +0 -180
- package/dist/mqtt/credential.js +0 -30
- package/dist/policy/add-rule.js +0 -124
- package/dist/policy/diff.js +0 -91
- package/dist/policy/format.js +0 -57
- package/dist/policy/load.js +0 -61
- package/dist/policy/migrate.js +0 -67
- package/dist/policy/schema.js +0 -18
- package/dist/policy/validate.js +0 -262
- package/dist/rules/action.js +0 -205
- package/dist/rules/audit-query.js +0 -89
- package/dist/rules/conflict-analyzer.js +0 -203
- package/dist/rules/cron-scheduler.js +0 -186
- package/dist/rules/destructive.js +0 -52
- package/dist/rules/engine.js +0 -757
- package/dist/rules/matcher.js +0 -230
- package/dist/rules/pid-file.js +0 -95
- package/dist/rules/quiet-hours.js +0 -45
- package/dist/rules/suggest.js +0 -95
- package/dist/rules/throttle.js +0 -116
- package/dist/rules/types.js +0 -34
- package/dist/rules/webhook-listener.js +0 -223
- package/dist/rules/webhook-token.js +0 -90
- package/dist/schema/field-aliases.js +0 -131
- package/dist/sinks/dispatcher.js +0 -12
- package/dist/sinks/file.js +0 -19
- package/dist/sinks/format.js +0 -56
- package/dist/sinks/homeassistant.js +0 -44
- package/dist/sinks/openclaw.js +0 -33
- package/dist/sinks/stdout.js +0 -5
- package/dist/sinks/telegram.js +0 -28
- package/dist/sinks/types.js +0 -1
- package/dist/sinks/webhook.js +0 -22
- package/dist/status-sync/manager.js +0 -268
- package/dist/utils/arg-parsers.js +0 -66
- package/dist/utils/audit.js +0 -117
- package/dist/utils/filter.js +0 -189
- package/dist/utils/flags.js +0 -186
- package/dist/utils/format.js +0 -117
- package/dist/utils/health.js +0 -101
- package/dist/utils/help-json.js +0 -54
- package/dist/utils/name-resolver.js +0 -137
- package/dist/utils/output.js +0 -404
- package/dist/utils/quota.js +0 -227
- package/dist/utils/redact.js +0 -68
- package/dist/utils/retry.js +0 -140
- package/dist/utils/string.js +0 -22
- package/dist/version.js +0 -4
package/dist/lib/devices.js
DELETED
|
@@ -1,382 +0,0 @@
|
|
|
1
|
-
import { createClient } from '../api/client.js';
|
|
2
|
-
import { idempotencyCache } from './idempotency.js';
|
|
3
|
-
import { findCatalogEntry, suggestedActions, getEffectiveCatalog, deriveSafetyTier, getCommandSafetyReason, } from '../devices/catalog.js';
|
|
4
|
-
import { getCachedDevice, updateCacheFromDeviceList, loadCache, isListCacheFresh, getCachedStatus, setCachedStatus, } from '../devices/cache.js';
|
|
5
|
-
import { getCacheMode } from '../utils/flags.js';
|
|
6
|
-
import { writeAudit } from '../utils/audit.js';
|
|
7
|
-
import { isDryRun } from '../utils/flags.js';
|
|
8
|
-
export class DeviceNotFoundError extends Error {
|
|
9
|
-
deviceId;
|
|
10
|
-
constructor(deviceId) {
|
|
11
|
-
super(`No device with id "${deviceId}" found on this account.`);
|
|
12
|
-
this.deviceId = deviceId;
|
|
13
|
-
this.name = 'DeviceNotFoundError';
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
export class CommandValidationError extends Error {
|
|
17
|
-
kind;
|
|
18
|
-
hint;
|
|
19
|
-
constructor(message, kind, hint) {
|
|
20
|
-
super(message);
|
|
21
|
-
this.kind = kind;
|
|
22
|
-
this.hint = hint;
|
|
23
|
-
this.name = 'CommandValidationError';
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
/** Fetch the full device + IR remote inventory and refresh the local cache. */
|
|
27
|
-
export async function fetchDeviceList(client) {
|
|
28
|
-
// TTL-gated read: when the on-disk cache is younger than the configured
|
|
29
|
-
// list TTL, skip the API call and synthesize a DeviceListBody from the
|
|
30
|
-
// metadata cache.
|
|
31
|
-
const mode = getCacheMode();
|
|
32
|
-
if (mode.listTtlMs > 0 && isListCacheFresh(mode.listTtlMs)) {
|
|
33
|
-
const cached = loadCache();
|
|
34
|
-
if (cached) {
|
|
35
|
-
const deviceList = [];
|
|
36
|
-
const infraredRemoteList = [];
|
|
37
|
-
for (const [deviceId, entry] of Object.entries(cached.devices)) {
|
|
38
|
-
if (entry.category === 'physical') {
|
|
39
|
-
deviceList.push({
|
|
40
|
-
deviceId,
|
|
41
|
-
deviceName: entry.name,
|
|
42
|
-
deviceType: entry.type,
|
|
43
|
-
enableCloudService: entry.enableCloudService ?? true,
|
|
44
|
-
hubDeviceId: entry.hubDeviceId ?? '',
|
|
45
|
-
roomID: entry.roomID,
|
|
46
|
-
roomName: entry.roomName,
|
|
47
|
-
familyName: entry.familyName,
|
|
48
|
-
controlType: entry.controlType,
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
else {
|
|
52
|
-
infraredRemoteList.push({
|
|
53
|
-
deviceId,
|
|
54
|
-
deviceName: entry.name,
|
|
55
|
-
remoteType: entry.type,
|
|
56
|
-
hubDeviceId: entry.hubDeviceId ?? '',
|
|
57
|
-
controlType: entry.controlType,
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
return { deviceList, infraredRemoteList };
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
const c = client ?? createClient();
|
|
65
|
-
const res = await c.get('/v1.1/devices');
|
|
66
|
-
updateCacheFromDeviceList(res.data.body);
|
|
67
|
-
return res.data.body;
|
|
68
|
-
}
|
|
69
|
-
/** Fetch live status for a single physical device. IR remotes have no status channel. */
|
|
70
|
-
export async function fetchDeviceStatus(deviceId, client) {
|
|
71
|
-
const mode = getCacheMode();
|
|
72
|
-
if (mode.statusTtlMs > 0) {
|
|
73
|
-
const cached = getCachedStatus(deviceId, mode.statusTtlMs);
|
|
74
|
-
if (cached)
|
|
75
|
-
return cached;
|
|
76
|
-
}
|
|
77
|
-
const c = client ?? createClient();
|
|
78
|
-
const res = await c.get(`/v1.1/devices/${deviceId}/status`);
|
|
79
|
-
if (mode.statusTtlMs > 0) {
|
|
80
|
-
setCachedStatus(deviceId, res.data.body, new Date(), mode.statusTtlMs);
|
|
81
|
-
}
|
|
82
|
-
return res.data.body;
|
|
83
|
-
}
|
|
84
|
-
/**
|
|
85
|
-
* Execute a command on a device. `parameter` is the fully-parsed value already
|
|
86
|
-
* (JSON-object when applicable), not a raw CLI string — callers should parse
|
|
87
|
-
* upstream if needed.
|
|
88
|
-
*/
|
|
89
|
-
export async function executeCommand(deviceId, cmd, parameter, commandType, client, options) {
|
|
90
|
-
const c = client ?? createClient();
|
|
91
|
-
const body = {
|
|
92
|
-
command: cmd,
|
|
93
|
-
parameter: parameter ?? 'default',
|
|
94
|
-
commandType,
|
|
95
|
-
};
|
|
96
|
-
const baseAudit = {
|
|
97
|
-
t: new Date().toISOString(),
|
|
98
|
-
kind: 'command',
|
|
99
|
-
deviceId,
|
|
100
|
-
command: cmd,
|
|
101
|
-
parameter,
|
|
102
|
-
commandType,
|
|
103
|
-
dryRun: isDryRun(),
|
|
104
|
-
...(options?.planId ? { planId: options.planId } : {}),
|
|
105
|
-
};
|
|
106
|
-
// Wrap in idempotency cache if key is provided
|
|
107
|
-
const execute = async () => {
|
|
108
|
-
try {
|
|
109
|
-
const res = await c.post(`/v1.1/devices/${deviceId}/commands`, body);
|
|
110
|
-
writeAudit({ ...baseAudit, result: 'ok' });
|
|
111
|
-
return res.data.body;
|
|
112
|
-
}
|
|
113
|
-
catch (err) {
|
|
114
|
-
// Dry-run intercepts throw DryRunSignal — still log the intent.
|
|
115
|
-
if (err instanceof Error && err.name === 'DryRunSignal') {
|
|
116
|
-
writeAudit({ ...baseAudit, result: 'ok' });
|
|
117
|
-
}
|
|
118
|
-
else {
|
|
119
|
-
writeAudit({
|
|
120
|
-
...baseAudit,
|
|
121
|
-
result: 'error',
|
|
122
|
-
error: err instanceof Error ? err.message : String(err),
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
throw err;
|
|
126
|
-
}
|
|
127
|
-
};
|
|
128
|
-
const { result, replayed } = await idempotencyCache.run(options?.idempotencyKey, execute, { command: cmd, parameter });
|
|
129
|
-
if (!replayed)
|
|
130
|
-
return result;
|
|
131
|
-
// Cached hit — attach replayed marker without mutating the original.
|
|
132
|
-
if (result && typeof result === 'object') {
|
|
133
|
-
return { ...result, replayed: true };
|
|
134
|
-
}
|
|
135
|
-
return { value: result, replayed: true };
|
|
136
|
-
}
|
|
137
|
-
/**
|
|
138
|
-
* Validate a command against the locally-cached device → catalog mapping.
|
|
139
|
-
* Returns `{ ok: true }` when validation passes or is skipped (unknown device,
|
|
140
|
-
* custom IR button, etc.). On a case-only mismatch the canonical command name
|
|
141
|
-
* is returned via `normalized` along with a `caseNormalizedFrom` field so the
|
|
142
|
-
* caller can emit a warning and continue with the canonical name.
|
|
143
|
-
* Returns `{ ok: false, error }` only when the caller should refuse the call.
|
|
144
|
-
*/
|
|
145
|
-
export function validateCommand(deviceId, cmd, parameter, commandType) {
|
|
146
|
-
if (commandType === 'customize')
|
|
147
|
-
return { ok: true };
|
|
148
|
-
const cached = getCachedDevice(deviceId);
|
|
149
|
-
if (!cached)
|
|
150
|
-
return { ok: true };
|
|
151
|
-
const match = findCatalogEntry(cached.type);
|
|
152
|
-
if (!match || Array.isArray(match))
|
|
153
|
-
return { ok: true };
|
|
154
|
-
const builtinCommands = match.commands.filter((c) => c.commandType !== 'customize');
|
|
155
|
-
if (match.readOnly || builtinCommands.length === 0) {
|
|
156
|
-
return {
|
|
157
|
-
ok: false,
|
|
158
|
-
error: new CommandValidationError(`${cached.name} (${cached.type}) is a read-only sensor — it has no control commands.`, 'read-only-device', `Use 'switchbot devices status ${deviceId}' to read this device instead.`),
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
let spec = builtinCommands.find((c) => c.command === cmd);
|
|
162
|
-
let caseNormalizedFrom;
|
|
163
|
-
let normalizedCmd = cmd;
|
|
164
|
-
if (!spec) {
|
|
165
|
-
const unique = [...new Set(builtinCommands.map((c) => c.command))];
|
|
166
|
-
const caseMatch = unique.find((c) => c.toLowerCase() === cmd.toLowerCase());
|
|
167
|
-
if (caseMatch) {
|
|
168
|
-
// Case-only mismatch: normalize and continue.
|
|
169
|
-
caseNormalizedFrom = cmd;
|
|
170
|
-
normalizedCmd = caseMatch;
|
|
171
|
-
spec = builtinCommands.find((c) => c.command === caseMatch);
|
|
172
|
-
}
|
|
173
|
-
else {
|
|
174
|
-
const hint = `Supported commands: ${unique.join(', ')}`;
|
|
175
|
-
return {
|
|
176
|
-
ok: false,
|
|
177
|
-
error: new CommandValidationError(`"${cmd}" is not a supported command for ${cached.name} (${cached.type}).`, 'unknown-command', hint),
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
if (!spec)
|
|
182
|
-
return { ok: true, normalized: normalizedCmd, caseNormalizedFrom };
|
|
183
|
-
const noParamExpected = spec.parameter === '—';
|
|
184
|
-
const userProvidedParam = parameter !== undefined && parameter !== 'default';
|
|
185
|
-
if (noParamExpected && userProvidedParam) {
|
|
186
|
-
return {
|
|
187
|
-
ok: false,
|
|
188
|
-
error: new CommandValidationError(`"${normalizedCmd}" takes no parameter, but one was provided: "${parameter}".`, 'unexpected-parameter', `Try: switchbot devices command ${deviceId} ${normalizedCmd}`),
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
// Warn when a parameter is required but the user omitted it
|
|
192
|
-
const paramRequired = !noParamExpected && spec.parameter !== 'default';
|
|
193
|
-
if (paramRequired && !userProvidedParam) {
|
|
194
|
-
const example = spec.exampleParams?.[0];
|
|
195
|
-
return {
|
|
196
|
-
ok: false,
|
|
197
|
-
error: new CommandValidationError(`"${normalizedCmd}" requires a parameter (${spec.parameter}).`, 'missing-parameter', example
|
|
198
|
-
? `Example: switchbot devices command <deviceId> ${normalizedCmd} "${example}"`
|
|
199
|
-
: `See: switchbot devices commands ${cached.type}`),
|
|
200
|
-
};
|
|
201
|
-
}
|
|
202
|
-
return { ok: true, normalized: normalizedCmd, caseNormalizedFrom };
|
|
203
|
-
}
|
|
204
|
-
/**
|
|
205
|
-
* Inspect catalog annotations to decide whether a command is destructive,
|
|
206
|
-
* i.e. has hard-to-reverse real-world effects and should require an explicit
|
|
207
|
-
* confirmation from an agent / operator before execution. Customize commands
|
|
208
|
-
* are considered non-destructive here — they're user-defined IR buttons
|
|
209
|
-
* whose behavior the catalog can't know about.
|
|
210
|
-
*/
|
|
211
|
-
export function isDestructiveCommand(deviceType, cmd, commandType) {
|
|
212
|
-
if (commandType === 'customize')
|
|
213
|
-
return false;
|
|
214
|
-
if (!deviceType)
|
|
215
|
-
return false;
|
|
216
|
-
const match = findCatalogEntry(deviceType);
|
|
217
|
-
if (!match || Array.isArray(match))
|
|
218
|
-
return false;
|
|
219
|
-
const spec = match.commands.find((c) => c.command === cmd);
|
|
220
|
-
if (!spec)
|
|
221
|
-
return false;
|
|
222
|
-
return deriveSafetyTier(spec, match) === 'destructive';
|
|
223
|
-
}
|
|
224
|
-
/** Return the safetyReason for a command, or null if not destructive / not found. */
|
|
225
|
-
export function getDestructiveReason(deviceType, cmd, commandType) {
|
|
226
|
-
if (commandType === 'customize')
|
|
227
|
-
return null;
|
|
228
|
-
if (!deviceType)
|
|
229
|
-
return null;
|
|
230
|
-
const match = findCatalogEntry(deviceType);
|
|
231
|
-
if (!match || Array.isArray(match))
|
|
232
|
-
return null;
|
|
233
|
-
const spec = match.commands.find((c) => c.command === cmd);
|
|
234
|
-
return spec ? getCommandSafetyReason(spec) : null;
|
|
235
|
-
}
|
|
236
|
-
/**
|
|
237
|
-
* Describe a device by id: metadata + catalog entry (if known) +
|
|
238
|
-
* optional live status. Throws `DeviceNotFoundError` when the id is unknown.
|
|
239
|
-
*/
|
|
240
|
-
export async function describeDevice(deviceId, options = {}, client) {
|
|
241
|
-
const body = await fetchDeviceList(client);
|
|
242
|
-
const { deviceList, infraredRemoteList } = body;
|
|
243
|
-
const physical = deviceList.find((d) => d.deviceId === deviceId);
|
|
244
|
-
const ir = infraredRemoteList.find((d) => d.deviceId === deviceId);
|
|
245
|
-
if (!physical && !ir)
|
|
246
|
-
throw new DeviceNotFoundError(deviceId);
|
|
247
|
-
const typeName = physical ? (physical.deviceType ?? '') : ir.remoteType;
|
|
248
|
-
const match = typeName ? findCatalogEntry(typeName) : null;
|
|
249
|
-
const catalogEntry = !match || Array.isArray(match) ? null : match;
|
|
250
|
-
let liveStatus;
|
|
251
|
-
if (options.live && physical) {
|
|
252
|
-
try {
|
|
253
|
-
liveStatus = await fetchDeviceStatus(deviceId, client);
|
|
254
|
-
}
|
|
255
|
-
catch (err) {
|
|
256
|
-
liveStatus = { error: err instanceof Error ? err.message : String(err) };
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
const source = catalogEntry
|
|
260
|
-
? liveStatus
|
|
261
|
-
? 'catalog+live'
|
|
262
|
-
: 'catalog'
|
|
263
|
-
: liveStatus
|
|
264
|
-
? 'live'
|
|
265
|
-
: 'none';
|
|
266
|
-
const capabilities = catalogEntry
|
|
267
|
-
? {
|
|
268
|
-
role: catalogEntry.role ?? null,
|
|
269
|
-
readOnly: catalogEntry.readOnly ?? false,
|
|
270
|
-
commands: catalogEntry.commands.map((c) => {
|
|
271
|
-
const tier = deriveSafetyTier(c, catalogEntry);
|
|
272
|
-
const reason = getCommandSafetyReason(c);
|
|
273
|
-
return {
|
|
274
|
-
...c,
|
|
275
|
-
safetyTier: tier,
|
|
276
|
-
...(reason ? { safetyReason: reason } : {}),
|
|
277
|
-
};
|
|
278
|
-
}),
|
|
279
|
-
statusFields: catalogEntry.statusFields ?? [],
|
|
280
|
-
...(liveStatus !== undefined ? { liveStatus } : {}),
|
|
281
|
-
}
|
|
282
|
-
: liveStatus !== undefined
|
|
283
|
-
? { liveStatus }
|
|
284
|
-
: null;
|
|
285
|
-
return {
|
|
286
|
-
device: (physical ?? ir),
|
|
287
|
-
isPhysical: Boolean(physical),
|
|
288
|
-
typeName,
|
|
289
|
-
controlType: physical?.controlType ?? ir?.controlType ?? null,
|
|
290
|
-
catalog: catalogEntry,
|
|
291
|
-
capabilities,
|
|
292
|
-
source,
|
|
293
|
-
suggestedActions: catalogEntry ? suggestedActions(catalogEntry) : [],
|
|
294
|
-
inheritedLocation: ir ? buildHubLocationMap(deviceList).get(ir.hubDeviceId) : undefined,
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
/** Build a map from hubDeviceId → room/family/roomID for IR-remote inheritance. */
|
|
298
|
-
export function buildHubLocationMap(deviceList) {
|
|
299
|
-
const map = new Map();
|
|
300
|
-
for (const d of deviceList) {
|
|
301
|
-
if (!d.deviceId)
|
|
302
|
-
continue;
|
|
303
|
-
map.set(d.deviceId, {
|
|
304
|
-
family: d.familyName ?? undefined,
|
|
305
|
-
room: d.roomName ?? undefined,
|
|
306
|
-
roomID: d.roomID ?? undefined,
|
|
307
|
-
});
|
|
308
|
-
}
|
|
309
|
-
return map;
|
|
310
|
-
}
|
|
311
|
-
/**
|
|
312
|
-
* Search the local catalog by type name / alias. Returns up to `limit`
|
|
313
|
-
* entries whose type or alias contains the query (case-insensitive).
|
|
314
|
-
* Intended for MCP's `search_catalog` tool — not for dispatching commands.
|
|
315
|
-
*/
|
|
316
|
-
export function searchCatalog(query, limit = 20) {
|
|
317
|
-
const catalog = getEffectiveCatalog();
|
|
318
|
-
const q = query.trim().toLowerCase();
|
|
319
|
-
if (!q)
|
|
320
|
-
return catalog.slice(0, limit);
|
|
321
|
-
const hits = [];
|
|
322
|
-
for (const entry of catalog) {
|
|
323
|
-
const haystack = [entry.type, ...(entry.aliases ?? [])].map((s) => s.toLowerCase());
|
|
324
|
-
if (haystack.some((h) => h.includes(q))) {
|
|
325
|
-
hits.push(entry);
|
|
326
|
-
if (hits.length >= limit)
|
|
327
|
-
break;
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
return hits;
|
|
331
|
-
}
|
|
332
|
-
/** Convert a DescribeResult to the shape validated by the MCP outputSchema. */
|
|
333
|
-
export function toMcpDescribeShape(r) {
|
|
334
|
-
const d = r.device;
|
|
335
|
-
return {
|
|
336
|
-
device: {
|
|
337
|
-
deviceId: d.deviceId,
|
|
338
|
-
deviceName: d.deviceName,
|
|
339
|
-
...(d.deviceType !== undefined ? { deviceType: d.deviceType } : {}),
|
|
340
|
-
...('enableCloudService' in d ? { enableCloudService: d.enableCloudService } : {}),
|
|
341
|
-
...(d.hubDeviceId !== undefined ? { hubDeviceId: d.hubDeviceId } : {}),
|
|
342
|
-
...(d.roomID !== undefined ? { roomID: d.roomID } : {}),
|
|
343
|
-
...(d.roomName !== undefined ? { roomName: d.roomName } : {}),
|
|
344
|
-
...(d.familyName !== undefined ? { familyName: d.familyName } : {}),
|
|
345
|
-
...(d.controlType !== undefined ? { controlType: d.controlType } : {}),
|
|
346
|
-
...('remoteType' in d && d.remoteType !== undefined ? { remoteType: d.remoteType } : {}),
|
|
347
|
-
},
|
|
348
|
-
isPhysical: r.isPhysical,
|
|
349
|
-
typeName: r.typeName,
|
|
350
|
-
controlType: r.controlType,
|
|
351
|
-
source: r.source,
|
|
352
|
-
capabilities: r.capabilities,
|
|
353
|
-
suggestedActions: r.suggestedActions,
|
|
354
|
-
...(r.inheritedLocation !== undefined
|
|
355
|
-
? { inheritedLocation: { family: r.inheritedLocation.family, room: r.inheritedLocation.room } }
|
|
356
|
-
: {}),
|
|
357
|
-
};
|
|
358
|
-
}
|
|
359
|
-
/** Shapes the device list body for the MCP list_devices outputSchema. */
|
|
360
|
-
export function toMcpDeviceListShape(d) {
|
|
361
|
-
return {
|
|
362
|
-
deviceId: d.deviceId,
|
|
363
|
-
deviceName: d.deviceName,
|
|
364
|
-
deviceType: d.deviceType,
|
|
365
|
-
enableCloudService: d.enableCloudService,
|
|
366
|
-
hubDeviceId: d.hubDeviceId,
|
|
367
|
-
roomID: d.roomID,
|
|
368
|
-
roomName: d.roomName,
|
|
369
|
-
familyName: d.familyName,
|
|
370
|
-
controlType: d.controlType,
|
|
371
|
-
};
|
|
372
|
-
}
|
|
373
|
-
/** Shapes an infrared remote for the MCP list_devices outputSchema. */
|
|
374
|
-
export function toMcpIrDeviceShape(d) {
|
|
375
|
-
return {
|
|
376
|
-
deviceId: d.deviceId,
|
|
377
|
-
deviceName: d.deviceName,
|
|
378
|
-
remoteType: d.remoteType,
|
|
379
|
-
hubDeviceId: d.hubDeviceId,
|
|
380
|
-
controlType: d.controlType,
|
|
381
|
-
};
|
|
382
|
-
}
|
package/dist/lib/idempotency.js
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* In-memory LRU cache for idempotent request deduplication.
|
|
3
|
-
* Caches the outcome of a keyed operation for 60 seconds;
|
|
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
|
-
*
|
|
12
|
-
* Process-local only — not shared across replicas.
|
|
13
|
-
*/
|
|
14
|
-
import crypto from 'node:crypto';
|
|
15
|
-
const DEFAULT_TTL_MS = 60000; // 60 seconds
|
|
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
|
-
}
|
|
44
|
-
export class IdempotencyCache {
|
|
45
|
-
cache = new Map();
|
|
46
|
-
ttlMs;
|
|
47
|
-
maxEntries;
|
|
48
|
-
constructor(ttlMs, maxEntries) {
|
|
49
|
-
this.ttlMs = ttlMs ?? DEFAULT_TTL_MS;
|
|
50
|
-
this.maxEntries = maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* Execute fn if the key is not cached, or return the cached result if it is.
|
|
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.
|
|
62
|
-
*/
|
|
63
|
-
async run(key, fn, shape) {
|
|
64
|
-
if (!key) {
|
|
65
|
-
const result = await fn();
|
|
66
|
-
return { result, replayed: false };
|
|
67
|
-
}
|
|
68
|
-
const hashed = hashKey(key);
|
|
69
|
-
const now = Date.now();
|
|
70
|
-
const cached = this.cache.get(hashed);
|
|
71
|
-
const currentShape = shape ? shapeSignature(shape.command, shape.parameter) : '*';
|
|
72
|
-
if (cached && cached.expiresAt > now) {
|
|
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 };
|
|
77
|
-
}
|
|
78
|
-
const result = await fn();
|
|
79
|
-
if (this.cache.size >= this.maxEntries) {
|
|
80
|
-
const toRemove = Math.ceil(this.maxEntries * 0.1);
|
|
81
|
-
let removed = 0;
|
|
82
|
-
for (const [k, v] of this.cache.entries()) {
|
|
83
|
-
if (removed >= toRemove)
|
|
84
|
-
break;
|
|
85
|
-
if (v.expiresAt <= now) {
|
|
86
|
-
this.cache.delete(k);
|
|
87
|
-
removed++;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
if (this.cache.size >= this.maxEntries) {
|
|
91
|
-
const firstKey = this.cache.keys().next().value;
|
|
92
|
-
if (firstKey)
|
|
93
|
-
this.cache.delete(firstKey);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
this.cache.set(hashed, { result, expiresAt: now + this.ttlMs, shape: currentShape });
|
|
97
|
-
return { result, replayed: false };
|
|
98
|
-
}
|
|
99
|
-
clear() {
|
|
100
|
-
this.cache.clear();
|
|
101
|
-
}
|
|
102
|
-
size() {
|
|
103
|
-
return this.cache.size;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
export const idempotencyCache = new IdempotencyCache();
|
package/dist/lib/plan-store.js
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import os from 'node:os';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { randomUUID } from 'node:crypto';
|
|
5
|
-
export const PLANS_DIR = path.join(os.homedir(), '.switchbot', 'plans');
|
|
6
|
-
function ensurePlansDir() {
|
|
7
|
-
fs.mkdirSync(PLANS_DIR, { recursive: true, mode: 0o700 });
|
|
8
|
-
}
|
|
9
|
-
function planPath(planId) {
|
|
10
|
-
return path.join(PLANS_DIR, `${planId}.json`);
|
|
11
|
-
}
|
|
12
|
-
const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
13
|
-
function assertValidPlanId(planId) {
|
|
14
|
-
if (!UUID_V4_RE.test(planId)) {
|
|
15
|
-
throw new Error(`invalid planId: ${planId}`);
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
export function savePlanRecord(plan) {
|
|
19
|
-
ensurePlansDir();
|
|
20
|
-
const record = {
|
|
21
|
-
planId: randomUUID(),
|
|
22
|
-
createdAt: new Date().toISOString(),
|
|
23
|
-
status: 'pending',
|
|
24
|
-
plan,
|
|
25
|
-
};
|
|
26
|
-
fs.writeFileSync(planPath(record.planId), JSON.stringify(record, null, 2), { mode: 0o600 });
|
|
27
|
-
return record;
|
|
28
|
-
}
|
|
29
|
-
export function loadPlanRecord(planId) {
|
|
30
|
-
assertValidPlanId(planId);
|
|
31
|
-
try {
|
|
32
|
-
const raw = fs.readFileSync(planPath(planId), 'utf-8');
|
|
33
|
-
return JSON.parse(raw);
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
export function updatePlanRecord(planId, updates) {
|
|
40
|
-
assertValidPlanId(planId);
|
|
41
|
-
const record = loadPlanRecord(planId);
|
|
42
|
-
if (!record)
|
|
43
|
-
throw new Error(`Plan ${planId} not found in ${PLANS_DIR}`);
|
|
44
|
-
const updated = { ...record, ...updates };
|
|
45
|
-
fs.writeFileSync(planPath(planId), JSON.stringify(updated, null, 2), { mode: 0o600 });
|
|
46
|
-
return updated;
|
|
47
|
-
}
|
|
48
|
-
export function listPlanRecords() {
|
|
49
|
-
try {
|
|
50
|
-
if (!fs.existsSync(PLANS_DIR))
|
|
51
|
-
return [];
|
|
52
|
-
return fs
|
|
53
|
-
.readdirSync(PLANS_DIR)
|
|
54
|
-
.filter((f) => f.endsWith('.json'))
|
|
55
|
-
.flatMap((f) => {
|
|
56
|
-
try {
|
|
57
|
-
return [JSON.parse(fs.readFileSync(path.join(PLANS_DIR, f), 'utf-8'))];
|
|
58
|
-
}
|
|
59
|
-
catch {
|
|
60
|
-
return [];
|
|
61
|
-
}
|
|
62
|
-
})
|
|
63
|
-
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
return [];
|
|
67
|
-
}
|
|
68
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
-
import { getProfile } from '../utils/flags.js';
|
|
3
|
-
export const requestContext = new AsyncLocalStorage();
|
|
4
|
-
export function withRequestContext(ctx, fn) {
|
|
5
|
-
return requestContext.run(ctx, fn);
|
|
6
|
-
}
|
|
7
|
-
export function getActiveProfile() {
|
|
8
|
-
const ctx = requestContext.getStore();
|
|
9
|
-
if (ctx?.profile !== undefined)
|
|
10
|
-
return ctx.profile;
|
|
11
|
-
return getProfile();
|
|
12
|
-
}
|
package/dist/lib/scenes.js
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { createClient } from '../api/client.js';
|
|
2
|
-
export async function fetchScenes(client) {
|
|
3
|
-
const c = client ?? createClient();
|
|
4
|
-
const res = await c.get('/v1.1/scenes');
|
|
5
|
-
return res.data.body;
|
|
6
|
-
}
|
|
7
|
-
export async function executeScene(sceneId, client) {
|
|
8
|
-
const c = client ?? createClient();
|
|
9
|
-
await c.post(`/v1.1/scenes/${sceneId}/execute`);
|
|
10
|
-
}
|
package/dist/logger.js
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import pino from 'pino';
|
|
2
|
-
const logLevel = process.env.LOG_LEVEL || 'warn';
|
|
3
|
-
const logFormat = process.env.LOG_FORMAT || 'json';
|
|
4
|
-
const pinoConfig = {
|
|
5
|
-
level: logLevel,
|
|
6
|
-
transport: logFormat === 'pretty'
|
|
7
|
-
? { target: 'pino-pretty' }
|
|
8
|
-
: undefined,
|
|
9
|
-
};
|
|
10
|
-
export const log = pino(pinoConfig);
|
|
11
|
-
export function setLogLevel(level) {
|
|
12
|
-
log.level = level;
|
|
13
|
-
}
|
|
14
|
-
export function getLogLevel() {
|
|
15
|
-
return log.level;
|
|
16
|
-
}
|