directionallylocal 0.0.0-stage → 0.1.2

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/up.js ADDED
@@ -0,0 +1,426 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { hostname } from "node:os";
3
+ import { agentCapacity, detectHost, preflightProblems } from "./detect.js";
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";
6
+ import { PlainUi } from "./ui.js";
7
+ import { ownerLink } from "./server-api.js";
8
+ const DRY_RUN_BRIDGE_IP = "172.17.0.1";
9
+ const DRY_RUN_URL = "https://<this-machine>.<tailnet>.ts.net";
10
+ /** `dl up`: install or repair Hotcell + Autopilot as services. Safe to re-run. */
11
+ export async function up(system, options) {
12
+ const env = options.env ?? process.env;
13
+ const fetchImpl = options.fetchImpl ?? fetch;
14
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
15
+ const ui = options.ui ?? new PlainUi((text) => { for (const line of text.replace(/\n$/, "").split("\n"))
16
+ system.log(line); });
17
+ // install.sh already showed the title on this screen.
18
+ if (env.DL_TITLE_SHOWN !== "1")
19
+ ui.title("Directionally Local");
20
+ let facts = await detectHost(system);
21
+ const problems = preflightProblems(facts);
22
+ if (problems.length > 0) {
23
+ ui.line("failed", "This machine can't run it yet");
24
+ throw new Error(`Can't install here:\n - ${problems.join("\n - ")}`);
25
+ }
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
+ // Every question comes first, so the long part runs unattended.
28
+ const previous = await readState(system);
29
+ const access = accessMode(env, previous);
30
+ const check = env.DL_SKIP_CREDENTIAL_CHECK === "1" ? undefined : fetchImpl;
31
+ const answers = await collectAnswers(system, ui, options.prompter, env, check, facts, access);
32
+ ui.plan([
33
+ ...(access === "tailscale" ? [{ id: "tailnet", label: "Joining your tailnet" }] : []),
34
+ { id: "docker", label: "Setting up Docker" },
35
+ { id: "software", label: "Installing Hotcell + Autopilot" },
36
+ { id: "image", label: "Building the sandbox image" },
37
+ { id: "services", label: "Starting services" },
38
+ { id: "access", label: access === "tailscale" ? "Opening private access" : "Starting the API" },
39
+ ]);
40
+ const step = async (id, work) => {
41
+ ui.start(id);
42
+ try {
43
+ const { value, detail } = await work();
44
+ ui.done(id, detail);
45
+ return value;
46
+ }
47
+ catch (error) {
48
+ ui.fail(id, error.message);
49
+ throw error;
50
+ }
51
+ };
52
+ if (access === "tailscale") {
53
+ await step("tailnet", async () => ({ value: undefined, detail: await joinTailnet(system, answers.tailscaleAuthKey) }));
54
+ }
55
+ const bridgeIp = await step("docker", async () => {
56
+ let detail = "already running";
57
+ if (!facts.docker) {
58
+ // An installed Docker (Ubuntu's docker.io or Docker's docker-ce) is started, never
59
+ // replaced: get.docker.com swapping packages under a running install is how the socket
60
+ // and service fall out of step.
61
+ if (facts.dockerInstalled) {
62
+ detail = "was installed; started it";
63
+ }
64
+ else {
65
+ await system.run("sh", ["-c", "curl -fsSL https://get.docker.com | sh"], { inherit: true, describe: "install Docker (get.docker.com)" });
66
+ detail = "installed";
67
+ }
68
+ await startDocker(system, sleep);
69
+ facts = await detectHost(system);
70
+ }
71
+ const ip = facts.dockerBridgeIp ?? (system.dryRun ? DRY_RUN_BRIDGE_IP : undefined);
72
+ if (!ip)
73
+ throw new Error("Docker is running but its docker0 bridge has no IPv4 address; sandboxes couldn't reach the gateway.");
74
+ return { value: ip, detail };
75
+ });
76
+ const { autopilotVersion, hotcellVersion, release } = await step("software", async () => {
77
+ await ensureAutomaticUpdates(system, ui);
78
+ await ensureUser(system, USERS.hotcell, PATHS.hotcellHome, ["docker"]);
79
+ await ensureUser(system, USERS.autopilot, PATHS.autopilotHome, []);
80
+ const autopilot = options.autopilotVersion ?? await latestVersion(system, "autopilot-ai");
81
+ const hotcell = options.hotcellVersion ?? await latestVersion(system, "hotcell");
82
+ const name = `autopilot-${autopilot}_hotcell-${hotcell}`;
83
+ const releaseDir = `${PATHS.releases}/${name}`;
84
+ if (!(await system.exists(`${releaseDir}/node_modules/autopilot-ai/package.json`) && await system.exists(`${releaseDir}/node_modules/hotcell/package.json`))) {
85
+ await system.mkdir(releaseDir, { mode: 0o755 });
86
+ await system.run("npm", ["install", "--prefix", releaseDir, "--no-audit", "--no-fund", "--omit=dev", `autopilot-ai@${autopilot}`, `hotcell@${hotcell}`], { inherit: true });
87
+ }
88
+ await system.run("ln", ["-sfn", releaseDir, PATHS.current]);
89
+ // Store the keys as soon as their homes exist, so a later failure never asks again.
90
+ if (answers.openRouterKey) {
91
+ // Into Hotcell's keystore over stdin: never in argv, env files, or Autopilot's config.
92
+ await asHotcell(system, ["keys", "add", "openrouter", "--stdin"], { input: answers.openRouterKey, describe: "hotcell keys add openrouter --stdin (as hotcell)" });
93
+ }
94
+ await system.mkdir(PATHS.autopilotEtc, { mode: 0o750, owner: `root:${USERS.autopilot}` });
95
+ if (answers.githubToken) {
96
+ await system.writeFile(PATHS.githubEnv, githubEnv(answers.githubToken), { mode: 0o640, owner: `root:${USERS.autopilot}`, secret: true });
97
+ }
98
+ return { value: { autopilotVersion: autopilot, hotcellVersion: hotcell, release: name }, detail: `Autopilot ${autopilot} · Hotcell ${hotcell}` };
99
+ });
100
+ const image = cellImage(autopilotVersion);
101
+ await step("image", async () => ({ value: undefined, detail: await ensureCellImage(system, image, autopilotVersion) ? "already built" : `Node 24 · Autopilot ${autopilotVersion}` }));
102
+ const hotcellApiKey = await step("services", async () => {
103
+ const hotcellValues = parseEnvFile(await system.readFile(PATHS.hotcellEnv));
104
+ const apiKey = hotcellValues.HOTCELL_API_KEY || randomBytes(24).toString("hex");
105
+ await system.mkdir(PATHS.hotcellEtc, { mode: 0o750, owner: `root:${USERS.hotcell}` });
106
+ await system.writeFile(PATHS.hotcellEnv, hotcellEnv({ apiKey, bridgeIp }), { mode: 0o640, owner: `root:${USERS.hotcell}`, secret: true });
107
+ if (!(await system.readFile(PATHS.asterConfig))) {
108
+ await system.writeFile(PATHS.asterConfig, asterConfig(), { mode: 0o640, owner: `root:${USERS.autopilot}` });
109
+ }
110
+ await ensureWorkspace(system);
111
+ await allowBridgePorts(system, bridgeIp);
112
+ await system.writeFile(`${PATHS.units}/${SERVICES.metadataBlock}`, metadataBlockUnit());
113
+ await system.writeFile(`${PATHS.units}/${SERVICES.hotcell}`, hotcellUnit());
114
+ await system.writeFile(`${PATHS.units}/${SERVICES.autopilot}`, autopilotUnit());
115
+ await system.run("systemctl", ["daemon-reload"]);
116
+ await system.run("systemctl", ["enable", "--now", SERVICES.metadataBlock]);
117
+ await system.run("systemctl", ["enable", SERVICES.hotcell]);
118
+ await system.run("systemctl", ["restart", SERVICES.hotcell]);
119
+ await waitUntil(system, sleep, "Hotcell", async () => (await system.probe("curl", ["-fsS", "-o", "/dev/null", `http://127.0.0.1:${HOTCELL_PORT}/`])).code === 0
120
+ || (await system.probe("sh", ["-c", `ss -ltn | grep -q '127.0.0.1:${HOTCELL_PORT}'`])).code === 0);
121
+ return { value: apiKey, detail: "sandboxes and keys ready" };
122
+ });
123
+ const url = await step("access", async () => {
124
+ const address = access === "tailscale" ? await serveOverTailscale(system) : `http://127.0.0.1:${AUTOPILOT_PORT}`;
125
+ await system.writeFile(PATHS.autopilotEnv, autopilotEnv({ hotcellApiKey, image, bridgeIp, publicUrl: address }), { mode: 0o640, owner: `root:${USERS.autopilot}`, secret: true });
126
+ await system.run("systemctl", ["enable", SERVICES.autopilot]);
127
+ await system.run("systemctl", ["restart", SERVICES.autopilot]);
128
+ // Without a token the Aster API answers 401: the server is up and refusing strangers.
129
+ await waitUntil(system, sleep, "Autopilot", async () => (await system.probe("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", `http://127.0.0.1:${AUTOPILOT_PORT}/api/v1/health`])).stdout.trim() === "401");
130
+ return { value: address, detail: address.replace(/^https?:\/\//, "") };
131
+ });
132
+ const now = (options.now ?? (() => new Date()))().toISOString();
133
+ const state = {
134
+ version: 1,
135
+ installedAt: previous?.installedAt ?? now,
136
+ updatedAt: now,
137
+ release,
138
+ autopilotVersion,
139
+ hotcellVersion,
140
+ image,
141
+ access: { mode: access, url },
142
+ };
143
+ await writeState(system, state);
144
+ const link = await ownerLink(system, url);
145
+ const capacity = agentCapacity(facts, CELL_MEMORY_MB);
146
+ await installLoginStatus(system);
147
+ ui.ready("Ready", nextSteps({ url, ownerLink: link, access }), {
148
+ about: [
149
+ "This server now runs coding agents for you. Send it a task from your laptop:",
150
+ "it works in its own sandbox, opens a pull request, and keeps going while",
151
+ `your laptop is closed. It fits ~${capacity} at once (estimate).`,
152
+ ],
153
+ footer: "Forgot any of this? Type dl on this server.",
154
+ });
155
+ return { url, ownerLink: link, capacity, state };
156
+ }
157
+ /** What to do next, shown at the end of `dl up` and by `dl connect-laptop`. */
158
+ export function nextSteps(input) {
159
+ const reach = input.access === "tailscale"
160
+ ? "On your laptop (signed in to the same tailnet)"
161
+ : `On your laptop, after: ssh -N -L ${AUTOPILOT_PORT}:127.0.0.1:${AUTOPILOT_PORT} root@<this-server>`;
162
+ return [
163
+ ...(input.ownerLink ? [{ label: "Open this once in your laptop's browser, so it can approve laptops", value: input.ownerLink }] : []),
164
+ { label: `${reach}, connect it`, value: `npm install -g directionallylocal\ndl connect ${input.url}` },
165
+ { label: "Send it a task, from inside a GitHub repository", value: `dl run "fix the flaky checkout test"\ndl runs see progress and pull requests` },
166
+ ];
167
+ }
168
+ /** A short status line when someone logs in, so a server visited weeks later explains itself. */
169
+ async function installLoginStatus(system) {
170
+ if (!(await system.exists("/etc/update-motd.d")))
171
+ return;
172
+ await system.writeFile(MOTD_SCRIPT, motdScript(), { mode: 0o755 });
173
+ }
174
+ function accessMode(env, previous) {
175
+ const requested = env.DL_ACCESS?.trim();
176
+ if (requested === undefined || requested === "")
177
+ return previous?.access.mode ?? "tailscale";
178
+ if (requested === "tailscale" || requested === "loopback")
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.`);
181
+ }
182
+ async function collectAnswers(system, ui, prompter, env, fetchImpl, facts, access) {
183
+ const tailscaleRunning = access !== "tailscale" || (facts.tailscale && await tailscaleIsUp(system));
184
+ const hasModelKey = await hotcellHasKey(system, "openrouter");
185
+ const hasGitHub = Boolean(parseEnvFile(await system.readFile(PATHS.githubEnv)).GITHUB_TOKEN);
186
+ if (system.dryRun) {
187
+ return {
188
+ tailscaleAuthKey: tailscaleRunning ? undefined : "<asked>",
189
+ openRouterKey: hasModelKey ? undefined : "<asked>",
190
+ githubToken: hasGitHub ? undefined : "<asked>",
191
+ };
192
+ }
193
+ const typed = [
194
+ !tailscaleRunning && !env.DL_TAILSCALE_AUTH_KEY,
195
+ !hasModelKey && !env.DL_OPENROUTER_API_KEY,
196
+ !hasGitHub && !env.DL_GITHUB_TOKEN,
197
+ ].filter(Boolean).length;
198
+ if (typed > 0)
199
+ ui.questions(`${typed === 1 ? "One key" : `${typed} keys`}, then it runs on its own. Typing is hidden.`);
200
+ const tailscaleAuthKey = tailscaleRunning ? undefined : await askValidated(ui, env.DL_TAILSCALE_AUTH_KEY, "Tailscale auth key", () => 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-…)." });
201
+ const openRouterKey = hasModelKey ? undefined : await askValidated(ui, env.DL_OPENROUTER_API_KEY, "OpenRouter API key", () => 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" }) }));
202
+ const githubToken = hasGitHub ? undefined : await askValidated(ui, env.DL_GITHUB_TOKEN, "GitHub token", () => prompter.secret(ui.prompt("GitHub token", "fine-grained · Contents + Pull requests read/write")), (value) => validateGitHubToken(value, fetchImpl));
203
+ return { tailscaleAuthKey, openRouterKey, githubToken };
204
+ }
205
+ /** Takes an env answer as-is (failing fast if invalid) or asks up to three times. */
206
+ async function askValidated(ui, fromEnv, label, ask, validate) {
207
+ if (fromEnv) {
208
+ const { problem, detail } = await validate(fromEnv.trim());
209
+ if (problem)
210
+ throw new Error(problem);
211
+ ui.line("done", label, detail ? `${detail} · from the environment` : "from the environment");
212
+ return fromEnv.trim();
213
+ }
214
+ for (let attempt = 1;; attempt++) {
215
+ const value = (await ask()).trim();
216
+ const { problem, detail } = value ? await validate(value) : { problem: "An answer is required.", detail: undefined };
217
+ if (!problem) {
218
+ ui.answered(label, detail);
219
+ return value;
220
+ }
221
+ if (attempt >= 3) {
222
+ ui.rejected(problem);
223
+ throw new Error(problem);
224
+ }
225
+ ui.rejected(`${problem} Try again.`);
226
+ }
227
+ }
228
+ export async function validateOpenRouterKey(key, fetchImpl) {
229
+ if (!/^sk-or-[A-Za-z0-9_-]{10,}$/.test(key))
230
+ return "That doesn't look like an OpenRouter key (sk-or-…).";
231
+ if (!fetchImpl)
232
+ return undefined;
233
+ try {
234
+ const response = await fetchImpl("https://openrouter.ai/api/v1/key", { headers: { Authorization: `Bearer ${key}` } });
235
+ if (response.status === 401 || response.status === 403)
236
+ return "OpenRouter rejected that key.";
237
+ return undefined;
238
+ }
239
+ catch {
240
+ // Offline validation isn't fatal: the format is right, and doctor checks it later.
241
+ return undefined;
242
+ }
243
+ }
244
+ export async function validateGitHubToken(token, fetchImpl) {
245
+ if (!/^(github_pat_|ghp_|gho_)[A-Za-z0-9_]{20,}$/.test(token))
246
+ return { problem: "That doesn't look like a GitHub token (github_pat_… or ghp_…)." };
247
+ if (!fetchImpl)
248
+ return {};
249
+ try {
250
+ const response = await fetchImpl("https://api.github.com/user", { headers: { Authorization: `Bearer ${token}`, "User-Agent": "directionallylocal" } });
251
+ if (response.status === 401)
252
+ return { problem: "GitHub rejected that token." };
253
+ if (response.ok) {
254
+ const user = await response.json();
255
+ if (user.login)
256
+ return { detail: `signed in as ${user.login}` };
257
+ }
258
+ return {};
259
+ }
260
+ catch {
261
+ return {};
262
+ }
263
+ }
264
+ /**
265
+ * Ubuntu's unattended-upgrades only patches Ubuntu's own packages, so Node.js from
266
+ * NodeSource would never pick up a security release by itself. Docker's repo is left out
267
+ * on purpose: it ships major upgrades, and restarting dockerd can stop running sandboxes.
268
+ */
269
+ async function ensureAutomaticUpdates(system, ui) {
270
+ const installed = await system.probe("dpkg-query", ["-W", "-f=${Status}", "unattended-upgrades"]);
271
+ if (!installed.stdout.includes("install ok installed")) {
272
+ await system.run("apt-get", ["install", "-y", "-qq", "unattended-upgrades"], { env: { DEBIAN_FRONTEND: "noninteractive" } });
273
+ }
274
+ // Keep an operator's own schedule; only turn the daily run on where it's missing.
275
+ if (!(await system.readFile(AUTO_UPGRADES)))
276
+ await system.writeFile(AUTO_UPGRADES, autoUpgrades());
277
+ if ((await system.probe("apt-cache", ["policy", "nodejs"])).stdout.includes("deb.nodesource.com")) {
278
+ await system.writeFile(NODE_AUTO_UPDATES, nodeAutoUpdates());
279
+ }
280
+ else {
281
+ ui.warn("Node.js isn't from NodeSource here, so it won't update itself; keep it at 22.23.4+, 24.22.1+ or 26.12.1+.");
282
+ }
283
+ }
284
+ /**
285
+ * Starts Docker so it actually answers. docker.service is socket-activated on Ubuntu: if it
286
+ * starts while docker.socket is down, dockerd exits ("no sockets found via socket
287
+ * activation") and systemd soon stops retrying. So: clear that failed state, bring up the
288
+ * socket first, then the service, and wait for the daemon to respond.
289
+ */
290
+ async function startDocker(system, sleep) {
291
+ await system.run("systemctl", ["reset-failed", "docker.service", "docker.socket"], { allowFailure: true });
292
+ await system.run("systemctl", ["enable", "--now", "docker.socket"], { allowFailure: true });
293
+ await system.run("systemctl", ["enable", "docker.service"]);
294
+ await system.run("systemctl", ["restart", "docker.service"]);
295
+ await waitUntil(system, sleep, "Docker", async () => (await system.probe("docker", ["version", "--format", "{{.Server.Version}}"])).stdout.trim() !== "", "docker");
296
+ }
297
+ async function ensureUser(system, name, home, groups) {
298
+ if ((await system.probe("id", ["-u", name])).code !== 0) {
299
+ await system.run("useradd", ["--system", "--user-group", "--home-dir", home, "--shell", "/usr/sbin/nologin", name]);
300
+ }
301
+ for (const group of groups)
302
+ await system.run("usermod", ["-aG", group, name]);
303
+ await system.mkdir(home, { mode: 0o750, owner: `${name}:${name}` });
304
+ }
305
+ async function latestVersion(system, pkg) {
306
+ const result = await system.probe("npm", ["view", pkg, "version"]);
307
+ const version = result.stdout.trim();
308
+ if (result.code !== 0 || !/^\d+\.\d+\.\d+/.test(version))
309
+ throw new Error(`Couldn't look up the latest ${pkg} on npm${result.stderr ? `: ${result.stderr.trim()}` : ""}`);
310
+ return version;
311
+ }
312
+ function asHotcell(system, args, options = {}) {
313
+ return system.run("runuser", [
314
+ "-u", USERS.hotcell, "--", "env", `HOTCELL_HOME=${PATHS.hotcellHome}`, "HOTCELL_KEYSTORE=file",
315
+ "/usr/bin/node", hotcellBin(), ...args,
316
+ ], options);
317
+ }
318
+ async function hotcellHasKey(system, provider) {
319
+ if (!(await system.exists(hotcellBin())))
320
+ return false;
321
+ const result = await system.probe("runuser", [
322
+ "-u", USERS.hotcell, "--", "env", `HOTCELL_HOME=${PATHS.hotcellHome}`, "HOTCELL_KEYSTORE=file", "/usr/bin/node", hotcellBin(), "keys", "ls",
323
+ ]);
324
+ return result.code === 0 && new RegExp(`^\\s*${provider}\\b`, "m").test(result.stdout);
325
+ }
326
+ /** The default workspace: an empty Git repository Aster requires as a root. */
327
+ async function ensureWorkspace(system) {
328
+ if (await system.exists(`${PATHS.workspace}/.git`))
329
+ return;
330
+ await system.mkdir(PATHS.workspace, { mode: 0o700, owner: `${USERS.autopilot}:${USERS.autopilot}` });
331
+ const git = (args) => system.run("runuser", ["-u", USERS.autopilot, "--", "git", "-C", PATHS.workspace, ...args]);
332
+ await git(["init", "--quiet", "--initial-branch=main"]);
333
+ await system.run("runuser", ["-u", USERS.autopilot, "--", "sh", "-c", `printf '# Default workspace\\n' > ${PATHS.workspace}/README.md`]);
334
+ await git(["add", "README.md"]);
335
+ await git(["-c", "user.name=Autopilot", "-c", "user.email=autopilot@localhost", "commit", "--quiet", "-m", "Initialize workspace"]);
336
+ }
337
+ /** Builds the sandbox image unless it exists; returns whether it already did. */
338
+ async function ensureCellImage(system, image, autopilotVersion) {
339
+ if ((await system.probe("docker", ["image", "inspect", image])).code === 0)
340
+ return true;
341
+ const dir = system.dryRun ? "/tmp/directionallylocal-image" : (await system.run("mktemp", ["-d"])).stdout.trim();
342
+ try {
343
+ await system.run("npm", ["pack", `autopilot-ai@${autopilotVersion}`, "--pack-destination", dir, "--silent"]);
344
+ await system.run("sh", ["-c", `mv ${dir}/autopilot-ai-*.tgz ${dir}/autopilot.tgz`]);
345
+ await system.writeFile(`${dir}/Dockerfile`, cellDockerfile());
346
+ await system.run("docker", ["build", "--pull", "-q", "-t", image, dir], { inherit: true });
347
+ }
348
+ finally {
349
+ await system.remove(dir);
350
+ }
351
+ return false;
352
+ }
353
+ /** With UFW on, sandboxes still need the gateway and GitHub proxy on the bridge address. */
354
+ async function allowBridgePorts(system, bridgeIp) {
355
+ const status = await system.probe("ufw", ["status"]);
356
+ if (status.code !== 0 || !/Status: active/.test(status.stdout))
357
+ return;
358
+ for (const port of [HOTCELL_EGRESS_PORT, GITHUB_PROXY_PORT]) {
359
+ await system.run("ufw", ["allow", "in", "on", "docker0", "to", bridgeIp, "port", String(port), "proto", "tcp", "comment", "directionallylocal sandboxes"]);
360
+ }
361
+ }
362
+ async function tailscaleIsUp(system) {
363
+ const status = await system.probe("tailscale", ["status", "--json"]);
364
+ if (status.code !== 0)
365
+ return false;
366
+ try {
367
+ return JSON.parse(status.stdout).BackendState === "Running";
368
+ }
369
+ catch {
370
+ return false;
371
+ }
372
+ }
373
+ /**
374
+ * Joins the tailnet right after the questions, before the long steps: the auth key is
375
+ * used at once, so a later failure never has to ask for it again (it's never kept on disk).
376
+ */
377
+ async function joinTailnet(system, authKey) {
378
+ if ((await system.probe("tailscale", ["version"])).code !== 0) {
379
+ await system.run("sh", ["-c", "curl -fsSL https://tailscale.com/install.sh | sh"], { inherit: true, describe: "install Tailscale (tailscale.com/install.sh)" });
380
+ }
381
+ if (authKey) {
382
+ // The key goes through a 0600 file, never argv.
383
+ const keyFile = `${PATHS.stateDir}/tailscale-auth-key`;
384
+ await system.mkdir(PATHS.stateDir, { mode: 0o700 });
385
+ await system.writeFile(keyFile, authKey, { mode: 0o600, secret: true });
386
+ try {
387
+ await system.run("tailscale", ["up", `--auth-key=file:${keyFile}`, `--hostname=${machineName()}`]);
388
+ }
389
+ finally {
390
+ await system.remove(keyFile);
391
+ }
392
+ }
393
+ if (system.dryRun)
394
+ return "on your tailnet";
395
+ const status = await system.probe("tailscale", ["status", "--json"]);
396
+ const name = JSON.parse(status.stdout || "{}").Self?.DNSName?.replace(/\.$/, "");
397
+ return name ? `${authKey ? "joined as" : "already on it as"} ${name.split(".")[0]}` : "joined";
398
+ }
399
+ /** Serves Autopilot on the machine's HTTPS ts.net name; returns that URL. */
400
+ async function serveOverTailscale(system) {
401
+ const served = await system.run("tailscale", ["serve", "--bg", "--https=443", `http://127.0.0.1:${AUTOPILOT_PORT}`], { allowFailure: true });
402
+ if (served.code !== 0) {
403
+ throw new Error(`Tailscale couldn't serve HTTPS: ${served.stderr.trim() || served.stdout.trim()}\n`
404
+ + " Turn on MagicDNS and HTTPS certificates at https://login.tailscale.com/admin/dns, then run `dl up` again.");
405
+ }
406
+ if (system.dryRun)
407
+ return DRY_RUN_URL;
408
+ const status = await system.probe("tailscale", ["status", "--json"]);
409
+ const dnsName = JSON.parse(status.stdout || "{}").Self?.DNSName?.replace(/\.$/, "");
410
+ if (!dnsName)
411
+ throw new Error("Tailscale is up but reported no DNS name for this machine; is MagicDNS on?");
412
+ return `https://${dnsName}`;
413
+ }
414
+ async function waitUntil(system, sleep, what, ready, unit = what.toLowerCase()) {
415
+ if (system.dryRun)
416
+ return;
417
+ for (let attempt = 0; attempt < 60; attempt++) {
418
+ if (await ready())
419
+ return;
420
+ await sleep(1_000);
421
+ }
422
+ throw new Error(`${what} didn't come up; check: journalctl -u ${unit} -n 50`);
423
+ }
424
+ function machineName() {
425
+ return hostname().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 63) || "directionallylocal";
426
+ }
package/install.sh ADDED
@@ -0,0 +1,150 @@
1
+ #!/bin/sh
2
+ # Directionally Local: one-line server install.
3
+ #
4
+ # curl -fsSL https://directionallylocal.run | sh
5
+ #
6
+ # Installs Node.js 24 if needed and the directionallylocal CLI (also `dl`), then runs
7
+ # `dl up`, which asks how to reach this server and for the model
8
+ # key, and sets up Hotcell + Autopilot as services. Safe to run again.
9
+ #
10
+ # Unattended: export DL_TAILSCALE_AUTH_KEY, DL_OPENROUTER_API_KEY and DL_GITHUB_TOKEN first.
11
+ # Pin a version: DL_VERSION=0.1.0. Install from a local tarball: DL_PACKAGE=/path/to.tgz.
12
+ set -eu
13
+
14
+ say() { printf '%s\n' "$*"; }
15
+ fail() { printf '\n directionallylocal: %s\n' "$*" >&2; exit 1; }
16
+
17
+ # The same look as `dl up`: a running step has a spinner and a timer, and becomes "✓"
18
+ # when done. Without a terminal (or with NO_COLOR) it's plain lines.
19
+ if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-}" != "dumb" ]; then
20
+ FANCY=1
21
+ else
22
+ FANCY=""
23
+ fi
24
+ STEP=""
25
+ step() {
26
+ STEP=$1
27
+ if [ -n "$FANCY" ]; then printf ' \033[36m⠋\033[0m %s' "$1"; else say ".. $1"; fi
28
+ }
29
+ 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
+ }
32
+
33
+ # Quiet steps: output goes to a log that's shown only if the step fails.
34
+ LOG=$(mktemp)
35
+ trap 'rm -f "$LOG"' EXIT
36
+ # Runs a command as root, through sudo when needed (keeping DL_* answers in the environment).
37
+ as_root() {
38
+ if [ "$(id -u)" -eq 0 ]; then
39
+ "$@"
40
+ else
41
+ sudo -E "$@"
42
+ fi
43
+ }
44
+
45
+ # The spinner frame for tick $1 (POSIX sh has no arrays, and multibyte cut isn't portable).
46
+ frame() {
47
+ case $(($1 % 10)) in
48
+ 0) printf '⠋' ;; 1) printf '⠙' ;; 2) printf '⠹' ;; 3) printf '⠸' ;; 4) printf '⠼' ;;
49
+ 5) printf '⠴' ;; 6) printf '⠦' ;; 7) printf '⠧' ;; 8) printf '⠇' ;; *) printf '⠏' ;;
50
+ esac
51
+ }
52
+
53
+ # On failure, show only this step's own output, not the earlier steps' in the same log.
54
+ show_step_log() {
55
+ tail -n "+$(($1 + 1))" "$LOG" | tail -n 15 | while IFS= read -r line; do
56
+ if [ -n "$FANCY" ]; then printf ' \033[2m%s\033[0m\n' "$line" >&2; else printf ' %s\n' "$line" >&2; fi
57
+ done
58
+ }
59
+
60
+ quietly() {
61
+ what=$1
62
+ shift
63
+ from=$(wc -l <"$LOG")
64
+ if [ -z "$FANCY" ]; then
65
+ "$@" >>"$LOG" 2>&1 || { show_step_log "$from"; fail "$what failed"; }
66
+ return 0
67
+ fi
68
+ # Run in the background and animate the current step until it finishes.
69
+ "$@" >>"$LOG" 2>&1 &
70
+ pid=$!
71
+ started=$(date +%s)
72
+ tick=0
73
+ while kill -0 "$pid" 2>/dev/null; do
74
+ elapsed=$(($(date +%s) - started))
75
+ printf '\r\033[2K \033[36m%s\033[0m %-33s\033[2m%d:%02d\033[0m' "$(frame "$tick")" "$STEP" $((elapsed / 60)) $((elapsed % 60))
76
+ tick=$((tick + 1))
77
+ sleep 0.1
78
+ done
79
+ if ! wait "$pid"; then
80
+ printf '\r\033[2K \033[31m✗\033[0m %s\n\n' "$STEP"
81
+ show_step_log "$from"
82
+ fail "$what failed"
83
+ fi
84
+ }
85
+
86
+ node_major() {
87
+ node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0
88
+ }
89
+
90
+ node_is_patched() {
91
+ command -v node >/dev/null 2>&1 || return 1
92
+ node -e '
93
+ const [major, minor, patch] = process.versions.node.split(".").map(Number);
94
+ const fixed = { 22: [23, 4], 24: [22, 1], 26: [12, 1] }[major];
95
+ const ok = major > 26 || (fixed && (minor > fixed[0] || (minor === fixed[0] && patch >= fixed[1])));
96
+ process.exit(ok ? 0 : 1);
97
+ ' 2>/dev/null
98
+ }
99
+
100
+ # Wrapped in a function so a truncated download never runs half a script.
101
+ main() {
102
+ if [ -n "$FANCY" ]; then printf '\n \033[1mDirectionally Local\033[0m\n\n'; else say "Directionally Local"; fi
103
+ if [ "$(id -u)" -ne 0 ] && ! command -v sudo >/dev/null 2>&1; then
104
+ fail "run as root (or install sudo)"
105
+ fi
106
+
107
+ [ "$(uname -s)" = "Linux" ] || fail "only Linux servers are supported (found $(uname -s))"
108
+ [ -r /etc/os-release ] || fail "can't identify this Linux distribution (/etc/os-release missing)"
109
+ # shellcheck disable=SC1091
110
+ . /etc/os-release
111
+ case "${ID:-}" in
112
+ ubuntu|debian) ;;
113
+ *) fail "${PRETTY_NAME:-this distribution} isn't supported yet; use Ubuntu 24.04 or 22.04, or Debian 12" ;;
114
+ esac
115
+ export DEBIAN_FRONTEND=noninteractive
116
+ if ! command -v curl >/dev/null 2>&1; then
117
+ quietly "apt-get update" as_root apt-get update -qq
118
+ quietly "installing curl" as_root apt-get install -y -qq curl ca-certificates
119
+ fi
120
+
121
+ # Node must have the fix for CVE-2026-111497 (Brotli RCE via fetch): 22.23.4+, 24.22.1+ or
122
+ # 26.12.1+, the same rule as Hotcell's engines. A missing or unsupported Node gets 24 LTS
123
+ # (security fixes until April 2028); an unpatched 22, 24 or 26 is upgraded on its own line.
124
+ if ! node_is_patched; then
125
+ line=24
126
+ case "$(node_major)" in 24|26) line=$(node_major) ;; esac
127
+ step "Installing Node.js ${line}"
128
+ quietly "adding the Node.js ${line} repository" as_root sh -c "curl -fsSL https://deb.nodesource.com/setup_${line}.x | bash -"
129
+ quietly "installing Node.js ${line}" as_root apt-get install -y -qq nodejs
130
+ node_is_patched || fail "Node.js $(node -v 2>/dev/null || echo '?') is still missing the CVE-2026-111497 fix; install 22.23.4+, 24.22.1+ or 26.12.1+ and re-run"
131
+ step_done "Installed Node.js" "$(node -v)"
132
+ fi
133
+
134
+ package="${DL_PACKAGE:-directionallylocal@${DL_VERSION:-latest}}"
135
+ step "Getting the installer"
136
+ quietly "installing $package" as_root npm install -g --no-audit --no-fund --loglevel=error "$package"
137
+ step_done "Got the installer" "dl $(dl --version 2>/dev/null || echo "")"
138
+
139
+ # Prompts read the terminal, not this piped script. Test by opening it: the device
140
+ # node exists even when there's no controlling terminal.
141
+ # dl up continues the same screen, so it skips its own title.
142
+ export DL_TITLE_SHOWN=1
143
+ if (exec </dev/tty) 2>/dev/null; then
144
+ as_root directionallylocal up </dev/tty
145
+ else
146
+ as_root directionallylocal up
147
+ fi
148
+ }
149
+
150
+ main "$@"
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "directionallylocal",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.2",
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
+ },
13
+ "type": "module",
14
+ "bin": {
15
+ "directionallylocal": "bin/directionallylocal.mjs",
16
+ "dl": "bin/directionallylocal.mjs"
17
+ },
18
+ "files": [
19
+ "bin",
20
+ "dist",
21
+ "install.sh",
22
+ "README.md"
23
+ ],
24
+ "engines": {
25
+ "node": ">=22"
26
+ },
27
+ "scripts": {
28
+ "build": "tsc -p tsconfig.build.json",
29
+ "typecheck": "tsc --noEmit && tsc -p site/tsconfig.json",
30
+ "test": "tsx --test \"src/**/*.test.ts\" \"site/**/*.test.ts\"",
31
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^22.10.0",
35
+ "tsx": "^4.19.2",
36
+ "typescript": "^5.7.2"
37
+ }
38
+ }