@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.
@@ -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 bytecode = await compileToBytecode(await bundleSolid())
45
- let folder = buildPackFolder(source!, bytecode)
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"
@@ -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
- state.projectDir = source ? projectDirFor(resolve(source)) : process.cwd()
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 needs no threading through those call sites.
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 and `projectDir` its /assets/ root
62
- * (repl `load`); `map` is the bundle's sourcemap, kept server-side for
63
- * stack-trace remapping (omitting it clears the server's map, so a mapless
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 port with --port <N>`)
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: process.cwd(),
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
@@ -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
- export function buildPackFolder(entry: string, bytecode: Buffer): PackFolder {
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
- for (let name of ["manifest.json", "bundle.bin", runnerName]) {
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 runnerBytes = readFileSync(requireBinary("solidrt"))
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
- export function buildManifest(code: string, entry: string): string {
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 { createInterface, emitKeypressEvents } from "node:readline"
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 with an optional default (shown in parentheses, used
4
- // when the answer is blank). Non-TTY stdin resolves the default rather than
5
- // blocking on input that will never arrive.
6
- export function text(message: string, def = ""): Promise<string> {
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
- // Minimal arrow-key single-select prompt, built on node:readline (same
24
- // dependency-free approach as repl.ts). Renders the option list, moves the
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
- return new Promise((resolve) => {
33
- let input = process.stdin
34
- let output = process.stdout
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
- let onKey = (_str: string, key: { name: string; ctrl: boolean } | undefined) => {
62
- if (!key) return
63
- if (key.name === "up") {
64
- selected = (selected - 1 + items.length) % items.length
65
- render()
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
- input.on("keypress", onKey)
81
- input.resume()
82
- render(true)
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, projectDirFor } from "./project"
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 /assets/ root to the new project dir, and its rebuild entry to the
133
- // new file (for a later MCP reload).
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
  })
@@ -1,4 +0,0 @@
1
- {
2
- "level": "components",
3
- "description": "components starter application"
4
- }
@@ -1,4 +0,0 @@
1
- {
2
- "level": "core",
3
- "description": "core starter application"
4
- }