lecodes-cli 0.20.1 → 1.0.0

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.
Files changed (107) hide show
  1. package/README.md +56 -57
  2. package/dist/index.js +5920 -5022
  3. package/package.json +9 -7
  4. package/runtime/materials/decal-relief.mat +12 -1
  5. package/runtime/materials/decal.mat +17 -3
  6. package/runtime/materials/lightmap-baked-lite.mat +6 -1
  7. package/runtime/materials/lightmap-baked.mat +6 -1
  8. package/runtime/materials/lightmap.mat +3 -0
  9. package/runtime/materials/particles-quad.mat +17 -9
  10. package/runtime/materials/particles.mat +17 -9
  11. package/runtime/scene-harness.json +1 -1
  12. package/runtime/sdk-types.json +1 -1
  13. package/runtime/web/assets/createViewerLite-DHqTFhWB.js +866 -0
  14. package/runtime/web/assets/{index-BTB5KV9x.js → index-Q06c6oHx.js} +1 -1
  15. package/runtime/web/embed.js +2 -2
  16. package/src/api.ts +1 -302
  17. package/src/cli/args.ts +37 -0
  18. package/src/cli/command.ts +113 -0
  19. package/src/cli/errors.ts +13 -0
  20. package/src/cli/help.ts +133 -0
  21. package/src/cli/index.ts +7 -0
  22. package/src/cli/output.ts +102 -0
  23. package/src/cli/run.ts +106 -0
  24. package/src/commands/{appAndroid.ts → app/android.ts} +451 -42
  25. package/src/commands/{appDesktop.ts → app/desktop.ts} +63 -32
  26. package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +24 -18
  27. package/src/commands/{appIcon.ts → app/icon.ts} +2 -2
  28. package/src/commands/{app.ts → app/index.ts} +397 -289
  29. package/src/commands/{appShared.ts → app/shared.ts} +30 -39
  30. package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +62 -28
  31. package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
  32. package/src/commands/{appTemplates.ts → app/templates/ios.ts} +1 -1
  33. package/src/commands/assets.ts +23 -21
  34. package/src/commands/clone.ts +48 -51
  35. package/src/commands/compile.ts +62 -88
  36. package/src/commands/create.ts +46 -47
  37. package/src/commands/design/comments.ts +361 -0
  38. package/src/commands/design/context.ts +101 -0
  39. package/src/commands/design/index.ts +391 -0
  40. package/src/commands/design/snapshot.ts +134 -0
  41. package/src/commands/{designTemplates.ts → design/templates.ts} +4 -3
  42. package/src/commands/desktop.ts +90 -82
  43. package/src/commands/dev.ts +201 -176
  44. package/src/commands/diff.ts +39 -51
  45. package/src/commands/index.ts +60 -0
  46. package/src/commands/{init.ts → init/index.ts} +199 -191
  47. package/src/commands/install.ts +80 -67
  48. package/src/commands/lightmap.ts +203 -171
  49. package/src/commands/link.ts +87 -94
  50. package/src/commands/login.ts +25 -23
  51. package/src/commands/navmesh.ts +168 -138
  52. package/src/commands/pn.ts +201 -244
  53. package/src/commands/pull.ts +49 -56
  54. package/src/commands/push.ts +48 -54
  55. package/src/commands/render.ts +158 -99
  56. package/src/commands/scene.ts +58 -55
  57. package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
  58. package/src/commands/{shadersNew.ts → shaders/new.ts} +2 -2
  59. package/src/commands/shared.ts +79 -0
  60. package/src/commands/status.ts +22 -26
  61. package/src/commands/test.ts +398 -371
  62. package/src/commands/thumbs.ts +185 -175
  63. package/src/commands/update/index.ts +226 -0
  64. package/src/commands/{types.ts → update/types.ts} +31 -22
  65. package/src/compile/collect.ts +5 -5
  66. package/src/compile/collectLocal.ts +3 -3
  67. package/src/compile/designCompile.ts +3 -3
  68. package/src/compile/headlessBundle.ts +133 -129
  69. package/src/compile/projectCompile.ts +2 -2
  70. package/src/compile/sceneCompile.ts +10 -8
  71. package/src/compile/screenEntry.ts +123 -127
  72. package/src/compile/shaders.ts +3 -3
  73. package/src/dev/androidDev.ts +1 -1
  74. package/src/dev/devServer.ts +3 -3
  75. package/src/dev/webRunner.ts +1 -1
  76. package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +1 -1
  77. package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +6 -5
  78. package/src/{desktopScript.ts → hosts/desktopScript.ts} +1 -1
  79. package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
  80. package/src/{matcTool.ts → hosts/matcTool.ts} +1 -1
  81. package/src/{peerInstall.ts → hosts/peerInstall.ts} +2 -2
  82. package/src/{releases.ts → hosts/releases.ts} +1 -1
  83. package/src/index.ts +32 -480
  84. package/src/platform/api.ts +302 -0
  85. package/src/{browserAuth.ts → platform/browserAuth.ts} +1 -1
  86. package/src/{config.ts → platform/config.ts} +1 -1
  87. package/src/{serverDiff.ts → platform/serverDiff.ts} +3 -3
  88. package/src/{projectEnv.ts → project/env.ts} +33 -2
  89. package/src/{localFiles.ts → project/localFiles.ts} +1 -1
  90. package/src/{manifest.ts → project/manifest.ts} +1 -1
  91. package/src/{project.ts → project/materialize.ts} +2 -2
  92. package/src/project/paths.ts +20 -0
  93. package/src/{textDiff.ts → project/textDiff.ts} +1 -1
  94. package/src/{types.ts → project/types.ts} +0 -0
  95. package/runtime/web/assets/createViewerLite-C_bkKXLg.js +0 -852
  96. package/src/commands/design.ts +0 -846
  97. package/src/commands/update.ts +0 -211
  98. package/src/util.ts +0 -146
  99. /package/src/commands/{projectTemplates.ts → init/templates.ts} +0 -0
  100. /package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +0 -0
  101. /package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +0 -0
  102. /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
  103. /package/src/{lecodes-renderer.d.ts → declarations/lecodes-renderer.d.ts} +0 -0
  104. /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
  105. /package/src/{peers.ts → hosts/peers.ts} +0 -0
  106. /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
  107. /package/src/{ignore.ts → project/ignore.ts} +0 -0
@@ -2,11 +2,11 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, s
2
2
  import { spawn, spawnSync } from "node:child_process"
