@geastack/cli 0.1.54 → 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 CHANGED
@@ -60,7 +60,7 @@ flowchart TD
60
60
  flowchart TD
61
61
  setup["npx gea setup"] --> mode{"Mode?"}
62
62
 
63
- mode -->|"--esp-idf"| directIdf["Install or check ESP-IDF v6.0.1"]
63
+ mode -->|"--esp-idf"| directIdf["Install or check ESP-IDF v6.0.2"]
64
64
  mode -->|"--board alias"| directBoard["Run target setup for board alias"]
65
65
  mode -->|"--target target-id"| directTarget["Run target setup directly"]
66
66
 
@@ -69,7 +69,7 @@ flowchart TD
69
69
  interactive -->|"Known supported board"| knownBoard["Pick board"]
70
70
  knownBoard --> alias["Set board alias"]
71
71
  alias --> serial["Detect serial devices"]
72
- serial --> saveSerial["Save stable USB serial"]
72
+ serial --> saveSerial["Detect the USB serial (registry + GEADEV ping)"]
73
73
  saveSerial --> ota["Optional OTA host"]
74
74
  ota --> reviewKnown["Review board setup"]
75
75
  reviewKnown --> writeKnown["Write .gea/boards.json"]
@@ -83,7 +83,7 @@ flowchart TD
83
83
  writeProfile --> writeCustomAlias["Write alias to .gea/boards.json"]
84
84
 
85
85
  interactive -->|"npm dependencies only"| npmInstall["Run npm install when package.json exists"]
86
- interactive -->|"ESP-IDF toolchain only"| idfOnly["Install or check ESP-IDF v6.0.1"]
86
+ interactive -->|"ESP-IDF toolchain only"| idfOnly["Install or check ESP-IDF v6.0.2"]
87
87
 
88
88
  writeKnown --> initialize["Initialize board target"]
89
89
  writeCustomAlias --> initialize
@@ -93,8 +93,9 @@ packages are scoped and configured for restricted npmjs publication.
93
93
  - npm dependency check/install only;
94
94
  - ESP-IDF toolchain check/install only.
95
95
 
96
- Known-board setup detects attached serial devices, asks for a stable USB serial,
97
- shows a review screen, writes a board alias into the active boards config
96
+ Known-board setup asks whether the board is plugged in, reads each USB
97
+ device's serial from the OS and pings it (so a board is picked by the app it
98
+ reports running, never by a `/dev` name), shows a review screen, writes a board alias into the active boards config
98
99
  (`--global` for `~/.geastack/boards.json`, `--local` for the project's
99
100
  `.gea/boards.json`; by default the project config when it exists), then
100
101
  initializes the selected board target so the next command can be
@@ -140,5 +141,8 @@ ESP-IDF setup is available as:
140
141
  npx gea setup --esp-idf
141
142
  ```
142
143
 
143
- It installs or dry-runs ESP-IDF v6.0.1 with the ESP32, ESP32-S3, and ESP32-P4
144
- toolchains.
144
+ It installs or dry-runs ESP-IDF with the ESP32, ESP32-S3, and ESP32-P4
145
+ toolchains. The version is resolved dynamically: `--idf-version <tag>` or
146
+ `GEA_ESP_IDF_VERSION` pins an exact release, otherwise GeaStack tries the
147
+ latest stable ESP-IDF release on GitHub and falls back to its pinned default
148
+ (currently v6.0.2) when that cannot be determined.
package/docs/SETUP.md CHANGED
@@ -34,7 +34,7 @@ For ESP32 hardware:
34
34
  - Node.js 20.19 or newer.
35
35
  - npm.
36
36
  - Python 3.
37
- - ESP-IDF v6.0.1.
37
+ - ESP-IDF v6.0.2 (GeaStack's pinned default; see ESP-IDF For ESP32 Targets below).
38
38
  - a board alias for your board (`~/.geastack/boards.json` or the project's `.gea/boards.json`, see Board Configuration).
39
39
 
40
40
  For the Waveshare ESP32-S3 AMOLED board, use
@@ -69,9 +69,21 @@ npm --version
69
69
 
70
70
  ## ESP-IDF For ESP32 Targets
71
71
 
72
- The embedded board scripts currently target ESP-IDF v6.0.1. Newer ESP-IDF
73
- 6.0.x releases may work, but v6.0.1 is the known target until the board scripts
74
- are updated.
72
+ The embedded board scripts target ESP-IDF v6.0.2 by default. GeaStack resolves
73
+ the version it installs or verifies dynamically, in this order:
74
+
75
+ 1. `--idf-version <tag>` (e.g. `npx gea setup --esp-idf --idf-version v6.1.0-rc1`)
76
+ to pin an exact release or try a release candidate.
77
+ 2. `GEA_ESP_IDF_VERSION` (same shape) when no `--idf-version` is given.
78
+ 3. The latest stable ESP-IDF release on GitHub, when it can be determined
79
+ (release candidates and betas are ignored).
80
+ 4. The pinned default, v6.0.2, when nothing above applies (offline, GitHub
81
+ unreachable, etc).
82
+
83
+ Verifying an existing install accepts any installed version whose
84
+ major.minor is the same as or newer than the resolved target -- an installed
85
+ 6.0.2 is never rejected just because the resolved target moved on to, say,
86
+ 6.1.0, unless it genuinely trails it.
75
87
 
76
88
  Command-line install:
77
89
 
@@ -84,7 +96,7 @@ Equivalent manual install:
84
96
  ```sh
