lecodes-cli 2.0.6 → 2.0.8

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.
@@ -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 — 1.2.0 = variant matrix (core/2d/3d/full)
65
- // + LeCodesUpdater (native OTA), both of which the generated files assume. The version a shell
66
- // actually links is app.json `ios.sdk` (init pins the newest release, `app update` moves it);
67
- // this floor is only the last-resort fallback when the network is unavailable at init.
68
- const SDK_MIN_VERSION = "1.2.0"
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). On older SDKs sync simply doesn't bake the uuid (and warns
72
- // when push is installed) instead of generating code the linked release can't compile.
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*"([^"]+)"/.exec(readFileSync(path, "utf8"))
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
- /** The filament resources bundle that must ship beside any CreatorGL-bearing variant (3d/full).
214
- * Published as a release asset next to the SDK binaries; downloaded once per SDK version into
215
- * ~/.lecodes/sdk-resources/. `LECODES_SDK_RESOURCES` overrides with a local .bundle — the
216
- * SDK-development loop, like LECODES_PLUGINS_DIR for plugins. */
217
- const ensureSdkResourcesBundle = async (sdkVersion: string): Promise<string> => {
218
- const override = process.env.LECODES_SDK_RESOURCES
219
- if (override) {
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, ios.sources ?? "ios")
311
- if (!existsSync(src)) throw new CliError(`Plugin "${id}": sources dir ${src} missing`)
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: below minSdk the vendored sources reference SDK API that
317
- // does not exist in the linked release, so the shell cannot compile anyway.
318
- if (ios.minSdk && VERSION_RE.test(ios.minSdk) && cmpVersion(sdkVersion, ios.minSdk) < 0) {
319
- throw new CliError(`Plugin "${id}" needs lecodes-ios-sdk ${ios.minSdk} but the shell links ${sdkVersion} — run \`lecodes app update\` (or set app.json ios.sdk).`)
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
- // host's setFetchLocal reads Resources/ by basename, falling back to the SDK resources
380
- // bundle for the sdkProvided names.
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
- if (!sdkProvided.has(name)) warn(`bundle needs local asset "${name}" but no project file provides it — it won't load on device`)
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
- writeFileSync(join(iosDir, "LeCodesRuntime", "Package.swift"), runtimePackageTemplate(sdkVersion, variant, runtimePlugins))
425
- writeFileSync(join(sourcesDir, "LeCodesRuntime", "LeCodesRuntime.swift"), runtimeSwiftTemplate(runtimePlugins, shellUuid))
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 (!config.update) {
433
- // Discoverability: server-backed projects get OTA with one app.json key, but nothing
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\napp.json \"update\": true (or {\"url\": …}) enables native OTA JS updates at launch (iOS). 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.",
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.startsWith("github:")) {
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(", ")}) or github:owner/repo[@tag] for a third-party plugin (native code — review it before shipping). Nothing reaches the shell until the next sync.`,
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 needsResources = variant === "3d" || variant === "full"
731
- const hasResources = existsSync(join(root, "ios", "App", "Resources", "LeCodesSDKResources.bundle"))
732
- check(pkg.includes(`"LeCodesSDK-${variant}"`) && (!needsResources || hasResources),
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 interface PluginManifest {
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
- const manifest = readJson<PluginManifest>(join(dir, "lecodes-plugin.json"))
507
- if (!manifest) throw new CliError(`Plugin "${id}": no lecodes-plugin.json in ${dir}`)
508
- if (manifest.id !== id) throw new CliError(`Plugin "${id}": manifest declares id "${manifest.id}"`)
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 ?? "unknown" },
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 => {