directionallylocal 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,77 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdtemp, rm, copyFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { promisify } from "node:util";
6
+ const execFileAsync = promisify(execFile);
7
+ /** Matches the server's limit on an uploaded patch. */
8
+ export const MAX_PATCH_CHARS = 3 * 1024 * 1024;
9
+ /** owner/name of the GitHub repository behind a git remote URL, or undefined for other hosts. */
10
+ export function parseGitHubRemote(url) {
11
+ const match = url.trim().match(/^(?:https:\/\/(?:[^@/]+@)?github\.com\/|ssh:\/\/git@github\.com(?::\d+)?\/|git@github\.com:)([A-Za-z0-9-]{1,39})\/([A-Za-z0-9._-]{1,100}?)(?:\.git)?\/?$/);
12
+ return match ? `${match[1]}/${match[2]}` : undefined;
13
+ }
14
+ export async function detectGitHubRepository(cwd, remote = "origin") {
15
+ let url;
16
+ try {
17
+ url = await git(cwd, ["remote", "get-url", remote]);
18
+ }
19
+ catch {
20
+ throw new Error(`This directory has no "${remote}" git remote; pass --repo owner/name`);
21
+ }
22
+ const repository = parseGitHubRemote(url);
23
+ if (!repository)
24
+ throw new Error(`The "${remote}" remote is not a GitHub repository (${url}); pass --repo owner/name`);
25
+ return repository;
26
+ }
27
+ /**
28
+ * The local work a remote run should start from. The base is the merge-base of HEAD
29
+ * with its upstream (or origin's default branch), so it exists on GitHub; the patch
30
+ * carries everything newer, built in a throwaway index so the user's index is untouched.
31
+ */
32
+ export async function snapshotWorkingTree(cwd) {
33
+ const root = await git(cwd, ["rev-parse", "--show-toplevel"]).catch(() => {
34
+ throw new Error("Not inside a git repository");
35
+ });
36
+ await git(root, ["rev-parse", "--verify", "HEAD"]).catch(() => {
37
+ throw new Error("This repository has no commits yet; push a first commit before running remotely");
38
+ });
39
+ const baseRef = await upstreamRef(root);
40
+ const baseCommit = await git(root, ["merge-base", "HEAD", baseRef]).catch(() => {
41
+ throw new Error(`HEAD shares no history with ${baseRef}`);
42
+ });
43
+ const scratch = await mkdtemp(join(tmpdir(), "autopilot-snapshot-"));
44
+ try {
45
+ const index = join(scratch, "index");
46
+ const realIndex = resolve(root, await git(root, ["rev-parse", "--git-path", "index"]));
47
+ await copyFile(realIndex, index).catch(() => { });
48
+ const env = { ...process.env, GIT_INDEX_FILE: index };
49
+ await git(root, ["add", "--all"], env);
50
+ const patch = await git(root, ["diff", "--cached", "--binary", "--no-color", "--no-ext-diff", baseCommit], env, false);
51
+ if (patch.length > MAX_PATCH_CHARS) {
52
+ throw new Error(`Local changes are ${(patch.length / 1024 / 1024).toFixed(1)} MiB, over the 3 MiB limit; commit and push some of them first`);
53
+ }
54
+ const files = (await git(root, ["diff", "--cached", "--name-only", "--no-renames", baseCommit], env)).split("\n").filter(Boolean);
55
+ return { baseCommit, patch, baseRef, files };
56
+ }
57
+ finally {
58
+ await rm(scratch, { recursive: true, force: true });
59
+ }
60
+ }
61
+ async function upstreamRef(root) {
62
+ const upstream = await git(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]).catch(() => "");
63
+ if (upstream.startsWith("origin/"))
64
+ return upstream;
65
+ const originHead = await git(root, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]).catch(() => "");
66
+ if (originHead)
67
+ return originHead;
68
+ for (const candidate of ["origin/main", "origin/master"]) {
69
+ if (await git(root, ["rev-parse", "--verify", "--quiet", candidate]).then(() => true, () => false))
70
+ return candidate;
71
+ }
72
+ throw new Error("Can't find this branch on origin; run `git fetch origin` (or push the branch) first");
73
+ }
74
+ async function git(cwd, args, env, trim = true) {
75
+ const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], { env: env ?? process.env, maxBuffer: 64 * 1024 * 1024 });
76
+ return trim ? stdout.trim() : stdout;
77
+ }
@@ -0,0 +1,22 @@
1
+ import { spawn } from "node:child_process";
2
+ import { platform } from "node:os";
3
+ /**
4
+ * Opens a URL in the laptop's browser. Returns false when that's turned off
5
+ * (DL_NO_BROWSER=1 or BROWSER=none) or impossible, so the caller shows the link instead.
6
+ */
7
+ export function openBrowser(url) {
8
+ if (process.env.DL_NO_BROWSER === "1" || process.env.BROWSER === "none")
9
+ return false;
10
+ try {
11
+ const os = platform();
12
+ // start is a cmd.exe builtin; the empty string is its window-title argument.
13
+ const [cmd, args] = os === "darwin" ? ["open", [url]] : os === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
14
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
15
+ child.on("error", () => undefined);
16
+ child.unref();
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
package/dist/layout.js ADDED
@@ -0,0 +1,216 @@
1
+ /** Where everything lives on the server, and the files the installer writes there. */
2
+ export const PATHS = {
3
+ root: "/opt/directionallylocal",
4
+ releases: "/opt/directionallylocal/releases",
5
+ current: "/opt/directionallylocal/current",
6
+ state: "/etc/directionallylocal/state.json",
7
+ stateDir: "/etc/directionallylocal",
8
+ hotcellEnv: "/etc/hotcell/hotcell.env",
9
+ hotcellEtc: "/etc/hotcell",
10
+ hotcellHome: "/var/lib/hotcell",
11
+ autopilotEtc: "/etc/autopilot",
12
+ autopilotEnv: "/etc/autopilot/autopilot.env",
13
+ githubEnv: "/etc/autopilot/github.env",
14
+ asterConfig: "/etc/autopilot/aster.json",
15
+ autopilotHome: "/var/lib/autopilot",
16
+ workspace: "/var/lib/autopilot/workspaces/default",
17
+ units: "/etc/systemd/system",
18
+ };
19
+ /** Lets Ubuntu's unattended-upgrades install Node.js fixes from NodeSource, like its own packages. */
20
+ export const NODE_AUTO_UPDATES = "/etc/apt/apt.conf.d/52directionallylocal-nodejs";
21
+ export const AUTO_UPGRADES = "/etc/apt/apt.conf.d/20auto-upgrades";
22
+ export const SERVICES = {
23
+ hotcell: "hotcell.service",
24
+ autopilot: "autopilot.service",
25
+ metadataBlock: "directionallylocal-metadata-block.service",
26
+ };
27
+ export const USERS = { hotcell: "hotcell", autopilot: "autopilot" };
28
+ export const AUTOPILOT_PORT = 4096;
29
+ export const HOTCELL_PORT = 4750;
30
+ export const HOTCELL_EGRESS_PORT = 4752;
31
+ export const GITHUB_PROXY_PORT = 4760;
32
+ /** Per-sandbox memory cap; the "fits ~N agents" estimate divides by it. */
33
+ export const CELL_MEMORY_MB = 1024;
34
+ export function hotcellBin() {
35
+ return `${PATHS.current}/node_modules/hotcell/dist/index.js`;
36
+ }
37
+ export function autopilotBin() {
38
+ return `${PATHS.current}/node_modules/autopilot-ai/bin/autopilot.mjs`;
39
+ }
40
+ export function cellImage(autopilotVersion) {
41
+ return `directionallylocal-cell:${autopilotVersion}`;
42
+ }
43
+ /** Renders KEY=value lines; values never contain newlines, so they need no quoting for systemd. */
44
+ export function envFile(header, values) {
45
+ for (const [key, value] of Object.entries(values)) {
46
+ if (!/^[A-Z][A-Z0-9_]*$/.test(key) || /[\n\r]/.test(value))
47
+ throw new Error(`invalid env entry ${key}`);
48
+ }
49
+ return `# ${header}\n# Written by directionallylocal; edit with care, \`directionallylocal up\` keeps your values.\n${Object.entries(values).map(([key, value]) => `${key}=${value}`).join("\n")}\n`;
50
+ }
51
+ /** Parses an env file back, so re-running `up` keeps generated secrets. */
52
+ export function parseEnvFile(text) {
53
+ const values = {};
54
+ for (const line of (text ?? "").split("\n")) {
55
+ const match = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
56
+ if (match)
57
+ values[match[1]] = match[2];
58
+ }
59
+ return values;
60
+ }
61
+ export function hotcellEnv(input) {
62
+ return envFile("Hotcell daemon (sandboxes + credential gateway)", {
63
+ HOTCELL_HOME: PATHS.hotcellHome,
64
+ HOTCELL_KEYSTORE: "file",
65
+ HOTCELL_HOST: "127.0.0.1",
66
+ HOTCELL_PORT: String(HOTCELL_PORT),
67
+ HOTCELL_API_KEY: input.apiKey,
68
+ HOTCELL_DRIVER: "container",
69
+ // Sandboxes reach the gateway over the Docker bridge; loopback isn't reachable from them.
70
+ HOTCELL_EGRESS_HOST: input.bridgeIp,
71
+ HOTCELL_EGRESS_PORT: String(HOTCELL_EGRESS_PORT),
72
+ HOTCELL_EGRESS_ADVERTISE_HOST: "host.docker.internal",
73
+ });
74
+ }
75
+ export function autopilotEnv(input) {
76
+ return envFile("Autopilot server (Aster API)", {
77
+ HOME: PATHS.autopilotHome,
78
+ AUTOPILOT_ASTER_CONFIG: PATHS.asterConfig,
79
+ AUTOPILOT_ASTER_DB: `${PATHS.autopilotHome}/aster.db`,
80
+ AUTOPILOT_RUNS_DB: `${PATHS.autopilotHome}/runs.db`,
81
+ AUTOPILOT_PUBLIC_URL: input.publicUrl,
82
+ // The model key lives only in Hotcell; no server-side model calls.
83
+ AUTOPILOT_ASTER_MEMORY: "off",
84
+ AUTOPILOT_HOTCELL_ENDPOINT: `http://127.0.0.1:${HOTCELL_PORT}`,
85
+ AUTOPILOT_HOTCELL_API_KEY: input.hotcellApiKey,
86
+ AUTOPILOT_HOTCELL_DRIVER: "container",
87
+ AUTOPILOT_HOTCELL_IMAGE: input.image,
88
+ AUTOPILOT_HOTCELL_NETWORKED: "true",
89
+ AUTOPILOT_HOTCELL_ANY_MODEL: "true",
90
+ AUTOPILOT_HOTCELL_SLEEP_AFTER_MS: "900000",
91
+ AUTOPILOT_HOTCELL_MEMORY_MB: String(CELL_MEMORY_MB),
92
+ AUTOPILOT_HOTCELL_CPUS: "1",
93
+ AUTOPILOT_HOTCELL_PIDS: "512",
94
+ AUTOPILOT_HOTCELL_SPEND_CAP_USD: "10",
95
+ AUTOPILOT_HOTCELL_TOKEN_TTL_MS: String(30 * 24 * 60 * 60 * 1000),
96
+ // Code cells clone and push through Autopilot's repository-scoped proxy on the bridge.
97
+ AUTOPILOT_ASTER_GITHUB_PROXY_LISTEN: `${input.bridgeIp}:${GITHUB_PROXY_PORT}`,
98
+ AUTOPILOT_ASTER_GITHUB_PROXY_URL: `http://host.docker.internal:${GITHUB_PROXY_PORT}`,
99
+ });
100
+ }
101
+ export function githubEnv(token) {
102
+ return envFile("GitHub credential for cloning, pushing, and opening PRs (never sent to sandboxes)", { GITHUB_TOKEN: token });
103
+ }
104
+ export function asterConfig() {
105
+ return `${JSON.stringify({
106
+ models: "openrouter",
107
+ workspaces: [{ id: "default", displayName: "Default workspace", rootPath: PATHS.workspace }],
108
+ }, null, 2)}\n`;
109
+ }
110
+ export function hotcellUnit() {
111
+ return `[Unit]
112
+ Description=Hotcell sandbox daemon (Directionally Local)
113
+ After=docker.service network-online.target ${SERVICES.metadataBlock}
114
+ Requires=docker.service
115
+ Wants=network-online.target
116
+
117
+ [Service]
118
+ Type=simple
119
+ User=${USERS.hotcell}
120
+ Group=${USERS.hotcell}
121
+ SupplementaryGroups=docker
122
+ EnvironmentFile=${PATHS.hotcellEnv}
123
+ WorkingDirectory=${PATHS.hotcellHome}
124
+ ExecStart=/usr/bin/node ${hotcellBin()} start --foreground --defaults
125
+ Restart=on-failure
126
+ RestartSec=5
127
+ NoNewPrivileges=true
128
+ ProtectSystem=strict
129
+ ProtectHome=true
130
+ ReadWritePaths=${PATHS.hotcellHome}
131
+ PrivateTmp=true
132
+
133
+ [Install]
134
+ WantedBy=multi-user.target
135
+ `;
136
+ }
137
+ export function autopilotUnit() {
138
+ return `[Unit]
139
+ Description=Autopilot server (Directionally Local)
140
+ After=${SERVICES.hotcell} network-online.target
141
+ Wants=${SERVICES.hotcell} network-online.target
142
+
143
+ [Service]
144
+ Type=simple
145
+ User=${USERS.autopilot}
146
+ Group=${USERS.autopilot}
147
+ EnvironmentFile=${PATHS.autopilotEnv}
148
+ EnvironmentFile=${PATHS.githubEnv}
149
+ WorkingDirectory=${PATHS.autopilotHome}
150
+ ExecStart=/usr/bin/node ${autopilotBin()} serve --hostname 127.0.0.1 --port ${AUTOPILOT_PORT}
151
+ Restart=on-failure
152
+ RestartSec=5
153
+ NoNewPrivileges=true
154
+ ProtectSystem=strict
155
+ ProtectHome=true
156
+ ReadWritePaths=${PATHS.autopilotHome}
157
+ PrivateTmp=true
158
+
159
+ [Install]
160
+ WantedBy=multi-user.target
161
+ `;
162
+ }
163
+ export function nodeAutoUpdates() {
164
+ return `// Written by directionallylocal: install Node.js security fixes from NodeSource
165
+ // automatically, the way Ubuntu's own packages are. Delete this file to opt out.
166
+ // Matched by site: NodeSource's Release file has no meaningful Origin or Label.
167
+ Unattended-Upgrade::Origins-Pattern {
168
+ "site=deb.nodesource.com";
169
+ };
170
+ `;
171
+ }
172
+ /** Prints `dl`'s one-line status at login (Ubuntu's update-motd); silent if dl is gone. */
173
+ export const MOTD_SCRIPT = "/etc/update-motd.d/60-directionallylocal";
174
+ export function motdScript() {
175
+ return `#!/bin/sh
176
+ # Written by directionallylocal: a one-line status at login. Delete this file to opt out.
177
+ command -v dl >/dev/null 2>&1 && dl status --brief 2>/dev/null || true
178
+ `;
179
+ }
180
+ /** Ubuntu's default: refresh package lists and run unattended-upgrades daily. */
181
+ export function autoUpgrades() {
182
+ return `APT::Periodic::Update-Package-Lists "1";
183
+ APT::Periodic::Unattended-Upgrade "1";
184
+ `;
185
+ }
186
+ /** Keeps sandboxes away from the cloud metadata endpoint (instance credentials). */
187
+ export function metadataBlockUnit() {
188
+ const rule = "DOCKER-USER -d 169.254.169.254/32 -j DROP";
189
+ return `[Unit]
190
+ Description=Block cloud metadata from Docker sandboxes (Directionally Local)
191
+ After=docker.service
192
+ Requires=docker.service
193
+ Before=${SERVICES.hotcell}
194
+
195
+ [Service]
196
+ Type=oneshot
197
+ RemainAfterExit=yes
198
+ ExecStart=/bin/sh -c 'iptables -C ${rule} 2>/dev/null || iptables -I ${rule}'
199
+ ExecStop=/bin/sh -c 'iptables -D ${rule} 2>/dev/null || true'
200
+
201
+ [Install]
202
+ WantedBy=multi-user.target
203
+ `;
204
+ }
205
+ /** The sandbox image: Node, git, common build tools, and the Autopilot runtime baked in. */
206
+ export function cellDockerfile() {
207
+ return `FROM node:24-bookworm
208
+ RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates python3 python3-pip \\
209
+ && rm -rf /var/lib/apt/lists/*
210
+ COPY autopilot.tgz /tmp/autopilot.tgz
211
+ RUN mkdir -p /opt/autopilot \\
212
+ && npm install --prefix /opt/autopilot --no-audit --no-fund /tmp/autopilot.tgz \\
213
+ && rm /tmp/autopilot.tgz
214
+ WORKDIR /workspace
215
+ `;
216
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Whether a Node.js version is safe to run the stack on. CVE-2026-111497 ("Node2Shell")
3
+ * is a critical out-of-bounds write in Node's bundled Brotli decoder, reachable through
4
+ * fetch(); it's fixed in 22.23.4, 24.22.1 and 26.12.1. Odd lines get no fix. Mirrors
5
+ * Hotcell's `engines` (^22.23.4 || ^24.22.1 || >=26.12.1) and its startup advisory.
6
+ */
7
+ const FIXED = { 22: [23, 4], 24: [22, 1], 26: [12, 1] };
8
+ /** End of security support for each LTS line (nodejs/Release schedule). */
9
+ export const NODE_END_OF_LIFE = { 22: "2027-04-30", 24: "2028-04-30", 26: "2029-04-30" };
10
+ export function nodeAdvisory(version, today = new Date()) {
11
+ const match = version.trim().replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
12
+ if (!match)
13
+ return { ok: false, message: `Couldn't read the Node.js version (${version || "none"}).` };
14
+ const [major, minor, patch] = [Number(match[1]), Number(match[2]), Number(match[3])];
15
+ const fixed = FIXED[major];
16
+ const patched = major > 26 || (fixed !== undefined && (minor > fixed[0] || (minor === fixed[0] && patch >= fixed[1])));
17
+ if (!patched) {
18
+ const reason = major < 22 || major % 2 === 1 ? `Node.js ${major} is unsupported and gets no fix` : `Node.js ${version.replace(/^v/, "")} is vulnerable`;
19
+ return { ok: false, message: `${reason} for CVE-2026-111497 (remote code execution via fetch). Use 22.23.4+, 24.22.1+ or 26.12.1+.` };
20
+ }
21
+ const end = NODE_END_OF_LIFE[major];
22
+ if (end && Date.parse(end) - today.getTime() < 90 * 24 * 60 * 60 * 1000) {
23
+ return { ok: true, message: `Node.js ${major} stops getting security fixes on ${end}; move to a newer LTS line.` };
24
+ }
25
+ return { ok: true, message: undefined };
26
+ }
package/dist/prompt.js ADDED
@@ -0,0 +1,87 @@
1
+ import { openSync, closeSync } from "node:fs";
2
+ import { createInterface } from "node:readline";
3
+ import { ReadStream, WriteStream } from "node:tty";
4
+ /**
5
+ * Reads from /dev/tty, so prompts work under `curl … | sh` where stdin is the script.
6
+ * Without a terminal every question fails with the env var that answers it.
7
+ */
8
+ export class TtyPrompter {
9
+ async choose(question, options, fallback) {
10
+ const lines = options.map((option, index) => ` ${index + 1}. ${option.label}`).join("\n");
11
+ const answer = (await this.ask(`${question}\n${lines}\nChoice [1]: `, false)).trim();
12
+ if (!answer)
13
+ return options[0]?.value ?? fallback;
14
+ const index = Number(answer) - 1;
15
+ return options[index]?.value ?? options.find((option) => option.value === answer)?.value ?? fallback;
16
+ }
17
+ secret(question) {
18
+ return this.ask(question, true);
19
+ }
20
+ text(question) {
21
+ return this.ask(question, false);
22
+ }
23
+ ask(question, hidden) {
24
+ let fd;
25
+ try {
26
+ fd = openSync("/dev/tty", "r+");
27
+ }
28
+ catch {
29
+ throw new Error("No terminal to ask on; pass the answers as environment variables (see `dl --help`).");
30
+ }
31
+ const input = new ReadStream(fd);
32
+ const output = new WriteStream(fd);
33
+ // A styled prompt is "label line\n› ": print the label line ourselves and give readline
34
+ // only the last line, so its redraws (and the hidden-input filter) stay on one line.
35
+ const split = question.lastIndexOf("\n");
36
+ const head = split >= 0 ? question.slice(0, split + 1) : "";
37
+ const line = split >= 0 ? question.slice(split + 1) : question;
38
+ if (head)
39
+ output.write(head);
40
+ return new Promise((resolve) => {
41
+ const rl = createInterface({ input, output, terminal: true });
42
+ if (hidden) {
43
+ // Echo nothing while the secret is typed.
44
+ rl._writeToOutput = (text) => {
45
+ if (text.includes(line))
46
+ output.write(text);
47
+ };
48
+ }
49
+ rl.question(line, (answer) => {
50
+ if (hidden)
51
+ output.write("\n");
52
+ rl.close();
53
+ input.destroy();
54
+ output.destroy();
55
+ try {
56
+ closeSync(fd);
57
+ }
58
+ catch { /* already closed with the streams */ }
59
+ resolve(answer.trim());
60
+ });
61
+ });
62
+ }
63
+ }
64
+ /** Answers from a map (tests); a missing answer is a test bug, so it throws. */
65
+ export class ScriptedPrompter {
66
+ answers;
67
+ asked = [];
68
+ constructor(answers) {
69
+ this.answers = answers;
70
+ }
71
+ async choose(question) {
72
+ return this.answer(question);
73
+ }
74
+ async secret(question) {
75
+ return this.answer(question);
76
+ }
77
+ async text(question) {
78
+ return this.answer(question);
79
+ }
80
+ answer(question) {
81
+ this.asked.push(question);
82
+ const key = Object.keys(this.answers).find((fragment) => question.includes(fragment));
83
+ if (key === undefined)
84
+ throw new Error(`unexpected question: ${question}`);
85
+ return this.answers[key];
86
+ }
87
+ }
@@ -0,0 +1,54 @@
1
+ import { PATHS, USERS, autopilotBin } from "./layout.js";
2
+ /**
3
+ * Autopilot's server-side commands, run as the autopilot account against the same
4
+ * config and database as the service. `dl` reads laptops and runs through these, never
5
+ * through Autopilot's database directly.
6
+ */
7
+ export function autopilotServer(system, args, options = {}) {
8
+ return system.run("runuser", [
9
+ "-u", USERS.autopilot, "--", "env", `HOME=${PATHS.autopilotHome}`, `AUTOPILOT_ASTER_CONFIG=${PATHS.asterConfig}`,
10
+ `AUTOPILOT_ASTER_DB=${PATHS.autopilotHome}/aster.db`, "/usr/bin/node", autopilotBin(), ...args,
11
+ ], { allowFailure: true, describe: options.describe ?? `autopilot ${args.join(" ")} (as autopilot)`, ...(options.secretOutput ? { secretOutput: true } : {}) });
12
+ }
13
+ /** Paired laptops and apps: active credentials that aren't owner browsers. Undefined if Autopilot can't say. */
14
+ export async function listLaptops(system) {
15
+ const result = await autopilotServer(system, ["aster", "token", "list", "--json"]);
16
+ const parsed = parseJson(result);
17
+ return parsed?.filter((entry) => entry.status === "active" && !entry.scopes.includes("pairing:approve"));
18
+ }
19
+ export async function listServerRuns(system, limit = 200) {
20
+ return parseJson(await autopilotServer(system, ["aster", "runs", "--json", "--limit", String(limit)]));
21
+ }
22
+ /** Pairing codes waiting for approval, e.g. "BCDF-GHJK MacBook (autopilot)". */
23
+ export async function pendingPairings(system) {
24
+ const result = await autopilotServer(system, ["aster", "pair", "list"]);
25
+ if (result.code !== 0)
26
+ return [];
27
+ return result.stdout.split("\n").flatMap((line) => {
28
+ const match = line.match(/^([A-Z]{4}-[A-Z]{4})\s+(.+?)\s+expires\s/);
29
+ return match ? [{ code: match[1], name: match[2] }] : [];
30
+ });
31
+ }
32
+ export async function approvePairing(system, code) {
33
+ const result = await autopilotServer(system, ["aster", "pair", "approve", code]);
34
+ return { ok: result.code === 0, message: (result.code === 0 ? result.stdout : result.stderr || result.stdout).trim() };
35
+ }
36
+ export async function revokeLaptop(system, id) {
37
+ return (await autopilotServer(system, ["aster", "token", "revoke", id])).code === 0;
38
+ }
39
+ /** A fresh one-time link that makes the browser opening it able to approve laptops. */
40
+ export async function ownerLink(system, url) {
41
+ const result = await autopilotServer(system, ["aster", "owner", "link", "--url", url], { secretOutput: true, describe: "autopilot aster owner link (as autopilot)" });
42
+ const link = result.stdout.trim().split("\n").at(-1);
43
+ return result.code === 0 && link?.startsWith("http") ? link : undefined;
44
+ }
45
+ function parseJson(result) {
46
+ if (result.code !== 0)
47
+ return undefined;
48
+ try {
49
+ return JSON.parse(result.stdout.trim().split("\n").at(-1) ?? "");
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ }
package/dist/state.js ADDED
@@ -0,0 +1,19 @@
1
+ import { PATHS } from "./layout.js";
2
+ export async function readState(system) {
3
+ const raw = await system.readFile(PATHS.state);
4
+ if (!raw)
5
+ return undefined;
6
+ try {
7
+ const parsed = JSON.parse(raw);
8
+ return parsed.version === 1 ? parsed : undefined;
9
+ }
10
+ catch {
11
+ return undefined;
12
+ }
13
+ }
14
+ export async function writeState(system, state) {
15
+ // Readable by everyone: versions and the address aren't secret, and any login can see the
16
+ // status. Secrets in this directory (a Tailscale key, briefly) are written 0600.
17
+ await system.mkdir(PATHS.stateDir, { mode: 0o755 });
18
+ await system.writeFile(PATHS.state, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o644 });
19
+ }