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 +11 -0
- package/dist/cli.js +10 -2
- package/dist/ports-command.js +158 -0
- package/dist/ports.js +81 -13
- package/package.json +1 -1
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
|
|
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
|