@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.
Files changed (3) hide show
  1. package/README.md +95 -2
  2. package/package.json +21 -4
  3. package/pick-ports.mjs +174 -0
package/README.md CHANGED
@@ -1,3 +1,96 @@
1
- # Temporary Holding Version
1
+ # `@futuristic.digital/pick-ports`
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }