@geastack/cli 0.1.52 → 0.1.54

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 (44) hide show
  1. package/README.md +14 -1
  2. package/docs/ESP32-WAVESHARE-AMOLED-QUICKSTART.md +2 -2
  3. package/docs/NPX-COMMANDS.md +3 -1
  4. package/docs/SETUP.md +81 -15
  5. package/docs/SPEC.md +8 -5
  6. package/package.json +2 -3
  7. package/src/apps/app-index-writer.mjs +15 -0
  8. package/src/apps/apple-icons.mjs +147 -0
  9. package/src/apps/bundle-writer.mjs +156 -0
  10. package/src/apps/launcher-catalog.mjs +114 -0
  11. package/src/apps/openai-icons.mjs +356 -0
  12. package/src/apps/zip-writer.mjs +104 -0
  13. package/src/ble/ble-ota.swift +291 -0
  14. package/src/boards/config.mjs +114 -0
  15. package/src/boards/custom-target.mjs +309 -0
  16. package/src/boards/resolve.mjs +145 -0
  17. package/src/boards/targets.mjs +45 -0
  18. package/src/boards/usb.mjs +252 -0
  19. package/src/chips.mjs +1 -1
  20. package/src/commands/apps.mjs +204 -0
  21. package/src/commands/board.mjs +402 -0
  22. package/src/commands/boards.mjs +334 -0
  23. package/src/commands/doctor.mjs +91 -0
  24. package/src/context.mjs +50 -54
  25. package/src/device/device.mjs +96 -0
  26. package/src/device/image.mjs +115 -0
  27. package/src/device/serial.mjs +430 -0
  28. package/src/device/wifi.mjs +190 -0
  29. package/src/esp32/build.mjs +321 -0
  30. package/src/esp32/capabilities.mjs +54 -0
  31. package/src/esp32/flash.mjs +181 -0
  32. package/src/esp32/idf-env.mjs +171 -0
  33. package/src/esp32/ota.mjs +80 -0
  34. package/src/esp32/partitions.mjs +59 -0
  35. package/src/esp32/sdkconfig.mjs +103 -0
  36. package/src/esp32/wifi-config.mjs +72 -0
  37. package/src/gea.mjs +89 -427
  38. package/src/geaos/adapter.mjs +119 -0
  39. package/src/heap-report.mjs +1276 -0
  40. package/src/manifest.mjs +135 -36
  41. package/src/rp2350/adapter.mjs +155 -0
  42. package/src/serial-devices.mjs +32 -20
  43. package/src/setup-wizard.mjs +11 -12
  44. package/src/taurus/adapter.mjs +52 -0
package/README.md CHANGED
@@ -100,6 +100,18 @@ host bindings, and the other native packages from npm. A project can use a
100
100
  local CLI with `npx gea` or a global installation with `gea`; neither command
101
101
  depends on a GeaStack source checkout.
102
102
 
103
+ Boards are managed without editing JSON by hand; aliases live in
104
+ `~/.geastack/boards.json` (this machine) and the project's `.gea/boards.json`
105
+ (overrides):
106
+
107
+ ```sh
108
+ gea boards discover # which registered board is on which USB port, its app and IP
109
+ gea boards list
110
+ gea boards set amoled host 192.168.1.100
111
+ gea boards rename amoled desk-amoled
112
+ gea boards remove desk-amoled
113
+ ```
114
+
103
115
  Custom boards remain editable after setup:
104
116
 
