@openpalm/lib 0.11.0-beta.8 → 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.
Files changed (63) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/control-plane/akm-sources.test.ts +206 -0
  4. package/src/control-plane/akm-sources.ts +234 -0
  5. package/src/control-plane/akm-user-env.test.ts +142 -0
  6. package/src/control-plane/akm-user-env.ts +167 -0
  7. package/src/control-plane/backup.ts +14 -5
  8. package/src/control-plane/channels.ts +48 -29
  9. package/src/control-plane/cleanup-guardrails.test.ts +1 -1
  10. package/src/control-plane/compose-args.test.ts +67 -30
  11. package/src/control-plane/compose-args.ts +63 -8
  12. package/src/control-plane/config-persistence.ts +95 -136
  13. package/src/control-plane/core-assets.ts +21 -44
  14. package/src/control-plane/docker.ts +15 -14
  15. package/src/control-plane/env.test.ts +10 -10
  16. package/src/control-plane/env.ts +1 -1
  17. package/src/control-plane/extends-support.test.ts +8 -8
  18. package/src/control-plane/fs-atomic.ts +15 -0
  19. package/src/control-plane/home.ts +34 -46
  20. package/src/control-plane/host-akm-sharing.test.ts +145 -0
  21. package/src/control-plane/host-akm-sharing.ts +129 -0
  22. package/src/control-plane/host-opencode.test.ts +82 -10
  23. package/src/control-plane/host-opencode.ts +42 -13
  24. package/src/control-plane/install-edge-cases.test.ts +98 -105
  25. package/src/control-plane/install-lock.ts +7 -7
  26. package/src/control-plane/lifecycle.ts +37 -36
  27. package/src/control-plane/markdown-task.ts +30 -50
  28. package/src/control-plane/opencode-client.ts +1 -1
  29. package/src/control-plane/paths.ts +61 -46
  30. package/src/control-plane/profile-ids.ts +21 -0
  31. package/src/control-plane/provider-models.ts +3 -3
  32. package/src/control-plane/registry.test.ts +107 -90
  33. package/src/control-plane/registry.ts +288 -109
  34. package/src/control-plane/rollback.ts +8 -38
  35. package/src/control-plane/scheduler.ts +10 -7
  36. package/src/control-plane/secret-audit.test.ts +159 -0
  37. package/src/control-plane/secret-audit.ts +255 -0
  38. package/src/control-plane/secret-mappings.ts +2 -2
  39. package/src/control-plane/secrets-files.test.ts +99 -0
  40. package/src/control-plane/secrets-files.ts +113 -0
  41. package/src/control-plane/secrets.ts +113 -86
  42. package/src/control-plane/setup-config.schema.json +1 -1
  43. package/src/control-plane/setup-status.ts +6 -11
  44. package/src/control-plane/setup.test.ts +140 -44
  45. package/src/control-plane/setup.ts +85 -62
  46. package/src/control-plane/skeleton-guardrail.test.ts +64 -55
  47. package/src/control-plane/spec-to-env.test.ts +63 -26
  48. package/src/control-plane/spec-to-env.ts +49 -12
  49. package/src/control-plane/stack-spec.test.ts +15 -11
  50. package/src/control-plane/stack-spec.ts +31 -10
  51. package/src/control-plane/task-files.test.ts +45 -0
  52. package/src/control-plane/task-files.ts +51 -0
  53. package/src/control-plane/types.ts +2 -4
  54. package/src/control-plane/ui-assets.test.ts +130 -0
  55. package/src/control-plane/ui-assets.ts +132 -57
  56. package/src/control-plane/validate.ts +13 -15
  57. package/src/index.ts +86 -16
  58. package/src/control-plane/akm-vault.test.ts +0 -105
  59. package/src/control-plane/akm-vault.ts +0 -311
  60. package/src/control-plane/core-assets.test.ts +0 -104
  61. package/src/control-plane/migrate-0110.test.ts +0 -177
  62. package/src/control-plane/migrate-0110.ts +0 -99
  63. 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/stash
30
+ stashDir: string; // homeDir/knowledge
31
31
  workspaceDir: string; // homeDir/workspace
32
- cacheDir: string; // homeDir/cache (regenerable/semi-persistent data)
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
- readFileSync, writeFileSync, rmSync, realpathSync, renameSync,
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 { resolveStateDir } from './home.js';
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. Relative to this source file (dev / bun run)
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
- // 3. Relative to the compiled binary on disk
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
- _stateDir: string,
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
- // Registry is system-managed — always refresh so addon overlays stay current.
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
- // Registry is system-managed — always refresh so addon overlays stay current.
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/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
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 GitHub-downloaded updates are applied automatically (disk wins
195
- * when newer), but a fresh AppImage install always works without a download.
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 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;
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 (localBuild) return localBuild;
211
- return stateBuild;
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/state/ui/.
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
- * state/ui/ is automatically included in backups because
222
- * backupOpenPalmHome() copies all of OP_HOME/state/.
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 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');
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(stateDir, '.ui-build.tar.gz.tmp');
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) => v.replace(/^v/, '').split('.').map(Number);
311
- const [aM, am, ap] = parse(a);
312
- const [bM, bm, bp] = parse(b);
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 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/
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
- stateDir: string,
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(stateDir, 'ui');
443
+ const uiDir = join(dataDir, 'ui');
369
444
  if (existsSync(join(uiDir, 'index.js'))) {
370
- const backupDir = join(stateDir, 'backups', `ui-${Date.now()}`);
371
- mkdirSync(join(stateDir, 'backups'), { recursive: true });
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, stateDir);
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/stack.env file. The
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 { readStackEnv } from "./secrets.js";
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 REQUIRED_STACK_KEYS = ["OP_UI_LOGIN_PASSWORD"] as 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. config/stack/stack.env exists and carries every required key with a
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.stackDir}/stack.env`;
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 stackEnv = readStackEnv(state.stackDir);
49
- const userEnv: Record<string, string> = {};
48
+ const runtimeEnv = readStackRuntimeEnv(state.stackDir);
50
49
 
51
- for (const key of REQUIRED_STACK_KEYS) {
52
- const value = stackEnv[key];
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 key ${key} is missing or empty in config/stack/stack.env`);
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(stackEnv)) {
62
- const inStack = Object.prototype.hasOwnProperty.call(stackEnv, mapping.envKey);
63
- const inUser = Object.prototype.hasOwnProperty.call(userEnv, mapping.envKey);
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 config/stack/stack.env`,
64
+ `WARN: ${mapping.envKey} (akm ${mapping.secretKey}) is not declared in knowledge/secrets/${mapping.envKey.toLowerCase()}`,
67
65
  );
68
66
  }
69
67
  }