frontmcp 1.2.1 → 1.4.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/README.md +38 -29
- package/package.json +4 -4
- package/src/commands/build/exec/bin-meta.d.ts +49 -0
- package/src/commands/build/exec/bin-meta.js +68 -0
- package/src/commands/build/exec/bin-meta.js.map +1 -0
- package/src/commands/build/exec/cli-runtime/generate-cli-entry.js +195 -3
- package/src/commands/build/exec/cli-runtime/generate-cli-entry.js.map +1 -1
- package/src/commands/build/exec/cli-runtime/plugin-emitter.d.ts +160 -0
- package/src/commands/build/exec/cli-runtime/plugin-emitter.js +512 -0
- package/src/commands/build/exec/cli-runtime/plugin-emitter.js.map +1 -0
- package/src/commands/build/exec/cli-runtime/schema-extractor.d.ts +13 -1
- package/src/commands/build/exec/cli-runtime/schema-extractor.js +29 -3
- package/src/commands/build/exec/cli-runtime/schema-extractor.js.map +1 -1
- package/src/commands/build/exec/cli-runtime/skill-md-compose.d.ts +25 -0
- package/src/commands/build/exec/cli-runtime/skill-md-compose.js +63 -0
- package/src/commands/build/exec/cli-runtime/skill-md-compose.js.map +1 -0
- package/src/commands/build/exec/index.js +26 -0
- package/src/commands/build/exec/index.js.map +1 -1
- package/src/commands/build/exec/runner-script.js +16 -4
- package/src/commands/build/exec/runner-script.js.map +1 -1
- package/src/commands/dev/bridge/child-supervisor.d.ts +48 -0
- package/src/commands/dev/bridge/child-supervisor.js +228 -0
- package/src/commands/dev/bridge/child-supervisor.js.map +1 -0
- package/src/commands/dev/bridge/errors.d.ts +23 -0
- package/src/commands/dev/bridge/errors.js +34 -0
- package/src/commands/dev/bridge/errors.js.map +1 -0
- package/src/commands/dev/bridge/index.d.ts +30 -0
- package/src/commands/dev/bridge/index.js +220 -0
- package/src/commands/dev/bridge/index.js.map +1 -0
- package/src/commands/dev/bridge/log.d.ts +29 -0
- package/src/commands/dev/bridge/log.js +82 -0
- package/src/commands/dev/bridge/log.js.map +1 -0
- package/src/commands/dev/bridge/state-machine.d.ts +56 -0
- package/src/commands/dev/bridge/state-machine.js +245 -0
- package/src/commands/dev/bridge/state-machine.js.map +1 -0
- package/src/commands/dev/bridge/stdio-framer.d.ts +47 -0
- package/src/commands/dev/bridge/stdio-framer.js +128 -0
- package/src/commands/dev/bridge/stdio-framer.js.map +1 -0
- package/src/commands/dev/bridge/upstream-client.d.ts +49 -0
- package/src/commands/dev/bridge/upstream-client.js +159 -0
- package/src/commands/dev/bridge/upstream-client.js.map +1 -0
- package/src/commands/dev/bridge/watcher.d.ts +30 -0
- package/src/commands/dev/bridge/watcher.js +87 -0
- package/src/commands/dev/bridge/watcher.js.map +1 -0
- package/src/commands/dev/dev.d.ts +34 -1
- package/src/commands/dev/dev.js +168 -14
- package/src/commands/dev/dev.js.map +1 -1
- package/src/commands/dev/inspector.d.ts +13 -1
- package/src/commands/dev/inspector.js +77 -3
- package/src/commands/dev/inspector.js.map +1 -1
- package/src/commands/dev/port.d.ts +23 -0
- package/src/commands/dev/port.js +87 -0
- package/src/commands/dev/port.js.map +1 -0
- package/src/commands/dev/register.d.ts +1 -1
- package/src/commands/dev/register.js +28 -4
- package/src/commands/dev/register.js.map +1 -1
- package/src/commands/dev/test.d.ts +26 -1
- package/src/commands/dev/test.js +181 -64
- package/src/commands/dev/test.js.map +1 -1
- package/src/commands/eject/mcp-client.d.ts +25 -0
- package/src/commands/eject/mcp-client.js +74 -0
- package/src/commands/eject/mcp-client.js.map +1 -0
- package/src/commands/eject/register.d.ts +9 -0
- package/src/commands/eject/register.js +56 -0
- package/src/commands/eject/register.js.map +1 -0
- package/src/commands/install/install-claude-plugin.d.ts +13 -0
- package/src/commands/install/install-claude-plugin.js +327 -0
- package/src/commands/install/install-claude-plugin.js.map +1 -0
- package/src/commands/install/register.d.ts +16 -0
- package/src/commands/install/register.js +70 -0
- package/src/commands/install/register.js.map +1 -0
- package/src/commands/scaffold/create.js +52 -8
- package/src/commands/scaffold/create.js.map +1 -1
- package/src/commands/skills/from-entry.d.ts +31 -0
- package/src/commands/skills/from-entry.js +68 -0
- package/src/commands/skills/from-entry.js.map +1 -0
- package/src/commands/skills/install.d.ts +12 -0
- package/src/commands/skills/install.js +173 -8
- package/src/commands/skills/install.js.map +1 -1
- package/src/commands/skills/register.js +7 -3
- package/src/commands/skills/register.js.map +1 -1
- package/src/config/frontmcp-config.loader.d.ts +28 -0
- package/src/config/frontmcp-config.loader.js +146 -67
- package/src/config/frontmcp-config.loader.js.map +1 -1
- package/src/config/frontmcp-config.resolve.d.ts +67 -0
- package/src/config/frontmcp-config.resolve.js +118 -0
- package/src/config/frontmcp-config.resolve.js.map +1 -0
- package/src/config/frontmcp-config.schema.d.ts +207 -0
- package/src/config/frontmcp-config.schema.js +217 -1
- package/src/config/frontmcp-config.schema.js.map +1 -1
- package/src/config/frontmcp-config.types.d.ts +133 -0
- package/src/config/frontmcp-config.types.js.map +1 -1
- package/src/config/index.d.ts +2 -1
- package/src/config/index.js +3 -1
- package/src/config/index.js.map +1 -1
- package/src/core/args.d.ts +13 -0
- package/src/core/args.js.map +1 -1
- package/src/core/bridge.js +39 -0
- package/src/core/bridge.js.map +1 -1
- package/src/core/cli.d.ts +0 -6
- package/src/core/cli.js +23 -3
- package/src/core/cli.js.map +1 -1
- package/src/core/help.d.ts +1 -1
- package/src/core/help.js +27 -6
- package/src/core/help.js.map +1 -1
- package/src/core/program.d.ts +1 -1
- package/src/core/program.js +56 -12
- package/src/core/program.js.map +1 -1
- package/src/core/project-commands.d.ts +44 -0
- package/src/core/project-commands.js +216 -0
- package/src/core/project-commands.js.map +1 -0
- package/src/core/tsconfig.d.ts +20 -0
- package/src/core/tsconfig.js +41 -2
- package/src/core/tsconfig.js.map +1 -1
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Upstream MCP client for the dev bridge (issue #399).
|
|
4
|
+
*
|
|
5
|
+
* Two transport variants:
|
|
6
|
+
*
|
|
7
|
+
* - **HTTP mode**: speaks streamable-HTTP JSON-RPC to the child's HTTP
|
|
8
|
+
* listener at `http://127.0.0.1:<port>/`. Carries the bridge-pinned
|
|
9
|
+
* `mcp-session-id` header on every request so session continuity
|
|
10
|
+
* survives reload.
|
|
11
|
+
*
|
|
12
|
+
* - **Pipe mode (--serve)**: writes/reads newline-delimited JSON-RPC
|
|
13
|
+
* frames on a pair of FDs paired with the child via `child_process`
|
|
14
|
+
* IPC. The child opens these FDs because `FRONTMCP_DEV_STDIO_FD` is
|
|
15
|
+
* set; the bridge writes requests, reads responses, no HTTP layer.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.createHttpUpstream = createHttpUpstream;
|
|
19
|
+
exports.createPipeUpstream = createPipeUpstream;
|
|
20
|
+
function createHttpUpstream(options) {
|
|
21
|
+
const { log, url, onFrame, sessionId } = options;
|
|
22
|
+
// Track every in-flight request — `close()` must abort all of them,
|
|
23
|
+
// not just whichever happened to be assigned last. A shared scalar
|
|
24
|
+
// would let concurrent sends clobber each other's controller.
|
|
25
|
+
const abortControllers = new Set();
|
|
26
|
+
async function send(frame) {
|
|
27
|
+
const abortController = new AbortController();
|
|
28
|
+
abortControllers.add(abortController);
|
|
29
|
+
const signal = abortController.signal;
|
|
30
|
+
try {
|
|
31
|
+
const headers = {
|
|
32
|
+
'content-type': 'application/json',
|
|
33
|
+
accept: 'application/json, text/event-stream',
|
|
34
|
+
};
|
|
35
|
+
if (sessionId)
|
|
36
|
+
headers['mcp-session-id'] = sessionId;
|
|
37
|
+
const res = await fetch(url, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers,
|
|
40
|
+
body: JSON.stringify(frame),
|
|
41
|
+
signal,
|
|
42
|
+
});
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
// Reject so the FSM can clear inflight bookkeeping and synthesise
|
|
45
|
+
// a JSON-RPC error response back to the client. Silently swallowing
|
|
46
|
+
// a non-OK status here leaves callers hanging.
|
|
47
|
+
log.warn('http-upstream-non-ok', { status: res.status, method: frame.method });
|
|
48
|
+
throw new Error(`upstream returned ${res.status} for ${frame.method ?? 'request'}`);
|
|
49
|
+
}
|
|
50
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
51
|
+
if (contentType.includes('text/event-stream')) {
|
|
52
|
+
// SSE: parse `data: <json>` lines until the stream ends, forward each.
|
|
53
|
+
const reader = res.body?.getReader();
|
|
54
|
+
if (!reader)
|
|
55
|
+
return;
|
|
56
|
+
const decoder = new TextDecoder();
|
|
57
|
+
let buffer = '';
|
|
58
|
+
while (true) {
|
|
59
|
+
const { done, value } = await reader.read();
|
|
60
|
+
if (done)
|
|
61
|
+
break;
|
|
62
|
+
buffer += decoder.decode(value, { stream: true });
|
|
63
|
+
let nl;
|
|
64
|
+
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
65
|
+
const line = buffer.slice(0, nl).trim();
|
|
66
|
+
buffer = buffer.slice(nl + 1);
|
|
67
|
+
if (!line.startsWith('data:'))
|
|
68
|
+
continue;
|
|
69
|
+
const json = line.slice(5).trim();
|
|
70
|
+
if (!json)
|
|
71
|
+
continue;
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(json);
|
|
74
|
+
await onFrame(parsed);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
log.warn('sse-parse-error', { error: err.message, raw: json.slice(0, 200) });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
// Single JSON response.
|
|
84
|
+
const body = (await res.json());
|
|
85
|
+
await onFrame(body);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
// AbortError surfaces when close() interrupts an in-flight request
|
|
90
|
+
// during reload; that's expected, so log it at info-not-error. We
|
|
91
|
+
// still rethrow so the FSM can clear inflight bookkeeping and
|
|
92
|
+
// synthesise an error response on its end.
|
|
93
|
+
if (err.name === 'AbortError') {
|
|
94
|
+
log.info('http-upstream-aborted', { method: frame.method });
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
log.error('http-upstream-error', { error: err.message, method: frame.method });
|
|
98
|
+
}
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
abortControllers.delete(abortController);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
send,
|
|
107
|
+
close: async () => {
|
|
108
|
+
for (const ac of abortControllers)
|
|
109
|
+
ac.abort();
|
|
110
|
+
abortControllers.clear();
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Pipe mode: the child speaks JSON-RPC on FD 3 (set via
|
|
116
|
+
* `FRONTMCP_DEV_STDIO_FD=3`). We use Node's IPC channel for the same wire
|
|
117
|
+
* — `child.send(...)` forwards a structured message and `child.on('message', …)`
|
|
118
|
+
* yields whatever the child writes back.
|
|
119
|
+
*
|
|
120
|
+
* (Node's IPC wraps JSON over a pipe internally; the framing is consistent
|
|
121
|
+
* with what `runStdio` would write if it were pointed at FD 3.)
|
|
122
|
+
*/
|
|
123
|
+
function createPipeUpstream(options) {
|
|
124
|
+
const { log, child, onFrame } = options;
|
|
125
|
+
function handleMessage(msg) {
|
|
126
|
+
if (!msg || typeof msg !== 'object') {
|
|
127
|
+
log.warn('pipe-upstream-non-object', { type: typeof msg });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
void onFrame(msg);
|
|
131
|
+
}
|
|
132
|
+
child.on('message', handleMessage);
|
|
133
|
+
async function send(frame) {
|
|
134
|
+
if (!child.connected) {
|
|
135
|
+
log.warn('pipe-upstream-disconnected', { method: frame.method });
|
|
136
|
+
throw new Error(`pipe upstream disconnected for ${frame.method ?? 'request'}`);
|
|
137
|
+
}
|
|
138
|
+
await new Promise((resolve, reject) => {
|
|
139
|
+
child.send(frame, (err) => {
|
|
140
|
+
if (err)
|
|
141
|
+
reject(err);
|
|
142
|
+
else
|
|
143
|
+
resolve();
|
|
144
|
+
});
|
|
145
|
+
}).catch((err) => {
|
|
146
|
+
// Reject so the FSM can produce a JSON-RPC error response back to
|
|
147
|
+
// the client rather than leaving the request stuck inflight.
|
|
148
|
+
log.warn('pipe-upstream-send-error', { error: err.message, method: frame.method });
|
|
149
|
+
throw err;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
send,
|
|
154
|
+
close: async () => {
|
|
155
|
+
child.off('message', handleMessage);
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=upstream-client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"upstream-client.js","sourceRoot":"","sources":["../../../../../src/commands/dev/bridge/upstream-client.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Upstream MCP client for the dev bridge (issue #399).\n *\n * Two transport variants:\n *\n * - **HTTP mode**: speaks streamable-HTTP JSON-RPC to the child's HTTP\n * listener at `http://127.0.0.1:<port>/`. Carries the bridge-pinned\n * `mcp-session-id` header on every request so session continuity\n * survives reload.\n *\n * - **Pipe mode (--serve)**: writes/reads newline-delimited JSON-RPC\n * frames on a pair of FDs paired with the child via `child_process`\n * IPC. The child opens these FDs because `FRONTMCP_DEV_STDIO_FD` is\n * set; the bridge writes requests, reads responses, no HTTP layer.\n */\n\nimport type { ChildProcess } from 'node:child_process';\n\nimport type { BridgeLogger } from './log';\nimport type { JsonRpcFrame } from './stdio-framer';\n\nexport interface UpstreamClient {\n send(frame: JsonRpcFrame): Promise<void>;\n /** Stop background tasks (SSE listener, pipe parser). */\n close(): Promise<void>;\n}\n\nexport interface UpstreamClientOptions {\n log: BridgeLogger;\n /** Called for every frame the upstream child sends back. */\n onFrame: (frame: JsonRpcFrame) => void | Promise<void>;\n /** Session id pinned by the bridge for HTTP mode. */\n sessionId?: string;\n}\n\n// ─── HTTP mode ──────────────────────────────────────────────────────────\n\nexport interface HttpUpstreamOptions extends UpstreamClientOptions {\n /** Loopback URL of the user-code HTTP server. */\n url: string;\n}\n\nexport function createHttpUpstream(options: HttpUpstreamOptions): UpstreamClient {\n const { log, url, onFrame, sessionId } = options;\n // Track every in-flight request — `close()` must abort all of them,\n // not just whichever happened to be assigned last. A shared scalar\n // would let concurrent sends clobber each other's controller.\n const abortControllers = new Set<AbortController>();\n\n async function send(frame: JsonRpcFrame): Promise<void> {\n const abortController = new AbortController();\n abortControllers.add(abortController);\n const signal = abortController.signal;\n try {\n const headers: Record<string, string> = {\n 'content-type': 'application/json',\n accept: 'application/json, text/event-stream',\n };\n if (sessionId) headers['mcp-session-id'] = sessionId;\n\n const res = await fetch(url, {\n method: 'POST',\n headers,\n body: JSON.stringify(frame),\n signal,\n });\n\n if (!res.ok) {\n // Reject so the FSM can clear inflight bookkeeping and synthesise\n // a JSON-RPC error response back to the client. Silently swallowing\n // a non-OK status here leaves callers hanging.\n log.warn('http-upstream-non-ok', { status: res.status, method: frame.method });\n throw new Error(`upstream returned ${res.status} for ${frame.method ?? 'request'}`);\n }\n\n const contentType = res.headers.get('content-type') ?? '';\n if (contentType.includes('text/event-stream')) {\n // SSE: parse `data: <json>` lines until the stream ends, forward each.\n const reader = res.body?.getReader();\n if (!reader) return;\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let nl: number;\n while ((nl = buffer.indexOf('\\n')) >= 0) {\n const line = buffer.slice(0, nl).trim();\n buffer = buffer.slice(nl + 1);\n if (!line.startsWith('data:')) continue;\n const json = line.slice(5).trim();\n if (!json) continue;\n try {\n const parsed = JSON.parse(json) as JsonRpcFrame;\n await onFrame(parsed);\n } catch (err) {\n log.warn('sse-parse-error', { error: (err as Error).message, raw: json.slice(0, 200) });\n }\n }\n }\n } else {\n // Single JSON response.\n const body = (await res.json()) as JsonRpcFrame;\n await onFrame(body);\n }\n } catch (err) {\n // AbortError surfaces when close() interrupts an in-flight request\n // during reload; that's expected, so log it at info-not-error. We\n // still rethrow so the FSM can clear inflight bookkeeping and\n // synthesise an error response on its end.\n if ((err as { name?: string }).name === 'AbortError') {\n log.info('http-upstream-aborted', { method: frame.method });\n } else {\n log.error('http-upstream-error', { error: (err as Error).message, method: frame.method });\n }\n throw err;\n } finally {\n abortControllers.delete(abortController);\n }\n }\n\n return {\n send,\n close: async () => {\n for (const ac of abortControllers) ac.abort();\n abortControllers.clear();\n },\n };\n}\n\n// ─── Pipe mode (--serve) ────────────────────────────────────────────────\n\nexport interface PipeUpstreamOptions extends UpstreamClientOptions {\n /** The forked child; we write to / read from the IPC pipe (FD 3). */\n child: ChildProcess;\n}\n\n/**\n * Pipe mode: the child speaks JSON-RPC on FD 3 (set via\n * `FRONTMCP_DEV_STDIO_FD=3`). We use Node's IPC channel for the same wire\n * — `child.send(...)` forwards a structured message and `child.on('message', …)`\n * yields whatever the child writes back.\n *\n * (Node's IPC wraps JSON over a pipe internally; the framing is consistent\n * with what `runStdio` would write if it were pointed at FD 3.)\n */\nexport function createPipeUpstream(options: PipeUpstreamOptions): UpstreamClient {\n const { log, child, onFrame } = options;\n\n function handleMessage(msg: unknown): void {\n if (!msg || typeof msg !== 'object') {\n log.warn('pipe-upstream-non-object', { type: typeof msg });\n return;\n }\n void onFrame(msg as JsonRpcFrame);\n }\n\n child.on('message', handleMessage);\n\n async function send(frame: JsonRpcFrame): Promise<void> {\n if (!child.connected) {\n log.warn('pipe-upstream-disconnected', { method: frame.method });\n throw new Error(`pipe upstream disconnected for ${frame.method ?? 'request'}`);\n }\n await new Promise<void>((resolve, reject) => {\n child.send(frame, (err) => {\n if (err) reject(err);\n else resolve();\n });\n }).catch((err: Error) => {\n // Reject so the FSM can produce a JSON-RPC error response back to\n // the client rather than leaving the request stuck inflight.\n log.warn('pipe-upstream-send-error', { error: err.message, method: frame.method });\n throw err;\n });\n }\n\n return {\n send,\n close: async () => {\n child.off('message', handleMessage);\n },\n };\n}\n"]}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File watcher for the dev bridge (issue #399).
|
|
3
|
+
*
|
|
4
|
+
* In bridge mode, `tsx` is used as a loader (no `--watch`) — the bridge
|
|
5
|
+
* owns the watcher so it can emit a `reload-start` signal to the state
|
|
6
|
+
* machine. Today's `tsx --watch` owns the watcher internally, which is
|
|
7
|
+
* why it can't coordinate with anyone else.
|
|
8
|
+
*
|
|
9
|
+
* Uses `fs.watch` (Node built-in, no dep) with a 150ms debounce. The
|
|
10
|
+
* default ignore list filters `node_modules`, `.git`, `dist`, and the
|
|
11
|
+
* non-TS extensions that don't trigger source-of-truth reloads.
|
|
12
|
+
*/
|
|
13
|
+
import type { BridgeLogger } from './log';
|
|
14
|
+
export interface DevWatcherOptions {
|
|
15
|
+
/** Directory to watch (typically the entry's parent). */
|
|
16
|
+
rootDir: string;
|
|
17
|
+
/** Debounce window in ms — multiple events within this window collapse. */
|
|
18
|
+
debounceMs?: number;
|
|
19
|
+
/** Glob fragments to skip. Default: node_modules, .git, dist, .frontmcp. */
|
|
20
|
+
ignore?: string[];
|
|
21
|
+
/** Extensions that trigger reload. Default: .ts, .tsx, .js, .mjs, .cjs. */
|
|
22
|
+
triggerExtensions?: string[];
|
|
23
|
+
log: BridgeLogger;
|
|
24
|
+
onChange: (relativePath: string) => void;
|
|
25
|
+
}
|
|
26
|
+
export interface DevWatcher {
|
|
27
|
+
start(): void;
|
|
28
|
+
stop(): void;
|
|
29
|
+
}
|
|
30
|
+
export declare function createDevWatcher(options: DevWatcherOptions): DevWatcher;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* File watcher for the dev bridge (issue #399).
|
|
4
|
+
*
|
|
5
|
+
* In bridge mode, `tsx` is used as a loader (no `--watch`) — the bridge
|
|
6
|
+
* owns the watcher so it can emit a `reload-start` signal to the state
|
|
7
|
+
* machine. Today's `tsx --watch` owns the watcher internally, which is
|
|
8
|
+
* why it can't coordinate with anyone else.
|
|
9
|
+
*
|
|
10
|
+
* Uses `fs.watch` (Node built-in, no dep) with a 150ms debounce. The
|
|
11
|
+
* default ignore list filters `node_modules`, `.git`, `dist`, and the
|
|
12
|
+
* non-TS extensions that don't trigger source-of-truth reloads.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.createDevWatcher = createDevWatcher;
|
|
16
|
+
const tslib_1 = require("tslib");
|
|
17
|
+
const fs = tslib_1.__importStar(require("node:fs"));
|
|
18
|
+
const path = tslib_1.__importStar(require("node:path"));
|
|
19
|
+
const DEFAULT_IGNORE = ['node_modules', '.git', 'dist', '.frontmcp'];
|
|
20
|
+
const DEFAULT_EXTS = ['.ts', '.tsx', '.js', '.mjs', '.cjs'];
|
|
21
|
+
function createDevWatcher(options) {
|
|
22
|
+
const debounce = options.debounceMs ?? 150;
|
|
23
|
+
const ignore = options.ignore ?? DEFAULT_IGNORE;
|
|
24
|
+
const exts = options.triggerExtensions ?? DEFAULT_EXTS;
|
|
25
|
+
const log = options.log;
|
|
26
|
+
let timer;
|
|
27
|
+
let lastTrigger;
|
|
28
|
+
let watcher;
|
|
29
|
+
function shouldIgnore(rel) {
|
|
30
|
+
if (ignore.some((seg) => rel.split(path.sep).includes(seg)))
|
|
31
|
+
return true;
|
|
32
|
+
const ext = path.extname(rel);
|
|
33
|
+
return ext.length > 0 && !exts.includes(ext);
|
|
34
|
+
}
|
|
35
|
+
function fire(rel) {
|
|
36
|
+
lastTrigger = rel;
|
|
37
|
+
if (timer)
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
timer = setTimeout(() => {
|
|
40
|
+
timer = undefined;
|
|
41
|
+
const triggerLabel = lastTrigger ?? '(unknown)';
|
|
42
|
+
log.reloadEvent('watcher', { trigger: triggerLabel });
|
|
43
|
+
options.onChange(triggerLabel);
|
|
44
|
+
}, debounce);
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
start() {
|
|
48
|
+
try {
|
|
49
|
+
watcher = fs.watch(options.rootDir, { recursive: true }, (_event, filename) => {
|
|
50
|
+
if (!filename)
|
|
51
|
+
return;
|
|
52
|
+
// Node's `fs.watch` types `filename` as `string | null` here
|
|
53
|
+
// (no `encoding: 'buffer'` option set), so a `typeof filename ===
|
|
54
|
+
// 'string'` narrow leaves the else-branch as `never` and trips
|
|
55
|
+
// TS2339. `String(filename)` is defensive for any future widening
|
|
56
|
+
// (e.g., a `Buffer` arriving via a polyfill) without confusing
|
|
57
|
+
// the type checker.
|
|
58
|
+
const rel = String(filename);
|
|
59
|
+
if (shouldIgnore(rel))
|
|
60
|
+
return;
|
|
61
|
+
fire(rel);
|
|
62
|
+
});
|
|
63
|
+
watcher.on('error', (err) => {
|
|
64
|
+
log.warn('watcher-error', { error: err.message });
|
|
65
|
+
});
|
|
66
|
+
log.info('watcher-started', { rootDir: options.rootDir, debounceMs: debounce });
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
log.error('watcher-start-failed', { error: err.message });
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
stop() {
|
|
73
|
+
if (timer) {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
timer = undefined;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
watcher?.close();
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// ignore
|
|
82
|
+
}
|
|
83
|
+
log.info('watcher-stopped');
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=watcher.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"watcher.js","sourceRoot":"","sources":["../../../../../src/commands/dev/bridge/watcher.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * File watcher for the dev bridge (issue #399).\n *\n * In bridge mode, `tsx` is used as a loader (no `--watch`) — the bridge\n * owns the watcher so it can emit a `reload-start` signal to the state\n * machine. Today's `tsx --watch` owns the watcher internally, which is\n * why it can't coordinate with anyone else.\n *\n * Uses `fs.watch` (Node built-in, no dep) with a 150ms debounce. The\n * default ignore list filters `node_modules`, `.git`, `dist`, and the\n * non-TS extensions that don't trigger source-of-truth reloads.\n */\n\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\n\nimport type { BridgeLogger } from './log';\n\nexport interface DevWatcherOptions {\n /** Directory to watch (typically the entry's parent). */\n rootDir: string;\n /** Debounce window in ms — multiple events within this window collapse. */\n debounceMs?: number;\n /** Glob fragments to skip. Default: node_modules, .git, dist, .frontmcp. */\n ignore?: string[];\n /** Extensions that trigger reload. Default: .ts, .tsx, .js, .mjs, .cjs. */\n triggerExtensions?: string[];\n log: BridgeLogger;\n onChange: (relativePath: string) => void;\n}\n\nexport interface DevWatcher {\n start(): void;\n stop(): void;\n}\n\nconst DEFAULT_IGNORE = ['node_modules', '.git', 'dist', '.frontmcp'];\nconst DEFAULT_EXTS = ['.ts', '.tsx', '.js', '.mjs', '.cjs'];\n\nexport function createDevWatcher(options: DevWatcherOptions): DevWatcher {\n const debounce = options.debounceMs ?? 150;\n const ignore = options.ignore ?? DEFAULT_IGNORE;\n const exts = options.triggerExtensions ?? DEFAULT_EXTS;\n const log = options.log;\n let timer: NodeJS.Timeout | undefined;\n let lastTrigger: string | undefined;\n let watcher: fs.FSWatcher | undefined;\n\n function shouldIgnore(rel: string): boolean {\n if (ignore.some((seg) => rel.split(path.sep).includes(seg))) return true;\n const ext = path.extname(rel);\n return ext.length > 0 && !exts.includes(ext);\n }\n\n function fire(rel: string): void {\n lastTrigger = rel;\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => {\n timer = undefined;\n const triggerLabel = lastTrigger ?? '(unknown)';\n log.reloadEvent('watcher', { trigger: triggerLabel });\n options.onChange(triggerLabel);\n }, debounce);\n }\n\n return {\n start() {\n try {\n watcher = fs.watch(options.rootDir, { recursive: true }, (_event, filename) => {\n if (!filename) return;\n // Node's `fs.watch` types `filename` as `string | null` here\n // (no `encoding: 'buffer'` option set), so a `typeof filename ===\n // 'string'` narrow leaves the else-branch as `never` and trips\n // TS2339. `String(filename)` is defensive for any future widening\n // (e.g., a `Buffer` arriving via a polyfill) without confusing\n // the type checker.\n const rel = String(filename);\n if (shouldIgnore(rel)) return;\n fire(rel);\n });\n watcher.on('error', (err) => {\n log.warn('watcher-error', { error: err.message });\n });\n log.info('watcher-started', { rootDir: options.rootDir, debounceMs: debounce });\n } catch (err) {\n log.error('watcher-start-failed', { error: (err as Error).message });\n }\n },\n stop() {\n if (timer) {\n clearTimeout(timer);\n timer = undefined;\n }\n try {\n watcher?.close();\n } catch {\n // ignore\n }\n log.info('watcher-stopped');\n },\n };\n}\n"]}
|
|
@@ -1,2 +1,35 @@
|
|
|
1
|
-
import { ParsedArgs } from '../../core/args';
|
|
1
|
+
import { type ParsedArgs } from '../../core/args';
|
|
2
|
+
/**
|
|
3
|
+
* Resolve the port the dev child should bind to and report any conflict
|
|
4
|
+
* clearly. Returns the chosen port — or never returns and exits the process
|
|
5
|
+
* with a clear error when the port is busy and `--auto-port` was not set.
|
|
6
|
+
*
|
|
7
|
+
* Issue #398: previously the child crashed with a raw `EADDRINUSE` stack
|
|
8
|
+
* trace; this helper turns that into a one-line message with a suggested
|
|
9
|
+
* remediation and (optionally) the owning process.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveDevPort(opts: {
|
|
12
|
+
port?: number;
|
|
13
|
+
autoPort?: boolean;
|
|
14
|
+
showConflict?: boolean;
|
|
15
|
+
envPort?: string | undefined;
|
|
16
|
+
exit?: (code: number) => never;
|
|
17
|
+
log?: (msg: string) => void;
|
|
18
|
+
}): Promise<number>;
|
|
19
|
+
/**
|
|
20
|
+
* Build the environment handed to the spawned dev child.
|
|
21
|
+
*
|
|
22
|
+
* The resolved port is exported as `PORT`, and the configured
|
|
23
|
+
* `transport.http.path` (when set) as `FRONTMCP_HTTP_ENTRY_PATH` so the server
|
|
24
|
+
* mounts the MCP endpoint where the generated client URLs point (#446). Both are
|
|
25
|
+
* applied AFTER the inherited env so the dev-resolved values win for this run —
|
|
26
|
+
* the same precedence as `PORT`. A hard-coded `@FrontMcp({ http: { entryPath } })`
|
|
27
|
+
* in metadata still wins over the env (the SDK only reads it as a default).
|
|
28
|
+
*/
|
|
29
|
+
export declare function buildDevChildEnv(params: {
|
|
30
|
+
effectiveEnv: NodeJS.ProcessEnv;
|
|
31
|
+
baseEnv: NodeJS.ProcessEnv;
|
|
32
|
+
port: number;
|
|
33
|
+
configHttpPath?: string;
|
|
34
|
+
}): NodeJS.ProcessEnv;
|
|
2
35
|
export declare function runDev(opts: ParsedArgs): Promise<void>;
|
package/src/commands/dev/dev.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveDevPort = resolveDevPort;
|
|
4
|
+
exports.buildDevChildEnv = buildDevChildEnv;
|
|
3
5
|
exports.runDev = runDev;
|
|
4
6
|
const tslib_1 = require("tslib");
|
|
5
|
-
const path = tslib_1.__importStar(require("path"));
|
|
6
7
|
const child_process_1 = require("child_process");
|
|
8
|
+
const path = tslib_1.__importStar(require("path"));
|
|
9
|
+
const config_1 = require("../../config");
|
|
7
10
|
const colors_1 = require("../../core/colors");
|
|
8
|
-
const fs_1 = require("../../shared/fs");
|
|
9
11
|
const env_1 = require("../../shared/env");
|
|
12
|
+
const fs_1 = require("../../shared/fs");
|
|
13
|
+
const port_1 = require("./port");
|
|
14
|
+
const DEFAULT_DEV_PORT = 3000;
|
|
10
15
|
function killQuiet(proc, signal = 'SIGINT') {
|
|
11
16
|
try {
|
|
12
17
|
if (proc && proc.exitCode === null && proc.signalCode === null) {
|
|
@@ -17,26 +22,165 @@ function killQuiet(proc, signal = 'SIGINT') {
|
|
|
17
22
|
// ignore
|
|
18
23
|
}
|
|
19
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the port the dev child should bind to and report any conflict
|
|
27
|
+
* clearly. Returns the chosen port — or never returns and exits the process
|
|
28
|
+
* with a clear error when the port is busy and `--auto-port` was not set.
|
|
29
|
+
*
|
|
30
|
+
* Issue #398: previously the child crashed with a raw `EADDRINUSE` stack
|
|
31
|
+
* trace; this helper turns that into a one-line message with a suggested
|
|
32
|
+
* remediation and (optionally) the owning process.
|
|
33
|
+
*/
|
|
34
|
+
async function resolveDevPort(opts) {
|
|
35
|
+
const exit = opts.exit ?? ((code) => process.exit(code));
|
|
36
|
+
const log = opts.log ?? ((msg) => console.error(msg));
|
|
37
|
+
const explicit = opts.port ?? (opts.envPort !== undefined && opts.envPort !== '' ? Number(opts.envPort) : undefined);
|
|
38
|
+
const port = explicit !== undefined && Number.isFinite(explicit) && explicit > 0
|
|
39
|
+
? explicit
|
|
40
|
+
: DEFAULT_DEV_PORT;
|
|
41
|
+
if (await (0, port_1.isPortFree)(port))
|
|
42
|
+
return port;
|
|
43
|
+
if (opts.autoPort) {
|
|
44
|
+
const alt = await (0, port_1.findNextFreePort)(port + 1);
|
|
45
|
+
log(`${(0, colors_1.c)('yellow', '[dev]')} port ${port} is in use; auto-picked ${alt}`);
|
|
46
|
+
return alt;
|
|
47
|
+
}
|
|
48
|
+
// Build a clear, actionable error message.
|
|
49
|
+
const lines = [
|
|
50
|
+
`${(0, colors_1.c)('red', '[dev]')} Port ${port} is already in use — refusing to start.`,
|
|
51
|
+
`${(0, colors_1.c)('gray', ' ')} Retry with one of:`,
|
|
52
|
+
`${(0, colors_1.c)('gray', ' ')} • ${(0, colors_1.c)('bold', `frontmcp dev --port <other-port>`)}`,
|
|
53
|
+
`${(0, colors_1.c)('gray', ' ')} • ${(0, colors_1.c)('bold', `frontmcp dev --auto-port`)} ${(0, colors_1.c)('gray', '(pick the next free port automatically)')}`,
|
|
54
|
+
`${(0, colors_1.c)('gray', ' ')} • ${(0, colors_1.c)('bold', `PORT=<other-port> frontmcp dev`)}`,
|
|
55
|
+
];
|
|
56
|
+
if (opts.showConflict) {
|
|
57
|
+
const owner = await (0, port_1.lookupPortOwner)(port);
|
|
58
|
+
if (owner) {
|
|
59
|
+
lines.push(`${(0, colors_1.c)('gray', ' ')} Holder of ${port}:`);
|
|
60
|
+
for (const row of owner.split('\n'))
|
|
61
|
+
lines.push(`${(0, colors_1.c)('gray', ' ')} ${row}`);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
lines.push(`${(0, colors_1.c)('gray', ' ')} (could not identify the holder of port ${port})`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
lines.push(`${(0, colors_1.c)('gray', ' ')} (pass --show-conflict to print which process is holding the port)`);
|
|
69
|
+
}
|
|
70
|
+
for (const line of lines)
|
|
71
|
+
log(line);
|
|
72
|
+
return exit(1);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Build the environment handed to the spawned dev child.
|
|
76
|
+
*
|
|
77
|
+
* The resolved port is exported as `PORT`, and the configured
|
|
78
|
+
* `transport.http.path` (when set) as `FRONTMCP_HTTP_ENTRY_PATH` so the server
|
|
79
|
+
* mounts the MCP endpoint where the generated client URLs point (#446). Both are
|
|
80
|
+
* applied AFTER the inherited env so the dev-resolved values win for this run —
|
|
81
|
+
* the same precedence as `PORT`. A hard-coded `@FrontMcp({ http: { entryPath } })`
|
|
82
|
+
* in metadata still wins over the env (the SDK only reads it as a default).
|
|
83
|
+
*/
|
|
84
|
+
function buildDevChildEnv(params) {
|
|
85
|
+
const { effectiveEnv, baseEnv, port, configHttpPath } = params;
|
|
86
|
+
return {
|
|
87
|
+
...effectiveEnv,
|
|
88
|
+
...baseEnv,
|
|
89
|
+
PORT: String(port),
|
|
90
|
+
...(configHttpPath !== undefined ? { FRONTMCP_HTTP_ENTRY_PATH: configHttpPath } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
20
93
|
async function runDev(opts) {
|
|
94
|
+
// Issue #399 — `--stdio` runs the first-party watch-aware stdio bridge
|
|
95
|
+
// instead of the legacy `tsx --watch + tsc --noEmit --watch` pair. The
|
|
96
|
+
// bridge owns process stdin/stdout (JSON-RPC frames only), holds the
|
|
97
|
+
// upstream MCP session across child restarts, and replaces the
|
|
98
|
+
// third-party `mcp-remote` recipe for the dev loop.
|
|
99
|
+
if (opts.stdio) {
|
|
100
|
+
const { runDevBridge } = await import('./bridge/index.js');
|
|
101
|
+
return runDevBridge(opts);
|
|
102
|
+
}
|
|
21
103
|
const cwd = process.cwd();
|
|
22
|
-
|
|
23
|
-
//
|
|
104
|
+
// Issue #400 — resolve frontmcp.config so `entry`, `transport.http.port`,
|
|
105
|
+
// and `env.shared`/`env.dev` overlays apply. Precedence:
|
|
106
|
+
// CLI flag > FRONTMCP_<NAME> env > frontmcp.config field > built-in default.
|
|
107
|
+
const resolved = await (0, config_1.resolveConfig)({
|
|
108
|
+
cwd,
|
|
109
|
+
mode: 'dev',
|
|
110
|
+
configPath: typeof opts.config === 'string' ? opts.config : undefined,
|
|
111
|
+
});
|
|
112
|
+
const cfg = resolved.config;
|
|
113
|
+
const cliEntry = typeof opts.entry === 'string' ? opts.entry : undefined;
|
|
114
|
+
const configEntry = typeof cfg?.entry === 'string' ? cfg.entry : undefined;
|
|
115
|
+
const entry = await (0, fs_1.resolveEntry)(cwd, cliEntry ?? configEntry);
|
|
116
|
+
// Load .env and .env.local files (these win over config env overlays for
|
|
117
|
+
// parity with existing behavior — file-based env is the deployment escape
|
|
118
|
+
// hatch and shouldn't be silently overridden by committed config).
|
|
24
119
|
(0, env_1.loadDevEnv)(cwd);
|
|
120
|
+
// Resolve the port BEFORE spawning tsx so EADDRINUSE produces a clean
|
|
121
|
+
// one-line error instead of a raw node:net stack trace (issue #398).
|
|
122
|
+
//
|
|
123
|
+
// Two caveats worth knowing about this pre-flight check:
|
|
124
|
+
// 1. TOCTOU — between this probe returning and the child actually binding,
|
|
125
|
+
// another process can grab the port. We accept that race: this is a
|
|
126
|
+
// dev-time tool, the worst case reverts to the prior behaviour (the
|
|
127
|
+
// child surfaces a raw EADDRINUSE), and the common case (port already
|
|
128
|
+
// busy at startup) is the one we wanted to fix.
|
|
129
|
+
// 2. The resolved port is exported as `PORT` to the child. It only takes
|
|
130
|
+
// effect when the user's `@FrontMcp({ http: { port } })` reads
|
|
131
|
+
// `process.env.PORT` (the SDK's `httpOptionsSchema` default does).
|
|
132
|
+
// If the user's metadata HARD-CODES `http.port`, the child binds to
|
|
133
|
+
// that hard-coded value and ignores PORT — the probe is then advisory
|
|
134
|
+
// only. Documented in docs/frontmcp/deployment/local-dev-server.mdx.
|
|
135
|
+
const cliPort = typeof opts.port === 'number' ? opts.port : opts.port ? Number(opts.port) : undefined;
|
|
136
|
+
const configPort = cfg?.transport?.http?.port;
|
|
137
|
+
const port = await resolveDevPort({
|
|
138
|
+
port: cliPort ?? configPort,
|
|
139
|
+
autoPort: !!opts.autoPort,
|
|
140
|
+
showConflict: !!opts.showConflict,
|
|
141
|
+
envPort: process.env['PORT'],
|
|
142
|
+
});
|
|
143
|
+
// Issue #446 — honor the configured MCP mount path in dev. `transport.http.path`
|
|
144
|
+
// already drives the generated client URLs (eject); propagate it to the spawned
|
|
145
|
+
// server via FRONTMCP_HTTP_ENTRY_PATH so the endpoint is actually mounted there
|
|
146
|
+
// (the SDK's httpOptionsSchema.entryPath default reads this env). Same precedence
|
|
147
|
+
// caveat as PORT: a hard-coded `@FrontMcp({ http: { entryPath } })` still wins.
|
|
148
|
+
const configHttpPath = typeof cfg?.transport?.http?.path === 'string' ? cfg.transport.http.path : undefined;
|
|
25
149
|
console.log(`${(0, colors_1.c)('cyan', '[dev]')} using entry: ${path.relative(cwd, entry)}`);
|
|
150
|
+
if (resolved.configPath || resolved.configDir) {
|
|
151
|
+
console.log(`${(0, colors_1.c)('gray', '[dev]')} config: ${resolved.configPath ?? resolved.configDir}`);
|
|
152
|
+
}
|
|
153
|
+
console.log(`${(0, colors_1.c)('cyan', '[dev]')} listening on port: ${port}`);
|
|
154
|
+
if (configHttpPath) {
|
|
155
|
+
console.log(`${(0, colors_1.c)('gray', '[dev]')} MCP endpoint path: ${configHttpPath}`);
|
|
156
|
+
}
|
|
26
157
|
console.log(`${(0, colors_1.c)('gray', '[dev]')} starting ${(0, colors_1.c)('bold', 'tsx --watch')} and ${(0, colors_1.c)('bold', 'tsc --noEmit --watch')} (async type-checker)`);
|
|
27
158
|
console.log(`${(0, colors_1.c)('gray', 'hint:')} press Ctrl+C to stop`);
|
|
28
|
-
// Use --conditions node to ensure proper Node.js module resolution
|
|
29
|
-
// This helps with dynamic require() calls in packages like ioredis
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
159
|
+
// Use --conditions node to ensure proper Node.js module resolution.
|
|
160
|
+
// This helps with dynamic require() calls in packages like ioredis.
|
|
161
|
+
// On Windows resolve npx.cmd directly — previously we passed shell:true
|
|
162
|
+
// for the .cmd suffix, but that triggers Node DEP0190 (#381) every run.
|
|
163
|
+
// spawn() resolves .cmd via CreateProcessW since Node 16, so no shell is
|
|
164
|
+
// needed; on Unix spawn() works on 'npx' directly. SIGINT still
|
|
165
|
+
// propagates cleanly because no intermediate shell sits between us and
|
|
166
|
+
// the child process.
|
|
167
|
+
const npxCmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
|
168
|
+
// Issue #400 — env overlays from `frontmcp.config.env.{shared,dev}` are
|
|
169
|
+
// included via `resolved.effectiveEnv`. `.env`/`.env.local` already loaded
|
|
170
|
+
// into `process.env` above, so they win (they're closer to deployment).
|
|
171
|
+
const childEnv = buildDevChildEnv({
|
|
172
|
+
effectiveEnv: resolved.effectiveEnv,
|
|
173
|
+
baseEnv: process.env,
|
|
174
|
+
port,
|
|
175
|
+
configHttpPath,
|
|
176
|
+
});
|
|
177
|
+
const app = (0, child_process_1.spawn)(npxCmd, ['-y', 'tsx', '--conditions', 'node', '--watch', entry], {
|
|
34
178
|
stdio: 'inherit',
|
|
35
|
-
|
|
179
|
+
env: childEnv,
|
|
36
180
|
});
|
|
37
|
-
const checker = (0, child_process_1.spawn)(
|
|
181
|
+
const checker = (0, child_process_1.spawn)(npxCmd, ['-y', 'tsc', '--noEmit', '--pretty', '--watch'], {
|
|
38
182
|
stdio: 'inherit',
|
|
39
|
-
|
|
183
|
+
env: childEnv,
|
|
40
184
|
});
|
|
41
185
|
const cleanup = (clearTimer = true) => {
|
|
42
186
|
if (clearTimer) {
|
|
@@ -95,8 +239,13 @@ async function runDev(opts) {
|
|
|
95
239
|
cleanup();
|
|
96
240
|
process.exit(0);
|
|
97
241
|
});
|
|
242
|
+
let appExitCode = 0;
|
|
98
243
|
await new Promise((resolve, reject) => {
|
|
99
|
-
app.on('close', () => {
|
|
244
|
+
app.on('close', (code) => {
|
|
245
|
+
// Capture the child's exit code so it can propagate to the parent
|
|
246
|
+
// shell. SIGINT/SIGTERM yield code=null with a signalCode — treat
|
|
247
|
+
// those as 0 so Ctrl+C doesn't appear as a failure.
|
|
248
|
+
appExitCode = typeof code === 'number' ? code : 0;
|
|
100
249
|
markClosed('app');
|
|
101
250
|
cleanup(false);
|
|
102
251
|
resolve();
|
|
@@ -115,5 +264,10 @@ async function runDev(opts) {
|
|
|
115
264
|
reject(err);
|
|
116
265
|
});
|
|
117
266
|
});
|
|
267
|
+
// Propagate the child's exit code so CI / shells see real failures
|
|
268
|
+
// instead of always-success.
|
|
269
|
+
if (appExitCode && appExitCode !== 0) {
|
|
270
|
+
process.exit(appExitCode);
|
|
271
|
+
}
|
|
118
272
|
}
|
|
119
273
|
//# sourceMappingURL=dev.js.map
|