@botbuddy/cli 1.59.6 → 1.59.8

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.
@@ -0,0 +1,438 @@
1
+ // BOT-2055: the Node CLI is only the privileged installer. The installed
2
+ // workload is the exact verified Go payload and contains no Node dependency.
3
+ import { lstat, mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { execFile as execFileCallback, spawn } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+ import { randomUUID } from "node:crypto";
9
+
10
+ const execFile = promisify(execFileCallback);
11
+ export const destinations = Object.freeze({
12
+ darwin: "/Library/LaunchDaemons/com.botbuddy.daemon.plist",
13
+ linux: "/etc/systemd/system/botbuddy-daemon.service",
14
+ });
15
+ const fail = (code) => { throw new Error(code); };
16
+ const safeAbsolutePath = (value) => typeof value === "string" && /^\/(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+$/.test(value);
17
+ const safeSshHost = (value) => typeof value === "string" && /^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9._-]+$/.test(value);
18
+ const safeRelease = (release) => release && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(release.releaseVersion)
19
+ && /^(darwin-arm64|linux-x64)$/.test(release.target)
20
+ && /^[0-9a-f]{40}$/.test(release.sourceCommit)
21
+ && /^[0-9a-f]{64}$/.test(release.sha256)
22
+ && Buffer.isBuffer(release.payload);
23
+ const sshOptions = Object.freeze(["-o", "BatchMode=yes", "-o", "ForwardAgent=no", "-o", "StrictHostKeyChecking=yes"]);
24
+
25
+ export async function serviceUserUid(user, exec = execFile) {
26
+ if (typeof user !== "string" || !/^[a-z_][a-z0-9_-]*$/i.test(user)) fail("daemon_service_user_invalid");
27
+ let result;
28
+ try { result = await exec("id", ["-u", user]); } catch { fail("daemon_service_user_invalid"); }
29
+ const uid = Number.parseInt(String(result?.stdout ?? "").trim(), 10);
30
+ if (!Number.isSafeInteger(uid) || uid <= 0 || String(uid) !== String(result?.stdout ?? "").trim()) fail("daemon_service_user_invalid");
31
+ return uid;
32
+ }
33
+
34
+ export function installedBinaryPath(release) {
35
+ return `/opt/botbuddy/daemon/releases/${release.releaseVersion}/${release.target}/botbuddy-daemon`;
36
+ }
37
+
38
+ function checksumCommand(platform) {
39
+ return platform === "darwin" ? ["shasum", ["-a", "256"]] : ["sha256sum", []];
40
+ }
41
+
42
+ function checksumMatches(output, expected) {
43
+ return String(output ?? "").trim().split(/\s+/)[0] === expected;
44
+ }
45
+
46
+ function expectedBootoutAbsent(error) {
47
+ return /Boot-out failed:\s*3:|Could not find service/.test(String(error?.stderr ?? error?.message ?? ""));
48
+ }
49
+
50
+ export function assertSshHost(host) {
51
+ if (!safeSshHost(host)) fail("daemon_remote_host_invalid");
52
+ return host;
53
+ }
54
+
55
+ function remoteScript(host, args, script, { timeout = 30_000, maxOutput = 16 * 1024 } = {}) {
56
+ assertSshHost(host);
57
+ return new Promise((resolve, reject) => {
58
+ const child = spawn("ssh", [...sshOptions, host, ...args], { stdio: ["pipe", "pipe", "pipe"] });
59
+ let stdout = "";
60
+ let stderr = "";
61
+ let bounded = true;
62
+ let timedOut = false;
63
+ const timer = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, timeout);
64
+ const append = (current, chunk) => {
65
+ const next = current + String(chunk);
66
+ if (next.length > maxOutput) { bounded = false; child.kill("SIGTERM"); return current; }
67
+ return next;
68
+ };
69
+ child.stdout.on("data", (chunk) => { stdout = append(stdout, chunk); });
70
+ child.stderr.on("data", (chunk) => { stderr = append(stderr, chunk); });
71
+ child.on("error", (error) => { clearTimeout(timer); reject(error); });
72
+ child.on("close", (code) => {
73
+ clearTimeout(timer);
74
+ if (timedOut) return reject(new Error("daemon_remote_command_timeout"));
75
+ if (!bounded) return reject(new Error("daemon_remote_command_output_exceeded"));
76
+ return code === 0 ? resolve({ stdout, stderr }) : reject(Object.assign(new Error("daemon_remote_command_failed"), { code }));
77
+ });
78
+ child.stdin.end(script);
79
+ });
80
+ }
81
+
82
+ const remotePrepareScript = String.raw`set -eu
83
+ directory=$(mktemp -d /tmp/botbuddy-daemon.XXXXXX)
84
+ chmod 0700 "$directory"
85
+ printf '%s\n' "$directory"
86
+ `;
87
+
88
+ const remoteCleanupScript = String.raw`set -eu
89
+ directory=$1
90
+ case "$directory" in /tmp/botbuddy-daemon.[A-Za-z0-9]*) ;; *) exit 80 ;; esac
91
+ rm -rf -- "$directory"
92
+ `;
93
+
94
+ async function remoteTemporaryDirectory(host, runScript = remoteScript) {
95
+ const result = await runScript(host, ["sh", "-s", "--"], remotePrepareScript);
96
+ const directory = String(result?.stdout ?? "").trim();
97
+ if (!/^\/tmp\/botbuddy-daemon\.[A-Za-z0-9]+$/.test(directory)) fail("daemon_remote_staging_invalid");
98
+ return directory;
99
+ }
100
+
101
+ // This is intentionally fixed shell, passed on stdin to ssh. Caller data is
102
+ // positional-only and validated before transport; the target needs no Node/npm.
103
+ export const remoteInstallScript = String.raw`set -eu
104
+ directory=$1
105
+ platform=$2
106
+ user=$3
107
+ version=$4
108
+ commit=$5
109
+ runtime_target=$6
110
+ digest=$7
111
+ config=$8
112
+ destination=$9
113
+ shift 9
114
+ binary=$1
115
+ case "$directory" in /tmp/botbuddy-daemon.[A-Za-z0-9]*) ;; *) exit 80 ;; esac
116
+ binary_stage=
117
+ definition_stage=
118
+ cleanup() {
119
+ test -z "$binary_stage" || sudo rm -f -- "$binary_stage" || true
120
+ test -z "$definition_stage" || sudo rm -f -- "$definition_stage" || true
121
+ rm -rf -- "$directory"
122
+ }
123
+ trap cleanup EXIT HUP INT TERM
124
+ test "$(id -u "$user")" -gt 0
125
+ test -f "$directory/botbuddy-daemon"
126
+ test -f "$directory/definition"
127
+ case "$platform" in
128
+ darwin) group=wheel; expected_destination=/Library/LaunchDaemons/com.botbuddy.daemon.plist ;;
129
+ linux) group=root; expected_destination=/etc/systemd/system/botbuddy-daemon.service ;;
130
+ *) exit 81 ;;
131
+ esac
132
+ test "$destination" = "$expected_destination"
133
+ # The staging directory starts private to the SSH user. It is made traverse-
134
+ # only so the distinct service user can execute the already-verified payload,
135
+ # but cannot list or modify the staging contents.
136
+ chmod 0711 "$directory"
137
+ chmod 0755 "$directory/botbuddy-daemon"
138
+ if [ "$platform" = darwin ]; then
139
+ actual=$(shasum -a 256 "$directory/botbuddy-daemon" | awk '{print $1}')
140
+ else
141
+ actual=$(sha256sum "$directory/botbuddy-daemon" | awk '{print $1}')
142
+ fi
143
+ test "$actual" = "$digest"
144
+ # Verify identity and configuration as the future non-root service user first.
145
+ test "$(sudo -u "$user" "$directory/botbuddy-daemon" --version)" = "{\"version\":\"$version\",\"commit\":\"$commit\",\"target\":\"$runtime_target\"}"
146
+ test "$(sudo -u "$user" "$directory/botbuddy-daemon" --check-config --config "$config")" = '{"valid":true}'
147
+ binary_directory=$(dirname "$binary")
148
+ service_directory=$(dirname "$destination")
149
+ safe_ancestry() {
150
+ path=$1
151
+ old_ifs=$IFS
152
+ IFS=/
153
+ set -- $path
154
+ IFS=$old_ifs
155
+ current=
156
+ for segment in "$@"; do
157
+ test -n "$segment" || continue
158
+ current=$current/$segment
159
+ if sudo test -L "$current"; then exit 82; fi
160
+ if ! sudo test -e "$current"; then return 0; fi
161
+ sudo test -d "$current"
162
+ if [ "$platform" = darwin ]; then
163
+ owner=$(sudo stat -f '%u' "$current")
164
+ mode=$(sudo stat -f '%OLp' "$current")
165
+ else
166
+ owner=$(sudo stat -c '%u' "$current")
167
+ mode=$(sudo stat -c '%a' "$current")
168
+ fi
169
+ test "$owner" = 0
170
+ case "$mode" in *[2367][0-7]|*[0-7][2367]) exit 82 ;; esac
171
+ done
172
+ }
173
+ safe_ancestry "$binary_directory"
174
+ safe_ancestry "$service_directory"
175
+ if sudo test -e "$binary"; then
176
+ if [ "$platform" = darwin ]; then actual=$(sudo shasum -a 256 "$binary" | awk '{print $1}'); else actual=$(sudo sha256sum "$binary" | awk '{print $1}'); fi
177
+ test "$actual" = "$digest"
178
+ else
179
+ sudo install -d -o root -g "$group" -m 0755 "$binary_directory"
180
+ binary_stage=$(sudo mktemp "$binary_directory/.botbuddy-daemon.XXXXXXXX")
181
+ sudo install -o root -g "$group" -m 0755 "$directory/botbuddy-daemon" "$binary_stage"
182
+ if [ "$platform" = darwin ]; then actual=$(sudo shasum -a 256 "$binary_stage" | awk '{print $1}'); else actual=$(sudo sha256sum "$binary_stage" | awk '{print $1}'); fi
183
+ test "$actual" = "$digest"
184
+ sudo mv -n "$binary_stage" "$binary"
185
+ binary_stage=
186
+ if [ "$platform" = darwin ]; then actual=$(sudo shasum -a 256 "$binary" | awk '{print $1}'); else actual=$(sudo sha256sum "$binary" | awk '{print $1}'); fi
187
+ test "$actual" = "$digest"
188
+ fi
189
+ sudo install -d -o root -g "$group" -m 0755 "$service_directory"
190
+ definition_stage=$(sudo mktemp "$service_directory/.botbuddy-daemon.XXXXXXXX")
191
+ sudo install -o root -g "$group" -m 0644 "$directory/definition" "$definition_stage"
192
+ sudo mv -f "$definition_stage" "$destination"
193
+ definition_stage=
194
+ case "$platform" in
195
+ darwin)
196
+ if ! bootout=$(sudo launchctl bootout system/com.botbuddy.daemon 2>&1); then
197
+ case "$bootout" in *"Boot-out failed: 3:"*|*"Could not find service"*) ;; *) printf '%s\n' "$bootout" >&2; exit 83 ;; esac
198
+ fi
199
+ sudo launchctl bootstrap system "$destination"
200
+ ;;
201
+ linux)
202
+ sudo systemctl daemon-reload
203
+ sudo systemctl enable botbuddy-daemon.service
204
+ sudo systemctl restart botbuddy-daemon.service
205
+ ;;
206
+ esac
207
+ printf '%s\n' '{"outcome":"installed"}'
208
+ `;
209
+
210
+ // Bootstrap must establish the selected Go executable before it asks that
211
+ // executable to create or inspect target identity. This phase deliberately
212
+ // excludes profile/config validation, service definitions, and activation: the
213
+ // existing remote installer owns those post-enrollment checks.
214
+ export const remoteStageReleaseScript = String.raw`set -eu
215
+ directory=$1
216
+ platform=$2
217
+ user=$3
218
+ version=$4
219
+ commit=$5
220
+ runtime_target=$6
221
+ digest=$7
222
+ binary=$8
223
+ case "$directory" in /tmp/botbuddy-daemon.[A-Za-z0-9]*) ;; *) exit 80 ;; esac
224
+ binary_stage=
225
+ cleanup() {
226
+ test -z "$binary_stage" || sudo -n rm -f -- "$binary_stage" || true
227
+ rm -rf -- "$directory"
228
+ }
229
+ trap cleanup EXIT HUP INT TERM
230
+ test "$(id -u "$user")" -gt 0
231
+ test -f "$directory/botbuddy-daemon"
232
+ case "$platform" in
233
+ darwin) group=wheel ;;
234
+ linux) group=root ;;
235
+ *) exit 81 ;;
236
+ esac
237
+ binary_directory=$(dirname "$binary")
238
+ safe_ancestry() {
239
+ path=$1
240
+ old_ifs=$IFS
241
+ IFS=/
242
+ set -- $path
243
+ IFS=$old_ifs
244
+ current=
245
+ for segment in "$@"; do
246
+ test -n "$segment" || continue
247
+ current=$current/$segment
248
+ if sudo -n test -L "$current"; then exit 82; fi
249
+ if ! sudo -n test -e "$current"; then return 0; fi
250
+ sudo -n test -d "$current"
251
+ if [ "$platform" = darwin ]; then owner=$(sudo -n stat -f '%u' "$current"); else owner=$(sudo -n stat -c '%u' "$current"); fi
252
+ test "$owner" = 0
253
+ if [ "$platform" = darwin ]; then mode=$(sudo -n stat -f '%OLp' "$current"); else mode=$(sudo -n stat -c '%a' "$current"); fi
254
+ case "$mode" in *[2367][0-7]|*[0-7][2367]) exit 82 ;; esac
255
+ done
256
+ }
257
+ verify_binary() {
258
+ sudo -n test -f "$binary"
259
+ sudo -n test ! -L "$binary"
260
+ if [ "$platform" = darwin ]; then owner=$(sudo -n stat -f '%u' "$binary"); else owner=$(sudo -n stat -c '%u' "$binary"); fi
261
+ test "$owner" = 0
262
+ if [ "$platform" = darwin ]; then mode=$(sudo -n stat -f '%OLp' "$binary"); else mode=$(sudo -n stat -c '%a' "$binary"); fi
263
+ case "$mode" in 755|100755) ;; *) exit 82 ;; esac
264
+ if [ "$platform" = darwin ]; then actual=$(sudo -n shasum -a 256 "$binary" | awk '{print $1}'); else actual=$(sudo -n sha256sum "$binary" | awk '{print $1}'); fi
265
+ test "$actual" = "$digest"
266
+ test "$(sudo -n -u "$user" -- "$binary" --version)" = "{\"version\":\"$version\",\"commit\":\"$commit\",\"target\":\"$runtime_target\"}"
267
+ }
268
+ safe_ancestry "$binary_directory"
269
+ if sudo -n test -e "$binary"; then
270
+ verify_binary
271
+ else
272
+ sudo -n install -d -o root -g "$group" -m 0755 "$binary_directory"
273
+ binary_stage=$(sudo -n mktemp "$binary_directory/.botbuddy-daemon.XXXXXXXX")
274
+ sudo -n install -o root -g "$group" -m 0755 "$directory/botbuddy-daemon" "$binary_stage"
275
+ if [ "$platform" = darwin ]; then actual=$(sudo -n shasum -a 256 "$binary_stage" | awk '{print $1}'); else actual=$(sudo -n sha256sum "$binary_stage" | awk '{print $1}'); fi
276
+ test "$actual" = "$digest"
277
+ sudo -n mv -n "$binary_stage" "$binary"
278
+ binary_stage=
279
+ verify_binary
280
+ fi
281
+ printf '%s\n' '{"outcome":"staged"}'
282
+ `;
283
+
284
+ export async function stageRemoteDaemonRelease({
285
+ platform, host, user, release,
286
+ exec = execFile, runScript = remoteScript, makeTemp = mkdtemp, write = writeFile, remove = rm,
287
+ } = {}) {
288
+ if (!destinations[platform] || !safeSshHost(host) || typeof user !== "string" || !/^[a-z_][a-z0-9_-]*$/i.test(user) || !safeRelease(release)) {
289
+ fail("daemon_remote_stage_invalid");
290
+ }
291
+ const runtimeTarget = release.target === "linux-x64" ? "linux-amd64" : release.target;
292
+ const localDirectory = await makeTemp(join(tmpdir(), "botbuddy-daemon-"));
293
+ const snapshot = join(localDirectory, "botbuddy-daemon");
294
+ let remoteDirectory;
295
+ let remoteCleaned = false;
296
+ try {
297
+ await write(snapshot, release.payload, { mode: 0o500 });
298
+ remoteDirectory = await remoteTemporaryDirectory(host, runScript);
299
+ await exec("scp", ["-q", ...sshOptions, "--", snapshot, `${host}:${remoteDirectory}/`], { timeout: 30_000, maxBuffer: 16 * 1024 });
300
+ const result = await runScript(host, ["sh", "-s", "--", remoteDirectory, platform, user, release.releaseVersion, release.sourceCommit, runtimeTarget, release.sha256, installedBinaryPath(release)], remoteStageReleaseScript);
301
+ if (String(result?.stdout ?? "").trim() !== '{"outcome":"staged"}') fail("daemon_remote_stage_unreadable");
302
+ remoteCleaned = true;
303
+ return { outcome: "staged", binary: installedBinaryPath(release), service_user: user, host };
304
+ } finally {
305
+ if (remoteDirectory && !remoteCleaned) await runScript(host, ["sh", "-s", "--", remoteDirectory], remoteCleanupScript, { timeout: 15_000 }).catch(() => {});
306
+ await remove(localDirectory, { recursive: true, force: true });
307
+ }
308
+ }
309
+
310
+ export async function installRemoteService({
311
+ platform, host, user, definition, release, config,
312
+ exec = execFile, runScript = remoteScript, makeTemp = mkdtemp, write = writeFile, remove = rm,
313
+ } = {}) {
314
+ if (!destinations[platform] || !safeSshHost(host) || typeof definition !== "string" || !definition.length || !safeRelease(release) || !safeAbsolutePath(config)) fail("daemon_remote_install_invalid");
315
+ const runtimeTarget = release.target === "linux-x64" ? "linux-amd64" : release.target;
316
+ const localDirectory = await makeTemp(join(tmpdir(), "botbuddy-daemon-"));
317
+ const snapshot = join(localDirectory, "botbuddy-daemon");
318
+ const definitionFile = join(localDirectory, "definition");
319
+ let remoteDirectory;
320
+ let remoteCleaned = false;
321
+ try {
322
+ await write(snapshot, release.payload, { mode: 0o500 });
323
+ await write(definitionFile, definition, { mode: 0o600 });
324
+ remoteDirectory = await remoteTemporaryDirectory(host, runScript);
325
+ await exec("scp", ["-q", ...sshOptions, "--", snapshot, definitionFile, `${host}:${remoteDirectory}/`], { timeout: 30_000, maxBuffer: 16 * 1024 });
326
+ const result = await runScript(host, ["sh", "-s", "--", remoteDirectory, platform, user, release.releaseVersion, release.sourceCommit, runtimeTarget, release.sha256, config, destinations[platform], installedBinaryPath(release)], remoteInstallScript);
327
+ if (String(result?.stdout ?? "").trim() !== '{"outcome":"installed"}') fail("daemon_remote_install_unreadable");
328
+ remoteCleaned = true;
329
+ return { outcome: "installed", destination: destinations[platform], binary: installedBinaryPath(release), service_user: user, host };
330
+ } finally {
331
+ if (remoteDirectory && !remoteCleaned) await runScript(host, ["sh", "-s", "--", remoteDirectory], remoteCleanupScript, { timeout: 15_000 }).catch(() => {});
332
+ await remove(localDirectory, { recursive: true, force: true });
333
+ }
334
+ }
335
+
336
+ async function safeRootAncestry(path, inspect = lstat) {
337
+ const segments = path.split("/").filter(Boolean);
338
+ let current = "";
339
+ for (const segment of segments) {
340
+ current += `/${segment}`;
341
+ const stat = await inspect(current).catch((error) => error?.code === "ENOENT" ? null : Promise.reject(error));
342
+ if (!stat) return;
343
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== 0 || (stat.mode & 0o022) !== 0) fail("daemon_install_unsafe_destination");
344
+ }
345
+ }
346
+
347
+ export async function installService({
348
+ platform, user, definition, release, config,
349
+ exec = execFile, makeTemp = mkdtemp, write = writeFile, remove = rm, random = randomUUID, inspect = lstat,
350
+ } = {}) {
351
+ if (!destinations[platform] || typeof definition !== "string" || !definition.length || !safeRelease(release) || !safeAbsolutePath(config)) fail("daemon_install_invalid");
352
+ await serviceUserUid(user, exec);
353
+ const destination = destinations[platform];
354
+ const binary = installedBinaryPath(release);
355
+ const binaryDirectory = binary.slice(0, binary.lastIndexOf("/"));
356
+ const serviceDirectory = destination.slice(0, destination.lastIndexOf("/"));
357
+ // Never let sudo create through an attacker-controlled pre-existing path.
358
+ await safeRootAncestry(binaryDirectory, inspect);
359
+ await safeRootAncestry(serviceDirectory, inspect);
360
+ const tempDirectory = await makeTemp(join(tmpdir(), "botbuddy-daemon-"));
361
+ const snapshot = join(tempDirectory, "botbuddy-daemon");
362
+ const definitionFile = join(tempDirectory, platform === "darwin" ? "daemon.plist" : "daemon.service");
363
+ const binaryStage = join(binaryDirectory, `.botbuddy-daemon.${release.sha256}.${random()}`);
364
+ const definitionStage = join(serviceDirectory, `.${destination.slice(destination.lastIndexOf("/") + 1)}.${random()}`);
365
+ let binaryStaged = false;
366
+ let definitionStaged = false;
367
+ try {
368
+ // `payload` was read and hash-checked by validateDaemonRelease. Snapshot it
369
+ // before sudo so a source pathname cannot be replaced during installation.
370
+ await write(snapshot, release.payload, { mode: 0o500 });
371
+ await write(definitionFile, definition, { mode: 0o600 });
372
+ const [checksumCommandName, checksumArgs] = checksumCommand(platform);
373
+ let existing = false;
374
+ try { await exec("sudo", ["test", "-e", binary]); existing = true; }
375
+ catch (error) { if (error?.code !== 1) throw error; }
376
+ if (existing) {
377
+ const result = await exec("sudo", [checksumCommandName, ...checksumArgs, binary]);
378
+ if (!checksumMatches(result.stdout, release.sha256)) fail("daemon_install_existing_payload_mismatch");
379
+ }
380
+ const group = platform === "darwin" ? "wheel" : "root";
381
+ if (!existing) {
382
+ await exec("sudo", ["install", "-d", "-o", "root", "-g", group, "-m", "0755", binaryDirectory]);
383
+ await exec("sudo", ["install", "-o", "root", "-g", group, "-m", "0755", snapshot, binaryStage]);
384
+ binaryStaged = true;
385
+ const checksumResult = await exec("sudo", [checksumCommandName, ...checksumArgs, binaryStage]);
386
+ if (!checksumMatches(checksumResult.stdout, release.sha256)) fail("daemon_install_digest_invalid");
387
+ await exec("sudo", ["mv", "-n", binaryStage, binary]);
388
+ binaryStaged = false;
389
+ const finalResult = await exec("sudo", [checksumCommandName, ...checksumArgs, binary]);
390
+ if (!checksumMatches(finalResult.stdout, release.sha256)) fail("daemon_install_existing_payload_mismatch");
391
+ }
392
+ await exec("sudo", ["install", "-o", "root", "-g", group, "-m", "0644", definitionFile, definitionStage]);
393
+ definitionStaged = true;
394
+ await exec("sudo", ["mv", "-f", definitionStage, destination]);
395
+ definitionStaged = false;
396
+ if (platform === "darwin") {
397
+ try { await exec("sudo", ["launchctl", "bootout", "system/com.botbuddy.daemon"]); }
398
+ catch (error) { if (!expectedBootoutAbsent(error)) throw error; }
399
+ await exec("sudo", ["launchctl", "bootstrap", "system", destination]);
400
+ } else {
401
+ await exec("sudo", ["systemctl", "daemon-reload"]);
402
+ await exec("sudo", ["systemctl", "enable", "botbuddy-daemon.service"]);
403
+ await exec("sudo", ["systemctl", "restart", "botbuddy-daemon.service"]);
404
+ }
405
+ return { outcome: "installed", destination, binary, service_user: user };
406
+ } finally {
407
+ if (binaryStaged) await exec("sudo", ["rm", "-f", binaryStage]).catch(() => {});
408
+ if (definitionStaged) await exec("sudo", ["rm", "-f", definitionStage]).catch(() => {});
409
+ await remove(tempDirectory, { recursive: true, force: true });
410
+ }
411
+ }
412
+
413
+ export async function serviceStatus({ platform, host, exec = execFile } = {}) {
414
+ if (!destinations[platform]) fail("daemon_status_invalid");
415
+ if (host !== undefined) assertSshHost(host);
416
+ try {
417
+ const command = platform === "darwin" ? "launchctl" : "systemctl";
418
+ const args = platform === "darwin"
419
+ ? ["print", "system/com.botbuddy.daemon"]
420
+ : ["show", "--property=LoadState", "--property=ActiveState", "botbuddy-daemon.service"];
421
+ const result = host ? await exec("ssh", [...sshOptions, host, command, ...args]) : await exec(command, args);
422
+ const output = String(result?.stdout ?? "");
423
+ if (platform === "darwin") {
424
+ const state = /^\s*state\s*=\s*(.+?)\s*$/m.exec(output)?.[1];
425
+ if (!state) fail("daemon_status_unreadable");
426
+ return { outcome: "status", platform, loaded: true, active: state === "running" };
427
+ }
428
+ const load = /^LoadState=(.+)$/m.exec(output)?.[1];
429
+ const active = /^ActiveState=(.+)$/m.exec(output)?.[1];
430
+ if (!load || !active) fail("daemon_status_unreadable");
431
+ if (load === "not-found") return { outcome: "status", platform, loaded: false, active: false };
432
+ if (load !== "loaded") fail("daemon_status_unexpected_load_state");
433
+ return { outcome: "status", platform, loaded: true, active: active === "active" };
434
+ } catch (error) {
435
+ if (platform === "darwin" && /Could not find service/.test(String(error?.stderr ?? error?.message ?? ""))) return { outcome: "status", platform, loaded: false, active: false };
436
+ throw error;
437
+ }
438
+ }