@luanpdd/kit-mcp 1.1.0 → 1.2.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.
@@ -0,0 +1,108 @@
1
+ // src/ui/auto-spawn.js
2
+ // Spawn the sidecar in a detached subprocess and wait until it's healthy.
3
+ // Used by MCP tool handlers when the caller passes `autoSpawn: true` and no
4
+ // sidecar lockfile is present for the project.
5
+ //
6
+ // Discipline: stderr/file logging only. Audit gate enforced.
7
+
8
+ import { spawn } from 'node:child_process';
9
+ import http from 'node:http';
10
+ import path from 'node:path';
11
+ import process from 'node:process';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ import { readLock } from './lockfile.js';
15
+ import { openBrowser } from './browser.js';
16
+
17
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
18
+ // src/ui → src → repo root → bin/ui.js
19
+ const UI_BIN = path.resolve(HERE, '..', '..', 'bin', 'ui.js');
20
+
21
+ const POLL_INTERVAL_MS = 100;
22
+ const POLL_TIMEOUT_MS = 5000;
23
+
24
+ // healthzOk returns true if GET /healthz on this port responds 200 within 1s.
25
+ function healthzOk(port) {
26
+ return new Promise((resolve) => {
27
+ const req = http.request({
28
+ method: 'GET',
29
+ host: '127.0.0.1',
30
+ port,
31
+ path: '/healthz',
32
+ agent: false,
33
+ headers: { host: `127.0.0.1:${port}`, connection: 'close' },
34
+ }, (res) => {
35
+ res.resume();
36
+ res.on('end', () => resolve(res.statusCode === 200));
37
+ });
38
+ req.on('error', () => resolve(false));
39
+ req.setTimeout(800, () => { try { req.destroy(); } catch { /* noop */ } resolve(false); });
40
+ req.end();
41
+ });
42
+ }
43
+
44
+ async function waitForHealth(projectRoot, deadline) {
45
+ // Poll for lockfile + healthz until deadline.
46
+ while (Date.now() < deadline) {
47
+ const lock = readLock(projectRoot);
48
+ if (lock?.port) {
49
+ // eslint-disable-next-line no-await-in-loop
50
+ if (await healthzOk(lock.port)) return lock;
51
+ }
52
+ // eslint-disable-next-line no-await-in-loop
53
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
54
+ }
55
+ return null;
56
+ }
57
+
58
+ // ensureSidecar({projectRoot, openBrowser?}): if a sidecar is already running
59
+ // for this projectRoot, returns immediately with its lock metadata. Otherwise
60
+ // spawns bin/ui.js detached and waits for it to come online, then optionally
61
+ // opens the browser. Resolves to:
62
+ // { ready: true, port, spawned: bool, opened: bool } on success
63
+ // { ready: false, reason } on timeout/spawn-fail
64
+ export async function ensureSidecar({ projectRoot, openBrowserOnSpawn = true } = {}) {
65
+ if (!projectRoot) return { ready: false, reason: 'no_project_root' };
66
+
67
+ // Already running?
68
+ const existing = readLock(projectRoot);
69
+ if (existing?.port) {
70
+ if (await healthzOk(existing.port)) {
71
+ return { ready: true, port: existing.port, spawned: false, opened: false };
72
+ }
73
+ // Stale lockfile — let the spawn step reclaim it.
74
+ }
75
+
76
+ // Spawn detached. Inherits stderr only — stdout is closed so a buggy child
77
+ // can never poison parent's stdout (e.g. when the parent is the MCP server
78
+ // running on stdio).
79
+ let child;
80
+ try {
81
+ child = spawn(process.execPath, [UI_BIN, '--project-root', projectRoot], {
82
+ detached: true,
83
+ stdio: ['ignore', 'ignore', 'inherit'],
84
+ windowsHide: true,
85
+ });
86
+ child.unref();
87
+ } catch (err) {
88
+ return { ready: false, reason: `spawn_failed: ${err.message}` };
89
+ }
90
+
91
+ // Wait for it to come online.
92
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
93
+ const lock = await waitForHealth(projectRoot, deadline);
94
+ if (!lock) {
95
+ return { ready: false, reason: 'healthz_timeout' };
96
+ }
97
+
98
+ let opened = false;
99
+ if (openBrowserOnSpawn) {
100
+ const url = `http://127.0.0.1:${lock.port}/`;
101
+ const r = await openBrowser(url);
102
+ opened = r.opened === true;
103
+ }
104
+
105
+ return { ready: true, port: lock.port, spawned: true, opened };
106
+ }
107
+
108
+ export const __test = { healthzOk, UI_BIN, POLL_INTERVAL_MS, POLL_TIMEOUT_MS };
@@ -0,0 +1,78 @@
1
+ // src/ui/browser.js
2
+ // Cross-platform browser opener. Wraps `open@11` with detection for environments
3
+ // where launching a browser silently fails (CI, headless SSH, WSL, sandboxed
4
+ // macOS Terminal). In those environments we DON'T attempt to launch — we just
5
+ // print the URL to stderr so the user can copy it.
6
+ //
7
+ // Discipline: nothing on stdout. Audit gate enforced by Phase 11 CI.
8
+
9
+ import process from 'node:process';
10
+
11
+ let openModule = null;
12
+
13
+ async function loadOpen() {
14
+ if (openModule) return openModule;
15
+ try {
16
+ const mod = await import('open');
17
+ openModule = mod.default || mod;
18
+ return openModule;
19
+ } catch (err) {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ // isHeadless heuristics — designed to err on the side of NOT launching.
25
+ // Returns a reason string when headless, or null when a browser launch is plausible.
26
+ export function detectHeadless(env = process.env, plat = process.platform) {
27
+ if (env.CI && env.CI !== 'false') return 'CI=' + env.CI;
28
+ if (env.KIT_MCP_NO_OPEN === '1' || env.KIT_MCP_NO_OPEN === 'true') return 'KIT_MCP_NO_OPEN';
29
+ if (env.TERM === 'dumb') return 'TERM=dumb';
30
+ // Linux without a display server is headless. WSL is special: it forwards
31
+ // to the Windows host browser via wslview, so we let `open` try.
32
+ if (plat === 'linux' && !env.DISPLAY && !env.WAYLAND_DISPLAY) {
33
+ if (env.WSL_DISTRO_NAME || env.WSLENV) return null; // WSL — let `open` try (it'll use wslview)
34
+ return 'no_display';
35
+ }
36
+ // SSH session without local display — no good way to open in user's browser.
37
+ if (env.SSH_CONNECTION && plat !== 'win32' && !env.DISPLAY && !env.WAYLAND_DISPLAY) {
38
+ return 'ssh_no_display';
39
+ }
40
+ return null;
41
+ }
42
+
43
+ // openBrowser(url, opts):
44
+ // { opened: true, via: 'open' } on success
45
+ // { opened: false, reason: 'headless:<why>', url } when headless detected
46
+ // { opened: false, reason: 'no_module' } if `open` package missing
47
+ // { opened: false, reason: 'launch_failed:<msg>' } if open() throws
48
+ //
49
+ // Always calls process.stderr.write with the URL so the user can copy it manually.
50
+ export async function openBrowser(url, { force = false } = {}) {
51
+ process.stderr.write(`[kit-mcp ui] ${url}\n`);
52
+
53
+ if (!force) {
54
+ const headless = detectHeadless();
55
+ if (headless) {
56
+ process.stderr.write(`[kit-mcp ui] not opening browser (${headless}) — open the URL above manually\n`);
57
+ return { opened: false, reason: `headless:${headless}`, url };
58
+ }
59
+ }
60
+
61
+ const open = await loadOpen();
62
+ if (!open) {
63
+ process.stderr.write('[kit-mcp ui] `open` package not available — open the URL above manually\n');
64
+ return { opened: false, reason: 'no_module' };
65
+ }
66
+
67
+ try {
68
+ // open() returns a child process; we don't await its exit (it's the browser).
69
+ // We just need to know that the spawn succeeded.
70
+ await open(url);
71
+ return { opened: true, via: 'open' };
72
+ } catch (err) {
73
+ process.stderr.write(`[kit-mcp ui] browser launch failed: ${err.message} — open the URL above manually\n`);
74
+ return { opened: false, reason: `launch_failed:${err.message}` };
75
+ }
76
+ }
77
+
78
+ export const __test = { loadOpen };
@@ -0,0 +1,115 @@
1
+ // src/ui/client.js
2
+ // Fire-and-forget publisher. Reads the lockfile to discover the running sidecar's
3
+ // port, then POSTs an event to /publish. If the sidecar isn't running (no lockfile,
4
+ // ECONNREFUSED, healthz mismatch), publish() resolves silently — publishers MUST NOT
5
+ // fail just because the optional UI isn't up.
6
+
7
+ import http from 'node:http';
8
+ import { readLock } from './lockfile.js';
9
+ import { validateEvent } from './events.js';
10
+
11
+ // Cache the resolved port across calls in a single process.
12
+ const portCache = new Map(); // projectRoot -> port (or 0 = no sidecar)
13
+ const PORT_CACHE_TTL_MS = 5_000;
14
+ const cacheTimestamps = new Map();
15
+
16
+ function readCachedPort(projectRoot) {
17
+ const ts = cacheTimestamps.get(projectRoot);
18
+ if (!ts || Date.now() - ts > PORT_CACHE_TTL_MS) return undefined;
19
+ return portCache.get(projectRoot);
20
+ }
21
+
22
+ function writeCachedPort(projectRoot, port) {
23
+ portCache.set(projectRoot, port);
24
+ cacheTimestamps.set(projectRoot, Date.now());
25
+ }
26
+
27
+ export function clearPortCache() {
28
+ portCache.clear();
29
+ cacheTimestamps.clear();
30
+ }
31
+
32
+ function resolvePort(projectRoot) {
33
+ const cached = readCachedPort(projectRoot);
34
+ if (cached !== undefined) return cached;
35
+ const lock = readLock(projectRoot);
36
+ const port = lock?.port ?? 0;
37
+ writeCachedPort(projectRoot, port);
38
+ return port;
39
+ }
40
+
41
+ // publish(event, { projectRoot, timeoutMs }): always resolves. Returns
42
+ // { sent: true, status } on 2xx
43
+ // { sent: false, reason } in every other case (no sidecar, validation, network)
44
+ export async function publish(event, { projectRoot, timeoutMs = 1500 } = {}) {
45
+ if (!projectRoot) return { sent: false, reason: 'no_project_root' };
46
+
47
+ const validationErr = validateEvent(event);
48
+ if (validationErr) return { sent: false, reason: `invalid_event: ${validationErr.message}` };
49
+
50
+ const port = resolvePort(projectRoot);
51
+ if (!port) return { sent: false, reason: 'no_sidecar' };
52
+
53
+ const body = JSON.stringify(event);
54
+
55
+ return new Promise((resolve) => {
56
+ const req = http.request({
57
+ method: 'POST',
58
+ host: '127.0.0.1',
59
+ port,
60
+ path: '/publish',
61
+ agent: false,
62
+ headers: {
63
+ 'host': `127.0.0.1:${port}`,
64
+ 'content-type': 'application/json',
65
+ 'content-length': Buffer.byteLength(body, 'utf8'),
66
+ 'origin': `http://127.0.0.1:${port}`,
67
+ 'connection': 'close',
68
+ },
69
+ }, (res) => {
70
+ // Drain — we don't actually care about the body, just the status.
71
+ res.resume();
72
+ res.on('end', () => {
73
+ if (res.statusCode >= 200 && res.statusCode < 300) {
74
+ resolve({ sent: true, status: res.statusCode });
75
+ } else {
76
+ // Stale lockfile? Drop the cache so the next call re-reads.
77
+ if (res.statusCode === 403 || res.statusCode === 404) {
78
+ portCache.delete(projectRoot);
79
+ cacheTimestamps.delete(projectRoot);
80
+ }
81
+ resolve({ sent: false, reason: `http_${res.statusCode}` });
82
+ }
83
+ });
84
+ });
85
+
86
+ req.on('error', (err) => {
87
+ // Most common: ECONNREFUSED (lockfile points at a dead port).
88
+ if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET') {
89
+ portCache.delete(projectRoot);
90
+ cacheTimestamps.delete(projectRoot);
91
+ }
92
+ resolve({ sent: false, reason: `error: ${err.code || err.message}` });
93
+ });
94
+
95
+ req.setTimeout(timeoutMs, () => {
96
+ try { req.destroy(); } catch { /* noop */ }
97
+ resolve({ sent: false, reason: 'timeout' });
98
+ });
99
+
100
+ req.write(body);
101
+ req.end();
102
+ });
103
+ }
104
+
105
+ // publishMany emits a sequence of events one after another. Used by callers
106
+ // that want best-effort guaranteed ordering — http.request is async, so
107
+ // firing in parallel doesn't preserve order at the server.
108
+ export async function publishMany(events, opts) {
109
+ const results = [];
110
+ for (const evt of events) {
111
+ // eslint-disable-next-line no-await-in-loop
112
+ results.push(await publish(evt, opts));
113
+ }
114
+ return results;
115
+ }
@@ -0,0 +1,65 @@
1
+ // src/ui/events.js
2
+ // Schema and helpers for sidecar event payloads.
3
+ // Pure module: no I/O, no module-level state. Safe to import from any context.
4
+
5
+ import { randomBytes } from 'node:crypto';
6
+
7
+ export const EVENT_TYPES = Object.freeze([
8
+ 'run.start',
9
+ 'run.end',
10
+ 'tool_invocation',
11
+ 'progress',
12
+ 'milestone',
13
+ 'error',
14
+ 'shutdown',
15
+ ]);
16
+
17
+ export const EVENT_TYPE_SET = new Set(EVENT_TYPES);
18
+
19
+ const MAX_PAYLOAD_BYTES = 64 * 1024;
20
+
21
+ export function newRunId() {
22
+ return randomBytes(8).toString('hex');
23
+ }
24
+
25
+ export function makeEvent({ type, runId, payload, ts }) {
26
+ if (!EVENT_TYPE_SET.has(type)) {
27
+ throw new TypeError(`Unknown event type: ${type}. Valid: ${EVENT_TYPES.join(', ')}`);
28
+ }
29
+ return {
30
+ type,
31
+ ts: typeof ts === 'number' ? ts : Date.now(),
32
+ runId: runId ?? null,
33
+ payload: payload ?? null,
34
+ };
35
+ }
36
+
37
+ // validateEvent returns null on success, or an Error explaining the rejection.
38
+ // Used by the server's POST /publish endpoint. Never throws.
39
+ export function validateEvent(value) {
40
+ if (value === null || typeof value !== 'object') {
41
+ return new Error('event must be an object');
42
+ }
43
+ if (!EVENT_TYPE_SET.has(value.type)) {
44
+ return new Error(`event.type must be one of ${EVENT_TYPES.join(', ')}`);
45
+ }
46
+ if (typeof value.ts !== 'number' || !Number.isFinite(value.ts)) {
47
+ return new Error('event.ts must be a finite number (epoch ms)');
48
+ }
49
+ if (value.runId !== null && value.runId !== undefined && typeof value.runId !== 'string') {
50
+ return new Error('event.runId must be string or null');
51
+ }
52
+ // payload may be anything serializable; cap raw size
53
+ let serialized;
54
+ try {
55
+ serialized = JSON.stringify(value);
56
+ } catch (err) {
57
+ return new Error(`event not serializable: ${err.message}`);
58
+ }
59
+ if (Buffer.byteLength(serialized, 'utf8') > MAX_PAYLOAD_BYTES) {
60
+ return new Error(`event exceeds ${MAX_PAYLOAD_BYTES} bytes (got ${Buffer.byteLength(serialized, 'utf8')})`);
61
+ }
62
+ return null;
63
+ }
64
+
65
+ export const __test = { MAX_PAYLOAD_BYTES };
@@ -0,0 +1,147 @@
1
+ // src/ui/lockfile.js
2
+ // Single-instance lockfile per projectRoot, located in os.tmpdir().
3
+ //
4
+ // Atomic create via fs.openSync(path, 'wx') (O_EXCL semantics — fails if file exists).
5
+ // Stale detection in two layers:
6
+ // 1. process.kill(pid, 0) — ESRCH/EPERM means the holder is gone
7
+ // 2. optional HTTP healthz probe (injected by caller; keeps this module pure of net)
8
+
9
+ import { createHash } from 'node:crypto';
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import process from 'node:process';
14
+
15
+ export const LOCK_VERSION = 1;
16
+
17
+ export function lockPathFor(projectRoot) {
18
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
19
+ throw new TypeError('projectRoot must be a non-empty string');
20
+ }
21
+ const hash = createHash('sha1').update(projectRoot).digest('hex').slice(0, 16);
22
+ return path.join(os.tmpdir(), `kit-mcp-ui-${hash}.lock`);
23
+ }
24
+
25
+ // readLock returns parsed lockfile content, or null if the file doesn't exist
26
+ // or is unreadable/unparseable. Never throws.
27
+ export function readLock(projectRoot) {
28
+ const file = lockPathFor(projectRoot);
29
+ let raw;
30
+ try {
31
+ raw = fs.readFileSync(file, 'utf8');
32
+ } catch (err) {
33
+ if (err.code === 'ENOENT') return null;
34
+ return null;
35
+ }
36
+ try {
37
+ const parsed = JSON.parse(raw);
38
+ if (parsed && typeof parsed === 'object' && typeof parsed.pid === 'number') {
39
+ return { ...parsed, _path: file };
40
+ }
41
+ return null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ // acquireLock attempts to create the lockfile atomically. On success returns
48
+ // the lock metadata. On EEXIST, throws an Error tagged with .code = 'ELOCKED'
49
+ // — the caller is expected to call probeStale + maybe retry.
50
+ export function acquireLock({ projectRoot, port, version, startedAt }) {
51
+ const file = lockPathFor(projectRoot);
52
+ const meta = {
53
+ pid: process.pid,
54
+ port,
55
+ version: version ?? null,
56
+ startedAt: startedAt ?? Date.now(),
57
+ lockSchema: LOCK_VERSION,
58
+ };
59
+ let fd;
60
+ try {
61
+ fd = fs.openSync(file, 'wx');
62
+ } catch (err) {
63
+ if (err.code === 'EEXIST') {
64
+ const lockErr = new Error(`Lockfile already exists: ${file}`);
65
+ lockErr.code = 'ELOCKED';
66
+ lockErr.path = file;
67
+ throw lockErr;
68
+ }
69
+ throw err;
70
+ }
71
+ try {
72
+ fs.writeSync(fd, JSON.stringify(meta, null, 2));
73
+ } finally {
74
+ fs.closeSync(fd);
75
+ }
76
+ return { ...meta, _path: file };
77
+ }
78
+
79
+ export function releaseLock(projectRoot) {
80
+ const file = lockPathFor(projectRoot);
81
+ try {
82
+ fs.unlinkSync(file);
83
+ return true;
84
+ } catch (err) {
85
+ if (err.code === 'ENOENT') return false;
86
+ throw err;
87
+ }
88
+ }
89
+
90
+ // probeStale checks if the lockfile holder is still alive.
91
+ // Uses two strategies in order:
92
+ // 1. process.kill(pid, 0) — synchronous, no network. ESRCH = dead, EPERM = different user (rare on dev box, treat as alive to be safe).
93
+ // 2. healthzProbe(port) — optional async function injected by caller. Should return truthy if the holder responds OK.
94
+ //
95
+ // Returns:
96
+ // { stale: false, reason: 'pid_alive' } — process exists
97
+ // { stale: false, reason: 'healthz_ok' } — process exists AND healthz responded
98
+ // { stale: true, reason: 'pid_gone' } — pid is ESRCH
99
+ // { stale: true, reason: 'healthz_failed' } — pid alive but no healthz response (used when healthzProbe provided)
100
+ export async function probeStale(lock, { healthzProbe } = {}) {
101
+ if (!lock || typeof lock.pid !== 'number') {
102
+ return { stale: true, reason: 'invalid_lock' };
103
+ }
104
+ let pidAlive = false;
105
+ try {
106
+ process.kill(lock.pid, 0);
107
+ pidAlive = true;
108
+ } catch (err) {
109
+ if (err.code === 'ESRCH') {
110
+ return { stale: true, reason: 'pid_gone' };
111
+ }
112
+ // EPERM: pid exists but is owned by another user. Treat as alive (safe default).
113
+ pidAlive = true;
114
+ }
115
+ if (!healthzProbe) {
116
+ return { stale: false, reason: 'pid_alive' };
117
+ }
118
+ try {
119
+ const ok = await healthzProbe(lock.port);
120
+ if (ok) return { stale: false, reason: 'healthz_ok' };
121
+ return { stale: true, reason: 'healthz_failed' };
122
+ } catch {
123
+ return { stale: true, reason: 'healthz_failed' };
124
+ }
125
+ }
126
+
127
+ // Convenience: take + retry once if stale lock is detected.
128
+ export async function acquireLockOrReclaim(opts) {
129
+ try {
130
+ return acquireLock(opts);
131
+ } catch (err) {
132
+ if (err.code !== 'ELOCKED') throw err;
133
+ const existing = readLock(opts.projectRoot);
134
+ const probe = await probeStale(existing, { healthzProbe: opts.healthzProbe });
135
+ if (probe.stale) {
136
+ releaseLock(opts.projectRoot);
137
+ return acquireLock(opts);
138
+ }
139
+ const liveErr = new Error(
140
+ `Sidecar already running for this project (pid=${existing?.pid}, port=${existing?.port}). ` +
141
+ `Use \`kit ui status\` to inspect or \`kit ui stop\` to shut it down.`,
142
+ );
143
+ liveErr.code = 'ELIVE';
144
+ liveErr.lock = existing;
145
+ throw liveErr;
146
+ }
147
+ }
package/src/ui/port.js ADDED
@@ -0,0 +1,67 @@
1
+ // src/ui/port.js
2
+ // Find a free TCP port within a bounded range.
3
+ // Pure utility: no module-level state, no logging.
4
+
5
+ import net from 'node:net';
6
+
7
+ export const DEFAULT_PORT_RANGE = Object.freeze({ start: 7100, end: 7199 });
8
+
9
+ // Probes a single port: resolves to true if free (could bind+close), false if taken.
10
+ function probePort(port, host) {
11
+ return new Promise((resolve) => {
12
+ const server = net.createServer();
13
+ let settled = false;
14
+ const finish = (free) => {
15
+ if (settled) return;
16
+ settled = true;
17
+ server.removeAllListeners();
18
+ try { server.close(); } catch { /* ignore */ }
19
+ resolve(free);
20
+ };
21
+ server.once('error', () => finish(false));
22
+ server.once('listening', () => finish(true));
23
+ try {
24
+ server.listen(port, host);
25
+ } catch {
26
+ finish(false);
27
+ }
28
+ });
29
+ }
30
+
31
+ // findFreePort scans [start..end] inclusive on host (default 127.0.0.1) and
32
+ // returns the first port where a transient bind succeeds. Returns null if none.
33
+ //
34
+ // Race note: between probe-close and the caller's bind, the port can be
35
+ // reclaimed by another process. The lockfile + healthz probe in upper layers
36
+ // covers this — port.js is best-effort discovery, not exclusive reservation.
37
+ export async function findFreePort({
38
+ start = DEFAULT_PORT_RANGE.start,
39
+ end = DEFAULT_PORT_RANGE.end,
40
+ host = '127.0.0.1',
41
+ } = {}) {
42
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start > end) {
43
+ throw new TypeError(`invalid port range: ${start}..${end}`);
44
+ }
45
+ for (let port = start; port <= end; port += 1) {
46
+ // eslint-disable-next-line no-await-in-loop
47
+ if (await probePort(port, host)) {
48
+ return port;
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+
54
+ // findFreePortOrThrow is the eager variant — surfaces an error message that
55
+ // includes the exhausted range, so callers don't have to format it.
56
+ export async function findFreePortOrThrow(opts = {}) {
57
+ const port = await findFreePort(opts);
58
+ if (port === null) {
59
+ const start = opts.start ?? DEFAULT_PORT_RANGE.start;
60
+ const end = opts.end ?? DEFAULT_PORT_RANGE.end;
61
+ throw new Error(
62
+ `No free TCP port in ${start}..${end} (host ${opts.host ?? '127.0.0.1'}). ` +
63
+ `Run \`kit ui status\` to inspect a running sidecar, or kill whatever is using these ports.`,
64
+ );
65
+ }
66
+ return port;
67
+ }