@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.
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
+ }
@@ -5,7 +5,6 @@ import path from 'node:path'
5
5
  import { knownBoards } from './board-catalog.mjs'
6
6
  import { configureChipSelection, loadChipCatalog, validateGpioAssignments } from './chips.mjs'
7
7
  import { flag, option } from './args.mjs'
8
- import { createChildEnv } from './context.mjs'
9
8
  import { ExitCode, fail } from './errors.mjs'
10
9
  import { exists, readJson, writeJson } from './fs-utils.mjs'
11
10
  import { ask, choose, confirm, createPrompt } from './prompts.mjs'
@@ -374,18 +373,15 @@ async function maybeInitializeBoardTarget(ctx, parsed, io, boardSetup) {
374
373
  io.stdout(`Board initialization skipped. Later: npx gea setup --board ${boardSetup.alias}`)
375
374
  return 0
376
375
  }
377
- if (!exists(ctx.scripts.board)) {
378
- io.stdout(`Board backend not found. Later: npx gea setup --board ${boardSetup.alias}`)
376
+ if (!ctx.targetsRoot) {
377
+ io.stdout(`@geastack/targets is not installed. Later: npx gea setup --board ${boardSetup.alias}`)
379
378
  return 0
380
379
  }
381
380
  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
- })
381
+ const { buildCommand } = await import('./commands/board.mjs')
382
+ const setupParsed = { ...parsed, options: { ...parsed.options, board: boardSetup.alias, 'configure-only': true } }
383
+ const reloaded = { ...ctx, boardsConfig: boardConfigPath(ctx, parsed) }
384
+ return buildCommand(reloaded, setupParsed, [], io)
389
385
  }
390
386
 
391
387
  async function maybeSetupEspIdf(ctx, parsed, io, prompt, { force = false } = {}) {
@@ -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
+ }