directionallylocal 0.1.0 → 0.2.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 +110 -9
- package/dist/doctor.js +3 -2
- package/dist/home.js +16 -4
- 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 +46 -5
- package/package.json +10 -2
package/dist/cli.js
CHANGED
|
@@ -2,14 +2,14 @@ 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
7
|
import { TtyPrompter } from "./prompt.js";
|
|
8
8
|
import { readState } from "./state.js";
|
|
9
9
|
import { DryRunSystem, RealSystem } from "./system.js";
|
|
10
10
|
import { uninstall } from "./uninstall.js";
|
|
11
|
-
import { PlainUi, TerminalUi, palette } from "./ui.js";
|
|
12
|
-
import { up } from "./up.js";
|
|
11
|
+
import { JsonUi, PlainUi, TerminalUi, palette } from "./ui.js";
|
|
12
|
+
import { needs, parseAccessMode, parseProvidedAnswers, up } from "./up.js";
|
|
13
13
|
const HELP = `Directionally Local: background coding agents on your own server.
|
|
14
14
|
Send a task from your laptop; it runs in its own sandbox and comes back as a pull request.
|
|
15
15
|
(dl is short for directionallylocal; both work.)
|
|
@@ -46,7 +46,14 @@ Unattended installs answer the questions with environment variables:
|
|
|
46
46
|
|
|
47
47
|
Advanced:
|
|
48
48
|
DL_ACCESS=loopback don't expose the server; reach it over an SSH tunnel
|
|
49
|
+
(or --access tailscale|loopback|ssh; ssh is the laptop's setup)
|
|
49
50
|
DL_SKIP_CREDENTIAL_CHECK=1 don't check the keys with OpenRouter and GitHub (offline installs)
|
|
51
|
+
|
|
52
|
+
Used by the laptop's setup (over SSH, as root):
|
|
53
|
+
dl up --access ssh --answers-stdin --events json
|
|
54
|
+
install with the keys as one JSON object on stdin; progress as JSON lines
|
|
55
|
+
dl up --needs [--access ssh] which keys dl up would still need, as JSON; changes nothing
|
|
56
|
+
dl pair --name NAME --json a credential for that laptop, as JSON
|
|
50
57
|
`;
|
|
51
58
|
export async function main(argv, io = {
|
|
52
59
|
out: (text) => process.stdout.write(text),
|
|
@@ -73,6 +80,10 @@ export async function main(argv, io = {
|
|
|
73
80
|
repo: { type: "string" },
|
|
74
81
|
detach: { type: "boolean" },
|
|
75
82
|
json: { type: "boolean" },
|
|
83
|
+
access: { type: "string" },
|
|
84
|
+
"answers-stdin": { type: "boolean" },
|
|
85
|
+
events: { type: "string" },
|
|
86
|
+
needs: { type: "boolean" },
|
|
76
87
|
},
|
|
77
88
|
});
|
|
78
89
|
}
|
|
@@ -90,27 +101,84 @@ export async function main(argv, io = {
|
|
|
90
101
|
io.out(HELP);
|
|
91
102
|
return 0;
|
|
92
103
|
}
|
|
93
|
-
|
|
104
|
+
if (values.events !== undefined && values.events !== "json") {
|
|
105
|
+
io.err(`--events takes json (got "${values.events}").\n\n${HELP}`);
|
|
106
|
+
return 2;
|
|
107
|
+
}
|
|
108
|
+
let access;
|
|
109
|
+
try {
|
|
110
|
+
access = values.access === undefined ? undefined : parseAccessMode(values.access);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
io.err(`${error.message}\n`);
|
|
114
|
+
return 2;
|
|
115
|
+
}
|
|
116
|
+
// `--events json`: stdout carries only the laptop's JSON events; everything else goes to
|
|
117
|
+
// stderr, and other tools' output to the install log, as with the live checklist.
|
|
118
|
+
const json = command === "up" && values.events === "json" && !values.needs;
|
|
119
|
+
const log = (line) => (json ? io.err : io.out)(`${line}\n`);
|
|
94
120
|
// The live checklist needs a terminal; a dry run prints its plan line by line instead.
|
|
95
121
|
const terminal = options.terminal ?? (process.stdout.isTTY === true);
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
const
|
|
122
|
+
// --needs only looks: it mustn't even start a new install log.
|
|
123
|
+
const live = terminal && command === "up" && !values["dry-run"] && !values.needs && !json;
|
|
124
|
+
const jsonUi = json ? new JsonUi(io.out) : undefined;
|
|
125
|
+
const ui = jsonUi ?? (live ? new TerminalUi(io.out) : new PlainUi(io.out));
|
|
126
|
+
const colorOn = terminal && !json && !process.env.NO_COLOR && process.env.TERM !== "dumb";
|
|
99
127
|
const colors = palette(colorOn);
|
|
100
128
|
const laptopIO = { out: io.out, err: io.err, color: colorOn };
|
|
101
|
-
const
|
|
129
|
+
const logged = (live || json) && !values["dry-run"];
|
|
130
|
+
let system;
|
|
131
|
+
try {
|
|
132
|
+
system = values["dry-run"] ? new DryRunSystem(log) : new RealSystem(log, logged ? { outputLog: UP_LOG } : {});
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
// Opening the install log is the first write, so this is where a non-root `up` stops.
|
|
136
|
+
const message = `Couldn't open ${UP_LOG}: ${describeError(error)}. Run it as root: sudo dl up`;
|
|
137
|
+
if (jsonUi)
|
|
138
|
+
jsonUi.error(message);
|
|
139
|
+
else
|
|
140
|
+
io.err(`\n ${palette(live && !process.env.NO_COLOR).red("✗")} ${message}\n`);
|
|
141
|
+
return 1;
|
|
142
|
+
}
|
|
102
143
|
try {
|
|
103
144
|
switch (command) {
|
|
104
|
-
case "up":
|
|
145
|
+
case "up": {
|
|
146
|
+
if (values.needs) {
|
|
147
|
+
io.out(`${JSON.stringify({ needs: await needs(system, access ? { access } : {}) })}\n`);
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
// Read before anything else happens, so a bad answer fails before any change.
|
|
151
|
+
const answers = values["answers-stdin"] ? parseProvidedAnswers(await readAnswers(options.stdin ?? process.stdin)) : undefined;
|
|
105
152
|
if (system.dryRun)
|
|
106
153
|
log("Dry run: nothing on this machine will change.\n");
|
|
107
154
|
await up(system, {
|
|
108
155
|
prompter: new TtyPrompter(),
|
|
109
156
|
ui,
|
|
157
|
+
...(access ? { access } : {}),
|
|
158
|
+
...(answers ? { answers } : {}),
|
|
110
159
|
...(values["autopilot-version"] ? { autopilotVersion: values["autopilot-version"] } : {}),
|
|
111
160
|
...(values["hotcell-version"] ? { hotcellVersion: values["hotcell-version"] } : {}),
|
|
112
161
|
});
|
|
113
162
|
return 0;
|
|
163
|
+
}
|
|
164
|
+
case "pair": {
|
|
165
|
+
// Only the laptop's setup runs this, and it reads the JSON; there's no human form.
|
|
166
|
+
if (!values.json || !values.name?.trim()) {
|
|
167
|
+
io.err(` Usage: dl pair --name NAME --json ${colors.dim("(run by the laptop's setup, over SSH)")}\n`);
|
|
168
|
+
return 2;
|
|
169
|
+
}
|
|
170
|
+
const state = await readState(system);
|
|
171
|
+
if (!state) {
|
|
172
|
+
io.err(" Directionally Local isn't installed on this server yet; run dl up first.\n");
|
|
173
|
+
return 1;
|
|
174
|
+
}
|
|
175
|
+
if (!isRoot()) {
|
|
176
|
+
io.err(" Run it as root: sudo dl pair --name NAME --json\n");
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
await pairLaptop(system, state, values.name, io.out);
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
114
182
|
case "doctor": {
|
|
115
183
|
const checks = await doctor(system);
|
|
116
184
|
log(formatChecks(checks, terminal && !process.env.NO_COLOR));
|
|
@@ -222,6 +290,12 @@ export async function main(argv, io = {
|
|
|
222
290
|
}
|
|
223
291
|
catch (error) {
|
|
224
292
|
ui.finish();
|
|
293
|
+
if (jsonUi) {
|
|
294
|
+
// The event is what the laptop shows; the log's tail on stderr is for whoever looks.
|
|
295
|
+
jsonUi.error(describeError(error));
|
|
296
|
+
io.err(await logTail(UP_LOG, palette(false)));
|
|
297
|
+
return 1;
|
|
298
|
+
}
|
|
225
299
|
const c = palette(live && !process.env.NO_COLOR);
|
|
226
300
|
io.err(`\n ${c.red("✗")} ${describeError(error).replace(/\n/g, "\n ")}\n`);
|
|
227
301
|
if (live)
|
|
@@ -242,6 +316,33 @@ const NOT_A_SERVER = (c) => `
|
|
|
242
316
|
dl connect https://your-server
|
|
243
317
|
|
|
244
318
|
`;
|
|
319
|
+
/**
|
|
320
|
+
* The `--answers-stdin` JSON: everything up to the end of stdin, or sooner once what came
|
|
321
|
+
* so far parses (a laptop that keeps the SSH channel open still gets going). Keys arrive
|
|
322
|
+
* this way so they're never in argv, the environment, or a file.
|
|
323
|
+
*/
|
|
324
|
+
export async function readAnswers(stdin) {
|
|
325
|
+
if (stdin.isTTY)
|
|
326
|
+
throw new Error("--answers-stdin reads one JSON object from a pipe, not a terminal.");
|
|
327
|
+
let text = "";
|
|
328
|
+
for await (const chunk of stdin) {
|
|
329
|
+
text += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
330
|
+
if (text.length > 64 * 1024)
|
|
331
|
+
throw new Error("The answers on stdin are too long; expected one small JSON object.");
|
|
332
|
+
if (text.includes("\n") && parses(text))
|
|
333
|
+
return text;
|
|
334
|
+
}
|
|
335
|
+
return text;
|
|
336
|
+
}
|
|
337
|
+
function parses(text) {
|
|
338
|
+
try {
|
|
339
|
+
JSON.parse(text);
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
245
346
|
/** Ctrl-C aborts the command (a running task keeps going on the server). */
|
|
246
347
|
async function interruptible(work) {
|
|
247
348
|
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/server-api.js
CHANGED
|
@@ -42,6 +42,28 @@ export async function ownerLink(system, url) {
|
|
|
42
42
|
const link = result.stdout.trim().split("\n").at(-1);
|
|
43
43
|
return result.code === 0 && link?.startsWith("http") ? link : undefined;
|
|
44
44
|
}
|
|
45
|
+
/** How long a laptop's credential lasts; re-running the laptop's setup pairs it again. */
|
|
46
|
+
export const LAPTOP_CREDENTIAL_DAYS = 90;
|
|
47
|
+
/**
|
|
48
|
+
* Creates a credential for a laptop that set this server up over SSH, so it never has to
|
|
49
|
+
* show a code and wait for approval: being root here already proves it's the owner's.
|
|
50
|
+
* The token comes back in the command's output, so that output stays out of the log.
|
|
51
|
+
*/
|
|
52
|
+
export async function createLaptopCredential(system, name, url) {
|
|
53
|
+
const result = await autopilotServer(system, [
|
|
54
|
+
"aster", "token", "create", "--name", name, "--workspace", "default", "--expires-in-days", String(LAPTOP_CREDENTIAL_DAYS), "--json",
|
|
55
|
+
], { secretOutput: true });
|
|
56
|
+
if (result.code !== 0) {
|
|
57
|
+
throw new Error(`Couldn't create a credential for the laptop${result.stderr.trim() ? `: ${result.stderr.trim().split("\n").slice(-3).join(" ")}` : ""}; run dl up to update this server.`);
|
|
58
|
+
}
|
|
59
|
+
const created = parseJson(result);
|
|
60
|
+
const { credentialId, expiresAt, token } = created ?? {};
|
|
61
|
+
// Never echo the output when it doesn't parse: it may hold the token.
|
|
62
|
+
if (typeof token !== "string" || !token || typeof credentialId !== "string" || typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) {
|
|
63
|
+
throw new Error("Autopilot created a credential but its answer wasn't what dl expects; run dl up to update this server.");
|
|
64
|
+
}
|
|
65
|
+
return { token, credentialId, name, expiresAt: new Date(expiresAt).toISOString(), url };
|
|
66
|
+
}
|
|
45
67
|
function parseJson(result) {
|
|
46
68
|
if (result.code !== 0)
|
|
47
69
|
return undefined;
|
package/dist/state.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { PATHS } from "./layout.js";
|
|
2
|
+
export const ACCESS_MODES = ["tailscale", "loopback", "ssh"];
|
|
3
|
+
/** How people reach this server, in words (the address alone means little for a tunnel). */
|
|
4
|
+
export function describeAccess(access) {
|
|
5
|
+
return access.mode === "ssh" ? "over SSH from your laptop" : access.url;
|
|
6
|
+
}
|
|
2
7
|
export async function readState(system) {
|
|
3
8
|
const raw = await system.readFile(PATHS.state);
|
|
4
9
|
if (!raw)
|
package/dist/ui.js
CHANGED
|
@@ -201,6 +201,39 @@ export class PlainUi {
|
|
|
201
201
|
warn(text) { this.write(`!! ${text}\n`); }
|
|
202
202
|
finish() { }
|
|
203
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* For the laptop that runs `dl up` over SSH and draws the progress itself: one JSON object
|
|
206
|
+
* per line (NDJSON) on stdout, nothing else there. Nothing is asked in this mode, so a
|
|
207
|
+
* rejected answer is only a warning, and a prompt is never shown.
|
|
208
|
+
*/
|
|
209
|
+
export class JsonUi {
|
|
210
|
+
write;
|
|
211
|
+
interactive = false;
|
|
212
|
+
constructor(write = (text) => process.stdout.write(text)) {
|
|
213
|
+
this.write = write;
|
|
214
|
+
}
|
|
215
|
+
title(text) { this.emit({ event: "title", text }); }
|
|
216
|
+
line(status, label, detail) { this.emit({ event: "line", status, label, detail }); }
|
|
217
|
+
plan(steps) { this.emit({ event: "plan", steps: steps.map(({ id, label }) => ({ id, label })) }); }
|
|
218
|
+
start(id, detail) { this.emit({ event: "start", id, detail }); }
|
|
219
|
+
done(id, detail) { this.emit({ event: "done", id, detail }); }
|
|
220
|
+
fail(id, message) { this.emit({ event: "fail", id, message }); }
|
|
221
|
+
questions(intro) { this.emit({ event: "questions", text: intro }); }
|
|
222
|
+
prompt(label) { return label; }
|
|
223
|
+
answered(label, detail) { this.emit({ event: "answered", label, detail }); }
|
|
224
|
+
rejected(message) { this.emit({ event: "warn", text: message }); }
|
|
225
|
+
ready(headline, items, extra = {}) {
|
|
226
|
+
this.emit({ event: "ready", headline, items: items.map(({ label, value }) => ({ label, value })), about: extra.about, footer: extra.footer });
|
|
227
|
+
}
|
|
228
|
+
warn(text) { this.emit({ event: "warn", text }); }
|
|
229
|
+
/** The last event of a failed run (not part of Ui: the CLI reports failures, not `up`). */
|
|
230
|
+
error(message) { this.emit({ event: "error", message }); }
|
|
231
|
+
finish() { }
|
|
232
|
+
emit(event) {
|
|
233
|
+
// JSON.stringify drops undefined fields and escapes newlines, so each event is one line.
|
|
234
|
+
this.write(`${JSON.stringify(event)}\n`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
204
237
|
/** Records events for tests. */
|
|
205
238
|
export class RecordingUi {
|
|
206
239
|
interactive = false;
|
package/dist/up.js
CHANGED
|
@@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto";
|
|
|
2
2
|
import { hostname } from "node:os";
|
|
3
3
|
import { agentCapacity, detectHost, preflightProblems } from "./detect.js";
|
|
4
4
|
import { AUTOPILOT_PORT, CELL_MEMORY_MB, GITHUB_PROXY_PORT, HOTCELL_EGRESS_PORT, HOTCELL_PORT, PATHS, SERVICES, USERS, AUTO_UPGRADES, NODE_AUTO_UPDATES, asterConfig, autoUpgrades, autopilotBin, autopilotEnv, autopilotUnit, MOTD_SCRIPT, cellDockerfile, cellImage, githubEnv, hotcellBin, hotcellEnv, hotcellUnit, metadataBlockUnit, motdScript, nodeAutoUpdates, parseEnvFile, } from "./layout.js";
|
|
5
|
-
import { readState, writeState } from "./state.js";
|
|
5
|
+
import { ACCESS_MODES, readState, writeState } from "./state.js";
|
|
6
6
|
import { PlainUi } from "./ui.js";
|
|
7
7
|
import { ownerLink } from "./server-api.js";
|
|
8
8
|
const DRY_RUN_BRIDGE_IP = "172.17.0.1";
|
|
@@ -26,9 +26,9 @@ export async function up(system, options) {
|
|
|
26
26
|
ui.line("done", "Checked this machine", `${facts.os.prettyName.replace(/ LTS$/, "")} · ${facts.cpus} CPU · ${(facts.memoryMb / 1024).toFixed(1)} GB · ${facts.diskFreeGb} GB free`);
|
|
27
27
|
// Every question comes first, so the long part runs unattended.
|
|
28
28
|
const previous = await readState(system);
|
|
29
|
-
const access = accessMode(env, previous);
|
|
29
|
+
const access = accessMode(options.access, env, previous);
|
|
30
30
|
const check = env.DL_SKIP_CREDENTIAL_CHECK === "1" ? undefined : fetchImpl;
|
|
31
|
-
const answers = await collectAnswers(system, ui, options.prompter, env, check, facts, access);
|
|
31
|
+
const answers = await collectAnswers(system, ui, options.prompter, given(env, options.answers), check, facts, access);
|
|
32
32
|
ui.plan([
|
|
33
33
|
...(access === "tailscale" ? [{ id: "tailnet", label: "Joining your tailnet" }] : []),
|
|
34
34
|
{ id: "docker", label: "Setting up Docker" },
|
|
@@ -121,6 +121,10 @@ export async function up(system, options) {
|
|
|
121
121
|
return { value: apiKey, detail: "sandboxes and keys ready" };
|
|
122
122
|
});
|
|
123
123
|
const url = await step("access", async () => {
|
|
124
|
+
// Moving off Tailscale: stop publishing on the tailnet, so the only way in is the new one.
|
|
125
|
+
if (access !== "tailscale" && previous?.access.mode === "tailscale") {
|
|
126
|
+
await system.run("tailscale", ["serve", "--https=443", `http://127.0.0.1:${AUTOPILOT_PORT}`, "off"], { allowFailure: true });
|
|
127
|
+
}
|
|
124
128
|
const address = access === "tailscale" ? await serveOverTailscale(system) : `http://127.0.0.1:${AUTOPILOT_PORT}`;
|
|
125
129
|
await system.writeFile(PATHS.autopilotEnv, autopilotEnv({ hotcellApiKey, image, bridgeIp, publicUrl: address }), { mode: 0o640, owner: `root:${USERS.autopilot}`, secret: true });
|
|
126
130
|
await system.run("systemctl", ["enable", SERVICES.autopilot]);
|
|
@@ -141,8 +145,15 @@ export async function up(system, options) {
|
|
|
141
145
|
access: { mode: access, url },
|
|
142
146
|
};
|
|
143
147
|
await writeState(system, state);
|
|
144
|
-
const link = await ownerLink(system, url);
|
|
145
148
|
const capacity = agentCapacity(facts, CELL_MEMORY_MB);
|
|
149
|
+
if (access === "ssh") {
|
|
150
|
+
// The laptop set this up, pairs itself (`dl pair`), and prints its own summary, so there's
|
|
151
|
+
// no browser link to make: a one-time owner link would only be a secret in its output.
|
|
152
|
+
await installLoginStatus(system);
|
|
153
|
+
ui.ready("Ready", [{ label: "On your laptop, from inside a GitHub repository", value: 'dl run "fix the flaky checkout test"' }]);
|
|
154
|
+
return { url, ownerLink: undefined, capacity, state };
|
|
155
|
+
}
|
|
156
|
+
const link = await ownerLink(system, url);
|
|
146
157
|
await installLoginStatus(system);
|
|
147
158
|
ui.ready("Ready", nextSteps({ url, ownerLink: link, access }), {
|
|
148
159
|
about: [
|
|
@@ -171,46 +182,116 @@ async function installLoginStatus(system) {
|
|
|
171
182
|
return;
|
|
172
183
|
await system.writeFile(MOTD_SCRIPT, motdScript(), { mode: 0o755 });
|
|
173
184
|
}
|
|
174
|
-
|
|
185
|
+
/** Checks an access mode named by `--access` or DL_ACCESS. */
|
|
186
|
+
export function parseAccessMode(value, source = "--access") {
|
|
187
|
+
const mode = ACCESS_MODES.find((entry) => entry === value);
|
|
188
|
+
if (mode)
|
|
189
|
+
return mode;
|
|
190
|
+
throw new Error(`${source} must be ${ACCESS_MODES.join(", ").replace(/, ([^,]+)$/, " or $1")} (got "${value}"); an own domain and the instant address are coming.`);
|
|
191
|
+
}
|
|
192
|
+
/** The flag wins, then DL_ACCESS, then what the last `up` chose; Tailscale on a fresh machine. */
|
|
193
|
+
function accessMode(flag, env, previous) {
|
|
194
|
+
if (flag)
|
|
195
|
+
return flag;
|
|
175
196
|
const requested = env.DL_ACCESS?.trim();
|
|
176
197
|
if (requested === undefined || requested === "")
|
|
177
198
|
return previous?.access.mode ?? "tailscale";
|
|
178
|
-
|
|
179
|
-
return requested;
|
|
180
|
-
throw new Error(`DL_ACCESS must be tailscale or loopback (got "${requested}"); an own domain and the instant address are coming.`);
|
|
199
|
+
return parseAccessMode(requested, "DL_ACCESS");
|
|
181
200
|
}
|
|
182
|
-
|
|
201
|
+
/**
|
|
202
|
+
* `dl up --needs`: the answers a real `up` would require on this machine, without asking for
|
|
203
|
+
* or changing anything (it only reads files and runs read-only probes). The laptop asks for
|
|
204
|
+
* exactly these before it starts the install. Answers in DL_* variables don't count: the
|
|
205
|
+
* laptop's `up` takes its answers from stdin.
|
|
206
|
+
*/
|
|
207
|
+
export async function needs(system, options = {}) {
|
|
208
|
+
const access = accessMode(options.access, options.env ?? process.env, await readState(system));
|
|
209
|
+
const missing = await missingAnswers(system, await detectHost(system), access);
|
|
210
|
+
return ["tailscale", "openrouter", "github"].filter((need) => missing[need]);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Which answers this machine still lacks: the model key unless Hotcell has one, a GitHub
|
|
214
|
+
* token unless its env file has one, a Tailscale key only for Tailscale access off a tailnet.
|
|
215
|
+
*/
|
|
216
|
+
async function missingAnswers(system, facts, access) {
|
|
183
217
|
const tailscaleRunning = access !== "tailscale" || (facts.tailscale && await tailscaleIsUp(system));
|
|
184
|
-
|
|
185
|
-
|
|
218
|
+
return {
|
|
219
|
+
tailscale: !tailscaleRunning,
|
|
220
|
+
openrouter: !(await hotcellHasKey(system, "openrouter")),
|
|
221
|
+
github: !parseEnvFile(await system.readFile(PATHS.githubEnv)).GITHUB_TOKEN,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function given(env, provided) {
|
|
225
|
+
if (provided) {
|
|
226
|
+
return { tailscaleAuthKey: provided.tailscaleAuthKey, openRouterKey: provided.openrouterKey, githubToken: provided.githubToken, source: "from your laptop", final: true };
|
|
227
|
+
}
|
|
228
|
+
return { tailscaleAuthKey: env.DL_TAILSCALE_AUTH_KEY, openRouterKey: env.DL_OPENROUTER_API_KEY, githubToken: env.DL_GITHUB_TOKEN, source: "from the environment", final: false };
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* The answers from `--answers-stdin`. Parse errors never quote the input: it holds keys.
|
|
232
|
+
* Unknown fields are ignored, so a newer laptop can send more than this server knows.
|
|
233
|
+
*/
|
|
234
|
+
export function parseProvidedAnswers(text) {
|
|
235
|
+
let parsed;
|
|
236
|
+
try {
|
|
237
|
+
parsed = JSON.parse(text);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
throw new Error("The answers on stdin aren't valid JSON; expected one object like {\"openrouterKey\": \"sk-or-…\"}.");
|
|
241
|
+
}
|
|
242
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
243
|
+
throw new Error("The answers on stdin must be one JSON object.");
|
|
244
|
+
const record = parsed;
|
|
245
|
+
const answers = {};
|
|
246
|
+
for (const key of ["openrouterKey", "githubToken", "tailscaleAuthKey"]) {
|
|
247
|
+
const value = record[key];
|
|
248
|
+
if (value === undefined || value === null)
|
|
249
|
+
continue;
|
|
250
|
+
if (typeof value !== "string")
|
|
251
|
+
throw new Error(`The answer "${key}" on stdin must be a string.`);
|
|
252
|
+
answers[key] = value;
|
|
253
|
+
}
|
|
254
|
+
return answers;
|
|
255
|
+
}
|
|
256
|
+
async function collectAnswers(system, ui, prompter, provided, fetchImpl, facts, access) {
|
|
257
|
+
const missing = await missingAnswers(system, facts, access);
|
|
186
258
|
if (system.dryRun) {
|
|
187
259
|
return {
|
|
188
|
-
tailscaleAuthKey:
|
|
189
|
-
openRouterKey:
|
|
190
|
-
githubToken:
|
|
260
|
+
tailscaleAuthKey: missing.tailscale ? "<asked>" : undefined,
|
|
261
|
+
openRouterKey: missing.openrouter ? "<asked>" : undefined,
|
|
262
|
+
githubToken: missing.github ? "<asked>" : undefined,
|
|
191
263
|
};
|
|
192
264
|
}
|
|
193
|
-
const typed = [
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
265
|
+
const typed = provided.final ? 0 : [
|
|
266
|
+
missing.tailscale && !provided.tailscaleAuthKey,
|
|
267
|
+
missing.openrouter && !provided.openRouterKey,
|
|
268
|
+
missing.github && !provided.githubToken,
|
|
197
269
|
].filter(Boolean).length;
|
|
198
270
|
if (typed > 0)
|
|
199
271
|
ui.questions(`${typed === 1 ? "One key" : `${typed} keys`}, then it runs on its own. Typing is hidden.`);
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
const
|
|
272
|
+
// With final answers there's no one to ask: a missing one fails before anything changes.
|
|
273
|
+
const asking = (ask) => provided.final ? undefined : ask;
|
|
274
|
+
const tailscaleAuthKey = !missing.tailscale ? undefined : await askValidated(ui, provided.tailscaleAuthKey, provided.source, "Tailscale auth key", asking(() => prompter.secret(ui.prompt("Tailscale auth key", "login.tailscale.com/admin/settings/keys"))), async (value) => /^tskey-[A-Za-z0-9-]+$/.test(value) ? {} : { problem: "That doesn't look like a Tailscale auth key (tskey-…)." });
|
|
275
|
+
const openRouterKey = !missing.openrouter ? undefined : await askValidated(ui, provided.openRouterKey, provided.source, "OpenRouter API key", asking(() => prompter.secret(ui.prompt("OpenRouter API key", "stays in Hotcell; sandboxes and laptops never see it"))), async (value) => ({ ...await validateOpenRouterKey(value, fetchImpl).then((problem) => problem ? { problem } : { detail: "kept in Hotcell" }) }));
|
|
276
|
+
const githubToken = !missing.github ? undefined : await askValidated(ui, provided.githubToken, provided.source, "GitHub token", asking(() => prompter.secret(ui.prompt("GitHub token", "fine-grained · Contents + Pull requests read/write"))), (value) => validateGitHubToken(value, fetchImpl));
|
|
203
277
|
return { tailscaleAuthKey, openRouterKey, githubToken };
|
|
204
278
|
}
|
|
205
|
-
/**
|
|
206
|
-
|
|
279
|
+
/**
|
|
280
|
+
* Takes a given answer as-is (failing fast if invalid) or asks up to three times. Without a
|
|
281
|
+
* way to ask (answers from the laptop), a missing answer is a failure.
|
|
282
|
+
*/
|
|
283
|
+
async function askValidated(ui, fromEnv, source, label, ask, validate) {
|
|
207
284
|
if (fromEnv) {
|
|
208
285
|
const { problem, detail } = await validate(fromEnv.trim());
|
|
209
286
|
if (problem)
|
|
210
287
|
throw new Error(problem);
|
|
211
|
-
ui.line("done", label, detail ? `${detail} ·
|
|
288
|
+
ui.line("done", label, detail ? `${detail} · ${source}` : source);
|
|
212
289
|
return fromEnv.trim();
|
|
213
290
|
}
|
|
291
|
+
if (!ask) {
|
|
292
|
+
ui.line("failed", label, "not given");
|
|
293
|
+
throw new Error(`This server needs ${/^[AEIOU]/.test(label) ? "an" : "a"} ${label} and none was given ${source.replace(/^from/, "by")}.`);
|
|
294
|
+
}
|
|
214
295
|
for (let attempt = 1;; attempt++) {
|
|
215
296
|
const value = (await ask()).trim();
|
|
216
297
|
const { problem, detail } = value ? await validate(value) : { problem: "An answer is required.", detail: undefined };
|
package/install.sh
CHANGED
|
@@ -8,21 +8,24 @@
|
|
|
8
8
|
# key, and sets up Hotcell + Autopilot as services. Safe to run again.
|
|
9
9
|
#
|
|
10
10
|
# Unattended: export DL_TAILSCALE_AUTH_KEY, DL_OPENROUTER_API_KEY and DL_GITHUB_TOKEN first.
|
|
11
|
+
# Install only (the laptop's setup, which then runs `dl up` itself over SSH): DL_INSTALL_ONLY=1.
|
|
11
12
|
# Pin a version: DL_VERSION=0.1.0. Install from a local tarball: DL_PACKAGE=/path/to.tgz.
|
|
12
13
|
set -eu
|
|
13
14
|
|
|
14
15
|
say() { printf '%s\n' "$*"; }
|
|
15
16
|
fail() { printf '\n directionallylocal: %s\n' "$*" >&2; exit 1; }
|
|
16
17
|
|
|
17
|
-
# The same look as `dl up`: a step
|
|
18
|
-
# Without a terminal (or with NO_COLOR) it's plain lines.
|
|
18
|
+
# The same look as `dl up`: a running step has a spinner and a timer, and becomes "✓"
|
|
19
|
+
# when done. Without a terminal (or with NO_COLOR) it's plain lines.
|
|
19
20
|
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-}" != "dumb" ]; then
|
|
20
21
|
FANCY=1
|
|
21
22
|
else
|
|
22
23
|
FANCY=""
|
|
23
24
|
fi
|
|
25
|
+
STEP=""
|
|
24
26
|
step() {
|
|
25
|
-
|
|
27
|
+
STEP=$1
|
|
28
|
+
if [ -n "$FANCY" ]; then printf ' \033[36m⠋\033[0m %s' "$1"; else say ".. $1"; fi
|
|
26
29
|
}
|
|
27
30
|
step_done() {
|
|
28
31
|
if [ -n "$FANCY" ]; then printf '\r\033[2K \033[32m✓\033[0m %-33s\033[2m%s\033[0m\n' "$1" "${2:-}"; else say "ok $1${2:+: $2}"; fi
|
|
@@ -40,11 +43,43 @@ as_root() {
|
|
|
40
43
|
fi
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
# The spinner frame for tick $1 (POSIX sh has no arrays, and multibyte cut isn't portable).
|
|
47
|
+
frame() {
|
|
48
|
+
case $(($1 % 10)) in
|
|
49
|
+
0) printf '⠋' ;; 1) printf '⠙' ;; 2) printf '⠹' ;; 3) printf '⠸' ;; 4) printf '⠼' ;;
|
|
50
|
+
5) printf '⠴' ;; 6) printf '⠦' ;; 7) printf '⠧' ;; 8) printf '⠇' ;; *) printf '⠏' ;;
|
|
51
|
+
esac
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# On failure, show only this step's own output, not the earlier steps' in the same log.
|
|
55
|
+
show_step_log() {
|
|
56
|
+
tail -n "+$(($1 + 1))" "$LOG" | tail -n 15 | while IFS= read -r line; do
|
|
57
|
+
if [ -n "$FANCY" ]; then printf ' \033[2m%s\033[0m\n' "$line" >&2; else printf ' %s\n' "$line" >&2; fi
|
|
58
|
+
done
|
|
59
|
+
}
|
|
60
|
+
|
|
43
61
|
quietly() {
|
|
44
62
|
what=$1
|
|
45
63
|
shift
|
|
46
|
-
|
|
47
|
-
|
|
64
|
+
from=$(wc -l <"$LOG")
|
|
65
|
+
if [ -z "$FANCY" ]; then
|
|
66
|
+
"$@" >>"$LOG" 2>&1 || { show_step_log "$from"; fail "$what failed"; }
|
|
67
|
+
return 0
|
|
68
|
+
fi
|
|
69
|
+
# Run in the background and animate the current step until it finishes.
|
|
70
|
+
"$@" >>"$LOG" 2>&1 &
|
|
71
|
+
pid=$!
|
|
72
|
+
started=$(date +%s)
|
|
73
|
+
tick=0
|
|
74
|
+
while kill -0 "$pid" 2>/dev/null; do
|
|
75
|
+
elapsed=$(($(date +%s) - started))
|
|
76
|
+
printf '\r\033[2K \033[36m%s\033[0m %-33s\033[2m%d:%02d\033[0m' "$(frame "$tick")" "$STEP" $((elapsed / 60)) $((elapsed % 60))
|
|
77
|
+
tick=$((tick + 1))
|
|
78
|
+
sleep 0.1
|
|
79
|
+
done
|
|
80
|
+
if ! wait "$pid"; then
|
|
81
|
+
printf '\r\033[2K \033[31m✗\033[0m %s\n\n' "$STEP"
|
|
82
|
+
show_step_log "$from"
|
|
48
83
|
fail "$what failed"
|
|
49
84
|
fi
|
|
50
85
|
}
|
|
@@ -102,6 +137,12 @@ main() {
|
|
|
102
137
|
quietly "installing $package" as_root npm install -g --no-audit --no-fund --loglevel=error "$package"
|
|
103
138
|
step_done "Got the installer" "dl $(dl --version 2>/dev/null || echo "")"
|
|
104
139
|
|
|
140
|
+
# The laptop's setup stops here: it asks for the keys itself and runs `dl up` next,
|
|
141
|
+
# with its own progress display.
|
|
142
|
+
if [ "${DL_INSTALL_ONLY:-}" = "1" ]; then
|
|
143
|
+
return 0
|
|
144
|
+
fi
|
|
145
|
+
|
|
105
146
|
# Prompts read the terminal, not this piped script. Test by opening it: the device
|
|
106
147
|
# node exists even when there's no controlling terminal.
|
|
107
148
|
# dl up continues the same screen, so it skips its own title.
|
package/package.json
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "directionallylocal",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "One-line server install
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Directionally Local: background coding agents on your own server. One-line server install, and the dl command for your server and laptop.",
|
|
5
|
+
"homepage": "https://directionallylocal.com",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/directionally-local/installer.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/directionally-local/installer/issues"
|
|
12
|
+
},
|
|
5
13
|
"type": "module",
|
|
6
14
|
"bin": {
|
|
7
15
|
"directionallylocal": "bin/directionallylocal.mjs",
|