directionallylocal 0.7.0 → 0.8.1

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/README.md CHANGED
@@ -11,7 +11,7 @@ dl
11
11
 
12
12
  `dl` asks where your agents should run:
13
13
 
14
- - **On this computer** — start now, nothing to rent, pauses when this computer sleeps.
14
+ - **On this computer** — start now, nothing to rent, pauses when this computer sleeps. macOS and Linux; not offered on Windows yet (connect a server, or run `dl` inside WSL 2, which takes the Linux path).
15
15
  - **On my own server** — keeps going when your laptop is closed; needs SSH access.
16
16
 
17
17
  For a server, `dl` lists the hosts in `~/.ssh/config` (or takes `user@host[:port]`), checks it over SSH, installs itself there, and pairs this laptop. Nothing is exposed on the server: `dl` reaches it through an SSH tunnel.
@@ -71,6 +71,7 @@ The "fits ~N agents" figure is a conservative estimate: memory left after the ho
71
71
  | `dl runs` | Your tasks and their pull requests. `--json` for scripts. |
72
72
  | `dl watch ID` / `dl cancel ID` | Follow a task, or stop one. |
73
73
  | `dl pull ID` | Check out a task's branch here. |
74
+ | `dl preview ID` | Deploy a task's Workers branch on the server with Serverless Objects and open it here, over the SSH tunnel. |
74
75
  | `dl servers` | Where agents run. `dl servers rename OLD NEW`, `dl servers remove NAME`. |
75
76
  | `dl use [NAME]` | Where tasks go without `--server`. With no name, shows it. |
76
77
  | `dl keys [openrouter\|github]` | Which keys are set, or replace one. `--server NAME` picks the server. |
@@ -93,6 +94,8 @@ Run these as root. `dl` on its own shows what this is, whether it's healthy, the
93
94
  | `dl laptops` | Connected laptops. `dl laptops remove NAME` disconnects one. |
94
95
  | `dl runs` | Tasks running or done, with their pull requests. |
95
96
  | `dl logs [hotcell\|autopilot] [-f]` | The services' logs. |
97
+ | `dl preview ID [--remove]` | Deploy a task's branch as a preview, or stop it. Needs `DL_PREVIEWS_BUCKET`. |
98
+ | `dl previews` | Live previews: task, port, branch. |
96
99
  | `dl doctor` | Check every part, with a fix for each failure. |
97
100
  | `dl keys [set openrouter\|github]` | Which keys are set, or replace one (hidden input). |
98
101
  | `dl status` | Show the installed versions and where to reach the server. |
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import { loadRemotes } from "./laptop/config.js";
9
9
  import { keys } from "./laptop/keys.js";
10
10
  import { model } from "./laptop/settings.js";
11
11
  import { projectSession } from "./laptop/session.js";
12
+ import { preview as laptopPreview } from "./laptop/preview.js";
12
13
  import { setup } from "./laptop/setup.js";
13
14
  import { realSshExec } from "./laptop/ssh.js";
14
15
  import { update } from "./laptop/update.js";
@@ -19,6 +20,7 @@ import { readState } from "./state.js";
19
20
  import { DryRunSystem, RealSystem } from "./system.js";
20
21
  import { uninstall } from "./uninstall.js";
21
22
  import { JsonUi, PlainUi, TerminalUi, palette } from "./ui.js";
23
+ import { previewCommand, previewsCommand } from "./preview.js";
22
24
  import { needs, parseAccessMode, parseProvidedAnswers, up } from "./up.js";
