directionallylocal 0.1.2 → 0.3.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.
@@ -0,0 +1,211 @@
1
+ import { spawn } from "node:child_process";
2
+ import { join } from "node:path";
3
+ /**
4
+ * The concrete hosts in an ssh config. Wildcard and negated patterns are defaults, not
5
+ * places to connect to, so they're skipped; `Match` blocks are ignored. ssh takes the first
6
+ * value it finds for each option, so a host's own block wins, then global or `Host *` lines.
7
+ */
8
+ export function parseSshConfig(text, home) {
9
+ const blocks = [];
10
+ const global = {};
11
+ let current = global;
12
+ for (const raw of text.split(/\r?\n/)) {
13
+ const line = raw.trim();
14
+ if (!line || line.startsWith("#"))
15
+ continue;
16
+ const match = /^(\S+?)(?:\s*=\s*|\s+)(.+)$/.exec(line);
17
+ if (!match)
18
+ continue;
19
+ const key = match[1].toLowerCase();
20
+ const value = match[2].trim();
21
+ if (key === "host") {
22
+ const block = { patterns: value.split(/\s+/).map(unquote), settings: {} };
23
+ blocks.push(block);
24
+ current = block.settings;
25
+ continue;
26
+ }
27
+ if (key === "match") {
28
+ current = undefined;
29
+ continue;
30
+ }
31
+ if (!current)
32
+ continue;
33
+ const setting = unquote(value);
34
+ if (key === "hostname")
35
+ current.hostName ??= setting;
36
+ else if (key === "user")
37
+ current.user ??= setting;
38
+ else if (key === "identityfile")
39
+ current.identityFile ??= expandHome(setting, home);
40
+ else if (key === "port" && /^\d+$/.test(setting))
41
+ current.port ??= Number(setting);
42
+ }
43
+ const defaults = { ...global };
44
+ for (const block of blocks) {
45
+ if (!block.patterns.includes("*"))
46
+ continue;
47
+ for (const [key, value] of Object.entries(block.settings))
48
+ defaults[key] ??= value;
49
+ }
50
+ const hosts = new Map();
51
+ for (const block of blocks) {
52
+ for (const pattern of block.patterns) {
53
+ if (/[*?!]/.test(pattern) || hosts.has(pattern))
54
+ continue;
55
+ const host = { alias: pattern };
56
+ for (const source of [block.settings, defaults]) {
57
+ for (const [key, value] of Object.entries(source))
58
+ host[key] ??= value;
59
+ }
60
+ hosts.set(pattern, host);
61
+ }
62
+ }
63
+ return [...hosts.values()];
64
+ }
65
+ /** How a config host reads in a list: root@203.0.113.10, or with a port when it isn't 22. */
66
+ export function describeHost(host) {
67
+ const port = host.port && host.port !== 22 ? `:${host.port}` : "";
68
+ return `${host.user ? `${host.user}@` : ""}${host.hostName ?? host.alias}${port}`;
69
+ }
70
+ /** Private keys in ~/.ssh: the files that have a matching .pub next to them. */
71
+ export function discoverKeys(sshDir, names) {
72
+ const present = new Set(names);
73
+ const preferred = ["id_ed25519", "id_ecdsa", "id_rsa"];
74
+ return names
75
+ .filter((name) => !name.endsWith(".pub") && present.has(`${name}.pub`))
76
+ .sort((a, b) => rank(a) - rank(b) || a.localeCompare(b))
77
+ .map((name) => join(sshDir, name));
78
+ function rank(name) {
79
+ const index = preferred.indexOf(name);
80
+ return index === -1 ? preferred.length : index;
81
+ }
82
+ }
83
+ /** `user@host`, `host`, `user@host:2222`, or `user@[2001:db8::1]:2222` as typed by the user. */
84
+ export function parseTarget(input) {
85
+ const text = input.trim().replace(/^ssh\s+/, "");
86
+ const match = /^(?:([A-Za-z0-9._-]+)@)?(\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9._-]+)(?::(\d{1,5}))?$/.exec(text);
87
+ if (!match || match[2].startsWith("-"))
88
+ throw new Error(`"${input.trim()}" isn't a server address; type it like root@203.0.113.10 or root@box.example.com:2222`);
89
+ const host = match[2].replace(/^\[(.*)\]$/, "$1");
90
+ const port = match[3] ? Number(match[3]) : undefined;
91
+ if (port !== undefined && (port < 1 || port > 65535))
92
+ throw new Error(`${port} isn't a port`);
93
+ return { target: `${match[1] ? `${match[1]}@` : ""}${host}`, ...(port && port !== 22 ? { port } : {}) };
94
+ }
95
+ /** Options for every non-interactive ssh: no password prompts, a bounded wait, and trust on first use. */
96
+ export function sshOptions(access) {
97
+ if (access.target.startsWith("-"))
98
+ throw new Error(`Not a server: ${access.target}`);
99
+ return [
100
+ "-o", "BatchMode=yes",
101
+ "-o", "ConnectTimeout=10",
102
+ "-o", "StrictHostKeyChecking=accept-new",
103
+ ...(access.port ? ["-p", String(access.port)] : []),
104
+ // With a chosen key, offer only it: an agent full of keys can hit the server's MaxAuthTries first.
105
+ ...(access.identityFile ? ["-i", access.identityFile, "-o", "IdentitiesOnly=yes"] : []),
106
+ ];
107
+ }
108
+ /** The command line people would type for this server, for messages. */
109
+ export function sshCommandLine(access) {
110
+ return ["ssh", ...(access.port ? ["-p", String(access.port)] : []), ...(access.identityFile ? ["-i", access.identityFile] : []), access.target].join(" ");
111
+ }
112
+ export const realSshExec = (access, command, options = {}) => new Promise((resolve, reject) => {
113
+ const child = spawn("ssh", [...sshOptions(access), access.target, command], {
114
+ stdio: ["pipe", "pipe", "pipe"],
115
+ ...(options.signal ? { signal: options.signal } : {}),
116
+ });
117
+ let stdout = "";
118
+ let stderr = "";
119
+ child.stdout.on("data", lines((chunk) => { stdout += chunk; }, options.onStdoutLine));
120
+ child.stderr.on("data", lines((chunk) => { stderr += chunk; }, options.onStderrLine));
121
+ child.on("error", (error) => reject(error.code === "ENOENT" ? new Error("ssh isn't installed on this computer") : error));
122
+ child.on("close", (code) => resolve({ code: code ?? 255, stdout, stderr }));
123
+ // Always close stdin: a remote command reading it would otherwise wait forever.
124
+ child.stdin.on("error", () => { });
125
+ child.stdin.end(options.input ?? "");
126
+ });
127
+ /** Collects a stream's text and calls back once per complete line. */
128
+ export function lines(collect, onLine) {
129
+ let pending = "";
130
+ return (chunk) => {
131
+ const text = chunk.toString();
132
+ collect(text);
133
+ if (!onLine)
134
+ return;
135
+ pending += text;
136
+ const parts = pending.split("\n");
137
+ pending = parts.pop() ?? "";
138
+ for (const part of parts)
139
+ onLine(part.replace(/\r$/, ""));
140
+ };
141
+ }
142
+ /** The server refused our keys; the wizard offers others from ~/.ssh. */
143
+ export class SshAuthError extends Error {
144
+ access;
145
+ constructor(access, detail) {
146
+ super(`The server didn't accept this computer's SSH key (${detail}).`);
147
+ this.access = access;
148
+ this.name = "SshAuthError";
149
+ }
150
+ }
151
+ const SUPPORTED_OS = new Set(["ubuntu 22.04", "ubuntu 24.04", "debian 12"]);
152
+ // One round trip: who we are, the OS, the CPU, and whether we can become root without a password.
153
+ const PROBE = 'id -u; id -un; . /etc/os-release; echo "$ID $VERSION_ID"; uname -m; ' +
154
+ 'if [ "$(id -u)" = 0 ]; then echo root; elif sudo -n true 2>/dev/null; then echo sudo; else echo nosudo; fi';
155
+ /** Connects once and checks the server can take an install: a supported OS, and root or passwordless sudo. */
156
+ export async function checkServer(access, ssh, display) {
157
+ const result = await ssh(access, PROBE);
158
+ const newHostKey = /Permanently added/i.test(result.stderr);
159
+ if (result.code === 255 || result.stdout.trim() === "") {
160
+ const detail = lastLine(result.stderr) || `ssh exited with ${result.code}`;
161
+ if (/Permission denied|Too many authentication failures|no mutual signature|Authentication failed/i.test(result.stderr)) {
162
+ throw new SshAuthError(access, detail);
163
+ }
164
+ if (/Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED/i.test(result.stderr)) {
165
+ throw new Error(`${display}'s SSH host key changed since this computer last connected, so it stopped (that can mean someone is in the middle). If you rebuilt the server, remove the old key with: ssh-keygen -R ${hostOf(access.target)}`);
166
+ }
167
+ throw new Error(`Couldn't reach ${display} over SSH: ${detail}\n Check that this works: ${sshCommandLine(access)}`);
168
+ }
169
+ const [uid, user, osLine, arch, privilege] = result.stdout.trim().split("\n").map((line) => line.trim());
170
+ const os = formatOs(osLine ?? "");
171
+ const facts = {
172
+ user: user || "?",
173
+ root: uid === "0",
174
+ sudo: privilege === "sudo",
175
+ os,
176
+ arch: arch === "aarch64" ? "arm64" : arch || "?",
177
+ newHostKey,
178
+ };
179
+ if (!facts.root && !facts.sudo) {
180
+ throw new Error([
181
+ `${facts.user} on ${display} isn't root and can't use sudo without a password, and setup needs one of them.`,
182
+ ` Either connect as root (root@${hostOf(access.target)}), or let ${facts.user} use sudo without a password:`,
183
+ ` echo '${facts.user} ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/${facts.user}`,
184
+ ].join("\n"));
185
+ }
186
+ if (!SUPPORTED_OS.has((osLine ?? "").toLowerCase().replace(/"/g, ""))) {
187
+ throw new Error(`${display} runs ${os || "an unknown system"}; Directionally Local needs Ubuntu 22.04, Ubuntu 24.04, or Debian 12.`);
188
+ }
189
+ return facts;
190
+ }
191
+ function formatOs(line) {
192
+ const [id = "", version = ""] = line.replace(/"/g, "").split(/\s+/);
193
+ const names = { ubuntu: "Ubuntu", debian: "Debian" };
194
+ return `${names[id] ?? id} ${version}`.trim();
195
+ }
196
+ export function hostOf(target) {
197
+ return target.slice(target.lastIndexOf("@") + 1);
198
+ }
199
+ function lastLine(text) {
200
+ return text.trim().split("\n").filter((line) => !/Permanently added/i.test(line)).at(-1)?.trim() ?? "";
201
+ }
202
+ function unquote(value) {
203
+ return value.length >= 2 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
204
+ }
205
+ function expandHome(path, home) {
206
+ return path.replace(/^~(?=$|\/)/, home).replace(/%d/g, home);
207
+ }
208
+ /** Single-quotes a value for the remote shell. */
209
+ export function shellQuote(value) {
210
+ return `'${value.replace(/'/g, `'\\''`)}'`;
211
+ }
@@ -0,0 +1,120 @@
1
+ import { spawn } from "node:child_process";
2
+ import { connect, createServer } from "node:net";
3
+ import { RemoteClient } from "./client.js";
4
+ import { sshCommandLine, sshOptions } from "./ssh.js";
5
+ const realSpawn = (args) => spawn("ssh", args, { stdio: ["ignore", "ignore", "pipe"] });
6
+ /** The ssh arguments for a tunnel from a local port to the server's loopback port. */
7
+ export function tunnelArgs(access, localPort, remotePort) {
8
+ return ["-N", "-o", "ExitOnForwardFailure=yes", ...sshOptions(access), "-L", `127.0.0.1:${localPort}:127.0.0.1:${remotePort}`, access.target];
9
+ }
10
+ export function createOpener(options = {}) {
11
+ const spawnTunnel = options.spawn ?? realSpawn;
12
+ const freePort = options.freePort ?? pickFreePort;
13
+ const accepts = options.accepts ?? acceptsConnections;
14
+ const timeoutMs = options.timeoutMs ?? 20_000;
15
+ const pollMs = options.pollMs ?? 100;
16
+ return async (remote) => {
17
+ if (!remote.ssh)
18
+ return { url: remote.url, close: async () => { } };
19
+ const access = remote.ssh;
20
+ const remotePort = Number(new URL(remote.url).port || 80);
21
+ const port = await freePort();
22
+ const child = spawnTunnel(tunnelArgs(access, port, remotePort));
23
+ let stderr = "";
24
+ child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); });
25
+ let exited = child.exitCode !== null;
26
+ child.once("exit", () => { exited = true; });
27
+ // If this process ends without closing the tunnel (an uncaught error), take ssh with it.
28
+ const onExit = () => { if (!exited)
29
+ child.kill("SIGTERM"); };
30
+ process.once("exit", onExit);
31
+ const close = async () => {
32
+ process.off("exit", onExit);
33
+ if (exited)
34
+ return;
35
+ const gone = new Promise((resolve) => {
36
+ child.once("exit", () => resolve());
37
+ setTimeout(resolve, 2000).unref?.();
38
+ });
39
+ child.kill("SIGTERM");
40
+ await gone;
41
+ };
42
+ const deadline = Date.now() + timeoutMs;
43
+ for (;;) {
44
+ if (exited) {
45
+ await close();
46
+ const detail = stderr.trim().split("\n").at(-1)?.trim();
47
+ throw new Error(`Couldn't open an SSH tunnel to ${remote.name} (${access.target})${detail ? `: ${detail}` : ""}\n Check that this works: ${sshCommandLine(access)}`);
48
+ }
49
+ if (await accepts(port))
50
+ return { url: `http://127.0.0.1:${port}`, close };
51
+ if (Date.now() >= deadline) {
52
+ await close();
53
+ throw new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s opening an SSH tunnel to ${remote.name} (${access.target}).\n Check that this works: ${sshCommandLine(access)}`);
54
+ }
55
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
56
+ }
57
+ };
58
+ }
59
+ export const openRemote = createOpener();
60
+ /**
61
+ * The endpoints one command uses: at most one tunnel per server, reused across its
62
+ * requests, all closed when the command ends (commands close it in `finally`, which also
63
+ * runs when Ctrl-C aborts a watch).
64
+ */
65
+ export class Connections {
66
+ opener;
67
+ open = new Map();
68
+ constructor(opener = openRemote) {
69
+ this.opener = opener;
70
+ }
71
+ async client(remote) {
72
+ let endpoint = this.open.get(remote.name);
73
+ if (!endpoint) {
74
+ endpoint = this.opener(remote);
75
+ this.open.set(remote.name, endpoint);
76
+ }
77
+ return new RemoteClient((await endpoint).url, remote.token);
78
+ }
79
+ async close() {
80
+ const endpoints = [...this.open.values()];
81
+ this.open.clear();
82
+ await Promise.all(endpoints.map((endpoint) => endpoint.then((entry) => entry.close(), () => { })));
83
+ }
84
+ }
85
+ /** Runs `work` with a fresh set of connections and always closes them. */
86
+ export async function withConnections(opener, work) {
87
+ const connections = new Connections(opener);
88
+ try {
89
+ return await work(connections);
90
+ }
91
+ finally {
92
+ await connections.close();
93
+ }
94
+ }
95
+ /** A port nothing listens on right now (the OS picks it; ssh binds it a moment later). */
96
+ export function pickFreePort() {
97
+ return new Promise((resolve, reject) => {
98
+ const server = createServer();
99
+ server.unref();
100
+ server.once("error", reject);
101
+ server.listen(0, "127.0.0.1", () => {
102
+ const address = server.address();
103
+ const port = typeof address === "object" && address ? address.port : 0;
104
+ server.close(() => port ? resolve(port) : reject(new Error("no free port")));
105
+ });
106
+ });
107
+ }
108
+ export function acceptsConnections(port) {
109
+ return new Promise((resolve) => {
110
+ const socket = connect({ host: "127.0.0.1", port });
111
+ const done = (ok) => {
112
+ socket.removeAllListeners();
113
+ socket.destroy();
114
+ resolve(ok);
115
+ };
116
+ socket.setTimeout(1000, () => done(false));
117
+ socket.once("connect", () => done(true));
118
+ socket.once("error", () => done(false));
119
+ });
120
+ }
@@ -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
+ }