@switchbot/openapi-cli 2.6.4 → 3.0.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 (67) hide show
  1. package/README.md +385 -103
  2. package/dist/api/client.js +13 -12
  3. package/dist/commands/agent-bootstrap.js +67 -16
  4. package/dist/commands/auth.js +354 -0
  5. package/dist/commands/batch.js +26 -21
  6. package/dist/commands/capabilities.js +29 -21
  7. package/dist/commands/catalog.js +4 -3
  8. package/dist/commands/config.js +57 -37
  9. package/dist/commands/devices.js +63 -37
  10. package/dist/commands/doctor.js +539 -26
  11. package/dist/commands/events.js +115 -26
  12. package/dist/commands/expand.js +7 -15
  13. package/dist/commands/explain.js +10 -7
  14. package/dist/commands/history.js +12 -18
  15. package/dist/commands/identity.js +59 -0
  16. package/dist/commands/install.js +246 -0
  17. package/dist/commands/mcp.js +895 -15
  18. package/dist/commands/plan.js +111 -15
  19. package/dist/commands/policy.js +469 -0
  20. package/dist/commands/rules.js +657 -0
  21. package/dist/commands/schema.js +20 -12
  22. package/dist/commands/status-sync.js +131 -0
  23. package/dist/commands/uninstall.js +237 -0
  24. package/dist/commands/watch.js +15 -2
  25. package/dist/config.js +14 -0
  26. package/dist/credentials/backends/file.js +101 -0
  27. package/dist/credentials/backends/linux.js +129 -0
  28. package/dist/credentials/backends/macos.js +129 -0
  29. package/dist/credentials/backends/windows.js +215 -0
  30. package/dist/credentials/keychain.js +88 -0
  31. package/dist/credentials/prime.js +52 -0
  32. package/dist/devices/catalog.js +118 -11
  33. package/dist/devices/resources.js +270 -0
  34. package/dist/index.js +39 -4
  35. package/dist/install/default-steps.js +257 -0
  36. package/dist/install/preflight.js +212 -0
  37. package/dist/install/steps.js +67 -0
  38. package/dist/lib/command-keywords.js +17 -0
  39. package/dist/lib/devices.js +15 -5
  40. package/dist/policy/add-rule.js +124 -0
  41. package/dist/policy/diff.js +91 -0
  42. package/dist/policy/examples/policy.example.yaml +99 -0
  43. package/dist/policy/format.js +57 -0
  44. package/dist/policy/load.js +61 -0
  45. package/dist/policy/migrate.js +67 -0
  46. package/dist/policy/schema/v0.2.json +302 -0
  47. package/dist/policy/schema.js +18 -0
  48. package/dist/policy/validate.js +262 -0
  49. package/dist/rules/action.js +205 -0
  50. package/dist/rules/audit-query.js +89 -0
  51. package/dist/rules/cron-scheduler.js +186 -0
  52. package/dist/rules/destructive.js +52 -0
  53. package/dist/rules/engine.js +567 -0
  54. package/dist/rules/matcher.js +230 -0
  55. package/dist/rules/pid-file.js +95 -0
  56. package/dist/rules/quiet-hours.js +45 -0
  57. package/dist/rules/suggest.js +95 -0
  58. package/dist/rules/throttle.js +78 -0
  59. package/dist/rules/types.js +34 -0
  60. package/dist/rules/webhook-listener.js +223 -0
  61. package/dist/rules/webhook-token.js +90 -0
  62. package/dist/schema/field-aliases.js +95 -0
  63. package/dist/status-sync/manager.js +268 -0
  64. package/dist/utils/audit.js +12 -2
  65. package/dist/utils/help-json.js +54 -0
  66. package/dist/utils/output.js +17 -0
  67. package/package.json +12 -4
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Local HTTP listener that delivers webhook events to the rules engine.
3
+ *
4
+ * Scope (E2):
5
+ * - Binds to `127.0.0.1` only — the loopback interface keeps the
6
+ * listener off the network by default. The plan's integration story
7
+ * is that an agent or local script POSTs to this endpoint.
8
+ * - Default port is 18790 (phase-3 design doc choice); override with
9
+ * `--webhook-port <n>` in `switchbot rules run`. `--webhook-port 0`
10
+ * asks the OS for an ephemeral port — useful in tests.
11
+ * - Bearer-token auth on every request: `Authorization: Bearer <t>`.
12
+ * The expected token comes from `WebhookTokenStore`; unauthorized
13
+ * requests get a 401 with no body, no hint about which header
14
+ * failed, and an audit entry (`rule-webhook-rejected`).
15
+ * - Matches request path against registered webhook rules: only
16
+ * `POST /path/exactly/as/declared`. Unknown paths return 404.
17
+ *
18
+ * Non-goals:
19
+ * - No TLS; operators who expose this outside loopback are expected
20
+ * to sit behind a reverse proxy that terminates TLS.
21
+ * - No payload parsing beyond reading the body as a string — the
22
+ * engine passes the raw body through in the event payload.
23
+ */
24
+ import http from 'node:http';
25
+ import { timingSafeEqual } from 'node:crypto';
26
+ import { writeAudit } from '../utils/audit.js';
27
+ import { isWebhookTrigger } from './types.js';
28
+ export const DEFAULT_WEBHOOK_PORT = 18790;
29
+ const MAX_BODY_BYTES = 16 * 1024; // guard against huge POSTs from misbehaving callers
30
+ export class WebhookListener {
31
+ opts;
32
+ server = null;
33
+ pathIndex = new Map();
34
+ actualPort = null;
35
+ constructor(opts) {
36
+ this.opts = opts;
37
+ for (const rule of opts.rules) {
38
+ if (!isWebhookTrigger(rule.when))
39
+ continue;
40
+ const normalised = normalisePath(rule.when.path);
41
+ if (this.pathIndex.has(normalised)) {
42
+ throw new Error(`WebhookListener: duplicate webhook path "${normalised}" — every webhook rule needs a unique path`);
43
+ }
44
+ this.pathIndex.set(normalised, rule);
45
+ }
46
+ }
47
+ /** Start listening. Resolves once the server has bound a port. */
48
+ async start() {
49
+ if (this.server)
50
+ return;
51
+ const server = http.createServer((req, res) => {
52
+ this.handle(req, res).catch((err) => {
53
+ // The dispatch chain should never reject — but if it does,
54
+ // make sure we close the socket so the caller doesn't hang.
55
+ if (!res.headersSent) {
56
+ res.writeHead(500);
57
+ res.end();
58
+ }
59
+ // eslint-disable-next-line no-console
60
+ console.error(`webhook-listener: unhandled dispatch error: ${err instanceof Error ? err.message : String(err)}`);
61
+ });
62
+ });
63
+ const host = this.opts.host ?? '127.0.0.1';
64
+ const port = this.opts.port ?? DEFAULT_WEBHOOK_PORT;
65
+ await new Promise((resolve, reject) => {
66
+ const onError = (err) => {
67
+ server.off('listening', onListening);
68
+ reject(err);
69
+ };
70
+ const onListening = () => {
71
+ server.off('error', onError);
72
+ resolve();
73
+ };
74
+ server.once('error', onError);
75
+ server.once('listening', onListening);
76
+ server.listen(port, host);
77
+ });
78
+ const address = server.address();
79
+ this.actualPort = typeof address === 'object' && address ? address.port : port;
80
+ this.server = server;
81
+ }
82
+ async stop() {
83
+ if (!this.server)
84
+ return;
85
+ const server = this.server;
86
+ this.server = null;
87
+ this.actualPort = null;
88
+ await new Promise((resolve) => server.close(() => resolve()));
89
+ }
90
+ getPort() {
91
+ return this.actualPort;
92
+ }
93
+ listPaths() {
94
+ return [...this.pathIndex.keys()].sort();
95
+ }
96
+ /**
97
+ * Replace the current rule → path index. Used by `engine.reload`: the
98
+ * listener keeps its open port and accepted connections, but routes
99
+ * subsequent requests against the fresh policy.
100
+ */
101
+ updateRules(rules) {
102
+ const next = new Map();
103
+ for (const rule of rules) {
104
+ if (!isWebhookTrigger(rule.when))
105
+ continue;
106
+ const normalised = normalisePath(rule.when.path);
107
+ if (next.has(normalised)) {
108
+ throw new Error(`WebhookListener.updateRules: duplicate webhook path "${normalised}"`);
109
+ }
110
+ next.set(normalised, rule);
111
+ }
112
+ this.pathIndex.clear();
113
+ for (const [k, v] of next)
114
+ this.pathIndex.set(k, v);
115
+ }
116
+ async handle(req, res) {
117
+ // Auth gate first — reject everything else so a wrong token never
118
+ // reveals which paths exist.
119
+ if (!this.isAuthorized(req)) {
120
+ writeAudit({
121
+ t: this.now().toISOString(),
122
+ kind: 'rule-webhook-rejected',
123
+ deviceId: 'unknown',
124
+ command: req.url ?? '',
125
+ parameter: null,
126
+ commandType: 'command',
127
+ dryRun: true,
128
+ result: 'error',
129
+ error: 'unauthorized',
130
+ });
131
+ res.writeHead(401);
132
+ res.end();
133
+ return;
134
+ }
135
+ if (req.method !== 'POST') {
136
+ res.writeHead(405, { Allow: 'POST' });
137
+ res.end();
138
+ return;
139
+ }
140
+ const reqUrl = req.url ?? '/';
141
+ const questionMarkIdx = reqUrl.indexOf('?');
142
+ const rawPath = questionMarkIdx === -1 ? reqUrl : reqUrl.slice(0, questionMarkIdx);
143
+ const normalised = normalisePath(rawPath);
144
+ const rule = this.pathIndex.get(normalised);
145
+ if (!rule) {
146
+ writeAudit({
147
+ t: this.now().toISOString(),
148
+ kind: 'rule-webhook-rejected',
149
+ deviceId: 'unknown',
150
+ command: rawPath,
151
+ parameter: null,
152
+ commandType: 'command',
153
+ dryRun: true,
154
+ result: 'error',
155
+ error: 'unknown-path',
156
+ });
157
+ res.writeHead(404);
158
+ res.end();
159
+ return;
160
+ }
161
+ const body = await readLimitedBody(req, MAX_BODY_BYTES);
162
+ if (body === null) {
163
+ res.writeHead(413);
164
+ res.end();
165
+ return;
166
+ }
167
+ const event = {
168
+ source: 'webhook',
169
+ event: normalised,
170
+ t: this.now(),
171
+ payload: { path: normalised, body },
172
+ };
173
+ // Accept the request before dispatch so callers aren't held waiting
174
+ // on rule actions (which can include SwitchBot API calls).
175
+ res.writeHead(202, { 'Content-Type': 'application/json' });
176
+ res.end(JSON.stringify({ status: 'accepted', path: normalised }));
177
+ this.opts.dispatch(rule, event).catch(() => undefined);
178
+ }
179
+ isAuthorized(req) {
180
+ const h = req.headers['authorization'];
181
+ if (typeof h !== 'string')
182
+ return false;
183
+ const match = /^Bearer\s+(.+)$/i.exec(h.trim());
184
+ if (!match)
185
+ return false;
186
+ const provided = Buffer.from(match[1].trim(), 'utf-8');
187
+ const expected = Buffer.from(this.opts.bearerToken, 'utf-8');
188
+ if (provided.length !== expected.length)
189
+ return false;
190
+ return timingSafeEqual(provided, expected);
191
+ }
192
+ now() {
193
+ return this.opts.now ? this.opts.now() : new Date();
194
+ }
195
+ }
196
+ function normalisePath(p) {
197
+ if (!p)
198
+ return '/';
199
+ let out = p.trim();
200
+ if (!out.startsWith('/'))
201
+ out = `/${out}`;
202
+ // Collapse a trailing slash (but leave the root '/').
203
+ if (out.length > 1 && out.endsWith('/'))
204
+ out = out.slice(0, -1);
205
+ return out;
206
+ }
207
+ function readLimitedBody(req, max) {
208
+ return new Promise((resolve, reject) => {
209
+ const chunks = [];
210
+ let total = 0;
211
+ req.on('data', (chunk) => {
212
+ total += chunk.length;
213
+ if (total > max) {
214
+ req.destroy();
215
+ resolve(null);
216
+ return;
217
+ }
218
+ chunks.push(chunk);
219
+ });
220
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
221
+ req.on('error', reject);
222
+ });
223
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Webhook bearer-token management for the rules engine.
3
+ *
4
+ * Responsibilities:
5
+ * - Resolve the bearer token the listener will accept. The order is
6
+ * env var (SWITCHBOT_WEBHOOK_TOKEN) → on-disk cache
7
+ * (~/.switchbot/webhook-token, chmod 0600) → generate a fresh
8
+ * 32-byte hex token and persist it.
9
+ * - Rotate the token on demand (`rules webhook-rotate-token` cli).
10
+ *
11
+ * Why not the OS keychain (F1 abstraction)? The webhook bearer is a
12
+ * single opaque string, whereas `CredentialStore` is shaped around the
13
+ * SwitchBot {token,secret} bundle. Fitting a one-field artifact into
14
+ * that contract bloats every profile; keeping it in a 0600 file gives
15
+ * the same protection the CLI has used for `~/.switchbot/config.json`.
16
+ * Promotion into the keychain is a future follow-up once the
17
+ * abstraction grows a generic single-value slot.
18
+ */
19
+ import fs from 'node:fs';
20
+ import os from 'node:os';
21
+ import path from 'node:path';
22
+ import { randomBytes } from 'node:crypto';
23
+ const ENV_TOKEN = 'SWITCHBOT_WEBHOOK_TOKEN';
24
+ const DEFAULT_FILE = '.switchbot/webhook-token';
25
+ export class WebhookTokenStore {
26
+ filePath;
27
+ envLookup;
28
+ constructor(opts = {}) {
29
+ this.filePath = opts.filePath ?? path.join(os.homedir(), DEFAULT_FILE);
30
+ this.envLookup = opts.envLookup ?? (() => process.env[ENV_TOKEN]);
31
+ }
32
+ /**
33
+ * Return a bearer token, creating + persisting one if none exists yet.
34
+ * Env var wins when set; otherwise the on-disk token is read (and
35
+ * generated on first call).
36
+ */
37
+ getOrCreate() {
38
+ const fromEnv = this.envLookup();
39
+ if (fromEnv && fromEnv.trim().length > 0)
40
+ return fromEnv.trim();
41
+ const existing = this.readFromDisk();
42
+ if (existing)
43
+ return existing;
44
+ const fresh = generateToken();
45
+ this.writeToDisk(fresh);
46
+ return fresh;
47
+ }
48
+ /**
49
+ * Read the persisted token, returning null when the file is absent
50
+ * or empty. Does NOT consult the env var — callers that want the
51
+ * env-aware path should use `getOrCreate()`.
52
+ */
53
+ readFromDisk() {
54
+ try {
55
+ const raw = fs.readFileSync(this.filePath, 'utf-8').trim();
56
+ return raw.length > 0 ? raw : null;
57
+ }
58
+ catch (err) {
59
+ if (err.code === 'ENOENT')
60
+ return null;
61
+ throw err;
62
+ }
63
+ }
64
+ /** Write a new token, persisting with 0600 perms. */
65
+ rotate() {
66
+ const fresh = generateToken();
67
+ this.writeToDisk(fresh);
68
+ return fresh;
69
+ }
70
+ getFilePath() {
71
+ return this.filePath;
72
+ }
73
+ writeToDisk(token) {
74
+ const dir = path.dirname(this.filePath);
75
+ fs.mkdirSync(dir, { recursive: true });
76
+ fs.writeFileSync(this.filePath, `${token}\n`, { mode: 0o600 });
77
+ // mkdirSync + writeFileSync race can leave broader perms on Windows
78
+ // (perm bits are mostly advisory there anyway), but on POSIX we
79
+ // re-chmod to be explicit about intent.
80
+ try {
81
+ fs.chmodSync(this.filePath, 0o600);
82
+ }
83
+ catch {
84
+ // non-POSIX filesystems may reject chmod — intentional best effort.
85
+ }
86
+ }
87
+ }
88
+ export function generateToken() {
89
+ return randomBytes(32).toString('hex');
90
+ }
@@ -1,5 +1,20 @@
1
1
  import { UsageError } from '../utils/output.js';
2
+ /**
3
+ * User-facing aliases for canonical field names.
4
+ *
5
+ * Keys are canonical names (matching API response keys and CLI/schema output);
6
+ * values are lowercase alternatives a user may type for `--fields` or `--filter`.
7
+ *
8
+ * Conflict rules (do not add an alias that violates these — tests will fail):
9
+ * - `temp` is exclusive to `temperature` (NOT `colorTemperature`, `targetTemperature`).
10
+ * - `motion` is exclusive to `moveDetected`; `moving` uses `active` instead.
11
+ * - `mode` is exclusive to top-level `mode` (preset); device-specific modes go through `deviceMode`.
12
+ * - Reserved / too-generic words never appear as aliases: `auto`, `status`, `state`,
13
+ * `switch`, `type`, `on`, `off`.
14
+ * - Device-type words are never aliases: `lock`, `fan`.
15
+ */
2
16
  export const FIELD_ALIASES = {
17
+ // Identification (shared with list/filter)
3
18
  deviceId: ['id'],
4
19
  deviceName: ['name'],
5
20
  deviceType: ['type'],
@@ -10,7 +25,71 @@ export const FIELD_ALIASES = {
10
25
  hubDeviceId: ['hub'],
11
26
  enableCloudService: ['cloud'],
12
27
  alias: ['alias'],
28
+ // Phase 1 — common status fields
29
+ battery: ['batt', 'bat'],
30
+ temperature: ['temp', 'ambient'],
31
+ colorTemperature: ['kelvin', 'colortemp'],
32
+ humidity: ['humid', 'rh'],
33
+ brightness: ['bright', 'bri'],
34
+ fanSpeed: ['speed'],
35
+ position: ['pos'],
36
+ moveDetected: ['motion'],
37
+ openState: ['open'],
38
+ doorState: ['door'],
39
+ CO2: ['co2'],
40
+ power: ['enabled'],
41
+ mode: ['preset'],
42
+ // Phase 2 — niche device fields
43
+ childLock: ['safe', 'childlock'],
44
+ targetTemperature: ['setpoint', 'target'],
45
+ electricCurrent: ['current', 'amps'],
46
+ voltage: ['volts'],
47
+ usedElectricity: ['energy', 'kwh'],
48
+ electricityOfDay: ['daily', 'today'],
49
+ weight: ['load'],
50
+ version: ['firmware', 'fw'],
51
+ lightLevel: ['light', 'lux'],
52
+ oscillation: ['swing', 'osc'],
53
+ verticalOscillation: ['vswing'],
54
+ nightStatus: ['night'],
55
+ chargingStatus: ['charging', 'charge'],
56
+ switch1Status: ['ch1', 'channel1'],
57
+ switch2Status: ['ch2', 'channel2'],
58
+ taskType: ['task'],
59
+ moving: ['active'],
60
+ onlineStatus: ['online_status'],
61
+ workingStatus: ['working'],
62
+ // Phase 3 — catalog statusFields coverage
63
+ group: ['cluster'],
64
+ calibrate: ['calibration', 'calib'],
65
+ direction: ['tilt'],
66
+ deviceMode: ['devmode'],
67
+ nebulizationEfficiency: ['mist', 'spray'],
68
+ sound: ['audio'],
69
+ lackWater: ['tank', 'water-low'],
70
+ filterElement: ['filter'],
71
+ color: ['rgb', 'hex'],
72
+ useTime: ['runtime', 'uptime'],
73
+ switchStatus: ['relay'],
74
+ lockState: ['locked'],
75
+ slidePosition: ['slide'],
76
+ // Phase 4 — ultra-niche sensor + webhook fields (~98% coverage target)
77
+ waterLeakDetect: ['leak', 'water'],
78
+ pressure: ['press', 'pa'],
79
+ moveCount: ['movecnt'],
80
+ errorCode: ['err'],
81
+ buttonName: ['btn', 'button'],
82
+ pressedAt: ['pressed'],
83
+ deviceMac: ['mac'],
84
+ detectionState: ['detected', 'detect'],
13
85
  };
86
+ /**
87
+ * Resolve a user-typed field name to its canonical form against an allowed list.
88
+ *
89
+ * Matching is case-insensitive and trims surrounding whitespace. Direct matches
90
+ * win over alias matches. Throws UsageError if the input is empty or does not
91
+ * match any canonical / alias in the allowed list.
92
+ */
14
93
  export function resolveField(input, allowedCanonical) {
15
94
  const normalized = input.trim().toLowerCase();
16
95
  if (!normalized) {
@@ -19,12 +98,21 @@ export function resolveField(input, allowedCanonical) {
19
98
  for (const canonical of allowedCanonical) {
20
99
  if (canonical.toLowerCase() === normalized)
21
100
  return canonical;
101
+ }
102
+ for (const canonical of allowedCanonical) {
22
103
  const aliases = FIELD_ALIASES[canonical] ?? [];
23
104
  if (aliases.some((a) => a.toLowerCase() === normalized))
24
105
  return canonical;
25
106
  }
26
107
  throw new UsageError(`Unknown field "${input}". Supported: ${listSupportedFieldInputs(allowedCanonical).join(', ')}`);
27
108
  }
109
+ /**
110
+ * Resolve every field in a list. Preserves order and the original UsageError
111
+ * from resolveField() on the first unknown input.
112
+ */
113
+ export function resolveFieldList(inputs, allowedCanonical) {
114
+ return inputs.map((f) => resolveField(f, allowedCanonical));
115
+ }
28
116
  export function listSupportedFieldInputs(allowedCanonical) {
29
117
  const out = new Set();
30
118
  for (const canonical of allowedCanonical) {
@@ -34,3 +122,10 @@ export function listSupportedFieldInputs(allowedCanonical) {
34
122
  }
35
123
  return [...out];
36
124
  }
125
+ /**
126
+ * All canonical keys known to the alias registry. Use when no dynamic
127
+ * canonical list is available (e.g. `watch` before the first poll response).
128
+ */
129
+ export function listAllCanonical() {
130
+ return Object.keys(FIELD_ALIASES);
131
+ }