@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 +2 -2
- package/package.json +2 -3
- package/src/apps/app-index-writer.mjs +15 -0
- package/src/apps/apple-icons.mjs +147 -0
- package/src/apps/bundle-writer.mjs +156 -0
- package/src/apps/launcher-catalog.mjs +114 -0
- package/src/apps/openai-icons.mjs +356 -0
- package/src/apps/zip-writer.mjs +104 -0
- package/src/ble/ble-ota.swift +291 -0
- package/src/boards/config.mjs +32 -0
- package/src/boards/custom-target.mjs +309 -0
- package/src/boards/resolve.mjs +143 -0
- package/src/boards/targets.mjs +45 -0
- package/src/boards/usb.mjs +211 -0
- package/src/commands/apps.mjs +236 -0
- package/src/commands/board.mjs +402 -0
- package/src/commands/doctor.mjs +91 -0
- package/src/context.mjs +45 -52
- package/src/device/device.mjs +96 -0
- package/src/device/image.mjs +115 -0
- package/src/device/serial.mjs +430 -0
- package/src/device/wifi.mjs +190 -0
- package/src/esp32/build.mjs +321 -0
- package/src/esp32/capabilities.mjs +54 -0
- package/src/esp32/flash.mjs +181 -0
- package/src/esp32/idf-env.mjs +171 -0
- package/src/esp32/ota.mjs +80 -0
- package/src/esp32/partitions.mjs +59 -0
- package/src/esp32/sdkconfig.mjs +103 -0
- package/src/esp32/wifi-config.mjs +72 -0
- package/src/gea.mjs +86 -427
- package/src/geaos/adapter.mjs +119 -0
- package/src/heap-report.mjs +1276 -0
- package/src/manifest.mjs +135 -36
- package/src/rp2350/adapter.mjs +155 -0
- package/src/setup-wizard.mjs +6 -10
- package/src/taurus/adapter.mjs +52 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { loadBoardConfig, normalizeBoardConfig } from './config.mjs'
|
|
5
|
+
import { loadTargets } from './targets.mjs'
|
|
6
|
+
import { resolveUsbSerialPort } from './usb.mjs'
|
|
7
|
+
|
|
8
|
+
export const usbSerialAdapters = new Set(['esp32-idf', 'rp2350-pico', 'taurus-s3'])
|
|
9
|
+
export const geaosAdapters = new Set(['geaos-linux', 'geaos-arm64'])
|
|
10
|
+
|
|
11
|
+
function boardTransport(board, name) {
|
|
12
|
+
return board?.transports?.[name] || {}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isAuto(value) {
|
|
16
|
+
return !value || /^auto$/i.test(value) || value === '<auto>'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Turns a board alias (or a bare target id) into everything a command needs:
|
|
20
|
+
// the target project directory, its adapter, chip/flash metadata and the
|
|
21
|
+
// transports the command asked for. `needs.usbPort` resolves the board's USB
|
|
22
|
+
// serial to today's /dev port; `needs.otaHost` resolves transports.ota.host.
|
|
23
|
+
// Commands state what they need instead of the resolver guessing from names.
|
|
24
|
+
export function resolveBoardSelection({
|
|
25
|
+
ctx = null,
|
|
26
|
+
boardName = '',
|
|
27
|
+
targetName = '',
|
|
28
|
+
requestedPort = '',
|
|
29
|
+
requestedHost = '',
|
|
30
|
+
needs = {},
|
|
31
|
+
targets = ctx ? loadTargets(ctx) : {},
|
|
32
|
+
config = ctx ? loadBoardConfig(ctx) : {},
|
|
33
|
+
configDir = ctx ? (ctx.boardsConfig ? path.dirname(ctx.boardsConfig) : path.dirname(ctx.projectBoardsConfig)) : process.cwd(),
|
|
34
|
+
usbSerialResolver = resolveUsbSerialPort,
|
|
35
|
+
deferUsbPort = false
|
|
36
|
+
} = {}) {
|
|
37
|
+
const boards = normalizeBoardConfig(config)
|
|
38
|
+
const board = boardName ? boards[boardName] : null
|
|
39
|
+
if (boardName && !board) {
|
|
40
|
+
throw new Error(`Unknown board '${boardName}'. Add it to .gea/boards.json (gea boards add) or run gea boards list.`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let target = board?.target || targetName || ''
|
|
44
|
+
if (!target) throw new Error('No board selected. Pass --board <alias> (gea boards list) or --target <id>.')
|
|
45
|
+
let targetBase = target
|
|
46
|
+
let targetDefinition = ''
|
|
47
|
+
let definition = null
|
|
48
|
+
if (board?.targetDefinition) {
|
|
49
|
+
targetDefinition = path.resolve(configDir, board.targetDefinition)
|
|
50
|
+
if (!existsSync(targetDefinition)) {
|
|
51
|
+
throw new Error(`Target definition for board '${boardName}' was not found: ${targetDefinition}`)
|
|
52
|
+
}
|
|
53
|
+
definition = JSON.parse(readFileSync(targetDefinition, 'utf8'))
|
|
54
|
+
if (!definition || typeof definition !== 'object' || Array.isArray(definition)) {
|
|
55
|
+
throw new Error(`Target definition must be a JSON object: ${targetDefinition}`)
|
|
56
|
+
}
|
|
57
|
+
if (!definition.id) throw new Error(`Target definition is missing id: ${targetDefinition}`)
|
|
58
|
+
if (!definition.extends) throw new Error(`Target definition is missing extends: ${targetDefinition}`)
|
|
59
|
+
if (board.target && board.target !== definition.id) {
|
|
60
|
+
throw new Error(`Board '${boardName}' selects target '${board.target}', but ${targetDefinition} defines '${definition.id}'.`)
|
|
61
|
+
}
|
|
62
|
+
target = definition.id
|
|
63
|
+
targetBase = definition.extends
|
|
64
|
+
}
|
|
65
|
+
const targetInfo = targets[targetBase] || {}
|
|
66
|
+
const adapter = board?.adapter || definition?.adapter || targetInfo.adapter || ''
|
|
67
|
+
if (!adapter) throw new Error(`Unknown target '${target}'. It is not in targets.json and the board declares no adapter.`)
|
|
68
|
+
|
|
69
|
+
const usb = boardTransport(board, 'usbSerial')
|
|
70
|
+
if (usb.path) {
|
|
71
|
+
throw new Error(`Board '${boardName}' uses transports.usbSerial.path, which is no longer supported; set transports.usbSerial.serial instead.`)
|
|
72
|
+
}
|
|
73
|
+
let port = ''
|
|
74
|
+
let host = ''
|
|
75
|
+
const usbSerial = usb.serial || ''
|
|
76
|
+
|
|
77
|
+
if (needs.usbPort && usbSerialAdapters.has(adapter)) {
|
|
78
|
+
if (!isAuto(requestedPort)) {
|
|
79
|
+
port = requestedPort
|
|
80
|
+
} else if (usbSerial) {
|
|
81
|
+
if (!deferUsbPort) port = usbSerialResolver({ serial: usbSerial })
|
|
82
|
+
} else if (boardName) {
|
|
83
|
+
// A WiFi-only board hits this on monitor/screenshot. Point at the
|
|
84
|
+
// cable-free command instead of dead-ending on USB.
|
|
85
|
+
const wireless = boardTransport(board, 'ota').host
|
|
86
|
+
? ` This board has transports.ota.host, so 'gea logs --board ${boardName}' and 'gea screenshot --board ${boardName}' work with no cable.`
|
|
87
|
+
: ''
|
|
88
|
+
throw new Error(`Board '${boardName}' does not define transports.usbSerial.serial, and no USB port was passed.${wireless}`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// geaos devices are identified by USB serial too, but tolerantly: a build
|
|
93
|
+
// with the watch detached must still work, so an unresolved port stays
|
|
94
|
+
// empty and the adapter errors at the point it needs the device.
|
|
95
|
+
if (needs.usbPort && geaosAdapters.has(adapter)) {
|
|
96
|
+
if (!isAuto(requestedPort)) {
|
|
97
|
+
port = requestedPort
|
|
98
|
+
} else if (usbSerial && !deferUsbPort) {
|
|
99
|
+
try {
|
|
100
|
+
port = usbSerialResolver({ serial: usbSerial })
|
|
101
|
+
} catch {
|
|
102
|
+
port = ''
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (needs.otaHost) {
|
|
108
|
+
if (!isAuto(requestedHost)) {
|
|
109
|
+
host = requestedHost
|
|
110
|
+
} else {
|
|
111
|
+
host = boardTransport(board, 'ota').host || ''
|
|
112
|
+
if (!host) throw new Error(`Board '${boardName || target}' does not define transports.ota.host, and no --host was passed.`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const selection = {
|
|
117
|
+
boardName,
|
|
118
|
+
target,
|
|
119
|
+
adapter,
|
|
120
|
+
targetDir: board?.targetDir || targetInfo.targetDir || '',
|
|
121
|
+
flashSize: board?.flashSize || targetInfo.flashSize || '',
|
|
122
|
+
appPlatform: board?.appPlatform || targetInfo.appPlatform || '',
|
|
123
|
+
compatibleAppPlatforms: Array.isArray(targetInfo.compatibleAppPlatforms) ? targetInfo.compatibleAppPlatforms : [],
|
|
124
|
+
idfTarget: board?.idfTarget || targetInfo.idfTarget || '',
|
|
125
|
+
esptoolChip: board?.esptoolChip || targetInfo.esptoolChip || '',
|
|
126
|
+
mainTaskStackSize: board?.mainTaskStackSize || targetInfo.mainTaskStackSize || '',
|
|
127
|
+
ipcTaskStackSize: board?.ipcTaskStackSize || targetInfo.ipcTaskStackSize || '',
|
|
128
|
+
port,
|
|
129
|
+
host,
|
|
130
|
+
usbSerial,
|
|
131
|
+
usbRestartAfterFlash: usb.restartAfterFlash || '',
|
|
132
|
+
otaHost: boardTransport(board, 'ota').host || '',
|
|
133
|
+
telnetHost: boardTransport(board, 'telnet').host || board?.telnetHost || targetInfo.telnetHost || '',
|
|
134
|
+
telnetPort: String(boardTransport(board, 'telnet').port || board?.telnetPort || targetInfo.telnetPort || ''),
|
|
135
|
+
fastbootSerial: boardTransport(board, 'fastboot').serial || board?.fastbootSerial || '',
|
|
136
|
+
mtkWorkdir: boardTransport(board, 'mtk').workdir || board?.mtkWorkdir || '',
|
|
137
|
+
mtkBootSlot: boardTransport(board, 'mtk').bootSlot || board?.mtkBootSlot || '',
|
|
138
|
+
mtkMethod: boardTransport(board, 'mtk').method || board?.mtkMethod || '',
|
|
139
|
+
mtkMonitorGlob: boardTransport(board, 'mtk').monitorGlob || board?.mtkMonitorGlob || '',
|
|
140
|
+
targetDefinition
|
|
141
|
+
}
|
|
142
|
+
return selection
|
|
143
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { ExitCode, fail } from '../errors.mjs'
|
|
4
|
+
import { exists, readJson } from '../fs-utils.mjs'
|
|
5
|
+
|
|
6
|
+
// Built-in targets are DATA shipped by @geastack/targets (targets.json at the
|
|
7
|
+
// package root). Each entry names an adapter and where its project lives; the
|
|
8
|
+
// project may sit in another package (geaos boards live in @geastack/geaos).
|
|
9
|
+
export function loadTargets(ctx) {
|
|
10
|
+
if (!ctx.targetsRoot) return {}
|
|
11
|
+
const file = path.join(ctx.targetsRoot, 'targets.json')
|
|
12
|
+
if (!exists(file)) return {}
|
|
13
|
+
const raw = readJson(file)
|
|
14
|
+
const out = {}
|
|
15
|
+
for (const [id, entry] of Object.entries(raw)) {
|
|
16
|
+
const { targetPath, package: packageName, ...rest } = entry
|
|
17
|
+
if (!targetPath) fail(`Built-in target '${id}' is missing targetPath in ${file}.`, ExitCode.missingDependency)
|
|
18
|
+
const packageRoot = packageName ? packageDirFor(ctx, packageName) : ctx.targetsRoot
|
|
19
|
+
out[id] = {
|
|
20
|
+
...rest,
|
|
21
|
+
id,
|
|
22
|
+
package: packageName || '@geastack/targets',
|
|
23
|
+
targetDir: packageRoot ? path.join(packageRoot, targetPath) : ''
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function requireTargets(ctx) {
|
|
30
|
+
if (!ctx.targetsRoot) {
|
|
31
|
+
fail('@geastack/targets is not installed in this project (npm i @geastack/targets).', ExitCode.missingDependency)
|
|
32
|
+
}
|
|
33
|
+
const targets = loadTargets(ctx)
|
|
34
|
+
if (Object.keys(targets).length === 0) {
|
|
35
|
+
fail(`No targets.json found in ${ctx.targetsRoot}.`, ExitCode.missingDependency)
|
|
36
|
+
}
|
|
37
|
+
return targets
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function packageDirFor(ctx, packageName) {
|
|
41
|
+
for (const field of Object.keys(ctx)) {
|
|
42
|
+
if (typeof ctx.packageName === 'function' && ctx.packageName(field) === packageName) return ctx[field]
|
|
43
|
+
}
|
|
44
|
+
return ''
|
|
45
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
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
|
+
function runIoreg(args, options = {}) {
|
|
117
|
+
return execFileSync('ioreg', args, { encoding: 'utf8', ...options })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function resolveMacUsbSerialPort(serial, ioreg = runIoreg) {
|
|
121
|
+
const candidates = serialPortCandidates().filter((candidate) => path.basename(candidate).startsWith('cu.'))
|
|
122
|
+
const needle = normalizedSerial(serial)
|
|
123
|
+
const serialMatches = candidates.filter((candidate) => normalizedSerial(path.basename(candidate)).includes(needle))
|
|
124
|
+
if (serialMatches.length === 1) return serialMatches[0]
|
|
125
|
+
|
|
126
|
+
const location = macUsbDeviceForSerial(serial, ioreg)?.locationHex || ''
|
|
127
|
+
const digits = locationDigits(location)
|
|
128
|
+
if (digits) {
|
|
129
|
+
const locationMatches = candidates.filter((candidate) => normalizedSerial(path.basename(candidate)).includes(digits))
|
|
130
|
+
if (locationMatches.length === 1) return locationMatches[0]
|
|
131
|
+
const registryMatches = macUsbCalloutPortsForSerial(serial, ioreg)
|
|
132
|
+
if (registryMatches.length === 1) return registryMatches[0]
|
|
133
|
+
if (registryMatches.length > 1) {
|
|
134
|
+
throw new Error([`USB serial ${serial} maps to multiple /dev/cu.* ports:`, ...registryMatches.map((c) => ` ${c}`)].join('\n'))
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
throw new Error(`Could not map USB serial ${serial} to a /dev/cu.* port. Check that the board is attached.`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function resolveUsbSerialPort({ serial }, { platform = process.platform, ioreg = runIoreg } = {}) {
|
|
141
|
+
if (!serial) throw new Error('A USB serial number is required to locate the board; /dev paths are not accepted.')
|
|
142
|
+
if (platform === 'linux') {
|
|
143
|
+
const matches = linuxSerialByIdCandidates(serial)
|
|
144
|
+
if (matches.length === 1) return realpathSync(matches[0])
|
|
145
|
+
throw new Error(`Could not map USB serial ${serial} to a /dev/serial/by-id entry. Check that the board is attached.`)
|
|
146
|
+
}
|
|
147
|
+
if (platform === 'darwin') return resolveMacUsbSerialPort(serial, ioreg)
|
|
148
|
+
throw new Error(`USB serial lookup is not implemented on ${platform}.`)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// picotool selects by bus/address on macOS (its --ser matching is unreliable
|
|
152
|
+
// there); everywhere else the serial itself is the selector.
|
|
153
|
+
export function resolvePicotoolSelection({ serial }, { platform = process.platform, ioreg = runIoreg } = {}) {
|
|
154
|
+
if (!serial) return []
|
|
155
|
+
if (platform !== 'darwin') return ['--ser', serial]
|
|
156
|
+
let device = null
|
|
157
|
+
try {
|
|
158
|
+
device = macUsbDeviceForSerial(serial, ioreg)
|
|
159
|
+
} catch {
|
|
160
|
+
device = null
|
|
161
|
+
}
|
|
162
|
+
if (!device?.locationHex) return ['--ser', serial]
|
|
163
|
+
const bus = Number.parseInt(device.locationHex.slice(0, 2), 16)
|
|
164
|
+
const address = device.text
|
|
165
|
+
.split(/\r?\n/)
|
|
166
|
+
.map((line) => ioregNumberProperty(line, 'USB Address') || ioregNumberProperty(line, 'kUSBAddress'))
|
|
167
|
+
.find(Boolean) || 0
|
|
168
|
+
if (!bus || !address) return ['--ser', serial]
|
|
169
|
+
return ['--bus', String(bus), '--address', String(address)]
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Poll until the board's port exists. A board reboots (and re-enumerates)
|
|
173
|
+
// after a flash, so callers retry on the SERIAL, not on a cached path.
|
|
174
|
+
export async function waitForSerialPort({
|
|
175
|
+
port = '',
|
|
176
|
+
serial = '',
|
|
177
|
+
timeoutSeconds = 300,
|
|
178
|
+
label = 'USB serial port',
|
|
179
|
+
pollSeconds = 1,
|
|
180
|
+
log = (line) => process.stderr.write(`${line}\n`),
|
|
181
|
+
resolver = resolveUsbSerialPort
|
|
182
|
+
} = {}) {
|
|
183
|
+
const startedAt = Date.now()
|
|
184
|
+
let nextLog = startedAt
|
|
185
|
+
while (true) {
|
|
186
|
+
if (port) {
|
|
187
|
+
if (existsSync(port)) return port
|
|
188
|
+
} else if (serial) {
|
|
189
|
+
try {
|
|
190
|
+
const resolved = resolver({ serial })
|
|
191
|
+
if (resolved && existsSync(resolved)) return resolved
|
|
192
|
+
} catch {
|
|
193
|
+
// not attached yet
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
const candidate = serialPortCandidates()[0]
|
|
197
|
+
if (candidate) return candidate
|
|
198
|
+
}
|
|
199
|
+
const elapsed = (Date.now() - startedAt) / 1000
|
|
200
|
+
if (timeoutSeconds > 0 && elapsed >= timeoutSeconds) {
|
|
201
|
+
const where = port ? ` at ${port}` : serial ? ` with USB serial ${serial}` : ''
|
|
202
|
+
throw new Error(`Timed out waiting for ${label}${where}.`)
|
|
203
|
+
}
|
|
204
|
+
if (Date.now() >= nextLog) {
|
|
205
|
+
const where = port ? ` at ${port}` : serial ? ` with USB serial ${serial}` : ''
|
|
206
|
+
log(`Waiting for ${label}${where}...`)
|
|
207
|
+
nextLog = Date.now() + 5000
|
|
208
|
+
}
|
|
209
|
+
await new Promise((resolve) => setTimeout(resolve, pollSeconds * 1000))
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { flag, option, optionList } from '../args.mjs'
|
|
5
|
+
import { loadBoardConfig, normalizeBoardConfig, boardConfigPath } from '../boards/config.mjs'
|
|
6
|
+
import { loadTargets } from '../boards/targets.mjs'
|
|
7
|
+
import { ExitCode, fail } from '../errors.mjs'
|
|
8
|
+
import { exists } from '../fs-utils.mjs'
|
|
9
|
+
import { appCmakeMeta, appSummary, assertValidApp, discoverApps, resolveRequestedApp } from '../manifest.mjs'
|
|
10
|
+
|
|
11
|
+
// gea apps ... : the app catalog and everything generated from it.
|
|
12
|
+
|
|
13
|
+
export async function appsCommand(ctx, parsed, rest, options) {
|
|
14
|
+
const sub = rest[0] || 'list'
|
|
15
|
+
const args = rest.slice(1)
|
|
16
|
+
switch (sub) {
|
|
17
|
+
case 'list': return listApps(ctx, parsed, options)
|
|
18
|
+
case 'inspect': return inspectApp(ctx, parsed, args, options)
|
|
19
|
+
case 'pack': return packApp(ctx, parsed, args, options)
|
|
20
|
+
case 'index': return appIndex(ctx, parsed, options)
|
|
21
|
+
case 'launcher': return launcher(ctx, parsed, options)
|
|
22
|
+
case 'icons': return icons(ctx, parsed, args, options)
|
|
23
|
+
case 'icon-sheet': return iconSheet(ctx, parsed, options)
|
|
24
|
+
case 'apple-icons': return appleIcons(ctx, parsed, args, options)
|
|
25
|
+
default:
|
|
26
|
+
fail(`Unknown apps subcommand '${sub}'. Expected list, inspect, pack, index, launcher, icons, icon-sheet, or apple-icons.`, ExitCode.usage)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function listApps(ctx, parsed, options) {
|
|
31
|
+
const target = option(parsed, 'target', '')
|
|
32
|
+
const apps = discoverApps(ctx).filter((app) => !target || app.targets?.[target] === true)
|
|
33
|
+
if (flag(parsed, 'json')) options.stdout(JSON.stringify(apps.map((app) => appSummary(ctx, app)), null, 2))
|
|
34
|
+
else for (const app of apps) options.stdout(app.id)
|
|
35
|
+
return 0
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function inspectApp(ctx, parsed, args, options) {
|
|
39
|
+
const app = resolveRequestedApp(ctx, parsed, args)
|
|
40
|
+
assertValidApp(app)
|
|
41
|
+
const format = option(parsed, 'format', flag(parsed, 'json') ? 'json' : 'text')
|
|
42
|
+
const summary = appSummary(ctx, app)
|
|
43
|
+
if (format === 'cmake') options.stdout(appCmakeMeta(ctx, app))
|
|
44
|
+
else if (format === 'shell') options.stdout(`${summary.root}\t${app.entry}\t${app.runtime}\t${app.name}`)
|
|
45
|
+
else if (format === 'json') options.stdout(JSON.stringify(summary, null, 2))
|
|
46
|
+
else options.stdout(`${app.id}\t${app.name}\t${summary.root}\t${app.entry}`)
|
|
47
|
+
return 0
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function packApp(ctx, parsed, args, options) {
|
|
51
|
+
const app = resolveRequestedApp(ctx, parsed, args)
|
|
52
|
+
assertValidApp(app)
|
|
53
|
+
const target = option(parsed, 'target', 'geaos')
|
|
54
|
+
const { createGeaBundle } = await import('../apps/bundle-writer.mjs')
|
|
55
|
+
const out = path.resolve(ctx.cwd, option(parsed, 'out', path.join(ctx.projectRoot, 'dist', `${app.id}-${target}.gea.zip`)))
|
|
56
|
+
const artifactDir = option(parsed, 'artifact-dir', '')
|
|
57
|
+
const manifest = createGeaBundle({ repoRoot: ctx.projectRoot, app, target, outputPath: out, artifactDir: artifactDir ? path.resolve(ctx.cwd, artifactDir) : '' })
|
|
58
|
+
options.stdout(flag(parsed, 'json') ? JSON.stringify({ outputPath: out, manifest }, null, 2) : out)
|
|
59
|
+
return 0
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function appIndex(ctx, parsed, options) {
|
|
63
|
+
const { generateAppIndex } = await import('../apps/app-index-writer.mjs')
|
|
64
|
+
const outputFile = path.resolve(ctx.cwd, option(parsed, 'out', path.join(ctx.projectRoot, 'simulator', 'src', 'generated', 'app-index.ts')))
|
|
65
|
+
const apps = generateAppIndex({ apps: discoverApps(ctx).map((app) => appSummary(ctx, app)), outputFile })
|
|
66
|
+
options.stdout(flag(parsed, 'json') ? JSON.stringify({ outputFile, apps }, null, 2) : outputFile)
|
|
67
|
+
return 0
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function launcher(ctx, parsed, options) {
|
|
71
|
+
const { generateLauncherCatalog } = await import('../apps/launcher-catalog.mjs')
|
|
72
|
+
const target = option(parsed, 'target', 'geaos')
|
|
73
|
+
const outputFile = path.resolve(ctx.cwd, option(parsed, 'out', path.join(ctx.projectRoot, 'apps', 'app-launcher', 'generated', 'LauncherCatalog.tsx')))
|
|
74
|
+
const apps = generateLauncherCatalog({ repoRoot: ctx.projectRoot, apps: discoverApps(ctx), target, outputFile })
|
|
75
|
+
options.stdout(flag(parsed, 'json') ? JSON.stringify({ outputFile, apps: apps.map((app) => appSummary(ctx, app)) }, null, 2) : outputFile)
|
|
76
|
+
return 0
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function icons(ctx, parsed, args, options) {
|
|
80
|
+
const requested = args[0] || option(parsed, 'app', '')
|
|
81
|
+
if (!requested) fail('gea apps icons <app-id|all> [--target <t>] [--model <m>] [--concurrency <n>] [--exclude a,b]', ExitCode.usage)
|
|
82
|
+
const { generateIconSets } = await import('../apps/openai-icons.mjs')
|
|
83
|
+
const excluded = new Set(optionList(parsed, 'exclude'))
|
|
84
|
+
const iconTarget = option(parsed, 'target', '')
|
|
85
|
+
const all = discoverApps(ctx)
|
|
86
|
+
const apps = requested === 'all'
|
|
87
|
+
? all
|
|
88
|
+
.filter((app) => iconTarget && iconTarget !== 'all'
|
|
89
|
+
? app.targets?.[iconTarget] === true
|
|
90
|
+
: ['geaos', 'esp32', 'ios', 'macos'].some((candidate) => app.targets?.[candidate] === true))
|
|
91
|
+
.filter((app) => !excluded.has(app.id))
|
|
92
|
+
: [resolveRequestedApp(ctx, parsed, [requested])]
|
|
93
|
+
await generateIconSets({
|
|
94
|
+
repoRoot: ctx.projectRoot,
|
|
95
|
+
apps,
|
|
96
|
+
model: option(parsed, 'model', ctx.env.GEA_OPENAI_IMAGE_MODEL || 'gpt-image-2'),
|
|
97
|
+
concurrency: Math.max(1, Number.parseInt(option(parsed, 'concurrency', '1'), 10) || 1)
|
|
98
|
+
})
|
|
99
|
+
return 0
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The Apple build scripts call this per app: the iOS generator wants an
|
|
103
|
+
// AppIcon.appiconset inside its asset catalog, the macOS bundle wants an
|
|
104
|
+
// AppIcon.icns in Resources. --signature prints a digest of every input the
|
|
105
|
+
// icon depends on so the scripts can skip regeneration when nothing changed.
|
|
106
|
+
async function appleIcons(ctx, parsed, args, options) {
|
|
107
|
+
const usage = 'gea apps apple-icons <app-id> --platform ios --assets-dir <dir> | --platform macos --build-dir <dir> --resources-dir <dir> [--signature]'
|
|
108
|
+
const app = resolveRequestedApp(ctx, parsed, args)
|
|
109
|
+
assertValidApp(app)
|
|
110
|
+
const platform = option(parsed, 'platform', '')
|
|
111
|
+
if (platform !== 'ios' && platform !== 'macos') fail(usage, ExitCode.usage)
|
|
112
|
+
const icons = await import('../apps/apple-icons.mjs')
|
|
113
|
+
const repoRoot = ctx.projectRoot
|
|
114
|
+
if (flag(parsed, 'signature')) {
|
|
115
|
+
const { createHash } = await import('node:crypto')
|
|
116
|
+
const { readFileSync } = await import('node:fs')
|
|
117
|
+
const hash = createHash('sha256')
|
|
118
|
+
const addFile = (file) => {
|
|
119
|
+
hash.update(file)
|
|
120
|
+
hash.update('\0')
|
|
121
|
+
hash.update(readFileSync(file))
|
|
122
|
+
hash.update('\0')
|
|
123
|
+
}
|
|
124
|
+
addFile(path.join(ctx.cliPackageRoot, 'src', 'apps', 'apple-icons.mjs'))
|
|
125
|
+
hash.update(JSON.stringify({ id: app.id, root: appSummary(ctx, app).root, icons: app.icons || {}, platform }))
|
|
126
|
+
addFile(icons.resolveAppleIconSourcePath(repoRoot, app))
|
|
127
|
+
options.stdout(hash.digest('hex'))
|
|
128
|
+
return 0
|
|
129
|
+
}
|
|
130
|
+
if (platform === 'ios') {
|
|
131
|
+
const assetsDir = option(parsed, 'assets-dir', '')
|
|
132
|
+
if (!assetsDir) fail(usage, ExitCode.usage)
|
|
133
|
+
options.stdout(icons.prepareIosAppIconAssets({ repoRoot, app, assetsDir: path.resolve(ctx.cwd, assetsDir) }))
|
|
134
|
+
return 0
|
|
135
|
+
}
|
|
136
|
+
const buildDir = option(parsed, 'build-dir', '')
|
|
137
|
+
const resourcesDir = option(parsed, 'resources-dir', '')
|
|
138
|
+
if (!buildDir || !resourcesDir) fail(usage, ExitCode.usage)
|
|
139
|
+
options.stdout(icons.prepareMacosAppIcon({ repoRoot, app, buildDir: path.resolve(ctx.cwd, buildDir), resourcesDir: path.resolve(ctx.cwd, resourcesDir) }))
|
|
140
|
+
return 0
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function iconSheet(ctx, parsed, options) {
|
|
144
|
+
const { generateIconStyleSheet } = await import('../apps/openai-icons.mjs')
|
|
145
|
+
const excluded = new Set(optionList(parsed, 'exclude'))
|
|
146
|
+
const iconTarget = option(parsed, 'target', 'geaos')
|
|
147
|
+
const outputPath = path.resolve(ctx.cwd, option(parsed, 'out', path.join(ctx.projectRoot, 'docs', 'icon-style-preview', `${iconTarget}-icon-style-sheet.png`)))
|
|
148
|
+
const apps = discoverApps(ctx)
|
|
149
|
+
.filter((app) => iconTarget === 'all' || app.targets?.[iconTarget] === true)
|
|
150
|
+
.filter((app) => flag(parsed, 'include-hidden') || !app.launcher.hidden)
|
|
151
|
+
.filter((app) => app.icons && Object.keys(app.icons).length > 0)
|
|
152
|
+
.filter((app) => !excluded.has(app.id))
|
|
153
|
+
if (apps.length === 0) fail(`No apps matched target '${iconTarget}'`, ExitCode.usage)
|
|
154
|
+
const sheet = await generateIconStyleSheet({
|
|
155
|
+
apps,
|
|
156
|
+
model: option(parsed, 'model', ctx.env.GEA_OPENAI_IMAGE_MODEL || 'gpt-image-2'),
|
|
157
|
+
outputPath,
|
|
158
|
+
columns: Math.max(1, Number.parseInt(option(parsed, 'columns', '5'), 10) || 5)
|
|
159
|
+
})
|
|
160
|
+
options.stdout(flag(parsed, 'json') ? JSON.stringify({ outputPath: sheet.outputPath, apps: apps.map((app) => appSummary(ctx, app)) }, null, 2) : sheet.outputPath)
|
|
161
|
+
return 0
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// gea boards ... / gea targets ...
|
|
165
|
+
|
|
166
|
+
export async function boardsCommand(ctx, parsed, rest, options) {
|
|
167
|
+
const sub = rest[0] || 'list'
|
|
168
|
+
const boards = normalizeBoardConfig(loadBoardConfig(ctx))
|
|
169
|
+
if (sub === 'list') {
|
|
170
|
+
if (flag(parsed, 'json')) options.stdout(JSON.stringify(boards, null, 2))
|
|
171
|
+
else {
|
|
172
|
+
const names = Object.keys(boards).sort()
|
|
173
|
+
if (names.length === 0) options.stdout(`No boards configured in ${boardConfigPath(ctx)}. Run gea setup, or gea boards add.`)
|
|
174
|
+
for (const name of names) {
|
|
175
|
+
const board = boards[name]
|
|
176
|
+
const bits = [board.target]
|
|
177
|
+
if (board.transports?.usbSerial?.serial) bits.push(`usb ${board.transports.usbSerial.serial}`)
|
|
178
|
+
if (board.transports?.ota?.host) bits.push(`wifi ${board.transports.ota.host}`)
|
|
179
|
+
options.stdout(`${name}\t${bits.join(' ')}`)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return 0
|
|
183
|
+
}
|
|
184
|
+
if (sub === 'show') {
|
|
185
|
+
const name = rest[1] || option(parsed, 'board', '')
|
|
186
|
+
if (!name || !boards[name]) fail(`Unknown board '${name}'. Run gea boards list.`, ExitCode.usage)
|
|
187
|
+
options.stdout(JSON.stringify({ [name]: boards[name] }, null, 2))
|
|
188
|
+
return 0
|
|
189
|
+
}
|
|
190
|
+
if (sub === 'add') {
|
|
191
|
+
const { runSetupWizard } = await import('../setup-wizard.mjs')
|
|
192
|
+
return runSetupWizard(ctx, parsed, options)
|
|
193
|
+
}
|
|
194
|
+
fail(`Unknown boards subcommand '${sub}'. Expected list, show, or add.`, ExitCode.usage)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function targetsCommand(ctx, parsed, rest, options) {
|
|
198
|
+
const sub = rest[0] || 'list'
|
|
199
|
+
let targets
|
|
200
|
+
try {
|
|
201
|
+
targets = loadTargets(ctx)
|
|
202
|
+
} catch (error) {
|
|
203
|
+
fail(error.message, ExitCode.missingDependency)
|
|
204
|
+
}
|
|
205
|
+
if (sub === 'list') {
|
|
206
|
+
if (flag(parsed, 'json')) options.stdout(JSON.stringify(targets, null, 2))
|
|
207
|
+
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)'}`)
|
|
208
|
+
return 0
|
|
209
|
+
}
|
|
210
|
+
if (sub === 'show') {
|
|
211
|
+
const id = rest[1] || ''
|
|
212
|
+
if (!targets[id]) fail(`Unknown target '${id}'. Run gea targets list.`, ExitCode.usage)
|
|
213
|
+
options.stdout(JSON.stringify(targets[id], null, 2))
|
|
214
|
+
return 0
|
|
215
|
+
}
|
|
216
|
+
fail(`Unknown targets subcommand '${sub}'. Expected list or show.`, ExitCode.usage)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function heapReportCommand(ctx, parsed, rest, options) {
|
|
220
|
+
const { DEFAULT_TITLE, parseLogs, renderHtml } = await import('../heap-report.mjs')
|
|
221
|
+
const { mkdirSync, writeFileSync } = await import('node:fs')
|
|
222
|
+
let inputs = rest.map((file) => path.resolve(ctx.cwd, file))
|
|
223
|
+
const logsDir = path.join(ctx.projectRoot, 'docs', 'esp32-perf-logs')
|
|
224
|
+
if (inputs.length === 0 && exists(logsDir)) {
|
|
225
|
+
inputs = readdirSync(logsDir).filter((name) => /\.(txt|log)$/.test(name)).map((name) => path.join(logsDir, name))
|
|
226
|
+
}
|
|
227
|
+
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)
|
|
228
|
+
const out = path.resolve(ctx.cwd, option(parsed, 'out', path.join(logsDir, 'heap-map-report.html')))
|
|
229
|
+
const maps = optionList(parsed, 'map').map((file) => path.resolve(ctx.cwd, file))
|
|
230
|
+
const data = parseLogs(inputs, maps)
|
|
231
|
+
mkdirSync(path.dirname(out), { recursive: true })
|
|
232
|
+
writeFileSync(out, renderHtml(data, option(parsed, 'title', DEFAULT_TITLE)))
|
|
233
|
+
options.stdout(`Wrote heap map report: ${out}`)
|
|
234
|
+
options.stdout(`Parsed ${data.snapshots.length} heap-map snapshots, ${data.probes.length} probes, ${data.liveAllocations.length} live allocations, ${data.linkerMaps.length} linker map(s)`)
|
|
235
|
+
return 0
|
|
236
|
+
}
|