@solidrt/cli 0.0.50 → 0.0.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/AGENTS.md +79 -36
  2. package/README.md +86 -15
  3. package/agents/assets.md +46 -0
  4. package/agents/debugging.md +294 -0
  5. package/dist/console.srtapp +94123 -73
  6. package/dist/server.js +3936 -0
  7. package/package.json +11 -10
  8. package/src/android/docs.md +21 -0
  9. package/src/android/main.ts +286 -0
  10. package/src/{bundler.ts → bundle/bundler.ts} +163 -76
  11. package/src/bundle/docs.md +12 -0
  12. package/src/bundle/main.ts +203 -0
  13. package/src/check/docs.md +10 -0
  14. package/src/check/main.ts +85 -0
  15. package/src/{commands/check.ts → check/typecheck.ts} +11 -34
  16. package/src/client/docs.md +9 -0
  17. package/src/client/main.ts +33 -0
  18. package/src/console/docs.md +14 -0
  19. package/src/console/main.ts +21 -0
  20. package/src/demo/docs.md +27 -0
  21. package/src/demo/main.ts +67 -0
  22. package/src/init/docs.md +11 -0
  23. package/src/{commands/init.ts → init/main.ts} +31 -25
  24. package/src/init/scaffold/AGENTS.md +98 -0
  25. package/src/init/scaffold/package.json +23 -0
  26. package/{scaffold → src/init/scaffold}/templates/components/index.tsx +2 -3
  27. package/{scaffold → src/init/scaffold}/templates/default/index.tsx +2 -3
  28. package/src/lib/args.ts +194 -0
  29. package/src/{artifacts.ts → lib/artifacts.ts} +41 -1
  30. package/src/{dev-dir.ts → lib/dev-dir.ts} +10 -8
  31. package/src/{fonts.ts → lib/fonts.ts} +12 -26
  32. package/src/lib/mode.ts +77 -0
  33. package/src/{project.ts → lib/project.ts} +109 -61
  34. package/src/lib/registry.ts +120 -0
  35. package/src/lib/server-bundle.ts +24 -0
  36. package/src/lib/usage.ts +117 -0
  37. package/src/lib/util.ts +36 -0
  38. package/src/main.ts +109 -31
  39. package/src/mcp/docs.md +22 -0
  40. package/src/mcp/main.ts +719 -0
  41. package/src/pack/docs.md +18 -0
  42. package/src/{pack-folder.ts → pack/layout.ts} +13 -22
  43. package/src/pack/main.ts +86 -0
  44. package/src/pack/trailer.ts +97 -0
  45. package/src/render/docs.md +19 -0
  46. package/src/render/main.ts +49 -0
  47. package/src/server/args.ts +126 -0
  48. package/src/server/binaries.ts +47 -0
  49. package/src/server/config.ts +54 -0
  50. package/{server → src/server}/control.ts +246 -78
  51. package/src/server/docs.md +51 -0
  52. package/src/server/line-editor.ts +200 -0
  53. package/src/server/main.ts +473 -0
  54. package/src/server/mode.ts +92 -0
  55. package/src/server/rebuild.ts +90 -0
  56. package/src/server/registry.ts +138 -0
  57. package/src/server/remap.ts +60 -0
  58. package/src/server/repl.ts +223 -0
  59. package/src/server/state.ts +54 -0
  60. package/{server → src/server}/tsconfig.json +1 -1
  61. package/{server → src/server}/tunnel.ts +6 -6
  62. package/src/server/watcher.ts +121 -0
  63. package/src/tool/main.ts +70 -0
  64. package/src/types/bundle.d.ts +24 -0
  65. package/src/types/control.d.ts +90 -0
  66. package/src/types/registry.d.ts +16 -0
  67. package/scaffold/AGENTS.md +0 -630
  68. package/scaffold/package.json +0 -22
  69. package/scaffold/templates/components/icon.tsx +0 -48
  70. package/scaffold/templates/default/icon.tsx +0 -48
  71. package/server/main.ts +0 -308
  72. package/server/rebuild.ts +0 -68
  73. package/server/remap.ts +0 -47
  74. package/server/state.ts +0 -93
  75. package/src/args.ts +0 -212
  76. package/src/bundle-cli.ts +0 -13
  77. package/src/commands/bundle.ts +0 -76
  78. package/src/commands/client.ts +0 -34
  79. package/src/commands/mcp.ts +0 -603
  80. package/src/commands/pack.ts +0 -65
  81. package/src/commands/render.ts +0 -24
  82. package/src/commands/server.ts +0 -73
  83. package/src/dev-android.ts +0 -176
  84. package/src/dev-client.ts +0 -29
  85. package/src/dev-server.ts +0 -302
  86. package/src/packer.ts +0 -103
  87. package/src/repl.ts +0 -233
  88. package/src/util.ts +0 -121
  89. package/src/watcher.ts +0 -69
  90. /package/src/{untyped-deps.d.ts → bundle/untyped-deps.d.ts} +0 -0
  91. /package/src/{prompt.ts → init/prompt.ts} +0 -0
  92. /package/{scaffold → src/init/scaffold}/gitignore +0 -0
  93. /package/{scaffold → src/init/scaffold}/icon.svg +0 -0
  94. /package/{scaffold → src/init/scaffold}/mcp.json +0 -0
  95. /package/{scaffold → src/init/scaffold}/tsconfig.json +0 -0
  96. /package/{server → src/server}/cache.ts +0 -0
  97. /package/{server → src/server}/proxy.ts +0 -0
  98. /package/{server → src/server}/qr.ts +0 -0