105
117
  ```sh
@@ -169,4 +181,5 @@ First implementation is in place:
169
181
  - `list` and `inspect` helpers;
170
182
  - `create-geastack` with a bundled counter starter, a blank application, and a
171
183
  GitHub-backed rich example flow for web, embedded, GeaOS, iOS, macOS, and Android apps,
172
- all with `.gea/boards.json`.
184
+ all with `.gea/boards.json`, plus `gea boards` for machine-wide aliases in
185
+ `~/.geastack/boards.json`.
@@ -90,7 +90,7 @@ You do not need to run that before every command unless `gea doctor` says ESP-ID
90
90
 
91
91
  If the wizard finds a connected serial device, pick it. If no board is plugged in yet, that is fine; you can provide the port later.
92
92
 
93
- The wizard writes the board config into your app at `.gea/boards.json` and initializes the selected board target.
93
+ The wizard writes the board alias into your app's `.gea/boards.json` (or `~/.geastack/boards.json` with `--global`, for every project on this machine) and initializes the selected board target.
94
94
 
95
95
  ## 4. Flash
96
96
 
@@ -134,4 +134,4 @@ Most first-run issues are one of:
134
134
  - ESP-IDF is not activated in the current shell.
135
135
  - The USB cable is power-only.
136
136
  - The serial port needs to be passed with `--port`.
137
- - The board alias does not match the name in `.gea/boards.json`.
137
+ - The board alias does not match a name in `npx gea boards list` (`~/.geastack/boards.json` plus the app's `.gea/boards.json`). `npx gea boards discover` shows which registered board each USB port is.
@@ -94,7 +94,9 @@ packages are scoped and configured for restricted npmjs publication.
94
94
  - ESP-IDF toolchain check/install only.
95
95
 
96
96
  Known-board setup detects attached serial devices, asks for a stable USB serial,
97
- shows a review screen, writes a board alias into the active boards config, then
97
+ shows a review screen, writes a board alias into the active boards config
98
+ (`--global` for `~/.geastack/boards.json`, `--local` for the project's
99
+ `.gea/boards.json`; by default the project config when it exists), then
98
100
  initializes the selected board target so the next command can be
99
101
  `npx gea flash --board <alias> --monitor`. In generated apps, that config is:
100
102
 
package/docs/SETUP.md CHANGED
@@ -35,7 +35,7 @@ For ESP32 hardware:
35
35
  - npm.
36
36
  - Python 3.
37
37
  - ESP-IDF v6.0.1.
38
- - `.gea/boards.json` configured for your board.
38
+ - a board alias for your board (`~/.geastack/boards.json` or the project's `.gea/boards.json`, see Board Configuration).
39
39
 
40
40
  For the Waveshare ESP32-S3 AMOLED board, use
41
41
  [ESP32-WAVESHARE-AMOLED-QUICKSTART.md](ESP32-WAVESHARE-AMOLED-QUICKSTART.md).
@@ -224,9 +224,44 @@ manager or from python.org.
224
224
 
225
225
  ## Board Configuration
226
226
 
227
- Board aliases are project-local machine configuration. `create-geastack`
228
- creates an empty `.gea/boards.json`, and `npx gea setup` writes aliases there.
229
- Example:
227
+ A board alias names one physical unit: `gea flash --board amoled` resolves
228
+ `amoled` to a target, a USB serial and, optionally, an IP. Aliases are
229
+ machine-local configuration and never come from a package. The CLI merges two
230
+ files, project over home:
231
+
232
+ | file | holds | written by |
233
+ | --- | --- | --- |
234
+ | `~/.geastack/boards.json` | every board on this machine (`GEA_HOME` relocates the directory) | `gea boards add --global`, `gea boards set` |
235
+ | `<project>/.gea/boards.json` | aliases specific to one application; overrides a home alias of the same name | `create-geastack` (empty), `gea boards add` |
236
+
237
+ `--boards-config <file>` (or `GEA_BOARDS_CONFIG`) replaces both with exactly
238
+ that file. By default `gea boards add` writes to the project config when the
239
+ project has one and to the home config otherwise; an existing alias is always
240
+ edited where it lives.
241
+
242
+ ```sh
243
+ npx gea boards discover # what is plugged in: alias, app, IP
244
+ npx gea boards add # register a board (guided)
245
+ npx gea boards list # every alias and which file it lives in
246
+ npx gea boards set amoled host 192.168.1.100
247
+ npx gea boards rename amoled desk-amoled
248
+ npx gea boards remove desk-amoled
249
+ npx gea doctor
250
+ ```
251
+
252
+ `gea boards discover` sends one `GEADEV PING` to each serial device without
253
+ resetting it; a board running gea firmware answers with its app id, its IP
254
+ (when it has joined WiFi) and its MAC, and the CLI pairs the reply with an
255
+ alias by USB serial. `--save` writes a reported IP into
256
+ `transports.ota.host`, which is what `gea ota`, `gea logs` and
257
+ `gea screenshot` use over WiFi.
258
+
259
+ ### Entry shapes
260
+
261
+ Every entry names a `target` (`gea targets list`) and its `adapter`, then the
262
+ transports the board offers. The USB serial is the stable identity: on ESP32
263
+ boards it is the station MAC, and the CLI resolves it to today's `/dev` port
264
+ at call time, so never record a port path.
230
265
 
231
266
  ```json
