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/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
+ };