lecodes-cli 0.20.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +56 -57
  2. package/dist/index.js +5918 -5020
  3. package/package.json +7 -5
  4. package/runtime/materials/decal-relief.mat +12 -1
  5. package/runtime/materials/decal.mat +17 -3
  6. package/runtime/materials/lightmap-baked-lite.mat +6 -1
  7. package/runtime/materials/lightmap-baked.mat +6 -1
  8. package/runtime/materials/lightmap.mat +3 -0
  9. package/runtime/scene-harness.json +1 -1
  10. package/runtime/sdk-types.json +1 -1
  11. package/runtime/web/assets/createViewerLite-DHqTFhWB.js +866 -0
  12. package/runtime/web/assets/{index-BMt7AnC5.js → index-Q06c6oHx.js} +1 -1
  13. package/runtime/web/embed.js +1 -1
  14. package/src/api.ts +1 -302
  15. package/src/cli/args.ts +37 -0
  16. package/src/cli/command.ts +113 -0
  17. package/src/cli/errors.ts +13 -0
  18. package/src/cli/help.ts +133 -0
  19. package/src/cli/index.ts +7 -0
  20. package/src/cli/output.ts +102 -0
  21. package/src/cli/run.ts +106 -0
  22. package/src/commands/{appAndroid.ts → app/android.ts} +451 -42
  23. package/src/commands/{appDesktop.ts → app/desktop.ts} +63 -32
  24. package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +24 -18
  25. package/src/commands/{appIcon.ts → app/icon.ts} +2 -2
  26. package/src/commands/{app.ts → app/index.ts} +397 -289
  27. package/src/commands/{appShared.ts → app/shared.ts} +30 -39
  28. package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +62 -28
  29. package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
  30. package/src/commands/{appTemplates.ts → app/templates/ios.ts} +1 -1
  31. package/src/commands/assets.ts +23 -21
  32. package/src/commands/clone.ts +48 -51
  33. package/src/commands/compile.ts +62 -88
  34. package/src/commands/create.ts +46 -47
  35. package/src/commands/design/comments.ts +361 -0
  36. package/src/commands/design/context.ts +101 -0
  37. package/src/commands/design/index.ts +391 -0
  38. package/src/commands/design/snapshot.ts +134 -0
  39. package/src/commands/{designTemplates.ts → design/templates.ts} +4 -3
  40. package/src/commands/desktop.ts +90 -82
  41. package/src/commands/dev.ts +201 -176
  42. package/src/commands/diff.ts +39 -51
  43. package/src/commands/index.ts +60 -0
  44. package/src/commands/{init.ts → init/index.ts} +199 -191
  45. package/src/commands/install.ts +80 -67
  46. package/src/commands/lightmap.ts +203 -171
  47. package/src/commands/link.ts +87 -94
  48. package/src/commands/login.ts +25 -23
  49. package/src/commands/navmesh.ts +168 -138
  50. package/src/commands/pn.ts +201 -244
  51. package/src/commands/pull.ts +49 -56
  52. package/src/commands/push.ts +48 -54
  53. package/src/commands/render.ts +158 -99
  54. package/src/commands/scene.ts +58 -55
  55. package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
  56. package/src/commands/{shadersNew.ts → shaders/new.ts} +2 -2
  57. package/src/commands/shared.ts +79 -0
  58. package/src/commands/status.ts +22 -26
  59. package/src/commands/test.ts +398 -371
  60. package/src/commands/thumbs.ts +185 -175
  61. package/src/commands/update/index.ts +226 -0
  62. package/src/commands/{types.ts → update/types.ts} +31 -22
  63. package/src/compile/collect.ts +5 -5
  64. package/src/compile/collectLocal.ts +3 -3
  65. package/src/compile/designCompile.ts +3 -3
  66. package/src/compile/headlessBundle.ts +133 -129
  67. package/src/compile/projectCompile.ts +2 -2
  68. package/src/compile/sceneCompile.ts +10 -8
  69. package/src/compile/screenEntry.ts +123 -127
  70. package/src/compile/shaders.ts +3 -3
  71. package/src/dev/androidDev.ts +1 -1
  72. package/src/dev/devServer.ts +3 -3
  73. package/src/dev/webRunner.ts +1 -1
  74. package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +1 -1
  75. package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +6 -5
  76. package/src/{desktopScript.ts → hosts/desktopScript.ts} +1 -1
  77. package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
  78. package/src/{matcTool.ts → hosts/matcTool.ts} +1 -1
  79. package/src/{peerInstall.ts → hosts/peerInstall.ts} +2 -2
  80. package/src/{releases.ts → hosts/releases.ts} +1 -1
  81. package/src/index.ts +32 -480
  82. package/src/platform/api.ts +302 -0
  83. package/src/{browserAuth.ts → platform/browserAuth.ts} +1 -1
  84. package/src/{config.ts → platform/config.ts} +1 -1
  85. package/src/{serverDiff.ts → platform/serverDiff.ts} +3 -3
  86. package/src/{projectEnv.ts → project/env.ts} +33 -2
  87. package/src/{localFiles.ts → project/localFiles.ts} +1 -1
  88. package/src/{manifest.ts → project/manifest.ts} +1 -1
  89. package/src/{project.ts → project/materialize.ts} +2 -2
  90. package/src/project/paths.ts +20 -0
  91. package/src/{textDiff.ts → project/textDiff.ts} +1 -1
  92. package/src/{types.ts → project/types.ts} +0 -0
  93. package/runtime/web/assets/createViewerLite-Ct_PZdof.js +0 -852
  94. package/src/commands/design.ts +0 -846
  95. package/src/commands/update.ts +0 -211
  96. package/src/util.ts +0 -146
  97. /package/src/commands/{projectTemplates.ts → init/templates.ts} +0 -0
  98. /package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +0 -0
  99. /package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +0 -0
  100. /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
  101. /package/src/{lecodes-renderer.d.ts → declarations/lecodes-renderer.d.ts} +0 -0
  102. /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
  103. /package/src/{peers.ts → hosts/peers.ts} +0 -0
  104. /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
  105. /package/src/{ignore.ts → project/ignore.ts} +0 -0
