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/src/cli.js ADDED
@@ -0,0 +1,278 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { loadConfig, mergeConfig } = require('./config');
6
+ const { detectFolders, classifyProject, scanBackend } = require('./detect');
7
+ const { allocatePorts, buildSpecs } = require('./runtime');
8
+ const { createCorsShim } = require('./cors');
9
+ const { runProcesses } = require('./process-manager');
10
+ const { startProxy } = require('./proxy');
11
+ const { generateIdeFiles, isIdeTerminal } = require('./ide');
12
+ const { launchInBridge, stopBridge } = require('./bridge-client');
13
+ const pkg = require('../package.json');
14
+
15
+ const HELP = `ports-manager [options]
16
+
17
+ Detect and run a paired frontend/backend JavaScript project.
18
+
19
+ --frontend-dir PATH Override frontend/client directory
20
+ --backend-dir PATH Override backend/server directory
21
+ --frontend-port PORT Request frontend port
22
+ --backend-port PORT Request backend port
23
+ --proxy-port PORT Request same-origin proxy port
24
+ --range MIN-MAX Port search range (default 4000-4999)
25
+ --ban PORTS Ban comma-separated ports
26
+ --cors / --no-cors-shim Enable/disable the CORS preload shim
27
+ --force-cors Inject shim even when CORS is heuristically detected
28
+ --cors-credentials BOOL Whether shim emits credential support
29
+ --proxy Enable same-origin proxy mode
30
+ --api-prefix PATH Backend proxy prefix (default /api)
31
+ --wait-for-backend[=MS] Wait before frontend (bare form: 10000ms)
32
+ --ide-terminals[=MODE] auto, off, tasks, extension (bare form: auto)
33
+ --with-keybinding Generate a user-keybindings reference file
34
+ --keybinding KEY Reference shortcut (default ctrl+alt+p)
35
+ --config PATH Explicit JSON config
36
+ --dry-run Inspect and allocate without writes/processes
37
+ --stop Ask the bridge to stop this workspace's terminals
38
+ --help Show help
39
+ --version, -v Show version and author
40
+ `;
41
+
42
+ function valueFor(argv, index, name) {
43
+ const argument = argv[index];
44
+ const equals = argument.indexOf('=');
45
+ if (equals !== -1) return { value: argument.slice(equals + 1), consumed: 0 };
46
+ if (index + 1 >= argv.length || argv[index + 1].startsWith('--')) {
47
+ throw new Error(`${name} requires a value`);
48
+ }
49
+ return { value: argv[index + 1], consumed: 1 };
50
+ }
51
+
52
+ function integer(value, name) {
53
+ if (!/^\d+$/.test(value)) throw new Error(`${name} must be an integer`);
54
+ return Number(value);
55
+ }
56
+
57
+ function boolean(value, name) {
58
+ if (value === 'true') return true;
59
+ if (value === 'false') return false;
60
+ throw new Error(`${name} must be true or false`);
61
+ }
62
+
63
+ function parseArgs(argv) {
64
+ const options = { bannedPorts: [] };
65
+ for (let index = 0; index < argv.length; index += 1) {
66
+ const argument = argv[index];
67
+ const name = argument.split('=')[0];
68
+ if (name === '--help' || name === '-h') options.help = true;
69
+ else if (name === '--version' || name === '-v') options.version = true;
70
+ else if (name === '--dry-run') options.dryRun = true;
71
+ else if (name === '--stop') options.stop = true;
72
+ else if (name === '--proxy') options.proxy = true;
73
+ else if (name === '--no-proxy') options.proxy = false;
74
+ else if (name === '--cors') options.cors = true;
75
+ else if (name === '--no-cors') options.cors = false;
76
+ else if (name === '--no-cors-shim') options.cors = false;
77
+ else if (name === '--force-cors') options.forceCors = true;
78
+ else if (name === '--with-keybinding') options.withKeybinding = true;
79
+ else if (name === '--no-cors-credentials') options.corsCredentials = false;
80
+ else if (name === '--internal-proxy') {
81
+ options.internalProxy = true;
82
+ } else if (name === '--ide-terminals') {
83
+ if (argument.includes('=')) options.ideTerminals = argument.slice(argument.indexOf('=') + 1) || 'auto';
84
+ else if (argv[index + 1] && !argv[index + 1].startsWith('--') &&
85
+ ['off', 'auto', 'tasks', 'extension'].includes(argv[index + 1])) {
86
+ options.ideTerminals = argv[++index];
87
+ } else options.ideTerminals = 'auto';
88
+ } else if (name === '--wait-for-backend') {
89
+ if (argument.includes('=')) options.waitForBackend = integer(argument.split('=').slice(1).join('='), name);
90
+ else if (argv[index + 1] && /^\d+$/.test(argv[index + 1])) options.waitForBackend = integer(argv[++index], name);
91
+ else options.waitForBackend = 10000;
92
+ } else {
93
+ const mapping = {
94
+ '--frontend': 'frontend', '--backend': 'backend',
95
+ '--frontend-dir': 'frontend', '--backend-dir': 'backend', '--config': 'configPath',
96
+ '--api-prefix': 'apiPrefix', '--keybinding': 'keybinding',
97
+ '--frontend-port': 'frontendPort', '--backend-port': 'backendPort',
98
+ '--proxy-port': 'proxyPort', '--cors-credentials': 'corsCredentials',
99
+ '--port-range': 'portRange', '--range': 'portRange',
100
+ '--ban-port': 'banPort', '--ban': 'ban',
101
+ '--proxy-frontend-port': 'proxyFrontendPort', '--proxy-backend-port': 'proxyBackendPort'
102
+ };
103
+ const key = mapping[name];
104
+ if (!key) throw new Error(`unknown option: ${argument}`);
105
+ const found = valueFor(argv, index, name);
106
+ index += found.consumed;
107
+ if (['frontendPort', 'backendPort', 'proxyPort', 'proxyFrontendPort', 'proxyBackendPort'].includes(key)) {
108
+ options[key] = integer(found.value, name);
109
+ } else if (key === 'corsCredentials') {
110
+ options[key] = boolean(found.value, name);
111
+ } else if (key === 'banPort') {
112
+ options.bannedPorts.push(integer(found.value, name));
113
+ } else if (key === 'ban') {
114
+ const ports = found.value.split(',').map((value) => value.trim()).filter(Boolean);
115
+ if (!ports.length) throw new Error('--ban requires a comma-separated port list');
116
+ options.bannedPorts.push(...ports.map((value) => integer(value, name)));
117
+ } else if (key === 'portRange') {
118
+ const match = found.value.match(/^(\d+)-(\d+)$/);
119
+ if (!match) throw new Error('--port-range must be MIN-MAX');
120
+ options.portRange = [Number(match[1]), Number(match[2])];
121
+ } else options[key] = found.value;
122
+ }
123
+ }
124
+ if (!options.bannedPorts.length) delete options.bannedPorts;
125
+ return options;
126
+ }
127
+
128
+ function proxySpec(root, ports, apiPrefix) {
129
+ return {
130
+ name: 'Proxy',
131
+ cwd: root,
132
+ command: 'node',
133
+ args: [
134
+ path.resolve(__dirname, '../bin/ports-manager.js'), '--internal-proxy',
135
+ '--proxy-port', String(ports.proxy), '--proxy-frontend-port', String(ports.frontend),
136
+ '--proxy-backend-port', String(ports.backend), '--api-prefix', apiPrefix
137
+ ],
138
+ env: {},
139
+ color: 'yellow'
140
+ };
141
+ }
142
+
143
+ async function runProxyOnly(options) {
144
+ for (const key of ['proxyPort', 'proxyFrontendPort', 'proxyBackendPort']) {
145
+ if (!options[key]) throw new Error(`internal proxy missing ${key}`);
146
+ }
147
+ const proxy = await startProxy({
148
+ port: options.proxyPort,
149
+ frontendPort: options.proxyFrontendPort,
150
+ backendPort: options.proxyBackendPort,
151
+ apiPrefix: options.apiPrefix || '/api'
152
+ });
153
+ await new Promise((resolve) => {
154
+ const finish = async () => {
155
+ await proxy.close();
156
+ resolve();
157
+ };
158
+ process.once('SIGINT', finish);
159
+ process.once('SIGTERM', finish);
160
+ });
161
+ }
162
+
163
+ function printPlan(root, frontend, backend, ports, config, corsEnabled) {
164
+ console.log(`Root: ${root}`);
165
+ console.log(`Backend: ${backend.framework} / npm run ${backend.script} / ${ports.backend}`);
166
+ console.log(`Frontend: ${frontend.framework} / npm run ${frontend.script} / ${ports.frontend}`);
167
+ if (config.proxy) console.log(`Proxy: http://127.0.0.1:${ports.proxy}${config.apiPrefix}`);
168
+ console.log(`CORS shim: ${corsEnabled ? 'enabled' : 'skipped'}`);
169
+ }
170
+
171
+ async function main(argv = process.argv.slice(2), cwd = process.cwd()) {
172
+ const options = parseArgs(argv);
173
+ if (options.help) return console.log(HELP);
174
+ if (options.version) {
175
+ return console.log(`ports-manager v${pkg.version}\nMuhammad Saad Amin (SENODROOM)`);
176
+ }
177
+ if (options.internalProxy) return runProxyOnly(options);
178
+
179
+ const root = path.resolve(cwd);
180
+ const tag = `ports-manager:${root}`;
181
+ if (options.stop) {
182
+ await stopBridge(tag);
183
+ console.log('Requested closure of Ports Manager terminals for this workspace.');
184
+ return;
185
+ }
186
+ const loaded = await loadConfig(root, options.configPath);
187
+ const config = mergeConfig(loaded.config, options);
188
+ const folders = detectFolders(root, config);
189
+ const frontend = classifyProject(folders.frontend, 'frontend');
190
+ const backend = classifyProject(folders.backend, 'backend');
191
+ const scan = scanBackend(backend);
192
+ const corsEnabled = config.cors && (config.forceCors || !scan.corsDetected);
193
+ if (scan.corsDetected && config.cors && !config.forceCors) {
194
+ console.log('Existing CORS support detected heuristically; skipping preload shim (use --force-cors to override).');
195
+ }
196
+ if (!scan.hasEnvPort) {
197
+ console.warn('Warning: static scan did not find process.env.PORT in likely backend entry files; runtime behavior may differ.');
198
+ }
199
+ if (scan.literals.length) {
200
+ console.warn(`Warning: possible literal listen port(s) ${scan.literals.join(', ')} found; this heuristic may include inactive code.`);
201
+ }
202
+ if (frontend.framework === 'generic') {
203
+ console.warn('Warning: generic frontend receives PORT and API URL cannot be inferred; configure env values if needed.');
204
+ }
205
+
206
+ const allocated = await allocatePorts(config, config.proxy ? 3 : 2);
207
+ const ports = { backend: allocated[0], frontend: allocated[1], proxy: allocated[2] };
208
+ const ideMode = config.ideTerminals;
209
+ const inIde = isIdeTerminal();
210
+ let effectiveIdeMode = ideMode;
211
+ if (ideMode !== 'off' && !inIde) {
212
+ console.log('IDE terminal mode requested outside a corroborated VS Code/Cursor terminal; using inline mode.');
213
+ effectiveIdeMode = 'off';
214
+ }
215
+ const durableShim = path.join(root, '.vscode', 'ports-manager-cors-preload.cjs');
216
+ const shimPath = corsEnabled
217
+ ? (effectiveIdeMode === 'off' ? (options.dryRun ? '<dry-run-cors-shim>' : null) : durableShim)
218
+ : null;
219
+ let temporaryShim;
220
+ if (corsEnabled && effectiveIdeMode === 'off' && !options.dryRun) {
221
+ temporaryShim = createCorsShim();
222
+ }
223
+ const specs = buildSpecs(frontend, backend, ports, config,
224
+ temporaryShim ? temporaryShim.filename : shimPath);
225
+ const pSpec = config.proxy ? proxySpec(root, ports, config.apiPrefix) : null;
226
+ printPlan(root, frontend, backend, ports, config, corsEnabled);
227
+ if (options.dryRun) {
228
+ console.log('Dry run: no files written and no processes started.');
229
+ return;
230
+ }
231
+
232
+ if (effectiveIdeMode === 'extension' || effectiveIdeMode === 'auto') {
233
+ if (corsEnabled) {
234
+ fs.mkdirSync(path.dirname(durableShim), { recursive: true });
235
+ fs.writeFileSync(durableShim, require('./cors').SHIM_SOURCE, 'utf8');
236
+ }
237
+ try {
238
+ await launchInBridge(specs, pSpec, tag);
239
+ console.log('Started tagged terminals through the Ports Manager bridge.');
240
+ return;
241
+ } catch (error) {
242
+ if (effectiveIdeMode === 'extension') throw error;
243
+ console.log(`Bridge unavailable (${error.message}); generating VS Code tasks instead.`);
244
+ effectiveIdeMode = 'tasks';
245
+ }
246
+ }
247
+ if (effectiveIdeMode === 'tasks') {
248
+ const generated = generateIdeFiles(root, specs, pSpec, {
249
+ keybinding: config.keybinding,
250
+ needsShim: corsEnabled,
251
+ withKeybinding: config.withKeybinding
252
+ });
253
+ console.log(`Generated ${generated.tasksFile}. Run “Ports Manager: Run All” from Tasks: Run Task.`);
254
+ if (generated.keybindingsFile) {
255
+ console.log(`Suggested shortcut ${config.keybinding} is in ${generated.keybindingsFile}; copy it into Preferences: Open Keyboard Shortcuts (JSON).`);
256
+ console.log('VS Code/Cursor does not apply workspace keybindings files automatically.');
257
+ }
258
+ return;
259
+ }
260
+
261
+ let proxy;
262
+ try {
263
+ if (config.proxy) proxy = await startProxy({
264
+ port: ports.proxy, frontendPort: ports.frontend, backendPort: ports.backend,
265
+ apiPrefix: config.apiPrefix
266
+ });
267
+ const code = await runProcesses(specs, {
268
+ waitForBackend: config.waitForBackend,
269
+ backendPort: ports.backend
270
+ });
271
+ process.exitCode = code;
272
+ } finally {
273
+ if (proxy) await proxy.close();
274
+ if (temporaryShim) temporaryShim.cleanup();
275
+ }
276
+ }
277
+
278
+ module.exports = { HELP, main, parseArgs, proxySpec, runProxyOnly };
package/src/config.js ADDED
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+ const fs = require('node:fs');
5
+ const { cosmiconfig } = require('cosmiconfig');
6
+
7
+ const DEFAULTS = Object.freeze({
8
+ pairs: [['frontend', 'backend'], ['client', 'server']],
9
+ portRange: [4000, 4999],
10
+ bannedPorts: [3000, 5000],
11
+ cors: true,
12
+ forceCors: false,
13
+ corsCredentials: true,
14
+ proxy: false,
15
+ apiPrefix: '/api',
16
+ ideTerminals: 'off',
17
+ waitForBackend: 0,
18
+ withKeybinding: false,
19
+ keybinding: 'ctrl+alt+p',
20
+ env: {}
21
+ });
22
+
23
+ const allowedKeys = new Set([
24
+ 'pairs', 'frontend', 'backend', 'frontendPort', 'backendPort', 'proxyPort', 'portRange',
25
+ 'bannedPorts', 'cors', 'forceCors', 'corsCredentials', 'proxy', 'apiPrefix',
26
+ 'ideTerminals', 'waitForBackend', 'withKeybinding', 'keybinding', 'env'
27
+ ]);
28
+
29
+ function normalizeConfig(input) {
30
+ const config = { ...input };
31
+ if (config.cors && typeof config.cors === 'object' && !Array.isArray(config.cors)) {
32
+ const cors = config.cors;
33
+ config.cors = cors.mode !== 'off' && cors.enabled !== false;
34
+ if (cors.credentials !== undefined) config.corsCredentials = cors.credentials;
35
+ }
36
+ if (config.proxy && typeof config.proxy === 'object' && !Array.isArray(config.proxy)) {
37
+ config.proxy = config.proxy.enabled === true;
38
+ }
39
+ if (config.ideTerminals && typeof config.ideTerminals === 'object' &&
40
+ !Array.isArray(config.ideTerminals)) {
41
+ const ide = config.ideTerminals;
42
+ config.ideTerminals = ide.mode || 'off';
43
+ if (ide.withKeybinding !== undefined) config.withKeybinding = ide.withKeybinding;
44
+ if (ide.keybinding !== undefined) config.keybinding = ide.keybinding;
45
+ }
46
+ return config;
47
+ }
48
+
49
+ function assertPort(value, name) {
50
+ if (!Number.isInteger(value) || value < 1 || value > 65535) {
51
+ throw new Error(`${name} must be an integer from 1 to 65535`);
52
+ }
53
+ }
54
+
55
+ function validateConfig(config) {
56
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
57
+ throw new Error('configuration must be a JSON object');
58
+ }
59
+ for (const key of Object.keys(config)) {
60
+ if (!allowedKeys.has(key)) throw new Error(`unknown configuration key: ${key}`);
61
+ }
62
+ if (config.pairs !== undefined) {
63
+ if (!Array.isArray(config.pairs) || config.pairs.length === 0 ||
64
+ config.pairs.some((pair) => !Array.isArray(pair) || pair.length !== 2 ||
65
+ pair.some((name) => typeof name !== 'string' || !name.trim()))) {
66
+ throw new Error('pairs must be a non-empty array of [frontend, backend] directory names');
67
+ }
68
+ }
69
+ for (const key of ['frontendPort', 'backendPort', 'proxyPort']) {
70
+ if (config[key] !== undefined) assertPort(config[key], key);
71
+ }
72
+ if (config.portRange !== undefined) {
73
+ if (!Array.isArray(config.portRange) || config.portRange.length !== 2) {
74
+ throw new Error('portRange must be [minimum, maximum]');
75
+ }
76
+ config.portRange.forEach((port, index) => assertPort(port, `portRange[${index}]`));
77
+ if (config.portRange[0] > config.portRange[1]) throw new Error('portRange minimum exceeds maximum');
78
+ }
79
+ if (config.bannedPorts !== undefined) {
80
+ if (!Array.isArray(config.bannedPorts)) throw new Error('bannedPorts must be an array');
81
+ config.bannedPorts.forEach((port, index) => assertPort(port, `bannedPorts[${index}]`));
82
+ }
83
+ for (const key of ['cors', 'forceCors', 'corsCredentials', 'proxy', 'withKeybinding']) {
84
+ if (config[key] !== undefined && typeof config[key] !== 'boolean') {
85
+ throw new Error(`${key} must be boolean`);
86
+ }
87
+ }
88
+ if (config.apiPrefix !== undefined &&
89
+ (typeof config.apiPrefix !== 'string' || !config.apiPrefix.startsWith('/'))) {
90
+ throw new Error('apiPrefix must be a string beginning with /');
91
+ }
92
+ if (config.ideTerminals !== undefined &&
93
+ !['off', 'auto', 'tasks', 'extension'].includes(config.ideTerminals)) {
94
+ throw new Error('ideTerminals must be off, auto, tasks, or extension');
95
+ }
96
+ if (config.waitForBackend !== undefined &&
97
+ (!Number.isInteger(config.waitForBackend) || config.waitForBackend < 0)) {
98
+ throw new Error('waitForBackend must be a non-negative integer (milliseconds)');
99
+ }
100
+ if (config.env !== undefined &&
101
+ (!config.env || typeof config.env !== 'object' || Array.isArray(config.env))) {
102
+ throw new Error('env must be an object');
103
+ }
104
+ return config;
105
+ }
106
+
107
+ async function loadConfig(cwd, explicitPath) {
108
+ let loaded = {};
109
+ let filepath;
110
+ if (explicitPath) {
111
+ filepath = path.resolve(cwd, explicitPath);
112
+ let raw;
113
+ try {
114
+ raw = fs.readFileSync(filepath, 'utf8');
115
+ } catch (error) {
116
+ throw new Error(`cannot read config ${filepath}: ${error.message}`);
117
+ }
118
+ try {
119
+ loaded = JSON.parse(raw);
120
+ } catch (error) {
121
+ throw new Error(`invalid JSON in ${filepath}: ${error.message}`);
122
+ }
123
+ } else {
124
+ const explorer = cosmiconfig('ports-manager', {
125
+ searchPlaces: ['ports-manager.config.json', '.ports-managerrc', '.ports-managerrc.json']
126
+ });
127
+ const result = await explorer.search(cwd);
128
+ if (result) {
129
+ loaded = result.config;
130
+ filepath = result.filepath;
131
+ }
132
+ }
133
+ loaded = normalizeConfig(loaded);
134
+ validateConfig(loaded);
135
+ return { config: loaded, filepath };
136
+ }
137
+
138
+ function mergeConfig(fileConfig, cliConfig) {
139
+ fileConfig = normalizeConfig(fileConfig);
140
+ const result = { ...DEFAULTS, ...fileConfig };
141
+ for (const [key, value] of Object.entries(cliConfig)) {
142
+ if (allowedKeys.has(key) && value !== undefined) result[key] = value;
143
+ }
144
+ result.bannedPorts = [...new Set([
145
+ 3000, 5000, ...(fileConfig.bannedPorts || []), ...(cliConfig.bannedPorts || [])
146
+ ])];
147
+ validateConfig(result);
148
+ return result;
149
+ }
150
+
151
+ module.exports = { DEFAULTS, loadConfig, mergeConfig, normalizeConfig, validateConfig };
package/src/cors.js ADDED
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+
7
+ const SHIM_SOURCE = String.raw`'use strict';
8
+ const http = require('node:http');
9
+ const originalEmit = http.Server.prototype.emit;
10
+ http.Server.prototype.emit = function portsManagerEmit(event, req, res) {
11
+ if (event !== 'request' || !req || !res) {
12
+ return originalEmit.apply(this, arguments);
13
+ }
14
+ const requestedOrigin = req.headers && req.headers.origin;
15
+ const configuredOrigin = process.env.PORTS_MANAGER_CORS_ORIGIN;
16
+ const origin = requestedOrigin || configuredOrigin;
17
+ if (origin && !res.headersSent) {
18
+ res.setHeader('Access-Control-Allow-Origin', origin);
19
+ res.setHeader('Vary', mergeVary(res.getHeader('Vary'), 'Origin'));
20
+ if (process.env.PORTS_MANAGER_CORS_CREDENTIALS !== 'false') {
21
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
22
+ }
23
+ res.setHeader('Access-Control-Allow-Methods',
24
+ req.headers['access-control-request-method'] || 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS');
25
+ res.setHeader('Access-Control-Allow-Headers',
26
+ req.headers['access-control-request-headers'] || 'Content-Type,Authorization');
27
+ }
28
+ if (req.method === 'OPTIONS') {
29
+ res.statusCode = 204;
30
+ res.setHeader('Content-Length', '0');
31
+ res.end();
32
+ return true;
33
+ }
34
+ return originalEmit.apply(this, arguments);
35
+ };
36
+ function mergeVary(current, value) {
37
+ const values = String(current || '').split(',').map((item) => item.trim()).filter(Boolean);
38
+ if (!values.some((item) => item.toLowerCase() === value.toLowerCase())) values.push(value);
39
+ return values.join(', ');
40
+ }
41
+ `;
42
+
43
+ function createCorsShim() {
44
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ports-manager-'));
45
+ const filename = path.join(directory, 'cors-preload.cjs');
46
+ fs.writeFileSync(filename, SHIM_SOURCE, { encoding: 'utf8', mode: 0o600 });
47
+ return {
48
+ filename,
49
+ cleanup() {
50
+ fs.rmSync(directory, { recursive: true, force: true });
51
+ }
52
+ };
53
+ }
54
+
55
+ module.exports = { SHIM_SOURCE, createCorsShim };
package/src/detect.js ADDED
@@ -0,0 +1,114 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ function readPackage(directory) {
7
+ const filename = path.join(directory, 'package.json');
8
+ if (!fs.existsSync(filename)) return null;
9
+ try {
10
+ return JSON.parse(fs.readFileSync(filename, 'utf8'));
11
+ } catch (error) {
12
+ throw new Error(`invalid package.json at ${filename}: ${error.message}`);
13
+ }
14
+ }
15
+
16
+ function validProject(directory) {
17
+ return fs.existsSync(directory) && fs.statSync(directory).isDirectory() && Boolean(readPackage(directory));
18
+ }
19
+
20
+ function detectFolders(cwd, overrides = {}) {
21
+ const pairs = overrides.pairs || [['frontend', 'backend'], ['client', 'server']];
22
+ const found = pairs.filter(([front, back]) =>
23
+ validProject(path.join(cwd, front)) && validProject(path.join(cwd, back)));
24
+
25
+ let frontend = overrides.frontend && path.resolve(cwd, overrides.frontend);
26
+ let backend = overrides.backend && path.resolve(cwd, overrides.backend);
27
+
28
+ if (!frontend && !backend) {
29
+ if (found.length > 1) {
30
+ throw new Error('ambiguous project layout: multiple configured folder pairs exist; use --frontend-dir and --backend-dir');
31
+ }
32
+ if (found.length === 1) {
33
+ frontend = path.join(cwd, found[0][0]);
34
+ backend = path.join(cwd, found[0][1]);
35
+ } else {
36
+ const basename = path.basename(cwd).toLowerCase();
37
+ for (const [front, back] of pairs) {
38
+ if (basename === front || basename === back) {
39
+ const parent = path.dirname(cwd);
40
+ if (validProject(path.join(parent, front)) && validProject(path.join(parent, back))) {
41
+ throw new Error(`run ports-manager from the parent directory: ${parent}`);
42
+ }
43
+ }
44
+ }
45
+ throw new Error('no paired frontend/backend or client/server package.json projects found');
46
+ }
47
+ } else if (!frontend || !backend) {
48
+ const wanted = frontend ? 'backend' : 'frontend';
49
+ const candidates = pairs.map((pair) => pair[wanted === 'frontend' ? 0 : 1])
50
+ .map((name) => path.join(cwd, name)).filter(validProject);
51
+ if (candidates.length !== 1) throw new Error(`cannot infer ${wanted}; specify --${wanted}-dir`);
52
+ if (wanted === 'frontend') frontend = candidates[0];
53
+ else backend = candidates[0];
54
+ }
55
+
56
+ if (!validProject(frontend)) throw new Error(`frontend must be a directory containing package.json: ${frontend}`);
57
+ if (!validProject(backend)) throw new Error(`backend must be a directory containing package.json: ${backend}`);
58
+ if (path.resolve(frontend) === path.resolve(backend)) throw new Error('frontend and backend must be distinct directories');
59
+ return { frontend, backend };
60
+ }
61
+
62
+ function hasDependency(pkg, name) {
63
+ return Boolean((pkg.dependencies && pkg.dependencies[name]) ||
64
+ (pkg.devDependencies && pkg.devDependencies[name]));
65
+ }
66
+
67
+ function classifyProject(directory, role) {
68
+ const pkg = readPackage(directory);
69
+ const scripts = pkg.scripts || {};
70
+ const script = scripts.dev ? 'dev' : scripts.start ? 'start' : null;
71
+ if (!script) throw new Error(`${directory} has neither a dev nor start script`);
72
+
73
+ let framework = 'generic';
74
+ if (role === 'frontend') {
75
+ if (hasDependency(pkg, 'react-scripts')) framework = 'cra';
76
+ else if (hasDependency(pkg, 'vite')) framework = 'vite';
77
+ else if (hasDependency(pkg, 'next')) framework = 'next';
78
+ } else {
79
+ for (const [dependency, name] of [
80
+ ['@nestjs/core', 'nest'], ['express', 'express'], ['koa', 'koa'], ['fastify', 'fastify']
81
+ ]) {
82
+ if (hasDependency(pkg, dependency)) {
83
+ framework = name;
84
+ break;
85
+ }
86
+ }
87
+ }
88
+ return { directory, pkg, role, framework, script };
89
+ }
90
+
91
+ function likelyEntryFiles(project) {
92
+ const scriptText = (project.pkg.scripts && project.pkg.scripts[project.script]) || '';
93
+ const entries = [];
94
+ const match = scriptText.match(/(?:node|nodemon|tsx|ts-node)\s+(?:--\S+\s+)*["']?([^\s"']+\.[cm]?[jt]s)/);
95
+ if (match) entries.push(match[1]);
96
+ for (const candidate of ['server.js', 'index.js', 'app.js', 'src/server.js', 'src/index.js', 'src/main.ts']) {
97
+ if (!entries.includes(candidate)) entries.push(candidate);
98
+ }
99
+ return entries.map((entry) => path.resolve(project.directory, entry)).filter(fs.existsSync);
100
+ }
101
+
102
+ function scanBackend(project) {
103
+ const files = likelyEntryFiles(project).slice(0, 3);
104
+ const source = files.map((file) => fs.readFileSync(file, 'utf8')).join('\n');
105
+ const hasEnvPort = /\bprocess\.env\.PORT\b|\bprocess\.env\[['"]PORT['"]\]/.test(source);
106
+ const literals = [...source.matchAll(/\.listen\s*\(\s*(\d{2,5})\b/g)].map((match) => Number(match[1]));
107
+ const corsDetected = /\brequire\s*\(\s*['"]cors['"]\s*\)|\bfrom\s+['"]cors['"]|\bapp\.use\s*\(\s*cors\s*\(/.test(source) ||
108
+ hasDependency(project.pkg, 'cors');
109
+ return { files, hasEnvPort, literals: [...new Set(literals)], corsDetected };
110
+ }
111
+
112
+ module.exports = {
113
+ classifyProject, detectFolders, hasDependency, readPackage, scanBackend, validProject
114
+ };