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.
- package/README.md +56 -57
- package/dist/index.js +11052 -7592
- package/package.json +14 -8
- package/runtime/materials/decal-relief.mat +12 -1
- package/runtime/materials/decal.mat +17 -3
- package/runtime/materials/lightmap.mat +117 -138
- package/runtime/materials/particles-quad.mat +6 -2
- package/runtime/materials/terrain-lightmap.mat +123 -0
- package/runtime/materials/terrain.mat +36 -55
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +5 -0
- package/runtime/web/assets/camera-5XOM8eHl.js +1 -0
- package/runtime/web/assets/creator-2d-D9WKYM6h.js +1 -0
- package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
- package/runtime/web/assets/creator-full-BBXzQlhq.js +1 -0
- package/runtime/web/assets/creator-full-D2dSDdDj.wasm +0 -0
- package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +8124 -0
- package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
- package/runtime/web/assets/creator-ui-_1Z_AX2M.js +1 -0
- package/runtime/web/assets/geolocation-BjTKN9_P.js +1 -0
- package/runtime/web/assets/inspect-C7x78PNs.js +2 -0
- package/runtime/web/assets/map-CyBxOSV0.js +1 -0
- package/runtime/web/assets/map-XzoOFt0E.js +2 -0
- package/runtime/web/assets/mapImpl-bxvQ2h1Z.js +823 -0
- package/runtime/web/assets/maplibre-gl-worker-B7LXXUkR.js +8 -0
- package/runtime/web/assets/materials-uri4FvPE.bin +0 -0
- package/runtime/web/assets/materials-world-CQWt8TAm.bin +0 -0
- package/runtime/web/assets/qr-scanner-CK_cYoJR.js +1 -0
- package/runtime/web/assets/uberarchive-DLH-kBhM.bin +0 -0
- package/runtime/web/index.html +36 -0
- package/runtime/web/player.js +21 -0
- package/runtime/web-page/index.html +41 -0
- package/runtime/web-page/page.css +1 -0
- package/runtime/web-page/page.js +31 -0
- package/src/api.ts +1 -302
- package/src/cli/args.ts +37 -0
- package/src/cli/command.ts +113 -0
- package/src/cli/errors.ts +13 -0
- package/src/cli/help.ts +158 -0
- package/src/cli/index.ts +7 -0
- package/src/cli/output.ts +102 -0
- package/src/cli/run.ts +106 -0
- package/src/commands/{appAndroid.ts → app/android.ts} +454 -43
- package/src/commands/{appDesktop.ts → app/desktop.ts} +64 -33
- package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +34 -27
- package/src/commands/{appIcon.ts → app/icon.ts} +11 -8
- package/src/commands/{app.ts → app/index.ts} +400 -290
- package/src/commands/{appShared.ts → app/shared.ts} +43 -44
- package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +72 -39
- package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
- package/src/commands/{appTemplates.ts → app/templates/ios.ts} +2 -2
- package/src/commands/assets.ts +23 -21
- package/src/commands/clone.ts +48 -51
- package/src/commands/compile.ts +76 -89
- package/src/commands/create.ts +46 -47
- package/src/commands/design/comments.ts +361 -0
- package/src/commands/design/context.ts +101 -0
- package/src/commands/design/index.ts +391 -0
- package/src/commands/design/snapshot.ts +134 -0
- package/src/commands/{designTemplates.ts → design/templates.ts} +4 -3
- package/src/commands/desktop.ts +94 -82
- package/src/commands/dev.ts +260 -182
- package/src/commands/diff.ts +39 -51
- package/src/commands/index.ts +64 -0
- package/src/commands/{init.ts → init/index.ts} +195 -191
- package/src/commands/{projectTemplates.ts → init/templates.ts} +64 -21
- package/src/commands/install.ts +80 -67
- package/src/commands/lightmap.ts +543 -252
- package/src/commands/link.ts +87 -94
- package/src/commands/login.ts +25 -23
- package/src/commands/navmesh.ts +169 -139
- package/src/commands/plugin.ts +46 -0
- package/src/commands/pn.ts +201 -244
- package/src/commands/pull.ts +49 -56
- package/src/commands/push.ts +48 -54
- package/src/commands/render.ts +176 -111
- package/src/commands/scene.ts +63 -55
- package/src/commands/serve.ts +58 -0
- package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
- package/src/commands/{shadersNew.ts → shaders/new.ts} +3 -3
- package/src/commands/shared.ts +79 -0
- package/src/commands/status.ts +22 -26
- package/src/commands/test.ts +94 -63
- package/src/commands/thumbs.ts +185 -175
- package/src/commands/update/index.ts +226 -0
- package/src/commands/{types.ts → update/types.ts} +31 -22
- package/src/compile/collect.ts +5 -5
- package/src/compile/collectLocal.ts +3 -3
- package/src/compile/designCompile.ts +4 -4
- package/src/compile/headlessBundle.ts +16 -12
- package/src/compile/nativeStack.ts +1 -1
- package/src/compile/projectCompile.ts +12 -4
- package/src/compile/sceneCompile.ts +14 -26
- package/src/compile/screenEntry.ts +123 -127
- package/src/compile/shaders.ts +3 -3
- package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +4 -0
- package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +1 -1
- package/src/{lecodes-renderer.d.ts → declarations/lecodes-headless.d.ts} +3 -3
- package/src/dev/androidDev.ts +1 -1
- package/src/dev/clientTemplates.ts +18 -14
- package/src/dev/devServer.ts +78 -41
- package/src/dev/iosDev.ts +180 -0
- package/src/dev/webRunner.ts +83 -15
- package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +2 -2
- package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +104 -32
- package/src/{desktopScript.ts → hosts/desktopScript.ts} +151 -36
- package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
- package/src/{matcTool.ts → hosts/matcTool.ts} +2 -2
- package/src/{peerInstall.ts → hosts/peerInstall.ts} +7 -7
- package/src/{peers.ts → hosts/peers.ts} +2 -2
- package/src/{releases.ts → hosts/releases.ts} +1 -1
- package/src/index.ts +35 -480
- package/src/platform/api.ts +302 -0
- package/src/{browserAuth.ts → platform/browserAuth.ts} +1 -1
- package/src/{config.ts → platform/config.ts} +1 -1
- package/src/{serverDiff.ts → platform/serverDiff.ts} +3 -3
- package/src/plugin/contract.ts +438 -0
- package/src/plugin/emitKotlin.ts +357 -0
- package/src/plugin/emitSdk.ts +259 -0
- package/src/plugin/emitSwift.ts +336 -0
- package/src/plugin/emitWeb.ts +383 -0
- package/src/plugin/generate.ts +157 -0
- package/src/plugin/lines.ts +28 -0
- package/src/plugin/lower.ts +231 -0
- package/src/plugin/manifest.ts +192 -0
- package/src/{projectEnv.ts → project/env.ts} +33 -2
- package/src/{ignore.ts → project/ignore.ts} +164 -163
- package/src/{localFiles.ts → project/localFiles.ts} +1 -1
- package/src/{manifest.ts → project/manifest.ts} +1 -1
- package/src/{project.ts → project/materialize.ts} +2 -2
- package/src/project/paths.ts +20 -0
- package/src/{textDiff.ts → project/textDiff.ts} +1 -1
- package/src/{types.ts → project/types.ts} +0 -0
- package/runtime/materials/lightmap-baked-lite.mat +0 -175
- package/runtime/materials/lightmap-baked.mat +0 -176
- package/runtime/scene-harness.json +0 -1
- package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +0 -1
- package/runtime/web/assets/basis-C64VHDVD.js +0 -1
- package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/runtime/web/assets/createViewerLite-Ct_PZdof.js +0 -852
- package/runtime/web/assets/draco-BiISTFcR.js +0 -118
- package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
- package/runtime/web/assets/index-BMt7AnC5.js +0 -2
- package/runtime/web/assets/mapViewImpl-B2JcES8l.js +0 -810
- package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +0 -8
- package/runtime/web/assets/worker-Caf-yYEI.js +0 -2
- package/runtime/web/embed.html +0 -32
- package/runtime/web/embed.js +0 -171
- package/src/commands/design.ts +0 -846
- package/src/commands/update.ts +0 -211
- package/src/util.ts +0 -146
- /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
- /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
- /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}`)
|