@@ -1,191 +1,199 @@
1
- import { existsSync, mkdirSync, writeFileSync } from "node:fs"
2
- import { basename, dirname, join, resolve } from "node:path"
3
- import { LECODES_DIR } from "../manifest"
4
- import { materializeTypesLocal, writeTsconfig } from "../types"
5
- import { DEFAULT_IGNORE_FILE, ensureGitignore } from "../ignore"
6
- import { appClaudeMd, CLAUDE_SETTINGS, STARTER_FLOW } from "./projectTemplates"
7
- import { c, flagBool, log, note, success, warnErr, type Args } from "../util"
8
-
9
- /*
10
- * `lecodes init [dir]` — scaffold a brand-new LOCAL project: no account, no login, no server.
11
- *
12
- * Writes a starter entry, a CLAUDE.md (the headless render/test workflow for AI assistants) with
13
- * a passing starter flow test, the SDK's import-free type surface + a tsconfig (so VS Code gives
14
- * full IntelliSense offline), and a `.lecodesignore`. The result runs immediately with `lecodes dev`
15
- * (hot reload on a device) and compiles with `lecodes compile -o app.js` (a self-contained bundle
16
- * for LeCodes Desktop). It's the offline counterpart to `lecodes create`, which makes a project on
17
- * the server; a local project needs no manifest — `dev`/`compile` compile straight from the tree.
18
- *
19
- * Idempotent: existing files are kept (never clobbered), so it's safe to re-run or run in a folder
20
- * that already has content. Only the generated tsconfig + `.lecodes/types/` are rewritten each run.
21
- */
22
-
23
- const STARTER = `// Welcome to LeCodes — the whole SDK is available as globals, no imports needed.
24
- // Run it live on a device with \`lecodes dev\`, or bundle it with \`lecodes compile -o app.js\`.
25
-
26
- let count = 0
27
-
28
- const counter = UIText({ color: "#9fb0d0", fontSize: 22, marginTop: 18 }, "Taps: 0")
29
-
30
- const button = UIButton(
31
- {
32
- backgroundColor: "#4f7cff",
33
- borderRadius: 14,
34
- paddingVertical: 16,
35
- paddingHorizontal: 40,
36
- marginTop: 24,
37
- alignItems: "center",
38
- justifyContent: "center",
39
- },
40
- [UIText({ color: "#ffffff", fontSize: 22 }, "Tap me")],
41
- )
42
- .style({ name: "tap" }) // names make elements targetable in tests/*.flow.json (see CLAUDE.md)
43
- .onClick(() => {
44
- count++
45
- counter.text = "Taps: " + count
46
- })
47
-
48
- const screen = UIScreen(
49
- { backgroundColor: "#1e2230", alignItems: "center", justifyContent: "center", padding: 40 },
50
- [
51
- UIColumn(
52
- { backgroundColor: "#2b3550", borderRadius: 24, padding: 40, alignItems: "center" },
53
- [UIText({ color: "#ffffff", fontSize: 32 }, "Hello, LeCodes"), button, counter],
54
- ),
55
- ],
56
- ).style({ name: "home" }) // the screen root's name is what tests' \`expect: { screen: … }\` reads
57
-
58
- screen.open()
59
- `
60
-
61
- /** `--desktop`: declare the desktop target up front. Window sizes are logical units (the host
62
- * scales for HiDPI); `dev --desktop` / `desktop run` / `render --desktop` all read this, and a
63
- * desktop-only app.json makes headless render/test default to the window size too. */
64
- const appJsonDesktop = (name: string) => JSON.stringify({
65
- name,
66
- desktop: {
67
- window: { width: 1280, height: 800, minWidth: 640, minHeight: 480 },
68
- },
69
- }, null, 2) + "\n"
70
-
71
- const readmeTemplate = (name: string) => `# ${name}
72
-
73
- A LeCodes app. The whole SDK is available as globals (\`UIScreen\`, \`UIText\`, \`Scene2D\`, …) —
74
- no imports needed. \`main.ts\` is the entry point.
75
-
76
- ## Run it
77
-
78
- \`\`\`
79
- lecodes dev # live on your phone: scan the QR with the LeCodes app, hot reload
80
- lecodes compile -o app.js # bundle to a single self-contained JS file
81
- \`\`\`
82
-
83
- ## Test it (no device needed)
84
-
85
- \`\`\`
86
- lecodes render --png shot.png # headless screenshot of the running app
87
- lecodes test # run tests/*.flow.json — the starter ships with a passing one
88
- \`\`\`
89
-
90
- Scenario format + conventions: see [CLAUDE.md](./CLAUDE.md) (written for AI assistants, useful
91
- for humans too).
92
-
93
- ## Put it on the platform
94
-
95
- This project is local-only. To give it a home on le.codes (web editor, sharing, OTA updates):
96
-
97
- \`\`\`
98
- lecodes login # once per machine
99
- lecodes link # create a project from this folder and connect it
100
- lecodes push -m "initial" # upload it as the first checkpoint (--publish to go live)
101
- \`\`\`
102
-
103
- Already made the project in the web editor? \`lecodes link --project <uuid>\` adopts it — it
104
- shows what your next push would change there before connecting anything.
105
-
106
- ## Go native
107
-
108
- \`\`\`
109
- lecodes app init ios # generate a native Xcode shell in ios/
110
- lecodes app sync # embed the compiled bundle + plugins into it
111
- lecodes app open # open it in Xcode, build & run
112
- \`\`\`
113
-
114
- ## Layout
115
-
116
- - \`main.ts\` — the app's entry point. Add more .ts files freely and import them from
117
- \`main.ts\`; a file named \`main.ts\` is always the entry.
118
- - \`tests/\` — headless flow tests (\`lecodes test\`); failing steps dump the frame to
119
- \`tests/.artifacts/\`
120
- - \`CLAUDE.md\` — guidance for AI assistants (the headless feedback loop + testability conventions)
121
- - \`assets/\` — images/fonts the app references (created on demand)
122
- - \`tsconfig.json\` + \`.lecodes/types/\` — generated IDE types (rewritten by the CLI; don't edit).
123
- They come from the SDK bundled in the CLI; \`lecodes update\` refreshes them from the platform.
124
- - \`.lecodes/\` is gitignored — per-checkout state (those types + \`manifest.json\`, this machine's
125
- sync cursor once linked). After a fresh git clone: \`lecodes update\`, then
126
- \`lecodes link --project <uuid>\` to re-attach.
127
- - \`.lecodesignore\` — files a future \`lecodes push\` would skip
128
-
129
- Full command list: \`lecodes --help\`.
130
- `
131
-
132
- export const init = async (args: Args) => {
133
- const dirArg = (args._[0] ?? ".").replace(/[/\\]+$/, "") || "."
134
- const root = resolve(dirArg)
135
- const name = basename(root)
136
-
137
- mkdirSync(root, { recursive: true })
138
-
139
- let created = 0
140
- const write = (rel: string, content: string) => {
141
- const abs = join(root, rel)
142
- if (existsSync(abs)) { note(`kept ${rel}`); return }
143
- mkdirSync(dirname(abs), { recursive: true })
144
- writeFileSync(abs, content)
145
- log(`${c.green("created")} ${rel}`)
146
- created++
147
- }
148
-
149
- const forDesktop = flagBool(args, "desktop")
150
-
151
- // Starter entry — a root file named main.ts is always the detected entrypoint.
152
- write("main.ts", STARTER)
153
- write("README.md", readmeTemplate(name))
154
- // --desktop: an app.json declaring the desktop target (window config in logical units).
155
- if (forDesktop) write("app.json", appJsonDesktop(name))
156
- // AI-assistant guidance + a passing flow test: the headless loop is discoverable from day one.
157
- write("CLAUDE.md", appClaudeMd(name))
158
- write(".claude/settings.json", CLAUDE_SETTINGS)
159
- write("tests/counter.flow.json", STARTER_FLOW)
160
- // Keep local render/preview outputs (screenshot.png, shots/) out of any future push.
161
- write(".lecodesignore", DEFAULT_IGNORE_FILE)
162
- // .lecodes/ is per-checkout state (generated types + the push cursor) — keep it out of git.
163
- const gi = ensureGitignore(root)
164
- if (gi === "created") { log(`${c.green("created")} .gitignore`); created++ }
165
- else if (gi === "updated") log(`${c.green("updated")} .gitignore ${c.dim("(+ .lecodes/)")}`)
166
- else note("kept .gitignore")
167
-
168
- // IDE types: the SDK's import-free global surface + a tsconfig, so VS Code resolves UIScreen,
169
- // Scene2D, setLoop, … with no server. Regenerated every run to pick up CLI upgrades.
170
- const wroteTypes = materializeTypesLocal(root)
171
- writeTsconfig(root)
172
- log(`${c.green("written")} tsconfig.json${wroteTypes ? ` + ${LECODES_DIR}/types/` : ""} (IDE types)`)
173
- if (!wroteTypes) {
174
- warnErr("Bundled SDK types not found — tsconfig written, but globals may show unresolved until the CLI is rebuilt with vendored types.")
175
- }
176
-
177
- log("")
178
- success(created
179
- ? `Local project ${c.bold(name)} ready${dirArg === "." ? "" : ` in ${c.bold(dirArg)}/`}.`
180
- : `Project ${c.bold(name)} already initialized.`)
181
- const cd = dirArg === "." ? "" : `cd ${dirArg} && `
182
- if (forDesktop) {
183
- log(` ${c.bold(cd + "lecodes dev --desktop")} run it in a desktop window with hot reload`)
184
- log(` ${c.bold(cd + "lecodes test")} run the flow tests headless (desktop-sized)`)
185
- log(` ${c.bold(cd + "lecodes desktop run")} open the compiled app in the desktop host`)
186
- } else {
187
- log(` ${c.bold(cd + "lecodes dev")} run it on a device with hot reload`)
188
- log(` ${c.bold(cd + "lecodes test")} run the flow tests headless (no device)`)
189
- log(` ${c.bold(cd + "lecodes compile -o app.js")} bundle it for LeCodes Desktop`)
190
- }
191
- }
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
2
+ import { basename, dirname, join, resolve } from "node:path"
3
+ import { LECODES_DIR } from "../../project/manifest"
4
+ import { materializeTypesLocal, writeTsconfig } from "../../project/types"
5
+ import { DEFAULT_IGNORE_FILE, ensureGitignore } from "../../project/ignore"
6
+ import { appClaudeMd, CLAUDE_SETTINGS, STARTER_FLOW } from "./templates"
7
+ import { bool, c, defineCommand, log, note, success, warnErr } from "../../cli"
8
+
9
+ /*
10
+ * `lecodes init [dir]` — scaffold a brand-new LOCAL project: no account, no login, no server.
11
+ *
12
+ * Writes a starter entry, a CLAUDE.md (the headless render/test workflow for AI assistants) with
13
+ * a passing starter flow test, the SDK's import-free type surface + a tsconfig (so VS Code gives
14
+ * full IntelliSense offline), and a `.lecodesignore`. The result runs immediately with `lecodes dev`
15
+ * (hot reload on a device) and compiles with `lecodes compile -o app.js` (a self-contained bundle
16
+ * for LeCodes Desktop). It's the offline counterpart to `lecodes create`, which makes a project on
17
+ * the server; a local project needs no manifest — `dev`/`compile` compile straight from the tree.
18
+ *
19
+ * Idempotent: existing files are kept (never clobbered), so it's safe to re-run or run in a folder
20
+ * that already has content. Only the generated tsconfig + `.lecodes/types/` are rewritten each run.
21
+ */
22
+
23
+ const STARTER = `// Welcome to LeCodes — the whole SDK is available as globals, no imports needed.
24
+ // Run it live on a device with \`lecodes dev\`, or bundle it with \`lecodes compile -o app.js\`.
25
+
26
+ let count = 0
27
+
28
+ const counter = UIText({ color: "#9fb0d0", fontSize: 22, marginTop: 18 }, "Taps: 0")
29
+
30
+ const button = UIButton(
31
+ {
32
+ backgroundColor: "#4f7cff",
33
+ borderRadius: 14,
34
+ paddingVertical: 16,
35
+ paddingHorizontal: 40,
36
+ marginTop: 24,
37
+ alignItems: "center",
38
+ justifyContent: "center",
39
+ },
40
+ [UIText({ color: "#ffffff", fontSize: 22 }, "Tap me")],
41
+ )
42
+ .style({ name: "tap" }) // names make elements targetable in tests/*.flow.json (see CLAUDE.md)
43
+ .onClick(() => {
44
+ count++
45
+ counter.text = "Taps: " + count
46
+ })
47
+
48
+ const screen = UIScreen(
49
+ { backgroundColor: "#1e2230", alignItems: "center", justifyContent: "center", padding: 40 },
50
+ [
51
+ UIColumn(
52
+ { backgroundColor: "#2b3550", borderRadius: 24, padding: 40, alignItems: "center" },
53
+ [UIText({ color: "#ffffff", fontSize: 32 }, "Hello, LeCodes"), button, counter],
54
+ ),
55
+ ],
56
+ ).style({ name: "home" }) // the screen root's name is what tests' \`expect: { screen: … }\` reads
57
+
58
+ screen.open()
59
+ `
60
+
61
+ /** `--desktop`: declare the desktop target up front. Window sizes are logical units (the host
62
+ * scales for HiDPI); `dev --desktop` / `desktop run` / `render --desktop` all read this, and a
63
+ * desktop-only app.json makes headless render/test default to the window size too. */
64
+ const appJsonDesktop = (name: string) => JSON.stringify({
65
+ name,
66
+ desktop: {
67
+ window: { width: 1280, height: 800, minWidth: 640, minHeight: 480 },
68
+ },
69
+ }, null, 2) + "\n"
70
+
71
+ const readmeTemplate = (name: string) => `# ${name}
72
+
73
+ A LeCodes app. The whole SDK is available as globals (\`UIScreen\`, \`UIText\`, \`Scene2D\`, …) —
74
+ no imports needed. \`main.ts\` is the entry point.
75
+
76
+ ## Run it
77
+
78
+ \`\`\`
79
+ lecodes dev # live on your phone: scan the QR with the LeCodes app, hot reload
80
+ lecodes compile -o app.js # bundle to a single self-contained JS file
81
+ \`\`\`
82
+
83
+ ## Test it (no device needed)
84
+
85
+ \`\`\`
86
+ lecodes render --png shot.png # headless screenshot of the running app
87
+ lecodes test # run tests/*.flow.json — the starter ships with a passing one
88
+ \`\`\`
89
+
90
+ Scenario format + conventions: see [CLAUDE.md](./CLAUDE.md) (written for AI assistants, useful
91
+ for humans too).
92
+
93
+ ## Put it on the platform
94
+
95
+ This project is local-only. To give it a home on le.codes (web editor, sharing, OTA updates):
96
+
97
+ \`\`\`
98
+ lecodes login # once per machine
99
+ lecodes link # create a project from this folder and connect it
100
+ lecodes push -m "initial" # upload it as the first checkpoint (--publish to go live)
101
+ \`\`\`
102
+
103
+ Already made the project in the web editor? \`lecodes link --project <uuid>\` adopts it — it
104
+ shows what your next push would change there before connecting anything.
105
+
106
+ ## Go native
107
+
108
+ \`\`\`
109
+ lecodes app init ios # generate a native Xcode shell in ios/
110
+ lecodes app sync # embed the compiled bundle + plugins into it
111
+ lecodes app open # open it in Xcode, build & run
112
+ \`\`\`
113
+
114
+ ## Layout
115
+
116
+ - \`main.ts\` — the app's entry point. Add more .ts files freely and import them from
117
+ \`main.ts\`; a file named \`main.ts\` is always the entry.
118
+ - \`tests/\` — headless flow tests (\`lecodes test\`); failing steps dump the frame to
119
+ \`tests/.artifacts/\`
120
+ - \`CLAUDE.md\` — guidance for AI assistants (the headless feedback loop + testability conventions)
121
+ - \`assets/\` — images/fonts the app references (created on demand)
122
+ - \`tsconfig.json\` + \`.lecodes/types/\` — generated IDE types (rewritten by the CLI; don't edit).
123
+ They come from the SDK bundled in the CLI; \`lecodes update\` refreshes them from the platform.
124
+ - \`.lecodes/\` is gitignored — per-checkout state (those types + \`manifest.json\`, this machine's
125
+ sync cursor once linked). After a fresh git clone: \`lecodes update\`, then
126
+ \`lecodes link --project <uuid>\` to re-attach.
127
+ - \`.lecodesignore\` — files a future \`lecodes push\` would skip
128
+
129
+ Full command list: \`lecodes --help\`.
130
+ `
131
+
132
+ export default defineCommand({
133
+ name: "init",
134
+ summary: "Scaffold a new LOCAL project (no login, no server)",
135
+ usage: "[dir]",
136
+ description: "Writes a starter main.ts, a CLAUDE.md + a passing flow test (the headless render / test loop), the IDE types + tsconfig, and a .lecodesignore. Run it with `lecodes dev` or `lecodes compile`. Idempotent: existing files are kept.",
137
+ flags: { desktop: bool("also write an app.json declaring the desktop target (window size; `dev --desktop`, `desktop run` and headless render / test pick it up)") },
138
+ examples: ["lecodes init myapp", "lecodes init --desktop"],
139
+ run: ({ args, flags }) => init(args[0], flags.desktop),
140
+ })
141
+
142
+ const init = (dir: string | undefined, forDesktop: boolean): void => {
143
+ const dirArg = (dir ?? ".").replace(/[/\\]+$/, "") || "."
144
+ const root = resolve(dirArg)
145
+ const name = basename(root)
146
+
147
+ mkdirSync(root, { recursive: true })
148
+
149
+ let created = 0
150
+ const write = (rel: string, content: string) => {
151
+ const abs = join(root, rel)
152
+ if (existsSync(abs)) { note(`kept ${rel}`); return }
153
+ mkdirSync(dirname(abs), { recursive: true })
154
+ writeFileSync(abs, content)
155
+ log(`${c.green("created")} ${rel}`)
156
+ created++
157
+ }
158
+
159
+ // Starter entry — a root file named main.ts is always the detected entrypoint.
160
+ write("main.ts", STARTER)
161
+ write("README.md", readmeTemplate(name))
162
+ // --desktop: an app.json declaring the desktop target (window config in logical units).
163
+ if (forDesktop) write("app.json", appJsonDesktop(name))
164
+ // AI-assistant guidance + a passing flow test: the headless loop is discoverable from day one.
165
+ write("CLAUDE.md", appClaudeMd(name))
166
+ write(".claude/settings.json", CLAUDE_SETTINGS)
167
+ write("tests/counter.flow.json", STARTER_FLOW)
168
+ // Keep local render/preview outputs (screenshot.png, shots/) out of any future push.
169
+ write(".lecodesignore", DEFAULT_IGNORE_FILE)
170
+ // .lecodes/ is per-checkout state (generated types + the push cursor) — keep it out of git.
171
+ const gi = ensureGitignore(root)
172
+ if (gi === "created") { log(`${c.green("created")} .gitignore`); created++ }
173
+ else if (gi === "updated") log(`${c.green("updated")} .gitignore ${c.dim("(+ .lecodes/)")}`)
174
+ else note("kept .gitignore")
175
+
176
+ // IDE types: the SDK's import-free global surface + a tsconfig, so VS Code resolves UIScreen,
177
+ // Scene2D, setLoop, … with no server. Regenerated every run to pick up CLI upgrades.
178
+ const wroteTypes = materializeTypesLocal(root)
179
+ writeTsconfig(root)
180
+ log(`${c.green("written")} tsconfig.json${wroteTypes ? ` + ${LECODES_DIR}/types/` : ""} (IDE types)`)
181
+ if (!wroteTypes) {
182
+ warnErr("Bundled SDK types not found — tsconfig written, but globals may show unresolved until the CLI is rebuilt with vendored types.")
183
+ }
184
+
185
+ log("")
186
+ success(created
187
+ ? `Local project ${c.bold(name)} ready${dirArg === "." ? "" : ` in ${c.bold(dirArg)}/`}.`
188
+ : `Project ${c.bold(name)} already initialized.`)
189
+ const cd = dirArg === "." ? "" : `cd ${dirArg} && `
190
+ if (forDesktop) {
191
+ log(` ${c.bold(cd + "lecodes dev --desktop")} run it in a desktop window with hot reload`)
192
+ log(` ${c.bold(cd + "lecodes test")} run the flow tests headless (desktop-sized)`)
193
+ log(` ${c.bold(cd + "lecodes desktop run")} open the compiled app in the desktop host`)
194
+ } else {
195
+ log(` ${c.bold(cd + "lecodes dev")} run it on a device with hot reload`)
196
+ log(` ${c.bold(cd + "lecodes test")} run the flow tests headless (no device)`)
197
+ log(` ${c.bold(cd + "lecodes compile -o app.js")} bundle it for LeCodes Desktop`)
198
+ }
199
+ }
@@ -1,8 +1,8 @@
1
1
  import { execFileSync } from "node:child_process"
