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,472 @@
1
+ /**
2
+ * "On this computer": Hotcell + Autopilot as the current user's background services, so a
3
+ * laptop runs its own agents with no server. No root, no sudo; everything lives under
4
+ * ~/.directionallylocal (0700 dirs, 0600 secrets), and the services are LaunchAgents on
5
+ * macOS or systemd user units on Linux. Safe to re-run: it repairs and keeps secrets.
6
+ *
7
+ * The OpenRouter key goes to Hotcell's keystore over stdin, as a 0600 file under
8
+ * HOTCELL_HOME (HOTCELL_KEYSTORE=file), not the login Keychain:
9
+ * - Hotcell's Keychain backend writes through `security add-generic-password -w KEY`,
10
+ * which puts the key in a process argument list; the file path keeps it on stdin only.
11
+ * - Keychain entries are global to the login session and ignore HOTCELL_HOME, so ours
12
+ * would collide with (and overwrite) a user's own `hotcell keys add openrouter`.
13
+ * - A LaunchAgent reading the login keychain can hit an access prompt or a locked
14
+ * keychain (after a `security lock-keychain`, over SSH); a file never prompts.
15
+ * - Uninstall removes it with the rest; nothing is left in Keychain Access.
16
+ * The file sits inside a 0700 directory in the user's home, which FileVault encrypts at rest.
17
+ *
18
+ * Networking: on macOS (Docker Desktop, OrbStack, colima) a container's host.docker.internal
19
+ * reaches the host's loopback, so Hotcell's egress gateway and Autopilot's GitHub proxy
20
+ * bind 127.0.0.1 and stay off the network. On Linux with Docker Engine they bind the
21
+ * docker0 address instead, as on a server (loopback isn't reachable from the bridge).
22
+ */
23
+ import { randomBytes } from "node:crypto";
24
+ import { hostname as osHostname, homedir } from "node:os";
25
+ import { join } from "node:path";
26
+ import { cellDockerfile, cellImage } from "../layout.js";
27
+ import { removeRemote } from "../laptop/config.js";
28
+ import { RealSystem } from "../system.js";
29
+ import { checkComputer, computerProblems, localCapacity, servicePath, supportedPlatform } from "./check.js";
30
+ import { LOCAL_SERVICES, PAIRED_NAME, PREFERRED_PORTS, autopilotXdg, localAsterConfig, localAutopilotBin, localAutopilotEnv, localGithubEnv, localHotcellBin, localHotcellEnv, localPaths, parseLocalEnvFile, serviceSpecs, } from "./layout.js";
31
+ import { httpStatus, portFree } from "./probes.js";
32
+ import { installService, removeServices, serviceState, stopService, userId } from "./services.js";
33
+ import { readLocalState, writeLocalState } from "./state.js";
34
+ const SETUP_LOG = "setup.log";
35
+ /** Where setup sends other tools' output (npm, docker build); shown only when something fails. */
36
+ export function localSetupLog(home = homedir()) {
37
+ return join(home, ".directionallylocal", "logs", SETUP_LOG);
38
+ }
39
+ function context(options, logName) {
40
+ const home = options.home ?? homedir();
41
+ const rawPlatform = options.platform ?? process.platform;
42
+ // Unsupported platforms still get paths (for a clear refusal); nothing is written there.
43
+ const platform = supportedPlatform(rawPlatform) ?? "linux";
44
+ return {
45
+ // Other tools' output goes to a log file (or nowhere), never the screen.
46
+ system: options.system ?? new RealSystem(() => { }, logName ? { outputLog: join(home, ".directionallylocal", "logs", logName) } : {}),
47
+ home,
48
+ platform,
49
+ rawPlatform,
50
+ paths: localPaths(home, platform),
51
+ sleep: options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
52
+ };
53
+ }
54
+ /** Checks this computer without changing anything, e.g. before the wizard asks for keys. */
55
+ export async function checkThisComputer(options = {}) {
56
+ const ctx = context(options);
57
+ const facts = await checkComputer(ctx.system, checkOptions(ctx, options));
58
+ return { facts, problems: computerProblems(facts) };
59
+ }
60
+ export async function setUpOnThisComputer(input) {
61
+ const { ui } = input;
62
+ const ctx = context(input, SETUP_LOG);
63
+ const { system, paths, platform } = ctx;
64
+ const now = () => (input.now ?? (() => new Date()))().toISOString();
65
+ ui.plan([
66
+ { id: "check", label: "Checking this computer" },
67
+ { id: "software", label: "Installing Hotcell + Autopilot" },
68
+ { id: "image", label: "Building the sandbox image" },
69
+ { id: "services", label: "Starting services" },
70
+ { id: "connect", label: "Connecting" },
71
+ ]);
72
+ const step = async (id, work) => {
73
+ ui.start(id);
74
+ try {
75
+ const { value, detail } = await work();
76
+ ui.done(id, detail);
77
+ return value;
78
+ }
79
+ catch (error) {
80
+ ui.fail(id, error.message);
81
+ throw error;
82
+ }
83
+ };
84
+ const { facts, uid } = await step("check", async () => {
85
+ let facts = await checkComputer(system, checkOptions(ctx, input));
86
+ const unsupported = computerProblems(facts);
87
+ if (ctx.rawPlatform !== "darwin" && ctx.rawPlatform !== "linux")
88
+ throw new Error(unsupported[0]);
89
+ const uid = await userId(system);
90
+ if (!facts.docker && facts.dockerApp && platform === "darwin") {
91
+ ui.start("check", `opening ${facts.dockerApp}`);
92
+ facts = await openDockerApp(ctx, facts, input);
93
+ }
94
+ const problems = computerProblems(facts);
95
+ if (problems.length > 0)
96
+ throw new Error(`This computer can't run agents yet:\n - ${problems.join("\n - ")}`);
97
+ const docker = facts.docker;
98
+ return {
99
+ value: { facts, uid },
100
+ detail: `${facts.osName} · ${docker.runtime} ${docker.version} · Node ${facts.node.version} · ${facts.diskFreeGb} GB free`,
101
+ };
102
+ });
103
+ const docker = facts.docker;
104
+ const node = facts.node.path;
105
+ const manager = { platform, paths, uid, sleep: ctx.sleep };
106
+ const { autopilotVersion, hotcellVersion, release } = await step("software", async () => {
107
+ for (const dir of [paths.root, paths.config, paths.logs, paths.releases, paths.hotcellHome, paths.autopilotHome]) {
108
+ await system.mkdir(dir, { mode: 0o700 });
109
+ }
110
+ const autopilot = input.autopilotVersion ?? await latestVersion(system, "autopilot-ai");
111
+ const hotcell = input.hotcellVersion ?? await latestVersion(system, "hotcell");
112
+ const name = `autopilot-${autopilot}_hotcell-${hotcell}`;
113
+ const releaseDir = join(paths.releases, name);
114
+ if (!(await system.exists(join(releaseDir, "node_modules", "autopilot-ai", "package.json")) && await system.exists(join(releaseDir, "node_modules", "hotcell", "package.json")))) {
115
+ await system.mkdir(releaseDir, { mode: 0o700 });
116
+ await system.run("npm", ["install", "--prefix", releaseDir, "--no-audit", "--no-fund", "--omit=dev", `autopilot-ai@${autopilot}`, `hotcell@${hotcell}`], { inherit: true });
117
+ }
118
+ await system.run("ln", ["-sfn", releaseDir, paths.current]);
119
+ // Secrets first, so a later failure never needs them again.
120
+ const openrouterKey = input.answers.openrouterKey.trim();
121
+ if (openrouterKey) {
122
+ await system.run(node, [localHotcellBin(paths), "keys", "add", "openrouter", "--stdin"], {
123
+ input: openrouterKey,
124
+ env: hotcellCliEnv(paths),
125
+ describe: "hotcell keys add openrouter --stdin",
126
+ });
127
+ }
128
+ else if (!(await hotcellHasKey(system, node, paths, "openrouter"))) {
129
+ throw new Error("An OpenRouter key is needed; none is stored on this computer yet.");
130
+ }
131
+ const githubToken = input.answers.githubToken.trim();
132
+ if (githubToken) {
133
+ await system.writeFile(paths.githubEnv, localGithubEnv(githubToken), { mode: 0o600, secret: true });
134
+ }
135
+ else if (!parseLocalEnvFile(await system.readFile(paths.githubEnv)).GITHUB_TOKEN) {
136
+ throw new Error("A GitHub token is needed; none is stored on this computer yet.");
137
+ }
138
+ return { value: { autopilotVersion: autopilot, hotcellVersion: hotcell, release: name }, detail: `Autopilot ${autopilot} · Hotcell ${hotcell}` };
139
+ });
140
+ const image = cellImage(autopilotVersion);
141
+ const dockerEnv = { DOCKER_HOST: docker.host };
142
+ const dockerCli = facts.dockerCli;
143
+ await step("image", async () => {
144
+ const built = await ensureCellImage(system, dockerCli, dockerEnv, image, autopilotVersion);
145
+ return { value: undefined, detail: built ? "already built" : `Node 24 · Autopilot ${autopilotVersion}` };
146
+ });
147
+ const ports = await step("services", async () => {
148
+ // Stop ours first: a re-run keeps its ports, and they must be free to check.
149
+ for (const service of ["autopilot", "hotcell"])
150
+ await stopService(system, manager, service);
151
+ const net = { listenHost: platform === "linux" && facts.bridgeIp ? facts.bridgeIp : "127.0.0.1" };
152
+ const previous = parseLocalEnvFile(await system.readFile(paths.hotcellEnv));
153
+ const previousAutopilot = parseLocalEnvFile(await system.readFile(paths.autopilotEnv));
154
+ const ports = await choosePorts(ctx, node, net, previousPorts(previous, previousAutopilot));
155
+ const apiKey = previous.HOTCELL_API_KEY || randomBytes(24).toString("hex");
156
+ await system.writeFile(paths.hotcellEnv, localHotcellEnv({ paths, apiKey, ports, net }), { mode: 0o600, secret: true });
157
+ if (!(await system.readFile(paths.asterConfig)))
158
+ await system.writeFile(paths.asterConfig, localAsterConfig(paths), { mode: 0o600 });
159
+ await ensureWorkspace(system, paths);
160
+ await system.writeFile(paths.autopilotEnv, localAutopilotEnv({ paths, hotcellApiKey: apiKey, image, ports, net }), { mode: 0o600, secret: true });
161
+ const specs = serviceSpecs({ paths, node, path: servicePath(facts), dockerHost: docker.host, ports });
162
+ for (const spec of Object.values(specs))
163
+ await rotateLog(system, spec.log);
164
+ await installService(system, manager, specs.hotcell);
165
+ await waitUntil(ctx, "Hotcell", join(paths.logs, LOCAL_SERVICES.hotcell.log), async () => await httpStatus(system, node, `http://127.0.0.1:${ports.hotcell}/`) > 0);
166
+ await checkSandboxReach(system, dockerCli, dockerEnv, image, ports.hotcellEgress, ctx.paths);
167
+ await installService(system, manager, specs.autopilot);
168
+ // Without a token the Aster API answers 401: up, and refusing strangers.
169
+ await waitUntil(ctx, "Autopilot", join(paths.logs, LOCAL_SERVICES.autopilot.log), async () => await httpStatus(system, node, `http://127.0.0.1:${ports.autopilot}/api/v1/health`) === 401);
170
+ await pruneReleases(system, paths, release);
171
+ return { value: ports, detail: `start at login · logs in ${tilde(paths.logs, ctx.home)}` };
172
+ });
173
+ const url = `http://127.0.0.1:${ports.autopilot}`;
174
+ return await step("connect", async () => {
175
+ const name = `${machineName(input.hostname)} (this computer)`;
176
+ const created = await autopilotCli(system, node, paths, ["aster", "token", "create", "--name", name, "--workspace", "default", "--expires-in-days", "365", "--json"], true);
177
+ const credential = lastJsonLine(created.stdout);
178
+ if (created.code !== 0 || !credential?.token || !credential.credentialId) {
179
+ throw new Error(`Couldn't create this computer's credential: ${(created.stderr || created.stdout).trim().split("\n").at(-1) ?? "no output"}`);
180
+ }
181
+ // One credential per computer: earlier runs' are revoked once the new one exists.
182
+ const listed = lastJsonLine((await autopilotCli(system, node, paths, ["aster", "token", "list", "--json"])).stdout) ?? [];
183
+ for (const old of listed.filter((entry) => entry.name === name && entry.status === "active" && entry.id !== credential.credentialId)) {
184
+ await autopilotCli(system, node, paths, ["aster", "token", "revoke", old.id]);
185
+ }
186
+ const status = await httpStatus(system, node, `${url}/api/v1/health`, credential.token);
187
+ if (status !== 200)
188
+ throw new Error(`Autopilot didn't accept this computer's credential (HTTP ${status}).`);
189
+ const at = now();
190
+ const previous = await readLocalState(system, paths);
191
+ const state = {
192
+ version: 1,
193
+ kind: "this-computer",
194
+ platform,
195
+ installedAt: previous?.installedAt ?? at,
196
+ updatedAt: at,
197
+ release,
198
+ autopilotVersion,
199
+ hotcellVersion,
200
+ image,
201
+ url,
202
+ ports,
203
+ dockerHost: docker.host,
204
+ dockerRuntime: docker.runtime,
205
+ };
206
+ await writeLocalState(system, paths, state);
207
+ const remote = {
208
+ name: PAIRED_NAME,
209
+ url,
210
+ token: credential.token,
211
+ credentialId: credential.credentialId,
212
+ expiresAt: new Date(credential.expiresAt).toISOString(),
213
+ addedAt: at,
214
+ };
215
+ return {
216
+ value: remote,
217
+ detail: `${url.replace(/^http:\/\//, "")} · fits ~${localCapacity(docker)} at once · tasks pause while this computer sleeps`,
218
+ };
219
+ });
220
+ }
221
+ /**
222
+ * Stops and removes the services and the installed software. Keys, configuration, and
223
+ * conversations stay (so setting up again picks up where it was) unless `purge`.
224
+ */
225
+ export async function uninstallFromThisComputer(input) {
226
+ const { ui } = input;
227
+ const ctx = context(input, "uninstall.log");
228
+ const { system, paths, platform } = ctx;
229
+ const state = await readLocalState(system, paths);
230
+ ui.plan([
231
+ { id: "services", label: "Stopping services" },
232
+ { id: "sandboxes", label: "Removing sandboxes" },
233
+ { id: "software", label: "Removing Hotcell + Autopilot" },
234
+ ...(input.purge ? [{ id: "data", label: "Removing keys and data" }] : []),
235
+ ]);
236
+ const step = async (id, work) => {
237
+ ui.start(id);
238
+ try {
239
+ ui.done(id, await work());
240
+ }
241
+ catch (error) {
242
+ ui.fail(id, error.message);
243
+ throw error;
244
+ }
245
+ };
246
+ const uid = await userId(system);
247
+ const manager = { platform, paths, uid, sleep: ctx.sleep };
248
+ await step("services", async () => {
249
+ await removeServices(system, manager);
250
+ return platform === "darwin" ? "LaunchAgents removed" : "user units removed";
251
+ });
252
+ const dockerCli = (await system.probe("sh", ["-c", "command -v docker"])).stdout.trim() || "docker";
253
+ const dockerEnv = state?.dockerHost ? { env: { DOCKER_HOST: state.dockerHost } } : {};
254
+ await step("sandboxes", async () => {
255
+ // Only Autopilot's cells: a user's own Hotcell sandboxes carry sbx.managed too.
256
+ const listed = await system.probe(dockerCli, ["ps", "-aq", "--filter", "label=sbx.managed=true", "--filter", "label=autopilot.conversation_id"], dockerEnv);
257
+ const containers = listed.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
258
+ if (containers.length > 0)
259
+ await system.run(dockerCli, ["rm", "-f", ...containers], { allowFailure: true, ...dockerEnv });
260
+ return containers.length > 0 ? `${containers.length} removed` : "none running";
261
+ });
262
+ await step("software", async () => {
263
+ await system.remove(paths.current);
264
+ await system.remove(paths.releases);
265
+ await system.remove(paths.state);
266
+ await (input.forgetRemote ?? ((name) => removeRemote(name)))(PAIRED_NAME).catch(() => false);
267
+ return input.purge ? undefined : `kept keys and data in ${tilde(paths.root, ctx.home)}`;
268
+ });
269
+ if (input.purge) {
270
+ await step("data", async () => {
271
+ if (state?.image)
272
+ await system.run(dockerCli, ["image", "rm", state.image], { allowFailure: true, ...dockerEnv });
273
+ await system.remove(paths.root);
274
+ return `${tilde(paths.root, ctx.home)} deleted`;
275
+ });
276
+ }
277
+ }
278
+ /** `dl local status`: is it running, where, and how much fits. Read-only. */
279
+ export async function localStatus(options = {}) {
280
+ const ctx = context(options);
281
+ const { system, paths, platform } = ctx;
282
+ const state = await readLocalState(system, paths);
283
+ const uid = (await system.probe("id", ["-u"])).stdout.trim();
284
+ const manager = { platform, paths, uid, sleep: ctx.sleep };
285
+ const [hotcell, autopilot] = await Promise.all([serviceState(system, manager, "hotcell"), serviceState(system, manager, "autopilot")]);
286
+ const node = options.execPath ?? process.execPath;
287
+ const answering = state ? await httpStatus(system, node, `${state.url}/api/v1/health`) === 401 : false;
288
+ let capacity;
289
+ let dockerRunning = false;
290
+ if (state) {
291
+ const cli = (await system.probe("sh", ["-c", "command -v docker"])).stdout.trim() || "docker";
292
+ const info = await system.probe(cli, ["info", "--format", "{{.MemTotal}}|{{.NCPU}}"], { env: { DOCKER_HOST: state.dockerHost } });
293
+ const [mem, cpus] = info.stdout.trim().split("|");
294
+ if (info.code === 0 && mem) {
295
+ dockerRunning = true;
296
+ capacity = localCapacity({ memoryMb: Math.round(Number(mem) / 1024 / 1024), cpus: Number(cpus) || 0 });
297
+ }
298
+ }
299
+ return { installed: Boolean(state), state, services: { hotcell, autopilot }, answering, capacity, dockerRunning, paths, platform: ctx.rawPlatform };
300
+ }
301
+ function checkOptions(ctx, options) {
302
+ return {
303
+ home: ctx.home,
304
+ ...(options.platform ? { platform: options.platform } : {}),
305
+ ...(options.arch ? { arch: options.arch } : {}),
306
+ ...(options.env ? { env: options.env } : {}),
307
+ ...(options.execPath ? { execPath: options.execPath } : {}),
308
+ ...(options.nodeVersion ? { nodeVersion: options.nodeVersion } : {}),
309
+ };
310
+ }
311
+ /** Docker Desktop / OrbStack installed but not running: open it and wait for the daemon. */
312
+ async function openDockerApp(ctx, facts, options) {
313
+ const app = facts.dockerApp === "OrbStack" ? "OrbStack" : "Docker";
314
+ await ctx.system.run("open", ["-g", "-a", app], { allowFailure: true });
315
+ for (let attempt = 0; attempt < 45; attempt++) {
316
+ await ctx.sleep(2_000);
317
+ const again = await checkComputer(ctx.system, checkOptions(ctx, options));
318
+ if (again.docker)
319
+ return again;
320
+ }
321
+ return facts;
322
+ }
323
+ function hotcellCliEnv(paths) {
324
+ return { HOME: paths.hotcellHome, HOTCELL_HOME: paths.hotcellHome, HOTCELL_KEYSTORE: "file" };
325
+ }
326
+ async function hotcellHasKey(system, node, paths, provider) {
327
+ if (!(await system.exists(localHotcellBin(paths))))
328
+ return false;
329
+ const result = await system.probe(node, [localHotcellBin(paths), "keys", "ls"], { env: hotcellCliEnv(paths) });
330
+ return result.code === 0 && new RegExp(`^\\s*${provider}\\b`, "m").test(result.stdout);
331
+ }
332
+ /** Autopilot's own CLI against the service's config and database (never its secrets). */
333
+ function autopilotCli(system, node, paths, args, secretOutput = false) {
334
+ return system.run(node, [localAutopilotBin(paths), ...args], {
335
+ allowFailure: true,
336
+ env: {
337
+ HOME: paths.autopilotHome,
338
+ ...autopilotXdg(paths),
339
+ AUTOPILOT_ASTER_CONFIG: paths.asterConfig,
340
+ AUTOPILOT_ASTER_DB: join(paths.autopilotHome, "aster.db"),
341
+ },
342
+ describe: `autopilot ${args.join(" ")}`,
343
+ ...(secretOutput ? { secretOutput: true } : {}),
344
+ });
345
+ }
346
+ async function latestVersion(system, pkg) {
347
+ const result = await system.probe("npm", ["view", pkg, "version"]);
348
+ const version = result.stdout.trim();
349
+ if (result.code !== 0 || !/^\d+\.\d+\.\d+/.test(version))
350
+ throw new Error(`Couldn't look up the latest ${pkg} on npm${result.stderr ? `: ${result.stderr.trim()}` : ""}`);
351
+ return version;
352
+ }
353
+ /** Builds the sandbox image unless it exists; returns whether it already did. */
354
+ async function ensureCellImage(system, cli, env, image, autopilotVersion) {
355
+ if ((await system.probe(cli, ["image", "inspect", image], { env })).code === 0)
356
+ return true;
357
+ const dir = (await system.run("mktemp", ["-d"])).stdout.trim();
358
+ try {
359
+ await system.run("npm", ["pack", `autopilot-ai@${autopilotVersion}`, "--pack-destination", dir, "--silent"]);
360
+ await system.run("sh", ["-c", 'mv "$1"/autopilot-ai-*.tgz "$1"/autopilot.tgz', "sh", dir]);
361
+ await system.writeFile(join(dir, "Dockerfile"), cellDockerfile());
362
+ await system.run(cli, ["build", "--pull", "-q", "-t", image, dir], { inherit: true, env });
363
+ }
364
+ finally {
365
+ await system.remove(dir);
366
+ }
367
+ return false;
368
+ }
369
+ /**
370
+ * Proves a sandbox can reach the egress gateway the way cells will (host.docker.internal,
371
+ * mapped like Hotcell maps it). Catches a runtime that doesn't forward to the host.
372
+ */
373
+ async function checkSandboxReach(system, cli, env, image, port, paths) {
374
+ const script = `fetch("http://host.docker.internal:${port}/").then(r=>console.log(r.status),()=>console.log(0))`;
375
+ const result = await system.run(cli, ["run", "--rm", "--add-host", "host.docker.internal:host-gateway", image, "node", "-e", script], { allowFailure: true, env });
376
+ if (!(Number(result.stdout.trim()) > 0)) {
377
+ throw new Error(`Sandboxes can't reach Hotcell's gateway at host.docker.internal:${port}. `
378
+ + `Check that your Docker runtime forwards host.docker.internal to this computer; details in ${join(paths.logs, "setup.log")}`);
379
+ }
380
+ }
381
+ function previousPorts(hotcell, autopilot) {
382
+ const port = (value) => {
383
+ const parsed = Number(value?.split(":").at(-1));
384
+ return Number.isInteger(parsed) && parsed > 0 && parsed < 65536 ? parsed : undefined;
385
+ };
386
+ const entries = [
387
+ ["autopilot", port(autopilot.AUTOPILOT_PUBLIC_URL)],
388
+ ["hotcell", port(hotcell.HOTCELL_PORT)],
389
+ ["hotcellProxy", port(hotcell.HOTCELL_PROXY_PORT)],
390
+ ["hotcellEgress", port(hotcell.HOTCELL_EGRESS_PORT)],
391
+ ["githubProxy", port(autopilot.AUTOPILOT_ASTER_GITHUB_PROXY_LISTEN)],
392
+ ];
393
+ return Object.fromEntries(entries.filter(([, value]) => value !== undefined));
394
+ }
395
+ /**
396
+ * Each port: what this computer used before (ours; it may take a moment to be released
397
+ * after the stop), else the preferred one, else the next free one above it.
398
+ */
399
+ async function choosePorts(ctx, node, net, previous) {
400
+ const chosen = new Set();
401
+ const result = {};
402
+ for (const key of Object.keys(PREFERRED_PORTS)) {
403
+ const host = key === "hotcellEgress" || key === "githubProxy" ? net.listenHost : "127.0.0.1";
404
+ const free = (port) => !chosen.has(port) && portFree(ctx.system, node, host, port);
405
+ let pick;
406
+ const before = previous[key];
407
+ if (before !== undefined) {
408
+ for (let attempt = 0; attempt < 5 && pick === undefined; attempt++) {
409
+ if (await free(before))
410
+ pick = before;
411
+ else
412
+ await ctx.sleep(500);
413
+ }
414
+ }
415
+ for (let port = PREFERRED_PORTS[key]; pick === undefined && port < PREFERRED_PORTS[key] + 200; port++) {
416
+ if (await free(port))
417
+ pick = port;
418
+ }
419
+ if (pick === undefined)
420
+ throw new Error(`No free port near ${PREFERRED_PORTS[key]} for ${key}.`);
421
+ chosen.add(pick);
422
+ result[key] = pick;
423
+ }
424
+ return result;
425
+ }
426
+ /** The default workspace: an empty Git repository Aster requires as a root. */
427
+ async function ensureWorkspace(system, paths) {
428
+ if (await system.exists(join(paths.workspace, ".git")))
429
+ return;
430
+ await system.mkdir(paths.workspace, { mode: 0o700 });
431
+ const git = (args) => system.run("git", ["-C", paths.workspace, ...args]);
432
+ await git(["init", "--quiet", "--initial-branch=main"]);
433
+ await system.writeFile(join(paths.workspace, "README.md"), "# Default workspace\n", { mode: 0o600 });
434
+ await git(["add", "README.md"]);
435
+ await git(["-c", "user.name=Autopilot", "-c", "user.email=autopilot@localhost", "-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "Initialize workspace"]);
436
+ }
437
+ /** launchd and systemd's append: never rotate; keep each log under ~20 MB across runs. */
438
+ async function rotateLog(system, log) {
439
+ const size = Number((await system.probe("wc", ["-c", log])).stdout.trim().split(/\s+/)[0]);
440
+ if (size > 20 * 1024 * 1024)
441
+ await system.run("mv", ["-f", log, `${log}.1`], { allowFailure: true });
442
+ }
443
+ /** Older releases go once the new one is running; the current one stays. */
444
+ async function pruneReleases(system, paths, keep) {
445
+ const listed = await system.probe("ls", ["-1", paths.releases]);
446
+ for (const name of listed.stdout.split("\n").map((line) => line.trim()).filter(Boolean)) {
447
+ if (name !== keep && /^autopilot-.+_hotcell-.+$/.test(name))
448
+ await system.remove(join(paths.releases, name));
449
+ }
450
+ }
451
+ async function waitUntil(ctx, what, log, ready) {
452
+ for (let attempt = 0; attempt < 60; attempt++) {
453
+ if (await ready())
454
+ return;
455
+ await ctx.sleep(1_000);
456
+ }
457
+ throw new Error(`${what} didn't come up; see ${tilde(log, ctx.home)}`);
458
+ }
459
+ function lastJsonLine(stdout) {
460
+ try {
461
+ return JSON.parse(stdout.trim().split("\n").at(-1) ?? "");
462
+ }
463
+ catch {
464
+ return undefined;
465
+ }
466
+ }
467
+ function machineName(name = osHostname()) {
468
+ return name.replace(/\.local$/i, "").trim() || "This computer";
469
+ }
470
+ function tilde(path, home) {
471
+ return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
472
+ }
@@ -0,0 +1,15 @@
1
+ export async function readLocalState(system, paths) {
2
+ const raw = await system.readFile(paths.state);
3
+ if (!raw)
4
+ return undefined;
5
+ try {
6
+ const parsed = JSON.parse(raw);
7
+ return parsed.version === 1 && parsed.kind === "this-computer" ? parsed : undefined;
8
+ }
9
+ catch {
10
+ return undefined;
11
+ }
12
+ }
13
+ export async function writeLocalState(system, paths, state) {
14
+ await system.writeFile(paths.state, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
15
+ }