directionallylocal 0.2.0 → 0.4.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/dist/cli.js +49 -9
- package/dist/keys.js +188 -0
- package/dist/laptop/commands.js +166 -43
- package/dist/laptop/config.js +119 -23
- package/dist/laptop/credentials.js +129 -0
- package/dist/laptop/keys.js +133 -0
- package/dist/laptop/remote-install.js +280 -0
- package/dist/laptop/setup.js +301 -0
- package/dist/laptop/ssh.js +216 -0
- package/dist/laptop/tunnel.js +120 -0
- package/dist/local/check.js +194 -0
- package/dist/local/cli.js +43 -0
- package/dist/local/keys.js +100 -0
- package/dist/local/layout.js +227 -0
- package/dist/local/probes.js +22 -0
- package/dist/local/services.js +68 -0
- package/dist/local/setup.js +472 -0
- package/dist/local/state.js +15 -0
- package/dist/local/test-support/fake-computer.js +280 -0
- package/dist/up.js +6 -4
- package/install.sh +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether this computer can run the agents: platform, Docker (whichever runtime the user
|
|
3
|
+
* has), a patched Node, git, and disk. Read-only: every probe goes through `System`.
|
|
4
|
+
*/
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { agentCapacity, parseDfFreeGb, parseInterfaceIp, parseOsRelease } from "../detect.js";
|
|
7
|
+
import { nodeAdvisory } from "../node-advisory.js";
|
|
8
|
+
import { LOCAL_CELL_MEMORY_MB, LOCAL_MIN_DISK_GB } from "./layout.js";
|
|
9
|
+
const DOCKER_INFO = "{{.ServerVersion}}|{{.OperatingSystem}}|{{.MemTotal}}|{{.NCPU}}";
|
|
10
|
+
export async function checkComputer(system, options) {
|
|
11
|
+
const platform = options.platform ?? process.platform;
|
|
12
|
+
const env = options.env ?? process.env;
|
|
13
|
+
const home = options.home;
|
|
14
|
+
const [osName, df, git] = await Promise.all([
|
|
15
|
+
describeOs(system, platform),
|
|
16
|
+
system.probe("df", ["-Pk", home]),
|
|
17
|
+
system.probe("git", ["--version"]),
|
|
18
|
+
]);
|
|
19
|
+
const dockerCli = await findDockerCli(system, home);
|
|
20
|
+
const { docker, denied } = dockerCli ? await findDocker(system, dockerCli, home, platform, env) : { docker: undefined, denied: false };
|
|
21
|
+
const dockerApp = platform === "darwin" && !docker ? await installedDockerApp(system, home) : undefined;
|
|
22
|
+
const bridge = platform === "linux" ? await system.probe("ip", ["-4", "-o", "addr", "show", "docker0"]) : undefined;
|
|
23
|
+
const systemd = platform === "linux" ? await system.probe("systemctl", ["--user", "is-system-running"]) : undefined;
|
|
24
|
+
const execPath = options.execPath ?? process.execPath;
|
|
25
|
+
return {
|
|
26
|
+
platform,
|
|
27
|
+
osName,
|
|
28
|
+
arch: options.arch ?? process.arch,
|
|
29
|
+
node: { path: await stableNodePath(system, execPath), version: options.nodeVersion ?? process.versions.node },
|
|
30
|
+
git: git.code === 0 ? git.stdout.trim().replace(/^git version /, "") : undefined,
|
|
31
|
+
diskFreeGb: parseDfFreeGb(df.stdout),
|
|
32
|
+
dockerCli,
|
|
33
|
+
docker,
|
|
34
|
+
dockerApp,
|
|
35
|
+
bridgeIp: bridge ? parseInterfaceIp(bridge.stdout) : undefined,
|
|
36
|
+
// "degraded" (one failed unit somewhere) still runs ours fine.
|
|
37
|
+
systemdUser: systemd ? ["running", "degraded", "starting"].includes(systemd.stdout.trim()) : false,
|
|
38
|
+
dockerDenied: denied,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** What stops setup before it changes anything, each with how to fix it. */
|
|
42
|
+
export function computerProblems(facts) {
|
|
43
|
+
const problems = [];
|
|
44
|
+
if (facts.platform !== "darwin" && facts.platform !== "linux") {
|
|
45
|
+
return [`Running agents on this computer works on macOS and Linux, not ${facts.platform}. Set up a server instead: curl -fsSL https://directionallylocal.run | sh`];
|
|
46
|
+
}
|
|
47
|
+
if (facts.platform === "linux" && !facts.systemdUser) {
|
|
48
|
+
problems.push("A systemd user session is needed to keep the agents running (systemctl --user). Log in to a desktop session, or set up a server instead.");
|
|
49
|
+
}
|
|
50
|
+
const node = nodeAdvisory(facts.node.version);
|
|
51
|
+
if (!node.ok) {
|
|
52
|
+
problems.push(`${node.message} ${facts.platform === "darwin" ? "Update Node (brew upgrade node, or nodejs.org)" : "Update Node (nodejs.org or your package manager)"}, then run this again.`);
|
|
53
|
+
}
|
|
54
|
+
if (!facts.git) {
|
|
55
|
+
problems.push(facts.platform === "darwin" ? "git isn't installed. Install it with: xcode-select --install" : "git isn't installed. Install it with your package manager (e.g. sudo apt install git).");
|
|
56
|
+
}
|
|
57
|
+
if (!facts.docker)
|
|
58
|
+
problems.push(dockerFix(facts));
|
|
59
|
+
if (facts.diskFreeGb < LOCAL_MIN_DISK_GB) {
|
|
60
|
+
problems.push(`At least ${LOCAL_MIN_DISK_GB} GB of free disk is needed for the sandbox image and workspaces (found ${facts.diskFreeGb} GB).`);
|
|
61
|
+
}
|
|
62
|
+
return problems;
|
|
63
|
+
}
|
|
64
|
+
function dockerFix(facts) {
|
|
65
|
+
if (facts.platform === "linux") {
|
|
66
|
+
if (facts.dockerDenied)
|
|
67
|
+
return "Docker is installed but you can't use it. Add yourself to the docker group (sudo usermod -aG docker $USER), log out and back in, then run this again.";
|
|
68
|
+
if (facts.dockerCli)
|
|
69
|
+
return "Docker isn't running. Start it (sudo systemctl start docker, or open Docker Desktop), then run this again.";
|
|
70
|
+
return "Docker isn't installed. Install Docker Engine (https://docs.docker.com/engine/install/) or Docker Desktop, then run this again.";
|
|
71
|
+
}
|
|
72
|
+
if (facts.dockerApp)
|
|
73
|
+
return `${facts.dockerApp} is installed but didn't start. Open it, wait until it says it's running, then run this again.`;
|
|
74
|
+
if (facts.dockerCli)
|
|
75
|
+
return "Docker isn't running. Start Docker Desktop, OrbStack, or colima (colima start), then run this again.";
|
|
76
|
+
return "Docker isn't installed. Install Docker Desktop (https://www.docker.com/products/docker-desktop/), OrbStack (brew install orbstack), or colima (brew install colima docker && colima start), then run this again.";
|
|
77
|
+
}
|
|
78
|
+
/** How many agents fit at once, by what Docker has (the VM on macOS). */
|
|
79
|
+
export function localCapacity(docker) {
|
|
80
|
+
return agentCapacity({ memoryMb: docker.memoryMb, cpus: docker.cpus }, LOCAL_CELL_MEMORY_MB);
|
|
81
|
+
}
|
|
82
|
+
export function supportedPlatform(platform) {
|
|
83
|
+
return platform === "darwin" || platform === "linux" ? platform : undefined;
|
|
84
|
+
}
|
|
85
|
+
async function describeOs(system, platform) {
|
|
86
|
+
if (platform === "darwin") {
|
|
87
|
+
const version = (await system.probe("sw_vers", ["-productVersion"])).stdout.trim();
|
|
88
|
+
return version ? `macOS ${version}` : "macOS";
|
|
89
|
+
}
|
|
90
|
+
if (platform === "linux")
|
|
91
|
+
return parseOsRelease(await system.readFile("/etc/os-release") ?? "").prettyName || "Linux";
|
|
92
|
+
return platform;
|
|
93
|
+
}
|
|
94
|
+
async function findDockerCli(system, home) {
|
|
95
|
+
const onPath = (await system.probe("sh", ["-c", "command -v docker"])).stdout.trim();
|
|
96
|
+
if (onPath)
|
|
97
|
+
return onPath;
|
|
98
|
+
// Launched from a GUI or a fresh shell, the runtimes' own bin dirs may not be on PATH.
|
|
99
|
+
for (const candidate of [
|
|
100
|
+
"/usr/local/bin/docker",
|
|
101
|
+
"/opt/homebrew/bin/docker",
|
|
102
|
+
"/Applications/Docker.app/Contents/Resources/bin/docker",
|
|
103
|
+
join(home, ".orbstack", "bin", "docker"),
|
|
104
|
+
"/usr/bin/docker",
|
|
105
|
+
]) {
|
|
106
|
+
if (await system.exists(candidate))
|
|
107
|
+
return candidate;
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The Docker endpoint to use. Hotcell talks to Docker through DOCKER_HOST (not the CLI's
|
|
113
|
+
* context), and a LaunchAgent has none of the shell's environment, so this resolves the
|
|
114
|
+
* endpoint once and the services get it explicitly. A DOCKER_HOST the user set wins.
|
|
115
|
+
*/
|
|
116
|
+
async function findDocker(system, cli, home, platform, env) {
|
|
117
|
+
const candidates = [];
|
|
118
|
+
if (env.DOCKER_HOST?.trim()) {
|
|
119
|
+
candidates.push(env.DOCKER_HOST.trim());
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
const context = (await system.probe(cli, ["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"])).stdout.trim();
|
|
123
|
+
if (context)
|
|
124
|
+
candidates.push(context);
|
|
125
|
+
const sockets = platform === "darwin"
|
|
126
|
+
? [join(home, ".docker", "run", "docker.sock"), join(home, ".orbstack", "run", "docker.sock"), join(home, ".colima", "default", "docker.sock"), join(home, ".colima", "docker.sock"), "/var/run/docker.sock"]
|
|
127
|
+
: ["/var/run/docker.sock", ...(env.XDG_RUNTIME_DIR ? [join(env.XDG_RUNTIME_DIR, "docker.sock")] : [])];
|
|
128
|
+
for (const socket of sockets)
|
|
129
|
+
if (await system.exists(socket))
|
|
130
|
+
candidates.push(`unix://${socket}`);
|
|
131
|
+
}
|
|
132
|
+
let denied = false;
|
|
133
|
+
for (const host of [...new Set(candidates)]) {
|
|
134
|
+
const info = await system.probe(cli, ["info", "--format", DOCKER_INFO], { env: { DOCKER_HOST: host } });
|
|
135
|
+
if (/permission denied/i.test(info.stderr))
|
|
136
|
+
denied = true;
|
|
137
|
+
const [version, os, memTotal, ncpu] = info.stdout.trim().split("|");
|
|
138
|
+
if (info.code !== 0 || !version)
|
|
139
|
+
continue;
|
|
140
|
+
return {
|
|
141
|
+
docker: {
|
|
142
|
+
host,
|
|
143
|
+
runtime: runtimeName(host, os ?? ""),
|
|
144
|
+
version,
|
|
145
|
+
memoryMb: Math.round(Number(memTotal) / 1024 / 1024) || 0,
|
|
146
|
+
cpus: Number(ncpu) || 0,
|
|
147
|
+
},
|
|
148
|
+
denied: false,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return { docker: undefined, denied };
|
|
152
|
+
}
|
|
153
|
+
function runtimeName(host, os) {
|
|
154
|
+
if (host.includes("/.orbstack/") || /orbstack/i.test(os))
|
|
155
|
+
return "OrbStack";
|
|
156
|
+
if (host.includes("/.colima/"))
|
|
157
|
+
return "colima";
|
|
158
|
+
if (/docker desktop/i.test(os) || host.includes("/.docker/run/"))
|
|
159
|
+
return "Docker Desktop";
|
|
160
|
+
if (host.includes("/run/user/"))
|
|
161
|
+
return "Docker (rootless)";
|
|
162
|
+
return "Docker";
|
|
163
|
+
}
|
|
164
|
+
/** Docker Desktop or OrbStack, installed as an app; undefined when neither is. */
|
|
165
|
+
export async function installedDockerApp(system, home) {
|
|
166
|
+
for (const [name, path] of [
|
|
167
|
+
["Docker Desktop", "/Applications/Docker.app"],
|
|
168
|
+
["OrbStack", "/Applications/OrbStack.app"],
|
|
169
|
+
["Docker Desktop", join(home, "Applications", "Docker.app")],
|
|
170
|
+
["OrbStack", join(home, "Applications", "OrbStack.app")],
|
|
171
|
+
]) {
|
|
172
|
+
if (await system.exists(path))
|
|
173
|
+
return name;
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* A Node path that survives upgrades: Homebrew's versioned Cellar path disappears on the
|
|
179
|
+
* next `brew upgrade`, but its opt/ link follows the formula, security fixes included.
|
|
180
|
+
*/
|
|
181
|
+
export async function stableNodePath(system, execPath) {
|
|
182
|
+
const match = execPath.match(/^(.*)\/Cellar\/(node(?:@\d+)?)\/[^/]+\/bin\/node$/);
|
|
183
|
+
if (match) {
|
|
184
|
+
const opt = `${match[1]}/opt/${match[2]}/bin/node`;
|
|
185
|
+
if (await system.exists(opt))
|
|
186
|
+
return opt;
|
|
187
|
+
}
|
|
188
|
+
return execPath;
|
|
189
|
+
}
|
|
190
|
+
/** PATH for the services: Node, the docker CLI, git, and the system's basics. */
|
|
191
|
+
export function servicePath(facts) {
|
|
192
|
+
const dirs = [dirname(facts.node.path), ...(facts.dockerCli ? [dirname(facts.dockerCli)] : []), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"];
|
|
193
|
+
return [...new Set(dirs)].join(":");
|
|
194
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** `dl local status` and `dl local uninstall [--purge]`: agents running on this computer. */
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { localStatus, uninstallFromThisComputer } from "./setup.js";
|
|
4
|
+
export const LOCAL_HELP = ` dl local status agents on this computer: running?, where, how many fit
|
|
5
|
+
dl local uninstall [--purge] stop and remove them (--purge also deletes keys and data)
|
|
6
|
+
`;
|
|
7
|
+
export async function localCommand(args, flags, io, colors, ui, options = {}) {
|
|
8
|
+
const [sub = "status"] = args;
|
|
9
|
+
if (sub === "status") {
|
|
10
|
+
io.out(formatLocalStatus(await localStatus(options), colors, options.home ?? homedir()));
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
13
|
+
if (sub === "uninstall") {
|
|
14
|
+
await uninstallFromThisComputer({ ui, purge: Boolean(flags.purge), ...options });
|
|
15
|
+
ui.finish();
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
io.err(` Unknown: dl local ${sub}\n\n${LOCAL_HELP}`);
|
|
19
|
+
return 2;
|
|
20
|
+
}
|
|
21
|
+
export function formatLocalStatus(status, c, home) {
|
|
22
|
+
const short = (path) => (path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path);
|
|
23
|
+
if (!status.installed || !status.state) {
|
|
24
|
+
const leftovers = status.services.hotcell.installed || status.services.autopilot.installed;
|
|
25
|
+
return `\n ${c.bold("Directionally Local")} ${c.dim("isn't set up on this computer.")}\n${leftovers ? `\n ${c.yellow("!")} Its services are still registered; remove them with: dl local uninstall\n` : ""}\n`;
|
|
26
|
+
}
|
|
27
|
+
const { state, services } = status;
|
|
28
|
+
const mark = (ok) => (ok ? c.green("✓") : c.red("✗"));
|
|
29
|
+
const line = (ok, label, detail) => ` ${mark(ok)} ${label.padEnd(12)}${c.dim(detail)}\n`;
|
|
30
|
+
const describe = (entry) => entry.running ? `running${entry.pid ? ` (pid ${entry.pid})` : ""}` : entry.installed ? "stopped" : "not installed";
|
|
31
|
+
const healthy = services.hotcell.running && services.autopilot.running && status.answering && status.dockerRunning;
|
|
32
|
+
return [
|
|
33
|
+
`\n ${c.bold("Directionally Local")} ${c.dim("on this computer")} ${healthy ? c.green("running") : c.red("needs attention")}\n\n`,
|
|
34
|
+
line(status.dockerRunning, "Docker", status.dockerRunning ? state.dockerRuntime : `${state.dockerRuntime} isn't running; start it`),
|
|
35
|
+
line(services.hotcell.running, "Hotcell", `${describe(services.hotcell)} · 127.0.0.1:${state.ports.hotcell} · ${state.hotcellVersion}`),
|
|
36
|
+
line(services.autopilot.running && status.answering, "Autopilot", `${describe(services.autopilot)}${status.answering ? "" : ", not answering"} · ${state.url} · ${state.autopilotVersion}`),
|
|
37
|
+
`\n ${c.dim("Fits")} ${status.capacity !== undefined ? `~${status.capacity} agents at once (estimate, from Docker's memory)` : "unknown while Docker is stopped"}\n`,
|
|
38
|
+
` ${c.dim("Installed")} ${short(status.paths.current)}\n`,
|
|
39
|
+
` ${c.dim("Logs")} ${short(status.paths.logs)}\n`,
|
|
40
|
+
` ${c.dim("Starts")} at login (${status.platform === "darwin" ? "LaunchAgents" : "systemd --user"}); tasks pause while this computer sleeps\n`,
|
|
41
|
+
healthy ? "\n" : `\n ${c.dim("Fix it by running setup again; it repairs and keeps your keys.")}\n\n`,
|
|
42
|
+
].join("");
|
|
43
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dl keys` for "on this computer": the same as on a server, in the user's home. The
|
|
3
|
+
* OpenRouter key goes to Hotcell's 0600 file keystore over stdin (HOTCELL_KEYSTORE=file,
|
|
4
|
+
* see setup.ts for why not the Keychain), the GitHub token to the 0600 github.env, and
|
|
5
|
+
* the service that reads it (a LaunchAgent or a systemd user unit) restarts.
|
|
6
|
+
*/
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { checkKey, githubListing, hotcellKeyListed, KeyRejectedError } from "../keys.js";
|
|
10
|
+
import { RealSystem } from "../system.js";
|
|
11
|
+
import { supportedPlatform } from "./check.js";
|
|
12
|
+
import { LOCAL_SERVICES, localGithubEnv, localHotcellBin, localPaths, parseLocalEnvFile, serviceFile } from "./layout.js";
|
|
13
|
+
import { httpStatus } from "./probes.js";
|
|
14
|
+
import { userId } from "./services.js";
|
|
15
|
+
import { hotcellCliEnv } from "./setup.js";
|
|
16
|
+
import { readLocalState } from "./state.js";
|
|
17
|
+
async function installed(options) {
|
|
18
|
+
const home = options.home ?? homedir();
|
|
19
|
+
const platform = supportedPlatform(options.platform ?? process.platform);
|
|
20
|
+
if (!platform)
|
|
21
|
+
throw new Error("Agents run on this computer only on macOS and Linux.");
|
|
22
|
+
const system = options.system ?? new RealSystem(() => { });
|
|
23
|
+
const paths = localPaths(home, platform);
|
|
24
|
+
const state = await readLocalState(system, paths);
|
|
25
|
+
if (!state)
|
|
26
|
+
throw new Error("Directionally Local isn't set up on this computer; run dl setup.");
|
|
27
|
+
return {
|
|
28
|
+
system,
|
|
29
|
+
home,
|
|
30
|
+
platform,
|
|
31
|
+
paths,
|
|
32
|
+
state,
|
|
33
|
+
node: options.execPath ?? process.execPath,
|
|
34
|
+
sleep: options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Stores the key where setup does, restarts the service that reads it, and waits for it to answer. */
|
|
38
|
+
export async function setLocalKey(which, value, options = {}) {
|
|
39
|
+
const key = value.trim();
|
|
40
|
+
const checked = await checkKey(which, key, options.fetchImpl);
|
|
41
|
+
if (checked.problem)
|
|
42
|
+
throw new KeyRejectedError(checked.problem);
|
|
43
|
+
const ctx = await installed(options);
|
|
44
|
+
const { system, paths, state, node } = ctx;
|
|
45
|
+
const uid = await userId(system);
|
|
46
|
+
if (which === "openrouter") {
|
|
47
|
+
await system.run(node, [localHotcellBin(paths), "keys", "add", "openrouter", "--stdin"], {
|
|
48
|
+
input: key,
|
|
49
|
+
env: hotcellCliEnv(paths),
|
|
50
|
+
describe: "hotcell keys add openrouter --stdin",
|
|
51
|
+
});
|
|
52
|
+
await restartService(ctx, uid, "hotcell");
|
|
53
|
+
await waitUntil(ctx, "Hotcell", async () => await httpStatus(system, node, `http://127.0.0.1:${state.ports.hotcell}/`) > 0);
|
|
54
|
+
return { ok: true, key: which, detail: "replaced in Hotcell; Hotcell restarted" };
|
|
55
|
+
}
|
|
56
|
+
await system.writeFile(paths.githubEnv, localGithubEnv(key), { mode: 0o600, secret: true });
|
|
57
|
+
await restartService(ctx, uid, "autopilot");
|
|
58
|
+
// Without a token the Aster API answers 401: up, and refusing strangers.
|
|
59
|
+
await waitUntil(ctx, "Autopilot", async () => await httpStatus(system, node, `${state.url}/api/v1/health`) === 401);
|
|
60
|
+
return { ok: true, key: which, detail: `${checked.detail ? `${checked.detail}; ` : ""}Autopilot restarted` };
|
|
61
|
+
}
|
|
62
|
+
/** Which keys this computer's install has; never their values. */
|
|
63
|
+
export async function listLocalKeys(options = {}) {
|
|
64
|
+
const { system, paths, node } = await installed(options);
|
|
65
|
+
let openrouter = { key: "openrouter", set: false };
|
|
66
|
+
if (await system.exists(localHotcellBin(paths))) {
|
|
67
|
+
const listed = await system.probe(node, [localHotcellBin(paths), "keys", "ls"], { env: { ...hotcellCliEnv(paths), NO_COLOR: "1" } });
|
|
68
|
+
if (listed.code === 0)
|
|
69
|
+
openrouter = { key: "openrouter", ...hotcellKeyListed(listed.stdout, "openrouter") };
|
|
70
|
+
}
|
|
71
|
+
const token = parseLocalEnvFile(await system.readFile(paths.githubEnv)).GITHUB_TOKEN;
|
|
72
|
+
return [openrouter, await githubListing(token, options.fetchImpl)];
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Restarts one service so it reads its keys again: `launchctl kickstart -k` on macOS (or
|
|
76
|
+
* bootstrap, when it isn't loaded), `systemctl --user restart` on Linux.
|
|
77
|
+
*/
|
|
78
|
+
async function restartService(ctx, uid, service) {
|
|
79
|
+
const { system, platform, paths } = ctx;
|
|
80
|
+
if (platform === "darwin") {
|
|
81
|
+
const restarted = await system.run("launchctl", ["kickstart", "-k", `gui/${uid}/${LOCAL_SERVICES[service].label}`], { allowFailure: true });
|
|
82
|
+
if (restarted.code === 0)
|
|
83
|
+
return;
|
|
84
|
+
const file = serviceFile(paths, platform, service);
|
|
85
|
+
if (!(await system.exists(file)))
|
|
86
|
+
throw new Error(`The ${service} service isn't installed on this computer; run dl setup to repair it.`);
|
|
87
|
+
await system.run("launchctl", ["bootstrap", `gui/${uid}`, file]);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
await system.run("systemctl", ["--user", "restart", LOCAL_SERVICES[service].unit]);
|
|
91
|
+
}
|
|
92
|
+
async function waitUntil(ctx, what, ready) {
|
|
93
|
+
for (let attempt = 0; attempt < 60; attempt++) {
|
|
94
|
+
if (await ready())
|
|
95
|
+
return;
|
|
96
|
+
await ctx.sleep(1_000);
|
|
97
|
+
}
|
|
98
|
+
const log = join(ctx.paths.logs, LOCAL_SERVICES[what === "Hotcell" ? "hotcell" : "autopilot"].log);
|
|
99
|
+
throw new Error(`${what} didn't come back up; see ${log.startsWith(`${ctx.home}/`) ? `~${log.slice(ctx.home.length)}` : log}`);
|
|
100
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "On this computer": where everything lives in the user's home, and the files written
|
|
3
|
+
* there. Nothing here needs root. Secrets sit in 0600 env files that `node --env-file`
|
|
4
|
+
* reads, so they never appear in a launchd plist, a systemd unit, or any command line.
|
|
5
|
+
*/
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
export function localPaths(home, platform) {
|
|
8
|
+
const root = join(home, ".directionallylocal");
|
|
9
|
+
const config = join(root, "config");
|
|
10
|
+
const autopilotHome = join(root, "autopilot");
|
|
11
|
+
return {
|
|
12
|
+
home,
|
|
13
|
+
root,
|
|
14
|
+
releases: join(root, "releases"),
|
|
15
|
+
current: join(root, "current"),
|
|
16
|
+
logs: join(root, "logs"),
|
|
17
|
+
state: join(root, "state.json"),
|
|
18
|
+
config,
|
|
19
|
+
hotcellEnv: join(config, "hotcell.env"),
|
|
20
|
+
autopilotEnv: join(config, "autopilot.env"),
|
|
21
|
+
githubEnv: join(config, "github.env"),
|
|
22
|
+
asterConfig: join(config, "aster.json"),
|
|
23
|
+
hotcellHome: join(root, "hotcell"),
|
|
24
|
+
autopilotHome,
|
|
25
|
+
workspace: join(autopilotHome, "workspaces", "default"),
|
|
26
|
+
services: platform === "darwin" ? join(home, "Library", "LaunchAgents") : join(home, ".config", "systemd", "user"),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** launchd labels and systemd user units, prefixed so they never clash with a user's own Hotcell. */
|
|
30
|
+
export const LOCAL_SERVICES = {
|
|
31
|
+
hotcell: { label: "com.directionallylocal.hotcell", unit: "directionallylocal-hotcell.service", log: "hotcell.log" },
|
|
32
|
+
autopilot: { label: "com.directionallylocal.autopilot", unit: "directionallylocal-autopilot.service", log: "autopilot.log" },
|
|
33
|
+
};
|
|
34
|
+
export function serviceFile(paths, platform, service) {
|
|
35
|
+
const entry = LOCAL_SERVICES[service];
|
|
36
|
+
return join(paths.services, platform === "darwin" ? `${entry.label}.plist` : entry.unit);
|
|
37
|
+
}
|
|
38
|
+
/** Preferred ports; each moves up to the next free one when something else has it. */
|
|
39
|
+
export const PREFERRED_PORTS = {
|
|
40
|
+
autopilot: 4096,
|
|
41
|
+
hotcell: 4750,
|
|
42
|
+
hotcellProxy: 4751,
|
|
43
|
+
hotcellEgress: 4752,
|
|
44
|
+
githubProxy: 4760,
|
|
45
|
+
};
|
|
46
|
+
export const LOCAL_CELL_MEMORY_MB = 1024;
|
|
47
|
+
export const LOCAL_MIN_DISK_GB = 10;
|
|
48
|
+
export const PAIRED_NAME = "this-computer";
|
|
49
|
+
export function localHotcellBin(paths) {
|
|
50
|
+
return join(paths.current, "node_modules", "hotcell", "dist", "index.js");
|
|
51
|
+
}
|
|
52
|
+
export function localAutopilotBin(paths) {
|
|
53
|
+
return join(paths.current, "node_modules", "autopilot-ai", "bin", "autopilot.mjs");
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* KEY=value lines for `node --env-file`. Values with spaces or `#` (a home like
|
|
57
|
+
* "/Users/Jane Doe") are double-quoted, which Node's parser understands.
|
|
58
|
+
*/
|
|
59
|
+
export function localEnvFile(header, values) {
|
|
60
|
+
const lines = Object.entries(values).map(([key, value]) => {
|
|
61
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(key) || /[\n\r"]/.test(value))
|
|
62
|
+
throw new Error(`invalid env entry ${key}`);
|
|
63
|
+
return `${key}=${/[\s#'`$\\]/.test(value) ? `"${value}"` : value}`;
|
|
64
|
+
});
|
|
65
|
+
return `# ${header}\n# Written by directionallylocal; re-running setup keeps your values.\n${lines.join("\n")}\n`;
|
|
66
|
+
}
|
|
67
|
+
export function parseLocalEnvFile(text) {
|
|
68
|
+
const values = {};
|
|
69
|
+
for (const line of (text ?? "").split("\n")) {
|
|
70
|
+
const match = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
|
|
71
|
+
if (match)
|
|
72
|
+
values[match[1]] = match[2].replace(/^"(.*)"$/, "$1");
|
|
73
|
+
}
|
|
74
|
+
return values;
|
|
75
|
+
}
|
|
76
|
+
export function localHotcellEnv(input) {
|
|
77
|
+
const { paths, ports } = input;
|
|
78
|
+
return localEnvFile("Hotcell daemon (sandboxes + credential gateway), on this computer", {
|
|
79
|
+
HOTCELL_HOME: paths.hotcellHome,
|
|
80
|
+
// A 0600 file, not the login keychain: see setup.ts for why.
|
|
81
|
+
HOTCELL_KEYSTORE: "file",
|
|
82
|
+
HOTCELL_DB: join(paths.hotcellHome, "state.db"),
|
|
83
|
+
HOTCELL_BACKUP_DIR: join(paths.hotcellHome, "backups"),
|
|
84
|
+
HOTCELL_HOST: "127.0.0.1",
|
|
85
|
+
HOTCELL_PORT: String(ports.hotcell),
|
|
86
|
+
HOTCELL_PROXY_PORT: String(ports.hotcellProxy),
|
|
87
|
+
HOTCELL_API_KEY: input.apiKey,
|
|
88
|
+
HOTCELL_DRIVER: "container",
|
|
89
|
+
HOTCELL_EGRESS_HOST: input.net.listenHost,
|
|
90
|
+
HOTCELL_EGRESS_PORT: String(ports.hotcellEgress),
|
|
91
|
+
HOTCELL_EGRESS_ADVERTISE_HOST: "host.docker.internal",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
export function localAutopilotEnv(input) {
|
|
95
|
+
const { paths, ports } = input;
|
|
96
|
+
return localEnvFile("Autopilot server (Aster API), on this computer", {
|
|
97
|
+
AUTOPILOT_ASTER_CONFIG: paths.asterConfig,
|
|
98
|
+
AUTOPILOT_ASTER_DB: join(paths.autopilotHome, "aster.db"),
|
|
99
|
+
AUTOPILOT_RUNS_DB: join(paths.autopilotHome, "runs.db"),
|
|
100
|
+
AUTOPILOT_PUBLIC_URL: `http://127.0.0.1:${ports.autopilot}`,
|
|
101
|
+
// The model key lives only in Hotcell; no model calls from this computer itself.
|
|
102
|
+
AUTOPILOT_ASTER_MEMORY: "off",
|
|
103
|
+
AUTOPILOT_HOTCELL_ENDPOINT: `http://127.0.0.1:${ports.hotcell}`,
|
|
104
|
+
AUTOPILOT_HOTCELL_API_KEY: input.hotcellApiKey,
|
|
105
|
+
AUTOPILOT_HOTCELL_DRIVER: "container",
|
|
106
|
+
AUTOPILOT_HOTCELL_IMAGE: input.image,
|
|
107
|
+
AUTOPILOT_HOTCELL_NETWORKED: "true",
|
|
108
|
+
AUTOPILOT_HOTCELL_ANY_MODEL: "true",
|
|
109
|
+
AUTOPILOT_HOTCELL_SLEEP_AFTER_MS: "900000",
|
|
110
|
+
AUTOPILOT_HOTCELL_MEMORY_MB: String(LOCAL_CELL_MEMORY_MB),
|
|
111
|
+
AUTOPILOT_HOTCELL_CPUS: "1",
|
|
112
|
+
AUTOPILOT_HOTCELL_PIDS: "512",
|
|
113
|
+
AUTOPILOT_HOTCELL_SPEND_CAP_USD: "10",
|
|
114
|
+
AUTOPILOT_HOTCELL_TOKEN_TTL_MS: String(30 * 24 * 60 * 60 * 1000),
|
|
115
|
+
// Cells clone and push through Autopilot's repository-scoped proxy; the token stays here.
|
|
116
|
+
AUTOPILOT_ASTER_GITHUB_PROXY_LISTEN: `${input.net.listenHost}:${ports.githubProxy}`,
|
|
117
|
+
AUTOPILOT_ASTER_GITHUB_PROXY_URL: `http://host.docker.internal:${ports.githubProxy}`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
export function localGithubEnv(token) {
|
|
121
|
+
return localEnvFile("GitHub credential for cloning, pushing, and opening PRs (never sent to sandboxes)", { GITHUB_TOKEN: token });
|
|
122
|
+
}
|
|
123
|
+
export function localAsterConfig(paths) {
|
|
124
|
+
return `${JSON.stringify({
|
|
125
|
+
models: "openrouter",
|
|
126
|
+
workspaces: [{ id: "default", displayName: "Default workspace", rootPath: paths.workspace }],
|
|
127
|
+
}, null, 2)}\n`;
|
|
128
|
+
}
|
|
129
|
+
export function serviceSpecs(input) {
|
|
130
|
+
const { paths } = input;
|
|
131
|
+
const docker = input.dockerHost ? { DOCKER_HOST: input.dockerHost } : {};
|
|
132
|
+
return {
|
|
133
|
+
hotcell: {
|
|
134
|
+
service: "hotcell",
|
|
135
|
+
description: "Hotcell sandbox daemon (Directionally Local)",
|
|
136
|
+
node: input.node,
|
|
137
|
+
envFiles: [paths.hotcellEnv],
|
|
138
|
+
args: [localHotcellBin(paths), "start", "--foreground", "--defaults"],
|
|
139
|
+
// Its own HOME, so nothing falls back to a user's own ~/.hotcell.
|
|
140
|
+
env: { HOME: paths.hotcellHome, PATH: input.path, ...docker },
|
|
141
|
+
workingDirectory: paths.hotcellHome,
|
|
142
|
+
log: join(paths.logs, LOCAL_SERVICES.hotcell.log),
|
|
143
|
+
},
|
|
144
|
+
autopilot: {
|
|
145
|
+
service: "autopilot",
|
|
146
|
+
description: "Autopilot server (Directionally Local)",
|
|
147
|
+
node: input.node,
|
|
148
|
+
envFiles: [paths.autopilotEnv, paths.githubEnv],
|
|
149
|
+
args: [localAutopilotBin(paths), "serve", "--hostname", "127.0.0.1", "--port", String(input.ports.autopilot)],
|
|
150
|
+
// Its own HOME and XDG dirs: a user's own Autopilot config and keys are never picked up.
|
|
151
|
+
env: { HOME: paths.autopilotHome, PATH: input.path, ...autopilotXdg(paths), ...docker },
|
|
152
|
+
workingDirectory: paths.autopilotHome,
|
|
153
|
+
log: join(paths.logs, LOCAL_SERVICES.autopilot.log),
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
export function autopilotXdg(paths) {
|
|
158
|
+
return {
|
|
159
|
+
XDG_CONFIG_HOME: join(paths.autopilotHome, ".config"),
|
|
160
|
+
XDG_STATE_HOME: join(paths.autopilotHome, ".local", "state"),
|
|
161
|
+
XDG_DATA_HOME: join(paths.autopilotHome, ".local", "share"),
|
|
162
|
+
XDG_CACHE_HOME: join(paths.autopilotHome, ".cache"),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function xml(text) {
|
|
166
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
167
|
+
}
|
|
168
|
+
/** A LaunchAgent: starts at login, restarted if it exits, logs under ~/.directionallylocal/logs. */
|
|
169
|
+
export function launchAgentPlist(spec) {
|
|
170
|
+
const program = [spec.node, ...spec.envFiles.map((file) => `--env-file=${file}`), ...spec.args];
|
|
171
|
+
const env = Object.entries(spec.env).map(([key, value]) => ` <key>${xml(key)}</key>\n <string>${xml(value)}</string>`).join("\n");
|
|
172
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
173
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
174
|
+
<!-- ${xml(spec.description)}. Written by directionallylocal; remove with: dl local uninstall -->
|
|
175
|
+
<plist version="1.0">
|
|
176
|
+
<dict>
|
|
177
|
+
<key>Label</key>
|
|
178
|
+
<string>${LOCAL_SERVICES[spec.service].label}</string>
|
|
179
|
+
<key>ProgramArguments</key>
|
|
180
|
+
<array>
|
|
181
|
+
${program.map((arg) => ` <string>${xml(arg)}</string>`).join("\n")}
|
|
182
|
+
</array>
|
|
183
|
+
<key>EnvironmentVariables</key>
|
|
184
|
+
<dict>
|
|
185
|
+
${env}
|
|
186
|
+
</dict>
|
|
187
|
+
<key>WorkingDirectory</key>
|
|
188
|
+
<string>${xml(spec.workingDirectory)}</string>
|
|
189
|
+
<key>RunAtLoad</key>
|
|
190
|
+
<true/>
|
|
191
|
+
<key>KeepAlive</key>
|
|
192
|
+
<true/>
|
|
193
|
+
<key>ThrottleInterval</key>
|
|
194
|
+
<integer>10</integer>
|
|
195
|
+
<key>ProcessType</key>
|
|
196
|
+
<string>Standard</string>
|
|
197
|
+
<key>StandardOutPath</key>
|
|
198
|
+
<string>${xml(spec.log)}</string>
|
|
199
|
+
<key>StandardErrorPath</key>
|
|
200
|
+
<string>${xml(spec.log)}</string>
|
|
201
|
+
</dict>
|
|
202
|
+
</plist>
|
|
203
|
+
`;
|
|
204
|
+
}
|
|
205
|
+
function systemdQuote(value) {
|
|
206
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;
|
|
207
|
+
}
|
|
208
|
+
/** A systemd user unit: starts with the user's session (default.target), restarted on exit. */
|
|
209
|
+
export function systemdUserUnit(spec, after) {
|
|
210
|
+
const program = [spec.node, ...spec.envFiles.map((file) => `--env-file=${file}`), ...spec.args];
|
|
211
|
+
return `[Unit]
|
|
212
|
+
Description=${spec.description}
|
|
213
|
+
${after ? `After=${after}\nWants=${after}\n` : ""}
|
|
214
|
+
[Service]
|
|
215
|
+
Type=simple
|
|
216
|
+
${Object.entries(spec.env).map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`).join("\n")}
|
|
217
|
+
WorkingDirectory=${spec.workingDirectory}
|
|
218
|
+
ExecStart=${program.map(systemdQuote).join(" ")}
|
|
219
|
+
Restart=always
|
|
220
|
+
RestartSec=5
|
|
221
|
+
StandardOutput=append:${spec.log}
|
|
222
|
+
StandardError=append:${spec.log}
|
|
223
|
+
|
|
224
|
+
[Install]
|
|
225
|
+
WantedBy=default.target
|
|
226
|
+
`;
|
|
227
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exit 0 when HOST:PORT is free. Connecting first catches a listener on 0.0.0.0, which
|
|
3
|
+
* macOS would still let us bind 127.0.0.1 next to.
|
|
4
|
+
*/
|
|
5
|
+
export const PORT_FREE_SCRIPT = [
|
|
6
|
+
"const net=require('net');const [h,p]=[process.argv[1],Number(process.argv[2])];",
|
|
7
|
+
"const c=net.connect(p,h);c.once('connect',()=>{c.destroy();process.exit(1)});",
|
|
8
|
+
"c.once('error',()=>{const s=net.createServer();s.once('error',()=>process.exit(1));s.listen(p,h,()=>s.close(()=>process.exit(0)))});",
|
|
9
|
+
].join("");
|
|
10
|
+
/** Prints the HTTP status of GET URL (0 if nothing answers); a bearer token, if any, comes on stdin. */
|
|
11
|
+
export const HTTP_STATUS_SCRIPT = [
|
|
12
|
+
"let t='';process.stdin.on('data',d=>t+=d).on('end',async()=>{",
|
|
13
|
+
"try{const r=await fetch(process.argv[1],{headers:t.trim()?{authorization:'Bearer '+t.trim()}:{},signal:AbortSignal.timeout(5000)});",
|
|
14
|
+
"console.log(r.status)}catch{console.log(0)}});",
|
|
15
|
+
].join("");
|
|
16
|
+
export async function portFree(system, node, host, port) {
|
|
17
|
+
return (await system.probe(node, ["-e", PORT_FREE_SCRIPT, host, String(port)])).code === 0;
|
|
18
|
+
}
|
|
19
|
+
export async function httpStatus(system, node, url, token = "") {
|
|
20
|
+
const result = await system.probe(node, ["-e", HTTP_STATUS_SCRIPT, url], { input: token });
|
|
21
|
+
return Number(result.stdout.trim()) || 0;
|
|
22
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { LOCAL_SERVICES, launchAgentPlist, serviceFile, systemdUserUnit, } from "./layout.js";
|
|
2
|
+
const ORDER = ["hotcell", "autopilot"];
|
|
3
|
+
export async function userId(system) {
|
|
4
|
+
const uid = (await system.probe("id", ["-u"])).stdout.trim();
|
|
5
|
+
if (!/^\d+$/.test(uid))
|
|
6
|
+
throw new Error("Couldn't tell which user this is (id -u).");
|
|
7
|
+
if (uid === "0")
|
|
8
|
+
throw new Error("Run this as yourself, not as root or with sudo: the agents run as your user.");
|
|
9
|
+
return uid;
|
|
10
|
+
}
|
|
11
|
+
/** Writes the service definition, then starts (or restarts) it. */
|
|
12
|
+
export async function installService(system, manager, spec) {
|
|
13
|
+
const { platform, paths } = manager;
|
|
14
|
+
const file = serviceFile(paths, platform, spec.service);
|
|
15
|
+
await system.mkdir(paths.services, { mode: platform === "darwin" ? 0o755 : 0o700 });
|
|
16
|
+
if (platform === "darwin") {
|
|
17
|
+
await system.writeFile(file, launchAgentPlist(spec), { mode: 0o644 });
|
|
18
|
+
await stopService(system, manager, spec.service);
|
|
19
|
+
const target = `gui/${manager.uid}/${LOCAL_SERVICES[spec.service].label}`;
|
|
20
|
+
// A service the user disabled stays disabled across bootstrap; setup means "on".
|
|
21
|
+
await system.run("launchctl", ["enable", target], { allowFailure: true });
|
|
22
|
+
// bootout returns before launchd has let go of the label; bootstrap can briefly fail with EIO.
|
|
23
|
+
for (let attempt = 1;; attempt++) {
|
|
24
|
+
const result = await system.run("launchctl", ["bootstrap", `gui/${manager.uid}`, file], { allowFailure: true });
|
|
25
|
+
if (result.code === 0)
|
|
26
|
+
break;
|
|
27
|
+
if (attempt >= 5)
|
|
28
|
+
throw new Error(`launchd wouldn't start ${spec.service}: ${(result.stderr || result.stdout).trim()}`);
|
|
29
|
+
await manager.sleep(1_000);
|
|
30
|
+
}
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const unit = LOCAL_SERVICES[spec.service].unit;
|
|
34
|
+
await system.writeFile(file, systemdUserUnit(spec, spec.service === "autopilot" ? LOCAL_SERVICES.hotcell.unit : undefined), { mode: 0o644 });
|
|
35
|
+
await system.run("systemctl", ["--user", "daemon-reload"]);
|
|
36
|
+
await system.run("systemctl", ["--user", "enable", unit]);
|
|
37
|
+
await system.run("systemctl", ["--user", "restart", unit]);
|
|
38
|
+
}
|
|
39
|
+
export async function stopService(system, manager, service) {
|
|
40
|
+
if (manager.platform === "darwin") {
|
|
41
|
+
await system.run("launchctl", ["bootout", `gui/${manager.uid}/${LOCAL_SERVICES[service].label}`], { allowFailure: true });
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
await system.run("systemctl", ["--user", "disable", "--now", LOCAL_SERVICES[service].unit], { allowFailure: true });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Stops and deletes both service definitions. */
|
|
48
|
+
export async function removeServices(system, manager) {
|
|
49
|
+
for (const service of [...ORDER].reverse()) {
|
|
50
|
+
await stopService(system, manager, service);
|
|
51
|
+
await system.remove(serviceFile(manager.paths, manager.platform, service));
|
|
52
|
+
}
|
|
53
|
+
if (manager.platform === "linux") {
|
|
54
|
+
await system.run("systemctl", ["--user", "daemon-reload"], { allowFailure: true });
|
|
55
|
+
await system.run("systemctl", ["--user", "reset-failed"], { allowFailure: true });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export async function serviceState(system, manager, service) {
|
|
59
|
+
const installed = await system.exists(serviceFile(manager.paths, manager.platform, service));
|
|
60
|
+
if (manager.platform === "darwin") {
|
|
61
|
+
const printed = await system.probe("launchctl", ["print", `gui/${manager.uid}/${LOCAL_SERVICES[service].label}`]);
|
|
62
|
+
const running = printed.code === 0 && /^\s*state = running\s*$/m.test(printed.stdout);
|
|
63
|
+
const pid = Number(printed.stdout.match(/^\s*pid = (\d+)\s*$/m)?.[1]);
|
|
64
|
+
return { installed, running, ...(pid ? { pid } : {}) };
|
|
65
|
+
}
|
|
66
|
+
const active = await system.probe("systemctl", ["--user", "is-active", LOCAL_SERVICES[service].unit]);
|
|
67
|
+
return { installed, running: active.stdout.trim() === "active" };
|
|
68
|
+
}
|