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.
- package/README.md +72 -2
- package/bin/directionallylocal.mjs +4 -0
- package/dist/cli.js +272 -0
- package/dist/detect.js +94 -0
- package/dist/doctor.js +72 -0
- package/dist/home.js +126 -0
- package/dist/laptop/client.js +223 -0
- package/dist/laptop/commands.js +335 -0
- package/dist/laptop/config.js +89 -0
- package/dist/laptop/git.js +77 -0
- package/dist/laptop/open-browser.js +22 -0
- package/dist/layout.js +216 -0
- package/dist/node-advisory.js +26 -0
- package/dist/prompt.js +87 -0
- package/dist/server-api.js +54 -0
- package/dist/state.js +19 -0
- package/dist/system.js +177 -0
- package/dist/ui.js +223 -0
- package/dist/uninstall.js +48 -0
- package/dist/up.js +426 -0
- package/install.sh +150 -0
- package/package.json +36 -4
package/README.md
CHANGED
|
@@ -1,3 +1,73 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Directionally Local installer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Rent a server, paste one line, and send coding tasks to it from your laptop. Each task runs in its own [Hotcell](https://github.com/directionally-local/hotcell) sandbox, driven by [Autopilot](https://github.com/directionally-local/autopilot), and comes back as a pull request, even with your laptop closed.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
curl -fsSL https://directionallylocal.run | sh
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
It asks three things:
|
|
10
|
+
|
|
11
|
+
1. **Tailscale auth key.** The server joins your tailnet and serves Autopilot on its private `https://<name>.<tailnet>.ts.net` address, with no public ports. Turn on MagicDNS and HTTPS certificates for the tailnet first. Own-domain and instant-address options are coming.
|
|
12
|
+
2. **OpenRouter API key.** This is stored in Hotcell's keystore on the server. Sandboxes get spend-capped gateway tokens instead, and your laptop never sees the key.
|
|
13
|
+
3. **GitHub token,** so runs can clone, push, and open PRs. A fine-grained token with Contents and Pull requests read/write on the repositories you'll use is enough. It stays on the server; sandboxes push through Autopilot's repository-scoped proxy.
|
|
14
|
+
|
|
15
|
+
It ends with:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
✓ Ready. Fits ~6 agents at once (estimate).
|
|
19
|
+
Open once to approve laptops in your browser: https://box.tail1234.ts.net/api/v1/auth/claim?token=…
|
|
20
|
+
On your laptop (signed in to the same tailnet): autopilot remote add https://box.tail1234.ts.net
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then, on your laptop:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npm install -g directionallylocal
|
|
27
|
+
dl connect https://box.tail1234.ts.net # approve the code in the browser you opened the link in
|
|
28
|
+
dl run "fix the flaky checkout test" # from inside a GitHub repository; local changes go along
|
|
29
|
+
dl runs # tasks and their pull requests
|
|
30
|
+
dl pull <id> # check one out here
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Typing `dl` on its own explains what it is and what's going on, on the server and on the laptop.
|
|
34
|
+
|
|
35
|
+
The "fits ~N agents" figure is a conservative estimate from memory and CPUs (1 GB per sandbox). Measured numbers will replace it.
|
|
36
|
+
|
|
37
|
+
## Commands
|
|
38
|
+
|
|
39
|
+
Run these as root on the server. `dl` is short for `directionallylocal`; both work.
|
|
40
|
+
|
|
41
|
+
| Command | What it does |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `dl up` | Install or repair everything. Safe to re-run: it keeps your keys and generated secrets and skips work that's already done. |
|
|
44
|
+
| `dl up --dry-run` | Print what `up` would change, without changing anything. |
|
|
45
|
+
| `dl doctor` | Check every part, with a fix for each failure. |
|
|
46
|
+
| `dl status` | Show the installed versions and where to reach the server. |
|
|
47
|
+
| `dl uninstall` | Remove the services and software but keep configuration and data. Add `--purge` to delete those too, plus the service accounts and the sandbox image. |
|
|
48
|
+
|
|
49
|
+
For unattended installs, set the answers as environment variables: `DL_TAILSCALE_AUTH_KEY`, `DL_OPENROUTER_API_KEY` and `DL_GITHUB_TOKEN`. `DL_ACCESS=loopback` skips Tailscale; you then reach the server over an SSH tunnel.
|
|
50
|
+
|
|
51
|
+
## What it sets up
|
|
52
|
+
|
|
53
|
+
- **Node.js 24 LTS,** if Node is missing or lacks the fix for CVE-2026-111497 (22.23.4+, 24.22.1+ and 26.12.1+ are accepted). Node security releases from NodeSource are added to Ubuntu's automatic updates, which otherwise skip them. Delete `/etc/apt/apt.conf.d/52directionallylocal-nodejs` to opt out.
|
|
54
|
+
- **Docker,** if it's missing, plus a rule that keeps sandboxes away from the cloud metadata endpoint. Docker isn't updated automatically, because its repo ships major upgrades and restarting the Docker daemon can stop running sandboxes.
|
|
55
|
+
- **Hotcell and Autopilot from npm,** under `/opt/directionallylocal/releases/<versions>`, with `current` pointing at the active release.
|
|
56
|
+
- **Two systemd services, each under its own unprivileged account:** `hotcell.service` (sandboxes and the credential gateway, on loopback) and `autopilot.service` (the API that laptops pair with, on `127.0.0.1:4096`). Only Tailscale Serve exposes it.
|
|
57
|
+
- **A sandbox image** with Node, git, Python, and the Autopilot runtime baked in.
|
|
58
|
+
- **Configuration** in `/etc/hotcell`, `/etc/autopilot` and `/etc/directionallylocal`, readable only by root and the matching service account. Data lives in `/var/lib/hotcell` and `/var/lib/autopilot`.
|
|
59
|
+
|
|
60
|
+
Supported: Ubuntu 24.04 and 22.04 and Debian 12, on x86_64 or arm64, with at least 4 GB of RAM and 15 GB free under `/var`.
|
|
61
|
+
|
|
62
|
+
## The install script's address
|
|
63
|
+
|
|
64
|
+
`site/` is the Cloudflare Worker for `https://directionallylocal.run`. It serves `install.sh` to `curl` at the bare domain (and `/install.sh`), sends browsers to directionallylocal.com, and serves nothing else. Subdomains of `directionallylocal.run` are users' instant addresses, and the domain is on the Public Suffix List, so the Worker never sets cookies. Deploy it with `npx wrangler deploy -c site/wrangler.toml`.
|
|
65
|
+
|
|
66
|
+
## Development
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
npm install
|
|
70
|
+
npm test # node:test through tsx; a simulated server exercises up, doctor, and uninstall
|
|
71
|
+
npm run typecheck
|
|
72
|
+
npm run build
|
|
73
|
+
```
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { doctor, formatChecks } from "./doctor.js";
|
|
5
|
+
import { briefStatus, connectLaptop, laptopsCommand, logsCommand, serverHome, serverRunsCommand } from "./home.js";
|
|
6
|
+
import { cancel, connect, describeError, laptopHome, pull, run, runs, servers, watch } from "./laptop/commands.js";
|
|
7
|
+
import { TtyPrompter } from "./prompt.js";
|
|
8
|
+
import { readState } from "./state.js";
|
|
9
|
+
import { DryRunSystem, RealSystem } from "./system.js";
|
|
10
|
+
import { uninstall } from "./uninstall.js";
|
|
11
|
+
import { PlainUi, TerminalUi, palette } from "./ui.js";
|
|
12
|
+
import { up } from "./up.js";
|
|
13
|
+
const HELP = `Directionally Local: background coding agents on your own server.
|
|
14
|
+
Send a task from your laptop; it runs in its own sandbox and comes back as a pull request.
|
|
15
|
+
(dl is short for directionallylocal; both work.)
|
|
16
|
+
|
|
17
|
+
On your laptop:
|
|
18
|
+
dl connect URL connect to your server (approve the code it shows)
|
|
19
|
+
dl run "TASK" run a task on the server, from inside a GitHub repository
|
|
20
|
+
(your uncommitted changes go along; Ctrl-C stops watching, not the task)
|
|
21
|
+
dl runs your tasks and their pull requests
|
|
22
|
+
dl watch ID follow a task dl cancel ID stop one
|
|
23
|
+
dl pull ID check out a task's branch here
|
|
24
|
+
dl servers connected servers (dl servers remove NAME)
|
|
25
|
+
run options: --server NAME, -m MODEL, --repo OWNER/NAME, --detach
|
|
26
|
+
|
|
27
|
+
On the server:
|
|
28
|
+
dl what this is, whether it's healthy, laptops, and runs
|
|
29
|
+
dl up [--dry-run] install, or repair, everything (safe to re-run)
|
|
30
|
+
dl connect-laptop how to connect a laptop, and a link to approve it in a browser
|
|
31
|
+
dl connect-laptop CODE approve the code a laptop shows
|
|
32
|
+
dl laptops connected laptops (dl laptops remove NAME to disconnect one)
|
|
33
|
+
dl runs tasks running or done, with their pull requests
|
|
34
|
+
dl logs [hotcell|autopilot] [-f] the services' logs
|
|
35
|
+
dl doctor check every part, with a fix for each problem
|
|
36
|
+
dl uninstall [--purge] remove everything (--purge also removes config and data)
|
|
37
|
+
|
|
38
|
+
up options:
|
|
39
|
+
--autopilot-version <x.y.z> default: latest on npm
|
|
40
|
+
--hotcell-version <x.y.z> default: latest on npm
|
|
41
|
+
|
|
42
|
+
Unattended installs answer the questions with environment variables:
|
|
43
|
+
DL_TAILSCALE_AUTH_KEY Tailscale auth key (tskey-…), unless this machine is already on a tailnet
|
|
44
|
+
DL_OPENROUTER_API_KEY model key, stored in Hotcell only
|
|
45
|
+
DL_GITHUB_TOKEN GitHub token for cloning, pushing, and opening PRs
|
|
46
|
+
|
|
47
|
+
Advanced:
|
|
48
|
+
DL_ACCESS=loopback don't expose the server; reach it over an SSH tunnel
|
|
49
|
+
DL_SKIP_CREDENTIAL_CHECK=1 don't check the keys with OpenRouter and GitHub (offline installs)
|
|
50
|
+
`;
|
|
51
|
+
export async function main(argv, io = {
|
|
52
|
+
out: (text) => process.stdout.write(text),
|
|
53
|
+
err: (text) => process.stderr.write(text),
|
|
54
|
+
}, options = {}) {
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = parseArgs({
|
|
58
|
+
args: argv,
|
|
59
|
+
allowPositionals: true,
|
|
60
|
+
options: {
|
|
61
|
+
"dry-run": { type: "boolean" },
|
|
62
|
+
purge: { type: "boolean" },
|
|
63
|
+
"autopilot-version": { type: "string" },
|
|
64
|
+
"hotcell-version": { type: "string" },
|
|
65
|
+
help: { type: "boolean", short: "h" },
|
|
66
|
+
version: { type: "boolean", short: "v" },
|
|
67
|
+
brief: { type: "boolean" },
|
|
68
|
+
follow: { type: "boolean", short: "f" },
|
|
69
|
+
name: { type: "string" },
|
|
70
|
+
"no-open": { type: "boolean" },
|
|
71
|
+
server: { type: "string" },
|
|
72
|
+
model: { type: "string", short: "m" },
|
|
73
|
+
repo: { type: "string" },
|
|
74
|
+
detach: { type: "boolean" },
|
|
75
|
+
json: { type: "boolean" },
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
io.err(`${error.message}\n\n${HELP}`);
|
|
81
|
+
return 2;
|
|
82
|
+
}
|
|
83
|
+
const [command] = parsed.positionals;
|
|
84
|
+
const { values } = parsed;
|
|
85
|
+
if (values.version) {
|
|
86
|
+
io.out(`${packageVersion()}\n`);
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
if (values.help || command === "help") {
|
|
90
|
+
io.out(HELP);
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
const log = (line) => io.out(`${line}\n`);
|
|
94
|
+
// The live checklist needs a terminal; a dry run prints its plan line by line instead.
|
|
95
|
+
const terminal = options.terminal ?? (process.stdout.isTTY === true);
|
|
96
|
+
const live = terminal && command === "up" && !values["dry-run"];
|
|
97
|
+
const ui = live ? new TerminalUi(io.out) : new PlainUi(io.out);
|
|
98
|
+
const colorOn = terminal && !process.env.NO_COLOR && process.env.TERM !== "dumb";
|
|
99
|
+
const colors = palette(colorOn);
|
|
100
|
+
const laptopIO = { out: io.out, err: io.err, color: colorOn };
|
|
101
|
+
const system = values["dry-run"] ? new DryRunSystem(log) : new RealSystem(log, live ? { outputLog: UP_LOG } : {});
|
|
102
|
+
try {
|
|
103
|
+
switch (command) {
|
|
104
|
+
case "up":
|
|
105
|
+
if (system.dryRun)
|
|
106
|
+
log("Dry run: nothing on this machine will change.\n");
|
|
107
|
+
await up(system, {
|
|
108
|
+
prompter: new TtyPrompter(),
|
|
109
|
+
ui,
|
|
110
|
+
...(values["autopilot-version"] ? { autopilotVersion: values["autopilot-version"] } : {}),
|
|
111
|
+
...(values["hotcell-version"] ? { hotcellVersion: values["hotcell-version"] } : {}),
|
|
112
|
+
});
|
|
113
|
+
return 0;
|
|
114
|
+
case "doctor": {
|
|
115
|
+
const checks = await doctor(system);
|
|
116
|
+
log(formatChecks(checks, terminal && !process.env.NO_COLOR));
|
|
117
|
+
return checks.every((check) => check.ok) ? 0 : 1;
|
|
118
|
+
}
|
|
119
|
+
case undefined:
|
|
120
|
+
case "status": {
|
|
121
|
+
const state = await readState(system);
|
|
122
|
+
if (!state) {
|
|
123
|
+
if (values.brief)
|
|
124
|
+
return 0;
|
|
125
|
+
// A laptop connected to servers gets its own home; otherwise, what this is and how to start.
|
|
126
|
+
if (!command && await laptopHome(laptopIO))
|
|
127
|
+
return 0;
|
|
128
|
+
io.out(NOT_A_SERVER(colors));
|
|
129
|
+
return command ? 1 : 0;
|
|
130
|
+
}
|
|
131
|
+
if (values.brief) {
|
|
132
|
+
await briefStatus(system, state, io.out, colors);
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
if (!isRoot()) {
|
|
136
|
+
await briefStatus(system, state, io.out, colors);
|
|
137
|
+
io.out(` ${colors.dim("For laptops and runs:")} sudo dl\n\n`);
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
await serverHome(system, state, io.out, colors);
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
case "connect": {
|
|
144
|
+
const url = parsed.positionals[1];
|
|
145
|
+
if (!url) {
|
|
146
|
+
io.err(` Usage: dl connect https://your-server ${colors.dim("(the address dl up printed on the server)")}\n`);
|
|
147
|
+
return 2;
|
|
148
|
+
}
|
|
149
|
+
await interruptible((signal) => connect({ url, signal, open: !values["no-open"], ...(values.name ? { name: values.name } : {}) }, laptopIO));
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
case "run": {
|
|
153
|
+
const task = parsed.positionals.slice(1).join(" ").trim();
|
|
154
|
+
if (!task) {
|
|
155
|
+
io.err(` Usage: dl run "fix the flaky checkout test" ${colors.dim("(from inside a GitHub repository)")}\n`);
|
|
156
|
+
return 2;
|
|
157
|
+
}
|
|
158
|
+
return await interruptible((signal) => run({
|
|
159
|
+
task,
|
|
160
|
+
cwd: process.cwd(),
|
|
161
|
+
signal,
|
|
162
|
+
detach: Boolean(values.detach),
|
|
163
|
+
...(values.server ? { server: values.server } : {}),
|
|
164
|
+
...(values.model ? { model: values.model } : {}),
|
|
165
|
+
...(values.repo ? { repository: values.repo } : {}),
|
|
166
|
+
}, laptopIO));
|
|
167
|
+
}
|
|
168
|
+
case "watch":
|
|
169
|
+
case "cancel":
|
|
170
|
+
case "pull": {
|
|
171
|
+
const id = parsed.positionals[1];
|
|
172
|
+
if (!id) {
|
|
173
|
+
io.err(` Usage: dl ${command} ID ${colors.dim("(from dl runs)")}\n`);
|
|
174
|
+
return 2;
|
|
175
|
+
}
|
|
176
|
+
const server = values.server ? { server: values.server } : {};
|
|
177
|
+
if (command === "watch")
|
|
178
|
+
return await interruptible((signal) => watch({ id, signal, ...server }, laptopIO));
|
|
179
|
+
if (command === "cancel")
|
|
180
|
+
await cancel({ id, ...server }, laptopIO);
|
|
181
|
+
else
|
|
182
|
+
await pull({ id, cwd: process.cwd(), ...server }, laptopIO);
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
case "servers":
|
|
186
|
+
return await servers(parsed.positionals.slice(1), laptopIO);
|
|
187
|
+
case "runs":
|
|
188
|
+
// On a server, its runs; on a laptop, the tasks it sent.
|
|
189
|
+
if (!(await readState(system))) {
|
|
190
|
+
await runs({ json: Boolean(values.json), ...(values.server ? { server: values.server } : {}) }, laptopIO);
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
// falls through
|
|
194
|
+
case "connect-laptop":
|
|
195
|
+
case "laptops":
|
|
196
|
+
case "logs": {
|
|
197
|
+
const state = await readState(system);
|
|
198
|
+
if (!state) {
|
|
199
|
+
io.out(NOT_A_SERVER(colors));
|
|
200
|
+
return 1;
|
|
201
|
+
}
|
|
202
|
+
if (!isRoot()) {
|
|
203
|
+
io.err(` Run it as root: sudo dl ${parsed.positionals.join(" ")}\n`);
|
|
204
|
+
return 1;
|
|
205
|
+
}
|
|
206
|
+
const rest = parsed.positionals.slice(1);
|
|
207
|
+
if (command === "connect-laptop")
|
|
208
|
+
return await connectLaptop(system, state, rest[0], io.out, colors);
|
|
209
|
+
if (command === "laptops")
|
|
210
|
+
return await laptopsCommand(system, rest, io.out, colors);
|
|
211
|
+
if (command === "runs")
|
|
212
|
+
return await serverRunsCommand(system, io.out, colors);
|
|
213
|
+
return await logsCommand(system, [...rest, ...(values.follow ? ["-f"] : [])]);
|
|
214
|
+
}
|
|
215
|
+
case "uninstall":
|
|
216
|
+
await uninstall(system, { purge: Boolean(values.purge) });
|
|
217
|
+
return 0;
|
|
218
|
+
default:
|
|
219
|
+
io.err(`Unknown command "${command}".\n\n${HELP}`);
|
|
220
|
+
return 2;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
ui.finish();
|
|
225
|
+
const c = palette(live && !process.env.NO_COLOR);
|
|
226
|
+
io.err(`\n ${c.red("✗")} ${describeError(error).replace(/\n/g, "\n ")}\n`);
|
|
227
|
+
if (live)
|
|
228
|
+
io.err(await logTail(UP_LOG, c));
|
|
229
|
+
return 1;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const NOT_A_SERVER = (c) => `
|
|
233
|
+
${c.bold("Directionally Local")} ${c.dim("isn't set up on this machine.")}
|
|
234
|
+
|
|
235
|
+
Background coding agents on your own server: send a task from your laptop,
|
|
236
|
+
get a pull request back.
|
|
237
|
+
|
|
238
|
+
${c.dim("Set up a server (as root, on Ubuntu or Debian):")}
|
|
239
|
+
curl -fsSL https://directionallylocal.run | sh
|
|
240
|
+
|
|
241
|
+
${c.dim("Already have one? Connect this laptop to it:")}
|
|
242
|
+
dl connect https://your-server
|
|
243
|
+
|
|
244
|
+
`;
|
|
245
|
+
/** Ctrl-C aborts the command (a running task keeps going on the server). */
|
|
246
|
+
async function interruptible(work) {
|
|
247
|
+
const controller = new AbortController();
|
|
248
|
+
const onInterrupt = () => controller.abort();
|
|
249
|
+
process.once("SIGINT", onInterrupt);
|
|
250
|
+
try {
|
|
251
|
+
return await work(controller.signal);
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
process.off("SIGINT", onInterrupt);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function isRoot() {
|
|
258
|
+
return process.getuid?.() === 0;
|
|
259
|
+
}
|
|
260
|
+
/** From package.json, which sits one level above both src/ and dist/. */
|
|
261
|
+
function packageVersion() {
|
|
262
|
+
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
263
|
+
}
|
|
264
|
+
/** Where `dl up` keeps other tools' output; shown only when something fails. */
|
|
265
|
+
export const UP_LOG = "/var/log/directionallylocal/up.log";
|
|
266
|
+
async function logTail(path, c) {
|
|
267
|
+
const text = await readFile(path, "utf8").catch(() => "");
|
|
268
|
+
const lines = text.trimEnd().split("\n").filter((line) => line.trim() && !line.startsWith("#")).slice(-15);
|
|
269
|
+
if (lines.length === 0)
|
|
270
|
+
return "";
|
|
271
|
+
return `\n ${c.dim(`Last lines of ${path}:`)}\n${lines.map((line) => ` ${c.dim(line.slice(0, 200))}`).join("\n")}\n\n Fix it and run ${c.bold("dl up")} again; it picks up where it stopped.\n`;
|
|
272
|
+
}
|
package/dist/detect.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { nodeAdvisory } from "./node-advisory.js";
|
|
2
|
+
/** v0 supports the Ubuntu/Debian releases the issue targets; others get a clear refusal. */
|
|
3
|
+
const SUPPORTED_OS = {
|
|
4
|
+
ubuntu: ["22.04", "24.04"],
|
|
5
|
+
debian: ["12"],
|
|
6
|
+
};
|
|
7
|
+
export const MIN_MEMORY_MB = 3_500;
|
|
8
|
+
export const MIN_DISK_GB = 15;
|
|
9
|
+
export async function detectHost(system) {
|
|
10
|
+
const [osRelease, meminfo, nproc, df, arch, kvm, systemd, uid, docker, bridge, node, tailscale, dockerd] = await Promise.all([
|
|
11
|
+
system.readFile("/etc/os-release"),
|
|
12
|
+
system.readFile("/proc/meminfo"),
|
|
13
|
+
system.probe("nproc", []),
|
|
14
|
+
system.probe("df", ["-Pk", "/var"]),
|
|
15
|
+
system.probe("uname", ["-m"]),
|
|
16
|
+
system.exists("/dev/kvm"),
|
|
17
|
+
system.exists("/run/systemd/system"),
|
|
18
|
+
system.probe("id", ["-u"]),
|
|
19
|
+
system.probe("docker", ["version", "--format", "{{.Server.Version}}"]),
|
|
20
|
+
system.probe("ip", ["-4", "-o", "addr", "show", "docker0"]),
|
|
21
|
+
system.probe("node", ["-p", "process.versions.node"]),
|
|
22
|
+
system.probe("tailscale", ["version"]),
|
|
23
|
+
system.exists("/usr/bin/dockerd"),
|
|
24
|
+
]);
|
|
25
|
+
return {
|
|
26
|
+
os: parseOsRelease(osRelease ?? ""),
|
|
27
|
+
arch: arch.stdout.trim(),
|
|
28
|
+
memoryMb: parseMemTotalMb(meminfo ?? ""),
|
|
29
|
+
cpus: Number(nproc.stdout.trim()) || 0,
|
|
30
|
+
diskFreeGb: parseDfFreeGb(df.stdout),
|
|
31
|
+
kvm,
|
|
32
|
+
systemd,
|
|
33
|
+
root: uid.stdout.trim() === "0",
|
|
34
|
+
docker: docker.code === 0 && docker.stdout.trim() !== "",
|
|
35
|
+
dockerInstalled: dockerd,
|
|
36
|
+
dockerBridgeIp: parseInterfaceIp(bridge.stdout),
|
|
37
|
+
nodeVersion: node.code === 0 && node.stdout.trim() ? node.stdout.trim() : undefined,
|
|
38
|
+
tailscale: tailscale.code === 0,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** Problems that stop `up` before it changes anything. */
|
|
42
|
+
export function preflightProblems(facts) {
|
|
43
|
+
const problems = [];
|
|
44
|
+
if (!facts.root)
|
|
45
|
+
problems.push("Run as root (sudo dl up).");
|
|
46
|
+
if (!facts.systemd)
|
|
47
|
+
problems.push("systemd is required to run the services.");
|
|
48
|
+
const versions = SUPPORTED_OS[facts.os.id];
|
|
49
|
+
if (!versions?.includes(facts.os.versionId)) {
|
|
50
|
+
problems.push(`${facts.os.prettyName || "This OS"} isn't supported yet; use Ubuntu 24.04 or 22.04, or Debian 12.`);
|
|
51
|
+
}
|
|
52
|
+
if (facts.arch !== "x86_64" && facts.arch !== "aarch64")
|
|
53
|
+
problems.push(`CPU architecture ${facts.arch} isn't supported.`);
|
|
54
|
+
if (facts.memoryMb < MIN_MEMORY_MB)
|
|
55
|
+
problems.push(`At least 4 GB of RAM is needed (found ${(facts.memoryMb / 1024).toFixed(1)} GB).`);
|
|
56
|
+
// The services run on this Node; never install onto one with a known RCE.
|
|
57
|
+
const node = nodeAdvisory(facts.nodeVersion ?? "");
|
|
58
|
+
if (!node.ok)
|
|
59
|
+
problems.push(`${node.message} Re-run install.sh, which upgrades Node.`);
|
|
60
|
+
if (facts.diskFreeGb < MIN_DISK_GB)
|
|
61
|
+
problems.push(`At least ${MIN_DISK_GB} GB free under /var is needed (found ${facts.diskFreeGb} GB).`);
|
|
62
|
+
return problems;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* How many agents fit at once: memory left after the host's own share, divided by each
|
|
66
|
+
* sandbox's cap. A conservative estimate until the density benchmark (hotcell#17) lands.
|
|
67
|
+
*/
|
|
68
|
+
export function agentCapacity(facts, cellMemoryMb) {
|
|
69
|
+
const reservedMb = Math.max(1_536, Math.round(facts.memoryMb * 0.15));
|
|
70
|
+
const byMemory = Math.floor((facts.memoryMb - reservedMb) / cellMemoryMb);
|
|
71
|
+
const byCpu = Math.max(1, facts.cpus * 2);
|
|
72
|
+
return Math.max(1, Math.min(byMemory, byCpu));
|
|
73
|
+
}
|
|
74
|
+
export function parseOsRelease(text) {
|
|
75
|
+
const values = new Map();
|
|
76
|
+
for (const line of text.split("\n")) {
|
|
77
|
+
const match = line.match(/^([A-Z_]+)=(.*)$/);
|
|
78
|
+
if (match)
|
|
79
|
+
values.set(match[1], match[2].replace(/^"(.*)"$/, "$1"));
|
|
80
|
+
}
|
|
81
|
+
return { id: values.get("ID") ?? "", versionId: values.get("VERSION_ID") ?? "", prettyName: values.get("PRETTY_NAME") ?? "" };
|
|
82
|
+
}
|
|
83
|
+
export function parseMemTotalMb(text) {
|
|
84
|
+
const kb = Number(text.match(/^MemTotal:\s+(\d+)\s+kB/m)?.[1] ?? 0);
|
|
85
|
+
return Math.round(kb / 1024);
|
|
86
|
+
}
|
|
87
|
+
export function parseDfFreeGb(text) {
|
|
88
|
+
const fields = text.trim().split("\n")[1]?.trim().split(/\s+/);
|
|
89
|
+
const availableKb = Number(fields?.[3] ?? 0);
|
|
90
|
+
return Math.floor(availableKb / 1024 / 1024);
|
|
91
|
+
}
|
|
92
|
+
export function parseInterfaceIp(text) {
|
|
93
|
+
return text.match(/\binet (\d+\.\d+\.\d+\.\d+)\//)?.[1];
|
|
94
|
+
}
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { detectHost, MIN_DISK_GB } from "./detect.js";
|
|
2
|
+
import { nodeAdvisory } from "./node-advisory.js";
|
|
3
|
+
import { AUTOPILOT_PORT, AUTO_UPGRADES, HOTCELL_PORT, NODE_AUTO_UPDATES, PATHS, SERVICES, parseEnvFile } from "./layout.js";
|
|
4
|
+
import { readState } from "./state.js";
|
|
5
|
+
import { palette } from "./ui.js";
|
|
6
|
+
/** `directionallylocal doctor`: every moving part, with a fix for each failure. */
|
|
7
|
+
export async function doctor(system) {
|
|
8
|
+
const checks = [];
|
|
9
|
+
const add = (check) => checks.push(check);
|
|
10
|
+
const state = await readState(system);
|
|
11
|
+
add(state
|
|
12
|
+
? { name: "installed", ok: true, detail: `${state.release}, ${state.access.mode} at ${state.access.url}` }
|
|
13
|
+
: { name: "installed", ok: false, detail: "not installed", fix: "sudo dl up" });
|
|
14
|
+
const facts = await detectHost(system);
|
|
15
|
+
const node = nodeAdvisory(facts.nodeVersion ?? "");
|
|
16
|
+
add({
|
|
17
|
+
name: "node",
|
|
18
|
+
ok: node.ok && node.message === undefined,
|
|
19
|
+
detail: node.message ?? `${facts.nodeVersion} (patched)`,
|
|
20
|
+
fix: "re-run install.sh to upgrade Node.js (apt-get install --only-upgrade nodejs for a patch)",
|
|
21
|
+
});
|
|
22
|
+
const periodic = /APT::Periodic::Unattended-Upgrade\s+"1"/.test((await system.readFile(AUTO_UPGRADES)) ?? "");
|
|
23
|
+
const nodeUpdates = await system.exists(NODE_AUTO_UPDATES);
|
|
24
|
+
add({
|
|
25
|
+
name: "security updates",
|
|
26
|
+
ok: periodic && nodeUpdates,
|
|
27
|
+
detail: periodic && nodeUpdates ? "daily, including Node.js" : !periodic ? "unattended-upgrades is off" : "Node.js isn't included",
|
|
28
|
+
fix: "sudo dl up",
|
|
29
|
+
});
|
|
30
|
+
add({ name: "disk", ok: facts.diskFreeGb >= MIN_DISK_GB, detail: `${facts.diskFreeGb} GB free under /var`, fix: "free space: docker system prune, or grow the disk" });
|
|
31
|
+
add(facts.docker
|
|
32
|
+
? { name: "docker", ok: true, detail: "running" }
|
|
33
|
+
: { name: "docker", ok: false, detail: "not running", fix: "dl up" });
|
|
34
|
+
// People read these, so they're named for what they do, not their systemd units.
|
|
35
|
+
for (const [service, name, logs] of [
|
|
36
|
+
[SERVICES.metadataBlock, "sandbox firewall", "dl up"],
|
|
37
|
+
[SERVICES.hotcell, "sandboxes", "dl logs hotcell · then dl up"],
|
|
38
|
+
[SERVICES.autopilot, "agent server", "dl logs autopilot · then dl up"],
|
|
39
|
+
]) {
|
|
40
|
+
const active = (await system.probe("systemctl", ["is-active", service])).stdout.trim();
|
|
41
|
+
add({ name, ok: active === "active", detail: active === "active" ? "running" : active || "unknown", fix: logs });
|
|
42
|
+
}
|
|
43
|
+
const hotcell = await system.probe("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", `http://127.0.0.1:${HOTCELL_PORT}/`]);
|
|
44
|
+
add({ name: "sandboxes API", ok: /^\d{3}$/.test(hotcell.stdout.trim()) && hotcell.stdout.trim() !== "000", detail: `HTTP ${hotcell.stdout.trim() || "none"}`, fix: "dl up" });
|
|
45
|
+
const keys = await system.probe("runuser", ["-u", "hotcell", "--", "env", `HOTCELL_HOME=${PATHS.hotcellHome}`, "HOTCELL_KEYSTORE=file", "/usr/bin/node", `${PATHS.current}/node_modules/hotcell/dist/index.js`, "keys", "ls"]);
|
|
46
|
+
const hasModelKey = keys.code === 0 && /^\s*openrouter\b/m.test(keys.stdout);
|
|
47
|
+
add({ name: "model key", ok: hasModelKey, detail: hasModelKey ? "OpenRouter key in Hotcell" : "no OpenRouter key in Hotcell", fix: "sudo dl up (asks for it)" });
|
|
48
|
+
const github = Boolean(parseEnvFile(await system.readFile(PATHS.githubEnv)).GITHUB_TOKEN);
|
|
49
|
+
add({ name: "github", ok: github, detail: github ? "token configured" : "no token; runs can't push or open PRs", fix: "sudo dl up (asks for it)" });
|
|
50
|
+
const autopilot = (await system.probe("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", `http://127.0.0.1:${AUTOPILOT_PORT}/api/v1/health`])).stdout.trim();
|
|
51
|
+
add({ name: "agent server API", ok: autopilot === "401", detail: autopilot === "401" ? "up (refuses requests without a token)" : `HTTP ${autopilot || "none"}`, fix: "dl logs autopilot · then dl up" });
|
|
52
|
+
if (state?.access.mode === "tailscale") {
|
|
53
|
+
const serve = await system.probe("tailscale", ["serve", "status"]);
|
|
54
|
+
const serving = serve.code === 0 && serve.stdout.includes(`127.0.0.1:${AUTOPILOT_PORT}`);
|
|
55
|
+
add({ name: "private access", ok: serving, detail: serving ? `on ${state.access.url}` : "not published on your tailnet", fix: "dl up" });
|
|
56
|
+
const remote = await system.probe("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "10", `${state.access.url}/api/v1/health`]);
|
|
57
|
+
add({ name: "reachable", ok: remote.stdout.trim() === "401", detail: `${state.access.url}: HTTP ${remote.stdout.trim() || "none"}`, fix: "turn on HTTPS certificates at https://login.tailscale.com/admin/dns" });
|
|
58
|
+
}
|
|
59
|
+
return checks;
|
|
60
|
+
}
|
|
61
|
+
export function formatChecks(checks, color = false) {
|
|
62
|
+
const c = palette(color);
|
|
63
|
+
const width = Math.max(...checks.map((check) => check.name.length)) + 2;
|
|
64
|
+
const rows = checks.map((check) => {
|
|
65
|
+
const glyph = check.ok ? c.green("✓") : c.red("✗");
|
|
66
|
+
const fix = !check.ok && check.fix ? `\n ${c.dim(`fix: ${check.fix}`)}` : "";
|
|
67
|
+
return ` ${glyph} ${check.name.padEnd(width)}${check.ok ? c.dim(check.detail) : c.red(check.detail)}${fix}`;
|
|
68
|
+
});
|
|
69
|
+
const failed = checks.filter((check) => !check.ok).length;
|
|
70
|
+
const verdict = failed === 0 ? `${c.green("✓")} ${c.bold("All good")}` : `${c.red("✗")} ${c.bold(`${failed} to fix`)}`;
|
|
71
|
+
return `${rows.join("\n")}\n\n ${verdict}`;
|
|
72
|
+
}
|
package/dist/home.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { agentCapacity, detectHost } from "./detect.js";
|
|
2
|
+
import { AUTOPILOT_PORT, CELL_MEMORY_MB, SERVICES } from "./layout.js";
|
|
3
|
+
import { approvePairing, listLaptops, listServerRuns, ownerLink, pendingPairings, revokeLaptop } from "./server-api.js";
|
|
4
|
+
import { readState } from "./state.js";
|
|
5
|
+
import { palette } from "./ui.js";
|
|
6
|
+
import { nextSteps } from "./up.js";
|
|
7
|
+
const ABOUT = "Background coding agents on your own server: send a task from your laptop, get a pull request back.";
|
|
8
|
+
/** Quick health for the home screen and the login line: services up and the API answering. */
|
|
9
|
+
async function health(system) {
|
|
10
|
+
const problems = [];
|
|
11
|
+
for (const [unit, label] of [[SERVICES.hotcell, "sandboxes"], [SERVICES.autopilot, "agent server"]]) {
|
|
12
|
+
if ((await system.probe("systemctl", ["is-active", unit])).stdout.trim() !== "active")
|
|
13
|
+
problems.push(`${label} stopped`);
|
|
14
|
+
}
|
|
15
|
+
if (problems.length === 0) {
|
|
16
|
+
const code = (await system.probe("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "3", `http://127.0.0.1:${AUTOPILOT_PORT}/api/v1/health`])).stdout.trim();
|
|
17
|
+
if (code !== "401")
|
|
18
|
+
problems.push("agent server not answering");
|
|
19
|
+
}
|
|
20
|
+
return { ok: problems.length === 0, problems };
|
|
21
|
+
}
|
|
22
|
+
/** `dl` on a server: what this is, whether it's healthy, who's connected, what's running, what to do. */
|
|
23
|
+
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),
|
|
26
|
+
]);
|
|
27
|
+
const verdict = status.ok ? c.green("● healthy") : c.red(`● ${status.problems.join(", ")}`);
|
|
28
|
+
const week = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
29
|
+
const running = runs?.filter((run) => run.active).length ?? 0;
|
|
30
|
+
const doneThisWeek = runs?.filter((run) => !run.active && Date.parse(run.createdAt) >= week).length ?? 0;
|
|
31
|
+
const row = (label, value, hint = "") => ` ${c.dim(label.padEnd(11))}${value}${hint ? ` ${c.dim(hint)}` : ""}\n`;
|
|
32
|
+
write(`\n ${c.bold("Directionally Local")} ${verdict} ${c.dim(state.access.url)}\n\n ${ABOUT}\n\n`);
|
|
33
|
+
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
|
+
write(row("Runs", runs === undefined ? c.dim("unknown (run dl up to update)") : `${running} running · ${doneThisWeek} this week`, "dl runs"));
|
|
35
|
+
write(row("Capacity", `~${agentCapacity(facts, CELL_MEMORY_MB)} agents at once (estimate)`));
|
|
36
|
+
if (pending.length > 0) {
|
|
37
|
+
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
|
+
}
|
|
39
|
+
write(`\n ${c.dim("Connect a laptop")} dl connect-laptop\n ${c.dim("Something wrong?")} dl doctor\n ${c.dim("All commands")} dl help\n\n`);
|
|
40
|
+
}
|
|
41
|
+
/** `dl status --brief`: one line, for the login message. Never slow, never fails loudly. */
|
|
42
|
+
export async function briefStatus(system, state, write, c) {
|
|
43
|
+
const status = await health(system);
|
|
44
|
+
write(`\n ${c.bold("Directionally Local")} ${status.ok ? c.green("● healthy") : c.red(`● ${status.problems.join(", ")}`)} ${c.dim(state.access.url)}\n ${c.dim("Type")} dl ${c.dim("for what this is and what's running.")}\n\n`);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* `dl connect-laptop [CODE]`: approve a laptop's code, or explain how to connect one and
|
|
48
|
+
* print a fresh browser link for approving them.
|
|
49
|
+
*/
|
|
50
|
+
export async function connectLaptop(system, state, code, write, c) {
|
|
51
|
+
if (code) {
|
|
52
|
+
const result = await approvePairing(system, code);
|
|
53
|
+
write(`\n ${result.ok ? c.green("✓") : c.red("✗")} ${result.ok ? "Laptop approved. It finishes connecting on its own." : result.message}\n\n`);
|
|
54
|
+
return result.ok ? 0 : 1;
|
|
55
|
+
}
|
|
56
|
+
const pending = await pendingPairings(system);
|
|
57
|
+
if (pending.length > 0) {
|
|
58
|
+
write(`\n ${c.bold("Waiting for approval")}\n${pending.map((entry) => ` ${c.bold(entry.code)} ${entry.name} ${c.dim(`dl connect-laptop ${entry.code}`)}`).join("\n")}\n`);
|
|
59
|
+
}
|
|
60
|
+
const link = await ownerLink(system, state.access.url);
|
|
61
|
+
write(`\n ${c.bold("Connect a laptop")}\n`);
|
|
62
|
+
nextSteps({ url: state.access.url, ownerLink: link, access: state.access.mode }).slice(0, 2).forEach((item, index) => {
|
|
63
|
+
write(`\n ${c.cyan(String(index + 1))} ${item.label}\n${item.value.split("\n").map((line) => ` ${c.bold(line)}`).join("\n")}\n`);
|
|
64
|
+
});
|
|
65
|
+
write(`\n ${c.dim("The laptop shows a code; approve it in that browser, or here with")} dl connect-laptop <code>\n\n`);
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
export async function laptopsCommand(system, args, write, c) {
|
|
69
|
+
const laptops = await listLaptops(system);
|
|
70
|
+
if (laptops === undefined) {
|
|
71
|
+
write(`\n ${c.red("✗")} Couldn't list laptops; run ${c.bold("dl up")} to update this server.\n\n`);
|
|
72
|
+
return 1;
|
|
73
|
+
}
|
|
74
|
+
if (args[0] === "remove") {
|
|
75
|
+
const target = args.slice(1).join(" ").trim().toLowerCase();
|
|
76
|
+
const matches = laptops.filter((laptop) => laptop.id === target || laptop.id.startsWith(target) || laptop.name.toLowerCase() === target);
|
|
77
|
+
if (!target || matches.length !== 1) {
|
|
78
|
+
write(`\n ${c.red("✗")} ${matches.length > 1 ? "That matches several laptops; use its id from dl laptops." : `No laptop named "${target}".`}\n\n`);
|
|
79
|
+
return 1;
|
|
80
|
+
}
|
|
81
|
+
const ok = await revokeLaptop(system, matches[0].id);
|
|
82
|
+
write(`\n ${ok ? c.green("✓") : c.red("✗")} ${ok ? `Disconnected ${matches[0].name}. It can't reach this server any more.` : "Couldn't disconnect it."}\n\n`);
|
|
83
|
+
return ok ? 0 : 1;
|
|
84
|
+
}
|
|
85
|
+
if (laptops.length === 0) {
|
|
86
|
+
write(`\n No laptops connected yet. ${c.dim("dl connect-laptop")}\n\n`);
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
write("\n");
|
|
90
|
+
for (const laptop of laptops) {
|
|
91
|
+
const used = laptop.lastUsedAt ? `last used ${laptop.lastUsedAt.slice(0, 10)}` : "not used yet";
|
|
92
|
+
write(` ${c.green("●")} ${laptop.name.padEnd(28)} ${c.dim(`${used} · ${laptop.id.slice(0, 8)}`)}\n`);
|
|
93
|
+
}
|
|
94
|
+
write(`\n ${c.dim("Disconnect one:")} dl laptops remove <name>\n\n`);
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
export async function serverRunsCommand(system, write, c) {
|
|
98
|
+
const runs = await listServerRuns(system, 50);
|
|
99
|
+
if (runs === undefined) {
|
|
100
|
+
write(`\n ${c.red("✗")} Couldn't list runs; run ${c.bold("dl up")} to update this server.\n\n`);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
if (runs.length === 0) {
|
|
104
|
+
write(`\n No runs yet. From a laptop: ${c.bold('dl run "fix the flaky checkout test"')}\n\n`);
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
write("\n");
|
|
108
|
+
for (const run of runs)
|
|
109
|
+
write(formatRun(run, c));
|
|
110
|
+
write("\n");
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
export function formatRun(run, c) {
|
|
114
|
+
const glyph = run.active ? c.cyan("◐") : run.status === "completed" || run.status === "merged" ? c.green("✓") : run.status === "failed" || run.status === "interrupted" ? c.red("✗") : c.dim("○");
|
|
115
|
+
const where = run.pullRequestURL ?? run.branch ?? "";
|
|
116
|
+
return ` ${glyph} ${run.conversationId.slice(0, 8)} ${(run.active ? "running" : run.status).padEnd(11)} ${(run.repository ?? "").padEnd(28)} ${c.dim(where)}${run.startedBy ? c.dim(` · ${run.startedBy}`) : ""}\n`;
|
|
117
|
+
}
|
|
118
|
+
/** `dl logs [hotcell|autopilot] [-f]`: the services' logs, without knowing their unit names. */
|
|
119
|
+
export async function logsCommand(system, args) {
|
|
120
|
+
const follow = args.includes("-f") || args.includes("--follow");
|
|
121
|
+
const which = args.find((arg) => !arg.startsWith("-"));
|
|
122
|
+
const units = which === "hotcell" ? [SERVICES.hotcell] : which === "autopilot" ? [SERVICES.autopilot] : [SERVICES.hotcell, SERVICES.autopilot];
|
|
123
|
+
const result = await system.run("journalctl", [...units.flatMap((unit) => ["-u", unit]), "-n", "80", "--no-pager", ...(follow ? ["-f"] : [])], { inherit: true, allowFailure: true });
|
|
124
|
+
return result.code;
|
|
125
|
+
}
|
|
126
|
+
export { readState };
|