lecodes-cli 0.20.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +56 -57
  2. package/dist/index.js +11052 -7592
  3. package/package.json +14 -8
  4. package/runtime/materials/decal-relief.mat +12 -1
  5. package/runtime/materials/decal.mat +17 -3
  6. package/runtime/materials/lightmap.mat +117 -138
  7. package/runtime/materials/particles-quad.mat +6 -2
  8. package/runtime/materials/terrain-lightmap.mat +123 -0
  9. package/runtime/materials/terrain.mat +36 -55
  10. package/runtime/sdk-types.json +1 -1
  11. package/runtime/web/VERSION.json +5 -0
  12. package/runtime/web/assets/camera-5XOM8eHl.js +1 -0
  13. package/runtime/web/assets/creator-2d-D9WKYM6h.js +1 -0
  14. package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
  15. package/runtime/web/assets/creator-full-BBXzQlhq.js +1 -0
  16. package/runtime/web/assets/creator-full-D2dSDdDj.wasm +0 -0
  17. package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +8124 -0
  18. package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
  19. package/runtime/web/assets/creator-ui-_1Z_AX2M.js +1 -0
  20. package/runtime/web/assets/geolocation-BjTKN9_P.js +1 -0
  21. package/runtime/web/assets/inspect-C7x78PNs.js +2 -0
  22. package/runtime/web/assets/map-CyBxOSV0.js +1 -0
  23. package/runtime/web/assets/map-XzoOFt0E.js +2 -0
  24. package/runtime/web/assets/mapImpl-bxvQ2h1Z.js +823 -0
  25. package/runtime/web/assets/maplibre-gl-worker-B7LXXUkR.js +8 -0
  26. package/runtime/web/assets/materials-uri4FvPE.bin +0 -0
  27. package/runtime/web/assets/materials-world-CQWt8TAm.bin +0 -0
  28. package/runtime/web/assets/qr-scanner-CK_cYoJR.js +1 -0
  29. package/runtime/web/assets/uberarchive-DLH-kBhM.bin +0 -0
  30. package/runtime/web/index.html +36 -0
  31. package/runtime/web/player.js +21 -0
  32. package/runtime/web-page/index.html +41 -0
  33. package/runtime/web-page/page.css +1 -0
  34. package/runtime/web-page/page.js +31 -0
  35. package/src/api.ts +1 -302
  36. package/src/cli/args.ts +37 -0
  37. package/src/cli/command.ts +113 -0
  38. package/src/cli/errors.ts +13 -0
  39. package/src/cli/help.ts +158 -0
  40. package/src/cli/index.ts +7 -0
  41. package/src/cli/output.ts +102 -0
  42. package/src/cli/run.ts +106 -0
  43. package/src/commands/{appAndroid.ts → app/android.ts} +454 -43
  44. package/src/commands/{appDesktop.ts → app/desktop.ts} +64 -33
  45. package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +34 -27
  46. package/src/commands/{appIcon.ts → app/icon.ts} +11 -8
  47. package/src/commands/{app.ts → app/index.ts} +400 -290
  48. package/src/commands/{appShared.ts → app/shared.ts} +43 -44
  49. package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +72 -39
  50. package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
  51. package/src/commands/{appTemplates.ts → app/templates/ios.ts} +2 -2
  52. package/src/commands/assets.ts +23 -21
  53. package/src/commands/clone.ts +48 -51
  54. package/src/commands/compile.ts +76 -89
  55. package/src/commands/create.ts +46 -47
  56. package/src/commands/design/comments.ts +361 -0
  57. package/src/commands/design/context.ts +101 -0
  58. package/src/commands/design/index.ts +391 -0
  59. package/src/commands/design/snapshot.ts +134 -0
  60. package/src/commands/{designTemplates.ts → design/templates.ts} +4 -3
  61. package/src/commands/desktop.ts +94 -82
  62. package/src/commands/dev.ts +260 -182
  63. package/src/commands/diff.ts +39 -51
  64. package/src/commands/index.ts +64 -0
  65. package/src/commands/{init.ts → init/index.ts} +195 -191
  66. package/src/commands/{projectTemplates.ts → init/templates.ts} +64 -21
  67. package/src/commands/install.ts +80 -67
  68. package/src/commands/lightmap.ts +543 -252
  69. package/src/commands/link.ts +87 -94
  70. package/src/commands/login.ts +25 -23
  71. package/src/commands/navmesh.ts +169 -139
  72. package/src/commands/plugin.ts +46 -0
  73. package/src/commands/pn.ts +201 -244
  74. package/src/commands/pull.ts +49 -56
  75. package/src/commands/push.ts +48 -54
  76. package/src/commands/render.ts +176 -111
  77. package/src/commands/scene.ts +63 -55
  78. package/src/commands/serve.ts +58 -0
  79. package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
  80. package/src/commands/{shadersNew.ts → shaders/new.ts} +3 -3
  81. package/src/commands/shared.ts +79 -0
  82. package/src/commands/status.ts +22 -26
  83. package/src/commands/test.ts +94 -63
  84. package/src/commands/thumbs.ts +185 -175
  85. package/src/commands/update/index.ts +226 -0
  86. package/src/commands/{types.ts → update/types.ts} +31 -22
  87. package/src/compile/collect.ts +5 -5
  88. package/src/compile/collectLocal.ts +3 -3
  89. package/src/compile/designCompile.ts +4 -4
  90. package/src/compile/headlessBundle.ts +16 -12
  91. package/src/compile/nativeStack.ts +1 -1
  92. package/src/compile/projectCompile.ts +12 -4
  93. package/src/compile/sceneCompile.ts +14 -26
  94. package/src/compile/screenEntry.ts +123 -127
  95. package/src/compile/shaders.ts +3 -3
  96. package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +4 -0
  97. package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +1 -1
  98. package/src/{lecodes-renderer.d.ts → declarations/lecodes-headless.d.ts} +3 -3
  99. package/src/dev/androidDev.ts +1 -1
  100. package/src/dev/clientTemplates.ts +18 -14
  101. package/src/dev/devServer.ts +78 -41
  102. package/src/dev/iosDev.ts +180 -0
  103. package/src/dev/webRunner.ts +83 -15
  104. package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +2 -2
  105. package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +104 -32
  106. package/src/{desktopScript.ts → hosts/desktopScript.ts} +151 -36
  107. package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
  108. package/src/{matcTool.ts → hosts/matcTool.ts} +2 -2
  109. package/src/{peerInstall.ts → hosts/peerInstall.ts} +7 -7
  110. package/src/{peers.ts → hosts/peers.ts} +2 -2
  111. package/src/{releases.ts → hosts/releases.ts} +1 -1
  112. package/src/index.ts +35 -480
  113. package/src/platform/api.ts +302 -0
  114. package/src/{browserAuth.ts → platform/browserAuth.ts} +1 -1
  115. package/src/{config.ts → platform/config.ts} +1 -1
  116. package/src/{serverDiff.ts → platform/serverDiff.ts} +3 -3
  117. package/src/plugin/contract.ts +438 -0
  118. package/src/plugin/emitKotlin.ts +357 -0
  119. package/src/plugin/emitSdk.ts +259 -0
  120. package/src/plugin/emitSwift.ts +336 -0
  121. package/src/plugin/emitWeb.ts +383 -0
  122. package/src/plugin/generate.ts +157 -0
  123. package/src/plugin/lines.ts +28 -0
  124. package/src/plugin/lower.ts +231 -0
  125. package/src/plugin/manifest.ts +192 -0
  126. package/src/{projectEnv.ts → project/env.ts} +33 -2
  127. package/src/{ignore.ts → project/ignore.ts} +164 -163
  128. package/src/{localFiles.ts → project/localFiles.ts} +1 -1
  129. package/src/{manifest.ts → project/manifest.ts} +1 -1
  130. package/src/{project.ts → project/materialize.ts} +2 -2
  131. package/src/project/paths.ts +20 -0
  132. package/src/{textDiff.ts → project/textDiff.ts} +1 -1
  133. package/src/{types.ts → project/types.ts} +0 -0
  134. package/runtime/materials/lightmap-baked-lite.mat +0 -175
  135. package/runtime/materials/lightmap-baked.mat +0 -176
  136. package/runtime/scene-harness.json +0 -1
  137. package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +0 -1
  138. package/runtime/web/assets/basis-C64VHDVD.js +0 -1
  139. package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  140. package/runtime/web/assets/createViewerLite-Ct_PZdof.js +0 -852
  141. package/runtime/web/assets/draco-BiISTFcR.js +0 -118
  142. package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  143. package/runtime/web/assets/index-BMt7AnC5.js +0 -2
  144. package/runtime/web/assets/mapViewImpl-B2JcES8l.js +0 -810
  145. package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +0 -8
  146. package/runtime/web/assets/worker-Caf-yYEI.js +0 -2
  147. package/runtime/web/embed.html +0 -32
  148. package/runtime/web/embed.js +0 -171
  149. package/src/commands/design.ts +0 -846
  150. package/src/commands/update.ts +0 -211
  151. package/src/util.ts +0 -146
  152. /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
  153. /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
  154. /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
