@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
@@ -0,0 +1,252 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { existsSync, readdirSync, realpathSync } from 'node:fs'
3
+ import path from 'node:path'
4
+
5
+ // A board is identified by its USB SERIAL, never by a /dev path: enumerated
6
+ // paths change on every plug-in and identical boards routinely share one.
7
+ // Everything here maps a stable serial to whatever port the OS gave it today.
8
+
9
+ const serialPatterns = [
10
+ /^cu\.usbmodem/,
11
+ /^tty\.usbmodem/,
12
+ /^cu\.usbserial/,
13
+ /^tty\.usbserial/,
14
+ /^cu\.SLAB_USBtoUART/,
15
+ /^tty\.SLAB_USBtoUART/,
16
+ /^ttyACM/,
17
+ /^ttyUSB/
18
+ ]
19
+
20
+ export function serialPortCandidates() {
21
+ const dev = '/dev'
22
+ if (!existsSync(dev)) return []
23
+ return readdirSync(dev)
24
+ .filter((name) => serialPatterns.some((pattern) => pattern.test(name)))
25
+ .map((name) => path.join(dev, name))
26
+ .sort()
27
+ }
28
+
29
+ export function resolveAutoUsbPort() {
30
+ const candidates = serialPortCandidates()
31
+ const callout = candidates.filter((candidate) => path.basename(candidate).startsWith('cu.'))
32
+ const usable = callout.length > 0 ? callout : candidates
33
+ if (usable.length <= 1) return usable[0] || ''
34
+ throw new Error(
35
+ ['Multiple USB serial ports are present; use --board <alias> with a registered USB serial.', ...usable.map((c) => ` ${c}`)].join('\n')
36
+ )
37
+ }
38
+
39
+ export function normalizedSerial(value) {
40
+ return String(value || '').replace(/[^a-z0-9]/gi, '').toLowerCase()
41
+ }
42
+
43
+ function linuxSerialByIdCandidates(serial) {
44
+ const dir = '/dev/serial/by-id'
45
+ if (!existsSync(dir)) return []
46
+ const needle = normalizedSerial(serial)
47
+ return readdirSync(dir)
48
+ .filter((name) => normalizedSerial(name).includes(needle))
49
+ .map((name) => path.join(dir, name))
50
+ }
51
+
52
+ function ioregStringProperty(line, key) {
53
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
54
+ return line.match(new RegExp(`"${escaped}"\\s*=\\s*"([^"]*)"`))?.[1] || ''
55
+ }
56
+
57
+ function ioregNumberProperty(line, key) {
58
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
59
+ const value = line.match(new RegExp(`"${escaped}"\\s*=\\s*(\\d+)`))?.[1] || ''
60
+ return value ? Number(value) : 0
61
+ }
62
+
63
+ function macUsbDeviceForSerial(serial, ioreg = runIoreg) {
64
+ const output = ioreg(['-p', 'IOUSB', '-l', '-w0'])
65
+ let current = null
66
+ const devices = []
67
+ for (const line of output.split(/\r?\n/)) {
68
+ // One `| ` per nesting level -- devices behind hubs carry several.
69
+ const node = line.match(/^([\s|]*)[+\\-]*o .*@([0-9a-fA-F]+)\s+<class IOUSBHostDevice/)
70
+ if (node) {
71
+ if (current) devices.push(current)
72
+ current = { locationHex: node[2], text: line }
73
+ continue
74
+ }
75
+ if (current) current.text += `\n${line}`
76
+ }
77
+ if (current) devices.push(current)
78
+ const needle = normalizedSerial(serial)
79
+ return devices.find((device) =>
80
+ device.text.split(/\r?\n/).some((line) => {
81
+ const value = ioregStringProperty(line, 'kUSBSerialNumberString') || ioregStringProperty(line, 'USB Serial Number')
82
+ return value && normalizedSerial(value) === needle
83
+ })
84
+ ) || null
85
+ }
86
+
87
+ function locationDigits(locationHex) {
88
+ return locationHex.toLowerCase().replace(/^0+/, '').replace(/0+$/, '').replace(/[^0-9a-f]/g, '')
89
+ }
90
+
91
+ function macUsbCalloutPortsForSerial(serial, ioreg = runIoreg) {
92
+ const output = ioreg(['-p', 'IOService', '-l', '-w0'], { maxBuffer: 64 * 1024 * 1024 })
93
+ const stack = []
94
+ const matches = new Set()
95
+ const needle = normalizedSerial(serial)
96
+ for (const line of output.split(/\r?\n/)) {
97
+ const node = line.match(/^([\s|]*)[+\\-]*o\s+/)
98
+ if (node) {
99
+ const depth = (node[1].match(/\|/g) || []).length
100
+ while (stack.length > 0 && stack[stack.length - 1].depth >= depth) stack.pop()
101
+ stack.push({ depth, serial: '' })
102
+ continue
103
+ }
104
+ if (stack.length === 0) continue
105
+ const current = stack[stack.length - 1]
106
+ const serialValue = ioregStringProperty(line, 'kUSBSerialNumberString') || ioregStringProperty(line, 'USB Serial Number')
107
+ if (serialValue) current.serial = serialValue
108
+ const callout = ioregStringProperty(line, 'IOCalloutDevice')
109
+ if (!callout || !path.basename(callout).startsWith('cu.')) continue
110
+ const inherited = [...stack].reverse().find((entry) => entry.serial)?.serial || ''
111
+ if (normalizedSerial(inherited) === needle) matches.add(callout)
112
+ }
113
+ return [...matches].sort()
114
+ }
115
+
116
+ // Every USB callout port the registry knows, with the serial and product
117
+ // name inherited from the enclosing USB device: the inverse of
118
+ // macUsbCalloutPortsForSerial, for `gea boards discover` and the setup
119
+ // wizard. The /dev name carries no identity (the usbmodem number changes on
120
+ // every enumeration and identical boards share it), so this is the only
121
+ // place a port's serial can come from on macOS.
122
+ export function listMacUsbCalloutPorts(ioreg = runIoreg) {
123
+ let output = ''
124
+ try {
125
+ output = ioreg(['-p', 'IOService', '-l', '-w0'], { maxBuffer: 64 * 1024 * 1024 })
126
+ } catch {
127
+ return []
128
+ }
129
+ const stack = []
130
+ const ports = new Map()
131
+ for (const line of output.split(/\r?\n/)) {
132
+ const node = line.match(/^([\s|]*)[+\\-]*o\s+/)
133
+ if (node) {
134
+ const depth = (node[1].match(/\|/g) || []).length
135
+ while (stack.length > 0 && stack[stack.length - 1].depth >= depth) stack.pop()
136
+ stack.push({ depth, serial: '', product: '' })
137
+ continue
138
+ }
139
+ if (stack.length === 0) continue
140
+ const current = stack[stack.length - 1]
141
+ const serialValue = ioregStringProperty(line, 'kUSBSerialNumberString') || ioregStringProperty(line, 'USB Serial Number')
142
+ if (serialValue) current.serial = serialValue
143
+ const productValue = ioregStringProperty(line, 'kUSBProductString') || ioregStringProperty(line, 'USB Product Name')
144
+ if (productValue) current.product = productValue
145
+ const callout = ioregStringProperty(line, 'IOCalloutDevice')
146
+ if (!callout || !path.basename(callout).startsWith('cu.')) continue
147
+ // A callout with no USB serial above it is not a USB device at all
148
+ // (Bluetooth SPP, the debug console); probing those is a wasted timeout.
149
+ const serial = [...stack].reverse().find((entry) => entry.serial)?.serial || ''
150
+ if (!serial) continue
151
+ const label = [...stack].reverse().find((entry) => entry.product)?.product || ''
152
+ ports.set(callout, { path: callout, serial, label })
153
+ }
154
+ return [...ports.values()].sort((a, b) => a.path.localeCompare(b.path))
155
+ }
156
+
157
+ function runIoreg(args, options = {}) {
158
+ return execFileSync('ioreg', args, { encoding: 'utf8', ...options })
159
+ }
160
+
161
+ function resolveMacUsbSerialPort(serial, ioreg = runIoreg) {
162
+ const candidates = serialPortCandidates().filter((candidate) => path.basename(candidate).startsWith('cu.'))
163
+ const needle = normalizedSerial(serial)
164
+ const serialMatches = candidates.filter((candidate) => normalizedSerial(path.basename(candidate)).includes(needle))
165
+ if (serialMatches.length === 1) return serialMatches[0]
166
+
167
+ const location = macUsbDeviceForSerial(serial, ioreg)?.locationHex || ''
168
+ const digits = locationDigits(location)
169
+ if (digits) {
170
+ const locationMatches = candidates.filter((candidate) => normalizedSerial(path.basename(candidate)).includes(digits))
171
+ if (locationMatches.length === 1) return locationMatches[0]
172
+ const registryMatches = macUsbCalloutPortsForSerial(serial, ioreg)
173
+ if (registryMatches.length === 1) return registryMatches[0]
174
+ if (registryMatches.length > 1) {
175
+ throw new Error([`USB serial ${serial} maps to multiple /dev/cu.* ports:`, ...registryMatches.map((c) => ` ${c}`)].join('\n'))
176
+ }
177
+ }
178
+ throw new Error(`Could not map USB serial ${serial} to a /dev/cu.* port. Check that the board is attached.`)
179
+ }
180
+
181
+ export function resolveUsbSerialPort({ serial }, { platform = process.platform, ioreg = runIoreg } = {}) {
182
+ if (!serial) throw new Error('A USB serial number is required to locate the board; /dev paths are not accepted.')
183
+ if (platform === 'linux') {
184
+ const matches = linuxSerialByIdCandidates(serial)
185
+ if (matches.length === 1) return realpathSync(matches[0])
186
+ throw new Error(`Could not map USB serial ${serial} to a /dev/serial/by-id entry. Check that the board is attached.`)
187
+ }
188
+ if (platform === 'darwin') return resolveMacUsbSerialPort(serial, ioreg)
189
+ throw new Error(`USB serial lookup is not implemented on ${platform}.`)
190
+ }
191
+
192
+ // picotool selects by bus/address on macOS (its --ser matching is unreliable
193
+ // there); everywhere else the serial itself is the selector.
194
+ export function resolvePicotoolSelection({ serial }, { platform = process.platform, ioreg = runIoreg } = {}) {
195
+ if (!serial) return []
196
+ if (platform !== 'darwin') return ['--ser', serial]
197
+ let device = null
198
+ try {
199
+ device = macUsbDeviceForSerial(serial, ioreg)
200
+ } catch {
201
+ device = null
202
+ }
203
+ if (!device?.locationHex) return ['--ser', serial]
204
+ const bus = Number.parseInt(device.locationHex.slice(0, 2), 16)
205
+ const address = device.text
206
+ .split(/\r?\n/)
207
+ .map((line) => ioregNumberProperty(line, 'USB Address') || ioregNumberProperty(line, 'kUSBAddress'))
208
+ .find(Boolean) || 0
209
+ if (!bus || !address) return ['--ser', serial]
210
+ return ['--bus', String(bus), '--address', String(address)]
211
+ }
212
+
213
+ // Poll until the board's port exists. A board reboots (and re-enumerates)
214
+ // after a flash, so callers retry on the SERIAL, not on a cached path.
215
+ export async function waitForSerialPort({
216
+ port = '',
217
+ serial = '',
218
+ timeoutSeconds = 300,
219
+ label = 'USB serial port',
220
+ pollSeconds = 1,
221
+ log = (line) => process.stderr.write(`${line}\n`),
222
+ resolver = resolveUsbSerialPort
223
+ } = {}) {
224
+ const startedAt = Date.now()
225
+ let nextLog = startedAt
226
+ while (true) {
227
+ if (port) {
228
+ if (existsSync(port)) return port
229
+ } else if (serial) {
230
+ try {
231
+ const resolved = resolver({ serial })
232
+ if (resolved && existsSync(resolved)) return resolved
233
+ } catch {
234
+ // not attached yet
235
+ }
236
+ } else {
237
+ const candidate = serialPortCandidates()[0]
238
+ if (candidate) return candidate
239
+ }
240
+ const elapsed = (Date.now() - startedAt) / 1000
241
+ if (timeoutSeconds > 0 && elapsed >= timeoutSeconds) {
242
+ const where = port ? ` at ${port}` : serial ? ` with USB serial ${serial}` : ''
243
+ throw new Error(`Timed out waiting for ${label}${where}.`)
244
+ }
245
+ if (Date.now() >= nextLog) {
246
+ const where = port ? ` at ${port}` : serial ? ` with USB serial ${serial}` : ''
247
+ log(`Waiting for ${label}${where}...`)
248
+ nextLog = Date.now() + 5000
249
+ }
250
+ await new Promise((resolve) => setTimeout(resolve, pollSeconds * 1000))
251
+ }
252
+ }
package/src/chips.mjs CHANGED
@@ -142,7 +142,7 @@ export function resolveTargetDefinition(ctx, parsed) {
142
142
  const configPath = option(parsed, 'boards-config')
143
143
  ? path.resolve(ctx.cwd, option(parsed, 'boards-config'))
144
144
  : ctx.projectBoardsConfig
145
- if (!configPath || !exists(configPath)) fail('No project .gea/boards.json was found. Run gea setup and create a custom board first.', ExitCode.usage)
145
+ if (!configPath || !exists(configPath)) fail(`No board config was found at ${configPath || '.gea/boards.json'}. Run gea setup and create a custom board first.`, ExitCode.usage)
146
146
  const boards = readJson(configPath)
147
147
  const requested = option(parsed, 'board', '')
148
148
  const customBoards = Object.entries(boards).filter(([, entry]) => typeof entry?.targetDefinition === 'string' && entry.targetDefinition)
@@ -0,0 +1,204 @@
1
+ import { readdirSync } from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import { flag, option, optionList } from '../args.mjs'
5
+ import { loadTargets } from '../boards/targets.mjs'
6
+ import { ExitCode, fail } from '../errors.mjs'
7
+ import { exists } from '../fs-utils.mjs'
8
+ import { appCmakeMeta, appSummary, assertValidApp, discoverApps, resolveRequestedApp } from '../manifest.mjs'
9
+
10
+ // gea apps ... : the app catalog and everything generated from it.
11
+
12
+ export async function appsCommand(ctx, parsed, rest, options) {
13
+ const sub = rest[0] || 'list'
14
+ const args = rest.slice(1)
15
+ switch (sub) {
16
+ case 'list': return listApps(ctx, parsed, options)
17
+ case 'inspect': return inspectApp(ctx, parsed, args, options)
18
+ case 'pack': return packApp(ctx, parsed, args, options)
19
+ case 'index': return appIndex(ctx, parsed, options)
20
+ case 'launcher': return launcher(ctx, parsed, options)
21
+ case 'icons': return icons(ctx, parsed, args, options)
22
+ case 'icon-sheet': return iconSheet(ctx, parsed, options)
23
+ case 'apple-icons': return appleIcons(ctx, parsed, args, options)
24
+ default:
25
+ fail(`Unknown apps subcommand '${sub}'. Expected list, inspect, pack, index, launcher, icons, icon-sheet, or apple-icons.`, ExitCode.usage)
26
+ }
27
+ }
28
+
29
+ function listApps(ctx, parsed, options) {
30
+ const target = option(parsed, 'target', '')
31
+ const apps = discoverApps(ctx).filter((app) => !target || app.targets?.[target] === true)
32
+ if (flag(parsed, 'json')) options.stdout(JSON.stringify(apps.map((app) => appSummary(ctx, app)), null, 2))
33
+ else for (const app of apps) options.stdout(app.id)
34
+ return 0
35
+ }
36
+
37
+ function inspectApp(ctx, parsed, args, options) {
38
+ const app = resolveRequestedApp(ctx, parsed, args)
39
+ assertValidApp(app)
40
+ const format = option(parsed, 'format', flag(parsed, 'json') ? 'json' : 'text')
41
+ const summary = appSummary(ctx, app)
42
+ if (format === 'cmake') options.stdout(appCmakeMeta(ctx, app))
43
+ else if (format === 'shell') options.stdout(`${summary.root}\t${app.entry}\t${app.runtime}\t${app.name}`)
44
+ else if (format === 'json') options.stdout(JSON.stringify(summary, null, 2))
45
+ else options.stdout(`${app.id}\t${app.name}\t${summary.root}\t${app.entry}`)
46
+ return 0
47
+ }
48
+
49
+ async function packApp(ctx, parsed, args, options) {
50
+ const app = resolveRequestedApp(ctx, parsed, args)
51
+ assertValidApp(app)
52
+ const target = option(parsed, 'target', 'geaos')
53
+ const { createGeaBundle } = await import('../apps/bundle-writer.mjs')
54
+ const out = path.resolve(ctx.cwd, option(parsed, 'out', path.join(ctx.projectRoot, 'dist', `${app.id}-${target}.gea.zip`)))
55
+ const artifactDir = option(parsed, 'artifact-dir', '')
56
+ const manifest = createGeaBundle({ repoRoot: ctx.projectRoot, app, target, outputPath: out, artifactDir: artifactDir ? path.resolve(ctx.cwd, artifactDir) : '' })
57
+ options.stdout(flag(parsed, 'json') ? JSON.stringify({ outputPath: out, manifest }, null, 2) : out)
58
+ return 0
59
+ }
60
+
61
+ async function appIndex(ctx, parsed, options) {
62
+ const { generateAppIndex } = await import('../apps/app-index-writer.mjs')
63
+ const outputFile = path.resolve(ctx.cwd, option(parsed, 'out', path.join(ctx.projectRoot, 'simulator', 'src', 'generated', 'app-index.ts')))
64
+ const apps = generateAppIndex({ apps: discoverApps(ctx).map((app) => appSummary(ctx, app)), outputFile })
65
+ options.stdout(flag(parsed, 'json') ? JSON.stringify({ outputFile, apps }, null, 2) : outputFile)
66
+ return 0
67
+ }
68
+
69
+ async function launcher(ctx, parsed, options) {
70
+ const { generateLauncherCatalog } = await import('../apps/launcher-catalog.mjs')
71
+ const target = option(parsed, 'target', 'geaos')
72
+ const outputFile = path.resolve(ctx.cwd, option(parsed, 'out', path.join(ctx.projectRoot, 'apps', 'app-launcher', 'generated', 'LauncherCatalog.tsx')))
73
+ const apps = generateLauncherCatalog({ repoRoot: ctx.projectRoot, apps: discoverApps(ctx), target, outputFile })
74
+ options.stdout(flag(parsed, 'json') ? JSON.stringify({ outputFile, apps: apps.map((app) => appSummary(ctx, app)) }, null, 2) : outputFile)
75
+ return 0
76
+ }
77
+
78
+ async function icons(ctx, parsed, args, options) {
79
+ const requested = args[0] || option(parsed, 'app', '')
80
+ if (!requested) fail('gea apps icons <app-id|all> [--target <t>] [--model <m>] [--concurrency <n>] [--exclude a,b]', ExitCode.usage)
81
+ const { generateIconSets } = await import('../apps/openai-icons.mjs')
82
+ const excluded = new Set(optionList(parsed, 'exclude'))
83
+ const iconTarget = option(parsed, 'target', '')
84
+ const all = discoverApps(ctx)
85
+ const apps = requested === 'all'
86
+ ? all
87
+ .filter((app) => iconTarget && iconTarget !== 'all'
88
+ ? app.targets?.[iconTarget] === true
89
+ : ['geaos', 'esp32', 'ios', 'macos'].some((candidate) => app.targets?.[candidate] === true))
90
+ .filter((app) => !excluded.has(app.id))
91
+ : [resolveRequestedApp(ctx, parsed, [requested])]
92
+ await generateIconSets({
93
+ repoRoot: ctx.projectRoot,
94
+ apps,
95
+ model: option(parsed, 'model', ctx.env.GEA_OPENAI_IMAGE_MODEL || 'gpt-image-2'),
96
+ concurrency: Math.max(1, Number.parseInt(option(parsed, 'concurrency', '1'), 10) || 1)
97
+ })
98
+ return 0
99
+ }
100
+
101
+ // The Apple build scripts call this per app: the iOS generator wants an
102
+ // AppIcon.appiconset inside its asset catalog, the macOS bundle wants an
103
+ // AppIcon.icns in Resources. --signature prints a digest of every input the
104
+ // icon depends on so the scripts can skip regeneration when nothing changed.
105
+ async function appleIcons(ctx, parsed, args, options) {
106
+ const usage = 'gea apps apple-icons <app-id> --platform ios --assets-dir <dir> | --platform macos --build-dir <dir> --resources-dir <dir> [--signature]'
107
+ const app = resolveRequestedApp(ctx, parsed, args)
108
+ assertValidApp(app)
109
+ const platform = option(parsed, 'platform', '')
110
+ if (platform !== 'ios' && platform !== 'macos') fail(usage, ExitCode.usage)
111
+ const icons = await import('../apps/apple-icons.mjs')
112
+ const repoRoot = ctx.projectRoot
113
+ if (flag(parsed, 'signature')) {
114
+ const { createHash } = await import('node:crypto')
115
+ const { readFileSync } = await import('node:fs')
116
+ const hash = createHash('sha256')
117
+ const addFile = (file) => {
118
+ hash.update(file)
119
+ hash.update('\0')
120
+ hash.update(readFileSync(file))
121
+ hash.update('\0')
122
+ }
123
+ addFile(path.join(ctx.cliPackageRoot, 'src', 'apps', 'apple-icons.mjs'))
124
+ hash.update(JSON.stringify({ id: app.id, root: appSummary(ctx, app).root, icons: app.icons || {}, platform }))
125
+ addFile(icons.resolveAppleIconSourcePath(repoRoot, app))
126
+ options.stdout(hash.digest('hex'))
127
+ return 0
128
+ }
129
+ if (platform === 'ios') {
130
+ const assetsDir = option(parsed, 'assets-dir', '')
131
+ if (!assetsDir) fail(usage, ExitCode.usage)
132
+ options.stdout(icons.prepareIosAppIconAssets({ repoRoot, app, assetsDir: path.resolve(ctx.cwd, assetsDir) }))
133
+ return 0
134
+ }
135
+ const buildDir = option(parsed, 'build-dir', '')
136
+ const resourcesDir = option(parsed, 'resources-dir', '')
137
+ if (!buildDir || !resourcesDir) fail(usage, ExitCode.usage)
138
+ options.stdout(icons.prepareMacosAppIcon({ repoRoot, app, buildDir: path.resolve(ctx.cwd, buildDir), resourcesDir: path.resolve(ctx.cwd, resourcesDir) }))
139
+ return 0
140
+ }
141
+
142
+ async function iconSheet(ctx, parsed, options) {
143
+ const { generateIconStyleSheet } = await import('../apps/openai-icons.mjs')
144
+ const excluded = new Set(optionList(parsed, 'exclude'))
145
+ const iconTarget = option(parsed, 'target', 'geaos')
146
+ const outputPath = path.resolve(ctx.cwd, option(parsed, 'out', path.join(ctx.projectRoot, 'docs', 'icon-style-preview', `${iconTarget}-icon-style-sheet.png`)))
147
+ const apps = discoverApps(ctx)
148
+ .filter((app) => iconTarget === 'all' || app.targets?.[iconTarget] === true)
149
+ .filter((app) => flag(parsed, 'include-hidden') || !app.launcher.hidden)
150
+ .filter((app) => app.icons && Object.keys(app.icons).length > 0)
151
+ .filter((app) => !excluded.has(app.id))
152
+ if (apps.length === 0) fail(`No apps matched target '${iconTarget}'`, ExitCode.usage)
153
+ const sheet = await generateIconStyleSheet({
154
+ apps,
155
+ model: option(parsed, 'model', ctx.env.GEA_OPENAI_IMAGE_MODEL || 'gpt-image-2'),
156
+ outputPath,
157
+ columns: Math.max(1, Number.parseInt(option(parsed, 'columns', '5'), 10) || 5)
158
+ })
159
+ options.stdout(flag(parsed, 'json') ? JSON.stringify({ outputPath: sheet.outputPath, apps: apps.map((app) => appSummary(ctx, app)) }, null, 2) : sheet.outputPath)
160
+ return 0
161
+ }
162
+
163
+ // gea targets ...
164
+
165
+ export function targetsCommand(ctx, parsed, rest, options) {
166
+ const sub = rest[0] || 'list'
167
+ let targets
168
+ try {
169
+ targets = loadTargets(ctx)
170
+ } catch (error) {
171
+ fail(error.message, ExitCode.missingDependency)
172
+ }
173
+ if (sub === 'list') {
174
+ if (flag(parsed, 'json')) options.stdout(JSON.stringify(targets, null, 2))
175
+ else for (const id of Object.keys(targets).sort()) options.stdout(`${id}\t${targets[id].adapter}\t${exists(targets[id].targetDir) ? targets[id].targetDir : '(not installed)'}`)
176
+ return 0
177
+ }
178
+ if (sub === 'show') {
179
+ const id = rest[1] || ''
180
+ if (!targets[id]) fail(`Unknown target '${id}'. Run gea targets list.`, ExitCode.usage)
181
+ options.stdout(JSON.stringify(targets[id], null, 2))
182
+ return 0
183
+ }
184
+ fail(`Unknown targets subcommand '${sub}'. Expected list or show.`, ExitCode.usage)
185
+ }
186
+
187
+ export async function heapReportCommand(ctx, parsed, rest, options) {
188
+ const { DEFAULT_TITLE, parseLogs, renderHtml } = await import('../heap-report.mjs')
189
+ const { mkdirSync, writeFileSync } = await import('node:fs')
190
+ let inputs = rest.map((file) => path.resolve(ctx.cwd, file))
191
+ const logsDir = path.join(ctx.projectRoot, 'docs', 'esp32-perf-logs')
192
+ if (inputs.length === 0 && exists(logsDir)) {
193
+ inputs = readdirSync(logsDir).filter((name) => /\.(txt|log)$/.test(name)).map((name) => path.join(logsDir, name))
194
+ }
195
+ if (inputs.length === 0) fail('No heap log inputs found. Pass log files or place .txt/.log files in docs/esp32-perf-logs/.', ExitCode.usage)
196
+ const out = path.resolve(ctx.cwd, option(parsed, 'out', path.join(logsDir, 'heap-map-report.html')))
197
+ const maps = optionList(parsed, 'map').map((file) => path.resolve(ctx.cwd, file))
198
+ const data = parseLogs(inputs, maps)
199
+ mkdirSync(path.dirname(out), { recursive: true })
200
+ writeFileSync(out, renderHtml(data, option(parsed, 'title', DEFAULT_TITLE)))
201
+ options.stdout(`Wrote heap map report: ${out}`)
202
+ options.stdout(`Parsed ${data.snapshots.length} heap-map snapshots, ${data.probes.length} probes, ${data.liveAllocations.length} live allocations, ${data.linkerMaps.length} linker map(s)`)
203
+ return 0
204
+ }