directionallylocal 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,47 +9,127 @@ export function remotesPath(env = process.env) {
9
9
  return join(configHome, "autopilot", "remotes.json");
10
10
  }
11
11
  export async function loadRemotes(path = remotesPath()) {
12
+ return (await loadRemotesFile(path)).remotes;
13
+ }
14
+ /**
15
+ * The remotes with the default and last-used names. A name that no longer matches a
16
+ * remote (it was removed, or renamed by something that didn't know) is dropped.
17
+ */
18
+ export async function loadRemotesFile(path = remotesPath()) {
12
19
  let raw;
13
20
  try {
14
21
  raw = await readFile(path, "utf8");
15
22
  }
16
23
  catch (error) {
17
24
  if (error.code === "ENOENT")
18
- return [];
25
+ return { remotes: [] };
19
26
  throw error;
20
27
  }
21
28
  const parsed = JSON.parse(raw);
22
- return Array.isArray(parsed.remotes) ? parsed.remotes.filter(isRemote) : [];
29
+ const remotes = Array.isArray(parsed.remotes) ? parsed.remotes.filter(isRemote) : [];
30
+ const known = (name) => typeof name === "string" && remotes.some((entry) => entry.name === name);
31
+ return {
32
+ remotes,
33
+ ...(known(parsed.default) ? { default: parsed.default } : {}),
34
+ ...(known(parsed.lastUsed) ? { lastUsed: parsed.lastUsed } : {}),
35
+ };
23
36
  }
24
- /** Adds or replaces a remote by name. The file holds bearer tokens, so it is 0600 in a 0700 directory. */
37
+ /**
38
+ * Adds or replaces a remote by name. The file holds bearer tokens, so it is 0600 in a 0700
39
+ * directory. When no default is set yet, the first place saved becomes it (an older file's
40
+ * first entry, if it had any), so the first setup needs no `dl use`.
41
+ */
25
42
  export async function saveRemote(remote, path = remotesPath()) {
26
- const remotes = (await loadRemotes(path)).filter((entry) => entry.name !== remote.name);
43
+ const file = await loadRemotesFile(path);
44
+ const remotes = file.remotes.filter((entry) => entry.name !== remote.name);
27
45
  remotes.push(remote);
28
- await writeRemotes(remotes, path);
46
+ await writeRemotes({ ...file, remotes, default: file.default ?? file.remotes[0]?.name ?? remote.name }, path);
29
47
  }
30
48
  export async function removeRemote(name, path = remotesPath()) {
31
- const remotes = await loadRemotes(path);
32
- const kept = remotes.filter((entry) => entry.name !== name);
33
- if (kept.length === remotes.length)
49
+ const file = await loadRemotesFile(path);
50
+ const kept = file.remotes.filter((entry) => entry.name !== name);
51
+ if (kept.length === file.remotes.length)
34
52
  return false;
35
- await writeRemotes(kept, path);
53
+ // Forgetting the default leaves none; with one place left, that one is used anyway.
54
+ await writeRemotes({
55
+ remotes: kept,
56
+ ...(file.default && file.default !== name ? { default: file.default } : {}),
57
+ ...(file.lastUsed && file.lastUsed !== name ? { lastUsed: file.lastUsed } : {}),
58
+ }, path);
36
59
  return true;
37
60
  }
