lecodes-cli 2.0.1 → 2.0.3

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.
@@ -0,0 +1,32 @@
1
+ import pkg from "../../package.json"
2
+
3
+ /* The peers that are no packages of npm's: the headless host and the editors go out as ARCHIVES in
4
+ * the releases bucket (the core's scripts/release/archives.ts says why), installed by their url —
5
+ * `npm install <url>` puts the package in place under its own name.
6
+ *
7
+ * A url names one version, and which one is this CLI's to say: the headless host at the CLI's own
8
+ * version (one ABI set), an editor at the version the CLI was published against (its peer range,
9
+ * snapped to it by scripts/sync-peer-versions.ts). */
10
+
11
+ export const ARCHIVED_PEERS: ReadonlySet<string> = new Set(["lecodes-headless", "lecodes-design", "lecodes-3d-editor"])
12
+
13
+ const BASE = "https://storage.yandexcloud.net/le-codes/releases/packages"
14
+ /** LECODES_PACKAGES_URL moves it (a mirror, a bucket of one's own). */
15
+ export const packagesBase = (): string => (process.env.LECODES_PACKAGES_URL || BASE).replace(/\/$/, "")
16
+
17
+ export const archiveUrl = (name: string, version: string): string => `${packagesBase()}/${name}/${name}-${version}.tgz`
18
+
19
+ /** The version of an archived peer this CLI installs; null for a peer that is none. */
20
+ export const archivedVersion = (name: string): string | null => {
21
+ if (!ARCHIVED_PEERS.has(name)) return null
22
+ if (name === "lecodes-headless") return pkg.version
23
+ const range = (pkg.peerDependencies as Record<string, string> | undefined)?.[name]
24
+ const version = range?.replace(/^[\^~>=\s]+/, "")
25
+ return version && /^\d+\.\d+\.\d+/.test(version) ? version : null
26
+ }
27
+
28
+ /** What an archived peer is installed by; null for a peer that is none. */
29
+ export const archiveSpec = (name: string): string | null => {
30
+ const version = archivedVersion(name)
31
+ return version ? archiveUrl(name, version) : null
32
+ }
@@ -0,0 +1,86 @@
1
+ // The browser of `lecodes test --lite`, as a process of its own: headless Chromium through
2
+ // Playwright, driven over stdin / stdout in JSON lines (liteRunner.ts is the other end).
3
+ //
4
+ // A process of NODE, whatever runs the CLI: Playwright does not start a browser under Bun on
5
+ // Windows (its launch never returns), and the CLI runs under Bun in a checkout.
6
+ //
7
+ // → { "id": 1, "op": "open", "args": { "width": 390, "height": 844 } }
8
+ // ← { "id": 1, "ok": true, "value": null }
9
+ // ← { "event": "console", "level": "log", "text": "…" } whatever the page says, as it says it
10
+ //
11
+ // One page at a time: `open` closes the one before it.
12
+ import { createRequire } from "node:module"
13
+ import { createInterface } from "node:readline"
14
+ import { pathToFileURL } from "node:url"
15
+
16
+ const send = (message) => process.stdout.write(JSON.stringify(message) + "\n")
17
+
18
+ let browser = null
19
+ let context = null
20
+ let page = null
21
+
22
+ const ops = {
23
+ /** `from`: where Playwright may be, as files to resolve it from. */
24
+ async launch({ from }) {
25
+ let chromium = null
26
+ for (const base of from) {
27
+ try {
28
+ const pw = await import(pathToFileURL(createRequire(base).resolve("playwright")).href)
29
+ chromium = pw.chromium ?? pw.default?.chromium
30
+ if (chromium) break
31
+ } catch { /* not there */ }
32
+ }
33
+ if (!chromium) throw new Error("NO_PLAYWRIGHT")
34
+ browser = await chromium.launch()
35
+ return null
36
+ },
37
+ async open({ width, height }) {
38
+ await context?.close().catch(() => {})
39
+ context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 1 })
40
+ page = await context.newPage()
41
+ page.on("console", (message) => send({ event: "console", level: message.type(), text: message.text() }))
42
+ page.on("pageerror", (error) => send({ event: "console", level: "error", text: String(error) }))
43
+ return null
44
+ },
45
+ async goto({ url }) { await page.goto(url); return null },
46
+ async waitFor({ expression, timeout }) { await page.waitForFunction(expression, null, { timeout }); return null },
47
+ evaluate: ({ expression }) => page.evaluate(expression),
48
+ async timeout({ ms }) { await page.waitForTimeout(ms); return null },
49
+ async mouse({ action, x, y }) {
50
+ if (action === "move") await page.mouse.move(x, y)
51
+ else if (action === "down") await page.mouse.down()
52
+ else if (action === "up") await page.mouse.up()
53
+ else if (action === "wheel") await page.mouse.wheel(x, y)
54
+ return null
55
+ },
56
+ async keyboard({ action, text }) {
57
+ if (action === "type") await page.keyboard.type(text)
58
+ else if (action === "down") await page.keyboard.down(text)
59
+ else if (action === "up") await page.keyboard.up(text)
60
+ return null
61
+ },
62
+ async screenshot({ path, clip }) { await page.screenshot({ path, ...(clip ? { clip } : {}) }); return null },
63
+ async close() {
64
+ await context?.close().catch(() => {})
65
+ await browser?.close().catch(() => {})
66
+ setTimeout(() => process.exit(0), 0)
67
+ return null
68
+ },
69
+ }
70
+
71
+ // One request at a time, in the order they came: the page is one.
72
+ let chain = Promise.resolve()
73
+ createInterface({ input: process.stdin }).on("line", (line) => {
74
+ let request
75
+ try { request = JSON.parse(line) } catch { return }
76
+ chain = chain.then(async () => {
77
+ try {
78
+ const op = ops[request.op]
79
+ if (!op) throw new Error(`no such op: ${request.op}`)
80
+ send({ id: request.id, ok: true, value: (await op(request.args ?? {})) ?? null })
81
+ } catch (e) {
82
+ send({ id: request.id, ok: false, error: e instanceof Error ? e.message.split("\n")[0] : String(e) })
83
+ }
84
+ })
85
+ })
86
+ process.stdin.on("end", () => { void ops.close() })
@@ -0,0 +1,386 @@
1
+ import { spawn, type ChildProcess } from "node:child_process"
2
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
3
+ import { createServer } from "node:net"
4
+ import { dirname, join } from "node:path"
5
+ import { createInterface } from "node:readline"
6
+ import { CliError } from "../cli"
7
+ import { startDevServer, type DevServer } from "../dev/devServer"
8
+ import type { Manifest } from "../project/manifest"
9
+ import { distRoot } from "./distRoot"
10
+ import { peersDir } from "./peers"
11
+ import type { DesktopPlan } from "./desktopScript"
12
+ // (a relative source import, as desktopScript.ts does: lecodes-headless is an optional peer)
13
+ import { findDumpTarget, parseTreeDump } from "../../../../runtime/headless/src/headless/paintDump"
14
+
15
+ /*
16
+ * `lecodes test --lite`: a flow scenario on WEB LITE, in a real browser. The scenario is the one
17
+ * every host takes; it is translated by desktopScript.ts into the native host's command file, and
18
+ * this module is a third reader of those commands — it plays them on the page of Web Lite in
19
+ * headless Chromium (Playwright: the page's own pointer and key events, nothing injected past
20
+ * them) and answers in the native host's words (`[script] #<line> done <op>`), so the verdict is
21
+ * desktopScript.ts's `evaluateDesktopRun`, the same rules as on the desktop.
22
+ *
23
+ * What differs is the CLOCK: a page runs on the browser's time, not on a fixed step. `frames N` /
24
+ * `wait ms` wait that long in real time — and a step that expects a log line is through as soon
25
+ * as the line is there, with room past its frames for a page that is slower than a fixed clock.
26
+ *
27
+ * The browser is a process of its own (liteBrowser.mjs, run by Node): Playwright does not start
28
+ * one under Bun on Windows.
29
+ */
30
+
31
+ type Clip = { x: number, y: number, width: number, height: number }
32
+
33
+ /** The browser, as this module drives it: one page at a time. */
34
+ type Driver = {
35
+ open(width: number, height: number, onConsole: (level: string, text: string) => void): Promise<void>
36
+ goto(url: string): Promise<void>
37
+ waitFor(expression: string, timeout: number): Promise<void>
38
+ evaluate<T>(expression: string): Promise<T>
39
+ timeout(ms: number): Promise<void>
40
+ mouse(action: "move" | "down" | "up" | "wheel", x?: number, y?: number): Promise<void>
41
+ keyboard(action: "type" | "down" | "up", text: string): Promise<void>
42
+ screenshot(path: string, clip: Clip | null): Promise<void>
43
+ close(): Promise<void>
44
+ }
45
+
46
+ const FRAME_MS = 16
47
+ /** How long past its frames a step that expects a log line may wait for it. */
48
+ const LOG_SLACK_MS = 3000
49
+
50
+ const NO_NODE = "--lite needs Node on the PATH: the browser is driven from a Node process."
51
+ const NO_PLAYWRIGHT = "--lite runs the flows in a browser and needs Playwright: `npm install -D playwright` in the project, then `npx playwright install chromium` once."
52
+
53
+ /** Start the browser's process and its browser. */
54
+ const startDriver = async (): Promise<Driver> => {
55
+ const script = join(distRoot, "src", "hosts", "liteBrowser.mjs")
56
+ if (!existsSync(script)) throw new CliError("--lite is not available in this build of the CLI (its browser driver ships with the npm package).")
57
+ const child: ChildProcess = spawn("node", [script], { stdio: ["pipe", "pipe", "inherit"] })
58
+
59
+ const waiting = new Map<number, { resolve: (value: unknown) => void, reject: (error: Error) => void }>()
60
+ let onConsole: (level: string, text: string) => void = () => {}
61
+ let next = 1
62
+ let dead: Error | null = null
63
+ const fail = (error: Error) => {
64
+ dead = dead ?? error
65
+ for (const w of waiting.values()) w.reject(dead)
66
+ waiting.clear()
67
+ }
68
+ child.on("error", () => fail(new CliError(NO_NODE)))
69
+ child.on("exit", () => fail(new Error("the browser's process ended")))
70
+ createInterface({ input: child.stdout! }).on("line", (line) => {
71
+ let message: { id?: number, ok?: boolean, value?: unknown, error?: string, event?: string, level?: string, text?: string }
72
+ try { message = JSON.parse(line) } catch { return }
73
+ if (message.event === "console") { onConsole(message.level ?? "log", message.text ?? ""); return }
74
+ const w = message.id !== undefined ? waiting.get(message.id) : undefined
75
+ if (!w) return
76
+ waiting.delete(message.id!)
77
+ if (message.ok) w.resolve(message.value)
78
+ else w.reject(new Error(message.error ?? "failed"))
79
+ })
80
+ const call = <T = void>(op: string, args: object = {}): Promise<T> => new Promise<T>((resolve, reject) => {
81
+ if (dead) { reject(dead); return }
82
+ const id = next++
83
+ waiting.set(id, { resolve: resolve as (value: unknown) => void, reject })
84
+ child.stdin!.write(JSON.stringify({ id, op, args }) + "\n")
85
+ })
86
+
87
+ // Playwright, from where it may be: the project, the CLI, the CLI's peers, a checkout's gl-lite.
88
+ const from = [
89
+ join(process.cwd(), "package.json"),
90
+ join(distRoot, "package.json"),
91
+ join(peersDir(), "package.json"),
92
+ join(distRoot, "..", "..", "engines", "gl-lite", "package.json"),
93
+ ]
94
+ try {
95
+ await call("launch", { from })
96
+ } catch (e) {
97
+ child.kill()
98
+ if (e instanceof CliError) throw e
99
+ const message = e instanceof Error ? e.message : String(e)
100
+ throw new CliError(message === "NO_PLAYWRIGHT" ? NO_PLAYWRIGHT : `Chromium did not start (${message}) — run "npx playwright install chromium" once.`)
101
+ }
102
+
103
+ return {
104
+ open(width, height, listener) { onConsole = listener; return call("open", { width, height }) },
105
+ goto: (url) => call("goto", { url }),
106
+ waitFor: (expression, timeout) => call("waitFor", { expression, timeout }),
107
+ evaluate: <T>(expression: string) => call<T>("evaluate", { expression }),
108
+ timeout: (ms) => call("timeout", { ms }),
109
+ mouse: (action, x, y) => call("mouse", { action, x, y }),
110
+ keyboard: (action, text) => call("keyboard", { action, text }),
111
+ screenshot: (path, clip) => call("screenshot", { path, clip }),
112
+ async close() {
113
+ await call("close").catch(() => {})
114
+ child.kill()
115
+ },
116
+ }
117
+ }
118
+
119
+ const freePort = (): Promise<number> => new Promise((resolve, reject) => {
120
+ const probe = createServer()
121
+ probe.once("error", reject)
122
+ probe.listen(0, "127.0.0.1", () => {
123
+ const address = probe.address()
124
+ const port = typeof address === "object" && address ? address.port : 0
125
+ probe.close(() => resolve(port))
126
+ })
127
+ })
128
+
129
+ export type LiteRunOptions = { width: number, height: number, onConsole?: (level: string, text: string) => void }
130
+
131
+ export type LiteSession = {
132
+ /** Play one case; the host's output as `evaluateDesktopRun` reads it. */
133
+ run(plan: DesktopPlan, options: LiteRunOptions): Promise<string>
134
+ close(): Promise<void>
135
+ }
136
+
137
+ /** The project compiled and served with the page of Web Lite, and a browser to open it in. */
138
+ export const startLiteSession = async (options: { root: string, manifest: Manifest | null, name: string, lite: string, entry?: string }): Promise<LiteSession> => {
139
+ const port = await freePort()
140
+ const server: DevServer = await startDevServer({
141
+ root: options.root, manifest: options.manifest, name: options.name, lite: options.lite,
142
+ origin: `http://127.0.0.1:${port}`, bind: "127.0.0.1", port, token: "test", entryOverride: options.entry,
143
+ })
144
+ const compiled = await server.recompile()
145
+ if (!compiled.ok) { server.close(); throw new CliError(`Compile failed:\n${compiled.error}`) }
146
+
147
+ let driver: Driver
148
+ try {
149
+ driver = await startDriver()
150
+ } catch (e) {
151
+ server.close()
152
+ throw e
153
+ }
154
+
155
+ return {
156
+ run: (plan, runOptions) => runPlan(driver, `${server.liteUrl}?dev=..&test`, plan, runOptions),
157
+ async close() {
158
+ await driver.close()
159
+ server.close()
160
+ },
161
+ }
162
+ }
163
+
164
+ // ---- the commands ------------------------------------------------------------------------------------------------
165
+
166
+ type Arg = number | string
167
+
168
+ /** The arguments of a command line: numbers and quoted names (`"` and `\` escaped). */
169
+ export const parseArgs = (rest: string): Arg[] => {
170
+ const out: Arg[] = []
171
+ let i = 0
172
+ while (i < rest.length) {
173
+ if (rest[i] === " ") { i++; continue }
174
+ if (rest[i] === '"') {
175
+ let text = ""
176
+ i++
177
+ while (i < rest.length && rest[i] !== '"') {
178
+ if (rest[i] === "\\") i++
179
+ text += rest[i] ?? ""
180
+ i++
181
+ }
182
+ i++
183
+ out.push(text)
184
+ continue
185
+ }
186
+ const end = rest.indexOf(" ", i)
187
+ const token = rest.slice(i, end < 0 ? rest.length : end)
188
+ const n = Number(token)
189
+ out.push(Number.isFinite(n) && token !== "" ? n : token)
190
+ i = end < 0 ? rest.length : end
191
+ }
192
+ return out
193
+ }
194
+
195
+ const unwire = (text: string): string => text.replace(/\\(n|\\)/g, (_, c: string) => (c === "n" ? "\n" : "\\"))
196
+
197
+ const LEVEL: Record<string, string> = { log: "DEBUG", debug: "DEBUG", info: "INFO", warning: "WARN", warn: "WARN", error: "ERROR" }
198
+
199
+ // What is on show: the root's children that the router did not cover.
200
+ const ON_SHOW = ".lc-root > .lc:not(.lc-covered)"
201
+
202
+ const FRAME = "new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))))"
203
+
204
+ const FILL_VALUE = `(f) => f.type === "email" ? "test@example.com" : f.type === "tel" ? "5550100" : f.inputMode === "numeric" || f.inputMode === "decimal" ? "1" : f.type === "date" ? "2026-01-01" : f.type === "time" ? "12:00" : "test"`
205
+
206
+ const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options: LiteRunOptions): Promise<string> => {
207
+ const output: string[] = []
208
+ const seen: string[] = []
209
+ const say = (line: string) => { output.push(line) }
210
+
211
+ await driver.open(options.width, options.height, (level, text) => {
212
+ seen.push(text)
213
+ say(`[${LEVEL[level] ?? "DEBUG"}] ${text}`)
214
+ options.onConsole?.(level, text)
215
+ })
216
+
217
+ // Which step a line belongs to: a step that expects a log line waits for it.
218
+ const expectOf = new Map<number, string>()
219
+ for (const step of plan.steps) if (step.expectLog !== undefined) for (const line of step.lines) expectOf.set(line, step.expectLog)
220
+
221
+ const dump = () => driver.evaluate<string>("window.lecodesTest.dump()")
222
+ const rectOf = (id: number) => driver.evaluate<[number, number, number, number] | null>(`window.lecodesTest.rect(${id})`)
223
+ const nodeOf = async (name: string) => {
224
+ const node = findDumpTarget(parseTreeDump(await dump()), name)
225
+ if (!node) throw new Error(`no element "${name}" on the screen`)
226
+ return node
227
+ }
228
+ /** The point of a target: a name resolved in the tree as every host resolves it, or a point. */
229
+ const pointOf = async (args: Arg[], at: number): Promise<{ point: [number, number], next: number }> => {
230
+ const first = args[at]
231
+ if (typeof first === "number") return { point: [first, Number(args[at + 1])], next: at + 2 }
232
+ const rect = await rectOf((await nodeOf(String(first))).id)
233
+ if (!rect || rect[2] === 0 || rect[3] === 0) throw new Error(`"${first}" is not laid out`)
234
+ return { point: [rect[0] + rect[2] / 2, rect[1] + rect[3] / 2], next: at + 1 }
235
+ }
236
+ const frame = () => driver.evaluate<null>(FRAME)
237
+ /** Wait `ms`; a line that expects a log is through when the log is there. */
238
+ const waitFor = async (ms: number, line: number) => {
239
+ const expected = expectOf.get(line)
240
+ if (expected === undefined) { await driver.timeout(ms); return }
241
+ const until = Date.now() + ms + LOG_SLACK_MS
242
+ while (Date.now() < until) {
243
+ if (seen.some((text) => text.includes(expected))) return
244
+ await driver.timeout(FRAME_MS)
245
+ }
246
+ }
247
+
248
+ let clip: Clip | null = null
249
+
250
+ const play = async (op: string, rest: string, line: number): Promise<void> => {
251
+ const args = parseArgs(rest)
252
+ switch (op) {
253
+ case "frames": await waitFor(Math.max(0, Number(args[0])) * FRAME_MS, line); return
254
+ case "wait": await waitFor(Math.max(0, Number(args[0])), line); return
255
+ case "tap": {
256
+ const { point } = await pointOf(args, 0)
257
+ await driver.mouse("move", point[0], point[1])
258
+ await driver.mouse("down")
259
+ await driver.mouse("up")
260
+ await frame()
261
+ return
262
+ }
263
+ case "hold": {
264
+ const { point, next } = await pointOf(args, 0)
265
+ await driver.mouse("move", point[0], point[1])
266
+ await driver.mouse("down")
267
+ await driver.timeout(Number(args[next]) || 0)
268
+ await driver.mouse("up")
269
+ await frame()
270
+ return
271
+ }
272
+ case "drag": {
273
+ const from = await pointOf(args, 0)
274
+ const to = await pointOf(args, from.next)
275
+ const ms = Number(args[to.next]) || 300
276
+ const steps = Math.max(2, Math.round(ms / FRAME_MS))
277
+ await driver.mouse("move", from.point[0], from.point[1])
278
+ await driver.mouse("down")
279
+ for (let i = 1; i <= steps; i++) {
280
+ const t = i / steps
281
+ await driver.mouse("move", from.point[0] + (to.point[0] - from.point[0]) * t, from.point[1] + (to.point[1] - from.point[1]) * t)
282
+ await driver.timeout(ms / steps)
283
+ }
284
+ await driver.mouse("up")
285
+ await frame()
286
+ return
287
+ }
288
+ case "mouseto": {
289
+ const { point } = await pointOf(args, 0)
290
+ await driver.mouse("move", point[0], point[1])
291
+ await frame()
292
+ return
293
+ }
294
+ case "type": await driver.keyboard("type", unwire(rest)); await frame(); return
295
+ case "fill": {
296
+ const value = rest === "" ? undefined : unwire(rest)
297
+ // every empty field on show, in the tree's order: what the native hosts fill
298
+ const count = await driver.evaluate<number>(`(() => {
299
+ window.__lecodesFill = [...document.querySelectorAll("${ON_SHOW} input.lc, ${ON_SHOW} textarea.lc")].filter((f) => f.value === "")
300
+ return window.__lecodesFill.length
301
+ })()`)
302
+ for (let i = 0; i < count; i++) {
303
+ const text = value ?? await driver.evaluate<string>(`(${FILL_VALUE})(window.__lecodesFill[${i}])`)
304
+ await driver.evaluate(`window.__lecodesFill[${i}].focus()`)
305
+ await driver.keyboard("type", text)
306
+ }
307
+ await frame()
308
+ return
309
+ }
310
+ case "scroll": {
311
+ const by = Number(args[0])
312
+ const name = typeof args[1] === "string" ? args[1] : null
313
+ const target = name === null
314
+ ? `document.querySelector("${ON_SHOW} .lc-scrollable, ${ON_SHOW} .lc-vlist")`
315
+ : `document.querySelector('[data-node="${(await nodeOf(name)).id}"]')`
316
+ const found = await driver.evaluate<boolean>(`(() => {
317
+ const el = ${target}
318
+ if (!el) return false
319
+ const style = getComputedStyle(el)
320
+ if (style.overflowX !== "hidden" && style.overflowY === "hidden") el.scrollLeft += ${by}
321
+ else el.scrollTop += ${by}
322
+ return true
323
+ })()`)
324
+ if (!found) throw new Error(name === null ? "no scrollable on the screen" : `"${name}" is not there`)
325
+ await frame()
326
+ return
327
+ }
328
+ case "key": {
329
+ await driver.keyboard("down", String(args[0]))
330
+ await driver.timeout(Number(args[1]) || 0)
331
+ await driver.keyboard("up", String(args[0]))
332
+ return
333
+ }
334
+ case "keydown": await driver.keyboard("down", String(args[0])); return
335
+ case "keyup": await driver.keyboard("up", String(args[0])); return
336
+ case "wheel": await driver.mouse("wheel", Number(args[0]) * 100, Number(args[1]) * 100); await frame(); return
337
+ case "dump": {
338
+ const file = rest.trim()
339
+ mkdirSync(dirname(file), { recursive: true })
340
+ writeFileSync(file, await dump())
341
+ return
342
+ }
343
+ case "clip": {
344
+ if (rest.trim() === "off") { clip = null; return }
345
+ if (typeof args[0] === "number") { clip = { x: args[0], y: Number(args[1]), width: Number(args[2]), height: Number(args[3]) }; return }
346
+ const rect = await rectOf((await nodeOf(String(args[0]))).id)
347
+ if (!rect) throw new Error(`"${args[0]}" is not laid out`)
348
+ clip = { x: rect[0], y: rect[1], width: rect[2], height: rect[3] }
349
+ return
350
+ }
351
+ case "shot": {
352
+ const file = rest.trim()
353
+ mkdirSync(dirname(file), { recursive: true })
354
+ await driver.screenshot(file, clip)
355
+ return
356
+ }
357
+ default:
358
+ throw new Error(`"${op}" is not available on Web Lite (it has no 3D scene, no gamepad)`)
359
+ }
360
+ }
361
+
362
+ try {
363
+ await driver.goto(url)
364
+ await driver.waitFor("window.lecodesTest && window.lecodesTest.ready === true", 20000)
365
+ say("[script] loaded")
366
+ const lines = plan.text.split("\n")
367
+ for (let i = 0; i < lines.length; i++) {
368
+ const text = lines[i]!.trim()
369
+ if (text === "" || text.startsWith("#")) continue
370
+ const space = text.indexOf(" ")
371
+ const op = space < 0 ? text : text.slice(0, space)
372
+ const rest = space < 0 ? "" : text.slice(space + 1)
373
+ try {
374
+ await play(op, rest, i + 1)
375
+ say(`[script] #${i + 1} done ${op}`)
376
+ } catch (e) {
377
+ say(`[script] #${i + 1} error ${op}: ${e instanceof Error ? e.message.split("\n")[0] : String(e)}`)
378
+ break
379
+ }
380
+ }
381
+ say("[script] end")
382
+ } catch (e) {
383
+ say(`[ERROR] ${e instanceof Error ? e.message.split("\n")[0] : String(e)}`)
384
+ }
385
+ return output.join("\n")
386
+ }
@@ -4,13 +4,15 @@ import { join } from "node:path"
4
4
  import { pathToFileURL } from "node:url"
5
5
  import pkg from "../../package.json"
6
6
  import { peerDirInPrefix, peersDir, peerVersionGlobal } from "./peers"
7
+ import { archiveSpec } from "./archives"
7
8
  import { CliError, c, confirm, log, note } from "../cli"
8
9
 
9
10
  /*
10
11
  * Peers install themselves on first use. `lecodes render` on a machine with only the CLI asks once,
11
12
  * then puts lecodes-headless (+ @napi-rs/canvas for PNG) into ~/.lecodes/peers and loads it from
12
13
  * there — the same pattern as matc and the desktop host (downloaded into ~/.lecodes on first use),
13
- * applied to npm packages. Why a private prefix and not `npm install -g`: no sudo / system prefix
14
+ * applied to packages: the headless host and the editors are ARCHIVES of the releases bucket,
15
+ * installed by their url (./archives.ts); canvas and lecodes-assets are npm's. Why a private prefix and not `npm install -g`: no sudo / system prefix
14
16
  * to guess, and the runtime-set peer is installed at EXACTLY this CLI's version — the compiler and the
15
17
  * headless host are one ABI set, so a global renderer that drifted behind the CLI is the very failure
16
18
  * this removes. A newer CLI re-installs its own version on the next use (peerSpec → `lecodes update`).
@@ -28,10 +30,12 @@ const CANVAS_RANGE_FALLBACK = "^0.1.65"
28
30
 
29
31
  const readJson = (path: string): any => { try { return JSON.parse(readFileSync(path, "utf8")) } catch { return null } }
30
32
 
31
- /** The npm spec a peer installs at: the renderer at exactly this CLI's version (one ABI set), canvas
32
- * at the range the installed renderer declares, the editors / assets at this CLI's peer ranges. */
33
+ /** What a peer installs by: the url of its archive — the renderer at exactly this CLI's version (one
34
+ * ABI set), an editor at the version this CLI was published against — or, for npm's, a spec: canvas
35
+ * at the range the installed renderer declares, lecodes-assets at this CLI's peer range. */
33
36
  export const peerSpec = (name: string): string => {
34
- if (name === "lecodes-headless") return `lecodes-headless@${pkg.version}`
37
+ const archive = archiveSpec(name)
38
+ if (archive) return archive
35
39
  if (name === "@napi-rs/canvas") {
36
40
  const renderer = readJson(join(peerDirInPrefix("lecodes-headless"), "package.json"))
37
41
  const range = renderer?.peerDependencies?.["@napi-rs/canvas"] ?? renderer?.optionalDependencies?.["@napi-rs/canvas"] ?? CANVAS_RANGE_FALLBACK