@geastack/cli 0.1.0

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.
@@ -0,0 +1,633 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ import { knownBoards } from './board-catalog.mjs'
6
+ import { flag, option } from './args.mjs'
7
+ import { createChildEnv } from './context.mjs'
8
+ import { ExitCode, fail } from './errors.mjs'
9
+ import { exists, readJson, writeJson } from './fs-utils.mjs'
10
+ import { ask, choose, confirm, createPrompt } from './prompts.mjs'
11
+ import { runExternal } from './run.mjs'
12
+ import { detectSerialDevices, formatSerialDevice } from './serial-devices.mjs'
13
+ import { commandVersion } from './toolchain.mjs'
14
+
15
+ const espIdfVersion = 'v6.0.1'
16
+ const espIdfInstallTargets = 'esp32,esp32s3,esp32p4'
17
+
18
+ export async function runSetupWizard(ctx, parsed, io) {
19
+ const stdout = io.stdout || console.log
20
+ const prompt = createPrompt(io)
21
+ try {
22
+ renderHeader(io, 'GeaStack setup', [
23
+ `Project: ${ctx.projectRoot}`,
24
+ `Boards: ${boardConfigPath(ctx, parsed)}`
25
+ ])
26
+ if (option(parsed, 'esp-idf') === true) {
27
+ renderStep(io, 'Toolchain', ['Checking ESP-IDF for ESP32 builds.'])
28
+ await maybeSetupEspIdf(ctx, parsed, io, prompt, { force: true })
29
+ return 0
30
+ }
31
+ const mode = await choose(prompt, {
32
+ message: 'What do you want to set up?',
33
+ choices: [
34
+ {
35
+ value: 'known',
36
+ label: 'Known supported board',
37
+ description: 'Fast path for Waveshare and other boards GeaStack already knows.'
38
+ },
39
+ {
40
+ value: 'custom',
41
+ label: 'Custom board profile',
42
+ description: 'Guided hardware profile for your own MCU, display, touch, wireless, audio, sensors, and transport.'
43
+ },
44
+ {
45
+ value: 'deps',
46
+ label: 'Only install/check project npm dependencies',
47
+ description: 'Runs npm install in the current app.'
48
+ },
49
+ {
50
+ value: 'esp-idf',
51
+ label: 'Only install/check ESP-IDF toolchain',
52
+ description: `Installs or verifies ESP-IDF ${espIdfVersion}.`
53
+ }
54
+ ],
55
+ defaultValue: 'known'
56
+ })
57
+
58
+ if (mode === 'deps') {
59
+ renderStep(io, 'Dependencies', ['Installing project npm dependencies.'])
60
+ await maybeInstallNpmDependencies(ctx, parsed, io, prompt, { force: true })
61
+ return 0
62
+ }
63
+
64
+ if (mode === 'esp-idf') {
65
+ renderStep(io, 'Toolchain', ['Checking ESP-IDF for ESP32 builds.'])
66
+ await maybeSetupEspIdf(ctx, parsed, io, prompt, { force: true })
67
+ return 0
68
+ }
69
+
70
+ const boardSetup = mode === 'custom'
71
+ ? await setupCustomBoard(ctx, parsed, io, prompt)
72
+ : await setupKnownBoard(ctx, parsed, io, prompt)
73
+ if (boardSetup?.cancelled) {
74
+ stdout('Setup cancelled. No board files were changed.')
75
+ return 0
76
+ }
77
+
78
+ renderStep(io, 'Toolchain', ['Checking ESP-IDF before board initialization.'])
79
+ await maybeSetupEspIdf(ctx, parsed, io, prompt)
80
+ if (option(parsed, 'install') === true) {
81
+ await maybeInstallNpmDependencies(ctx, parsed, io, prompt, { force: true })
82
+ }
83
+ await maybeInitializeBoardTarget(ctx, parsed, io, boardSetup)
84
+ if (boardSetup?.flashReady) {
85
+ stdout(`Ready: npx gea flash --board ${boardSetup.alias} --monitor`)
86
+ } else {
87
+ stdout('Profile saved. Add or select a target backend before flashing this board.')
88
+ }
89
+ return 0
90
+ } finally {
91
+ await prompt.close()
92
+ }
93
+ }
94
+
95
+ async function setupKnownBoard(ctx, parsed, io, prompt) {
96
+ renderStep(io, 'Board', ['Pick a board GeaStack can already flash.'])
97
+ const boardId = await choose(prompt, {
98
+ message: 'Which board do you have?',
99
+ choices: knownBoards.map((board) => ({
100
+ value: board.id,
101
+ label: board.label,
102
+ description: boardDescription(board)
103
+ })),
104
+ defaultValue: knownBoards[0].id
105
+ })
106
+ const board = knownBoards.find((candidate) => candidate.id === boardId)
107
+ if (!board) fail(`Unknown board selection '${boardId}'.`, ExitCode.usage)
108
+
109
+ renderStep(io, 'Identity', ['Give this physical board a short local alias.'])
110
+ const alias = await ask(prompt, {
111
+ message: 'Board alias',
112
+ defaultValue: board.alias,
113
+ validate: validateAlias
114
+ })
115
+ 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)'
118
+ })
119
+ const otaHost = await ask(prompt, {
120
+ message: 'OTA host/IP (optional)',
121
+ defaultValue: ''
122
+ })
123
+
124
+ const configPath = boardConfigPath(ctx, parsed)
125
+ const entry = {
126
+ target: board.target,
127
+ adapter: board.adapter,
128
+ transports: compactObject({
129
+ usbSerial: serial ? { serial } : undefined,
130
+ ota: otaHost ? { host: otaHost } : undefined
131
+ })
132
+ }
133
+ renderKnownBoardReview(io, { alias, board, configPath, entry })
134
+ if (!await shouldSaveSetup(parsed, prompt, 'Save this board setup?')) {
135
+ return { alias, cancelled: true }
136
+ }
137
+ const config = readBoardConfig(configPath)
138
+ config[alias] = entry
139
+ writeJsonEnsured(configPath, config)
140
+ io.stdout(`Wrote board alias '${alias}' to ${configPath}`)
141
+ io.stdout(`Target: ${board.target}`)
142
+ return { alias, flashReady: true }
143
+ }
144
+
145
+ async function setupCustomBoard(ctx, parsed, io, prompt) {
146
+ renderStep(io, 'Board', ['Name the profile and pick the closest target backend.'])
147
+ const alias = await ask(prompt, {
148
+ message: 'Custom board alias',
149
+ defaultValue: 'custom-board',
150
+ validate: validateAlias
151
+ })
152
+ const mcu = await choose(prompt, {
153
+ message: 'MCU / SoC',
154
+ choices: [
155
+ { value: 'esp32-s3', label: 'ESP32-S3' },
156
+ { value: 'esp32-p4', label: 'ESP32-P4' },
157
+ { value: 'esp32-c6', label: 'ESP32-C6' },
158
+ { value: 'esp32', label: 'ESP32' },
159
+ { value: 'other', label: 'Other / not listed' }
160
+ ],
161
+ defaultValue: 'esp32-s3'
162
+ })
163
+ const mcuName = mcu === 'other' ? await ask(prompt, { message: 'MCU / SoC name', defaultValue: '' }) : mcu
164
+ const baseTarget = await choose(prompt, {
165
+ message: 'Closest existing target to start from',
166
+ choices: [
167
+ { value: '', label: 'None yet, generate profile only' },
168
+ ...knownBoards.map((board) => ({ value: board.target, label: `${board.label} (${board.target})` }))
169
+ ],
170
+ defaultValue: ''
171
+ })
172
+ const depth = await choose(prompt, {
173
+ message: 'How much hardware detail do you want to enter?',
174
+ choices: [
175
+ {
176
+ value: 'full',
177
+ label: 'Full hardware profile',
178
+ description: 'Display, touch, wireless, GPS, audio, storage, sensors, power, and transport.'
179
+ },
180
+ {
181
+ value: 'quick',
182
+ label: 'Fast profile',
183
+ description: 'Core board, display/touch, transport, and sensible defaults for everything else.'
184
+ }
185
+ ],
186
+ defaultValue: 'full'
187
+ })
188
+
189
+ renderStep(io, 'Display and touch', ['Describe what the user can see and touch on the board.'])
190
+ const displayKind = await choose(prompt, {
191
+ message: 'Display type',
192
+ choices: [
193
+ { value: 'amoled', label: 'AMOLED' },
194
+ { value: 'tft-lcd', label: 'TFT LCD' },
195
+ { value: 'epaper', label: 'E-paper' },
196
+ { value: 'monochrome-oled', label: 'Monochrome OLED' },
197
+ { value: 'none', label: 'No display' },
198
+ { value: 'other', label: 'Other' }
199
+ ],
200
+ defaultValue: 'tft-lcd'
201
+ })
202
+ const displayController = displayKind === 'none' ? '' : await ask(prompt, { message: 'Display controller/chip', defaultValue: '' })
203
+ const displayInterface = displayKind === 'none' ? '' : await choose(prompt, {
204
+ message: 'Display interface',
205
+ choices: [
206
+ { value: 'spi', label: 'SPI' },
207
+ { value: 'qspi', label: 'QSPI' },
208
+ { value: 'rgb', label: 'RGB parallel' },
209
+ { value: 'i8080', label: '8080 parallel' },
210
+ { value: 'mipi-dsi', label: 'MIPI DSI' },
211
+ { value: 'i2c', label: 'I2C' },
212
+ { value: 'other', label: 'Other' }
213
+ ],
214
+ defaultValue: 'spi'
215
+ })
216
+ const resolution = displayKind === 'none' ? '' : await ask(prompt, { message: 'Display resolution, for example 480x480', defaultValue: '' })
217
+ const touchController = await ask(prompt, { message: 'Touch controller/chip (blank for none)', defaultValue: '' })
218
+ const touchInterface = touchController ? await choose(prompt, {
219
+ message: 'Touch interface',
220
+ choices: [
221
+ { value: 'i2c', label: 'I2C' },
222
+ { value: 'spi', label: 'SPI' },
223
+ { value: 'gpio', label: 'GPIO buttons/interrupts' },
224
+ { value: 'other', label: 'Other' }
225
+ ],
226
+ defaultValue: 'i2c'
227
+ }) : ''
228
+
229
+ const defaults = defaultCustomPeripherals(mcuName)
230
+ let wifi = defaults.wifi
231
+ let ble = defaults.ble
232
+ let gpsModule = ''
233
+ let gpsInterface = ''
234
+ let audioCodec = ''
235
+ let audioOutput = ''
236
+ let audioInput = ''
237
+ let storage = defaults.storage
238
+ let sensors = ''
239
+ let power = 'USB'
240
+
241
+ if (depth === 'full') {
242
+ renderStep(io, 'Peripherals', ['Add wireless, location, audio, storage, sensors, and power details.'])
243
+ wifi = await choose(prompt, {
244
+ message: 'WiFi',
245
+ choices: [
246
+ { value: 'built-in', label: 'Built into MCU/module' },
247
+ { value: 'external', label: 'External WiFi chip/module' },
248
+ { value: 'none', label: 'None' }
249
+ ],
250
+ defaultValue: defaults.wifi
251
+ })
252
+ ble = await choose(prompt, {
253
+ message: 'BLE',
254
+ choices: [
255
+ { value: 'built-in', label: 'Built into MCU/module' },
256
+ { value: 'external', label: 'External BLE chip/module' },
257
+ { value: 'none', label: 'None' }
258
+ ],
259
+ defaultValue: defaults.ble
260
+ })
261
+ gpsModule = await ask(prompt, { message: 'GPS module/chip (blank for none)', defaultValue: '' })
262
+ gpsInterface = gpsModule ? await choose(prompt, {
263
+ message: 'GPS interface',
264
+ choices: [
265
+ { value: 'uart', label: 'UART' },
266
+ { value: 'i2c', label: 'I2C' },
267
+ { value: 'spi', label: 'SPI' },
268
+ { value: 'other', label: 'Other' }
269
+ ],
270
+ defaultValue: 'uart'
271
+ }) : ''
272
+ audioCodec = await ask(prompt, { message: 'Audio codec/chip (blank for none)', defaultValue: '' })
273
+ audioOutput = audioCodec ? await choose(prompt, {
274
+ message: 'Audio output',
275
+ choices: [
276
+ { value: 'i2s-speaker', label: 'I2S speaker/output' },
277
+ { value: 'dac', label: 'DAC output' },
278
+ { value: 'pwm', label: 'PWM/buzzer' },
279
+ { value: 'other', label: 'Other' }
280
+ ],
281
+ defaultValue: 'i2s-speaker'
282
+ }) : ''
283
+ audioInput = audioCodec ? await choose(prompt, {
284
+ message: 'Audio input',
285
+ choices: [
286
+ { value: 'none', label: 'None' },
287
+ { value: 'i2s-mic', label: 'I2S microphone' },
288
+ { value: 'pdm-mic', label: 'PDM microphone' },
289
+ { value: 'analog-mic', label: 'Analog microphone' },
290
+ { value: 'other', label: 'Other' }
291
+ ],
292
+ defaultValue: 'none'
293
+ }) : ''
294
+ storage = await ask(prompt, { message: 'Storage chips/features, comma-separated', defaultValue: defaults.storage })
295
+ sensors = await ask(prompt, { message: 'Sensors, comma-separated (IMU, light, temp, etc.)', defaultValue: '' })
296
+ power = await ask(prompt, { message: 'Power path (USB, battery charger, PMIC, etc.)', defaultValue: 'USB' })
297
+ } else {
298
+ renderStep(io, 'Peripherals', [
299
+ `Using defaults: WiFi ${wifi}, BLE ${ble}, storage ${storage}, power USB.`,
300
+ `You can edit .gea/boards/${alias}.json later if the board has GPS, audio, or sensors.`
301
+ ])
302
+ }
303
+
304
+ renderStep(io, 'Connection', ['Choose how GeaStack should flash and monitor the board.'])
305
+ const transport = await choose(prompt, {
306
+ message: 'Primary flash/monitor transport',
307
+ choices: [
308
+ { value: 'usbSerial', label: 'USB serial' },
309
+ { value: 'ota', label: 'WiFi OTA' },
310
+ { value: 'both', label: 'USB serial + OTA' },
311
+ { value: 'custom', label: 'Custom' }
312
+ ],
313
+ defaultValue: 'usbSerial'
314
+ })
315
+ const usbSerial = transport === 'usbSerial' || transport === 'both'
316
+ ? await selectUsbSerial(prompt, io, { message: 'USB serial number (optional)' })
317
+ : ''
318
+ const otaHost = transport === 'ota' || transport === 'both'
319
+ ? await ask(prompt, { message: 'OTA host/IP (optional)', defaultValue: '' })
320
+ : ''
321
+ const notes = await ask(prompt, { message: 'Notes / links to schematic, display datasheet, etc. (optional)', defaultValue: '' })
322
+
323
+ const profile = compactObject({
324
+ alias,
325
+ kind: 'custom-board-profile',
326
+ targetFamily: 'esp32',
327
+ adapter: 'esp32-idf',
328
+ baseTarget,
329
+ mcu: mcuName,
330
+ display: compactObject({
331
+ kind: displayKind,
332
+ controller: displayController,
333
+ interface: displayInterface,
334
+ resolution
335
+ }),
336
+ touch: compactObject({
337
+ controller: touchController,
338
+ interface: touchInterface
339
+ }),
340
+ wireless: compactObject({ wifi, ble }),
341
+ gps: compactObject({ module: gpsModule, interface: gpsInterface }),
342
+ audio: compactObject({ codec: audioCodec, output: audioOutput, input: audioInput }),
343
+ storage: csv(storage),
344
+ sensors: csv(sensors),
345
+ power,
346
+ transports: compactObject({
347
+ usbSerial: usbSerial ? { serial: usbSerial } : undefined,
348
+ ota: otaHost ? { host: otaHost } : undefined
349
+ }),
350
+ notes
351
+ })
352
+
353
+ const profilePath = path.join(ctx.cwd, '.gea', 'boards', `${alias}.json`)
354
+ const configPath = boardConfigPath(ctx, parsed)
355
+ renderCustomBoardReview(io, { alias, profile, profilePath, configPath, flashReady: Boolean(baseTarget) })
356
+ if (!await shouldSaveSetup(parsed, prompt, 'Save this custom board profile?')) {
357
+ return { alias, cancelled: true }
358
+ }
359
+ writeJsonEnsured(profilePath, profile)
360
+ io.stdout(`Wrote custom board profile to ${profilePath}`)
361
+
362
+ if (baseTarget) {
363
+ const config = readBoardConfig(configPath)
364
+ config[alias] = compactObject({
365
+ target: baseTarget,
366
+ adapter: 'esp32-idf',
367
+ customProfile: profilePath,
368
+ transports: profile.transports
369
+ })
370
+ writeJsonEnsured(configPath, config)
371
+ io.stdout(`Wrote experimental board alias '${alias}' to ${configPath}`)
372
+ return { alias, flashReady: true }
373
+ } else {
374
+ io.stdout('No board alias was added because no base target was selected.')
375
+ io.stdout('Add a target backend before flashing this custom profile.')
376
+ return { alias, flashReady: false }
377
+ }
378
+ }
379
+
380
+ function renderHeader(io, title, lines = []) {
381
+ io.stdout('')
382
+ io.stdout(title)
383
+ io.stdout('-'.repeat(title.length))
384
+ for (const line of lines.filter(Boolean)) io.stdout(line)
385
+ }
386
+
387
+ function renderStep(io, title, lines = []) {
388
+ io.stdout('')
389
+ io.stdout(`[ ${title} ]`)
390
+ for (const line of lines.filter(Boolean)) io.stdout(line)
391
+ }
392
+
393
+ function renderKnownBoardReview(io, { alias, board, configPath, entry }) {
394
+ renderStep(io, 'Review', [
395
+ 'This is what GeaStack will save.'
396
+ ])
397
+ writeRows(io, [
398
+ ['Alias', alias],
399
+ ['Board', board.label],
400
+ ['Target', entry.target],
401
+ ['Adapter', entry.adapter],
402
+ ['USB serial', entry.transports?.usbSerial?.serial || 'manual / not set'],
403
+ ['OTA host', entry.transports?.ota?.host || 'not set'],
404
+ ['Config', configPath]
405
+ ])
406
+ }
407
+
408
+ function renderCustomBoardReview(io, { alias, profile, profilePath, configPath, flashReady }) {
409
+ renderStep(io, 'Review', [
410
+ 'This is what GeaStack will save.'
411
+ ])
412
+ writeRows(io, [
413
+ ['Alias', alias],
414
+ ['MCU / SoC', profile.mcu],
415
+ ['Base target', profile.baseTarget || 'none yet'],
416
+ ['Display', describeObject(profile.display)],
417
+ ['Touch', describeObject(profile.touch)],
418
+ ['Wireless', describeObject(profile.wireless)],
419
+ ['GPS', describeObject(profile.gps)],
420
+ ['Audio', describeObject(profile.audio)],
421
+ ['Storage', list(profile.storage)],
422
+ ['Sensors', list(profile.sensors)],
423
+ ['Power', profile.power || 'not set'],
424
+ ['Transport', describeObject(profile.transports)],
425
+ ['Profile', profilePath],
426
+ ['Board config', flashReady ? configPath : 'not written until a base target is selected']
427
+ ])
428
+ }
429
+
430
+ async function shouldSaveSetup(parsed, prompt, message) {
431
+ if (flag(parsed, 'yes')) return true
432
+ return confirm(prompt, { message, defaultValue: true })
433
+ }
434
+
435
+ function writeRows(io, rows) {
436
+ const width = rows.reduce((max, [label]) => Math.max(max, label.length), 0)
437
+ for (const [label, value] of rows) {
438
+ io.stdout(`${label.padEnd(width)} : ${value || 'not set'}`)
439
+ }
440
+ }
441
+
442
+ function boardDescription(board) {
443
+ const parts = [
444
+ board.mcu,
445
+ board.capabilities?.display,
446
+ board.capabilities?.wireless?.length ? board.capabilities.wireless.join('/') : '',
447
+ board.target
448
+ ].filter(Boolean)
449
+ return parts.join(' - ')
450
+ }
451
+
452
+ function describeObject(value) {
453
+ if (!value || typeof value !== 'object') return 'not set'
454
+ const entries = Object.entries(value)
455
+ .flatMap(([key, entry]) => {
456
+ if (entry === undefined || entry === null || entry === '') return []
457
+ if (Array.isArray(entry)) return entry.length ? [`${key}: ${entry.join(', ')}`] : []
458
+ if (typeof entry === 'object') return Object.keys(entry).length ? [`${key}: ${describeObject(entry)}`] : []
459
+ return [`${key}: ${entry}`]
460
+ })
461
+ return entries.length ? entries.join('; ') : 'not set'
462
+ }
463
+
464
+ function defaultCustomPeripherals(mcuName) {
465
+ const normalized = String(mcuName || '').toLowerCase()
466
+ const espWithWireless = ['esp32', 'esp32-s3', 'esp32-c3', 'esp32-c6', 'esp32-h2'].includes(normalized)
467
+ const hasPsramByDefault = ['esp32-s3', 'esp32-p4'].includes(normalized)
468
+ return {
469
+ wifi: espWithWireless && normalized !== 'esp32-h2' ? 'built-in' : 'none',
470
+ ble: espWithWireless ? 'built-in' : 'none',
471
+ storage: hasPsramByDefault ? 'flash, psram' : 'flash'
472
+ }
473
+ }
474
+
475
+ async function maybeInstallNpmDependencies(ctx, parsed, io, prompt, { force = false } = {}) {
476
+ const install = force || option(parsed, 'install') === true || await confirm(prompt, {
477
+ message: 'Install npm dependencies now if package.json exists?',
478
+ defaultValue: true
479
+ })
480
+ if (!install) return
481
+ if (!exists(path.join(ctx.cwd, 'package.json'))) {
482
+ io.stdout(`No package.json in ${ctx.cwd}; skipping npm install.`)
483
+ return
484
+ }
485
+ runExternal('npm', ['install'], {
486
+ cwd: ctx.cwd,
487
+ env: io.env || process.env,
488
+ dryRun: option(parsed, 'dry-run') === true,
489
+ stdout: io.stdout
490
+ })
491
+ }
492
+
493
+ async function maybeInitializeBoardTarget(ctx, parsed, io, boardSetup) {
494
+ if (!boardSetup?.alias || !boardSetup.flashReady) return 0
495
+ if (option(parsed, 'initialize') === false) {
496
+ io.stdout(`Board initialization skipped. Later: npx gea setup --board ${boardSetup.alias}`)
497
+ return 0
498
+ }
499
+ if (!exists(ctx.scripts.board)) {
500
+ io.stdout(`Board backend not found. Later: npx gea setup --board ${boardSetup.alias}`)
501
+ return 0
502
+ }
503
+ io.stdout(`Initializing board target '${boardSetup.alias}'...`)
504
+ return runExternal(ctx.scripts.board, ['setup', `--board=${boardSetup.alias}`], {
505
+ cwd: ctx.targetsRoot,
506
+ env: createChildEnv(ctx, io.env || process.env),
507
+ dryRun: flag(parsed, 'dry-run'),
508
+ failureCode: ExitCode.buildFailed,
509
+ stdout: io.stdout
510
+ })
511
+ }
512
+
513
+ async function maybeSetupEspIdf(ctx, parsed, io, prompt, { force = false } = {}) {
514
+ const env = io.env || process.env
515
+ const status = detectEspIdf(env)
516
+ if (status.available) {
517
+ if (force) io.stdout(`ESP-IDF found: ${status.detail}`)
518
+ return 0
519
+ }
520
+
521
+ const install = force || await confirm(prompt, {
522
+ message: `ESP-IDF ${espIdfVersion} was not found. Install it now?`,
523
+ defaultValue: false
524
+ })
525
+ if (!install) {
526
+ io.stdout(`ESP-IDF install later: npx gea setup --esp-idf`)
527
+ return 0
528
+ }
529
+
530
+ const idfDir = option(parsed, 'idf-dir') || env.GEA_ESP_IDF_DIR || path.join(os.homedir(), 'esp', 'esp-idf')
531
+ const dryRun = flag(parsed, 'dry-run')
532
+ if (!dryRun) fs.mkdirSync(path.dirname(idfDir), { recursive: true })
533
+ if (!exists(path.join(idfDir, 'install.sh')) && !exists(path.join(idfDir, 'install.bat'))) {
534
+ runExternal('git', ['clone', '-b', espIdfVersion, '--recursive', 'https://github.com/espressif/esp-idf.git', idfDir], {
535
+ cwd: ctx.cwd,
536
+ env,
537
+ dryRun,
538
+ failureCode: ExitCode.missingDependency,
539
+ stdout: io.stdout
540
+ })
541
+ }
542
+
543
+ const installScript = process.platform === 'win32' ? path.join(idfDir, 'install.bat') : path.join(idfDir, 'install.sh')
544
+ runExternal(installScript, [espIdfInstallTargets], {
545
+ cwd: idfDir,
546
+ env,
547
+ dryRun,
548
+ failureCode: ExitCode.missingDependency,
549
+ stdout: io.stdout
550
+ })
551
+
552
+ const exportScript = process.platform === 'win32' ? path.join(idfDir, 'export.bat') : path.join(idfDir, 'export.sh')
553
+ io.stdout(`ESP-IDF installed at ${idfDir}`)
554
+ io.stdout(process.platform === 'win32' ? `For future shells: ${exportScript}` : `For future shells: . "${exportScript}"`)
555
+ return 0
556
+ }
557
+
558
+ function detectEspIdf(env) {
559
+ const idfVersion = commandVersion('idf.py', ['--version'], env)
560
+ if (idfVersion) return { available: true, detail: idfVersion }
561
+ if (env.IDF_PATH) return { available: true, detail: env.IDF_PATH }
562
+ return { available: false, detail: '' }
563
+ }
564
+
565
+ async function selectUsbSerial(prompt, io, { message }) {
566
+ const devices = detectSerialDevices({ env: io.env || process.env })
567
+ if (devices.length === 0) {
568
+ return ask(prompt, {
569
+ message,
570
+ defaultValue: ''
571
+ })
572
+ }
573
+
574
+ const selected = await choose(prompt, {
575
+ message: 'Detected serial devices. Which board is connected?',
576
+ choices: [
577
+ ...devices.map((device, index) => ({ value: `device-${index}`, label: formatSerialDevice(device) })),
578
+ { value: 'manual', label: 'Enter stable USB serial manually' },
579
+ { value: 'skip', label: 'Skip for now' }
580
+ ],
581
+ defaultValue: 'device-0'
582
+ })
583
+ if (selected === 'skip') return ''
584
+ if (selected === 'manual') return ask(prompt, { message, defaultValue: '' })
585
+ const index = Number.parseInt(selected.slice('device-'.length), 10)
586
+ const device = devices[index]
587
+ return ask(prompt, {
588
+ message: `Stable USB serial for ${device.path}`,
589
+ defaultValue: device.serial || ''
590
+ })
591
+ }
592
+
593
+ function boardConfigPath(ctx, parsed) {
594
+ const explicit = option(parsed, 'boards-config') || ctx.boardsConfig
595
+ if (explicit) return path.resolve(ctx.cwd, explicit)
596
+ return ctx.projectBoardsConfig || path.join(ctx.cwd, '.gea', 'boards.json')
597
+ }
598
+
599
+ function readBoardConfig(filePath) {
600
+ if (!exists(filePath)) return {}
601
+ return readJson(filePath)
602
+ }
603
+
604
+ function writeJsonEnsured(filePath, value) {
605
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
606
+ writeJson(filePath, value)
607
+ }
608
+
609
+ function validateAlias(value) {
610
+ if (!value) return 'alias is required'
611
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(value)) return 'use letters, numbers, dot, dash, or underscore'
612
+ return ''
613
+ }
614
+
615
+ function compactObject(value) {
616
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => {
617
+ if (entry === undefined || entry === null || entry === '') return false
618
+ if (Array.isArray(entry)) return entry.length > 0
619
+ if (typeof entry === 'object') return Object.keys(entry).length > 0
620
+ return true
621
+ }))
622
+ }
623
+
624
+ function csv(value) {
625
+ return String(value || '')
626
+ .split(',')
627
+ .map((entry) => entry.trim())
628
+ .filter(Boolean)
629
+ }
630
+
631
+ function list(value) {
632
+ return Array.isArray(value) && value.length ? value.join(', ') : 'not set'
633
+ }