@@ -0,0 +1,231 @@
1
+ // The contract's model → the model of the NATIVE emitters (Swift, Kotlin): every type a native
2
+ // language must declare has a NAME. TypeScript writes `{ message: string }`, `"front" | "back"` and
3
+ // `f64 | string` in place; Swift and Kotlin need a struct, an enum and a tagged union for them, so
4
+ // the anonymous ones are named after where they stand (`PushStatus.permission` →
5
+ // `PushStatusPermission`) and the aliases are resolved.
6
+ //
7
+ // Also decided here, once for both languages: `T | null` and an optional member are ONE thing on
8
+ // the native side (an optional), and a union is told apart by what its members are on the wire —
9
+ // a union the wire cannot tell apart is refused.
10
+
11
+ import type { Channel, Contract, Decl, Field, Type } from "./contract"
12
+ import { EmitError } from "./emitSdk"
13
+
14
+ export type Scalar = "i32" | "f64" | "boolean" | "string" | "json" | "file" | "bytes" | "void"
15
+
16
+ export type L =
17
+ | { kind: Scalar }
18
+ | { kind: "enum" | "struct" | "union", name: string }
19
+ | { kind: "list" | "map" | "optional", of: L }
20
+ | { kind: "tuple", of: L[] }
21
+
22
+ export type LField = {
23
+ name: string
24
+ /** `optional` when the member may be absent or null. */
25
+ type: L
26
+ /** Absent when nil (an optional member); a null on the wire otherwise (`T | null`). */
27
+ omit: boolean
28
+ doc: string
29
+ }
30
+
31
+ export type LDecl =
32
+ | { kind: "enum", name: string, values: string[], doc: string }
33
+ | { kind: "struct", name: string, fields: LField[], doc: string }
34
+ | { kind: "union", name: string, members: L[], doc: string }
35
+
36
+ /** `omit`: the argument may be left out (`name?:`); a nullable one (`T | null`) is always named. */
37
+ export type LArg = { name: string, type: L, omit: boolean }
38
+
39
+ export type LMethod = { name: string, args: LArg[], result: L, since: number, rejects: string[], doc: string }
40
+
41
+ export type LEvent = {
42
+ name: string
43
+ payload: L
44
+ /** The payload was written in place (`error: { message: string }`): its members are the
45
+ * arguments of the event's function, no struct is declared for it. */
46
+ inline?: LField[]
47
+ since: number
48
+ doc: string
49
+ }
50
+
51
+ export type LChannel = {
52
+ kind: "view" | "service"
53
+ name: string
54
+ iface: string
55
+ version: number
56
+ /** The struct of the params; the channel declares an empty one when the marker names none. */
57
+ params: string
58
+ methods: LMethod[]
59
+ events: LEvent[]
60
+ /** Every code a method of the channel rejects with, in the order they appear. */
61
+ codes: string[]
62
+ doc: string
63
+ }
64
+
65
+ export type Lowered = { decls: LDecl[], channels: LChannel[] }
66
+
67
+ const upper = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
68
+
69
+ /** What a value is on the wire — two members of a union must differ in it, which is how a reader
70
+ * tells them apart: by what the value at hand IS, before it reads it. */
71
+ export const wireClass = (t: L): string => {
72
+ switch (t.kind) {
73
+ case "i32": case "f64": return "number"
74
+ case "string": case "enum": return "string"
75
+ case "boolean": return "boolean"
76
+ case "struct": case "map": return "object"
77
+ case "list": case "tuple": return "array"
78
+ default: return t.kind
79
+ }
80
+ }
81
+
82
+ export const lower = (contract: Contract): Lowered => {
83
+ const source = new Map<string, Decl>(contract.decls.map(d => [d.name, d]))
84
+ const decls = new Map<string, LDecl>()
85
+ const resolving = new Set<string>()
86
+ const aliases = new Map<string, L>()
87
+
88
+ const addDecl = (d: LDecl, where: string): void => {
89
+ const taken = decls.get(d.name)
90
+ if (taken && JSON.stringify(taken) !== JSON.stringify(d)) {
91
+ throw new EmitError(`${where}: the name ${d.name} the generator gives this type is taken — name the type yourself (export type ${d.name}… = …)`)
92
+ }
93
+ decls.set(d.name, d)
94
+ }
95
+
96
+ const fields = (list: Field[], owner: string): LField[] => list.map(f => {
97
+ const t = type(f.type, `${owner}${upper(f.name)}`, `${owner}.${f.name}`)
98
+ const optional = f.optional || t.kind === "optional"
99
+ return { name: f.name, type: optional && t.kind !== "optional" ? { kind: "optional", of: t } : t, omit: f.optional, doc: f.doc }
100
+ })
101
+
102
+ /** `name`: what an anonymous type is called if this one needs a declaration. */
103
+ const type = (t: Type, name: string, where: string): L => {
104
+ switch (t.kind) {
105
+ case "ref": return named(t.name, where)
106
+ case "literals": addDecl({ kind: "enum", name, values: t.values, doc: "" }, where); return { kind: "enum", name }
107
+ case "object": addDecl({ kind: "struct", name, fields: fields(t.fields, name), doc: "" }, where); return { kind: "struct", name }
108
+ case "map": return { kind: "map", of: type(t.of, `${name}Value`, where) }
109
+ case "list": return { kind: "list", of: type(t.of, `${name}Item`, where) }
110
+ case "tuple": return { kind: "tuple", of: t.of.map((m, i) => type(m, `${name}${i}`, where)) }
111
+ case "nullable": {
112
+ const of = type(t.of, name, where)
113
+ return of.kind === "optional" ? of : { kind: "optional", of }
114
+ }
115
+ case "union": {
116
+ const members = t.of.map((m, i) => type(m, `${name}${upper(memberLabel(m, i))}`, where))
117
+ const seen = new Map<string, L>()
118
+ for (const m of members) {
119
+ if (m.kind === "json" || m.kind === "file" || m.kind === "bytes" || m.kind === "optional" || m.kind === "union") {
120
+ throw new EmitError(`${where}: a union holds scalars, enums, structs, lists — not ${m.kind === "optional" ? "a nullable member" : m.kind}`)
121
+ }
122
+ const c = wireClass(m)
123
+ if (seen.has(c)) throw new EmitError(`${where}: two members of the union are both a ${c} on the wire — a half cannot tell them apart`)
124
+ seen.set(c, m)
125
+ }
126
+ addDecl({ kind: "union", name, members, doc: "" }, where)
127
+ return { kind: "union", name }
128
+ }
129
+ default: return { kind: t.kind }
130
+ }
131
+ }
132
+
133
+ const memberLabel = (t: Type, i: number): string => t.kind === "ref" ? t.name : t.kind === "literals" ? "name" : `${t.kind}${i}`
134
+
135
+ /** A declaration of the contract by its name: a struct, or an alias resolved to what it names. */
136
+ const named = (name: string, where: string): L => {
137
+ const d = source.get(name)!
138
+ if (d.kind === "struct") {
139
+ if (!decls.has(name) && !resolving.has(name)) {
140
+ resolving.add(name)
141
+ const f = fields(d.fields, name)
142
+ resolving.delete(name)
143
+ addDecl({ kind: "struct", name, fields: f, doc: d.doc }, where)
144
+ }
145
+ return { kind: "struct", name }
146
+ }
147
+ const known = aliases.get(name)
148
+ if (known) return known
149
+ if (resolving.has(name)) throw new EmitError(`${where}: the alias ${name} names itself`)
150
+ resolving.add(name)
151
+ const l = type(d.type, name, name)
152
+ resolving.delete(name)
153
+ aliases.set(name, l)
154
+ const own = decls.get(name)
155
+ if (own && own.doc === "" && d.doc) decls.set(name, { ...own, doc: d.doc })
156
+ return l
157
+ }
158
+
159
+ const channels = contract.channels.map((ch: Channel): LChannel => {
160
+ const iface = ch.iface
161
+ const params = ch.paramsType ?? `${iface}Params`
162
+ if (ch.paramsType) named(ch.paramsType, iface)
163
+ else addDecl({ kind: "struct", name: params, fields: [], doc: "" }, iface)
164
+
165
+ const methods = ch.methods.map((m): LMethod => {
166
+ const where = `${iface}.${m.name}`
167
+ return {
168
+ name: m.name,
169
+ args: m.args.map(a => {
170
+ const t = type(a.type, `${iface}${upper(m.name)}${upper(a.name)}`, `${where}(${a.name})`)
171
+ return { name: a.name, type: a.optional && t.kind !== "optional" ? { kind: "optional", of: t } : t, omit: a.optional }
172
+ }),
173
+ result: m.result.kind === "void" ? { kind: "void" } : type(m.result, `${iface}${upper(m.name)}Result`, `${where}()`),
174
+ since: m.since, rejects: m.rejects, doc: m.doc,
175
+ }
176
+ })
177
+
178
+ const events = ch.events.map((e): LEvent => {
179
+ const where = `${iface} event ${e.name}`
180
+ if (e.payload.kind === "object") {
181
+ return { name: e.name, payload: { kind: "void" }, inline: fields(e.payload.fields, `${iface}${upper(e.name)}`), since: e.since, doc: e.doc }
182
+ }
183
+ return { name: e.name, payload: e.payload.kind === "void" ? { kind: "void" } : type(e.payload, `${iface}${upper(e.name)}Payload`, where), since: e.since, doc: e.doc }
184
+ })
185
+
186
+ const codes: string[] = []
187
+ for (const m of methods) for (const c of m.rejects) if (!codes.includes(c)) codes.push(c)
188
+ return { kind: ch.kind, name: ch.name, iface, version: ch.version, params, methods, events, codes, doc: ch.doc }
189
+ })
190
+
191
+ // Only what a channel reaches is declared: a struct of the contract nothing names is not a type
192
+ // a half ever sees.
193
+ return { decls: [...decls.values()], channels }
194
+ }
195
+
196
+ /** Which way a declaration crosses: the app SENDS it (an argument, a param) → it is read here; the
197
+ * plugin sends it (a result, an event) → it is written here. Only the codec a way needs is
198
+ * emitted by a native emitter — a struct that carries a File is written, never read. */
199
+ export type Ways = { read: Set<string>, write: Set<string> }
200
+
201
+ export const waysOf = (lowered: Lowered): Ways => {
202
+ const byName = new Map(lowered.decls.map(d => [d.name, d]))
203
+ const ways: Ways = { read: new Set(), write: new Set() }
204
+ const walk = (t: L, set: Set<string>): void => {
205
+ switch (t.kind) {
206
+ case "list": case "map": case "optional": walk(t.of, set); return
207
+ case "tuple": t.of.forEach(m => walk(m, set)); return
208
+ case "enum": case "struct": case "union": {
209
+ if (set.has(t.name)) return
210
+ set.add(t.name)
211
+ const d = byName.get(t.name)!
212
+ if (d.kind === "struct") d.fields.forEach(f => walk(f.type, set))
213
+ if (d.kind === "union") d.members.forEach(m => walk(m, set))
214
+ return
215
+ }
216
+ default: return
217
+ }
218
+ }
219
+ for (const ch of lowered.channels) {
220
+ walk({ kind: "struct", name: ch.params }, ways.read)
221
+ for (const m of ch.methods) {
222
+ m.args.forEach(a => walk(a.type, ways.read))
223
+ walk(m.result, ways.write)
224
+ }
225
+ for (const e of ch.events) {
226
+ walk(e.payload, ways.write)
227
+ e.inline?.forEach(f => walk(f.type, ways.write))
228
+ }
229
+ }
230
+ return ways
231
+ }
@@ -0,0 +1,192 @@
1
+ // A plugin's manifest (`lecodes-plugin.json`, 2.x): what a host needs to BUILD the plugin in — the
2
+ // type that registers each half, what a half asks of its platform (usage strings, entitlements,
3
+ // packages, Gradle dependencies, bundler resources). What the plugin DOES is not here: the channels,
4
+ // their names and their types are the contract's (contract.d.ts), the folders are the convention
5
+ // (`sdk/`, `ios/`, `android/`, `web/`).
6
+ //
7
+ // A platform is a key of the manifest, ALWAYS: an object where the plugin has a half, `null` where
8
+ // it has none. A missing key is refused — "not supported" is a statement, not an omission.
9
+ //
10
+ // Desktop plugins are another system (a prebuilt library behind a C ABI) with a manifest of their
11
+ // own shape; nothing of it is read here.
12
+
13
+ import { existsSync, readFileSync, statSync } from "node:fs"
14
+ import { join } from "node:path"
15
+
16
+ export const MANIFEST_FILE = "lecodes-plugin.json"
17
+
18
+ /** A SwiftPM dependency the iOS half links. */
19
+ export type IosPackage = { url: string, exact?: string, from?: string, products: string[] }
20
+
21
+ /** What a web half asks of its bundler: a file of a package, handed over as a URL of a script that
22
+ * stands alone (`worker-url`), as a URL (`url`) or as its text (`text`). */
23
+ export type WebResource = { module: string, as: "worker-url" | "url" | "text" }
24
+
25
+ export type PluginManifest = {
26
+ id: string
27
+ name: string
28
+ version: string
29
+ /** The SDK the plugin is written for, as a range of its ONE version (`^2.0.0`): the major is the
30
+ * contract between a bundle, the runtime and a half. */
31
+ sdk: string
32
+ description?: string
33
+ ios: {
34
+ /** The Swift module the half is built as in a shell. Default: `LeCodes` + the id in PascalCase. */
35
+ module?: string
36
+ /** The type with `static func register(in: LeCodesEngine)`. */
37
+ register: string
38
+ infoPlist?: Record<string, string>
39
+ entitlements?: Record<string, unknown>
40
+ packages?: IosPackage[]
41
+ } | null
42
+ android: {
43
+ /** The Gradle module the half is built as in a shell. Default: `lecodes-` + the id. */
44
+ module?: string
45
+ /** The object with `fun register(engine: LecodesEngine, context: Context)`, by its full name. */
46
+ register: string
47
+ /** Maven coordinates; a `platform:` prefix is a BoM. */
48
+ dependencies?: string[]
49
+ /** App-level Gradle plugins the shell applies (`com.google.gms.google-services`). */
50
+ gradlePlugins?: string[]
51
+ } | null
52
+ web: {
53
+ /** The module that exports `register(host, resources?)`, relative to the plugin. */
54
+ register: string
55
+ /** npm packages the half imports, as a package.json's dependencies. */
56
+ dependencies?: Record<string, string>
57
+ /** The half's bundler resources, by the name `register` receives them under. */
58
+ resources?: Record<string, WebResource>
59
+ } | null
60
+ }
61
+
62
+ export class ManifestError extends Error {
63
+ constructor(readonly file: string, readonly problems: string[]) {
64
+ super(`${file}:\n${problems.map(p => ` ${p}`).join("\n")}`)
65
+ }
66
+ }
67
+
68
+ const ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/
69
+ const VERSION = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/
70
+ const RANGE = /^[\^~]?\d+\.\d+\.\d+$/
71
+ const PLATFORMS = ["ios", "android", "web"] as const
72
+ const KNOWN = new Set(["id", "name", "version", "sdk", "description", ...PLATFORMS])
73
+ const GONE: Record<string, string> = {
74
+ provides: "the channels are the contract's (contract.d.ts)",
75
+ js: "the wrapper is found by convention (sdk/)",
76
+ desktop: "desktop plugins are another system, with a manifest of their own",
77
+ }
78
+ const BRANCH: Record<(typeof PLATFORMS)[number], Set<string>> = {
79
+ ios: new Set(["module", "register", "infoPlist", "entitlements", "packages"]),
80
+ android: new Set(["module", "register", "dependencies", "gradlePlugins"]),
81
+ web: new Set(["register", "dependencies", "resources"]),
82
+ }
83
+
84
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
85
+ const isFolder = (path: string): boolean => existsSync(path) && statSync(path).isDirectory()
86
+
87
+ export const pascal = (id: string): string => id.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join("")
88
+
89
+ /** The manifest of the plugin in `dir` (its folder's name is its id). Every problem of the file is
90
+ * reported at once. */
91
+ export const readManifest = (dir: string, id: string): PluginManifest => {
92
+ const file = join(dir, MANIFEST_FILE)
93
+ let raw: unknown
94
+ try { raw = JSON.parse(readFileSync(file, "utf8").replace(/^/, "")) } catch (e) {
95
+ throw new ManifestError(file, [`not JSON: ${e instanceof Error ? e.message : String(e)}`])
96
+ }
97
+ if (!isObject(raw)) throw new ManifestError(file, ["a manifest is an object"])
98
+
99
+ const problems: string[] = []
100
+ const text = (key: string, of: Record<string, unknown> = raw, where = ""): string | undefined => {
101
+ const v = of[key]
102
+ if (typeof v === "string" && v.trim() !== "") return v
103
+ problems.push(`${where}${key}: ${v === undefined ? "missing" : "a text"}`)
104
+ return undefined
105
+ }
106
+
107
+ for (const key of Object.keys(raw)) {
108
+ if (GONE[key]) problems.push(`${key}: gone in 2.x — ${GONE[key]}`)
109
+ else if (!KNOWN.has(key)) problems.push(`${key}: not a key of a manifest`)
110
+ }
111
+ if (text("id") !== undefined && raw.id !== id) problems.push(`id: "${String(raw.id)}" — the plugin's folder is "${id}", and the folder is the id`)
112
+ if (!ID.test(id)) problems.push(`id: "${id}" — lower-case words joined by "-"`)
113
+ text("name")
114
+ const version = text("version")
115
+ if (version !== undefined && !VERSION.test(version)) problems.push(`version: "${version}" — a version is 2.0.0`)
116
+ const sdk = text("sdk")
117
+ if (sdk !== undefined && !RANGE.test(sdk)) problems.push(`sdk: "${sdk}" — a range of the SDK's version: ^2.0.0`)
118
+ if (raw.description !== undefined && typeof raw.description !== "string") problems.push("description: a text")
119
+
120
+ for (const platform of PLATFORMS) {
121
+ const branch = raw[platform]
122
+ const folder = isFolder(join(dir, platform))
123
+ if (branch === undefined) { problems.push(`${platform}: missing — an object, or null where the plugin has no ${platform} half`); continue }
124
+ if (branch === null) {
125
+ if (folder) problems.push(`${platform}: null, and the plugin has a ${platform}/ folder`)
126
+ continue
127
+ }
128
+ if (!isObject(branch)) { problems.push(`${platform}: an object, or null`); continue }
129
+ if (!folder) problems.push(`${platform}: the plugin has no ${platform}/ folder — say null`)
130
+ for (const key of Object.keys(branch)) {
131
+ if (key === "sources" || key === "minSdk") problems.push(`${platform}.${key}: gone in 2.x — ${key === "sources" ? `the half is the ${platform}/ folder` : "the SDK a plugin needs is `sdk`, one for every platform"}`)
132
+ else if (!BRANCH[platform].has(key)) problems.push(`${platform}.${key}: not a key of a manifest`)
133
+ }
134
+ const register = text("register", branch, `${platform}.`)
135
+ if (platform === "android" && register !== undefined && !/^[A-Za-z_][\w]*(\.[A-Za-z_][\w]*)+$/.test(register)) {
136
+ problems.push(`android.register: "${register}" — the object's full name (com.example.plugin.Plugin)`)
137
+ }
138
+ if (platform === "ios" && register !== undefined && !/^[A-Za-z_]\w*$/.test(register)) problems.push(`ios.register: "${register}" — a type's name`)
139
+ if (platform === "web" && register !== undefined) {
140
+ if (!register.startsWith("web/")) problems.push(`web.register: "${register}" — a module of the web/ folder`)
141
+ else if (!existsSync(join(dir, register))) problems.push(`web.register: ${register} is not there`)
142
+ }
143
+ }
144
+
145
+ const ios = isObject(raw.ios) ? raw.ios : null
146
+ if (ios) {
147
+ for (const key of ["infoPlist"] as const) {
148
+ const v = ios[key]
149
+ if (v !== undefined && (!isObject(v) || Object.values(v).some(x => typeof x !== "string"))) problems.push(`ios.${key}: a key → a text`)
150
+ }
151
+ if (ios.entitlements !== undefined && !isObject(ios.entitlements)) problems.push("ios.entitlements: an object")
152
+ if (ios.packages !== undefined) {
153
+ if (!Array.isArray(ios.packages)) problems.push("ios.packages: a list")
154
+ else ios.packages.forEach((p, i) => {
155
+ if (!isObject(p) || typeof p.url !== "string" || !Array.isArray(p.products) || p.products.length === 0 || p.products.some(x => typeof x !== "string")) {
156
+ problems.push(`ios.packages[${i}]: { url, exact | from, products }`)
157
+ } else if ((typeof p.exact === "string") === (typeof p.from === "string")) {
158
+ problems.push(`ios.packages[${i}]: one of exact and from`)
159
+ }
160
+ })
161
+ }
162
+ }
163
+ const android = isObject(raw.android) ? raw.android : null
164
+ if (android) {
165
+ for (const key of ["dependencies", "gradlePlugins"] as const) {
166
+ const v = android[key]
167
+ if (v !== undefined && (!Array.isArray(v) || v.some(x => typeof x !== "string"))) problems.push(`android.${key}: a list of texts`)
168
+ }
169
+ }
170
+ const web = isObject(raw.web) ? raw.web : null
171
+ if (web) {
172
+ const deps = web.dependencies
173
+ if (deps !== undefined && (!isObject(deps) || Object.values(deps).some(x => typeof x !== "string"))) problems.push("web.dependencies: a package → its range")
174
+ const resources = web.resources
175
+ if (resources !== undefined) {
176
+ if (!isObject(resources)) problems.push("web.resources: a name → { module, as }")
177
+ else for (const [name, r] of Object.entries(resources)) {
178
+ if (!/^[A-Za-z_]\w*$/.test(name)) problems.push(`web.resources.${name}: a name the half reads it under`)
179
+ if (!isObject(r) || typeof r.module !== "string" || (r.as !== "worker-url" && r.as !== "url" && r.as !== "text")) {
180
+ problems.push(`web.resources.${name}: { module, as: "worker-url" | "url" | "text" }`)
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ if (problems.length) throw new ManifestError(file, problems)
187
+ return raw as unknown as PluginManifest
188
+ }
189
+
190
+ /** The manifest, or null where the plugin has none yet (a contract is where a plugin starts). */
191
+ export const readManifestIfAny = (dir: string, id: string): PluginManifest | null =>
192
+ existsSync(join(dir, MANIFEST_FILE)) ? readManifest(dir, id) : null
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from "node:fs"
1
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs"
2
2
  import { homedir } from "node:os"
3
3
  import { dirname, join, resolve } from "node:path"
4
4
 
@@ -57,7 +57,12 @@ export const applyDotEnv = (
57
57
  ): Record<string, string> => {
58
58
  const out: Record<string, string> = {}
59
59
  for (const [key, raw] of Object.entries(parsed)) {
60
- if (!KEY_RE.test(key) || env[key] !== undefined) continue
60
+ if (!KEY_RE.test(key)) continue
61
+ // Bun auto-loads the cwd's .env into process.env before any code runs, so a project's own
62
+ // keys arrive here already set — to their RAW text, `./` paths unresolved. That is not the
63
+ // shell overriding the file, it is the same file seen twice: apply the resolution. A shell
64
+ // value that differs from the file's text is a real override and stays.
65
+ if (env[key] !== undefined && env[key] !== raw) continue
61
66
  let value = raw
62
67
  if (value.startsWith("./") || value.startsWith("../")) value = resolve(dir, value)
63
68
  else if (value === "~" || value.startsWith("~/")) value = join(homedir(), value.slice(1))
@@ -96,3 +101,29 @@ export const loadProjectEnv = (cwd = process.cwd()): string[] => {
96
101
  return []
97
102
  }
98
103
  }
104
+
105
+ /** Set keys in `<root>/.env` — an existing `KEY=` line is REPLACED (rotating a secret must not
106
+ * leave two live lines), a new one is appended, other lines are kept verbatim — and make sure
107
+ * git can't swallow the file (`.env` in .gitignore). Returns the paths touched, root-relative.
108
+ * Values are written raw: quote them yourself if they carry spaces or `#`. */
109
+ export const upsertDotEnv = (root: string, entries: Record<string, string>): string[] => {
110
+ const touched: string[] = []
111
+ const envPath = join(root, ".env")
112
+ let content = existsSync(envPath) ? readFileSync(envPath, "utf8") : ""
113
+ for (const [key, value] of Object.entries(entries)) {
114
+ const line = `${key}=${value}`
115
+ const re = new RegExp(`^(?:export\\s+)?${key}\\s*=.*$`, "m")
116
+ if (re.test(content)) content = content.replace(re, line)
117
+ else content = content + (!content || content.endsWith("\n") ? "" : "\n") + line + "\n"
118
+ }
119
+ writeFileSync(envPath, content)
120
+ touched.push(".env")
121
+
122
+ const ignorePath = join(root, ".gitignore")
123
+ const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : ""
124
+ if (!ignore.split("\n").some(l => l.trim() === ".env")) {
125
+ appendFileSync(ignorePath, `${!ignore || ignore.endsWith("\n") ? "" : "\n"}.env\n`)
126
+ touched.push(".gitignore")
127
+ }
128
+ return touched
129
+ }