@hexclave/cli 1.0.68 → 1.0.70

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hexclave/cli",
3
- "version": "1.0.68",
3
+ "version": "1.0.70",
4
4
  "repository": "https://github.com/hexclave/hexclave",
5
5
  "description": "The CLI for Hexclave. https://hexclave.com",
6
6
  "main": "dist/index.js",
@@ -25,12 +25,13 @@
25
25
  "commander": "^13.1.0",
26
26
  "extract-zip": "^2.0.1",
27
27
  "jiti": "^2.4.2",
28
- "@hexclave/shared-backend": "1.0.68",
29
- "@hexclave/js": "1.0.68",
30
- "@hexclave/shared": "1.0.68"
28
+ "tar": "^7.4.3",
29
+ "@hexclave/shared-backend": "1.0.70",
30
+ "@hexclave/js": "1.0.70",
31
+ "@hexclave/shared": "1.0.70"
31
32
  },
32
33
  "devDependencies": {
33
- "@types/node": "20.17.6",
34
+ "@types/node": "22.15.21",
34
35
  "rimraf": "^6.0.1",
35
36
  "tsdown": "^0.22.3",
36
37
  "typescript": "6.0.3"
@@ -1,14 +1,29 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
2
+ import { tmpdir } from "os";
3
+ import { join } from "path";
4
+ import { afterEach, describe, expect, it } from "vitest";
2
5
  import {
3
6
  DASHBOARD_DIR_OVERRIDE_ENV_VAR,
4
7
  DASHBOARD_MANIFEST_URL_ENV_VAR,
5
8
  dashboardDirOverride,
6
9
  dashboardManifestUrl,
10
+ dashboardPlatformKey,
11
+ dashboardVersionDir,
12
+ hasZstdSupport,
13
+ latestCachedDashboard,
7
14
  parseDashboardManifest,
8
15
  pickLatestVersion,
16
+ selectDashboardArchive,
9
17
  } from "./dashboard-release.js";
10
18
 
11
19
  const VALID_SHA = "a".repeat(64);
20
+ const temporaryDirectories: string[] = [];
21
+
22
+ afterEach(() => {
23
+ for (const directory of temporaryDirectories.splice(0)) {
24
+ rmSync(directory, { recursive: true, force: true });
25
+ }
26
+ });
12
27
 
13
28
  describe("parseDashboardManifest", () => {
14
29
  it("accepts a well-formed manifest and lowercases the digest", () => {
@@ -61,6 +76,95 @@ describe("parseDashboardManifest", () => {
61
76
  expect(parseDashboardManifest({ version: "latest", sha256: VALID_SHA, url: "https://x/y.zip" })).toBeNull();
62
77
  expect(parseDashboardManifest({ version: "1.2.3/../x", sha256: VALID_SHA, url: "https://x/y.zip" })).toBeNull();
63
78
  });
79
+
80
+ it("accepts optional platform archives while retaining the universal zip fields", () => {
81
+ expect(parseDashboardManifest({
82
+ version: "1.2.3",
83
+ sha256: VALID_SHA,
84
+ url: "https://x/dashboard.zip",
85
+ platformArchives: {
86
+ "linux-x64": { sha256: VALID_SHA.toUpperCase(), url: "https://x/dashboard-linux-x64.tar.zst" },
87
+ },
88
+ })).toEqual({
89
+ version: "1.2.3",
90
+ sha256: VALID_SHA,
91
+ url: "https://x/dashboard.zip",
92
+ platformArchives: {
93
+ "linux-x64": { sha256: VALID_SHA, url: "https://x/dashboard-linux-x64.tar.zst" },
94
+ },
95
+ });
96
+ });
97
+
98
+ it("ignores malformed platform archives so the universal zip remains usable", () => {
99
+ expect(parseDashboardManifest({
100
+ version: "1.2.3",
101
+ sha256: VALID_SHA,
102
+ url: "https://x/dashboard.zip",
103
+ platformArchives: { "linux-x64": { sha256: "bad", url: "https://x/dashboard.tar.zst" } },
104
+ })).toEqual({ version: "1.2.3", sha256: VALID_SHA, url: "https://x/dashboard.zip" });
105
+ expect(parseDashboardManifest({
106
+ version: "1.2.3",
107
+ sha256: VALID_SHA,
108
+ url: "https://x/dashboard.zip",
109
+ platformArchives: { "plan9-x64": { sha256: VALID_SHA, url: "https://x/dashboard.tar.zst" } },
110
+ })).toEqual({ version: "1.2.3", sha256: VALID_SHA, url: "https://x/dashboard.zip" });
111
+ });
112
+ });
113
+
114
+ describe("dashboard archive selection", () => {
115
+ const manifest = {
116
+ version: "1.2.3",
117
+ sha256: VALID_SHA,
118
+ url: "https://x/dashboard.zip",
119
+ platformArchives: {
120
+ "linux-x64": { sha256: "b".repeat(64), url: "https://x/dashboard-linux-x64.tar.zst" },
121
+ },
122
+ };
123
+
124
+ it("uses the platform archive when one matches", () => {
125
+ expect(selectDashboardArchive(manifest, "linux-x64", true)).toEqual({
126
+ sha256: "b".repeat(64),
127
+ url: "https://x/dashboard-linux-x64.tar.zst",
128
+ cacheSuffix: "linux-x64",
129
+ });
130
+ });
131
+
132
+ it("falls back to the universal zip for an unsupported or missing platform", () => {
133
+ expect(selectDashboardArchive(manifest, "darwin-arm64")).toEqual({
134
+ sha256: VALID_SHA,
135
+ url: "https://x/dashboard.zip",
136
+ });
137
+ expect(selectDashboardArchive(manifest, "win32-x64")).toEqual({
138
+ sha256: VALID_SHA,
139
+ url: "https://x/dashboard.zip",
140
+ });
141
+ expect(dashboardPlatformKey("linux", "x64")).toBe("linux-x64");
142
+ expect(dashboardPlatformKey("aix", "ppc64")).toBeUndefined();
143
+ });
144
+
145
+ it("falls back when zstd is unavailable at runtime", () => {
146
+ expect(hasZstdSupport({ createZstdCompress: undefined, createZstdDecompress: undefined })).toBe(false);
147
+ expect(selectDashboardArchive(manifest, "linux-x64", false)).toEqual({
148
+ sha256: VALID_SHA,
149
+ url: "https://x/dashboard.zip",
150
+ });
151
+ });
152
+
153
+ it("keeps platform-specific and universal cache directories separate", () => {
154
+ expect(dashboardVersionDir("1.2.3", "linux-x64")).not.toBe(dashboardVersionDir("1.2.3"));
155
+ expect(dashboardVersionDir("1.2.3", "linux-x64")).toContain("1.2.3--linux-x64");
156
+ });
157
+
158
+ it("never returns a platform cache from the universal lookup", () => {
159
+ const cacheRoot = mkdtempSync(join(tmpdir(), "dashboard-cache-test-"));
160
+ temporaryDirectories.push(cacheRoot);
161
+ const platformRoot = join(cacheRoot, "1.2.3--linux-x64");
162
+ mkdirSync(join(platformRoot, "apps", "dashboard"), { recursive: true });
163
+ writeFileSync(join(platformRoot, ".hexclave-complete"), "hash\n");
164
+ writeFileSync(join(platformRoot, "apps", "dashboard", "server.js"), "");
165
+
166
+ expect(latestCachedDashboard(undefined, cacheRoot)).toBeUndefined();
167
+ });
64
168
  });
65
169
 
66
170
  describe("dashboardDirOverride", () => {
@@ -3,13 +3,16 @@ import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync
3
3
  import { dirname, join } from "path";
4
4
  import { Readable } from "stream";
5
5
  import { pipeline } from "stream/promises";
6
+ import * as zlib from "node:zlib";
7
+ import * as tar from "tar";
6
8
  import extractZip from "extract-zip";
7
9
  import { devEnvStatePath } from "./dev-env-state.js";
8
10
  import { CliError, errorMessage } from "./errors.js";
9
11
 
10
- // The RDE dashboard ships as a zipped standalone build attached to a GitHub
11
- // Release rather than bundled in the CLI tarball; `hexclave dev` fetches the
12
- // newest one at runtime and caches it. Publishing side: dashboard-release.yaml.
12
+ // The development-environment dashboard ships as standalone archives attached
13
+ // to a GitHub Release rather than bundled in the CLI tarball; `hexclave dev`
14
+ // fetches the newest one at runtime and caches it. Publishing side:
15
+ // dashboard-release.yaml.
13
16
 
14
17
  const DASHBOARD_REPO = "hexclave/hexclave";
15
18
  // Floating manifest pointing at the newest build — a stable download URL (no API
@@ -30,6 +33,9 @@ const LOG_PREFIX = "[Hexclave] ";
30
33
  // `version` becomes a cache dir name and the manifest is untrusted, so require a
31
34
  // path-safe semver.
32
35
  const SAFE_VERSION_REGEX = /^v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/;
36
+ function isRecord(value: unknown): value is Record<string, unknown> {
37
+ return value != null && typeof value === "object" && !Array.isArray(value);
38
+ }
33
39
  // Don't hang forever on a slow host; a timeout falls through to the offline cache.
34
40
  const MANIFEST_FETCH_TIMEOUT_MS = 10_000;
35
41
  const DASHBOARD_DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
@@ -54,6 +60,12 @@ export type DashboardManifest = {
54
60
  version: string,
55
61
  sha256: string,
56
62
  url: string,
63
+ platformArchives?: Record<string, DashboardPlatformArchive>,
64
+ };
65
+
66
+ export type DashboardPlatformArchive = {
67
+ sha256: string,
68
+ url: string,
57
69
  };
58
70
 
59
71
  export type ResolvedDashboard = {
@@ -61,17 +73,85 @@ export type ResolvedDashboard = {
61
73
  version: string,
62
74
  };
63
75
 
76
+ export const DASHBOARD_PLATFORM_KEYS = [
77
+ "darwin-arm64",
78
+ "darwin-x64",
79
+ "linux-arm64",
80
+ "linux-x64",
81
+ "win32-arm64",
82
+ "win32-x64",
83
+ ] as const;
84
+ export type DashboardPlatformKey = (typeof DASHBOARD_PLATFORM_KEYS)[number];
85
+
64
86
  function logDashboard(message: string): void {
65
87
  console.warn(`${LOG_PREFIX}${message}`);
66
88
  }
67
89
 
68
90
  export function parseDashboardManifest(raw: unknown): DashboardManifest | null {
69
- if (raw == null || typeof raw !== "object") return null;
70
- const manifest = raw as Record<string, unknown>;
91
+ if (!isRecord(raw)) return null;
92
+ const manifest = raw;
71
93
  if (typeof manifest.version !== "string" || !SAFE_VERSION_REGEX.test(manifest.version)) return null;
72
94
  if (typeof manifest.sha256 !== "string" || !/^[0-9a-f]{64}$/i.test(manifest.sha256)) return null;
73
95
  if (typeof manifest.url !== "string" || !isAllowedDownloadUrl(manifest.url)) return null;
74
- return { version: manifest.version, sha256: manifest.sha256.toLowerCase(), url: manifest.url };
96
+ let platformArchives: Record<string, DashboardPlatformArchive> | undefined;
97
+ if (manifest.platformArchives != null) {
98
+ if (!isRecord(manifest.platformArchives)) {
99
+ return { version: manifest.version, sha256: manifest.sha256.toLowerCase(), url: manifest.url };
100
+ }
101
+ const validPlatformArchives: Record<string, DashboardPlatformArchive> = {};
102
+ for (const [platform, value] of Object.entries(manifest.platformArchives)) {
103
+ if (!DASHBOARD_PLATFORM_KEYS.some((supportedKey) => supportedKey === platform)) continue;
104
+ if (!isRecord(value)) continue;
105
+ const archive = value;
106
+ if (
107
+ typeof archive.sha256 !== "string"
108
+ || !/^[0-9a-f]{64}$/i.test(archive.sha256)
109
+ || typeof archive.url !== "string"
110
+ || !isAllowedDownloadUrl(archive.url)
111
+ ) {
112
+ continue;
113
+ }
114
+ validPlatformArchives[platform] = {
115
+ sha256: archive.sha256.toLowerCase(),
116
+ url: archive.url,
117
+ };
118
+ }
119
+ if (Object.keys(validPlatformArchives).length > 0) platformArchives = validPlatformArchives;
120
+ }
121
+ return {
122
+ version: manifest.version,
123
+ sha256: manifest.sha256.toLowerCase(),
124
+ url: manifest.url,
125
+ ...(platformArchives == null ? {} : { platformArchives }),
126
+ };
127
+ }
128
+
129
+ export function dashboardPlatformKey(
130
+ platform: NodeJS.Platform = process.platform,
131
+ arch: string = process.arch,
132
+ ): DashboardPlatformKey | undefined {
133
+ const key = `${platform}-${arch}`;
134
+ return DASHBOARD_PLATFORM_KEYS.find((supportedKey) => supportedKey === key);
135
+ }
136
+
137
+ type ZstdCapability = {
138
+ createZstdCompress?: unknown,
139
+ createZstdDecompress?: unknown,
140
+ };
141
+
142
+ export function hasZstdSupport(zlibModule: ZstdCapability = zlib): boolean {
143
+ return typeof zlibModule.createZstdCompress === "function" && typeof zlibModule.createZstdDecompress === "function";
144
+ }
145
+
146
+ export function selectDashboardArchive(
147
+ manifest: DashboardManifest,
148
+ platformKey = dashboardPlatformKey(),
149
+ zstdAvailable = hasZstdSupport(),
150
+ ): { sha256: string, url: string, cacheSuffix?: DashboardPlatformKey } {
151
+ const platformArchive = !zstdAvailable || platformKey == null ? undefined : manifest.platformArchives?.[platformKey];
152
+ return platformArchive == null
153
+ ? { sha256: manifest.sha256, url: manifest.url }
154
+ : { ...platformArchive, cacheSuffix: platformKey };
75
155
  }
76
156
 
77
157
  export function dashboardDirOverride(env: NodeJS.ProcessEnv = process.env): string | undefined {
@@ -88,12 +168,16 @@ export function dashboardCacheRoot(): string {
88
168
  return join(dirname(devEnvStatePath()), DASHBOARD_CACHE_DIR_NAME);
89
169
  }
90
170
 
91
- export function dashboardVersionDir(version: string): string {
92
- return join(dashboardCacheRoot(), version);
171
+ export function dashboardVersionDir(version: string, cacheSuffix?: DashboardPlatformKey): string {
172
+ return join(dashboardCacheRoot(), cacheSuffix == null ? version : `${version}--${cacheSuffix}`);
93
173
  }
94
174
 
95
- export function isDashboardCached(version: string): boolean {
96
- const dir = dashboardVersionDir(version);
175
+ export function isDashboardCached(
176
+ version: string,
177
+ cacheSuffix?: DashboardPlatformKey,
178
+ cacheRoot: string = dashboardCacheRoot(),
179
+ ): boolean {
180
+ const dir = join(cacheRoot, cacheSuffix == null ? version : `${version}--${cacheSuffix}`);
97
181
  return existsSync(join(dir, DASHBOARD_COMPLETE_MARKER)) && existsSync(join(dir, DASHBOARD_SERVER_RELATIVE_PATH));
98
182
  }
99
183
 
@@ -136,13 +220,29 @@ function isVersionNewer(candidate: ParsedVersion, current: ParsedVersion): boole
136
220
  return false;
137
221
  }
138
222
 
139
- export function latestCachedDashboardVersion(): string | undefined {
140
- const root = dashboardCacheRoot();
141
- if (!existsSync(root)) return undefined;
142
- const cached = readdirSync(root, { withFileTypes: true })
143
- .filter((entry) => entry.isDirectory() && isDashboardCached(entry.name))
144
- .map((entry) => entry.name);
145
- return pickLatestVersion(cached);
223
+ export function latestCachedDashboard(
224
+ platformKey?: DashboardPlatformKey,
225
+ cacheRoot: string = dashboardCacheRoot(),
226
+ ): { version: string, root: string } | undefined {
227
+ if (!existsSync(cacheRoot)) return undefined;
228
+ const suffix = platformKey == null ? "" : `--${platformKey}`;
229
+ const cached = readdirSync(cacheRoot, { withFileTypes: true })
230
+ .filter((entry) => (
231
+ entry.isDirectory()
232
+ && (platformKey == null
233
+ ? !DASHBOARD_PLATFORM_KEYS.some((supportedKey) => entry.name.endsWith(`--${supportedKey}`))
234
+ : entry.name.endsWith(suffix))
235
+ ))
236
+ .map((entry) => {
237
+ const version = suffix.length === 0 ? entry.name : entry.name.slice(0, -suffix.length);
238
+ const root = join(cacheRoot, platformKey == null ? version : `${version}--${platformKey}`);
239
+ return isDashboardCached(version, platformKey, cacheRoot)
240
+ ? { version, root }
241
+ : undefined;
242
+ })
243
+ .filter((entry): entry is { version: string, root: string } => entry != null);
244
+ const version = pickLatestVersion(cached.map((entry) => entry.version));
245
+ return version == null ? undefined : cached.find((entry) => entry.version === version);
146
246
  }
147
247
 
148
248
  export async function fetchDashboardManifest(env: NodeJS.ProcessEnv = process.env): Promise<DashboardManifest | null> {
@@ -166,52 +266,67 @@ async function sha256File(path: string): Promise<string> {
166
266
  return hash.digest("hex");
167
267
  }
168
268
 
169
- async function downloadDashboardRelease(manifest: DashboardManifest, onProgress?: (message: string) => void): Promise<void> {
269
+ async function downloadDashboardRelease(
270
+ manifest: DashboardManifest,
271
+ archive: { sha256: string, url: string, cacheSuffix?: DashboardPlatformKey },
272
+ onProgress?: (message: string) => void,
273
+ ): Promise<void> {
170
274
  const cacheRoot = dashboardCacheRoot();
171
275
  mkdirSync(cacheRoot, { recursive: true });
172
276
  // Unique temp names so parallel runs don't collide; publish is an atomic rename.
173
277
  const suffix = `${process.pid}-${randomBytes(8).toString("hex")}`;
174
- const tmpZip = join(cacheRoot, `.download-${manifest.version}-${suffix}.zip`);
278
+ const tmpArchive = join(
279
+ cacheRoot,
280
+ `.download-${manifest.version}-${suffix}${archive.cacheSuffix == null ? ".zip" : ".tar.zst"}`,
281
+ );
175
282
  const tmpDir = join(cacheRoot, `.extract-${manifest.version}-${suffix}`);
176
- const targetDir = dashboardVersionDir(manifest.version);
283
+ const targetDir = dashboardVersionDir(manifest.version, archive.cacheSuffix);
177
284
  try {
178
285
  onProgress?.(`Downloading Hexclave dashboard ${manifest.version}`);
179
- const response = await fetch(manifest.url, { redirect: "follow", signal: AbortSignal.timeout(DASHBOARD_DOWNLOAD_TIMEOUT_MS) });
286
+ const response = await fetch(archive.url, { redirect: "follow", signal: AbortSignal.timeout(DASHBOARD_DOWNLOAD_TIMEOUT_MS) });
180
287
  // The manifest URL passed isAllowedDownloadUrl, but redirects can land on a
181
288
  // different host/scheme; re-check the final URL before streaming the archive.
182
289
  if (!isAllowedDownloadUrl(response.url)) {
183
290
  throw new CliError(`Dashboard ${manifest.version} download was redirected to a disallowed URL (${response.url}).`);
184
291
  }
185
292
  if (!response.ok || response.body == null) {
186
- throw new CliError(`Failed to download dashboard ${manifest.version} (HTTP ${response.status}) from ${manifest.url}.`);
293
+ throw new CliError(`Failed to download dashboard ${manifest.version} (HTTP ${response.status}) from ${archive.url}.`);
187
294
  }
188
- await pipeline(Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]), createWriteStream(tmpZip));
295
+ await pipeline(Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]), createWriteStream(tmpArchive));
189
296
 
190
297
  onProgress?.(`Verifying Hexclave dashboard ${manifest.version}`);
191
- const digest = await sha256File(tmpZip);
192
- if (digest !== manifest.sha256) {
193
- throw new CliError(`Dashboard ${manifest.version} failed its integrity check (expected ${manifest.sha256}, got ${digest}).`);
298
+ const digest = await sha256File(tmpArchive);
299
+ if (digest !== archive.sha256) {
300
+ throw new CliError(`Dashboard ${manifest.version} failed its integrity check (expected ${archive.sha256}, got ${digest}).`);
194
301
  }
195
302
 
196
303
  rmSync(tmpDir, { recursive: true, force: true });
197
304
  mkdirSync(tmpDir, { recursive: true });
198
305
  onProgress?.(`Extracting Hexclave dashboard ${manifest.version}`);
199
- await extractZip(tmpZip, { dir: tmpDir });
306
+ if (archive.cacheSuffix == null) {
307
+ await extractZip(tmpArchive, { dir: tmpDir });
308
+ } else {
309
+ await pipeline(
310
+ createReadStream(tmpArchive),
311
+ zlib.createZstdDecompress(),
312
+ tar.x({ cwd: tmpDir, strict: true, preservePaths: false }),
313
+ );
314
+ }
200
315
  if (!existsSync(join(tmpDir, DASHBOARD_SERVER_RELATIVE_PATH))) {
201
316
  throw new CliError(`Dashboard ${manifest.version} archive is missing its server entrypoint.`);
202
317
  }
203
- writeFileSync(join(tmpDir, DASHBOARD_COMPLETE_MARKER), `${manifest.sha256}\n`);
318
+ writeFileSync(join(tmpDir, DASHBOARD_COMPLETE_MARKER), `${archive.sha256}\n`);
204
319
 
205
320
  // Publish atomically, never rmSync-ing a *valid* targetDir — a concurrent
206
321
  // `hexclave dev` may be reading it. The marker is written before the rename,
207
322
  // so any fully-published dir passes isDashboardCached.
208
- if (isDashboardCached(manifest.version)) {
323
+ if (isDashboardCached(manifest.version, archive.cacheSuffix)) {
209
324
  return;
210
325
  }
211
326
  try {
212
327
  renameSync(tmpDir, targetDir);
213
328
  } catch {
214
- if (isDashboardCached(manifest.version)) {
329
+ if (isDashboardCached(manifest.version, archive.cacheSuffix)) {
215
330
  return;
216
331
  }
217
332
  // targetDir exists but isn't valid — an interrupted publish left a partial
@@ -221,7 +336,7 @@ async function downloadDashboardRelease(manifest: DashboardManifest, onProgress?
221
336
  renameSync(tmpDir, targetDir);
222
337
  }
223
338
  } finally {
224
- rmSync(tmpZip, { force: true });
339
+ rmSync(tmpArchive, { force: true });
225
340
  rmSync(tmpDir, { recursive: true, force: true });
226
341
  }
227
342
  }
@@ -242,26 +357,27 @@ export async function resolveDashboardRuntime(opts: {
242
357
 
243
358
  const manifest = opts.manifest !== undefined ? opts.manifest : await fetchDashboardManifest();
244
359
  if (manifest != null) {
245
- if (isDashboardCached(manifest.version)) {
246
- return { root: dashboardVersionDir(manifest.version), version: manifest.version };
360
+ const archive = selectDashboardArchive(manifest);
361
+ if (isDashboardCached(manifest.version, archive.cacheSuffix)) {
362
+ return { root: dashboardVersionDir(manifest.version, archive.cacheSuffix), version: manifest.version };
247
363
  }
248
364
  try {
249
- await downloadDashboardRelease(manifest, opts.onProgress);
250
- return { root: dashboardVersionDir(manifest.version), version: manifest.version };
365
+ await downloadDashboardRelease(manifest, archive, opts.onProgress);
366
+ return { root: dashboardVersionDir(manifest.version, archive.cacheSuffix), version: manifest.version };
251
367
  } catch (error) {
252
- const cached = latestCachedDashboardVersion();
368
+ const cached = latestCachedDashboard(dashboardPlatformKey()) ?? latestCachedDashboard();
253
369
  if (cached != null) {
254
- logDashboard(`Failed to download dashboard ${manifest.version} (${errorMessage(error)}); using cached ${cached}.`);
255
- return { root: dashboardVersionDir(cached), version: cached };
370
+ logDashboard(`Failed to download dashboard ${manifest.version} (${errorMessage(error)}); using cached ${cached.version}.`);
371
+ return cached;
256
372
  }
257
373
  throw error;
258
374
  }
259
375
  }
260
376
 
261
- const cached = latestCachedDashboardVersion();
377
+ const cached = latestCachedDashboard(dashboardPlatformKey()) ?? latestCachedDashboard();
262
378
  if (cached != null) {
263
- logDashboard(`Offline: using cached Hexclave dashboard ${cached}.`);
264
- return { root: dashboardVersionDir(cached), version: cached };
379
+ logDashboard(`Offline: using cached Hexclave dashboard ${cached.version}.`);
380
+ return cached;
265
381
  }
266
382
 
267
383
  throw new CliError([