@openpalm/lib 0.10.1 → 0.11.0-beta.1

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.
Files changed (55) hide show
  1. package/README.md +2 -2
  2. package/package.json +7 -3
  3. package/src/control-plane/admin-token.ts +73 -0
  4. package/src/control-plane/akm-vault.test.ts +108 -0
  5. package/src/control-plane/akm-vault.ts +307 -0
  6. package/src/control-plane/audit.ts +3 -2
  7. package/src/control-plane/channels.ts +3 -3
  8. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  9. package/src/control-plane/compose-args.test.ts +25 -21
  10. package/src/control-plane/config-persistence.ts +103 -64
  11. package/src/control-plane/core-assets.test.ts +104 -0
  12. package/src/control-plane/core-assets.ts +54 -57
  13. package/src/control-plane/docker.ts +55 -21
  14. package/src/control-plane/env.test.ts +25 -1
  15. package/src/control-plane/env.ts +80 -0
  16. package/src/control-plane/home.ts +66 -69
  17. package/src/control-plane/host-opencode.test.ts +263 -0
  18. package/src/control-plane/host-opencode.ts +229 -0
  19. package/src/control-plane/install-edge-cases.test.ts +182 -244
  20. package/src/control-plane/install-lock.ts +157 -0
  21. package/src/control-plane/lifecycle.ts +57 -56
  22. package/src/control-plane/markdown-task.ts +200 -0
  23. package/src/control-plane/paths.ts +75 -0
  24. package/src/control-plane/provider-config.ts +2 -2
  25. package/src/control-plane/provider-models.ts +154 -0
  26. package/src/control-plane/registry-components.test.ts +102 -25
  27. package/src/control-plane/registry.test.ts +49 -47
  28. package/src/control-plane/registry.ts +71 -50
  29. package/src/control-plane/rollback.ts +17 -16
  30. package/src/control-plane/scheduler.ts +75 -262
  31. package/src/control-plane/secret-backend.test.ts +98 -108
  32. package/src/control-plane/secret-backend.ts +221 -181
  33. package/src/control-plane/secret-mappings.ts +3 -6
  34. package/src/control-plane/secrets.ts +83 -47
  35. package/src/control-plane/setup-config.schema.json +2 -14
  36. package/src/control-plane/setup-status.ts +4 -29
  37. package/src/control-plane/setup-validation.ts +21 -21
  38. package/src/control-plane/setup.test.ts +122 -227
  39. package/src/control-plane/setup.ts +224 -125
  40. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  41. package/src/control-plane/spec-to-env.test.ts +59 -58
  42. package/src/control-plane/spec-to-env.ts +39 -140
  43. package/src/control-plane/spec-validator.ts +2 -99
  44. package/src/control-plane/stack-spec.test.ts +21 -77
  45. package/src/control-plane/stack-spec.ts +7 -83
  46. package/src/control-plane/types.ts +17 -15
  47. package/src/control-plane/ui-assets.ts +349 -0
  48. package/src/control-plane/validate.ts +44 -79
  49. package/src/index.ts +77 -44
  50. package/src/logger.test.ts +228 -0
  51. package/src/logger.ts +71 -1
  52. package/src/provider-constants.ts +22 -1
  53. package/src/control-plane/env-schema-validation.test.ts +0 -118
  54. package/src/control-plane/memory-config.ts +0 -298
  55. package/src/control-plane/redact-schema.ts +0 -50
@@ -1,70 +1,19 @@
1
1
  /**
2
2
  * Stack specification file (stack.yml) management.
3
3
  *
4
- * The stack spec is a YAML document that captures the high-level
5
- * configuration of an OpenPalm installation: capabilities only.
6
- * It lives in CONFIG_HOME.
4
+ * The stack spec is a YAML document used as a version marker for the
5
+ * OpenPalm installation schema. AI provider configuration lives in
6
+ * config/akm/config.json (managed via the admin AKM tab).
7
7
  *
8
- * v2: Capabilities-based schema. No connections array capabilities
9
- * carry their own provider info.
8
+ * v2: capabilities removed LLM/embedding now live in akm config.
10
9
  */
