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/ports.js
CHANGED
|
@@ -1,24 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Zero-dependency free-port detection via node:net (spec §1 Step 5).
|
|
3
3
|
*
|
|
4
|
-
* A port must be probed on
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* A port must be probed on every listener shape a process can occupy it
|
|
5
|
+
* with, not just the specific loopback addresses:
|
|
6
|
+
*
|
|
7
|
+
* - A server listening only on [::1] (IPv6) leaves 127.0.0.1 bindable, so an
|
|
8
|
+
* IPv4-only probe reports the port "free" while browsers (which resolve
|
|
9
|
+
* localhost to ::1 first) hit the other process. Field-tested: a Node dev
|
|
10
|
+
* server on [::1]:1347 made the installer pre-fill an occupied port.
|
|
11
|
+
* - A server listening on the WILDCARD address (e.g. `Bun.serve()` with no
|
|
12
|
+
* `hostname`, or `net.createServer().listen(port)` with no host) binds
|
|
13
|
+
* every local address in that family, including both loopback addresses,
|
|
14
|
+
* yet a bind-probe of 127.0.0.1/::1 alone can still report "free" on
|
|
15
|
+
* macOS: binding a specific address with SO_REUSEADDR (Node's default) can
|
|
16
|
+
* succeed even though a wildcard listener already holds the port. Real
|
|
17
|
+
* incident: `isPortFree(5274)` reported free while a Bun server held
|
|
18
|
+
* `*:5274`, and the installer wrote that occupied port into config.
|
|
19
|
+
*
|
|
20
|
+
* The fix probes both shapes with two independent techniques:
|
|
21
|
+
* 1. Bind-probes on the specific loopback addresses AND the wildcard
|
|
22
|
+
* addresses for each family. A wildcard bind-probe fails with
|
|
23
|
+
* EADDRINUSE whenever *any* listener (wildcard or specific) holds the
|
|
24
|
+
* port in that family — it is the strongest bind test available.
|
|
25
|
+
* 2. A short-timeout TCP connect-probe to 127.0.0.1 and ::1. A successful
|
|
26
|
+
* connection proves the port is occupied even in edge cases the bind
|
|
27
|
+
* probes can't reach; ECONNREFUSED proves free for that family; a
|
|
28
|
+
* timeout or other inconclusive result is never treated as occupied on
|
|
29
|
+
* its own.
|
|
30
|
+
*
|
|
31
|
+
* A port counts as free only if every applicable bind probe succeeds AND no
|
|
32
|
+
* connect probe manages to connect.
|
|
10
33
|
*/
|
|
11
|
-
import { createServer } from "node:net";
|
|
34
|
+
import { createServer, connect as netConnect } from "node:net";
|
|
12
35
|
/** Error codes meaning "this address family isn't available on this machine". */
|
|
13
36
|
const FAMILY_UNAVAILABLE = new Set(["EADDRNOTAVAIL", "EAFNOSUPPORT", "EINVAL"]);
|
|
14
|
-
/** Try to bind `port` on one
|
|
37
|
+
/** Try to bind `port` on one host (specific address or wildcard) and report what happened. */
|
|
15
38
|
function probeBind(port, host) {
|
|
16
39
|
return new Promise((resolve) => {
|
|
17
40
|
const server = createServer();
|
|
18
41
|
server.once("error", (err) => {
|
|
19
42
|
server.close();
|
|
20
43
|
if (err.code && FAMILY_UNAVAILABLE.has(err.code)) {
|
|
21
|
-
// No IPv6 (or IPv4)
|
|
44
|
+
// No IPv6 (or IPv4) support on this machine — nothing to conflict with.
|
|
22
45
|
resolve("family-unavailable");
|
|
23
46
|
}
|
|
24
47
|
else {
|
|
@@ -33,13 +56,58 @@ function probeBind(port, host) {
|
|
|
33
56
|
server.listen(port, host);
|
|
34
57
|
});
|
|
35
58
|
}
|
|
59
|
+
const CONNECT_PROBE_TIMEOUT_MS = 250;
|
|
60
|
+
/**
|
|
61
|
+
* Try to TCP-connect to `host:port` with a short timeout. A successful
|
|
62
|
+
* connection means something is definitely listening there. ECONNREFUSED
|
|
63
|
+
* means nothing is listening (free, for that family). Anything else
|
|
64
|
+
* (timeout, unreachable, family unavailable) is inconclusive and must not be
|
|
65
|
+
* treated as proof of occupation by itself.
|
|
66
|
+
*/
|
|
67
|
+
function probeConnect(port, host) {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
let settled = false;
|
|
70
|
+
let socket;
|
|
71
|
+
const finish = (result) => {
|
|
72
|
+
if (settled)
|
|
73
|
+
return;
|
|
74
|
+
settled = true;
|
|
75
|
+
socket.destroy();
|
|
76
|
+
resolve(result);
|
|
77
|
+
};
|
|
78
|
+
socket = netConnect({ port, host });
|
|
79
|
+
socket.setTimeout(CONNECT_PROBE_TIMEOUT_MS);
|
|
80
|
+
socket.once("connect", () => finish("occupied"));
|
|
81
|
+
socket.once("timeout", () => finish("inconclusive"));
|
|
82
|
+
socket.once("error", (err) => {
|
|
83
|
+
if (err.code === "ECONNREFUSED") {
|
|
84
|
+
finish("free");
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
// EHOSTUNREACH, ENETUNREACH, family unavailable, etc. — inconclusive.
|
|
88
|
+
finish("inconclusive");
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
36
93
|
/**
|
|
37
|
-
* Resolve true if `port` is free
|
|
38
|
-
*
|
|
94
|
+
* Resolve true if `port` is free on every probe: bind-probes on the specific
|
|
95
|
+
* loopback addresses and the wildcard addresses for both families, plus
|
|
96
|
+
* connect-probes to the loopback addresses. False if any bind probe reports
|
|
97
|
+
* "taken" or any connect probe successfully connects.
|
|
39
98
|
*/
|
|
40
99
|
export async function isPortFree(port) {
|
|
41
|
-
const [
|
|
42
|
-
|
|
100
|
+
const [v4Loopback, v6Loopback, v4Wildcard, v6Wildcard, v4Connect, v6Connect] = await Promise.all([
|
|
101
|
+
probeBind(port, "127.0.0.1"),
|
|
102
|
+
probeBind(port, "::1"),
|
|
103
|
+
probeBind(port, "0.0.0.0"),
|
|
104
|
+
probeBind(port, "::"),
|
|
105
|
+
probeConnect(port, "127.0.0.1"),
|
|
106
|
+
probeConnect(port, "::1"),
|
|
107
|
+
]);
|
|
108
|
+
const bindsOk = [v4Loopback, v6Loopback, v4Wildcard, v6Wildcard].every((result) => result !== "taken");
|
|
109
|
+
const noConnectSucceeded = v4Connect !== "occupied" && v6Connect !== "occupied";
|
|
110
|
+
return bindsOk && noConnectSucceeded;
|
|
43
111
|
}
|
|
44
112
|
/**
|
|
45
113
|
* Find the first free port at or above `start`, scanning upward. Stops at
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global multi-install registry (~/.arelos/installs.json, or
|
|
3
|
+
* ARELOS_REGISTRY_PATH override). Each entry records one named, self-contained
|
|
4
|
+
* install so `arelos` can find/list/select among several installs on one Mac
|
|
5
|
+
* without any of them clobbering a shared config file (0.2.0 mission).
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname } from "node:path";
|
|
9
|
+
import { registryPath } from "./paths.js";
|
|
10
|
+
export function readRegistry() {
|
|
11
|
+
const p = registryPath();
|
|
12
|
+
if (!existsSync(p))
|
|
13
|
+
return [];
|
|
14
|
+
const raw = JSON.parse(readFileSync(p, "utf8"));
|
|
15
|
+
if (!Array.isArray(raw)) {
|
|
16
|
+
throw new Error(`Invalid registry at ${p}: expected an array`);
|
|
17
|
+
}
|
|
18
|
+
return raw;
|
|
19
|
+
}
|
|
20
|
+
/** Atomic write: tmp file + rename, so a reader never sees a partial file. */
|
|
21
|
+
function writeRegistry(entries) {
|
|
22
|
+
const p = registryPath();
|
|
23
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
24
|
+
const tmp = `${p}.tmp`;
|
|
25
|
+
writeFileSync(tmp, `${JSON.stringify(entries, null, 2)}\n`, { mode: 0o600 });
|
|
26
|
+
renameSync(tmp, p);
|
|
27
|
+
}
|
|
28
|
+
/** Append (or replace, by matching root) an entry — a reinstall at the same root updates it in place. */
|
|
29
|
+
export function addRegistryEntry(entry) {
|
|
30
|
+
const entries = readRegistry().filter((e) => e.root !== entry.root);
|
|
31
|
+
entries.push(entry);
|
|
32
|
+
writeRegistry(entries);
|
|
33
|
+
}
|
|
34
|
+
/** Remove the entry for a given root (uninstall). No-op if absent. */
|
|
35
|
+
export function removeRegistryEntry(root) {
|
|
36
|
+
const entries = readRegistry().filter((e) => e.root !== root);
|
|
37
|
+
writeRegistry(entries);
|
|
38
|
+
}
|
|
39
|
+
export function findRegistryEntryByName(name) {
|
|
40
|
+
return readRegistry().find((e) => e.name === name || e.slug === name) ?? null;
|
|
41
|
+
}
|
package/dist/repair.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import * as p from "@clack/prompts";
|
|
7
7
|
import pc from "picocolors";
|
|
8
|
-
import { resolveServiceLabels } from "./config.js";
|
|
8
|
+
import { resolveRoot, resolveServiceLabels } from "./config.js";
|
|
9
9
|
import { runUpdate } from "./update.js";
|
|
10
|
-
import { waitForHealthy } from "./health.js";
|
|
10
|
+
import { formatHealthTimeoutDiagnostics, waitForHealthy } from "./health.js";
|
|
11
|
+
import { lastLines, logPathFor } from "./logs.js";
|
|
11
12
|
import { bootstrapAndStart, installServiceFiles } from "./services.js";
|
|
12
13
|
import { runStreaming } from "./exec.js";
|
|
13
14
|
import { ensureBun } from "./bun-setup.js";
|
|
@@ -40,9 +41,8 @@ export async function runRepairMenu(existing) {
|
|
|
40
41
|
return runRepair(existing);
|
|
41
42
|
}
|
|
42
43
|
if (choice === "reinstall") {
|
|
43
|
-
p.log.message("
|
|
44
|
-
"
|
|
45
|
-
"for a side-by-side install with a different ARELOS_CONFIG_PATH.");
|
|
44
|
+
p.log.message("Just re-run `npx arelos` with a different name — every install is self-contained, so a new name " +
|
|
45
|
+
"creates a fresh, independent install side-by-side with this one (see `arelos list`).");
|
|
46
46
|
return 0;
|
|
47
47
|
}
|
|
48
48
|
return 1;
|
|
@@ -69,13 +69,17 @@ async function runRepair(existing) {
|
|
|
69
69
|
s.stop("Rebuilt.");
|
|
70
70
|
s.start("Re-rendering and re-bootstrapping services…");
|
|
71
71
|
const labels = resolveServiceLabels(existing);
|
|
72
|
-
installServiceFiles(existing.installDir, labels);
|
|
72
|
+
installServiceFiles(existing.installDir, resolveRoot(existing), labels);
|
|
73
73
|
const bootstrap = await bootstrapAndStart(labels);
|
|
74
74
|
s.stop(bootstrap.errors.length ? "Services re-registered with warnings." : "Services re-registered.");
|
|
75
75
|
for (const e of bootstrap.errors)
|
|
76
76
|
console.error(pc.yellow(e));
|
|
77
77
|
s.start("Health check…");
|
|
78
78
|
const health = await waitForHealthy(existing.webPort, existing.vaultPort);
|
|
79
|
-
s.stop(health.healthy ? "Healthy." : "Health check timed out
|
|
79
|
+
s.stop(health.healthy ? "Healthy." : "Health check timed out.");
|
|
80
|
+
if (!health.healthy) {
|
|
81
|
+
console.error(pc.red(formatHealthTimeoutDiagnostics(resolveRoot(existing), (p) => lastLines(p, 10), logPathFor)));
|
|
82
|
+
console.error(pc.dim("\nFull logs: arelos logs"));
|
|
83
|
+
}
|
|
80
84
|
return health.healthy ? 0 : 1;
|
|
81
85
|
}
|
package/dist/repo.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Getting the app source onto disk
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Getting the app source onto disk and keeping it updated (`arelos update`).
|
|
3
|
+
* Supports a `--local-repo <path>` override for development/dry-run testing
|
|
4
|
+
* so we never have to hit GitHub in tests.
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
@@ -40,7 +40,7 @@ export async function currentRevision(installDir) {
|
|
|
40
40
|
const res = await runCapture("git", ["-C", installDir, "rev-parse", "--short", "HEAD"]);
|
|
41
41
|
return res.code === 0 ? res.stdout.trim() : null;
|
|
42
42
|
}
|
|
43
|
-
/** Best-effort "is the local branch behind origin" check (
|
|
43
|
+
/** Best-effort "is the local branch behind origin" check (`arelos status`). */
|
|
44
44
|
export async function isBehindOrigin(installDir) {
|
|
45
45
|
const fetchRes = await runCapture("git", ["-C", installDir, "fetch", "--dry-run"]);
|
|
46
46
|
if (fetchRes.code !== 0)
|
package/dist/scaffold.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Vault scaffolding + supporting install-dir setup
|
|
3
|
-
*
|
|
4
|
-
* overwrites user data (spec §3.2 idempotency rule).
|
|
2
|
+
* Vault scaffolding + supporting install-dir setup. Only copies the template
|
|
3
|
+
* vault when the destination is empty — never overwrites user data.
|
|
5
4
|
*/
|
|
6
5
|
import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
7
6
|
import { join } from "node:path";
|
|
8
7
|
export class TemplateVaultMissingError extends Error {
|
|
9
8
|
constructor(templateDir) {
|
|
10
9
|
super(`templates/vault/ is missing at ${templateDir}. This is a repo gap — ` +
|
|
11
|
-
`the app repo must ship a seed vault at templates/vault
|
|
10
|
+
`the app repo must ship a seed vault at templates/vault/. Cannot scaffold a new vault without it.`);
|
|
12
11
|
this.name = "TemplateVaultMissingError";
|
|
13
12
|
}
|
|
14
13
|
}
|
|
@@ -34,10 +33,11 @@ export function scaffoldVault(installDir, vaultPath) {
|
|
|
34
33
|
cpSync(templateDir, vaultPath, { recursive: true });
|
|
35
34
|
return { copied: true };
|
|
36
35
|
}
|
|
37
|
-
/** Create <
|
|
38
|
-
*
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
/** Create <root>/logs/service/ — must exist before launchd bootstraps the
|
|
37
|
+
* plists, since they reference it for stdout/stderr. Logs live at the
|
|
38
|
+
* self-contained root, not inside the app checkout (0.2.0 layout). */
|
|
39
|
+
export function ensureLogsDir(root) {
|
|
40
|
+
const dir = join(root, "logs", "service");
|
|
41
41
|
mkdirSync(dir, { recursive: true });
|
|
42
42
|
return dir;
|
|
43
43
|
}
|
package/dist/services.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Ties together plist rendering + writing + launchd bootstrap
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Ties together plist rendering + writing + launchd bootstrap. Also used by
|
|
3
|
+
* `--no-service` dry runs, which stop before the launchctl calls (see
|
|
4
|
+
* install.ts).
|
|
5
|
+
*
|
|
6
|
+
* `installDir` is the app checkout (root/app); `root` is the self-contained
|
|
7
|
+
* install root that owns logs/ and config.json (0.2.0 layout). Both are
|
|
8
|
+
* needed because the plist bakes in both {{INSTALL_DIR}} and {{ROOT_DIR}}.
|
|
5
9
|
*/
|
|
6
10
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
11
|
import { join } from "node:path";
|
|
8
12
|
import { bootstrapService, kickstartService } from "./launchd.js";
|
|
9
13
|
import { deriveServiceLabels, launchAgentsDir, plistPath } from "./paths.js";
|
|
10
14
|
import { renderPlistTemplate } from "./plist.js";
|
|
11
|
-
export function renderServicePlists(installDir, labels = deriveServiceLabels(
|
|
15
|
+
export function renderServicePlists(installDir, root, labels = deriveServiceLabels(root)) {
|
|
12
16
|
const specs = [
|
|
13
17
|
{ label: labels.web, templateFile: "web.plist.tmpl" },
|
|
14
18
|
{ label: labels.vault, templateFile: "vault.plist.tmpl" },
|
|
@@ -19,22 +23,22 @@ export function renderServicePlists(installDir, labels = deriveServiceLabels(ins
|
|
|
19
23
|
throw new Error(`Missing plist template: ${templatePath}`);
|
|
20
24
|
}
|
|
21
25
|
const template = readFileSync(templatePath, "utf8");
|
|
22
|
-
const xml = renderPlistTemplate(template, installDir, label);
|
|
26
|
+
const xml = renderPlistTemplate(template, installDir, root, label);
|
|
23
27
|
return { label, templatePath, targetPath: plistPath(label), xml };
|
|
24
28
|
});
|
|
25
29
|
}
|
|
26
30
|
/** Write rendered plists to ~/Library/LaunchAgents and chmod the run scripts. */
|
|
27
|
-
export function installServiceFiles(installDir, labels = deriveServiceLabels(
|
|
31
|
+
export function installServiceFiles(installDir, root, labels = deriveServiceLabels(root)) {
|
|
28
32
|
mkdirSync(launchAgentsDir(), { recursive: true });
|
|
29
|
-
// The plists' StandardOutPath/StandardErrorPath point at <
|
|
33
|
+
// The plists' StandardOutPath/StandardErrorPath point at <root>/logs/service/*.log;
|
|
30
34
|
// launchd needs that directory to exist before it can bootstrap the job (a
|
|
31
35
|
// missing log dir is one confirmed cause of the "Bootstrap failed: 5:
|
|
32
36
|
// Input/output error" field report — see launchd.ts docstring). install.ts
|
|
33
37
|
// already calls ensureLogsDir before this, but repair/update funnel through
|
|
34
38
|
// here too, so guarantee it unconditionally rather than relying on callers
|
|
35
39
|
// to remember.
|
|
36
|
-
mkdirSync(join(
|
|
37
|
-
const rendered = renderServicePlists(installDir, labels);
|
|
40
|
+
mkdirSync(join(root, "logs", "service"), { recursive: true });
|
|
41
|
+
const rendered = renderServicePlists(installDir, root, labels);
|
|
38
42
|
for (const svc of rendered) {
|
|
39
43
|
writeFileSync(svc.targetPath, svc.xml);
|
|
40
44
|
}
|
package/dist/status.js
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `
|
|
3
|
-
*
|
|
2
|
+
* `arelos status`. Config summary + service state + port probes + git revision /
|
|
3
|
+
* behind-origin check. With multiple installs registered, resolves by name
|
|
4
|
+
* (or prompts/lists per resolveInstall's rule).
|
|
4
5
|
*/
|
|
5
6
|
import pc from "picocolors";
|
|
6
|
-
import {
|
|
7
|
+
import { resolveServiceLabels } from "./config.js";
|
|
8
|
+
import { resolveInstall } from "./cli-context.js";
|
|
7
9
|
import { checkVaultHealth, checkWebHealth } from "./health.js";
|
|
8
10
|
import { getServiceStatus } from "./launchd.js";
|
|
9
11
|
import { currentRevision, isBehindOrigin } from "./repo.js";
|
|
10
|
-
export async function statusCommand() {
|
|
11
|
-
const
|
|
12
|
-
if (!
|
|
13
|
-
console.error(
|
|
12
|
+
export async function statusCommand(name) {
|
|
13
|
+
const result = await resolveInstall({ name, interactive: process.stdout.isTTY === true });
|
|
14
|
+
if (!result.ok) {
|
|
15
|
+
console.error(result.message);
|
|
14
16
|
return 1;
|
|
15
17
|
}
|
|
18
|
+
const config = result.install.config;
|
|
16
19
|
console.log(pc.bold(config.displayName));
|
|
20
|
+
if (config.root)
|
|
21
|
+
console.log(` Root: ${config.root}`);
|
|
17
22
|
console.log(` Install dir: ${config.installDir}`);
|
|
18
23
|
console.log(` Vault path: ${config.vaultPath}`);
|
|
19
24
|
console.log(` Web port: ${config.webPort}`);
|
package/dist/uninstall.js
CHANGED
|
@@ -1,31 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* (
|
|
6
|
-
*
|
|
2
|
+
* `arelos uninstall`. Vault deletion is gated behind confirm + a literal typed
|
|
3
|
+
* "DELETE" — a single yes can never destroy notes. This module separates the
|
|
4
|
+
* pure decision logic (shouldDeleteVault) from the destructive I/O
|
|
5
|
+
* (performUninstall) so the gate can be unit tested without touching a
|
|
6
|
+
* filesystem.
|
|
7
|
+
*
|
|
8
|
+
* 0.2.0 self-contained layout: installDir (root/app) and vaultPath (root/vault)
|
|
9
|
+
* are siblings under root, so removing installDir never touches the vault —
|
|
10
|
+
* the pre-0.2.0 "install dir vs vault" gate semantics carry over unchanged.
|
|
11
|
+
* Removing the registry entry is separate from folder/vault deletion (always
|
|
12
|
+
* done, since a stale registry entry pointing at a gone or kept-around
|
|
13
|
+
* install is never useful).
|
|
7
14
|
*/
|
|
8
15
|
import * as p from "@clack/prompts";
|
|
9
16
|
import pc from "picocolors";
|
|
10
17
|
import { existsSync, rmSync, unlinkSync } from "node:fs";
|
|
11
|
-
import {
|
|
18
|
+
import { resolveRoot, resolveServiceLabels } from "./config.js";
|
|
19
|
+
import { resolveInstall } from "./cli-context.js";
|
|
12
20
|
import { bootoutService } from "./launchd.js";
|
|
13
|
-
import { plistPath,
|
|
21
|
+
import { plistPath, installConfigPath, legacyConfigPath } from "./paths.js";
|
|
22
|
+
import { removeRegistryEntry } from "./registry.js";
|
|
14
23
|
/**
|
|
15
24
|
* Pure gate: vault is deleted iff confirmed AND the typed word is exactly
|
|
16
25
|
* "DELETE". Any other input (including case variants, whitespace, empty)
|
|
17
|
-
* preserves the vault. This is the load-bearing safety rule
|
|
18
|
-
*
|
|
26
|
+
* preserves the vault. This is the load-bearing safety rule — kept as an
|
|
27
|
+
* isolated pure function so it's trivially unit-testable.
|
|
19
28
|
*/
|
|
20
29
|
export function shouldDeleteVault(confirmed, typedWord) {
|
|
21
30
|
return confirmed === true && typedWord === "DELETE";
|
|
22
31
|
}
|
|
23
|
-
export async function uninstallCommand() {
|
|
24
|
-
const
|
|
25
|
-
if (!
|
|
26
|
-
console.error(
|
|
32
|
+
export async function uninstallCommand(name) {
|
|
33
|
+
const result = await resolveInstall({ name, interactive: process.stdout.isTTY === true });
|
|
34
|
+
if (!result.ok) {
|
|
35
|
+
console.error(result.message);
|
|
27
36
|
return 1;
|
|
28
37
|
}
|
|
38
|
+
const { config, root } = result.install;
|
|
29
39
|
p.intro(pc.bold("Uninstall Arel OS"));
|
|
30
40
|
const labels = resolveServiceLabels(config);
|
|
31
41
|
await bootoutService(labels.web);
|
|
@@ -60,26 +70,37 @@ export async function uninstallCommand() {
|
|
|
60
70
|
}
|
|
61
71
|
}
|
|
62
72
|
const removeConfig = await p.confirm({
|
|
63
|
-
message: "Remove the saved config
|
|
73
|
+
message: "Remove the saved config? Keeping it lets a reinstall remember your settings.",
|
|
64
74
|
initialValue: false,
|
|
65
75
|
});
|
|
66
|
-
performUninstall(config.installDir, config.vaultPath, {
|
|
76
|
+
performUninstall(config.installDir, config.vaultPath, resolveRoot(config), {
|
|
67
77
|
removeInstallDir: !p.isCancel(removeInstallDir) && removeInstallDir,
|
|
68
78
|
deleteVault,
|
|
69
79
|
removeConfig: !p.isCancel(removeConfig) && removeConfig,
|
|
70
80
|
});
|
|
81
|
+
if (root)
|
|
82
|
+
removeRegistryEntry(root);
|
|
71
83
|
p.outro(pc.green("Arel OS uninstalled." + (deleteVault ? "" : " Your vault was preserved.")));
|
|
72
84
|
return 0;
|
|
73
85
|
}
|
|
74
|
-
/**
|
|
75
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Destructive I/O, isolated from prompt flow for testability via direct
|
|
88
|
+
* calls with explicit choices. `root` is passed separately from `installDir`
|
|
89
|
+
* so config.json (which lives at root, a parent of installDir) is removed
|
|
90
|
+
* correctly rather than assumed to live inside installDir.
|
|
91
|
+
*/
|
|
92
|
+
export function performUninstall(installDir, vaultPath, root, choices) {
|
|
76
93
|
if (choices.deleteVault && existsSync(vaultPath)) {
|
|
77
94
|
rmSync(vaultPath, { recursive: true, force: true });
|
|
78
95
|
}
|
|
79
96
|
if (choices.removeInstallDir && existsSync(installDir)) {
|
|
80
97
|
rmSync(installDir, { recursive: true, force: true });
|
|
81
98
|
}
|
|
82
|
-
if (choices.removeConfig
|
|
83
|
-
|
|
99
|
+
if (choices.removeConfig) {
|
|
100
|
+
const cfgPath = installConfigPath(root);
|
|
101
|
+
if (existsSync(cfgPath))
|
|
102
|
+
unlinkSync(cfgPath);
|
|
103
|
+
if (existsSync(legacyConfigPath()))
|
|
104
|
+
unlinkSync(legacyConfigPath());
|
|
84
105
|
}
|
|
85
106
|
}
|
package/dist/update.js
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `
|
|
3
|
-
* re-health-check. Config file untouched.
|
|
2
|
+
* `arelos update`. git pull --ff-only + bun install/build + restart +
|
|
3
|
+
* re-health-check. Config file untouched. With multiple installs registered,
|
|
4
|
+
* resolves by name (or prompts/lists per resolveInstall's rule).
|
|
4
5
|
*/
|
|
5
6
|
import pc from "picocolors";
|
|
6
7
|
import { ensureBun } from "./bun-setup.js";
|
|
7
|
-
import {
|
|
8
|
+
import { resolveRoot, resolveServiceLabels } from "./config.js";
|
|
9
|
+
import { resolveInstall } from "./cli-context.js";
|
|
8
10
|
import { runStreaming } from "./exec.js";
|
|
9
11
|
import { waitForHealthy } from "./health.js";
|
|
10
12
|
import { pullLatest } from "./repo.js";
|
|
11
13
|
import { bootstrapAndStart, installServiceFiles } from "./services.js";
|
|
12
|
-
export async function updateCommand() {
|
|
13
|
-
const
|
|
14
|
-
if (!
|
|
15
|
-
console.error(
|
|
14
|
+
export async function updateCommand(name) {
|
|
15
|
+
const result = await resolveInstall({ name, interactive: process.stdout.isTTY === true });
|
|
16
|
+
if (!result.ok) {
|
|
17
|
+
console.error(result.message);
|
|
16
18
|
return 1;
|
|
17
19
|
}
|
|
18
|
-
return runUpdate(config);
|
|
20
|
+
return runUpdate(result.install.config);
|
|
19
21
|
}
|
|
20
22
|
export async function runUpdate(config) {
|
|
21
23
|
console.log(`Updating ${config.installDir}…`);
|
|
@@ -45,7 +47,7 @@ export async function runUpdate(config) {
|
|
|
45
47
|
console.warn(pc.yellow("bun run build failed — keeping old dist/. The web service will keep serving it."));
|
|
46
48
|
}
|
|
47
49
|
const labels = resolveServiceLabels(config);
|
|
48
|
-
installServiceFiles(config.installDir, labels);
|
|
50
|
+
installServiceFiles(config.installDir, resolveRoot(config), labels);
|
|
49
51
|
const bootstrap = await bootstrapAndStart(labels);
|
|
50
52
|
for (const e of bootstrap.errors)
|
|
51
53
|
console.error(pc.yellow(e));
|
package/package.json
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arelos",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Installer and service manager for a self-hosted Arel OS on macOS.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"arelos": "dist/cli.js"
|
|
8
|
-
"rlo": "dist/cli.js"
|
|
7
|
+
"arelos": "dist/cli.js"
|
|
9
8
|
},
|
|
10
9
|
"files": [
|
|
11
10
|
"dist"
|