@geastack/cli 0.1.9 → 0.1.10

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 CHANGED
@@ -73,20 +73,13 @@ flowchart TD
73
73
  ota --> reviewKnown["Review board setup"]
74
74
  reviewKnown --> writeKnown["Write .gea/boards.json"]
75
75
 
76
- interactive -->|"Custom board profile"| custom["Collect hardware profile"]
77
- custom --> core["Alias, MCU, closest base target"]
78
- core --> depth{"Detail level?"}
79
- depth -->|"Full"| chips["Display, touch,<br/>WiFi/BLE, GPS, audio"]
80
- depth -->|"Fast"| fast["Display, touch,<br/>default peripherals"]
81
- chips --> peripherals["Storage, sensors, power,<br/>USB serial or OTA"]
82
- fast --> connection["USB serial or OTA"]
83
- peripherals --> connection
84
- connection --> notes["Notes and datasheet links"]
85
- notes --> reviewCustom["Review custom profile"]
86
- reviewCustom --> writeProfile["Write .gea/boards/alias.json"]
87
- writeProfile --> maybeAlias{"Base target selected?"}
88
- maybeAlias -->|"Yes"| writeCustomAlias["Write alias to .gea/boards.json"]
89
- maybeAlias -->|"No"| profileOnly["Profile only, not flash-ready yet"]
76
+ interactive -->|"Custom board target"| custom["Choose MCU and compatible chips"]
77
+ custom --> chipConfig["Ask interface and pin questions<br/>from @geastack/chips/catalog.json"]
78
+ chipConfig --> features["Optional microSD and launcher button"]
79
+ features --> connection["Detect initial USB connection"]
80
+ connection --> reviewCustom["Review native composition"]
81
+ reviewCustom --> writeProfile["Write .gea/targets/alias.json"]
82
+ writeProfile --> writeCustomAlias["Write alias to .gea/boards.json"]
90
83
 
91
84
  interactive -->|"npm dependencies only"| npmInstall["Run npm install when package.json exists"]
92
85
  interactive -->|"ESP-IDF toolchain only"| idfOnly["Install or check ESP-IDF v6.0.1"]
@@ -94,7 +87,6 @@ flowchart TD
94
87
  writeKnown --> initialize["Initialize board target"]
95
88
  writeCustomAlias --> initialize
96
89
  initialize --> ready["Ready: npx gea flash --board alias --monitor"]
97
- profileOnly --> done["Done"]
98
90
  npmInstall --> done
99
91
  idfOnly --> done
100
92
  directIdf --> done
@@ -107,6 +99,19 @@ host bindings, and the other native packages from npm. A project can use a
107
99
  local CLI with `npx gea` or a global installation with `gea`; neither command
108
100
  depends on a GeaStack source checkout.
109
101
 
102
+ Custom boards remain editable after setup:
103
+
104
+ ```sh
105
+ gea chips list
106
+ gea chips info co5300
107
+ gea chips add co5300 ft3168 --board my-board
108
+ gea chips remove ft3168 --board my-board
109
+ ```
110
+
111
+ These commands update the app-local target definition. They do not copy native
112
+ sources into the application; the target adapter compiles the selected drivers
113
+ directly from the installed `@geastack/chips` package.
114
+
110
115
  ## Development
111
116
 
112
117
  ```sh
@@ -23,7 +23,7 @@ Use these release packages instead:
23
23
  the `create-geastack` bin.
24
24
  - `@geastack/cli`: scoped private Gea CLI package exposing the `gea` bin.
25
25
 
26
- `create-geastack` should add `@geastack/cli` as a devDependency in generated
26
+ `create-geastack` should add `@geastack/cli` as a dependency in generated
27
27
  projects and install dependencies by default in interactive terminals. After
28
28
  that, inside the project, `npx gea ...` works because npm finds the local
29
29
  `node_modules/.bin/gea` binary. It does not require an npm package named `gea`.
@@ -31,7 +31,7 @@ that, inside the project, `npx gea ...` works because npm finds the local
31
31
  If someone is wiring an existing app by hand, install the package first:
32
32
 
33
33
  ```sh
34
- npm install --save-dev @geastack/cli
34
+ npm install @geastack/cli
35
35
  npx gea setup
36
36
  ```
37
37
 
@@ -89,7 +89,7 @@ packages are scoped and configured for restricted npmjs publication.
89
89
  `gea setup` with no `--board` or `--target` opens a guided setup flow:
90
90
 
91
91
  - known supported board, with descriptions for each board;
92
- - custom board profile, with fast and full hardware paths;
92
+ - custom board target composed from the installed chip catalog;
93
93
  - npm dependency check/install only;
94
94
  - ESP-IDF toolchain check/install only.
95
95
 
@@ -102,38 +102,35 @@ initializes the selected board target so the next command can be
102
102
  .gea/boards.json
103
103
  ```
104
104
 
105
- Custom-board setup can run in two depths:
106
-
107
- - `Full hardware profile`: display, touch, WiFi/BLE, GPS, audio, storage,
108
- sensors, power, transport, and notes.
109
- - `Fast profile`: core board identity, display/touch, transport, and inferred
110
- defaults for optional peripherals.
111
-
112
- Both paths show a review screen before writing a profile under:
105
+ Custom-board setup reads `@geastack/chips/catalog.json`, offers only drivers
106
+ with a compatible platform binding, and asks the configuration questions
107
+ declared by each selected driver. It shows a review before writing the target:
113
108
 
114
109
  ```text
115
- .gea/boards/<alias>.json
110
+ .gea/targets/<alias>.json
116
111
  ```
117
112
 
118
- The full profile captures:
113
+ The target captures:
119
114
 
120
115
  - MCU / SoC;