@@ -0,0 +1,92 @@
1
+ // What this server serves, decided by the cwd and the entry argument and
2
+ // never by searching upward. The rule and its table live in
3
+ // packages/cli/src/lib/mode.ts (the bun copy the one-shot commands use); this is
4
+ // the same table for the server, over flux:fs - two honest copies rather
5
+ // than an fs shim (okf/done/srt-command-folders.md):
6
+ //
7
+ // cwd has package.json argument mode
8
+ // yes none project (entry = solidrt.entry, default src/index.tsx)
9
+ // no none error
10
+ // no file file
11
+ // yes file error, unless --project (project at cwd,
12
+ // entry overridden) or --file (ignore the project)
13
+ //
14
+ // The key (the canonical project root, or the canonical file path) is what
15
+ // the registry and every control response name.
16
+
17
+ import { file, realpath } from "flux:fs"
18
+ import { join } from "flux:path"
19
+ import { fail } from "./args"
20
+
21
+ export type Mode =
22
+ | { mode: "project"; key: string; projectDir: string; entry: string }
23
+ | { mode: "file"; key: string; projectDir: null; entry: string }
24
+
25
+ const DEFAULT_ENTRY = "src/index.tsx"
26
+ // A prebuilt .srt.js ends with .js, so it is admitted by the same list.
27
+ export const ENTRY_EXTENSIONS = [".tsx", ".jsx", ".ts", ".js"]
28
+
29
+ export async function resolveMode(args: { entry: string | undefined; project: boolean; file: boolean }): Promise<Mode> {
30
+ let cwd = await realpath(".")
31
+ let hasPkg = await file(join(cwd, "package.json")).exists()
32
+ let source = args.entry
33
+
34
+ if (source !== undefined && !ENTRY_EXTENSIONS.some((ext) => source.endsWith(ext))) {
35
+ fail(`Not an app entry: ${source} (expected .tsx, .jsx, .ts, .js or .srt.js)`)
36
+ }
37
+ if (source !== undefined && !(await file(source).exists())) fail(`Entry not found: ${source}`)
38
+ if (args.file && args.project) fail("--file and --project exclude each other")
39
+
40
+ if (source === undefined) {
41
+ if (args.file || args.project) fail("--file and --project need an entry file")
42
+ if (!hasPkg) fail(`No package.json in ${cwd}. Run from the project root, or pass a file to use on its own.`)
43
+ let declared = await declaredEntry(cwd)
44
+ let entry = join(cwd, declared ?? DEFAULT_ENTRY)
45
+ if (!(await file(entry).exists())) {
46
+ fail(`Entry not found: ${entry}${declared ? "" : ' (set "solidrt": { "entry": ... } in package.json)'}`)
47
+ }
48
+ return { mode: "project", key: cwd, projectDir: cwd, entry }
49
+ }
50
+
51
+ let entry = await realpath(source)
52
+ if (hasPkg && !args.file && !args.project) {
53
+ fail(
54
+ `${cwd} is a project (it has a package.json) and ${source} is a file: pass --project to use the project with this entry, or --file to use the file on its own.`,
55
+ )
56
+ }
57
+ if (hasPkg && args.project) return { mode: "project", key: cwd, projectDir: cwd, entry }
58
+ if (!hasPkg && args.project) fail(`--project needs a package.json in ${cwd}`)
59
+ return { mode: "file", key: entry, projectDir: null, entry }
60
+ }
61
+
62
+ // The project's declared entry (`"solidrt": { "entry" }` in package.json),
63
+ // relative to the project root. Only the one field the server needs is
64
+ // checked here; the bun commands validate the whole key (src/lib/project.ts).
65
+ async function declaredEntry(dir: string): Promise<string | undefined> {
66
+ let pkg = await file(join(dir, "package.json"))
67
+ .json()
68
+ .catch(() => fail(`Unreadable package.json in ${dir}`))
69
+ let config = pkg?.solidrt
70
+ if (config === undefined) return undefined
71
+ if (typeof config !== "object" || config === null || Array.isArray(config)) {
72
+ fail('"solidrt" in package.json must be an object')
73
+ }
74
+ if ("entry" in config && typeof config.entry !== "string") fail('"solidrt": "entry" must be a string')
75
+ return config.entry
76
+ }
77
+
78
+ /** The directory the file routes serve: the entry's directory. */
79
+ export function sourceDirOf(mode: Mode): string {
80
+ return dirname(mode.entry)
81
+ }
82
+
83
+ /** The parent of a path (flux:path has no dirname); "." for a bare name. */
84
+ export function dirname(path: string): string {
85
+ let i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))
86
+ return i <= 0 ? (i === 0 ? path.slice(0, 1) : ".") : path.slice(0, i)
87
+ }
88
+
89
+ /** `path` made absolute against `base` when it is not already. */
90
+ export function absolute(path: string, base: string): string {
91
+ return /^([A-Za-z]:)?[\\/]/.test(path) ? path : join(base, path)
92
+ }
@@ -0,0 +1,90 @@
1
+ import { command } from "flux:subprocess"
2
+ import { dir, file } from "flux:fs"
3
+ import { state } from "./state"
4
+ import { armWatcher } from "./watcher"
5
+ import type { BundleOutput } from "../types/bundle"
6
+
7
+ // The single rebuild-and-push path: the initial bundle at start, every
8
+ // MCP reload and every reload-on-save go through here. The bundle itself
9
+ // runs in a bun subprocess (`srt bundle --json`, the one thing only bun can
10
+ // do); this side latches the result for late-joining clients, broadcasts
11
+ // it, and re-arms the watcher from the bundle's inputs (watcher.ts).
12
+
13
+ // Build the reload message for the client protocol. `manifest` is the
14
+ // bundle's version manifest JSON string; when present, clients install the
15
+ // push into their version store before applying it (absent for the
16
+ // build-failure trigger, which must never be installed). proxyHttp is a
17
+ // message flag, not a build input.
18
+ function buildReload(code: string, manifest?: string) {
19
+ let config = state.config
20
+ return {
21
+ type: "reload",
22
+ proxyHttp: config.proxyHttp,
23
+ args: config.args,
24
+ ...(manifest ? { manifest } : {}),
25
+ code,
26
+ }
27
+ }
28
+
29
+ // Reload code that fails to start the engine on purpose. The runtime treats a
30
+ // startup error like any app that never called render() and falls back to its
31
+ // baked-in BSOD screen, so a build that doesn't compile shows the BSOD instead
32
+ // of leaving the previous app frozen on screen.
33
+ const BSOD_TRIGGER = `throw new Error("SolidRT: build failed")`
34
+
35
+ function latchAndSend(text: string) {
36
+ state.currentReload = text
37
+ for (let [ws, info] of state.clients) {
38
+ // The runtime restarts the app's clock with the app.
39
+ info.timeScale = 1
40
+ ws.send(text)
41
+ }
42
+ }
43
+
44
+ /** Latch the build-failure trigger and push it, so every client (and any
45
+ * that connects later) shows the BSOD instead of a stale app. */
46
+ export function showBuildFailure() {
47
+ state.currentMaps = null
48
+ latchAndSend(JSON.stringify(buildReload(BSOD_TRIGGER)))
49
+ }
50
+
51
+ // Rebuild from config.entry via `srt bundle --json` (a bun subprocess run
52
+ // in config.cwd with config.entryArgs, so it resolves the mode this server
53
+ // did), then latch (for late-joining clients) and broadcast the reload to
54
+ // every connected client. Resolves with an error message on failure (a build
55
+ // error), or null on success.
56
+ export async function rebuildAndBroadcast(): Promise<string | null> {
57
+ let config = state.config
58
+ let args = [...config.srt.slice(1), "bundle", "--json", "--dev", ...config.entryArgs]
59
+ if (config.minify) args.push("--minify")
60
+
61
+ let result = await command(config.srt[0]!, args, { cwd: config.cwd }).output()
62
+ if (!result.success) {
63
+ let stderr = typeof result.stderr === "string" ? result.stderr : ""
64
+ armWatcher(null)
65
+ return `Rebuild failed:\n${stderr.trim()}`
66
+ }
67
+
68
+ // The bundle is one JSON object on stdout (types/bundle.d.ts).
69
+ let bundle: BundleOutput
70
+ try {
71
+ bundle = JSON.parse(typeof result.stdout === "string" ? result.stdout : "")
72
+ } catch {
73
+ armWatcher(null)
74
+ return "Rebuild failed: unreadable bundler output"
75
+ }
76
+ armWatcher(bundle.inputs ?? null)
77
+ // Isolate bundles are manifest assets clients fetch from our /isolates/
78
+ // route (served from cacheDir), so they must be on disk before the push.
79
+ let maps: Record<string, string> = {}
80
+ if (bundle.map) maps.main = bundle.map
81
+ for (let isolate of bundle.isolates ?? []) {
82
+ let path = `${config.cacheDir}/isolates/${isolate.id}.js`
83
+ await dir(path.slice(0, path.lastIndexOf("/"))).create()
84
+ await file(path).write(isolate.code)
85
+ if (isolate.map) maps[isolate.id] = isolate.map
86
+ }
87
+ state.currentMaps = Object.keys(maps).length ? maps : null
88
+ latchAndSend(JSON.stringify(buildReload(bundle.code ?? "", bundle.manifest)))
89
+ return null
90
+ }
@@ -0,0 +1,138 @@
1
+ // The dev server registry: ~/.solidrt/servers/<key hash>/live.json, one
2
+ // folder per server key, written by the server itself once its port is
3
+ // bound and removed when it exits, so `srt client`, `srt mcp` and the
4
+ // console resolve a server without any per-project config
5
+ // (okf/backlog/cli-flux-migration.md). The process that owns the pid and the
6
+ // port owns the record, so a record whose pid is dead is stale and nothing
7
+ // else; readers still confirm with a control call. The bun readers live in
8
+ // packages/cli/src/lib/registry.ts and dev-dir.ts (the same paths and hash).
9
+ //
10
+ // The folder also remembers the last bound port so the next run tries it
11
+ // first: a project keeps its port in practice, and tunnel tickets and client
12
+ // recents stay valid, without anyone choosing a number.
13
+
14
+ import { dir, file, realpath } from "flux:fs"
15
+ import { join } from "flux:path"
16
+ import { alive, homedir, pid } from "flux:process"
17
+ import { fail } from "./args"
18
+ import type { ServerConfig } from "./config"
19
+ import type { LiveRecord } from "../types/registry"
20
+
21
+ const RECORD_FILE = "live.json"
22
+ const PORT_FILE = "port"
23
+
24
+ /** All dev-tooling state lives in ~/.solidrt (one rule on every platform). */
25
+ export function devDir(...parts: string[]): string {
26
+ let home = homedir()
27
+ if (!home) fail("No home directory: the dev server keeps its state under ~/.solidrt")
28
+ return join(home, ".solidrt", ...parts)
29
+ }
30
+
31
+ /** A server's folder: `servers/<sha256 of its canonical key, truncated>/`.
32
+ * The name is never parsed back; live.json inside is the record. */
33
+ export async function serverDirFor(key: string): Promise<string> {
34
+ let digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key))
35
+ let hex = Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("")
36
+ return devDir("servers", hex.slice(0, 16))
37
+ }
38
+
39
+ type Entry = { path: string; record: LiveRecord }
40
+
41
+ // Every well-formed record on disk, alive or not, with its file path.
42
+ // Malformed records are skipped, not fatal.
43
+ async function records(): Promise<Entry[]> {
44
+ let root = devDir("servers")
45
+ let entries = await dir(root)
46
+ .entries()
47
+ .catch(() => [])
48
+ let found: Entry[] = []
49
+ for (let entry of entries) {
50
+ if (entry.type !== "directory") continue
51
+ let path = join(root, entry.name, RECORD_FILE)
52
+ let record = await file(path)
53
+ .json()
54
+ .catch(() => null)
55
+ if (typeof record?.pid === "number" && typeof record?.port === "number" && typeof record?.key === "string") {
56
+ found.push({ path, record })
57
+ }
58
+ }
59
+ return found
60
+ }
61
+
62
+ /** Every readable record whose process is alive. */
63
+ export async function liveRecords(): Promise<LiveRecord[]> {
64
+ return (await records()).filter((e) => alive(e.record.pid)).map((e) => e.record)
65
+ }
66
+
67
+ /** Drop every record whose process is gone. A server removes its own record
68
+ * on exit, so one left behind is a crash's fossil. Runs at server start, the
69
+ * one moment every registry user passes through, so readers never write. */
70
+ export async function pruneDeadRecords() {
71
+ for (let { path, record } of await records()) {
72
+ if (!alive(record.pid)) await file(path).remove().catch(() => {})
73
+ }
74
+ }
75
+
76
+ // Whether the control API on the record's port answers for the record's key:
77
+ // every control response names the key it serves.
78
+ async function serves(record: LiveRecord): Promise<boolean> {
79
+ let control = new AbortController()
80
+ let timer = setTimeout(() => control.abort(), 1000)
81
+ try {
82
+ let resp = await fetch(`http://127.0.0.1:${record.port}/__control__/clients`, { signal: control.signal })
83
+ return resp.headers.get("x-solidrt-project") === record.key
84
+ } catch {
85
+ return false
86
+ } finally {
87
+ clearTimeout(timer)
88
+ }
89
+ }
90
+
91
+ /** The running server for the canonical `key`, if any. Keys from different
92
+ * processes agree on the path, not the spelling, so compare canonical. The
93
+ * record is a hint and the server is authoritative: a candidate whose server
94
+ * does not answer for the key is a pid reused by an unrelated process, and
95
+ * is dropped instead of reported as a clash. */
96
+ export async function runningFor(key: string): Promise<LiveRecord | undefined> {
97
+ for (let { path, record } of await records()) {
98
+ if (!alive(record.pid)) continue
99
+ if (record.key === key || (await realpath(record.key).catch(() => null)) === key) {
100
+ if (await serves(record)) return record
101
+ await file(path).remove().catch(() => {})
102
+ }
103
+ }
104
+ return undefined
105
+ }
106
+
107
+ /** The port this server bound last time, or null on a first run. */
108
+ export async function rememberedPort(serverDir: string): Promise<number | null> {
109
+ let text = await file(join(serverDir, PORT_FILE))
110
+ .text()
111
+ .catch(() => "")
112
+ let port = Number(text.trim())
113
+ return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null
114
+ }
115
+
116
+ /** Write the record and remember the port. */
117
+ export async function writeRecord(config: ServerConfig, port: number, address: string) {
118
+ await dir(config.serverDir).create()
119
+ let record: LiveRecord = {
120
+ pid,
121
+ port,
122
+ address,
123
+ key: config.key,
124
+ mode: config.mode,
125
+ entry: config.entry,
126
+ projectDir: config.projectDir,
127
+ started: new Date().toISOString(),
128
+ }
129
+ await file(join(config.serverDir, RECORD_FILE)).write(JSON.stringify(record))
130
+ await file(join(config.serverDir, PORT_FILE)).write(String(port))
131
+ }
132
+
133
+ /** Drop the record; the port memory and the tunnel key stay. */
134
+ export async function removeRecord(serverDir: string) {
135
+ await file(join(serverDir, RECORD_FILE))
136
+ .remove()
137
+ .catch(() => {})
138
+ }
@@ -0,0 +1,60 @@
1
+ import { TraceMap, originalPositionFor } from "@jridgewell/trace-mapping"
2
+
3
+ // Stack-trace remapping for forwarded client logs. The runtime evaluates the
4
+ // app bundle as module "main" and each isolate bundle under its isolate id,
5
+ // so QuickJS frames cite bundle positions like "at boom (main:212:9)" or
6
+ // "at boom (worker:65:13)". With the current reload's sourcemaps latched on
7
+ // the server (state.currentMaps, keyed by module name), those positions are
8
+ // rewritten to the original .tsx sources before a log entry is buffered. A
9
+ // module without a map is left as it is: a position is never remapped against
10
+ // another module's map.
11
+
12
+ // Parsed maps are cached per module name and map text; a reload swaps the
13
+ // texts and the next lookup rebuilds the tracers. Entries for removed modules
14
+ // linger unused, which is harmless.
15
+ let cached = new Map<string, { text: string; tracer: TraceMap | null }>()
16
+
17
+ function tracerFor(name: string, text: string): TraceMap | null {
18
+ let entry = cached.get(name)
19
+ if (!entry || entry.text !== text) {
20
+ let tracer: TraceMap | null = null
21
+ try {
22
+ tracer = new TraceMap(JSON.parse(text))
23
+ } catch {
24
+ // A malformed map disables remapping for this module until the next reload.
25
+ }
26
+ entry = { text, tracer }
27
+ cached.set(name, entry)
28
+ }
29
+ return entry.tracer
30
+ }
31
+
32
+ let REGEX_SPECIALS = /[.*+?^${}()|[\]\\]/g
33
+
34
+ /**
35
+ * Rewrite every "NAME:LINE:COL" (or "NAME:LINE") position in `text`, for each
36
+ * module NAME in `maps`, to its original source position, e.g.
37
+ * "src/app.tsx:42:7". Positions a map has no entry for, positions of modules
38
+ * without a map, and all text when `maps` is null, pass through unchanged.
39
+ * QuickJS lines and columns are 1-based; sourcemap columns are 0-based.
40
+ */
41
+ export function remapPositions(text: string, maps: Record<string, string> | null): string {
42
+ if (!maps) return text
43
+ for (let [name, map] of Object.entries(maps)) {
44
+ if (!text.includes(name + ":")) continue
45
+ let t = tracerFor(name, map)
46
+ if (!t) continue
47
+ // The leading capture keeps a name from matching inside a longer one
48
+ // ("audio" inside "workers/audio" or "app" inside "src/app.tsx").
49
+ let pattern = new RegExp(`(^|[^\\w/.$-])${name.replace(REGEX_SPECIALS, "\\$&")}:(\\d+)(?::(\\d+))?\\b`, "g")
50
+ text = text.replace(pattern, (frame, prefix, line, column) => {
51
+ let pos = originalPositionFor(t, {
52
+ line: parseInt(line, 10),
53
+ column: column ? Math.max(parseInt(column, 10) - 1, 0) : 0,
54
+ })
55
+ if (pos.source == null || pos.line == null) return frame
56
+ return `${prefix}${pos.source}:${pos.line}:${(pos.column ?? 0) + 1}`
57
+ })
58
+ }
59
+ return text
60
+ }
@@ -0,0 +1,223 @@
1
+ import { isTTY, on, write } from "flux:tty"
2
+ import { dir } from "flux:fs"
3
+ import type { ServerWebSocket } from "flux:http"
4
+ import { state } from "./state"
5
+ import { rebuildAndBroadcast, showBuildFailure } from "./rebuild"
6
+ import { clientList, loadEntry, setStats, setUserInputMuted, setWatchActive } from "./control"
7
+ import { ENTRY_EXTENSIONS, absolute } from "./mode"
8
+ import { startLineEditor } from "./line-editor"
9
+ import type { Completion } from "./line-editor"
10
+
11
+ // The repl on the server's own terminal: the same actions the control API
12
+ // offers a coding agent, typed by hand. A line editor over flux:tty raw mode
13
+ // (line-editor.ts: history, Tab completion of commands and `load` paths);
14
+ // where raw mode is refused, cooked lines from the terminal's own line
15
+ // discipline. Only with a terminal on stdin: a server started by a
16
+ // supervisor, the console or a background `&` has none, runs without a
17
+ // prompt, and stops on a signal.
18
+
19
+ const PROMPT = "srt> "
20
+ const HELP = "Commands: load <file>, reload [id...], stop [id...], list, stats [on|off], watch on|off, mute on|off, quit, help"
21
+ const COMMANDS = ["load ", "reload", "stop", "list", "stats", "watch ", "mute ", "quit", "exit", "help"]
22
+
23
+ // The clients named by a list of ids ("0 2", as `list` prints them), or
24
+ // every client for no ids. Unknown ids are reported and skipped.
25
+ function targets(args: string): ServerWebSocket[] {
26
+ let entries = [...state.clients.entries()]
27
+ if (!args) return entries.map(([ws]) => ws)
28
+ let found: ServerWebSocket[] = []
29
+ for (let token of args.split(/\s+/)) {
30
+ let id = parseInt(token, 10)
31
+ let entry = entries.find(([, info]) => info.id === id)
32
+ if (!entry) {
33
+ console.log(`No client with id ${token}`)
34
+ continue
35
+ }
36
+ found.push(entry[0])
37
+ }
38
+ return found
39
+ }
40
+
41
+ function describe(args: string, count: number): string {
42
+ return args ? `client(s) ${args.split(/\s+/).join(", ")}` : `all ${count} client(s)`
43
+ }
44
+
45
+ async function cmdReload(args: string) {
46
+ let error = await rebuildAndBroadcast()
47
+ if (error) {
48
+ console.error(error)
49
+ showBuildFailure()
50
+ return
51
+ }
52
+ if (args) {
53
+ // rebuildAndBroadcast pushed to everyone; a selective reload re-sends
54
+ // the latched bundle to the named clients only, which is what it meant
55
+ // before the push existed. Kept for the one case it still serves: a
56
+ // client that missed the broadcast.
57
+ for (let ws of targets(args)) if (state.currentReload) ws.send(state.currentReload)
58
+ }
59
+ console.log(`[cli] Sent reload to ${describe(args, state.clients.size)}`)
60
+ }
61
+
62
+ function cmdStop(args: string) {
63
+ let text = JSON.stringify({ type: "stop" })
64
+ let list = targets(args)
65
+ for (let ws of list) ws.send(text)
66
+ if (list.length) console.log(`[cli] Sent stop to ${describe(args, list.length)}`)
67
+ }
68
+
69
+ async function cmdLoad(file: string) {
70
+ if (!file) {
71
+ console.log("Usage: load <file>")
72
+ return
73
+ }
74
+ let result = await loadEntry(file)
75
+ if ("error" in result) {
76
+ console.error(result.error)
77
+ if (result.status === 502) showBuildFailure()
78
+ return
79
+ }
80
+ console.log(`[cli] Loaded ${result.entry}`)
81
+ }
82
+
83
+ function cmdList() {
84
+ let clients = clientList(true)
85
+ if (clients.length === 0) {
86
+ console.log("No connected clients")
87
+ return
88
+ }
89
+ console.log(`${clients.length} connected client(s):`)
90
+ for (let c of clients) {
91
+ console.log(` ${c.id}: ${c.address ?? "unknown"} [${c.platform}, ${c.version}]`)
92
+ }
93
+ }
94
+
95
+ // "on" | "off" | "" (toggle) for the three switches; anything else is a
96
+ // usage error, reported with the command's usage line.
97
+ function toggle(args: string, current: boolean, usage: string): boolean | undefined {
98
+ if (args === "on") return true
99
+ if (args === "off") return false
100
+ if (!args) return !current
101
+ console.log(usage)
102
+ return undefined
103
+ }
104
+
105
+ function cmdStats(args: string) {
106
+ let on = toggle(args, state.stats, "Usage: stats [on|off]")
107
+ if (on === undefined) return
108
+ setStats(on)
109
+ console.log(`[cli] Stats overlay ${on ? "on" : "off"}`)
110
+ }
111
+
112
+ function cmdWatch(args: string) {
113
+ let on = toggle(args, !state.watchPaused, "Usage: watch on|off")
114
+ if (on === undefined) return
115
+ setWatchActive(on)
116
+ }
117
+
118
+ function cmdMute(args: string) {
119
+ let on = toggle(args, state.userInputMuted, "Usage: mute on|off")
120
+ if (on === undefined) return
121
+ setUserInputMuted(on)
122
+ }
123
+
124
+ // The word and the rest of the line, trimmed.
125
+ function split(line: string): [string, string] {
126
+ let cmd = line.trim()
127
+ let i = cmd.indexOf(" ")
128
+ return i < 0 ? [cmd, ""] : [cmd.slice(0, i), cmd.slice(i + 1).trim()]
129
+ }
130
+
131
+ async function dispatch(line: string, quit: () => void): Promise<void> {
132
+ let [cmd, args] = split(line)
133
+ switch (cmd) {
134
+ case "":
135
+ return
136
+ case "reload":
137
+ return cmdReload(args)
138
+ case "stop":
139
+ return cmdStop(args)
140
+ case "load":
141
+ return cmdLoad(args)
142
+ case "list":
143
+ return cmdList()
144
+ case "stats":
145
+ return cmdStats(args)
146
+ case "watch":
147
+ return cmdWatch(args)
148
+ case "mute":
149
+ return cmdMute(args)
150
+ case "quit":
151
+ case "exit":
152
+ return quit()
153
+ case "help":
154
+ console.log(HELP)
155
+ return
156
+ default:
157
+ console.log(`Unknown command: ${cmd}`)
158
+ }
159
+ }
160
+
161
+ // Tab completion: a command name, or for `load` a path relative to what
162
+ // loadEntry resolves against (the project dir, or the served file's dir):
163
+ // directories and app entries only.
164
+ async function complete(line: string): Promise<Completion> {
165
+ if (!line.startsWith("load ")) {
166
+ return { matches: COMMANDS.filter((c) => c.startsWith(line)), replace: line }
167
+ }
168
+ let partial = line.slice(5)
169
+ let slash = partial.lastIndexOf("/")
170
+ let dirPart = slash < 0 ? "" : partial.slice(0, slash + 1)
171
+ let prefix = partial.slice(dirPart.length)
172
+ let base = state.config.projectDir ?? state.config.sourceDir
173
+ let matches: string[] = []
174
+ try {
175
+ for (let entry of await dir(absolute(dirPart || ".", base)).entries()) {
176
+ if (!entry.name.startsWith(prefix)) continue
177
+ if (entry.type === "directory") matches.push(entry.name + "/")
178
+ else if (ENTRY_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) matches.push(entry.name)
179
+ }
180
+ } catch {}
181
+ return { matches, replace: prefix }
182
+ }
183
+
184
+ // Start the repl when a terminal is attached; `quit` ends the server (the
185
+ // quit command, Ctrl-C, or stdin closing). Returns the function that
186
+ // detaches it, for the server's shutdown.
187
+ export function startRepl(quit: () => void): () => void {
188
+ if (!isTTY) {
189
+ console.log("[cli] No terminal on stdin, running without the repl")
190
+ return () => {}
191
+ }
192
+ try {
193
+ return startLineEditor({ prompt: PROMPT, onLine: (line) => dispatch(line, quit), onQuit: quit, complete })
194
+ } catch (e) {
195
+ console.log(`[cli] ${String(e)}; using plain line input`)
196
+ return startLineInput(quit)
197
+ }
198
+ }
199
+
200
+ // Cooked-mode fallback: the terminal edits the line, this side prompts.
201
+ // Commands run one at a time: a line typed while one runs waits for it, so
202
+ // its output and the next prompt keep their order. No prompt once the
203
+ // server is shutting down (quit, or a signal mid-command).
204
+ function startLineInput(quit: () => void): () => void {
205
+ let attached = true
206
+ let prompt = () => {
207
+ if (attached) write(PROMPT)
208
+ }
209
+ let chain = Promise.resolve()
210
+ let offLine = on("line", (line) => {
211
+ chain = chain.then(() => dispatch(line, quit)).then(prompt, (e) => {
212
+ console.error(`[cli] ${String(e)}`)
213
+ prompt()
214
+ })
215
+ })
216
+ let offClose = on("close", quit)
217
+ prompt()
218
+ return () => {
219
+ attached = false
220
+ offLine()
221
+ offClose()
222
+ }
223
+ }
@@ -0,0 +1,54 @@
1
+ // Server-side state shared by the route handlers: what the protocol needs,
2
+ // plus what main.ts resolved at startup (config.ts).
3
+
4
+ import type { ServerWebSocket } from "flux:http"
5
+ import type { ServerConfig } from "./config"
6
+ import type { ClientEntry } from "../types/control"
7
+
8
+ export let state = {
9
+ config: undefined as unknown as ServerConfig,
10
+ clients: new Map<ServerWebSocket, ClientEntry>(),
11
+ nextClientId: 0,
12
+ /**
13
+ * Identity of this server run, included in control responses that carry
14
+ * cross-call state (client ids, log seq cursors). Both reset on restart, so
15
+ * a consumer that sees the generation change knows its ids and cursors are
16
+ * from a dead server and must be re-fetched.
17
+ */
18
+ generation: Date.now(),
19
+ /**
20
+ * The latched reload message (JSON text), replayed to late-joining clients.
21
+ * Set by a successful rebuild (and the build-failure trigger).
22
+ */
23
+ currentReload: null as string | null,
24
+ /**
25
+ * The running bundle's sourcemaps (JSON text, bundle -> .tsx sources),
26
+ * keyed by the module name stack frames cite ("main" for the app, the
27
+ * isolate id for each isolate), used to remap stack traces in forwarded
28
+ * client logs (see control.ts). Replaced on every reload; a reload without
29
+ * maps clears them so frames are never remapped against a stale map.
30
+ */
31
+ currentMaps: null as Record<string, string> | null,
32
+ /** The address clients reach this server on (host:port), set once bound. */
33
+ serverUrl: "",
34
+ stats: false,
35
+ /**
36
+ * Whether the user's own input is muted on every client, for an agent
37
+ * measuring or testing (the `mute` message; `welcome` carries it to a client
38
+ * joining while muted). Set by /mute.
39
+ */
40
+ userInputMuted: false,
41
+ /**
42
+ * Whether reload-on-save is paused (the /watch endpoint, srt mcp's
43
+ * pause_watch), so an agent's half-finished saves are not pushed to the
44
+ * user's screens while it edits; its explicit reload still is.
45
+ */
46
+ watchPaused: false,
47
+ // Capture events from all connected clients share one clock (captureStartMs,
48
+ // integer milliseconds) so they merge into one coherent timeline, tagged by
49
+ // `device`. Streamed to disk as JSON Lines - see main.ts's "capture" handling.
50
+ captureStartMs: 0,
51
+ captureLastAt: 0, // ms, same clock as captureStartMs
52
+ /** Serializes capture appends so events land on disk in arrival order. */
53
+ captureChain: Promise.resolve(),
54
+ }
@@ -8,5 +8,5 @@
8
8
  "noUncheckedIndexedAccess": true,
9
9
  "types": ["@solidrt/flux-types"]
10
10
  },
11
- "include": ["."]
11
+ "include": [".", "../types"]
12
12
  }