@geastack/cli 0.1.51 → 0.1.53

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,402 @@
1
+ import path from 'node:path'
2
+
3
+ import { flag, option, optionList } from '../args.mjs'
4
+ import { resolveBoardSelection } from '../boards/resolve.mjs'
5
+ import { createChildEnv } from '../context.mjs'
6
+ import { chooseTransport, openDevice, saveScreenshot } from '../device/device.mjs'
7
+ import { geadev } from '../device/serial.mjs'
8
+ import { ExitCode, fail } from '../errors.mjs'
9
+ import { buildEsp32Firmware, buildImages, esp32BuildDir, fullCleanEsp32, requireEspIdf } from '../esp32/build.mjs'
10
+ import { eraseSlot, flashFirmware, flashImageSet, flashOptions, postFlashRestartNote, restoreBootMetadata, stageImage } from '../esp32/flash.mjs'
11
+ import { bleOta, otaEraseSlot, otaFlash, otaStage, waitForReboot } from '../esp32/ota.mjs'
12
+ import { manifestRequestsBleOta } from '../esp32/capabilities.mjs'
13
+ import { runGeaos } from '../geaos/adapter.mjs'
14
+ import { assertTargetEnabled, assertValidApp, resolveRequestedApp } from '../manifest.mjs'
15
+ import { buildRp2350, flashRp2350, rp2350BuildDir } from '../rp2350/adapter.mjs'
16
+ import { runTargetHook } from '../taurus/adapter.mjs'
17
+
18
+ // Every board-facing command: resolve the alias, pick the adapter, run.
19
+
20
+ export function selectBoard(ctx, parsed, needs = {}) {
21
+ const boardName = option(parsed, 'board', '')
22
+ const targetName = option(parsed, 'target', '')
23
+ if (!boardName && !targetName) fail('--board <alias> is required (see gea boards list).', ExitCode.usage)
24
+ try {
25
+ return resolveBoardSelection({
26
+ ctx,
27
+ boardName,
28
+ targetName,
29
+ requestedPort: option(parsed, 'port', ''),
30
+ requestedHost: option(parsed, 'host', ''),
31
+ needs,
32
+ deferUsbPort: true
33
+ })
34
+ } catch (error) {
35
+ fail(error.message, ExitCode.usage)
36
+ }
37
+ }
38
+
39
+ function optionalApp(ctx, parsed, rest, selection, { required = false } = {}) {
40
+ const requested = option(parsed, 'app') || rest[0]
41
+ let app = null
42
+ if (requested || required) {
43
+ app = resolveRequestedApp(ctx, parsed, rest)
44
+ } else {
45
+ try {
46
+ app = resolveRequestedApp(ctx, parsed, [])
47
+ } catch {
48
+ app = null
49
+ }
50
+ }
51
+ if (!app) return null
52
+ assertValidApp(app)
53
+ assertTargetEnabled(ctx, app, selection.boardName || selection.target)
54
+ return app
55
+ }
56
+
57
+ function bleOtaRequested(parsed, app, env) {
58
+ return option(parsed, 'transport') === 'ble' || env.GEA_EMBEDDED_BLE_OTA === '1' || manifestRequestsBleOta(app?.packageJson)
59
+ }
60
+
61
+ function io(parsed, options) {
62
+ return {
63
+ env: options.env,
64
+ dryRun: flag(parsed, 'dry-run'),
65
+ stdout: options.stdout,
66
+ stderr: options.stderr
67
+ }
68
+ }
69
+
70
+ // ---- build ------------------------------------------------------------------
71
+
72
+ export async function buildCommand(ctx, parsed, rest, options) {
73
+ const selection = selectBoard(ctx, parsed)
74
+ const app = optionalApp(ctx, parsed, rest, selection)
75
+ const base = io(parsed, options)
76
+ const env = createChildEnv(ctx, base.env)
77
+ switch (selection.adapter) {
78
+ case 'esp32-idf': {
79
+ buildEsp32Firmware({ ctx, selection, app, env, bleOta: bleOtaRequested(parsed, app, env), dryRun: base.dryRun, stdout: base.stdout, stderr: base.stderr, configureOnly: flag(parsed, 'configure-only') })
80
+ return 0
81
+ }
82
+ case 'rp2350-pico':
83
+ buildRp2350({ ctx, selection, app, env, dryRun: base.dryRun, stdout: base.stdout, stderr: base.stderr, configureOnly: flag(parsed, 'configure-only') })
84
+ return 0
85
+ case 'geaos-linux':
86
+ case 'geaos-arm64':
87
+ return runGeaos({ ctx, selection, action: 'build', app, positionals: rest, env, dryRun: base.dryRun, stdout: base.stdout })
88
+ case 'taurus-s3':
89
+ return runTargetHook({ ctx, selection, action: 'build', app, env, dryRun: base.dryRun, stdout: base.stdout })
90
+ default:
91
+ fail(`Unknown adapter '${selection.adapter}' for target '${selection.target}'.`, ExitCode.usage)
92
+ }
93
+ }
94
+
95
+ export async function cleanCommand(ctx, parsed, rest, options) {
96
+ const selection = selectBoard(ctx, parsed)
97
+ const app = optionalApp(ctx, parsed, rest, selection)
98
+ const base = io(parsed, options)
99
+ if (selection.adapter === 'esp32-idf') {
100
+ fullCleanEsp32({ ctx, selection, app, env: base.env, stdout: base.stdout })
101
+ return 0
102
+ }
103
+ if (selection.adapter === 'rp2350-pico') {
104
+ const dir = rp2350BuildDir(ctx, selection)
105
+ base.stdout(`Removing build artifacts in ${dir}...`)
106
+ const { rmSync } = await import('node:fs')
107
+ rmSync(dir, { recursive: true, force: true })
108
+ return 0
109
+ }
110
+ fail(`'clean' is not supported for ${selection.adapter} boards.`, ExitCode.usage)
111
+ }
112
+
113
+ // ---- flash / run --------------------------------------------------------------
114
+
115
+ async function flashEsp32(ctx, parsed, rest, options, selection, { monitor }) {
116
+ const base = io(parsed, options)
117
+ const env = createChildEnv(ctx, base.env)
118
+ const idf = requireEspIdf(env, base.stdout)
119
+ const flashEnv = idf.env
120
+ const opts = flashOptions(flashEnv, { manualBoot: flag(parsed, 'manual-boot'), noReset: option(parsed, 'reset') === false, baud: option(parsed, 'flash-baud', '') })
121
+ const common = { idf, selection, options: opts, port: selection.port, env: flashEnv, dryRun: base.dryRun, stdout: base.stdout, stderr: base.stderr }
122
+ const slotImages = optionList(parsed, 'slot-image')
123
+ const eraseSlotName = option(parsed, 'erase-slot', '')
124
+ const slot = option(parsed, 'slot', '')
125
+ const explicitImage = option(parsed, 'image', '')
126
+ const app = explicitImage && !option(parsed, 'app') && !rest[0] ? null : optionalApp(ctx, parsed, rest, selection, { required: !explicitImage && !slotImages.length && !eraseSlotName && !flag(parsed, 'restore-boot') })
127
+
128
+ const buildDir = esp32BuildDir(ctx, selection, app?.id, flashEnv)
129
+ const images = { ...buildImages(buildDir), buildDir }
130
+
131
+ if (eraseSlotName) {
132
+ await eraseSlot({ ...common, slot: eraseSlotName })
133
+ return 0
134
+ }
135
+ if (flag(parsed, 'restore-boot')) {
136
+ await restoreBootMetadata({ ...common, images })
137
+ return 0
138
+ }
139
+ if (slotImages.length) {
140
+ await flashImageSet({ ...common, images, slotImages })
141
+ postFlashRestartNote(selection, base.stderr)
142
+ return 0
143
+ }
144
+
145
+ let image = explicitImage ? path.resolve(ctx.cwd, explicitImage) : images.app
146
+ if (!explicitImage && !flag(parsed, 'no-build')) {
147
+ buildEsp32Firmware({ ctx, selection, app, env, bleOta: bleOtaRequested(parsed, app, env), dryRun: base.dryRun, stdout: base.stdout, stderr: base.stderr })
148
+ }
149
+ const appLabel = app?.id || 'prebuilt image'
150
+ if (slot) {
151
+ await stageImage({ ...common, image, slot, appLabel })
152
+ return 0
153
+ }
154
+ await flashFirmware({ ...common, images, appImage: image, appLabel })
155
+ if (!monitor) {
156
+ postFlashRestartNote(selection, base.stderr)
157
+ return 0
158
+ }
159
+ return monitorCommand(ctx, parsed, rest, options, selection)
160
+ }
161
+
162
+ export async function flashCommand(ctx, parsed, rest, options, { monitor = false } = {}) {
163
+ const selection = selectBoard(ctx, parsed, { usbPort: true })
164
+ const base = io(parsed, options)
165
+ const env = createChildEnv(ctx, base.env)
166
+ switch (selection.adapter) {
167
+ case 'esp32-idf':
168
+ return flashEsp32(ctx, parsed, rest, options, selection, { monitor })
169
+ case 'rp2350-pico': {
170
+ const app = optionalApp(ctx, parsed, rest, selection)
171
+ const { uf2 } = buildRp2350({ ctx, selection, app, env, dryRun: base.dryRun, stdout: base.stdout, stderr: base.stderr })
172
+ flashRp2350({ selection, uf2, env, dryRun: base.dryRun, stdout: base.stdout })
173
+ return monitor ? monitorCommand(ctx, parsed, rest, options, selection) : 0
174
+ }
175
+ case 'geaos-linux':
176
+ case 'geaos-arm64': {
177
+ const app = optionalApp(ctx, parsed, rest, selection)
178
+ return runGeaos({ ctx, selection, action: monitor ? 'flash-monitor' : 'flash', app, positionals: rest, env, dryRun: base.dryRun, stdout: base.stdout })
179
+ }
180
+ case 'taurus-s3': {
181
+ const app = optionalApp(ctx, parsed, rest, selection)
182
+ return runTargetHook({ ctx, selection, action: 'flash', app, env, dryRun: base.dryRun, stdout: base.stdout })
183
+ }
184
+ default:
185
+ fail(`Unknown adapter '${selection.adapter}' for target '${selection.target}'.`, ExitCode.usage)
186
+ }
187
+ }
188
+
189
+ // ---- ota ------------------------------------------------------------------------
190
+
191
+ export async function otaCommand(ctx, parsed, rest, options) {
192
+ const transport = option(parsed, 'transport', 'wifi')
193
+ if (transport !== 'wifi' && transport !== 'ble') fail("--transport must be 'wifi' or 'ble'.", ExitCode.usage)
194
+ const selection = selectBoard(ctx, parsed, transport === 'wifi' ? { otaHost: true } : {})
195
+ if (selection.adapter !== 'esp32-idf') fail(`OTA is only available for ESP32 boards (board '${selection.boardName}' is ${selection.adapter}).`, ExitCode.usage)
196
+ const base = io(parsed, options)
197
+ const env = createChildEnv(ctx, base.env)
198
+ const slot = option(parsed, 'slot', '')
199
+ const eraseSlotName = option(parsed, 'erase-slot', '')
200
+ const explicitImage = option(parsed, 'image', '')
201
+
202
+ if (eraseSlotName) {
203
+ await otaEraseSlot({ selection, host: selection.host, slot: eraseSlotName, dryRun: base.dryRun, stdout: base.stdout })
204
+ return 0
205
+ }
206
+
207
+ const app = explicitImage && !option(parsed, 'app') && !rest[0] ? null : optionalApp(ctx, parsed, rest, selection, { required: !explicitImage })
208
+ let image = explicitImage ? path.resolve(ctx.cwd, explicitImage) : ''
209
+ if (!explicitImage) {
210
+ const prepared = flag(parsed, 'no-build')
211
+ ? { images: buildImages(esp32BuildDir(ctx, selection, app.id, env)) }
212
+ : buildEsp32Firmware({ ctx, selection, app, env, bleOta: transport === 'ble' || bleOtaRequested(parsed, app, env), dryRun: base.dryRun, stdout: base.stdout, stderr: base.stderr })
213
+ image = prepared.images.app
214
+ }
215
+
216
+ if (transport === 'ble') {
217
+ bleOta({ cliPackageRoot: ctx.cliPackageRoot, image, deviceName: option(parsed, 'device', ''), env, dryRun: base.dryRun, stdout: base.stdout })
218
+ return 0
219
+ }
220
+ if (slot) {
221
+ await otaStage({ selection, host: selection.host, image, slot, boot: flag(parsed, 'boot'), reboot: flag(parsed, 'reboot'), appLabel: app?.id || 'prebuilt image', dryRun: base.dryRun, stdout: base.stdout })
222
+ return 0
223
+ }
224
+ await otaFlash({ host: selection.host, image, dryRun: base.dryRun, stdout: base.stdout })
225
+ if (flag(parsed, 'monitor') || flag(parsed, 'logs')) {
226
+ if (base.dryRun) return 0
227
+ await waitForReboot({ host: selection.host, stdout: base.stdout })
228
+ return logsCommand(ctx, { ...parsed, options: { ...parsed.options, transport: 'wifi', follow: true } }, rest, options, selection)
229
+ }
230
+ return 0
231
+ }
232
+
233
+ // ---- monitor / logs / screenshot ---------------------------------------------
234
+
235
+ function abortOnSigint() {
236
+ const controller = new AbortController()
237
+ const onSigint = () => controller.abort()
238
+ process.once('SIGINT', onSigint)
239
+ return { signal: controller.signal, release: () => process.removeListener('SIGINT', onSigint) }
240
+ }
241
+
242
+ async function withDevice(ctx, parsed, options, selection, transport, fn) {
243
+ const base = io(parsed, options)
244
+ if (base.dryRun) {
245
+ base.stdout(`[dry-run] ${transport} device: ${transport === 'wifi' ? option(parsed, 'host', '') || selection.otaHost : selection.port || `usb serial ${selection.usbSerial}`}`)
246
+ return 0
247
+ }
248
+ const device = await openDevice({
249
+ selection,
250
+ transport,
251
+ host: option(parsed, 'host', ''),
252
+ port: selection.port,
253
+ env: base.env,
254
+ trace: flag(parsed, 'trace'),
255
+ stderr: base.stderr,
256
+ waitSeconds: Number(option(parsed, 'wait', base.env.GEA_ESP32_MONITOR_WAIT_SECONDS || 0))
257
+ })
258
+ try {
259
+ return (await fn(device, base)) ?? 0
260
+ } finally {
261
+ await device.close()
262
+ }
263
+ }
264
+
265
+ export async function monitorCommand(ctx, parsed, rest, options, preselected = null) {
266
+ const selection = preselected || selectBoard(ctx, parsed, { usbPort: true })
267
+ if (selection.adapter === 'geaos-linux' || selection.adapter === 'geaos-arm64') {
268
+ const base = io(parsed, options)
269
+ return runGeaos({ ctx, selection, action: 'monitor', positionals: rest, env: createChildEnv(ctx, base.env), dryRun: base.dryRun, stdout: base.stdout })
270
+ }
271
+ return withDevice(ctx, parsed, options, selection, 'usb', async (device, base) => {
272
+ base.stderr(`Opening serial monitor on ${device.description}... (Ctrl+C to exit)`)
273
+ const { signal, release } = abortOnSigint()
274
+ try {
275
+ await device.logs({ write: (line) => base.stdout(line), timestamps: flag(parsed, 'timestamps'), logFile: option(parsed, 'log-file', ''), signal })
276
+ } finally {
277
+ release()
278
+ }
279
+ })
280
+ }
281
+
282
+ export async function logsCommand(ctx, parsed, rest, options, preselected = null) {
283
+ const selection = preselected || selectBoard(ctx, parsed, {})
284
+ const transport = chooseTransport(option(parsed, 'transport', 'auto'), selection, { host: option(parsed, 'host', '') })
285
+ if (transport === 'usb') return monitorCommand(ctx, parsed, rest, options, preselected || selectBoard(ctx, parsed, { usbPort: true }))
286
+ return withDevice(ctx, parsed, options, selection, 'wifi', async (device, base) => {
287
+ const follow = flag(parsed, 'follow')
288
+ base.stderr(`Connecting to diagnostics stream at ${device.host}:8081${follow ? ' (Ctrl+C to exit)' : ''}`)
289
+ const { signal, release } = abortOnSigint()
290
+ try {
291
+ await device.logs({ follow, write: (chunk) => process.stdout.write(chunk), timeoutMs: Number(option(parsed, 'timeout', 10)) * 1000, signal })
292
+ } finally {
293
+ release()
294
+ }
295
+ })
296
+ }
297
+
298
+ export async function screenshotCommand(ctx, parsed, rest, options) {
299
+ const selection = selectBoard(ctx, parsed, {})
300
+ const transport = chooseTransport(option(parsed, 'transport', 'auto'), selection, { host: option(parsed, 'host', '') })
301
+ const file = path.resolve(ctx.cwd, rest[0] || option(parsed, 'out', '') || 'screenshot.png')
302
+ const usbSelection = transport === 'usb' ? selectBoard(ctx, parsed, { usbPort: true }) : selection
303
+ return withDevice(ctx, parsed, options, usbSelection, transport, async (device, base) => {
304
+ const timeoutMs = Number(option(parsed, 'timeout', transport === 'wifi' ? 30 : 12)) * 1000
305
+ const shot = await saveScreenshot(device, file, { timeoutMs, legacy: flag(parsed, 'legacy') })
306
+ base.stdout(`Saved ${shot.width}x${shot.height} screenshot${shot.app ? ` of ${shot.app}` : ''} from ${device.description} to ${file}`)
307
+ })
308
+ }
309
+
310
+ // ---- devctl -------------------------------------------------------------------------
311
+
312
+ const devctlUsage = `gea devctl <verb> [args] --board <alias> [--transport auto|usb|wifi]
313
+
314
+ Verbs (USB, GEADEV protocol):
315
+ ping | app | state | mem | summary | i2cscan | reboot
316
+ node <class> hit <x> <y> tap <x> <y> [holdMs]
317
+ drag <x1> <y1> <x2> <y2> [steps] [delayMs] swipe <x> <y1> <y2>
318
+ back key <code> notify <text>
319
+ storage get <key> | storage set <key> <value>
320
+ set-default <app-id> set-time [epochSeconds]
321
+ brightness [0-100] ls [path] rm <path>
322
+ push <local> <remote> [--base64] pull <remote> <local>
323
+ playfile <path>
324
+ Verbs (WiFi):
325
+ hbm on|off -- high-brightness mode (POST /display/hbm)`
326
+
327
+ export async function devctlCommand(ctx, parsed, rest, options) {
328
+ const verb = rest[0]
329
+ const args = rest.slice(1)
330
+ if (!verb || verb === 'help') {
331
+ options.stdout(devctlUsage)
332
+ return verb ? 0 : ExitCode.usage
333
+ }
334
+ const selection = selectBoard(ctx, parsed, {})
335
+ const wifiVerbs = new Set(['hbm'])
336
+ const requested = option(parsed, 'transport', wifiVerbs.has(verb) ? 'wifi' : 'usb')
337
+ const transport = chooseTransport(requested, selection, { host: option(parsed, 'host', '') })
338
+ const usbSelection = transport === 'usb' ? selectBoard(ctx, parsed, { usbPort: true }) : selection
339
+ return withDevice(ctx, parsed, options, usbSelection, transport, async (device, base) => {
340
+ if (verb === 'hbm') {
341
+ const state = args[0]
342
+ if (!['on', 'off', '1', '0'].includes(state)) fail('devctl hbm expects on|off.', ExitCode.usage)
343
+ const reply = await device.hbm(state === 'on' || state === '1')
344
+ base.stdout(JSON.stringify(reply))
345
+ return 0
346
+ }
347
+ if (device.kind !== 'usb') fail(`devctl ${verb} needs the USB transport.`, ExitCode.usage)
348
+ const d = device.serial
349
+ const num = (value, name) => {
350
+ const n = Number(value)
351
+ if (!Number.isFinite(n)) fail(`devctl ${verb}: ${name} must be a number.`, ExitCode.usage)
352
+ return n
353
+ }
354
+ const need = (count) => {
355
+ if (args.length < count) fail(`devctl ${verb} needs ${count} argument(s).\n${devctlUsage}`, ExitCode.usage)
356
+ }
357
+ const print = (value) => base.stdout(String(value))
358
+ switch (verb) {
359
+ case 'ping': print(await geadev.ping(d)); break
360
+ case 'app': print(await geadev.app(d)); break
361
+ case 'state': print(await geadev.state(d)); break
362
+ case 'mem': print(await geadev.mem(d)); break
363
+ case 'summary': print(await geadev.summary(d)); break
364
+ case 'i2cscan': print(await geadev.i2cScan(d)); break
365
+ case 'reboot': print(await geadev.reboot(d)); break
366
+ case 'node': need(1); print(await geadev.node(d, args[0])); break
367
+ case 'hit': need(2); print(await geadev.hit(d, num(args[0], 'x'), num(args[1], 'y'))); break
368
+ case 'tap': need(2); print(await geadev.tap(d, num(args[0], 'x'), num(args[1], 'y'), args[2] ? num(args[2], 'holdMs') : 80)); break
369
+ case 'drag': need(4); print(await geadev.drag(d, num(args[0], 'x1'), num(args[1], 'y1'), num(args[2], 'x2'), num(args[3], 'y2'), args[4] ? num(args[4], 'steps') : 6, args[5] ? num(args[5], 'delayMs') : 24)); break
370
+ case 'swipe': need(3); print(await geadev.swipe(d, num(args[0], 'x'), num(args[1], 'y1'), num(args[2], 'y2'))); break
371
+ case 'back': print(await geadev.back(d)); break
372
+ case 'key': need(1); print(await geadev.key(d, args[0])); break
373
+ case 'notify': need(1); print(await geadev.notify(d, args.join(' '))); break
374
+ case 'storage':
375
+ need(2)
376
+ if (args[0] === 'get') print((await geadev.storageGet(d, args[1])).value)
377
+ else if (args[0] === 'set') { need(3); print(await geadev.storageSet(d, args[1], args.slice(2).join(' '))) }
378
+ else fail('devctl storage expects get <key> or set <key> <value>.', ExitCode.usage)
379
+ break
380
+ case 'set-default': need(1); print(await geadev.setDefault(d, args[0])); break
381
+ case 'set-time': print(await geadev.setTime(d, args[0] ? num(args[0], 'epoch') : Math.floor(Date.now() / 1000))); break
382
+ case 'brightness': print(await geadev.brightness(d, args[0] === undefined ? undefined : num(args[0], 'value'))); break
383
+ case 'ls': print(await geadev.ls(d, args[0] || '/sdcard')); break
384
+ case 'rm': need(1); print(await geadev.rm(d, args[0])); break
385
+ case 'push': need(2); print(await geadev.pushFile(d, path.resolve(ctx.cwd, args[0]), args[1], { base64: flag(parsed, 'base64'), stderr: base.stderr })); break
386
+ case 'pull': need(2); print(await geadev.pullFile(d, args[0], path.resolve(ctx.cwd, args[1]))); break
387
+ case 'playfile': need(1); print(await geadev.playFile(d, args[0])); break
388
+ default:
389
+ fail(`Unknown devctl verb '${verb}'.\n${devctlUsage}`, ExitCode.usage)
390
+ }
391
+ return 0
392
+ })
393
+ }
394
+
395
+ // ---- geaos device passthrough -------------------------------------------------
396
+
397
+ export async function geaosDeviceCommand(ctx, parsed, rest, options) {
398
+ const selection = selectBoard(ctx, parsed, { usbPort: true })
399
+ if (selection.adapter !== 'geaos-linux' && selection.adapter !== 'geaos-arm64') fail(`'${rest[0]}' is a geaos device action; board '${selection.boardName}' is ${selection.adapter}.`, ExitCode.usage)
400
+ const base = io(parsed, options)
401
+ return runGeaos({ ctx, selection, action: rest[0], positionals: rest.slice(1), env: createChildEnv(ctx, base.env), dryRun: base.dryRun, stdout: base.stdout })
402
+ }
@@ -0,0 +1,91 @@
1
+ import { existsSync } from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import { flag } from '../args.mjs'
5
+ import { boardConfigPath, loadBoardConfig } from '../boards/config.mjs'
6
+ import { ExitCode } from '../errors.mjs'
7
+ import { findEspIdf, findIdfPythonEnv } from '../esp32/idf-env.mjs'
8
+ import { exists } from '../fs-utils.mjs'
9
+ import { discoverApps, resolveRequestedApp, validateApp } from '../manifest.mjs'
10
+ import { commandVersion, nodeAtLeast } from '../toolchain.mjs'
11
+
12
+ function packageExists(dir) {
13
+ return Boolean(dir) && exists(path.join(dir, 'package.json'))
14
+ }
15
+
16
+ function onPath(name, env) {
17
+ return String(env.PATH || '').split(path.delimiter).some((dir) => dir && existsSync(path.join(dir, name)))
18
+ }
19
+
20
+ async function serialportAvailable() {
21
+ try {
22
+ await import('serialport')
23
+ return true
24
+ } catch {
25
+ return false
26
+ }
27
+ }
28
+
29
+ export async function doctorCommand(ctx, parsed, rest, options) {
30
+ const env = options.env || process.env
31
+ const checks = []
32
+ const add = (name, ok, detail, required) => checks.push({ name, ok: Boolean(ok), detail: String(detail ?? ''), required })
33
+
34
+ add('project root', exists(ctx.projectRoot), ctx.projectRoot, true)
35
+ add('Node >= 20.19', nodeAtLeast(20, 19), process.version, true)
36
+ add('@geastack/targets', packageExists(ctx.targetsRoot), ctx.targetsRoot || 'not installed in this project', true)
37
+ add('@geastack/core', packageExists(ctx.corePackageDir), ctx.corePackageDir || 'not installed', true)
38
+ add('@geastack/compiler', packageExists(ctx.compilerPackageDir), ctx.compilerPackageDir || 'not installed', true)
39
+ add('@geastack/geatsc-plugin-gea', packageExists(ctx.pluginPackageDir), ctx.pluginPackageDir || 'not installed', true)
40
+ for (const [name, dir] of [['@geastack/chips', ctx.chipsPackageDir], ['@geastack/engine', ctx.enginePackageDir], ['@geastack/host', ctx.hostPackageDir], ['@geastack/elements', ctx.elementsPackageDir], ['@geastack/geaos', ctx.geaosPackageDir]]) {
41
+ add(name, packageExists(dir), dir || 'not installed', false)
42
+ }
43
+ add('serialport (USB device access)', await serialportAvailable(), 'npm package', false)
44
+
45
+ const idfDir = findEspIdf(env)
46
+ const pythonEnv = idfDir ? findIdfPythonEnv(idfDir, env) : ''
47
+ add('ESP-IDF', Boolean(idfDir), idfDir || 'not found (set IDF_PATH)', false)
48
+ add('ESP-IDF python env', Boolean(pythonEnv), pythonEnv || 'run install.sh in ESP-IDF', false)
49
+ add('cmake', onPath('cmake', env) || Boolean(pythonEnv), commandVersion('cmake', ['--version'], env).split('\n')[0] || 'from ESP-IDF tools', false)
50
+ add('ninja', onPath('ninja', env), onPath('ninja', env) ? 'on PATH' : 'optional; Unix Makefiles used otherwise', false)
51
+ add('ccache', onPath('ccache', env), onPath('ccache', env) ? 'on PATH' : 'optional', false)
52
+ add('arm-none-eabi-gcc (RP2350)', onPath('arm-none-eabi-gcc', env) || Boolean(env.PICO_TOOLCHAIN_PATH), env.PICO_TOOLCHAIN_PATH || 'optional', false)
53
+ add('picotool (RP2350)', onPath('picotool', env), onPath('picotool', env) ? 'on PATH' : 'optional', false)
54
+ add('swift (BLE OTA)', onPath('swift', env), onPath('swift', env) ? 'on PATH' : 'optional; macOS only', false)
55
+
56
+ const boardFile = boardConfigPath(ctx)
57
+ try {
58
+ const boards = exists(boardFile) ? loadBoardConfig(ctx) : {}
59
+ add('boards.json', true, exists(boardFile) ? `${boardFile} (${Object.keys(boards).length} board(s))` : 'not configured (gea setup)', false)
60
+ } catch (error) {
61
+ add('boards.json', false, error.message, true)
62
+ }
63
+
64
+ const apps = discoverApps(ctx)
65
+ add('app catalog', apps.length > 0, `${apps.length} app(s)`, true)
66
+ let currentApp = null
67
+ try {
68
+ currentApp = resolveRequestedApp(ctx, parsed, [])
69
+ } catch {
70
+ currentApp = null
71
+ }
72
+ if (currentApp) {
73
+ const errors = validateApp(currentApp)
74
+ add(`app manifest (${currentApp.id})`, errors.length === 0, errors.length === 0 ? currentApp.root : errors.join('; '), true)
75
+ }
76
+
77
+ const failedRequired = checks.filter((check) => check.required && !check.ok)
78
+ const failedOptional = checks.filter((check) => !check.required && !check.ok)
79
+ if (flag(parsed, 'json')) {
80
+ options.stdout(JSON.stringify({ ok: failedRequired.length === 0, checks }, null, 2))
81
+ } else {
82
+ for (const check of checks) {
83
+ const marker = check.ok ? '[ok]' : check.required ? '[fail]' : '[warn]'
84
+ options.stdout(`${marker} ${check.name}: ${check.detail}`)
85
+ }
86
+ if (failedRequired.length > 0 || failedOptional.length > 0) options.stdout('Setup guide: cli/docs/SETUP.md')
87
+ }
88
+ if (failedRequired.length > 0) return ExitCode.missingDependency
89
+ if (failedOptional.length > 0 && flag(parsed, 'strict')) return ExitCode.missingDependency
90
+ return 0
91
+ }
package/src/context.mjs CHANGED
@@ -1,5 +1,4 @@
1
1
  import path from 'node:path'
