arelos 0.2.0 → 0.2.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-args.js CHANGED
@@ -43,6 +43,17 @@ export function parseInstallFlags(argv) {
43
43
  }
44
44
  return flags;
45
45
  }
46
+ export function parsePortsFlags(argv) {
47
+ const flags = { webPort: null, vaultPort: null };
48
+ for (let i = 0; i < argv.length; i++) {
49
+ const arg = argv[i];
50
+ if (arg === "--web-port")
51
+ flags.webPort = Number(argv[++i]);
52
+ else if (arg === "--vault-port")
53
+ flags.vaultPort = Number(argv[++i]);
54
+ }
55
+ return flags;
56
+ }
46
57
  export function parseLogsFlags(argv) {
47
58
  let which = "both";
48
59
  let follow = false;
package/dist/cli.js CHANGED
@@ -9,10 +9,11 @@ if (process.platform !== "darwin") {
9
9
  console.error("Arel OS currently supports macOS only.");
10
10
  process.exit(1);
11
11
  }
12
- import { parseInstallFlags, parseLogsFlags } from "./cli-args.js";
12
+ import { parseInstallFlags, parseLogsFlags, parsePortsFlags } from "./cli-args.js";
13
13
  import { runInstall } from "./install.js";
14
14
  import { listCommand } from "./list.js";
15
15
  import { logsCommand } from "./logs.js";
16
+ import { portsCommand } from "./ports-command.js";
16
17
  import { statusCommand } from "./status.js";
17
18
  import { uninstallCommand } from "./uninstall.js";
18
19
  import { updateCommand } from "./update.js";
@@ -26,7 +27,7 @@ async function main() {
26
27
  // rather than a subcommand name — both mean "install". Anything else in
27
28
  // the known set consumes its name as the subcommand; everything after it
28
29
  // is passed through as that subcommand's own args.
29
- const knownSubcommands = new Set(["install", "status", "update", "uninstall", "logs", "list"]);
30
+ const knownSubcommands = new Set(["install", "status", "update", "uninstall", "logs", "list", "ports"]);
30
31
  const firstIsFlag = argv.length === 0 || argv[0].startsWith("-");
31
32
  const firstIsKnown = argv.length > 0 && knownSubcommands.has(argv[0]);
32
33
  if (!firstIsFlag && !firstIsKnown) {
@@ -50,6 +51,8 @@ async function main() {
50
51
  return uninstallCommand(nameArgFrom(rest));
51
52
  case "logs":
52
53
  return logsCommand(parseLogsFlags(rest), logsNameArgFrom(rest));
54
+ case "ports":
55
+ return portsCommand(parsePortsFlags(rest), nameArgFrom(rest));
53
56
  default:
54
57
  console.error(`Unknown command: ${subcommand}\n`);
55
58
  printHelp();
@@ -89,6 +92,7 @@ Usage:
89
92
  arelos update [name] git pull + rebuild + restart
90
93
  arelos uninstall [name] Stop services, optionally remove install dir / vault
91
94
  arelos logs [name] [web|vault] Tail service logs (-f to follow, -n <N> for line count)
95
+ arelos ports [name] Change the web/vault ports (interactive, or via flags below)
92
96
 
93
97
  [name] is only needed when you have more than one install; omit it with a
94
98
  single install, or you'll be prompted to choose interactively.
@@ -102,6 +106,10 @@ Install flags (non-interactive):
102
106
  --vault-port <port>
103
107
  --no-service Skip launchd bootstrap (for dry runs / development)
104
108
  --local-repo <path> Use a local path instead of cloning from GitHub
109
+
110
+ Ports flags (non-interactive):
111
+ --web-port <port> New web port (omit to keep current)
112
+ --vault-port <port> New vault port (omit to keep current)
105
113
  `);
106
114
  }
107
115
  main()
@@ -0,0 +1,158 @@
1
+ /**
2
+ * `arelos ports [name]`. Change an existing install's web/vault ports:
3
+ * interactive prompt (default = keep current) or non-interactive
4
+ * `--web-port`/`--vault-port` flags. Registry-aware like status/update/logs
5
+ * (see cli-context.ts resolveInstall).
6
+ *
7
+ * On change: rewrite <root>/config.json atomically, then restart both
8
+ * launchd services (kickstart -k — the web service's run-web.sh rebuilds and
9
+ * re-bakes VITE_VAULT_API from config.json on every start, so a kickstart
10
+ * alone is enough to pick up new ports; see scripts/service/run-web.sh), then
11
+ * health-check both new ports with the existing health/diagnostics machinery.
12
+ *
13
+ * The restart+health-check step is expressed as an injectable "effects"
14
+ * object (RestartEffects) so tests can assert the exact call sequence
15
+ * without ever touching real launchctl or the network.
16
+ */
17
+ import * as p from "@clack/prompts";
18
+ import pc from "picocolors";
19
+ import { resolveRoot, resolveServiceLabels, writeConfig } from "./config.js";
20
+ import { resolveInstall } from "./cli-context.js";
21
+ import { formatHealthTimeoutDiagnostics, waitForHealthy } from "./health.js";
22
+ import { lastLines, logPathFor } from "./logs.js";
23
+ import { installConfigPath } from "./paths.js";
24
+ import { resolvePort } from "./install-plan.js";
25
+ import { bootstrapAndStart } from "./services.js";
26
+ /**
27
+ * Resolve one requested port against its current value: null (or equal to
28
+ * current) means "keep, no validation needed" — re-validating the port
29
+ * you're already bound to would spuriously fail once services are up and
30
+ * holding it. A genuinely different request must resolve to itself exactly
31
+ * (hard-fail on occupied) — `resolvePortFn` is injected so tests can stub it
32
+ * without touching real sockets, and the real caller passes the hardened
33
+ * `resolvePort` from install-plan.ts.
34
+ */
35
+ async function resolveOneField(field, current, requested, resolvePortFn) {
36
+ if (requested === null || requested === current) {
37
+ return { field, current, requested: null, resolution: null, changed: false };
38
+ }
39
+ const resolution = await resolvePortFn(requested);
40
+ return { field, current, requested, resolution, changed: resolution.resolved === requested };
41
+ }
42
+ /**
43
+ * Build and validate a port-change plan from the install's current config
44
+ * and the caller's requested new ports (null = keep current). Both fields
45
+ * are validated independently; a requested port that's occupied (and thus
46
+ * would resolve to something other than itself) is a hard failure — ports is
47
+ * a deliberate, exact change, unlike install's "pick something nearby".
48
+ */
49
+ export async function buildPortChangePlan(current, requested, resolvePortFn = resolvePort) {
50
+ if (requested.webPort !== null && requested.vaultPort !== null && requested.webPort === requested.vaultPort) {
51
+ return { ok: false, message: `Web port and vault port must differ (both were ${requested.webPort}).` };
52
+ }
53
+ const web = await resolveOneField("webPort", current.webPort, requested.webPort, resolvePortFn);
54
+ if (web.requested !== null && web.resolution && !web.resolution.wasFree) {
55
+ return {
56
+ ok: false,
57
+ message: `Port ${web.requested} is already in use — choose a free port for the web service.`,
58
+ };
59
+ }
60
+ const vault = await resolveOneField("vaultPort", current.vaultPort, requested.vaultPort, resolvePortFn);
61
+ if (vault.requested !== null && vault.resolution && !vault.resolution.wasFree) {
62
+ return {
63
+ ok: false,
64
+ message: `Port ${vault.requested} is already in use — choose a free port for the vault service.`,
65
+ };
66
+ }
67
+ // Cross-field collision: e.g. new web port == current (unchanged) vault port.
68
+ const finalWeb = web.changed ? web.requested : current.webPort;
69
+ const finalVault = vault.changed ? vault.requested : current.vaultPort;
70
+ if (finalWeb === finalVault) {
71
+ return { ok: false, message: `Web port and vault port must differ (both would be ${finalWeb}).` };
72
+ }
73
+ return { ok: true, plan: { web, vault, anyChange: web.changed || vault.changed } };
74
+ }
75
+ /** Apply a resolved plan to a config object, returning the updated config (pure — no I/O). */
76
+ export function applyPlanToConfig(config, plan) {
77
+ return {
78
+ ...config,
79
+ webPort: plan.web.changed ? plan.web.requested : config.webPort,
80
+ vaultPort: plan.vault.changed ? plan.vault.requested : config.vaultPort,
81
+ };
82
+ }
83
+ export const realRestartEffects = {
84
+ restartServices: async (labels) => {
85
+ const result = await bootstrapAndStart(labels);
86
+ return { errors: result.errors };
87
+ },
88
+ waitForHealthy,
89
+ };
90
+ export async function portsCommand(flags, name, effects = realRestartEffects) {
91
+ const result = await resolveInstall({ name, interactive: process.stdout.isTTY === true });
92
+ if (!result.ok) {
93
+ console.error(result.message);
94
+ return 1;
95
+ }
96
+ const { config, root } = result.install;
97
+ const interactive = process.stdout.isTTY === true && flags.webPort === null && flags.vaultPort === null;
98
+ console.log(pc.bold(config.displayName));
99
+ console.log(` Web port: ${config.webPort}`);
100
+ console.log(` Vault port: ${config.vaultPort}`);
101
+ console.log("");
102
+ let requestedWeb = flags.webPort;
103
+ let requestedVault = flags.vaultPort;
104
+ if (interactive) {
105
+ p.intro(pc.bold("Change ports"));
106
+ const webRaw = await p.text({
107
+ message: "New web port (blank = keep current):",
108
+ placeholder: String(config.webPort),
109
+ defaultValue: String(config.webPort),
110
+ });
111
+ if (p.isCancel(webRaw)) {
112
+ p.cancel("Cancelled.");
113
+ return 1;
114
+ }
115
+ const vaultRaw = await p.text({
116
+ message: "New vault port (blank = keep current):",
117
+ placeholder: String(config.vaultPort),
118
+ defaultValue: String(config.vaultPort),
119
+ });
120
+ if (p.isCancel(vaultRaw)) {
121
+ p.cancel("Cancelled.");
122
+ return 1;
123
+ }
124
+ const webNum = Number(webRaw);
125
+ const vaultNum = Number(vaultRaw);
126
+ requestedWeb = Number.isFinite(webNum) ? webNum : null;
127
+ requestedVault = Number.isFinite(vaultNum) ? vaultNum : null;
128
+ }
129
+ const planOutcome = await buildPortChangePlan({ webPort: config.webPort, vaultPort: config.vaultPort }, { webPort: requestedWeb, vaultPort: requestedVault });
130
+ if (!planOutcome.ok) {
131
+ console.error(pc.red(planOutcome.message));
132
+ return 1;
133
+ }
134
+ const { plan } = planOutcome;
135
+ if (!plan.anyChange) {
136
+ console.log("No change — ports are already what was requested.");
137
+ return 0;
138
+ }
139
+ const updatedConfig = applyPlanToConfig(config, plan);
140
+ const cfgRoot = resolveRoot(config);
141
+ const cfgPath = installConfigPath(cfgRoot);
142
+ writeConfig(updatedConfig, cfgPath);
143
+ console.log(pc.green(`Config updated: web ${config.webPort} -> ${updatedConfig.webPort}, vault ${config.vaultPort} -> ${updatedConfig.vaultPort}.`));
144
+ const labels = resolveServiceLabels(updatedConfig);
145
+ console.log("Restarting services…");
146
+ const restart = await effects.restartServices(labels);
147
+ for (const e of restart.errors)
148
+ console.error(pc.yellow(e));
149
+ console.log("Waiting for the app to come back up…");
150
+ const health = await effects.waitForHealthy(updatedConfig.webPort, updatedConfig.vaultPort);
151
+ if (!health.healthy) {
152
+ console.error(pc.red(formatHealthTimeoutDiagnostics(cfgRoot, (path) => lastLines(path, 10), logPathFor)));
153
+ console.error(pc.dim("\nFull logs: arelos logs"));
154
+ return 1;
155
+ }
156
+ console.log(pc.green("Ports changed and Arel OS is healthy again."));
157
+ return 0;
158
+ }
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 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.
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 loopback host and report what happened. */
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) loopback on this machine — nothing to conflict with.
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 to bind on ALL applicable loopback families
38
- * (127.0.0.1 and ::1), false if any listener holds it on either family.
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 [v4, v6] = await Promise.all([probeBind(port, "127.0.0.1"), probeBind(port, "::1")]);
42
- return v4 !== "taken" && v6 !== "taken";
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arelos",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Installer and service manager for a self-hosted Arel OS on macOS.",
5
5
  "type": "module",
6
6
  "bin": {