lecodes-cli 0.20.2 → 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 (105) hide show
  1. package/README.md +56 -57
  2. package/dist/index.js +5918 -5020
  3. package/package.json +7 -5
  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/scene-harness.json +1 -1
  10. package/runtime/sdk-types.json +1 -1
  11. package/runtime/web/assets/createViewerLite-DHqTFhWB.js +866 -0
  12. package/runtime/web/assets/{index-BMt7AnC5.js → index-Q06c6oHx.js} +1 -1
  13. package/runtime/web/embed.js +1 -1
  14. package/src/api.ts +1 -302
  15. package/src/cli/args.ts +37 -0
  16. package/src/cli/command.ts +113 -0
  17. package/src/cli/errors.ts +13 -0
  18. package/src/cli/help.ts +133 -0
  19. package/src/cli/index.ts +7 -0
  20. package/src/cli/output.ts +102 -0
  21. package/src/cli/run.ts +106 -0
  22. package/src/commands/{appAndroid.ts → app/android.ts} +451 -42
  23. package/src/commands/{appDesktop.ts → app/desktop.ts} +63 -32
  24. package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +24 -18
  25. package/src/commands/{appIcon.ts → app/icon.ts} +2 -2
  26. package/src/commands/{app.ts → app/index.ts} +397 -289
  27. package/src/commands/{appShared.ts → app/shared.ts} +30 -39
  28. package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +62 -28
  29. package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
  30. package/src/commands/{appTemplates.ts → app/templates/ios.ts} +1 -1
  31. package/src/commands/assets.ts +23 -21
  32. package/src/commands/clone.ts +48 -51
  33. package/src/commands/compile.ts +62 -88
  34. package/src/commands/create.ts +46 -47
  35. package/src/commands/design/comments.ts +361 -0
  36. package/src/commands/design/context.ts +101 -0
  37. package/src/commands/design/index.ts +391 -0
  38. package/src/commands/design/snapshot.ts +134 -0
  39. package/src/commands/{designTemplates.ts → design/templates.ts} +4 -3
  40. package/src/commands/desktop.ts +90 -82
  41. package/src/commands/dev.ts +201 -176
  42. package/src/commands/diff.ts +39 -51
  43. package/src/commands/index.ts +60 -0
  44. package/src/commands/{init.ts → init/index.ts} +199 -191
  45. package/src/commands/install.ts +80 -67
  46. package/src/commands/lightmap.ts +203 -171
  47. package/src/commands/link.ts +87 -94
  48. package/src/commands/login.ts +25 -23
  49. package/src/commands/navmesh.ts +168 -138
  50. package/src/commands/pn.ts +201 -244
  51. package/src/commands/pull.ts +49 -56
  52. package/src/commands/push.ts +48 -54
  53. package/src/commands/render.ts +158 -99
  54. package/src/commands/scene.ts +58 -55
  55. package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
  56. package/src/commands/{shadersNew.ts → shaders/new.ts} +2 -2
  57. package/src/commands/shared.ts +79 -0
  58. package/src/commands/status.ts +22 -26
  59. package/src/commands/test.ts +398 -371
  60. package/src/commands/thumbs.ts +185 -175
  61. package/src/commands/update/index.ts +226 -0
  62. package/src/commands/{types.ts → update/types.ts} +31 -22
  63. package/src/compile/collect.ts +5 -5
  64. package/src/compile/collectLocal.ts +3 -3
  65. package/src/compile/designCompile.ts +3 -3
  66. package/src/compile/headlessBundle.ts +133 -129
  67. package/src/compile/projectCompile.ts +2 -2
  68. package/src/compile/sceneCompile.ts +10 -8
  69. package/src/compile/screenEntry.ts +123 -127
  70. package/src/compile/shaders.ts +3 -3
  71. package/src/dev/androidDev.ts +1 -1
  72. package/src/dev/devServer.ts +3 -3
  73. package/src/dev/webRunner.ts +1 -1
  74. package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +1 -1
  75. package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +6 -5
  76. package/src/{desktopScript.ts → hosts/desktopScript.ts} +1 -1
  77. package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
  78. package/src/{matcTool.ts → hosts/matcTool.ts} +1 -1
  79. package/src/{peerInstall.ts → hosts/peerInstall.ts} +2 -2
  80. package/src/{releases.ts → hosts/releases.ts} +1 -1
  81. package/src/index.ts +32 -480
  82. package/src/platform/api.ts +302 -0
  83. package/src/{browserAuth.ts → platform/browserAuth.ts} +1 -1
  84. package/src/{config.ts → platform/config.ts} +1 -1
  85. package/src/{serverDiff.ts → platform/serverDiff.ts} +3 -3
  86. package/src/{projectEnv.ts → project/env.ts} +33 -2
  87. package/src/{localFiles.ts → project/localFiles.ts} +1 -1
  88. package/src/{manifest.ts → project/manifest.ts} +1 -1
  89. package/src/{project.ts → project/materialize.ts} +2 -2
  90. package/src/project/paths.ts +20 -0
  91. package/src/{textDiff.ts → project/textDiff.ts} +1 -1
  92. package/src/{types.ts → project/types.ts} +0 -0
  93. package/runtime/web/assets/createViewerLite-Ct_PZdof.js +0 -852
  94. package/src/commands/design.ts +0 -846
  95. package/src/commands/update.ts +0 -211
  96. package/src/util.ts +0 -146
  97. /package/src/commands/{projectTemplates.ts → init/templates.ts} +0 -0
  98. /package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +0 -0
  99. /package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +0 -0
  100. /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
  101. /package/src/{lecodes-renderer.d.ts → declarations/lecodes-renderer.d.ts} +0 -0
  102. /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
  103. /package/src/{peers.ts → hosts/peers.ts} +0 -0
  104. /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
  105. /package/src/{ignore.ts → project/ignore.ts} +0 -0
@@ -1,12 +1,12 @@
1
- import { basename } from "node:path"
2
1
  import { readFileSync } from "node:fs"