2
2
  import { appendFileSync, chmodSync, cpSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync } from "node:fs"
3
3
  import { homedir } from "node:os"
4
- import { basename, delimiter, dirname, join, resolve, sep } from "node:path"
5
- import { c, CliError, flagBool, flagStr, log, success, warn, type Args } from "../util"
4
+ import { basename, delimiter, dirname, join, resolve } from "node:path"
5
+ import { CliError, bool, c, defineCommand, log, str, success, warn } from "../cli"
6
6
 
7
7
  /*
8
8
  * `lecodes install` — install this standalone build so `lecodes` is on your PATH.
@@ -17,15 +17,13 @@ import { c, CliError, flagBool, flagStr, log, success, warn, type Args } from ".
17
17
  * PATH is only edited on request (`--modify-path`); otherwise we place the files and print exactly how
18
18
  * to finish (a one-liner for this shell, a line for the rc file, or `--system` which lands in
19
19
  * /usr/local/bin and needs no PATH edit). Only meaningful for the release archive — from npm/monorepo
20
- * source process.execPath has no bundle beside it, so it refuses.
20
+ * source process.execPath has no bundle beside it, so it refuses. The registry hides the command
21
+ * unless the running binary IS such a bundle (src/index.ts decides).
21
22
  */
22
23
 
