@solidrt/cli 0.0.48 → 0.0.50
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/AGENTS.md +34 -3
- package/package.json +8 -7
- package/scaffold/AGENTS.md +48 -4
- package/scaffold/package.json +5 -4
- package/server/main.ts +14 -8
- package/server/rebuild.ts +10 -2
- package/server/state.ts +1 -1
- package/server/tunnel.ts +5 -4
- package/src/args.ts +59 -24
- package/src/bundle-cli.ts +3 -2
- package/src/bundler.ts +127 -28
- package/src/commands/check.ts +6 -2
- package/src/commands/client.ts +16 -8
- package/src/commands/init.ts +59 -75
- package/src/commands/mcp.ts +143 -4
- package/src/commands/pack.ts +6 -2
- package/src/commands/server.ts +4 -1
- package/src/dev-dir.ts +25 -0
- package/src/dev-server.ts +46 -13
- package/src/pack-folder.ts +24 -3
- package/src/packer.ts +36 -3
- package/src/project.ts +9 -1
- package/src/prompt.ts +46 -72
- package/src/repl.ts +14 -6
- package/scaffold/templates/components/template.json +0 -4
- package/scaffold/templates/default/template.json +0 -4
package/src/bundler.ts
CHANGED
|
@@ -4,11 +4,11 @@ import ts from "@babel/preset-typescript"
|
|
|
4
4
|
import remapping from "@jridgewell/remapping"
|
|
5
5
|
import solid from "babel-preset-solid"
|
|
6
6
|
import { type BunPlugin, type BuildArtifact } from "bun"
|
|
7
|
-
import { readFileSync } from "node:fs"
|
|
8
|
-
import { dirname, resolve as resolvePath } from "node:path"
|
|
7
|
+
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"
|
|
8
|
+
import { dirname, join, relative, resolve as resolvePath, sep } from "node:path"
|
|
9
9
|
import { values, source } from "./args"
|
|
10
10
|
import { state, print, requireBinary } from "./util"
|
|
11
|
-
import { buildManifest } from "./project"
|
|
11
|
+
import { buildManifest, manifestAssetFor } from "./project"
|
|
12
12
|
|
|
13
13
|
// Babel plugin: rewrite `import data from "./x" with { type: "binary" }` into an
|
|
14
14
|
// inline Uint8Array of the file's bytes, and `with { type: "text" }` into an
|
|
@@ -82,7 +82,10 @@ async function codeFromOutputs(outputs: BuildArtifact[]): Promise<string> {
|
|
|
82
82
|
// (node_modules) skips the babel detour and keeps Bun's native loaders.
|
|
83
83
|
// With `babelMaps`, each file's transform map (original -> babel output) is
|
|
84
84
|
// collected there, keyed by absolute path, for sourcemap composition later.
|
|
85
|
-
|
|
85
|
+
// `isolateEntry` is the one "use isolate" module this build may load (its own
|
|
86
|
+
// entry); loading any other one means a by-value import of an isolate module,
|
|
87
|
+
// which is a build error (see isolate modules below).
|
|
88
|
+
function solidPlugin(babelMaps?: Map<string, object>, isolateEntry?: string): BunPlugin {
|
|
86
89
|
return {
|
|
87
90
|
name: "bun-plugin-solid",
|
|
88
91
|
setup: (build) => {
|
|
@@ -90,6 +93,11 @@ function solidPlugin(babelMaps?: Map<string, object>): BunPlugin {
|
|
|
90
93
|
if (!/\.(js|ts)x$/.test(args.path) && args.path.includes("node_modules")) return
|
|
91
94
|
let file = Bun.file(args.path)
|
|
92
95
|
let code = await file.text()
|
|
96
|
+
if (args.path !== isolateEntry && hasIsolateDirective(code)) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`${args.path} is a "use isolate" module: import its types only (import type * as W from "./...") and call it through isolate() from flux:isolate`,
|
|
99
|
+
)
|
|
100
|
+
}
|
|
93
101
|
let transforms = await transformAsync(code, {
|
|
94
102
|
filename: args.path,
|
|
95
103
|
sourceMaps: !!babelMaps,
|
|
@@ -103,6 +111,53 @@ function solidPlugin(babelMaps?: Map<string, object>): BunPlugin {
|
|
|
103
111
|
}
|
|
104
112
|
}
|
|
105
113
|
|
|
114
|
+
// Isolate modules (okf/done/isolates-and-ports.md): a source file whose first
|
|
115
|
+
// statement is the "use isolate" directive is the entry of its own bundle,
|
|
116
|
+
// run by flux:isolate in a second runtime. Its id is its path relative to
|
|
117
|
+
// the source root (the entry's directory) without extension; the bundle
|
|
118
|
+
// travels as the manifest asset isolates/<id>.js (dev) or .bin (pack). The
|
|
119
|
+
// main build never loads such a module (only `import type` reaches it), so
|
|
120
|
+
// the set is found by scanning the tree rather than by following imports.
|
|
121
|
+
|
|
122
|
+
// The directive is the first statement: leading whitespace, comments and a
|
|
123
|
+
// shebang may precede it, nothing else.
|
|
124
|
+
let ISOLATE_DIRECTIVE = /^(?:#![^\n]*\n)?(?:\s|\/\/[^\n]*|\/\*[\s\S]*?\*\/)*(?:"use isolate"|'use isolate')\s*(?:;|\n|$)/
|
|
125
|
+
|
|
126
|
+
export function hasIsolateDirective(code: string): boolean {
|
|
127
|
+
return ISOLATE_DIRECTIVE.test(code)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let SKIP_DIRS = new Set(["node_modules", "dist"])
|
|
131
|
+
|
|
132
|
+
export type IsolateModule = { id: string; path: string }
|
|
133
|
+
|
|
134
|
+
/** Every "use isolate" module under `root`, in id order. */
|
|
135
|
+
export function findIsolateModules(root: string): IsolateModule[] {
|
|
136
|
+
let out: IsolateModule[] = []
|
|
137
|
+
let walk = (dir: string) => {
|
|
138
|
+
for (let entry of readdirSync(dir, { withFileTypes: true })) {
|
|
139
|
+
if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) continue
|
|
140
|
+
let abs = join(dir, entry.name)
|
|
141
|
+
if (entry.isDirectory()) {
|
|
142
|
+
walk(abs)
|
|
143
|
+
} else if (entry.isFile() && /\.(js|ts)x?$/.test(entry.name) && !entry.name.endsWith(".d.ts")) {
|
|
144
|
+
if (hasIsolateDirective(readFileSync(abs, "utf8"))) {
|
|
145
|
+
let id = relative(root, abs).split(sep).join("/").replace(/\.(js|ts)x?$/, "")
|
|
146
|
+
out.push({ id, path: abs })
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
walk(root)
|
|
152
|
+
out.sort((a, b) => (a.id < b.id ? -1 : 1))
|
|
153
|
+
return out
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The manifest asset path of an isolate bundle. */
|
|
157
|
+
export function isolateAssetPath(id: string, ext: "js" | "bin"): string {
|
|
158
|
+
return `isolates/${id}.${ext}`
|
|
159
|
+
}
|
|
160
|
+
|
|
106
161
|
export type BundleOptions = { entry: string; devBase?: string; dev: boolean; minify: boolean }
|
|
107
162
|
|
|
108
163
|
export type BundleResult = {
|
|
@@ -111,6 +166,8 @@ export type BundleResult = {
|
|
|
111
166
|
map: string | null
|
|
112
167
|
/** Version manifest JSON for this bundle; clients install pushes under its hash. */
|
|
113
168
|
manifest: string
|
|
169
|
+
/** The app's isolate bundles, one per "use isolate" module, in id order. */
|
|
170
|
+
isolates: { id: string; code: string }[]
|
|
114
171
|
}
|
|
115
172
|
|
|
116
173
|
// The pure bundle: every input is explicit, so it runs identically in the srt
|
|
@@ -130,32 +187,66 @@ export async function bundleWith(opts: BundleOptions): Promise<BundleResult | nu
|
|
|
130
187
|
}
|
|
131
188
|
if (opts.devBase) define.__SRT_DEV_BASE__ = opts.devBase
|
|
132
189
|
|
|
190
|
+
// One Bun.build per entry: the app, then each isolate module as its own
|
|
191
|
+
// self-contained bundle (splitting is off, so a helper both import gets
|
|
192
|
+
// duplicated rather than shared). Only the app's build gets a composed
|
|
193
|
+
// sourcemap for now.
|
|
194
|
+
let build = async (entry: string, babelMaps?: Map<string, object>, isolateEntry?: string) => {
|
|
195
|
+
let result = null
|
|
196
|
+
try {
|
|
197
|
+
result = await Bun.build({
|
|
198
|
+
entrypoints: [entry],
|
|
199
|
+
target: "browser",
|
|
200
|
+
format: "esm",
|
|
201
|
+
minify: opts.minify,
|
|
202
|
+
external: ["flux:*", "srt:*"],
|
|
203
|
+
define,
|
|
204
|
+
loader: { ".svg": "text" },
|
|
205
|
+
sourcemap: babelMaps ? "external" : "none",
|
|
206
|
+
plugins: [solidPlugin(babelMaps, isolateEntry)],
|
|
207
|
+
})
|
|
208
|
+
} catch (e) {
|
|
209
|
+
console.error("[cli] compile error:\n", e)
|
|
210
|
+
return null
|
|
211
|
+
}
|
|
212
|
+
if (!result.success) {
|
|
213
|
+
for (let msg of result.logs) console.error(msg)
|
|
214
|
+
return null
|
|
215
|
+
}
|
|
216
|
+
return result
|
|
217
|
+
}
|
|
218
|
+
|
|
133
219
|
let babelMaps = opts.dev ? new Map<string, object>() : undefined
|
|
134
|
-
let
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
loader: { ".svg": "text" },
|
|
144
|
-
sourcemap: opts.dev ? "external" : "none",
|
|
145
|
-
plugins: [solidPlugin(babelMaps)],
|
|
146
|
-
})
|
|
147
|
-
} catch (e) {
|
|
148
|
-
console.error("[cli] compile error:\n", e)
|
|
149
|
-
return null
|
|
220
|
+
let main = await build(opts.entry, babelMaps)
|
|
221
|
+
if (!main) return null
|
|
222
|
+
let code = await codeFromOutputs(main.outputs)
|
|
223
|
+
|
|
224
|
+
let isolates: { id: string; code: string }[] = []
|
|
225
|
+
for (let module of findIsolateModules(dirname(resolvePath(opts.entry)))) {
|
|
226
|
+
let result = await build(module.path, undefined, module.path)
|
|
227
|
+
if (!result) return null
|
|
228
|
+
isolates.push({ id: module.id, code: await codeFromOutputs(result.outputs) })
|
|
150
229
|
}
|
|
151
230
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
231
|
+
let extra = isolates.map((i) => manifestAssetFor(isolateAssetPath(i.id, "js"), Buffer.from(i.code, "utf8")))
|
|
232
|
+
return {
|
|
233
|
+
code,
|
|
234
|
+
map: await composeMap(main.outputs, babelMaps),
|
|
235
|
+
manifest: buildManifest(code, opts.entry, extra),
|
|
236
|
+
isolates,
|
|
155
237
|
}
|
|
238
|
+
}
|
|
156
239
|
|
|
157
|
-
|
|
158
|
-
|
|
240
|
+
// Write dev isolate bundles where the dev server serves /isolates/ from
|
|
241
|
+
// (<project>/.srt-data/isolates/<id>.js), so clients can fetch the manifest
|
|
242
|
+
// assets the bundle lists. Stale files from removed modules stay behind
|
|
243
|
+
// unlisted, which is harmless.
|
|
244
|
+
export function writeIsolates(dir: string, isolates: { id: string; code: string }[]) {
|
|
245
|
+
for (let i of isolates) {
|
|
246
|
+
let file = join(dir, `${i.id}.js`)
|
|
247
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
248
|
+
writeFileSync(file, i.code)
|
|
249
|
+
}
|
|
159
250
|
}
|
|
160
251
|
|
|
161
252
|
// Compose Bun's bundle map (babel output -> bundle) with the per-file Babel
|
|
@@ -185,7 +276,15 @@ export async function bundle(entry = source) {
|
|
|
185
276
|
let dev = !!devBase || values.dev
|
|
186
277
|
// Keep stdout clean when the bundle itself is written to stdout.
|
|
187
278
|
if (!values.stdout) print(`[cli] Bundling (${dev ? "development" : "production"})`)
|
|
188
|
-
|
|
279
|
+
let result = await bundleWith({ entry: entry!, devBase, dev, minify: values.minify })
|
|
280
|
+
// With a server running, its /isolates/ route serves what we write here.
|
|
281
|
+
if (result && devBase) writeIsolates(devIsolatesDir(state.projectDir), result.isolates)
|
|
282
|
+
return result
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Where a project's dev isolate bundles are written and served from. */
|
|
286
|
+
export function devIsolatesDir(projectDir: string): string {
|
|
287
|
+
return join(projectDir, ".srt-data", "isolates")
|
|
189
288
|
}
|
|
190
289
|
|
|
191
290
|
export async function bundleTo(outfile: string) {
|
|
@@ -216,13 +315,13 @@ export async function bundleFlux(entry: string): Promise<string> {
|
|
|
216
315
|
}
|
|
217
316
|
|
|
218
317
|
// Bundle for the SolidRT runtime via the standard Solid-aware bundler.
|
|
219
|
-
export async function bundleSolid(): Promise<
|
|
318
|
+
export async function bundleSolid(): Promise<BundleResult> {
|
|
220
319
|
let result = await bundle()
|
|
221
320
|
if (!result) {
|
|
222
321
|
console.error("Build failed")
|
|
223
322
|
process.exit(1)
|
|
224
323
|
}
|
|
225
|
-
return result
|
|
324
|
+
return result
|
|
226
325
|
}
|
|
227
326
|
|
|
228
327
|
// Compile JS source to QuickJS bytecode via the fluxc binary.
|
package/src/commands/check.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, rmSync } from "node:fs"
|
|
2
2
|
import { dirname, join, resolve } from "node:path"
|
|
3
3
|
import { source } from "../args"
|
|
4
|
-
import { bundleWith } from "../bundler"
|
|
4
|
+
import { bundleWith, findIsolateModules } from "../bundler"
|
|
5
5
|
|
|
6
6
|
// srt check: verify the app without side effects. Bundles in memory (nothing
|
|
7
7
|
// written, so no dev-server reload fires and no build outputs land in the
|
|
@@ -86,7 +86,11 @@ export async function typecheck(root: string, entry: string): Promise<{ app: Dia
|
|
|
86
86
|
// applies precisely because nothing imports it, so entry-only rooting would
|
|
87
87
|
// silently drop it and every asset import would fail with TS2307. The
|
|
88
88
|
// pattern is relative to this config, which sits one level under the root.
|
|
89
|
-
|
|
89
|
+
// Isolate modules are program roots of their own: main reaches them by
|
|
90
|
+
// `import type` at most, and one nothing imports would otherwise go
|
|
91
|
+
// unchecked.
|
|
92
|
+
let files = [resolve(entry), ...findIsolateModules(dirname(resolve(entry))).map((m) => m.path)]
|
|
93
|
+
await Bun.write(config, JSON.stringify({ extends: tsconfig, include: ["../**/*.d.ts"], files }))
|
|
90
94
|
try {
|
|
91
95
|
let proc = Bun.spawn([tsc, "-p", config, "--noEmit", "--pretty", "false"], {
|
|
92
96
|
cwd: root,
|
package/src/commands/client.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { values, clientStorageArgs } from "../args"
|
|
2
2
|
import { requireBinary, run } from "../util"
|
|
3
3
|
import { spawnAndroidClient } from "../dev-android"
|
|
4
|
-
import { DEV_PORT } from "../dev-server"
|
|
4
|
+
import { DEV_HOST, DEV_PORT } from "../dev-server"
|
|
5
5
|
|
|
6
6
|
// Standalone solidrt-go client (no dev server). The `run` command instead uses
|
|
7
7
|
// spawnClient() to launch a client tied to the dev-server lifecycle. --server
|
|
8
|
-
// auto-connects to a dev server at the given address
|
|
9
|
-
//
|
|
10
|
-
// on
|
|
8
|
+
// auto-connects to a dev server at the given address, and -s <N> is its
|
|
9
|
+
// shorthand for the session's server on this machine; without either, the
|
|
10
|
+
// client starts on the connect screen. With --android it is installed and
|
|
11
|
+
// launched on a connected Android device instead of run locally.
|
|
11
12
|
export async function runClientCommand() {
|
|
12
13
|
if (values.android) {
|
|
13
14
|
await spawnAndroidClient()
|
|
@@ -17,10 +18,17 @@ export async function runClientCommand() {
|
|
|
17
18
|
let runner = requireBinary("solidrt-go")
|
|
18
19
|
let args: string[] = [...clientStorageArgs()]
|
|
19
20
|
if (values.size) args.push("--size", values.size)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
// Both flags resolve to the one address the client understands. --server
|
|
22
|
+
// wins: an explicit host is never overridden by a session number, which
|
|
23
|
+
// only ever names a loopback port.
|
|
24
|
+
let address = values.server
|
|
25
|
+
? values.server.includes(":")
|
|
26
|
+
? values.server
|
|
27
|
+
: `${values.server}:${DEV_PORT}`
|
|
28
|
+
: values.session !== undefined
|
|
29
|
+
? `${DEV_HOST}:${DEV_PORT}`
|
|
30
|
+
: null
|
|
31
|
+
if (address) args.push("--dev-server", address)
|
|
24
32
|
let exit = await run(runner, args)
|
|
25
33
|
process.exit(exit)
|
|
26
34
|
}
|
package/src/commands/init.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { cp, mkdir, readFile, readdir, writeFile } from "node:fs/promises"
|
|
2
2
|
import { basename, dirname, join, resolve } from "node:path"
|
|
3
3
|
import { source, values } from "../args"
|
|
4
|
-
import {
|
|
4
|
+
import { multiselect, note, text } from "../prompt"
|
|
5
5
|
|
|
6
6
|
const DEFAULT_NAME = "solidrt-app"
|
|
7
7
|
|
|
@@ -29,76 +29,57 @@ function packageName(dir: string): string {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
const DEFAULT_TEMPLATE = "default"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
name: string
|
|
40
|
-
level: "core" | "components"
|
|
32
|
+
|
|
33
|
+
// Optional packages an app can opt into on top of core. Each maps to a
|
|
34
|
+
// dependency in the scaffold package.json (kept when selected, removed
|
|
35
|
+
// otherwise) and optionally to a starter under scaffold/templates/.
|
|
36
|
+
interface Extension {
|
|
37
|
+
pkg: string
|
|
38
|
+
template?: string
|
|
41
39
|
description: string
|
|
42
40
|
}
|
|
43
41
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
.sort((a, b) =>
|
|
53
|
-
a === DEFAULT_TEMPLATE ? -1 : b === DEFAULT_TEMPLATE ? 1 : a.localeCompare(b),
|
|
54
|
-
)
|
|
55
|
-
let templates: TemplateInfo[] = []
|
|
56
|
-
for (let name of names) {
|
|
57
|
-
// A missing manifest falls back to the components level: it keeps every
|
|
58
|
-
// dependency, so the scaffolded app works at either level.
|
|
59
|
-
let manifest = await readFile(join(TEMPLATES_DIR, name, TEMPLATE_MANIFEST), "utf8")
|
|
60
|
-
.then((raw) => JSON.parse(raw))
|
|
61
|
-
.catch(() => ({}))
|
|
62
|
-
templates.push({
|
|
63
|
-
name,
|
|
64
|
-
level: manifest.level === "core" ? "core" : "components",
|
|
65
|
-
description: typeof manifest.description === "string" ? manifest.description : "",
|
|
66
|
-
})
|
|
67
|
-
}
|
|
68
|
-
return templates
|
|
69
|
-
}
|
|
42
|
+
const EXTENSIONS: Extension[] = [
|
|
43
|
+
{
|
|
44
|
+
pkg: "@solidrt/components",
|
|
45
|
+
template: "components",
|
|
46
|
+
description: "component framework: widgets, theming, navigation",
|
|
47
|
+
},
|
|
48
|
+
{ pkg: "@solidrt/3d", description: "general purpose 3D library" },
|
|
49
|
+
]
|
|
70
50
|
|
|
71
|
-
// Resolve which
|
|
72
|
-
// interactive picker on a TTY, else
|
|
73
|
-
async function
|
|
74
|
-
let
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
51
|
+
// Resolve which extensions the app takes: an explicit --with list if valid,
|
|
52
|
+
// an interactive picker on a TTY, else none (core only).
|
|
53
|
+
async function resolveExtensions(): Promise<Extension[]> {
|
|
54
|
+
let raw = values.with
|
|
55
|
+
if (raw !== undefined) {
|
|
56
|
+
let names = raw.split(",").map((n) => n.trim()).filter(Boolean)
|
|
57
|
+
let chosen: Extension[] = []
|
|
58
|
+
for (let name of names) {
|
|
59
|
+
let found = EXTENSIONS.find((e) => e.pkg === name)
|
|
60
|
+
if (!found) {
|
|
61
|
+
let all = EXTENSIONS.map((e) => e.pkg).join(", ")
|
|
62
|
+
console.error(`!! Unknown extension "${name}"; choose from: ${all}`)
|
|
63
|
+
process.exit(1)
|
|
64
|
+
}
|
|
65
|
+
if (!chosen.includes(found)) chosen.push(found)
|
|
86
66
|
}
|
|
87
|
-
return
|
|
88
|
-
}
|
|
89
|
-
if (process.stdin.isTTY) {
|
|
90
|
-
let picked = await select(
|
|
91
|
-
"Select a template",
|
|
92
|
-
templates.map((t) => {
|
|
93
|
-
// Core is the runtime every app has; anything else is a package the
|
|
94
|
-
// app opts into, so the picker marks it as such.
|
|
95
|
-
let name = t.level === "core" ? t.name : `${t.name} (extension)`
|
|
96
|
-
return { label: t.description ? `${name} - ${t.description}` : name, value: t.name }
|
|
97
|
-
}),
|
|
98
|
-
)
|
|
99
|
-
return templates.find((t) => t.name === picked)!
|
|
67
|
+
return chosen
|
|
100
68
|
}
|
|
101
|
-
|
|
69
|
+
if (!process.stdin.isTTY) return []
|
|
70
|
+
// Core is the runtime every app has, so it is not a choice.
|
|
71
|
+
note("@solidrt/core is always included", "Packages")
|
|
72
|
+
let picked = await multiselect(
|
|
73
|
+
"Select extensions",
|
|
74
|
+
EXTENSIONS.map((e) => ({ label: `${e.pkg} - ${e.description}`, value: e.pkg })),
|
|
75
|
+
)
|
|
76
|
+
return EXTENSIONS.filter((e) => picked.includes(e.pkg))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The starter src/ comes from the first selected extension that brings a
|
|
80
|
+
// template; with none, the core `default` starter.
|
|
81
|
+
function resolveTemplate(extensions: Extension[]): string {
|
|
82
|
+
return extensions.find((e) => e.template)?.template ?? DEFAULT_TEMPLATE
|
|
102
83
|
}
|
|
103
84
|
|
|
104
85
|
export async function runInitCommand() {
|
|
@@ -120,9 +101,11 @@ export async function runInitCommand() {
|
|
|
120
101
|
process.exit(1)
|
|
121
102
|
}
|
|
122
103
|
|
|
123
|
-
let
|
|
104
|
+
let extensions = await resolveExtensions()
|
|
105
|
+
let template = resolveTemplate(extensions)
|
|
106
|
+
let summary = ["@solidrt/core", ...extensions.map((e) => e.pkg)].join(", ")
|
|
124
107
|
|
|
125
|
-
console.log(`>> Scaffolding SolidRT project in ${resolve(dir)} (${
|
|
108
|
+
console.log(`>> Scaffolding SolidRT project in ${resolve(dir)} (${summary})`)
|
|
126
109
|
for (let { from, to } of TEMPLATE_FILES) {
|
|
127
110
|
let dest = join(dir, to)
|
|
128
111
|
await mkdir(dirname(dest), { recursive: true })
|
|
@@ -130,13 +113,11 @@ export async function runInitCommand() {
|
|
|
130
113
|
console.log(` Write ${to}`)
|
|
131
114
|
}
|
|
132
115
|
|
|
133
|
-
// The
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
let templateDir = join(TEMPLATES_DIR, template.name)
|
|
116
|
+
// The template's files become the project's src/. Entries may be nested
|
|
117
|
+
// directories (e.g. an asset folder), so copy recursively.
|
|
118
|
+
let templateDir = join(TEMPLATES_DIR, template)
|
|
137
119
|
await mkdir(join(dir, "src"), { recursive: true })
|
|
138
120
|
for (let file of await readdir(templateDir)) {
|
|
139
|
-
if (file === TEMPLATE_MANIFEST) continue
|
|
140
121
|
await cp(join(templateDir, file), join(dir, "src", file), { recursive: true })
|
|
141
122
|
console.log(` Write src/${file}`)
|
|
142
123
|
}
|
|
@@ -149,12 +130,15 @@ export async function runInitCommand() {
|
|
|
149
130
|
await writeFile(join(dir, "assets", "icon.svg"), await readFile(join(SCAFFOLD_DIR, "icon.svg")))
|
|
150
131
|
console.log(" Write assets/icon.svg")
|
|
151
132
|
|
|
152
|
-
// The scaffold package.json carries a placeholder name
|
|
153
|
-
//
|
|
133
|
+
// The scaffold package.json carries a placeholder name and every extension
|
|
134
|
+
// dependency; set the name from the target folder and keep only the
|
|
135
|
+
// selected extensions.
|
|
154
136
|
let pkgPath = join(dir, "package.json")
|
|
155
137
|
let pkg = JSON.parse(await readFile(pkgPath, "utf8"))
|
|
156
138
|
pkg.name = packageName(dir)
|
|
157
|
-
|
|
139
|
+
for (let ext of EXTENSIONS) {
|
|
140
|
+
if (!extensions.includes(ext)) delete pkg.dependencies[ext.pkg]
|
|
141
|
+
}
|
|
158
142
|
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n")
|
|
159
143
|
|
|
160
144
|
// Deps are declared in scaffold/package.json (Solid peers resolve via
|
package/src/commands/mcp.ts
CHANGED
|
@@ -9,14 +9,153 @@ import { z } from "zod"
|
|
|
9
9
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
|
10
10
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
11
11
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
|
|
12
|
-
import { resolve } from "node:path"
|
|
12
|
+
import { dirname, join, resolve } from "node:path"
|
|
13
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs"
|
|
14
|
+
import { values, DEFAULT_DEV_PORT } from "../args"
|
|
13
15
|
import { DEV_PORT } from "../dev-server"
|
|
16
|
+
import { devDir } from "../dev-dir"
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
// An explicit -s/--port pins the port for the bridge's lifetime. Otherwise
|
|
19
|
+
// the port is resolved per tool call from the server registry, so one bridge
|
|
20
|
+
// (started when the workspace opens, kept alive across server restarts)
|
|
21
|
+
// follows whichever server is currently serving this project - and the
|
|
22
|
+
// scaffold's mcp.json never carries a port.
|
|
23
|
+
const FIXED_PORT = values.port !== undefined || values.session !== undefined ? DEV_PORT : null
|
|
24
|
+
|
|
25
|
+
// The projectDir the bridge is working in: the nearest package.json above its
|
|
26
|
+
// own cwd, the same rule srt applies to an entry (project.ts projectDirFor),
|
|
27
|
+
// so both sides derive the same string.
|
|
28
|
+
function findProjectDir(): string | null {
|
|
29
|
+
let dir = process.cwd()
|
|
30
|
+
while (true) {
|
|
31
|
+
if (existsSync(join(dir, "package.json"))) return dir
|
|
32
|
+
let parent = dirname(dir)
|
|
33
|
+
if (parent === dir) return null
|
|
34
|
+
dir = parent
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The two sides of a projectDir comparison come from different processes
|
|
39
|
+
// (the server's entry path, the bridge's cwd) and only agree by construction
|
|
40
|
+
// on the directory, not the spelling: an editor-spawned bridge on Windows
|
|
41
|
+
// keeps its parent's lower-case drive letter while a shell writes it upper
|
|
42
|
+
// case, and 8.3 names, symlinks and subst drives are the same class. Compare
|
|
43
|
+
// the canonical path, so the spelling never decides.
|
|
44
|
+
function sameDir(a: string, b: string): boolean {
|
|
45
|
+
if (a === b) return true
|
|
46
|
+
try {
|
|
47
|
+
return realpathSync.native(a) === realpathSync.native(b)
|
|
48
|
+
} catch {
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Only ESRCH means the process is gone. EPERM is a live process this bridge
|
|
54
|
+
// may not signal (Windows reports it for other users' processes), and a
|
|
55
|
+
// bare try/catch would drop that healthy server from the registry.
|
|
56
|
+
function pidAlive(pid: number): boolean {
|
|
57
|
+
try {
|
|
58
|
+
process.kill(pid, 0)
|
|
59
|
+
return true
|
|
60
|
+
} catch (e: any) {
|
|
61
|
+
return e?.code === "EPERM"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type LiveRecord = { pid: number; port: number; projectDir: string }
|
|
66
|
+
|
|
67
|
+
// The global server registry: every running dev server keeps a live.json in
|
|
68
|
+
// ~/.solidrt/servers/<port>/ (see dev-server.ts writeLiveRecord). Unreadable or
|
|
69
|
+
// malformed records are skipped, not fatal - the registry is a hint.
|
|
70
|
+
function liveRecords(): LiveRecord[] {
|
|
71
|
+
let root = devDir("servers")
|
|
72
|
+
let names: string[]
|
|
73
|
+
try {
|
|
74
|
+
names = readdirSync(root)
|
|
75
|
+
} catch {
|
|
76
|
+
return []
|
|
77
|
+
}
|
|
78
|
+
let records: LiveRecord[] = []
|
|
79
|
+
for (let name of names) {
|
|
80
|
+
try {
|
|
81
|
+
let record = JSON.parse(readFileSync(join(root, name, "live.json"), "utf8"))
|
|
82
|
+
if (typeof record?.pid === "number" && typeof record?.port === "number" && typeof record?.projectDir === "string") {
|
|
83
|
+
records.push(record)
|
|
84
|
+
}
|
|
85
|
+
} catch {}
|
|
86
|
+
}
|
|
87
|
+
return records
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type PortResult = { ok: true; port: number } | { ok: false; message: string }
|
|
91
|
+
|
|
92
|
+
async function resolvePort(): Promise<PortResult> {
|
|
93
|
+
if (FIXED_PORT !== null) return { ok: true, port: FIXED_PORT }
|
|
94
|
+
let project = findProjectDir()
|
|
95
|
+
if (!project) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
message: `No package.json found above ${process.cwd()}, so no dev server can be resolved by project. Pass -s <N> or --port <N> to srt mcp.`,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
let records = liveRecords()
|
|
102
|
+
let matches = records.filter((r) => sameDir(r.projectDir, project) && pidAlive(r.pid))
|
|
103
|
+
if (matches.length > 1) {
|
|
104
|
+
let ports = matches
|
|
105
|
+
.map((r) => r.port)
|
|
106
|
+
.sort((a, b) => a - b)
|
|
107
|
+
.join(", ")
|
|
108
|
+
return { ok: false, message: `${matches.length} dev servers are serving this project (ports ${ports}); pass -s <N> to srt mcp` }
|
|
109
|
+
}
|
|
110
|
+
if (matches.length === 0) {
|
|
111
|
+
// A lookup by key that fails against a small table prints the table: an
|
|
112
|
+
// empty registry, a dead pid and a record for another project are three
|
|
113
|
+
// different problems, and the reader can only tell them apart if the
|
|
114
|
+
// candidates are listed next to the key that was looked up.
|
|
115
|
+
let listing =
|
|
116
|
+
records.length === 0
|
|
117
|
+
? `Registry ${devDir("servers")}: no records.`
|
|
118
|
+
: `Registry ${devDir("servers")}: ${records.length} record(s).\n` +
|
|
119
|
+
records
|
|
120
|
+
.map((r) => {
|
|
121
|
+
let session = r.port - DEFAULT_DEV_PORT
|
|
122
|
+
let flag = session >= 0 && session < 100 ? `-s ${session}` : `--port ${r.port}`
|
|
123
|
+
return ` port ${r.port} (${flag}) pid ${r.pid} (${pidAlive(r.pid) ? "alive" : "dead"}) serving ${r.projectDir}`
|
|
124
|
+
})
|
|
125
|
+
.join("\n")
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
message: `No dev server for ${project}.\n${listing}\nStart one with srt run, or pin one of the servers above by passing its flag to srt mcp.`,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
let port = matches[0]!.port
|
|
132
|
+
// The record is a hint; the server is authoritative. The probe catches a
|
|
133
|
+
// stale record whose pid was reused by an unrelated process.
|
|
134
|
+
try {
|
|
135
|
+
let probe = await fetch(`http://127.0.0.1:${port}/__control__/clients`)
|
|
136
|
+
let body: any = await probe.json().catch(() => null)
|
|
137
|
+
if (!probe.ok || typeof body?.projectDir !== "string" || !sameDir(body.projectDir, project)) {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
message: `The server on port ${port} is not serving ${project}${
|
|
141
|
+
typeof body?.projectDir === "string" ? ` (it serves ${body.projectDir})` : ""
|
|
142
|
+
}. Start one with srt run, or pass -s <N> to srt mcp.`,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
message: `No dev server for ${project}: the registry lists port ${port} but nothing answers there. Start one with srt run.`,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { ok: true, port }
|
|
152
|
+
}
|
|
16
153
|
|
|
17
154
|
type ControlResult = { ok: true; body: any } | { ok: false; message: string }
|
|
18
155
|
|
|
19
156
|
async function control(path: string, method: "GET" | "POST" = "GET", payload?: unknown): Promise<ControlResult> {
|
|
157
|
+
let resolved = await resolvePort()
|
|
158
|
+
if (!resolved.ok) return resolved
|
|
20
159
|
let resp
|
|
21
160
|
try {
|
|
22
161
|
let init: RequestInit = { method }
|
|
@@ -24,7 +163,7 @@ async function control(path: string, method: "GET" | "POST" = "GET", payload?: u
|
|
|
24
163
|
init.headers = { "content-type": "application/json" }
|
|
25
164
|
init.body = JSON.stringify(payload)
|
|
26
165
|
}
|
|
27
|
-
resp = await fetch(
|
|
166
|
+
resp = await fetch(`http://127.0.0.1:${resolved.port}/__control__${path}`, init)
|
|
28
167
|
} catch {
|
|
29
168
|
return {
|
|
30
169
|
ok: false,
|
|
@@ -104,7 +243,7 @@ let TOOLS: {
|
|
|
104
243
|
name: "get_stats",
|
|
105
244
|
readOnly: true,
|
|
106
245
|
description:
|
|
107
|
-
"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, mounted AND detached), mountedNodes/orphanNodes (live at query time: nodes reachable from the root vs not - orphans growing at a stable tree shape mean an unmount leak; absent when no engine is running), 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). GPU-side health, read live at query time (absent when no engine is running): rasterQueue (raster commands sent but not yet executed; stuck nonzero means the raster thread is backlogged - the state where fps and frameMs go blind because no frames complete), idleTicks (cumulative idle frame signals emitted while the GPU had nothing queued; idleTicks racing while rasterQueue sits nonzero would mean the idle-tick gate is broken), fenceTimeouts (cumulative present-fence waits that expired instead of signaling - each one is a frame where the GPU was over budget for 100ms+ and one-frame-in-flight pacing was lost; zero on a healthy machine, climbing means the GPU is the bottleneck right now), gpuPasses/gpuPassMs (cumulative shader/pipeline target renders on the raster thread and the wall time they took in whole ms - diff two queries to get a rate; passes racing far ahead of frames means redundant target re-renders, the failure mode where fps and frameMs look healthy while the raster thread drowns; the ms figure is raster-thread occupancy issuing the passes, not GPU-side duration), rasterCmdMs (cumulative wall time in whole ms the raster thread spent executing non-frame commands - texture uploads, readbacks, offscreen rasterizations, shader compiles, param writes and the target re-renders they trigger; the work frameMs never sees, so rasterCmdMs growing much faster than frames are presented means the raster thread is drowning in side work even if every counter above looks calm).",
|
|
246
|
+
"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, mounted AND detached), mountedNodes/orphanNodes (live at query time: nodes reachable from the root vs not - orphans growing at a stable tree shape mean an unmount leak; absent when no engine is running), measureCalls (text measures; mostly cache hits, cheap), paraShapes (paragraphs actually shaped, i.e. words the shared word cache did not have; the expensive signal - high layoutMs with near-zero paraShapes means the cost is not text shaping), wordHits (words answered from the shared word cache; hits high and paraShapes near zero on a text change means only the changed words were reshaped), 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). GPU-side health, read live at query time (absent when no engine is running): rasterQueue (raster commands sent but not yet executed; stuck nonzero means the raster thread is backlogged - the state where fps and frameMs go blind because no frames complete), idleTicks (cumulative idle frame signals emitted while the GPU had nothing queued; idleTicks racing while rasterQueue sits nonzero would mean the idle-tick gate is broken), fenceTimeouts (cumulative present-fence waits that expired instead of signaling - each one is a frame where the GPU was over budget for 100ms+ and one-frame-in-flight pacing was lost; zero on a healthy machine, climbing means the GPU is the bottleneck right now), gpuPasses/gpuPassMs (cumulative shader/pipeline target renders on the raster thread and the wall time they took in whole ms - diff two queries to get a rate; passes racing far ahead of frames means redundant target re-renders, the failure mode where fps and frameMs look healthy while the raster thread drowns; the ms figure is raster-thread occupancy issuing the passes, not GPU-side duration), rasterCmdMs (cumulative wall time in whole ms the raster thread spent executing non-frame commands - texture uploads, readbacks, offscreen rasterizations, shader compiles, param writes and the target re-renders they trigger; the work frameMs never sees, so rasterCmdMs growing much faster than frames are presented means the raster thread is drowning in side work even if every counter above looks calm).",
|
|
108
247
|
inputSchema: { client: CLIENT_ARG },
|
|
109
248
|
},
|
|
110
249
|
{
|