@openpalm/lib 0.11.0-rc.2 → 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.2",
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
  *
@@ -264,35 +266,135 @@ export function resolveUiBuildDir(): string {
264
266
  }
265
267
 
266
268
  /**
267
- * Install the UI build to OP_HOME/data/ui/.
268
- *
269
- * Copies from local packages/ui/build/ when running from source,
270
- * otherwise downloads ui-build.tar.gz from the GitHub release.
271
- * Called during install and update; always replaces existing content.
272
- *
273
- * data/ui/ is automatically included in backups because
274
- * backupOpenPalmHome() copies all of OP_HOME/data/.
269
+ * The UI ships as `@openpalm/ui` on npm — a self-contained `adapter-node`
270
+ * bundle (only `build/` is published; no `node_modules` is needed at runtime,
271
+ * because the build bundles every dependency). The desktop and host updaters
272
+ * fetch the registry TARBALL over plain HTTPS and verify its integrity hash;
273
+ * they never invoke a package manager (the Electron runtime has none). npm gives
274
+ * us, for free, the four things the GitHub-release path forced us to hand-roll:
275
+ * an independent version line, `latest`/`next` dist-tag channels (prerelease-
276
+ * aware unlike `releases/latest`, which silently excludes prereleases),
277
+ * immutable versions, and a sha512 integrity we verify fail-closed.
278
+ */
279
+ const NPM_REGISTRY = 'https://registry.npmjs.org';
280
+ const UI_PACKAGE = '@openpalm/ui';
281
+
282
+ interface NpmUiManifest {
283
+ version: string;
284
+ tarball: string;
285
+ /** Subresource-integrity string ("sha512-<base64>"); null if the registry omitted it. */
286
+ integrity: string | null;
287
+ }
288
+
289
+ /** Strip a single leading 'v' so a release ref (v1.2.3) becomes an npm version (1.2.3). */
290
+ function toNpmVersion(repoRef: string): string {
291
+ return repoRef.replace(/^v/, '');
292
+ }
293
+
294
+ /**
295
+ * The npm dist-tag channel a release stream tracks: prereleases ride `next`,
296
+ * stable rides `latest`. `@openpalm/ui` is independently versioned (it publishes
297
+ * on its own `publish-ui.yml` workflow, like the channel adapters), so the
298
+ * desktop/host updaters can't compare a UI version against the app version —
299
+ * they pick the CHANNEL from the app's release stream and then resolve the
300
+ * newest UI on that channel.
275
301
  */
276
- /** SHA-256 hex digest of arbitrary bytes. */
277
- function sha256Hex(data: Uint8Array): string {
278
- return createHash('sha256').update(data).digest('hex');
302
+ export function uiUpdateChannel(appVersion: string): 'latest' | 'next' {
303
+ return appVersion.includes('-') ? 'next' : 'latest';
279
304
  }
280
305
 
281
306
  /**
282
- * Parse a `sha256sum`-format checksums file into a filename→hash map.
283
- * Each line is: `<hash> <filename>` (one or two spaces).
307
+ * Resolve the npm manifest for `@openpalm/ui` by exact version OR dist-tag.
308
+ * `GET <registry>/@openpalm/ui/<version-or-tag>` returns the abbreviated
309
+ * manifest (version + dist.tarball + dist.integrity). Throws on non-OK.
284
310
  */
285
- function parseChecksumsFile(content: string): Map<string, string> {
286
- const map = new Map<string, string>();
287
- for (const line of content.trim().split('\n')) {
288
- const parts = line.trim().split(/\s+/);
289
- if (parts.length >= 2) {
290
- map.set(parts[parts.length - 1], parts[0]);
311
+ async function fetchNpmUiManifest(versionOrTag: string): Promise<NpmUiManifest> {
312
+ const url = `${NPM_REGISTRY}/${UI_PACKAGE}/${versionOrTag}`;
313
+ const res = await fetchWithRetry(url);
314
+ if (!res.ok) throw new Error(`npm registry returned HTTP ${res.status} for ${UI_PACKAGE}@${versionOrTag}`);
315
+ const m = await res.json() as { version?: string; dist?: { tarball?: string; integrity?: string } };
316
+ if (!m.version || !m.dist?.tarball) {
317
+ throw new Error(`npm manifest for ${UI_PACKAGE}@${versionOrTag} is missing version/dist.tarball`);
318
+ }
319
+ return { version: m.version, tarball: m.dist.tarball, integrity: m.dist.integrity ?? null };
320
+ }
321
+
322
+ /**
323
+ * Verify a Subresource-Integrity string against the bytes. FAIL-CLOSED: a
324
+ * present-but-wrong hash throws (the corruption / tamper case). A registry that
325
+ * omits the hash entirely (legacy metadata) is logged and allowed — modern npm
326
+ * always provides one, so this only affects pathological registry responses.
327
+ */
328
+ function verifyNpmIntegrity(data: Uint8Array, integrity: string): void {
329
+ const entries = integrity.trim().split(/\s+/);
330
+ const entry = entries.find(e => e.startsWith('sha512-')) ?? entries.find(e => e.startsWith('sha256-'));
331
+ if (!entry) throw new Error(`unrecognized integrity format: ${integrity}`);
332
+ const dash = entry.indexOf('-');
333
+ const algo = entry.slice(0, dash);
334
+ const expected = entry.slice(dash + 1);
335
+ const actual = createHash(algo).update(data).digest('base64');
336
+ if (actual !== expected) throw new Error(`UI bundle integrity mismatch (${algo})`);
337
+ }
338
+
339
+ /**
340
+ * Download `@openpalm/ui`'s npm tarball, verify integrity, and install its
341
+ * `build/` contents into `uiDir`. npm tarballs nest everything under `package/`
342
+ * and we publish `files: ["build"]`, so the bundle lives at `package/build/**` —
343
+ * strip 2 path components and filter to that subtree.
344
+ *
345
+ * FAIL-CLOSED + non-destructive: we throw if integrity is missing or mismatched
346
+ * (the contract is that npm always provides a sha512), and we extract into a
347
+ * STAGING dir and validate it has a runnable `index.js` before swapping it over
348
+ * `uiDir` — so a truncated download or bad tarball never leaves `uiDir` empty.
349
+ */
350
+ async function downloadNpmUiBundle(manifest: NpmUiManifest, uiDir: string, dataDir: string): Promise<void> {
351
+ const res = await fetchWithRetry(manifest.tarball);
352
+ if (!res.ok) throw new Error(`Failed to download UI bundle (HTTP ${res.status})`);
353
+ const data = new Uint8Array(await res.arrayBuffer());
354
+
355
+ // Verify BEFORE touching anything. Fail closed: a missing hash is treated as a
356
+ // verification failure, not a warning — modern npm always supplies dist.integrity,
357
+ // so its absence means a non-canonical/altered registry response.
358
+ if (!manifest.integrity) {
359
+ throw new Error(`npm manifest for ${UI_PACKAGE}@${manifest.version} has no integrity hash — refusing to install unverified`);
360
+ }
361
+ verifyNpmIntegrity(data, manifest.integrity);
362
+ logger.debug('UI bundle integrity verified', { version: manifest.version });
363
+
364
+ const tmpTar = join(dataDir, '.ui-build.tgz.tmp');
365
+ const staging = join(dataDir, '.ui-build.staging');
366
+ try {
367
+ rmSync(staging, { recursive: true, force: true });
368
+ mkdirSync(staging, { recursive: true });
369
+ writeFileSync(tmpTar, data);
370
+ await tarExtract({
371
+ file: tmpTar,
372
+ cwd: staging,
373
+ strip: 2,
374
+ filter: (p) => p.startsWith('package/build/'),
375
+ });
376
+ // Validate the staged build is runnable before destroying the live one.
377
+ if (!existsSync(join(staging, 'index.js'))) {
378
+ throw new Error('downloaded UI bundle is missing build/index.js');
291
379
  }
380
+ // Swap: only now do we remove the existing build and move staging into place.
381
+ rmSync(uiDir, { recursive: true, force: true });
382
+ renameSync(staging, uiDir);
383
+ } finally {
384
+ rmSync(tmpTar, { force: true });
385
+ rmSync(staging, { recursive: true, force: true });
292
386
  }
293
- return map;
294
387
  }
295
388
 
389
+ /**
390
+ * Install the UI build to OP_HOME/data/ui/.
391
+ *
392
+ * Copies from the local/bundled `packages/ui/build/` when available, otherwise
393
+ * downloads the `@openpalm/ui` bundle from npm (by exact version; `repoRef` may
394
+ * be a tag like `latest`/`next` via the admin route). Always replaces existing
395
+ * content. data/ui/ is included in backups because backupOpenPalmHome() copies
396
+ * all of OP_HOME/data/.
397
+ */
296
398
  export async function seedUiBuild(repoRef: string, dataDir: string, options?: { forceRemote?: boolean }): Promise<void> {
297
399
  const uiDir = join(dataDir, 'ui');
298
400
  mkdirSync(uiDir, { recursive: true });
@@ -301,54 +403,23 @@ export async function seedUiBuild(repoRef: string, dataDir: string, options?: {
301
403
  if (local) {
302
404
  logger.debug('seeding UI build from local source', { src: local });
303
405
  copyTree(local, uiDir);
406
+ // The build script (stamp-version.mjs) writes .openpalm-ui-version into build/.
407
+ // A local build missing it would seed an UNSTAMPED data/ui, which makes the
408
+ // update check unable to read the running UI version. Surface it loudly rather
409
+ // than silently degrade update behavior.
410
+ if (!readUiBuildVersion(uiDir)) {
411
+ logger.warn('seeded UI build has no version stamp — auto-update comparison will be unreliable', { src: local });
412
+ }
304
413
  return;
305
414
  }
306
415
 
307
- const base = `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/${repoRef}`;
308
- const tarballUrl = `${base}/ui-build.tar.gz`;
309
- const checksumUrl = `${base}/checksums-sha256.txt`;
310
- logger.debug('downloading UI build', { url: tarballUrl });
311
-
312
- const tmpTar = join(dataDir, '.ui-build.tar.gz.tmp');
313
- try {
314
- // Download tarball and checksums file in parallel (checksums best-effort)
315
- const [tarRes, csRes] = await Promise.all([
316
- fetchWithRetry(tarballUrl),
317
- fetchWithRetry(checksumUrl).catch(() => null),
318
- ]);
319
- if (!tarRes.ok) throw new Error(`Failed to download UI build (HTTP ${tarRes.status})`);
320
-
321
- const tarData = new Uint8Array(await tarRes.arrayBuffer());
322
-
323
- // Verify SHA-256 if the checksums file was available
324
- if (csRes?.ok) {
325
- const checksums = parseChecksumsFile(await csRes.text());
326
- const expected = checksums.get('ui-build.tar.gz');
327
- if (expected) {
328
- const actual = sha256Hex(tarData);
329
- if (actual !== expected) {
330
- throw new Error(`UI build checksum mismatch (expected ${expected}, got ${actual})`);
331
- }
332
- logger.debug('UI build checksum verified', { sha256: actual });
333
- }
334
- }
335
-
336
- writeFileSync(tmpTar, tarData);
337
-
338
- // Clear stale files before extracting so old build files don't persist
339
- rmSync(uiDir, { recursive: true, force: true });
340
- mkdirSync(uiDir, { recursive: true });
341
- // Cross-platform extraction via the `tar` npm package — no shell dependency
342
- await tarExtract({ file: tmpTar, cwd: uiDir, strip: 1 });
343
- } finally {
344
- rmSync(tmpTar, { force: true });
345
- }
416
+ const manifest = await fetchNpmUiManifest(toNpmVersion(repoRef));
417
+ logger.debug('downloading UI build from npm', { version: manifest.version });
418
+ await downloadNpmUiBundle(manifest, uiDir, dataDir);
346
419
  }
347
420
 
348
421
  // ── UI update check ──────────────────────────────────────────────────────────
349
422
 
350
- const GITHUB_API = 'https://api.github.com';
351
-
352
423
  /** Returns 1 if a > b, -1 if a < b, 0 if equal. Strips leading 'v'. Handles pre-release tags. */
353
424
  function compareVersionTags(a: string, b: string): number {
354
425
  const parse = (v: string): [number, number, number, string | null] => {
@@ -398,48 +469,50 @@ export interface UiBuildUpdateResult {
398
469
  }
399
470
 
400
471
  /**
401
- * Check GitHub for a newer UI build and apply it if one exists.
472
+ * Check npm for a newer `@openpalm/ui` build and apply it if one exists.
473
+ *
474
+ * `@openpalm/ui` is INDEPENDENTLY versioned, so we do NOT compare against the
475
+ * app/platform version. We pick the dist-tag CHANNEL from the app's release
476
+ * stream (`appVersion`: prerelease → `next`, stable → `latest`) and compare the
477
+ * newest UI on that channel against the version actually on disk (the stamp in
478
+ * the resolved build). This tracks prerelease UIs for prerelease apps and fixes
479
+ * the `releases/latest`-excludes-prereleases blind spot.
402
480
  *
403
481
  * When an update is available:
404
482
  * 1. Move data/ui/ → data/backups/ui-{timestamp}/ (preserves the old build)
405
- * 2. Download ui-build.tar.gz from the latest release and extract to data/ui/
483
+ * 2. Download the npm bundle (integrity-verified) and extract to data/ui/
406
484
  *
407
485
  * Non-fatal: any network or extraction error returns { updated: false, error }.
408
486
  * The caller should proceed with the existing build on failure.
409
487
  */
410
488
  export async function checkAndUpdateUiBuild(
411
- currentVersion: string,
489
+ appVersion: string,
412
490
  dataDir: string,
413
491
  ): Promise<UiBuildUpdateResult> {
414
492
  try {
415
- const res = await fetch(
416
- `${GITHUB_API}/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
417
- {
418
- headers: { 'User-Agent': `OpenPalm/${currentVersion}` },
419
- signal: AbortSignal.timeout(10_000),
420
- },
421
- );
422
- if (!res.ok) {
423
- return { updated: false, latestVersion: null, error: `GitHub API returned ${res.status}` };
424
- }
425
-
426
- const release = await res.json() as {
427
- tag_name: string;
428
- assets: Array<{ name: string }>;
429
- };
430
- const latestTag = release.tag_name; // e.g. "v0.11.0"
431
- const latestVersion = latestTag.replace(/^v/, '');
432
-
433
- if (compareVersionTags(latestTag, currentVersion) <= 0) {
434
- logger.debug('UI build is up to date', { current: currentVersion, latest: latestVersion });
493
+ const channel = uiUpdateChannel(appVersion);
494
+ const manifest = await fetchNpmUiManifest(channel);
495
+ const latestVersion = manifest.version;
496
+
497
+ // Compare against the UI build currently on disk, NOT the app version — the
498
+ // UI floats on its own version line, so the platform/app version is not
499
+ // comparable. If the build is unstamped (e.g. a legacy data/ui seeded by the
500
+ // old GitHub-asset path before npm distribution), we cannot compare, so we
501
+ // refresh once from npm — the npm bundle is stamped, so it self-heals to the
502
+ // normal compare path on the next launch. Do NOT fall back to appVersion:
503
+ // comparing two independent version lines silently suppresses real updates.
504
+ const currentUiVersion = readUiBuildVersion(resolveUiBuildDir());
505
+
506
+ if (currentUiVersion && compareVersionTags(latestVersion, currentUiVersion) <= 0) {
507
+ logger.debug('UI build is up to date', { currentUi: currentUiVersion, latest: latestVersion, channel });
435
508
  return { updated: false, latestVersion };
436
509
  }
437
-
438
- if (!release.assets.some(a => a.name === 'ui-build.tar.gz')) {
439
- return { updated: false, latestVersion, error: 'Latest release has no ui-build.tar.gz' };
510
+ if (!currentUiVersion) {
511
+ logger.debug('UI build is unstamped — refreshing from npm to re-establish a known version', { latest: latestVersion, channel });
440
512
  }
441
513
 
442
- // Back up the existing UI build before replacing it
514
+ // Back up the existing UI build before replacing it. (Automatic rollback on
515
+ // a failed start is deferred — see ui-distribution-gap-analysis.md G1.)
443
516
  const uiDir = join(dataDir, 'ui');
444
517
  if (existsSync(join(uiDir, 'index.js'))) {
445
518
  const backupDir = join(resolveBackupsDir(), `ui-${Date.now()}`);
@@ -448,8 +521,8 @@ export async function checkAndUpdateUiBuild(
448
521
  logger.debug('backed up UI build before update', { backup: backupDir });
449
522
  }
450
523
 
451
- await seedUiBuild(latestTag, dataDir);
452
- logger.debug('UI build updated', { from: currentVersion, to: latestVersion });
524
+ await downloadNpmUiBundle(manifest, uiDir, dataDir);
525
+ logger.debug('UI build updated', { from: currentUiVersion ?? '(unstamped)', to: latestVersion });
453
526
 
454
527
  return { updated: true, latestVersion };
455
528
  } catch (err) {
package/src/index.ts CHANGED
@@ -380,4 +380,5 @@ export {
380
380
  resolveUiBuildDir,
381
381
  seedUiBuild,
382
382
  checkAndUpdateUiBuild,
383
+ uiUpdateChannel,
383
384
  } from "./control-plane/ui-assets.js";