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/dist/system.js ADDED
@@ -0,0 +1,177 @@
1
+ import { spawn } from "node:child_process";
2
+ import { appendFileSync, mkdirSync, openSync } from "node:fs";
3
+ import { chmod, chown, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
4
+ import { dirname, resolve } from "node:path";
5
+ export class CommandError extends Error {
6
+ command;
7
+ result;
8
+ constructor(command, result) {
9
+ super(`${command} failed (exit ${result.code})${result.stderr.trim() ? `: ${result.stderr.trim().split("\n").slice(-3).join(" ")}` : ""}`);
10
+ this.command = command;
11
+ this.result = result;
12
+ this.name = "CommandError";
13
+ }
14
+ }
15
+ export class RealSystem {
16
+ output;
17
+ dryRun = false;
18
+ logFd;
19
+ /**
20
+ * With `outputLog`, every command and its output goes to that file instead of the screen,
21
+ * including long installs that would otherwise stream (`inherit`), so the terminal UI owns
22
+ * the screen. Without it, those installs stream as before (plain mode, logs, CI).
23
+ */
24
+ constructor(output = (line) => process.stdout.write(`${line}\n`), options = {}) {
25
+ this.output = output;
26
+ if (options.outputLog) {
27
+ mkdirSync(dirname(options.outputLog), { recursive: true, mode: 0o700 });
28
+ this.logFd = openSync(options.outputLog, "w", 0o600);
29
+ appendFileSync(this.logFd, `# directionallylocal ${new Date().toISOString()}\n`);
30
+ }
31
+ }
32
+ run(command, args, options = {}) {
33
+ return this.spawn(command, args, options, true);
34
+ }
35
+ spawn(command, args, options, logged) {
36
+ const log = logged ? this.logFd : undefined;
37
+ const stream = options.inherit && this.logFd === undefined;
38
+ if (log !== undefined)
39
+ appendFileSync(log, `\n$ ${options.describe ?? [command, ...args].join(" ")}\n`);
40
+ return new Promise((resolve, reject) => {
41
+ const child = spawn(command, args, {
42
+ cwd: options.cwd,
43
+ env: { ...process.env, ...options.env },
44
+ stdio: [options.input === undefined ? "ignore" : "pipe", stream ? "inherit" : "pipe", stream ? "inherit" : "pipe"],
45
+ });
46
+ let stdout = "";
47
+ let stderr = "";
48
+ child.stdout?.on("data", (chunk) => {
49
+ stdout += chunk.toString();
50
+ if (log !== undefined && !options.secretOutput)
51
+ appendFileSync(log, chunk);
52
+ });
53
+ child.stderr?.on("data", (chunk) => {
54
+ stderr += chunk.toString();
55
+ if (log !== undefined)
56
+ appendFileSync(log, chunk);
57
+ });
58
+ child.on("error", (error) => {
59
+ const result = { code: 127, stdout, stderr: stderr || error.message };
60
+ if (options.allowFailure)
61
+ resolve(result);
62
+ else
63
+ reject(new CommandError(options.describe ?? command, result));
64
+ });
65
+ child.on("close", (code) => {
66
+ const result = { code: code ?? 1, stdout, stderr };
67
+ if (log !== undefined && result.code !== 0)
68
+ appendFileSync(log, `(exit ${result.code})\n`);
69
+ if (result.code !== 0 && !options.allowFailure)
70
+ reject(new CommandError(options.describe ?? [command, ...args].join(" "), result));
71
+ else
72
+ resolve(result);
73
+ });
74
+ if (options.input !== undefined)
75
+ child.stdin?.end(options.input);
76
+ });
77
+ }
78
+ /** Detection probes are frequent and read-only; they stay out of the install log. */
79
+ probe(command, args, options = {}) {
80
+ return this.spawn(command, args, { ...options, allowFailure: true }, false);
81
+ }
82
+ async readFile(path) {
83
+ try {
84
+ return await readFile(path, "utf8");
85
+ }
86
+ catch (error) {
87
+ if (error.code === "ENOENT")
88
+ return undefined;
89
+ throw error;
90
+ }
91
+ }
92
+ async writeFile(path, content, options = {}) {
93
+ await mkdir(dirname(path), { recursive: true });
94
+ const mode = options.mode ?? 0o644;
95
+ // Create with the final mode so a secret is never briefly world-readable.
96
+ await writeFile(path, content, { mode });
97
+ await chmod(path, mode);
98
+ if (options.owner)
99
+ await this.chown(path, options.owner);
100
+ }
101
+ /**
102
+ * Every directory this creates gets the mode and owner, not just the last one: Node's
103
+ * recursive mkdir applies the mode to new parents too, so a root-owned 0700 parent would
104
+ * lock the owner out of its own directory.
105
+ */
106
+ async mkdir(path, options = {}) {
107
+ const created = [];
108
+ for (let dir = resolve(path); !(await this.exists(dir)); dir = dirname(dir))
109
+ created.unshift(dir);
110
+ await mkdir(path, { recursive: true, ...(options.mode !== undefined ? { mode: options.mode } : {}) });
111
+ for (const dir of created.length > 0 ? created : [resolve(path)]) {
112
+ if (options.mode !== undefined)
113
+ await chmod(dir, options.mode);
114
+ if (options.owner)
115
+ await this.chown(dir, options.owner);
116
+ }
117
+ }
118
+ async remove(path) {
119
+ await rm(path, { recursive: true, force: true });
120
+ }
121
+ async exists(path) {
122
+ return stat(path).then(() => true, () => false);
123
+ }
124
+ log(line) {
125
+ this.output(line);
126
+ }
127
+ async chown(path, owner) {
128
+ const [user, group = user] = owner.split(":");
129
+ const uid = Number((await this.run("id", ["-u", user])).stdout.trim());
130
+ const gid = Number((await this.run("getent", ["group", group])).stdout.split(":")[2]);
131
+ await chown(path, uid, gid);
132
+ }
133
+ }
134
+ /**
135
+ * Prints what `up` would change and changes nothing. Reads still hit the real machine,
136
+ * so the plan reflects what is already installed.
137
+ */
138
+ export class DryRunSystem {
139
+ output;
140
+ dryRun = true;
141
+ real;
142
+ constructor(output = (line) => process.stdout.write(`${line}\n`)) {
143
+ this.output = output;
144
+ this.real = new RealSystem(output);
145
+ }
146
+ async run(command, args, options = {}) {
147
+ this.output(` would run: ${options.describe ?? [command, ...args].join(" ")}`);
148
+ return { code: 0, stdout: "", stderr: "" };
149
+ }
150
+ probe(command, args, options = {}) {
151
+ return this.real.probe(command, args, options);
152
+ }
153
+ readFile(path) {
154
+ return this.real.readFile(path);
155
+ }
156
+ async writeFile(path, content, options = {}) {
157
+ const mode = (options.mode ?? 0o644).toString(8).padStart(4, "0");
158
+ this.output(` would write: ${path} (${mode}${options.owner ? ` ${options.owner}` : ""})`);
159
+ if (!options.secret)
160
+ for (const line of content.trimEnd().split("\n"))
161
+ this.output(` ${line}`);
162
+ }
163
+ async mkdir(path, options = {}) {
164
+ if (!(await this.real.exists(path)))
165
+ this.output(` would create: ${path}/${options.owner ? ` (${options.owner})` : ""}`);
166
+ }
167
+ async remove(path) {
168
+ if (await this.real.exists(path))
169
+ this.output(` would remove: ${path}`);
170
+ }
171
+ exists(path) {
172
+ return this.real.exists(path);
173
+ }
174
+ log(line) {
175
+ this.output(line);
176
+ }
177
+ }
package/dist/ui.js ADDED
@@ -0,0 +1,223 @@
1
+ /**
2
+ * What people see while the installer works. On a terminal: a live checklist in
3
+ * Camouflage's visual language (✓ done, ✗ failed, ○ pending, a spinner and timer on the
4
+ * active step), redrawn in place in normal scrollback so the final link and command stay
5
+ * on screen to copy. Without a terminal (CI, logs, unattended installs): one plain line
6
+ * per event. Other tools' output never reaches the screen; it goes to the install log.
7
+ */
8
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
9
+ const GLYPH = { pending: "○", active: "", done: "✓", failed: "✗", info: "·" };
10
+ const LABEL_WIDTH = 33;
11
+ /** ANSI styles, or none under NO_COLOR / TERM=dumb. */
12
+ export function palette(enabled) {
13
+ const wrap = (open, close) => (text) => enabled ? `\x1b[${open}m${text}\x1b[${close}m` : text;
14
+ return {
15
+ bold: wrap("1", "22"),
16
+ dim: wrap("2", "22"),
17
+ green: wrap("32", "39"),
18
+ red: wrap("31", "39"),
19
+ yellow: wrap("33", "39"),
20
+ cyan: wrap("36", "39"),
21
+ };
22
+ }
23
+ export class TerminalUi {
24
+ write;
25
+ columns;
26
+ interactive = true;
27
+ steps = [];
28
+ rendered = 0;
29
+ frame = 0;
30
+ timer;
31
+ warnings = [];
32
+ c;
33
+ constructor(write = (text) => process.stdout.write(text), columns = () => process.stdout.columns || 80, color = !process.env.NO_COLOR && process.env.TERM !== "dumb") {
34
+ this.write = write;
35
+ this.columns = columns;
36
+ this.c = palette(color);
37
+ }
38
+ title(text) {
39
+ this.write(`\n ${this.c.bold(text)}\n\n`);
40
+ }
41
+ line(status, label, detail) {
42
+ this.write(`${this.fit(this.row(status, label, detail))}\n`);
43
+ }
44
+ plan(steps) {
45
+ this.steps = steps.map((step) => ({ ...step, status: "pending" }));
46
+ this.render();
47
+ }
48
+ start(id, detail) {
49
+ const step = this.step(id);
50
+ step.status = "active";
51
+ step.startedAt = Date.now();
52
+ if (detail !== undefined)
53
+ step.detail = detail;
54
+ // Animate only while something is running; unref so a finished run exits.
55
+ this.timer ??= setInterval(() => { this.frame++; this.render(); }, 80);
56
+ this.timer.unref?.();
57
+ this.render();
58
+ }
59
+ done(id, detail) {
60
+ const step = this.step(id);
61
+ step.status = "done";
62
+ step.detail = detail;
63
+ this.settle();
64
+ }
65
+ fail(id, message) {
66
+ const step = this.step(id);
67
+ step.status = "failed";
68
+ step.detail = message.split("\n")[0];
69
+ this.settle();
70
+ }
71
+ questions(intro) {
72
+ this.write(`\n ${this.c.dim(intro)}\n\n`);
73
+ }
74
+ prompt(label, hint) {
75
+ return ` ${this.c.bold(label)}${hint ? ` ${this.c.dim(hint)}` : ""}\n ${this.c.cyan("›")} `;
76
+ }
77
+ answered(label, detail) {
78
+ // Replace the two prompt lines with a confirmation.
79
+ this.write(`\x1b[2F\x1b[J${this.fit(this.row("done", label, detail))}\n`);
80
+ }
81
+ rejected(message) {
82
+ this.write(`\x1b[2F\x1b[J ${this.c.yellow("!")} ${message}\n`);
83
+ }
84
+ ready(headline, items, extra = {}) {
85
+ this.stop();
86
+ this.write(`\n ${this.c.green("✓")} ${this.c.bold(headline)}\n`);
87
+ if (extra.about)
88
+ this.write(`\n${extra.about.map((line) => ` ${line}`).join("\n")}\n`);
89
+ items.forEach((item, index) => {
90
+ // Values are never truncated: they're meant to be copied. Each line is its own command.
91
+ const value = item.value.split("\n").map((line) => ` ${this.c.bold(line)}`).join("\n");
92
+ this.write(`\n ${this.c.cyan(String(index + 1))} ${item.label}\n${value}\n`);
93
+ });
94
+ if (extra.footer)
95
+ this.write(`\n ${this.c.dim(extra.footer)}\n`);
96
+ this.write("\n");
97
+ this.flushWarnings();
98
+ }
99
+ warn(text) {
100
+ this.warnings.push(text);
101
+ }
102
+ finish() {
103
+ this.stop();
104
+ this.flushWarnings();
105
+ }
106
+ settle() {
107
+ if (!this.steps.some((step) => step.status === "active"))
108
+ this.stop();
109
+ this.render();
110
+ }
111
+ stop() {
112
+ if (this.timer)
113
+ clearInterval(this.timer);
114
+ this.timer = undefined;
115
+ }
116
+ flushWarnings() {
117
+ for (const text of this.warnings.splice(0))
118
+ this.write(` ${this.c.yellow("!")} ${text}\n`);
119
+ }
120
+ render() {
121
+ const lines = this.steps.map((step) => this.fit(this.row(step.status, step.label, this.detailFor(step))));
122
+ // Back to the top of the checklist and redraw it; lines never wrap, so the count holds.
123
+ const rewind = this.rendered > 0 ? `\x1b[${this.rendered}F` : "";
124
+ this.write(`${rewind}${lines.map((line) => `\x1b[2K${line}\n`).join("")}`);
125
+ this.rendered = lines.length;
126
+ }
127
+ detailFor(step) {
128
+ if (step.status !== "active")
129
+ return step.detail;
130
+ const seconds = Math.floor((Date.now() - (step.startedAt ?? Date.now())) / 1000);
131
+ const elapsed = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
132
+ return step.detail ? `${step.detail} · ${elapsed}` : elapsed;
133
+ }
134
+ row(status, label, detail) {
135
+ const glyph = status === "active" ? this.c.cyan(SPINNER[this.frame % SPINNER.length]) :
136
+ status === "done" ? this.c.green(GLYPH.done) :
137
+ status === "failed" ? this.c.red(GLYPH.failed) :
138
+ this.c.dim(GLYPH[status]);
139
+ const text = status === "pending" ? this.c.dim(label) : status === "failed" ? this.c.red(label) : label;
140
+ const segments = [{ plain: " ", styled: " " }, { plain: "x", styled: glyph }, { plain: " ", styled: " " }, { plain: label, styled: text }];
141
+ if (detail) {
142
+ const pad = " ".repeat(Math.max(2, LABEL_WIDTH - label.length));
143
+ segments.push({ plain: pad, styled: pad }, { plain: detail, styled: status === "failed" ? this.c.red(detail) : this.c.dim(detail) });
144
+ }
145
+ return segments;
146
+ }
147
+ /** Joins styled segments, truncated by their visible width so a line never wraps. */
148
+ fit(segments) {
149
+ let room = Math.max(10, this.columns() - 1);
150
+ let out = "";
151
+ for (const segment of segments) {
152
+ if (room <= 0)
153
+ break;
154
+ if (segment.plain.length <= room) {
155
+ out += segment.styled;
156
+ room -= segment.plain.length;
157
+ }
158
+ else {
159
+ out += this.c.dim(`${segment.plain.slice(0, Math.max(0, room - 1))}…`);
160
+ room = 0;
161
+ }
162
+ }
163
+ return out;
164
+ }
165
+ step(id) {
166
+ const step = this.steps.find((entry) => entry.id === id);
167
+ if (!step)
168
+ throw new Error(`unknown step ${id}`);
169
+ return step;
170
+ }
171
+ }
172
+ /** Logs, CI, and unattended installs: one plain line per event, no escape codes. */
173
+ export class PlainUi {
174
+ write;
175
+ interactive = false;
176
+ labels = new Map();
177
+ constructor(write = (text) => process.stdout.write(text)) {
178
+ this.write = write;
179
+ }
180
+ title(text) { this.write(`${text}\n`); }
181
+ line(status, label, detail) {
182
+ this.write(`${status === "done" ? "ok" : status === "failed" ? "FAILED" : "--"} ${label}${detail ? `: ${detail}` : ""}\n`);
183
+ }
184
+ plan(steps) { this.labels = new Map(steps.map((step) => [step.id, step.label])); }
185
+ start(id, detail) { this.write(`.. ${this.labels.get(id) ?? id}${detail ? `: ${detail}` : ""}\n`); }
186
+ done(id, detail) { this.write(`ok ${this.labels.get(id) ?? id}${detail ? `: ${detail}` : ""}\n`); }
187
+ fail(id, message) { this.write(`FAILED ${this.labels.get(id) ?? id}: ${message}\n`); }
188
+ questions(intro) { this.write(`${intro}\n`); }
189
+ prompt(label, hint) { return `${label}${hint ? ` (${hint})` : ""}: `; }
190
+ answered(label, detail) { this.write(`ok ${label}${detail ? `: ${detail}` : ""}\n`); }
191
+ rejected(message) { this.write(`!! ${message}\n`); }
192
+ ready(headline, items, extra = {}) {
193
+ this.write(`\n${headline}\n`);
194
+ if (extra.about)
195
+ this.write(`${extra.about.join(" ")}\n`);
196
+ for (const item of items)
197
+ this.write(` ${item.label}: ${item.value.split("\n").join(" ; ")}\n`);
198
+ if (extra.footer)
199
+ this.write(`${extra.footer}\n`);
200
+ }
201
+ warn(text) { this.write(`!! ${text}\n`); }
202
+ finish() { }
203
+ }
204
+ /** Records events for tests. */
205
+ export class RecordingUi {
206
+ interactive = false;
207
+ events = [];
208
+ title(text) { this.events.push(`title ${text}`); }
209
+ line(status, label, detail) { this.events.push(`line ${status} ${label}${detail ? ` | ${detail}` : ""}`); }
210
+ plan(steps) { this.events.push(`plan ${steps.map((step) => step.id).join(",")}`); }
211
+ start(id) { this.events.push(`start ${id}`); }
212
+ done(id, detail) { this.events.push(`done ${id}${detail ? ` | ${detail}` : ""}`); }
213
+ fail(id, message) { this.events.push(`fail ${id} | ${message}`); }
214
+ questions(intro) { this.events.push(`questions ${intro}`); }
215
+ prompt(label) { return label; }
216
+ answered(label, detail) { this.events.push(`answered ${label}${detail ? ` | ${detail}` : ""}`); }
217
+ rejected(message) { this.events.push(`rejected ${message}`); }
218
+ ready(headline, items, extra = {}) {
219
+ this.events.push(`ready ${headline}`, ...(extra.about ?? []).map((line) => `about ${line}`), ...items.map((item) => `item ${item.label} | ${item.value}`), ...(extra.footer ? [`footer ${extra.footer}`] : []));
220
+ }
221
+ warn(text) { this.events.push(`warn ${text}`); }
222
+ finish() { }
223
+ }
@@ -0,0 +1,48 @@
1
+ import { AUTOPILOT_PORT, MOTD_SCRIPT, NODE_AUTO_UPDATES, PATHS, SERVICES, USERS } from "./layout.js";
2
+ import { readState } from "./state.js";
3
+ /**
4
+ * `directionallylocal uninstall`: stops and removes every service and the installed
5
+ * software. Data and secrets stay unless --purge, so a later `up` picks up where it was.
6
+ * Docker and Tailscale stay installed; Tailscale only stops serving Autopilot.
7
+ */
8
+ export async function uninstall(system, options = {}) {
9
+ const say = (line) => system.log(line);
10
+ const state = await readState(system);
11
+ say("== Services");
12
+ for (const service of [SERVICES.autopilot, SERVICES.hotcell, SERVICES.metadataBlock]) {
13
+ await system.run("systemctl", ["disable", "--now", service], { allowFailure: true });
14
+ await system.remove(`${PATHS.units}/${service}`);
15
+ }
16
+ await system.run("systemctl", ["daemon-reload"]);
17
+ await system.run("systemctl", ["reset-failed"], { allowFailure: true });
18
+ if (state?.access.mode === "tailscale" || (await system.probe("tailscale", ["version"])).code === 0) {
19
+ say("== Tailscale");
20
+ await system.run("tailscale", ["serve", "--https=443", `http://127.0.0.1:${AUTOPILOT_PORT}`, "off"], { allowFailure: true });
21
+ }
22
+ say("== Sandboxes and software");
23
+ // Hotcell's sandboxes are labeled; remove any left running so nothing keeps the box busy.
24
+ const containers = (await system.probe("docker", ["ps", "-aq", "--filter", "label=sbx.managed=true"])).stdout.split("\n").filter(Boolean);
25
+ if (containers.length > 0)
26
+ await system.run("docker", ["rm", "-f", ...containers], { allowFailure: true });
27
+ await system.remove(PATHS.root);
28
+ // Node.js stays installed, and so do Ubuntu's own automatic updates; only our addition goes.
29
+ await system.remove(NODE_AUTO_UPDATES);
30
+ await system.remove(MOTD_SCRIPT);
31
+ if (!options.purge) {
32
+ say(`Removed the services and software. Kept configuration and data (${PATHS.stateDir}, ${PATHS.autopilotEtc}, ${PATHS.hotcellEtc}, /var/lib/{autopilot,hotcell}); --purge deletes them.`);
33
+ return;
34
+ }
35
+ say("== Configuration, data, and accounts (--purge)");
36
+ if (state?.image)
37
+ await system.run("docker", ["image", "rm", state.image], { allowFailure: true });
38
+ const volumes = (await system.probe("docker", ["volume", "ls", "-q", "--filter", "label=sbx.managed=true"])).stdout.split("\n").filter(Boolean);
39
+ if (volumes.length > 0)
40
+ await system.run("docker", ["volume", "rm", ...volumes], { allowFailure: true });
41
+ for (const path of [PATHS.stateDir, PATHS.autopilotEtc, PATHS.hotcellEtc, PATHS.autopilotHome, PATHS.hotcellHome])
42
+ await system.remove(path);
43
+ for (const user of [USERS.autopilot, USERS.hotcell]) {
44
+ if ((await system.probe("id", ["-u", user])).code === 0)
45
+ await system.run("userdel", [user], { allowFailure: true });
46
+ }
47
+ say("Removed everything directionallylocal installed. Docker and Tailscale are still installed.");
48
+ }