23
24
  const isWin = process.platform === "win32"
24
25
  const HOME = homedir()
25
26
 
26
- /** Display a path with the home dir collapsed to `~` (for copy-pasteable hints). */
27
- const tildify = (p: string): string => (p === HOME || p.startsWith(HOME + sep)) ? "~" + p.slice(HOME.length) : p
28
-
29
27
  const safeRealpath = (p: string): string => { try { return realpathSync(p) } catch { return resolve(p) } }
30
28
 
31
29
  /** True if `dir` is already an entry in PATH (compared by resolved path). */
@@ -37,25 +35,7 @@ const onPath = (dir: string): boolean => {
37
35
  })
38
36
  }
39
37
 
40
- export const install = async (args: Args): Promise<void> => {
41
- const bundle = dirname(process.execPath)
42
- const exeName = basename(process.execPath) // "lecodes" | "lecodes.exe"
43
- const chiselName = isWin ? "chisel.exe" : "chisel"
44
-
45
- // Guard: this must be the packaged standalone bundle (chisel + runtime/ sit next to the binary).
46
- // From npm / the monorepo, process.execPath is the bun/node binary and has no companions.
47
- if (!existsSync(join(bundle, "runtime")) || !existsSync(join(bundle, chiselName))) {
48
- throw new CliError(
49
- "`lecodes install` only works from the packaged release build (the archive containing lecodes + " +
50
- "chisel + runtime/). You appear to be running from source or an npm install — install globally " +
51
- "with your package manager there instead.",
52
- )
53
- }
54
-
55
- const companions = [exeName, chiselName, "runtime", "README.txt"].filter(f => existsSync(join(bundle, f)))
56
- if (isWin) return installWindows(args, bundle, companions)
57
- return installUnix(args, bundle, companions)
58
- }
38
+ type InstallOptions = { modifyPath: boolean, system: boolean, dir?: string, bin?: string }
59
39
 
