@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.
- package/README.md +34 -42
- package/dist/index.js +56945 -169
- package/dist/policy/schema/v0.2.json +1 -1
- package/package.json +3 -2
- package/dist/api/client.js +0 -235
- package/dist/auth.js +0 -20
- package/dist/commands/agent-bootstrap.js +0 -182
- package/dist/commands/auth.js +0 -354
- package/dist/commands/batch.js +0 -413
- package/dist/commands/cache.js +0 -126
- package/dist/commands/capabilities.js +0 -385
- package/dist/commands/catalog.js +0 -359
- package/dist/commands/completion.js +0 -385
- package/dist/commands/config.js +0 -376
- package/dist/commands/daemon.js +0 -367
- package/dist/commands/device-meta.js +0 -159
- package/dist/commands/devices.js +0 -948
- package/dist/commands/doctor.js +0 -1015
- package/dist/commands/events.js +0 -563
- package/dist/commands/expand.js +0 -130
- package/dist/commands/explain.js +0 -139
- package/dist/commands/health.js +0 -113
- package/dist/commands/history.js +0 -320
- package/dist/commands/identity.js +0 -59
- package/dist/commands/install.js +0 -246
- package/dist/commands/mcp.js +0 -2017
- package/dist/commands/plan.js +0 -653
- package/dist/commands/policy.js +0 -586
- package/dist/commands/quota.js +0 -78
- package/dist/commands/rules.js +0 -875
- package/dist/commands/scenes.js +0 -264
- package/dist/commands/schema.js +0 -177
- package/dist/commands/status-sync.js +0 -131
- package/dist/commands/uninstall.js +0 -237
- package/dist/commands/upgrade-check.js +0 -88
- package/dist/commands/watch.js +0 -194
- package/dist/commands/webhook.js +0 -182
- package/dist/config.js +0 -258
- package/dist/credentials/backends/file.js +0 -101
- package/dist/credentials/backends/linux.js +0 -129
- package/dist/credentials/backends/macos.js +0 -129
- package/dist/credentials/backends/windows.js +0 -215
- package/dist/credentials/keychain.js +0 -88
- package/dist/credentials/prime.js +0 -52
- package/dist/devices/cache.js +0 -293
- package/dist/devices/catalog.js +0 -767
- package/dist/devices/device-meta.js +0 -56
- package/dist/devices/history-agg.js +0 -138
- package/dist/devices/history-query.js +0 -181
- package/dist/devices/param-validator.js +0 -433
- package/dist/devices/resources.js +0 -270
- package/dist/install/default-steps.js +0 -257
- package/dist/install/preflight.js +0 -212
- package/dist/install/steps.js +0 -67
- package/dist/lib/command-keywords.js +0 -17
- package/dist/lib/daemon-state.js +0 -46
- package/dist/lib/destructive-mode.js +0 -12
- package/dist/lib/devices.js +0 -382
- package/dist/lib/idempotency.js +0 -106
- package/dist/lib/plan-store.js +0 -68
- package/dist/lib/request-context.js +0 -12
- package/dist/lib/scenes.js +0 -10
- package/dist/logger.js +0 -16
- package/dist/mcp/device-history.js +0 -145
- package/dist/mcp/events-subscription.js +0 -213
- package/dist/mqtt/client.js +0 -180
- package/dist/mqtt/credential.js +0 -30
- package/dist/policy/add-rule.js +0 -124
- package/dist/policy/diff.js +0 -91
- package/dist/policy/format.js +0 -57
- package/dist/policy/load.js +0 -61
- package/dist/policy/migrate.js +0 -67
- package/dist/policy/schema.js +0 -18
- package/dist/policy/validate.js +0 -262
- package/dist/rules/action.js +0 -205
- package/dist/rules/audit-query.js +0 -89
- package/dist/rules/conflict-analyzer.js +0 -203
- package/dist/rules/cron-scheduler.js +0 -186
- package/dist/rules/destructive.js +0 -52
- package/dist/rules/engine.js +0 -757
- package/dist/rules/matcher.js +0 -230
- package/dist/rules/pid-file.js +0 -95
- package/dist/rules/quiet-hours.js +0 -45
- package/dist/rules/suggest.js +0 -95
- package/dist/rules/throttle.js +0 -116
- package/dist/rules/types.js +0 -34
- package/dist/rules/webhook-listener.js +0 -223
- package/dist/rules/webhook-token.js +0 -90
- package/dist/schema/field-aliases.js +0 -131
- package/dist/sinks/dispatcher.js +0 -12
- package/dist/sinks/file.js +0 -19
- package/dist/sinks/format.js +0 -56
- package/dist/sinks/homeassistant.js +0 -44
- package/dist/sinks/openclaw.js +0 -33
- package/dist/sinks/stdout.js +0 -5
- package/dist/sinks/telegram.js +0 -28
- package/dist/sinks/types.js +0 -1
- package/dist/sinks/webhook.js +0 -22
- package/dist/status-sync/manager.js +0 -268
- package/dist/utils/arg-parsers.js +0 -66
- package/dist/utils/audit.js +0 -117
- package/dist/utils/filter.js +0 -189
- package/dist/utils/flags.js +0 -186
- package/dist/utils/format.js +0 -117
- package/dist/utils/health.js +0 -101
- package/dist/utils/help-json.js +0 -54
- package/dist/utils/name-resolver.js +0 -137
- package/dist/utils/output.js +0 -404
- package/dist/utils/quota.js +0 -227
- package/dist/utils/redact.js +0 -68
- package/dist/utils/retry.js +0 -140
- package/dist/utils/string.js +0 -22
- package/dist/version.js +0 -4
package/dist/commands/webhook.js
DELETED
|
@@ -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
|
-
}
|