@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpalm/lib",
3
- "version": "0.11.0-rc.3",
3
+ "version": "0.11.0-rc.6",
4
4
  "license": "MPL-2.0",
5
5
  "type": "module",
6
6
  "description": "Shared control-plane library for OpenPalm — lifecycle, staging, secrets, channels, connections, scheduler",
@@ -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:-${OP_IMAGE_TAG:-latest}-<variant>}
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
- const baseTag = process.env.OP_IMAGE_TAG?.trim() || 'latest';
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 { resolveUiBuildDir, readUiBuildVersion, UI_VERSION_STAMP, seedOpenPalmDir, SKELETON_VERSION_STAMP } from "./ui-assets.js";
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 (same for UI build and .openpalm/):
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 → GitHub release download
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 GitHub download in seedUiBuild.
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 GitHub releases). To fix
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
  *