@geastack/cli 0.1.52 → 0.1.54

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.
Files changed (44) hide show
  1. package/README.md +14 -1
  2. package/docs/ESP32-WAVESHARE-AMOLED-QUICKSTART.md +2 -2
  3. package/docs/NPX-COMMANDS.md +3 -1
  4. package/docs/SETUP.md +81 -15
  5. package/docs/SPEC.md +8 -5
  6. package/package.json +2 -3
  7. package/src/apps/app-index-writer.mjs +15 -0
  8. package/src/apps/apple-icons.mjs +147 -0
  9. package/src/apps/bundle-writer.mjs +156 -0
  10. package/src/apps/launcher-catalog.mjs +114 -0
  11. package/src/apps/openai-icons.mjs +356 -0
  12. package/src/apps/zip-writer.mjs +104 -0
  13. package/src/ble/ble-ota.swift +291 -0
  14. package/src/boards/config.mjs +114 -0
  15. package/src/boards/custom-target.mjs +309 -0
  16. package/src/boards/resolve.mjs +145 -0
  17. package/src/boards/targets.mjs +45 -0
  18. package/src/boards/usb.mjs +252 -0
  19. package/src/chips.mjs +1 -1
  20. package/src/commands/apps.mjs +204 -0
  21. package/src/commands/board.mjs +402 -0
  22. package/src/commands/boards.mjs +334 -0
  23. package/src/commands/doctor.mjs +91 -0
  24. package/src/context.mjs +50 -54
  25. package/src/device/device.mjs +96 -0
  26. package/src/device/image.mjs +115 -0
  27. package/src/device/serial.mjs +430 -0
  28. package/src/device/wifi.mjs +190 -0
  29. package/src/esp32/build.mjs +321 -0
  30. package/src/esp32/capabilities.mjs +54 -0
  31. package/src/esp32/flash.mjs +181 -0
  32. package/src/esp32/idf-env.mjs +171 -0
  33. package/src/esp32/ota.mjs +80 -0
  34. package/src/esp32/partitions.mjs +59 -0
  35. package/src/esp32/sdkconfig.mjs +103 -0
  36. package/src/esp32/wifi-config.mjs +72 -0
  37. package/src/gea.mjs +89 -427
  38. package/src/geaos/adapter.mjs +119 -0
  39. package/src/heap-report.mjs +1276 -0
  40. package/src/manifest.mjs +135 -36
  41. package/src/rp2350/adapter.mjs +155 -0
  42. package/src/serial-devices.mjs +32 -20
  43. package/src/setup-wizard.mjs +11 -12
  44. package/src/taurus/adapter.mjs +52 -0
package/src/manifest.mjs CHANGED
@@ -1,21 +1,31 @@
1
1
  import path from 'node:path'
2
2
 
3
+ import { loadBoardConfig, normalizeBoardConfig } from './boards/config.mjs'
4
+ import { loadTargets } from './boards/targets.mjs'
3
5
  import { CliError, ExitCode, fail } from './errors.mjs'
4
6
  import { exists, findUp, isDirectory, listDirectories, readJson } from './fs-utils.mjs'
5
7
 
8
+ // App discovery. A Gea app is a package.json with a `gea` block; the project
9
+ // root, its apps/ and examples/ children, and any GEA_EXTRA_APP_DIRS entries
10
+ // are searched. The normalized shape is the one every consumer (IDF, the
11
+ // Pico SDK, geaos, the bundle writer, the launcher generator) reads.
12
+
13
+ export function readPathList(value) {
14
+ return String(value || '').split(path.delimiter).map((entry) => entry.trim()).filter(Boolean)
15
+ }
16
+
6
17
  export function discoverApps(ctx) {
7
- const roots = [ctx.projectRoot]
18
+ const roots = [ctx.projectRoot, ...readPathList(ctx.env?.GEA_EXTRA_APP_DIRS)]
8
19
  const seen = new Set()
9
20
  const apps = []
10
21
  for (const root of roots) {
11
22
  for (const app of discoverAppsInRoot(root)) {
12
- const key = app.root
13
- if (seen.has(key)) continue
14
- seen.add(key)
23
+ if (seen.has(app.root) || apps.some((known) => known.id === app.id)) continue
24
+ seen.add(app.root)
15
25
  apps.push(app)
16
26
  }
17
27
  }
18
- return apps.sort((a, b) => a.id.localeCompare(b.id))
28
+ return apps.sort((a, b) => a.launcher.order - b.launcher.order || a.id.localeCompare(b.id))
19
29
  }
