lecodes-cli 0.20.2 → 2.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 (154) hide show
  1. package/README.md +56 -57
  2. package/dist/index.js +11052 -7592
  3. package/package.json +14 -8
  4. package/runtime/materials/decal-relief.mat +12 -1
  5. package/runtime/materials/decal.mat +17 -3
  6. package/runtime/materials/lightmap.mat +117 -138
  7. package/runtime/materials/particles-quad.mat +6 -2
  8. package/runtime/materials/terrain-lightmap.mat +123 -0
  9. package/runtime/materials/terrain.mat +36 -55
  10. package/runtime/sdk-types.json +1 -1
  11. package/runtime/web/VERSION.json +5 -0
  12. package/runtime/web/assets/camera-5XOM8eHl.js +1 -0
  13. package/runtime/web/assets/creator-2d-D9WKYM6h.js +1 -0
  14. package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
  15. package/runtime/web/assets/creator-full-BBXzQlhq.js +1 -0
  16. package/runtime/web/assets/creator-full-D2dSDdDj.wasm +0 -0
  17. package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +8124 -0
  18. package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
  19. package/runtime/web/assets/creator-ui-_1Z_AX2M.js +1 -0
  20. package/runtime/web/assets/geolocation-BjTKN9_P.js +1 -0
  21. package/runtime/web/assets/inspect-C7x78PNs.js +2 -0
  22. package/runtime/web/assets/map-CyBxOSV0.js +1 -0
  23. package/runtime/web/assets/map-XzoOFt0E.js +2 -0
  24. package/runtime/web/assets/mapImpl-bxvQ2h1Z.js +823 -0
  25. package/runtime/web/assets/maplibre-gl-worker-B7LXXUkR.js +8 -0
  26. package/runtime/web/assets/materials-uri4FvPE.bin +0 -0
  27. package/runtime/web/assets/materials-world-CQWt8TAm.bin +0 -0
  28. package/runtime/web/assets/qr-scanner-CK_cYoJR.js +1 -0
  29. package/runtime/web/assets/uberarchive-DLH-kBhM.bin +0 -0
  30. package/runtime/web/index.html +36 -0
  31. package/runtime/web/player.js +21 -0
  32. package/runtime/web-page/index.html +41 -0
  33. package/runtime/web-page/page.css +1 -0
  34. package/runtime/web-page/page.js +31 -0
  35. package/src/api.ts +1 -302
  36. package/src/cli/args.ts +37 -0
  37. package/src/cli/command.ts +113 -0
  38. package/src/cli/errors.ts +13 -0
  39. package/src/cli/help.ts +158 -0
  40. package/src/cli/index.ts +7 -0
  41. package/src/cli/output.ts +102 -0
  42. package/src/cli/run.ts +106 -0
  43. package/src/commands/{appAndroid.ts → app/android.ts} +454 -43
  44. package/src/commands/{appDesktop.ts → app/desktop.ts} +64 -33
  45. package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +34 -27
  46. package/src/commands/{appIcon.ts → app/icon.ts} +11 -8
  47. package/src/commands/{app.ts → app/index.ts} +400 -290
  48. package/src/commands/{appShared.ts → app/shared.ts} +43 -44
  49. package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +72 -39
  50. package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
  51. package/src/commands/{appTemplates.ts → app/templates/ios.ts} +2 -2
  52. package/src/commands/assets.ts +23 -21
  53. package/src/commands/clone.ts +48 -51
  54. package/src/commands/compile.ts +76 -89
  55. package/src/commands/create.ts +46 -47
  56. package/src/commands/design/comments.ts +361 -0
  57. package/src/commands/design/context.ts +101 -0
  58. package/src/commands/design/index.ts +391 -0
  59. package/src/commands/design/snapshot.ts +134 -0
  60. package/src/commands/{designTemplates.ts → design/templates.ts} +4 -3
  61. package/src/commands/desktop.ts +94 -82
  62. package/src/commands/dev.ts +260 -182
  63. package/src/commands/diff.ts +39 -51
  64. package/src/commands/index.ts +64 -0
  65. package/src/commands/{init.ts → init/index.ts} +195 -191
  66. package/src/commands/{projectTemplates.ts → init/templates.ts} +64 -21
  67. package/src/commands/install.ts +80 -67
  68. package/src/commands/lightmap.ts +543 -252
  69. package/src/commands/link.ts +87 -94
  70. package/src/commands/login.ts +25 -23
  71. package/src/commands/navmesh.ts +169 -139
  72. package/src/commands/plugin.ts +46 -0
  73. package/src/commands/pn.ts +201 -244
  74. package/src/commands/pull.ts +49 -56
  75. package/src/commands/push.ts +48 -54
  76. package/src/commands/render.ts +176 -111
  77. package/src/commands/scene.ts +63 -55
  78. package/src/commands/serve.ts +58 -0
  79. package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
  80. package/src/commands/{shadersNew.ts → shaders/new.ts} +3 -3
  81. package/src/commands/shared.ts +79 -0
  82. package/src/commands/status.ts +22 -26
  83. package/src/commands/test.ts +94 -63
  84. package/src/commands/thumbs.ts +185 -175
  85. package/src/commands/update/index.ts +226 -0
  86. package/src/commands/{types.ts → update/types.ts} +31 -22
  87. package/src/compile/collect.ts +5 -5
  88. package/src/compile/collectLocal.ts +3 -3
  89. package/src/compile/designCompile.ts +4 -4
  90. package/src/compile/headlessBundle.ts +16 -12
  91. package/src/compile/nativeStack.ts +1 -1
  92. package/src/compile/projectCompile.ts +12 -4
  93. package/src/compile/sceneCompile.ts +14 -26
  94. package/src/compile/screenEntry.ts +123 -127
  95. package/src/compile/shaders.ts +3 -3
  96. package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +4 -0
  97. package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +1 -1
  98. package/src/{lecodes-renderer.d.ts → declarations/lecodes-headless.d.ts} +3 -3
  99. package/src/dev/androidDev.ts +1 -1
  100. package/src/dev/clientTemplates.ts +18 -14
  101. package/src/dev/devServer.ts +78 -41
  102. package/src/dev/iosDev.ts +180 -0
  103. package/src/dev/webRunner.ts +83 -15
  104. package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +2 -2
  105. package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +104 -32
  106. package/src/{desktopScript.ts → hosts/desktopScript.ts} +151 -36
  107. package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
  108. package/src/{matcTool.ts → hosts/matcTool.ts} +2 -2
  109. package/src/{peerInstall.ts → hosts/peerInstall.ts} +7 -7
  110. package/src/{peers.ts → hosts/peers.ts} +2 -2
  111. package/src/{releases.ts → hosts/releases.ts} +1 -1
  112. package/src/index.ts +35 -480
  113. package/src/platform/api.ts +302 -0
  114. package/src/{browserAuth.ts → platform/browserAuth.ts} +1 -1
  115. package/src/{config.ts → platform/config.ts} +1 -1
  116. package/src/{serverDiff.ts → platform/serverDiff.ts} +3 -3
  117. package/src/plugin/contract.ts +438 -0
  118. package/src/plugin/emitKotlin.ts +357 -0
  119. package/src/plugin/emitSdk.ts +259 -0
  120. package/src/plugin/emitSwift.ts +336 -0
  121. package/src/plugin/emitWeb.ts +383 -0
  122. package/src/plugin/generate.ts +157 -0
  123. package/src/plugin/lines.ts +28 -0
  124. package/src/plugin/lower.ts +231 -0
  125. package/src/plugin/manifest.ts +192 -0
  126. package/src/{projectEnv.ts → project/env.ts} +33 -2
  127. package/src/{ignore.ts → project/ignore.ts} +164 -163
  128. package/src/{localFiles.ts → project/localFiles.ts} +1 -1
  129. package/src/{manifest.ts → project/manifest.ts} +1 -1
  130. package/src/{project.ts → project/materialize.ts} +2 -2
  131. package/src/project/paths.ts +20 -0
  132. package/src/{textDiff.ts → project/textDiff.ts} +1 -1
  133. package/src/{types.ts → project/types.ts} +0 -0
  134. package/runtime/materials/lightmap-baked-lite.mat +0 -175
  135. package/runtime/materials/lightmap-baked.mat +0 -176
  136. package/runtime/scene-harness.json +0 -1
  137. package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +0 -1
  138. package/runtime/web/assets/basis-C64VHDVD.js +0 -1
  139. package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  140. package/runtime/web/assets/createViewerLite-Ct_PZdof.js +0 -852
  141. package/runtime/web/assets/draco-BiISTFcR.js +0 -118
  142. package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  143. package/runtime/web/assets/index-BMt7AnC5.js +0 -2
  144. package/runtime/web/assets/mapViewImpl-B2JcES8l.js +0 -810
  145. package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +0 -8
  146. package/runtime/web/assets/worker-Caf-yYEI.js +0 -2
  147. package/runtime/web/embed.html +0 -32
  148. package/runtime/web/embed.js +0 -171
  149. package/src/commands/design.ts +0 -846
  150. package/src/commands/update.ts +0 -211
  151. package/src/util.ts +0 -146
  152. /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
  153. /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
  154. /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
