lecodes-cli 0.18.2 → 0.19.2
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/dist/index.js +1866 -301
- package/package.json +14 -12
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk-types.json +1 -1
- package/src/api.ts +302 -0
- package/src/browserAuth.ts +87 -0
- package/src/cmgenTool.ts +104 -0
- package/src/commands/app.ts +891 -0
- package/src/commands/appAndroid.ts +603 -0
- package/src/commands/appDesktop.ts +326 -0
- package/src/commands/appDesktopMac.ts +470 -0
- package/src/commands/appIcon.ts +187 -0
- package/src/commands/appShared.ts +448 -0
- package/src/commands/appTemplates.ts +883 -0
- package/src/commands/appTemplatesAndroid.ts +599 -0
- package/src/commands/appTemplatesGradlew.ts +9 -0
- package/src/commands/assets.ts +28 -0
- package/src/commands/clone.ts +60 -0
- package/src/commands/compile.ts +253 -0
- package/src/commands/create.ts +59 -0
- package/src/commands/design.ts +863 -0
- package/src/commands/designTemplates.ts +11 -0
- package/src/commands/desktop.ts +122 -0
- package/src/commands/dev.ts +214 -0
- package/src/commands/diff.ts +59 -0
- package/src/commands/init.ts +191 -0
- package/src/commands/install.ts +198 -0
- package/src/commands/lightmap.ts +290 -0
- package/src/commands/link.ts +147 -0
- package/src/commands/login.ts +24 -0
- package/src/commands/navmesh.ts +225 -0
- package/src/commands/pn.ts +305 -0
- package/src/commands/projectTemplates.ts +129 -0
- package/src/commands/pull.ts +109 -0
- package/src/commands/push.ts +173 -0
- package/src/commands/render.ts +414 -0
- package/src/commands/scene.ts +148 -0
- package/src/commands/shaders.ts +157 -0
- package/src/commands/status.ts +29 -0
- package/src/commands/test.ts +370 -0
- package/src/commands/thumbs.ts +178 -0
- package/src/commands/types.ts +87 -0
- package/src/commands/update.ts +190 -0
- package/src/compile/assetIcons.ts +214 -0
- package/src/compile/collect.ts +46 -0
- package/src/compile/collectLocal.ts +37 -0
- package/src/compile/designCompile.ts +104 -0
- package/src/compile/fonts.ts +156 -0
- package/src/compile/headlessBundle.ts +129 -0
- package/src/compile/nativeStack.ts +21 -0
- package/src/compile/projectCompile.ts +109 -0
- package/src/compile/sceneCompile.ts +113 -0
- package/src/compile/screenEntry.ts +127 -0
- package/src/compile/shaders.ts +245 -0
- package/src/config.ts +42 -0
- package/src/designMeta.ts +35 -0
- package/src/desktopRenderer.ts +532 -0
- package/src/desktopScript.ts +276 -0
- package/src/dev/clientTemplates.ts +160 -0
- package/src/dev/devServer.ts +292 -0
- package/src/dev/wsServer.ts +144 -0
- package/src/distRoot.ts +20 -0
- package/src/ignore.ts +163 -0
- package/src/index.ts +491 -0
- package/src/lecodes-3d-editor.d.ts +41 -0
- package/src/lecodes-assets.d.ts +7 -0
- package/src/lecodes-design.d.ts +191 -0
- package/src/lecodes-renderer.d.ts +131 -0
- package/src/localFiles.ts +144 -0
- package/src/manifest.ts +46 -0
- package/src/matcTool.ts +137 -0
- package/src/peers.ts +40 -0
- package/src/project.ts +61 -0
- package/src/projectEnv.ts +94 -0
- package/src/qrcode-terminal.d.ts +9 -0
- package/src/releases.ts +125 -0
- package/src/serverDiff.ts +78 -0
- package/src/textDiff.ts +103 -0
- package/src/types.ts +0 -0
- package/src/util.ts +146 -0
- package/runtime/sdk/animate/animate.ts +0 -238
- package/runtime/sdk/animate/bezier.ts +0 -138
- package/runtime/sdk/animate/easings.ts +0 -126
- package/runtime/sdk/canvas/Canvas.ts +0 -305
- package/runtime/sdk/core/Aspect.ts +0 -512
- package/runtime/sdk/core/InspectorUI.ts +0 -212
- package/runtime/sdk/core/color.ts +0 -66
- package/runtime/sdk/core/compWrite.ts +0 -42
- package/runtime/sdk/core/events.ts +0 -38
- package/runtime/sdk/core/fields.ts +0 -120
- package/runtime/sdk/core/registry.ts +0 -23
- package/runtime/sdk/core/signals.ts +0 -277
- package/runtime/sdk/core/time.ts +0 -81
- package/runtime/sdk/g2/Camera2D.ts +0 -40
- package/runtime/sdk/g2/CharacterController2D.ts +0 -276
- package/runtime/sdk/g2/Node2D.ts +0 -267
- package/runtime/sdk/g2/OneWay2D.ts +0 -66
- package/runtime/sdk/g2/Physics2D.ts +0 -346
- package/runtime/sdk/g2/Scene2D.ts +0 -209
- package/runtime/sdk/g2/Shape2D.ts +0 -259
- package/runtime/sdk/g2/Sprite.ts +0 -89
- package/runtime/sdk/g2/SpriteAnimation.ts +0 -171
- package/runtime/sdk/g2/SpriteSheet.ts +0 -166
- package/runtime/sdk/g2/Texture2D.ts +0 -47
- package/runtime/sdk/g2/Tilemap.ts +0 -41
- package/runtime/sdk/g2/Tileset.ts +0 -71
- package/runtime/sdk/g2/Trigger2D.ts +0 -77
- package/runtime/sdk/g2/autotile.ts +0 -433
- package/runtime/sdk/g2/cells.ts +0 -91
- package/runtime/sdk/g2/defineScene2d.ts +0 -381
- package/runtime/sdk/g2/groups2d.ts +0 -106
- package/runtime/sdk/g2/loop.ts +0 -50
- package/runtime/sdk/g2/scenarios2d.ts +0 -69
- package/runtime/sdk/g2/touch.ts +0 -83
- package/runtime/sdk/gl/Camera.ts +0 -160
- package/runtime/sdk/gl/CameraPlace.ts +0 -52
- package/runtime/sdk/gl/CharacterController.ts +0 -238
- package/runtime/sdk/gl/Gearbox.ts +0 -212
- package/runtime/sdk/gl/Geometry.ts +0 -279
- package/runtime/sdk/gl/IK.ts +0 -193
- package/runtime/sdk/gl/InstancedMesh.ts +0 -132
- package/runtime/sdk/gl/Light.ts +0 -99
- package/runtime/sdk/gl/Lightmap.ts +0 -179
- package/runtime/sdk/gl/Material.ts +0 -245
- package/runtime/sdk/gl/Mesh.ts +0 -83
- package/runtime/sdk/gl/Model.ts +0 -64
- package/runtime/sdk/gl/Node.ts +0 -350
- package/runtime/sdk/gl/Noise.ts +0 -30
- package/runtime/sdk/gl/Particles.ts +0 -676
- package/runtime/sdk/gl/Physics.ts +0 -222
- package/runtime/sdk/gl/Plane.ts +0 -53
- package/runtime/sdk/gl/Ray.ts +0 -16
- package/runtime/sdk/gl/Scene.ts +0 -479
- package/runtime/sdk/gl/Shape.ts +0 -377
- package/runtime/sdk/gl/Texture.ts +0 -46
- package/runtime/sdk/gl/Trigger.ts +0 -45
- package/runtime/sdk/gl/Vehicle.ts +0 -473
- package/runtime/sdk/gl/Wheel.ts +0 -240
- package/runtime/sdk/gl/animation/AnimationClip.ts +0 -204
- package/runtime/sdk/gl/animation/Animator.ts +0 -87
- package/runtime/sdk/gl/animation/Layer.ts +0 -29
- package/runtime/sdk/gl/animation/Loop.ts +0 -25
- package/runtime/sdk/gl/animation/Playback.ts +0 -43
- package/runtime/sdk/gl/animation/core.ts +0 -294
- package/runtime/sdk/gl/controls.ts +0 -95
- package/runtime/sdk/gl/physicsEvents.ts +0 -20
- package/runtime/sdk/gl/scenarios.ts +0 -291
- package/runtime/sdk/gl/state.ts +0 -6
- package/runtime/sdk/gl/touch.ts +0 -68
- package/runtime/sdk/inject.ts +0 -186
- package/runtime/sdk/math/Mathf.ts +0 -118
- package/runtime/sdk/math/mat4.ts +0 -278
- package/runtime/sdk/math/quat.ts +0 -232
- package/runtime/sdk/math/vec.ts +0 -255
- package/runtime/sdk/plugins/camera.ts +0 -81
- package/runtime/sdk/plugins/geolocation.ts +0 -123
- package/runtime/sdk/plugins/oauth.ts +0 -61
- package/runtime/sdk/plugins/permission.ts +0 -7
- package/runtime/sdk/plugins/push.ts +0 -132
- package/runtime/sdk/plugins/qr.ts +0 -73
- package/runtime/sdk/plugins/service.ts +0 -47
- package/runtime/sdk/runtime/app.ts +0 -101
- package/runtime/sdk/runtime/appEvents.ts +0 -54
- package/runtime/sdk/runtime/channel.ts +0 -61
- package/runtime/sdk/runtime/clipboard.ts +0 -20
- package/runtime/sdk/runtime/datetime.ts +0 -329
- package/runtime/sdk/runtime/device.ts +0 -293
- package/runtime/sdk/runtime/fetch.ts +0 -77
- package/runtime/sdk/runtime/files.ts +0 -23
- package/runtime/sdk/runtime/input.ts +0 -175
- package/runtime/sdk/runtime/media.ts +0 -111
- package/runtime/sdk/runtime/misc.ts +0 -16
- package/runtime/sdk/runtime/net.ts +0 -36
- package/runtime/sdk/runtime/rpc.ts +0 -218
- package/runtime/sdk/runtime/service.ts +0 -83
- package/runtime/sdk/runtime/share.ts +0 -9
- package/runtime/sdk/runtime/storage.ts +0 -13
- package/runtime/sdk/runtime/touch.ts +0 -76
- package/runtime/sdk/scene/defineScene.ts +0 -1227
- package/runtime/sdk/scene/editorPlugins.ts +0 -92
- package/runtime/sdk/scene/gizmos.ts +0 -148
- package/runtime/sdk/scene/grammar.ts +0 -120
- package/runtime/sdk/scene/material.ts +0 -188
- package/runtime/sdk/server/auth/appConfig.ts +0 -12
- package/runtime/sdk/server/auth/global.ts +0 -80
- package/runtime/sdk/server/auth/host.ts +0 -318
- package/runtime/sdk/server/auth/models.ts +0 -83
- package/runtime/sdk/server/auth/types.ts +0 -50
- package/runtime/sdk/server/channel.ts +0 -56
- package/runtime/sdk/server/context.ts +0 -36
- package/runtime/sdk/server/db/defineDb.ts +0 -237
- package/runtime/sdk/server/db/fields.ts +0 -132
- package/runtime/sdk/server/db/httpTransport.ts +0 -93
- package/runtime/sdk/server/db/index.ts +0 -7
- package/runtime/sdk/server/db/marci/query.ts +0 -412
- package/runtime/sdk/server/db/types.ts +0 -202
- package/runtime/sdk/server/errors.ts +0 -12
- package/runtime/sdk/server/host.ts +0 -74
- package/runtime/sdk/server/inject.ts +0 -13
- package/runtime/sdk/server/runtime.ts +0 -133
- package/runtime/sdk/server/validate.ts +0 -87
- package/runtime/sdk/ui/NativeView.ts +0 -142
- package/runtime/sdk/ui/UI.ts +0 -39
- package/runtime/sdk/ui/UIBottomSheet.ts +0 -139
- package/runtime/sdk/ui/UIButton.ts +0 -101
- package/runtime/sdk/ui/UIContainer.ts +0 -60
- package/runtime/sdk/ui/UIImage.ts +0 -83
- package/runtime/sdk/ui/UIInput.ts +0 -185
- package/runtime/sdk/ui/UIModal.ts +0 -139
- package/runtime/sdk/ui/UINode.ts +0 -826
- package/runtime/sdk/ui/UIPager.ts +0 -362
- package/runtime/sdk/ui/UIPopover.ts +0 -100
- package/runtime/sdk/ui/UIScreen.ts +0 -123
- package/runtime/sdk/ui/UIScrollable.ts +0 -87
- package/runtime/sdk/ui/UISpacer.ts +0 -14
- package/runtime/sdk/ui/UITabs.ts +0 -236
- package/runtime/sdk/ui/UIText.ts +0 -51
- package/runtime/sdk/ui/UIVideo.ts +0 -88
- package/runtime/sdk/ui/UIVirtualizedList.ts +0 -241
- package/runtime/sdk/ui/UIWidget.ts +0 -127
- package/runtime/sdk/ui/fonts.ts +0 -13
- package/runtime/sdk/ui/presentable.ts +0 -117
- package/runtime/sdk/ui/router.ts +0 -132
- package/runtime/sdk/ui/theme.ts +0 -84
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import { basename, join, relative, resolve } from "node:path"
|
|
4
|
+
import { findProjectRoot } from "../manifest"
|
|
5
|
+
import { buildBundle } from "./compile"
|
|
6
|
+
import { readAppConfigAt, resolveDesktopRenderer } from "./appShared"
|
|
7
|
+
import { runDesktopRender } from "../desktopRenderer"
|
|
8
|
+
import { CliError, c, flagStr, flagBool, logErr, note, warnErr, type Args } from "../util"
|
|
9
|
+
import { loadCreatorNav, NavBuilder, readNgeo, navMeshTriangles, type BuildParams } from "lecodes-creator-nav"
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* `lecodes navmesh bake` — build the level's navigation mesh (docs/navmesh-plan.md §4). Two halves:
|
|
13
|
+
*
|
|
14
|
+
* 1. The app is compiled with a synthetic entry that puts it in BAKE mode
|
|
15
|
+
* (`globalThis.__lecodesNavmesh = { outDir, name }`, then `import "./main"`) and run in the
|
|
16
|
+
* native desktop host; when the level is complete — the scene file's `env.navmesh`, or a
|
|
17
|
+
* hand-written `NavMesh.load(...)` — the SDK calls `_creator.physicsStaticGeometry` instead of
|
|
18
|
+
* loading: every STATIC Jolt body's triangles land in `<out>/<name>.navgeo`, and the host's
|
|
19
|
+
* stdout carries `[navmesh] geometry …` / `[navmesh] config {…}` / `[navmesh] done`.
|
|
20
|
+
* 2. Recast runs HERE (creator-nav's wasm): the dump + the scene's agent size (+ the flags below)
|
|
21
|
+
* → a tiled Detour navmesh → `<out>/<name>.navmesh` (LNAV: header JSON + tiles).
|
|
22
|
+
*
|
|
23
|
+
* Running the build in the CLI keeps the host generic (a geometry dump, useful on its own),
|
|
24
|
+
* lets the bake parameters iterate without a host rebuild, and is the same code a future headless
|
|
25
|
+
* bake reuses. The desktop host must know the dump (built from packages/desktop, or a release that
|
|
26
|
+
* ships it: LECODES_DESKTOP_EXE for a local build until then).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const num = (args: Args, key: string, fallback: number, min: number, max: number): number => {
|
|
30
|
+
const raw = flagStr(args, key)
|
|
31
|
+
if (raw === undefined) return fallback
|
|
32
|
+
const n = Number(raw)
|
|
33
|
+
if (!Number.isFinite(n) || n < min || n > max) throw new CliError(`--${key} wants a number in ${min}..${max}, got "${raw}".`)
|
|
34
|
+
return n
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type BakeConfigLine = {
|
|
38
|
+
agent: { radius: number, height: number, climb: number, slope: number }
|
|
39
|
+
areas: Record<string, number>
|
|
40
|
+
costs: Record<string, number>
|
|
41
|
+
data?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The first `*.scene.ts` under src/ (or the root) that declares `navmesh:` names the output by default. */
|
|
45
|
+
const guessName = (root: string): string | null => {
|
|
46
|
+
const dirs = [ join(root, "src"), root ]
|
|
47
|
+
for (const dir of dirs) {
|
|
48
|
+
if (!existsSync(dir)) continue
|
|
49
|
+
let entries: string[]
|
|
50
|
+
try { entries = readdirSync(dir) } catch { continue }
|
|
51
|
+
for (const f of entries) {
|
|
52
|
+
if (!f.endsWith(".scene.ts")) continue
|
|
53
|
+
try {
|
|
54
|
+
if (/\bnavmesh\s*:/.test(readFileSync(join(dir, f), "utf8"))) return f.slice(0, -".scene.ts".length)
|
|
55
|
+
} catch { /* skip */ }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** An empty LNAV (header only, one dummy tile slot) so the compiler can stage the file before the
|
|
62
|
+
* first bake; NavMesh.load reads `stats.polys === 0` as "placeholder — rebake". */
|
|
63
|
+
const placeholderLnav = (): Buffer => {
|
|
64
|
+
const json = Buffer.from(JSON.stringify({ version: 1, placeholder: true, stats: { tiles: 0, polys: 0 } }), "utf8")
|
|
65
|
+
const jsonPadded = Buffer.alloc(Math.ceil(json.length / 4) * 4)
|
|
66
|
+
json.copy(jsonPadded)
|
|
67
|
+
const head = Buffer.alloc(12)
|
|
68
|
+
head.writeUInt32LE(0x56414e4c, 0) // 'LNAV'
|
|
69
|
+
head.writeUInt32LE(1, 4)
|
|
70
|
+
head.writeUInt32LE(json.length, 8)
|
|
71
|
+
// dtNavMeshParams: orig[3] f32, tileWidth, tileHeight f32, maxTiles, maxPolys i32
|
|
72
|
+
const params = Buffer.alloc(28)
|
|
73
|
+
params.writeFloatLE(1, 12)
|
|
74
|
+
params.writeFloatLE(1, 16)
|
|
75
|
+
params.writeInt32LE(1, 20)
|
|
76
|
+
params.writeInt32LE(1, 24)
|
|
77
|
+
const tiles = Buffer.alloc(4)
|
|
78
|
+
return Buffer.concat([ head, jsonPadded, params, tiles ])
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const writeObj = (path: string, tris: Float32Array): void => {
|
|
82
|
+
const lines: string[] = [ "# navmesh polygons — lecodes navmesh bake --obj" ]
|
|
83
|
+
for (let i = 0; i < tris.length; i += 3) lines.push(`v ${tris[i]!.toFixed(4)} ${tris[i + 1]!.toFixed(4)} ${tris[i + 2]!.toFixed(4)}`)
|
|
84
|
+
for (let i = 0; i < tris.length / 3; i += 3) lines.push(`f ${i + 1} ${i + 2} ${i + 3}`)
|
|
85
|
+
writeFileSync(path, lines.join("\n") + "\n")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const bake = async (args: Args): Promise<void> => {
|
|
89
|
+
let root: string
|
|
90
|
+
try { root = findProjectRoot(process.cwd()) } catch { root = process.cwd() }
|
|
91
|
+
const outDir = resolve(root, flagStr(args, "out") ?? "assets/nav")
|
|
92
|
+
const entryFlag = flagStr(args, "entry")
|
|
93
|
+
const entryFile = entryFlag ?? "main.ts"
|
|
94
|
+
if (!existsSync(resolve(root, entryFile))) {
|
|
95
|
+
throw new CliError(`No ${entryFile} in ${root} — pass --entry <file> (the file that builds the level: a scene file with env.navmesh, or code calling NavMesh.load).`)
|
|
96
|
+
}
|
|
97
|
+
const name = flagStr(args, "name") ?? guessName(root) ?? "scene"
|
|
98
|
+
const frames = num(args, "frames", 900, 30, 100000)
|
|
99
|
+
const win = readAppConfigAt(root)?.desktop?.window
|
|
100
|
+
const width = Number(flagStr(args, "width")) || win?.width || 1280
|
|
101
|
+
const height = Number(flagStr(args, "height")) || win?.height || 720
|
|
102
|
+
const logs = flagBool(args, "logs")
|
|
103
|
+
const keepGeometry = flagBool(args, "keep-geometry")
|
|
104
|
+
const obj = flagBool(args, "obj")
|
|
105
|
+
|
|
106
|
+
// The navmesh file must exist BEFORE the compile: the scene references it through asset(), and a
|
|
107
|
+
// missing asset is a broken bundle. The first bake starts from a placeholder the real file overwrites.
|
|
108
|
+
mkdirSync(outDir, { recursive: true })
|
|
109
|
+
const navmeshPath = join(outDir, `${name}.navmesh`)
|
|
110
|
+
if (!existsSync(navmeshPath)) writeFileSync(navmeshPath, placeholderLnav())
|
|
111
|
+
|
|
112
|
+
const importPath = "./" + entryFile.replace(/\\/g, "/").replace(/\.tsx?$/, "")
|
|
113
|
+
const entryPath = "/.navmesh-entry.ts"
|
|
114
|
+
const cfg = { outDir: outDir.replace(/\\/g, "/"), name }
|
|
115
|
+
const entryText = `globalThis.__lecodesNavmesh = ${JSON.stringify(cfg)}\nimport ${JSON.stringify(importPath)}\n`
|
|
116
|
+
|
|
117
|
+
const desktopRenderer = resolveDesktopRenderer(root, args)
|
|
118
|
+
const { js, resources } = await buildBundle(args, undefined, {
|
|
119
|
+
localAssets: true, desktopRenderer,
|
|
120
|
+
extraEntries: [{ path: entryPath, type: "text", text: entryText }],
|
|
121
|
+
entryOverride: entryPath,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const scratch = mkdtempSync(join(tmpdir(), "lecodes-navmesh-"))
|
|
125
|
+
const outPng = join(scratch, "frame.png")
|
|
126
|
+
note(`Dumping the level's static collision geometry through the desktop host → ${relative(root, outDir) || "."}/${name}.navgeo…`)
|
|
127
|
+
let output = ""
|
|
128
|
+
try {
|
|
129
|
+
const timeoutMs = Number(flagStr(args, "timeout")) || 600000
|
|
130
|
+
const res = await runDesktopRender(js, resources, {
|
|
131
|
+
renderer: desktopRenderer, width, height, frames, outPng, logs, timeoutMs,
|
|
132
|
+
stopOnLine: (chunk) => chunk.includes("[navmesh] done") || chunk.includes("[navmesh] error"),
|
|
133
|
+
})
|
|
134
|
+
output = res.output
|
|
135
|
+
} catch (e) {
|
|
136
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
137
|
+
if (!/\[navmesh\] done/.test(msg)) throw e
|
|
138
|
+
output = msg
|
|
139
|
+
} finally {
|
|
140
|
+
rmSync(scratch, { recursive: true, force: true })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// the markers are console.log lines from the SDK — the host prefixes them ("[DEBUG] [navmesh] …")
|
|
144
|
+
const lines = output.split(/\r?\n/).filter((l) => l.includes("[navmesh]")).map((l) => l.slice(l.indexOf("[navmesh]")))
|
|
145
|
+
let config: BakeConfigLine | null = null
|
|
146
|
+
let triangles = 0
|
|
147
|
+
for (const l of lines) {
|
|
148
|
+
const text = l.slice("[navmesh]".length).trim()
|
|
149
|
+
if (text === "done") continue
|
|
150
|
+
if (text.startsWith("config ")) { try { config = JSON.parse(text.slice(7)) as BakeConfigLine } catch { /* reported below */ } continue }
|
|
151
|
+
const geo = /^geometry (\d+) triangles/.exec(text)
|
|
152
|
+
if (geo) triangles = Number(geo[1])
|
|
153
|
+
logErr(text.startsWith("error") ? c.red(`✗ ${text}`) : c.dim(` ${text}`))
|
|
154
|
+
}
|
|
155
|
+
if (!/\[navmesh\] done/.test(output)) {
|
|
156
|
+
if (lines.some((l) => /cannot dump collision geometry/.test(l))) {
|
|
157
|
+
throw new CliError("This desktop host has no collision-geometry dump. Build packages/desktop and point LECODES_DESKTOP_EXE at build/lecodes-desktop.exe, or update: lecodes desktop update.")
|
|
158
|
+
}
|
|
159
|
+
if (lines.some((l) => /error/.test(l))) throw new CliError("The bake failed — see the lines above.")
|
|
160
|
+
warnErr("The host ran to its frame cap without dumping geometry. Make sure the level declares `env.navmesh` (a scene file) or calls NavMesh.load(...) after its static bodies exist, and skips menus when NavMesh.baking is true. More load headroom: --frames <n>. Host output: --logs.")
|
|
161
|
+
throw new CliError("Nothing was baked.")
|
|
162
|
+
}
|
|
163
|
+
if (!config) throw new CliError("The host reported a dump but no `[navmesh] config` line — SDK/host mismatch?")
|
|
164
|
+
const geoPath = join(outDir, `${name}.navgeo`)
|
|
165
|
+
if (!existsSync(geoPath)) throw new CliError(`The host reported a dump but ${geoPath} is missing.`)
|
|
166
|
+
|
|
167
|
+
// ---- Recast (wasm) ----
|
|
168
|
+
const geo = readNgeo(new Uint8Array(readFileSync(geoPath)))
|
|
169
|
+
const params: BuildParams = {
|
|
170
|
+
agentRadius: num(args, "radius", config.agent.radius, 0.05, 10),
|
|
171
|
+
agentHeight: num(args, "height", config.agent.height, 0.1, 20),
|
|
172
|
+
agentMaxClimb: num(args, "climb", config.agent.climb, 0, 10),
|
|
173
|
+
agentMaxSlope: num(args, "slope", config.agent.slope, 0, 89),
|
|
174
|
+
cellSize: num(args, "cell", 0.25, 0.01, 5),
|
|
175
|
+
cellHeight: num(args, "cell-height", 0.1, 0.01, 5),
|
|
176
|
+
tileSize: num(args, "tile", 48, 0, 1024),
|
|
177
|
+
regionMinSize: num(args, "region-min", 8, 0, 1000),
|
|
178
|
+
regionMergeSize: num(args, "region-merge", 20, 0, 1000),
|
|
179
|
+
edgeMaxLen: num(args, "edge-max", 12, 0, 1000),
|
|
180
|
+
edgeMaxError: num(args, "edge-error", 1.3, 0.1, 10),
|
|
181
|
+
detailSampleDist: num(args, "detail", 6, 0, 100),
|
|
182
|
+
detailMaxError: num(args, "detail-error", 1, 0, 100),
|
|
183
|
+
}
|
|
184
|
+
note(`Building the navmesh: ${geo.triangleCount} triangles from ${geo.bodyCount} static bodies, agent r ${params.agentRadius} / h ${params.agentHeight} / climb ${params.agentMaxClimb} / slope ${params.agentMaxSlope}°, cell ${params.cellSize} m…`)
|
|
185
|
+
const mod = await loadCreatorNav()
|
|
186
|
+
const builder = new NavBuilder(mod, params)
|
|
187
|
+
let result
|
|
188
|
+
try {
|
|
189
|
+
builder.addSoup(geo.triangles, geo.areas)
|
|
190
|
+
const meta = {
|
|
191
|
+
scene: name, areas: config.areas, costs: config.costs, bakedAt: new Date().toISOString(),
|
|
192
|
+
params, triangles: geo.triangleCount, bodies: geo.bodyCount,
|
|
193
|
+
}
|
|
194
|
+
let lastTile = -1
|
|
195
|
+
result = builder.run(meta, (tile, count, message) => {
|
|
196
|
+
if (message) logErr(c.dim(` recast: ${message}`))
|
|
197
|
+
else if (count > 8 && tile !== lastTile && (tile + 1) % Math.max(1, Math.floor(count / 8)) === 0) { lastTile = tile; logErr(c.dim(` tile ${tile + 1}/${count}`)) }
|
|
198
|
+
})
|
|
199
|
+
} finally {
|
|
200
|
+
builder.destroy()
|
|
201
|
+
}
|
|
202
|
+
if (result.polys === 0) {
|
|
203
|
+
throw new CliError("The build produced no polygons — nothing walkable for this agent. Check the cell size against the level's scale, the slope, and that the floor is a STATIC body (Shape + Physics { motion: 'static' }).")
|
|
204
|
+
}
|
|
205
|
+
writeFileSync(navmeshPath, result.data)
|
|
206
|
+
if (obj) {
|
|
207
|
+
const objPath = join(outDir, `${name}.navmesh.obj`)
|
|
208
|
+
writeObj(objPath, navMeshTriangles(mod, result.data))
|
|
209
|
+
logErr(c.dim(` wrote ${relative(root, objPath)} (the polygon mesh, for inspection)`))
|
|
210
|
+
}
|
|
211
|
+
if (!keepGeometry) unlinkSync(geoPath)
|
|
212
|
+
const kb = Math.round(statSync(navmeshPath).size / 1024)
|
|
213
|
+
const b = result.bounds
|
|
214
|
+
logErr(c.dim(` ${result.tiles} tiles, ${result.polys} polygons, ${(b.max[0] - b.min[0]).toFixed(0)} × ${(b.max[2] - b.min[2]).toFixed(0)} m, ${result.time.toFixed(1)} s`))
|
|
215
|
+
logErr(c.green(`✓ baked → ${relative(root, navmeshPath).replace(/\\/g, "/")} (${kb} KB)`))
|
|
216
|
+
if (!config.data) {
|
|
217
|
+
logErr(c.dim(` load it with env.navmesh: { data: asset('<rel>/${basename(navmeshPath)}') } in the scene file, or NavMesh.load(scene, { data: asset(...) })`))
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export const navmesh = async (args: Args): Promise<void> => {
|
|
222
|
+
const sub = args._[0]
|
|
223
|
+
if (sub === "bake") return bake({ ...args, _: args._.slice(1) })
|
|
224
|
+
throw new CliError("Usage: lecodes navmesh bake [--out assets/nav] [--name <scene>] [--entry main.ts] [--radius 0.4] [--height 1.8] [--climb 0.4] [--slope 50] [--cell 0.25] [--cell-height 0.1] [--tile 48] [--obj] [--keep-geometry] [--frames 900] [--logs]")
|
|
225
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import {
|
|
4
|
+
createPushKey, forgetPushDevice, listPushDevices, listPushKeys, revokePushKey, sendPush,
|
|
5
|
+
type PushDevice, type PushSendPayload,
|
|
6
|
+
} from "../api"
|
|
7
|
+
import { loadConfig, requireApiUrl, requireToken } from "../config"
|
|
8
|
+
import { findProjectRoot, readManifest } from "../manifest"
|
|
9
|
+
import { CliError, c, confirm, flagBool, flagStr, info, log, success, warn, type Args } from "../util"
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* `lecodes pn` — push notifications for this project (docs/push-plan.md). Named `pn` because
|
|
13
|
+
* `lecodes push` is already the code-upload command.
|
|
14
|
+
*
|
|
15
|
+
* pn send test-send using the PAT from `lecodes login` — /push/send accepts a
|
|
16
|
+
* developer-role user token exactly like a project push key, so the dev loop
|
|
17
|
+
* needs NO key at all.
|
|
18
|
+
* pn devices which devices actually registered — the first thing to check when a push
|
|
19
|
+
* doesn't arrive (an empty list means the app never called Push.register()).
|
|
20
|
+
* pn keys mint / list / revoke the `lecodes_pk_…` keys. The SERVER generates these;
|
|
21
|
+
* the plaintext exists in the mint response and nowhere else, so `keys new`
|
|
22
|
+
* is the only moment it can be captured (--env captures it for you).
|
|
23
|
+
*
|
|
24
|
+
* Everything is scoped to the project in .lecodes/manifest.json — no uuid to look up.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const ENV_VAR = "LECODES_PUSH_KEY"
|
|
28
|
+
|
|
29
|
+
type Context = { root: string, uuid: string, name: string, apiUrl: string, token: string }
|
|
30
|
+
|
|
31
|
+
const context = (): Context => {
|
|
32
|
+
const root = findProjectRoot(process.cwd())
|
|
33
|
+
const manifest = readManifest(root)
|
|
34
|
+
const config = loadConfig()
|
|
35
|
+
return { root, uuid: manifest.uuid, name: manifest.name, apiUrl: requireApiUrl(config), token: requireToken(config) }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A compact "3h ago" — absolute timestamps make these tables unreadable at a glance. */
|
|
39
|
+
const ago = (iso: string | null): string => {
|
|
40
|
+
if (!iso) return c.dim("never")
|
|
41
|
+
const ms = Date.now() - new Date(iso).getTime()
|
|
42
|
+
if (!Number.isFinite(ms)) return c.dim("—")
|
|
43
|
+
const mins = Math.round(ms / 60_000)
|
|
44
|
+
if (mins < 1) return "just now"
|
|
45
|
+
if (mins < 60) return `${mins}m ago`
|
|
46
|
+
const hours = Math.round(mins / 60)
|
|
47
|
+
if (hours < 24) return `${hours}h ago`
|
|
48
|
+
return `${Math.round(hours / 24)}d ago`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Column widths must count PRINTED characters: on a TTY the cells carry ANSI color codes, and
|
|
52
|
+
// padEnd would count those too — every colored column drifting a few chars wide.
|
|
53
|
+
const ANSI = /\x1b\[[0-9;]*m/g
|
|
54
|
+
const printed = (s: string) => s.replace(ANSI, "").length
|
|
55
|
+
|
|
56
|
+
/** Print rows as aligned columns (last column unpadded so colors don't drag trailing spaces). */
|
|
57
|
+
const table = (rows: string[][]) => {
|
|
58
|
+
const widths = rows[0].map((_, i) => Math.max(...rows.map(r => printed(r[i] ?? ""))))
|
|
59
|
+
for (const row of rows) {
|
|
60
|
+
const cells = row.map((cell, i) =>
|
|
61
|
+
i === row.length - 1 ? cell : cell + " ".repeat(Math.max(0, widths[i] - printed(cell))))
|
|
62
|
+
log(" " + cells.join(" "))
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Comma-separated list flag. parseArgs keeps only the LAST of a repeated `--user`, so repeats
|
|
67
|
+
* would silently drop targets — commas are the form that can't lose one. */
|
|
68
|
+
const listFlag = (args: Args, ...keys: string[]): string[] => {
|
|
69
|
+
const raw = flagStr(args, ...keys)
|
|
70
|
+
if (!raw) return []
|
|
71
|
+
return [ ...new Set(raw.split(",").map(s => s.trim()).filter(Boolean)) ]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* ---------------------------------------- send ---------------------------------------- */
|
|
75
|
+
|
|
76
|
+
/** `lecodes pn send --title … [--user u42] [--to pa_…]` — a real send through the relay,
|
|
77
|
+
* authenticated with your own token. Targets are the same ones a production server uses. */
|
|
78
|
+
const pnSend = async (args: Args) => {
|
|
79
|
+
const ctx = context()
|
|
80
|
+
|
|
81
|
+
const title = flagStr(args, "title", "t") ?? args._[1]
|
|
82
|
+
if (!title) throw new CliError('A title is required: lecodes pn send --title "Order shipped" --user u42')
|
|
83
|
+
|
|
84
|
+
const users = listFlag(args, "user", "users", "u")
|
|
85
|
+
const to = listFlag(args, "to", "address", "addresses")
|
|
86
|
+
if (users.length === 0 && to.length === 0) {
|
|
87
|
+
throw new CliError("Nothing to send to — pass --user <id> (from Push.register({ user })) or --to <pa_…>. `lecodes pn devices` lists both.")
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const badgeRaw = flagStr(args, "badge")
|
|
91
|
+
const badge = badgeRaw === undefined ? undefined : Number(badgeRaw)
|
|
92
|
+
if (badge !== undefined && !Number.isInteger(badge)) throw new CliError(`--badge must be a whole number (got "${badgeRaw}")`)
|
|
93
|
+
|
|
94
|
+
const dataRaw = flagStr(args, "data")
|
|
95
|
+
let data: Record<string, unknown> | undefined
|
|
96
|
+
if (dataRaw !== undefined) {
|
|
97
|
+
try { data = JSON.parse(dataRaw) as Record<string, unknown> } catch { throw new CliError("--data must be a JSON object, e.g. --data '{\"orderId\":42}'") }
|
|
98
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) throw new CliError("--data must be a JSON object, e.g. --data '{\"orderId\":42}'")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const payload: PushSendPayload = {
|
|
102
|
+
title,
|
|
103
|
+
body: flagStr(args, "body", "b"),
|
|
104
|
+
url: flagStr(args, "url"),
|
|
105
|
+
users: users.length ? users : undefined,
|
|
106
|
+
to: to.length ? to : undefined,
|
|
107
|
+
data,
|
|
108
|
+
badge,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const result = await sendPush(ctx.apiUrl, ctx.token, ctx.uuid, payload)
|
|
112
|
+
|
|
113
|
+
if (result.sent > 0) success(`Sent to ${result.sent} device${result.sent === 1 ? "" : "s"}.`)
|
|
114
|
+
if (result.failed.length === 0) {
|
|
115
|
+
if (result.sent === 0) {
|
|
116
|
+
warn("Matched 0 devices — nobody with that user id has registered. Run `lecodes pn devices` to see who has.")
|
|
117
|
+
}
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
warn(`${result.failed.length} failed:`)
|
|
121
|
+
for (const failure of result.failed) log(` ${c.dim(failure.address)} ${c.red(failure.reason)}`)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* -------------------------------------- devices --------------------------------------- */
|
|
125
|
+
|
|
126
|
+
const deviceLabel = (device: PushDevice): string => {
|
|
127
|
+
const env = device.environment === "sandbox" ? c.yellow(" sandbox") : ""
|
|
128
|
+
return `${device.platform}${env}`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** `lecodes pn devices [--user u42] [--forget pa_…]` — the registration ledger. Raw OS tokens
|
|
132
|
+
* never leave the backend, so this is address + who/where/when only. --forget drops a dead row
|
|
133
|
+
* (rotated token, wrong project) that would otherwise sit here forever and fail every send. */
|
|
134
|
+
const pnDevices = async (args: Args) => {
|
|
135
|
+
const ctx = context()
|
|
136
|
+
const user = flagStr(args, "user", "u")
|
|
137
|
+
|
|
138
|
+
const forget = flagStr(args, "forget")
|
|
139
|
+
if (forget) {
|
|
140
|
+
if (!flagBool(args, "yes", "y")) {
|
|
141
|
+
warn(`Forgetting ${c.bold(forget)} — sends to this address stop immediately. The device gets a NEW address on its next Push.register().`)
|
|
142
|
+
if (!await confirm("Forget it?")) return info("Cancelled.")
|
|
143
|
+
}
|
|
144
|
+
await forgetPushDevice(ctx.apiUrl, ctx.token, ctx.uuid, forget)
|
|
145
|
+
success(`Forgot ${c.bold(forget)}.`)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const { devices, truncated } = await listPushDevices(ctx.apiUrl, ctx.token, ctx.uuid, user)
|
|
150
|
+
|
|
151
|
+
if (devices.length === 0) {
|
|
152
|
+
info(user
|
|
153
|
+
? `No devices registered for user ${c.bold(user)} in ${c.bold(ctx.name)}.`
|
|
154
|
+
: `No devices registered in ${c.bold(ctx.name)} yet.`)
|
|
155
|
+
info(c.dim("A device appears here after the app calls Push.register(). Note that `lecodes dev`"))
|
|
156
|
+
info(c.dim("bundles are unattributed — register() rejects there; open a PUBLISHED project via QR."))
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
info(`${devices.length} device${devices.length === 1 ? "" : "s"} in ${c.bold(ctx.name)}${user ? ` for user ${c.bold(user)}` : ""}:`)
|
|
161
|
+
table([
|
|
162
|
+
[c.dim("ADDRESS"), c.dim("PLATFORM"), c.dim("USER"), c.dim("HOST"), c.dim("LAST SEEN")],
|
|
163
|
+
...devices.map(device => [
|
|
164
|
+
device.disabledAt ? c.dim(device.address) : device.address,
|
|
165
|
+
deviceLabel(device),
|
|
166
|
+
device.user ?? c.dim("—"),
|
|
167
|
+
device.hostApp,
|
|
168
|
+
device.disabledAt ? c.red("disabled") : ago(device.lastSeenAt),
|
|
169
|
+
]),
|
|
170
|
+
])
|
|
171
|
+
if (devices.some(d => d.disabledAt)) {
|
|
172
|
+
info(c.dim("Disabled = the provider reported a dead token; re-registering from the device revives it."))
|
|
173
|
+
}
|
|
174
|
+
if (truncated) warn("Showing the 200 most recently seen — there are more.")
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* ---------------------------------------- keys ---------------------------------------- */
|
|
178
|
+
|
|
179
|
+
const pnKeysList = async (ctx: Context) => {
|
|
180
|
+
const keys = await listPushKeys(ctx.apiUrl, ctx.token, ctx.uuid)
|
|
181
|
+
if (keys.length === 0) {
|
|
182
|
+
info(`No push keys in ${c.bold(ctx.name)}.`)
|
|
183
|
+
info(c.dim("You only need one to send from your OWN server — `lecodes pn send` uses your login."))
|
|
184
|
+
info(c.dim("Mint one with `lecodes pn keys new \"prod server\" --env`."))
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
info(`${keys.length} push key${keys.length === 1 ? "" : "s"} in ${c.bold(ctx.name)}:`)
|
|
188
|
+
table([
|
|
189
|
+
[c.dim("ID"), c.dim("NAME"), c.dim("KEY"), c.dim("LAST USED")],
|
|
190
|
+
...keys.map(key => [String(key.id), key.name, `${key.prefix}${c.dim("…")}`, ago(key.lastUsedAt)]),
|
|
191
|
+
])
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Append `LECODES_PUSH_KEY=…` to <root>/.env and make sure git can't swallow it. Returns the
|
|
195
|
+
* paths touched. A key already in .env is REPLACED — rotating shouldn't leave two live lines. */
|
|
196
|
+
const writeEnv = (root: string, key: string): string[] => {
|
|
197
|
+
const touched: string[] = []
|
|
198
|
+
const envPath = join(root, ".env")
|
|
199
|
+
const line = `${ENV_VAR}=${key}`
|
|
200
|
+
|
|
201
|
+
const existing = existsSync(envPath) ? readFileSync(envPath, "utf8") : ""
|
|
202
|
+
if (new RegExp(`^${ENV_VAR}=`, "m").test(existing)) {
|
|
203
|
+
writeFileSync(envPath, existing.replace(new RegExp(`^${ENV_VAR}=.*$`, "m"), line))
|
|
204
|
+
} else {
|
|
205
|
+
const separator = !existing || existing.endsWith("\n") ? "" : "\n"
|
|
206
|
+
appendFileSync(envPath, `${separator}${line}\n`)
|
|
207
|
+
}
|
|
208
|
+
touched.push(".env")
|
|
209
|
+
|
|
210
|
+
// A push key in a committed .env is the one way this command could make things worse.
|
|
211
|
+
const ignorePath = join(root, ".gitignore")
|
|
212
|
+
const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : ""
|
|
213
|
+
if (!ignore.split("\n").some(l => l.trim() === ".env")) {
|
|
214
|
+
const separator = !ignore || ignore.endsWith("\n") ? "" : "\n"
|
|
215
|
+
appendFileSync(ignorePath, `${separator}.env\n`)
|
|
216
|
+
touched.push(".gitignore")
|
|
217
|
+
}
|
|
218
|
+
return touched
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** `lecodes pn keys new [name] [--env]` — the server mints it; this is the ONLY time the
|
|
222
|
+
* plaintext exists anywhere, so print it loudly (or bank it in .env with --env). */
|
|
223
|
+
const pnKeysNew = async (ctx: Context, args: Args) => {
|
|
224
|
+
const name = args._[2] ?? flagStr(args, "name")
|
|
225
|
+
const created = await createPushKey(ctx.apiUrl, ctx.token, ctx.uuid, name)
|
|
226
|
+
|
|
227
|
+
success(`Created push key ${c.bold(created.name)} (id ${created.id}).`)
|
|
228
|
+
log()
|
|
229
|
+
log(` ${c.bold(created.key)}`)
|
|
230
|
+
log()
|
|
231
|
+
|
|
232
|
+
if (flagBool(args, "env")) {
|
|
233
|
+
const touched = writeEnv(ctx.root, created.key)
|
|
234
|
+
success(`Wrote ${ENV_VAR} to ${touched.join(" + ")}.`)
|
|
235
|
+
} else {
|
|
236
|
+
warn("Copy it now — it is never shown again. Lose it and you mint a new one.")
|
|
237
|
+
info(c.dim(` Tip: \`lecodes pn keys new --env\` writes it straight into .env as ${ENV_VAR}.`))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
log()
|
|
241
|
+
info(c.dim("Send from your server with:"))
|
|
242
|
+
log(c.dim(` curl -X POST ${ctx.apiUrl}/api/projects/${ctx.uuid}/push/send \\`))
|
|
243
|
+
log(c.dim(` -H "Authorization: Bearer $${ENV_VAR}" -H "Content-Type: application/json" \\`))
|
|
244
|
+
log(c.dim(` -d '{"title":"Order shipped","users":["u42"]}'`))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** `lecodes pn keys revoke <id>` — deletion, effective on the next request. Confirmed unless -y. */
|
|
248
|
+
const pnKeysRevoke = async (ctx: Context, args: Args) => {
|
|
249
|
+
const raw = args._[2]
|
|
250
|
+
if (!raw) throw new CliError("Which key? `lecodes pn keys` lists their ids, then `lecodes pn keys revoke <id>`.")
|
|
251
|
+
const id = Number(raw)
|
|
252
|
+
if (!Number.isInteger(id)) throw new CliError(`"${raw}" is not a key id — run \`lecodes pn keys\` to see them.`)
|
|
253
|
+
|
|
254
|
+
const keys = await listPushKeys(ctx.apiUrl, ctx.token, ctx.uuid)
|
|
255
|
+
const key = keys.find(k => k.id === id)
|
|
256
|
+
if (!key) throw new CliError(`No push key with id ${id} in ${ctx.name}.`)
|
|
257
|
+
|
|
258
|
+
if (!flagBool(args, "yes", "y")) {
|
|
259
|
+
warn(`Revoking ${c.bold(key.name)} (${key.prefix}…) — any server still using it stops sending immediately.`)
|
|
260
|
+
if (!await confirm("Revoke it?")) return info("Cancelled.")
|
|
261
|
+
}
|
|
262
|
+
await revokePushKey(ctx.apiUrl, ctx.token, ctx.uuid, id)
|
|
263
|
+
success(`Revoked push key ${c.bold(key.name)}.`)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const pnKeys = async (args: Args) => {
|
|
267
|
+
const ctx = context()
|
|
268
|
+
const sub = args._[1]
|
|
269
|
+
if (!sub || sub === "list") return pnKeysList(ctx)
|
|
270
|
+
if (sub === "new" || sub === "create") return pnKeysNew(ctx, args)
|
|
271
|
+
if (sub === "revoke" || sub === "delete" || sub === "rm") return pnKeysRevoke(ctx, args)
|
|
272
|
+
throw new CliError(`Unknown keys command "${sub}" — use: lecodes pn keys [list | new [name] | revoke <id>]`)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/* --------------------------------------- entry ---------------------------------------- */
|
|
276
|
+
|
|
277
|
+
const PN_HELP = `${c.bold("lecodes pn")} — push notifications for this project.
|
|
278
|
+
|
|
279
|
+
${c.bold("Commands:")}
|
|
280
|
+
pn send --title <s> Send a notification (uses your login — no key needed)
|
|
281
|
+
--body <s> second line
|
|
282
|
+
--url <s> deep link (also arrives as app.launchUrl)
|
|
283
|
+
--user <ids> target Push.register({ user }) ids, comma-separated
|
|
284
|
+
--to <pa_…> target addresses, comma-separated
|
|
285
|
+
--badge <n> app icon badge
|
|
286
|
+
--data <json> your JSON payload (≤ 2 KB)
|
|
287
|
+
pn devices List the devices that registered for this project
|
|
288
|
+
--user <id> only that user's devices
|
|
289
|
+
--forget <pa_…> drop a dead registration (-y to skip confirm)
|
|
290
|
+
pn keys List this project's push keys
|
|
291
|
+
pn keys new [name] Mint one for YOUR server (owner only; shown once)
|
|
292
|
+
--env write it to .env as ${ENV_VAR} (+ gitignore)
|
|
293
|
+
pn keys revoke <id> Revoke a key (-y to skip the confirmation)
|
|
294
|
+
|
|
295
|
+
${c.dim("A push key is generated BY the server — you can't choose or recover one. It only sends")}
|
|
296
|
+
${c.dim("for this project, which is why deploying it beats deploying a personal access token.")}`
|
|
297
|
+
|
|
298
|
+
export const pn = async (args: Args) => {
|
|
299
|
+
const sub = args._[0]
|
|
300
|
+
if (!sub || sub === "help") return log(PN_HELP)
|
|
301
|
+
if (sub === "send") return pnSend(args)
|
|
302
|
+
if (sub === "devices" || sub === "device") return pnDevices(args)
|
|
303
|
+
if (sub === "keys" || sub === "key") return pnKeys(args)
|
|
304
|
+
throw new CliError(`Unknown pn command "${sub}" — use: lecodes pn <send | devices | keys>. Run \`lecodes pn\` for the full list.`)
|
|
305
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/* Templates `lecodes init` / `lecodes create` scaffold into a new APP project (the design folder
|
|
2
|
+
* has its own set — designTemplates.ts). CLAUDE.md is the AI-assistant counterpart of README.md:
|
|
3
|
+
* it teaches the headless feedback loop (render / --script / test) and the conventions that make
|
|
4
|
+
* an app testable, so an assistant opening the project drives the real app instead of guessing.
|
|
5
|
+
* Facts here mirror lecodes-renderer's scenario engine — update both when the format changes. */
|
|
6
|
+
|
|
7
|
+
export const appClaudeMd = (name: string) => `# ${name} — LeCodes app
|
|
8
|
+
|
|
9
|
+
Guidance for AI assistants working in this folder. Human docs: README.md. The whole SDK is
|
|
10
|
+
available as globals (\`UIScreen\`, \`UIText\`, \`Scene2D\`, …) — no SDK imports. \`main.ts\` is
|
|
11
|
+
the entry point; split code into other \`.ts\` files and import them (relative imports only) —
|
|
12
|
+
a file no other file imports is treated as an entry candidate, so keep everything reachable
|
|
13
|
+
from \`main.ts\`.
|
|
14
|
+
|
|
15
|
+
## Feedback loop — run the app headless instead of guessing
|
|
16
|
+
|
|
17
|
+
\`\`\`
|
|
18
|
+
lecodes render # semantic JSON of the launched UI (structure, text, names, values)
|
|
19
|
+
lecodes render --png shot.png # pixels (needs @napi-rs/canvas installed)
|
|
20
|
+
lecodes render --png card.png --clip card # ONE component/region: a node name or x,y,w,h
|
|
21
|
+
lecodes render src/screens/cart.ts # ONE screen module, without booting the app: any file that
|
|
22
|
+
# \`export default\`s a UIScreen (or a (state) => UIScreen —
|
|
23
|
+
# --state <name> picks the branch). Same flags as above.
|
|
24
|
+
lecodes render --desktop --png shot.png # NATIVE render: real Filament 3D + UI + 2D composited —
|
|
25
|
+
# use this to SEE 3D scenes / procedural geometry (headless
|
|
26
|
+
# renders record 3D as JSON only). Windows/Linux; downloads
|
|
27
|
+
# the renderer on first use. --frames <n> for slow loads.
|
|
28
|
+
lecodes render --script <file> # drive the app through a scenario (format below)
|
|
29
|
+
lecodes render --desktop --view iso --png iso.png # 3D: inspection camera fitted to the WHOLE
|
|
30
|
+
# scene (iso|top|front|side|back [--fov N]) — or --camera x,y,z
|
|
31
|
+
# --look-at x,y,z; the app's camera is untouched. In scripts:
|
|
32
|
+
# { "view": "iso", "screenshot": "iso.png" } then { "view": "off" }
|
|
33
|
+
lecodes render --desktop --script <file> # the same scenario on the NATIVE host (3D/Jolt, real
|
|
34
|
+
# GPU): coordinates only ({ x, y }), expect.log checked,
|
|
35
|
+
# text/node/screen expects skipped; deterministic 16 ms frames
|
|
36
|
+
lecodes test # all tests/*.flow.json + design-derived flows; run before "done"
|
|
37
|
+
lecodes test --desktop # the file flows on the NATIVE host (3D/Jolt/real GPU; coordinate
|
|
38
|
+
# flows only — design flows stay headless)
|
|
39
|
+
\`\`\`
|
|
40
|
+
|
|
41
|
+
Iterate with \`--script\` scenarios and keep the useful ones as \`tests/<name>.flow.json\` — they
|
|
42
|
+
are the regression suite. A failing step dumps the failing frame (JSON + PNG) under
|
|
43
|
+
\`tests/.artifacts/\` so a red step explains itself.
|
|
44
|
+
|
|
45
|
+
## Scenario format (tests/*.flow.json and --script files)
|
|
46
|
+
|
|
47
|
+
One action per step (\`tap\`, \`type\`+\`text\`, \`fill\`, \`scroll\` [+\`by\`], \`wait\`, and for
|
|
48
|
+
games: \`frames\` (advance N fixed 1/60 steps, no sleeping), \`drag\`+\`to\` [+\`ms\`], \`hold\`+\`ms\`,
|
|
49
|
+
\`key\`+\`ms\` / \`keyDown\` / \`keyUp\` (KeyboardEvent.code: "ArrowRight", "KeyW", "Space" — mouse
|
|
50
|
+
buttons are keys too: "MouseLeft"/"MouseRight"), \`look: [dx, dy]\` [+\`ms\`] (mouse motion for
|
|
51
|
+
\`Input.mouse.delta\`), \`mouseTo: [x, y]\` (cursor position), \`wheel: [dx, dy]\`, gamepad:
|
|
52
|
+
\`stick: { left: [x, y], right: [x, y], triggers: [l, r] }\` (values persist; \`index\` = pad),
|
|
53
|
+
\`gamepad: "connect" | "disconnect"\`, pad buttons as keys: \`keyDown: "GamepadSouth"\`), then an
|
|
54
|
+
optional \`expect\` (\`screen\`, \`node\`/\`notNode\`, \`text\`/\`notText\`,
|
|
55
|
+
\`value: { target, equals }\`, \`log\` — a \`console.log\` line contains it), then outputs
|
|
56
|
+
(\`screenshot\` [+\`clip\`: a node name or \`{ x, y, width, height }\`], \`json\`). Targets resolve by
|
|
57
|
+
\`name\` → exact visible text → substring → input placeholder; a tap/drag on empty space reaches
|
|
58
|
+
the 2D scene (sprite \`click\`/\`touchstart\`, \`ev.track()\`). Envelope keys: \`name\`,
|
|
59
|
+
\`width\`/\`height\`, \`settle\`, \`fixtures\`, \`steps\`.
|
|
60
|
+
|
|
61
|
+
\`\`\`json
|
|
62
|
+
{ "steps": [
|
|
63
|
+
{ "expect": { "screen": "home", "text": "Taps: 0" } },
|
|
64
|
+
{ "tap": "tap", "expect": { "text": "Taps: 1" }, "screenshot": "after.png" },
|
|
65
|
+
{ "key": "ArrowRight", "ms": 500, "expect": { "log": "hero x=" } },
|
|
66
|
+
{ "frames": 60, "screenshot": "hud.png", "clip": "hud" }
|
|
67
|
+
] }
|
|
68
|
+
\`\`\`
|
|
69
|
+
|
|
70
|
+
Game time is deterministic headless: \`setLoop\`, 2D physics/animation and aspect \`update(dt)\`
|
|
71
|
+
advance in fixed 1/60 steps driven by \`wait\`/\`frames\`/\`drag\`/\`key\` — never by a wall clock —
|
|
72
|
+
so a flow that passes once passes every time. \`Input.key(code)\` reads the keys a flow holds and
|
|
73
|
+
\`Input.on('keydown' | 'keyup', e => e.code)\` fires for each \`key\`/\`keyDown\`/\`keyUp\` step
|
|
74
|
+
(events land before the next loop tick); \`stick\` values are what \`Input.gamepad(0).axis()\` reads.
|
|
75
|
+
Headless draws UI + the 2D scene (sprites, tilemaps, Box2D); 3D is captured as JSON and Jolt does
|
|
76
|
+
not simulate — use \`--desktop\` for 3D pixels. \`console.log\` + \`expect.log\` is the assertion
|
|
77
|
+
channel for game state that has no UI.
|
|
78
|
+
|
|
79
|
+
## Make the app testable
|
|
80
|
+
|
|
81
|
+
- Give interactive elements a \`name\`: \`UIButton(...).style({ name: "login" })\`.
|
|
82
|
+
- Name every screen's ROOT node with the screen's id — \`expect.screen\` reads it.
|
|
83
|
+
- If the project has a \`design/\` folder, keep the design's screen ids and activator \`name\`s
|
|
84
|
+
in the app code: \`lecodes test\` then checks the built app against the designed navigation
|
|
85
|
+
automatically (conventions in design/CLAUDE.md).
|
|
86
|
+
- Backend-gated screens: canned responses in \`tests/fixtures.json\` (URL pattern →
|
|
87
|
+
\`{ status?, json?, text? }\`; \`*\` wildcards full-match, plain strings substring-match, first
|
|
88
|
+
match wins). \`lecodes test\` auto-loads it for design-derived flows; a scenario file opts in
|
|
89
|
+
with \`"fixtures": "fixtures.json"\` in its envelope.
|
|
90
|
+
- \`fill\` fills every empty input — use it before tapping a disabled-until-filled submit.
|
|
91
|
+
|
|
92
|
+
## SDK documentation
|
|
93
|
+
|
|
94
|
+
\`.lecodes/types/\` is the exact API surface (signatures only; generated and gitignored — run
|
|
95
|
+
\`lecodes update\` if it's missing, never commit \`.lecodes/\`). For semantics and examples,
|
|
96
|
+
fetch the docs — they are served as raw markdown for AI tools:
|
|
97
|
+
|
|
98
|
+
- https://le.codes/llms.txt — the docs map; fetch it first to find the right page
|
|
99
|
+
- https://le.codes/en/docs/api/<area>/<page>.md — one reference page as raw markdown
|
|
100
|
+
(e.g. \`ui/styling.md\`, \`2d/scene2d.md\`, \`3d/scene.md\`, \`runtime/storage.md\`)
|
|
101
|
+
- https://le.codes/en/docs/guide/<page>.md — guide pages (project anatomy, CLI, tracks)
|
|
102
|
+
- https://le.codes/llms-full.txt — everything in one file (large; prefer per-page)
|
|
103
|
+
|
|
104
|
+
Always fetch the \`.md\` URLs — the HTML routes are a JS app and return an empty shell.
|
|
105
|
+
|
|
106
|
+
## Other commands
|
|
107
|
+
|
|
108
|
+
\`lecodes dev\` (run on a device with hot reload + QR), \`lecodes compile -o app.js\`
|
|
109
|
+
(single-file bundle), \`lecodes design\` (screen-design board — the living spec).
|
|
110
|
+
`
|
|
111
|
+
|
|
112
|
+
/** `.claude/settings.json` scaffolded next to CLAUDE.md: pre-approves doc fetches from
|
|
113
|
+
* le.codes so the assistant can follow the "SDK documentation" pointers without a
|
|
114
|
+
* per-domain permission prompt on first use. */
|
|
115
|
+
export const CLAUDE_SETTINGS = JSON.stringify({
|
|
116
|
+
permissions: { allow: ["WebFetch(domain:le.codes)"] },
|
|
117
|
+
}, null, 2) + "\n"
|
|
118
|
+
|
|
119
|
+
/** A flow test that passes against init's starter counter app — proves the loop works out of the
|
|
120
|
+
* box and gives an in-project example of the format (the button/screen carry matching names). */
|
|
121
|
+
export const STARTER_FLOW = `{
|
|
122
|
+
"name": "counter",
|
|
123
|
+
"steps": [
|
|
124
|
+
{ "expect": { "screen": "home", "text": "Taps: 0" } },
|
|
125
|
+
{ "tap": "tap", "expect": { "text": "Taps: 1" } },
|
|
126
|
+
{ "tap": "tap", "expect": { "text": "Taps: 2" } }
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
`
|