@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.
- package/README.md +14 -1
- package/docs/ESP32-WAVESHARE-AMOLED-QUICKSTART.md +2 -2
- package/docs/NPX-COMMANDS.md +3 -1
- package/docs/SETUP.md +81 -15
- package/docs/SPEC.md +8 -5
- 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 +114 -0
- package/src/boards/custom-target.mjs +309 -0
- package/src/boards/resolve.mjs +145 -0
- package/src/boards/targets.mjs +45 -0
- package/src/boards/usb.mjs +252 -0
- package/src/chips.mjs +1 -1
- package/src/commands/apps.mjs +204 -0
- package/src/commands/board.mjs +402 -0
- package/src/commands/boards.mjs +334 -0
- package/src/commands/doctor.mjs +91 -0
- package/src/context.mjs +50 -54
- 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 +89 -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/serial-devices.mjs +32 -20
- package/src/setup-wizard.mjs +11 -12
- package/src/taurus/adapter.mjs +52 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { flag, option } from '../args.mjs'
|
|
2
|
+
import {
|
|
3
|
+
boardConfigOrigins,
|
|
4
|
+
boardConfigPath,
|
|
5
|
+
boardConfigTiers,
|
|
6
|
+
boardConfigWritePath,
|
|
7
|
+
loadBoardConfigWithOrigins,
|
|
8
|
+
readBoardConfigFile,
|
|
9
|
+
writeBoardConfigFile
|
|
10
|
+
} from '../boards/config.mjs'
|
|
11
|
+
import { normalizedSerial } from '../boards/usb.mjs'
|
|
12
|
+
import { SerialDevice, geadev } from '../device/serial.mjs'
|
|
13
|
+
import { ExitCode, fail } from '../errors.mjs'
|
|
14
|
+
import { exists } from '../fs-utils.mjs'
|
|
15
|
+
import { detectSerialDevices } from '../serial-devices.mjs'
|
|
16
|
+
|
|
17
|
+
export const boardsUsage = `gea boards <subcommand> [--global | --local]
|
|
18
|
+
|
|
19
|
+
list [--json] every alias, with the file it lives in
|
|
20
|
+
show <alias> [--json]
|
|
21
|
+
add register a board (guided; gea setup)
|
|
22
|
+
set <alias> <key> <value> edit one field; an empty value removes it
|
|
23
|
+
remove <alias>
|
|
24
|
+
rename <alias> <new-alias>
|
|
25
|
+
discover [--json] [--save] identify the boards plugged in over USB
|
|
26
|
+
|
|
27
|
+
Keys for set: host (transports.ota.host), serial (transports.usbSerial.serial),
|
|
28
|
+
restart (transports.usbSerial.restartAfterFlash), target, adapter, or any
|
|
29
|
+
dotted path such as transports.telnet.port.
|
|
30
|
+
|
|
31
|
+
Aliases are read from ~/.geastack/boards.json (every board on this machine)
|
|
32
|
+
and the project's .gea/boards.json (project overrides), or only from
|
|
33
|
+
--boards-config when given. --global / --local choose where a write goes
|
|
34
|
+
(the machine-wide file or the project's); by default an existing alias is
|
|
35
|
+
edited where it lives and a new one joins the project config when the project
|
|
36
|
+
has one.`
|
|
37
|
+
|
|
38
|
+
const keyShorthands = {
|
|
39
|
+
host: 'transports.ota.host',
|
|
40
|
+
ip: 'transports.ota.host',
|
|
41
|
+
serial: 'transports.usbSerial.serial',
|
|
42
|
+
usb: 'transports.usbSerial.serial',
|
|
43
|
+
restart: 'transports.usbSerial.restartAfterFlash',
|
|
44
|
+
restartAfterFlash: 'transports.usbSerial.restartAfterFlash'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --project is already the global "project directory" option, so the
|
|
48
|
+
// project tier is selected with --local.
|
|
49
|
+
function writeScope(parsed) {
|
|
50
|
+
if (flag(parsed, 'global') && flag(parsed, 'local')) fail('Pass either --global or --local, not both.', ExitCode.usage)
|
|
51
|
+
return flag(parsed, 'global') ? 'global' : flag(parsed, 'local') ? 'project' : ''
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function tierLabel(ctx, file) {
|
|
55
|
+
const tier = boardConfigTiers(ctx).find((candidate) => candidate.file === file)
|
|
56
|
+
return tier ? tier.scope : file
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function requireAlias(boards, alias, verb) {
|
|
60
|
+
if (!alias) fail(`gea boards ${verb} needs a board alias.\n${boardsUsage}`, ExitCode.usage)
|
|
61
|
+
if (!boards[alias]) fail(`Unknown board '${alias}'. Run gea boards list.`, ExitCode.usage)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function boardSummaryLine(alias, board, scope = '') {
|
|
65
|
+
const bits = [board.target || '(no target)']
|
|
66
|
+
if (board.transports?.usbSerial?.serial) bits.push(`usb ${board.transports.usbSerial.serial}`)
|
|
67
|
+
if (board.transports?.ota?.host) bits.push(`wifi ${board.transports.ota.host}`)
|
|
68
|
+
if (board.transports?.telnet?.host) bits.push(`telnet ${board.transports.telnet.host}`)
|
|
69
|
+
if (scope) bits.push(`[${scope}]`)
|
|
70
|
+
return `${alias}\t${bits.join(' ')}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A value typed on the command line: numbers and booleans become JSON
|
|
74
|
+
// values (transports.telnet.port is a number), everything else stays a
|
|
75
|
+
// string. IPs, MAC-style serials and hostnames never parse as JSON.
|
|
76
|
+
export function parseFieldValue(raw) {
|
|
77
|
+
if (raw === '' || raw === undefined) return undefined
|
|
78
|
+
if (/^(true|false|null|-?\d+(\.\d+)?)$/.test(raw) || /^[[{]/.test(raw)) {
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(raw)
|
|
81
|
+
} catch {
|
|
82
|
+
return raw
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return raw
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function setFieldPath(board, keyPath, value) {
|
|
89
|
+
const segments = keyPath.split('.').filter(Boolean)
|
|
90
|
+
if (segments.length === 0) fail('The key to set is empty.', ExitCode.usage)
|
|
91
|
+
let cursor = board
|
|
92
|
+
for (const segment of segments.slice(0, -1)) {
|
|
93
|
+
if (cursor[segment] === undefined || cursor[segment] === null || typeof cursor[segment] !== 'object') {
|
|
94
|
+
if (value === undefined) return board
|
|
95
|
+
cursor[segment] = {}
|
|
96
|
+
}
|
|
97
|
+
cursor = cursor[segment]
|
|
98
|
+
}
|
|
99
|
+
const last = segments[segments.length - 1]
|
|
100
|
+
if (value === undefined) delete cursor[last]
|
|
101
|
+
else cursor[last] = value
|
|
102
|
+
pruneEmptyObjects(board)
|
|
103
|
+
return board
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function pruneEmptyObjects(value) {
|
|
107
|
+
for (const [key, child] of Object.entries(value)) {
|
|
108
|
+
if (child && typeof child === 'object' && !Array.isArray(child)) {
|
|
109
|
+
pruneEmptyObjects(child)
|
|
110
|
+
if (Object.keys(child).length === 0 && key !== 'transports') delete value[key]
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function updateBoardFile(file, mutate) {
|
|
116
|
+
const boards = readBoardConfigFile(file)
|
|
117
|
+
const next = mutate(boards)
|
|
118
|
+
writeBoardConfigFile(file, next)
|
|
119
|
+
return next
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---- discover -----------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
// Asks a serial device who it is with one GEADEV PING. A board that is mid
|
|
125
|
+
// boot, running a firmware without device control, or not a gea board at
|
|
126
|
+
// all answers nothing and is reported as such; nothing here resets the port
|
|
127
|
+
// (SerialDevice.open never touches DTR/RTS).
|
|
128
|
+
export async function probeSerialDevice(device, { baudRate = 115200, timeoutMs = 1500, env = process.env } = {}) {
|
|
129
|
+
let serial = null
|
|
130
|
+
try {
|
|
131
|
+
serial = await SerialDevice.open({ path: device.path, baudRate: Number(env.GEA_SERIAL_BAUD) || baudRate })
|
|
132
|
+
await serial.drainInput(60, 200)
|
|
133
|
+
const reply = await serial.command('GEADEV PING', ['GEADEV:PONG'], timeoutMs)
|
|
134
|
+
const values = parsePong(reply)
|
|
135
|
+
return { ok: true, ...values }
|
|
136
|
+
} catch (error) {
|
|
137
|
+
return { ok: false, error: error.message }
|
|
138
|
+
} finally {
|
|
139
|
+
if (serial) await serial.close().catch(() => {})
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function parsePong(reply) {
|
|
144
|
+
const values = {}
|
|
145
|
+
for (const token of String(reply).split(/\s+/).slice(1)) {
|
|
146
|
+
const eq = token.indexOf('=')
|
|
147
|
+
if (eq > 0) values[token.slice(0, eq)] = token.slice(eq + 1)
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
app: values.app || '',
|
|
151
|
+
ip: values.ip && values.ip !== '0.0.0.0' ? values.ip : '',
|
|
152
|
+
mac: values.mac || ''
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Pure: pairs detected serial devices with configured aliases by USB serial
|
|
157
|
+
// (the MAC on ESP32 boards, so a PONG's mac= confirms the same thing), and
|
|
158
|
+
// records what each device answered.
|
|
159
|
+
export async function discoverBoards({ devices, boards, probe }) {
|
|
160
|
+
const results = []
|
|
161
|
+
for (const device of devices) {
|
|
162
|
+
const probed = await probe(device)
|
|
163
|
+
const serials = [device.serial, probed.mac].map(normalizedSerial).filter(Boolean)
|
|
164
|
+
const alias = Object.keys(boards).find((name) => {
|
|
165
|
+
const configured = normalizedSerial(boards[name]?.transports?.usbSerial?.serial)
|
|
166
|
+
return configured && serials.includes(configured)
|
|
167
|
+
})
|
|
168
|
+
results.push({
|
|
169
|
+
path: device.path,
|
|
170
|
+
label: device.label || '',
|
|
171
|
+
serial: device.serial || probed.mac || '',
|
|
172
|
+
alias: alias || '',
|
|
173
|
+
target: alias ? boards[alias].target || '' : '',
|
|
174
|
+
configuredHost: alias ? boards[alias].transports?.ota?.host || '' : '',
|
|
175
|
+
responds: probed.ok,
|
|
176
|
+
app: probed.app || '',
|
|
177
|
+
ip: probed.ip || '',
|
|
178
|
+
mac: probed.mac || '',
|
|
179
|
+
error: probed.ok ? '' : probed.error || ''
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
return results
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function formatDiscovery(result) {
|
|
186
|
+
const who = result.alias ? `${result.alias} (${result.target})` : 'not configured'
|
|
187
|
+
const bits = [result.path, who]
|
|
188
|
+
if (result.serial) bits.push(`serial ${result.serial}`)
|
|
189
|
+
if (result.responds) {
|
|
190
|
+
bits.push(result.app ? `app ${result.app}` : 'app ?')
|
|
191
|
+
bits.push(result.ip ? `ip ${result.ip}` : 'no ip')
|
|
192
|
+
} else {
|
|
193
|
+
bits.push('no GEADEV reply')
|
|
194
|
+
}
|
|
195
|
+
return bits.join(' ')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---- command --------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
export async function boardsCommand(ctx, parsed, rest, options) {
|
|
201
|
+
const sub = rest[0] || 'list'
|
|
202
|
+
const json = flag(parsed, 'json')
|
|
203
|
+
const { boards, origins } = loadBoardConfigWithOrigins(ctx)
|
|
204
|
+
|
|
205
|
+
if (sub === 'help') {
|
|
206
|
+
options.stdout(boardsUsage)
|
|
207
|
+
return 0
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (sub === 'list') {
|
|
211
|
+
const names = Object.keys(boards).sort()
|
|
212
|
+
if (json) {
|
|
213
|
+
options.stdout(JSON.stringify(boards, null, 2))
|
|
214
|
+
return 0
|
|
215
|
+
}
|
|
216
|
+
if (names.length === 0) {
|
|
217
|
+
const tiers = boardConfigTiers(ctx).map((tier) => `${tier.file}${exists(tier.file) ? '' : ' (missing)'}`)
|
|
218
|
+
options.stdout(`No boards configured. Looked in:\n ${tiers.join('\n ')}\nRun gea boards add, or gea boards discover to see what is plugged in.`)
|
|
219
|
+
return 0
|
|
220
|
+
}
|
|
221
|
+
for (const name of names) options.stdout(boardSummaryLine(name, boards[name], tierLabel(ctx, origins.get(name))))
|
|
222
|
+
return 0
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (sub === 'show') {
|
|
226
|
+
const name = rest[1] || option(parsed, 'board', '')
|
|
227
|
+
requireAlias(boards, name, 'show')
|
|
228
|
+
if (json) options.stdout(JSON.stringify({ [name]: boards[name] }, null, 2))
|
|
229
|
+
else {
|
|
230
|
+
options.stdout(`${name}: ${origins.get(name)}`)
|
|
231
|
+
options.stdout(JSON.stringify(boards[name], null, 2))
|
|
232
|
+
}
|
|
233
|
+
return 0
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (sub === 'add') {
|
|
237
|
+
const { runSetupWizard } = await import('../setup-wizard.mjs')
|
|
238
|
+
return runSetupWizard(ctx, parsed, options)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (sub === 'set') {
|
|
242
|
+
const [, name, key, ...valueParts] = rest
|
|
243
|
+
requireAlias(boards, name, 'set')
|
|
244
|
+
if (!key) fail(`gea boards set needs a key and a value.\n${boardsUsage}`, ExitCode.usage)
|
|
245
|
+
const keyPath = keyShorthands[key] || key
|
|
246
|
+
const value = parseFieldValue(valueParts.join(' '))
|
|
247
|
+
const file = boardConfigWritePath(ctx, { scope: writeScope(parsed), alias: name })
|
|
248
|
+
const scope = writeScope(parsed)
|
|
249
|
+
updateBoardFile(file, (current) => {
|
|
250
|
+
// Moving an alias between tiers with --global/--project copies the
|
|
251
|
+
// merged entry so the edit lands on a complete board, not a fragment.
|
|
252
|
+
const base = current[name] ?? structuredClone(boards[name])
|
|
253
|
+
current[name] = setFieldPath(base, keyPath, value)
|
|
254
|
+
return current
|
|
255
|
+
})
|
|
256
|
+
if (scope && origins.get(name) && origins.get(name) !== file) {
|
|
257
|
+
options.stderr(`Note: '${name}' also exists in ${origins.get(name)}; the project entry is the one commands see.`)
|
|
258
|
+
}
|
|
259
|
+
options.stdout(value === undefined ? `Removed ${keyPath} from '${name}' in ${file}` : `Set ${keyPath}=${JSON.stringify(value)} on '${name}' in ${file}`)
|
|
260
|
+
return 0
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
264
|
+
const name = rest[1]
|
|
265
|
+
requireAlias(boards, name, 'remove')
|
|
266
|
+
const scope = writeScope(parsed)
|
|
267
|
+
const files = scope ? [boardConfigWritePath(ctx, { scope })] : boardConfigTiers(ctx).map((tier) => tier.file)
|
|
268
|
+
let removedFrom = []
|
|
269
|
+
for (const file of files) {
|
|
270
|
+
if (!exists(file)) continue
|
|
271
|
+
const current = readBoardConfigFile(file)
|
|
272
|
+
if (!current[name]) continue
|
|
273
|
+
delete current[name]
|
|
274
|
+
writeBoardConfigFile(file, current)
|
|
275
|
+
removedFrom.push(file)
|
|
276
|
+
}
|
|
277
|
+
if (removedFrom.length === 0) fail(`'${name}' is not defined in ${files.join(' or ')}.`, ExitCode.usage)
|
|
278
|
+
options.stdout(`Removed '${name}' from ${removedFrom.join(', ')}`)
|
|
279
|
+
return 0
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (sub === 'rename' || sub === 'mv') {
|
|
283
|
+
const [, from, to] = rest
|
|
284
|
+
requireAlias(boards, from, 'rename')
|
|
285
|
+
if (!to || !/^[a-z0-9][a-z0-9._-]*$/i.test(to)) fail(`gea boards rename needs a new alias made of letters, digits, dots, dashes or underscores.`, ExitCode.usage)
|
|
286
|
+
if (boards[to]) fail(`A board named '${to}' already exists. Remove it first.`, ExitCode.usage)
|
|
287
|
+
const file = boardConfigWritePath(ctx, { scope: writeScope(parsed), alias: from })
|
|
288
|
+
updateBoardFile(file, (current) => {
|
|
289
|
+
const entry = current[from] ?? structuredClone(boards[from])
|
|
290
|
+
delete current[from]
|
|
291
|
+
current[to] = entry
|
|
292
|
+
return current
|
|
293
|
+
})
|
|
294
|
+
options.stdout(`Renamed '${from}' to '${to}' in ${file}`)
|
|
295
|
+
return 0
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (sub === 'discover' || sub === 'scan') {
|
|
299
|
+
const devices = detectSerialDevices({ env: options.env || ctx.env || process.env })
|
|
300
|
+
const timeoutMs = Number(option(parsed, 'timeout', '1500')) || 1500
|
|
301
|
+
const probe = options.probeSerialDevice || ((device) => probeSerialDevice(device, { timeoutMs, env: options.env || ctx.env || process.env }))
|
|
302
|
+
const results = await discoverBoards({ devices, boards, probe: flag(parsed, 'no-probe') ? async () => ({ ok: false, error: 'not probed' }) : probe })
|
|
303
|
+
if (json) {
|
|
304
|
+
options.stdout(JSON.stringify(results, null, 2))
|
|
305
|
+
} else if (results.length === 0) {
|
|
306
|
+
options.stdout('No serial devices detected. Plug a board in over USB and retry.')
|
|
307
|
+
} else {
|
|
308
|
+
for (const result of results) options.stdout(formatDiscovery(result))
|
|
309
|
+
const unconfigured = results.filter((result) => !result.alias && result.serial)
|
|
310
|
+
for (const result of unconfigured) {
|
|
311
|
+
options.stdout(` -> register it: gea boards add (USB serial ${result.serial}${result.app ? `, running ${result.app}` : ''})`)
|
|
312
|
+
}
|
|
313
|
+
for (const result of results.filter((entry) => entry.alias && entry.ip && entry.ip !== entry.configuredHost)) {
|
|
314
|
+
options.stdout(` -> '${result.alias}' is at ${result.ip}${result.configuredHost ? ` (config says ${result.configuredHost})` : ''}: gea boards set ${result.alias} host ${result.ip}, or rerun with --save`)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (flag(parsed, 'save')) {
|
|
318
|
+
for (const result of results.filter((entry) => entry.alias && entry.ip && entry.ip !== entry.configuredHost)) {
|
|
319
|
+
const file = boardConfigWritePath(ctx, { scope: writeScope(parsed), alias: result.alias })
|
|
320
|
+
updateBoardFile(file, (current) => {
|
|
321
|
+
const base = current[result.alias] ?? structuredClone(boards[result.alias])
|
|
322
|
+
current[result.alias] = setFieldPath(base, 'transports.ota.host', result.ip)
|
|
323
|
+
return current
|
|
324
|
+
})
|
|
325
|
+
options.stdout(`Saved transports.ota.host=${result.ip} for '${result.alias}' in ${file}`)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return 0
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
fail(`Unknown boards subcommand '${sub}'.\n${boardsUsage}`, ExitCode.usage)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export { boardConfigPath, boardConfigOrigins }
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { flag } from '../args.mjs'
|
|
5
|
+
import { boardConfigPath, boardConfigTiers, loadBoardConfig } from '../boards/config.mjs'
|
|
6
|
+
import { ExitCode } from '../errors.mjs'
|
|
7
|
+
import { findEspIdf, findIdfPythonEnv } from '../esp32/idf-env.mjs'
|
|
8
|
+
import { exists } from '../fs-utils.mjs'
|
|
9
|
+
import { discoverApps, resolveRequestedApp, validateApp } from '../manifest.mjs'
|
|
10
|
+
import { commandVersion, nodeAtLeast } from '../toolchain.mjs'
|
|
11
|
+
|
|
12
|
+
function packageExists(dir) {
|
|
13
|
+
return Boolean(dir) && exists(path.join(dir, 'package.json'))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function onPath(name, env) {
|
|
17
|
+
return String(env.PATH || '').split(path.delimiter).some((dir) => dir && existsSync(path.join(dir, name)))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function serialportAvailable() {
|
|
21
|
+
try {
|
|
22
|
+
await import('serialport')
|
|
23
|
+
return true
|
|
24
|
+
} catch {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function doctorCommand(ctx, parsed, rest, options) {
|
|
30
|
+
const env = options.env || process.env
|
|
31
|
+
const checks = []
|
|
32
|
+
const add = (name, ok, detail, required) => checks.push({ name, ok: Boolean(ok), detail: String(detail ?? ''), required })
|
|
33
|
+
|
|
34
|
+
add('project root', exists(ctx.projectRoot), ctx.projectRoot, true)
|
|
35
|
+
add('Node >= 20.19', nodeAtLeast(20, 19), process.version, true)
|
|
36
|
+
add('@geastack/targets', packageExists(ctx.targetsRoot), ctx.targetsRoot || 'not installed in this project', true)
|
|
37
|
+
add('@geastack/core', packageExists(ctx.corePackageDir), ctx.corePackageDir || 'not installed', true)
|
|
38
|
+
add('@geastack/compiler', packageExists(ctx.compilerPackageDir), ctx.compilerPackageDir || 'not installed', true)
|
|
39
|
+
add('@geastack/geatsc-plugin-gea', packageExists(ctx.pluginPackageDir), ctx.pluginPackageDir || 'not installed', true)
|
|
40
|
+
for (const [name, dir] of [['@geastack/chips', ctx.chipsPackageDir], ['@geastack/engine', ctx.enginePackageDir], ['@geastack/host', ctx.hostPackageDir], ['@geastack/elements', ctx.elementsPackageDir], ['@geastack/geaos', ctx.geaosPackageDir]]) {
|
|
41
|
+
add(name, packageExists(dir), dir || 'not installed', false)
|
|
42
|
+
}
|
|
43
|
+
add('serialport (USB device access)', await serialportAvailable(), 'npm package', false)
|
|
44
|
+
|
|
45
|
+
const idfDir = findEspIdf(env)
|
|
46
|
+
const pythonEnv = idfDir ? findIdfPythonEnv(idfDir, env) : ''
|
|
47
|
+
add('ESP-IDF', Boolean(idfDir), idfDir || 'not found (set IDF_PATH)', false)
|
|
48
|
+
add('ESP-IDF python env', Boolean(pythonEnv), pythonEnv || 'run install.sh in ESP-IDF', false)
|
|
49
|
+
add('cmake', onPath('cmake', env) || Boolean(pythonEnv), commandVersion('cmake', ['--version'], env).split('\n')[0] || 'from ESP-IDF tools', false)
|
|
50
|
+
add('ninja', onPath('ninja', env), onPath('ninja', env) ? 'on PATH' : 'optional; Unix Makefiles used otherwise', false)
|
|
51
|
+
add('ccache', onPath('ccache', env), onPath('ccache', env) ? 'on PATH' : 'optional', false)
|
|
52
|
+
add('arm-none-eabi-gcc (RP2350)', onPath('arm-none-eabi-gcc', env) || Boolean(env.PICO_TOOLCHAIN_PATH), env.PICO_TOOLCHAIN_PATH || 'optional', false)
|
|
53
|
+
add('picotool (RP2350)', onPath('picotool', env), onPath('picotool', env) ? 'on PATH' : 'optional', false)
|
|
54
|
+
add('swift (BLE OTA)', onPath('swift', env), onPath('swift', env) ? 'on PATH' : 'optional; macOS only', false)
|
|
55
|
+
|
|
56
|
+
const boardFiles = boardConfigTiers(ctx).filter((tier) => exists(tier.file)).map((tier) => tier.file)
|
|
57
|
+
try {
|
|
58
|
+
const boards = boardFiles.length ? loadBoardConfig(ctx) : {}
|
|
59
|
+
add('boards.json', true, boardFiles.length ? `${boardFiles.join(', ')} (${Object.keys(boards).length} board(s))` : `not configured (gea boards add; looked for ${boardConfigPath(ctx)})`, false)
|
|
60
|
+
} catch (error) {
|
|
61
|
+
add('boards.json', false, error.message, true)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const apps = discoverApps(ctx)
|
|
65
|
+
add('app catalog', apps.length > 0, `${apps.length} app(s)`, true)
|
|
66
|
+
let currentApp = null
|
|
67
|
+
try {
|
|
68
|
+
currentApp = resolveRequestedApp(ctx, parsed, [])
|
|
69
|
+
} catch {
|
|
70
|
+
currentApp = null
|
|
71
|
+
}
|
|
72
|
+
if (currentApp) {
|
|
73
|
+
const errors = validateApp(currentApp)
|
|
74
|
+
add(`app manifest (${currentApp.id})`, errors.length === 0, errors.length === 0 ? currentApp.root : errors.join('; '), true)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const failedRequired = checks.filter((check) => check.required && !check.ok)
|
|
78
|
+
const failedOptional = checks.filter((check) => !check.required && !check.ok)
|
|
79
|
+
if (flag(parsed, 'json')) {
|
|
80
|
+
options.stdout(JSON.stringify({ ok: failedRequired.length === 0, checks }, null, 2))
|
|
81
|
+
} else {
|
|
82
|
+
for (const check of checks) {
|
|
83
|
+
const marker = check.ok ? '[ok]' : check.required ? '[fail]' : '[warn]'
|
|
84
|
+
options.stdout(`${marker} ${check.name}: ${check.detail}`)
|
|
85
|
+
}
|
|
86
|
+
if (failedRequired.length > 0 || failedOptional.length > 0) options.stdout('Setup guide: cli/docs/SETUP.md')
|
|
87
|
+
}
|
|
88
|
+
if (failedRequired.length > 0) return ExitCode.missingDependency
|
|
89
|
+
if (failedOptional.length > 0 && flag(parsed, 'strict')) return ExitCode.missingDependency
|
|
90
|
+
return 0
|
|
91
|
+
}
|
package/src/context.mjs
CHANGED
|
@@ -1,65 +1,72 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
-
import { createRequire } from 'node:module'
|
|
3
2
|
import { fileURLToPath } from 'node:url'
|
|
4
3
|
|
|
5
4
|
import { option } from './args.mjs'
|
|
5
|
+
import { homeBoardsConfigPath } from './boards/config.mjs'
|
|
6
6
|
import { exists, findUp } from './fs-utils.mjs'
|
|
7
7
|
|
|
8
8
|
const srcDir = path.dirname(fileURLToPath(import.meta.url))
|
|
9
9
|
export const cliPackageRoot = path.resolve(srcDir, '..')
|
|
10
|
-
const
|
|
10
|
+
export const cliBin = path.join(cliPackageRoot, 'bin', 'gea.mjs')
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// Every @geastack package the CLI reads. They are sources and data (IDF
|
|
13
|
+
// projects, chip catalogs, C++ trees, the compiler) and they belong to the
|
|
14
|
+
// USER'S project: the CLI resolves them from the project's node_modules the
|
|
15
|
+
// way node itself would, never from its own install. The CLI has no @geastack
|
|
16
|
+
// dependencies of its own, so availability of a command is simply "is that
|
|
17
|
+
// package installed here".
|
|
18
|
+
const geastackPackages = Object.freeze({
|
|
19
|
+
targetsRoot: { name: '@geastack/targets', env: 'GEA_TARGETS_ROOT' },
|
|
20
|
+
corePackageDir: { name: '@geastack/core', env: 'GEA_CORE_DIR' },
|
|
21
|
+
compilerPackageDir: { name: '@geastack/compiler', env: 'GEA_COMPILER_DIR' },
|
|
22
|
+
chipsPackageDir: { name: '@geastack/chips', env: 'GEA_CHIPS_DIR' },
|
|
23
|
+
elementsPackageDir: { name: '@geastack/elements', env: 'GEA_ELEMENTS_DIR' },
|
|
24
|
+
enginePackageDir: { name: '@geastack/engine', env: 'GEA_ENGINE_DIR' },
|
|
25
|
+
geaosPackageDir: { name: '@geastack/geaos', env: 'GEA_GEAOS_PACKAGE_DIR' },
|
|
26
|
+
hostPackageDir: { name: '@geastack/host', env: 'GEA_HOST_DIR' },
|
|
27
|
+
pluginPackageDir: { name: '@geastack/geatsc-plugin-gea', env: 'GEA_PLUGIN_DIR' }
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export function createContext(parsed, env = process.env, cwd = process.cwd()) {
|
|
13
31
|
const absoluteCwd = path.resolve(cwd)
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const targetsRoot = _env.GEA_TARGETS_ROOT || resolveInstalledPackageDir('@geastack/targets', initialAnchors)
|
|
17
|
-
const corePackageDir = resolveInstalledPackageDir('@geastack/core', initialAnchors)
|
|
18
|
-
const packageAnchors = [projectRoot, cliPackageRoot, targetsRoot, corePackageDir].filter(Boolean)
|
|
19
|
-
const compilerPackageDir = resolveInstalledPackageDir('@geastack/compiler', packageAnchors)
|
|
32
|
+
const explicitProject = option(parsed, 'project') || env.GEA_PROJECT_ROOT || ''
|
|
33
|
+
const projectRoot = explicitProject ? path.resolve(absoluteCwd, explicitProject) : findNodeProjectRoot(absoluteCwd) || absoluteCwd
|
|
20
34
|
const projectBoardsConfig = path.join(projectRoot, '.gea', 'boards.json')
|
|
21
|
-
|
|
22
|
-
|
|
35
|
+
// Only an explicit file is `boardsConfig`; the project and home tiers are
|
|
36
|
+
// merged by src/boards/config.mjs, which also decides where a write goes.
|
|
37
|
+
const boardsConfig = option(parsed, 'boards-config') || env.GEA_BOARDS_CONFIG || ''
|
|
23
38
|
|
|
24
39
|
const ctx = {
|
|
25
40
|
cwd: absoluteCwd,
|
|
26
41
|
projectRoot,
|
|
27
42
|
projectBoardsConfig,
|
|
43
|
+
homeBoardsConfig: homeBoardsConfigPath(env),
|
|
28
44
|
cliPackageRoot,
|
|
29
|
-
|
|
30
|
-
corePackageDir,
|
|
31
|
-
chipsPackageDir: _env.GEA_CHIPS_DIR || resolveInstalledPackageDir('@geastack/chips', packageAnchors),
|
|
32
|
-
elementsPackageDir: resolveInstalledPackageDir('@geastack/elements', packageAnchors),
|
|
33
|
-
enginePackageDir: resolveInstalledPackageDir('@geastack/engine', packageAnchors),
|
|
34
|
-
geaosPackageDir: resolveInstalledPackageDir('@geastack/geaos', packageAnchors),
|
|
35
|
-
hostPackageDir: resolveInstalledPackageDir('@geastack/host', packageAnchors),
|
|
36
|
-
pluginPackageDir: resolveInstalledPackageDir('@geastack/geatsc-plugin-gea', packageAnchors),
|
|
45
|
+
cliBin,
|
|
37
46
|
boardsConfig: boardsConfig ? path.resolve(absoluteCwd, boardsConfig) : '',
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
// Generated state (IDF build directories, sdkconfigs, generated board
|
|
48
|
+
// headers) lives in the project, never inside an installed package.
|
|
49
|
+
buildRoot: env.GEA_PROJECT_BUILD_ROOT
|
|
50
|
+
? path.resolve(absoluteCwd, env.GEA_PROJECT_BUILD_ROOT)
|
|
51
|
+
: path.join(projectRoot, '.gea', 'build'),
|
|
52
|
+
env
|
|
43
53
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
webBuild: '',
|
|
47
|
-
webDev: '',
|
|
48
|
-
androidBuild: '',
|
|
49
|
-
macosBuild: '',
|
|
50
|
-
iosBuild: '',
|
|
51
|
-
geaEmbedded: packageFile(ctx.corePackageDir, 'bin', 'gea-embedded.mjs')
|
|
54
|
+
for (const [field, { name, env: envName }] of Object.entries(geastackPackages)) {
|
|
55
|
+
ctx[field] = env[envName] || resolveInstalledPackageDir(name, projectRoot)
|
|
52
56
|
}
|
|
57
|
+
ctx.packageName = (field) => geastackPackages[field]?.name || field
|
|
53
58
|
return ctx
|
|
54
59
|
}
|
|
55
60
|
|
|
56
|
-
|
|
61
|
+
// Environment handed to every build system the CLI drives (IDF/CMake, the
|
|
62
|
+
// Pico SDK, the geaos scripts). They receive exact package paths and never
|
|
63
|
+
// resolve anything themselves.
|
|
64
|
+
export function createChildEnv(ctx, env = ctx.env || process.env) {
|
|
57
65
|
const out = {
|
|
58
66
|
...env,
|
|
59
67
|
GEA_APPS_ROOT: ctx.projectRoot,
|
|
60
|
-
GEA_CLI_BIN:
|
|
68
|
+
GEA_CLI_BIN: ctx.cliBin,
|
|
61
69
|
GEA_CHIPS_DIR: ctx.chipsPackageDir,
|
|
62
|
-
GEA_CORE_PACKAGE: ctx.corePackageDir,
|
|
63
70
|
GEA_CORE_DIR: ctx.corePackageDir,
|
|
64
71
|
GEA_COMPILER_DIR: ctx.compilerPackageDir,
|
|
65
72
|
GEA_ELEMENTS_DIR: ctx.elementsPackageDir,
|
|
@@ -77,28 +84,17 @@ export function createChildEnv(ctx, env = process.env) {
|
|
|
77
84
|
return out
|
|
78
85
|
}
|
|
79
86
|
|
|
80
|
-
function resolveInstalledPackageDir(packageName,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
current = parent
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
try {
|
|
92
|
-
return path.dirname(requireFromCli.resolve(`${packageName}/package.json`))
|
|
93
|
-
} catch {
|
|
94
|
-
return ''
|
|
87
|
+
export function resolveInstalledPackageDir(packageName, anchor) {
|
|
88
|
+
let current = path.resolve(anchor)
|
|
89
|
+
while (true) {
|
|
90
|
+
const candidate = path.join(current, 'node_modules', ...packageName.split('/'))
|
|
91
|
+
if (exists(path.join(candidate, 'package.json'))) return candidate
|
|
92
|
+
const parent = path.dirname(current)
|
|
93
|
+
if (parent === current) return ''
|
|
94
|
+
current = parent
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
function packageFile(packageRoot, ...segments) {
|
|
99
|
-
return packageRoot ? path.join(packageRoot, ...segments) : ''
|
|
100
|
-
}
|
|
101
|
-
|
|
102
98
|
function appendPathList(current, value) {
|
|
103
99
|
const entries = String(current || '').split(path.delimiter).filter(Boolean)
|
|
104
100
|
if (value && !entries.includes(value)) entries.push(value)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { waitForSerialPort } from '../boards/usb.mjs'
|
|
2
|
+
import { ExitCode, fail } from '../errors.mjs'
|
|
3
|
+
import { writeImage } from './image.mjs'
|
|
4
|
+
import { SerialDevice, geadev, streamSerialMonitor } from './serial.mjs'
|
|
5
|
+
import { fetchScreenshot, setHighBrightnessMode, tailLogs } from './wifi.mjs'
|
|
6
|
+
|
|
7
|
+
// One handle for "the board", whichever cable (or no cable) reaches it. Logs
|
|
8
|
+
// and screenshots are written once against this interface; the transport
|
|
9
|
+
// decides how the bytes travel.
|
|
10
|
+
|
|
11
|
+
export const transports = Object.freeze(['auto', 'usb', 'wifi', 'ble'])
|
|
12
|
+
|
|
13
|
+
// 'auto' prefers WiFi whenever the board can be addressed by IP: that works
|
|
14
|
+
// whether or not a cable is attached, and a dropped USB enumeration is the
|
|
15
|
+
// exact situation where you most want to see the screen or the log.
|
|
16
|
+
export function chooseTransport(requested, selection, { host = '' } = {}) {
|
|
17
|
+
const wanted = requested || 'auto'
|
|
18
|
+
if (!transports.includes(wanted)) fail(`--transport must be one of ${transports.join(', ')}.`, ExitCode.usage)
|
|
19
|
+
if (wanted === 'ble') fail('BLE device access is not available yet; use --transport usb or wifi.', ExitCode.usage)
|
|
20
|
+
if (wanted !== 'auto') return wanted
|
|
21
|
+
if (host || selection.otaHost) return 'wifi'
|
|
22
|
+
return 'usb'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function serialBaudRate(selection, env = process.env) {
|
|
26
|
+
if (env.GEA_SERIAL_BAUD) return Number(env.GEA_SERIAL_BAUD)
|
|
27
|
+
return (selection.idfTarget || '') === 'esp32p4' ? 921600 : 115200
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function openDevice({ selection, transport, host = '', port = '', env = process.env, trace = false, stderr = () => {}, waitSeconds = 0 }) {
|
|
31
|
+
if (transport === 'wifi') {
|
|
32
|
+
const address = host || selection.otaHost
|
|
33
|
+
if (!address) fail(`Board '${selection.boardName || selection.target}' has no transports.ota.host and no --host was passed.`, ExitCode.usage)
|
|
34
|
+
return new WifiDevice(address, { stderr })
|
|
35
|
+
}
|
|
36
|
+
if (!selection.usbSerial && !port) {
|
|
37
|
+
fail(`Board '${selection.boardName || selection.target}' has no transports.usbSerial.serial; use --transport wifi or add the USB serial.`, ExitCode.usage)
|
|
38
|
+
}
|
|
39
|
+
const devicePath = await waitForSerialPort({ port, serial: selection.usbSerial, timeoutSeconds: waitSeconds, label: 'USB serial port', log: stderr })
|
|
40
|
+
const serial = await SerialDevice.open({ path: devicePath, baudRate: serialBaudRate(selection, env), trace, stderr })
|
|
41
|
+
return new UsbDevice(serial, { stderr })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class WifiDevice {
|
|
45
|
+
constructor(host, { stderr }) {
|
|
46
|
+
this.kind = 'wifi'
|
|
47
|
+
this.host = host
|
|
48
|
+
this.stderr = stderr
|
|
49
|
+
this.description = `${host} (WiFi)`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async logs({ follow = false, write, timeoutMs, signal }) {
|
|
53
|
+
await tailLogs({ host: this.host, follow, timeoutMs, write, stderr: this.stderr, signal })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async screenshot() {
|
|
57
|
+
return fetchScreenshot({ host: this.host })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async hbm(enabled) {
|
|
61
|
+
return setHighBrightnessMode({ host: this.host, enabled })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async close() {}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class UsbDevice {
|
|
68
|
+
constructor(serial, { stderr }) {
|
|
69
|
+
this.kind = 'usb'
|
|
70
|
+
this.serial = serial
|
|
71
|
+
this.stderr = stderr
|
|
72
|
+
this.description = `${serial.path} (USB)`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async logs({ write, timestamps = false, logFile = '', signal }) {
|
|
76
|
+
await streamSerialMonitor(this.serial, { write, timestamps, logFile, signal })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async screenshot(options = {}) {
|
|
80
|
+
return geadev.screenshot(this.serial, options)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async hbm() {
|
|
84
|
+
fail('High-brightness mode is toggled over WiFi (POST /display/hbm); use --transport wifi.', ExitCode.usage)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async close() {
|
|
88
|
+
await this.serial.close()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function saveScreenshot(device, file, options = {}) {
|
|
93
|
+
const shot = await device.screenshot(options)
|
|
94
|
+
writeImage(file, shot.width, shot.height, shot.rgb)
|
|
95
|
+
return shot
|
|
96
|
+
}
|