@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.
@@ -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, formatSerialDevice } from './serial-devices.mjs'
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 ${espIdfVersion}.`
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 number (leave blank to pass --port manually)'
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 number (leave blank to auto-detect or pass --port)'
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
- async function maybeInitializeBoardTarget(ctx, parsed, io, boardSetup) {
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
- io.stdout(`Initializing board target '${boardSetup.alias}'...`)
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
- const reloaded = { ...ctx, boardsConfig: boardConfigPath(ctx, parsed) }
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 maybeSetupEspIdf(ctx, parsed, io, prompt, { force = false } = {}) {
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 status = detectEspIdf(env)
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 ${espIdfVersion} was not found. Install it now?`,
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', espIdfVersion, '--recursive', 'https://github.com/espressif/esp-idf.git', idfDir], {
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
- function detectEspIdf(env) {
433
- const idfVersion = commandVersion('idf.py', ['--version'], env)
434
- if (idfVersion) return { available: true, detail: idfVersion }
435
- if (env.IDF_PATH) return { available: true, detail: env.IDF_PATH }
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
- async function selectUsbSerial(prompt, io, { message }) {
440
- const devices = detectSerialDevices({ env: io.env || process.env })
441
- if (devices.length === 0) {
442
- return ask(prompt, {
443
- message,
444
- defaultValue: ''
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') || ctx.boardsConfig
556
+ const explicit = option(parsed, 'boards-config')
469
557
  if (explicit) return path.resolve(ctx.cwd, explicit)
470
- return ctx.projectBoardsConfig || path.join(ctx.cwd, '.gea', 'boards.json')
558
+ return boardConfigWritePath(ctx, { scope: flag(parsed, 'global') ? 'global' : flag(parsed, 'local') ? 'project' : '' })
471
559
  }
472
560
 
473
561
  function readBoardConfig(filePath) {