@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,145 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import os from 'node:os';
4
- const MAX_HISTORY = 100;
5
- const JSONL_ROTATE_BYTES = 50 * 1024 * 1024; // 50 MB
6
- const JSONL_KEEP_ROTATIONS = 3; // .1 .2 .3
7
- function historyDir() {
8
- return path.join(os.homedir(), '.switchbot', 'device-history');
9
- }
10
- export class DeviceHistoryStore {
11
- // In-memory size counter so we don't stat() on every append.
12
- jsonlSizes = new Map();
13
- /** Reset the in-memory size counter. Tests use this between runs. */
14
- resetSizes() {
15
- this.jsonlSizes.clear();
16
- }
17
- get dir() {
18
- return historyDir();
19
- }
20
- record(deviceId, topic, deviceType, payload, t) {
21
- const entry = { t: t ?? new Date().toISOString(), topic, deviceType, payload };
22
- try {
23
- if (!fs.existsSync(this.dir))
24
- fs.mkdirSync(this.dir, { recursive: true });
25
- // 1. Ring-buffer JSON (back-compat with existing consumers).
26
- const file = path.join(this.dir, `${deviceId}.json`);
27
- const existing = fs.existsSync(file)
28
- ? JSON.parse(fs.readFileSync(file, 'utf-8'))
29
- : { latest: null, history: [] };
30
- existing.latest = entry;
31
- existing.history = [entry, ...existing.history].slice(0, MAX_HISTORY);
32
- fs.writeFileSync(file, JSON.stringify(existing, null, 2), { mode: 0o600 });
33
- // 2. Append-only JSONL for range queries.
34
- this.writeJsonl(deviceId, entry);
35
- }
36
- catch {
37
- // best-effort — history loss is non-fatal
38
- }
39
- }
40
- /** Append a mqtt control event (no deviceId) to the dedicated __control.jsonl file. */
41
- recordControl(event) {
42
- try {
43
- if (!fs.existsSync(this.dir))
44
- fs.mkdirSync(this.dir, { recursive: true });
45
- this.writeJsonl('__control', event);
46
- }
47
- catch {
48
- // best-effort — never block the event stream
49
- }
50
- }
51
- writeJsonl(fileKey, record) {
52
- try {
53
- const jsonlPath = path.join(this.dir, `${fileKey}.jsonl`);
54
- const line = JSON.stringify(record) + '\n';
55
- const lineBytes = Buffer.byteLength(line, 'utf-8');
56
- // Seed size counter from disk on first touch (avoids drift across restarts).
57
- let size = this.jsonlSizes.get(fileKey);
58
- if (size === undefined) {
59
- try {
60
- size = fs.existsSync(jsonlPath) ? fs.statSync(jsonlPath).size : 0;
61
- }
62
- catch {
63
- size = 0;
64
- }
65
- }
66
- if (size + lineBytes > JSONL_ROTATE_BYTES) {
67
- this.rotateJsonl(fileKey);
68
- size = 0;
69
- }
70
- fs.appendFileSync(jsonlPath, line, { mode: 0o600 });
71
- this.jsonlSizes.set(fileKey, size + lineBytes);
72
- }
73
- catch {
74
- // best-effort
75
- }
76
- }
77
- rotateJsonl(fileKey) {
78
- const base = path.join(this.dir, `${fileKey}.jsonl`);
79
- // .jsonl.3 is dropped; .2 → .3, .1 → .2, current → .1
80
- try {
81
- const oldest = `${base}.${JSONL_KEEP_ROTATIONS}`;
82
- if (fs.existsSync(oldest))
83
- fs.rmSync(oldest);
84
- }
85
- catch { /* swallow */ }
86
- for (let i = JSONL_KEEP_ROTATIONS - 1; i >= 1; i--) {
87
- const from = `${base}.${i}`;
88
- const to = `${base}.${i + 1}`;
89
- try {
90
- if (fs.existsSync(from))
91
- fs.renameSync(from, to);
92
- }
93
- catch { /* swallow */ }
94
- }
95
- try {
96
- if (fs.existsSync(base))
97
- fs.renameSync(base, `${base}.1`);
98
- }
99
- catch { /* swallow */ }
100
- }
101
- getLatest(deviceId) {
102
- try {
103
- const file = path.join(this.dir, `${deviceId}.json`);
104
- if (!fs.existsSync(file))
105
- return null;
106
- return JSON.parse(fs.readFileSync(file, 'utf-8')).latest;
107
- }
108
- catch {
109
- return null;
110
- }
111
- }
112
- getHistory(deviceId, limit = 20) {
113
- try {
114
- const file = path.join(this.dir, `${deviceId}.json`);
115
- if (!fs.existsSync(file))
116
- return [];
117
- const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
118
- return data.history.slice(0, Math.min(limit, MAX_HISTORY));
119
- }
120
- catch {
121
- return [];
122
- }
123
- }
124
- listDevices() {
125
- try {
126
- if (!fs.existsSync(this.dir))
127
- return [];
128
- const seen = new Set();
129
- for (const f of fs.readdirSync(this.dir)) {
130
- if (f.endsWith('.json'))
131
- seen.add(f.slice(0, -5));
132
- else if (f.endsWith('.jsonl'))
133
- seen.add(f.slice(0, -6));
134
- }
135
- return Array.from(seen);
136
- }
137
- catch {
138
- return [];
139
- }
140
- }
141
- getHistoryDir() {
142
- return this.dir;
143
- }
144
- }
145
- export const deviceHistoryStore = new DeviceHistoryStore();
@@ -1,213 +0,0 @@
1
- import { SwitchBotMqttClient } from '../mqtt/client.js';
2
- import { fetchMqttCredential } from '../mqtt/credential.js';
3
- import { parseFilter, applyFilter } from '../utils/filter.js';
4
- import { fetchDeviceList } from '../lib/devices.js';
5
- import { getCachedDevice } from '../devices/cache.js';
6
- import { createClient } from '../api/client.js';
7
- import { log } from '../logger.js';
8
- import { deviceHistoryStore } from './device-history.js';
9
- export class EventSubscriptionManager {
10
- mqttClient = null;
11
- subscribers = new Map();
12
- ringBuffer = [];
13
- ringSize = 1000;
14
- typeMap = new Map();
15
- refreshTypeMapTimer = null;
16
- idleCleanupTimer = null;
17
- getClient;
18
- lastRefreshAttempt = 0;
19
- constructor(mqttClient, getClient) {
20
- this.mqttClient = mqttClient || null;
21
- this.getClient = getClient;
22
- }
23
- async initialize(token, secret) {
24
- if (!this.mqttClient) {
25
- const credential = await fetchMqttCredential(token, secret);
26
- const client = new SwitchBotMqttClient(credential, () => fetchMqttCredential(token, secret));
27
- client.onStateChange((state) => {
28
- if (state === 'connected') {
29
- this.emit({
30
- kind: 'events.reconnected',
31
- timestamp: Date.now(),
32
- });
33
- client.subscribe(credential.topics.status);
34
- }
35
- });
36
- client.onMessage((topic, payload) => {
37
- try {
38
- const data = JSON.parse(payload.toString());
39
- // Support SwitchBot direct format: { eventType, context: { deviceMac, deviceType, ... } }
40
- // and AWS IoT shadow format: $aws/things/<id>/shadow/... with data.state
41
- const context = data.context;
42
- const deviceId = context?.deviceMac ?? this.extractDeviceId(topic);
43
- const payloadData = context ?? data.state;
44
- const deviceType = String(context?.deviceType ?? 'Unknown');
45
- if (deviceId && payloadData) {
46
- deviceHistoryStore.record(deviceId, topic, deviceType, payloadData);
47
- this.addEvent({
48
- kind: 'shadow.updated',
49
- deviceId,
50
- payload: payloadData,
51
- timestamp: Date.now(),
52
- });
53
- }
54
- }
55
- catch (err) {
56
- log.debug({ err, topic }, 'failed to parse shadow payload');
57
- }
58
- });
59
- await client.connect();
60
- this.mqttClient = client;
61
- }
62
- this.scheduleIdleCleanup();
63
- }
64
- subscribe(id, handler, filter) {
65
- // Validate filter syntax if provided
66
- if (filter) {
67
- parseFilter(filter);
68
- }
69
- const subscriber = {
70
- id,
71
- handler,
72
- filter,
73
- lastActivity: Date.now(),
74
- };
75
- this.subscribers.set(id, subscriber);
76
- // Send recent events that match the filter
77
- for (const event of this.ringBuffer) {
78
- if (this.matchesFilter(event, filter)) {
79
- handler(event);
80
- }
81
- }
82
- return () => {
83
- this.subscribers.delete(id);
84
- };
85
- }
86
- addEvent(event) {
87
- this.ringBuffer.push(event);
88
- // Check for overflow
89
- if (this.ringBuffer.length > this.ringSize) {
90
- const droppedCount = this.ringBuffer.length - this.ringSize;
91
- const oldestTimestamp = this.ringBuffer[0]?.timestamp || Date.now();
92
- // Emit overflow notice to all subscribers
93
- this.emit({
94
- kind: 'events.dropped',
95
- count: droppedCount,
96
- sinceTs: oldestTimestamp,
97
- });
98
- // Trim buffer
99
- this.ringBuffer = this.ringBuffer.slice(-this.ringSize);
100
- }
101
- // Broadcast to matching subscribers
102
- this.emit(event);
103
- }
104
- emit(event) {
105
- for (const subscriber of this.subscribers.values()) {
106
- if (this.matchesFilter(event, subscriber.filter)) {
107
- subscriber.lastActivity = Date.now();
108
- subscriber.handler(event);
109
- }
110
- }
111
- }
112
- matchesFilter(event, filter) {
113
- if (!filter)
114
- return true;
115
- // Only filter shadow events
116
- if (event.kind !== 'shadow.updated')
117
- return true;
118
- try {
119
- // Parse filter and match against device metadata
120
- const clauses = parseFilter(filter);
121
- const deviceId = event.deviceId;
122
- // Get device info from cache
123
- const cached = getCachedDevice(deviceId);
124
- if (!cached) {
125
- // Lazily refresh type map if device unknown
126
- this.scheduleTypeMapRefresh();
127
- return false; // Conservative: drop if unknown
128
- }
129
- // Build a Device-compatible shape for applyFilter
130
- const device = {
131
- deviceId,
132
- deviceType: this.typeMap.get(deviceId) || cached.type,
133
- deviceName: cached.name,
134
- familyName: cached.familyName,
135
- roomName: cached.roomName,
136
- enableCloudService: true,
137
- hubDeviceId: '',
138
- };
139
- // Use applyFilter with single device in list
140
- const matched = applyFilter(clauses, [device], [], new Map());
141
- return matched.length > 0;
142
- }
143
- catch {
144
- return false; // Invalid filter matches nothing
145
- }
146
- }
147
- scheduleTypeMapRefresh() {
148
- if (this.refreshTypeMapTimer || Date.now() - this.lastRefreshAttempt < 5000) {
149
- return; // Already scheduled or too recent
150
- }
151
- this.refreshTypeMapTimer = setTimeout(async () => {
152
- this.refreshTypeMapTimer = null;
153
- this.lastRefreshAttempt = Date.now();
154
- try {
155
- const client = this.getClient?.() || createClient();
156
- const body = await fetchDeviceList(client);
157
- for (const d of body.deviceList) {
158
- if (d.deviceType)
159
- this.typeMap.set(d.deviceId, d.deviceType);
160
- }
161
- for (const ir of body.infraredRemoteList) {
162
- this.typeMap.set(ir.deviceId, ir.remoteType);
163
- }
164
- }
165
- catch {
166
- // Silently fail type map refresh
167
- }
168
- }, 100);
169
- }
170
- scheduleIdleCleanup() {
171
- if (this.idleCleanupTimer)
172
- return;
173
- this.idleCleanupTimer = setInterval(() => {
174
- const now = Date.now();
175
- const idleThreshold = 10 * 60 * 1000; // 10 minutes
176
- for (const [id, subscriber] of this.subscribers.entries()) {
177
- if (now - subscriber.lastActivity > idleThreshold) {
178
- this.subscribers.delete(id);
179
- }
180
- }
181
- }, 60000); // Check every minute
182
- }
183
- extractDeviceId(topic) {
184
- // Topic format: $aws/things/<deviceId>/shadow/update/accepted
185
- const match = topic.match(/\$aws\/things\/([^/]+)\/shadow/);
186
- return match ? match[1] : null;
187
- }
188
- getState() {
189
- if (!this.mqttClient)
190
- return 'disabled';
191
- return this.mqttClient.getState();
192
- }
193
- getSubscriberCount() {
194
- return this.subscribers.size;
195
- }
196
- getRecentEvents(limit = 100) {
197
- return this.ringBuffer.slice(-limit);
198
- }
199
- async shutdown() {
200
- if (this.refreshTypeMapTimer) {
201
- clearTimeout(this.refreshTypeMapTimer);
202
- }
203
- if (this.idleCleanupTimer) {
204
- clearInterval(this.idleCleanupTimer);
205
- }
206
- if (this.mqttClient) {
207
- await this.mqttClient.disconnect();
208
- this.mqttClient = null;
209
- }
210
- this.subscribers.clear();
211
- this.ringBuffer = [];
212
- }
213
- }
@@ -1,180 +0,0 @@
1
- import { connect } from 'mqtt';
2
- export class SwitchBotMqttClient {
3
- client = null;
4
- credential;
5
- state = 'connecting';
6
- credentialExpired = false;
7
- reconnectAttempts = 0;
8
- maxReconnectAttempts = 10;
9
- disconnecting = false;
10
- handlers = new Set();
11
- messageHandlers = new Set();
12
- credentialRefreshCallback;
13
- constructor(credential, onCredentialExpired) {
14
- this.credential = credential;
15
- this.credentialRefreshCallback = onCredentialExpired;
16
- }
17
- async connect() {
18
- if (this.client && this.state === 'connected')
19
- return;
20
- // Remove stale listeners before replacing the client instance, otherwise
21
- // the old client's close event fires after the new connection is established
22
- // (AWS IoT drops the old session), triggering a spurious reconnect loop.
23
- if (this.client) {
24
- this.client.removeAllListeners();
25
- this.client.end(true);
26
- this.client = null;
27
- }
28
- this.setState('connecting');
29
- this.credentialExpired = false;
30
- this.reconnectAttempts = 0;
31
- try {
32
- const { tls, brokerUrl, clientId } = this.credential;
33
- // tls.ca/cert/keyBase64 are PEM strings despite the misleading field name
34
- const options = {
35
- clientId,
36
- ca: tls.caBase64,
37
- cert: tls.certBase64,
38
- key: tls.keyBase64,
39
- rejectUnauthorized: true,
40
- clean: true,
41
- reconnectPeriod: 0,
42
- connectTimeout: 30000,
43
- keepalive: 60,
44
- reschedulePings: true,
45
- };
46
- this.client = connect(brokerUrl, options);
47
- this.client.on('connect', () => {
48
- this.reconnectAttempts = 0;
49
- this.setState('connected');
50
- this.credentialExpired = false;
51
- });
52
- this.client.on('message', (topic, payload) => {
53
- for (const handler of this.messageHandlers) {
54
- handler(topic, payload);
55
- }
56
- });
57
- this.client.on('error', (err) => {
58
- if (err instanceof Error &&
59
- (err.message.includes('certificate') ||
60
- err.message.includes('ECONNRESET') ||
61
- err.message.includes('handshake'))) {
62
- this.credentialExpired = true;
63
- }
64
- });
65
- this.client.on('close', () => {
66
- if (this.disconnecting)
67
- return;
68
- if (this.credentialExpired) {
69
- this.setState('failed');
70
- }
71
- else if (this.reconnectAttempts < this.maxReconnectAttempts) {
72
- this.attemptReconnect();
73
- }
74
- else {
75
- this.setState('failed');
76
- }
77
- });
78
- await new Promise((resolve, reject) => {
79
- const timeout = setTimeout(() => {
80
- reject(new Error('MQTT connection timeout'));
81
- }, 15000);
82
- const onConnect = () => {
83
- clearTimeout(timeout);
84
- this.client?.removeListener('error', onError);
85
- resolve();
86
- };
87
- const onError = (err) => {
88
- clearTimeout(timeout);
89
- this.client?.removeListener('connect', onConnect);
90
- reject(err);
91
- };
92
- if (this.client?.connected) {
93
- clearTimeout(timeout);
94
- resolve();
95
- }
96
- else {
97
- this.client?.once('connect', onConnect);
98
- this.client?.once('error', onError);
99
- }
100
- });
101
- }
102
- catch (err) {
103
- this.setState('failed');
104
- throw err;
105
- }
106
- }
107
- async attemptReconnect() {
108
- this.reconnectAttempts++;
109
- this.setState('reconnecting');
110
- if (this.credentialExpired && this.credentialRefreshCallback) {
111
- try {
112
- this.credential = await this.credentialRefreshCallback();
113
- this.credentialExpired = false;
114
- }
115
- catch {
116
- this.setState('failed');
117
- return;
118
- }
119
- }
120
- const delay = Math.min(30000, 1000 * Math.pow(2, this.reconnectAttempts - 1));
121
- await new Promise((r) => setTimeout(r, delay));
122
- try {
123
- await this.connect();
124
- }
125
- catch {
126
- if (this.reconnectAttempts < this.maxReconnectAttempts) {
127
- await this.attemptReconnect();
128
- }
129
- else {
130
- this.setState('failed');
131
- }
132
- }
133
- }
134
- setState(newState) {
135
- if (this.state !== newState) {
136
- this.state = newState;
137
- for (const handler of this.handlers) {
138
- handler(newState);
139
- }
140
- }
141
- }
142
- subscribe(topic) {
143
- if (this.client && this.state === 'connected') {
144
- this.client.subscribe(topic, (err) => {
145
- if (err) {
146
- console.error(`Failed to subscribe to ${topic}:`, err);
147
- }
148
- });
149
- }
150
- }
151
- onStateChange(handler) {
152
- this.handlers.add(handler);
153
- return () => {
154
- this.handlers.delete(handler);
155
- };
156
- }
157
- onMessage(handler) {
158
- this.messageHandlers.add(handler);
159
- return () => {
160
- this.messageHandlers.delete(handler);
161
- };
162
- }
163
- getState() {
164
- return this.state;
165
- }
166
- isConnected() {
167
- return this.state === 'connected' && this.client?.connected === true;
168
- }
169
- async disconnect() {
170
- this.disconnecting = true;
171
- if (this.client) {
172
- await new Promise((resolve) => {
173
- this.client?.end(false, () => resolve());
174
- });
175
- this.client = null;
176
- }
177
- this.disconnecting = false;
178
- this.setState('failed');
179
- }
180
- }
@@ -1,30 +0,0 @@
1
- import crypto from 'node:crypto';
2
- import { buildAuthHeaders } from '../auth.js';
3
- const CREDENTIAL_ENDPOINT = 'https://api.switchbot.net/v1.1/iot/credential';
4
- export async function fetchMqttCredential(token, secret) {
5
- // Use a random instanceId so each CLI session gets its own clientId, avoiding
6
- // conflicts with the SwitchBot cloud service that shares the same account credentials.
7
- const instanceId = crypto.randomUUID().replace(/-/g, '').slice(0, 16);
8
- const headers = buildAuthHeaders(token, secret);
9
- const res = await fetch(CREDENTIAL_ENDPOINT, {
10
- method: 'POST',
11
- headers,
12
- body: JSON.stringify({ instanceId }),
13
- signal: AbortSignal.timeout(15000),
14
- });
15
- if (!res.ok) {
16
- throw new Error(`MQTT credential request failed: HTTP ${res.status} ${res.statusText}`);
17
- }
18
- const json = (await res.json());
19
- if (json.statusCode !== 100) {
20
- throw new Error(`MQTT credential API error: statusCode ${json.statusCode}`);
21
- }
22
- // Response shape: { statusCode, body: { body: { channels: { mqtt: ... } } } }
23
- const outer = json.body;
24
- const inner = (outer.body ?? outer);
25
- const channels = inner.channels;
26
- if (!channels?.mqtt) {
27
- throw new Error('Unexpected MQTT credential response — channels.mqtt missing');
28
- }
29
- return channels.mqtt;
30
- }
@@ -1,124 +0,0 @@
1
- import { parseDocument, isMap, isSeq, isScalar, LineCounter } from 'yaml';
2
- import { parse as yamlParse } from 'yaml';
3
- import { loadPolicyFile } from './load.js';
4
- import { validateLoadedPolicy } from './validate.js';
5
- import fs from 'node:fs';
6
- export class AddRuleError extends Error {
7
- code;
8
- constructor(message, code) {
9
- super(message);
10
- this.code = code;
11
- this.name = 'AddRuleError';
12
- }
13
- }
14
- function buildDiff(before, after) {
15
- const beforeLines = before.split('\n');
16
- const afterLines = after.split('\n');
17
- const lines = ['--- before', '+++ after'];
18
- let i = 0;
19
- let j = 0;
20
- while (i < beforeLines.length || j < afterLines.length) {
21
- const b = beforeLines[i];
22
- const a = afterLines[j];
23
- if (i < beforeLines.length && j < afterLines.length && b === a) {
24
- lines.push(` ${b}`);
25
- i++;
26
- j++;
27
- }
28
- else if (j < afterLines.length && (i >= beforeLines.length || b !== a)) {
29
- lines.push(`+${a}`);
30
- j++;
31
- }
32
- else {
33
- lines.push(`-${b}`);
34
- i++;
35
- }
36
- }
37
- return lines.join('\n');
38
- }
39
- function isNullNode(node) {
40
- return isScalar(node) && node.value === null;
41
- }
42
- export function addRuleToPolicySource(opts) {
43
- const loaded = loadPolicyFile(opts.policyPath);
44
- const beforeSource = loaded.source;
45
- // Parse the incoming rule
46
- let ruleObj;
47
- try {
48
- ruleObj = yamlParse(opts.ruleYaml);
49
- }
50
- catch (err) {
51
- throw new AddRuleError(`Could not parse rule YAML: ${err.message}`, 'invalid-rule-yaml');
52
- }
53
- if (!ruleObj || typeof ruleObj !== 'object' || Array.isArray(ruleObj)) {
54
- throw new AddRuleError('Rule YAML must be a single mapping object', 'invalid-rule-shape');
55
- }
56
- const ruleName = ruleObj['name'];
57
- if (typeof ruleName !== 'string' || !ruleName) {
58
- throw new AddRuleError('Rule must have a non-empty "name" field', 'missing-rule-name');
59
- }
60
- // Clone the document using source round-trip (preserves comments)
61
- const clone = parseDocument(beforeSource, { keepSourceTokens: true });
62
- if (!isMap(clone.contents)) {
63
- throw new AddRuleError('Policy root must be a YAML mapping', 'invalid-policy-shape');
64
- }
65
- // Ensure automation block exists
66
- let automationNode = clone.contents.get('automation', true);
67
- if (!automationNode || isNullNode(automationNode)) {
68
- clone.setIn(['automation'], clone.createNode({ enabled: false, rules: [] }));
69
- automationNode = clone.contents.get('automation', true);
70
- }
71
- // Ensure automation.rules exists and is a sequence
72
- const rulesNode = clone.getIn(['automation', 'rules'], true);
73
- if (!rulesNode || isNullNode(rulesNode)) {
74
- clone.setIn(['automation', 'rules'], clone.createNode([]));
75
- }
76
- else if (!isSeq(rulesNode)) {
77
- throw new AddRuleError('automation.rules exists but is not a sequence; cannot append', 'invalid-rules-shape');
78
- }
79
- // Duplicate name check — use JS conversion for simplicity
80
- const policyJs = clone.toJS({ maxAliasCount: 100 });
81
- const existingRulesJs = policyJs['automation']?.['rules'];
82
- const existingRulesArr = Array.isArray(existingRulesJs) ? existingRulesJs : [];
83
- const duplicateIdx = existingRulesArr.findIndex((r) => r?.['name'] === ruleName);
84
- if (duplicateIdx !== -1 && !opts.force) {
85
- throw new AddRuleError(`Rule named "${ruleName}" already exists. Use --force to overwrite.`, 'duplicate-rule-name');
86
- }
87
- if (duplicateIdx !== -1 && opts.force) {
88
- const rulesSeq = clone.getIn(['automation', 'rules'], true);
89
- rulesSeq.items.splice(duplicateIdx, 1);
90
- }
91
- // Enable automation if requested
92
- if (opts.enableAutomation) {
93
- clone.setIn(['automation', 'enabled'], true);
94
- }
95
- // Append the rule
96
- const ruleNode = clone.createNode(ruleObj);
97
- const rulesSeq = clone.getIn(['automation', 'rules'], true);
98
- rulesSeq.items.push(ruleNode);
99
- const nextSource = String(clone);
100
- // Validate the resulting policy
101
- const reLC = new LineCounter();
102
- const reDoc = parseDocument(nextSource, { lineCounter: reLC, keepSourceTokens: true });
103
- const validation = validateLoadedPolicy({
104
- path: opts.policyPath,
105
- source: nextSource,
106
- doc: reDoc,
107
- lineCounter: reLC,
108
- data: reDoc.toJS({ maxAliasCount: 100 }),
109
- });
110
- if (!validation.valid) {
111
- const msgs = validation.errors.map((e) => ` line ${e.line}: ${e.message}`).join('\n');
112
- throw new AddRuleError(`Policy would be invalid after adding the rule:\n${msgs}`, 'validation-failed');
113
- }
114
- const diff = buildDiff(beforeSource, nextSource);
115
- return { ruleName, diff, nextSource };
116
- }
117
- export function addRuleToPolicyFile(opts) {
118
- const result = addRuleToPolicySource(opts);
119
- if (!opts.dryRun) {
120
- fs.writeFileSync(opts.policyPath, result.nextSource, 'utf8');
121
- return { ...result, written: true };
122
- }
123
- return { ...result, written: false };
124
- }