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.
- package/README.md +56 -57
- package/dist/index.js +5918 -5020
- package/package.json +7 -5
- package/runtime/materials/decal-relief.mat +12 -1
- package/runtime/materials/decal.mat +17 -3
- package/runtime/materials/lightmap-baked-lite.mat +6 -1
- package/runtime/materials/lightmap-baked.mat +6 -1
- package/runtime/materials/lightmap.mat +3 -0
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/assets/createViewerLite-DHqTFhWB.js +866 -0
- package/runtime/web/assets/{index-BMt7AnC5.js → index-Q06c6oHx.js} +1 -1
- package/runtime/web/embed.js +1 -1
- 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 +133 -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} +451 -42
- package/src/commands/{appDesktop.ts → app/desktop.ts} +63 -32
- package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +24 -18
- package/src/commands/{appIcon.ts → app/icon.ts} +2 -2
- package/src/commands/{app.ts → app/index.ts} +397 -289
- package/src/commands/{appShared.ts → app/shared.ts} +30 -39
- package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +62 -28
- package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
- package/src/commands/{appTemplates.ts → app/templates/ios.ts} +1 -1
- package/src/commands/assets.ts +23 -21
- package/src/commands/clone.ts +48 -51
- package/src/commands/compile.ts +62 -88
- 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 +90 -82
- package/src/commands/dev.ts +201 -176
- package/src/commands/diff.ts +39 -51
- package/src/commands/index.ts +60 -0
- package/src/commands/{init.ts → init/index.ts} +199 -191
- package/src/commands/install.ts +80 -67
- package/src/commands/lightmap.ts +203 -171
- package/src/commands/link.ts +87 -94
- package/src/commands/login.ts +25 -23
- package/src/commands/navmesh.ts +168 -138
- 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 +158 -99
- package/src/commands/scene.ts +58 -55
- package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
- package/src/commands/{shadersNew.ts → shaders/new.ts} +2 -2
- package/src/commands/shared.ts +79 -0
- package/src/commands/status.ts +22 -26
- package/src/commands/test.ts +398 -371
- 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 +3 -3
- package/src/compile/headlessBundle.ts +133 -129
- package/src/compile/projectCompile.ts +2 -2
- package/src/compile/sceneCompile.ts +10 -8
- package/src/compile/screenEntry.ts +123 -127
- package/src/compile/shaders.ts +3 -3
- package/src/dev/androidDev.ts +1 -1
- package/src/dev/devServer.ts +3 -3
- package/src/dev/webRunner.ts +1 -1
- package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +1 -1
- package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +6 -5
- package/src/{desktopScript.ts → hosts/desktopScript.ts} +1 -1
- package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
- package/src/{matcTool.ts → hosts/matcTool.ts} +1 -1
- package/src/{peerInstall.ts → hosts/peerInstall.ts} +2 -2
- package/src/{releases.ts → hosts/releases.ts} +1 -1
- package/src/index.ts +32 -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/{projectEnv.ts → project/env.ts} +33 -2
- 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/web/assets/createViewerLite-Ct_PZdof.js +0 -852
- package/src/commands/design.ts +0 -846
- package/src/commands/update.ts +0 -211
- package/src/util.ts +0 -146
- /package/src/commands/{projectTemplates.ts → init/templates.ts} +0 -0
- /package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +0 -0
- /package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +0 -0
- /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
- /package/src/{lecodes-renderer.d.ts → declarations/lecodes-renderer.d.ts} +0 -0
- /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
- /package/src/{peers.ts → hosts/peers.ts} +0 -0
- /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
- /package/src/{ignore.ts → project/ignore.ts} +0 -0
package/src/commands/design.ts
DELETED
|
@@ -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
|
-
}
|