directionallylocal 0.2.0 → 0.3.0

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