directionallylocal 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +49 -9
- package/dist/keys.js +188 -0
- package/dist/laptop/commands.js +166 -43
- package/dist/laptop/config.js +119 -23
- package/dist/laptop/credentials.js +129 -0
- package/dist/laptop/keys.js +133 -0
- package/dist/laptop/remote-install.js +280 -0
- package/dist/laptop/setup.js +301 -0
- package/dist/laptop/ssh.js +216 -0
- package/dist/laptop/tunnel.js +120 -0
- package/dist/local/check.js +194 -0
- package/dist/local/cli.js +43 -0
- package/dist/local/keys.js +100 -0
- package/dist/local/layout.js +227 -0
- package/dist/local/probes.js +22 -0
- package/dist/local/services.js +68 -0
- package/dist/local/setup.js +472 -0
- package/dist/local/state.js +15 -0
- package/dist/local/test-support/fake-computer.js +280 -0
- package/dist/up.js +6 -4
- package/install.sh +1 -1
- package/package.json +1 -1
package/dist/laptop/commands.js
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
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";
|
|
5
6
|
import { palette } from "../ui.js";
|
|
6
7
|
import { RemoteApiError, RemoteClient, TERMINAL_STATUSES } from "./client.js";
|
|
7
|
-
import { defaultRemoteName, loadRemotes, normalizeServerUrl, REMOTE_NAME_RE, removeRemote, resolveRemote, saveRemote } from "./config.js";
|
|
8
|
+
import { defaultRemoteName, loadRemotes, loadRemotesFile, normalizeServerUrl, recordLastUsed, REMOTE_NAME_RE, removeRemote, renameRemote, resolveRemote, resolveTarget, saveRemote, setDefaultRemote, } from "./config.js";
|
|
9
|
+
import { autopilotConfigPath } from "./credentials.js";
|
|
10
|
+
import { PAIRED_NAME } from "../local/layout.js";
|
|
8
11
|
import { detectGitHubRepository, snapshotWorkingTree } from "./git.js";
|
|
12
|
+
import { sshCommandLine } from "./ssh.js";
|
|
9
13
|
import { openBrowser } from "./open-browser.js";
|
|
14
|
+
import { withConnections } from "./tunnel.js";
|
|
10
15
|
/**
|
|
11
|
-
* `dl` on a laptop:
|
|
12
|
-
* pull requests. It talks to the server's API directly (the
|
|
13
|
-
* `--on` uses, and shares its list of servers), so the laptop needs
|
|
16
|
+
* `dl` on a laptop: send tasks to where agents run (this computer, or your own server),
|
|
17
|
+
* follow them, and check out the pull requests. It talks to the server's API directly (the
|
|
18
|
+
* same one Autopilot's `--on` uses, and shares its list of servers), so the laptop needs
|
|
19
|
+
* nothing else.
|
|
20
|
+
* Servers set up with `dl setup` are reached through an SSH tunnel that each command
|
|
21
|
+
* opens and closes (see tunnel.ts); `open` lets tests swap the tunnel out.
|
|
14
22
|
*/
|
|
15
23
|
const execFileAsync = promisify(execFile);
|
|
16
24
|
const RUN_END_EVENTS = new Set(["completed", "failed", "cancelled", "interrupted"]);
|
|
25
|
+
/** The model a task gets when neither -m nor the laptop's Autopilot picks one the server offers. */
|
|
26
|
+
export const DEFAULT_MODEL = "moonshotai/kimi-k2.6";
|
|
17
27
|
const processIO = {
|
|
18
28
|
out: (text) => process.stdout.write(text),
|
|
19
29
|
err: (text) => process.stderr.write(text),
|
|
@@ -56,9 +66,12 @@ export async function connect(input, io = processIO) {
|
|
|
56
66
|
* checkout (uncommitted changes go along). Follows it until it ends; Ctrl-C only detaches.
|
|
57
67
|
*/
|
|
58
68
|
export async function run(input, io = processIO) {
|
|
69
|
+
const { remote, reason } = await resolveTarget(input.server);
|
|
70
|
+
const preferred = input.preferredModel ?? await (input.autopilotModel ?? (() => readAutopilotModel()))();
|
|
71
|
+
return withConnections(input.open, async (connections) => runOn(await connections.client(remote), remote, reason, { ...input, ...(preferred ? { preferredModel: preferred } : {}) }, io));
|
|
72
|
+
}
|
|
73
|
+
async function runOn(client, remote, reason, input, io) {
|
|
59
74
|
const c = palette(io.color === true);
|
|
60
|
-
const remote = await resolveRemote(input.server);
|
|
61
|
-
const client = new RemoteClient(remote.url, remote.token);
|
|
62
75
|
const repository = input.repository ?? await detectGitHubRepository(input.cwd);
|
|
63
76
|
const snapshot = await snapshotWorkingTree(input.cwd);
|
|
64
77
|
const [workspaces, models] = await Promise.all([client.workspaces(), client.models()]);
|
|
@@ -67,7 +80,8 @@ export async function run(input, io = processIO) {
|
|
|
67
80
|
throw new Error(`${remote.name} has no workspace this laptop can use`);
|
|
68
81
|
const model = pickModel(models, input.model, input.preferredModel);
|
|
69
82
|
const changes = snapshot.files.length ? ` + ${snapshot.files.length} local change${snapshot.files.length === 1 ? "" : "s"}` : "";
|
|
70
|
-
|
|
83
|
+
const why = reason === "default" || reason === "last used" ? ` ${c.dim(`(${reason})`)}` : "";
|
|
84
|
+
io.err(`\n ${c.cyan("·")} Sending to ${c.bold(remote.name)}${why}: ${repository} at ${snapshot.baseCommit.slice(0, 7)}${changes} ${c.dim(model)}\n`);
|
|
71
85
|
const conversation = await client.createCodeConversation({
|
|
72
86
|
workspaceId: workspace.id,
|
|
73
87
|
model,
|
|
@@ -77,6 +91,8 @@ export async function run(input, io = processIO) {
|
|
|
77
91
|
idempotencyKey: `dl-${randomUUID()}`,
|
|
78
92
|
});
|
|
79
93
|
await client.submitTurn(conversation.conversationId, input.task, `dl-${randomUUID()}`);
|
|
94
|
+
// Only a fallback (a default, or the only place, always wins), so failing to write it is harmless.
|
|
95
|
+
await recordLastUsed(remote.name).catch(() => undefined);
|
|
80
96
|
const id = conversation.conversationId.slice(0, 8);
|
|
81
97
|
io.err(`${rewrite(io)} ${c.green("✓")} Started ${c.bold(id)} on ${remote.name} ${c.dim(`branch ${conversation.branch ?? "?"}`)}\n`);
|
|
82
98
|
if (input.detach) {
|
|
@@ -88,14 +104,17 @@ export async function run(input, io = processIO) {
|
|
|
88
104
|
}
|
|
89
105
|
/** `dl runs`: tasks on every connected server, newest first. */
|
|
90
106
|
export async function runs(input, io = processIO) {
|
|
91
|
-
const c = palette(io.color === true);
|
|
92
107
|
const remotes = input.server ? [await resolveRemote(input.server)] : await loadRemotes();
|
|
93
108
|
if (remotes.length === 0)
|
|
94
|
-
throw new Error("
|
|
109
|
+
throw new Error("Nothing is set up yet. Run: dl setup (or, for a server someone else set up: dl connect https://your-server)");
|
|
110
|
+
return withConnections(input.open, (connections) => listRuns(remotes, connections, input.json === true, io));
|
|
111
|
+
}
|
|
112
|
+
async function listRuns(remotes, connections, json, io) {
|
|
113
|
+
const c = palette(io.color === true);
|
|
95
114
|
const rows = [];
|
|
96
115
|
for (const remote of remotes) {
|
|
97
116
|
try {
|
|
98
|
-
const list = await
|
|
117
|
+
const list = await (await connections.client(remote)).listConversations();
|
|
99
118
|
rows.push(...list.filter((entry) => entry.kind === "code").map((entry) => ({ ...entry, server: remote.name })));
|
|
100
119
|
}
|
|
101
120
|
catch (error) {
|
|
@@ -103,7 +122,7 @@ export async function runs(input, io = processIO) {
|
|
|
103
122
|
}
|
|
104
123
|
}
|
|
105
124
|
rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
106
|
-
if (
|
|
125
|
+
if (json) {
|
|
107
126
|
io.out(`${JSON.stringify(rows, null, 2)}\n`);
|
|
108
127
|
return;
|
|
109
128
|
}
|
|
@@ -122,21 +141,26 @@ export async function runs(input, io = processIO) {
|
|
|
122
141
|
}
|
|
123
142
|
/** `dl watch <id>`: replay and follow a task's output. */
|
|
124
143
|
export async function watch(input, io = processIO) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
144
|
+
return withConnections(input.open, async (connections) => {
|
|
145
|
+
const { remote, run: found } = await findRun(input.id, input.server, connections);
|
|
146
|
+
const client = await connections.client(remote);
|
|
147
|
+
const finished = TERMINAL_STATUSES.has(found.status) && !found.activeRunId;
|
|
148
|
+
return follow(client, remote, found.conversationId, 0, input.signal, io, finished ? found.lastEventId : undefined);
|
|
149
|
+
});
|
|
129
150
|
}
|
|
130
151
|
export async function cancel(input, io = processIO) {
|
|
131
152
|
const c = palette(io.color === true);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
153
|
+
await withConnections(input.open, async (connections) => {
|
|
154
|
+
const { remote, run: found } = await findRun(input.id, input.server, connections);
|
|
155
|
+
await (await connections.client(remote)).cancel(found.conversationId);
|
|
156
|
+
io.out(` ${c.green("✓")} Stopped ${found.conversationId.slice(0, 8)}\n`);
|
|
157
|
+
});
|
|
135
158
|
}
|
|
136
159
|
/** `dl pull <id>`: check out a task's branch here. */
|
|
137
160
|
export async function pull(input, io = processIO) {
|
|
138
161
|
const c = palette(io.color === true);
|
|
139
|
-
|
|
162
|
+
// The server is only asked for the branch name; the code itself comes from GitHub.
|
|
163
|
+
const { run: found } = await withConnections(input.open, (connections) => findRun(input.id, input.server, connections));
|
|
140
164
|
const branch = found.branch;
|
|
141
165
|
if (!branch)
|
|
142
166
|
throw new Error("That task has no branch");
|
|
@@ -159,45 +183,104 @@ export async function pull(input, io = processIO) {
|
|
|
159
183
|
}
|
|
160
184
|
io.out(` ${c.green("✓")} On ${c.bold(branch)}${found.pullRequestURL ? ` ${c.dim(found.pullRequestURL)}` : ""}\n`);
|
|
161
185
|
}
|
|
162
|
-
/** `dl servers [remove NAME]`: the servers this laptop is connected to. */
|
|
186
|
+
/** `dl servers [remove NAME | rename OLD NEW]`: the servers this laptop is connected to. */
|
|
163
187
|
export async function servers(args, io = processIO) {
|
|
164
188
|
const c = palette(io.color === true);
|
|
189
|
+
if (args[0] === "rename")
|
|
190
|
+
return renameServer(args.slice(1), io);
|
|
165
191
|
if (args[0] === "remove") {
|
|
166
192
|
const name = args[1] ?? "";
|
|
167
|
-
|
|
193
|
+
const removed = (await loadRemotes()).find((remote) => remote.name === name);
|
|
194
|
+
if (!removed || !(await removeRemote(name))) {
|
|
168
195
|
io.err(` ${c.red("✗")} Not connected to "${name}".\n`);
|
|
169
196
|
return 1;
|
|
170
197
|
}
|
|
171
|
-
|
|
198
|
+
const onServer = `sudo dl laptops remove "${clientName()}"`;
|
|
199
|
+
io.out(` ${c.green("✓")} Forgot ${name}. To disconnect it on the server too: ${removed.ssh ? `${sshCommandLine(removed.ssh)} '${onServer}'` : onServer}\n`);
|
|
172
200
|
return 0;
|
|
173
201
|
}
|
|
174
202
|
const remotes = await loadRemotes();
|
|
175
203
|
if (remotes.length === 0) {
|
|
176
|
-
io.out(`\n
|
|
204
|
+
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`);
|
|
177
205
|
return 0;
|
|
178
206
|
}
|
|
207
|
+
const current = await defaultName();
|
|
179
208
|
io.out("\n");
|
|
180
209
|
for (const remote of remotes)
|
|
181
|
-
io.out(` ${c
|
|
182
|
-
io.out("\n");
|
|
210
|
+
io.out(` ${mark(remote, current, c)} ${remote.name.padEnd(16)} ${where(remote)} ${c.dim(`until ${remote.expiresAt.slice(0, 10)}`)}\n`);
|
|
211
|
+
io.out(remotes.length > 1 ? `\n ${c.dim(`● is where tasks go without --server. Change it: dl use NAME`)}\n\n` : "\n");
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
/** `dl use [NAME]`: set, or show, where commands go when no --server is given. */
|
|
215
|
+
export async function use(args, io = processIO) {
|
|
216
|
+
const c = palette(io.color === true);
|
|
217
|
+
const name = args[0];
|
|
218
|
+
if (name === undefined) {
|
|
219
|
+
const file = await loadRemotesFile();
|
|
220
|
+
if (file.remotes.length === 0) {
|
|
221
|
+
io.out(` Nothing set up yet. ${c.bold("dl setup")}\n`);
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
const current = await defaultName();
|
|
225
|
+
io.out(current
|
|
226
|
+
? ` 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`
|
|
227
|
+
: ` No default yet. Pick one: dl use NAME ${c.dim(`(${file.remotes.map((entry) => entry.name).join(", ")})`)}\n`);
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
const remote = await setDefaultRemote(name);
|
|
232
|
+
io.out(` ${c.green("✓")} Tasks now go to ${c.bold(remote.name)} ${c.dim(where(remote))} unless you pass --server NAME.\n`);
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
io.err(` ${c.red("✗")} ${describeError(error)}\n`);
|
|
237
|
+
return 1;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/** Where a command without --server goes, or undefined when that's ambiguous. */
|
|
241
|
+
async function defaultName() {
|
|
242
|
+
return resolveTarget(undefined).then((target) => target.remote.name, () => undefined);
|
|
243
|
+
}
|
|
244
|
+
function mark(remote, current, c) {
|
|
245
|
+
return remote.name === current ? c.green("●") : c.dim("○");
|
|
246
|
+
}
|
|
247
|
+
/** `dl servers rename OLD NEW`: only this laptop's name for it; the server doesn't change. */
|
|
248
|
+
export async function renameServer(args, io = processIO, path) {
|
|
249
|
+
const c = palette(io.color === true);
|
|
250
|
+
const [from, to] = args;
|
|
251
|
+
if (!from || !to || args.length > 2) {
|
|
252
|
+
io.err(` Usage: dl servers rename OLD NEW ${c.dim("(names: lowercase letters, digits, and dashes)")}\n`);
|
|
253
|
+
return 2;
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
await renameRemote(from, to, path);
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
io.err(` ${c.red("✗")} ${error.message}.\n`.replace(/\.\.\n$/, ".\n"));
|
|
260
|
+
return 1;
|
|
261
|
+
}
|
|
262
|
+
io.out(` ${c.green("✓")} Renamed ${from} to ${c.bold(to)} ${c.dim(`dl run --server ${to} …`)}\n`);
|
|
183
263
|
return 0;
|
|
184
264
|
}
|
|
185
265
|
/** `dl` on a laptop: what this is, which servers it's connected to, what to type next. */
|
|
186
|
-
export async function laptopHome(io = processIO) {
|
|
266
|
+
export async function laptopHome(io = processIO, open) {
|
|
187
267
|
const c = palette(io.color === true);
|
|
188
268
|
const remotes = await loadRemotes();
|
|
189
269
|
if (remotes.length === 0)
|
|
190
270
|
return false;
|
|
191
271
|
let running = 0;
|
|
192
272
|
let total = 0;
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
273
|
+
await withConnections(open, async (connections) => {
|
|
274
|
+
for (const remote of remotes) {
|
|
275
|
+
const list = await connections.client(remote).then((client) => client.listConversations()).catch(() => undefined);
|
|
276
|
+
running += list?.filter((entry) => entry.activeRunId !== null).length ?? 0;
|
|
277
|
+
total += list?.length ?? 0;
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
const current = await defaultName();
|
|
198
281
|
io.out(`\n ${c.bold("Directionally Local")} ${c.dim("on this laptop")}\n\n`);
|
|
199
|
-
io.out("
|
|
200
|
-
io.out(` ${c.dim("
|
|
282
|
+
io.out(" Coding agents that run on this computer or your own server: send a task, get a pull request back.\n\n");
|
|
283
|
+
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`);
|
|
201
284
|
io.out(` ${c.dim("Tasks".padEnd(11))}${running} running · ${total} in all ${c.dim("dl runs")}\n\n`);
|
|
202
285
|
io.out(` ${c.dim("Send a task, from inside a GitHub repository:")}\n ${c.bold('dl run "fix the flaky checkout test"')}\n\n`);
|
|
203
286
|
return true;
|
|
@@ -285,36 +368,76 @@ class EventRenderer {
|
|
|
285
368
|
this.io.out(`${text}\n`);
|
|
286
369
|
}
|
|
287
370
|
}
|
|
288
|
-
async function findRun(id, server) {
|
|
371
|
+
async function findRun(id, server, connections) {
|
|
289
372
|
const needle = id.trim().toLowerCase();
|
|
290
373
|
if (!/^[0-9a-f-]{4,36}$/.test(needle))
|
|
291
374
|
throw new Error("Task IDs are the short hex IDs dl runs shows");
|
|
292
375
|
const remotes = server ? [await resolveRemote(server)] : await loadRemotes();
|
|
293
376
|
const matches = [];
|
|
377
|
+
const unreachable = [];
|
|
294
378
|
for (const remote of remotes) {
|
|
295
|
-
const list = await
|
|
379
|
+
const list = await connections.client(remote).then((client) => client.listConversations(200)).catch((error) => {
|
|
380
|
+
unreachable.push(`${remote.name}: ${describeError(error)}`);
|
|
381
|
+
return [];
|
|
382
|
+
});
|
|
296
383
|
for (const entry of list)
|
|
297
384
|
if (entry.conversationId.startsWith(needle))
|
|
298
385
|
matches.push({ remote, run: entry });
|
|
299
386
|
}
|
|
387
|
+
// Say why when a server couldn't be asked (e.g. its SSH tunnel failed), rather than "no task".
|
|
300
388
|
if (matches.length === 0)
|
|
301
|
-
throw new Error(`No task ${id} on ${server ?? "any connected server"}`);
|
|
389
|
+
throw new Error(`No task ${id} on ${server ?? "any connected server"}${unreachable.length ? `\n${unreachable.join("\n")}` : ""}`);
|
|
302
390
|
if (matches.length > 1)
|
|
303
391
|
throw new Error(`${id} matches several tasks; use more characters`);
|
|
304
392
|
return matches[0];
|
|
305
393
|
}
|
|
306
|
-
|
|
394
|
+
/**
|
|
395
|
+
* The task's model: -m/--model; else the laptop's Autopilot default, if the server offers it;
|
|
396
|
+
* else DEFAULT_MODEL, if offered. Never just the first in the server's list (that's the
|
|
397
|
+
* whole OpenRouter catalog, in no useful order).
|
|
398
|
+
*/
|
|
399
|
+
export function pickModel(models, requested, preferred) {
|
|
400
|
+
const how = `Pick one: dl run -m <model> "task" (model IDs: https://openrouter.ai/models)`;
|
|
307
401
|
if (requested) {
|
|
308
402
|
if (!models.includes(requested))
|
|
309
|
-
throw new Error(`The server doesn't offer ${requested}`);
|
|
403
|
+
throw new Error(`The server doesn't offer ${requested}. ${how}`);
|
|
310
404
|
return requested;
|
|
311
405
|
}
|
|
312
406
|
if (preferred && models.includes(preferred))
|
|
313
407
|
return preferred;
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
408
|
+
if (models.includes(DEFAULT_MODEL))
|
|
409
|
+
return DEFAULT_MODEL;
|
|
410
|
+
if (models.length === 0)
|
|
411
|
+
throw new Error("The server offers no models; check its OpenRouter key (on the server: dl doctor)");
|
|
412
|
+
const tried = preferred && preferred !== DEFAULT_MODEL ? `your Autopilot model (${preferred}) or ${DEFAULT_MODEL}` : DEFAULT_MODEL;
|
|
413
|
+
throw new Error(`The server doesn't offer ${tried}. ${how}`);
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* The laptop's Autopilot default model: KIMI_MODEL (Autopilot's own override) or the
|
|
417
|
+
* `model` in its config. undefined when neither is set or the config can't be read.
|
|
418
|
+
*/
|
|
419
|
+
export async function readAutopilotModel(sources = {}) {
|
|
420
|
+
const env = sources.env ?? process.env;
|
|
421
|
+
const fromEnv = env.KIMI_MODEL?.trim();
|
|
422
|
+
if (fromEnv)
|
|
423
|
+
return fromEnv;
|
|
424
|
+
const read = sources.readFile ?? ((path) => readFile(path, "utf8").catch(() => undefined));
|
|
425
|
+
const raw = await read(autopilotConfigPath(env, sources.home ?? homedir()));
|
|
426
|
+
if (!raw)
|
|
427
|
+
return undefined;
|
|
428
|
+
try {
|
|
429
|
+
const model = JSON.parse(raw).model;
|
|
430
|
+
return typeof model === "string" && model.trim() ? model.trim() : undefined;
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
return undefined;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
/** Where a place is, as people know it: this computer, its URL, or the SSH destination its tunnel goes to. */
|
|
437
|
+
function where(remote) {
|
|
438
|
+
if (remote.ssh)
|
|
439
|
+
return `ssh ${remote.ssh.target}${remote.ssh.port ? `:${remote.ssh.port}` : ""}`;
|
|
440
|
+
return remote.name === PAIRED_NAME ? "this computer" : remote.url;
|
|
318
441
|
}
|
|
319
442
|
function clientName() {
|
|
320
443
|
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
|
}
|
|
@@ -85,5 +171,15 @@ function isRemote(value) {
|
|
|
85
171
|
if (!value || typeof value !== "object")
|
|
86
172
|
return false;
|
|
87
173
|
const entry = value;
|
|
88
|
-
|
|
174
|
+
if (!["name", "url", "token", "credentialId", "expiresAt", "addedAt"].every((key) => typeof entry[key] === "string"))
|
|
175
|
+
return false;
|
|
176
|
+
return entry.ssh === undefined || isSshAccess(entry.ssh);
|
|
177
|
+
}
|
|
178
|
+
function isSshAccess(value) {
|
|
179
|
+
if (!value || typeof value !== "object")
|
|
180
|
+
return false;
|
|
181
|
+
const ssh = value;
|
|
182
|
+
return typeof ssh.target === "string" && ssh.target.length > 0 && !ssh.target.startsWith("-") &&
|
|
183
|
+
(ssh.port === undefined || (typeof ssh.port === "number" && Number.isInteger(ssh.port) && ssh.port > 0 && ssh.port < 65536)) &&
|
|
184
|
+
(ssh.identityFile === undefined || typeof ssh.identityFile === "string");
|
|
89
185
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
/** Autopilot's config, where `autopilot` keeps the user's OpenRouter key. */
|
|
3
|
+
export function autopilotConfigPath(env, home) {
|
|
4
|
+
return join(env.XDG_CONFIG_HOME?.trim() || join(home, ".config"), "kimiflare", "config.json");
|
|
5
|
+
}
|
|
6
|
+
export async function validateOpenRouterKey(key, fetchImpl) {
|
|
7
|
+
if (!/^sk-or-[A-Za-z0-9_-]{10,}$/.test(key))
|
|
8
|
+
return "That doesn't look like an OpenRouter key (sk-or-…).";
|
|
9
|
+
if (!fetchImpl)
|
|
10
|
+
return undefined;
|
|
11
|
+
try {
|
|
12
|
+
const response = await fetchImpl("https://openrouter.ai/api/v1/key", { headers: { Authorization: `Bearer ${key}` } });
|
|
13
|
+
if (response.status === 401 || response.status === 403)
|
|
14
|
+
return "OpenRouter rejected that key.";
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// Offline: the format is right, and the server checks it again.
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
export async function validateGitHubToken(token, fetchImpl) {
|
|
22
|
+
if (!/^(github_pat_|ghp_|gho_|ghu_)[A-Za-z0-9_]{20,}$/.test(token))
|
|
23
|
+
return { problem: "That doesn't look like a GitHub token (github_pat_… or ghp_…)." };
|
|
24
|
+
if (!fetchImpl)
|
|
25
|
+
return {};
|
|
26
|
+
try {
|
|
27
|
+
const response = await fetchImpl("https://api.github.com/user", { headers: { Authorization: `Bearer ${token}`, "User-Agent": "directionallylocal" } });
|
|
28
|
+
if (response.status === 401)
|
|
29
|
+
return { problem: "GitHub rejected that token." };
|
|
30
|
+
if (response.ok) {
|
|
31
|
+
const user = await response.json();
|
|
32
|
+
if (typeof user.login === "string")
|
|
33
|
+
return { login: user.login };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Offline isn't fatal.
|
|
38
|
+
}
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
/** Finds, confirms, or asks for exactly the keys in `needs`. */
|
|
42
|
+
export async function gatherKeys(needs, input) {
|
|
43
|
+
const { ui } = input;
|
|
44
|
+
const answers = {};
|
|
45
|
+
const wanted = needs.filter((need) => need === "github" || need === "openrouter");
|
|
46
|
+
if (wanted.length === 0)
|
|
47
|
+
return answers;
|
|
48
|
+
ui.questions(`${wanted.length === 1 ? "1 key" : "2 keys"}, kept on your server only; then it sets itself up.`);
|
|
49
|
+
if (wanted.includes("github"))
|
|
50
|
+
answers.githubToken = await githubToken(input);
|
|
51
|
+
if (wanted.includes("openrouter"))
|
|
52
|
+
answers.openrouterKey = await openrouterKey(input);
|
|
53
|
+
return answers;
|
|
54
|
+
}
|
|
55
|
+
export async function githubToken({ ui, prompter, sources }) {
|
|
56
|
+
const fromCli = (await sources.gh().catch(() => undefined))?.trim();
|
|
57
|
+
if (fromCli) {
|
|
58
|
+
const checked = await validateGitHubToken(fromCli, sources.fetch);
|
|
59
|
+
if (checked.problem) {
|
|
60
|
+
ui.warn(`Your GitHub CLI login didn't work (${checked.problem}); asking for a token instead.`);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
const who = checked.login ? ` (${checked.login})` : "";
|
|
64
|
+
if (await confirm(ui, prompter, `Use your GitHub CLI login${who}? It stays on your server.`)) {
|
|
65
|
+
ui.answered("GitHub", `${checked.login ? `signed in as ${checked.login} · ` : ""}from the GitHub CLI`);
|
|
66
|
+
return fromCli;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return askUntilValid(ui, "GitHub", async () => {
|
|
71
|
+
const token = await prompter.secret(ui.prompt("GitHub token", "fine-grained: github.com/settings/personal-access-tokens/new"));
|
|
72
|
+
const checked = await validateGitHubToken(token, sources.fetch);
|
|
73
|
+
return { value: token, ...(checked.problem ? { problem: checked.problem } : {}), ...(checked.login ? { detail: `signed in as ${checked.login}` } : {}) };
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
export async function openrouterKey({ ui, prompter, sources }) {
|
|
77
|
+
const candidates = [];
|
|
78
|
+
const fromEnv = sources.env.OPENROUTER_API_KEY?.trim();
|
|
79
|
+
if (fromEnv)
|
|
80
|
+
candidates.push({ key: fromEnv, question: "Use the OpenRouter key in OPENROUTER_API_KEY?", from: "from OPENROUTER_API_KEY" });
|
|
81
|
+
const config = await sources.readFile(autopilotConfigPath(sources.env, sources.home));
|
|
82
|
+
const fromAutopilot = config ? readAutopilotKey(config) : undefined;
|
|
83
|
+
if (fromAutopilot && fromAutopilot !== fromEnv)
|
|
84
|
+
candidates.push({ key: fromAutopilot, question: "Use the OpenRouter key from Autopilot?", from: "from Autopilot" });
|
|
85
|
+
for (const candidate of candidates) {
|
|
86
|
+
const problem = await validateOpenRouterKey(candidate.key, sources.fetch);
|
|
87
|
+
if (problem) {
|
|
88
|
+
ui.warn(`The OpenRouter key ${candidate.from} didn't work (${problem})`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (await confirm(ui, prompter, `${candidate.question} It stays on your server.`)) {
|
|
92
|
+
ui.answered("OpenRouter", candidate.from);
|
|
93
|
+
return candidate.key;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return askUntilValid(ui, "OpenRouter", async () => {
|
|
97
|
+
const key = await prompter.secret(ui.prompt("OpenRouter key", "openrouter.ai/keys"));
|
|
98
|
+
const problem = await validateOpenRouterKey(key, sources.fetch);
|
|
99
|
+
return { value: key, ...(problem ? { problem } : {}) };
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
function readAutopilotKey(text) {
|
|
103
|
+
try {
|
|
104
|
+
const value = JSON.parse(text).openrouterApiKey;
|
|
105
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** A Y/n question; Enter means yes. */
|
|
112
|
+
export async function confirm(ui, prompter, question) {
|
|
113
|
+
const answer = (await prompter.text(ui.prompt(question, "Y/n"))).trim().toLowerCase();
|
|
114
|
+
return answer === "" || answer === "y" || answer === "yes";
|
|
115
|
+
}
|
|
116
|
+
async function askUntilValid(ui, label, ask) {
|
|
117
|
+
for (let attempt = 1;; attempt++) {
|
|
118
|
+
const { value, problem, detail } = await ask();
|
|
119
|
+
if (!problem) {
|
|
120
|
+
ui.answered(label, detail);
|
|
121
|
+
return value.trim();
|
|
122
|
+
}
|
|
123
|
+
if (attempt >= 3) {
|
|
124
|
+
ui.rejected(problem);
|
|
125
|
+
throw new Error(problem);
|
|
126
|
+
}
|
|
127
|
+
ui.rejected(`${problem} Try again.`);
|
|
128
|
+
}
|
|
129
|
+
}
|