@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.
Files changed (113) hide show
  1. package/README.md +34 -42
  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 -367
  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 -88
  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 -205
  76. package/dist/rules/audit-query.js +0 -89
  77. package/dist/rules/conflict-analyzer.js +0 -203
  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 -117
  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,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
- }