@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
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
#!/usr/bin/env swift
|
|
2
|
+
|
|
3
|
+
import CoreBluetooth
|
|
4
|
+
import Darwin
|
|
5
|
+
import Foundation
|
|
6
|
+
|
|
7
|
+
// `gea` runs this helper with stdout connected to a pipe. Disable stdio
|
|
8
|
+
// buffering so discovery, transport selection, and percentage updates remain
|
|
9
|
+
// visible during a long first update from older firmware.
|
|
10
|
+
setbuf(stdout, nil)
|
|
11
|
+
|
|
12
|
+
private let otaService = CBUUID(string: "7F2E1001-6D8F-4A4F-A0E9-5B8892140001")
|
|
13
|
+
private let otaControl = CBUUID(string: "7F2E1001-6D8F-4A4F-A0E9-5B8892140002")
|
|
14
|
+
private let otaData = CBUUID(string: "7F2E1001-6D8F-4A4F-A0E9-5B8892140003")
|
|
15
|
+
|
|
16
|
+
private func u32le(_ value: UInt32) -> [UInt8] {
|
|
17
|
+
[
|
|
18
|
+
UInt8(truncatingIfNeeded: value),
|
|
19
|
+
UInt8(truncatingIfNeeded: value >> 8),
|
|
20
|
+
UInt8(truncatingIfNeeded: value >> 16),
|
|
21
|
+
UInt8(truncatingIfNeeded: value >> 24),
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private func readU32le(_ bytes: [UInt8], _ offset: Int) -> UInt32 {
|
|
26
|
+
UInt32(bytes[offset])
|
|
27
|
+
| (UInt32(bytes[offset + 1]) << 8)
|
|
28
|
+
| (UInt32(bytes[offset + 2]) << 16)
|
|
29
|
+
| (UInt32(bytes[offset + 3]) << 24)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private func controlPacket(opcode: UInt8, imageSize: UInt32? = nil) -> Data {
|
|
33
|
+
var bytes = [opcode]
|
|
34
|
+
if let imageSize { bytes.append(contentsOf: u32le(imageSize)) }
|
|
35
|
+
return Data(bytes)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private final class BleOtaUpdater: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
|
|
39
|
+
private let image: Data
|
|
40
|
+
private let deviceName: String
|
|
41
|
+
private let statusOnly: Bool
|
|
42
|
+
private var central: CBCentralManager!
|
|
43
|
+
private var peripheral: CBPeripheral?
|
|
44
|
+
private var controlCharacteristic: CBCharacteristic?
|
|
45
|
+
private var dataCharacteristic: CBCharacteristic?
|
|
46
|
+
private var offset = 0
|
|
47
|
+
private var lastPrintedPercent = -1
|
|
48
|
+
private var sentFinish = false
|
|
49
|
+
private var finished = false
|
|
50
|
+
private var timeout: Timer?
|
|
51
|
+
private var transferStartedAt: Date?
|
|
52
|
+
|
|
53
|
+
init(image: Data, deviceName: String, statusOnly: Bool = false) {
|
|
54
|
+
self.image = image
|
|
55
|
+
self.deviceName = deviceName
|
|
56
|
+
self.statusOnly = statusOnly
|
|
57
|
+
super.init()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func start() {
|
|
61
|
+
print("Looking for \(deviceName)…")
|
|
62
|
+
central = CBCentralManager(delegate: self, queue: .main)
|
|
63
|
+
// Give discovery, transfer, flash validation, and reboot a bounded
|
|
64
|
+
// window. Data always uses CoreBluetooth's no-response flow control.
|
|
65
|
+
let timeoutSeconds = statusOnly ? 30 : max(300, Double(image.count) / 2048)
|
|
66
|
+
timeout = Timer.scheduledTimer(withTimeInterval: timeoutSeconds, repeats: false) { [weak self] _ in
|
|
67
|
+
self?.fail("Timed out waiting for the BLE update to complete.")
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
|
72
|
+
guard central.state == .poweredOn else {
|
|
73
|
+
if central.state == .unsupported || central.state == .unauthorized || central.state == .poweredOff {
|
|
74
|
+
fail("Bluetooth is unavailable (state \(central.state.rawValue)).")
|
|
75
|
+
}
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
// Bonded BLE peripherals may already be connected by macOS (for example
|
|
79
|
+
// because the firmware exposes a battery service). Connected peripherals
|
|
80
|
+
// no longer produce scan results, so claim an existing OTA connection
|
|
81
|
+
// before starting discovery.
|
|
82
|
+
if let connected = central.retrieveConnectedPeripherals(withServices: [otaService]).first {
|
|
83
|
+
connect(connected, advertisedName: connected.name)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
central.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private func connect(_ peripheral: CBPeripheral, advertisedName: String?) {
|
|
90
|
+
central.stopScan()
|
|
91
|
+
self.peripheral = peripheral
|
|
92
|
+
peripheral.delegate = self
|
|
93
|
+
print("Connecting to \(advertisedName ?? peripheral.name ?? deviceName)…")
|
|
94
|
+
central.connect(peripheral)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
|
|
98
|
+
advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
|
99
|
+
let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
|
|
100
|
+
let advertisedServices = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
|
|
101
|
+
let nameMatches = peripheral.name?.localizedCaseInsensitiveContains(deviceName) == true
|
|
102
|
+
|| advertisedName?.localizedCaseInsensitiveContains(deviceName) == true
|
|
103
|
+
guard nameMatches || advertisedServices.contains(otaService) else { return }
|
|
104
|
+
|
|
105
|
+
connect(peripheral, advertisedName: advertisedName)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral,
|
|
109
|
+
error: Error?) {
|
|
110
|
+
fail("Could not connect: \(error?.localizedDescription ?? "unknown error")")
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral,
|
|
114
|
+
error: Error?) {
|
|
115
|
+
if !finished { fail("The board disconnected before the update completed: \(error?.localizedDescription ?? "no reason reported")") }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
|
119
|
+
print("Connected. Discovering the OTA service…")
|
|
120
|
+
peripheral.discoverServices([otaService])
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
|
124
|
+
if let error { return fail("Service discovery failed: \(error.localizedDescription)") }
|
|
125
|
+
guard let service = peripheral.services?.first(where: { $0.uuid == otaService }) else {
|
|
126
|
+
return fail("The board does not expose the Geastack OTA service. Flash a BLE-OTA-enabled app over USB first.")
|
|
127
|
+
}
|
|
128
|
+
peripheral.discoverCharacteristics([otaControl, otaData], for: service)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService,
|
|
132
|
+
error: Error?) {
|
|
133
|
+
if let error { return fail("Characteristic discovery failed: \(error.localizedDescription)") }
|
|
134
|
+
for characteristic in service.characteristics ?? [] {
|
|
135
|
+
if characteristic.uuid == otaControl { controlCharacteristic = characteristic }
|
|
136
|
+
if characteristic.uuid == otaData { dataCharacteristic = characteristic }
|
|
137
|
+
}
|
|
138
|
+
guard let controlCharacteristic, dataCharacteristic != nil else {
|
|
139
|
+
return fail("The OTA service is incomplete on this firmware.")
|
|
140
|
+
}
|
|
141
|
+
peripheral.setNotifyValue(true, for: controlCharacteristic)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic,
|
|
145
|
+
error: Error?) {
|
|
146
|
+
if let error { return fail("Could not subscribe to OTA status: \(error.localizedDescription)") }
|
|
147
|
+
guard characteristic.uuid == otaControl, characteristic.isNotifying else { return }
|
|
148
|
+
if statusOnly {
|
|
149
|
+
peripheral.writeValue(controlPacket(opcode: 0x05), for: characteristic, type: .withResponse)
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
guard image.count <= Int(UInt32.max) else { return fail("Firmware image is too large for the OTA protocol.") }
|
|
153
|
+
print("Starting encrypted transfer of \(image.count) bytes…")
|
|
154
|
+
transferStartedAt = Date()
|
|
155
|
+
peripheral.writeValue(controlPacket(opcode: 0x01, imageSize: UInt32(image.count)),
|
|
156
|
+
for: characteristic, type: .withResponse)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic,
|
|
160
|
+
error: Error?) {
|
|
161
|
+
if let error { return fail("OTA status read failed: \(error.localizedDescription)") }
|
|
162
|
+
guard characteristic.uuid == otaControl, let value = characteristic.value else { return }
|
|
163
|
+
let bytes = [UInt8](value)
|
|
164
|
+
guard bytes.count == 10 || bytes.count == 18 else { return fail("The board returned a malformed OTA status packet.") }
|
|
165
|
+
let opcode = bytes[0]
|
|
166
|
+
if opcode == 0x85 {
|
|
167
|
+
let error = Int32(bitPattern: readU32le(bytes, 2))
|
|
168
|
+
finished = true
|
|
169
|
+
timeout?.invalidate()
|
|
170
|
+
var details = "Wi-Fi BLE status: state=\(bytes[1]) error=\(error) enabled=\(bytes[6]) connected=\(bytes[7])"
|
|
171
|
+
if bytes.count == 18 && bytes[9] == 0xa5 {
|
|
172
|
+
let otaError = Int32(bitPattern: readU32le(bytes, 10))
|
|
173
|
+
details += " ota-started=\(bytes[8]) ota-error=\(otaError) live-dma-free=\(readU32le(bytes, 14))"
|
|
174
|
+
} else if bytes.count == 18 {
|
|
175
|
+
details += " dma-free=\(readU32le(bytes, 10)) dma-largest=\(readU32le(bytes, 14))"
|
|
176
|
+
}
|
|
177
|
+
print(details)
|
|
178
|
+
exit(EXIT_SUCCESS)
|
|
179
|
+
}
|
|
180
|
+
let result = bytes[1]
|
|
181
|
+
let received = readU32le(bytes, 2)
|
|
182
|
+
let expected = readU32le(bytes, 6)
|
|
183
|
+
guard result == 0 else {
|
|
184
|
+
return fail("The board rejected OTA command 0x\(String(opcode & 0x7f, radix: 16)) (result \(result), \(received)/\(expected) bytes).")
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if opcode == 0x81 {
|
|
188
|
+
sendNextChunk(peripheral)
|
|
189
|
+
} else if opcode == 0x82 {
|
|
190
|
+
guard received == 0 && expected == 0 else {
|
|
191
|
+
return fail("The board completed OTA with unexpected residual state \(received)/\(expected).")
|
|
192
|
+
}
|
|
193
|
+
finished = true
|
|
194
|
+
timeout?.invalidate()
|
|
195
|
+
let elapsed = max(0.001, Date().timeIntervalSince(transferStartedAt ?? Date()))
|
|
196
|
+
let kibPerSecond = Double(image.count) / elapsed / 1024
|
|
197
|
+
let mibPerSecond = kibPerSecond / 1024
|
|
198
|
+
print(String(format: "BLE OTA complete in %.2fs (%.3f MiB/s, %.1f KiB/s). The board is rebooting into the new firmware.", elapsed, mibPerSecond, kibPerSecond))
|
|
199
|
+
exit(EXIT_SUCCESS)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic,
|
|
204
|
+
error: Error?) {
|
|
205
|
+
if let error { return fail("BLE write failed: \(error.localizedDescription)") }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
|
|
209
|
+
sendNextChunk(peripheral)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private func sendNextChunk(_ peripheral: CBPeripheral) {
|
|
213
|
+
guard let dataCharacteristic, let controlCharacteristic else { return }
|
|
214
|
+
if offset == image.count {
|
|
215
|
+
guard !sentFinish else { return }
|
|
216
|
+
sentFinish = true
|
|
217
|
+
print("\nValidating firmware…")
|
|
218
|
+
peripheral.writeValue(controlPacket(opcode: 0x02), for: controlCharacteristic, type: .withResponse)
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
// OTA has its own BEGIN/FINISH control acknowledgements. Sending DATA as
|
|
222
|
+
// ATT write commands is correct even when a bonded CoreBluetooth cache
|
|
223
|
+
// still reports the flags from older firmware; waiting for an ATT write
|
|
224
|
+
// response per chunk makes a full image take many minutes.
|
|
225
|
+
let writeType: CBCharacteristicWriteType = .withoutResponse
|
|
226
|
+
let maximum = min(512, peripheral.maximumWriteValueLength(for: writeType))
|
|
227
|
+
guard maximum > 0 else { return fail("CoreBluetooth reported an invalid write size.") }
|
|
228
|
+
if offset == 0 {
|
|
229
|
+
print("BLE OTA data: write without response, \(maximum)-byte chunks")
|
|
230
|
+
}
|
|
231
|
+
while offset < image.count && peripheral.canSendWriteWithoutResponse {
|
|
232
|
+
let length = min(maximum, image.count - offset)
|
|
233
|
+
let chunk = image.subdata(in: offset..<(offset + length))
|
|
234
|
+
peripheral.writeValue(chunk, for: dataCharacteristic, type: writeType)
|
|
235
|
+
offset += length
|
|
236
|
+
printProgress()
|
|
237
|
+
}
|
|
238
|
+
if offset == image.count { sendNextChunk(peripheral) }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private func printProgress() {
|
|
242
|
+
let percent = image.isEmpty ? 100 : (offset * 100 / image.count)
|
|
243
|
+
guard percent != lastPrintedPercent else { return }
|
|
244
|
+
lastPrintedPercent = percent
|
|
245
|
+
print("Transferred \(offset)/\(image.count) bytes (\(percent)%)")
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private func fail(_ message: String) {
|
|
249
|
+
guard !finished else { return }
|
|
250
|
+
finished = true
|
|
251
|
+
timeout?.invalidate()
|
|
252
|
+
if let peripheral, let controlCharacteristic {
|
|
253
|
+
peripheral.writeValue(controlPacket(opcode: 0x03), for: controlCharacteristic, type: .withResponse)
|
|
254
|
+
}
|
|
255
|
+
fputs("ERROR: \(message)\n", stderr)
|
|
256
|
+
exit(EXIT_FAILURE)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if CommandLine.arguments.dropFirst().first == "--self-test" {
|
|
261
|
+
precondition([UInt8](controlPacket(opcode: 0x01, imageSize: 0x78563412)) == [1, 0x12, 0x34, 0x56, 0x78])
|
|
262
|
+
precondition(readU32le([0, 0, 0x12, 0x34, 0x56, 0x78], 2) == 0x78563412)
|
|
263
|
+
print("BLE OTA protocol self-test passed")
|
|
264
|
+
exit(EXIT_SUCCESS)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private let updater: BleOtaUpdater = {
|
|
268
|
+
let arguments = Array(CommandLine.arguments.dropFirst())
|
|
269
|
+
if arguments.first == "--wifi-status" {
|
|
270
|
+
guard arguments.count <= 2 else {
|
|
271
|
+
fputs("Usage: ble-ota.swift --wifi-status [device name]\n", stderr)
|
|
272
|
+
exit(EXIT_FAILURE)
|
|
273
|
+
}
|
|
274
|
+
return BleOtaUpdater(
|
|
275
|
+
image: Data(),
|
|
276
|
+
deviceName: arguments.count == 2 ? arguments[1] : "Geastack OTA",
|
|
277
|
+
statusOnly: true)
|
|
278
|
+
}
|
|
279
|
+
guard arguments.count == 1 || arguments.count == 2 else {
|
|
280
|
+
fputs("Usage: ble-ota.swift <firmware.bin> [device name]\n", stderr)
|
|
281
|
+
exit(EXIT_FAILURE)
|
|
282
|
+
}
|
|
283
|
+
let imagePath = arguments[0]
|
|
284
|
+
guard let image = FileManager.default.contents(atPath: imagePath), !image.isEmpty else {
|
|
285
|
+
fputs("ERROR: Could not read firmware image at \(imagePath)\n", stderr)
|
|
286
|
+
exit(EXIT_FAILURE)
|
|
287
|
+
}
|
|
288
|
+
return BleOtaUpdater(image: image, deviceName: arguments.count == 2 ? arguments[1] : "Geastack OTA")
|
|
289
|
+
}()
|
|
290
|
+
updater.start()
|
|
291
|
+
RunLoop.main.run()
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import os from 'node:os'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { ExitCode, fail } from '../errors.mjs'
|
|
5
|
+
import { exists, readJson, writeJson } from '../fs-utils.mjs'
|
|
6
|
+
|
|
7
|
+
// Board aliases are machine-local configuration: which physical board answers
|
|
8
|
+
// to `--board amoled`, its USB serial, its IP. They live in two tiers that
|
|
9
|
+
// are merged, project over home:
|
|
10
|
+
//
|
|
11
|
+
// ~/.geastack/boards.json every board on this machine (GEA_HOME overrides
|
|
12
|
+
// the directory)
|
|
13
|
+
// <project>/.gea/boards.json aliases specific to one project, overriding a
|
|
14
|
+
// home alias of the same name
|
|
15
|
+
//
|
|
16
|
+
// An explicit --boards-config (or GEA_BOARDS_CONFIG) replaces both tiers: a
|
|
17
|
+
// caller naming a file wants exactly that file. Nothing is ever read from an
|
|
18
|
+
// installed package -- a board catalog shipped in @geastack/targets was a
|
|
19
|
+
// development convenience that described one developer's bench, and every
|
|
20
|
+
// npm install would have to overwrite it.
|
|
21
|
+
|
|
22
|
+
export function homeBoardsConfigPath(env = process.env) {
|
|
23
|
+
const home = env.GEA_HOME || path.join(env.HOME || env.USERPROFILE || os.homedir(), '.geastack')
|
|
24
|
+
return path.join(home, 'boards.json')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Every file that contributes aliases, lowest precedence first. The list is
|
|
28
|
+
// the same whether or not the files exist so writers can target a tier that
|
|
29
|
+
// has not been created yet.
|
|
30
|
+
export function boardConfigTiers(ctx) {
|
|
31
|
+
if (ctx.boardsConfig) return [{ scope: 'explicit', file: ctx.boardsConfig }]
|
|
32
|
+
const tiers = []
|
|
33
|
+
if (ctx.homeBoardsConfig) tiers.push({ scope: 'home', file: ctx.homeBoardsConfig })
|
|
34
|
+
if (ctx.projectBoardsConfig) tiers.push({ scope: 'project', file: ctx.projectBoardsConfig })
|
|
35
|
+
return tiers
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The single path older callers print or check: the explicit file, else the
|
|
39
|
+
// highest-precedence tier that exists, else where `gea boards add` would
|
|
40
|
+
// write (project when the project already has a config, else home).
|
|
41
|
+
export function boardConfigPath(ctx) {
|
|
42
|
+
const tiers = boardConfigTiers(ctx)
|
|
43
|
+
const existing = [...tiers].reverse().find((tier) => exists(tier.file))
|
|
44
|
+
return existing ? existing.file : boardConfigWritePath(ctx)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Where a write goes when the caller does not say: `--global` / `--local`
|
|
48
|
+
// pick a tier, an alias that already exists is edited in place, a new alias
|
|
49
|
+
// joins the project config if the project has one and the home config
|
|
50
|
+
// otherwise.
|
|
51
|
+
export function boardConfigWritePath(ctx, { scope = '', alias = '' } = {}) {
|
|
52
|
+
const tiers = boardConfigTiers(ctx)
|
|
53
|
+
if (ctx.boardsConfig) return ctx.boardsConfig
|
|
54
|
+
if (scope === 'global' || scope === 'home') return ctx.homeBoardsConfig
|
|
55
|
+
if (scope === 'project') return ctx.projectBoardsConfig
|
|
56
|
+
if (scope) fail(`Unknown board config scope '${scope}'. Expected --global or --local.`, ExitCode.usage)
|
|
57
|
+
if (alias) {
|
|
58
|
+
const origin = boardConfigOrigins(ctx).get(alias)
|
|
59
|
+
if (origin) return origin
|
|
60
|
+
}
|
|
61
|
+
const project = tiers.find((tier) => tier.scope === 'project')
|
|
62
|
+
if (project && exists(project.file)) return project.file
|
|
63
|
+
return ctx.homeBoardsConfig || project?.file || path.join(ctx.cwd || process.cwd(), '.gea', 'boards.json')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readTier(file) {
|
|
67
|
+
if (!exists(file)) return {}
|
|
68
|
+
try {
|
|
69
|
+
return normalizeBoardConfig(readJson(file))
|
|
70
|
+
} catch (error) {
|
|
71
|
+
fail(`Could not read board config ${file}: ${error.message}`, ExitCode.usage)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Merged aliases plus, for each, the file it came from: a `targetDefinition`
|
|
76
|
+
// is relative to its own file, and `gea boards list` says which tier an alias
|
|
77
|
+
// lives in.
|
|
78
|
+
export function loadBoardConfigWithOrigins(ctx) {
|
|
79
|
+
const boards = {}
|
|
80
|
+
const origins = new Map()
|
|
81
|
+
for (const tier of boardConfigTiers(ctx)) {
|
|
82
|
+
for (const [alias, board] of Object.entries(readTier(tier.file))) {
|
|
83
|
+
boards[alias] = board
|
|
84
|
+
origins.set(alias, tier.file)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { boards, origins }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function loadBoardConfig(ctx) {
|
|
91
|
+
return loadBoardConfigWithOrigins(ctx).boards
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function boardConfigOrigins(ctx) {
|
|
95
|
+
return loadBoardConfigWithOrigins(ctx).origins
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function readBoardConfigFile(file) {
|
|
99
|
+
return readTier(file)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function writeBoardConfigFile(file, boards) {
|
|
103
|
+
writeJson(file, boards)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function normalizeBoardConfig(raw) {
|
|
107
|
+
if (!raw || typeof raw !== 'object') return {}
|
|
108
|
+
if (raw.boards && typeof raw.boards === 'object') return raw.boards
|
|
109
|
+
const out = {}
|
|
110
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
111
|
+
if (!key.startsWith('$') && value && typeof value === 'object') out[key] = value
|
|
112
|
+
}
|
|
113
|
+
return out
|
|
114
|
+
}
|