@solidrt/cli 0.0.49 → 0.0.51
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 +31 -5
- package/agents/assets.md +32 -0
- package/agents/debugging.md +142 -0
- package/package.json +9 -6
- package/scaffold/AGENTS.md +102 -541
- package/scaffold/package.json +5 -4
- package/server/control.ts +15 -5
- package/server/main.ts +8 -8
- package/server/rebuild.ts +10 -2
- package/server/remap.ts +46 -33
- package/server/state.ts +6 -5
- package/src/args.ts +5 -7
- package/src/bundler.ts +112 -37
- package/src/commands/bundle.ts +106 -21
- package/src/commands/check.ts +7 -0
- package/src/commands/init.ts +61 -77
- package/src/commands/mcp.ts +63 -12
- package/src/commands/pack.ts +18 -8
- package/src/commands/render.ts +28 -5
- package/src/commands/server.ts +5 -5
- package/src/dev-server.ts +7 -7
- package/src/packer.ts +16 -12
- package/src/prompt.ts +46 -72
- package/src/repl.ts +27 -11
- package/src/util.ts +4 -3
- package/src/watcher.ts +7 -3
- package/scaffold/templates/components/template.json +0 -4
- package/scaffold/templates/default/template.json +0 -4
package/src/commands/bundle.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { values, source, isPrebuilt } from "../args"
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
bundleFlux,
|
|
4
|
+
bundleIsolatesDir,
|
|
5
|
+
bundleSolid,
|
|
6
|
+
compileToBytecode,
|
|
7
|
+
findFluxIsolates,
|
|
8
|
+
readPrebuiltIsolates,
|
|
9
|
+
walkFiles,
|
|
10
|
+
writeIsolates,
|
|
11
|
+
} from "../bundler"
|
|
12
|
+
import { projectDirFor } from "../project"
|
|
13
|
+
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"
|
|
14
|
+
import { basename, dirname, join, resolve } from "node:path"
|
|
4
15
|
|
|
5
16
|
// Write to stdout and resolve only once the whole payload is flushed.
|
|
6
17
|
// process.stdout.write to a pipe is async and applies backpressure; the
|
|
@@ -11,7 +22,41 @@ function writeStdout(data: string): Promise<void> {
|
|
|
11
22
|
})
|
|
12
23
|
}
|
|
13
24
|
|
|
14
|
-
//
|
|
25
|
+
// The bundle output dir (okf/backlog/build-output-dirs.md): the bundle flow's
|
|
26
|
+
// subdir of the build root, or an explicit --output dir. Only reused when it
|
|
27
|
+
// is empty or already a bundle output (a *.srt.* or *.flux.* bundle at top
|
|
28
|
+
// level) - the writePackFolder rule - so it never writes into an unrelated
|
|
29
|
+
// directory.
|
|
30
|
+
function ensureOutDir(entry: string): string {
|
|
31
|
+
let outDir = values.output ?? join(projectDirFor(entry), "dist", "bundle")
|
|
32
|
+
let existing = existsSync(outDir) ? readdirSync(outDir) : null
|
|
33
|
+
if (existing && existing.length > 0 && !existing.some((name) => /\.(srt|flux)\.(js|bin)$/.test(name))) {
|
|
34
|
+
console.error(`${resolve(outDir)} exists and is not a bundle output; choose another --output`)
|
|
35
|
+
process.exit(1)
|
|
36
|
+
}
|
|
37
|
+
mkdirSync(outDir, { recursive: true })
|
|
38
|
+
return outDir
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Clear one form's files from the output's isolates/ dir before rewriting it,
|
|
42
|
+
// so removed modules cannot go stale. The dir is shared by a bundle's .js and
|
|
43
|
+
// .bin forms, so only the form being rewritten is cleared - a --compile must
|
|
44
|
+
// not delete the .js set the .js bundle pairs with, nor the reverse.
|
|
45
|
+
function clearIsolates(dir: string, ext: ".js" | ".bin") {
|
|
46
|
+
walkFiles(dir, (abs) => {
|
|
47
|
+
if (abs.endsWith(ext)) rmSync(abs)
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Compile one isolate bundle to `<dir>/<id>.bin` (module name = its id, for
|
|
52
|
+
// stack attribution).
|
|
53
|
+
async function writeIsolateBytecode(dir: string, isolate: { id: string; code: string }) {
|
|
54
|
+
let outfile = join(dir, isolate.id + ".bin")
|
|
55
|
+
mkdirSync(dirname(outfile), { recursive: true })
|
|
56
|
+
await Bun.write(outfile, await compileToBytecode(isolate.code, isolate.id))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Compile JS to a bytecode file and report its size.
|
|
15
60
|
async function writeBytecode(jsCode: string, outfile: string) {
|
|
16
61
|
let bytecode = await compileToBytecode(jsCode)
|
|
17
62
|
await Bun.write(outfile, bytecode)
|
|
@@ -21,18 +66,47 @@ async function writeBytecode(jsCode: string, outfile: string) {
|
|
|
21
66
|
|
|
22
67
|
export async function runBundleCommand() {
|
|
23
68
|
if (values.flux) {
|
|
24
|
-
let
|
|
25
|
-
let
|
|
69
|
+
let entry = resolve(source!)
|
|
70
|
+
let name = basename(entry).replace(/\.[jt]s$/, "")
|
|
71
|
+
let jsCode = await bundleFlux(entry)
|
|
72
|
+
// Standalone flux resolves isolates by location, not directive: module
|
|
73
|
+
// <id> is <entry dir>/isolates/<id>.js. Bundling keeps that shape - every
|
|
74
|
+
// module under the entry's isolates/ dir is built bare like the entry
|
|
75
|
+
// (which also lets a worker be .ts, unlike running from source) and lands
|
|
76
|
+
// as isolates/<id>.js next to the bundle.
|
|
77
|
+
let isolateModules = findFluxIsolates(dirname(entry))
|
|
26
78
|
|
|
27
79
|
if (values.stdout) {
|
|
80
|
+
if (isolateModules.length) {
|
|
81
|
+
console.error("[cli] Warning: this script has isolate modules; --stdout carries only the main bundle")
|
|
82
|
+
}
|
|
28
83
|
await writeStdout(jsCode)
|
|
29
|
-
|
|
30
|
-
|
|
84
|
+
process.exit()
|
|
85
|
+
}
|
|
86
|
+
let outDir = ensureOutDir(entry)
|
|
87
|
+
if (values.compile) {
|
|
88
|
+
await writeBytecode(jsCode, join(outDir, name + ".flux.bin"))
|
|
31
89
|
} else {
|
|
32
|
-
let outfile =
|
|
90
|
+
let outfile = join(outDir, name + ".flux.js")
|
|
33
91
|
await Bun.write(outfile, jsCode)
|
|
34
92
|
console.log(`>> wrote ${jsCode.length} bytes to ${outfile}`)
|
|
35
93
|
}
|
|
94
|
+
// Isolates follow the main bundle's form: source beside a .flux.js,
|
|
95
|
+
// bytecode beside a .flux.bin (the flux resolver reads .bin first).
|
|
96
|
+
let isolatesDir = join(outDir, "isolates")
|
|
97
|
+
if (values.compile) {
|
|
98
|
+
clearIsolates(isolatesDir, ".bin")
|
|
99
|
+
for (let module of isolateModules) {
|
|
100
|
+
await writeIsolateBytecode(isolatesDir, { id: module.id, code: await bundleFlux(module.path) })
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
clearIsolates(isolatesDir, ".js")
|
|
104
|
+
for (let module of isolateModules) {
|
|
105
|
+
let file = join(isolatesDir, module.id + ".js")
|
|
106
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
107
|
+
await Bun.write(file, await bundleFlux(module.path))
|
|
108
|
+
}
|
|
109
|
+
}
|
|
36
110
|
process.exit()
|
|
37
111
|
}
|
|
38
112
|
|
|
@@ -44,33 +118,44 @@ export async function runBundleCommand() {
|
|
|
44
118
|
let jsFile = resolve(source!)
|
|
45
119
|
let binOut = jsFile.replace(/\.srt\.js$/, ".srt.bin").replace(/\.js$/, ".bin")
|
|
46
120
|
await writeBytecode(await Bun.file(jsFile).text(), binOut)
|
|
121
|
+
// The isolate bundles compile along, .bin beside .js in the output's
|
|
122
|
+
// isolates/ dir (the ids match, the extension picks the form).
|
|
123
|
+
for (let isolate of readPrebuiltIsolates(jsFile)) {
|
|
124
|
+
await writeIsolateBytecode(bundleIsolatesDir(jsFile), isolate)
|
|
125
|
+
}
|
|
47
126
|
process.exit()
|
|
48
127
|
}
|
|
49
128
|
|
|
50
|
-
let
|
|
129
|
+
let entry = resolve(source!)
|
|
130
|
+
let name = basename(entry).replace(/\.[jt]sx?$/, "")
|
|
51
131
|
|
|
52
132
|
if (values.stdout) {
|
|
53
|
-
let result = await
|
|
54
|
-
if (
|
|
55
|
-
console.error("
|
|
56
|
-
process.exit(1)
|
|
133
|
+
let result = await bundleSolid()
|
|
134
|
+
if (result.isolates.length) {
|
|
135
|
+
console.error("[cli] Warning: this app has isolate modules; --stdout carries only the main bundle")
|
|
57
136
|
}
|
|
58
137
|
await writeStdout(result.code)
|
|
59
138
|
process.exit()
|
|
60
139
|
}
|
|
61
140
|
|
|
141
|
+
let outDir = ensureOutDir(entry)
|
|
142
|
+
let isolatesDir = join(outDir, "isolates")
|
|
143
|
+
|
|
62
144
|
if (values.compile) {
|
|
63
|
-
let result = await
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
145
|
+
let result = await bundleSolid()
|
|
146
|
+
await writeBytecode(result.code, join(outDir, name + ".srt.bin"))
|
|
147
|
+
clearIsolates(isolatesDir, ".bin")
|
|
148
|
+
for (let isolate of result.isolates) {
|
|
149
|
+
await writeIsolateBytecode(isolatesDir, isolate)
|
|
67
150
|
}
|
|
68
|
-
await writeBytecode(result.code, baseName + ".srt.bin")
|
|
69
151
|
process.exit()
|
|
70
152
|
}
|
|
71
153
|
|
|
72
|
-
let
|
|
73
|
-
let
|
|
154
|
+
let result = await bundleSolid()
|
|
155
|
+
let jsOutfile = join(outDir, name + ".srt.js")
|
|
156
|
+
await Bun.write(jsOutfile, result.code)
|
|
157
|
+
clearIsolates(isolatesDir, ".js")
|
|
158
|
+
writeIsolates(isolatesDir, result.isolates)
|
|
74
159
|
console.log(`>> wrote ${result.code.length} bytes to ${jsOutfile}`)
|
|
75
160
|
process.exit()
|
|
76
|
-
}
|
|
161
|
+
}
|
package/src/commands/check.ts
CHANGED
|
@@ -128,6 +128,13 @@ export function reportTypes(
|
|
|
128
128
|
|
|
129
129
|
export async function runCheckCommand() {
|
|
130
130
|
let entry = source!
|
|
131
|
+
if (!existsSync(entry)) {
|
|
132
|
+
// Without this, the missing file surfaces later as an internal ENOENT
|
|
133
|
+
// stack trace (scandir/Bun.build), which reads as a CLI bug - the common
|
|
134
|
+
// cause is just running from the wrong directory.
|
|
135
|
+
console.error(`No such entry: ${entry} (resolved from ${process.cwd()})`)
|
|
136
|
+
process.exit(1)
|
|
137
|
+
}
|
|
131
138
|
let failed = false
|
|
132
139
|
|
|
133
140
|
let result = await bundleWith({ entry, dev: true, minify: false })
|
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,55 @@ function packageName(dir: string): string {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
const DEFAULT_TEMPLATE = "default"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
|
|
33
|
+
// One AGENTS.md serves every template, so the lines that point an agent at
|
|
34
|
+
// @solidrt/components docs are fenced between markers: with the extension
|
|
35
|
+
// selected only the markers go, without it the block goes too, so a core-only
|
|
36
|
+
// app never ships references to files that are not installed.
|
|
37
|
+
const MARKED_BLOCK = /^<!-- components:begin -->\n[\s\S]*?^<!-- components:end -->\n/gm
|
|
38
|
+
const MARKER = /^<!-- components:(?:begin|end) -->\n/gm
|
|
39
|
+
|
|
40
|
+
function resolveMarkers(text: string, extensions: Extension[]): string {
|
|
41
|
+
let selected = extensions.some((e) => e.pkg === "@solidrt/components")
|
|
42
|
+
return selected ? text.replace(MARKER, "") : text.replace(MARKED_BLOCK, "")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Optional packages an app can opt into on top of core. Each maps to a
|
|
46
|
+
// dependency in the scaffold package.json (kept when selected, removed
|
|
47
|
+
// otherwise) and optionally to a starter under scaffold/templates/.
|
|
48
|
+
interface Extension {
|
|
49
|
+
pkg: string
|
|
50
|
+
template?: string
|
|
41
51
|
description: string
|
|
42
52
|
}
|
|
43
53
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
description: typeof manifest.description === "string" ? manifest.description : "",
|
|
66
|
-
})
|
|
67
|
-
}
|
|
68
|
-
return templates
|
|
54
|
+
const EXTENSIONS: Extension[] = [
|
|
55
|
+
{
|
|
56
|
+
pkg: "@solidrt/components",
|
|
57
|
+
template: "components",
|
|
58
|
+
description: "component framework: widgets, theming, navigation",
|
|
59
|
+
},
|
|
60
|
+
{ pkg: "@solidrt/3d", description: "general purpose 3D library" },
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
// Resolve which extensions the app takes: an interactive picker on a TTY,
|
|
64
|
+
// else none (core only). Extensions are ordinary dependencies, so a script
|
|
65
|
+
// adds them afterwards with `bun add`.
|
|
66
|
+
async function resolveExtensions(): Promise<Extension[]> {
|
|
67
|
+
if (!process.stdin.isTTY) return []
|
|
68
|
+
// Core is the runtime every app has, so it is not a choice.
|
|
69
|
+
note("@solidrt/core is always included", "Packages")
|
|
70
|
+
let picked = await multiselect(
|
|
71
|
+
"Select extensions",
|
|
72
|
+
EXTENSIONS.map((e) => ({ label: `${e.pkg} - ${e.description}`, value: e.pkg })),
|
|
73
|
+
)
|
|
74
|
+
return EXTENSIONS.filter((e) => picked.includes(e.pkg))
|
|
69
75
|
}
|
|
70
76
|
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (templates.length === 0) {
|
|
76
|
-
console.error(`!! No templates found in ${TEMPLATES_DIR}`)
|
|
77
|
-
process.exit(1)
|
|
78
|
-
}
|
|
79
|
-
let chosen = values.template
|
|
80
|
-
if (chosen) {
|
|
81
|
-
let found = templates.find((t) => t.name === chosen)
|
|
82
|
-
if (!found) {
|
|
83
|
-
let names = templates.map((t) => t.name).join(", ")
|
|
84
|
-
console.error(`!! Unknown template "${chosen}"; choose from: ${names}`)
|
|
85
|
-
process.exit(1)
|
|
86
|
-
}
|
|
87
|
-
return found
|
|
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)!
|
|
100
|
-
}
|
|
101
|
-
return templates.find((t) => t.name === DEFAULT_TEMPLATE) ?? templates[0]!
|
|
77
|
+
// The starter src/ comes from the first selected extension that brings a
|
|
78
|
+
// template; with none, the core `default` starter.
|
|
79
|
+
function resolveTemplate(extensions: Extension[]): string {
|
|
80
|
+
return extensions.find((e) => e.template)?.template ?? DEFAULT_TEMPLATE
|
|
102
81
|
}
|
|
103
82
|
|
|
104
83
|
export async function runInitCommand() {
|
|
@@ -120,23 +99,25 @@ export async function runInitCommand() {
|
|
|
120
99
|
process.exit(1)
|
|
121
100
|
}
|
|
122
101
|
|
|
123
|
-
let
|
|
102
|
+
let extensions = await resolveExtensions()
|
|
103
|
+
let template = resolveTemplate(extensions)
|
|
104
|
+
let summary = ["@solidrt/core", ...extensions.map((e) => e.pkg)].join(", ")
|
|
124
105
|
|
|
125
|
-
console.log(`>> Scaffolding SolidRT project in ${resolve(dir)} (${
|
|
106
|
+
console.log(`>> Scaffolding SolidRT project in ${resolve(dir)} (${summary})`)
|
|
126
107
|
for (let { from, to } of TEMPLATE_FILES) {
|
|
127
108
|
let dest = join(dir, to)
|
|
128
109
|
await mkdir(dirname(dest), { recursive: true })
|
|
129
|
-
|
|
110
|
+
let body: string | Buffer = await readFile(join(SCAFFOLD_DIR, from))
|
|
111
|
+
if (to === "AGENTS.md") body = resolveMarkers(body.toString("utf8"), extensions)
|
|
112
|
+
await writeFile(dest, body)
|
|
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
|
@@ -10,8 +10,8 @@ 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
12
|
import { dirname, join, resolve } from "node:path"
|
|
13
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
|
14
|
-
import { values } from "../args"
|
|
13
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs"
|
|
14
|
+
import { values, DEFAULT_DEV_PORT } from "../args"
|
|
15
15
|
import { DEV_PORT } from "../dev-server"
|
|
16
16
|
import { devDir } from "../dev-dir"
|
|
17
17
|
|
|
@@ -35,12 +35,30 @@ function findProjectDir(): string | null {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
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.
|
|
38
56
|
function pidAlive(pid: number): boolean {
|
|
39
57
|
try {
|
|
40
58
|
process.kill(pid, 0)
|
|
41
59
|
return true
|
|
42
|
-
} catch {
|
|
43
|
-
return
|
|
60
|
+
} catch (e: any) {
|
|
61
|
+
return e?.code === "EPERM"
|
|
44
62
|
}
|
|
45
63
|
}
|
|
46
64
|
|
|
@@ -80,7 +98,8 @@ async function resolvePort(): Promise<PortResult> {
|
|
|
80
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.`,
|
|
81
99
|
}
|
|
82
100
|
}
|
|
83
|
-
let
|
|
101
|
+
let records = liveRecords()
|
|
102
|
+
let matches = records.filter((r) => sameDir(r.projectDir, project) && pidAlive(r.pid))
|
|
84
103
|
if (matches.length > 1) {
|
|
85
104
|
let ports = matches
|
|
86
105
|
.map((r) => r.port)
|
|
@@ -89,7 +108,25 @@ async function resolvePort(): Promise<PortResult> {
|
|
|
89
108
|
return { ok: false, message: `${matches.length} dev servers are serving this project (ports ${ports}); pass -s <N> to srt mcp` }
|
|
90
109
|
}
|
|
91
110
|
if (matches.length === 0) {
|
|
92
|
-
|
|
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
|
+
}
|
|
93
130
|
}
|
|
94
131
|
let port = matches[0]!.port
|
|
95
132
|
// The record is a hint; the server is authoritative. The probe catches a
|
|
@@ -97,7 +134,7 @@ async function resolvePort(): Promise<PortResult> {
|
|
|
97
134
|
try {
|
|
98
135
|
let probe = await fetch(`http://127.0.0.1:${port}/__control__/clients`)
|
|
99
136
|
let body: any = await probe.json().catch(() => null)
|
|
100
|
-
if (!probe.ok || body?.projectDir !== project) {
|
|
137
|
+
if (!probe.ok || typeof body?.projectDir !== "string" || !sameDir(body.projectDir, project)) {
|
|
101
138
|
return {
|
|
102
139
|
ok: false,
|
|
103
140
|
message: `The server on port ${port} is not serving ${project}${
|
|
@@ -206,8 +243,17 @@ let TOOLS: {
|
|
|
206
243
|
name: "get_stats",
|
|
207
244
|
readOnly: true,
|
|
208
245
|
description:
|
|
209
|
-
"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
|
|
210
|
-
inputSchema: {
|
|
246
|
+
"Performance statistics from a running app client. Start with `window`: a summary of the frames rebuilt in the last window_ms (default 5000, max 10000) - frames, p50Ms/p95Ms/maxMs of the JS-thread critical path per frame (render handler + layout + postLayout + paint + hover), slowFrames (frames over the refresh period, periodMs), and `worst`, the single most expensive frame with its ageMs, phase breakdown (jsMs/layoutMs/postLayoutMs/paintMs/hoverMs) and that frame's own layout activity (paraShapes, measureCalls, dirtiedNodes, cacheGets/cacheHits, nodesPainted). This is where jank shows: the smoothed figures below average a one-frame hitch away, the window keeps it. Typical flow: send_input a burst (typing, a drag), then get_stats - `frames: 0` means nothing was rebuilt in the window (idle app), which is different from all-fast. The window also carries rates for the GPU counters when it spans 2+ frames: fenceTimeoutsPerSec, gpuPassesPerFrame (per presented frame), gpuPassMsPerFrame, rasterCmdMsPerSec - read these instead of differencing the cumulatives yourself. timeMs (client monotonic clock) and frame (present index) stamp the payload so two samples can be differenced. Then the smoothed figures: 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), nodesPainted (nodes the last paint walk entered; mountedNodes minus this is what viewport culling skipped - a long scroller should paint a near-constant number of nodes however long its content). GPU-side health, read live at query time (absent when no engine is running): rasterQueue (raster commands sent but not yet executed at the instant of the query, including the one executing; the frame command blocks on vsync in it, so 1 while frames flow is normal - it is a backlog signal only when it climbs across queries while fps drops; a persistently high idle reading has been seen once on a Windows client and is unexplained, so do not conclude from this field alone), 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).",
|
|
247
|
+
inputSchema: {
|
|
248
|
+
window_ms: z
|
|
249
|
+
.number()
|
|
250
|
+
.int()
|
|
251
|
+
.min(0)
|
|
252
|
+
.max(10000)
|
|
253
|
+
.describe("How far back the window summary looks, in ms (default 5000, max 10000)")
|
|
254
|
+
.optional(),
|
|
255
|
+
client: CLIENT_ARG,
|
|
256
|
+
},
|
|
211
257
|
},
|
|
212
258
|
{
|
|
213
259
|
name: "get_render_tree",
|
|
@@ -352,7 +398,7 @@ let TOOLS: {
|
|
|
352
398
|
name: "set_time_scale",
|
|
353
399
|
annotations: { destructiveHint: false, idempotentHint: true },
|
|
354
400
|
description:
|
|
355
|
-
"Control a running app client's clock. scale=0 freezes app time: onFrame/requestAnimationFrame stop being delivered, setTimeout/setInterval freeze, performance.now()
|
|
401
|
+
"Control a running app client's clock. scale=0 freezes app time: onFrame/requestAnimationFrame stop being delivered, setTimeout/setInterval freeze, and the picture stops (performance.now() and Date.now() keep running: they are real time, not the frame timeline, so only animations driven off the onFrame tick pause) - so get_snapshot can capture an exact frame of any animation instead of racing it (tool round trips are usually slower than the animation). Combine with a registerDebug command that sets up the state to photograph: set state, pause, snapshot. Other values scale time for dt-driven apps (0.5 = half speed, 2 = double); apps that advance a fixed amount per onFrame call only respond to 0 and 1. The scale is client runtime state: it survives across your snapshots but resets to 1 on reload/load and on client restart. ALWAYS set it back to 1 when you are done - a paused client looks wedged to the human watching the screen.",
|
|
356
402
|
inputSchema: {
|
|
357
403
|
scale: z
|
|
358
404
|
.number()
|
|
@@ -431,8 +477,13 @@ async function callTool(name: string, args: any): Promise<ControlResult> {
|
|
|
431
477
|
let qs = params.toString()
|
|
432
478
|
return control(qs ? `/logs?${qs}` : "/logs")
|
|
433
479
|
}
|
|
434
|
-
case "get_stats":
|
|
435
|
-
|
|
480
|
+
case "get_stats": {
|
|
481
|
+
let params = new URLSearchParams()
|
|
482
|
+
if (typeof args?.window_ms === "number") params.set("window", String(args.window_ms))
|
|
483
|
+
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
484
|
+
let qs = params.toString()
|
|
485
|
+
return control(qs ? `/stats?${qs}` : "/stats")
|
|
486
|
+
}
|
|
436
487
|
case "get_render_tree": {
|
|
437
488
|
let params = new URLSearchParams()
|
|
438
489
|
if (typeof args?.root === "number") params.set("root", String(args.root))
|
package/src/commands/pack.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { values, source } from "../args"
|
|
2
|
-
import { bundleFlux, bundleSolid, compileToBytecode } from "../bundler"
|
|
2
|
+
import { bundleFlux, bundleSolid, compileToBytecode, findFluxIsolates } from "../bundler"
|
|
3
3
|
import { resolvePackFonts } from "../fonts"
|
|
4
4
|
import { loadAppIdentity } from "../project"
|
|
5
5
|
import { packFlux, packSolid } from "../packer"
|
|
6
6
|
import { buildPackFolder, writePackFolder } from "../pack-folder"
|
|
7
7
|
import { requireBinary } from "../util"
|
|
8
|
-
import { resolve } from "node:path"
|
|
8
|
+
import { dirname, join, resolve } from "node:path"
|
|
9
|
+
|
|
10
|
+
// Windows executables need the suffix; a user-given --output may already
|
|
11
|
+
// carry it.
|
|
12
|
+
function exeName(outfile: string): string {
|
|
13
|
+
return process.platform === "win32" && !outfile.toLowerCase().endsWith(".exe") ? outfile + ".exe" : outfile
|
|
14
|
+
}
|
|
9
15
|
|
|
10
16
|
// Write the packed executable, mark it runnable, and report its size.
|
|
11
17
|
async function writeExecutable(packed: Buffer, outfile: string) {
|
|
@@ -22,11 +28,15 @@ export async function runPackCommand() {
|
|
|
22
28
|
console.error("--folder is for app packs; flux scripts have no folder output")
|
|
23
29
|
process.exit(1)
|
|
24
30
|
}
|
|
25
|
-
let outfile = values.output ?? source!.replace(/\.[jt]
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
let outfile = exeName(values.output ?? source!.replace(/\.[jt]s$/, ""))
|
|
32
|
+
// The entry's isolate modules ride along as isolates/<id>.bin sections
|
|
33
|
+
// (module name = id, for stack attribution).
|
|
34
|
+
let isolates = []
|
|
35
|
+
for (let module of findFluxIsolates(dirname(resolve(source!)))) {
|
|
36
|
+
isolates.push({ id: module.id, bytecode: await compileToBytecode(await bundleFlux(module.path), module.id) })
|
|
28
37
|
}
|
|
29
|
-
|
|
38
|
+
if (isolates.length) console.log(`>> isolates: ${isolates.map((i) => i.id).join(", ")}`)
|
|
39
|
+
await writeExecutable(packFlux(await compileToBytecode(await bundleFlux(source!)), isolates), outfile)
|
|
30
40
|
process.exit()
|
|
31
41
|
}
|
|
32
42
|
|
|
@@ -44,12 +54,12 @@ export async function runPackCommand() {
|
|
|
44
54
|
let bundled = await bundleSolid()
|
|
45
55
|
let bytecode = await compileToBytecode(bundled.code)
|
|
46
56
|
let isolates = []
|
|
47
|
-
for (let i of bundled.isolates) isolates.push({ id: i.id, bytecode: await compileToBytecode(i.code) })
|
|
57
|
+
for (let i of bundled.isolates) isolates.push({ id: i.id, bytecode: await compileToBytecode(i.code, i.id) })
|
|
48
58
|
if (isolates.length) console.log(`>> isolates: ${isolates.map((i) => i.id).join(", ")}`)
|
|
49
59
|
let folder = buildPackFolder(source!, bytecode, isolates)
|
|
50
60
|
|
|
51
61
|
if (values.folder) {
|
|
52
|
-
let outDir = values.output ?? "dist"
|
|
62
|
+
let outDir = values.output ?? join("dist", "pack")
|
|
53
63
|
writePackFolder(outDir, requireBinary("solidrt"), bytecode, folder)
|
|
54
64
|
console.log(`>> wrote pack folder to ${resolve(outDir)}`)
|
|
55
65
|
process.exit()
|
package/src/commands/render.ts
CHANGED
|
@@ -1,11 +1,33 @@
|
|
|
1
1
|
import { appArgs, source, values } from "../args"
|
|
2
2
|
import { requireBinary, run } from "../util"
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { bundleSolid, writeIsolates } from "../bundler"
|
|
4
|
+
import { collectAssets, projectDirFor } from "../project"
|
|
5
|
+
import { cpSync, mkdirSync, rmSync } from "node:fs"
|
|
6
|
+
import { basename, dirname, join, resolve } from "path"
|
|
5
7
|
|
|
6
8
|
export async function runRenderCommand() {
|
|
7
|
-
let
|
|
8
|
-
|
|
9
|
+
let entry = resolve(source!)
|
|
10
|
+
let projectDir = projectDirFor(entry)
|
|
11
|
+
let result = await bundleSolid()
|
|
12
|
+
// The staged run dir (okf/backlog/build-output-dirs.md): bundle +
|
|
13
|
+
// isolates/ + assets/ under one root - the shape of an installed version
|
|
14
|
+
// dir, so the runtime's assets mount resolves both trees. Wiped first so
|
|
15
|
+
// removed isolates and deleted assets cannot go stale; render owns this
|
|
16
|
+
// subdir and nothing else under dist/.
|
|
17
|
+
let outDir = join(projectDir, "dist", "render")
|
|
18
|
+
rmSync(outDir, { recursive: true, force: true })
|
|
19
|
+
let jsOutfile = join(outDir, basename(entry).replace(/\.[jt]sx?$/, "") + ".srt.js")
|
|
20
|
+
await Bun.write(jsOutfile, result.code)
|
|
21
|
+
writeIsolates(join(outDir, "isolates"), result.isolates)
|
|
22
|
+
// The project's assets/ tree, copied in (dotfiles filtered, like a pack)
|
|
23
|
+
// so `assets/...` resolves like it does under the dev server and in a
|
|
24
|
+
// packed app (the runtime's cwd is the data sandbox, which holds no
|
|
25
|
+
// assets).
|
|
26
|
+
for (let asset of collectAssets(entry).assets) {
|
|
27
|
+
let dest = join(outDir, asset.path)
|
|
28
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
29
|
+
cpSync(join(projectDir, asset.path), dest)
|
|
30
|
+
}
|
|
9
31
|
let runner = requireBinary("solidrt-go")
|
|
10
32
|
let playbackArgs = ["--playback"]
|
|
11
33
|
if (values.fps) playbackArgs.push("--fps", values.fps)
|
|
@@ -15,7 +37,8 @@ export async function runRenderCommand() {
|
|
|
15
37
|
// Always absolute: the runtime chdirs into the app's data sandbox before
|
|
16
38
|
// frames are written, so a bare prefix would land the PNGs there.
|
|
17
39
|
playbackArgs.push("--out", resolve(values.output ?? "."))
|
|
18
|
-
playbackArgs.push(
|
|
40
|
+
playbackArgs.push("--assets", outDir)
|
|
41
|
+
playbackArgs.push(jsOutfile)
|
|
19
42
|
// The runner takes everything after the source path verbatim as the app's
|
|
20
43
|
// argument vector (flux:process argv).
|
|
21
44
|
playbackArgs.push(...appArgs)
|