@openpalm/lib 0.11.0-rc.3 → 0.11.0-rc.6
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
|
@@ -574,14 +574,18 @@ function execFileNoThrow(
|
|
|
574
574
|
/**
|
|
575
575
|
* Compute the openpalm/voice image ref for a given GPU variant, matching
|
|
576
576
|
* the substitution chain in the addon compose file:
|
|
577
|
-
* ${OP_IMAGE_NAMESPACE:-openpalm}/voice:${OP_VOICE_IMAGE_TAG
|
|
577
|
+
* ${OP_IMAGE_NAMESPACE:-openpalm}/voice:${OP_VOICE_IMAGE_TAG:-latest-<variant>}
|
|
578
|
+
*
|
|
579
|
+
* Voice images are published OUT OF BAND (publish-voice.yml), decoupled from the
|
|
580
|
+
* platform OP_IMAGE_TAG — they are heavy and rarely change. So the default is
|
|
581
|
+
* the moving `latest-<variant>` voice tag; operators pin a specific build by
|
|
582
|
+
* setting OP_VOICE_IMAGE_TAG (e.g. `v1.0.0-cpu`).
|
|
578
583
|
*/
|
|
579
584
|
function voiceImageRef(variant: 'cpu' | 'cu121' | 'rocm6'): string {
|
|
580
585
|
const namespace = process.env.OP_IMAGE_NAMESPACE?.trim() || 'openpalm';
|
|
581
586
|
const explicit = process.env.OP_VOICE_IMAGE_TAG?.trim();
|
|
582
587
|
if (explicit) return `${namespace}/voice:${explicit}`;
|
|
583
|
-
|
|
584
|
-
return `${namespace}/voice:${baseTag}-${variant}`;
|
|
588
|
+
return `${namespace}/voice:latest-${variant}`;
|
|
585
589
|
}
|
|
586
590
|
|
|
587
591
|
/**
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from "bun:test";
|
|
2
2
|
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
-
import {
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import {
|
|
8
|
+
resolveUiBuildDir, readUiBuildVersion, UI_VERSION_STAMP,
|
|
9
|
+
seedOpenPalmDir, SKELETON_VERSION_STAMP,
|
|
10
|
+
uiUpdateChannel, checkAndUpdateUiBuild,
|
|
11
|
+
} from "./ui-assets.js";
|
|
7
12
|
|
|
8
13
|
let root = "";
|
|
9
14
|
let opHome = "";
|
|
@@ -128,3 +133,201 @@ describe("seedOpenPalmDir — version guard (P2)", () => {
|
|
|
128
133
|
expect(readFileSync(stamp(), "utf-8").trim()).toBe("v2");
|
|
129
134
|
});
|
|
130
135
|
});
|
|
136
|
+
|
|
137
|
+
// ── uiUpdateChannel ───────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
describe("uiUpdateChannel", () => {
|
|
140
|
+
it("returns 'latest' for a stable version", () => {
|
|
141
|
+
expect(uiUpdateChannel("0.11.0")).toBe("latest");
|
|
142
|
+
expect(uiUpdateChannel("1.0.0")).toBe("latest");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns 'next' for a prerelease version (contains '-')", () => {
|
|
146
|
+
expect(uiUpdateChannel("0.11.0-rc.2")).toBe("next");
|
|
147
|
+
expect(uiUpdateChannel("0.11.0-beta.5")).toBe("next");
|
|
148
|
+
expect(uiUpdateChannel("1.0.0-alpha.1")).toBe("next");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ── npm integrity verification (via checkAndUpdateUiBuild) ────────────────────
|
|
153
|
+
//
|
|
154
|
+
// We mock globalThis.fetch to avoid real network calls. The integrity paths
|
|
155
|
+
// are exercised through checkAndUpdateUiBuild → downloadNpmUiBundle (for the
|
|
156
|
+
// missing-integrity and mismatch cases) and through checkAndUpdateUiBuild
|
|
157
|
+
// returning {updated:false} early (for the up-to-date case).
|
|
158
|
+
|
|
159
|
+
/** Build a correct sha512 SRI string for the given bytes. */
|
|
160
|
+
function makeSri(data: Uint8Array): string {
|
|
161
|
+
const digest = createHash("sha512").update(data).digest("base64");
|
|
162
|
+
return `sha512-${digest}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
describe("npm integrity verification (fail-closed)", () => {
|
|
166
|
+
let savedFetch: typeof globalThis.fetch;
|
|
167
|
+
|
|
168
|
+
beforeEach(() => {
|
|
169
|
+
savedFetch = globalThis.fetch;
|
|
170
|
+
// Set forceRemote context: no local build available for these tests.
|
|
171
|
+
// (data/ui has no index.js, bundledUi dir exists but is empty → no local build)
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
afterEach(() => {
|
|
175
|
+
globalThis.fetch = savedFetch;
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("throws when the manifest has no integrity hash (fail-closed)", async () => {
|
|
179
|
+
// manifest fetch returns a version with no integrity
|
|
180
|
+
globalThis.fetch = async (_url: string | URL | Request) => {
|
|
181
|
+
const url = String(typeof _url === "string" ? _url : (_url as Request).url ?? _url);
|
|
182
|
+
if (url.includes("registry.npmjs.org")) {
|
|
183
|
+
return new Response(
|
|
184
|
+
JSON.stringify({
|
|
185
|
+
version: "0.11.0",
|
|
186
|
+
dist: { tarball: "https://registry.npmjs.org/tarball.tgz" },
|
|
187
|
+
// integrity intentionally omitted
|
|
188
|
+
}),
|
|
189
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
// tarball fetch — should NOT be reached because we throw before it
|
|
193
|
+
return new Response("not-reached", { status: 200 });
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const result = await checkAndUpdateUiBuild("0.11.0-beta.1", join(dataUi, ".."));
|
|
197
|
+
// Missing integrity → non-fatal error path
|
|
198
|
+
expect(result.updated).toBe(false);
|
|
199
|
+
expect(result.error).toMatch(/no integrity hash/i);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("throws when the tarball bytes do not match the stated integrity hash", async () => {
|
|
203
|
+
const fakeData = new Uint8Array([1, 2, 3, 4]);
|
|
204
|
+
const wrongSri = `sha512-${Buffer.from("wrong").toString("base64")}`;
|
|
205
|
+
|
|
206
|
+
globalThis.fetch = async (_url: string | URL | Request) => {
|
|
207
|
+
const url = String(typeof _url === "string" ? _url : (_url as Request).url ?? _url);
|
|
208
|
+
if (url.includes("registry.npmjs.org") && !url.includes("tarball")) {
|
|
209
|
+
return new Response(
|
|
210
|
+
JSON.stringify({
|
|
211
|
+
version: "0.99.0", // newer than anything on disk
|
|
212
|
+
dist: { tarball: "https://registry.npmjs.org/tarball.tgz", integrity: wrongSri },
|
|
213
|
+
}),
|
|
214
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
// tarball response — bytes deliberately do NOT match wrongSri
|
|
218
|
+
return new Response(fakeData, { status: 200 });
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const result = await checkAndUpdateUiBuild("0.11.0", join(dataUi, ".."));
|
|
222
|
+
expect(result.updated).toBe(false);
|
|
223
|
+
expect(result.error).toMatch(/integrity mismatch/i);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ── checkAndUpdateUiBuild ─────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
describe("checkAndUpdateUiBuild", () => {
|
|
230
|
+
let savedFetch: typeof globalThis.fetch;
|
|
231
|
+
|
|
232
|
+
beforeEach(() => {
|
|
233
|
+
savedFetch = globalThis.fetch;
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
afterEach(() => {
|
|
237
|
+
globalThis.fetch = savedFetch;
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
/** Make a minimal npm manifest response for a given version. */
|
|
241
|
+
function manifestResponse(version: string, integrity?: string) {
|
|
242
|
+
return new Response(
|
|
243
|
+
JSON.stringify({
|
|
244
|
+
version,
|
|
245
|
+
dist: {
|
|
246
|
+
tarball: "https://registry.npmjs.org/tarball.tgz",
|
|
247
|
+
...(integrity !== undefined ? { integrity } : {}),
|
|
248
|
+
},
|
|
249
|
+
}),
|
|
250
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
it("returns {updated:false} when the npm channel version is not newer than on-disk stamp", async () => {
|
|
255
|
+
// Seed data/ui with a stamped build
|
|
256
|
+
makeBuild(dataUi, "0.11.0");
|
|
257
|
+
|
|
258
|
+
globalThis.fetch = async () => manifestResponse("0.11.0"); // same version
|
|
259
|
+
const result = await checkAndUpdateUiBuild("0.11.0", join(opHome, "data"));
|
|
260
|
+
expect(result.updated).toBe(false);
|
|
261
|
+
expect(result.latestVersion).toBe("0.11.0");
|
|
262
|
+
expect(result.error).toBeUndefined();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("returns {updated:false} when the npm channel version is older than on-disk stamp", async () => {
|
|
266
|
+
makeBuild(dataUi, "0.12.0");
|
|
267
|
+
|
|
268
|
+
globalThis.fetch = async () => manifestResponse("0.11.0"); // older
|
|
269
|
+
const result = await checkAndUpdateUiBuild("0.12.0", join(opHome, "data"));
|
|
270
|
+
expect(result.updated).toBe(false);
|
|
271
|
+
expect(result.latestVersion).toBe("0.11.0");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("returns {updated:false, error} when the manifest fetch rejects (non-fatal)", async () => {
|
|
275
|
+
globalThis.fetch = async () => { throw new Error("network failure"); };
|
|
276
|
+
|
|
277
|
+
const result = await checkAndUpdateUiBuild("0.11.0", join(opHome, "data"));
|
|
278
|
+
expect(result.updated).toBe(false);
|
|
279
|
+
expect(result.latestVersion).toBeNull();
|
|
280
|
+
expect(result.error).toMatch(/network failure/i);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("returns {updated:false, error} when the registry returns a non-OK status", async () => {
|
|
284
|
+
globalThis.fetch = async () => new Response("not found", { status: 404 });
|
|
285
|
+
|
|
286
|
+
const result = await checkAndUpdateUiBuild("0.11.0", join(opHome, "data"));
|
|
287
|
+
expect(result.updated).toBe(false);
|
|
288
|
+
expect(result.error).toBeDefined();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("attempts an update when the on-disk build is unstamped (legacy data/ui)", async () => {
|
|
292
|
+
// Unstamped data/ui — cannot compare, so it should try to refresh from npm.
|
|
293
|
+
// We give it a manifest with missing integrity so it fails non-fatally
|
|
294
|
+
// (avoids needing a real tarball), but we confirm it DID attempt the download.
|
|
295
|
+
makeBuild(dataUi, null); // unstamped
|
|
296
|
+
|
|
297
|
+
let manifestFetched = false;
|
|
298
|
+
globalThis.fetch = async (_url: string | URL | Request) => {
|
|
299
|
+
const url = String(typeof _url === "string" ? _url : (_url as Request).url ?? _url);
|
|
300
|
+
if (url.includes("registry.npmjs.org")) {
|
|
301
|
+
manifestFetched = true;
|
|
302
|
+
// Return a manifest without integrity so downloadNpmUiBundle throws
|
|
303
|
+
return manifestResponse("0.11.1");
|
|
304
|
+
}
|
|
305
|
+
return new Response("", { status: 200 });
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const result = await checkAndUpdateUiBuild("0.11.0", join(opHome, "data"));
|
|
309
|
+
expect(manifestFetched).toBe(true);
|
|
310
|
+
// non-fatal: missing integrity → error path
|
|
311
|
+
expect(result.updated).toBe(false);
|
|
312
|
+
expect(result.error).toBeDefined();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("attempts an update when npm has a newer version than the on-disk stamp", async () => {
|
|
316
|
+
makeBuild(dataUi, "0.11.0");
|
|
317
|
+
|
|
318
|
+
let manifestFetched = false;
|
|
319
|
+
globalThis.fetch = async (_url: string | URL | Request) => {
|
|
320
|
+
const url = String(typeof _url === "string" ? _url : (_url as Request).url ?? _url);
|
|
321
|
+
if (url.includes("registry.npmjs.org")) {
|
|
322
|
+
manifestFetched = true;
|
|
323
|
+
return manifestResponse("0.12.0"); // newer — no integrity → non-fatal error
|
|
324
|
+
}
|
|
325
|
+
return new Response("", { status: 200 });
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const result = await checkAndUpdateUiBuild("0.11.0", join(opHome, "data"));
|
|
329
|
+
expect(manifestFetched).toBe(true);
|
|
330
|
+
expect(result.updated).toBe(false);
|
|
331
|
+
expect(result.error).toMatch(/no integrity hash/i);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
* These functions are consumed by both the CLI and the Electron shell — they
|
|
5
5
|
* must use only Node.js-compatible APIs (no Bun.spawn, Bun.write, etc.).
|
|
6
6
|
*
|
|
7
|
-
* Source resolution order (
|
|
7
|
+
* Source resolution order (UI build and .openpalm/ skeleton):
|
|
8
8
|
* 1. OPENPALM_REPO_ROOT env var — explicit dev override
|
|
9
9
|
* 2. Relative to import.meta.url — works for `bun run` / source installs
|
|
10
10
|
* 3. Relative to process.execPath — works for compiled Bun binary in repo
|
|
11
|
-
* 4. null →
|
|
11
|
+
* 4. null → remote download (the UI build from the @openpalm/ui npm registry
|
|
12
|
+
* tarball; the .openpalm skeleton from the GitHub repo tarball)
|
|
12
13
|
*/
|
|
13
14
|
import {
|
|
14
15
|
existsSync, mkdirSync, readdirSync, copyFileSync,
|
|
@@ -175,7 +176,7 @@ export async function seedOpenPalmDir(
|
|
|
175
176
|
|
|
176
177
|
/**
|
|
177
178
|
* Locate the compiled SvelteKit UI build on disk.
|
|
178
|
-
* Returns null when not found — triggers
|
|
179
|
+
* Returns null when not found — triggers the npm registry download in seedUiBuild.
|
|
179
180
|
*/
|
|
180
181
|
export function resolveLocalUiBuild(): string | null {
|
|
181
182
|
return resolveLocalCandidate(
|
|
@@ -230,7 +231,8 @@ export function readUiBuildVersion(dir: string): string | null {
|
|
|
230
231
|
* Resolve which UI build to run.
|
|
231
232
|
*
|
|
232
233
|
* Two channels exist: the bundled build (shipped inside the AppImage / source
|
|
233
|
-
* tree) and `data/ui` (operator-updatable, seeded from
|
|
234
|
+
* tree) and `data/ui` (operator-updatable, seeded from the @openpalm/ui npm
|
|
235
|
+
* registry tarball). To fix
|
|
234
236
|
* the stale-`data/ui` shadowing bug AND stay forward-compatible with updating the
|
|
235
237
|
* UI without shipping a new app (D5), selection is VERSION-AWARE:
|
|
236
238
|
*
|