@kvell007/embed-labs-cli 0.1.0-alpha.5 → 0.1.0-alpha.50

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.
@@ -9,18 +9,27 @@ import { pipeline } from "node:stream/promises";
9
9
  import { fileURLToPath } from "node:url";
10
10
  const DEFAULT_RELEASE_ROOT = "/Volumes/LLVM-TSPI/tspi-rk3566-llvm-release-minimal";
11
11
  const DEFAULT_QT_SMOKE_SOURCE = "/Users/kvell/kk-project/DBT-Agent-Project/llvm-build-tspi/qt-smoke";
12
- const DEFAULT_METADATA_ROOT = "/Users/kvell/kk-project/DBT-Agent-Project/llvm-build-tspi/embedlabs-release";
13
12
  const DEFAULT_BOARD_ID = "taishanpi-1m-rk3566";
13
+ const PICO2W_RP2350_BOARD_ID = "pico2w-rp2350-monitor";
14
+ const COLOREASYPICO2_RP2350_BOARD_ID = "coloreasypico2-rp2350-monitor";
14
15
  const DEFAULT_CHANNEL = "stable";
16
+ const DEFAULT_DOWNLOAD_BASE_URL = "https://download.embedboard.com";
17
+ const DOWNLOAD_REQUEST_TIMEOUT_MS = 12_000;
15
18
  const BUILT_IN_CHANNEL = {
16
19
  schema: "embedlabs.channel.v1",
17
20
  channel: DEFAULT_CHANNEL,
18
21
  packages: [
19
22
  { id: "embedlabs.tools.vendor.rockchip", version: "1.0.0", manifest: "" },
20
23
  { id: "embedlabs.tools.common.llvm", version: "22.1.3", manifest: "" },
24
+ { id: "embedlabs.tools.common.arm-none-eabi", version: "15.2.rel1", manifest: "" },
21
25
  { id: "embedlabs.tools.common.e2fsprogs", version: "1.0.0", manifest: "" },
26
+ { id: "embedlabs.tools.runtime.qtquick-live-preview", version: "1.0.31", manifest: "" },
27
+ { id: "embedlabs.tools.runtime.rp2350-monitor", version: "1.0.31", manifest: "" },
22
28
  { id: "embedlabs.family.rk356x", version: "1.0.0", manifest: "" },
23
- { id: "embedlabs.board.taishanpi.1m-rk3566", version: "1.0.31", manifest: "" }
29
+ { id: "embedlabs.family.rp2350", version: "1.0.0", manifest: "" },
30
+ { id: "embedlabs.board.taishanpi.1m-rk3566", version: "1.0.31", manifest: "" },
31
+ { id: "embedlabs.board.pico2w.rp2350-monitor", version: "1.0.31", manifest: "" },
32
+ { id: "embedlabs.board.coloreasypico2.rp2350-monitor", version: "1.0.31", manifest: "" }
24
33
  ]
25
34
  };