232
267
  {
@@ -234,22 +269,53 @@ Example:
234
269
  "target": "esp32-s3-touch-amoled-2.06",
235
270
  "adapter": "esp32-idf",
236
271
  "transports": {
237
- "usbSerial": {
238
- "serial": "YOUR_BOARD_USB_SERIAL"
239
- }
272
+ "usbSerial": { "serial": "80:B5:4E:DA:73:88" },
273
+ "ota": { "host": "192.168.1.100" }
274
+ }
275
+ },
276
+ "rotary": {
277
+ "target": "esp32-s3-elecrow-rotary-2.1",
278
+ "adapter": "esp32-idf",
279
+ "transports": {
280
+ "usbSerial": { "serial": "14:C1:9F:26:65:08", "restartAfterFlash": "manual" }
281
+ }
282
+ },
283
+ "tufty": {
284
+ "target": "rp2350-tufty-2350",
285
+ "adapter": "rp2350-pico",
286
+ "transports": { "usbSerial": { "serial": "fa59949adbb4802f" } }
287
+ },
288
+ "linux": {
289
+ "target": "geaos",
290
+ "adapter": "geaos-linux",
291
+ "transports": {
292
+ "telnet": { "host": "192.168.7.2", "port": 2323 },
293
+ "fastboot": { "serial": "geaos001" }
240
294
  }
295
+ },
296
+ "lokmat": {
297
+ "target": "lokmat-applp2max",
298
+ "adapter": "geaos-arm64",
299
+ "transports": {
300
+ "usbSerial": { "serial": "geaos01" },
301
+ "fastboot": { "serial": "0123456789ABCDEF" },
302
+ "mtk": { "workdir": "~/lokmat-root" }
303
+ }
304
+ },
305
+ "my-board": {
306
+ "target": "my-board",
307
+ "targetDefinition": "targets/my-board.json",
308
+ "adapter": "esp32-idf",
309
+ "transports": { "usbSerial": { "serial": "YOUR_BOARD_USB_SERIAL" } }
241
310
  }
242
311
  }
243
312
  ```
244
313
 
245
- Then check discovery:
246
-
247
- ```sh
248
- npx gea setup
249
- npx gea list boards
250
- npx gea list targets
251
- npx gea doctor
252
- ```
314
+ - `restartAfterFlash: "manual"` marks a board whose USB-Serial-JTAG port
315
+ re-enters ROM download mode after a flash; the CLI stops and asks for a
316
+ power cycle instead of pulsing the reset lines.
317
+ - `targetDefinition` points at a custom target composed by `gea setup` /
318
+ `gea chips`, relative to the file the alias lives in.
253
319
 
254
320
  ## Common Verification Flow
255
321
 
package/docs/SPEC.md CHANGED
@@ -83,9 +83,12 @@ gea flash --app bouncing-balls-jsx --board amoled --monitor
83
83
  gea flash --app css-3d-cube --target android
84
84
  ```
85
85
 
86
- The CLI should pass through board aliases from the active board config. A project
87
- uses its own `.gea/boards.json`; otherwise the CLI reads the board catalog shipped
88
- by the installed `@geastack/targets` package.
86
+ The CLI resolves board aliases from two machine-local files merged project over
87
+ home: `~/.geastack/boards.json` (every board on the machine, `GEA_HOME`
88
+ relocates it) and the project's `.gea/boards.json`. `--boards-config` replaces
89
+ both. No package ships aliases. `gea boards` manages them: `list`, `show`,
90
+ `add`, `set <alias> <key> <value>`, `remove`, `rename`, and `discover`, which
91
+ identifies connected boards over USB (`GEADEV PING` reports app, IP and MAC).
89
92
 
90
93
  ### `gea chips`
91
94
 
@@ -228,8 +231,8 @@ Use predictable exit codes:
228
231
  1. Implement `gea doctor` for toolchain discovery and app manifest validation.
229
232
  2. Implement `gea dev --target web` by delegating to the simulator repo.
230
233
  3. Implement `gea build --target web`.
231
- 4. Implement `gea flash --board <alias>` by delegating to
232
- `targets/scripts/board`.
234
+ 4. Implement `gea flash --board <alias>` natively (ESP-IDF, esptool, OTA and
235
+ device transports live in `src/esp32` and `src/device`).
233
236
  5. Add `create-geastack` with one JSX app template.
234
237
 
235
238
  Status: implemented.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geastack/cli",
3
- "version": "0.1.52",
3
+ "version": "0.1.54",
4
4
  "type": "module",
5
5
  "description": "Command-line front door for GeaStack apps, targets, and local toolchains.",
6
6
  "publishConfig": {
@@ -27,8 +27,7 @@
27
27
  "create-geastack": "bin/create-geastack.mjs"
28
28
  },
