lecodes-cli 2.0.4 → 2.0.6
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/dist/index.js +3506 -1819
- package/package.json +20 -11
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/assets/creator-2d-CYlV77UR.js +1 -0
- package/runtime/web/assets/creator-2d-nG7rGEmq.wasm +0 -0
- package/runtime/web/assets/{creator-full-D2dSDdDj.wasm → creator-full-Cla1R8N6.wasm} +0 -0
- package/runtime/web/assets/creator-full-DmfoULcB.js +1 -0
- package/runtime/web/assets/creator-full.js-D8SMHuCt.symbols +8419 -0
- package/runtime/web/assets/creator-ui-CbtoaRlz.js +1 -0
- package/runtime/web/assets/creator-ui-DZaoxIpX.wasm +0 -0
- package/runtime/web/player.js +18 -18
- package/runtime/web-lite/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/runtime/web-lite/assets/ar-BOdQ-wV4.js +1 -0
- package/runtime/web-lite/assets/basis-C64VHDVD.js +1 -0
- package/runtime/web-lite/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/runtime/web-lite/assets/{canvas-C6G_Pb2Q.js → canvas-WxiusXCB.js} +1 -1
- package/runtime/web-lite/assets/creatorAnim-BOwKr-C1.js +1 -0
- package/runtime/web-lite/assets/creatorParticles-DYlv4g1J.js +1 -0
- package/runtime/web-lite/assets/creatorPhysics-k7wL0kV1.js +1 -0
- package/runtime/web-lite/assets/draco-BiISTFcR.js +118 -0
- package/runtime/web-lite/assets/draco_decoder-DsQ12WqX.wasm +0 -0
- package/runtime/web-lite/assets/index-3CbsYWmo.js +2 -0
- package/runtime/web-lite/assets/neutral_ibl128-D-lzdkLN.ktx +0 -0
- package/runtime/web-lite/assets/{pager-O712dZv9.js → pager-Db5BY7FB.js} +1 -1
- package/runtime/web-lite/assets/plugins-CuEPHbHL.js +1 -0
- package/runtime/web-lite/assets/scene3d-CX7MAqmN.js +866 -0
- package/runtime/web-lite/assets/tween-BnWYhJ7P.js +1 -0
- package/runtime/web-lite/assets/video-BLj61nDx.js +1 -0
- package/runtime/web-lite/assets/{vlist-gEB8Sn0S.js → vlist-DrV2Q7P5.js} +1 -1
- package/runtime/web-lite/assets/worker-Caf-yYEI.js +2 -0
- package/runtime/web-lite/features.json +87 -4
- package/runtime/web-lite/lite.js +39 -10
- package/runtime/web-lite/plugins/camera/index-5XOM8eHl.js +1 -0
- package/runtime/web-lite/plugins/geolocation/index-BjTKN9_P.js +1 -0
- package/runtime/web-lite/plugins/map/_virtual_lecodes-half-CQBO044A.js +1 -0
- package/runtime/web-lite/plugins/map/_virtual_lecodes-half-resources-BYwqQkWQ.js +1 -0
- package/runtime/web-lite/plugins/map/assets/maplibre-gl-worker-DeQFNGa4.js +8 -0
- package/runtime/web-lite/plugins/map/index-BTBHQ264.js +1 -0
- package/runtime/web-lite/plugins/map/mapImpl-C6E1SwCS.js +823 -0
- package/runtime/web-lite/plugins/qr-scanner/index-CK_cYoJR.js +1 -0
- package/runtime/web-lite/plugins.json +8 -0
- package/src/commands/compile.ts +7 -1
- package/src/commands/desktop.ts +7 -4
- package/src/commands/dev.ts +4 -14
- package/src/commands/render.ts +6 -2
- package/src/commands/test.ts +25 -9
- package/src/commands/update/index.ts +12 -7
- package/src/commands/update/types.ts +1 -1
- package/src/compile/projectCompile.ts +48 -1
- package/src/dev/androidDev.ts +1 -1
- package/src/dev/backendWorker.ts +138 -0
- package/src/dev/devServer.ts +119 -115
- package/src/dev/localBackend.ts +214 -0
- package/src/dev/projectBackend.ts +199 -0
- package/src/dev/serve.ts +164 -0
- package/src/hosts/cmgenTool.ts +6 -9
- package/src/hosts/desktopRenderer.ts +6 -5
- package/src/hosts/liteRunner.ts +52 -9
- package/src/hosts/matcTool.ts +5 -22
- package/src/hosts/packageManager.ts +45 -0
- package/src/hosts/peerInstall.ts +8 -13
- package/src/hosts/peers.ts +2 -0
- package/src/hosts/releases.ts +6 -0
- package/src/index.ts +5 -1
- package/src/plugin/generate.ts +10 -6
- package/src/web/build.ts +20 -3
- package/runtime/web/assets/creator-2d-D9WKYM6h.js +0 -1
- package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
- package/runtime/web/assets/creator-full-BBXzQlhq.js +0 -1
- package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +0 -8124
- package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
- package/runtime/web/assets/creator-ui-_1Z_AX2M.js +0 -1
- package/runtime/web-lite/assets/tween-t_XDy3Nd.js +0 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { rmSync } from "node:fs"
|
|
2
|
+
import { join, relative } from "node:path"
|
|
3
|
+
import { createChannelHub } from "lecodes-sdk/server"
|
|
4
|
+
import { compileServer, hasServerFiles, type CompileEntry } from "../compile/projectCompile"
|
|
5
|
+
import { loadPeer } from "../hosts/peerInstall"
|
|
6
|
+
import { LECODES_DIR } from "../project/manifest"
|
|
7
|
+
import { readAppConfigAt } from "../commands/app/shared"
|
|
8
|
+
import { startLocalBackend, type LocalBackend } from "./localBackend"
|
|
9
|
+
import { serve, type Served, type Socket, type SocketHandlers } from "./serve"
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* The project's backend ON THE WIRE, on this machine: its server bundle run by localBackend.ts, put
|
|
13
|
+
* behind the routes the SDK's client transport speaks (sdk/src/runtime/rpc.ts) —
|
|
14
|
+
*
|
|
15
|
+
* POST <base>/api/<id> an endpoint call, the envelope always HTTP 200 (the client reads `ok`)
|
|
16
|
+
* WS <base>/ws the channel socket: the SDK's hub, the one the runner serves on le.codes
|
|
17
|
+
*
|
|
18
|
+
* Two users: the dev server mounts the routes under its token path (`lecodes dev`, `web dev`, the
|
|
19
|
+
* browser tests), and `runBackend` listens on a port of its own for the commands that run a bundle
|
|
20
|
+
* on a host this CLI starts (`desktop run`, `serve`, `render --desktop`, `test --desktop`).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type ServerBundle = Awaited<ReturnType<typeof compileServer>>
|
|
24
|
+
|
|
25
|
+
export type ProjectBackendOptions = {
|
|
26
|
+
root: string
|
|
27
|
+
log: (msg: string) => void
|
|
28
|
+
/** The database's folder (default `.lecodes/db`). */
|
|
29
|
+
dbDir?: string
|
|
30
|
+
/** The command, for the note of the database peer's install. */
|
|
31
|
+
command: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ProjectBackend = {
|
|
35
|
+
/** The server bundle of these entries; null when the project has no server files. */
|
|
36
|
+
compile: (entries: CompileEntry[]) => Promise<ServerBundle | null>
|
|
37
|
+
/** Run this bundle from now on (null = the project has no server side any more: stop). Rejects
|
|
38
|
+
* when it does not load; resolves to what to tell the developer. */
|
|
39
|
+
run: (bundle: ServerBundle | null) => Promise<string[]>
|
|
40
|
+
/** A request under the backend's base; `route` is the path after it ("/api/<id>"). */
|
|
41
|
+
handle: (req: Request, route: string, ip: string | undefined) => Promise<Response>
|
|
42
|
+
/** The channel socket (`<base>/ws`). */
|
|
43
|
+
socket: (req: Request, ip: string | undefined) => (conn: Socket) => SocketHandlers
|
|
44
|
+
/** Start again on an EMPTY database (a test's case). */
|
|
45
|
+
reset: () => Promise<void>
|
|
46
|
+
/** Resolves once the database is closed. */
|
|
47
|
+
close: () => Promise<void>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const CORS = {
|
|
51
|
+
"access-control-allow-origin": "*",
|
|
52
|
+
"access-control-allow-methods": "POST, GET, OPTIONS",
|
|
53
|
+
"access-control-allow-headers": "authorization, content-type",
|
|
54
|
+
"access-control-max-age": "86400",
|
|
55
|
+
}
|
|
56
|
+
const bearerOf = (req: Request): string | null => /^Bearer\s+(.+)$/i.exec(req.headers.get("authorization") ?? "")?.[1]?.trim() ?? null
|
|
57
|
+
const BODY_LIMIT = 8 * 1024 * 1024
|
|
58
|
+
|
|
59
|
+
export const createProjectBackend = (options: ProjectBackendOptions): ProjectBackend => {
|
|
60
|
+
const { root, log } = options
|
|
61
|
+
const dbDir = options.dbDir ?? join(root, LECODES_DIR, "db")
|
|
62
|
+
let backend: LocalBackend | null = null
|
|
63
|
+
let running: string | null = null
|
|
64
|
+
|
|
65
|
+
// The channel socket is the SDK's hub — the one the runner serves too.
|
|
66
|
+
const hub = createChannelHub<Socket>({
|
|
67
|
+
send: (conn, frame) => conn.send(frame),
|
|
68
|
+
subscribe: (_scope, ch, args, ctx) => backend
|
|
69
|
+
? backend.subscribe(ch, args, ctx)
|
|
70
|
+
: Promise.resolve({ ok: false as const, status: 404, message: "The project has no server files" }),
|
|
71
|
+
})
|
|
72
|
+
const sockets = new Set<Socket>()
|
|
73
|
+
const start = () => startLocalBackend({ root, dbDir, log, onPublish: (ch, group, data) => hub.publish("", ch, group, data) })
|
|
74
|
+
|
|
75
|
+
const envelope = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json", "cache-control": "no-store", ...CORS } })
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
compile: async (entries) => {
|
|
79
|
+
if (!hasServerFiles(entries)) return null
|
|
80
|
+
// the app's name, for the sign-in mail
|
|
81
|
+
const app = readAppConfigAt(root)
|
|
82
|
+
return compileServer({ entries, root, app: app ? { name: app.name } : undefined })
|
|
83
|
+
},
|
|
84
|
+
run: async (bundle) => {
|
|
85
|
+
if (!bundle) {
|
|
86
|
+
await backend?.close()
|
|
87
|
+
backend = null
|
|
88
|
+
running = null
|
|
89
|
+
return []
|
|
90
|
+
}
|
|
91
|
+
// the embedded database is a peer, installed the first time a project defines models
|
|
92
|
+
if (Object.keys(bundle.manifest.modules).length && /\bdefineDb\s*\(/.test(bundle.code)) {
|
|
93
|
+
await loadPeer("marcidb-embedded", undefined, { for: `${options.command} (the project's database)` })
|
|
94
|
+
}
|
|
95
|
+
backend ??= start()
|
|
96
|
+
const deployed = await backend.deploy(bundle.code)
|
|
97
|
+
running = bundle.code
|
|
98
|
+
if (!deployed.changed) return []
|
|
99
|
+
const d = deployed.describe
|
|
100
|
+
const models = Object.keys(d.models).length
|
|
101
|
+
const count = (n: number, what: string) => `${n} ${what}${n === 1 ? "" : "s"}`
|
|
102
|
+
log(`server: ${count(d.endpoints.length, "endpoint")}, ${count(d.channels.length, "channel")}${models ? `, ${count(models, "model")} (database in ${relative(root, dbDir).replace(/\\/g, "/")})` : ""}`)
|
|
103
|
+
return deployed.notes.map((n) => `server: ${n}`)
|
|
104
|
+
},
|
|
105
|
+
handle: async (req, route, ip) => {
|
|
106
|
+
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS })
|
|
107
|
+
const id = route.startsWith("/api/") ? decodeURIComponent(route.slice("/api/".length)) : ""
|
|
108
|
+
if (!id || req.method !== "POST") return envelope({ ok: false, status: 404, message: "not an endpoint call" })
|
|
109
|
+
if (!backend) return envelope({ ok: false, status: 404, message: "The project has no server files" })
|
|
110
|
+
let args: unknown
|
|
111
|
+
try {
|
|
112
|
+
const body = await req.text()
|
|
113
|
+
args = body.length > BODY_LIMIT ? null : JSON.parse(body)?.args
|
|
114
|
+
} catch { args = null }
|
|
115
|
+
if (!Array.isArray(args)) return envelope({ ok: false, status: 400, message: "body must be {\"args\":[…]}" })
|
|
116
|
+
return envelope(await backend.invoke(id, args, { headers: Object.fromEntries(req.headers), ip, sessionToken: bearerOf(req) ?? undefined }))
|
|
117
|
+
},
|
|
118
|
+
socket: (req, ip) => (conn) => {
|
|
119
|
+
const c = hub.connect(conn, { ip, session: bearerOf(req) })
|
|
120
|
+
sockets.add(conn)
|
|
121
|
+
return {
|
|
122
|
+
onMessage: (raw) => { void c.message(raw) },
|
|
123
|
+
onClose: () => { sockets.delete(conn); c.close() },
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
reset: async () => {
|
|
127
|
+
if (!backend || running === null) return
|
|
128
|
+
await backend.close()
|
|
129
|
+
rmSync(dbDir, { recursive: true, force: true })
|
|
130
|
+
backend = start()
|
|
131
|
+
await backend.deploy(running)
|
|
132
|
+
},
|
|
133
|
+
close: async () => {
|
|
134
|
+
for (const conn of [...sockets]) conn.close()
|
|
135
|
+
await backend?.close()
|
|
136
|
+
backend = null
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ───────────────────────────── a backend for one run of a bundle ─────────────────────────────
|
|
142
|
+
|
|
143
|
+
export type RunBackend = {
|
|
144
|
+
/** Called by the compile, once it has the project's entries: the backend is started when there
|
|
145
|
+
* are server files, and its url — what the app's stubs call — is the answer. */
|
|
146
|
+
start: (entries: CompileEntry[], onWarning: (w: string) => void) => Promise<string | undefined>
|
|
147
|
+
/** Start again on an empty database. */
|
|
148
|
+
reset: () => Promise<void>
|
|
149
|
+
close: () => Promise<void>
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A port that stays the same for a project from run to run: the app keeps its session under the
|
|
153
|
+
* backend's url, so a url that moved would sign the developer out at every run. */
|
|
154
|
+
const stablePort = (root: string): number => {
|
|
155
|
+
let h = 0
|
|
156
|
+
for (let i = 0; i < root.length; i++) h = (h * 31 + root.charCodeAt(i)) >>> 0
|
|
157
|
+
return 42000 + (h % 4000)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The backend of a project run on this machine by a host the CLI starts, listening on
|
|
162
|
+
* 127.0.0.1. `test`: a database of its own (`.lecodes/test-db`, emptied by `reset`) and any free
|
|
163
|
+
* port — a test never touches what `lecodes dev` and `desktop run` keep in `.lecodes/db`.
|
|
164
|
+
*/
|
|
165
|
+
export const runBackend = (options: { root: string, command: string, log: (msg: string) => void, test?: boolean }): RunBackend => {
|
|
166
|
+
const { root } = options
|
|
167
|
+
const dbDir = join(root, LECODES_DIR, options.test ? "test-db" : "db")
|
|
168
|
+
if (options.test) rmSync(dbDir, { recursive: true, force: true })
|
|
169
|
+
const backend = createProjectBackend({ root, dbDir, log: options.log, command: options.command })
|
|
170
|
+
let server: Served | null = null
|
|
171
|
+
|
|
172
|
+
const listen = (port: number) => serve({
|
|
173
|
+
port,
|
|
174
|
+
hostname: "127.0.0.1",
|
|
175
|
+
fetch: (req, peer) => {
|
|
176
|
+
const path = new URL(req.url).pathname
|
|
177
|
+
return path.startsWith("/server/") ? backend.handle(req, path.slice("/server".length), peer.ip) : new Response("Not found", { status: 404 })
|
|
178
|
+
},
|
|
179
|
+
websocket: (req, peer) => new URL(req.url).pathname === "/server/ws" ? backend.socket(req, peer.ip) : null,
|
|
180
|
+
onError: (e) => options.log(`request failed: ${e instanceof Error ? e.message : String(e)}`),
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
start: async (entries, onWarning) => {
|
|
185
|
+
const bundle = await backend.compile(entries)
|
|
186
|
+
if (!bundle) return undefined
|
|
187
|
+
for (const w of await backend.run(bundle)) onWarning(w)
|
|
188
|
+
// the project's port when it is free (two runs of one project at once: the second takes any)
|
|
189
|
+
server ??= await (options.test ? listen(0) : listen(stablePort(root)).catch(() => listen(0)))
|
|
190
|
+
return `http://127.0.0.1:${server.port}/server`
|
|
191
|
+
},
|
|
192
|
+
reset: () => backend.reset(),
|
|
193
|
+
close: async () => {
|
|
194
|
+
server?.close()
|
|
195
|
+
server = null
|
|
196
|
+
await backend.close()
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
}
|
package/src/dev/serve.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage } from "node:http"
|
|
2
|
+
import { Readable } from "node:stream"
|
|
3
|
+
import { upgradeWebSocket } from "./wsServer"
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* ONE way for the CLI to listen: a handler of web-standard requests (`Request` → `Response`) and
|
|
7
|
+
* websockets as text messages. The CLI runs on two runtimes — Node (the npm install) and Bun (a
|
|
8
|
+
* checkout, the standalone binary) — and this file is the only place that knows which:
|
|
9
|
+
*
|
|
10
|
+
* Bun `Bun.serve`: its own HTTP server and websockets. Bun's `node:http` is NOT an option
|
|
11
|
+
* there — it emits 'upgrade' and then drops what is written to the socket, so a websocket
|
|
12
|
+
* never completes its handshake (probed on 1.3.14).
|
|
13
|
+
* Node `node:http` + the dependency-free websocket of ./wsServer.ts.
|
|
14
|
+
*
|
|
15
|
+
* A server of the CLI that needs a websocket is written against this, never against `node:http`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type Peer = {
|
|
19
|
+
/** The remote address, as the runtime reports it. */
|
|
20
|
+
ip: string | undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type Socket = {
|
|
24
|
+
send: (text: string) => void
|
|
25
|
+
close: () => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type SocketHandlers = {
|
|
29
|
+
onMessage: (text: string) => void
|
|
30
|
+
/** Fired exactly once, however the connection ends. */
|
|
31
|
+
onClose: () => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ServeOptions = {
|
|
35
|
+
port: number
|
|
36
|
+
/** Bind address (default 0.0.0.0). */
|
|
37
|
+
hostname?: string
|
|
38
|
+
fetch: (req: Request, peer: Peer) => Response | Promise<Response>
|
|
39
|
+
/** A websocket asks to connect: return what takes the open socket, or null to refuse. Decided
|
|
40
|
+
* from the request alone (its path), before the handshake. */
|
|
41
|
+
websocket?: (req: Request, peer: Peer) => ((socket: Socket) => SocketHandlers) | null
|
|
42
|
+
/** A handler that threw (the response is a 500). */
|
|
43
|
+
onError?: (e: unknown) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type Served = {
|
|
47
|
+
/** The bound port (differs from `options.port` when 0 was passed). */
|
|
48
|
+
port: number
|
|
49
|
+
close: () => void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const failure = (opts: ServeOptions, e: unknown): Response => {
|
|
53
|
+
opts.onError?.(e)
|
|
54
|
+
return new Response("Internal error", { status: 500, headers: { "content-type": "text/plain", "cache-control": "no-store" } })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ───────────────────────────── Bun ─────────────────────────────
|
|
58
|
+
|
|
59
|
+
type BunSocketData = { accept: (socket: Socket) => SocketHandlers, handlers: SocketHandlers | null }
|
|
60
|
+
|
|
61
|
+
const serveBun = (opts: ServeOptions): Served => {
|
|
62
|
+
let server: ReturnType<typeof Bun.serve<BunSocketData>>
|
|
63
|
+
try {
|
|
64
|
+
server = Bun.serve<BunSocketData>({
|
|
65
|
+
port: opts.port,
|
|
66
|
+
hostname: opts.hostname ?? "0.0.0.0",
|
|
67
|
+
// seconds, the most Bun takes: an endpoint of a project's backend may think for 30
|
|
68
|
+
idleTimeout: 255,
|
|
69
|
+
async fetch(req, srv) {
|
|
70
|
+
const peer: Peer = { ip: srv.requestIP(req)?.address }
|
|
71
|
+
try {
|
|
72
|
+
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
73
|
+
const accept = opts.websocket?.(req, peer)
|
|
74
|
+
if (!accept) return new Response("Not found", { status: 404 })
|
|
75
|
+
if (srv.upgrade(req, { data: { accept, handlers: null } })) return undefined as unknown as Response
|
|
76
|
+
return new Response("Expected a websocket upgrade", { status: 400 })
|
|
77
|
+
}
|
|
78
|
+
return await opts.fetch(req, peer)
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return failure(opts, e)
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
websocket: {
|
|
84
|
+
// A reload channel is silent for as long as nobody saves: Bun's pings keep it open.
|
|
85
|
+
idleTimeout: 960,
|
|
86
|
+
sendPings: true,
|
|
87
|
+
open(ws) {
|
|
88
|
+
ws.data.handlers = ws.data.accept({
|
|
89
|
+
send: (text) => { if (ws.readyState === 1) ws.send(text) },
|
|
90
|
+
close: () => { ws.close() },
|
|
91
|
+
})
|
|
92
|
+
},
|
|
93
|
+
message(ws, raw) {
|
|
94
|
+
ws.data.handlers?.onMessage(typeof raw === "string" ? raw : new TextDecoder().decode(raw))
|
|
95
|
+
},
|
|
96
|
+
close(ws) {
|
|
97
|
+
const h = ws.data.handlers
|
|
98
|
+
ws.data.handlers = null
|
|
99
|
+
h?.onClose()
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
} catch (e) {
|
|
104
|
+
// the code node's listen() reports, which callers match on
|
|
105
|
+
if ((e as { code?: string }).code === "EADDRINUSE") throw new Error(`EADDRINUSE: port ${opts.port} is already in use`)
|
|
106
|
+
throw e
|
|
107
|
+
}
|
|
108
|
+
return { port: server.port ?? opts.port, close: () => { void server.stop(true) } }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ───────────────────────────── Node ─────────────────────────────
|
|
112
|
+
|
|
113
|
+
const requestOf = (req: IncomingMessage, upgrade = false): Request => {
|
|
114
|
+
const headers = new Headers()
|
|
115
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
116
|
+
if (v !== undefined) headers.set(k, Array.isArray(v) ? v.join(", ") : v)
|
|
117
|
+
}
|
|
118
|
+
const method = req.method ?? "GET"
|
|
119
|
+
const bodyless = upgrade || method === "GET" || method === "HEAD"
|
|
120
|
+
return new Request(new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`).href, {
|
|
121
|
+
method, headers,
|
|
122
|
+
...(bodyless ? {} : { body: Readable.toWeb(req), duplex: "half" }),
|
|
123
|
+
} as unknown as RequestInit)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const serveNode = async (opts: ServeOptions): Promise<Served> => {
|
|
127
|
+
const server = createServer((req, res) => {
|
|
128
|
+
void (async () => {
|
|
129
|
+
let response: Response
|
|
130
|
+
try { response = await opts.fetch(requestOf(req), { ip: req.socket.remoteAddress }) }
|
|
131
|
+
catch (e) { response = failure(opts, e) }
|
|
132
|
+
res.writeHead(response.status, Object.fromEntries(response.headers))
|
|
133
|
+
if (!response.body || req.method === "HEAD") { res.end(); return }
|
|
134
|
+
Readable.fromWeb(response.body as unknown as import("node:stream/web").ReadableStream).on("error", () => res.destroy()).pipe(res)
|
|
135
|
+
})()
|
|
136
|
+
})
|
|
137
|
+
server.on("upgrade", (req, socket) => {
|
|
138
|
+
let accept: ((socket: Socket) => SocketHandlers) | null = null
|
|
139
|
+
try { accept = opts.websocket?.(requestOf(req, true), { ip: req.socket.remoteAddress }) ?? null }
|
|
140
|
+
catch (e) { opts.onError?.(e) }
|
|
141
|
+
if (!accept) { socket.destroy(); return }
|
|
142
|
+
let handlers: SocketHandlers | null = null
|
|
143
|
+
const conn = upgradeWebSocket(req, socket, {
|
|
144
|
+
onMessage: (text) => { handlers?.onMessage(text) },
|
|
145
|
+
onClose: () => { handlers?.onClose() },
|
|
146
|
+
})
|
|
147
|
+
if (conn) handlers = accept(conn)
|
|
148
|
+
})
|
|
149
|
+
await new Promise<void>((resolve, reject) => {
|
|
150
|
+
server.once("error", reject)
|
|
151
|
+
server.listen(opts.port, opts.hostname ?? "0.0.0.0", () => {
|
|
152
|
+
server.removeListener("error", reject)
|
|
153
|
+
resolve()
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
const address = server.address()
|
|
157
|
+
return {
|
|
158
|
+
port: typeof address === "object" && address !== null ? address.port : opts.port,
|
|
159
|
+
close: () => { server.close(); server.closeAllConnections?.() },
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const serve = async (opts: ServeOptions): Promise<Served> =>
|
|
164
|
+
typeof (globalThis as { Bun?: unknown }).Bun !== "undefined" ? serveBun(opts) : serveNode(opts)
|
package/src/hosts/cmgenTool.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from "node:fs"
|
|
2
2
|
import { homedir } from "node:os"
|
|
3
3
|
import { join, resolve } from "node:path"
|
|
4
|
-
import { downloadReleaseArchive, fetchReleaseProduct, newestForPlatform, semverNewer, type ManifestFile, type ManifestVersion } from "./releases"
|
|
4
|
+
import { downloadReleaseArchive, fetchReleaseProduct, macPlatformKey, newestForPlatform, semverNewer, type ManifestFile, type ManifestVersion } from "./releases"
|
|
5
5
|
import { CliError, note } from "../cli"
|
|
6
6
|
|
|
7
7
|
/*
|
|
@@ -11,9 +11,9 @@ import { CliError, note } from "../cli"
|
|
|
11
11
|
*
|
|
12
12
|
* The binary is NOT shipped with the CLI (most projects never bake an environment). It downloads on
|
|
13
13
|
* first use from the same S3 release bucket as the desktop renderer and matc (product "cmgen" in
|
|
14
|
-
* releases/versions.json — published by scripts/release/release-windows.ps1
|
|
15
|
-
* build's in-tree filament host tools, so the KTX it writes always
|
|
16
|
-
* ship). Cache: ~/.lecodes/cmgen/cmgen-<version>/. LECODES_CMGEN overrides resolution entirely
|
|
14
|
+
* releases/versions.json — published by scripts/release/release-windows.ps1 / release-ubuntu.sh /
|
|
15
|
+
* release-macos.sh from the desktop build's in-tree filament host tools, so the KTX it writes always
|
|
16
|
+
* matches the engine the hosts ship). Cache: ~/.lecodes/cmgen/cmgen-<version>/. LECODES_CMGEN overrides resolution entirely
|
|
17
17
|
* (local dev: hosts/desktop/build/creator-gl/filament/tools/cmgen/cmgen.exe).
|
|
18
18
|
*
|
|
19
19
|
* The CLI resolves it and hands the path to lecodes-assets through LECODES_CMGEN, so that package
|
|
@@ -29,11 +29,8 @@ const exeName = () => (process.platform === "win32" ? "cmgen.exe" : "cmgen")
|
|
|
29
29
|
const platformKey = (): string => {
|
|
30
30
|
if (process.platform === "win32") return "windows-x64"
|
|
31
31
|
if (process.platform === "linux") return "linux-x64"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
? "cmgen isn't published for macOS yet — build filament's host tools locally and set LECODES_CMGEN."
|
|
35
|
-
: `cmgen isn't published for ${process.platform}.`,
|
|
36
|
-
)
|
|
32
|
+
if (process.platform === "darwin") return macPlatformKey()
|
|
33
|
+
throw new CliError(`cmgen isn't published for ${process.platform}.`)
|
|
37
34
|
}
|
|
38
35
|
|
|
39
36
|
const cacheRoot = () => join(homedir(), ".lecodes", "cmgen")
|
|
@@ -2,7 +2,7 @@ import { execFileSync, spawn, spawnSync } from "node:child_process"
|
|
|
2
2
|
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, linkSync } from "node:fs"
|
|
3
3
|
import { homedir, tmpdir } from "node:os"
|
|
4
4
|
import { delimiter, join, resolve } from "node:path"
|
|
5
|
-
import { cachedArtifactSha, downloadReleaseArchive, fetchReleaseProduct, semverNewer, type ManifestVersion } from "./releases"
|
|
5
|
+
import { cachedArtifactSha, downloadReleaseArchive, fetchReleaseProduct, macPlatformKey, semverNewer, type ManifestVersion } from "./releases"
|
|
6
6
|
import { CliError, note, warnErr } from "../cli"
|
|
7
7
|
import { makeStackDemangler } from "../compile/nativeStack"
|
|
8
8
|
|
|
@@ -17,9 +17,10 @@ import { makeStackDemangler } from "../compile/nativeStack"
|
|
|
17
17
|
* ~/.lecodes/renderer/desktop-<version>/. LECODES_DESKTOP_EXE overrides resolution entirely
|
|
18
18
|
* (local dev: point it at hosts/desktop/build/lecodes-desktop.exe).
|
|
19
19
|
*
|
|
20
|
-
* Platforms: windows-x64 + linux (the ubuntu build) + macos-arm64. The macOS host is
|
|
21
|
-
* published separately (
|
|
22
|
-
* as a notarized DMG under the same "desktop" product — so its
|
|
20
|
+
* Platforms: windows-x64 + linux (the ubuntu build) + macos-arm64 / macos-x64. The macOS host is
|
|
21
|
+
* built and published separately (hosts/desktop's LeCodes Desktop.app, one per CPU, via
|
|
22
|
+
* scripts/release/release-macos.sh) as a notarized DMG under the same "desktop" product — so its
|
|
23
|
+
* version line has gaps: a release
|
|
23
24
|
* may ship for Windows only. On darwin the cache therefore holds the newest published version
|
|
24
25
|
* that HAS a macOS artifact (never below the pin), and an app installed in /Applications is only
|
|
25
26
|
* the fallback for when the manifest can't be reached. The headless render contract
|
|
@@ -52,7 +53,7 @@ const platformKey = (renderer: DesktopRenderer = "gl"): string => {
|
|
|
52
53
|
throw new CliError(`The Vulkan desktop host is Windows-only (this is ${process.platform}). Use the default renderer.`)
|
|
53
54
|
}
|
|
54
55
|
if (process.platform === "linux") return "ubuntu-linux-x64"
|
|
55
|
-
if (process.platform === "darwin") return
|
|
56
|
+
if (process.platform === "darwin") return macPlatformKey()
|
|
56
57
|
throw new CliError(`The LeCodes Desktop host isn't available on ${process.platform}.`)
|
|
57
58
|
}
|
|
58
59
|
|
package/src/hosts/liteRunner.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process"
|
|
2
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
|
3
3
|
import { createServer } from "node:net"
|
|
4
4
|
import { dirname, join } from "node:path"
|
|
5
5
|
import { createInterface } from "node:readline"
|
|
6
6
|
import { CliError } from "../cli"
|
|
7
7
|
import { startDevServer, type DevServer } from "../dev/devServer"
|
|
8
|
-
import type {
|
|
8
|
+
import type { WebRunner } from "../dev/webRunner"
|
|
9
|
+
import { LECODES_DIR, type Manifest } from "../project/manifest"
|
|
9
10
|
import { distRoot } from "./distRoot"
|
|
10
11
|
import { peersDir } from "./peers"
|
|
11
12
|
import type { DesktopPlan } from "./desktopScript"
|
|
12
13
|
// (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
|
+
import { findDumpTarget, parseTreeDump, type DumpNode } from "../../../../runtime/headless/src/headless/paintDump"
|
|
14
15
|
|
|
15
16
|
/*
|
|
16
17
|
* `lecodes test --lite`: a flow scenario on WEB LITE, in a real browser. The scenario is the one
|
|
@@ -135,11 +136,15 @@ export type LiteSession = {
|
|
|
135
136
|
}
|
|
136
137
|
|
|
137
138
|
/** 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
|
|
139
|
+
export const startLiteSession = async (options: { root: string, manifest: Manifest | null, name: string, lite?: string, web?: WebRunner, entry?: string }): Promise<LiteSession> => {
|
|
139
140
|
const port = await freePort()
|
|
141
|
+
// The project's backend, when it has one: a database of the tests' own, emptied before every case.
|
|
142
|
+
const dbDir = join(options.root, LECODES_DIR, "test-db")
|
|
143
|
+
rmSync(dbDir, { recursive: true, force: true })
|
|
144
|
+
let ran = 0
|
|
140
145
|
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,
|
|
146
|
+
root: options.root, manifest: options.manifest, name: options.name, lite: options.lite, web: options.web,
|
|
147
|
+
origin: `http://127.0.0.1:${port}`, bind: "127.0.0.1", port, token: "test", entryOverride: options.entry, dbDir,
|
|
143
148
|
})
|
|
144
149
|
const compiled = await server.recompile()
|
|
145
150
|
if (!compiled.ok) { server.close(); throw new CliError(`Compile failed:\n${compiled.error}`) }
|
|
@@ -153,7 +158,13 @@ export const startLiteSession = async (options: { root: string, manifest: Manife
|
|
|
153
158
|
}
|
|
154
159
|
|
|
155
160
|
return {
|
|
156
|
-
|
|
161
|
+
// the web player (`web`): the same contract on its page — `window.lecodesTest` under `?test`
|
|
162
|
+
run: async (plan, runOptions) => {
|
|
163
|
+
if (ran++ > 0) await server.resetBackend()
|
|
164
|
+
return options.web
|
|
165
|
+
? runPlan(driver, `${server.webUrl}player/?dev=../..&test`, plan, runOptions, "web")
|
|
166
|
+
: runPlan(driver, `${server.liteUrl}?dev=..&test`, plan, runOptions, "lite")
|
|
167
|
+
},
|
|
157
168
|
async close() {
|
|
158
169
|
await driver.close()
|
|
159
170
|
server.close()
|
|
@@ -203,7 +214,7 @@ const FRAME = "new Promise((r) => requestAnimationFrame(() => requestAnimationFr
|
|
|
203
214
|
|
|
204
215
|
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
216
|
|
|
206
|
-
const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options: LiteRunOptions): Promise<string> => {
|
|
217
|
+
const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options: LiteRunOptions, page: "lite" | "web"): Promise<string> => {
|
|
207
218
|
const output: string[] = []
|
|
208
219
|
const seen: string[] = []
|
|
209
220
|
const say = (line: string) => { output.push(line) }
|
|
@@ -294,6 +305,30 @@ const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options:
|
|
|
294
305
|
case "type": await driver.keyboard("type", unwire(rest)); await frame(); return
|
|
295
306
|
case "fill": {
|
|
296
307
|
const value = rest === "" ? undefined : unwire(rest)
|
|
308
|
+
if (page === "web") {
|
|
309
|
+
// The player paints its fields: every empty one on show, in the tree's order, is tapped
|
|
310
|
+
// (a live <input> goes over it) and typed into.
|
|
311
|
+
const fields: DumpNode[] = []
|
|
312
|
+
const walk = (node: DumpNode) => {
|
|
313
|
+
if ((node.type === "input" || node.type === "textarea") && !node.value) fields.push(node)
|
|
314
|
+
for (const child of node.children ?? []) walk(child)
|
|
315
|
+
for (const p of node.pages ?? []) walk(p.node)
|
|
316
|
+
}
|
|
317
|
+
for (const root of parseTreeDump(await dump()).roots) walk(root.node)
|
|
318
|
+
for (const field of fields) {
|
|
319
|
+
const rect = await rectOf(field.id)
|
|
320
|
+
if (!rect || rect[2] === 0 || rect[3] === 0) continue // not on show (a covered page)
|
|
321
|
+
const kind = String(field.paint?.type ?? "")
|
|
322
|
+
const text = value ?? (kind === "email" ? "test@example.com" : kind === "tel" || kind === "phone" ? "5550100" : kind === "number" || kind === "decimal" ? "1" : "test")
|
|
323
|
+
await driver.mouse("move", rect[0] + rect[2] / 2, rect[1] + rect[3] / 2)
|
|
324
|
+
await driver.mouse("down")
|
|
325
|
+
await driver.mouse("up")
|
|
326
|
+
await frame()
|
|
327
|
+
await driver.keyboard("type", text)
|
|
328
|
+
}
|
|
329
|
+
await frame()
|
|
330
|
+
return
|
|
331
|
+
}
|
|
297
332
|
// every empty field on show, in the tree's order: what the native hosts fill
|
|
298
333
|
const count = await driver.evaluate<number>(`(() => {
|
|
299
334
|
window.__lecodesFill = [...document.querySelectorAll("${ON_SHOW} input.lc, ${ON_SHOW} textarea.lc")].filter((f) => f.value === "")
|
|
@@ -310,6 +345,14 @@ const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options:
|
|
|
310
345
|
case "scroll": {
|
|
311
346
|
const by = Number(args[0])
|
|
312
347
|
const name = typeof args[1] === "string" ? args[1] : null
|
|
348
|
+
if (page === "web") {
|
|
349
|
+
// the player scrolls what is under the pointer: the wheel over the named node, or the middle of the page
|
|
350
|
+
const point = name === null ? [options.width / 2, options.height / 2] as [number, number] : (await pointOf(args, 1)).point
|
|
351
|
+
await driver.mouse("move", point[0], point[1])
|
|
352
|
+
await driver.mouse("wheel", 0, by)
|
|
353
|
+
await frame()
|
|
354
|
+
return
|
|
355
|
+
}
|
|
313
356
|
const target = name === null
|
|
314
357
|
? `document.querySelector("${ON_SHOW} .lc-scrollable, ${ON_SHOW} .lc-vlist")`
|
|
315
358
|
: `document.querySelector('[data-node="${(await nodeOf(name)).id}"]')`
|
|
@@ -355,7 +398,7 @@ const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options:
|
|
|
355
398
|
return
|
|
356
399
|
}
|
|
357
400
|
default:
|
|
358
|
-
throw new Error(`"${op}" is not available
|
|
401
|
+
throw new Error(`"${op}" is not available in a browser run (no inspection camera, no gamepad)`)
|
|
359
402
|
}
|
|
360
403
|
}
|
|
361
404
|
|
package/src/hosts/matcTool.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from "node:fs"
|
|
2
2
|
import { homedir } from "node:os"
|
|
3
3
|
import { join, resolve } from "node:path"
|
|
4
|
-
import { downloadReleaseArchive, fetchReleaseProduct, newestForPlatform, semverNewer, type ManifestFile, type ManifestVersion } from "./releases"
|
|
4
|
+
import { downloadReleaseArchive, fetchReleaseProduct, macPlatformKey, newestForPlatform, semverNewer, type ManifestFile, type ManifestVersion } from "./releases"
|
|
5
5
|
import { CliError, note } from "../cli"
|
|
6
6
|
|
|
7
7
|
/*
|
|
@@ -23,14 +23,9 @@ import { CliError, note } from "../cli"
|
|
|
23
23
|
* read "not published" with a perfectly good older build sitting right there. `lecodes shaders
|
|
24
24
|
* update` moves an existing cache forward after an engine upgrade.
|
|
25
25
|
*
|
|
26
|
-
* Platforms: windows-x64, linux-x64, macos-arm64.
|
|
27
|
-
* (filament's host tools build in-tree there)
|
|
28
|
-
*
|
|
29
|
-
* (`out/cmake-release/filament/tools/matc`), which is the same filament tree those xcframeworks are
|
|
30
|
-
* compiled from. Same guarantee, different source; see scripts/release/release-macos.sh.
|
|
31
|
-
*
|
|
32
|
-
* macos-x64 is deliberately absent: the host tools are built for the machine doing the release, and
|
|
33
|
-
* only Apple Silicon is built today (release-macos.sh skips Intel for the same reason).
|
|
26
|
+
* Platforms: windows-x64, linux-x64, macos-arm64, macos-x64. matc rides the DESKTOP build on every
|
|
27
|
+
* OS (filament's host tools build in-tree there): on the Mac, the two trees of
|
|
28
|
+
* hosts/desktop/build-macos.sh — see scripts/release/release-macos.sh.
|
|
34
29
|
*/
|
|
35
30
|
|
|
36
31
|
const matcName = () => (process.platform === "win32" ? "matc.exe" : "matc")
|
|
@@ -38,19 +33,7 @@ const matcName = () => (process.platform === "win32" ? "matc.exe" : "matc")
|
|
|
38
33
|
const platformKey = (): string => {
|
|
39
34
|
if (process.platform === "win32") return "windows-x64"
|
|
40
35
|
if (process.platform === "linux") return "linux-x64"
|
|
41
|
-
if (process.platform === "darwin")
|
|
42
|
-
// Intel Macs have no published matc (see the platform note above). Rosetta would run an
|
|
43
|
-
// arm64 binary anyway, but publishing a key we never build is how a download turns into a
|
|
44
|
-
// corrupt-archive error instead of a readable one.
|
|
45
|
-
if (process.arch !== "arm64") {
|
|
46
|
-
throw new CliError(
|
|
47
|
-
"matc isn't published for Intel macOS — set LECODES_MATC to a locally built matc " +
|
|
48
|
-
"(creator-gl/out/cmake-release/filament/tools/matc), or push the project once so shaders " +
|
|
49
|
-
"compile on the server.",
|
|
50
|
-
)
|
|
51
|
-
}
|
|
52
|
-
return "macos-arm64"
|
|
53
|
-
}
|
|
36
|
+
if (process.platform === "darwin") return macPlatformKey()
|
|
54
37
|
throw new CliError(`matc isn't published for ${process.platform}.`)
|
|
55
38
|
}
|
|
56
39
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import { join, resolve, sep } from "node:path"
|
|
4
|
+
import { fileURLToPath } from "node:url"
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* ONE answer to "which package manager installs things for this CLI": the one the CLI itself was
|
|
8
|
+
* installed with. A CLI under bun's global dir (`bun install -g lecodes-cli`) installs its peers and
|
|
9
|
+
* updates itself with bun; any other install goes through npm. Mixing them is the failure this
|
|
10
|
+
* removes: `npm install -g lecodes-cli@latest` beside a bun-installed CLI puts a SECOND copy in npm's
|
|
11
|
+
* prefix and leaves the `lecodes` on PATH as old as it was.
|
|
12
|
+
*/
|
|
13
|
+
export type PackageManager = "npm" | "bun"
|
|
14
|
+
|
|
15
|
+
const onPath = (cmd: string): boolean => {
|
|
16
|
+
try { return spawnSync(cmd, ["--version"], { stdio: "ignore", shell: process.platform === "win32" }).status === 0 } catch { return false }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Bun's global install dir (`BUN_INSTALL` moves it). */
|
|
20
|
+
const bunGlobalDir = (): string => join(process.env.BUN_INSTALL ? resolve(process.env.BUN_INSTALL) : join(homedir(), ".bun"), "install", "global")
|
|
21
|
+
|
|
22
|
+
/** The manager that owns the install this file runs from. */
|
|
23
|
+
export const ownManager = (file = fileURLToPath(import.meta.url)): PackageManager => {
|
|
24
|
+
const norm = (p: string) => process.platform === "win32" ? p.toLowerCase() : p
|
|
25
|
+
return norm(resolve(file)).startsWith(norm(bunGlobalDir()) + sep) ? "bun" : "npm"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The manager to install with: the CLI's own, else whichever of the two is on PATH; null = neither. */
|
|
29
|
+
export const pickManager = (): PackageManager | null => {
|
|
30
|
+
const own = ownManager()
|
|
31
|
+
const other = own === "npm" ? "bun" : "npm"
|
|
32
|
+
return onPath(own) ? own : onPath(other) ? other : null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `<tool> <argv>` that installs `specs` into the directory `dir` (a private prefix with a package.json). */
|
|
36
|
+
export const installIntoArgv = (tool: PackageManager, dir: string, specs: string[]): string[] =>
|
|
37
|
+
tool === "npm" ? ["install", "--prefix", dir, "--no-audit", "--no-fund", "--loglevel=error", ...specs] : ["add", "--cwd", dir, ...specs]
|
|
38
|
+
|
|
39
|
+
/** `<tool> <argv>` that installs `specs` globally. */
|
|
40
|
+
export const installGlobalArgv = (tool: PackageManager, specs: string[]): string[] =>
|
|
41
|
+
tool === "npm" ? ["install", "-g", ...specs] : ["add", "-g", ...specs]
|
|
42
|
+
|
|
43
|
+
/** The same, as a line a person can run. */
|
|
44
|
+
export const commandLine = (tool: PackageManager, argv: string[]): string =>
|
|
45
|
+
`${tool} ${argv.map((a) => /\s/.test(a) ? `"${a}"` : a).join(" ")}`
|