@openpalm/lib 0.10.2 → 0.11.0-beta.10

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 (63) hide show
  1. package/README.md +4 -2
  2. package/package.json +11 -3
  3. package/src/control-plane/akm-vault.test.ts +105 -0
  4. package/src/control-plane/akm-vault.ts +311 -0
  5. package/src/control-plane/channels.ts +11 -9
  6. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  7. package/src/control-plane/compose-args.test.ts +25 -33
  8. package/src/control-plane/compose-args.ts +0 -4
  9. package/src/control-plane/compose-errors.test.ts +106 -0
  10. package/src/control-plane/compose-errors.ts +117 -0
  11. package/src/control-plane/config-persistence.ts +148 -73
  12. package/src/control-plane/core-assets.test.ts +104 -0
  13. package/src/control-plane/core-assets.ts +111 -58
  14. package/src/control-plane/docker.ts +70 -25
  15. package/src/control-plane/env.test.ts +25 -1
  16. package/src/control-plane/env.ts +84 -1
  17. package/src/control-plane/home.ts +66 -69
  18. package/src/control-plane/host-opencode.test.ts +260 -0
  19. package/src/control-plane/host-opencode.ts +229 -0
  20. package/src/control-plane/install-edge-cases.test.ts +190 -292
  21. package/src/control-plane/install-lock.ts +157 -0
  22. package/src/control-plane/lifecycle.ts +65 -75
  23. package/src/control-plane/markdown-task.ts +200 -0
  24. package/src/control-plane/migrate-0110.test.ts +177 -0
  25. package/src/control-plane/migrate-0110.ts +99 -0
  26. package/src/control-plane/operator-ids.test.ts +130 -0
  27. package/src/control-plane/operator-ids.ts +89 -0
  28. package/src/control-plane/paths.ts +80 -0
  29. package/src/control-plane/provider-models.ts +154 -0
  30. package/src/control-plane/registry-components.test.ts +105 -27
  31. package/src/control-plane/registry.test.ts +247 -51
  32. package/src/control-plane/registry.ts +404 -54
  33. package/src/control-plane/rollback.ts +17 -16
  34. package/src/control-plane/scheduler.ts +75 -262
  35. package/src/control-plane/secret-mappings.ts +4 -8
  36. package/src/control-plane/secrets.ts +97 -55
  37. package/src/control-plane/setup-config.schema.json +5 -17
  38. package/src/control-plane/setup-status.ts +9 -29
  39. package/src/control-plane/setup-validation.ts +23 -23
  40. package/src/control-plane/setup.test.ts +143 -244
  41. package/src/control-plane/setup.ts +216 -133
  42. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  43. package/src/control-plane/spec-to-env.test.ts +75 -60
  44. package/src/control-plane/spec-to-env.ts +68 -153
  45. package/src/control-plane/stack-spec.test.ts +22 -84
  46. package/src/control-plane/stack-spec.ts +9 -89
  47. package/src/control-plane/types.ts +9 -29
  48. package/src/control-plane/ui-assets.ts +385 -0
  49. package/src/control-plane/validate.ts +44 -79
  50. package/src/index.ts +102 -56
  51. package/src/logger.test.ts +228 -0
  52. package/src/logger.ts +71 -1
  53. package/src/provider-constants.ts +22 -1
  54. package/src/control-plane/audit.ts +0 -40
  55. package/src/control-plane/env-schema-validation.test.ts +0 -118
  56. package/src/control-plane/lock.test.ts +0 -194
  57. package/src/control-plane/lock.ts +0 -176
  58. package/src/control-plane/memory-config.ts +0 -298
  59. package/src/control-plane/provider-config.ts +0 -34
  60. package/src/control-plane/redact-schema.ts +0 -50
  61. package/src/control-plane/secret-backend.test.ts +0 -359
  62. package/src/control-plane/secret-backend.ts +0 -322
  63. package/src/control-plane/spec-validator.ts +0 -159
@@ -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,37 +34,20 @@ 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
- export function stackSpecPath(configDir: string): string {
106
- return `${configDir}/${STACK_SPEC_FILENAME}`;
107
- }
108
-
109
39
  export function writeStackSpec(configDir: string, spec: StackSpec): void {
110
40
  mkdirSync(configDir, { recursive: true });
111
41
  const content = yamlStringify(spec, { indent: 2 });
112
- writeFileSync(stackSpecPath(configDir), content);
42
+ writeFileSync(`${configDir}/${STACK_SPEC_FILENAME}`, content);
113
43
  }
114
44
 
115
45
  /**
116
- * Read the stack spec. Returns null for missing, corrupt, or unrecognized version files.
46
+ * Read the stack spec. Returns null for missing or corrupt files.
47
+ * Only the version field is checked; legacy capability fields are ignored.
117
48
  */
