arelos 0.1.1 → 0.2.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,41 @@
1
+ /**
2
+ * Global multi-install registry (~/.arelos/installs.json, or
3
+ * ARELOS_REGISTRY_PATH override). Each entry records one named, self-contained
4
+ * install so `arelos` can find/list/select among several installs on one Mac
5
+ * without any of them clobbering a shared config file (0.2.0 mission).
6
+ */
7
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
8
+ import { dirname } from "node:path";
9
+ import { registryPath } from "./paths.js";
10
+ export function readRegistry() {
11
+ const p = registryPath();
12
+ if (!existsSync(p))
13
+ return [];
14
+ const raw = JSON.parse(readFileSync(p, "utf8"));
15
+ if (!Array.isArray(raw)) {
16
+ throw new Error(`Invalid registry at ${p}: expected an array`);
17
+ }
18
+ return raw;
19
+ }
20
+ /** Atomic write: tmp file + rename, so a reader never sees a partial file. */
21
+ function writeRegistry(entries) {
22
+ const p = registryPath();
23
+ mkdirSync(dirname(p), { recursive: true });
24
+ const tmp = `${p}.tmp`;
25
+ writeFileSync(tmp, `${JSON.stringify(entries, null, 2)}\n`, { mode: 0o600 });
26
+ renameSync(tmp, p);
27
+ }
28
+ /** Append (or replace, by matching root) an entry — a reinstall at the same root updates it in place. */
29
+ export function addRegistryEntry(entry) {
30
+ const entries = readRegistry().filter((e) => e.root !== entry.root);
31
+ entries.push(entry);
32
+ writeRegistry(entries);
33
+ }
34
+ /** Remove the entry for a given root (uninstall). No-op if absent. */
35
+ export function removeRegistryEntry(root) {
36
+ const entries = readRegistry().filter((e) => e.root !== root);
37
+ writeRegistry(entries);
38
+ }
39
+ export function findRegistryEntryByName(name) {
40
+ return readRegistry().find((e) => e.name === name || e.slug === name) ?? null;
41
+ }
package/dist/repair.js CHANGED
@@ -5,9 +5,10 @@
5
5
  */
6
6
  import * as p from "@clack/prompts";
7
7
  import pc from "picocolors";
8
- import { resolveServiceLabels } from "./config.js";
8
+ import { resolveRoot, resolveServiceLabels } from "./config.js";
9
9
  import { runUpdate } from "./update.js";
10
- import { waitForHealthy } from "./health.js";
10
+ import { formatHealthTimeoutDiagnostics, waitForHealthy } from "./health.js";
11
+ import { lastLines, logPathFor } from "./logs.js";
11
12
  import { bootstrapAndStart, installServiceFiles } from "./services.js";
12
13
  import { runStreaming } from "./exec.js";
13
14
  import { ensureBun } from "./bun-setup.js";
@@ -40,9 +41,8 @@ export async function runRepairMenu(existing) {
40
41
  return runRepair(existing);
41
42
  }
42
43
  if (choice === "reinstall") {
43
- p.log.message("Reinstall elsewhere is not automatic yet run `arelos uninstall` first if you want to reuse this " +
44
- "install dir/ports, or re-run `npx arelos --install-dir <new path> --web-port <n> --vault-port <n>` " +
45
- "for a side-by-side install with a different ARELOS_CONFIG_PATH.");
44
+ p.log.message("Just re-run `npx arelos` with a different name every install is self-contained, so a new name " +
45
+ "creates a fresh, independent install side-by-side with this one (see `arelos list`).");
46
46
  return 0;
47
47
  }
48
48
  return 1;
