arelos 0.1.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,292 @@
1
+ /**
2
+ * Interactive + non-interactive install flow (rlo-cli-spec.md §1).
3
+ * Steps are numbered in comments to match the spec exactly.
4
+ */
5
+ import * as p from "@clack/prompts";
6
+ import pc from "picocolors";
7
+ import { join } from "node:path";
8
+ import { ensureBun } from "./bun-setup.js";
9
+ import { readConfig, writeConfig } from "./config.js";
10
+ import { runStreaming } from "./exec.js";
11
+ import { waitForHealthy } from "./health.js";
12
+ import { checkInstallDir, defaultVaultPath, DEFAULTS, normalizeDisplayName, resolvePort, toArelConfig, } from "./install-plan.js";
13
+ import { bootstrapAndStart, installServiceFiles } from "./services.js";
14
+ import { cloneRepo, isGitCheckout } from "./repo.js";
15
+ import { ensureEnvFile, ensureLogsDir, scaffoldVault, TemplateVaultMissingError } from "./scaffold.js";
16
+ import { runRepairMenu } from "./repair.js";
17
+ import { configPath } from "./paths.js";
18
+ export async function runInstall(argv, flags) {
19
+ // Step 0 — Preflight & existing-install detection.
20
+ if (process.platform !== "darwin") {
21
+ console.error("Arel OS currently supports macOS only.");
22
+ return 1;
23
+ }
24
+ const existing = readConfig();
25
+ if (existing && !flags.yes) {
26
+ return runRepairMenu(existing);
27
+ }
28
+ if (existing && flags.yes) {
29
+ // Non-interactive re-run against an existing install: treat as repair to
30
+ // stay consistent with "never silently reinstall" (spec §3.1), unless the
31
+ // caller explicitly pointed at a fresh installDir/config.
32
+ console.log(pc.yellow(`Existing install detected at ${existing.installDir}; repairing.`));
33
+ }
34
+ const gitOk = flags.localRepo ? true : await checkGit();
35
+ if (!gitOk)
36
+ return 1;
37
+ p.intro(pc.bold("Arel OS — your personal life-OS, self-hosted on your Mac."));
38
+ if (!flags.yes) {
39
+ p.log.message("This installs Arel OS to a folder, runs two background services, and opens it in your browser.\nMIT licensed. Everything stays local on this Mac.");
40
+ }
41
+ const answers = await collectAnswers(flags);
42
+ if (answers === null) {
43
+ p.cancel("Install cancelled.");
44
+ return 1;
45
+ }
46
+ if (!flags.yes) {
47
+ p.note([
48
+ `Name: ${answers.displayName}`,
49
+ `Install dir: ${answers.installDir}`,
50
+ `Vault path: ${answers.vaultPath}`,
51
+ `Web port: ${answers.webPort}`,
52
+ `Vault port: ${answers.vaultPort}`,
53
+ ].join("\n"), "Summary");
54
+ const proceed = await p.confirm({ message: "Proceed with install?", initialValue: true });
55
+ if (p.isCancel(proceed) || !proceed) {
56
+ p.cancel("Install cancelled.");
57
+ return 1;
58
+ }
59
+ }
60
+ // Step 7 — Install Bun if missing.
61
+ const bunSpin = spinner(flags.yes);
62
+ bunSpin.start("Checking for Bun…");
63
+ const bunResult = await ensureBun();
64
+ if ("error" in bunResult) {
65
+ bunSpin.stop("Bun setup failed.");
66
+ console.error(pc.red(bunResult.error));
67
+ return 1;
68
+ }
69
+ bunSpin.stop(`Bun ready (${bunResult.bunBin}).`);
70
+ // Step 8 — Get the app source.
71
+ const installDir = answers.installDir;
72
+ if (isGitCheckout(installDir)) {
73
+ log(flags.yes, `Existing checkout found at ${installDir}; skipping clone.`);
74
+ }
75
+ else {
76
+ const cloneSpin = spinner(flags.yes);
77
+ cloneSpin.start(`Cloning app source into ${installDir}…`);
78
+ const cloneRes = await cloneRepo(installDir, { sourcePath: flags.localRepo ?? undefined });
79
+ if (cloneRes.code !== 0) {
80
+ cloneSpin.stop("Clone failed.");
81
+ console.error(pc.red(cloneRes.stderr));
82
+ return 1;
83
+ }
84
+ cloneSpin.stop("App source cloned.");
85
+ }
86
+ // Step 9 — Build + scaffold.
87
+ const buildSpin = spinner(flags.yes);
88
+ buildSpin.start("Installing dependencies (bun install)…");
89
+ const installRes = await runStreaming(bunResult.bunBin, ["install"], { cwd: installDir });
90
+ if (installRes.code !== 0) {
91
+ buildSpin.stop("bun install failed.");
92
+ return 1;
93
+ }
94
+ buildSpin.stop("Dependencies installed.");
95
+ const buildSpin2 = spinner(flags.yes);
96
+ buildSpin2.start("Building the app (bun run build)…");
97
+ const buildRes = await runStreaming(bunResult.bunBin, ["run", "build"], { cwd: installDir });
98
+ if (buildRes.code !== 0) {
99
+ buildSpin2.stop("Build failed — services will not be registered.");
100
+ return 1;
101
+ }
102
+ buildSpin2.stop("Build complete.");
103
+ try {
104
+ const scaffoldResult = scaffoldVault(installDir, answers.vaultPath);
105
+ log(flags.yes, scaffoldResult.copied
106
+ ? `Vault scaffolded at ${answers.vaultPath}.`
107
+ : `Vault at ${answers.vaultPath} already has content; left untouched.`);
108
+ }
109
+ catch (err) {
110
+ if (err instanceof TemplateVaultMissingError) {
111
+ console.error(pc.red(err.message));
112
+ return 1;
113
+ }
114
+ throw err;
115
+ }
116
+ ensureLogsDir(installDir);
117
+ const envResult = ensureEnvFile(installDir);
118
+ log(flags.yes, envResult.created ? "Wrote .env from .env.example." : ".env already present; left untouched.");
119
+ // Step 10 — Write config.
120
+ const config = toArelConfig(answers);
121
+ writeConfig(config);
122
+ log(flags.yes, `Config written to ${configPath()}.`);
123
+ if (flags.noService) {
124
+ log(flags.yes, "Skipping launchd bootstrap (--no-service).");
125
+ p.outro(pc.green("Dry-run install complete (no services started)."));
126
+ return 0;
127
+ }
128
+ // Step 11 — Generate + bootstrap launchd services.
129
+ const svcSpin = spinner(flags.yes);
130
+ svcSpin.start("Registering background services…");
131
+ installServiceFiles(installDir);
132
+ const bootstrapResult = await bootstrapAndStart();
133
+ if (bootstrapResult.errors.length > 0) {
134
+ svcSpin.stop("Service registration had errors.");
135
+ for (const e of bootstrapResult.errors)
136
+ console.error(pc.yellow(e));
137
+ }
138
+ else {
139
+ svcSpin.stop("Services registered and started.");
140
+ }
141
+ // Step 12 — Health check.
142
+ const healthSpin = spinner(flags.yes);
143
+ healthSpin.start("Waiting for the app to come up (building the dashboard…)");
144
+ const health = await waitForHealthy(config.webPort, config.vaultPort);
145
+ if (!health.healthy) {
146
+ healthSpin.stop("Health check timed out.");
147
+ console.error(pc.red(`App did not come up in time. Check logs:\n arelos logs\n ${join(installDir, "logs/service/web.log")}\n ${join(installDir, "logs/service/vault.log")}`));
148
+ return 1;
149
+ }
150
+ healthSpin.stop("App is up.");
151
+ // Step 13 — Open browser.
152
+ const url = `http://localhost:${config.webPort}`;
153
+ if (!flags.yes) {
154
+ try {
155
+ await runStreaming("open", [url]);
156
+ }
157
+ catch {
158
+ // best-effort
159
+ }
160
+ }
161
+ p.outro([
162
+ pc.green(`Arel OS is running at ${url}`),
163
+ "Runs 24/7 in the background.",
164
+ "Next: arelos status · arelos logs · arelos update · arelos uninstall",
165
+ ].join("\n"));
166
+ return 0;
167
+ }
168
+ async function checkGit() {
169
+ const { commandExists } = await import("./exec.js");
170
+ if (commandExists("git"))
171
+ return true;
172
+ console.error("git is required but was not found. Install Xcode Command Line Tools:\n xcode-select --install");
173
+ return false;
174
+ }
175
+ async function collectAnswers(flags) {
176
+ if (flags.yes) {
177
+ const displayName = normalizeDisplayName(flags.displayName ?? DEFAULTS.displayName);
178
+ const installDir = flags.installDir ?? DEFAULTS.installDir;
179
+ const vaultPath = flags.vaultPath ?? defaultVaultPath(installDir);
180
+ const webPortReq = flags.webPort ?? DEFAULTS.webPort;
181
+ const vaultPortReq = flags.vaultPort ?? DEFAULTS.vaultPort;
182
+ const web = await resolvePort(webPortReq);
183
+ const vault = await resolvePort(vaultPortReq === web.resolved ? vaultPortReq + 1 : vaultPortReq);
184
+ return {
185
+ displayName,
186
+ installDir,
187
+ vaultPath,
188
+ webPort: web.resolved,
189
+ vaultPort: vault.resolved,
190
+ };
191
+ }
192
+ // Step 2 — Name your system.
193
+ const displayNameRaw = await p.text({
194
+ message: "What should we call your system?",
195
+ placeholder: DEFAULTS.displayName,
196
+ defaultValue: DEFAULTS.displayName,
197
+ });
198
+ if (p.isCancel(displayNameRaw))
199
+ return null;
200
+ // Step 3 — Install location.
201
+ let installDir = "";
202
+ for (;;) {
203
+ const raw = await p.text({
204
+ message: "Where should Arel OS be installed?",
205
+ placeholder: DEFAULTS.installDir,
206
+ defaultValue: DEFAULTS.installDir,
207
+ });
208
+ if (p.isCancel(raw))
209
+ return null;
210
+ const check = checkInstallDir(String(raw || DEFAULTS.installDir));
211
+ if (!check.parentWritable) {
212
+ p.log.error(`Cannot write to ${check.path} — choose another location.`);
213
+ continue;
214
+ }
215
+ if (check.nonEmpty && !check.isPriorArelosInstall) {
216
+ const proceed = await p.confirm({
217
+ message: `${check.path} exists and is not empty. Use it anyway?`,
218
+ initialValue: false,
219
+ });
220
+ if (p.isCancel(proceed) || !proceed)
221
+ continue;
222
+ }
223
+ installDir = check.path;
224
+ break;
225
+ }
226
+ // Step 4 — Vault location.
227
+ const vaultRaw = await p.text({
228
+ message: "Where's your vault (notes/tasks/quests)?",
229
+ placeholder: defaultVaultPath(installDir),
230
+ defaultValue: defaultVaultPath(installDir),
231
+ });
232
+ if (p.isCancel(vaultRaw))
233
+ return null;
234
+ // Step 5 — Ports.
235
+ const webPort = await promptPort("Web port?", DEFAULTS.webPort);
236
+ if (webPort === null)
237
+ return null;
238
+ const vaultPort = await promptPort("Vault port?", DEFAULTS.vaultPort === webPort ? DEFAULTS.vaultPort + 1 : DEFAULTS.vaultPort);
239
+ if (vaultPort === null)
240
+ return null;
241
+ return {
242
+ displayName: normalizeDisplayName(String(displayNameRaw)),
243
+ installDir,
244
+ vaultPath: String(vaultRaw || defaultVaultPath(installDir)),
245
+ webPort,
246
+ vaultPort,
247
+ };
248
+ }
249
+ async function promptPort(message, defaultPort) {
250
+ let candidate = defaultPort;
251
+ for (;;) {
252
+ const resolution = await resolvePort(candidate);
253
+ let suggested = resolution.resolved;
254
+ if (!resolution.wasFree) {
255
+ p.log.warn(`Port ${resolution.requested} is in use — suggesting ${suggested}.`);
256
+ }
257
+ const raw = await p.text({
258
+ message,
259
+ placeholder: String(suggested),
260
+ defaultValue: String(suggested),
261
+ });
262
+ if (p.isCancel(raw))
263
+ return null;
264
+ const parsed = Number(raw || suggested);
265
+ if (!Number.isInteger(parsed) || parsed <= 1023) {
266
+ p.log.error("Enter a valid port number above 1023.");
267
+ candidate = suggested;
268
+ continue;
269
+ }
270
+ const check = await resolvePort(parsed);
271
+ if (!check.wasFree) {
272
+ candidate = check.resolved;
273
+ continue;
274
+ }
275
+ return parsed;
276
+ }
277
+ }
278
+ function spinner(quiet) {
279
+ if (quiet) {
280
+ return {
281
+ start: (msg) => console.log(msg),
282
+ stop: (msg) => console.log(msg),
283
+ };
284
+ }
285
+ return p.spinner();
286
+ }
287
+ function log(quiet, msg) {
288
+ if (quiet)
289
+ console.log(msg);
290
+ else
291
+ p.log.message(msg);
292
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * launchctl wrapper (modern per-GUI-domain bootstrap/bootout/kickstart API).
3
+ * Every op is idempotent per spec §3.2: bootout-then-bootstrap never errors
4
+ * on "already loaded"; a --no-service dry run skips all of this.
5
+ */
6
+ import { userInfo } from "node:os";
7
+ import { runCapture } from "./exec.js";
8
+ import { plistPath } from "./paths.js";
9
+ function uid() {
10
+ return userInfo().uid;
11
+ }
12
+ function guiDomain() {
13
+ return `gui/${uid()}`;
14
+ }
15
+ export async function bootoutService(label) {
16
+ // Ignore errors — idempotent "unload if loaded".
17
+ await runCapture("launchctl", ["bootout", `${guiDomain()}/${label}`]);
18
+ }
19
+ export async function bootstrapService(label) {
20
+ await bootoutService(label);
21
+ const res = await runCapture("launchctl", ["bootstrap", guiDomain(), plistPath(label)]);
22
+ return { ok: res.code === 0, stderr: res.stderr };
23
+ }
24
+ export async function kickstartService(label) {
25
+ const res = await runCapture("launchctl", ["kickstart", "-k", `${guiDomain()}/${label}`]);
26
+ return { ok: res.code === 0, stderr: res.stderr };
27
+ }
28
+ /** Best-effort parse of `launchctl list | grep com.arelos`. */
29
+ export async function getServiceStatus(label) {
30
+ const res = await runCapture("launchctl", ["list"]);
31
+ const line = res.stdout.split("\n").find((l) => l.trim().endsWith(label));
32
+ if (!line)
33
+ return { label, loaded: false, pid: null, lastExitCode: null };
34
+ const parts = line.trim().split(/\s+/);
35
+ const pidRaw = parts[0];
36
+ const exitRaw = parts[1];
37
+ return {
38
+ label,
39
+ loaded: true,
40
+ pid: pidRaw === "-" ? null : Number(pidRaw),
41
+ lastExitCode: exitRaw === "-" ? null : Number(exitRaw),
42
+ };
43
+ }
44
+ export function guiDomainForTest() {
45
+ return guiDomain();
46
+ }
package/dist/logs.js ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `rlo logs` (spec §2). Tails <installDir>/logs/service/{web,vault}.log.
3
+ */
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { spawn } from "node:child_process";
7
+ import { readConfig } from "./config.js";
8
+ export function logPathFor(installDir, which) {
9
+ return join(installDir, "logs", "service", `${which}.log`);
10
+ }
11
+ export function lastLines(filePath, n) {
12
+ if (!existsSync(filePath))
13
+ return `(no log file at ${filePath})\n`;
14
+ const content = readFileSync(filePath, "utf8");
15
+ const lines = content.split("\n");
16
+ return lines.slice(Math.max(0, lines.length - n - 1)).join("\n");
17
+ }
18
+ export async function logsCommand(flags) {
19
+ const config = readConfig();
20
+ if (!config) {
21
+ console.error("No Arel OS install found. Run `npx arelos` to install.");
22
+ return 1;
23
+ }
24
+ const targets = flags.which === "both" ? ["web", "vault"] : [flags.which];
25
+ const paths = targets.map((t) => logPathFor(config.installDir, t));
26
+ if (flags.follow) {
27
+ const existing = paths.filter((p) => existsSync(p));
28
+ if (existing.length === 0) {
29
+ console.error("No log files found yet.");
30
+ return 1;
31
+ }
32
+ const child = spawn("tail", ["-f", ...existing], { stdio: "inherit" });
33
+ await new Promise((resolve) => child.on("close", resolve));
34
+ return 0;
35
+ }
36
+ for (const [i, target] of targets.entries()) {
37
+ if (targets.length > 1)
38
+ console.log(`\n==> ${target} <==`);
39
+ console.log(lastLines(paths[i], flags.lines));
40
+ }
41
+ return 0;
42
+ }
package/dist/paths.js ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Central place for the fixed, well-known paths `rlo` reads/writes.
3
+ * Everything here honors ARELOS_CONFIG_PATH so tests/dry-runs never touch
4
+ * the real ~/.arelos.
5
+ */
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ export function configPath() {
9
+ return process.env.ARELOS_CONFIG_PATH ?? join(homedir(), ".arelos", "config.json");
10
+ }
11
+ export function configDir() {
12
+ return join(configPath(), "..");
13
+ }
14
+ export function launchAgentsDir() {
15
+ return join(homedir(), "Library", "LaunchAgents");
16
+ }
17
+ export const WEB_LABEL = "com.arelos.web";
18
+ export const VAULT_LABEL = "com.arelos.vault";
19
+ export function plistPath(label) {
20
+ return join(launchAgentsDir(), `${label}.plist`);
21
+ }
22
+ /** Expand a leading `~` to the user's home directory. */
23
+ export function expandHome(p) {
24
+ if (p === "~")
25
+ return homedir();
26
+ if (p.startsWith("~/"))
27
+ return join(homedir(), p.slice(2));
28
+ return p;
29
+ }
package/dist/plist.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Plist template rendering (portability-contract.md §3.1). Templates ship at
3
+ * `scripts/service/com.arelos.{web,vault}.plist.tmpl` in the app repo and
4
+ * take a single {{INSTALL_DIR}} token — ports/vaultPath are read from config
5
+ * at process start by the run-*.sh scripts / server, never baked into the plist.
6
+ */
7
+ export function renderPlistTemplate(template, installDir) {
8
+ return template.split("{{INSTALL_DIR}}").join(installDir);
9
+ }
10
+ /** Basic structural sanity check without shelling out to `plutil` (used in unit tests; `plutil -lint` is used in the dry-run for the real macOS check). */
11
+ export function looksLikeValidPlist(xml) {
12
+ return (xml.includes("<?xml") &&
13
+ xml.includes("<!DOCTYPE plist") &&
14
+ xml.includes("<plist") &&
15
+ xml.includes("</plist>") &&
16
+ !xml.includes("{{"));
17
+ }
package/dist/ports.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Zero-dependency free-port detection via node:net (spec §1 Step 5).
3
+ */
4
+ import { createServer } from "node:net";
5
+ /** Resolve true if `port` is free to bind on 127.0.0.1, false if taken. */
6
+ export function isPortFree(port) {
7
+ return new Promise((resolve) => {
8
+ const server = createServer();
9
+ server.once("error", (err) => {
10
+ server.close();
11
+ if (err.code === "EADDRINUSE" || err.code === "EACCES") {
12
+ resolve(false);
13
+ }
14
+ else {
15
+ // Unexpected error probing the port — treat conservatively as taken
16
+ // so we never suggest a port we can't actually validate.
17
+ resolve(false);
18
+ }
19
+ });
20
+ server.once("listening", () => {
21
+ server.close(() => resolve(true));
22
+ });
23
+ server.listen(port, "127.0.0.1");
24
+ });
25
+ }
26
+ /**
27
+ * Find the first free port at or above `start`, scanning upward. Stops at
28
+ * `start + maxScan` to avoid an unbounded loop.
29
+ */
30
+ export async function findFreePort(start, maxScan = 200) {
31
+ for (let port = start; port < start + maxScan; port++) {
32
+ if (port <= 1023)
33
+ continue;
34
+ if (await isPortFree(port))
35
+ return port;
36
+ }
37
+ throw new Error(`No free port found in range ${start}-${start + maxScan}`);
38
+ }
39
+ export function isValidPort(port) {
40
+ return Number.isInteger(port) && port > 1023 && port <= 65535;
41
+ }
package/dist/repair.js ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Existing-install repair/update menu (spec §1 Step 0, §3.1). Shown whenever
3
+ * ~/.arelos/config.json already exists so a re-run of `npx arelos` never
4
+ * silently reinstalls.
5
+ */
6
+ import * as p from "@clack/prompts";
7
+ import pc from "picocolors";
8
+ import { runUpdate } from "./update.js";
9
+ import { waitForHealthy } from "./health.js";
10
+ import { bootstrapAndStart, installServiceFiles } from "./services.js";
11
+ import { runStreaming } from "./exec.js";
12
+ import { ensureBun } from "./bun-setup.js";
13
+ export async function runRepairMenu(existing) {
14
+ p.intro(pc.bold("Existing Arel OS install detected"));
15
+ p.note([
16
+ `Name: ${existing.displayName}`,
17
+ `Install dir: ${existing.installDir}`,
18
+ `Vault path: ${existing.vaultPath}`,
19
+ `Web port: ${existing.webPort}`,
20
+ `Vault port: ${existing.vaultPort}`,
21
+ ].join("\n"), "Detected install");
22
+ const choice = await p.select({
23
+ message: "What would you like to do?",
24
+ options: [
25
+ { value: "update", label: "Update", hint: "git pull + rebuild + restart" },
26
+ { value: "repair", label: "Repair", hint: "re-render services + rebuild, keep vault & config" },
27
+ { value: "reinstall", label: "Reinstall elsewhere", hint: "fresh install at a new location" },
28
+ { value: "cancel", label: "Cancel" },
29
+ ],
30
+ });
31
+ if (p.isCancel(choice) || choice === "cancel") {
32
+ p.cancel("Cancelled.");
33
+ return 1;
34
+ }
35
+ if (choice === "update") {
36
+ return runUpdate(existing);
37
+ }
38
+ if (choice === "repair") {
39
+ return runRepair(existing);
40
+ }
41
+ if (choice === "reinstall") {
42
+ p.log.message("Reinstall elsewhere is not automatic yet — run `arelos uninstall` first if you want to reuse this " +
43
+ "install dir/ports, or re-run `npx arelos --install-dir <new path> --web-port <n> --vault-port <n>` " +
44
+ "for a side-by-side install with a different ARELOS_CONFIG_PATH.");
45
+ return 0;
46
+ }
47
+ return 1;
48
+ }
49
+ async function runRepair(existing) {
50
+ const s = p.spinner();
51
+ s.start("Ensuring Bun…");
52
+ const bunResult = await ensureBun();
53
+ if ("error" in bunResult) {
54
+ s.stop("Bun check failed.");
55
+ console.error(pc.red(bunResult.error));
56
+ return 1;
57
+ }
58
+ s.stop("Bun ready.");
59
+ s.start("Reinstalling dependencies and rebuilding…");
60
+ const installRes = await runStreaming(bunResult.bunBin, ["install"], { cwd: existing.installDir });
61
+ const buildRes = installRes.code === 0
62
+ ? await runStreaming(bunResult.bunBin, ["run", "build"], { cwd: existing.installDir })
63
+ : installRes;
64
+ if (buildRes.code !== 0) {
65
+ s.stop("Build failed.");
66
+ return 1;
67
+ }
68
+ s.stop("Rebuilt.");
69
+ s.start("Re-rendering and re-bootstrapping services…");
70
+ installServiceFiles(existing.installDir);
71
+ const bootstrap = await bootstrapAndStart();
72
+ s.stop(bootstrap.errors.length ? "Services re-registered with warnings." : "Services re-registered.");
73
+ for (const e of bootstrap.errors)
74
+ console.error(pc.yellow(e));
75
+ s.start("Health check…");
76
+ const health = await waitForHealthy(existing.webPort, existing.vaultPort);
77
+ s.stop(health.healthy ? "Healthy." : "Health check timed out — see arelos logs.");
78
+ return health.healthy ? 0 : 1;
79
+ }
package/dist/repo.js ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Getting the app source onto disk (spec §1 Step 8) and keeping it updated
3
+ * (spec §2 rlo update). Supports a `--local-repo <path>` override for
4
+ * development/dry-run testing so we never have to hit GitHub in tests.
5
+ */
6
+ import { existsSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { runCapture, runStreaming } from "./exec.js";
9
+ export const DEFAULT_REPO_URL = "https://github.com/vishmathpati/arel-os";
10
+ export const DEFAULT_BRANCH = "main";
11
+ export function isGitCheckout(dir) {
12
+ return existsSync(join(dir, ".git"));
13
+ }
14
+ /**
15
+ * Get the app source into installDir. If installDir is already a git
16
+ * checkout (existing install / repair), this is a no-op — caller should use
17
+ * `pullLatest` instead. Otherwise clones fresh.
18
+ *
19
+ * `sourcePath` (the --local-repo override) copies a local working tree via
20
+ * `git clone <local path>` instead of hitting GitHub — this keeps `git`
21
+ * semantics (still a real .git checkout, `git pull` still works against the
22
+ * local origin) while avoiding a network dependency in dev/dry-run.
23
+ */
24
+ export async function cloneRepo(installDir, opts = {}) {
25
+ const source = opts.sourcePath ?? opts.repoUrl ?? DEFAULT_REPO_URL;
26
+ const args = ["clone", "--depth", "1", "--branch", opts.branch ?? DEFAULT_BRANCH, source, installDir];
27
+ const res = await runStreaming("git", args);
28
+ return { code: res.code, stderr: res.stderr };
29
+ }
30
+ export async function pullLatest(installDir) {
31
+ const statusRes = await runCapture("git", ["-C", installDir, "status", "--porcelain"]);
32
+ const dirty = statusRes.stdout.trim().length > 0;
33
+ if (dirty) {
34
+ return { code: 1, dirty: true, stderr: "working tree has uncommitted changes" };
35
+ }
36
+ const res = await runStreaming("git", ["-C", installDir, "pull", "--ff-only"]);
37
+ return { code: res.code, dirty: false, stderr: res.stderr };
38
+ }
39
+ export async function currentRevision(installDir) {
40
+ const res = await runCapture("git", ["-C", installDir, "rev-parse", "--short", "HEAD"]);
41
+ return res.code === 0 ? res.stdout.trim() : null;
42
+ }
43
+ /** Best-effort "is the local branch behind origin" check (spec §2 rlo status). */
44
+ export async function isBehindOrigin(installDir) {
45
+ const fetchRes = await runCapture("git", ["-C", installDir, "fetch", "--dry-run"]);
46
+ if (fetchRes.code !== 0)
47
+ return null;
48
+ const countRes = await runCapture("git", ["-C", installDir, "rev-list", "--count", "HEAD..@{u}"]);
49
+ if (countRes.code !== 0)
50
+ return null;
51
+ const count = Number(countRes.stdout.trim());
52
+ return Number.isFinite(count) ? count > 0 : null;
53
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Vault scaffolding + supporting install-dir setup (spec §1 Step 9).
3
+ * Only copies the template vault when the destination is empty — never
4
+ * overwrites user data (spec §3.2 idempotency rule).
5
+ */
6
+ import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ export class TemplateVaultMissingError extends Error {
9
+ constructor(templateDir) {
10
+ super(`templates/vault/ is missing at ${templateDir}. This is a repo gap — ` +
11
+ `the app repo must ship a seed vault at templates/vault/ (see portability-contract.md / rlo-cli-spec.md §1 Step 9). Cannot scaffold a new vault without it.`);
12
+ this.name = "TemplateVaultMissingError";
13
+ }
14
+ }
15
+ export function isDirEmpty(dir) {
16
+ if (!existsSync(dir))
17
+ return true;
18
+ return readdirSync(dir).length === 0;
19
+ }
20
+ /**
21
+ * Copy templates/vault/** from the cloned app repo into vaultPath, only if
22
+ * vaultPath is empty/absent. Throws TemplateVaultMissingError if the source
23
+ * template is absent in the repo (hard error per spec §3.3).
24
+ */
25
+ export function scaffoldVault(installDir, vaultPath) {
26
+ const templateDir = join(installDir, "templates", "vault");
27
+ if (!existsSync(templateDir)) {
28
+ throw new TemplateVaultMissingError(templateDir);
29
+ }
30
+ if (!isDirEmpty(vaultPath)) {
31
+ return { copied: false };
32
+ }
33
+ mkdirSync(vaultPath, { recursive: true });
34
+ cpSync(templateDir, vaultPath, { recursive: true });
35
+ return { copied: true };
36
+ }
37
+ /** Create <installDir>/logs/service/ — must exist before launchd bootstraps
38
+ * the plists, since they reference it for stdout/stderr (spec, implementer notes). */
39
+ export function ensureLogsDir(installDir) {
40
+ const dir = join(installDir, "logs", "service");
41
+ mkdirSync(dir, { recursive: true });
42
+ return dir;
43
+ }
44
+ /** Write .env from .env.example if absent; never overwrite an existing .env. */
45
+ export function ensureEnvFile(installDir) {
46
+ const envPath = join(installDir, ".env");
47
+ const examplePath = join(installDir, ".env.example");
48
+ if (existsSync(envPath))
49
+ return { created: false };
50
+ if (!existsSync(examplePath))
51
+ return { created: false };
52
+ cpSync(examplePath, envPath);
53
+ return { created: true };
54
+ }