lecodes-cli 0.20.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +56 -57
  2. package/dist/index.js +5920 -5022
  3. package/package.json +9 -7
  4. package/runtime/materials/decal-relief.mat +12 -1
  5. package/runtime/materials/decal.mat +17 -3
  6. package/runtime/materials/lightmap-baked-lite.mat +6 -1
  7. package/runtime/materials/lightmap-baked.mat +6 -1
  8. package/runtime/materials/lightmap.mat +3 -0
  9. package/runtime/materials/particles-quad.mat +17 -9
  10. package/runtime/materials/particles.mat +17 -9
  11. package/runtime/scene-harness.json +1 -1
  12. package/runtime/sdk-types.json +1 -1
  13. package/runtime/web/assets/createViewerLite-DHqTFhWB.js +866 -0
  14. package/runtime/web/assets/{index-BTB5KV9x.js → index-Q06c6oHx.js} +1 -1
  15. package/runtime/web/embed.js +2 -2
  16. package/src/api.ts +1 -302
  17. package/src/cli/args.ts +37 -0
  18. package/src/cli/command.ts +113 -0
  19. package/src/cli/errors.ts +13 -0
  20. package/src/cli/help.ts +133 -0
  21. package/src/cli/index.ts +7 -0
  22. package/src/cli/output.ts +102 -0
  23. package/src/cli/run.ts +106 -0
  24. package/src/commands/{appAndroid.ts → app/android.ts} +451 -42
  25. package/src/commands/{appDesktop.ts → app/desktop.ts} +63 -32
  26. package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +24 -18
  27. package/src/commands/{appIcon.ts → app/icon.ts} +2 -2
  28. package/src/commands/{app.ts → app/index.ts} +397 -289
  29. package/src/commands/{appShared.ts → app/shared.ts} +30 -39
  30. package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +62 -28
  31. package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
  32. package/src/commands/{appTemplates.ts → app/templates/ios.ts} +1 -1
  33. package/src/commands/assets.ts +23 -21
  34. package/src/commands/clone.ts +48 -51
  35. package/src/commands/compile.ts +62 -88
  36. package/src/commands/create.ts +46 -47
  37. package/src/commands/design/comments.ts +361 -0
  38. package/src/commands/design/context.ts +101 -0
  39. package/src/commands/design/index.ts +391 -0
  40. package/src/commands/design/snapshot.ts +134 -0
  41. package/src/commands/{designTemplates.ts → design/templates.ts} +4 -3
  42. package/src/commands/desktop.ts +90 -82
  43. package/src/commands/dev.ts +201 -176
  44. package/src/commands/diff.ts +39 -51
  45. package/src/commands/index.ts +60 -0
  46. package/src/commands/{init.ts → init/index.ts} +199 -191
  47. package/src/commands/install.ts +80 -67
  48. package/src/commands/lightmap.ts +203 -171
  49. package/src/commands/link.ts +87 -94
  50. package/src/commands/login.ts +25 -23
  51. package/src/commands/navmesh.ts +168 -138
  52. package/src/commands/pn.ts +201 -244
  53. package/src/commands/pull.ts +49 -56
  54. package/src/commands/push.ts +48 -54
  55. package/src/commands/render.ts +158 -99
  56. package/src/commands/scene.ts +58 -55
  57. package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
  58. package/src/commands/{shadersNew.ts → shaders/new.ts} +2 -2
  59. package/src/commands/shared.ts +79 -0
  60. package/src/commands/status.ts +22 -26
  61. package/src/commands/test.ts +398 -371
  62. package/src/commands/thumbs.ts +185 -175
  63. package/src/commands/update/index.ts +226 -0
  64. package/src/commands/{types.ts → update/types.ts} +31 -22
  65. package/src/compile/collect.ts +5 -5
  66. package/src/compile/collectLocal.ts +3 -3
  67. package/src/compile/designCompile.ts +3 -3
  68. package/src/compile/headlessBundle.ts +133 -129
  69. package/src/compile/projectCompile.ts +2 -2
  70. package/src/compile/sceneCompile.ts +10 -8
  71. package/src/compile/screenEntry.ts +123 -127
  72. package/src/compile/shaders.ts +3 -3
  73. package/src/dev/androidDev.ts +1 -1
  74. package/src/dev/devServer.ts +3 -3
  75. package/src/dev/webRunner.ts +1 -1
  76. package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +1 -1
  77. package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +6 -5
  78. package/src/{desktopScript.ts → hosts/desktopScript.ts} +1 -1
  79. package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
  80. package/src/{matcTool.ts → hosts/matcTool.ts} +1 -1
  81. package/src/{peerInstall.ts → hosts/peerInstall.ts} +2 -2
  82. package/src/{releases.ts → hosts/releases.ts} +1 -1
  83. package/src/index.ts +32 -480
  84. package/src/platform/api.ts +302 -0
  85. package/src/{browserAuth.ts → platform/browserAuth.ts} +1 -1
  86. package/src/{config.ts → platform/config.ts} +1 -1
  87. package/src/{serverDiff.ts → platform/serverDiff.ts} +3 -3
  88. package/src/{projectEnv.ts → project/env.ts} +33 -2
  89. package/src/{localFiles.ts → project/localFiles.ts} +1 -1
  90. package/src/{manifest.ts → project/manifest.ts} +1 -1
  91. package/src/{project.ts → project/materialize.ts} +2 -2
  92. package/src/project/paths.ts +20 -0
  93. package/src/{textDiff.ts → project/textDiff.ts} +1 -1
  94. package/src/{types.ts → project/types.ts} +0 -0
  95. package/runtime/web/assets/createViewerLite-C_bkKXLg.js +0 -852
  96. package/src/commands/design.ts +0 -846
  97. package/src/commands/update.ts +0 -211
  98. package/src/util.ts +0 -146
  99. /package/src/commands/{projectTemplates.ts → init/templates.ts} +0 -0
  100. /package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +0 -0
  101. /package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +0 -0
  102. /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
  103. /package/src/{lecodes-renderer.d.ts → declarations/lecodes-renderer.d.ts} +0 -0
  104. /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
  105. /package/src/{peers.ts → hosts/peers.ts} +0 -0
  106. /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
  107. /package/src/{ignore.ts → project/ignore.ts} +0 -0
