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/install.js CHANGED
@@ -1,37 +1,30 @@
1
1
  /**
2
- * Interactive + non-interactive install flow (rlo-cli-spec.md §1).
3
- * Steps are numbered in comments to match the spec exactly.
2
+ * Interactive + non-interactive install flow. 0.2.0 owner-directed redesign:
3
+ * one required question (name), an optional location override (default: your
4
+ * home directory), silent auto-picked ports, then a summary + confirm. Every
5
+ * install is self-contained under `<parent>/<slug>` — see install-plan.ts.
4
6
  */
5
7
  import * as p from "@clack/prompts";
6
8
  import pc from "picocolors";
7
- import { join } from "node:path";
8
9
  import { ensureBun } from "./bun-setup.js";
9
- import { readConfig, writeConfig } from "./config.js";
10
+ import { readConfigAt, writeConfig } from "./config.js";
10
11
  import { runStreaming } from "./exec.js";
11
- import { waitForHealthy } from "./health.js";
12
- import { checkInstallDir, defaultVaultPath, DEFAULTS, normalizeDisplayName, resolvePort, toArelConfig, } from "./install-plan.js";
12
+ import { formatHealthTimeoutDiagnostics, waitForHealthy } from "./health.js";
13
+ import { lastLines, logPathFor } from "./logs.js";
14
+ import { appDirFor, checkRootDir, DEFAULTS, defaultParentDir, normalizeDisplayName, resolvePort, rootFor, slugOrFallback, TCC_PROTECTED_PATH_MESSAGE, toArelConfig, vaultPathFor, } from "./install-plan.js";
13
15
  import { bootstrapAndStart, installServiceFiles } from "./services.js";
14
16
  import { cloneRepo, isGitCheckout } from "./repo.js";
15
17
  import { ensureEnvFile, ensureLogsDir, scaffoldVault, TemplateVaultMissingError } from "./scaffold.js";
16
18
  import { runRepairMenu } from "./repair.js";
17
- import { configPath, deriveServiceLabels } from "./paths.js";
19
+ import { installConfigPath, isTccProtectedPath } from "./paths.js";
18
20
  import { listLoadedArelosLabels } from "./launchd.js";
