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.
@@ -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
+ }
@@ -0,0 +1,280 @@
1
+ import { agentCapacity } from "../detect.js";
2
+ import { CELL_MEMORY_MB } from "../layout.js";
3
+ import { shellQuote } from "./ssh.js";
4
+ /**
5
+ * Installing on the user's server from the laptop, over SSH, through the server-side
6
+ * commands' machine-readable modes:
7
+ * 1. bootstrap install.sh with DL_INSTALL_ONLY=1 (plain `.. step` / `ok step: detail` lines)
8
+ * 2. needs `dl up --needs --access ssh` (one JSON line: which keys it still lacks)
9
+ * 3. install `dl up --access ssh --answers-stdin --events json` (keys on stdin, NDJSON out)
10
+ * 4. pair `dl pair --name … --json` (a credential for this laptop)
11
+ * The server's checklist is replayed into the laptop's own Ui, so it animates here.
12
+ */
13
+ export const INSTALL_URL = "https://directionallylocal.run";
14
+ /** Non-root users run the server commands with sudo; checkServer made sure it needs no password. */
15
+ function sudo(facts) {
16
+ return facts.root ? "" : "sudo -n ";
17
+ }
18
+ export function bootstrapCommand(facts, installUrl = INSTALL_URL) {
19
+ const run = facts.root ? "DL_INSTALL_ONLY=1 sh /tmp/dl-install.sh" : "sudo -n env DL_INSTALL_ONLY=1 sh /tmp/dl-install.sh";
20
+ return `curl -fsSL ${shellQuote(installUrl)} -o /tmp/dl-install.sh && ${run}`;
21
+ }
22
+ /**
23
+ * Renders install.sh's plain lines as a checklist. Steps aren't known up front, so each new
24
+ * one re-plans the list on a terminal (and restores the finished steps' ticks).
25
+ */
26
+ export class BootstrapRenderer {
27
+ ui;
28
+ steps = [];
29
+ active;
30
+ output = [];
31
+ failure;
32
+ constructor(ui) {
33
+ this.ui = ui;
34
+ }
35
+ stdout(line) {
36
+ const started = /^\.\. (.+)$/.exec(line);
37
+ if (started)
38
+ return this.start(started[1].trim());
39
+ const finished = /^ok ([^:]+?)(?:: (.*))?$/.exec(line);
40
+ if (finished)
41
+ return this.done(finished[1].trim(), finished[2]?.trim());
42
+ this.keep(line);
43
+ }
44
+ stderr(line) {
45
+ const failed = /^directionallylocal: (.+)$/.exec(line);
46
+ if (failed) {
47
+ this.failure = failed[1].trim();
48
+ return;
49
+ }
50
+ this.keep(line);
51
+ }
52
+ /** After install.sh exits: fails the running step and throws with its output when it failed. */
53
+ finish(code) {
54
+ if (code === 0) {
55
+ if (this.active)
56
+ this.done(this.active);
57
+ this.ui.finish();
58
+ return;
59
+ }
60
+ const what = this.failure ?? `the install script exited with ${code}`;
61
+ const id = this.active ?? this.steps.at(-1)?.id;
62
+ if (id)
63
+ this.ui.fail(id, what);
64
+ this.ui.finish();
65
+ const tail = this.output.slice(-15).map((line) => ` ${line}`).join("\n");
66
+ throw new Error(`Installing dl on the server failed: ${what}${tail ? `\n${tail}` : ""}`);
67
+ }
68
+ start(label) {
69
+ if (this.active && this.active !== label)
70
+ this.done(this.active);
71
+ let step = this.steps.find((entry) => entry.id === label);
72
+ if (!step) {
73
+ step = { id: label, label, done: false };
74
+ this.steps.push(step);
75
+ this.ui.plan(this.steps.map(({ id, label: text }) => ({ id, label: text })));
76
+ // plan() shows every step as pending again; on a terminal, put the finished ones back.
77
+ if (this.ui.interactive)
78
+ for (const entry of this.steps)
79
+ if (entry.done)
80
+ this.ui.done(entry.id, entry.detail);
81
+ }
82
+ this.active = label;
83
+ this.ui.start(label);
84
+ }
85
+ done(label, detail) {
86
+ if (!this.steps.some((entry) => entry.id === label))
87
+ this.start(label);
88
+ const step = this.steps.find((entry) => entry.id === label);
89
+ step.done = true;
90
+ if (detail !== undefined)
91
+ step.detail = detail;
92
+ if (this.active === label)
93
+ this.active = undefined;
94
+ this.ui.done(label, detail);
95
+ }
96
+ keep(line) {
97
+ if (!line.trim())
98
+ return;
99
+ this.output.push(line);
100
+ if (this.output.length > 200)
101
+ this.output.shift();
102
+ }
103
+ }
104
+ export async function bootstrap(access, facts, ssh, ui, installUrl) {
105
+ const renderer = new BootstrapRenderer(ui);
106
+ const result = await ssh(access, bootstrapCommand(facts, installUrl), {
107
+ onStdoutLine: (line) => renderer.stdout(line),
108
+ onStderrLine: (line) => renderer.stderr(line),
109
+ });
110
+ renderer.finish(result.code);
111
+ }
112
+ /** Which keys the server still lacks, so a configured server is asked nothing. */
113
+ export async function serverNeeds(access, facts, ssh) {
114
+ const result = await ssh(access, `${sudo(facts)}dl up --needs --access ssh`);
115
+ const parsed = lastJson(result.stdout);
116
+ if (result.code !== 0 || !parsed || !Array.isArray(parsed.needs)) {
117
+ throw new Error(`Couldn't ask the server what it needs: ${lastLine(result.stderr) || lastLine(result.stdout) || `exit ${result.code}`}`);
118
+ }
119
+ return parsed.needs.filter((need) => need === "openrouter" || need === "github" || need === "tailscale");
120
+ }
121
+ /**
122
+ * Replays `dl up --events json` into Ui calls. The server's own title and ready screen are
123
+ * skipped (the laptop prints its own); "answered" becomes a plain ✓ line, since on a
124
+ * terminal answered() erases the prompt above it, and there is none here.
125
+ */
126
+ export class EventReplayer {
127
+ newUi;
128
+ ui;
129
+ planned = false;
130
+ ids = new Set();
131
+ output = [];
132
+ error;
133
+ capacity;
134
+ constructor(newUi) {
135
+ this.newUi = newUi;
136
+ this.ui = newUi();
137
+ }
138
+ line(text) {
139
+ let event;
140
+ try {
141
+ const parsed = JSON.parse(text);
142
+ if (!parsed || typeof parsed !== "object" || typeof parsed.event !== "string")
143
+ throw new Error("not an event");
144
+ event = parsed;
145
+ }
146
+ catch {
147
+ if (text.trim())
148
+ this.output.push(text);
149
+ return;
150
+ }
151
+ const str = (key) => typeof event[key] === "string" ? event[key] : undefined;
152
+ switch (event.event) {
153
+ case "line":
154
+ case "answered": {
155
+ // A line after a checklist starts a new one below it, or the redraw would overwrite it.
156
+ this.fresh();
157
+ const status = event.event === "answered" ? "done" : str("status");
158
+ this.capacity ??= capacityFromMachine(str("label"), str("detail"));
159
+ this.ui.line(status === "done" || status === "failed" ? status : "info", str("label") ?? "", ...optional(str("detail")));
160
+ return;
161
+ }
162
+ case "plan": {
163
+ if (this.planned)
164
+ this.fresh();
165
+ const steps = Array.isArray(event.steps) ? event.steps
166
+ .filter((step) => typeof step.id === "string")
167
+ .map((step) => ({ id: step.id, label: typeof step.label === "string" ? step.label : step.id })) : [];
168
+ for (const step of steps)
169
+ this.ids.add(step.id);
170
+ this.ui.plan(steps);
171
+ this.planned = true;
172
+ return;
173
+ }
174
+ case "start":
175
+ case "done":
176
+ case "fail": {
177
+ const id = str("id");
178
+ if (!id || !this.ids.has(id))
179
+ return;
180
+ if (event.event === "start")
181
+ this.ui.start(id, ...optional(str("detail")));
182
+ else if (event.event === "done")
183
+ this.ui.done(id, ...optional(str("detail")));
184
+ else
185
+ this.ui.fail(id, str("message") ?? "failed");
186
+ return;
187
+ }
188
+ case "warn":
189
+ if (str("text"))
190
+ this.ui.warn(str("text"));
191
+ return;
192
+ case "ready":
193
+ this.capacity = capacityFrom(event) ?? this.capacity;
194
+ return;
195
+ case "error":
196
+ this.error = str("message") ?? "the install failed";
197
+ return;
198
+ default:
199
+ // "title", and anything newer than this laptop knows about.
200
+ return;
201
+ }
202
+ }
203
+ /** Recent lines that weren't events (the server's own messages), for an error. */
204
+ tail() {
205
+ return this.output.slice(-15);
206
+ }
207
+ finish() {
208
+ this.ui.finish();
209
+ }
210
+ fresh() {
211
+ if (!this.planned)
212
+ return;
213
+ this.ui.finish();
214
+ this.ui = this.newUi();
215
+ this.planned = false;
216
+ this.ids.clear();
217
+ }
218
+ }
219
+ function optional(value) {
220
+ return value === undefined ? [] : [value];
221
+ }
222
+ /**
223
+ * In SSH mode the server's ready screen has no estimate, so it's worked out here the way
224
+ * `dl up` does, from its machine check ("Ubuntu 24.04 · 4 CPU · 7.5 GB · …").
225
+ */
226
+ function capacityFromMachine(label, detail) {
227
+ if (label !== "Checked this machine" || !detail)
228
+ return undefined;
229
+ const cpus = /(\d+) CPU/.exec(detail);
230
+ const memory = /([\d.]+) GB(?! free)/.exec(detail);
231
+ if (!cpus || !memory)
232
+ return undefined;
233
+ return agentCapacity({ cpus: Number(cpus[1]), memoryMb: Math.round(Number(memory[1]) * 1024) }, CELL_MEMORY_MB);
234
+ }
235
+ /** "fits ~6 agents at once" anywhere in the server's ready screen (non-SSH servers print it). */
236
+ function capacityFrom(event) {
237
+ const match = /fits ~(\d+)/.exec(JSON.stringify(event));
238
+ return match ? Number(match[1]) : undefined;
239
+ }
240
+ /** Runs the install, keys on stdin as one JSON object. Resolves with the capacity estimate, if given. */
241
+ export async function installOnServer(access, facts, ssh, answers, newUi) {
242
+ const replay = new EventReplayer(newUi);
243
+ const stderr = [];
244
+ const result = await ssh(access, `${sudo(facts)}dl up --access ssh --answers-stdin --events json`, {
245
+ input: `${JSON.stringify(answers)}\n`,
246
+ onStdoutLine: (line) => replay.line(line),
247
+ onStderrLine: (line) => { if (line.trim())
248
+ stderr.push(line); },
249
+ });
250
+ replay.finish();
251
+ if (result.code !== 0 || replay.error) {
252
+ const tail = [...replay.tail(), ...stderr.slice(-15)].map((line) => ` ${line}`).join("\n");
253
+ throw new Error(`Setting up the server failed: ${replay.error ?? `dl up exited with ${result.code}`}${tail ? `\n${tail}` : ""}\n Fix it and run dl setup again; it picks up where it stopped.`);
254
+ }
255
+ return replay.capacity === undefined ? {} : { capacity: replay.capacity };
256
+ }
257
+ /** A credential for this laptop, issued on the server (no browser approval needed: SSH already proved who we are). */
258
+ export async function pairLaptop(access, facts, ssh, name) {
259
+ const result = await ssh(access, `${sudo(facts)}dl pair --name ${shellQuote(name)} --json`);
260
+ const parsed = lastJson(result.stdout);
261
+ const fields = ["token", "credentialId", "name", "expiresAt", "url"];
262
+ if (result.code !== 0 || !parsed || !fields.every((key) => typeof parsed[key] === "string")) {
263
+ throw new Error(`Couldn't connect this laptop to the server: ${lastLine(result.stderr) || `dl pair exited with ${result.code}`}`);
264
+ }
265
+ return parsed;
266
+ }
267
+ function lastJson(text) {
268
+ for (const line of text.trim().split("\n").reverse()) {
269
+ try {
270
+ return JSON.parse(line);
271
+ }
272
+ catch {
273
+ // keep looking: a banner or warning may come before the JSON
274
+ }
275
+ }
276
+ return undefined;
277
+ }
278
+ function lastLine(text) {
279
+ return text.trim().split("\n").at(-1)?.trim() ?? "";
280
+ }