@hexclave/cli 1.0.68 → 1.0.69
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/dist/index.js +101 -41
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
- package/src/lib/dashboard-release.test.ts +105 -1
- package/src/lib/dashboard-release.ts +157 -41
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hexclave/cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.69",
|
|
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
|
-
"
|
|
29
|
-
"@hexclave/
|
|
30
|
-
"@hexclave/shared": "1.0.
|
|
28
|
+
"tar": "^7.4.3",
|
|
29
|
+
"@hexclave/shared-backend": "1.0.69",
|
|
30
|
+
"@hexclave/shared": "1.0.69",
|
|
31
|
+
"@hexclave/js": "1.0.69"
|
|
31
32
|
},
|
|
32
33
|
"devDependencies": {
|
|
33
|
-
"@types/node": "
|
|
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 {
|
|
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
|
|
11
|
-
// Release rather than bundled in the CLI tarball; `hexclave dev`
|
|
12
|
-
// newest one at runtime and caches it. Publishing side:
|
|
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
|
|
70
|
-
const manifest = raw
|
|
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
|
-
|
|
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(
|
|
96
|
-
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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 ${
|
|
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(
|
|
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(
|
|
192
|
-
if (digest !==
|
|
193
|
-
throw new CliError(`Dashboard ${manifest.version} failed its integrity check (expected ${
|
|
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
|
-
|
|
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), `${
|
|
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(
|
|
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
|
-
|
|
246
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
377
|
+
const cached = latestCachedDashboard(dashboardPlatformKey()) ?? latestCachedDashboard();
|
|
262
378
|
if (cached != null) {
|
|
263
|
-
logDashboard(`Offline: using cached Hexclave dashboard ${cached}.`);
|
|
264
|
-
return
|
|
379
|
+
logDashboard(`Offline: using cached Hexclave dashboard ${cached.version}.`);
|
|
380
|
+
return cached;
|
|
265
381
|
}
|
|
266
382
|
|
|
267
383
|
throw new CliError([
|