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/cli-args.js +6 -6
- package/dist/cli-context.js +80 -0
- package/dist/cli.js +45 -13
- package/dist/config.js +40 -11
- package/dist/health.js +62 -1
- package/dist/install-plan.js +58 -17
- package/dist/install.js +139 -96
- package/dist/list.js +57 -0
- package/dist/logs.js +14 -9
- package/dist/paths.js +78 -6
- package/dist/plist.js +19 -7
- package/dist/ports.js +81 -13
- package/dist/registry.js +41 -0
- package/dist/repair.js +11 -7
- package/dist/repo.js +4 -4
- package/dist/scaffold.js +8 -8
- package/dist/services.js +13 -9
- package/dist/status.js +12 -7
- package/dist/uninstall.js +40 -19
- package/dist/update.js +11 -9
- package/package.json +2 -3
package/dist/install.js
CHANGED
|
@@ -1,37 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Interactive + non-interactive install flow
|
|
3
|
-
*
|
|
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 {
|
|
10
|
+
import { readConfigAt, writeConfig } from "./config.js";
|
|
10
11
|
import { runStreaming } from "./exec.js";
|
|
11
|
-
import { waitForHealthy } from "./health.js";
|
|
12
|
-
import {
|
|
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 {
|
|
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
|
-
`
|
|
51
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
193
|
-
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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:
|
|
221
|
-
placeholder:
|
|
222
|
-
defaultValue:
|
|
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
|
-
|
|
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
|
|
286
|
+
message: `${check.path} already has files in it and isn't a prior install named "${displayName}".`,
|
|
235
287
|
options: [
|
|
236
|
-
{ value: "
|
|
237
|
-
{ value: "
|
|
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 === "
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
312
|
+
root = check.path;
|
|
255
313
|
break;
|
|
256
314
|
}
|
|
257
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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
|
|
284
|
-
|
|
285
|
-
if (
|
|
286
|
-
p.log.
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|
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
|
-
* `
|
|
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 {
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
20
|
-
if (!
|
|
21
|
-
console.error(
|
|
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
|
|
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 `
|
|
3
|
-
* Everything here honors ARELOS_CONFIG_PATH
|
|
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
|
-
|
|
10
|
-
|
|
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(
|
|
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
|
|
3
|
-
* `scripts/service/{web,vault}.plist.tmpl` in the app repo and take
|
|
4
|
-
* tokens
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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) {
|