arelos 0.1.2 → 0.2.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/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, defaultInstallDirFor, defaultVaultPath, DEFAULTS, normalizeDisplayName, resolvePort, slugifyName, 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.");
@@ -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 ?? defaultInstallDirFor(displayName);
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,
@@ -212,100 +240,115 @@ async function collectAnswers(flags) {
212
240
  if (p.isCancel(displayNameRaw))
213
241
  return null;
214
242
  const displayName = normalizeDisplayName(String(displayNameRaw));
215
- const installDirDefault = defaultInstallDirFor(displayName);
216
- // Step 3Install location.
217
- let installDir = "";
218
- for (;;) {
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) {
219
251
  const raw = await p.text({
220
- message: `Where should ${displayName} be installed?`,
221
- placeholder: installDirDefault,
222
- defaultValue: installDirDefault,
252
+ message: "Parent folder to install into (we'll create the app's folder inside it):",
253
+ placeholder: defaultParentDir(),
254
+ defaultValue: defaultParentDir(),
223
255
  });
224
256
  if (p.isCancel(raw))
225
257
  return null;
226
- const check = checkInstallDir(String(raw || installDirDefault));
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
+ }
227
276
  if (!check.parentWritable) {
228
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);
229
282
  continue;
230
283
  }
231
284
  if (check.nonEmpty && !check.isPriorArelosInstall) {
232
- const subfolder = `${check.path}/${slugifyName(displayName) || "arelos"}`;
233
285
  const choice = await p.select({
234
- message: `${check.path} already has files in it, so installing there would fail.`,
286
+ message: `${check.path} already has files in it and isn't a prior install named "${displayName}".`,
235
287
  options: [
236
- { value: "subfolder", label: `Install into ${subfolder} instead`, hint: "recommended" },
237
- { value: "different", label: "Choose a different location" },
288
+ { value: "name", label: "Choose a different name" },
289
+ { value: "location", label: "Choose a different location" },
238
290
  { value: "cancel", label: "Cancel install" },
239
291
  ],
240
- initialValue: "subfolder",
241
292
  });
242
293
  if (p.isCancel(choice) || choice === "cancel")
243
294
  return null;
244
- if (choice === "different")
245
- continue;
246
- const subCheck = checkInstallDir(subfolder);
247
- if (subCheck.nonEmpty && !subCheck.isPriorArelosInstall) {
248
- p.log.error(`${subCheck.path} also already has files in it — choose another location.`);
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);
249
300
  continue;
250
301
  }
251
- installDir = subCheck.path;
252
- break;
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);
253
311
  }
254
- installDir = check.path;
312
+ root = check.path;
255
313
  break;
256
314
  }
257
- // Step 4 — Vault location.
258
- const vaultRaw = await p.text({
259
- message: "Where's your vault (notes/tasks/quests)?",
260
- placeholder: defaultVaultPath(installDir),
261
- defaultValue: defaultVaultPath(installDir),
262
- });
263
- if (p.isCancel(vaultRaw))
264
- return null;
265
- // Step 5 — Ports.
266
- const webPort = await promptPort("Web port?", DEFAULTS.webPort);
267
- if (webPort === null)
268
- return null;
269
- const vaultPort = await promptPort("Vault port?", DEFAULTS.vaultPort === webPort ? DEFAULTS.vaultPort + 1 : DEFAULTS.vaultPort);
270
- if (vaultPort === null)
271
- return null;
272
- return {
273
- displayName,
274
- installDir,
275
- vaultPath: String(vaultRaw || defaultVaultPath(installDir)),
276
- webPort,
277
- vaultPort,
278
- };
315
+ return finalizeAnswers(displayName, root);
279
316
  }
280
- async function promptPort(message, defaultPort) {
281
- 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) {
282
319
  for (;;) {
283
- const resolution = await resolvePort(candidate);
284
- let suggested = resolution.resolved;
285
- if (!resolution.wasFree) {
286
- p.log.warn(`Port ${resolution.requested} is in use — suggesting ${suggested}.`);
287
- }
288
- const raw = await p.text({
289
- message,
290
- placeholder: String(suggested),
291
- defaultValue: String(suggested),
292
- });
293
- 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);
294
324
  return null;
295
- const parsed = Number(raw || suggested);
296
- if (!Number.isInteger(parsed) || parsed <= 1023) {
297
- p.log.error("Enter a valid port number above 1023.");
298
- candidate = suggested;
299
- continue;
300
325
  }
301
- const check = await resolvePort(parsed);
302
- if (!check.wasFree) {
303
- 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));
304
332
  continue;
305
333
  }
306
- return parsed;
334
+ return finalizeAnswers(displayName, check.path);
307
335
  }
308
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
+ }
309
352
  function spinner(quiet) {
310
353
  if (quiet) {
311
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) {