@geastack/cli 0.1.53 → 0.1.55
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 +17 -4
- package/docs/ESP32-WAVESHARE-AMOLED-QUICKSTART.md +2 -2
- package/docs/NPX-COMMANDS.md +10 -4
- package/docs/SETUP.md +99 -21
- package/docs/SPEC.md +6 -3
- package/package.json +1 -1
- package/src/boards/config.mjs +90 -8
- package/src/boards/resolve.mjs +5 -3
- package/src/boards/usb.mjs +41 -0
- package/src/chips.mjs +1 -1
- package/src/commands/apps.mjs +1 -33
- package/src/commands/board.mjs +15 -2
- package/src/commands/boards.mjs +334 -0
- package/src/commands/doctor.mjs +4 -4
- package/src/context.mjs +5 -2
- package/src/esp32/idf-version.mjs +89 -0
- package/src/gea.mjs +9 -4
- package/src/serial-devices.mjs +32 -20
- package/src/setup-wizard.mjs +140 -52
package/src/setup-wizard.mjs
CHANGED
|
@@ -3,29 +3,34 @@ import os from 'node:os'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
|
|
5
5
|
import { knownBoards } from './board-catalog.mjs'
|
|
6
|
+
import { boardConfigWritePath, loadBoardConfig } from './boards/config.mjs'
|
|
7
|
+
import { discoverBoards, probeSerialDevice } from './commands/boards.mjs'
|
|
8
|
+
import { discoverApps, resolveRequestedApp, targetEnabledForApp } from './manifest.mjs'
|
|
6
9
|
import { configureChipSelection, loadChipCatalog, validateGpioAssignments } from './chips.mjs'
|
|
7
10
|
import { flag, option } from './args.mjs'
|
|
8
11
|
import { ExitCode, fail } from './errors.mjs'
|
|
12
|
+
import { espIdfVersion as readInstalledEspIdfVersion, findEspIdf } from './esp32/idf-env.mjs'
|
|
13
|
+
import { extractIdfVersionFromText, fetchLatestEspIdfVersion, idfVersionMeetsTarget, resolveEspIdfVersion } from './esp32/idf-version.mjs'
|
|
9
14
|
import { exists, readJson, writeJson } from './fs-utils.mjs'
|
|
10
15
|
import { ask, choose, confirm, createPrompt } from './prompts.mjs'
|
|
11
16
|
import { runExternal } from './run.mjs'
|
|
12
|
-
import { detectSerialDevices
|
|
17
|
+
import { detectSerialDevices } from './serial-devices.mjs'
|
|
13
18
|
import { commandVersion } from './toolchain.mjs'
|
|
14
19
|
|
|
15
|
-
const espIdfVersion = 'v6.0.1'
|
|
16
20
|
const espIdfInstallTargets = 'esp32,esp32s3,esp32p4'
|
|
17
21
|
|
|
18
22
|
export async function runSetupWizard(ctx, parsed, io) {
|
|
19
23
|
const stdout = io.stdout || console.log
|
|
20
24
|
const prompt = createPrompt(io)
|
|
21
25
|
try {
|
|
26
|
+
const targetIdfVersion = await resolveEspIdfVersionForWizard(parsed, io)
|
|
22
27
|
renderHeader(io, 'GeaStack setup', [
|
|
23
28
|
`Project: ${ctx.projectRoot}`,
|
|
24
29
|
`Boards: ${boardConfigPath(ctx, parsed)}`
|
|
25
30
|
])
|
|
26
31
|
if (option(parsed, 'esp-idf') === true) {
|
|
27
32
|
renderStep(io, 'Toolchain', ['Checking ESP-IDF for ESP32 builds.'])
|
|
28
|
-
await maybeSetupEspIdf(ctx, parsed, io, prompt, { force: true })
|
|
33
|
+
await maybeSetupEspIdf(ctx, parsed, io, prompt, { force: true, targetVersion: targetIdfVersion })
|
|
29
34
|
return 0
|
|
30
35
|
}
|
|
31
36
|
const mode = await choose(prompt, {
|
|
@@ -49,7 +54,7 @@ export async function runSetupWizard(ctx, parsed, io) {
|
|
|
49
54
|
{
|
|
50
55
|
value: 'esp-idf',
|
|
51
56
|
label: 'Only install/check ESP-IDF toolchain',
|
|
52
|
-
description: `Installs or verifies ESP-IDF ${
|
|
57
|
+
description: `Installs or verifies ESP-IDF ${targetIdfVersion}.`
|
|
53
58
|
}
|
|
54
59
|
],
|
|
55
60
|
defaultValue: 'known'
|
|
@@ -63,7 +68,7 @@ export async function runSetupWizard(ctx, parsed, io) {
|
|
|
63
68
|
|
|
64
69
|
if (mode === 'esp-idf') {
|
|
65
70
|
renderStep(io, 'Toolchain', ['Checking ESP-IDF for ESP32 builds.'])
|
|
66
|
-
await maybeSetupEspIdf(ctx, parsed, io, prompt, { force: true })
|
|
71
|
+
await maybeSetupEspIdf(ctx, parsed, io, prompt, { force: true, targetVersion: targetIdfVersion })
|
|
67
72
|
return 0
|
|
68
73
|
}
|
|
69
74
|
|
|
@@ -76,11 +81,11 @@ export async function runSetupWizard(ctx, parsed, io) {
|
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
renderStep(io, 'Toolchain', ['Checking ESP-IDF before board initialization.'])
|
|
79
|
-
await maybeSetupEspIdf(ctx, parsed, io, prompt)
|
|
84
|
+
await maybeSetupEspIdf(ctx, parsed, io, prompt, { targetVersion: targetIdfVersion })
|
|
80
85
|
if (option(parsed, 'install') === true) {
|
|
81
86
|
await maybeInstallNpmDependencies(ctx, parsed, io, prompt, { force: true })
|
|
82
87
|
}
|
|
83
|
-
await maybeInitializeBoardTarget(ctx, parsed, io, boardSetup)
|
|
88
|
+
await maybeInitializeBoardTarget(ctx, parsed, io, prompt, boardSetup)
|
|
84
89
|
if (boardSetup?.flashReady) {
|
|
85
90
|
stdout(`Ready: npx gea flash --board ${boardSetup.alias} --monitor`)
|
|
86
91
|
} else {
|
|
@@ -113,8 +118,8 @@ async function setupKnownBoard(ctx, parsed, io, prompt) {
|
|
|
113
118
|
validate: validateAlias
|
|
114
119
|
})
|
|
115
120
|
renderStep(io, 'Connection', ['Use a detected serial device, enter one manually, or skip it for now.'])
|
|
116
|
-
const serial = await selectUsbSerial(prompt, io, {
|
|
117
|
-
message: 'USB serial
|
|
121
|
+
const serial = await selectUsbSerial(prompt, io, ctx, {
|
|
122
|
+
message: 'The USB serial identifies this board whatever port it lands on.'
|
|
118
123
|
})
|
|
119
124
|
const otaHost = await ask(prompt, {
|
|
120
125
|
message: 'OTA host/IP (optional)',
|
|
@@ -230,8 +235,8 @@ async function setupCustomBoard(ctx, parsed, io, prompt) {
|
|
|
230
235
|
}
|
|
231
236
|
|
|
232
237
|
renderStep(io, 'Connection', ['The first flash uses USB. BLE OTA can take over after the initial firmware is running.'])
|
|
233
|
-
const usbSerial = await selectUsbSerial(prompt, io, {
|
|
234
|
-
message: 'USB serial
|
|
238
|
+
const usbSerial = await selectUsbSerial(prompt, io, ctx, {
|
|
239
|
+
message: 'The USB serial identifies this board whatever port it lands on.'
|
|
235
240
|
})
|
|
236
241
|
|
|
237
242
|
const configPath = boardConfigPath(ctx, parsed)
|
|
@@ -367,33 +372,78 @@ async function maybeInstallNpmDependencies(ctx, parsed, io, prompt, { force = fa
|
|
|
367
372
|
})
|
|
368
373
|
}
|
|
369
374
|
|
|
370
|
-
|
|
375
|
+
// A board target is configured for one app (the firmware is built per app,
|
|
376
|
+
// and its CMake refuses to run without one), so initialization needs an
|
|
377
|
+
// app: the current one when the wizard runs inside an app, else the only
|
|
378
|
+
// app in the project that targets this board, else the user's pick.
|
|
379
|
+
async function maybeInitializeBoardTarget(ctx, parsed, io, prompt, boardSetup) {
|
|
371
380
|
if (!boardSetup?.alias || !boardSetup.flashReady) return 0
|
|
372
381
|
if (option(parsed, 'initialize') === false) {
|
|
373
|
-
io.stdout(`Board initialization skipped. Later: npx gea setup --board ${boardSetup.alias}
|
|
382
|
+
io.stdout(`Board initialization skipped. Later: npx gea setup --board ${boardSetup.alias} --app <id>`)
|
|
374
383
|
return 0
|
|
375
384
|
}
|
|
376
385
|
if (!ctx.targetsRoot) {
|
|
377
|
-
io.stdout(`@geastack/targets is not installed. Later: npx gea setup --board ${boardSetup.alias}
|
|
386
|
+
io.stdout(`@geastack/targets is not installed. Later: npx gea setup --board ${boardSetup.alias} --app <id>`)
|
|
378
387
|
return 0
|
|
379
388
|
}
|
|
380
|
-
|
|
389
|
+
const app = await chooseAppForBoard(ctx, parsed, io, prompt, boardSetup.alias)
|
|
390
|
+
if (!app) return 0
|
|
391
|
+
io.stdout(`Initializing board target '${boardSetup.alias}' for app '${app.id}'...`)
|
|
381
392
|
const { buildCommand } = await import('./commands/board.mjs')
|
|
382
|
-
const setupParsed = { ...parsed, options: { ...parsed.options, board: boardSetup.alias, 'configure-only': true } }
|
|
383
|
-
|
|
384
|
-
return buildCommand(reloaded, setupParsed, [], io)
|
|
393
|
+
const setupParsed = { ...parsed, options: { ...parsed.options, board: boardSetup.alias, app: app.id, 'configure-only': true } }
|
|
394
|
+
return buildCommand(ctx, setupParsed, [], io)
|
|
385
395
|
}
|
|
386
396
|
|
|
387
|
-
async function
|
|
397
|
+
async function chooseAppForBoard(ctx, parsed, io, prompt, alias) {
|
|
398
|
+
let current = null
|
|
399
|
+
try {
|
|
400
|
+
current = resolveRequestedApp(ctx, parsed, [])
|
|
401
|
+
} catch {
|
|
402
|
+
current = null
|
|
403
|
+
}
|
|
404
|
+
if (current && targetEnabledForApp(ctx, current, alias)) return current
|
|
405
|
+
const candidates = discoverApps(ctx).filter((app) => targetEnabledForApp(ctx, app, alias))
|
|
406
|
+
if (candidates.length === 0) {
|
|
407
|
+
io.stdout(`No app in ${ctx.projectRoot} targets '${alias}' yet; skipping board initialization. Later: npx gea setup --board ${alias} --app <id>`)
|
|
408
|
+
return null
|
|
409
|
+
}
|
|
410
|
+
if (candidates.length === 1) return candidates[0]
|
|
411
|
+
const picked = await choose(prompt, {
|
|
412
|
+
message: `Which app should the first '${alias}' build target?`,
|
|
413
|
+
choices: [
|
|
414
|
+
...candidates.map((app) => ({ value: app.id, label: app.id, description: app.root })),
|
|
415
|
+
{ value: '', label: 'Skip board initialization for now' }
|
|
416
|
+
],
|
|
417
|
+
defaultValue: candidates[0].id
|
|
418
|
+
})
|
|
419
|
+
return picked ? candidates.find((app) => app.id === picked) : null
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Resolves the ESP-IDF version target once for the whole wizard run:
|
|
423
|
+
// --idf-version / GEA_ESP_IDF_VERSION win outright; otherwise a best-effort
|
|
424
|
+
// GitHub "latest release" lookup, falling back to the pinned default when it
|
|
425
|
+
// cannot be determined. `io.fetchEspIdfLatest` lets callers (tests) inject a
|
|
426
|
+
// fake fetch instead of touching the network -- mirrors `probeSerialDevice`.
|
|
427
|
+
async function resolveEspIdfVersionForWizard(parsed, io) {
|
|
428
|
+
return resolveEspIdfVersion({
|
|
429
|
+
override: option(parsed, 'idf-version'),
|
|
430
|
+
env: io.env || process.env,
|
|
431
|
+
fetchLatest: io.fetchEspIdfLatest || fetchLatestEspIdfVersion,
|
|
432
|
+
log: io.stderr || (() => {})
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function maybeSetupEspIdf(ctx, parsed, io, prompt, { force = false, targetVersion } = {}) {
|
|
388
437
|
const env = io.env || process.env
|
|
389
|
-
const
|
|
438
|
+
const resolvedVersion = targetVersion || await resolveEspIdfVersionForWizard(parsed, io)
|
|
439
|
+
const status = detectEspIdf(env, resolvedVersion)
|
|
390
440
|
if (status.available) {
|
|
391
441
|
if (force) io.stdout(`ESP-IDF found: ${status.detail}`)
|
|
392
442
|
return 0
|
|
393
443
|
}
|
|
394
444
|
|
|
395
445
|
const install = force || await confirm(prompt, {
|
|
396
|
-
message: `ESP-IDF ${
|
|
446
|
+
message: `ESP-IDF ${resolvedVersion} was not found${status.detail ? ` (found ${status.detail})` : ''}. Install it now?`,
|
|
397
447
|
defaultValue: false
|
|
398
448
|
})
|
|
399
449
|
if (!install) {
|
|
@@ -405,7 +455,7 @@ async function maybeSetupEspIdf(ctx, parsed, io, prompt, { force = false } = {})
|
|
|
405
455
|
const dryRun = flag(parsed, 'dry-run')
|
|
406
456
|
if (!dryRun) fs.mkdirSync(path.dirname(idfDir), { recursive: true })
|
|
407
457
|
if (!exists(path.join(idfDir, 'install.sh')) && !exists(path.join(idfDir, 'install.bat'))) {
|
|
408
|
-
runExternal('git', ['clone', '-b',
|
|
458
|
+
runExternal('git', ['clone', '-b', resolvedVersion, '--recursive', 'https://github.com/espressif/esp-idf.git', idfDir], {
|
|
409
459
|
cwd: ctx.cwd,
|
|
410
460
|
env,
|
|
411
461
|
dryRun,
|
|
@@ -429,45 +479,83 @@ async function maybeSetupEspIdf(ctx, parsed, io, prompt, { force = false } = {})
|
|
|
429
479
|
return 0
|
|
430
480
|
}
|
|
431
481
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
482
|
+
// Prefers the same conventional-directory detection the build path uses
|
|
483
|
+
// (`findEspIdf` + its version.cmake), which lets a version-floor check run;
|
|
484
|
+
// falls back to a bare `idf.py --version` on PATH when no such directory is
|
|
485
|
+
// found but some ESP-IDF install has still put idf.py on PATH.
|
|
486
|
+
function detectEspIdf(env, targetVersion) {
|
|
487
|
+
const idfDir = findEspIdf(env)
|
|
488
|
+
if (idfDir) {
|
|
489
|
+
const installed = readInstalledEspIdfVersion(idfDir)
|
|
490
|
+
const detail = `${installed?.full || 'unknown version'} at ${idfDir}`
|
|
491
|
+
return { available: idfVersionMeetsTarget(installed, targetVersion), detail }
|
|
492
|
+
}
|
|
493
|
+
const legacyOutput = commandVersion('idf.py', ['--version'], env)
|
|
494
|
+
if (legacyOutput) {
|
|
495
|
+
const installed = extractIdfVersionFromText(legacyOutput)
|
|
496
|
+
const available = !installed || idfVersionMeetsTarget(installed, targetVersion)
|
|
497
|
+
return { available, detail: legacyOutput }
|
|
498
|
+
}
|
|
436
499
|
return { available: false, detail: '' }
|
|
437
500
|
}
|
|
438
501
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
502
|
+
// Nobody knows their board's USB serial by heart, so the wizard never asks
|
|
503
|
+
// for one. It asks whether the board is plugged in, reads the serial from the
|
|
504
|
+
// USB registry, and PINGs each port so the user picks by what the board says
|
|
505
|
+
// it is running rather than by a /dev name. A board that is not connected is
|
|
506
|
+
// registered without a serial; `gea boards discover --save` fills it in later.
|
|
507
|
+
async function selectUsbSerial(prompt, io, ctx, { message }) {
|
|
508
|
+
const env = io.env || process.env
|
|
509
|
+
io.stdout(message)
|
|
510
|
+
const connected = await confirm(prompt, { message: 'Is the board connected over USB right now?', defaultValue: true })
|
|
511
|
+
if (!connected) {
|
|
512
|
+
io.stdout('No USB serial recorded. Later, with the board plugged in: gea boards discover --save')
|
|
513
|
+
return ''
|
|
514
|
+
}
|
|
515
|
+
const known = loadBoardConfig(ctx)
|
|
516
|
+
const probe = io.probeSerialDevice || ((device) => probeSerialDevice(device, { env }))
|
|
517
|
+
while (true) {
|
|
518
|
+
const devices = detectSerialDevices({ env }).filter((device) => device.serial)
|
|
519
|
+
if (devices.length === 0) {
|
|
520
|
+
io.stdout('No USB board detected. Check the cable (some are power-only) and that the board is on.')
|
|
521
|
+
const retry = await confirm(prompt, { message: 'Retry detection?', defaultValue: true })
|
|
522
|
+
if (retry) continue
|
|
523
|
+
io.stdout('No USB serial recorded. Later, with the board plugged in: gea boards discover --save')
|
|
524
|
+
return ''
|
|
525
|
+
}
|
|
526
|
+
const results = await discoverBoards({ devices, boards: known, probe })
|
|
527
|
+
const describe = (result) => {
|
|
528
|
+
const bits = [result.label && result.label !== result.path ? `${result.label} on ${result.path}` : result.path, `serial ${result.serial}`]
|
|
529
|
+
if (result.responds) bits.push(result.app ? `running ${result.app}` : 'gea firmware')
|
|
530
|
+
if (result.alias) bits.push(`already registered as '${result.alias}'`)
|
|
531
|
+
return bits.join(', ')
|
|
532
|
+
}
|
|
533
|
+
if (results.length === 1) {
|
|
534
|
+
io.stdout(`Detected ${describe(results[0])}`)
|
|
535
|
+
return results[0].serial
|
|
536
|
+
}
|
|
537
|
+
const selected = await choose(prompt, {
|
|
538
|
+
message: 'Several USB devices are connected. Which one is this board?',
|
|
539
|
+
choices: [
|
|
540
|
+
...results.map((result, index) => ({ value: `device-${index}`, label: describe(result) })),
|
|
541
|
+
{ value: 'retry', label: 'Unplug the others and detect again' },
|
|
542
|
+
{ value: 'skip', label: 'Skip for now' }
|
|
543
|
+
],
|
|
544
|
+
defaultValue: 'device-0'
|
|
445
545
|
})
|
|
546
|
+
if (selected === 'skip') return ''
|
|
547
|
+
if (selected === 'retry') continue
|
|
548
|
+
return results[Number.parseInt(selected.slice('device-'.length), 10)].serial
|
|
446
549
|
}
|
|
447
|
-
|
|
448
|
-
const selected = await choose(prompt, {
|
|
449
|
-
message: 'Detected serial devices. Which board is connected?',
|
|
450
|
-
choices: [
|
|
451
|
-
...devices.map((device, index) => ({ value: `device-${index}`, label: formatSerialDevice(device) })),
|
|
452
|
-
{ value: 'manual', label: 'Enter stable USB serial manually' },
|
|
453
|
-
{ value: 'skip', label: 'Skip for now' }
|
|
454
|
-
],
|
|
455
|
-
defaultValue: 'device-0'
|
|
456
|
-
})
|
|
457
|
-
if (selected === 'skip') return ''
|
|
458
|
-
if (selected === 'manual') return ask(prompt, { message, defaultValue: '' })
|
|
459
|
-
const index = Number.parseInt(selected.slice('device-'.length), 10)
|
|
460
|
-
const device = devices[index]
|
|
461
|
-
return ask(prompt, {
|
|
462
|
-
message: `Stable USB serial for ${device.path}`,
|
|
463
|
-
defaultValue: device.serial || ''
|
|
464
|
-
})
|
|
465
550
|
}
|
|
466
551
|
|
|
552
|
+
// --global writes the alias to ~/.geastack/boards.json, --local to the
|
|
553
|
+
// project's .gea/boards.json; otherwise the project config when it exists,
|
|
554
|
+
// else the home one (src/boards/config.mjs owns that rule).
|
|
467
555
|
function boardConfigPath(ctx, parsed) {
|
|
468
|
-
const explicit = option(parsed, 'boards-config')
|
|
556
|
+
const explicit = option(parsed, 'boards-config')
|
|
469
557
|
if (explicit) return path.resolve(ctx.cwd, explicit)
|
|
470
|
-
return ctx
|
|
558
|
+
return boardConfigWritePath(ctx, { scope: flag(parsed, 'global') ? 'global' : flag(parsed, 'local') ? 'project' : '' })
|
|
471
559
|
}
|
|
472
560
|
|
|
473
561
|
function readBoardConfig(filePath) {
|