directionallylocal 0.2.0 → 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,280 @@
1
+ import { agentCapacity } from "../detect.js";
2
+ import { CELL_MEMORY_MB } from "../layout.js";
3
+ import { shellQuote } from "./ssh.js";
4
+ /**
5
+ * Installing on the user's server from the laptop, over SSH, through the server-side
6
+ * commands' machine-readable modes:
7
+ * 1. bootstrap install.sh with DL_INSTALL_ONLY=1 (plain `.. step` / `ok step: detail` lines)
8
+ * 2. needs `dl up --needs --access ssh` (one JSON line: which keys it still lacks)
9
+ * 3. install `dl up --access ssh --answers-stdin --events json` (keys on stdin, NDJSON out)
10
+ * 4. pair `dl pair --name … --json` (a credential for this laptop)
11
+ * The server's checklist is replayed into the laptop's own Ui, so it animates here.
12
+ */
13
+ export const INSTALL_URL = "https://directionallylocal.run";
14
+ /** Non-root users run the server commands with sudo; checkServer made sure it needs no password. */
15
+ function sudo(facts) {
16
+ return facts.root ? "" : "sudo -n ";
17
+ }
18
+ export function bootstrapCommand(facts, installUrl = INSTALL_URL) {
19
+ const run = facts.root ? "DL_INSTALL_ONLY=1 sh /tmp/dl-install.sh" : "sudo -n env DL_INSTALL_ONLY=1 sh /tmp/dl-install.sh";
20
+ return `curl -fsSL ${shellQuote(installUrl)} -o /tmp/dl-install.sh && ${run}`;
21
+ }
22
+ /**
23
+ * Renders install.sh's plain lines as a checklist. Steps aren't known up front, so each new
24
+ * one re-plans the list on a terminal (and restores the finished steps' ticks).
25
+ */
26
+ export class BootstrapRenderer {
27
+ ui;
28
+ steps = [];
29
+ active;
30
+ output = [];
31
+ failure;
32
+ constructor(ui) {
33
+ this.ui = ui;
34
+ }
35
+ stdout(line) {
36
+ const started = /^\.\. (.+)$/.exec(line);
37
+ if (started)
38
+ return this.start(started[1].trim());
39
+ const finished = /^ok ([^:]+?)(?:: (.*))?$/.exec(line);
40
+ if (finished)
41
+ return this.done(finished[1].trim(), finished[2]?.trim());
42
+ this.keep(line);
43
+ }
44
+ stderr(line) {
45
+ const failed = /^directionallylocal: (.+)$/.exec(line);
46
+ if (failed) {
47
+ this.failure = failed[1].trim();
48
+ return;
49
+ }
50
+ this.keep(line);
51
+ }
52
+ /** After install.sh exits: fails the running step and throws with its output when it failed. */
53
+ finish(code) {
54
+ if (code === 0) {
55
+ if (this.active)
56
+ this.done(this.active);
57
+ this.ui.finish();
58
+ return;
59
+ }
60
+ const what = this.failure ?? `the install script exited with ${code}`;
61
+ const id = this.active ?? this.steps.at(-1)?.id;
62
+ if (id)
63
+ this.ui.fail(id, what);
64
+ this.ui.finish();
65
+ const tail = this.output.slice(-15).map((line) => ` ${line}`).join("\n");
66
+ throw new Error(`Installing dl on the server failed: ${what}${tail ? `\n${tail}` : ""}`);
67
+ }
68
+ start(label) {
69
+ if (this.active && this.active !== label)
70
+ this.done(this.active);
71
+ let step = this.steps.find((entry) => entry.id === label);
72
+ if (!step) {
73
+ step = { id: label, label, done: false };
74
+ this.steps.push(step);
75
+ this.ui.plan(this.steps.map(({ id, label: text }) => ({ id, label: text })));
76
+ // plan() shows every step as pending again; on a terminal, put the finished ones back.
77
+ if (this.ui.interactive)
78
+ for (const entry of this.steps)
79
+ if (entry.done)
80
+ this.ui.done(entry.id, entry.detail);
81
+ }
82
+ this.active = label;
83
+ this.ui.start(label);
84
+ }
85
+ done(label, detail) {
86
+ if (!this.steps.some((entry) => entry.id === label))
87
+ this.start(label);
88
+ const step = this.steps.find((entry) => entry.id === label);
89
+ step.done = true;
90
+ if (detail !== undefined)
91
+ step.detail = detail;
92
+ if (this.active === label)
93
+ this.active = undefined;
94
+ this.ui.done(label, detail);
95
+ }
96
+ keep(line) {
97
+ if (!line.trim())
98
+ return;
99
+ this.output.push(line);
100
+ if (this.output.length > 200)
101
+ this.output.shift();
102
+ }
103
+ }
104
+ export async function bootstrap(access, facts, ssh, ui, installUrl) {
105
+ const renderer = new BootstrapRenderer(ui);
106
+ const result = await ssh(access, bootstrapCommand(facts, installUrl), {
107
+ onStdoutLine: (line) => renderer.stdout(line),
108
+ onStderrLine: (line) => renderer.stderr(line),
109
+ });
110
+ renderer.finish(result.code);
111
+ }
112
+ /** Which keys the server still lacks, so a configured server is asked nothing. */
113
+ export async function serverNeeds(access, facts, ssh) {
114
+ const result = await ssh(access, `${sudo(facts)}dl up --needs --access ssh`);
115
+ const parsed = lastJson(result.stdout);
116
+ if (result.code !== 0 || !parsed || !Array.isArray(parsed.needs)) {
117
+ throw new Error(`Couldn't ask the server what it needs: ${lastLine(result.stderr) || lastLine(result.stdout) || `exit ${result.code}`}`);
118
+ }
119
+ return parsed.needs.filter((need) => need === "openrouter" || need === "github" || need === "tailscale");
120
+ }
121
+ /**
122
+ * Replays `dl up --events json` into Ui calls. The server's own title and ready screen are
123
+ * skipped (the laptop prints its own); "answered" becomes a plain ✓ line, since on a
124
+ * terminal answered() erases the prompt above it, and there is none here.
125
+ */
126
+ export class EventReplayer {
127
+ newUi;
128
+ ui;
129
+ planned = false;
130
+ ids = new Set();
131
+ output = [];
132
+ error;
133
+ capacity;
134
+ constructor(newUi) {
135
+ this.newUi = newUi;
136
+ this.ui = newUi();
137
+ }
138
+ line(text) {
139
+ let event;
140
+ try {
141
+ const parsed = JSON.parse(text);
142
+ if (!parsed || typeof parsed !== "object" || typeof parsed.event !== "string")
143
+ throw new Error("not an event");
144
+ event = parsed;
145
+ }
146
+ catch {
147
+ if (text.trim())
148
+ this.output.push(text);
149
+ return;
150
+ }
151
+ const str = (key) => typeof event[key] === "string" ? event[key] : undefined;
152
+ switch (event.event) {
153
+ case "line":
154
+ case "answered": {
155
+ // A line after a checklist starts a new one below it, or the redraw would overwrite it.
156
+ this.fresh();
157
+ const status = event.event === "answered" ? "done" : str("status");
158
+ this.capacity ??= capacityFromMachine(str("label"), str("detail"));
159
+ this.ui.line(status === "done" || status === "failed" ? status : "info", str("label") ?? "", ...optional(str("detail")));
160
+ return;
161
+ }
162
+ case "plan": {
163
+ if (this.planned)
164
+ this.fresh();
165
+ const steps = Array.isArray(event.steps) ? event.steps
166
+ .filter((step) => typeof step.id === "string")
167
+ .map((step) => ({ id: step.id, label: typeof step.label === "string" ? step.label : step.id })) : [];
168
+ for (const step of steps)
169
+ this.ids.add(step.id);
170
+ this.ui.plan(steps);
171
+ this.planned = true;
172
+ return;
173
+ }
174
+ case "start":
175
+ case "done":
176
+ case "fail": {
177
+ const id = str("id");
178
+ if (!id || !this.ids.has(id))
179
+ return;
180
+ if (event.event === "start")
181
+ this.ui.start(id, ...optional(str("detail")));
182
+ else if (event.event === "done")
183
+ this.ui.done(id, ...optional(str("detail")));
184
+ else
185
+ this.ui.fail(id, str("message") ?? "failed");
186
+ return;
187
+ }
188
+ case "warn":
189
+ if (str("text"))
190
+ this.ui.warn(str("text"));
191
+ return;
192
+ case "ready":
193
+ this.capacity = capacityFrom(event) ?? this.capacity;
194
+ return;
195
+ case "error":
196
+ this.error = str("message") ?? "the install failed";
197
+ return;
198
+ default:
199
+ // "title", and anything newer than this laptop knows about.
200
+ return;
201
+ }
202
+ }
203
+ /** Recent lines that weren't events (the server's own messages), for an error. */
204
+ tail() {
205
+ return this.output.slice(-15);
206
+ }
207
+ finish() {
208
+ this.ui.finish();
209
+ }
210
+ fresh() {
211
+ if (!this.planned)
212
+ return;
213
+ this.ui.finish();
214
+ this.ui = this.newUi();
215
+ this.planned = false;
216
+ this.ids.clear();
217
+ }
218
+ }
219
+ function optional(value) {
220
+ return value === undefined ? [] : [value];
221
+ }
222
+ /**
223
+ * In SSH mode the server's ready screen has no estimate, so it's worked out here the way
224
+ * `dl up` does, from its machine check ("Ubuntu 24.04 · 4 CPU · 7.5 GB · …").
225
+ */
226
+ function capacityFromMachine(label, detail) {
227
+ if (label !== "Checked this machine" || !detail)
228
+ return undefined;
229
+ const cpus = /(\d+) CPU/.exec(detail);
230
+ const memory = /([\d.]+) GB(?! free)/.exec(detail);
231
+ if (!cpus || !memory)
232
+ return undefined;
233
+ return agentCapacity({ cpus: Number(cpus[1]), memoryMb: Math.round(Number(memory[1]) * 1024) }, CELL_MEMORY_MB);
234
+ }
235
+ /** "fits ~6 agents at once" anywhere in the server's ready screen (non-SSH servers print it). */
236
+ function capacityFrom(event) {
237
+ const match = /fits ~(\d+)/.exec(JSON.stringify(event));
238
+ return match ? Number(match[1]) : undefined;
239
+ }
240
+ /** Runs the install, keys on stdin as one JSON object. Resolves with the capacity estimate, if given. */
241
+ export async function installOnServer(access, facts, ssh, answers, newUi) {
242
+ const replay = new EventReplayer(newUi);
243
+ const stderr = [];
244
+ const result = await ssh(access, `${sudo(facts)}dl up --access ssh --answers-stdin --events json`, {
245
+ input: `${JSON.stringify(answers)}\n`,
246
+ onStdoutLine: (line) => replay.line(line),
247
+ onStderrLine: (line) => { if (line.trim())
248
+ stderr.push(line); },
249
+ });
250
+ replay.finish();
251
+ if (result.code !== 0 || replay.error) {
252
+ const tail = [...replay.tail(), ...stderr.slice(-15)].map((line) => ` ${line}`).join("\n");
253
+ throw new Error(`Setting up the server failed: ${replay.error ?? `dl up exited with ${result.code}`}${tail ? `\n${tail}` : ""}\n Fix it and run dl setup again; it picks up where it stopped.`);
254
+ }
255
+ return replay.capacity === undefined ? {} : { capacity: replay.capacity };
256
+ }
257
+ /** A credential for this laptop, issued on the server (no browser approval needed: SSH already proved who we are). */
258
+ export async function pairLaptop(access, facts, ssh, name) {
259
+ const result = await ssh(access, `${sudo(facts)}dl pair --name ${shellQuote(name)} --json`);
260
+ const parsed = lastJson(result.stdout);
261
+ const fields = ["token", "credentialId", "name", "expiresAt", "url"];
262
+ if (result.code !== 0 || !parsed || !fields.every((key) => typeof parsed[key] === "string")) {
263
+ throw new Error(`Couldn't connect this laptop to the server: ${lastLine(result.stderr) || `dl pair exited with ${result.code}`}`);
264
+ }
265
+ return parsed;
266
+ }
267
+ function lastJson(text) {
268
+ for (const line of text.trim().split("\n").reverse()) {
269
+ try {
270
+ return JSON.parse(line);
271
+ }
272
+ catch {
273
+ // keep looking: a banner or warning may come before the JSON
274
+ }
275
+ }
276
+ return undefined;
277
+ }
278
+ function lastLine(text) {
279
+ return text.trim().split("\n").at(-1)?.trim() ?? "";
280
+ }
@@ -0,0 +1,253 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readdir, readFile } from "node:fs/promises";
3
+ import { homedir, hostname } from "node:os";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { TtyPrompter } from "../prompt.js";
7
+ import { palette, PlainUi, TerminalUi } from "../ui.js";
8
+ import { loadRemotes, normalizeServerUrl, REMOTE_NAME_RE, remotesPath, saveRemote } from "./config.js";
9
+ import { confirm, gatherKeys } from "./credentials.js";
10
+ import { bootstrap, INSTALL_URL, installOnServer, pairLaptop, serverNeeds } from "./remote-install.js";
11
+ import { checkServer, describeHost, discoverKeys, hostOf, parseSshConfig, parseTarget, realSshExec, SshAuthError } from "./ssh.js";
12
+ import { openRemote, withConnections } from "./tunnel.js";
13
+ const RUN_EXAMPLE = 'dl run "fix the flaky checkout test"';
14
+ export async function setupWizard(deps) {
15
+ const c = palette(deps.color);
16
+ deps.write(`\n ${c.bold("Directionally Local")}\n Background coding agents. Where should they run?\n\n`);
17
+ deps.write(` ${c.cyan("1")} ${"On this computer".padEnd(22)} ${c.dim("start now · nothing to rent · pauses when this computer sleeps")}\n`);
18
+ deps.write(` ${c.cyan("2")} ${"On my own server".padEnd(22)} ${c.dim("keeps going when your laptop is closed · needs SSH access")}\n\n`);
19
+ const ui = deps.newUi();
20
+ let where = await pick(ui, deps.prompter, "Where", "1 or 2 · Enter for 1", { "1": "local", "2": "server" });
21
+ ui.answered("Where", where === "local" ? "On this computer" : "On my own server");
22
+ if (where === "local" && !deps.localSetup) {
23
+ ui.line("info", "On this computer", "coming soon");
24
+ if (!(await confirm(ui, deps.prompter, "Set up on your own server instead?"))) {
25
+ deps.write(`\n ${c.dim("Nothing changed. Run")} dl setup ${c.dim("when you're ready.")}\n\n`);
26
+ return undefined;
27
+ }
28
+ ui.answered("Where", "On my own server");
29
+ where = "server";
30
+ }
31
+ if (where === "local") {
32
+ const keysUi = deps.newUi();
33
+ const answers = await gatherKeys(["github", "openrouter"], { ui: keysUi, prompter: deps.prompter, sources: deps.sources });
34
+ keysUi.finish();
35
+ const remote = await deps.localSetup({ ui: deps.newUi(), prompter: deps.prompter, answers: { openrouterKey: answers.openrouterKey, githubToken: answers.githubToken } });
36
+ await saveRemote(remote, deps.remotesFile ?? remotesPath());
37
+ finishScreen(deps.newUi(), undefined);
38
+ return remote;
39
+ }
40
+ return setUpServer(deps);
41
+ }
42
+ async function setUpServer(deps) {
43
+ const c = palette(deps.color);
44
+ const home = deps.sources.home;
45
+ const sshDir = join(home, ".ssh");
46
+ const hosts = parseSshConfig(await deps.sources.readFile(join(sshDir, "config")) ?? "", home);
47
+ // 1. Which server, and can we get in?
48
+ let { access, display } = await chooseServer(deps, hosts);
49
+ let facts;
50
+ for (let attempt = 0; !facts; attempt++) {
51
+ const ui = deps.newUi();
52
+ ui.plan([{ id: "ssh", label: "Server" }]);
53
+ ui.start("ssh", display);
54
+ try {
55
+ facts = await checkServer(access, deps.ssh, display);
56
+ }
57
+ catch (error) {
58
+ ui.fail("ssh", error instanceof SshAuthError ? "SSH key not accepted" : "can't set up here");
59
+ ui.finish();
60
+ if (!(error instanceof SshAuthError) || attempt >= 2)
61
+ throw explainAuth(error, access);
62
+ access = { ...access, identityFile: await chooseKey(deps, sshDir, access) };
63
+ continue;
64
+ }
65
+ display = `${facts.user}@${display.replace(/^[^@]*@/, "")}`;
66
+ ui.done("ssh", `${display} ${facts.os} · ${facts.arch}`);
67
+ ui.finish();
68
+ if (facts.newHostKey)
69
+ deps.newUi().line("info", "New server", "first connection: trusted its host key (accept-new)");
70
+ }
71
+ // 2. Install dl there, then ask only for the keys it still lacks.
72
+ await bootstrap(access, facts, deps.ssh, deps.newUi(), deps.installUrl);
73
+ const needs = await serverNeeds(access, facts, deps.ssh);
74
+ if (needs.includes("tailscale"))
75
+ throw new Error("The server asked for Tailscale, but this setup reaches it over SSH; update dl on the server and run dl setup again.");
76
+ const keysUi = deps.newUi();
77
+ const answers = await gatherKeys(needs, { ui: keysUi, prompter: deps.prompter, sources: deps.sources });
78
+ if (needs.length === 0)
79
+ keysUi.line("done", "Keys", "already on the server");
80
+ keysUi.finish();
81
+ // 3. The install itself, its checklist replayed here.
82
+ const { capacity } = await installOnServer(access, facts, deps.ssh, answers, deps.newUi);
83
+ // 4. A credential for this laptop, checked through the tunnel every command will use. Each
84
+ // `dl pair` makes a new credential on the server, so a re-run keeps the saved one if it works.
85
+ const remotesFile = deps.remotesFile ?? remotesPath();
86
+ const saved = (await loadRemotes(remotesFile)).find((entry) => sameServer(entry, access));
87
+ let remote;
88
+ if (saved && await stillWorks({ ...saved, ssh: access }, deps.open))
89
+ remote = { ...saved, ssh: access };
90
+ if (!remote) {
91
+ const pairing = await pairLaptop(access, facts, deps.ssh, `${laptopName(deps.hostname)} (dl)`);
92
+ remote = {
93
+ name: saved?.name ?? await remoteName(access, remotesFile),
94
+ url: normalizeServerUrl(pairing.url),
95
+ token: pairing.token,
96
+ credentialId: pairing.credentialId,
97
+ expiresAt: pairing.expiresAt,
98
+ addedAt: (deps.now?.() ?? new Date()).toISOString(),
99
+ ssh: access,
100
+ };
101
+ }
102
+ const connectedUi = deps.newUi();
103
+ const paired = remote;
104
+ await withConnections(deps.open, async (connections) => {
105
+ try {
106
+ await (await connections.client(paired)).health();
107
+ }
108
+ catch (error) {
109
+ connectedUi.line("failed", "This laptop", "couldn't reach the server's API through SSH");
110
+ throw new Error(`The server is set up, but this laptop couldn't reach it through an SSH tunnel: ${error instanceof Error ? error.message : String(error)}`);
111
+ }
112
+ });
113
+ await saveRemote(remote, remotesFile);
114
+ connectedUi.line("done", "This laptop", `connected as ${remote.name}`);
115
+ connectedUi.finish();
116
+ finishScreen(deps.newUi(), capacity);
117
+ return remote;
118
+ }
119
+ async function chooseServer(deps, hosts) {
120
+ const c = palette(deps.color);
121
+ const ui = deps.newUi();
122
+ if (hosts.length > 0) {
123
+ deps.write(`\n ${c.dim("Your servers in ~/.ssh/config:")}\n`);
124
+ hosts.forEach((host, index) => deps.write(` ${c.cyan(String(index + 1))} ${host.alias.padEnd(22)} ${c.dim(describeHost(host))}\n`));
125
+ deps.write(` ${c.cyan(String(hosts.length + 1))} Another server\n\n`);
126
+ }
127
+ else {
128
+ deps.write("\n");
129
+ }
130
+ const hint = hosts.length > 0 ? `1-${hosts.length + 1}, or user@host[:port]` : "user@host[:port], e.g. root@203.0.113.10";
131
+ for (let attempt = 1;; attempt++) {
132
+ let answer = (await deps.prompter.text(ui.prompt("Server", hint))).trim();
133
+ const index = /^\d+$/.test(answer) ? Number(answer) - 1 : -1;
134
+ const host = hosts[index];
135
+ if (host) {
136
+ ui.answered("SSH", host.alias);
137
+ return {
138
+ access: { target: host.alias },
139
+ display: `${host.user ? `${host.user}@` : ""}${host.hostName ?? host.alias}${host.port && host.port !== 22 ? `:${host.port}` : ""}`,
140
+ };
141
+ }
142
+ if (index === hosts.length && hosts.length > 0) {
143
+ ui.answered("SSH", "another server");
144
+ answer = (await deps.prompter.text(ui.prompt("Server address", "user@host[:port], e.g. root@203.0.113.10"))).trim();
145
+ }
146
+ try {
147
+ const access = parseTarget(answer);
148
+ ui.answered("SSH", access.target);
149
+ return { access, display: `${access.target}${access.port ? `:${access.port}` : ""}` };
150
+ }
151
+ catch (error) {
152
+ if (attempt >= 3)
153
+ throw error;
154
+ ui.rejected(`${error.message}. Try again.`);
155
+ }
156
+ }
157
+ }
158
+ /** The server refused the default keys: offer the ones in ~/.ssh and retry with -i. */
159
+ async function chooseKey(deps, sshDir, access) {
160
+ const c = palette(deps.color);
161
+ const keys = discoverKeys(sshDir, await deps.sources.listDir(sshDir)).filter((key) => key !== access.identityFile);
162
+ if (keys.length === 0)
163
+ throw explainAuth(new SshAuthError(access, "no other keys in ~/.ssh"), access);
164
+ deps.write(`\n ${c.dim("Which key does this server accept?")}\n`);
165
+ keys.forEach((key, index) => deps.write(` ${c.cyan(String(index + 1))} ${key.replace(deps.sources.home, "~")}\n`));
166
+ deps.write("\n");
167
+ const ui = deps.newUi();
168
+ const answer = (await deps.prompter.text(ui.prompt("SSH key", `1-${keys.length}`))).trim();
169
+ const key = keys[(Number(answer) || 1) - 1] ?? keys[0];
170
+ ui.answered("SSH key", key.replace(deps.sources.home, "~"));
171
+ return key;
172
+ }
173
+ function explainAuth(error, access) {
174
+ if (!(error instanceof SshAuthError))
175
+ return error instanceof Error ? error : new Error(String(error));
176
+ return new Error([
177
+ error.message,
178
+ " Add this computer's public key to the server (it asks for the password once):",
179
+ ` ssh-copy-id ${access.port ? `-p ${access.port} ` : ""}${access.target}`,
180
+ " If your key has a passphrase, load it first with: ssh-add",
181
+ ].join("\n"));
182
+ }
183
+ function sameServer(remote, access) {
184
+ return remote.ssh?.target === access.target && remote.ssh.port === access.port;
185
+ }
186
+ async function stillWorks(remote, open) {
187
+ return withConnections(open, async (connections) => {
188
+ await (await connections.client(remote)).health();
189
+ return true;
190
+ }).catch(() => false);
191
+ }
192
+ /** box (from an ssh alias) or 203-0-113-10, unique unless it's this same server again. */
193
+ async function remoteName(access, remotesFile) {
194
+ const host = hostOf(access.target).toLowerCase();
195
+ const label = /^\d+(\.\d+){3}$/.test(host) || host.includes(":") ? host.replace(/[.:]/g, "-") : host.split(".")[0] ?? "server";
196
+ const base = label.replace(/[^a-z0-9-]/g, "-").replace(/^-+/, "").slice(0, 36) || "server";
197
+ const name = REMOTE_NAME_RE.test(base) ? base : "server";
198
+ const remotes = await loadRemotes(remotesFile);
199
+ for (let n = 1;; n++) {
200
+ const candidate = n === 1 ? name : `${name}-${n}`;
201
+ const existing = remotes.find((remote) => remote.name === candidate);
202
+ if (!existing || sameServer(existing, access))
203
+ return candidate;
204
+ }
205
+ }
206
+ function finishScreen(ui, capacity) {
207
+ ui.ready(capacity ? `Ready · fits ~${capacity} agents at once (estimate)` : "Ready", [
208
+ { label: "From any GitHub repository", value: RUN_EXAMPLE },
209
+ { label: "See progress, then check out the result", value: "dl runs\ndl pull ID" },
210
+ ]);
211
+ }
212
+ function laptopName(name) {
213
+ return name.replace(/\.local$/, "").replace(/[^\p{L}\p{N} ._'()@-]/gu, "-").slice(0, 40) || "laptop";
214
+ }
215
+ async function pick(ui, prompter, label, hint, choices) {
216
+ const values = Object.values(choices);
217
+ for (let attempt = 1;; attempt++) {
218
+ const answer = (await prompter.text(ui.prompt(label, hint))).trim();
219
+ const choice = answer === "" ? values[0] : choices[answer];
220
+ if (choice)
221
+ return choice;
222
+ if (attempt >= 3)
223
+ throw new Error(`Pick one of ${Object.keys(choices).join(", ")}`);
224
+ ui.rejected(`Type ${Object.keys(choices).join(" or ")}.`);
225
+ }
226
+ }
227
+ /** `dl setup` with the real terminal, ssh, gh, and filesystem. */
228
+ export async function setup(options) {
229
+ const { io, terminal } = options;
230
+ const color = io.color === true;
231
+ const execFileAsync = promisify(execFile);
232
+ const home = homedir();
233
+ await setupWizard({
234
+ newUi: () => terminal ? new TerminalUi(io.out, undefined, color) : new PlainUi(io.out),
235
+ prompter: new TtyPrompter(),
236
+ write: io.out,
237
+ color,
238
+ ssh: realSshExec,
239
+ open: openRemote,
240
+ sources: {
241
+ env: process.env,
242
+ home,
243
+ readFile: (path) => readFile(path, "utf8").catch(() => undefined),
244
+ listDir: (path) => readdir(path).catch(() => []),
245
+ gh: () => execFileAsync("gh", ["auth", "token"], { timeout: 5000 }).then(({ stdout }) => stdout.trim() || undefined, () => undefined),
246
+ fetch: globalThis.fetch,
247
+ },
248
+ hostname: hostname(),
249
+ installUrl: process.env.DL_INSTALL_URL || INSTALL_URL,
250
+ ...(options.localSetup ? { localSetup: options.localSetup } : {}),
251
+ });
252
+ return 0;
253
+ }