ports-manager 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Muhammad Saad Amin (SENODROOM)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,276 @@
1
+ # ๐Ÿšฆ Ports Manager
2
+
3
+ <p align="center">
4
+ <strong>Launch your frontend and backend together โ€” on free ports, without CORS headaches.</strong>
5
+ </p>
6
+
7
+ <p align="center">
8
+ <img alt="Node.js 18+" src="https://img.shields.io/badge/Node.js-18%2B-339933?logo=node.js&logoColor=white">
9
+ <img alt="License MIT" src="https://img.shields.io/badge/license-MIT-blue">
10
+ <img alt="VS Code" src="https://img.shields.io/badge/VS%20Code-supported-007ACC?logo=visualstudiocode">
11
+ <img alt="Cursor" src="https://img.shields.io/badge/Cursor-supported-black">
12
+ </p>
13
+
14
+ ---
15
+
16
+ ## Why Ports Manager?
17
+
18
+ Local frontend/backend development should not require manually checking ports, editing `.env`
19
+ files, fixing CORS, and juggling multiple terminals.
20
+
21
+ Run one command from your project root:
22
+
23
+ ```bash
24
+ npx ports-manager
25
+ ```
26
+
27
+ Ports Manager will:
28
+
29
+ - ๐Ÿ”Ž Detect your `frontend` + `backend` or `client` + `server`
30
+ - ๐Ÿ›ฌ Find separate free ports while always avoiding `3000` and `5000`
31
+ - โš›๏ธ Configure CRA, Vite, or Next.js automatically
32
+ - ๐ŸŸข Start Express, Koa, Fastify, NestJS, or generic Node backends
33
+ - ๐Ÿ”— Provide the backend URL to the frontend at runtime
34
+ - ๐Ÿ›ก๏ธ Handle development CORS without changing application source
35
+ - ๐Ÿงน Stop both process trees cleanly when you press `Ctrl+C`
36
+ - ๐Ÿ–ฅ๏ธ Optionally open each app in its own VS Code/Cursor terminal
37
+
38
+ No application source files are rewritten.
39
+
40
+ ## Quick start
41
+
42
+ Your project should look like one of these:
43
+
44
+ ```text
45
+ my-project/
46
+ โ”œโ”€โ”€ frontend/ # or client/
47
+ โ”‚ โ””โ”€โ”€ package.json
48
+ โ””โ”€โ”€ backend/ # or server/
49
+ โ””โ”€โ”€ package.json
50
+ ```
51
+
52
+ Run Ports Manager from `my-project`, not from inside either child:
53
+
54
+ ```bash
55
+ cd my-project
56
+ npx ports-manager
57
+ ```
58
+
59
+ Example output:
60
+
61
+ ```text
62
+ Backend: express / npm run dev / 4000
63
+ Frontend: vite / npm run dev / 4001
64
+ CORS shim: enabled
65
+ ```
66
+
67
+ Ports Manager prefers `npm run dev`, then falls back to `npm start`.
68
+
69
+ ## Common recipes
70
+
71
+ ### Preview without starting anything
72
+
73
+ ```bash
74
+ npx ports-manager --dry-run
75
+ ```
76
+
77
+ ### Use custom folder names
78
+
79
+ ```bash
80
+ npx ports-manager \
81
+ --frontend-dir apps/web \
82
+ --backend-dir apps/api
83
+ ```
84
+
85
+ ### Choose a port range and ban extra ports
86
+
87
+ ```bash
88
+ npx ports-manager --range 4100-4900 --ban 4200,4300
89
+ ```
90
+
91
+ ### Wait for the backend before starting the frontend
92
+
93
+ ```bash
94
+ npx ports-manager --wait-for-backend
95
+ ```
96
+
97
+ ### Use a same-origin development proxy
98
+
99
+ ```bash
100
+ npx ports-manager --proxy --api-prefix /api
101
+ ```
102
+
103
+ ### Open separate VS Code/Cursor terminals
104
+
105
+ From an integrated terminal:
106
+
107
+ ```bash
108
+ npx ports-manager --ide-terminals
109
+ ```
110
+
111
+ `auto` mode uses the Ports Manager extension bridge when available. Otherwise, it
112
+ creates dedicated VS Code tasks. Launch generated tasks through:
113
+
114
+ ```text
115
+ Ctrl/Cmd+Shift+P โ†’ Tasks: Run Task โ†’ Ports Manager: Run All
116
+ ```
117
+
118
+ To generate a shortcut reference:
119
+
120
+ ```bash
121
+ npx ports-manager --ide-terminals=tasks --with-keybinding
122
+ ```
123
+
124
+ VS Code does not apply workspace keybindings automatically. Copy the generated
125
+ entry from `.vscode/ports-manager-keybindings.json` into **Keyboard Shortcuts
126
+ (JSON)**.
127
+
128
+ ## Supported stacks
129
+
130
+ | Role | Supported detection | Port strategy |
131
+ |---|---|---|
132
+ | Frontend | Create React App | `PORT` environment variable |
133
+ | Frontend | Vite | `--port` + `--strictPort` |
134
+ | Frontend | Next.js | `-p` argument |
135
+ | Backend | Express, Koa, Fastify | `PORT` environment variable |
136
+ | Backend | NestJS | `PORT` environment variable, best effort |
137
+ | Either | Generic Node project | `PORT`, with a compatibility warning |
138
+
139
+ The backend must read `process.env.PORT`. Ports Manager warns when its static scan
140
+ cannot find that behavior or detects a possible literal `.listen(5000)`.
141
+
142
+ ## CORS and API URLs
143
+
144
+ The development CORS shim is enabled by default. It is injected into the
145
+ backend through `NODE_OPTIONS` and:
146
+
147
+ - reflects the browser's `Origin`
148
+ - supports credentials
149
+ - handles `OPTIONS` preflight with `204`
150
+ - preserves the existing `Vary` header
151
+
152
+ If Ports Manager detects existing CORS middleware, it skips the shim. Override or
153
+ disable that behavior with:
154
+
155
+ ```bash
156
+ npx ports-manager --force-cors
157
+ npx ports-manager --no-cors-shim
158
+ ```
159
+
160
+ Framework-specific API variables are supplied automatically:
161
+
162
+ - CRA: `REACT_APP_API_URL`
163
+ - Vite: `VITE_API_URL`
164
+ - Next.js: `NEXT_PUBLIC_API_URL`
165
+
166
+ > [!WARNING]
167
+ > The CORS shim is intentionally permissive and intended only for local
168
+ > development. It is not a production security policy.
169
+
170
+ ## Configuration
171
+
172
+ Create `ports-manager.config.json` in the project root:
173
+
174
+ ```json
175
+ {
176
+ "pairs": [
177
+ ["frontend", "backend"],
178
+ ["client", "server"]
179
+ ],
180
+ "portRange": [4000, 4999],
181
+ "bannedPorts": [3000, 5000],
182
+ "apiPrefix": "/api",
183
+ "cors": {
184
+ "mode": "shim",
185
+ "credentials": true
186
+ },
187
+ "proxy": {
188
+ "enabled": false
189
+ },
190
+ "ideTerminals": {
191
+ "mode": "off",
192
+ "withKeybinding": false
193
+ },
194
+ "env": {
195
+ "frontend": {
196
+ "CUSTOM_API_URL": "http://127.0.0.1:{backendPort}"
197
+ }
198
+ }
199
+ }
200
+ ```
201
+
202
+ Environment values can use `{frontendPort}`, `{backendPort}`, `{proxyPort}`,
203
+ and `{apiPrefix}` placeholders. CLI flags override configuration values.
204
+
205
+ ## CLI reference
206
+
207
+ ```text
208
+ --frontend-dir PATH Override frontend directory
209
+ --backend-dir PATH Override backend directory
210
+ --frontend-port PORT Request a specific frontend port
211
+ --backend-port PORT Request a specific backend port
212
+ --proxy-port PORT Request a specific proxy port
213
+ --range MIN-MAX Port search range
214
+ --ban PORTS Comma-separated banned ports
215
+ --no-cors-shim Disable the development CORS shim
216
+ --force-cors Override detected CORS middleware
217
+ --proxy Enable the same-origin proxy
218
+ --api-prefix PATH Backend proxy prefix
219
+ --wait-for-backend[=MS] Wait for the backend to listen
220
+ --ide-terminals[=MODE] auto, off, tasks, or extension
221
+ --with-keybinding Generate a shortcut reference
222
+ --config PATH Use an explicit configuration file
223
+ --dry-run Print the plan without writing or starting
224
+ --stop Stop bridge-managed workspace terminals
225
+ --version, -v Show version and author
226
+ --help, -h Show all options
227
+ ```
228
+
229
+ Run `npx ports-manager --help` for the canonical list.
230
+
231
+ ## VS Code/Cursor extension
232
+
233
+ This repository is one package that produces both the npm CLI and an optional
234
+ VSIX:
235
+
236
+ ```bash
237
+ npm install
238
+ npm run package:vsix
239
+ ```
240
+
241
+ Install `ports-manager-1.0.0.vsix` in VS Code or Cursor. The bridge listens
242
+ only on `127.0.0.1`, requires a random bearer token, validates request data,
243
+ and tracks terminals by workspace.
244
+
245
+ Stop managed terminals with:
246
+
247
+ ```bash
248
+ npx ports-manager --stop
249
+ ```
250
+
251
+ or run **Ports Manager: Stop All Managed Terminals** from the command palette.
252
+
253
+ ## Limitations
254
+
255
+ - Port checks cannot reserve a port until the framework binds to it.
256
+ - Framework, CORS, and hardcoded-port scans are heuristic.
257
+ - Backends that ignore `process.env.PORT` cannot be redirected without code changes.
258
+ - IDE task mode needs one manual task launch unless the extension bridge is installed.
259
+ - Workspaces/monorepos and non-VS-Code-family IDEs are not currently supported.
260
+
261
+ ## Development
262
+
263
+ ```bash
264
+ npm install
265
+ npm test
266
+ npm run test:cli
267
+ npm run test:bridge
268
+ npm run package:vsix
269
+ ```
270
+
271
+ ---
272
+
273
+ <p align="center">
274
+ Built by <strong>Muhammad Saad Amin</strong> โ€” <strong>SENODROOM</strong><br>
275
+ Released under the MIT License.
276
+ </p>
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ require('../src/cli').main().catch((error) => {
5
+ console.error(`ports-manager: ${error.message}`);
6
+ if (process.env.PORTS_MANAGER_DEBUG) console.error(error.stack);
7
+ process.exitCode = 1;
8
+ });
@@ -0,0 +1,96 @@
1
+ import * as vscode from 'vscode';
2
+ import * as crypto from 'node:crypto';
3
+ import * as fs from 'node:fs';
4
+ import * as os from 'node:os';
5
+ import * as path from 'node:path';
6
+ import { BridgeServer, TerminalHost, TerminalSpec } from './server';
7
+
8
+ let bridge: BridgeServer | undefined;
9
+ let descriptorFile: string | undefined;
10
+ let activeToken: string | undefined;
11
+
12
+ function writeDescriptor(port: number, token: string): string {
13
+ const directory = path.join(os.homedir(), '.ports-manager');
14
+ const filename = path.join(directory, 'bridge.json');
15
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
16
+ try {
17
+ fs.chmodSync(directory, 0o700);
18
+ } catch {
19
+ // Windows ACLs do not map directly to POSIX modes.
20
+ }
21
+ const temporary = path.join(directory, `.bridge-${process.pid}-${crypto.randomBytes(6).toString('hex')}.tmp`);
22
+ const payload = JSON.stringify({
23
+ endpoint: `http://127.0.0.1:${port}/`,
24
+ token,
25
+ pid: process.pid
26
+ });
27
+ fs.writeFileSync(temporary, payload, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
28
+ try {
29
+ fs.renameSync(temporary, filename);
30
+ } catch (error) {
31
+ if (process.platform !== 'win32') {
32
+ fs.rmSync(temporary, { force: true });
33
+ throw error;
34
+ }
35
+ fs.rmSync(filename, { force: true });
36
+ fs.renameSync(temporary, filename);
37
+ }
38
+ try {
39
+ fs.chmodSync(filename, 0o600);
40
+ } catch {
41
+ // Best effort on Windows.
42
+ }
43
+ return filename;
44
+ }
45
+
46
+ function removeOwnDescriptor(): void {
47
+ if (!descriptorFile || !activeToken) return;
48
+ try {
49
+ const current = JSON.parse(fs.readFileSync(descriptorFile, 'utf8')) as { token?: string };
50
+ if (current.token === activeToken) fs.rmSync(descriptorFile, { force: true });
51
+ } catch {
52
+ // Already removed or replaced.
53
+ }
54
+ }
55
+
56
+ export async function activate(context: vscode.ExtensionContext): Promise<void> {
57
+ const token = crypto.randomBytes(32).toString('hex');
58
+ activeToken = token;
59
+ const host: TerminalHost = {
60
+ create(spec: TerminalSpec) {
61
+ return vscode.window.createTerminal({
62
+ name: spec.name,
63
+ cwd: spec.cwd,
64
+ env: spec.env,
65
+ isTransient: true
66
+ });
67
+ }
68
+ };
69
+ bridge = new BridgeServer(token, host);
70
+ try {
71
+ const port = await bridge.listen();
72
+ descriptorFile = writeDescriptor(port, token);
73
+ } catch (error) {
74
+ await bridge.close();
75
+ bridge = undefined;
76
+ activeToken = undefined;
77
+ vscode.window.showErrorMessage(`Ports Manager bridge failed to start: ${error instanceof Error ? error.message : error}`);
78
+ return;
79
+ }
80
+
81
+ context.subscriptions.push(
82
+ vscode.window.onDidCloseTerminal((terminal) => bridge?.forget(terminal)),
83
+ vscode.commands.registerCommand('ports-manager.stopAll', () => {
84
+ const count = bridge?.stopAll() || 0;
85
+ vscode.window.showInformationMessage(`Stopped ${count} Ports Manager terminal${count === 1 ? '' : 's'}.`);
86
+ })
87
+ );
88
+ }
89
+
90
+ export async function deactivate(): Promise<void> {
91
+ removeOwnDescriptor();
92
+ if (bridge) await bridge.close();
93
+ bridge = undefined;
94
+ activeToken = undefined;
95
+ descriptorFile = undefined;
96
+ }
@@ -0,0 +1,185 @@
1
+ import * as http from 'node:http';
2
+ import { timingSafeEqual } from 'node:crypto';
3
+
4
+ export interface TerminalSpec {
5
+ name: string;
6
+ cwd: string;
7
+ env: Record<string, string>;
8
+ command: string;
9
+ }
10
+
11
+ export interface ManagedTerminal {
12
+ show(preserveFocus?: boolean): void;
13
+ sendText(text: string, addNewLine?: boolean): void;
14
+ dispose(): void;
15
+ }
16
+
17
+ export interface TerminalHost {
18
+ create(spec: TerminalSpec): ManagedTerminal;
19
+ }
20
+
21
+ interface LaunchBody {
22
+ tag: string;
23
+ terminals: TerminalSpec[];
24
+ }
25
+
26
+ interface StopBody {
27
+ tag: string;
28
+ }
29
+
30
+ const MAX_BODY = 64 * 1024;
31
+
32
+ function plainObject(value: unknown): value is Record<string, unknown> {
33
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
34
+ }
35
+
36
+ function shortString(value: unknown, maximum = 4096): value is string {
37
+ return typeof value === 'string' && value.length > 0 && value.length <= maximum;
38
+ }
39
+
40
+ export function validateLaunchBody(value: unknown): LaunchBody {
41
+ if (!plainObject(value) || !shortString(value.tag, 1024) ||
42
+ !Array.isArray(value.terminals) || value.terminals.length < 1 || value.terminals.length > 8) {
43
+ throw new Error('invalid terminal launch body');
44
+ }
45
+ const terminals = value.terminals.map((candidate): TerminalSpec => {
46
+ if (!plainObject(candidate) || !shortString(candidate.name, 200) ||
47
+ !shortString(candidate.cwd, 4096) || !shortString(candidate.command, 16384) ||
48
+ !plainObject(candidate.env)) {
49
+ throw new Error('invalid terminal specification');
50
+ }
51
+ const env: Record<string, string> = {};
52
+ for (const [key, item] of Object.entries(candidate.env)) {
53
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || typeof item !== 'string' || item.length > 16384) {
54
+ throw new Error('invalid terminal environment');
55
+ }
56
+ env[key] = item;
57
+ }
58
+ return { name: candidate.name, cwd: candidate.cwd, command: candidate.command, env };
59
+ });
60
+ return { tag: value.tag, terminals };
61
+ }
62
+
63
+ function validateStopBody(value: unknown): StopBody {
64
+ if (!plainObject(value) || !shortString(value.tag, 1024)) throw new Error('invalid stop body');
65
+ return { tag: value.tag };
66
+ }
67
+
68
+ function isLoopback(address: string | undefined): boolean {
69
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
70
+ }
71
+
72
+ function authorized(header: string | undefined, token: string): boolean {
73
+ if (!header?.startsWith('Bearer ')) return false;
74
+ const supplied = Buffer.from(header.slice(7));
75
+ const expected = Buffer.from(token);
76
+ return supplied.length === expected.length && timingSafeEqual(supplied, expected);
77
+ }
78
+
79
+ async function readJson(request: http.IncomingMessage): Promise<unknown> {
80
+ if (!String(request.headers['content-type'] || '').toLowerCase().startsWith('application/json')) {
81
+ throw new Error('content-type must be application/json');
82
+ }
83
+ const chunks: Buffer[] = [];
84
+ let size = 0;
85
+ for await (const chunk of request) {
86
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
87
+ size += buffer.length;
88
+ if (size > MAX_BODY) throw new Error('request body too large');
89
+ chunks.push(buffer);
90
+ }
91
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
92
+ }
93
+
94
+ function send(response: http.ServerResponse, status: number, body: object): void {
95
+ const payload = JSON.stringify(body);
96
+ response.writeHead(status, {
97
+ 'Content-Type': 'application/json',
98
+ 'Content-Length': Buffer.byteLength(payload),
99
+ 'Cache-Control': 'no-store'
100
+ });
101
+ response.end(payload);
102
+ }
103
+
104
+ export class BridgeServer {
105
+ private server?: http.Server;
106
+ private readonly tracked = new Map<string, Set<ManagedTerminal>>();
107
+
108
+ constructor(private readonly token: string, private readonly host: TerminalHost) {}
109
+
110
+ async listen(): Promise<number> {
111
+ if (this.server) throw new Error('bridge already listening');
112
+ this.server = http.createServer((request, response) => void this.handle(request, response));
113
+ await new Promise<void>((resolve, reject) => {
114
+ this.server!.once('error', reject);
115
+ this.server!.listen(0, '127.0.0.1', resolve);
116
+ });
117
+ const address = this.server.address();
118
+ if (!address || typeof address === 'string') throw new Error('bridge failed to bind');
119
+ return address.port;
120
+ }
121
+
122
+ private async handle(request: http.IncomingMessage, response: http.ServerResponse): Promise<void> {
123
+ try {
124
+ if (!isLoopback(request.socket.remoteAddress)) return send(response, 403, { error: 'loopback only' });
125
+ if (!authorized(request.headers.authorization, this.token)) return send(response, 401, { error: 'unauthorized' });
126
+ if (request.method !== 'POST') return send(response, 405, { error: 'POST required' });
127
+ const body = await readJson(request);
128
+ if (request.url === '/terminals') {
129
+ const launch = validateLaunchBody(body);
130
+ this.stopTag(launch.tag);
131
+ const terminals = new Set<ManagedTerminal>();
132
+ try {
133
+ for (const spec of launch.terminals) {
134
+ const terminal = this.host.create(spec);
135
+ terminals.add(terminal);
136
+ terminal.show(true);
137
+ terminal.sendText(spec.command, true);
138
+ }
139
+ } catch (error) {
140
+ for (const terminal of terminals) terminal.dispose();
141
+ throw error;
142
+ }
143
+ this.tracked.set(launch.tag, terminals);
144
+ return send(response, 201, { created: terminals.size });
145
+ }
146
+ if (request.url === '/stop') {
147
+ const stop = validateStopBody(body);
148
+ const stopped = this.stopTag(stop.tag);
149
+ return send(response, 200, { stopped });
150
+ }
151
+ return send(response, 404, { error: 'not found' });
152
+ } catch (error) {
153
+ return send(response, 400, { error: error instanceof Error ? error.message : 'bad request' });
154
+ }
155
+ }
156
+
157
+ forget(terminal: ManagedTerminal): void {
158
+ for (const [tag, terminals] of this.tracked) {
159
+ terminals.delete(terminal);
160
+ if (!terminals.size) this.tracked.delete(tag);
161
+ }
162
+ }
163
+
164
+ stopTag(tag: string): number {
165
+ const terminals = this.tracked.get(tag);
166
+ if (!terminals) return 0;
167
+ this.tracked.delete(tag);
168
+ for (const terminal of terminals) terminal.dispose();
169
+ return terminals.size;
170
+ }
171
+
172
+ stopAll(): number {
173
+ let count = 0;
174
+ for (const tag of [...this.tracked.keys()]) count += this.stopTag(tag);
175
+ return count;
176
+ }
177
+
178
+ async close(): Promise<void> {
179
+ this.stopAll();
180
+ if (!this.server) return;
181
+ const server = this.server;
182
+ this.server = undefined;
183
+ await new Promise<void>((resolve) => server.close(() => resolve()));
184
+ }
185
+ }