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.
package/dist/cli-args.js CHANGED
@@ -4,8 +4,8 @@ export function parseInstallFlags(argv) {
4
4
  noService: false,
5
5
  localRepo: null,
6
6
  displayName: null,
7
- installDir: null,
8
- vaultPath: null,
7
+ root: null,
8
+ parentDir: null,
9
9
  webPort: null,
10
10
  vaultPort: null,
11
11
  };
@@ -25,11 +25,11 @@ export function parseInstallFlags(argv) {
25
25
  case "--display-name":
26
26
  flags.displayName = argv[++i] ?? null;
27
27
  break;
28
- case "--install-dir":
29
- flags.installDir = argv[++i] ?? null;
28
+ case "--root":
29
+ flags.root = argv[++i] ?? null;
30
30
  break;
31
- case "--vault-path":
32
- flags.vaultPath = argv[++i] ?? null;
31
+ case "--parent-dir":
32
+ flags.parentDir = argv[++i] ?? null;
33
33
  break;
34
34
  case "--web-port":
35
35
  flags.webPort = Number(argv[++i]);
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Shared "which install does this command target" resolution for
3
+ * status/update/logs/uninstall (0.2.0 multi-install support).
4
+ *
5
+ * Rule: no name arg + exactly one known install -> that one. No name arg +
6
+ * multiple installs -> interactive select (or, non-interactively, an error
7
+ * listing the options). A name arg always resolves by exact registry
8
+ * name/slug match. "Known installs" = registry entries, plus the legacy
9
+ * fixed ~/.arelos/config.json as an "(unnamed)" install when present and not
10
+ * already superseded by a registry entry at the same root.
11
+ */
12
+ import * as p from "@clack/prompts";
13
+ import { readConfigAt, readConfig } from "./config.js";
14
+ import { legacyConfigPath } from "./paths.js";
15
+ import { existsSync } from "node:fs";
16
+ import { readRegistry } from "./registry.js";
17
+ function listCandidates() {
18
+ const entries = readRegistry();
19
+ const candidates = entries.map((e) => ({ name: e.name, root: e.root }));
20
+ if (existsSync(legacyConfigPath())) {
21
+ candidates.push({ name: "(unnamed — legacy install)", root: null });
22
+ }
23
+ return candidates;
24
+ }
25
+ function loadCandidate(candidate) {
26
+ const config = candidate.root ? readConfigAt(candidate.root) : readConfig();
27
+ if (!config)
28
+ return null;
29
+ return { name: candidate.name, root: candidate.root, config };
30
+ }
31
+ /** Resolve which install a status/update/logs/uninstall invocation targets. */
32
+ export async function resolveInstall(opts) {
33
+ const candidates = listCandidates();
34
+ if (opts.name) {
35
+ const match = candidates.find((c) => c.name === opts.name);
36
+ if (!match) {
37
+ const known = candidates.map((c) => c.name).join(", ") || "(none)";
38
+ return { ok: false, message: `No install named "${opts.name}" found. Known installs: ${known}` };
39
+ }
40
+ const resolved = loadCandidate(match);
41
+ if (!resolved) {
42
+ return { ok: false, message: `Install "${opts.name}" is registered but its config could not be read.` };
43
+ }
44
+ return { ok: true, install: resolved };
45
+ }
46
+ if (candidates.length === 0) {
47
+ return { ok: false, message: "No Arel OS install found. Run `npx arelos` to install." };
48
+ }
49
+ if (candidates.length === 1) {
50
+ const resolved = loadCandidate(candidates[0]);
51
+ if (!resolved) {
52
+ return { ok: false, message: "Install is registered but its config could not be read." };
53
+ }
54
+ return { ok: true, install: resolved };
55
+ }
56
+ // Multiple installs, no name given.
57
+ if (!opts.interactive) {
58
+ const known = candidates.map((c) => c.name).join(", ");
59
+ return {
60
+ ok: false,
61
+ message: `Multiple Arel OS installs found — pass a name. Known installs: ${known}`,
62
+ };
63
+ }
64
+ const choice = await p.select({
65
+ message: "Which install?",
66
+ options: candidates.map((c) => ({ value: c.name, label: c.name })),
67
+ });
68
+ if (p.isCancel(choice)) {
69
+ return { ok: false, message: "Cancelled." };
70
+ }
71
+ const match = candidates.find((c) => c.name === choice);
72
+ const resolved = match ? loadCandidate(match) : null;
73
+ if (!resolved) {
74
+ return { ok: false, message: "Selected install's config could not be read." };
75
+ }
76
+ return { ok: true, install: resolved };
77
+ }
78
+ export function listInstallNames() {
79
+ return listCandidates().map((c) => c.name);
80
+ }
package/dist/cli.js CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * arelos — the Arel OS installer/service manager. `npx arelos` with no subcommand
4
- * runs the interactive install flow (rlo-cli-spec.md §1).
4
+ * runs the interactive install flow. 0.2.0 supports multiple named,
5
+ * self-contained installs on one Mac — status/update/logs/uninstall accept an
6
+ * optional install name/slug (see cli-context.ts for the resolution rule).
5
7
  */
6
8
  if (process.platform !== "darwin") {
7
9
  console.error("Arel OS currently supports macOS only.");
@@ -9,6 +11,7 @@ if (process.platform !== "darwin") {
9
11
  }
10
12
  import { parseInstallFlags, parseLogsFlags } from "./cli-args.js";
11
13
  import { runInstall } from "./install.js";
14
+ import { listCommand } from "./list.js";
12
15
  import { logsCommand } from "./logs.js";
13
16
  import { statusCommand } from "./status.js";
14
17
  import { uninstallCommand } from "./uninstall.js";
@@ -23,7 +26,7 @@ async function main() {
23
26
  // rather than a subcommand name — both mean "install". Anything else in
24
27
  // the known set consumes its name as the subcommand; everything after it
25
28
  // is passed through as that subcommand's own args.
26
- const knownSubcommands = new Set(["install", "status", "update", "uninstall", "logs"]);
29
+ const knownSubcommands = new Set(["install", "status", "update", "uninstall", "logs", "list"]);
27
30
  const firstIsFlag = argv.length === 0 || argv[0].startsWith("-");
28
31
  const firstIsKnown = argv.length > 0 && knownSubcommands.has(argv[0]);
29
32
  if (!firstIsFlag && !firstIsKnown) {
@@ -37,35 +40,64 @@ async function main() {
37
40
  switch (subcommand) {
38
41
  case "install":
39
42
  return runInstall(rest, parseInstallFlags(rest));
43
+ case "list":
44
+ return listCommand();
40
45
  case "status":
41
- return statusCommand();
46
+ return statusCommand(nameArgFrom(rest));
42
47
  case "update":
43
- return updateCommand();
48
+ return updateCommand(nameArgFrom(rest));
44
49
  case "uninstall":
45
- return uninstallCommand();
50
+ return uninstallCommand(nameArgFrom(rest));
46
51
  case "logs":
47
- return logsCommand(parseLogsFlags(rest));
52
+ return logsCommand(parseLogsFlags(rest), logsNameArgFrom(rest));
48
53
  default:
49
54
  console.error(`Unknown command: ${subcommand}\n`);
50
55
  printHelp();
51
56
  return 1;
52
57
  }
53
58
  }
59
+ /** The first non-flag positional arg, if any — the optional install name/slug. */
60
+ function nameArgFrom(args) {
61
+ return args.find((a) => !a.startsWith("-")) ?? null;
62
+ }
63
+ /**
64
+ * `logs` has two possible positionals — the install name and the web/vault
65
+ * target — in either order. The target is a fixed keyword, so anything else
66
+ * non-flag (and not `-n`'s own value) is the name.
67
+ */
68
+ function logsNameArgFrom(args) {
69
+ for (let i = 0; i < args.length; i++) {
70
+ const arg = args[i];
71
+ if (arg.startsWith("-")) {
72
+ if (arg === "-n")
73
+ i++; // skip -n's value
74
+ continue;
75
+ }
76
+ if (arg === "web" || arg === "vault")
77
+ continue;
78
+ return arg;
79
+ }
80
+ return null;
81
+ }
54
82
  function printHelp() {
55
83
  console.log(`arelos — install and manage a self-hosted Arel OS
56
84
 
57
85
  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)
86
+ npx arelos Install (interactive)
87
+ arelos list List every install (name, root, ports, status)
88
+ arelos status [name] Show install + service status
89
+ arelos update [name] git pull + rebuild + restart
90
+ arelos uninstall [name] Stop services, optionally remove install dir / vault
91
+ arelos logs [name] [web|vault] Tail service logs (-f to follow, -n <N> for line count)
92
+
93
+ [name] is only needed when you have more than one install; omit it with a
94
+ single install, or you'll be prompted to choose interactively.
63
95
 
64
96
  Install flags (non-interactive):
65
97
  --yes, --defaults Skip prompts, use defaults/flags below
66
98
  --display-name <name>
67
- --install-dir <path>
68
- --vault-path <path>
99
+ --root <path> Full override of the resolved install root
100
+ --parent-dir <path> Override the parent dir the app's folder is created in
69
101
  --web-port <port>
70
102
  --vault-port <port>
71
103
  --no-service Skip launchd bootstrap (for dry runs / development)
package/dist/config.js CHANGED
@@ -1,13 +1,16 @@
1
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.
2
+ * arelos's own config read/write helpers — mirrors the shape read by
3
+ * server/config.ts. arelos is the writer, the app is the reader; this is the
4
+ * one contract between them.
5
+ *
6
+ * 0.2.0: config is per-install, at <root>/config.json (see paths.ts
7
+ * installConfigPath). The old single fixed ~/.arelos/config.json is kept as
8
+ * a read-only legacy fallback ("unnamed install") for 0.1.x installs.
5
9
  */
6
10
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
7
11
  import { dirname } from "node:path";
8
- import { configPath, LEGACY_VAULT_LABEL, LEGACY_WEB_LABEL } from "./paths.js";
9
- export function readConfig() {
10
- const p = configPath();
12
+ import { configPathOverride, installConfigPath, legacyConfigPath, LEGACY_VAULT_LABEL, LEGACY_WEB_LABEL, } from "./paths.js";
13
+ function parseConfigFile(p) {
11
14
  if (!existsSync(p))
12
15
  return null;
13
16
  const raw = JSON.parse(readFileSync(p, "utf8"));
@@ -16,13 +19,30 @@ export function readConfig() {
16
19
  }
17
20
  return raw;
18
21
  }
22
+ /** Read the config at a specific known root's config.json. */
23
+ export function readConfigAt(root) {
24
+ return parseConfigFile(installConfigPath(root));
25
+ }
26
+ /**
27
+ * Read a single config with no registry context: honors ARELOS_CONFIG_PATH
28
+ * if set (tests / pointing at one install directly), else falls back to the
29
+ * legacy fixed ~/.arelos/config.json (pre-0.2.0 "unnamed install"). Used by
30
+ * callers that haven't gone through the registry (e.g. a bare `arelos status`
31
+ * when there's exactly one legacy install and no registry entries).
32
+ */
33
+ export function readConfig() {
34
+ const override = configPathOverride();
35
+ if (override)
36
+ return parseConfigFile(override);
37
+ return parseConfigFile(legacyConfigPath());
38
+ }
19
39
  /**
20
40
  * 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").
41
+ * target. Guarantees a reader never observes a partially written file
42
+ * config write is last-writer-wins; never partially written.
23
43
  */
24
- export function writeConfig(config) {
25
- const p = configPath();
44
+ export function writeConfig(config, targetPath) {
45
+ const p = targetPath ?? configPathOverride() ?? (config.root ? installConfigPath(config.root) : legacyConfigPath());
26
46
  mkdirSync(dirname(p), { recursive: true });
27
47
  const tmp = `${p}.tmp`;
28
48
  writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
@@ -31,8 +51,17 @@ export function writeConfig(config) {
31
51
  /**
32
52
  * The labels to actually operate on for an existing install: config.serviceLabels
33
53
  * when present (installs made after this fix), else the legacy fixed labels
34
- * (installs made before it — backward compat per spec).
54
+ * (installs made before it — backward compat).
35
55
  */
36
56
  export function resolveServiceLabels(config) {
37
57
  return config.serviceLabels ?? { web: LEGACY_WEB_LABEL, vault: LEGACY_VAULT_LABEL };
38
58
  }
59
+ /**
60
+ * The self-contained root that owns this install's logs/service/ and
61
+ * config.json (0.2.0 layout). Legacy (pre-0.2.0) configs have no `root` field
62
+ * — for those, installDir *was* the root (logs lived at installDir/logs),
63
+ * so fall back to it.
64
+ */
65
+ export function resolveRoot(config) {
66
+ return config.root ?? config.installDir;
67
+ }
package/dist/health.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * HTTP health probes for the vault (`/config`) and web (`/`) services
3
- * (spec §1 Step 12, §2 rlo status). Pure fetch-based, no deps.
3
+ * (used by `arelos install` and `arelos status`). Pure fetch-based, no deps.
4
4
  */
5
5
  async function fetchWithTimeout(url, timeoutMs) {
6
6
  const controller = new AbortController();
@@ -56,3 +56,64 @@ export async function waitForHealthy(webPort, vaultPort, opts = {}) {
56
56
  }
57
57
  return { healthy: false, vault, web };
58
58
  }
59
+ export const LOG_SIGNATURE_HINTS = [
60
+ {
61
+ pattern: "operation not permitted",
62
+ hint: "This looks like macOS's TCC privacy protection: background services can't run from " +
63
+ "Desktop, Documents, Downloads, or iCloud Drive. Move the install to a folder in your home " +
64
+ "directory (e.g. ~/arelos) and reinstall.",
65
+ },
66
+ {
67
+ pattern: "eaddrinuse",
68
+ hint: "This looks like a port conflict: another process is already using the configured port. " +
69
+ "Free the port or re-run install with a different --web-port/--vault-port.",
70
+ },
71
+ {
72
+ pattern: "address already in use",
73
+ hint: "This looks like a port conflict: another process is already using the configured port. " +
74
+ "Free the port or re-run install with a different --web-port/--vault-port.",
75
+ },
76
+ {
77
+ pattern: "command not found",
78
+ hint: "This looks like a missing dependency on PATH inside the launchd environment. Check the service's shebang/interpreter is installed and resolvable without a login shell.",
79
+ },
80
+ ];
81
+ /**
82
+ * Match a log tail against known failure signatures. Pure string matching,
83
+ * no I/O — unit-testable without touching disk. Returns null when nothing
84
+ * recognized matches, so callers can fall back to a generic message.
85
+ */
86
+ export function matchLogSignature(logTail) {
87
+ const lower = logTail.toLowerCase();
88
+ for (const entry of LOG_SIGNATURE_HINTS) {
89
+ if (lower.includes(entry.pattern))
90
+ return entry;
91
+ }
92
+ return null;
93
+ }
94
+ /**
95
+ * Build the full health-timeout diagnostic message: last N lines of both
96
+ * service logs, plus a targeted hint when a known failure signature is
97
+ * found in either. `readTail` is injected so this stays pure/testable
98
+ * (no direct fs access) while install.ts/repair.ts wire in the real reader.
99
+ */
100
+ export function formatHealthTimeoutDiagnostics(logsRoot, readTail, logPathFor) {
101
+ const webLogPath = logPathFor(logsRoot, "web");
102
+ const vaultLogPath = logPathFor(logsRoot, "vault");
103
+ const webTail = readTail(webLogPath);
104
+ const vaultTail = readTail(vaultLogPath);
105
+ const hint = matchLogSignature(webTail) ?? matchLogSignature(vaultTail);
106
+ const lines = [
107
+ "App did not come up in time.",
108
+ "",
109
+ `-- last lines of ${webLogPath} --`,
110
+ webTail.trim() || "(empty)",
111
+ "",
112
+ `-- last lines of ${vaultLogPath} --`,
113
+ vaultTail.trim() || "(empty)",
114
+ ];
115
+ if (hint) {
116
+ lines.push("", hint.hint);
117
+ }
118
+ return lines.join("\n");
119
+ }
@@ -1,17 +1,23 @@
1
1
  /**
2
2
  * Pure planning/validation logic for the install flow, factored out of the
3
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.
4
+ * a TTY. Each function here validates one prompt's answer.
5
+ *
6
+ * 0.2.0 self-contained layout: everything for one install lives under a
7
+ * single `root` folder (`<parent>/<slug>`) — `root/app` (the git checkout),
8
+ * `root/vault`, `root/logs/service`, and `root/config.json`. `installDir` in
9
+ * this module and in ArelConfig now specifically means the app checkout
10
+ * (`root/app`), not the root — see toArelConfig.
5
11
  */
6
12
  import { existsSync, readdirSync, statSync } from "node:fs";
7
13
  import { accessSync, constants } from "node:fs";
8
- import { dirname } from "node:path";
9
- import { deriveServiceLabels, expandHome } from "./paths.js";
14
+ import { dirname, join } from "node:path";
15
+ import { deriveServiceLabels, expandHome, isTccProtectedPath } from "./paths.js";
10
16
  import { findFreePort, isValidPort } from "./ports.js";
11
17
  export const DEFAULTS = {
12
18
  displayName: "Arel OS",
13
- installDir: "~/ArelOS",
14
- vaultPathSuffix: "vault",
19
+ parentDir: "~",
20
+ slugFallback: "arelos",
15
21
  webPort: 1347,
16
22
  vaultPort: 5274,
17
23
  };
@@ -19,7 +25,58 @@ export function normalizeDisplayName(input) {
19
25
  const trimmed = input.trim();
20
26
  return trimmed.length > 0 ? trimmed : DEFAULTS.displayName;
21
27
  }
22
- export function checkInstallDir(rawPath) {
28
+ /**
29
+ * Slugify a chosen display name into a safe directory-name fragment:
30
+ * lowercase, spaces/unsafe chars -> dashes, collapsed and trimmed. Falls back
31
+ * to the fixed default install dir's basename when the slug would be empty
32
+ * (e.g. a name made entirely of emoji/punctuation).
33
+ */
34
+ export function slugifyName(input) {
35
+ return input
36
+ .toLowerCase()
37
+ .trim()
38
+ .replace(/[^a-z0-9]+/g, "-")
39
+ .replace(/^-+|-+$/g, "");
40
+ }
41
+ /** Slug fallback when the chosen name slugifies to empty (e.g. all emoji/punctuation). */
42
+ export function slugOrFallback(displayName) {
43
+ const slug = slugifyName(displayName);
44
+ return slug || DEFAULTS.slugFallback;
45
+ }
46
+ /** Default parent dir offered for "change location?" — always the home directory. */
47
+ export function defaultParentDir() {
48
+ return DEFAULTS.parentDir;
49
+ }
50
+ /**
51
+ * The self-contained root for this install: always `<parent>/<slug>` — we
52
+ * never install loose into an existing folder, so the slug subfolder is
53
+ * appended unconditionally, even when the user changes the parent.
54
+ */
55
+ export function rootFor(parentDir, displayName) {
56
+ return join(expandHome(parentDir), slugOrFallback(displayName));
57
+ }
58
+ /** installDir (the app checkout) and vaultPath are fixed children of root. */
59
+ export function appDirFor(root) {
60
+ return join(root, "app");
61
+ }
62
+ export function vaultPathFor(root) {
63
+ return join(root, "vault");
64
+ }
65
+ /**
66
+ * Plain-English explanation shown (and re-prompted with) when the chosen
67
+ * root or vault path resolves to inside a macOS TCC-protected folder
68
+ * (field bug: launchd-spawned services get `Operation not permitted`,
69
+ * exit 126, and crash-loop forever from Desktop/Documents/Downloads/iCloud
70
+ * Drive — see paths.ts isTccProtectedPath).
71
+ */
72
+ export const TCC_PROTECTED_PATH_MESSAGE = "macOS blocks background services from running in Desktop, Documents, Downloads, or iCloud Drive. Pick a folder in your home directory instead.";
73
+ /**
74
+ * Validate a candidate self-contained root folder (`<parent>/<slug>`). A
75
+ * "prior arelos install of the same name" is recognized by the self-contained
76
+ * layout's own marker — root/app is a git checkout — so re-running the
77
+ * installer against the same root is a repair, not a collision.
78
+ */
79
+ export function checkRootDir(rawPath) {
23
80
  const path = expandHome(rawPath);
24
81
  const parent = dirname(path);
25
82
  let parentWritable = true;
@@ -35,12 +92,11 @@ export function checkInstallDir(rawPath) {
35
92
  if (exists && statSync(path).isDirectory()) {
36
93
  const entries = readdirSync(path);
37
94
  nonEmpty = entries.length > 0;
38
- isPriorArelosInstall = entries.includes(".git") && entries.includes("package.json");
95
+ const appDir = join(path, "app");
96
+ isPriorArelosInstall =
97
+ existsSync(join(appDir, ".git")) && existsSync(join(appDir, "package.json"));
39
98
  }
40
- return { path, parentWritable, exists, nonEmpty, isPriorArelosInstall };
41
- }
42
- export function defaultVaultPath(installDir) {
43
- return `${expandHome(installDir)}/${DEFAULTS.vaultPathSuffix}`;
99
+ return { path, parentWritable, exists, nonEmpty, isPriorArelosInstall, isTccProtected: isTccProtectedPath(path) };
44
100
  }
45
101
  /** Validate a chosen port and, if it's taken, propose the next free one. */
46
102
  export async function resolvePort(requested) {
@@ -55,14 +111,19 @@ export async function resolvePort(requested) {
55
111
  return { requested, resolved: suggestion, wasFree: false };
56
112
  }
57
113
  export function toArelConfig(answers) {
114
+ const root = expandHome(answers.root);
58
115
  const installDir = expandHome(answers.installDir);
59
116
  return {
60
117
  version: 1,
61
118
  displayName: answers.displayName,
119
+ root,
62
120
  installDir,
63
121
  vaultPath: expandHome(answers.vaultPath),
64
122
  webPort: answers.webPort,
65
123
  vaultPort: answers.vaultPort,
66
- serviceLabels: deriveServiceLabels(installDir),
124
+ // Labels are derived from root, not installDir: root is what's unique
125
+ // per named install; installDir (root/app) would collide in derivation
126
+ // only coincidentally, but keying off root is the more direct contract.
127
+ serviceLabels: deriveServiceLabels(root),
67
128
  };
68
129
  }