2
- import { createRequire } from 'node:module'
3
2
  import { fileURLToPath } from 'node:url'
4
3
 
5
4
  import { option } from './args.mjs'
@@ -7,16 +6,30 @@ import { exists, findUp } from './fs-utils.mjs'
7
6
 
8
7
  const srcDir = path.dirname(fileURLToPath(import.meta.url))
9
8
  export const cliPackageRoot = path.resolve(srcDir, '..')
10
- const requireFromCli = createRequire(import.meta.url)
9
+ export const cliBin = path.join(cliPackageRoot, 'bin', 'gea.mjs')
11
10
 
12
- export function createContext(parsed, _env = process.env, cwd = process.cwd()) {
11
+ // Every @geastack package the CLI reads. They are sources and data (IDF
12
+ // projects, chip catalogs, C++ trees, the compiler) and they belong to the
13
+ // USER'S project: the CLI resolves them from the project's node_modules the
14
+ // way node itself would, never from its own install. The CLI has no @geastack
15
+ // dependencies of its own, so availability of a command is simply "is that
16
+ // package installed here".
17
+ const geastackPackages = Object.freeze({
18
+ targetsRoot: { name: '@geastack/targets', env: 'GEA_TARGETS_ROOT' },
19
+ corePackageDir: { name: '@geastack/core', env: 'GEA_CORE_DIR' },
20
+ compilerPackageDir: { name: '@geastack/compiler', env: 'GEA_COMPILER_DIR' },
21
+ chipsPackageDir: { name: '@geastack/chips', env: 'GEA_CHIPS_DIR' },
22
+ elementsPackageDir: { name: '@geastack/elements', env: 'GEA_ELEMENTS_DIR' },
23
+ enginePackageDir: { name: '@geastack/engine', env: 'GEA_ENGINE_DIR' },
24
+ geaosPackageDir: { name: '@geastack/geaos', env: 'GEA_GEAOS_PACKAGE_DIR' },
25
+ hostPackageDir: { name: '@geastack/host', env: 'GEA_HOST_DIR' },
26
+ pluginPackageDir: { name: '@geastack/geatsc-plugin-gea', env: 'GEA_PLUGIN_DIR' }
27
+ })
28
+
29
+ export function createContext(parsed, env = process.env, cwd = process.cwd()) {
13
30
  const absoluteCwd = path.resolve(cwd)
14
- const projectRoot = findNodeProjectRoot(absoluteCwd) || absoluteCwd
15
- const initialAnchors = [projectRoot, cliPackageRoot]
16
- const targetsRoot = _env.GEA_TARGETS_ROOT || resolveInstalledPackageDir('@geastack/targets', initialAnchors)
17
- const corePackageDir = resolveInstalledPackageDir('@geastack/core', initialAnchors)
18
- const packageAnchors = [projectRoot, cliPackageRoot, targetsRoot, corePackageDir].filter(Boolean)
19
- const compilerPackageDir = resolveInstalledPackageDir('@geastack/compiler', packageAnchors)
31
+ const explicitProject = option(parsed, 'project') || env.GEA_PROJECT_ROOT || ''
32
+ const projectRoot = explicitProject ? path.resolve(absoluteCwd, explicitProject) : findNodeProjectRoot(absoluteCwd) || absoluteCwd
20
33
  const projectBoardsConfig = path.join(projectRoot, '.gea', 'boards.json')
21
34
  const explicitBoardsConfig = option(parsed, 'boards-config') || ''
22
35
  const boardsConfig = explicitBoardsConfig || (exists(projectBoardsConfig) ? projectBoardsConfig : '')
@@ -26,40 +39,31 @@ export function createContext(parsed, _env = process.env, cwd = process.cwd()) {
26
39
  projectRoot,
27
40
  projectBoardsConfig,
28
41
  cliPackageRoot,
29
- compilerPackageDir,
30
- corePackageDir,
31
- chipsPackageDir: _env.GEA_CHIPS_DIR || resolveInstalledPackageDir('@geastack/chips', packageAnchors),
32
- elementsPackageDir: resolveInstalledPackageDir('@geastack/elements', packageAnchors),
33
- enginePackageDir: resolveInstalledPackageDir('@geastack/engine', packageAnchors),
34
- geaosPackageDir: resolveInstalledPackageDir('@geastack/geaos', packageAnchors),
35
- hostPackageDir: resolveInstalledPackageDir('@geastack/host', packageAnchors),
36
- pluginPackageDir: resolveInstalledPackageDir('@geastack/geatsc-plugin-gea', packageAnchors),
42
+ cliBin,
37
43
  boardsConfig: boardsConfig ? path.resolve(absoluteCwd, boardsConfig) : '',
38
- examplesRoot: projectRoot,
39
- simulatorRoot: '',
40
- androidRoot: '',
41
- appleRoot: '',
42
- targetsRoot
44
+ // Generated state (IDF build directories, sdkconfigs, generated board
45
+ // headers) lives in the project, never inside an installed package.
46
+ buildRoot: env.GEA_PROJECT_BUILD_ROOT
47
+ ? path.resolve(absoluteCwd, env.GEA_PROJECT_BUILD_ROOT)
48
+ : path.join(projectRoot, '.gea', 'build'),
49
+ env
43
50
  }
44
- ctx.scripts = {
45
- board: packageFile(ctx.targetsRoot, 'scripts', 'board'),
46
- webBuild: '',
47
- webDev: '',
48
- androidBuild: '',
49
- macosBuild: '',
50
- iosBuild: '',
51
- geaEmbedded: packageFile(ctx.corePackageDir, 'bin', 'gea-embedded.mjs')
51
+ for (const [field, { name, env: envName }] of Object.entries(geastackPackages)) {
52
+ ctx[field] = env[envName] || resolveInstalledPackageDir(name, projectRoot)
52
53
  }
54
+ ctx.packageName = (field) => geastackPackages[field]?.name || field
53
55
  return ctx
54
56
  }
55
57
 
56
- export function createChildEnv(ctx, env = process.env) {
58
+ // Environment handed to every build system the CLI drives (IDF/CMake, the
59
+ // Pico SDK, the geaos scripts). They receive exact package paths and never
60
+ // resolve anything themselves.
61
+ export function createChildEnv(ctx, env = ctx.env || process.env) {
57
62
  const out = {
58
63
  ...env,
59
64
  GEA_APPS_ROOT: ctx.projectRoot,
60
- GEA_CLI_BIN: path.join(ctx.cliPackageRoot, 'bin', 'gea.mjs'),
65
+ GEA_CLI_BIN: ctx.cliBin,
61
66
  GEA_CHIPS_DIR: ctx.chipsPackageDir,
62
- GEA_CORE_PACKAGE: ctx.corePackageDir,
63
67
  GEA_CORE_DIR: ctx.corePackageDir,
64
68
  GEA_COMPILER_DIR: ctx.compilerPackageDir,
65
69
  GEA_ELEMENTS_DIR: ctx.elementsPackageDir,
@@ -77,28 +81,17 @@ export function createChildEnv(ctx, env = process.env) {
77
81
  return out
78
82
  }
79
83
 
80
- function resolveInstalledPackageDir(packageName, anchors) {
81
- for (const anchor of anchors) {
82
- let current = path.resolve(anchor)
83
- while (true) {
84
- const candidate = path.join(current, 'node_modules', ...packageName.split('/'))
85
- if (exists(path.join(candidate, 'package.json'))) return candidate
86
- const parent = path.dirname(current)
87
- if (parent === current) break
88
- current = parent
89
- }
90
- }
91
- try {
92
- return path.dirname(requireFromCli.resolve(`${packageName}/package.json`))
93
- } catch {
94
- return ''
84
+ export function resolveInstalledPackageDir(packageName, anchor) {
85
+ let current = path.resolve(anchor)
86
+ while (true) {
87
+ const candidate = path.join(current, 'node_modules', ...packageName.split('/'))
88
+ if (exists(path.join(candidate, 'package.json'))) return candidate
89
+ const parent = path.dirname(current)
90
+ if (parent === current) return ''
91
+ current = parent
95
92
  }
96
93
  }
97
94
 
98
- function packageFile(packageRoot, ...segments) {
99
- return packageRoot ? path.join(packageRoot, ...segments) : ''
100
- }
101
-
102
95
  function appendPathList(current, value) {
103
96
  const entries = String(current || '').split(path.delimiter).filter(Boolean)
104
97
  if (value && !entries.includes(value)) entries.push(value)