lecodes-cli 0.20.2 → 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.
- package/README.md +56 -57
- package/dist/index.js +5918 -5020
- package/package.json +7 -5
- package/runtime/materials/decal-relief.mat +12 -1
- package/runtime/materials/decal.mat +17 -3
- package/runtime/materials/lightmap-baked-lite.mat +6 -1
- package/runtime/materials/lightmap-baked.mat +6 -1
- package/runtime/materials/lightmap.mat +3 -0
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/assets/createViewerLite-DHqTFhWB.js +866 -0
- package/runtime/web/assets/{index-BMt7AnC5.js → index-Q06c6oHx.js} +1 -1
- package/runtime/web/embed.js +1 -1
- package/src/api.ts +1 -302
- package/src/cli/args.ts +37 -0
- package/src/cli/command.ts +113 -0
- package/src/cli/errors.ts +13 -0
- package/src/cli/help.ts +133 -0
- package/src/cli/index.ts +7 -0
- package/src/cli/output.ts +102 -0
- package/src/cli/run.ts +106 -0
- package/src/commands/{appAndroid.ts → app/android.ts} +451 -42
- package/src/commands/{appDesktop.ts → app/desktop.ts} +63 -32
- package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +24 -18
- package/src/commands/{appIcon.ts → app/icon.ts} +2 -2
- package/src/commands/{app.ts → app/index.ts} +397 -289
- package/src/commands/{appShared.ts → app/shared.ts} +30 -39
- package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +62 -28
- package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
- package/src/commands/{appTemplates.ts → app/templates/ios.ts} +1 -1
- package/src/commands/assets.ts +23 -21
- package/src/commands/clone.ts +48 -51
- package/src/commands/compile.ts +62 -88
- package/src/commands/create.ts +46 -47
- package/src/commands/design/comments.ts +361 -0
- package/src/commands/design/context.ts +101 -0
- package/src/commands/design/index.ts +391 -0
- package/src/commands/design/snapshot.ts +134 -0
- package/src/commands/{designTemplates.ts → design/templates.ts} +4 -3
- package/src/commands/desktop.ts +90 -82
- package/src/commands/dev.ts +201 -176
- package/src/commands/diff.ts +39 -51
- package/src/commands/index.ts +60 -0
- package/src/commands/{init.ts → init/index.ts} +199 -191
- package/src/commands/install.ts +80 -67
- package/src/commands/lightmap.ts +203 -171
- package/src/commands/link.ts +87 -94
- package/src/commands/login.ts +25 -23
- package/src/commands/navmesh.ts +168 -138
- package/src/commands/pn.ts +201 -244
- package/src/commands/pull.ts +49 -56
- package/src/commands/push.ts +48 -54
- package/src/commands/render.ts +158 -99
- package/src/commands/scene.ts +58 -55
- package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
- package/src/commands/{shadersNew.ts → shaders/new.ts} +2 -2
- package/src/commands/shared.ts +79 -0
- package/src/commands/status.ts +22 -26
- package/src/commands/test.ts +398 -371
- package/src/commands/thumbs.ts +185 -175
- package/src/commands/update/index.ts +226 -0
- package/src/commands/{types.ts → update/types.ts} +31 -22
- package/src/compile/collect.ts +5 -5
- package/src/compile/collectLocal.ts +3 -3
- package/src/compile/designCompile.ts +3 -3
- package/src/compile/headlessBundle.ts +133 -129
- package/src/compile/projectCompile.ts +2 -2
- package/src/compile/sceneCompile.ts +10 -8
- package/src/compile/screenEntry.ts +123 -127
- package/src/compile/shaders.ts +3 -3
- package/src/dev/androidDev.ts +1 -1
- package/src/dev/devServer.ts +3 -3
- package/src/dev/webRunner.ts +1 -1
- package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +1 -1
- package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +6 -5
- package/src/{desktopScript.ts → hosts/desktopScript.ts} +1 -1
- package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
- package/src/{matcTool.ts → hosts/matcTool.ts} +1 -1
- package/src/{peerInstall.ts → hosts/peerInstall.ts} +2 -2
- package/src/{releases.ts → hosts/releases.ts} +1 -1
- package/src/index.ts +32 -480
- package/src/platform/api.ts +302 -0
- package/src/{browserAuth.ts → platform/browserAuth.ts} +1 -1
- package/src/{config.ts → platform/config.ts} +1 -1
- package/src/{serverDiff.ts → platform/serverDiff.ts} +3 -3
- package/src/{projectEnv.ts → project/env.ts} +33 -2
- package/src/{localFiles.ts → project/localFiles.ts} +1 -1
- package/src/{manifest.ts → project/manifest.ts} +1 -1
- package/src/{project.ts → project/materialize.ts} +2 -2
- package/src/project/paths.ts +20 -0
- package/src/{textDiff.ts → project/textDiff.ts} +1 -1
- package/src/{types.ts → project/types.ts} +0 -0
- package/runtime/web/assets/createViewerLite-Ct_PZdof.js +0 -852
- package/src/commands/design.ts +0 -846
- package/src/commands/update.ts +0 -211
- package/src/util.ts +0 -146
- /package/src/commands/{projectTemplates.ts → init/templates.ts} +0 -0
- /package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +0 -0
- /package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +0 -0
- /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
- /package/src/{lecodes-renderer.d.ts → declarations/lecodes-renderer.d.ts} +0 -0
- /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
- /package/src/{peers.ts → hosts/peers.ts} +0 -0
- /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
- /package/src/{ignore.ts → project/ignore.ts} +0 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { CliError } from "../cli"
|
|
2
|
+
|
|
3
|
+
/* Thin client over the backend HTTP API. All project/account calls go through `${apiUrl}/api/...`;
|
|
4
|
+
* blob downloads hit `${apiUrl}<src>` (the public /uploads/* route). Mirrors the contract the
|
|
5
|
+
* frontend uses (see packages/frontend/src/api). */
|
|
6
|
+
|
|
7
|
+
export type ProjectAsset = {
|
|
8
|
+
id: number
|
|
9
|
+
name: string
|
|
10
|
+
type: "folder" | "file" | "text" | "shader" | "scene"
|
|
11
|
+
parentId: number | null
|
|
12
|
+
meta?: Record<string, unknown> | null
|
|
13
|
+
text?: string | null
|
|
14
|
+
file?: { src: string, previewSrc?: string | null, size?: number } | null
|
|
15
|
+
shaders?: { platform: string, src: string }[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type Project = {
|
|
19
|
+
uuid: string
|
|
20
|
+
name: string
|
|
21
|
+
role: "owner" | "developer" | "viewer"
|
|
22
|
+
assets: ProjectAsset[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type CommitsResponse = { dirty: boolean, headId: number | null }
|
|
26
|
+
|
|
27
|
+
export type SyncFileInput = {
|
|
28
|
+
path: string
|
|
29
|
+
assetId?: number
|
|
30
|
+
type: "text" | "shader" | "scene" | "resource"
|
|
31
|
+
text?: string | null
|
|
32
|
+
blobFileId?: number | null
|
|
33
|
+
}
|
|
34
|
+
export type SyncFolderInput = { path: string }
|
|
35
|
+
export type SyncResponse = {
|
|
36
|
+
created: boolean
|
|
37
|
+
headId: number
|
|
38
|
+
seq?: number
|
|
39
|
+
nodes: { id: number, path: string, type: string, versionId: number | null }[]
|
|
40
|
+
/** Set when the push requested `publish` — whether the deploy (compile → live) succeeded. */
|
|
41
|
+
published?: boolean
|
|
42
|
+
/** A human-readable reason when `publish` was requested but the deploy failed (push still landed). */
|
|
43
|
+
publishError?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const errorMessage = (body: unknown, status: number): string => {
|
|
47
|
+
if (typeof body === "string" && body) return body
|
|
48
|
+
if (body && typeof body === "object") {
|
|
49
|
+
const b = body as Record<string, unknown>
|
|
50
|
+
if (typeof b.error === "string") return b.error
|
|
51
|
+
if (b.error && typeof b.error === "object") {
|
|
52
|
+
const first = Object.values(b.error)[0] as unknown
|
|
53
|
+
if (typeof first === "string") return first
|
|
54
|
+
if (first && typeof first === "object" && typeof (first as { message?: unknown }).message === "string") {
|
|
55
|
+
return (first as { message: string }).message
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (typeof b.message === "string") return b.message
|
|
59
|
+
}
|
|
60
|
+
return `Request failed with status ${status}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type RequestOptions = { method?: string, body?: unknown, token?: string }
|
|
64
|
+
|
|
65
|
+
export const apiRequest = async <T = unknown>(apiUrl: string, path: string, opts: RequestOptions = {}): Promise<T> => {
|
|
66
|
+
const headers: Record<string, string> = {}
|
|
67
|
+
const isForm = opts.body instanceof FormData
|
|
68
|
+
if (opts.token) headers["Authorization"] = "Bearer " + opts.token
|
|
69
|
+
if (opts.body !== undefined && !isForm) headers["Content-Type"] = "application/json"
|
|
70
|
+
|
|
71
|
+
let resp: Response
|
|
72
|
+
try {
|
|
73
|
+
resp = await fetch(`${apiUrl}/api${path}`, {
|
|
74
|
+
method: opts.method ?? (opts.body !== undefined ? "POST" : "GET"),
|
|
75
|
+
headers,
|
|
76
|
+
body: opts.body === undefined ? undefined : isForm ? (opts.body as FormData) : JSON.stringify(opts.body),
|
|
77
|
+
})
|
|
78
|
+
} catch (e) {
|
|
79
|
+
throw new CliError(`Cannot reach ${apiUrl} — is the server URL correct and online?`)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const contentType = resp.headers.get("content-type") ?? ""
|
|
83
|
+
const parsed: unknown = contentType.startsWith("application/json") ? await resp.json()
|
|
84
|
+
: contentType.startsWith("text/") ? await resp.text()
|
|
85
|
+
: null
|
|
86
|
+
|
|
87
|
+
if (resp.status >= 400) throw new CliError(errorMessage(parsed, resp.status), resp.status)
|
|
88
|
+
return parsed as T
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const login = (apiUrl: string, login: string, password: string) =>
|
|
92
|
+
apiRequest<{ accessToken: string, refreshToken: string }>(apiUrl, "/account/login", { body: { login, password } })
|
|
93
|
+
|
|
94
|
+
export const createAccessToken = (apiUrl: string, jwt: string, name: string) =>
|
|
95
|
+
apiRequest<{ id: number, name: string, prefix: string, token: string }>(apiUrl, "/account/tokens", { token: jwt, body: { name } })
|
|
96
|
+
|
|
97
|
+
export const getAccount = (apiUrl: string, token: string) =>
|
|
98
|
+
apiRequest<{ id: number, name: string, email: string }>(apiUrl, "/account", { token })
|
|
99
|
+
|
|
100
|
+
export const getProject = (apiUrl: string, token: string, uuid: string) =>
|
|
101
|
+
apiRequest<Project>(apiUrl, `/projects/${uuid}`, { token })
|
|
102
|
+
|
|
103
|
+
/** Create a new (empty) project on the server. Returns its uuid. */
|
|
104
|
+
export const createProject = (apiUrl: string, token: string, name: string, teamUuid?: string) =>
|
|
105
|
+
apiRequest<{ uuid: string }>(apiUrl, "/projects", { token, body: { name, teamUuid } })
|
|
106
|
+
|
|
107
|
+
export const getCommits = (apiUrl: string, token: string, uuid: string) =>
|
|
108
|
+
apiRequest<CommitsResponse>(apiUrl, `/projects/${uuid}/commits`, { token })
|
|
109
|
+
|
|
110
|
+
export const getTypes = (apiUrl: string) =>
|
|
111
|
+
apiRequest<Record<string, string>>(apiUrl, "/types")
|
|
112
|
+
|
|
113
|
+
export const uploadBlob = (apiUrl: string, token: string, uuid: string, name: string, bytes: Uint8Array) => {
|
|
114
|
+
const form = new FormData()
|
|
115
|
+
// Cast is type-only (TS 5.7 typed-array generics vs the Blob signature); Blob accepts a Uint8Array.
|
|
116
|
+
form.append("file", new Blob([bytes as unknown as ArrayBuffer]), name)
|
|
117
|
+
return apiRequest<{ id: number, src: string, size: number }>(apiUrl, `/projects/${uuid}/blobs`, { token, body: form })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const sync = (apiUrl: string, token: string, uuid: string, payload: { message: string, files: SyncFileInput[], folders: SyncFolderInput[], publish?: boolean }) =>
|
|
121
|
+
apiRequest<SyncResponse>(apiUrl, `/projects/${uuid}/sync`, { token, body: payload })
|
|
122
|
+
|
|
123
|
+
/** Deploy the project's current server state (compile → live bundle). Used by `push --publish` when
|
|
124
|
+
* there are no file changes to sync. */
|
|
125
|
+
export const publishProject = (apiUrl: string, token: string, uuid: string) =>
|
|
126
|
+
apiRequest<{ published: boolean, size: number }>(apiUrl, `/projects/${uuid}/publish`, { token, body: {} })
|
|
127
|
+
|
|
128
|
+
export type DesignShare = {
|
|
129
|
+
token: string | null
|
|
130
|
+
url: string | null
|
|
131
|
+
/** Whether share-link visitors may WRITE comments (reading is never gated on it). */
|
|
132
|
+
commentsOpen?: boolean
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Current public share-link state for a project's design board. */
|
|
136
|
+
export const getDesignShare = (apiUrl: string, token: string, uuid: string) =>
|
|
137
|
+
apiRequest<DesignShare>(apiUrl, `/projects/${uuid}/design/share`, { token })
|
|
138
|
+
|
|
139
|
+
/** Enable the design share link (idempotent). */
|
|
140
|
+
export const enableDesignShare = (apiUrl: string, token: string, uuid: string) =>
|
|
141
|
+
apiRequest<DesignShare>(apiUrl, `/projects/${uuid}/design/share`, { token, body: {} })
|
|
142
|
+
|
|
143
|
+
/** Revoke the design share link. */
|
|
144
|
+
export const disableDesignShare = (apiUrl: string, token: string, uuid: string) =>
|
|
145
|
+
apiRequest<DesignShare>(apiUrl, `/projects/${uuid}/design/share`, { token, method: "DELETE" })
|
|
146
|
+
|
|
147
|
+
/** Let / stop share-link visitors WRITING comments. Reading the existing conversation is unaffected,
|
|
148
|
+
* so switching this off never hides feedback that already arrived. */
|
|
149
|
+
export const setDesignCommentsOpen = (apiUrl: string, token: string, uuid: string, open: boolean) =>
|
|
150
|
+
apiRequest<{ commentsOpen: boolean }>(apiUrl, `/projects/${uuid}/design/comments/open`, { token, body: { open } })
|
|
151
|
+
|
|
152
|
+
/* ---- Design-board comments (docs/design-comments-plan.md) ---------------------------------
|
|
153
|
+
* The platform is the source of truth; `design/comments/` (one file per thread) is the mirror the
|
|
154
|
+
* running `lecodes design` sync — or `comments pull` — fills. The shapes below are the same
|
|
155
|
+
* document the design package's core defines —
|
|
156
|
+
* spelled out here, like every other DTO in this file, because `lecodes-design` is an optional
|
|
157
|
+
* peer the CLI must typecheck and run without. */
|
|
158
|
+
|
|
159
|
+
export type CommentAnchor = {
|
|
160
|
+
screen?: string
|
|
161
|
+
state?: string
|
|
162
|
+
element?: string
|
|
163
|
+
point?: [number, number]
|
|
164
|
+
world?: [number, number]
|
|
165
|
+
}
|
|
166
|
+
export type CommentMessage = {
|
|
167
|
+
id: string
|
|
168
|
+
author: string
|
|
169
|
+
/** Provenance: `share` = unverified stranger on the link, `ai` = Claude on a member's behalf. */
|
|
170
|
+
origin: "share" | "editor" | "cli" | "ai"
|
|
171
|
+
at: string
|
|
172
|
+
body: string
|
|
173
|
+
editedAt?: string
|
|
174
|
+
mine?: boolean
|
|
175
|
+
}
|
|
176
|
+
export type CommentThread = {
|
|
177
|
+
id: string
|
|
178
|
+
number: number
|
|
179
|
+
anchor: CommentAnchor
|
|
180
|
+
resolved: boolean
|
|
181
|
+
resolvedAt?: string | null
|
|
182
|
+
createdAt: string
|
|
183
|
+
updatedAt: string
|
|
184
|
+
commitId?: number | null
|
|
185
|
+
messages: CommentMessage[]
|
|
186
|
+
mine?: boolean
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export type DesignCommentsSnapshot = {
|
|
190
|
+
threads: CommentThread[]
|
|
191
|
+
cursor: string | null
|
|
192
|
+
canComment: boolean
|
|
193
|
+
shared?: boolean
|
|
194
|
+
commentsOpen?: boolean
|
|
195
|
+
/** The project's thread-number allocator, so a pull can't hand out a used number. */
|
|
196
|
+
nextNumber?: number
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export const listDesignComments = (apiUrl: string, token: string, uuid: string) =>
|
|
200
|
+
apiRequest<DesignCommentsSnapshot>(apiUrl, `/projects/${uuid}/design/comments`, { token })
|
|
201
|
+
|
|
202
|
+
/** `origin` is provenance, carried up so a reply Claude wrote doesn't reach the reviewer looking
|
|
203
|
+
* like one the developer typed. The account is the identity either way; the server rejects any
|
|
204
|
+
* value but `cli` / `ai` / `editor`. */
|
|
205
|
+
export const createDesignComment = (
|
|
206
|
+
apiUrl: string, token: string, uuid: string,
|
|
207
|
+
input: { anchor: CommentAnchor, body: string, origin?: CommentMessage["origin"] },
|
|
208
|
+
) => apiRequest<CommentThread>(apiUrl, `/projects/${uuid}/design/comments`, { token, body: input })
|
|
209
|
+
|
|
210
|
+
export const replyDesignComment = (
|
|
211
|
+
apiUrl: string, token: string, uuid: string, threadId: string, body: string,
|
|
212
|
+
origin?: CommentMessage["origin"],
|
|
213
|
+
) => apiRequest<CommentThread>(
|
|
214
|
+
apiUrl, `/projects/${uuid}/design/comments/${threadId}/messages`, { token, body: { body, origin } })
|
|
215
|
+
|
|
216
|
+
export const resolveDesignComment = (apiUrl: string, token: string, uuid: string, threadId: string, resolved: boolean) =>
|
|
217
|
+
apiRequest<CommentThread>(apiUrl, `/projects/${uuid}/design/comments/${threadId}`, { token, method: "PATCH", body: { resolved } })
|
|
218
|
+
|
|
219
|
+
export const deleteDesignComment = (apiUrl: string, token: string, uuid: string, threadId: string) =>
|
|
220
|
+
apiRequest<{ ok: boolean }>(apiUrl, `/projects/${uuid}/design/comments/${threadId}`, { token, method: "DELETE" })
|
|
221
|
+
|
|
222
|
+
/** Re-anchor a pin. The anchor goes up whole — `element` is replaced, never merged, or a stale name
|
|
223
|
+
* silently re-anchors the pin to whatever else still answers to it. */
|
|
224
|
+
export const moveDesignComment = (
|
|
225
|
+
apiUrl: string, token: string, uuid: string, threadId: string, anchor: CommentAnchor,
|
|
226
|
+
) => apiRequest<CommentThread>(apiUrl, `/projects/${uuid}/design/comments/${threadId}`, {
|
|
227
|
+
token,
|
|
228
|
+
method: "PATCH",
|
|
229
|
+
body: { screen: anchor.screen, state: anchor.state, element: anchor.element, point: anchor.point },
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
export const editDesignCommentMessage = (
|
|
233
|
+
apiUrl: string, token: string, uuid: string, threadId: string, messageId: string, body: string,
|
|
234
|
+
) => apiRequest<CommentThread>(
|
|
235
|
+
apiUrl, `/projects/${uuid}/design/comments/${threadId}/messages/${messageId}`, { token, method: "PATCH", body: { body } })
|
|
236
|
+
|
|
237
|
+
export const deleteDesignCommentMessage = (
|
|
238
|
+
apiUrl: string, token: string, uuid: string, threadId: string, messageId: string,
|
|
239
|
+
) => apiRequest<{ threadRemoved: boolean }>(
|
|
240
|
+
apiUrl, `/projects/${uuid}/design/comments/${threadId}/messages/${messageId}`, { token, method: "DELETE" })
|
|
241
|
+
|
|
242
|
+
/* ---- Push notifications (`lecodes pn`, docs/push-plan.md) ---------------------------------
|
|
243
|
+
* Every call here authenticates with the stored PAT: /push/send accepts a developer-role user
|
|
244
|
+
* token exactly like a project push key, so test sends need no key at all. Keys are minted for
|
|
245
|
+
* ONE purpose — the developer's own production server — and only an owner can manage them. */
|
|
246
|
+
|
|
247
|
+
export type PushSendPayload = {
|
|
248
|
+
to?: string[]
|
|
249
|
+
users?: string[]
|
|
250
|
+
title: string
|
|
251
|
+
body?: string
|
|
252
|
+
url?: string
|
|
253
|
+
data?: Record<string, unknown>
|
|
254
|
+
badge?: number
|
|
255
|
+
}
|
|
256
|
+
export type PushSendResult = { sent: number, failed: { address: string, reason: string }[] }
|
|
257
|
+
|
|
258
|
+
export type PushDevice = {
|
|
259
|
+
address: string
|
|
260
|
+
platform: "apns" | "fcm" | "webpush"
|
|
261
|
+
environment: "production" | "sandbox"
|
|
262
|
+
hostApp: string
|
|
263
|
+
user: string | null
|
|
264
|
+
createdAt: string
|
|
265
|
+
lastSeenAt: string
|
|
266
|
+
disabledAt: string | null
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export type PushKey = {
|
|
270
|
+
id: number
|
|
271
|
+
name: string
|
|
272
|
+
prefix: string
|
|
273
|
+
createdAt: string
|
|
274
|
+
lastUsedAt: string | null
|
|
275
|
+
}
|
|
276
|
+
/** Only the mint response carries `key` — the plaintext exists in this one response and nowhere else. */
|
|
277
|
+
export type CreatedPushKey = PushKey & { key: string }
|
|
278
|
+
|
|
279
|
+
export const sendPush = (apiUrl: string, token: string, uuid: string, payload: PushSendPayload) =>
|
|
280
|
+
apiRequest<PushSendResult>(apiUrl, `/projects/${uuid}/push/send`, { token, body: payload })
|
|
281
|
+
|
|
282
|
+
export const listPushDevices = (apiUrl: string, token: string, uuid: string, user?: string) =>
|
|
283
|
+
apiRequest<{ devices: PushDevice[], truncated: boolean }>(
|
|
284
|
+
apiUrl, `/projects/${uuid}/push/registrations${user ? `?user=${encodeURIComponent(user)}` : ""}`, { token })
|
|
285
|
+
|
|
286
|
+
export const forgetPushDevice = (apiUrl: string, token: string, uuid: string, address: string) =>
|
|
287
|
+
apiRequest<void>(apiUrl, `/projects/${uuid}/push/registrations/${encodeURIComponent(address)}`, { token, method: "DELETE" })
|
|
288
|
+
|
|
289
|
+
export const listPushKeys = (apiUrl: string, token: string, uuid: string) =>
|
|
290
|
+
apiRequest<PushKey[]>(apiUrl, `/projects/${uuid}/push/keys`, { token })
|
|
291
|
+
|
|
292
|
+
export const createPushKey = (apiUrl: string, token: string, uuid: string, name?: string) =>
|
|
293
|
+
apiRequest<CreatedPushKey>(apiUrl, `/projects/${uuid}/push/keys`, { token, body: { name } })
|
|
294
|
+
|
|
295
|
+
export const revokePushKey = (apiUrl: string, token: string, uuid: string, id: number) =>
|
|
296
|
+
apiRequest<void>(apiUrl, `/projects/${uuid}/push/keys/${id}`, { token, method: "DELETE" })
|
|
297
|
+
|
|
298
|
+
export const downloadBlob = async (apiUrl: string, src: string): Promise<Uint8Array> => {
|
|
299
|
+
const resp = await fetch(`${apiUrl}${src}`)
|
|
300
|
+
if (!resp.ok) throw new CliError(`Failed to download ${src} (status ${resp.status})`)
|
|
301
|
+
return new Uint8Array(await resp.arrayBuffer())
|
|
302
|
+
}
|
|
@@ -3,7 +3,7 @@ import type { AddressInfo } from "node:net"
|
|
|
3
3
|
import { spawn } from "node:child_process"
|
|
4
4
|
import { randomBytes } from "node:crypto"
|
|
5
5
|
import { hostname } from "node:os"
|
|
6
|
-
import { CliError, c, info, log } from "
|
|
6
|
+
import { CliError, c, info, log } from "../cli"
|
|
7
7
|
|
|
8
8
|
/*
|
|
9
9
|
* Browser login (loopback OAuth-style flow, like `vercel`/`gh`):
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { homedir } from "node:os"
|
|
2
2
|
import { join } from "node:path"
|
|
3
3
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
4
|
-
import { CliError } from "
|
|
4
|
+
import { CliError } from "../cli"
|
|
5
5
|
|
|
6
6
|
/* Global CLI config in ~/.lecodes/config.json: the server URL + the personal access token.
|
|
7
7
|
* Env vars LECODES_API / LECODES_TOKEN override the stored values (handy for CI). */
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs"
|
|
2
2
|
import type { Project, ProjectAsset } from "./api"
|
|
3
|
-
import type { LocalScan } from "
|
|
4
|
-
import { classifyKind, isTextKind } from "
|
|
5
|
-
import { buildPaths } from "
|
|
3
|
+
import type { LocalScan } from "../project/localFiles"
|
|
4
|
+
import { classifyKind, isTextKind } from "../project/localFiles"
|
|
5
|
+
import { buildPaths } from "../project/materialize"
|
|
6
6
|
import { basename } from "node:path"
|
|
7
7
|
|
|
8
8
|
/*
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs"
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
2
|
import { homedir } from "node:os"
|
|
3
3
|
import { dirname, join, resolve } from "node:path"
|
|
4
4
|
|
|
@@ -57,7 +57,12 @@ export const applyDotEnv = (
|
|
|
57
57
|
): Record<string, string> => {
|
|
58
58
|
const out: Record<string, string> = {}
|
|
59
59
|
for (const [key, raw] of Object.entries(parsed)) {
|
|
60
|
-
if (!KEY_RE.test(key)
|
|
60
|
+
if (!KEY_RE.test(key)) continue
|
|
61
|
+
// Bun auto-loads the cwd's .env into process.env before any code runs, so a project's own
|
|
62
|
+
// keys arrive here already set — to their RAW text, `./` paths unresolved. That is not the
|
|
63
|
+
// shell overriding the file, it is the same file seen twice: apply the resolution. A shell
|
|
64
|
+
// value that differs from the file's text is a real override and stays.
|
|
65
|
+
if (env[key] !== undefined && env[key] !== raw) continue
|
|
61
66
|
let value = raw
|
|
62
67
|
if (value.startsWith("./") || value.startsWith("../")) value = resolve(dir, value)
|
|
63
68
|
else if (value === "~" || value.startsWith("~/")) value = join(homedir(), value.slice(1))
|
|
@@ -96,3 +101,29 @@ export const loadProjectEnv = (cwd = process.cwd()): string[] => {
|
|
|
96
101
|
return []
|
|
97
102
|
}
|
|
98
103
|
}
|
|
104
|
+
|
|
105
|
+
/** Set keys in `<root>/.env` — an existing `KEY=` line is REPLACED (rotating a secret must not
|
|
106
|
+
* leave two live lines), a new one is appended, other lines are kept verbatim — and make sure
|
|
107
|
+
* git can't swallow the file (`.env` in .gitignore). Returns the paths touched, root-relative.
|
|
108
|
+
* Values are written raw: quote them yourself if they carry spaces or `#`. */
|
|
109
|
+
export const upsertDotEnv = (root: string, entries: Record<string, string>): string[] => {
|
|
110
|
+
const touched: string[] = []
|
|
111
|
+
const envPath = join(root, ".env")
|
|
112
|
+
let content = existsSync(envPath) ? readFileSync(envPath, "utf8") : ""
|
|
113
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
114
|
+
const line = `${key}=${value}`
|
|
115
|
+
const re = new RegExp(`^(?:export\\s+)?${key}\\s*=.*$`, "m")
|
|
116
|
+
if (re.test(content)) content = content.replace(re, line)
|
|
117
|
+
else content = content + (!content || content.endsWith("\n") ? "" : "\n") + line + "\n"
|
|
118
|
+
}
|
|
119
|
+
writeFileSync(envPath, content)
|
|
120
|
+
touched.push(".env")
|
|
121
|
+
|
|
122
|
+
const ignorePath = join(root, ".gitignore")
|
|
123
|
+
const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : ""
|
|
124
|
+
if (!ignore.split("\n").some(l => l.trim() === ".env")) {
|
|
125
|
+
appendFileSync(ignorePath, `${!ignore || ignore.endsWith("\n") ? "" : "\n"}.env\n`)
|
|
126
|
+
touched.push(".gitignore")
|
|
127
|
+
}
|
|
128
|
+
return touched
|
|
129
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
|
2
2
|
import { join, relative } from "node:path"
|
|
3
3
|
import { sanitizeAssetName } from "lecodes-sdk/compile/assetName"
|
|
4
|
-
import { sha256, toKey } from "./
|
|
4
|
+
import { sha256, toKey } from "./paths"
|
|
5
5
|
import { loadIgnore, type IgnoreMatcher } from "./ignore"
|
|
6
6
|
import type { FileKind, Manifest } from "./manifest"
|
|
7
7
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { dirname, join } from "node:path"
|
|
2
2
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
|
|
3
|
-
import { CliError } from "
|
|
3
|
+
import { CliError } from "../cli"
|
|
4
4
|
|
|
5
5
|
/* The per-checkout state in <root>/.lecodes/manifest.json: which remote project this is, the
|
|
6
6
|
* head we last synced from (for drift detection), and a path -> { id, type, sha } map used to
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { dirname, join } from "node:path"
|
|
2
2
|
import { mkdirSync, writeFileSync } from "node:fs"
|
|
3
|
-
import { downloadBlob, type Project, type ProjectAsset } from "
|
|
3
|
+
import { downloadBlob, type Project, type ProjectAsset } from "../platform/api"
|
|
4
4
|
import type { FileKind, ManifestFile } from "./manifest"
|
|
5
|
-
import { sha256 } from "./
|
|
5
|
+
import { sha256 } from "./paths"
|
|
6
6
|
|
|
7
7
|
/* Writes a project's asset tree to disk and returns the path -> { id, type, sha } map for the
|
|
8
8
|
* manifest. Shared by clone and pull. */
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { sanitizeAssetName } from "lecodes-sdk/compile/assetName"
|
|
3
|
+
import { CliError } from "../cli/errors"
|
|
4
|
+
|
|
5
|
+
/* How a project file is addressed: the same posix key on every platform, and the hash the
|
|
6
|
+
* manifest stores per file. */
|
|
7
|
+
|
|
8
|
+
export const sha256 = (data: string | Uint8Array): string => createHash("sha256").update(data).digest("hex")
|
|
9
|
+
|
|
10
|
+
/** Normalize any path to a leading-slash, forward-slash, "." / ".." free key (e.g. "/a/b.ts").
|
|
11
|
+
* Each segment gets the platform's canonical spelling (sdk/compile/assetName) — macOS hands out
|
|
12
|
+
* decomposed names ("й" as и + U+0306) from readdir, which would otherwise key a file differently
|
|
13
|
+
* than the same name typed in an import or stored on the server. */
|
|
14
|
+
export const toKey = (path: string): string => {
|
|
15
|
+
const parts = path.replace(/\\/g, "/").split("/").map(p => p.trim()).filter(p => p && p !== ".")
|
|
16
|
+
if (parts.some(p => p === "..")) throw new CliError(`Unsafe path: ${path}`)
|
|
17
|
+
const names = parts.map(p => sanitizeAssetName(p))
|
|
18
|
+
if (names.some(name => !name)) throw new CliError(`Wrong path: ${path}`)
|
|
19
|
+
return "/" + names.join("/")
|
|
20
|
+
}
|
|
Binary file
|