directionallylocal 0.3.0 → 0.4.1
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/LICENSE +202 -0
- package/README.md +4 -0
- package/dist/cli.js +33 -11
- package/dist/doctor.js +6 -3
- package/dist/keys.js +188 -0
- package/dist/laptop/commands.js +130 -25
- 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/layout.js +4 -0
- package/dist/local/cli.js +4 -1
- package/dist/local/keys.js +100 -0
- package/dist/local/layout.js +3 -0
- package/dist/local/setup.js +13 -3
- package/dist/local/test-support/fake-computer.js +27 -3
- package/dist/model-prices.js +119 -0
- package/dist/up.js +16 -5
- package/package.json +2 -1
package/dist/laptop/commands.js
CHANGED
|
@@ -1,23 +1,30 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import {
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { homedir, hostname } from "node:os";
|
|
4
5
|
import { promisify } from "node:util";
|
|
6
|
+
import { isModelNotPriced } from "../model-prices.js";
|
|
5
7
|
import { palette } from "../ui.js";
|
|
6
8
|
import { RemoteApiError, RemoteClient, TERMINAL_STATUSES } from "./client.js";
|
|
7
|
-
import { defaultRemoteName, loadRemotes, normalizeServerUrl, REMOTE_NAME_RE, removeRemote, resolveRemote, saveRemote } from "./config.js";
|
|
9
|
+
import { defaultRemoteName, loadRemotes, loadRemotesFile, normalizeServerUrl, recordLastUsed, REMOTE_NAME_RE, removeRemote, renameRemote, resolveRemote, resolveTarget, saveRemote, setDefaultRemote, } from "./config.js";
|
|
10
|
+
import { autopilotConfigPath } from "./credentials.js";
|
|
11
|
+
import { PAIRED_NAME } from "../local/layout.js";
|
|
8
12
|
import { detectGitHubRepository, snapshotWorkingTree } from "./git.js";
|
|
9
13
|
import { sshCommandLine } from "./ssh.js";
|
|
10
14
|
import { openBrowser } from "./open-browser.js";
|
|
11
15
|
import { withConnections } from "./tunnel.js";
|
|
12
16
|
/**
|
|
13
|
-
* `dl` on a laptop:
|
|
14
|
-
* pull requests. It talks to the server's API directly (the
|
|
15
|
-
* `--on` uses, and shares its list of servers), so the laptop needs
|
|
17
|
+
* `dl` on a laptop: send tasks to where agents run (this computer, or your own server),
|
|
18
|
+
* follow them, and check out the pull requests. It talks to the server's API directly (the
|
|
19
|
+
* same one Autopilot's `--on` uses, and shares its list of servers), so the laptop needs
|
|
20
|
+
* nothing else.
|
|
16
21
|
* Servers set up with `dl setup` are reached through an SSH tunnel that each command
|
|
17
22
|
* opens and closes (see tunnel.ts); `open` lets tests swap the tunnel out.
|
|
18
23
|
*/
|
|
19
24
|
const execFileAsync = promisify(execFile);
|
|
20
25
|
const RUN_END_EVENTS = new Set(["completed", "failed", "cancelled", "interrupted"]);
|
|
26
|
+
/** The model a task gets when neither -m nor the laptop's Autopilot picks one the server offers. */
|
|
27
|
+
export const DEFAULT_MODEL = "moonshotai/kimi-k2.6";
|
|
21
28
|
const processIO = {
|
|
22
29
|
out: (text) => process.stdout.write(text),
|
|
23
30
|
err: (text) => process.stderr.write(text),
|
|
@@ -60,10 +67,11 @@ export async function connect(input, io = processIO) {
|
|
|
60
67
|
* checkout (uncommitted changes go along). Follows it until it ends; Ctrl-C only detaches.
|
|
61
68
|
*/
|
|
62
69
|
export async function run(input, io = processIO) {
|
|
63
|
-
const remote = await
|
|
64
|
-
|
|
70
|
+
const { remote, reason } = await resolveTarget(input.server);
|
|
71
|
+
const preferred = input.preferredModel ?? await (input.autopilotModel ?? (() => readAutopilotModel()))();
|
|
72
|
+
return withConnections(input.open, async (connections) => runOn(await connections.client(remote), remote, reason, { ...input, ...(preferred ? { preferredModel: preferred } : {}) }, io));
|
|
65
73
|
}
|
|
66
|
-
async function runOn(client, remote, input, io) {
|
|
74
|
+
async function runOn(client, remote, reason, input, io) {
|
|
67
75
|
const c = palette(io.color === true);
|
|
68
76
|
const repository = input.repository ?? await detectGitHubRepository(input.cwd);
|
|
69
77
|
const snapshot = await snapshotWorkingTree(input.cwd);
|
|
@@ -73,7 +81,8 @@ async function runOn(client, remote, input, io) {
|
|
|
73
81
|
throw new Error(`${remote.name} has no workspace this laptop can use`);
|
|
74
82
|
const model = pickModel(models, input.model, input.preferredModel);
|
|
75
83
|
const changes = snapshot.files.length ? ` + ${snapshot.files.length} local change${snapshot.files.length === 1 ? "" : "s"}` : "";
|
|
76
|
-
|
|
84
|
+
const why = reason === "default" || reason === "last used" ? ` ${c.dim(`(${reason})`)}` : "";
|
|
85
|
+
io.err(`\n ${c.cyan("·")} Sending to ${c.bold(remote.name)}${why}: ${repository} at ${snapshot.baseCommit.slice(0, 7)}${changes} ${c.dim(model)}\n`);
|
|
77
86
|
const conversation = await client.createCodeConversation({
|
|
78
87
|
workspaceId: workspace.id,
|
|
79
88
|
model,
|
|
@@ -83,6 +92,8 @@ async function runOn(client, remote, input, io) {
|
|
|
83
92
|
idempotencyKey: `dl-${randomUUID()}`,
|
|
84
93
|
});
|
|
85
94
|
await client.submitTurn(conversation.conversationId, input.task, `dl-${randomUUID()}`);
|
|
95
|
+
// Only a fallback (a default, or the only place, always wins), so failing to write it is harmless.
|
|
96
|
+
await recordLastUsed(remote.name).catch(() => undefined);
|
|
86
97
|
const id = conversation.conversationId.slice(0, 8);
|
|
87
98
|
io.err(`${rewrite(io)} ${c.green("✓")} Started ${c.bold(id)} on ${remote.name} ${c.dim(`branch ${conversation.branch ?? "?"}`)}\n`);
|
|
88
99
|
if (input.detach) {
|
|
@@ -96,7 +107,7 @@ async function runOn(client, remote, input, io) {
|
|
|
96
107
|
export async function runs(input, io = processIO) {
|
|
97
108
|
const remotes = input.server ? [await resolveRemote(input.server)] : await loadRemotes();
|
|
98
109
|
if (remotes.length === 0)
|
|
99
|
-
throw new Error("
|
|
110
|
+
throw new Error("Nothing is set up yet. Run: dl setup (or, for a server someone else set up: dl connect https://your-server)");
|
|
100
111
|
return withConnections(input.open, (connections) => listRuns(remotes, connections, input.json === true, io));
|
|
101
112
|
}
|
|
102
113
|
async function listRuns(remotes, connections, json, io) {
|
|
@@ -173,9 +184,11 @@ export async function pull(input, io = processIO) {
|
|
|
173
184
|
}
|
|
174
185
|
io.out(` ${c.green("✓")} On ${c.bold(branch)}${found.pullRequestURL ? ` ${c.dim(found.pullRequestURL)}` : ""}\n`);
|
|
175
186
|
}
|
|
176
|
-
/** `dl servers [remove NAME]`: the servers this laptop is connected to. */
|
|
187
|
+
/** `dl servers [remove NAME | rename OLD NEW]`: the servers this laptop is connected to. */
|
|
177
188
|
export async function servers(args, io = processIO) {
|
|
178
189
|
const c = palette(io.color === true);
|
|
190
|
+
if (args[0] === "rename")
|
|
191
|
+
return renameServer(args.slice(1), io);
|
|
179
192
|
if (args[0] === "remove") {
|
|
180
193
|
const name = args[1] ?? "";
|
|
181
194
|
const removed = (await loadRemotes()).find((remote) => remote.name === name);
|
|
@@ -189,13 +202,65 @@ export async function servers(args, io = processIO) {
|
|
|
189
202
|
}
|
|
190
203
|
const remotes = await loadRemotes();
|
|
191
204
|
if (remotes.length === 0) {
|
|
192
|
-
io.out(`\n
|
|
205
|
+
io.out(`\n Nothing set up yet. ${c.bold("dl setup")} ${c.dim("or, for a server someone else set up:")} ${c.bold("dl connect https://your-server")}\n\n`);
|
|
193
206
|
return 0;
|
|
194
207
|
}
|
|
208
|
+
const current = await defaultName();
|
|
195
209
|
io.out("\n");
|
|
196
210
|
for (const remote of remotes)
|
|
197
|
-
io.out(` ${c
|
|
198
|
-
io.out("\n");
|
|
211
|
+
io.out(` ${mark(remote, current, c)} ${remote.name.padEnd(16)} ${where(remote)} ${c.dim(`until ${remote.expiresAt.slice(0, 10)}`)}\n`);
|
|
212
|
+
io.out(remotes.length > 1 ? `\n ${c.dim(`● is where tasks go without --server. Change it: dl use NAME`)}\n\n` : "\n");
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
/** `dl use [NAME]`: set, or show, where commands go when no --server is given. */
|
|
216
|
+
export async function use(args, io = processIO) {
|
|
217
|
+
const c = palette(io.color === true);
|
|
218
|
+
const name = args[0];
|
|
219
|
+
if (name === undefined) {
|
|
220
|
+
const file = await loadRemotesFile();
|
|
221
|
+
if (file.remotes.length === 0) {
|
|
222
|
+
io.out(` Nothing set up yet. ${c.bold("dl setup")}\n`);
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
|
225
|
+
const current = await defaultName();
|
|
226
|
+
io.out(current
|
|
227
|
+
? ` Tasks go to ${c.bold(current)} unless you pass --server NAME.${file.remotes.length > 1 ? ` ${c.dim(`Change it: dl use NAME (${file.remotes.map((entry) => entry.name).join(", ")})`)}` : ""}\n`
|
|
228
|
+
: ` No default yet. Pick one: dl use NAME ${c.dim(`(${file.remotes.map((entry) => entry.name).join(", ")})`)}\n`);
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const remote = await setDefaultRemote(name);
|
|
233
|
+
io.out(` ${c.green("✓")} Tasks now go to ${c.bold(remote.name)} ${c.dim(where(remote))} unless you pass --server NAME.\n`);
|
|
234
|
+
return 0;
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
io.err(` ${c.red("✗")} ${describeError(error)}\n`);
|
|
238
|
+
return 1;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/** Where a command without --server goes, or undefined when that's ambiguous. */
|
|
242
|
+
async function defaultName() {
|
|
243
|
+
return resolveTarget(undefined).then((target) => target.remote.name, () => undefined);
|
|
244
|
+
}
|
|
245
|
+
function mark(remote, current, c) {
|
|
246
|
+
return remote.name === current ? c.green("●") : c.dim("○");
|
|
247
|
+
}
|
|
248
|
+
/** `dl servers rename OLD NEW`: only this laptop's name for it; the server doesn't change. */
|
|
249
|
+
export async function renameServer(args, io = processIO, path) {
|
|
250
|
+
const c = palette(io.color === true);
|
|
251
|
+
const [from, to] = args;
|
|
252
|
+
if (!from || !to || args.length > 2) {
|
|
253
|
+
io.err(` Usage: dl servers rename OLD NEW ${c.dim("(names: lowercase letters, digits, and dashes)")}\n`);
|
|
254
|
+
return 2;
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
await renameRemote(from, to, path);
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
io.err(` ${c.red("✗")} ${error.message}.\n`.replace(/\.\.\n$/, ".\n"));
|
|
261
|
+
return 1;
|
|
262
|
+
}
|
|
263
|
+
io.out(` ${c.green("✓")} Renamed ${from} to ${c.bold(to)} ${c.dim(`dl run --server ${to} …`)}\n`);
|
|
199
264
|
return 0;
|
|
200
265
|
}
|
|
201
266
|
/** `dl` on a laptop: what this is, which servers it's connected to, what to type next. */
|
|
@@ -213,9 +278,10 @@ export async function laptopHome(io = processIO, open) {
|
|
|
213
278
|
total += list?.length ?? 0;
|
|
214
279
|
}
|
|
215
280
|
});
|
|
281
|
+
const current = await defaultName();
|
|
216
282
|
io.out(`\n ${c.bold("Directionally Local")} ${c.dim("on this laptop")}\n\n`);
|
|
217
|
-
io.out("
|
|
218
|
-
io.out(` ${c.dim("
|
|
283
|
+
io.out(" Coding agents that run on this computer or your own server: send a task, get a pull request back.\n\n");
|
|
284
|
+
io.out(` ${c.dim("Runs on".padEnd(11))}${remotes.map((remote) => `${mark(remote, current, c)} ${remote.name} ${c.dim(where(remote))}`).join(", ")} ${c.dim(remotes.length > 1 ? "dl servers · dl use NAME" : "dl servers")}\n`);
|
|
219
285
|
io.out(` ${c.dim("Tasks".padEnd(11))}${running} running · ${total} in all ${c.dim("dl runs")}\n\n`);
|
|
220
286
|
io.out(` ${c.dim("Send a task, from inside a GitHub repository:")}\n ${c.bold('dl run "fix the flaky checkout test"')}\n\n`);
|
|
221
287
|
return true;
|
|
@@ -258,7 +324,11 @@ async function follow(client, remote, conversationId, cursor, signal, io, replay
|
|
|
258
324
|
io.err("\n");
|
|
259
325
|
return 0;
|
|
260
326
|
}
|
|
261
|
-
io.err(`\n ${c.red("✗")} ${id} ${outcome}\n
|
|
327
|
+
io.err(`\n ${c.red("✗")} ${id} ${outcome}\n`);
|
|
328
|
+
if (renderer.unpriced || isModelNotPriced(JSON.stringify(final))) {
|
|
329
|
+
io.err(` This model isn't in your server's price list yet. Run ${c.bold("dl setup")} to refresh it, or pick another model with -m.\n`);
|
|
330
|
+
}
|
|
331
|
+
io.err("\n");
|
|
262
332
|
return 1;
|
|
263
333
|
}
|
|
264
334
|
/** Writes the agent's text as it streams and one line per tool call. */
|
|
@@ -266,12 +336,16 @@ class EventRenderer {
|
|
|
266
336
|
io;
|
|
267
337
|
c;
|
|
268
338
|
midLine = false;
|
|
339
|
+
/** Hotcell refused the model for lack of a price (a stale price list on the server). */
|
|
340
|
+
unpriced = false;
|
|
269
341
|
constructor(io, c) {
|
|
270
342
|
this.io = io;
|
|
271
343
|
this.c = c;
|
|
272
344
|
}
|
|
273
345
|
render(event) {
|
|
274
346
|
const data = event.data;
|
|
347
|
+
if (event.type !== "assistant.delta" && !this.unpriced && isModelNotPriced(JSON.stringify(data)))
|
|
348
|
+
this.unpriced = true;
|
|
275
349
|
if (event.type === "assistant.delta") {
|
|
276
350
|
const text = typeof data.text === "string" ? data.text : typeof data.delta === "string" ? data.delta : "";
|
|
277
351
|
if (!text)
|
|
@@ -326,22 +400,53 @@ async function findRun(id, server, connections) {
|
|
|
326
400
|
throw new Error(`${id} matches several tasks; use more characters`);
|
|
327
401
|
return matches[0];
|
|
328
402
|
}
|
|
329
|
-
|
|
403
|
+
/**
|
|
404
|
+
* The task's model: -m/--model; else the laptop's Autopilot default, if the server offers it;
|
|
405
|
+
* else DEFAULT_MODEL, if offered. Never just the first in the server's list (that's the
|
|
406
|
+
* whole OpenRouter catalog, in no useful order).
|
|
407
|
+
*/
|
|
408
|
+
export function pickModel(models, requested, preferred) {
|
|
409
|
+
const how = `Pick one: dl run -m <model> "task" (model IDs: https://openrouter.ai/models)`;
|
|
330
410
|
if (requested) {
|
|
331
411
|
if (!models.includes(requested))
|
|
332
|
-
throw new Error(`The server doesn't offer ${requested}`);
|
|
412
|
+
throw new Error(`The server doesn't offer ${requested}. ${how}`);
|
|
333
413
|
return requested;
|
|
334
414
|
}
|
|
335
415
|
if (preferred && models.includes(preferred))
|
|
336
416
|
return preferred;
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
417
|
+
if (models.includes(DEFAULT_MODEL))
|
|
418
|
+
return DEFAULT_MODEL;
|
|
419
|
+
if (models.length === 0)
|
|
420
|
+
throw new Error("The server offers no models; check its OpenRouter key (on the server: dl doctor)");
|
|
421
|
+
const tried = preferred && preferred !== DEFAULT_MODEL ? `your Autopilot model (${preferred}) or ${DEFAULT_MODEL}` : DEFAULT_MODEL;
|
|
422
|
+
throw new Error(`The server doesn't offer ${tried}. ${how}`);
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* The laptop's Autopilot default model: KIMI_MODEL (Autopilot's own override) or the
|
|
426
|
+
* `model` in its config. undefined when neither is set or the config can't be read.
|
|
427
|
+
*/
|
|
428
|
+
export async function readAutopilotModel(sources = {}) {
|
|
429
|
+
const env = sources.env ?? process.env;
|
|
430
|
+
const fromEnv = env.KIMI_MODEL?.trim();
|
|
431
|
+
if (fromEnv)
|
|
432
|
+
return fromEnv;
|
|
433
|
+
const read = sources.readFile ?? ((path) => readFile(path, "utf8").catch(() => undefined));
|
|
434
|
+
const raw = await read(autopilotConfigPath(env, sources.home ?? homedir()));
|
|
435
|
+
if (!raw)
|
|
436
|
+
return undefined;
|
|
437
|
+
try {
|
|
438
|
+
const model = JSON.parse(raw).model;
|
|
439
|
+
return typeof model === "string" && model.trim() ? model.trim() : undefined;
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
return undefined;
|
|
443
|
+
}
|
|
341
444
|
}
|
|
342
|
-
/** Where a
|
|
445
|
+
/** Where a place is, as people know it: this computer, its URL, or the SSH destination its tunnel goes to. */
|
|
343
446
|
function where(remote) {
|
|
344
|
-
|
|
447
|
+
if (remote.ssh)
|
|
448
|
+
return `ssh ${remote.ssh.target}${remote.ssh.port ? `:${remote.ssh.port}` : ""}`;
|
|
449
|
+
return remote.name === PAIRED_NAME ? "this computer" : remote.url;
|
|
345
450
|
}
|
|
346
451
|
function clientName() {
|
|
347
452
|
const host = hostname().replace(/\.local$/, "").replace(/[^\p{L}\p{N} ._'()@-]/gu, "-").slice(0, 40) || "laptop";
|
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
|
+
}
|