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
@@ -0,0 +1,391 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { CliError, bool, c, defineCommand, log, note, num, str, warnErr, type FlagValues } from "../../cli"
4
+ import { compileDesignScreen } from "../../compile/designCompile"
5
+ import { loadPeer } from "../../hosts/peerInstall"
6
+ import { disableDesignShare, enableDesignShare, setDesignCommentsOpen } from "../../platform/api"
7
+ import { openBrowser } from "../../platform/browserAuth"
8
+ import { readMetaFrames } from "../../project/designMeta"
9
+ import { materializeTypesLocal, writeTsconfig } from "../../project/types"
10
+ import { session } from "../shared"
11
+ import comments, { startCommentSync } from "./comments"
12
+ import { DEFAULT_PORT, dirFlag, getContext, loadDesignServer, optionalPlatform, threadRef, type DesignContext } from "./context"
13
+ import snapshot from "./snapshot"
14
+ import { CLAUDE_MD, DESIGN_MACROS_DTS, HOME_SCREEN_TS, META_JSON, META_JSON_DESKTOP, README_HINT, SPEC_MD, TABS_TS, TOKENS_TS } from "./templates"
15
+
16
+ export { flushDesignComments, type CommentFlush } from "./comments"
17
+
18
+ /*
19
+ * `lecodes design` — the LeCodes Design canvas: prototype an app as live screens + a flow graph.
20
+ *
21
+ * design [serve] start the dev server + browser canvas (needs `lecodes-design`)
22
+ * design init scaffold the design folder (screens/, shared/, meta.json, spec.md, CLAUDE.md)
23
+ * design snapshot render screens headless to JSON/PNG (snapshot.ts, needs `lecodes-renderer`)
24
+ * design arrange auto-layout the flow graph (--new: only place unplaced screens)
25
+ * design check static coherence: spec.md links, flow warnings, untagged screens
26
+ * design share toggle the public share link (design must be pushed first)
27
+ * design comments the board's review comments + add / reply / resolve / pull (comments.ts)
28
+ *
29
+ * The design folder (default `design/`) holds one file per screen (screens/<id>.ts default-exports
30
+ * a UIScreen), shared tokens/components (shared/), and meta.json — the human-owned graph of
31
+ * positions, descriptions and edges. Screens compile per-file through the normal project pipeline
32
+ * and render via canvas-ui, live in the browser and headless for AI feedback.
33
+ */
34
+
35
+ /** First non-empty line, for one-line echoes of a comment body. */
36
+ const firstLine = (body: string, max = 64): string => {
37
+ const line = body.split("\n").find((l) => l.trim())?.trim() ?? ""
38
+ return line.length > max ? line.slice(0, max - 1) + "…" : line
39
+ }
40
+
41
+ // ---- serve -------------------------------------------------------------------------------------
42
+
43
+ const serveFlags = {
44
+ ...dirFlag,
45
+ port: num("dev-server port", { default: DEFAULT_PORT }),
46
+ "no-open": bool("don't open the browser"),
47
+ "no-sync": bool("don't reconcile comments with the platform while serving"),
48
+ }
49
+
50
+ const serve = async (flags: FlagValues<typeof serveFlags>): Promise<void> => {
51
+ const ctx = getContext(flags.dir)
52
+ const designServer = await loadDesignServer(ctx)
53
+
54
+ // The MCP render_screen tool renders headless through lecodes-renderer, injected here so the
55
+ // design server keeps no dependency on it. Absent renderer → the tool returns an install hint.
56
+ // Opportunistic: never triggers an install from inside the MCP server (the tool reports the hint).
57
+ const renderer = await loadPeer<typeof import("lecodes-renderer/headless")>("lecodes-renderer", "headless", { for: "lecodes design (render_screen)", install: false })
58
+ const renderScreen = renderer
59
+ ? async (id: string, format: "json" | "png" | "both", state?: string) => {
60
+ const js = await compileDesignScreen({
61
+ root: ctx.root, manifest: ctx.manifest, name: ctx.name, designDir: ctx.designDir, designKey: ctx.designKey, screenId: id, state, assets: "disk",
62
+ })
63
+ const frames = readMetaFrames(ctx.designDir)
64
+ const [width, height] = frames.screens[id]?.size ?? frames.defaultSize
65
+ const warnings: string[] = []
66
+ let json: string | undefined
67
+ let png: Buffer | undefined
68
+ if (format === "json" || format === "both") {
69
+ const res = await renderer.renderToJson(js, { width, height, localAssets: true })
70
+ json = res.text
71
+ warnings.push(...res.warnings)
72
+ }
73
+ if (format === "png" || format === "both") {
74
+ const res = await renderer.renderToPng(js, { width, height, localAssets: true, scale: 2 })
75
+ png = res.png
76
+ warnings.push(...res.warnings)
77
+ }
78
+ return { json, png, warnings }
79
+ }
80
+ : undefined
81
+
82
+ const server = await designServer.startDesignServer({
83
+ root: ctx.root,
84
+ designDir: ctx.designDir,
85
+ name: ctx.name,
86
+ port: flags.port,
87
+ log: note,
88
+ // Who owns the conversation: linked, the files mirror the platform's open threads and local
89
+ // writes queue for `lecodes push`; standalone, these files are the record.
90
+ linked: !!optionalPlatform(ctx),
91
+ compileScreen: async (id: string, state?: string) => ({
92
+ js: await compileDesignScreen({
93
+ root: ctx.root, manifest: ctx.manifest, name: ctx.name, designDir: ctx.designDir, designKey: ctx.designKey, screenId: id, state, assets: "server",
94
+ }),
95
+ warnings: [],
96
+ }),
97
+ renderScreen,
98
+ })
99
+
100
+ // Reconcile comments with the platform while we run, so review feedback from the share link shows
101
+ // up on the board (and in comments/) without a manual pull; answers go back with `lecodes push`.
102
+ // `--no-sync` opts out.
103
+ const sync = flags["no-sync"] ? null : startCommentSync(ctx, designServer)
104
+
105
+ log(`${c.bold("LeCodes Design")} — ${ctx.name}`)
106
+ log(`Canvas: ${c.bold(server.url)}`)
107
+ log(`MCP: ${c.bold(server.url + "/mcp")}${renderer ? "" : c.dim(" (render_screen off — npm install -g lecodes-renderer)")}`)
108
+ log(`Watching: ${ctx.dirName}/ (screens hot-reload on save; Ctrl+C to stop)`)
109
+ if (sync) log(`Comments: ${c.dim("in sync with the platform — replies and resolves go back automatically")}`)
110
+ if (!flags["no-open"]) openBrowser(server.url)
111
+ }
112
+
113
+ const serveCmd = defineCommand({
114
+ name: "serve",
115
+ summary: "Start the dev server + browser canvas (what bare `lecodes design` does)",
116
+ flags: serveFlags,
117
+ examples: ["lecodes design serve --port 4480 --no-open"],
118
+ run: ({ flags }) => serve(flags),
119
+ })
120
+
121
+ // ---- init --------------------------------------------------------------------------------------
122
+
123
+ /** Register the design server as a project-scoped MCP server so Claude Code auto-discovers it.
124
+ * Merges into an existing .mcp.json (never clobbers a hand-broken one). */
125
+ const writeMcpConfig = (ctx: DesignContext, port: number): void => {
126
+ const path = join(ctx.root, ".mcp.json")
127
+ const entry = { type: "http", url: `http://localhost:${port}/mcp` }
128
+ let config: { mcpServers?: Record<string, unknown> } = {}
129
+ if (existsSync(path)) {
130
+ try {
131
+ config = JSON.parse(readFileSync(path, "utf8"))
132
+ } catch {
133
+ warnErr(".mcp.json exists but isn't valid JSON — left it untouched. Add the lecodes-design server by hand.")
134
+ return
135
+ }
136
+ }
137
+ config.mcpServers ??= {}
138
+ const existing = config.mcpServers["lecodes-design"] as { url?: string } | undefined
139
+ if (existing?.url === entry.url) { note("kept .mcp.json (lecodes-design MCP server)"); return }
140
+ config.mcpServers["lecodes-design"] = entry
141
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n")
142
+ log(`${c.green(existing ? "updated" : "created")} .mcp.json (lecodes-design MCP → ${entry.url})`)
143
+ }
144
+
145
+ const init = defineCommand({
146
+ name: "init",
147
+ summary: "Scaffold the design folder (screens/, shared/, meta.json, spec.md, CLAUDE.md)",
148
+ description: "Existing files are kept. Also ignores .snapshots/ and comments/ in .lecodesignore, registers the board's MCP server in .mcp.json (so Claude Code reaches it), and writes the IDE types + a design-scoped tsconfig so screens typecheck standalone or in a project.",
149
+ flags: {
150
+ ...dirFlag,
151
+ port: num("dev-server port to register in .mcp.json", { default: DEFAULT_PORT }),
152
+ desktop: bool("desktop-frame board (meta.json defaultSize [1280, 800] instead of the phone frame)"),
153
+ },
154
+ examples: ["lecodes design init", "lecodes design init --desktop"],
155
+ run: async ({ flags }) => {
156
+ const ctx = getContext(flags.dir)
157
+ // --desktop: scaffold a desktop-frame board (meta.defaultSize [1280, 800]; every consumer —
158
+ // board tiles, arrange, snapshots, render_screen — resolves frames from it).
159
+ const files: [string, string][] = [
160
+ ["meta.json", flags.desktop ? META_JSON_DESKTOP : META_JSON],
161
+ ["spec.md", SPEC_MD],
162
+ ["README.md", README_HINT],
163
+ ["CLAUDE.md", CLAUDE_MD],
164
+ [join("shared", "tokens.ts"), TOKENS_TS],
165
+ [join("shared", "tabs.ts"), TABS_TS],
166
+ [join("screens", "home.ts"), HOME_SCREEN_TS],
167
+ ]
168
+ mkdirSync(join(ctx.designDir, "screens"), { recursive: true })
169
+ mkdirSync(join(ctx.designDir, "shared"), { recursive: true })
170
+ let created = 0
171
+ for (const [rel, content] of files) {
172
+ const abs = join(ctx.designDir, rel)
173
+ if (existsSync(abs)) { note(`kept ${ctx.dirName}/${rel.replace(/\\/g, "/")}`); continue }
174
+ writeFileSync(abs, content)
175
+ log(`${c.green("created")} ${ctx.dirName}/${rel.replace(/\\/g, "/")}`)
176
+ created++
177
+ }
178
+
179
+ // Neither of these is a project file: snapshots are local render artifacts, and comments/
180
+ // mirrors rows the platform already owns (pushing it back would just duplicate the database).
181
+ const ignorePath = join(ctx.root, ".lecodesignore")
182
+ const ignoreLines = [ `${ctx.dirName}/.snapshots/`, `${ctx.dirName}/comments/` ]
183
+ const current = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : ""
184
+ const present = new Set(current.split(/\r?\n/))
185
+ const missing = ignoreLines.filter((line) => !present.has(line))
186
+ if (missing.length) {
187
+ appendFileSync(ignorePath, (current && !current.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n")
188
+ for (const line of missing) log(`${c.green("ignored")} ${line} (in .lecodesignore)`)
189
+ }
190
+
191
+ // Let Claude Code reach the running board over MCP (render/check/state/activity tools).
192
+ writeMcpConfig(ctx, flags.port)
193
+
194
+ // IDE types: materialize the SDK's import-free type surface + a design-scoped tsconfig so screens
195
+ // typecheck honestly (globals like UIScreen / UINode / UINodeChild resolve), standalone or in a
196
+ // project. .lecodes/ and tsconfig.json are skipped by the push scanner.
197
+ const wroteTypes = materializeTypesLocal(ctx.designDir)
198
+ writeTsconfig(ctx.designDir)
199
+ // Design-only compiler globals (assetIcon) that aren't in the shipped SDK type surface. Written
200
+ // alongside the bundle so the editor resolves them; rewritten every init to pick up upgrades.
201
+ mkdirSync(join(ctx.designDir, ".lecodes", "types"), { recursive: true })
202
+ writeFileSync(join(ctx.designDir, ".lecodes", "types", "design-macros.d.ts"), DESIGN_MACROS_DTS)
203
+ log(`${c.green("created")} ${ctx.dirName}/tsconfig.json${wroteTypes ? ` + ${ctx.dirName}/.lecodes/types/` : ""} (IDE types)`)
204
+ if (!wroteTypes) warnErr("Bundled SDK types not found — tsconfig written, but globals may show unresolved until the CLI is rebuilt with vendored types.")
205
+
206
+ log("")
207
+ log(created ? "Design canvas ready." : "Design canvas already initialized.")
208
+ log(` ${c.bold("lecodes design")} start the canvas (browser)`)
209
+ log(` ${c.bold("lecodes design snapshot")} render screens headless (for AI feedback)`)
210
+ log(` Point Claude Code at ${c.bold(ctx.dirName + "/CLAUDE.md")} — it documents the conventions.`)
211
+ },
212
+ })
213
+
214
+ // ---- arrange -----------------------------------------------------------------------------------
215
+
216
+ /**
217
+ * Auto-layout the flow graph in meta.json. Full arrange re-lays-out every screen and always
218
+ * applies (the board offers Undo; the structure-aware score delta is printed); `--new` places only
219
+ * screens that have no explicit `pos` yet and never moves the rest — the mode Claude uses after
220
+ * adding screens. If the dev server is running it picks up the meta.json change and refreshes.
221
+ */
222
+ const arrange = defineCommand({
223
+ name: "arrange",
224
+ summary: "Auto-layout the flow graph in meta.json",
225
+ description: "Re-lays-out every screen and always applies (the board offers Undo; the layout score delta is printed, lower is better). A running dev server picks the change up and refreshes.",
226
+ flags: { ...dirFlag, new: bool("only place screens without a pos yet; never move the rest") },
227
+ examples: ["lecodes design arrange", "lecodes design arrange --new"],
228
+ run: async ({ flags }) => {
229
+ const ctx = getContext(flags.dir)
230
+ const designServer = await loadDesignServer(ctx)
231
+ const onlyNew = flags.new
232
+ const summary = designServer.arrangeDesign(ctx.designDir, { onlyNew })
233
+ const score = summary.score ? ` (score ${summary.score.before.toFixed(0)} → ${summary.score.after.toFixed(0)}, lower is better)` : ""
234
+ if (!summary.changed) {
235
+ log(onlyNew ? "No unplaced screens — nothing to arrange." : `Already in its arranged shape — nothing moved${score}.`)
236
+ return
237
+ }
238
+ log(`${c.green("✓")} ${onlyNew ? "Placed" : "Arranged"} ${summary.moved} screen${summary.moved === 1 ? "" : "s"} in ${ctx.dirName}/meta.json${score}.`)
239
+ },
240
+ })
241
+
242
+ // ---- share -------------------------------------------------------------------------------------
243
+
244
+ /**
245
+ * Toggle the project's public design share link on the platform. The design must already be pushed
246
+ * (`lecodes push`) — this only flips the link, it doesn't upload anything. `--off` revokes it;
247
+ * `--comments on|off` flips whether link visitors may WRITE comments (reading is never affected).
248
+ */
249
+ const share = defineCommand({
250
+ name: "share",
251
+ summary: "Turn the public share link on (or --off); --comments on|off gates visitors' comments",
252
+ description: "Needs a cloned project whose design is already pushed — this only flips the link, nothing is uploaded. Anyone with the link can view the mockups; the app code stays private.",
253
+ flags: {
254
+ ...dirFlag,
255
+ off: bool("revoke the share link"),
256
+ comments: str("let link visitors leave comments (existing ones stay readable either way)", { value: "<on|off>" }),
257
+ },
258
+ examples: ["lecodes design share", "lecodes design share --comments off", "lecodes design share --off"],
259
+ run: async ({ flags }) => {
260
+ const ctx = getContext(flags.dir)
261
+ if (!ctx.manifest) {
262
+ throw new CliError("`lecodes design share` needs a cloned project. Run it inside one, and push the design first (lecodes push).")
263
+ }
264
+ const { apiUrl, token } = session()
265
+ const uuid = ctx.manifest.uuid
266
+
267
+ if (flags.comments !== undefined) {
268
+ const value = flags.comments.trim().toLowerCase()
269
+ if (value !== "on" && value !== "off") {
270
+ throw new CliError(`--comments takes "on" or "off" (got "${flags.comments}").`)
271
+ }
272
+ const { commentsOpen } = await setDesignCommentsOpen(apiUrl, token, uuid, value === "on")
273
+ log(`${c.green("✓")} Comments on the shared design are ${commentsOpen ? "ON" : "OFF"}.`)
274
+ if (!commentsOpen) note("Existing comments stay readable — this only stops new ones.")
275
+ return
276
+ }
277
+
278
+ if (flags.off) {
279
+ await disableDesignShare(apiUrl, token, uuid)
280
+ log(`${c.green("✓")} Design sharing is off.`)
281
+ return
282
+ }
283
+ const res = await enableDesignShare(apiUrl, token, uuid)
284
+ log(`${c.bold("Design shared")} — anyone with this link can view the mockups (your app code stays private):`)
285
+ log(` ${c.bold(res.url ?? "")}`)
286
+ log(` ${c.dim(`comments: ${res.commentsOpen === false ? "off" : "on"}`)}`)
287
+ note('Turn it off with: lecodes design share --off · comments: --comments off')
288
+ },
289
+ })
290
+
291
+ // ---- check -------------------------------------------------------------------------------------
292
+
293
+ /**
294
+ * Static coherence check for a design folder — the terminal/CI sibling of the `check_concept` MCP
295
+ * tool. Reports the flow warnings meta.json/screens/spec.md already carry (broken edges, missing
296
+ * screens, edges targeting an undeclared `@state`, duplicate state names, dangling `[[spec]]` links)
297
+ * plus concept completeness (spec.md present? screens with no role?) and discovered states. Hard
298
+ * warnings exit non-zero; soft hints never fail the check. No compile, no render — fast, and needs
299
+ * only lecodes-design (not the renderer).
300
+ */
301
+ const check = defineCommand({
302
+ name: "check",
303
+ summary: "Static coherence check: flow warnings, spec.md links, states, tab bars, open comments",
304
+ description: "The terminal / CI sibling of the board's check_concept tool. Hard warnings (broken edges, missing screens, undeclared states, dangling [[spec]] links) exit non-zero; hints and open comments never fail it. No compile, no render — needs only lecodes-design.",
305
+ flags: dirFlag,
306
+ examples: ["lecodes design check"],
307
+ run: async ({ flags }) => {
308
+ const ctx = getContext(flags.dir)
309
+ const designServer = await loadDesignServer(ctx)
310
+ const state = designServer.readDesignState(ctx.designDir)
311
+ const concept = designServer.conceptReport(ctx.designDir, state.screens)
312
+
313
+ if (state.warnings.length) {
314
+ log(c.bold(`Warnings (${state.warnings.length})`))
315
+ for (const w of state.warnings) log(` ${c.yellow("!")} ${w}`)
316
+ log("")
317
+ }
318
+
319
+ log(c.bold("Concept"))
320
+ log(` spec.md: ${concept.hasSpec ? c.green("present") : c.dim("missing (optional)")}`)
321
+ if (concept.screenRefs.length) {
322
+ const resolved = concept.screenRefs.filter((r) => r.exists).length
323
+ log(` [[links]]: ${resolved}/${concept.screenRefs.length} resolve`)
324
+ }
325
+ if (concept.screensWithoutRole.length) log(` ${c.dim("no role:")} ${concept.screensWithoutRole.join(", ")}`)
326
+ log("")
327
+
328
+ // States: list multi-state screens (canonical highlighted) + soft "not discoverable" hints.
329
+ const stateful = state.screens.filter((s) => s.states.length > 1)
330
+ const undiscoverable = state.screens.filter((s) => s.statesWarning)
331
+ if (stateful.length || undiscoverable.length) {
332
+ log(c.bold("States"))
333
+ for (const s of stateful) {
334
+ log(` ${s.id}: ${s.states.map((st) => st === s.canonical ? c.green(st) : st).join(", ")}`)
335
+ }
336
+ for (const s of undiscoverable) log(` ${c.yellow("!")} ${s.id}: ${s.statesWarning}`)
337
+ log("")
338
+ }
339
+
340
+ // Tab bars: the source-discovered hubs (defineTabs) — tabs in order + how many screens sit on
341
+ // each bar. Problems (missing roots, unknown tabs, bar-implied edges) surface in Warnings above.
342
+ if (state.hubs.length) {
343
+ log(c.bold("Tab bars"))
344
+ for (const h of state.hubs) {
345
+ const mounted = Object.keys(h.lanes).length
346
+ log(` ${h.name} (${h.file}): ${h.tabs.join(" · ")} — ${mounted} screen${mounted === 1 ? "" : "s"} on the bar`)
347
+ }
348
+ log("")
349
+ }
350
+
351
+ // Review comments: open threads are outstanding work on this design, so a coherence check that
352
+ // stayed silent about them would report "no problems" over a board full of unanswered feedback.
353
+ // They never fail the check — feedback isn't an error, and the file may be a stale mirror. An
354
+ // older lecodes-design without the comment store simply omits the section rather than failing.
355
+ const commentThreads = typeof designServer.listDesignComments === "function"
356
+ ? designServer.listDesignComments(ctx.designDir).threads
357
+ : []
358
+ if (commentThreads.length) {
359
+ const open = commentThreads.filter((t) => !t.resolved)
360
+ log(c.bold("Comments"))
361
+ log(` ${open.length} open · ${commentThreads.length} total`)
362
+ for (const thread of open.slice(0, 5)) {
363
+ const where = thread.anchor.screen ?? "(board)"
364
+ log(` ${c.dim(`#${thread.number}`)} ${where} ${c.dim(firstLine(thread.messages[0]?.body ?? ""))}`)
365
+ }
366
+ if (open.length > 5) log(` ${c.dim(`…and ${open.length - 5} more — lecodes design comments`)}`)
367
+ log("")
368
+ }
369
+
370
+ const hard = state.warnings.length
371
+ if (hard === 0) {
372
+ const n = state.screens.length
373
+ log(`${c.green("✓")} No coherence problems${n ? ` across ${n} screen${n === 1 ? "" : "s"}` : ""}.`)
374
+ return
375
+ }
376
+ log(`${c.red("✗")} ${hard} coherence warning${hard === 1 ? "" : "s"} — see above.`)
377
+ process.exitCode = 1
378
+ },
379
+ })
380
+
381
+ // ---- the tree ----------------------------------------------------------------------------------
382
+
383
+ export default defineCommand({
384
+ name: "design",
385
+ summary: "LeCodes Design — a live canvas of screens for AI-driven prototyping",
386
+ description: "Bare `lecodes design` starts the canvas (= `design serve`). Needs the optional lecodes-design package (installed on first use); works inside a cloned project or standalone in any folder.\n\nThe design folder (default design/) holds one file per screen (screens/<id>.ts default-exports a UIScreen), shared tokens and components (shared/), and meta.json — the human-owned graph of positions, descriptions and edges. Screens compile per file through the normal project pipeline and render live in the browser and headless for AI feedback.",
387
+ flags: serveFlags,
388
+ commands: [serveCmd, init, snapshot, arrange, check, share, comments],
389
+ examples: ["lecodes design init && lecodes design", "lecodes design --port 4480 --no-open", "lecodes design snapshot --png"],
390
+ run: ({ flags }) => serve(flags),
391
+ })
@@ -0,0 +1,134 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { CliError, bool, c, defineCommand, log, logErr, note, str, warnErr } from "../../cli"
4
+ import { compileDesignScreen } from "../../compile/designCompile"
5
+ import { loadPeer } from "../../hosts/peerInstall"
6
+ import { readMetaFrames } from "../../project/designMeta"
7
+ import { dirFlag, getContext, type DesignServer } from "./context"
8
+
9
+ /*
10
+ * `lecodes design snapshot [screen…]` — render design screens headless through lecodes-renderer,
11
+ * the way the board's MCP render_screen tool does, for AI feedback without a browser. One requested
12
+ * screen prints its JSON to stdout; anything else lands as files in <dir>/.snapshots/ (or --out-dir).
13
+ * States: `--state <s>` renders one named state of each screen, `--all-states` every discovered
14
+ * one (the canonical state also written unsuffixed, so `<id>.json` always exists).
15
+ */
16
+
17
+ /** Screen ids from <designDir>/screens/*.ts. */
18
+ const listScreens = (designDir: string): string[] => {
19
+ const dir = join(designDir, "screens")
20
+ if (!existsSync(dir)) return []
21
+ return readdirSync(dir)
22
+ .filter((f) => f.endsWith(".ts") && !f.startsWith("."))
23
+ .map((f) => f.slice(0, -3))
24
+ .sort()
25
+ }
26
+
27
+ export default defineCommand({
28
+ name: "snapshot",
29
+ summary: "Render design screens headless to JSON / PNG (needs lecodes-renderer)",
30
+ usage: "[screen…]",
31
+ description: "One screen → its JSON on stdout; otherwise one file per screen in <dir>/.snapshots/ (<id>.json, <id>@<state>.json, PNGs with --png). --state and --all-states need lecodes-design to discover a screen's states from its source. A stuck screen (runaway loop) is killed after 120 s.",
32
+ flags: {
33
+ ...dirFlag,
34
+ state: str("render this state of every (state) => UIScreen screen", { value: "<name>" }),
35
+ "all-states": bool("render every discovered state of each screen"),
36
+ png: str("also rasterize PNGs at 2× (needs @napi-rs/canvas); a value is accepted and ignored", { optional: true, value: "[file]" }),
37
+ "out-dir": str("output folder (default <dir>/.snapshots)", { value: "<dir>" }),
38
+ logs: bool("forward the screens' console output (to stderr)"),
39
+ },
40
+ examples: [
41
+ "lecodes design snapshot",
42
+ "lecodes design snapshot login",
43
+ "lecodes design snapshot --all-states --png",
44
+ "lecodes design snapshot login --state error",
45
+ ],
46
+ run: async ({ args, flags }) => {
47
+ const ctx = getContext(flags.dir)
48
+ const all = listScreens(ctx.designDir)
49
+ if (all.length === 0) throw new CliError(`No screens in ${ctx.dirName}/screens/ — run "lecodes design init" first.`)
50
+ for (const id of args) {
51
+ if (!all.includes(id)) throw new CliError(`No screen "${id}" (have: ${all.join(", ")}).`)
52
+ }
53
+ const targets = args.length ? args : all
54
+
55
+ const stateFlag = flags.state
56
+ const allStates = flags["all-states"]
57
+ if (stateFlag !== undefined && allStates) throw new CliError("Use either --state <name> or --all-states, not both.")
58
+
59
+ const renderer = await loadPeer<typeof import("lecodes-renderer/headless")>("lecodes-renderer", "headless", { for: "lecodes design render" })
60
+
61
+ // Enumerating / validating states parses the screen source — that discovery lives in the design
62
+ // package. The canonical-only path (no flag) needs no discovery, so base snapshot stays lean.
63
+ let discover: ((source: string) => { states: string[], canonical: string }) | null = null
64
+ if (stateFlag !== undefined || allStates) {
65
+ const ds = await loadPeer<DesignServer>("lecodes-design", "server", { for: "lecodes design render --state" })
66
+ discover = (src) => { const d = ds.discoverScreenStates(src); return { states: d.states, canonical: d.canonical } }
67
+ }
68
+
69
+ // A stuck screen (runaway loop) shouldn't hang the CLI forever.
70
+ const killer = setTimeout(() => {
71
+ warnErr("Snapshot timed out after 120s.")
72
+ process.exit(1)
73
+ }, 120000)
74
+ killer.unref?.()
75
+
76
+ const png = flags.png !== undefined
77
+ const frames = readMetaFrames(ctx.designDir)
78
+ const outDir = flags["out-dir"] ?? join(ctx.designDir, ".snapshots")
79
+ const single = targets.length === 1 && args.length === 1 && !png && !allStates && !flags["out-dir"]
80
+ const onConsole = flags.logs
81
+ ? (level: string, text: string) => logErr(`${c.dim(`[${level}]`)} ${text}`)
82
+ : undefined
83
+
84
+ if (!single) mkdirSync(outDir, { recursive: true })
85
+
86
+ for (const id of targets) {
87
+ const [ width, height ] = frames.screens[id]?.size ?? frames.defaultSize
88
+
89
+ // What to render, and the file base(s) for each. `undefined` state = a bare (canonical) render.
90
+ // The canonical state also writes the unsuffixed `<id>` for backward compatibility.
91
+ let plan: { state?: string, bases: string[] }[] = [{ state: undefined, bases: [id] }]
92
+ if (discover) {
93
+ const { states, canonical } = discover(readFileSync(join(ctx.designDir, "screens", `${id}.ts`), "utf8"))
94
+ if (stateFlag !== undefined) {
95
+ if (!states.includes(stateFlag)) {
96
+ if (args.length === 1) throw new CliError(`No state "${stateFlag}" on ${id} (has: ${states.join(", ")}).`)
97
+ warnErr(`${id}: no state "${stateFlag}" (has: ${states.join(", ")}) — skipped.`)
98
+ continue
99
+ }
100
+ plan = [{ state: stateFlag, bases: [`${id}@${stateFlag}`] }]
101
+ } else {
102
+ plan = states.map((st) => ({ state: st, bases: st === canonical ? [`${id}@${st}`, id] : [`${id}@${st}`] }))
103
+ }
104
+ }
105
+
106
+ for (const { state, bases } of plan) {
107
+ const tag = state ? `${id}@${state}` : id
108
+ note(`Rendering ${tag}…`)
109
+ const js = await compileDesignScreen({
110
+ root: ctx.root, manifest: ctx.manifest, name: ctx.name, designDir: ctx.designDir, designKey: ctx.designKey, screenId: id, state, assets: "disk",
111
+ })
112
+ const result = await renderer.renderToJson(js, { width, height, localAssets: true, onConsole })
113
+ for (const w of result.warnings) warnErr(`${tag}: ${w}`)
114
+
115
+ if (single) {
116
+ const text = result.text.endsWith("\n") ? result.text : result.text + "\n"
117
+ process.stdout.write(text)
118
+ return
119
+ }
120
+ const pngResult = png ? await renderer.renderToPng(js, { width, height, localAssets: true, scale: 2, onConsole }) : null
121
+ if (pngResult) for (const w of pngResult.warnings) warnErr(`${tag}: ${w}`)
122
+
123
+ for (const base of bases) {
124
+ writeFileSync(join(outDir, `${base}.json`), result.text)
125
+ log(`${c.green("✓")} ${tag} → ${join(outDir, `${base}.json`)}`)
126
+ if (pngResult) {
127
+ writeFileSync(join(outDir, `${base}.png`), pngResult.png)
128
+ log(`${c.green("✓")} ${tag} → ${join(outDir, `${base}.png`)}`)
129
+ }
130
+ }
131
+ }
132
+ }
133
+ },
134
+ })
@@ -3,9 +3,10 @@
3
3
  * platform editor's "Create design" button. Imported by a RELATIVE path through the package's
4
4
  * node_modules link (not as `lecodes-design/templates`) on purpose: the CLI build externalizes the
5
5
  * `lecodes-design` package (it's an optional runtime install for `design serve`), but `init` must
6
- * work without it — a relative import gets bundled into the binary like any local module. The link
7
- * is the `link:lecodes-design` dev dependency (see the root `bun run link`). */
6
+ * work without it — a relative import gets bundled into the binary like any local module. The
7
+ * package comes from the `lecodes-design` dev dependency (an npm range; `bun link lecodes-design`
8
+ * swaps in a sibling checkout). */
8
9
 
9
10
  export {
10
11
  META_JSON, META_JSON_DESKTOP, SPEC_MD, TOKENS_TS, TABS_TS, HOME_SCREEN_TS, CLAUDE_MD, DESIGN_MACROS_DTS, README_HINT,
11
- } from "../../node_modules/lecodes-design/src/templates"
12
+ } from "../../../node_modules/lecodes-design/src/templates"