directionallylocal 0.2.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.
- package/dist/cli.js +49 -9
- package/dist/keys.js +188 -0
- package/dist/laptop/commands.js +166 -43
- package/dist/laptop/config.js +119 -23
- package/dist/laptop/credentials.js +129 -0
- package/dist/laptop/keys.js +133 -0
- package/dist/laptop/remote-install.js +280 -0
- package/dist/laptop/setup.js +301 -0
- package/dist/laptop/ssh.js +216 -0
- package/dist/laptop/tunnel.js +120 -0
- package/dist/local/check.js +194 -0
- package/dist/local/cli.js +43 -0
- package/dist/local/keys.js +100 -0
- package/dist/local/layout.js +227 -0
- package/dist/local/probes.js +22 -0
- package/dist/local/services.js +68 -0
- package/dist/local/setup.js +472 -0
- package/dist/local/state.js +15 -0
- package/dist/local/test-support/fake-computer.js +280 -0
- package/dist/up.js +6 -4
- package/install.sh +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { access as fileAccess, readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { homedir, hostname } from "node:os";
|
|
4
|
+
import { join, resolve } 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 home = deps.sources.home;
|
|
44
|
+
const sshDir = join(home, ".ssh");
|
|
45
|
+
const hosts = parseSshConfig(await deps.sources.readFile(join(sshDir, "config")) ?? "", home);
|
|
46
|
+
// 1. Which server, and can we get in?
|
|
47
|
+
const chosen = await chooseServer(deps, hosts);
|
|
48
|
+
const { access, facts } = await reachServer(deps, sshDir, deps.identityFile ? { ...chosen.access, identityFile: deps.identityFile } : chosen.access, chosen.display);
|
|
49
|
+
// 2. Install dl there, then ask only for the keys it still lacks.
|
|
50
|
+
await bootstrap(access, facts, deps.ssh, deps.newUi(), deps.installUrl);
|
|
51
|
+
const needs = await serverNeeds(access, facts, deps.ssh);
|
|
52
|
+
if (needs.includes("tailscale"))
|
|
53
|
+
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.");
|
|
54
|
+
const keysUi = deps.newUi();
|
|
55
|
+
const answers = await gatherKeys(needs, { ui: keysUi, prompter: deps.prompter, sources: deps.sources });
|
|
56
|
+
if (needs.length === 0)
|
|
57
|
+
keysUi.line("done", "Keys", "already on the server");
|
|
58
|
+
keysUi.finish();
|
|
59
|
+
// 3. The install itself, its checklist replayed here.
|
|
60
|
+
const { capacity } = await installOnServer(access, facts, deps.ssh, answers, deps.newUi);
|
|
61
|
+
// 4. A credential for this laptop, checked through the tunnel every command will use. Each
|
|
62
|
+
// `dl pair` makes a new credential on the server, so a re-run keeps the saved one if it works.
|
|
63
|
+
const remotesFile = deps.remotesFile ?? remotesPath();
|
|
64
|
+
const saved = (await loadRemotes(remotesFile)).find((entry) => sameServer(entry, access));
|
|
65
|
+
let remote;
|
|
66
|
+
if (saved && await stillWorks({ ...saved, ssh: access }, deps.open))
|
|
67
|
+
remote = { ...saved, ssh: access };
|
|
68
|
+
if (!remote) {
|
|
69
|
+
const pairing = await pairLaptop(access, facts, deps.ssh, `${laptopName(deps.hostname)} (dl)`);
|
|
70
|
+
remote = {
|
|
71
|
+
name: saved?.name ?? await remoteName(access, remotesFile, chosen.alias ?? facts.hostname),
|
|
72
|
+
url: normalizeServerUrl(pairing.url),
|
|
73
|
+
token: pairing.token,
|
|
74
|
+
credentialId: pairing.credentialId,
|
|
75
|
+
expiresAt: pairing.expiresAt,
|
|
76
|
+
addedAt: (deps.now?.() ?? new Date()).toISOString(),
|
|
77
|
+
ssh: access,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const connectedUi = deps.newUi();
|
|
81
|
+
const paired = remote;
|
|
82
|
+
await withConnections(deps.open, async (connections) => {
|
|
83
|
+
try {
|
|
84
|
+
await (await connections.client(paired)).health();
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
connectedUi.line("failed", "This laptop", "couldn't reach the server's API through SSH");
|
|
88
|
+
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)}`);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
await saveRemote(remote, remotesFile);
|
|
92
|
+
connectedUi.line("done", "This laptop", `connected as ${remote.name}`);
|
|
93
|
+
connectedUi.finish();
|
|
94
|
+
finishScreen(deps.newUi(), capacity);
|
|
95
|
+
return remote;
|
|
96
|
+
}
|
|
97
|
+
async function chooseServer(deps, hosts) {
|
|
98
|
+
const c = palette(deps.color);
|
|
99
|
+
const ui = deps.newUi();
|
|
100
|
+
if (hosts.length > 0) {
|
|
101
|
+
deps.write(`\n ${c.dim("Your servers in ~/.ssh/config:")}\n`);
|
|
102
|
+
hosts.forEach((host, index) => deps.write(` ${c.cyan(String(index + 1))} ${host.alias.padEnd(22)} ${c.dim(describeHost(host))}\n`));
|
|
103
|
+
deps.write(` ${c.cyan(String(hosts.length + 1))} Another server\n\n`);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
deps.write("\n");
|
|
107
|
+
}
|
|
108
|
+
const hint = hosts.length > 0 ? `1-${hosts.length + 1}, or user@host[:port]` : "user@host[:port], e.g. root@203.0.113.10";
|
|
109
|
+
for (let attempt = 1;; attempt++) {
|
|
110
|
+
let answer = (await deps.prompter.text(ui.prompt("Server", hint))).trim();
|
|
111
|
+
const index = /^\d+$/.test(answer) ? Number(answer) - 1 : -1;
|
|
112
|
+
const host = hosts[index];
|
|
113
|
+
if (host) {
|
|
114
|
+
ui.answered("SSH", host.alias);
|
|
115
|
+
return {
|
|
116
|
+
alias: host.alias,
|
|
117
|
+
access: { target: host.alias },
|
|
118
|
+
display: `${host.user ? `${host.user}@` : ""}${host.hostName ?? host.alias}${host.port && host.port !== 22 ? `:${host.port}` : ""}`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (index === hosts.length && hosts.length > 0) {
|
|
122
|
+
ui.answered("SSH", "another server");
|
|
123
|
+
answer = (await deps.prompter.text(ui.prompt("Server address", "user@host[:port], e.g. root@203.0.113.10"))).trim();
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const access = parseTarget(answer);
|
|
127
|
+
ui.answered("SSH", access.target);
|
|
128
|
+
return { access, display: `${access.target}${access.port ? `:${access.port}` : ""}` };
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (attempt >= 3)
|
|
132
|
+
throw error;
|
|
133
|
+
ui.rejected(`${error.message}. Try again.`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Connects and checks the server. If it refuses the default keys (ssh's own: the agent and
|
|
139
|
+
* ~/.ssh/id_*), each key pair in ~/.ssh is tried in turn with -i, without asking: auth
|
|
140
|
+
* refusals come back fast, so this takes seconds. Only when none works does it stop, with
|
|
141
|
+
* what to do. A key named with --identity is the only one tried.
|
|
142
|
+
*/
|
|
143
|
+
async function reachServer(deps, sshDir, first, target) {
|
|
144
|
+
const home = deps.sources.home;
|
|
145
|
+
const ui = deps.newUi();
|
|
146
|
+
ui.plan([{ id: "ssh", label: "Server" }]);
|
|
147
|
+
ui.start("ssh", target);
|
|
148
|
+
const tried = [];
|
|
149
|
+
let searching = false;
|
|
150
|
+
let newHostKey = false;
|
|
151
|
+
const attempt = async (access) => {
|
|
152
|
+
try {
|
|
153
|
+
return await checkServer(access, deps.ssh, target);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (error instanceof SshAuthError) {
|
|
157
|
+
newHostKey ||= error.newHostKey;
|
|
158
|
+
if (access.identityFile)
|
|
159
|
+
tried.push(access.identityFile);
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
if (searching)
|
|
163
|
+
ui.plan([{ id: "ssh", label: "Server" }]);
|
|
164
|
+
ui.fail("ssh", "can't set up here");
|
|
165
|
+
ui.finish();
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
let access = first;
|
|
170
|
+
let facts = await attempt(access);
|
|
171
|
+
if (!facts && !first.identityFile) {
|
|
172
|
+
const keys = discoverKeys(sshDir, await deps.sources.listDir(sshDir));
|
|
173
|
+
searching = keys.length > 0;
|
|
174
|
+
if (searching)
|
|
175
|
+
ui.plan([{ id: "keys", label: "Trying your SSH keys…" }]);
|
|
176
|
+
for (const key of keys) {
|
|
177
|
+
access = { ...first, identityFile: key };
|
|
178
|
+
ui.start("keys", tilde(key, home));
|
|
179
|
+
facts = await attempt(access);
|
|
180
|
+
if (facts)
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (searching)
|
|
185
|
+
ui.plan([{ id: "ssh", label: "Server" }]);
|
|
186
|
+
if (!facts) {
|
|
187
|
+
ui.fail("ssh", first.identityFile ? `${tilde(first.identityFile, home)} not accepted` : tried.length > 0 ? "none of your SSH keys was accepted" : "SSH key not accepted");
|
|
188
|
+
ui.finish();
|
|
189
|
+
throw noKeyWorks(first, tried, discoverKeys(sshDir, await deps.sources.listDir(sshDir)), home);
|
|
190
|
+
}
|
|
191
|
+
const display = `${facts.user}@${target.replace(/^[^@]*@/, "")}`;
|
|
192
|
+
ui.done("ssh", access.identityFile ? `${display} with ${tilde(access.identityFile, home)} · ${facts.os} · ${facts.arch}` : `${display} ${facts.os} · ${facts.arch}`);
|
|
193
|
+
ui.finish();
|
|
194
|
+
if (facts.newHostKey || newHostKey)
|
|
195
|
+
deps.newUi().line("info", "New server", "first connection: trusted its host key (accept-new)");
|
|
196
|
+
return { access, facts };
|
|
197
|
+
}
|
|
198
|
+
/** Every key was refused: what to do next, with this server and this laptop's own key filled in. */
|
|
199
|
+
function noKeyWorks(access, tried, keys, home) {
|
|
200
|
+
const port = access.port ? `-p ${access.port} ` : "";
|
|
201
|
+
const key = keys[0] ? tilde(keys[0], home) : undefined;
|
|
202
|
+
const lines = [
|
|
203
|
+
access.identityFile
|
|
204
|
+
? `${access.target} didn't accept ${tilde(access.identityFile, home)}.`
|
|
205
|
+
: `${access.target} didn't accept this computer's SSH keys (${["the default ones", ...tried.map((file) => tilde(file, home))].join(", ")}).`,
|
|
206
|
+
" Add your public key to the server (it asks for the password once):",
|
|
207
|
+
...(key ? [] : [" ssh-keygen -t ed25519"]),
|
|
208
|
+
` ssh-copy-id -i ${key ?? "~/.ssh/id_ed25519"}.pub ${port}${access.target}`,
|
|
209
|
+
" If your key has a passphrase, load it first with: ssh-add",
|
|
210
|
+
" Or name the key this server takes: dl setup --identity ~/.ssh/KEY",
|
|
211
|
+
];
|
|
212
|
+
return new Error(lines.join("\n"));
|
|
213
|
+
}
|
|
214
|
+
function tilde(path, home) {
|
|
215
|
+
return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
|
|
216
|
+
}
|
|
217
|
+
function sameServer(remote, access) {
|
|
218
|
+
return remote.ssh?.target === access.target && remote.ssh.port === access.port;
|
|
219
|
+
}
|
|
220
|
+
async function stillWorks(remote, open) {
|
|
221
|
+
return withConnections(open, async (connections) => {
|
|
222
|
+
await (await connections.client(remote)).health();
|
|
223
|
+
return true;
|
|
224
|
+
}).catch(() => false);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* What this laptop calls the server: the ~/.ssh/config alias the user picked, else the
|
|
228
|
+
* server's own hostname, else its address (203-0-113-10). Unique unless it's this same server.
|
|
229
|
+
*/
|
|
230
|
+
async function remoteName(access, remotesFile, preferred) {
|
|
231
|
+
const host = hostOf(access.target).toLowerCase();
|
|
232
|
+
const fromAddress = /^\d+(\.\d+){3}$/.test(host) || host.includes(":") ? host.replace(/[.:]/g, "-") : host.split(".")[0] ?? "";
|
|
233
|
+
const name = [preferred, fromAddress].map((label) => serverLabel(label ?? "")).find((label) => label !== undefined) ?? "server";
|
|
234
|
+
const remotes = await loadRemotes(remotesFile);
|
|
235
|
+
for (let n = 1;; n++) {
|
|
236
|
+
const candidate = n === 1 ? name : `${name}-${n}`;
|
|
237
|
+
const existing = remotes.find((remote) => remote.name === candidate);
|
|
238
|
+
if (!existing || sameServer(existing, access))
|
|
239
|
+
return candidate;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/** A hostname as a server name (lowercase, dashes, room for a -2), or undefined if nothing usable is left. */
|
|
243
|
+
export function serverLabel(text) {
|
|
244
|
+
const label = text.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 36).replace(/-+$/, "");
|
|
245
|
+
return REMOTE_NAME_RE.test(label) && label !== "localhost" ? label : undefined;
|
|
246
|
+
}
|
|
247
|
+
function finishScreen(ui, capacity) {
|
|
248
|
+
ui.ready(capacity ? `Ready · fits ~${capacity} agents at once (estimate)` : "Ready", [
|
|
249
|
+
{ label: "From any GitHub repository", value: RUN_EXAMPLE },
|
|
250
|
+
{ label: "See progress, then check out the result", value: "dl runs\ndl pull ID" },
|
|
251
|
+
]);
|
|
252
|
+
}
|
|
253
|
+
function laptopName(name) {
|
|
254
|
+
return name.replace(/\.local$/, "").replace(/[^\p{L}\p{N} ._'()@-]/gu, "-").slice(0, 40) || "laptop";
|
|
255
|
+
}
|
|
256
|
+
async function pick(ui, prompter, label, hint, choices) {
|
|
257
|
+
const values = Object.values(choices);
|
|
258
|
+
for (let attempt = 1;; attempt++) {
|
|
259
|
+
const answer = (await prompter.text(ui.prompt(label, hint))).trim();
|
|
260
|
+
const choice = answer === "" ? values[0] : choices[answer];
|
|
261
|
+
if (choice)
|
|
262
|
+
return choice;
|
|
263
|
+
if (attempt >= 3)
|
|
264
|
+
throw new Error(`Pick one of ${Object.keys(choices).join(", ")}`);
|
|
265
|
+
ui.rejected(`Type ${Object.keys(choices).join(" or ")}.`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/** `dl setup` with the real terminal, ssh, gh, and filesystem. */
|
|
269
|
+
export async function setup(options) {
|
|
270
|
+
const { io, terminal } = options;
|
|
271
|
+
const color = io.color === true;
|
|
272
|
+
const execFileAsync = promisify(execFile);
|
|
273
|
+
const home = homedir();
|
|
274
|
+
let identityFile;
|
|
275
|
+
if (options.identityFile !== undefined) {
|
|
276
|
+
// The private key, even when the .pub next to it was named.
|
|
277
|
+
identityFile = resolve(options.identityFile.replace(/^~(?=$|\/)/, home).replace(/\.pub$/, ""));
|
|
278
|
+
await fileAccess(identityFile).catch(() => { throw new Error(`No SSH key at ${options.identityFile} (--identity takes the private key, e.g. ~/.ssh/id_ed25519)`); });
|
|
279
|
+
}
|
|
280
|
+
await setupWizard({
|
|
281
|
+
newUi: () => terminal ? new TerminalUi(io.out, undefined, color) : new PlainUi(io.out),
|
|
282
|
+
prompter: new TtyPrompter(),
|
|
283
|
+
write: io.out,
|
|
284
|
+
color,
|
|
285
|
+
ssh: realSshExec,
|
|
286
|
+
open: openRemote,
|
|
287
|
+
sources: {
|
|
288
|
+
env: process.env,
|
|
289
|
+
home,
|
|
290
|
+
readFile: (path) => readFile(path, "utf8").catch(() => undefined),
|
|
291
|
+
listDir: (path) => readdir(path).catch(() => []),
|
|
292
|
+
gh: () => execFileAsync("gh", ["auth", "token"], { timeout: 5000 }).then(({ stdout }) => stdout.trim() || undefined, () => undefined),
|
|
293
|
+
fetch: globalThis.fetch,
|
|
294
|
+
},
|
|
295
|
+
hostname: hostname(),
|
|
296
|
+
installUrl: process.env.DL_INSTALL_URL || INSTALL_URL,
|
|
297
|
+
...(options.localSetup ? { localSetup: options.localSetup } : {}),
|
|
298
|
+
...(identityFile ? { identityFile } : {}),
|
|
299
|
+
});
|
|
300
|
+
return 0;
|
|
301
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* The concrete hosts in an ssh config. Wildcard and negated patterns are defaults, not
|
|
5
|
+
* places to connect to, so they're skipped; `Match` blocks are ignored. ssh takes the first
|
|
6
|
+
* value it finds for each option, so a host's own block wins, then global or `Host *` lines.
|
|
7
|
+
*/
|
|
8
|
+
export function parseSshConfig(text, home) {
|
|
9
|
+
const blocks = [];
|
|
10
|
+
const global = {};
|
|
11
|
+
let current = global;
|
|
12
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
13
|
+
const line = raw.trim();
|
|
14
|
+
if (!line || line.startsWith("#"))
|
|
15
|
+
continue;
|
|
16
|
+
const match = /^(\S+?)(?:\s*=\s*|\s+)(.+)$/.exec(line);
|
|
17
|
+
if (!match)
|
|
18
|
+
continue;
|
|
19
|
+
const key = match[1].toLowerCase();
|
|
20
|
+
const value = match[2].trim();
|
|
21
|
+
if (key === "host") {
|
|
22
|
+
const block = { patterns: value.split(/\s+/).map(unquote), settings: {} };
|
|
23
|
+
blocks.push(block);
|
|
24
|
+
current = block.settings;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (key === "match") {
|
|
28
|
+
current = undefined;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (!current)
|
|
32
|
+
continue;
|
|
33
|
+
const setting = unquote(value);
|
|
34
|
+
if (key === "hostname")
|
|
35
|
+
current.hostName ??= setting;
|
|
36
|
+
else if (key === "user")
|
|
37
|
+
current.user ??= setting;
|
|
38
|
+
else if (key === "identityfile")
|
|
39
|
+
current.identityFile ??= expandHome(setting, home);
|
|
40
|
+
else if (key === "port" && /^\d+$/.test(setting))
|
|
41
|
+
current.port ??= Number(setting);
|
|
42
|
+
}
|
|
43
|
+
const defaults = { ...global };
|
|
44
|
+
for (const block of blocks) {
|
|
45
|
+
if (!block.patterns.includes("*"))
|
|
46
|
+
continue;
|
|
47
|
+
for (const [key, value] of Object.entries(block.settings))
|
|
48
|
+
defaults[key] ??= value;
|
|
49
|
+
}
|
|
50
|
+
const hosts = new Map();
|
|
51
|
+
for (const block of blocks) {
|
|
52
|
+
for (const pattern of block.patterns) {
|
|
53
|
+
if (/[*?!]/.test(pattern) || hosts.has(pattern))
|
|
54
|
+
continue;
|
|
55
|
+
const host = { alias: pattern };
|
|
56
|
+
for (const source of [block.settings, defaults]) {
|
|
57
|
+
for (const [key, value] of Object.entries(source))
|
|
58
|
+
host[key] ??= value;
|
|
59
|
+
}
|
|
60
|
+
hosts.set(pattern, host);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return [...hosts.values()];
|
|
64
|
+
}
|
|
65
|
+
/** How a config host reads in a list: root@203.0.113.10, or with a port when it isn't 22. */
|
|
66
|
+
export function describeHost(host) {
|
|
67
|
+
const port = host.port && host.port !== 22 ? `:${host.port}` : "";
|
|
68
|
+
return `${host.user ? `${host.user}@` : ""}${host.hostName ?? host.alias}${port}`;
|
|
69
|
+
}
|
|
70
|
+
/** Private keys in ~/.ssh: the files that have a matching .pub next to them. */
|
|
71
|
+
export function discoverKeys(sshDir, names) {
|
|
72
|
+
const present = new Set(names);
|
|
73
|
+
const preferred = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
|
74
|
+
return names
|
|
75
|
+
.filter((name) => !name.endsWith(".pub") && present.has(`${name}.pub`))
|
|
76
|
+
.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b))
|
|
77
|
+
.map((name) => join(sshDir, name));
|
|
78
|
+
function rank(name) {
|
|
79
|
+
const index = preferred.indexOf(name);
|
|
80
|
+
return index === -1 ? preferred.length : index;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** `user@host`, `host`, `user@host:2222`, or `user@[2001:db8::1]:2222` as typed by the user. */
|
|
84
|
+
export function parseTarget(input) {
|
|
85
|
+
const text = input.trim().replace(/^ssh\s+/, "");
|
|
86
|
+
const match = /^(?:([A-Za-z0-9._-]+)@)?(\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9._-]+)(?::(\d{1,5}))?$/.exec(text);
|
|
87
|
+
if (!match || match[2].startsWith("-"))
|
|
88
|
+
throw new Error(`"${input.trim()}" isn't a server address; type it like root@203.0.113.10 or root@box.example.com:2222`);
|
|
89
|
+
const host = match[2].replace(/^\[(.*)\]$/, "$1");
|
|
90
|
+
const port = match[3] ? Number(match[3]) : undefined;
|
|
91
|
+
if (port !== undefined && (port < 1 || port > 65535))
|
|
92
|
+
throw new Error(`${port} isn't a port`);
|
|
93
|
+
return { target: `${match[1] ? `${match[1]}@` : ""}${host}`, ...(port && port !== 22 ? { port } : {}) };
|
|
94
|
+
}
|
|
95
|
+
/** Options for every non-interactive ssh: no password prompts, a bounded wait, and trust on first use. */
|
|
96
|
+
export function sshOptions(access) {
|
|
97
|
+
if (access.target.startsWith("-"))
|
|
98
|
+
throw new Error(`Not a server: ${access.target}`);
|
|
99
|
+
return [
|
|
100
|
+
"-o", "BatchMode=yes",
|
|
101
|
+
"-o", "ConnectTimeout=10",
|
|
102
|
+
"-o", "StrictHostKeyChecking=accept-new",
|
|
103
|
+
...(access.port ? ["-p", String(access.port)] : []),
|
|
104
|
+
// With a chosen key, offer only it: an agent full of keys can hit the server's MaxAuthTries first.
|
|
105
|
+
...(access.identityFile ? ["-i", access.identityFile, "-o", "IdentitiesOnly=yes"] : []),
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
/** The command line people would type for this server, for messages. */
|
|
109
|
+
export function sshCommandLine(access) {
|
|
110
|
+
return ["ssh", ...(access.port ? ["-p", String(access.port)] : []), ...(access.identityFile ? ["-i", access.identityFile] : []), access.target].join(" ");
|
|
111
|
+
}
|
|
112
|
+
export const realSshExec = (access, command, options = {}) => new Promise((resolve, reject) => {
|
|
113
|
+
const child = spawn("ssh", [...sshOptions(access), access.target, command], {
|
|
114
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
115
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
116
|
+
});
|
|
117
|
+
let stdout = "";
|
|
118
|
+
let stderr = "";
|
|
119
|
+
child.stdout.on("data", lines((chunk) => { stdout += chunk; }, options.onStdoutLine));
|
|
120
|
+
child.stderr.on("data", lines((chunk) => { stderr += chunk; }, options.onStderrLine));
|
|
121
|
+
child.on("error", (error) => reject(error.code === "ENOENT" ? new Error("ssh isn't installed on this computer") : error));
|
|
122
|
+
child.on("close", (code) => resolve({ code: code ?? 255, stdout, stderr }));
|
|
123
|
+
// Always close stdin: a remote command reading it would otherwise wait forever.
|
|
124
|
+
child.stdin.on("error", () => { });
|
|
125
|
+
child.stdin.end(options.input ?? "");
|
|
126
|
+
});
|
|
127
|
+
/** Collects a stream's text and calls back once per complete line. */
|
|
128
|
+
export function lines(collect, onLine) {
|
|
129
|
+
let pending = "";
|
|
130
|
+
return (chunk) => {
|
|
131
|
+
const text = chunk.toString();
|
|
132
|
+
collect(text);
|
|
133
|
+
if (!onLine)
|
|
134
|
+
return;
|
|
135
|
+
pending += text;
|
|
136
|
+
const parts = pending.split("\n");
|
|
137
|
+
pending = parts.pop() ?? "";
|
|
138
|
+
for (const part of parts)
|
|
139
|
+
onLine(part.replace(/\r$/, ""));
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** The server refused our keys; the wizard tries the others in ~/.ssh. */
|
|
143
|
+
export class SshAuthError extends Error {
|
|
144
|
+
access;
|
|
145
|
+
newHostKey;
|
|
146
|
+
constructor(access, detail, newHostKey = false) {
|
|
147
|
+
super(`The server didn't accept this computer's SSH key (${detail}).`);
|
|
148
|
+
this.access = access;
|
|
149
|
+
this.newHostKey = newHostKey;
|
|
150
|
+
this.name = "SshAuthError";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const SUPPORTED_OS = new Set(["ubuntu 22.04", "ubuntu 24.04", "debian 12"]);
|
|
154
|
+
// One round trip: who we are, the OS, the CPU, whether we can become root without a password,
|
|
155
|
+
// and the server's own name (what the laptop calls it unless ~/.ssh/config already names it).
|
|
156
|
+
const PROBE = 'id -u; id -un; . /etc/os-release; echo "$ID $VERSION_ID"; uname -m; ' +
|
|
157
|
+
'if [ "$(id -u)" = 0 ]; then echo root; elif sudo -n true 2>/dev/null; then echo sudo; else echo nosudo; fi; ' +
|
|
158
|
+
'hostname -s 2>/dev/null || cat /etc/hostname 2>/dev/null';
|
|
159
|
+
/** Connects once and checks the server can take an install: a supported OS, and root or passwordless sudo. */
|
|
160
|
+
export async function checkServer(access, ssh, display) {
|
|
161
|
+
const result = await ssh(access, PROBE);
|
|
162
|
+
const newHostKey = /Permanently added/i.test(result.stderr);
|
|
163
|
+
if (result.code === 255 || result.stdout.trim() === "") {
|
|
164
|
+
const detail = lastLine(result.stderr) || `ssh exited with ${result.code}`;
|
|
165
|
+
if (/Permission denied|Too many authentication failures|no mutual signature|Authentication failed/i.test(result.stderr)) {
|
|
166
|
+
throw new SshAuthError(access, detail, newHostKey);
|
|
167
|
+
}
|
|
168
|
+
if (/Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED/i.test(result.stderr)) {
|
|
169
|
+
throw new Error(`${display}'s SSH host key changed since this computer last connected, so it stopped (that can mean someone is in the middle). If you rebuilt the server, remove the old key with: ssh-keygen -R ${hostOf(access.target)}`);
|
|
170
|
+
}
|
|
171
|
+
throw new Error(`Couldn't reach ${display} over SSH: ${detail}\n Check that this works: ${sshCommandLine(access)}`);
|
|
172
|
+
}
|
|
173
|
+
const [uid, user, osLine, arch, privilege, name] = result.stdout.trim().split("\n").map((line) => line.trim());
|
|
174
|
+
const os = formatOs(osLine ?? "");
|
|
175
|
+
const facts = {
|
|
176
|
+
user: user || "?",
|
|
177
|
+
root: uid === "0",
|
|
178
|
+
sudo: privilege === "sudo",
|
|
179
|
+
os,
|
|
180
|
+
arch: arch === "aarch64" ? "arm64" : arch || "?",
|
|
181
|
+
newHostKey,
|
|
182
|
+
...(name ? { hostname: name } : {}),
|
|
183
|
+
};
|
|
184
|
+
if (!facts.root && !facts.sudo) {
|
|
185
|
+
throw new Error([
|
|
186
|
+
`${facts.user} on ${display} isn't root and can't use sudo without a password, and setup needs one of them.`,
|
|
187
|
+
` Either connect as root (root@${hostOf(access.target)}), or let ${facts.user} use sudo without a password:`,
|
|
188
|
+
` echo '${facts.user} ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/${facts.user}`,
|
|
189
|
+
].join("\n"));
|
|
190
|
+
}
|
|
191
|
+
if (!SUPPORTED_OS.has((osLine ?? "").toLowerCase().replace(/"/g, ""))) {
|
|
192
|
+
throw new Error(`${display} runs ${os || "an unknown system"}; Directionally Local needs Ubuntu 22.04, Ubuntu 24.04, or Debian 12.`);
|
|
193
|
+
}
|
|
194
|
+
return facts;
|
|
195
|
+
}
|
|
196
|
+
function formatOs(line) {
|
|
197
|
+
const [id = "", version = ""] = line.replace(/"/g, "").split(/\s+/);
|
|
198
|
+
const names = { ubuntu: "Ubuntu", debian: "Debian" };
|
|
199
|
+
return `${names[id] ?? id} ${version}`.trim();
|
|
200
|
+
}
|
|
201
|
+
export function hostOf(target) {
|
|
202
|
+
return target.slice(target.lastIndexOf("@") + 1);
|
|
203
|
+
}
|
|
204
|
+
function lastLine(text) {
|
|
205
|
+
return text.trim().split("\n").filter((line) => !/Permanently added/i.test(line)).at(-1)?.trim() ?? "";
|
|
206
|
+
}
|
|
207
|
+
function unquote(value) {
|
|
208
|
+
return value.length >= 2 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
|
|
209
|
+
}
|
|
210
|
+
function expandHome(path, home) {
|
|
211
|
+
return path.replace(/^~(?=$|\/)/, home).replace(/%d/g, home);
|
|
212
|
+
}
|
|
213
|
+
/** Single-quotes a value for the remote shell. */
|
|
214
|
+
export function shellQuote(value) {
|
|
215
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
216
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { connect, createServer } from "node:net";
|
|
3
|
+
import { RemoteClient } from "./client.js";
|
|
4
|
+
import { sshCommandLine, sshOptions } from "./ssh.js";
|
|
5
|
+
const realSpawn = (args) => spawn("ssh", args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
6
|
+
/** The ssh arguments for a tunnel from a local port to the server's loopback port. */
|
|
7
|
+
export function tunnelArgs(access, localPort, remotePort) {
|
|
8
|
+
return ["-N", "-o", "ExitOnForwardFailure=yes", ...sshOptions(access), "-L", `127.0.0.1:${localPort}:127.0.0.1:${remotePort}`, access.target];
|
|
9
|
+
}
|
|
10
|
+
export function createOpener(options = {}) {
|
|
11
|
+
const spawnTunnel = options.spawn ?? realSpawn;
|
|
12
|
+
const freePort = options.freePort ?? pickFreePort;
|
|
13
|
+
const accepts = options.accepts ?? acceptsConnections;
|
|
14
|
+
const timeoutMs = options.timeoutMs ?? 20_000;
|
|
15
|
+
const pollMs = options.pollMs ?? 100;
|
|
16
|
+
return async (remote) => {
|
|
17
|
+
if (!remote.ssh)
|
|
18
|
+
return { url: remote.url, close: async () => { } };
|
|
19
|
+
const access = remote.ssh;
|
|
20
|
+
const remotePort = Number(new URL(remote.url).port || 80);
|
|
21
|
+
const port = await freePort();
|
|
22
|
+
const child = spawnTunnel(tunnelArgs(access, port, remotePort));
|
|
23
|
+
let stderr = "";
|
|
24
|
+
child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); });
|
|
25
|
+
let exited = child.exitCode !== null;
|
|
26
|
+
child.once("exit", () => { exited = true; });
|
|
27
|
+
// If this process ends without closing the tunnel (an uncaught error), take ssh with it.
|
|
28
|
+
const onExit = () => { if (!exited)
|
|
29
|
+
child.kill("SIGTERM"); };
|
|
30
|
+
process.once("exit", onExit);
|
|
31
|
+
const close = async () => {
|
|
32
|
+
process.off("exit", onExit);
|
|
33
|
+
if (exited)
|
|
34
|
+
return;
|
|
35
|
+
const gone = new Promise((resolve) => {
|
|
36
|
+
child.once("exit", () => resolve());
|
|
37
|
+
setTimeout(resolve, 2000).unref?.();
|
|
38
|
+
});
|
|
39
|
+
child.kill("SIGTERM");
|
|
40
|
+
await gone;
|
|
41
|
+
};
|
|
42
|
+
const deadline = Date.now() + timeoutMs;
|
|
43
|
+
for (;;) {
|
|
44
|
+
if (exited) {
|
|
45
|
+
await close();
|
|
46
|
+
const detail = stderr.trim().split("\n").at(-1)?.trim();
|
|
47
|
+
throw new Error(`Couldn't open an SSH tunnel to ${remote.name} (${access.target})${detail ? `: ${detail}` : ""}\n Check that this works: ${sshCommandLine(access)}`);
|
|
48
|
+
}
|
|
49
|
+
if (await accepts(port))
|
|
50
|
+
return { url: `http://127.0.0.1:${port}`, close };
|
|
51
|
+
if (Date.now() >= deadline) {
|
|
52
|
+
await close();
|
|
53
|
+
throw new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s opening an SSH tunnel to ${remote.name} (${access.target}).\n Check that this works: ${sshCommandLine(access)}`);
|
|
54
|
+
}
|
|
55
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export const openRemote = createOpener();
|
|
60
|
+
/**
|
|
61
|
+
* The endpoints one command uses: at most one tunnel per server, reused across its
|
|
62
|
+
* requests, all closed when the command ends (commands close it in `finally`, which also
|
|
63
|
+
* runs when Ctrl-C aborts a watch).
|
|
64
|
+
*/
|
|
65
|
+
export class Connections {
|
|
66
|
+
opener;
|
|
67
|
+
open = new Map();
|
|
68
|
+
constructor(opener = openRemote) {
|
|
69
|
+
this.opener = opener;
|
|
70
|
+
}
|
|
71
|
+
async client(remote) {
|
|
72
|
+
let endpoint = this.open.get(remote.name);
|
|
73
|
+
if (!endpoint) {
|
|
74
|
+
endpoint = this.opener(remote);
|
|
75
|
+
this.open.set(remote.name, endpoint);
|
|
76
|
+
}
|
|
77
|
+
return new RemoteClient((await endpoint).url, remote.token);
|
|
78
|
+
}
|
|
79
|
+
async close() {
|
|
80
|
+
const endpoints = [...this.open.values()];
|
|
81
|
+
this.open.clear();
|
|
82
|
+
await Promise.all(endpoints.map((endpoint) => endpoint.then((entry) => entry.close(), () => { })));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Runs `work` with a fresh set of connections and always closes them. */
|
|
86
|
+
export async function withConnections(opener, work) {
|
|
87
|
+
const connections = new Connections(opener);
|
|
88
|
+
try {
|
|
89
|
+
return await work(connections);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
await connections.close();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** A port nothing listens on right now (the OS picks it; ssh binds it a moment later). */
|
|
96
|
+
export function pickFreePort() {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const server = createServer();
|
|
99
|
+
server.unref();
|
|
100
|
+
server.once("error", reject);
|
|
101
|
+
server.listen(0, "127.0.0.1", () => {
|
|
102
|
+
const address = server.address();
|
|
103
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
104
|
+
server.close(() => port ? resolve(port) : reject(new Error("no free port")));
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
export function acceptsConnections(port) {
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
const socket = connect({ host: "127.0.0.1", port });
|
|
111
|
+
const done = (ok) => {
|
|
112
|
+
socket.removeAllListeners();
|
|
113
|
+
socket.destroy();
|
|
114
|
+
resolve(ok);
|
|
115
|
+
};
|
|
116
|
+
socket.setTimeout(1000, () => done(false));
|
|
117
|
+
socket.once("connect", () => done(true));
|
|
118
|
+
socket.once("error", () => done(false));
|
|
119
|
+
});
|
|
120
|
+
}
|