121
- - closest existing base target;
122
- - display type, controller, interface, resolution;
123
- - touch controller and interface;
124
- - WiFi and BLE;
125
- - GPS module and interface;
126
- - audio codec, input, and output;
127
- - storage;
128
- - sensors;
129
- - power path;
130
- - USB serial and OTA transports;
131
- - notes/datasheet links.
132
-
133
- If the user selects a base target, the wizard also writes an experimental board
134
- alias that points at that base target and references the custom profile. If no
135
- base target is selected, the profile is generated without claiming the board is
136
- flash-ready.
116
+ - the generic platform base and adapter;
117
+ - display controller, interface, dimensions, bus, and pins;
118
+ - touch controller, bus, reset, and interrupt pins;
119
+ - power, IMU, and audio drivers;
120
+ - shared buses and audio pins;
121
+ - optional storage and launcher-button pins;
122
+ - the initial USB serial connection.
123
+
124
+ The wizard also writes `.gea/boards.json`, where the physical board alias points
125
+ to that definition. A partial composition is saved but is not described as
126
+ flash-ready. It can be completed later with:
127
+
128
+ ```sh
129
+ npx gea chips list
130
+ npx gea chips info co5300
131
+ npx gea chips add co5300 --board my-board
132
+ npx gea chips remove co5300 --board my-board
133
+ ```
137
134
 
138
135
  ESP-IDF setup is available as:
139
136
 
package/docs/SPEC.md CHANGED
@@ -87,6 +87,23 @@ The CLI should pass through board aliases from the active board config. A projec
87
87
  uses its own `.gea/boards.json`; otherwise the CLI reads the board catalog shipped
88
88
  by the installed `@geastack/targets` package.
89
89
 
90
+ ### `gea chips`
91
+
92
+ Inspects the installed `@geastack/chips` catalog and composes a custom board
93
+ definition without copying native driver source into the app.
94
+
95
+ ```sh
96
+ gea chips list
97
+ gea chips info co5300
98
+ gea chips add co5300 ft3168 --board my-board
99
+ gea chips remove ft3168 --board my-board
100
+ ```
101
+
102
+ `add` asks only the interface and pin questions declared by each chip. Automation
103
+ can answer them with repeated `--set chip.path=value` options. The command rejects
104
+ chips without a binding for the board adapter and MCU before changing the target
105
+ definition.
106
+
90
107
  ### `gea monitor`
91
108
 
92
109
  Starts a log monitor for a configured board or target.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geastack/cli",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "type": "module",
5
5
  "description": "Command-line front door for GeaStack apps, targets, and local toolchains.",
6
6
  "publishConfig": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@geastack/core": "0.1.4",
31
- "@geastack/targets": "0.1.4"
31
+ "@geastack/targets": "0.1.5"
32
32
  },