3
- import { getCommits, getProject, publishProject, sync, uploadBlob, type Project, type SyncFileInput, type SyncFolderInput } from "../api"
4
- import { loadConfig, requireApiUrl, requireToken } from "../config"
5
- import { findProjectRoot, readManifest, writeManifest, type FileKind, type Manifest, type ManifestFile } from "../manifest"
6
- import { classifyKind, diffLocal, hasChanges, isTextKind, scanProject } from "../localFiles"
7
- import { computeServerDiff } from "../serverDiff"
8
- import { buildPaths } from "../project"
9
- import { CliError, c, confirm, flagBool, flagStr, info, log, prompt, success, warn, type Args } from "../util"
2
+ import { basename } from "node:path"
3
+ import { CliError, bool, c, confirm, defineCommand, info, log, prompt, str, success, warn } from "../cli"
4
+ import { getCommits, getProject, publishProject, sync, uploadBlob, type Project, type SyncFileInput, type SyncFolderInput } from "../platform/api"
5
+ import { computeServerDiff } from "../platform/serverDiff"
6
+ import { classifyKind, diffLocal, hasChanges, isTextKind, scanProject } from "../project/localFiles"
7
+ import { writeManifest, type FileKind, type Manifest, type ManifestFile } from "../project/manifest"
8
+ import { buildPaths } from "../project/materialize"
9
+ import { linkedProject } from "./shared"
10
10
 
11
11
  /*
12
12
  * `lecodes push -m "message"` — diff the working tree against the manifest, upload changed binaries,
@@ -21,33 +21,42 @@ import { CliError, c, confirm, flagBool, flagStr, info, log, prompt, success, wa
21
21
  * arrives before the fix is a lie the reviewer has no way to detect. That ordering is why nothing
22
22
  * else in the toolchain sends a comment upward.
23
23
  */
