@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.
- package/LICENSE +674 -0
- package/README.md +161 -0
- package/bin/create-geastack.mjs +15 -0
- package/bin/gea.mjs +15 -0
- package/docs/ESP32-WAVESHARE-AMOLED-QUICKSTART.md +133 -0
- package/docs/NPX-COMMANDS.md +144 -0
- package/docs/SETUP.md +276 -0
- package/docs/SPEC.md +217 -0
- package/examples/catalog.json +668 -0
- package/package.json +41 -0
- package/src/args.mjs +78 -0
- package/src/board-catalog.mjs +224 -0
- package/src/context.mjs +110 -0
- package/src/create-geastack.mjs +430 -0
- package/src/errors.mjs +20 -0
- package/src/fs-utils.mjs +47 -0
- package/src/gea.mjs +444 -0
- package/src/manifest.mjs +171 -0
- package/src/prompts.mjs +60 -0
- package/src/run.mjs +33 -0
- package/src/serial-devices.mjs +143 -0
- package/src/setup-wizard.mjs +633 -0
- package/src/starter-catalog.mjs +191 -0
- package/src/toolchain.mjs +15 -0
- package/starters/bundled/counter/index.tsx +25 -0
- package/starters/bundled/counter/package.json +33 -0
- package/starters/bundled/counter/store.ts +7 -0
- package/starters/bundled/counter/styles.css +39 -0
- package/starters/catalog.json +22 -0
|
@@ -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
|
+
}
|