@@ -0,0 +1,361 @@
1
+ import { existsSync } from "node:fs"
2
+ import type { CommentOrigin, CommentThread, MirrorSummary } from "lecodes-design/server"
3
+ import { CliError, UsageError, bool, c, defineCommand, log, note, str, warnErr } from "../../cli"
4
+ import { loadPeer } from "../../hosts/peerInstall"
5
+ import { createDesignComment, listDesignComments, replyDesignComment, resolveDesignComment } from "../../platform/api"
6
+ import {
7
+ DEFAULT_DIR, dirFlag, errText, getContext, loadDesignServer, optionalPlatform, requirePlatform, threadRef,
8
+ type DesignContext, type DesignServer, type Platform,
9
+ } from "./context"
10
+
11
+ /*
12
+ * `lecodes design comments` — the board's review comments in the terminal: the same threads the
13
+ * canvas pins, grouped by screen, open first. The write verbs (add / reply / resolve) are the
14
+ * server-down path — same store, same rules as the MCP tools.
15
+ *
16
+ * Reads `design/comments/` directly (no server needed). On the platform those files mirror the
17
+ * database — `comments pull` refreshes them, and the running board keeps them fed — but for a
18
+ * standalone design folder they ARE the store, so everything here works with or without a project.
19
+ *
20
+ * Direction is fixed and it is what keeps this small: the platform owns the conversation, the design
21
+ * folder mirrors its OPEN threads, and **nothing leaves this machine except via `lecodes push`**. So
22
+ * there are exactly two moving parts — a pull (safe any time, feedback arriving early is just
23
+ * feedback) and `flushDesignComments`, which rides along with the design push. A reply cannot reach
24
+ * a reviewer before the work it describes, because they are the same command in that order.
25
+ */
26
+
27
+ // ---- write verbs -------------------------------------------------------------------------------
28
+
29
+ const aiFlag = { ai: bool("sign the write as Claude (automatic inside a Claude Code session)") }
30
+
31
+ /**
32
+ * Attribution for a CLI write. The developer's git name by default; Claude when `--ai` says so, or
33
+ * when the command is visibly running inside a Claude session (Claude Code's shell sets CLAUDECODE).
34
+ * The reviewer reading the answer is owed knowing a machine wrote it, so err toward the badge.
35
+ */
36
+ const cliAuthor = (ai: boolean): { author: string, origin: CommentOrigin } | undefined =>
37
+ ai || process.env.CLAUDECODE ? { author: "Claude", origin: "ai" } : undefined
38
+
39
+ /** Where a write just went — said after every verb, because a finished-looking comment that hasn't
40
+ * reached the reviewer yet is the misunderstanding this whole queue exists to prevent. */
41
+ const commentDelivery = (ctx: DesignContext): string =>
42
+ optionalPlatform(ctx)
43
+ ? "queued — it goes out with the next `lecodes push`, after the design it describes"
44
+ : "saved — this folder is the record"
45
+
46
+ const findThreadByNumber = (designServer: DesignServer, ctx: DesignContext, raw: string | undefined): CommentThread => {
47
+ const number = Number(raw)
48
+ if (!Number.isInteger(number) || number <= 0) {
49
+ throw new CliError(`"${raw ?? ""}" is not a thread number — see: lecodes design comments`)
50
+ }
51
+ const thread = designServer.listDesignComments(ctx.designDir).threads.find((t) => t.number === number)
52
+ if (!thread) throw new CliError(`No comment #${number} on this design — see: lecodes design comments`)
53
+ return thread
54
+ }
55
+
56
+ /** Start a thread from the terminal — Claude asking the reviewer something with no server running,
57
+ * or the developer leaving themselves a note. Pin it to a real screen so it never lands detached. */
58
+ const add = defineCommand({
59
+ name: "add",
60
+ summary: "Start a thread from the terminal, pinned to a screen",
61
+ usage: '"<text>"',
62
+ flags: {
63
+ ...dirFlag,
64
+ screen: str("pin it to this screen (checked against the design)", { value: "<id>" }),
65
+ state: str("…in this state of the screen", { value: "<s>" }),
66
+ element: str("…on this named element", { value: "<name>" }),
67
+ ...aiFlag,
68
+ },
69
+ examples: ['lecodes design comments add "Is the CTA meant to be this small?" --screen login --element cta'],
70
+ run: async ({ args, flags }) => {
71
+ const ctx = getContext(flags.dir)
72
+ const body = (args[0] ?? "").trim()
73
+ if (!body) throw new UsageError("add needs the comment text.")
74
+ const designServer = await loadDesignServer(ctx, [ "startThread", "readDesignState" ])
75
+ const screen = flags.screen?.trim() || undefined
76
+ if (screen) {
77
+ const ids = designServer.readDesignState(ctx.designDir).screens.map((s) => s.id)
78
+ if (!ids.includes(screen)) {
79
+ throw new CliError(`No screen "${screen}" in ${ctx.dirName}/ — the pin would point at nothing. Have: ${ids.join(", ") || "(none)"}.`)
80
+ }
81
+ }
82
+ const element = flags.element?.trim() || undefined
83
+ const thread = designServer.startThread(ctx.designDir, {
84
+ anchor: {
85
+ screen,
86
+ state: flags.state?.trim() || undefined,
87
+ ...(element ? { target: { name: element } } : {}),
88
+ },
89
+ body,
90
+ ...cliAuthor(flags.ai),
91
+ }, { linked: !!optionalPlatform(ctx) })
92
+ log(`${c.green("✓")} ${threadRef(thread)} added — ${commentDelivery(ctx)}`)
93
+ },
94
+ })
95
+
96
+ const reply = defineCommand({
97
+ name: "reply",
98
+ summary: "Answer a thread",
99
+ usage: '<number> "<text>"',
100
+ flags: { ...dirFlag, ...aiFlag },
101
+ examples: ['lecodes design comments reply 3 "Bumped it to 48px — see the new login state."'],
102
+ run: async ({ args, flags }) => {
103
+ const ctx = getContext(flags.dir)
104
+ const body = (args[1] ?? "").trim()
105
+ if (!body) throw new UsageError("reply needs a thread number and the reply text.")
106
+ const designServer = await loadDesignServer(ctx, [ "replyToThread" ])
107
+ const thread = findThreadByNumber(designServer, ctx, args[0])
108
+ designServer.replyToThread(ctx.designDir, thread.id, body, cliAuthor(flags.ai))
109
+ log(`${c.green("✓")} replied to #${thread.number} — ${commentDelivery(ctx)}`)
110
+ },
111
+ })
112
+
113
+ /** Close (or `--reopen`) a thread, optionally replying first — same order as the MCP tool, so the
114
+ * reviewer always sees the "what was done" before the checkmark that claims it. */
115
+ const resolve = defineCommand({
116
+ name: "resolve",
117
+ summary: "Close a thread (or reopen it), optionally replying first",
118
+ usage: "<number>",
119
+ flags: {
120
+ ...dirFlag,
121
+ reply: str("post this reply before closing", { value: "<text>" }),
122
+ reopen: bool("reopen the thread instead of closing it"),
123
+ ...aiFlag,
124
+ },
125
+ examples: ['lecodes design comments resolve 3 --reply "Done."', "lecodes design comments resolve 3 --reopen"],
126
+ run: async ({ args, flags }) => {
127
+ const ctx = getContext(flags.dir)
128
+ const designServer = await loadDesignServer(ctx, [ "resolveThread", "replyToThread" ])
129
+ const thread = findThreadByNumber(designServer, ctx, args[0])
130
+ const text = flags.reply?.trim()
131
+ if (text) designServer.replyToThread(ctx.designDir, thread.id, text, cliAuthor(flags.ai))
132
+ designServer.resolveThread(ctx.designDir, thread.id, !flags.reopen)
133
+ log(`${c.green("✓")} #${thread.number} ${flags.reopen ? "reopened" : "resolved"} — ${commentDelivery(ctx)}`)
134
+ },
135
+ })
136
+
137
+ // ---- pull down, and the queue `lecodes push` drains --------------------------------------------
138
+
139
+ const reportMirror = (ctx: DesignContext, summary: MirrorSummary, open: number): void => {
140
+ const parts = [
141
+ summary.added ? `+${summary.added} new` : null,
142
+ summary.updated ? `${summary.updated} updated` : null,
143
+ summary.closed ? `${summary.closed} closed` : null,
144
+ ].filter((p): p is string => p !== null)
145
+ if (parts.length === 0) {
146
+ log(`Already up to date — ${open} open.`)
147
+ return
148
+ }
149
+ log(`${c.green("✓")} ${ctx.dirName}/comments/ — ${parts.join(", ")} ${c.dim(`(${open} open)`)}`)
150
+ }
151
+
152
+ /** The platform's open threads. Resolved ones aren't mirrored: the mirror is the work queue, and the
153
+ * platform keeps the record. */
154
+ const fetchOpen = async (platform: Platform): Promise<CommentThread[]> => {
155
+ const snapshot = await listDesignComments(platform.apiUrl, platform.token, platform.uuid)
156
+ return snapshot.threads.filter((t) => !t.resolved)
157
+ }
158
+
159
+ const pull = defineCommand({
160
+ name: "pull",
161
+ summary: "Mirror the platform's open threads into <dir>/comments/",
162
+ description: "Inbound only — writes go the other way with `lecodes push`, never on their own. Needs a cloned project; a standalone folder's comments/ is already the only store.",
163
+ flags: dirFlag,
164
+ examples: ["lecodes design comments pull"],
165
+ run: async ({ flags }) => {
166
+ const ctx = getContext(flags.dir)
167
+ const designServer = await loadDesignServer(ctx, [ "mirrorThreads" ])
168
+ const platform = requirePlatform(ctx, "pull")
169
+ const open = await fetchOpen(platform)
170
+ const summary = designServer.mirrorThreads(ctx.designDir, open)
171
+ reportMirror(ctx, summary, open.length)
172
+ if (open.length) note("Read them with: lecodes design comments")
173
+ },
174
+ })
175
+
176
+ /** Not a verb — the answer to the question, kept as a hidden node so the reflex gets the reason. */
177
+ const push = defineCommand({
178
+ name: "push",
179
+ summary: "(comments go up with `lecodes push`)",
180
+ hidden: true,
181
+ run: () => {
182
+ throw new CliError(
183
+ "Comments go up with the design, not on their own — run `lecodes push`.\n" +
184
+ " (A reply that arrives before the change it describes is worse than one that arrives late.)")
185
+ },
186
+ })
187
+
188
+ export type CommentFlush = { sent: number, threads: number, failures: string[] }
189
+
190
+ /**
191
+ * Send everything the design folder has queued, and settle each thread against what the platform says
192
+ * it is afterwards. Called by `lecodes push` once the design itself has landed — never before, which
193
+ * is the whole reason a reply can't claim a fix nobody can see yet.
194
+ *
195
+ * Returns null when there is nothing to do (no design folder, no project, no queue), so the caller
196
+ * stays a single `if`.
197
+ */
198
+ export const flushDesignComments = async (dir: string = DEFAULT_DIR): Promise<CommentFlush | null> => {
199
+ const ctx = getContext(dir)
200
+ if (!existsSync(ctx.designDir)) return null
201
+ const platform = optionalPlatform(ctx)
202
+ if (!platform) return null
203
+
204
+ const designServer = await loadPeer<DesignServer>("lecodes-design", "server", { for: "lecodes design", install: false })
205
+ if (!designServer) return null
206
+ // `settleQueued` must take `delivered` (4 params): settling a half-failed push with the older
207
+ // signature would drop the writes that DIDN'T go through instead of retrying them.
208
+ if (typeof designServer.queuedThreads !== "function") return null
209
+ if (typeof designServer.settleQueued !== "function" || designServer.settleQueued.length < 4) return null
210
+
211
+ const queued = designServer.queuedThreads(ctx.designDir)
212
+ if (queued.length === 0) return null
213
+
214
+ const { apiUrl, token, uuid } = platform
215
+ const result: CommentFlush = { sent: 0, threads: 0, failures: [] }
216
+
217
+ for (const item of queued) {
218
+ const { thread, pending } = item
219
+ /** What the platform says the thread is after the last delivered write. */
220
+ let upstream: CommentThread | null = null
221
+ /** LOCAL ids of what actually landed — a push can die halfway, and only these get settled. */
222
+ const delivered = { messageIds: new Set<string>(), resolve: false }
223
+ try {
224
+ if (pending.newThread) {
225
+ const [ opening, ...rest ] = thread.messages
226
+ if (!opening) continue
227
+ upstream = await createDesignComment(apiUrl, token, uuid, {
228
+ anchor: thread.anchor, body: opening.body, origin: opening.origin,
229
+ })
230
+ result.sent++
231
+ delivered.messageIds.add(opening.id)
232
+ for (const message of rest) {
233
+ upstream = await replyDesignComment(apiUrl, token, uuid, upstream.id, message.body, message.origin)
234
+ result.sent++
235
+ delivered.messageIds.add(message.id)
236
+ }
237
+ } else {
238
+ for (const message of pending.messages) {
239
+ upstream = await replyDesignComment(apiUrl, token, uuid, thread.id, message.body, message.origin)
240
+ result.sent++
241
+ delivered.messageIds.add(message.id)
242
+ }
243
+ }
244
+
245
+ if (pending.resolve) {
246
+ upstream = await resolveDesignComment(apiUrl, token, uuid, upstream?.id ?? thread.id, thread.resolved)
247
+ result.sent++
248
+ delivered.resolve = true
249
+ }
250
+ } catch (e) {
251
+ // The undelivered remainder stays queued: the next push retries it. What must never happen is
252
+ // the file claiming something was sent when it wasn't.
253
+ result.failures.push(`${threadRef(thread)}: ${errText(e)}`)
254
+ }
255
+ // Settle whatever DID land, even after a mid-thread failure — adopting the platform's copy is
256
+ // what stops the next push from sending it again as a duplicate.
257
+ if (upstream) {
258
+ designServer.settleQueued(ctx.designDir, item, upstream, delivered)
259
+ result.threads++
260
+ }
261
+ }
262
+ return result
263
+ }
264
+
265
+ /** How often the running board re-reads the platform's comments. */
266
+ const COMMENT_SYNC_MS = 20_000
267
+
268
+ /**
269
+ * Keep `design/comments/` fed while the canvas runs, so feedback left on the share link reaches the
270
+ * board — and Claude — without anyone remembering to pull.
271
+ *
272
+ * Strictly inbound. Writing the files is what refreshes the board: the fs watcher sees the directory
273
+ * change and pushes the SSE `comments` flag, exactly as it does for a hand edit.
274
+ */
275
+ export const startCommentSync = (ctx: DesignContext, designServer: DesignServer): (() => void) | null => {
276
+ const platform = optionalPlatform(ctx)
277
+ if (!platform) return null
278
+ // An older lecodes-design has no mirror to drive. The board still works off the files, so losing
279
+ // the sync is not a reason to refuse to serve.
280
+ if (typeof designServer.mirrorThreads !== "function") {
281
+ note("Comment sync needs a newer lecodes-design (npm install -g lecodes-design@latest) — serving without it.")
282
+ return null
283
+ }
284
+ let failures = 0
285
+ const timer: ReturnType<typeof setInterval> = setInterval(() => { void tick() }, COMMENT_SYNC_MS)
286
+ timer.unref?.()
287
+
288
+ const tick = async (): Promise<void> => {
289
+ try {
290
+ const open = await fetchOpen(platform)
291
+ failures = 0
292
+ const summary = designServer.mirrorThreads(ctx.designDir, open)
293
+ if (summary.added || summary.updated || summary.closed) {
294
+ note(`comments ← platform (${open.length} open)`)
295
+ }
296
+ } catch (e) {
297
+ // Offline, revoked token, project gone: say it once and give up rather than logging forever.
298
+ // The board keeps working off the files, and `comments pull` reports the real error on demand.
299
+ if (++failures === 1) warnErr(`Comment sync paused — ${errText(e)}`)
300
+ if (failures >= 3) clearInterval(timer)
301
+ }
302
+ }
303
+ void tick()
304
+ return () => clearInterval(timer)
305
+ }
306
+
307
+ // ---- the list (bare `comments`) ----------------------------------------------------------------
308
+
309
+ export default defineCommand({
310
+ name: "comments",
311
+ summary: "Review comments pinned to the board, grouped by screen (open first)",
312
+ description: "Reads <dir>/comments/ directly, no server needed. Inside a cloned project those files mirror the platform's open threads (`comments pull` refreshes them; the running board keeps them in sync); standalone they are the store. Writes (add / reply / resolve) queue locally and go up with the next `lecodes push`, after the design they describe.",
313
+ flags: { ...dirFlag, all: bool("include resolved threads") },
314
+ commands: [add, reply, resolve, pull, push],
315
+ examples: ["lecodes design comments", "lecodes design comments --all"],
316
+ run: async ({ args, flags }) => {
317
+ if (args.length) throw new UsageError(`Unknown comments subcommand "${args[0]}" (add, reply, resolve, pull, or nothing to list).`)
318
+ const ctx = getContext(flags.dir)
319
+ const designServer = await loadDesignServer(ctx)
320
+
321
+ const { threads } = designServer.listDesignComments(ctx.designDir)
322
+ const shown = flags.all ? threads : threads.filter((t) => !t.resolved)
323
+ const open = threads.filter((t) => !t.resolved).length
324
+
325
+ if (threads.length === 0) {
326
+ log("No comments on this design yet.")
327
+ note("Open the board (lecodes design) and use 💬 Comment, or share it for review.")
328
+ return
329
+ }
330
+ if (shown.length === 0) {
331
+ log(`${c.green("✓")} Nothing open — all ${threads.length} comment${threads.length === 1 ? "" : "s"} resolved.`)
332
+ note("See them with: lecodes design comments --all")
333
+ return
334
+ }
335
+
336
+ // Group by anchor so the list reads like a walk of the board, not a feed.
337
+ const byScreen = new Map<string, typeof shown>()
338
+ for (const thread of shown) {
339
+ const key = thread.anchor.screen ?? "(detached)"
340
+ byScreen.set(key, [ ...(byScreen.get(key) ?? []), thread ])
341
+ }
342
+ // Unpushed drafts have no number yet; they sort last rather than pretending to be #0.
343
+ const order = (t: { number?: number }) => t.number ?? Number.MAX_SAFE_INTEGER
344
+ for (const [ screen, list ] of [ ...byScreen ].sort((a, b) => a[0].localeCompare(b[0]))) {
345
+ log(c.bold(screen))
346
+ for (const thread of list.sort((a, b) => order(a) - order(b))) {
347
+ const target = thread.anchor.target
348
+ const where = [ thread.anchor.state && `@${thread.anchor.state}`, target?.name ?? target?.text ]
349
+ .filter(Boolean).join(" · ")
350
+ const head = ` ${c.bold(threadRef(thread))} ${thread.messages[0]?.author ?? "—"}`
351
+ log(`${head}${where ? c.dim(` ${where}`) : ""}${thread.resolved ? c.green(" ✓ resolved") : ""}`)
352
+ for (const message of thread.messages) {
353
+ for (const line of message.body.split("\n")) log(` ${c.dim("│")} ${line}`)
354
+ }
355
+ if (thread.messages.length > 1) log(` ${c.dim(`${thread.messages.length} messages`)}`)
356
+ }
357
+ log("")
358
+ }
359
+ log(`${open} open · ${threads.length} total`)
360
+ },
361
+ })
@@ -0,0 +1,101 @@
1
+ import { existsSync } from "node:fs"
2
+ import { basename, join } from "node:path"
3
+ import { CliError, str } from "../../cli"
4
+ import { loadPeer } from "../../hosts/peerInstall"
5
+ import { loadConfig, normalizeApiUrl } from "../../platform/config"
6
+ import { findProjectRoot, readManifest, type Manifest } from "../../project/manifest"
7
+ import { session } from "../shared"
8
+
9
+ /*
10
+ * What every `lecodes design …` verb starts from: where the design folder is (a cloned project's
11
+ * root, else the current directory standing alone), the optional `lecodes-design` server package,
12
+ * and the platform link a comment sync needs. Kept apart from the verbs so comments.ts and
13
+ * snapshot.ts share it without importing the command tree.
14
+ */
15
+
16
+ export const DEFAULT_DIR = "design"
17
+ export const DEFAULT_PORT = 4477
18
+
19
+ /** `--dir`, on every verb: the design folder, relative to the project root. */
20
+ export const dirFlag = { dir: str("design folder", { value: "<folder>", default: DEFAULT_DIR }) }
21
+
22
+ export type DesignServer = typeof import("lecodes-design/server")
23
+
24
+ export type DesignContext = {
25
+ root: string
26
+ /** Null in standalone mode: the design folder lives outside any cloned lecodes project. */
27
+ manifest: Manifest | null
28
+ name: string
29
+ dirName: string
30
+ designDir: string
31
+ designKey: string
32
+ }
33
+
34
+ /** Inside a cloned project the design folder sits at its root; anywhere else the tool runs
35
+ * standalone against the current directory (no login, no server project needed). */
36
+ export const getContext = (dir: string): DesignContext => {
37
+ let root: string
38
+ let manifest: Manifest | null
39
+ try {
40
+ root = findProjectRoot(process.cwd())
41
+ manifest = readManifest(root)
42
+ } catch {
43
+ root = process.cwd()
44
+ manifest = null
45
+ }
46
+ const dirName = dir.replace(/[/\\]+$/, "")
47
+ return {
48
+ root, manifest,
49
+ name: manifest?.name ?? basename(root),
50
+ dirName,
51
+ designDir: join(root, dirName),
52
+ designKey: "/" + dirName.replace(/\\/g, "/"),
53
+ }
54
+ }
55
+
56
+ /** The design canvas package, lazily: it's an optional dev dependency of the project, never a
57
+ * dependency of the CLI. Also asserts the folder exists, since every caller needs both.
58
+ *
59
+ * `needs` names exports the caller will actually call. The two packages are versioned and installed
60
+ * independently, so a project can easily hold a CLI newer than its `lecodes-design` — without this
61
+ * check that surfaces as `undefined is not a function` halfway through a command. */
62
+ export const loadDesignServer = async (ctx: DesignContext, needs: (keyof DesignServer)[] = []): Promise<DesignServer> => {
63
+ if (!existsSync(ctx.designDir)) {
64
+ throw new CliError(`No ${ctx.dirName}/ folder here. Run "lecodes design init" to scaffold it.`)
65
+ }
66
+ const mod = await loadPeer<DesignServer>("lecodes-design", "server", { for: "lecodes design" })
67
+ const missing = needs.filter((name) => typeof mod[name] !== "function")
68
+ if (missing.length) {
69
+ throw new CliError(
70
+ `This needs a newer lecodes-design — the installed one has no \`${missing[0]}\`. Run: npm install -g lecodes-design@latest`)
71
+ }
72
+ return mod
73
+ }
74
+
75
+ export type Platform = { apiUrl: string, token: string, uuid: string }
76
+
77
+ /** The platform link a comment sync needs. A standalone design folder has no database behind it —
78
+ * there `comments/` IS the store, so saying that beats prompting for a login. */
79
+ export const requirePlatform = (ctx: DesignContext, verb: string): Platform => {
80
+ if (!ctx.manifest) {
81
+ throw new CliError(
82
+ `\`lecodes design comments ${verb}\` needs a cloned project. This folder is standalone, so ` +
83
+ `${ctx.dirName}/comments/ is already the only store — there is nothing to sync with.`)
84
+ }
85
+ return { ...session(), uuid: ctx.manifest.uuid }
86
+ }
87
+
88
+ /** The same link, but optional — null when standalone or not logged in. Used where syncing is a
89
+ * bonus rather than the point (the dev server's mirror, the push-time flush). */
90
+ export const optionalPlatform = (ctx: DesignContext): Platform | null => {
91
+ if (!ctx.manifest) return null
92
+ const config = loadConfig()
93
+ if (!config.apiUrl || !config.token) return null
94
+ return { apiUrl: normalizeApiUrl(config.apiUrl), token: config.token, uuid: ctx.manifest.uuid }
95
+ }
96
+
97
+ export const errText = (e: unknown): string => (e instanceof Error ? e.message : String(e))
98
+
99
+ /** How a thread is referred to. Duplicated from the design package rather than imported: that package
100
+ * is an optional peer, so a value import here would break the CLI wherever it isn't installed. */
101
+ export const threadRef = (thread: { number?: number }): string => (thread.number === undefined ? "new" : `#${thread.number}`)