lecodes-cli 0.19.0 → 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 +730 -235
- package/package.json +14 -13
- 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/compile/aspectMacro.ts +0 -86
- package/runtime/sdk/compile/assetIconMacro.ts +0 -384
- package/runtime/sdk/compile/assetMacro.ts +0 -146
- package/runtime/sdk/compile/assetName.ts +0 -50
- package/runtime/sdk/compile/bundler.ts +0 -287
- package/runtime/sdk/compile/compileProject.ts +0 -129
- package/runtime/sdk/compile/detectEntry.ts +0 -128
- package/runtime/sdk/compile/fontMacro.ts +0 -459
- package/runtime/sdk/compile/fontRegistry.ts +0 -78
- package/runtime/sdk/compile/header.ts +0 -67
- package/runtime/sdk/compile/index.ts +0 -100
- package/runtime/sdk/compile/libraryImports.ts +0 -52
- package/runtime/sdk/compile/liteMaterial.ts +0 -247
- package/runtime/sdk/compile/sceneEditor.ts +0 -88
- package/runtime/sdk/compile/serverSplit.ts +0 -233
- package/runtime/sdk/compile/serverTypes.ts +0 -227
- package/runtime/sdk/compile/sfnt.ts +0 -98
- package/runtime/sdk/compile/shaderSchema.ts +0 -202
- package/runtime/sdk/compile/shaderTargets.ts +0 -81
- package/runtime/sdk/compile/sourcemap.ts +0 -25
- package/runtime/sdk/core/Aspect.ts +0 -568
- package/runtime/sdk/core/InspectorUI.ts +0 -212
- package/runtime/sdk/core/StateMachine.ts +0 -308
- 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 -84
- 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 -107
- package/runtime/sdk/gl/Lightmap.ts +0 -256
- package/runtime/sdk/gl/Material.ts +0 -260
- package/runtime/sdk/gl/Mesh.ts +0 -83
- package/runtime/sdk/gl/Model.ts +0 -124
- package/runtime/sdk/gl/NavAgent.ts +0 -337
- package/runtime/sdk/gl/NavMesh.ts +0 -397
- 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/Ragdoll.ts +0 -270
- package/runtime/sdk/gl/Ray.ts +0 -16
- package/runtime/sdk/gl/Scene.ts +0 -486
- package/runtime/sdk/gl/Shape.ts +0 -409
- package/runtime/sdk/gl/Terrain.ts +0 -1092
- package/runtime/sdk/gl/Texture.ts +0 -63
- 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 -120
- package/runtime/sdk/gl/animation/Layer.ts +0 -31
- package/runtime/sdk/gl/animation/Loop.ts +0 -30
- package/runtime/sdk/gl/animation/Playback.ts +0 -44
- package/runtime/sdk/gl/animation/core.ts +0 -336
- 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/terrainMesh.ts +0 -219
- package/runtime/sdk/gl/touch.ts +0 -68
- package/runtime/sdk/inject.ts +0 -197
- 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/net/codec.ts +0 -119
- package/runtime/sdk/net/core.ts +0 -384
- package/runtime/sdk/net/index.ts +0 -181
- package/runtime/sdk/net/replication.ts +0 -622
- 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 -1353
- package/runtime/sdk/scene/editorPlugins.ts +0 -110
- 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 -830
- 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
package/src/peers.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { createRequire } from "node:module"
|
|
3
|
+
import { dirname, join } from "node:path"
|
|
4
|
+
|
|
5
|
+
/* Optional peer packages a user installs separately — `npm install -g …`, matching how the CLI
|
|
6
|
+
* itself is installed. A bare specifier resolves upward from the importing FILE, so a global CLI
|
|
7
|
+
* only ever sees globally installed peers: a project-local `-D` install is invisible to it, and
|
|
8
|
+
* under `npx` neither works (pass every peer as its own `-p`). `--version` reports each one's
|
|
9
|
+
* installed version so a stale/mismatched copy — the usual cause of "needs X" errors or silent
|
|
10
|
+
* render failures like a design screen coming back empty — is visible at a glance. The probe is an
|
|
11
|
+
* export subpath that resolves to a real file in BOTH the published package and the workspace
|
|
12
|
+
* source, so version reporting works whether the CLI runs from npm or straight from the monorepo. */
|
|
13
|
+
export const OPTIONAL_PEERS: [name: string, probe: string][] = [
|
|
14
|
+
["lecodes-renderer", "lecodes-renderer/headless"],
|
|
15
|
+
["lecodes-design", "lecodes-design/server"],
|
|
16
|
+
["lecodes-3d-editor", "lecodes-3d-editor/server"],
|
|
17
|
+
["lecodes-assets", "lecodes-assets/cli"],
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
/** Installed version of an optional peer, or null when it isn't installed. Resolves the probe
|
|
21
|
+
* subpath, then walks up to the nearest package.json that actually names the package (skipping any
|
|
22
|
+
* nested ones). Never throws — a standalone binary with no node_modules reports every peer absent. */
|
|
23
|
+
export const peerVersion = (name: string, probe: string): string | null => {
|
|
24
|
+
try {
|
|
25
|
+
const require = createRequire(import.meta.url)
|
|
26
|
+
let dir = dirname(require.resolve(probe))
|
|
27
|
+
for (let depth = 0; depth < 16; depth++) {
|
|
28
|
+
const manifest = join(dir, "package.json")
|
|
29
|
+
if (existsSync(manifest)) {
|
|
30
|
+
const parsed = JSON.parse(readFileSync(manifest, "utf8")) as { name?: string, version?: string }
|
|
31
|
+
if (parsed.name === name) return parsed.version ?? "unknown"
|
|
32
|
+
}
|
|
33
|
+
const parent = dirname(dir)
|
|
34
|
+
if (parent === dir) break
|
|
35
|
+
dir = parent
|
|
36
|
+
}
|
|
37
|
+
} catch { /* not installed, or not resolvable (e.g. the standalone binary) */ }
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
package/src/project.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { dirname, join } from "node:path"
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { downloadBlob, type Project, type ProjectAsset } from "./api"
|
|
4
|
+
import type { FileKind, ManifestFile } from "./manifest"
|
|
5
|
+
import { sha256 } from "./util"
|
|
6
|
+
|
|
7
|
+
/* Writes a project's asset tree to disk and returns the path -> { id, type, sha } map for the
|
|
8
|
+
* manifest. Shared by clone and pull. */
|
|
9
|
+
|
|
10
|
+
const kindOf = (type: ProjectAsset["type"]): FileKind => (type === "file" ? "resource" : type as FileKind)
|
|
11
|
+
|
|
12
|
+
const absOf = (root: string, key: string) => join(root, ...key.replace(/^\//, "").split("/"))
|
|
13
|
+
|
|
14
|
+
/** Build each asset's posix path key ("/a/b.ts"). Assets arrive parents-first. */
|
|
15
|
+
export const buildPaths = (assets: ProjectAsset[]): Map<number, string> => {
|
|
16
|
+
const pathById = new Map<number, string>()
|
|
17
|
+
for (const asset of assets) {
|
|
18
|
+
const parent = asset.parentId !== null ? pathById.get(asset.parentId) : undefined
|
|
19
|
+
pathById.set(asset.id, `${parent ?? ""}/${asset.name}`)
|
|
20
|
+
}
|
|
21
|
+
return pathById
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Write all assets to disk; returns the manifest file map (folders are created but not tracked). */
|
|
25
|
+
export const materializeAssets = async (
|
|
26
|
+
root: string, project: Project, apiUrl: string, token: string,
|
|
27
|
+
onProgress?: (done: number, total: number, path: string) => void,
|
|
28
|
+
): Promise<Record<string, ManifestFile>> => {
|
|
29
|
+
const pathById = buildPaths(project.assets)
|
|
30
|
+
const files: Record<string, ManifestFile> = {}
|
|
31
|
+
|
|
32
|
+
const total = project.assets.filter(a => a.type !== "folder").length
|
|
33
|
+
let done = 0
|
|
34
|
+
|
|
35
|
+
for (const asset of project.assets) {
|
|
36
|
+
const key = pathById.get(asset.id)!
|
|
37
|
+
const abs = absOf(root, key)
|
|
38
|
+
|
|
39
|
+
if (asset.type === "folder") {
|
|
40
|
+
mkdirSync(abs, { recursive: true })
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
45
|
+
let sha: string
|
|
46
|
+
if (asset.type === "file") {
|
|
47
|
+
if (!asset.file?.src) continue
|
|
48
|
+
const bytes = await downloadBlob(apiUrl, asset.file.src)
|
|
49
|
+
writeFileSync(abs, bytes)
|
|
50
|
+
sha = sha256(bytes)
|
|
51
|
+
} else {
|
|
52
|
+
const text = asset.text ?? ""
|
|
53
|
+
writeFileSync(abs, text)
|
|
54
|
+
sha = sha256(text)
|
|
55
|
+
}
|
|
56
|
+
files[key] = { id: asset.id, type: kindOf(asset.type), sha }
|
|
57
|
+
onProgress?.(++done, total, key)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return files
|
|
61
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import { dirname, join, resolve } from "node:path"
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Project `.env` — the CLI's LECODES_* / CREATOR_* knobs kept next to app.json instead of in the
|
|
7
|
+
* shell's environment. Loaded once at startup, before any command runs, from the nearest project
|
|
8
|
+
* root above the cwd (a dir with app.json or .lecodes/); nothing is loaded outside a project.
|
|
9
|
+
*
|
|
10
|
+
* Rules, all chosen so a .env can never surprise:
|
|
11
|
+
* - the REAL environment wins: a key already set in the shell is never overridden — the same
|
|
12
|
+
* precedence every command already documents (flag > env > app.json);
|
|
13
|
+
* - only LECODES_* and CREATOR_* keys are taken. A .env is a per-project config file for THIS
|
|
14
|
+
* tool, not a way to rewrite PATH or HOME for the child processes it spawns;
|
|
15
|
+
* - a value starting with ./ or ../ is resolved against the .env's own directory, so a project
|
|
16
|
+
* can point LECODES_DESKTOP_EXE at a host build checked out next to it and still work from any
|
|
17
|
+
* subdirectory; a leading ~/ is the home dir;
|
|
18
|
+
* - the file is never pushed (ignore.ts BUILTIN_PATTERNS) — LECODES_TOKEN and the Android
|
|
19
|
+
* keystore passwords are exactly what ends up in it.
|
|
20
|
+
* Syntax: KEY=value per line, `export KEY=value` accepted, # comments, single or double quotes
|
|
21
|
+
* (\n, \t and \" unescape inside double quotes only), blank lines ignored.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const KEY_RE = /^(LECODES|CREATOR)_[A-Z0-9_]+$/
|
|
25
|
+
|
|
26
|
+
/** Parse .env text into key → value. Pure; unknown/odd lines are skipped, never fatal. */
|
|
27
|
+
export const parseDotEnv = (text: string): Record<string, string> => {
|
|
28
|
+
const out: Record<string, string> = {}
|
|
29
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
30
|
+
const line = raw.trim()
|
|
31
|
+
if (!line || line.startsWith("#")) continue
|
|
32
|
+
const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line)
|
|
33
|
+
if (!m) continue
|
|
34
|
+
const key = m[1]
|
|
35
|
+
let value = m[2]
|
|
36
|
+
if (value.startsWith('"')) {
|
|
37
|
+
const end = value.indexOf('"', 1)
|
|
38
|
+
value = (end < 0 ? value.slice(1) : value.slice(1, end))
|
|
39
|
+
.replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\"/g, '"').replace(/\\\\/g, "\\")
|
|
40
|
+
} else if (value.startsWith("'")) {
|
|
41
|
+
const end = value.indexOf("'", 1)
|
|
42
|
+
value = end < 0 ? value.slice(1) : value.slice(1, end)
|
|
43
|
+
} else {
|
|
44
|
+
// Unquoted: an inline ` # comment` ends the value.
|
|
45
|
+
const hash = value.search(/\s#/)
|
|
46
|
+
value = (hash >= 0 ? value.slice(0, hash) : value).trim()
|
|
47
|
+
}
|
|
48
|
+
out[key] = value
|
|
49
|
+
}
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The values a .env contributes to the environment: prefix-filtered, paths resolved against
|
|
54
|
+
* `dir`, and nothing that `env` already has. Pure — the file/process plumbing is in loadProjectEnv. */
|
|
55
|
+
export const applyDotEnv = (
|
|
56
|
+
parsed: Record<string, string>, dir: string, env: Record<string, string | undefined>,
|
|
57
|
+
): Record<string, string> => {
|
|
58
|
+
const out: Record<string, string> = {}
|
|
59
|
+
for (const [key, raw] of Object.entries(parsed)) {
|
|
60
|
+
if (!KEY_RE.test(key) || env[key] !== undefined) continue
|
|
61
|
+
let value = raw
|
|
62
|
+
if (value.startsWith("./") || value.startsWith("../")) value = resolve(dir, value)
|
|
63
|
+
else if (value === "~" || value.startsWith("~/")) value = join(homedir(), value.slice(1))
|
|
64
|
+
out[key] = value
|
|
65
|
+
}
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The nearest project root at or above `start`: the first dir with app.json or .lecodes/. */
|
|
70
|
+
export const findEnvRoot = (start: string): string | null => {
|
|
71
|
+
let dir = resolve(start)
|
|
72
|
+
while (true) {
|
|
73
|
+
if (existsSync(join(dir, "app.json")) || existsSync(join(dir, ".lecodes"))) return dir
|
|
74
|
+
const parent = dirname(dir)
|
|
75
|
+
if (parent === dir) return null
|
|
76
|
+
dir = parent
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Load `<project>/.env` into process.env (see the rules above). Returns the keys it set, for
|
|
81
|
+
* diagnostics. Never throws — a broken .env is skipped, not a reason the CLI can't run. */
|
|
82
|
+
export const loadProjectEnv = (cwd = process.cwd()): string[] => {
|
|
83
|
+
try {
|
|
84
|
+
const root = findEnvRoot(cwd)
|
|
85
|
+
if (!root) return []
|
|
86
|
+
const file = join(root, ".env")
|
|
87
|
+
if (!existsSync(file)) return []
|
|
88
|
+
const added = applyDotEnv(parseDotEnv(readFileSync(file, "utf8")), root, process.env)
|
|
89
|
+
for (const [k, v] of Object.entries(added)) process.env[k] = v
|
|
90
|
+
return Object.keys(added)
|
|
91
|
+
} catch {
|
|
92
|
+
return []
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// qrcode-terminal ships no types — the minimal surface the dev command uses.
|
|
2
|
+
declare module "qrcode-terminal" {
|
|
3
|
+
type QrOptions = { small?: boolean }
|
|
4
|
+
const qrcode: {
|
|
5
|
+
generate(text: string, options: QrOptions, callback: (output: string) => void): void
|
|
6
|
+
generate(text: string, callback?: (output: string) => void): void
|
|
7
|
+
}
|
|
8
|
+
export default qrcode
|
|
9
|
+
}
|
package/src/releases.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { spawnSync } from "node:child_process"
|
|
5
|
+
import { CliError } from "./util"
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* The S3 release channel the CLI downloads native tools from: the manifest scripts/release/*
|
|
9
|
+
* publishes (releases/versions.json) + the sha256-verified archive download. Consumers:
|
|
10
|
+
* - desktopRenderer.ts the LeCodes Desktop host behind `render --desktop` (product "desktop")
|
|
11
|
+
* - matcTool.ts Filament's matc for local shader compile (product "matc")
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Straight off the S3 bucket, NOT the CDN: the manifest must never be stale (an updater reading
|
|
15
|
+
// a cached copy would miss fresh releases), and a ~2 KB JSON doesn't need one. The artifact URLs
|
|
16
|
+
// INSIDE it are whatever publish.ts baked (RELEASES_PUBLIC_BASE) — immutable, so caching is fine there.
|
|
17
|
+
const MANIFEST_URL = "https://storage.yandexcloud.net/le-codes/releases/versions.json"
|
|
18
|
+
|
|
19
|
+
export type ManifestFile = { url: string, size: number, sha256: string }
|
|
20
|
+
|
|
21
|
+
/** Written inside every downloaded cache dir: WHICH artifact it holds. A cache dir is named after
|
|
22
|
+
* the release VERSION, but a version can be republished — the macOS desktop host shipped twice as
|
|
23
|
+
* 1.3.2 — and then the name alone says nothing about the contents. Recording the sha256 is what
|
|
24
|
+
* lets `update` tell "already have it" from "same number, different build". */
|
|
25
|
+
const ARTIFACT_STAMP = ".lecodes-artifact.json"
|
|
26
|
+
|
|
27
|
+
/** The sha256 of the artifact a cache dir was filled from; null for a dir predating the stamp
|
|
28
|
+
* (treat as unknown — i.e. re-download, since its contents can't be vouched for). */
|
|
29
|
+
export const cachedArtifactSha = (dir: string): string | null => {
|
|
30
|
+
try {
|
|
31
|
+
return (JSON.parse(readFileSync(join(dir, ARTIFACT_STAMP), "utf8")) as { sha256?: string }).sha256 ?? null
|
|
32
|
+
} catch {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export type ManifestVersion = { version: string, files: Record<string, ManifestFile> }
|
|
37
|
+
type Manifest = { products?: Record<string, { latest: string, versions: ManifestVersion[] }> }
|
|
38
|
+
|
|
39
|
+
/** Positive when b is a newer semver than a (the manifest publisher's ordering, mirrored). */
|
|
40
|
+
export const semverNewer = (a: string, b: string): number => {
|
|
41
|
+
const pa = a.split(/[.-]/).map(Number), pb = b.split(/[.-]/).map(Number)
|
|
42
|
+
for (let i = 0; i < 3; i++) if ((pb[i] ?? 0) !== (pa[i] ?? 0)) return (pb[i] ?? 0) - (pa[i] ?? 0)
|
|
43
|
+
return 0
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One product from the release manifest (always fetched fresh — see MANIFEST_URL). */
|
|
47
|
+
export const fetchReleaseProduct = async (
|
|
48
|
+
product: string, label: string,
|
|
49
|
+
): Promise<{ latest: string, versions: ManifestVersion[] }> => {
|
|
50
|
+
let manifest: Manifest
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(MANIFEST_URL)
|
|
53
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
54
|
+
manifest = (await res.json()) as Manifest
|
|
55
|
+
} catch (e) {
|
|
56
|
+
throw new CliError(`Can't fetch the release manifest (${MANIFEST_URL}): ${e instanceof Error ? e.message : String(e)}`)
|
|
57
|
+
}
|
|
58
|
+
const entry = manifest.products?.[product]
|
|
59
|
+
if (!entry) throw new CliError(`The release manifest has no ${label} product.`)
|
|
60
|
+
return entry
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Download a manifest artifact, verify its sha256, extract it, and move it into `dest` — staged
|
|
65
|
+
* through a temp sibling so a killed download never leaves a half-cache that later resolves as
|
|
66
|
+
* valid. `validate` inspects the extracted stage and returns an error string when the archive
|
|
67
|
+
* doesn't contain what the caller expects (null = good). A concurrent download winning the final
|
|
68
|
+
* rename is fine as long as `dest` then validates.
|
|
69
|
+
*/
|
|
70
|
+
export const downloadReleaseArchive = async (
|
|
71
|
+
file: ManifestFile,
|
|
72
|
+
opts: {
|
|
73
|
+
cacheRoot: string, dest: string, validate: (dir: string) => string | null,
|
|
74
|
+
/** Unpack `archive` into `stage` (default: tar — which reads .zip/.tar.gz everywhere it runs).
|
|
75
|
+
* The macOS desktop host publishes as a DMG, which tar can't open; desktopRenderer passes an
|
|
76
|
+
* hdiutil-based extractor for it. */
|
|
77
|
+
extract?: (archive: string, stage: string) => void,
|
|
78
|
+
},
|
|
79
|
+
): Promise<void> => {
|
|
80
|
+
let bytes: Buffer
|
|
81
|
+
try {
|
|
82
|
+
const res = await fetch(file.url)
|
|
83
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
84
|
+
bytes = Buffer.from(await res.arrayBuffer())
|
|
85
|
+
} catch (e) {
|
|
86
|
+
throw new CliError(`Download failed (${file.url}): ${e instanceof Error ? e.message : String(e)}`)
|
|
87
|
+
}
|
|
88
|
+
const sha = createHash("sha256").update(bytes).digest("hex")
|
|
89
|
+
if (sha !== file.sha256) throw new CliError(`Downloaded archive is corrupt (sha256 mismatch) — try again.`)
|
|
90
|
+
|
|
91
|
+
// Extract into a temp sibling, then rename into place. `tar` handles .zip too on Windows 10+ (bsdtar).
|
|
92
|
+
mkdirSync(opts.cacheRoot, { recursive: true })
|
|
93
|
+
const stage = mkdtempSync(join(opts.cacheRoot, "dl-"))
|
|
94
|
+
try {
|
|
95
|
+
const archiveName = file.url.split("/").pop() ?? "archive"
|
|
96
|
+
const archive = join(stage, archiveName)
|
|
97
|
+
writeFileSync(archive, bytes)
|
|
98
|
+
if (opts.extract) {
|
|
99
|
+
opts.extract(archive, stage)
|
|
100
|
+
} else {
|
|
101
|
+
// On Windows use the SYSTEM tar (bsdtar, Windows 10+) by absolute path — it's the one that
|
|
102
|
+
// extracts .zip too, and a Git Bash PATH would otherwise resolve GNU tar, which can't read
|
|
103
|
+
// zip AND parses the `:` in `C:\…` as a remote-host separator. Relative path + cwd keeps the
|
|
104
|
+
// argv drive-letter-free for the GNU-tar fallback (PATH) on other setups.
|
|
105
|
+
const winTar = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "tar.exe")
|
|
106
|
+
const tarExe = process.platform === "win32" && existsSync(winTar) ? winTar : "tar"
|
|
107
|
+
const tar = spawnSync(tarExe, ["-xf", archiveName], { cwd: stage, stdio: ["ignore", "ignore", "pipe"] })
|
|
108
|
+
if (tar.status !== 0) throw new CliError(`Couldn't extract the archive: ${tar.stderr?.toString() ?? "tar failed"}`)
|
|
109
|
+
}
|
|
110
|
+
rmSync(archive)
|
|
111
|
+
const bad = opts.validate(stage)
|
|
112
|
+
if (bad) throw new CliError(bad)
|
|
113
|
+
// Stamp before the rename, so a cache dir can never exist without saying what it holds.
|
|
114
|
+
writeFileSync(join(stage, ARTIFACT_STAMP), JSON.stringify({ sha256: file.sha256, url: file.url }, null, 2))
|
|
115
|
+
try {
|
|
116
|
+
renameSync(stage, opts.dest)
|
|
117
|
+
} catch {
|
|
118
|
+
let destBad: string | null
|
|
119
|
+
try { destBad = opts.validate(opts.dest) } catch { destBad = "missing" }
|
|
120
|
+
if (destBad) throw new CliError(`Couldn't move the download into ${opts.dest}.`) // a concurrent download won the race → fine
|
|
121
|
+
}
|
|
122
|
+
} finally {
|
|
123
|
+
rmSync(stage, { recursive: true, force: true })
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import type { Project, ProjectAsset } from "./api"
|
|
3
|
+
import type { LocalScan } from "./localFiles"
|
|
4
|
+
import { classifyKind, isTextKind } from "./localFiles"
|
|
5
|
+
import { buildPaths } from "./project"
|
|
6
|
+
import { basename } from "node:path"
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* Compares the local working tree against the project's current server state — the basis for
|
|
10
|
+
* `lecodes diff` and the `push --force` overwrite preview. Status is local-relative:
|
|
11
|
+
* added — present locally, absent on the server (a `push` would create it)
|
|
12
|
+
* removed — present on the server, absent locally (a `push` would delete it)
|
|
13
|
+
* modified — present on both with differing content (a `push` would overwrite the server copy)
|
|
14
|
+
*
|
|
15
|
+
* Text content (text/shader/scene assets) is compared exactly and carried for a line diff. Binary
|
|
16
|
+
* assets are compared by size only — true server-side binary changes aren't detected (noted to the
|
|
17
|
+
* user), but local binary edits and presence differences are.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type ServerDiffStatus = "added" | "removed" | "modified"
|
|
21
|
+
|
|
22
|
+
export type ServerDiffEntry = {
|
|
23
|
+
path: string
|
|
24
|
+
status: ServerDiffStatus
|
|
25
|
+
binary: boolean
|
|
26
|
+
/** For text entries: the server-side text (the diff's "old" side); "" when absent on the server. */
|
|
27
|
+
serverText?: string
|
|
28
|
+
/** For text entries: the local text (the diff's "new" side); "" when absent locally. */
|
|
29
|
+
localText?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const isTextAsset = (t: ProjectAsset["type"]) => t === "text" || t === "shader" || t === "scene"
|
|
33
|
+
|
|
34
|
+
/** Diff the local scan against the server `project`. Sorted by path. */
|
|
35
|
+
export const computeServerDiff = (project: Project, scan: LocalScan): ServerDiffEntry[] => {
|
|
36
|
+
const pathById = buildPaths(project.assets)
|
|
37
|
+
const serverByPath = new Map<string, ProjectAsset>()
|
|
38
|
+
for (const asset of project.assets) {
|
|
39
|
+
if (asset.type === "folder") continue
|
|
40
|
+
serverByPath.set(pathById.get(asset.id)!, asset)
|
|
41
|
+
}
|
|
42
|
+
const localByPath = new Map(scan.files.map(f => [f.path, f]))
|
|
43
|
+
|
|
44
|
+
const entries: ServerDiffEntry[] = []
|
|
45
|
+
const paths = new Set<string>([...serverByPath.keys(), ...localByPath.keys()])
|
|
46
|
+
|
|
47
|
+
for (const path of [...paths].sort()) {
|
|
48
|
+
const server = serverByPath.get(path)
|
|
49
|
+
const local = localByPath.get(path)
|
|
50
|
+
|
|
51
|
+
if (local && !server) {
|
|
52
|
+
const text = isTextKind(classifyKind(basename(path)))
|
|
53
|
+
entries.push({ path, status: "added", binary: !text, serverText: "", localText: text ? readFileSync(local.absPath, "utf8") : undefined })
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
if (server && !local) {
|
|
57
|
+
const text = isTextAsset(server.type)
|
|
58
|
+
entries.push({ path, status: "removed", binary: !text, serverText: text ? (server.text ?? "") : undefined, localText: "" })
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
// present on both
|
|
62
|
+
if (server && local) {
|
|
63
|
+
if (isTextAsset(server.type)) {
|
|
64
|
+
const serverText = server.text ?? ""
|
|
65
|
+
const localText = readFileSync(local.absPath, "utf8")
|
|
66
|
+
if (serverText !== localText) entries.push({ path, status: "modified", binary: false, serverText, localText })
|
|
67
|
+
} else {
|
|
68
|
+
// Binary: compare by size when the server reports one; otherwise can't tell → skip quietly.
|
|
69
|
+
const serverSize = server.file?.size
|
|
70
|
+
const localSize = Buffer.byteLength(readFileSync(local.absPath))
|
|
71
|
+
if (serverSize !== undefined && serverSize !== localSize) {
|
|
72
|
+
entries.push({ path, status: "modified", binary: true })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return entries
|
|
78
|
+
}
|
package/src/textDiff.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { c } from "./util"
|
|
2
|
+
|
|
3
|
+
/* A small line-level unified diff (LCS-based) for `lecodes diff`. Good enough for source files;
|
|
4
|
+
* for very large inputs it bails out to a one-line summary to avoid O(n·m) blow-up. */
|
|
5
|
+
|
|
6
|
+
const MAX_LINES = 4000
|
|
7
|
+
|
|
8
|
+
type Op = { tag: " " | "-" | "+", line: string }
|
|
9
|
+
|
|
10
|
+
/** Longest-common-subsequence over lines → a sequence of context/-/+ ops. */
|
|
11
|
+
const lcsDiff = (a: string[], b: string[]): Op[] => {
|
|
12
|
+
const n = a.length, m = b.length
|
|
13
|
+
// dp[i][j] = LCS length of a[i:], b[j:]
|
|
14
|
+
const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))
|
|
15
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
16
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
17
|
+
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const ops: Op[] = []
|
|
21
|
+
let i = 0, j = 0
|
|
22
|
+
while (i < n && j < m) {
|
|
23
|
+
if (a[i] === b[j]) { ops.push({ tag: " ", line: a[i] }); i++; j++ }
|
|
24
|
+
else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ tag: "-", line: a[i] }); i++ }
|
|
25
|
+
else { ops.push({ tag: "+", line: b[j] }); j++ }
|
|
26
|
+
}
|
|
27
|
+
while (i < n) ops.push({ tag: "-", line: a[i++] })
|
|
28
|
+
while (j < m) ops.push({ tag: "+", line: b[j++] })
|
|
29
|
+
return ops
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const splitLines = (text: string): string[] => {
|
|
33
|
+
if (text === "") return []
|
|
34
|
+
const lines = text.split("\n")
|
|
35
|
+
if (lines[lines.length - 1] === "") lines.pop() // trailing newline isn't a line
|
|
36
|
+
return lines
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Produce a colored unified diff of `oldText` → `newText`. Returns "" when there's no difference.
|
|
41
|
+
* `oldLabel`/`newLabel` head the `--- ` / `+++ ` lines.
|
|
42
|
+
*/
|
|
43
|
+
export const unifiedDiff = (
|
|
44
|
+
oldText: string, newText: string, oldLabel: string, newLabel: string, context = 3,
|
|
45
|
+
): string => {
|
|
46
|
+
if (oldText === newText) return ""
|
|
47
|
+
const a = splitLines(oldText)
|
|
48
|
+
const b = splitLines(newText)
|
|
49
|
+
if (a.length + b.length > MAX_LINES) {
|
|
50
|
+
return c.dim(` (large file — ${a.length} → ${b.length} lines, diff omitted)`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ops = lcsDiff(a, b)
|
|
54
|
+
|
|
55
|
+
// Group ops into hunks, keeping `context` unchanged lines around each run of changes.
|
|
56
|
+
type Hunk = { aStart: number, bStart: number, lines: Op[] }
|
|
57
|
+
const hunks: Hunk[] = []
|
|
58
|
+
let aLine = 0, bLine = 0 // 1-based counters advanced as we walk ops
|
|
59
|
+
let cur: Hunk | null = null
|
|
60
|
+
let trailingContext = 0
|
|
61
|
+
|
|
62
|
+
for (let k = 0; k < ops.length; k++) {
|
|
63
|
+
const op = ops[k]
|
|
64
|
+
const isChange = op.tag !== " "
|
|
65
|
+
if (isChange) {
|
|
66
|
+
if (!cur) {
|
|
67
|
+
// open a hunk, back-filling up to `context` preceding context lines
|
|
68
|
+
const back: Op[] = []
|
|
69
|
+
let bi = k - 1
|
|
70
|
+
while (bi >= 0 && ops[bi].tag === " " && back.length < context) { back.unshift(ops[bi]); bi-- }
|
|
71
|
+
cur = { aStart: aLine - back.length, bStart: bLine - back.length, lines: [...back] }
|
|
72
|
+
}
|
|
73
|
+
cur.lines.push(op)
|
|
74
|
+
trailingContext = 0
|
|
75
|
+
} else if (cur) {
|
|
76
|
+
cur.lines.push(op)
|
|
77
|
+
if (++trailingContext >= context * 2) {
|
|
78
|
+
// enough trailing context → close the hunk, dropping the surplus tail context
|
|
79
|
+
cur.lines.length -= trailingContext - context
|
|
80
|
+
hunks.push(cur); cur = null; trailingContext = 0
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (op.tag !== "+") aLine++
|
|
84
|
+
if (op.tag !== "-") bLine++
|
|
85
|
+
}
|
|
86
|
+
if (cur) {
|
|
87
|
+
if (trailingContext > context) cur.lines.length -= trailingContext - context
|
|
88
|
+
hunks.push(cur)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const out: string[] = [c.bold(`--- ${oldLabel}`), c.bold(`+++ ${newLabel}`)]
|
|
92
|
+
for (const h of hunks) {
|
|
93
|
+
const aCount = h.lines.filter(o => o.tag !== "+").length
|
|
94
|
+
const bCount = h.lines.filter(o => o.tag !== "-").length
|
|
95
|
+
out.push(c.cyan(`@@ -${h.aStart + 1},${aCount} +${h.bStart + 1},${bCount} @@`))
|
|
96
|
+
for (const o of h.lines) {
|
|
97
|
+
if (o.tag === "+") out.push(c.green("+" + o.line))
|
|
98
|
+
else if (o.tag === "-") out.push(c.red("-" + o.line))
|
|
99
|
+
else out.push(c.dim(" " + o.line))
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out.join("\n")
|
|
103
|
+
}
|
package/src/types.ts
ADDED
|
Binary file
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { createInterface } from "node:readline"
|
|
3
|
+
import { sanitizeAssetName } from "lecodes-sdk/compile/assetName"
|
|
4
|
+
|
|
5
|
+
/* Small dependency-free helpers shared across commands: colored logging, prompts, hashing,
|
|
6
|
+
* posix-path normalization and a minimal argv parser. */
|
|
7
|
+
|
|
8
|
+
const useColor = process.stdout.isTTY && process.env.NO_COLOR === undefined
|
|
9
|
+
const paint = (code: string, s: string) => useColor ? `\x1b[${code}m${s}\x1b[0m` : s
|
|
10
|
+
export const c = {
|
|
11
|
+
bold: (s: string) => paint("1", s),
|
|
12
|
+
dim: (s: string) => paint("2", s),
|
|
13
|
+
red: (s: string) => paint("31", s),
|
|
14
|
+
green: (s: string) => paint("32", s),
|
|
15
|
+
yellow: (s: string) => paint("33", s),
|
|
16
|
+
cyan: (s: string) => paint("36", s),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const log = (msg = ""): void => { process.stdout.write(msg + "\n") }
|
|
20
|
+
export const info = (msg: string) => log(msg)
|
|
21
|
+
export const success = (msg: string) => log(c.green("✓ ") + msg)
|
|
22
|
+
export const warn = (msg: string) => log(c.yellow("! ") + msg)
|
|
23
|
+
|
|
24
|
+
// stderr variants — for commands that write a result (JS, data) to stdout, so diagnostics never
|
|
25
|
+
// pollute the piped output.
|
|
26
|
+
export const logErr = (msg = "") => process.stderr.write(msg + "\n")
|
|
27
|
+
export const note = (msg: string) => logErr(c.dim(msg))
|
|
28
|
+
export const warnErr = (msg: string) => logErr(c.yellow("! ") + msg)
|
|
29
|
+
|
|
30
|
+
/** Keep a label short, dropping characters from the FRONT (paths matter most at the end). */
|
|
31
|
+
const ellipsize = (s: string, max: number) => s.length <= max ? s : "…" + s.slice(s.length - max + 1)
|
|
32
|
+
|
|
33
|
+
/** A single, in-place-updating progress line (TTY only; a no-op when piped). */
|
|
34
|
+
export const progress = (done: number, total: number, label = "") => {
|
|
35
|
+
if (!process.stdout.isTTY) return
|
|
36
|
+
const pct = total > 0 ? Math.round((done / total) * 100) : 100
|
|
37
|
+
process.stdout.clearLine(0)
|
|
38
|
+
process.stdout.cursorTo(0)
|
|
39
|
+
process.stdout.write(` ${c.cyan(`${pct}`.padStart(3) + "%")} ${c.dim(`(${done}/${total})`)} ${c.dim(ellipsize(label, 48))}`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Finish a progress line with a newline so later output starts cleanly. */
|
|
43
|
+
export const endProgress = () => { if (process.stdout.isTTY) process.stdout.write("\n") }
|
|
44
|
+
|
|
45
|
+
/** A user-facing error: index.ts prints its message without a stack trace and exits non-zero. */
|
|
46
|
+
export class CliError extends Error {
|
|
47
|
+
/** HTTP status, when the failure came from the API. Lets a caller tell "already gone" (404) from
|
|
48
|
+
* "refused" (403) — deleting something the server no longer has is a success, not an error. */
|
|
49
|
+
status?: number
|
|
50
|
+
constructor(message: string, status?: number) {
|
|
51
|
+
super(message)
|
|
52
|
+
this.status = status
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Output targets that mean "discard" — so `-o /dev/null` (or Windows NUL) doesn't create a real file.
|
|
57
|
+
const NULL_SINKS = new Set(["/dev/null", "nul", "//./nul"])
|
|
58
|
+
/** True if an `--out` value is a null device, i.e. the output should be thrown away, not written. */
|
|
59
|
+
export const isNullSink = (path: string): boolean =>
|
|
60
|
+
NULL_SINKS.has(path.trim().replace(/\\/g, "/").toLowerCase())
|
|
61
|
+
|
|
62
|
+
export const sha256 = (data: string | Uint8Array): string =>
|
|
63
|
+
createHash("sha256").update(data).digest("hex")
|
|
64
|
+
|
|
65
|
+
/** Normalize any path to a leading-slash, forward-slash, "." / ".." free key (e.g. "/a/b.ts").
|
|
66
|
+
* Each segment gets the platform's canonical spelling (sdk/compile/assetName) — macOS hands out
|
|
67
|
+
* decomposed names ("й" as и + U+0306) from readdir, which would otherwise key a file differently
|
|
68
|
+
* than the same name typed in an import or stored on the server. */
|
|
69
|
+
export const toKey = (path: string): string => {
|
|
70
|
+
const parts = path.replace(/\\/g, "/").split("/").map(p => p.trim()).filter(p => p && p !== ".")
|
|
71
|
+
if (parts.some(p => p === "..")) throw new CliError(`Unsafe path: ${path}`)
|
|
72
|
+
const names = parts.map(p => sanitizeAssetName(p))
|
|
73
|
+
if (names.some(name => !name)) throw new CliError(`Wrong path: ${path}`)
|
|
74
|
+
return "/" + names.join("/")
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const prompt = (question: string, fallback?: string): Promise<string> =>
|
|
78
|
+
new Promise(resolve => {
|
|
79
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
80
|
+
const suffix = fallback ? c.dim(` (${fallback})`) : ""
|
|
81
|
+
rl.question(`${question}${suffix}: `, answer => {
|
|
82
|
+
rl.close()
|
|
83
|
+
resolve(answer.trim() || fallback || "")
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
/** Prompt without echoing the input (for passwords / token paste). */
|
|
88
|
+
export const promptHidden = (question: string): Promise<string> =>
|
|
89
|
+
new Promise(resolve => {
|
|
90
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true })
|
|
91
|
+
rl.question(`${question}: `, answer => {
|
|
92
|
+
rl.close()
|
|
93
|
+
process.stdout.write("\n")
|
|
94
|
+
resolve(answer.trim())
|
|
95
|
+
})
|
|
96
|
+
// Mute everything written after the prompt line was emitted (the keystroke echoes).
|
|
97
|
+
;(rl as unknown as { _writeToOutput: (s: string) => void })._writeToOutput = () => {}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
export const confirm = async (question: string, fallback = false): Promise<boolean> => {
|
|
101
|
+
const answer = (await prompt(`${question} ${fallback ? "[Y/n]" : "[y/N]"}`)).toLowerCase()
|
|
102
|
+
if (!answer) return fallback
|
|
103
|
+
return answer === "y" || answer === "yes"
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type Args = { _: string[], flags: Record<string, string | boolean> }
|
|
107
|
+
|
|
108
|
+
/** Parse argv into positionals + flags. Supports `--key value`, `--key=value`, `--bool`, `-m msg`. */
|
|
109
|
+
// `--look-at -0.15,1,2` / `--scale -1`: a token that starts with "-" followed by a digit (or ".5")
|
|
110
|
+
// is a negative NUMBER (or a vector starting with one), not the next flag.
|
|
111
|
+
const isNegativeNumber = (s: string): boolean => /^-(\d|\.\d)/.test(s)
|
|
112
|
+
const isValue = (s: string): boolean => !s.startsWith("-") || isNegativeNumber(s)
|
|
113
|
+
|
|
114
|
+
export const parseArgs = (argv: string[]): Args => {
|
|
115
|
+
const out: Args = { _: [], flags: {} }
|
|
116
|
+
for (let i = 0; i < argv.length; i++) {
|
|
117
|
+
const arg = argv[i]
|
|
118
|
+
if (arg.startsWith("--")) {
|
|
119
|
+
const eq = arg.indexOf("=")
|
|
120
|
+
if (eq >= 0) { out.flags[arg.slice(2, eq)] = arg.slice(eq + 1); continue }
|
|
121
|
+
const key = arg.slice(2)
|
|
122
|
+
const next = argv[i + 1]
|
|
123
|
+
if (next !== undefined && isValue(next)) { out.flags[key] = next; i++ }
|
|
124
|
+
else out.flags[key] = true
|
|
125
|
+
} else if (arg.startsWith("-") && arg.length > 1 && !isNegativeNumber(arg)) {
|
|
126
|
+
const key = arg.slice(1)
|
|
127
|
+
const next = argv[i + 1]
|
|
128
|
+
if (next !== undefined && isValue(next)) { out.flags[key] = next; i++ }
|
|
129
|
+
else out.flags[key] = true
|
|
130
|
+
} else {
|
|
131
|
+
out._.push(arg)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return out
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const flagStr = (args: Args, ...keys: string[]): string | undefined => {
|
|
138
|
+
for (const key of keys) {
|
|
139
|
+
const v = args.flags[key]
|
|
140
|
+
if (typeof v === "string") return v
|
|
141
|
+
}
|
|
142
|
+
return undefined
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const flagBool = (args: Args, ...keys: string[]): boolean =>
|
|
146
|
+
keys.some(key => args.flags[key] === true || args.flags[key] === "true")
|