directionallylocal 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2,14 +2,14 @@ import { readFileSync } from "node:fs";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { parseArgs } from "node:util";
4
4
  import { doctor, formatChecks } from "./doctor.js";
5
- import { briefStatus, connectLaptop, laptopsCommand, logsCommand, serverHome, serverRunsCommand } from "./home.js";
5
+ import { briefStatus, connectLaptop, laptopsCommand, logsCommand, pairLaptop, serverHome, serverRunsCommand } from "./home.js";
6
6
  import { cancel, connect, describeError, laptopHome, pull, run, runs, servers, watch } from "./laptop/commands.js";
7
7
  import { TtyPrompter } from "./prompt.js";
8
8
  import { readState } from "./state.js";
9
9
  import { DryRunSystem, RealSystem } from "./system.js";
10
10
  import { uninstall } from "./uninstall.js";
11
- import { PlainUi, TerminalUi, palette } from "./ui.js";
12
- import { up } from "./up.js";
11
+ import { JsonUi, PlainUi, TerminalUi, palette } from "./ui.js";
12
+ import { needs, parseAccessMode, parseProvidedAnswers, up } from "./up.js";
13
13
  const HELP = `Directionally Local: background coding agents on your own server.
14
14
  Send a task from your laptop; it runs in its own sandbox and comes back as a pull request.
15
15
  (dl is short for directionallylocal; both work.)
@@ -46,7 +46,14 @@ Unattended installs answer the questions with environment variables:
46
46
 
47
47
  Advanced:
48
48
  DL_ACCESS=loopback don't expose the server; reach it over an SSH tunnel
49
+ (or --access tailscale|loopback|ssh; ssh is the laptop's setup)
49
50
  DL_SKIP_CREDENTIAL_CHECK=1 don't check the keys with OpenRouter and GitHub (offline installs)
51
+
52
+ Used by the laptop's setup (over SSH, as root):
53
+ dl up --access ssh --answers-stdin --events json
54
+ install with the keys as one JSON object on stdin; progress as JSON lines
55
+ dl up --needs [--access ssh] which keys dl up would still need, as JSON; changes nothing
56
+ dl pair --name NAME --json a credential for that laptop, as JSON
50
57
  `;
51
58
  export async function main(argv, io = {
52
59
  out: (text) => process.stdout.write(text),
@@ -73,6 +80,10 @@ export async function main(argv, io = {
73
80
  repo: { type: "string" },
74
81
  detach: { type: "boolean" },
75
82
  json: { type: "boolean" },
83
+ access: { type: "string" },
84
+ "answers-stdin": { type: "boolean" },
85
+ events: { type: "string" },
86
+ needs: { type: "boolean" },
76
87
  },
77
88
  });
78
89
  }
