@switchbot/openapi-cli 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +34 -42
  2. package/dist/index.js +56945 -169
  3. package/dist/policy/schema/v0.2.json +1 -1
  4. package/package.json +3 -2
  5. package/dist/api/client.js +0 -235
  6. package/dist/auth.js +0 -20
  7. package/dist/commands/agent-bootstrap.js +0 -182
  8. package/dist/commands/auth.js +0 -354
  9. package/dist/commands/batch.js +0 -413
  10. package/dist/commands/cache.js +0 -126
  11. package/dist/commands/capabilities.js +0 -385
  12. package/dist/commands/catalog.js +0 -359
  13. package/dist/commands/completion.js +0 -385
  14. package/dist/commands/config.js +0 -376
  15. package/dist/commands/daemon.js +0 -367
  16. package/dist/commands/device-meta.js +0 -159
  17. package/dist/commands/devices.js +0 -948
  18. package/dist/commands/doctor.js +0 -1015
  19. package/dist/commands/events.js +0 -563
  20. package/dist/commands/expand.js +0 -130
  21. package/dist/commands/explain.js +0 -139
  22. package/dist/commands/health.js +0 -113
  23. package/dist/commands/history.js +0 -320
  24. package/dist/commands/identity.js +0 -59
  25. package/dist/commands/install.js +0 -246
  26. package/dist/commands/mcp.js +0 -2017
  27. package/dist/commands/plan.js +0 -653
  28. package/dist/commands/policy.js +0 -586
  29. package/dist/commands/quota.js +0 -78
  30. package/dist/commands/rules.js +0 -875
  31. package/dist/commands/scenes.js +0 -264
  32. package/dist/commands/schema.js +0 -177
  33. package/dist/commands/status-sync.js +0 -131
  34. package/dist/commands/uninstall.js +0 -237
  35. package/dist/commands/upgrade-check.js +0 -88
  36. package/dist/commands/watch.js +0 -194
  37. package/dist/commands/webhook.js +0 -182
  38. package/dist/config.js +0 -258
  39. package/dist/credentials/backends/file.js +0 -101
  40. package/dist/credentials/backends/linux.js +0 -129
  41. package/dist/credentials/backends/macos.js +0 -129
  42. package/dist/credentials/backends/windows.js +0 -215
  43. package/dist/credentials/keychain.js +0 -88
  44. package/dist/credentials/prime.js +0 -52
  45. package/dist/devices/cache.js +0 -293
  46. package/dist/devices/catalog.js +0 -767
  47. package/dist/devices/device-meta.js +0 -56
  48. package/dist/devices/history-agg.js +0 -138
  49. package/dist/devices/history-query.js +0 -181
  50. package/dist/devices/param-validator.js +0 -433
  51. package/dist/devices/resources.js +0 -270
  52. package/dist/install/default-steps.js +0 -257
  53. package/dist/install/preflight.js +0 -212
  54. package/dist/install/steps.js +0 -67
  55. package/dist/lib/command-keywords.js +0 -17
  56. package/dist/lib/daemon-state.js +0 -46
  57. package/dist/lib/destructive-mode.js +0 -12
  58. package/dist/lib/devices.js +0 -382
  59. package/dist/lib/idempotency.js +0 -106
  60. package/dist/lib/plan-store.js +0 -68
  61. package/dist/lib/request-context.js +0 -12
  62. package/dist/lib/scenes.js +0 -10
  63. package/dist/logger.js +0 -16
  64. package/dist/mcp/device-history.js +0 -145
  65. package/dist/mcp/events-subscription.js +0 -213
  66. package/dist/mqtt/client.js +0 -180
  67. package/dist/mqtt/credential.js +0 -30
  68. package/dist/policy/add-rule.js +0 -124
  69. package/dist/policy/diff.js +0 -91
  70. package/dist/policy/format.js +0 -57
  71. package/dist/policy/load.js +0 -61
  72. package/dist/policy/migrate.js +0 -67
  73. package/dist/policy/schema.js +0 -18
  74. package/dist/policy/validate.js +0 -262
  75. package/dist/rules/action.js +0 -205
  76. package/dist/rules/audit-query.js +0 -89
  77. package/dist/rules/conflict-analyzer.js +0 -203
  78. package/dist/rules/cron-scheduler.js +0 -186
  79. package/dist/rules/destructive.js +0 -52
  80. package/dist/rules/engine.js +0 -757
  81. package/dist/rules/matcher.js +0 -230
  82. package/dist/rules/pid-file.js +0 -95
  83. package/dist/rules/quiet-hours.js +0 -45
  84. package/dist/rules/suggest.js +0 -95
  85. package/dist/rules/throttle.js +0 -116
  86. package/dist/rules/types.js +0 -34
  87. package/dist/rules/webhook-listener.js +0 -223
  88. package/dist/rules/webhook-token.js +0 -90
  89. package/dist/schema/field-aliases.js +0 -131
  90. package/dist/sinks/dispatcher.js +0 -12
  91. package/dist/sinks/file.js +0 -19
  92. package/dist/sinks/format.js +0 -56
  93. package/dist/sinks/homeassistant.js +0 -44
  94. package/dist/sinks/openclaw.js +0 -33
  95. package/dist/sinks/stdout.js +0 -5
  96. package/dist/sinks/telegram.js +0 -28
  97. package/dist/sinks/types.js +0 -1
  98. package/dist/sinks/webhook.js +0 -22
  99. package/dist/status-sync/manager.js +0 -268
  100. package/dist/utils/arg-parsers.js +0 -66
  101. package/dist/utils/audit.js +0 -117
  102. package/dist/utils/filter.js +0 -189
  103. package/dist/utils/flags.js +0 -186
  104. package/dist/utils/format.js +0 -117
  105. package/dist/utils/health.js +0 -101
  106. package/dist/utils/help-json.js +0 -54
  107. package/dist/utils/name-resolver.js +0 -137
  108. package/dist/utils/output.js +0 -404
  109. package/dist/utils/quota.js +0 -227
  110. package/dist/utils/redact.js +0 -68
  111. package/dist/utils/retry.js +0 -140
  112. package/dist/utils/string.js +0 -22
  113. package/dist/version.js +0 -4
