lecodes-cli 2.2.13 → 2.3.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 +14798 -7313
- package/package.json +8 -8
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/player.js +2 -2
- package/runtime/web-lite/lite.js +1 -1
- package/src/commands/init/index.ts +195 -195
- package/src/commands/shared.ts +79 -79
- package/src/commands/thumbs.ts +185 -185
- package/src/compile/screenEntry.ts +123 -123
- package/src/dev/backendWorker.ts +26 -3
- package/src/dev/localBackend.ts +7 -2
- package/src/dev/projectBackend.ts +8 -2
- package/src/platform/api.ts +2 -0
- package/src/project/ignore.ts +164 -164
package/src/dev/backendWorker.ts
CHANGED
|
@@ -4,11 +4,12 @@ import { tmpdir } from "node:os"
|
|
|
4
4
|
import { join } from "node:path"
|
|
5
5
|
import { format } from "node:util"
|
|
6
6
|
import {
|
|
7
|
-
createAuthHost, createFilesHost, createInvoker, createSubscribeGuard, describeServer, loadServerModules, runSeed,
|
|
8
|
-
setChannelPublisher, setDbTransport, type AuthHost, type Heic, type LoadedServer, type Sharp,
|
|
7
|
+
createAuthHost, createFilesHost, createInvoker, createPushHost, createSubscribeGuard, describeServer, loadServerModules, runSeed,
|
|
8
|
+
setChannelPublisher, setDbTransport, type AuthHost, type Heic, type LoadedServer, type PushRelay, type Sharp,
|
|
9
9
|
} from "lecodes-sdk/server"
|
|
10
10
|
import { loadPeer } from "../hosts/peerInstall"
|
|
11
|
-
import
|
|
11
|
+
import { ApiError, sendPush } from "../platform/api"
|
|
12
|
+
import type { FromWorker, LogLevel, PushSender, ToWorker } from "./localBackend"
|
|
12
13
|
|
|
13
14
|
/*
|
|
14
15
|
* The process a project's server bundle runs in under `lecodes dev` — the local twin of the runner's
|
|
@@ -96,6 +97,21 @@ const openDb = async (dir: string, filesDir: string, schema: string): Promise<st
|
|
|
96
97
|
}
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
/** Push notifications go out through the platform's send route on the developer's own login, as `lecodes pn send`
|
|
101
|
+
* does — a project push key is never needed here. The platform's 404 for an address it knows no more
|
|
102
|
+
* (`unknown_addresses`) is answered as `unknown`, for the host to drop and send the rest again. */
|
|
103
|
+
const pushRelay = (sender: PushSender): PushRelay => async (to, message) => {
|
|
104
|
+
try {
|
|
105
|
+
return await sendPush(sender.apiUrl, sender.token, sender.uuid, { to, ...message })
|
|
106
|
+
} catch (e) {
|
|
107
|
+
const body = e instanceof ApiError ? e.body as { code?: string, addresses?: unknown } | null : null
|
|
108
|
+
if (body?.code === "unknown_addresses" && Array.isArray(body.addresses)) {
|
|
109
|
+
return { sent: 0, failed: [], unknown: body.addresses.filter((a): a is string => typeof a === "string") }
|
|
110
|
+
}
|
|
111
|
+
throw new Error(`push: the platform refused the send: ${(e as Error).message}`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
99
115
|
const load = async (m: Extract<ToWorker, { t: "load" }>) => {
|
|
100
116
|
const noDb = () => { throw new Error("db: the database is not open yet — query it inside an endpoint, not while the module loads") }
|
|
101
117
|
setDbTransport({ exec: (op) => (db ?? noDb()).exec(op), batch: (ops) => (db ?? noDb()).batch(ops) })
|
|
@@ -116,6 +132,13 @@ const load = async (m: Extract<ToWorker, { t: "load" }>) => {
|
|
|
116
132
|
log: (level, text) => post({ t: "log", level, text }),
|
|
117
133
|
})
|
|
118
134
|
authHost.install()
|
|
135
|
+
// Push channels: a user's devices are the sessions of the db with sign-in; the notifications go out through the platform
|
|
136
|
+
// when this checkout is linked and the developer is logged in.
|
|
137
|
+
createPushHost({
|
|
138
|
+
db: server.dbs.find(d => d.$auth) ?? null,
|
|
139
|
+
relay: m.push ? pushRelay(m.push) : null,
|
|
140
|
+
log: (level, text) => post({ t: "log", level, text }),
|
|
141
|
+
}).install()
|
|
119
142
|
const auth = authHost.enabled ? authHost : null
|
|
120
143
|
const onError = (id: string, e: unknown) => post({ t: "log", level: "error", text: `${id}: ${(e as Error)?.stack ?? e}` })
|
|
121
144
|
// Stored files (`t.file()`): rows in the database, bytes in the folder beside it
|
package/src/dev/localBackend.ts
CHANGED
|
@@ -25,7 +25,7 @@ export type LogLevel = "log" | "info" | "warn" | "error" | "debug"
|
|
|
25
25
|
type Ctx = Omit<RequestContext, "id">
|
|
26
26
|
|
|
27
27
|
export type ToWorker =
|
|
28
|
-
| { t: "load", code: string, dbDir: string, filesDir: string, root: string }
|
|
28
|
+
| { t: "load", code: string, dbDir: string, filesDir: string, root: string, push: PushSender | null }
|
|
29
29
|
| { t: "invoke", req: number, id: string, args: unknown[], ctx: Ctx }
|
|
30
30
|
| { t: "subscribe", req: number, ch: string, args: unknown[], ctx: Ctx }
|
|
31
31
|
| { t: "stop" }
|
|
@@ -37,8 +37,13 @@ export type FromWorker =
|
|
|
37
37
|
| { t: "publish", ch: string, group: ChannelGroup | null, data: unknown }
|
|
38
38
|
| { t: "log", level: LogLevel, text: string }
|
|
39
39
|
|
|
40
|
+
/** How `pushChannel.send` reaches the platform's sender from this machine: the linked project and the developer's
|
|
41
|
+
* own login (what `lecodes pn send` sends with). `null` = not linked or not logged in: sends answer 0, said once. */
|
|
42
|
+
export type PushSender = { apiUrl: string, token: string, uuid: string }
|
|
43
|
+
|
|
40
44
|
export type LocalBackendOptions = {
|
|
41
45
|
root: string
|
|
46
|
+
push?: PushSender | null
|
|
42
47
|
/** The database's folder (default `.lecodes/db`). */
|
|
43
48
|
dbDir?: string
|
|
44
49
|
/** The stored files' folder (`t.file()`; default: beside the database — `.lecodes/files`). */
|
|
@@ -147,7 +152,7 @@ export const startLocalBackend = (options: LocalBackendOptions): LocalBackend =>
|
|
|
147
152
|
if (!closed) log(`[server:error] the server process exited (${status ?? "killed"}) — it starts again with the next call`)
|
|
148
153
|
}
|
|
149
154
|
})
|
|
150
|
-
child.send({ t: "load", code: bundle, dbDir, filesDir, root: options.root } satisfies ToWorker)
|
|
155
|
+
child.send({ t: "load", code: bundle, dbDir, filesDir, root: options.root, push: options.push ?? null } satisfies ToWorker)
|
|
151
156
|
})
|
|
152
157
|
|
|
153
158
|
const stop = async (l: Live) => {
|
|
@@ -3,7 +3,8 @@ import { join, relative } from "node:path"
|
|
|
3
3
|
import { createChannelHub, fileResponse, isMultipart, receiveUploads, UploadError, type Upload } from "lecodes-sdk/server"
|
|
4
4
|
import { compileServer, hasServerFiles, type CompileEntry } from "../compile/projectCompile"
|
|
5
5
|
import { loadPeer } from "../hosts/peerInstall"
|
|
6
|
-
import { LECODES_DIR } from "../project/manifest"
|
|
6
|
+
import { LECODES_DIR, readManifest } from "../project/manifest"
|
|
7
|
+
import { session } from "../commands/shared"
|
|
7
8
|
import { readAppConfigAt } from "../commands/app/shared"
|
|
8
9
|
import { filesDirOf, startLocalBackend, type LocalBackend } from "./localBackend"
|
|
9
10
|
import { serve, type Served, type Socket, type SocketHandlers } from "./serve"
|
|
@@ -73,7 +74,12 @@ export const createProjectBackend = (options: ProjectBackendOptions): ProjectBac
|
|
|
73
74
|
: Promise.resolve({ ok: false as const, status: 404, message: "The project has no server files" }),
|
|
74
75
|
})
|
|
75
76
|
const sockets = new Set<Socket>()
|
|
76
|
-
|
|
77
|
+
// `pushChannel.send` reaches the platform's sender through the developer's own login when the checkout is linked —
|
|
78
|
+
// the way `lecodes pn send` sends; a project standing alone, or no login, sends nothing (said once in the console)
|
|
79
|
+
const push = (() => {
|
|
80
|
+
try { const { apiUrl, token } = session(); return { apiUrl, token, uuid: readManifest(root).uuid } } catch { return null }
|
|
81
|
+
})()
|
|
82
|
+
const start = () => startLocalBackend({ root, dbDir, log, push, onPublish: (ch, group, data) => hub.publish("", ch, group, data) })
|
|
77
83
|
|
|
78
84
|
const envelope = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json", "cache-control": "no-store", ...CORS } })
|
|
79
85
|
|
package/src/platform/api.ts
CHANGED
|
@@ -353,6 +353,8 @@ export const deleteDesignCommentMessage = (
|
|
|
353
353
|
export type PushSendPayload = {
|
|
354
354
|
to?: string[]
|
|
355
355
|
users?: string[]
|
|
356
|
+
/** The server's push channel the notification is of (`<path>#<export>`): the app's side hears each channel's own. */
|
|
357
|
+
channel?: string
|
|
356
358
|
title: string
|
|
357
359
|
body?: string
|
|
358
360
|
url?: string
|
package/src/project/ignore.ts
CHANGED
|
@@ -1,164 +1,164 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
-
import { join } from "node:path"
|
|
3
|
-
|
|
4
|
-
/*
|
|
5
|
-
* `.lecodesignore` support — keeps scratch files (temp probes, render screenshots, logs) out of
|
|
6
|
-
* `status` / `push` so they never land in a checkpoint. Syntax is a practical subset of .gitignore:
|
|
7
|
-
* - blank lines and `# comments` are skipped
|
|
8
|
-
* - `!pattern` re-includes something a previous pattern ignored
|
|
9
|
-
* - a trailing `/` matches directories only
|
|
10
|
-
* - a leading `/` (or any embedded `/`) anchors the pattern to the project root
|
|
11
|
-
* - `*` matches within a path segment, `**` across segments, `?` a single char
|
|
12
|
-
* - a slash-free pattern (e.g. `_*`, `*.png`) matches a basename at any depth
|
|
13
|
-
*
|
|
14
|
-
* IMPORTANT: ignores apply to UNTRACKED files only. A file already in the project (recorded in the
|
|
15
|
-
* manifest) is always tracked, even if it matches a pattern — so ignoring `*.png` can never make an
|
|
16
|
-
* existing image asset silently disappear from the project. Pruning happens in `scanLocal`.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
export type IgnoreMatcher = (key: string, isDir: boolean) => boolean
|
|
20
|
-
|
|
21
|
-
type Rule = { re: RegExp, negate: boolean, dirOnly: boolean }
|
|
22
|
-
|
|
23
|
-
// Always applied, even with no .lecodesignore — only patterns that are never real project assets, so
|
|
24
|
-
// they're safe as invisible defaults. Everything asset-shaped (*.png, *.json, …) is opt-in via the
|
|
25
|
-
// file written at clone time (see DEFAULT_IGNORE_FILE). `.artifacts/` is `lecodes test`'s
|
|
26
|
-
// failure-frame dump dir (tests/.artifacts/) — regenerated scratch, never a project asset.
|
|
27
|
-
// `dist/` is `lecodes app build desktop`'s default output (<root>/dist/<slug>, appDesktop.ts) —
|
|
28
|
-
// same class, but it must be ignored for a second, load-bearing reason: that folder holds a COPY of
|
|
29
|
-
// the host's runtime assets (lit.filamat, uberarchive.bin, …). Collected as project assets they get
|
|
30
|
-
// staged next to the bundle, and onFetchLocal searches the bundle's dir BEFORE the host's assets dir
|
|
31
|
-
// (main.cpp) — so the copy shadows the host's own. Harmless while every host was OpenGL and the
|
|
32
|
-
// bytes matched; fatal once a build output can come from the Vulkan host, where the shadowing
|
|
33
|
-
// lit.filamat is SPIR-V and Filament aborts with "not built for any of the OpenGL backend's
|
|
34
|
-
// supported shader languages".
|
|
35
|
-
// `.env` / `.env.*`: the project's LECODES_* config (projectEnv.ts) — it holds tokens and keystore
|
|
36
|
-
// passwords, so it never leaves the machine whatever .lecodesignore says.
|
|
37
|
-
// `.lightmap/` = a bake's float debug layers (`lecodes lightmap bake --debug-dir`): gigabytes the compile scan must never read
|
|
38
|
-
const BUILTIN_PATTERNS = ["node_modules/", ".git/", "_*", "*.log", ".DS_Store", ".artifacts/", "dist/", ".env", ".env.*", ".lightmap/"]
|
|
39
|
-
|
|
40
|
-
/** Template `.lecodesignore` written on clone — the explicit, editable "what we don't push" list. */
|
|
41
|
-
export const DEFAULT_IGNORE_FILE = `# Files matched here are NOT pushed to LeCodes.
|
|
42
|
-
# Only untracked files are affected — assets already in the project stay tracked
|
|
43
|
-
# even if they match a pattern. Built-in defaults (node_modules, _*, *.log, .artifacts)
|
|
44
|
-
# always apply.
|
|
45
|
-
|
|
46
|
-
# Render / preview outputs (the default \`lecodes render --png\` target)
|
|
47
|
-
screenshot.png
|
|
48
|
-
shots/
|
|
49
|
-
|
|
50
|
-
# Design-board comments live on the platform; design/comments/ is the local mirror of the
|
|
51
|
-
# open ones (\`lecodes design comments pull\`) — read by Claude, but pushing it back as
|
|
52
|
-
# project files would only duplicate the database.
|
|
53
|
-
design/comments/
|
|
54
|
-
|
|
55
|
-
# Image & other binary assets ARE pushed by default — they're real project resources.
|
|
56
|
-
# Uncomment a pattern below only if you want to keep some untracked ones out of pushes.
|
|
57
|
-
# *.png
|
|
58
|
-
|
|
59
|
-
# Local notes & dumps (uncomment to ignore)
|
|
60
|
-
# *.json
|
|
61
|
-
# notes.md
|
|
62
|
-
`
|
|
63
|
-
|
|
64
|
-
/** Translate a glob (one path segment's worth of `*`/`?`/`**`) into a regex fragment. */
|
|
65
|
-
const translate = (glob: string): string => {
|
|
66
|
-
let re = ""
|
|
67
|
-
for (let i = 0; i < glob.length; i++) {
|
|
68
|
-
const ch = glob[i]
|
|
69
|
-
if (ch === "*") {
|
|
70
|
-
if (glob[i + 1] === "*") { re += ".*"; i++; if (glob[i + 1] === "/") i++ } // `**/` spans dirs
|
|
71
|
-
else re += "[^/]*"
|
|
72
|
-
} else if (ch === "?") re += "[^/]"
|
|
73
|
-
else if (ch === "/") re += "/"
|
|
74
|
-
else if ("\\^$.|+()[]{}".includes(ch)) re += "\\" + ch
|
|
75
|
-
else re += ch
|
|
76
|
-
}
|
|
77
|
-
return re
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const compile = (raw: string): Rule | null => {
|
|
81
|
-
let p = raw.trim()
|
|
82
|
-
if (!p || p.startsWith("#")) return null
|
|
83
|
-
let negate = false
|
|
84
|
-
if (p.startsWith("!")) { negate = true; p = p.slice(1) }
|
|
85
|
-
let dirOnly = false
|
|
86
|
-
if (p.endsWith("/")) { dirOnly = true; p = p.slice(0, -1) }
|
|
87
|
-
const anchored = p.startsWith("/") || p.includes("/")
|
|
88
|
-
if (p.startsWith("/")) p = p.slice(1)
|
|
89
|
-
if (!p) return null
|
|
90
|
-
const body = translate(p)
|
|
91
|
-
// `(/.*)?$` lets a directory pattern also match files nested under it, matching gitignore behavior
|
|
92
|
-
// even without relying on directory pruning.
|
|
93
|
-
const re = anchored ? new RegExp("^/" + body + "(/.*)?$") : new RegExp("(^|/)" + body + "(/.*)?$")
|
|
94
|
-
return { re, negate, dirOnly }
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const buildMatcher = (rules: Rule[]): IgnoreMatcher => (key, isDir) => {
|
|
98
|
-
let ignored = false
|
|
99
|
-
for (const r of rules) {
|
|
100
|
-
if (r.dirOnly && !isDir) continue // a `foo/` rule only matches dirs directly (files via prune)
|
|
101
|
-
if (r.re.test(key)) ignored = !r.negate
|
|
102
|
-
}
|
|
103
|
-
return ignored
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Compile a standalone glob list (same syntax as .lecodesignore lines, including `!` re-includes)
|
|
107
|
-
* into a file-path matcher — used for app.json's `resources.remote` patterns. */
|
|
108
|
-
export const buildGlobMatcher = (patterns: string[]): ((path: string) => boolean) => {
|
|
109
|
-
// Matching is over FILE paths — normalize `videos/` (dir-only, would match nothing here) to
|
|
110
|
-
// `videos`, whose `(/.*)?$` tail matches everything under the directory.
|
|
111
|
-
const rules = patterns.map((p) => compile(p.trim().replace(/\/+$/, ""))).filter((r): r is Rule => r !== null)
|
|
112
|
-
const match = buildMatcher(rules)
|
|
113
|
-
// Anchored rules compile to `^/…` — they expect the canonical leading-slash key form.
|
|
114
|
-
return (path) => match(path.startsWith("/") ? path : "/" + path, false)
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Read `<root>/.lecodesignore` (if any) and combine with the built-in defaults into a matcher. */
|
|
118
|
-
export const loadIgnore = (root: string): IgnoreMatcher => {
|
|
119
|
-
const lines = [...BUILTIN_PATTERNS]
|
|
120
|
-
const file = join(root, ".lecodesignore")
|
|
121
|
-
if (existsSync(file)) {
|
|
122
|
-
try { lines.push(...readFileSync(file, "utf8").split(/\r?\n/)) } catch { /* unreadable → defaults only */ }
|
|
123
|
-
}
|
|
124
|
-
const rules = lines.map(compile).filter((r): r is Rule => r !== null)
|
|
125
|
-
return buildMatcher(rules)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Write the template `.lecodesignore` if the project doesn't have one yet (used on clone). */
|
|
129
|
-
export const writeDefaultIgnore = (root: string) => {
|
|
130
|
-
const file = join(root, ".lecodesignore")
|
|
131
|
-
if (!existsSync(file)) writeFileSync(file, DEFAULT_IGNORE_FILE)
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/* `.gitignore` for the project's OWN git repo. `.lecodes/` is per-checkout state — regenerable IDE
|
|
135
|
-
* types (`lecodes update`) + `manifest.json`, this machine's push cursor (baseHeadId + per-file shas).
|
|
136
|
-
* Committing it makes collaborators fight over the cursor and ship stale types, so every scaffold
|
|
137
|
-
* path (init / clone / link) makes sure it's ignored. Render outputs mirror DEFAULT_IGNORE_FILE. */
|
|
138
|
-
export const DEFAULT_GITIGNORE = `# LeCodes per-checkout state: generated IDE types + this machine's sync cursor
|
|
139
|
-
# (.lecodes/manifest.json). Regenerate with \`lecodes update\` / re-attach with \`lecodes link\`.
|
|
140
|
-
.lecodes/
|
|
141
|
-
|
|
142
|
-
# Render / preview outputs and test failure frames
|
|
143
|
-
screenshot.png
|
|
144
|
-
shots/
|
|
145
|
-
tests/.artifacts/
|
|
146
|
-
tests/.shots/
|
|
147
|
-
|
|
148
|
-
node_modules/
|
|
149
|
-
|
|
150
|
-
dist/
|
|
151
|
-
`
|
|
152
|
-
|
|
153
|
-
/** Create `.gitignore` if missing; if it exists but doesn't ignore `.lecodes/`, append that rule.
|
|
154
|
-
* Returns what happened so callers can log it ("created" | "updated" | null). */
|
|
155
|
-
export const ensureGitignore = (root: string): "created" | "updated" | null => {
|
|
156
|
-
const file = join(root, ".gitignore")
|
|
157
|
-
if (!existsSync(file)) { writeFileSync(file, DEFAULT_GITIGNORE); return "created" }
|
|
158
|
-
const text = readFileSync(file, "utf8")
|
|
159
|
-
const covered = text.split(/\r?\n/).some(l => /^\/?\.lecodes\/?\s*$/.test(l.trim()))
|
|
160
|
-
if (covered) return null
|
|
161
|
-
const sep = text.length === 0 || text.endsWith("\n") ? "" : "\n"
|
|
162
|
-
writeFileSync(file, `${text}${sep}\n# LeCodes per-checkout state (generated types + sync cursor) — not source\n.lecodes/\n`)
|
|
163
|
-
return "updated"
|
|
164
|
-
}
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* `.lecodesignore` support — keeps scratch files (temp probes, render screenshots, logs) out of
|
|
6
|
+
* `status` / `push` so they never land in a checkpoint. Syntax is a practical subset of .gitignore:
|
|
7
|
+
* - blank lines and `# comments` are skipped
|
|
8
|
+
* - `!pattern` re-includes something a previous pattern ignored
|
|
9
|
+
* - a trailing `/` matches directories only
|
|
10
|
+
* - a leading `/` (or any embedded `/`) anchors the pattern to the project root
|
|
11
|
+
* - `*` matches within a path segment, `**` across segments, `?` a single char
|
|
12
|
+
* - a slash-free pattern (e.g. `_*`, `*.png`) matches a basename at any depth
|
|
13
|
+
*
|
|
14
|
+
* IMPORTANT: ignores apply to UNTRACKED files only. A file already in the project (recorded in the
|
|
15
|
+
* manifest) is always tracked, even if it matches a pattern — so ignoring `*.png` can never make an
|
|
16
|
+
* existing image asset silently disappear from the project. Pruning happens in `scanLocal`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type IgnoreMatcher = (key: string, isDir: boolean) => boolean
|
|
20
|
+
|
|
21
|
+
type Rule = { re: RegExp, negate: boolean, dirOnly: boolean }
|
|
22
|
+
|
|
23
|
+
// Always applied, even with no .lecodesignore — only patterns that are never real project assets, so
|
|
24
|
+
// they're safe as invisible defaults. Everything asset-shaped (*.png, *.json, …) is opt-in via the
|
|
25
|
+
// file written at clone time (see DEFAULT_IGNORE_FILE). `.artifacts/` is `lecodes test`'s
|
|
26
|
+
// failure-frame dump dir (tests/.artifacts/) — regenerated scratch, never a project asset.
|
|
27
|
+
// `dist/` is `lecodes app build desktop`'s default output (<root>/dist/<slug>, appDesktop.ts) —
|
|
28
|
+
// same class, but it must be ignored for a second, load-bearing reason: that folder holds a COPY of
|
|
29
|
+
// the host's runtime assets (lit.filamat, uberarchive.bin, …). Collected as project assets they get
|
|
30
|
+
// staged next to the bundle, and onFetchLocal searches the bundle's dir BEFORE the host's assets dir
|
|
31
|
+
// (main.cpp) — so the copy shadows the host's own. Harmless while every host was OpenGL and the
|
|
32
|
+
// bytes matched; fatal once a build output can come from the Vulkan host, where the shadowing
|
|
33
|
+
// lit.filamat is SPIR-V and Filament aborts with "not built for any of the OpenGL backend's
|
|
34
|
+
// supported shader languages".
|
|
35
|
+
// `.env` / `.env.*`: the project's LECODES_* config (projectEnv.ts) — it holds tokens and keystore
|
|
36
|
+
// passwords, so it never leaves the machine whatever .lecodesignore says.
|
|
37
|
+
// `.lightmap/` = a bake's float debug layers (`lecodes lightmap bake --debug-dir`): gigabytes the compile scan must never read
|
|
38
|
+
const BUILTIN_PATTERNS = ["node_modules/", ".git/", "_*", "*.log", ".DS_Store", ".artifacts/", "dist/", ".env", ".env.*", ".lightmap/"]
|
|
39
|
+
|
|
40
|
+
/** Template `.lecodesignore` written on clone — the explicit, editable "what we don't push" list. */
|
|
41
|
+
export const DEFAULT_IGNORE_FILE = `# Files matched here are NOT pushed to LeCodes.
|
|
42
|
+
# Only untracked files are affected — assets already in the project stay tracked
|
|
43
|
+
# even if they match a pattern. Built-in defaults (node_modules, _*, *.log, .artifacts)
|
|
44
|
+
# always apply.
|
|
45
|
+
|
|
46
|
+
# Render / preview outputs (the default \`lecodes render --png\` target)
|
|
47
|
+
screenshot.png
|
|
48
|
+
shots/
|
|
49
|
+
|
|
50
|
+
# Design-board comments live on the platform; design/comments/ is the local mirror of the
|
|
51
|
+
# open ones (\`lecodes design comments pull\`) — read by Claude, but pushing it back as
|
|
52
|
+
# project files would only duplicate the database.
|
|
53
|
+
design/comments/
|
|
54
|
+
|
|
55
|
+
# Image & other binary assets ARE pushed by default — they're real project resources.
|
|
56
|
+
# Uncomment a pattern below only if you want to keep some untracked ones out of pushes.
|
|
57
|
+
# *.png
|
|
58
|
+
|
|
59
|
+
# Local notes & dumps (uncomment to ignore)
|
|
60
|
+
# *.json
|
|
61
|
+
# notes.md
|
|
62
|
+
`
|
|
63
|
+
|
|
64
|
+
/** Translate a glob (one path segment's worth of `*`/`?`/`**`) into a regex fragment. */
|
|
65
|
+
const translate = (glob: string): string => {
|
|
66
|
+
let re = ""
|
|
67
|
+
for (let i = 0; i < glob.length; i++) {
|
|
68
|
+
const ch = glob[i]
|
|
69
|
+
if (ch === "*") {
|
|
70
|
+
if (glob[i + 1] === "*") { re += ".*"; i++; if (glob[i + 1] === "/") i++ } // `**/` spans dirs
|
|
71
|
+
else re += "[^/]*"
|
|
72
|
+
} else if (ch === "?") re += "[^/]"
|
|
73
|
+
else if (ch === "/") re += "/"
|
|
74
|
+
else if ("\\^$.|+()[]{}".includes(ch)) re += "\\" + ch
|
|
75
|
+
else re += ch
|
|
76
|
+
}
|
|
77
|
+
return re
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const compile = (raw: string): Rule | null => {
|
|
81
|
+
let p = raw.trim()
|
|
82
|
+
if (!p || p.startsWith("#")) return null
|
|
83
|
+
let negate = false
|
|
84
|
+
if (p.startsWith("!")) { negate = true; p = p.slice(1) }
|
|
85
|
+
let dirOnly = false
|
|
86
|
+
if (p.endsWith("/")) { dirOnly = true; p = p.slice(0, -1) }
|
|
87
|
+
const anchored = p.startsWith("/") || p.includes("/")
|
|
88
|
+
if (p.startsWith("/")) p = p.slice(1)
|
|
89
|
+
if (!p) return null
|
|
90
|
+
const body = translate(p)
|
|
91
|
+
// `(/.*)?$` lets a directory pattern also match files nested under it, matching gitignore behavior
|
|
92
|
+
// even without relying on directory pruning.
|
|
93
|
+
const re = anchored ? new RegExp("^/" + body + "(/.*)?$") : new RegExp("(^|/)" + body + "(/.*)?$")
|
|
94
|
+
return { re, negate, dirOnly }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const buildMatcher = (rules: Rule[]): IgnoreMatcher => (key, isDir) => {
|
|
98
|
+
let ignored = false
|
|
99
|
+
for (const r of rules) {
|
|
100
|
+
if (r.dirOnly && !isDir) continue // a `foo/` rule only matches dirs directly (files via prune)
|
|
101
|
+
if (r.re.test(key)) ignored = !r.negate
|
|
102
|
+
}
|
|
103
|
+
return ignored
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Compile a standalone glob list (same syntax as .lecodesignore lines, including `!` re-includes)
|
|
107
|
+
* into a file-path matcher — used for app.json's `resources.remote` patterns. */
|
|
108
|
+
export const buildGlobMatcher = (patterns: string[]): ((path: string) => boolean) => {
|
|
109
|
+
// Matching is over FILE paths — normalize `videos/` (dir-only, would match nothing here) to
|
|
110
|
+
// `videos`, whose `(/.*)?$` tail matches everything under the directory.
|
|
111
|
+
const rules = patterns.map((p) => compile(p.trim().replace(/\/+$/, ""))).filter((r): r is Rule => r !== null)
|
|
112
|
+
const match = buildMatcher(rules)
|
|
113
|
+
// Anchored rules compile to `^/…` — they expect the canonical leading-slash key form.
|
|
114
|
+
return (path) => match(path.startsWith("/") ? path : "/" + path, false)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Read `<root>/.lecodesignore` (if any) and combine with the built-in defaults into a matcher. */
|
|
118
|
+
export const loadIgnore = (root: string): IgnoreMatcher => {
|
|
119
|
+
const lines = [...BUILTIN_PATTERNS]
|
|
120
|
+
const file = join(root, ".lecodesignore")
|
|
121
|
+
if (existsSync(file)) {
|
|
122
|
+
try { lines.push(...readFileSync(file, "utf8").split(/\r?\n/)) } catch { /* unreadable → defaults only */ }
|
|
123
|
+
}
|
|
124
|
+
const rules = lines.map(compile).filter((r): r is Rule => r !== null)
|
|
125
|
+
return buildMatcher(rules)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Write the template `.lecodesignore` if the project doesn't have one yet (used on clone). */
|
|
129
|
+
export const writeDefaultIgnore = (root: string) => {
|
|
130
|
+
const file = join(root, ".lecodesignore")
|
|
131
|
+
if (!existsSync(file)) writeFileSync(file, DEFAULT_IGNORE_FILE)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* `.gitignore` for the project's OWN git repo. `.lecodes/` is per-checkout state — regenerable IDE
|
|
135
|
+
* types (`lecodes update`) + `manifest.json`, this machine's push cursor (baseHeadId + per-file shas).
|
|
136
|
+
* Committing it makes collaborators fight over the cursor and ship stale types, so every scaffold
|
|
137
|
+
* path (init / clone / link) makes sure it's ignored. Render outputs mirror DEFAULT_IGNORE_FILE. */
|
|
138
|
+
export const DEFAULT_GITIGNORE = `# LeCodes per-checkout state: generated IDE types + this machine's sync cursor
|
|
139
|
+
# (.lecodes/manifest.json). Regenerate with \`lecodes update\` / re-attach with \`lecodes link\`.
|
|
140
|
+
.lecodes/
|
|
141
|
+
|
|
142
|
+
# Render / preview outputs and test failure frames
|
|
143
|
+
screenshot.png
|
|
144
|
+
shots/
|
|
145
|
+
tests/.artifacts/
|
|
146
|
+
tests/.shots/
|
|
147
|
+
|
|
148
|
+
node_modules/
|
|
149
|
+
|
|
150
|
+
dist/
|
|
151
|
+
`
|
|
152
|
+
|
|
153
|
+
/** Create `.gitignore` if missing; if it exists but doesn't ignore `.lecodes/`, append that rule.
|
|
154
|
+
* Returns what happened so callers can log it ("created" | "updated" | null). */
|
|
155
|
+
export const ensureGitignore = (root: string): "created" | "updated" | null => {
|
|
156
|
+
const file = join(root, ".gitignore")
|
|
157
|
+
if (!existsSync(file)) { writeFileSync(file, DEFAULT_GITIGNORE); return "created" }
|
|
158
|
+
const text = readFileSync(file, "utf8")
|
|
159
|
+
const covered = text.split(/\r?\n/).some(l => /^\/?\.lecodes\/?\s*$/.test(l.trim()))
|
|
160
|
+
if (covered) return null
|
|
161
|
+
const sep = text.length === 0 || text.endsWith("\n") ? "" : "\n"
|
|
162
|
+
writeFileSync(file, `${text}${sep}\n# LeCodes per-checkout state (generated types + sync cursor) — not source\n.lecodes/\n`)
|
|
163
|
+
return "updated"
|
|
164
|
+
}
|