@@ -69,13 +69,17 @@ async function runRepair(existing) {
69
69
  s.stop("Rebuilt.");
70
70
  s.start("Re-rendering and re-bootstrapping services…");
71
71
  const labels = resolveServiceLabels(existing);
72
- installServiceFiles(existing.installDir, labels);
72
+ installServiceFiles(existing.installDir, resolveRoot(existing), labels);
73
73
  const bootstrap = await bootstrapAndStart(labels);
74
74
  s.stop(bootstrap.errors.length ? "Services re-registered with warnings." : "Services re-registered.");
75
75
  for (const e of bootstrap.errors)
76
76
  console.error(pc.yellow(e));
77
77
  s.start("Health check…");
78
78
  const health = await waitForHealthy(existing.webPort, existing.vaultPort);
79
- s.stop(health.healthy ? "Healthy." : "Health check timed out — see arelos logs.");
79
+ s.stop(health.healthy ? "Healthy." : "Health check timed out.");
80
+ if (!health.healthy) {
81
+ console.error(pc.red(formatHealthTimeoutDiagnostics(resolveRoot(existing), (p) => lastLines(p, 10), logPathFor)));
82
+ console.error(pc.dim("\nFull logs: arelos logs"));
83
+ }
80
84
  return health.healthy ? 0 : 1;
81
85
  }
package/dist/repo.js CHANGED
@@ -1,7 +1,7 @@
1
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.
2
+ * Getting the app source onto disk and keeping it updated (`arelos update`).
3
+ * Supports a `--local-repo <path>` override for development/dry-run testing
4
+ * so we never have to hit GitHub in tests.
5
5
  */
6
6
  import { existsSync } from "node:fs";
7
7
  import { join } from "node:path";
@@ -40,7 +40,7 @@ export async function currentRevision(installDir) {
40
40
  const res = await runCapture("git", ["-C", installDir, "rev-parse", "--short", "HEAD"]);
41
41
  return res.code === 0 ? res.stdout.trim() : null;
42
42
  }
43
- /** Best-effort "is the local branch behind origin" check (spec §2 rlo status). */
43
+ /** Best-effort "is the local branch behind origin" check (`arelos status`). */
44
44
  export async function isBehindOrigin(installDir) {
45
45
  const fetchRes = await runCapture("git", ["-C", installDir, "fetch", "--dry-run"]);
46
46
  if (fetchRes.code !== 0)
package/dist/scaffold.js CHANGED
@@ -1,14 +1,13 @@
1
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).
2
+ * Vault scaffolding + supporting install-dir setup. Only copies the template
3
+ * vault when the destination is empty — never overwrites user data.
5
4
  */
6
5
  import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
7
6
  import { join } from "node:path";
