directionallylocal 0.1.2 → 0.3.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 +130 -11
- package/dist/doctor.js +3 -2
- package/dist/home.js +16 -4
- package/dist/laptop/commands.js +54 -27
- package/dist/laptop/config.js +11 -1
- package/dist/laptop/credentials.js +129 -0
- package/dist/laptop/remote-install.js +280 -0
- package/dist/laptop/setup.js +253 -0
- package/dist/laptop/ssh.js +211 -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/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 +263 -0
- package/dist/server-api.js +22 -0
- package/dist/state.js +5 -0
- package/dist/ui.js +33 -0
- package/dist/up.js +105 -24
- package/install.sh +8 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2,20 +2,26 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
4
|
import { doctor, formatChecks } from "./doctor.js";
|
|
5
|
-
import { briefStatus, connectLaptop, laptopsCommand, logsCommand, serverHome, serverRunsCommand } from "./home.js";
|
|
5
|
+
import { briefStatus, connectLaptop, laptopsCommand, logsCommand, pairLaptop, serverHome, serverRunsCommand } from "./home.js";
|
|
6
6
|
import { cancel, connect, describeError, laptopHome, pull, run, runs, servers, watch } from "./laptop/commands.js";
|
|
7
|
+
import { loadRemotes } from "./laptop/config.js";
|
|
8
|
+
import { setup } from "./laptop/setup.js";
|
|
9
|
+
import { localCommand } from "./local/cli.js";
|
|
10
|
+
import { setUpOnThisComputer } from "./local/setup.js";
|
|
7
11
|
import { TtyPrompter } from "./prompt.js";
|
|
8
12
|
import { readState } from "./state.js";
|
|
9
13
|
import { DryRunSystem, RealSystem } from "./system.js";
|
|
10
14
|
import { uninstall } from "./uninstall.js";
|
|
11
|
-
import { PlainUi, TerminalUi, palette } from "./ui.js";
|
|
12
|
-
import { up } from "./up.js";
|
|
15
|
+
import { JsonUi, PlainUi, TerminalUi, palette } from "./ui.js";
|
|
16
|
+
import { needs, parseAccessMode, parseProvidedAnswers, up } from "./up.js";
|
|
13
17
|
const HELP = `Directionally Local: background coding agents on your own server.
|
|
14
18
|
Send a task from your laptop; it runs in its own sandbox and comes back as a pull request.
|
|
15
19
|
(dl is short for directionallylocal; both work.)
|
|
16
20
|
|
|
17
21
|
On your laptop:
|
|
18
|
-
dl
|
|
22
|
+
dl set up where agents run (the first time; afterwards, an overview)
|
|
23
|
+
dl setup set up, or repair, a server over SSH (safe to re-run)
|
|
24
|
+
dl connect URL connect to a server someone else set up (approve the code it shows)
|
|
19
25
|
dl run "TASK" run a task on the server, from inside a GitHub repository
|
|
20
26
|
(your uncommitted changes go along; Ctrl-C stops watching, not the task)
|
|
21
27
|
dl runs your tasks and their pull requests
|
|
@@ -23,6 +29,8 @@ On your laptop:
|
|
|
23
29
|
dl pull ID check out a task's branch here
|
|
24
30
|
dl servers connected servers (dl servers remove NAME)
|
|
25
31
|
run options: --server NAME, -m MODEL, --repo OWNER/NAME, --detach
|
|
32
|
+
dl local status agents running on this computer: running?, where, how many fit
|
|
33
|
+
dl local uninstall [--purge] stop and remove them (--purge also deletes keys and data)
|
|
26
34
|
|
|
27
35
|
On the server:
|
|
28
36
|
dl what this is, whether it's healthy, laptops, and runs
|
|
@@ -46,7 +54,14 @@ Unattended installs answer the questions with environment variables:
|
|
|
46
54
|
|
|
47
55
|
Advanced:
|
|
48
56
|
DL_ACCESS=loopback don't expose the server; reach it over an SSH tunnel
|
|
57
|
+
(or --access tailscale|loopback|ssh; ssh is the laptop's setup)
|
|
49
58
|
DL_SKIP_CREDENTIAL_CHECK=1 don't check the keys with OpenRouter and GitHub (offline installs)
|
|
59
|
+
|
|
60
|
+
Used by the laptop's setup (over SSH, as root):
|
|
61
|
+
dl up --access ssh --answers-stdin --events json
|
|
62
|
+
install with the keys as one JSON object on stdin; progress as JSON lines
|
|
63
|
+
dl up --needs [--access ssh] which keys dl up would still need, as JSON; changes nothing
|
|
64
|
+
dl pair --name NAME --json a credential for that laptop, as JSON
|
|
50
65
|
`;
|
|
51
66
|
export async function main(argv, io = {
|
|
52
67
|
out: (text) => process.stdout.write(text),
|
|
@@ -73,6 +88,10 @@ export async function main(argv, io = {
|
|
|
73
88
|
repo: { type: "string" },
|
|
74
89
|
detach: { type: "boolean" },
|
|
75
90
|
json: { type: "boolean" },
|
|
91
|
+
access: { type: "string" },
|
|
92
|
+
"answers-stdin": { type: "boolean" },
|
|
93
|
+
events: { type: "string" },
|
|
94
|
+
needs: { type: "boolean" },
|
|
76
95
|
},
|
|
77
96
|
});
|
|
78
97
|
}
|
|
@@ -90,27 +109,84 @@ export async function main(argv, io = {
|
|
|
90
109
|
io.out(HELP);
|
|
91
110
|
return 0;
|
|
92
111
|
}
|
|
93
|
-
|
|
112
|
+
if (values.events !== undefined && values.events !== "json") {
|
|
113
|
+
io.err(`--events takes json (got "${values.events}").\n\n${HELP}`);
|
|
114
|
+
return 2;
|
|
115
|
+
}
|
|
116
|
+
let access;
|
|
117
|
+
try {
|
|
118
|
+
access = values.access === undefined ? undefined : parseAccessMode(values.access);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
io.err(`${error.message}\n`);
|
|
122
|
+
return 2;
|
|
123
|
+
}
|
|
124
|
+
// `--events json`: stdout carries only the laptop's JSON events; everything else goes to
|
|
125
|
+
// stderr, and other tools' output to the install log, as with the live checklist.
|
|
126
|
+
const json = command === "up" && values.events === "json" && !values.needs;
|
|
127
|
+
const log = (line) => (json ? io.err : io.out)(`${line}\n`);
|
|
94
128
|
// The live checklist needs a terminal; a dry run prints its plan line by line instead.
|
|
95
129
|
const terminal = options.terminal ?? (process.stdout.isTTY === true);
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
const
|
|
130
|
+
// --needs only looks: it mustn't even start a new install log.
|
|
131
|
+
const live = terminal && command === "up" && !values["dry-run"] && !values.needs && !json;
|
|
132
|
+
const jsonUi = json ? new JsonUi(io.out) : undefined;
|
|
133
|
+
const ui = jsonUi ?? (live ? new TerminalUi(io.out) : new PlainUi(io.out));
|
|
134
|
+
const colorOn = terminal && !json && !process.env.NO_COLOR && process.env.TERM !== "dumb";
|
|
99
135
|
const colors = palette(colorOn);
|
|
100
136
|
const laptopIO = { out: io.out, err: io.err, color: colorOn };
|
|
101
|
-
const
|
|
137
|
+
const logged = (live || json) && !values["dry-run"];
|
|
138
|
+
let system;
|
|
139
|
+
try {
|
|
140
|
+
system = values["dry-run"] ? new DryRunSystem(log) : new RealSystem(log, logged ? { outputLog: UP_LOG } : {});
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
// Opening the install log is the first write, so this is where a non-root `up` stops.
|
|
144
|
+
const message = `Couldn't open ${UP_LOG}: ${describeError(error)}. Run it as root: sudo dl up`;
|
|
145
|
+
if (jsonUi)
|
|
146
|
+
jsonUi.error(message);
|
|
147
|
+
else
|
|
148
|
+
io.err(`\n ${palette(live && !process.env.NO_COLOR).red("✗")} ${message}\n`);
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
102
151
|
try {
|
|
103
152
|
switch (command) {
|
|
104
|
-
case "up":
|
|
153
|
+
case "up": {
|
|
154
|
+
if (values.needs) {
|
|
155
|
+
io.out(`${JSON.stringify({ needs: await needs(system, access ? { access } : {}) })}\n`);
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
// Read before anything else happens, so a bad answer fails before any change.
|
|
159
|
+
const answers = values["answers-stdin"] ? parseProvidedAnswers(await readAnswers(options.stdin ?? process.stdin)) : undefined;
|
|
105
160
|
if (system.dryRun)
|
|
106
161
|
log("Dry run: nothing on this machine will change.\n");
|
|
107
162
|
await up(system, {
|
|
108
163
|
prompter: new TtyPrompter(),
|
|
109
164
|
ui,
|
|
165
|
+
...(access ? { access } : {}),
|
|
166
|
+
...(answers ? { answers } : {}),
|
|
110
167
|
...(values["autopilot-version"] ? { autopilotVersion: values["autopilot-version"] } : {}),
|
|
111
168
|
...(values["hotcell-version"] ? { hotcellVersion: values["hotcell-version"] } : {}),
|
|
112
169
|
});
|
|
113
170
|
return 0;
|
|
171
|
+
}
|
|
172
|
+
case "pair": {
|
|
173
|
+
// Only the laptop's setup runs this, and it reads the JSON; there's no human form.
|
|
174
|
+
if (!values.json || !values.name?.trim()) {
|
|
175
|
+
io.err(` Usage: dl pair --name NAME --json ${colors.dim("(run by the laptop's setup, over SSH)")}\n`);
|
|
176
|
+
return 2;
|
|
177
|
+
}
|
|
178
|
+
const state = await readState(system);
|
|
179
|
+
if (!state) {
|
|
180
|
+
io.err(" Directionally Local isn't installed on this server yet; run dl up first.\n");
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
183
|
+
if (!isRoot()) {
|
|
184
|
+
io.err(" Run it as root: sudo dl pair --name NAME --json\n");
|
|
185
|
+
return 1;
|
|
186
|
+
}
|
|
187
|
+
await pairLaptop(system, state, values.name, io.out);
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
114
190
|
case "doctor": {
|
|
115
191
|
const checks = await doctor(system);
|
|
116
192
|
log(formatChecks(checks, terminal && !process.env.NO_COLOR));
|
|
@@ -125,6 +201,9 @@ export async function main(argv, io = {
|
|
|
125
201
|
// A laptop connected to servers gets its own home; otherwise, what this is and how to start.
|
|
126
202
|
if (!command && await laptopHome(laptopIO))
|
|
127
203
|
return 0;
|
|
204
|
+
// A laptop with nothing set up yet: the wizard (it needs someone to answer it).
|
|
205
|
+
if (!command && terminal && !isRoot() && (await loadRemotes()).length === 0)
|
|
206
|
+
return await setup({ io: laptopIO, terminal, localSetup: (input) => setUpOnThisComputer(input) });
|
|
128
207
|
io.out(NOT_A_SERVER(colors));
|
|
129
208
|
return command ? 1 : 0;
|
|
130
209
|
}
|
|
@@ -140,6 +219,8 @@ export async function main(argv, io = {
|
|
|
140
219
|
await serverHome(system, state, io.out, colors);
|
|
141
220
|
return 0;
|
|
142
221
|
}
|
|
222
|
+
case "setup":
|
|
223
|
+
return await setup({ io: laptopIO, terminal, localSetup: (input) => setUpOnThisComputer(input) });
|
|
143
224
|
case "connect": {
|
|
144
225
|
const url = parsed.positionals[1];
|
|
145
226
|
if (!url) {
|
|
@@ -212,6 +293,8 @@ export async function main(argv, io = {
|
|
|
212
293
|
return await serverRunsCommand(system, io.out, colors);
|
|
213
294
|
return await logsCommand(system, [...rest, ...(values.follow ? ["-f"] : [])]);
|
|
214
295
|
}
|
|
296
|
+
case "local":
|
|
297
|
+
return await localCommand(parsed.positionals.slice(1), { purge: Boolean(values.purge) }, io, colors, ui);
|
|
215
298
|
case "uninstall":
|
|
216
299
|
await uninstall(system, { purge: Boolean(values.purge) });
|
|
217
300
|
return 0;
|
|
@@ -222,6 +305,12 @@ export async function main(argv, io = {
|
|
|
222
305
|
}
|
|
223
306
|
catch (error) {
|
|
224
307
|
ui.finish();
|
|
308
|
+
if (jsonUi) {
|
|
309
|
+
// The event is what the laptop shows; the log's tail on stderr is for whoever looks.
|
|
310
|
+
jsonUi.error(describeError(error));
|
|
311
|
+
io.err(await logTail(UP_LOG, palette(false)));
|
|
312
|
+
return 1;
|
|
313
|
+
}
|
|
225
314
|
const c = palette(live && !process.env.NO_COLOR);
|
|
226
315
|
io.err(`\n ${c.red("✗")} ${describeError(error).replace(/\n/g, "\n ")}\n`);
|
|
227
316
|
if (live)
|
|
@@ -235,13 +324,43 @@ const NOT_A_SERVER = (c) => `
|
|
|
235
324
|
Background coding agents on your own server: send a task from your laptop,
|
|
236
325
|
get a pull request back.
|
|
237
326
|
|
|
238
|
-
${c.dim("Set up
|
|
327
|
+
${c.dim("Set it up from your laptop (asks a few questions):")}
|
|
328
|
+
dl setup
|
|
329
|
+
|
|
330
|
+
${c.dim("Or on a server (as root, on Ubuntu or Debian):")}
|
|
239
331
|
curl -fsSL https://directionallylocal.run | sh
|
|
240
332
|
|
|
241
333
|
${c.dim("Already have one? Connect this laptop to it:")}
|
|
242
334
|
dl connect https://your-server
|
|
243
335
|
|
|
244
336
|
`;
|
|
337
|
+
/**
|
|
338
|
+
* The `--answers-stdin` JSON: everything up to the end of stdin, or sooner once what came
|
|
339
|
+
* so far parses (a laptop that keeps the SSH channel open still gets going). Keys arrive
|
|
340
|
+
* this way so they're never in argv, the environment, or a file.
|
|
341
|
+
*/
|
|
342
|
+
export async function readAnswers(stdin) {
|
|
343
|
+
if (stdin.isTTY)
|
|
344
|
+
throw new Error("--answers-stdin reads one JSON object from a pipe, not a terminal.");
|
|
345
|
+
let text = "";
|
|
346
|
+
for await (const chunk of stdin) {
|
|
347
|
+
text += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
348
|
+
if (text.length > 64 * 1024)
|
|
349
|
+
throw new Error("The answers on stdin are too long; expected one small JSON object.");
|
|
350
|
+
if (text.includes("\n") && parses(text))
|
|
351
|
+
return text;
|
|
352
|
+
}
|
|
353
|
+
return text;
|
|
354
|
+
}
|
|
355
|
+
function parses(text) {
|
|
356
|
+
try {
|
|
357
|
+
JSON.parse(text);
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
245
364
|
/** Ctrl-C aborts the command (a running task keeps going on the server). */
|
|
246
365
|
async function interruptible(work) {
|
|
247
366
|
const controller = new AbortController();
|
package/dist/doctor.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { detectHost, MIN_DISK_GB } from "./detect.js";
|
|
2
2
|
import { nodeAdvisory } from "./node-advisory.js";
|
|
3
3
|
import { AUTOPILOT_PORT, AUTO_UPGRADES, HOTCELL_PORT, NODE_AUTO_UPDATES, PATHS, SERVICES, parseEnvFile } from "./layout.js";
|
|
4
|
-
import { readState } from "./state.js";
|
|
4
|
+
import { describeAccess, readState } from "./state.js";
|
|
5
5
|
import { palette } from "./ui.js";
|
|
6
6
|
/** `directionallylocal doctor`: every moving part, with a fix for each failure. */
|
|
7
7
|
export async function doctor(system) {
|
|
@@ -9,7 +9,7 @@ export async function doctor(system) {
|
|
|
9
9
|
const add = (check) => checks.push(check);
|
|
10
10
|
const state = await readState(system);
|
|
11
11
|
add(state
|
|
12
|
-
? { name: "installed", ok: true, detail: `${state.release}, ${state.access.mode} at ${state.access.url}` }
|
|
12
|
+
? { name: "installed", ok: true, detail: `${state.release}, ${state.access.mode === "ssh" ? describeAccess(state.access) : `${state.access.mode} at ${state.access.url}`}` }
|
|
13
13
|
: { name: "installed", ok: false, detail: "not installed", fix: "sudo dl up" });
|
|
14
14
|
const facts = await detectHost(system);
|
|
15
15
|
const node = nodeAdvisory(facts.nodeVersion ?? "");
|
|
@@ -49,6 +49,7 @@ export async function doctor(system) {
|
|
|
49
49
|
add({ name: "github", ok: github, detail: github ? "token configured" : "no token; runs can't push or open PRs", fix: "sudo dl up (asks for it)" });
|
|
50
50
|
const autopilot = (await system.probe("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", `http://127.0.0.1:${AUTOPILOT_PORT}/api/v1/health`])).stdout.trim();
|
|
51
51
|
add({ name: "agent server API", ok: autopilot === "401", detail: autopilot === "401" ? "up (refuses requests without a token)" : `HTTP ${autopilot || "none"}`, fix: "dl logs autopilot · then dl up" });
|
|
52
|
+
// Loopback and SSH access have nothing published to check: the tunnel is the laptop's.
|
|
52
53
|
if (state?.access.mode === "tailscale") {
|
|
53
54
|
const serve = await system.probe("tailscale", ["serve", "status"]);
|
|
54
55
|
const serving = serve.code === 0 && serve.stdout.includes(`127.0.0.1:${AUTOPILOT_PORT}`);
|
package/dist/home.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { agentCapacity, detectHost } from "./detect.js";
|
|
2
2
|
import { AUTOPILOT_PORT, CELL_MEMORY_MB, SERVICES } from "./layout.js";
|
|
3
|
-
import { approvePairing, listLaptops, listServerRuns, ownerLink, pendingPairings, revokeLaptop } from "./server-api.js";
|
|
4
|
-
import { readState } from "./state.js";
|
|
3
|
+
import { approvePairing, createLaptopCredential, listLaptops, listServerRuns, ownerLink, pendingPairings, revokeLaptop } from "./server-api.js";
|
|
4
|
+
import { describeAccess, readState } from "./state.js";
|
|
5
5
|
import { palette } from "./ui.js";
|
|
6
6
|
import { nextSteps } from "./up.js";
|
|
7
7
|
const ABOUT = "Background coding agents on your own server: send a task from your laptop, get a pull request back.";
|
|
@@ -29,7 +29,7 @@ export async function serverHome(system, state, write, c) {
|
|
|
29
29
|
const running = runs?.filter((run) => run.active).length ?? 0;
|
|
30
30
|
const doneThisWeek = runs?.filter((run) => !run.active && Date.parse(run.createdAt) >= week).length ?? 0;
|
|
31
31
|
const row = (label, value, hint = "") => ` ${c.dim(label.padEnd(11))}${value}${hint ? ` ${c.dim(hint)}` : ""}\n`;
|
|
32
|
-
write(`\n ${c.bold("Directionally Local")} ${verdict} ${c.dim(state.access
|
|
32
|
+
write(`\n ${c.bold("Directionally Local")} ${verdict} ${c.dim(describeAccess(state.access))}\n\n ${ABOUT}\n\n`);
|
|
33
33
|
write(row("Laptops", laptops === undefined ? c.dim("unknown (run dl up to update)") : laptops.length === 0 ? "none connected yet" : `${laptops.length} connected · ${laptops.map((laptop) => laptop.name).join(", ")}`, "dl laptops"));
|
|
34
34
|
write(row("Runs", runs === undefined ? c.dim("unknown (run dl up to update)") : `${running} running · ${doneThisWeek} this week`, "dl runs"));
|
|
35
35
|
write(row("Capacity", `~${agentCapacity(facts, CELL_MEMORY_MB)} agents at once (estimate)`));
|
|
@@ -41,7 +41,7 @@ export async function serverHome(system, state, write, c) {
|
|
|
41
41
|
/** `dl status --brief`: one line, for the login message. Never slow, never fails loudly. */
|
|
42
42
|
export async function briefStatus(system, state, write, c) {
|
|
43
43
|
const status = await health(system);
|
|
44
|
-
write(`\n ${c.bold("Directionally Local")} ${status.ok ? c.green("● healthy") : c.red(`● ${status.problems.join(", ")}`)} ${c.dim(state.access
|
|
44
|
+
write(`\n ${c.bold("Directionally Local")} ${status.ok ? c.green("● healthy") : c.red(`● ${status.problems.join(", ")}`)} ${c.dim(describeAccess(state.access))}\n ${c.dim("Type")} dl ${c.dim("for what this is and what's running.")}\n\n`);
|
|
45
45
|
}
|
|
46
46
|
/**
|
|
47
47
|
* `dl connect-laptop [CODE]`: approve a laptop's code, or explain how to connect one and
|
|
@@ -65,6 +65,18 @@ export async function connectLaptop(system, state, code, write, c) {
|
|
|
65
65
|
write(`\n ${c.dim("The laptop shows a code; approve it in that browser, or here with")} dl connect-laptop <code>\n\n`);
|
|
66
66
|
return 0;
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* `dl pair --name NAME --json`: a credential for the laptop that's setting this server up
|
|
70
|
+
* over SSH, printed as one JSON line for it to keep. The token appears nowhere else.
|
|
71
|
+
*/
|
|
72
|
+
export async function pairLaptop(system, state, name, write) {
|
|
73
|
+
const trimmed = name.trim();
|
|
74
|
+
if (!trimmed || trimmed.length > 100 || /[\u0000-\u001f\u007f]/.test(trimmed)) {
|
|
75
|
+
throw new Error("--name must be 1 to 100 printable characters, e.g. the laptop's hostname.");
|
|
76
|
+
}
|
|
77
|
+
const paired = await createLaptopCredential(system, trimmed, state.access.url);
|
|
78
|
+
write(`${JSON.stringify({ token: paired.token, credentialId: paired.credentialId, name: paired.name, expiresAt: paired.expiresAt, url: paired.url })}\n`);
|
|
79
|
+
}
|
|
68
80
|
export async function laptopsCommand(system, args, write, c) {
|
|
69
81
|
const laptops = await listLaptops(system);
|
|
70
82
|
if (laptops === undefined) {
|
package/dist/laptop/commands.js
CHANGED
|
@@ -6,11 +6,15 @@ import { palette } from "../ui.js";
|
|
|
6
6
|
import { RemoteApiError, RemoteClient, TERMINAL_STATUSES } from "./client.js";
|
|
7
7
|
import { defaultRemoteName, loadRemotes, normalizeServerUrl, REMOTE_NAME_RE, removeRemote, resolveRemote, saveRemote } from "./config.js";
|
|
8
8
|
import { detectGitHubRepository, snapshotWorkingTree } from "./git.js";
|
|
9
|
+
import { sshCommandLine } from "./ssh.js";
|
|
9
10
|
import { openBrowser } from "./open-browser.js";
|
|
11
|
+
import { withConnections } from "./tunnel.js";
|
|
10
12
|
/**
|
|
11
13
|
* `dl` on a laptop: connect to a server, send it tasks, follow them, and check out the
|
|
12
14
|
* pull requests. It talks to the server's API directly (the same one Autopilot's
|
|
13
15
|
* `--on` uses, and shares its list of servers), so the laptop needs nothing else.
|
|
16
|
+
* Servers set up with `dl setup` are reached through an SSH tunnel that each command
|
|
17
|
+
* opens and closes (see tunnel.ts); `open` lets tests swap the tunnel out.
|
|
14
18
|
*/
|
|
15
19
|
const execFileAsync = promisify(execFile);
|
|
16
20
|
const RUN_END_EVENTS = new Set(["completed", "failed", "cancelled", "interrupted"]);
|
|
@@ -56,9 +60,11 @@ export async function connect(input, io = processIO) {
|
|
|
56
60
|
* checkout (uncommitted changes go along). Follows it until it ends; Ctrl-C only detaches.
|
|
57
61
|
*/
|
|
58
62
|
export async function run(input, io = processIO) {
|
|
59
|
-
const c = palette(io.color === true);
|
|
60
63
|
const remote = await resolveRemote(input.server);
|
|
61
|
-
|
|
64
|
+
return withConnections(input.open, async (connections) => runOn(await connections.client(remote), remote, input, io));
|
|
65
|
+
}
|
|
66
|
+
async function runOn(client, remote, input, io) {
|
|
67
|
+
const c = palette(io.color === true);
|
|
62
68
|
const repository = input.repository ?? await detectGitHubRepository(input.cwd);
|
|
63
69
|
const snapshot = await snapshotWorkingTree(input.cwd);
|
|
64
70
|
const [workspaces, models] = await Promise.all([client.workspaces(), client.models()]);
|
|
@@ -88,14 +94,17 @@ export async function run(input, io = processIO) {
|
|
|
88
94
|
}
|
|
89
95
|
/** `dl runs`: tasks on every connected server, newest first. */
|
|
90
96
|
export async function runs(input, io = processIO) {
|
|
91
|
-
const c = palette(io.color === true);
|
|
92
97
|
const remotes = input.server ? [await resolveRemote(input.server)] : await loadRemotes();
|
|
93
98
|
if (remotes.length === 0)
|
|
94
99
|
throw new Error("This laptop isn't connected to a server yet. Run: dl connect https://your-server");
|
|
100
|
+
return withConnections(input.open, (connections) => listRuns(remotes, connections, input.json === true, io));
|
|
101
|
+
}
|
|
102
|
+
async function listRuns(remotes, connections, json, io) {
|
|
103
|
+
const c = palette(io.color === true);
|
|
95
104
|
const rows = [];
|
|
96
105
|
for (const remote of remotes) {
|
|
97
106
|
try {
|
|
98
|
-
const list = await
|
|
107
|
+
const list = await (await connections.client(remote)).listConversations();
|
|
99
108
|
rows.push(...list.filter((entry) => entry.kind === "code").map((entry) => ({ ...entry, server: remote.name })));
|
|
100
109
|
}
|
|
101
110
|
catch (error) {
|
|
@@ -103,7 +112,7 @@ export async function runs(input, io = processIO) {
|
|
|
103
112
|
}
|
|
104
113
|
}
|
|
105
114
|
rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
106
|
-
if (
|
|
115
|
+
if (json) {
|
|
107
116
|
io.out(`${JSON.stringify(rows, null, 2)}\n`);
|
|
108
117
|
return;
|
|
109
118
|
}
|
|
@@ -122,21 +131,26 @@ export async function runs(input, io = processIO) {
|
|
|
122
131
|
}
|
|
123
132
|
/** `dl watch <id>`: replay and follow a task's output. */
|
|
124
133
|
export async function watch(input, io = processIO) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
134
|
+
return withConnections(input.open, async (connections) => {
|
|
135
|
+
const { remote, run: found } = await findRun(input.id, input.server, connections);
|
|
136
|
+
const client = await connections.client(remote);
|
|
137
|
+
const finished = TERMINAL_STATUSES.has(found.status) && !found.activeRunId;
|
|
138
|
+
return follow(client, remote, found.conversationId, 0, input.signal, io, finished ? found.lastEventId : undefined);
|
|
139
|
+
});
|
|
129
140
|
}
|
|
130
141
|
export async function cancel(input, io = processIO) {
|
|
131
142
|
const c = palette(io.color === true);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
143
|
+
await withConnections(input.open, async (connections) => {
|
|
144
|
+
const { remote, run: found } = await findRun(input.id, input.server, connections);
|
|
145
|
+
await (await connections.client(remote)).cancel(found.conversationId);
|
|
146
|
+
io.out(` ${c.green("✓")} Stopped ${found.conversationId.slice(0, 8)}\n`);
|
|
147
|
+
});
|
|
135
148
|
}
|
|
136
149
|
/** `dl pull <id>`: check out a task's branch here. */
|
|
137
150
|
export async function pull(input, io = processIO) {
|
|
138
151
|
const c = palette(io.color === true);
|
|
139
|
-
|
|
152
|
+
// The server is only asked for the branch name; the code itself comes from GitHub.
|
|
153
|
+
const { run: found } = await withConnections(input.open, (connections) => findRun(input.id, input.server, connections));
|
|
140
154
|
const branch = found.branch;
|
|
141
155
|
if (!branch)
|
|
142
156
|
throw new Error("That task has no branch");
|
|
@@ -164,40 +178,44 @@ export async function servers(args, io = processIO) {
|
|
|
164
178
|
const c = palette(io.color === true);
|
|
165
179
|
if (args[0] === "remove") {
|
|
166
180
|
const name = args[1] ?? "";
|
|
167
|
-
|
|
181
|
+
const removed = (await loadRemotes()).find((remote) => remote.name === name);
|
|
182
|
+
if (!removed || !(await removeRemote(name))) {
|
|
168
183
|
io.err(` ${c.red("✗")} Not connected to "${name}".\n`);
|
|
169
184
|
return 1;
|
|
170
185
|
}
|
|
171
|
-
|
|
186
|
+
const onServer = `sudo dl laptops remove "${clientName()}"`;
|
|
187
|
+
io.out(` ${c.green("✓")} Forgot ${name}. To disconnect it on the server too: ${removed.ssh ? `${sshCommandLine(removed.ssh)} '${onServer}'` : onServer}\n`);
|
|
172
188
|
return 0;
|
|
173
189
|
}
|
|
174
190
|
const remotes = await loadRemotes();
|
|
175
191
|
if (remotes.length === 0) {
|
|
176
|
-
io.out(`\n Not connected to a server yet. ${c.bold("dl connect https://your-server")}\n\n`);
|
|
192
|
+
io.out(`\n Not connected to a server 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
193
|
return 0;
|
|
178
194
|
}
|
|
179
195
|
io.out("\n");
|
|
180
196
|
for (const remote of remotes)
|
|
181
|
-
io.out(` ${c.green("●")} ${remote.name.padEnd(16)} ${remote
|
|
197
|
+
io.out(` ${c.green("●")} ${remote.name.padEnd(16)} ${where(remote)} ${c.dim(`until ${remote.expiresAt.slice(0, 10)}`)}\n`);
|
|
182
198
|
io.out("\n");
|
|
183
199
|
return 0;
|
|
184
200
|
}
|
|
185
201
|
/** `dl` on a laptop: what this is, which servers it's connected to, what to type next. */
|
|
186
|
-
export async function laptopHome(io = processIO) {
|
|
202
|
+
export async function laptopHome(io = processIO, open) {
|
|
187
203
|
const c = palette(io.color === true);
|
|
188
204
|
const remotes = await loadRemotes();
|
|
189
205
|
if (remotes.length === 0)
|
|
190
206
|
return false;
|
|
191
207
|
let running = 0;
|
|
192
208
|
let total = 0;
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
209
|
+
await withConnections(open, async (connections) => {
|
|
210
|
+
for (const remote of remotes) {
|
|
211
|
+
const list = await connections.client(remote).then((client) => client.listConversations()).catch(() => undefined);
|
|
212
|
+
running += list?.filter((entry) => entry.activeRunId !== null).length ?? 0;
|
|
213
|
+
total += list?.length ?? 0;
|
|
214
|
+
}
|
|
215
|
+
});
|
|
198
216
|
io.out(`\n ${c.bold("Directionally Local")} ${c.dim("on this laptop")}\n\n`);
|
|
199
217
|
io.out(" Background coding agents on your own server: send a task, get a pull request back.\n\n");
|
|
200
|
-
io.out(` ${c.dim("Servers".padEnd(11))}${remotes.map((remote) => `${remote.name} ${c.dim(remote
|
|
218
|
+
io.out(` ${c.dim("Servers".padEnd(11))}${remotes.map((remote) => `${remote.name} ${c.dim(where(remote))}`).join(", ")} ${c.dim("dl servers")}\n`);
|
|
201
219
|
io.out(` ${c.dim("Tasks".padEnd(11))}${running} running · ${total} in all ${c.dim("dl runs")}\n\n`);
|
|
202
220
|
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
221
|
return true;
|
|
@@ -285,20 +303,25 @@ class EventRenderer {
|
|
|
285
303
|
this.io.out(`${text}\n`);
|
|
286
304
|
}
|
|
287
305
|
}
|
|
288
|
-
async function findRun(id, server) {
|
|
306
|
+
async function findRun(id, server, connections) {
|
|
289
307
|
const needle = id.trim().toLowerCase();
|
|
290
308
|
if (!/^[0-9a-f-]{4,36}$/.test(needle))
|
|
291
309
|
throw new Error("Task IDs are the short hex IDs dl runs shows");
|
|
292
310
|
const remotes = server ? [await resolveRemote(server)] : await loadRemotes();
|
|
293
311
|
const matches = [];
|
|
312
|
+
const unreachable = [];
|
|
294
313
|
for (const remote of remotes) {
|
|
295
|
-
const list = await
|
|
314
|
+
const list = await connections.client(remote).then((client) => client.listConversations(200)).catch((error) => {
|
|
315
|
+
unreachable.push(`${remote.name}: ${describeError(error)}`);
|
|
316
|
+
return [];
|
|
317
|
+
});
|
|
296
318
|
for (const entry of list)
|
|
297
319
|
if (entry.conversationId.startsWith(needle))
|
|
298
320
|
matches.push({ remote, run: entry });
|
|
299
321
|
}
|
|
322
|
+
// Say why when a server couldn't be asked (e.g. its SSH tunnel failed), rather than "no task".
|
|
300
323
|
if (matches.length === 0)
|
|
301
|
-
throw new Error(`No task ${id} on ${server ?? "any connected server"}`);
|
|
324
|
+
throw new Error(`No task ${id} on ${server ?? "any connected server"}${unreachable.length ? `\n${unreachable.join("\n")}` : ""}`);
|
|
302
325
|
if (matches.length > 1)
|
|
303
326
|
throw new Error(`${id} matches several tasks; use more characters`);
|
|
304
327
|
return matches[0];
|
|
@@ -316,6 +339,10 @@ function pickModel(models, requested, preferred) {
|
|
|
316
339
|
throw new Error("The server offers no models");
|
|
317
340
|
return first;
|
|
318
341
|
}
|
|
342
|
+
/** Where a server is, as people know it: its URL, or the SSH destination its tunnel goes to. */
|
|
343
|
+
function where(remote) {
|
|
344
|
+
return remote.ssh ? `ssh ${remote.ssh.target}${remote.ssh.port ? `:${remote.ssh.port}` : ""}` : remote.url;
|
|
345
|
+
}
|
|
319
346
|
function clientName() {
|
|
320
347
|
const host = hostname().replace(/\.local$/, "").replace(/[^\p{L}\p{N} ._'()@-]/gu, "-").slice(0, 40) || "laptop";
|
|
321
348
|
return `${host} (dl)`;
|
package/dist/laptop/config.js
CHANGED
|
@@ -85,5 +85,15 @@ function isRemote(value) {
|
|
|
85
85
|
if (!value || typeof value !== "object")
|
|
86
86
|
return false;
|
|
87
87
|
const entry = value;
|
|
88
|
-
|
|
88
|
+
if (!["name", "url", "token", "credentialId", "expiresAt", "addedAt"].every((key) => typeof entry[key] === "string"))
|
|
89
|
+
return false;
|
|
90
|
+
return entry.ssh === undefined || isSshAccess(entry.ssh);
|
|
91
|
+
}
|
|
92
|
+
function isSshAccess(value) {
|
|
93
|
+
if (!value || typeof value !== "object")
|
|
94
|
+
return false;
|
|
95
|
+
const ssh = value;
|
|
96
|
+
return typeof ssh.target === "string" && ssh.target.length > 0 && !ssh.target.startsWith("-") &&
|
|
97
|
+
(ssh.port === undefined || (typeof ssh.port === "number" && Number.isInteger(ssh.port) && ssh.port > 0 && ssh.port < 65536)) &&
|
|
98
|
+
(ssh.identityFile === undefined || typeof ssh.identityFile === "string");
|
|
89
99
|
}
|
|
@@ -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
|
+
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
|
+
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
|
+
}
|