@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
|
@@ -1,270 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Declarative metadata for non-device resources exposed by the SwitchBot API:
|
|
3
|
-
* scenes, webhooks, and keypad credentials ("keys").
|
|
4
|
-
*
|
|
5
|
-
* Consumed by `capabilities --json` and `schema export` so AI agents can
|
|
6
|
-
* discover these surfaces the same way they discover device commands.
|
|
7
|
-
*
|
|
8
|
-
* Scope:
|
|
9
|
-
* - Descriptive metadata only (no runtime execution — CLI/MCP handlers stay
|
|
10
|
-
* source-of-truth for behavior).
|
|
11
|
-
* - Webhook event list is derived from the device catalog and is advisory —
|
|
12
|
-
* not every SwitchBot device actually pushes every listed event; refer to
|
|
13
|
-
* the SwitchBot webhook docs for authoritative shapes.
|
|
14
|
-
*/
|
|
15
|
-
const COMMON_WEBHOOK_FIELDS = [
|
|
16
|
-
{ name: 'deviceType', type: 'string', description: 'SwitchBot device type string', example: 'WoMeter' },
|
|
17
|
-
{ name: 'deviceMac', type: 'string', description: 'Bluetooth MAC address (uppercase, colon-separated)', example: 'AA:BB:CC:11:22:33' },
|
|
18
|
-
{ name: 'timeOfSample', type: 'timestamp', description: 'Millisecond Unix timestamp when the sample was taken', example: 1700000000000 },
|
|
19
|
-
];
|
|
20
|
-
export const RESOURCE_CATALOG = {
|
|
21
|
-
scenes: {
|
|
22
|
-
description: 'Manual scenes (IFTTT-style rules) authored in the SwitchBot app. Execution is fire-and-forget from the cloud — side-effects happen on the user\'s devices.',
|
|
23
|
-
operations: [
|
|
24
|
-
{
|
|
25
|
-
verb: 'list',
|
|
26
|
-
method: 'GET',
|
|
27
|
-
endpoint: '/v1.1/scenes',
|
|
28
|
-
params: [],
|
|
29
|
-
safetyTier: 'read',
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
verb: 'execute',
|
|
33
|
-
method: 'POST',
|
|
34
|
-
endpoint: '/v1.1/scenes/{sceneId}/execute',
|
|
35
|
-
params: [{ name: 'sceneId', required: true, type: 'string' }],
|
|
36
|
-
safetyTier: 'mutation',
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
verb: 'describe',
|
|
40
|
-
method: 'GET',
|
|
41
|
-
endpoint: '/v1.1/scenes/{sceneId}',
|
|
42
|
-
params: [{ name: 'sceneId', required: true, type: 'string' }],
|
|
43
|
-
safetyTier: 'read',
|
|
44
|
-
},
|
|
45
|
-
],
|
|
46
|
-
},
|
|
47
|
-
webhooks: {
|
|
48
|
-
endpoints: [
|
|
49
|
-
{
|
|
50
|
-
verb: 'setup',
|
|
51
|
-
method: 'POST',
|
|
52
|
-
path: '/v1.1/webhook/setupWebhook',
|
|
53
|
-
safetyTier: 'mutation',
|
|
54
|
-
requiredParams: ['url'],
|
|
55
|
-
},
|
|
56
|
-
{
|
|
57
|
-
verb: 'query',
|
|
58
|
-
method: 'POST',
|
|
59
|
-
path: '/v1.1/webhook/queryWebhook',
|
|
60
|
-
safetyTier: 'read',
|
|
61
|
-
requiredParams: ['action'],
|
|
62
|
-
},
|
|
63
|
-
{
|
|
64
|
-
verb: 'update',
|
|
65
|
-
method: 'POST',
|
|
66
|
-
path: '/v1.1/webhook/updateWebhook',
|
|
67
|
-
safetyTier: 'mutation',
|
|
68
|
-
requiredParams: ['url', 'enable'],
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
verb: 'delete',
|
|
72
|
-
method: 'POST',
|
|
73
|
-
path: '/v1.1/webhook/deleteWebhook',
|
|
74
|
-
safetyTier: 'destructive',
|
|
75
|
-
requiredParams: ['url'],
|
|
76
|
-
},
|
|
77
|
-
],
|
|
78
|
-
events: [
|
|
79
|
-
{
|
|
80
|
-
eventType: 'WoMeter',
|
|
81
|
-
devicePattern: 'Meter / Meter Plus / Indoor-Outdoor Meter',
|
|
82
|
-
fields: [
|
|
83
|
-
...COMMON_WEBHOOK_FIELDS,
|
|
84
|
-
{ name: 'temperature', type: 'number', description: 'Ambient temperature in Celsius', example: 22.5 },
|
|
85
|
-
{ name: 'humidity', type: 'number', description: 'Relative humidity (%)', example: 45 },
|
|
86
|
-
{ name: 'battery', type: 'number', description: 'Battery remaining (%)', example: 88 },
|
|
87
|
-
],
|
|
88
|
-
},
|
|
89
|
-
{
|
|
90
|
-
eventType: 'WoCO2Sensor',
|
|
91
|
-
devicePattern: 'CO2 Monitor',
|
|
92
|
-
fields: [
|
|
93
|
-
...COMMON_WEBHOOK_FIELDS,
|
|
94
|
-
{ name: 'CO2', type: 'number', description: 'CO2 concentration in ppm', example: 520 },
|
|
95
|
-
{ name: 'temperature', type: 'number', description: 'Ambient temperature in Celsius' },
|
|
96
|
-
{ name: 'humidity', type: 'number', description: 'Relative humidity (%)' },
|
|
97
|
-
],
|
|
98
|
-
},
|
|
99
|
-
{
|
|
100
|
-
eventType: 'WoPresence',
|
|
101
|
-
devicePattern: 'Motion Sensor / Video Doorbell motion',
|
|
102
|
-
fields: [
|
|
103
|
-
...COMMON_WEBHOOK_FIELDS,
|
|
104
|
-
{ name: 'detectionState', type: 'string', description: 'Detection result word', example: 'DETECTED' },
|
|
105
|
-
],
|
|
106
|
-
},
|
|
107
|
-
{
|
|
108
|
-
eventType: 'WoContact',
|
|
109
|
-
devicePattern: 'Contact Sensor',
|
|
110
|
-
fields: [
|
|
111
|
-
...COMMON_WEBHOOK_FIELDS,
|
|
112
|
-
{ name: 'openState', type: 'string', description: 'Door/window state', example: 'open' },
|
|
113
|
-
{ name: 'moveDetected', type: 'boolean', description: 'Motion detected during this sample' },
|
|
114
|
-
],
|
|
115
|
-
},
|
|
116
|
-
{
|
|
117
|
-
eventType: 'WoLock',
|
|
118
|
-
devicePattern: 'Smart Lock / Smart Lock Lite / Smart Lock Pro',
|
|
119
|
-
fields: [
|
|
120
|
-
...COMMON_WEBHOOK_FIELDS,
|
|
121
|
-
{ name: 'lockState', type: 'string', description: 'Lock state: locked, unlocked, jammed', example: 'locked' },
|
|
122
|
-
{ name: 'battery', type: 'number', description: 'Battery remaining (%)' },
|
|
123
|
-
],
|
|
124
|
-
},
|
|
125
|
-
{
|
|
126
|
-
eventType: 'WoPlug',
|
|
127
|
-
devicePattern: 'Plug Mini / Plug / Relay Switch',
|
|
128
|
-
fields: [
|
|
129
|
-
...COMMON_WEBHOOK_FIELDS,
|
|
130
|
-
{ name: 'power', type: 'string', description: 'Power state (on/off)', example: 'on' },
|
|
131
|
-
{ name: 'voltage', type: 'number', description: 'Instantaneous voltage (V)' },
|
|
132
|
-
{ name: 'electricCurrent', type: 'number', description: 'Instantaneous current (A)' },
|
|
133
|
-
],
|
|
134
|
-
},
|
|
135
|
-
{
|
|
136
|
-
eventType: 'WoBot',
|
|
137
|
-
devicePattern: 'Bot',
|
|
138
|
-
fields: [
|
|
139
|
-
...COMMON_WEBHOOK_FIELDS,
|
|
140
|
-
{ name: 'power', type: 'string', description: 'Power state (on/off)' },
|
|
141
|
-
{ name: 'battery', type: 'number', description: 'Battery remaining (%)' },
|
|
142
|
-
],
|
|
143
|
-
},
|
|
144
|
-
{
|
|
145
|
-
eventType: 'WoCurtain',
|
|
146
|
-
devicePattern: 'Curtain / Blind Tilt / Roller Shade',
|
|
147
|
-
fields: [
|
|
148
|
-
...COMMON_WEBHOOK_FIELDS,
|
|
149
|
-
{ name: 'slidePosition', type: 'number', description: 'Current slide position (0–100)' },
|
|
150
|
-
{ name: 'calibrate', type: 'boolean', description: 'True if device is calibrated' },
|
|
151
|
-
],
|
|
152
|
-
},
|
|
153
|
-
{
|
|
154
|
-
eventType: 'WoDoorbell',
|
|
155
|
-
devicePattern: 'Video Doorbell button press',
|
|
156
|
-
fields: [
|
|
157
|
-
...COMMON_WEBHOOK_FIELDS,
|
|
158
|
-
{ name: 'buttonName', type: 'string', description: 'Identifier of the pressed button' },
|
|
159
|
-
{ name: 'pressedAt', type: 'timestamp', description: 'Press timestamp in milliseconds' },
|
|
160
|
-
],
|
|
161
|
-
},
|
|
162
|
-
{
|
|
163
|
-
eventType: 'WoKeypad',
|
|
164
|
-
devicePattern: 'Keypad scan / createKey result / deleteKey result',
|
|
165
|
-
fields: [
|
|
166
|
-
...COMMON_WEBHOOK_FIELDS,
|
|
167
|
-
{ name: 'eventType', type: 'string', description: 'Sub-event (createKey / deleteKey / invalidCode)' },
|
|
168
|
-
{ name: 'commandId', type: 'string', description: 'Correlation id returned by the original command' },
|
|
169
|
-
{ name: 'result', type: 'string', description: 'Outcome (success / failed / timeout)' },
|
|
170
|
-
],
|
|
171
|
-
},
|
|
172
|
-
{
|
|
173
|
-
eventType: 'WoColorBulb',
|
|
174
|
-
devicePattern: 'Color Bulb',
|
|
175
|
-
fields: [
|
|
176
|
-
...COMMON_WEBHOOK_FIELDS,
|
|
177
|
-
{ name: 'power', type: 'string', description: 'Power state (on/off)' },
|
|
178
|
-
{ name: 'brightness', type: 'number', description: 'Brightness (0–100)' },
|
|
179
|
-
{ name: 'color', type: 'string', description: 'RGB triplet "r:g:b"' },
|
|
180
|
-
{ name: 'colorTemperature', type: 'number', description: 'Color temperature in Kelvin' },
|
|
181
|
-
],
|
|
182
|
-
},
|
|
183
|
-
{
|
|
184
|
-
eventType: 'WoStrip',
|
|
185
|
-
devicePattern: 'Strip Light',
|
|
186
|
-
fields: [
|
|
187
|
-
...COMMON_WEBHOOK_FIELDS,
|
|
188
|
-
{ name: 'power', type: 'string', description: 'Power state (on/off)' },
|
|
189
|
-
{ name: 'brightness', type: 'number', description: 'Brightness (0–100)' },
|
|
190
|
-
{ name: 'color', type: 'string', description: 'RGB triplet "r:g:b"' },
|
|
191
|
-
],
|
|
192
|
-
},
|
|
193
|
-
{
|
|
194
|
-
eventType: 'WoSweeper',
|
|
195
|
-
devicePattern: 'Robot Vacuum',
|
|
196
|
-
fields: [
|
|
197
|
-
...COMMON_WEBHOOK_FIELDS,
|
|
198
|
-
{ name: 'workingStatus', type: 'string', description: 'Cleaning state' },
|
|
199
|
-
{ name: 'battery', type: 'number', description: 'Battery remaining (%)' },
|
|
200
|
-
{ name: 'taskType', type: 'string', description: 'Current task (standby / clean / charge)' },
|
|
201
|
-
],
|
|
202
|
-
},
|
|
203
|
-
{
|
|
204
|
-
eventType: 'WoWaterLeakDetect',
|
|
205
|
-
devicePattern: 'Water Leak Detector',
|
|
206
|
-
fields: [
|
|
207
|
-
...COMMON_WEBHOOK_FIELDS,
|
|
208
|
-
{ name: 'waterLeakDetect', type: 'number', description: 'Leak flag (0 = dry, 1 = leak detected)' },
|
|
209
|
-
{ name: 'battery', type: 'number', description: 'Battery remaining (%)' },
|
|
210
|
-
],
|
|
211
|
-
},
|
|
212
|
-
{
|
|
213
|
-
eventType: 'WoHub',
|
|
214
|
-
devicePattern: 'Hub 2 / Hub 3 (ambient sensors)',
|
|
215
|
-
fields: [
|
|
216
|
-
...COMMON_WEBHOOK_FIELDS,
|
|
217
|
-
{ name: 'temperature', type: 'number', description: 'Ambient temperature in Celsius' },
|
|
218
|
-
{ name: 'humidity', type: 'number', description: 'Relative humidity (%)' },
|
|
219
|
-
{ name: 'lightLevel', type: 'number', description: 'Illuminance level' },
|
|
220
|
-
],
|
|
221
|
-
},
|
|
222
|
-
],
|
|
223
|
-
constraints: {
|
|
224
|
-
maxUrlLength: 2048,
|
|
225
|
-
maxWebhooksPerAccount: 1,
|
|
226
|
-
},
|
|
227
|
-
},
|
|
228
|
-
keys: [
|
|
229
|
-
{
|
|
230
|
-
keyType: 'permanent',
|
|
231
|
-
description: 'Passcode that never expires — valid until manually deleted.',
|
|
232
|
-
requiredParams: ['name', 'password'],
|
|
233
|
-
optionalParams: [],
|
|
234
|
-
supportedDevices: ['Keypad', 'Keypad Touch'],
|
|
235
|
-
safetyTier: 'destructive',
|
|
236
|
-
},
|
|
237
|
-
{
|
|
238
|
-
keyType: 'timeLimit',
|
|
239
|
-
description: 'Passcode valid only between startTime and endTime (Unix seconds).',
|
|
240
|
-
requiredParams: ['name', 'password', 'startTime', 'endTime'],
|
|
241
|
-
optionalParams: [],
|
|
242
|
-
supportedDevices: ['Keypad', 'Keypad Touch'],
|
|
243
|
-
safetyTier: 'destructive',
|
|
244
|
-
},
|
|
245
|
-
{
|
|
246
|
-
keyType: 'disposable',
|
|
247
|
-
description: 'Passcode that can be used once and then auto-expires.',
|
|
248
|
-
requiredParams: ['name', 'password'],
|
|
249
|
-
optionalParams: ['startTime', 'endTime'],
|
|
250
|
-
supportedDevices: ['Keypad', 'Keypad Touch'],
|
|
251
|
-
safetyTier: 'destructive',
|
|
252
|
-
},
|
|
253
|
-
{
|
|
254
|
-
keyType: 'urgent',
|
|
255
|
-
description: 'Emergency passcode (typically tied to panic / audit workflow).',
|
|
256
|
-
requiredParams: ['name', 'password'],
|
|
257
|
-
optionalParams: [],
|
|
258
|
-
supportedDevices: ['Keypad', 'Keypad Touch'],
|
|
259
|
-
safetyTier: 'destructive',
|
|
260
|
-
},
|
|
261
|
-
],
|
|
262
|
-
};
|
|
263
|
-
/** Convenience: return the list of known webhook event types. */
|
|
264
|
-
export function listWebhookEventTypes() {
|
|
265
|
-
return RESOURCE_CATALOG.webhooks.events.map((e) => e.eventType);
|
|
266
|
-
}
|
|
267
|
-
/** Convenience: return the list of supported keypad key types. */
|
|
268
|
-
export function listKeyTypes() {
|
|
269
|
-
return RESOURCE_CATALOG.keys.map((k) => k.keyType);
|
|
270
|
-
}
|
|
@@ -1,257 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Default install steps used by `switchbot install` (Phase 3B in-repo).
|
|
3
|
-
*
|
|
4
|
-
* Each factory returns an `InstallStep<InstallContext>` whose `execute`
|
|
5
|
-
* and `undo` both operate on the shared context. Steps are intentionally
|
|
6
|
-
* small — each one either mutates one system (keychain / filesystem /
|
|
7
|
-
* symlink) or captures input, never a mix. The orchestrator composes
|
|
8
|
-
* them in `src/commands/install.ts`.
|
|
9
|
-
*
|
|
10
|
-
* The step runner (`src/install/steps.ts`) handles rollback on failure;
|
|
11
|
-
* these factories just make sure every `execute` records what it needs
|
|
12
|
-
* into the context so the matching `undo` can unwind it.
|
|
13
|
-
*/
|
|
14
|
-
import fs from 'node:fs';
|
|
15
|
-
import path from 'node:path';
|
|
16
|
-
import os from 'node:os';
|
|
17
|
-
import { spawnSync } from 'node:child_process';
|
|
18
|
-
import { scaffoldPolicyFile, PolicyFileExistsError, } from '../commands/policy.js';
|
|
19
|
-
import { promptTokenAndSecret, readCredentialsFile } from '../commands/config.js';
|
|
20
|
-
import { selectCredentialStore } from '../credentials/keychain.js';
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
|
-
// Step 1: capture credentials (memory only — no side effects until step 2)
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
export function stepPromptCredentials() {
|
|
25
|
-
return {
|
|
26
|
-
name: 'prompt-credentials',
|
|
27
|
-
description: 'Collect SwitchBot token + secret (interactive unless --token-file)',
|
|
28
|
-
async execute(ctx) {
|
|
29
|
-
if (ctx.credentials)
|
|
30
|
-
return; // already provided via API consumer
|
|
31
|
-
if (ctx.tokenFile) {
|
|
32
|
-
const creds = readCredentialsFile(ctx.tokenFile);
|
|
33
|
-
ctx.credentials = creds;
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
if (ctx.nonInteractive) {
|
|
37
|
-
throw new Error('no --token-file and stdin is not a TTY; pass --token-file <path> to install non-interactively');
|
|
38
|
-
}
|
|
39
|
-
ctx.credentials = await promptTokenAndSecret();
|
|
40
|
-
},
|
|
41
|
-
undo() {
|
|
42
|
-
// No disk state created; clearing memory is enough.
|
|
43
|
-
// The calling process will exit shortly after rollback, but null
|
|
44
|
-
// the field for defence-in-depth.
|
|
45
|
-
return;
|
|
46
|
-
},
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
// ---------------------------------------------------------------------------
|
|
50
|
-
// Step 2: write credentials to keychain (or file fallback)
|
|
51
|
-
// ---------------------------------------------------------------------------
|
|
52
|
-
export function stepWriteKeychain() {
|
|
53
|
-
return {
|
|
54
|
-
name: 'write-keychain',
|
|
55
|
-
description: 'Store credentials in the OS keychain (falls back to ~/.switchbot/config.json)',
|
|
56
|
-
async execute(ctx) {
|
|
57
|
-
if (!ctx.credentials) {
|
|
58
|
-
throw new Error('internal: credentials missing at write-keychain; prompt step must run first');
|
|
59
|
-
}
|
|
60
|
-
const store = await selectCredentialStore();
|
|
61
|
-
const previous = await store.get(ctx.profile);
|
|
62
|
-
ctx.previousCredentials = previous;
|
|
63
|
-
await store.set(ctx.profile, ctx.credentials);
|
|
64
|
-
ctx.credentialStore = store;
|
|
65
|
-
ctx.credentialsWereStored = true;
|
|
66
|
-
},
|
|
67
|
-
async undo(ctx) {
|
|
68
|
-
if (!ctx.credentialsWereStored || !ctx.credentialStore)
|
|
69
|
-
return;
|
|
70
|
-
try {
|
|
71
|
-
if (ctx.previousCredentials) {
|
|
72
|
-
await ctx.credentialStore.set(ctx.profile, ctx.previousCredentials);
|
|
73
|
-
}
|
|
74
|
-
else {
|
|
75
|
-
await ctx.credentialStore.delete(ctx.profile);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
finally {
|
|
79
|
-
ctx.credentialsWereStored = false;
|
|
80
|
-
ctx.previousCredentials = undefined;
|
|
81
|
-
}
|
|
82
|
-
},
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
// ---------------------------------------------------------------------------
|
|
86
|
-
// Step 3: scaffold policy.yaml if missing (skip if present, don't clobber)
|
|
87
|
-
// ---------------------------------------------------------------------------
|
|
88
|
-
export function stepScaffoldPolicy() {
|
|
89
|
-
return {
|
|
90
|
-
name: 'scaffold-policy',
|
|
91
|
-
description: 'Create a starter policy.yaml (only if none exists)',
|
|
92
|
-
execute(ctx) {
|
|
93
|
-
try {
|
|
94
|
-
const result = scaffoldPolicyFile(ctx.policyPath, { skipExisting: true });
|
|
95
|
-
ctx.policyScaffoldResult = result;
|
|
96
|
-
}
|
|
97
|
-
catch (err) {
|
|
98
|
-
if (err instanceof PolicyFileExistsError) {
|
|
99
|
-
// skipExisting is true → this branch is unreachable, but be
|
|
100
|
-
// defensive against future changes.
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
throw err;
|
|
104
|
-
}
|
|
105
|
-
},
|
|
106
|
-
undo(ctx) {
|
|
107
|
-
const r = ctx.policyScaffoldResult;
|
|
108
|
-
if (!r || r.skipped)
|
|
109
|
-
return;
|
|
110
|
-
// Only remove the file if WE created it (skipped === false means
|
|
111
|
-
// we wrote fresh content to a path that did not exist before).
|
|
112
|
-
try {
|
|
113
|
-
fs.unlinkSync(r.policyPath);
|
|
114
|
-
}
|
|
115
|
-
catch {
|
|
116
|
-
// best-effort; do not fail rollback on cleanup
|
|
117
|
-
}
|
|
118
|
-
},
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
// ---------------------------------------------------------------------------
|
|
122
|
-
// Step 4: install skill into the agent's skills directory
|
|
123
|
-
// ---------------------------------------------------------------------------
|
|
124
|
-
/**
|
|
125
|
-
* Compute the on-disk location where an agent expects to find this skill.
|
|
126
|
-
* Only `claude-code` has an automation path today; others are informational
|
|
127
|
-
* (the installer will print a recipe instead of creating anything).
|
|
128
|
-
*/
|
|
129
|
-
export function skillLinkPathFor(agent, home = os.homedir()) {
|
|
130
|
-
if (agent === 'claude-code') {
|
|
131
|
-
return path.join(home, '.claude', 'skills', 'switchbot');
|
|
132
|
-
}
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
export function stepSymlinkSkill(opts = {}) {
|
|
136
|
-
return {
|
|
137
|
-
name: 'symlink-skill',
|
|
138
|
-
description: 'Link the skill into ~/.claude/skills/switchbot (Claude Code)',
|
|
139
|
-
execute(ctx) {
|
|
140
|
-
if (ctx.agent === 'none')
|
|
141
|
-
return;
|
|
142
|
-
if (!ctx.skillPath) {
|
|
143
|
-
// Informational path: print the recipe, do not fail. Undo can
|
|
144
|
-
// safely no-op in this branch.
|
|
145
|
-
ctx.skillRecipePrinted = true;
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
const target = path.resolve(ctx.skillPath);
|
|
149
|
-
if (!fs.existsSync(target)) {
|
|
150
|
-
throw new Error(`--skill-path does not exist: ${target}`);
|
|
151
|
-
}
|
|
152
|
-
const stat = fs.statSync(target);
|
|
153
|
-
if (!stat.isDirectory()) {
|
|
154
|
-
throw new Error(`--skill-path is not a directory: ${target}`);
|
|
155
|
-
}
|
|
156
|
-
const linkPath = skillLinkPathFor(ctx.agent);
|
|
157
|
-
if (!linkPath) {
|
|
158
|
-
// Non-automating agent: print a recipe instead of creating state.
|
|
159
|
-
ctx.skillRecipePrinted = true;
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
// A2: require a SKILL.md only when we are about to create a link.
|
|
163
|
-
// Non-automating agents (cursor/copilot) print a recipe and return
|
|
164
|
-
// above, so they are never blocked by this check.
|
|
165
|
-
if (!opts.force && !fs.existsSync(path.join(target, 'SKILL.md'))) {
|
|
166
|
-
throw new Error(`${target} does not look like a skill (no SKILL.md at the root). ` +
|
|
167
|
-
'Pass --force if you really mean to link this directory.');
|
|
168
|
-
}
|
|
169
|
-
if (fs.existsSync(linkPath)) {
|
|
170
|
-
const st = fs.lstatSync(linkPath);
|
|
171
|
-
if (st.isSymbolicLink()) {
|
|
172
|
-
// A3: tolerate an existing link only when it points at the same
|
|
173
|
-
// target; otherwise the user is likely trying to repoint and we
|
|
174
|
-
// should not silently pretend success. --force replaces it.
|
|
175
|
-
let existingTarget = null;
|
|
176
|
-
try {
|
|
177
|
-
existingTarget = path.resolve(path.dirname(linkPath), fs.readlinkSync(linkPath));
|
|
178
|
-
}
|
|
179
|
-
catch {
|
|
180
|
-
existingTarget = null;
|
|
181
|
-
}
|
|
182
|
-
if (existingTarget === target) {
|
|
183
|
-
ctx.skillLinkPath = linkPath;
|
|
184
|
-
ctx.skillLinkCreated = false;
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
if (!opts.force) {
|
|
188
|
-
throw new Error(`${linkPath} already links to ${existingTarget ?? '(unreadable)'}; ` +
|
|
189
|
-
'pass --force to replace it, or run `switchbot uninstall` first.');
|
|
190
|
-
}
|
|
191
|
-
fs.unlinkSync(linkPath);
|
|
192
|
-
}
|
|
193
|
-
else {
|
|
194
|
-
throw new Error(`${linkPath} exists and is not a symlink; refusing to clobber (move it aside and re-run)`);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
|
198
|
-
// Windows: regular symlinks require admin or Developer Mode. A
|
|
199
|
-
// directory junction works for any user and is transparent to
|
|
200
|
-
// most tools. Unix: plain symlink.
|
|
201
|
-
const linkType = process.platform === 'win32' ? 'junction' : 'dir';
|
|
202
|
-
fs.symlinkSync(target, linkPath, linkType);
|
|
203
|
-
ctx.skillLinkPath = linkPath;
|
|
204
|
-
ctx.skillLinkCreated = true;
|
|
205
|
-
},
|
|
206
|
-
undo(ctx) {
|
|
207
|
-
if (!ctx.skillLinkCreated || !ctx.skillLinkPath)
|
|
208
|
-
return;
|
|
209
|
-
try {
|
|
210
|
-
fs.unlinkSync(ctx.skillLinkPath);
|
|
211
|
-
}
|
|
212
|
-
catch {
|
|
213
|
-
// best-effort
|
|
214
|
-
}
|
|
215
|
-
},
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
|
-
function defaultDoctorSpawner(cliPath, profile) {
|
|
219
|
-
const args = profile === 'default' ? [cliPath, 'doctor', '--json'] : [cliPath, '--profile', profile, 'doctor', '--json'];
|
|
220
|
-
const r = spawnSync(process.execPath, args, { encoding: 'utf-8' });
|
|
221
|
-
return {
|
|
222
|
-
ok: r.status === 0,
|
|
223
|
-
exitCode: r.status,
|
|
224
|
-
stdout: r.stdout ?? '',
|
|
225
|
-
stderr: r.stderr ?? '',
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
export function stepDoctorVerify(opts = { cliPath: '' }) {
|
|
229
|
-
const spawner = opts.spawner ?? defaultDoctorSpawner;
|
|
230
|
-
const cliPath = opts.cliPath;
|
|
231
|
-
return {
|
|
232
|
-
name: 'doctor-verify',
|
|
233
|
-
description: 'Verify the install with switchbot doctor --json',
|
|
234
|
-
execute(ctx) {
|
|
235
|
-
if (!cliPath) {
|
|
236
|
-
// Fail closed: without a known CLI path we cannot spawn doctor.
|
|
237
|
-
// Mark not-ok but still succeed (no rollback).
|
|
238
|
-
ctx.doctorOk = false;
|
|
239
|
-
ctx.doctorReport = { skipped: true, reason: 'no cliPath provided' };
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
const r = spawner(cliPath, ctx.profile);
|
|
243
|
-
ctx.doctorOk = r.ok;
|
|
244
|
-
try {
|
|
245
|
-
ctx.doctorReport = r.stdout ? JSON.parse(r.stdout) : { exitCode: r.exitCode, stderr: r.stderr };
|
|
246
|
-
}
|
|
247
|
-
catch {
|
|
248
|
-
ctx.doctorReport = { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr };
|
|
249
|
-
}
|
|
250
|
-
// NOTE: never throw here. Doctor failure is reported; rollback is
|
|
251
|
-
// opt-in by the user via `switchbot uninstall`.
|
|
252
|
-
},
|
|
253
|
-
undo() {
|
|
254
|
-
return;
|
|
255
|
-
},
|
|
256
|
-
};
|
|
257
|
-
}
|