23
25
  const HELP = `Directionally Local: coding agents that run on this computer or on your own server.
24
26
  Send a task; it runs in its own sandbox and comes back as a pull request.
@@ -36,6 +38,7 @@ On your laptop:
36
38
  dl runs your tasks and their pull requests
37
39
  dl watch ID follow a task dl cancel ID stop one
38
40
  dl pull ID check out a task's branch here
41
+ dl preview ID deploy a task's Workers branch on the server and open it here
39
42
  dl servers where agents run (dl servers remove NAME · dl servers rename OLD NEW)
40
43
  dl use [NAME] where tasks go without --server (no NAME: show it)
41
44
  dl keys [openrouter|github] which keys are set, or replace one (--server NAME)
@@ -53,6 +56,8 @@ On the server:
53
56
  dl connect-laptop CODE approve the code a laptop shows
54
57
  dl laptops connected laptops (dl laptops remove NAME to disconnect one)
55
58
  dl runs tasks running or done, with their pull requests
59
+ dl preview ID [--remove] deploy a task's branch with Serverless Objects, or stop it
60
+ dl previews live previews: task, port, branch
56
61
  dl logs [hotcell|autopilot] [-f] the services' logs
57
62
  dl doctor check every part, with a fix for each problem
58
63
  dl keys [set openrouter|github] which keys are set, or replace one (hidden input)
@@ -305,6 +310,34 @@ export async function main(argv, io = {
305
310
  io.err(` dl new starts a fresh conversation from inside a GitHub repository (with something set up: dl setup).\n`);
306
311
  return 1;
307
312
  }
313
+ case "preview": {
314
+ const id = parsed.positionals[1];
315
+ if (!id) {
316
+ io.err(` Usage: dl preview ID ${colors.dim("(from dl runs)")}\n`);
317
+ return 2;
318
+ }
319
+ // On a server, deploy it; on a laptop, ask the server to and tunnel to the result.
320
+ if (await readState(system)) {
321
+ if (!isRoot()) {
322
+ io.err(` Run it as root: sudo dl preview ${id}\n`);
323
+ return 1;
324
+ }
325
+ return await previewCommand(system, parsed.positionals.slice(1), { json: Boolean(values.json) }, io.out, colors);
326
+ }
327
+ return await laptopPreview({ id, ...(values.server ? { server: values.server } : {}) }, {}, laptopIO);
328
+ }
329
+ case "previews": {
330
+ const state = await readState(system);
331
+ if (!state) {
332
+ io.out(NOT_A_SERVER(colors));
333
+ return 1;
334
+ }
335
+ if (!isRoot()) {
336
+ io.err(" Run it as root: sudo dl previews\n");
337
+ return 1;
338
+ }
339
+ return await previewsCommand(system, io.out, colors);
340
+ }
308
341
  case "servers":
309
342
  return await servers(parsed.positionals.slice(1), laptopIO);
310
343
  case "use":
package/dist/home.js CHANGED
@@ -3,6 +3,7 @@ import { AUTOPILOT_PORT, CELL_MEMORY_MB, SERVICES } from "./layout.js";
3
3
  import { approvePairing, createLaptopCredential, listLaptops, listServerRuns, ownerLink, pendingPairings, revokeLaptop } from "./server-api.js";
4
4
  import { describeAccess, readState } from "./state.js";
5
5
  import { palette } from "./ui.js";
6
+ import { readPreviews } from "./preview.js";
6
7
  import { nextSteps } from "./up.js";
7
8
  const ABOUT = "Background coding agents on your own server: send a task from your laptop, get a pull request back.";
8
9
  /** Quick health for the home screen and the login line: services up and the API answering. */
@@ -21,8 +22,8 @@ async function health(system) {
21
22
  }
22
23
  /** `dl` on a server: what this is, whether it's healthy, who's connected, what's running, what to do. */
23
24
  export async function serverHome(system, state, write, c) {
24
- const [status, laptops, runs, pending, facts] = await Promise.all([
25
- health(system), listLaptops(system), listServerRuns(system), pendingPairings(system), detectHost(system),
25
+ const [status, laptops, runs, pending, facts, previews] = await Promise.all([
26
+ health(system), listLaptops(system), listServerRuns(system), pendingPairings(system), detectHost(system), readPreviews(system),
26
27
  ]);
27
28
  const verdict = status.ok ? c.green("● healthy") : c.red(`● ${status.problems.join(", ")}`);
28
29
  const week = Date.now() - 7 * 24 * 60 * 60 * 1000;
@@ -33,6 +34,9 @@ export async function serverHome(system, state, write, c) {
33
34
  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
35
  write(row("Runs", runs === undefined ? c.dim("unknown (run dl up to update)") : `${running} running · ${doneThisWeek} this week`, "dl runs"));
35
36
  write(row("Capacity", `~${agentCapacity(facts, CELL_MEMORY_MB)} agents at once (estimate)`));
37
+ if (previews.previews.length > 0) {
38
+ write(row("Previews", `${previews.previews.length} live · ${previews.previews.map((preview) => preview.id).join(", ")}`, "dl previews"));
39
+ }
36
40
  if (pending.length > 0) {
37
41
  write(`\n ${c.yellow("!")} Waiting for approval: ${pending.map((entry) => `${c.bold(entry.code)} (${entry.name})`).join(", ")} ${c.dim("dl connect-laptop <code>")}\n`);
38
42
  }
@@ -0,0 +1,60 @@
1
+ import { palette } from "../ui.js";
2
+ import { resolveRemote } from "./config.js";
3
+ import { openBrowser } from "./open-browser.js";
4
+ import { checkServer, realSshExec, shellQuote } from "./ssh.js";
5
+ import { createForwarder } from "./tunnel.js";
6
+ /** Non-root users run the server commands with sudo; checkServer made sure it needs no password. */
7
+ function sudo(facts) {
8
+ return facts.root ? "" : "sudo -n ";
9
+ }
10
+ export async function preview(input, deps, io) {
11
+ const c = palette(io.color === true);
12
+ const remote = await resolveRemote(input.server);
13
+ if (!remote.ssh) {
14
+ io.err(`\n ${c.red("✗")} ${remote.name} wasn't set up over SSH, so this laptop can't reach a preview on it.\n On the server: ${c.bold(`dl preview ${input.id}`)}\n\n`);
15
+ return 1;
16
+ }
17
+ const ssh = deps.ssh ?? realSshExec;
18
+ const facts = await checkServer(remote.ssh, ssh, remote.name);
19
+ io.err(` ${c.cyan("·")} Deploying ${input.id} on ${remote.name}…\n`);
20
+ const result = await ssh(remote.ssh, `${sudo(facts)}dl preview ${shellQuote(input.id)} --json`);
21
+ const answer = lastJson(result.stdout);
22
+ if (result.code !== 0 || typeof answer?.port !== "number") {
23
+ const detail = lastLine(result.stderr) || lastLine(result.stdout) || `dl preview exited with ${result.code}`;
24
+ io.err(`\n ${c.red("✗")} ${detail}\n\n`);
25
+ return 1;
26
+ }
27
+ const forward = deps.forward ?? createForwarder();
28
+ const endpoint = await forward(remote.ssh, answer.port, remote.name);
29
+ const id = typeof answer.id === "string" ? answer.id : input.id;
30
+ io.out(`\n ${c.green("✓")} ${id} is live at ${c.bold(endpoint.url)}\n`);
31
+ io.out(` ${c.dim("Ctrl-C closes the tunnel; the preview keeps running (dl preview " + id + " --remove stops it).")}\n\n`);
32
+ if ((deps.openBrowser ?? openBrowser)(endpoint.url))
33
+ io.out(` ${c.dim("(opened it for you)")}\n\n`);
34
+ try {
35
+ await (deps.hold ?? holdUntilInterrupted)();
36
+ }
37
+ finally {
38
+ await endpoint.close();
39
+ }
40
+ return 0;
41
+ }
42
+ function holdUntilInterrupted() {
43
+ return new Promise((resolve) => {
44
+ process.once("SIGINT", () => resolve());
45
+ });
46
+ }
47
+ function lastJson(text) {
48
+ for (const line of text.trim().split("\n").reverse()) {
49
+ try {
50
+ return JSON.parse(line);
51
+ }
52
+ catch {
53
+ // keep looking: a banner or warning may come before the JSON
54
+ }
55
+ }
56
+ return undefined;
57
+ }
58
+ function lastLine(text) {
59
+ return text.trim().split("\n").at(-1)?.trim() ?? "";
60
+ }
@@ -55,7 +55,7 @@ export async function projectSession(input, io) {
55
55
  note(describeError(error));
56
56
  return undefined;
57
57
  }
58
- const { remote } = target;
58
+ const { remote, reason } = target;
59
59
  const key = { place: remote.name, repository, root };
60
60
  const file = input.sessionsFile ?? sessionsPath();
61
61
  const autopilot = await (input.autopilot ?? findAutopilot)();
@@ -142,7 +142,12 @@ export async function projectSession(input, io) {
142
142
  // `dl new`: the old conversation keeps running on the place (dl runs lists it).
143
143
  if (input.fresh)
144
144
  await forgetSession(key, file);
145
- io.err(`\n ${c.cyan("·")} Connecting to ${c.bold(remote.name)} · ${repository} … ${c.dim(`(${detail})`)}\n`);
145
+ // The place is on the Connecting line already; with more than one set up, also why this
146
+ // one and how to pick another, as `dl run` does (a place named with --server needs neither).
147
+ const chosen = reason === "default" || reason === "last used";
148
+ io.err(`\n ${c.cyan("·")} Connecting to ${c.bold(remote.name)}${chosen ? ` ${c.dim(`(${reason})`)}` : ""} · ${repository} … ${c.dim(`(${detail})`)}\n`);
149
+ if (chosen)
150
+ io.err(` ${c.dim("dl use NAME to change where dl runs")}\n`);
146
151
  // The token goes in the environment only: argv is visible to every user on the machine.
147
152
  const env = { ...process.env, AUTOPILOT_REMOTE_URL: client.baseUrl, AUTOPILOT_REMOTE_TOKEN: remote.token };
148
153
  let saved = remembered?.conversationId;
@@ -37,7 +37,10 @@ export async function loadSettings(path = settingsPath()) {
37
37
  }
38
38
  try {
39
39
  const parsed = JSON.parse(raw);
40
- return typeof parsed.model === "string" && parsed.model.trim() ? { model: parsed.model.trim() } : {};
40
+ return {
41
+ ...(typeof parsed.model === "string" && parsed.model.trim() ? { model: parsed.model.trim() } : {}),
42
+ ...(parsed.askedLocalDefault === true ? { askedLocalDefault: true } : {}),
43
+ };
41
44
  }
42
45
  catch {
43
46
  // A damaged file only loses the saved model; the next save rewrites it.
@@ -45,7 +48,11 @@ export async function loadSettings(path = settingsPath()) {
45
48
  }
46
49
  }
47
50
  export async function saveModel(model, path = settingsPath()) {
48
- const settings = { ...(await loadSettings(path)), model };
51
+ await saveSettings({ model }, path);
52
+ }
53
+ /** Merges into dl's settings, keeping what's already there. */
54
+ export async function saveSettings(changes, path = settingsPath()) {
55
+ const settings = { ...(await loadSettings(path)), ...changes };
49
56
  await mkdir(dirname(path), { recursive: true });
50
57
  const temp = `${path}.${process.pid}.tmp`;
51
58
  await writeFile(temp, `${JSON.stringify({ version: 1, ...settings }, null, 2)}\n`, { mode: 0o600 });
@@ -5,11 +5,12 @@ import { join, resolve } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { PromptCancelled, TtyPrompter } from "../prompt.js";
7
7
  import { palette, PlainUi, TerminalUi } from "../ui.js";
8
- import { loadRemotes, loadRemotesFile, normalizeServerUrl, REMOTE_NAME_RE, remotesPath, saveRemote, setDefaultRemote } from "./config.js";
8
+ import { localUnsupported } from "../local/check.js";
9
+ import { loadRemotes, loadRemotesFile, normalizeServerUrl, REMOTE_NAME_RE, remotesPath, resolveTarget, saveRemote, setDefaultRemote } from "./config.js";
9
10
  import { confirm, gatherKeys } from "./credentials.js";
10
11
  import { bootstrap, INSTALL_URL, installOnServer, pairLaptop, serverNeeds } from "./remote-install.js";
11
12
  import { checkServer, describeHost, discoverKeys, hostOf, parseSshConfig, parseTarget, realSshExec, SshAuthError } from "./ssh.js";
12
- import { askModel, DEFAULT_MODEL, loadSettings, modelPrices, readAutopilotModel, saveModel } from "./settings.js";
13
+ import { askModel, DEFAULT_MODEL, loadSettings, modelPrices, readAutopilotModel, saveModel, saveSettings } from "./settings.js";
13
14
  import { openRemote, withConnections } from "./tunnel.js";
14
15
  const RUN_EXAMPLE = 'dl run "fix the flaky checkout test"';
15
16
  const PLACES = [
@@ -20,6 +21,13 @@ export async function setupWizard(deps) {
20
21
  const c = palette(deps.color);
21
22
  deps.write(`\n ${c.bold("Directionally Local")}\n Background coding agents. Where should they run?\n\n`);
22
23
  const ui = deps.newUi();
24
+ // Where this computer can't run agents (Windows), the list would hold one real choice: say why, and go on.
25
+ const unsupported = localUnsupported(deps.platform ?? process.platform);
26
+ if (unsupported) {
27
+ ui.line("info", "On this computer", unsupported);
28
+ ui.answered("Where", "On my own server");
29
+ return setUpServer(deps);
30
+ }
23
31
  let where = (await deps.prompter.select(ui.prompt("Where"), PLACES, 0)).value;
24
32
  ui.answered("Where", where === "local" ? "On this computer" : "On my own server");
25
33
  if (where === "local" && !deps.localSetup) {
@@ -39,8 +47,7 @@ export async function setupWizard(deps) {
39
47
  const remotesFile = deps.remotesFile ?? remotesPath();
40
48
  const isNew = !(await loadRemotes(remotesFile)).some((entry) => entry.name === remote.name);
41
49
  await saveRemote(remote, remotesFile);
42
- if (isNew)
43
- await chooseDefault(deps, remote, remotesFile);
50
+ await offerThisComputer(deps, remote, remotesFile, isNew);
44
51
  await chooseModel(deps);
45
52
  finishScreen(deps.newUi(), capacity);
46
53
  return remote;
@@ -142,7 +149,7 @@ async function chooseServer(deps, hosts) {
142
149
  }
143
150
  /**
144
151
  * With two or more places set up, where `dl run` sends tasks unless told otherwise. Asked
145
- * only when setup adds a place, with the new one preselected; the first place becomes the
152
+ * only when setup adds a server, with the new one preselected (this computer: offerThisComputer); the first place becomes the
146
153
  * default by itself (saveRemote).
147
154
  */
148
155
  async function chooseDefault(deps, added, remotesFile) {
@@ -158,6 +165,36 @@ async function chooseDefault(deps, added, remotesFile) {
158
165
  ui.answered("Default", value);
159
166
  ui.finish();
160
167
  }
168
+ /**
169
+ * After "on this computer", when commands would still go somewhere else (a server set up
170
+ * first stays the default, saveRemote): asks once, at a terminal, whether this computer
171
+ * should be the default instead. Asked again only when this computer is newly added.
172
+ * Otherwise (no terminal, already asked, or "no") it says where things go and how to change it.
173
+ */
174
+ async function offerThisComputer(deps, local, remotesFile, isNew) {
175
+ const { remotes } = await loadRemotesFile(remotesFile);
176
+ if (remotes.length < 2)
177
+ return;
178
+ const current = await resolveTarget(undefined, remotesFile).then((target) => target.remote.name, () => undefined);
179
+ if (current === local.name)
180
+ return;
181
+ const hint = `dl use ${local.name}`;
182
+ const ui = deps.newUi();
183
+ deps.write("\n");
184
+ if (deps.interactive !== false && (isNew || !(await loadSettings(deps.settingsFile)).askedLocalDefault)) {
185
+ const question = current ? `Make this computer the default? Tasks and sessions go to ${current} now` : "Make this computer the default for tasks and sessions?";
186
+ const yes = await confirm(ui, deps.prompter, question);
187
+ await saveSettings({ askedLocalDefault: true }, deps.settingsFile);
188
+ if (yes) {
189
+ await setDefaultRemote(local.name, remotesFile);
190
+ ui.answered("Default", "this computer");
191
+ ui.finish();
192
+ return;
193
+ }
194
+ }
195
+ ui.line("info", "Default", current ? `still ${current} · ${hint} to run here instead` : `none yet · ${hint} to run here`);
196
+ ui.finish();
197
+ }
161
198
  /**
162
199
  * Which model tasks and sessions use, asked once: setup skips it when dl already has one
163
200
  * (dl model changes it). Autopilot's own default is preselected for people who have one.
@@ -335,6 +372,7 @@ export async function setup(options) {
335
372
  installUrl: process.env.DL_INSTALL_URL || INSTALL_URL,
336
373
  ...(options.localSetup ? { localSetup: options.localSetup } : {}),
337
374
  ...(identityFile ? { identityFile } : {}),
375
+ interactive: terminal && process.stdin.isTTY === true,
338
376
  });
339
377
  }
340
378
  catch (error) {
@@ -7,17 +7,13 @@ const realSpawn = (args) => spawn("ssh", args, { stdio: ["ignore", "ignore", "pi
7
7
  export function tunnelArgs(access, localPort, remotePort) {
8
8
  return ["-N", "-o", "ExitOnForwardFailure=yes", ...sshOptions(access), "-L", `127.0.0.1:${localPort}:127.0.0.1:${remotePort}`, access.target];
9
9
  }
10
- export function createOpener(options = {}) {
10
+ export function createForwarder(options = {}) {
11
11
  const spawnTunnel = options.spawn ?? realSpawn;
12
12
  const freePort = options.freePort ?? pickFreePort;
13
13
  const accepts = options.accepts ?? acceptsConnections;
14
14
  const timeoutMs = options.timeoutMs ?? 20_000;
15
15
  const pollMs = options.pollMs ?? 100;
16
- return async (remote) => {
17
- if (!remote.ssh)
18
- return { url: remote.url, close: async () => { } };
19
- const access = remote.ssh;
20
- const remotePort = Number(new URL(remote.url).port || 80);
16
+ return async (access, remotePort, display = access.target) => {
21
17
  const port = await freePort();
22
18
  const child = spawnTunnel(tunnelArgs(access, port, remotePort));
23
19
  let stderr = "";
@@ -44,18 +40,26 @@ export function createOpener(options = {}) {
44
40
  if (exited) {
45
41
  await close();
46
42
  const detail = stderr.trim().split("\n").at(-1)?.trim();
47
- throw new Error(`Couldn't open an SSH tunnel to ${remote.name} (${access.target})${detail ? `: ${detail}` : ""}\n Check that this works: ${sshCommandLine(access)}`);
43
+ throw new Error(`Couldn't open an SSH tunnel to ${display} (${access.target})${detail ? `: ${detail}` : ""}\n Check that this works: ${sshCommandLine(access)}`);
48
44
  }