118
49
  export function readStackSpec(configDir: string): StackSpec | null {
119
- const path = stackSpecPath(configDir);
50
+ const path = `${configDir}/${STACK_SPEC_FILENAME}`;
120
51
  if (!existsSync(path)) return null;
121
52
 
122
53
  let raw: unknown;
@@ -128,16 +59,5 @@ export function readStackSpec(configDir: string): StackSpec | null {
128
59
  if (typeof raw !== "object" || raw === null) return null;
129
60
  const obj = raw as Record<string, unknown>;
130
61
  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);
62
+ return { version: 2 };
143
63
  }
@@ -6,11 +6,7 @@
6
6
 
7
7
  export type CoreServiceName =
8
8
  | "assistant"
9
- | "guardian"
10
- | "memory"
11
- | "scheduler";
12
-
13
- export type OptionalServiceName = "admin" | "docker-socket-proxy";
9
+ | "guardian";
14
10
 
15
11
  export type AccessScope = "host" | "lan";
16
12
  export type CallerType = "assistant" | "cli" | "ui" | "system" | "test" | "unknown";
@@ -21,16 +17,6 @@ export type ChannelInfo = {
21
17
  ymlPath: string;
22
18
  };
23
19
 
24
- export type AuditEntry = {
25
- at: string;
26
- requestId: string;
27
- actor: string;
28
- callerType: CallerType;
29
- action: string;
30
- args: Record<string, unknown>;
31
- ok: boolean;
32
- };
33
-
34
20
  export type ArtifactMeta = {
35
21
  name: string;
36
22
  sha256: string;
@@ -39,33 +25,27 @@ export type ArtifactMeta = {
39
25
  };
40
26
 
41
27
  export type ControlPlaneState = {
42
- adminToken: string;
43
- assistantToken: string;
44
- setupToken: string;
45
28
  homeDir: string;
46
29
  configDir: string;
47
- vaultDir: string;
48
- dataDir: string;
49
- logsDir: string;
50
- cacheDir: string;
30
+ stashDir: string; // homeDir/stash
31
+ workspaceDir: string; // homeDir/workspace
32
+ cacheDir: string; // homeDir/cache (regenerable/semi-persistent data)
33
+ stateDir: string; // homeDir/state (service data + system state)
34
+ stackDir: string; // configDir/stack (compose runtime + stack config)
51
35
  services: Record<string, "running" | "stopped">;
52
36
  artifacts: {
53
37
  compose: string;
54
38
  };
55
39
  artifactMeta: ArtifactMeta[];
56
- audit: AuditEntry[];
57
40
  };
58
41
 
59
42
  // ── Constants ──────────────────────────────────────────────────────────
60
43
 
44
+ // Scheduler is no longer a separate service — it runs as a co-process inside
45
+ // the assistant container. See core/assistant/entrypoint.sh.
46
+ // Memory has been replaced by the akm-cli stash (shared with assistant).
61
47
  export const CORE_SERVICES: CoreServiceName[] = [
62
- "memory",
63
48
  "assistant",
64
49
  "guardian",
65
- "scheduler",
66
50
  ];
67
51
 
68
- export const OPTIONAL_SERVICES: OptionalServiceName[] = [
69
- "admin",
70
- "docker-socket-proxy",
71
- ];
@@ -0,0 +1,385 @@
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
+ readFileSync, 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. Electron extraResources — ui-build/ is placed alongside the asar
160
+ () => {
161
+ const rp = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
162
+ if (!rp) return null;
163
+ return join(rp, 'ui-build');
164
+ },
165
+ // 3. Relative to this source file (dev / bun run)
166
+ () => {
167
+ const meta = fileURLToPath(import.meta.url);
168
+ if (meta.startsWith('/$bunfs/')) return null;
169
+ // lib source: packages/lib/src/control-plane/ui-assets.ts → 5 levels up
170
+ const candidate = join(dirname(meta), '..', '..', '..', '..', 'packages', 'ui', 'build');
171
+ return existsSync(join(candidate, 'index.js')) ? candidate : null;
172
+ },
173
+ // 4. Relative to compiled binary / Electron executable
174
+ () => {
175
+ const binDir = dirname(realpathSync(process.execPath));
176
+ const candidate = join(binDir, '..', '..', '..', 'packages', 'ui', 'build');
177
+ return existsSync(join(candidate, 'index.js')) ? candidate : null;
178
+ },
179
+ );
180
+ }
181
+
182
+ function readUiVersionFile(dir: string): string | null {
183
+ try { return readFileSync(join(dir, 'version.txt'), 'utf-8').trim(); } catch { return null; }
184
+ }
185
+
186
+ /**
187
+ * Resolve the best available UI build directory at runtime.
188
+ *
189
+ * Priority:
190
+ * 1. OP_HOME/state/ui/ — if its version.txt is NEWER than the bundled build
191
+ * 2. Bundled / local build (Electron extraResources, source checkout)
192
+ * 3. OP_HOME/state/ui/ — fallback when no bundled build exists
193
+ *
194
+ * This means GitHub-downloaded updates are applied automatically (disk wins
195
+ * when newer), but a fresh AppImage install always works without a download.
196
+ */
197
+ export function resolveUiBuildDir(): string {
198
+ const stateBuild = join(resolveStateDir(), 'ui');
199
+ const localBuild = resolveLocalUiBuild();
200
+
201
+ if (existsSync(join(stateBuild, 'index.js')) && localBuild) {
202
+ const diskVer = readUiVersionFile(stateBuild);
203
+ const bundledVer = readUiVersionFile(localBuild);
204
+ if (diskVer && bundledVer && compareVersionTags(diskVer, bundledVer) > 0) {
205
+ return stateBuild;
206
+ }
207
+ return localBuild;
208
+ }
209
+
210
+ if (localBuild) return localBuild;
211
+ return stateBuild;
212
+ }
213
+
214
+ /**
215
+ * Install the UI build to OP_HOME/state/ui/.
216
+ *
217
+ * Copies from local packages/ui/build/ when running from source,
218
+ * otherwise downloads ui-build.tar.gz from the GitHub release.
219
+ * Called during install and update; always replaces existing content.
220
+ *
221
+ * state/ui/ is automatically included in backups because
222
+ * backupOpenPalmHome() copies all of OP_HOME/state/.
223
+ */
224
+ /** SHA-256 hex digest of arbitrary bytes. */
225
+ function sha256Hex(data: Uint8Array): string {
226
+ return createHash('sha256').update(data).digest('hex');
227
+ }
228
+
229
+ /**
230
+ * Parse a `sha256sum`-format checksums file into a filename→hash map.
231
+ * Each line is: `<hash> <filename>` (one or two spaces).
232
+ */
233
+ function parseChecksumsFile(content: string): Map<string, string> {
234
+ const map = new Map<string, string>();
235
+ for (const line of content.trim().split('\n')) {
236
+ const parts = line.trim().split(/\s+/);
237
+ if (parts.length >= 2) {
238
+ map.set(parts[parts.length - 1], parts[0]);
239
+ }
240
+ }
241
+ return map;
242
+ }
243
+
244
+ export function readCurrentUiBuildVersion(stateDir: string): string | null {
245
+ const versionFile = join(stateDir, 'ui', 'version.txt');
246
+ if (!existsSync(versionFile)) return null;
247
+ return readFileSync(versionFile, 'utf-8').trim() || null;
248
+ }
249
+
250
+ export async function seedUiBuild(repoRef: string, stateDir: string, options?: { forceRemote?: boolean }): Promise<void> {
251
+ const uiDir = join(stateDir, 'ui');
252
+ mkdirSync(uiDir, { recursive: true });
253
+
254
+ const local = options?.forceRemote ? null : resolveLocalUiBuild();
255
+ if (local) {
256
+ logger.debug('seeding UI build from local source', { src: local });
257
+ copyTree(local, uiDir);
258
+ writeFileSync(join(uiDir, 'version.txt'), repoRef.replace(/^v/, ''));
259
+ return;
260
+ }
261
+
262
+ const base = `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/${repoRef}`;
263
+ const tarballUrl = `${base}/ui-build.tar.gz`;
264
+ const checksumUrl = `${base}/checksums-sha256.txt`;
265
+ logger.debug('downloading UI build', { url: tarballUrl });
266
+
267
+ const tmpTar = join(stateDir, '.ui-build.tar.gz.tmp');
268
+ try {
269
+ // Download tarball and checksums file in parallel (checksums best-effort)
270
+ const [tarRes, csRes] = await Promise.all([
271
+ fetchWithRetry(tarballUrl),
272
+ fetchWithRetry(checksumUrl).catch(() => null),
273
+ ]);
274
+ if (!tarRes.ok) throw new Error(`Failed to download UI build (HTTP ${tarRes.status})`);
275
+
276
+ const tarData = new Uint8Array(await tarRes.arrayBuffer());
277
+
278
+ // Verify SHA-256 if the checksums file was available
279
+ if (csRes?.ok) {
280
+ const checksums = parseChecksumsFile(await csRes.text());
281
+ const expected = checksums.get('ui-build.tar.gz');
282
+ if (expected) {
283
+ const actual = sha256Hex(tarData);
284
+ if (actual !== expected) {
285
+ throw new Error(`UI build checksum mismatch (expected ${expected}, got ${actual})`);
286
+ }
287
+ logger.debug('UI build checksum verified', { sha256: actual });
288
+ }
289
+ }
290
+
291
+ writeFileSync(tmpTar, tarData);
292
+
293
+ // Clear stale files before extracting so old build files don't persist
294
+ rmSync(uiDir, { recursive: true, force: true });
295
+ mkdirSync(uiDir, { recursive: true });
296
+ // Cross-platform extraction via the `tar` npm package — no shell dependency
297
+ await tarExtract({ file: tmpTar, cwd: uiDir, strip: 1 });
298
+ writeFileSync(join(uiDir, 'version.txt'), repoRef.replace(/^v/, ''));
299
+ } finally {
300
+ rmSync(tmpTar, { force: true });
301
+ }
302
+ }
303
+
304
+ // ── UI update check ──────────────────────────────────────────────────────────
305
+
306
+ const GITHUB_API = 'https://api.github.com';
307
+
308
+ /** Returns 1 if a > b, -1 if a < b, 0 if equal. Strips leading 'v'. */
309
+ function compareVersionTags(a: string, b: string): number {
310
+ const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number);
311
+ const [aM, am, ap] = parse(a);
312
+ const [bM, bm, bp] = parse(b);
313
+ if (aM !== bM) return aM > bM ? 1 : -1;
314
+ if (am !== bm) return am > bm ? 1 : -1;
315
+ if (ap !== bp) return ap > bp ? 1 : -1;
316
+ return 0;
317
+ }
318
+
319
+ export interface UiBuildUpdateResult {
320
+ updated: boolean;
321
+ latestVersion: string | null;
322
+ error?: string;
323
+ }
324
+
325
+ /**
326
+ * Check GitHub for a newer UI build and apply it if one exists.
327
+ *
328
+ * When an update is available:
329
+ * 1. Move state/ui/ → state/backups/ui-{timestamp}/ (preserves the old build)
330
+ * 2. Download ui-build.tar.gz from the latest release and extract to state/ui/
331
+ *
332
+ * Non-fatal: any network or extraction error returns { updated: false, error }.
333
+ * The caller should proceed with the existing build on failure.
334
+ */
335
+ export async function checkAndUpdateUiBuild(
336
+ currentVersion: string,
337
+ stateDir: string,
338
+ ): Promise<UiBuildUpdateResult> {
339
+ try {
340
+ const res = await fetch(
341
+ `${GITHUB_API}/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
342
+ {
343
+ headers: { 'User-Agent': `OpenPalm/${currentVersion}` },
344
+ signal: AbortSignal.timeout(10_000),
345
+ },
346
+ );
347
+ if (!res.ok) {
348
+ return { updated: false, latestVersion: null, error: `GitHub API returned ${res.status}` };
349
+ }
350
+
351
+ const release = await res.json() as {
352
+ tag_name: string;
353
+ assets: Array<{ name: string }>;
354
+ };
355
+ const latestTag = release.tag_name; // e.g. "v0.11.0"
356
+ const latestVersion = latestTag.replace(/^v/, '');
357
+
358
+ if (compareVersionTags(latestTag, currentVersion) <= 0) {
359
+ logger.debug('UI build is up to date', { current: currentVersion, latest: latestVersion });
360
+ return { updated: false, latestVersion };
361
+ }
362
+
363
+ if (!release.assets.some(a => a.name === 'ui-build.tar.gz')) {
364
+ return { updated: false, latestVersion, error: 'Latest release has no ui-build.tar.gz' };
365
+ }
366
+
367
+ // Back up the existing UI build before replacing it
368
+ const uiDir = join(stateDir, 'ui');
369
+ if (existsSync(join(uiDir, 'index.js'))) {
370
+ const backupDir = join(stateDir, 'backups', `ui-${Date.now()}`);
371
+ mkdirSync(join(stateDir, 'backups'), { recursive: true });
372
+ renameSync(uiDir, backupDir);
373
+ logger.debug('backed up UI build before update', { backup: backupDir });
374
+ }
375
+
376
+ await seedUiBuild(latestTag, stateDir);
377
+ logger.debug('UI build updated', { from: currentVersion, to: latestVersion });
378
+
379
+ return { updated: true, latestVersion };
380
+ } catch (err) {
381
+ const error = err instanceof Error ? err.message : String(err);
382
+ logger.debug('UI build update check failed (non-fatal)', { error });
383
+ return { updated: false, latestVersion: null, error };
384
+ }
385
+ }