arelos 0.1.0 → 0.1.1

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.js CHANGED
File without changes
package/dist/config.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
7
7
  import { dirname } from "node:path";
8
- import { configPath } from "./paths.js";
8
+ import { configPath, LEGACY_VAULT_LABEL, LEGACY_WEB_LABEL } from "./paths.js";
9
9
  export function readConfig() {
10
10
  const p = configPath();
11
11
  if (!existsSync(p))
@@ -28,3 +28,11 @@ export function writeConfig(config) {
28
28
  writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
29
29
  renameSync(tmp, p);
30
30
  }
31
+ /**
32
+ * The labels to actually operate on for an existing install: config.serviceLabels
33
+ * when present (installs made after this fix), else the legacy fixed labels
34
+ * (installs made before it — backward compat per spec).
35
+ */
36
+ export function resolveServiceLabels(config) {
37
+ return config.serviceLabels ?? { web: LEGACY_WEB_LABEL, vault: LEGACY_VAULT_LABEL };
38
+ }
@@ -6,7 +6,7 @@
6
6
  import { existsSync, readdirSync, statSync } from "node:fs";
7
7
  import { accessSync, constants } from "node:fs";
8
8
  import { dirname } from "node:path";
9
- import { expandHome } from "./paths.js";
9
+ import { deriveServiceLabels, expandHome } from "./paths.js";
10
10
  import { findFreePort, isValidPort } from "./ports.js";
11
11
  export const DEFAULTS = {
12
12
  displayName: "Arel OS",
@@ -55,12 +55,14 @@ export async function resolvePort(requested) {
55
55
  return { requested, resolved: suggestion, wasFree: false };
56
56
  }
57
57
  export function toArelConfig(answers) {
58
+ const installDir = expandHome(answers.installDir);
58
59
  return {
59
60
  version: 1,
60
61
  displayName: answers.displayName,
61
- installDir: expandHome(answers.installDir),
62
+ installDir,
62
63
  vaultPath: expandHome(answers.vaultPath),
63
64
  webPort: answers.webPort,
64
65
  vaultPort: answers.vaultPort,
66
+ serviceLabels: deriveServiceLabels(installDir),
65
67
  };
66
68
  }
package/dist/install.js CHANGED
@@ -14,7 +14,8 @@ import { bootstrapAndStart, installServiceFiles } from "./services.js";
14
14
  import { cloneRepo, isGitCheckout } from "./repo.js";
15
15
  import { ensureEnvFile, ensureLogsDir, scaffoldVault, TemplateVaultMissingError } from "./scaffold.js";
16
16
  import { runRepairMenu } from "./repair.js";
17
- import { configPath } from "./paths.js";
17
+ import { configPath, deriveServiceLabels } from "./paths.js";
18
+ import { listLoadedArelosLabels } from "./launchd.js";
18
19
  export async function runInstall(argv, flags) {
19
20
  // Step 0 — Preflight & existing-install detection.
20
21
  if (process.platform !== "darwin") {
@@ -125,11 +126,24 @@ export async function runInstall(argv, flags) {
125
126
  p.outro(pc.green("Dry-run install complete (no services started)."));
126
127
  return 0;
127
128
  }
129
+ // Step 10.5 — Preflight: check for a same-slug reinstall vs. an unrelated
130
+ // Arel OS install already holding other com.arelos.* labels.
131
+ const labels = config.serviceLabels ?? deriveServiceLabels(installDir);
132
+ const loadedLabels = await listLoadedArelosLabels();
133
+ const oursAlreadyLoaded = loadedLabels.filter((l) => l === labels.web || l === labels.vault);
134
+ const othersLoaded = loadedLabels.filter((l) => l !== labels.web && l !== labels.vault);
135
+ if (oursAlreadyLoaded.length > 0) {
136
+ log(flags.yes, `Services for this install dir are already loaded (${oursAlreadyLoaded.join(", ")}) — will bootout and re-bootstrap.`);
137
+ }
138
+ if (othersLoaded.length > 0) {
139
+ console.error(pc.yellow(`Another Arel OS install is already running with its own services (${othersLoaded.join(", ")}). ` +
140
+ "This install uses its own unique labels, so it will not disturb that install."));
141
+ }
128
142
  // Step 11 — Generate + bootstrap launchd services.
129
143
  const svcSpin = spinner(flags.yes);
130
144
  svcSpin.start("Registering background services…");
131
- installServiceFiles(installDir);
132
- const bootstrapResult = await bootstrapAndStart();
145
+ installServiceFiles(installDir, labels);
146
+ const bootstrapResult = await bootstrapAndStart(labels);
133
147
  if (bootstrapResult.errors.length > 0) {
134
148
  svcSpin.stop("Service registration had errors.");
135
149
  for (const e of bootstrapResult.errors)
package/dist/launchd.js CHANGED
@@ -2,6 +2,29 @@
2
2
  * launchctl wrapper (modern per-GUI-domain bootstrap/bootout/kickstart API).
3
3
  * Every op is idempotent per spec §3.2: bootout-then-bootstrap never errors
4
4
  * on "already loaded"; a --no-service dry run skips all of this.
5
+ *
6
+ * Field report conclusion — "Bootstrap failed: 5: Input/output error":
7
+ * launchd's errno 5 (EIO) here is a catch-all it surfaces for several distinct
8
+ * setup failures, not just "job already loaded". Two concrete causes were
9
+ * found and fixed in this codebase:
10
+ * 1. Label collision (the actual reported bug) — a second install on the
11
+ * same Mac reused the fixed labels com.arelos.web/.vault. Bootstrapping
12
+ * a plist under a label already claimed by a *different* plist path
13
+ * (the first install's) is exactly the kind of state launchd reports as
14
+ * an I/O error rather than a clean "already exists". Fixed by deriving a
15
+ * per-installDir label (paths.ts deriveServiceLabels) so two installs
16
+ * never share a label.
17
+ * 2. Missing log directory — the plists' StandardOutPath/StandardErrorPath
18
+ * point at <installDir>/logs/service/*.log. If that directory doesn't
19
+ * exist yet, launchd can fail to set up the job's stdio redirection at
20
+ * bootstrap/first-spawn time. install.ts already called ensureLogsDir
21
+ * before registering services, but repair.ts/update.ts funneled straight
22
+ * into installServiceFiles without it — a real gap for any repair/update
23
+ * run where logs/service/ had been deleted or never existed. Fixed by
24
+ * making installServiceFiles itself create the directory unconditionally
25
+ * (see services.ts), so all three callers get it for free.
26
+ * The domain string ("gui/<uid>") and the bootout-before-bootstrap idempotency
27
+ * pattern were checked and are correct as written — not a contributor here.
5
28
  */
6
29
  import { userInfo } from "node:os";
7
30
  import { runCapture } from "./exec.js";
@@ -44,3 +67,15 @@ export async function getServiceStatus(label) {
44
67
  export function guiDomainForTest() {
45
68
  return guiDomain();
46
69
  }
70
+ /**
71
+ * All currently-loaded launchd labels starting with "com.arelos." — used by
72
+ * install preflight to detect a same-slug reinstall vs. an unrelated,
73
+ * still-running Arel OS install that grabbed different labels first.
74
+ */
75
+ export async function listLoadedArelosLabels() {
76
+ const res = await runCapture("launchctl", ["list"]);
77
+ return res.stdout
78
+ .split("\n")
79
+ .map((l) => l.trim().split(/\s+/).pop())
80
+ .filter((label) => !!label && label.startsWith("com.arelos."));
81
+ }
package/dist/paths.js CHANGED
@@ -3,6 +3,7 @@
3
3
  * Everything here honors ARELOS_CONFIG_PATH so tests/dry-runs never touch
4
4
  * the real ~/.arelos.
5
5
  */
6
+ import { createHash } from "node:crypto";
6
7
  import { homedir } from "node:os";
7
8
  import { join } from "node:path";
8
9
  export function configPath() {
@@ -14,8 +15,30 @@ export function configDir() {
14
15
  export function launchAgentsDir() {
15
16
  return join(homedir(), "Library", "LaunchAgents");
16
17
  }
17
- export const WEB_LABEL = "com.arelos.web";
18
- export const VAULT_LABEL = "com.arelos.vault";
18
+ /**
19
+ * Pre-per-install-label launchd labels. A second install on the same Mac used
20
+ * to collide on these fixed labels — see serviceLabels below for the fix.
21
+ * Kept as the fallback for configs written before this fix (spec: legacy
22
+ * compat) and as the thing preflight checks for "another install exists".
23
+ */
24
+ export const LEGACY_WEB_LABEL = "com.arelos.web";
25
+ export const LEGACY_VAULT_LABEL = "com.arelos.vault";
26
+ /**
27
+ * Short stable hash of the resolved installDir: same dir -> same slug across
28
+ * reinstalls/repairs, different dirs -> different slugs, so two installs on
29
+ * one Mac never fight over the same launchd label.
30
+ */
31
+ export function installSlug(installDir) {
32
+ return createHash("sha256").update(installDir).digest("hex").slice(0, 8);
33
+ }
34
+ /** Derive the unique per-install label pair from the resolved installDir. */
35
+ export function deriveServiceLabels(installDir) {
36
+ const slug = installSlug(installDir);
37
+ return {
38
+ web: `com.arelos.${slug}.web`,
39
+ vault: `com.arelos.${slug}.vault`,
40
+ };
41
+ }
19
42
  export function plistPath(label) {
20
43
  return join(launchAgentsDir(), `${label}.plist`);
21
44
  }
package/dist/plist.js CHANGED
@@ -1,11 +1,12 @@
1
1
  /**
2
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.
3
+ * `scripts/service/{web,vault}.plist.tmpl` in the app repo and take two
4
+ * tokens {{INSTALL_DIR}} and {{LABEL}} (the per-install launchd label from
5
+ * paths.ts deriveServiceLabels). Ports/vaultPath are read from config at
6
+ * process start by the run-*.sh scripts / server, never baked into the plist.
6
7
  */
7
- export function renderPlistTemplate(template, installDir) {
8
- return template.split("{{INSTALL_DIR}}").join(installDir);
8
+ export function renderPlistTemplate(template, installDir, label) {
9
+ return template.split("{{INSTALL_DIR}}").join(installDir).split("{{LABEL}}").join(label);
9
10
  }
10
11
  /** 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
12
  export function looksLikeValidPlist(xml) {
package/dist/repair.js CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import * as p from "@clack/prompts";
7
7
  import pc from "picocolors";
8
+ import { resolveServiceLabels } from "./config.js";
8
9
  import { runUpdate } from "./update.js";
9
10
  import { waitForHealthy } from "./health.js";
10
11
  import { bootstrapAndStart, installServiceFiles } from "./services.js";
@@ -67,8 +68,9 @@ async function runRepair(existing) {
67
68
  }
68
69
  s.stop("Rebuilt.");
69
70
  s.start("Re-rendering and re-bootstrapping services…");
70
- installServiceFiles(existing.installDir);
71
- const bootstrap = await bootstrapAndStart();
71
+ const labels = resolveServiceLabels(existing);
72
+ installServiceFiles(existing.installDir, labels);
73
+ const bootstrap = await bootstrapAndStart(labels);
72
74
  s.stop(bootstrap.errors.length ? "Services re-registered with warnings." : "Services re-registered.");
73
75
  for (const e of bootstrap.errors)
74
76
  console.error(pc.yellow(e));
package/dist/services.js CHANGED
@@ -6,12 +6,12 @@
6
6
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { bootstrapService, kickstartService } from "./launchd.js";
9
- import { VAULT_LABEL, WEB_LABEL, launchAgentsDir, plistPath } from "./paths.js";
9
+ import { deriveServiceLabels, launchAgentsDir, plistPath } from "./paths.js";
10
10
  import { renderPlistTemplate } from "./plist.js";
11
- export function renderServicePlists(installDir) {
11
+ export function renderServicePlists(installDir, labels = deriveServiceLabels(installDir)) {
12
12
  const specs = [
13
- { label: WEB_LABEL, templateFile: "com.arelos.web.plist.tmpl" },
14
- { label: VAULT_LABEL, templateFile: "com.arelos.vault.plist.tmpl" },
13
+ { label: labels.web, templateFile: "web.plist.tmpl" },
14
+ { label: labels.vault, templateFile: "vault.plist.tmpl" },
15
15
  ];
16
16
  return specs.map(({ label, templateFile }) => {
17
17
  const templatePath = join(installDir, "scripts", "service", templateFile);
@@ -19,14 +19,22 @@ export function renderServicePlists(installDir) {
19
19
  throw new Error(`Missing plist template: ${templatePath}`);
20
20
  }
21
21
  const template = readFileSync(templatePath, "utf8");
22
- const xml = renderPlistTemplate(template, installDir);
22
+ const xml = renderPlistTemplate(template, installDir, label);
23
23
  return { label, templatePath, targetPath: plistPath(label), xml };
24
24
  });
25
25
  }
26
26
  /** Write rendered plists to ~/Library/LaunchAgents and chmod the run scripts. */
27
- export function installServiceFiles(installDir) {
27
+ export function installServiceFiles(installDir, labels = deriveServiceLabels(installDir)) {
28
28
  mkdirSync(launchAgentsDir(), { recursive: true });
29
- const rendered = renderServicePlists(installDir);
29
+ // The plists' StandardOutPath/StandardErrorPath point at <installDir>/logs/service/*.log;
30
+ // launchd needs that directory to exist before it can bootstrap the job (a
31
+ // missing log dir is one confirmed cause of the "Bootstrap failed: 5:
32
+ // Input/output error" field report — see launchd.ts docstring). install.ts
33
+ // already calls ensureLogsDir before this, but repair/update funnel through
34
+ // here too, so guarantee it unconditionally rather than relying on callers
35
+ // to remember.
36
+ mkdirSync(join(installDir, "logs", "service"), { recursive: true });
37
+ const rendered = renderServicePlists(installDir, labels);
30
38
  for (const svc of rendered) {
31
39
  writeFileSync(svc.targetPath, svc.xml);
32
40
  }
@@ -38,18 +46,18 @@ export function installServiceFiles(installDir) {
38
46
  return rendered;
39
47
  }
40
48
  /** Bootstrap + kickstart both services (idempotent bootout-then-bootstrap). */
41
- export async function bootstrapAndStart() {
49
+ export async function bootstrapAndStart(labels) {
42
50
  const errors = [];
43
- const webRes = await bootstrapService(WEB_LABEL);
51
+ const webRes = await bootstrapService(labels.web);
44
52
  if (!webRes.ok)
45
53
  errors.push(`web bootstrap: ${webRes.stderr.trim()}`);
46
- const vaultRes = await bootstrapService(VAULT_LABEL);
54
+ const vaultRes = await bootstrapService(labels.vault);
47
55
  if (!vaultRes.ok)
48
56
  errors.push(`vault bootstrap: ${vaultRes.stderr.trim()}`);
49
- const webKick = await kickstartService(WEB_LABEL);
57
+ const webKick = await kickstartService(labels.web);
50
58
  if (!webKick.ok)
51
59
  errors.push(`web kickstart: ${webKick.stderr.trim()}`);
52
- const vaultKick = await kickstartService(VAULT_LABEL);
60
+ const vaultKick = await kickstartService(labels.vault);
53
61
  if (!vaultKick.ok)
54
62
  errors.push(`vault kickstart: ${vaultKick.stderr.trim()}`);
55
63
  return { web: webRes.ok && webKick.ok, vault: vaultRes.ok && vaultKick.ok, errors };
package/dist/status.js CHANGED
@@ -3,11 +3,10 @@
3
3
  * git revision / behind-origin check.
4
4
  */
5
5
  import pc from "picocolors";
6
- import { readConfig } from "./config.js";
6
+ import { readConfig, resolveServiceLabels } from "./config.js";
7
7
  import { checkVaultHealth, checkWebHealth } from "./health.js";
8
8
  import { getServiceStatus } from "./launchd.js";
9
9
  import { currentRevision, isBehindOrigin } from "./repo.js";
10
- import { VAULT_LABEL, WEB_LABEL } from "./paths.js";
11
10
  export async function statusCommand() {
12
11
  const config = readConfig();
13
12
  if (!config) {
@@ -20,9 +19,10 @@ export async function statusCommand() {
20
19
  console.log(` Web port: ${config.webPort}`);
21
20
  console.log(` Vault port: ${config.vaultPort}`);
22
21
  console.log("");
22
+ const labels = resolveServiceLabels(config);
23
23
  const [webSvc, vaultSvc, webHealth, vaultHealth, rev, behind] = await Promise.all([
24
- getServiceStatus(WEB_LABEL),
25
- getServiceStatus(VAULT_LABEL),
24
+ getServiceStatus(labels.web),
25
+ getServiceStatus(labels.vault),
26
26
  checkWebHealth(config.webPort),
27
27
  checkVaultHealth(config.vaultPort),
28
28
  currentRevision(config.installDir),
package/dist/uninstall.js CHANGED
@@ -8,9 +8,9 @@
8
8
  import * as p from "@clack/prompts";
9
9
  import pc from "picocolors";
10
10
  import { existsSync, rmSync, unlinkSync } from "node:fs";
11
- import { readConfig } from "./config.js";
11
+ import { readConfig, resolveServiceLabels } from "./config.js";
12
12
  import { bootoutService } from "./launchd.js";
13
- import { VAULT_LABEL, WEB_LABEL, plistPath, configPath } from "./paths.js";
13
+ import { plistPath, configPath } from "./paths.js";
14
14
  /**
15
15
  * Pure gate: vault is deleted iff confirmed AND the typed word is exactly
16
16
  * "DELETE". Any other input (including case variants, whitespace, empty)
@@ -27,9 +27,10 @@ export async function uninstallCommand() {
27
27
  return 1;
28
28
  }
29
29
  p.intro(pc.bold("Uninstall Arel OS"));
30
- await bootoutService(WEB_LABEL);
31
- await bootoutService(VAULT_LABEL);
32
- for (const label of [WEB_LABEL, VAULT_LABEL]) {
30
+ const labels = resolveServiceLabels(config);
31
+ await bootoutService(labels.web);
32
+ await bootoutService(labels.vault);
33
+ for (const label of [labels.web, labels.vault]) {
33
34
  const path = plistPath(label);
34
35
  if (existsSync(path))
35
36
  unlinkSync(path);
package/dist/update.js CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import pc from "picocolors";
6
6
  import { ensureBun } from "./bun-setup.js";
7
- import { readConfig } from "./config.js";
7
+ import { readConfig, resolveServiceLabels } from "./config.js";
8
8
  import { runStreaming } from "./exec.js";
9
9
  import { waitForHealthy } from "./health.js";
10
10
  import { pullLatest } from "./repo.js";
@@ -44,8 +44,9 @@ export async function runUpdate(config) {
44
44
  if (buildRes.code !== 0) {
45
45
  console.warn(pc.yellow("bun run build failed — keeping old dist/. The web service will keep serving it."));
46
46
  }
47
- installServiceFiles(config.installDir);
48
- const bootstrap = await bootstrapAndStart();
47
+ const labels = resolveServiceLabels(config);
48
+ installServiceFiles(config.installDir, labels);
49
+ const bootstrap = await bootstrapAndStart(labels);
49
50
  for (const e of bootstrap.errors)
50
51
  console.error(pc.yellow(e));
51
52
  console.log("Restarting services and re-checking health…");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arelos",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Installer and service manager for a self-hosted Arel OS on macOS.",
5
5
  "type": "module",
6
6
  "bin": {