21
+ import { addRegistryEntry } from "./registry.js";
19
22
  export async function runInstall(argv, flags) {
20
23
  // Step 0 — Preflight & existing-install detection.
21
24
  if (process.platform !== "darwin") {
22
25
  console.error("Arel OS currently supports macOS only.");
23
26
  return 1;
24
27
  }
25
- const existing = readConfig();
26
- if (existing && !flags.yes) {
27
- return runRepairMenu(existing);
28
- }
29
- if (existing && flags.yes) {
30
- // Non-interactive re-run against an existing install: treat as repair to
31
- // stay consistent with "never silently reinstall" (spec §3.1), unless the
32
- // caller explicitly pointed at a fresh installDir/config.
33
- console.log(pc.yellow(`Existing install detected at ${existing.installDir}; repairing.`));
34
- }
35
28
  const gitOk = flags.localRepo ? true : await checkGit();
36
29
  if (!gitOk)
37
30
  return 1;
@@ -44,11 +37,26 @@ export async function runInstall(argv, flags) {
44
37
  p.cancel("Install cancelled.");
45
38
  return 1;
46
39
  }
40
+ // A prior install of the *same name* (same resolved root, already a git
41
+ // checkout at root/app) is a repair, not a fresh install — never silently
42
+ // reinstall over it.
43
+ const rootCheck = checkRootDir(answers.root);
44
+ if (rootCheck.isPriorArelosInstall) {
45
+ const existingConfig = readConfigAt(answers.root);
46
+ if (existingConfig) {
47
+ if (!flags.yes) {
48
+ return runRepairMenu(existingConfig);
49
+ }
50
+ console.log(pc.yellow(`Existing install detected at ${answers.root}; repairing.`));
51
+ }
52
+ }
47
53
  if (!flags.yes) {
48
54
  p.note([
49
55
  `Name: ${answers.displayName}`,
50
- `Install dir: ${answers.installDir}`,
51
- `Vault path: ${answers.vaultPath}`,
56
+ `Location: ${answers.root}`,
57
+ ` app: ${answers.installDir}`,
58
+ ` vault: ${answers.vaultPath}`,
59
+ ` logs: ${answers.root}/logs/service`,
52
60
  `Web port: ${answers.webPort}`,
53
61
  `Vault port: ${answers.vaultPort}`,
54
62
  ].join("\n"), "Summary");
@@ -68,7 +76,7 @@ export async function runInstall(argv, flags) {
68
76
  return 1;
69
77
  }
70
78
  bunSpin.stop(`Bun ready (${bunResult.bunBin}).`);
71
- // Step 8 — Get the app source.
79
+ // Step 8 — Get the app source into root/app.
72
80
  const installDir = answers.installDir;
73
81
  if (isGitCheckout(installDir)) {
74
82
  log(flags.yes, `Existing checkout found at ${installDir}; skipping clone.`);
@@ -114,13 +122,21 @@ export async function runInstall(argv, flags) {
114
122
  }
115
123
  throw err;
116
124
  }
117
- ensureLogsDir(installDir);
125
+ // Logs live at the self-contained root, not inside the app checkout.
126
+ ensureLogsDir(answers.root);
118
127
  const envResult = ensureEnvFile(installDir);
119
128
  log(flags.yes, envResult.created ? "Wrote .env from .env.example." : ".env already present; left untouched.");
120
- // Step 10 — Write config.
129
+ // Step 10 — Write per-install config to <root>/config.json + register.
121
130
  const config = toArelConfig(answers);
122
- writeConfig(config);
123
- log(flags.yes, `Config written to ${configPath()}.`);
131
+ const cfgPath = installConfigPath(config.root);
132
+ writeConfig(config, cfgPath);
133
+ log(flags.yes, `Config written to ${cfgPath}.`);
134
+ addRegistryEntry({
135
+ name: answers.displayName,
136
+ slug: slugOrFallback(answers.displayName),
137
+ root: config.root,
138
+ createdAt: new Date().toISOString(),
139
+ });
124
140
  if (flags.noService) {
125
141
  log(flags.yes, "Skipping launchd bootstrap (--no-service).");
126
142
  p.outro(pc.green("Dry-run install complete (no services started)."));
@@ -128,12 +144,12 @@ export async function runInstall(argv, flags) {
128
144
  }
129
145
  // Step 10.5 — Preflight: check for a same-slug reinstall vs. an unrelated
130
146
  // Arel OS install already holding other com.arelos.* labels.
131
- const labels = config.serviceLabels ?? deriveServiceLabels(installDir);
147
+ const labels = config.serviceLabels;
132
148
  const loadedLabels = await listLoadedArelosLabels();
133
149
  const oursAlreadyLoaded = loadedLabels.filter((l) => l === labels.web || l === labels.vault);
134
150
  const othersLoaded = loadedLabels.filter((l) => l !== labels.web && l !== labels.vault);
135
151
  if (oursAlreadyLoaded.length > 0) {
136
- log(flags.yes, `Services for this install dir are already loaded (${oursAlreadyLoaded.join(", ")}) — will bootout and re-bootstrap.`);
152
+ log(flags.yes, `Services for this install are already loaded (${oursAlreadyLoaded.join(", ")}) — will bootout and re-bootstrap.`);
137
153
  }
138
154
  if (othersLoaded.length > 0) {
139
155
  console.error(pc.yellow(`Another Arel OS install is already running with its own services (${othersLoaded.join(", ")}). ` +
@@ -142,7 +158,7 @@ export async function runInstall(argv, flags) {
142
158
  // Step 11 — Generate + bootstrap launchd services.
143
159
  const svcSpin = spinner(flags.yes);
144
160
  svcSpin.start("Registering background services…");
145
- installServiceFiles(installDir, labels);
161
+ installServiceFiles(installDir, config.root, labels);
146
162
  const bootstrapResult = await bootstrapAndStart(labels);
147
163
  if (bootstrapResult.errors.length > 0) {
148
164
  svcSpin.stop("Service registration had errors.");
@@ -158,7 +174,8 @@ export async function runInstall(argv, flags) {
158
174
  const health = await waitForHealthy(config.webPort, config.vaultPort);
159
175
  if (!health.healthy) {
160
176
  healthSpin.stop("Health check timed out.");
161
- 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")}`));
177
+ console.error(pc.red(formatHealthTimeoutDiagnostics(config.root, (p) => lastLines(p, 10), logPathFor)));
178
+ console.error(pc.dim("\nFull logs: arelos logs"));
162
179
  return 1;
163
180
  }
164
181
  healthSpin.stop("App is up.");
@@ -173,7 +190,7 @@ export async function runInstall(argv, flags) {
173
190
  }
174
191
  }
175
192
  p.outro([
176
- pc.green(`Arel OS is running at ${url}`),
193
+ pc.green(`${answers.displayName} is running at ${url}`),
177
194
  "Runs 24/7 in the background.",
178
195
  "Next: arelos status · arelos logs · arelos update · arelos uninstall",
179
196
  ].join("\n"));
@@ -189,21 +206,32 @@ async function checkGit() {
189
206
  async function collectAnswers(flags) {
190
207
  if (flags.yes) {
191
208
  const displayName = normalizeDisplayName(flags.displayName ?? DEFAULTS.displayName);
192
- const installDir = flags.installDir ?? DEFAULTS.installDir;
193
- const vaultPath = flags.vaultPath ?? defaultVaultPath(installDir);
209
+ const root = flags.root ?? rootFor(flags.parentDir ?? defaultParentDir(), displayName);
210
+ if (isTccProtectedPath(root)) {
211
+ console.error(pc.red(`Install location ${root} is not safe to use: ${TCC_PROTECTED_PATH_MESSAGE}`));
212
+ return null;
213
+ }
214
+ const check = checkRootDir(root);
215
+ if (check.nonEmpty && !check.isPriorArelosInstall) {
216
+ console.error(pc.red(`${check.path} already has files in it and isn't a prior Arel OS install — choose a different name or location.`));
217
+ return null;
218
+ }
219
+ const installDir = appDirFor(root);
220
+ const vaultPath = vaultPathFor(root);
194
221
  const webPortReq = flags.webPort ?? DEFAULTS.webPort;
195
222
  const vaultPortReq = flags.vaultPort ?? DEFAULTS.vaultPort;
196
223
  const web = await resolvePort(webPortReq);
197
224
  const vault = await resolvePort(vaultPortReq === web.resolved ? vaultPortReq + 1 : vaultPortReq);
198
225
  return {
199
226
  displayName,
227
+ root,
200
228
  installDir,
201
229
  vaultPath,
202
230
  webPort: web.resolved,
203
231
  vaultPort: vault.resolved,
204
232
  };
205
233
  }
206
- // Step 2 — Name your system.
234
+ // Step 2 — Name your system (the single required question).
207
235
  const displayNameRaw = await p.text({
208
236
  message: "What should we call your system?",
209
237
  placeholder: DEFAULTS.displayName,
@@ -211,84 +239,116 @@ async function collectAnswers(flags) {
211
239
  });
212
240
  if (p.isCancel(displayNameRaw))
213
241
  return null;
214
- // Step 3 — Install location.
215
- let installDir = "";
216
- for (;;) {
242
+ const displayName = normalizeDisplayName(String(displayNameRaw));
243
+ const slug = slugOrFallback(displayName);
244
+ p.log.message(`We'll create everything in ~/${slug} — the app, your vault, and logs all live inside this one folder.`);
245
+ // Step 3 — Change location? (default No — we always create <parent>/<slug>, never install loose.)
246
+ let parentDir = defaultParentDir();
247
+ const changeLocation = await p.confirm({ message: "Change location?", initialValue: false });
248
+ if (p.isCancel(changeLocation))
249
+ return null;
250
+ if (changeLocation) {
217
251
  const raw = await p.text({
218
- message: "Where should Arel OS be installed?",
219
- placeholder: DEFAULTS.installDir,
220
- defaultValue: DEFAULTS.installDir,
252
+ message: "Parent folder to install into (we'll create the app's folder inside it):",
253
+ placeholder: defaultParentDir(),
254
+ defaultValue: defaultParentDir(),
221
255
  });
222
256
  if (p.isCancel(raw))
223
257
  return null;
224
- const check = checkInstallDir(String(raw || DEFAULTS.installDir));
258
+ parentDir = String(raw || defaultParentDir());
259
+ }
260
+ let root = "";
261
+ for (;;) {
262
+ const candidateRoot = rootFor(parentDir, displayName);
263
+ const check = checkRootDir(candidateRoot);
264
+ if (check.isTccProtected) {
265
+ p.log.error(`${TCC_PROTECTED_PATH_MESSAGE} (suggested: ${rootFor(defaultParentDir(), displayName)})`);
266
+ const raw = await p.text({
267
+ message: "Parent folder to install into:",
268
+ placeholder: defaultParentDir(),
269
+ defaultValue: defaultParentDir(),
270
+ });
271
+ if (p.isCancel(raw))
272
+ return null;
273
+ parentDir = String(raw || defaultParentDir());
274
+ continue;
275
+ }
225
276
  if (!check.parentWritable) {
226
277
  p.log.error(`Cannot write to ${check.path} — choose another location.`);
278
+ const raw = await p.text({ message: "Parent folder to install into:" });
279
+ if (p.isCancel(raw))
280
+ return null;
281
+ parentDir = String(raw);
227
282
  continue;
228
283
  }
229
284
  if (check.nonEmpty && !check.isPriorArelosInstall) {
230
- const proceed = await p.confirm({
231
- message: `${check.path} exists and is not empty. Use it anyway?`,
232
- initialValue: false,
285
+ const choice = await p.select({
286
+ message: `${check.path} already has files in it and isn't a prior install named "${displayName}".`,
287
+ options: [
288
+ { value: "name", label: "Choose a different name" },
289
+ { value: "location", label: "Choose a different location" },
290
+ { value: "cancel", label: "Cancel install" },
291
+ ],
233
292
  });
234
- if (p.isCancel(proceed) || !proceed)
293
+ if (p.isCancel(choice) || choice === "cancel")
294
+ return null;
295
+ if (choice === "location") {
296
+ const raw = await p.text({ message: "Parent folder to install into:" });
297
+ if (p.isCancel(raw))
298
+ return null;
299
+ parentDir = String(raw);
235
300
  continue;
301
+ }
302
+ // choice === "name": re-ask for a name, keep the same parentDir.
303
+ const nameRaw = await p.text({
304
+ message: "What should we call your system?",
305
+ placeholder: DEFAULTS.displayName,
306
+ defaultValue: DEFAULTS.displayName,
307
+ });
308
+ if (p.isCancel(nameRaw))
309
+ return null;
310
+ return collectAnswersFromName(normalizeDisplayName(String(nameRaw)), parentDir);
236
311
  }
237
- installDir = check.path;
312
+ root = check.path;
238
313
  break;
239
314
  }
240
- // Step 4 — Vault location.
241
- const vaultRaw = await p.text({
242
- message: "Where's your vault (notes/tasks/quests)?",
243
- placeholder: defaultVaultPath(installDir),
244
- defaultValue: defaultVaultPath(installDir),
245
- });
246
- if (p.isCancel(vaultRaw))
247
- return null;
248
- // Step 5 — Ports.
249
- const webPort = await promptPort("Web port?", DEFAULTS.webPort);
250
- if (webPort === null)
251
- return null;
252
- const vaultPort = await promptPort("Vault port?", DEFAULTS.vaultPort === webPort ? DEFAULTS.vaultPort + 1 : DEFAULTS.vaultPort);
253
- if (vaultPort === null)
254
- return null;
255
- return {
256
- displayName: normalizeDisplayName(String(displayNameRaw)),
257
- installDir,
258
- vaultPath: String(vaultRaw || defaultVaultPath(installDir)),
259
- webPort,
260
- vaultPort,
261
- };
315
+ return finalizeAnswers(displayName, root);
262
316
  }
263
- async function promptPort(message, defaultPort) {
264
- let candidate = defaultPort;
317
+ /** Re-entry point when the user picks "choose a different name" mid-flow (avoids re-asking location). */
318
+ async function collectAnswersFromName(displayName, parentDir) {
265
319
  for (;;) {
266
- const resolution = await resolvePort(candidate);
267
- let suggested = resolution.resolved;
268
- if (!resolution.wasFree) {
269
- p.log.warn(`Port ${resolution.requested} is in use — suggesting ${suggested}.`);
270
- }
271
- const raw = await p.text({
272
- message,
273
- placeholder: String(suggested),
274
- defaultValue: String(suggested),
275
- });
276
- if (p.isCancel(raw))
320
+ const candidateRoot = rootFor(parentDir, displayName);
321
+ const check = checkRootDir(candidateRoot);
322
+ if (check.isTccProtected) {
323
+ p.log.error(TCC_PROTECTED_PATH_MESSAGE);
277
324
  return null;
278
- const parsed = Number(raw || suggested);
279
- if (!Number.isInteger(parsed) || parsed <= 1023) {
280
- p.log.error("Enter a valid port number above 1023.");
281
- candidate = suggested;
282
- continue;
283
325
  }
284
- const check = await resolvePort(parsed);
285
- if (!check.wasFree) {
286
- candidate = check.resolved;
326
+ if (check.nonEmpty && !check.isPriorArelosInstall) {
327
+ p.log.error(`${check.path} also already has files in it — try a different name.`);
328
+ const nameRaw = await p.text({ message: "What should we call your system?" });
329
+ if (p.isCancel(nameRaw))
330
+ return null;
331
+ displayName = normalizeDisplayName(String(nameRaw));
287
332
  continue;
288
333
  }
289
- return parsed;
334
+ return finalizeAnswers(displayName, check.path);
290
335
  }
291
336
  }
337
+ /** Auto-pick free ports silently and assemble the final InstallAnswers. */
338
+ async function finalizeAnswers(displayName, root) {
339
+ const installDir = appDirFor(root);
340
+ const vaultPath = vaultPathFor(root);
341
+ const web = await resolvePort(DEFAULTS.webPort);
342
+ const vault = await resolvePort(DEFAULTS.vaultPort === web.resolved ? DEFAULTS.vaultPort + 1 : DEFAULTS.vaultPort);
343
+ return {
344
+ displayName,
345
+ root,
346
+ installDir,
347
+ vaultPath,
348
+ webPort: web.resolved,
349
+ vaultPort: vault.resolved,
350
+ };
351
+ }
292
352
  function spinner(quiet) {
293
353
  if (quiet) {
294
354
  return {
package/dist/list.js ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `arelos list`. Table of every registered install (name, root, ports, service
3
+ * status) — the multi-install discovery surface (0.2.0).
4
+ */
5
+ import pc from "picocolors";
6
+ import { readConfig, readConfigAt, resolveServiceLabels } from "./config.js";
7
+ import { getServiceStatus } from "./launchd.js";
8
+ import { readRegistry } from "./registry.js";
9
+ import { existsSync } from "node:fs";
10
+ import { legacyConfigPath } from "./paths.js";
11
+ export async function listCommand() {
12
+ const entries = readRegistry();
13
+ const rows = [];
14
+ for (const entry of entries) {
15
+ const config = readConfigAt(entry.root);
16
+ if (!config) {
17
+ rows.push({ name: entry.name, root: entry.root, webPort: "?", vaultPort: "?", status: pc.red("config missing") });
18
+ continue;
19
+ }
20
+ const labels = resolveServiceLabels(config);
21
+ const [webSvc, vaultSvc] = await Promise.all([getServiceStatus(labels.web), getServiceStatus(labels.vault)]);
22
+ const status = webSvc.loaded && vaultSvc.loaded ? pc.green("running") : pc.yellow("stopped");
23
+ rows.push({
24
+ name: entry.name,
25
+ root: entry.root,
26
+ webPort: String(config.webPort),
27
+ vaultPort: String(config.vaultPort),
28
+ status,
29
+ });
30
+ }
31
+ if (existsSync(legacyConfigPath())) {
32
+ const legacy = readConfig();
33
+ if (legacy) {
34
+ const labels = resolveServiceLabels(legacy);
35
+ const [webSvc, vaultSvc] = await Promise.all([getServiceStatus(labels.web), getServiceStatus(labels.vault)]);
36
+ const status = webSvc.loaded && vaultSvc.loaded ? pc.green("running") : pc.yellow("stopped");
37
+ rows.push({
38
+ name: "(unnamed — legacy install)",
39
+ root: legacy.installDir,
40
+ webPort: String(legacy.webPort),
41
+ vaultPort: String(legacy.vaultPort),
42
+ status,
43
+ });
44
+ }
45
+ }
46
+ if (rows.length === 0) {
47
+ console.log("No Arel OS installs found. Run `npx arelos` to install.");
48
+ return 0;
49
+ }
50
+ const nameWidth = Math.max(4, ...rows.map((r) => r.name.length));
51
+ const rootWidth = Math.max(4, ...rows.map((r) => r.root.length));
52
+ console.log(`${"NAME".padEnd(nameWidth)} ${"ROOT".padEnd(rootWidth)} WEB VAULT STATUS`);
53
+ for (const row of rows) {
54
+ console.log(`${row.name.padEnd(nameWidth)} ${row.root.padEnd(rootWidth)} ${row.webPort.padEnd(5)} ${row.vaultPort.padEnd(5)} ${row.status}`);
55
+ }
56
+ return 0;
57
+ }
package/dist/logs.js CHANGED
@@ -1,12 +1,16 @@
1
1
  /**
2
- * `rlo logs` (spec §2). Tails <installDir>/logs/service/{web,vault}.log.
2
+ * `arelos logs`. Tails <root>/logs/service/{web,vault}.log (0.2.0 self-contained
3
+ * layout — logs live at the install root, not inside the app checkout).
4
+ * Legacy (pre-0.2.0) installs have no `root`; their logs live under
5
+ * installDir directly, matching the layout their plists were rendered with.
3
6
  */
4
7
  import { existsSync, readFileSync } from "node:fs";
5
8
  import { join } from "node:path";
6
9
  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
+ import { resolveInstall } from "./cli-context.js";
11
+ import { resolveRoot } from "./config.js";
12
+ export function logPathFor(root, which) {
13
+ return join(root, "logs", "service", `${which}.log`);
10
14
  }
11
15
  export function lastLines(filePath, n) {
12
16
  if (!existsSync(filePath))
@@ -15,14 +19,15 @@ export function lastLines(filePath, n) {
15
19
  const lines = content.split("\n");
16
20
  return lines.slice(Math.max(0, lines.length - n - 1)).join("\n");
17
21
  }
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
+ export async function logsCommand(flags, name) {
23
+ const result = await resolveInstall({ name, interactive: process.stdout.isTTY === true });
24
+ if (!result.ok) {
25
+ console.error(result.message);
22
26
  return 1;
23
27
  }
28
+ const config = result.install.config;
24
29
  const targets = flags.which === "both" ? ["web", "vault"] : [flags.which];
25
- const paths = targets.map((t) => logPathFor(config.installDir, t));
30
+ const paths = targets.map((t) => logPathFor(resolveRoot(config), t));
26
31
  if (flags.follow) {
27
32
  const existing = paths.filter((p) => existsSync(p));
28
33
  if (existing.length === 0) {
package/dist/paths.js CHANGED
@@ -1,16 +1,42 @@
1
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.
2
+ * Central place for the fixed, well-known paths `arelos` reads/writes.
3
+ * Everything here honors ARELOS_CONFIG_PATH / ARELOS_REGISTRY_PATH so
4
+ * tests/dry-runs never touch the real ~/.arelos.
5
5
  */
6
6
  import { createHash } from "node:crypto";
7
7
  import { homedir } from "node:os";
8
8
  import { join } from "node:path";
9
- export function configPath() {
10
- return process.env.ARELOS_CONFIG_PATH ?? join(homedir(), ".arelos", "config.json");
9
+ /**
10
+ * Per-install config now lives at <root>/config.json (0.2.0 self-contained
11
+ * layout). ARELOS_CONFIG_PATH remains the escape hatch for tests/dry-runs and
12
+ * for pointing a command at a specific install's config directly. There is no
13
+ * single well-known default anymore — callers that need "the" config either
14
+ * have a root in hand (installConfigPath(root)) or go through the registry.
15
+ */
16
+ export function installConfigPath(root) {
17
+ return join(root, "config.json");
18
+ }
19
+ /**
20
+ * Pre-0.2.0 fixed config location. Still read as a fallback "unnamed install"
21
+ * so 0.1.x installs remain manageable after upgrading the CLI.
22
+ */
23
+ export function legacyConfigPath() {
24
+ return join(homedir(), ".arelos", "config.json");
25
+ }
26
+ /** Explicit override honored everywhere a single config path is needed directly. */
27
+ export function configPathOverride() {
28
+ return process.env.ARELOS_CONFIG_PATH ?? null;
11
29
  }
12
30
  export function configDir() {
13
- return join(configPath(), "..");
31
+ return join(homedir(), ".arelos");
32
+ }
33
+ /**
34
+ * Global multi-install registry: ~/.arelos/installs.json, listing every named
35
+ * install on this Mac ({name, slug, root, createdAt}). ARELOS_REGISTRY_PATH
36
+ * overrides the location for tests so they never touch the real file.
37
+ */
38
+ export function registryPath() {
39
+ return process.env.ARELOS_REGISTRY_PATH ?? join(homedir(), ".arelos", "installs.json");
14
40
  }
15
41
  export function launchAgentsDir() {
16
42
  return join(homedir(), "Library", "LaunchAgents");
@@ -50,3 +76,49 @@ export function expandHome(p) {
50
76
  return join(homedir(), p.slice(2));
51
77
  return p;
52
78
  }
79
+ /**
80
+ * Folders macOS TCC privacy protection blocks background (launchd-spawned)
81
+ * processes from accessing without an interactive grant: Desktop, Documents,
82
+ * Downloads, and iCloud Drive (~/Library/Mobile Documents). A service that
83
+ * installs into one of these will register fine but crash-loop forever on
84
+ * start with `Operation not permitted` (field bug: exit 126). Checked against
85
+ * the *resolved* (home-expanded) path so `~/Desktop/foo` and
86
+ * `/Users/x/Desktop/foo` are both caught.
87
+ */
88
+ const TCC_PROTECTED_SUFFIXES = ["Desktop", "Documents", "Downloads", "Library/Mobile Documents"];
89
+ /**
90
+ * True if `rawPath` resolves to inside (or exactly at) one of the TCC-protected
91
+ * directories under `home`. Pure string/path logic — no filesystem access —
92
+ * so it can be unit tested without touching disk.
93
+ *
94
+ * - Exact home root (`~`) is safe.
95
+ * - Sibling names that merely start with the same string (e.g. `~/Desktopx`)
96
+ * are safe — matched by path segment, not string prefix.
97
+ * - Nested arbitrarily deep paths inside a protected dir are caught.
98
+ * - Case-insensitive, matching macOS's default case-insensitive APFS/HFS+.
99
+ */
100
+ export function isTccProtectedPath(rawPath, home = homedir()) {
101
+ const resolved = expandHomeWith(rawPath, home);
102
+ const normalizedHome = normalizeForCompare(home);
103
+ const normalizedResolved = normalizeForCompare(resolved);
104
+ for (const suffix of TCC_PROTECTED_SUFFIXES) {
105
+ const protectedDir = normalizeForCompare(join(normalizedHome, suffix));
106
+ if (normalizedResolved === protectedDir || normalizedResolved.startsWith(`${protectedDir}/`)) {
107
+ return true;
108
+ }
109
+ }
110
+ return false;
111
+ }
112
+ function expandHomeWith(p, home) {
113
+ if (p === "~")
114
+ return home;
115
+ if (p.startsWith("~/"))
116
+ return join(home, p.slice(2));
117
+ return p;
118
+ }
119
+ function normalizeForCompare(p) {
120
+ // Collapse any trailing slash and lowercase for macOS's default
121
+ // case-insensitive filesystem semantics.
122
+ const withoutTrailingSlash = p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p;
123
+ return withoutTrailingSlash.toLowerCase();
124
+ }
package/dist/plist.js CHANGED
@@ -1,12 +1,24 @@
1
1
  /**
2
- * Plist template rendering (portability-contract.md §3.1). Templates ship at
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.
2
+ * Plist template rendering. Templates ship at
3
+ * `scripts/service/{web,vault}.plist.tmpl` in the app repo and take three
4
+ * tokens:
5
+ * - {{INSTALL_DIR}} the app checkout (root/app): where run-*.sh lives and
6
+ * the job's WorkingDirectory.
7
+ * - {{ROOT_DIR}} — the self-contained install root: where logs/service/
8
+ * lives (0.2.0 self-contained layout — logs are a root-level concern, not
9
+ * nested inside the app checkout).
10
+ * - {{LABEL}} — the per-install launchd label from paths.ts deriveServiceLabels.
11
+ * Ports/vaultPath are read from config at process start by the run-*.sh
12
+ * scripts / server, never baked into the plist.
7
13
  */
8
- export function renderPlistTemplate(template, installDir, label) {
9
- return template.split("{{INSTALL_DIR}}").join(installDir).split("{{LABEL}}").join(label);
14
+ export function renderPlistTemplate(template, installDir, rootDir, label) {
15
+ return template
16
+ .split("{{INSTALL_DIR}}")
17
+ .join(installDir)
18
+ .split("{{ROOT_DIR}}")
19
+ .join(rootDir)
20
+ .split("{{LABEL}}")
21
+ .join(label);
10
22
  }
11
23
  /** 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). */
12
24
  export function looksLikeValidPlist(xml) {
package/dist/ports.js CHANGED
@@ -1,28 +1,46 @@
1
1
  /**
2
2
  * Zero-dependency free-port detection via node:net (spec §1 Step 5).
3
+ *
4
+ * A port must be probed on BOTH loopback families: a server listening only on
5
+ * [::1] (IPv6) leaves 127.0.0.1 bindable, so an IPv4-only probe reports the
6
+ * port "free" while browsers (which resolve localhost to ::1 first) hit the
7
+ * other process. Field-tested: a Node dev server on [::1]:1347 made the
8
+ * installer pre-fill an occupied port. A port counts as free only if every
9
+ * loopback family available on this machine can bind it.
3
10
  */
4
11
  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) {
12
+ /** Error codes meaning "this address family isn't available on this machine". */
13
+ const FAMILY_UNAVAILABLE = new Set(["EADDRNOTAVAIL", "EAFNOSUPPORT", "EINVAL"]);
14
+ /** Try to bind `port` on one loopback host and report what happened. */
15
+ function probeBind(port, host) {
7
16
  return new Promise((resolve) => {
8
17
  const server = createServer();
9
18
  server.once("error", (err) => {
10
19
  server.close();
11
- if (err.code === "EADDRINUSE" || err.code === "EACCES") {
12
- resolve(false);
20
+ if (err.code && FAMILY_UNAVAILABLE.has(err.code)) {
21
+ // No IPv6 (or IPv4) loopback on this machine — nothing to conflict with.
22
+ resolve("family-unavailable");
13
23
  }
14
24
  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);
25
+ // EADDRINUSE/EACCES, or anything unexpected — treat conservatively as
26
+ // taken so we never suggest a port we can't actually validate.
27
+ resolve("taken");
18
28
  }
19
29
  });
20
30
  server.once("listening", () => {
21
- server.close(() => resolve(true));
31
+ server.close(() => resolve("free"));
22
32
  });
23
- server.listen(port, "127.0.0.1");
33
+ server.listen(port, host);
24
34
  });
25
35
  }
36
+ /**
37
+ * Resolve true if `port` is free to bind on ALL applicable loopback families
38
+ * (127.0.0.1 and ::1), false if any listener holds it on either family.
39
+ */
40
+ export async function isPortFree(port) {
41
+ const [v4, v6] = await Promise.all([probeBind(port, "127.0.0.1"), probeBind(port, "::1")]);
42
+ return v4 !== "taken" && v6 !== "taken";
43
+ }
26
44
  /**
27
45
  * Find the first free port at or above `start`, scanning upward. Stops at
28
46
  * `start + maxScan` to avoid an unbounded loop.