@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.
- package/README.md +14 -1
- package/docs/ESP32-WAVESHARE-AMOLED-QUICKSTART.md +2 -2
- package/docs/NPX-COMMANDS.md +3 -1
- package/docs/SETUP.md +81 -15
- package/docs/SPEC.md +8 -5
- package/package.json +2 -3
- package/src/apps/app-index-writer.mjs +15 -0
- package/src/apps/apple-icons.mjs +147 -0
- package/src/apps/bundle-writer.mjs +156 -0
- package/src/apps/launcher-catalog.mjs +114 -0
- package/src/apps/openai-icons.mjs +356 -0
- package/src/apps/zip-writer.mjs +104 -0
- package/src/ble/ble-ota.swift +291 -0
- package/src/boards/config.mjs +114 -0
- package/src/boards/custom-target.mjs +309 -0
- package/src/boards/resolve.mjs +145 -0
- package/src/boards/targets.mjs +45 -0
- package/src/boards/usb.mjs +252 -0
- package/src/chips.mjs +1 -1
- package/src/commands/apps.mjs +204 -0
- package/src/commands/board.mjs +402 -0
- package/src/commands/boards.mjs +334 -0
- package/src/commands/doctor.mjs +91 -0
- package/src/context.mjs +50 -54
- package/src/device/device.mjs +96 -0
- package/src/device/image.mjs +115 -0
- package/src/device/serial.mjs +430 -0
- package/src/device/wifi.mjs +190 -0
- package/src/esp32/build.mjs +321 -0
- package/src/esp32/capabilities.mjs +54 -0
- package/src/esp32/flash.mjs +181 -0
- package/src/esp32/idf-env.mjs +171 -0
- package/src/esp32/ota.mjs +80 -0
- package/src/esp32/partitions.mjs +59 -0
- package/src/esp32/sdkconfig.mjs +103 -0
- package/src/esp32/wifi-config.mjs +72 -0
- package/src/gea.mjs +89 -427
- package/src/geaos/adapter.mjs +119 -0
- package/src/heap-report.mjs +1276 -0
- package/src/manifest.mjs +135 -36
- package/src/rp2350/adapter.mjs +155 -0
- package/src/serial-devices.mjs +32 -20
- package/src/setup-wizard.mjs +11 -12
- 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
|
-
|
|
13
|
-
|
|
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
|
|
44
|
-
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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 (
|
|
99
|
-
const targets =
|
|
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 =
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
return
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
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
|
|
159
|
-
name:
|
|
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
|
|
164
|
-
runtime: gea.runtime
|
|
165
|
-
targets: gea.targets
|
|
166
|
-
icons: gea.icons
|
|
167
|
-
|
|
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
|
+
}
|
package/src/serial-devices.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/^
|
|
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
|
|
package/src/setup-wizard.mjs
CHANGED
|
@@ -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 (!
|
|
378
|
-
io.stdout(
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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')
|
|
471
|
+
const explicit = option(parsed, 'boards-config')
|
|
473
472
|
if (explicit) return path.resolve(ctx.cwd, explicit)
|
|
474
|
-
return ctx
|
|
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
|
+
}
|