@futuristic.digital/pick-ports 0.0.0-stage → 0.1.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/README.md +95 -2
- package/package.json +21 -4
- package/pick-ports.mjs +174 -0
package/README.md
CHANGED
|
@@ -1,3 +1,96 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@futuristic.digital/pick-ports`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Run a command with available TCP ports assigned to environment variables. Define
|
|
4
|
+
each variable name and preferred default port directly in your npm script. Uses
|
|
5
|
+
Node.js 22 or newer and has no runtime dependencies.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install --save-dev @futuristic.digital/pick-ports
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"scripts": {
|
|
14
|
+
"dev": "pick-ports WEB_PORT=3000 ADMIN_PORT=3001 API_PORT=3100 -- turbo run dev"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`npm run dev` checks all preferred defaults first. An available default is used;
|
|
20
|
+
otherwise the OS chooses an available port. Defaults that cannot be bound because
|
|
21
|
+
of permissions also fall back to an OS-assigned port. Each variable gets a distinct port,
|
|
22
|
+
even when defaults repeat. Assignments override existing values in the child
|
|
23
|
+
environment. Other environment variables, command arguments, terminal input and
|
|
24
|
+
output, and the command's exit status are preserved. The selected mapping is
|
|
25
|
+
printed to stderr before the command starts.
|
|
26
|
+
|
|
27
|
+
You can also invoke it without installing it locally:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
npm exec --package=@futuristic.digital/pick-ports -- pick-ports WEB_PORT=3000 API_PORT=3100 -- turbo run dev
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Use the variables in applications
|
|
34
|
+
|
|
35
|
+
The command after `--` and its descendants inherit the variables. For Turbo's
|
|
36
|
+
strict environment mode, allow them through in the consuming project's
|
|
37
|
+
`turbo.json`:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"tasks": {
|
|
42
|
+
"dev": {
|
|
43
|
+
"cache": false,
|
|
44
|
+
"persistent": true,
|
|
45
|
+
"passThroughEnv": ["WEB_PORT", "ADMIN_PORT", "API_PORT"]
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
An application's npm script can then read its assigned variable:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"scripts": {
|
|
56
|
+
"dev": "next dev --port $WEB_PORT"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
This variable expansion example uses a POSIX shell. The launcher passes arguments
|
|
62
|
+
directly to the child; it does not interpret shell expressions. Use a child npm
|
|
63
|
+
script or an explicit shell if expansion is needed. For example,
|
|
64
|
+
`pick-ports WEB_PORT=3000 -- npm run dev:app` lets `dev:app` expand `$WEB_PORT`
|
|
65
|
+
after allocation. Putting `$WEB_PORT` directly after `--` in the outer script
|
|
66
|
+
would expand it too early. On Windows, use a Node launcher to read `process.env`
|
|
67
|
+
and spawn the application, or invoke shell commands through `cmd.exe /c`.
|
|
68
|
+
|
|
69
|
+
## Scope and availability
|
|
70
|
+
|
|
71
|
+
- Pass at least one `NAME=PORT` assignment and a command after `--`. Names use
|
|
72
|
+
letters, digits, and underscores, starting with a letter or underscore. Names
|
|
73
|
+
must be unique (case-insensitively); default ports must be integers from 1 to 65535. A repeated default goes to the first variable that requests it.
|
|
74
|
+
- Ports are checked with wildcard TCP listeners using Node's default network
|
|
75
|
+
binding. UDP availability is not checked.
|
|
76
|
+
- Reservations stay open while allocating the full mapping, then close before
|
|
77
|
+
starting the child. Another process can claim a port between allocation and
|
|
78
|
+
application startup; this tool cannot guarantee a later bind will succeed.
|
|
79
|
+
- Values exist in the child environment only. A process cannot change the parent
|
|
80
|
+
shell's environment. No env files or saved port registry are written.
|
|
81
|
+
- URLs are not derived automatically. Build local URLs from the assigned port
|
|
82
|
+
variables in your application's configuration or a child launcher. Remove stale
|
|
83
|
+
hardcoded URLs and ports from local env files as needed.
|
|
84
|
+
|
|
85
|
+
Run `pick-ports --help` for the argument syntax.
|
|
86
|
+
|
|
87
|
+
## Development
|
|
88
|
+
|
|
89
|
+
From the repository root, run the pure argument-parser tests with:
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
npm run test --workspace @futuristic.digital/pick-ports
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
These tests cover input validation and argument preservation; they do not exercise
|
|
96
|
+
TCP allocation or child-process lifecycle behavior.
|
package/package.json
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@futuristic.digital/pick-ports",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Run a command with available TCP ports assigned to environment variables.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "vitest run"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"pick-ports": "pick-ports.mjs"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"pick-ports.mjs",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22"
|
|
18
|
+
},
|
|
19
|
+
"license": "UNLICENSED",
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/pick-ports.mjs
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/** Allocate ports together before starting a command with their environment. */
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import console from "node:console";
|
|
6
|
+
import { realpathSync } from "node:fs";
|
|
7
|
+
import { createServer } from "node:net";
|
|
8
|
+
import process from "node:process";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const usage = `Usage: pick-ports NAME=DEFAULT_PORT [NAME=DEFAULT_PORT ...] -- command [args...]
|
|
12
|
+
|
|
13
|
+
Example: pick-ports WEB_PORT=3000 API_PORT=3100 -- turbo run dev
|
|
14
|
+
|
|
15
|
+
Each default must be an integer from 1 to 65535. Occupied defaults fall back
|
|
16
|
+
to distinct OS-assigned TCP ports. Values override the child's environment.`;
|
|
17
|
+
|
|
18
|
+
// Argument parsing
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validate user input before opening sockets or starting a child process.
|
|
22
|
+
* @param {string[]} args CLI arguments without the Node and script paths.
|
|
23
|
+
*/
|
|
24
|
+
export function parseArguments(args) {
|
|
25
|
+
const separator = args.indexOf("--");
|
|
26
|
+
const command = args[separator + 1];
|
|
27
|
+
if (separator < 1 || !command) {
|
|
28
|
+
throw new Error(usage);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const defaults = new Map();
|
|
32
|
+
for (const assignment of args.slice(0, separator)) {
|
|
33
|
+
const match = /^([A-Za-z_][A-Za-z0-9_]*)=([0-9]+)$/.exec(assignment);
|
|
34
|
+
if (!match) {
|
|
35
|
+
throw new Error(`Invalid assignment: ${assignment}. Expected NAME=PORT.`);
|
|
36
|
+
}
|
|
37
|
+
const [, name, portText] = match;
|
|
38
|
+
const port = Number(portText);
|
|
39
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
40
|
+
throw new Error(`Invalid port for ${name}: ${portText}. Use 1–65535.`);
|
|
41
|
+
}
|
|
42
|
+
// Environment names are case-insensitive on Windows.
|
|
43
|
+
if (
|
|
44
|
+
[...defaults.keys()].some(
|
|
45
|
+
(key) => key.toUpperCase() === name.toUpperCase()
|
|
46
|
+
)
|
|
47
|
+
) {
|
|
48
|
+
throw new Error(`Duplicate environment variable: ${name}.`);
|
|
49
|
+
}
|
|
50
|
+
defaults.set(name, port);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { defaults, command, commandArgs: args.slice(separator + 2) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Port allocation
|
|
57
|
+
|
|
58
|
+
/** Keep a TCP port reserved until all variables have distinct assignments. */
|
|
59
|
+
async function reservePort(port) {
|
|
60
|
+
const server = createServer();
|
|
61
|
+
await new Promise((resolve, reject) => {
|
|
62
|
+
server.once("error", reject);
|
|
63
|
+
server.listen({ port, exclusive: true }, resolve);
|
|
64
|
+
});
|
|
65
|
+
return server;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Prefer every requested default before asking the OS for fallback ports. */
|
|
69
|
+
async function allocatePorts(defaults) {
|
|
70
|
+
const reservations = new Map();
|
|
71
|
+
try {
|
|
72
|
+
// Claim defaults first so a fallback cannot take another variable's default.
|
|
73
|
+
for (const [name, port] of defaults) {
|
|
74
|
+
try {
|
|
75
|
+
reservations.set(name, await reservePort(port));
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (
|
|
78
|
+
!(error instanceof Error) ||
|
|
79
|
+
!("code" in error) ||
|
|
80
|
+
!["EADDRINUSE", "EACCES"].includes(error.code)
|
|
81
|
+
) {
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const name of defaults.keys()) {
|
|
88
|
+
if (!reservations.has(name)) {
|
|
89
|
+
reservations.set(name, await reservePort(0));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return Object.fromEntries(
|
|
94
|
+
[...reservations].map(([name, server]) => [
|
|
95
|
+
name,
|
|
96
|
+
String(server.address().port)
|
|
97
|
+
])
|
|
98
|
+
);
|
|
99
|
+
} finally {
|
|
100
|
+
// Applications need to bind the ports themselves, so release before spawn.
|
|
101
|
+
await Promise.all(
|
|
102
|
+
[...reservations.values()].map(
|
|
103
|
+
(server) => new Promise((resolve) => server.close(resolve))
|
|
104
|
+
)
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Command lifecycle
|
|
110
|
+
|
|
111
|
+
/** Preserve arguments, terminal IO, exit codes, and termination signals. */
|
|
112
|
+
async function runCommand(command, args, ports) {
|
|
113
|
+
const child = spawn(command, args, {
|
|
114
|
+
env: { ...process.env, ...ports },
|
|
115
|
+
stdio: "inherit"
|
|
116
|
+
});
|
|
117
|
+
const signalHandlers = new Map(
|
|
118
|
+
["SIGINT", "SIGTERM", "SIGHUP"].map((signal) => [
|
|
119
|
+
signal,
|
|
120
|
+
() => child.kill(signal)
|
|
121
|
+
])
|
|
122
|
+
);
|
|
123
|
+
for (const [signal, handler] of signalHandlers) process.on(signal, handler);
|
|
124
|
+
|
|
125
|
+
let result;
|
|
126
|
+
try {
|
|
127
|
+
result = await new Promise((resolve, reject) => {
|
|
128
|
+
child.once("error", reject);
|
|
129
|
+
child.once("exit", (code, signal) => resolve({ code, signal }));
|
|
130
|
+
});
|
|
131
|
+
} finally {
|
|
132
|
+
for (const [signal, handler] of signalHandlers)
|
|
133
|
+
process.off(signal, handler);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (result.signal) {
|
|
137
|
+
process.kill(process.pid, result.signal);
|
|
138
|
+
} else {
|
|
139
|
+
process.exitCode = result.code;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Resolve the complete mapping once so every child receives the same values. */
|
|
144
|
+
async function main() {
|
|
145
|
+
const args = process.argv.slice(2);
|
|
146
|
+
if (args.length === 1 && ["--help", "-h"].includes(args[0])) {
|
|
147
|
+
console.log(usage);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const { defaults, command, commandArgs } = parseArguments(args);
|
|
152
|
+
const ports = await allocatePorts(defaults);
|
|
153
|
+
console.error(
|
|
154
|
+
`[pick-ports] ${Object.entries(ports)
|
|
155
|
+
.map(([name, port]) => `${name}=${port}`)
|
|
156
|
+
.join(" ")}`
|
|
157
|
+
);
|
|
158
|
+
await runCommand(command, commandArgs, ports);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Imports expose the pure parser without launching a command. Resolve npm's bin symlink.
|
|
162
|
+
if (
|
|
163
|
+
process.argv[1] &&
|
|
164
|
+
realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
165
|
+
) {
|
|
166
|
+
try {
|
|
167
|
+
await main();
|
|
168
|
+
} catch (error) {
|
|
169
|
+
console.error(
|
|
170
|
+
`[pick-ports] ${error instanceof Error ? error.message : String(error)}`
|
|
171
|
+
);
|
|
172
|
+
process.exitCode = 1;
|
|
173
|
+
}
|
|
174
|
+
}
|