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
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { HTTP_STATUS_SCRIPT, PORT_FREE_SCRIPT } from "../probes.js";
|
|
2
|
+
/**
|
|
3
|
+
* A simulated Mac (or Linux desktop) with Docker Desktop, for "on this computer" tests.
|
|
4
|
+
* launchctl/systemctl load and unload services, which then answer on the ports in their
|
|
5
|
+
* env files; npm installs packages; Autopilot's CLI issues credentials. So setup can run
|
|
6
|
+
* twice to prove it repairs without reinstalling.
|
|
7
|
+
*/
|
|
8
|
+
export class FakeComputer {
|
|
9
|
+
home;
|
|
10
|
+
dryRun = false;
|
|
11
|
+
files = new Map();
|
|
12
|
+
dirs = new Map();
|
|
13
|
+
commands = [];
|
|
14
|
+
probes = [];
|
|
15
|
+
logs = [];
|
|
16
|
+
images = new Set();
|
|
17
|
+
containers = [];
|
|
18
|
+
/** launchd labels or systemd units that are running. */
|
|
19
|
+
running = new Set();
|
|
20
|
+
/** Ports something else on this computer listens on. */
|
|
21
|
+
busy = new Set();
|
|
22
|
+
credentials = [];
|
|
23
|
+
hotcellKeys = new Map();
|
|
24
|
+
docker = true;
|
|
25
|
+
/** Docker Desktop is installed as an app (it may be stopped). */
|
|
26
|
+
dockerApp = true;
|
|
27
|
+
dockerDenied = false;
|
|
28
|
+
/** Containers reach host.docker.internal. */
|
|
29
|
+
reachHost = true;
|
|
30
|
+
git = true;
|
|
31
|
+
uid = "501";
|
|
32
|
+
diskFreeKb = 120 * 1024 * 1024;
|
|
33
|
+
/** Docker Desktop's VM: 8 GB, 8 CPUs. */
|
|
34
|
+
dockerMemBytes = 8 * 1024 * 1024 * 1024;
|
|
35
|
+
systemdUser = true;
|
|
36
|
+
bridgeIp = undefined;
|
|
37
|
+
constructor(home = "/Users/ada") {
|
|
38
|
+
this.home = home;
|
|
39
|
+
this.files.set(`${home}/.docker/run/docker.sock`, { content: "", mode: 0o755 });
|
|
40
|
+
this.files.set("/opt/homebrew/opt/node/bin/node", { content: "", mode: 0o755 });
|
|
41
|
+
this.files.set("/Applications/Docker.app/Contents/Info.plist", { content: "", mode: 0o644 });
|
|
42
|
+
this.files.set("/etc/os-release", { content: 'ID=ubuntu\nPRETTY_NAME="Ubuntu 24.04.1 LTS"\n', mode: 0o644 });
|
|
43
|
+
}
|
|
44
|
+
get ran() {
|
|
45
|
+
return this.commands.map(({ command, args }) => [command, ...args].join(" "));
|
|
46
|
+
}
|
|
47
|
+
async run(command, args, options = {}) {
|
|
48
|
+
this.commands.push({ command, args, ...(options.input !== undefined ? { input: options.input } : {}), ...(options.env ? { env: options.env } : {}) });
|
|
49
|
+
const result = this.respond(command, args, options);
|
|
50
|
+
if (result.code !== 0 && !options.allowFailure)
|
|
51
|
+
throw new Error(`${command} ${args.join(" ")} failed: ${result.stderr}`);
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
async probe(command, args, options = {}) {
|
|
55
|
+
this.probes.push({ command, args, ...(options.input !== undefined ? { input: options.input } : {}), ...(options.env ? { env: options.env } : {}) });
|
|
56
|
+
return this.respond(command, args, options);
|
|
57
|
+
}
|
|
58
|
+
async readFile(path) {
|
|
59
|
+
return this.files.get(path)?.content;
|
|
60
|
+
}
|
|
61
|
+
async writeFile(path, content, options = {}) {
|
|
62
|
+
this.files.set(path, { content, mode: options.mode ?? 0o644 });
|
|
63
|
+
}
|
|
64
|
+
async mkdir(path, options = {}) {
|
|
65
|
+
this.dirs.set(path, options.mode);
|
|
66
|
+
}
|
|
67
|
+
async remove(path) {
|
|
68
|
+
for (const file of [...this.files.keys()])
|
|
69
|
+
if (file === path || file.startsWith(`${path}/`))
|
|
70
|
+
this.files.delete(file);
|
|
71
|
+
for (const dir of [...this.dirs.keys()])
|
|
72
|
+
if (dir === path || dir.startsWith(`${path}/`))
|
|
73
|
+
this.dirs.delete(dir);
|
|
74
|
+
}
|
|
75
|
+
async exists(path) {
|
|
76
|
+
if (path === "/Applications/Docker.app")
|
|
77
|
+
return this.dockerApp;
|
|
78
|
+
return this.files.has(path) || this.dirs.has(path) || [...this.files.keys(), ...this.dirs.keys()].some((entry) => entry.startsWith(`${path}/`));
|
|
79
|
+
}
|
|
80
|
+
log(line) {
|
|
81
|
+
this.logs.push(line);
|
|
82
|
+
}
|
|
83
|
+
/** The ports a running service of ours listens on, from its env file. */
|
|
84
|
+
ourPorts() {
|
|
85
|
+
const ports = new Map();
|
|
86
|
+
const env = (name) => Object.fromEntries((this.files.get(`${this.home}/.directionallylocal/config/${name}`)?.content ?? "")
|
|
87
|
+
.split("\n").flatMap((line) => line.match(/^([A-Z_]+)=(.*)$/) ? [[line.split("=")[0], line.slice(line.indexOf("=") + 1)]] : []));
|
|
88
|
+
if ([...this.running].some((name) => name.includes("hotcell"))) {
|
|
89
|
+
const hotcell = env("hotcell.env");
|
|
90
|
+
for (const key of ["HOTCELL_PORT", "HOTCELL_PROXY_PORT", "HOTCELL_EGRESS_PORT"])
|
|
91
|
+
if (hotcell[key])
|
|
92
|
+
ports.set(Number(hotcell[key]), "hotcell");
|
|
93
|
+
}
|
|
94
|
+
if ([...this.running].some((name) => name.includes("autopilot"))) {
|
|
95
|
+
const autopilot = env("autopilot.env");
|
|
96
|
+
if (autopilot.AUTOPILOT_PUBLIC_URL)
|
|
97
|
+
ports.set(Number(autopilot.AUTOPILOT_PUBLIC_URL.split(":").at(-1)), "autopilot");
|
|
98
|
+
if (autopilot.AUTOPILOT_ASTER_GITHUB_PROXY_LISTEN)
|
|
99
|
+
ports.set(Number(autopilot.AUTOPILOT_ASTER_GITHUB_PROXY_LISTEN.split(":").at(-1)), "autopilot");
|
|
100
|
+
}
|
|
101
|
+
return ports;
|
|
102
|
+
}
|
|
103
|
+
respond(command, args, options) {
|
|
104
|
+
const ok = (stdout = "") => ({ code: 0, stdout, stderr: "" });
|
|
105
|
+
const fail = (stderr = "failed") => ({ code: 1, stdout: "", stderr });
|
|
106
|
+
const line = args.join(" ");
|
|
107
|
+
if (command.endsWith("/node") && args[0] === "-e") {
|
|
108
|
+
if (args[1] === PORT_FREE_SCRIPT) {
|
|
109
|
+
const port = Number(args[3]);
|
|
110
|
+
return this.busy.has(port) || this.ourPorts().has(port) ? fail() : ok();
|
|
111
|
+
}
|
|
112
|
+
if (args[1] === HTTP_STATUS_SCRIPT) {
|
|
113
|
+
const url = new URL(args[2]);
|
|
114
|
+
const owner = this.ourPorts().get(Number(url.port));
|
|
115
|
+
if (owner === "hotcell")
|
|
116
|
+
return ok("401\n");
|
|
117
|
+
if (owner === "autopilot" && url.pathname === "/api/v1/health") {
|
|
118
|
+
const token = (options.input ?? "").trim();
|
|
119
|
+
return ok(token && this.credentials.some((entry) => entry.token === token && entry.status === "active") ? "200\n" : "401\n");
|
|
120
|
+
}
|
|
121
|
+
return ok("0\n");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (command.endsWith("/node") && args[0]?.endsWith("hotcell/dist/index.js")) {
|
|
125
|
+
if (args[1] === "keys" && args[2] === "add") {
|
|
126
|
+
this.hotcellKeys.set(args[3], options.input ?? "");
|
|
127
|
+
const home = options.env?.HOTCELL_HOME ?? "";
|
|
128
|
+
this.files.set(`${home}/keys.json`, { content: "{}", mode: 0o600 });
|
|
129
|
+
return ok("stored\n");
|
|
130
|
+
}
|
|
131
|
+
if (args[1] === "keys" && args[2] === "ls")
|
|
132
|
+
return !this.files.has(`${options.env?.HOTCELL_HOME ?? ""}/keys.json`) ? ok("") : ok([...this.hotcellKeys.keys()].map((key) => ` ${key} file\n`).join(""));
|
|
133
|
+
return ok();
|
|
134
|
+
}
|
|
135
|
+
if (command.endsWith("/node") && args[0]?.endsWith("autopilot-ai/bin/autopilot.mjs")) {
|
|
136
|
+
const sub = args.slice(1, 4).join(" ");
|
|
137
|
+
if (sub.startsWith("aster token create")) {
|
|
138
|
+
const id = `cred_${this.credentials.length + 1}`;
|
|
139
|
+
const token = `aster_${"t".repeat(30)}${this.credentials.length + 1}`;
|
|
140
|
+
const expiresAt = Date.parse("2027-10-28T10:00:00Z");
|
|
141
|
+
this.credentials.push({ id, name: args[args.indexOf("--name") + 1], status: "active", token, expiresAt });
|
|
142
|
+
return ok(`${JSON.stringify({ credentialId: id, workspaceIds: ["default"], scopes: [], createdAt: 0, expiresAt, token })}\n`);
|
|
143
|
+
}
|
|
144
|
+
if (sub.startsWith("aster token list")) {
|
|
145
|
+
return ok(`${JSON.stringify(this.credentials.map(({ token: _token, ...entry }) => ({ ...entry, scopes: [] })))}\n`);
|
|
146
|
+
}
|
|
147
|
+
if (sub.startsWith("aster token revoke")) {
|
|
148
|
+
const entry = this.credentials.find((credential) => credential.id === args.at(-1));
|
|
149
|
+
if (entry)
|
|
150
|
+
entry.status = "revoked";
|
|
151
|
+
return ok();
|
|
152
|
+
}
|
|
153
|
+
return ok();
|
|
154
|
+
}
|
|
155
|
+
switch (command) {
|
|
156
|
+
case "id": return ok(`${this.uid}\n`);
|
|
157
|
+
case "sw_vers": return ok("15.1\n");
|
|
158
|
+
case "df": return ok(`Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/disk3s5 971350180 1000 ${this.diskFreeKb} 10% /System/Volumes/Data\n`);
|
|
159
|
+
case "git":
|
|
160
|
+
if (!this.git)
|
|
161
|
+
return { code: 127, stdout: "", stderr: "not found" };
|
|
162
|
+
if (args[0] === "--version")
|
|
163
|
+
return ok("git version 2.50.1\n");
|
|
164
|
+
if (args.includes("init"))
|
|
165
|
+
this.dirs.set(`${args[1]}/.git`, undefined);
|
|
166
|
+
return ok();
|
|
167
|
+
case "sh":
|
|
168
|
+
if (line === "-c command -v docker")
|
|
169
|
+
return ok("/usr/local/bin/docker\n");
|
|
170
|
+
return ok();
|
|
171
|
+
case "ip": return this.bridgeIp ? ok(`4: docker0 inet ${this.bridgeIp}/16 brd 172.17.255.255 scope global docker0\n`) : fail("no docker0");
|
|
172
|
+
case "systemctl":
|
|
173
|
+
if (args[1] === "is-system-running")
|
|
174
|
+
return this.systemdUser ? ok("running\n") : fail("Failed to connect to bus");
|
|
175
|
+
if (args[1] === "restart")
|
|
176
|
+
this.running.add(args[2]);
|
|
177
|
+
if (args[1] === "disable" && args[2] === "--now")
|
|
178
|
+
this.running.delete(args[3]);
|
|
179
|
+
if (args[1] === "is-active")
|
|
180
|
+
return ok(this.running.has(args[2]) ? "active\n" : "inactive\n");
|
|
181
|
+
return ok();
|
|
182
|
+
case "launchctl": {
|
|
183
|
+
if (args[0] === "bootstrap") {
|
|
184
|
+
const plist = this.files.get(args[2])?.content ?? "";
|
|
185
|
+
const label = plist.match(/<key>Label<\/key>\s*<string>([^<]+)<\/string>/)?.[1];
|
|
186
|
+
if (!label)
|
|
187
|
+
return fail("Bootstrap failed: 5: Input/output error");
|
|
188
|
+
this.running.add(label);
|
|
189
|
+
return ok();
|
|
190
|
+
}
|
|
191
|
+
if (args[0] === "bootout") {
|
|
192
|
+
const label = args[1].split("/").at(-1);
|
|
193
|
+
return this.running.delete(label) ? ok() : fail("Boot-out failed: 3: No such process");
|
|
194
|
+
}
|
|
195
|
+
if (args[0] === "print") {
|
|
196
|
+
const label = args[1].split("/").at(-1);
|
|
197
|
+
return this.running.has(label) ? ok(`${args[1]} = {\n\tstate = running\n\tpid = 4242\n}\n`) : fail("Could not find service");
|
|
198
|
+
}
|
|
199
|
+
return ok();
|
|
200
|
+
}
|
|
201
|
+
case "open":
|
|
202
|
+
if (this.dockerApp)
|
|
203
|
+
this.docker = true;
|
|
204
|
+
return ok();
|
|
205
|
+
case "npm":
|
|
206
|
+
if (args[0] === "view")
|
|
207
|
+
return ok(args[1] === "hotcell" ? "0.1.30\n" : "2.6.1\n");
|
|
208
|
+
if (args[0] === "install" && args[1] === "--prefix") {
|
|
209
|
+
for (const pkg of ["autopilot-ai", "hotcell"])
|
|
210
|
+
this.files.set(`${args[2]}/node_modules/${pkg}/package.json`, { content: "{}", mode: 0o644 });
|
|
211
|
+
this.files.set(`${args[2]}/node_modules/hotcell/dist/index.js`, { content: "", mode: 0o755 });
|
|
212
|
+
this.dirs.set(args[2], 0o700);
|
|
213
|
+
}
|
|
214
|
+
return ok();
|
|
215
|
+
case "ln":
|
|
216
|
+
for (const [path, file] of [...this.files])
|
|
217
|
+
if (path.startsWith(`${args[1]}/`))
|
|
218
|
+
this.files.set(path.replace(args[1], args[2]), file);
|
|
219
|
+
return ok();
|
|
220
|
+
case "ls":
|
|
221
|
+
return ok([...this.dirs.keys()].filter((dir) => dir.startsWith(`${args[1]}/`) && !dir.slice(args[1].length + 1).includes("/")).map((dir) => dir.split("/").at(-1)).join("\n"));
|
|
222
|
+
case "mktemp": return ok("/tmp/tmp.build\n");
|
|
223
|
+
case "wc": return ok("0\n");
|
|
224
|
+
case "/usr/local/bin/docker": {
|
|
225
|
+
if (args[0] === "context")
|
|
226
|
+
return ok(`unix://${this.home}/.docker/run/docker.sock\n`);
|
|
227
|
+
if (args[0] === "info") {
|
|
228
|
+
if (this.dockerDenied)
|
|
229
|
+
return { code: 1, stdout: "", stderr: "permission denied while trying to connect to the Docker daemon socket" };
|
|
230
|
+
if (!this.docker)
|
|
231
|
+
return fail("Cannot connect to the Docker daemon");
|
|
232
|
+
return args[2].startsWith("{{.ServerVersion}}") ? ok(`29.5.2|Docker Desktop|${this.dockerMemBytes}|8\n`) : ok(`${this.dockerMemBytes}|8\n`);
|
|
233
|
+
}
|
|
234
|
+
if (!this.docker)
|
|
235
|
+
return fail("Cannot connect to the Docker daemon");
|
|
236
|
+
if (args[0] === "image" && args[1] === "inspect")
|
|
237
|
+
return this.images.has(args[2]) ? ok("[]") : fail("no such image");
|
|
238
|
+
if (args[0] === "image" && args[1] === "rm") {
|
|
239
|
+
this.images.delete(args[2]);
|
|
240
|
+
return ok();
|
|
241
|
+
}
|
|
242
|
+
if (args[0] === "build") {
|
|
243
|
+
this.images.add(args[args.indexOf("-t") + 1]);
|
|
244
|
+
return ok();
|
|
245
|
+
}
|
|
246
|
+
if (args[0] === "run")
|
|
247
|
+
return ok(this.reachHost && this.ourPorts().size > 0 ? "404\n" : "0\n");
|
|
248
|
+
if (args[0] === "ps") {
|
|
249
|
+
const filters = args.flatMap((arg, index) => args[index - 1] === "--filter" ? [arg.replace(/^label=/, "")] : []);
|
|
250
|
+
return ok(this.containers.filter((entry) => filters.every((filter) => entry.labels.includes(filter))).map((entry) => entry.id).join("\n"));
|
|
251
|
+
}
|
|
252
|
+
if (args[0] === "rm") {
|
|
253
|
+
for (const id of args.slice(2))
|
|
254
|
+
this.containers.splice(this.containers.findIndex((entry) => entry.id === id), 1);
|
|
255
|
+
return ok();
|
|
256
|
+
}
|
|
257
|
+
return ok();
|
|
258
|
+
}
|
|
259
|
+
default:
|
|
260
|
+
return ok();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
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,6 +8,7 @@
|
|
|
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
|
|
|
@@ -27,7 +28,7 @@ step() {
|
|
|
27
28
|
if [ -n "$FANCY" ]; then printf ' \033[36m⠋\033[0m %s' "$1"; else say ".. $1"; fi
|
|
28
29
|
}
|
|
29
30
|
step_done() {
|
|
30
|
-
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
|
|
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 ${STEP:-$1}${2:+: $2}"; fi
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
# Quiet steps: output goes to a log that's shown only if the step fails.
|
|
@@ -136,6 +137,12 @@ main() {
|
|
|
136
137
|
quietly "installing $package" as_root npm install -g --no-audit --no-fund --loglevel=error "$package"
|
|
137
138
|
step_done "Got the installer" "dl $(dl --version 2>/dev/null || echo "")"
|
|
138
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
|
+
|
|
139
146
|
# Prompts read the terminal, not this piped script. Test by opening it: the device
|
|
140
147
|
# node exists even when there's no controlling terminal.
|
|
141
148
|
# dl up continues the same screen, so it skips its own title.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "directionallylocal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
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
5
|
"homepage": "https://directionallylocal.com",
|
|
6
6
|
"repository": {
|