@solidrt/cli 0.0.26 → 0.0.28
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/package.json +10 -7
- package/scaffold/AGENTS.md +138 -9
- package/scaffold/package.json +4 -4
- package/scaffold/templates/components/index.tsx +15 -0
- package/scaffold/templates/components/template.json +4 -0
- package/scaffold/templates/default/index.tsx +7 -9
- package/scaffold/templates/default/template.json +4 -0
- package/scaffold/templates/gallery/template.json +4 -0
- package/scaffold/templates/minimal/index.tsx +5 -2
- package/scaffold/templates/minimal/template.json +4 -0
- package/scaffold/tsconfig.json +1 -0
- package/server/control.ts +132 -16
- package/server/main.ts +13 -4
- package/server/rebuild.ts +54 -0
- package/server/remap.ts +47 -0
- package/server/state.ts +24 -1
- package/src/args.ts +4 -0
- package/src/bundle-cli.ts +12 -0
- package/src/bundler.ts +69 -23
- package/src/commands/bundle.ts +4 -7
- package/src/commands/check.ts +94 -0
- package/src/commands/init.ts +53 -14
- package/src/commands/mcp.ts +205 -66
- package/src/commands/server.ts +4 -3
- package/src/dev-server.ts +15 -2
- package/src/main.ts +3 -0
- package/src/prompt.ts +19 -11
- package/src/repl.ts +17 -7
- package/src/util.ts +3 -0
- package/src/watcher.ts +4 -3
package/src/commands/mcp.ts
CHANGED
|
@@ -5,16 +5,26 @@
|
|
|
5
5
|
//
|
|
6
6
|
// stdout is the JSON-RPC channel; nothing here may print to it.
|
|
7
7
|
|
|
8
|
+
import { z } from "zod"
|
|
9
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
11
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
|
|
12
|
+
import { resolve } from "node:path"
|
|
8
13
|
import { DEV_PORT } from "../dev-server"
|
|
9
14
|
|
|
10
15
|
const CONTROL_BASE = `http://127.0.0.1:${DEV_PORT}/__control__`
|
|
11
16
|
|
|
12
17
|
type ControlResult = { ok: true; body: any } | { ok: false; message: string }
|
|
13
18
|
|
|
14
|
-
async function control(path: string): Promise<ControlResult> {
|
|
19
|
+
async function control(path: string, method: "GET" | "POST" = "GET", payload?: unknown): Promise<ControlResult> {
|
|
15
20
|
let resp
|
|
16
21
|
try {
|
|
17
|
-
|
|
22
|
+
let init: RequestInit = { method }
|
|
23
|
+
if (payload !== undefined) {
|
|
24
|
+
init.headers = { "content-type": "application/json" }
|
|
25
|
+
init.body = JSON.stringify(payload)
|
|
26
|
+
}
|
|
27
|
+
resp = await fetch(CONTROL_BASE + path, init)
|
|
18
28
|
} catch {
|
|
19
29
|
return {
|
|
20
30
|
ok: false,
|
|
@@ -29,70 +39,147 @@ async function control(path: string): Promise<ControlResult> {
|
|
|
29
39
|
return { ok: true, body }
|
|
30
40
|
}
|
|
31
41
|
|
|
32
|
-
let
|
|
42
|
+
let CLIENT_ARG = z
|
|
43
|
+
.number()
|
|
44
|
+
.int()
|
|
45
|
+
.describe("Client id from list_clients (default: the only connected client)")
|
|
46
|
+
.optional()
|
|
47
|
+
|
|
48
|
+
let SAVE_TO_ARG = z
|
|
49
|
+
.string()
|
|
50
|
+
.describe(
|
|
51
|
+
"Also write the PNG to this file path (relative paths resolve against the project root; parent directories are created)",
|
|
52
|
+
)
|
|
53
|
+
.optional()
|
|
54
|
+
|
|
55
|
+
let TOOLS: { name: string; description: string; inputSchema: Record<string, z.ZodTypeAny> }[] = [
|
|
33
56
|
{
|
|
34
57
|
name: "list_clients",
|
|
35
58
|
description:
|
|
36
|
-
"List the app clients connected to the SolidRT dev server. Each entry has id (pass it as `client` to the other tools), platform, runtime version, and the capability names compiled into that client's runtime.",
|
|
37
|
-
inputSchema: {
|
|
59
|
+
"List the app clients connected to the SolidRT dev server. Returns `generation` (identity of this server run: client ids and log cursors are only valid within one generation, so if it changed since your last call, re-fetch ids and cursors) and `clients`. Each entry has id (pass it as `client` to the other tools), platform, runtime version (git describe; a -dirty suffix means the binary was built from uncommitted engine changes), build profile (debug/release), and the capability names compiled into that client's runtime. Use version/profile to check whether a connected binary contains a given engine change before debugging against it.",
|
|
60
|
+
inputSchema: {},
|
|
38
61
|
},
|
|
39
62
|
{
|
|
40
63
|
name: "get_logs",
|
|
41
64
|
description:
|
|
42
|
-
"Read console output and runtime errors from connected app clients. Returns entries (seq, at, client, level, text) plus `latest
|
|
65
|
+
"Read console output and runtime errors from connected app clients. Returns entries (seq, at, client, level, text; consecutive identical entries are collapsed into one with a `repeats` count and the run's last seq), plus `latest` (the newest seq) and `generation` (identity of this server run; if it changed since your last call, your seq cursor and client ids are stale - start over from since 0). Pass `since` (a seq or `latest` from a previous call) to only get newer entries; pass `wait_ms` to hold the call until new output arrives, e.g. right after triggering a reload; pass `level`/`contains` to filter, e.g. level \"error\" to skip chatty output.",
|
|
43
66
|
inputSchema: {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
67
|
+
since: z
|
|
68
|
+
.number()
|
|
69
|
+
.int()
|
|
70
|
+
.describe("Only return entries with seq greater than this (default 0: the whole buffer)")
|
|
71
|
+
.optional(),
|
|
72
|
+
wait_ms: z
|
|
73
|
+
.number()
|
|
74
|
+
.int()
|
|
75
|
+
.describe("If nothing matches newer than `since`, wait up to this many milliseconds for new output (max 30000)")
|
|
76
|
+
.optional(),
|
|
77
|
+
level: z
|
|
78
|
+
.string()
|
|
79
|
+
.describe('Only return entries with one of these levels, comma-separated (e.g. "error" or "error,warn")')
|
|
80
|
+
.optional(),
|
|
81
|
+
contains: z
|
|
82
|
+
.string()
|
|
83
|
+
.describe("Only return entries whose text contains this substring (case-insensitive)")
|
|
84
|
+
.optional(),
|
|
56
85
|
},
|
|
57
86
|
},
|
|
58
87
|
{
|
|
59
88
|
name: "get_stats",
|
|
60
89
|
description:
|
|
61
|
-
"Performance statistics from a running app client: fps, CPU%, memory, smoothed JS/layout/paint/hover frame times (ms), setProperty writes per frame, demand-gate reuse/skip counts per second, and live texture count.",
|
|
62
|
-
inputSchema: {
|
|
63
|
-
type: "object",
|
|
64
|
-
properties: {
|
|
65
|
-
client: { type: "integer", description: "Client id from list_clients (default: the only connected client)" },
|
|
66
|
-
},
|
|
67
|
-
additionalProperties: false,
|
|
68
|
-
},
|
|
90
|
+
"Performance statistics from a running app client: fps, CPU%, memory, smoothed JS/layout/paint/hover frame times (ms), setProperty writes per frame, demand-gate reuse/skip counts per second, and live texture count. Layout-activity counters cover the last full rebuild, raw: nodes (live node count), measureCalls (text measures; mostly cache hits, cheap), paraShapes (paragraphs actually shaped; the expensive signal - high layoutMs with near-zero paraShapes means the cost is not text shaping), dirtiedNodes (layout caches cleared by property writes since the previous rebuild; how much of the tree a write burst invalidated), cacheGets/cacheHits (layout-cache lookups during the rebuild; a hit on a container skips its whole subtree, so a healthy incremental rebuild shows a near-100% hit rate - a low rate at scale means the layout cache is being defeated).",
|
|
91
|
+
inputSchema: { client: CLIENT_ARG },
|
|
69
92
|
},
|
|
70
93
|
{
|
|
71
94
|
name: "get_render_tree",
|
|
72
95
|
description:
|
|
73
|
-
"Snapshot of a running app client's render tree: node id, kind, window-relative box (x, y, width, height), text content, and children. Use it to verify what the app actually rendered and where.",
|
|
96
|
+
"Snapshot of a running app client's render tree: node id, kind, window-relative box (x, y, width, height), text content, and children. Use it to verify what the app actually rendered and where. Whole trees get large: prefer `query` to find nodes by kind or text first, then `root` + `depth` to inspect the region around a match. A node whose children were cut off by `depth` carries `childCount`; descend into it with root=<its id>.",
|
|
74
97
|
inputSchema: {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
98
|
+
root: z
|
|
99
|
+
.number()
|
|
100
|
+
.int()
|
|
101
|
+
.describe("Only return the subtree under this node id (default: the whole tree)")
|
|
102
|
+
.optional(),
|
|
103
|
+
depth: z
|
|
104
|
+
.number()
|
|
105
|
+
.int()
|
|
106
|
+
.describe("Levels of children to include below the root (default: unlimited; 0 = the root node only)")
|
|
107
|
+
.optional(),
|
|
108
|
+
query: z
|
|
109
|
+
.string()
|
|
110
|
+
.describe(
|
|
111
|
+
"Search instead of snapshot: return `matches`, nodes whose kind equals or text contains this " +
|
|
112
|
+
"(case-insensitive), each with a `path` of ancestor ids from the search root. Combine with `root` to " +
|
|
113
|
+
"scope the search; `depth` is ignored.",
|
|
114
|
+
)
|
|
115
|
+
.optional(),
|
|
116
|
+
client: CLIENT_ARG,
|
|
80
117
|
},
|
|
81
118
|
},
|
|
82
119
|
{
|
|
83
120
|
name: "get_snapshot",
|
|
84
121
|
description:
|
|
85
|
-
"Capture a PNG image of any node in a running app client's render tree, by node id (get ids from get_render_tree). Returns the rendered pixels of that node's subtree, so you can see what the app actually drew. The node must be currently mounted and have a non-zero layout box.",
|
|
122
|
+
"Capture a PNG image of any node in a running app client's render tree, by node id (get ids from get_render_tree). Returns the rendered pixels of that node's subtree, so you can see what the app actually drew. The node must be currently mounted and have a non-zero layout box. Works on an idle client (the capture requests its own frame); a timeout means the client's JS thread is busy or wedged, not that the app is idle.",
|
|
123
|
+
inputSchema: {
|
|
124
|
+
nodeId: z.number().int().describe("Id of the node to capture, from get_render_tree"),
|
|
125
|
+
save_to: SAVE_TO_ARG,
|
|
126
|
+
client: CLIENT_ARG,
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "get_gpu_resources",
|
|
131
|
+
description:
|
|
132
|
+
"Inventory of a running app client's GPU resources: textures (id, size, whether a shader renders into it), vertex buffers (id, byteLength), and shader/pipeline targets (output textureId, kind, bufferId, topology, drawCount, depth, attribute layout, bound sampler texture ids, last-applied uniform values). Use it when the render tree is just a <texture> leaf and the interesting state lives behind it; follow up with get_texture or get_buffer to see contents.",
|
|
133
|
+
inputSchema: { client: CLIENT_ARG },
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: "get_texture",
|
|
137
|
+
description:
|
|
138
|
+
"Read back any GPU texture from a running app client as a PNG, by texture id (from get_gpu_resources, or the id returned by createImage/createShader/createPipeline in app code). Works on sampled textures (atlases, data textures) and shader/pipeline render targets alike, without needing a frame. Pass x/y/width/height to crop, e.g. one tile of an atlas.",
|
|
86
139
|
inputSchema: {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
140
|
+
id: z.number().int().describe("Texture id, from get_gpu_resources"),
|
|
141
|
+
x: z.number().int().describe("Crop rect left edge in texture pixels (requires y, width, height)").optional(),
|
|
142
|
+
y: z.number().int().describe("Crop rect top edge in texture pixels").optional(),
|
|
143
|
+
width: z.number().int().describe("Crop rect width in texture pixels").optional(),
|
|
144
|
+
height: z.number().int().describe("Crop rect height in texture pixels").optional(),
|
|
145
|
+
save_to: SAVE_TO_ARG,
|
|
146
|
+
client: CLIENT_ARG,
|
|
94
147
|
},
|
|
95
148
|
},
|
|
149
|
+
{
|
|
150
|
+
name: "get_buffer",
|
|
151
|
+
description:
|
|
152
|
+
"Read back part of a GPU vertex buffer from a running app client, decoded to numbers. Returns values plus byteOffset/byteLength actually read and bufferByteLength. Reads are capped at 64 KiB per call; page through larger buffers with offset. Use it to verify geometry after a writeBuffer, e.g. the dynamic sprite tail of a vertex buffer.",
|
|
153
|
+
inputSchema: {
|
|
154
|
+
id: z.number().int().describe("Buffer id, from get_gpu_resources"),
|
|
155
|
+
offset: z.number().int().describe("Byte offset to start reading at (default 0)").optional(),
|
|
156
|
+
length: z.number().int().describe("Number of values to read (default: the rest of the buffer, capped)").optional(),
|
|
157
|
+
as: z.enum(["f32", "u16", "u8"]).describe("How to decode the bytes (default f32)").optional(),
|
|
158
|
+
client: CLIENT_ARG,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: "list_debug",
|
|
163
|
+
description:
|
|
164
|
+
"List the debug commands the running app registered via registerDebug from srt:dev. Returns the command names; call one with call_debug. Empty when the app registered none.",
|
|
165
|
+
inputSchema: { client: CLIENT_ARG },
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "call_debug",
|
|
169
|
+
description:
|
|
170
|
+
"Call a debug command the running app registered via registerDebug from srt:dev, by name (from list_debug). `args` is passed to the command's function as its single argument; the command's return value comes back JSON-serialized (undefined as null). Commands run synchronously on the app's JS thread - use them to query app state (positions, counters, internal flags) or trigger app behavior (toggle a mode, open a door) without touching its real input handling.",
|
|
171
|
+
inputSchema: {
|
|
172
|
+
name: z.string().describe("Debug command name, from list_debug"),
|
|
173
|
+
args: z.record(z.string(), z.any()).describe("Argument object passed to the command (default: none)").optional(),
|
|
174
|
+
client: CLIENT_ARG,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: "reload",
|
|
179
|
+
description:
|
|
180
|
+
"Rebuild the app from source and push it to every connected client. Call this after editing the app's .tsx/.jsx source to apply the changes: it bundles once and reloads all clients, so a burst of edits becomes a single explicit reload. Returns the number of clients reloaded, or a build error if the source failed to compile. Follow with get_logs to see runtime output from the reloaded app.",
|
|
181
|
+
inputSchema: {},
|
|
182
|
+
},
|
|
96
183
|
]
|
|
97
184
|
|
|
98
185
|
function clientParam(args: any): string {
|
|
@@ -107,50 +194,102 @@ async function callTool(name: string, args: any): Promise<ControlResult> {
|
|
|
107
194
|
let params = new URLSearchParams()
|
|
108
195
|
if (typeof args?.since === "number") params.set("since", String(args.since))
|
|
109
196
|
if (typeof args?.wait_ms === "number") params.set("wait", String(args.wait_ms))
|
|
197
|
+
if (typeof args?.level === "string") params.set("level", args.level)
|
|
198
|
+
if (typeof args?.contains === "string") params.set("contains", args.contains)
|
|
110
199
|
let qs = params.toString()
|
|
111
200
|
return control(qs ? `/logs?${qs}` : "/logs")
|
|
112
201
|
}
|
|
113
202
|
case "get_stats":
|
|
114
203
|
return control(`/stats${clientParam(args)}`)
|
|
115
|
-
case "get_render_tree":
|
|
116
|
-
|
|
204
|
+
case "get_render_tree": {
|
|
205
|
+
let params = new URLSearchParams()
|
|
206
|
+
if (typeof args?.root === "number") params.set("root", String(args.root))
|
|
207
|
+
if (typeof args?.depth === "number") params.set("depth", String(args.depth))
|
|
208
|
+
if (typeof args?.query === "string") params.set("query", args.query)
|
|
209
|
+
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
210
|
+
let qs = params.toString()
|
|
211
|
+
return control(qs ? `/tree?${qs}` : "/tree")
|
|
212
|
+
}
|
|
213
|
+
case "reload":
|
|
214
|
+
return control("/reload", "POST")
|
|
117
215
|
case "get_snapshot": {
|
|
118
216
|
if (typeof args?.nodeId !== "number") return { ok: false, message: "get_snapshot requires a numeric nodeId" }
|
|
119
217
|
let params = new URLSearchParams({ node: String(args.nodeId) })
|
|
120
218
|
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
121
219
|
return control(`/snapshot?${params.toString()}`)
|
|
122
220
|
}
|
|
221
|
+
case "get_gpu_resources":
|
|
222
|
+
return control(`/gpu${clientParam(args)}`)
|
|
223
|
+
case "list_debug":
|
|
224
|
+
return control(`/debug${clientParam(args)}`)
|
|
225
|
+
case "call_debug": {
|
|
226
|
+
if (typeof args?.name !== "string") return { ok: false, message: "call_debug requires a command name" }
|
|
227
|
+
let params = new URLSearchParams({ name: args.name })
|
|
228
|
+
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
229
|
+
return control(`/debug?${params.toString()}`, "POST", args?.args)
|
|
230
|
+
}
|
|
231
|
+
case "get_texture": {
|
|
232
|
+
if (typeof args?.id !== "number") return { ok: false, message: "get_texture requires a numeric id" }
|
|
233
|
+
let params = new URLSearchParams({ id: String(args.id) })
|
|
234
|
+
for (let key of ["x", "y", "width", "height"]) {
|
|
235
|
+
if (typeof args?.[key] === "number") params.set(key, String(args[key]))
|
|
236
|
+
}
|
|
237
|
+
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
238
|
+
return control(`/texture?${params.toString()}`)
|
|
239
|
+
}
|
|
240
|
+
case "get_buffer": {
|
|
241
|
+
if (typeof args?.id !== "number") return { ok: false, message: "get_buffer requires a numeric id" }
|
|
242
|
+
let params = new URLSearchParams({ id: String(args.id) })
|
|
243
|
+
if (typeof args?.offset === "number") params.set("offset", String(args.offset))
|
|
244
|
+
if (typeof args?.length === "number") params.set("length", String(args.length))
|
|
245
|
+
if (typeof args?.as === "string") params.set("as", args.as)
|
|
246
|
+
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
247
|
+
return control(`/buffer?${params.toString()}`)
|
|
248
|
+
}
|
|
123
249
|
default:
|
|
124
250
|
return { ok: false, message: `Unknown tool: ${name}` }
|
|
125
251
|
}
|
|
126
252
|
}
|
|
127
253
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
return {
|
|
146
|
-
content: [
|
|
147
|
-
{ type: "image", data: pngBase64, mimeType: "image/png" },
|
|
148
|
-
{ type: "text", text: `Captured node snapshot: ${width}x${height} px` },
|
|
149
|
-
],
|
|
254
|
+
async function toContent(name: string, result: ControlResult, args?: any): Promise<CallToolResult> {
|
|
255
|
+
if (!result.ok) return { content: [{ type: "text", text: result.message }], isError: true }
|
|
256
|
+
if (name === "get_snapshot" || name === "get_texture") {
|
|
257
|
+
let { pngBase64, width, height } = result.body
|
|
258
|
+
let label = name === "get_snapshot" ? "Captured node snapshot" : "Texture contents"
|
|
259
|
+
let text = `${label}: ${width}x${height} px`
|
|
260
|
+
// save_to is handled here in the bridge, not by the dev server: this
|
|
261
|
+
// process runs on the caller's machine, so the path lands where the
|
|
262
|
+
// agent expects it. The image content block alone is a dead end for
|
|
263
|
+
// that - the model sees the pixels but never the bytes.
|
|
264
|
+
if (typeof args?.save_to === "string") {
|
|
265
|
+
let path = resolve(args.save_to)
|
|
266
|
+
try {
|
|
267
|
+
await Bun.write(path, Buffer.from(pngBase64, "base64"))
|
|
268
|
+
text += `, saved to ${path}`
|
|
269
|
+
} catch (e) {
|
|
270
|
+
return { content: [{ type: "text", text: `Captured, but saving to ${path} failed: ${e}` }], isError: true }
|
|
150
271
|
}
|
|
151
272
|
}
|
|
152
|
-
return {
|
|
153
|
-
|
|
273
|
+
return {
|
|
274
|
+
content: [
|
|
275
|
+
{ type: "image", data: pngBase64, mimeType: "image/png" },
|
|
276
|
+
{ type: "text", text },
|
|
277
|
+
],
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return { content: [{ type: "text", text: JSON.stringify(result.body, null, 2) }] }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function runMcpCommand() {
|
|
284
|
+
let server = new McpServer({ name: "solidrt", version: "0.0.0" })
|
|
285
|
+
|
|
286
|
+
for (let tool of TOOLS) {
|
|
287
|
+
server.registerTool(
|
|
288
|
+
tool.name,
|
|
289
|
+
{ description: tool.description, inputSchema: tool.inputSchema },
|
|
290
|
+
async (args: any) => toContent(tool.name, await callTool(tool.name, args ?? {}), args),
|
|
291
|
+
)
|
|
292
|
+
}
|
|
154
293
|
|
|
155
294
|
// The stdin read keeps the process alive; it exits when the agent host
|
|
156
295
|
// closes the pipe.
|
package/src/commands/server.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import pkg from "../../package.json"
|
|
2
2
|
import { source, isSource, isPrebuilt, values } from "../args"
|
|
3
3
|
import { state, shutdown } from "../util"
|
|
4
|
-
import { bundle
|
|
4
|
+
import { bundle } from "../bundler"
|
|
5
5
|
import { startServer, buildReload, sendReload, showBuildFailure } from "../dev-server"
|
|
6
6
|
import { startRepl } from "../repl"
|
|
7
7
|
import { startWatcher } from "../watcher"
|
|
@@ -27,8 +27,9 @@ export async function runServerCommand() {
|
|
|
27
27
|
if (source && isSource) {
|
|
28
28
|
let initialResult = await bundle()
|
|
29
29
|
if (initialResult) {
|
|
30
|
-
state.currentCode =
|
|
31
|
-
|
|
30
|
+
state.currentCode = initialResult.code
|
|
31
|
+
state.currentMap = initialResult.map
|
|
32
|
+
await sendReload(buildReload({ code: state.currentCode }), { latch: true, map: state.currentMap })
|
|
32
33
|
} else {
|
|
33
34
|
await showBuildFailure()
|
|
34
35
|
}
|
package/src/dev-server.ts
CHANGED
|
@@ -30,9 +30,14 @@ async function post(path: string, body: object) {
|
|
|
30
30
|
* Send a client-protocol message through the server: to the given client ids,
|
|
31
31
|
* or to every client when omitted. `latch` keeps the message for late-joining
|
|
32
32
|
* clients (code reloads latch, one-shot bytecode loads do not); `sourceDir`
|
|
33
|
-
* moves the server's file-serving root (repl `load`)
|
|
33
|
+
* moves the server's file-serving root (repl `load`); `map` is the bundle's
|
|
34
|
+
* sourcemap, kept server-side for stack-trace remapping (omitting it clears
|
|
35
|
+
* the server's map, so a mapless reload never remaps against a stale one).
|
|
34
36
|
*/
|
|
35
|
-
export async function sendReload(
|
|
37
|
+
export async function sendReload(
|
|
38
|
+
message: object,
|
|
39
|
+
opts: { clients?: number[]; latch?: boolean; sourceDir?: string; entry?: string; map?: string | null } = {},
|
|
40
|
+
) {
|
|
36
41
|
await post("/reload", { message, ...opts })
|
|
37
42
|
}
|
|
38
43
|
|
|
@@ -125,12 +130,20 @@ export async function startServer() {
|
|
|
125
130
|
// computes it and passes it down.
|
|
126
131
|
state.serverUrl = `${address}:${DEV_PORT}`
|
|
127
132
|
|
|
133
|
+
// How the server rebuilds on an MCP-triggered reload: it cannot call
|
|
134
|
+
// Bun.build itself (it is a flux process), so it spawns srt's own bun on the
|
|
135
|
+
// standalone bundle-cli entry. Both paths are known here at spawn time.
|
|
136
|
+
let bundleCli = fileURLToPath(new URL("./bundle-cli.ts", import.meta.url))
|
|
137
|
+
|
|
128
138
|
let config = {
|
|
129
139
|
port: DEV_PORT,
|
|
130
140
|
sourceDir: state.sourceDir,
|
|
131
141
|
address,
|
|
132
142
|
proxyFiles: values["proxy-files"],
|
|
133
143
|
proxyHttp: values["proxy-http"],
|
|
144
|
+
entry: state.source,
|
|
145
|
+
minify: values.minify,
|
|
146
|
+
bundlerCmd: [process.execPath, bundleCli],
|
|
134
147
|
cache: values["proxy-http"],
|
|
135
148
|
cacheDir: process.cwd(),
|
|
136
149
|
capture: state.capture,
|
package/src/main.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { values, command, validateArgs, printUsage } from "./args"
|
|
4
4
|
import { runInitCommand } from "./commands/init"
|
|
5
5
|
import { runBundleCommand } from "./commands/bundle"
|
|
6
|
+
import { runCheckCommand } from "./commands/check"
|
|
6
7
|
import { runPackCommand } from "./commands/pack"
|
|
7
8
|
import { runRenderCommand } from "./commands/render"
|
|
8
9
|
import { runServerCommand } from "./commands/server"
|
|
@@ -41,6 +42,8 @@ if (command === "init") {
|
|
|
41
42
|
await runInitCommand()
|
|
42
43
|
} else if (command === "bundle") {
|
|
43
44
|
await runBundleCommand()
|
|
45
|
+
} else if (command === "check") {
|
|
46
|
+
await runCheckCommand()
|
|
44
47
|
} else if (command === "pack") {
|
|
45
48
|
await runPackCommand()
|
|
46
49
|
} else if (command === "render") {
|
package/src/prompt.ts
CHANGED
|
@@ -15,16 +15,24 @@ export function text(message: string, def = ""): Promise<string> {
|
|
|
15
15
|
})
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
export interface SelectOption {
|
|
19
|
+
label: string
|
|
20
|
+
value: string
|
|
21
|
+
}
|
|
22
|
+
|
|
18
23
|
// Minimal arrow-key single-select prompt, built on node:readline (same
|
|
19
24
|
// dependency-free approach as repl.ts). Renders the option list, moves the
|
|
20
|
-
// highlight on up/down, resolves the chosen value on enter.
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
25
|
+
// highlight on up/down, resolves the chosen value on enter. Options are plain
|
|
26
|
+
// strings or { label, value } pairs when the display text differs from the
|
|
27
|
+
// resolved value. Callers guard on process.stdin.isTTY; a non-TTY stdin here
|
|
28
|
+
// resolves the first option rather than hanging on input that will never
|
|
29
|
+
// arrive.
|
|
30
|
+
export function select(message: string, options: Array<string | SelectOption>): Promise<string> {
|
|
31
|
+
let items = options.map((o) => (typeof o === "string" ? { label: o, value: o } : o))
|
|
24
32
|
return new Promise((resolve) => {
|
|
25
33
|
let input = process.stdin
|
|
26
34
|
let output = process.stdout
|
|
27
|
-
if (!input.isTTY) return resolve(
|
|
35
|
+
if (!input.isTTY) return resolve(items[0]!.value)
|
|
28
36
|
|
|
29
37
|
let selected = 0
|
|
30
38
|
emitKeypressEvents(input)
|
|
@@ -34,13 +42,13 @@ export function select(message: string, options: string[]): Promise<string> {
|
|
|
34
42
|
let render = (first = false) => {
|
|
35
43
|
// After the first paint the cursor sits below the block; move it back up
|
|
36
44
|
// to the message line so the list redraws in place.
|
|
37
|
-
if (!first) output.write(`\x1b[${
|
|
45
|
+
if (!first) output.write(`\x1b[${items.length + 1}A`)
|
|
38
46
|
output.write(`\x1b[K? ${message}\n`)
|
|
39
|
-
for (let i = 0; i <
|
|
47
|
+
for (let i = 0; i < items.length; i++) {
|
|
40
48
|
let active = i === selected
|
|
41
49
|
let pointer = active ? "\x1b[36m> " : " "
|
|
42
50
|
let reset = active ? "\x1b[0m" : ""
|
|
43
|
-
output.write(`\x1b[K${pointer}${
|
|
51
|
+
output.write(`\x1b[K${pointer}${items[i]!.label}${reset}\n`)
|
|
44
52
|
}
|
|
45
53
|
}
|
|
46
54
|
|
|
@@ -53,15 +61,15 @@ export function select(message: string, options: string[]): Promise<string> {
|
|
|
53
61
|
let onKey = (_str: string, key: { name: string; ctrl: boolean } | undefined) => {
|
|
54
62
|
if (!key) return
|
|
55
63
|
if (key.name === "up") {
|
|
56
|
-
selected = (selected - 1 +
|
|
64
|
+
selected = (selected - 1 + items.length) % items.length
|
|
57
65
|
render()
|
|
58
66
|
} else if (key.name === "down") {
|
|
59
|
-
selected = (selected + 1) %
|
|
67
|
+
selected = (selected + 1) % items.length
|
|
60
68
|
render()
|
|
61
69
|
} else if (key.name === "return" || key.name === "enter") {
|
|
62
70
|
cleanup()
|
|
63
71
|
output.write("\n")
|
|
64
|
-
resolve(
|
|
72
|
+
resolve(items[selected]!.value)
|
|
65
73
|
} else if (key.ctrl && (key.name === "c" || key.name === "d")) {
|
|
66
74
|
cleanup()
|
|
67
75
|
output.write("\n")
|
package/src/repl.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { resolve, dirname } from "path"
|
|
|
3
3
|
import { readdirSync } from "node:fs"
|
|
4
4
|
import { state, print, printErr, shutdown } from "./util"
|
|
5
5
|
import { buildReload, getClients, sendReload, sendStop, sendStats, showBuildFailure } from "./dev-server"
|
|
6
|
-
import { bundle
|
|
6
|
+
import { bundle } from "./bundler"
|
|
7
7
|
import { startWatcher, stopWatcher } from "./watcher"
|
|
8
8
|
|
|
9
9
|
// Resolve repl client indexes ("0 2") against the server's client list,
|
|
@@ -27,6 +27,7 @@ async function cmdStop(args: string) {
|
|
|
27
27
|
if (!args) {
|
|
28
28
|
stopWatcher()
|
|
29
29
|
state.currentCode = null
|
|
30
|
+
state.currentMap = null
|
|
30
31
|
state.source = undefined
|
|
31
32
|
await sendStop()
|
|
32
33
|
print("[cli] Sent stop to all clients")
|
|
@@ -47,17 +48,18 @@ async function cmdReload(args: string) {
|
|
|
47
48
|
await showBuildFailure()
|
|
48
49
|
return
|
|
49
50
|
}
|
|
50
|
-
state.currentCode =
|
|
51
|
+
state.currentCode = result.code
|
|
52
|
+
state.currentMap = result.map
|
|
51
53
|
}
|
|
52
54
|
let msg = buildReload({ code: state.currentCode })
|
|
53
55
|
if (!args) {
|
|
54
|
-
await sendReload(msg, { latch: true })
|
|
56
|
+
await sendReload(msg, { latch: true, map: state.currentMap })
|
|
55
57
|
print("[cli] Sent reload to all clients")
|
|
56
58
|
return
|
|
57
59
|
}
|
|
58
60
|
let ids = await indexesToIds(args)
|
|
59
61
|
if (ids.length) {
|
|
60
|
-
await sendReload(msg, { clients: ids })
|
|
62
|
+
await sendReload(msg, { clients: ids, map: state.currentMap })
|
|
61
63
|
print(`[cli] Sent reload to client(s) ${ids.join(", ")}`)
|
|
62
64
|
}
|
|
63
65
|
}
|
|
@@ -102,9 +104,11 @@ async function cmdLoad(file: string) {
|
|
|
102
104
|
printErr("[cli] Build failed")
|
|
103
105
|
return
|
|
104
106
|
}
|
|
105
|
-
state.currentCode =
|
|
107
|
+
state.currentCode = result.code
|
|
108
|
+
state.currentMap = result.map
|
|
106
109
|
} else if (file.endsWith(".srt.js")) {
|
|
107
110
|
state.currentCode = await Bun.file(path).text()
|
|
111
|
+
state.currentMap = null
|
|
108
112
|
} else if (file.endsWith(".srt.bin")) {
|
|
109
113
|
let bytes = await Bun.file(path).arrayBuffer()
|
|
110
114
|
// One-shot: bytecode loads are pushed but not latched for late joiners.
|
|
@@ -118,8 +122,14 @@ async function cmdLoad(file: string) {
|
|
|
118
122
|
state.source = path
|
|
119
123
|
state.sourceDir = dirname(path)
|
|
120
124
|
startWatcher()
|
|
121
|
-
// The load also moves the server's file-serving root to the new source dir
|
|
122
|
-
|
|
125
|
+
// The load also moves the server's file-serving root to the new source dir,
|
|
126
|
+
// and its rebuild entry to the new file (for a later MCP reload).
|
|
127
|
+
await sendReload(buildReload({ code: state.currentCode }), {
|
|
128
|
+
latch: true,
|
|
129
|
+
sourceDir: state.sourceDir,
|
|
130
|
+
entry: file.endsWith(".tsx") ? path : undefined,
|
|
131
|
+
map: state.currentMap,
|
|
132
|
+
})
|
|
123
133
|
print(`[cli] Loaded ${file}`)
|
|
124
134
|
}
|
|
125
135
|
|
package/src/util.ts
CHANGED
|
@@ -8,6 +8,9 @@ export let state = {
|
|
|
8
8
|
// What srt believes the current bundle is; the server process keeps its own
|
|
9
9
|
// latched copy for late-joining clients (see packages/cli/server/).
|
|
10
10
|
currentCode: null as string | null,
|
|
11
|
+
// The bundle's composed sourcemap (dev builds), sent to the server alongside
|
|
12
|
+
// reloads so it can remap logged stack traces to .tsx positions.
|
|
13
|
+
currentMap: null as string | null,
|
|
11
14
|
source: undefined as string | undefined,
|
|
12
15
|
sourceDir: process.cwd(),
|
|
13
16
|
child: null as ReturnType<typeof Bun.spawn> | null,
|
package/src/watcher.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { watch } from "node:fs"
|
|
|
2
2
|
import { resolve, dirname } from "path"
|
|
3
3
|
import { state, print, printErr } from "./util"
|
|
4
4
|
import { buildReload, sendReload, showBuildFailure } from "./dev-server"
|
|
5
|
-
import { bundle
|
|
5
|
+
import { bundle } from "./bundler"
|
|
6
6
|
|
|
7
7
|
let currentWatcher: ReturnType<typeof watch> | null = null
|
|
8
8
|
|
|
@@ -31,7 +31,8 @@ export function startWatcher() {
|
|
|
31
31
|
await showBuildFailure()
|
|
32
32
|
return
|
|
33
33
|
}
|
|
34
|
-
state.currentCode =
|
|
35
|
-
|
|
34
|
+
state.currentCode = result.code
|
|
35
|
+
state.currentMap = result.map
|
|
36
|
+
await sendReload(buildReload({ code: state.currentCode }), { latch: true, map: state.currentMap })
|
|
36
37
|
})
|
|
37
38
|
}
|