38
- /** The named remote, or the only one when no name is given. */
39
- export async function resolveRemote(name, path = remotesPath()) {
40
- const remotes = await loadRemotes(path);
41
- if (remotes.length === 0)
42
- throw new Error("No servers paired yet. Run: autopilot remote add https://your-server");
43
- if (name === undefined) {
44
- if (remotes.length === 1)
45
- return remotes[0];
46
- throw new Error(`Several servers are paired; pick one with --on (${remotes.map((entry) => entry.name).join(", ")})`);
61
+ /**
62
+ * `dl servers rename OLD NEW`: renames a place in this laptop's list (the server itself doesn't
63
+ * change). The `default` and `lastUsed` pointers follow the entry to its new name.
64
+ */
65
+ export async function renameRemote(from, to, path = remotesPath()) {
66
+ if (!REMOTE_NAME_RE.test(to))
67
+ throw new Error(`"${to}" can't be a server name: use lowercase letters, digits, and dashes (up to 40), starting with a letter or digit`);
68
+ const file = await loadRemotesFile(path);
69
+ if (!file.remotes.some((entry) => entry.name === from)) {
70
+ throw new Error(`No server named "${from}". Connected: ${file.remotes.map((entry) => entry.name).join(", ") || "none"}`);
47
71
  }
48
- const remote = remotes.find((entry) => entry.name === name);
72
+ if (from === to)
73
+ return;
74
+ if (file.remotes.some((entry) => entry.name === to))
75
+ throw new Error(`There's already a server named "${to}"`);
76
+ const follow = (name) => (name === from ? to : name);
77
+ const defaultName = follow(file.default);
78
+ const lastUsed = follow(file.lastUsed);
79
+ await writeRemotes({
80
+ remotes: file.remotes.map((entry) => (entry.name === from ? { ...entry, name: to } : entry)),
81
+ ...(defaultName ? { default: defaultName } : {}),
82
+ ...(lastUsed ? { lastUsed } : {}),
83
+ }, path);
84
+ }
85
+ /** `dl use NAME`: where commands go when none is named. */
86
+ export async function setDefaultRemote(name, path = remotesPath()) {
87
+ const file = await loadRemotesFile(path);
88
+ const remote = file.remotes.find((entry) => entry.name === name);
49
89
  if (!remote)
50
- throw new Error(`No server named "${name}". Paired: ${remotes.map((entry) => entry.name).join(", ")}`);
90
+ throw new Error(unknownName(name, file.remotes));
91
+ await writeRemotes({ ...file, default: name }, path);
51
92
  return remote;
52
93
  }
94
+ /** Remembers where the last task went (the fallback when there's no default). */
95
+ export async function recordLastUsed(name, path = remotesPath()) {
96
+ const file = await loadRemotesFile(path);
97
+ if (file.lastUsed === name || !file.remotes.some((entry) => entry.name === name))
98
+ return;
99
+ await writeRemotes({ ...file, lastUsed: name }, path);
100
+ }
101
+ /** The named remote, else the default, else the only one, else the last one used. */
102
+ export async function resolveRemote(name, path = remotesPath()) {
103
+ return (await resolveTarget(name, path)).remote;
104
+ }
105
+ /** resolveRemote, and why that one (for the command's first line). */
106
+ export async function resolveTarget(name, path = remotesPath()) {
107
+ const file = await loadRemotesFile(path);
108
+ const { remotes } = file;
109
+ if (remotes.length === 0)
110
+ throw new Error("Nothing is set up yet. Run: dl setup");
111
+ const named = (wanted) => remotes.find((entry) => entry.name === wanted);
112
+ if (name !== undefined) {
113
+ const remote = named(name);
114
+ if (!remote)
115
+ throw new Error(unknownName(name, remotes));
116
+ return { remote, reason: "named" };
117
+ }
118
+ if (remotes.length === 1)
119
+ return { remote: remotes[0], reason: "only" };
120
+ const chosen = file.default ? named(file.default) : undefined;
121
+ if (chosen)
122
+ return { remote: chosen, reason: "default" };
123
+ const last = file.lastUsed ? named(file.lastUsed) : undefined;
124
+ if (last)
125
+ return { remote: last, reason: "last used" };
126
+ throw new Error(`Several places are set up (${remotes.map((entry) => entry.name).join(", ")}). Pick one with --server NAME, or set a default: dl use NAME`);
127
+ }
128
+ function unknownName(name, remotes) {
129
+ return remotes.length
130
+ ? `Nothing named "${name}" is set up. Set up: ${remotes.map((entry) => entry.name).join(", ")}`
131
+ : `Nothing named "${name}" is set up; nothing is yet. Run: dl setup`;
132
+ }
53
133
  /** A short default name from the URL's host: https://acme.example.com -> acme. */
54
134
  export function defaultRemoteName(url) {
55
135
  const host = new URL(url).hostname.toLowerCase();
@@ -74,10 +154,16 @@ export function normalizeServerUrl(input) {
74
154
  throw new Error("The server URL must not include credentials, a query, or a fragment");
75
155
  return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
76
156
  }
77
- async function writeRemotes(remotes, path) {
157
+ async function writeRemotes(file, path) {
78
158
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
79
159
  const temp = `${path}.${process.pid}.tmp`;
80
- await writeFile(temp, `${JSON.stringify({ version: 1, remotes }, null, 2)}\n`, { mode: 0o600 });
160
+ const body = {
161
+ version: 1,
162
+ remotes: file.remotes,
163
+ ...(file.default ? { default: file.default } : {}),
164
+ ...(file.lastUsed ? { lastUsed: file.lastUsed } : {}),
165
+ };
166
+ await writeFile(temp, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600 });
81
167
  await chmod(temp, 0o600);
82
168
  await rename(temp, path);
83
169
  }
@@ -52,7 +52,7 @@ export async function gatherKeys(needs, input) {
52
52
  answers.openrouterKey = await openrouterKey(input);
53
53
  return answers;
54
54
  }
55
- async function githubToken({ ui, prompter, sources }) {
55
+ export async function githubToken({ ui, prompter, sources }) {
56
56
  const fromCli = (await sources.gh().catch(() => undefined))?.trim();
57
57
  if (fromCli) {
58
58
  const checked = await validateGitHubToken(fromCli, sources.fetch);
@@ -73,7 +73,7 @@ async function githubToken({ ui, prompter, sources }) {
73
73
  return { value: token, ...(checked.problem ? { problem: checked.problem } : {}), ...(checked.login ? { detail: `signed in as ${checked.login}` } : {}) };
74
74
  });
75
75
  }
76
- async function openrouterKey({ ui, prompter, sources }) {
76
+ export async function openrouterKey({ ui, prompter, sources }) {
77
77
  const candidates = [];
78
78
  const fromEnv = sources.env.OPENROUTER_API_KEY?.trim();
79
79
  if (fromEnv)
@@ -0,0 +1,133 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { promisify } from "node:util";
5
+ import { KEY_LABELS, formatKeyListing, parseKeyName } from "../keys.js";
6
+ import { listLocalKeys, setLocalKey } from "../local/keys.js";
7
+ import { PAIRED_NAME } from "../local/layout.js";
8
+ import { TtyPrompter } from "../prompt.js";
9
+ import { palette, PlainUi, TerminalUi } from "../ui.js";
10
+ import { resolveRemote } from "./config.js";
11
+ import { githubToken, openrouterKey } from "./credentials.js";
12
+ import { checkServer, realSshExec } from "./ssh.js";
13
+ export const LAPTOP_KEYS_USAGE = " Usage: dl keys [openrouter|github] [--server NAME]\n"
14
+ + " dl keys which keys are set where your agents run\n"
15
+ + " dl keys openrouter replace the OpenRouter key dl keys github replace the GitHub token\n";
16
+ export async function laptopKeys(args, options, deps) {
17
+ const [first, ...rest] = args;
18
+ const which = first === undefined || first === "ls" || first === "list" ? undefined : parseKeyName(first);
19
+ if ((first !== undefined && first !== "ls" && first !== "list" && !which) || rest.length > 0) {
20
+ deps.write(LAPTOP_KEYS_USAGE);
21
+ return 2;
22
+ }
23
+ const target = await resolveTarget(options.server, deps.remotesFile);
24
+ const again = (key) => `dl keys ${key}${options.server ? ` --server ${target.remote.name}` : ""}`;
25
+ if (!which) {
26
+ const listing = target.kind === "local" ? await deps.local.list() : await listOverSsh(target, deps.ssh);
27
+ deps.write(formatKeyListing(listing, palette(deps.color), `on ${target.kind === "local" ? "this computer" : target.remote.name}`, again));
28
+ return 0;
29
+ }
30
+ // Reach the server before asking for anything, so a dead connection doesn't waste a key.
31
+ const root = target.kind === "ssh" ? (await checkServer(target.access, deps.ssh, target.remote.name)).root : true;
32
+ const where = target.kind === "local" ? "this computer" : target.remote.name;
33
+ const label = `${KEY_LABELS[which]} ${which === "github" ? "token" : "key"}`;
34
+ const askUi = deps.newUi();
35
+ askUi.questions(`A new ${label} for ${where}; it replaces the one there. Typing is hidden.`);
36
+ const value = which === "github"
37
+ ? await githubToken({ ui: askUi, prompter: deps.prompter, sources: deps.sources })
38
+ : await openrouterKey({ ui: askUi, prompter: deps.prompter, sources: deps.sources });
39
+ askUi.finish();
40
+ const ui = deps.newUi();
41
+ ui.plan([{ id: "apply", label: `Replacing the ${label} on ${where}` }]);
42
+ ui.start("apply");
43
+ try {
44
+ const result = target.kind === "local" ? await deps.local.set(which, value) : await setOverSsh(target, root, which, value, deps.ssh);
45
+ ui.done("apply", result.detail);
46
+ }
47
+ catch (error) {
48
+ ui.fail("apply", error instanceof Error ? error.message.split("\n")[0] : String(error));
49
+ ui.finish();
50
+ throw error;
51
+ }
52
+ ui.finish();
53
+ return 0;
54
+ }
55
+ /** The server in use: `--server NAME`, or the only (or default) one. */
56
+ async function resolveTarget(server, remotesFile) {
57
+ const remote = remotesFile ? await resolveRemote(server, remotesFile) : await resolveRemote(server);
58
+ if (remote.ssh)
59
+ return { kind: "ssh", remote, access: remote.ssh };
60
+ if (remote.name === PAIRED_NAME)
61
+ return { kind: "local", remote };
62
+ throw new Error(`${remote.name} was connected with dl connect, so this laptop can't reach its keys. On that server, run: sudo dl keys`);
63
+ }
64
+ function sudo(root) {
65
+ return root ? "" : "sudo -n ";
66
+ }
67
+ async function setOverSsh(target, root, which, value, ssh) {
68
+ // The key travels on stdin only; the command line names which key, never its value.
69
+ const result = await ssh(target.access, `${sudo(root)}dl keys set ${which} --stdin --json`, { input: `${value}\n` });
70
+ const parsed = lastJson(result.stdout);
71
+ if (parsed?.ok === true)
72
+ return { ok: true, key: which, detail: typeof parsed.detail === "string" ? parsed.detail : "replaced" };
73
+ if (typeof parsed?.error === "string")
74
+ throw new Error(`${target.remote.name}: ${parsed.error}`);
75
+ throw serverFailure(target.remote.name, result);
76
+ }
77
+ async function listOverSsh(target, ssh) {
78
+ const { root } = await checkServer(target.access, ssh, target.remote.name);
79
+ const result = await ssh(target.access, `${sudo(root)}dl keys --json`);
80
+ const parsed = lastJson(result.stdout);
81
+ if (result.code !== 0 || !parsed || !Array.isArray(parsed.keys))
82
+ throw serverFailure(target.remote.name, result);
83
+ return parsed.keys.filter(isListing);
84
+ }
85
+ function serverFailure(name, result) {
86
+ if (/Unknown command "keys"|Unknown option '--(stdin|json)'/.test(result.stderr)) {
87
+ return new Error(`The dl on ${name} is too old for dl keys; update it with dl setup (it keeps your keys), then try again.`);
88
+ }
89
+ return new Error(`Couldn't change the keys on ${name}: ${lastLine(result.stderr) || lastLine(result.stdout) || `exit ${result.code}`}`);
90
+ }
91
+ function isListing(value) {
92
+ if (!value || typeof value !== "object")
93
+ return false;
94
+ const entry = value;
95
+ return (entry.key === "openrouter" || entry.key === "github") && typeof entry.set === "boolean"
96
+ && (entry.detail === undefined || typeof entry.detail === "string") && (entry.problem === undefined || typeof entry.problem === "string");
97
+ }
98
+ function lastJson(stdout) {
99
+ for (const line of stdout.trim().split("\n").reverse()) {
100
+ try {
101
+ return JSON.parse(line);
102
+ }
103
+ catch {
104
+ // not JSON; keep looking
105
+ }
106
+ }
107
+ return undefined;
108
+ }
109
+ function lastLine(text) {
110
+ return text.trim().split("\n").filter((line) => line.trim() && !/Permanently added/i.test(line)).at(-1)?.trim() ?? "";
111
+ }
112
+ /** `dl keys` on a laptop with the real terminal, ssh, gh, and filesystem. */
113
+ export async function keys(args, options) {
114
+ const { io, terminal } = options;
115
+ const color = io.color === true;
116
+ const execFileAsync = promisify(execFile);
117
+ return laptopKeys(args, options.server ? { server: options.server } : {}, {
118
+ newUi: () => terminal ? new TerminalUi(io.out, undefined, color) : new PlainUi(io.out),
119
+ prompter: new TtyPrompter(),
120
+ write: io.out,
121
+ color,
122
+ ssh: realSshExec,
123
+ sources: {
124
+ env: process.env,
125
+ home: homedir(),
126
+ readFile: (path) => readFile(path, "utf8").catch(() => undefined),
127
+ gh: () => execFileAsync("gh", ["auth", "token"], { timeout: 5000 }).then(({ stdout }) => stdout.trim() || undefined, () => undefined),
128
+ fetch: globalThis.fetch,
129
+ },
130
+ // Checked online here already; this computer re-checks only the format.
131
+ local: { set: (which, value) => setLocalKey(which, value), list: () => listLocalKeys({ fetchImpl: globalThis.fetch }) },
132
+ });
133
+ }
@@ -1,7 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
- import { readdir, readFile } from "node:fs/promises";
2
+ import { access as fileAccess, readdir, readFile } from "node:fs/promises";
3
3
  import { homedir, hostname } from "node:os";
4
- import { join } from "node:path";
4
+ import { join, resolve } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { TtyPrompter } from "../prompt.js";
7
7
  import { palette, PlainUi, TerminalUi } from "../ui.js";
@@ -40,34 +40,12 @@ export async function setupWizard(deps) {
40
40
  return setUpServer(deps);
41
41
  }
42
42
  async function setUpServer(deps) {
43
- const c = palette(deps.color);
44
43
  const home = deps.sources.home;
45
44
  const sshDir = join(home, ".ssh");
46
45
  const hosts = parseSshConfig(await deps.sources.readFile(join(sshDir, "config")) ?? "", home);
47
46
  // 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
- }
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);
71
49
  // 2. Install dl there, then ask only for the keys it still lacks.
72
50
  await bootstrap(access, facts, deps.ssh, deps.newUi(), deps.installUrl);
73
51
  const needs = await serverNeeds(access, facts, deps.ssh);
@@ -90,7 +68,7 @@ async function setUpServer(deps) {
90
68
  if (!remote) {
91
69
  const pairing = await pairLaptop(access, facts, deps.ssh, `${laptopName(deps.hostname)} (dl)`);
92
70
  remote = {
93
- name: saved?.name ?? await remoteName(access, remotesFile),
71
+ name: saved?.name ?? await remoteName(access, remotesFile, chosen.alias ?? facts.hostname),
94
72
  url: normalizeServerUrl(pairing.url),
95
73
  token: pairing.token,
96
74
  credentialId: pairing.credentialId,
@@ -135,6 +113,7 @@ async function chooseServer(deps, hosts) {
135
113
  if (host) {
136
114
  ui.answered("SSH", host.alias);
137
115
  return {
116
+ alias: host.alias,
138
117
  access: { target: host.alias },
139
118
  display: `${host.user ? `${host.user}@` : ""}${host.hostName ?? host.alias}${host.port && host.port !== 22 ? `:${host.port}` : ""}`,
140
119
  };
@@ -155,30 +134,85 @@ async function chooseServer(deps, hosts) {
155
134
  }
156
135
  }
157
136
  }
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");
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;
167
145
  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;
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 };
172
197
  }
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}`,
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}`,
180
209
  " If your key has a passphrase, load it first with: ssh-add",
181
- ].join("\n"));
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;
182
216
  }
183
217
  function sameServer(remote, access) {
184
218
  return remote.ssh?.target === access.target && remote.ssh.port === access.port;
@@ -189,12 +223,14 @@ async function stillWorks(remote, open) {
189
223
  return true;
190
224
  }).catch(() => false);
191
225
  }
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) {
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) {
194
231
  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";
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";
198
234
  const remotes = await loadRemotes(remotesFile);
199
235
  for (let n = 1;; n++) {
200
236
  const candidate = n === 1 ? name : `${name}-${n}`;
@@ -203,6 +239,11 @@ async function remoteName(access, remotesFile) {
203
239
  return candidate;
204
240
  }
205
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
+ }
206
247
  function finishScreen(ui, capacity) {
207
248
  ui.ready(capacity ? `Ready · fits ~${capacity} agents at once (estimate)` : "Ready", [
208
249
  { label: "From any GitHub repository", value: RUN_EXAMPLE },
@@ -230,6 +271,12 @@ export async function setup(options) {
230
271
  const color = io.color === true;
231
272
  const execFileAsync = promisify(execFile);
232
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
+ }
233
280
  await setupWizard({
234
281
  newUi: () => terminal ? new TerminalUi(io.out, undefined, color) : new PlainUi(io.out),
235
282
  prompter: new TtyPrompter(),
@@ -248,6 +295,7 @@ export async function setup(options) {
248
295
  hostname: hostname(),
249
296
  installUrl: process.env.DL_INSTALL_URL || INSTALL_URL,
250
297
  ...(options.localSetup ? { localSetup: options.localSetup } : {}),
298
+ ...(identityFile ? { identityFile } : {}),
251
299
  });
252
300
  return 0;
253
301
  }
@@ -139,19 +139,23 @@ export function lines(collect, onLine) {
139
139
  onLine(part.replace(/\r$/, ""));
140
140
  };
141
141
  }
142
- /** The server refused our keys; the wizard offers others from ~/.ssh. */
142
+ /** The server refused our keys; the wizard tries the others in ~/.ssh. */
143
143
  export class SshAuthError extends Error {
144
144
  access;
145
- constructor(access, detail) {
145
+ newHostKey;
146
+ constructor(access, detail, newHostKey = false) {
146
147
  super(`The server didn't accept this computer's SSH key (${detail}).`);
147
148
  this.access = access;
149
+ this.newHostKey = newHostKey;
148
150
  this.name = "SshAuthError";
149
151
  }
150
152
  }
151
153
  const SUPPORTED_OS = new Set(["ubuntu 22.04", "ubuntu 24.04", "debian 12"]);
152
- // One round trip: who we are, the OS, the CPU, and whether we can become root without a password.
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).
153
156
  const PROBE = 'id -u; id -un; . /etc/os-release; echo "$ID $VERSION_ID"; uname -m; ' +
154
- 'if [ "$(id -u)" = 0 ]; then echo root; elif sudo -n true 2>/dev/null; then echo sudo; else echo nosudo; fi';
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';
155
159
  /** Connects once and checks the server can take an install: a supported OS, and root or passwordless sudo. */
156
160
  export async function checkServer(access, ssh, display) {
157
161
  const result = await ssh(access, PROBE);
@@ -159,14 +163,14 @@ export async function checkServer(access, ssh, display) {
159
163
  if (result.code === 255 || result.stdout.trim() === "") {
160
164
  const detail = lastLine(result.stderr) || `ssh exited with ${result.code}`;
161
165
  if (/Permission denied|Too many authentication failures|no mutual signature|Authentication failed/i.test(result.stderr)) {
162
- throw new SshAuthError(access, detail);
166
+ throw new SshAuthError(access, detail, newHostKey);
163
167
  }
164
168
  if (/Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED/i.test(result.stderr)) {
165
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)}`);
166
170
  }
167
171
  throw new Error(`Couldn't reach ${display} over SSH: ${detail}\n Check that this works: ${sshCommandLine(access)}`);
168
172
  }
169
- const [uid, user, osLine, arch, privilege] = result.stdout.trim().split("\n").map((line) => line.trim());
173
+ const [uid, user, osLine, arch, privilege, name] = result.stdout.trim().split("\n").map((line) => line.trim());
170
174
  const os = formatOs(osLine ?? "");
171
175
  const facts = {
172
176
  user: user || "?",
@@ -175,6 +179,7 @@ export async function checkServer(access, ssh, display) {
175
179
  os,
176
180
  arch: arch === "aarch64" ? "arm64" : arch || "?",
177
181
  newHostKey,
182
+ ...(name ? { hostname: name } : {}),
178
183
  };
179
184
  if (!facts.root && !facts.sudo) {
180
185
  throw new Error([