@geastack/cli 0.1.52 → 0.1.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/SPEC.md CHANGED
@@ -228,8 +228,8 @@ Use predictable exit codes:
228
228
  1. Implement `gea doctor` for toolchain discovery and app manifest validation.
229
229
  2. Implement `gea dev --target web` by delegating to the simulator repo.
230
230
  3. Implement `gea build --target web`.
231
- 4. Implement `gea flash --board <alias>` by delegating to
232
- `targets/scripts/board`.
231
+ 4. Implement `gea flash --board <alias>` natively (ESP-IDF, esptool, OTA and
232
+ device transports live in `src/esp32` and `src/device`).
233
233
  5. Add `create-geastack` with one JSX app template.
234
234
 
235
235
  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.53",
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
+ }