@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.
- package/README.md +481 -103
- package/dist/api/client.js +23 -1
- package/dist/commands/agent-bootstrap.js +47 -2
- package/dist/commands/auth.js +354 -0
- package/dist/commands/batch.js +20 -4
- package/dist/commands/capabilities.js +155 -65
- package/dist/commands/config.js +109 -0
- package/dist/commands/daemon.js +367 -0
- package/dist/commands/devices.js +62 -11
- package/dist/commands/doctor.js +417 -8
- package/dist/commands/events.js +3 -3
- package/dist/commands/explain.js +1 -2
- package/dist/commands/health.js +113 -0
- package/dist/commands/install.js +246 -0
- package/dist/commands/mcp.js +888 -7
- package/dist/commands/plan.js +379 -103
- package/dist/commands/policy.js +586 -0
- package/dist/commands/rules.js +875 -0
- package/dist/commands/scenes.js +140 -0
- package/dist/commands/schema.js +0 -2
- package/dist/commands/status-sync.js +131 -0
- package/dist/commands/uninstall.js +237 -0
- package/dist/commands/upgrade-check.js +88 -0
- package/dist/config.js +14 -0
- package/dist/credentials/backends/file.js +101 -0
- package/dist/credentials/backends/linux.js +129 -0
- package/dist/credentials/backends/macos.js +129 -0
- package/dist/credentials/backends/windows.js +215 -0
- package/dist/credentials/keychain.js +88 -0
- package/dist/credentials/prime.js +52 -0
- package/dist/devices/catalog.js +4 -10
- package/dist/index.js +30 -1
- package/dist/install/default-steps.js +257 -0
- package/dist/install/preflight.js +212 -0
- package/dist/install/steps.js +67 -0
- package/dist/lib/command-keywords.js +17 -0
- package/dist/lib/daemon-state.js +46 -0
- package/dist/lib/destructive-mode.js +12 -0
- package/dist/lib/devices.js +1 -1
- package/dist/lib/plan-store.js +68 -0
- package/dist/policy/add-rule.js +124 -0
- package/dist/policy/diff.js +91 -0
- package/dist/policy/examples/policy.example.yaml +99 -0
- package/dist/policy/format.js +57 -0
- package/dist/policy/load.js +61 -0
- package/dist/policy/migrate.js +67 -0
- package/dist/policy/schema/v0.2.json +331 -0
- package/dist/policy/schema.js +18 -0
- package/dist/policy/validate.js +262 -0
- package/dist/rules/action.js +205 -0
- package/dist/rules/audit-query.js +89 -0
- package/dist/rules/conflict-analyzer.js +203 -0
- package/dist/rules/cron-scheduler.js +186 -0
- package/dist/rules/destructive.js +52 -0
- package/dist/rules/engine.js +757 -0
- package/dist/rules/matcher.js +230 -0
- package/dist/rules/pid-file.js +95 -0
- package/dist/rules/quiet-hours.js +45 -0
- package/dist/rules/suggest.js +95 -0
- package/dist/rules/throttle.js +116 -0
- package/dist/rules/types.js +34 -0
- package/dist/rules/webhook-listener.js +223 -0
- package/dist/rules/webhook-token.js +90 -0
- package/dist/status-sync/manager.js +268 -0
- package/dist/utils/audit.js +12 -2
- package/dist/utils/health.js +101 -0
- package/dist/utils/output.js +72 -23
- package/dist/utils/retry.js +81 -0
- 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
|
+
}
|
package/dist/devices/catalog.js
CHANGED
|
@@ -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.
|
|
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
|
-
*
|
|
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
|
|
642
|
+
/** Read the safety reason for a command. */
|
|
649
643
|
export function getCommandSafetyReason(spec) {
|
|
650
|
-
return spec.safetyReason ??
|
|
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
|
+
}
|