@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 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
@@ -100,6 +100,18 @@ host bindings, and the other native packages from npm. A project can use a
100
100
  local CLI with `npx gea` or a global installation with `gea`; neither command
101
101
  depends on a GeaStack source checkout.
102
102
 
103
+ Boards are managed without editing JSON by hand; aliases live in
104
+ `~/.geastack/boards.json` (this machine) and the project's `.gea/boards.json`
105
+ (overrides):
106
+
107
+ ```sh
108
+ gea boards discover # which registered board is on which USB port, its app and IP
109
+ gea boards list
110
+ gea boards set amoled host 192.168.1.100
111
+ gea boards rename amoled desk-amoled
112
+ gea boards remove desk-amoled
113
+ ```
114
+
103
115
  Custom boards remain editable after setup:
104
116
 
105
117
  ```sh
@@ -169,4 +181,5 @@ First implementation is in place:
169
181
  - `list` and `inspect` helpers;
170
182
  - `create-geastack` with a bundled counter starter, a blank application, and a
171
183
  GitHub-backed rich example flow for web, embedded, GeaOS, iOS, macOS, and Android apps,
172
- all with `.gea/boards.json`.
184
+ all with `.gea/boards.json`, plus `gea boards` for machine-wide aliases in
185
+ `~/.geastack/boards.json`.
@@ -90,7 +90,7 @@ You do not need to run that before every command unless `gea doctor` says ESP-ID
90
90
 
91
91
  If the wizard finds a connected serial device, pick it. If no board is plugged in yet, that is fine; you can provide the port later.
92
92
 
93
- The wizard writes the board config into your app at `.gea/boards.json` and initializes the selected board target.
93
+ The wizard writes the board alias into your app's `.gea/boards.json` (or `~/.geastack/boards.json` with `--global`, for every project on this machine) and initializes the selected board target.
94
94
 
95
95
  ## 4. Flash
96
96
 
@@ -134,4 +134,4 @@ Most first-run issues are one of:
134
134
  - ESP-IDF is not activated in the current shell.
135
135
  - The USB cable is power-only.
136
136
  - The serial port needs to be passed with `--port`.
137
- - The board alias does not match the name in `.gea/boards.json`.
137
+ - The board alias does not match a name in `npx gea boards list` (`~/.geastack/boards.json` plus the app's `.gea/boards.json`). `npx gea boards discover` shows which registered board each USB port is.
@@ -93,8 +93,11 @@ 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, then
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
99
+ (`--global` for `~/.geastack/boards.json`, `--local` for the project's
100
+ `.gea/boards.json`; by default the project config when it exists), then
98
101
  initializes the selected board target so the next command can be
99
102
  `npx gea flash --board <alias> --monitor`. In generated apps, that config is:
100
103
 
@@ -138,5 +141,8 @@ ESP-IDF setup is available as:
138
141
  npx gea setup --esp-idf
139
142
  ```
140
143
 
141
- It installs or dry-runs ESP-IDF v6.0.1 with the ESP32, ESP32-S3, and ESP32-P4
142
- 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,8 +34,8 @@ For ESP32 hardware:
34
34
  - Node.js 20.19 or newer.
35
35
  - npm.
36
36
  - Python 3.
