lecodes-cli 2.4.2 → 2.4.4

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.
@@ -636,6 +636,69 @@ export const LAUNCH_STORYBOARD = `<?xml version="1.0" encoding="UTF-8" standalon
636
636
  </document>
637
637
  `
638
638
 
639
+ /** App/Base.lproj/LaunchScreen.storyboard as sync writes it when app.json sets "splash" (the file
640
+ * is CLI-owned then; without the key it stays the person's, LAUNCH_STORYBOARD of init): the view's
641
+ * colour is the colorset LeCodesSplashBackground, the image — when there is one — a size×size pt
642
+ * UIImageView (aspect fit; "splash.size", 160 by default) of the imageset LeCodesSplash centred on
643
+ * the view. Both live in Assets.xcassets, which
644
+ * the project already compiles, so no pbxproj edit. `rgb` (0xRRGGBB) only feeds the storyboard's
645
+ * resource cache (Interface Builder's preview); the app reads the colorset. LeCodesShellViewController
646
+ * mounts this same storyboard over its container until the first frame. */
647
+ export const splashStoryboardTemplate = (image: boolean, rgb: number, size = 160): string => {
648
+ const channel = (shift: number) => String(((rgb >> shift) & 0xff) / 255)
649
+ // The frame is Interface Builder's preview on its 375×667 canvas; the constraints place the view.
650
+ const x = (375 - size) / 2, y = (667 - size) / 2
651
+ const imageView = image ? `
652
+ <subviews>
653
+ <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="LeCodesSplash" translatesAutoresizingMaskIntoConstraints="NO" id="LcS-im-001">
654
+ <rect key="frame" x="${x}" y="${y}" width="${size}" height="${size}"/>
655
+ <constraints>
656
+ <constraint firstAttribute="width" constant="${size}" id="LcS-cw-001"/>
657
+ <constraint firstAttribute="height" constant="${size}" id="LcS-ch-001"/>
658
+ </constraints>
659
+ </imageView>
660
+ </subviews>` : ""
661
+ const centre = image ? `
662
+ <constraints>
663
+ <constraint firstItem="LcS-im-001" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="LcS-cx-001"/>
664
+ <constraint firstItem="LcS-im-001" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="LcS-cy-001"/>
665
+ </constraints>` : ""
666
+ return `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
667
+ <!-- Generated by \`lecodes app sync\` from app.json "splash" — regenerated on every sync, do not edit. -->
668
+ <document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
669
+ <dependencies>
670
+ <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
671
+ <capability name="Named colors" minToolsVersion="9.0"/>
672
+ <capability name="Safe area layout guides" minToolsVersion="9.0"/>
673
+ <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
674
+ </dependencies>
675
+ <scenes>
676
+ <!--View Controller-->
677
+ <scene sceneID="EHf-IW-A2E">
678
+ <objects>
679
+ <viewController id="01J-lp-oVM" sceneMemberID="viewController">
680
+ <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
681
+ <rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
682
+ <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>${imageView}
683
+ <viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
684
+ <color key="backgroundColor" name="LeCodesSplashBackground"/>${centre}
685
+ </view>
686
+ </viewController>
687
+ <placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
688
+ </objects>
689
+ <point key="canvasLocation" x="53" y="375"/>
690
+ </scene>
691
+ </scenes>
692
+ <resources>${image ? `
693
+ <image name="LeCodesSplash" width="${size}" height="${size}"/>` : ""}
694
+ <namedColor name="LeCodesSplashBackground">
695
+ <color red="${channel(16)}" green="${channel(8)}" blue="${channel(0)}" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
696
+ </namedColor>
697
+ </resources>
698
+ </document>
699
+ `
700
+ }
701
+
639
702
  export const ASSETS_ROOT_JSON = `{
640
703
  "info" : {
641
704
  "author" : "xcode",
@@ -703,8 +766,16 @@ export interface RuntimePlugin {
703
766
  id: string
704
767
  /** Swift packages the target links (manifest ios.packages). */
705
768
  packages?: IosPackage[]
769
+ /** The binary targets the target links (manifest ios.binaries), by name: the xcframeworks sync
770
+ * copies to `runtimeBinaryPath(id, name)`. */
771
+ binaries?: string[]
706
772
  }
707
773
 
774
+ /** Where sync copies a plugin's prebuilt xcframework (manifest ios.binaries), relative to the
775
+ * LeCodesRuntime package: inside it, as SwiftPM takes a binary target's path, beside the vendored
776
+ * sources and with the same lifecycle (replaced on every sync, removed with the plugin). */
777
+ export const runtimeBinaryPath = (id: string, name: string): string => `Binaries/${id}/${name}.xcframework`
778
+
708
779
  /** The SDK's engine variants — one tag of lecodes-ios-sdk per variant (`<version>` = full,
709
780
  * `<version>-3d`, `-2d`, `-core`): core = UI only, 2d = + the 2D engine, 3d = + Filament with Jolt,
710
781
  * full = everything. The Swift sources are the same; the engine binary differs. */
@@ -722,8 +793,9 @@ export const sdkProducts = (variant: SdkVariant): string[] =>
722
793
  /** LeCodesRuntime/Package.swift — the CLI-owned dependency graph: the SDK (one tag per
723
794
  * variant, pinned with `.exact(…)`: the variant is the engine binary the tag's manifest names,
724
795
  * so SwiftPM must never drift on its own — moving the version is the CLI's job, `lecodes app
