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.
Files changed (114) hide show
  1. package/README.md +38 -29
  2. package/package.json +4 -4
  3. package/src/commands/build/exec/bin-meta.d.ts +49 -0
  4. package/src/commands/build/exec/bin-meta.js +68 -0
  5. package/src/commands/build/exec/bin-meta.js.map +1 -0
  6. package/src/commands/build/exec/cli-runtime/generate-cli-entry.js +195 -3
  7. package/src/commands/build/exec/cli-runtime/generate-cli-entry.js.map +1 -1
  8. package/src/commands/build/exec/cli-runtime/plugin-emitter.d.ts +160 -0
  9. package/src/commands/build/exec/cli-runtime/plugin-emitter.js +512 -0
  10. package/src/commands/build/exec/cli-runtime/plugin-emitter.js.map +1 -0
  11. package/src/commands/build/exec/cli-runtime/schema-extractor.d.ts +13 -1
  12. package/src/commands/build/exec/cli-runtime/schema-extractor.js +29 -3
  13. package/src/commands/build/exec/cli-runtime/schema-extractor.js.map +1 -1
  14. package/src/commands/build/exec/cli-runtime/skill-md-compose.d.ts +25 -0
  15. package/src/commands/build/exec/cli-runtime/skill-md-compose.js +63 -0
  16. package/src/commands/build/exec/cli-runtime/skill-md-compose.js.map +1 -0
  17. package/src/commands/build/exec/index.js +26 -0
  18. package/src/commands/build/exec/index.js.map +1 -1
  19. package/src/commands/build/exec/runner-script.js +16 -4
  20. package/src/commands/build/exec/runner-script.js.map +1 -1
  21. package/src/commands/dev/bridge/child-supervisor.d.ts +48 -0
  22. package/src/commands/dev/bridge/child-supervisor.js +228 -0
  23. package/src/commands/dev/bridge/child-supervisor.js.map +1 -0
  24. package/src/commands/dev/bridge/errors.d.ts +23 -0
  25. package/src/commands/dev/bridge/errors.js +34 -0
  26. package/src/commands/dev/bridge/errors.js.map +1 -0
  27. package/src/commands/dev/bridge/index.d.ts +30 -0
  28. package/src/commands/dev/bridge/index.js +220 -0
  29. package/src/commands/dev/bridge/index.js.map +1 -0
  30. package/src/commands/dev/bridge/log.d.ts +29 -0
  31. package/src/commands/dev/bridge/log.js +82 -0
  32. package/src/commands/dev/bridge/log.js.map +1 -0
  33. package/src/commands/dev/bridge/state-machine.d.ts +56 -0
  34. package/src/commands/dev/bridge/state-machine.js +245 -0
  35. package/src/commands/dev/bridge/state-machine.js.map +1 -0
  36. package/src/commands/dev/bridge/stdio-framer.d.ts +47 -0
  37. package/src/commands/dev/bridge/stdio-framer.js +128 -0
  38. package/src/commands/dev/bridge/stdio-framer.js.map +1 -0
  39. package/src/commands/dev/bridge/upstream-client.d.ts +49 -0
  40. package/src/commands/dev/bridge/upstream-client.js +159 -0
  41. package/src/commands/dev/bridge/upstream-client.js.map +1 -0
  42. package/src/commands/dev/bridge/watcher.d.ts +30 -0
  43. package/src/commands/dev/bridge/watcher.js +87 -0
  44. package/src/commands/dev/bridge/watcher.js.map +1 -0
  45. package/src/commands/dev/dev.d.ts +34 -1
  46. package/src/commands/dev/dev.js +168 -14
  47. package/src/commands/dev/dev.js.map +1 -1
  48. package/src/commands/dev/inspector.d.ts +13 -1
  49. package/src/commands/dev/inspector.js +77 -3
  50. package/src/commands/dev/inspector.js.map +1 -1
  51. package/src/commands/dev/port.d.ts +23 -0
  52. package/src/commands/dev/port.js +87 -0
  53. package/src/commands/dev/port.js.map +1 -0
  54. package/src/commands/dev/register.d.ts +1 -1
  55. package/src/commands/dev/register.js +28 -4
  56. package/src/commands/dev/register.js.map +1 -1
  57. package/src/commands/dev/test.d.ts +26 -1
  58. package/src/commands/dev/test.js +181 -64
  59. package/src/commands/dev/test.js.map +1 -1
  60. package/src/commands/eject/mcp-client.d.ts +25 -0
  61. package/src/commands/eject/mcp-client.js +74 -0
  62. package/src/commands/eject/mcp-client.js.map +1 -0
  63. package/src/commands/eject/register.d.ts +9 -0
  64. package/src/commands/eject/register.js +56 -0
  65. package/src/commands/eject/register.js.map +1 -0
  66. package/src/commands/install/install-claude-plugin.d.ts +13 -0
  67. package/src/commands/install/install-claude-plugin.js +327 -0
  68. package/src/commands/install/install-claude-plugin.js.map +1 -0
  69. package/src/commands/install/register.d.ts +16 -0
  70. package/src/commands/install/register.js +70 -0
  71. package/src/commands/install/register.js.map +1 -0
  72. package/src/commands/scaffold/create.js +52 -8
  73. package/src/commands/scaffold/create.js.map +1 -1
  74. package/src/commands/skills/from-entry.d.ts +31 -0
  75. package/src/commands/skills/from-entry.js +68 -0
  76. package/src/commands/skills/from-entry.js.map +1 -0
  77. package/src/commands/skills/install.d.ts +12 -0
  78. package/src/commands/skills/install.js +173 -8
  79. package/src/commands/skills/install.js.map +1 -1
  80. package/src/commands/skills/register.js +7 -3
  81. package/src/commands/skills/register.js.map +1 -1
  82. package/src/config/frontmcp-config.loader.d.ts +28 -0
  83. package/src/config/frontmcp-config.loader.js +146 -67
  84. package/src/config/frontmcp-config.loader.js.map +1 -1
  85. package/src/config/frontmcp-config.resolve.d.ts +67 -0
  86. package/src/config/frontmcp-config.resolve.js +118 -0
  87. package/src/config/frontmcp-config.resolve.js.map +1 -0
  88. package/src/config/frontmcp-config.schema.d.ts +207 -0
  89. package/src/config/frontmcp-config.schema.js +217 -1
  90. package/src/config/frontmcp-config.schema.js.map +1 -1
  91. package/src/config/frontmcp-config.types.d.ts +133 -0
  92. package/src/config/frontmcp-config.types.js.map +1 -1
  93. package/src/config/index.d.ts +2 -1
  94. package/src/config/index.js +3 -1
  95. package/src/config/index.js.map +1 -1
  96. package/src/core/args.d.ts +13 -0
  97. package/src/core/args.js.map +1 -1
  98. package/src/core/bridge.js +39 -0
  99. package/src/core/bridge.js.map +1 -1
  100. package/src/core/cli.d.ts +0 -6
  101. package/src/core/cli.js +23 -3
  102. package/src/core/cli.js.map +1 -1
  103. package/src/core/help.d.ts +1 -1
  104. package/src/core/help.js +27 -6
  105. package/src/core/help.js.map +1 -1
  106. package/src/core/program.d.ts +1 -1
  107. package/src/core/program.js +56 -12
  108. package/src/core/program.js.map +1 -1
  109. package/src/core/project-commands.d.ts +44 -0
  110. package/src/core/project-commands.js +216 -0
  111. package/src/core/project-commands.js.map +1 -0
  112. package/src/core/tsconfig.d.ts +20 -0
  113. package/src/core/tsconfig.js +41 -2
  114. 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>;
@@ -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
- const entry = await (0, fs_1.resolveEntry)(cwd, opts.entry);
23
- // Load .env and .env.local files before starting the server
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
- // Only use shell on Windows where npx.cmd requires it; on Unix, direct spawn
31
- // allows proper SIGINT propagation without intermediate shell processes
32
- const useShell = process.platform === 'win32';
33
- const app = (0, child_process_1.spawn)('npx', ['-y', 'tsx', '--conditions', 'node', '--watch', entry], {
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
- shell: useShell,
179
+ env: childEnv,
36
180
  });
37
- const checker = (0, child_process_1.spawn)('npx', ['-y', 'tsc', '--noEmit', '--pretty', '--watch'], {
181
+ const checker = (0, child_process_1.spawn)(npxCmd, ['-y', 'tsc', '--noEmit', '--pretty', '--watch'], {
38
182
  stdio: 'inherit',
39
- shell: useShell,
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