@switchbot/openapi-cli 2.7.2 → 3.0.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 +383 -101
- package/dist/commands/agent-bootstrap.js +47 -2
- package/dist/commands/auth.js +354 -0
- package/dist/commands/config.js +30 -0
- package/dist/commands/devices.js +0 -1
- package/dist/commands/doctor.js +184 -7
- package/dist/commands/events.js +3 -3
- package/dist/commands/explain.js +1 -2
- package/dist/commands/install.js +246 -0
- package/dist/commands/mcp.js +796 -3
- package/dist/commands/plan.js +110 -14
- package/dist/commands/policy.js +469 -0
- package/dist/commands/rules.js +657 -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/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 +23 -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/devices.js +0 -1
- 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 +302 -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/cron-scheduler.js +186 -0
- package/dist/rules/destructive.js +52 -0
- package/dist/rules/engine.js +567 -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 +78 -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/package.json +12 -4
package/dist/commands/doctor.js
CHANGED
|
@@ -9,32 +9,126 @@ import { DAILY_QUOTA, todayUsage } from '../utils/quota.js';
|
|
|
9
9
|
import { AGENT_BOOTSTRAP_SCHEMA_VERSION } from './agent-bootstrap.js';
|
|
10
10
|
import { CATALOG_SCHEMA_VERSION } from '../devices/catalog.js';
|
|
11
11
|
import { createSwitchBotMcpServer, listRegisteredTools } from './mcp.js';
|
|
12
|
+
import { resolvePolicyPath, loadPolicyFile, PolicyFileNotFoundError, PolicyYamlParseError, } from '../policy/load.js';
|
|
13
|
+
import { validateLoadedPolicy } from '../policy/validate.js';
|
|
14
|
+
import { selectCredentialStore } from '../credentials/keychain.js';
|
|
15
|
+
import { getActiveProfile } from '../lib/request-context.js';
|
|
12
16
|
export const DOCTOR_SCHEMA_VERSION = 1;
|
|
13
17
|
async function checkCredentials() {
|
|
14
18
|
const envOk = Boolean(process.env.SWITCHBOT_TOKEN && process.env.SWITCHBOT_SECRET);
|
|
15
|
-
|
|
16
|
-
|
|
19
|
+
const profile = getActiveProfile() ?? 'default';
|
|
20
|
+
let backendName = 'file';
|
|
21
|
+
let backendLabel = 'file';
|
|
22
|
+
let writable = true;
|
|
23
|
+
let keychainHasProfile = false;
|
|
24
|
+
try {
|
|
25
|
+
const store = await selectCredentialStore();
|
|
26
|
+
const desc = store.describe();
|
|
27
|
+
backendName = store.name;
|
|
28
|
+
backendLabel = desc.backend;
|
|
29
|
+
writable = desc.writable;
|
|
30
|
+
try {
|
|
31
|
+
const creds = await store.get(profile);
|
|
32
|
+
keychainHasProfile = Boolean(creds && creds.token && creds.secret);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
keychainHasProfile = false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// selectCredentialStore falls back to file; a throw here is unexpected but
|
|
40
|
+
// non-fatal — downstream callers degrade to the file path.
|
|
41
|
+
}
|
|
42
|
+
if (envOk) {
|
|
43
|
+
return {
|
|
44
|
+
name: 'credentials',
|
|
45
|
+
status: 'ok',
|
|
46
|
+
detail: {
|
|
47
|
+
source: 'env',
|
|
48
|
+
backend: backendName,
|
|
49
|
+
backendLabel,
|
|
50
|
+
writable,
|
|
51
|
+
profile,
|
|
52
|
+
message: 'env: SWITCHBOT_TOKEN + SWITCHBOT_SECRET',
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (keychainHasProfile && backendName !== 'file') {
|
|
57
|
+
return {
|
|
58
|
+
name: 'credentials',
|
|
59
|
+
status: 'ok',
|
|
60
|
+
detail: {
|
|
61
|
+
source: 'keychain',
|
|
62
|
+
backend: backendName,
|
|
63
|
+
backendLabel,
|
|
64
|
+
writable,
|
|
65
|
+
profile,
|
|
66
|
+
message: `keychain (${backendLabel}) has credentials for profile "${profile}"`,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
17
70
|
const file = configFilePath();
|
|
18
71
|
if (!fs.existsSync(file)) {
|
|
19
72
|
return {
|
|
20
73
|
name: 'credentials',
|
|
21
74
|
status: 'fail',
|
|
22
|
-
detail:
|
|
75
|
+
detail: {
|
|
76
|
+
source: 'none',
|
|
77
|
+
backend: backendName,
|
|
78
|
+
backendLabel,
|
|
79
|
+
writable,
|
|
80
|
+
profile,
|
|
81
|
+
message: `No env vars, no keychain entry for profile "${profile}", and no config at ${file}. Run 'switchbot config set-token' or 'switchbot auth keychain set'.`,
|
|
82
|
+
},
|
|
23
83
|
};
|
|
24
84
|
}
|
|
25
85
|
try {
|
|
26
86
|
const raw = fs.readFileSync(file, 'utf-8');
|
|
27
87
|
const cfg = JSON.parse(raw);
|
|
28
88
|
if (!cfg.token || !cfg.secret) {
|
|
29
|
-
return {
|
|
89
|
+
return {
|
|
90
|
+
name: 'credentials',
|
|
91
|
+
status: 'fail',
|
|
92
|
+
detail: {
|
|
93
|
+
source: 'file',
|
|
94
|
+
backend: backendName,
|
|
95
|
+
backendLabel,
|
|
96
|
+
writable,
|
|
97
|
+
profile,
|
|
98
|
+
message: `Config ${file} missing token/secret.`,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
30
101
|
}
|
|
31
|
-
|
|
102
|
+
const status = writable && backendName !== 'file' ? 'warn' : 'ok';
|
|
103
|
+
const hint = status === 'warn'
|
|
104
|
+
? `Consider running 'switchbot auth keychain migrate' to move credentials into ${backendLabel}.`
|
|
105
|
+
: undefined;
|
|
106
|
+
return {
|
|
107
|
+
name: 'credentials',
|
|
108
|
+
status,
|
|
109
|
+
detail: {
|
|
110
|
+
source: 'file',
|
|
111
|
+
backend: backendName,
|
|
112
|
+
backendLabel,
|
|
113
|
+
writable,
|
|
114
|
+
profile,
|
|
115
|
+
message: `file: ${file}`,
|
|
116
|
+
...(hint ? { hint } : {}),
|
|
117
|
+
},
|
|
118
|
+
};
|
|
32
119
|
}
|
|
33
120
|
catch (err) {
|
|
34
121
|
return {
|
|
35
122
|
name: 'credentials',
|
|
36
123
|
status: 'fail',
|
|
37
|
-
detail:
|
|
124
|
+
detail: {
|
|
125
|
+
source: 'file',
|
|
126
|
+
backend: backendName,
|
|
127
|
+
backendLabel,
|
|
128
|
+
writable,
|
|
129
|
+
profile,
|
|
130
|
+
message: `Unreadable config ${file}: ${err instanceof Error ? err.message : String(err)}`,
|
|
131
|
+
},
|
|
38
132
|
};
|
|
39
133
|
}
|
|
40
134
|
}
|
|
@@ -308,6 +402,84 @@ function checkAudit() {
|
|
|
308
402
|
};
|
|
309
403
|
}
|
|
310
404
|
}
|
|
405
|
+
function checkPolicy() {
|
|
406
|
+
// A policy file is optional — many users run the CLI without one. Report
|
|
407
|
+
// `ok` with `present: false` so agents can tell the difference between
|
|
408
|
+
// "no policy configured" (fine) and "policy broken" (needs attention).
|
|
409
|
+
const policyPath = resolvePolicyPath();
|
|
410
|
+
try {
|
|
411
|
+
const loaded = loadPolicyFile(policyPath);
|
|
412
|
+
const result = validateLoadedPolicy(loaded);
|
|
413
|
+
if (result.valid) {
|
|
414
|
+
return {
|
|
415
|
+
name: 'policy',
|
|
416
|
+
status: 'ok',
|
|
417
|
+
detail: {
|
|
418
|
+
path: policyPath,
|
|
419
|
+
present: true,
|
|
420
|
+
valid: true,
|
|
421
|
+
schemaVersion: result.schemaVersion,
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
name: 'policy',
|
|
427
|
+
status: 'fail',
|
|
428
|
+
detail: {
|
|
429
|
+
path: policyPath,
|
|
430
|
+
present: true,
|
|
431
|
+
valid: false,
|
|
432
|
+
schemaVersion: result.schemaVersion,
|
|
433
|
+
errorCount: result.errors.length,
|
|
434
|
+
firstError: result.errors[0]
|
|
435
|
+
? {
|
|
436
|
+
path: result.errors[0].path,
|
|
437
|
+
line: result.errors[0].line,
|
|
438
|
+
message: result.errors[0].message,
|
|
439
|
+
}
|
|
440
|
+
: undefined,
|
|
441
|
+
message: "run 'switchbot policy validate' for full diagnostics",
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
if (err instanceof PolicyFileNotFoundError) {
|
|
447
|
+
return {
|
|
448
|
+
name: 'policy',
|
|
449
|
+
status: 'ok',
|
|
450
|
+
detail: {
|
|
451
|
+
path: policyPath,
|
|
452
|
+
present: false,
|
|
453
|
+
message: "no policy file (optional — run 'switchbot policy new' to scaffold one)",
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
if (err instanceof PolicyYamlParseError) {
|
|
458
|
+
const first = err.yamlErrors[0];
|
|
459
|
+
return {
|
|
460
|
+
name: 'policy',
|
|
461
|
+
status: 'fail',
|
|
462
|
+
detail: {
|
|
463
|
+
path: policyPath,
|
|
464
|
+
present: true,
|
|
465
|
+
valid: false,
|
|
466
|
+
parseError: true,
|
|
467
|
+
line: first?.line,
|
|
468
|
+
col: first?.col,
|
|
469
|
+
message: first?.message ?? err.message,
|
|
470
|
+
},
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
name: 'policy',
|
|
475
|
+
status: 'warn',
|
|
476
|
+
detail: {
|
|
477
|
+
path: policyPath,
|
|
478
|
+
message: `could not read policy file: ${err instanceof Error ? err.message : String(err)}`,
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
}
|
|
311
483
|
function checkNodeVersion() {
|
|
312
484
|
const major = Number(process.versions.node.split('.')[0]);
|
|
313
485
|
if (Number.isFinite(major) && major < 18) {
|
|
@@ -444,6 +616,7 @@ const CHECK_REGISTRY = [
|
|
|
444
616
|
run: ({ probe }) => (probe ? checkMqttProbe() : checkMqtt()),
|
|
445
617
|
},
|
|
446
618
|
{ name: 'mcp', description: 'MCP server instantiable + tool count', run: () => checkMcp() },
|
|
619
|
+
{ name: 'policy', description: 'policy.yaml present + schema-valid (if configured)', run: () => checkPolicy() },
|
|
447
620
|
{ name: 'audit', description: 'recent command errors (last 24h)', run: () => checkAudit() },
|
|
448
621
|
];
|
|
449
622
|
function applyFixes(checks, writeOk) {
|
|
@@ -586,7 +759,11 @@ Examples:
|
|
|
586
759
|
else {
|
|
587
760
|
for (const c of checks) {
|
|
588
761
|
const icon = c.status === 'ok' ? '✓' : c.status === 'warn' ? '!' : '✗';
|
|
589
|
-
const detailStr = typeof c.detail === 'string'
|
|
762
|
+
const detailStr = typeof c.detail === 'string'
|
|
763
|
+
? c.detail
|
|
764
|
+
: (typeof c.detail.message === 'string'
|
|
765
|
+
? (c.detail.message)
|
|
766
|
+
: JSON.stringify(c.detail));
|
|
590
767
|
console.log(`${icon} ${c.name.padEnd(12)} ${detailStr}`);
|
|
591
768
|
}
|
|
592
769
|
console.log('');
|
package/dist/commands/events.js
CHANGED
|
@@ -114,10 +114,10 @@ export function startReceiver(port, pathMatch, filter, onEvent) {
|
|
|
114
114
|
if (size > MAX_BODY_BYTES) {
|
|
115
115
|
bailed = true;
|
|
116
116
|
res.statusCode = 413;
|
|
117
|
-
res.setHeader('connection', 'close');
|
|
118
117
|
res.end('payload too large');
|
|
119
|
-
//
|
|
120
|
-
|
|
118
|
+
// Drain remaining upload so the client can read the 413 response before
|
|
119
|
+
// the connection closes naturally (avoids ECONNRESET racing the response).
|
|
120
|
+
req.resume();
|
|
121
121
|
return;
|
|
122
122
|
}
|
|
123
123
|
chunks.push(c);
|
package/dist/commands/explain.js
CHANGED
|
@@ -50,7 +50,6 @@ Examples:
|
|
|
50
50
|
parameter: c.parameter,
|
|
51
51
|
idempotent: c.idempotent,
|
|
52
52
|
...(tier ? { safetyTier: tier } : {}),
|
|
53
|
-
destructive: c.destructive,
|
|
54
53
|
};
|
|
55
54
|
})
|
|
56
55
|
: [];
|
|
@@ -114,7 +113,7 @@ function printHuman(r) {
|
|
|
114
113
|
if (r.commands.length) {
|
|
115
114
|
console.log('commands:');
|
|
116
115
|
for (const c of r.commands) {
|
|
117
|
-
const flags = [c.idempotent && 'idempotent', c.destructive && 'destructive']
|
|
116
|
+
const flags = [c.idempotent && 'idempotent', c.safetyTier === 'destructive' && 'destructive']
|
|
118
117
|
.filter(Boolean)
|
|
119
118
|
.join(', ');
|
|
120
119
|
const suffix = flags ? ` [${flags}]` : '';
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `switchbot install` — one-command bootstrap (Phase 3B in-repo).
|
|
3
|
+
*
|
|
4
|
+
* Collapses the 7-step Quickstart (credentials → policy → skill link →
|
|
5
|
+
* doctor verify) into a single orchestrated command with automatic
|
|
6
|
+
* rollback on any step failure. The step library
|
|
7
|
+
* (`src/install/default-steps.ts`) does the heavy lifting; this file
|
|
8
|
+
* composes the steps based on user flags, drives the step runner, and
|
|
9
|
+
* formats the outcome.
|
|
10
|
+
*
|
|
11
|
+
* Design notes:
|
|
12
|
+
* - `switchbot install` assumes the CLI is already on PATH (the user
|
|
13
|
+
* ran `npm i -g @switchbot/openapi-cli` to get here). We do not
|
|
14
|
+
* re-install the CLI from inside itself.
|
|
15
|
+
* - Doctor verification is NOT a step — if it failed, an automatic
|
|
16
|
+
* rollback would destroy good state. Instead we print a "next: run
|
|
17
|
+
* `switchbot doctor`" hint after success.
|
|
18
|
+
*/
|
|
19
|
+
import { InvalidArgumentError } from 'commander';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { resolvePolicyPath } from '../policy/load.js';
|
|
23
|
+
import { runInstall } from '../install/steps.js';
|
|
24
|
+
import { runPreflight } from '../install/preflight.js';
|
|
25
|
+
import { stepPromptCredentials, stepWriteKeychain, stepScaffoldPolicy, stepSymlinkSkill, stepDoctorVerify, } from '../install/default-steps.js';
|
|
26
|
+
import { isJsonMode, printJson } from '../utils/output.js';
|
|
27
|
+
import { getActiveProfile } from '../lib/request-context.js';
|
|
28
|
+
import chalk from 'chalk';
|
|
29
|
+
const AGENT_VALUES = ['claude-code', 'cursor', 'copilot', 'none'];
|
|
30
|
+
function parseAgent(value) {
|
|
31
|
+
if (!value)
|
|
32
|
+
return 'claude-code';
|
|
33
|
+
if (!AGENT_VALUES.includes(value)) {
|
|
34
|
+
throw new InvalidArgumentError(`--agent must be one of ${AGENT_VALUES.join(', ')} (got "${value}")`);
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
function parseSkipList(value) {
|
|
39
|
+
if (!value)
|
|
40
|
+
return new Set();
|
|
41
|
+
return new Set(value
|
|
42
|
+
.split(',')
|
|
43
|
+
.map((s) => s.trim())
|
|
44
|
+
.filter(Boolean));
|
|
45
|
+
}
|
|
46
|
+
function printRecipe(ctx) {
|
|
47
|
+
if (!ctx.skillRecipePrinted)
|
|
48
|
+
return;
|
|
49
|
+
const lines = [];
|
|
50
|
+
lines.push('');
|
|
51
|
+
lines.push(chalk.bold(`Skill-install recipe for agent=${ctx.agent}:`));
|
|
52
|
+
switch (ctx.agent) {
|
|
53
|
+
case 'claude-code':
|
|
54
|
+
lines.push(' # re-run with --skill-path pointing at your local clone of openclaw-switchbot-skill', ' switchbot install --agent claude-code --skill-path /path/to/openclaw-switchbot-skill');
|
|
55
|
+
break;
|
|
56
|
+
case 'cursor':
|
|
57
|
+
lines.push(' # Cursor expects a rules file, not a skill directory. See:', ' # openclaw-switchbot-skill/docs/agents/cursor.md');
|
|
58
|
+
break;
|
|
59
|
+
case 'copilot':
|
|
60
|
+
lines.push(' # Copilot merges instructions into .github/copilot-instructions.md. See:', ' # openclaw-switchbot-skill/docs/agents/copilot.md');
|
|
61
|
+
break;
|
|
62
|
+
case 'none':
|
|
63
|
+
lines.push(' (none — skill step skipped)');
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
console.error(lines.join('\n'));
|
|
67
|
+
}
|
|
68
|
+
function printDryRun(steps, ctx) {
|
|
69
|
+
if (isJsonMode()) {
|
|
70
|
+
printJson({
|
|
71
|
+
dryRun: true,
|
|
72
|
+
profile: ctx.profile,
|
|
73
|
+
agent: ctx.agent,
|
|
74
|
+
skillPath: ctx.skillPath ?? null,
|
|
75
|
+
policyPath: ctx.policyPath,
|
|
76
|
+
steps: steps.map((s) => ({ name: s.name, description: s.description })),
|
|
77
|
+
});
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
console.log(chalk.bold('switchbot install — dry run'));
|
|
81
|
+
console.log(` profile: ${ctx.profile}`);
|
|
82
|
+
console.log(` agent: ${ctx.agent}`);
|
|
83
|
+
console.log(` skill: ${ctx.skillPath ?? '(none — recipe will be printed)'}`);
|
|
84
|
+
console.log(` policy: ${ctx.policyPath}`);
|
|
85
|
+
console.log('');
|
|
86
|
+
console.log(chalk.bold('Steps that would run (in order):'));
|
|
87
|
+
for (const s of steps) {
|
|
88
|
+
console.log(` • ${s.name}${s.description ? ` — ${s.description}` : ''}`);
|
|
89
|
+
}
|
|
90
|
+
console.log('');
|
|
91
|
+
console.log(chalk.dim('No changes made. Re-run without --dry-run to apply.'));
|
|
92
|
+
}
|
|
93
|
+
export function registerInstallCommand(program) {
|
|
94
|
+
program
|
|
95
|
+
.command('install')
|
|
96
|
+
.description('One-command bootstrap: credentials + policy + skill link (rolls back on failure)')
|
|
97
|
+
.option('--agent <name>', `target agent: ${AGENT_VALUES.join(' | ')} (default: claude-code)`)
|
|
98
|
+
.option('--skill-path <dir>', 'local clone of openclaw-switchbot-skill (enables auto-link)')
|
|
99
|
+
.option('--token-file <path>', 'two-line credential file (token, secret); read once and deleted on success')
|
|
100
|
+
.option('--skip <names>', 'comma-separated list of step names to skip (e.g. "scaffold-policy,symlink-skill")')
|
|
101
|
+
.option('--force', 'replace an existing skill symlink pointing at a different path; allow link even without SKILL.md')
|
|
102
|
+
.option('--verify', 'after a successful install, run `switchbot doctor --json` as a warn-only post-check')
|
|
103
|
+
.addHelpText('after', `
|
|
104
|
+
The global --dry-run flag previews the step list without making changes.
|
|
105
|
+
Global --json emits the install report as JSON to stdout.
|
|
106
|
+
|
|
107
|
+
Exit codes:
|
|
108
|
+
0 success
|
|
109
|
+
2 preflight check failed (nothing changed)
|
|
110
|
+
3 step failed; rollback completed
|
|
111
|
+
4 step failed; rollback had residue (see output)
|
|
112
|
+
|
|
113
|
+
Examples:
|
|
114
|
+
# Interactive install, Claude Code skill not linked (recipe printed):
|
|
115
|
+
switchbot install
|
|
116
|
+
|
|
117
|
+
# Full install with skill link:
|
|
118
|
+
switchbot install --skill-path ../openclaw-switchbot-skill
|
|
119
|
+
|
|
120
|
+
# Non-interactive (CI) install:
|
|
121
|
+
printf '%s\\n%s\\n' "$TOKEN" "$SECRET" > /tmp/sb-creds
|
|
122
|
+
switchbot install --token-file /tmp/sb-creds --skill-path ./skill
|
|
123
|
+
`)
|
|
124
|
+
.action(async (opts, command) => {
|
|
125
|
+
const agent = parseAgent(opts.agent);
|
|
126
|
+
const profile = getActiveProfile() ?? 'default';
|
|
127
|
+
const skip = parseSkipList(opts.skip);
|
|
128
|
+
const skillPath = opts.skillPath ? path.resolve(opts.skillPath) : undefined;
|
|
129
|
+
const tokenFile = opts.tokenFile ? path.resolve(opts.tokenFile) : undefined;
|
|
130
|
+
const force = Boolean(opts.force);
|
|
131
|
+
const verify = Boolean(opts.verify);
|
|
132
|
+
const globalOpts = command.parent?.opts() ?? {};
|
|
133
|
+
const dryRun = Boolean(globalOpts.dryRun);
|
|
134
|
+
// Pre-flight: read-only checks, never mutate anything.
|
|
135
|
+
const pf = await runPreflight({
|
|
136
|
+
agent,
|
|
137
|
+
expectSkillLink: agent === 'claude-code' && Boolean(skillPath),
|
|
138
|
+
});
|
|
139
|
+
if (!pf.ok) {
|
|
140
|
+
if (isJsonMode()) {
|
|
141
|
+
printJson({ ok: false, stage: 'preflight', preflight: pf });
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
console.error(chalk.red('✗ preflight failed — nothing changed'));
|
|
145
|
+
for (const c of pf.checks) {
|
|
146
|
+
const mark = c.status === 'fail' ? chalk.red('✗') : c.status === 'warn' ? chalk.yellow('!') : chalk.green('✓');
|
|
147
|
+
console.error(` ${mark} ${c.name}: ${c.message}`);
|
|
148
|
+
if (c.hint)
|
|
149
|
+
console.error(` hint: ${c.hint}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
process.exit(2);
|
|
153
|
+
}
|
|
154
|
+
const ctx = {
|
|
155
|
+
profile,
|
|
156
|
+
agent,
|
|
157
|
+
skillPath,
|
|
158
|
+
tokenFile,
|
|
159
|
+
policyPath: resolvePolicyPath(),
|
|
160
|
+
nonInteractive: !process.stdin.isTTY && !tokenFile,
|
|
161
|
+
};
|
|
162
|
+
const allSteps = [
|
|
163
|
+
stepPromptCredentials(),
|
|
164
|
+
stepWriteKeychain(),
|
|
165
|
+
stepScaffoldPolicy(),
|
|
166
|
+
stepSymlinkSkill({ force }),
|
|
167
|
+
];
|
|
168
|
+
const steps = allSteps.filter((s) => !skip.has(s.name));
|
|
169
|
+
if (dryRun) {
|
|
170
|
+
printDryRun(steps, ctx);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const report = await runInstall(steps, { context: ctx });
|
|
174
|
+
// Delete the token file now that credentials are committed.
|
|
175
|
+
if (report.ok && tokenFile) {
|
|
176
|
+
try {
|
|
177
|
+
fs.unlinkSync(tokenFile);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// non-fatal: credentials are already in the keychain
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// A7: opt-in post-install verification. Doctor is NEVER part of the
|
|
184
|
+
// rollback chain — a failing doctor after a good install would
|
|
185
|
+
// destroy working state. So we run it AFTER runInstall resolves, as
|
|
186
|
+
// a warn-only check. The outcome is reported but never flips the
|
|
187
|
+
// command's exit code.
|
|
188
|
+
if (report.ok && verify) {
|
|
189
|
+
const cliPath = process.argv[1] ?? '';
|
|
190
|
+
const step = stepDoctorVerify({ cliPath });
|
|
191
|
+
await step.execute(ctx);
|
|
192
|
+
}
|
|
193
|
+
if (isJsonMode()) {
|
|
194
|
+
printJson({
|
|
195
|
+
ok: report.ok,
|
|
196
|
+
profile: ctx.profile,
|
|
197
|
+
agent: ctx.agent,
|
|
198
|
+
report,
|
|
199
|
+
preflight: pf,
|
|
200
|
+
policyPath: ctx.policyPath,
|
|
201
|
+
policyScaffolded: ctx.policyScaffoldResult && !ctx.policyScaffoldResult.skipped,
|
|
202
|
+
skillLinkPath: ctx.skillLinkPath,
|
|
203
|
+
skillLinkCreated: Boolean(ctx.skillLinkCreated),
|
|
204
|
+
verify: verify ? { ok: ctx.doctorOk ?? null, report: ctx.doctorReport ?? null } : undefined,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
else if (report.ok) {
|
|
208
|
+
console.log(chalk.green('✓ install complete'));
|
|
209
|
+
if (ctx.skillLinkCreated)
|
|
210
|
+
console.log(` linked skill: ${ctx.skillLinkPath}`);
|
|
211
|
+
if (ctx.policyScaffoldResult?.skipped === false)
|
|
212
|
+
console.log(` wrote policy: ${ctx.policyScaffoldResult.policyPath}`);
|
|
213
|
+
printRecipe(ctx);
|
|
214
|
+
if (verify) {
|
|
215
|
+
if (ctx.doctorOk) {
|
|
216
|
+
console.log(chalk.green('✓ doctor --json: all green'));
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
console.log(chalk.yellow('! doctor --json reported issues — install is committed; run `switchbot doctor` to inspect'));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
console.log('');
|
|
223
|
+
console.log(chalk.bold('Next:'));
|
|
224
|
+
console.log(' switchbot doctor # verify the setup');
|
|
225
|
+
console.log(' switchbot devices list # smoke test');
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
console.error(chalk.red(`✗ install failed at step: ${report.failedAt}`));
|
|
229
|
+
const residue = report.outcomes.some((o) => o.status === 'rollback-failed');
|
|
230
|
+
for (const o of report.outcomes) {
|
|
231
|
+
const tag = o.status === 'succeeded' ? chalk.green('✓') :
|
|
232
|
+
o.status === 'failed' ? chalk.red('✗') :
|
|
233
|
+
o.status === 'rolled-back' ? chalk.yellow('↺') :
|
|
234
|
+
o.status === 'rollback-failed' ? chalk.red('!!') :
|
|
235
|
+
chalk.dim('·');
|
|
236
|
+
const msg = o.status === 'failed' || o.status === 'rollback-failed' ? ` — ${o.error}` : '';
|
|
237
|
+
console.error(` ${tag} ${o.step} [${o.status}]${msg}`);
|
|
238
|
+
}
|
|
239
|
+
if (residue) {
|
|
240
|
+
console.error(chalk.red('Rollback left residue. Run `switchbot uninstall` to clean up or review output above.'));
|
|
241
|
+
process.exit(4);
|
|
242
|
+
}
|
|
243
|
+
process.exit(3);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|