@switchbot/openapi-cli 2.7.2 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +481 -103
- package/dist/api/client.js +23 -1
- package/dist/commands/agent-bootstrap.js +47 -2
- package/dist/commands/auth.js +354 -0
- package/dist/commands/batch.js +20 -4
- package/dist/commands/capabilities.js +155 -65
- package/dist/commands/config.js +109 -0
- package/dist/commands/daemon.js +367 -0
- package/dist/commands/devices.js +62 -11
- package/dist/commands/doctor.js +417 -8
- package/dist/commands/events.js +3 -3
- package/dist/commands/explain.js +1 -2
- package/dist/commands/health.js +113 -0
- package/dist/commands/install.js +246 -0
- package/dist/commands/mcp.js +888 -7
- package/dist/commands/plan.js +379 -103
- package/dist/commands/policy.js +586 -0
- package/dist/commands/rules.js +875 -0
- package/dist/commands/scenes.js +140 -0
- package/dist/commands/schema.js +0 -2
- package/dist/commands/status-sync.js +131 -0
- package/dist/commands/uninstall.js +237 -0
- package/dist/commands/upgrade-check.js +88 -0
- package/dist/config.js +14 -0
- package/dist/credentials/backends/file.js +101 -0
- package/dist/credentials/backends/linux.js +129 -0
- package/dist/credentials/backends/macos.js +129 -0
- package/dist/credentials/backends/windows.js +215 -0
- package/dist/credentials/keychain.js +88 -0
- package/dist/credentials/prime.js +52 -0
- package/dist/devices/catalog.js +4 -10
- package/dist/index.js +30 -1
- package/dist/install/default-steps.js +257 -0
- package/dist/install/preflight.js +212 -0
- package/dist/install/steps.js +67 -0
- package/dist/lib/command-keywords.js +17 -0
- package/dist/lib/daemon-state.js +46 -0
- package/dist/lib/destructive-mode.js +12 -0
- package/dist/lib/devices.js +1 -1
- package/dist/lib/plan-store.js +68 -0
- package/dist/policy/add-rule.js +124 -0
- package/dist/policy/diff.js +91 -0
- package/dist/policy/examples/policy.example.yaml +99 -0
- package/dist/policy/format.js +57 -0
- package/dist/policy/load.js +61 -0
- package/dist/policy/migrate.js +67 -0
- package/dist/policy/schema/v0.2.json +331 -0
- package/dist/policy/schema.js +18 -0
- package/dist/policy/validate.js +262 -0
- package/dist/rules/action.js +205 -0
- package/dist/rules/audit-query.js +89 -0
- package/dist/rules/conflict-analyzer.js +203 -0
- package/dist/rules/cron-scheduler.js +186 -0
- package/dist/rules/destructive.js +52 -0
- package/dist/rules/engine.js +757 -0
- package/dist/rules/matcher.js +230 -0
- package/dist/rules/pid-file.js +95 -0
- package/dist/rules/quiet-hours.js +45 -0
- package/dist/rules/suggest.js +95 -0
- package/dist/rules/throttle.js +116 -0
- package/dist/rules/types.js +34 -0
- package/dist/rules/webhook-listener.js +223 -0
- package/dist/rules/webhook-token.js +90 -0
- package/dist/status-sync/manager.js +268 -0
- package/dist/utils/audit.js +12 -2
- package/dist/utils/health.js +101 -0
- package/dist/utils/output.js +72 -23
- package/dist/utils/retry.js +81 -0
- package/package.json +12 -4
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local HTTP listener that delivers webhook events to the rules engine.
|
|
3
|
+
*
|
|
4
|
+
* Scope (E2):
|
|
5
|
+
* - Binds to `127.0.0.1` only — the loopback interface keeps the
|
|
6
|
+
* listener off the network by default. The plan's integration story
|
|
7
|
+
* is that an agent or local script POSTs to this endpoint.
|
|
8
|
+
* - Default port is 18790 (phase-3 design doc choice); override with
|
|
9
|
+
* `--webhook-port <n>` in `switchbot rules run`. `--webhook-port 0`
|
|
10
|
+
* asks the OS for an ephemeral port — useful in tests.
|
|
11
|
+
* - Bearer-token auth on every request: `Authorization: Bearer <t>`.
|
|
12
|
+
* The expected token comes from `WebhookTokenStore`; unauthorized
|
|
13
|
+
* requests get a 401 with no body, no hint about which header
|
|
14
|
+
* failed, and an audit entry (`rule-webhook-rejected`).
|
|
15
|
+
* - Matches request path against registered webhook rules: only
|
|
16
|
+
* `POST /path/exactly/as/declared`. Unknown paths return 404.
|
|
17
|
+
*
|
|
18
|
+
* Non-goals:
|
|
19
|
+
* - No TLS; operators who expose this outside loopback are expected
|
|
20
|
+
* to sit behind a reverse proxy that terminates TLS.
|
|
21
|
+
* - No payload parsing beyond reading the body as a string — the
|
|
22
|
+
* engine passes the raw body through in the event payload.
|
|
23
|
+
*/
|
|
24
|
+
import http from 'node:http';
|
|
25
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
26
|
+
import { writeAudit } from '../utils/audit.js';
|
|
27
|
+
import { isWebhookTrigger } from './types.js';
|
|
28
|
+
export const DEFAULT_WEBHOOK_PORT = 18790;
|
|
29
|
+
const MAX_BODY_BYTES = 16 * 1024; // guard against huge POSTs from misbehaving callers
|
|
30
|
+
export class WebhookListener {
|
|
31
|
+
opts;
|
|
32
|
+
server = null;
|
|
33
|
+
pathIndex = new Map();
|
|
34
|
+
actualPort = null;
|
|
35
|
+
constructor(opts) {
|
|
36
|
+
this.opts = opts;
|
|
37
|
+
for (const rule of opts.rules) {
|
|
38
|
+
if (!isWebhookTrigger(rule.when))
|
|
39
|
+
continue;
|
|
40
|
+
const normalised = normalisePath(rule.when.path);
|
|
41
|
+
if (this.pathIndex.has(normalised)) {
|
|
42
|
+
throw new Error(`WebhookListener: duplicate webhook path "${normalised}" — every webhook rule needs a unique path`);
|
|
43
|
+
}
|
|
44
|
+
this.pathIndex.set(normalised, rule);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Start listening. Resolves once the server has bound a port. */
|
|
48
|
+
async start() {
|
|
49
|
+
if (this.server)
|
|
50
|
+
return;
|
|
51
|
+
const server = http.createServer((req, res) => {
|
|
52
|
+
this.handle(req, res).catch((err) => {
|
|
53
|
+
// The dispatch chain should never reject — but if it does,
|
|
54
|
+
// make sure we close the socket so the caller doesn't hang.
|
|
55
|
+
if (!res.headersSent) {
|
|
56
|
+
res.writeHead(500);
|
|
57
|
+
res.end();
|
|
58
|
+
}
|
|
59
|
+
// eslint-disable-next-line no-console
|
|
60
|
+
console.error(`webhook-listener: unhandled dispatch error: ${err instanceof Error ? err.message : String(err)}`);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
const host = this.opts.host ?? '127.0.0.1';
|
|
64
|
+
const port = this.opts.port ?? DEFAULT_WEBHOOK_PORT;
|
|
65
|
+
await new Promise((resolve, reject) => {
|
|
66
|
+
const onError = (err) => {
|
|
67
|
+
server.off('listening', onListening);
|
|
68
|
+
reject(err);
|
|
69
|
+
};
|
|
70
|
+
const onListening = () => {
|
|
71
|
+
server.off('error', onError);
|
|
72
|
+
resolve();
|
|
73
|
+
};
|
|
74
|
+
server.once('error', onError);
|
|
75
|
+
server.once('listening', onListening);
|
|
76
|
+
server.listen(port, host);
|
|
77
|
+
});
|
|
78
|
+
const address = server.address();
|
|
79
|
+
this.actualPort = typeof address === 'object' && address ? address.port : port;
|
|
80
|
+
this.server = server;
|
|
81
|
+
}
|
|
82
|
+
async stop() {
|
|
83
|
+
if (!this.server)
|
|
84
|
+
return;
|
|
85
|
+
const server = this.server;
|
|
86
|
+
this.server = null;
|
|
87
|
+
this.actualPort = null;
|
|
88
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
89
|
+
}
|
|
90
|
+
getPort() {
|
|
91
|
+
return this.actualPort;
|
|
92
|
+
}
|
|
93
|
+
listPaths() {
|
|
94
|
+
return [...this.pathIndex.keys()].sort();
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Replace the current rule → path index. Used by `engine.reload`: the
|
|
98
|
+
* listener keeps its open port and accepted connections, but routes
|
|
99
|
+
* subsequent requests against the fresh policy.
|
|
100
|
+
*/
|
|
101
|
+
updateRules(rules) {
|
|
102
|
+
const next = new Map();
|
|
103
|
+
for (const rule of rules) {
|
|
104
|
+
if (!isWebhookTrigger(rule.when))
|
|
105
|
+
continue;
|
|
106
|
+
const normalised = normalisePath(rule.when.path);
|
|
107
|
+
if (next.has(normalised)) {
|
|
108
|
+
throw new Error(`WebhookListener.updateRules: duplicate webhook path "${normalised}"`);
|
|
109
|
+
}
|
|
110
|
+
next.set(normalised, rule);
|
|
111
|
+
}
|
|
112
|
+
this.pathIndex.clear();
|
|
113
|
+
for (const [k, v] of next)
|
|
114
|
+
this.pathIndex.set(k, v);
|
|
115
|
+
}
|
|
116
|
+
async handle(req, res) {
|
|
117
|
+
// Auth gate first — reject everything else so a wrong token never
|
|
118
|
+
// reveals which paths exist.
|
|
119
|
+
if (!this.isAuthorized(req)) {
|
|
120
|
+
writeAudit({
|
|
121
|
+
t: this.now().toISOString(),
|
|
122
|
+
kind: 'rule-webhook-rejected',
|
|
123
|
+
deviceId: 'unknown',
|
|
124
|
+
command: req.url ?? '',
|
|
125
|
+
parameter: null,
|
|
126
|
+
commandType: 'command',
|
|
127
|
+
dryRun: true,
|
|
128
|
+
result: 'error',
|
|
129
|
+
error: 'unauthorized',
|
|
130
|
+
});
|
|
131
|
+
res.writeHead(401);
|
|
132
|
+
res.end();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (req.method !== 'POST') {
|
|
136
|
+
res.writeHead(405, { Allow: 'POST' });
|
|
137
|
+
res.end();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const reqUrl = req.url ?? '/';
|
|
141
|
+
const questionMarkIdx = reqUrl.indexOf('?');
|
|
142
|
+
const rawPath = questionMarkIdx === -1 ? reqUrl : reqUrl.slice(0, questionMarkIdx);
|
|
143
|
+
const normalised = normalisePath(rawPath);
|
|
144
|
+
const rule = this.pathIndex.get(normalised);
|
|
145
|
+
if (!rule) {
|
|
146
|
+
writeAudit({
|
|
147
|
+
t: this.now().toISOString(),
|
|
148
|
+
kind: 'rule-webhook-rejected',
|
|
149
|
+
deviceId: 'unknown',
|
|
150
|
+
command: rawPath,
|
|
151
|
+
parameter: null,
|
|
152
|
+
commandType: 'command',
|
|
153
|
+
dryRun: true,
|
|
154
|
+
result: 'error',
|
|
155
|
+
error: 'unknown-path',
|
|
156
|
+
});
|
|
157
|
+
res.writeHead(404);
|
|
158
|
+
res.end();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const body = await readLimitedBody(req, MAX_BODY_BYTES);
|
|
162
|
+
if (body === null) {
|
|
163
|
+
res.writeHead(413);
|
|
164
|
+
res.end();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const event = {
|
|
168
|
+
source: 'webhook',
|
|
169
|
+
event: normalised,
|
|
170
|
+
t: this.now(),
|
|
171
|
+
payload: { path: normalised, body },
|
|
172
|
+
};
|
|
173
|
+
// Accept the request before dispatch so callers aren't held waiting
|
|
174
|
+
// on rule actions (which can include SwitchBot API calls).
|
|
175
|
+
res.writeHead(202, { 'Content-Type': 'application/json' });
|
|
176
|
+
res.end(JSON.stringify({ status: 'accepted', path: normalised }));
|
|
177
|
+
this.opts.dispatch(rule, event).catch(() => undefined);
|
|
178
|
+
}
|
|
179
|
+
isAuthorized(req) {
|
|
180
|
+
const h = req.headers['authorization'];
|
|
181
|
+
if (typeof h !== 'string')
|
|
182
|
+
return false;
|
|
183
|
+
const match = /^Bearer\s+(.+)$/i.exec(h.trim());
|
|
184
|
+
if (!match)
|
|
185
|
+
return false;
|
|
186
|
+
const provided = Buffer.from(match[1].trim(), 'utf-8');
|
|
187
|
+
const expected = Buffer.from(this.opts.bearerToken, 'utf-8');
|
|
188
|
+
if (provided.length !== expected.length)
|
|
189
|
+
return false;
|
|
190
|
+
return timingSafeEqual(provided, expected);
|
|
191
|
+
}
|
|
192
|
+
now() {
|
|
193
|
+
return this.opts.now ? this.opts.now() : new Date();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function normalisePath(p) {
|
|
197
|
+
if (!p)
|
|
198
|
+
return '/';
|
|
199
|
+
let out = p.trim();
|
|
200
|
+
if (!out.startsWith('/'))
|
|
201
|
+
out = `/${out}`;
|
|
202
|
+
// Collapse a trailing slash (but leave the root '/').
|
|
203
|
+
if (out.length > 1 && out.endsWith('/'))
|
|
204
|
+
out = out.slice(0, -1);
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
function readLimitedBody(req, max) {
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
const chunks = [];
|
|
210
|
+
let total = 0;
|
|
211
|
+
req.on('data', (chunk) => {
|
|
212
|
+
total += chunk.length;
|
|
213
|
+
if (total > max) {
|
|
214
|
+
req.destroy();
|
|
215
|
+
resolve(null);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
chunks.push(chunk);
|
|
219
|
+
});
|
|
220
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
221
|
+
req.on('error', reject);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook bearer-token management for the rules engine.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Resolve the bearer token the listener will accept. The order is
|
|
6
|
+
* env var (SWITCHBOT_WEBHOOK_TOKEN) → on-disk cache
|
|
7
|
+
* (~/.switchbot/webhook-token, chmod 0600) → generate a fresh
|
|
8
|
+
* 32-byte hex token and persist it.
|
|
9
|
+
* - Rotate the token on demand (`rules webhook-rotate-token` cli).
|
|
10
|
+
*
|
|
11
|
+
* Why not the OS keychain (F1 abstraction)? The webhook bearer is a
|
|
12
|
+
* single opaque string, whereas `CredentialStore` is shaped around the
|
|
13
|
+
* SwitchBot {token,secret} bundle. Fitting a one-field artifact into
|
|
14
|
+
* that contract bloats every profile; keeping it in a 0600 file gives
|
|
15
|
+
* the same protection the CLI has used for `~/.switchbot/config.json`.
|
|
16
|
+
* Promotion into the keychain is a future follow-up once the
|
|
17
|
+
* abstraction grows a generic single-value slot.
|
|
18
|
+
*/
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { randomBytes } from 'node:crypto';
|
|
23
|
+
const ENV_TOKEN = 'SWITCHBOT_WEBHOOK_TOKEN';
|
|
24
|
+
const DEFAULT_FILE = '.switchbot/webhook-token';
|
|
25
|
+
export class WebhookTokenStore {
|
|
26
|
+
filePath;
|
|
27
|
+
envLookup;
|
|
28
|
+
constructor(opts = {}) {
|
|
29
|
+
this.filePath = opts.filePath ?? path.join(os.homedir(), DEFAULT_FILE);
|
|
30
|
+
this.envLookup = opts.envLookup ?? (() => process.env[ENV_TOKEN]);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Return a bearer token, creating + persisting one if none exists yet.
|
|
34
|
+
* Env var wins when set; otherwise the on-disk token is read (and
|
|
35
|
+
* generated on first call).
|
|
36
|
+
*/
|
|
37
|
+
getOrCreate() {
|
|
38
|
+
const fromEnv = this.envLookup();
|
|
39
|
+
if (fromEnv && fromEnv.trim().length > 0)
|
|
40
|
+
return fromEnv.trim();
|
|
41
|
+
const existing = this.readFromDisk();
|
|
42
|
+
if (existing)
|
|
43
|
+
return existing;
|
|
44
|
+
const fresh = generateToken();
|
|
45
|
+
this.writeToDisk(fresh);
|
|
46
|
+
return fresh;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Read the persisted token, returning null when the file is absent
|
|
50
|
+
* or empty. Does NOT consult the env var — callers that want the
|
|
51
|
+
* env-aware path should use `getOrCreate()`.
|
|
52
|
+
*/
|
|
53
|
+
readFromDisk() {
|
|
54
|
+
try {
|
|
55
|
+
const raw = fs.readFileSync(this.filePath, 'utf-8').trim();
|
|
56
|
+
return raw.length > 0 ? raw : null;
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
if (err.code === 'ENOENT')
|
|
60
|
+
return null;
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Write a new token, persisting with 0600 perms. */
|
|
65
|
+
rotate() {
|
|
66
|
+
const fresh = generateToken();
|
|
67
|
+
this.writeToDisk(fresh);
|
|
68
|
+
return fresh;
|
|
69
|
+
}
|
|
70
|
+
getFilePath() {
|
|
71
|
+
return this.filePath;
|
|
72
|
+
}
|
|
73
|
+
writeToDisk(token) {
|
|
74
|
+
const dir = path.dirname(this.filePath);
|
|
75
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
76
|
+
fs.writeFileSync(this.filePath, `${token}\n`, { mode: 0o600 });
|
|
77
|
+
// mkdirSync + writeFileSync race can leave broader perms on Windows
|
|
78
|
+
// (perm bits are mostly advisory there anyway), but on POSIX we
|
|
79
|
+
// re-chmod to be explicit about intent.
|
|
80
|
+
try {
|
|
81
|
+
fs.chmodSync(this.filePath, 0o600);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// non-POSIX filesystems may reject chmod — intentional best effort.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export function generateToken() {
|
|
89
|
+
return randomBytes(32).toString('hex');
|
|
90
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { tryLoadConfig } from '../config.js';
|
|
6
|
+
import { getActiveProfile } from '../lib/request-context.js';
|
|
7
|
+
import { UsageError } from '../utils/output.js';
|
|
8
|
+
import { getConfigPath } from '../utils/flags.js';
|
|
9
|
+
const DEFAULT_OPENCLAW_URL = 'http://localhost:18789';
|
|
10
|
+
function resolveStatusSyncRuntime(options) {
|
|
11
|
+
if (!tryLoadConfig()) {
|
|
12
|
+
throw new UsageError('No credentials found. Run \'switchbot config set-token\' or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET.');
|
|
13
|
+
}
|
|
14
|
+
const openclawToken = options.openclawToken ?? process.env.OPENCLAW_TOKEN;
|
|
15
|
+
if (!openclawToken) {
|
|
16
|
+
throw new UsageError('--openclaw-token is required or set OPENCLAW_TOKEN in the environment.');
|
|
17
|
+
}
|
|
18
|
+
const openclawModel = options.openclawModel ?? process.env.OPENCLAW_MODEL;
|
|
19
|
+
if (!openclawModel) {
|
|
20
|
+
throw new UsageError('--openclaw-model is required or set OPENCLAW_MODEL in the environment.');
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
openclawUrl: options.openclawUrl ?? process.env.OPENCLAW_URL ?? DEFAULT_OPENCLAW_URL,
|
|
24
|
+
openclawToken,
|
|
25
|
+
openclawModel,
|
|
26
|
+
...(options.topic ? { topic: options.topic } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function resolveStatusSyncPaths(explicitStateDir) {
|
|
30
|
+
const stateDir = path.resolve(explicitStateDir
|
|
31
|
+
?? process.env.SWITCHBOT_STATUS_SYNC_HOME
|
|
32
|
+
?? path.join(os.homedir(), '.switchbot', 'status-sync'));
|
|
33
|
+
return {
|
|
34
|
+
stateDir,
|
|
35
|
+
stateFile: path.join(stateDir, 'state.json'),
|
|
36
|
+
stdoutLog: path.join(stateDir, 'stdout.log'),
|
|
37
|
+
stderrLog: path.join(stateDir, 'stderr.log'),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function buildStatusSyncChildArgs(options) {
|
|
41
|
+
const scriptPath = process.argv[1];
|
|
42
|
+
if (!scriptPath) {
|
|
43
|
+
throw new Error('Cannot determine the current CLI entrypoint path.');
|
|
44
|
+
}
|
|
45
|
+
const args = [path.resolve(scriptPath)];
|
|
46
|
+
const configPath = getConfigPath();
|
|
47
|
+
const profile = getActiveProfile();
|
|
48
|
+
if (configPath) {
|
|
49
|
+
args.push('--config', path.resolve(configPath));
|
|
50
|
+
}
|
|
51
|
+
else if (profile) {
|
|
52
|
+
args.push('--profile', profile);
|
|
53
|
+
}
|
|
54
|
+
args.push('events', 'mqtt-tail', '--sink', 'openclaw', '--openclaw-url', options.openclawUrl, '--openclaw-model', options.openclawModel);
|
|
55
|
+
if (options.topic) {
|
|
56
|
+
args.push('--topic', options.topic);
|
|
57
|
+
}
|
|
58
|
+
return args;
|
|
59
|
+
}
|
|
60
|
+
function safeUnlink(filePath) {
|
|
61
|
+
try {
|
|
62
|
+
fs.unlinkSync(filePath);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// best-effort cleanup
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function isProcessRunning(pid) {
|
|
69
|
+
try {
|
|
70
|
+
process.kill(pid, 0);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
const code = err.code;
|
|
75
|
+
if (code === 'EPERM')
|
|
76
|
+
return true;
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function readStateFile(paths) {
|
|
81
|
+
if (!fs.existsSync(paths.stateFile))
|
|
82
|
+
return null;
|
|
83
|
+
try {
|
|
84
|
+
const raw = JSON.parse(fs.readFileSync(paths.stateFile, 'utf-8'));
|
|
85
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
86
|
+
safeUnlink(paths.stateFile);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const parsed = raw;
|
|
90
|
+
if (typeof parsed.pid !== 'number' ||
|
|
91
|
+
!Number.isInteger(parsed.pid) ||
|
|
92
|
+
parsed.pid < 1 ||
|
|
93
|
+
typeof parsed.startedAt !== 'string' ||
|
|
94
|
+
!Array.isArray(parsed.command) ||
|
|
95
|
+
typeof parsed.stdoutLog !== 'string' ||
|
|
96
|
+
typeof parsed.stderrLog !== 'string') {
|
|
97
|
+
safeUnlink(paths.stateFile);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
pid: parsed.pid,
|
|
102
|
+
startedAt: parsed.startedAt,
|
|
103
|
+
command: parsed.command.map(String),
|
|
104
|
+
openclawUrl: typeof parsed.openclawUrl === 'string' ? parsed.openclawUrl : DEFAULT_OPENCLAW_URL,
|
|
105
|
+
openclawModel: typeof parsed.openclawModel === 'string' ? parsed.openclawModel : '',
|
|
106
|
+
topic: typeof parsed.topic === 'string' ? parsed.topic : null,
|
|
107
|
+
configPath: typeof parsed.configPath === 'string' ? parsed.configPath : null,
|
|
108
|
+
profile: typeof parsed.profile === 'string' ? parsed.profile : null,
|
|
109
|
+
stdoutLog: parsed.stdoutLog,
|
|
110
|
+
stderrLog: parsed.stderrLog,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
safeUnlink(paths.stateFile);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function toStatus(paths, state, running) {
|
|
119
|
+
return {
|
|
120
|
+
running,
|
|
121
|
+
pid: running && state ? state.pid : null,
|
|
122
|
+
startedAt: running && state ? state.startedAt : null,
|
|
123
|
+
stateDir: paths.stateDir,
|
|
124
|
+
stateFile: paths.stateFile,
|
|
125
|
+
stdoutLog: state?.stdoutLog ?? paths.stdoutLog,
|
|
126
|
+
stderrLog: state?.stderrLog ?? paths.stderrLog,
|
|
127
|
+
command: running && state ? state.command : null,
|
|
128
|
+
openclawUrl: running && state ? state.openclawUrl : null,
|
|
129
|
+
openclawModel: running && state ? state.openclawModel : null,
|
|
130
|
+
topic: running && state ? state.topic : null,
|
|
131
|
+
configPath: running && state ? state.configPath : null,
|
|
132
|
+
profile: running && state ? state.profile : null,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function killProcessTree(pid) {
|
|
136
|
+
if (process.platform === 'win32') {
|
|
137
|
+
const result = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
|
|
138
|
+
if (result.error)
|
|
139
|
+
throw result.error;
|
|
140
|
+
if (result.status !== 0 && isProcessRunning(pid)) {
|
|
141
|
+
throw new Error(`Failed to stop status-sync process tree (PID ${pid}).`);
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
process.kill(-pid, 'SIGTERM');
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
const code = err.code;
|
|
150
|
+
if (code === 'ESRCH') {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
process.kill(pid, 'SIGTERM');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
export function getStatusSyncStatus(options = {}) {
|
|
157
|
+
const paths = resolveStatusSyncPaths(options.stateDir);
|
|
158
|
+
const state = readStateFile(paths);
|
|
159
|
+
if (!state) {
|
|
160
|
+
return toStatus(paths, null, false);
|
|
161
|
+
}
|
|
162
|
+
if (!isProcessRunning(state.pid)) {
|
|
163
|
+
safeUnlink(paths.stateFile);
|
|
164
|
+
return toStatus(paths, null, false);
|
|
165
|
+
}
|
|
166
|
+
return toStatus(paths, state, true);
|
|
167
|
+
}
|
|
168
|
+
export function stopStatusSync(options = {}) {
|
|
169
|
+
const paths = resolveStatusSyncPaths(options.stateDir);
|
|
170
|
+
const state = readStateFile(paths);
|
|
171
|
+
if (!state) {
|
|
172
|
+
return {
|
|
173
|
+
stopped: false,
|
|
174
|
+
stale: false,
|
|
175
|
+
pid: null,
|
|
176
|
+
status: toStatus(paths, null, false),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (!isProcessRunning(state.pid)) {
|
|
180
|
+
safeUnlink(paths.stateFile);
|
|
181
|
+
return {
|
|
182
|
+
stopped: false,
|
|
183
|
+
stale: true,
|
|
184
|
+
pid: state.pid,
|
|
185
|
+
status: toStatus(paths, null, false),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
killProcessTree(state.pid);
|
|
189
|
+
if (isProcessRunning(state.pid)) {
|
|
190
|
+
throw new Error(`Failed to stop status-sync process (PID ${state.pid}); process is still running.`);
|
|
191
|
+
}
|
|
192
|
+
safeUnlink(paths.stateFile);
|
|
193
|
+
return {
|
|
194
|
+
stopped: true,
|
|
195
|
+
stale: false,
|
|
196
|
+
pid: state.pid,
|
|
197
|
+
status: toStatus(paths, null, false),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
export function startStatusSync(options = {}) {
|
|
201
|
+
const runtime = resolveStatusSyncRuntime(options);
|
|
202
|
+
const paths = resolveStatusSyncPaths(options.stateDir);
|
|
203
|
+
const existing = getStatusSyncStatus({ stateDir: paths.stateDir });
|
|
204
|
+
if (existing.running) {
|
|
205
|
+
if (!options.force) {
|
|
206
|
+
throw new UsageError(`status-sync is already running (PID ${existing.pid}). Run 'switchbot status-sync stop' first or re-run with --force.`);
|
|
207
|
+
}
|
|
208
|
+
stopStatusSync({ stateDir: paths.stateDir });
|
|
209
|
+
}
|
|
210
|
+
fs.mkdirSync(paths.stateDir, { recursive: true });
|
|
211
|
+
const configPath = getConfigPath();
|
|
212
|
+
const command = buildStatusSyncChildArgs(runtime);
|
|
213
|
+
let stdoutFd = null;
|
|
214
|
+
let stderrFd = null;
|
|
215
|
+
try {
|
|
216
|
+
stdoutFd = fs.openSync(paths.stdoutLog, 'a');
|
|
217
|
+
stderrFd = fs.openSync(paths.stderrLog, 'a');
|
|
218
|
+
const child = spawn(process.execPath, command, {
|
|
219
|
+
detached: true,
|
|
220
|
+
stdio: ['ignore', stdoutFd, stderrFd],
|
|
221
|
+
windowsHide: true,
|
|
222
|
+
env: { ...process.env, OPENCLAW_TOKEN: runtime.openclawToken },
|
|
223
|
+
});
|
|
224
|
+
if (!child.pid) {
|
|
225
|
+
throw new Error('Failed to start status-sync child process.');
|
|
226
|
+
}
|
|
227
|
+
child.unref();
|
|
228
|
+
const state = {
|
|
229
|
+
pid: child.pid,
|
|
230
|
+
startedAt: new Date().toISOString(),
|
|
231
|
+
command: [process.execPath, ...command],
|
|
232
|
+
openclawUrl: runtime.openclawUrl,
|
|
233
|
+
openclawModel: runtime.openclawModel,
|
|
234
|
+
topic: runtime.topic ?? null,
|
|
235
|
+
configPath: configPath ? path.resolve(configPath) : null,
|
|
236
|
+
profile: configPath ? null : (getActiveProfile() ?? null),
|
|
237
|
+
stdoutLog: paths.stdoutLog,
|
|
238
|
+
stderrLog: paths.stderrLog,
|
|
239
|
+
};
|
|
240
|
+
fs.writeFileSync(paths.stateFile, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
241
|
+
return toStatus(paths, state, true);
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
if (stdoutFd !== null)
|
|
245
|
+
fs.closeSync(stdoutFd);
|
|
246
|
+
if (stderrFd !== null)
|
|
247
|
+
fs.closeSync(stderrFd);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
export async function runStatusSyncForeground(options = {}) {
|
|
251
|
+
const runtime = resolveStatusSyncRuntime(options);
|
|
252
|
+
const command = buildStatusSyncChildArgs(runtime);
|
|
253
|
+
return await new Promise((resolve, reject) => {
|
|
254
|
+
const child = spawn(process.execPath, command, {
|
|
255
|
+
stdio: 'inherit',
|
|
256
|
+
windowsHide: true,
|
|
257
|
+
env: { ...process.env, OPENCLAW_TOKEN: runtime.openclawToken },
|
|
258
|
+
});
|
|
259
|
+
child.once('error', reject);
|
|
260
|
+
child.once('exit', (code, signal) => {
|
|
261
|
+
if (signal) {
|
|
262
|
+
resolve(1);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
resolve(code ?? 0);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
}
|
package/dist/utils/audit.js
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { getAuditLog } from './flags.js';
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Bump when breaking changes to the audit line shape land.
|
|
6
|
+
*
|
|
7
|
+
* History:
|
|
8
|
+
* 1 — initial command audit (kind: 'command' only).
|
|
9
|
+
* 2 — adds rule-engine kinds ('rule-fire', 'rule-fire-dry',
|
|
10
|
+
* 'rule-throttled', 'rule-webhook-rejected') and a sibling `rule`
|
|
11
|
+
* block describing which rule fired and why. Reader stays backwards
|
|
12
|
+
* compatible: v1 lines parse as command entries with `rule`
|
|
13
|
+
* undefined.
|
|
14
|
+
*/
|
|
15
|
+
export const AUDIT_VERSION = 2;
|
|
6
16
|
function resolveAuditPath() {
|
|
7
17
|
const flag = getAuditLog();
|
|
8
18
|
if (flag === null)
|