725
- * update` or app.json ios.sdk) + one vendored target per installed plugin. The SDK's assets
726
- * (the 3D variants' shader archives) ride inside the package. */
796
+ * update` or app.json ios.sdk) + one vendored target per installed plugin, + a binary target per
797
+ * xcframework a plugin carries (its copy under Binaries/<id>/). The SDK's assets (the 3D variants'
798
+ * shader archives) ride inside the package. */
727
799
  export const runtimePackageTemplate = (sdkVersion: string, variant: SdkVariant, plugins: RuntimePlugin[], sdkPath?: string): string => {
728
800
  const sdkDep = (name: string) => `.product(name: "${name}", package: "lecodes-ios-sdk")`
729
801
  // LECODES_IOS_SDK_PATH: the SDK-development loop — a package tree on disk (release-ios-sdk.sh
@@ -746,18 +818,31 @@ export const runtimePackageTemplate = (sdkVersion: string, variant: SdkVariant,
746
818
  }
747
819
  const packageDecls = [...packages.values()].map(pkg =>
748
820
  `\n .package(url: "${pkg.url}", ${pkg.exact ? `exact: "${pkg.exact}"` : `from: "${pkg.from}"`}),`).join("")
821
+ // The plugins' prebuilt xcframeworks (manifest ios.binaries): a binary target each, by its copy's
822
+ // path. A name is one target of the package — two plugins carrying one alike cannot both link.
823
+ const targetNames = new Map<string, string>([["LeCodesRuntime", "the runtime's own target"], ...plugins.map(p => [p.module, `the module of "${p.id}"`] as [string, string])])
824
+ for (const p of plugins) {
825
+ for (const name of p.binaries ?? []) {
826
+ const prior = targetNames.get(name)
827
+ if (prior) throw new Error(`Plugin "${p.id}" carries the binary ${name} and ${name} is ${prior} — one name is one target of the shell's package.`)
828
+ targetNames.set(name, `a binary of "${p.id}"`)
829
+ }
830
+ }
749
831
  const pluginTargets = plugins.map(p => {
750
832
  const deps = [
751
833
  sdkDep("LeCodes"),
752
834
  ...(p.packages ?? []).flatMap(pkg => pkg.products.map(name => `.product(name: "${name}", package: "${swiftPackageName(pkg.url)}")`)),
835
+ ...(p.binaries ?? []).map(name => `"${name}"`),
753
836
  ]
837
+ const binaries = (p.binaries ?? []).map(name => `
838
+ .binaryTarget(name: "${name}", path: "${runtimeBinaryPath(p.id, name)}"),`).join("")
754
839
  return `
755
840
  // plugin: ${p.id} (vendored — see plugins.lock.json)
756
841
  .target(
757
842
  name: "${p.module}",
758
843
  dependencies: [${deps.join(", ")}],
759
844
  path: "Sources/${p.module}"
760
- ),`
845
+ ),${binaries}`
761
846
  }).join("")
762
847
  const runtimeDeps = [...sdkProducts(variant).map(sdkDep), ...plugins.map(p => `"${p.module}"`)]
763
848
  return `// swift-tools-version:5.9
@@ -877,7 +962,7 @@ Yours — \`lecodes app sync\` never touches these:
877
962
  ≥1024px image and every sync renders AppIcon.png from it (flattened over \`"iconBackground"\`,
878
963
  default white — App Store icons must be opaque). Or leave the key unset and drop a
879
964
  1024×1024 PNG into AppIcon.appiconset/ yourself — sync never touches it then.
880
- - \`App/Base.lproj/LaunchScreen.storyboard\`
965
+ - \`App/Base.lproj/LaunchScreen.storyboard\` — unless \`app.json\` sets \`"splash"\` (below)
881
966
 
882
967
  CLI-owned — regenerated on every sync, hand edits are overwritten:
883
968
 
@@ -888,10 +973,18 @@ CLI-owned — regenerated on every sync, hand edits are overwritten:
888
973
  - \`App/App.entitlements\` — the capabilities the installed plugins declare (e.g. push)
889
974
  - \`LeCodesRuntime/\` — local Swift package: the lecodes-ios-sdk dependency (one tag per engine
890
975
  variant), vendored plugin sources (Sources/<Module>/), and LeCodesAppViewController — the SDK's
891
- shell controller with this project's plugins registered (App/ViewController.swift subclasses it)
976
+ shell controller with this project's plugins registered (App/ViewController.swift subclasses it).
977
+ A plugin's prebuilt xcframeworks (its manifest's \`ios.binaries\`) are copied to
978
+ Binaries/<plugin id>/: binary targets its vendored target links
892
979
  - the version values inside \`App.xcodeproj/project.pbxproj\` (from app.json)
893
980
  - this README
894
981
 
982
+ CLI-owned **only when** \`../app.json\` sets \`"splash"\` — regenerated on every sync then;
983
+ without the key sync never touches them (see "Splash screen"):
984
+
985
+ - \`App/Base.lproj/LaunchScreen.storyboard\`
986
+ - \`App/Assets.xcassets/LeCodesSplashBackground.colorset/\`, \`App/Assets.xcassets/LeCodesSplash.imageset/\`
987
+
895
988
  ## SDK variant
896
989
 
897
990
  Sync pins the SDK tag of the smallest engine variant that covers what the compiled bundle actually
@@ -900,30 +993,48 @@ physics, ~10 MB heavier), or \`full\` (everything) — \`<version>-core\`, \`-2d
900
993
  bare \`<version>\` for full. Adding your first 3D scene or 2D game to the project switches it
901
994
  automatically on the next sync; set \`ios.variant\` in \`../app.json\` to pin one instead.
902
995
 
996
+ ## Splash screen
997
+
998
+ \`"splash": { "background": "#RRGGBB", "image": "./splash.png", "size": 160 }\` in \`../app.json\`
999
+ (every key optional) makes every sync write the launch screen: a full-screen view of the colour
1000
+ (\`background\`, default \`"iconBackground"\`, default white) with the image fitted centred into a
1001
+ \`size\`×\`size\` pt square (160 by default) — never at its pixel size — from @1x/@2x/@3x renderings
1002
+ (size × 1/2/3 px: 160/320/480 at the default) of the one master. Use a square mark of at least
1003
+ 3 × size px (a non-square one is fitted into the square, a smaller one is upscaled); the same key
1004
+ draws the Android splash, which crops the mark to a circle on Android 12+ — above a \`size\` of 192
1005
+ its corners are cut there (sync warns; 288 is the most it takes). No image = the colour alone. The app holds this screen until its first frame — through the
1006
+ first-launch wait of an OTA shell too — so nothing flashes between the two. iOS caches launch
1007
+ screens: after a change, delete the app from the device (or restart the device) to see the new one.
1008
+
903
1009
  ## Over-the-air updates
904
1010
 
905
1011
  With \`"update": true\` in \`../app.json\` (server-backed projects; or
906
1012
  \`"update": {"url": "https://…/app.js"}\` for any static hosting), the app checks that URL
907
1013
  at launch — natively, before any JS runs, so a broken bundle can never block its own fix.
908
- Unchanged code costs a ~0-byte 304; new code downloads in the background and runs on the
909
- NEXT launch. Offline it's a silent no-op — the app always launches from a local bundle.
910
- An App Store update always beats a cached OTA bundle. JS only: adding/removing native
911
- plugins still needs a store release.
1014
+ Unchanged code costs a ~0-byte 304. The FIRST launch of an installed build waits for the answer
1015
+ (5 s at most, the launch screen held on screen) and runs the newest bundle at once, so a fresh
1016
+ install from the App Store never starts on the older code it shipped with; every later launch
1017
+ runs the local bundle at once, new code downloads in the background and runs on the NEXT launch.
1018
+ Offline it's a silent no-op with no wait — the app always launches from a local bundle. An App
1019
+ Store update keeps a downloaded bundle only when it is newer code than the one the update ships
1020
+ (by compile date); otherwise the store build wins. JS only: adding/removing native plugins still
1021
+ needs a store release.
912
1022
 
913
1023
  Shipping an update is just \`lecodes push --publish\` — no rebuild, no store review:
914
1024
  running apps download the new code on their next launch and run it on the launch after.
915
1025
 
916
1026
  Shells generated before OTA support have a \`ViewController.swift\` that reads app.js from
917
1027
  \`Bundle.main\` directly, so downloaded updates would never run — and since \`App/*.swift\`
918
- is yours, sync won't rewrite it (\`lecodes app doctor\` flags this). Replace the
919
- \`Bundle.main\` load with:
1028
+ is yours, sync won't rewrite it (\`lecodes app doctor\` flags this). Make it subclass
1029
+ \`LeCodesAppViewController\` (the boot above, the first-launch wait included), or replace the
1030
+ \`Bundle.main\` load with (no wait):
920
1031
 
921
1032
  \`\`\`swift
922
- LeCodesUpdater.checkForUpdate()
923
1033
  guard let code = LeCodesUpdater.activeBundle() else {
924
1034
  print("LeCodes: bundled app.js is missing — run \`lecodes app sync\`")
925
1035
  return
926
1036
  }
1037
+ LeCodesUpdater.checkForUpdate()
927
1038
  engine.run(code)
928
1039
  \`\`\`
929
1040
 
@@ -1,195 +1,195 @@
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("Taps: 0").style({ color: "#9fb0d0", fontSize: 22, marginTop: 18 })
29
-
30
- const button = UIButton(UIText("Tap me").style({ color: "#ffffff", fontSize: 22 }))
31
- .style({
32
- backgroundColor: "#4f7cff",
33
- borderRadius: 14,
34
- paddingVertical: 16,
35
- paddingHorizontal: 40,
36
- marginTop: 24,
37
- alignItems: "center",
38
- justifyContent: "center",
39
- $hovered: { backgroundColor: "#6b91ff" }, // the mouse over it (desktop); $pressed is the press
40
- })
41
- .named("tap") // names make elements targetable in tests/*.flow.json (see CLAUDE.md)
42
- .onClick(() => {
43
- count++
44
- counter.text = "Taps: " + count
45
- })
46
-
47
- const screen = UIScreen(
48
- UIColumn(UIText("Hello, LeCodes").style({ color: "#ffffff", fontSize: 32 }), button, counter)
49
- .style({ backgroundColor: "#2b3550", borderRadius: 24, padding: 40, alignItems: "center" }),
50
- )
51
- .style({ backgroundColor: "#1e2230", alignItems: "center", justifyContent: "center", padding: 40 })
52
- .named("home") // the screen root's name is what tests' \`expect: { screen: … }\` reads
53
-
54
- screen.open()
55
- `
56
-
57
- /** `--desktop`: declare the desktop target up front. Window sizes are logical units (the host
58
- * scales for HiDPI); `dev --desktop` / `desktop run` / `render --desktop` all read this, and a
59
- * desktop-only app.json makes headless render/test default to the window size too. */
60
- const appJsonDesktop = (name: string) => JSON.stringify({
61
- name,
62
- desktop: {
63
- window: { width: 1280, height: 800, minWidth: 640, minHeight: 480 },
64
- },
65
- }, null, 2) + "\n"
66
-
67
- const readmeTemplate = (name: string) => `# ${name}
68
-
69
- A LeCodes app. The whole SDK is available as globals (\`UIScreen\`, \`UIText\`, \`Scene2D\`, …) —
70
- no imports needed. \`main.ts\` is the entry point.
71
-
72
- ## Run it
73
-
74
- \`\`\`
75
- lecodes dev # live on your phone: scan the QR with the LeCodes app, hot reload
76
- lecodes compile -o app.js # bundle to a single self-contained JS file
77
- \`\`\`
78
-
79
- ## Test it (no device needed)
80
-
81
- \`\`\`
82
- lecodes render --png shot.png # headless screenshot of the running app
83
- lecodes test # run tests/*.flow.json — the starter ships with a passing one
84
- \`\`\`
85
-
86
- Scenario format + conventions: see [CLAUDE.md](./CLAUDE.md) (written for AI assistants, useful
87
- for humans too).
88
-
89
- ## Put it on the platform
90
-
91
- This project is local-only. To give it a home on le.codes (web editor, sharing, OTA updates):
92
-
93
- \`\`\`
94
- lecodes login # once per machine
95
- lecodes link # create a project from this folder and connect it
96
- lecodes push -m "initial" # upload it as the first checkpoint (--publish to go live)
97
- \`\`\`
98
-
99
- Already made the project in the web editor? \`lecodes link --project <uuid>\` adopts it — it
100
- shows what your next push would change there before connecting anything.
101
-
102
- ## Go native
103
-
104
- \`\`\`
105
- lecodes app init ios # generate a native Xcode shell in ios/
106
- lecodes app sync # embed the compiled bundle + plugins into it
107
- lecodes app open # open it in Xcode, build & run
108
- \`\`\`
109
-
110
- ## Layout
111
-
112
- - \`main.ts\` — the app's entry point. Add more .ts files freely and import them from
113
- \`main.ts\`; a file named \`main.ts\` is always the entry.
114
- - \`tests/\` — headless flow tests (\`lecodes test\`); failing steps dump the frame to
115
- \`tests/.artifacts/\`
116
- - \`CLAUDE.md\` — guidance for AI assistants (the headless feedback loop + testability conventions)
117
- - \`assets/\` — images/fonts the app references (created on demand)
118
- - \`tsconfig.json\` + \`.lecodes/types/\` — generated IDE types (rewritten by the CLI; don't edit).
119
- They come from the SDK bundled in the CLI; \`lecodes update\` refreshes them from the platform.
120
- - \`.lecodes/\` is gitignored — per-checkout state (those types + \`manifest.json\`, this machine's
121
- sync cursor once linked). After a fresh git clone: \`lecodes update\`, then
122
- \`lecodes link --project <uuid>\` to re-attach.
123
- - \`.lecodesignore\` — files a future \`lecodes push\` would skip
124
-
125
- Full command list: \`lecodes --help\`.
126
- `
127
-
128
- export default defineCommand({
129
- name: "init",
130
- summary: "Scaffold a new LOCAL project (no login, no server)",
131
- usage: "[dir]",
132
- 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.",
133
- 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)") },
134
- examples: ["lecodes init myapp", "lecodes init --desktop"],
135
- run: ({ args, flags }) => init(args[0], flags.desktop),
136
- })
137
-
138
- const init = (dir: string | undefined, forDesktop: boolean): void => {
139
- const dirArg = (dir ?? ".").replace(/[/\\]+$/, "") || "."
140
- const root = resolve(dirArg)
141
- const name = basename(root)
142
-
143
- mkdirSync(root, { recursive: true })
144
-
145
- let created = 0
146
- const write = (rel: string, content: string) => {
147
- const abs = join(root, rel)
148
- if (existsSync(abs)) { note(`kept ${rel}`); return }
149
- mkdirSync(dirname(abs), { recursive: true })
150
- writeFileSync(abs, content)
151
- log(`${c.green("created")} ${rel}`)
152
- created++
153
- }
154
-
155
- // Starter entry — a root file named main.ts is always the detected entrypoint.
156
- write("main.ts", STARTER)
157
- write("README.md", readmeTemplate(name))
158
- // --desktop: an app.json declaring the desktop target (window config in logical units).
159
- if (forDesktop) write("app.json", appJsonDesktop(name))
160
- // AI-assistant guidance + a passing flow test: the headless loop is discoverable from day one.
161
- write("CLAUDE.md", appClaudeMd(name))
162
- write(".claude/settings.json", CLAUDE_SETTINGS)
163
- write("tests/counter.flow.json", STARTER_FLOW)
164
- // Keep local render/preview outputs (screenshot.png, shots/) out of any future push.
165
- write(".lecodesignore", DEFAULT_IGNORE_FILE)
166
- // .lecodes/ is per-checkout state (generated types + the push cursor) — keep it out of git.
167
- const gi = ensureGitignore(root)
168
- if (gi === "created") { log(`${c.green("created")} .gitignore`); created++ }
169
- else if (gi === "updated") log(`${c.green("updated")} .gitignore ${c.dim("(+ .lecodes/)")}`)
170
- else note("kept .gitignore")
171
-
172
- // IDE types: the SDK's import-free global surface + a tsconfig, so VS Code resolves UIScreen,
173
- // Scene2D, setLoop, … with no server. Regenerated every run to pick up CLI upgrades.
174
- const wroteTypes = materializeTypesLocal(root)
175
- writeTsconfig(root)
176
- log(`${c.green("written")} tsconfig.json${wroteTypes ? ` + ${LECODES_DIR}/types/` : ""} (IDE types)`)
177
- if (!wroteTypes) {
178
- warnErr("Bundled SDK types not found — tsconfig written, but globals may show unresolved until the CLI is rebuilt with vendored types.")
179
- }
180
-
181
- log("")
182
- success(created
183
- ? `Local project ${c.bold(name)} ready${dirArg === "." ? "" : ` in ${c.bold(dirArg)}/`}.`
184
- : `Project ${c.bold(name)} already initialized.`)
185
- const cd = dirArg === "." ? "" : `cd ${dirArg} && `
186
- if (forDesktop) {
187
- log(` ${c.bold(cd + "lecodes dev --desktop")} run it in a desktop window with hot reload`)
188
- log(` ${c.bold(cd + "lecodes test")} run the flow tests headless (desktop-sized)`)
189
- log(` ${c.bold(cd + "lecodes desktop run")} open the compiled app in the desktop host`)
190
- } else {
191
- log(` ${c.bold(cd + "lecodes dev")} run it on a device with hot reload`)
192
- log(` ${c.bold(cd + "lecodes test")} run the flow tests headless (no device)`)
193
- log(` ${c.bold(cd + "lecodes compile -o app.js")} bundle it for LeCodes Desktop`)
194
- }
195
- }
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("Taps: 0").style({ color: "#9fb0d0", fontSize: 22, marginTop: 18 })
29
+
30
+ const button = UIButton(UIText("Tap me").style({ color: "#ffffff", fontSize: 22 }))
31
+ .style({
32
+ backgroundColor: "#4f7cff",
33
+ borderRadius: 14,
34
+ paddingVertical: 16,
35
+ paddingHorizontal: 40,
36
+ marginTop: 24,
37
+ alignItems: "center",
38
+ justifyContent: "center",
39
+ $hovered: { backgroundColor: "#6b91ff" }, // the mouse over it (desktop); $pressed is the press
40
+ })
41
+ .named("tap") // names make elements targetable in tests/*.flow.json (see CLAUDE.md)
42
+ .onClick(() => {
43
+ count++
44
+ counter.text = "Taps: " + count
45
+ })
46
+
47
+ const screen = UIScreen(
48
+ UIColumn(UIText("Hello, LeCodes").style({ color: "#ffffff", fontSize: 32 }), button, counter)
49
+ .style({ backgroundColor: "#2b3550", borderRadius: 24, padding: 40, alignItems: "center" }),
50
+ )
51
+ .style({ backgroundColor: "#1e2230", alignItems: "center", justifyContent: "center", padding: 40 })
52
+ .named("home") // the screen root's name is what tests' \`expect: { screen: … }\` reads
53
+
54
+ screen.open()
55
+ `
56
+
57
+ /** `--desktop`: declare the desktop target up front. Window sizes are logical units (the host
58
+ * scales for HiDPI); `dev --desktop` / `desktop run` / `render --desktop` all read this, and a
59
+ * desktop-only app.json makes headless render/test default to the window size too. */
60
+ const appJsonDesktop = (name: string) => JSON.stringify({
61
+ name,
62
+ desktop: {
63
+ window: { width: 1280, height: 800, minWidth: 640, minHeight: 480 },
64
+ },
65
+ }, null, 2) + "\n"
66
+
67
+ const readmeTemplate = (name: string) => `# ${name}
68
+
69
+ A LeCodes app. The whole SDK is available as globals (\`UIScreen\`, \`UIText\`, \`Scene2D\`, …) —
70
+ no imports needed. \`main.ts\` is the entry point.
71
+
72
+ ## Run it
73
+
74
+ \`\`\`
75
+ lecodes dev # live on your phone: scan the QR with the LeCodes app, hot reload
76
+ lecodes compile -o app.js # bundle to a single self-contained JS file
77
+ \`\`\`
78
+
79
+ ## Test it (no device needed)
80
+
81
+ \`\`\`
82
+ lecodes render --png shot.png # headless screenshot of the running app
83
+ lecodes test # run tests/*.flow.json — the starter ships with a passing one
84
+ \`\`\`
85
+
86
+ Scenario format + conventions: see [CLAUDE.md](./CLAUDE.md) (written for AI assistants, useful
87
+ for humans too).
88
+
89
+ ## Put it on the platform
90
+
91
+ This project is local-only. To give it a home on le.codes (web editor, sharing, OTA updates):
92
+
93
+ \`\`\`
94
+ lecodes login # once per machine
95
+ lecodes link # create a project from this folder and connect it
96
+ lecodes push -m "initial" # upload it as the first checkpoint (--publish to go live)
97
+ \`\`\`
98
+
99
+ Already made the project in the web editor? \`lecodes link --project <uuid>\` adopts it — it
100
+ shows what your next push would change there before connecting anything.
101
+
102
+ ## Go native
103
+
104
+ \`\`\`
105
+ lecodes app init ios # generate a native Xcode shell in ios/
106
+ lecodes app sync # embed the compiled bundle + plugins into it
107
+ lecodes app open # open it in Xcode, build & run
108
+ \`\`\`
109
+
110
+ ## Layout
111
+
112
+ - \`main.ts\` — the app's entry point. Add more .ts files freely and import them from
113
+ \`main.ts\`; a file named \`main.ts\` is always the entry.
114
+ - \`tests/\` — headless flow tests (\`lecodes test\`); failing steps dump the frame to
115
+ \`tests/.artifacts/\`
116
+ - \`CLAUDE.md\` — guidance for AI assistants (the headless feedback loop + testability conventions)
117
+ - \`assets/\` — images/fonts the app references (created on demand)
118
+ - \`tsconfig.json\` + \`.lecodes/types/\` — generated IDE types (rewritten by the CLI; don't edit).
119
+ They come from the SDK bundled in the CLI; \`lecodes update\` refreshes them from the platform.
120
+ - \`.lecodes/\` is gitignored — per-checkout state (those types + \`manifest.json\`, this machine's
121
+ sync cursor once linked). After a fresh git clone: \`lecodes update\`, then
122
+ \`lecodes link --project <uuid>\` to re-attach.
123
+ - \`.lecodesignore\` — files a future \`lecodes push\` would skip
124
+
125
+ Full command list: \`lecodes --help\`.
126
+ `
127
+
128
+ export default defineCommand({
129
+ name: "init",
130
+ summary: "Scaffold a new LOCAL project (no login, no server)",
131
+ usage: "[dir]",
132
+ 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.",
133
+ 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)") },
134
+ examples: ["lecodes init myapp", "lecodes init --desktop"],
135
+ run: ({ args, flags }) => init(args[0], flags.desktop),
136
+ })
137
+
138
+ const init = (dir: string | undefined, forDesktop: boolean): void => {
139
+ const dirArg = (dir ?? ".").replace(/[/\\]+$/, "") || "."
140
+ const root = resolve(dirArg)
141
+ const name = basename(root)
142
+
143
+ mkdirSync(root, { recursive: true })
144
+
145
+ let created = 0
146
+ const write = (rel: string, content: string) => {
147
+ const abs = join(root, rel)
148
+ if (existsSync(abs)) { note(`kept ${rel}`); return }
149
+ mkdirSync(dirname(abs), { recursive: true })
150
+ writeFileSync(abs, content)
151
+ log(`${c.green("created")} ${rel}`)
152
+ created++
153
+ }
154
+
155
+ // Starter entry — a root file named main.ts is always the detected entrypoint.
156
+ write("main.ts", STARTER)
157
+ write("README.md", readmeTemplate(name))
158
+ // --desktop: an app.json declaring the desktop target (window config in logical units).
159
+ if (forDesktop) write("app.json", appJsonDesktop(name))
160
+ // AI-assistant guidance + a passing flow test: the headless loop is discoverable from day one.
161
+ write("CLAUDE.md", appClaudeMd(name))
162
+ write(".claude/settings.json", CLAUDE_SETTINGS)
163
+ write("tests/counter.flow.json", STARTER_FLOW)
164
+ // Keep local render/preview outputs (screenshot.png, shots/) out of any future push.
165
+ write(".lecodesignore", DEFAULT_IGNORE_FILE)
166
+ // .lecodes/ is per-checkout state (generated types + the push cursor) — keep it out of git.
167
+ const gi = ensureGitignore(root)
168
+ if (gi === "created") { log(`${c.green("created")} .gitignore`); created++ }
169
+ else if (gi === "updated") log(`${c.green("updated")} .gitignore ${c.dim("(+ .lecodes/)")}`)
170
+ else note("kept .gitignore")
171
+
172
+ // IDE types: the SDK's import-free global surface + a tsconfig, so VS Code resolves UIScreen,
173
+ // Scene2D, setLoop, … with no server. Regenerated every run to pick up CLI upgrades.
174
+ const wroteTypes = materializeTypesLocal(root)
175
+ writeTsconfig(root)
176
+ log(`${c.green("written")} tsconfig.json${wroteTypes ? ` + ${LECODES_DIR}/types/` : ""} (IDE types)`)
177
+ if (!wroteTypes) {
178
+ warnErr("Bundled SDK types not found — tsconfig written, but globals may show unresolved until the CLI is rebuilt with vendored types.")
179
+ }
180
+
181
+ log("")
182
+ success(created
183
+ ? `Local project ${c.bold(name)} ready${dirArg === "." ? "" : ` in ${c.bold(dirArg)}/`}.`
184
+ : `Project ${c.bold(name)} already initialized.`)
185
+ const cd = dirArg === "." ? "" : `cd ${dirArg} && `
186
+ if (forDesktop) {
187
+ log(` ${c.bold(cd + "lecodes dev --desktop")} run it in a desktop window with hot reload`)
188
+ log(` ${c.bold(cd + "lecodes test")} run the flow tests headless (desktop-sized)`)
189
+ log(` ${c.bold(cd + "lecodes desktop run")} open the compiled app in the desktop host`)
190
+ } else {
191
+ log(` ${c.bold(cd + "lecodes dev")} run it on a device with hot reload`)
192
+ log(` ${c.bold(cd + "lecodes test")} run the flow tests headless (no device)`)
193
+ log(` ${c.bold(cd + "lecodes compile -o app.js")} bundle it for LeCodes Desktop`)
194
+ }
195
+ }