@openpalm/lib 0.10.2 → 0.11.0-beta.2
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 +2 -2
- package/package.json +7 -3
- package/src/control-plane/admin-token.ts +73 -0
- package/src/control-plane/akm-vault.test.ts +105 -0
- package/src/control-plane/akm-vault.ts +307 -0
- package/src/control-plane/channels.ts +3 -3
- package/src/control-plane/cleanup-guardrails.test.ts +8 -9
- package/src/control-plane/compose-args.test.ts +25 -24
- 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 +103 -65
- package/src/control-plane/core-assets.test.ts +104 -0
- package/src/control-plane/core-assets.ts +54 -57
- package/src/control-plane/docker.ts +55 -21
- package/src/control-plane/env.test.ts +25 -1
- package/src/control-plane/env.ts +80 -0
- 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 +187 -289
- package/src/control-plane/install-lock.ts +157 -0
- package/src/control-plane/lifecycle.ts +34 -65
- 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/paths.ts +82 -0
- package/src/control-plane/provider-config.ts +2 -2
- 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 +49 -47
- package/src/control-plane/registry.ts +71 -50
- package/src/control-plane/rollback.ts +17 -16
- package/src/control-plane/scheduler.ts +75 -262
- package/src/control-plane/secret-backend.test.ts +98 -111
- package/src/control-plane/secret-backend.ts +221 -181
- package/src/control-plane/secret-mappings.ts +4 -8
- package/src/control-plane/secrets.ts +93 -51
- 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 +138 -239
- package/src/control-plane/setup.ts +215 -130
- package/src/control-plane/skeleton-guardrail.test.ts +151 -0
- package/src/control-plane/spec-to-env.test.ts +59 -58
- package/src/control-plane/spec-to-env.ts +52 -142
- package/src/control-plane/spec-validator.ts +2 -99
- package/src/control-plane/stack-spec.test.ts +21 -77
- package/src/control-plane/stack-spec.ts +7 -83
- package/src/control-plane/types.ts +12 -28
- package/src/control-plane/ui-assets.ts +349 -0
- package/src/control-plane/validate.ts +44 -79
- package/src/index.ts +86 -48
- 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/memory-config.ts +0 -298
- 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
|
|
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,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
|
|
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
|
-
|
|
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,11 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
export type CoreServiceName =
|
|
8
8
|
| "assistant"
|
|
9
|
-
| "guardian"
|
|
10
|
-
| "memory"
|
|
11
|
-
| "scheduler";
|
|
9
|
+
| "guardian";
|
|
12
10
|
|
|
13
|
-
export type OptionalServiceName =
|
|
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";
|
|
@@ -21,16 +19,6 @@ export type ChannelInfo = {
|
|
|
21
19
|
ymlPath: string;
|
|
22
20
|
};
|
|
23
21
|
|
|
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
22
|
export type ArtifactMeta = {
|
|
35
23
|
name: string;
|
|
36
24
|
sha256: string;
|
|
@@ -39,33 +27,29 @@ export type ArtifactMeta = {
|
|
|
39
27
|
};
|
|
40
28
|
|
|
41
29
|
export type ControlPlaneState = {
|
|
42
|
-
adminToken: string;
|
|
43
|
-
assistantToken: string;
|
|
44
|
-
setupToken: string;
|
|
45
30
|
homeDir: string;
|
|
46
31
|
configDir: string;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
32
|
+
stashDir: string; // homeDir/stash
|
|
33
|
+
workspaceDir: string; // homeDir/workspace
|
|
34
|
+
cacheDir: string; // homeDir/cache (regenerable/semi-persistent data)
|
|
35
|
+
stateDir: string; // homeDir/state (service data + system state)
|
|
36
|
+
stackDir: string; // configDir/stack (compose runtime + stack config)
|
|
51
37
|
services: Record<string, "running" | "stopped">;
|
|
52
38
|
artifacts: {
|
|
53
39
|
compose: string;
|
|
54
40
|
};
|
|
55
41
|
artifactMeta: ArtifactMeta[];
|
|
56
|
-
audit: AuditEntry[];
|
|
57
42
|
};
|
|
58
43
|
|
|
59
44
|
// ── Constants ──────────────────────────────────────────────────────────
|
|
60
45
|
|
|
46
|
+
// Scheduler is no longer a separate service — it runs as a co-process inside
|
|
47
|
+
// the assistant container. See core/assistant/entrypoint.sh.
|
|
48
|
+
// Memory has been replaced by the akm-cli stash (shared with assistant).
|
|
61
49
|
export const CORE_SERVICES: CoreServiceName[] = [
|
|
62
|
-
"memory",
|
|
63
50
|
"assistant",
|
|
64
51
|
"guardian",
|
|
65
|
-
"scheduler",
|
|
66
52
|
];
|
|
67
53
|
|
|
68
|
-
export const OPTIONAL_SERVICES: OptionalServiceName[] = [
|
|
69
|
-
|
|
70
|
-
"docker-socket-proxy",
|
|
71
|
-
];
|
|
54
|
+
export const OPTIONAL_SERVICES: OptionalServiceName[] = [];
|
|
55
|
+
|
|
@@ -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
|
+
}
|