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,263 @@
1
+ import { HTTP_STATUS_SCRIPT, PORT_FREE_SCRIPT } from "../probes.js";
2
+ /**
3
+ * A simulated Mac (or Linux desktop) with Docker Desktop, for "on this computer" tests.
4
+ * launchctl/systemctl load and unload services, which then answer on the ports in their
5
+ * env files; npm installs packages; Autopilot's CLI issues credentials. So setup can run
6
+ * twice to prove it repairs without reinstalling.
7
+ */
8
+ export class FakeComputer {
9
+ home;
10
+ dryRun = false;
11
+ files = new Map();
12
+ dirs = new Map();
13
+ commands = [];
14
+ probes = [];
15
+ logs = [];
16
+ images = new Set();
17
+ containers = [];
18
+ /** launchd labels or systemd units that are running. */
19
+ running = new Set();
20
+ /** Ports something else on this computer listens on. */
21
+ busy = new Set();
22
+ credentials = [];
23
+ hotcellKeys = new Map();
24
+ docker = true;
25
+ /** Docker Desktop is installed as an app (it may be stopped). */
26
+ dockerApp = true;
27
+ dockerDenied = false;
28
+ /** Containers reach host.docker.internal. */
29
+ reachHost = true;
30
+ git = true;
31
+ uid = "501";
32
+ diskFreeKb = 120 * 1024 * 1024;
33
+ /** Docker Desktop's VM: 8 GB, 8 CPUs. */
34
+ dockerMemBytes = 8 * 1024 * 1024 * 1024;
35
+ systemdUser = true;
36
+ bridgeIp = undefined;
37
+ constructor(home = "/Users/ada") {
38
+ this.home = home;
39
+ this.files.set(`${home}/.docker/run/docker.sock`, { content: "", mode: 0o755 });
40
+ this.files.set("/opt/homebrew/opt/node/bin/node", { content: "", mode: 0o755 });
41
+ this.files.set("/Applications/Docker.app/Contents/Info.plist", { content: "", mode: 0o644 });
42
+ this.files.set("/etc/os-release", { content: 'ID=ubuntu\nPRETTY_NAME="Ubuntu 24.04.1 LTS"\n', mode: 0o644 });
43
+ }
44
+ get ran() {
45
+ return this.commands.map(({ command, args }) => [command, ...args].join(" "));
46
+ }
47
+ async run(command, args, options = {}) {
48
+ this.commands.push({ command, args, ...(options.input !== undefined ? { input: options.input } : {}), ...(options.env ? { env: options.env } : {}) });
49
+ const result = this.respond(command, args, options);
50
+ if (result.code !== 0 && !options.allowFailure)
51
+ throw new Error(`${command} ${args.join(" ")} failed: ${result.stderr}`);
52
+ return result;
53
+ }
54
+ async probe(command, args, options = {}) {
55
+ this.probes.push({ command, args, ...(options.input !== undefined ? { input: options.input } : {}), ...(options.env ? { env: options.env } : {}) });
56
+ return this.respond(command, args, options);
57
+ }
58
+ async readFile(path) {
59
+ return this.files.get(path)?.content;
60
+ }
61
+ async writeFile(path, content, options = {}) {
62
+ this.files.set(path, { content, mode: options.mode ?? 0o644 });
63
+ }
64
+ async mkdir(path, options = {}) {
65
+ this.dirs.set(path, options.mode);
66
+ }
67
+ async remove(path) {
68
+ for (const file of [...this.files.keys()])
69
+ if (file === path || file.startsWith(`${path}/`))
70
+ this.files.delete(file);
71
+ for (const dir of [...this.dirs.keys()])
72
+ if (dir === path || dir.startsWith(`${path}/`))
73
+ this.dirs.delete(dir);
74
+ }
75
+ async exists(path) {
76
+ if (path === "/Applications/Docker.app")
77
+ return this.dockerApp;
78
+ return this.files.has(path) || this.dirs.has(path) || [...this.files.keys(), ...this.dirs.keys()].some((entry) => entry.startsWith(`${path}/`));
79
+ }
80
+ log(line) {
81
+ this.logs.push(line);
82
+ }
83
+ /** The ports a running service of ours listens on, from its env file. */
84
+ ourPorts() {
85
+ const ports = new Map();
86
+ const env = (name) => Object.fromEntries((this.files.get(`${this.home}/.directionallylocal/config/${name}`)?.content ?? "")
87
+ .split("\n").flatMap((line) => line.match(/^([A-Z_]+)=(.*)$/) ? [[line.split("=")[0], line.slice(line.indexOf("=") + 1)]] : []));
88
+ if ([...this.running].some((name) => name.includes("hotcell"))) {
89
+ const hotcell = env("hotcell.env");
90
+ for (const key of ["HOTCELL_PORT", "HOTCELL_PROXY_PORT", "HOTCELL_EGRESS_PORT"])
91
+ if (hotcell[key])
92
+ ports.set(Number(hotcell[key]), "hotcell");
93
+ }
94
+ if ([...this.running].some((name) => name.includes("autopilot"))) {
95
+ const autopilot = env("autopilot.env");
96
+ if (autopilot.AUTOPILOT_PUBLIC_URL)
97
+ ports.set(Number(autopilot.AUTOPILOT_PUBLIC_URL.split(":").at(-1)), "autopilot");
98
+ if (autopilot.AUTOPILOT_ASTER_GITHUB_PROXY_LISTEN)
99
+ ports.set(Number(autopilot.AUTOPILOT_ASTER_GITHUB_PROXY_LISTEN.split(":").at(-1)), "autopilot");
100
+ }
101
+ return ports;
102
+ }
103
+ respond(command, args, options) {
104
+ const ok = (stdout = "") => ({ code: 0, stdout, stderr: "" });
105
+ const fail = (stderr = "failed") => ({ code: 1, stdout: "", stderr });
106
+ const line = args.join(" ");
107
+ if (command.endsWith("/node") && args[0] === "-e") {
108
+ if (args[1] === PORT_FREE_SCRIPT) {
109
+ const port = Number(args[3]);
110
+ return this.busy.has(port) || this.ourPorts().has(port) ? fail() : ok();
111
+ }
112
+ if (args[1] === HTTP_STATUS_SCRIPT) {
113
+ const url = new URL(args[2]);
114
+ const owner = this.ourPorts().get(Number(url.port));
115
+ if (owner === "hotcell")
116
+ return ok("401\n");
117
+ if (owner === "autopilot" && url.pathname === "/api/v1/health") {
118
+ const token = (options.input ?? "").trim();
119
+ return ok(token && this.credentials.some((entry) => entry.token === token && entry.status === "active") ? "200\n" : "401\n");
120
+ }
121
+ return ok("0\n");
122
+ }
123
+ }
124
+ if (command.endsWith("/node") && args[0]?.endsWith("hotcell/dist/index.js")) {
125
+ if (args[1] === "keys" && args[2] === "add") {
126
+ this.hotcellKeys.set(args[3], options.input ?? "");
127
+ const home = options.env?.HOTCELL_HOME ?? "";
128
+ this.files.set(`${home}/keys.json`, { content: "{}", mode: 0o600 });
129
+ return ok("stored\n");
130
+ }
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
+ return ok();
134
+ }
135
+ if (command.endsWith("/node") && args[0]?.endsWith("autopilot-ai/bin/autopilot.mjs")) {
136
+ const sub = args.slice(1, 4).join(" ");
137
+ if (sub.startsWith("aster token create")) {
138
+ const id = `cred_${this.credentials.length + 1}`;
139
+ const token = `aster_${"t".repeat(30)}${this.credentials.length + 1}`;
140
+ const expiresAt = Date.parse("2027-10-28T10:00:00Z");
141
+ this.credentials.push({ id, name: args[args.indexOf("--name") + 1], status: "active", token, expiresAt });
142
+ return ok(`${JSON.stringify({ credentialId: id, workspaceIds: ["default"], scopes: [], createdAt: 0, expiresAt, token })}\n`);
143
+ }
144
+ if (sub.startsWith("aster token list")) {
145
+ return ok(`${JSON.stringify(this.credentials.map(({ token: _token, ...entry }) => ({ ...entry, scopes: [] })))}\n`);
146
+ }
147
+ if (sub.startsWith("aster token revoke")) {
148
+ const entry = this.credentials.find((credential) => credential.id === args.at(-1));
149
+ if (entry)
150
+ entry.status = "revoked";
151
+ return ok();
152
+ }
153
+ return ok();
154
+ }
155
+ switch (command) {
156
+ case "id": return ok(`${this.uid}\n`);
157
+ case "sw_vers": return ok("15.1\n");
158
+ case "df": return ok(`Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/disk3s5 971350180 1000 ${this.diskFreeKb} 10% /System/Volumes/Data\n`);
159
+ case "git":
160
+ if (!this.git)
161
+ return { code: 127, stdout: "", stderr: "not found" };
162
+ if (args[0] === "--version")
163
+ return ok("git version 2.50.1\n");
164
+ if (args.includes("init"))
165
+ this.dirs.set(`${args[1]}/.git`, undefined);
166
+ return ok();
167
+ case "sh":
168
+ if (line === "-c command -v docker")
169
+ return ok("/usr/local/bin/docker\n");
170
+ return ok();
171
+ case "ip": return this.bridgeIp ? ok(`4: docker0 inet ${this.bridgeIp}/16 brd 172.17.255.255 scope global docker0\n`) : fail("no docker0");
172
+ case "systemctl":
173
+ if (args[1] === "is-system-running")
174
+ return this.systemdUser ? ok("running\n") : fail("Failed to connect to bus");
175
+ if (args[1] === "restart")
176
+ this.running.add(args[2]);
177
+ if (args[1] === "disable" && args[2] === "--now")
178
+ this.running.delete(args[3]);
179
+ if (args[1] === "is-active")
180
+ return ok(this.running.has(args[2]) ? "active\n" : "inactive\n");
181
+ return ok();
182
+ case "launchctl": {
183
+ if (args[0] === "bootstrap") {
184
+ const plist = this.files.get(args[2])?.content ?? "";
185
+ const label = plist.match(/<key>Label<\/key>\s*<string>([^<]+)<\/string>/)?.[1];
186
+ if (!label)
187
+ return fail("Bootstrap failed: 5: Input/output error");
188
+ this.running.add(label);
189
+ return ok();
190
+ }
191
+ if (args[0] === "bootout") {
192
+ const label = args[1].split("/").at(-1);
193
+ return this.running.delete(label) ? ok() : fail("Boot-out failed: 3: No such process");
194
+ }
195
+ if (args[0] === "print") {
196
+ const label = args[1].split("/").at(-1);
197
+ return this.running.has(label) ? ok(`${args[1]} = {\n\tstate = running\n\tpid = 4242\n}\n`) : fail("Could not find service");
198
+ }
199
+ return ok();
200
+ }
201
+ case "open":
202
+ if (this.dockerApp)
203
+ this.docker = true;
204
+ return ok();
205
+ case "npm":
206
+ if (args[0] === "view")
207
+ return ok(args[1] === "hotcell" ? "0.1.30\n" : "2.6.1\n");
208
+ if (args[0] === "install" && args[1] === "--prefix") {
209
+ for (const pkg of ["autopilot-ai", "hotcell"])
210
+ this.files.set(`${args[2]}/node_modules/${pkg}/package.json`, { content: "{}", mode: 0o644 });
211
+ this.files.set(`${args[2]}/node_modules/hotcell/dist/index.js`, { content: "", mode: 0o755 });
212
+ this.dirs.set(args[2], 0o700);
213
+ }
214
+ return ok();
215
+ case "ln":
216
+ for (const [path, file] of [...this.files])
217
+ if (path.startsWith(`${args[1]}/`))
218
+ this.files.set(path.replace(args[1], args[2]), file);
219
+ return ok();
220
+ case "ls":
221
+ return ok([...this.dirs.keys()].filter((dir) => dir.startsWith(`${args[1]}/`) && !dir.slice(args[1].length + 1).includes("/")).map((dir) => dir.split("/").at(-1)).join("\n"));
222
+ case "mktemp": return ok("/tmp/tmp.build\n");
223
+ case "wc": return ok("0\n");
224
+ case "/usr/local/bin/docker": {
225
+ if (args[0] === "context")
226
+ return ok(`unix://${this.home}/.docker/run/docker.sock\n`);
227
+ if (args[0] === "info") {
228
+ if (this.dockerDenied)
229
+ return { code: 1, stdout: "", stderr: "permission denied while trying to connect to the Docker daemon socket" };
230
+ if (!this.docker)
231
+ return fail("Cannot connect to the Docker daemon");
232
+ return args[2].startsWith("{{.ServerVersion}}") ? ok(`29.5.2|Docker Desktop|${this.dockerMemBytes}|8\n`) : ok(`${this.dockerMemBytes}|8\n`);
233
+ }
234
+ if (!this.docker)
235
+ return fail("Cannot connect to the Docker daemon");
236
+ if (args[0] === "image" && args[1] === "inspect")
237
+ return this.images.has(args[2]) ? ok("[]") : fail("no such image");
238
+ if (args[0] === "image" && args[1] === "rm") {
239
+ this.images.delete(args[2]);
240
+ return ok();
241
+ }
242
+ if (args[0] === "build") {
243
+ this.images.add(args[args.indexOf("-t") + 1]);
244
+ return ok();
245
+ }
246
+ if (args[0] === "run")
247
+ return ok(this.reachHost && this.ourPorts().size > 0 ? "404\n" : "0\n");
248
+ if (args[0] === "ps") {
249
+ const filters = args.flatMap((arg, index) => args[index - 1] === "--filter" ? [arg.replace(/^label=/, "")] : []);
250
+ return ok(this.containers.filter((entry) => filters.every((filter) => entry.labels.includes(filter))).map((entry) => entry.id).join("\n"));
251
+ }
252
+ if (args[0] === "rm") {
253
+ for (const id of args.slice(2))
254
+ this.containers.splice(this.containers.findIndex((entry) => entry.id === id), 1);
255
+ return ok();
256
+ }
257
+ return ok();
258
+ }
259
+ default:
260
+ return ok();
261
+ }
262
+ }
263
+ }
@@ -42,6 +42,28 @@ export async function ownerLink(system, url) {
42
42
  const link = result.stdout.trim().split("\n").at(-1);
43
43
  return result.code === 0 && link?.startsWith("http") ? link : undefined;
44
44
  }
