directionallylocal 0.7.0 → 0.8.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/README.md +3 -0
- package/dist/cli.js +33 -0
- package/dist/home.js +6 -2
- package/dist/laptop/preview.js +60 -0
- package/dist/laptop/tunnel.js +12 -8
- package/dist/layout.js +3 -0
- package/dist/preview.js +219 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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
|
+
}
|
package/dist/laptop/tunnel.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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 ${
|
|
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 ${
|
|
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";
|
package/dist/preview.js
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.8.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
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://directionallylocal.com",
|