29
29
  "dependencies": {
30
- "@geastack/core": "0.1.10",
31
- "@geastack/targets": "0.1.47"
30
+ "serialport": "^13.0.0"
32
31
  },
33
32
  "scripts": {
34
33
  "check": "node --check bin/gea.mjs && node --check bin/create-geastack.mjs && find src test packages -name '*.mjs' -print0 | xargs -0 -n1 node --check",
@@ -0,0 +1,15 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ // apps are discovered by the caller (cli manifest)
5
+
6
+ export function generateAppIndex({ apps, outputFile }) {
7
+ if (!Array.isArray(apps)) throw new Error('generateAppIndex requires apps')
8
+ if (!outputFile) throw new Error('generateAppIndex requires outputFile')
9
+ mkdirSync(path.dirname(outputFile), { recursive: true })
10
+ writeFileSync(
11
+ outputFile,
12
+ `// Auto-generated by gea apps index. Do not edit.\nexport const GEA_APPS = ${JSON.stringify(apps, null, 2)} as const\n`
13
+ )
14
+ return apps
15
+ }
@@ -0,0 +1,147 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { spawnSync } from 'node:child_process'
4
+
5
+ import { ICON_SIZES } from './openai-icons.mjs'
6
+
7
+ export const IOS_APP_ICON_IMAGES = [
8
+ { idiom: 'iphone', size: '20x20', scale: '2x', pixels: 40, filename: 'AppIcon-20@2x.png' },
9
+ { idiom: 'iphone', size: '20x20', scale: '3x', pixels: 60, filename: 'AppIcon-20@3x.png' },
10
+ { idiom: 'iphone', size: '29x29', scale: '2x', pixels: 58, filename: 'AppIcon-29@2x.png' },
11
+ { idiom: 'iphone', size: '29x29', scale: '3x', pixels: 87, filename: 'AppIcon-29@3x.png' },
12
+ { idiom: 'iphone', size: '40x40', scale: '2x', pixels: 80, filename: 'AppIcon-40@2x.png' },
13
+ { idiom: 'iphone', size: '40x40', scale: '3x', pixels: 120, filename: 'AppIcon-40@3x.png' },
14
+ { idiom: 'iphone', size: '60x60', scale: '2x', pixels: 120, filename: 'AppIcon-60@2x.png' },
15
+ { idiom: 'iphone', size: '60x60', scale: '3x', pixels: 180, filename: 'AppIcon-60@3x.png' },
16
+ { idiom: 'ipad', size: '20x20', scale: '1x', pixels: 20, filename: 'AppIcon-iPad-20.png' },
17
+ { idiom: 'ipad', size: '20x20', scale: '2x', pixels: 40, filename: 'AppIcon-iPad-20@2x.png' },
18
+ { idiom: 'ipad', size: '29x29', scale: '1x', pixels: 29, filename: 'AppIcon-iPad-29.png' },
19
+ { idiom: 'ipad', size: '29x29', scale: '2x', pixels: 58, filename: 'AppIcon-iPad-29@2x.png' },
20
+ { idiom: 'ipad', size: '40x40', scale: '1x', pixels: 40, filename: 'AppIcon-iPad-40.png' },
21
+ { idiom: 'ipad', size: '40x40', scale: '2x', pixels: 80, filename: 'AppIcon-iPad-40@2x.png' },
22
+ { idiom: 'ipad', size: '76x76', scale: '1x', pixels: 76, filename: 'AppIcon-iPad-76.png' },
23
+ { idiom: 'ipad', size: '76x76', scale: '2x', pixels: 152, filename: 'AppIcon-iPad-76@2x.png' },
24
+ { idiom: 'ipad', size: '83.5x83.5', scale: '2x', pixels: 167, filename: 'AppIcon-iPad-83.5@2x.png' },
25
+ { idiom: 'ios-marketing', size: '1024x1024', scale: '1x', pixels: 1024, filename: 'AppIcon-1024.png' },
26
+ ]
27
+
28
+ export const MACOS_ICONSET_IMAGES = [
29
+ { filename: 'icon_16x16.png', pixels: 16 },
30
+ { filename: 'icon_16x16@2x.png', pixels: 32 },
31
+ { filename: 'icon_32x32.png', pixels: 32 },
32
+ { filename: 'icon_32x32@2x.png', pixels: 64 },
33
+ { filename: 'icon_128x128.png', pixels: 128 },
34
+ { filename: 'icon_128x128@2x.png', pixels: 256 },
35
+ { filename: 'icon_256x256.png', pixels: 256 },
36
+ { filename: 'icon_256x256@2x.png', pixels: 512 },
37
+ { filename: 'icon_512x512.png', pixels: 512 },
38
+ { filename: 'icon_512x512@2x.png', pixels: 1024 },
39
+ ]
40
+
41
+ export const MACOS_ICNS_CHUNKS = [
42
+ { type: 'icp4', filename: 'icon_16x16.png' },
43
+ { type: 'icp5', filename: 'icon_32x32.png' },
44
+ { type: 'icp6', filename: 'icon_32x32@2x.png' },
45
+ { type: 'ic07', filename: 'icon_128x128.png' },
46
+ { type: 'ic08', filename: 'icon_256x256.png' },
47
+ { type: 'ic09', filename: 'icon_512x512.png' },
48
+ { type: 'ic10', filename: 'icon_512x512@2x.png' },
49
+ ]
50
+
51
+ export function appleIconConfig() {
52
+ return Object.fromEntries(ICON_SIZES.map((size) => [String(size), `icons/icon-${size}.png`]))
53
+ }
54
+
55
+ export function resolveAppleIconSourcePath(repoRoot, app) {
56
+ const sourcePath = path.resolve(repoRoot, app.root, 'icons', 'icon-source.png')
57
+ if (existsSync(sourcePath)) return sourcePath
58
+
59
+ for (const size of ['1024', '512', '256', '128', '64', '32']) {
60
+ const iconPath = app.icons?.[size]
61
+ if (!iconPath) continue
62
+ const resolved = path.resolve(repoRoot, app.root, iconPath)
63
+ if (existsSync(resolved)) return resolved
64
+ }
65
+
66
+ throw new Error(`Missing Apple icon source for ${app.id}: expected ${sourcePath} or a declared package icon`)
67
+ }
68
+
69
+ function resizePng(sourcePath, outputPath, pixels) {
70
+ mkdirSync(path.dirname(outputPath), { recursive: true })
71
+ const result = spawnSync('sips', ['-s', 'format', 'png', '-z', String(pixels), String(pixels), sourcePath, '--out', outputPath], {
72
+ encoding: 'utf8',
73
+ })
74
+ if (result.status !== 0) {
75
+ const detail = result.stderr || result.stdout || `exit ${result.status}`
76
+ throw new Error(`Failed to resize icon ${sourcePath} to ${pixels}x${pixels}: ${detail.trim()}`)
77
+ }
78
+ }
79
+
80
+ export function writeIosAppIconContents(appIconSetDir) {
81
+ const contents = {
82
+ images: IOS_APP_ICON_IMAGES.map(({ idiom, size, scale, filename }) => ({
83
+ idiom,
84
+ size,
85
+ scale,
86
+ filename,
87
+ })),
88
+ info: {
89
+ author: 'xcode',
90
+ version: 1,
91
+ },
92
+ }
93
+ writeFileSync(path.join(appIconSetDir, 'Contents.json'), `${JSON.stringify(contents, null, 2)}\n`)
94
+ }
95
+
96
+ export function prepareIosAppIconAssets({ repoRoot, app, assetsDir }) {
97
+ if (!repoRoot) throw new Error('prepareIosAppIconAssets requires repoRoot')
98
+ if (!app) throw new Error('prepareIosAppIconAssets requires app')
99
+ if (!assetsDir) throw new Error('prepareIosAppIconAssets requires assetsDir')
100
+
101
+ const sourcePath = resolveAppleIconSourcePath(repoRoot, app)
102
+ const appIconSetDir = path.join(assetsDir, 'AppIcon.appiconset')
103
+ rmSync(appIconSetDir, { recursive: true, force: true })
104
+ mkdirSync(appIconSetDir, { recursive: true })
105
+
106
+ for (const image of IOS_APP_ICON_IMAGES) {
107
+ resizePng(sourcePath, path.join(appIconSetDir, image.filename), image.pixels)
108
+ }
109
+ writeIosAppIconContents(appIconSetDir)
110
+ return appIconSetDir
111
+ }
112
+
113
+ export function writeIcnsFromIconset({ iconsetDir, icnsPath }) {
114
+ const chunks = MACOS_ICNS_CHUNKS.map(({ type, filename }) => {
115
+ const data = readFileSync(path.join(iconsetDir, filename))
116
+ const header = Buffer.alloc(8)
117
+ header.write(type, 0, 'ascii')
118
+ header.writeUInt32BE(data.length + 8, 4)
119
+ return Buffer.concat([header, data])
120
+ })
121
+ const length = 8 + chunks.reduce((sum, chunk) => sum + chunk.length, 0)
122
+ const header = Buffer.alloc(8)
123
+ header.write('icns', 0, 'ascii')
124
+ header.writeUInt32BE(length, 4)
125
+ writeFileSync(icnsPath, Buffer.concat([header, ...chunks], length))
126
+ }
127
+
128
+ export function prepareMacosAppIcon({ repoRoot, app, buildDir, resourcesDir }) {
129
+ if (!repoRoot) throw new Error('prepareMacosAppIcon requires repoRoot')
130
+ if (!app) throw new Error('prepareMacosAppIcon requires app')
131
+ if (!buildDir) throw new Error('prepareMacosAppIcon requires buildDir')
132
+ if (!resourcesDir) throw new Error('prepareMacosAppIcon requires resourcesDir')
133
+
134
+ const sourcePath = resolveAppleIconSourcePath(repoRoot, app)
135
+ const iconsetDir = path.join(buildDir, 'AppIcon.iconset')
136
+ const icnsPath = path.join(resourcesDir, 'AppIcon.icns')
137
+ rmSync(iconsetDir, { recursive: true, force: true })
138
+ mkdirSync(iconsetDir, { recursive: true })
139
+ mkdirSync(resourcesDir, { recursive: true })
140
+
141
+ for (const image of MACOS_ICONSET_IMAGES) {
142
+ resizePng(sourcePath, path.join(iconsetDir, image.filename), image.pixels)
143
+ }
144
+
145
+ writeIcnsFromIconset({ iconsetDir, icnsPath })
146
+ return icnsPath
147
+ }
@@ -0,0 +1,156 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import path from 'node:path'
4
+
5
+
6
+ import { writeZip } from './zip-writer.mjs'
7
+
8
+ const FORMAT = 'dev.gea.geaos.app.bundle'
9
+ const FORMAT_VERSION = 1
10
+ const ICON_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg'])
11
+
12
+ function sha256(buffer) {
13
+ return `sha256-${createHash('sha256').update(buffer).digest('hex')}`
14
+ }
15
+
16
+ function targetArtifact(repoRoot, app, target) {
17
+ if (target === 'geaos') {
18
+ return {
19
+ target: 'geaos-armv7',
20
+ sourcePath: path.join(repoRoot, 'targets', 'geaos', 'dist', app.id, app.id),
21
+ bundlePath: `bin/geaos-armv7/${app.id}`,
22
+ executable: true
23
+ }
24
+ }
25
+ throw new Error(`Unsupported bundle target '${target}'`)
26
+ }
27
+
28
+ function readBundleFile(sourcePath, bundlePath, executable = false) {
29
+ if (!existsSync(sourcePath)) throw new Error(`Missing bundle input: ${sourcePath}`)
30
+ return {
31
+ name: bundlePath,
32
+ data: readFileSync(sourcePath),
33
+ executable
34
+ }
35
+ }
36
+
37
+ function pngDimensions(buffer) {
38
+ const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
39
+ if (buffer.length < 24) return null
40
+ if (!signature.every((byte, index) => buffer[index] === byte)) return null
41
+ if (buffer.subarray(12, 16).toString('ascii') !== 'IHDR') return null
42
+ return {
43
+ width: buffer.readUInt32BE(16),
44
+ height: buffer.readUInt32BE(20),
45
+ format: 'png'
46
+ }
47
+ }
48
+
49
+ function jpegDimensions(buffer) {
50
+ if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return null
51
+ let offset = 2
52
+ while (offset + 9 < buffer.length) {
53
+ if (buffer[offset] !== 0xff) {
54
+ offset += 1
55
+ continue
56
+ }
57
+ const marker = buffer[offset + 1]
58
+ offset += 2
59
+ if (marker === 0xd9 || marker === 0xda) break
60
+ if (offset + 2 > buffer.length) break
61
+ const length = buffer.readUInt16BE(offset)
62
+ if (length < 2 || offset + length > buffer.length) break
63
+ const isSof = (marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) ||
64
+ (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf)
65
+ if (isSof && length >= 7) {
66
+ return {
67
+ height: buffer.readUInt16BE(offset + 3),
68
+ width: buffer.readUInt16BE(offset + 5),
69
+ format: 'jpeg'
70
+ }
71
+ }
72
+ offset += length
73
+ }
74
+ return null
75
+ }
76
+
77
+ export function validateIconFile(sourcePath, expectedSize) {
78
+ const ext = path.extname(sourcePath).toLowerCase()
79
+ if (!ICON_EXTENSIONS.has(ext)) {
80
+ throw new Error(`Icon must be a PNG or JPEG: ${sourcePath}`)
81
+ }
82
+ const buffer = readFileSync(sourcePath)
83
+ const dimensions = ext === '.png' ? pngDimensions(buffer) : jpegDimensions(buffer)
84
+ if (!dimensions) throw new Error(`Icon has unsupported or invalid image data: ${sourcePath}`)
85
+ if (dimensions.width !== expectedSize || dimensions.height !== expectedSize) {
86
+ throw new Error(`Icon ${sourcePath} must be ${expectedSize}x${expectedSize}; got ${dimensions.width}x${dimensions.height}`)
87
+ }
88
+ }
89
+
90
+ function iconEntries(repoRoot, app) {
91
+ const entries = []
92
+ for (const [size, iconPath] of Object.entries(app.icons)) {
93
+ const sourcePath = path.resolve(repoRoot, app.root, iconPath)
94
+ validateIconFile(sourcePath, Number(size))
95
+ entries.push(readBundleFile(sourcePath, `icons/icon-${size}${path.extname(iconPath).toLowerCase()}`))
96
+ }
97
+ return entries
98
+ }
99
+
100
+ function packageEntry(repoRoot, app) {
101
+ return readBundleFile(path.resolve(repoRoot, app.root, 'package.json'), 'package.json')
102
+ }
103
+
104
+ export function createGeaBundle({ repoRoot, app, target = 'geaos', outputPath }) {
105
+ if (!repoRoot) throw new Error('createGeaBundle requires repoRoot')
106
+ if (!app) throw new Error('createGeaBundle requires app')
107
+ if (!app.targets?.[target] === true) throw new Error(`App '${app.id}' does not enable target '${target}'`)
108
+ if (!outputPath) throw new Error('createGeaBundle requires outputPath')
109
+
110
+ const artifact = targetArtifact(repoRoot, app, target)
111
+ const payloadEntries = [
112
+ packageEntry(repoRoot, app),
113
+ ...iconEntries(repoRoot, app),
114
+ readBundleFile(artifact.sourcePath, artifact.bundlePath, artifact.executable)
115
+ ]
116
+ const checksums = Object.fromEntries(payloadEntries.map((entry) => [entry.name, sha256(entry.data)]))
117
+ const icons = Object.fromEntries(
118
+ Object.entries(app.icons).map(([size, iconPath]) => [
119
+ size,
120
+ `icons/icon-${size}${path.extname(iconPath).toLowerCase()}`
121
+ ])
122
+ )
123
+
124
+ const manifest = {
125
+ format: FORMAT,
126
+ formatVersion: FORMAT_VERSION,
127
+ createdAt: new Date().toISOString(),
128
+ app: {
129
+ id: app.id,
130
+ name: app.name,
131
+ packageName: app.packageName,
132
+ version: app.version,
133
+ runtime: app.runtime,
134
+ entry: app.entry,
135
+ launcher: app.launcher
136
+ },
137
+ icons,
138
+ artifacts: [
139
+ {
140
+ target: artifact.target,
141
+ path: artifact.bundlePath,
142
+ executable: artifact.executable
143
+ }
144
+ ],
145
+ checksums
146
+ }
147
+
148
+ writeZip(outputPath, [
149
+ {
150
+ name: 'gea-bundle.json',
151
+ data: `${JSON.stringify(manifest, null, 2)}\n`
152
+ },
153
+ ...payloadEntries
154
+ ])
155
+ return manifest
156
+ }
@@ -0,0 +1,114 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ // apps are discovered by the caller (cli manifest)
5
+
6
+ function q(value) {
7
+ return JSON.stringify(String(value ?? ''))
8
+ }
9
+
10
+ function jsxText(value) {
11
+ return String(value ?? '')
12
+ .replace(/&/g, '&amp;')
13
+ .replace(/</g, '&lt;')
14
+ .replace(/>/g, '&gt;')
15
+ .replace(/\{/g, '&#123;')
16
+ .replace(/\}/g, '&#125;')
17
+ }
18
+
19
+ function readJson(file) {
20
+ return JSON.parse(readFileSync(file, 'utf8'))
21
+ }
22
+
23
+ function preferredIcon(app) {
24
+ for (const size of ['64', '128', '32', '256', '512']) {
25
+ if (app.icons[size]) return { size, file: app.icons[size] }
26
+ }
27
+ return null
28
+ }
29
+
30
+ function configuredLauncherAppIds(repoRoot) {
31
+ const manifestPath = path.join(repoRoot, 'apps', 'app-launcher', 'package.json')
32
+ if (!existsSync(manifestPath)) return null
33
+ const manifest = readJson(manifestPath)
34
+ const ids = manifest?.gea?.launcherCatalog?.appIds
35
+ return Array.isArray(ids) ? ids.map((id) => String(id).trim()).filter(Boolean) : null
36
+ }
37
+
38
+ function orderedIncludeFilter(apps, includeAppIds) {
39
+ if (!includeAppIds || includeAppIds.length === 0) return apps
40
+ const byId = new Map(apps.map((app) => [app.id, app]))
41
+ return includeAppIds.map((id) => byId.get(id)).filter(Boolean)
42
+ }
43
+
44
+ function launcherApps(allApps, target, includeAppIds = null) {
45
+ const apps = allApps
46
+ .filter((app) => app.runtime === 'gea')
47
+ .filter((app) => app.targets?.[target] === true)
48
+ .filter((app) => app.launcher.hidden !== true)
49
+ .sort((a, b) => {
50
+ const order = a.launcher.order - b.launcher.order
51
+ return order || a.name.localeCompare(b.name)
52
+ })
53
+ return orderedIncludeFilter(apps, includeAppIds)
54
+ }
55
+
56
+ function copyLauncherIcon({ repoRoot, app, outputFile }) {
57
+ const icon = preferredIcon(app)
58
+ if (!icon) return ''
59
+ const sourcePath = path.resolve(repoRoot, app.root, icon.file)
60
+ if (!existsSync(sourcePath)) return ''
61
+ const iconsDir = path.join(path.dirname(outputFile), 'icons')
62
+ mkdirSync(iconsDir, { recursive: true })
63
+ const ext = path.extname(icon.file).toLowerCase() || '.png'
64
+ const outputName = `${app.id}-${icon.size}${ext}`
65
+ copyFileSync(sourcePath, path.join(iconsDir, outputName))
66
+ return `generated/icons/${outputName}`
67
+ }
68
+
69
+ export function generateLauncherCatalog({ repoRoot, apps: allApps, target = 'geaos', outputFile }) {
70
+ if (!repoRoot) throw new Error('generateLauncherCatalog requires repoRoot')
71
+ if (!Array.isArray(allApps)) throw new Error('generateLauncherCatalog requires apps')
72
+ if (!outputFile) throw new Error('generateLauncherCatalog requires outputFile')
73
+ const includeAppIds = configuredLauncherAppIds(repoRoot)
74
+ const apps = launcherApps(allApps, target, includeAppIds)
75
+ const generatedDir = path.dirname(outputFile)
76
+ rmSync(path.join(generatedDir, 'icons'), { recursive: true, force: true })
77
+ mkdirSync(generatedDir, { recursive: true })
78
+
79
+ const lines = [
80
+ '// Auto-generated by gea apps launcher. Do not edit.',
81
+ "import { Apps } from '@geastack/core'",
82
+ "import '../components/LauncherButton.css'",
83
+ '',
84
+ 'export function LauncherCatalog() {',
85
+ ' return (',
86
+ ' <div class="launcher-list" momentum>'
87
+ ]
88
+
89
+ for (const app of apps) {
90
+ const iconSrc = copyLauncherIcon({ repoRoot, app, outputFile })
91
+ const style = app.launcher.accent ? ` style={{ borderColor: ${q(app.launcher.accent)} }}` : ''
92
+ lines.push(` <div class="launcher-card"${style} onClick={() => Apps.launch(${q(app.id)})}>`)
93
+ if (iconSrc) {
94
+ lines.push(` <img class="launcher-card-icon" src=${q(iconSrc)} />`)
95
+ } else {
96
+ lines.push(' <div class="launcher-card-icon launcher-card-icon-placeholder" />')
97
+ }
98
+ lines.push(
99
+ ' <div class="launcher-card-copy">',
100
+ ` <span class="launcher-card-title">${jsxText(app.name)}</span>`,
101
+ ' </div>',
102
+ ' </div>'
103
+ )
104
+ }
105
+
106
+ lines.push(
107
+ ' </div>',
108
+ ' )',
109
+ '}',
110
+ ''
111
+ )
112
+ writeFileSync(outputFile, `${lines.join('\n')}`)
113
+ return apps
114
+ }