@@ -1,182 +0,0 @@
1
- import { stringArg } from '../utils/arg-parsers.js';
2
- import { createClient } from '../api/client.js';
3
- import { printKeyValue, printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
4
- import chalk from 'chalk';
5
- function assertValidUrl(url) {
6
- let parsed;
7
- try {
8
- parsed = new URL(url);
9
- }
10
- catch {
11
- throw new UsageError(`Invalid URL "${url}" (expected absolute URL, e.g. https://example.com/hook)`);
12
- }
13
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
14
- throw new UsageError(`URL must use http:// or https:// (got "${parsed.protocol}")`);
15
- }
16
- }
17
- export function registerWebhookCommand(program) {
18
- const webhook = program
19
- .command('webhook')
20
- .description('Manage SwitchBot Webhook configuration')
21
- .addHelpText('after', `
22
- A webhook lets SwitchBot POST device state-change events to a URL you host.
23
- Only one webhook URL can be active per account; "setup" registers it for ALL devices.
24
- `);
25
- // switchbot webhook setup <url>
26
- webhook
27
- .command('setup')
28
- .description('Configure the webhook receiver URL (receives events from all devices)')
29
- .argument('<url>', 'Absolute http(s):// URL where SwitchBot will POST events')
30
- .addHelpText('after', `
31
- Example:
32
- $ switchbot webhook setup https://example.com/switchbot/events
33
- `)
34
- .action(async (url) => {
35
- try {
36
- assertValidUrl(url);
37
- const client = createClient();
38
- await client.post('/v1.1/webhook/setupWebhook', {
39
- action: 'setupWebhook',
40
- url,
41
- deviceList: 'ALL',
42
- });
43
- if (isJsonMode()) {
44
- printJson({ ok: true, url });
45
- }
46
- else {
47
- console.log(chalk.green(`✓ Webhook configured: ${url}`));
48
- }
49
- }
50
- catch (error) {
51
- handleError(error);
52
- }
53
- });
54
- // switchbot webhook query [--details <url>]
55
- webhook
56
- .command('query')
57
- .description('Query webhook configuration')
58
- .option('--details <url>', 'Query detailed configuration (enable/deviceList/timestamps) for a specific URL', stringArg('--details'))
59
- .addHelpText('after', `
60
- Without --details, lists all configured webhook URLs.
61
- With --details, prints enable/deviceList/createTime/lastUpdateTime for the given URL.
62
-
63
- Examples:
64
- $ switchbot webhook query
65
- $ switchbot webhook query --details https://example.com/hook
66
- $ switchbot webhook query --json
67
- `)
68
- .action(async (options) => {
69
- try {
70
- const client = createClient();
71
- if (options.details) {
72
- const res = await client.post('/v1.1/webhook/queryWebhook', { action: 'queryDetails', urls: [options.details] });
73
- if (isJsonMode()) {
74
- printJson(res.data.body);
75
- return;
76
- }
77
- const details = res.data.body;
78
- if (!details || details.length === 0) {
79
- console.log('No webhook configuration found for this URL');
80
- return;
81
- }
82
- for (const d of details) {
83
- printKeyValue({
84
- url: d.url,
85
- enable: d.enable,
86
- deviceList: d.deviceList,
87
- createTime: new Date(d.createTime).toLocaleString(),
88
- lastUpdateTime: new Date(d.lastUpdateTime).toLocaleString(),
89
- });
90
- }
91
- }
92
- else {
93
- const res = await client.post('/v1.1/webhook/queryWebhook', { action: 'queryUrl' });
94
- if (isJsonMode()) {
95
- printJson(res.data.body);
96
- return;
97
- }
98
- const urls = res.data.body.urls ?? [];
99
- if (urls.length === 0) {
100
- console.log('No webhooks configured');
101
- return;
102
- }
103
- console.log('Configured Webhook URLs:');
104
- urls.forEach((u) => console.log(` ${chalk.cyan(u)}`));
105
- }
106
- }
107
- catch (error) {
108
- handleError(error);
109
- }
110
- });
111
- // switchbot webhook update <url> [--enable | --disable]
112
- webhook
113
- .command('update')
114
- .description('Update webhook configuration (enable / disable a registered URL)')
115
- .argument('<url>', 'URL of the webhook to update (must already be configured)')
116
- .option('--enable', 'Enable the webhook')
117
- .option('--disable', 'Disable the webhook')
118
- .addHelpText('after', `
119
- --enable and --disable are mutually exclusive. If neither is provided, the
120
- webhook is re-submitted with no change to its enabled state.
121
-
122
- Examples:
123
- $ switchbot webhook update https://example.com/hook --enable
124
- $ switchbot webhook update https://example.com/hook --disable
125
- `)
126
- .action(async (url, options) => {
127
- try {
128
- if (options.enable && options.disable) {
129
- throw new UsageError('--enable and --disable are mutually exclusive');
130
- }
131
- assertValidUrl(url);
132
- const client = createClient();
133
- const config = { url };
134
- if (options.enable)
135
- config.enable = true;
136
- if (options.disable)
137
- config.enable = false;
138
- await client.post('/v1.1/webhook/updateWebhook', {
139
- action: 'updateWebhook',
140
- config,
141
- });
142
- const statusText = options.enable ? 'enabled' : options.disable ? 'disabled' : 'updated';
143
- if (isJsonMode()) {
144
- printJson({ ok: true, url, status: statusText });
145
- }
146
- else {
147
- console.log(chalk.green(`✓ Webhook ${statusText}: ${url}`));
148
- }
149
- }
150
- catch (error) {
151
- handleError(error);
152
- }
153
- });
154
- // switchbot webhook delete <url>
155
- webhook
156
- .command('delete')
157
- .description('Delete webhook configuration')
158
- .argument('<url>', 'URL of the webhook to remove')
159
- .addHelpText('after', `
160
- Example:
161
- $ switchbot webhook delete https://example.com/hook
162
- `)
163
- .action(async (url) => {
164
- try {
165
- assertValidUrl(url);
166
- const client = createClient();
167
- await client.post('/v1.1/webhook/deleteWebhook', {
168
- action: 'deleteWebhook',
169
- url,
170
- });
171
- if (isJsonMode()) {
172
- printJson({ ok: true, url });
173
- }
174
- else {
175
- console.log(chalk.green(`✓ Webhook deleted: ${url}`));
176
- }
177
- }
178
- catch (error) {
179
- handleError(error);
180
- }
181
- });
182
- }
package/dist/config.js DELETED
@@ -1,258 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import os from 'node:os';
4
- import { getConfigPath } from './utils/flags.js';
5
- import { getActiveProfile } from './lib/request-context.js';
6
- import { emitJsonError, isJsonMode } from './utils/output.js';
7
- import { getPrimedCredentials } from './credentials/prime.js';
8
- function sanitizeOptionalString(v) {
9
- if (typeof v !== 'string')
10
- return undefined;
11
- const trimmed = v.trim();
12
- return trimmed ? trimmed : undefined;
13
- }
14
- /**
15
- * Credential file resolution priority:
16
- * 1. --config <path> (absolute override — wins over everything)
17
- * 2. active profile (ALS request context, else --profile flag) → ~/.switchbot/profiles/<name>.json
18
- * 3. default → ~/.switchbot/config.json
19
- *
20
- * Env SWITCHBOT_TOKEN+SWITCHBOT_SECRET still take priority inside loadConfig.
21
- */
22
- export function configFilePath() {
23
- const override = getConfigPath();
24
- if (override)
25
- return path.resolve(override);
26
- const profile = getActiveProfile();
27
- if (profile) {
28
- return path.join(os.homedir(), '.switchbot', 'profiles', `${profile}.json`);
29
- }
30
- return path.join(os.homedir(), '.switchbot', 'config.json');
31
- }
32
- export function profileFilePath(profile) {
33
- return path.join(os.homedir(), '.switchbot', 'profiles', `${profile}.json`);
34
- }
35
- export function listProfiles() {
36
- const dir = path.join(os.homedir(), '.switchbot', 'profiles');
37
- if (!fs.existsSync(dir))
38
- return [];
39
- return fs.readdirSync(dir)
40
- .filter((f) => f.endsWith('.json'))
41
- .map((f) => f.slice(0, -5))
42
- .sort();
43
- }
44
- export function loadConfig() {
45
- const envToken = process.env.SWITCHBOT_TOKEN;
46
- const envSecret = process.env.SWITCHBOT_SECRET;
47
- if (envToken && envSecret) {
48
- return { token: envToken, secret: envSecret };
49
- }
50
- // After env, try the OS keychain (via the priming cache populated at
51
- // command start). When --config is passed we skip the keychain so the
52
- // override remains authoritative.
53
- if (!getConfigPath()) {
54
- const primed = getPrimedCredentials(getActiveProfile() ?? 'default');
55
- if (primed)
56
- return primed;
57
- }
58
- const file = configFilePath();
59
- if (!fs.existsSync(file)) {
60
- const profile = getActiveProfile();
61
- const hint = profile
62
- ? `No credentials configured for profile "${profile}". Run: switchbot --profile ${profile} config set-token <token> <secret>`
63
- : 'No credentials configured. Run: switchbot config set-token <token> <secret>';
64
- const msg = `${hint}\nOr set SWITCHBOT_TOKEN and SWITCHBOT_SECRET environment variables.`;
65
- if (isJsonMode()) {
66
- emitJsonError({ code: 1, kind: 'runtime', message: hint });
67
- }
68
- else {
69
- console.error(msg);
70
- }
71
- process.exit(1);
72
- }
73
- try {
74
- const raw = fs.readFileSync(file, 'utf-8');
75
- const cfg = JSON.parse(raw);
76
- if (!cfg.token || !cfg.secret) {
77
- if (isJsonMode()) {
78
- emitJsonError({ code: 1, kind: 'runtime', message: 'Invalid config format. Please re-run: switchbot config set-token' });
79
- }
80
- else {
81
- console.error('Invalid config format. Please re-run: switchbot config set-token');
82
- }
83
- process.exit(1);
84
- }
85
- return cfg;
86
- }
87
- catch {
88
- if (isJsonMode()) {
89
- emitJsonError({ code: 1, kind: 'runtime', message: 'Failed to read config file. Please re-run: switchbot config set-token' });
90
- }
91
- else {
92
- console.error('Failed to read config file. Please re-run: switchbot config set-token');
93
- }
94
- process.exit(1);
95
- }
96
- }
97
- /**
98
- * Like loadConfig but returns null instead of exiting. Use this in code paths
99
- * that want graceful degradation (e.g. optional MQTT init in `mcp serve`).
100
- */
101
- export function tryLoadConfig() {
102
- const envToken = process.env.SWITCHBOT_TOKEN;
103
- const envSecret = process.env.SWITCHBOT_SECRET;
104
- if (envToken && envSecret)
105
- return { token: envToken, secret: envSecret };
106
- if (!getConfigPath()) {
107
- const primed = getPrimedCredentials(getActiveProfile() ?? 'default');
108
- if (primed)
109
- return primed;
110
- }
111
- const file = configFilePath();
112
- if (!fs.existsSync(file))
113
- return null;
114
- try {
115
- const raw = fs.readFileSync(file, 'utf-8');
116
- const cfg = JSON.parse(raw);
117
- if (!cfg.token || !cfg.secret)
118
- return null;
119
- return cfg;
120
- }
121
- catch {
122
- return null;
123
- }
124
- }
125
- export function saveConfig(token, secret, extras) {
126
- const file = configFilePath();
127
- const dir = path.dirname(file);
128
- if (!fs.existsSync(dir)) {
129
- fs.mkdirSync(dir, { recursive: true });
130
- }
131
- // Merge with existing file so label/limits/defaults aren't dropped when the
132
- // user just rotates the token.
133
- let existing = {};
134
- if (fs.existsSync(file)) {
135
- try {
136
- existing = JSON.parse(fs.readFileSync(file, 'utf-8'));
137
- }
138
- catch {
139
- existing = {};
140
- }
141
- }
142
- const cfg = {
143
- token,
144
- secret,
145
- ...(existing.label ? { label: existing.label } : {}),
146
- ...(existing.description ? { description: existing.description } : {}),
147
- ...(existing.limits ? { limits: existing.limits } : {}),
148
- ...(existing.defaults ? { defaults: existing.defaults } : {}),
149
- };
150
- if (extras) {
151
- const label = sanitizeOptionalString(extras.label);
152
- const description = sanitizeOptionalString(extras.description);
153
- if (label !== undefined)
154
- cfg.label = label;
155
- if (description !== undefined)
156
- cfg.description = description;
157
- if (extras.limits)
158
- cfg.limits = { ...(cfg.limits ?? {}), ...extras.limits };
159
- if (extras.defaults)
160
- cfg.defaults = { ...(cfg.defaults ?? {}), ...extras.defaults };
161
- }
162
- fs.writeFileSync(file, JSON.stringify(cfg, null, 2), { mode: 0o600 });
163
- }
164
- /**
165
- * Read a profile's metadata (label / description / limits / defaults) without
166
- * exposing the token/secret. Returns null when the file is missing or invalid.
167
- */
168
- export function readProfileMeta(profile) {
169
- const file = profile
170
- ? profileFilePath(profile)
171
- : path.join(os.homedir(), '.switchbot', 'config.json');
172
- if (!fs.existsSync(file))
173
- return null;
174
- try {
175
- const raw = fs.readFileSync(file, 'utf-8');
176
- const cfg = JSON.parse(raw);
177
- return {
178
- label: cfg.label,
179
- description: cfg.description,
180
- limits: cfg.limits,
181
- defaults: cfg.defaults,
182
- path: file,
183
- };
184
- }
185
- catch {
186
- return null;
187
- }
188
- }
189
- export function showConfig() {
190
- const summary = getConfigSummary();
191
- if (summary.source === 'env') {
192
- console.log('Credential source: environment variables');
193
- console.log(`token : ${summary.token ?? ''}`);
194
- console.log(`secret: ${summary.secret ?? ''}`);
195
- return;
196
- }
197
- if (summary.source === 'none') {
198
- console.log('No credentials configured');
199
- return;
200
- }
201
- if (summary.source === 'invalid') {
202
- console.error('Failed to read config file');
203
- return;
204
- }
205
- console.log(`Credential source: ${summary.path}`);
206
- if (summary.label)
207
- console.log(`label : ${summary.label}`);
208
- if (summary.description)
209
- console.log(`desc : ${summary.description}`);
210
- console.log(`token : ${summary.token ?? ''}`);
211
- console.log(`secret: ${summary.secret ?? ''}`);
212
- if (summary.dailyCap)
213
- console.log(`limits: dailyCap=${summary.dailyCap}`);
214
- if (summary.defaultFlags?.length)
215
- console.log(`defaults: ${summary.defaultFlags.join(' ')}`);
216
- }
217
- export function getConfigSummary() {
218
- const envToken = process.env.SWITCHBOT_TOKEN;
219
- const envSecret = process.env.SWITCHBOT_SECRET;
220
- if (envToken && envSecret) {
221
- return {
222
- source: 'env',
223
- token: maskCredential(envToken),
224
- secret: maskSecret(envSecret),
225
- };
226
- }
227
- const file = configFilePath();
228
- if (!fs.existsSync(file)) {
229
- return { source: 'none' };
230
- }
231
- try {
232
- const raw = fs.readFileSync(file, 'utf-8');
233
- const cfg = JSON.parse(raw);
234
- return {
235
- source: 'file',
236
- path: file,
237
- label: cfg.label,
238
- description: cfg.description,
239
- token: maskCredential(cfg.token),
240
- secret: maskSecret(cfg.secret),
241
- dailyCap: cfg.limits?.dailyCap,
242
- defaultFlags: cfg.defaults?.flags,
243
- };
244
- }
245
- catch {
246
- return { source: 'invalid', path: file };
247
- }
248
- }
249
- function maskCredential(token) {
250
- if (token.length <= 8)
251
- return '*'.repeat(Math.max(4, token.length));
252
- return token.slice(0, 4) + '*'.repeat(token.length - 8) + token.slice(-4);
253
- }
254
- function maskSecret(secret) {
255
- if (secret.length <= 4)
256
- return '****';
257
- return secret.slice(0, 2) + '*'.repeat(secret.length - 4) + secret.slice(-2);
258
- }
@@ -1,101 +0,0 @@
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
- }
@@ -1,129 +0,0 @@
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
- }