@@ -90,27 +101,84 @@ export async function main(argv, io = {
90
101
  io.out(HELP);
91
102
  return 0;
92
103
  }
93
- const log = (line) => io.out(`${line}\n`);
104
+ if (values.events !== undefined && values.events !== "json") {
105
+ io.err(`--events takes json (got "${values.events}").\n\n${HELP}`);
106
+ return 2;
107
+ }
108
+ let access;
109
+ try {
110
+ access = values.access === undefined ? undefined : parseAccessMode(values.access);
111
+ }
112
+ catch (error) {
113
+ io.err(`${error.message}\n`);
114
+ return 2;
115
+ }
116
+ // `--events json`: stdout carries only the laptop's JSON events; everything else goes to
117
+ // stderr, and other tools' output to the install log, as with the live checklist.
118
+ const json = command === "up" && values.events === "json" && !values.needs;
119
+ const log = (line) => (json ? io.err : io.out)(`${line}\n`);
94
120
  // The live checklist needs a terminal; a dry run prints its plan line by line instead.
95
121
  const terminal = options.terminal ?? (process.stdout.isTTY === true);
96
- const live = terminal && command === "up" && !values["dry-run"];
97
- const ui = live ? new TerminalUi(io.out) : new PlainUi(io.out);
98
- const colorOn = terminal && !process.env.NO_COLOR && process.env.TERM !== "dumb";
122
+ // --needs only looks: it mustn't even start a new install log.
123
+ const live = terminal && command === "up" && !values["dry-run"] && !values.needs && !json;
124
+ const jsonUi = json ? new JsonUi(io.out) : undefined;
125
+ const ui = jsonUi ?? (live ? new TerminalUi(io.out) : new PlainUi(io.out));
126
+ const colorOn = terminal && !json && !process.env.NO_COLOR && process.env.TERM !== "dumb";
99
127
  const colors = palette(colorOn);
100
128
  const laptopIO = { out: io.out, err: io.err, color: colorOn };
101
- const system = values["dry-run"] ? new DryRunSystem(log) : new RealSystem(log, live ? { outputLog: UP_LOG } : {});
129
+ const logged = (live || json) && !values["dry-run"];
130
+ let system;
131
+ try {
132
+ system = values["dry-run"] ? new DryRunSystem(log) : new RealSystem(log, logged ? { outputLog: UP_LOG } : {});
133
+ }
134
+ catch (error) {
135
+ // Opening the install log is the first write, so this is where a non-root `up` stops.
136
+ const message = `Couldn't open ${UP_LOG}: ${describeError(error)}. Run it as root: sudo dl up`;
137
+ if (jsonUi)
138
+ jsonUi.error(message);
139
+ else
140
+ io.err(`\n ${palette(live && !process.env.NO_COLOR).red("✗")} ${message}\n`);
141
+ return 1;
142
+ }
102
143
  try {
103
144
  switch (command) {
104
- case "up":
145
+ case "up": {
146
+ if (values.needs) {
147
+ io.out(`${JSON.stringify({ needs: await needs(system, access ? { access } : {}) })}\n`);
148
+ return 0;
149
+ }
150
+ // Read before anything else happens, so a bad answer fails before any change.
151
+ const answers = values["answers-stdin"] ? parseProvidedAnswers(await readAnswers(options.stdin ?? process.stdin)) : undefined;
105
152
  if (system.dryRun)
106
153
  log("Dry run: nothing on this machine will change.\n");
107
154
  await up(system, {
108
155
  prompter: new TtyPrompter(),
109
156
  ui,
157
+ ...(access ? { access } : {}),
158
+ ...(answers ? { answers } : {}),
110
159
  ...(values["autopilot-version"] ? { autopilotVersion: values["autopilot-version"] } : {}),
111
160
  ...(values["hotcell-version"] ? { hotcellVersion: values["hotcell-version"] } : {}),
112
161
  });
113
162
  return 0;
163
+ }
164
+ case "pair": {
165
+ // Only the laptop's setup runs this, and it reads the JSON; there's no human form.
166
+ if (!values.json || !values.name?.trim()) {
167
+ io.err(` Usage: dl pair --name NAME --json ${colors.dim("(run by the laptop's setup, over SSH)")}\n`);
168
+ return 2;
169
+ }
170
+ const state = await readState(system);
171
+ if (!state) {
172
+ io.err(" Directionally Local isn't installed on this server yet; run dl up first.\n");
173
+ return 1;
174
+ }
175
+ if (!isRoot()) {
176
+ io.err(" Run it as root: sudo dl pair --name NAME --json\n");
177
+ return 1;
178
+ }
179
+ await pairLaptop(system, state, values.name, io.out);
180
+ return 0;
181
+ }
114
182
  case "doctor": {
115
183
  const checks = await doctor(system);
116
184
  log(formatChecks(checks, terminal && !process.env.NO_COLOR));
@@ -222,6 +290,12 @@ export async function main(argv, io = {
222
290
  }
223
291
  catch (error) {
224
292
  ui.finish();
293
+ if (jsonUi) {
294
+ // The event is what the laptop shows; the log's tail on stderr is for whoever looks.
295
+ jsonUi.error(describeError(error));
296
+ io.err(await logTail(UP_LOG, palette(false)));
297
+ return 1;
298
+ }
225
299
  const c = palette(live && !process.env.NO_COLOR);
226
300
  io.err(`\n ${c.red("✗")} ${describeError(error).replace(/\n/g, "\n ")}\n`);
227
301
  if (live)
@@ -242,6 +316,33 @@ const NOT_A_SERVER = (c) => `
242
316
  dl connect https://your-server
243
317
 
244
318
  `;
319
+ /**
320
+ * The `--answers-stdin` JSON: everything up to the end of stdin, or sooner once what came
321
+ * so far parses (a laptop that keeps the SSH channel open still gets going). Keys arrive
322
+ * this way so they're never in argv, the environment, or a file.
323
+ */
324
+ export async function readAnswers(stdin) {
325
+ if (stdin.isTTY)
326
+ throw new Error("--answers-stdin reads one JSON object from a pipe, not a terminal.");
327
+ let text = "";
328
+ for await (const chunk of stdin) {
329
+ text += typeof chunk === "string" ? chunk : chunk.toString("utf8");
330
+ if (text.length > 64 * 1024)
331
+ throw new Error("The answers on stdin are too long; expected one small JSON object.");
332
+ if (text.includes("\n") && parses(text))
333
+ return text;
334
+ }
335
+ return text;
336
+ }
337
+ function parses(text) {
338
+ try {
339
+ JSON.parse(text);
340
+ return true;
341
+ }
342
+ catch {
343
+ return false;
344
+ }
345
+ }
245
346
  /** Ctrl-C aborts the command (a running task keeps going on the server). */
246
347
  async function interruptible(work) {
247
348
  const controller = new AbortController();
package/dist/doctor.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { detectHost, MIN_DISK_GB } from "./detect.js";
2
2
  import { nodeAdvisory } from "./node-advisory.js";
3
3
  import { AUTOPILOT_PORT, AUTO_UPGRADES, HOTCELL_PORT, NODE_AUTO_UPDATES, PATHS, SERVICES, parseEnvFile } from "./layout.js";
4
- import { readState } from "./state.js";
4
+ import { describeAccess, readState } from "./state.js";
5
5
  import { palette } from "./ui.js";
6
6
  /** `directionallylocal doctor`: every moving part, with a fix for each failure. */
7
7
  export async function doctor(system) {
@@ -9,7 +9,7 @@ export async function doctor(system) {
9
9
  const add = (check) => checks.push(check);
10
10
  const state = await readState(system);
11
11
  add(state
12
- ? { name: "installed", ok: true, detail: `${state.release}, ${state.access.mode} at ${state.access.url}` }
12
+ ? { name: "installed", ok: true, detail: `${state.release}, ${state.access.mode === "ssh" ? describeAccess(state.access) : `${state.access.mode} at ${state.access.url}`}` }
13
13
  : { name: "installed", ok: false, detail: "not installed", fix: "sudo dl up" });
14
14
  const facts = await detectHost(system);
15
15
  const node = nodeAdvisory(facts.nodeVersion ?? "");
@@ -49,6 +49,7 @@ export async function doctor(system) {
49
49
  add({ name: "github", ok: github, detail: github ? "token configured" : "no token; runs can't push or open PRs", fix: "sudo dl up (asks for it)" });
50
50
  const autopilot = (await system.probe("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", `http://127.0.0.1:${AUTOPILOT_PORT}/api/v1/health`])).stdout.trim();
51
51
  add({ name: "agent server API", ok: autopilot === "401", detail: autopilot === "401" ? "up (refuses requests without a token)" : `HTTP ${autopilot || "none"}`, fix: "dl logs autopilot · then dl up" });
52
+ // Loopback and SSH access have nothing published to check: the tunnel is the laptop's.
52
53
  if (state?.access.mode === "tailscale") {
53
54
  const serve = await system.probe("tailscale", ["serve", "status"]);
54
55
  const serving = serve.code === 0 && serve.stdout.includes(`127.0.0.1:${AUTOPILOT_PORT}`);
package/dist/home.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { agentCapacity, detectHost } from "./detect.js";
2
2
  import { AUTOPILOT_PORT, CELL_MEMORY_MB, SERVICES } from "./layout.js";
3
- import { approvePairing, listLaptops, listServerRuns, ownerLink, pendingPairings, revokeLaptop } from "./server-api.js";
4
- import { readState } from "./state.js";
3
+ import { approvePairing, createLaptopCredential, listLaptops, listServerRuns, ownerLink, pendingPairings, revokeLaptop } from "./server-api.js";
4
+ import { describeAccess, readState } from "./state.js";
5
5
  import { palette } from "./ui.js";
6
6
  import { nextSteps } from "./up.js";
7
7
  const ABOUT = "Background coding agents on your own server: send a task from your laptop, get a pull request back.";
@@ -29,7 +29,7 @@ export async function serverHome(system, state, write, c) {
29
29
  const running = runs?.filter((run) => run.active).length ?? 0;
30
30
  const doneThisWeek = runs?.filter((run) => !run.active && Date.parse(run.createdAt) >= week).length ?? 0;
31
31
  const row = (label, value, hint = "") => ` ${c.dim(label.padEnd(11))}${value}${hint ? ` ${c.dim(hint)}` : ""}\n`;
32
- write(`\n ${c.bold("Directionally Local")} ${verdict} ${c.dim(state.access.url)}\n\n ${ABOUT}\n\n`);
32
+ write(`\n ${c.bold("Directionally Local")} ${verdict} ${c.dim(describeAccess(state.access))}\n\n ${ABOUT}\n\n`);
33
33
  write(row("Laptops", laptops === undefined ? c.dim("unknown (run dl up to update)") : laptops.length === 0 ? "none connected yet" : `${laptops.length} connected · ${laptops.map((laptop) => laptop.name).join(", ")}`, "dl laptops"));
34
34
  write(row("Runs", runs === undefined ? c.dim("unknown (run dl up to update)") : `${running} running · ${doneThisWeek} this week`, "dl runs"));
35
35
  write(row("Capacity", `~${agentCapacity(facts, CELL_MEMORY_MB)} agents at once (estimate)`));
@@ -41,7 +41,7 @@ export async function serverHome(system, state, write, c) {
41
41
  /** `dl status --brief`: one line, for the login message. Never slow, never fails loudly. */
42
42
  export async function briefStatus(system, state, write, c) {
43
43
  const status = await health(system);
44
- write(`\n ${c.bold("Directionally Local")} ${status.ok ? c.green("● healthy") : c.red(`● ${status.problems.join(", ")}`)} ${c.dim(state.access.url)}\n ${c.dim("Type")} dl ${c.dim("for what this is and what's running.")}\n\n`);
44
+ write(`\n ${c.bold("Directionally Local")} ${status.ok ? c.green("● healthy") : c.red(`● ${status.problems.join(", ")}`)} ${c.dim(describeAccess(state.access))}\n ${c.dim("Type")} dl ${c.dim("for what this is and what's running.")}\n\n`);
45
45
  }
46
46
  /**
47
47
  * `dl connect-laptop [CODE]`: approve a laptop's code, or explain how to connect one and
@@ -65,6 +65,18 @@ export async function connectLaptop(system, state, code, write, c) {
65
65
  write(`\n ${c.dim("The laptop shows a code; approve it in that browser, or here with")} dl connect-laptop <code>\n\n`);
66
66
  return 0;
67
67
  }
68
+ /**
69
+ * `dl pair --name NAME --json`: a credential for the laptop that's setting this server up
70
+ * over SSH, printed as one JSON line for it to keep. The token appears nowhere else.
71
+ */
72
+ export async function pairLaptop(system, state, name, write) {
73
+ const trimmed = name.trim();
74
+ if (!trimmed || trimmed.length > 100 || /[\u0000-\u001f\u007f]/.test(trimmed)) {
75
+ throw new Error("--name must be 1 to 100 printable characters, e.g. the laptop's hostname.");
76
+ }
77
+ const paired = await createLaptopCredential(system, trimmed, state.access.url);
78
+ write(`${JSON.stringify({ token: paired.token, credentialId: paired.credentialId, name: paired.name, expiresAt: paired.expiresAt, url: paired.url })}\n`);
79
+ }
68
80
  export async function laptopsCommand(system, args, write, c) {
69
81
  const laptops = await listLaptops(system);
70
82
  if (laptops === undefined) {
@@ -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,21 +8,24 @@
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
 
14
15
  say() { printf '%s\n' "$*"; }
15
16
  fail() { printf '\n directionallylocal: %s\n' "$*" >&2; exit 1; }
16
17
 
17
- # The same look as `dl up`: a step shows "·" while it runs and becomes "✓" when done.
18
- # Without a terminal (or with NO_COLOR) it's plain lines.
18
+ # The same look as `dl up`: a running step has a spinner and a timer, and becomes "✓"
19
+ # when done. Without a terminal (or with NO_COLOR) it's plain lines.
19
20
  if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-}" != "dumb" ]; then
20
21
  FANCY=1
21
22
  else
22
23
  FANCY=""
23
24
  fi
25
+ STEP=""
24
26
  step() {
25
- if [ -n "$FANCY" ]; then printf ' \033[36m·\033[0m %s' "$1"; else say ".. $1"; fi
27
+ STEP=$1
28
+ if [ -n "$FANCY" ]; then printf ' \033[36m⠋\033[0m %s' "$1"; else say ".. $1"; fi
26
29
  }
27
30
  step_done() {
28
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 $1${2:+: $2}"; fi
@@ -40,11 +43,43 @@ as_root() {
40
43
  fi
41
44
  }
42
45
 
46
+ # The spinner frame for tick $1 (POSIX sh has no arrays, and multibyte cut isn't portable).
47
+ frame() {
48
+ case $(($1 % 10)) in
49
+ 0) printf '⠋' ;; 1) printf '⠙' ;; 2) printf '⠹' ;; 3) printf '⠸' ;; 4) printf '⠼' ;;
50
+ 5) printf '⠴' ;; 6) printf '⠦' ;; 7) printf '⠧' ;; 8) printf '⠇' ;; *) printf '⠏' ;;
51
+ esac
52
+ }
53
+
54
+ # On failure, show only this step's own output, not the earlier steps' in the same log.
55
+ show_step_log() {
56
+ tail -n "+$(($1 + 1))" "$LOG" | tail -n 15 | while IFS= read -r line; do
57
+ if [ -n "$FANCY" ]; then printf ' \033[2m%s\033[0m\n' "$line" >&2; else printf ' %s\n' "$line" >&2; fi
58
+ done
59
+ }
60
+
43
61
  quietly() {
44
62
  what=$1
45
63
  shift
46
- if ! "$@" >>"$LOG" 2>&1; then
47
- tail -n 20 "$LOG" >&2
64
+ from=$(wc -l <"$LOG")
65
+ if [ -z "$FANCY" ]; then
66
+ "$@" >>"$LOG" 2>&1 || { show_step_log "$from"; fail "$what failed"; }
67
+ return 0
68
+ fi
69
+ # Run in the background and animate the current step until it finishes.
70
+ "$@" >>"$LOG" 2>&1 &
71
+ pid=$!
72
+ started=$(date +%s)
73
+ tick=0
74
+ while kill -0 "$pid" 2>/dev/null; do
75
+ elapsed=$(($(date +%s) - started))
76
+ printf '\r\033[2K \033[36m%s\033[0m %-33s\033[2m%d:%02d\033[0m' "$(frame "$tick")" "$STEP" $((elapsed / 60)) $((elapsed % 60))
77
+ tick=$((tick + 1))
78
+ sleep 0.1
79
+ done
80
+ if ! wait "$pid"; then
81
+ printf '\r\033[2K \033[31m✗\033[0m %s\n\n' "$STEP"
82
+ show_step_log "$from"
48
83
  fail "$what failed"
49
84
  fi
50
85
  }
@@ -102,6 +137,12 @@ main() {
102
137
  quietly "installing $package" as_root npm install -g --no-audit --no-fund --loglevel=error "$package"
103
138
  step_done "Got the installer" "dl $(dl --version 2>/dev/null || echo "")"
104
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
+
105
146
  # Prompts read the terminal, not this piped script. Test by opening it: the device
106
147
  # node exists even when there's no controlling terminal.
107
148
  # dl up continues the same screen, so it skips its own title.
package/package.json CHANGED
@@ -1,7 +1,15 @@
1
1
  {
2
2
  "name": "directionallylocal",
3
- "version": "0.1.0",
4
- "description": "One-line server install for Directionally Local: Hotcell sandboxes and Autopilot, ready for autopilot --on",
3
+ "version": "0.2.0",
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
+ "homepage": "https://directionallylocal.com",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/directionally-local/installer.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/directionally-local/installer/issues"
12
+ },
5
13
  "type": "module",
6
14
  "bin": {
7
15
  "directionallylocal": "bin/directionallylocal.mjs",