localpeek 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/GUIDE.md +128 -0
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/bin/localpeek.js +19 -0
- package/package.json +51 -0
- package/src/adapters/astro.js +27 -0
- package/src/adapters/generic.js +38 -0
- package/src/adapters/html.js +97 -0
- package/src/adapters/next.js +28 -0
- package/src/adapters/vite.js +30 -0
- package/src/cli.js +310 -0
- package/src/detect.js +193 -0
- package/src/errors.js +81 -0
- package/src/logger.js +208 -0
- package/src/network.js +113 -0
- package/src/port.js +65 -0
- package/src/qr.js +21 -0
- package/src/server.js +130 -0
package/src/logger.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// CAVEMAN: No color/UI dependency. Hand-rolled ANSI + box drawing.
|
|
4
|
+
// Falls back to plain text when the terminal can't handle it
|
|
5
|
+
// (non-TTY, NO_COLOR, dumb terminals, piped output).
|
|
6
|
+
|
|
7
|
+
const supportsColor =
|
|
8
|
+
process.stdout.isTTY &&
|
|
9
|
+
process.env.TERM !== 'dumb' &&
|
|
10
|
+
!('NO_COLOR' in process.env);
|
|
11
|
+
|
|
12
|
+
const isTTY = Boolean(process.stdout.isTTY);
|
|
13
|
+
|
|
14
|
+
function wrap(code) {
|
|
15
|
+
return (str) => (supportsColor ? `\u001b[${code}m${str}\u001b[0m` : String(str));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const colors = {
|
|
19
|
+
reset: wrap('0'),
|
|
20
|
+
bold: wrap('1'),
|
|
21
|
+
dim: wrap('2'),
|
|
22
|
+
italic: wrap('3'),
|
|
23
|
+
green: wrap('32'),
|
|
24
|
+
yellow: wrap('33'),
|
|
25
|
+
red: wrap('31'),
|
|
26
|
+
cyan: wrap('36'),
|
|
27
|
+
magenta: wrap('35'),
|
|
28
|
+
blue: wrap('34'),
|
|
29
|
+
gray: wrap('90'),
|
|
30
|
+
white: wrap('97'),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Compose helpers, e.g. colors.boldCyan('x')
|
|
34
|
+
colors.boldCyan = (s) => colors.bold(colors.cyan(s));
|
|
35
|
+
colors.boldWhite = (s) => colors.bold(colors.white(s));
|
|
36
|
+
|
|
37
|
+
const symbols = {
|
|
38
|
+
check: supportsColor ? '✓' : 'v',
|
|
39
|
+
cross: supportsColor ? '✗' : 'x',
|
|
40
|
+
warn: '!',
|
|
41
|
+
arrow: supportsColor ? '→' : '->',
|
|
42
|
+
bullet: supportsColor ? '›' : '-',
|
|
43
|
+
dot: '·',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Visible-length helper: strips ANSI codes before measuring, so
|
|
47
|
+
// padding/box math isn't thrown off by color codes.
|
|
48
|
+
function stripAnsi(str) {
|
|
49
|
+
// eslint-disable-next-line no-control-regex
|
|
50
|
+
return str.replace(/\u001b\[[0-9;]*m/g, '');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function visibleLength(str) {
|
|
54
|
+
return stripAnsi(str).length;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function padEndVisible(str, width) {
|
|
58
|
+
const len = visibleLength(str);
|
|
59
|
+
return len >= width ? str : str + ' '.repeat(width - len);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------
|
|
63
|
+
// Basic lines
|
|
64
|
+
// ---------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
function blank() {
|
|
67
|
+
console.log('');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function raw(msg) {
|
|
71
|
+
console.log(msg);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function info(msg) {
|
|
75
|
+
console.log(` ${msg}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function success(msg) {
|
|
79
|
+
console.log(` ${colors.green(symbols.check)} ${msg}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function warn(msg) {
|
|
83
|
+
console.log(` ${colors.yellow(symbols.warn)} ${msg}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function error(msg) {
|
|
87
|
+
console.error(` ${colors.red(symbols.cross)} ${msg}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function line(label, value) {
|
|
91
|
+
const padded = label.padEnd(11, ' ');
|
|
92
|
+
console.log(` ${colors.dim(padded)} ${value}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function step(n, msg) {
|
|
96
|
+
console.log(` ${colors.dim(`${n}.`)} ${msg}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------
|
|
100
|
+
// Banner — small, premium, not full ASCII-art bloat
|
|
101
|
+
// ---------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
function banner(version, author) {
|
|
104
|
+
blank();
|
|
105
|
+
raw(` ${colors.boldCyan('▲ LocalPeek')} ${colors.dim(`v${version}`)}`);
|
|
106
|
+
if (author) raw(` ${colors.dim(`by ${author}`)}`);
|
|
107
|
+
blank();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------
|
|
111
|
+
// Boxed panel (rounded corners), auto-sized to content
|
|
112
|
+
// ---------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Draws a rounded box around `lines` (array of pre-formatted strings,
|
|
116
|
+
* color codes allowed). Optional `title` renders inline on the top
|
|
117
|
+
* border, like a labeled panel.
|
|
118
|
+
*/
|
|
119
|
+
function box(lines, { title: boxTitle, minWidth = 0, padding = 1 } = {}) {
|
|
120
|
+
const contentWidth = Math.max(
|
|
121
|
+
minWidth,
|
|
122
|
+
...lines.map(visibleLength),
|
|
123
|
+
boxTitle ? visibleLength(boxTitle) + 2 : 0
|
|
124
|
+
);
|
|
125
|
+
const innerWidth = contentWidth + padding * 2;
|
|
126
|
+
|
|
127
|
+
const top = boxTitle
|
|
128
|
+
? `╭─ ${colors.bold(boxTitle)} ${'─'.repeat(Math.max(0, innerWidth - visibleLength(boxTitle) - 3))}╮`
|
|
129
|
+
: `╭${'─'.repeat(innerWidth)}╮`;
|
|
130
|
+
const bottom = `╰${'─'.repeat(innerWidth)}╯`;
|
|
131
|
+
|
|
132
|
+
raw(` ${top}`);
|
|
133
|
+
for (const l of lines) {
|
|
134
|
+
const padded = padEndVisible(l, contentWidth);
|
|
135
|
+
raw(` │${' '.repeat(padding)}${padded}${' '.repeat(padding)}│`);
|
|
136
|
+
}
|
|
137
|
+
raw(` ${bottom}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------
|
|
141
|
+
// Spinner — for "detecting project", "starting dev server", etc.
|
|
142
|
+
// No-op animation when not a TTY (just prints the label once).
|
|
143
|
+
// ---------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
146
|
+
|
|
147
|
+
function createSpinner(initialText) {
|
|
148
|
+
let text = initialText;
|
|
149
|
+
let frame = 0;
|
|
150
|
+
let timer = null;
|
|
151
|
+
let active = false;
|
|
152
|
+
|
|
153
|
+
function render() {
|
|
154
|
+
const f = colors.cyan(SPINNER_FRAMES[frame % SPINNER_FRAMES.length]);
|
|
155
|
+
process.stdout.write(`\r ${f} ${text}${' '.repeat(4)}`);
|
|
156
|
+
frame += 1;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
start() {
|
|
161
|
+
if (!isTTY) {
|
|
162
|
+
raw(` ${symbols.dot} ${text}`);
|
|
163
|
+
return this;
|
|
164
|
+
}
|
|
165
|
+
active = true;
|
|
166
|
+
render();
|
|
167
|
+
timer = setInterval(render, 80);
|
|
168
|
+
return this;
|
|
169
|
+
},
|
|
170
|
+
update(newText) {
|
|
171
|
+
text = newText;
|
|
172
|
+
if (!isTTY) {
|
|
173
|
+
raw(` ${symbols.dot} ${text}`);
|
|
174
|
+
}
|
|
175
|
+
return this;
|
|
176
|
+
},
|
|
177
|
+
stop(finalText, kind = 'success') {
|
|
178
|
+
if (active) {
|
|
179
|
+
clearInterval(timer);
|
|
180
|
+
active = false;
|
|
181
|
+
// Clear the spinner line.
|
|
182
|
+
process.stdout.write('\r' + ' '.repeat(text.length + 8) + '\r');
|
|
183
|
+
}
|
|
184
|
+
if (finalText) {
|
|
185
|
+
if (kind === 'success') success(finalText);
|
|
186
|
+
else if (kind === 'warn') warn(finalText);
|
|
187
|
+
else info(finalText);
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = {
|
|
194
|
+
colors,
|
|
195
|
+
symbols,
|
|
196
|
+
blank,
|
|
197
|
+
raw,
|
|
198
|
+
info,
|
|
199
|
+
success,
|
|
200
|
+
warn,
|
|
201
|
+
error,
|
|
202
|
+
line,
|
|
203
|
+
step,
|
|
204
|
+
banner,
|
|
205
|
+
box,
|
|
206
|
+
createSpinner,
|
|
207
|
+
visibleLength,
|
|
208
|
+
};
|
package/src/network.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const { NoLanInterfaceError } = require('./errors.js');
|
|
5
|
+
|
|
6
|
+
// CAVEMAN: Do not assume the first network adapter is Wi-Fi.
|
|
7
|
+
// os.networkInterfaces() order is not guaranteed to mean anything.
|
|
8
|
+
// We score candidates instead of trusting position.
|
|
9
|
+
|
|
10
|
+
// Interface name patterns that usually mean "not a real LAN link
|
|
11
|
+
// a phone could reach" — virtual machines, containers, tunnels.
|
|
12
|
+
const VIRTUAL_NAME_PATTERNS = [
|
|
13
|
+
/^docker/i,
|
|
14
|
+
/^br-/i,
|
|
15
|
+
/^veth/i,
|
|
16
|
+
/^vmnet/i,
|
|
17
|
+
/^vboxnet/i,
|
|
18
|
+
/virtualbox/i,
|
|
19
|
+
/^vEthernet/i,
|
|
20
|
+
/hyper-?v/i,
|
|
21
|
+
/^utun/i, // often VPN tunnels on macOS
|
|
22
|
+
/^tun\d*/i,
|
|
23
|
+
/^tap\d*/i,
|
|
24
|
+
/^wsl/i,
|
|
25
|
+
/loopback/i,
|
|
26
|
+
/^lo$/i,
|
|
27
|
+
/^anpi/i, // some macOS internal
|
|
28
|
+
/^awdl/i, // Apple Wireless Direct Link, not a normal LAN
|
|
29
|
+
/^llw/i,
|
|
30
|
+
/^bridge/i,
|
|
31
|
+
/^zt/i, // ZeroTier virtual adapter
|
|
32
|
+
/^tailscale/i,
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// Names that strongly suggest a real, physical, LAN-facing link.
|
|
36
|
+
const PREFERRED_NAME_PATTERNS = [
|
|
37
|
+
/^wi-?fi/i,
|
|
38
|
+
/^wlan/i,
|
|
39
|
+
/^en0$/i, // common macOS Wi-Fi name
|
|
40
|
+
/^eth\d*/i,
|
|
41
|
+
/^ethernet/i,
|
|
42
|
+
/^en\d+/i,
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
function looksVirtual(name) {
|
|
46
|
+
return VIRTUAL_NAME_PATTERNS.some((re) => re.test(name));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function looksPreferred(name) {
|
|
50
|
+
return PREFERRED_NAME_PATTERNS.some((re) => re.test(name));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Returns a list of candidate LAN IPv4 addresses, best guess first.
|
|
55
|
+
* Each candidate: { address, iface, score }
|
|
56
|
+
*/
|
|
57
|
+
function getLanCandidates() {
|
|
58
|
+
const interfaces = os.networkInterfaces();
|
|
59
|
+
const candidates = [];
|
|
60
|
+
|
|
61
|
+
for (const [name, addrs] of Object.entries(interfaces)) {
|
|
62
|
+
if (!addrs) continue;
|
|
63
|
+
for (const addr of addrs) {
|
|
64
|
+
if (addr.family !== 'IPv4') continue;
|
|
65
|
+
if (addr.internal) continue; // skips 127.0.0.1
|
|
66
|
+
if (addr.address.startsWith('127.')) continue;
|
|
67
|
+
|
|
68
|
+
let score = 0;
|
|
69
|
+
if (looksVirtual(name)) score -= 10;
|
|
70
|
+
if (looksPreferred(name)) score += 5;
|
|
71
|
+
|
|
72
|
+
// Private LAN ranges are what we actually want.
|
|
73
|
+
const isPrivateA = addr.address.startsWith('10.');
|
|
74
|
+
const isPrivateB = /^172\.(1[6-9]|2\d|3[0-1])\./.test(addr.address);
|
|
75
|
+
const isPrivateC = addr.address.startsWith('192.168.');
|
|
76
|
+
const isLinkLocal = addr.address.startsWith('169.254.');
|
|
77
|
+
|
|
78
|
+
if (isLinkLocal) {
|
|
79
|
+
// 169.254.x.x means no DHCP lease — usually not reachable
|
|
80
|
+
// from another device in a useful way.
|
|
81
|
+
score -= 8;
|
|
82
|
+
} else if (isPrivateA || isPrivateB || isPrivateC) {
|
|
83
|
+
score += 3;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
candidates.push({ address: addr.address, iface: name, score });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
91
|
+
return candidates;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Picks the best LAN address, or throws NoLanInterfaceError.
|
|
96
|
+
* If `preferredIface` is given (via --iface flag), it wins when present.
|
|
97
|
+
*/
|
|
98
|
+
function getBestLanAddress(preferredIface) {
|
|
99
|
+
const candidates = getLanCandidates();
|
|
100
|
+
|
|
101
|
+
if (candidates.length === 0) {
|
|
102
|
+
throw new NoLanInterfaceError();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (preferredIface) {
|
|
106
|
+
const match = candidates.find((c) => c.iface === preferredIface);
|
|
107
|
+
if (match) return match;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return candidates[0];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { getLanCandidates, getBestLanAddress };
|
package/src/port.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const net = require('net');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
|
|
6
|
+
// CAVEMAN: Do not kill random processes because a port is busy.
|
|
7
|
+
// We only ever *check* ports here, never terminate anything.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolves true if the port is free to bind on all interfaces.
|
|
11
|
+
*/
|
|
12
|
+
function isPortFree(port, host = '0.0.0.0') {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
const tester = net.createServer();
|
|
15
|
+
tester.once('error', () => resolve(false));
|
|
16
|
+
tester.once('listening', () => {
|
|
17
|
+
tester.close(() => resolve(true));
|
|
18
|
+
});
|
|
19
|
+
try {
|
|
20
|
+
tester.listen(port, host);
|
|
21
|
+
} catch {
|
|
22
|
+
resolve(false);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Finds the first free port starting at `start`, scanning upward.
|
|
29
|
+
* Gives up after `maxTries` attempts.
|
|
30
|
+
*/
|
|
31
|
+
async function findFreePort(start, maxTries = 20) {
|
|
32
|
+
let port = start;
|
|
33
|
+
for (let i = 0; i < maxTries; i++) {
|
|
34
|
+
// eslint-disable-next-line no-await-in-loop
|
|
35
|
+
if (await isPortFree(port)) return port;
|
|
36
|
+
port += 1;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Best-effort check for whether something already listening on
|
|
43
|
+
* `port` looks like it could be serving the current project.
|
|
44
|
+
* This is a heuristic only — we ask for `/` and look at headers,
|
|
45
|
+
* never anything that could be treated as "safe to reuse" blindly.
|
|
46
|
+
* Returns true/false. Never throws.
|
|
47
|
+
*/
|
|
48
|
+
function probeExistingServer(port, host = '127.0.0.1', timeoutMs = 800) {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
const req = http.get(
|
|
51
|
+
{ host, port, path: '/', timeout: timeoutMs },
|
|
52
|
+
(res) => {
|
|
53
|
+
res.resume();
|
|
54
|
+
resolve(true);
|
|
55
|
+
}
|
|
56
|
+
);
|
|
57
|
+
req.on('timeout', () => {
|
|
58
|
+
req.destroy();
|
|
59
|
+
resolve(false);
|
|
60
|
+
});
|
|
61
|
+
req.on('error', () => resolve(false));
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { isPortFree, findFreePort, probeExistingServer };
|
package/src/qr.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const qrcodeTerminal = require('qrcode-terminal');
|
|
4
|
+
|
|
5
|
+
// CAVEMAN: QR must contain the real LAN IP, never 0.0.0.0 or localhost.
|
|
6
|
+
// The caller (server.js/cli.js) is responsible for passing the right
|
|
7
|
+
// URL in — this module just renders whatever string it is given.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Renders a QR code to the terminal for `url` and resolves with the
|
|
11
|
+
* ASCII-art string (also printed as a side effect of qrcode-terminal).
|
|
12
|
+
*/
|
|
13
|
+
function printQr(url) {
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
qrcodeTerminal.generate(url, { small: true }, (qrString) => {
|
|
16
|
+
resolve(qrString);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { printQr };
|
package/src/server.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
const { startStaticServer } = require('./adapters/html.js');
|
|
5
|
+
const viteAdapter = require('./adapters/vite.js');
|
|
6
|
+
const nextAdapter = require('./adapters/next.js');
|
|
7
|
+
const astroAdapter = require('./adapters/astro.js');
|
|
8
|
+
const genericAdapter = require('./adapters/generic.js');
|
|
9
|
+
const { isPortFree } = require('./port.js');
|
|
10
|
+
const { ServerStartError } = require('./errors.js');
|
|
11
|
+
|
|
12
|
+
const ADAPTERS = {
|
|
13
|
+
vite: viteAdapter,
|
|
14
|
+
next: nextAdapter,
|
|
15
|
+
astro: astroAdapter,
|
|
16
|
+
generic: genericAdapter,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const DEFAULT_PORTS = {
|
|
20
|
+
html: 4000,
|
|
21
|
+
vite: viteAdapter.DEFAULT_PORT,
|
|
22
|
+
next: nextAdapter.DEFAULT_PORT,
|
|
23
|
+
astro: astroAdapter.DEFAULT_PORT,
|
|
24
|
+
generic: 3000,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function defaultPortFor(type) {
|
|
28
|
+
return DEFAULT_PORTS[type] || 3000;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Waits until something is listening on `port` (i.e. isPortFree
|
|
33
|
+
* returns false), polling every `intervalMs`, up to `timeoutMs`.
|
|
34
|
+
* Resolves true if it became busy (server is up), false on timeout.
|
|
35
|
+
*/
|
|
36
|
+
function waitUntilListening(port, timeoutMs = 30000, intervalMs = 300) {
|
|
37
|
+
const start = Date.now();
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
const check = async () => {
|
|
40
|
+
const free = await isPortFree(port);
|
|
41
|
+
if (!free) {
|
|
42
|
+
resolve(true);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (Date.now() - start > timeoutMs) {
|
|
46
|
+
resolve(false);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
setTimeout(check, intervalMs);
|
|
50
|
+
};
|
|
51
|
+
check();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// CAVEMAN: Node warns (DEP0190) when you pass an `args` array AND
|
|
56
|
+
// shell:true to spawn() — with a shell, args get concatenated into
|
|
57
|
+
// one string rather than passed safely separated, so an unescaped
|
|
58
|
+
// arg could break out into shell syntax. We build every argument
|
|
59
|
+
// ourselves (ports, hosts, flags) so real-world risk here is low,
|
|
60
|
+
// but we still quote defensively and pass ONE command string instead
|
|
61
|
+
// of a separate args array, which silences the warning and is the
|
|
62
|
+
// actually-correct way to use shell:true.
|
|
63
|
+
function quoteArg(arg) {
|
|
64
|
+
const str = String(arg);
|
|
65
|
+
// Bare word made only of safe characters — no quoting needed.
|
|
66
|
+
if (/^[A-Za-z0-9_\-./:@=]+$/.test(str)) return str;
|
|
67
|
+
|
|
68
|
+
if (process.platform === 'win32') {
|
|
69
|
+
// CAVEMAN: cmd.exe quoting. Wrap in double quotes, escape
|
|
70
|
+
// any literal double quotes inside.
|
|
71
|
+
return `"${str.replace(/"/g, '""')}"`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// POSIX shells: single-quote, escaping any embedded single quotes.
|
|
75
|
+
return `'${str.replace(/'/g, `'\\''`)}'`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildCommandString(command, args) {
|
|
79
|
+
return [command, ...args].map(quoteArg).join(' ');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Spawns the appropriate dev process for a framework project.
|
|
84
|
+
* Returns { child, stop } — stop() gracefully shuts the child down.
|
|
85
|
+
* For "html" projects, use startStaticServer instead (see cli.js).
|
|
86
|
+
*/
|
|
87
|
+
function spawnDevServer(project, port, host) {
|
|
88
|
+
const adapter = ADAPTERS[project.adapter] || ADAPTERS.generic;
|
|
89
|
+
const { command, args, env } = adapter.buildCommand({ project, port, host });
|
|
90
|
+
const commandString = buildCommandString(command, args);
|
|
91
|
+
|
|
92
|
+
let child;
|
|
93
|
+
try {
|
|
94
|
+
child = spawn(commandString, {
|
|
95
|
+
cwd: project.dir,
|
|
96
|
+
env,
|
|
97
|
+
// CAVEMAN: shell:true keeps this working cross-platform for
|
|
98
|
+
// npm/npx-style commands without hardcoding path resolution.
|
|
99
|
+
// Passing one pre-built, pre-quoted string (instead of a
|
|
100
|
+
// separate args array) is what avoids Node's DEP0190 warning.
|
|
101
|
+
shell: true,
|
|
102
|
+
stdio: 'inherit',
|
|
103
|
+
});
|
|
104
|
+
} catch (err) {
|
|
105
|
+
throw new ServerStartError(err.message);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function stop() {
|
|
109
|
+
if (!child || child.killed) return;
|
|
110
|
+
// CAVEMAN: Ctrl+C on Windows behaves differently than POSIX
|
|
111
|
+
// signals. A plain kill() sends SIGTERM-equivalent everywhere,
|
|
112
|
+
// which is good enough here since we spawned this process
|
|
113
|
+
// ourselves — we are not touching anyone else's process.
|
|
114
|
+
if (process.platform === 'win32') {
|
|
115
|
+
child.kill();
|
|
116
|
+
} else {
|
|
117
|
+
child.kill('SIGINT');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { child, stop };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
spawnDevServer,
|
|
126
|
+
startStaticServer,
|
|
127
|
+
waitUntilListening,
|
|
128
|
+
defaultPortFor,
|
|
129
|
+
buildCommandString,
|
|
130
|
+
};
|