@switchbot/openapi-cli 3.1.1 → 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.
Files changed (113) hide show
  1. package/README.md +3 -3
  2. package/dist/index.js +56945 -169
  3. package/dist/policy/schema/v0.2.json +1 -1
  4. package/package.json +3 -2
  5. package/dist/api/client.js +0 -235
  6. package/dist/auth.js +0 -20
  7. package/dist/commands/agent-bootstrap.js +0 -182
  8. package/dist/commands/auth.js +0 -354
  9. package/dist/commands/batch.js +0 -413
  10. package/dist/commands/cache.js +0 -126
  11. package/dist/commands/capabilities.js +0 -385
  12. package/dist/commands/catalog.js +0 -359
  13. package/dist/commands/completion.js +0 -385
  14. package/dist/commands/config.js +0 -376
  15. package/dist/commands/daemon.js +0 -410
  16. package/dist/commands/device-meta.js +0 -159
  17. package/dist/commands/devices.js +0 -948
  18. package/dist/commands/doctor.js +0 -1015
  19. package/dist/commands/events.js +0 -563
  20. package/dist/commands/expand.js +0 -130
  21. package/dist/commands/explain.js +0 -139
  22. package/dist/commands/health.js +0 -113
  23. package/dist/commands/history.js +0 -320
  24. package/dist/commands/identity.js +0 -59
  25. package/dist/commands/install.js +0 -246
  26. package/dist/commands/mcp.js +0 -2017
  27. package/dist/commands/plan.js +0 -653
  28. package/dist/commands/policy.js +0 -586
  29. package/dist/commands/quota.js +0 -78
  30. package/dist/commands/rules.js +0 -875
  31. package/dist/commands/scenes.js +0 -264
  32. package/dist/commands/schema.js +0 -177
  33. package/dist/commands/status-sync.js +0 -131
  34. package/dist/commands/uninstall.js +0 -237
  35. package/dist/commands/upgrade-check.js +0 -107
  36. package/dist/commands/watch.js +0 -194
  37. package/dist/commands/webhook.js +0 -182
  38. package/dist/config.js +0 -258
  39. package/dist/credentials/backends/file.js +0 -101
  40. package/dist/credentials/backends/linux.js +0 -129
  41. package/dist/credentials/backends/macos.js +0 -129
  42. package/dist/credentials/backends/windows.js +0 -215
  43. package/dist/credentials/keychain.js +0 -88
  44. package/dist/credentials/prime.js +0 -52
  45. package/dist/devices/cache.js +0 -293
  46. package/dist/devices/catalog.js +0 -767
  47. package/dist/devices/device-meta.js +0 -56
  48. package/dist/devices/history-agg.js +0 -138
  49. package/dist/devices/history-query.js +0 -181
  50. package/dist/devices/param-validator.js +0 -433
  51. package/dist/devices/resources.js +0 -270
  52. package/dist/install/default-steps.js +0 -257
  53. package/dist/install/preflight.js +0 -212
  54. package/dist/install/steps.js +0 -67
  55. package/dist/lib/command-keywords.js +0 -17
  56. package/dist/lib/daemon-state.js +0 -46
  57. package/dist/lib/destructive-mode.js +0 -12
  58. package/dist/lib/devices.js +0 -382
  59. package/dist/lib/idempotency.js +0 -106
  60. package/dist/lib/plan-store.js +0 -68
  61. package/dist/lib/request-context.js +0 -12
  62. package/dist/lib/scenes.js +0 -10
  63. package/dist/logger.js +0 -16
  64. package/dist/mcp/device-history.js +0 -145
  65. package/dist/mcp/events-subscription.js +0 -213
  66. package/dist/mqtt/client.js +0 -180
  67. package/dist/mqtt/credential.js +0 -30
  68. package/dist/policy/add-rule.js +0 -124
  69. package/dist/policy/diff.js +0 -91
  70. package/dist/policy/format.js +0 -57
  71. package/dist/policy/load.js +0 -61
  72. package/dist/policy/migrate.js +0 -67
  73. package/dist/policy/schema.js +0 -18
  74. package/dist/policy/validate.js +0 -262
  75. package/dist/rules/action.js +0 -216
  76. package/dist/rules/audit-query.js +0 -89
  77. package/dist/rules/conflict-analyzer.js +0 -214
  78. package/dist/rules/cron-scheduler.js +0 -186
  79. package/dist/rules/destructive.js +0 -52
  80. package/dist/rules/engine.js +0 -757
  81. package/dist/rules/matcher.js +0 -230
  82. package/dist/rules/pid-file.js +0 -95
  83. package/dist/rules/quiet-hours.js +0 -45
  84. package/dist/rules/suggest.js +0 -95
  85. package/dist/rules/throttle.js +0 -116
  86. package/dist/rules/types.js +0 -34
  87. package/dist/rules/webhook-listener.js +0 -223
  88. package/dist/rules/webhook-token.js +0 -90
  89. package/dist/schema/field-aliases.js +0 -131
  90. package/dist/sinks/dispatcher.js +0 -12
  91. package/dist/sinks/file.js +0 -19
  92. package/dist/sinks/format.js +0 -56
  93. package/dist/sinks/homeassistant.js +0 -44
  94. package/dist/sinks/openclaw.js +0 -33
  95. package/dist/sinks/stdout.js +0 -5
  96. package/dist/sinks/telegram.js +0 -28
  97. package/dist/sinks/types.js +0 -1
  98. package/dist/sinks/webhook.js +0 -22
  99. package/dist/status-sync/manager.js +0 -268
  100. package/dist/utils/arg-parsers.js +0 -66
  101. package/dist/utils/audit.js +0 -121
  102. package/dist/utils/filter.js +0 -189
  103. package/dist/utils/flags.js +0 -186
  104. package/dist/utils/format.js +0 -117
  105. package/dist/utils/health.js +0 -101
  106. package/dist/utils/help-json.js +0 -54
  107. package/dist/utils/name-resolver.js +0 -137
  108. package/dist/utils/output.js +0 -404
  109. package/dist/utils/quota.js +0 -227
  110. package/dist/utils/redact.js +0 -68
  111. package/dist/utils/retry.js +0 -140
  112. package/dist/utils/string.js +0 -22
  113. package/dist/version.js +0 -4