20
30
 
21
31
  export function discoverAppsInRoot(root) {
@@ -37,16 +47,17 @@ export function discoverAppsInRoot(root) {
37
47
  })
38
48
  }
39
49
 
40
- export function resolveRequestedApp(ctx, parsed, positionals) {
50
+ export function resolveRequestedApp(ctx, parsed, positionals = []) {
41
51
  const requested = parsed.options.app || positionals[0]
42
52
  if (requested) {
43
- const app = findAppById(ctx, String(Array.isArray(requested) ? requested.at(-1) : requested))
44
- if (!app) fail(`Could not find Gea app '${requested}' in ${ctx.projectRoot}.`, ExitCode.usage)
53
+ const id = String(Array.isArray(requested) ? requested.at(-1) : requested)
54
+ const app = findAppById(ctx, id)
55
+ if (!app) fail(`Could not find Gea app '${id}' in ${ctx.projectRoot}.`, ExitCode.usage)
45
56
  return app
46
57
  }
47
58
  const current = findCurrentApp(ctx.cwd)
48
59
  if (current) return current
49
- fail('No app selected. Pass --app <id>, pass an app id, or run inside a Gea app folder.', ExitCode.usage)
60
+ fail('No app selected. Pass --app <id> or run inside a Gea app folder.', ExitCode.usage)
50
61
  }
51
62
 
52
63
  export function findAppById(ctx, id) {
@@ -54,7 +65,7 @@ export function findAppById(ctx, id) {
54
65
  }
55
66
 
56
67
  export function findCurrentApp(cwd) {
57
- const packageDir = findUp(cwd, (dir) => exists(path.join(dir, 'package.json')) && hasGeaManifest(path.join(dir, 'package.json')))
68
+ const packageDir = findUp(cwd, (dir) => hasGeaManifest(path.join(dir, 'package.json')))
58
69
  if (!packageDir) return null
59
70
  return normalizeApp(packageDir, readJson(path.join(packageDir, 'package.json')))
60
71
  }
@@ -66,6 +77,9 @@ export function validateApp(app) {
66
77
  if (app.entry && !exists(path.join(app.root, app.entry))) errors.push(`gea.entry does not exist: ${app.entry}`)
67
78
  if (!app.runtime) errors.push('gea.runtime is required or must default to gea')
68
79
  if (!app.targets || typeof app.targets !== 'object' || Array.isArray(app.targets)) errors.push('gea.targets must be an object')
80
+ for (const source of app.nativeSources) {
81
+ if (!exists(path.join(app.root, source))) errors.push(`gea.nativeSources entry does not exist: ${source}`)
82
+ }
69
83
  return errors
70
84
  }
71
85
 
@@ -78,8 +92,7 @@ export function assertValidApp(app) {
78
92
 
79
93
  export function targetEnabledForApp(ctx, app, targetOrPlatform) {
80
94
  if (!targetOrPlatform) return true
81
- const platforms = appPlatformsForTarget(ctx, targetOrPlatform)
82
- return platforms.some((platform) => app.targets?.[platform] === true)
95
+ return appPlatformsForTarget(ctx, targetOrPlatform).some((platform) => app.targets?.[platform] === true)
83
96
  }
84
97
 
85
98
  export function assertTargetEnabled(ctx, app, targetOrPlatform) {
@@ -89,16 +102,18 @@ export function assertTargetEnabled(ctx, app, targetOrPlatform) {
89
102
  }
90
103
  }
91
104
 
105
+ export const knownPlatforms = Object.freeze(['web', 'esp32', 'rp2350', 'geaos', 'macos', 'ios', 'android'])
106
+
92
107
  export function appPlatformForTarget(ctx, targetOrBoard) {
93
108
  return appPlatformsForTarget(ctx, targetOrBoard)[0] || ''
94
109
  }
95
110
 
96
111
  export function appPlatformsForTarget(ctx, targetOrBoard) {
97
112
  if (!targetOrBoard) return []
98
- if (['web', 'esp32', 'rp2350', 'geaos', 'macos', 'ios', 'android'].includes(targetOrBoard)) return [targetOrBoard]
99
- const targets = loadTargetMetadata(ctx)
113
+ if (knownPlatforms.includes(targetOrBoard)) return [targetOrBoard]
114
+ const targets = safeTargets(ctx)
100
115
  if (targets[targetOrBoard]?.appPlatform) return targetAppPlatforms(targets[targetOrBoard])
101
- const boards = loadBoardConfig(ctx)
116
+ const boards = safeBoards(ctx)
102
117
  const board = boards[targetOrBoard]
103
118
  const boardTarget = board?.target
104
119
  if (board?.appPlatform) {
@@ -112,25 +127,54 @@ export function appPlatformsForTarget(ctx, targetOrBoard) {
112
127
  return [targetOrBoard]
113
128
  }
114
129
 
115
- export function loadTargetMetadata(ctx) {
116
- const file = path.join(ctx.targetsRoot, 'scripts', 'boards', 'targets.json')
117
- return exists(file) ? readJson(file) : {}
130
+ // Consumers (CMake, the apple/geaos build scripts) join a relative root onto
131
+ // GEA_APPS_ROOT; an app outside the project keeps its absolute path.
132
+ export function appRootFor(ctx, app) {
133
+ const relative = path.relative(ctx.projectRoot, app.root)
134
+ if (!relative) return '.'
135
+ return relative.startsWith('..') || path.isAbsolute(relative) ? app.root : relative.split(path.sep).join('/')
118
136
  }
119
137
 
120
- export function loadBoardConfig(ctx) {
121
- const file = boardConfigPath(ctx)
122
- return exists(file) ? readJson(file) : {}
138
+ // `root;entry;runtime;nativeSource...` -- the one line CMake splits on ';'.
139
+ export function appCmakeMeta(ctx, app) {
140
+ return [appRootFor(ctx, app), app.entry, app.runtime, ...app.nativeSources].join(';')
141
+ }
142
+
143
+ export function appSummary(ctx, app) {
144
+ return {
145
+ id: app.id,
146
+ name: app.name,
147
+ packageName: app.packageName,
148
+ version: app.version,
149
+ root: appRootFor(ctx, app),
150
+ entry: app.entry,
151
+ runtime: app.runtime,
152
+ targets: app.targets,
153
+ icons: app.icons,
154
+ nativeSources: app.nativeSources,
155
+ launcher: app.launcher
156
+ }
123
157
  }
124
158
 
125
- export function boardConfigPath(ctx) {
126
- if (ctx.boardsConfig) return ctx.boardsConfig
127
- if (ctx.projectBoardsConfig && exists(ctx.projectBoardsConfig)) return ctx.projectBoardsConfig
128
- return path.join(ctx.targetsRoot, 'boards.json')
159
+ function safeTargets(ctx) {
160
+ try {
161
+ return loadTargets(ctx)
162
+ } catch {
163
+ return {}
164
+ }
165
+ }
166
+
167
+ function safeBoards(ctx) {
168
+ try {
169
+ return normalizeBoardConfig(loadBoardConfig(ctx))
170
+ } catch {
171
+ return {}
172
+ }
129
173
  }
130
174
 
131
175
  function hasGeaManifest(packagePath) {
132
176
  try {
133
- return Boolean(readJson(packagePath).gea)
177
+ return exists(packagePath) && Boolean(readJson(packagePath).gea)
134
178
  } catch {
135
179
  return false
136
180
  }
@@ -152,19 +196,74 @@ function uniquePlatforms(values) {
152
196
  return out
153
197
  }
154
198
 
155
- function normalizeApp(root, packageJson) {
199
+ function packageId(packageName) {
200
+ const last = String(packageName || '').split('/').pop() || ''
201
+ return last.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'app'
202
+ }
203
+
204
+ function normalizeTargets(raw) {
205
+ if (Array.isArray(raw)) return Object.fromEntries(raw.map((name) => [String(name), true]))
206
+ if (!raw || typeof raw !== 'object') return {}
207
+ const out = {}
208
+ for (const [name, value] of Object.entries(raw)) {
209
+ if (!name) continue
210
+ if (value && typeof value === 'object') out[name] = value.enabled !== false
211
+ else out[name] = value === true
212
+ }
213
+ return out
214
+ }
215
+
216
+ function normalizeIcons(raw) {
217
+ if (!raw || typeof raw !== 'object') return {}
218
+ const icons = {}
219
+ for (const [size, file] of Object.entries(raw)) {
220
+ const numericSize = Number(size)
221
+ if (!Number.isInteger(numericSize) || numericSize <= 0) continue
222
+ if (typeof file !== 'string' || file.length === 0) continue
223
+ icons[numericSize] = file
224
+ }
225
+ return icons
226
+ }
227
+
228
+ function normalizeLauncher(raw) {
229
+ if (!raw || typeof raw !== 'object') return { description: '', order: 0, accent: '', hidden: false }
230
+ return {
231
+ description: typeof raw.description === 'string' ? raw.description : '',
232
+ order: Number.isFinite(raw.order) ? raw.order : 0,
233
+ accent: typeof raw.accent === 'string' ? raw.accent : '',
234
+ hidden: raw.hidden === true
235
+ }
236
+ }
237
+
238
+ function normalizeManifestRelativePath(value) {
239
+ const normalized = path.posix.normalize(String(value || '').replaceAll('\\', '/'))
240
+ if (!normalized || normalized === '.' || normalized === '..') return ''
241
+ if (path.posix.isAbsolute(normalized) || normalized.startsWith('../')) return ''
242
+ if (normalized.includes('/../') || normalized.includes(';')) return ''
243
+ return normalized
244
+ }
245
+
246
+ export function normalizeNativeSources(raw) {
247
+ if (!Array.isArray(raw)) return []
248
+ const sources = raw.map(normalizeManifestRelativePath).filter((source) => /\.(?:c|cc|cpp|cxx|m|mm|S)$/.test(source))
249
+ return [...new Set(sources)]
250
+ }
251
+
252
+ export function normalizeApp(root, packageJson) {
156
253
  const gea = packageJson.gea || {}
254
+ const id = typeof gea.id === 'string' && gea.id ? gea.id : packageId(packageJson.name)
157
255
  return {
158
- id: String(gea.id || ''),
159
- name: String(gea.name || packageJson.name || gea.id || ''),
160
- packageName: packageJson.name || '',
161
- version: packageJson.version || '',
256
+ id,
257
+ name: typeof gea.name === 'string' && gea.name ? gea.name : packageJson.name || id,
258
+ packageName: packageJson.name || id,
259
+ version: packageJson.version || '0.0.0',
162
260
  root: path.resolve(root),
163
- entry: gea.entry || 'index.tsx',
164
- runtime: gea.runtime || 'gea',
165
- targets: gea.targets || {},
166
- icons: gea.icons || {},
167
- launcher: gea.launcher || {},
261
+ entry: typeof gea.entry === 'string' && gea.entry ? gea.entry : 'index.tsx',
262
+ runtime: typeof gea.runtime === 'string' && gea.runtime ? gea.runtime : 'gea',
263
+ targets: normalizeTargets(gea.targets),
264
+ icons: normalizeIcons(gea.icons),
265
+ nativeSources: normalizeNativeSources(gea.nativeSources),
266
+ launcher: normalizeLauncher(gea.launcher),
168
267
  manifest: gea,
169
268
  packageJson
170
269
  }
@@ -0,0 +1,155 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { existsSync, readdirSync } from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+
6
+ import { resolvePicotoolSelection } from '../boards/usb.mjs'
7
+ import { CliError, ExitCode, fail } from '../errors.mjs'
8
+ import { appCmakeMeta } from '../manifest.mjs'
9
+ import { formatCommand } from '../run.mjs'
10
+
11
+ // Raspberry Pi RP2350 boards through the Pico SDK: cmake configure + build,
12
+ // then UF2 over the BOOTSEL volume or picotool.
13
+
14
+ const firmwareStems = {
15
+ 'rp2350-waveshare-touch-amoled-2.41': 'gea_rp2350_touch_amoled_241',
16
+ 'rp2350-tufty-2350': 'gea_rp2350_tufty_2350'
17
+ }
18
+
19
+ function toolchainHasRuntime(root) {
20
+ if (!existsSync(path.join(root, 'bin', 'arm-none-eabi-gcc'))) return false
21
+ const stack = [root]
22
+ while (stack.length) {
23
+ const dir = stack.pop()
24
+ let entries = []
25
+ try {
26
+ entries = readdirSync(dir, { withFileTypes: true })
27
+ } catch {
28
+ continue
29
+ }
30
+ for (const entry of entries) {
31
+ if (entry.isFile() && entry.name === 'nosys.specs') return true
32
+ if (entry.isDirectory()) stack.push(path.join(dir, entry.name))
33
+ }
34
+ }
35
+ return false
36
+ }
37
+
38
+ function globDirs(pattern) {
39
+ const parent = path.dirname(pattern)
40
+ const prefix = path.basename(pattern).replace(/\*.*$/, '')
41
+ const suffix = pattern.includes('*') ? pattern.slice(pattern.indexOf('*') + 1) : ''
42
+ try {
43
+ return readdirSync(parent)
44
+ .filter((name) => name.startsWith(prefix))
45
+ .map((name) => path.join(parent, name, suffix))
46
+ .filter((dir) => existsSync(dir))
47
+ } catch {
48
+ return []
49
+ }
50
+ }
51
+
52
+ export function prepareArmToolchain(env, stderr = () => {}) {
53
+ if (env.PICO_TOOLCHAIN_PATH) {
54
+ return { ...env, PATH: `${path.join(env.PICO_TOOLCHAIN_PATH, 'bin')}${path.delimiter}${env.PATH || ''}` }
55
+ }
56
+ const probe = spawnSync('arm-none-eabi-gcc', ['-print-file-name=nosys.specs'], { env, encoding: 'utf8' })
57
+ const specs = (probe.stdout || '').trim()
58
+ if (probe.status === 0 && specs && specs !== 'nosys.specs' && existsSync(specs)) return env
59
+ const candidates = [
60
+ ...globDirs(path.join(os.homedir(), 'Tools', 'arm-gnu-toolchain-*', 'extract', 'Payload')),
61
+ ...globDirs('/Applications/ArmGNUToolchain/*/arm-none-eabi')
62
+ ]
63
+ for (const candidate of candidates) {
64
+ if (toolchainHasRuntime(candidate)) {
65
+ stderr(`Using RP2350 ARM toolchain: ${candidate}`)
66
+ return { ...env, PICO_TOOLCHAIN_PATH: candidate, PATH: `${path.join(candidate, 'bin')}${path.delimiter}${env.PATH || ''}` }
67
+ }
68
+ }
69
+ fail('RP2350 Pico SDK builds need an arm-none-eabi toolchain with newlib/nosys.specs.\nInstall the official Arm GNU Embedded toolchain or set PICO_TOOLCHAIN_PATH to its root.', ExitCode.missingDependency)
70
+ }
71
+
72
+ export function cmakeBinary(env) {
73
+ if (env.CMAKE) return env.CMAKE
74
+ for (const dir of String(env.PATH || '').split(path.delimiter)) {
75
+ if (dir && existsSync(path.join(dir, 'cmake'))) return path.join(dir, 'cmake')
76
+ }
77
+ for (const candidate of ['/opt/homebrew/bin/cmake', '/usr/local/bin/cmake', '/Applications/CMake.app/Contents/bin/cmake']) {
78
+ if (existsSync(candidate)) return candidate
79
+ }
80
+ fail('CMake is required for RP2350 Pico SDK builds. Install cmake or set CMAKE=/path/to/cmake.', ExitCode.missingDependency)
81
+ }
82
+
83
+ export function firmwareTarget(selection, app) {
84
+ const stem = firmwareStems[selection.target]
85
+ if (!stem) fail(`No RP2350 firmware target is registered for '${selection.target}'.`, ExitCode.usage)
86
+ return app ? `${stem}_app` : `${stem}_bringup`
87
+ }
88
+
89
+ function run(command, args, { cwd, env, dryRun, stdout, failureCode = ExitCode.buildFailed }) {
90
+ if (dryRun) {
91
+ stdout(formatCommand([command, ...args]))
92
+ return
93
+ }
94
+ const result = spawnSync(command, args, { cwd, env, stdio: 'inherit' })
95
+ if (result.error) throw result.error
96
+ if (result.status !== 0) throw new CliError(`ERROR: Command failed (${result.status ?? 1}): ${formatCommand([command, ...args])}`, failureCode)
97
+ }
98
+
99
+ export function rp2350BuildDir(ctx, selection) {
100
+ return path.join(ctx.buildRoot, selection.target)
101
+ }
102
+
103
+ export function buildRp2350({ ctx, selection, app = null, env, dryRun = false, stdout, stderr, configureOnly = false }) {
104
+ const toolEnv = prepareArmToolchain(env, stderr)
105
+ const cmake = cmakeBinary(toolEnv)
106
+ const buildDir = rp2350BuildDir(ctx, selection)
107
+ const configure = ['-S', selection.targetDir, '-B', buildDir]
108
+ const buildEnv = { ...toolEnv }
109
+ if (app) {
110
+ const meta = appCmakeMeta(ctx, app)
111
+ configure.push(`-DGEA_EMBEDDED_APP=${app.id}`, `-DGEA_EMBEDDED_APP_META=${meta}`)
112
+ buildEnv.GEA_EMBEDDED_APP = app.id
113
+ buildEnv.GEA_EMBEDDED_APP_META = meta
114
+ }
115
+ run(cmake, configure, { cwd: selection.targetDir, env: buildEnv, dryRun, stdout })
116
+ if (configureOnly) return { buildDir, uf2: '' }
117
+ const target = firmwareTarget(selection, app)
118
+ run(cmake, ['--build', buildDir, '--target', target], { cwd: selection.targetDir, env: buildEnv, dryRun, stdout })
119
+ return { buildDir, uf2: path.join(buildDir, `${target}.uf2`) }
120
+ }
121
+
122
+ function bootselVolumes(env) {
123
+ const user = env.USER || 'user'
124
+ return [
125
+ ...globDirs('/Volumes/RPI-RP2*'),
126
+ ...globDirs('/Volumes/RP2350*'),
127
+ `/media/${user}/RPI-RP2`,
128
+ `/media/${user}/RP2350`,
129
+ `/run/media/${user}/RPI-RP2`,
130
+ `/run/media/${user}/RP2350`
131
+ ].filter((dir) => existsSync(dir))
132
+ }
133
+
134
+ export function flashRp2350({ selection, uf2, env, dryRun = false, stdout }) {
135
+ if (!dryRun && !existsSync(uf2)) fail(`UF2 image not found after build: ${uf2}`, ExitCode.deployFailed)
136
+ const [volume] = bootselVolumes(env)
137
+ if (volume) {
138
+ const copyArgs = process.platform === 'darwin' ? ['-X', uf2, `${volume}/`] : [uf2, `${volume}/`]
139
+ run('cp', copyArgs, { env, dryRun, stdout, failureCode: ExitCode.deployFailed })
140
+ if (!dryRun) spawnSync('sync', [], { stdio: 'ignore' })
141
+ stdout(`Copied UF2 to ${volume}. The board should reboot automatically.`)
142
+ return
143
+ }
144
+ const picotool = spawnSync('picotool', ['version'], { env, stdio: 'ignore' })
145
+ if (!picotool.error) {
146
+ const selectionArgs = selection.usbSerial ? resolvePicotoolSelection({ serial: selection.usbSerial }) : []
147
+ run('picotool', ['load', ...selectionArgs, '-f', '-x', uf2], { env, dryRun, stdout, failureCode: ExitCode.deployFailed })
148
+ return
149
+ }
150
+ if (dryRun) {
151
+ stdout(formatCommand(['picotool', 'load', '-f', '-x', uf2]))
152
+ return
153
+ }
154
+ fail('Could not flash RP2350 target.\nInstall picotool or put the board in BOOTSEL mode so RPI-RP2/RP2350 is mounted, then retry.', ExitCode.deployFailed)
155
+ }
@@ -2,10 +2,16 @@ import { execFileSync } from 'node:child_process'
2
2
  import fs from 'node:fs'
3
3
  import path from 'node:path'
4
4
 
5
- export function detectSerialDevices({ env = process.env, platform = process.platform } = {}) {
5
+ import { listMacUsbCalloutPorts } from './boards/usb.mjs'
6
+
7
+ // Serial devices with, where the OS can tell, the USB serial that identifies
8
+ // the board. A /dev name is never that identity -- `cu.usbmodem1101` is a
9
+ // slot number that changes on every enumeration -- so a device whose serial
10
+ // the registry does not know reports an empty one and the caller asks.
11
+ export function detectSerialDevices({ env = process.env, platform = process.platform, ioreg = undefined } = {}) {
6
12
  if (env.GEA_SERIAL_DEVICES) return parseSerialDevices(env.GEA_SERIAL_DEVICES)
7
13
  if (platform === 'win32') return detectWindowsSerialDevices(env)
8
- return detectUnixSerialDevices()
14
+ return detectUnixSerialDevices({ platform, ioreg })
9
15
  }
10
16
 
11
17
  export function formatSerialDevice(device) {
@@ -32,13 +38,28 @@ function parseSerialDevices(value) {
32
38
  .filter((device) => device.path)
33
39
  }
34
40
 
35
- function detectUnixSerialDevices() {
41
+ function detectUnixSerialDevices({ platform, ioreg }) {
36
42
  const devices = new Map()
37
43
  addLinuxByIdDevices(devices)
38
- addDevPatternDevices(devices)
44
+ addDevPatternDevices(devices, platform)
45
+ if (platform === 'darwin') mergeRegistryDevices(devices, ioreg ? listMacUsbCalloutPorts(ioreg) : listMacUsbCalloutPorts())
39
46
  return [...devices.values()].sort((a, b) => a.path.localeCompare(b.path))
40
47
  }
41
48
 
49
+ // Overlays what the USB registry knows (serial, product name) on the ports
50
+ // found under /dev, and adds callout ports /dev scanning did not match.
51
+ export function mergeRegistryDevices(devices, registry) {
52
+ for (const entry of registry) {
53
+ const known = devices.get(entry.path)
54
+ devices.set(entry.path, normalizeDevice({
55
+ path: entry.path,
56
+ label: entry.label || known?.label || entry.path,
57
+ serial: entry.serial || known?.serial || ''
58
+ }))
59
+ }
60
+ return devices
61
+ }
62
+
42
63
  function addLinuxByIdDevices(devices) {
43
64
  const byId = '/dev/serial/by-id'
44
65
  if (!isDirectory(byId)) return
@@ -52,27 +73,18 @@ function addLinuxByIdDevices(devices) {
52
73
  }
53
74
  }
54
75
 
55
- function addDevPatternDevices(devices) {
76
+ // On macOS only the call-up (`cu.`) device is listed: `tty.` is the same
77
+ // port waiting for carrier, and opening it blocks.
78
+ function addDevPatternDevices(devices, platform) {
56
79
  const dev = '/dev'
57
80
  if (!isDirectory(dev)) return
58
- const patterns = [
59
- /^cu\.usbmodem/,
60
- /^tty\.usbmodem/,
61
- /^cu\.usbserial/,
62
- /^tty\.usbserial/,
63
- /^cu\.SLAB_USBtoUART/,
64
- /^tty\.SLAB_USBtoUART/,
65
- /^ttyACM/,
66
- /^ttyUSB/
67
- ]
81
+ const patterns = platform === 'darwin'
82
+ ? [/^cu\.usbmodem/, /^cu\.usbserial/, /^cu\.SLAB_USBtoUART/, /^cu\.wchusbserial/]
83
+ : [/^ttyACM/, /^ttyUSB/]
68
84
  for (const name of safeReaddir(dev)) {
69
85
  if (!patterns.some((pattern) => pattern.test(name))) continue
70
86
  const devicePath = path.join(dev, name)
71
- devices.set(devicePath, normalizeDevice({
72
- path: devicePath,
73
- label: name,
74
- serial: serialFromDeviceName(name)
75
- }))
87
+ devices.set(devicePath, normalizeDevice({ path: devicePath, label: name, serial: '' }))
76
88
  }
77
89
  }
78
90
 
@@ -3,9 +3,9 @@ import os from 'node:os'
3
3
  import path from 'node:path'
4
4
 
5
5
  import { knownBoards } from './board-catalog.mjs'
6
+ import { boardConfigWritePath } from './boards/config.mjs'
6
7
  import { configureChipSelection, loadChipCatalog, validateGpioAssignments } from './chips.mjs'
7
8
  import { flag, option } from './args.mjs'
8
- import { createChildEnv } from './context.mjs'
9
9
  import { ExitCode, fail } from './errors.mjs'
10
10
  import { exists, readJson, writeJson } from './fs-utils.mjs'
11
11
  import { ask, choose, confirm, createPrompt } from './prompts.mjs'
@@ -374,18 +374,14 @@ async function maybeInitializeBoardTarget(ctx, parsed, io, boardSetup) {
374
374
  io.stdout(`Board initialization skipped. Later: npx gea setup --board ${boardSetup.alias}`)
375
375
  return 0
376
376
  }
377
- if (!exists(ctx.scripts.board)) {
378
- io.stdout(`Board backend not found. Later: npx gea setup --board ${boardSetup.alias}`)
377
+ if (!ctx.targetsRoot) {
378
+ io.stdout(`@geastack/targets is not installed. Later: npx gea setup --board ${boardSetup.alias}`)
379
379
  return 0
380
380
  }
381
381
  io.stdout(`Initializing board target '${boardSetup.alias}'...`)
382
- return runExternal(ctx.scripts.board, ['setup', `--board=${boardSetup.alias}`], {
383
- cwd: ctx.targetsRoot,
384
- env: createChildEnv(ctx, io.env || process.env),
385
- dryRun: flag(parsed, 'dry-run'),
386
- failureCode: ExitCode.buildFailed,
387
- stdout: io.stdout
388
- })
382
+ const { buildCommand } = await import('./commands/board.mjs')
383
+ const setupParsed = { ...parsed, options: { ...parsed.options, board: boardSetup.alias, 'configure-only': true } }
384
+ return buildCommand(ctx, setupParsed, [], io)
389
385
  }
390
386
 
391
387
  async function maybeSetupEspIdf(ctx, parsed, io, prompt, { force = false } = {}) {
@@ -468,10 +464,13 @@ async function selectUsbSerial(prompt, io, { message }) {
468
464
  })
469
465
  }
470
466
 
467
+ // --global writes the alias to ~/.geastack/boards.json, --local to the
468
+ // project's .gea/boards.json; otherwise the project config when it exists,
469
+ // else the home one (src/boards/config.mjs owns that rule).
471
470
  function boardConfigPath(ctx, parsed) {
472
- const explicit = option(parsed, 'boards-config') || ctx.boardsConfig
471
+ const explicit = option(parsed, 'boards-config')
473
472
  if (explicit) return path.resolve(ctx.cwd, explicit)
474
- return ctx.projectBoardsConfig || path.join(ctx.cwd, '.gea', 'boards.json')
473
+ return boardConfigWritePath(ctx, { scope: flag(parsed, 'global') ? 'global' : flag(parsed, 'local') ? 'project' : '' })
475
474
  }
476
475
 
477
476
  function readBoardConfig(filePath) {
@@ -0,0 +1,52 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { pathToFileURL } from 'node:url'
5
+
6
+ import { resolveUsbSerialPort } from '../boards/usb.mjs'
7
+ import { CliError, ExitCode, fail } from '../errors.mjs'
8
+ import { formatCommand } from '../run.mjs'
9
+
10
+ // The one per-target hook: a target directory may ship a board.mjs whose
11
+ // `commandFor({ action, app, port, serial, pedalRoot })` names the external
12
+ // command to run. taurus-s3 uses it to defer to the taurus-pedal firmware
13
+ // tree, which lives in its own repository.
14
+
15
+ export function pedalRootCandidates(ctx, env) {
16
+ if (env.TAURUS_PEDAL_ROOT) return [path.resolve(env.TAURUS_PEDAL_ROOT)]
17
+ const targetsRoot = ctx.targetsRoot || ctx.projectRoot
18
+ return [path.resolve(targetsRoot, '../taurus-pedal'), path.resolve(targetsRoot, '../../coyotiv/taurus-pedal')]
19
+ }
20
+
21
+ export async function runTargetHook({ ctx, selection, action, app = null, env, dryRun = false, stdout }) {
22
+ const hook = path.join(selection.targetDir, 'board.mjs')
23
+ if (!existsSync(hook)) fail(`Target '${selection.target}' has no board.mjs hook at ${hook}.`, ExitCode.missingDependency)
24
+ const module = await import(pathToFileURL(hook).href)
25
+ if (typeof module.commandFor !== 'function') fail(`${hook} does not export commandFor().`, ExitCode.missingDependency)
26
+ const candidates = pedalRootCandidates(ctx, env)
27
+ const pedalRoot = candidates.find((candidate) => existsSync(path.join(candidate, 'esp32-s3-a2-full/flash-s3.sh'))) || ''
28
+ let port = selection.port
29
+ if (!port && selection.usbSerial && action === 'flash' && !dryRun) {
30
+ try {
31
+ port = resolveUsbSerialPort({ serial: selection.usbSerial })
32
+ } catch {
33
+ port = ''
34
+ }
35
+ }
36
+ let command
37
+ try {
38
+ command = module.commandFor({ action, app: app?.id || '', port, serial: selection.usbSerial, pedalRoot: pedalRoot || candidates[0] })
39
+ } catch (error) {
40
+ fail(error.message, ExitCode.usage)
41
+ }
42
+ if (!command) return 0
43
+ if (dryRun) {
44
+ stdout(formatCommand([command.executable, ...command.args]))
45
+ return 0
46
+ }
47
+ if (!pedalRoot) fail('Set TAURUS_PEDAL_ROOT to the taurus-pedal checkout; firmware sources remain in that repository.', ExitCode.missingDependency)
48
+ const result = spawnSync(command.executable, command.args, { env, stdio: 'inherit' })
49
+ if (result.error) throw result.error
50
+ if (result.status !== 0) throw new CliError(`ERROR: Command failed (${result.status ?? 1}): ${formatCommand([command.executable, ...command.args])}`, ExitCode.deployFailed)
51
+ return 0
52
+ }