@solidrt/cli 0.0.48 → 0.0.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +34 -3
- package/package.json +8 -7
- package/scaffold/AGENTS.md +48 -4
- package/scaffold/package.json +5 -4
- package/server/main.ts +14 -8
- package/server/rebuild.ts +10 -2
- package/server/state.ts +1 -1
- package/server/tunnel.ts +5 -4
- package/src/args.ts +59 -24
- package/src/bundle-cli.ts +3 -2
- package/src/bundler.ts +127 -28
- package/src/commands/check.ts +6 -2
- package/src/commands/client.ts +16 -8
- package/src/commands/init.ts +59 -75
- package/src/commands/mcp.ts +143 -4
- package/src/commands/pack.ts +6 -2
- package/src/commands/server.ts +4 -1
- package/src/dev-dir.ts +25 -0
- package/src/dev-server.ts +46 -13
- package/src/pack-folder.ts +24 -3
- package/src/packer.ts +36 -3
- package/src/project.ts +9 -1
- package/src/prompt.ts +46 -72
- package/src/repl.ts +14 -6
- package/scaffold/templates/components/template.json +0 -4
- package/scaffold/templates/default/template.json +0 -4
package/src/commands/pack.ts
CHANGED
|
@@ -41,8 +41,12 @@ export async function runPackCommand() {
|
|
|
41
41
|
let fonts = resolvePackFonts(source!)
|
|
42
42
|
console.log(`>> fonts: ${fonts.length ? fonts.map((f) => f.alias).join(", ") : "none"}`)
|
|
43
43
|
|
|
44
|
-
let
|
|
45
|
-
let
|
|
44
|
+
let bundled = await bundleSolid()
|
|
45
|
+
let bytecode = await compileToBytecode(bundled.code)
|
|
46
|
+
let isolates = []
|
|
47
|
+
for (let i of bundled.isolates) isolates.push({ id: i.id, bytecode: await compileToBytecode(i.code) })
|
|
48
|
+
if (isolates.length) console.log(`>> isolates: ${isolates.map((i) => i.id).join(", ")}`)
|
|
49
|
+
let folder = buildPackFolder(source!, bytecode, isolates)
|
|
46
50
|
|
|
47
51
|
if (values.folder) {
|
|
48
52
|
let outDir = values.output ?? "dist"
|
package/src/commands/server.ts
CHANGED
|
@@ -16,7 +16,10 @@ export async function runServerCommand() {
|
|
|
16
16
|
// Initialize state from args
|
|
17
17
|
state.source = source
|
|
18
18
|
state.sourceDir = source ? dirname(resolve(source)) : process.cwd()
|
|
19
|
-
|
|
19
|
+
// With no entry the project is wherever srt was started: walk up to the
|
|
20
|
+
// nearest package.json exactly like an entry would, so the projectDir the
|
|
21
|
+
// MCP bridge derives for its registry match agrees with ours.
|
|
22
|
+
state.projectDir = projectDirFor(source ? resolve(source) : resolve("package.json"))
|
|
20
23
|
state.stats = values.stats
|
|
21
24
|
state.capture = values.capture ? resolve(values.capture) : undefined
|
|
22
25
|
|
package/src/dev-dir.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
|
|
4
|
+
// The folder name is deliberately isolated here: one switch point if it ever
|
|
5
|
+
// changes or becomes configurable.
|
|
6
|
+
const DEV_DIR_NAME = ".solidrt"
|
|
7
|
+
|
|
8
|
+
// All dev-tooling state lives in one home dotdir, one rule on every platform
|
|
9
|
+
// (okf/backlog/parallel-dev-servers.md): servers/<port>/ holds a dev server's
|
|
10
|
+
// identity (tunnel.key) and its registry record (live.json), clients/ is the
|
|
11
|
+
// data root srt passes for every locally spawned client, so dev client trees
|
|
12
|
+
// land in clients/client<M>/. Deleting the dir resets every bit of dev state.
|
|
13
|
+
export function devDir(...parts: string[]): string {
|
|
14
|
+
return join(homedir(), DEV_DIR_NAME, ...parts)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `servers/<port>/` under the dev dir - a dev server's identity and registry record, keyed by port. */
|
|
18
|
+
export function serverDir(port: number): string {
|
|
19
|
+
return devDir("servers", String(port))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `clients/` under the dev dir - the --data-root for locally spawned dev clients. */
|
|
23
|
+
export function clientsRoot(): string {
|
|
24
|
+
return devDir("clients")
|
|
25
|
+
}
|
package/src/dev-server.ts
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
|
-
import { resolve } from "path"
|
|
1
|
+
import { resolve, join } from "path"
|
|
2
2
|
import { tmpdir, networkInterfaces } from "node:os"
|
|
3
3
|
import { fileURLToPath } from "node:url"
|
|
4
|
+
import { mkdirSync, writeFileSync, unlinkSync } from "node:fs"
|
|
4
5
|
import { state, print, printErr, requireBinary, pipeAbovePrompt, shutdown } from "./util"
|
|
5
|
-
import { appArgs, values } from "./args"
|
|
6
|
+
import { appArgs, values, session, DEFAULT_DEV_PORT } from "./args"
|
|
7
|
+
import { serverDir } from "./dev-dir"
|
|
6
8
|
|
|
7
9
|
export const DEV_HOST = "127.0.0.1"
|
|
8
|
-
export const DEFAULT_DEV_PORT = 0x8844
|
|
9
10
|
|
|
10
11
|
// The port every dev-server consumer dials: the spawned server, the local and
|
|
11
12
|
// Android clients' --dev-server address, and the MCP bridge's control base.
|
|
12
|
-
// Resolved once here, so --port
|
|
13
|
+
// Resolved once here, so --port/--session need no threading through those
|
|
14
|
+
// call sites. An explicit --port wins over the session; the server folder is
|
|
15
|
+
// keyed by the port actually bound either way.
|
|
13
16
|
function resolveDevPort(): number {
|
|
14
17
|
let raw = values.port
|
|
15
|
-
if (raw === undefined) return DEFAULT_DEV_PORT
|
|
18
|
+
if (raw === undefined) return DEFAULT_DEV_PORT + session
|
|
16
19
|
let port = Number(raw)
|
|
17
20
|
if (!/^\d+$/.test(raw) || port < 1 || port > 65535) {
|
|
18
21
|
console.error(`Invalid --port value "${raw}": expected a port number between 1 and 65535`)
|
|
@@ -58,10 +61,10 @@ async function post(path: string, body: object) {
|
|
|
58
61
|
* Send a client-protocol message through the server: to the given client ids,
|
|
59
62
|
* or to every client when omitted. `latch` keeps the message for late-joining
|
|
60
63
|
* clients (code reloads latch, one-shot bytecode loads do not); `sourceDir`
|
|
61
|
-
* moves the server's file-serving root
|
|
62
|
-
*
|
|
63
|
-
* stack-trace remapping (omitting it clears the server's map,
|
|
64
|
-
* reload never remaps against a stale one).
|
|
64
|
+
* moves the server's file-serving root (repl `load`; the project root is
|
|
65
|
+
* fixed for the life of the run); `map` is the bundle's sourcemap, kept
|
|
66
|
+
* server-side for stack-trace remapping (omitting it clears the server's map,
|
|
67
|
+
* so a mapless reload never remaps against a stale one).
|
|
65
68
|
*/
|
|
66
69
|
export async function sendReload(
|
|
67
70
|
message: object,
|
|
@@ -69,7 +72,6 @@ export async function sendReload(
|
|
|
69
72
|
clients?: number[]
|
|
70
73
|
latch?: boolean
|
|
71
74
|
sourceDir?: string
|
|
72
|
-
projectDir?: string
|
|
73
75
|
entry?: string
|
|
74
76
|
map?: string | null
|
|
75
77
|
} = {},
|
|
@@ -185,15 +187,41 @@ function requireFreePort(port: number) {
|
|
|
185
187
|
let probe = Bun.serve({ port, fetch: () => new Response() })
|
|
186
188
|
probe.stop(true)
|
|
187
189
|
} catch {
|
|
188
|
-
printErr(`[cli] Port ${port} is already in use; start on another
|
|
190
|
+
printErr(`[cli] Port ${port} is already in use; start on another session with -s <N> (or --port <P>)`)
|
|
189
191
|
process.exit(1)
|
|
190
192
|
}
|
|
191
193
|
}
|
|
192
194
|
|
|
195
|
+
// The server's registry record: written once the server answers, removed at
|
|
196
|
+
// exit, so MCP bridges can resolve a project to a port without any per-project
|
|
197
|
+
// config (okf/backlog/parallel-dev-servers.md). The pid is the flux server's
|
|
198
|
+
// (the process owning the port), so a record left behind by a crash fails the
|
|
199
|
+
// bridge's liveness check; the record is a hint either way - the bridge's
|
|
200
|
+
// /__control__/clients probe is authoritative.
|
|
201
|
+
function writeLiveRecord() {
|
|
202
|
+
let record = {
|
|
203
|
+
pid: state.serverProc?.pid,
|
|
204
|
+
port: DEV_PORT,
|
|
205
|
+
projectDir: state.projectDir,
|
|
206
|
+
entry: state.source ?? null,
|
|
207
|
+
started: new Date().toISOString(),
|
|
208
|
+
}
|
|
209
|
+
writeFileSync(join(serverDir(DEV_PORT), "live.json"), JSON.stringify(record))
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function removeLiveRecord() {
|
|
213
|
+
try {
|
|
214
|
+
unlinkSync(join(serverDir(DEV_PORT), "live.json"))
|
|
215
|
+
} catch {}
|
|
216
|
+
}
|
|
217
|
+
|
|
193
218
|
export async function startServer() {
|
|
194
219
|
let flux = requireBinary("flux")
|
|
195
220
|
requireFreePort(DEV_PORT)
|
|
196
221
|
let script = await bundleServer()
|
|
222
|
+
// The server's own folder (tunnel.key lands there, written by the flux
|
|
223
|
+
// process, which does not create directories).
|
|
224
|
+
mkdirSync(serverDir(DEV_PORT), { recursive: true })
|
|
197
225
|
|
|
198
226
|
let lanAddress = Object.values(networkInterfaces())
|
|
199
227
|
.flat()
|
|
@@ -220,8 +248,8 @@ export async function startServer() {
|
|
|
220
248
|
minify: values.minify,
|
|
221
249
|
bundlerCmd: [process.execPath, bundleCli],
|
|
222
250
|
cache: values["proxy-http"],
|
|
223
|
-
cacheDir: resolve(".srt-data"),
|
|
224
|
-
keyDir:
|
|
251
|
+
cacheDir: resolve(state.projectDir, ".srt-data"),
|
|
252
|
+
keyDir: serverDir(DEV_PORT),
|
|
225
253
|
capture: state.capture,
|
|
226
254
|
stats: state.stats,
|
|
227
255
|
tunnel: values.tunnel,
|
|
@@ -257,6 +285,11 @@ export async function startServer() {
|
|
|
257
285
|
}
|
|
258
286
|
}
|
|
259
287
|
|
|
288
|
+
writeLiveRecord()
|
|
289
|
+
// shutdown() exits via process.exit, so the exit hook covers every orderly
|
|
290
|
+
// path; only a kill -9 leaves the record behind, for the pid check to catch.
|
|
291
|
+
process.on("exit", removeLiveRecord)
|
|
292
|
+
|
|
260
293
|
// mDNS advertise (dropped, code kept for future use - see
|
|
261
294
|
// docs/flux-dev-server-plan.md): the p2p ticket is the cross-device connect
|
|
262
295
|
// story now. If advertise returns, it belongs next to the server (a flux
|
package/src/pack-folder.ts
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
type ManifestFont,
|
|
11
11
|
} from "./project"
|
|
12
12
|
import { resolvePackFonts } from "./fonts"
|
|
13
|
+
import { runnerGlLibs } from "./packer"
|
|
14
|
+
import { isolateAssetPath } from "./bundler"
|
|
13
15
|
|
|
14
16
|
// The canonical flat pack folder (okf/plans/client-storage-updates.md, Pack
|
|
15
17
|
// output): runner + manifest.json + bundle.bin + assets/. The manifest
|
|
@@ -32,13 +34,20 @@ export type PackFolder = {
|
|
|
32
34
|
manifest: string
|
|
33
35
|
/** Files to place in the folder: absolute source -> folder-relative path. */
|
|
34
36
|
copies: Array<{ from: string; to: string }>
|
|
37
|
+
/** Build outputs to place in the folder (isolate bytecode): folder-relative path + bytes. */
|
|
38
|
+
files: Array<{ to: string; bytes: Buffer }>
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
|
|
41
|
+
// `isolates` are the app's isolate bundles compiled to bytecode; they ship as
|
|
42
|
+
// the manifest assets isolates/<id>.bin (the production runtime has no
|
|
43
|
+
// compiler, so pack never ships isolate source).
|
|
44
|
+
export function buildPackFolder(entry: string, bytecode: Buffer, isolates: { id: string; bytecode: Buffer }[]): PackFolder {
|
|
38
45
|
let identity = loadAppIdentity(entry)
|
|
39
46
|
let projectDir = projectDirFor(resolve(entry))
|
|
40
47
|
let { assets, icon } = collectAssets(entry)
|
|
41
48
|
let copies = assets.map((a) => ({ from: join(projectDir, a.path), to: a.path }))
|
|
49
|
+
let files = isolates.map((i) => ({ to: isolateAssetPath(i.id, "bin"), bytes: i.bytecode }))
|
|
50
|
+
for (let f of files) assets.push({ path: f.to, sha256: hashHex(f.bytes), size: f.bytes.length })
|
|
42
51
|
|
|
43
52
|
// The full resolved font set: custom fonts are already collected assets;
|
|
44
53
|
// defaults materialize under assets/fonts/ (a user file already at that
|
|
@@ -77,7 +86,7 @@ export function buildPackFolder(entry: string, bytecode: Buffer): PackFolder {
|
|
|
77
86
|
...(assets.length ? { assets } : {}),
|
|
78
87
|
...(fonts.length ? { fonts } : {}),
|
|
79
88
|
})
|
|
80
|
-
return { manifest, copies }
|
|
89
|
+
return { manifest, copies, files }
|
|
81
90
|
}
|
|
82
91
|
|
|
83
92
|
/**
|
|
@@ -93,9 +102,11 @@ export function writePackFolder(outDir: string, runnerPath: string, bytecode: Bu
|
|
|
93
102
|
}
|
|
94
103
|
|
|
95
104
|
let runnerName = "solidrt" + (process.platform === "win32" ? ".exe" : "")
|
|
105
|
+
let glLibs = runnerGlLibs(runnerPath)
|
|
96
106
|
mkdirSync(outDir, { recursive: true })
|
|
97
107
|
rmSync(join(outDir, "assets"), { recursive: true, force: true })
|
|
98
|
-
|
|
108
|
+
rmSync(join(outDir, "isolates"), { recursive: true, force: true })
|
|
109
|
+
for (let name of ["manifest.json", "bundle.bin", runnerName, ...glLibs.map((lib) => lib.name)]) {
|
|
99
110
|
rmSync(join(outDir, name), { force: true })
|
|
100
111
|
}
|
|
101
112
|
|
|
@@ -105,6 +116,11 @@ export function writePackFolder(outDir: string, runnerPath: string, bytecode: Bu
|
|
|
105
116
|
if (process.platform !== "win32") {
|
|
106
117
|
Bun.spawnSync(["chmod", "+x", join(outDir, runnerName)])
|
|
107
118
|
}
|
|
119
|
+
// The runner loads its GL libraries from next to itself; a folder pack must
|
|
120
|
+
// carry them like the platform package does.
|
|
121
|
+
for (let lib of glLibs) {
|
|
122
|
+
cpSync(lib.path, join(outDir, lib.name), { dereference: true })
|
|
123
|
+
}
|
|
108
124
|
writeFileSync(join(outDir, "bundle.bin"), bytecode)
|
|
109
125
|
writeFileSync(join(outDir, "manifest.json"), folder.manifest)
|
|
110
126
|
for (let { from, to } of folder.copies) {
|
|
@@ -112,4 +128,9 @@ export function writePackFolder(outDir: string, runnerPath: string, bytecode: Bu
|
|
|
112
128
|
mkdirSync(dirname(dest), { recursive: true })
|
|
113
129
|
cpSync(from, dest)
|
|
114
130
|
}
|
|
131
|
+
for (let { to, bytes } of folder.files) {
|
|
132
|
+
let dest = join(outDir, to)
|
|
133
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
134
|
+
writeFileSync(dest, bytes)
|
|
135
|
+
}
|
|
115
136
|
}
|
package/src/packer.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs"
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { dirname, join } from "node:path"
|
|
2
3
|
import { requireBinary } from "./util"
|
|
3
4
|
import { compileToBytecode } from "./bundler"
|
|
4
5
|
import type { PackFolder } from "./pack-folder"
|
|
@@ -14,6 +15,33 @@ const MAGIC = {
|
|
|
14
15
|
// Section kinds in the solidrt trailer. Must match lattice/src/main.rs.
|
|
15
16
|
const SECTION_MANIFEST = 1
|
|
16
17
|
const SECTION_FILE = 2
|
|
18
|
+
const SECTION_GL_LIB = 3
|
|
19
|
+
|
|
20
|
+
// The GL libraries the runner needs next to it (or, single-file, embedded as
|
|
21
|
+
// kind-3 sections it extracts at boot): ANGLE's libraries on Windows and
|
|
22
|
+
// macOS, nothing on platforms with a system GL. Order matters and the runner
|
|
23
|
+
// preloads in section order: libGLESv2 must load before libEGL so libEGL's
|
|
24
|
+
// import of it resolves against the already-loaded module instead of a
|
|
25
|
+
// directory search.
|
|
26
|
+
const GL_LIB_NAMES: Partial<Record<NodeJS.Platform, string[]>> = {
|
|
27
|
+
win32: ["libGLESv2.dll", "libEGL.dll"],
|
|
28
|
+
darwin: ["libGLESv2.dylib", "libEGL.dylib"],
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The GL libraries shipped next to the runner binary, resolved to their paths.
|
|
32
|
+
// Missing files are fatal: a pack without them cannot create a window.
|
|
33
|
+
export function runnerGlLibs(runnerPath: string): Array<{ name: string; path: string }> {
|
|
34
|
+
let names = GL_LIB_NAMES[process.platform] ?? []
|
|
35
|
+
let dir = dirname(runnerPath)
|
|
36
|
+
return names.map((name) => {
|
|
37
|
+
let path = join(dir, name)
|
|
38
|
+
if (!existsSync(path)) {
|
|
39
|
+
console.error(`Could not find ${name} next to the runner (${dir}); the packed app needs it to create a GL context.`)
|
|
40
|
+
process.exit(1)
|
|
41
|
+
}
|
|
42
|
+
return { name, path }
|
|
43
|
+
})
|
|
44
|
+
}
|
|
17
45
|
|
|
18
46
|
type Section = { kind: number; bytes: Buffer; name?: string }
|
|
19
47
|
|
|
@@ -48,13 +76,18 @@ function packSections(runnerBytes: Buffer, sections: Section[], magic: Buffer):
|
|
|
48
76
|
// section form - the canonical manifest verbatim, then every manifest-listed
|
|
49
77
|
// file named by its manifest path. Bundle, fonts, and identity all come from
|
|
50
78
|
// the manifest; assets are read in place via ranged reads at their section
|
|
51
|
-
// offsets, so nothing is unpacked at runtime.
|
|
79
|
+
// offsets, so nothing is unpacked at runtime. GL libraries ride along as
|
|
80
|
+
// kind-3 sections (runtime freight, deliberately outside the manifest); the
|
|
81
|
+
// runner extracts those to its cache and preloads them before window setup.
|
|
52
82
|
export function packSolid(folder: PackFolder, bytecode: Buffer): Buffer {
|
|
53
|
-
let
|
|
83
|
+
let runnerPath = requireBinary("solidrt")
|
|
84
|
+
let runnerBytes = readFileSync(runnerPath)
|
|
54
85
|
let sections: Section[] = [
|
|
55
86
|
{ kind: SECTION_MANIFEST, bytes: Buffer.from(folder.manifest, "utf8") },
|
|
56
87
|
{ kind: SECTION_FILE, bytes: bytecode, name: "bundle.bin" },
|
|
57
88
|
...folder.copies.map((c) => ({ kind: SECTION_FILE, bytes: readFileSync(c.from), name: c.to })),
|
|
89
|
+
...folder.files.map((f) => ({ kind: SECTION_FILE, bytes: f.bytes, name: f.to })),
|
|
90
|
+
...runnerGlLibs(runnerPath).map((lib) => ({ kind: SECTION_GL_LIB, bytes: readFileSync(lib.path), name: lib.name })),
|
|
58
91
|
]
|
|
59
92
|
return packSections(runnerBytes, sections, MAGIC.solidrt)
|
|
60
93
|
}
|
package/src/project.ts
CHANGED
|
@@ -71,10 +71,13 @@ export const RUNTIME_VERSION = 1
|
|
|
71
71
|
let pkgVersion = JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")).version
|
|
72
72
|
export const SOLIDRT_VERSION: string = pkgVersion === "0.0.0" ? "unknown" : pkgVersion
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
// `extra` are build outputs that ship as assets too (isolate bundles); they
|
|
75
|
+
// follow the assets/ tree in the list, in the order given.
|
|
76
|
+
export function buildManifest(code: string, entry: string, extra: ManifestAsset[] = []): string {
|
|
75
77
|
let identity = loadAppIdentity(entry)
|
|
76
78
|
let sha256 = new Bun.CryptoHasher("sha256").update(code).digest("hex")
|
|
77
79
|
let { assets, fonts, icon } = collectAssets(entry)
|
|
80
|
+
assets.push(...extra)
|
|
78
81
|
return JSON.stringify({
|
|
79
82
|
appId: identity.appId,
|
|
80
83
|
displayName: identity.displayName,
|
|
@@ -90,6 +93,11 @@ export function buildManifest(code: string, entry: string): string {
|
|
|
90
93
|
export type ManifestAsset = { path: string; sha256: string; size: number }
|
|
91
94
|
export type ManifestFont = { path: string; alias: string }
|
|
92
95
|
|
|
96
|
+
/** The manifest entry for in-memory asset bytes at `path`. */
|
|
97
|
+
export function manifestAssetFor(path: string, bytes: Uint8Array): ManifestAsset {
|
|
98
|
+
return { path, sha256: new Bun.CryptoHasher("sha256").update(bytes).digest("hex"), size: bytes.length }
|
|
99
|
+
}
|
|
100
|
+
|
|
93
101
|
// The project root the assets/ convention hangs off: the nearest package.json
|
|
94
102
|
// dir, or the entry's own dir when there is none.
|
|
95
103
|
export function projectDirFor(sourcePath: string): string {
|
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
|
+
}
|
package/src/repl.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { readdirSync } from "node:fs"
|
|
|
4
4
|
import { state, print, printErr, shutdown } from "./util"
|
|
5
5
|
import { buildReload, getClients, sendReload, sendStop, sendStats, sendWatch, showBuildFailure } from "./dev-server"
|
|
6
6
|
import { bundle } from "./bundler"
|
|
7
|
-
import { buildManifest
|
|
7
|
+
import { buildManifest } from "./project"
|
|
8
8
|
import { startWatcher, stopWatcher } from "./watcher"
|
|
9
9
|
|
|
10
10
|
// Resolve repl client indexes ("0 2") against the server's client list,
|
|
@@ -101,6 +101,15 @@ async function cmdLoad(file: string) {
|
|
|
101
101
|
return
|
|
102
102
|
}
|
|
103
103
|
let path = resolve(file)
|
|
104
|
+
// Same rule as /__control__/load (control.ts): a server run serves the
|
|
105
|
+
// project it started in, and an entry outside the project root cannot
|
|
106
|
+
// resolve the project's dependencies anyway.
|
|
107
|
+
let norm = (p: string) => p.replace(/\\/g, "/")
|
|
108
|
+
let root = norm(state.projectDir).replace(/\/+$/, "") + "/"
|
|
109
|
+
if (!norm(path).startsWith(root)) {
|
|
110
|
+
printErr(`[cli] Entry is outside the project root: ${path} is not under ${state.projectDir}. Restart srt in that project to work on it.`)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
104
113
|
if (file.endsWith(".tsx")) {
|
|
105
114
|
let result = await bundle(path)
|
|
106
115
|
if (!result) {
|
|
@@ -126,15 +135,14 @@ async function cmdLoad(file: string) {
|
|
|
126
135
|
}
|
|
127
136
|
state.source = path
|
|
128
137
|
state.sourceDir = dirname(path)
|
|
129
|
-
state.projectDir = projectDirFor(path)
|
|
130
138
|
startWatcher()
|
|
131
|
-
// The load also moves the server's file-serving root to the new source dir
|
|
132
|
-
// its
|
|
133
|
-
//
|
|
139
|
+
// The load also moves the server's file-serving root to the new source dir
|
|
140
|
+
// and its rebuild entry to the new file (for a later MCP reload). The
|
|
141
|
+
// project root - and with it the /assets/ root - is fixed for the life of
|
|
142
|
+
// the run.
|
|
134
143
|
await sendReload(buildReload({ code: state.currentCode, manifest: state.currentManifest }), {
|
|
135
144
|
latch: true,
|
|
136
145
|
sourceDir: state.sourceDir,
|
|
137
|
-
projectDir: state.projectDir,
|
|
138
146
|
entry: file.endsWith(".tsx") ? path : undefined,
|
|
139
147
|
map: state.currentMap,
|
|
140
148
|
})
|