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.
- package/dist/cli.js +33 -11
- package/dist/keys.js +188 -0
- package/dist/laptop/commands.js +120 -24
- package/dist/laptop/config.js +108 -22
- package/dist/laptop/credentials.js +2 -2
- package/dist/laptop/keys.js +133 -0
- package/dist/laptop/setup.js +101 -53
- package/dist/laptop/ssh.js +11 -6
- package/dist/local/keys.js +100 -0
- package/dist/local/setup.js +1 -1
- package/dist/local/test-support/fake-computer.js +20 -3
- package/dist/up.js +6 -4
- package/package.json +1 -1
package/dist/laptop/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/laptop/setup.js
CHANGED
|
@@ -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
|
-
|
|
49
|
-
|
|
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
|
-
/**
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
193
|
-
|
|
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
|
|
196
|
-
const
|
|
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
|
}
|
package/dist/laptop/ssh.js
CHANGED
|
@@ -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
|
|
142
|
+
/** The server refused our keys; the wizard tries the others in ~/.ssh. */
|
|
143
143
|
export class SshAuthError extends Error {
|
|
144
144
|
access;
|
|
145
|
-
|
|
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,
|
|
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([
|