3
3
  import { tmpdir } from "node:os"
4
4
  import { join, relative, resolve } from "node:path"
5
- import { findProjectRoot } from "../manifest"
6
5
  import { buildBundle } from "./compile"
7
- import { readAppConfigAt, resolveDesktopRenderer } from "./appShared"
8
- import { runDesktopRender } from "../desktopRenderer"
9
- import { CliError, c, flagStr, flagBool, logErr, note, warnErr, type Args } from "../util"
6
+ import { readAppConfigAt, resolveDesktopRenderer } from "./app/shared"
7
+ import { runDesktopRender } from "../hosts/desktopRenderer"
8
+ import { CliError, bool, c, defineCommand, logErr, note, num, str, warnErr } from "../cli"
9
+ import { bundleFlags, bundleOptions, localProject } from "./shared"
10
10
 
11
11
  /*
12
12
  * `lecodes lightmap bake` — bake static shadows, ambient occlusion and the level's point lights for
@@ -33,12 +33,11 @@ import { CliError, c, flagStr, flagBool, logErr, note, warnErr, type Args } from
33
33
  * The props need lightmap UVs first: `lecodes assets doctor assets/props/*.glb --lightmap-uv`.
34
34
  */
35
35
 
36
- const num = (args: Args, key: string, fallback: number, min: number, max: number): number => {
37
- const raw = flagStr(args, key)
38
- if (raw === undefined) return fallback
39
- const n = Number(raw)
40
- if (!Number.isFinite(n) || n < min || n > max) throw new CliError(`--${key} wants a number in ${min}..${max}, got "${raw}".`)
41
- return n
36
+ /** A numeric flag the bake hands to the tracer: the framework parses it, the range is checked here
37
+ * (a texel size of 0 or 10 000 sun rays is a mistake, not a setting). */
38
+ const range = (name: string, value: number, min: number, max: number): number => {
39
+ if (value < min || value > max) throw new CliError(`--${name} wants a number in ${min}..${max}, got "${value}".`)
40
+ return value
42
41
  }
43
42
 
44
43
  // ---- toktx (KTX-Software) — same lookup as lecodes-assets' doctor --------------------------------
@@ -92,162 +91,6 @@ const encodeKtx2 = (toktx: string, png: string, ktx2: string): Promise<void> =>
92
91
  const pageFile = (stem: string, page: number, light: boolean, ext = ".ktx2"): string =>
93
92
  `${stem}${light ? "-light" : ""}${page > 0 ? `_${page}` : ""}${ext}`
94
93
 
95
- const bake = async (args: Args): Promise<void> => {
96
- let root: string
97
- try { root = findProjectRoot(process.cwd()) } catch { root = process.cwd() }
98
- const toktx = requireToktx()
99
- const outDir = resolve(root, flagStr(args, "out") ?? "assets/lightmap")
100
- const stem = flagStr(args, "name") ?? "lightmap"
101
- if (!/^[A-Za-z0-9_-]+$/.test(stem)) throw new CliError(`--name wants a plain file stem (letters, digits, - and _), got "${stem}".`)
102
- const entryFlag = flagStr(args, "entry")
103
- const entryFile = entryFlag ?? "main.ts"
104
- if (!existsSync(resolve(root, entryFile))) {
105
- throw new CliError(`No ${entryFile} in ${root} — pass --entry <file> (the file that builds the scene and calls Lightmap.load).`)
106
- }
107
- const cfg = {
108
- outDir: outDir.replace(/\\/g, "/"),
109
- size: num(args, "size", 4096, 256, 16384),
110
- texel: num(args, "texel", 0.02, 0.001, 2),
111
- // atlas PAGES: the bake fills up to this many atlases of --size at the requested texel before it
112
- // coarsens; every page is one more texture the scene lists under env.lightmap.texture
113
- pages: num(args, "pages", 4, 1, 16),
114
- sunRays: num(args, "sun-rays", 32, 1, 1024),
115
- aoRays: num(args, "ao-rays", 64, 1, 4096),
116
- aoDistance: num(args, "ao-distance", 2, 0.01, 1000),
117
- bias: num(args, "bias", 0.02, 0.0001, 1),
118
- sunAngle: num(args, "sun-angle", 0.5, 0.01, 30),
119
- // point lights: shadow rays per light per texel, toward a disc of this radius at the light
120
- lightRays: num(args, "light-rays", 16, 1, 256),
121
- lightRadius: num(args, "light-radius", 0.1, 0.001, 5),
122
- // One bounce of indirect from the lamps, gathered on the AO rays (the albedo it assumes for
123
- // every surface it hits). Off by default: it is the difference between "a room lit by its
124
- // lamps" and "lit fixtures in a black room", but it costs a full Intersect per AO ray.
125
- bounce: num(args, "bounce", 0, 0, 1),
126
- bounceDistance: num(args, "bounce-distance", 8, 0.5, 100),
127
- bounceRays: num(args, "bounce-rays", 1, 1, 32),
128
- coplanarSkip: num(args, "coplanar-skip", 0.05, 0, 1),
129
- // geometry-aware denoise of the traced texels (radius in px; 0 = off)
130
- denoise: num(args, "denoise", 2, 0, 4),
131
- // the light volume for dynamic objects (docs/lightmap-plan.md §9); 0 = skip it
132
- volumeCell: flagBool(args, "no-volume") ? 0 : num(args, "cell", 0.5, 0.05, 20),
133
- }
134
- const frames = num(args, "frames", 900, 30, 100000)
135
- const win = readAppConfigAt(root)?.desktop?.window
136
- const width = Number(flagStr(args, "width")) || win?.width || 1280
137
- const height = Number(flagStr(args, "height")) || win?.height || 720
138
- const logs = flagBool(args, "logs")
139
-
140
- // The synthetic entry: bake mode flag first, then the real entry. It lives at the project root so
141
- // the real entry's own relative imports resolve unchanged.
142
- const importPath = "./" + entryFile.replace(/\\/g, "/").replace(/\.tsx?$/, "")
143
- const entryPath = "/.lightmap-entry.ts"
144
- const entryText = `globalThis.__lecodesLightmap = ${JSON.stringify(cfg)}\nimport ${JSON.stringify(importPath)}\n`
145
-
146
- // The bake files must exist BEFORE the compile: the scene references them through asset(), and
147
- // a missing asset is a broken bundle (chisel emits the constant after its use — a TDZ crash at
148
- // boot, seen 2026-08-26). The first bake starts from placeholders the real files overwrite;
149
- // Lightmap.load on a placeholder applies nothing and says "rebake".
150
- mkdirSync(outDir, { recursive: true })
151
- ensurePlaceholders(outDir, stem)
152
-
153
- const desktopRenderer = resolveDesktopRenderer(root, args)
154
- const { js, resources } = await buildBundle(args, undefined, {
155
- localAssets: true, desktopRenderer,
156
- extraEntries: [{ path: entryPath, type: "text", text: entryText }],
157
- entryOverride: entryPath,
158
- })
159
-
160
- // The host writes the bake itself; the PNG the render driver insists on goes to a scratch dir.
161
- const scratch = mkdtempSync(join(tmpdir(), "lecodes-lightmap-"))
162
- const outPng = join(scratch, "frame.png")
163
- note(`Baking up to ${cfg.pages} × ${cfg.size}² at ${(cfg.texel * 100).toFixed(1)} cm texels (${cfg.sunRays} sun + ${cfg.aoRays} AO + ${cfg.lightRays} light rays)${cfg.volumeCell ? ` + a ${cfg.volumeCell} m light volume` : ""} → ${relative(root, outDir) || "."}/${stem}.*…`)
164
- let output = ""
165
- try {
166
- // A bake is not interactive and a large level traces for a long time: no timeout unless asked
167
- // (the host prints its progress once a second — a silent host is the thing to watch, not the clock).
168
- const timeoutMs = Number(flagStr(args, "timeout")) || 24 * 3600 * 1000
169
- const res = await runDesktopRender(js, resources, {
170
- renderer: desktopRenderer, width, height, frames, outPng, logs, timeoutMs,
171
- // the bake is synchronous inside Lightmap.load — once the marker is out there is nothing to wait for
172
- stopOnLine: (chunk) => chunk.includes("[lightmap] done") || chunk.includes("[lightmap] error"),
173
- // no script: the frame cap ends the run once Lightmap.load has fired (the bake is synchronous)
174
- })
175
- output = res.output
176
- } catch (e) {
177
- const msg = e instanceof Error ? e.message : String(e)
178
- // A run that baked and then hit the cap without a PNG is still a success — check the markers.
179
- if (!/\[lightmap\] done/.test(msg)) throw e
180
- output = msg
181
- } finally {
182
- try { rmSync(scratch, { recursive: true, force: true }) } catch { /* a killed host may hold the dir a moment longer */ }
183
- }
184
-
185
- const lines = output.split(/\r?\n/).filter((l) => l.startsWith("[lightmap]"))
186
- for (const l of lines) {
187
- const text = l.slice("[lightmap]".length).trim()
188
- if (text === "done") continue
189
- logErr(text.startsWith("error") ? c.red(`✗ ${text}`) : text.startsWith("warning") ? c.yellow(` ${text}`) : c.dim(` ${text}`))
190
- }
191
- if (/\[lightmap\] done/.test(output)) {
192
- // the host writes `lightmap.*`; the stem is the CLI's, applied while the PNGs turn into KTX2
193
- const bakePath = join(outDir, "lightmap.bake")
194
- if (!existsSync(bakePath)) throw new CliError(`The host reported a bake but ${bakePath} is missing.`)
195
- const data = JSON.parse(readFileSync(bakePath, "utf8")) as { pages?: number, textures?: string[], lightTextures?: string[], volume?: { file: string }, texture?: string }
196
- const pages = Math.max(1, data.pages ?? 1)
197
- const hasLight = !!data.lightTextures?.length
198
- const t0 = Date.now()
199
- let pngKb = 0, ktxKb = 0
200
- const encodePage = async (page: number, light: boolean): Promise<void> => {
201
- const png = join(outDir, pageFile("lightmap", page, light, ".png"))
202
- const ktx2 = join(outDir, pageFile(stem, page, light))
203
- if (!existsSync(png)) throw new CliError(`The host reported a bake but ${png} is missing.`)
204
- await encodeKtx2(toktx, png, ktx2)
205
- pngKb += statSync(png).size / 1024; ktxKb += statSync(ktx2).size / 1024
206
- unlinkSync(png)
207
- }
208
- const encodes: Promise<void>[] = []
209
- for (let p = 0; p < pages; p++) {
210
- encodes.push(encodePage(p, false))
211
- if (hasLight) encodes.push(encodePage(p, true))
212
- }
213
- await Promise.all(encodes)
214
- // a page the previous bake had and this one does not: keep the file a scene may still list, as a placeholder
215
- for (let p = pages; p < 16; p++) {
216
- for (const light of [ false, true ]) {
217
- const stale = join(outDir, pageFile(stem, p, light))
218
- if (existsSync(stale) && statSync(stale).size !== PLACEHOLDER_KTX2.length) writeFileSync(stale, PLACEHOLDER_KTX2)
219
- }
220
- }
221
- if (!hasLight) {
222
- // no point lights in the level: a light atlas the scene may still reference goes back to a placeholder
223
- const light = join(outDir, pageFile(stem, 0, true))
224
- if (existsSync(light) && statSync(light).size !== PLACEHOLDER_KTX2.length) writeFileSync(light, PLACEHOLDER_KTX2)
225
- }
226
- // the .bake names the encoded files under the stem
227
- data.texture = pageFile(stem, 0, false)
228
- data.textures = Array.from({ length: pages }, (_, p) => pageFile(stem, p, false))
229
- if (hasLight) data.lightTextures = Array.from({ length: pages }, (_, p) => pageFile(stem, p, true))
230
- if (data.volume) data.volume.file = `${stem}.volume`
231
- writeFileSync(join(outDir, `${stem}.bake`), JSON.stringify(data, null, 1) + "\n")
232
- if (stem !== "lightmap") unlinkSync(bakePath)
233
- const volumeSrc = join(outDir, "lightmap.volume")
234
- const hasVolume = cfg.volumeCell > 0 && existsSync(volumeSrc)
235
- if (hasVolume && stem !== "lightmap") { rmSync(join(outDir, `${stem}.volume`), { force: true }); renameSync(volumeSrc, join(outDir, `${stem}.volume`)) }
236
- logErr(c.dim(` encoded ${pages} page(s)${hasLight ? " + the light atlas" : ""} (UASTC + mips, ${Math.round(ktxKb)} KB from ${Math.round(pngKb)} KB of rasters) in ${((Date.now() - t0) / 1000).toFixed(1)} s`))
237
- logErr(c.green(`✓ baked → ${relative(root, outDir) || "."}/${stem}.bake + ${data.textures.join(", ")}${hasLight ? ` + ${data.lightTextures!.join(", ")}` : ""}${hasVolume ? ` + ${stem}.volume` : ""}`))
238
- const rel = (f: string): string => relative(root, join(outDir, f)).replace(/\\/g, "/")
239
- const assets = (files: string[]): string => files.length === 1 ? `asset('<rel>/${rel(files[0])}')` : `[${files.map((f) => `asset('<rel>/${rel(f)}')`).join(", ")}]`
240
- logErr(c.dim(` scene file: env.lightmap: { data: asset('<rel>/${rel(`${stem}.bake`)}'), texture: ${assets(data.textures)}${hasLight ? `, light: ${assets(data.lightTextures!)}` : ""}${hasVolume ? `, volume: asset('<rel>/${rel(`${stem}.volume`)}')` : ""} } — asset() paths are relative to the scene file`))
241
- return
242
- }
243
- if (lines.some((l) => /no bake module/.test(l))) {
244
- throw new CliError("This desktop host has no bake module. Build packages/desktop (CREATOR_GL_LIGHTMAP is on there) and point LECODES_DESKTOP_EXE at build/lecodes-desktop.exe, or update: lecodes desktop update.")
245
- }
246
- if (lines.some((l) => /error/.test(l))) throw new CliError("The bake failed — see the lines above.")
247
- warnErr("The host ran to its frame cap without baking. Make sure the app calls Lightmap.load(...) after the static scene is built, and skips menus when Lightmap.baking is true (a title screen never reaches the scene). More load headroom: --frames <n>. Host output: --logs.")
248
- throw new CliError("Nothing was baked.")
249
- }
250
-
251
94
  /** A 1x1 white UASTC KTX2 (the atlas' "no shadow, no occlusion" value; toktx v4.4.2, sRGB) + an empty
252
95
  * bake — enough for the compiler to stage the files and for Lightmap.load to no-op with a "rebake"
253
96
  * warning. */
@@ -283,8 +126,197 @@ const ensurePlaceholders = (outDir: string, stem: string): void => {
283
126
  if (missing(volume)) writeFileSync(volume, placeholderVolume())
284
127
  }
285
128
 
286
- export const lightmap = async (args: Args): Promise<void> => {
287
- const sub = args._[0]
288
- if (sub === "bake") return bake({ ...args, _: args._.slice(1) })
289
- throw new CliError("Usage: lecodes lightmap bake [--out assets/lightmap] [--name lightmap] [--size 4096] [--pages 4] [--texel 0.02] [--sun-rays 32] [--ao-rays 64] [--ao-distance 2] [--bias 0.02] [--sun-angle 0.5] [--light-rays 16] [--light-radius 0.1] [--bounce 0..1] [--denoise 2] [--cell 0.5 | --no-volume] [--frames 900] [--timeout <ms>] [--entry main.ts] [--logs] (needs KTX-Software's toktx)")
290
- }
129
+ const bake = defineCommand({
130
+ name: "bake",
131
+ summary: "Bake static shadows + ambient occlusion (+ point lights) into a KTX2 lightmap atlas",
132
+ description: "Runs the app in the desktop host in bake mode: the statics are what the app registers with Lightmap.add, the bake fires at its Lightmap.load call. Writes <out>/<name>.bake + <name>[_n].ktx2 (+ <name>-light[_n].ktx2 for point lights, <name>.volume for movers). Needs KTX-Software's toktx on PATH (or KTX_TOKTX) and a desktop host with the bake module (LECODES_DESKTOP_EXE for a local build).\n\nProps need lightmap UVs first: lecodes assets doctor assets/props/*.glb --lightmap-uv",
133
+ flags: {
134
+ out: str("where the atlas lands", { value: "<dir>", default: "assets/lightmap" }),
135
+ name: str("file stem — one per baked level", { value: "<stem>", default: "lightmap" }),
136
+ ...bundleFlags,
137
+ entry: str("the file that builds the scene and calls Lightmap.load", { value: "<file>", default: "main.ts" }),
138
+ size: num("atlas edge (px)", { default: 4096 }),
139
+ texel: num("texel density (m per texel)", { default: 0.02 }),
140
+ pages: num("atlas pages to fill before the bake coarsens the texel", { default: 4 }),
141
+ "sun-rays": num("sun shadow rays per texel", { default: 32 }),
142
+ "ao-rays": num("ambient-occlusion rays per texel", { default: 64 }),
143
+ "ao-distance": num("AO reach (m)", { default: 2 }),
144
+ bias: num("ray start offset from the surface (m)", { default: 0.02 }),
145
+ "sun-angle": num("sun angular diameter (deg) — soft shadow edge", { default: 0.5 }),
146
+ "light-rays": num("point lights: shadow rays per light per texel", { default: 16 }),
147
+ "light-radius": num("point lights: source disc radius (m)", { default: 0.1 }),
148
+ bounce: num("one indirect bounce from the lamps, 0..1 = the albedo assumed for every surface (0 = off)", { default: 0 }),
149
+ "bounce-distance": num("(--bounce) gather reach (m)", { default: 8 }),
150
+ "bounce-rays": num("(--bounce) gather rays per AO ray", { default: 1 }),
151
+ "coplanar-skip": num("ignore hits closer than this fraction of the texel (self-shadowing on coplanar faces)", { default: 0.05 }),
152
+ denoise: num("geometry-aware denoise radius (px, 0 = off)", { default: 2 }),
153
+ cell: num("light-volume cell size for movers (m)", { default: 0.5 }),
154
+ "no-volume": bool("skip the light volume"),
155
+ frames: num("host frames to run before giving up — load headroom", { default: 900 }),
156
+ timeout: num("host time cap (ms; default: none)"),
157
+ width: num("host window width (default: app.json desktop.window, else 1280)"),
158
+ height: num("host window height (default: app.json desktop.window, else 720)"),
159
+ renderer: str("desktop host + shader variant (default: app.json desktop.renderer, else gl)", { value: "<gl|vulkan>" }),
160
+ logs: bool("forward the host's output"),
161
+ },
162
+ examples: ["lecodes lightmap bake", "lecodes lightmap bake --name yard --size 2048 --texel 0.05 --logs", "lecodes lightmap bake --bounce 0.5 --no-volume"],
163
+ run: async ({ flags }) => {
164
+ const { root } = localProject()
165
+ const toktx = requireToktx()
166
+ const outDir = resolve(root, flags.out)
167
+ const stem = flags.name
168
+ if (!/^[A-Za-z0-9_-]+$/.test(stem)) throw new CliError(`--name wants a plain file stem (letters, digits, - and _), got "${stem}".`)
169
+ const entryFile = flags.entry
170
+ if (!existsSync(resolve(root, entryFile))) {
171
+ throw new CliError(`No ${entryFile} in ${root} — pass --entry <file> (the file that builds the scene and calls Lightmap.load).`)
172
+ }
173
+ const cfg = {
174
+ outDir: outDir.replace(/\\/g, "/"),
175
+ size: range("size", flags.size, 256, 16384),
176
+ texel: range("texel", flags.texel, 0.001, 2),
177
+ // atlas PAGES: the bake fills up to this many atlases of --size at the requested texel before it
178
+ // coarsens; every page is one more texture the scene lists under env.lightmap.texture
179
+ pages: range("pages", flags.pages, 1, 16),
180
+ sunRays: range("sun-rays", flags["sun-rays"], 1, 1024),
181
+ aoRays: range("ao-rays", flags["ao-rays"], 1, 4096),
182
+ aoDistance: range("ao-distance", flags["ao-distance"], 0.01, 1000),
183
+ bias: range("bias", flags.bias, 0.0001, 1),
184
+ sunAngle: range("sun-angle", flags["sun-angle"], 0.01, 30),
185
+ // point lights: shadow rays per light per texel, toward a disc of this radius at the light
186
+ lightRays: range("light-rays", flags["light-rays"], 1, 256),
187
+ lightRadius: range("light-radius", flags["light-radius"], 0.001, 5),
188
+ // One bounce of indirect from the lamps, gathered on the AO rays (the albedo it assumes for
189
+ // every surface it hits). Off by default: it is the difference between "a room lit by its
190
+ // lamps" and "lit fixtures in a black room", but it costs a full Intersect per AO ray.
191
+ bounce: range("bounce", flags.bounce, 0, 1),
192
+ bounceDistance: range("bounce-distance", flags["bounce-distance"], 0.5, 100),
193
+ bounceRays: range("bounce-rays", flags["bounce-rays"], 1, 32),
194
+ coplanarSkip: range("coplanar-skip", flags["coplanar-skip"], 0, 1),
195
+ // geometry-aware denoise of the traced texels (radius in px; 0 = off)
196
+ denoise: range("denoise", flags.denoise, 0, 4),
197
+ // the light volume for dynamic objects (docs/lightmap-plan.md §9); 0 = skip it
198
+ volumeCell: flags["no-volume"] ? 0 : range("cell", flags.cell, 0.05, 20),
199
+ }
200
+ const frames = range("frames", flags.frames, 30, 100000)
201
+ const win = readAppConfigAt(root)?.desktop?.window
202
+ const width = flags.width || win?.width || 1280
203
+ const height = flags.height || win?.height || 720
204
+ const logs = flags.logs
205
+
206
+ // The synthetic entry: bake mode flag first, then the real entry. It lives at the project root so
207
+ // the real entry's own relative imports resolve unchanged.
208
+ const importPath = "./" + entryFile.replace(/\\/g, "/").replace(/\.tsx?$/, "")
209
+ const entryPath = "/.lightmap-entry.ts"
210
+ const entryText = `globalThis.__lecodesLightmap = ${JSON.stringify(cfg)}\nimport ${JSON.stringify(importPath)}\n`
211
+
212
+ // The bake files must exist BEFORE the compile: the scene references them through asset(), and
213
+ // a missing asset is a broken bundle (chisel emits the constant after its use — a TDZ crash at
214
+ // boot, seen 2026-08-26). The first bake starts from placeholders the real files overwrite;
215
+ // Lightmap.load on a placeholder applies nothing and says "rebake".
216
+ mkdirSync(outDir, { recursive: true })
217
+ ensurePlaceholders(outDir, stem)
218
+
219
+ const desktopRenderer = resolveDesktopRenderer(root, flags.renderer)
220
+ const { js, resources } = await buildBundle(bundleOptions(flags), {
221
+ localAssets: true, desktopRenderer,
222
+ extraEntries: [{ path: entryPath, type: "text", text: entryText }],
223
+ entryOverride: entryPath,
224
+ })
225
+
226
+ // The host writes the bake itself; the PNG the render driver insists on goes to a scratch dir.
227
+ const scratch = mkdtempSync(join(tmpdir(), "lecodes-lightmap-"))
228
+ const outPng = join(scratch, "frame.png")
229
+ note(`Baking up to ${cfg.pages} × ${cfg.size}² at ${(cfg.texel * 100).toFixed(1)} cm texels (${cfg.sunRays} sun + ${cfg.aoRays} AO + ${cfg.lightRays} light rays)${cfg.volumeCell ? ` + a ${cfg.volumeCell} m light volume` : ""} → ${relative(root, outDir) || "."}/${stem}.*…`)
230
+ let output = ""
231
+ try {
232
+ // A bake is not interactive and a large level traces for a long time: no timeout unless asked
233
+ // (the host prints its progress once a second — a silent host is the thing to watch, not the clock).
234
+ const timeoutMs = flags.timeout || 24 * 3600 * 1000
235
+ const res = await runDesktopRender(js, resources, {
236
+ renderer: desktopRenderer, width, height, frames, outPng, logs, timeoutMs,
237
+ // the bake is synchronous inside Lightmap.load — once the marker is out there is nothing to wait for
238
+ stopOnLine: (chunk) => chunk.includes("[lightmap] done") || chunk.includes("[lightmap] error"),
239
+ // no script: the frame cap ends the run once Lightmap.load has fired (the bake is synchronous)
240
+ })
241
+ output = res.output
242
+ } catch (e) {
243
+ const msg = e instanceof Error ? e.message : String(e)
244
+ // A run that baked and then hit the cap without a PNG is still a success — check the markers.
245
+ if (!/\[lightmap\] done/.test(msg)) throw e
246
+ output = msg
247
+ } finally {
248
+ try { rmSync(scratch, { recursive: true, force: true }) } catch { /* a killed host may hold the dir a moment longer */ }
249
+ }
250
+
251
+ const lines = output.split(/\r?\n/).filter((l) => l.startsWith("[lightmap]"))
252
+ for (const l of lines) {
253
+ const text = l.slice("[lightmap]".length).trim()
254
+ if (text === "done") continue
255
+ logErr(text.startsWith("error") ? c.red(`✗ ${text}`) : text.startsWith("warning") ? c.yellow(` ${text}`) : c.dim(` ${text}`))
256
+ }
257
+ if (/\[lightmap\] done/.test(output)) {
258
+ // the host writes `lightmap.*`; the stem is the CLI's, applied while the PNGs turn into KTX2
259
+ const bakePath = join(outDir, "lightmap.bake")
260
+ if (!existsSync(bakePath)) throw new CliError(`The host reported a bake but ${bakePath} is missing.`)
261
+ const data = JSON.parse(readFileSync(bakePath, "utf8")) as { pages?: number, textures?: string[], lightTextures?: string[], volume?: { file: string }, texture?: string }
262
+ const pages = Math.max(1, data.pages ?? 1)
263
+ const hasLight = !!data.lightTextures?.length
264
+ const t0 = Date.now()
265
+ let pngKb = 0, ktxKb = 0
266
+ const encodePage = async (page: number, light: boolean): Promise<void> => {
267
+ const png = join(outDir, pageFile("lightmap", page, light, ".png"))
268
+ const ktx2 = join(outDir, pageFile(stem, page, light))
269
+ if (!existsSync(png)) throw new CliError(`The host reported a bake but ${png} is missing.`)
270
+ await encodeKtx2(toktx, png, ktx2)
271
+ pngKb += statSync(png).size / 1024; ktxKb += statSync(ktx2).size / 1024
272
+ unlinkSync(png)
273
+ }
274
+ const encodes: Promise<void>[] = []
275
+ for (let p = 0; p < pages; p++) {
276
+ encodes.push(encodePage(p, false))
277
+ if (hasLight) encodes.push(encodePage(p, true))
278
+ }
279
+ await Promise.all(encodes)
280
+ // a page the previous bake had and this one does not: keep the file a scene may still list, as a placeholder
281
+ for (let p = pages; p < 16; p++) {
282
+ for (const light of [ false, true ]) {
283
+ const stale = join(outDir, pageFile(stem, p, light))
284
+ if (existsSync(stale) && statSync(stale).size !== PLACEHOLDER_KTX2.length) writeFileSync(stale, PLACEHOLDER_KTX2)
285
+ }
286
+ }
287
+ if (!hasLight) {
288
+ // no point lights in the level: a light atlas the scene may still reference goes back to a placeholder
289
+ const light = join(outDir, pageFile(stem, 0, true))
290
+ if (existsSync(light) && statSync(light).size !== PLACEHOLDER_KTX2.length) writeFileSync(light, PLACEHOLDER_KTX2)
291
+ }
292
+ // the .bake names the encoded files under the stem
293
+ data.texture = pageFile(stem, 0, false)
294
+ data.textures = Array.from({ length: pages }, (_, p) => pageFile(stem, p, false))
295
+ if (hasLight) data.lightTextures = Array.from({ length: pages }, (_, p) => pageFile(stem, p, true))
296
+ if (data.volume) data.volume.file = `${stem}.volume`
297
+ writeFileSync(join(outDir, `${stem}.bake`), JSON.stringify(data, null, 1) + "\n")
298
+ if (stem !== "lightmap") unlinkSync(bakePath)
299
+ const volumeSrc = join(outDir, "lightmap.volume")
300
+ const hasVolume = cfg.volumeCell > 0 && existsSync(volumeSrc)
301
+ if (hasVolume && stem !== "lightmap") { rmSync(join(outDir, `${stem}.volume`), { force: true }); renameSync(volumeSrc, join(outDir, `${stem}.volume`)) }
302
+ logErr(c.dim(` encoded ${pages} page(s)${hasLight ? " + the light atlas" : ""} (UASTC + mips, ${Math.round(ktxKb)} KB from ${Math.round(pngKb)} KB of rasters) in ${((Date.now() - t0) / 1000).toFixed(1)} s`))
303
+ logErr(c.green(`✓ baked → ${relative(root, outDir) || "."}/${stem}.bake + ${data.textures.join(", ")}${hasLight ? ` + ${data.lightTextures!.join(", ")}` : ""}${hasVolume ? ` + ${stem}.volume` : ""}`))
304
+ const rel = (f: string): string => relative(root, join(outDir, f)).replace(/\\/g, "/")
305
+ const assets = (files: string[]): string => files.length === 1 ? `asset('<rel>/${rel(files[0])}')` : `[${files.map((f) => `asset('<rel>/${rel(f)}')`).join(", ")}]`
306
+ logErr(c.dim(` scene file: env.lightmap: { data: asset('<rel>/${rel(`${stem}.bake`)}'), texture: ${assets(data.textures)}${hasLight ? `, light: ${assets(data.lightTextures!)}` : ""}${hasVolume ? `, volume: asset('<rel>/${rel(`${stem}.volume`)}')` : ""} } — asset() paths are relative to the scene file`))
307
+ return
308
+ }
309
+ if (lines.some((l) => /no bake module/.test(l))) {
310
+ throw new CliError("This desktop host has no bake module. Build packages/desktop (CREATOR_GL_LIGHTMAP is on there) and point LECODES_DESKTOP_EXE at build/lecodes-desktop.exe, or update: lecodes desktop update.")
311
+ }
312
+ if (lines.some((l) => /error/.test(l))) throw new CliError("The bake failed — see the lines above.")
313
+ warnErr("The host ran to its frame cap without baking. Make sure the app calls Lightmap.load(...) after the static scene is built, and skips menus when Lightmap.baking is true (a title screen never reaches the scene). More load headroom: --frames <n>. Host output: --logs.")
314
+ throw new CliError("Nothing was baked.")
315
+ },
316
+ })
317
+
318
+ export default defineCommand({
319
+ name: "lightmap",
320
+ summary: "Bake static shadows + ambient occlusion into a lightmap atlas (bake)",
321
+ commands: [bake],
322
+ })
@@ -1,16 +1,17 @@
1
1
  import { existsSync } from "node:fs"
2
2
  import { basename, join, resolve } from "node:path"
3
- import { createProject, getCommits, getProject, type Project, type ProjectAsset } from "../api"
4
- import { loadConfig, requireApiUrl, requireToken } from "../config"
5
- import { ensureGitignore, writeDefaultIgnore } from "../ignore"
6
- import { scanProject } from "../localFiles"
7
- import { findProjectRoot, LECODES_DIR, writeManifest, type FileKind, type ManifestFile, type Manifest } from "../manifest"
8
- import { buildPaths } from "../project"
9
- import { computeServerDiff } from "../serverDiff"
10
- import { materializeTypes, writeTsconfig } from "../types"
11
- import { CliError, c, confirm, flagBool, flagStr, info, log, note, sha256, success, warn, type Args } from "../util"
3
+ import { CliError, bool, c, confirm, defineCommand, info, log, note, str, success, warn } from "../cli"
4
+ import { createProject, getCommits, getProject, type Project, type ProjectAsset } from "../platform/api"
5
+ import { computeServerDiff } from "../platform/serverDiff"
6
+ import { ensureGitignore, writeDefaultIgnore } from "../project/ignore"
7
+ import { scanProject } from "../project/localFiles"
8
+ import { findProjectRoot, LECODES_DIR, writeManifest, type FileKind, type Manifest, type ManifestFile } from "../project/manifest"
9
+ import { buildPaths } from "../project/materialize"
10
+ import { sha256 } from "../project/paths"
11
+ import { materializeTypes, writeTsconfig } from "../project/types"
12
12
  import { parseUuid } from "./clone"
13
- import { push } from "./push"
13
+ import { pushFlags, runPush } from "./push"
14
+ import { session } from "./shared"
14
15
 
15
16
  /*
16
17
  * `lecodes link [dir]` — connect an existing local folder (typically one made by `lecodes init`) to
@@ -38,110 +39,102 @@ const manifestFromServer = (project: Project): Record<string, ManifestFile> => {
38
39
  const files: Record<string, ManifestFile> = {}
39
40
  for (const asset of project.assets) {
40
41
  if (asset.type === "folder") continue
41
- files[pathById.get(asset.id)!] = {
42
- id: asset.id,
43
- type: kindOf(asset.type),
44
- sha: asset.type === "file" ? "" : sha256(asset.text ?? ""),
45
- }
42
+ files[pathById.get(asset.id)!] = { id: asset.id, type: kindOf(asset.type), sha: asset.type === "file" ? "" : sha256(asset.text ?? "") }
46
43
  }
47
44
  return files
48
45
  }
49
46
 
50
47
  /** Show what a push from this folder would do to the project's server tree, and get a yes. */
51
- const confirmAdoption = async (root: string, project: Project, files: Record<string, ManifestFile>, args: Args): Promise<boolean> => {
48
+ const confirmAdoption = async (root: string, project: Project, files: Record<string, ManifestFile>, skipAsk: boolean): Promise<boolean> => {
52
49
  const count = Object.keys(files).length
53
- if (count === 0) {
54
- info(c.dim(`Project ${c.bold(project.name)} is empty on the server — nothing to overwrite.`))
55
- return true
56
- }
50
+ if (count === 0) { info(c.dim(`Project ${c.bold(project.name)} is empty on the server — nothing to overwrite.`)); return true }
57
51
 
58
52
  const scan = scanProject(root, { files } as Manifest)
59
53
  const diff = computeServerDiff(project, scan)
60
- if (diff.length === 0) {
61
- info(c.dim(`Project ${c.bold(project.name)} (${count} file(s)) already matches this folder.`))
62
- return true
63
- }
54
+ if (diff.length === 0) { info(c.dim(`Project ${c.bold(project.name)} (${count} file(s)) already matches this folder.`)); return true }
64
55
 
65
56
  warn(`${c.bold(project.name)} already has ${count} file(s). Your next push would change the server:`)
66
57
  const label: Record<string, string> = { added: c.green(" create "), removed: c.red(" delete "), modified: c.yellow(" update ") }
67
58
  for (const e of diff) log(label[e.status] + e.path + (e.binary ? c.dim(" (binary)") : ""))
68
59
  log("")
69
60
  note("`lecodes pull` after linking takes the server's version instead.")
70
-
71
- if (flagBool(args, "y", "yes") || flagBool(args, "force") || !process.stdin.isTTY) return true
61
+ if (skipAsk || !process.stdin.isTTY) return true
72
62
  return confirm("Link this folder to that project?", false)
73
63
  }
74
64
 
75
- export const link = async (args: Args) => {
76
- const config = loadConfig()
77
- const apiUrl = requireApiUrl(config)
78
- const token = requireToken(config)
79
-
80
- const dirArg = args._[0]
81
- const root = dirArg ? resolve(dirArg) : process.cwd()
82
- if (!existsSync(root)) throw new CliError(`No such directory: ${root}`)
83
-
84
- // Already linked? Re-linking silently would orphan the old project's history.
85
- if (existsSync(join(root, LECODES_DIR, "manifest.json")) && !flagBool(args, "force")) {
86
- throw new CliError("This folder is already linked (.lecodes/manifest.json exists) — use `lecodes push`, or `lecodes link --force` to re-link it to another project.")
87
- }
88
- // …or sitting inside a linked project, where a nested manifest would shadow the real root.
89
- let enclosing: string | null = null
90
- try { enclosing = findProjectRoot(root) } catch { /* not inside a project — the normal case */ }
91
- if (enclosing && enclosing !== root) {
92
- throw new CliError(`This folder is inside the linked project at ${enclosing} — push from there instead.`)
93
- }
94
-
95
- const projectRef = flagStr(args, "project")
96
- let uuid: string
97
- let name: string
98
- let files: Record<string, ManifestFile>
99
-
100
- if (projectRef) {
101
- uuid = parseUuid(projectRef)
102
- const project = await getProject(apiUrl, token, uuid)
103
- if (project.role === "viewer") {
104
- throw new CliError(`You have view-only access to ${project.name} — linking would give you a checkout you can't push.`)
65
+ export default defineCommand({
66
+ name: "link",
67
+ summary: "Connect an existing local folder to the platform (the reverse of clone)",
68
+ usage: "[dir]",
69
+ description: "Creates a project on the server, writes .lecodes/manifest.json and refreshes the types; nothing is uploaded until you push, so `status` shows what would go up first. `--project` adopts an EXISTING project instead: the manifest is built from its server tree and the command prints exactly what your next push would create / update / delete there before linking.",
70
+ flags: {
71
+ name: str("project name (default: the folder name)", { value: "<s>" }),
72
+ team: str("create it inside a team", { value: "<uuid>" }),
73
+ project: str("adopt an existing project instead of creating one", { value: "<uuid|url>" }),
74
+ push: bool("push right after linking (--publish to deploy it live in the same step)"),
75
+ "no-types": bool("skip the .d.ts / tsconfig refresh"),
76
+ ...pushFlags,
77
+ },
78
+ examples: ["lecodes link", "lecodes link --project <uuid> && lecodes status", 'lecodes link --push -m "initial"'],
79
+ run: async ({ args, flags }) => {
80
+ const { apiUrl, token } = session()
81
+ const dirArg = args[0]
82
+ const root = dirArg ? resolve(dirArg) : process.cwd()
83
+ if (!existsSync(root)) throw new CliError(`No such directory: ${root}`)
84
+
85
+ // Already linked? Re-linking silently would orphan the old project's history.
86
+ if (existsSync(join(root, LECODES_DIR, "manifest.json")) && !flags.force) {
87
+ throw new CliError("This folder is already linked (.lecodes/manifest.json exists) — use `lecodes push`, or `lecodes link --force` to re-link it to another project.")
88
+ }
89
+ // …or sitting inside a linked project, where a nested manifest would shadow the real root.
90
+ let enclosing: string | null = null
91
+ try { enclosing = findProjectRoot(root) } catch { /* not inside a project — the normal case */ }
92
+ if (enclosing && enclosing !== root) throw new CliError(`This folder is inside the linked project at ${enclosing} — push from there instead.`)
93
+
94
+ let uuid: string
95
+ let name: string
96
+ let files: Record<string, ManifestFile>
97
+ if (flags.project) {
98
+ uuid = parseUuid(flags.project)
99
+ const project = await getProject(apiUrl, token, uuid)
100
+ if (project.role === "viewer") throw new CliError(`You have view-only access to ${project.name} — linking would give you a checkout you can't push.`)
101
+ files = manifestFromServer(project)
102
+ if (!(await confirmAdoption(root, project, files, flags.yes || flags.force))) { info(c.dim("Aborted — nothing was linked.")); return }
103
+ name = project.name
104
+ } else {
105
+ name = flags.name ?? basename(root)
106
+ info(c.dim(`Creating project ${c.bold(name)}…`))
107
+ uuid = (await createProject(apiUrl, token, name, flags.team)).uuid
108
+ files = {} // fresh project: the whole folder is a pending add
105
109
  }
106
- files = manifestFromServer(project)
107
- if (!(await confirmAdoption(root, project, files, args))) { info(c.dim("Aborted — nothing was linked.")); return }
108
- name = project.name
109
- } else {
110
- name = flagStr(args, "name") ?? basename(root)
111
- info(c.dim(`Creating project ${c.bold(name)}…`))
112
- uuid = (await createProject(apiUrl, token, name, flagStr(args, "team"))).uuid
113
- files = {} // fresh project: the whole folder is a pending add
114
- }
115
110
 
116
- const commits = await getCommits(apiUrl, token, uuid)
117
- writeManifest(root, { uuid, apiUrl, name, baseHeadId: commits.headId, files })
118
- log(`${c.green("linked")} ${c.bold(name)} ${c.dim(uuid)}`)
111
+ const commits = await getCommits(apiUrl, token, uuid)
112
+ writeManifest(root, { uuid, apiUrl, name, baseHeadId: commits.headId, files })
113
+ log(`${c.green("linked")} ${c.bold(name)} ${c.dim(uuid)}`)
119
114
 
120
- // Now that there IS a server, take its type surface — it matches the runtime the project will
121
- // actually run on, unlike the CLI-bundled copy `lecodes init` wrote.
122
- if (!flagBool(args, "no-types")) {
123
- await materializeTypes(root, apiUrl)
124
- writeTsconfig(root)
125
- log(`${c.green("written")} ${LECODES_DIR}/types/ + tsconfig.json ${c.dim("(from the server)")}`)
126
- }
127
- writeDefaultIgnore(root)
128
- if (ensureGitignore(root)) log(`${c.green("written")} .gitignore ${c.dim("(.lecodes/ is per-checkout state)")}`)
115
+ // Now that there IS a server, take its type surface — it matches the runtime the project will
116
+ // actually run on, unlike the CLI-bundled copy `lecodes init` wrote.
117
+ if (!flags["no-types"]) {
118
+ await materializeTypes(root, apiUrl)
119
+ writeTsconfig(root)
120
+ log(`${c.green("written")} ${LECODES_DIR}/types/ + tsconfig.json ${c.dim("(from the server)")}`)
121
+ }
122
+ writeDefaultIgnore(root)
123
+ if (ensureGitignore(root)) log(`${c.green("written")} .gitignore ${c.dim("(.lecodes/ is per-checkout state)")}`)
124
+
125
+ if (flags.push) {
126
+ log("")
127
+ if (root !== process.cwd()) process.chdir(root) // push works from the current project root
128
+ await runPush(flags)
129
+ return
130
+ }
129
131
 
130
- if (flagBool(args, "push")) {
132
+ const pending = scanProject(root, { files } as Manifest).files.filter((f) => { const known = files[f.path]; return !known || known.sha !== f.sha }).length
131
133
  log("")
132
- if (root !== process.cwd()) process.chdir(root) // push works from the current project root
133
- await push(args)
134
- return
135
- }
136
-
137
- const pending = scanProject(root, { files } as Manifest).files.filter((f) => {
138
- const known = files[f.path]
139
- return !known || known.sha !== f.sha
140
- }).length
141
- log("")
142
- success(`Connected to ${c.bold(name)}.`)
143
- const cd = dirArg ? `cd ${dirArg} && ` : ""
144
- log(` ${c.bold(cd + "lecodes status")} ${pending} file(s) would go up`)
145
- log(` ${c.bold(cd + 'lecodes push -m "initial"')} push them as the first checkpoint`)
146
- log(` ${c.dim("(add --publish to deploy it live in the same step)")}`)
147
- }
134
+ success(`Connected to ${c.bold(name)}.`)
135
+ const cd = dirArg ? `cd ${dirArg} && ` : ""
136
+ log(` ${c.bold(cd + "lecodes status")} ${pending} file(s) would go up`)
137
+ log(` ${c.bold(cd + 'lecodes push -m "initial"')} push them as the first checkpoint`)
138
+ log(` ${c.dim("(add --publish to deploy it live in the same step)")}`)
139
+ },
140
+ })