ports-manager 1.0.0 → 1.0.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/.claude/settings.json +12 -0
- package/README.md +278 -276
- package/package.json +8 -37
- package/src/cli.js +248 -278
- package/src/config.js +13 -20
- package/src/cors.js +95 -55
- package/src/detect.js +281 -114
- package/src/index.js +10 -9
- package/src/leases.js +130 -0
- package/src/preflight.js +214 -0
- package/src/process-manager.js +187 -97
- package/src/runtime.js +228 -121
- package/src/wiring.js +261 -0
- package/bridge/src/extension.ts +0 -96
- package/bridge/src/server.ts +0 -185
- package/dist/bridge/src/extension.js +0 -133
- package/dist/bridge/src/extension.js.map +0 -1
- package/dist/bridge/src/server.js +0 -202
- package/dist/bridge/src/server.js.map +0 -1
- package/src/bridge-client.js +0 -81
- package/src/ide.js +0 -107
- package/tsconfig.bridge.json +0 -18
package/src/leases.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
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 LEASE_DIR = path.join(os.homedir() || os.tmpdir(), '.ports-manager');
|
|
8
|
+
const LEASE_FILE = path.join(LEASE_DIR, 'leases.json');
|
|
9
|
+
const LOCK_FILE = path.join(LEASE_DIR, 'leases.lock');
|
|
10
|
+
const STALE_LOCK_MS = 10000;
|
|
11
|
+
|
|
12
|
+
function alive(pid) {
|
|
13
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
14
|
+
if (pid === process.pid) return true;
|
|
15
|
+
try {
|
|
16
|
+
process.kill(pid, 0);
|
|
17
|
+
return true;
|
|
18
|
+
} catch (error) {
|
|
19
|
+
// EPERM means the process exists but belongs to another user.
|
|
20
|
+
return error.code === 'EPERM';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ensureDirectory() {
|
|
25
|
+
fs.mkdirSync(LEASE_DIR, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Best-effort exclusive lock. The lease file is a secondary guard (allocation also
|
|
30
|
+
* holds real listening sockets), so a lock we cannot take is not worth failing over.
|
|
31
|
+
*/
|
|
32
|
+
function withLock(action) {
|
|
33
|
+
ensureDirectory();
|
|
34
|
+
const deadline = Date.now() + 2000;
|
|
35
|
+
let handle;
|
|
36
|
+
while (!handle) {
|
|
37
|
+
try {
|
|
38
|
+
handle = fs.openSync(LOCK_FILE, 'wx');
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error.code !== 'EEXIST') return action();
|
|
41
|
+
let stale = true;
|
|
42
|
+
try {
|
|
43
|
+
stale = Date.now() - fs.statSync(LOCK_FILE).mtimeMs > STALE_LOCK_MS;
|
|
44
|
+
} catch {
|
|
45
|
+
stale = true;
|
|
46
|
+
}
|
|
47
|
+
if (stale) {
|
|
48
|
+
try {
|
|
49
|
+
fs.unlinkSync(LOCK_FILE);
|
|
50
|
+
} catch {
|
|
51
|
+
/* another process won the race; retry */
|
|
52
|
+
}
|
|
53
|
+
} else if (Date.now() > deadline) {
|
|
54
|
+
return action();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
return action();
|
|
60
|
+
} finally {
|
|
61
|
+
fs.closeSync(handle);
|
|
62
|
+
try {
|
|
63
|
+
fs.unlinkSync(LOCK_FILE);
|
|
64
|
+
} catch {
|
|
65
|
+
/* already removed */
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parse(raw) {
|
|
71
|
+
const parsed = JSON.parse(raw);
|
|
72
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Reads leases, dropping any whose owning process has exited. */
|
|
76
|
+
function readLeases() {
|
|
77
|
+
let entries;
|
|
78
|
+
try {
|
|
79
|
+
entries = parse(fs.readFileSync(LEASE_FILE, 'utf8'));
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
return entries.filter((entry) => entry && Number.isInteger(entry.port) && alive(entry.pid));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function writeLeases(entries) {
|
|
87
|
+
ensureDirectory();
|
|
88
|
+
const temporary = `${LEASE_FILE}.${process.pid}.tmp`;
|
|
89
|
+
fs.writeFileSync(temporary, `${JSON.stringify(entries, null, 2)}\n`, 'utf8');
|
|
90
|
+
fs.renameSync(temporary, LEASE_FILE);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Ports currently leased by other live ports-manager instances. */
|
|
94
|
+
function heldPorts() {
|
|
95
|
+
try {
|
|
96
|
+
return new Set(readLeases().filter((entry) => entry.pid !== process.pid).map((entry) => entry.port));
|
|
97
|
+
} catch {
|
|
98
|
+
return new Set();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Records this process as the owner of `ports`, replacing any earlier claim of ours. */
|
|
103
|
+
function acquire(ports, root) {
|
|
104
|
+
try {
|
|
105
|
+
return withLock(() => {
|
|
106
|
+
const entries = readLeases().filter((entry) => entry.pid !== process.pid);
|
|
107
|
+
const startedAt = new Date().toISOString();
|
|
108
|
+
for (const port of ports) {
|
|
109
|
+
if (Number.isInteger(port)) entries.push({ port, pid: process.pid, root, startedAt });
|
|
110
|
+
}
|
|
111
|
+
writeLeases(entries);
|
|
112
|
+
return true;
|
|
113
|
+
});
|
|
114
|
+
} catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function release() {
|
|
120
|
+
try {
|
|
121
|
+
return withLock(() => {
|
|
122
|
+
writeLeases(readLeases().filter((entry) => entry.pid !== process.pid));
|
|
123
|
+
return true;
|
|
124
|
+
});
|
|
125
|
+
} catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { LEASE_FILE, acquire, alive, heldPorts, readLeases, release, withLock };
|
package/src/preflight.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { spawn } = require('node:child_process');
|
|
6
|
+
|
|
7
|
+
/** Wrappers that prefix the real dev binary; the token after them matters too. */
|
|
8
|
+
const WRAPPERS = new Set(['cross-env', 'dotenv', 'env-cmd', 'npx', 'pnpm', 'yarn', 'bun', 'run', 'exec']);
|
|
9
|
+
/** Always present, so never worth reporting as missing. */
|
|
10
|
+
const ALWAYS_AVAILABLE = new Set(['node', 'npm', 'npm.cmd', 'sh', 'bash', 'cmd', 'echo', 'set', 'wait']);
|
|
11
|
+
|
|
12
|
+
const PACKAGE_MANAGERS = {
|
|
13
|
+
npm: { lockfile: 'package-lock.json', install: ['install'] },
|
|
14
|
+
pnpm: { lockfile: 'pnpm-lock.yaml', install: ['install'] },
|
|
15
|
+
yarn: { lockfile: 'yarn.lock', install: ['install'] },
|
|
16
|
+
bun: { lockfile: 'bun.lockb', install: ['install'] }
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Windows needs an interpreter for `.cmd` shims; spawn cannot exec them directly. */
|
|
20
|
+
function commandInvocation(name) {
|
|
21
|
+
if (process.platform === 'win32') {
|
|
22
|
+
return { command: process.env.ComSpec || 'cmd.exe', argsPrefix: ['/d', '/s', '/c', `${name}.cmd`] };
|
|
23
|
+
}
|
|
24
|
+
return { command: name, argsPrefix: [] };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function npmInvocation() {
|
|
28
|
+
return commandInvocation('npm');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ancestors(directory) {
|
|
32
|
+
const chain = [];
|
|
33
|
+
let current = path.resolve(directory);
|
|
34
|
+
while (true) {
|
|
35
|
+
chain.push(current);
|
|
36
|
+
const parent = path.dirname(current);
|
|
37
|
+
if (parent === current) return chain;
|
|
38
|
+
current = parent;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Every node_modules/.bin from `directory` up to the filesystem root (handles hoisted installs). */
|
|
43
|
+
function binDirectories(directory) {
|
|
44
|
+
return ancestors(directory)
|
|
45
|
+
.map((entry) => path.join(entry, 'node_modules', '.bin'))
|
|
46
|
+
.filter((entry) => fs.existsSync(entry));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function detectPackageManager(directory) {
|
|
50
|
+
for (const entry of ancestors(directory)) {
|
|
51
|
+
const manifest = path.join(entry, 'package.json');
|
|
52
|
+
if (fs.existsSync(manifest)) {
|
|
53
|
+
try {
|
|
54
|
+
const declared = JSON.parse(fs.readFileSync(manifest, 'utf8')).packageManager;
|
|
55
|
+
const name = typeof declared === 'string' ? declared.split('@')[0] : null;
|
|
56
|
+
if (name && PACKAGE_MANAGERS[name]) return name;
|
|
57
|
+
} catch {
|
|
58
|
+
/* unreadable manifest; fall through to lockfile detection */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const [name, meta] of Object.entries(PACKAGE_MANAGERS)) {
|
|
62
|
+
if (fs.existsSync(path.join(entry, meta.lockfile))) return name;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return 'npm';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function splitSegments(script) {
|
|
69
|
+
return String(script || '').split(/&&|\|\||;|(?<!\|)\|(?!\|)/g);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function stripQuotes(token) {
|
|
73
|
+
return token.replace(/^["']|["']$/g, '');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Binaries an npm script needs before it can run. Skips `KEY=value` prefixes, looks past
|
|
78
|
+
* known wrappers, and ignores paths and always-available commands.
|
|
79
|
+
*/
|
|
80
|
+
function requiredBinaries(script) {
|
|
81
|
+
const found = [];
|
|
82
|
+
for (const segment of splitSegments(script)) {
|
|
83
|
+
const tokens = segment.trim().split(/\s+/).map(stripQuotes).filter(Boolean);
|
|
84
|
+
let index = 0;
|
|
85
|
+
let considered = 0;
|
|
86
|
+
while (index < tokens.length && considered < 2) {
|
|
87
|
+
const token = tokens[index];
|
|
88
|
+
index += 1;
|
|
89
|
+
// Skip flags and `KEY=value` prefixes, both leading and cross-env style.
|
|
90
|
+
if (token.startsWith('-') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) continue;
|
|
91
|
+
considered += 1;
|
|
92
|
+
const bare = path.basename(token);
|
|
93
|
+
if (token.includes('/') || token.includes('\\') || bare !== token) break;
|
|
94
|
+
if (ALWAYS_AVAILABLE.has(token)) break;
|
|
95
|
+
if (!found.includes(token)) found.push(token);
|
|
96
|
+
if (!WRAPPERS.has(token)) break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return found;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function executableCandidates(directory, name) {
|
|
103
|
+
const suffixes = process.platform === 'win32' ? ['.cmd', '.exe', '.bat', '.ps1', ''] : [''];
|
|
104
|
+
return suffixes.map((suffix) => path.join(directory, `${name}${suffix}`));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Resolves `name` against local .bin directories first, then PATH. */
|
|
108
|
+
function resolveBinary(name, directory) {
|
|
109
|
+
for (const binDirectory of binDirectories(directory)) {
|
|
110
|
+
for (const candidate of executableCandidates(binDirectory, name)) {
|
|
111
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const pathEntries = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
115
|
+
for (const entry of pathEntries) {
|
|
116
|
+
for (const candidate of executableCandidates(entry, name)) {
|
|
117
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function packageInstalled(name, directory) {
|
|
124
|
+
return ancestors(directory)
|
|
125
|
+
.some((entry) => fs.existsSync(path.join(entry, 'node_modules', name, 'package.json')));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Declared dependencies with nothing on disk. A partially installed `node_modules` passes
|
|
130
|
+
* a dev-binary check and then dies at require time, which is worth catching before we
|
|
131
|
+
* start anything. optionalDependencies are skipped — absence there is legitimate.
|
|
132
|
+
*/
|
|
133
|
+
function missingPackages(pkg, directory) {
|
|
134
|
+
const declared = [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.devDependencies || {})];
|
|
135
|
+
return declared.filter((name) => !packageInstalled(name, directory));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Verifies a project can actually start: dependencies present and every binary its dev
|
|
140
|
+
* script invokes resolvable. This is the check whose absence produced a bare
|
|
141
|
+
* `'vite' is not recognized` from cmd.exe with no explanation.
|
|
142
|
+
*/
|
|
143
|
+
function checkProject(project) {
|
|
144
|
+
const script = (project.pkg.scripts || {})[project.script] || '';
|
|
145
|
+
const hasNodeModules = fs.existsSync(path.join(project.directory, 'node_modules')) ||
|
|
146
|
+
binDirectories(project.directory).length > 0;
|
|
147
|
+
const missing = requiredBinaries(script)
|
|
148
|
+
.filter((name) => !resolveBinary(name, project.directory));
|
|
149
|
+
const missingDeps = missingPackages(project.pkg, project.directory);
|
|
150
|
+
return {
|
|
151
|
+
role: project.role,
|
|
152
|
+
directory: project.directory,
|
|
153
|
+
script,
|
|
154
|
+
hasNodeModules,
|
|
155
|
+
missing,
|
|
156
|
+
missingDeps,
|
|
157
|
+
packageManager: detectPackageManager(project.directory),
|
|
158
|
+
ok: missing.length === 0 && missingDeps.length === 0
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function installDependencies(check, logger = console) {
|
|
163
|
+
const meta = PACKAGE_MANAGERS[check.packageManager] || PACKAGE_MANAGERS.npm;
|
|
164
|
+
const invocation = commandInvocation(check.packageManager);
|
|
165
|
+
const args = [...invocation.argsPrefix, ...meta.install];
|
|
166
|
+
logger.log(`[${check.role}] running \`${check.packageManager} ${meta.install.join(' ')}\` in ${check.directory}`);
|
|
167
|
+
return new Promise((resolve) => {
|
|
168
|
+
const child = spawn(invocation.command, args, {
|
|
169
|
+
cwd: check.directory,
|
|
170
|
+
env: process.env,
|
|
171
|
+
stdio: 'inherit',
|
|
172
|
+
shell: false
|
|
173
|
+
});
|
|
174
|
+
child.once('error', (error) => {
|
|
175
|
+
logger.error(`[${check.role}] install failed to start: ${error.message}`);
|
|
176
|
+
resolve(1);
|
|
177
|
+
});
|
|
178
|
+
child.once('exit', (code) => resolve(code === null ? 1 : code));
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function summarize(check) {
|
|
183
|
+
if (!check.hasNodeModules) return 'dependencies are not installed';
|
|
184
|
+
const parts = [];
|
|
185
|
+
if (check.missing.length) parts.push(`${check.missing.map((name) => `\`${name}\``).join(', ')} not found`);
|
|
186
|
+
if (check.missingDeps.length) {
|
|
187
|
+
const shown = check.missingDeps.slice(0, 3).join(', ');
|
|
188
|
+
const extra = check.missingDeps.length > 3 ? ` and ${check.missingDeps.length - 3} more` : '';
|
|
189
|
+
parts.push(`${check.missingDeps.length} package(s) missing from node_modules (${shown}${extra})`);
|
|
190
|
+
}
|
|
191
|
+
return parts.join('; ') || 'dependencies are incomplete';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function describeFailure(check) {
|
|
195
|
+
return [
|
|
196
|
+
`${check.role}: ${summarize(check)} in ${check.directory}`,
|
|
197
|
+
` fix: cd ${check.directory} && ${check.packageManager} install`
|
|
198
|
+
].join('\n');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
PACKAGE_MANAGERS,
|
|
203
|
+
binDirectories,
|
|
204
|
+
checkProject,
|
|
205
|
+
commandInvocation,
|
|
206
|
+
describeFailure,
|
|
207
|
+
detectPackageManager,
|
|
208
|
+
installDependencies,
|
|
209
|
+
missingPackages,
|
|
210
|
+
npmInvocation,
|
|
211
|
+
requiredBinaries,
|
|
212
|
+
resolveBinary,
|
|
213
|
+
summarize
|
|
214
|
+
};
|
package/src/process-manager.js
CHANGED
|
@@ -1,97 +1,187 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const { spawn } = require('node:child_process');
|
|
4
|
-
const treeKill = require('tree-kill');
|
|
5
|
-
const chalk = require('chalk');
|
|
6
|
-
const { isPortFree } = require('./runtime');
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('node:child_process');
|
|
4
|
+
const treeKill = require('tree-kill');
|
|
5
|
+
const chalk = require('chalk');
|
|
6
|
+
const { isPortFree } = require('./runtime');
|
|
7
|
+
|
|
8
|
+
/** A child dying this quickly almost always means it never really started. */
|
|
9
|
+
const FAST_EXIT_MS = 15000;
|
|
10
|
+
const RECENT_LINES = 10;
|
|
11
|
+
|
|
12
|
+
function pipeLines(stream, output, prefix, color, recent) {
|
|
13
|
+
let pending = '';
|
|
14
|
+
stream.on('data', (chunk) => {
|
|
15
|
+
pending += chunk.toString();
|
|
16
|
+
const lines = pending.split(/\r?\n/);
|
|
17
|
+
pending = lines.pop();
|
|
18
|
+
for (const line of lines) {
|
|
19
|
+
if (recent) {
|
|
20
|
+
recent.push(line);
|
|
21
|
+
if (recent.length > RECENT_LINES) recent.shift();
|
|
22
|
+
}
|
|
23
|
+
output.write(`${chalk[color](`[${prefix}]`)} ${line}\n`);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
stream.on('end', () => {
|
|
27
|
+
if (pending) output.write(`${chalk[color](`[${prefix}]`)} ${pending}\n`);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function spawnSpec(spec, recent) {
|
|
32
|
+
const child = spawn(spec.command, spec.args, {
|
|
33
|
+
cwd: spec.cwd,
|
|
34
|
+
env: { ...process.env, ...spec.env },
|
|
35
|
+
windowsHide: false,
|
|
36
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
37
|
+
shell: false
|
|
38
|
+
});
|
|
39
|
+
pipeLines(child.stdout, process.stdout, spec.name, spec.color, recent);
|
|
40
|
+
pipeLines(child.stderr, process.stderr, spec.name, spec.color, recent);
|
|
41
|
+
return child;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Turns a raw shell error into something actionable. A bare
|
|
46
|
+
* `'vite' is not recognized as an internal or external command` tells the user nothing
|
|
47
|
+
* about which directory is missing its dependencies.
|
|
48
|
+
*/
|
|
49
|
+
function explainExit(spec, recent) {
|
|
50
|
+
const text = recent.join('\n');
|
|
51
|
+
const notRecognized = text.match(/'([^']+)' is not recognized as an internal or external command/) ||
|
|
52
|
+
text.match(/([\w.@/-]+): (?:command )?not found/) ||
|
|
53
|
+
text.match(/spawn ([\w.@/-]+) ENOENT/);
|
|
54
|
+
if (notRecognized) {
|
|
55
|
+
return [
|
|
56
|
+
`${spec.name} could not run \`${notRecognized[1]}\` — it is not installed in ${spec.cwd}.`,
|
|
57
|
+
` fix: cd ${spec.cwd} && npm install (or re-run ports-manager without --no-install)`
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
// ESM says "Cannot find package", CommonJS says "Cannot find module".
|
|
61
|
+
const missingModule = text.match(/Cannot find (?:module|package) ['"]([^'"]+)['"]/);
|
|
62
|
+
if (missingModule) {
|
|
63
|
+
return [
|
|
64
|
+
`${spec.name} is missing the package \`${missingModule[1]}\`; its node_modules is incomplete.`,
|
|
65
|
+
` fix: cd ${spec.cwd} && npm install`
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
const inUse = text.match(/EADDRINUSE[^\n]*?(\d{2,5})/);
|
|
69
|
+
if (inUse) {
|
|
70
|
+
return [
|
|
71
|
+
`${spec.name} could not bind port ${inUse[1]} — something else took it after allocation.`,
|
|
72
|
+
' fix: re-run ports-manager, or pin a port with --backend-port / --frontend-port'
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
if (/ERR_MODULE_NOT_FOUND|ERR_REQUIRE_ESM/.test(text)) {
|
|
76
|
+
return [`${spec.name} failed to load its entry module; see the output above.`];
|
|
77
|
+
}
|
|
78
|
+
// Unrecognized, but clearly not healthy — say that rather than guessing at a cause.
|
|
79
|
+
if (/\b(error|failed|exception|ECONNREFUSED)\b/i.test(text)) {
|
|
80
|
+
return [`${spec.name} reported an error while starting; see its output above.`];
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function waitForListening(port, timeout, shouldAbort = () => false) {
|
|
86
|
+
const deadline = Date.now() + timeout;
|
|
87
|
+
while (Date.now() < deadline) {
|
|
88
|
+
if (shouldAbort()) return false;
|
|
89
|
+
if (!await isPortFree(port)) return true;
|
|
90
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function killTree(pid, signal = 'SIGTERM') {
|
|
96
|
+
return new Promise((resolve) => {
|
|
97
|
+
if (!pid) return resolve();
|
|
98
|
+
treeKill(pid, signal, () => resolve());
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function runProcesses(specs, options = {}) {
|
|
103
|
+
const children = [];
|
|
104
|
+
const output = new Map();
|
|
105
|
+
const startedAt = new Map();
|
|
106
|
+
let stopping = false;
|
|
107
|
+
let resolveDone;
|
|
108
|
+
const done = new Promise((resolve) => { resolveDone = resolve; });
|
|
109
|
+
|
|
110
|
+
const stop = async (code = 0) => {
|
|
111
|
+
if (stopping) return;
|
|
112
|
+
stopping = true;
|
|
113
|
+
await Promise.all(children.filter((child) => child.exitCode === null)
|
|
114
|
+
.map((child) => killTree(child.pid)));
|
|
115
|
+
resolveDone(code);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const register = (spec) => {
|
|
119
|
+
const recent = [];
|
|
120
|
+
output.set(spec.name, recent);
|
|
121
|
+
startedAt.set(spec.name, Date.now());
|
|
122
|
+
const child = spawnSpec(spec, recent);
|
|
123
|
+
children.push(child);
|
|
124
|
+
child.once('error', (error) => {
|
|
125
|
+
console.error(`[${spec.name}] failed to launch: ${error.message}`);
|
|
126
|
+
stop(1);
|
|
127
|
+
});
|
|
128
|
+
child.once('exit', (code, signal) => {
|
|
129
|
+
if (stopping) return;
|
|
130
|
+
const elapsed = Date.now() - startedAt.get(spec.name);
|
|
131
|
+
console.error(`\n[${spec.name}] exited (${signal || `code ${code}`}).`);
|
|
132
|
+
if (code && elapsed < FAST_EXIT_MS) {
|
|
133
|
+
const explanation = explainExit(spec, recent);
|
|
134
|
+
if (explanation) for (const line of explanation) console.error(line);
|
|
135
|
+
}
|
|
136
|
+
console.error('Stopping the other services.');
|
|
137
|
+
stop(code || 1);
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
register(specs.backend);
|
|
142
|
+
if (options.waitForBackend > 0) {
|
|
143
|
+
const ready = await waitForListening(options.backendPort, options.waitForBackend, () => stopping);
|
|
144
|
+
if (!ready && !stopping) {
|
|
145
|
+
console.warn(`[Ports Manager] Backend did not listen within ${options.waitForBackend}ms; starting frontend anyway.`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (!stopping) register(specs.frontend);
|
|
149
|
+
|
|
150
|
+
// Verify what actually happened instead of guessing from a static scan beforehand.
|
|
151
|
+
// Attached immediately so a failure here can never surface as an unhandled rejection.
|
|
152
|
+
const verify = (async () => {
|
|
153
|
+
const timeout = options.verifyTimeout ?? 45000;
|
|
154
|
+
let backendUp = await waitForListening(options.backendPort, timeout, () => stopping);
|
|
155
|
+
if (stopping) return;
|
|
156
|
+
if (!backendUp) {
|
|
157
|
+
console.warn(`\n[Ports Manager] Backend has not bound port ${options.backendPort} after ${Math.round(timeout / 1000)}s.`);
|
|
158
|
+
// A watcher (tsx watch, nodemon) survives a crash, so the process is still alive and
|
|
159
|
+
// the real cause is only visible in its output.
|
|
160
|
+
const explanation = explainExit(specs.backend, output.get(specs.backend.name) || []);
|
|
161
|
+
if (explanation) for (const line of explanation) console.warn(line);
|
|
162
|
+
else {
|
|
163
|
+
console.warn(' Check the backend output above for an error first. If it looks healthy, it may');
|
|
164
|
+
console.warn(` be ignoring PORT — set PORT in its .env or pass --backend-port ${options.backendPort}.`);
|
|
165
|
+
}
|
|
166
|
+
// A slow dependency (a database handshake, say) can still come good.
|
|
167
|
+
backendUp = await waitForListening(options.backendPort, timeout, () => stopping);
|
|
168
|
+
if (stopping) return;
|
|
169
|
+
if (backendUp) console.log(`[Ports Manager] Backend came up on port ${options.backendPort}.`);
|
|
170
|
+
}
|
|
171
|
+
const frontendUp = options.frontendPort
|
|
172
|
+
? await waitForListening(options.frontendPort, timeout, () => stopping)
|
|
173
|
+
: true;
|
|
174
|
+
if (!stopping && options.onReady) options.onReady({ backendUp, frontendUp });
|
|
175
|
+
})().catch(() => {});
|
|
176
|
+
|
|
177
|
+
const onSignal = () => stop(0);
|
|
178
|
+
process.once('SIGINT', onSignal);
|
|
179
|
+
process.once('SIGTERM', onSignal);
|
|
180
|
+
const code = await done;
|
|
181
|
+
await verify.catch(() => {});
|
|
182
|
+
process.removeListener('SIGINT', onSignal);
|
|
183
|
+
process.removeListener('SIGTERM', onSignal);
|
|
184
|
+
return code;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = { explainExit, killTree, pipeLines, runProcesses, spawnSpec, waitForListening };
|