directionallylocal 0.3.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.
@@ -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
+ }
@@ -320,7 +320,7 @@ async function openDockerApp(ctx, facts, options) {
320
320
  }
321
321
  return facts;
322
322
  }
323
- function hotcellCliEnv(paths) {
323
+ export function hotcellCliEnv(paths) {
324
324
  return { HOME: paths.hotcellHome, HOTCELL_HOME: paths.hotcellHome, HOTCELL_KEYSTORE: "file" };
325
325
  }
326
326
  async function hotcellHasKey(system, node, paths, provider) {
@@ -17,6 +17,8 @@ export class FakeComputer {
17
17
  containers = [];
18
18
  /** launchd labels or systemd units that are running. */
19
19
  running = new Set();
20
+ /** Services restarted in place (launchctl kickstart -k, systemctl --user restart), in order. */
21
+ restarted = [];
20
22
  /** Ports something else on this computer listens on. */
21
23
  busy = new Set();
22
24
  credentials = [];
@@ -128,8 +130,13 @@ export class FakeComputer {
128
130
  this.files.set(`${home}/keys.json`, { content: "{}", mode: 0o600 });
129
131
  return ok("stored\n");
130
132
  }
131
- if (args[1] === "keys" && args[2] === "ls")
132
- return !this.files.has(`${options.env?.HOTCELL_HOME ?? ""}/keys.json`) ? ok("") : ok([...this.hotcellKeys.keys()].map((key) => ` ${key} file\n`).join(""));
133
+ if (args[1] === "keys" && args[2] === "ls") {
134
+ if (!this.files.has(`${options.env?.HOTCELL_HOME ?? ""}/keys.json`))
135
+ return ok("");
136
+ // Like Hotcell's own listing: a header, then each provider with its masked value.
137
+ const mask = (value) => value.length <= 8 ? "•".repeat(Math.max(4, value.length)) : `${value.slice(0, 5)}…${value.slice(-2)}`;
138
+ return ok(`PROVIDER KEY SOURCE\n${[...this.hotcellKeys].map(([key, value]) => `${key.padEnd(8)} ${mask(value).padEnd(12)} file\n`).join("")}`);
139
+ }
133
140
  return ok();
134
141
  }
135
142
  if (command.endsWith("/node") && args[0]?.endsWith("autopilot-ai/bin/autopilot.mjs")) {
@@ -172,8 +179,10 @@ export class FakeComputer {
172
179
  case "systemctl":
173
180
  if (args[1] === "is-system-running")
174
181
  return this.systemdUser ? ok("running\n") : fail("Failed to connect to bus");
175
- if (args[1] === "restart")
182
+ if (args[1] === "restart") {
176
183
  this.running.add(args[2]);
184
+ this.restarted.push(args[2]);
185
+ }
177
186
  if (args[1] === "disable" && args[2] === "--now")
178
187
  this.running.delete(args[3]);
179
188
  if (args[1] === "is-active")
@@ -192,6 +201,14 @@ export class FakeComputer {
192
201
  const label = args[1].split("/").at(-1);
193
202
  return this.running.delete(label) ? ok() : fail("Boot-out failed: 3: No such process");
194
203
  }
204
+ if (args[0] === "kickstart") {
205
+ // -k restarts a loaded service; one that isn't loaded can't be kickstarted.
206
+ const label = args.at(-1).split("/").at(-1);
207
+ if (!this.running.has(label))
208
+ return fail("Could not find service");
209
+ this.restarted.push(label);
210
+ return ok();
211
+ }
195
212
  if (args[0] === "print") {
196
213
  const label = args[1].split("/").at(-1);
197
214
  return this.running.has(label) ? ok(`${args[1]} = {\n\tstate = running\n\tpid = 4242\n}\n`) : fail("Could not find service");
package/dist/up.js CHANGED
@@ -35,7 +35,7 @@ export async function up(system, options) {
35
35
  { id: "software", label: "Installing Hotcell + Autopilot" },
36
36
  { id: "image", label: "Building the sandbox image" },
37
37
  { id: "services", label: "Starting services" },
38
- { id: "access", label: access === "tailscale" ? "Opening private access" : "Starting the API" },
38
+ { id: "access", label: access === "tailscale" ? "Opening private access" : "Starting the server" },
39
39
  ]);
40
40
  const step = async (id, work) => {
41
41
  ui.start(id);
@@ -131,7 +131,9 @@ export async function up(system, options) {
131
131
  await system.run("systemctl", ["restart", SERVICES.autopilot]);
132
132
  // Without a token the Aster API answers 401: the server is up and refusing strangers.
133
133
  await waitUntil(system, sleep, "Autopilot", async () => (await system.probe("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", `http://127.0.0.1:${AUTOPILOT_PORT}/api/v1/health`])).stdout.trim() === "401");
134
- return { value: address, detail: address.replace(/^https?:\/\//, "") };
134
+ // The API only listens on this machine: say who can reach it rather than a loopback address.
135
+ const reach = access === "tailscale" ? address.replace(/^https?:\/\//, "") : access === "ssh" ? "private · reached over SSH" : "private · this machine only";
136
+ return { value: address, detail: reach };
135
137
  });
136
138
  const now = (options.now ?? (() => new Date()))().toISOString();
137
139
  const state = {
@@ -390,7 +392,7 @@ async function latestVersion(system, pkg) {
390
392
  throw new Error(`Couldn't look up the latest ${pkg} on npm${result.stderr ? `: ${result.stderr.trim()}` : ""}`);
391
393
  return version;
392
394
  }
393
- function asHotcell(system, args, options = {}) {
395
+ export function asHotcell(system, args, options = {}) {
394
396
  return system.run("runuser", [
395
397
  "-u", USERS.hotcell, "--", "env", `HOTCELL_HOME=${PATHS.hotcellHome}`, "HOTCELL_KEYSTORE=file",
396
398
  "/usr/bin/node", hotcellBin(), ...args,
@@ -492,7 +494,7 @@ async function serveOverTailscale(system) {
492
494
  throw new Error("Tailscale is up but reported no DNS name for this machine; is MagicDNS on?");
493
495
  return `https://${dnsName}`;
494
496
  }
495
- async function waitUntil(system, sleep, what, ready, unit = what.toLowerCase()) {
497
+ export async function waitUntil(system, sleep, what, ready, unit = what.toLowerCase()) {
496
498
  if (system.dryRun)
497
499
  return;
498
500
  for (let attempt = 0; attempt < 60; attempt++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "directionallylocal",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Directionally Local: background coding agents on your own server. One-line server install, and the dl command for your server and laptop.",
5
5
  "homepage": "https://directionallylocal.com",
6
6
  "repository": {