37
- - ESP-IDF v6.0.1.
38
- - `.gea/boards.json` configured for your board.
37
+ - ESP-IDF v6.0.2 (GeaStack's pinned default; see ESP-IDF For ESP32 Targets below).
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
41
41
  [ESP32-WAVESHARE-AMOLED-QUICKSTART.md](ESP32-WAVESHARE-AMOLED-QUICKSTART.md).
@@ -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
 
@@ -224,9 +236,44 @@ manager or from python.org.
224
236
 
225
237
  ## Board Configuration
226
238
 
227
- Board aliases are project-local machine configuration. `create-geastack`
228
- creates an empty `.gea/boards.json`, and `npx gea setup` writes aliases there.
229
- Example:
239
+ A board alias names one physical unit: `gea flash --board amoled` resolves
240
+ `amoled` to a target, a USB serial and, optionally, an IP. Aliases are
241
+ machine-local configuration and never come from a package. The CLI merges two
242
+ files, project over home:
243
+
244
+ | file | holds | written by |
245
+ | --- | --- | --- |
246
+ | `~/.geastack/boards.json` | every board on this machine (`GEA_HOME` relocates the directory) | `gea boards add --global`, `gea boards set` |
247
+ | `<project>/.gea/boards.json` | aliases specific to one application; overrides a home alias of the same name | `create-geastack` (empty), `gea boards add` |
248
+
249
+ `--boards-config <file>` (or `GEA_BOARDS_CONFIG`) replaces both with exactly
250
+ that file. By default `gea boards add` writes to the project config when the
251
+ project has one and to the home config otherwise; an existing alias is always
252
+ edited where it lives.
253
+
254
+ ```sh
255
+ npx gea boards discover # what is plugged in: alias, app, IP
256
+ npx gea boards add # register a board (guided)
257
+ npx gea boards list # every alias and which file it lives in
258
+ npx gea boards set amoled host 192.168.1.100
259
+ npx gea boards rename amoled desk-amoled
260
+ npx gea boards remove desk-amoled
261
+ npx gea doctor
262
+ ```
263
+
264
+ `gea boards discover` sends one `GEADEV PING` to each serial device without
265
+ resetting it; a board running gea firmware answers with its app id, its IP
266
+ (when it has joined WiFi) and its MAC, and the CLI pairs the reply with an
267
+ alias by USB serial. `--save` writes a reported IP into
268
+ `transports.ota.host`, which is what `gea ota`, `gea logs` and
269
+ `gea screenshot` use over WiFi.
270
+
271
+ ### Entry shapes
272
+
273
+ Every entry names a `target` (`gea targets list`) and its `adapter`, then the
274
+ transports the board offers. The USB serial is the stable identity: on ESP32
275
+ boards it is the station MAC, and the CLI resolves it to today's `/dev` port
276
+ at call time, so never record a port path.
230
277
 
231
278
  ```json
232
279
  {
@@ -234,22 +281,53 @@ Example:
234
281
  "target": "esp32-s3-touch-amoled-2.06",
235
282
  "adapter": "esp32-idf",
236
283
  "transports": {
237
- "usbSerial": {
238
- "serial": "YOUR_BOARD_USB_SERIAL"
239
- }
284
+ "usbSerial": { "serial": "80:B5:4E:DA:73:88" },
285
+ "ota": { "host": "192.168.1.100" }
286
+ }
287
+ },
288
+ "rotary": {
289
+ "target": "esp32-s3-elecrow-rotary-2.1",
290
+ "adapter": "esp32-idf",
291
+ "transports": {
292
+ "usbSerial": { "serial": "14:C1:9F:26:65:08", "restartAfterFlash": "manual" }
293
+ }
294
+ },
295
+ "tufty": {
296
+ "target": "rp2350-tufty-2350",
297
+ "adapter": "rp2350-pico",
298
+ "transports": { "usbSerial": { "serial": "fa59949adbb4802f" } }
299
+ },
300
+ "linux": {
301
+ "target": "geaos",
302
+ "adapter": "geaos-linux",
303
+ "transports": {
304
+ "telnet": { "host": "192.168.7.2", "port": 2323 },
305
+ "fastboot": { "serial": "geaos001" }
306
+ }
307
+ },
308
+ "lokmat": {
309
+ "target": "lokmat-applp2max",
310
+ "adapter": "geaos-arm64",
311
+ "transports": {
312
+ "usbSerial": { "serial": "geaos01" },
313
+ "fastboot": { "serial": "0123456789ABCDEF" },
314
+ "mtk": { "workdir": "~/lokmat-root" }
240
315
  }
316
+ },
317
+ "my-board": {
318
+ "target": "my-board",
319
+ "targetDefinition": "targets/my-board.json",
320
+ "adapter": "esp32-idf",
321
+ "transports": { "usbSerial": { "serial": "YOUR_BOARD_USB_SERIAL" } }
241
322
  }
242
323
  }
243
324
  ```
244
325
 
245
- Then check discovery:
246
-
247
- ```sh
248
- npx gea setup
249
- npx gea list boards
250
- npx gea list targets
251
- npx gea doctor
252
- ```
326
+ - `restartAfterFlash: "manual"` marks a board whose USB-Serial-JTAG port
327
+ re-enters ROM download mode after a flash; the CLI stops and asks for a
328
+ power cycle instead of pulsing the reset lines.
329
+ - `targetDefinition` points at a custom target composed by `gea setup` /
330
+ `gea chips`, relative to the file the alias lives in.
253
331
 
254
332
  ## Common Verification Flow
255
333
 
package/docs/SPEC.md CHANGED
@@ -83,9 +83,12 @@ gea flash --app bouncing-balls-jsx --board amoled --monitor
83
83
  gea flash --app css-3d-cube --target android
84
84
  ```
85
85
 
86
- The CLI should pass through board aliases from the active board config. A project
87
- uses its own `.gea/boards.json`; otherwise the CLI reads the board catalog shipped
88
- by the installed `@geastack/targets` package.
86
+ The CLI resolves board aliases from two machine-local files merged project over
87
+ home: `~/.geastack/boards.json` (every board on the machine, `GEA_HOME`
88
+ relocates it) and the project's `.gea/boards.json`. `--boards-config` replaces
89
+ both. No package ships aliases. `gea boards` manages them: `list`, `show`,
90
+ `add`, `set <alias> <key> <value>`, `remove`, `rename`, and `discover`, which
91
+ identifies connected boards over USB (`GEADEV PING` reports app, IP and MAC).
89
92
 
90
93
  ### `gea chips`
91
94
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geastack/cli",
3
- "version": "0.1.53",
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": {
@@ -1,19 +1,70 @@
1
+ import os from 'node:os'
1
2
  import path from 'node:path'
2
3
 
3
4
  import { ExitCode, fail } from '../errors.mjs'
4
- import { exists, readJson } from '../fs-utils.mjs'
5
+ import { exists, readJson, writeJson } from '../fs-utils.mjs'
5
6
 
6
- // Board aliases: an explicit --boards-config, else the project's
7
- // .gea/boards.json, else the boards.json shipped by @geastack/targets.
7
+ // Board aliases are machine-local configuration: which physical board answers
8
+ // to `--board amoled`, its USB serial, its IP. They live in two tiers that
9
+ // are merged, project over home:
10
+ //
11
+ // ~/.geastack/boards.json every board on this machine (GEA_HOME overrides
12
+ // the directory)
13
+ // <project>/.gea/boards.json aliases specific to one project, overriding a
14
+ // home alias of the same name
15
+ //
16
+ // An explicit --boards-config (or GEA_BOARDS_CONFIG) replaces both tiers: a
17
+ // caller naming a file wants exactly that file. Nothing is ever read from an
18
+ // installed package -- a board catalog shipped in @geastack/targets was a
19
+ // development convenience that described one developer's bench, and every
20
+ // npm install would have to overwrite it.
21
+
22
+ export function homeBoardsConfigPath(env = process.env) {
23
+ const home = env.GEA_HOME || path.join(env.HOME || env.USERPROFILE || os.homedir(), '.geastack')
24
+ return path.join(home, 'boards.json')
25
+ }
26
+
27
+ // Every file that contributes aliases, lowest precedence first. The list is
28
+ // the same whether or not the files exist so writers can target a tier that
29
+ // has not been created yet.
30
+ export function boardConfigTiers(ctx) {
31
+ if (ctx.boardsConfig) return [{ scope: 'explicit', file: ctx.boardsConfig }]
32
+ const tiers = []
33
+ if (ctx.homeBoardsConfig) tiers.push({ scope: 'home', file: ctx.homeBoardsConfig })
34
+ if (ctx.projectBoardsConfig) tiers.push({ scope: 'project', file: ctx.projectBoardsConfig })
35
+ return tiers
36
+ }
37
+
38
+ // The single path older callers print or check: the explicit file, else the
39
+ // highest-precedence tier that exists, else where `gea boards add` would
40
+ // write (project when the project already has a config, else home).
8
41
  export function boardConfigPath(ctx) {
42
+ const tiers = boardConfigTiers(ctx)
43
+ const existing = [...tiers].reverse().find((tier) => exists(tier.file))
44
+ return existing ? existing.file : boardConfigWritePath(ctx)
45
+ }
46
+
47
+ // Where a write goes when the caller does not say: `--global` / `--local`
48
+ // pick a tier, an alias that already exists is edited in place, a new alias
49
+ // joins the project config if the project has one and the home config
50
+ // otherwise.
51
+ export function boardConfigWritePath(ctx, { scope = '', alias = '' } = {}) {
52
+ const tiers = boardConfigTiers(ctx)
9
53
  if (ctx.boardsConfig) return ctx.boardsConfig
10
- if (ctx.projectBoardsConfig && exists(ctx.projectBoardsConfig)) return ctx.projectBoardsConfig
11
- return ctx.targetsRoot ? path.join(ctx.targetsRoot, 'boards.json') : ''
54
+ if (scope === 'global' || scope === 'home') return ctx.homeBoardsConfig
55
+ if (scope === 'project') return ctx.projectBoardsConfig
56
+ if (scope) fail(`Unknown board config scope '${scope}'. Expected --global or --local.`, ExitCode.usage)
57
+ if (alias) {
58
+ const origin = boardConfigOrigins(ctx).get(alias)
59
+ if (origin) return origin
60
+ }
61
+ const project = tiers.find((tier) => tier.scope === 'project')
62
+ if (project && exists(project.file)) return project.file
63
+ return ctx.homeBoardsConfig || project?.file || path.join(ctx.cwd || process.cwd(), '.gea', 'boards.json')
12
64
  }
13
65
 
14
- export function loadBoardConfig(ctx) {
15
- const file = boardConfigPath(ctx)
16
- if (!file || !exists(file)) return {}
66
+ function readTier(file) {
67
+ if (!exists(file)) return {}
17
68
  try {
18
69
  return normalizeBoardConfig(readJson(file))
19
70
  } catch (error) {
@@ -21,6 +72,37 @@ export function loadBoardConfig(ctx) {
21
72
  }
22
73
  }
23
74
 
75
+ // Merged aliases plus, for each, the file it came from: a `targetDefinition`
76
+ // is relative to its own file, and `gea boards list` says which tier an alias
77
+ // lives in.
78
+ export function loadBoardConfigWithOrigins(ctx) {
79
+ const boards = {}
80
+ const origins = new Map()
81
+ for (const tier of boardConfigTiers(ctx)) {
82
+ for (const [alias, board] of Object.entries(readTier(tier.file))) {
83
+ boards[alias] = board
84
+ origins.set(alias, tier.file)
85
+ }
86
+ }
87
+ return { boards, origins }
88
+ }
89
+
90
+ export function loadBoardConfig(ctx) {
91
+ return loadBoardConfigWithOrigins(ctx).boards
92
+ }
93
+
94
+ export function boardConfigOrigins(ctx) {
95
+ return loadBoardConfigWithOrigins(ctx).origins
96
+ }
97
+
98
+ export function readBoardConfigFile(file) {
99
+ return readTier(file)
100
+ }
101
+
102
+ export function writeBoardConfigFile(file, boards) {
103
+ writeJson(file, boards)
104
+ }
105
+
24
106
  export function normalizeBoardConfig(raw) {
25
107
  if (!raw || typeof raw !== 'object') return {}
26
108
  if (raw.boards && typeof raw.boards === 'object') return raw.boards
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
 
4
- import { loadBoardConfig, normalizeBoardConfig } from './config.mjs'
4
+ import { boardConfigOrigins, boardConfigPath, loadBoardConfig, normalizeBoardConfig } from './config.mjs'
5
5
  import { loadTargets } from './targets.mjs'
6
6
  import { resolveUsbSerialPort } from './usb.mjs'
7
7
 
@@ -30,14 +30,16 @@ export function resolveBoardSelection({
30
30
  needs = {},
31
31
  targets = ctx ? loadTargets(ctx) : {},
32
32
  config = ctx ? loadBoardConfig(ctx) : {},
33
- configDir = ctx ? (ctx.boardsConfig ? path.dirname(ctx.boardsConfig) : path.dirname(ctx.projectBoardsConfig)) : process.cwd(),
33
+ // A targetDefinition is relative to the file its alias came from, which
34
+ // differs between the home and project tiers.
35
+ configDir = ctx ? path.dirname((boardName && boardConfigOrigins(ctx).get(boardName)) || boardConfigPath(ctx)) : process.cwd(),
34
36
  usbSerialResolver = resolveUsbSerialPort,
35
37
  deferUsbPort = false
36
38
  } = {}) {
37
39
  const boards = normalizeBoardConfig(config)
38
40
  const board = boardName ? boards[boardName] : null
39
41
  if (boardName && !board) {
40
- throw new Error(`Unknown board '${boardName}'. Add it to .gea/boards.json (gea boards add) or run gea boards list.`)
42
+ throw new Error(`Unknown board '${boardName}'. Run gea boards list, or gea boards add to register it.`)
41
43
  }
42
44
 
43
45
  let target = board?.target || targetName || ''
@@ -113,6 +113,47 @@ function macUsbCalloutPortsForSerial(serial, ioreg = runIoreg) {
113
113
  return [...matches].sort()
114
114
  }
115
115
 
116
+ // Every USB callout port the registry knows, with the serial and product
117
+ // name inherited from the enclosing USB device: the inverse of
118
+ // macUsbCalloutPortsForSerial, for `gea boards discover` and the setup
119
+ // wizard. The /dev name carries no identity (the usbmodem number changes on
120
+ // every enumeration and identical boards share it), so this is the only
121
+ // place a port's serial can come from on macOS.
122
+ export function listMacUsbCalloutPorts(ioreg = runIoreg) {
123
+ let output = ''
124
+ try {
125
+ output = ioreg(['-p', 'IOService', '-l', '-w0'], { maxBuffer: 64 * 1024 * 1024 })
126
+ } catch {
127
+ return []
128
+ }
129
+ const stack = []
130
+ const ports = new Map()
131
+ for (const line of output.split(/\r?\n/)) {
132
+ const node = line.match(/^([\s|]*)[+\\-]*o\s+/)
133
+ if (node) {
134
+ const depth = (node[1].match(/\|/g) || []).length
135
+ while (stack.length > 0 && stack[stack.length - 1].depth >= depth) stack.pop()
136
+ stack.push({ depth, serial: '', product: '' })
137
+ continue
138
+ }
139
+ if (stack.length === 0) continue
140
+ const current = stack[stack.length - 1]
141
+ const serialValue = ioregStringProperty(line, 'kUSBSerialNumberString') || ioregStringProperty(line, 'USB Serial Number')
142
+ if (serialValue) current.serial = serialValue
143
+ const productValue = ioregStringProperty(line, 'kUSBProductString') || ioregStringProperty(line, 'USB Product Name')
144
+ if (productValue) current.product = productValue
145
+ const callout = ioregStringProperty(line, 'IOCalloutDevice')
146
+ if (!callout || !path.basename(callout).startsWith('cu.')) continue
147
+ // A callout with no USB serial above it is not a USB device at all
148
+ // (Bluetooth SPP, the debug console); probing those is a wasted timeout.
149
+ const serial = [...stack].reverse().find((entry) => entry.serial)?.serial || ''
150
+ if (!serial) continue
151
+ const label = [...stack].reverse().find((entry) => entry.product)?.product || ''
152
+ ports.set(callout, { path: callout, serial, label })
153
+ }
154
+ return [...ports.values()].sort((a, b) => a.path.localeCompare(b.path))
155
+ }
156
+
116
157
  function runIoreg(args, options = {}) {
117
158
  return execFileSync('ioreg', args, { encoding: 'utf8', ...options })
118
159
  }
package/src/chips.mjs CHANGED
@@ -142,7 +142,7 @@ export function resolveTargetDefinition(ctx, parsed) {
142
142
  const configPath = option(parsed, 'boards-config')
143
143
  ? path.resolve(ctx.cwd, option(parsed, 'boards-config'))
144
144
  : ctx.projectBoardsConfig
145
- if (!configPath || !exists(configPath)) fail('No project .gea/boards.json was found. Run gea setup and create a custom board first.', ExitCode.usage)
145
+ if (!configPath || !exists(configPath)) fail(`No board config was found at ${configPath || '.gea/boards.json'}. Run gea setup and create a custom board first.`, ExitCode.usage)
146
146
  const boards = readJson(configPath)
147
147
  const requested = option(parsed, 'board', '')
148
148
  const customBoards = Object.entries(boards).filter(([, entry]) => typeof entry?.targetDefinition === 'string' && entry.targetDefinition)
@@ -2,7 +2,6 @@ import { readdirSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
 
4
4
  import { flag, option, optionList } from '../args.mjs'
5
- import { loadBoardConfig, normalizeBoardConfig, boardConfigPath } from '../boards/config.mjs'
6
5
  import { loadTargets } from '../boards/targets.mjs'
7
6
  import { ExitCode, fail } from '../errors.mjs'
8
7
  import { exists } from '../fs-utils.mjs'
@@ -161,38 +160,7 @@ async function iconSheet(ctx, parsed, options) {
161
160
  return 0
162
161
  }
163
162
 
164
- // gea boards ... / gea targets ...
165
-
166
- export async function boardsCommand(ctx, parsed, rest, options) {
167
- const sub = rest[0] || 'list'
168
- const boards = normalizeBoardConfig(loadBoardConfig(ctx))
169
- if (sub === 'list') {
170
- if (flag(parsed, 'json')) options.stdout(JSON.stringify(boards, null, 2))
171
- else {
172
- const names = Object.keys(boards).sort()
173
- if (names.length === 0) options.stdout(`No boards configured in ${boardConfigPath(ctx)}. Run gea setup, or gea boards add.`)
174
- for (const name of names) {
175
- const board = boards[name]
176
- const bits = [board.target]
177
- if (board.transports?.usbSerial?.serial) bits.push(`usb ${board.transports.usbSerial.serial}`)
178
- if (board.transports?.ota?.host) bits.push(`wifi ${board.transports.ota.host}`)
179
- options.stdout(`${name}\t${bits.join(' ')}`)
180
- }
181
- }
182
- return 0
183
- }
184
- if (sub === 'show') {
185
- const name = rest[1] || option(parsed, 'board', '')
186
- if (!name || !boards[name]) fail(`Unknown board '${name}'. Run gea boards list.`, ExitCode.usage)
187
- options.stdout(JSON.stringify({ [name]: boards[name] }, null, 2))
188
- return 0
189
- }
190
- if (sub === 'add') {
191
- const { runSetupWizard } = await import('../setup-wizard.mjs')
192
- return runSetupWizard(ctx, parsed, options)
193
- }
194
- fail(`Unknown boards subcommand '${sub}'. Expected list, show, or add.`, ExitCode.usage)
195
- }
163
+ // gea targets ...
196
164
 
197
165
  export function targetsCommand(ctx, parsed, rest, options) {
198
166
  const sub = rest[0] || 'list'
@@ -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) {