24
- export const push = async (args: Args) => {
25
- const root = findProjectRoot(process.cwd())
26
- const manifest = readManifest(root)
27
- const config = loadConfig()
28
- const apiUrl = requireApiUrl(config)
29
- const token = requireToken(config)
30
-
31
- const publish = flagBool(args, "publish")
24
+
25
+ export const pushFlags = {
26
+ message: str("checkpoint message (asked for when omitted)", { alias: "m", value: "<text>" }),
27
+ publish: bool("also deploy the project live (compile) with the push"),
28
+ force: bool("push even if the server moved on (shows + confirms which server files get overwritten)"),
29
+ yes: bool("skip the --force overwrite confirmation", { alias: "y" }),
30
+ }
31
+
32
+ export type PushOptions = { message?: string, publish?: boolean, force?: boolean, yes?: boolean }
33
+
34
+ export default defineCommand({
35
+ name: "push",
36
+ summary: "Push local changes as one new checkpoint",
37
+ flags: pushFlags,
38
+ examples: ['lecodes push -m "Tweak the menu"', "lecodes push --publish"],
39
+ run: ({ flags }) => runPush(flags),
40
+ })
41
+
42
+ /** The push itself — `lecodes link --push` runs it too. */
43
+ export const runPush = async (opts: PushOptions): Promise<void> => {
44
+ const { root, manifest, apiUrl, token } = linkedProject()
45
+ const publish = !!opts.publish
32
46
  const scan = scanProject(root, manifest)
33
47
  const diff = diffLocal(manifest, scan)
34
48
  if (!hasChanges(diff)) {
35
- if (publish) {
36
- await publishProject(apiUrl, token, manifest.uuid)
37
- success("No file changes — published the current project state.")
38
- } else {
39
- info(c.dim("Working tree clean — nothing to push."))
40
- }
49
+ if (publish) { await publishProject(apiUrl, token, manifest.uuid); success("No file changes — published the current project state.") }
50
+ else info(c.dim("Working tree clean — nothing to push."))
41
51
  // Still flush: a queued answer must not be stuck behind "no files changed", which is exactly the
42
52
  // state you're in after resolving a comment about something you already pushed.
43
- await flushComments(args)
53
+ await flushComments()
44
54
  return
45
55
  }
46
56
 
47
- const force = flagBool(args, "force")
48
57
  // Concurrency guard: a push rewrites the server's WHOLE tree (set-tree), so anything that changed
49
58
  // on the server since we cloned/pulled would be clobbered. Two kinds of drift to catch:
50
- if (!force && manifest.baseHeadId !== null) {
59
+ if (!opts.force && manifest.baseHeadId !== null) {
51
60
  const commits = await getCommits(apiUrl, token, manifest.uuid)
52
61
  // (1) A new checkpoint was frozen on the server — that advances the head id.
53
62
  if (commits.headId !== manifest.baseHeadId) {
@@ -58,28 +67,24 @@ export const push = async (args: Args) => {
58
67
  // manifest (e.g. a resource was added in the editor) a blind push would silently DELETE it. Bail.
59
68
  if (commits.dirty) {
60
69
  const drift = serverPathDrift(await getProject(apiUrl, token, manifest.uuid), manifest)
61
- if (drift) {
62
- throw new CliError(`The project has uncommitted changes in the web editor your checkout doesn't have (${drift}). Run \`lecodes pull\` to take them, or \`lecodes push --force\` to overwrite the server.`)
63
- }
70
+ if (drift) throw new CliError(`The project has uncommitted changes in the web editor your checkout doesn't have (${drift}). Run \`lecodes pull\` to take them, or \`lecodes push --force\` to overwrite the server.`)
64
71
  }
65
72
  }
66
73
 
67
74
  // With --force we may clobber server-side edits. Show exactly what changes on the server and
68
75
  // confirm (interactive only; skip with --yes or when piped).
69
- if (force) {
76
+ if (opts.force) {
70
77
  const project = await getProject(apiUrl, token, manifest.uuid)
71
78
  const serverDiff = computeServerDiff(project, scan)
72
79
  if (serverDiff.length) {
73
80
  info(c.yellow(`--force will overwrite the server. ${serverDiff.length} file${serverDiff.length === 1 ? "" : "s"} change:`))
74
81
  const label: Record<string, string> = { added: c.green(" create "), removed: c.red(" delete "), modified: c.yellow(" update ") }
75
82
  for (const e of serverDiff) log(label[e.status] + e.path + (e.binary ? c.dim(" (binary)") : ""))
76
- if (process.stdin.isTTY && !flagBool(args, "y", "yes")) {
77
- if (!(await confirm("Overwrite the server with your local copy?", false))) { info(c.dim("Aborted.")); return }
78
- }
83
+ if (process.stdin.isTTY && !opts.yes && !(await confirm("Overwrite the server with your local copy?", false))) { info(c.dim("Aborted.")); return }
79
84
  }
80
85
  }
81
86
 
82
- const message = (flagStr(args, "m", "message") ?? await prompt("Checkpoint message")) || "Update from lecodes CLI"
87
+ const message = (opts.message ?? await prompt("Checkpoint message")) || "Update from lecodes CLI"
83
88
 
84
89
  // The identity source for a path: the same path in the manifest, else its move origin.
85
90
  const sourceFor = (path: string): ManifestFile | undefined =>
@@ -92,10 +97,8 @@ export const push = async (args: Args) => {
92
97
  const kind: FileKind = source?.type ?? classifyKind(basename(file.path))
93
98
  const entry: SyncFileInput = { path: file.path, type: kind }
94
99
  if (source) entry.assetId = source.id
95
-
96
- if (isTextKind(kind)) {
97
- entry.text = readFileSync(file.absPath, "utf8")
98
- } else if (!source || source.sha !== file.sha) {
100
+ if (isTextKind(kind)) entry.text = readFileSync(file.absPath, "utf8")
101
+ else if (!source || source.sha !== file.sha) {
99
102
  // New or changed binary — upload it and reference the fresh blob.
100
103
  const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename(file.path), readFileSync(file.absPath))
101
104
  entry.blobFileId = blob.id
@@ -104,7 +107,6 @@ export const push = async (args: Args) => {
104
107
  // Else: unchanged binary — assetId alone tells the server to keep the existing blob.
105
108
  files.push(entry)
106
109
  }
107
-
108
110
  const folders: SyncFolderInput[] = scan.dirs.map(path => ({ path }))
109
111
 
110
112
  if (uploaded > 0) info(c.dim(`Uploaded ${uploaded} binary file${uploaded === 1 ? "" : "s"}.`))
@@ -118,9 +120,7 @@ export const push = async (args: Args) => {
118
120
  // Rebuild the manifest from the authoritative server tree + local hashes.
119
121
  const shaByPath = new Map(scan.files.map(f => [f.path, f.sha]))
120
122
  const newFiles: Record<string, ManifestFile> = {}
121
- for (const node of res.nodes) {
122
- newFiles[node.path] = { id: node.id, type: node.type as FileKind, sha: shaByPath.get(node.path) ?? "" }
123
- }
123
+ for (const node of res.nodes) newFiles[node.path] = { id: node.id, type: node.type as FileKind, sha: shaByPath.get(node.path) ?? "" }
124
124
  writeManifest(root, { ...manifest, baseHeadId: res.headId, files: newFiles })
125
125
 
126
126
  const counts = [
@@ -131,19 +131,17 @@ export const push = async (args: Args) => {
131
131
  ].filter(Boolean).join(", ")
132
132
  success(`Pushed checkpoint${res.seq ? ` #${res.seq}` : ""}${counts ? ` (${counts})` : ""}: ${message}`)
133
133
  if (publish) reportPublish(res)
134
- await flushComments(args)
134
+ await flushComments()
135
135
  }
136
136
 
137
137
  /** Send the design board's queued comment writes, now that the design they describe is up. Silent
138
138
  * when there's no design folder, no queue, or no login — this is a rider on the push, not a step
139
139
  * the user asked for. */
140
- const flushComments = async (args: Args) => {
140
+ const flushComments = async (): Promise<void> => {
141
141
  const { flushDesignComments } = await import("./design")
142
- const result = await flushDesignComments(args)
142
+ const result = await flushDesignComments()
143
143
  if (!result) return
144
- if (result.threads) {
145
- success(`Sent ${result.sent} comment update${result.sent === 1 ? "" : "s"} across ${result.threads} thread${result.threads === 1 ? "" : "s"}.`)
146
- }
144
+ if (result.threads) success(`Sent ${result.sent} comment update${result.sent === 1 ? "" : "s"} across ${result.threads} thread${result.threads === 1 ? "" : "s"}.`)
147
145
  for (const failure of result.failures) warn(`Comment not sent — ${failure} (still queued; the next push retries it).`)
148
146
  }
149
147
 
@@ -157,17 +155,13 @@ const serverPathDrift = (project: Project, manifest: Manifest): string | null =>
157
155
  const manifestPaths = new Set(Object.keys(manifest.files))
158
156
  const added = [...serverPaths].filter(p => !manifestPaths.has(p))
159
157
  const removed = [...manifestPaths].filter(p => !serverPaths.has(p))
160
- const describe = (label: string, paths: string[]) =>
161
- `${paths.length} ${label} on the server: ${paths.slice(0, 3).join(", ")}${paths.length > 3 ? ", …" : ""}`
162
- const parts = [
163
- added.length ? describe("added", added) : "",
164
- removed.length ? describe("removed", removed) : "",
165
- ].filter(Boolean)
158
+ const describe = (label: string, paths: string[]) => `${paths.length} ${label} on the server: ${paths.slice(0, 3).join(", ")}${paths.length > 3 ? ", …" : ""}`
159
+ const parts = [added.length ? describe("added", added) : "", removed.length ? describe("removed", removed) : ""].filter(Boolean)
166
160
  return parts.length ? parts.join("; ") : null
167
161
  }
168
162
 
169
163
  /** Report the outcome of a --publish deploy bundled with the push (best-effort on the server). */
170
- const reportPublish = (res: { published?: boolean, publishError?: string }) => {
164
+ const reportPublish = (res: { published?: boolean, publishError?: string }): void => {
171
165
  if (res.published) success("Published — the project is now live.")
172
166
  else warn(`Push landed, but publish failed: ${res.publishError ?? "unknown error"}`)
173
167
  }
@@ -1,14 +1,15 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
2
  import { dirname, resolve } from "node:path"
3
3
  import { compileHeadlessBundle } from "../compile/headlessBundle"
4
- import { screenTargetFromArgs } from "../compile/screenEntry"
5
- import { findProjectRoot } from "../manifest"
4
+ import { screenTargetFrom } from "../compile/screenEntry"
5
+ import { findProjectRoot } from "../project/manifest"
6
6
  import { buildBundle } from "./compile"
7
- import { desktopDefaultViewport, desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./appShared"
8
- import { runDesktopRender } from "../desktopRenderer"
9
- import { evaluateDesktopRun, translateScenario } from "../desktopScript"
10
- import { CliError, c, flagStr, flagBool, isNullSink, logErr, note, warnErr, type Args } from "../util"
11
- import { loadPeer } from "../peerInstall"
7
+ import { desktopDefaultViewport, desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./app/shared"
8
+ import { runDesktopRender } from "../hosts/desktopRenderer"
9
+ import { evaluateDesktopRun, translateScenario } from "../hosts/desktopScript"
10
+ import { CliError, bool, c, defineCommand, isNullSink, logErr, note, num, str, warnErr, type FlagValues } from "../cli"
11
+ import { bundleFlags, bundleOptions, outFlag } from "./shared"
12
+ import { loadPeer } from "../hosts/peerInstall"
12
13
 
13
14
  /*
14
15
  * `lecodes render` — compile the local project and render it headless. By default it emits a
@@ -32,29 +33,45 @@ import { loadPeer } from "../peerInstall"
32
33
  * lecodes-renderer (headless/scenario.ts). Step outputs land under --out-dir (default: the CWD);
33
34
  * the run stops at the first failed step and dumps the failing frame next to them.
34
35
  *
36
+ * `--desktop` renders through the NATIVE desktop host instead of the headless renderer — the real
37
+ * tgfx UI + Filament 3D + creator-2d pipeline, composited exactly as desktop users see it. PNG only
38
+ * (the host has no semantic-JSON serializer), no lecodes-renderer / @napi-rs/canvas needed. The
39
+ * host binary downloads on first use (see desktopRenderer.ts); Windows + Linux for now.
40
+ *
35
41
  * Works in both server-backed projects (login) and local `lecodes init` projects (no login) — see
36
42
  * compile/headlessBundle.ts. Resource assets (asset('./x.png')) resolve from the local working tree
37
43
  * by default; use --remote-assets (or an explicit --public-url) to load them from the server
38
44
  * instead, which then needs --wait-network.
39
45
  *
40
- * Snapshot timing flags:
41
- * --settle <ms> max wait for promises/timers/fetch/image decode (ends early when idle)
42
- * --wait-network let data fetches hit the real network (off by default → empty responses)
43
- * --remote-assets load asset() resources from the server URL instead of the local disk
44
- * --time <ms> render at this animation time (0 = the initial frame-0 state)
45
- * --no-after-animations keep frame 0 instead of advancing animations to their resting state
46
- * --timeout <ms> hard cap on the whole render before aborting (default 15000; a script
47
- * run defaults to 15000 + 3000 per step)
48
- * --safe-area t,r,b,l override the safe-area insets (px) the `safe-*` keywords resolve to
49
- * --device <preset> safe-area preset: none | iphone | android (default: orientation-based).
50
- * Presets also carry the edge KIND: android's status/nav bars are exact
51
- * heights (comfort-* adds its knob past them); iphone insets are
52
- * clearance zones (comfort-* floors). --safe-area alone = all clearance.
53
- * --logs forward the app's console.log/error (to stderr) for logic debugging
54
- * --clip <name|x,y,w,h> (--png) crop to a named node's layout rect or an explicit logical-px
55
- * rect — a component / region render instead of the whole viewport
46
+ * The viewport / timing flags and the --desktop flags are exported as groups because `lecodes test`
47
+ * takes the very same ones. The device presets carry the edge KIND: android's status/nav bars are
48
+ * exact heights (comfort-* adds its knob past them); iphone insets are clearance zones (comfort-*
49
+ * floors). --safe-area alone = all clearance.
56
50
  */
57
51
 
52
+ // ---- flag groups (shared with `lecodes test`) ------------------------------------------------
53
+
54
+ /** Viewport + snapshot timing. */
55
+ export const viewportFlags = {
56
+ width: num("viewport width, logical px (default 390 — a desktop-only project renders at its window, a design screen at its board frame)"),
57
+ height: num("viewport height, logical px (default 844, same fallbacks)"),
58
+ settle: num("max wait for promises / timers / fetch / image decode before the snapshot (ends early when idle)", { value: "<ms>" }),
59
+ "wait-network": bool("let data fetches hit the real network (off by default → empty responses)"),
60
+ device: str("safe-area preset: none | iphone | android (default: orientation-based)", { value: "<preset>" }),
61
+ "safe-area": str("override the safe-area insets (px) the safe-* keywords resolve to", { value: "<t,r,b,l>" }),
62
+ logs: bool("forward the app's console output (to stderr)"),
63
+ }
64
+
65
+ /** `--desktop`: the NATIVE host in place of the headless renderer. */
66
+ export const desktopRenderFlags = {
67
+ desktop: bool("render through the NATIVE desktop host instead: real Filament 3D + tgfx UI + 2D, composited. PNG only (--png). Downloads the LeCodes Desktop renderer on first use into ~/.lecodes/renderer (Windows/Linux; LECODES_DESKTOP_EXE overrides)"),
68
+ frames: num("(--desktop) frames to run before the shot — headroom for async loads", { default: 60 }),
69
+ "fixed-dt": num("(--desktop) virtual app clock, ms per frame — deterministic game time (scripted runs default to 16; plain renders to wall clock)", { value: "<ms>" }),
70
+ renderer: str("(--desktop) host + shader variant: gl | vulkan (default: app.json desktop.renderer, else gl)", { value: "<gl|vulkan>" }),
71
+ }
72
+
73
+ // ---- helpers `lecodes test` shares -----------------------------------------------------------
74
+
58
75
  // Approximate device safe-area presets, portrait: insets [top, right, bottom, left] (px) + which
59
76
  // edges are exact-height system BARS. `comfort-*` ADDS its knob past a bar edge (content at the
60
77
  // inset touches the chrome) and floors (max) a clearance edge (breathing room built in).
@@ -157,11 +174,82 @@ const parseClip = (raw: string | undefined): string | { x: number, y: number, wi
157
174
  throw new CliError(`--clip: expected a node name or "x,y,width,height" (got "${raw}").`)
158
175
  }
159
176
 
160
- /* `--desktop`: render through the NATIVE desktop host instead of the headless renderer — the real
161
- * tgfx UI + Filament 3D + creator-2d pipeline, composited exactly as desktop users see it. PNG
162
- * only (the host has no semantic-JSON serializer), no lecodes-renderer / @napi-rs/canvas needed.
163
- * The host binary downloads on first use (see desktopRenderer.ts); Windows + Linux for now. */
164
- const renderDesktop = async (args: Args) => {
177
+ /** `--png` / `--map` take an optional file: bare = the default name, absent = off. */
178
+ const optionalFile = (value: string | undefined, fallback: string): string | undefined =>
179
+ value === undefined ? undefined : value || fallback
180
+
181
+ // ---- the command -----------------------------------------------------------------------------
182
+
183
+ const renderFlags = {
184
+ state: str("([file]) the state to pass a (state) => UIScreen screen", { value: "<name>" }),
185
+ dir: str("([file]) the design folder a bare screen id resolves in (default design/)", { value: "<folder>" }),
186
+ ...outFlag,
187
+ png: str("rasterize a PNG (default screenshot.png; needs @napi-rs/canvas)", { value: "[file]", optional: true }),
188
+ map: str("draw the 3D scene's annotated top-down map (grid + XZ footprints + named markers) as <file>.png + <file>.json world-space data (default map.png; also a { \"map\": \"f.png\" } script step)", { value: "[file]", optional: true }),
189
+ "label-all": bool("(--map) label every object, not only the named markers"),
190
+ "max-px": num("(--map) longest edge of the map image, px"),
191
+ script: str("drive the app through a step list (tap/type/scroll/wait/expect/screenshot/json steps; games: frames/drag/hold/key/keyDown/keyUp, expect.log, screenshot clip). With --desktop: tap/drag/hold take { x, y } coordinates (no names — the host has no UI tree; type/scroll/clip unavailable), expect.log is checked, text/node/screen/value are skipped; per-step screenshots + final --png", { value: "<file>" }),
192
+ "out-dir": str("where --script step outputs land", { value: "<dir>", default: "." }),
193
+ clip: str("(--png) crop to one node's rect or a logical-px rect — a component / region render", { value: "<name|x,y,w,h>" }),
194
+ fixtures: str("canned fetch responses (URL pattern → { status?, json?, text? }) — make backend-gated screens reachable headless; merged over the scenario file's own", { value: "<file>" }),
195
+ scale: num("PNG device-pixel scale", { default: 2 }),
196
+ time: num("render at this animation time (0 = the initial frame-0 state)", { value: "<ms>" }),
197
+ "no-after-animations": bool("keep frame 0 instead of advancing animations to their resting state"),
198
+ timeout: num("hard cap on the whole render before aborting (default 15000, + 3000 per script step; --desktop 60000, + 5000 per step)", { value: "<ms>" }),
199
+ ...viewportFlags,
200
+ ...bundleFlags,
201
+ ...desktopRenderFlags,
202
+ view: str("(--desktop) inspection camera auto-fitted to the whole 3D scene: iso | top | front | side | back [--fov N]; the app's camera is untouched", { value: "<name>" }),
203
+ camera: str("(--desktop) inspection camera at a fixed eye point (world units, Y up) — goes with --look-at [--fov N]. In a --script run use { \"view\" } / { \"camera\" } steps", { value: "<x,y,z>" }),
204
+ "look-at": str("(--desktop) the point --camera looks at", { value: "<x,y,z>" }),
205
+ fov: num("(--desktop) vertical field of view for --view / --camera, degrees", { value: "<deg>" }),
206
+ }
207
+
208
+ type RenderFlags = FlagValues<typeof renderFlags>
209
+
210
+ export default defineCommand({
211
+ name: "render",
212
+ summary: "Render the local project headless (JSON / PNG), or through the desktop host",
213
+ usage: "[file]",
214
+ description: "Needs the optional lecodes-renderer package (installed on first use). [file] renders ONE screen module instead of the app: a file that default-exports a UIScreen (or a (state) => UIScreen) — every `lecodes design` screen is written that way. A bare id (\"login\") resolves in design/screens/, and a design screen renders at its meta.json frame.\n\nasset() resources load from the working tree by default; --remote-assets / --public-url use the server's copies (then data needs --wait-network).",
215
+ flags: renderFlags,
216
+ examples: [
217
+ "lecodes render -o ui.json",
218
+ "lecodes render --png shots/home.png --device iphone",
219
+ "lecodes render login --state empty --png",
220
+ "lecodes render --script tests/hero.flow.json --out-dir tests/.shots --logs",
221
+ "lecodes render --desktop --png --view iso",
222
+ ],
223
+ run: ({ args, flags }) => flags.desktop ? renderDesktop(flags, args[0]) : renderHeadless(flags, args[0]),
224
+ })
225
+
226
+ // ---- --desktop -------------------------------------------------------------------------------
227
+
228
+ const VIEW_NAMES = ["iso", "top", "front", "side", "back"]
229
+ const parseVec3Flag = (name: string, raw: string): string => {
230
+ const parts = raw.split(",").map((p) => p.trim())
231
+ if (parts.length !== 3 || parts.some((p) => !/^-?\d+(\.\d+)?$/.test(p))) throw new CliError(`--${name} wants "x,y,z" (got "${raw}").`)
232
+ return parts.join(",")
233
+ }
234
+
235
+ /** `--camera x,y,z --look-at x,y,z [--fov N]` → CREATOR_CAMERA; `--view name [--fov N]` → CREATOR_VIEW. */
236
+ const resolveInspectionCamera = ({ camera: cam, "look-at": look, view, fov }: Pick<RenderFlags, "camera" | "look-at" | "view" | "fov">): { camera?: string, view?: string } => {
237
+ if (fov !== undefined && !(fov > 0 && fov < 180)) throw new CliError(`--fov wants degrees in (0, 180), got "${fov}".`)
238
+ const fovSuffix = fov !== undefined ? `;${fov}` : ""
239
+ if (cam !== undefined || look !== undefined) {
240
+ if (view !== undefined) throw new CliError("Use either --camera/--look-at or --view, not both.")
241
+ if (cam === undefined || look === undefined) throw new CliError("--camera and --look-at go together: --camera x,y,z --look-at x,y,z")
242
+ return { camera: `${parseVec3Flag("camera", cam)};${parseVec3Flag("look-at", look)}${fovSuffix}` }
243
+ }
244
+ if (view !== undefined) {
245
+ if (!VIEW_NAMES.includes(view)) throw new CliError(`--view wants one of ${VIEW_NAMES.join(", ")} (got "${view}").`)
246
+ return { view: `${view}${fovSuffix}` }
247
+ }
248
+ if (fov !== undefined) throw new CliError("--fov goes with --camera/--look-at or --view.")
249
+ return {}
250
+ }
251
+
252
+ const renderDesktop = async (flags: RenderFlags, file: string | undefined): Promise<void> => {
165
253
  // A project with a desktop block renders at its configured window (this IS the desktop host);
166
254
  // the phone-portrait literal stays only for projects that never declared one.
167
255
  let root: string
@@ -169,32 +257,30 @@ const renderDesktop = async (args: Args) => {
169
257
  const win = readAppConfigAt(root)?.desktop?.window
170
258
  // `render <file>`: one screen module rather than the app (see screenEntry.ts) — the native host
171
259
  // runs the same bundle, so a design screen renders through the real tgfx UI here.
172
- const screen = screenTargetFromArgs(root, args)
173
- const scriptFlag = flagStr(args, "script")
174
- const scenario = scriptFlag !== undefined ? readScenarioFile(scriptFlag) : null
175
- const width = Number(flagStr(args, "width")) || scenario?.width || screen?.frame?.[0] || win?.width || 390
176
- const height = Number(flagStr(args, "height")) || scenario?.height || screen?.frame?.[1] || win?.height || 844
177
- const frames = Number(flagStr(args, "frames")) || 60
178
- const pngFlag = flagStr(args, "png") ?? "screenshot.png"
179
- const logs = flagBool(args, "logs")
260
+ const screen = screenTargetFrom(root, { file, state: flags.state, entry: flags.entry, dir: flags.dir })
261
+ const scenario = flags.script !== undefined ? readScenarioFile(flags.script) : null
262
+ const width = flags.width || scenario?.width || screen?.frame?.[0] || win?.width || 390
263
+ const height = flags.height || scenario?.height || screen?.frame?.[1] || win?.height || 844
264
+ const frames = flags.frames || 60
265
+ const pngFlag = flags.png || "screenshot.png"
266
+ const logs = flags.logs
180
267
  // --fixed-dt <ms>: virtual app clock (CREATOR_FIXED_DT). Scripted runs default to 16 so a held
181
268
  // key / drag / wait means the same game time on every machine; plain renders keep the wall clock
182
269
  // (their contract is "frame N", unchanged) unless asked.
183
- const fixedDtFlag = flagStr(args, "fixed-dt")
184
- const fixedDtMs = fixedDtFlag !== undefined ? Number(fixedDtFlag) : scenario ? 16 : 0
185
- if (!Number.isFinite(fixedDtMs) || fixedDtMs < 0 || fixedDtMs > 1000) throw new CliError(`--fixed-dt wants a number of ms (0 = wall clock, max 1000), got "${fixedDtFlag}".`)
270
+ const fixedDtMs = flags["fixed-dt"] ?? (scenario ? 16 : 0)
271
+ if (fixedDtMs < 0 || fixedDtMs > 1000) throw new CliError(`--fixed-dt wants a number of ms (0 = wall clock, max 1000), got "${fixedDtMs}".`)
186
272
 
187
273
  // Inspection camera: --camera x,y,z --look-at x,y,z [--fov N], or --view iso|top|front|side|back
188
274
  // [--fov N] (auto-fitted to the scene's bounds). App input can't be trusted while it's on, so a
189
275
  // scripted run sets it per step ({ "view": … } / { "camera": … }) instead of globally.
190
- const { camera, view } = resolveInspectionCamera(args)
276
+ const { camera, view } = resolveInspectionCamera(flags)
191
277
  if ((camera || view) && scenario) throw new CliError("--camera/--view apply to a plain render; in a --script run use a { \"view\": \"iso\" } or { \"camera\": { \"at\", \"lookAt\" } } step before the screenshot.")
192
278
 
193
- // One renderer decision: the host binary AND the staged .filamat variant (see appShared).
194
- const desktopRenderer = resolveDesktopRenderer(root, args)
279
+ // One renderer decision: the host binary AND the staged .filamat variant (see app/shared).
280
+ const desktopRenderer = resolveDesktopRenderer(root, flags.renderer)
195
281
  // Native plugins (project plugins/ dir + declared libraries) — the host scans CREATOR_PLUGIN_DIRS.
196
282
  const { dirs: pluginDirs } = desktopPluginLibraries(root)
197
- const { js, resources } = await buildBundle(args, undefined, {
283
+ const { js, resources } = await buildBundle(bundleOptions(flags), {
198
284
  localAssets: true, desktopRenderer,
199
285
  extraEntries: screen ? [screen.entry] : undefined, entryOverride: screen?.entryPath,
200
286
  })
@@ -204,10 +290,10 @@ const renderDesktop = async (args: Args) => {
204
290
  if (scenario) {
205
291
  // Scripted native run: translate the scenario into the host's command file, let the host drive
206
292
  // it (it self-terminates), then read its markers back into per-step results.
207
- const outDir = flagStr(args, "out-dir") ?? "."
293
+ const outDir = flags["out-dir"]
208
294
  const plan = translateScenario(scenario.steps, { outDir, headroomFrames: frames })
209
295
  for (const st of plan.steps) if (st.screenshot) mkdirSync(dirname(st.screenshot), { recursive: true })
210
- const timeoutMs = Number(flagStr(args, "timeout")) || (60000 + plan.steps.length * 5000)
296
+ const timeoutMs = flags.timeout || (60000 + plan.steps.length * 5000)
211
297
  note(`Running ${plan.steps.length} step${plan.steps.length === 1 ? "" : "s"} (native)…`)
212
298
  const { output } = await runDesktopRender(js, resources, {
213
299
  renderer: desktopRenderer, pluginDirs,
@@ -229,7 +315,7 @@ const renderDesktop = async (args: Args) => {
229
315
  process.exit(result.ok ? 0 : 1)
230
316
  }
231
317
 
232
- const timeoutMs = Number(flagStr(args, "timeout")) || 60000
318
+ const timeoutMs = flags.timeout || 60000
233
319
  note(screen ? `Rendering ${screen.label} (native)…` : "Rendering (native)…")
234
320
  await runDesktopRender(js, resources, {
235
321
  renderer: desktopRenderer, pluginDirs,
@@ -238,72 +324,45 @@ const renderDesktop = async (args: Args) => {
238
324
  note(`Wrote ${pngFlag} (${width}x${height}, native render${view ? `, ${view.split(";")[0]} view` : camera ? ", custom camera" : ""})`)
239
325
  }
240
326
 
241
- const VIEW_NAMES = ["iso", "top", "front", "side", "back"]
242
- const parseVec3Flag = (name: string, raw: string): string => {
243
- const parts = raw.split(",").map((p) => p.trim())
244
- if (parts.length !== 3 || parts.some((p) => !/^-?\d+(\.\d+)?$/.test(p))) throw new CliError(`--${name} wants "x,y,z" (got "${raw}").`)
245
- return parts.join(",")
246
- }
247
- /** `--camera x,y,z --look-at x,y,z [--fov N]` → CREATOR_CAMERA; `--view name [--fov N]` → CREATOR_VIEW. */
248
- const resolveInspectionCamera = (args: Args): { camera?: string, view?: string } => {
249
- const cam = flagStr(args, "camera"), look = flagStr(args, "look-at"), view = flagStr(args, "view"), fov = flagStr(args, "fov")
250
- if (fov !== undefined && !(Number(fov) > 0 && Number(fov) < 180)) throw new CliError(`--fov wants degrees in (0, 180), got "${fov}".`)
251
- const fovSuffix = fov !== undefined ? `;${Number(fov)}` : ""
252
- if (cam !== undefined || look !== undefined) {
253
- if (view !== undefined) throw new CliError("Use either --camera/--look-at or --view, not both.")
254
- if (cam === undefined || look === undefined) throw new CliError("--camera and --look-at go together: --camera x,y,z --look-at x,y,z")
255
- return { camera: `${parseVec3Flag("camera", cam)};${parseVec3Flag("look-at", look)}${fovSuffix}` }
256
- }
257
- if (view !== undefined) {
258
- if (!VIEW_NAMES.includes(view)) throw new CliError(`--view wants one of ${VIEW_NAMES.join(", ")} (got "${view}").`)
259
- return { view: `${view}${fovSuffix}` }
260
- }
261
- if (fov !== undefined) throw new CliError("--fov goes with --camera/--look-at or --view.")
262
- return {}
263
- }
264
-
265
- export const render = async (args: Args) => {
266
- if (flagBool(args, "desktop")) return renderDesktop(args)
327
+ // ---- headless --------------------------------------------------------------------------------
267
328
 
268
- const scriptFlag = flagStr(args, "script")
269
- const scenario = scriptFlag !== undefined ? readScenarioFile(scriptFlag) : null
329
+ const renderHeadless = async (flags: RenderFlags, file: string | undefined): Promise<void> => {
330
+ const scenario = flags.script !== undefined ? readScenarioFile(flags.script) : null
270
331
 
271
332
  // `render <file>`: render that one screen module instead of the app's own entrypoint.
272
333
  let projectRoot: string
273
334
  try { projectRoot = findProjectRoot(process.cwd()) } catch { projectRoot = process.cwd() }
274
- const screen = screenTargetFromArgs(projectRoot, args)
335
+ const screen = screenTargetFrom(projectRoot, { file, state: flags.state, entry: flags.entry, dir: flags.dir })
275
336
 
276
337
  // Hard timeout so a pathological scene (stuck fetch, runaway loop) can't hang the CLI. Scripts get
277
338
  // a per-step allowance. Unref'd so it never itself keeps the process alive once we're done.
278
339
  const stepCount = scenario && Array.isArray(scenario.steps) ? scenario.steps.length : 0
279
- const timeoutMs = Number(flagStr(args, "timeout")) || (15000 + stepCount * 3000)
340
+ const timeoutMs = flags.timeout || (15000 + stepCount * 3000)
280
341
  const killer = setTimeout(() => {
281
342
  warnErr(`Render timed out after ${timeoutMs}ms (try --settle / --wait-network, or raise --timeout).`)
282
343
  process.exit(1)
283
344
  }, timeoutMs)
284
345
  killer.unref?.()
285
346
 
286
- const { js, warnings, useLocalAssets, root } = await compileHeadlessBundle(args, { screen })
347
+ const { js, warnings, useLocalAssets, root } = await compileHeadlessBundle({ screen, entry: flags.entry, publicUrl: flags["public-url"], remoteAssets: flags["remote-assets"], waitNetwork: flags["wait-network"] })
287
348
  const renderer = await requireRenderer()
288
349
 
289
350
  // Flags override the scenario file's envelope; both fall back to the project default (a
290
351
  // desktop-only app.json renders desktop-sized), then the phone-portrait literals.
291
352
  // A design screen carries its own board frame (meta.json), which beats the app-wide default.
292
353
  const projectViewport = desktopDefaultViewport(root)
293
- const width = Number(flagStr(args, "width")) || scenario?.width || screen?.frame?.[0] || projectViewport?.width || 390
294
- const height = Number(flagStr(args, "height")) || scenario?.height || screen?.frame?.[1] || projectViewport?.height || 844
295
-
296
- // Shared snapshot-timing options (see the header comment). `--logs` forwards the app's console to
297
- // stderr (kept off stdout so it never corrupts the JSON/PNG result there).
298
- const settleStr = flagStr(args, "settle") ?? (scenario?.settle !== undefined ? String(scenario.settle) : undefined)
299
- const timeStr = flagStr(args, "time")
300
- const onConsole = flagBool(args, "logs")
354
+ const width = flags.width || scenario?.width || screen?.frame?.[0] || projectViewport?.width || 390
355
+ const height = flags.height || scenario?.height || screen?.frame?.[1] || projectViewport?.height || 844
356
+
357
+ // Shared snapshot-timing options. `--logs` forwards the app's console to stderr (kept off stdout
358
+ // so it never corrupts the JSON/PNG result there).
359
+ const onConsole = flags.logs
301
360
  ? (level: string, text: string) => logErr(`${c.dim(`[${level}]`)} ${text}`)
302
361
  : undefined
303
362
  // Fixtures: the scenario file's (inline map or a path), with a `--fixtures <file>` flag merged
304
363
  // over it (flag patterns win on collision). Validated here so a typo is a clean CLI error.
305
- const envelopeFixtures = resolveFixtures(scenario?.fixtures, scriptFlag !== undefined ? dirname(resolve(scriptFlag)) : ".")
306
- const flagFixtures = resolveFixtures(flagStr(args, "fixtures"), ".")
364
+ const envelopeFixtures = resolveFixtures(scenario?.fixtures, flags.script !== undefined ? dirname(resolve(flags.script)) : ".")
365
+ const flagFixtures = resolveFixtures(flags.fixtures, ".")
307
366
  const fixtures = envelopeFixtures || flagFixtures ? { ...envelopeFixtures, ...flagFixtures } : undefined
308
367
  if (fixtures) {
309
368
  try {
@@ -314,13 +373,13 @@ export const render = async (args: Args) => {
314
373
  }
315
374
 
316
375
  const timing = {
317
- settleMs: settleStr !== undefined ? Number(settleStr) : undefined,
318
- waitNetwork: flagBool(args, "wait-network"),
376
+ settleMs: flags.settle ?? scenario?.settle,
377
+ waitNetwork: flags["wait-network"],
319
378
  localAssets: useLocalAssets,
320
379
  fixtures,
321
- timeMs: timeStr !== undefined ? Number(timeStr) : undefined,
322
- afterAnimations: !flagBool(args, "no-after-animations"),
323
- ...resolveSafeAreaChoice(flagStr(args, "safe-area") ?? scenario?.safeArea, flagStr(args, "device") ?? scenario?.device),
380
+ timeMs: flags.time,
381
+ afterAnimations: !flags["no-after-animations"],
382
+ ...resolveSafeAreaChoice(flags["safe-area"] ?? scenario?.safeArea, flags.device ?? scenario?.device),
324
383
  onConsole,
325
384
  }
326
385
 
@@ -331,7 +390,7 @@ export const render = async (args: Args) => {
331
390
  // --script: drive the compiled app through the scenario steps against a live session.
332
391
  if (scenario) {
333
392
  const steps = renderer.parseScenarioSteps(scenario.steps)
334
- const outDir = flagStr(args, "out-dir") ?? "."
393
+ const outDir = flags["out-dir"]
335
394
  // Screenshots require pixels; otherwise rasterize opportunistically (napi installed → failure
336
395
  // PNGs and real Skia text metrics, absent → deterministic HeadlessHost metrics).
337
396
  const needsRaster = steps.some((s) => s.screenshot !== undefined)
@@ -356,12 +415,12 @@ export const render = async (args: Args) => {
356
415
  // --map [file]: draw the 3D scene's annotated top-down map (grid + XZ footprints + named
357
416
  // markers) instead of a screenshot — the level-design view. Writes <file>.png + <file>.json
358
417
  // (the same data, world-space, queryable). Default map.png. `--label-all` labels every object.
359
- const mapFlag = flagStr(args, "map") ?? (flagBool(args, "map") ? "map.png" : undefined)
418
+ const mapFlag = optionalFile(flags.map, "map.png")
360
419
  if (mapFlag !== undefined) {
361
420
  note(`Rendering map…`)
362
421
  const title = root.split(/[\\/]/).pop()
363
422
  const { png, data, warnings: mapWarnings } = await renderer.renderToMap(js, {
364
- ...timing, maxPx: Number(flagStr(args, "max-px")) || undefined, title, labelAll: flagBool(args, "label-all"),
423
+ ...timing, maxPx: flags["max-px"] || undefined, title, labelAll: flags["label-all"],
365
424
  })
366
425
  for (const w of [ ...warnings, ...mapWarnings ]) warnErr(w)
367
426
  const pngPath = mapFlag.endsWith(".png") ? mapFlag : mapFlag + ".png"
@@ -374,12 +433,12 @@ export const render = async (args: Args) => {
374
433
  }
375
434
 
376
435
  // --png [file]: rasterize a PNG (binary → always to a file, default screenshot.png).
377
- const pngFlag = flagStr(args, "png") ?? (flagBool(args, "png") ? "screenshot.png" : undefined)
436
+ const pngFlag = optionalFile(flags.png, "screenshot.png")
378
437
  if (pngFlag !== undefined) {
379
438
  note(screen ? `Rendering ${screen.label} (PNG)…` : "Rendering PNG…")
380
- const clip = parseClip(flagStr(args, "clip"))
439
+ const clip = parseClip(flags.clip)
381
440
  const { png, width: outW, height: outH, warnings: pngWarnings } = await renderer.renderToPng(js, {
382
- width, height, scale: Number(flagStr(args, "scale")) || 2, ...timing, clip,
441
+ width, height, scale: flags.scale || 2, ...timing, clip,
383
442
  })
384
443
  for (const w of [ ...warnings, ...pngWarnings ]) warnErr(w)
385
444
  if (isNullSink(pngFlag)) {
@@ -397,7 +456,7 @@ export const render = async (args: Args) => {
397
456
 
398
457
  for (const w of [ ...warnings, ...result.warnings ]) warnErr(w)
399
458
 
400
- const out = flagStr(args, "o", "out")
459
+ const out = flags.out
401
460
  if (out && isNullSink(out)) {
402
461
  note(`Discarded output (${out})`)
403
462
  return done((cb) => cb())