49
45
  if (await accepts(port))
50
46
  return { url: `http://127.0.0.1:${port}`, close };
51
47
  if (Date.now() >= deadline) {
52
48
  await close();
53
- throw new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s opening an SSH tunnel to ${remote.name} (${access.target}).\n Check that this works: ${sshCommandLine(access)}`);
49
+ throw new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s opening an SSH tunnel to ${display} (${access.target}).\n Check that this works: ${sshCommandLine(access)}`);
54
50
  }
55
51
  await new Promise((resolve) => setTimeout(resolve, pollMs));
56
52
  }
57
53
  };
58
54
  }
55
+ export function createOpener(options = {}) {
56
+ const forward = createForwarder(options);
57
+ return async (remote) => {
58
+ if (!remote.ssh)
59
+ return { url: remote.url, close: async () => { } };
60
+ return forward(remote.ssh, Number(new URL(remote.url).port || 80), remote.name);
61
+ };
62
+ }
59
63
  export const openRemote = createOpener();
60
64
  /**
61
65
  * For an interactive session: ssh in its own process group, so Ctrl-C typed into the agent's
package/dist/layout.js CHANGED
@@ -17,6 +17,9 @@ export const PATHS = {
17
17
  autopilotHome: "/var/lib/autopilot",
18
18
  workspace: "/var/lib/autopilot/workspaces/default",
19
19
  units: "/etc/systemd/system",
20
+ /** One checkout and one Serverless Objects node per preview (docs/plans/preview-deploys.md). */
21
+ previews: "/var/lib/directionallylocal/previews",
22
+ previewsState: "/var/lib/directionallylocal/previews.json",
20
23
  };
