arelos 0.1.0 → 0.1.2
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 +0 -0
- package/dist/config.js +9 -1
- package/dist/install-plan.js +24 -2
- package/dist/install.js +46 -15
- package/dist/launchd.js +35 -0
- package/dist/paths.js +25 -2
- package/dist/plist.js +6 -5
- package/dist/ports.js +27 -9
- package/dist/repair.js +4 -2
- package/dist/services.js +20 -12
- package/dist/status.js +4 -4
- package/dist/uninstall.js +6 -5
- package/dist/update.js +4 -3
- package/package.json +1 -1
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
|
+
}
|
package/dist/install-plan.js
CHANGED
|
@@ -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",
|
|
@@ -19,6 +19,26 @@ export function normalizeDisplayName(input) {
|
|
|
19
19
|
const trimmed = input.trim();
|
|
20
20
|
return trimmed.length > 0 ? trimmed : DEFAULTS.displayName;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Slugify a chosen display name into a safe directory-name fragment:
|
|
24
|
+
* lowercase, spaces/unsafe chars -> dashes, collapsed and trimmed. Falls back
|
|
25
|
+
* to the fixed default install dir's basename when the slug would be empty
|
|
26
|
+
* (e.g. a name made entirely of emoji/punctuation).
|
|
27
|
+
*/
|
|
28
|
+
export function slugifyName(input) {
|
|
29
|
+
return input
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.trim()
|
|
32
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
33
|
+
.replace(/^-+|-+$/g, "");
|
|
34
|
+
}
|
|
35
|
+
/** Default install dir derived from the chosen display name, e.g. "My Brain" -> "~/my-brain". */
|
|
36
|
+
export function defaultInstallDirFor(displayName) {
|
|
37
|
+
const slug = slugifyName(displayName);
|
|
38
|
+
if (!slug)
|
|
39
|
+
return DEFAULTS.installDir;
|
|
40
|
+
return `~/${slug}`;
|
|
41
|
+
}
|
|
22
42
|
export function checkInstallDir(rawPath) {
|
|
23
43
|
const path = expandHome(rawPath);
|
|
24
44
|
const parent = dirname(path);
|
|
@@ -55,12 +75,14 @@ export async function resolvePort(requested) {
|
|
|
55
75
|
return { requested, resolved: suggestion, wasFree: false };
|
|
56
76
|
}
|
|
57
77
|
export function toArelConfig(answers) {
|
|
78
|
+
const installDir = expandHome(answers.installDir);
|
|
58
79
|
return {
|
|
59
80
|
version: 1,
|
|
60
81
|
displayName: answers.displayName,
|
|
61
|
-
installDir
|
|
82
|
+
installDir,
|
|
62
83
|
vaultPath: expandHome(answers.vaultPath),
|
|
63
84
|
webPort: answers.webPort,
|
|
64
85
|
vaultPort: answers.vaultPort,
|
|
86
|
+
serviceLabels: deriveServiceLabels(installDir),
|
|
65
87
|
};
|
|
66
88
|
}
|
package/dist/install.js
CHANGED
|
@@ -9,12 +9,13 @@ import { ensureBun } from "./bun-setup.js";
|
|
|
9
9
|
import { readConfig, writeConfig } from "./config.js";
|
|
10
10
|
import { runStreaming } from "./exec.js";
|
|
11
11
|
import { waitForHealthy } from "./health.js";
|
|
12
|
-
import { checkInstallDir, defaultVaultPath, DEFAULTS, normalizeDisplayName, resolvePort, toArelConfig, } from "./install-plan.js";
|
|
12
|
+
import { checkInstallDir, defaultInstallDirFor, defaultVaultPath, DEFAULTS, normalizeDisplayName, resolvePort, slugifyName, toArelConfig, } from "./install-plan.js";
|
|
13
13
|
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)
|
|
@@ -159,7 +173,7 @@ export async function runInstall(argv, flags) {
|
|
|
159
173
|
}
|
|
160
174
|
}
|
|
161
175
|
p.outro([
|
|
162
|
-
pc.green(
|
|
176
|
+
pc.green(`${answers.displayName} is running at ${url}`),
|
|
163
177
|
"Runs 24/7 in the background.",
|
|
164
178
|
"Next: arelos status · arelos logs · arelos update · arelos uninstall",
|
|
165
179
|
].join("\n"));
|
|
@@ -175,7 +189,7 @@ async function checkGit() {
|
|
|
175
189
|
async function collectAnswers(flags) {
|
|
176
190
|
if (flags.yes) {
|
|
177
191
|
const displayName = normalizeDisplayName(flags.displayName ?? DEFAULTS.displayName);
|
|
178
|
-
const installDir = flags.installDir ??
|
|
192
|
+
const installDir = flags.installDir ?? defaultInstallDirFor(displayName);
|
|
179
193
|
const vaultPath = flags.vaultPath ?? defaultVaultPath(installDir);
|
|
180
194
|
const webPortReq = flags.webPort ?? DEFAULTS.webPort;
|
|
181
195
|
const vaultPortReq = flags.vaultPort ?? DEFAULTS.vaultPort;
|
|
@@ -197,28 +211,45 @@ async function collectAnswers(flags) {
|
|
|
197
211
|
});
|
|
198
212
|
if (p.isCancel(displayNameRaw))
|
|
199
213
|
return null;
|
|
214
|
+
const displayName = normalizeDisplayName(String(displayNameRaw));
|
|
215
|
+
const installDirDefault = defaultInstallDirFor(displayName);
|
|
200
216
|
// Step 3 — Install location.
|
|
201
217
|
let installDir = "";
|
|
202
218
|
for (;;) {
|
|
203
219
|
const raw = await p.text({
|
|
204
|
-
message:
|
|
205
|
-
placeholder:
|
|
206
|
-
defaultValue:
|
|
220
|
+
message: `Where should ${displayName} be installed?`,
|
|
221
|
+
placeholder: installDirDefault,
|
|
222
|
+
defaultValue: installDirDefault,
|
|
207
223
|
});
|
|
208
224
|
if (p.isCancel(raw))
|
|
209
225
|
return null;
|
|
210
|
-
const check = checkInstallDir(String(raw ||
|
|
226
|
+
const check = checkInstallDir(String(raw || installDirDefault));
|
|
211
227
|
if (!check.parentWritable) {
|
|
212
228
|
p.log.error(`Cannot write to ${check.path} — choose another location.`);
|
|
213
229
|
continue;
|
|
214
230
|
}
|
|
215
231
|
if (check.nonEmpty && !check.isPriorArelosInstall) {
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
232
|
+
const subfolder = `${check.path}/${slugifyName(displayName) || "arelos"}`;
|
|
233
|
+
const choice = await p.select({
|
|
234
|
+
message: `${check.path} already has files in it, so installing there would fail.`,
|
|
235
|
+
options: [
|
|
236
|
+
{ value: "subfolder", label: `Install into ${subfolder} instead`, hint: "recommended" },
|
|
237
|
+
{ value: "different", label: "Choose a different location" },
|
|
238
|
+
{ value: "cancel", label: "Cancel install" },
|
|
239
|
+
],
|
|
240
|
+
initialValue: "subfolder",
|
|
219
241
|
});
|
|
220
|
-
if (p.isCancel(
|
|
242
|
+
if (p.isCancel(choice) || choice === "cancel")
|
|
243
|
+
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.`);
|
|
221
249
|
continue;
|
|
250
|
+
}
|
|
251
|
+
installDir = subCheck.path;
|
|
252
|
+
break;
|
|
222
253
|
}
|
|
223
254
|
installDir = check.path;
|
|
224
255
|
break;
|
|
@@ -239,7 +270,7 @@ async function collectAnswers(flags) {
|
|
|
239
270
|
if (vaultPort === null)
|
|
240
271
|
return null;
|
|
241
272
|
return {
|
|
242
|
-
displayName
|
|
273
|
+
displayName,
|
|
243
274
|
installDir,
|
|
244
275
|
vaultPath: String(vaultRaw || defaultVaultPath(installDir)),
|
|
245
276
|
webPort,
|
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
|
-
|
|
18
|
-
|
|
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/
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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/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
|
-
/**
|
|
6
|
-
|
|
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
|
|
12
|
-
|
|
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
|
-
//
|
|
16
|
-
// so we never suggest a port we can't actually validate.
|
|
17
|
-
resolve(
|
|
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(
|
|
31
|
+
server.close(() => resolve("free"));
|
|
22
32
|
});
|
|
23
|
-
server.listen(port,
|
|
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.
|
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
|
-
|
|
71
|
-
|
|
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 {
|
|
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:
|
|
14
|
-
{ label:
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
25
|
-
getServiceStatus(
|
|
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 {
|
|
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
|
-
|
|
31
|
-
await bootoutService(
|
|
32
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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…");
|