@solidrt/cli 0.0.49 → 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 +15 -2
- package/package.json +7 -6
- package/scaffold/AGENTS.md +7 -1
- package/scaffold/package.json +5 -4
- package/src/args.ts +2 -2
- package/src/commands/init.ts +59 -75
- package/src/commands/mcp.ts +45 -8
- package/src/prompt.ts +46 -72
- package/scaffold/templates/components/template.json +0 -4
- package/scaffold/templates/default/template.json +0 -4
package/AGENTS.md
CHANGED
|
@@ -24,7 +24,11 @@ bundled `flux` runtime, not on Bun. Invoke via `bunx srt <command>`.
|
|
|
24
24
|
separately (server distributes code; clients on other devices connect to it).
|
|
25
25
|
- `bunx srt run src/index.tsx --capture out.script.json` - records keydown/keyup
|
|
26
26
|
from every connected client into one script file (written on client
|
|
27
|
-
disconnect), for replaying later with `render --script`.
|
|
27
|
+
disconnect), for replaying later with `render --script`. The file is JSON
|
|
28
|
+
Lines and hand-authorable - the exact shape (`after` ms, `type`, `key`,
|
|
29
|
+
`device`) is in `docs/cli.md` under `--capture`. For probing app state
|
|
30
|
+
without a display, `-- <args...>` reaches the app as `flux:process` argv
|
|
31
|
+
(also `docs/cli.md`), which is often simpler than scripting input.
|
|
28
32
|
|
|
29
33
|
## Verifying without a display (headless / CI / agent box)
|
|
30
34
|
|
|
@@ -35,7 +39,8 @@ Two reliable checks that need no GUI:
|
|
|
35
39
|
renders offscreen via EGL and writes `frame-NNNNNN.png`. This actually
|
|
36
40
|
proves the app renders. Combine with `--fps`/`--duration` (defaults
|
|
37
41
|
1280x720, 60fps, 1s). No display needed: rendering uses SDL's offscreen
|
|
38
|
-
driver
|
|
42
|
+
driver, or alloy's own EGL pbuffer where that driver cannot go headless
|
|
43
|
+
(see the ANGLE gotcha below).
|
|
39
44
|
|
|
40
45
|
Also headless: the bundled flux runtime runs a plain `.js` file directly -
|
|
41
46
|
`node_modules/@solidrt/<platform>/flux script.js` (e.g.
|
|
@@ -49,6 +54,14 @@ behavior in isolation.
|
|
|
49
54
|
- `--size` is physical output pixels: layout runs at exactly that size
|
|
50
55
|
(display scale is pinned to 1), so frames are identical on every machine.
|
|
51
56
|
- Run from the project directory. There is no `bunx --cwd` flag.
|
|
57
|
+
- On ANGLE stacks (Windows, macOS) SDL's offscreen driver cannot go
|
|
58
|
+
headless (no EGL device enumeration), so `render` there builds its own
|
|
59
|
+
EGL pbuffer context behind SDL's dummy video driver instead; the log says
|
|
60
|
+
"using a headless EGL context". If that also fails it renders into a
|
|
61
|
+
hidden window, which needs an interactive desktop session (fails under a
|
|
62
|
+
service, in Session 0, or over SSH-only). Verified headless on Linux
|
|
63
|
+
(Wayland) and the pbuffer path on Windows from a desktop session; a
|
|
64
|
+
non-interactive Windows session and macOS are unverified.
|
|
52
65
|
|
|
53
66
|
## Sessions (parallel dev servers on one machine)
|
|
54
67
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.50",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Antoine van Wel",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"@babel/core": "^7.0.0",
|
|
20
20
|
"@babel/plugin-syntax-jsx": "^7.0.0",
|
|
21
21
|
"@babel/preset-typescript": "^7.0.0",
|
|
22
|
+
"@clack/prompts": "^1.7.0",
|
|
22
23
|
"@jridgewell/remapping": "^2.3.0",
|
|
23
24
|
"@jridgewell/trace-mapping": "^0.3.25",
|
|
24
25
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
@@ -28,16 +29,16 @@
|
|
|
28
29
|
"zod": "^4.4.3"
|
|
29
30
|
},
|
|
30
31
|
"optionalDependencies": {
|
|
31
|
-
"@solidrt/darwin-arm64": "0.0.
|
|
32
|
-
"@solidrt/linux-arm64-gnu": "0.0.
|
|
33
|
-
"@solidrt/linux-x64-gnu": "0.0.
|
|
34
|
-
"@solidrt/win32-x64-msvc": "0.0.
|
|
32
|
+
"@solidrt/darwin-arm64": "0.0.50",
|
|
33
|
+
"@solidrt/linux-arm64-gnu": "0.0.50",
|
|
34
|
+
"@solidrt/linux-x64-gnu": "0.0.50",
|
|
35
|
+
"@solidrt/win32-x64-msvc": "0.0.50"
|
|
35
36
|
},
|
|
36
37
|
"peerDependencies": {
|
|
37
38
|
"typescript": "^7"
|
|
38
39
|
},
|
|
39
40
|
"devDependencies": {
|
|
40
|
-
"@solidrt/flux-types": "0.0.
|
|
41
|
+
"@solidrt/flux-types": "0.0.50",
|
|
41
42
|
"@types/babel__core": "^7.20.5",
|
|
42
43
|
"@types/bun": "latest"
|
|
43
44
|
}
|
package/scaffold/AGENTS.md
CHANGED
|
@@ -280,6 +280,12 @@ work stops being free" below is where it does not. Rules, in order of leverage:
|
|
|
280
280
|
snapshot instead of re-rasterizing. A window shader's output is invisible
|
|
281
281
|
to get_snapshot and every other MCP tool; `srt render` is the only way to
|
|
282
282
|
see it (Run / verify below).
|
|
283
|
+
7. `flux:wasm` is not the fast lane. It runs a pure interpreter (wasmi, no
|
|
284
|
+
JIT), so tight typed compute gains a small constant factor over the same
|
|
285
|
+
loop in JavaScript, nowhere near browser wasm speed, and every host call
|
|
286
|
+
costs marshalling. Use it to ship one compiled module across every target
|
|
287
|
+
without native binaries, not to speed up per-frame work; for that, rules
|
|
288
|
+
1-2 (move it to the GPU, cut property writes) are the leverage.
|
|
283
289
|
|
|
284
290
|
### Isolates: heavy work off the JS thread
|
|
285
291
|
|
|
@@ -432,7 +438,7 @@ its tools over guessing at runtime state:
|
|
|
432
438
|
re-fetch ids and restart cursors
|
|
433
439
|
- get_stats: fps, CPU/memory, frame phase timings, setProperty rate, plus
|
|
434
440
|
layout-activity counters for the last rebuild (nodes, measureCalls,
|
|
435
|
-
paraShapes, dirtiedNodes, cacheGets/cacheHits) - when layoutMs looks
|
|
441
|
+
paraShapes/wordHits, dirtiedNodes, cacheGets/cacheHits) - when layoutMs looks
|
|
436
442
|
wrong, these say whether the cost is text shaping, invalidation breadth,
|
|
437
443
|
or a defeated layout cache (healthy incremental rebuilds show a near-100%
|
|
438
444
|
cacheHits rate). reusedPerSec/skippedPerSec are the demand gate's visible
|
package/scaffold/package.json
CHANGED
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
"android": "srt client --android"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@solidrt/core": "0.0.
|
|
14
|
-
"@solidrt/components": "0.0.
|
|
13
|
+
"@solidrt/core": "0.0.50",
|
|
14
|
+
"@solidrt/components": "0.0.50",
|
|
15
|
+
"@solidrt/3d": "0.0.50"
|
|
15
16
|
},
|
|
16
17
|
"devDependencies": {
|
|
17
|
-
"@solidrt/cli": "0.0.
|
|
18
|
-
"@solidrt/flux-types": "0.0.
|
|
18
|
+
"@solidrt/cli": "0.0.50",
|
|
19
|
+
"@solidrt/flux-types": "0.0.50",
|
|
19
20
|
"typescript": "^7"
|
|
20
21
|
}
|
|
21
22
|
}
|
package/src/args.ts
CHANGED
|
@@ -35,7 +35,7 @@ export let { values, positionals } = parseArgs({
|
|
|
35
35
|
port: { type: "string" },
|
|
36
36
|
android: { type: "boolean", default: false },
|
|
37
37
|
device: { type: "string" },
|
|
38
|
-
|
|
38
|
+
with: { type: "string" },
|
|
39
39
|
},
|
|
40
40
|
allowPositionals: true,
|
|
41
41
|
})
|
|
@@ -159,7 +159,7 @@ Commands:
|
|
|
159
159
|
mcp MCP server (stdio) exposing the running dev server to coding agents
|
|
160
160
|
|
|
161
161
|
init options:
|
|
162
|
-
|
|
162
|
+
--with <pkg,pkg> Extensions to include, e.g. @solidrt/components,@solidrt/3d (skips the picker)
|
|
163
163
|
|
|
164
164
|
run/server options:
|
|
165
165
|
-s, --session <N> Session number: dev server on port 34884+N, client slot N (default: 0)
|
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
|
@@ -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,7 +243,7 @@ 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; 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).",
|
|
210
247
|
inputSchema: { client: CLIENT_ARG },
|
|
211
248
|
},
|
|
212
249
|
{
|
package/src/prompt.ts
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as clack from "@clack/prompts"
|
|
2
|
+
|
|
3
|
+
// Thin wrappers over @clack/prompts. Every prompt guards on a TTY: a non-TTY
|
|
4
|
+
// stdin resolves the default rather than blocking on input that will never
|
|
5
|
+
// arrive. Cancelling (ctrl-c) exits the process.
|
|
6
|
+
|
|
7
|
+
function unwrap<T>(value: T | symbol): T {
|
|
8
|
+
if (clack.isCancel(value)) {
|
|
9
|
+
clack.cancel("Cancelled")
|
|
10
|
+
process.exit(130)
|
|
11
|
+
}
|
|
12
|
+
return value as T
|
|
13
|
+
}
|
|
2
14
|
|
|
3
|
-
// Single-line text prompt
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
return new Promise<string>((resolve) => {
|
|
8
|
-
if (!process.stdin.isTTY) return resolve(def)
|
|
9
|
-
let rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
10
|
-
let suffix = def ? ` (${def})` : ""
|
|
11
|
-
rl.question(`? ${message}${suffix}: `, (answer) => {
|
|
12
|
-
rl.close()
|
|
13
|
-
resolve(answer.trim() || def)
|
|
14
|
-
})
|
|
15
|
-
})
|
|
15
|
+
// Single-line text prompt; a blank answer resolves the default.
|
|
16
|
+
export async function text(message: string, def = ""): Promise<string> {
|
|
17
|
+
if (!process.stdin.isTTY) return def
|
|
18
|
+
return unwrap(await clack.text({ message, defaultValue: def, placeholder: def }))
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
export interface SelectOption {
|
|
@@ -20,65 +23,36 @@ export interface SelectOption {
|
|
|
20
23
|
value: string
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
//
|
|
24
|
-
|
|
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> {
|
|
26
|
+
// Arrow-key single-select; non-TTY resolves the first option.
|
|
27
|
+
export async function select(message: string, options: Array<string | SelectOption>): Promise<string> {
|
|
31
28
|
let items = options.map((o) => (typeof o === "string" ? { label: o, value: o } : o))
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (!input.isTTY) return resolve(items[0]!.value)
|
|
36
|
-
|
|
37
|
-
let selected = 0
|
|
38
|
-
emitKeypressEvents(input)
|
|
39
|
-
let wasRaw = input.isRaw
|
|
40
|
-
input.setRawMode(true)
|
|
41
|
-
|
|
42
|
-
let render = (first = false) => {
|
|
43
|
-
// After the first paint the cursor sits below the block; move it back up
|
|
44
|
-
// to the message line so the list redraws in place.
|
|
45
|
-
if (!first) output.write(`\x1b[${items.length + 1}A`)
|
|
46
|
-
output.write(`\x1b[K? ${message}\n`)
|
|
47
|
-
for (let i = 0; i < items.length; i++) {
|
|
48
|
-
let active = i === selected
|
|
49
|
-
let pointer = active ? "\x1b[36m> " : " "
|
|
50
|
-
let reset = active ? "\x1b[0m" : ""
|
|
51
|
-
output.write(`\x1b[K${pointer}${items[i]!.label}${reset}\n`)
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
let cleanup = () => {
|
|
56
|
-
input.off("keypress", onKey)
|
|
57
|
-
input.setRawMode(wasRaw)
|
|
58
|
-
input.pause()
|
|
59
|
-
}
|
|
29
|
+
if (!process.stdin.isTTY) return items[0]!.value
|
|
30
|
+
return unwrap(await clack.select({ message, options: items }))
|
|
31
|
+
}
|
|
60
32
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
} else if (key.name === "down") {
|
|
67
|
-
selected = (selected + 1) % items.length
|
|
68
|
-
render()
|
|
69
|
-
} else if (key.name === "return" || key.name === "enter") {
|
|
70
|
-
cleanup()
|
|
71
|
-
output.write("\n")
|
|
72
|
-
resolve(items[selected]!.value)
|
|
73
|
-
} else if (key.ctrl && (key.name === "c" || key.name === "d")) {
|
|
74
|
-
cleanup()
|
|
75
|
-
output.write("\n")
|
|
76
|
-
process.exit(130)
|
|
77
|
-
}
|
|
78
|
-
}
|
|
33
|
+
export interface MultiSelectOption {
|
|
34
|
+
label: string
|
|
35
|
+
value: string
|
|
36
|
+
checked?: boolean
|
|
37
|
+
}
|
|
79
38
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
39
|
+
// Space toggles, enter confirms; resolves the selected values in option
|
|
40
|
+
// order. Non-TTY resolves the preselected values.
|
|
41
|
+
export async function multiselect(message: string, options: MultiSelectOption[]): Promise<string[]> {
|
|
42
|
+
let preset = options.filter((o) => o.checked).map((o) => o.value)
|
|
43
|
+
if (!process.stdin.isTTY) return preset
|
|
44
|
+
let picked = unwrap(
|
|
45
|
+
await clack.multiselect({
|
|
46
|
+
message,
|
|
47
|
+
options: options.map((o) => ({ label: o.label, value: o.value })),
|
|
48
|
+
initialValues: preset,
|
|
49
|
+
required: false,
|
|
50
|
+
}),
|
|
51
|
+
)
|
|
52
|
+
return options.filter((o) => picked.includes(o.value)).map((o) => o.value)
|
|
84
53
|
}
|
|
54
|
+
|
|
55
|
+
// Boxed informational message; silent on a non-TTY.
|
|
56
|
+
export function note(message: string, title?: string) {
|
|
57
|
+
if (process.stdin.isTTY) clack.note(message, title)
|
|
58
|
+
}
|