85
97
  mkdir -p "$HOME/esp"
86
98
  cd "$HOME/esp"
87
- git clone -b v6.0.1 --recursive https://github.com/espressif/esp-idf.git
99
+ git clone -b v6.0.2 --recursive https://github.com/espressif/esp-idf.git
88
100
  cd esp-idf
89
101
  ./install.sh esp32,esp32s3,esp32p4
90
102
  . ./export.sh
@@ -106,7 +118,7 @@ export GEA_EMBEDDED_IDF_EXPORT="/path/to/esp-idf/export.sh"
106
118
  ```
107
119
 
108
120
  The board script also checks common locations such as `$HOME/esp/esp-idf`,
109
- `$HOME/esp32/esp-idf`, and `$HOME/esp32/esp-idf-v6.0.1`.
121
+ `$HOME/esp32/esp-idf`, and `$HOME/esp32/esp-idf-v6.0.2`.
110
122
 
111
123
  Verify through GeaStack:
112
124
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geastack/cli",
3
- "version": "0.1.54",
3
+ "version": "0.1.55",
4
4
  "type": "module",
5
5
  "description": "Command-line front door for GeaStack apps, targets, and local toolchains.",
6
6
  "publishConfig": {
@@ -11,7 +11,7 @@ import { eraseSlot, flashFirmware, flashImageSet, flashOptions, postFlashRestart
11
11
  import { bleOta, otaEraseSlot, otaFlash, otaStage, waitForReboot } from '../esp32/ota.mjs'
12
12
  import { manifestRequestsBleOta } from '../esp32/capabilities.mjs'
13
13
  import { runGeaos } from '../geaos/adapter.mjs'
14
- import { assertTargetEnabled, assertValidApp, resolveRequestedApp } from '../manifest.mjs'
14
+ import { assertTargetEnabled, assertValidApp, discoverApps, resolveRequestedApp, targetEnabledForApp } from '../manifest.mjs'
15
15
  import { buildRp2350, flashRp2350, rp2350BuildDir } from '../rp2350/adapter.mjs'
16
16
  import { runTargetHook } from '../taurus/adapter.mjs'
17
17
 
@@ -69,9 +69,22 @@ function io(parsed, options) {
69
69
 
70
70
  // ---- build ------------------------------------------------------------------
71
71
 
72
+ // Firmware is built per app and the ESP32/RP2350 CMake refuses to configure
73
+ // without one (its script-mode pass would otherwise analyze a directory), so
74
+ // those adapters need an app even for --configure-only; the geaos and taurus
75
+ // adapters have app-less actions.
76
+ const appRequiredAdapters = new Set(['esp32-idf', 'rp2350-pico'])
77
+
78
+ function requireAppForAdapter(ctx, parsed, selection, app) {
79
+ if (app || !appRequiredAdapters.has(selection.adapter)) return app
80
+ const candidates = discoverApps(ctx).filter((candidate) => targetEnabledForApp(ctx, candidate, selection.boardName || selection.target))
81
+ const hint = candidates.length > 0 ? `apps targeting '${selection.boardName || selection.target}': ${candidates.map((candidate) => candidate.id).join(', ')}` : `no app in ${ctx.projectRoot} targets '${selection.boardName || selection.target}' yet`
82
+ fail(`Board '${selection.boardName || selection.target}' builds one app at a time: pass --app <id> or run inside the app folder (${hint}).`, ExitCode.usage)
83
+ }
84
+
72
85
  export async function buildCommand(ctx, parsed, rest, options) {
73
86
  const selection = selectBoard(ctx, parsed)
74
- const app = optionalApp(ctx, parsed, rest, selection)
87
+ const app = requireAppForAdapter(ctx, parsed, selection, optionalApp(ctx, parsed, rest, selection))
75
88
  const base = io(parsed, options)
76
89
  const env = createChildEnv(ctx, base.env)
77
90
  switch (selection.adapter) {
@@ -0,0 +1,89 @@
1
+ // ESP-IDF target-version resolution.
2
+ //
3
+ // One pinned default in code, an env var / CLI option that overrides it
4
+ // outright (pin an older release or try a release candidate), and a
5
+ // best-effort GitHub "latest release" lookup used only when nothing
6
+ // overrides the pin. The network call is injected (`fetchImpl`/`fetchLatest`)
7
+ // so real usage hits GitHub while tests supply a fake and never touch the
8
+ // network.
9
+
10
+ const STABLE_TAG = /^v(\d+)\.(\d+)\.(\d+)$/
11
+ const VERSION_IN_TEXT = /v?(\d+)\.(\d+)\.(\d+)/i
12
+
13
+ // The version GeaStack targets when nothing overrides it and the latest
14
+ // release cannot be determined (offline, GitHub unreachable, rate limited).
15
+ // Bump this alongside board-script updates.
16
+ export const DEFAULT_ESP_IDF_VERSION = 'v6.0.2'
17
+
18
+ const GITHUB_LATEST_RELEASE_URL = 'https://api.github.com/repos/espressif/esp-idf/releases/latest'
19
+
20
+ // Parses a strict `vX.Y.Z` release tag. Returns null for anything else,
21
+ // including release candidates and betas (`v6.1.0-rc1`), so callers can
22
+ // filter those out of the "latest" lookup.
23
+ export function parseStableIdfTag(value) {
24
+ const match = STABLE_TAG.exec(String(value || '').trim())
25
+ if (!match) return null
26
+ return { tag: match[0], major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) }
27
+ }
28
+
29
+ // Pulls a `major.minor(.patch)` out of free-form text such as
30
+ // `idf.py --version`'s `ESP-IDF v6.0.2-dirty` output.
31
+ export function extractIdfVersionFromText(text) {
32
+ const match = VERSION_IN_TEXT.exec(String(text || ''))
33
+ if (!match) return null
34
+ return { majorMinor: `${match[1]}.${match[2]}`, full: `${match[1]}.${match[2]}.${match[3]}` }
35
+ }
36
+
37
+ // Best-effort lookup of the latest stable ESP-IDF release tag from GitHub.
38
+ // Never throws: returns '' when the request fails, times out (~4s default),
39
+ // the machine is offline, or the latest release is not a stable vX.Y.Z tag.
40
+ // `fetchImpl` defaults to the global fetch; tests inject a fake instead of
41
+ // touching the network.
42
+ export async function fetchLatestEspIdfVersion({ fetchImpl = globalThis.fetch, timeoutMs = 4000, log = () => {} } = {}) {
43
+ if (typeof fetchImpl !== 'function') return ''
44
+ const controller = new AbortController()
45
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
46
+ try {
47
+ const response = await fetchImpl(GITHUB_LATEST_RELEASE_URL, {
48
+ signal: controller.signal,
49
+ headers: { Accept: 'application/vnd.github+json' }
50
+ })
51
+ if (!response?.ok) return ''
52
+ const body = await response.json()
53
+ return parseStableIdfTag(body?.tag_name)?.tag || ''
54
+ } catch (error) {
55
+ log(`Could not determine the latest ESP-IDF release (${error.message}); using ${DEFAULT_ESP_IDF_VERSION}.`)
56
+ return ''
57
+ } finally {
58
+ clearTimeout(timer)
59
+ }
60
+ }
61
+
62
+ // Resolves the ESP-IDF version tag to install/verify against, in order:
63
+ // 1. `override` (--idf-version) or GEA_ESP_IDF_VERSION -- explicit pin or
64
+ // a release candidate to test; used as-is (not required to be stable).
65
+ // 2. the latest stable GitHub release, when `fetchLatest` can determine one.
66
+ // 3. DEFAULT_ESP_IDF_VERSION.
67
+ export async function resolveEspIdfVersion({ override, env = process.env, fetchLatest = fetchLatestEspIdfVersion, log = () => {} } = {}) {
68
+ const requested = override || env.GEA_ESP_IDF_VERSION
69
+ if (requested) return requested.startsWith('v') ? requested : `v${requested}`
70
+ const latest = await fetchLatest({ log })
71
+ return latest || DEFAULT_ESP_IDF_VERSION
72
+ }
73
+
74
+ // True when an installed ESP-IDF's version.cmake-derived version satisfies
75
+ // the resolved target: same-or-newer major.minor. This is a floor, not an
76
+ // exact-match: an installed 6.0.2 must not be rejected just because the
77
+ // resolved target moved on to, say, 6.1.0 -- reinstalling isn't required
78
+ // unless the installed major.minor genuinely trails the target's.
79
+ // An unparsable target is treated as "anything installed is acceptable".
80
+ export function idfVersionMeetsTarget(installedVersion, targetTag) {
81
+ const target = extractIdfVersionFromText(targetTag)
82
+ if (!target) return true
83
+ const [targetMajor, targetMinor] = target.majorMinor.split('.').map(Number)
84
+ const parts = String(installedVersion?.majorMinor || '').split('.').map(Number)
85
+ if (parts.length < 2 || parts.some((part) => Number.isNaN(part))) return false
86
+ const [installedMajor, installedMinor] = parts
87
+ if (installedMajor !== targetMajor) return installedMajor > targetMajor
88
+ return installedMinor >= targetMinor
89
+ }
package/src/gea.mjs CHANGED
@@ -43,8 +43,10 @@ export async function runGea(argv, io = {}) {
43
43
 
44
44
  const ctx = createContext(parsed, env, cwd)
45
45
  const rest = parsed.positionals.slice(1)
46
- // probeSerialDevice lets tests answer `gea boards discover` without a port.
47
- const options = { stdout, stderr, env, stdin, output, prompt, probeSerialDevice: io.probeSerialDevice }
46
+ // probeSerialDevice lets tests answer `gea boards discover` without a port;
47
+ // fetchEspIdfLatest likewise lets tests answer the setup wizard's ESP-IDF
48
+ // "latest release" lookup without touching the network.
49
+ const options = { stdout, stderr, env, stdin, output, prompt, probeSerialDevice: io.probeSerialDevice, fetchEspIdfLatest: io.fetchEspIdfLatest }
48
50
 
49
51
  // A platform name in --target (web, macos, ...) is not a board.
50
52
  const target = option(parsed, 'target', '')
@@ -3,30 +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 } from './boards/config.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'
7
9
  import { configureChipSelection, loadChipCatalog, validateGpioAssignments } from './chips.mjs'
8
10
  import { flag, option } from './args.mjs'
9
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'
10
14
  import { exists, readJson, writeJson } from './fs-utils.mjs'
11
15
  import { ask, choose, confirm, createPrompt } from './prompts.mjs'
12
16
  import { runExternal } from './run.mjs'
13
- import { detectSerialDevices, formatSerialDevice } from './serial-devices.mjs'
17
+ import { detectSerialDevices } from './serial-devices.mjs'
14
18
  import { commandVersion } from './toolchain.mjs'
15
19
 
16
- const espIdfVersion = 'v6.0.1'
17
20
  const espIdfInstallTargets = 'esp32,esp32s3,esp32p4'
18
21
 
19
22
  export async function runSetupWizard(ctx, parsed, io) {
20
23
  const stdout = io.stdout || console.log
21
24
  const prompt = createPrompt(io)
22
25
  try {
26
+ const targetIdfVersion = await resolveEspIdfVersionForWizard(parsed, io)
23
27
  renderHeader(io, 'GeaStack setup', [
24
28
  `Project: ${ctx.projectRoot}`,
25
29
  `Boards: ${boardConfigPath(ctx, parsed)}`
26
30
  ])
27
31
  if (option(parsed, 'esp-idf') === true) {
28
32
  renderStep(io, 'Toolchain', ['Checking ESP-IDF for ESP32 builds.'])
29
- await maybeSetupEspIdf(ctx, parsed, io, prompt, { force: true })
33
+ await maybeSetupEspIdf(ctx, parsed, io, prompt, { force: true, targetVersion: targetIdfVersion })
30
34
  return 0
31
35
  }
32
36
  const mode = await choose(prompt, {
@@ -50,7 +54,7 @@ export async function runSetupWizard(ctx, parsed, io) {
50
54
  {
51
55
  value: 'esp-idf',
52
56
  label: 'Only install/check ESP-IDF toolchain',
53
- description: `Installs or verifies ESP-IDF ${espIdfVersion}.`
57
+ description: `Installs or verifies ESP-IDF ${targetIdfVersion}.`
54
58
  }
55
59
  ],
56
60
  defaultValue: 'known'
@@ -64,7 +68,7 @@ export async function runSetupWizard(ctx, parsed, io) {
64
68
 
65
69
  if (mode === 'esp-idf') {
66
70
  renderStep(io, 'Toolchain', ['Checking ESP-IDF for ESP32 builds.'])
67
- await maybeSetupEspIdf(ctx, parsed, io, prompt, { force: true })
71
+ await maybeSetupEspIdf(ctx, parsed, io, prompt, { force: true, targetVersion: targetIdfVersion })
68
72
  return 0
69
73
  }
70
74
 
@@ -77,11 +81,11 @@ export async function runSetupWizard(ctx, parsed, io) {
77
81
  }
78
82
 
79
83
  renderStep(io, 'Toolchain', ['Checking ESP-IDF before board initialization.'])
80
- await maybeSetupEspIdf(ctx, parsed, io, prompt)
84
+ await maybeSetupEspIdf(ctx, parsed, io, prompt, { targetVersion: targetIdfVersion })
81
85
  if (option(parsed, 'install') === true) {
82
86
  await maybeInstallNpmDependencies(ctx, parsed, io, prompt, { force: true })
83
87
  }
84
- await maybeInitializeBoardTarget(ctx, parsed, io, boardSetup)
88
+ await maybeInitializeBoardTarget(ctx, parsed, io, prompt, boardSetup)
85
89
  if (boardSetup?.flashReady) {
86
90
  stdout(`Ready: npx gea flash --board ${boardSetup.alias} --monitor`)
87
91
  } else {
@@ -114,8 +118,8 @@ async function setupKnownBoard(ctx, parsed, io, prompt) {
114
118
  validate: validateAlias
115
119
  })
116
120
  renderStep(io, 'Connection', ['Use a detected serial device, enter one manually, or skip it for now.'])
117
- const serial = await selectUsbSerial(prompt, io, {
118
- 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.'
119
123
  })
120
124
  const otaHost = await ask(prompt, {
121
125
  message: 'OTA host/IP (optional)',
@@ -231,8 +235,8 @@ async function setupCustomBoard(ctx, parsed, io, prompt) {
231
235
  }
232
236
 
233
237
  renderStep(io, 'Connection', ['The first flash uses USB. BLE OTA can take over after the initial firmware is running.'])
234
- const usbSerial = await selectUsbSerial(prompt, io, {
235
- 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.'
236
240
  })
237
241
 
238
242
  const configPath = boardConfigPath(ctx, parsed)
@@ -368,32 +372,78 @@ async function maybeInstallNpmDependencies(ctx, parsed, io, prompt, { force = fa
368
372
  })
369
373
  }
370
374
 
371
- 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) {
372
380
  if (!boardSetup?.alias || !boardSetup.flashReady) return 0
373
381
  if (option(parsed, 'initialize') === false) {
374
- 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>`)
375
383
  return 0
376
384
  }
377
385
  if (!ctx.targetsRoot) {
378
- 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>`)
379
387
  return 0
380
388
  }
381
- 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}'...`)
382
392
  const { buildCommand } = await import('./commands/board.mjs')
383
- const setupParsed = { ...parsed, options: { ...parsed.options, board: boardSetup.alias, 'configure-only': true } }
393
+ const setupParsed = { ...parsed, options: { ...parsed.options, board: boardSetup.alias, app: app.id, 'configure-only': true } }
384
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,39 +479,74 @@ 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
 
467
552
  // --global writes the alias to ~/.geastack/boards.json, --local to the