lecodes-cli 2.4.10 → 2.5.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 (57) hide show
  1. package/dist/index.js +2510 -1035
  2. package/package.json +10 -10
  3. package/runtime/materials/decal-relief.mat +21 -46
  4. package/runtime/materials/decal.mat +41 -61
  5. package/runtime/materials/foliage-cards.mat +28 -140
  6. package/runtime/materials/foliage-impostor.mat +485 -0
  7. package/runtime/materials/foliage.mat +232 -0
  8. package/runtime/materials/grass.mat +244 -0
  9. package/runtime/materials/particles-quad.mat +5 -3
  10. package/runtime/materials/particles.mat +5 -3
  11. package/runtime/sdk-types.json +1 -1
  12. package/runtime/web/VERSION.json +3 -3
  13. package/runtime/web/assets/materials-Cs_z1zLi.bin +0 -0
  14. package/runtime/web/assets/materials-world-BtoAfznA.bin +0 -0
  15. package/runtime/web/player.js +3 -3
  16. package/runtime/web-lite/assets/{canvas-WxiusXCB.js → canvas-wFjS40IX.js} +1 -1
  17. package/runtime/web-lite/assets/creator2d-1pSkYRCn.js +1 -0
  18. package/runtime/web-lite/assets/creator2d-Ci5-pZGr.wasm +0 -0
  19. package/runtime/web-lite/assets/{pager-DpGa2uaE.js → pager-BZAO9gMC.js} +1 -1
  20. package/runtime/web-lite/assets/{plugins-CuEPHbHL.js → plugins-B7TuO_p4.js} +1 -1
  21. package/runtime/web-lite/assets/scene2d-F6T1Io7I.js +1 -0
  22. package/runtime/web-lite/assets/{tween-BnWYhJ7P.js → tween-DbrQTld4.js} +1 -1
  23. package/runtime/web-lite/assets/{video-BLj61nDx.js → video-bzR_t2i6.js} +1 -1
  24. package/runtime/web-lite/assets/{vlist-CwSYpLz-.js → vlist-Ap-F4H5J.js} +1 -1
  25. package/runtime/web-lite/features.json +18 -6
  26. package/runtime/web-lite/lite.js +6 -6
  27. package/src/commands/compile.ts +2 -1
  28. package/src/commands/hub/create.ts +100 -0
  29. package/src/commands/hub/credential.ts +32 -0
  30. package/src/commands/hub/index.ts +21 -0
  31. package/src/commands/hub/login.ts +89 -0
  32. package/src/commands/hub/status.ts +109 -0
  33. package/src/commands/index.ts +5 -1
  34. package/src/commands/level.ts +123 -0
  35. package/src/commands/lightmap.ts +41 -79
  36. package/src/commands/navmesh.ts +84 -67
  37. package/src/commands/secrets.ts +147 -0
  38. package/src/commands/update/index.ts +154 -64
  39. package/src/compile/designCompile.ts +2 -0
  40. package/src/compile/headlessBundle.ts +2 -0
  41. package/src/compile/projectCompile.ts +12 -1
  42. package/src/compile/sceneCompile.ts +2 -1
  43. package/src/dev/backendWorker.ts +6 -1
  44. package/src/dev/devServer.ts +2 -1
  45. package/src/dev/localBackend.ts +21 -14
  46. package/src/dev/projectBackend.ts +7 -3
  47. package/src/dev/watchProject.ts +2 -0
  48. package/src/hosts/desktopRenderer.ts +10 -2
  49. package/src/hosts/peerInstall.ts +7 -3
  50. package/src/hub/api.ts +220 -0
  51. package/src/hub/config.ts +53 -0
  52. package/src/hub/git.ts +148 -0
  53. package/src/platform/api.ts +21 -0
  54. package/src/project/env.ts +29 -0
  55. package/src/web/build.ts +149 -88
  56. package/runtime/web/assets/materials-CJzxzdUK.bin +0 -0
  57. package/runtime/web/assets/materials-world-BNQb0Z1l.bin +0 -0