45
+ /** How long a laptop's credential lasts; re-running the laptop's setup pairs it again. */
46
+ export const LAPTOP_CREDENTIAL_DAYS = 90;
47
+ /**
48
+ * Creates a credential for a laptop that set this server up over SSH, so it never has to
49
+ * show a code and wait for approval: being root here already proves it's the owner's.
50
+ * The token comes back in the command's output, so that output stays out of the log.
51
+ */
52
+ export async function createLaptopCredential(system, name, url) {
53
+ const result = await autopilotServer(system, [
54
+ "aster", "token", "create", "--name", name, "--workspace", "default", "--expires-in-days", String(LAPTOP_CREDENTIAL_DAYS), "--json",
55
+ ], { secretOutput: true });
56
+ if (result.code !== 0) {
57
+ throw new Error(`Couldn't create a credential for the laptop${result.stderr.trim() ? `: ${result.stderr.trim().split("\n").slice(-3).join(" ")}` : ""}; run dl up to update this server.`);
58
+ }
59
+ const created = parseJson(result);
60
+ const { credentialId, expiresAt, token } = created ?? {};
61
+ // Never echo the output when it doesn't parse: it may hold the token.
62
+ if (typeof token !== "string" || !token || typeof credentialId !== "string" || typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) {
63
+ throw new Error("Autopilot created a credential but its answer wasn't what dl expects; run dl up to update this server.");
64
+ }
65
+ return { token, credentialId, name, expiresAt: new Date(expiresAt).toISOString(), url };
66
+ }
45
67
  function parseJson(result) {
46
68
  if (result.code !== 0)
47
69
  return undefined;
package/dist/state.js CHANGED
@@ -1,4 +1,9 @@
1
1
  import { PATHS } from "./layout.js";
2
+ export const ACCESS_MODES = ["tailscale", "loopback", "ssh"];
3
+ /** How people reach this server, in words (the address alone means little for a tunnel). */
4
+ export function describeAccess(access) {
5
+ return access.mode === "ssh" ? "over SSH from your laptop" : access.url;
6
+ }
2
7
  export async function readState(system) {
3
8
  const raw = await system.readFile(PATHS.state);
4
9
  if (!raw)
package/dist/ui.js CHANGED
@@ -201,6 +201,39 @@ export class PlainUi {
201
201
  warn(text) { this.write(`!! ${text}\n`); }
202
202
  finish() { }
203
203
  }
204
+ /**
205
+ * For the laptop that runs `dl up` over SSH and draws the progress itself: one JSON object
206
+ * per line (NDJSON) on stdout, nothing else there. Nothing is asked in this mode, so a
207
+ * rejected answer is only a warning, and a prompt is never shown.
208
+ */
209
+ export class JsonUi {
210
+ write;
211
+ interactive = false;
212
+ constructor(write = (text) => process.stdout.write(text)) {
213
+ this.write = write;
214
+ }
215
+ title(text) { this.emit({ event: "title", text }); }
216
+ line(status, label, detail) { this.emit({ event: "line", status, label, detail }); }
217
+ plan(steps) { this.emit({ event: "plan", steps: steps.map(({ id, label }) => ({ id, label })) }); }
218
+ start(id, detail) { this.emit({ event: "start", id, detail }); }
219
+ done(id, detail) { this.emit({ event: "done", id, detail }); }
220
+ fail(id, message) { this.emit({ event: "fail", id, message }); }
221
+ questions(intro) { this.emit({ event: "questions", text: intro }); }
222
+ prompt(label) { return label; }
223
+ answered(label, detail) { this.emit({ event: "answered", label, detail }); }
224
+ rejected(message) { this.emit({ event: "warn", text: message }); }
225
+ ready(headline, items, extra = {}) {
226
+ this.emit({ event: "ready", headline, items: items.map(({ label, value }) => ({ label, value })), about: extra.about, footer: extra.footer });
227
+ }
228
+ warn(text) { this.emit({ event: "warn", text }); }
229
+ /** The last event of a failed run (not part of Ui: the CLI reports failures, not `up`). */
230
+ error(message) { this.emit({ event: "error", message }); }
231
+ finish() { }
232
+ emit(event) {
233
+ // JSON.stringify drops undefined fields and escapes newlines, so each event is one line.
234
+ this.write(`${JSON.stringify(event)}\n`);
235
+ }
236
+ }
204
237
  /** Records events for tests. */
205
238
  export class RecordingUi {
206
239
  interactive = false;
package/dist/up.js CHANGED
@@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto";
2
2
  import { hostname } from "node:os";
3
3
  import { agentCapacity, detectHost, preflightProblems } from "./detect.js";
4
4
  import { AUTOPILOT_PORT, CELL_MEMORY_MB, GITHUB_PROXY_PORT, HOTCELL_EGRESS_PORT, HOTCELL_PORT, PATHS, SERVICES, USERS, AUTO_UPGRADES, NODE_AUTO_UPDATES, asterConfig, autoUpgrades, autopilotBin, autopilotEnv, autopilotUnit, MOTD_SCRIPT, cellDockerfile, cellImage, githubEnv, hotcellBin, hotcellEnv, hotcellUnit, metadataBlockUnit, motdScript, nodeAutoUpdates, parseEnvFile, } from "./layout.js";
5
- import { readState, writeState } from "./state.js";
5
+ import { ACCESS_MODES, readState, writeState } from "./state.js";
6
6
  import { PlainUi } from "./ui.js";
7
7
  import { ownerLink } from "./server-api.js";
8
8
  const DRY_RUN_BRIDGE_IP = "172.17.0.1";
@@ -26,9 +26,9 @@ export async function up(system, options) {
26
26
  ui.line("done", "Checked this machine", `${facts.os.prettyName.replace(/ LTS$/, "")} · ${facts.cpus} CPU · ${(facts.memoryMb / 1024).toFixed(1)} GB · ${facts.diskFreeGb} GB free`);
27
27
  // Every question comes first, so the long part runs unattended.
28
28
  const previous = await readState(system);
29
- const access = accessMode(env, previous);
29
+ const access = accessMode(options.access, env, previous);
30
30
  const check = env.DL_SKIP_CREDENTIAL_CHECK === "1" ? undefined : fetchImpl;
31
- const answers = await collectAnswers(system, ui, options.prompter, env, check, facts, access);
31
+ const answers = await collectAnswers(system, ui, options.prompter, given(env, options.answers), check, facts, access);
32
32
  ui.plan([
33
33
  ...(access === "tailscale" ? [{ id: "tailnet", label: "Joining your tailnet" }] : []),
34
34
  { id: "docker", label: "Setting up Docker" },
@@ -121,6 +121,10 @@ export async function up(system, options) {
121
121
  return { value: apiKey, detail: "sandboxes and keys ready" };
122
122
  });
123
123
  const url = await step("access", async () => {
124
+ // Moving off Tailscale: stop publishing on the tailnet, so the only way in is the new one.
125
+ if (access !== "tailscale" && previous?.access.mode === "tailscale") {
126
+ await system.run("tailscale", ["serve", "--https=443", `http://127.0.0.1:${AUTOPILOT_PORT}`, "off"], { allowFailure: true });
127
+ }
124
128
  const address = access === "tailscale" ? await serveOverTailscale(system) : `http://127.0.0.1:${AUTOPILOT_PORT}`;
125
129
  await system.writeFile(PATHS.autopilotEnv, autopilotEnv({ hotcellApiKey, image, bridgeIp, publicUrl: address }), { mode: 0o640, owner: `root:${USERS.autopilot}`, secret: true });
126
130
  await system.run("systemctl", ["enable", SERVICES.autopilot]);
@@ -141,8 +145,15 @@ export async function up(system, options) {
141
145
  access: { mode: access, url },
142
146
  };
143
147
  await writeState(system, state);
144
- const link = await ownerLink(system, url);
145
148
  const capacity = agentCapacity(facts, CELL_MEMORY_MB);
149
+ if (access === "ssh") {
150
+ // The laptop set this up, pairs itself (`dl pair`), and prints its own summary, so there's
151
+ // no browser link to make: a one-time owner link would only be a secret in its output.
152
+ await installLoginStatus(system);
153
+ ui.ready("Ready", [{ label: "On your laptop, from inside a GitHub repository", value: 'dl run "fix the flaky checkout test"' }]);
154
+ return { url, ownerLink: undefined, capacity, state };
155
+ }
156
+ const link = await ownerLink(system, url);
146
157
  await installLoginStatus(system);
147
158
  ui.ready("Ready", nextSteps({ url, ownerLink: link, access }), {
148
159
  about: [
@@ -171,46 +182,116 @@ async function installLoginStatus(system) {
171
182
  return;
172
183
  await system.writeFile(MOTD_SCRIPT, motdScript(), { mode: 0o755 });
173
184
  }
174
- function accessMode(env, previous) {
185
+ /** Checks an access mode named by `--access` or DL_ACCESS. */
186
+ export function parseAccessMode(value, source = "--access") {
187
+ const mode = ACCESS_MODES.find((entry) => entry === value);
188
+ if (mode)
189
+ return mode;
190
+ throw new Error(`${source} must be ${ACCESS_MODES.join(", ").replace(/, ([^,]+)$/, " or $1")} (got "${value}"); an own domain and the instant address are coming.`);
191
+ }
192
+ /** The flag wins, then DL_ACCESS, then what the last `up` chose; Tailscale on a fresh machine. */
193
+ function accessMode(flag, env, previous) {
194
+ if (flag)
195
+ return flag;
175
196
  const requested = env.DL_ACCESS?.trim();
176
197
  if (requested === undefined || requested === "")
177
198
  return previous?.access.mode ?? "tailscale";
178
- if (requested === "tailscale" || requested === "loopback")
179
- return requested;
180
- throw new Error(`DL_ACCESS must be tailscale or loopback (got "${requested}"); an own domain and the instant address are coming.`);
199
+ return parseAccessMode(requested, "DL_ACCESS");
181
200
  }
182
- async function collectAnswers(system, ui, prompter, env, fetchImpl, facts, access) {
201
+ /**
202
+ * `dl up --needs`: the answers a real `up` would require on this machine, without asking for
203
+ * or changing anything (it only reads files and runs read-only probes). The laptop asks for
204
+ * exactly these before it starts the install. Answers in DL_* variables don't count: the
205
+ * laptop's `up` takes its answers from stdin.
206
+ */
207
+ export async function needs(system, options = {}) {
208
+ const access = accessMode(options.access, options.env ?? process.env, await readState(system));
209
+ const missing = await missingAnswers(system, await detectHost(system), access);
210
+ return ["tailscale", "openrouter", "github"].filter((need) => missing[need]);
211
+ }
212
+ /**
213
+ * Which answers this machine still lacks: the model key unless Hotcell has one, a GitHub
214
+ * token unless its env file has one, a Tailscale key only for Tailscale access off a tailnet.
215
+ */
216
+ async function missingAnswers(system, facts, access) {
183
217
  const tailscaleRunning = access !== "tailscale" || (facts.tailscale && await tailscaleIsUp(system));
184
- const hasModelKey = await hotcellHasKey(system, "openrouter");
185
- const hasGitHub = Boolean(parseEnvFile(await system.readFile(PATHS.githubEnv)).GITHUB_TOKEN);
218
+ return {
219
+ tailscale: !tailscaleRunning,
220
+ openrouter: !(await hotcellHasKey(system, "openrouter")),
221
+ github: !parseEnvFile(await system.readFile(PATHS.githubEnv)).GITHUB_TOKEN,
222
+ };
223
+ }
224
+ function given(env, provided) {
225
+ if (provided) {
226
+ return { tailscaleAuthKey: provided.tailscaleAuthKey, openRouterKey: provided.openrouterKey, githubToken: provided.githubToken, source: "from your laptop", final: true };
227
+ }
228
+ return { tailscaleAuthKey: env.DL_TAILSCALE_AUTH_KEY, openRouterKey: env.DL_OPENROUTER_API_KEY, githubToken: env.DL_GITHUB_TOKEN, source: "from the environment", final: false };
229
+ }
230
+ /**
231
+ * The answers from `--answers-stdin`. Parse errors never quote the input: it holds keys.
232
+ * Unknown fields are ignored, so a newer laptop can send more than this server knows.
233
+ */
234
+ export function parseProvidedAnswers(text) {
235
+ let parsed;
236
+ try {
237
+ parsed = JSON.parse(text);
238
+ }
239
+ catch {
240
+ throw new Error("The answers on stdin aren't valid JSON; expected one object like {\"openrouterKey\": \"sk-or-…\"}.");
241
+ }
242
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
243
+ throw new Error("The answers on stdin must be one JSON object.");
244
+ const record = parsed;
245
+ const answers = {};
246
+ for (const key of ["openrouterKey", "githubToken", "tailscaleAuthKey"]) {
247
+ const value = record[key];
248
+ if (value === undefined || value === null)
249
+ continue;
250
+ if (typeof value !== "string")
251
+ throw new Error(`The answer "${key}" on stdin must be a string.`);
252
+ answers[key] = value;
253
+ }
254
+ return answers;
255
+ }
256
+ async function collectAnswers(system, ui, prompter, provided, fetchImpl, facts, access) {
257
+ const missing = await missingAnswers(system, facts, access);
186
258
  if (system.dryRun) {
187
259
  return {
188
- tailscaleAuthKey: tailscaleRunning ? undefined : "<asked>",
189
- openRouterKey: hasModelKey ? undefined : "<asked>",
190
- githubToken: hasGitHub ? undefined : "<asked>",
260
+ tailscaleAuthKey: missing.tailscale ? "<asked>" : undefined,
261
+ openRouterKey: missing.openrouter ? "<asked>" : undefined,
262
+ githubToken: missing.github ? "<asked>" : undefined,
191
263
  };
192
264
  }
193
- const typed = [
194
- !tailscaleRunning && !env.DL_TAILSCALE_AUTH_KEY,
195
- !hasModelKey && !env.DL_OPENROUTER_API_KEY,
196
- !hasGitHub && !env.DL_GITHUB_TOKEN,
265
+ const typed = provided.final ? 0 : [
266
+ missing.tailscale && !provided.tailscaleAuthKey,
267
+ missing.openrouter && !provided.openRouterKey,
268
+ missing.github && !provided.githubToken,
197
269
  ].filter(Boolean).length;
198
270
  if (typed > 0)
199
271
  ui.questions(`${typed === 1 ? "One key" : `${typed} keys`}, then it runs on its own. Typing is hidden.`);
200
- const tailscaleAuthKey = tailscaleRunning ? undefined : await askValidated(ui, env.DL_TAILSCALE_AUTH_KEY, "Tailscale auth key", () => prompter.secret(ui.prompt("Tailscale auth key", "login.tailscale.com/admin/settings/keys")), async (value) => /^tskey-[A-Za-z0-9-]+$/.test(value) ? {} : { problem: "That doesn't look like a Tailscale auth key (tskey-…)." });
201
- const openRouterKey = hasModelKey ? undefined : await askValidated(ui, env.DL_OPENROUTER_API_KEY, "OpenRouter API key", () => prompter.secret(ui.prompt("OpenRouter API key", "stays in Hotcell; sandboxes and laptops never see it")), async (value) => ({ ...await validateOpenRouterKey(value, fetchImpl).then((problem) => problem ? { problem } : { detail: "kept in Hotcell" }) }));
202
- const githubToken = hasGitHub ? undefined : await askValidated(ui, env.DL_GITHUB_TOKEN, "GitHub token", () => prompter.secret(ui.prompt("GitHub token", "fine-grained · Contents + Pull requests read/write")), (value) => validateGitHubToken(value, fetchImpl));
272
+ // With final answers there's no one to ask: a missing one fails before anything changes.
273
+ const asking = (ask) => provided.final ? undefined : ask;
274
+ const tailscaleAuthKey = !missing.tailscale ? undefined : await askValidated(ui, provided.tailscaleAuthKey, provided.source, "Tailscale auth key", asking(() => prompter.secret(ui.prompt("Tailscale auth key", "login.tailscale.com/admin/settings/keys"))), async (value) => /^tskey-[A-Za-z0-9-]+$/.test(value) ? {} : { problem: "That doesn't look like a Tailscale auth key (tskey-…)." });
275
+ const openRouterKey = !missing.openrouter ? undefined : await askValidated(ui, provided.openRouterKey, provided.source, "OpenRouter API key", asking(() => prompter.secret(ui.prompt("OpenRouter API key", "stays in Hotcell; sandboxes and laptops never see it"))), async (value) => ({ ...await validateOpenRouterKey(value, fetchImpl).then((problem) => problem ? { problem } : { detail: "kept in Hotcell" }) }));
276
+ const githubToken = !missing.github ? undefined : await askValidated(ui, provided.githubToken, provided.source, "GitHub token", asking(() => prompter.secret(ui.prompt("GitHub token", "fine-grained · Contents + Pull requests read/write"))), (value) => validateGitHubToken(value, fetchImpl));
203
277
  return { tailscaleAuthKey, openRouterKey, githubToken };
204
278
  }
205
- /** Takes an env answer as-is (failing fast if invalid) or asks up to three times. */
206
- async function askValidated(ui, fromEnv, label, ask, validate) {
279
+ /**
280
+ * Takes a given answer as-is (failing fast if invalid) or asks up to three times. Without a
281
+ * way to ask (answers from the laptop), a missing answer is a failure.
282
+ */
283
+ async function askValidated(ui, fromEnv, source, label, ask, validate) {
207
284
  if (fromEnv) {
208
285
  const { problem, detail } = await validate(fromEnv.trim());
209
286
  if (problem)
210
287
  throw new Error(problem);
211
- ui.line("done", label, detail ? `${detail} · from the environment` : "from the environment");
288
+ ui.line("done", label, detail ? `${detail} · ${source}` : source);
212
289
  return fromEnv.trim();
213
290
  }
291
+ if (!ask) {
292
+ ui.line("failed", label, "not given");
293
+ throw new Error(`This server needs ${/^[AEIOU]/.test(label) ? "an" : "a"} ${label} and none was given ${source.replace(/^from/, "by")}.`);
294
+ }
214
295
  for (let attempt = 1;; attempt++) {
215
296
  const value = (await ask()).trim();
216
297
  const { problem, detail } = value ? await validate(value) : { problem: "An answer is required.", detail: undefined };
package/install.sh CHANGED
@@ -8,6 +8,7 @@
8
8
  # key, and sets up Hotcell + Autopilot as services. Safe to run again.
9
9
  #
10
10
  # Unattended: export DL_TAILSCALE_AUTH_KEY, DL_OPENROUTER_API_KEY and DL_GITHUB_TOKEN first.
11
+ # Install only (the laptop's setup, which then runs `dl up` itself over SSH): DL_INSTALL_ONLY=1.
11
12
  # Pin a version: DL_VERSION=0.1.0. Install from a local tarball: DL_PACKAGE=/path/to.tgz.
12
13
  set -eu
13
14
 
@@ -27,7 +28,7 @@ step() {
27
28
  if [ -n "$FANCY" ]; then printf ' \033[36m⠋\033[0m %s' "$1"; else say ".. $1"; fi
28
29
  }
29
30
  step_done() {
30
- if [ -n "$FANCY" ]; then printf '\r\033[2K \033[32m✓\033[0m %-33s\033[2m%s\033[0m\n' "$1" "${2:-}"; else say "ok $1${2:+: $2}"; fi
31
+ if [ -n "$FANCY" ]; then printf '\r\033[2K \033[32m✓\033[0m %-33s\033[2m%s\033[0m\n' "$1" "${2:-}"; else say "ok ${STEP:-$1}${2:+: $2}"; fi
31
32
  }
32
33
 
33
34
  # Quiet steps: output goes to a log that's shown only if the step fails.
@@ -136,6 +137,12 @@ main() {
136
137
  quietly "installing $package" as_root npm install -g --no-audit --no-fund --loglevel=error "$package"
137
138
  step_done "Got the installer" "dl $(dl --version 2>/dev/null || echo "")"
138
139
 
140
+ # The laptop's setup stops here: it asks for the keys itself and runs `dl up` next,
141
+ # with its own progress display.
142
+ if [ "${DL_INSTALL_ONLY:-}" = "1" ]; then
143
+ return 0
144
+ fi
145
+
139
146
  # Prompts read the terminal, not this piped script. Test by opening it: the device
140
147
  # node exists even when there's no controlling terminal.
141
148
  # dl up continues the same screen, so it skips its own title.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "directionallylocal",
3
- "version": "0.1.2",
3
+ "version": "0.3.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": {