11
10
  import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
12
11
  import { stringify as yamlStringify, parse as yamlParse } from "yaml";
13
12
 
14
- // ── Capability Types ────────────────────────────────────────────────────
15
-
16
- export type StackSpecEmbeddings = {
17
- provider: string;
18
- model: string;
19
- dims: number;
20
- };
21
-
22
- export type StackSpecMemory = {
23
- userId: string;
24
- customInstructions?: string;
25
- };
26
-
27
- export type StackSpecTts = {
28
- enabled: boolean;
29
- provider?: string;
30
- model?: string;
31
- voice?: string;
32
- format?: string;
33
- };
34
-
35
- export type StackSpecStt = {
36
- enabled: boolean;
37
- provider?: string;
38
- model?: string;
39
- language?: string;
40
- };
41
-
42
- export type StackSpecReranker = {
43
- enabled: boolean;
44
- provider?: string;
45
- mode?: "llm" | "dedicated";
46
- model?: string;
47
- topK?: number;
48
- topN?: number;
49
- };
50
-
51
- export type StackSpecCapabilities = {
52
- /** Primary LLM: "provider/model" */
53
- llm: string;
54
- /** Small/fast model: "provider/model" */
55
- slm?: string;
56
- embeddings: StackSpecEmbeddings;
57
- memory: StackSpecMemory;
58
- tts?: StackSpecTts;
59
- stt?: StackSpecStt;
60
- reranking?: StackSpecReranker;
61
- };
62
-
63
13
  // ── StackSpec v2 ────────────────────────────────────────────────────────
64
14
 
65
15
  export type StackSpec = {
66
16
  version: 2;
67
- capabilities: StackSpecCapabilities;
68
17
  };
69
18
 
70
19
  // ── Constants ───────────────────────────────────────────────────────────
@@ -76,7 +25,6 @@ export const SPEC_DEFAULTS = {
76
25
  assistant: 3800,
77
26
  admin: 3880,
78
27
  adminOpencode: 3881,
79
- memory: 3898,
80
28
  guardian: 3899,
81
29
  assistantSsh: 2222,
82
30
  },
@@ -86,20 +34,6 @@ export const SPEC_DEFAULTS = {
86
34
  },
87
35
  } as const;
88
36
 
89
- // ── Capability Helpers ──────────────────────────────────────────────────
90
-
91
- /** Parse a "provider/model" capability string into parts */
92
- export function parseCapabilityString(cap: string): { provider: string; model: string } {
93
- const idx = cap.indexOf("/");
94
- if (idx < 0) return { provider: cap, model: "" };
95
- return { provider: cap.slice(0, idx), model: cap.slice(idx + 1) };
96
- }
97
-
98
- /** Format provider + model into a capability string */
99
- export function formatCapabilityString(provider: string, model: string): string {
100
- return `${provider}/${model}`;
101
- }
102
-
103
37
  // ── Read / Write ────────────────────────────────────────────────────────
104
38
 
