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,66 @@
1
+ /**
2
+ * Bun install/detection (spec §1 Step 7). npx only guarantees Node — Bun is
3
+ * installed during setup if missing, via the official installer script.
4
+ */
5
+ import { existsSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { commandExists, runCapture, runStreaming } from "./exec.js";
9
+ const MIN_BUN_MAJOR = 1;
10
+ const MIN_BUN_MINOR = 1;
11
+ export function resolvedBunPath() {
12
+ return join(homedir(), ".bun", "bin", "bun");
13
+ }
14
+ export function findBun() {
15
+ if (commandExists("bun"))
16
+ return "bun";
17
+ const resolved = resolvedBunPath();
18
+ if (existsSync(resolved))
19
+ return resolved;
20
+ return null;
21
+ }
22
+ export function parseBunVersion(versionOutput) {
23
+ const match = versionOutput.trim().match(/^(\d+)\.(\d+)/);
24
+ if (!match)
25
+ return null;
26
+ return { major: Number(match[1]), minor: Number(match[2]) };
27
+ }
28
+ export function meetsMinimumVersion(version) {
29
+ if (!version)
30
+ return false;
31
+ if (version.major !== MIN_BUN_MAJOR)
32
+ return version.major > MIN_BUN_MAJOR;
33
+ return version.minor >= MIN_BUN_MINOR;
34
+ }
35
+ export async function bunVersion(bunBin) {
36
+ const res = await runCapture(bunBin, ["--version"]);
37
+ if (res.code !== 0)
38
+ return null;
39
+ return parseBunVersion(res.stdout);
40
+ }
41
+ export const MANUAL_BUN_INSTALL_HINT = "curl -fsSL https://bun.sh/install | bash";
42
+ /** Install Bun via the official installer script. Returns the resolved bun binary path, or null on failure. */
43
+ export async function installBun() {
44
+ const res = await runStreaming("bash", ["-c", "curl -fsSL https://bun.sh/install | bash"]);
45
+ if (res.code !== 0)
46
+ return null;
47
+ const resolved = resolvedBunPath();
48
+ return existsSync(resolved) ? resolved : null;
49
+ }
50
+ /** Ensure a usable Bun is present, installing it if missing. Returns the bun binary to invoke. */
51
+ export async function ensureBun() {
52
+ let bunBin = findBun();
53
+ if (!bunBin) {
54
+ bunBin = await installBun();
55
+ if (!bunBin) {
56
+ return { error: `Bun install failed. Install manually: ${MANUAL_BUN_INSTALL_HINT}` };
57
+ }
58
+ }
59
+ const version = await bunVersion(bunBin);
60
+ if (!meetsMinimumVersion(version)) {
61
+ return {
62
+ error: `Bun ${version ? `${version.major}.${version.minor}` : "(unknown)"} found, but >= ${MIN_BUN_MAJOR}.${MIN_BUN_MINOR} is required. Reinstall: ${MANUAL_BUN_INSTALL_HINT}`,
63
+ };
64
+ }
65
+ return { bunBin };
66
+ }
@@ -0,0 +1,62 @@
1
+ export function parseInstallFlags(argv) {
2
+ const flags = {
3
+ yes: false,
4
+ noService: false,
5
+ localRepo: null,
6
+ displayName: null,
7
+ installDir: null,
8
+ vaultPath: null,
9
+ webPort: null,
10
+ vaultPort: null,
11
+ };
12
+ for (let i = 0; i < argv.length; i++) {
13
+ const arg = argv[i];
14
+ switch (arg) {
15
+ case "--yes":
16
+ case "--defaults":
17
+ flags.yes = true;
18
+ break;
19
+ case "--no-service":
20
+ flags.noService = true;
21
+ break;
22
+ case "--local-repo":
23
+ flags.localRepo = argv[++i] ?? null;
24
+ break;
25
+ case "--display-name":
26
+ flags.displayName = argv[++i] ?? null;
27
+ break;
28
+ case "--install-dir":
29
+ flags.installDir = argv[++i] ?? null;
30
+ break;
31
+ case "--vault-path":
32
+ flags.vaultPath = argv[++i] ?? null;
33
+ break;
34
+ case "--web-port":
35
+ flags.webPort = Number(argv[++i]);
36
+ break;
37
+ case "--vault-port":
38
+ flags.vaultPort = Number(argv[++i]);
39
+ break;
40
+ default:
41
+ break;
42
+ }
43
+ }
44
+ return flags;
45
+ }
46
+ export function parseLogsFlags(argv) {
47
+ let which = "both";
48
+ let follow = false;
49
+ let lines = 100;
50
+ for (let i = 0; i < argv.length; i++) {
51
+ const arg = argv[i];
52
+ if (arg === "web" || arg === "vault")
53
+ which = arg;
54
+ else if (arg === "-f" || arg === "--follow")
55
+ follow = true;
56
+ else if (arg === "-n")
57
+ lines = Number(argv[++i]) || 100;
58
+ else if (arg.startsWith("-n="))
59
+ lines = Number(arg.slice(3)) || 100;
60
+ }
61
+ return { which, follow, lines };
62
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * arelos — the Arel OS installer/service manager. `npx arelos` with no subcommand
4
+ * runs the interactive install flow (rlo-cli-spec.md §1).
5
+ */
6
+ if (process.platform !== "darwin") {
7
+ console.error("Arel OS currently supports macOS only.");
8
+ process.exit(1);
9
+ }
10
+ import { parseInstallFlags, parseLogsFlags } from "./cli-args.js";
11
+ import { runInstall } from "./install.js";
12
+ import { logsCommand } from "./logs.js";
13
+ import { statusCommand } from "./status.js";
14
+ import { uninstallCommand } from "./uninstall.js";
15
+ import { updateCommand } from "./update.js";
16
+ async function main() {
17
+ const argv = process.argv.slice(2);
18
+ if (argv[0] === "--help" || argv[0] === "-h" || argv[0] === "help") {
19
+ printHelp();
20
+ return 0;
21
+ }
22
+ // No subcommand given, or the first token is a flag (e.g. `arelos --yes ...`)
23
+ // rather than a subcommand name — both mean "install". Anything else in
24
+ // the known set consumes its name as the subcommand; everything after it
25
+ // is passed through as that subcommand's own args.
26
+ const knownSubcommands = new Set(["install", "status", "update", "uninstall", "logs"]);
27
+ const firstIsFlag = argv.length === 0 || argv[0].startsWith("-");
28
+ const firstIsKnown = argv.length > 0 && knownSubcommands.has(argv[0]);
29
+ if (!firstIsFlag && !firstIsKnown) {
30
+ console.error(`Unknown command: ${argv[0]}\n`);
31
+ printHelp();
32
+ return 1;
33
+ }
34
+ const hasExplicitSubcommand = firstIsKnown;
35
+ const subcommand = hasExplicitSubcommand ? argv[0] : "install";
36
+ const rest = hasExplicitSubcommand ? argv.slice(1) : argv;
37
+ switch (subcommand) {
38
+ case "install":
39
+ return runInstall(rest, parseInstallFlags(rest));
40
+ case "status":
41
+ return statusCommand();
42
+ case "update":
43
+ return updateCommand();
44
+ case "uninstall":
45
+ return uninstallCommand();
46
+ case "logs":
47
+ return logsCommand(parseLogsFlags(rest));
48
+ default:
49
+ console.error(`Unknown command: ${subcommand}\n`);
50
+ printHelp();
51
+ return 1;
52
+ }
53
+ }
54
+ function printHelp() {
55
+ console.log(`arelos — install and manage a self-hosted Arel OS
56
+
57
+ Usage:
58
+ npx arelos Install (interactive)
59
+ arelos status Show install + service status
60
+ arelos update git pull + rebuild + restart
61
+ arelos uninstall Stop services, optionally remove install dir / vault
62
+ arelos logs [web|vault] Tail service logs (-f to follow, -n <N> for line count)
63
+
64
+ Install flags (non-interactive):
65
+ --yes, --defaults Skip prompts, use defaults/flags below
66
+ --display-name <name>
67
+ --install-dir <path>
68
+ --vault-path <path>
69
+ --web-port <port>
70
+ --vault-port <port>
71
+ --no-service Skip launchd bootstrap (for dry runs / development)
72
+ --local-repo <path> Use a local path instead of cloning from GitHub
73
+ `);
74
+ }
75
+ main()
76
+ .then((code) => process.exit(code))
77
+ .catch((err) => {
78
+ console.error(err instanceof Error ? err.stack ?? err.message : err);
79
+ process.exit(1);
80
+ });
package/dist/config.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * rlo's own config read/write helpers — mirrors the shape read by
3
+ * server/config.ts (portability-contract.md §1.1/§1.2). rlo is the writer,
4
+ * the app is the reader; this is the one contract between them.
5
+ */
6
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+ import { configPath } from "./paths.js";
9
+ export function readConfig() {
10
+ const p = configPath();
11
+ if (!existsSync(p))
12
+ return null;
13
+ const raw = JSON.parse(readFileSync(p, "utf8"));
14
+ if (typeof raw !== "object" || raw === null || raw.version !== 1) {
15
+ throw new Error(`Invalid or unsupported config at ${p}: expected { version: 1, ... }`);
16
+ }
17
+ return raw;
18
+ }
19
+ /**
20
+ * Write config atomically: write to a .tmp sibling then rename over the
21
+ * target. Guarantees a reader never observes a partially written file
22
+ * (spec §3.2 "Config write is last-writer-wins; never partially written").
23
+ */
24
+ export function writeConfig(config) {
25
+ const p = configPath();
26
+ mkdirSync(dirname(p), { recursive: true });
27
+ const tmp = `${p}.tmp`;
28
+ writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
29
+ renameSync(tmp, p);
30
+ }
package/dist/exec.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Thin wrappers over node:child_process so the rest of the CLI never calls
3
+ * execFileSync/spawn directly — keeps side effects easy to find and stub.
4
+ */
5
+ import { execFileSync, spawn } from "node:child_process";
6
+ /** Run a command to completion, streaming its stdout/stderr to ours. */
7
+ export function runStreaming(cmd, args, opts = {}) {
8
+ return new Promise((resolve, reject) => {
9
+ const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], ...opts });
10
+ let stdout = "";
11
+ let stderr = "";
12
+ child.stdout?.on("data", (d) => {
13
+ stdout += d.toString();
14
+ process.stdout.write(d);
15
+ });
16
+ child.stderr?.on("data", (d) => {
17
+ stderr += d.toString();
18
+ process.stderr.write(d);
19
+ });
20
+ child.on("error", reject);
21
+ child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
22
+ });
23
+ }
24
+ /** Run a command silently, capturing output, never throwing on nonzero exit. */
25
+ export function runCapture(cmd, args, opts = {}) {
26
+ return new Promise((resolve) => {
27
+ const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], ...opts });
28
+ let stdout = "";
29
+ let stderr = "";
30
+ child.stdout?.on("data", (d) => (stdout += d.toString()));
31
+ child.stderr?.on("data", (d) => (stderr += d.toString()));
32
+ child.on("error", (err) => resolve({ code: 1, stdout, stderr: String(err) }));
33
+ child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
34
+ });
35
+ }
36
+ /** True if `cmd` exists on PATH and runs without error (e.g. `git --version`). */
37
+ export function commandExists(cmd, versionArgs = ["--version"]) {
38
+ try {
39
+ execFileSync(cmd, versionArgs, { stdio: "ignore" });
40
+ return true;
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
package/dist/health.js ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * HTTP health probes for the vault (`/config`) and web (`/`) services
3
+ * (spec §1 Step 12, §2 rlo status). Pure fetch-based, no deps.
4
+ */
5
+ async function fetchWithTimeout(url, timeoutMs) {
6
+ const controller = new AbortController();
7
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
8
+ try {
9
+ return await fetch(url, { signal: controller.signal });
10
+ }
11
+ finally {
12
+ clearTimeout(timer);
13
+ }
14
+ }
15
+ export async function checkVaultHealth(vaultPort, timeoutMs = 3000) {
16
+ try {
17
+ const res = await fetchWithTimeout(`http://localhost:${vaultPort}/config`, timeoutMs);
18
+ if (!res.ok)
19
+ return { up: false, error: `HTTP ${res.status}` };
20
+ const body = (await res.json());
21
+ return { up: true, displayName: body.displayName, vaultPort: body.vaultPort };
22
+ }
23
+ catch (err) {
24
+ return { up: false, error: err instanceof Error ? err.message : String(err) };
25
+ }
26
+ }
27
+ export async function checkWebHealth(webPort, timeoutMs = 3000) {
28
+ try {
29
+ const res = await fetchWithTimeout(`http://localhost:${webPort}/`, timeoutMs);
30
+ return { up: res.ok, status: res.status };
31
+ }
32
+ catch (err) {
33
+ return { up: false, error: err instanceof Error ? err.message : String(err) };
34
+ }
35
+ }
36
+ /**
37
+ * Poll both services until healthy or timeout. Used post-install/update
38
+ * (spec §1 Step 12): vault must serve /config with the right port, web must
39
+ * return 200/HTML. The web service runs a full build on first start, so the
40
+ * default timeout is generous.
41
+ */
42
+ export async function waitForHealthy(webPort, vaultPort, opts = {}) {
43
+ const timeoutMs = opts.timeoutMs ?? 60_000;
44
+ const intervalMs = opts.intervalMs ?? 1500;
45
+ const start = Date.now();
46
+ let vault = { up: false };
47
+ let web = { up: false };
48
+ while (Date.now() - start < timeoutMs) {
49
+ vault = await checkVaultHealth(vaultPort);
50
+ web = await checkWebHealth(webPort);
51
+ opts.onTick?.(Date.now() - start);
52
+ if (vault.up && vault.vaultPort === vaultPort && web.up) {
53
+ return { healthy: true, vault, web };
54
+ }
55
+ await new Promise((r) => setTimeout(r, intervalMs));
56
+ }
57
+ return { healthy: false, vault, web };
58
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Pure planning/validation logic for the install flow, factored out of the
3
+ * interactive prompt wiring in install.ts so it can be unit tested without
4
+ * a TTY. Each function here validates one prompt's answer per rlo-cli-spec §1.
5
+ */
6
+ import { existsSync, readdirSync, statSync } from "node:fs";
7
+ import { accessSync, constants } from "node:fs";
8
+ import { dirname } from "node:path";
9
+ import { expandHome } from "./paths.js";
10
+ import { findFreePort, isValidPort } from "./ports.js";
11
+ export const DEFAULTS = {
12
+ displayName: "Arel OS",
13
+ installDir: "~/ArelOS",
14
+ vaultPathSuffix: "vault",
15
+ webPort: 1347,
16
+ vaultPort: 5274,
17
+ };
18
+ export function normalizeDisplayName(input) {
19
+ const trimmed = input.trim();
20
+ return trimmed.length > 0 ? trimmed : DEFAULTS.displayName;
21
+ }
22
+ export function checkInstallDir(rawPath) {
23
+ const path = expandHome(rawPath);
24
+ const parent = dirname(path);
25
+ let parentWritable = true;
26
+ try {
27
+ accessSync(existsSync(parent) ? parent : dirname(parent), constants.W_OK);
28
+ }
29
+ catch {
30
+ parentWritable = false;
31
+ }
32
+ const exists = existsSync(path);
33
+ let nonEmpty = false;
34
+ let isPriorArelosInstall = false;
35
+ if (exists && statSync(path).isDirectory()) {
36
+ const entries = readdirSync(path);
37
+ nonEmpty = entries.length > 0;
38
+ isPriorArelosInstall = entries.includes(".git") && entries.includes("package.json");
39
+ }
40
+ return { path, parentWritable, exists, nonEmpty, isPriorArelosInstall };
41
+ }
42
+ export function defaultVaultPath(installDir) {
43
+ return `${expandHome(installDir)}/${DEFAULTS.vaultPathSuffix}`;
44
+ }
45
+ /** Validate a chosen port and, if it's taken, propose the next free one. */
46
+ export async function resolvePort(requested) {
47
+ if (!isValidPort(requested)) {
48
+ throw new Error(`Port ${requested} is invalid — must be an integer between 1024 and 65535.`);
49
+ }
50
+ const { isPortFree } = await import("./ports.js");
51
+ const free = await isPortFree(requested);
52
+ if (free)
53
+ return { requested, resolved: requested, wasFree: true };
54
+ const suggestion = await findFreePort(requested + 1);
55
+ return { requested, resolved: suggestion, wasFree: false };
56
+ }
57
+ export function toArelConfig(answers) {
58
+ return {
59
+ version: 1,
60
+ displayName: answers.displayName,
61
+ installDir: expandHome(answers.installDir),
62
+ vaultPath: expandHome(answers.vaultPath),
63
+ webPort: answers.webPort,
64
+ vaultPort: answers.vaultPort,
65
+ };
66
+ }