@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,101 @@
1
+ /**
2
+ * File-backed credential store.
3
+ *
4
+ * Reads/writes the same `~/.switchbot/config.json` shape the CLI has
5
+ * used since v1.0, so a fresh install on a machine without a keychain
6
+ * still works and legacy users can migrate in-place via
7
+ * `switchbot auth keychain migrate` without data loss.
8
+ *
9
+ * Profile layout (inherited from `src/config.ts`):
10
+ * - default profile → `~/.switchbot/config.json`
11
+ * - named profile → `~/.switchbot/profiles/<name>.json`
12
+ *
13
+ * This backend only owns the `token` and `secret` fields — label /
14
+ * description / limits / defaults are preserved on write by merging
15
+ * with the existing JSON, keeping parity with `saveConfig()`.
16
+ */
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import { KeychainError, } from '../keychain.js';
21
+ function profilePath(profile) {
22
+ if (profile === 'default') {
23
+ return path.join(os.homedir(), '.switchbot', 'config.json');
24
+ }
25
+ return path.join(os.homedir(), '.switchbot', 'profiles', `${profile}.json`);
26
+ }
27
+ function readJson(file) {
28
+ if (!fs.existsSync(file))
29
+ return null;
30
+ try {
31
+ const raw = fs.readFileSync(file, 'utf-8');
32
+ const parsed = JSON.parse(raw);
33
+ return parsed && typeof parsed === 'object' ? parsed : null;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ export function createFileBackend() {
40
+ return {
41
+ name: 'file',
42
+ async get(profile) {
43
+ const file = profilePath(profile);
44
+ const data = readJson(file);
45
+ if (!data)
46
+ return null;
47
+ const token = typeof data.token === 'string' ? data.token : '';
48
+ const secret = typeof data.secret === 'string' ? data.secret : '';
49
+ if (!token || !secret)
50
+ return null;
51
+ return { token, secret };
52
+ },
53
+ async set(profile, creds) {
54
+ const file = profilePath(profile);
55
+ const dir = path.dirname(file);
56
+ try {
57
+ fs.mkdirSync(dir, { recursive: true });
58
+ const existing = readJson(file) ?? {};
59
+ const next = { ...existing, token: creds.token, secret: creds.secret };
60
+ fs.writeFileSync(file, JSON.stringify(next, null, 2), { mode: 0o600 });
61
+ }
62
+ catch (err) {
63
+ const msg = err instanceof Error ? err.message : String(err);
64
+ throw new KeychainError('file', 'set', msg);
65
+ }
66
+ },
67
+ async delete(profile) {
68
+ const file = profilePath(profile);
69
+ try {
70
+ if (!fs.existsSync(file))
71
+ return;
72
+ const existing = readJson(file);
73
+ if (existing) {
74
+ delete existing.token;
75
+ delete existing.secret;
76
+ if (Object.keys(existing).length === 0) {
77
+ fs.unlinkSync(file);
78
+ }
79
+ else {
80
+ fs.writeFileSync(file, JSON.stringify(existing, null, 2), { mode: 0o600 });
81
+ }
82
+ }
83
+ else {
84
+ fs.unlinkSync(file);
85
+ }
86
+ }
87
+ catch (err) {
88
+ const msg = err instanceof Error ? err.message : String(err);
89
+ throw new KeychainError('file', 'delete', msg);
90
+ }
91
+ },
92
+ describe() {
93
+ return {
94
+ backend: 'File (~/.switchbot/)',
95
+ tag: 'file',
96
+ writable: true,
97
+ notes: 'Last-resort fallback; credentials stored in a 0600 JSON file.',
98
+ };
99
+ },
100
+ };
101
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Linux libsecret backend.
3
+ *
4
+ * Shells out to `secret-tool(1)` — the libsecret CLI shipped by most
5
+ * distros when GNOME Keyring or KWallet is available. We intentionally
6
+ * avoid a native binding so `npm install` doesn't drag in a build
7
+ * toolchain on minimal CI images.
8
+ *
9
+ * On a fresh Linux box without secret-tool installed (or without a
10
+ * secret service daemon running), `linuxAvailable()` returns false and
11
+ * `selectCredentialStore()` falls back to the file backend. We do NOT
12
+ * try to `apt install libsecret-tools` on the user's behalf.
13
+ */
14
+ import { spawn } from 'node:child_process';
15
+ import { accountFor, CREDENTIAL_SERVICE, KeychainError, } from '../keychain.js';
16
+ function run(cmd, args, stdin) {
17
+ return new Promise((resolve) => {
18
+ const proc = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
19
+ let stdout = '';
20
+ let stderr = '';
21
+ proc.stdout.on('data', (buf) => {
22
+ stdout += buf.toString('utf-8');
23
+ });
24
+ proc.stderr.on('data', (buf) => {
25
+ stderr += buf.toString('utf-8');
26
+ });
27
+ proc.on('error', () => resolve({ code: 127, stdout, stderr }));
28
+ proc.on('close', (code) => resolve({ code: code ?? 0, stdout, stderr }));
29
+ if (stdin !== undefined) {
30
+ proc.stdin.write(stdin);
31
+ }
32
+ proc.stdin.end();
33
+ });
34
+ }
35
+ export async function linuxAvailable() {
36
+ if (process.platform !== 'linux')
37
+ return false;
38
+ const which = await run('which', ['secret-tool']);
39
+ if (which.code !== 0 || which.stdout.trim().length === 0)
40
+ return false;
41
+ // Probe the secret service is actually running. `secret-tool search`
42
+ // with a bogus attribute returns 0 on miss but 1 when the D-Bus
43
+ // service isn't reachable — so we use the exit code to distinguish.
44
+ const probe = await run('secret-tool', ['search', 'service', CREDENTIAL_SERVICE]);
45
+ return probe.code === 0 || probe.code === 1;
46
+ }
47
+ async function readField(profile, field) {
48
+ const account = accountFor(profile, field);
49
+ const res = await run('secret-tool', [
50
+ 'lookup',
51
+ 'service', CREDENTIAL_SERVICE,
52
+ 'account', account,
53
+ ]);
54
+ if (res.code !== 0)
55
+ return null;
56
+ const value = res.stdout.replace(/\n$/, '');
57
+ return value.length > 0 ? value : null;
58
+ }
59
+ async function writeField(profile, field, value) {
60
+ const account = accountFor(profile, field);
61
+ const label = `SwitchBot CLI (${account})`;
62
+ // `secret-tool store` reads the password from stdin.
63
+ const res = await run('secret-tool', ['store', '--label', label, 'service', CREDENTIAL_SERVICE, 'account', account], value);
64
+ if (res.code !== 0) {
65
+ throw new KeychainError('secret-service', 'set', `secret-tool exit ${res.code}`);
66
+ }
67
+ }
68
+ async function deleteField(profile, field) {
69
+ const account = accountFor(profile, field);
70
+ const res = await run('secret-tool', [
71
+ 'clear',
72
+ 'service', CREDENTIAL_SERVICE,
73
+ 'account', account,
74
+ ]);
75
+ // secret-tool returns 0 even when nothing matched, so we tolerate
76
+ // both 0 and the "nothing to clear" path transparently.
77
+ if (res.code !== 0) {
78
+ throw new KeychainError('secret-service', 'delete', `secret-tool exit ${res.code}`);
79
+ }
80
+ }
81
+ async function restoreField(profile, field, value) {
82
+ try {
83
+ if (value === null) {
84
+ await deleteField(profile, field);
85
+ return;
86
+ }
87
+ await writeField(profile, field, value);
88
+ }
89
+ catch {
90
+ // Best effort only. The original write error is the actionable failure.
91
+ }
92
+ }
93
+ export function createLinuxBackend() {
94
+ return {
95
+ name: 'secret-service',
96
+ async get(profile) {
97
+ const token = await readField(profile, 'token');
98
+ const secret = await readField(profile, 'secret');
99
+ if (!token || !secret)
100
+ return null;
101
+ return { token, secret };
102
+ },
103
+ async set(profile, creds) {
104
+ const previousToken = await readField(profile, 'token');
105
+ const previousSecret = await readField(profile, 'secret');
106
+ try {
107
+ await writeField(profile, 'token', creds.token);
108
+ await writeField(profile, 'secret', creds.secret);
109
+ }
110
+ catch (err) {
111
+ await restoreField(profile, 'token', previousToken);
112
+ await restoreField(profile, 'secret', previousSecret);
113
+ throw err;
114
+ }
115
+ },
116
+ async delete(profile) {
117
+ await deleteField(profile, 'token');
118
+ await deleteField(profile, 'secret');
119
+ },
120
+ describe() {
121
+ return {
122
+ backend: 'Secret Service (libsecret)',
123
+ tag: 'secret-service',
124
+ writable: true,
125
+ notes: `Stored under service "${CREDENTIAL_SERVICE}" via secret-tool.`,
126
+ };
127
+ },
128
+ };
129
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * macOS Keychain backend.
3
+ *
4
+ * Wraps the built-in `security(1)` CLI so `npm install` stays free of
5
+ * native compile steps. Service name is shared with the Linux and
6
+ * Windows backends (`com.openclaw.switchbot`), so a user migrating a
7
+ * config between machines sees the same lookup shape.
8
+ *
9
+ * Errors never leak credential material — `add-generic-password`
10
+ * receives the password via `-w <value>` on argv, which is visible in
11
+ * `ps` to the current user but not persisted anywhere, and any stderr
12
+ * we surface back up is bounded to the library's own messages
13
+ * (`password not found`, `could not be added`, etc.) rather than our
14
+ * input values.
15
+ */
16
+ import { spawn } from 'node:child_process';
17
+ import { accountFor, CREDENTIAL_SERVICE, KeychainError, } from '../keychain.js';
18
+ function run(cmd, args, stdin) {
19
+ return new Promise((resolve) => {
20
+ const proc = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
21
+ let stdout = '';
22
+ let stderr = '';
23
+ proc.stdout.on('data', (buf) => {
24
+ stdout += buf.toString('utf-8');
25
+ });
26
+ proc.stderr.on('data', (buf) => {
27
+ stderr += buf.toString('utf-8');
28
+ });
29
+ proc.on('error', () => resolve({ code: 127, stdout, stderr }));
30
+ proc.on('close', (code) => resolve({ code: code ?? 0, stdout, stderr }));
31
+ if (stdin !== undefined) {
32
+ proc.stdin.write(stdin);
33
+ }
34
+ proc.stdin.end();
35
+ });
36
+ }
37
+ export async function macOsAvailable() {
38
+ if (process.platform !== 'darwin')
39
+ return false;
40
+ const res = await run('which', ['security']);
41
+ return res.code === 0 && res.stdout.trim().length > 0;
42
+ }
43
+ async function readField(profile, field) {
44
+ const account = accountFor(profile, field);
45
+ const res = await run('security', [
46
+ 'find-generic-password',
47
+ '-s', CREDENTIAL_SERVICE,
48
+ '-a', account,
49
+ '-w',
50
+ ]);
51
+ if (res.code !== 0)
52
+ return null;
53
+ const value = res.stdout.replace(/\n$/, '');
54
+ return value.length > 0 ? value : null;
55
+ }
56
+ async function writeField(profile, field, value) {
57
+ const account = accountFor(profile, field);
58
+ const res = await run('security', [
59
+ 'add-generic-password',
60
+ '-U', // update if exists
61
+ '-s', CREDENTIAL_SERVICE,
62
+ '-a', account,
63
+ '-w', value,
64
+ ]);
65
+ if (res.code !== 0) {
66
+ throw new KeychainError('keychain', 'set', `security(1) exit ${res.code}`);
67
+ }
68
+ }
69
+ async function deleteField(profile, field) {
70
+ const account = accountFor(profile, field);
71
+ const res = await run('security', [
72
+ 'delete-generic-password',
73
+ '-s', CREDENTIAL_SERVICE,
74
+ '-a', account,
75
+ ]);
76
+ // exit 44 = "The specified item could not be found" — tolerate as idempotent delete.
77
+ if (res.code !== 0 && res.code !== 44) {
78
+ throw new KeychainError('keychain', 'delete', `security(1) exit ${res.code}`);
79
+ }
80
+ }
81
+ async function restoreField(profile, field, value) {
82
+ try {
83
+ if (value === null) {
84
+ await deleteField(profile, field);
85
+ return;
86
+ }
87
+ await writeField(profile, field, value);
88
+ }
89
+ catch {
90
+ // Best effort only. Preserve the original write failure.
91
+ }
92
+ }
93
+ export function createMacOsBackend() {
94
+ return {
95
+ name: 'keychain',
96
+ async get(profile) {
97
+ const token = await readField(profile, 'token');
98
+ const secret = await readField(profile, 'secret');
99
+ if (!token || !secret)
100
+ return null;
101
+ return { token, secret };
102
+ },
103
+ async set(profile, creds) {
104
+ const previousToken = await readField(profile, 'token');
105
+ const previousSecret = await readField(profile, 'secret');
106
+ try {
107
+ await writeField(profile, 'token', creds.token);
108
+ await writeField(profile, 'secret', creds.secret);
109
+ }
110
+ catch (err) {
111
+ await restoreField(profile, 'token', previousToken);
112
+ await restoreField(profile, 'secret', previousSecret);
113
+ throw err;
114
+ }
115
+ },
116
+ async delete(profile) {
117
+ await deleteField(profile, 'token');
118
+ await deleteField(profile, 'secret');
119
+ },
120
+ describe() {
121
+ return {
122
+ backend: 'macOS Keychain',
123
+ tag: 'keychain',
124
+ writable: true,
125
+ notes: `Stored under service "${CREDENTIAL_SERVICE}" via security(1).`,
126
+ };
127
+ },
128
+ };
129
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Windows Credential Manager backend.
3
+ *
4
+ * Uses PowerShell + Win32 P/Invoke (`CredReadW` / `CredWriteW` /
5
+ * `CredDeleteW`) instead of a native binding so `npm install` stays
6
+ * toolchain-free on Windows runners. `cmdkey.exe` could create and
7
+ * delete credentials but can't read the password back — reading is the
8
+ * whole point, so PowerShell is mandatory.
9
+ *
10
+ * Target-name shape is `com.openclaw.switchbot:<profile>:<field>` so
11
+ * `rundll32.exe keymgr.dll,KRShowKeyMgr` displays our entries in a
12
+ * clear, groupable list.
13
+ *
14
+ * Credential values are passed to the child process via environment
15
+ * variables, not argv — this keeps them out of any process listing
16
+ * and out of the PowerShell command history. Env blocks on Windows
17
+ * are only visible to the current user (and admins), so this is a
18
+ * reasonable trade versus the alternatives (stdin requires a second
19
+ * round-trip; temp files leave disk residue).
20
+ */
21
+ import { spawn } from 'node:child_process';
22
+ import { accountFor, CREDENTIAL_SERVICE, KeychainError, } from '../keychain.js';
23
+ const PS_HEADER = `$ErrorActionPreference = 'Stop'
24
+ Add-Type -MemberDefinition @'
25
+ [DllImport("Advapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
26
+ public static extern bool CredReadW(string target, int type, int flags, out System.IntPtr credentialPtr);
27
+
28
+ [DllImport("Advapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
29
+ public static extern bool CredWriteW(ref CREDENTIAL cred, int flags);
30
+
31
+ [DllImport("Advapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
32
+ public static extern bool CredDeleteW(string target, int type, int flags);
33
+
34
+ [DllImport("Advapi32.dll", SetLastError=true)]
35
+ public static extern void CredFree(System.IntPtr buffer);
36
+
37
+ [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
38
+ public struct CREDENTIAL {
39
+ public int Flags;
40
+ public int Type;
41
+ public System.IntPtr TargetName;
42
+ public System.IntPtr Comment;
43
+ public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
44
+ public int CredentialBlobSize;
45
+ public System.IntPtr CredentialBlob;
46
+ public int Persist;
47
+ public int AttributeCount;
48
+ public System.IntPtr Attributes;
49
+ public System.IntPtr TargetAlias;
50
+ public System.IntPtr UserName;
51
+ }
52
+ '@ -Name CredApi -Namespace Win32 | Out-Null
53
+ `;
54
+ const PS_GET = `${PS_HEADER}
55
+ $target = $env:SWITCHBOT_CRED_TARGET
56
+ $ptr = [System.IntPtr]::Zero
57
+ $ok = [Win32.CredApi]::CredReadW($target, 1, 0, [ref]$ptr)
58
+ if (-not $ok) { exit 2 }
59
+ $cred = [System.Runtime.InteropServices.Marshal]::PtrToStructure($ptr, [type][Win32.CredApi+CREDENTIAL])
60
+ $bytes = New-Object byte[] $cred.CredentialBlobSize
61
+ [System.Runtime.InteropServices.Marshal]::Copy($cred.CredentialBlob, $bytes, 0, $cred.CredentialBlobSize)
62
+ [Win32.CredApi]::CredFree($ptr) | Out-Null
63
+ $password = [System.Text.Encoding]::Unicode.GetString($bytes)
64
+ [Console]::Out.Write([Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($password)))
65
+ `;
66
+ const PS_SET = `${PS_HEADER}
67
+ $target = $env:SWITCHBOT_CRED_TARGET
68
+ $user = $env:SWITCHBOT_CRED_USER
69
+ $value = $env:SWITCHBOT_CRED_VALUE
70
+ $bytes = [System.Text.Encoding]::Unicode.GetBytes($value)
71
+ $blob = [System.Runtime.InteropServices.Marshal]::AllocHGlobal($bytes.Length)
72
+ [System.Runtime.InteropServices.Marshal]::Copy($bytes, 0, $blob, $bytes.Length)
73
+ $cred = New-Object Win32.CredApi+CREDENTIAL
74
+ $cred.Flags = 0
75
+ $cred.Type = 1
76
+ $cred.TargetName = [System.Runtime.InteropServices.Marshal]::StringToCoTaskMemUni($target)
77
+ $cred.UserName = [System.Runtime.InteropServices.Marshal]::StringToCoTaskMemUni($user)
78
+ $cred.CredentialBlob = $blob
79
+ $cred.CredentialBlobSize = $bytes.Length
80
+ $cred.Persist = 2
81
+ $cred.AttributeCount = 0
82
+ $ok = [Win32.CredApi]::CredWriteW([ref]$cred, 0)
83
+ [System.Runtime.InteropServices.Marshal]::FreeCoTaskMem($cred.TargetName)
84
+ [System.Runtime.InteropServices.Marshal]::FreeCoTaskMem($cred.UserName)
85
+ [System.Runtime.InteropServices.Marshal]::FreeHGlobal($blob)
86
+ if (-not $ok) { exit 3 }
87
+ `;
88
+ const PS_DELETE = `${PS_HEADER}
89
+ $target = $env:SWITCHBOT_CRED_TARGET
90
+ $ok = [Win32.CredApi]::CredDeleteW($target, 1, 0)
91
+ if (-not $ok) {
92
+ $errno = [System.Runtime.InteropServices.Marshal]::GetLastWin32Error()
93
+ # 1168 = ERROR_NOT_FOUND — tolerate as idempotent delete.
94
+ if ($errno -ne 1168) { exit 4 }
95
+ }
96
+ `;
97
+ function encodePs(script) {
98
+ return Buffer.from(script, 'utf16le').toString('base64');
99
+ }
100
+ function runPowerShell(script, env) {
101
+ return new Promise((resolve) => {
102
+ const proc = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', encodePs(script)], {
103
+ stdio: ['ignore', 'pipe', 'pipe'],
104
+ env: { ...process.env, ...env },
105
+ });
106
+ let stdout = '';
107
+ let stderr = '';
108
+ proc.stdout.on('data', (buf) => {
109
+ stdout += buf.toString('utf-8');
110
+ });
111
+ proc.stderr.on('data', (buf) => {
112
+ stderr += buf.toString('utf-8');
113
+ });
114
+ proc.on('error', () => resolve({ code: 127, stdout, stderr }));
115
+ proc.on('close', (code) => resolve({ code: code ?? 0, stdout, stderr }));
116
+ });
117
+ }
118
+ function targetFor(profile, field) {
119
+ return `${CREDENTIAL_SERVICE}:${accountFor(profile, field)}`;
120
+ }
121
+ export async function windowsAvailable() {
122
+ if (process.platform !== 'win32')
123
+ return false;
124
+ return new Promise((resolve) => {
125
+ const proc = spawn('where', ['powershell.exe'], { stdio: ['ignore', 'pipe', 'pipe'] });
126
+ let ok = false;
127
+ proc.stdout.on('data', (buf) => {
128
+ if (buf.toString().trim().length > 0)
129
+ ok = true;
130
+ });
131
+ proc.on('error', () => resolve(false));
132
+ proc.on('close', (code) => resolve(ok && (code ?? 0) === 0));
133
+ });
134
+ }
135
+ async function readField(profile, field) {
136
+ const res = await runPowerShell(PS_GET, {
137
+ SWITCHBOT_CRED_TARGET: targetFor(profile, field),
138
+ });
139
+ if (res.code !== 0)
140
+ return null;
141
+ try {
142
+ const decoded = Buffer.from(res.stdout, 'base64').toString('utf-8');
143
+ return decoded.length > 0 ? decoded : null;
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ }
149
+ async function writeField(profile, field, value) {
150
+ const res = await runPowerShell(PS_SET, {
151
+ SWITCHBOT_CRED_TARGET: targetFor(profile, field),
152
+ SWITCHBOT_CRED_USER: accountFor(profile, field),
153
+ SWITCHBOT_CRED_VALUE: value,
154
+ });
155
+ if (res.code !== 0) {
156
+ throw new KeychainError('credman', 'set', `CredWrite exit ${res.code}`);
157
+ }
158
+ }
159
+ async function deleteField(profile, field) {
160
+ const res = await runPowerShell(PS_DELETE, {
161
+ SWITCHBOT_CRED_TARGET: targetFor(profile, field),
162
+ });
163
+ if (res.code !== 0) {
164
+ throw new KeychainError('credman', 'delete', `CredDelete exit ${res.code}`);
165
+ }
166
+ }
167
+ async function restoreField(profile, field, value) {
168
+ try {
169
+ if (value === null) {
170
+ await deleteField(profile, field);
171
+ return;
172
+ }
173
+ await writeField(profile, field, value);
174
+ }
175
+ catch {
176
+ // Best effort only. Preserve the original write failure.
177
+ }
178
+ }
179
+ export function createWindowsBackend() {
180
+ return {
181
+ name: 'credman',
182
+ async get(profile) {
183
+ const token = await readField(profile, 'token');
184
+ const secret = await readField(profile, 'secret');
185
+ if (!token || !secret)
186
+ return null;
187
+ return { token, secret };
188
+ },
189
+ async set(profile, creds) {
190
+ const previousToken = await readField(profile, 'token');
191
+ const previousSecret = await readField(profile, 'secret');
192
+ try {
193
+ await writeField(profile, 'token', creds.token);
194
+ await writeField(profile, 'secret', creds.secret);
195
+ }
196
+ catch (err) {
197
+ await restoreField(profile, 'token', previousToken);
198
+ await restoreField(profile, 'secret', previousSecret);
199
+ throw err;
200
+ }
201
+ },
202
+ async delete(profile) {
203
+ await deleteField(profile, 'token');
204
+ await deleteField(profile, 'secret');
205
+ },
206
+ describe() {
207
+ return {
208
+ backend: 'Credential Manager (Windows)',
209
+ tag: 'credman',
210
+ writable: true,
211
+ notes: `Stored under target "${CREDENTIAL_SERVICE}:*" via Win32 CredRead/CredWrite.`,
212
+ };
213
+ },
214
+ };
215
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * OS-keychain credential store abstraction.
3
+ *
4
+ * F1 scope (plan: `feat/v2.8-policy-tooling`):
5
+ * - Defines the `CredentialStore` contract the rest of the CLI can
6
+ * depend on (token/secret per profile, auditable describe(), best-
7
+ * effort delete()).
8
+ * - Ships four backends: `macos` (security(1)), `linux`
9
+ * (secret-tool), `windows` (PowerShell + Win32 CredRead/CredWrite)
10
+ * and `file` (the existing `~/.switchbot/config.json` shape as
11
+ * last-resort fallback).
12
+ * - `selectCredentialStore()` picks the OS-native backend first and
13
+ * silently degrades to `file` whenever a backend is absent or
14
+ * non-writable — so a fresh Linux box without libsecret installed
15
+ * still Just Works.
16
+ *
17
+ * Out of scope here: migrating existing users off `~/.switchbot/config.json`
18
+ * into the keychain. F3's `switchbot auth keychain migrate` subcommand
19
+ * handles the explicit opt-in; F2 only wires the *read* path.
20
+ *
21
+ * Design choices:
22
+ * - No native bindings. Every native backend shells out to an
23
+ * OS-provided CLI / interpreter, which keeps `npm install` free of
24
+ * compile steps on CI machines.
25
+ * - Errors never leak credential material to logs or stderr. On any
26
+ * subprocess failure backends return `null` (read) or throw a
27
+ * `KeychainError` without the input token/secret in the message.
28
+ * - Service / account namespacing is identical across backends
29
+ * (`com.openclaw.switchbot` / `<profile>:<field>`) so a user can
30
+ * move between machines and expect `switchbot auth keychain get`
31
+ * to produce the same lookup shape.
32
+ */
33
+ export const CREDENTIAL_SERVICE = 'com.openclaw.switchbot';
34
+ export const CREDENTIAL_FIELDS = ['token', 'secret'];
35
+ /**
36
+ * Thrown when a backend cannot service a `set`/`delete` request even
37
+ * though it reported itself as writable. Never includes the
38
+ * credential material in the message.
39
+ */
40
+ export class KeychainError extends Error {
41
+ backend;
42
+ operation;
43
+ constructor(backend, operation, message) {
44
+ super(`[${backend}] ${operation} failed: ${message}`);
45
+ this.backend = backend;
46
+ this.operation = operation;
47
+ this.name = 'KeychainError';
48
+ }
49
+ }
50
+ /** Encode the account string used by every native backend. Kept public
51
+ * so F3's CLI can show what the underlying keychain will see. */
52
+ export function accountFor(profile, field) {
53
+ return `${profile}:${field}`;
54
+ }
55
+ /**
56
+ * Select the best backend for the current platform. The caller does
57
+ * not need to handle "no keychain available" — this function always
58
+ * returns a store, falling back to the file backend if necessary.
59
+ *
60
+ * Detection is done eagerly at call time (cheap `which` probe) so a
61
+ * long-running process reflects environment changes (e.g. user
62
+ * installs secret-tool after first run). Selection does NOT mutate
63
+ * any state; calling it twice returns fresh instances.
64
+ */
65
+ export async function selectCredentialStore(opts = {}) {
66
+ if (opts.preferFile) {
67
+ const { createFileBackend } = await import('./backends/file.js');
68
+ return createFileBackend();
69
+ }
70
+ const platform = process.platform;
71
+ if (platform === 'darwin') {
72
+ const { createMacOsBackend, macOsAvailable } = await import('./backends/macos.js');
73
+ if (await macOsAvailable())
74
+ return createMacOsBackend();
75
+ }
76
+ else if (platform === 'linux') {
77
+ const { createLinuxBackend, linuxAvailable } = await import('./backends/linux.js');
78
+ if (await linuxAvailable())
79
+ return createLinuxBackend();
80
+ }
81
+ else if (platform === 'win32') {
82
+ const { createWindowsBackend, windowsAvailable } = await import('./backends/windows.js');
83
+ if (await windowsAvailable())
84
+ return createWindowsBackend();
85
+ }
86
+ const { createFileBackend } = await import('./backends/file.js');
87
+ return createFileBackend();
88
+ }