lecodes-cli 2.0.5 → 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.
@@ -3,6 +3,8 @@ import { createRequire } from "node:module"
3
3
  import { dirname, join } from "node:path"
4
4
  import type { FileKind } from "../project/manifest"
5
5
  import { distRoot } from "../hosts/distRoot"
6
+ import { installPeers } from "../hosts/peerInstall"
7
+ import { peerDirInPrefix } from "../hosts/peers"
6
8
  import {
7
9
  compileProject as sdkCompileProject, compileServerBundle, detectEntry, isServerPath, scanModulesChisel, localAssetNames, ENTRY_CONVENTION,
8
10
  type CompileServerOptions,
@@ -123,40 +125,39 @@ export const compileProject = (opts: CompileOptions): Promise<string> =>
123
125
  export const hasServerFiles = (entries: CompileEntry[]): boolean =>
124
126
  entries.some((e) => e.type === "text" && isServerPath(e.path))
125
127
 
126
- /** TypeScript with its lib files on disk — the endpoint validators are derived by its checker. The
127
- * project's own, else what this CLI's install can see; null when there is none. */
128
- const findTypescript = (root: string): CompileServerOptions["typescript"] | null => {
129
- for (const from of [join(root, "package.json"), import.meta.url]) {
128
+ type Typescript = NonNullable<CompileServerOptions["typescript"]>
129
+
130
+ /** TypeScript with its lib files on disk — the server code is checked, and the endpoint validators
131
+ * derived, by its checker. The project's own, else one this CLI's install can see, else the CLI's
132
+ * peer (installed on first use, like the database engine). One WITHOUT the compiler API is passed
133
+ * over: TypeScript 7 — what `install typescript` brings today — is the native compiler, with no
134
+ * `createProgram` to call. */
135
+ const findTypescript = async (root: string): Promise<Typescript> => {
136
+ const from = (at: string): Typescript | null => {
130
137
  try {
131
- const mod = createRequire(from)("typescript")
132
- const ts = mod?.createProgram ? mod : mod?.default
133
- if (ts?.createProgram) return ts
134
- } catch { /* the next place */ }
138
+ const mod = createRequire(at)("typescript")
139
+ return mod?.createProgram ? mod : mod?.default?.createProgram ? mod.default : null
140
+ } catch { return null }
135
141
  }
136
- return null
142
+ const prefix = join(peerDirInPrefix("typescript"), "package.json")
143
+ const found = from(join(root, "package.json")) ?? from(import.meta.url) ?? from(prefix)
144
+ if (found) return found
145
+ await installPeers(["typescript"], "The project's server code (*.server.ts)")
146
+ return from(prefix) ?? (() => { throw new Error(`The typescript installed in ${peerDirInPrefix("typescript")} has no compiler API`) })()
137
147
  }
138
148
 
139
149
  /**
140
150
  * The project's server bundle (its `*.server.ts` files and what they import), or null when it has
141
- * none. `app` = app.json's name (the sign-in mail). Without a TypeScript to derive them with the
142
- * bundle carries no argument validators — said through `onWarning`, since the deployed backend
143
- * (the le.codes build) does check them.
151
+ * none. `app` = app.json's name (the sign-in mail). Always checked with TypeScript, the way the
152
+ * le.codes build checks it: an endpoint that is not async, an unawaited `requireUser()` are errors
153
+ * only the checker finds.
144
154
  */
145
- export const compileServer = async (opts: { entries: CompileEntry[], root: string, app?: CompileServerOptions["app"], onWarning?: (message: string) => void }) => {
146
- const typescript = findTypescript(opts.root)
147
- if (!typescript) {
148
- // Sign-in rests on a check only the compiler makes: `db.auth.requireUser()` is a query, and one that is
149
- // not awaited guards nothing. A server with sign-in is not built without it.
150
- const signIn = opts.entries.find((e) => e.type === "text" && isServerPath(e.path) && /\.withAuth\s*\(/.test(e.text ?? ""))
151
- if (signIn) throw new Error(`${signIn.path} uses sign-in (withAuth): its server code is checked with TypeScript, and none was found — npm install --save-dev typescript in the project.`)
152
- opts.onWarning?.("Endpoint arguments are not checked against their types here: no TypeScript found (npm install --save-dev typescript in the project).")
153
- }
154
- return compileServerBundle({
155
+ export const compileServer = async (opts: { entries: CompileEntry[], root: string, app?: CompileServerOptions["app"] }) =>
156
+ compileServerBundle({
155
157
  entries: opts.entries.map((e) => ({ path: e.path, type: e.type as any, text: e.text })),
156
158
  app: opts.app,
157
- ...(typescript ? { typescript } : { validate: false }),
159
+ typescript: await findTypescript(opts.root),
158
160
  })
159
- }
160
161
 
161
162
  /** Re-exported so the CALLERS that stage files next to the bundle name them the way the bundle asks
162
163
  * for them (see localAssetNames in the SDK compile — two packs shipping SM_Sign_01.glb). */
@@ -187,7 +187,7 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
187
187
  if (ambiguous) warnings.push(ambiguous)
188
188
  }
189
189
  const onWarning = (w: string) => { warnings.push(w) }
190
- const serverBundle = await backend.compile(entries, onWarning)
190
+ const serverBundle = await backend.compile(entries)
191
191
  const js = await compileProject({ entries, name, publicUrl: origin, entryOverride, header: true, fontsHeader, iconsHeader, onWarning, serverUrl: serverBundle ? serverUrl : undefined })
192
192
  // the backend takes the new bundle once the app compiled, and before devices are told to reload
193
193
  warnings.push(...await backend.run(serverBundle))
@@ -33,7 +33,7 @@ export type ProjectBackendOptions = {
33
33
 
34
34
  export type ProjectBackend = {
35
35
  /** The server bundle of these entries; null when the project has no server files. */
36
- compile: (entries: CompileEntry[], onWarning: (w: string) => void) => Promise<ServerBundle | null>
36
+ compile: (entries: CompileEntry[]) => Promise<ServerBundle | null>
37
37
  /** Run this bundle from now on (null = the project has no server side any more: stop). Rejects
38
38
  * when it does not load; resolves to what to tell the developer. */
39
39
  run: (bundle: ServerBundle | null) => Promise<string[]>
@@ -75,11 +75,11 @@ export const createProjectBackend = (options: ProjectBackendOptions): ProjectBac
75
75
  const envelope = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json", "cache-control": "no-store", ...CORS } })
76
76
 
77
77
  return {
78
- compile: async (entries, onWarning) => {
78
+ compile: async (entries) => {
79
79
  if (!hasServerFiles(entries)) return null
80
80
  // the app's name, for the sign-in mail
81
81
  const app = readAppConfigAt(root)
82
- return compileServer({ entries, root, app: app ? { name: app.name } : undefined, onWarning })
82
+ return compileServer({ entries, root, app: app ? { name: app.name } : undefined })
83
83
  },
84
84
  run: async (bundle) => {
85
85
  if (!bundle) {
@@ -182,7 +182,7 @@ export const runBackend = (options: { root: string, command: string, log: (msg:
182
182
 
183
183
  return {
184
184
  start: async (entries, onWarning) => {
185
- const bundle = await backend.compile(entries, onWarning)
185
+ const bundle = await backend.compile(entries)
186
186
  if (!bundle) return undefined
187
187
  for (const w of await backend.run(bundle)) onWarning(w)
188
188
  // the project's port when it is free (two runs of one project at once: the second takes any)
@@ -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(" ")}`
@@ -5,6 +5,7 @@ import { pathToFileURL } from "node:url"
5
5
  import pkg from "../../package.json"
6
6
  import { peerDirInPrefix, peersDir, peerVersionGlobal } from "./peers"
7
7
  import { archiveSpec } from "./archives"
8
+ import { commandLine, installGlobalArgv, installIntoArgv, ownManager, pickManager } from "./packageManager"
8
9
  import { CliError, c, confirm, log, note } from "../cli"
9
10
 
10
11
  /*
@@ -19,8 +20,9 @@ import { CliError, c, confirm, log, note } from "../cli"
19
20
  *
20
21
  * Consent: an interactive terminal is asked (default yes); `--yes` / `-y` on any command or
21
22
  * LECODES_YES=1 (CI) skips the question; a non-interactive run without either fails with the exact
22
- * command to run by hand. Installer: npm if on PATH, else bun; neither → a clear error (the standalone
23
- * binary on a machine without Node can't install packages — it says so).
23
+ * command to run by hand. Installer: the package manager this CLI was installed with (./packageManager.ts
24
+ * — bun for a `bun install -g` CLI, npm otherwise), else the other one; neither → a clear error (the
25
+ * standalone binary on a machine without Node can't install packages — it says so).
24
26
  *
25
27
  * Search order when a command needs a peer: the bare specifier (global / the workspace — what the
26
28
  * user installed by hand always wins) → the prefix → install into the prefix, then load from there.
@@ -48,12 +50,7 @@ export const peerSpec = async (name: string): Promise<string> => {
48
50
  /** Peers that only make sense together: the renderer's PNG output needs canvas next to it. */
49
51
  const companions = (name: string): string[] => name === "lecodes-headless" ? ["lecodes-headless", "@napi-rs/canvas"] : [name]
50
52
 
51
- const onPath = (cmd: string): boolean => {
52
- try { return spawnSync(cmd, ["--version"], { stdio: "ignore", shell: process.platform === "win32" }).status === 0 } catch { return false }
53
- }
54
- const pickInstaller = (): "npm" | "bun" | null => onPath("npm") ? "npm" : onPath("bun") ? "bun" : null
55
-
56
- const manualCommand = (dir: string, specs: string[]) => `npm install --prefix "${dir}" ${specs.join(" ")}`
53
+ const manualCommand = (dir: string, specs: string[]) => commandLine(ownManager(), installIntoArgv(ownManager(), dir, specs))
57
54
 
58
55
  /**
59
56
  * Install peers into the prefix. Asks for consent unless `opts.yes` / LECODES_YES / `--yes` say so;
@@ -86,14 +83,12 @@ export const installPeers = async (names: string[], why: string, opts: { yes?: b
86
83
  }
87
84
  }
88
85
 
89
- const tool = pickInstaller()
86
+ const tool = pickManager()
90
87
  if (!tool) throw new CliError(`Neither npm nor bun is on PATH, so ${names.join(" + ")} can't be installed automatically. Install one, or run on a machine with Node: ${manualCommand(dir, specsNow)}`)
91
88
 
92
89
  for (const name of ordered) {
93
90
  const spec = await peerSpec(name) // canvas's range comes from the renderer installed a step earlier
94
- const argv = tool === "npm"
95
- ? ["install", "--prefix", dir, "--no-audit", "--no-fund", "--loglevel=error", spec]
96
- : ["add", "--cwd", dir, spec]
91
+ const argv = installIntoArgv(tool, dir, [spec])
97
92
  note(`$ ${tool} ${argv.join(" ")}`)
98
93
  const r = spawnSync(tool, argv, { stdio: "inherit", shell: process.platform === "win32", timeout: 10 * 60_000 })
99
94
  if (r.error) throw new CliError(`${tool} could not run: ${r.error.message}`)
@@ -157,7 +152,7 @@ export async function loadPeer<T>(name: string, subpath: string | undefined, opt
157
152
  if (opts.install === false) return null
158
153
  // A global install of a DIFFERENT version than the probe expects would have resolved above; here
159
154
  // the peer is nowhere. (Globally installed but broken → the error above was not "not found".)
160
- if (peerVersionGlobal(name, spec) !== null) throw new CliError(`${name} is installed but "${spec}" can't be loaded from it — reinstall it: npm install -g ${await peerSpec(name)}`)
155
+ if (peerVersionGlobal(name, spec) !== null) throw new CliError(`${name} is installed but "${spec}" can't be loaded from it — reinstall it: ${commandLine(ownManager(), installGlobalArgv(ownManager(), [await peerSpec(name)]))}`)
161
156
  await installPeers(companions(name), opts.for)
162
157
  }
163
158
  return (await import(pathToFileURL(resolveEntry(dir, subpath)).href)) as T
@@ -24,6 +24,7 @@ export const OPTIONAL_PEERS: [name: string, probe: string][] = [
24
24
  ["lecodes-assets", "lecodes-assets/cli"],
25
25
  ["@napi-rs/canvas", "@napi-rs/canvas"],
26
26
  ["marcidb-embedded", "marcidb-embedded"],
27
+ ["typescript", "typescript"],
27
28
  ]
28
29
 
29
30
  /** The CLI's own peer install dir. */