@@ -1,211 +0,0 @@
1
- import { spawnSync } from "node:child_process"
2
- import { existsSync, readdirSync } from "node:fs"
3
- import { homedir } from "node:os"
4
- import { dirname, join, resolve } from "node:path"
5
- import pkg from "../../package.json"
6
- import { CliError, c, flagBool, log, note, type Args } from "../util"
7
- import { OPTIONAL_PEERS, peerSource, peerVersion } from "../peers"
8
- import { installPeers } from "../peerInstall"
9
- import { updateDesktop } from "../desktopRenderer"
10
- import { updateMatc } from "../matcTool"
11
- import { types } from "./types"
12
- import { shaders } from "./shaders"
13
- import { app } from "./app"
14
-
15
- /*
16
- * `lecodes update` — one command that moves EVERYTHING a project depends on to its newest published
17
- * state, in the order the pieces depend on each other:
18
- *
19
- * 1. desktop the LeCodes Desktop host `desktop run` / `render --desktop` use (= `desktop update`)
20
- * 2. matc Filament's material compiler for local shader compiles (= `shaders update`)
21
- * 3. shaders recompile the project's .mat files with that matc (= `shaders compile`)
22
- * 4. types the IDE types (.lecodes/types + tsconfig) from the server (= `types`)
23
- * 5. shells ios/ + android/ pins → newest SDK release + plugin tags, re-sync (= `app update`)
24
- * 6. cli the npm installs — every installed optional peer (lecodes-renderer, lecodes-design,
25
- * lecodes-3d-editor, lecodes-assets, @napi-rs/canvas) and the CLI itself that is behind:
26
- * peers in ~/.lecodes/peers (first-use installs, peerInstall.ts) are re-installed there
27
- * — the renderer at THIS CLI's version, the rest at npm's latest in range; global ones
28
- * (`npm install -g`, the old way) via `npm install -g …@latest`; the CLI itself last
29
- * (the standalone binary can't come from npm — it says so and does the peers only)
30
- *
31
- * Every step is the existing subcommand, so nothing here can drift from what the individual
32
- * commands do; this only sequences them and keeps going when one fails (the summary says which).
33
- * Steps that the environment pins are SKIPPED, not overridden: LECODES_DESKTOP_EXE means a local
34
- * host build (engine work — the cache is irrelevant), LECODES_MATC a local matc; the project steps
35
- * need a project (a dir with app.json / .lecodes above the cwd) and the shell step a shell folder.
36
- *
37
- * Flags: --local (types from the SDK bundled in this CLI instead of the server), --no-shells (leave
38
- * ios/android pins alone — `app update` re-syncs, which compiles the bundle and, for server projects,
39
- * needs a login), --check (the npm step only REPORTS what is behind and prints the install line),
40
- * --no-cli (skip the npm step — offline / air-gapped machines).
41
- *
42
- * `lecodes types` used to be its own command; the types step here replaced it (2026-09-04).
43
- */
44
-
45
- /** The nearest project at or above `start`: a dir with app.json, or a .lecodes/ that holds a project's
46
- * manifest or types. NOT the bare `.lecodes/` marker projectEnv uses — the CLI's own config lives in
47
- * ~/.lecodes, so that rule would turn any folder under the home dir into a "project" and the types
48
- * step would scaffold .lecodes/types + tsconfig.json into it. */
49
- const findProjectDir = (start: string): string | null => {
50
- const home = resolve(homedir())
51
- let dir = resolve(start)
52
- while (true) {
53
- if (dir !== home && (existsSync(join(dir, "app.json")) || existsSync(join(dir, ".lecodes", "manifest.json")) || existsSync(join(dir, ".lecodes", "types")))) return dir
54
- const parent = dirname(dir)
55
- if (parent === dir) return null
56
- dir = parent
57
- }
58
- }
59
-
60
- type Outcome = "updated" | "ok" | "skipped" | "failed"
61
- type Step = { name: string, outcome: Outcome, detail?: string }
62
-
63
- const NPM = "https://registry.npmjs.org"
64
-
65
- /** `latest` dist-tag of an npm package, or null when npm is unreachable / the package is unknown. */
66
- const npmLatest = async (name: string): Promise<string | null> => {
67
- try {
68
- const res = await fetch(`${NPM}/${name}/latest`, { signal: AbortSignal.timeout(4000) })
69
- if (!res.ok) return null
70
- const json = await res.json() as { version?: string }
71
- return json.version ?? null
72
- } catch {
73
- return null
74
- }
75
- }
76
-
77
- const semverNewer = (a: string, b: string): number => {
78
- const pa = a.split(/[.-]/).map(Number), pb = b.split(/[.-]/).map(Number)
79
- for (let i = 0; i < 3; i++) { const d = (pa[i] ?? 0) - (pb[i] ?? 0); if (d) return d }
80
- return 0
81
- }
82
-
83
- /** Running straight from the repo checkout (src/commands/update.ts), not a built dist/binary. */
84
- const FROM_SOURCE = /[\\/]src[\\/]commands[\\/]/.test(import.meta.url)
85
- /** The standalone release build: the bundle + chisel sit next to the executable (same test as index.ts). */
86
- const STANDALONE = existsSync(join(dirname(process.execPath), "runtime")) &&
87
- existsSync(join(dirname(process.execPath), process.platform === "win32" ? "chisel.exe" : "chisel"))
88
-
89
- export const update = async (args: Args) => {
90
- const steps: Step[] = []
91
- const heading = (name: string, what: string) => log(`\n${c.bold(name)} ${c.dim(what)}`)
92
- const run = async (name: string, what: string, fn: () => Promise<Outcome | [Outcome, string]>) => {
93
- heading(name, what)
94
- try {
95
- const r = await fn()
96
- const [outcome, detail] = Array.isArray(r) ? r : [r, undefined]
97
- steps.push({ name, outcome, detail })
98
- if (outcome === "skipped" && detail) note(`skipped — ${detail}`)
99
- } catch (e) {
100
- const msg = e instanceof CliError ? e.message : e instanceof Error ? e.message : String(e)
101
- log(c.red("✗ ") + msg)
102
- steps.push({ name, outcome: "failed", detail: msg })
103
- }
104
- }
105
-
106
- const root = findProjectDir(process.cwd())
107
-
108
- // 1. desktop host
109
- await run("desktop", "LeCodes Desktop host (desktop run / render --desktop)", async () => {
110
- if (process.env.LECODES_DESKTOP_EXE) return ["skipped", `LECODES_DESKTOP_EXE=${process.env.LECODES_DESKTOP_EXE} (a local build; \`lecodes desktop update\` refreshes the cache anyway)`]
111
- await updateDesktop()
112
- return "ok"
113
- })
114
-
115
- // 2. matc
116
- await run("matc", "Filament material compiler for local shader compiles", async () => {
117
- if (process.env.LECODES_MATC) return ["skipped", `LECODES_MATC=${process.env.LECODES_MATC} (a local matc)`]
118
- await updateMatc()
119
- return "ok"
120
- })
121
-
122
- // 3. shaders (project)
123
- await run("shaders", "recompile the project's .mat shaders with the current matc", async () => {
124
- if (!root) return ["skipped", "not inside a project"]
125
- await shaders({ _: ["compile"], flags: {} })
126
- return "ok"
127
- })
128
-
129
- // 4. types (project)
130
- await run("types", flagBool(args, "local") ? "IDE types from the SDK bundled in this CLI" : "IDE types from the server", async () => {
131
- if (!root) return ["skipped", "not inside a project"]
132
- await types({ _: [], flags: flagBool(args, "local") ? { local: true } : {} })
133
- return "ok"
134
- })
135
-
136
- // 5. native shells (project)
137
- await run("shells", "ios/ + android/ pins → newest SDK release + plugin tags, then re-sync", async () => {
138
- if (!root) return ["skipped", "not inside a project"]
139
- if (flagBool(args, "no-shells")) return ["skipped", "--no-shells"]
140
- const present = ["ios", "android"].filter((d) => existsSync(join(root!, d)) && readdirSync(join(root!, d)).length > 0)
141
- if (present.length === 0) return ["skipped", "no ios/ or android/ shell in this project (`lecodes app init ios|android`)"]
142
- await app({ _: ["update"], flags: {} })
143
- return "ok"
144
- })
145
-
146
- // 6. this CLI + its optional peers — the GLOBAL npm installs (`npm install -g`, how the CLI and its
147
- // peers are installed): every installed one that is behind npm's latest moves forward. The peers go
148
- // first, the CLI itself last — replacing the running CLI's files is fine (this process already
149
- // holds its code), but its new version is what the next invocation runs, so nothing after it here.
150
- // Peers that are NOT installed stay that way: they're optional, installing one is a choice.
151
- await run("cli", "lecodes-cli + its optional peers (global npm installs)", async () => {
152
- if (flagBool(args, "no-cli")) return ["skipped", "--no-cli"]
153
- if (FROM_SOURCE) return ["skipped", "running from the repo checkout — the CLI and its peers are the repo's own"]
154
- // Where each peer lives decides how it moves: the CLI's own prefix (~/.lecodes/peers, filled on
155
- // first use) is re-installed by peerInstall — the renderer there tracks THIS CLI's version, not
156
- // npm's latest, because compiler and headless host are one ABI set; a global install
157
- // (`npm install -g`, the old way) moves through npm like before.
158
- const rows: { name: string, installed: string | null, latest: string | null, where: "global" | "prefix" | null }[] = []
159
- for (const [name, probe] of OPTIONAL_PEERS) {
160
- const where = peerSource(name, probe)
161
- const installed = peerVersion(name, probe)
162
- const latest = !installed ? null
163
- : where === "prefix" && name === "lecodes-renderer" ? pkg.version
164
- : await npmLatest(name)
165
- rows.push({ name, installed, latest, where })
166
- }
167
- rows.push({ name: "lecodes-cli", installed: pkg.version, latest: await npmLatest("lecodes-cli"), where: "global" })
168
- if (rows.every((r) => r.latest === null)) return ["skipped", "npm unreachable"]
169
- const behind = rows.filter((r) => r.installed && r.latest && semverNewer(r.latest, r.installed) > 0)
170
- for (const r of rows) {
171
- const state = !r.installed ? c.dim("not installed (optional — installed on first use)")
172
- : !r.latest ? c.dim("(npm: unknown)")
173
- : behind.includes(r) ? c.yellow("→ " + r.latest)
174
- : c.dim("(up to date)")
175
- log(" " + r.name.padEnd(18) + " " + (r.installed ?? "").padEnd(8) + " " + (r.where === "prefix" ? c.dim("peers ") : "") + state)
176
- }
177
- if (behind.length === 0) return "ok"
178
- const prefixBehind = behind.filter((r) => r.where === "prefix")
179
- const globalSpecs = behind.filter((r) => r.where !== "prefix").map((r) => r.name + "@latest")
180
- if (flagBool(args, "check")) {
181
- return ["updated", behind.length + " behind — run: lecodes update" + (globalSpecs.length ? " (global: npm install -g " + globalSpecs.join(" ") + ")" : "")]
182
- }
183
- const done: string[] = []
184
- if (prefixBehind.length) {
185
- await installPeers(prefixBehind.map((r) => r.name), "lecodes update", { yes: true })
186
- done.push(...prefixBehind.map((r) => r.name))
187
- }
188
- if (STANDALONE && globalSpecs.some((s) => s.startsWith("lecodes-cli@"))) {
189
- note("this is the standalone build — the CLI updates by re-installing the release archive, not through npm; peers still go through npm")
190
- }
191
- const toInstall = STANDALONE ? globalSpecs.filter((s) => !s.startsWith("lecodes-cli@")) : globalSpecs
192
- if (toInstall.length) {
193
- log(" " + c.dim("$") + " npm install -g " + toInstall.join(" "))
194
- const r = spawnSync("npm", ["install", "-g", ...toInstall], { stdio: "inherit", shell: true })
195
- if (r.status !== 0) throw new CliError("npm install -g exited with " + (r.status ?? "a signal"))
196
- done.push(...toInstall.map((s) => s.replace("@latest", "")))
197
- }
198
- if (done.length === 0) return ["skipped", "only the standalone CLI itself is behind — download the release"]
199
- const cliToo = done.includes("lecodes-cli")
200
- return ["updated", done.join(", ") + " → latest" + (cliToo ? " (the new CLI runs from your next command)" : "")]
201
- })
202
-
203
- // Summary — one line per step so a scrolled-away failure can't hide.
204
- log(`\n${c.bold("Summary")}`)
205
- const width = Math.max(...steps.map((s) => s.name.length))
206
- for (const s of steps) {
207
- const mark = s.outcome === "failed" ? c.red("✗") : s.outcome === "skipped" ? c.dim("–") : c.green("✓")
208
- log(` ${mark} ${s.name.padEnd(width)} ${s.outcome === "failed" ? c.red(s.detail ?? "failed") : s.outcome === "skipped" ? c.dim(s.detail ?? "skipped") : s.detail ?? s.outcome}`)
209
- }
210
- if (steps.some((s) => s.outcome === "failed")) process.exitCode = 1
211
- }
package/src/util.ts DELETED
@@ -1,146 +0,0 @@
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")
File without changes
File without changes
File without changes