@@ -61,7 +61,11 @@ const manualCommand = (dir: string, specs: string[]) => commandLine(ownManager()
61
61
  * Install peers into the prefix. Asks for consent unless `opts.yes` / LECODES_YES / `--yes` say so;
62
62
  * throws CliError with the manual command when it can't ask or the user declines.
63
63
  */
64
- export const installPeers = async (names: string[], why: string, opts: { yes?: boolean } = {}): Promise<void> => {
64
+ export const installPeers = async (names: string[], why: string, opts: { yes?: boolean, at?: Record<string, string> } = {}): Promise<void> => {
65
+ // `at`: an exact version per npm peer instead of its spec's range — `lecodes update` moving one to the newest
66
+ // release in its range: bun keeps a locked version that already satisfies the range (canvas stayed 0.1.65
67
+ // under ^0.1.65 with 0.1.100 out), so the range alone would move nothing
68
+ const specOf = async (name: string) => opts.at?.[name] ? `${name}@${opts.at[name]}` : peerSpec(name)
65
69
  const dir = peersDir()
66
70
  mkdirSync(dir, { recursive: true })
67
71
  const manifest = join(dir, "package.json")
@@ -73,7 +77,7 @@ export const installPeers = async (names: string[], why: string, opts: { yes?: b
73
77
  }
74
78
  // The renderer must be in place before canvas's range can be read off it — install in order.
75
79
  const ordered = [...names].sort((a, b) => (a === "lecodes-headless" ? -1 : b === "lecodes-headless" ? 1 : 0))
76
- const specsNow = await Promise.all(ordered.map(peerSpec))
80
+ const specsNow = await Promise.all(ordered.map(specOf))
77
81
 
78
82
  const yes = opts.yes === true || !!process.env.LECODES_YES
79
83
  if (!yes) {
@@ -92,7 +96,7 @@ export const installPeers = async (names: string[], why: string, opts: { yes?: b
92
96
  if (!tool) throw new CliError(`Neither npm nor bun is on PATH, so ${names.join(" + ")} can't be installed automatically. Install one, or run on a machine with Node: ${manualCommand(dir, specsNow)}`)
93
97
 
94
98
  for (const name of ordered) {
95
- const spec = await peerSpec(name) // canvas's range comes from the renderer installed a step earlier
99
+ const spec = await specOf(name) // canvas's range comes from the renderer installed a step earlier
96
100
  const argv = installIntoArgv(tool, dir, [spec])
97
101
  note(`$ ${tool} ${argv.join(" ")}`)
98
102
  const r = spawnSync(tool, argv, { stdio: "inherit", shell: process.platform === "win32", timeout: 10 * 60_000 })
package/src/hub/api.ts ADDED
@@ -0,0 +1,220 @@
1
+ import { CliError } from "../cli"
2
+
3
+ /*
4
+ * The client of hub's HTTP API (LeCodesHub/docs: accounts.md "Device flow", deploy.md "The project
5
+ * API"). Every call takes the hub origin and, when it needs one, the PAT (`Authorization: Bearer
6
+ * lch_…`). Failures are `HubApiError`: a CliError carrying the HTTP status and the parsed body, the
7
+ * same shape as the old platform client (src/platform/api.ts), so a command can tell a 404 from a
8
+ * 403 and print the server's sentence. The device-flow poll is the one call that reads statuses
9
+ * as answers (428 pending, 410 expired) instead of errors.
10
+ */
11
+
12
+ export type Visibility = "public" | "private"
13
+
14
+ /** `POST /api/account/device`. */
15
+ export type DeviceStart = { deviceCode: string, userCode: string, verificationUrl: string, interval: number, expiresIn: number }
16
+
17
+ /** One poll of `POST /api/account/device/token`. `slow-down` is RFC 8628's `slow_down`. */
18
+ export type DevicePoll = { kind: "token", token: string } | { kind: "pending" } | { kind: "slow-down" } | { kind: "expired" }
19
+
20
+ /** `POST /api/repos`. `remote` is the repo's smart-HTTP URL (`https://hub.le.codes/ns/name.git`). */
21
+ export type CreatedRepo = { repo: unknown, project: unknown, remote: string }
22
+
23
+ /** queued → building → ready | failed; a queued one superseded by a newer push is cancelled. */
24
+ export type DeployStatus = "queued" | "building" | "ready" | "failed" | "cancelled" | (string & {})
25
+
26
+ /** One row of `GET /api/projects/:ns/:name/deploys`. `environment` is null for a preview. */
27
+ export type HubDeploy = {
28
+ number: number
29
+ commit: string
30
+ ref: string
31
+ message?: string | null
32
+ author?: string | null
33
+ status: DeployStatus
34
+ host: string
35
+ environment?: string | null
36
+ createdAt: string
37
+ finishedAt?: string | null
38
+ size?: number | null
39
+ /** Only on `GET …/deploys/:number`. */
40
+ error?: string | null
41
+ warnings?: string[]
42
+ sdk?: string | null
43
+ }
44
+
45
+ export type HubEnvironment = {
46
+ name: string
47
+ rule?: { branch: string } | { tag: string }
48
+ host: string
49
+ current?: HubDeploy | null
50
+ }
51
+
52
+ /** `GET /api/projects/:ns/:name` (deploy.md "The project API"): `project`, its `environments` with
53
+ * their current deployment, the owner's `usage`. The repo's `ns/name` is the caller's own. */
54
+ export type HubProject = {
55
+ project: { handle: string, visibility?: Visibility, sdk?: string | null, serverUrl?: string, deploys?: number, createdAt?: string }
56
+ environments: HubEnvironment[]
57
+ usage?: Record<string, unknown>
58
+ }
59
+
60
+ /** An HTTP failure: the message, the status and the parsed body. */
61
+ export class HubApiError extends CliError {
62
+ readonly body: unknown
63
+ constructor(message: string, status: number, body: unknown) {
64
+ super(message, status)
65
+ this.body = body
66
+ }
67
+ }
68
+
69
+ const errorMessage = (body: unknown, status: number): string => {
70
+ if (typeof body === "string" && body.trim()) return body.trim()
71
+ if (body && typeof body === "object") {
72
+ const b = body as Record<string, unknown>
73
+ if (typeof b.message === "string") return b.message
74
+ if (typeof b.error === "string") return b.error
75
+ // field errors: { error: { slug: "taken" } } or { error: { slug: { message } } }
76
+ if (b.error && typeof b.error === "object") {
77
+ const [field, first] = Object.entries(b.error)[0] ?? []
78
+ if (typeof first === "string") return `${field}: ${first}`
79
+ if (first && typeof first === "object" && typeof (first as { message?: unknown }).message === "string") return `${field}: ${(first as { message: string }).message}`
80
+ }
81
+ }
82
+ return `Request failed with status ${status}`
83
+ }
84
+
85
+ type RequestOptions = { method?: string, body?: unknown, token?: string, accept?: string }
86
+
87
+ /** The raw response of a hub call (network failure → CliError naming the origin). */
88
+ export const hubFetch = async (base: string, path: string, opts: RequestOptions = {}): Promise<Response> => {
89
+ const headers: Record<string, string> = {}
90
+ if (opts.token) headers["Authorization"] = "Bearer " + opts.token
91
+ if (opts.body !== undefined) headers["Content-Type"] = "application/json"
92
+ if (opts.accept) headers["Accept"] = opts.accept
93
+ try {
94
+ return await fetch(`${base}${path}`, {
95
+ method: opts.method ?? (opts.body !== undefined ? "POST" : "GET"),
96
+ headers,
97
+ body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
98
+ })
99
+ } catch {
100
+ throw new CliError(`Cannot reach ${base} — is the hub URL correct and online?`)
101
+ }
102
+ }
103
+
104
+ const parseBody = async (resp: Response): Promise<unknown> => {
105
+ const type = resp.headers.get("content-type") ?? ""
106
+ if (type.startsWith("application/json")) { try { return await resp.json() } catch { return null } }
107
+ if (type.startsWith("text/")) return await resp.text()
108
+ return null
109
+ }
110
+
111
+ const failure = (base: string, status: number, body: unknown, hadToken: boolean): HubApiError => {
112
+ let message = errorMessage(body, status)
113
+ if (status === 401) message += hadToken
114
+ ? ` — ${base} refused the stored token; run \`lecodes hub login\` again`
115
+ : " — run `lecodes hub login` first"
116
+ return new HubApiError(message, status, body)
117
+ }
118
+
119
+ /** A JSON call: the parsed answer, or a HubApiError for a status >= 400. */
120
+ export const hubRequest = async <T = unknown>(base: string, path: string, opts: RequestOptions = {}): Promise<T> => {
121
+ const resp = await hubFetch(base, path, opts)
122
+ const body = await parseBody(resp)
123
+ if (resp.status >= 400) throw failure(base, resp.status, body, !!opts.token)
124
+ return body as T
125
+ }
126
+
127
+ const projectPath = (ns: string, name: string): string => `/api/projects/${encodeURIComponent(ns)}/${encodeURIComponent(name)}`
128
+
129
+ // ---- who am I ---------------------------------------------------------------------------------
130
+
131
+ export type HubNamespace = { id: number | string, slug: string, name: string, kind: "user" | "org" | (string & {}), role?: string }
132
+
133
+ /** `GET /api/account` (accounts.md "Who am I"): `namespaces` lists the user's own account first, then orgs. */
134
+ export type HubWhoami = {
135
+ account: { id: number | string, slug: string, name: string, kind: string, plan: string, staff: boolean }
136
+ email: string
137
+ emailVerified: boolean
138
+ namespaces: HubNamespace[]
139
+ scopes: string[]
140
+ }
141
+
142
+ export const whoami = (base: string, token: string): Promise<HubWhoami> =>
143
+ hubRequest<HubWhoami>(base, "/api/account", { token })
144
+
145
+ // ---- device flow (RFC 8628) -------------------------------------------------------------------
146
+
147
+ export const startDeviceLogin = (base: string, body: { scopes: string[], name: string }): Promise<DeviceStart> =>
148
+ hubRequest<DeviceStart>(base, "/api/account/device", { body })
149
+
150
+ export const pollDeviceLogin = async (base: string, deviceCode: string): Promise<DevicePoll> => {
151
+ const resp = await hubFetch(base, "/api/account/device/token", { body: { deviceCode } })
152
+ const body = await parseBody(resp)
153
+ const fields = body && typeof body === "object" ? body as { token?: unknown, error?: unknown } : {}
154
+ if (resp.status < 300 && typeof fields.token === "string") return { kind: "token", token: fields.token }
155
+ if (resp.status === 410 || fields.error === "expired_token") return { kind: "expired" }
156
+ if (fields.error === "slow_down") return { kind: "slow-down" }
157
+ if (resp.status === 428 || fields.error === "authorization_pending") return { kind: "pending" }
158
+ throw failure(base, resp.status, body, false)
159
+ }
160
+
161
+ // ---- repos and projects -----------------------------------------------------------------------
162
+
163
+ /** Create the repo and its project in `ns` (the CLI always names it; `GET /api/account` lists the choices). */
164
+ export const createRepo = (base: string, token: string, body: { ns: string, name: string, visibility: Visibility }): Promise<CreatedRepo> =>
165
+ hubRequest<CreatedRepo>(base, "/api/repos", { token, body })
166
+
167
+ export const getProject = (base: string, token: string | undefined, ns: string, name: string): Promise<HubProject> =>
168
+ hubRequest<HubProject>(base, projectPath(ns, name), { token })
169
+
170
+ export const listDeploys = (base: string, token: string | undefined, ns: string, name: string): Promise<HubDeploy[]> =>
171
+ hubRequest<HubDeploy[]>(base, `${projectPath(ns, name)}/deploys`, { token })
172
+
173
+ export const getDeploy = (base: string, token: string | undefined, ns: string, name: string, number: number): Promise<HubDeploy> =>
174
+ hubRequest<HubDeploy>(base, `${projectPath(ns, name)}/deploys/${number}`, { token })
175
+
176
+ /**
177
+ * Follow a deployment's build log: `GET …/deploys/:number/log?follow=1` as server-sent events.
178
+ * `onText` gets each event's data (multi-line data joined with "\n", as the SSE spec says). The
179
+ * promise settles when the server closes the stream or sends an `end` / `done` event. A server that
180
+ * answers plain text instead (the build finished between the list and this call) is passed through
181
+ * whole.
182
+ */
183
+ export const followDeployLog = async (
184
+ base: string, token: string | undefined, ns: string, name: string, number: number, onText: (text: string) => void,
185
+ ): Promise<void> => {
186
+ const resp = await hubFetch(base, `${projectPath(ns, name)}/deploys/${number}/log?follow=1`, { token, accept: "text/event-stream" })
187
+ if (resp.status >= 400) throw failure(base, resp.status, await parseBody(resp), !!token)
188
+ const type = resp.headers.get("content-type") ?? ""
189
+ if (!type.startsWith("text/event-stream")) { const text = await resp.text(); if (text) onText(text); return }
190
+ if (!resp.body) return
191
+ const reader = resp.body.getReader()
192
+ const decoder = new TextDecoder()
193
+ let buffer = ""
194
+ let event = "", data: string[] = []
195
+ /** Hand the pending event over; true when it was the end marker. */
196
+ const dispatch = (): boolean => {
197
+ const end = event === "end" || event === "done"
198
+ if (data.length && !end) onText(data.join("\n"))
199
+ event = ""; data = []
200
+ return end
201
+ }
202
+ for (;;) {
203
+ const { value, done } = await reader.read()
204
+ if (done) break
205
+ buffer += decoder.decode(value, { stream: true })
206
+ let nl: number
207
+ while ((nl = buffer.indexOf("\n")) >= 0) {
208
+ const line = buffer.slice(0, nl).replace(/\r$/, "")
209
+ buffer = buffer.slice(nl + 1)
210
+ if (line === "") { if (dispatch()) { await reader.cancel(); return } continue }
211
+ if (line.startsWith(":")) continue // a comment / keep-alive
212
+ const colon = line.indexOf(":")
213
+ const field = colon < 0 ? line : line.slice(0, colon)
214
+ const val = colon < 0 ? "" : line.slice(colon + 1).replace(/^ /, "")
215
+ if (field === "data") data.push(val)
216
+ else if (field === "event") event = val
217
+ }
218
+ }
219
+ dispatch()
220
+ }
@@ -0,0 +1,53 @@
1
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
+ import { homedir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { CliError } from "../cli"
5
+
6
+ /*
7
+ * ~/.lecodes/hub.json — the hub this machine is signed in to and its personal access token:
8
+ * `{ "url": "https://hub.le.codes", "token": "lch_…" }`. Separate from config.json (the old
9
+ * le.codes platform), so the two groups never read each other's token. The file is written with
10
+ * mode 0600: git's credential helper (`lecodes hub credential`) reads the token from it, and it is
11
+ * the only copy on disk (no `.git-credentials`). The path is resolved on every call, not at module
12
+ * load, so a test can point HOME / USERPROFILE at a temporary folder.
13
+ */
14
+
15
+ export type HubConfig = { url: string, token: string }
16
+
17
+ export const DEFAULT_HUB = "https://hub.le.codes"
18
+
19
+ export const hubConfigPath = (): string => join(homedir(), ".lecodes", "hub.json")
20
+
21
+ /** Strip whitespace and trailing slashes so `url + "/api/…"` composes. */
22
+ export const normalizeHubUrl = (url: string): string => {
23
+ const trimmed = url.trim().replace(/\/+$/, "")
24
+ let parsed: URL
25
+ try { parsed = new URL(trimmed) } catch { throw new CliError(`Not a URL: ${url}`) }
26
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new CliError(`The hub URL must be http(s): ${url}`)
27
+ return trimmed
28
+ }
29
+
30
+ /** The stored login, or null when there is none (or the file is unreadable / malformed). */
31
+ export const loadHubConfig = (): HubConfig | null => {
32
+ try {
33
+ const raw = JSON.parse(readFileSync(hubConfigPath(), "utf8")) as Partial<HubConfig>
34
+ if (typeof raw.url === "string" && typeof raw.token === "string" && raw.url && raw.token) return { url: raw.url, token: raw.token }
35
+ } catch { /* none yet */ }
36
+ return null
37
+ }
38
+
39
+ /** The stored login or a "run `lecodes hub login`" error. */
40
+ export const requireHubConfig = (): HubConfig => {
41
+ const config = loadHubConfig()
42
+ if (!config) throw new CliError("Not signed in to hub. Run `lecodes hub login` first.")
43
+ return config
44
+ }
45
+
46
+ export const saveHubConfig = (config: HubConfig): string => {
47
+ const file = hubConfigPath()
48
+ mkdirSync(join(file, ".."), { recursive: true })
49
+ writeFileSync(file, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 })
50
+ // `mode` applies only when the file is created; an existing file keeps its bits without this.
51
+ chmodSync(file, 0o600)
52
+ return file
53
+ }
package/src/hub/git.ts ADDED
@@ -0,0 +1,148 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { existsSync, realpathSync } from "node:fs"
3
+ import { basename, delimiter, join } from "node:path"
4
+ import { CliError } from "../cli"
5
+ import type { HubConfig } from "./config"
6
+
7
+ /*
8
+ * What `lecodes hub` asks of git: running it, the credential-helper line `hub login` writes into the
9
+ * global config, the answer `hub credential` gives git, and reading `ns/name` back from the `hub`
10
+ * remote. The CLI never stores the token anywhere git would (no `.git-credentials`): git asks the
11
+ * helper on every push / fetch and the helper reads ~/.lecodes/hub.json.
12
+ */
13
+
14
+ export type GitResult = { ok: boolean, stdout: string, stderr: string }
15
+
16
+ /** Run git; a missing git binary is a CliError, a non-zero exit is `ok: false` for the caller to read. */
17
+ export const git = (args: string[], cwd = process.cwd()): GitResult => {
18
+ const r = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true })
19
+ if (r.error) throw new CliError((r.error as NodeJS.ErrnoException).code === "ENOENT" ? "git is not installed (or not on PATH)." : `git failed: ${r.error.message}`)
20
+ return { ok: r.status === 0, stdout: (r.stdout ?? "").trim(), stderr: (r.stderr ?? "").trim() }
21
+ }
22
+
23
+ /** Run git and throw its stderr as a CliError when it fails. */
24
+ export const gitOrThrow = (args: string[], cwd = process.cwd()): string => {
25
+ const r = git(args, cwd)
26
+ if (!r.ok) throw new CliError(`git ${args.join(" ")} failed${r.stderr ? `: ${r.stderr}` : ""}`)
27
+ return r.stdout
28
+ }
29
+
30
+ // ---- which hosts get the token ----------------------------------------------------------------
31
+
32
+ const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]", "::1"])
33
+
34
+ /**
35
+ * The protocol the token may travel over to this hub: https always; plain http only to a loopback
36
+ * hub (`--url http://localhost:3000`, a hub run on this machine) — never over the network in clear.
37
+ * null = the URL is refused.
38
+ */
39
+ export const tokenProtocol = (hubUrl: string): "https" | "http" | null => {
40
+ const u = new URL(hubUrl)
41
+ if (u.protocol === "https:") return "https"
42
+ if (u.protocol === "http:" && LOOPBACK.has(u.hostname.toLowerCase())) return "http"
43
+ return null
44
+ }
45
+
46
+ /** `credential.<this>.helper`: git's URL-scoped key for the hub (`https://hub.le.codes`). */
47
+ export const credentialScope = (hubUrl: string): string => {
48
+ const u = new URL(hubUrl)
49
+ return `${u.protocol}//${u.host}`
50
+ }
51
+
52
+ /** git's credential-helper input: `key=value` lines up to a blank line. */
53
+ export const parseCredentialInput = (input: string): Record<string, string> => {
54
+ const out: Record<string, string> = {}
55
+ for (const line of input.split(/\r?\n/)) {
56
+ if (line === "") break
57
+ const eq = line.indexOf("=")
58
+ if (eq > 0) out[line.slice(0, eq)] = line.slice(eq + 1)
59
+ }
60
+ return out
61
+ }
62
+
63
+ /**
64
+ * What `lecodes hub credential <action>` prints for git. Only `get` answers, and only when the
65
+ * request is for the stored hub: the same host (port included) over the protocol `tokenProtocol`
66
+ * allows. Anything else, any other host, `store`, `erase`: nothing — git then asks its next helper,
67
+ * and the token never leaves for a host it was not minted for.
68
+ */
69
+ export const credentialAnswer = (action: string, input: string, config: HubConfig | null): string => {
70
+ if (action !== "get" || !config) return ""
71
+ const req = parseCredentialInput(input)
72
+ let stored: URL
73
+ try { stored = new URL(config.url) } catch { return "" }
74
+ const protocol = tokenProtocol(config.url)
75
+ if (!protocol || req.protocol !== protocol) return ""
76
+ if (!req.host || req.host.toLowerCase() !== stored.host.toLowerCase()) return ""
77
+ return `username=token\npassword=${config.token}\n`
78
+ }
79
+
80
+ // ---- the helper line --------------------------------------------------------------------------
81
+
82
+ const shellQuote = (s: string): string => `'${s.replace(/\\/g, "/").replace(/'/g, `'\\''`)}'`
83
+
84
+ const real = (p: string): string => { try { return realpathSync(p) } catch { return p } }
85
+
86
+ /** Some `lecodes` on PATH resolves to `target`. */
87
+ const onPathAs = (target: string): boolean => {
88
+ const exe = process.platform === "win32" ? "lecodes.exe" : "lecodes"
89
+ const want = real(target)
90
+ return (process.env.PATH ?? "").split(delimiter).some(d => {
91
+ if (!d) return false
92
+ const cand = join(d, exe)
93
+ return existsSync(cand) && real(cand) === want
94
+ })
95
+ }
96
+
97
+ /**
98
+ * How git should start THIS CLI, for `credential.<hub>.helper = !<this> hub credential`. git runs
99
+ * the `!` form through its shell, so the line is a shell command:
100
+ * - the standalone binary (src/commands/install.ts: the `lecodes` executable with `chisel` and
101
+ * `runtime/` beside it): `lecodes` when the `lecodes` on PATH is this very binary (installed),
102
+ * else the binary's absolute path (run from an unpacked archive);
103
+ * - the npm package (a script under node_modules run by node / bun): `lecodes`, the bin npm put
104
+ * on PATH;
105
+ * - a source checkout (`bun tools/cli/src/index.ts`): the runtime and the entry, both absolute —
106
+ * a global `lecodes` would be a different CLI.
107
+ */
108
+ export const selfCommand = (): string => {
109
+ const exe = basename(process.execPath).toLowerCase().replace(/\.exe$/, "")
110
+ if (exe === "node" || exe === "bun") {
111
+ const script = process.argv[1] ? real(process.argv[1]) : ""
112
+ if (!script || /[\\/]node_modules[\\/]/.test(script)) return "lecodes"
113
+ return `${shellQuote(process.execPath)} ${shellQuote(script)}`
114
+ }
115
+ return onPathAs(process.execPath) ? "lecodes" : shellQuote(process.execPath)
116
+ }
117
+
118
+ /**
119
+ * Point git's credential helper for the hub at `lecodes hub credential`, in the global config. Two
120
+ * writes, as `gh auth setup-git` does: an empty helper first resets the list git has collected from
121
+ * the system config (Git for Windows ships `credential.helper=manager`, which would otherwise be
122
+ * asked first and pop its own sign-in for the hub), then ours. Idempotent: `--replace-all` drops
123
+ * the earlier values of the scoped key.
124
+ */
125
+ export const configureCredentialHelper = (hubUrl: string, command = selfCommand()): { key: string, value: string } => {
126
+ const key = `credential.${credentialScope(hubUrl)}.helper`
127
+ const value = `!${command} hub credential`
128
+ gitOrThrow(["config", "--global", "--replace-all", key, ""])
129
+ gitOrThrow(["config", "--global", "--add", key, value])
130
+ return { key, value }
131
+ }
132
+
133
+ // ---- the hub remote ---------------------------------------------------------------------------
134
+
135
+ export type HubRemote = { base: string, ns: string, name: string }
136
+
137
+ /** `https://hub.le.codes/den/demo.git` → `{ base: "https://hub.le.codes", ns: "den", name: "demo" }`. */
138
+ export const parseHubRemote = (remote: string): HubRemote | null => {
139
+ let u: URL
140
+ try { u = new URL(remote.trim()) } catch { return null }
141
+ if (u.protocol !== "https:" && u.protocol !== "http:") return null
142
+ const parts = u.pathname.replace(/\/+$/, "").split("/").filter(Boolean)
143
+ if (parts.length !== 2) return null
144
+ const [ns, file] = parts
145
+ const name = file.replace(/\.git$/, "")
146
+ if (!ns || !name) return null
147
+ return { base: `${u.protocol}//${u.host}`, ns: decodeURIComponent(ns), name: decodeURIComponent(name) }
148
+ }
@@ -250,6 +250,27 @@ export const enableDesignShare = (apiUrl: string, token: string, uuid: string) =
250
250
  export const disableDesignShare = (apiUrl: string, token: string, uuid: string) =>
251
251
  apiRequest<DesignShare>(apiUrl, `/projects/${uuid}/design/share`, { token, method: "DELETE" })
252
252
 
253
+ /* ---- Project keys (Settings → Keys; `env.NAME` in code) -----------------------------------
254
+ * The platform never answers a value: a key is listed by name, set (or replaced), removed. */
255
+ export type ProjectSecret = {
256
+ name: string
257
+ /** false = nobody set it: the code reads it (`readBy`), the platform lists the name for a value to be given. */
258
+ set: boolean
259
+ createdAt: string | null
260
+ updatedAt: string | null
261
+ /** Which code reads it, as the platform's last compile of the head found. */
262
+ readBy: "server" | "app" | "both" | null
263
+ }
264
+
265
+ export const listSecrets = (apiUrl: string, token: string, uuid: string) =>
266
+ apiRequest<ProjectSecret[]>(apiUrl, `/projects/${uuid}/secrets`, { token })
267
+
268
+ export const setSecret = (apiUrl: string, token: string, uuid: string, name: string, value: string) =>
269
+ apiRequest<ProjectSecret>(apiUrl, `/projects/${uuid}/secrets/${encodeURIComponent(name)}`, { token, method: "PUT", body: { value } })
270
+
271
+ export const removeSecret = (apiUrl: string, token: string, uuid: string, name: string) =>
272
+ apiRequest<{ ok: true }>(apiUrl, `/projects/${uuid}/secrets/${encodeURIComponent(name)}`, { token, method: "DELETE" })
273
+
253
274
  /** Let / stop share-link visitors WRITING comments. Reading the existing conversation is unaffected,
254
275
  * so switching this off never hides feedback that already arrived. */
255
276
  export const setDesignCommentsOpen = (apiUrl: string, token: string, uuid: string, open: boolean) =>
@@ -17,6 +17,8 @@ import { dirname, join, resolve } from "node:path"
17
17
  * subdirectory; a leading ~/ is the home dir;
18
18
  * - the file is never pushed (ignore.ts BUILTIN_PATTERNS) — LECODES_TOKEN and the Android
19
19
  * keystore passwords are exactly what ends up in it.
20
+ * The same file holds the APP's keys — `STRIPE_KEY=…`, any capital name that is not the CLI's: read
21
+ * by `readAppEnv` for the project's server (`env.NAME`), never put into process.env.
20
22
  * Syntax: KEY=value per line, `export KEY=value` accepted, # comments, single or double quotes
21
23
  * (\n, \t and \" unescape inside double quotes only), blank lines ignored.
22
24
  */
@@ -86,6 +88,33 @@ export const findEnvRoot = (start: string): string | null => {
86
88
  }
87
89
  }
88
90
 
91
+ /** How the app's own keys are named (the same rule as Settings → Keys on le.codes). */
92
+ export const APP_ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/
93
+
94
+ /** The app's keys among a parsed .env: every `NAME=value` under the key rule that is not the CLI's own
95
+ * (`LECODES_*` / `CREATOR_*`). These never reach process.env — they go to the project's server
96
+ * (`env.NAME`, the local backend) and, under `PUBLIC_`, to the app bundle; what the platform's
97
+ * Settings → Keys is on le.codes. Pure. */
98
+ export const appEnvOf = (parsed: Record<string, string>): Record<string, string> => {
99
+ const out: Record<string, string> = {}
100
+ for (const [key, value] of Object.entries(parsed)) {
101
+ if (KEY_RE.test(key) || !APP_ENV_NAME.test(key)) continue
102
+ out[key] = value
103
+ }
104
+ return out
105
+ }
106
+
107
+ /** `<root>/.env`'s app keys (`appEnvOf`), `{}` without the file. Never throws. */
108
+ export const readAppEnv = (root: string): Record<string, string> => {
109
+ try {
110
+ const file = join(root, ".env")
111
+ if (!existsSync(file)) return {}
112
+ return appEnvOf(parseDotEnv(readFileSync(file, "utf8")))
113
+ } catch {
114
+ return {}
115
+ }
116
+ }
117
+
89
118
  /** Load `<project>/.env` into process.env (see the rules above). Returns the keys it set, for
90
119
  * diagnostics. Never throws — a broken .env is skipped, not a reason the CLI can't run. */
91
120
  export const loadProjectEnv = (cwd = process.cwd()): string[] => {