lecodes-cli 2.4.9 → 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.
- package/dist/index.js +2681 -1196
- package/package.json +10 -10
- package/runtime/materials/decal-relief.mat +21 -46
- package/runtime/materials/decal.mat +41 -61
- package/runtime/materials/foliage-cards.mat +28 -140
- package/runtime/materials/foliage-impostor.mat +485 -0
- package/runtime/materials/foliage.mat +232 -0
- package/runtime/materials/grass.mat +244 -0
- package/runtime/materials/particles-quad.mat +5 -3
- package/runtime/materials/particles.mat +5 -3
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/assets/materials-Cs_z1zLi.bin +0 -0
- package/runtime/web/assets/materials-world-BtoAfznA.bin +0 -0
- package/runtime/web/player.js +3 -3
- package/runtime/web-lite/assets/{canvas-WxiusXCB.js → canvas-wFjS40IX.js} +1 -1
- package/runtime/web-lite/assets/creator2d-1pSkYRCn.js +1 -0
- package/runtime/web-lite/assets/creator2d-Ci5-pZGr.wasm +0 -0
- package/runtime/web-lite/assets/{pager-DpGa2uaE.js → pager-BZAO9gMC.js} +1 -1
- package/runtime/web-lite/assets/{plugins-CuEPHbHL.js → plugins-B7TuO_p4.js} +1 -1
- package/runtime/web-lite/assets/scene2d-F6T1Io7I.js +1 -0
- package/runtime/web-lite/assets/{tween-BnWYhJ7P.js → tween-DbrQTld4.js} +1 -1
- package/runtime/web-lite/assets/{video-BLj61nDx.js → video-bzR_t2i6.js} +1 -1
- package/runtime/web-lite/assets/{vlist-CwSYpLz-.js → vlist-Ap-F4H5J.js} +1 -1
- package/runtime/web-lite/features.json +18 -6
- package/runtime/web-lite/lite.js +6 -6
- package/src/commands/compile.ts +2 -1
- package/src/commands/hub/create.ts +100 -0
- package/src/commands/hub/credential.ts +32 -0
- package/src/commands/hub/index.ts +21 -0
- package/src/commands/hub/login.ts +89 -0
- package/src/commands/hub/status.ts +109 -0
- package/src/commands/index.ts +5 -1
- package/src/commands/level.ts +123 -0
- package/src/commands/lightmap.ts +41 -76
- package/src/commands/navmesh.ts +84 -67
- package/src/commands/secrets.ts +147 -0
- package/src/commands/update/index.ts +154 -64
- package/src/compile/designCompile.ts +2 -0
- package/src/compile/headlessBundle.ts +2 -0
- package/src/compile/projectCompile.ts +12 -1
- package/src/compile/sceneCompile.ts +2 -1
- package/src/dev/backendWorker.ts +6 -1
- package/src/dev/devServer.ts +2 -1
- package/src/dev/localBackend.ts +21 -14
- package/src/dev/projectBackend.ts +7 -3
- package/src/dev/watchProject.ts +2 -0
- package/src/hosts/desktopRenderer.ts +10 -2
- package/src/hosts/peerInstall.ts +26 -6
- package/src/hub/api.ts +220 -0
- package/src/hub/config.ts +53 -0
- package/src/hub/git.ts +148 -0
- package/src/platform/api.ts +21 -0
- package/src/project/env.ts +29 -0
- package/src/web/build.ts +149 -88
- package/runtime/web/assets/materials-CJzxzdUK.bin +0 -0
- package/runtime/web/assets/materials-world-BNQb0Z1l.bin +0 -0
package/src/hosts/peerInstall.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
|
3
3
|
import { join } from "node:path"
|
|
4
4
|
import { pathToFileURL } from "node:url"
|
|
5
5
|
import pkg from "../../package.json"
|
|
6
|
-
import { peerDirInPrefix, peersDir, peerVersionGlobal } from "./peers"
|
|
6
|
+
import { peerDirInPrefix, peersDir, peerVersionGlobal, peerVersionPrefix } from "./peers"
|
|
7
|
+
import { semverNewer } from "./releases"
|
|
7
8
|
import { archiveSpec } from "./archives"
|
|
8
9
|
import { commandLine, installGlobalArgv, installIntoArgv, ownManager, pickManager } from "./packageManager"
|
|
9
10
|
import { CliError, c, confirm, log, note } from "../cli"
|
|
@@ -60,7 +61,11 @@ const manualCommand = (dir: string, specs: string[]) => commandLine(ownManager()
|
|
|
60
61
|
* Install peers into the prefix. Asks for consent unless `opts.yes` / LECODES_YES / `--yes` say so;
|
|
61
62
|
* throws CliError with the manual command when it can't ask or the user declines.
|
|
62
63
|
*/
|
|
63
|
-
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)
|
|
64
69
|
const dir = peersDir()
|
|
65
70
|
mkdirSync(dir, { recursive: true })
|
|
66
71
|
const manifest = join(dir, "package.json")
|
|
@@ -72,7 +77,7 @@ export const installPeers = async (names: string[], why: string, opts: { yes?: b
|
|
|
72
77
|
}
|
|
73
78
|
// The renderer must be in place before canvas's range can be read off it — install in order.
|
|
74
79
|
const ordered = [...names].sort((a, b) => (a === "lecodes-headless" ? -1 : b === "lecodes-headless" ? 1 : 0))
|
|
75
|
-
const specsNow = await Promise.all(ordered.map(
|
|
80
|
+
const specsNow = await Promise.all(ordered.map(specOf))
|
|
76
81
|
|
|
77
82
|
const yes = opts.yes === true || !!process.env.LECODES_YES
|
|
78
83
|
if (!yes) {
|
|
@@ -91,7 +96,7 @@ export const installPeers = async (names: string[], why: string, opts: { yes?: b
|
|
|
91
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)}`)
|
|
92
97
|
|
|
93
98
|
for (const name of ordered) {
|
|
94
|
-
const spec = await
|
|
99
|
+
const spec = await specOf(name) // canvas's range comes from the renderer installed a step earlier
|
|
95
100
|
const argv = installIntoArgv(tool, dir, [spec])
|
|
96
101
|
note(`$ ${tool} ${argv.join(" ")}`)
|
|
97
102
|
const r = spawnSync(tool, argv, { stdio: "inherit", shell: process.platform === "win32", timeout: 10 * 60_000 })
|
|
@@ -124,6 +129,13 @@ const resolveEntry = (pkgDir: string, subpath?: string): string => {
|
|
|
124
129
|
return join(pkgDir, rel)
|
|
125
130
|
}
|
|
126
131
|
|
|
132
|
+
/** The runtime set's peers that are npm's: the CLI calls their API (lightmap bake's unwrapGlbFiles), so one
|
|
133
|
+
* OLDER than this CLI is a "not a function" deep inside a command — re-installed at this CLI's range, never
|
|
134
|
+
* loaded. (The set's archive, lecodes-headless, is installed by its url at exactly this version.) */
|
|
135
|
+
const SET_ON_NPM = new Set(["lecodes-assets"])
|
|
136
|
+
const olderThanCli = (name: string, version: string | null): boolean =>
|
|
137
|
+
SET_ON_NPM.has(name) && version !== null && semverNewer(version, pkg.version) > 0
|
|
138
|
+
|
|
127
139
|
const isNotFound = (e: unknown, spec: string): boolean => {
|
|
128
140
|
const code = (e as { code?: string })?.code
|
|
129
141
|
const msg = e instanceof Error ? e.message : String(e)
|
|
@@ -146,17 +158,25 @@ export async function loadPeer<T>(name: string, subpath: string | undefined, opt
|
|
|
146
158
|
export async function loadPeer<T>(name: string, subpath: string | undefined, opts: LoadPeerOptions & { install: false }): Promise<T | null>
|
|
147
159
|
export async function loadPeer<T>(name: string, subpath: string | undefined, opts: LoadPeerOptions): Promise<T | null> {
|
|
148
160
|
const spec = subpath ? `${name}/${subpath}` : name
|
|
161
|
+
// what the user installed by hand wins — unless it is older than the API this CLI calls
|
|
162
|
+
const byHand = peerVersionGlobal(name, spec)
|
|
163
|
+
if (olderThanCli(name, byHand)) {
|
|
164
|
+
throw new CliError(`${name} ${byHand} (installed by hand) is older than this CLI (${pkg.version}) needs — update it: ${commandLine(ownManager(), installGlobalArgv(ownManager(), [await peerSpec(name)]))}`)
|
|
165
|
+
}
|
|
149
166
|
try {
|
|
150
167
|
return (await import(spec)) as T
|
|
151
168
|
} catch (e) {
|
|
152
169
|
if (!isNotFound(e, spec)) throw e
|
|
153
170
|
}
|
|
154
171
|
const dir = peerDirInPrefix(name)
|
|
155
|
-
|
|
172
|
+
const installed = peerVersionPrefix(name)
|
|
173
|
+
const stale = olderThanCli(name, installed)
|
|
174
|
+
if (installed === null || stale) {
|
|
156
175
|
if (opts.install === false) return null
|
|
157
176
|
// A global install of a DIFFERENT version than the probe expects would have resolved above; here
|
|
158
177
|
// the peer is nowhere. (Globally installed but broken → the error above was not "not found".)
|
|
159
|
-
if (peerVersionGlobal(name, spec) !== null) throw new CliError(`${name} is installed but "${spec}" can't be loaded from it — reinstall it: ${commandLine(ownManager(), installGlobalArgv(ownManager(), [await peerSpec(name)]))}`)
|
|
178
|
+
if (installed === null && peerVersionGlobal(name, spec) !== null) throw new CliError(`${name} is installed but "${spec}" can't be loaded from it — reinstall it: ${commandLine(ownManager(), installGlobalArgv(ownManager(), [await peerSpec(name)]))}`)
|
|
179
|
+
if (stale) note(`${name} ${installed} in ${peersDir()} is older than this CLI (${pkg.version}) — installing the one it needs`)
|
|
160
180
|
await installPeers(companions(name), opts.for)
|
|
161
181
|
}
|
|
162
182
|
return (await import(pathToFileURL(resolveEntry(dir, subpath)).href)) as T
|
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
|
+
}
|
package/src/platform/api.ts
CHANGED
|
@@ -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) =>
|
package/src/project/env.ts
CHANGED
|
@@ -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[] => {
|