@openpalm/lib 0.11.0-beta.9 → 0.11.0-rc.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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/control-plane/akm-sources.test.ts +206 -0
- package/src/control-plane/akm-sources.ts +234 -0
- package/src/control-plane/akm-user-env.test.ts +142 -0
- package/src/control-plane/akm-user-env.ts +167 -0
- package/src/control-plane/backup.ts +14 -5
- package/src/control-plane/channels.ts +48 -29
- package/src/control-plane/cleanup-guardrails.test.ts +1 -1
- package/src/control-plane/compose-args.test.ts +67 -30
- package/src/control-plane/compose-args.ts +63 -8
- package/src/control-plane/config-persistence.ts +95 -136
- package/src/control-plane/core-assets.ts +21 -44
- package/src/control-plane/docker.ts +15 -14
- package/src/control-plane/env.test.ts +10 -10
- package/src/control-plane/env.ts +1 -1
- package/src/control-plane/extends-support.test.ts +8 -8
- package/src/control-plane/fs-atomic.ts +15 -0
- package/src/control-plane/home.ts +34 -46
- package/src/control-plane/host-akm-sharing.test.ts +145 -0
- package/src/control-plane/host-akm-sharing.ts +129 -0
- package/src/control-plane/host-opencode.test.ts +82 -10
- package/src/control-plane/host-opencode.ts +42 -13
- package/src/control-plane/install-edge-cases.test.ts +98 -105
- package/src/control-plane/install-lock.ts +7 -7
- package/src/control-plane/lifecycle.ts +37 -36
- package/src/control-plane/markdown-task.ts +30 -50
- package/src/control-plane/opencode-client.ts +1 -1
- package/src/control-plane/paths.ts +61 -46
- package/src/control-plane/profile-ids.ts +21 -0
- package/src/control-plane/provider-models.ts +3 -3
- package/src/control-plane/registry.test.ts +107 -90
- package/src/control-plane/registry.ts +288 -109
- package/src/control-plane/rollback.ts +8 -38
- package/src/control-plane/scheduler.ts +10 -7
- package/src/control-plane/secret-audit.test.ts +159 -0
- package/src/control-plane/secret-audit.ts +255 -0
- package/src/control-plane/secret-mappings.ts +2 -2
- package/src/control-plane/secrets-files.test.ts +99 -0
- package/src/control-plane/secrets-files.ts +113 -0
- package/src/control-plane/secrets.ts +113 -86
- package/src/control-plane/setup-config.schema.json +1 -1
- package/src/control-plane/setup-status.ts +6 -11
- package/src/control-plane/setup.test.ts +140 -44
- package/src/control-plane/setup.ts +85 -62
- package/src/control-plane/skeleton-guardrail.test.ts +64 -55
- package/src/control-plane/spec-to-env.test.ts +63 -26
- package/src/control-plane/spec-to-env.ts +49 -12
- package/src/control-plane/stack-spec.test.ts +15 -11
- package/src/control-plane/stack-spec.ts +31 -10
- package/src/control-plane/task-files.test.ts +45 -0
- package/src/control-plane/task-files.ts +51 -0
- package/src/control-plane/types.ts +2 -4
- package/src/control-plane/ui-assets.test.ts +130 -0
- package/src/control-plane/ui-assets.ts +132 -57
- package/src/control-plane/validate.ts +13 -15
- package/src/index.ts +86 -16
- package/src/control-plane/akm-vault.test.ts +0 -105
- package/src/control-plane/akm-vault.ts +0 -311
- package/src/control-plane/core-assets.test.ts +0 -104
- package/src/control-plane/migrate-0110.test.ts +0 -177
- package/src/control-plane/migrate-0110.ts +0 -99
- package/src/control-plane/registry-components.test.ts +0 -391
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw file access for the Automations admin tab — a plain editor for the akm
|
|
3
|
+
* task files in the assistant tasks dir (/stash/tasks = knowledge/tasks).
|
|
4
|
+
*
|
|
5
|
+
* akm task files are YAML (`.yml`/`.yaml`) or markdown (`.md`). Names are always
|
|
6
|
+
* basenames within the tasks dir; the guard rejects path separators and `..`.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
const TASK_FILENAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.(ya?ml|md)$/;
|
|
12
|
+
|
|
13
|
+
export function assertSafeTaskFilename(name: string): void {
|
|
14
|
+
if (!TASK_FILENAME_RE.test(name) || name.includes('..')) {
|
|
15
|
+
throw new Error(`Invalid task file name: ${name} (expected a .yml/.yaml/.md basename)`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The assistant tasks dir for a given stash dir (knowledge). Created if absent. */
|
|
20
|
+
export function resolveTasksDir(stashDir: string): string {
|
|
21
|
+
const dir = join(stashDir, 'tasks');
|
|
22
|
+
mkdirSync(dir, { recursive: true });
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type TaskFileInfo = { name: string; size: number };
|
|
27
|
+
|
|
28
|
+
/** List the task files (.yml/.yaml/.md) in the tasks dir, with byte sizes. */
|
|
29
|
+
export function listTaskFiles(stashDir: string): TaskFileInfo[] {
|
|
30
|
+
const dir = resolveTasksDir(stashDir);
|
|
31
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
32
|
+
.filter((e) => e.isFile() && TASK_FILENAME_RE.test(e.name) && !e.name.includes('..'))
|
|
33
|
+
.map((e) => ({ name: e.name, size: statSync(join(dir, e.name)).size }))
|
|
34
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function readTaskFile(stashDir: string, name: string): string | null {
|
|
38
|
+
assertSafeTaskFilename(name);
|
|
39
|
+
const path = join(resolveTasksDir(stashDir), name);
|
|
40
|
+
return existsSync(path) ? readFileSync(path, 'utf-8') : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function writeTaskFile(stashDir: string, name: string, content: string): void {
|
|
44
|
+
assertSafeTaskFilename(name);
|
|
45
|
+
writeFileSync(join(resolveTasksDir(stashDir), name), content, { mode: 0o644 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function removeTaskFile(stashDir: string, name: string): void {
|
|
49
|
+
assertSafeTaskFilename(name);
|
|
50
|
+
rmSync(join(resolveTasksDir(stashDir), name), { force: true });
|
|
51
|
+
}
|
|
@@ -27,10 +27,9 @@ export type ArtifactMeta = {
|
|
|
27
27
|
export type ControlPlaneState = {
|
|
28
28
|
homeDir: string;
|
|
29
29
|
configDir: string;
|
|
30
|
-
stashDir: string; // homeDir/
|
|
30
|
+
stashDir: string; // homeDir/knowledge
|
|
31
31
|
workspaceDir: string; // homeDir/workspace
|
|
32
|
-
|
|
33
|
-
stateDir: string; // homeDir/state (service data + system state)
|
|
32
|
+
dataDir: string; // homeDir/data (service data + operational files)
|
|
34
33
|
stackDir: string; // configDir/stack (compose runtime + stack config)
|
|
35
34
|
services: Record<string, "running" | "stopped">;
|
|
36
35
|
artifacts: {
|
|
@@ -48,4 +47,3 @@ export const CORE_SERVICES: CoreServiceName[] = [
|
|
|
48
47
|
"assistant",
|
|
49
48
|
"guardian",
|
|
50
49
|
];
|
|
51
|
-
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { resolveUiBuildDir, readUiBuildVersion, UI_VERSION_STAMP, seedOpenPalmDir, SKELETON_VERSION_STAMP } from "./ui-assets.js";
|
|
7
|
+
|
|
8
|
+
let root = "";
|
|
9
|
+
let opHome = "";
|
|
10
|
+
let repoRoot = "";
|
|
11
|
+
let dataUi = "";
|
|
12
|
+
let bundledUi = "";
|
|
13
|
+
const saved: Record<string, string | undefined> = {};
|
|
14
|
+
|
|
15
|
+
function makeBuild(dir: string, version: string | null): void {
|
|
16
|
+
mkdirSync(dir, { recursive: true });
|
|
17
|
+
writeFileSync(join(dir, "index.js"), "// ui server\n");
|
|
18
|
+
if (version !== null) writeFileSync(join(dir, UI_VERSION_STAMP), `${version}\n`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
root = mkdtempSync(join(tmpdir(), "ui-assets-"));
|
|
23
|
+
opHome = join(root, "ophome");
|
|
24
|
+
repoRoot = join(root, "repo");
|
|
25
|
+
dataUi = join(opHome, "data", "ui");
|
|
26
|
+
bundledUi = join(repoRoot, "packages", "ui", "build"); // resolveLocalUiBuild() candidate 1
|
|
27
|
+
saved.OP_HOME = process.env.OP_HOME;
|
|
28
|
+
saved.OPENPALM_REPO_ROOT = process.env.OPENPALM_REPO_ROOT;
|
|
29
|
+
process.env.OP_HOME = opHome;
|
|
30
|
+
// Pin the bundled candidate to a controlled location so the resolver never
|
|
31
|
+
// discovers the real packages/ui/build via its source-relative fallback.
|
|
32
|
+
// Default: an EMPTY build dir (exists but no index.js) = "no bundled build".
|
|
33
|
+
process.env.OPENPALM_REPO_ROOT = repoRoot;
|
|
34
|
+
mkdirSync(bundledUi, { recursive: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
rmSync(root, { recursive: true, force: true });
|
|
39
|
+
for (const k of ["OP_HOME", "OPENPALM_REPO_ROOT"] as const) {
|
|
40
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
41
|
+
else process.env[k] = saved[k];
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("readUiBuildVersion", () => {
|
|
46
|
+
it("reads the stamp, or null when absent", () => {
|
|
47
|
+
makeBuild(dataUi, "0.11.0");
|
|
48
|
+
expect(readUiBuildVersion(dataUi)).toBe("0.11.0");
|
|
49
|
+
makeBuild(bundledUi, null);
|
|
50
|
+
expect(readUiBuildVersion(bundledUi)).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("resolveUiBuildDir — version-aware selection", () => {
|
|
55
|
+
it("uses data/ui when only it exists", () => {
|
|
56
|
+
makeBuild(dataUi, "0.11.0");
|
|
57
|
+
expect(resolveUiBuildDir()).toBe(dataUi);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("uses bundled when only it exists", () => {
|
|
61
|
+
makeBuild(bundledUi, "0.11.0");
|
|
62
|
+
process.env.OPENPALM_REPO_ROOT = repoRoot;
|
|
63
|
+
expect(resolveUiBuildDir()).toBe(bundledUi);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("prefers data/ui only when it is strictly NEWER than bundled", () => {
|
|
67
|
+
makeBuild(dataUi, "0.12.0");
|
|
68
|
+
makeBuild(bundledUi, "0.11.0");
|
|
69
|
+
process.env.OPENPALM_REPO_ROOT = repoRoot;
|
|
70
|
+
expect(resolveUiBuildDir()).toBe(dataUi);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("prefers bundled when it is newer than data/ui (fixes stale-data/ui shadowing)", () => {
|
|
74
|
+
makeBuild(dataUi, "0.11.0");
|
|
75
|
+
makeBuild(bundledUi, "0.12.0");
|
|
76
|
+
process.env.OPENPALM_REPO_ROOT = repoRoot;
|
|
77
|
+
expect(resolveUiBuildDir()).toBe(bundledUi);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("prefers bundled when versions are equal", () => {
|
|
81
|
+
makeBuild(dataUi, "0.11.0");
|
|
82
|
+
makeBuild(bundledUi, "0.11.0");
|
|
83
|
+
process.env.OPENPALM_REPO_ROOT = repoRoot;
|
|
84
|
+
expect(resolveUiBuildDir()).toBe(bundledUi);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("prefers bundled when data/ui is unstamped (cannot prove it is newer)", () => {
|
|
88
|
+
makeBuild(dataUi, null);
|
|
89
|
+
makeBuild(bundledUi, "0.11.0");
|
|
90
|
+
process.env.OPENPALM_REPO_ROOT = repoRoot;
|
|
91
|
+
expect(resolveUiBuildDir()).toBe(bundledUi);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("falls back to the data/ui path when nothing is present (caller seeds)", () => {
|
|
95
|
+
expect(resolveUiBuildDir()).toBe(dataUi);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("seedOpenPalmDir — version guard (P2)", () => {
|
|
100
|
+
const seededFile = () => join(opHome, "config", "stack", "x.txt");
|
|
101
|
+
const stamp = () => join(opHome, SKELETON_VERSION_STAMP);
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
// Local skeleton source at OPENPALM_REPO_ROOT/.openpalm (candidate 1).
|
|
105
|
+
mkdirSync(join(repoRoot, ".openpalm", "config", "stack"), { recursive: true });
|
|
106
|
+
writeFileSync(join(repoRoot, ".openpalm", "config", "stack", "x.txt"), "seed\n");
|
|
107
|
+
mkdirSync(opHome, { recursive: true });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("seeds once and stamps the version", async () => {
|
|
111
|
+
await seedOpenPalmDir("v1", opHome, join(opHome, "config"), join(opHome, "data"));
|
|
112
|
+
expect(existsSync(seededFile())).toBe(true);
|
|
113
|
+
expect(readFileSync(stamp(), "utf-8").trim()).toBe("v1");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("does NOT re-seed (or re-materialize a removed file) for the same version", async () => {
|
|
117
|
+
await seedOpenPalmDir("v1", opHome, join(opHome, "config"), join(opHome, "data"));
|
|
118
|
+
rmSync(seededFile(), { force: true });
|
|
119
|
+
await seedOpenPalmDir("v1", opHome, join(opHome, "config"), join(opHome, "data"));
|
|
120
|
+
expect(existsSync(seededFile())).toBe(false); // guard skipped the copy
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("re-seeds on a version change", async () => {
|
|
124
|
+
await seedOpenPalmDir("v1", opHome, join(opHome, "config"), join(opHome, "data"));
|
|
125
|
+
rmSync(seededFile(), { force: true });
|
|
126
|
+
await seedOpenPalmDir("v2", opHome, join(opHome, "config"), join(opHome, "data"));
|
|
127
|
+
expect(existsSync(seededFile())).toBe(true);
|
|
128
|
+
expect(readFileSync(stamp(), "utf-8").trim()).toBe("v2");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import {
|
|
14
14
|
existsSync, mkdirSync, readdirSync, copyFileSync,
|
|
15
|
-
|
|
15
|
+
writeFileSync, readFileSync, rmSync, realpathSync, renameSync,
|
|
16
16
|
} from 'node:fs';
|
|
17
17
|
import { join, dirname, relative } from 'node:path';
|
|
18
18
|
import { fileURLToPath } from 'node:url';
|
|
19
19
|
import { createHash } from 'node:crypto';
|
|
20
20
|
import { x as tarExtract } from 'tar';
|
|
21
|
-
import {
|
|
21
|
+
import { resolveBackupsDir, resolveDataDir } from './home.js';
|
|
22
22
|
import { createLogger } from '../logger.js';
|
|
23
23
|
|
|
24
24
|
const logger = createLogger('lib:ui-assets');
|
|
@@ -87,13 +87,15 @@ export function resolveLocalOpenpalmDir(): string | null {
|
|
|
87
87
|
() => process.env.OPENPALM_REPO_ROOT
|
|
88
88
|
? join(process.env.OPENPALM_REPO_ROOT, '.openpalm')
|
|
89
89
|
: null,
|
|
90
|
-
// 2.
|
|
90
|
+
// 2. Electron extraResources — openpalm-skeleton/ placed alongside the asar
|
|
91
|
+
() => process.env.OPENPALM_SKELETON_DIR ?? null,
|
|
92
|
+
// 3. Relative to this source file (dev / bun run)
|
|
91
93
|
() => {
|
|
92
94
|
const meta = fileURLToPath(import.meta.url);
|
|
93
95
|
if (meta.startsWith('/$bunfs/')) return null;
|
|
94
96
|
return join(dirname(meta), '..', '..', '..', '..', '.openpalm');
|
|
95
97
|
},
|
|
96
|
-
//
|
|
98
|
+
// 4. Relative to the compiled binary on disk
|
|
97
99
|
() => join(dirname(realpathSync(process.execPath)), '..', '..', '..', '.openpalm'),
|
|
98
100
|
);
|
|
99
101
|
}
|
|
@@ -105,18 +107,44 @@ export function resolveLocalOpenpalmDir(): string | null {
|
|
|
105
107
|
* Falls back to downloading the repo tarball from GitHub when no local
|
|
106
108
|
* skeleton is found (production binary, packaged Electron app).
|
|
107
109
|
*/
|
|
110
|
+
/** Version stamp recording which skeleton version OP_HOME was last seeded from. */
|
|
111
|
+
export const SKELETON_VERSION_STAMP = '.skeleton-version';
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Seed the bundled `.openpalm/` skeleton into OP_HOME — ONCE PER VERSION.
|
|
115
|
+
*
|
|
116
|
+
* Electron calls this on every launch; without a guard it re-copied the entire
|
|
117
|
+
* skeleton tree each time (wasteful, and it re-materialized files a user/process
|
|
118
|
+
* had deliberately removed). We stamp OP_HOME/.skeleton-version with `repoRef`
|
|
119
|
+
* after a successful seed and skip the copy when it already matches — so a given
|
|
120
|
+
* version seeds once and an upgrade re-seeds (skipExisting still preserves any
|
|
121
|
+
* user edits). To force a re-seed, delete the stamp.
|
|
122
|
+
*/
|
|
108
123
|
export async function seedOpenPalmDir(
|
|
109
124
|
repoRef: string,
|
|
110
125
|
homeDir: string,
|
|
111
126
|
_configDir: string,
|
|
112
|
-
|
|
127
|
+
_dataDir: string,
|
|
113
128
|
): Promise<void> {
|
|
129
|
+
const stampPath = join(homeDir, SKELETON_VERSION_STAMP);
|
|
130
|
+
if (existsSync(stampPath)) {
|
|
131
|
+
try {
|
|
132
|
+
if (readFileSync(stampPath, 'utf-8').trim() === repoRef.trim()) {
|
|
133
|
+
logger.debug('skeleton already seeded for this version — skipping', { repoRef });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
} catch { /* unreadable stamp → re-seed */ }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const stamp = (): void => {
|
|
140
|
+
try { writeFileSync(stampPath, `${repoRef}\n`); } catch { /* best-effort */ }
|
|
141
|
+
};
|
|
142
|
+
|
|
114
143
|
const local = resolveLocalOpenpalmDir();
|
|
115
144
|
if (local) {
|
|
116
|
-
logger.debug('seeding .openpalm from local source', { src: local });
|
|
145
|
+
logger.debug('seeding .openpalm from local source', { src: local, repoRef });
|
|
117
146
|
copyTree(local, homeDir, { skipExisting: true });
|
|
118
|
-
|
|
119
|
-
copyTree(join(local, 'state', 'registry'), join(homeDir, 'state', 'registry'));
|
|
147
|
+
stamp();
|
|
120
148
|
return;
|
|
121
149
|
}
|
|
122
150
|
|
|
@@ -137,8 +165,7 @@ export async function seedOpenPalmDir(
|
|
|
137
165
|
const srcOpenpalm = join(tmpDir, '.openpalm');
|
|
138
166
|
if (!existsSync(srcOpenpalm)) throw new Error('.openpalm/ not found in tarball');
|
|
139
167
|
copyTree(srcOpenpalm, homeDir, { skipExisting: true });
|
|
140
|
-
|
|
141
|
-
copyTree(join(srcOpenpalm, 'state', 'registry'), join(homeDir, 'state', 'registry'));
|
|
168
|
+
stamp();
|
|
142
169
|
} finally {
|
|
143
170
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
144
171
|
}
|
|
@@ -179,47 +206,72 @@ export function resolveLocalUiBuild(): string | null {
|
|
|
179
206
|
);
|
|
180
207
|
}
|
|
181
208
|
|
|
182
|
-
function readUiVersionFile(dir: string): string | null {
|
|
183
|
-
try { return readFileSync(join(dir, 'version.txt'), 'utf-8').trim(); } catch { return null; }
|
|
184
|
-
}
|
|
185
|
-
|
|
186
209
|
/**
|
|
187
210
|
* Resolve the best available UI build directory at runtime.
|
|
188
211
|
*
|
|
189
212
|
* Priority:
|
|
190
|
-
* 1. OP_HOME/
|
|
191
|
-
* 2. Bundled / local build (Electron extraResources, source checkout)
|
|
192
|
-
|
|
213
|
+
* 1. OP_HOME/data/ui/ — user-installed or auto-updated build
|
|
214
|
+
* 2. Bundled / local build (Electron extraResources, OPENPALM_REPO_ROOT, source checkout)
|
|
215
|
+
*/
|
|
216
|
+
/** Filename of the build-time version stamp written into the UI build root. */
|
|
217
|
+
export const UI_VERSION_STAMP = '.openpalm-ui-version';
|
|
218
|
+
|
|
219
|
+
/** Read the stamped UI version from a build dir, or null if absent/unreadable. */
|
|
220
|
+
export function readUiBuildVersion(dir: string): string | null {
|
|
221
|
+
try {
|
|
222
|
+
const v = readFileSync(join(dir, UI_VERSION_STAMP), 'utf-8').trim();
|
|
223
|
+
return v || null;
|
|
224
|
+
} catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Resolve which UI build to run.
|
|
231
|
+
*
|
|
232
|
+
* Two channels exist: the bundled build (shipped inside the AppImage / source
|
|
233
|
+
* tree) and `data/ui` (operator-updatable, seeded from GitHub releases). To fix
|
|
234
|
+
* the stale-`data/ui` shadowing bug AND stay forward-compatible with updating the
|
|
235
|
+
* UI without shipping a new app (D5), selection is VERSION-AWARE:
|
|
236
|
+
*
|
|
237
|
+
* - If only one channel has a build → use it.
|
|
238
|
+
* - If both exist → use `data/ui` ONLY when it is strictly NEWER than the
|
|
239
|
+
* bundled build (per the version stamp); otherwise prefer the bundled build.
|
|
240
|
+
* An unstamped/older `data/ui` never shadows a newer bundled build.
|
|
193
241
|
*
|
|
194
|
-
* This means
|
|
195
|
-
*
|
|
242
|
+
* This means a fresh app runs its bundled UI, and a future "update UI only" flow
|
|
243
|
+
* (seed a newer-stamped build into data/ui) is picked up automatically — no app
|
|
244
|
+
* reinstall required.
|
|
196
245
|
*/
|
|
197
246
|
export function resolveUiBuildDir(): string {
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
247
|
+
const dataBuild = join(resolveDataDir(), 'ui');
|
|
248
|
+
const hasData = existsSync(join(dataBuild, 'index.js'));
|
|
249
|
+
// resolveLocalUiBuild()'s env/resourcesPath candidates only check the dir
|
|
250
|
+
// exists, not that it holds a runnable build — require index.js before trusting it.
|
|
251
|
+
const bundledRaw = resolveLocalUiBuild();
|
|
252
|
+
const bundled = bundledRaw && existsSync(join(bundledRaw, 'index.js')) ? bundledRaw : null;
|
|
253
|
+
|
|
254
|
+
if (hasData && bundled) {
|
|
255
|
+
const dataVer = readUiBuildVersion(dataBuild);
|
|
256
|
+
const bundledVer = readUiBuildVersion(bundled);
|
|
257
|
+
// data/ui wins only when we can prove it's strictly newer.
|
|
258
|
+
if (dataVer && bundledVer && compareVersionTags(dataVer, bundledVer) > 0) return dataBuild;
|
|
259
|
+
return bundled;
|
|
208
260
|
}
|
|
209
|
-
|
|
210
|
-
if (
|
|
211
|
-
return
|
|
261
|
+
if (hasData) return dataBuild;
|
|
262
|
+
if (bundled) return bundled;
|
|
263
|
+
return dataBuild; // nothing present yet → caller triggers seedUiBuild
|
|
212
264
|
}
|
|
213
265
|
|
|
214
266
|
/**
|
|
215
|
-
* Install the UI build to OP_HOME/
|
|
267
|
+
* Install the UI build to OP_HOME/data/ui/.
|
|
216
268
|
*
|
|
217
269
|
* Copies from local packages/ui/build/ when running from source,
|
|
218
270
|
* otherwise downloads ui-build.tar.gz from the GitHub release.
|
|
219
271
|
* Called during install and update; always replaces existing content.
|
|
220
272
|
*
|
|
221
|
-
*
|
|
222
|
-
* backupOpenPalmHome() copies all of OP_HOME/
|
|
273
|
+
* data/ui/ is automatically included in backups because
|
|
274
|
+
* backupOpenPalmHome() copies all of OP_HOME/data/.
|
|
223
275
|
*/
|
|
224
276
|
/** SHA-256 hex digest of arbitrary bytes. */
|
|
225
277
|
function sha256Hex(data: Uint8Array): string {
|
|
@@ -241,21 +293,14 @@ function parseChecksumsFile(content: string): Map<string, string> {
|
|
|
241
293
|
return map;
|
|
242
294
|
}
|
|
243
295
|
|
|
244
|
-
export function
|
|
245
|
-
const
|
|
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');
|
|
296
|
+
export async function seedUiBuild(repoRef: string, dataDir: string, options?: { forceRemote?: boolean }): Promise<void> {
|
|
297
|
+
const uiDir = join(dataDir, 'ui');
|
|
252
298
|
mkdirSync(uiDir, { recursive: true });
|
|
253
299
|
|
|
254
300
|
const local = options?.forceRemote ? null : resolveLocalUiBuild();
|
|
255
301
|
if (local) {
|
|
256
302
|
logger.debug('seeding UI build from local source', { src: local });
|
|
257
303
|
copyTree(local, uiDir);
|
|
258
|
-
writeFileSync(join(uiDir, 'version.txt'), repoRef.replace(/^v/, ''));
|
|
259
304
|
return;
|
|
260
305
|
}
|
|
261
306
|
|
|
@@ -264,7 +309,7 @@ export async function seedUiBuild(repoRef: string, stateDir: string, options?: {
|
|
|
264
309
|
const checksumUrl = `${base}/checksums-sha256.txt`;
|
|
265
310
|
logger.debug('downloading UI build', { url: tarballUrl });
|
|
266
311
|
|
|
267
|
-
const tmpTar = join(
|
|
312
|
+
const tmpTar = join(dataDir, '.ui-build.tar.gz.tmp');
|
|
268
313
|
try {
|
|
269
314
|
// Download tarball and checksums file in parallel (checksums best-effort)
|
|
270
315
|
const [tarRes, csRes] = await Promise.all([
|
|
@@ -295,7 +340,6 @@ export async function seedUiBuild(repoRef: string, stateDir: string, options?: {
|
|
|
295
340
|
mkdirSync(uiDir, { recursive: true });
|
|
296
341
|
// Cross-platform extraction via the `tar` npm package — no shell dependency
|
|
297
342
|
await tarExtract({ file: tmpTar, cwd: uiDir, strip: 1 });
|
|
298
|
-
writeFileSync(join(uiDir, 'version.txt'), repoRef.replace(/^v/, ''));
|
|
299
343
|
} finally {
|
|
300
344
|
rmSync(tmpTar, { force: true });
|
|
301
345
|
}
|
|
@@ -305,14 +349,45 @@ export async function seedUiBuild(repoRef: string, stateDir: string, options?: {
|
|
|
305
349
|
|
|
306
350
|
const GITHUB_API = 'https://api.github.com';
|
|
307
351
|
|
|
308
|
-
/** Returns 1 if a > b, -1 if a < b, 0 if equal. Strips leading 'v'. */
|
|
352
|
+
/** Returns 1 if a > b, -1 if a < b, 0 if equal. Strips leading 'v'. Handles pre-release tags. */
|
|
309
353
|
function compareVersionTags(a: string, b: string): number {
|
|
310
|
-
const parse = (v: string) =>
|
|
311
|
-
|
|
312
|
-
|
|
354
|
+
const parse = (v: string): [number, number, number, string | null] => {
|
|
355
|
+
const clean = v.replace(/^v/, '');
|
|
356
|
+
const dashIdx = clean.indexOf('-');
|
|
357
|
+
const main = dashIdx === -1 ? clean : clean.slice(0, dashIdx);
|
|
358
|
+
const pre = dashIdx === -1 ? null : clean.slice(dashIdx + 1);
|
|
359
|
+
const parts = main.split('.').map(Number);
|
|
360
|
+
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0, pre];
|
|
361
|
+
};
|
|
362
|
+
const comparePre = (x: string, y: string): number => {
|
|
363
|
+
const xp = x.split('.');
|
|
364
|
+
const yp = y.split('.');
|
|
365
|
+
for (let i = 0; i < Math.max(xp.length, yp.length); i++) {
|
|
366
|
+
if (i >= xp.length) return -1;
|
|
367
|
+
if (i >= yp.length) return 1;
|
|
368
|
+
const xn = Number(xp[i]);
|
|
369
|
+
const yn = Number(yp[i]);
|
|
370
|
+
const xIsNum = !isNaN(xn);
|
|
371
|
+
const yIsNum = !isNaN(yn);
|
|
372
|
+
if (xIsNum && yIsNum) {
|
|
373
|
+
if (xn !== yn) return xn > yn ? 1 : -1;
|
|
374
|
+
} else if (xIsNum !== yIsNum) {
|
|
375
|
+
return xIsNum ? -1 : 1; // numeric < alphanumeric per semver
|
|
376
|
+
} else {
|
|
377
|
+
if (xp[i] !== yp[i]) return xp[i]! > yp[i]! ? 1 : -1;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return 0;
|
|
381
|
+
};
|
|
382
|
+
const [aM, am, ap, aPre] = parse(a);
|
|
383
|
+
const [bM, bm, bp, bPre] = parse(b);
|
|
313
384
|
if (aM !== bM) return aM > bM ? 1 : -1;
|
|
314
385
|
if (am !== bm) return am > bm ? 1 : -1;
|
|
315
386
|
if (ap !== bp) return ap > bp ? 1 : -1;
|
|
387
|
+
// Same numeric version: stable > pre-release (semver spec)
|
|
388
|
+
if (aPre === null && bPre !== null) return 1;
|
|
389
|
+
if (aPre !== null && bPre === null) return -1;
|
|
390
|
+
if (aPre !== null && bPre !== null) return comparePre(aPre, bPre);
|
|
316
391
|
return 0;
|
|
317
392
|
}
|
|
318
393
|
|
|
@@ -326,15 +401,15 @@ export interface UiBuildUpdateResult {
|
|
|
326
401
|
* Check GitHub for a newer UI build and apply it if one exists.
|
|
327
402
|
*
|
|
328
403
|
* When an update is available:
|
|
329
|
-
* 1. Move
|
|
330
|
-
* 2. Download ui-build.tar.gz from the latest release and extract to
|
|
404
|
+
* 1. Move data/ui/ → data/backups/ui-{timestamp}/ (preserves the old build)
|
|
405
|
+
* 2. Download ui-build.tar.gz from the latest release and extract to data/ui/
|
|
331
406
|
*
|
|
332
407
|
* Non-fatal: any network or extraction error returns { updated: false, error }.
|
|
333
408
|
* The caller should proceed with the existing build on failure.
|
|
334
409
|
*/
|
|
335
410
|
export async function checkAndUpdateUiBuild(
|
|
336
411
|
currentVersion: string,
|
|
337
|
-
|
|
412
|
+
dataDir: string,
|
|
338
413
|
): Promise<UiBuildUpdateResult> {
|
|
339
414
|
try {
|
|
340
415
|
const res = await fetch(
|
|
@@ -365,15 +440,15 @@ export async function checkAndUpdateUiBuild(
|
|
|
365
440
|
}
|
|
366
441
|
|
|
367
442
|
// Back up the existing UI build before replacing it
|
|
368
|
-
const uiDir = join(
|
|
443
|
+
const uiDir = join(dataDir, 'ui');
|
|
369
444
|
if (existsSync(join(uiDir, 'index.js'))) {
|
|
370
|
-
const backupDir = join(
|
|
371
|
-
mkdirSync(
|
|
445
|
+
const backupDir = join(resolveBackupsDir(), `ui-${Date.now()}`);
|
|
446
|
+
mkdirSync(resolveBackupsDir(), { recursive: true });
|
|
372
447
|
renameSync(uiDir, backupDir);
|
|
373
448
|
logger.debug('backed up UI build before update', { backup: backupDir });
|
|
374
449
|
}
|
|
375
450
|
|
|
376
|
-
await seedUiBuild(latestTag,
|
|
451
|
+
await seedUiBuild(latestTag, dataDir);
|
|
377
452
|
logger.debug('UI build updated', { from: currentVersion, to: latestVersion });
|
|
378
453
|
|
|
379
454
|
return { updated: true, latestVersion };
|
|
@@ -2,26 +2,26 @@
|
|
|
2
2
|
* Runtime configuration validation for the OpenPalm control plane.
|
|
3
3
|
*
|
|
4
4
|
* Validation is a presence check on the canonical env keys we expect in
|
|
5
|
-
* the live config/stack
|
|
5
|
+
* the live config/stack files. The
|
|
6
6
|
* historical schema files and external validation binary were retired in
|
|
7
7
|
* #391; everything advisory is surfaced as a non-blocking warning. The
|
|
8
8
|
* function never shells out and never reads schemas.
|
|
9
9
|
*/
|
|
10
10
|
import { existsSync } from "node:fs";
|
|
11
|
-
import {
|
|
11
|
+
import { readStackRuntimeEnv } from "./secrets.js";
|
|
12
12
|
import { getCoreSecretMappings } from "./secret-mappings.js";
|
|
13
13
|
import type { ControlPlaneState } from "./types.js";
|
|
14
14
|
|
|
15
15
|
// Stack-scoped env keys that must always exist and carry a non-empty value
|
|
16
16
|
// for the platform to boot. Keep this list small — anything optional
|
|
17
17
|
// belongs in the warning bucket instead.
|
|
18
|
-
const
|
|
18
|
+
const REQUIRED_SECRET_KEYS = ["OP_UI_LOGIN_PASSWORD"] as const;
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Validate the live configuration files.
|
|
22
22
|
*
|
|
23
23
|
* Checks:
|
|
24
|
-
* 1.
|
|
24
|
+
* 1. knowledge/env/stack.env exists and carries every required key with a
|
|
25
25
|
* non-empty value.
|
|
26
26
|
* 2. Every secret env key in getCoreSecretMappings() is present (key only
|
|
27
27
|
* — blank values are warned about, never erred on, because operators
|
|
@@ -38,32 +38,30 @@ export async function validateProposedState(state: ControlPlaneState): Promise<{
|
|
|
38
38
|
const errors: string[] = [];
|
|
39
39
|
const warnings: string[] = [];
|
|
40
40
|
|
|
41
|
-
const stackEnvPath = `${state.
|
|
41
|
+
const stackEnvPath = `${state.stashDir}/env/stack.env`;
|
|
42
42
|
|
|
43
43
|
if (!existsSync(stackEnvPath)) {
|
|
44
44
|
errors.push(`ERROR: stack env file missing at ${stackEnvPath}`);
|
|
45
45
|
return { ok: false, errors, warnings };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
const
|
|
49
|
-
const userEnv: Record<string, string> = {};
|
|
48
|
+
const runtimeEnv = readStackRuntimeEnv(state.stackDir);
|
|
50
49
|
|
|
51
|
-
for (const key of
|
|
52
|
-
const value =
|
|
50
|
+
for (const key of REQUIRED_SECRET_KEYS) {
|
|
51
|
+
const value = runtimeEnv[key];
|
|
53
52
|
if (!value || value.trim().length === 0) {
|
|
54
|
-
errors.push(`ERROR: required
|
|
53
|
+
errors.push(`ERROR: required secret ${key} is missing or empty in knowledge/secrets/${key.toLowerCase()}`);
|
|
55
54
|
}
|
|
56
55
|
}
|
|
57
56
|
|
|
58
57
|
// Every canonical secret should at least appear as a key somewhere in
|
|
59
58
|
// the env files so the operator sees the slot. Missing slots warn (not
|
|
60
59
|
// error) since not every provider is in use on every install.
|
|
61
|
-
for (const mapping of getCoreSecretMappings(
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
if (!inStack && !inUser) {
|
|
60
|
+
for (const mapping of getCoreSecretMappings(runtimeEnv)) {
|
|
61
|
+
const inRuntime = Object.prototype.hasOwnProperty.call(runtimeEnv, mapping.envKey);
|
|
62
|
+
if (!inRuntime) {
|
|
65
63
|
warnings.push(
|
|
66
|
-
`WARN: ${mapping.envKey} (akm ${mapping.secretKey}) is not declared in
|
|
64
|
+
`WARN: ${mapping.envKey} (akm ${mapping.secretKey}) is not declared in knowledge/secrets/${mapping.envKey.toLowerCase()}`,
|
|
67
65
|
);
|
|
68
66
|
}
|
|
69
67
|
}
|