26
35
  const BUILT_IN_MANIFESTS = {
@@ -40,6 +49,14 @@ const BUILT_IN_MANIFESTS = {
40
49
  hosts: ["darwin-arm64", "linux-x86_64"],
41
50
  provides: ["llvm.clang", "llvm.clangxx", "llvm.ld_lld", "llvm.ar", "llvm.objcopy", "llvm.readelf"]
42
51
  },
52
+ "embedlabs.tools.common.arm-none-eabi": {
53
+ schema: "embedlabs.package.v1",
54
+ id: "embedlabs.tools.common.arm-none-eabi",
55
+ version: "15.2.rel1",
56
+ kind: "tools",
57
+ hosts: ["darwin-arm64"],
58
+ provides: ["arm-none-eabi.gcc", "arm-none-eabi.g++", "arm-none-eabi.objcopy", "picotool"]
59
+ },
43
60
  "embedlabs.tools.common.e2fsprogs": {
44
61
  schema: "embedlabs.package.v1",
45
62
  id: "embedlabs.tools.common.e2fsprogs",
@@ -48,6 +65,22 @@ const BUILT_IN_MANIFESTS = {
48
65
  hosts: ["darwin-arm64", "linux-x86_64"],
49
66
  provides: ["ext4.mke2fs", "ext4.resize2fs", "fakeroot"]
50
67
  },
68
+ "embedlabs.tools.runtime.qtquick-live-preview": {
69
+ schema: "embedlabs.package.v1",
70
+ id: "embedlabs.tools.runtime.qtquick-live-preview",
71
+ version: "1.0.31",
72
+ kind: "tools",
73
+ hosts: ["darwin-arm64", "linux-x86_64"],
74
+ provides: ["qtquick.live_preview", "qtquick.live_preview.inspector", "qtquick.live_preview.feedback"]
75
+ },
76
+ "embedlabs.tools.runtime.rp2350-monitor": {
77
+ schema: "embedlabs.package.v1",
78
+ id: "embedlabs.tools.runtime.rp2350-monitor",
79
+ version: "1.0.31",
80
+ kind: "tools",
81
+ hosts: ["darwin-arm64", "linux-x86_64"],
82
+ provides: ["rp2350.monitor.cli", "rp2350.monitor.logic_analyzer", "rp2350.monitor.logic_decode"]
83
+ },
51
84
  "embedlabs.family.rk356x": {
52
85
  schema: "embedlabs.package.v1",
53
86
  id: "embedlabs.family.rk356x",
@@ -60,6 +93,17 @@ const BUILT_IN_MANIFESTS = {
60
93
  { id: "embedlabs.tools.common.e2fsprogs", version: "^1.0.0" }
61
94
  ]
62
95
  },
96
+ "embedlabs.family.rp2350": {
97
+ schema: "embedlabs.package.v1",
98
+ id: "embedlabs.family.rp2350",
99
+ version: "1.0.0",
100
+ kind: "family",
101
+ family: "rp2350",
102
+ requires: [
103
+ { id: "embedlabs.tools.common.arm-none-eabi", version: "15.x", roles: ["compile", "uf2"] },
104
+ { id: "embedlabs.tools.runtime.rp2350-monitor", version: "^1.0.31", roles: ["hardware-control", "logic-analyzer", "debug-probe"] }
105
+ ]
106
+ },
63
107
  "embedlabs.board.taishanpi.1m-rk3566": {
64
108
  schema: "embedlabs.package.v1",
65
109
  id: "embedlabs.board.taishanpi.1m-rk3566",
@@ -72,20 +116,67 @@ const BUILT_IN_MANIFESTS = {
72
116
  { id: "embedlabs.family.rk356x", version: "^1.0.0" },
73
117
  { id: "embedlabs.tools.vendor.rockchip", version: "^1.0.0", roles: ["flash", "resource-image"] },
74
118
  { id: "embedlabs.tools.common.llvm", version: "22.x", roles: ["compile"] },
75
- { id: "embedlabs.tools.common.e2fsprogs", version: "^1.0.0", roles: ["userdata-image"] }
119
+ { id: "embedlabs.tools.common.e2fsprogs", version: "^1.0.0", roles: ["userdata-image"] },
120
+ { id: "embedlabs.tools.runtime.qtquick-live-preview", version: "^1.0.31", roles: ["qtquick-preview"] }
76
121
  ],
77
122
  build_modes: ["local-llvm"]
123
+ },
124
+ "embedlabs.board.pico2w.rp2350-monitor": {
125
+ schema: "embedlabs.package.v1",
126
+ id: "embedlabs.board.pico2w.rp2350-monitor",
127
+ version: "1.0.31",
128
+ kind: "board",
129
+ display_name: "Pico 2 W",
130
+ family: "rp2350",
131
+ board: "Pico 2 W",
132
+ board_id: PICO2W_RP2350_BOARD_ID,
133
+ requires: [
134
+ { id: "embedlabs.family.rp2350", version: "^1.0.0" },
135
+ { id: "embedlabs.tools.common.arm-none-eabi", version: "15.x", roles: ["compile", "uf2", "picotool"] },
136
+ { id: "embedlabs.tools.runtime.rp2350-monitor", version: "^1.0.31", roles: ["hardware-control", "logic-analyzer", "debug-probe"] }
137
+ ],
138
+ build_modes: ["local-pico-sdk", "optional-rp2350-monitor"]
139
+ },
140
+ "embedlabs.board.coloreasypico2.rp2350-monitor": {
141
+ schema: "embedlabs.package.v1",
142
+ id: "embedlabs.board.coloreasypico2.rp2350-monitor",
143
+ version: "1.0.31",
144
+ kind: "board",
145
+ display_name: "ColorEasyPICO2",
146
+ family: "rp2350",
147
+ board: "ColorEasyPICO2",
148
+ board_id: COLOREASYPICO2_RP2350_BOARD_ID,
149
+ requires: [
150
+ { id: "embedlabs.family.rp2350", version: "^1.0.0" },
151
+ { id: "embedlabs.tools.common.arm-none-eabi", version: "15.x", roles: ["compile", "uf2", "picotool"] },
152
+ { id: "embedlabs.tools.runtime.rp2350-monitor", version: "^1.0.31", roles: ["hardware-control", "logic-analyzer", "debug-probe"] }
153
+ ],
154
+ build_modes: ["local-pico-sdk", "optional-rp2350-monitor"]
78
155
  }
79
156
  };
80
157
  const INSTALL_COPY_PATHS = [
81
158
  "toolchain/llvm-cross",
82
- "toolchain/host/aarch64-buildroot-linux-gnu/sysroot",
159
+ "toolchain/host",
160
+ "toolchain/host-tools",
161
+ "toolchain/qt6-rk3566-llvm-toolchain.cmake",
83
162
  "qt-target/qt6-rk3566-llvm-6.8.3",
163
+ "qt-host/qt6-host-macos-6.8.3",
84
164
  "tools/mac",
165
+ "toolkit-runtime/qtquick-live-preview",
166
+ "toolkit-runtime/rp2350-monitor",
167
+ "rp2350-sdk",
168
+ "rp2350-initial-firmware",
169
+ "rp2350-examples",
85
170
  "images/current",
86
171
  "userdata/rootfs",
87
- "boot-workspace/kernel-tree"
172
+ "boot-workspace",
173
+ "README.md",
174
+ "meta",
175
+ "scripts",
176
+ "support",
177
+ "third_party"
88
178
  ];
179
+ const LOCAL_TOOLCHAIN_INSTALL_MODES = ["minimal", "runtime", "compile", "qt", "firmware", "full", "images"];
89
180
  export function defaultLocalReleaseRoot() {
90
181
  return process.env.EMBEDLABS_LOCAL_RELEASE_ROOT?.trim()
91
182
  || process.env.EMBEDLABS_RELEASE_ROOT?.trim()
@@ -100,14 +191,30 @@ export async function latestLocalToolchain(options = {}) {
100
191
  if (!board) {
101
192
  throw new Error(`No local toolchain board package found for ${boardId}.`);
102
193
  }
194
+ const canonicalBoardId = boardIdForPackageManifest(board);
103
195
  const packages = resolvePackageRefs(boardPackageId, channel, manifests);
196
+ let download;
197
+ let downloadError;
198
+ try {
199
+ download = await resolveLocalToolchainDownloadPlan({
200
+ boardId: canonicalBoardId,
201
+ channel: channelName,
202
+ host: hostId(),
203
+ toolchain: "llvm"
204
+ });
205
+ }
206
+ catch (error) {
207
+ downloadError = error instanceof Error ? error.message : String(error);
208
+ }
104
209
  return {
105
- board_id: boardId,
210
+ board_id: canonicalBoardId,
106
211
  channel: channel.channel,
107
212
  host: hostId(),
108
- version: board.version,
213
+ version: download?.version ?? board.version,
109
214
  metadata_root: metadataRoot,
110
- packages
215
+ packages,
216
+ download,
217
+ download_error: downloadError
111
218
  };
112
219
  }
113
220
  export async function currentLocalToolchain(installRoot, boardId = DEFAULT_BOARD_ID) {
@@ -115,11 +222,36 @@ export async function currentLocalToolchain(installRoot, boardId = DEFAULT_BOARD
115
222
  const registryPath = localToolchainRegistryPath(root);
116
223
  try {
117
224
  const registry = JSON.parse(await readFile(registryPath, "utf8"));
225
+ const environments = registry.environments;
226
+ const boardInstall = environments?.[normalizeBoardId(boardId)];
227
+ if (boardInstall?.release_root) {
228
+ return {
229
+ installed: true,
230
+ board_id: typeof boardInstall.board_id === "string" ? boardInstall.board_id : boardId,
231
+ version: typeof boardInstall.version === "string" ? boardInstall.version : undefined,
232
+ mode: typeof boardInstall.mode === "string" ? boardInstall.mode : undefined,
233
+ release_root: boardInstall.release_root,
234
+ registry_path: registryPath,
235
+ install_root: root,
236
+ channel: typeof boardInstall.channel === "string" ? boardInstall.channel : undefined,
237
+ packages: Array.isArray(boardInstall.packages) ? boardInstall.packages : undefined
238
+ };
239
+ }
118
240
  const releaseRoot = typeof registry.release_root === "string" ? registry.release_root : undefined;
241
+ const registryBoardId = typeof registry.board_id === "string" ? registry.board_id : boardId;
242
+ if (releaseRoot && normalizeBoardId(registryBoardId) !== normalizeBoardId(boardId)) {
243
+ return {
244
+ installed: false,
245
+ board_id: boardId,
246
+ registry_path: registryPath,
247
+ install_root: root
248
+ };
249
+ }
119
250
  return {
120
251
  installed: !!releaseRoot,
121
- board_id: typeof registry.board_id === "string" ? registry.board_id : boardId,
252
+ board_id: registryBoardId,
122
253
  version: typeof registry.version === "string" ? registry.version : undefined,
254
+ mode: typeof registry.mode === "string" ? registry.mode : undefined,
123
255
  release_root: releaseRoot,
124
256
  registry_path: registryPath,
125
257
  install_root: root,
@@ -136,48 +268,221 @@ export async function currentLocalToolchain(installRoot, boardId = DEFAULT_BOARD
136
268
  };
137
269
  }
138
270
  }
271
+ async function discoverInstalledLocalToolchains(installRoot, current) {
272
+ const installed = new Map();
273
+ if (current.installed && current.release_root) {
274
+ installed.set(normalizeBoardId(current.board_id), {
275
+ board_id: current.board_id,
276
+ version: current.version,
277
+ channel: current.channel,
278
+ mode: current.mode,
279
+ release_root: current.release_root
280
+ });
281
+ }
282
+ const toolchainsRoot = join(installRoot, "toolchains");
283
+ let boardEntries;
284
+ try {
285
+ boardEntries = await readdir(toolchainsRoot, { withFileTypes: true });
286
+ }
287
+ catch {
288
+ return installed;
289
+ }
290
+ for (const boardEntry of boardEntries) {
291
+ if (!boardEntry.isDirectory()) {
292
+ continue;
293
+ }
294
+ const boardId = normalizeBoardId(boardEntry.name);
295
+ if (installed.has(boardId)) {
296
+ continue;
297
+ }
298
+ const boardRoot = join(toolchainsRoot, boardEntry.name);
299
+ let versionEntries;
300
+ try {
301
+ versionEntries = await readdir(boardRoot, { withFileTypes: true });
302
+ }
303
+ catch {
304
+ continue;
305
+ }
306
+ const versions = versionEntries
307
+ .filter((entry) => entry.isDirectory())
308
+ .map((entry) => entry.name)
309
+ .sort(compareVersionLike)
310
+ .reverse();
311
+ const version = versions[0];
312
+ if (!version) {
313
+ continue;
314
+ }
315
+ installed.set(boardId, {
316
+ board_id: boardId,
317
+ version,
318
+ release_root: join(boardRoot, version)
319
+ });
320
+ }
321
+ return installed;
322
+ }
323
+ export async function listLocalToolchainEnvironments(options = {}) {
324
+ const channelName = options.channel ?? DEFAULT_CHANNEL;
325
+ const host = hostId();
326
+ const installRoot = resolveInstallRoot(options.installRoot);
327
+ const registryPath = localToolchainRegistryPath(installRoot);
328
+ const { channel, manifests, metadataRoot } = await loadLocalToolchainMetadata(options.metadataRoot, channelName);
329
+ const current = await currentLocalToolchain(installRoot);
330
+ const installedByBoard = await discoverInstalledLocalToolchains(installRoot, current);
331
+ const boardManifests = [...manifests.values()]
332
+ .filter((manifest) => manifest.kind === "board")
333
+ .filter((manifest) => {
334
+ if (!options.boardId) {
335
+ return true;
336
+ }
337
+ const normalizedFilter = normalizeBoardId(options.boardId);
338
+ return boardIdForPackageManifest(manifest) === normalizedFilter
339
+ || manifest.id === options.boardId
340
+ || manifest.id === packageIdForBoardFilter(options.boardId);
341
+ })
342
+ .sort((left, right) => boardIdForPackageManifest(left).localeCompare(boardIdForPackageManifest(right)));
343
+ const environments = [];
344
+ for (const board of boardManifests) {
345
+ const boardId = boardIdForPackageManifest(board);
346
+ const packages = resolvePackageRefs(board.id, channel, manifests);
347
+ const hostSupport = packageHostSupport(packages, manifests, host);
348
+ let download;
349
+ let downloadError;
350
+ try {
351
+ download = await resolveLocalToolchainDownloadPlan({
352
+ boardId,
353
+ channel: channelName,
354
+ host,
355
+ toolchain: "llvm"
356
+ });
357
+ }
358
+ catch (error) {
359
+ downloadError = error instanceof Error ? error.message : String(error);
360
+ }
361
+ const latestVersion = download?.version ?? board.version;
362
+ const currentForBoard = await currentLocalToolchain(installRoot, boardId);
363
+ const installedCandidate = currentForBoard.installed
364
+ ? currentForBoard
365
+ : installedByBoard.get(normalizeBoardId(boardId));
366
+ const installed = installedCandidate
367
+ ? {
368
+ version: installedCandidate.version,
369
+ channel: installedCandidate.channel,
370
+ mode: installedCandidate.mode,
371
+ release_root: installedCandidate.release_root
372
+ }
373
+ : undefined;
374
+ const updateAvailable = !!installed?.version && installed.version !== latestVersion;
375
+ const status = !hostSupport.supported
376
+ ? "unsupported_host"
377
+ : updateAvailable
378
+ ? "update_available"
379
+ : installed
380
+ ? "installed"
381
+ : "available";
382
+ const mode = download?.default_mode ?? "qt";
383
+ const installModes = localToolchainInstallModesForDownload(download);
384
+ environments.push({
385
+ board_id: boardId,
386
+ package_id: board.id,
387
+ display_name: board.display_name || [board.board, board.variant].filter(Boolean).join(" ") || boardId,
388
+ family: board.family,
389
+ variant: board.variant,
390
+ channel: channel.channel,
391
+ host,
392
+ status,
393
+ supported_host: hostSupport.supported,
394
+ unsupported_packages: hostSupport.unsupportedPackages,
395
+ install_modes: installModes,
396
+ installed,
397
+ latest: {
398
+ version: latestVersion,
399
+ default_mode: download?.default_mode,
400
+ source_url: download?.source_url,
401
+ manifest_url: download?.manifest_url,
402
+ component_count: download?.components?.length,
403
+ download_error: downloadError
404
+ },
405
+ packages,
406
+ components: download?.components?.map(localToolchainEnvironmentComponent),
407
+ install_command: `embedlabs local toolchain install --board ${boardId} --mode ${mode}`,
408
+ update_command: `embedlabs local toolchain install --board ${boardId} --mode ${mode} --force`,
409
+ notes: environmentNotes({ boardId, status, downloadError, unsupportedPackages: hostSupport.unsupportedPackages })
410
+ });
411
+ }
412
+ const filteredEnvironments = options.installedOnly
413
+ ? environments.filter((environment) => !!environment.installed)
414
+ : environments;
415
+ return {
416
+ host,
417
+ channel: channel.channel,
418
+ metadata_source: metadataRoot ? "local_override" : "built_in",
419
+ metadata_root: metadataRoot,
420
+ install_root: installRoot,
421
+ registry_path: registryPath,
422
+ environments: filteredEnvironments
423
+ };
424
+ }
139
425
  export async function installLocalToolchain(options = {}) {
140
426
  const latest = await latestLocalToolchain(options);
141
427
  const installRoot = resolveInstallRoot(options.installRoot);
142
428
  const releaseRoot = resolve(installRoot, "toolchains", latest.board_id, latest.version);
429
+ const installMode = normalizeLocalToolchainInstallMode(options.mode ?? latest.download?.default_mode);
143
430
  if (await pathExists(releaseRoot) && !options.force) {
144
- const validation = await validateLocalToolchain(releaseRoot);
431
+ await rewriteLocalToolchainPortablePaths(releaseRoot, latest.board_id);
432
+ const validation = await validateLocalToolchain({ releaseRoot, mode: installMode, boardId: latest.board_id });
145
433
  if (!validation.ok) {
146
- throw new Error(`Existing local toolchain is incomplete at ${releaseRoot}; rerun with --force.`);
434
+ if (latest.download?.components?.length) {
435
+ // Component installs can upgrade an existing lower-mode install by overlaying
436
+ // only the newly selected components instead of deleting the whole tree.
437
+ }
438
+ else {
439
+ throw new Error(`Existing local toolchain is incomplete at ${releaseRoot}; rerun with --force.`);
440
+ }
441
+ }
442
+ else {
443
+ await writeCurrentRegistry(installRoot, latest, releaseRoot, installMode);
444
+ return {
445
+ board_id: latest.board_id,
446
+ version: latest.version,
447
+ channel: latest.channel,
448
+ host: latest.host,
449
+ mode: installMode,
450
+ install_root: installRoot,
451
+ release_root: releaseRoot,
452
+ registry_path: localToolchainRegistryPath(installRoot),
453
+ source: { kind: "directory", value: releaseRoot },
454
+ installed_paths: [],
455
+ packages: latest.packages,
456
+ validation
457
+ };
147
458
  }
148
- await writeCurrentRegistry(installRoot, latest, releaseRoot);
149
- return {
150
- board_id: latest.board_id,
151
- version: latest.version,
152
- channel: latest.channel,
153
- host: latest.host,
154
- install_root: installRoot,
155
- release_root: releaseRoot,
156
- registry_path: localToolchainRegistryPath(installRoot),
157
- source: { kind: "directory", value: releaseRoot },
158
- installed_paths: [],
159
- packages: latest.packages,
160
- validation
161
- };
162
459
  }
163
460
  const tempDir = await mkdtemp(join(tmpdir(), "embedlabs-local-toolchain-install-"));
164
461
  try {
165
- const sourceRoot = await sourceReleaseRootForInstall(options, installRoot, tempDir);
166
- await rm(releaseRoot, { recursive: true, force: true });
462
+ const sourceRoot = await sourceReleaseRootForInstall(options, latest, installRoot, tempDir);
463
+ if (options.force || !await pathExists(releaseRoot) || sourceRoot.source.kind !== "components") {
464
+ await rm(releaseRoot, { recursive: true, force: true });
465
+ }
167
466
  await mkdir(releaseRoot, { recursive: true });
168
467
  const installedPaths = [];
169
- for (const relativePath of INSTALL_COPY_PATHS) {
170
- const sourcePath = resolve(sourceRoot.path, relativePath);
468
+ for (const relativePath of installCopyPathsForBoard(latest.board_id)) {
469
+ const sourcePath = await resolveInstallSourcePathForBoard(sourceRoot.path, relativePath, latest.board_id);
171
470
  if (!await pathExists(sourcePath)) {
172
471
  continue;
173
472
  }
174
473
  const targetPath = resolve(releaseRoot, relativePath);
175
474
  await mkdir(dirname(targetPath), { recursive: true });
176
- await cp(sourcePath, targetPath, { recursive: true, force: true, preserveTimestamps: true });
475
+ await cp(sourcePath, targetPath, {
476
+ recursive: true,
477
+ force: true,
478
+ preserveTimestamps: true,
479
+ verbatimSymlinks: true
480
+ });
177
481
  installedPaths.push(relativePath);
178
482
  }
179
- await writeCurrentRegistry(installRoot, latest, releaseRoot);
180
- const validation = await validateLocalToolchain(releaseRoot);
483
+ await rewriteLocalToolchainPortablePaths(releaseRoot, latest.board_id);
484
+ await writeCurrentRegistry(installRoot, latest, releaseRoot, installMode, sourceRoot.source);
485
+ const validation = await validateLocalToolchain({ releaseRoot, mode: installMode, boardId: latest.board_id });
181
486
  if (!validation.ok) {
182
487
  throw new Error(`Installed local toolchain is incomplete: ${validation.missing_paths.join(", ")}`);
183
488
  }
@@ -186,6 +491,7 @@ export async function installLocalToolchain(options = {}) {
186
491
  version: latest.version,
187
492
  channel: latest.channel,
188
493
  host: latest.host,
494
+ mode: installMode,
189
495
  install_root: installRoot,
190
496
  release_root: releaseRoot,
191
497
  registry_path: localToolchainRegistryPath(installRoot),
@@ -199,19 +505,12 @@ export async function installLocalToolchain(options = {}) {
199
505
  await rm(tempDir, { recursive: true, force: true });
200
506
  }
201
507
  }
202
- export async function validateLocalToolchain(releaseRoot) {
203
- const resolvedRoot = await resolveLocalReleaseRoot(releaseRoot);
204
- const required = [
205
- ["release root", "."],
206
- ["C compiler", "toolchain/llvm-cross/bin/aarch64-linux-gnu-gcc"],
207
- ["C++ compiler", "toolchain/llvm-cross/bin/aarch64-linux-gnu-g++"],
208
- ["readelf", "toolchain/llvm-cross/bin/aarch64-linux-gnu-readelf"],
209
- ["Qt CMake", "qt-target/qt6-rk3566-llvm-6.8.3/bin/qt-cmake"],
210
- ["target sysroot", "toolchain/host/aarch64-buildroot-linux-gnu/sysroot"],
211
- ["Qt target libraries", "qt-target/qt6-rk3566-llvm-6.8.3/lib"],
212
- ["Rockchip mkimage", "tools/mac/mkimage"],
213
- ["Rockchip resource_tool", "tools/mac/resource_tool"]
214
- ];
508
+ export async function validateLocalToolchain(input) {
509
+ const releaseRoot = typeof input === "string" ? input : input?.releaseRoot;
510
+ const mode = normalizeLocalToolchainInstallMode(typeof input === "string" ? undefined : input?.mode);
511
+ const boardId = normalizeBoardId(typeof input === "string" ? DEFAULT_BOARD_ID : input?.boardId ?? DEFAULT_BOARD_ID);
512
+ const resolvedRoot = await resolveLocalReleaseRoot(releaseRoot, boardId);
513
+ const required = requiredLocalToolchainChecks(mode, boardId);
215
514
  const checked_paths = [];
216
515
  for (const [label, relativePath] of required) {
217
516
  const absolutePath = resolve(resolvedRoot, relativePath);
@@ -222,22 +521,290 @@ export async function validateLocalToolchain(releaseRoot) {
222
521
  });
223
522
  }
224
523
  const missing_paths = checked_paths.filter((item) => !item.exists).map((item) => item.path);
524
+ const path_leaks = await localToolchainPathLeaks(resolvedRoot, boardId);
525
+ const missing_groups = localToolchainMissingGroups(checked_paths, path_leaks);
526
+ const repair_command = missing_paths.length > 0 || path_leaks.length > 0
527
+ ? `embedlabs local toolchain install --board ${boardId} --mode ${mode}`
528
+ : undefined;
225
529
  return {
226
- ok: missing_paths.length === 0,
530
+ ok: missing_paths.length === 0 && path_leaks.length === 0,
531
+ mode,
227
532
  host: {
228
533
  platform: platform(),
229
534
  arch: arch()
230
535
  },
231
- board_id: DEFAULT_BOARD_ID,
536
+ board_id: boardId,
232
537
  release_root: resolvedRoot,
233
538
  checked_paths,
234
539
  missing_paths,
540
+ path_leaks,
541
+ missing_groups,
542
+ repair_command,
543
+ summary_for_user: localToolchainValidationSummary({
544
+ boardId,
545
+ mode,
546
+ ok: missing_paths.length === 0,
547
+ missingGroups: missing_groups,
548
+ repairCommand: repair_command
549
+ }),
235
550
  notes: [
236
551
  "Local build commands require an Embed Labs auth token so local resource use remains account attributable.",
237
- "This validator checks the Mac-first TaishanPi LLVM release layout; package install/update registry work is tracked separately."
552
+ `This validator checks the ${boardId} local support layout for install mode ${mode}.`
238
553
  ]
239
554
  };
240
555
  }
556
+ function localToolchainMissingGroups(checkedPaths, pathLeaks = []) {
557
+ const groups = new Set();
558
+ if (pathLeaks.length > 0) {
559
+ groups.add("portable-paths/安装包可移植路径");
560
+ }
561
+ for (const check of checkedPaths) {
562
+ if (check.exists) {
563
+ continue;
564
+ }
565
+ const text = `${check.label} ${check.path}`.toLowerCase();
566
+ if (text.includes("qt host") || text.includes("qtquick") || text.includes("qt cmake") || text.includes("qt target")) {
567
+ groups.add("qt/Qt host、target 或实时预览组件");
568
+ continue;
569
+ }
570
+ if (text.includes("compiler") || text.includes("readelf") || text.includes("sysroot") || text.includes("include directory") || text.includes("clang wrapper") || text.includes("gcc libraries")) {
571
+ groups.add("compile/交叉编译器与 sysroot");
572
+ continue;
573
+ }
574
+ if (text.includes("boot") || text.includes("dtb") || text.includes("resource image") || text.includes("metadata") || text.includes("/meta")) {
575
+ groups.add("board-resources/启动资源、DTB 与元数据");
576
+ continue;
577
+ }
578
+ if (text.includes("image") || text.includes("rootfs") || text.includes("parameter")) {
579
+ groups.add("images/基础镜像资源");
580
+ continue;
581
+ }
582
+ if (text.includes("rp2350") || text.includes("pico")) {
583
+ groups.add("rp2350/RP2350 SDK、固件或监控运行时");
584
+ continue;
585
+ }
586
+ groups.add("runtime/运行时文件");
587
+ }
588
+ return [...groups];
589
+ }
590
+ async function rewriteLocalToolchainPortablePaths(releaseRoot, boardId) {
591
+ if (normalizeBoardId(boardId) !== DEFAULT_BOARD_ID) {
592
+ return;
593
+ }
594
+ const replacements = taishanPiPortablePathReplacements(releaseRoot);
595
+ const files = await taishanPiPortableTextFiles(releaseRoot);
596
+ for (const file of files) {
597
+ let content;
598
+ try {
599
+ content = await readFile(file, "utf8");
600
+ }
601
+ catch {
602
+ continue;
603
+ }
604
+ const rewritten = rewritePortablePathContent(content, replacements);
605
+ if (rewritten !== content) {
606
+ await writeFile(file, rewritten, "utf8");
607
+ }
608
+ }
609
+ }
610
+ async function localToolchainPathLeaks(releaseRoot, boardId) {
611
+ if (normalizeBoardId(boardId) !== DEFAULT_BOARD_ID || !await pathExists(releaseRoot)) {
612
+ return [];
613
+ }
614
+ const forbidden = taishanPiForbiddenPortablePathFragments();
615
+ const files = await taishanPiPortableTextFiles(releaseRoot);
616
+ const leaks = [];
617
+ for (const file of files) {
618
+ let content;
619
+ try {
620
+ content = await readFile(file, "utf8");
621
+ }
622
+ catch {
623
+ continue;
624
+ }
625
+ for (const fragment of forbidden) {
626
+ if (content.includes(fragment)) {
627
+ leaks.push({
628
+ label: "portable path check",
629
+ path: file,
630
+ forbidden: fragment
631
+ });
632
+ break;
633
+ }
634
+ }
635
+ }
636
+ return leaks;
637
+ }
638
+ async function taishanPiPortableTextFiles(releaseRoot) {
639
+ const candidates = [
640
+ "toolchain/qt6-rk3566-llvm-toolchain.cmake",
641
+ "qt-target/qt6-rk3566-llvm-6.8.3/bin/target_qt.conf",
642
+ "qt-target/qt6-rk3566-llvm-6.8.3/lib/cmake/Qt6/Qt6Dependencies.cmake",
643
+ "qt-target/qt6-rk3566-llvm-6.8.3/lib/cmake/Qt6/qt.toolchain.cmake",
644
+ "qt-target/qt6-rk3566-llvm-6.8.3/lib/cmake/Qt6BuildInternals/QtBuildInternalsExtra.cmake",
645
+ "qt-target/qt6-rk3566-llvm-6.8.3/mkspecs/qdevice.pri",
646
+ "qt-target/qt6-rk3566-llvm-6.8.3/mkspecs/qmodule.pri",
647
+ "qt-target/qt6-rk3566-llvm-6.8.3/mkspecs/modules/qt_lib_core_private.pri",
648
+ "qt-target/qt6-rk3566-llvm-6.8.3/mkspecs/modules/qt_lib_gui_private.pri",
649
+ "qt-target/qt6-rk3566-llvm-6.8.3/mkspecs/modules/qt_ext_openxr_loader.pri",
650
+ "qt-host/qt6-host-macos-6.8.3/lib/cmake/Qt6BuildInternals/QtBuildInternalsExtra.cmake",
651
+ "qt-host/qt6-host-macos-6.8.3/mkspecs/modules/qt_ext_openxr_loader.pri"
652
+ ];
653
+ const files = [];
654
+ for (const candidate of candidates) {
655
+ const file = join(releaseRoot, candidate);
656
+ if (await pathExists(file)) {
657
+ files.push(file);
658
+ }
659
+ }
660
+ return files;
661
+ }
662
+ function taishanPiPortablePathReplacements(releaseRoot) {
663
+ const normalizedRoot = resolve(releaseRoot);
664
+ return [
665
+ ["@EMBEDLABS_RELEASE_ROOT@", normalizedRoot],
666
+ ["/Users/kvell/kk-project/DBT-Agent-Project/llvm-build-tspi/scripts/qt6-rk3566-llvm-toolchain.cmake", join(normalizedRoot, "toolchain", "qt6-rk3566-llvm-toolchain.cmake")],
667
+ ["/Users/kvell/kk-project/DBT-Agent-Project/llvm-build-tspi/.llvm-cross", join(normalizedRoot, "toolchain", "llvm-cross")],
668
+ ["/Volumes/LLVM-TSPI/tspi-rk3566-llvm-release-minimal", normalizedRoot],
669
+ ["/Volumes/LLVM-TSPI/sdk-tools/buildroot/output/rockchip_rk3566/host", join(normalizedRoot, "toolchain", "host")],
670
+ ["/Volumes/LLVM-TSPI/qt6-host-macos-6.8.3", join(normalizedRoot, "qt-host", "qt6-host-macos-6.8.3")],
671
+ ["/Volumes/LLVM-TSPI/qt6-rk3566-llvm-6.8.3", join(normalizedRoot, "qt-target", "qt6-rk3566-llvm-6.8.3")],
672
+ ["/Volumes/LLVM-TSPI/qt-build-host-macos-6.8.3", join(normalizedRoot, "qt-host", "qt6-host-macos-6.8.3", ".build-info", "qt-build-host-macos-6.8.3")],
673
+ ["/Volumes/LLVM-TSPI/qt-build-rk3566-llvm-6.8.3", join(normalizedRoot, "qt-target", "qt6-rk3566-llvm-6.8.3", ".build-info", "qt-build-rk3566-llvm-6.8.3")],
674
+ ["/Volumes/LLVM-TSPI/qt-everywhere-src-6.8.3", join(normalizedRoot, "meta", "source", "qt-everywhere-src-6.8.3")],
675
+ ["/Users/kvell/kk-project/DBT-Agent-Project/llvm-build-tspi", join(normalizedRoot, "meta", "source", "llvm-build-tspi")]
676
+ ];
677
+ }
678
+ function taishanPiForbiddenPortablePathFragments() {
679
+ return [
680
+ "/Volumes/LLVM-TSPI",
681
+ "/Users/kvell/kk-project/DBT-Agent-Project"
682
+ ];
683
+ }
684
+ function rewritePortablePathContent(content, replacements) {
685
+ let rewritten = content;
686
+ for (const [from, to] of replacements) {
687
+ rewritten = rewritten.split(from).join(to);
688
+ }
689
+ return rewritten;
690
+ }
691
+ function localToolchainValidationSummary(input) {
692
+ const boardName = input.boardId === DEFAULT_BOARD_ID
693
+ ? "泰山派 1M-RK3566"
694
+ : input.boardId;
695
+ if (input.ok) {
696
+ if (input.boardId === DEFAULT_BOARD_ID && input.mode === "qt") {
697
+ return `${boardName} Qt 本地开发环境完整,可以继续 QtQuick 原型、一键交叉编译和真机部署。`;
698
+ }
699
+ return `${boardName} 本地开发环境完整,可以继续使用 ${input.mode} 模式。`;
700
+ }
701
+ const groups = input.missingGroups.length > 0 ? input.missingGroups.join("、") : "部分文件";
702
+ if (input.boardId === DEFAULT_BOARD_ID && input.mode === "qt") {
703
+ return `${boardName} Qt 本地开发环境不完整,缺少 ${groups}。这会影响一键交叉编译、实时预览或真机部署;请先执行 ${input.repairCommand} 补装缺失组件。`;
704
+ }
705
+ return `${boardName} 本地开发环境不完整,缺少 ${groups};请先执行 ${input.repairCommand} 补装缺失组件。`;
706
+ }
707
+ function normalizeLocalToolchainInstallMode(mode) {
708
+ const normalized = mode?.trim();
709
+ if (!normalized) {
710
+ return "qt";
711
+ }
712
+ if (LOCAL_TOOLCHAIN_INSTALL_MODES.includes(normalized)) {
713
+ return normalized;
714
+ }
715
+ throw new Error(`Unsupported local toolchain install mode ${normalized}; expected ${LOCAL_TOOLCHAIN_INSTALL_MODES.join(", ")}.`);
716
+ }
717
+ function requiredLocalToolchainChecks(mode, boardId) {
718
+ if (isRp2350MonitorBoardId(boardId)) {
719
+ return requiredRp2350MonitorChecks(mode);
720
+ }
721
+ const base = [
722
+ ["release root", "."],
723
+ ["Rockchip mkimage", "tools/mac/mkimage"],
724
+ ["Rockchip dumpimage", "tools/mac/dumpimage"],
725
+ ["Rockchip resource_tool", "tools/mac/resource_tool"],
726
+ ["Rockchip rkdeveloptool", "tools/mac/rkdeveloptool"],
727
+ ["boot resource image", "boot-workspace/out/resource.img"],
728
+ ["boot image", "boot-workspace/out/boot.img"],
729
+ ["boot DTB", "boot-workspace/out/tspi-rk3566-user-v10-linux.dtb"],
730
+ ["package metadata", "meta"]
731
+ ];
732
+ const compile = [
733
+ ["C compiler", "toolchain/llvm-cross/bin/aarch64-linux-gnu-gcc"],
734
+ ["C++ compiler", "toolchain/llvm-cross/bin/aarch64-linux-gnu-g++"],
735
+ ["readelf", "toolchain/llvm-cross/bin/aarch64-linux-gnu-readelf"],
736
+ ["host clang wrapper", "toolchain/host-tools/bin/clang-aarch64-linux-gnu"],
737
+ ["host GCC libraries", "toolchain/host/lib/gcc"],
738
+ ["target sysroot", "toolchain/host/aarch64-buildroot-linux-gnu/sysroot"],
739
+ ["target include directory", "toolchain/host/aarch64-buildroot-linux-gnu/include"]
740
+ ];
741
+ const qt = [
742
+ ["Qt CMake", "qt-target/qt6-rk3566-llvm-6.8.3/bin/qt-cmake"],
743
+ ["Qt target libraries", "qt-target/qt6-rk3566-llvm-6.8.3/lib"],
744
+ ["Qt host tools", "qt-host/qt6-host-macos-6.8.3"],
745
+ ["QtQuick live preview", "toolkit-runtime/qtquick-live-preview/bin/embed-qml-live-preview"]
746
+ ];
747
+ const images = [
748
+ ["base boot image", "images/current/boot.img"],
749
+ ["base rootfs image", "images/current/rootfs.img"],
750
+ ["base image parameter", "images/current/parameter.txt"]
751
+ ];
752
+ const full = [
753
+ ["rootfs overlay", "userdata/rootfs"]
754
+ ];
755
+ if (mode === "minimal") {
756
+ return base;
757
+ }
758
+ if (mode === "compile") {
759
+ return [...base, ...compile];
760
+ }
761
+ if (mode === "qt") {
762
+ return [...base, ...compile, ...qt];
763
+ }
764
+ if (mode === "images") {
765
+ return [...base, ...images];
766
+ }
767
+ return [...base, ...compile, ...qt, ...images, ...full];
768
+ }
769
+ function requiredRp2350MonitorChecks(mode) {
770
+ const base = [
771
+ ["release root", "."],
772
+ ];
773
+ const runtime = [
774
+ ["RP2350 Monitor UI", "toolkit-runtime/rp2350-monitor/ui/index.html"],
775
+ ["RP2350 Monitor bridge", "toolkit-runtime/rp2350-monitor/ui/bridge/rpmon_bridge.py"],
776
+ ["RP2350 Monitor CLI", "toolkit-runtime/rp2350-monitor/tools/rpmon_cli.py"],
777
+ ["RP2350 picotool", "toolkit-runtime/rp2350-monitor/tools/picotool"],
778
+ ["RP2350 Monitor logic analyzer", "toolkit-runtime/rp2350-monitor/ui/bin/embed-labs-logic-analyzer"],
779
+ ["RP2350 Monitor AI operation contract", "toolkit-runtime/rp2350-monitor/ui/docs/ai-operation-contract.md"],
780
+ ["package metadata", "meta"]
781
+ ];
782
+ const firmware = [
783
+ ["RP2350 Monitor UF2", "toolkit-runtime/rp2350-monitor/firmware/rp2350_monitor.uf2"],
784
+ ["RP2350 Monitor firmware source", "toolkit-runtime/rp2350-monitor/firmware/src/main.cpp"]
785
+ ];
786
+ const initialFirmware = [
787
+ ["Pico 2 W initialization UF2", "rp2350-initial-firmware/pico2w/initial.uf2"],
788
+ ["ColorEasyPICO2 initialization UF2", "rp2350-initial-firmware/coloreasypico2/initial.uf2"]
789
+ ];
790
+ const compile = [
791
+ ["Pico SDK", "rp2350-sdk/pico-sdk/pico_sdk_init.cmake"],
792
+ ["Pico SDK CMakeLists", "rp2350-sdk/pico-sdk/CMakeLists.txt"],
793
+ ["ARM bare-metal C compiler", "rp2350-sdk/toolchains/arm-gnu-toolchain-15.2.rel1-darwin-arm64-arm-none-eabi/bin/arm-none-eabi-gcc"],
794
+ ["ARM bare-metal C++ compiler", "rp2350-sdk/toolchains/arm-gnu-toolchain-15.2.rel1-darwin-arm64-arm-none-eabi/bin/arm-none-eabi-g++"],
795
+ ["picotool", "rp2350-sdk/picotool/install/picotool/picotool"]
796
+ ];
797
+ if (mode === "compile") {
798
+ return [...base, ...compile, ...initialFirmware];
799
+ }
800
+ if (mode === "firmware") {
801
+ return [...base, ...runtime, ...firmware, ...initialFirmware];
802
+ }
803
+ if (mode === "full") {
804
+ return [...base, ...runtime, ...firmware, ...initialFirmware, ...compile];
805
+ }
806
+ return [...base, ...runtime];
807
+ }
241
808
  export async function compileTaishanPiSingleFile(options) {
242
809
  assertAuthenticated(options.auth);
243
810
  const releaseRoot = await resolveLocalReleaseRoot(options.releaseRoot);
@@ -307,15 +874,14 @@ export async function buildTaishanPiQtSmoke(options) {
307
874
  }
308
875
  async function loadLocalToolchainMetadata(metadataRoot, channelName) {
309
876
  const explicitRoot = metadataRoot || process.env.EMBEDLABS_METADATA_ROOT?.trim();
310
- const candidateRoot = explicitRoot || (await pathExists(DEFAULT_METADATA_ROOT) ? DEFAULT_METADATA_ROOT : undefined);
311
- if (!candidateRoot) {
877
+ if (!explicitRoot) {
312
878
  return {
313
879
  channel: BUILT_IN_CHANNEL,
314
880
  manifests: new Map(Object.entries(BUILT_IN_MANIFESTS)),
315
881
  metadataRoot: undefined
316
882
  };
317
883
  }
318
- const root = resolve(candidateRoot);
884
+ const root = resolve(explicitRoot);
319
885
  const channelPath = join(root, "channels", channelName, "index.json");
320
886
  const channel = JSON.parse(await readFile(channelPath, "utf8"));
321
887
  if (channel.schema !== "embedlabs.channel.v1") {
@@ -337,6 +903,149 @@ async function loadLocalToolchainMetadata(metadataRoot, channelName) {
337
903
  }
338
904
  return { channel, manifests, metadataRoot: root };
339
905
  }
906
+ async function resolveLocalToolchainDownloadPlan(input) {
907
+ const requestedBoardId = normalizeBoardId(input.boardId);
908
+ const compatibleBoardIds = compatibleDownloadBoardIds(requestedBoardId);
909
+ const channelUrl = downloadChannelUrl(input.channel);
910
+ const channel = await fetchJson(channelUrl);
911
+ if (channel.schema !== "embedlabs.download-channel.v1") {
912
+ throw new Error(`Unexpected download channel schema ${channel.schema}.`);
913
+ }
914
+ const entries = channel.packages ?? [];
915
+ let entry;
916
+ for (const boardId of compatibleBoardIds) {
917
+ entry = entries.find((item) => {
918
+ return normalizeBoardId(item.board_id ?? "") === boardId
919
+ && item.host === input.host
920
+ && item.toolchain === input.toolchain
921
+ && (item.kind === undefined || item.kind === "toolchain-archive" || item.kind === "board-support-archive");
922
+ });
923
+ if (entry) {
924
+ break;
925
+ }
926
+ }
927
+ if (!entry?.manifest) {
928
+ return undefined;
929
+ }
930
+ const manifestUrl = new URL(entry.manifest, channelUrl).toString();
931
+ const manifest = await fetchJson(manifestUrl);
932
+ if (manifest.id !== entry.id || manifest.version !== entry.version) {
933
+ throw new Error(`Download manifest mismatch for ${entry.id}@${entry.version}.`);
934
+ }
935
+ const manifestBoardId = normalizeBoardId(manifest.board_id ?? "");
936
+ if (!compatibleBoardIds.includes(manifestBoardId) || manifest.host !== input.host || manifest.toolchain !== input.toolchain) {
937
+ throw new Error(`Download manifest does not match requested ${requestedBoardId}/${input.host}/${input.toolchain}.`);
938
+ }
939
+ if ((!manifest.archive?.file || !manifest.archive.sha256 || !Number.isFinite(manifest.archive.size_bytes))
940
+ && (!Array.isArray(manifest.components) || manifest.components.length === 0)) {
941
+ throw new Error(`Download manifest ${manifest.id}@${manifest.version} is missing archive/components metadata.`);
942
+ }
943
+ const mirrors = manifest.archive
944
+ ? orderDownloadMirrors((manifest.mirrors ?? [])
945
+ .filter((mirror) => mirror.enabled !== false && typeof mirror.url === "string" && mirror.url.length > 0)
946
+ .map((mirror) => ({
947
+ kind: mirror.kind || "unknown",
948
+ enabled: mirror.enabled !== false,
949
+ url: mirror.url,
950
+ sha256: typeof mirror.sha256 === "string" ? mirror.sha256 : manifest.archive?.sha256,
951
+ size_bytes: Number.isFinite(mirror.size_bytes) ? mirror.size_bytes : manifest.archive?.size_bytes
952
+ })), manifest.download_policy?.preferred_order)
953
+ : [];
954
+ const components = downloadComponentsForBoard(requestedBoardId, (manifest.components ?? []).map((component) => normalizeDownloadComponent(component, manifest, manifestUrl)));
955
+ const first = mirrors[0];
956
+ if (!first && components.length === 0) {
957
+ return undefined;
958
+ }
959
+ return {
960
+ channel_url: channelUrl,
961
+ manifest_url: manifestUrl,
962
+ package_id: manifest.id,
963
+ version: manifest.version,
964
+ board_id: requestedBoardId,
965
+ host: input.host,
966
+ toolchain: input.toolchain,
967
+ source_url: first?.url,
968
+ mirror_kind: first?.kind,
969
+ archive: manifest.archive ? {
970
+ file: manifest.archive.file,
971
+ size_bytes: manifest.archive.size_bytes,
972
+ sha256: manifest.archive.sha256,
973
+ content_type: manifest.archive.content_type
974
+ } : undefined,
975
+ mirrors,
976
+ components: components.length > 0 ? components : undefined,
977
+ default_mode: manifest.download_policy?.default_mode
978
+ };
979
+ }
980
+ function normalizeDownloadComponent(component, manifest, manifestUrl) {
981
+ if (!component.id || !component.version) {
982
+ throw new Error(`Download manifest ${manifest.id}@${manifest.version} contains a component without id/version.`);
983
+ }
984
+ if (!component.archive?.file || !component.archive.sha256 || !Number.isFinite(component.archive.size_bytes)) {
985
+ throw new Error(`Download manifest ${manifest.id}@${manifest.version} component ${component.id} is missing archive metadata.`);
986
+ }
987
+ const mirrors = orderDownloadMirrors((component.mirrors ?? [])
988
+ .filter((mirror) => mirror.enabled !== false && typeof mirror.url === "string" && mirror.url.length > 0)
989
+ .map((mirror) => ({
990
+ kind: mirror.kind || "unknown",
991
+ enabled: mirror.enabled !== false,
992
+ url: new URL(mirror.url, manifestUrl).toString(),
993
+ sha256: typeof mirror.sha256 === "string" ? mirror.sha256 : component.archive?.sha256,
994
+ size_bytes: Number.isFinite(mirror.size_bytes) ? mirror.size_bytes : component.archive?.size_bytes
995
+ })), manifest.download_policy?.preferred_order);
996
+ const first = mirrors[0];
997
+ if (!first) {
998
+ throw new Error(`Download manifest ${manifest.id}@${manifest.version} component ${component.id} has no enabled mirrors.`);
999
+ }
1000
+ return {
1001
+ id: component.id,
1002
+ version: component.version,
1003
+ role: component.role,
1004
+ install_modes: Array.isArray(component.install_modes) ? component.install_modes : undefined,
1005
+ archive: {
1006
+ file: component.archive.file,
1007
+ size_bytes: component.archive.size_bytes,
1008
+ sha256: component.archive.sha256,
1009
+ content_type: component.archive.content_type
1010
+ },
1011
+ source_url: first.url,
1012
+ mirror_kind: first.kind,
1013
+ mirrors
1014
+ };
1015
+ }
1016
+ function orderDownloadMirrors(mirrors, preferredOrder) {
1017
+ const preference = preferredOrder?.length
1018
+ ? preferredOrder
1019
+ : ["github_release", "embedlabs_cdn", "cloudfront", "embedlabs_server"];
1020
+ return [...mirrors].sort((left, right) => mirrorRank(left.kind, preference) - mirrorRank(right.kind, preference));
1021
+ }
1022
+ function mirrorRank(kind, preferredOrder) {
1023
+ const index = preferredOrder.indexOf(kind);
1024
+ return index >= 0 ? index : preferredOrder.length + 1;
1025
+ }
1026
+ async function fetchJson(url) {
1027
+ const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_REQUEST_TIMEOUT_MS) });
1028
+ if (!response.ok) {
1029
+ throw new Error(`HTTP ${response.status} while loading ${url}`);
1030
+ }
1031
+ return await response.json();
1032
+ }
1033
+ function downloadChannelUrl(channelName) {
1034
+ const explicit = process.env.EMBED_DOWNLOAD_CHANNEL_URL?.trim()
1035
+ || process.env.EMBEDLABS_DOWNLOAD_CHANNEL_URL?.trim();
1036
+ if (explicit) {
1037
+ return explicit;
1038
+ }
1039
+ return `${downloadBaseUrl()}/downloads/metadata/channels/${encodeURIComponent(channelName)}/index.json`;
1040
+ }
1041
+ function downloadBaseUrl() {
1042
+ return trimTrailingSlash(process.env.EMBED_DOWNLOAD_BASE_URL?.trim()
1043
+ || process.env.EMBEDLABS_DOWNLOAD_BASE_URL?.trim()
1044
+ || DEFAULT_DOWNLOAD_BASE_URL);
1045
+ }
1046
+ function trimTrailingSlash(value) {
1047
+ return value.replace(/\/+$/, "");
1048
+ }
340
1049
  function resolvePackageRefs(packageId, channel, manifests, seen = new Set()) {
341
1050
  if (seen.has(packageId)) {
342
1051
  return [];
@@ -359,14 +1068,153 @@ function resolvePackageRefs(packageId, channel, manifests, seen = new Set()) {
359
1068
  return refs;
360
1069
  }
361
1070
  function boardPackageIdFor(boardId) {
362
- if (boardId === DEFAULT_BOARD_ID || boardId === "taishanpi" || boardId === "taishanpi-1m-rk3566") {
1071
+ const normalized = normalizeBoardId(boardId);
1072
+ if (normalized === DEFAULT_BOARD_ID || normalized === "taishanpi" || normalized === "taishanpi-1m-rk3566") {
363
1073
  return "embedlabs.board.taishanpi.1m-rk3566";
364
1074
  }
365
- if (boardId.startsWith("embedlabs.board.")) {
1075
+ if (normalized === COLOREASYPICO2_RP2350_BOARD_ID
1076
+ || normalized === "coloreasypico2"
1077
+ || normalized === "color-easy-pico2"
1078
+ || normalized === "color-easy-pico-2"
1079
+ || normalized === "color-easy-pico-2-rp2350-monitor"
1080
+ || normalized === "ce-pico2") {
1081
+ return "embedlabs.board.coloreasypico2.rp2350-monitor";
1082
+ }
1083
+ if (normalized === PICO2W_RP2350_BOARD_ID
1084
+ || normalized === "pico2w"
1085
+ || normalized === "pico-2-w"
1086
+ || normalized === "pico2"
1087
+ || normalized === "rp2350"
1088
+ || normalized === "rp2350-monitor") {
1089
+ return "embedlabs.board.pico2w.rp2350-monitor";
1090
+ }
1091
+ if (normalized.startsWith("embedlabs.board.")) {
366
1092
  return boardId;
367
1093
  }
368
1094
  throw new Error(`Unsupported local toolchain board ${boardId}.`);
369
1095
  }
1096
+ function packageIdForBoardFilter(boardId) {
1097
+ try {
1098
+ return boardPackageIdFor(boardId);
1099
+ }
1100
+ catch {
1101
+ return undefined;
1102
+ }
1103
+ }
1104
+ function boardIdForPackageManifest(manifest) {
1105
+ const explicit = manifest.board_id;
1106
+ if (explicit?.trim()) {
1107
+ return normalizeBoardId(explicit);
1108
+ }
1109
+ if (manifest.id === "embedlabs.board.taishanpi.1m-rk3566") {
1110
+ return DEFAULT_BOARD_ID;
1111
+ }
1112
+ if (manifest.id.startsWith("embedlabs.board.")) {
1113
+ return normalizeBoardId(manifest.id.slice("embedlabs.board.".length).replaceAll(".", "-"));
1114
+ }
1115
+ return normalizeBoardId([manifest.board, manifest.variant].filter(Boolean).join("-") || manifest.id);
1116
+ }
1117
+ function normalizeBoardId(boardId) {
1118
+ return boardId.trim().toLowerCase().replaceAll("_", "-");
1119
+ }
1120
+ function isRp2350MonitorBoardId(boardId) {
1121
+ const normalized = normalizeBoardId(boardId);
1122
+ return normalized === PICO2W_RP2350_BOARD_ID
1123
+ || normalized === COLOREASYPICO2_RP2350_BOARD_ID
1124
+ || normalized === "pico2w"
1125
+ || normalized === "pico-2-w"
1126
+ || normalized === "pico2"
1127
+ || normalized === "rp2350"
1128
+ || normalized === "rp2350-monitor"
1129
+ || normalized === "coloreasypico2"
1130
+ || normalized === "color-easy-pico2"
1131
+ || normalized === "color-easy-pico-2"
1132
+ || normalized === "ce-pico2";
1133
+ }
1134
+ function compatibleDownloadBoardIds(boardId) {
1135
+ const normalized = normalizeBoardId(boardId);
1136
+ if (normalized === COLOREASYPICO2_RP2350_BOARD_ID) {
1137
+ return [COLOREASYPICO2_RP2350_BOARD_ID, PICO2W_RP2350_BOARD_ID];
1138
+ }
1139
+ return [normalized];
1140
+ }
1141
+ function downloadComponentsForBoard(boardId, components) {
1142
+ const normalized = normalizeBoardId(boardId);
1143
+ if (normalized === DEFAULT_BOARD_ID) {
1144
+ return components.filter((component) => !isRp2350MonitorComponent(component));
1145
+ }
1146
+ return components;
1147
+ }
1148
+ function isRp2350MonitorComponent(component) {
1149
+ const text = `${component.id} ${component.role ?? ""} ${component.archive.file}`.toLowerCase();
1150
+ return text.includes("rp2350-monitor") || text.includes("pico2w-rp2350-monitor");
1151
+ }
1152
+ function compareVersionLike(left, right) {
1153
+ return left.localeCompare(right, undefined, { numeric: true, sensitivity: "base" });
1154
+ }
1155
+ function isRecord(value) {
1156
+ return !!value && typeof value === "object" && !Array.isArray(value);
1157
+ }
1158
+ function packageHostSupport(packages, manifests, host) {
1159
+ const unsupportedPackages = [];
1160
+ for (const item of packages) {
1161
+ const manifest = manifests.get(item.id);
1162
+ if (manifest?.hosts?.length && !manifest.hosts.includes(host)) {
1163
+ unsupportedPackages.push(`${item.id}@${manifest.version}`);
1164
+ }
1165
+ }
1166
+ return {
1167
+ supported: unsupportedPackages.length === 0,
1168
+ unsupportedPackages
1169
+ };
1170
+ }
1171
+ function environmentNotes(input) {
1172
+ const notes = [];
1173
+ if (normalizeBoardId(input.boardId) === COLOREASYPICO2_RP2350_BOARD_ID) {
1174
+ notes.push("ColorEasyPICO2 uses the same RP2350/Pico SDK, ARM bare-metal toolchain, picotool, and optional RP2350 Monitor image package as Pico 2 W; Wi-Fi operations are not declared for this no-Wi-Fi board.");
1175
+ }
1176
+ if (isRp2350MonitorBoardId(input.boardId)) {
1177
+ notes.push("RP2350 Monitor is an optional hardware-control firmware image; the board environment itself is the Pico 2 W or ColorEasyPICO2 C/C++ SDK environment.");
1178
+ }
1179
+ if (input.status === "available") {
1180
+ notes.push("Environment is available but not installed on this computer.");
1181
+ }
1182
+ if (input.status === "update_available") {
1183
+ notes.push("A newer package is available; run the update command to refresh only the selected environment.");
1184
+ }
1185
+ if (input.status === "unsupported_host") {
1186
+ notes.push(`This host is missing platform support for: ${input.unsupportedPackages.join(", ")}`);
1187
+ }
1188
+ if (input.downloadError) {
1189
+ notes.push(`Download manifest could not be resolved yet: ${input.downloadError}`);
1190
+ }
1191
+ return notes;
1192
+ }
1193
+ function localToolchainInstallModesForDownload(download) {
1194
+ if (!download?.components?.length) {
1195
+ return [...LOCAL_TOOLCHAIN_INSTALL_MODES];
1196
+ }
1197
+ const modes = new Set();
1198
+ for (const component of download.components) {
1199
+ for (const mode of component.install_modes ?? []) {
1200
+ modes.add(mode);
1201
+ }
1202
+ }
1203
+ return modes.size > 0
1204
+ ? [...modes].filter((mode) => LOCAL_TOOLCHAIN_INSTALL_MODES.includes(mode))
1205
+ : [...LOCAL_TOOLCHAIN_INSTALL_MODES];
1206
+ }
1207
+ function localToolchainEnvironmentComponent(component) {
1208
+ return {
1209
+ id: component.id,
1210
+ version: component.version,
1211
+ role: component.role,
1212
+ install_modes: component.install_modes,
1213
+ file: component.archive.file,
1214
+ size_bytes: component.archive.size_bytes,
1215
+ source_url: component.source_url
1216
+ };
1217
+ }
370
1218
  function hostId() {
371
1219
  if (platform() === "darwin" && arch() === "arm64") {
372
1220
  return "darwin-arm64";
@@ -384,21 +1232,44 @@ function resolveInstallRoot(installRoot) {
384
1232
  function localToolchainRegistryPath(installRoot) {
385
1233
  return join(installRoot, "registry", "local-toolchains.json");
386
1234
  }
387
- async function writeCurrentRegistry(installRoot, latest, releaseRoot) {
1235
+ async function writeCurrentRegistry(installRoot, latest, releaseRoot, mode, source) {
388
1236
  const registryPath = localToolchainRegistryPath(installRoot);
389
1237
  await mkdir(dirname(registryPath), { recursive: true });
390
- await writeFile(registryPath, `${JSON.stringify({
1238
+ let existing = {};
1239
+ try {
1240
+ existing = JSON.parse(await readFile(registryPath, "utf8"));
1241
+ }
1242
+ catch {
1243
+ existing = {};
1244
+ }
1245
+ const entry = {
391
1246
  installed: true,
392
1247
  board_id: latest.board_id,
393
1248
  version: latest.version,
394
1249
  channel: latest.channel,
395
1250
  host: latest.host,
1251
+ mode,
396
1252
  release_root: releaseRoot,
397
1253
  packages: latest.packages,
1254
+ source,
1255
+ installed_components: source?.components,
1256
+ updated_at: new Date().toISOString()
1257
+ };
1258
+ const environments = isRecord(existing.environments)
1259
+ ? { ...existing.environments }
1260
+ : {};
1261
+ environments[normalizeBoardId(latest.board_id)] = entry;
1262
+ const preserveTopLevel = latest.board_id !== DEFAULT_BOARD_ID
1263
+ && typeof existing.release_root === "string"
1264
+ && normalizeBoardId(String(existing.board_id ?? DEFAULT_BOARD_ID)) === DEFAULT_BOARD_ID;
1265
+ const topLevel = preserveTopLevel ? existing : entry;
1266
+ await writeFile(registryPath, `${JSON.stringify({
1267
+ ...topLevel,
1268
+ environments,
398
1269
  updated_at: new Date().toISOString()
399
1270
  }, null, 2)}\n`, "utf8");
400
1271
  }
401
- async function resolveLocalReleaseRoot(releaseRoot) {
1272
+ async function resolveLocalReleaseRoot(releaseRoot, boardId = DEFAULT_BOARD_ID) {
402
1273
  if (releaseRoot?.trim()) {
403
1274
  return resolve(releaseRoot);
404
1275
  }
@@ -406,13 +1277,13 @@ async function resolveLocalReleaseRoot(releaseRoot) {
406
1277
  if (envRoot) {
407
1278
  return resolve(envRoot);
408
1279
  }
409
- const current = await currentLocalToolchain(undefined, DEFAULT_BOARD_ID);
1280
+ const current = await currentLocalToolchain(undefined, boardId);
410
1281
  if (current.release_root) {
411
1282
  return resolve(current.release_root);
412
1283
  }
413
1284
  return resolve(DEFAULT_RELEASE_ROOT);
414
1285
  }
415
- async function sourceReleaseRootForInstall(options, installRoot, tempDir) {
1286
+ async function sourceReleaseRootForInstall(options, latest, installRoot, tempDir) {
416
1287
  if (options.sourceReleaseRoot) {
417
1288
  return {
418
1289
  path: resolve(options.sourceReleaseRoot),
@@ -432,15 +1303,180 @@ async function sourceReleaseRootForInstall(options, installRoot, tempDir) {
432
1303
  source: { kind: "url", value: options.sourceUrl, downloaded_path: downloadedPath }
433
1304
  };
434
1305
  }
1306
+ if (latest.download?.components?.length) {
1307
+ return await componentSourceRootForInstall(options, latest.download, installRoot, tempDir);
1308
+ }
1309
+ if (latest.download) {
1310
+ const failures = [];
1311
+ for (const mirror of latest.download.mirrors) {
1312
+ if (!mirror.enabled) {
1313
+ continue;
1314
+ }
1315
+ if (!latest.download.archive) {
1316
+ continue;
1317
+ }
1318
+ const extractRoot = join(tempDir, `extract-${safeFileToken(mirror.kind)}`);
1319
+ try {
1320
+ const downloadedPath = await downloadToolchainArchive(mirror.url, installRoot, {
1321
+ sha256: mirror.sha256 ?? latest.download.archive.sha256,
1322
+ size_bytes: mirror.size_bytes ?? latest.download.archive.size_bytes
1323
+ });
1324
+ await rm(extractRoot, { recursive: true, force: true });
1325
+ await mkdir(extractRoot, { recursive: true });
1326
+ const extracted = await runCommand(["tar", "-xzf", downloadedPath, "-C", extractRoot], tempDir);
1327
+ if (extracted.exit_code !== 0) {
1328
+ throw new Error(`Could not extract local toolchain archive: ${extracted.stderr_tail.join("\n")}`);
1329
+ }
1330
+ return {
1331
+ path: await findReleaseRoot(extractRoot),
1332
+ source: {
1333
+ kind: "url",
1334
+ value: mirror.url,
1335
+ downloaded_path: downloadedPath,
1336
+ mirror_kind: mirror.kind,
1337
+ sha256: mirror.sha256 ?? latest.download.archive.sha256,
1338
+ size_bytes: mirror.size_bytes ?? latest.download.archive.size_bytes
1339
+ }
1340
+ };
1341
+ }
1342
+ catch (error) {
1343
+ failures.push(`${mirror.kind}: ${error instanceof Error ? error.message : String(error)}`);
1344
+ }
1345
+ }
1346
+ if (failures.length > 0 && !await pathExists(DEFAULT_RELEASE_ROOT)) {
1347
+ throw new Error(`Could not install local toolchain from download mirrors: ${failures.join("; ")}`);
1348
+ }
1349
+ }
435
1350
  if (await pathExists(DEFAULT_RELEASE_ROOT)) {
436
1351
  return {
437
1352
  path: resolve(DEFAULT_RELEASE_ROOT),
438
1353
  source: { kind: "directory", value: resolve(DEFAULT_RELEASE_ROOT) }
439
1354
  };
440
1355
  }
441
- throw new Error("Local toolchain install needs --source-url <tar.gz> or --source-release-root <path> until the public download service is configured.");
1356
+ throw new Error("Local toolchain install could not resolve a source. Pass --source-url <tar.gz>, --source-release-root <path>, or configure EMBED_DOWNLOAD_BASE_URL.");
1357
+ }
1358
+ async function componentSourceRootForInstall(options, download, installRoot, tempDir) {
1359
+ const mode = normalizeLocalToolchainInstallMode(options.mode ?? download.default_mode);
1360
+ const components = selectedDownloadComponents(download.components ?? [], mode);
1361
+ if (components.length === 0) {
1362
+ throw new Error(`No local toolchain components selected for mode ${mode}.`);
1363
+ }
1364
+ const extractRoot = join(tempDir, "extract-components");
1365
+ await mkdir(extractRoot, { recursive: true });
1366
+ const installedComponents = [];
1367
+ const failures = [];
1368
+ for (const component of components) {
1369
+ let installed = false;
1370
+ for (const mirror of component.mirrors) {
1371
+ if (!mirror.enabled) {
1372
+ continue;
1373
+ }
1374
+ try {
1375
+ const downloadedPath = await downloadToolchainArchive(mirror.url, installRoot, {
1376
+ sha256: mirror.sha256 ?? component.archive.sha256,
1377
+ size_bytes: mirror.size_bytes ?? component.archive.size_bytes
1378
+ });
1379
+ const extracted = await runCommand(["tar", "-xzf", downloadedPath, "-C", extractRoot], tempDir);
1380
+ if (extracted.exit_code !== 0) {
1381
+ throw new Error(`Could not extract component ${component.id}: ${extracted.stderr_tail.join("\n")}`);
1382
+ }
1383
+ installedComponents.push({
1384
+ id: component.id,
1385
+ version: component.version,
1386
+ role: component.role,
1387
+ archive_file: component.archive.file,
1388
+ mirror_kind: mirror.kind,
1389
+ downloaded_path: downloadedPath,
1390
+ size_bytes: mirror.size_bytes ?? component.archive.size_bytes,
1391
+ sha256: mirror.sha256 ?? component.archive.sha256
1392
+ });
1393
+ installed = true;
1394
+ break;
1395
+ }
1396
+ catch (error) {
1397
+ failures.push(`${component.id}@${component.version}/${mirror.kind}: ${error instanceof Error ? error.message : String(error)}`);
1398
+ }
1399
+ }
1400
+ if (!installed) {
1401
+ throw new Error(`Could not install component ${component.id}@${component.version}: ${failures.join("; ")}`);
1402
+ }
1403
+ }
1404
+ return {
1405
+ path: extractRoot,
1406
+ source: {
1407
+ kind: "components",
1408
+ value: download.manifest_url,
1409
+ mirror_kind: "components",
1410
+ size_bytes: installedComponents.reduce((total, component) => total + component.size_bytes, 0),
1411
+ components: installedComponents
1412
+ }
1413
+ };
1414
+ }
1415
+ function selectedDownloadComponents(components, mode) {
1416
+ return components.filter((component) => {
1417
+ if (!component.install_modes?.length) {
1418
+ return true;
1419
+ }
1420
+ return component.install_modes.includes(mode);
1421
+ });
1422
+ }
1423
+ function installCopyPathsForBoard(boardId) {
1424
+ if (normalizeBoardId(boardId) === DEFAULT_BOARD_ID) {
1425
+ return INSTALL_COPY_PATHS.filter((relativePath) => {
1426
+ const normalized = relativePath.toLowerCase();
1427
+ return normalized !== "toolkit-runtime/rp2350-monitor"
1428
+ && normalized !== "toolkit-runtime/rp2350-monitor/";
1429
+ });
1430
+ }
1431
+ return INSTALL_COPY_PATHS;
1432
+ }
1433
+ async function resolveInstallSourcePathForBoard(sourceRoot, relativePath, boardId) {
1434
+ const direct = resolve(sourceRoot, relativePath);
1435
+ if (await pathExists(direct)) {
1436
+ return direct;
1437
+ }
1438
+ if (!isRp2350MonitorBoardId(boardId)) {
1439
+ return direct;
1440
+ }
1441
+ const legacyRp2350Root = await findLegacyRp2350Root(sourceRoot);
1442
+ if (!legacyRp2350Root) {
1443
+ return direct;
1444
+ }
1445
+ if (relativePath === "rp2350-sdk") {
1446
+ return legacyRp2350Root;
1447
+ }
1448
+ if (relativePath === "rp2350-initial-firmware") {
1449
+ return resolve(legacyRp2350Root, "initial_firmware");
1450
+ }
1451
+ if (relativePath === "rp2350-examples") {
1452
+ return resolve(legacyRp2350Root, "validation");
1453
+ }
1454
+ return direct;
442
1455
  }
443
- async function downloadToolchainArchive(sourceUrl, installRoot) {
1456
+ async function findLegacyRp2350Root(sourceRoot) {
1457
+ const direct = resolve(sourceRoot, "RP2350");
1458
+ if (await pathExists(resolve(direct, "pico-sdk", "pico_sdk_init.cmake"))) {
1459
+ return direct;
1460
+ }
1461
+ let entries;
1462
+ try {
1463
+ entries = await readdir(sourceRoot, { withFileTypes: true });
1464
+ }
1465
+ catch {
1466
+ return undefined;
1467
+ }
1468
+ for (const entry of entries) {
1469
+ if (!entry.isDirectory()) {
1470
+ continue;
1471
+ }
1472
+ const candidate = resolve(sourceRoot, entry.name, "RP2350");
1473
+ if (await pathExists(resolve(candidate, "pico-sdk", "pico_sdk_init.cmake"))) {
1474
+ return candidate;
1475
+ }
1476
+ }
1477
+ return undefined;
1478
+ }
1479
+ async function downloadToolchainArchive(sourceUrl, installRoot, expected) {
444
1480
  const downloadsDir = join(installRoot, "cache", "downloads");
445
1481
  await mkdir(downloadsDir, { recursive: true });
446
1482
  const parsed = new URL(sourceUrl);
@@ -454,10 +1490,21 @@ async function downloadToolchainArchive(sourceUrl, installRoot) {
454
1490
  throw new Error(`Unsupported local toolchain download URL protocol: ${parsed.protocol}`);
455
1491
  }
456
1492
  const partialPath = `${outputPath}.part`;
1493
+ const expectedSize = expected?.size_bytes;
457
1494
  const existingComplete = await fileSize(outputPath);
458
1495
  const remoteSize = await remoteContentLength(sourceUrl);
459
- if (existingComplete > 0 && remoteSize !== undefined && existingComplete === remoteSize) {
460
- return outputPath;
1496
+ const targetSize = expectedSize ?? remoteSize;
1497
+ if (existingComplete > 0 && targetSize !== undefined && existingComplete === targetSize) {
1498
+ if (expected?.sha256) {
1499
+ const actual = await sha256(outputPath);
1500
+ if (actual === expected.sha256) {
1501
+ return outputPath;
1502
+ }
1503
+ await rm(outputPath, { force: true });
1504
+ }
1505
+ else {
1506
+ return outputPath;
1507
+ }
461
1508
  }
462
1509
  let resumeFrom = await fileSize(partialPath);
463
1510
  const headers = new Headers();
@@ -479,10 +1526,17 @@ async function downloadToolchainArchive(sourceUrl, installRoot) {
479
1526
  const writeStream = createWriteStream(partialPath, { flags: resumeFrom > 0 ? "a" : "w" });
480
1527
  await pipeline(Readable.fromWeb(response.body), writeStream);
481
1528
  const downloadedSize = await fileSize(partialPath);
482
- if (remoteSize !== undefined && downloadedSize !== remoteSize) {
483
- throw new Error(`Local toolchain download incomplete: expected ${remoteSize} bytes, got ${downloadedSize} bytes.`);
1529
+ if (targetSize !== undefined && downloadedSize !== targetSize) {
1530
+ throw new Error(`Local toolchain download incomplete: expected ${targetSize} bytes, got ${downloadedSize} bytes.`);
484
1531
  }
485
1532
  await rename(partialPath, outputPath);
1533
+ if (expected?.sha256) {
1534
+ const actual = await sha256(outputPath);
1535
+ if (actual !== expected.sha256) {
1536
+ await rm(outputPath, { force: true });
1537
+ throw new Error(`Local toolchain download SHA256 mismatch: expected ${expected.sha256}, got ${actual}.`);
1538
+ }
1539
+ }
486
1540
  return outputPath;
487
1541
  }
488
1542
  async function remoteContentLength(sourceUrl) {
@@ -510,6 +1564,9 @@ async function fileSize(filePath) {
510
1564
  return 0;
511
1565
  }
512
1566
  }
1567
+ function safeFileToken(value) {
1568
+ return value.replace(/[^a-zA-Z0-9_.-]+/g, "_") || "mirror";
1569
+ }
513
1570
  async function findReleaseRoot(extractRoot) {
514
1571
  if (await pathExists(join(extractRoot, "toolchain"))) {
515
1572
  return extractRoot;