60
40
  /** Copy the bundle's companion files into `target` (skipping the copy when target IS the bundle). */
61
41
  const copyBundle = (bundle: string, target: string, companions: string[]): void => {
@@ -79,29 +59,6 @@ const permError = (e: unknown, dir: string, system: boolean): CliError => {
79
59
  return new CliError(`Install failed: ${e instanceof Error ? e.message : String(e)}`)
80
60
  }
81
61
 
82
- const installUnix = (args: Args, bundle: string, companions: string[]): void => {
83
- const system = flagBool(args, "system")
84
- const libDir = resolve(flagStr(args, "dir") ?? (system ? "/usr/local/lib/lecodes-cli" : join(HOME, ".local", "lib", "lecodes-cli")))
85
- const binDir = resolve(flagStr(args, "bin") ?? (system ? "/usr/local/bin" : join(HOME, ".local", "bin")))
86
- const link = join(binDir, "lecodes")
87
-
88
- try {
89
- copyBundle(bundle, libDir, companions)
90
- chmodSync(join(libDir, "lecodes"), 0o755)
91
- chmodSync(join(libDir, "chisel"), 0o755)
92
- } catch (e) { throw permError(e, libDir, system) }
93
-
94
- try {
95
- mkdirSync(binDir, { recursive: true })
96
- rmSync(link, { force: true }) // replace a previous symlink/file
97
- symlinkSync(join(libDir, "lecodes"), link)
98
- } catch (e) { throw permError(e, binDir, system) }
99
-
100
- success(`Installed to ${c.bold(libDir)}`)
101
- log(` ${c.dim(link + " → " + join(libDir, "lecodes"))}`)
102
- finishPathUnix(args, binDir)
103
- }
104
-
105
62
  /** The shell startup file to persist PATH into, picked from $SHELL (bash/zsh handled; else ~/.profile,
106
63
  * which login shells read). Returns the absolute path and a ~-collapsed display name. */
107
64
  const shellRc = (): { file: string, name: string } => {
@@ -127,12 +84,12 @@ const appendPathToShellRc = (binDir: string): string | null => {
127
84
  }
128
85
 
129
86
  /** Put `binDir` on PATH: verify (already there), or `--modify-path` (edit the rc), or print how. */
130
- const finishPathUnix = (args: Args, binDir: string): void => {
87
+ const finishPathUnix = (modifyPath: boolean, binDir: string): void => {
131
88
  if (onPath(binDir)) {
132
89
  log(` Run ${c.bold("lecodes --version")} in a new shell to verify.`)
133
90
  return
134
91
  }
135
- if (flagBool(args, "modify-path")) {
92
+ if (modifyPath) {
136
93
  const rc = appendPathToShellRc(binDir)
137
94
  if (rc) {
138
95
  success(`Added ${c.bold(binDir)} to PATH in ${rc}.`)
@@ -151,8 +108,46 @@ const finishPathUnix = (args: Args, binDir: string): void => {
151
108
  log(` • or install system-wide (no PATH edit): ${c.bold("sudo lecodes install --system")}`)
152
109
  }
153
110
 
154
- const installWindows = (args: Args, bundle: string, companions: string[]): void => {
155
- const target = resolve(flagStr(args, "dir") ?? join(process.env.LOCALAPPDATA ?? join(HOME, "AppData", "Local"), "Programs", "lecodes-cli"))
111
+ const installUnix = (opts: InstallOptions, bundle: string, companions: string[]): void => {
112
+ const { system } = opts
113
+ const libDir = resolve(opts.dir ?? (system ? "/usr/local/lib/lecodes-cli" : join(HOME, ".local", "lib", "lecodes-cli")))
114
+ const binDir = resolve(opts.bin ?? (system ? "/usr/local/bin" : join(HOME, ".local", "bin")))
115
+ const link = join(binDir, "lecodes")
116
+
117
+ try {
118
+ copyBundle(bundle, libDir, companions)
119
+ chmodSync(join(libDir, "lecodes"), 0o755)
120
+ chmodSync(join(libDir, "chisel"), 0o755)
121
+ } catch (e) { throw permError(e, libDir, system) }
122
+
123
+ try {
124
+ mkdirSync(binDir, { recursive: true })
125
+ rmSync(link, { force: true }) // replace a previous symlink/file
126
+ symlinkSync(join(libDir, "lecodes"), link)
127
+ } catch (e) { throw permError(e, binDir, system) }
128
+
129
+ success(`Installed to ${c.bold(libDir)}`)
130
+ log(` ${c.dim(link + " → " + join(libDir, "lecodes"))}`)
131
+ finishPathUnix(opts.modifyPath, binDir)
132
+ }
133
+
134
+ /** Append `dir` to the current user's Path via the .NET env API (PowerShell). Unlike `setx` this never
135
+ * truncates a long Path. The dir is passed through an env var to avoid any quoting/injection. */
136
+ const addToUserPathWindows = (dir: string): boolean => {
137
+ const script =
138
+ "$d = $env:LECODES_INSTALL_DIR;" +
139
+ "$p = [Environment]::GetEnvironmentVariable('Path','User'); if (-not $p) { $p = '' };" +
140
+ "$parts = @($p.Split(';') | Where-Object { $_ -ne '' });" +
141
+ "if ($parts -notcontains $d) { [Environment]::SetEnvironmentVariable('Path', (@($parts + $d) -join ';'), 'User') }"
142
+ try {
143
+ execFileSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", script],
144
+ { stdio: "ignore", env: { ...process.env, LECODES_INSTALL_DIR: dir } })
145
+ return true
146
+ } catch { return false }
147
+ }
148
+
149
+ const installWindows = (opts: InstallOptions, bundle: string, companions: string[]): void => {
150
+ const target = resolve(opts.dir ?? join(process.env.LOCALAPPDATA ?? join(HOME, "AppData", "Local"), "Programs", "lecodes-cli"))
156
151
 
157
152
  try {
158
153
  copyBundle(bundle, target, companions)
@@ -167,7 +162,7 @@ const installWindows = (args: Args, bundle: string, companions: string[]): void
167
162
  log(` Already on PATH — run ${c.bold("lecodes --version")} in a new terminal.`)
168
163
  return
169
164
  }
170
- if (flagBool(args, "modify-path")) {
165
+ if (opts.modifyPath) {
171
166
  if (addToUserPathWindows(target)) {
172
167
  success(`Added ${c.bold(target)} to your user PATH.`)
173
168
  log(` ${c.yellow("Open a NEW terminal")} for it to take effect, then run ${c.bold("lecodes --version")}.`)
@@ -182,17 +177,35 @@ const installWindows = (args: Args, bundle: string, companions: string[]): void
182
177
  log(` • or add the folder manually via System → Environment Variables.`)
183
178
  }
184
179
 
185
- /** Append `dir` to the current user's Path via the .NET env API (PowerShell). Unlike `setx` this never
186
- * truncates a long Path. The dir is passed through an env var to avoid any quoting/injection. */
187
- const addToUserPathWindows = (dir: string): boolean => {
188
- const script =
189
- "$d = $env:LECODES_INSTALL_DIR;" +
190
- "$p = [Environment]::GetEnvironmentVariable('Path','User'); if (-not $p) { $p = '' };" +
191
- "$parts = @($p.Split(';') | Where-Object { $_ -ne '' });" +
192
- "if ($parts -notcontains $d) { [Environment]::SetEnvironmentVariable('Path', (@($parts + $d) -join ';'), 'User') }"
193
- try {
194
- execFileSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", script],
195
- { stdio: "ignore", env: { ...process.env, LECODES_INSTALL_DIR: dir } })
196
- return true
197
- } catch { return false }
198
- }
180
+ export default defineCommand({
181
+ name: "install",
182
+ summary: "Install this standalone build so `lecodes` is on your PATH",
183
+ description: "Copies the bundle (lecodes + chisel + runtime/) to a library folder and exposes the binary: a symlink in ~/.local/bin on Linux/macOS, the install folder itself on PATH on Windows. PATH is only edited with --modify-path; otherwise the command prints how to finish. Only works from the packaged release archive.",
184
+ flags: {
185
+ "modify-path": bool("also add it to PATH (edits your shell rc / user PATH)"),
186
+ system: bool("install for all users (Linux/macOS; needs sudo)"),
187
+ dir: str("override the install/library folder", { value: "<path>" }),
188
+ bin: str("override the symlink folder (Linux/macOS)", { value: "<path>" }),
189
+ },
190
+ examples: ["lecodes install --modify-path", "sudo lecodes install --system"],
191
+ run: ({ flags }) => {
192
+ const bundle = dirname(process.execPath)
193
+ const exeName = basename(process.execPath) // "lecodes" | "lecodes.exe"
194
+ const chiselName = isWin ? "chisel.exe" : "chisel"
195
+
196
+ // Guard: this must be the packaged standalone bundle (chisel + runtime/ sit next to the binary).
197
+ // From npm / the monorepo, process.execPath is the bun/node binary and has no companions.
198
+ if (!existsSync(join(bundle, "runtime")) || !existsSync(join(bundle, chiselName))) {
199
+ throw new CliError(
200
+ "`lecodes install` only works from the packaged release build (the archive containing lecodes + " +
201
+ "chisel + runtime/). You appear to be running from source or an npm install — install globally " +
202
+ "with your package manager there instead.",
203
+ )
204
+ }
205
+
206
+ const companions = [exeName, chiselName, "runtime", "README.txt"].filter(f => existsSync(join(bundle, f)))
207
+ const opts: InstallOptions = { modifyPath: flags["modify-path"], system: flags.system, dir: flags.dir, bin: flags.bin }
208
+ if (isWin) return installWindows(opts, bundle, companions)
209
+ return installUnix(opts, bundle, companions)
210
+ },
211
+ })