lecodes-cli 2.4.10 → 2.5.1
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 +2580 -1095
- package/package.json +10 -10
- package/runtime/materials/decal-relief.mat +21 -46
- package/runtime/materials/decal.mat +41 -61
- package/runtime/materials/foliage-cards.mat +28 -140
- package/runtime/materials/foliage-impostor.mat +485 -0
- package/runtime/materials/foliage.mat +232 -0
- package/runtime/materials/grass.mat +244 -0
- package/runtime/materials/particles-quad.mat +5 -3
- package/runtime/materials/particles.mat +5 -3
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/assets/materials-Cs_z1zLi.bin +0 -0
- package/runtime/web/assets/materials-world-BtoAfznA.bin +0 -0
- package/runtime/web/player.js +3 -3
- package/runtime/web-lite/assets/{canvas-WxiusXCB.js → canvas-wFjS40IX.js} +1 -1
- package/runtime/web-lite/assets/creator2d-1pSkYRCn.js +1 -0
- package/runtime/web-lite/assets/creator2d-Ci5-pZGr.wasm +0 -0
- package/runtime/web-lite/assets/{pager-DpGa2uaE.js → pager-BZAO9gMC.js} +1 -1
- package/runtime/web-lite/assets/{plugins-CuEPHbHL.js → plugins-B7TuO_p4.js} +1 -1
- package/runtime/web-lite/assets/scene2d-F6T1Io7I.js +1 -0
- package/runtime/web-lite/assets/{tween-BnWYhJ7P.js → tween-DbrQTld4.js} +1 -1
- package/runtime/web-lite/assets/{video-BLj61nDx.js → video-bzR_t2i6.js} +1 -1
- package/runtime/web-lite/assets/{vlist-CwSYpLz-.js → vlist-Ap-F4H5J.js} +1 -1
- package/runtime/web-lite/features.json +18 -6
- package/runtime/web-lite/lite.js +6 -6
- package/src/commands/compile.ts +2 -1
- package/src/commands/hub/create.ts +100 -0
- package/src/commands/hub/credential.ts +32 -0
- package/src/commands/hub/index.ts +21 -0
- package/src/commands/hub/login.ts +89 -0
- package/src/commands/hub/status.ts +109 -0
- package/src/commands/index.ts +5 -1
- package/src/commands/level.ts +123 -0
- package/src/commands/lightmap.ts +41 -79
- package/src/commands/navmesh.ts +84 -67
- package/src/commands/secrets.ts +147 -0
- package/src/commands/update/index.ts +154 -64
- package/src/compile/designCompile.ts +2 -0
- package/src/compile/headlessBundle.ts +2 -0
- package/src/compile/projectCompile.ts +12 -1
- package/src/compile/sceneCompile.ts +2 -1
- package/src/dev/backendWorker.ts +6 -1
- package/src/dev/devServer.ts +2 -1
- package/src/dev/localBackend.ts +25 -16
- package/src/dev/projectBackend.ts +7 -3
- package/src/dev/watchProject.ts +2 -0
- package/src/hosts/desktopRenderer.ts +10 -2
- package/src/hosts/peerInstall.ts +7 -3
- package/src/hub/api.ts +220 -0
- package/src/hub/config.ts +53 -0
- package/src/hub/git.ts +148 -0
- package/src/platform/api.ts +21 -0
- package/src/project/env.ts +29 -0
- package/src/web/build.ts +149 -88
- package/runtime/web/assets/materials-CJzxzdUK.bin +0 -0
- package/runtime/web/assets/materials-world-BNQb0Z1l.bin +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process"
|
|
2
|
-
import { existsSync, readdirSync } from "node:fs"
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
|
3
3
|
import { homedir } from "node:os"
|
|
4
4
|
import { dirname, join, resolve } from "node:path"
|
|
5
5
|
import pkg from "../../../package.json"
|
|
6
6
|
import { CliError, bool, c, defineCommand, log, note, readFlags, type Command } from "../../cli"
|
|
7
7
|
import { OPTIONAL_PEERS, peerSource, peerVersion } from "../../hosts/peers"
|
|
8
|
-
import { installPeers } from "../../hosts/peerInstall"
|
|
8
|
+
import { installPeers, peerSpec } from "../../hosts/peerInstall"
|
|
9
9
|
import { archiveSpec, archivedVersion } from "../../hosts/archives"
|
|
10
10
|
import { commandLine, installGlobalArgv, ownManager } from "../../hosts/packageManager"
|
|
11
11
|
import { updateDesktop } from "../../hosts/desktopRenderer"
|
|
@@ -18,18 +18,24 @@ import app from "../app"
|
|
|
18
18
|
* `lecodes update` — one command that moves EVERYTHING a project depends on to its newest published
|
|
19
19
|
* state, in the order the pieces depend on each other:
|
|
20
20
|
*
|
|
21
|
-
* 1.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
21
|
+
* 1. cli the CLI itself, FIRST: when npm has a newer one it is installed and the rest of the run
|
|
22
|
+
* is ITS — the same command from the same entry file, told it was handed over. Every step
|
|
23
|
+
* below is then the new version's, the peers above all (updated last, the old CLI put back
|
|
24
|
+
* its own ranges: lecodes-assets 0.2.1 under CLI 2.4.10, and a second `update` was needed —
|
|
25
|
+
* 2026-10-28). Already current (a `bun add -g lecodes-cli` beforehand) → nothing to do.
|
|
26
|
+
* The standalone binary can't come from npm: it says so and goes on.
|
|
27
|
+
* 2. desktop the LeCodes Desktop host `desktop run` / `render --desktop` use (= `desktop update`)
|
|
28
|
+
* 3. matc Filament's material compiler for local shader compiles (= `shaders update`)
|
|
29
|
+
* 4. shaders recompile the project's .mat files with that matc (= `shaders compile`)
|
|
30
|
+
* 5. types the IDE types (.lecodes/types + tsconfig) from the server (types.ts)
|
|
31
|
+
* 6. shells ios/ + android/ pins → newest SDK release + plugin tags, re-sync (= `app update`)
|
|
32
|
+
* 7. peers every installed optional peer (lecodes-headless, lecodes-design, lecodes-3d-editor,
|
|
33
|
+
* lecodes-assets, @napi-rs/canvas, …) that is behind WHAT AN INSTALL WOULD PUT THERE:
|
|
34
|
+
* in ~/.lecodes/peers (first-use installs, peerInstall.ts) that is peerInstall's own spec
|
|
35
|
+
* — the renderer at THIS CLI's version, an editor at the newest archive its range takes,
|
|
36
|
+
* an npm peer at the newest version IN ITS RANGE (not npm's latest: compared with latest,
|
|
37
|
+
* canvas and marcidb-embedded read "behind" on every run and were re-installed at the
|
|
38
|
+
* version they had); global ones (`npm install -g`, the old way) via `…@latest`
|
|
33
39
|
*
|
|
34
40
|
* Every step is the existing subcommand, so nothing here can drift from what the individual
|
|
35
41
|
* commands do; this only sequences them and keeps going when one fails (the summary says which).
|
|
@@ -86,6 +92,45 @@ const semverNewer = (a: string, b: string): number => {
|
|
|
86
92
|
return 0
|
|
87
93
|
}
|
|
88
94
|
|
|
95
|
+
/** A version test for the range forms peer specs take (^x.y.z, ~x.y.z, >=x.y.z, x.y.z, *, latest); null for
|
|
96
|
+
* any other form — the caller falls back to npm's latest. */
|
|
97
|
+
const rangeTest = (range: string): ((v: string) => boolean) | null => {
|
|
98
|
+
const r = range.trim()
|
|
99
|
+
if (r === "" || r === "*" || r === "latest") return () => true
|
|
100
|
+
const m = /^(\^|~|>=)?(\d+)\.(\d+)\.(\d+)$/.exec(r)
|
|
101
|
+
if (!m) return null
|
|
102
|
+
const [ a, b, p ] = [ Number(m[2]), Number(m[3]), Number(m[4]) ]
|
|
103
|
+
const low = `${a}.${b}.${p}`
|
|
104
|
+
if (!m[1]) return (v) => v === low
|
|
105
|
+
if (m[1] === ">=") return (v) => semverNewer(v, low) >= 0
|
|
106
|
+
const below = m[1] === "~" ? `${a}.${b + 1}.0` : a > 0 ? `${a + 1}.0.0` : b > 0 ? `0.${b + 1}.0` : `0.0.${p + 1}`
|
|
107
|
+
return (v) => semverNewer(v, low) >= 0 && semverNewer(v, below) < 0
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The newest release of an npm package inside a range — what an install of `name@range` resolves to; null
|
|
111
|
+
* when npm is unreachable or the range is of another form. */
|
|
112
|
+
const npmNewestIn = async (name: string, range: string): Promise<string | null> => {
|
|
113
|
+
const accepts = rangeTest(range)
|
|
114
|
+
if (!accepts) return null
|
|
115
|
+
try {
|
|
116
|
+
const res = await fetch(`${NPM}/${name.replace("/", "%2f")}`, { headers: { accept: "application/vnd.npm.install-v1+json" }, signal: AbortSignal.timeout(6000) })
|
|
117
|
+
if (!res.ok) return null
|
|
118
|
+
const json = await res.json() as { versions?: Record<string, unknown> }
|
|
119
|
+
return Object.keys(json.versions ?? {}).filter((v) => /^\d+\.\d+\.\d+$/.test(v) && accepts(v)).sort(semverNewer).at(-1) ?? null
|
|
120
|
+
} catch {
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The version of the lecodes-cli package an entry file belongs to (`<pkg>/dist/index.js`), or null. */
|
|
126
|
+
const cliVersionAt = (entry: string): string | null => {
|
|
127
|
+
try {
|
|
128
|
+
return (JSON.parse(readFileSync(join(dirname(entry), "..", "package.json"), "utf8")) as { name?: string, version?: string }).version ?? null
|
|
129
|
+
} catch {
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
89
134
|
/** Running straight from the repo checkout (src/commands/update/index.ts), not a built dist/binary. */
|
|
90
135
|
const FROM_SOURCE = /[\\/]src[\\/]commands[\\/]/.test(import.meta.url)
|
|
91
136
|
/** The standalone release build: the bundle + chisel sit next to the executable (same test as index.ts). */
|
|
@@ -95,15 +140,16 @@ const STANDALONE = existsSync(join(dirname(process.execPath), "runtime")) &&
|
|
|
95
140
|
export default defineCommand({
|
|
96
141
|
name: "update",
|
|
97
142
|
summary: "Move everything the project depends on to its newest published state",
|
|
98
|
-
description: "In dependency order: the desktop host (`desktop update`), matc (`shaders update`), the project's shader compiles, the IDE types (.lecodes/types/ + tsconfig.json, from the server — the former `lecodes types`), the ios/android shell pins (`app update`), then
|
|
143
|
+
description: "In dependency order: this CLI first (when npm has a newer one it is installed with the package manager the CLI came by — `npm install -g`, or `bun add -g` for a CLI installed with bun — and the rest of the run is the new version's), the desktop host (`desktop update`), matc (`shaders update`), the project's shader compiles, the IDE types (.lecodes/types/ + tsconfig.json, from the server — the former `lecodes types`), the ios/android shell pins (`app update`), then every installed optional peer (lecodes-headless, lecodes-design, lecodes-3d-editor, lecodes-assets, …) that is behind what this CLI installs: its own version for the renderer, the newest version in its range for the rest.\n\nPinned tools are skipped, not overridden: LECODES_DESKTOP_EXE / LECODES_MATC mean a local build. Keeps going when a step fails; the summary says which.",
|
|
99
144
|
flags: {
|
|
100
145
|
local: bool("types from the SDK bundled in this CLI"),
|
|
101
146
|
"no-shells": bool("leave ios/android pins alone (no re-sync)"),
|
|
102
|
-
check: bool("npm
|
|
103
|
-
"no-cli": bool("skip the npm
|
|
147
|
+
check: bool("npm steps: report what's behind, install nothing"),
|
|
148
|
+
"no-cli": bool("skip the npm steps — the CLI and its peers (offline)"),
|
|
149
|
+
yes: bool("answer yes to every install prompt (the global --yes / -y; LECODES_YES=1)"),
|
|
104
150
|
},
|
|
105
|
-
examples: ["lecodes update", "lecodes update --check", "lecodes update --local --no-shells"],
|
|
106
|
-
run: async ({ flags }) => {
|
|
151
|
+
examples: ["lecodes update", "lecodes update --check", "lecodes update --yes", "lecodes update --local --no-shells"],
|
|
152
|
+
run: async ({ flags, argv }) => {
|
|
107
153
|
const steps: Step[] = []
|
|
108
154
|
const heading = (name: string, what: string) => log(`\n${c.bold(name)} ${c.dim(what)}`)
|
|
109
155
|
const run = async (name: string, what: string, fn: () => Promise<Outcome | [Outcome, string]>) => {
|
|
@@ -122,35 +168,82 @@ export default defineCommand({
|
|
|
122
168
|
|
|
123
169
|
const root = findProjectDir(process.cwd())
|
|
124
170
|
|
|
125
|
-
// 1.
|
|
171
|
+
// 1. this CLI, first (see the header). LECODES_UPDATE_RESUMED is the hand-over: the CLI before this one
|
|
172
|
+
// installed it and ran this command again — "<from> → <to>", for the summary.
|
|
173
|
+
const resumed = process.env.LECODES_UPDATE_RESUMED
|
|
174
|
+
let handover = null as string | null // set inside the step's closure, read after it
|
|
175
|
+
if (resumed) {
|
|
176
|
+
delete process.env.LECODES_UPDATE_RESUMED
|
|
177
|
+
heading("cli", "lecodes-cli itself")
|
|
178
|
+
log(` lecodes-cli ${resumed}`)
|
|
179
|
+
steps.push({ name: "cli", outcome: "updated", detail: `lecodes-cli ${resumed}` })
|
|
180
|
+
} else {
|
|
181
|
+
await run("cli", "lecodes-cli itself — first, so every step after it is the new version's", async () => {
|
|
182
|
+
if (flags["no-cli"]) return ["skipped", "--no-cli"]
|
|
183
|
+
if (FROM_SOURCE) return ["skipped", "running from the repo checkout — the CLI and its peers are the repo's own"]
|
|
184
|
+
const latest = await npmLatest("lecodes-cli")
|
|
185
|
+
if (!latest) return ["skipped", "npm unreachable"]
|
|
186
|
+
if (semverNewer(latest, pkg.version) <= 0) { log(` lecodes-cli ${pkg.version} ${c.dim("(up to date)")}`); return "ok" }
|
|
187
|
+
log(` lecodes-cli ${pkg.version} ${c.yellow("→ " + latest)}`)
|
|
188
|
+
if (flags.check) return ["updated", `${pkg.version} → ${latest} behind — run: lecodes update (the peers below are compared with this CLI's ranges, not ${latest}'s)`]
|
|
189
|
+
if (STANDALONE) return ["skipped", `this is the standalone build — ${latest} comes as the release archive, not through npm`]
|
|
190
|
+
// with the manager this CLI was installed by: another one's global dir is another copy
|
|
191
|
+
const tool = ownManager()
|
|
192
|
+
const install = installGlobalArgv(tool, [`lecodes-cli@${latest}`])
|
|
193
|
+
log(" " + c.dim("$") + " " + commandLine(tool, install))
|
|
194
|
+
const r = spawnSync(tool, install, { stdio: "inherit", shell: true })
|
|
195
|
+
if (r.error) throw new CliError(tool + " could not run: " + r.error.message)
|
|
196
|
+
if (r.status !== 0) throw new CliError(commandLine(tool, install.slice(0, 2)) + " exited with " + (r.status ?? "a signal"))
|
|
197
|
+
// The new CLI is the same entry file now — unless the install landed in another copy than the one
|
|
198
|
+
// running (another prefix, another manager): then the rest stays this version's, and says so.
|
|
199
|
+
const now = cliVersionAt(process.argv[1] ?? "")
|
|
200
|
+
if (now !== latest) {
|
|
201
|
+
note(`the install put ${latest} where this CLI does not run from (${process.argv[1]} is ${now ?? "unreadable"}) — the rest of this run is ${pkg.version}'s; run \`lecodes update\` again`)
|
|
202
|
+
return ["updated", `${pkg.version} → ${latest} (another copy — run lecodes update again)`]
|
|
203
|
+
}
|
|
204
|
+
handover = `${pkg.version} → ${latest}`
|
|
205
|
+
return ["updated", handover]
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
if (handover) {
|
|
209
|
+
// the rest of the run is the new version's: the same command, the same flags, from the same entry file —
|
|
210
|
+
// never `lecodes` off PATH, which may name another install
|
|
211
|
+
log(c.dim(`\n the rest of this update runs in lecodes-cli ${handover.split(" → ")[1]}`))
|
|
212
|
+
const r = spawnSync(process.execPath, [process.argv[1]!, "update", ...argv], { stdio: "inherit", env: { ...process.env, LECODES_UPDATE_RESUMED: handover } })
|
|
213
|
+
if (r.error || r.status === null) throw new CliError(`the new lecodes-cli could not run: ${r.error?.message ?? "a signal"} — run \`lecodes update\` again`)
|
|
214
|
+
process.exitCode = r.status
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 2. desktop host
|
|
126
219
|
await run("desktop", "LeCodes Desktop host (desktop run / render --desktop)", async () => {
|
|
127
220
|
if (process.env.LECODES_DESKTOP_EXE) return ["skipped", `LECODES_DESKTOP_EXE=${process.env.LECODES_DESKTOP_EXE} (a local build; \`lecodes desktop update\` refreshes the cache anyway)`]
|
|
128
221
|
await updateDesktop()
|
|
129
222
|
return "ok"
|
|
130
223
|
})
|
|
131
224
|
|
|
132
|
-
//
|
|
225
|
+
// 3. matc
|
|
133
226
|
await run("matc", "Filament material compiler for local shader compiles", async () => {
|
|
134
227
|
if (process.env.LECODES_MATC) return ["skipped", `LECODES_MATC=${process.env.LECODES_MATC} (a local matc)`]
|
|
135
228
|
await updateMatc()
|
|
136
229
|
return "ok"
|
|
137
230
|
})
|
|
138
231
|
|
|
139
|
-
//
|
|
232
|
+
// 4. shaders (project)
|
|
140
233
|
await run("shaders", "recompile the project's .mat shaders with the current matc", async () => {
|
|
141
234
|
if (!root) return ["skipped", "not inside a project"]
|
|
142
235
|
await compileShaders()
|
|
143
236
|
return "ok"
|
|
144
237
|
})
|
|
145
238
|
|
|
146
|
-
//
|
|
239
|
+
// 5. types (project)
|
|
147
240
|
await run("types", flags.local ? "IDE types from the SDK bundled in this CLI" : "IDE types from the server", async () => {
|
|
148
241
|
if (!root) return ["skipped", "not inside a project"]
|
|
149
242
|
await refreshTypes({ local: flags.local, check: false })
|
|
150
243
|
return "ok"
|
|
151
244
|
})
|
|
152
245
|
|
|
153
|
-
//
|
|
246
|
+
// 6. native shells (project)
|
|
154
247
|
await run("shells", "ios/ + android/ pins → newest SDK release + plugin tags, then re-sync", async () => {
|
|
155
248
|
if (!root) return ["skipped", "not inside a project"]
|
|
156
249
|
if (flags["no-shells"]) return ["skipped", "--no-shells"]
|
|
@@ -160,37 +253,42 @@ export default defineCommand({
|
|
|
160
253
|
return "ok"
|
|
161
254
|
})
|
|
162
255
|
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
|
|
256
|
+
// 7. the optional peers — the CLI's prefix (~/.lecodes/peers, filled on first use) or global installs.
|
|
257
|
+
// Each installed one is compared with what an install would PUT where it lives, never with npm's latest
|
|
258
|
+
// alone: in the prefix that is peerInstall's own spec (the renderer at THIS CLI's version — compiler and
|
|
259
|
+
// headless host are one ABI set —, an editor's archive, an npm peer's newest release in its range), so the
|
|
260
|
+
// report and the install cannot disagree; a global install (`npm install -g`, the old way) moves to npm's
|
|
261
|
+
// latest, as it was put there. Peers that are NOT installed stay that way: optional, installing one is a choice.
|
|
262
|
+
await run("peers", "the CLI's optional peers (~/.lecodes/peers, or global installs)", async () => {
|
|
169
263
|
if (flags["no-cli"]) return ["skipped", "--no-cli"]
|
|
170
264
|
if (FROM_SOURCE) return ["skipped", "running from the repo checkout — the CLI and its peers are the repo's own"]
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
// npm's latest, because compiler and headless host are one ABI set; a global install
|
|
174
|
-
// (`npm install -g`, the old way) moves through npm like before.
|
|
175
|
-
const rows: { name: string, installed: string | null, latest: string | null, where: "global" | "prefix" | null }[] = []
|
|
265
|
+
type Row = { name: string, installed: string | null, target: string | null, latest: string | null, range: string | null, where: "global" | "prefix" | null }
|
|
266
|
+
const rows: Row[] = []
|
|
176
267
|
for (const [name, probe] of OPTIONAL_PEERS) {
|
|
177
268
|
const where = peerSource(name, probe)
|
|
178
269
|
const installed = peerVersion(name, probe)
|
|
179
|
-
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
rows.push({ name, installed, latest, where })
|
|
270
|
+
if (!installed) { rows.push({ name, installed, target: null, latest: null, range: null, where }); continue }
|
|
271
|
+
// an archived peer (the headless host, the editors) has no "latest" of a registry: the version this CLI
|
|
272
|
+
// installs is the one to have
|
|
273
|
+
const archived = await archivedVersion(name)
|
|
274
|
+
if (archived) { rows.push({ name, installed, target: archived, latest: archived, range: null, where }); continue }
|
|
275
|
+
const latest = await npmLatest(name)
|
|
276
|
+
if (where !== "prefix") { rows.push({ name, installed, target: latest, latest, range: null, where }); continue }
|
|
277
|
+
const spec = await peerSpec(name) // `name@range` — what installPeers below would install
|
|
278
|
+
const range = spec.lastIndexOf("@") > 0 ? spec.slice(spec.lastIndexOf("@") + 1) : "latest"
|
|
279
|
+
rows.push({ name, installed, target: await npmNewestIn(name, range) ?? latest, latest, range, where })
|
|
184
280
|
}
|
|
185
|
-
|
|
186
|
-
if (
|
|
187
|
-
const behind =
|
|
281
|
+
const known = rows.filter((r) => r.installed)
|
|
282
|
+
if (known.length > 0 && known.every((r) => r.target === null)) return ["skipped", "npm unreachable"]
|
|
283
|
+
const behind = known.filter((r) => r.target && semverNewer(r.target, r.installed!) > 0)
|
|
188
284
|
for (const r of rows) {
|
|
189
285
|
const state = !r.installed ? c.dim("not installed (optional — installed on first use)")
|
|
190
|
-
: !r.
|
|
191
|
-
: behind.includes(r) ? c.yellow("→ " + r.
|
|
286
|
+
: !r.target ? c.dim("(npm: unknown)")
|
|
287
|
+
: behind.includes(r) ? c.yellow("→ " + r.target)
|
|
192
288
|
: c.dim("(up to date)")
|
|
193
|
-
|
|
289
|
+
// a newer release out of the range this CLI installs: current for this CLI — said, so it is no mystery
|
|
290
|
+
const outside = r.range && r.latest && r.target && semverNewer(r.latest, r.target) > 0 ? c.dim(` ${r.latest} is out, outside ${r.range}`) : ""
|
|
291
|
+
log(" " + r.name.padEnd(18) + " " + (r.installed ?? "").padEnd(8) + " " + (r.where === "prefix" ? c.dim("peers ") : "") + state + outside)
|
|
194
292
|
}
|
|
195
293
|
if (behind.length === 0) return "ok"
|
|
196
294
|
const prefixBehind = behind.filter((r) => r.where === "prefix")
|
|
@@ -198,28 +296,20 @@ export default defineCommand({
|
|
|
198
296
|
if (flags.check) {
|
|
199
297
|
return ["updated", behind.length + " behind — run: lecodes update" + (globalSpecs.length ? " (global: " + commandLine(ownManager(), installGlobalArgv(ownManager(), globalSpecs)) + ")" : "")]
|
|
200
298
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if (STANDALONE && globalSpecs.some((s) => s.startsWith("lecodes-cli@"))) {
|
|
207
|
-
note("this is the standalone build — the CLI updates by re-installing the release archive, not through npm; peers still go through npm")
|
|
208
|
-
}
|
|
209
|
-
const toInstall = STANDALONE ? globalSpecs.filter((s) => !s.startsWith("lecodes-cli@")) : globalSpecs
|
|
210
|
-
if (toInstall.length) {
|
|
299
|
+
// an npm peer at the exact version reported (the range alone leaves a locked one where it is); an archive's
|
|
300
|
+
// spec is its versioned url already
|
|
301
|
+
const at = Object.fromEntries(prefixBehind.filter((r) => r.range !== null).map((r) => [ r.name, r.target! ]))
|
|
302
|
+
if (prefixBehind.length) await installPeers(prefixBehind.map((r) => r.name), "lecodes update", { yes: true, at })
|
|
303
|
+
if (globalSpecs.length) {
|
|
211
304
|
// with the manager this CLI was installed by: another one's global dir is another copy
|
|
212
305
|
const tool = ownManager()
|
|
213
|
-
const
|
|
214
|
-
log(" " + c.dim("$") + " " + commandLine(tool,
|
|
215
|
-
const r = spawnSync(tool,
|
|
306
|
+
const install = installGlobalArgv(tool, globalSpecs)
|
|
307
|
+
log(" " + c.dim("$") + " " + commandLine(tool, install))
|
|
308
|
+
const r = spawnSync(tool, install, { stdio: "inherit", shell: true })
|
|
216
309
|
if (r.error) throw new CliError(tool + " could not run: " + r.error.message)
|
|
217
|
-
if (r.status !== 0) throw new CliError(commandLine(tool,
|
|
218
|
-
done.push(...behind.filter((r) => r.where !== "prefix" && (!STANDALONE || r.name !== "lecodes-cli")).map((r) => r.name))
|
|
310
|
+
if (r.status !== 0) throw new CliError(commandLine(tool, install.slice(0, 2)) + " exited with " + (r.status ?? "a signal"))
|
|
219
311
|
}
|
|
220
|
-
|
|
221
|
-
const cliToo = done.includes("lecodes-cli")
|
|
222
|
-
return ["updated", done.join(", ") + " → latest" + (cliToo ? " (the new CLI runs from your next command)" : "")]
|
|
312
|
+
return ["updated", behind.map((r) => `${r.name} ${r.installed} → ${r.target}`).join(", ")]
|
|
223
313
|
})
|
|
224
314
|
|
|
225
315
|
// Summary — one line per step so a scrolled-away failure can't hide.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readAppEnv } from "../project/env"
|
|
1
2
|
import { readFileSync } from "node:fs"
|
|
2
3
|
import type { Manifest } from "../project/manifest"
|
|
3
4
|
import { scanLocal } from "../project/localFiles"
|
|
@@ -95,6 +96,7 @@ export const compileDesignScreen = async (opts: {
|
|
|
95
96
|
return compileProject({
|
|
96
97
|
entries,
|
|
97
98
|
name: opts.name,
|
|
99
|
+
env: readAppEnv(root),
|
|
98
100
|
publicUrl: opts.assets === "disk" ? LOCAL_ASSET_SCHEME : "/project",
|
|
99
101
|
entryOverride: entryPath,
|
|
100
102
|
header: false,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readAppEnv } from "../project/env"
|
|
1
2
|
import { getProject } from "../platform/api"
|
|
2
3
|
import { loadConfig, requireApiUrl, requireToken } from "../platform/config"
|
|
3
4
|
import { findProjectRoot, readManifest, type Manifest } from "../project/manifest"
|
|
@@ -123,6 +124,7 @@ export const compileHeadlessBundle = async (opts: HeadlessOptions = {}): Promise
|
|
|
123
124
|
name,
|
|
124
125
|
publicUrl,
|
|
125
126
|
serverUrl,
|
|
127
|
+
env: readAppEnv(root),
|
|
126
128
|
entryOverride: entryFlag,
|
|
127
129
|
header: false,
|
|
128
130
|
format: "iife",
|
|
@@ -7,7 +7,7 @@ import { installPeers } from "../hosts/peerInstall"
|
|
|
7
7
|
import { peerDirInPrefix } from "../hosts/peers"
|
|
8
8
|
import {
|
|
9
9
|
compileProjectWithMap as sdkCompileProjectWithMap, compileServerBundle, detectEntry, inlineSourceMapComment, isServerPath, scanModulesChisel,
|
|
10
|
-
localAssetNames, ENTRY_CONVENTION, type BundleBake, type CompileServerOptions,
|
|
10
|
+
localAssetNames, ENTRY_CONVENTION, findEnvLeaks, EnvLeakError, type BundleBake, type CompileServerOptions,
|
|
11
11
|
} from "lecodes-sdk/compile"
|
|
12
12
|
|
|
13
13
|
// Every project compiles through the SDK's one import-free compiler, which bundles with chisel (the
|
|
@@ -95,6 +95,10 @@ export type CompileOptions = {
|
|
|
95
95
|
/** Base URL of the project's backend, baked into the stubs of its `*.server.ts` modules; absent =
|
|
96
96
|
* every call to the server rejects (status 0). */
|
|
97
97
|
serverUrl?: string
|
|
98
|
+
/** The project's keys (`readAppEnv(root)` — its .env): the `PUBLIC_*` ones are baked in as `env.PUBLIC_*`,
|
|
99
|
+
* and the bundle is searched for every other value — a secret pasted into app code fails the compile
|
|
100
|
+
* (`EnvLeakError`). Absent = no keys. */
|
|
101
|
+
env?: Record<string, string>
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
/** A warning when auto-detection has several import-graph roots and no main.ts to settle it —
|
|
@@ -136,7 +140,14 @@ export const compileProjectBundle = async (opts: CompileOptions): Promise<{ js:
|
|
|
136
140
|
editor: opts.editor,
|
|
137
141
|
onWarning: opts.onWarning,
|
|
138
142
|
serverUrl: opts.serverUrl,
|
|
143
|
+
env: opts.env,
|
|
139
144
|
})
|
|
145
|
+
// a secret's VALUE in the bundle — pasted into app code or an asset — is what `env` cannot prevent, and this is
|
|
146
|
+
// where the values are known (the project's .env)
|
|
147
|
+
if (opts.env) {
|
|
148
|
+
const leaked = findEnvLeaks(code, opts.env)
|
|
149
|
+
if (leaked.length) throw new EnvLeakError(leaked, "the app bundle")
|
|
150
|
+
}
|
|
140
151
|
return { js: map ? `${code}\n${inlineSourceMapComment(map)}` : code, bakes }
|
|
141
152
|
}
|
|
142
153
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readAppEnv } from "../project/env"
|
|
1
2
|
import { createHash } from "node:crypto"
|
|
2
3
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
3
4
|
import { join } from "node:path"
|
|
@@ -93,7 +94,7 @@ export const compileSceneEditor = async (opts: {
|
|
|
93
94
|
try {
|
|
94
95
|
const js = await compileProject({
|
|
95
96
|
entries, name: opts.name, publicUrl: "", entryOverride: SCENE_ENTRY_PATH,
|
|
96
|
-
header: false, format: "iife", minify: false, editor: true,
|
|
97
|
+
header: false, format: "iife", minify: false, editor: true, env: readAppEnv(root),
|
|
97
98
|
})
|
|
98
99
|
return { js }
|
|
99
100
|
} catch (e: any) {
|
package/src/dev/backendWorker.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { join } from "node:path"
|
|
|
5
5
|
import { format } from "node:util"
|
|
6
6
|
import {
|
|
7
7
|
createAuthHost, createFilesHost, createInvoker, createPushHost, createSubscribeGuard, describeServer, loadServerModules, runSeed,
|
|
8
|
-
setChannelPublisher, setDbTransport, type AuthHost, type Heic, type LoadedServer, type PushRelay, type Sharp,
|
|
8
|
+
setChannelPublisher, setDbTransport, setEnv, type AuthHost, type Heic, type LoadedServer, type PushRelay, type Sharp,
|
|
9
9
|
} from "lecodes-sdk/server"
|
|
10
10
|
import { loadPeer } from "../hosts/peerInstall"
|
|
11
11
|
import { ApiError, sendPush } from "../platform/api"
|
|
@@ -116,11 +116,16 @@ const load = async (m: Extract<ToWorker, { t: "load" }>) => {
|
|
|
116
116
|
const noDb = () => { throw new Error("db: the database is not open yet — query it inside an endpoint, not while the module loads") }
|
|
117
117
|
setDbTransport({ exec: (op) => (db ?? noDb()).exec(op), batch: (ops) => (db ?? noDb()).batch(ops) })
|
|
118
118
|
setChannelPublisher((ch, group, data) => post({ t: "publish", ch, group, data }))
|
|
119
|
+
// The project's keys (its .env), before any module-level code can read one
|
|
120
|
+
setEnv(m.env)
|
|
119
121
|
// The bundle is a plain script (chisel esm output with only side effects): registers globalThis.__lecodesServer
|
|
120
122
|
;(0, eval)(m.code)
|
|
121
123
|
const server: LoadedServer = loadServerModules()
|
|
122
124
|
const describe = describeServer(server)
|
|
123
125
|
const notes = describe.marci ? await openDb(m.dbDir, m.filesDir, describe.marci) : []
|
|
126
|
+
// the keys the code reads and .env does not have: said now, not at the first failed call
|
|
127
|
+
const missing = describe.env.filter(name => !(name in m.env))
|
|
128
|
+
if (missing.length) notes.push(`env: ${missing.join(", ")} not set — add ${missing.length === 1 ? "it" : "them"} to the project's .env (NAME=value); a call that reads one fails until then`)
|
|
124
129
|
// Sessions, passwords and email codes over the project's own db, as on the runner. A code that was
|
|
125
130
|
// asked for is printed here, and DEV_CODE signs any email in without one — the developer is the
|
|
126
131
|
// only user.
|
package/src/dev/devServer.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readAppEnv } from "../project/env"
|
|
1
2
|
import { createHash } from "node:crypto"
|
|
2
3
|
import { createReadStream, readFileSync, statSync } from "node:fs"
|
|
3
4
|
import { join } from "node:path"
|
|
@@ -199,7 +200,7 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
|
|
|
199
200
|
}
|
|
200
201
|
const onWarning = (w: string) => { warnings.push(w) }
|
|
201
202
|
const serverBundle = await backend.compile(entries)
|
|
202
|
-
const js = await compileProject({ entries, name, publicUrl: origin, entryOverride, header: true, fontsHeader, iconsHeader, onWarning, serverUrl: serverBundle ? serverUrl : undefined })
|
|
203
|
+
const js = await compileProject({ entries, name, publicUrl: origin, entryOverride, header: true, fontsHeader, iconsHeader, onWarning, serverUrl: serverBundle ? serverUrl : undefined, env: readAppEnv(root) })
|
|
203
204
|
// the page's own halves, before anything is told to reload: one that does not build fails the
|
|
204
205
|
// compile, the page keeps the last good ones
|
|
205
206
|
const halvesChanged = lite ? await buildHalves() : false
|
package/src/dev/localBackend.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process"
|
|
2
2
|
import { basename, dirname, join } from "node:path"
|
|
3
3
|
import { fileURLToPath } from "node:url"
|
|
4
|
-
import type
|
|
4
|
+
import { trustedCaEnv, type ChannelGroup, type InvokeResult, type RequestContext, type ServerDescription } from "lecodes-sdk/server"
|
|
5
5
|
import { LECODES_DIR } from "../project/manifest"
|
|
6
6
|
|
|
7
7
|
/*
|
|
@@ -25,7 +25,7 @@ export type LogLevel = "log" | "info" | "warn" | "error" | "debug"
|
|
|
25
25
|
type Ctx = Omit<RequestContext, "id">
|
|
26
26
|
|
|
27
27
|
export type ToWorker =
|
|
28
|
-
| { t: "load", code: string, dbDir: string, filesDir: string, root: string, push: PushSender | null }
|
|
28
|
+
| { t: "load", code: string, dbDir: string, filesDir: string, root: string, push: PushSender | null, env: Record<string, string> }
|
|
29
29
|
| { t: "invoke", req: number, id: string, args: unknown[], ctx: Ctx }
|
|
30
30
|
| { t: "subscribe", req: number, ch: string, args: unknown[], ctx: Ctx }
|
|
31
31
|
| { t: "stop" }
|
|
@@ -57,8 +57,9 @@ export type LocalBackendOptions = {
|
|
|
57
57
|
export type DeployResult = { changed: boolean, describe: ServerDescription, notes: string[] }
|
|
58
58
|
|
|
59
59
|
export type LocalBackend = {
|
|
60
|
-
/** Run this bundle from now on.
|
|
61
|
-
deploy
|
|
60
|
+
/** Run this bundle from now on, with these keys (`env.NAME` in the bundle — the project's .env).
|
|
61
|
+
* A changed key alone is a deploy too. Rejects with the reason when it does not load. */
|
|
62
|
+
deploy: (code: string, env: Record<string, string>) => Promise<DeployResult>
|
|
62
63
|
invoke: (id: string, args: unknown[], ctx: Ctx) => Promise<InvokeResult>
|
|
63
64
|
/** A channel subscription: its hooks → the subscriber's group (the envelope's `result`). */
|
|
64
65
|
subscribe: (ch: string, args: unknown[], ctx: Ctx) => Promise<InvokeResult>
|
|
@@ -92,8 +93,13 @@ export const startLocalBackend = (options: LocalBackendOptions): LocalBackend =>
|
|
|
92
93
|
const filesDir = options.filesDir ?? filesDirOf(dbDir)
|
|
93
94
|
|
|
94
95
|
let live: Live | null = null
|
|
95
|
-
/** The bundle that is deployed — what a crashed or timed-out process is started from again. */
|
|
96
|
+
/** The bundle that is deployed — what a crashed or timed-out process is started from again — and its keys. */
|
|
96
97
|
let code: string | null = null
|
|
98
|
+
let env: Record<string, string> = {}
|
|
99
|
+
const sameEnv = (a: Record<string, string>, b: Record<string, string>) => {
|
|
100
|
+
const ka = Object.keys(a).sort(), kb = Object.keys(b).sort()
|
|
101
|
+
return ka.length === kb.length && ka.every((k, i) => k === kb[i] && a[k] === b[k])
|
|
102
|
+
}
|
|
97
103
|
let starting: Promise<Live> | null = null
|
|
98
104
|
let chain: Promise<unknown> = Promise.resolve()
|
|
99
105
|
let nextReq = 1
|
|
@@ -107,9 +113,11 @@ export const startLocalBackend = (options: LocalBackendOptions): LocalBackend =>
|
|
|
107
113
|
l.pending.clear()
|
|
108
114
|
}
|
|
109
115
|
|
|
110
|
-
const start = (bundle: string): Promise<{ live: Live, notes: string[] }> => new Promise((resolve, reject) => {
|
|
116
|
+
const start = (bundle: string, keys: Record<string, string>): Promise<{ live: Live, notes: string[] }> => new Promise((resolve, reject) => {
|
|
111
117
|
const [exe, ...args] = selfCommand()
|
|
112
|
-
|
|
118
|
+
// the server's fetch trusts the Russian Trusted CA (Минцифры) too — GigaChat, Sber, Gosuslugi — the way the
|
|
119
|
+
// runner's does; Bun reads NODE_EXTRA_CA_CERTS at start, so it is the child's environment (sdk/server trustedCa.ts)
|
|
120
|
+
const child = spawn(exe!, [...args, BACKEND_WORKER_ARG], { cwd: options.root, stdio: ["ignore", "inherit", "inherit", "ipc"], env: { ...process.env, ...trustedCaEnv() } })
|
|
113
121
|
let exited!: () => void
|
|
114
122
|
const l: Live = { child, describe: null as unknown as ServerDescription, pending: new Map(), gone: new Promise<void>((r) => { exited = r }) }
|
|
115
123
|
let settled = false
|
|
@@ -152,7 +160,7 @@ export const startLocalBackend = (options: LocalBackendOptions): LocalBackend =>
|
|
|
152
160
|
if (!closed) log(`[server:error] the server process exited (${status ?? "killed"}) — it starts again with the next call`)
|
|
153
161
|
}
|
|
154
162
|
})
|
|
155
|
-
child.send({ t: "load", code: bundle, dbDir, filesDir, root: options.root, push: options.push ?? null } satisfies ToWorker)
|
|
163
|
+
child.send({ t: "load", code: bundle, dbDir, filesDir, root: options.root, push: options.push ?? null, env: keys } satisfies ToWorker)
|
|
156
164
|
})
|
|
157
165
|
|
|
158
166
|
const stop = async (l: Live) => {
|
|
@@ -169,7 +177,7 @@ export const startLocalBackend = (options: LocalBackendOptions): LocalBackend =>
|
|
|
169
177
|
const ensure = async (): Promise<Live | null> => {
|
|
170
178
|
await chain.catch(() => {})
|
|
171
179
|
if (live || code === null || closed) return live
|
|
172
|
-
starting ??= start(code).then((r) => { live = r.live; return r.live }).finally(() => { starting = null })
|
|
180
|
+
starting ??= start(code, env).then((r) => { live = r.live; return r.live }).finally(() => { starting = null })
|
|
173
181
|
try { return await starting } catch (e) { log(`[server:error] ${(e as Error).message}`); return null }
|
|
174
182
|
}
|
|
175
183
|
|
|
@@ -192,25 +200,26 @@ export const startLocalBackend = (options: LocalBackendOptions): LocalBackend =>
|
|
|
192
200
|
})
|
|
193
201
|
}
|
|
194
202
|
|
|
195
|
-
const deployNow = async (bundle: string): Promise<DeployResult> => {
|
|
196
|
-
if (bundle === code && live) return { changed: false, describe: live.describe, notes: [] }
|
|
197
|
-
const before = code
|
|
203
|
+
const deployNow = async (bundle: string, keys: Record<string, string>): Promise<DeployResult> => {
|
|
204
|
+
if (bundle === code && sameEnv(keys, env) && live) return { changed: false, describe: live.describe, notes: [] }
|
|
205
|
+
const before = code, beforeEnv = env
|
|
198
206
|
if (live) await stop(live)
|
|
199
207
|
try {
|
|
200
|
-
const next = await start(bundle)
|
|
208
|
+
const next = await start(bundle, keys)
|
|
201
209
|
live = next.live
|
|
202
210
|
code = bundle
|
|
211
|
+
env = keys
|
|
203
212
|
return { changed: true, describe: next.live.describe, notes: next.notes }
|
|
204
213
|
} catch (e) {
|
|
205
214
|
// the bundle that ran before answers the devices again (they still run its client)
|
|
206
|
-
if (before !== null) await start(before).then((r) => { live = r.live }, () => {})
|
|
215
|
+
if (before !== null) await start(before, beforeEnv).then((r) => { live = r.live }, () => {})
|
|
207
216
|
throw e
|
|
208
217
|
}
|
|
209
218
|
}
|
|
210
219
|
|
|
211
220
|
return {
|
|
212
|
-
deploy: (bundle) => {
|
|
213
|
-
const run = chain.catch(() => {}).then(() => deployNow(bundle))
|
|
221
|
+
deploy: (bundle, keys) => {
|
|
222
|
+
const run = chain.catch(() => {}).then(() => deployNow(bundle, keys))
|
|
214
223
|
chain = run
|
|
215
224
|
return run
|
|
216
225
|
},
|
|
@@ -4,6 +4,7 @@ import { createChannelHub, fileResponse, isMultipart, receiveUploads, UploadErro
|
|
|
4
4
|
import { compileServer, hasServerFiles, type CompileEntry } from "../compile/projectCompile"
|
|
5
5
|
import { loadPeer } from "../hosts/peerInstall"
|
|
6
6
|
import { LECODES_DIR, readManifest } from "../project/manifest"
|
|
7
|
+
import { readAppEnv } from "../project/env"
|
|
7
8
|
import { session } from "../commands/shared"
|
|
8
9
|
import { readAppConfigAt } from "../commands/app/shared"
|
|
9
10
|
import { filesDirOf, startLocalBackend, type LocalBackend } from "./localBackend"
|
|
@@ -104,13 +105,16 @@ export const createProjectBackend = (options: ProjectBackendOptions): ProjectBac
|
|
|
104
105
|
if (/\bimage\s*\(/.test(bundle.code)) await loadPeer("sharp", undefined, { for: `${options.command} (the project's t.image() fields)` })
|
|
105
106
|
}
|
|
106
107
|
backend ??= start()
|
|
107
|
-
|
|
108
|
+
// the project's keys come from its .env, read on every run: a changed key is a deploy by itself
|
|
109
|
+
const env = readAppEnv(root)
|
|
110
|
+
const deployed = await backend.deploy(bundle.code, env)
|
|
108
111
|
running = bundle.code
|
|
109
112
|
if (!deployed.changed) return []
|
|
110
113
|
const d = deployed.describe
|
|
111
114
|
const models = Object.keys(d.models).length
|
|
112
115
|
const count = (n: number, what: string) => `${n} ${what}${n === 1 ? "" : "s"}`
|
|
113
|
-
|
|
116
|
+
const keys = Object.keys(env).sort()
|
|
117
|
+
log(`server: ${count(d.endpoints.length, "endpoint")}, ${count(d.channels.length, "channel")}${models ? `, ${count(models, "model")} (database in ${relative(root, dbDir).replace(/\\/g, "/")})` : ""}${keys.length ? `, env ${keys.join(", ")} (.env)` : ""}`)
|
|
114
118
|
return deployed.notes.map((n) => `server: ${n}`)
|
|
115
119
|
},
|
|
116
120
|
handle: async (req, route, ip) => {
|
|
@@ -156,7 +160,7 @@ export const createProjectBackend = (options: ProjectBackendOptions): ProjectBac
|
|
|
156
160
|
rmSync(dbDir, { recursive: true, force: true })
|
|
157
161
|
rmSync(filesDir, { recursive: true, force: true })
|
|
158
162
|
backend = start()
|
|
159
|
-
await backend.deploy(running)
|
|
163
|
+
await backend.deploy(running, readAppEnv(root))
|
|
160
164
|
},
|
|
161
165
|
close: async () => {
|
|
162
166
|
for (const conn of [...sockets]) conn.close()
|
package/src/dev/watchProject.ts
CHANGED
|
@@ -21,6 +21,8 @@ export const watchProject = (root: string, onChange: () => void, log: { warn(mes
|
|
|
21
21
|
const counts = (file: string | null): boolean => {
|
|
22
22
|
if (file === null) return true // an event without a name: assume the project's content
|
|
23
23
|
const parts = file.replace(/\\/g, "/").split("/")
|
|
24
|
+
// the project's .env is never pushed (ignored), but the backend reads its keys: a save is a deploy
|
|
25
|
+
if (parts.length === 1 && parts[0] === ".env") return true
|
|
24
26
|
if (parts.some((p) => SKIP_DIRS.has(p))) return false
|
|
25
27
|
if (SKIP_FILES.has(parts[parts.length - 1]!)) return false
|
|
26
28
|
try {
|