@switchbot/openapi-cli 2.7.2 → 3.1.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 (69) hide show
  1. package/README.md +481 -103
  2. package/dist/api/client.js +23 -1
  3. package/dist/commands/agent-bootstrap.js +47 -2
  4. package/dist/commands/auth.js +354 -0
  5. package/dist/commands/batch.js +20 -4
  6. package/dist/commands/capabilities.js +155 -65
  7. package/dist/commands/config.js +109 -0
  8. package/dist/commands/daemon.js +367 -0
  9. package/dist/commands/devices.js +62 -11
  10. package/dist/commands/doctor.js +417 -8
  11. package/dist/commands/events.js +3 -3
  12. package/dist/commands/explain.js +1 -2
  13. package/dist/commands/health.js +113 -0
  14. package/dist/commands/install.js +246 -0
  15. package/dist/commands/mcp.js +888 -7
  16. package/dist/commands/plan.js +379 -103
  17. package/dist/commands/policy.js +586 -0
  18. package/dist/commands/rules.js +875 -0
  19. package/dist/commands/scenes.js +140 -0
  20. package/dist/commands/schema.js +0 -2
  21. package/dist/commands/status-sync.js +131 -0
  22. package/dist/commands/uninstall.js +237 -0
  23. package/dist/commands/upgrade-check.js +88 -0
  24. package/dist/config.js +14 -0
  25. package/dist/credentials/backends/file.js +101 -0
  26. package/dist/credentials/backends/linux.js +129 -0
  27. package/dist/credentials/backends/macos.js +129 -0
  28. package/dist/credentials/backends/windows.js +215 -0
  29. package/dist/credentials/keychain.js +88 -0
  30. package/dist/credentials/prime.js +52 -0
  31. package/dist/devices/catalog.js +4 -10
  32. package/dist/index.js +30 -1
  33. package/dist/install/default-steps.js +257 -0
  34. package/dist/install/preflight.js +212 -0
  35. package/dist/install/steps.js +67 -0
  36. package/dist/lib/command-keywords.js +17 -0
  37. package/dist/lib/daemon-state.js +46 -0
  38. package/dist/lib/destructive-mode.js +12 -0
  39. package/dist/lib/devices.js +1 -1
  40. package/dist/lib/plan-store.js +68 -0
  41. package/dist/policy/add-rule.js +124 -0
  42. package/dist/policy/diff.js +91 -0
  43. package/dist/policy/examples/policy.example.yaml +99 -0
  44. package/dist/policy/format.js +57 -0
  45. package/dist/policy/load.js +61 -0
  46. package/dist/policy/migrate.js +67 -0
  47. package/dist/policy/schema/v0.2.json +331 -0
  48. package/dist/policy/schema.js +18 -0
  49. package/dist/policy/validate.js +262 -0
  50. package/dist/rules/action.js +205 -0
  51. package/dist/rules/audit-query.js +89 -0
  52. package/dist/rules/conflict-analyzer.js +203 -0
  53. package/dist/rules/cron-scheduler.js +186 -0
  54. package/dist/rules/destructive.js +52 -0
  55. package/dist/rules/engine.js +757 -0
  56. package/dist/rules/matcher.js +230 -0
  57. package/dist/rules/pid-file.js +95 -0
  58. package/dist/rules/quiet-hours.js +45 -0
  59. package/dist/rules/suggest.js +95 -0
  60. package/dist/rules/throttle.js +116 -0
  61. package/dist/rules/types.js +34 -0
  62. package/dist/rules/webhook-listener.js +223 -0
  63. package/dist/rules/webhook-token.js +90 -0
  64. package/dist/status-sync/manager.js +268 -0
  65. package/dist/utils/audit.js +12 -2
  66. package/dist/utils/health.js +101 -0
  67. package/dist/utils/output.js +72 -23
  68. package/dist/utils/retry.js +81 -0
  69. package/package.json +12 -4
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Credential priming cache.
3
+ *
4
+ * `loadConfig()` runs synchronously, but every OS keychain backend is
5
+ * async (subprocess-based). We bridge the two by priming credentials
6
+ * once per command, early in the `preAction` hook, and keeping the
7
+ * result in a tiny in-process cache keyed by profile name.
8
+ *
9
+ * After priming, sync callers can consult `getPrimedCredentials()` to
10
+ * pick up keychain-stored token/secret without any await.
11
+ *
12
+ * This module intentionally swallows errors — a flaky keychain
13
+ * probe must never block the CLI from running. When the probe fails
14
+ * we behave as "nothing primed" and the existing file path is used.
15
+ */
16
+ import { selectCredentialStore } from './keychain.js';
17
+ let cache = null;
18
+ /**
19
+ * Look up the given profile in the active credential store and cache
20
+ * the result. Safe to call multiple times — subsequent calls with the
21
+ * same profile short-circuit against the cache. Swallows all errors.
22
+ */
23
+ export async function primeCredentials(profile) {
24
+ if (cache?.profile === profile)
25
+ return;
26
+ try {
27
+ const store = await selectCredentialStore();
28
+ const creds = await store.get(profile);
29
+ cache = { profile, creds };
30
+ }
31
+ catch {
32
+ cache = { profile, creds: null };
33
+ }
34
+ }
35
+ /**
36
+ * Sync accessor for code paths that cannot be made async. Returns
37
+ * null when the cache is empty or keyed against a different profile,
38
+ * so existing file-based fallback stays the authoritative source.
39
+ */
40
+ export function getPrimedCredentials(profile) {
41
+ if (!cache)
42
+ return null;
43
+ if (cache.profile !== profile)
44
+ return null;
45
+ return cache.creds;
46
+ }
47
+ /**
48
+ * Test helper. Not used by production code.
49
+ */
50
+ export function __resetPrimedCredentials() {
51
+ cache = null;
52
+ }
@@ -10,9 +10,6 @@
10
10
  * - CommandSpec.safetyTier: explicit action safety classification. See
