directionallylocal 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +49 -9
- package/dist/keys.js +188 -0
- package/dist/laptop/commands.js +166 -43
- package/dist/laptop/config.js +119 -23
- package/dist/laptop/credentials.js +129 -0
- package/dist/laptop/keys.js +133 -0
- package/dist/laptop/remote-install.js +280 -0
- package/dist/laptop/setup.js +301 -0
- package/dist/laptop/ssh.js +216 -0
- package/dist/laptop/tunnel.js +120 -0
- package/dist/local/check.js +194 -0
- package/dist/local/cli.js +43 -0
- package/dist/local/keys.js +100 -0
- package/dist/local/layout.js +227 -0
- package/dist/local/probes.js +22 -0
- package/dist/local/services.js +68 -0
- package/dist/local/setup.js +472 -0
- package/dist/local/state.js +15 -0
- package/dist/local/test-support/fake-computer.js +280 -0
- package/dist/up.js +6 -4
- package/install.sh +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { HTTP_STATUS_SCRIPT, PORT_FREE_SCRIPT } from "../probes.js";
|
|
2
|
+
/**
|
|
3
|
+
* A simulated Mac (or Linux desktop) with Docker Desktop, for "on this computer" tests.
|
|
4
|
+
* launchctl/systemctl load and unload services, which then answer on the ports in their
|
|
5
|
+
* env files; npm installs packages; Autopilot's CLI issues credentials. So setup can run
|
|
6
|
+
* twice to prove it repairs without reinstalling.
|
|
7
|
+
*/
|
|
8
|
+
export class FakeComputer {
|
|
9
|
+
home;
|
|
10
|
+
dryRun = false;
|
|
11
|
+
files = new Map();
|
|
12
|
+
dirs = new Map();
|
|
13
|
+
commands = [];
|
|
14
|
+
probes = [];
|
|
15
|
+
logs = [];
|
|
16
|
+
images = new Set();
|
|
17
|
+
containers = [];
|
|
18
|
+
/** launchd labels or systemd units that are running. */
|
|
19
|
+
running = new Set();
|
|
20
|
+
/** Services restarted in place (launchctl kickstart -k, systemctl --user restart), in order. */
|
|
21
|
+
restarted = [];
|
|
22
|
+
/** Ports something else on this computer listens on. */
|
|
23
|
+
busy = new Set();
|
|
24
|
+
credentials = [];
|
|
25
|
+
hotcellKeys = new Map();
|
|
26
|
+
docker = true;
|
|
27
|
+
/** Docker Desktop is installed as an app (it may be stopped). */
|
|
28
|
+
dockerApp = true;
|
|
29
|
+
dockerDenied = false;
|
|
30
|
+
/** Containers reach host.docker.internal. */
|
|
31
|
+
reachHost = true;
|
|
32
|
+
git = true;
|
|
33
|
+
uid = "501";
|
|
34
|
+
diskFreeKb = 120 * 1024 * 1024;
|
|
35
|
+
/** Docker Desktop's VM: 8 GB, 8 CPUs. */
|
|
36
|
+
dockerMemBytes = 8 * 1024 * 1024 * 1024;
|
|
37
|
+
systemdUser = true;
|
|
38
|
+
bridgeIp = undefined;
|
|
39
|
+
constructor(home = "/Users/ada") {
|
|
40
|
+
this.home = home;
|
|
41
|
+
this.files.set(`${home}/.docker/run/docker.sock`, { content: "", mode: 0o755 });
|
|
42
|
+
this.files.set("/opt/homebrew/opt/node/bin/node", { content: "", mode: 0o755 });
|
|
43
|
+
this.files.set("/Applications/Docker.app/Contents/Info.plist", { content: "", mode: 0o644 });
|
|
44
|
+
this.files.set("/etc/os-release", { content: 'ID=ubuntu\nPRETTY_NAME="Ubuntu 24.04.1 LTS"\n', mode: 0o644 });
|
|
45
|
+
}
|
|
46
|
+
get ran() {
|
|
47
|
+
return this.commands.map(({ command, args }) => [command, ...args].join(" "));
|
|
48
|
+
}
|
|
49
|
+
async run(command, args, options = {}) {
|
|
50
|
+
this.commands.push({ command, args, ...(options.input !== undefined ? { input: options.input } : {}), ...(options.env ? { env: options.env } : {}) });
|
|
51
|
+
const result = this.respond(command, args, options);
|
|
52
|
+
if (result.code !== 0 && !options.allowFailure)
|
|
53
|
+
throw new Error(`${command} ${args.join(" ")} failed: ${result.stderr}`);
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
async probe(command, args, options = {}) {
|
|
57
|
+
this.probes.push({ command, args, ...(options.input !== undefined ? { input: options.input } : {}), ...(options.env ? { env: options.env } : {}) });
|
|
58
|
+
return this.respond(command, args, options);
|
|
59
|
+
}
|
|
60
|
+
async readFile(path) {
|
|
61
|
+
return this.files.get(path)?.content;
|
|
62
|
+
}
|
|
63
|
+
async writeFile(path, content, options = {}) {
|
|
64
|
+
this.files.set(path, { content, mode: options.mode ?? 0o644 });
|
|
65
|
+
}
|
|
66
|
+
async mkdir(path, options = {}) {
|
|
67
|
+
this.dirs.set(path, options.mode);
|
|
68
|
+
}
|
|
69
|
+
async remove(path) {
|
|
70
|
+
for (const file of [...this.files.keys()])
|
|
71
|
+
if (file === path || file.startsWith(`${path}/`))
|
|
72
|
+
this.files.delete(file);
|
|
73
|
+
for (const dir of [...this.dirs.keys()])
|
|
74
|
+
if (dir === path || dir.startsWith(`${path}/`))
|
|
75
|
+
this.dirs.delete(dir);
|
|
76
|
+
}
|
|
77
|
+
async exists(path) {
|
|
78
|
+
if (path === "/Applications/Docker.app")
|
|
79
|
+
return this.dockerApp;
|
|
80
|
+
return this.files.has(path) || this.dirs.has(path) || [...this.files.keys(), ...this.dirs.keys()].some((entry) => entry.startsWith(`${path}/`));
|
|
81
|
+
}
|
|
82
|
+
log(line) {
|
|
83
|
+
this.logs.push(line);
|
|
84
|
+
}
|
|
85
|
+
/** The ports a running service of ours listens on, from its env file. */
|
|
86
|
+
ourPorts() {
|
|
87
|
+
const ports = new Map();
|
|
88
|
+
const env = (name) => Object.fromEntries((this.files.get(`${this.home}/.directionallylocal/config/${name}`)?.content ?? "")
|
|
89
|
+
.split("\n").flatMap((line) => line.match(/^([A-Z_]+)=(.*)$/) ? [[line.split("=")[0], line.slice(line.indexOf("=") + 1)]] : []));
|
|
90
|
+
if ([...this.running].some((name) => name.includes("hotcell"))) {
|
|
91
|
+
const hotcell = env("hotcell.env");
|
|
92
|
+
for (const key of ["HOTCELL_PORT", "HOTCELL_PROXY_PORT", "HOTCELL_EGRESS_PORT"])
|
|
93
|
+
if (hotcell[key])
|
|
94
|
+
ports.set(Number(hotcell[key]), "hotcell");
|
|
95
|
+
}
|
|
96
|
+
if ([...this.running].some((name) => name.includes("autopilot"))) {
|
|
97
|
+
const autopilot = env("autopilot.env");
|
|
98
|
+
if (autopilot.AUTOPILOT_PUBLIC_URL)
|
|
99
|
+
ports.set(Number(autopilot.AUTOPILOT_PUBLIC_URL.split(":").at(-1)), "autopilot");
|
|
100
|
+
if (autopilot.AUTOPILOT_ASTER_GITHUB_PROXY_LISTEN)
|
|
101
|
+
ports.set(Number(autopilot.AUTOPILOT_ASTER_GITHUB_PROXY_LISTEN.split(":").at(-1)), "autopilot");
|
|
102
|
+
}
|
|
103
|
+
return ports;
|
|
104
|
+
}
|
|
105
|
+
respond(command, args, options) {
|
|
106
|
+
const ok = (stdout = "") => ({ code: 0, stdout, stderr: "" });
|
|
107
|
+
const fail = (stderr = "failed") => ({ code: 1, stdout: "", stderr });
|
|
108
|
+
const line = args.join(" ");
|
|
109
|
+
if (command.endsWith("/node") && args[0] === "-e") {
|
|
110
|
+
if (args[1] === PORT_FREE_SCRIPT) {
|
|
111
|
+
const port = Number(args[3]);
|
|
112
|
+
return this.busy.has(port) || this.ourPorts().has(port) ? fail() : ok();
|
|
113
|
+
}
|
|
114
|
+
if (args[1] === HTTP_STATUS_SCRIPT) {
|
|
115
|
+
const url = new URL(args[2]);
|
|
116
|
+
const owner = this.ourPorts().get(Number(url.port));
|
|
117
|
+
if (owner === "hotcell")
|
|
118
|
+
return ok("401\n");
|
|
119
|
+
if (owner === "autopilot" && url.pathname === "/api/v1/health") {
|
|
120
|
+
const token = (options.input ?? "").trim();
|
|
121
|
+
return ok(token && this.credentials.some((entry) => entry.token === token && entry.status === "active") ? "200\n" : "401\n");
|
|
122
|
+
}
|
|
123
|
+
return ok("0\n");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (command.endsWith("/node") && args[0]?.endsWith("hotcell/dist/index.js")) {
|
|
127
|
+
if (args[1] === "keys" && args[2] === "add") {
|
|
128
|
+
this.hotcellKeys.set(args[3], options.input ?? "");
|
|
129
|
+
const home = options.env?.HOTCELL_HOME ?? "";
|
|
130
|
+
this.files.set(`${home}/keys.json`, { content: "{}", mode: 0o600 });
|
|
131
|
+
return ok("stored\n");
|
|
132
|
+
}
|
|
133
|
+
if (args[1] === "keys" && args[2] === "ls") {
|
|
134
|
+
if (!this.files.has(`${options.env?.HOTCELL_HOME ?? ""}/keys.json`))
|
|
135
|
+
return ok("");
|
|
136
|
+
// Like Hotcell's own listing: a header, then each provider with its masked value.
|
|
137
|
+
const mask = (value) => value.length <= 8 ? "•".repeat(Math.max(4, value.length)) : `${value.slice(0, 5)}…${value.slice(-2)}`;
|
|
138
|
+
return ok(`PROVIDER KEY SOURCE\n${[...this.hotcellKeys].map(([key, value]) => `${key.padEnd(8)} ${mask(value).padEnd(12)} file\n`).join("")}`);
|
|
139
|
+
}
|
|
140
|
+
return ok();
|
|
141
|
+
}
|
|
142
|
+
if (command.endsWith("/node") && args[0]?.endsWith("autopilot-ai/bin/autopilot.mjs")) {
|
|
143
|
+
const sub = args.slice(1, 4).join(" ");
|
|
144
|
+
if (sub.startsWith("aster token create")) {
|
|
145
|
+
const id = `cred_${this.credentials.length + 1}`;
|
|
146
|
+
const token = `aster_${"t".repeat(30)}${this.credentials.length + 1}`;
|
|
147
|
+
const expiresAt = Date.parse("2027-10-28T10:00:00Z");
|
|
148
|
+
this.credentials.push({ id, name: args[args.indexOf("--name") + 1], status: "active", token, expiresAt });
|
|
149
|
+
return ok(`${JSON.stringify({ credentialId: id, workspaceIds: ["default"], scopes: [], createdAt: 0, expiresAt, token })}\n`);
|
|
150
|
+
}
|
|
151
|
+
if (sub.startsWith("aster token list")) {
|
|
152
|
+
return ok(`${JSON.stringify(this.credentials.map(({ token: _token, ...entry }) => ({ ...entry, scopes: [] })))}\n`);
|
|
153
|
+
}
|
|
154
|
+
if (sub.startsWith("aster token revoke")) {
|
|
155
|
+
const entry = this.credentials.find((credential) => credential.id === args.at(-1));
|
|
156
|
+
if (entry)
|
|
157
|
+
entry.status = "revoked";
|
|
158
|
+
return ok();
|
|
159
|
+
}
|
|
160
|
+
return ok();
|
|
161
|
+
}
|
|
162
|
+
switch (command) {
|
|
163
|
+
case "id": return ok(`${this.uid}\n`);
|
|
164
|
+
case "sw_vers": return ok("15.1\n");
|
|
165
|
+
case "df": return ok(`Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/disk3s5 971350180 1000 ${this.diskFreeKb} 10% /System/Volumes/Data\n`);
|
|
166
|
+
case "git":
|
|
167
|
+
if (!this.git)
|
|
168
|
+
return { code: 127, stdout: "", stderr: "not found" };
|
|
169
|
+
if (args[0] === "--version")
|
|
170
|
+
return ok("git version 2.50.1\n");
|
|
171
|
+
if (args.includes("init"))
|
|
172
|
+
this.dirs.set(`${args[1]}/.git`, undefined);
|
|
173
|
+
return ok();
|
|
174
|
+
case "sh":
|
|
175
|
+
if (line === "-c command -v docker")
|
|
176
|
+
return ok("/usr/local/bin/docker\n");
|
|
177
|
+
return ok();
|
|
178
|
+
case "ip": return this.bridgeIp ? ok(`4: docker0 inet ${this.bridgeIp}/16 brd 172.17.255.255 scope global docker0\n`) : fail("no docker0");
|
|
179
|
+
case "systemctl":
|
|
180
|
+
if (args[1] === "is-system-running")
|
|
181
|
+
return this.systemdUser ? ok("running\n") : fail("Failed to connect to bus");
|
|
182
|
+
if (args[1] === "restart") {
|
|
183
|
+
this.running.add(args[2]);
|
|
184
|
+
this.restarted.push(args[2]);
|
|
185
|
+
}
|
|
186
|
+
if (args[1] === "disable" && args[2] === "--now")
|
|
187
|
+
this.running.delete(args[3]);
|
|
188
|
+
if (args[1] === "is-active")
|
|
189
|
+
return ok(this.running.has(args[2]) ? "active\n" : "inactive\n");
|
|
190
|
+
return ok();
|
|
191
|
+
case "launchctl": {
|
|
192
|
+
if (args[0] === "bootstrap") {
|
|
193
|
+
const plist = this.files.get(args[2])?.content ?? "";
|
|
194
|
+
const label = plist.match(/<key>Label<\/key>\s*<string>([^<]+)<\/string>/)?.[1];
|
|
195
|
+
if (!label)
|
|
196
|
+
return fail("Bootstrap failed: 5: Input/output error");
|
|
197
|
+
this.running.add(label);
|
|
198
|
+
return ok();
|
|
199
|
+
}
|
|
200
|
+
if (args[0] === "bootout") {
|
|
201
|
+
const label = args[1].split("/").at(-1);
|
|
202
|
+
return this.running.delete(label) ? ok() : fail("Boot-out failed: 3: No such process");
|
|
203
|
+
}
|
|
204
|
+
if (args[0] === "kickstart") {
|
|
205
|
+
// -k restarts a loaded service; one that isn't loaded can't be kickstarted.
|
|
206
|
+
const label = args.at(-1).split("/").at(-1);
|
|
207
|
+
if (!this.running.has(label))
|
|
208
|
+
return fail("Could not find service");
|
|
209
|
+
this.restarted.push(label);
|
|
210
|
+
return ok();
|
|
211
|
+
}
|
|
212
|
+
if (args[0] === "print") {
|
|
213
|
+
const label = args[1].split("/").at(-1);
|
|
214
|
+
return this.running.has(label) ? ok(`${args[1]} = {\n\tstate = running\n\tpid = 4242\n}\n`) : fail("Could not find service");
|
|
215
|
+
}
|
|
216
|
+
return ok();
|
|
217
|
+
}
|
|
218
|
+
case "open":
|
|
219
|
+
if (this.dockerApp)
|
|
220
|
+
this.docker = true;
|
|
221
|
+
return ok();
|
|
222
|
+
case "npm":
|
|
223
|
+
if (args[0] === "view")
|
|
224
|
+
return ok(args[1] === "hotcell" ? "0.1.30\n" : "2.6.1\n");
|
|
225
|
+
if (args[0] === "install" && args[1] === "--prefix") {
|
|
226
|
+
for (const pkg of ["autopilot-ai", "hotcell"])
|
|
227
|
+
this.files.set(`${args[2]}/node_modules/${pkg}/package.json`, { content: "{}", mode: 0o644 });
|
|
228
|
+
this.files.set(`${args[2]}/node_modules/hotcell/dist/index.js`, { content: "", mode: 0o755 });
|
|
229
|
+
this.dirs.set(args[2], 0o700);
|
|
230
|
+
}
|
|
231
|
+
return ok();
|
|
232
|
+
case "ln":
|
|
233
|
+
for (const [path, file] of [...this.files])
|
|
234
|
+
if (path.startsWith(`${args[1]}/`))
|
|
235
|
+
this.files.set(path.replace(args[1], args[2]), file);
|
|
236
|
+
return ok();
|
|
237
|
+
case "ls":
|
|
238
|
+
return ok([...this.dirs.keys()].filter((dir) => dir.startsWith(`${args[1]}/`) && !dir.slice(args[1].length + 1).includes("/")).map((dir) => dir.split("/").at(-1)).join("\n"));
|
|
239
|
+
case "mktemp": return ok("/tmp/tmp.build\n");
|
|
240
|
+
case "wc": return ok("0\n");
|
|
241
|
+
case "/usr/local/bin/docker": {
|
|
242
|
+
if (args[0] === "context")
|
|
243
|
+
return ok(`unix://${this.home}/.docker/run/docker.sock\n`);
|
|
244
|
+
if (args[0] === "info") {
|
|
245
|
+
if (this.dockerDenied)
|
|
246
|
+
return { code: 1, stdout: "", stderr: "permission denied while trying to connect to the Docker daemon socket" };
|
|
247
|
+
if (!this.docker)
|
|
248
|
+
return fail("Cannot connect to the Docker daemon");
|
|
249
|
+
return args[2].startsWith("{{.ServerVersion}}") ? ok(`29.5.2|Docker Desktop|${this.dockerMemBytes}|8\n`) : ok(`${this.dockerMemBytes}|8\n`);
|
|
250
|
+
}
|
|
251
|
+
if (!this.docker)
|
|
252
|
+
return fail("Cannot connect to the Docker daemon");
|
|
253
|
+
if (args[0] === "image" && args[1] === "inspect")
|
|
254
|
+
return this.images.has(args[2]) ? ok("[]") : fail("no such image");
|
|
255
|
+
if (args[0] === "image" && args[1] === "rm") {
|
|
256
|
+
this.images.delete(args[2]);
|
|
257
|
+
return ok();
|
|
258
|
+
}
|
|
259
|
+
if (args[0] === "build") {
|
|
260
|
+
this.images.add(args[args.indexOf("-t") + 1]);
|
|
261
|
+
return ok();
|
|
262
|
+
}
|
|
263
|
+
if (args[0] === "run")
|
|
264
|
+
return ok(this.reachHost && this.ourPorts().size > 0 ? "404\n" : "0\n");
|
|
265
|
+
if (args[0] === "ps") {
|
|
266
|
+
const filters = args.flatMap((arg, index) => args[index - 1] === "--filter" ? [arg.replace(/^label=/, "")] : []);
|
|
267
|
+
return ok(this.containers.filter((entry) => filters.every((filter) => entry.labels.includes(filter))).map((entry) => entry.id).join("\n"));
|
|
268
|
+
}
|
|
269
|
+
if (args[0] === "rm") {
|
|
270
|
+
for (const id of args.slice(2))
|
|
271
|
+
this.containers.splice(this.containers.findIndex((entry) => entry.id === id), 1);
|
|
272
|
+
return ok();
|
|
273
|
+
}
|
|
274
|
+
return ok();
|
|
275
|
+
}
|
|
276
|
+
default:
|
|
277
|
+
return ok();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
package/dist/up.js
CHANGED
|
@@ -35,7 +35,7 @@ export async function up(system, options) {
|
|
|
35
35
|
{ id: "software", label: "Installing Hotcell + Autopilot" },
|
|
36
36
|
{ id: "image", label: "Building the sandbox image" },
|
|
37
37
|
{ id: "services", label: "Starting services" },
|
|
38
|
-
{ id: "access", label: access === "tailscale" ? "Opening private access" : "Starting the
|
|
38
|
+
{ id: "access", label: access === "tailscale" ? "Opening private access" : "Starting the server" },
|
|
39
39
|
]);
|
|
40
40
|
const step = async (id, work) => {
|
|
41
41
|
ui.start(id);
|
|
@@ -131,7 +131,9 @@ export async function up(system, options) {
|
|
|
131
131
|
await system.run("systemctl", ["restart", SERVICES.autopilot]);
|
|
132
132
|
// Without a token the Aster API answers 401: the server is up and refusing strangers.
|
|
133
133
|
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");
|
|
134
|
-
|
|
134
|
+
// The API only listens on this machine: say who can reach it rather than a loopback address.
|
|
135
|
+
const reach = access === "tailscale" ? address.replace(/^https?:\/\//, "") : access === "ssh" ? "private · reached over SSH" : "private · this machine only";
|
|
136
|
+
return { value: address, detail: reach };
|
|
135
137
|
});
|
|
136
138
|
const now = (options.now ?? (() => new Date()))().toISOString();
|
|
137
139
|
const state = {
|
|
@@ -390,7 +392,7 @@ async function latestVersion(system, pkg) {
|
|
|
390
392
|
throw new Error(`Couldn't look up the latest ${pkg} on npm${result.stderr ? `: ${result.stderr.trim()}` : ""}`);
|
|
391
393
|
return version;
|
|
392
394
|
}
|
|
393
|
-
function asHotcell(system, args, options = {}) {
|
|
395
|
+
export function asHotcell(system, args, options = {}) {
|
|
394
396
|
return system.run("runuser", [
|
|
395
397
|
"-u", USERS.hotcell, "--", "env", `HOTCELL_HOME=${PATHS.hotcellHome}`, "HOTCELL_KEYSTORE=file",
|
|
396
398
|
"/usr/bin/node", hotcellBin(), ...args,
|
|
@@ -492,7 +494,7 @@ async function serveOverTailscale(system) {
|
|
|
492
494
|
throw new Error("Tailscale is up but reported no DNS name for this machine; is MagicDNS on?");
|
|
493
495
|
return `https://${dnsName}`;
|
|
494
496
|
}
|
|
495
|
-
async function waitUntil(system, sleep, what, ready, unit = what.toLowerCase()) {
|
|
497
|
+
export async function waitUntil(system, sleep, what, ready, unit = what.toLowerCase()) {
|
|
496
498
|
if (system.dryRun)
|
|
497
499
|
return;
|
|
498
500
|
for (let attempt = 0; attempt < 60; attempt++) {
|
package/install.sh
CHANGED
|
@@ -28,7 +28,7 @@ step() {
|
|
|
28
28
|
if [ -n "$FANCY" ]; then printf ' \033[36m⠋\033[0m %s' "$1"; else say ".. $1"; fi
|
|
29
29
|
}
|
|
30
30
|
step_done() {
|
|
31
|
-
if [ -n "$FANCY" ]; then printf '\r\033[2K \033[32m✓\033[0m %-33s\033[2m%s\033[0m\n' "$1" "${2:-}"; else say "ok $1${2:+: $2}"; fi
|
|
31
|
+
if [ -n "$FANCY" ]; then printf '\r\033[2K \033[32m✓\033[0m %-33s\033[2m%s\033[0m\n' "$1" "${2:-}"; else say "ok ${STEP:-$1}${2:+: $2}"; fi
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
# Quiet steps: output goes to a log that's shown only if the step fails.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "directionallylocal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Directionally Local: background coding agents on your own server. One-line server install, and the dl command for your server and laptop.",
|
|
5
5
|
"homepage": "https://directionallylocal.com",
|
|
6
6
|
"repository": {
|