105
39
  export function stackSpecPath(configDir: string): string {
@@ -113,7 +47,8 @@ export function writeStackSpec(configDir: string, spec: StackSpec): void {
113
47
  }
114
48
 
115
49
  /**
116
- * Read the stack spec. Returns null for missing, corrupt, or unrecognized version files.
50
+ * Read the stack spec. Returns null for missing or corrupt files.
51
+ * Only the version field is checked; legacy capability fields are ignored.
117
52
  */
118
53
  export function readStackSpec(configDir: string): StackSpec | null {
119
54
  const path = stackSpecPath(configDir);
@@ -128,16 +63,5 @@ export function readStackSpec(configDir: string): StackSpec | null {
128
63
  if (typeof raw !== "object" || raw === null) return null;
129
64
  const obj = raw as Record<string, unknown>;
130
65
  if (obj.version !== 2) return null;
131
- if (typeof obj.capabilities !== "object" || obj.capabilities === null) return null;
132
- return obj as unknown as StackSpec;
133
- }
134
-
135
- /**
136
- * Update a single capability key in the stack spec.
137
- */
138
- export function updateCapability(configDir: string, key: string, value: unknown): void {
139
- const spec = readStackSpec(configDir);
140
- if (!spec) throw new Error("stack.yml not found or invalid");
141
- (spec.capabilities as Record<string, unknown>)[key] = value;
142
- writeStackSpec(configDir, spec);
66
+ return { version: 2 };
143
67
  }
@@ -6,14 +6,17 @@
6
6
 
7
7
  export type CoreServiceName =
8
8
  | "assistant"
9
- | "guardian"
10
- | "memory"
11
- | "scheduler";
9
+ | "guardian";
12
10
 
13
- export type OptionalServiceName = "admin" | "docker-socket-proxy";
11
+ export type OptionalServiceName = never;
14
12
 
15
13
  export type AccessScope = "host" | "lan";
16
14
  export type CallerType = "assistant" | "cli" | "ui" | "system" | "test" | "unknown";
15
+ export type AuditContext = {
16
+ actor: string;
17
+ requestId?: string;
18
+ callerType?: CallerType;
19
+ };
17
20
 
18
21
  /** Info about a discovered channel */
19
22
  export type ChannelInfo = {
@@ -41,13 +44,13 @@ export type ArtifactMeta = {
41
44
  export type ControlPlaneState = {
42
45
  adminToken: string;
43
46
  assistantToken: string;
44
- setupToken: string;
45
47
  homeDir: string;
46
48
  configDir: string;
47
- vaultDir: string;
48
- dataDir: string;
49
- logsDir: string;
50
- cacheDir: string;
49
+ stashDir: string; // homeDir/stash
50
+ workspaceDir: string; // homeDir/workspace
51
+ cacheDir: string; // homeDir/cache (regenerable/semi-persistent data)
52
+ stateDir: string; // homeDir/state (service data + system state)
53
+ stackDir: string; // configDir/stack (compose runtime + stack config)
51
54
  services: Record<string, "running" | "stopped">;
52
55
  artifacts: {
53
56
  compose: string;
@@ -58,14 +61,13 @@ export type ControlPlaneState = {
58
61
 
59
62
  // ── Constants ──────────────────────────────────────────────────────────
60
63
 
64
+ // Scheduler is no longer a separate service — it runs as a co-process inside
65
+ // the assistant container. See core/assistant/entrypoint.sh.
66
+ // Memory has been replaced by the akm-cli stash (shared with assistant).
61
67
  export const CORE_SERVICES: CoreServiceName[] = [
62
- "memory",
63
68
  "assistant",
64
69
  "guardian",
65
- "scheduler",
66
70
  ];
67
71
 
68
- export const OPTIONAL_SERVICES: OptionalServiceName[] = [
69
- "admin",
70
- "docker-socket-proxy",
71
- ];
72
+ export const OPTIONAL_SERVICES: OptionalServiceName[] = [];
73
+
@@ -0,0 +1,349 @@
1
+ /**
2
+ * Runtime asset seeding and resolution for the UI build and OP_HOME skeleton.
3
+ *
4
+ * These functions are consumed by both the CLI and the Electron shell — they
5
+ * must use only Node.js-compatible APIs (no Bun.spawn, Bun.write, etc.).
6
+ *
7
+ * Source resolution order (same for UI build and .openpalm/):
8
+ * 1. OPENPALM_REPO_ROOT env var — explicit dev override
9
+ * 2. Relative to import.meta.url — works for `bun run` / source installs
10
+ * 3. Relative to process.execPath — works for compiled Bun binary in repo
11
+ * 4. null → GitHub release download
12
+ */
13
+ import {
14
+ existsSync, mkdirSync, readdirSync, copyFileSync,
15
+ writeFileSync, rmSync, realpathSync, renameSync,
16
+ } from 'node:fs';
17
+ import { join, dirname, relative } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { createHash } from 'node:crypto';
20
+ import { x as tarExtract } from 'tar';
21
+ import { resolveStateDir } from './home.js';
22
+ import { createLogger } from '../logger.js';
23
+
24
+ const logger = createLogger('lib:ui-assets');
25
+
26
+ const REPO_OWNER = 'itlackey';
27
+ const REPO_NAME = 'openpalm';
28
+
29
+ // ── Private helpers ──────────────────────────────────────────────────────────
30
+
31
+ async function fetchWithRetry(url: string, retries = 3): Promise<Response> {
32
+ for (let i = 0; i < retries; i++) {
33
+ try {
34
+ const res = await fetch(url, { signal: AbortSignal.timeout(60_000) });
35
+ if (res.ok || res.status < 500) return res;
36
+ if (i < retries - 1) await new Promise(r => setTimeout(r, 200 * 2 ** i));
37
+ } catch (err) {
38
+ if (i === retries - 1) throw err;
39
+ await new Promise(r => setTimeout(r, 200 * 2 ** i));
40
+ }
41
+ }
42
+ throw new Error(`Failed to fetch ${url} after ${retries} attempts`);
43
+ }
44
+
45
+ function copyTree(
46
+ src: string,
47
+ dest: string,
48
+ opts?: { skipExisting?: boolean },
49
+ ): void {
50
+ if (!existsSync(src)) return;
51
+ const entries = readdirSync(src, { recursive: true, withFileTypes: true });
52
+ for (const entry of entries) {
53
+ if (!entry.isFile()) continue;
54
+ const parentDir = (entry as unknown as { parentPath?: string; path?: string }).parentPath
55
+ ?? (entry as unknown as { path: string }).path;
56
+ const srcFile = join(parentDir, entry.name);
57
+ const rel = relative(src, srcFile);
58
+ const destFile = join(dest, rel);
59
+ if (opts?.skipExisting && existsSync(destFile)) continue;
60
+ mkdirSync(dirname(destFile), { recursive: true });
61
+ copyFileSync(srcFile, destFile);
62
+ }
63
+ }
64
+
65
+ /** Resolve a candidate path using three strategies, returning the first that exists. */
66
+ function resolveLocalCandidate(
67
+ ...strategies: Array<() => string | null>
68
+ ): string | null {
69
+ for (const strategy of strategies) {
70
+ try {
71
+ const p = strategy();
72
+ if (p && existsSync(p)) return p;
73
+ } catch { /* skip */ }
74
+ }
75
+ return null;
76
+ }
77
+
78
+ // ── .openpalm/ skeleton ──────────────────────────────────────────────────────
79
+
80
+ /**
81
+ * Locate the repo's .openpalm/ skeleton directory.
82
+ * Used by seedOpenPalmDir to avoid a network download when running from source.
83
+ */
84
+ export function resolveLocalOpenpalmDir(): string | null {
85
+ return resolveLocalCandidate(
86
+ // 1. Explicit dev override
87
+ () => process.env.OPENPALM_REPO_ROOT
88
+ ? join(process.env.OPENPALM_REPO_ROOT, '.openpalm')
89
+ : null,
90
+ // 2. Relative to this source file (dev / bun run)
91
+ () => {
92
+ const meta = fileURLToPath(import.meta.url);
93
+ if (meta.startsWith('/$bunfs/')) return null;
94
+ return join(dirname(meta), '..', '..', '..', '..', '.openpalm');
95
+ },
96
+ // 3. Relative to the compiled binary on disk
97
+ () => join(dirname(realpathSync(process.execPath)), '..', '..', '..', '.openpalm'),
98
+ );
99
+ }
100
+
101
+ /**
102
+ * Seed OP_HOME from the .openpalm/ skeleton.
103
+ *
104
+ * Existing files are never overwritten (user edits win).
105
+ * Falls back to downloading the repo tarball from GitHub when no local
106
+ * skeleton is found (production binary, packaged Electron app).
107
+ */
108
+ export async function seedOpenPalmDir(
109
+ repoRef: string,
110
+ homeDir: string,
111
+ _configDir: string,
112
+ _stateDir: string,
113
+ ): Promise<void> {
114
+ const local = resolveLocalOpenpalmDir();
115
+ if (local) {
116
+ logger.debug('seeding .openpalm from local source', { src: local });
117
+ copyTree(local, homeDir, { skipExisting: true });
118
+ // Registry is system-managed — always refresh so addon overlays stay current.
119
+ copyTree(join(local, 'state', 'registry'), join(homeDir, 'state', 'registry'));
120
+ return;
121
+ }
122
+
123
+ const tarballUrl = `https://github.com/${REPO_OWNER}/${REPO_NAME}/archive/${repoRef}.tar.gz`;
124
+ logger.debug('downloading .openpalm skeleton', { url: tarballUrl });
125
+
126
+ const tmpDir = join(homeDir, '.seed-tmp');
127
+ const tmpTar = join(tmpDir, 'repo.tar.gz');
128
+ mkdirSync(tmpDir, { recursive: true });
129
+
130
+ try {
131
+ const res = await fetchWithRetry(tarballUrl);
132
+ if (!res.ok) throw new Error(`Failed to download tarball (HTTP ${res.status})`);
133
+ writeFileSync(tmpTar, new Uint8Array(await res.arrayBuffer()));
134
+
135
+ await tarExtract({ file: tmpTar, cwd: tmpDir, strip: 1 });
136
+
137
+ const srcOpenpalm = join(tmpDir, '.openpalm');
138
+ if (!existsSync(srcOpenpalm)) throw new Error('.openpalm/ not found in tarball');
139
+ copyTree(srcOpenpalm, homeDir, { skipExisting: true });
140
+ // Registry is system-managed — always refresh so addon overlays stay current.
141
+ copyTree(join(srcOpenpalm, 'state', 'registry'), join(homeDir, 'state', 'registry'));
142
+ } finally {
143
+ rmSync(tmpDir, { recursive: true, force: true });
144
+ }
145
+ }
146
+
147
+ // ── UI build ─────────────────────────────────────────────────────────────────
148
+
149
+ /**
150
+ * Locate the compiled SvelteKit UI build on disk.
151
+ * Returns null when not found — triggers GitHub download in seedUiBuild.
152
+ */
153
+ export function resolveLocalUiBuild(): string | null {
154
+ return resolveLocalCandidate(
155
+ // 1. Explicit dev override
156
+ () => process.env.OPENPALM_REPO_ROOT
157
+ ? join(process.env.OPENPALM_REPO_ROOT, 'packages', 'ui', 'build')
158
+ : null,
159
+ // 2. Relative to this source file (dev / bun run)
160
+ () => {
161
+ const meta = fileURLToPath(import.meta.url);
162
+ if (meta.startsWith('/$bunfs/')) return null;
163
+ // lib source: packages/lib/src/control-plane/ui-assets.ts → 5 levels up
164
+ const candidate = join(dirname(meta), '..', '..', '..', '..', 'packages', 'ui', 'build');
165
+ return existsSync(join(candidate, 'index.js')) ? candidate : null;
166
+ },
167
+ // 3. Relative to compiled binary / Electron executable
168
+ () => {
169
+ const binDir = dirname(realpathSync(process.execPath));
170
+ const candidate = join(binDir, '..', '..', '..', 'packages', 'ui', 'build');
171
+ return existsSync(join(candidate, 'index.js')) ? candidate : null;
172
+ },
173
+ );
174
+ }
175
+
176
+ /**
177
+ * Resolve the best available UI build directory at runtime.
178
+ *
179
+ * Priority:
180
+ * 1. OP_HOME/state/ui/ — installed by seedUiBuild (production)
181
+ * 2. Local packages/ui/build/ — dev / source install fallback
182
+ */
183
+ export function resolveUiBuildDir(): string {
184
+ const stateBuild = join(resolveStateDir(), 'ui');
185
+ if (existsSync(join(stateBuild, 'index.js'))) return stateBuild;
186
+ return resolveLocalUiBuild() ?? stateBuild; // fall back even if missing (error surfaces later)
187
+ }
188
+
189
+ /**
190
+ * Install the UI build to OP_HOME/state/ui/.
191
+ *
192
+ * Copies from local packages/ui/build/ when running from source,
193
+ * otherwise downloads ui-build.tar.gz from the GitHub release.
194
+ * Called during install and update; always replaces existing content.
195
+ *
196
+ * state/ui/ is automatically included in backups because
197
+ * backupOpenPalmHome() copies all of OP_HOME/state/.
198
+ */
199
+ /** SHA-256 hex digest of arbitrary bytes. */
200
+ function sha256Hex(data: Uint8Array): string {
201
+ return createHash('sha256').update(data).digest('hex');
202
+ }
203
+
204
+ /**
205
+ * Parse a `sha256sum`-format checksums file into a filename→hash map.
206
+ * Each line is: `<hash> <filename>` (one or two spaces).
207
+ */
208
+ function parseChecksumsFile(content: string): Map<string, string> {
209
+ const map = new Map<string, string>();
210
+ for (const line of content.trim().split('\n')) {
211
+ const parts = line.trim().split(/\s+/);
212
+ if (parts.length >= 2) {
213
+ map.set(parts[parts.length - 1], parts[0]);
214
+ }
215
+ }
216
+ return map;
217
+ }
218
+
219
+ export async function seedUiBuild(repoRef: string, stateDir: string): Promise<void> {
220
+ const uiDir = join(stateDir, 'ui');
221
+ mkdirSync(uiDir, { recursive: true });
222
+
223
+ const local = resolveLocalUiBuild();
224
+ if (local) {
225
+ logger.debug('seeding UI build from local source', { src: local });
226
+ copyTree(local, uiDir);
227
+ return;
228
+ }
229
+
230
+ const base = `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/${repoRef}`;
231
+ const tarballUrl = `${base}/ui-build.tar.gz`;
232
+ const checksumUrl = `${base}/checksums-sha256.txt`;
233
+ logger.debug('downloading UI build', { url: tarballUrl });
234
+
235
+ const tmpTar = join(stateDir, '.ui-build.tar.gz.tmp');
236
+ try {
237
+ // Download tarball and checksums file in parallel (checksums best-effort)
238
+ const [tarRes, csRes] = await Promise.all([
239
+ fetchWithRetry(tarballUrl),
240
+ fetchWithRetry(checksumUrl).catch(() => null),
241
+ ]);
242
+ if (!tarRes.ok) throw new Error(`Failed to download UI build (HTTP ${tarRes.status})`);
243
+
244
+ const tarData = new Uint8Array(await tarRes.arrayBuffer());
245
+
246
+ // Verify SHA-256 if the checksums file was available
247
+ if (csRes?.ok) {
248
+ const checksums = parseChecksumsFile(await csRes.text());
249
+ const expected = checksums.get('ui-build.tar.gz');
250
+ if (expected) {
251
+ const actual = sha256Hex(tarData);
252
+ if (actual !== expected) {
253
+ throw new Error(`UI build checksum mismatch (expected ${expected}, got ${actual})`);
254
+ }
255
+ logger.debug('UI build checksum verified', { sha256: actual });
256
+ }
257
+ }
258
+
259
+ writeFileSync(tmpTar, tarData);
260
+
261
+ // Cross-platform extraction via the `tar` npm package — no shell dependency
262
+ await tarExtract({ file: tmpTar, cwd: uiDir, strip: 1 });
263
+ } finally {
264
+ rmSync(tmpTar, { force: true });
265
+ }
266
+ }
267
+
268
+ // ── UI update check ──────────────────────────────────────────────────────────
269
+
270
+ const GITHUB_API = 'https://api.github.com';
271
+
272
+ /** Returns 1 if a > b, -1 if a < b, 0 if equal. Strips leading 'v'. */
273
+ function compareVersionTags(a: string, b: string): number {
274
+ const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number);
275
+ const [aM, am, ap] = parse(a);
276
+ const [bM, bm, bp] = parse(b);
277
+ if (aM !== bM) return aM > bM ? 1 : -1;
278
+ if (am !== bm) return am > bm ? 1 : -1;
279
+ if (ap !== bp) return ap > bp ? 1 : -1;
280
+ return 0;
281
+ }
282
+
283
+ export interface UiBuildUpdateResult {
284
+ updated: boolean;
285
+ latestVersion: string | null;
286
+ error?: string;
287
+ }
288
+
289
+ /**
290
+ * Check GitHub for a newer UI build and apply it if one exists.
291
+ *
292
+ * When an update is available:
293
+ * 1. Move state/ui/ → state/backups/ui-{timestamp}/ (preserves the old build)
294
+ * 2. Download ui-build.tar.gz from the latest release and extract to state/ui/
295
+ *
296
+ * Non-fatal: any network or extraction error returns { updated: false, error }.
297
+ * The caller should proceed with the existing build on failure.
298
+ */
299
+ export async function checkAndUpdateUiBuild(
300
+ currentVersion: string,
301
+ stateDir: string,
302
+ ): Promise<UiBuildUpdateResult> {
303
+ try {
304
+ const res = await fetch(
305
+ `${GITHUB_API}/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
306
+ {
307
+ headers: { 'User-Agent': `OpenPalm/${currentVersion}` },
308
+ signal: AbortSignal.timeout(10_000),
309
+ },
310
+ );
311
+ if (!res.ok) {
312
+ return { updated: false, latestVersion: null, error: `GitHub API returned ${res.status}` };
313
+ }
314
+
315
+ const release = await res.json() as {
316
+ tag_name: string;
317
+ assets: Array<{ name: string }>;
318
+ };
319
+ const latestTag = release.tag_name; // e.g. "v0.11.0"
320
+ const latestVersion = latestTag.replace(/^v/, '');
321
+
322
+ if (compareVersionTags(latestTag, currentVersion) <= 0) {
323
+ logger.debug('UI build is up to date', { current: currentVersion, latest: latestVersion });
324
+ return { updated: false, latestVersion };
325
+ }
326
+
327
+ if (!release.assets.some(a => a.name === 'ui-build.tar.gz')) {
328
+ return { updated: false, latestVersion, error: 'Latest release has no ui-build.tar.gz' };
329
+ }
330
+
331
+ // Back up the existing UI build before replacing it
332
+ const uiDir = join(stateDir, 'ui');
333
+ if (existsSync(join(uiDir, 'index.js'))) {
334
+ const backupDir = join(stateDir, 'backups', `ui-${Date.now()}`);
335
+ mkdirSync(join(stateDir, 'backups'), { recursive: true });
336
+ renameSync(uiDir, backupDir);
337
+ logger.debug('backed up UI build before update', { backup: backupDir });
338
+ }
339
+
340
+ await seedUiBuild(latestTag, stateDir);
341
+ logger.debug('UI build updated', { from: currentVersion, to: latestVersion });
342
+
343
+ return { updated: true, latestVersion };
344
+ } catch (err) {
345
+ const error = err instanceof Error ? err.message : String(err);
346
+ logger.debug('UI build update check failed (non-fatal)', { error });
347
+ return { updated: false, latestVersion: null, error };
348
+ }
349
+ }