lecodes-cli 2.0.7 → 2.0.9
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 +1798 -1116
- package/package.json +21 -11
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/assets/creator-2d-BSJoqaE1.wasm +0 -0
- package/runtime/web/assets/{creator-2d-CYlV77UR.js → creator-2d-DG2jJnoQ.js} +1 -1
- package/runtime/web/assets/creator-ui-Bu2prHDl.wasm +0 -0
- package/runtime/web/assets/creator-ui-HDloYDcM.js +1 -0
- package/runtime/web/player.js +15 -15
- package/runtime/web-lite/assets/pager-DpGa2uaE.js +1 -0
- package/runtime/web-lite/assets/vlist-CwSYpLz-.js +1 -0
- package/runtime/web-lite/features.json +2 -2
- package/runtime/web-lite/lite.js +21 -10
- package/src/commands/app/android.ts +7 -7
- package/src/commands/app/index.ts +58 -84
- package/src/commands/app/shared.ts +53 -74
- package/src/commands/app/templates/ios.ts +75 -78
- package/src/commands/compile.ts +1 -1
- package/src/commands/init/templates.ts +7 -0
- package/src/commands/render.ts +17 -4
- package/src/commands/test.ts +10 -6
- package/src/compile/collect.ts +12 -3
- package/src/compile/headlessBundle.ts +10 -3
- package/src/declarations/lecodes-headless.d.ts +3 -0
- package/src/dev/backendWorker.ts +17 -6
- package/src/dev/localBackend.ts +8 -2
- package/src/dev/projectBackend.ts +31 -9
- package/src/hosts/desktopScript.ts +11 -1
- package/src/hosts/flowPick.ts +111 -0
- package/src/hosts/liteBrowser.mjs +5 -0
- package/src/hosts/liteRunner.ts +30 -0
- package/src/hosts/peerInstall.ts +5 -1
- package/src/hosts/peers.ts +2 -0
- package/runtime/web/assets/creator-2d-nG7rGEmq.wasm +0 -0
- package/runtime/web/assets/creator-ui-CbtoaRlz.js +0 -1
- package/runtime/web/assets/creator-ui-DZaoxIpX.wasm +0 -0
- package/runtime/web-lite/assets/pager-Db5BY7FB.js +0 -1
- package/runtime/web-lite/assets/vlist-DrV2Q7P5.js +0 -1
|
@@ -9,7 +9,7 @@ import { CliError, UsageError, bool, c, defineCommand, log, note, promptHidden,
|
|
|
9
9
|
import { upsertDotEnv } from "../../project/env"
|
|
10
10
|
import {
|
|
11
11
|
type AppConfig, SDK_VARIANTS, VERSION_RE, childEnv, cmpVersion, ensureShellIgnored, fetchLatestSdkVersion,
|
|
12
|
-
projectRoot, readJson, resolvePlugin, resolveSdkVersion, resolveVariant, runTool, slugify,
|
|
12
|
+
projectRoot, readJson, resolvePlugin, resolveSdkVersion, resolveVariant, runTool, sdkSatisfies, slugify,
|
|
13
13
|
writeJson, type LockEntry,
|
|
14
14
|
} from "./shared"
|
|
15
15
|
import { loadIconMaster, restampManifestIcon, writeAndroidIcons } from "./icon"
|
|
@@ -278,16 +278,16 @@ export const syncAndroid = async (root: string, config: AppConfig, prior: SyncAr
|
|
|
278
278
|
const register = android.register
|
|
279
279
|
if (!module || !register) throw new CliError(`Plugin "${id}": manifest android.module / android.register missing`)
|
|
280
280
|
|
|
281
|
-
const src = join(plugin.dir,
|
|
282
|
-
if (!existsSync(src)) throw new CliError(`Plugin "${id}":
|
|
281
|
+
const src = join(plugin.dir, "android")
|
|
282
|
+
if (!existsSync(src)) throw new CliError(`Plugin "${id}": android/ folder missing in ${plugin.dir}`)
|
|
283
283
|
const dest = join(pluginsDir, module)
|
|
284
284
|
rmrf(dest)
|
|
285
285
|
cpSync(src, dest, { recursive: true })
|
|
286
286
|
|
|
287
|
-
// Hard error, not a warning:
|
|
288
|
-
//
|
|
289
|
-
if (
|
|
290
|
-
throw new CliError(`Plugin "${id}"
|
|
287
|
+
// Hard error, not a warning: outside the plugin's `sdk` range the vendored sources reference
|
|
288
|
+
// an SDK API the linked release does not have, so the shell cannot compile anyway.
|
|
289
|
+
if (!sdkSatisfies(sdkVersion, plugin.manifest.sdk)) {
|
|
290
|
+
throw new CliError(`Plugin "${id}" is written for lecodes-android-sdk ${plugin.manifest.sdk} but the shell links ${sdkVersion} — run \`lecodes app update\` (or set app.json android.sdk).`)
|
|
291
291
|
}
|
|
292
292
|
for (const entry of android.gradlePlugins ?? []) {
|
|
293
293
|
const resolved = resolveGradlePlugin(id, entry)
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "./templates/ios"
|
|
15
15
|
import {
|
|
16
16
|
FIRST_PARTY_PLUGINS, SDK_VARIANTS, cmpVersion, ensureShellIgnored,
|
|
17
|
-
fetchLatestSdkVersion, latestPluginTag, projectRoot, readJson, resolvePlugin, resolveSdkVersion,
|
|
17
|
+
fetchLatestSdkVersion, isLocalPluginSpec, latestPluginTag, projectRoot, readJson, resolvePlugin, resolveSdkVersion, sdkSatisfies,
|
|
18
18
|
resolveVariant, runTool, slugify, writeJson, VERSION_RE, type AppConfig, type LockEntry,
|
|
19
19
|
} from "./shared"
|
|
20
20
|
import {
|
|
@@ -61,16 +61,18 @@ import { loadIconMaster, writeIosAppIcon } from "./icon"
|
|
|
61
61
|
|
|
62
62
|
const SDK_REPO = "letary/lecodes-ios-sdk"
|
|
63
63
|
|
|
64
|
-
// Oldest lecodes-ios-sdk a generated shell can use —
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
64
|
+
// Oldest lecodes-ios-sdk a generated shell can use — 2.0.7 = the first 2.x release: a SOURCE
|
|
65
|
+
// package over the engine, one tag per variant (`<version>` = full, `-3d`, `-2d`, `-core`),
|
|
66
|
+
// LeCodesShellViewController + LeCodesUpdater, which the generated files assume. The version a
|
|
67
|
+
// shell actually links is app.json `ios.sdk` (init pins the newest release, `app update` moves
|
|
68
|
+
// it); this floor is only the last-resort fallback when the network is unavailable at init. A 1.x
|
|
69
|
+
// shell is moved here by `lecodes app update` (its CLI-owned files are regenerated; its
|
|
70
|
+
// App/ViewController.swift keeps compiling: LeCodesViewController and LeCodesUpdater exist in 2.x).
|
|
71
|
+
const SDK_MIN_VERSION = "2.0.7"
|
|
69
72
|
|
|
70
73
|
// First lecodes-ios-sdk release with `LeCodesEngine.bootProjectUuid` — the shell's world
|
|
71
|
-
// identity (docs/push-plan.md)
|
|
72
|
-
|
|
73
|
-
const SDK_IDENTITY_VERSION = "1.6.0"
|
|
74
|
+
// identity (docs/push-plan.md); every 2.x release has it.
|
|
75
|
+
const SDK_IDENTITY_VERSION = "2.0.7"
|
|
74
76
|
|
|
75
77
|
/** The SDK version a synced shell currently links (Package.swift is CLI-owned, so its
|
|
76
78
|
* `exact:` pin is the truth) — the offline fallback, so a sync without network never
|
|
@@ -78,8 +80,8 @@ const SDK_IDENTITY_VERSION = "1.6.0"
|
|
|
78
80
|
const shellSdkVersion = (iosDir: string): string | null => {
|
|
79
81
|
const path = join(iosDir, "LeCodesRuntime", "Package.swift")
|
|
80
82
|
if (!existsSync(path)) return null
|
|
81
|
-
const m = /lecodes-ios-sdk\.git",\s*(?:exact|from):\s*"(
|
|
82
|
-
return m && VERSION_RE.test(m[1]) ? m[1] : null
|
|
83
|
+
const m = /lecodes-ios-sdk\.git",\s*(?:exact|from):\s*"(\d+\.\d+\.\d+)(?:-(?:core|2d|3d))?"/.exec(readFileSync(path, "utf8"))
|
|
84
|
+
return m && VERSION_RE.test(m[1]) ? m[1] : null // a LECODES_IOS_SDK_PATH shell names no release: null
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
const IOS_SDK_OPTS = { repo: SDK_REPO, minVersion: SDK_MIN_VERSION, configKey: "ios.sdk" }
|
|
@@ -173,7 +175,7 @@ const initIos = async (opts: IosInitOptions): Promise<void> => {
|
|
|
173
175
|
// "auto" resolves at sync time (the compiled bundle says which engines it uses); until the
|
|
174
176
|
// first sync the shell gets the slimmest product as a placeholder.
|
|
175
177
|
write("LeCodesRuntime/Package.swift", runtimePackageTemplate(sdkVersion, variant === "auto" ? "core" : variant as SdkVariant, []))
|
|
176
|
-
write("LeCodesRuntime/Sources/LeCodesRuntime/LeCodesRuntime.swift", runtimeSwiftTemplate([]))
|
|
178
|
+
write("LeCodesRuntime/Sources/LeCodesRuntime/LeCodesRuntime.swift", runtimeSwiftTemplate([], undefined, variant === "auto" ? "core" : variant as SdkVariant))
|
|
177
179
|
|
|
178
180
|
log("")
|
|
179
181
|
log(`${c.green("sdk")} lecodes-ios-sdk ${sdkVersion}${sdk === "latest" ? c.dim(" (ios.sdk: latest — re-resolved on every sync)") : c.dim(" (pinned in app.json ios.sdk — `lecodes app update` moves it)")}`)
|
|
@@ -210,46 +212,22 @@ const init = defineCommand({
|
|
|
210
212
|
|
|
211
213
|
/* --------------------------------------- sync --------------------------------------- */
|
|
212
214
|
|
|
213
|
-
/**
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
if (
|
|
220
|
-
note(`Using local resources bundle ${override} (LECODES_SDK_RESOURCES)`)
|
|
221
|
-
return resolve(override)
|
|
222
|
-
}
|
|
223
|
-
const dir = join(homedir(), ".lecodes", "sdk-resources", sdkVersion)
|
|
224
|
-
const bundle = join(dir, "LeCodesSDKResources.bundle")
|
|
225
|
-
if (existsSync(bundle)) return bundle
|
|
226
|
-
const url = `https://github.com/${SDK_REPO}/releases/download/${sdkVersion}/LeCodesSDKResources.bundle.zip`
|
|
227
|
-
note(`Downloading LeCodesSDKResources.bundle (SDK ${sdkVersion})…`)
|
|
228
|
-
const res = await fetch(url)
|
|
229
|
-
if (!res.ok) throw new CliError(`Resources bundle download failed (HTTP ${res.status}) — ${url}`)
|
|
230
|
-
mkdirSync(dir, { recursive: true })
|
|
231
|
-
const zip = join(dir, "LeCodesSDKResources.bundle.zip")
|
|
232
|
-
writeFileSync(zip, new Uint8Array(await res.arrayBuffer()))
|
|
233
|
-
execFileSync("ditto", ["-x", "-k", zip, dir]) // release zips are ditto archives (--keepParent)
|
|
234
|
-
rmSync(zip)
|
|
235
|
-
if (!existsSync(bundle)) throw new CliError(`No LeCodesSDKResources.bundle inside the release asset ${url}`)
|
|
236
|
-
return bundle
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/** Resolve app.json "update" to the URL the shell's native updater checks. `true` means the
|
|
240
|
-
* project's live bundle on the platform (`/code/<uuid>.js` — needs a server-backed project);
|
|
241
|
-
* strings / { url } pass through. Warns on http: ATS blocks it on device. */
|
|
242
|
-
const resolveUpdateUrl = (update: AppConfig["update"], root: string): string | undefined => {
|
|
243
|
-
if (!update) return undefined
|
|
215
|
+
/** Resolve app.json "update" to the URL the shell's native updater checks (LeCodesUpdater):
|
|
216
|
+
* absent or `true` = the project's live bundle on the platform — `/code/<uuid>.js?sdk=<version>`,
|
|
217
|
+
* the launcher's own URL, so a pushed project updates its shells with no server of its own (a
|
|
218
|
+
* local-only project has none: unconfigured); `false` = no OTA; a string / { url } = custom
|
|
219
|
+
* hosting, passed through. Warns on http: ATS blocks it on device. */
|
|
220
|
+
const resolveUpdateUrl = (update: AppConfig["update"], root: string, sdkVersion: string): string | undefined => {
|
|
221
|
+
if (update === false) return undefined
|
|
244
222
|
let url = typeof update === "string" ? update : typeof update === "object" ? update.url : undefined
|
|
245
223
|
if (!url) {
|
|
246
224
|
let manifest: Manifest | null = null
|
|
247
225
|
try { manifest = readManifest(root) } catch { /* not a project dir */ }
|
|
248
226
|
if (!manifest) {
|
|
249
|
-
warn(`app.json "update": true needs a server-backed project — use "update": {"url": "https://…/app.js"} for custom hosting. OTA left unconfigured.`)
|
|
227
|
+
if (update) warn(`app.json "update": true needs a server-backed project — use "update": {"url": "https://…/app.js"} for custom hosting. OTA left unconfigured.`)
|
|
250
228
|
return undefined
|
|
251
229
|
}
|
|
252
|
-
url = `${requireApiUrl(loadConfig())}/code/${manifest.uuid}.js`
|
|
230
|
+
url = `${requireApiUrl(loadConfig())}/code/${manifest.uuid}.js?sdk=${sdkVersion}`
|
|
253
231
|
}
|
|
254
232
|
if (!url.startsWith("https://")) warn(`update URL is not https — iOS blocks it on device (ATS): ${url}`)
|
|
255
233
|
return url
|
|
@@ -307,16 +285,17 @@ const syncIos = async (root: string, config: AppConfig): Promise<SyncArtifacts>
|
|
|
307
285
|
const register = ios.register
|
|
308
286
|
if (!module || !register) throw new CliError(`Plugin "${id}": manifest ios.module / ios.register missing`)
|
|
309
287
|
|
|
310
|
-
const src = join(plugin.dir,
|
|
311
|
-
if (!existsSync(src)) throw new CliError(`Plugin "${id}":
|
|
288
|
+
const src = join(plugin.dir, "ios")
|
|
289
|
+
if (!existsSync(src)) throw new CliError(`Plugin "${id}": ios/ folder missing in ${plugin.dir}`)
|
|
312
290
|
const dest = join(sourcesDir, module)
|
|
313
291
|
rmSync(dest, { recursive: true, force: true })
|
|
314
292
|
cpSync(src, dest, { recursive: true })
|
|
315
293
|
|
|
316
|
-
// Hard error, not a warning:
|
|
317
|
-
// does not
|
|
318
|
-
|
|
319
|
-
|
|
294
|
+
// Hard error, not a warning: outside the plugin's `sdk` range the vendored sources reference
|
|
295
|
+
// an SDK API the linked release does not have (or another contract major), so the shell
|
|
296
|
+
// cannot compile anyway.
|
|
297
|
+
if (!sdkSatisfies(sdkVersion, plugin.manifest.sdk)) {
|
|
298
|
+
throw new CliError(`Plugin "${id}" is written for lecodes-ios-sdk ${plugin.manifest.sdk} but the shell links ${sdkVersion} — run \`lecodes app update\` (or set app.json ios.sdk).`)
|
|
320
299
|
}
|
|
321
300
|
for (const [key, value] of Object.entries(ios.infoPlist ?? {})) {
|
|
322
301
|
(infoValues[key] ??= []).push(value)
|
|
@@ -363,21 +342,11 @@ const syncIos = async (root: string, config: AppConfig): Promise<SyncArtifacts>
|
|
|
363
342
|
const sdkWhy = sdkPin === "latest" ? "latest" : config.ios.sdk ? "pinned by ios.sdk" : "from the shell — set ios.sdk or run `lecodes app update`"
|
|
364
343
|
log(`${c.green("sdk")} lecodes-ios-sdk ${sdkVersion}${c.dim(` (${sdkWhy})`)}`)
|
|
365
344
|
|
|
366
|
-
// The filament resources ride along with any CreatorGL-bearing variant. Its files also back
|
|
367
|
-
// fetchLocal for SDK built-ins (lit.filamat & co), so the embed step below can skip them.
|
|
368
|
-
const sdkProvided = new Set<string>()
|
|
369
|
-
if (variant === "3d" || variant === "full") {
|
|
370
|
-
const sdkResources = await ensureSdkResourcesBundle(sdkVersion)
|
|
371
|
-
cpSync(sdkResources, join(resourcesDir, "LeCodesSDKResources.bundle"), { recursive: true })
|
|
372
|
-
for (const f of readdirSync(sdkResources)) if (f !== "Info.plist") sdkProvided.add(f)
|
|
373
|
-
log(`${c.green("resources")} LeCodesSDKResources.bundle (filament) → ios/App/Resources/`)
|
|
374
|
-
}
|
|
375
|
-
|
|
376
345
|
// Embed exactly what the bundle resolves locally: the `// preload:` header names every
|
|
377
346
|
// fetchLocal resource (vendored fonts included). resources.remote entries stayed URLs, and
|
|
378
347
|
// an asset the code never references isn't shipped. Copies are flat by lookup name — the
|
|
379
|
-
//
|
|
380
|
-
//
|
|
348
|
+
// engine's fetchLocal reads the app bundle by file name; the SDK's own blobs (the shader
|
|
349
|
+
// archives, the IBL) ride inside the SDK package and are never named here.
|
|
381
350
|
const preload = /^\/\/ preload: (.*)$/m.exec(bundleJs)?.[1].split(";").map((s) => s.trim()).filter(Boolean) ?? []
|
|
382
351
|
// engine-synthesized, not project files: the ubershader archive, the capture flag and the
|
|
383
352
|
// engine's own materials (`material:<name>` — they ride the SDK)
|
|
@@ -394,7 +363,7 @@ const syncIos = async (root: string, config: AppConfig): Promise<SyncArtifacts>
|
|
|
394
363
|
if (IMPLICIT_PRELOADS.has(name)) continue
|
|
395
364
|
const candidates = byName.get(name) ?? []
|
|
396
365
|
if (candidates.length === 0) {
|
|
397
|
-
|
|
366
|
+
warn(`bundle needs local asset "${name}" but no project file provides it — it won't load on device`)
|
|
398
367
|
continue
|
|
399
368
|
}
|
|
400
369
|
if (candidates.length > 1) warn(`asset name collision: ${name} (${candidates.map((r) => r.path).join(", ")}) — embedding ${candidates[0].path}`)
|
|
@@ -421,21 +390,18 @@ const syncIos = async (root: string, config: AppConfig): Promise<SyncArtifacts>
|
|
|
421
390
|
|
|
422
391
|
// ---- regenerate the CLI-owned files ----
|
|
423
392
|
writeFileSync(join(iosDir, "README.md"), IOS_README)
|
|
424
|
-
|
|
425
|
-
|
|
393
|
+
const sdkPath = process.env.LECODES_IOS_SDK_PATH
|
|
394
|
+
if (sdkPath) note(`Using local SDK package ${sdkPath} (LECODES_IOS_SDK_PATH) — the shell links it by path, not the ${sdkVersion} release`)
|
|
395
|
+
writeFileSync(join(iosDir, "LeCodesRuntime", "Package.swift"), runtimePackageTemplate(sdkVersion, variant, runtimePlugins, sdkPath ? resolve(sdkPath) : undefined))
|
|
396
|
+
writeFileSync(join(sourcesDir, "LeCodesRuntime", "LeCodesRuntime.swift"), runtimeSwiftTemplate(runtimePlugins, shellUuid, variant))
|
|
426
397
|
|
|
427
398
|
const mergedKeys: Record<string, string> = {}
|
|
428
399
|
for (const [key, values] of Object.entries(infoValues)) mergedKeys[key] = [...new Set(values)].join(" ")
|
|
429
|
-
const updateUrl = resolveUpdateUrl(config.update, root)
|
|
400
|
+
const updateUrl = resolveUpdateUrl(config.update, root, sdkVersion)
|
|
430
401
|
writeFileSync(join(iosDir, "App", "Info.plist"), infoPlistTemplate(mergedKeys, updateUrl))
|
|
431
|
-
if (updateUrl) log(`${c.green("update")} OTA source ${updateUrl} — checked natively at launch, new code applies next launch`)
|
|
432
|
-
else if (
|
|
433
|
-
|
|
434
|
-
// else ever mentions it. One dim line, only while unconfigured.
|
|
435
|
-
let manifest: Manifest | null = null
|
|
436
|
-
try { manifest = readManifest(root) } catch { /* not a project dir */ }
|
|
437
|
-
if (manifest) note(`tip: add "update": true to app.json for over-the-air JS updates — ship with \`lecodes push --publish\`, no store release`)
|
|
438
|
-
}
|
|
402
|
+
if (updateUrl) log(`${c.green("update")} OTA source ${updateUrl} — checked natively at launch, new code applies next launch${config.update === undefined ? c.dim(' ("update": false in app.json turns it off)') : ""}`)
|
|
403
|
+
else if (config.update === false) log(`${c.green("update")} off (app.json "update": false)`)
|
|
404
|
+
else note(`no OTA: a local-only project has no bundle on the platform — push it, or set "update": {"url": "https://…/app.js"} for custom hosting`)
|
|
439
405
|
|
|
440
406
|
if (pluginSpecs.length > 0 || existsSync(lockPath)) writeJson(lockPath, { plugins: newLock })
|
|
441
407
|
|
|
@@ -495,7 +461,7 @@ const sync = defineCommand({
|
|
|
495
461
|
name: "sync",
|
|
496
462
|
summary: "Make every existing shell match the project: bundle + assets + SDK variant + plugins + CLI-owned files",
|
|
497
463
|
usage: "[ios|android]",
|
|
498
|
-
description: "Compiles the bundle and embeds the assets it references (ios/App/Resources/ resp. the android assets/; offline by default, also for server projects — app.json \"resources\": {\"remote\": [globs]} keeps heavy assets streaming), picks the SDK variant from the bundle's engine use, vendors the app.json plugins (pinned in plugins.lock.json) and regenerates the CLI-owned shell files. User files (ios/App/*.swift, android/app/) are never touched.\n\
|
|
464
|
+
description: "Compiles the bundle and embeds the assets it references (ios/App/Resources/ resp. the android assets/; offline by default, also for server projects — app.json \"resources\": {\"remote\": [globs]} keeps heavy assets streaming), picks the SDK variant from the bundle's engine use, vendors the app.json plugins (pinned in plugins.lock.json) and regenerates the CLI-owned shell files. User files (ios/App/*.swift, android/app/) are never touched.\n\nNative OTA JS updates at launch (iOS): on by default for a pushed project (its bundle on the platform, the launcher's own URL), app.json \"update\": {\"url\": …} for custom hosting, \"update\": false turns it off. app.json \"icon\" (a square ≥1024px image) renders the app icons every sync (\"iconBackground\": the adaptive / flatten backdrop). No platform = every shell that exists; a dual-shell project compiles once.",
|
|
499
465
|
examples: ["lecodes app sync", "lecodes app sync android"],
|
|
500
466
|
run: ({ args }) => runSync(args[0]),
|
|
501
467
|
})
|
|
@@ -523,9 +489,13 @@ const pluginList = (): void => {
|
|
|
523
489
|
}
|
|
524
490
|
|
|
525
491
|
const pluginAdd = (id: string | undefined): void => {
|
|
526
|
-
if (!id) throw new UsageError("Which plugin? `lecodes app plugin add <id | github:owner/repo[@tag]
|
|
492
|
+
if (!id) throw new UsageError("Which plugin? `lecodes app plugin add <id | github:owner/repo[@tag] | ./plugins/<id>>`")
|
|
527
493
|
const { configPath, config } = pluginConfig()
|
|
528
|
-
if (id
|
|
494
|
+
if (isLocalPluginSpec(id)) {
|
|
495
|
+
const bare = basename(id.replace(/\/+$/, ""))
|
|
496
|
+
config.plugins[bare] = id
|
|
497
|
+
log(`${c.green("added")} ${bare} → ${id} ${c.dim("(a plugin of this project: its folder, as it is)")}`)
|
|
498
|
+
} else if (id.startsWith("github:")) {
|
|
529
499
|
const bare = id.slice("github:".length).split("@")[0].split("/").pop()!
|
|
530
500
|
config.plugins[bare] = id
|
|
531
501
|
log(`${c.green("added")} ${bare} → ${id} ${c.dim("(third-party native code — review it before shipping)")}`)
|
|
@@ -552,14 +522,14 @@ const pluginRemove = (id: string | undefined): void => {
|
|
|
552
522
|
const plugin = defineCommand({
|
|
553
523
|
name: "plugin",
|
|
554
524
|
summary: "Manage native plugins in app.json: list | add | remove (then run `lecodes app sync`)",
|
|
555
|
-
description: `Bare \`lecodes app plugin\` lists them with their plugins.lock.json pins. \`add\` takes a first-party id (${Object.keys(FIRST_PARTY_PLUGINS).join(", ")})
|
|
525
|
+
description: `Bare \`lecodes app plugin\` lists them with their plugins.lock.json pins. \`add\` takes a first-party id (${Object.keys(FIRST_PARTY_PLUGINS).join(", ")}), github:owner/repo[@tag] for a third-party plugin (native code — review it before shipping), or the folder of a plugin of this project (./plugins/<id> — written with \`lecodes plugin gen\`, nothing to publish). Nothing reaches the shell until the next sync.`,
|
|
556
526
|
commands: [
|
|
557
527
|
defineCommand({ name: "list", summary: "The plugins in app.json and their lock pins", run: () => pluginList() }),
|
|
558
528
|
defineCommand({
|
|
559
529
|
name: "add",
|
|
560
530
|
summary: "Add a plugin: a known id, or github:owner/repo[@tag]",
|
|
561
531
|
usage: "<id | github:owner/repo[@tag]>",
|
|
562
|
-
examples: ["lecodes app plugin add camera", "lecodes app plugin add github:acme/lecodes-plugin-thing@v1.2.0"],
|
|
532
|
+
examples: ["lecodes app plugin add camera", "lecodes app plugin add github:acme/lecodes-plugin-thing@v1.2.0", "lecodes app plugin add ./plugins/acme-dial"],
|
|
563
533
|
run: ({ args }) => pluginAdd(args[0]),
|
|
564
534
|
}),
|
|
565
535
|
defineCommand({ name: "remove", summary: "Remove a plugin entry", usage: "<id>", run: ({ args }) => pluginRemove(args[0]) }),
|
|
@@ -651,6 +621,11 @@ export const runAppUpdate = async (opts: AppUpdateOptions = {}): Promise<void> =
|
|
|
651
621
|
const kept: Record<string, LockEntry> = {}
|
|
652
622
|
for (const [id, spec] of specs) {
|
|
653
623
|
const entry = lock[id]
|
|
624
|
+
if (isLocalPluginSpec(spec)) {
|
|
625
|
+
log(`${c.green("plugin")} ${id} ${c.dim(`a folder of the project (${spec}) — synced as it is`)}`)
|
|
626
|
+
if (entry) kept[id] = entry
|
|
627
|
+
continue
|
|
628
|
+
}
|
|
654
629
|
if (spec.startsWith("github:") && spec.includes("@")) {
|
|
655
630
|
log(`${c.green("plugin")} ${id} ${c.dim(`pinned by app.json (${spec}) — not updated`)}`)
|
|
656
631
|
if (entry) kept[id] = entry
|
|
@@ -727,10 +702,9 @@ const doctorIosChecks = async (root: string, config: AppConfig, check: Check) =>
|
|
|
727
702
|
if (existsSync(bundle) && validVariant) {
|
|
728
703
|
const { variant } = resolveVariant(requestedVariant, readFileSync(bundle, "utf8"))
|
|
729
704
|
const pkg = readFileSync(join(root, "ios", "LeCodesRuntime", "Package.swift"), "utf8")
|
|
730
|
-
const
|
|
731
|
-
const
|
|
732
|
-
check(
|
|
733
|
-
`SDK variant ${variant}${needsResources ? " (+ filament resources)" : ""}`, "run `lecodes app sync`")
|
|
705
|
+
const tag = /lecodes-ios-sdk\.git",\s*exact:\s*"([^"]+)"/.exec(pkg)?.[1] ?? ""
|
|
706
|
+
const tagVariant = (/-(core|2d|3d)$/.exec(tag)?.[1] ?? "full") as SdkVariant
|
|
707
|
+
check(tagVariant === variant, `SDK variant ${variant}`, "run `lecodes app sync`")
|
|
734
708
|
}
|
|
735
709
|
|
|
736
710
|
// SDK: the shell must link the version app.json asks for, and (soft, needs network) that
|
|
@@ -5,6 +5,13 @@ import { dirname, join, resolve } from "node:path"
|
|
|
5
5
|
import { findProjectRoot } from "../../project/manifest"
|
|
6
6
|
import { DEFAULT_IGNORE_FILE } from "../../project/ignore"
|
|
7
7
|
import { CliError, c, log, note, warn } from "../../cli"
|
|
8
|
+
import { pascal, readManifest as readPluginManifest, type IosPackage, type PluginManifest as PluginManifest2 } from "../../plugin/manifest"
|
|
9
|
+
|
|
10
|
+
/** The 2.x manifest (plugin/manifest.ts), plus the desktop branch the desktop flow reads: a
|
|
11
|
+
* PREBUILT shared library per OS/arch behind the C ABI in hosts/desktop/plugin-sdk — another
|
|
12
|
+
* system, with a manifest of its own (the 2.x reader refuses the key; desktop plugins are
|
|
13
|
+
* resolved from the project's plugins/ folder). */
|
|
14
|
+
export type PluginManifest = PluginManifest2 & { desktop?: { library?: Record<string, string> } | null }
|
|
8
15
|
|
|
9
16
|
/* Shared plumbing for the `lecodes app` platform arms (index.ts = iOS + the command tree,
|
|
10
17
|
* android.ts = Android, desktop.ts = the portable desktop build) and for every other command that
|
|
@@ -12,7 +19,9 @@ import { CliError, c, log, note, warn } from "../../cli"
|
|
|
12
19
|
* a repo parameter (lecodes-ios-sdk vs lecodes-android-sdk) and resolvePlugin a platform one. */
|
|
13
20
|
|
|
14
21
|
/** First-party plugin registry: id → GitHub repo holding plugins/<id>/. Third-party plugins
|
|
15
|
-
* use a `github:owner/repo[@tag]` spec in app.json instead (manifest at the repo root)
|
|
22
|
+
* use a `github:owner/repo[@tag]` spec in app.json instead (manifest at the repo root); a plugin
|
|
23
|
+
* of the project itself a PATH (`./plugins/<id>` — the folder with its lecodes-plugin.json,
|
|
24
|
+
* generated by `lecodes plugin gen`; nothing to publish). */
|
|
16
25
|
export const FIRST_PARTY_PLUGINS: Record<string, string> = {
|
|
17
26
|
"camera": "letary/lecodes-plugins",
|
|
18
27
|
"qr-scanner": "letary/lecodes-plugins",
|
|
@@ -227,74 +236,32 @@ export const desktopDefaultViewport = (root: string): { width: number, height: n
|
|
|
227
236
|
}
|
|
228
237
|
}
|
|
229
238
|
|
|
230
|
-
/** A SwiftPM dependency a plugin's iOS target links (manifest ios.packages). */
|
|
231
|
-
export interface IosPackage {
|
|
232
|
-
url: string
|
|
233
|
-
/** Pinned release — the normal case (a plugin's sources compile against one API). */
|
|
234
|
-
exact?: string
|
|
235
|
-
/** Minimum release, SwiftPM picks the newest compatible major. Exclusive with `exact`. */
|
|
236
|
-
from?: string
|
|
237
|
-
/** Library products of that package the plugin target imports. */
|
|
238
|
-
products: string[]
|
|
239
|
-
}
|
|
240
|
-
|
|
241
239
|
/** SwiftPM's package identity for a url: the last path segment without `.git`. */
|
|
242
240
|
export const swiftPackageName = (url: string): string =>
|
|
243
241
|
(url.replace(/\/+$/, "").split("/").pop() ?? url).replace(/\.git$/, "")
|
|
244
242
|
|
|
245
|
-
export
|
|
246
|
-
id: string
|
|
247
|
-
version?: string
|
|
248
|
-
provides?: { views?: string[], services?: string[] }
|
|
249
|
-
ios?: {
|
|
250
|
-
sources?: string, module?: string, register?: string, minSdk?: string,
|
|
251
|
-
infoPlist?: Record<string, string>,
|
|
252
|
-
/** Entitlements the plugin's capability needs (plist-typed JSON values, arrays allowed —
|
|
253
|
-
* e.g. push's `{"aps-environment": "development"}`). Merged into App/App.entitlements
|
|
254
|
-
* by sync; the relay signs sends for Letary-published bundle ids with the shared team
|
|
255
|
-
* key, and the register route fail-fasts for anything outside that namespace. */
|
|
256
|
-
entitlements?: Record<string, unknown>,
|
|
257
|
-
/** Swift packages the vendored target links (e.g. the map plugin's maplibre-native). Each
|
|
258
|
-
* becomes a `.package(url:…)` in the generated LeCodesRuntime/Package.swift (exact or
|
|
259
|
-
* from — one of the two) and its `products` become `.product(name:package:)` deps of the
|
|
260
|
-
* plugin target. The package name is the URL's last path segment minus `.git`, as SwiftPM
|
|
261
|
-
* derives it. Two plugins naming the same url must agree on the version. */
|
|
262
|
-
packages?: IosPackage[],
|
|
263
|
-
} | null
|
|
264
|
-
android?: {
|
|
265
|
-
/** Dir vendored into the shell (default "android") — a Gradle-module-shaped tree minus
|
|
266
|
-
* build.gradle (sync generates that): src/main/AndroidManifest.xml carries the plugin's
|
|
267
|
-
* permissions/services (AGP's manifest merger is the infoPlist analog), libs/*.aar become
|
|
268
|
-
* local file deps, consumer-rules.pro becomes the module's consumer ProGuard rules. */
|
|
269
|
-
sources?: string,
|
|
270
|
-
/** Gradle module name (kebab-case) → :plugins:<module> in the shell. */
|
|
271
|
-
module?: string,
|
|
272
|
-
/** Fully-qualified Kotlin object with `fun register(engine: LecodesEngine, context: Context)`;
|
|
273
|
-
* its package doubles as the generated module's AGP namespace. */
|
|
274
|
-
register?: string,
|
|
275
|
-
/** Minimum lecodes-android-sdk release (NOT Android's minSdkVersion — the OS floor comes
|
|
276
|
-
* from the SDK itself). Sync hard-errors below it, like ios.minSdk. */
|
|
277
|
-
minSdk?: string,
|
|
278
|
-
/** Maven coordinates for the generated build.gradle. A "platform:" prefix maps to
|
|
279
|
-
* implementation(platform(…)) — BoMs; versionless coords are only valid under one. */
|
|
280
|
-
dependencies?: string[],
|
|
281
|
-
/** App-level Gradle plugin ids the shell must apply (e.g. com.google.gms.google-services).
|
|
282
|
-
* Bare ids resolve through the CLI's known-version table; "id:version" pins explicitly. */
|
|
283
|
-
gradlePlugins?: string[],
|
|
284
|
-
} | null
|
|
285
|
-
/** Desktop host (hosts/desktop): a PREBUILT shared library per OS/arch behind the C ABI in
|
|
286
|
-
* `hosts/desktop/plugin-sdk/lecodes_plugin.h` — no host rebuild, no source vendoring.
|
|
287
|
-
* `library` keys are `${process.platform}-${process.arch}` ("win32-x64", "linux-x64",
|
|
288
|
-
* "linux-arm64"), values plugin-dir-relative paths. `lecodes desktop run` / `render --desktop`
|
|
289
|
-
* forward the library's dir to the host (CREATOR_PLUGIN_DIRS); `app build desktop` stages the
|
|
290
|
-
* file into `<build>/plugins/`. A plugin without a build for the current target is skipped. */
|
|
291
|
-
desktop?: {
|
|
292
|
-
library?: Record<string, string>,
|
|
293
|
-
} | null
|
|
294
|
-
}
|
|
243
|
+
export type { IosPackage }
|
|
295
244
|
|
|
296
245
|
export interface LockEntry { repo: string, tag: string, commit: string, version: string }
|
|
297
246
|
|
|
247
|
+
/** A plugin spec that names a folder of the project (`./plugins/x`, `../x`, `/abs`, `file:x`). */
|
|
248
|
+
export const isLocalPluginSpec = (spec: string): boolean =>
|
|
249
|
+
spec.startsWith("./") || spec.startsWith("../") || spec.startsWith("/") || spec.startsWith("file:")
|
|
250
|
+
|
|
251
|
+
/** Does the SDK `version` a shell links satisfy a plugin manifest's `sdk` range (`^2.0.0`: the
|
|
252
|
+
* same major, not older; `~2.1.0`: the same major.minor, not older; `2.0.0`: exactly)? */
|
|
253
|
+
export const sdkSatisfies = (version: string, range: string): boolean => {
|
|
254
|
+
const op = range.startsWith("^") ? "^" : range.startsWith("~") ? "~" : ""
|
|
255
|
+
const want = range.slice(op.length)
|
|
256
|
+
if (!VERSION_RE.test(version) || !VERSION_RE.test(want)) return false
|
|
257
|
+
const [vMaj, vMin] = version.split(".").map(Number)
|
|
258
|
+
const [wMaj, wMin] = want.split(".").map(Number)
|
|
259
|
+
if (op === "") return version === want
|
|
260
|
+
if (vMaj !== wMaj) return false
|
|
261
|
+
if (op === "~" && vMin !== wMin) return false
|
|
262
|
+
return cmpVersion(version, want) >= 0
|
|
263
|
+
}
|
|
264
|
+
|
|
298
265
|
export const readJson = <T>(path: string): T | null => {
|
|
299
266
|
if (!existsSync(path)) return null
|
|
300
267
|
// BOM-tolerant: Notepad and PowerShell write UTF-8 with a BOM, and app.json is hand-edited.
|
|
@@ -484,6 +451,19 @@ const PLUGIN_PLATFORM_LABEL: Record<PluginPlatform, string> = { ios: "iOS", andr
|
|
|
484
451
|
export function resolvePlugin(id: string, spec: string, lock: LockEntry | undefined, platform: PluginPlatform): ResolvedPlugin
|
|
485
452
|
export function resolvePlugin(id: string, spec: string, lock: LockEntry | undefined, platform: PluginPlatform, opts: { optional: true }): ResolvedPlugin | null
|
|
486
453
|
export function resolvePlugin(id: string, spec: string, lock: LockEntry | undefined, platform: PluginPlatform, opts?: { optional?: boolean }): ResolvedPlugin | null {
|
|
454
|
+
// A plugin of the project itself: its folder, as it is — no clone, no tag, nothing to publish.
|
|
455
|
+
if (isLocalPluginSpec(spec)) {
|
|
456
|
+
const dir = resolve(projectRoot(), spec.startsWith("file:") ? spec.slice("file:".length) : spec)
|
|
457
|
+
if (!existsSync(dir)) throw new CliError(`Plugin "${id}": ${spec} is not a folder (${dir})`)
|
|
458
|
+
const manifest: PluginManifest = readPluginManifest(dir, id)
|
|
459
|
+
fillManifestDefaults(manifest, id)
|
|
460
|
+
if (!manifest[platform]) {
|
|
461
|
+
if (opts?.optional) return null
|
|
462
|
+
throw new CliError(`Plugin "${id}" does not support ${PLUGIN_PLATFORM_LABEL[platform]} (manifest "${platform}" is null)`)
|
|
463
|
+
}
|
|
464
|
+
return { id, dir, manifest, lock: { repo: "local", tag: "local", commit: "local", version: manifest.version } }
|
|
465
|
+
}
|
|
466
|
+
|
|
487
467
|
let repo: string
|
|
488
468
|
let subdir: string
|
|
489
469
|
let wantTag: string | null = null
|
|
@@ -503,28 +483,27 @@ export function resolvePlugin(id: string, spec: string, lock: LockEntry | undefi
|
|
|
503
483
|
|
|
504
484
|
const checkout = ensureRepo(repo, wantTag, lock?.repo === repo ? lock : undefined)
|
|
505
485
|
const dir = subdir ? join(checkout.dir, subdir) : checkout.dir
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
486
|
+
if (!existsSync(join(dir, "lecodes-plugin.json"))) throw new CliError(`Plugin "${id}": no lecodes-plugin.json in ${dir}`)
|
|
487
|
+
const manifest: PluginManifest = readPluginManifest(dir, id) // the 2.x manifest: every problem of it at once
|
|
488
|
+
fillManifestDefaults(manifest, id)
|
|
509
489
|
if (!manifest[platform]) {
|
|
510
490
|
if (opts?.optional) return null
|
|
511
491
|
throw new CliError(`Plugin "${id}" does not support ${PLUGIN_PLATFORM_LABEL[platform]} (manifest "${platform}" is null)`)
|
|
512
492
|
}
|
|
513
493
|
|
|
514
|
-
// Namespacing: third-party channel names must be vendor-prefixed ("acme.thing").
|
|
515
|
-
if (spec.startsWith("github:")) {
|
|
516
|
-
const names = [...(manifest.provides?.views ?? []), ...(manifest.provides?.services ?? [])]
|
|
517
|
-
for (const name of names) {
|
|
518
|
-
if (!name.includes(".")) warn(`Plugin "${id}" provides bare channel name "${name}" — third-party names should be vendor-prefixed (e.g. "acme.${name}").`)
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
|
|
522
494
|
return {
|
|
523
495
|
id, dir, manifest,
|
|
524
|
-
lock: { repo, tag: checkout.tag, commit: checkout.commit, version: manifest.version
|
|
496
|
+
lock: { repo, tag: checkout.tag, commit: checkout.commit, version: manifest.version },
|
|
525
497
|
}
|
|
526
498
|
}
|
|
527
499
|
|
|
500
|
+
/** The manifest's defaults (plugin/manifest.ts documents them): the iOS module `LeCodes<Id>`,
|
|
501
|
+
* the Android module `lecodes-<id>`. */
|
|
502
|
+
const fillManifestDefaults = (manifest: PluginManifest, id: string): void => {
|
|
503
|
+
if (manifest.ios) manifest.ios.module ??= "LeCodes" + pascal(id)
|
|
504
|
+
if (manifest.android) manifest.android.module ??= "lecodes-" + id
|
|
505
|
+
}
|
|
506
|
+
|
|
528
507
|
/** Newest v* tag of a plugin repo, via the shared plugin cache. Null when it can't be read
|
|
529
508
|
* (no network, no tags, local override) — `app update` reports it and moves on. */
|
|
530
509
|
export const latestPluginTag = (repo: string): string | null => {
|