@@ -1,268 +0,0 @@
1
- import { spawn, spawnSync } from 'node:child_process';
2
- import fs from 'node:fs';
3
- import os from 'node:os';
4
- import path from 'node:path';
5
- import { tryLoadConfig } from '../config.js';
6
- import { getActiveProfile } from '../lib/request-context.js';
7
- import { UsageError } from '../utils/output.js';
8
- import { getConfigPath } from '../utils/flags.js';
9
- const DEFAULT_OPENCLAW_URL = 'http://localhost:18789';
10
- function resolveStatusSyncRuntime(options) {
11
- if (!tryLoadConfig()) {
12
- throw new UsageError('No credentials found. Run \'switchbot config set-token\' or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET.');
13
- }
14
- const openclawToken = options.openclawToken ?? process.env.OPENCLAW_TOKEN;
15
- if (!openclawToken) {
16
- throw new UsageError('--openclaw-token is required or set OPENCLAW_TOKEN in the environment.');
17
- }
18
- const openclawModel = options.openclawModel ?? process.env.OPENCLAW_MODEL;
19
- if (!openclawModel) {
20
- throw new UsageError('--openclaw-model is required or set OPENCLAW_MODEL in the environment.');
21
- }
22
- return {
23
- openclawUrl: options.openclawUrl ?? process.env.OPENCLAW_URL ?? DEFAULT_OPENCLAW_URL,
24
- openclawToken,
25
- openclawModel,
26
- ...(options.topic ? { topic: options.topic } : {}),
27
- };
28
- }
29
- export function resolveStatusSyncPaths(explicitStateDir) {
30
- const stateDir = path.resolve(explicitStateDir
31
- ?? process.env.SWITCHBOT_STATUS_SYNC_HOME
32
- ?? path.join(os.homedir(), '.switchbot', 'status-sync'));
33
- return {
34
- stateDir,
35
- stateFile: path.join(stateDir, 'state.json'),
36
- stdoutLog: path.join(stateDir, 'stdout.log'),
37
- stderrLog: path.join(stateDir, 'stderr.log'),
38
- };
39
- }
40
- export function buildStatusSyncChildArgs(options) {
41
- const scriptPath = process.argv[1];
42
- if (!scriptPath) {
43
- throw new Error('Cannot determine the current CLI entrypoint path.');
44
- }
45
- const args = [path.resolve(scriptPath)];
46
- const configPath = getConfigPath();
47
- const profile = getActiveProfile();
48
- if (configPath) {
49
- args.push('--config', path.resolve(configPath));
50
- }
51
- else if (profile) {
52
- args.push('--profile', profile);
53
- }
54
- args.push('events', 'mqtt-tail', '--sink', 'openclaw', '--openclaw-url', options.openclawUrl, '--openclaw-model', options.openclawModel);
55
- if (options.topic) {
56
- args.push('--topic', options.topic);
57
- }
58
- return args;
59
- }
60
- function safeUnlink(filePath) {
61
- try {
62
- fs.unlinkSync(filePath);
63
- }
64
- catch {
65
- // best-effort cleanup
66
- }
67
- }
68
- function isProcessRunning(pid) {
69
- try {
70
- process.kill(pid, 0);
71
- return true;
72
- }
73
- catch (err) {
74
- const code = err.code;
75
- if (code === 'EPERM')
76
- return true;
77
- return false;
78
- }
79
- }
80
- function readStateFile(paths) {
81
- if (!fs.existsSync(paths.stateFile))
82
- return null;
83
- try {
84
- const raw = JSON.parse(fs.readFileSync(paths.stateFile, 'utf-8'));
85
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
86
- safeUnlink(paths.stateFile);
87
- return null;
88
- }
89
- const parsed = raw;
90
- if (typeof parsed.pid !== 'number' ||
91
- !Number.isInteger(parsed.pid) ||
92
- parsed.pid < 1 ||
93
- typeof parsed.startedAt !== 'string' ||
94
- !Array.isArray(parsed.command) ||
95
- typeof parsed.stdoutLog !== 'string' ||
96
- typeof parsed.stderrLog !== 'string') {
97
- safeUnlink(paths.stateFile);
98
- return null;
99
- }
100
- return {
101
- pid: parsed.pid,
102
- startedAt: parsed.startedAt,
103
- command: parsed.command.map(String),
104
- openclawUrl: typeof parsed.openclawUrl === 'string' ? parsed.openclawUrl : DEFAULT_OPENCLAW_URL,
105
- openclawModel: typeof parsed.openclawModel === 'string' ? parsed.openclawModel : '',
106
- topic: typeof parsed.topic === 'string' ? parsed.topic : null,
107
- configPath: typeof parsed.configPath === 'string' ? parsed.configPath : null,
108
- profile: typeof parsed.profile === 'string' ? parsed.profile : null,
109
- stdoutLog: parsed.stdoutLog,
110
- stderrLog: parsed.stderrLog,
111
- };
112
- }
113
- catch {
114
- safeUnlink(paths.stateFile);
115
- return null;
116
- }
117
- }
118
- function toStatus(paths, state, running) {
119
- return {
120
- running,
121
- pid: running && state ? state.pid : null,
122
- startedAt: running && state ? state.startedAt : null,
123
- stateDir: paths.stateDir,
124
- stateFile: paths.stateFile,
125
- stdoutLog: state?.stdoutLog ?? paths.stdoutLog,
126
- stderrLog: state?.stderrLog ?? paths.stderrLog,
127
- command: running && state ? state.command : null,
128
- openclawUrl: running && state ? state.openclawUrl : null,
129
- openclawModel: running && state ? state.openclawModel : null,
130
- topic: running && state ? state.topic : null,
131
- configPath: running && state ? state.configPath : null,
132
- profile: running && state ? state.profile : null,
133
- };
134
- }
135
- function killProcessTree(pid) {
136
- if (process.platform === 'win32') {
137
- const result = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
138
- if (result.error)
139
- throw result.error;
140
- if (result.status !== 0 && isProcessRunning(pid)) {
141
- throw new Error(`Failed to stop status-sync process tree (PID ${pid}).`);
142
- }
143
- return;
144
- }
145
- try {
146
- process.kill(-pid, 'SIGTERM');
147
- }
148
- catch (err) {
149
- const code = err.code;
150
- if (code === 'ESRCH') {
151
- return;
152
- }
153
- process.kill(pid, 'SIGTERM');
154
- }
155
- }
156
- export function getStatusSyncStatus(options = {}) {
157
- const paths = resolveStatusSyncPaths(options.stateDir);
158
- const state = readStateFile(paths);
159
- if (!state) {
160
- return toStatus(paths, null, false);
161
- }
162
- if (!isProcessRunning(state.pid)) {
163
- safeUnlink(paths.stateFile);
164
- return toStatus(paths, null, false);
165
- }
166
- return toStatus(paths, state, true);
167
- }
168
- export function stopStatusSync(options = {}) {
169
- const paths = resolveStatusSyncPaths(options.stateDir);
170
- const state = readStateFile(paths);
171
- if (!state) {
172
- return {
173
- stopped: false,
174
- stale: false,
175
- pid: null,
176
- status: toStatus(paths, null, false),
177
- };
178
- }
179
- if (!isProcessRunning(state.pid)) {
180
- safeUnlink(paths.stateFile);
181
- return {
182
- stopped: false,
183
- stale: true,
184
- pid: state.pid,
185
- status: toStatus(paths, null, false),
186
- };
187
- }
188
- killProcessTree(state.pid);
189
- if (isProcessRunning(state.pid)) {
190
- throw new Error(`Failed to stop status-sync process (PID ${state.pid}); process is still running.`);
191
- }
192
- safeUnlink(paths.stateFile);
193
- return {
194
- stopped: true,
195
- stale: false,
196
- pid: state.pid,
197
- status: toStatus(paths, null, false),
198
- };
199
- }
200
- export function startStatusSync(options = {}) {
201
- const runtime = resolveStatusSyncRuntime(options);
202
- const paths = resolveStatusSyncPaths(options.stateDir);
203
- const existing = getStatusSyncStatus({ stateDir: paths.stateDir });
204
- if (existing.running) {
205
- if (!options.force) {
206
- throw new UsageError(`status-sync is already running (PID ${existing.pid}). Run 'switchbot status-sync stop' first or re-run with --force.`);
207
- }
208
- stopStatusSync({ stateDir: paths.stateDir });
209
- }
210
- fs.mkdirSync(paths.stateDir, { recursive: true });
211
- const configPath = getConfigPath();
212
- const command = buildStatusSyncChildArgs(runtime);
213
- let stdoutFd = null;
214
- let stderrFd = null;
215
- try {
216
- stdoutFd = fs.openSync(paths.stdoutLog, 'a');
217
- stderrFd = fs.openSync(paths.stderrLog, 'a');
218
- const child = spawn(process.execPath, command, {
219
- detached: true,
220
- stdio: ['ignore', stdoutFd, stderrFd],
221
- windowsHide: true,
222
- env: { ...process.env, OPENCLAW_TOKEN: runtime.openclawToken },
223
- });
224
- if (!child.pid) {
225
- throw new Error('Failed to start status-sync child process.');
226
- }
227
- child.unref();
228
- const state = {
229
- pid: child.pid,
230
- startedAt: new Date().toISOString(),
231
- command: [process.execPath, ...command],
232
- openclawUrl: runtime.openclawUrl,
233
- openclawModel: runtime.openclawModel,
234
- topic: runtime.topic ?? null,
235
- configPath: configPath ? path.resolve(configPath) : null,
236
- profile: configPath ? null : (getActiveProfile() ?? null),
237
- stdoutLog: paths.stdoutLog,
238
- stderrLog: paths.stderrLog,
239
- };
240
- fs.writeFileSync(paths.stateFile, JSON.stringify(state, null, 2), { mode: 0o600 });
241
- return toStatus(paths, state, true);
242
- }
243
- finally {
244
- if (stdoutFd !== null)
245
- fs.closeSync(stdoutFd);
246
- if (stderrFd !== null)
247
- fs.closeSync(stderrFd);
248
- }
249
- }
250
- export async function runStatusSyncForeground(options = {}) {
251
- const runtime = resolveStatusSyncRuntime(options);
252
- const command = buildStatusSyncChildArgs(runtime);
253
- return await new Promise((resolve, reject) => {
254
- const child = spawn(process.execPath, command, {
255
- stdio: 'inherit',
256
- windowsHide: true,
257
- env: { ...process.env, OPENCLAW_TOKEN: runtime.openclawToken },
258
- });
259
- child.once('error', reject);
260
- child.once('exit', (code, signal) => {
261
- if (signal) {
262
- resolve(1);
263
- return;
264
- }
265
- resolve(code ?? 0);
266
- });
267
- });
268
- }
@@ -1,66 +0,0 @@
1
- import { InvalidArgumentError } from 'commander';
2
- import { parseDurationToMs } from './flags.js';
3
- /**
4
- * Commander argParser callbacks that fail fast when a required-value flag
5
- * swallows the next token (another flag, a subcommand name, etc.) — the
6
- * default Commander behavior is to take the next argv token verbatim.
7
- *
8
- * Use `--flag=<val>` form to pass values that legitimately start with `--`.
9
- */
10
- export function intArg(flagName, opts) {
11
- return (value) => {
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)) {
16
- throw new InvalidArgumentError(`${flagName} requires a numeric value, got "${value}". ` +
17
- `Did you forget a value? Use ${flagName}=<n> if the value really starts with "-".`);
18
- }
19
- const n = Number(value);
20
- if (!Number.isInteger(n)) {
21
- throw new InvalidArgumentError(`${flagName} must be an integer (got "${value}")`);
22
- }
23
- if (opts?.min !== undefined && n < opts.min) {
24
- throw new InvalidArgumentError(`${flagName} must be >= ${opts.min} (got "${value}")`);
25
- }
26
- if (opts?.max !== undefined && n > opts.max) {
27
- throw new InvalidArgumentError(`${flagName} must be <= ${opts.max} (got "${value}")`);
28
- }
29
- return String(n);
30
- };
31
- }
32
- export function durationArg(flagName) {
33
- return (value) => {
34
- if (value.startsWith('-')) {
35
- throw new InvalidArgumentError(`${flagName} requires a duration value, got "${value}". ` +
36
- `Use ${flagName}=<dur> if the value really starts with "-".`);
37
- }
38
- const ms = parseDurationToMs(value);
39
- if (ms === null) {
40
- throw new InvalidArgumentError(`${flagName} must look like "30s", "1m", "500ms", "1h", "7d", "2w" ` +
41
- `(supported units: ms, s, m, h, d, w — got "${value}")`);
42
- }
43
- return value;
44
- };
45
- }
46
- export function stringArg(flagName, opts) {
47
- return (value) => {
48
- if (value.startsWith('--')) {
49
- throw new InvalidArgumentError(`${flagName} requires a value. "${value}" looks like another option — ` +
50
- `did you forget the value? Use ${flagName}=<val> if your value really starts with "--".`);
51
- }
52
- if (opts?.disallow?.includes(value)) {
53
- throw new InvalidArgumentError(`${flagName} requires a value but got "${value}", which is a subcommand name. ` +
54
- `Did you forget the value? Use ${flagName}=<val> or put ${flagName} after the subcommand.`);
55
- }
56
- return value;
57
- };
58
- }
59
- export function enumArg(flagName, allowed) {
60
- return (value) => {
61
- if (!allowed.includes(value)) {
62
- throw new InvalidArgumentError(`${flagName} must be one of: ${allowed.join(', ')} (got "${value}")`);
63
- }
64
- return value;
65
- };
66
- }
@@ -1,121 +0,0 @@
1
- import fs from 'node:fs';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
- import { getAuditLog } from './flags.js';
5
- export const DEFAULT_AUDIT_PATH = path.join(os.homedir(), '.switchbot', 'audit.log');
6
- /**
7
- * Bump when breaking changes to the audit line shape land.
8
- *
9
- * History:
10
- * 1 — initial command audit (kind: 'command' only).
11
- * 2 — adds rule-engine kinds ('rule-fire', 'rule-fire-dry',
12
- * 'rule-throttled', 'rule-webhook-rejected') and a sibling `rule`
13
- * block describing which rule fired and why. Reader stays backwards
14
- * compatible: v1 lines parse as command entries with `rule`
15
- * undefined.
16
- */
17
- export const AUDIT_VERSION = 2;
18
- function resolveAuditPath() {
19
- const flag = getAuditLog();
20
- if (flag === null)
21
- return null;
22
- return path.resolve(flag);
23
- }
24
- export function writeAudit(entry) {
25
- let file = resolveAuditPath();
26
- if (!file && entry.planId)
27
- file = DEFAULT_AUDIT_PATH;
28
- if (!file)
29
- return;
30
- const dir = path.dirname(file);
31
- try {
32
- if (!fs.existsSync(dir)) {
33
- fs.mkdirSync(dir, { recursive: true });
34
- }
35
- const stamped = { auditVersion: AUDIT_VERSION, ...entry };
36
- fs.appendFileSync(file, JSON.stringify(stamped) + '\n');
37
- }
38
- catch {
39
- // Best-effort — never let audit failures break the actual command.
40
- }
41
- }
42
- export function readAudit(file) {
43
- if (!fs.existsSync(file))
44
- return [];
45
- const raw = fs.readFileSync(file, 'utf-8');
46
- const out = [];
47
- for (const line of raw.split(/\r?\n/)) {
48
- const trimmed = line.trim();
49
- if (!trimmed)
50
- continue;
51
- try {
52
- out.push(JSON.parse(trimmed));
53
- }
54
- catch {
55
- // skip malformed lines
56
- }
57
- }
58
- return out;
59
- }
60
- export function verifyAudit(file) {
61
- const report = {
62
- file,
63
- totalLines: 0,
64
- parsedLines: 0,
65
- skippedBlankLines: 0,
66
- malformedLines: 0,
67
- unversionedEntries: 0,
68
- versionCounts: {},
69
- problems: [],
70
- };
71
- if (!fs.existsSync(file)) {
72
- report.fileMissing = true;
73
- return report;
74
- }
75
- const raw = fs.readFileSync(file, 'utf-8');
76
- const lines = raw.split(/\r?\n/);
77
- let minT;
78
- let maxT;
79
- for (let i = 0; i < lines.length; i++) {
80
- const trimmed = lines[i].trim();
81
- if (!trimmed) {
82
- report.skippedBlankLines++;
83
- continue;
84
- }
85
- report.totalLines++;
86
- let entry = null;
87
- try {
88
- entry = JSON.parse(trimmed);
89
- }
90
- catch {
91
- report.malformedLines++;
92
- report.problems.push({
93
- line: i + 1,
94
- reason: 'JSON parse failed',
95
- preview: trimmed.slice(0, 80),
96
- });
97
- continue;
98
- }
99
- report.parsedLines++;
100
- const v = entry.auditVersion;
101
- if (v === undefined) {
102
- report.unversionedEntries++;
103
- report.versionCounts['unversioned'] = (report.versionCounts['unversioned'] ?? 0) + 1;
104
- }
105
- else {
106
- const key = String(v);
107
- report.versionCounts[key] = (report.versionCounts[key] ?? 0) + 1;
108
- }
109
- if (entry.t) {
110
- if (!minT || entry.t < minT)
111
- minT = entry.t;
112
- if (!maxT || entry.t > maxT)
113
- maxT = entry.t;
114
- }
115
- }
116
- if (minT)
117
- report.earliest = minT;
118
- if (maxT)
119
- report.latest = maxT;
120
- return report;
121
- }
@@ -1,189 +0,0 @@
1
- export class FilterSyntaxError extends Error {
2
- constructor(message) {
3
- super(message);
4
- this.name = 'FilterSyntaxError';
5
- }
6
- }
7
- /**
8
- * Parse a comma-separated filter expression into discrete clauses.
9
- *
10
- * Grammar (per clause, recognition order):
11
- * 1. key=/pattern/ → regex (case-insensitive); invalid regex throws.
12
- * 2. key!=value → 'neq' op (negated substring; exact-negated for keys
13
- * listed in matchClause's `exactKeys` option).
14
- * 3. key~value → substring (case-insensitive).
15
- * 4. key=value → 'eq' op (substring; caller decides whether to treat
16
- * as exact for specific keys via matchClause's
17
- * `exactKeys` option).
18
- *
19
- * `allowedKeys` is command-specific: `devices list` uses
20
- * {type,name,category,room}; `devices batch` uses {type,family,room,category};
21
- * `events tail` uses {deviceId,type}.
22
- */
23
- export function parseFilterExpr(expr, allowedKeys, options) {
24
- if (!expr)
25
- return [];
26
- const parts = expr.split(',').map((p) => p.trim()).filter((p) => p.length > 0);
27
- const clauses = [];
28
- for (const part of parts) {
29
- const regexMatch = /^([^=~!]+)=\/(.*)\/$/.exec(part);
30
- const neqIdx = part.indexOf('!=');
31
- const tildeIdx = part.indexOf('~');
32
- const eqIdx = part.indexOf('=');
33
- let key;
34
- let op;
35
- let raw;
36
- let regex;
37
- if (regexMatch) {
38
- key = regexMatch[1].trim();
39
- op = 'regex';
40
- raw = regexMatch[2];
41
- try {
42
- regex = new RegExp(raw, 'i');
43
- }
44
- catch (err) {
45
- throw new FilterSyntaxError(`Invalid regex in --filter "${part}": ${err.message}`);
46
- }
47
- }
48
- else if (neqIdx !== -1 && (tildeIdx === -1 || neqIdx < tildeIdx)) {
49
- key = part.slice(0, neqIdx).trim();
50
- op = 'neq';
51
- raw = part.slice(neqIdx + 2).trim();
52
- }
53
- else if (tildeIdx !== -1 && (eqIdx === -1 || tildeIdx < eqIdx)) {
54
- key = part.slice(0, tildeIdx).trim();
55
- op = 'sub';
56
- raw = part.slice(tildeIdx + 1).trim();
57
- if (raw.startsWith('=')) {
58
- throw new FilterSyntaxError(`Invalid filter clause "${part}" — "~=" is no longer supported. Use "${key}~${raw.slice(1)}" instead.`);
59
- }
60
- }
61
- else if (eqIdx !== -1) {
62
- key = part.slice(0, eqIdx).trim();
63
- op = 'eq';
64
- raw = part.slice(eqIdx + 1).trim();
65
- }
66
- else {
67
- throw new FilterSyntaxError(`Invalid filter clause "${part}" — expected "<key>=<value>", "<key>!=<value>", "<key>~<value>", or "<key>=/<regex>/"`);
68
- }
69
- if (!key) {
70
- throw new FilterSyntaxError(`Empty key in filter clause "${part}"`);
71
- }
72
- if (!raw) {
73
- throw new FilterSyntaxError(`Empty value for filter clause "${part}"`);
74
- }
75
- let resolvedKey = key;
76
- if (options?.resolveKey) {
77
- try {
78
- resolvedKey = options.resolveKey(key);
79
- }
80
- catch (err) {
81
- if (err instanceof Error) {
82
- throw new FilterSyntaxError(err.message);
83
- }
84
- throw err;
85
- }
86
- }
87
- if (!allowedKeys.includes(resolvedKey)) {
88
- const printableKeys = options?.supportedKeys ?? allowedKeys;
89
- throw new FilterSyntaxError(`Unknown filter key "${key}" – supported: ${printableKeys.join(', ')}`);
90
- }
91
- clauses.push({ key: resolvedKey, op, raw, regex });
92
- }
93
- return clauses;
94
- }
95
- /**
96
- * Match a single candidate string against a clause.
97
- *
98
- * - `regex` → RegExp.test against the candidate (case-insensitive by construction).
99
- * - `sub` → case-insensitive substring.
100
- * - `eq` → case-insensitive substring, except for keys listed in
101
- * `exactKeys`, which get case-insensitive exact comparison.
102
- * Default `exactKeys` is `['category']` to preserve the existing
103
- * list/batch behavior for that key.
104
- * - `neq` → logical inverse of `eq` (negated substring; exact-negated for
105
- * `exactKeys`). `undefined` candidates remain non-matching so a
106
- * `neq` clause does NOT accidentally match missing data.
107
- */
108
- export function matchClause(candidate, clause, options) {
109
- if (candidate === undefined) {
110
- // Missing field: `neq` treats absence as "definitely not X"; everything
111
- // else treats it as "no evidence — don't match".
112
- return clause.op === 'neq';
113
- }
114
- if (clause.op === 'regex') {
115
- return clause.regex.test(candidate);
116
- }
117
- const cLower = candidate.toLowerCase();
118
- const vLower = clause.raw.toLowerCase();
119
- if (clause.op === 'sub') {
120
- return cLower.includes(vLower);
121
- }
122
- const exactKeys = options?.exactKeys ?? ['category'];
123
- const exact = exactKeys.includes(clause.key);
124
- if (clause.op === 'neq') {
125
- return exact ? cLower !== vLower : !cLower.includes(vLower);
126
- }
127
- if (exact) {
128
- return cLower === vLower;
129
- }
130
- return cLower.includes(vLower);
131
- }
132
- const BATCH_KEYS = ['type', 'family', 'room', 'category'];
133
- /**
134
- * Back-compat narrow signature: parses with the batch key set. Callers that
135
- * need a different key set (list, events tail) should call parseFilterExpr
136
- * directly.
137
- */
138
- export function parseFilter(expr) {
139
- return parseFilterExpr(expr, BATCH_KEYS);
140
- }
141
- /** Normalize a physical / IR device entry to the shape the filter matcher expects. */
142
- function toFilterable(d, isPhysical, hubLocation) {
143
- if (isPhysical) {
144
- const p = d;
145
- return {
146
- deviceId: p.deviceId,
147
- type: p.deviceType ?? '',
148
- family: p.familyName ?? undefined,
149
- room: p.roomName ?? undefined,
150
- category: 'physical',
151
- };
152
- }
153
- const ir = d;
154
- const inherited = hubLocation?.get(ir.hubDeviceId);
155
- return {
156
- deviceId: ir.deviceId,
157
- type: ir.remoteType ?? '',
158
- family: inherited?.family,
159
- room: inherited?.room,
160
- category: 'ir',
161
- };
162
- }
163
- function candidateFor(d, key) {
164
- switch (key) {
165
- case 'type':
166
- return d.type;
167
- case 'family':
168
- return d.family;
169
- case 'room':
170
- return d.room;
171
- case 'category':
172
- return d.category;
173
- default:
174
- return undefined;
175
- }
176
- }
177
- /**
178
- * Apply the parsed clauses to a mixed list of physical devices + IR remotes.
179
- * Returns the filterable entries that satisfy every clause.
180
- */
181
- export function applyFilter(clauses, deviceList, infraredRemoteList, hubLocation) {
182
- const candidates = [
183
- ...deviceList.map((d) => toFilterable(d, true)),
184
- ...infraredRemoteList.map((d) => toFilterable(d, false, hubLocation)),
185
- ];
186
- if (clauses.length === 0)
187
- return candidates;
188
- return candidates.filter((c) => clauses.every((clause) => matchClause(candidateFor(c, clause.key), clause)));
189
- }