21
24
  /** Lets Ubuntu's unattended-upgrades install Node.js fixes from NodeSource, like its own packages. */
22
25
  export const NODE_AUTO_UPDATES = "/etc/apt/apt.conf.d/52directionallylocal-nodejs";
@@ -41,9 +41,9 @@ export async function checkComputer(system, options) {
41
41
  /** What stops setup before it changes anything, each with how to fix it. */
42
42
  export function computerProblems(facts) {
43
43
  const problems = [];
44
- if (facts.platform !== "darwin" && facts.platform !== "linux") {
45
- return [`Running agents on this computer works on macOS and Linux, not ${facts.platform}. Set up a server instead: curl -fsSL https://directionallylocal.run | sh`];
46
- }
44
+ const unsupported = localUnsupported(facts.platform);
45
+ if (unsupported)
46
+ return [unsupported];
47
47
  if (facts.platform === "linux" && !facts.systemdUser) {
48
48
  problems.push("A systemd user session is needed to keep the agents running (systemctl --user). Log in to a desktop session, or set up a server instead.");
49
49
  }
@@ -79,6 +79,20 @@ function dockerFix(facts) {
79
79
  export function localCapacity(docker) {
80
80
  return agentCapacity({ memoryMb: docker.memoryMb, cpus: docker.cpus }, LOCAL_CELL_MEMORY_MB);
81
81
  }
82
+ /**
83
+ * Why agents can't run on this computer's platform, or undefined on macOS and Linux. The
84
+ * local setup is POSIX throughout (sh, launchd or systemd --user, unix sockets), so other
85
+ * platforms are refused before anything runs. WSL reports "linux" and takes the Linux path.
86
+ */
87
+ export function localUnsupported(platform) {
88
+ if (supportedPlatform(platform))
89
+ return undefined;
90
+ if (platform === "win32") {
91
+ return "Running on this computer isn't supported on Windows yet; connect a server instead (dl setup, then \"On my own server\"). "
92
+ + "Inside WSL 2, dl runs as on Linux (it needs systemd and Docker there).";
93
+ }
94
+ return `Running agents on this computer works on macOS and Linux, not ${platform}. Connect a server instead: dl setup, then "On my own server".`;
95
+ }
82
96
  export function supportedPlatform(platform) {
83
97
  return platform === "darwin" || platform === "linux" ? platform : undefined;
84
98
  }
@@ -8,7 +8,7 @@ import { homedir } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import { checkKey, githubListing, hotcellKeyListed, KeyRejectedError } from "../keys.js";
10
10
  import { RealSystem } from "../system.js";
11
- import { supportedPlatform } from "./check.js";
11
+ import { localUnsupported, supportedPlatform } from "./check.js";
12
12
  import { LOCAL_SERVICES, localGithubEnv, localHotcellBin, localPaths, parseLocalEnvFile, serviceFile } from "./layout.js";
13
13
  import { httpStatus } from "./probes.js";
14
14
  import { userId } from "./services.js";
@@ -16,9 +16,10 @@ import { hotcellCliEnv } from "./setup.js";
16
16
  import { readLocalState } from "./state.js";
17
17
  async function installed(options) {
18
18
  const home = options.home ?? homedir();
19
- const platform = supportedPlatform(options.platform ?? process.platform);
19
+ const raw = options.platform ?? process.platform;
20
+ const platform = supportedPlatform(raw);
20
21
  if (!platform)
21
- throw new Error("Agents run on this computer only on macOS and Linux.");
22
+ throw new Error(localUnsupported(raw));
22
23
  const system = options.system ?? new RealSystem(() => { });
23
24
  const paths = localPaths(home, platform);
24
25
  const state = await readLocalState(system, paths);
@@ -26,7 +26,7 @@ import { join } from "node:path";
26
26
  import { cellDockerfile, cellImage } from "../layout.js";
27
27
  import { removeRemote } from "../laptop/config.js";
28
28
  import { RealSystem } from "../system.js";
29
- import { checkComputer, computerProblems, localCapacity, servicePath, supportedPlatform } from "./check.js";
29
+ import { checkComputer, computerProblems, localCapacity, localUnsupported, servicePath, supportedPlatform } from "./check.js";
30
30
  import { LOCAL_SERVICES, PAIRED_NAME, PREFERRED_PORTS, autopilotXdg, localAsterConfig, localAutopilotBin, localAutopilotEnv, localGithubEnv, localHotcellBin, localHotcellEnv, localPaths, parseLocalEnvFile, serviceSpecs, } from "./layout.js";
31
31
  import { fileMtime, modelPricesHealth, refreshModelPrices } from "../model-prices.js";
32
32
  import { httpStatus, portFree } from "./probes.js";
@@ -37,10 +37,16 @@ const SETUP_LOG = "setup.log";
37
37
  export function localSetupLog(home = homedir()) {
38
38
  return join(home, ".directionallylocal", "logs", SETUP_LOG);
39
39
  }
40
+ /** Stops on a platform the local setup can't run on (Windows), before any command runs. */
41
+ export function assertLocalSupported(platform = process.platform) {
42
+ const unsupported = localUnsupported(platform);
43
+ if (unsupported)
44
+ throw new Error(unsupported);
45
+ }
40
46
  function context(options, logName) {
41
47
  const home = options.home ?? homedir();
42
48
  const rawPlatform = options.platform ?? process.platform;
43
- // Unsupported platforms still get paths (for a clear refusal); nothing is written there.
49
+ // Callers refuse unsupported platforms first (assertLocalSupported); this only keeps types simple.
44
50
  const platform = supportedPlatform(rawPlatform) ?? "linux";
45
51
  return {
46
52
  // Other tools' output goes to a log file (or nowhere), never the screen.
@@ -54,12 +60,15 @@ function context(options, logName) {
54
60
  }
55
61
  /** Checks this computer without changing anything, e.g. before the wizard asks for keys. */
56
62
  export async function checkThisComputer(options = {}) {
63
+ assertLocalSupported(options.platform);
57
64
  const ctx = context(options);
58
65
  const facts = await checkComputer(ctx.system, checkOptions(ctx, options));
59
66
  return { facts, problems: computerProblems(facts) };
60
67
  }
61
68
  export async function setUpOnThisComputer(input) {
62
69
  const { ui } = input;
70
+ // Before the checklist: on Windows nothing here (sh, launchd/systemd, unix sockets) works.
71
+ assertLocalSupported(input.platform);
63
72
  const ctx = context(input, SETUP_LOG);
64
73
  const { system, paths, platform } = ctx;
65
74
  const now = () => (input.now ?? (() => new Date()))().toISOString();
@@ -84,9 +93,6 @@ export async function setUpOnThisComputer(input) {
84
93
  };
85
94
  const { facts, uid } = await step("check", async () => {
86
95
  let facts = await checkComputer(system, checkOptions(ctx, input));
87
- const unsupported = computerProblems(facts);
88
- if (ctx.rawPlatform !== "darwin" && ctx.rawPlatform !== "linux")
89
- throw new Error(unsupported[0]);
90
96
  const uid = await userId(system);
91
97
  if (!facts.docker && facts.dockerApp && platform === "darwin") {
92
98
  ui.start("check", `opening ${facts.dockerApp}`);
@@ -231,6 +237,7 @@ export async function setUpOnThisComputer(input) {
231
237
  */
232
238
  export async function uninstallFromThisComputer(input) {
233
239
  const { ui } = input;
240
+ assertLocalSupported(input.platform);
234
241
  const ctx = context(input, "uninstall.log");
235
242
  const { system, paths, platform } = ctx;
236
243
  const state = await readLocalState(system, paths);
@@ -284,6 +291,7 @@ export async function uninstallFromThisComputer(input) {
284
291
  }
285
292
  /** `dl local status`: is it running, where, and how much fits. Read-only. */
286
293
  export async function localStatus(options = {}) {
294
+ assertLocalSupported(options.platform);
287
295
  const ctx = context(options);
288
296
  const { system, paths, platform } = ctx;
289
297
  const state = await readLocalState(system, paths);
@@ -0,0 +1,219 @@
1
+ import { join } from "node:path";
2
+ import { parseEnvFile, PATHS, USERS } from "./layout.js";
3
+ import { listServerRuns } from "./server-api.js";
4
+ import { palette } from "./ui.js";
5
+ /**
6
+ * `dl preview`: deploy an agent's Workers branch on this server with Serverless Objects and
7
+ * reach it over the laptop's SSH tunnel. Design: docs/plans/preview-deploys.md.
8
+ *
9
+ * Serverless Objects is pre-alpha, so a preview failing is expected rather than exceptional:
10
+ * it never fails the task or its pull request, and it is reported in `dl previews`.
11
+ */
12
+ export const MAX_PREVIEWS = 3;
13
+ export const PREVIEW_PORT_FIRST = 8090;
14
+ export const PREVIEW_PORT_LAST = 8099;
15
+ /** Root only: a monorepo with several Wrangler projects is out of scope for Phase 1. */
16
+ const WRANGLER_CONFIGS = /^wrangler\.(json|jsonc|toml)$/;
17
+ export function previewId(conversationId) {
18
+ return conversationId.slice(0, 8);
19
+ }
20
+ /** A branch is previewable when its root has a Wrangler config (design §3). */
21
+ export function isWranglerProject(files) {
22
+ return files.some((name) => WRANGLER_CONFIGS.test(name));
23
+ }
24
+ /** The first free port in the range, or undefined when every slot is taken. */
25
+ export function freePort(previews) {
26
+ const used = new Set(previews.map((preview) => preview.port));
27
+ for (let port = PREVIEW_PORT_FIRST; port <= PREVIEW_PORT_LAST; port++)
28
+ if (!used.has(port))
29
+ return port;
30
+ return undefined;
31
+ }
32
+ /** The preview's own node config (design §2.3): its own port and its own bucket prefix. */
33
+ export function sobjectsToml(input) {
34
+ return `[celld]\nbucket = ${JSON.stringify(input.bucket)}\nlisten = "127.0.0.1:${input.port}"\n`;
35
+ }
36
+ export function bucketFor(prefix, id) {
37
+ return `${prefix.replace(/\/+$/, "")}/${id}`;
38
+ }
39
+ /**
40
+ * The token reaches git through the environment, never argv: `ps` would show it there.
41
+ * The helper string itself holds no secret.
42
+ */
43
+ const CREDENTIAL_HELPER = 'credential.helper=!f() { echo username=x-access-token; echo "password=$GITHUB_TOKEN"; }; f';
44
+ export function cloneArgs(repository, branch, directory) {
45
+ return ["-c", CREDENTIAL_HELPER, "clone", "--depth", "1", "--branch", branch, `https://github.com/${repository}.git`, directory];
46
+ }
47
+ export function fetchArgs(branch) {
48
+ return ["-c", CREDENTIAL_HELPER, "fetch", "--depth", "1", "origin", branch];
49
+ }
50
+ export async function readPreviews(system) {
51
+ const raw = await system.readFile(PATHS.previewsState);
52
+ if (!raw)
53
+ return { version: 1, previews: [] };
54
+ try {
55
+ const parsed = JSON.parse(raw);
56
+ return parsed.version === 1 && Array.isArray(parsed.previews) ? { version: 1, previews: parsed.previews } : { version: 1, previews: [] };
57
+ }
58
+ catch {
59
+ return { version: 1, previews: [] };
60
+ }
61
+ }
62
+ export async function writePreviews(system, state) {
63
+ await system.writeFile(PATHS.previewsState, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o644 });
64
+ }
65
+ /** The server's GitHub token, for a read-only clone as the autopilot user (design §2.5). */
66
+ export async function githubToken(system) {
67
+ const token = parseEnvFile(await system.readFile(PATHS.githubEnv)).GITHUB_TOKEN;
68
+ return token || undefined;
69
+ }
70
+ /** Deploys (or redeploys) one run's branch, keeping its port and URL stable. */
71
+ export async function deployPreview(run, deps) {
72
+ const { system } = deps;
73
+ const { repository, branch } = run;
74
+ if (!repository || !branch)
75
+ throw new Error("That task has no branch to preview yet; it pushes one when it finishes a step.");
76
+ const id = previewId(run.conversationId);
77
+ const directory = join(PATHS.previews, id);
78
+ const state = await readPreviews(system);
79
+ const existing = state.previews.find((preview) => preview.id === id);
80
+ const port = existing?.port ?? freePort(state.previews);
81
+ if (port === undefined)
82
+ throw new Error(`All ${MAX_PREVIEWS} preview slots are in use. Remove one: dl preview <id> --remove`);
83
+ await syncCheckout(system, { repository, branch, directory }, deps.token);
84
+ if (!(await isWranglerCheckout(system, directory))) {
85
+ throw new Error(`${repository} at ${branch} has no wrangler.json, wrangler.jsonc or wrangler.toml at its root, so there's nothing to preview.`);
86
+ }
87
+ await system.writeFile(join(directory, "sobjects.toml"), sobjectsToml({ bucket: bucketFor(deps.bucket, id), port }), {
88
+ mode: 0o644,
89
+ owner: `${USERS.autopilot}:${USERS.autopilot}`,
90
+ });
91
+ const env = { SOBJECTS_STATE_DIR: join(directory, "state") };
92
+ await system.run("sobjects", ["deploy", directory], { env, describe: `sobjects deploy ${id}` });
93
+ await system.run("sobjects", ["up", "--detach"], { env, cwd: directory, describe: `sobjects up ${id}` });
94
+ const wait = deps.waitForPort ?? ((target) => waitForPort(system, target));
95
+ if (!(await wait(port)))
96
+ throw new Error(`The preview started but nothing answered on 127.0.0.1:${port}. Check its log: dl logs`);
97
+ const preview = {
98
+ id,
99
+ conversationId: run.conversationId,
100
+ repository,
101
+ branch,
102
+ port,
103
+ directory,
104
+ createdAt: (deps.now?.() ?? new Date()).toISOString(),
105
+ };
106
+ await writePreviews(system, { version: 1, previews: [...state.previews.filter((entry) => entry.id !== id), preview] });
107
+ return preview;
108
+ }
109
+ /** Stops a preview's node and deletes its checkout and state. */
110
+ export async function removePreview(id, system) {
111
+ const state = await readPreviews(system);
112
+ const preview = state.previews.find((entry) => entry.id === id);
113
+ if (!preview)
114
+ return false;
115
+ await system.run("sobjects", ["down"], {
116
+ cwd: preview.directory,
117
+ env: { SOBJECTS_STATE_DIR: join(preview.directory, "state") },
118
+ allowFailure: true,
119
+ describe: `sobjects down ${id}`,
120
+ });
121
+ await system.remove(preview.directory);
122
+ await writePreviews(system, { version: 1, previews: state.previews.filter((entry) => entry.id !== id) });
123
+ return true;
124
+ }
125
+ async function syncCheckout(system, input, token) {
126
+ const owner = `${USERS.autopilot}:${USERS.autopilot}`;
127
+ const env = { GITHUB_TOKEN: token };
128
+ if (!(await system.exists(input.directory))) {
129
+ await system.mkdir(PATHS.previews, { owner });
130
+ await system.run("runuser", ["-u", USERS.autopilot, "--", "git", ...cloneArgs(input.repository, input.branch, input.directory)], {
131
+ env,
132
+ describe: `clone ${input.repository}`,
133
+ });
134
+ return;
135
+ }
136
+ await system.run("runuser", ["-u", USERS.autopilot, "--", "git", "-C", input.directory, ...fetchArgs(input.branch)], {
137
+ env,
138
+ describe: `fetch ${input.branch}`,
139
+ });
140
+ await system.run("runuser", ["-u", USERS.autopilot, "--", "git", "-C", input.directory, "reset", "--hard", "FETCH_HEAD"], {
141
+ describe: `reset ${input.branch}`,
142
+ });
143
+ }
144
+ async function isWranglerCheckout(system, directory) {
145
+ const listing = await system.probe("ls", ["-1", directory]);
146
+ return listing.code === 0 && isWranglerProject(listing.stdout.split("\n").map((line) => line.trim()).filter(Boolean));
147
+ }
148
+ async function waitForPort(system, port, attempts = 30) {
149
+ for (let attempt = 0; attempt < attempts; attempt++) {
150
+ const answer = await system.probe("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "2", `http://127.0.0.1:${port}/`]);
151
+ const code = answer.stdout.trim();
152
+ if (answer.code === 0 && code !== "" && code !== "000")
153
+ return true;
154
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
155
+ }
156
+ return false;
157
+ }
158
+ /** `dl preview <id> [--remove] [--json]`: deploy a run's branch, or stop its preview. */
159
+ export async function previewCommand(system, args, options, write, c) {
160
+ const id = args.find((arg) => !arg.startsWith("-"));
161
+ if (!id) {
162
+ write(` Usage: dl preview <id> ${c.dim("(from dl runs)")} ${c.dim("--remove stops it")}\n`);
163
+ return 2;
164
+ }
165
+ if (args.includes("--remove")) {
166
+ const removed = await removePreview(id, system);
167
+ if (options.json)
168
+ write(`${JSON.stringify({ id, removed })}\n`);
169
+ else
170
+ write(`\n ${removed ? c.green("✓") : c.red("✗")} ${removed ? `Removed the preview for ${id}` : `No preview for ${id}`}\n\n`);
171
+ return removed ? 0 : 1;
172
+ }
173
+ const runs = await listServerRuns(system, 200);
174
+ if (runs === undefined) {
175
+ write(`\n ${c.red("✗")} Couldn't list runs; run ${c.bold("dl up")} to update this server.\n\n`);
176
+ return 1;
177
+ }
178
+ const matches = runs.filter((run) => run.conversationId.startsWith(id));
179
+ if (matches.length === 0) {
180
+ write(`\n ${c.red("✗")} No task ${id}. ${c.dim("dl runs")} lists them.\n\n`);
181
+ return 1;
182
+ }
183
+ if (matches.length > 1) {
184
+ write(`\n ${c.red("✗")} ${id} matches several tasks; use more characters.\n\n`);
185
+ return 1;
186
+ }
187
+ const token = await githubToken(system);
188
+ if (!token) {
189
+ write(`\n ${c.red("✗")} This server has no GitHub token, so it can't clone the branch. Set one: ${c.bold("dl keys set github")}\n\n`);
190
+ return 1;
191
+ }
192
+ const bucket = options.bucket ?? process.env.DL_PREVIEWS_BUCKET;
193
+ if (!bucket) {
194
+ write(`\n ${c.red("✗")} Previews need a bucket for their state. Set ${c.bold("DL_PREVIEWS_BUCKET")} in ${c.dim(PATHS.autopilotEnv)} (e.g. s3://my-previews).\n\n`);
195
+ return 1;
196
+ }
197
+ const preview = await deployPreview(matches[0], { system, bucket, token });
198
+ if (options.json) {
199
+ write(`${JSON.stringify({ id: preview.id, port: preview.port, url: `http://127.0.0.1:${preview.port}` })}\n`);
200
+ return 0;
201
+ }
202
+ write(`\n ${c.green("✓")} ${preview.id} is live at ${c.bold(`http://127.0.0.1:${preview.port}`)}\n`);
203
+ write(` ${c.dim("From your laptop:")} dl preview ${preview.id} ${c.dim("Stop it:")} dl preview ${preview.id} --remove\n\n`);
204
+ return 0;
205
+ }
206
+ /** `dl previews`: the live previews, newest first. */
207
+ export async function previewsCommand(system, write, c) {
208
+ const { previews } = await readPreviews(system);
209
+ if (previews.length === 0) {
210
+ write(`\n No previews. From a task's branch: ${c.bold("dl preview <id>")}\n\n`);
211
+ return 0;
212
+ }
213
+ write("\n");
214
+ for (const preview of [...previews].sort((a, b) => b.createdAt.localeCompare(a.createdAt))) {
215
+ write(` ${c.green("●")} ${preview.id} ${c.dim(`:${preview.port}`)} ${(preview.repository ?? "").padEnd(28)} ${c.dim(preview.branch)}\n`);
216
+ }
217
+ write(`\n ${c.dim("Open one from your laptop:")} dl preview <id> ${c.dim("Stop one:")} dl preview <id> --remove\n\n`);
218
+ return 0;
219
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "directionallylocal",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
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
  "license": "Apache-2.0",
6
6
  "homepage": "https://directionallylocal.com",