8
7
  export class TemplateVaultMissingError extends Error {
9
8
  constructor(templateDir) {
10
9
  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.`);
10
+ `the app repo must ship a seed vault at templates/vault/. Cannot scaffold a new vault without it.`);
12
11
  this.name = "TemplateVaultMissingError";
13
12
  }
14
13
  }
@@ -34,10 +33,11 @@ export function scaffoldVault(installDir, vaultPath) {
34
33
  cpSync(templateDir, vaultPath, { recursive: true });
35
34
  return { copied: true };
36
35
  }
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");
36
+ /** Create <root>/logs/service/ — must exist before launchd bootstraps the
37
+ * plists, since they reference it for stdout/stderr. Logs live at the
38
+ * self-contained root, not inside the app checkout (0.2.0 layout). */
39
+ export function ensureLogsDir(root) {
40
+ const dir = join(root, "logs", "service");
41
41
  mkdirSync(dir, { recursive: true });
42
42
  return dir;
43
43
  }
package/dist/services.js CHANGED
@@ -1,14 +1,18 @@
1
1
  /**
2
- * Ties together plist rendering + writing + launchd bootstrap (spec §1 Step 11,
3
- * §3.1 Repair). Also used by `--no-service` dry runs, which stop before the
4
- * launchctl calls (see install.ts).
2
+ * Ties together plist rendering + writing + launchd bootstrap. Also used by
3
+ * `--no-service` dry runs, which stop before the launchctl calls (see
4
+ * install.ts).
5
+ *
6
+ * `installDir` is the app checkout (root/app); `root` is the self-contained
7
+ * install root that owns logs/ and config.json (0.2.0 layout). Both are
8
+ * needed because the plist bakes in both {{INSTALL_DIR}} and {{ROOT_DIR}}.
5
9
  */
6
10
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
11
  import { join } from "node:path";
8
12
  import { bootstrapService, kickstartService } from "./launchd.js";
9
13
  import { deriveServiceLabels, launchAgentsDir, plistPath } from "./paths.js";
10
14
  import { renderPlistTemplate } from "./plist.js";
11
- export function renderServicePlists(installDir, labels = deriveServiceLabels(installDir)) {
15
+ export function renderServicePlists(installDir, root, labels = deriveServiceLabels(root)) {
12
16
  const specs = [
13
17
  { label: labels.web, templateFile: "web.plist.tmpl" },
14
18
  { label: labels.vault, templateFile: "vault.plist.tmpl" },
@@ -19,22 +23,22 @@ export function renderServicePlists(installDir, labels = deriveServiceLabels(ins
19
23
  throw new Error(`Missing plist template: ${templatePath}`);
20
24
  }
21
25
  const template = readFileSync(templatePath, "utf8");
22
- const xml = renderPlistTemplate(template, installDir, label);
26
+ const xml = renderPlistTemplate(template, installDir, root, label);
23
27
  return { label, templatePath, targetPath: plistPath(label), xml };
24
28
  });
25
29
  }
26
30
  /** Write rendered plists to ~/Library/LaunchAgents and chmod the run scripts. */
27
- export function installServiceFiles(installDir, labels = deriveServiceLabels(installDir)) {
31
+ export function installServiceFiles(installDir, root, labels = deriveServiceLabels(root)) {
28
32
  mkdirSync(launchAgentsDir(), { recursive: true });
29
- // The plists' StandardOutPath/StandardErrorPath point at <installDir>/logs/service/*.log;
33
+ // The plists' StandardOutPath/StandardErrorPath point at <root>/logs/service/*.log;
30
34
  // launchd needs that directory to exist before it can bootstrap the job (a
31
35
  // missing log dir is one confirmed cause of the "Bootstrap failed: 5:
32
36
  // Input/output error" field report — see launchd.ts docstring). install.ts
33
37
  // already calls ensureLogsDir before this, but repair/update funnel through
34
38
  // here too, so guarantee it unconditionally rather than relying on callers
35
39
  // to remember.
36
- mkdirSync(join(installDir, "logs", "service"), { recursive: true });
37
- const rendered = renderServicePlists(installDir, labels);
40
+ mkdirSync(join(root, "logs", "service"), { recursive: true });
41
+ const rendered = renderServicePlists(installDir, root, labels);
38
42
  for (const svc of rendered) {
39
43
  writeFileSync(svc.targetPath, svc.xml);
40
44
  }
package/dist/status.js CHANGED
@@ -1,19 +1,24 @@
1
1
  /**
2
- * `rlo status` (spec §2). Config summary + service state + port probes +
3
- * git revision / behind-origin check.
2
+ * `arelos status`. Config summary + service state + port probes + git revision /
3
+ * behind-origin check. With multiple installs registered, resolves by name
4
+ * (or prompts/lists per resolveInstall's rule).
4
5
  */
5
6
  import pc from "picocolors";
6
- import { readConfig, resolveServiceLabels } from "./config.js";
7
+ import { resolveServiceLabels } from "./config.js";
8
+ import { resolveInstall } from "./cli-context.js";
7
9
  import { checkVaultHealth, checkWebHealth } from "./health.js";
8
10
  import { getServiceStatus } from "./launchd.js";
9
11
  import { currentRevision, isBehindOrigin } from "./repo.js";
10
- export async function statusCommand() {
11
- const config = readConfig();
12
- if (!config) {
13
- console.error("No Arel OS install found. Run `npx arelos` to install.");
12
+ export async function statusCommand(name) {
13
+ const result = await resolveInstall({ name, interactive: process.stdout.isTTY === true });
14
+ if (!result.ok) {
15
+ console.error(result.message);
14
16
  return 1;
15
17
  }
18
+ const config = result.install.config;
16
19
  console.log(pc.bold(config.displayName));
20
+ if (config.root)
21
+ console.log(` Root: ${config.root}`);
17
22
  console.log(` Install dir: ${config.installDir}`);
18
23
  console.log(` Vault path: ${config.vaultPath}`);
19
24
  console.log(` Web port: ${config.webPort}`);
package/dist/uninstall.js CHANGED
@@ -1,31 +1,41 @@
1
1
  /**
2
- * `rlo uninstall` (spec §2). Vault deletion is gated behind confirm + a
3
- * literal typed "DELETE" — a single yes can never destroy notes (spec §3.2,
4
- * acceptance criterion 5). This module separates the pure decision logic
5
- * (shouldDeleteVault) from the destructive I/O (performUninstall) so the
6
- * gate can be unit tested without touching a filesystem.
2
+ * `arelos uninstall`. Vault deletion is gated behind confirm + a literal typed
3
+ * "DELETE" — a single yes can never destroy notes. This module separates the
4
+ * pure decision logic (shouldDeleteVault) from the destructive I/O
5
+ * (performUninstall) so the gate can be unit tested without touching a
6
+ * filesystem.
7
+ *
8
+ * 0.2.0 self-contained layout: installDir (root/app) and vaultPath (root/vault)
9
+ * are siblings under root, so removing installDir never touches the vault —
10
+ * the pre-0.2.0 "install dir vs vault" gate semantics carry over unchanged.
11
+ * Removing the registry entry is separate from folder/vault deletion (always
12
+ * done, since a stale registry entry pointing at a gone or kept-around
13
+ * install is never useful).
7
14
  */
8
15
  import * as p from "@clack/prompts";
9
16
  import pc from "picocolors";
10
17
  import { existsSync, rmSync, unlinkSync } from "node:fs";
11
- import { readConfig, resolveServiceLabels } from "./config.js";
18
+ import { resolveRoot, resolveServiceLabels } from "./config.js";
19
+ import { resolveInstall } from "./cli-context.js";
12
20
  import { bootoutService } from "./launchd.js";
13
- import { plistPath, configPath } from "./paths.js";
21
+ import { plistPath, installConfigPath, legacyConfigPath } from "./paths.js";
22
+ import { removeRegistryEntry } from "./registry.js";
14
23
  /**
15
24
  * Pure gate: vault is deleted iff confirmed AND the typed word is exactly
16
25
  * "DELETE". Any other input (including case variants, whitespace, empty)
17
- * preserves the vault. This is the load-bearing safety rule from the spec
18
- * kept as an isolated pure function so it's trivially unit-testable.
26
+ * preserves the vault. This is the load-bearing safety rule kept as an
27
+ * isolated pure function so it's trivially unit-testable.
19
28
  */
20
29
  export function shouldDeleteVault(confirmed, typedWord) {
21
30
  return confirmed === true && typedWord === "DELETE";
22
31
  }
23
- export async function uninstallCommand() {
24
- const config = readConfig();
25
- if (!config) {
26
- console.error("No Arel OS install found. Nothing to uninstall.");
32
+ export async function uninstallCommand(name) {
33
+ const result = await resolveInstall({ name, interactive: process.stdout.isTTY === true });
34
+ if (!result.ok) {
35
+ console.error(result.message);
27
36
  return 1;
28
37
  }
38
+ const { config, root } = result.install;
29
39
  p.intro(pc.bold("Uninstall Arel OS"));
30
40
  const labels = resolveServiceLabels(config);
31
41
  await bootoutService(labels.web);
@@ -60,26 +70,37 @@ export async function uninstallCommand() {
60
70
  }
61
71
  }
62
72
  const removeConfig = await p.confirm({
63
- message: "Remove the saved config (~/.arelos/config.json)? Keeping it lets a reinstall remember your settings.",
73
+ message: "Remove the saved config? Keeping it lets a reinstall remember your settings.",
64
74
  initialValue: false,
65
75
  });
66
- performUninstall(config.installDir, config.vaultPath, {
76
+ performUninstall(config.installDir, config.vaultPath, resolveRoot(config), {
67
77
  removeInstallDir: !p.isCancel(removeInstallDir) && removeInstallDir,
68
78
  deleteVault,
69
79
  removeConfig: !p.isCancel(removeConfig) && removeConfig,
70
80
  });
81
+ if (root)
82
+ removeRegistryEntry(root);
71
83
  p.outro(pc.green("Arel OS uninstalled." + (deleteVault ? "" : " Your vault was preserved.")));
72
84
  return 0;
73
85
  }
74
- /** Destructive I/O, isolated from prompt flow for testability via direct calls with explicit choices. */
75
- export function performUninstall(installDir, vaultPath, choices) {
86
+ /**
87
+ * Destructive I/O, isolated from prompt flow for testability via direct
88
+ * calls with explicit choices. `root` is passed separately from `installDir`
89
+ * so config.json (which lives at root, a parent of installDir) is removed
90
+ * correctly rather than assumed to live inside installDir.
91
+ */
92
+ export function performUninstall(installDir, vaultPath, root, choices) {
76
93
  if (choices.deleteVault && existsSync(vaultPath)) {
77
94
  rmSync(vaultPath, { recursive: true, force: true });
78
95
  }
79
96
  if (choices.removeInstallDir && existsSync(installDir)) {
80
97
  rmSync(installDir, { recursive: true, force: true });
81
98
  }
82
- if (choices.removeConfig && existsSync(configPath())) {
83
- unlinkSync(configPath());
99
+ if (choices.removeConfig) {
100
+ const cfgPath = installConfigPath(root);
101
+ if (existsSync(cfgPath))
102
+ unlinkSync(cfgPath);
103
+ if (existsSync(legacyConfigPath()))
104
+ unlinkSync(legacyConfigPath());
84
105
  }
85
106
  }
package/dist/update.js CHANGED
@@ -1,21 +1,23 @@
1
1
  /**
2
- * `rlo update` (spec §2). git pull --ff-only + bun install/build + restart +
3
- * re-health-check. Config file untouched.
2
+ * `arelos update`. git pull --ff-only + bun install/build + restart +
3
+ * re-health-check. Config file untouched. With multiple installs registered,
4
+ * resolves by name (or prompts/lists per resolveInstall's rule).
4
5
  */
5
6
  import pc from "picocolors";
6
7
  import { ensureBun } from "./bun-setup.js";
7
- import { readConfig, resolveServiceLabels } from "./config.js";
8
+ import { resolveRoot, resolveServiceLabels } from "./config.js";
9
+ import { resolveInstall } from "./cli-context.js";
8
10
  import { runStreaming } from "./exec.js";
9
11
  import { waitForHealthy } from "./health.js";
10
12
  import { pullLatest } from "./repo.js";
11
13
  import { bootstrapAndStart, installServiceFiles } from "./services.js";
12
- export async function updateCommand() {
13
- const config = readConfig();
14
- if (!config) {
15
- console.error("No Arel OS install found. Run `npx arelos` to install.");
14
+ export async function updateCommand(name) {
15
+ const result = await resolveInstall({ name, interactive: process.stdout.isTTY === true });
16
+ if (!result.ok) {
17
+ console.error(result.message);
16
18
  return 1;
17
19
  }
18
- return runUpdate(config);
20
+ return runUpdate(result.install.config);
19
21
  }
20
22
  export async function runUpdate(config) {
21
23
  console.log(`Updating ${config.installDir}…`);
@@ -45,7 +47,7 @@ export async function runUpdate(config) {
45
47
  console.warn(pc.yellow("bun run build failed — keeping old dist/. The web service will keep serving it."));
46
48
  }
47
49
  const labels = resolveServiceLabels(config);
48
- installServiceFiles(config.installDir, labels);
50
+ installServiceFiles(config.installDir, resolveRoot(config), labels);
49
51
  const bootstrap = await bootstrapAndStart(labels);
50
52
  for (const e of bootstrap.errors)
51
53
  console.error(pc.yellow(e));
package/package.json CHANGED
@@ -1,11 +1,10 @@
1
1
  {
2
2
  "name": "arelos",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Installer and service manager for a self-hosted Arel OS on macOS.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "arelos": "dist/cli.js",
8
- "rlo": "dist/cli.js"
7
+ "arelos": "dist/cli.js"
9
8
  },
10
9
  "files": [
11
10
  "dist"