11
11
  * SafetyTier for the 5-tier enum. Built-in entries set this on the
12
12
  * destructive tier; other tiers are derived (see deriveSafetyTier).
13
- * - CommandSpec.destructive (deprecated, v3.0 removal): legacy boolean
14
- * that maps to safetyTier === 'destructive'. Still accepted in
15
- * ~/.switchbot/catalog.json overlays and derived into safetyTier.
16
13
  * - DeviceCatalogEntry.role: functional grouping for filter/search
17
14
  * ("all lighting", "all security"). Does not affect API behavior.
18
15
  * - DeviceCatalogEntry.readOnly: the device has no control commands; it
@@ -629,25 +626,22 @@ export function findCatalogEntry(query) {
629
626
  *
630
627
  * The inference order is:
631
628
  * 1. Explicit `spec.safetyTier`.
632
- * 2. Legacy `spec.destructive: true` → `'destructive'` (overlay compat).
633
- * 3. IR context (customize command OR entry.category === 'ir')
629
+ * 2. IR context (customize command OR entry.category === 'ir')
634
630
  * → `'ir-fire-forget'`.
635
- * 4. Default → `'mutation'`.
631
+ * 3. Default → `'mutation'`.
636
632
  */
637
633
  export function deriveSafetyTier(spec, entry) {
638
634
  if (spec.safetyTier)
639
635
  return spec.safetyTier;
640
- if (spec.destructive)
641
- return 'destructive';
642
636
  if (spec.commandType === 'customize')
643
637
  return 'ir-fire-forget';
644
638
  if (entry?.category === 'ir')
645
639
  return 'ir-fire-forget';
646
640
  return 'mutation';
647
641
  }
648
- /** Read the safety reason for a command, with fallback to the legacy field. */
642
+ /** Read the safety reason for a command. */
649
643
  export function getCommandSafetyReason(spec) {
650
- return spec.safetyReason ?? spec.destructiveReason ?? null;
644
+ return spec.safetyReason ?? null;
651
645
  }
652
646
  /**
653
647
  * Pick up to 3 non-destructive, idempotent commands an agent can safely invoke
package/dist/index.js CHANGED
@@ -23,6 +23,17 @@ import { registerHistoryCommand } from './commands/history.js';
23
23
  import { registerPlanCommand } from './commands/plan.js';
24
24
  import { registerCapabilitiesCommand } from './commands/capabilities.js';
25
25
  import { registerAgentBootstrapCommand } from './commands/agent-bootstrap.js';
26
+ import { registerPolicyCommand } from './commands/policy.js';
27
+ import { registerRulesCommand } from './commands/rules.js';
28
+ import { registerAuthCommand } from './commands/auth.js';
29
+ import { registerInstallCommand } from './commands/install.js';
30
+ import { registerUninstallCommand } from './commands/uninstall.js';
31
+ import { registerStatusSyncCommand } from './commands/status-sync.js';
32
+ import { registerHealthCommand } from './commands/health.js';
33
+ import { registerUpgradeCheckCommand } from './commands/upgrade-check.js';
34
+ import { registerDaemonCommand } from './commands/daemon.js';
35
+ import { primeCredentials } from './credentials/prime.js';
36
+ import { getActiveProfile } from './lib/request-context.js';
26
37
  const require = createRequire(import.meta.url);
27
38
  const { version: pkgVersion } = require('../package.json');
28
39
  // Early initialization: check for --no-color flag or NO_COLOR env var and disable chalk.
@@ -41,7 +52,8 @@ if (isJsonMode()) {
41
52
  const TOP_LEVEL_COMMANDS = [
42
53
  'config', 'devices', 'scenes', 'webhook', 'completion', 'mcp',
43
54
  'quota', 'catalog', 'cache', 'events', 'doctor', 'schema',
44
- 'history', 'plan', 'capabilities', 'agent-bootstrap',
55
+ 'history', 'plan', 'capabilities', 'agent-bootstrap', 'install', 'uninstall', 'status-sync',
56
+ 'health', 'upgrade-check', 'daemon',
45
57
  ];
46
58
  const cacheModeArg = (value) => {
47
59
  if (value.startsWith('-')) {
@@ -94,6 +106,22 @@ registerHistoryCommand(program);
94
106
  registerPlanCommand(program);
95
107
  registerCapabilitiesCommand(program);
96
108
  registerAgentBootstrapCommand(program);
109
+ registerPolicyCommand(program);
110
+ registerRulesCommand(program);
111
+ registerAuthCommand(program);
112
+ registerInstallCommand(program);
113
+ registerUninstallCommand(program);
114
+ registerStatusSyncCommand(program);
115
+ registerHealthCommand(program);
116
+ registerUpgradeCheckCommand(program);
117
+ registerDaemonCommand(program);
118
+ // Prime keychain-stored credentials before any command runs. This is a
119
+ // best-effort probe: failures are silently swallowed inside primeCredentials,
120
+ // so the existing file-based path remains the safety net. We probe once per
121
+ // invocation (even for --help and --version, which is harmless).
122
+ program.hook('preAction', async () => {
123
+ await primeCredentials(getActiveProfile() ?? 'default');
124
+ });
97
125
  program.addHelpText('after', `
98
126
  Credentials:
99
127
  Provide SwitchBot API v1.1 credentials via either:
@@ -122,6 +150,7 @@ Examples:
122
150
  $ switchbot devices command <deviceId> turnOn --dry-run
123
151
  $ switchbot scenes execute <sceneId> --verbose
124
152
  $ switchbot webhook setup https://your.host/hook
153
+ $ switchbot status-sync start --openclaw-model home-agent
125
154
 
126
155
  Discovery:
127
156
  Don't know a device ID / what it supports?
@@ -0,0 +1,257 @@
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
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Install-orchestrator pre-flight (Phase 3A · F5).
3
+ *
4
+ * Pure library — no CLI entry. Consumers (e.g. a future
5
+ * `openclaw plugins install` command) call `runPreflight()` and decide
6
+ * whether to proceed based on the returned result. Nothing here mutates
7
+ * user state: every check is read-only.
8
+ *
9
+ * The check list mirrors `docs/design/phase3-install.md` step 1 minus
10
+ * the bits that require external services (npm registry / SwitchBot API
11
+ * reachability are left for the installer itself to probe when it has
12
+ * a plan to retry, since they are the flakiest of the lot).
13
+ */
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import os from 'node:os';
17
+ import { resolvePolicyPath, loadPolicyFile, PolicyFileNotFoundError } from '../policy/load.js';
18
+ import { validateLoadedPolicy } from '../policy/validate.js';
19
+ import { selectCredentialStore } from '../credentials/keychain.js';
20
+ function parseMajor(version) {
21
+ const m = /^v?(\d+)\./.exec(version);
22
+ if (!m)
23
+ return null;
24
+ const n = Number(m[1]);
25
+ return Number.isFinite(n) ? n : null;
26
+ }
27
+ function checkNodeVersion(opts) {
28
+ const required = opts.minNodeMajor ?? 18;
29
+ const version = opts.nodeVersion ?? process.version;
30
+ const major = parseMajor(version);
31
+ if (major === null) {
32
+ return {
33
+ name: 'node',
34
+ status: 'fail',
35
+ message: `unrecognised Node.js version string: ${version}`,
36
+ hint: 'reinstall Node.js from https://nodejs.org',
37
+ };
38
+ }
39
+ if (major < required) {
40
+ return {
41
+ name: 'node',
42
+ status: 'fail',
43
+ message: `Node.js ${version} < required v${required}`,
44
+ hint: `upgrade Node.js to v${required} or later`,
45
+ };
46
+ }
47
+ return { name: 'node', status: 'ok', message: `Node.js ${version}` };
48
+ }
49
+ function checkPolicy() {
50
+ const policyPath = resolvePolicyPath();
51
+ try {
52
+ const loaded = loadPolicyFile(policyPath);
53
+ const result = validateLoadedPolicy(loaded);
54
+ if (result.valid) {
55
+ return {
56
+ name: 'policy',
57
+ status: 'ok',
58
+ message: `policy at ${policyPath} validates (v${result.schemaVersion ?? '?'})`,
59
+ };
60
+ }
61
+ return {
62
+ name: 'policy',
63
+ status: 'warn',
64
+ message: `policy at ${policyPath} has ${result.errors.length} validation error(s)`,
65
+ hint: 'run "switchbot policy validate" to see details before installing',
66
+ };
67
+ }
68
+ catch (err) {
69
+ if (err instanceof PolicyFileNotFoundError) {
70
+ return {
71
+ name: 'policy',
72
+ status: 'ok',
73
+ message: `no policy at ${policyPath} (installer will scaffold one)`,
74
+ };
75
+ }
76
+ return {
77
+ name: 'policy',
78
+ status: 'warn',
79
+ message: `policy at ${policyPath} is unreadable: ${err instanceof Error ? err.message : String(err)}`,
80
+ hint: 'move the file aside, then re-run — the installer will scaffold a fresh copy',
81
+ };
82
+ }
83
+ }
84
+ async function checkKeychain() {
85
+ try {
86
+ const store = await selectCredentialStore();
87
+ const desc = store.describe();
88
+ if (desc.writable) {
89
+ return {
90
+ name: 'keychain',
91
+ status: 'ok',
92
+ message: `credential backend: ${desc.backend}`,
93
+ };
94
+ }
95
+ return {
96
+ name: 'keychain',
97
+ status: 'warn',
98
+ message: `credential backend ${desc.backend} is not writable — will fall back to file`,
99
+ hint: desc.notes ?? 'install the OS keychain helper to get native credential storage',
100
+ };
101
+ }
102
+ catch (err) {
103
+ return {
104
+ name: 'keychain',
105
+ status: 'warn',
106
+ message: `keychain probe failed: ${err instanceof Error ? err.message : String(err)}`,
107
+ hint: 'the installer will fall back to the file backend',
108
+ };
109
+ }
110
+ }
111
+ function checkHomeDirWritable() {
112
+ const home = os.homedir();
113
+ const switchbotDir = path.join(home, '.switchbot');
114
+ try {
115
+ const homeStat = fs.statSync(home);
116
+ if (!homeStat.isDirectory()) {
117
+ return {
118
+ name: 'home',
119
+ status: 'fail',
120
+ message: `home path is not a directory: ${home}`,
121
+ hint: 'check your HOME/USERPROFILE environment configuration',
122
+ };
123
+ }
124
+ if (fs.existsSync(switchbotDir)) {
125
+ const sbStat = fs.statSync(switchbotDir);
126
+ if (!sbStat.isDirectory()) {
127
+ return {
128
+ name: 'home',
129
+ status: 'fail',
130
+ message: `${switchbotDir} exists but is not a directory`,
131
+ hint: 'move the file aside and re-run install',
132
+ };
133
+ }
134
+ fs.accessSync(switchbotDir, fs.constants.W_OK);
135
+ return { name: 'home', status: 'ok', message: `writable: ${switchbotDir}` };
136
+ }
137
+ fs.accessSync(home, fs.constants.W_OK);
138
+ return { name: 'home', status: 'ok', message: `writable: ${home}` };
139
+ }
140
+ catch (err) {
141
+ return {
142
+ name: 'home',
143
+ status: 'fail',
144
+ message: `cannot write under ${home}: ${err instanceof Error ? err.message : String(err)}`,
145
+ hint: 'check ownership and permissions on your home directory',
146
+ };
147
+ }
148
+ }
149
+ function nearestExistingPath(target) {
150
+ let cur = target;
151
+ while (true) {
152
+ if (fs.existsSync(cur))
153
+ return cur;
154
+ const parent = path.dirname(cur);
155
+ if (parent === cur)
156
+ return null;
157
+ cur = parent;
158
+ }
159
+ }
160
+ function checkAgentSkillDirWritable(opts) {
161
+ const shouldCheck = opts.agent === 'claude-code' && (opts.expectSkillLink ?? true);
162
+ if (!shouldCheck)
163
+ return null;
164
+ const home = os.homedir();
165
+ const target = path.join(home, '.claude', 'skills');
166
+ try {
167
+ const existing = nearestExistingPath(target);
168
+ if (!existing) {
169
+ return {
170
+ name: 'agent-skills-dir',
171
+ status: 'fail',
172
+ message: `cannot resolve an existing parent for ${target}`,
173
+ hint: 'check your home directory path and permissions',
174
+ };
175
+ }
176
+ const stat = fs.statSync(existing);
177
+ if (!stat.isDirectory()) {
178
+ return {
179
+ name: 'agent-skills-dir',
180
+ status: 'fail',
181
+ message: `path component is not a directory: ${existing}`,
182
+ hint: 'move the blocking file aside and re-run install',
183
+ };
184
+ }
185
+ fs.accessSync(existing, fs.constants.W_OK);
186
+ return { name: 'agent-skills-dir', status: 'ok', message: `writable: ${target}` };
187
+ }
188
+ catch (err) {
189
+ return {
190
+ name: 'agent-skills-dir',
191
+ status: 'fail',
192
+ message: `cannot write to ${target}: ${err instanceof Error ? err.message : String(err)}`,
193
+ hint: 'open Claude Code once (it will create ~/.claude) or create the directory manually',
194
+ };
195
+ }
196
+ }
197
+ /**
198
+ * Run every pre-flight check and return a combined result. Safe to
199
+ * call multiple times; no state is cached.
200
+ */
201
+ export async function runPreflight(options = {}) {
202
+ const checks = [];
203
+ checks.push(checkNodeVersion(options));
204
+ checks.push(checkPolicy());
205
+ checks.push(await checkKeychain());
206
+ checks.push(checkHomeDirWritable());
207
+ const agentCheck = checkAgentSkillDirWritable(options);
208
+ if (agentCheck)
209
+ checks.push(agentCheck);
210
+ const ok = checks.every((c) => c.status !== 'fail');
211
+ return { checks, ok };
212
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Install-orchestrator step runner (Phase 3A · F5).
3
+ *
4
+ * Each step has a deterministic `execute` and a matching `undo`. The
5
+ * runner executes steps in order; on any failure it walks the
6
+ * already-completed steps in reverse and invokes their `undo`. If an
7
+ * `undo` itself fails, the error is captured and surfaced — the
8
+ * runner does NOT abort the rollback. The caller gets a full report
9
+ * and can decide how to surface partial cleanup failures.
10
+ *
11
+ * The module is intentionally agnostic of what steps do; consumers
12
+ * (future `openclaw plugins install`) plug in concrete steps like
13
+ * "npm i -g the CLI" or "write the credential to the keychain".
14
+ */
15
+ /**
16
+ * Run the given steps in order. On the first failure, the runner
17
+ * walks already-executed steps in reverse and invokes each step's
18
+ * undo. Returns a report describing every step's fate.
19
+ */
20
+ export async function runInstall(steps, options = {}) {
21
+ const ctx = (options.context ?? {});
22
+ const outcomes = [];
23
+ const executed = [];
24
+ let failedAt;
25
+ for (const step of steps) {
26
+ try {
27
+ await step.execute(ctx);
28
+ outcomes.push({ step: step.name, status: 'succeeded' });
29
+ executed.push(step);
30
+ }
31
+ catch (err) {
32
+ outcomes.push({
33
+ step: step.name,
34
+ status: 'failed',
35
+ error: err instanceof Error ? err.message : String(err),
36
+ });
37
+ failedAt = step.name;
38
+ break;
39
+ }
40
+ if (options.stopAfter === step.name)
41
+ break;
42
+ }
43
+ if (failedAt !== undefined) {
44
+ // Roll back completed steps in reverse. Undo failures are captured
45
+ // but do not abort further rollback attempts — the goal is to
46
+ // leave as little residue as possible.
47
+ for (let i = executed.length - 1; i >= 0; i--) {
48
+ const step = executed[i];
49
+ try {
50
+ await step.undo(ctx);
51
+ outcomes.push({ step: step.name, status: 'rolled-back' });
52
+ }
53
+ catch (err) {
54
+ outcomes.push({
55
+ step: step.name,
56
+ status: 'rollback-failed',
57
+ error: err instanceof Error ? err.message : String(err),
58
+ });
59
+ }
60
+ }
61
+ }
62
+ return {
63
+ ok: failedAt === undefined,
64
+ outcomes,
65
+ ...(failedAt !== undefined ? { failedAt } : {}),
66
+ };
67
+ }