@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.
- package/README.md +4 -2
- package/package.json +11 -3
- package/src/control-plane/akm-vault.test.ts +105 -0
- package/src/control-plane/akm-vault.ts +311 -0
- package/src/control-plane/channels.ts +11 -9
- package/src/control-plane/cleanup-guardrails.test.ts +8 -9
- package/src/control-plane/compose-args.test.ts +25 -33
- package/src/control-plane/compose-args.ts +0 -4
- package/src/control-plane/compose-errors.test.ts +106 -0
- package/src/control-plane/compose-errors.ts +117 -0
- package/src/control-plane/config-persistence.ts +148 -73
- package/src/control-plane/core-assets.test.ts +104 -0
- package/src/control-plane/core-assets.ts +111 -58
- package/src/control-plane/docker.ts +70 -25
- package/src/control-plane/env.test.ts +25 -1
- package/src/control-plane/env.ts +84 -1
- package/src/control-plane/home.ts +66 -69
- package/src/control-plane/host-opencode.test.ts +260 -0
- package/src/control-plane/host-opencode.ts +229 -0
- package/src/control-plane/install-edge-cases.test.ts +190 -292
- package/src/control-plane/install-lock.ts +157 -0
- package/src/control-plane/lifecycle.ts +65 -75
- package/src/control-plane/markdown-task.ts +200 -0
- package/src/control-plane/migrate-0110.test.ts +177 -0
- package/src/control-plane/migrate-0110.ts +99 -0
- package/src/control-plane/operator-ids.test.ts +130 -0
- package/src/control-plane/operator-ids.ts +89 -0
- package/src/control-plane/paths.ts +80 -0
- package/src/control-plane/provider-models.ts +154 -0
- package/src/control-plane/registry-components.test.ts +105 -27
- package/src/control-plane/registry.test.ts +247 -51
- package/src/control-plane/registry.ts +404 -54
- package/src/control-plane/rollback.ts +17 -16
- package/src/control-plane/scheduler.ts +75 -262
- package/src/control-plane/secret-mappings.ts +4 -8
- package/src/control-plane/secrets.ts +97 -55
- package/src/control-plane/setup-config.schema.json +5 -17
- package/src/control-plane/setup-status.ts +9 -29
- package/src/control-plane/setup-validation.ts +23 -23
- package/src/control-plane/setup.test.ts +143 -244
- package/src/control-plane/setup.ts +216 -133
- package/src/control-plane/skeleton-guardrail.test.ts +151 -0
- package/src/control-plane/spec-to-env.test.ts +75 -60
- package/src/control-plane/spec-to-env.ts +68 -153
- package/src/control-plane/stack-spec.test.ts +22 -84
- package/src/control-plane/stack-spec.ts +9 -89
- package/src/control-plane/types.ts +9 -29
- package/src/control-plane/ui-assets.ts +385 -0
- package/src/control-plane/validate.ts +44 -79
- package/src/index.ts +102 -56
- package/src/logger.test.ts +228 -0
- package/src/logger.ts +71 -1
- package/src/provider-constants.ts +22 -1
- package/src/control-plane/audit.ts +0 -40
- package/src/control-plane/env-schema-validation.test.ts +0 -118
- package/src/control-plane/lock.test.ts +0 -194
- package/src/control-plane/lock.ts +0 -176
- package/src/control-plane/memory-config.ts +0 -298
- package/src/control-plane/provider-config.ts +0 -34
- package/src/control-plane/redact-schema.ts +0 -50
- package/src/control-plane/secret-backend.test.ts +0 -359
- package/src/control-plane/secret-backend.ts +0 -322
- 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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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:
|
|
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(
|
|
42
|
+
writeFileSync(`${configDir}/${STACK_SPEC_FILENAME}`, content);
|
|
113
43
|
}
|
|
114
44
|
|
|
115
45
|
/**
|
|
116
|
-
* Read the stack spec. Returns null for missing
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
+
}
|