33
33
  "scripts": {
34
34
  "check": "node --check bin/gea.mjs && node --check bin/create-geastack.mjs && find src test packages -name '*.mjs' -print0 | xargs -0 -n1 node --check",
package/src/chips.mjs ADDED
@@ -0,0 +1,295 @@
1
+ import path from 'node:path'
2
+
3
+ import { flag, option } from './args.mjs'
4
+ import { ExitCode, fail } from './errors.mjs'
5
+ import { exists, readJson, writeJson } from './fs-utils.mjs'
6
+ import { ask, choose, createPrompt } from './prompts.mjs'
7
+
8
+ export async function runChips(ctx, parsed, rest, io = {}) {
9
+ const action = rest[0] || 'list'
10
+ const chipIds = rest.slice(1)
11
+ const stdout = io.stdout || console.log
12
+ const catalog = loadChipCatalog(ctx)
13
+
14
+ if (action === 'list') {
15
+ const entries = Object.entries(catalog).sort((a, b) => a[0].localeCompare(b[0]))
16
+ if (flag(parsed, 'json')) stdout(JSON.stringify(Object.fromEntries(entries), null, 2))
17
+ else {
18
+ for (const [id, chip] of entries) {
19
+ const adapters = Object.keys(chip.adapters || {})
20
+ stdout(`${id}\t${chip.category}\t${chip.label}${adapters.length ? `\t${adapters.join(',')}` : '\tunbound'}`)
21
+ }
22
+ }
23
+ return 0
24
+ }
25
+
26
+ if (action === 'info') {
27
+ if (chipIds.length !== 1) fail('chips info requires one chip id.', ExitCode.usage)
28
+ const chip = catalog[chipIds[0]]
29
+ if (!chip) failUnknownChip(chipIds[0], catalog)
30
+ if (flag(parsed, 'json')) stdout(JSON.stringify({ id: chipIds[0], ...chip }, null, 2))
31
+ else {
32
+ stdout(`${chipIds[0]}: ${chip.label}`)
33
+ stdout(`Category: ${chip.category}`)
34
+ stdout(`Interfaces: ${(chip.interfaces || []).join(', ') || 'none'}`)
35
+ stdout(`Adapters: ${Object.keys(chip.adapters || {}).join(', ') || 'no platform binding yet'}`)
36
+ for (const field of chip.configuration || []) stdout(`Configure: ${field.path} (${field.label})`)
37
+ }
38
+ return 0
39
+ }
40
+
41
+ if (action !== 'add' && action !== 'remove') {
42
+ fail(`Unknown chips action '${action}'. Expected list, info, add, or remove.`, ExitCode.usage)
43
+ }
44
+ if (chipIds.length === 0) fail(`chips ${action} requires at least one chip id.`, ExitCode.usage)
45
+
46
+ const location = resolveTargetDefinition(ctx, parsed)
47
+ const definition = readJson(location.definitionPath)
48
+ definition.chips ||= {}
49
+
50
+ if (action === 'remove') {
51
+ for (const id of chipIds) {
52
+ const role = Object.keys(definition.chips).find((key) => selectedDriver(definition.chips[key]) === id)
53
+ if (!role) fail(`Chip '${id}' is not selected by board '${location.boardName}'.`, ExitCode.usage)
54
+ delete definition.chips[role]
55
+ stdout(`Removed ${id} from ${location.boardName}.`)
56
+ }
57
+ writeJson(location.definitionPath, definition)
58
+ return 0
59
+ }
60
+
61
+ const prompt = createPrompt(io)
62
+ try {
63
+ const provided = parseSetOptions(parsed)
64
+ for (const id of chipIds) {
65
+ const descriptor = catalog[id]
66
+ if (!descriptor) failUnknownChip(id, catalog)
67
+ const role = await configureChipSelection({
68
+ definition,
69
+ id,
70
+ descriptor,
71
+ provided,
72
+ prompt,
73
+ io,
74
+ replace: flag(parsed, 'replace'),
75
+ boardName: location.boardName
76
+ })
77
+ stdout(`Added ${id} as ${role} on ${location.boardName}.`)
78
+ }
79
+ if (provided.size > 0) {
80
+ fail(`Unused --set values: ${[...provided.keys()].join(', ')}`, ExitCode.usage)
81
+ }
82
+ validateGpioAssignments(definition)
83
+ writeJson(location.definitionPath, definition)
84
+ stdout(`Updated ${location.definitionPath}`)
85
+ return 0
86
+ } finally {
87
+ await prompt.close()
88
+ }
89
+ }
90
+
91
+ export async function configureChipSelection({ definition, id, descriptor, provided = new Map(), prompt, io = {}, replace = false, boardName = definition.id }) {
92
+ definition.chips ||= {}
93
+ const adapter = definition.adapter || 'esp32-idf'
94
+ const binding = descriptor.adapters?.[adapter]
95
+ if (!binding) fail(`Chip '${id}' has no ${adapter} binding. 'gea chips info ${id}' shows its current support.`, ExitCode.targetUnavailable)
96
+ if (Array.isArray(binding.mcus) && !binding.mcus.includes(definition.mcu)) {
97
+ fail(`Chip '${id}' does not support MCU '${definition.mcu}' through ${adapter}.`, ExitCode.targetUnavailable)
98
+ }
99
+
100
+ const role = descriptor.category
101
+ const previous = definition.chips[role]
102
+ if (previous && selectedDriver(previous) !== id && !replace) {
103
+ fail(`Board '${boardName}' already uses '${selectedDriver(previous)}' as ${role}. Pass --replace to change it.`, ExitCode.usage)
104
+ }
105
+ const selection = previous && selectedDriver(previous) === id ? structuredClone(previous) : { driver: id }
106
+ selection.driver = id
107
+ delete selection.controller
108
+ selection.interface = await configuredInterface({ id, descriptor, selection, provided, prompt, io })
109
+ if (selection.interface === 'i2c') {
110
+ definition.buses ||= {}
111
+ definition.buses.i2c ||= {}
112
+ await configureFields({
113
+ id: 'i2c',
114
+ target: definition.buses.i2c,
115
+ fields: [
116
+ { path: 'sda', label: 'Shared I2C SDA pin', type: 'pin' },
117
+ { path: 'scl', label: 'Shared I2C SCL pin', type: 'pin' }
118
+ ],
119
+ provided,
120
+ prompt,
121
+ io
122
+ })
123
+ }
124
+ await configureFields({ id, target: selection, fields: descriptor.configuration || [], provided, prompt, io })
125
+ definition.chips[role] = selection
126
+ return role
127
+ }
128
+
129
+ export function loadChipCatalog(ctx) {
130
+ const catalogPath = path.join(ctx.chipsPackageDir || '', 'catalog.json')
131
+ if (!ctx.chipsPackageDir || !exists(catalogPath)) {
132
+ fail(`Missing @geastack/chips catalog: ${catalogPath || '@geastack/chips/catalog.json'}`, ExitCode.missingDependency)
133
+ }
134
+ const catalog = readJson(catalogPath)
135
+ if (catalog.schemaVersion !== 1 || !catalog.chips || typeof catalog.chips !== 'object') {
136
+ fail(`Unsupported @geastack/chips catalog schema in ${catalogPath}.`, ExitCode.missingDependency)
137
+ }
138
+ return catalog.chips
139
+ }
140
+
141
+ export function resolveTargetDefinition(ctx, parsed) {
142
+ const configPath = option(parsed, 'boards-config')
143
+ ? path.resolve(ctx.cwd, option(parsed, 'boards-config'))
144
+ : ctx.projectBoardsConfig
145
+ if (!configPath || !exists(configPath)) fail('No project .gea/boards.json was found. Run gea setup and create a custom board first.', ExitCode.usage)
146
+ const boards = readJson(configPath)
147
+ const requested = option(parsed, 'board', '')
148
+ const customBoards = Object.entries(boards).filter(([, entry]) => typeof entry?.targetDefinition === 'string' && entry.targetDefinition)
149
+ const boardName = requested || (customBoards.length === 1 ? customBoards[0][0] : '')
150
+ if (!boardName) fail('Select a custom board with --board <alias>.', ExitCode.usage)
151
+ const board = boards[boardName]
152
+ if (!board) fail(`Unknown board '${boardName}' in ${configPath}.`, ExitCode.usage)
153
+ if (!board.targetDefinition) fail(`Board '${boardName}' is a preset and has no editable target definition.`, ExitCode.usage)
154
+ const definitionPath = path.resolve(path.dirname(configPath), board.targetDefinition)
155
+ if (!exists(definitionPath)) fail(`Target definition for '${boardName}' does not exist: ${definitionPath}`, ExitCode.usage)
156
+ return { boardName, board, configPath, definitionPath }
157
+ }
158
+
159
+ export function validateGpioAssignments(definition) {
160
+ const pins = []
161
+ collectGpioAssignments(definition, [], false, pins)
162
+ const used = new Map()
163
+ for (const [label, pin] of pins) {
164
+ const previous = used.get(pin)
165
+ if (previous) fail(`GPIO ${pin} is assigned to both ${previous} and ${label}.`, ExitCode.usage)
166
+ used.set(pin, label)
167
+ }
168
+ }
169
+
170
+ function collectGpioAssignments(value, pathParts, pinMap, out) {
171
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return
172
+ for (const [key, entry] of Object.entries(value)) {
173
+ const nextPath = [...pathParts, key]
174
+ const nextPinMap = pinMap || key === 'pins' || pathParts[0] === 'buses'
175
+ if (typeof entry === 'number' && entry >= 0 && (nextPinMap || key === 'pin')) {
176
+ out.push([nextPath.join('.'), entry])
177
+ } else if (entry && typeof entry === 'object') {
178
+ collectGpioAssignments(entry, nextPath, nextPinMap, out)
179
+ }
180
+ }
181
+ }
182
+
183
+ async function configuredInterface({ id, descriptor, selection, provided, prompt, io }) {
184
+ const interfaces = Array.isArray(descriptor.interfaces) ? descriptor.interfaces : []
185
+ const supplied = provided.get(`${id}.interface`)
186
+ if (supplied !== undefined) {
187
+ provided.delete(`${id}.interface`)
188
+ if (!interfaces.includes(supplied)) fail(`Chip '${id}' does not support interface '${supplied}'.`, ExitCode.usage)
189
+ return supplied
190
+ }
191
+ if (selection.interface && interfaces.includes(selection.interface)) return selection.interface
192
+ if (interfaces.length === 1) return interfaces[0]
193
+ if (!canAnswer(prompt, io)) fail(`Set ${id}.interface with --set ${id}.interface=<value>.`, ExitCode.usage)
194
+ return choose(prompt, {
195
+ message: `${descriptor.label} interface`,
196
+ choices: interfaces.map((value) => ({ value, label: value.toUpperCase() })),
197
+ defaultValue: interfaces[0]
198
+ })
199
+ }
200
+
201
+ async function configureFields({ id, target, fields, provided, prompt, io }) {
202
+ for (const field of fields) {
203
+ const supplied = provided.get(`${id}.${field.path}`)
204
+ if (supplied !== undefined) {
205
+ provided.delete(`${id}.${field.path}`)
206
+ const error = validateFieldValue(supplied, field)
207
+ if (error) fail(`Invalid ${id}.${field.path}: ${error}.`, ExitCode.usage)
208
+ setPath(target, field.path, parseFieldValue(supplied, field))
209
+ continue
210
+ }
211
+ const current = getPath(target, field.path)
212
+ if (current !== undefined && current !== null) continue
213
+ if (!canAnswer(prompt, io)) {
214
+ if (field.default !== undefined) {
215
+ setPath(target, field.path, field.default)
216
+ continue
217
+ }
218
+ if (field.optional) {
219
+ setPath(target, field.path, null)
220
+ continue
221
+ }
222
+ fail(`Missing ${id}.${field.path}. Pass --set ${id}.${field.path}=<value> or run interactively.`, ExitCode.usage)
223
+ }
224
+ if (field.type === 'choice') {
225
+ const value = await choose(prompt, {
226
+ message: field.label,
227
+ choices: field.values.map((entry) => ({ value: entry, label: entry })),
228
+ defaultValue: field.default || field.values[0]
229
+ })
230
+ setPath(target, field.path, value)
231
+ continue
232
+ }
233
+ const value = await ask(prompt, {
234
+ message: field.optional ? `${field.label} (blank for none)` : field.label,
235
+ defaultValue: field.default === undefined ? '' : String(field.default),
236
+ validate: (entry) => validateFieldValue(entry, field)
237
+ })
238
+ setPath(target, field.path, field.optional && value === '' ? null : parseFieldValue(value, field))
239
+ }
240
+ }
241
+
242
+ function parseSetOptions(parsed) {
243
+ const raw = parsed.options.set
244
+ const entries = raw === undefined ? [] : Array.isArray(raw) ? raw : [raw]
245
+ const out = new Map()
246
+ for (const entry of entries) {
247
+ const index = String(entry).indexOf('=')
248
+ if (index <= 0) fail(`Invalid --set '${entry}'. Expected --set chip.path=value.`, ExitCode.usage)
249
+ out.set(String(entry).slice(0, index), String(entry).slice(index + 1))
250
+ }
251
+ return out
252
+ }
253
+
254
+ function parseFieldValue(value, field) {
255
+ if (field.optional && (value === '' || value === 'none' || value === 'null')) return null
256
+ if (field.type === 'integer' || field.type === 'pin') return Number.parseInt(value, 10)
257
+ return String(value)
258
+ }
259
+
260
+ function validateFieldValue(value, field) {
261
+ if (field.optional && value === '') return ''
262
+ if (field.type === 'choice') return field.values.includes(value) ? '' : `choose one of ${field.values.join(', ')}`
263
+ if (field.type !== 'integer' && field.type !== 'pin') return ''
264
+ if (!/^-?\d+$/.test(String(value))) return 'enter an integer'
265
+ const number = Number(value)
266
+ const min = field.type === 'pin' ? 0 : field.min
267
+ const max = field.type === 'pin' ? 48 : field.max
268
+ if (min !== undefined && number < min) return `minimum is ${min}`
269
+ if (max !== undefined && number > max) return `maximum is ${max}`
270
+ return ''
271
+ }
272
+
273
+ function getPath(value, dottedPath) {
274
+ return dottedPath.split('.').reduce((current, part) => current?.[part], value)
275
+ }
276
+
277
+ function setPath(value, dottedPath, next) {
278
+ const parts = dottedPath.split('.')
279
+ let current = value
280
+ for (const part of parts.slice(0, -1)) current = current[part] ||= {}
281
+ current[parts.at(-1)] = next
282
+ }
283
+
284
+ function selectedDriver(selection) {
285
+ return String(selection?.driver || selection?.controller || '')
286
+ }
287
+
288
+ function failUnknownChip(id, catalog) {
289
+ const available = Object.keys(catalog).sort().join(', ')
290
+ fail(`Unknown chip '${id}'. Available chips: ${available}`, ExitCode.usage)
291
+ }
292
+
293
+ function canAnswer(prompt, io) {
294
+ return Boolean(io.prompt || io.stdin?.isTTY || (!io.stdin && process.stdin.isTTY))
295
+ }
package/src/context.mjs CHANGED
@@ -13,7 +13,7 @@ export function createContext(parsed, _env = process.env, cwd = process.cwd()) {
13
13
  const absoluteCwd = path.resolve(cwd)
14
14
  const projectRoot = findNodeProjectRoot(absoluteCwd) || absoluteCwd
15
15
  const initialAnchors = [projectRoot, cliPackageRoot]
16
- const targetsRoot = resolveInstalledPackageDir('@geastack/targets', initialAnchors)
16
+ const targetsRoot = _env.GEA_TARGETS_ROOT || resolveInstalledPackageDir('@geastack/targets', initialAnchors)
17
17
  const corePackageDir = resolveInstalledPackageDir('@geastack/core', initialAnchors)
18
18
  const packageAnchors = [projectRoot, cliPackageRoot, targetsRoot, corePackageDir].filter(Boolean)
19
19
  const compilerPackageDir = resolveInstalledPackageDir('@geastack/compiler', packageAnchors)
@@ -28,7 +28,7 @@ export function createContext(parsed, _env = process.env, cwd = process.cwd()) {
28
28
  cliPackageRoot,
29
29
  compilerPackageDir,
30
30
  corePackageDir,
31
- chipsPackageDir: resolveInstalledPackageDir('@geastack/chips', packageAnchors),
31
+ chipsPackageDir: _env.GEA_CHIPS_DIR || resolveInstalledPackageDir('@geastack/chips', packageAnchors),
32
32
  elementsPackageDir: resolveInstalledPackageDir('@geastack/elements', packageAnchors),
33
33
  enginePackageDir: resolveInstalledPackageDir('@geastack/engine', packageAnchors),
34
34
  geaosPackageDir: resolveInstalledPackageDir('@geastack/geaos', packageAnchors),
@@ -308,11 +308,11 @@ function packageJson({ appId, displayName, targets, entry, coreDependency, cliDe
308
308
  dependencies: {
309
309
  ...(sourcePackage.dependencies || {}),
310
310
  '@geajs/core': sourcePackage.dependencies?.['@geajs/core'] || '^1.3.0',
311
- '@geastack/core': coreDependency
311
+ '@geastack/core': coreDependency,
312
+ '@geastack/cli': cliDependency
312
313
  },
313
314
  devDependencies: {
314
315
  ...(sourcePackage.devDependencies || {}),
315
- '@geastack/cli': cliDependency,
316
316
  typescript: sourcePackage.devDependencies?.typescript || 'latest',
317
317
  ...(targets.web ? { vite: sourcePackage.devDependencies?.vite || 'latest' } : {})
318
318
  },
package/src/gea.mjs CHANGED
@@ -3,6 +3,7 @@ import fs from 'node:fs'
3
3
 
4
4
  import { flag, option, parseArgs } from './args.mjs'
5
5
  import { runCreateGeastack } from './create-geastack.mjs'
6
+ import { runChips } from './chips.mjs'
6
7
  import { createChildEnv, createContext } from './context.mjs'
7
8
  import { ExitCode, fail } from './errors.mjs'
8
9
  import { exists, readJson } from './fs-utils.mjs'
@@ -59,6 +60,8 @@ export async function runGea(argv, io = {}) {
59
60
  return build(ctx, parsed, rest, { stdout, env })
60
61
  case 'setup':
61
62
  return setup(ctx, parsed, { stdout, env, stdin, output, prompt })
63
+ case 'chips':
64
+ return runChips(ctx, parsed, rest, { stdout, env, stdin, output, prompt })
62
65
  case 'flash':
63
66
  return flash(ctx, parsed, rest, { stdout, env })
64
67
  case 'monitor':
@@ -432,6 +435,10 @@ function usage() {
432
435
  gea create <name> [--starter counter|empty|example] [--targets <list>]
433
436
  gea doctor [--strict] [--json]
434
437
  gea setup --board <alias>
438
+ gea chips list [--json]
439
+ gea chips info <chip> [--json]
440
+ gea chips add <chip...> [--board <alias>] [--set chip.path=value]
441
+ gea chips remove <chip...> [--board <alias>]
435
442
  gea dev [app] [--target web] [--port 5181]
436
443
  gea build [app] [--target web|macos|ios|android|<target>] [--board <alias>] [--output-tag <tag>]
437
444
  gea flash [app] --board <alias> [--monitor] [--port auto]
@@ -3,6 +3,7 @@ import os from 'node:os'
3
3
  import path from 'node:path'
4
4
 
5
5
  import { knownBoards } from './board-catalog.mjs'
6
+ import { configureChipSelection, loadChipCatalog, validateGpioAssignments } from './chips.mjs'
6
7
  import { flag, option } from './args.mjs'
7
8
  import { createChildEnv } from './context.mjs'
8
9
  import { ExitCode, fail } from './errors.mjs'
@@ -143,7 +144,7 @@ async function setupKnownBoard(ctx, parsed, io, prompt) {
143
144
  }
144
145
 
145
146
  async function setupCustomBoard(ctx, parsed, io, prompt) {
146
- renderStep(io, 'Board', ['Name the profile and pick the closest target backend.'])
147
+ renderStep(io, 'Board', ['Create an app-local hardware target from the installed chip catalog.'])
147
148
  const alias = await ask(prompt, {
148
149
  message: 'Custom board alias',
149
150
  defaultValue: 'custom-board',
@@ -151,230 +152,119 @@ async function setupCustomBoard(ctx, parsed, io, prompt) {
151
152
  })
152
153
  const mcu = await choose(prompt, {
153
154
  message: 'MCU / SoC',
154
- choices: [
155
- { value: 'esp32-s3', label: 'ESP32-S3' },
156
- { value: 'esp32-p4', label: 'ESP32-P4' },
157
- { value: 'esp32-c6', label: 'ESP32-C6' },
158
- { value: 'esp32', label: 'ESP32' },
159
- { value: 'other', label: 'Other / not listed' }
160
- ],
161
- defaultValue: 'esp32-s3'
162
- })
163
- const mcuName = mcu === 'other' ? await ask(prompt, { message: 'MCU / SoC name', defaultValue: '' }) : mcu
164
- const baseTarget = await choose(prompt, {
165
- message: 'Closest existing target to start from',
166
- choices: [
167
- { value: '', label: 'None yet, generate profile only' },
168
- ...knownBoards.map((board) => ({ value: board.target, label: `${board.label} (${board.target})` }))
169
- ],
170
- defaultValue: ''
171
- })
172
- const depth = await choose(prompt, {
173
- message: 'How much hardware detail do you want to enter?',
174
155
  choices: [
175
156
  {
176
- value: 'full',
177
- label: 'Full hardware profile',
178
- description: 'Display, touch, wireless, GPS, audio, storage, sensors, power, and transport.'
179
- },
180
- {
181
- value: 'quick',
182
- label: 'Fast profile',
183
- description: 'Core board, display/touch, transport, and sensible defaults for everything else.'
157
+ value: 'esp32s3',
158
+ label: 'ESP32-S3',
159
+ description: 'Composable ESP-IDF target with display, touch, power, IMU, audio, WiFi, BLE, and OTA support.'
184
160
  }
185
161
  ],
186
- defaultValue: 'full'
162
+ defaultValue: 'esp32s3'
187
163
  })
188
164
 
189
- renderStep(io, 'Display and touch', ['Describe what the user can see and touch on the board.'])
190
- const displayKind = await choose(prompt, {
191
- message: 'Display type',
192
- choices: [
193
- { value: 'amoled', label: 'AMOLED' },
194
- { value: 'tft-lcd', label: 'TFT LCD' },
195
- { value: 'epaper', label: 'E-paper' },
196
- { value: 'monochrome-oled', label: 'Monochrome OLED' },
197
- { value: 'none', label: 'No display' },
198
- { value: 'other', label: 'Other' }
199
- ],
200
- defaultValue: 'tft-lcd'
201
- })
202
- const displayController = displayKind === 'none' ? '' : await ask(prompt, { message: 'Display controller/chip', defaultValue: '' })
203
- const displayInterface = displayKind === 'none' ? '' : await choose(prompt, {
204
- message: 'Display interface',
205
- choices: [
206
- { value: 'spi', label: 'SPI' },
207
- { value: 'qspi', label: 'QSPI' },
208
- { value: 'rgb', label: 'RGB parallel' },
209
- { value: 'i8080', label: '8080 parallel' },
210
- { value: 'mipi-dsi', label: 'MIPI DSI' },
211
- { value: 'i2c', label: 'I2C' },
212
- { value: 'other', label: 'Other' }
213
- ],
214
- defaultValue: 'spi'
215
- })
216
- const resolution = displayKind === 'none' ? '' : await ask(prompt, { message: 'Display resolution, for example 480x480', defaultValue: '' })
217
- const touchController = await ask(prompt, { message: 'Touch controller/chip (blank for none)', defaultValue: '' })
218
- const touchInterface = touchController ? await choose(prompt, {
219
- message: 'Touch interface',
220
- choices: [
221
- { value: 'i2c', label: 'I2C' },
222
- { value: 'spi', label: 'SPI' },
223
- { value: 'gpio', label: 'GPIO buttons/interrupts' },
224
- { value: 'other', label: 'Other' }
225
- ],
226
- defaultValue: 'i2c'
227
- }) : ''
228
-
229
- const defaults = defaultCustomPeripherals(mcuName)
230
- let wifi = defaults.wifi
231
- let ble = defaults.ble
232
- let gpsModule = ''
233
- let gpsInterface = ''
234
- let audioCodec = ''
235
- let audioOutput = ''
236
- let audioInput = ''
237
- let storage = defaults.storage
238
- let sensors = ''
239
- let power = 'USB'
240
-
241
- if (depth === 'full') {
242
- renderStep(io, 'Peripherals', ['Add wireless, location, audio, storage, sensors, and power details.'])
243
- wifi = await choose(prompt, {
244
- message: 'WiFi',
165
+ const definition = {
166
+ id: alias,
167
+ extends: 'esp32-s3',
168
+ adapter: 'esp32-idf',
169
+ mcu,
170
+ buses: {},
171
+ chips: {},
172
+ storage: {
173
+ microSD: {
174
+ interface: 'sdmmc-1bit',
175
+ pins: { clk: null, cmd: null, data0: null }
176
+ }
177
+ },
178
+ controls: {
179
+ launcherButton: { pin: null, activeLevel: 0 }
180
+ }
181
+ }
182
+
183
+ const catalog = loadChipCatalog(ctx)
184
+ const roles = [
185
+ ['display', 'Display controller'],
186
+ ['touch', 'Touch controller'],
187
+ ['power', 'Power-management controller'],
188
+ ['imu', 'Inertial measurement unit'],
189
+ ['audio', 'Audio codec']
190
+ ]
191
+ renderStep(io, 'Chips', ['Choose each controller from @geastack/chips. Pin questions come from the installed catalog.'])
192
+ for (const [role, label] of roles) {
193
+ const compatible = Object.entries(catalog)
194
+ .filter(([, chip]) => chip.category === role && chip.adapters?.['esp32-idf']?.mcus?.includes(mcu))
195
+ const selected = await choose(prompt, {
196
+ message: label,
245
197
  choices: [
246
- { value: 'built-in', label: 'Built into MCU/module' },
247
- { value: 'external', label: 'External WiFi chip/module' },
248
- { value: 'none', label: 'None' }
198
+ ...compatible.map(([id, chip]) => ({ value: id, label: `${id} ${chip.label}` })),
199
+ { value: '', label: 'None / configure later' }
249
200
  ],
250
- defaultValue: defaults.wifi
201
+ defaultValue: compatible[0]?.[0] || ''
251
202
  })
252
- ble = await choose(prompt, {
253
- message: 'BLE',
254
- choices: [
255
- { value: 'built-in', label: 'Built into MCU/module' },
256
- { value: 'external', label: 'External BLE chip/module' },
257
- { value: 'none', label: 'None' }
258
- ],
259
- defaultValue: defaults.ble
203
+ if (!selected) continue
204
+ await configureChipSelection({
205
+ definition,
206
+ id: selected,
207
+ descriptor: catalog[selected],
208
+ prompt,
209
+ io,
210
+ replace: true,
211
+ boardName: alias
260
212
  })
261
- gpsModule = await ask(prompt, { message: 'GPS module/chip (blank for none)', defaultValue: '' })
262
- gpsInterface = gpsModule ? await choose(prompt, {
263
- message: 'GPS interface',
264
- choices: [
265
- { value: 'uart', label: 'UART' },
266
- { value: 'i2c', label: 'I2C' },
267
- { value: 'spi', label: 'SPI' },
268
- { value: 'other', label: 'Other' }
269
- ],
270
- defaultValue: 'uart'
271
- }) : ''
272
- audioCodec = await ask(prompt, { message: 'Audio codec/chip (blank for none)', defaultValue: '' })
273
- audioOutput = audioCodec ? await choose(prompt, {
274
- message: 'Audio output',
275
- choices: [
276
- { value: 'i2s-speaker', label: 'I2S speaker/output' },
277
- { value: 'dac', label: 'DAC output' },
278
- { value: 'pwm', label: 'PWM/buzzer' },
279
- { value: 'other', label: 'Other' }
280
- ],
281
- defaultValue: 'i2s-speaker'
282
- }) : ''
283
- audioInput = audioCodec ? await choose(prompt, {
284
- message: 'Audio input',
213
+ }
214
+
215
+ renderStep(io, 'Board features', ['Configure physical features that are not separate chip drivers.'])
216
+ if (await confirm(prompt, { message: 'Does this board expose a microSD slot?', defaultValue: false })) {
217
+ definition.storage.microSD.pins.clk = await askPin(prompt, 'microSD clock pin')
218
+ definition.storage.microSD.pins.cmd = await askPin(prompt, 'microSD command pin')
219
+ definition.storage.microSD.pins.data0 = await askPin(prompt, 'microSD data 0 pin')
220
+ }
221
+ if (await confirm(prompt, { message: 'Use a hardware button to return to the launcher?', defaultValue: false })) {
222
+ definition.controls.launcherButton.pin = await askPin(prompt, 'Launcher button pin')
223
+ definition.controls.launcherButton.activeLevel = Number(await choose(prompt, {
224
+ message: 'Launcher button active level',
285
225
  choices: [
286
- { value: 'none', label: 'None' },
287
- { value: 'i2s-mic', label: 'I2S microphone' },
288
- { value: 'pdm-mic', label: 'PDM microphone' },
289
- { value: 'analog-mic', label: 'Analog microphone' },
290
- { value: 'other', label: 'Other' }
226
+ { value: '0', label: 'Active low' },
227
+ { value: '1', label: 'Active high' }
291
228
  ],
292
- defaultValue: 'none'
293
- }) : ''
294
- storage = await ask(prompt, { message: 'Storage chips/features, comma-separated', defaultValue: defaults.storage })
295
- sensors = await ask(prompt, { message: 'Sensors, comma-separated (IMU, light, temp, etc.)', defaultValue: '' })
296
- power = await ask(prompt, { message: 'Power path (USB, battery charger, PMIC, etc.)', defaultValue: 'USB' })
297
- } else {
298
- renderStep(io, 'Peripherals', [
299
- `Using defaults: WiFi ${wifi}, BLE ${ble}, storage ${storage}, power USB.`,
300
- `You can edit .gea/boards/${alias}.json later if the board has GPS, audio, or sensors.`
301
- ])
229
+ defaultValue: '0'
230
+ }))
302
231
  }
303
232
 
304
- renderStep(io, 'Connection', ['Choose how GeaStack should flash and monitor the board.'])
305
- const transport = await choose(prompt, {
306
- message: 'Primary flash/monitor transport',
307
- choices: [
308
- { value: 'usbSerial', label: 'USB serial' },
309
- { value: 'ota', label: 'WiFi OTA' },
310
- { value: 'both', label: 'USB serial + OTA' },
311
- { value: 'custom', label: 'Custom' }
312
- ],
313
- defaultValue: 'usbSerial'
233
+ renderStep(io, 'Connection', ['The first flash uses USB. BLE OTA can take over after the initial firmware is running.'])
234
+ const usbSerial = await selectUsbSerial(prompt, io, {
235
+ message: 'USB serial number (leave blank to auto-detect or pass --port)'
314
236
  })
315
- const usbSerial = transport === 'usbSerial' || transport === 'both'
316
- ? await selectUsbSerial(prompt, io, { message: 'USB serial number (optional)' })
317
- : ''
318
- const otaHost = transport === 'ota' || transport === 'both'
319
- ? await ask(prompt, { message: 'OTA host/IP (optional)', defaultValue: '' })
320
- : ''
321
- const notes = await ask(prompt, { message: 'Notes / links to schematic, display datasheet, etc. (optional)', defaultValue: '' })
322
-
323
- const profile = compactObject({
324
- alias,
325
- kind: 'custom-board-profile',
326
- targetFamily: 'esp32',
237
+
238
+ const configPath = boardConfigPath(ctx, parsed)
239
+ const definitionPath = path.join(path.dirname(configPath), 'targets', `${alias}.json`)
240
+ const relativeDefinition = path.relative(path.dirname(configPath), definitionPath)
241
+ const entry = compactObject({
242
+ target: alias,
243
+ targetDefinition: relativeDefinition,
327
244
  adapter: 'esp32-idf',
328
- baseTarget,
329
- mcu: mcuName,
330
- display: compactObject({
331
- kind: displayKind,
332
- controller: displayController,
333
- interface: displayInterface,
334
- resolution
335
- }),
336
- touch: compactObject({
337
- controller: touchController,
338
- interface: touchInterface
339
- }),
340
- wireless: compactObject({ wifi, ble }),
341
- gps: compactObject({ module: gpsModule, interface: gpsInterface }),
342
- audio: compactObject({ codec: audioCodec, output: audioOutput, input: audioInput }),
343
- storage: csv(storage),
344
- sensors: csv(sensors),
345
- power,
245
+ appPlatform: 'esp32',
346
246
  transports: compactObject({
347
- usbSerial: usbSerial ? { serial: usbSerial } : undefined,
348
- ota: otaHost ? { host: otaHost } : undefined
349
- }),
350
- notes
247
+ usbSerial: usbSerial ? { serial: usbSerial } : undefined
248
+ })
351
249
  })
352
-
353
- const profilePath = path.join(ctx.cwd, '.gea', 'boards', `${alias}.json`)
354
- const configPath = boardConfigPath(ctx, parsed)
355
- renderCustomBoardReview(io, { alias, profile, profilePath, configPath, flashReady: Boolean(baseTarget) })
356
- if (!await shouldSaveSetup(parsed, prompt, 'Save this custom board profile?')) {
250
+ const requiredRoles = roles.map(([role]) => role)
251
+ const missingRoles = requiredRoles.filter((role) => !definition.chips[role])
252
+ validateGpioAssignments(definition)
253
+ renderCustomBoardReview(io, { alias, definition, definitionPath, configPath, entry, missingRoles })
254
+ if (!await shouldSaveSetup(parsed, prompt, 'Save this custom board target?')) {
357
255
  return { alias, cancelled: true }
358
256
  }
359
- writeJsonEnsured(profilePath, profile)
360
- io.stdout(`Wrote custom board profile to ${profilePath}`)
361
-
362
- if (baseTarget) {
363
- const config = readBoardConfig(configPath)
364
- config[alias] = compactObject({
365
- target: baseTarget,
366
- adapter: 'esp32-idf',
367
- customProfile: profilePath,
368
- transports: profile.transports
369
- })
370
- writeJsonEnsured(configPath, config)
371
- io.stdout(`Wrote experimental board alias '${alias}' to ${configPath}`)
372
- return { alias, flashReady: true }
373
- } else {
374
- io.stdout('No board alias was added because no base target was selected.')
375
- io.stdout('Add a target backend before flashing this custom profile.')
376
- return { alias, flashReady: false }
257
+
258
+ writeJsonEnsured(definitionPath, definition)
259
+ const config = readBoardConfig(configPath)
260
+ config[alias] = entry
261
+ writeJsonEnsured(configPath, config)
262
+ io.stdout(`Wrote custom target to ${definitionPath}`)
263
+ io.stdout(`Wrote board alias '${alias}' to ${configPath}`)
264
+ if (missingRoles.length) {
265
+ io.stdout(`Add the remaining roles with: npx gea chips add <chip> --board ${alias}`)
377
266
  }
267
+ return { alias, flashReady: missingRoles.length === 0 }
378
268
  }
379
269
 
380
270
  function renderHeader(io, title, lines = []) {
@@ -405,25 +295,24 @@ function renderKnownBoardReview(io, { alias, board, configPath, entry }) {
405
295
  ])
406
296
  }
407
297
 
408
- function renderCustomBoardReview(io, { alias, profile, profilePath, configPath, flashReady }) {
298
+ function renderCustomBoardReview(io, { alias, definition, definitionPath, configPath, entry, missingRoles }) {
409
299
  renderStep(io, 'Review', [
410
300
  'This is what GeaStack will save.'
411
301
  ])
412
302
  writeRows(io, [
413
303
  ['Alias', alias],
414
- ['MCU / SoC', profile.mcu],
415
- ['Base target', profile.baseTarget || 'none yet'],
416
- ['Display', describeObject(profile.display)],
417
- ['Touch', describeObject(profile.touch)],
418
- ['Wireless', describeObject(profile.wireless)],
419
- ['GPS', describeObject(profile.gps)],
420
- ['Audio', describeObject(profile.audio)],
421
- ['Storage', list(profile.storage)],
422
- ['Sensors', list(profile.sensors)],
423
- ['Power', profile.power || 'not set'],
424
- ['Transport', describeObject(profile.transports)],
425
- ['Profile', profilePath],
426
- ['Board config', flashReady ? configPath : 'not written until a base target is selected']
304
+ ['MCU / SoC', definition.mcu],
305
+ ['Platform base', definition.extends],
306
+ ['Display', describeObject(definition.chips.display)],
307
+ ['Touch', describeObject(definition.chips.touch)],
308
+ ['Power', describeObject(definition.chips.power)],
309
+ ['IMU', describeObject(definition.chips.imu)],
310
+ ['Audio', describeObject(definition.chips.audio)],
311
+ ['I2C', describeObject(definition.buses.i2c)],
312
+ ['USB serial', entry.transports?.usbSerial?.serial || 'auto / pass --port'],
313
+ ['Missing roles', missingRoles.join(', ') || 'none'],
314
+ ['Target definition', definitionPath],
315
+ ['Board config', configPath]
427
316
  ])
428
317
  }
429
318
 
@@ -461,17 +350,6 @@ function describeObject(value) {
461
350
  return entries.length ? entries.join('; ') : 'not set'
462
351
  }
463
352
 
464
- function defaultCustomPeripherals(mcuName) {
465
- const normalized = String(mcuName || '').toLowerCase()
466
- const espWithWireless = ['esp32', 'esp32-s3', 'esp32-c3', 'esp32-c6', 'esp32-h2'].includes(normalized)
467
- const hasPsramByDefault = ['esp32-s3', 'esp32-p4'].includes(normalized)
468
- return {
469
- wifi: espWithWireless && normalized !== 'esp32-h2' ? 'built-in' : 'none',
470
- ble: espWithWireless ? 'built-in' : 'none',
471
- storage: hasPsramByDefault ? 'flash, psram' : 'flash'
472
- }
473
- }
474
-
475
353
  async function maybeInstallNpmDependencies(ctx, parsed, io, prompt, { force = false } = {}) {
476
354
  const install = force || option(parsed, 'install') === true || await confirm(prompt, {
477
355
  message: 'Install npm dependencies now if package.json exists?',
@@ -612,6 +490,18 @@ function validateAlias(value) {
612
490
  return ''
613
491
  }
614
492
 
493
+ async function askPin(prompt, message) {
494
+ return Number(await ask(prompt, {
495
+ message,
496
+ defaultValue: '',
497
+ validate: (value) => {
498
+ if (!/^\d+$/.test(value)) return 'enter a GPIO number from 0 through 48'
499
+ const pin = Number(value)
500
+ return pin >= 0 && pin <= 48 ? '' : 'enter a GPIO number from 0 through 48'
501
+ }
502
+ }))
503
+ }
504
+
615
505
  function compactObject(value) {
616
506
  return Object.fromEntries(Object.entries(value).filter(([, entry]) => {
617
507
  if (entry === undefined || entry === null || entry === '') return false
@@ -620,14 +510,3 @@ function compactObject(value) {
620
510
  return true
621
511
  }))
622
512
  }
623
-
624
- function csv(value) {
625
- return String(value || '')
626
- .split(',')
627
- .map((entry) => entry.trim())
628
- .filter(Boolean)
629
- }
630
-
631
- function list(value) {
632
- return Array.isArray(value) && value.length ? value.join(', ') : 'not set'
633
- }