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,846 +0,0 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, appendFileSync } from "node:fs"
2
- import { basename, join } from "node:path"
3
- import { findProjectRoot, readManifest, type Manifest } from "../manifest"
4
- import { compileDesignScreen } from "../compile/designCompile"
5
- import { openBrowser } from "../browserAuth"
6
- import { CLAUDE_MD, DESIGN_MACROS_DTS, HOME_SCREEN_TS, META_JSON, META_JSON_DESKTOP, README_HINT, SPEC_MD, TABS_TS, TOKENS_TS } from "./designTemplates"
7
- import { CliError, c, flagBool, flagStr, log, logErr, note, warnErr, type Args } from "../util"
8
- import { loadPeer } from "../peerInstall"
9
- import { loadConfig, normalizeApiUrl, requireApiUrl, requireToken } from "../config"
10
- import {
11
- createDesignComment, deleteDesignComment, deleteDesignCommentMessage, disableDesignShare,
12
- editDesignCommentMessage, enableDesignShare, listDesignComments, moveDesignComment,
13
- replyDesignComment, resolveDesignComment, setDesignCommentsOpen, type DesignCommentsSnapshot,
14
- } from "../api"
15
- import type { CommentOrigin, CommentThread, MirrorSummary } from "lecodes-design/server"
16
- import { materializeTypesLocal, writeTsconfig } from "../types"
17
- import { readMetaFrames } from "../designMeta"
18
-
19
- /*
20
- * `lecodes design` — the LeCodes Design canvas: prototype an app as live screens + a flow graph.
21
- *
22
- * lecodes design [serve] start the dev server + browser canvas (needs `lecodes-design`)
23
- * --no-sync don't reconcile comments with the platform while serving
24
- * lecodes design init scaffold the design folder (screens/, shared/, meta.json, spec.md, CLAUDE.md)
25
- * lecodes design snapshot [--state <s> | --all-states] render screens headless to JSON/PNG (needs `lecodes-renderer`)
26
- * lecodes design arrange [--new] auto-layout the flow graph (--new: only place unplaced screens)
27
- * lecodes design check static coherence: spec.md links, flow warnings, untagged screens
28
- * lecodes design share [--off] toggle the public share link (design must be pushed first)
29
- * --comments <on|off> let link visitors leave comments
30
- * lecodes design comments [--all] the board's review comments, grouped by screen (open first)
31
- * lecodes design comments add "<text>" [--screen <id>] [--state <s>] [--element <name>] [--ai]
32
- * lecodes design comments reply <number> "<text>" [--ai]
33
- * lecodes design comments resolve <number> [--reply "<text>"] [--reopen] [--ai]
34
- * lecodes design comments pull mirror the platform's open threads into design/comments/
35
- * (writes go the other way with `lecodes push`, never on their own)
36
- *
37
- * The design folder (default `design/`) holds one file per screen (screens/<id>.ts default-exports
38
- * a UIScreen), shared tokens/components (shared/), and meta.json — the human-owned graph of
39
- * positions, descriptions and edges. Screens compile per-file through the normal project pipeline
40
- * and render via canvas-ui, live in the browser and headless for AI feedback.
41
- */
42
-
43
- const DEFAULT_DIR = "design"
44
- const DEFAULT_PORT = 4477
45
-
46
- type DesignContext = {
47
- root: string
48
- /** Null in standalone mode: the design folder lives outside any cloned lecodes project. */
49
- manifest: Manifest | null
50
- name: string
51
- dirName: string
52
- designDir: string
53
- designKey: string
54
- }
55
-
56
- const getContext = (args: Args): DesignContext => {
57
- // Inside a cloned project the design folder sits at its root; anywhere else the tool runs
58
- // standalone against the current directory (no login, no server project needed).
59
- let root: string
60
- let manifest: Manifest | null
61
- try {
62
- root = findProjectRoot(process.cwd())
63
- manifest = readManifest(root)
64
- } catch {
65
- root = process.cwd()
66
- manifest = null
67
- }
68
- const dirName = (flagStr(args, "dir") ?? DEFAULT_DIR).replace(/[/\\]+$/, "")
69
- return {
70
- root, manifest,
71
- name: manifest?.name ?? basename(root),
72
- dirName,
73
- designDir: join(root, dirName),
74
- designKey: "/" + dirName.replace(/\\/g, "/"),
75
- }
76
- }
77
-
78
- /** The design canvas package, lazily: it's an optional dev dependency of the project, never a
79
- * dependency of the CLI. Also asserts the folder exists, since every caller needs both.
80
- *
81
- * `needs` names exports the caller will actually call. The two packages are versioned and installed
82
- * independently, so a project can easily hold a CLI newer than its `lecodes-design` — without this
83
- * check that surfaces as `undefined is not a function` halfway through a command. */
84
- const loadDesignServer = async (
85
- ctx: DesignContext,
86
- needs: (keyof typeof import("lecodes-design/server"))[] = [],
87
- ): Promise<typeof import("lecodes-design/server")> => {
88
- if (!existsSync(ctx.designDir)) {
89
- throw new CliError(`No ${ctx.dirName}/ folder here. Run "lecodes design init" to scaffold it.`)
90
- }
91
- const mod = await loadPeer<typeof import("lecodes-design/server")>("lecodes-design", "server", { for: "lecodes design" })
92
- const missing = needs.filter((name) => typeof mod[name] !== "function")
93
- if (missing.length) {
94
- throw new CliError(
95
- `This needs a newer lecodes-design — the installed one has no \`${missing[0]}\`. Run: npm install -g lecodes-design@latest`)
96
- }
97
- return mod
98
- }
99
-
100
- /** The platform link a comment sync needs. A standalone design folder has no database behind it —
101
- * there `comments/` IS the store, so saying that beats prompting for a login. */
102
- const requirePlatform = (ctx: DesignContext, verb: string) => {
103
- if (!ctx.manifest) {
104
- throw new CliError(
105
- `\`lecodes design comments ${verb}\` needs a cloned project. This folder is standalone, so ` +
106
- `${ctx.dirName}/comments/ is already the only store — there is nothing to sync with.`)
107
- }
108
- const config = loadConfig()
109
- return { apiUrl: requireApiUrl(config), token: requireToken(config), uuid: ctx.manifest.uuid }
110
- }
111
-
112
- /** The same link, but optional — null when standalone or not logged in. Used where syncing is a
113
- * bonus rather than the point (the dev server's mirror, prune). */
114
- const optionalPlatform = (ctx: DesignContext) => {
115
- if (!ctx.manifest) return null
116
- const config = loadConfig()
117
- if (!config.apiUrl || !config.token) return null
118
- return { apiUrl: normalizeApiUrl(config.apiUrl), token: config.token, uuid: ctx.manifest.uuid }
119
- }
120
-
121
- const errText = (e: unknown) => (e instanceof Error ? e.message : String(e))
122
-
123
- /** First non-empty line, for one-line echoes of a comment body. */
124
- const firstLine = (body: string, max = 64): string => {
125
- const line = body.split("\n").find((l) => l.trim())?.trim() ?? ""
126
- return line.length > max ? line.slice(0, max - 1) + "…" : line
127
- }
128
-
129
- /** Screen ids from <designDir>/screens/*.ts. */
130
- const listScreens = (designDir: string): string[] => {
131
- const dir = join(designDir, "screens")
132
- if (!existsSync(dir)) return []
133
- return readdirSync(dir)
134
- .filter((f) => f.endsWith(".ts") && !f.startsWith("."))
135
- .map((f) => f.slice(0, -3))
136
- .sort()
137
- }
138
-
139
- // ---- init --------------------------------------------------------------------------------------
140
-
141
- /** Register the design server as a project-scoped MCP server so Claude Code auto-discovers it.
142
- * Merges into an existing .mcp.json (never clobbers a hand-broken one). */
143
- const writeMcpConfig = (ctx: DesignContext, port: number) => {
144
- const path = join(ctx.root, ".mcp.json")
145
- const entry = { type: "http", url: `http://localhost:${port}/mcp` }
146
- let config: { mcpServers?: Record<string, unknown> } = {}
147
- if (existsSync(path)) {
148
- try {
149
- config = JSON.parse(readFileSync(path, "utf8"))
150
- } catch {
151
- warnErr(".mcp.json exists but isn't valid JSON — left it untouched. Add the lecodes-design server by hand.")
152
- return
153
- }
154
- }
155
- config.mcpServers ??= {}
156
- const existing = config.mcpServers["lecodes-design"] as { url?: string } | undefined
157
- if (existing?.url === entry.url) { note("kept .mcp.json (lecodes-design MCP server)"); return }
158
- config.mcpServers["lecodes-design"] = entry
159
- writeFileSync(path, JSON.stringify(config, null, 2) + "\n")
160
- log(`${c.green(existing ? "updated" : "created")} .mcp.json (lecodes-design MCP → ${entry.url})`)
161
- }
162
-
163
- const init = async (ctx: DesignContext, args: Args) => {
164
- // --desktop: scaffold a desktop-frame board (meta.defaultSize [1280, 800]; every consumer —
165
- // board tiles, arrange, snapshots, render_screen — resolves frames from it).
166
- const files: [string, string][] = [
167
- ["meta.json", flagBool(args, "desktop") ? META_JSON_DESKTOP : META_JSON],
168
- ["spec.md", SPEC_MD],
169
- ["README.md", README_HINT],
170
- ["CLAUDE.md", CLAUDE_MD],
171
- [join("shared", "tokens.ts"), TOKENS_TS],
172
- [join("shared", "tabs.ts"), TABS_TS],
173
- [join("screens", "home.ts"), HOME_SCREEN_TS],
174
- ]
175
- mkdirSync(join(ctx.designDir, "screens"), { recursive: true })
176
- mkdirSync(join(ctx.designDir, "shared"), { recursive: true })
177
- let created = 0
178
- for (const [rel, content] of files) {
179
- const abs = join(ctx.designDir, rel)
180
- if (existsSync(abs)) { note(`kept ${ctx.dirName}/${rel.replace(/\\/g, "/")}`); continue }
181
- writeFileSync(abs, content)
182
- log(`${c.green("created")} ${ctx.dirName}/${rel.replace(/\\/g, "/")}`)
183
- created++
184
- }
185
-
186
- // Neither of these is a project file: snapshots are local render artifacts, and comments/
187
- // mirrors rows the platform already owns (pushing it back would just duplicate the database).
188
- const ignorePath = join(ctx.root, ".lecodesignore")
189
- const ignoreLines = [ `${ctx.dirName}/.snapshots/`, `${ctx.dirName}/comments/` ]
190
- const current = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : ""
191
- const present = new Set(current.split(/\r?\n/))
192
- const missing = ignoreLines.filter((line) => !present.has(line))
193
- if (missing.length) {
194
- appendFileSync(ignorePath, (current && !current.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n")
195
- for (const line of missing) log(`${c.green("ignored")} ${line} (in .lecodesignore)`)
196
- }
197
-
198
- // Let Claude Code reach the running board over MCP (render/check/state/activity tools).
199
- writeMcpConfig(ctx, Number(flagStr(args, "port")) || DEFAULT_PORT)
200
-
201
- // IDE types: materialize the SDK's import-free type surface + a design-scoped tsconfig so screens
202
- // typecheck honestly (globals like UIScreen / UINode / UINodeChild resolve), standalone or in a
203
- // project. .lecodes/ and tsconfig.json are skipped by the push scanner.
204
- const wroteTypes = materializeTypesLocal(ctx.designDir)
205
- writeTsconfig(ctx.designDir)
206
- // Design-only compiler globals (assetIcon) that aren't in the shipped SDK type surface. Written
207
- // alongside the bundle so the editor resolves them; rewritten every init to pick up upgrades.
208
- mkdirSync(join(ctx.designDir, ".lecodes", "types"), { recursive: true })
209
- writeFileSync(join(ctx.designDir, ".lecodes", "types", "design-macros.d.ts"), DESIGN_MACROS_DTS)
210
- log(`${c.green("created")} ${ctx.dirName}/tsconfig.json${wroteTypes ? ` + ${ctx.dirName}/.lecodes/types/` : ""} (IDE types)`)
211
- if (!wroteTypes) warnErr("Bundled SDK types not found — tsconfig written, but globals may show unresolved until the CLI is rebuilt with vendored types.")
212
-
213
- log("")
214
- log(created ? "Design canvas ready." : "Design canvas already initialized.")
215
- log(` ${c.bold("lecodes design")} start the canvas (browser)`)
216
- log(` ${c.bold("lecodes design snapshot")} render screens headless (for AI feedback)`)
217
- log(` Point Claude Code at ${c.bold(ctx.dirName + "/CLAUDE.md")} — it documents the conventions.`)
218
- }
219
-
220
- // ---- snapshot ----------------------------------------------------------------------------------
221
-
222
- const snapshot = async (ctx: DesignContext, args: Args) => {
223
- const all = listScreens(ctx.designDir)
224
- if (all.length === 0) throw new CliError(`No screens in ${ctx.dirName}/screens/ — run "lecodes design init" first.`)
225
- const requested = args._.slice(1)
226
- for (const id of requested) {
227
- if (!all.includes(id)) throw new CliError(`No screen "${id}" (have: ${all.join(", ")}).`)
228
- }
229
- const targets = requested.length ? requested : all
230
-
231
- const stateFlag = flagStr(args, "state")
232
- const allStates = flagBool(args, "all-states")
233
- if (stateFlag !== undefined && allStates) throw new CliError("Use either --state <name> or --all-states, not both.")
234
-
235
- const renderer = await loadPeer<typeof import("lecodes-renderer/headless")>("lecodes-renderer", "headless", { for: "lecodes design render" })
236
-
237
- // Enumerating / validating states parses the screen source — that discovery lives in the design
238
- // package. The canonical-only path (no flag) needs no discovery, so base snapshot stays lean.
239
- let discover: ((source: string) => { states: string[], canonical: string }) | null = null
240
- if (stateFlag !== undefined || allStates) {
241
- const ds = await loadPeer<typeof import("lecodes-design/server")>("lecodes-design", "server", { for: "lecodes design render --state" })
242
- discover = (src) => { const d = ds.discoverScreenStates(src); return { states: d.states, canonical: d.canonical } }
243
- }
244
-
245
- // A stuck screen (runaway loop) shouldn't hang the CLI forever.
246
- const killer = setTimeout(() => {
247
- warnErr("Snapshot timed out after 120s.")
248
- process.exit(1)
249
- }, 120000)
250
- killer.unref?.()
251
-
252
- const png = flagBool(args, "png") || flagStr(args, "png") !== undefined
253
- const frames = readMetaFrames(ctx.designDir)
254
- const outDir = flagStr(args, "out-dir") ?? join(ctx.designDir, ".snapshots")
255
- const single = targets.length === 1 && requested.length === 1 && !png && !allStates && !flagStr(args, "out-dir")
256
- const onConsole = flagBool(args, "logs")
257
- ? (level: string, text: string) => logErr(`${c.dim(`[${level}]`)} ${text}`)
258
- : undefined
259
-
260
- if (!single) mkdirSync(outDir, { recursive: true })
261
-
262
- for (const id of targets) {
263
- const [ width, height ] = frames.screens[id]?.size ?? frames.defaultSize
264
-
265
- // What to render, and the file base(s) for each. `undefined` state = a bare (canonical) render.
266
- // The canonical state also writes the unsuffixed `<id>` for backward compatibility.
267
- let plan: { state?: string, bases: string[] }[] = [{ state: undefined, bases: [id] }]
268
- if (discover) {
269
- const { states, canonical } = discover(readFileSync(join(ctx.designDir, "screens", `${id}.ts`), "utf8"))
270
- if (stateFlag !== undefined) {
271
- if (!states.includes(stateFlag)) {
272
- if (requested.length === 1) throw new CliError(`No state "${stateFlag}" on ${id} (has: ${states.join(", ")}).`)
273
- warnErr(`${id}: no state "${stateFlag}" (has: ${states.join(", ")}) — skipped.`)
274
- continue
275
- }
276
- plan = [{ state: stateFlag, bases: [`${id}@${stateFlag}`] }]
277
- } else {
278
- plan = states.map((st) => ({ state: st, bases: st === canonical ? [`${id}@${st}`, id] : [`${id}@${st}`] }))
279
- }
280
- }
281
-
282
- for (const { state, bases } of plan) {
283
- const tag = state ? `${id}@${state}` : id
284
- note(`Rendering ${tag}…`)
285
- const js = await compileDesignScreen({
286
- root: ctx.root, manifest: ctx.manifest, name: ctx.name, designDir: ctx.designDir, designKey: ctx.designKey, screenId: id, state, assets: "disk",
287
- })
288
- const result = await renderer.renderToJson(js, { width, height, localAssets: true, onConsole })
289
- for (const w of result.warnings) warnErr(`${tag}: ${w}`)
290
-
291
- if (single) {
292
- const text = result.text.endsWith("\n") ? result.text : result.text + "\n"
293
- process.stdout.write(text)
294
- return
295
- }
296
- const pngResult = png ? await renderer.renderToPng(js, { width, height, localAssets: true, scale: 2, onConsole }) : null
297
- if (pngResult) for (const w of pngResult.warnings) warnErr(`${tag}: ${w}`)
298
-
299
- for (const base of bases) {
300
- writeFileSync(join(outDir, `${base}.json`), result.text)
301
- log(`${c.green("✓")} ${tag} → ${join(outDir, `${base}.json`)}`)
302
- if (pngResult) {
303
- writeFileSync(join(outDir, `${base}.png`), pngResult.png)
304
- log(`${c.green("✓")} ${tag} → ${join(outDir, `${base}.png`)}`)
305
- }
306
- }
307
- }
308
- }
309
- }
310
-
311
- // ---- serve -------------------------------------------------------------------------------------
312
-
313
- const serve = async (ctx: DesignContext, args: Args) => {
314
- const designServer = await loadDesignServer(ctx)
315
-
316
- const port = Number(flagStr(args, "port")) || DEFAULT_PORT
317
-
318
- // The MCP render_screen tool renders headless through lecodes-renderer, injected here so the
319
- // design server keeps no dependency on it. Absent renderer → the tool returns an install hint.
320
- // Opportunistic: never triggers an install from inside the MCP server (the tool reports the hint).
321
- const renderer = await loadPeer<typeof import("lecodes-renderer/headless")>("lecodes-renderer", "headless", { for: "lecodes design (render_screen)", install: false })
322
- const renderScreen = renderer
323
- ? async (id: string, format: "json" | "png" | "both", state?: string) => {
324
- const r = renderer!
325
- const js = await compileDesignScreen({
326
- root: ctx.root, manifest: ctx.manifest, name: ctx.name, designDir: ctx.designDir, designKey: ctx.designKey, screenId: id, state, assets: "disk",
327
- })
328
- const frames = readMetaFrames(ctx.designDir)
329
- const [width, height] = frames.screens[id]?.size ?? frames.defaultSize
330
- const warnings: string[] = []
331
- let json: string | undefined
332
- let png: Buffer | undefined
333
- if (format === "json" || format === "both") {
334
- const res = await r.renderToJson(js, { width, height, localAssets: true })
335
- json = res.text
336
- warnings.push(...res.warnings)
337
- }
338
- if (format === "png" || format === "both") {
339
- const res = await r.renderToPng(js, { width, height, localAssets: true, scale: 2 })
340
- png = res.png
341
- warnings.push(...res.warnings)
342
- }
343
- return { json, png, warnings }
344
- }
345
- : undefined
346
-
347
- const server = await designServer.startDesignServer({
348
- root: ctx.root,
349
- designDir: ctx.designDir,
350
- name: ctx.name,
351
- port,
352
- log: note,
353
- // Who owns the conversation: linked, the files mirror the platform's open threads and local
354
- // writes queue for `lecodes push`; standalone, these files are the record.
355
- linked: !!optionalPlatform(ctx),
356
- compileScreen: async (id: string, state?: string) => ({
357
- js: await compileDesignScreen({
358
- root: ctx.root, manifest: ctx.manifest, name: ctx.name, designDir: ctx.designDir, designKey: ctx.designKey, screenId: id, state, assets: "server",
359
- }),
360
- warnings: [],
361
- }),
362
- renderScreen,
363
- })
364
-
365
- // Reconcile comments with the platform while we run, so review feedback from the share link shows
366
- // up on the board (and in comments/) without a manual pull; answers go back with `lecodes push`.
367
- // `--no-sync` opts out.
368
- const sync = flagBool(args, "no-sync") ? null : startCommentSync(ctx, designServer)
369
-
370
- log(`${c.bold("LeCodes Design")} — ${ctx.name}`)
371
- log(`Canvas: ${c.bold(server.url)}`)
372
- log(`MCP: ${c.bold(server.url + "/mcp")}${renderer ? "" : c.dim(" (render_screen off — npm install -g lecodes-renderer)")}`)
373
- log(`Watching: ${ctx.dirName}/ (screens hot-reload on save; Ctrl+C to stop)`)
374
- if (sync) log(`Comments: ${c.dim("in sync with the platform — replies and resolves go back automatically")}`)
375
- if (!flagBool(args, "no-open")) openBrowser(server.url)
376
- }
377
-
378
- // ---- arrange -----------------------------------------------------------------------------------
379
-
380
- /**
381
- * Auto-layout the flow graph in meta.json. Full arrange re-lays-out every screen and always
382
- * applies (the board offers Undo; the structure-aware score delta is printed); `--new` places only
383
- * screens that have no explicit `pos` yet and never moves the rest — the mode Claude uses after
384
- * adding screens. If the dev server is running it picks up the meta.json change and refreshes.
385
- */
386
- const arrange = async (ctx: DesignContext, args: Args) => {
387
- const designServer = await loadDesignServer(ctx)
388
- const onlyNew = flagBool(args, "new")
389
- const summary = designServer.arrangeDesign(ctx.designDir, { onlyNew })
390
- const score = summary.score ? ` (score ${summary.score.before.toFixed(0)} → ${summary.score.after.toFixed(0)}, lower is better)` : ""
391
- if (!summary.changed) {
392
- log(onlyNew ? "No unplaced screens — nothing to arrange." : `Already in its arranged shape — nothing moved${score}.`)
393
- return
394
- }
395
- log(`${c.green("✓")} ${onlyNew ? "Placed" : "Arranged"} ${summary.moved} screen${summary.moved === 1 ? "" : "s"} in ${ctx.dirName}/meta.json${score}.`)
396
- }
397
-
398
- // ---- share -------------------------------------------------------------------------------------
399
-
400
- /**
401
- * Toggle the project's public design share link on the platform. The design must already be pushed
402
- * (`lecodes push`) — this only flips the link, it doesn't upload anything. `--off` revokes it;
403
- * `--comments on|off` flips whether link visitors may WRITE comments (reading is never affected).
404
- */
405
- const share = async (ctx: DesignContext, args: Args) => {
406
- if (!ctx.manifest) {
407
- throw new CliError("`lecodes design share` needs a cloned project. Run it inside one, and push the design first (lecodes push).")
408
- }
409
- const config = loadConfig()
410
- const apiUrl = requireApiUrl(config)
411
- const token = requireToken(config)
412
- const uuid = ctx.manifest.uuid
413
-
414
- const commentsFlag = flagStr(args, "comments")
415
- if (commentsFlag !== undefined) {
416
- const value = commentsFlag.trim().toLowerCase()
417
- if (value !== "on" && value !== "off") {
418
- throw new CliError(`--comments takes "on" or "off" (got "${commentsFlag}").`)
419
- }
420
- const { commentsOpen } = await setDesignCommentsOpen(apiUrl, token, uuid, value === "on")
421
- log(`${c.green("✓")} Comments on the shared design are ${commentsOpen ? "ON" : "OFF"}.`)
422
- if (!commentsOpen) note("Existing comments stay readable — this only stops new ones.")
423
- return
424
- }
425
-
426
- if (flagBool(args, "off")) {
427
- await disableDesignShare(apiUrl, token, uuid)
428
- log(`${c.green("✓")} Design sharing is off.`)
429
- return
430
- }
431
- const res = await enableDesignShare(apiUrl, token, uuid)
432
- log(`${c.bold("Design shared")} — anyone with this link can view the mockups (your app code stays private):`)
433
- log(` ${c.bold(res.url ?? "")}`)
434
- log(` ${c.dim(`comments: ${res.commentsOpen === false ? "off" : "on"}`)}`)
435
- note('Turn it off with: lecodes design share --off · comments: --comments off')
436
- }
437
-
438
- // ---- check -------------------------------------------------------------------------------------
439
-
440
- /**
441
- * Static coherence check for a design folder — the terminal/CI sibling of the `check_concept` MCP
442
- * tool. Reports the flow warnings meta.json/screens/spec.md already carry (broken edges, missing
443
- * screens, edges targeting an undeclared `@state`, duplicate state names, dangling `[[spec]]` links)
444
- * plus concept completeness (spec.md present? screens with no role?) and discovered states. Hard
445
- * warnings exit non-zero; soft hints never fail the check. No compile, no render — fast, and needs
446
- * only lecodes-design (not the renderer).
447
- */
448
- const check = async (ctx: DesignContext, args: Args) => {
449
- const designServer = await loadDesignServer(ctx)
450
- const state = designServer.readDesignState(ctx.designDir)
451
- const concept = designServer.conceptReport(ctx.designDir, state.screens)
452
-
453
- if (state.warnings.length) {
454
- log(c.bold(`Warnings (${state.warnings.length})`))
455
- for (const w of state.warnings) log(` ${c.yellow("!")} ${w}`)
456
- log("")
457
- }
458
-
459
- log(c.bold("Concept"))
460
- log(` spec.md: ${concept.hasSpec ? c.green("present") : c.dim("missing (optional)")}`)
461
- if (concept.screenRefs.length) {
462
- const resolved = concept.screenRefs.filter((r) => r.exists).length
463
- log(` [[links]]: ${resolved}/${concept.screenRefs.length} resolve`)
464
- }
465
- if (concept.screensWithoutRole.length) log(` ${c.dim("no role:")} ${concept.screensWithoutRole.join(", ")}`)
466
- log("")
467
-
468
- // States: list multi-state screens (canonical highlighted) + soft "not discoverable" hints.
469
- const stateful = state.screens.filter((s) => s.states.length > 1)
470
- const undiscoverable = state.screens.filter((s) => s.statesWarning)
471
- if (stateful.length || undiscoverable.length) {
472
- log(c.bold("States"))
473
- for (const s of stateful) {
474
- log(` ${s.id}: ${s.states.map((st) => st === s.canonical ? c.green(st) : st).join(", ")}`)
475
- }
476
- for (const s of undiscoverable) log(` ${c.yellow("!")} ${s.id}: ${s.statesWarning}`)
477
- log("")
478
- }
479
-
480
- // Tab bars: the source-discovered hubs (defineTabs) — tabs in order + how many screens sit on
481
- // each bar. Problems (missing roots, unknown tabs, bar-implied edges) surface in Warnings above.
482
- if (state.hubs.length) {
483
- log(c.bold("Tab bars"))
484
- for (const h of state.hubs) {
485
- const mounted = Object.keys(h.lanes).length
486
- log(` ${h.name} (${h.file}): ${h.tabs.join(" · ")} — ${mounted} screen${mounted === 1 ? "" : "s"} on the bar`)
487
- }
488
- log("")
489
- }
490
-
491
- // Review comments: open threads are outstanding work on this design, so a coherence check that
492
- // stayed silent about them would report "no problems" over a board full of unanswered feedback.
493
- // They never fail the check — feedback isn't an error, and the file may be a stale mirror. An
494
- // older lecodes-design without the comment store simply omits the section rather than failing.
495
- const commentThreads = typeof designServer.listDesignComments === "function"
496
- ? designServer.listDesignComments(ctx.designDir).threads
497
- : []
498
- if (commentThreads.length) {
499
- const open = commentThreads.filter((t) => !t.resolved)
500
- log(c.bold("Comments"))
501
- log(` ${open.length} open · ${commentThreads.length} total`)
502
- for (const thread of open.slice(0, 5)) {
503
- const where = thread.anchor.screen ?? "(board)"
504
- log(` ${c.dim(`#${thread.number}`)} ${where} ${c.dim(firstLine(thread.messages[0]?.body ?? ""))}`)
505
- }
506
- if (open.length > 5) log(` ${c.dim(`…and ${open.length - 5} more — lecodes design comments`)}`)
507
- log("")
508
- }
509
-
510
- const hard = state.warnings.length
511
- if (hard === 0) {
512
- const n = state.screens.length
513
- log(`${c.green("✓")} No coherence problems${n ? ` across ${n} screen${n === 1 ? "" : "s"}` : ""}.`)
514
- return
515
- }
516
- log(`${c.red("✗")} ${hard} coherence warning${hard === 1 ? "" : "s"} — see above.`)
517
- process.exitCode = 1
518
- }
519
-
520
- // ---- comments ----------------------------------------------------------------------------------
521
-
522
- /**
523
- * Read the board's review comments in the terminal — the same threads the canvas pins, grouped by
524
- * screen. Open first, because the open ones are the work; `--all` includes resolved.
525
- *
526
- * Reads `design/comments/` directly (no server needed). On the platform those files mirror the
527
- * database — `lecodes design comments pull` refreshes them — but for a standalone design folder they
528
- * are the store, so this works with or without a project.
529
- */
530
- const commentsCmd = async (ctx: DesignContext, args: Args) => {
531
- const sub = args._[1]
532
- if (sub === "pull") return commentsPull(ctx)
533
- if (sub === "add") return commentsAdd(ctx, args)
534
- if (sub === "reply") return commentsReply(ctx, args)
535
- if (sub === "resolve") return commentsResolve(ctx, args)
536
- if (sub === "push") {
537
- throw new CliError(
538
- "Comments go up with the design, not on their own — run `lecodes push`.\n" +
539
- " (A reply that arrives before the change it describes is worse than one that arrives late.)")
540
- }
541
- if (sub !== undefined) {
542
- throw new CliError(`Unknown comments subcommand "${sub}" (add, reply, resolve, pull, or nothing to list).`)
543
- }
544
- const designServer = await loadDesignServer(ctx)
545
-
546
- const all = flagBool(args, "all")
547
- const { threads } = designServer.listDesignComments(ctx.designDir)
548
- const shown = all ? threads : threads.filter((t) => !t.resolved)
549
- const open = threads.filter((t) => !t.resolved).length
550
-
551
- if (threads.length === 0) {
552
- log("No comments on this design yet.")
553
- note("Open the board (lecodes design) and use 💬 Comment, or share it for review.")
554
- return
555
- }
556
- if (shown.length === 0) {
557
- log(`${c.green("✓")} Nothing open — all ${threads.length} comment${threads.length === 1 ? "" : "s"} resolved.`)
558
- note("See them with: lecodes design comments --all")
559
- return
560
- }
561
-
562
- // Group by anchor so the list reads like a walk of the board, not a feed.
563
- const byScreen = new Map<string, typeof shown>()
564
- for (const thread of shown) {
565
- const key = thread.anchor.screen ?? "(detached)"
566
- byScreen.set(key, [ ...(byScreen.get(key) ?? []), thread ])
567
- }
568
- // Unpushed drafts have no number yet; they sort last rather than pretending to be #0.
569
- const order = (t: { number?: number }) => t.number ?? Number.MAX_SAFE_INTEGER
570
- for (const [ screen, list ] of [ ...byScreen ].sort((a, b) => a[0].localeCompare(b[0]))) {
571
- log(c.bold(screen))
572
- for (const thread of list.sort((a, b) => order(a) - order(b))) {
573
- const target = thread.anchor.target
574
- const where = [ thread.anchor.state && `@${thread.anchor.state}`, target?.name ?? target?.text ]
575
- .filter(Boolean).join(" · ")
576
- const head = ` ${c.bold(threadRef(thread))} ${thread.messages[0]?.author ?? "—"}`
577
- log(`${head}${where ? c.dim(` ${where}`) : ""}${thread.resolved ? c.green(" ✓ resolved") : ""}`)
578
- for (const message of thread.messages) {
579
- for (const line of message.body.split("\n")) log(` ${c.dim("│")} ${line}`)
580
- }
581
- if (thread.messages.length > 1) log(` ${c.dim(`${thread.messages.length} messages`)}`)
582
- }
583
- log("")
584
- }
585
- log(`${open} open · ${threads.length} total`)
586
- }
587
-
588
- // ---- comments: write verbs (the server-down path — same store, same rules as the MCP tools) ----
589
-
590
- /**
591
- * Attribution for a CLI write. The developer's git name by default; Claude when `--ai` says so, or
592
- * when the command is visibly running inside a Claude session (Claude Code's shell sets CLAUDECODE).
593
- * The reviewer reading the answer is owed knowing a machine wrote it, so err toward the badge.
594
- */
595
- const cliAuthor = (args: Args): { author: string, origin: CommentOrigin } | undefined =>
596
- flagBool(args, "ai") || process.env.CLAUDECODE ? { author: "Claude", origin: "ai" } : undefined
597
-
598
- /** Where a write just went — said after every verb, because a finished-looking comment that hasn't
599
- * reached the reviewer yet is the misunderstanding this whole queue exists to prevent. */
600
- const commentDelivery = (ctx: DesignContext) =>
601
- optionalPlatform(ctx)
602
- ? "queued — it goes out with the next `lecodes push`, after the design it describes"
603
- : "saved — this folder is the record"
604
-
605
- const findThreadByNumber = (
606
- designServer: typeof import("lecodes-design/server"),
607
- ctx: DesignContext,
608
- raw: unknown,
609
- ): CommentThread => {
610
- const number = Number(raw)
611
- if (!Number.isInteger(number) || number <= 0) {
612
- throw new CliError(`"${String(raw ?? "")}" is not a thread number — see: lecodes design comments`)
613
- }
614
- const thread = designServer.listDesignComments(ctx.designDir).threads.find((t) => t.number === number)
615
- if (!thread) throw new CliError(`No comment #${number} on this design — see: lecodes design comments`)
616
- return thread
617
- }
618
-
619
- /** Start a thread from the terminal — Claude asking the reviewer something with no server running,
620
- * or the developer leaving themselves a note. Pin it to a real screen so it never lands detached. */
621
- const commentsAdd = async (ctx: DesignContext, args: Args) => {
622
- const body = String(args._[2] ?? "").trim()
623
- if (!body) {
624
- throw new CliError('Usage: lecodes design comments add "<text>" [--screen <id>] [--state <s>] [--element <name>] [--ai]')
625
- }
626
- const designServer = await loadDesignServer(ctx, [ "startThread", "readDesignState" ])
627
- const screen = flagStr(args, "screen")?.trim() || undefined
628
- if (screen) {
629
- const ids = designServer.readDesignState(ctx.designDir).screens.map((s) => s.id)
630
- if (!ids.includes(screen)) {
631
- throw new CliError(`No screen "${screen}" in ${ctx.dirName}/ — the pin would point at nothing. Have: ${ids.join(", ") || "(none)"}.`)
632
- }
633
- }
634
- const element = flagStr(args, "element")?.trim() || undefined
635
- const thread = designServer.startThread(ctx.designDir, {
636
- anchor: {
637
- screen,
638
- state: flagStr(args, "state")?.trim() || undefined,
639
- ...(element ? { target: { name: element } } : {}),
640
- },
641
- body,
642
- ...cliAuthor(args),
643
- }, { linked: !!optionalPlatform(ctx) })
644
- log(`${c.green("✓")} ${threadRef(thread)} added — ${commentDelivery(ctx)}`)
645
- }
646
-
647
- const commentsReply = async (ctx: DesignContext, args: Args) => {
648
- const body = String(args._[3] ?? "").trim()
649
- if (!body) throw new CliError('Usage: lecodes design comments reply <number> "<text>" [--ai]')
650
- const designServer = await loadDesignServer(ctx, [ "replyToThread" ])
651
- const thread = findThreadByNumber(designServer, ctx, args._[2])
652
- designServer.replyToThread(ctx.designDir, thread.id, body, cliAuthor(args))
653
- log(`${c.green("✓")} replied to #${thread.number} — ${commentDelivery(ctx)}`)
654
- }
655
-
656
- /** Close (or `--reopen`) a thread, optionally replying first — same order as the MCP tool, so the
657
- * reviewer always sees the "what was done" before the checkmark that claims it. */
658
- const commentsResolve = async (ctx: DesignContext, args: Args) => {
659
- const designServer = await loadDesignServer(ctx, [ "resolveThread", "replyToThread" ])
660
- const thread = findThreadByNumber(designServer, ctx, args._[2])
661
- const reopen = flagBool(args, "reopen")
662
- const reply = flagStr(args, "reply")?.trim()
663
- if (reply) designServer.replyToThread(ctx.designDir, thread.id, reply, cliAuthor(args))
664
- designServer.resolveThread(ctx.designDir, thread.id, !reopen)
665
- log(`${c.green("✓")} #${thread.number} ${reopen ? "reopened" : "resolved"} — ${commentDelivery(ctx)}`)
666
- }
667
-
668
- // ---- comments: pull down, and the queue `lecodes push` drains ---------------------------------
669
-
670
- /*
671
- * Direction is fixed and it is what keeps this small: the platform owns the conversation, the design
672
- * folder mirrors its OPEN threads, and **nothing leaves this machine except via `lecodes push`**.
673
- *
674
- * So there are exactly two moving parts here — a pull (safe any time, feedback arriving early is just
675
- * feedback) and a flush that rides along with the design push. A reply cannot reach a reviewer before
676
- * the work it describes, because they are the same command in that order.
677
- */
678
-
679
- type Platform = { apiUrl: string, token: string, uuid: string }
680
-
681
- /** How a thread is referred to. Duplicated from the design package rather than imported: that package
682
- * is an optional peer, so a value import here would break the CLI wherever it isn't installed. */
683
- const threadRef = (thread: { number?: number }) => (thread.number === undefined ? "new" : `#${thread.number}`)
684
-
685
- const reportMirror = (ctx: DesignContext, summary: MirrorSummary, open: number) => {
686
- const parts = [
687
- summary.added ? `+${summary.added} new` : null,
688
- summary.updated ? `${summary.updated} updated` : null,
689
- summary.closed ? `${summary.closed} closed` : null,
690
- ].filter((p): p is string => p !== null)
691
- if (parts.length === 0) {
692
- log(`Already up to date — ${open} open.`)
693
- return
694
- }
695
- log(`${c.green("✓")} ${ctx.dirName}/comments/ — ${parts.join(", ")} ${c.dim(`(${open} open)`)}`)
696
- }
697
-
698
- /** The platform's open threads. Resolved ones aren't mirrored: the mirror is the work queue, and the
699
- * platform keeps the record. */
700
- const fetchOpen = async (platform: Platform) => {
701
- const snapshot = await listDesignComments(platform.apiUrl, platform.token, platform.uuid)
702
- return snapshot.threads.filter((t) => !t.resolved)
703
- }
704
-
705
- const commentsPull = async (ctx: DesignContext) => {
706
- const designServer = await loadDesignServer(ctx, [ "mirrorThreads" ])
707
- const platform = requirePlatform(ctx, "pull")
708
- const open = await fetchOpen(platform)
709
- const summary = designServer.mirrorThreads(ctx.designDir, open)
710
- reportMirror(ctx, summary, open.length)
711
- if (open.length) note("Read them with: lecodes design comments")
712
- }
713
-
714
- export type CommentFlush = { sent: number, threads: number, failures: string[] }
715
-
716
- /**
717
- * Send everything the design folder has queued, and settle each thread against what the platform says
718
- * it is afterwards. Called by `lecodes push` once the design itself has landed — never before, which
719
- * is the whole reason a reply can't claim a fix nobody can see yet.
720
- *
721
- * Returns null when there is nothing to do (no design folder, no project, no queue), so the caller
722
- * stays a single `if`.
723
- */
724
- export const flushDesignComments = async (args: Args): Promise<CommentFlush | null> => {
725
- const ctx = getContext(args)
726
- if (!existsSync(ctx.designDir)) return null
727
- const platform = optionalPlatform(ctx)
728
- if (!platform) return null
729
-
730
- const designServer = await loadPeer<typeof import("lecodes-design/server")>("lecodes-design", "server", { for: "lecodes design", install: false })
731
- if (!designServer) return null
732
- // `settleQueued` must take `delivered` (4 params): settling a half-failed push with the older
733
- // signature would drop the writes that DIDN'T go through instead of retrying them.
734
- if (typeof designServer.queuedThreads !== "function") return null
735
- if (typeof designServer.settleQueued !== "function" || designServer.settleQueued.length < 4) return null
736
-
737
- const queued = designServer.queuedThreads(ctx.designDir)
738
- if (queued.length === 0) return null
739
-
740
- const { apiUrl, token, uuid } = platform
741
- const result: CommentFlush = { sent: 0, threads: 0, failures: [] }
742
-
743
- for (const item of queued) {
744
- const { thread, pending } = item
745
- /** What the platform says the thread is after the last delivered write. */
746
- let upstream: CommentThread | null = null
747
- /** LOCAL ids of what actually landed — a push can die halfway, and only these get settled. */
748
- const delivered = { messageIds: new Set<string>(), resolve: false }
749
- try {
750
- if (pending.newThread) {
751
- const [ opening, ...rest ] = thread.messages
752
- if (!opening) continue
753
- upstream = await createDesignComment(apiUrl, token, uuid, {
754
- anchor: thread.anchor, body: opening.body, origin: opening.origin,
755
- })
756
- result.sent++
757
- delivered.messageIds.add(opening.id)
758
- for (const message of rest) {
759
- upstream = await replyDesignComment(apiUrl, token, uuid, upstream.id, message.body, message.origin)
760
- result.sent++
761
- delivered.messageIds.add(message.id)
762
- }
763
- } else {
764
- for (const message of pending.messages) {
765
- upstream = await replyDesignComment(apiUrl, token, uuid, thread.id, message.body, message.origin)
766
- result.sent++
767
- delivered.messageIds.add(message.id)
768
- }
769
- }
770
-
771
- if (pending.resolve) {
772
- upstream = await resolveDesignComment(apiUrl, token, uuid, upstream?.id ?? thread.id, thread.resolved)
773
- result.sent++
774
- delivered.resolve = true
775
- }
776
- } catch (e) {
777
- // The undelivered remainder stays queued: the next push retries it. What must never happen is
778
- // the file claiming something was sent when it wasn't.
779
- result.failures.push(`${threadRef(thread)}: ${errText(e)}`)
780
- }
781
- // Settle whatever DID land, even after a mid-thread failure — adopting the platform's copy is
782
- // what stops the next push from sending it again as a duplicate.
783
- if (upstream) {
784
- designServer.settleQueued(ctx.designDir, item, upstream, delivered)
785
- result.threads++
786
- }
787
- }
788
- return result
789
- }
790
-
791
- /** How often the running board re-reads the platform's comments. */
792
- const COMMENT_SYNC_MS = 20_000
793
-
794
- /**
795
- * Keep `design/comments/` fed while the canvas runs, so feedback left on the share link reaches the
796
- * board — and Claude — without anyone remembering to pull.
797
- *
798
- * Strictly inbound. Writing the files is what refreshes the board: the fs watcher sees the directory
799
- * change and pushes the SSE `comments` flag, exactly as it does for a hand edit.
800
- */
801
- const startCommentSync = (ctx: DesignContext, designServer: typeof import("lecodes-design/server")) => {
802
- const platform = optionalPlatform(ctx)
803
- if (!platform) return null
804
- // An older lecodes-design has no mirror to drive. The board still works off the files, so losing
805
- // the sync is not a reason to refuse to serve.
806
- if (typeof designServer.mirrorThreads !== "function") {
807
- note("Comment sync needs a newer lecodes-design (npm install -g lecodes-design@latest) — serving without it.")
808
- return null
809
- }
810
- let failures = 0
811
- const timer: ReturnType<typeof setInterval> = setInterval(() => { void tick() }, COMMENT_SYNC_MS)
812
- timer.unref?.()
813
-
814
- async function tick() {
815
- try {
816
- const open = await fetchOpen(platform!)
817
- failures = 0
818
- const summary = designServer.mirrorThreads(ctx.designDir, open)
819
- if (summary.added || summary.updated || summary.closed) {
820
- note(`comments ← platform (${open.length} open)`)
821
- }
822
- } catch (e) {
823
- // Offline, revoked token, project gone: say it once and give up rather than logging forever.
824
- // The board keeps working off the files, and `comments pull` reports the real error on demand.
825
- if (++failures === 1) warnErr(`Comment sync paused — ${errText(e)}`)
826
- if (failures >= 3) clearInterval(timer)
827
- }
828
- }
829
- void tick()
830
- return () => clearInterval(timer)
831
- }
832
-
833
- // ---- dispatch ----------------------------------------------------------------------------------
834
-
835
- export const design = async (args: Args) => {
836
- const sub = args._[0] ?? "serve"
837
- const ctx = getContext(args)
838
- if (sub === "serve") return serve(ctx, args)
839
- if (sub === "init") return init(ctx, args)
840
- if (sub === "snapshot") return snapshot(ctx, args)
841
- if (sub === "arrange") return arrange(ctx, args)
842
- if (sub === "check") return check(ctx, args)
843
- if (sub === "share") return share(ctx, args)
844
- if (sub === "comments") return commentsCmd(ctx, args)
845
- throw new CliError(`Unknown design subcommand "${sub}" (serve | init | snapshot | arrange | check | share | comments).`)
846
- }