Files
paseo/packages/desktop/scripts/managed-daemon-smoke.mjs
T
2026-03-11 23:13:36 +07:00

808 lines
25 KiB
JavaScript

import assert from "node:assert/strict";
import { Buffer } from "node:buffer";
import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import WebSocket from "ws";
import { createClientChannel } from "@getpaseo/relay/e2ee";
const execFileAsync = promisify(execFile);
const COMMAND_TIMEOUT_MS = 120_000;
const repoRoot = fileURLToPath(new URL("../../..", import.meta.url));
const desktopRoot = path.join(repoRoot, "packages", "desktop");
const relayRoot = path.join(repoRoot, "packages", "relay");
const desktopPackageJson = JSON.parse(
await fs.readFile(path.join(desktopRoot, "package.json"), "utf8")
);
const currentRuntimeVersion = desktopPackageJson.version;
const currentRuntimePointer = JSON.parse(
await fs.readFile(
path.join(desktopRoot, "src-tauri", "resources", "managed-runtime", "current-runtime.json"),
"utf8"
)
);
const currentRuntimeId = currentRuntimePointer.runtimeId;
function resolvePackagedBinary() {
const targetRoot = process.env.PASEO_MANAGED_SMOKE_RUST_TARGET
? path.join(
desktopRoot,
"src-tauri",
"target",
process.env.PASEO_MANAGED_SMOKE_RUST_TARGET,
"release"
)
: path.join(desktopRoot, "src-tauri", "target", "release");
if (process.platform === "darwin") {
return path.join(
targetRoot,
"bundle",
"macos",
"Paseo.app",
"Contents",
"MacOS",
"Paseo"
);
}
if (process.platform === "linux") {
return path.join(
targetRoot,
"bundle",
"appimage",
`Paseo_${desktopPackageJson.version}_amd64.AppImage`
);
}
if (process.platform === "win32") {
return path.join(
targetRoot,
"Paseo.exe"
);
}
throw new Error(`Managed desktop smoke is not implemented for ${process.platform} yet.`);
}
async function pathExists(target) {
try {
await fs.access(target);
return true;
} catch {
return false;
}
}
async function snapshotTree(root) {
if (!(await pathExists(root))) {
return [];
}
const entries = [];
async function walk(current) {
const stat = await fs.stat(current);
const relative = path.relative(root, current) || ".";
entries.push({
relative,
kind: stat.isDirectory() ? "dir" : "file",
size: stat.size,
mtimeMs: stat.mtimeMs,
});
if (!stat.isDirectory()) {
return;
}
const children = await fs.readdir(current);
children.sort();
for (const child of children) {
await walk(path.join(current, child));
}
}
await walk(root);
entries.sort((left, right) => left.relative.localeCompare(right.relative));
return entries;
}
async function sleep(ms) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
function escapeForRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function waitForChildExit(child, timeoutMs) {
if (!child || child.exitCode !== null || child.killed) {
return;
}
await new Promise((resolve) => {
const timeout = setTimeout(resolve, timeoutMs);
child.once("exit", () => {
clearTimeout(timeout);
resolve();
});
});
}
async function execFileWithTimeout(command, args, options, label) {
try {
return await execFileAsync(command, args, {
timeout: COMMAND_TIMEOUT_MS,
...options,
});
} catch (error) {
if (error?.killed && error?.signal === "SIGTERM") {
const renderedArgs = args.map((arg) => JSON.stringify(arg)).join(" ");
throw new Error(
`Timed out after ${COMMAND_TIMEOUT_MS}ms running ${label}: ${JSON.stringify(command)} ${renderedArgs}`
);
}
throw error;
}
}
async function runBinary(binaryPath, args, env) {
const { stdout, stderr } = await execFileWithTimeout(binaryPath, args, {
env,
cwd: repoRoot,
maxBuffer: 10 * 1024 * 1024,
}, "packaged desktop binary");
const trimmed = stdout.trim();
return {
stdout,
stderr,
json: trimmed ? JSON.parse(trimmed) : null,
};
}
async function terminateChildProcess(child, label) {
if (!child || child.exitCode !== null || child.killed) {
return;
}
if (process.platform === "win32" && typeof child.pid === "number") {
try {
await execFileWithTimeout(
"taskkill",
["/pid", String(child.pid), "/T", "/F"],
{ maxBuffer: 1024 * 1024 },
`${label} taskkill`
);
} catch {}
await waitForChildExit(child, 5_000);
return;
}
try {
child.kill("SIGTERM");
} catch {}
await waitForChildExit(child, 5_000);
if (child.exitCode === null && !child.killed) {
try {
child.kill("SIGKILL");
} catch {}
await waitForChildExit(child, 5_000);
}
}
async function runWorkspaceCli(args, env) {
const { stdout, stderr } = await execFileWithTimeout(
process.execPath,
[path.join(repoRoot, "packages", "cli", "dist", "index.js"), ...args],
{
cwd: repoRoot,
env: {
...process.env,
...env,
},
maxBuffer: 10 * 1024 * 1024,
},
"workspace CLI"
);
let json = null;
if (stdout.trim()) {
try {
json = JSON.parse(stdout.trim());
} catch {
json = null;
}
}
return { stdout, stderr, json };
}
async function runBundledRuntimeCli(runtimeRoot, managedHome, args, env) {
const manifest = JSON.parse(
await fs.readFile(path.join(runtimeRoot, "runtime-manifest.json"), "utf8")
);
const { stdout, stderr } = await execFileWithTimeout(
path.join(runtimeRoot, manifest.nodeRelativePath),
[path.join(runtimeRoot, manifest.cliEntrypointRelativePath), ...args],
{
cwd: repoRoot,
env: {
...process.env,
...env,
PASEO_HOME: managedHome,
},
maxBuffer: 10 * 1024 * 1024,
},
"bundled runtime CLI"
);
return { stdout, stderr };
}
async function resolvePackagedRuntimeStatus(binaryPath) {
const binaryDir = path.dirname(binaryPath);
const resourceDirs = [
path.join(binaryDir, "resources"),
binaryDir,
path.join(binaryDir, "..", "Resources"),
path.join(binaryDir, "..", "resources"),
];
let bundledRoot = null;
for (const resourceDir of resourceDirs) {
for (const candidate of [
path.join(resourceDir, "resources", "managed-runtime"),
path.join(resourceDir, "managed-runtime"),
]) {
if (await pathExists(candidate)) {
bundledRoot = candidate;
break;
}
}
if (bundledRoot) {
break;
}
}
if (!bundledRoot) {
throw new Error("Managed runtime resources are not bundled with this desktop build.");
}
const pointer = JSON.parse(
await fs.readFile(path.join(bundledRoot, "current-runtime.json"), "utf8")
);
const runtimeRoot = path.join(bundledRoot, pointer.relativeRoot);
const manifest = JSON.parse(
await fs.readFile(path.join(runtimeRoot, "runtime-manifest.json"), "utf8")
);
return {
runtimeId: manifest.runtimeId,
runtimeVersion: manifest.runtimeVersion,
runtimeRoot,
};
}
async function readDaemonStatus(home, env) {
const result = await runWorkspaceCli(["daemon", "status", "--home", home, "--json"], env);
return result.json ?? {};
}
async function pidListeningOnPort(port) {
try {
const { stdout } = await execFileAsync("lsof", ["-ti", `tcp:${port}`], {
maxBuffer: 1024 * 1024,
});
const pid = Number.parseInt(stdout.trim().split("\n")[0] ?? "", 10);
return Number.isFinite(pid) ? pid : null;
} catch {
return null;
}
}
async function getAvailablePort() {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate an ephemeral test port.")));
return;
}
const { port } = address;
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve(port);
});
});
});
}
function buildRelayWebSocketUrl({ endpoint, serverId, role }) {
const url = new URL(`ws://${endpoint}/ws`);
url.searchParams.set("serverId", serverId);
url.searchParams.set("role", role);
url.searchParams.set("v", "2");
return url.toString();
}
function parseOfferUrlFromCommandOutput(stdout) {
const match = stdout.match(/https?:\/\/\S+#offer=\S+/);
if (!match) {
throw new Error(`Failed to find pairing URL in output: ${stdout}`);
}
return match[0];
}
function decodeOfferFromFragmentUrl(url) {
const marker = "#offer=";
const index = url.indexOf(marker);
if (index === -1) {
throw new Error(`Pairing URL is missing ${marker}: ${url}`);
}
const encoded = url.slice(index + marker.length);
const raw = Buffer.from(encoded, "base64url").toString("utf8");
const offer = JSON.parse(raw);
if (offer?.v !== 2 || typeof offer?.serverId !== "string" || typeof offer?.daemonPublicKeyB64 !== "string") {
throw new Error(`Unexpected relay offer payload: ${raw}`);
}
return offer;
}
async function waitForRelayWebSocketReady(endpoint, timeoutMs) {
await waitFor(async () => {
const probeServerId = `probe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const url = buildRelayWebSocketUrl({
endpoint,
serverId: probeServerId,
role: "server",
});
const opened = await new Promise((resolve) => {
const ws = new WebSocket(url);
const timer = setTimeout(() => {
ws.terminate();
resolve(false);
}, 5_000);
ws.once("open", () => {
clearTimeout(timer);
ws.close(1000, "probe");
resolve(true);
});
ws.once("error", () => {
clearTimeout(timer);
resolve(false);
});
});
assert.equal(opened, true);
}, timeoutMs, "relay websocket endpoint to accept connections");
}
async function connectViaRelay(endpoint, offer) {
const stableClientId = `cid-managed-smoke-${Date.now().toString(36)}`;
const ws = new WebSocket(
buildRelayWebSocketUrl({
endpoint,
serverId: offer.serverId,
role: "client",
})
);
return await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
reject(new Error("Timed out waiting for managed relay pong."));
}, 20_000);
const transport = {
send: (data) => ws.send(data),
close: (code, reason) => ws.close(code, reason),
onmessage: null,
onclose: null,
onerror: null,
};
ws.on("message", (data) => {
transport.onmessage?.(typeof data === "string" ? data : data.toString());
});
ws.on("close", (code, reason) => {
transport.onclose?.(code, reason.toString());
});
ws.on("error", (error) => {
transport.onerror?.(error);
});
ws.on("open", async () => {
try {
let pingSent = false;
let channelRef = null;
const channel = await createClientChannel(transport, offer.daemonPublicKeyB64, {
onmessage: (data) => {
try {
const payload = typeof data === "string" ? JSON.parse(data) : data;
if (payload?.type === "welcome") {
if (!pingSent && channelRef) {
pingSent = true;
void channelRef.send(JSON.stringify({ type: "ping" }));
}
return;
}
if (payload?.type === "pong") {
clearTimeout(timeout);
resolve(payload);
ws.close();
}
} catch (error) {
clearTimeout(timeout);
reject(error);
}
},
onerror: (error) => {
clearTimeout(timeout);
reject(error);
},
});
channelRef = channel;
await channel.send(
JSON.stringify({
type: "hello",
clientId: stableClientId,
clientType: "cli",
protocolVersion: 1,
})
);
} catch (error) {
clearTimeout(timeout);
reject(error);
}
});
});
}
function assertNoForbiddenPathsOrPorts(value, forbidden) {
const text =
typeof value === "string" ? value : JSON.stringify(value, null, 2);
for (const candidate of forbidden) {
assert.ok(
!text.includes(candidate),
`Unexpected forbidden managed smoke reference: ${candidate}`
);
}
}
async function ensurePackagedArtifact(binaryPath) {
if (process.env.PASEO_MANAGED_SMOKE_SKIP_BUILD === "1") {
return;
}
const npmExecPath = process.env.npm_execpath;
if (!npmExecPath) {
throw new Error("npm_execpath is required to build the packaged desktop artifact during smoke tests.");
}
try {
await execFileWithTimeout(process.execPath, [npmExecPath, "run", "build"], {
cwd: desktopRoot,
env: process.env,
maxBuffer: 20 * 1024 * 1024,
}, "desktop smoke artifact build");
} catch (error) {
const combined = `${error.stdout ?? ""}\n${error.stderr ?? ""}`;
const signingBlocked =
combined.includes("TAURI_SIGNING_PRIVATE_KEY") &&
(await pathExists(binaryPath));
if (!signingBlocked) {
throw error;
}
console.warn(
"[managed-smoke] continuing after Tauri updater signing failure because the packaged app artifact was already produced"
);
}
}
async function waitFor(assertion, timeoutMs, label) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
return await assertion();
} catch {
await sleep(250);
}
}
throw new Error(`Timed out waiting for ${label}`);
}
function logStep(label) {
currentStepLabel = label;
currentStepStartedAt = Date.now();
console.log(`\n[managed-smoke] ${label}`);
}
const packagedBinary = resolvePackagedBinary();
await ensurePackagedArtifact(packagedBinary);
if (!(await pathExists(packagedBinary))) {
throw new Error(`Packaged desktop artifact not found: ${packagedBinary}`);
}
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-desktop-smoke-"));
const testRoot = path.join(tmpRoot, "managed-test-root");
const fakeHome = path.join(tmpRoot, "fake-home");
const fakePaseoHome = path.join(fakeHome, ".paseo");
const cliScratchHome = path.join(tmpRoot, "cli-scratch-home");
const externalHome = path.join(tmpRoot, "external-daemon-home");
const externalPort = await getAvailablePort();
const externalEndpoint = `127.0.0.1:${externalPort}`;
const relayPort = await getAvailablePort();
const relayEndpoint = `127.0.0.1:${relayPort}`;
const managedRuntimeDir = path.join(testRoot, "runtime");
await fs.mkdir(testRoot, { recursive: true });
await fs.mkdir(fakePaseoHome, { recursive: true });
await fs.mkdir(cliScratchHome, { recursive: true });
await fs.mkdir(externalHome, { recursive: true });
await fs.writeFile(path.join(fakePaseoHome, "sentinel.txt"), "do not touch\n", "utf8");
const fakePaseoSnapshotBefore = await snapshotTree(fakePaseoHome);
const smokeStartedAt = Date.now();
const smokeDeadlineMs = 15 * 60 * 1000;
let currentStepLabel = "initializing";
let currentStepStartedAt = smokeStartedAt;
const heartbeat = setInterval(() => {
const elapsedSeconds = Math.floor((Date.now() - smokeStartedAt) / 1000);
const currentStepSeconds = Math.floor((Date.now() - currentStepStartedAt) / 1000);
console.log(
`[managed-smoke] heartbeat elapsed=${elapsedSeconds}s currentStep=${JSON.stringify(currentStepLabel)} stepElapsed=${currentStepSeconds}s`
);
if (Date.now() - smokeStartedAt > smokeDeadlineMs) {
console.error(
`[managed-smoke] FAIL exceeded ${smokeDeadlineMs / 1000}s overall deadline during ${JSON.stringify(currentStepLabel)}`
);
process.exit(1);
}
}, 30_000);
const managedEnv = {
...process.env,
HOME: fakeHome,
PASEO_HOME: cliScratchHome,
PASEO_DESKTOP_TEST_ROOT: testRoot,
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0",
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_RELAY_ENABLED: "true",
PASEO_RELAY_ENDPOINT: relayEndpoint,
PASEO_RELAY_PUBLIC_ENDPOINT: relayEndpoint,
PASEO_APP_BASE_URL: "https://app.paseo.test",
PASEO_PRIMARY_LAN_IP: "192.168.1.12",
CI: "true",
};
let externalPid = null;
let startedTemporaryExternalDaemon = false;
let relayProcess = null;
let runtimeStatus = null;
let managedStart = null;
const forbiddenManagedReferences = ["127.0.0.1:6767", fakePaseoHome, managedRuntimeDir];
const npmExecPath = process.env.npm_execpath;
if (!npmExecPath) {
throw new Error("npm_execpath is required to launch wrangler during managed desktop smoke tests.");
}
try {
logStep(`Starting isolated local relay on ${relayEndpoint}`);
relayProcess = spawn(process.execPath, [
npmExecPath,
"exec",
"--",
"wrangler",
"dev",
"--local",
"--ip",
"127.0.0.1",
"--port",
String(relayPort),
"--live-reload=false",
"--show-interactive-dev-session=false",
], {
cwd: relayRoot,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
relayProcess.stdout?.on("data", (chunk) => {
process.stdout.write(`[managed-smoke relay] ${chunk}`);
});
relayProcess.stderr?.on("data", (chunk) => {
process.stderr.write(`[managed-smoke relay] ${chunk}`);
});
await waitFor(async () => {
await execFileAsync(process.execPath, ["-e", `require("node:net").connect(${relayPort}, "127.0.0.1").on("connect", function () { this.end(); process.exit(0); }).on("error", () => process.exit(1));`]);
}, 60_000, "relay HTTP endpoint to start");
await waitForRelayWebSocketReady(relayEndpoint, 60_000);
logStep(`Starting isolated external daemon on ${externalEndpoint}`);
startedTemporaryExternalDaemon = true;
await runWorkspaceCli(
["start", "--home", externalHome, "--listen", externalEndpoint, "--no-relay"],
managedEnv
);
const externalStatus = await waitFor(
async () => {
const status = await readDaemonStatus(externalHome, managedEnv);
assert.equal(status.status, "running");
assert.ok(typeof status.pid === "number" && status.pid > 0, "expected external daemon pid");
await runWorkspaceCli(["ls", "--host", externalEndpoint, "--json"], managedEnv);
return status;
},
15_000,
"external daemon to start"
);
externalPid = externalStatus.pid;
assert.ok(externalPid, "external daemon pid should be present");
logStep("Resolving bundled runtime from packaged desktop artifact");
runtimeStatus = await resolvePackagedRuntimeStatus(packagedBinary);
assert.equal(runtimeStatus.runtimeId, currentRuntimeId);
assert.equal(runtimeStatus.runtimeVersion, currentRuntimeVersion);
assert.match(
runtimeStatus.runtimeRoot,
new RegExp(escapeForRegExp(currentRuntimeId)),
"runtime status should point into the bundled runtime selected by current-runtime.json"
);
assert.equal(
runtimeStatus.runtimeRoot.startsWith(testRoot),
false,
"runtime status should not resolve into managed app data"
);
assertNoForbiddenPathsOrPorts(runtimeStatus, forbiddenManagedReferences);
assert.equal(await pathExists(managedRuntimeDir), false, "managed app data should not gain a runtime tree");
const managedBootstrap = await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
cliScratchHome,
["start", "--json"],
managedEnv
);
managedStart = (managedBootstrap.stdout.trim() ? JSON.parse(managedBootstrap.stdout.trim()) : null) ?? await waitFor(
async () => {
const status = await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
cliScratchHome,
["daemon", "status", "--json"],
managedEnv
);
const json = JSON.parse(status.stdout.trim());
assert.equal(json.status, "running");
assert.ok(json.pid, "managed daemon pid should exist");
assert.ok(json.serverId, "managed daemon should expose a server id");
return json;
},
10_000,
"managed daemon bootstrap status"
);
assert.equal(managedStart.status, "running");
assert.ok(managedStart.pid, "managed daemon pid should exist");
assert.ok(managedStart.serverId, "managed daemon should expose a server id");
assert.ok(managedStart.home, "managed daemon should expose its home directory");
assert.ok(managedStart.listen, "managed daemon should expose its listen target");
assertNoForbiddenPathsOrPorts(managedStart, forbiddenManagedReferences);
assert.equal(await pathExists(managedRuntimeDir), false, "starting the daemon should not install a runtime copy");
const managedPid = managedStart.pid;
logStep("Verifying the managed daemon stays alive after the packaged command exits");
await sleep(1_500);
const persistedManagedStatus = JSON.parse(
(
await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["daemon", "status", "--json"],
managedEnv
)
).stdout.trim()
);
assert.equal(persistedManagedStatus.pid, managedPid);
assert.equal(persistedManagedStatus.status, "running");
assert.equal(persistedManagedStatus.serverId, managedStart.serverId);
assertNoForbiddenPathsOrPorts(persistedManagedStatus, forbiddenManagedReferences);
logStep("Verifying bundled CLI access without installing a shim");
const cliVersion = await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["--version"],
managedEnv
);
assert.match(cliVersion.stdout.trim(), /^0\./);
const bundledCliStatus = await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["daemon", "status", "--json"],
managedEnv
);
const bundledCliDaemonStatus = JSON.parse(bundledCliStatus.stdout.trim());
assert.equal(bundledCliDaemonStatus.pid, managedPid);
assertNoForbiddenPathsOrPorts(bundledCliDaemonStatus, forbiddenManagedReferences);
logStep("Verifying relay connectivity still works after the desktop command has exited");
const relayPairing = await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["daemon", "pair"],
managedEnv
);
const relayOfferUrl = parseOfferUrlFromCommandOutput(relayPairing.stdout);
const relayOffer = decodeOfferFromFragmentUrl(relayOfferUrl);
assert.equal(relayOffer.relay?.endpoint, relayEndpoint);
const relayPong = await connectViaRelay(relayEndpoint, relayOffer);
assert.deepEqual(relayPong, { type: "pong" });
logStep("Re-running managed start without spawning duplicate daemons");
const managedRestartless = JSON.parse(
(
await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["start", "--json"],
managedEnv
)
).stdout.trim()
);
assert.equal(managedRestartless.pid, managedPid);
logStep("Verifying managed and external daemons coexist");
const externalStatusAfter = await readDaemonStatus(externalHome, managedEnv);
const externalPidAfter = externalStatusAfter.pid ?? null;
assert.equal(externalStatusAfter.status, "running");
assert.equal(externalPidAfter, externalPid);
await runWorkspaceCli(["ls", "--host", externalEndpoint, "--json"], managedEnv);
const managedStatus = JSON.parse(
(
await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["daemon", "status", "--json"],
managedEnv
)
).stdout.trim()
);
assert.ok(managedStatus.serverId, "managed daemon should expose a server id");
assertNoForbiddenPathsOrPorts(managedStatus, forbiddenManagedReferences);
logStep("Capturing diagnostics and verifying the fake ~/.paseo stayed untouched");
const fakePaseoSnapshotAfter = await snapshotTree(fakePaseoHome);
assert.deepEqual(fakePaseoSnapshotAfter, fakePaseoSnapshotBefore);
await fs.writeFile(
path.join(testRoot, "managed-daemon-smoke-diagnostics.json"),
JSON.stringify(
{
runtimeStatus,
managedBootstrap: managedBootstrap.stdout.trim() ? JSON.parse(managedBootstrap.stdout.trim()) : null,
managedStart,
persistedManagedStatus,
managedRestartless,
bundledCliDaemonStatus,
relayEndpoint,
relayOfferUrl,
relayPong,
externalEndpoint,
externalPid,
externalPidAfter,
managedStatus,
},
null,
2
) + "\n",
"utf8"
);
console.log(`\n[managed-smoke] PASS (${testRoot})`);
} finally {
clearInterval(heartbeat);
try {
if (runtimeStatus?.runtimeRoot) {
await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart?.home ?? cliScratchHome,
["daemon", "stop", "--json"],
managedEnv
);
}
} catch {}
try {
if (startedTemporaryExternalDaemon) {
await runWorkspaceCli(["daemon", "stop", "--home", externalHome, "--json"]);
}
} catch {}
try {
await terminateChildProcess(relayProcess, "local relay");
} catch {}
}