@openpalm/lib 0.9.8 → 0.10.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 (56) hide show
  1. package/README.md +31 -71
  2. package/package.json +1 -1
  3. package/src/control-plane/audit.ts +4 -4
  4. package/src/control-plane/backup.ts +31 -0
  5. package/src/control-plane/channels.ts +88 -156
  6. package/src/control-plane/cleanup-guardrails.test.ts +289 -0
  7. package/src/control-plane/compose-args.test.ts +170 -0
  8. package/src/control-plane/compose-args.ts +57 -0
  9. package/src/control-plane/config-persistence.ts +270 -0
  10. package/src/control-plane/core-assets.ts +58 -234
  11. package/src/control-plane/crypto.ts +14 -0
  12. package/src/control-plane/docker.ts +94 -204
  13. package/src/control-plane/env-schema-validation.test.ts +118 -0
  14. package/src/control-plane/extends-support.test.ts +105 -0
  15. package/src/control-plane/home.ts +133 -0
  16. package/src/control-plane/install-edge-cases.test.ts +314 -717
  17. package/src/control-plane/lifecycle.ts +215 -233
  18. package/src/control-plane/lock.test.ts +194 -0
  19. package/src/control-plane/lock.ts +176 -0
  20. package/src/control-plane/memory-config.ts +34 -160
  21. package/src/control-plane/opencode-client.test.ts +154 -0
  22. package/src/control-plane/opencode-client.ts +113 -0
  23. package/src/control-plane/provider-config.ts +34 -0
  24. package/src/control-plane/redact-schema.ts +50 -0
  25. package/src/control-plane/registry-components.test.ts +313 -0
  26. package/src/control-plane/registry.test.ts +414 -0
  27. package/src/control-plane/registry.ts +418 -0
  28. package/src/control-plane/rollback.ts +128 -0
  29. package/src/control-plane/scheduler.ts +18 -190
  30. package/src/control-plane/secret-backend.test.ts +359 -0
  31. package/src/control-plane/secret-backend.ts +322 -0
  32. package/src/control-plane/secret-mappings.ts +185 -0
  33. package/src/control-plane/secrets.ts +186 -112
  34. package/src/control-plane/setup-config.schema.json +306 -0
  35. package/src/control-plane/setup-status.ts +15 -8
  36. package/src/control-plane/setup-validation.ts +90 -0
  37. package/src/control-plane/setup.test.ts +336 -929
  38. package/src/control-plane/setup.ts +159 -849
  39. package/src/control-plane/spec-to-env.test.ts +100 -0
  40. package/src/control-plane/spec-to-env.ts +195 -0
  41. package/src/control-plane/spec-validator.ts +159 -0
  42. package/src/control-plane/stack-spec.test.ts +150 -0
  43. package/src/control-plane/stack-spec.ts +101 -22
  44. package/src/control-plane/types.ts +6 -99
  45. package/src/control-plane/validate.ts +107 -0
  46. package/src/index.ts +101 -159
  47. package/src/provider-constants.ts +2 -31
  48. package/src/control-plane/connection-mapping.ts +0 -191
  49. package/src/control-plane/connection-migration-flags.ts +0 -40
  50. package/src/control-plane/connection-profiles.ts +0 -317
  51. package/src/control-plane/core-asset-provider.ts +0 -21
  52. package/src/control-plane/fs-asset-provider.ts +0 -65
  53. package/src/control-plane/fs-registry-provider.ts +0 -46
  54. package/src/control-plane/paths.ts +0 -77
  55. package/src/control-plane/registry-provider.ts +0 -19
  56. package/src/control-plane/staging.ts +0 -399
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Runtime file resolution and persistence for the OpenPalm control plane.
3
+ *
4
+ * Writes and derives live runtime files (compose, env, schemas).
5
+ * Files are validated in-place before writing; rollback is handled by
6
+ * the rollback module (snapshot to ~/.cache/openpalm/rollback/).
7
+ */
8
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, chmodSync } from "node:fs";
9
+ import { parseEnvFile, mergeEnvContent } from './env.js';
10
+ import type { ControlPlaneState, ArtifactMeta } from "./types.js";
11
+ import { isChannelAddon } from "./channels.js";
12
+ import { readStackSpec } from "./stack-spec.js";
13
+ import { writeCapabilityVars } from "./spec-to-env.js";
14
+ import { listEnabledAddonIds } from "./registry.js";
15
+
16
+ import { generateRedactSchema } from "./redact-schema.js";
17
+ import { readStackEnv } from "./secrets.js";
18
+ import {
19
+ readCoreCompose,
20
+ ensureUserEnvSchema,
21
+ ensureSystemEnvSchema,
22
+ } from "./core-assets.js";
23
+ export { sha256, randomHex } from "./crypto.js";
24
+ import { sha256, randomHex } from "./crypto.js";
25
+
26
+ const DEFAULT_IMAGE_TAG = process.env.OP_IMAGE_TAG ?? "latest";
27
+
28
+ // ── Stack Config (stack.yml) ─────────────────────────────────────
29
+
30
+ /**
31
+ * Check whether Ollama is enabled via active stack/addons/ overlay.
32
+ */
33
+ export function isOllamaEnabled(state: ControlPlaneState): boolean {
34
+ return listEnabledAddonIds(state.homeDir).includes("ollama");
35
+ }
36
+
37
+ /**
38
+ * Check whether admin is enabled via active stack/addons/ overlay.
39
+ */
40
+ export function isAdminEnabled(state: ControlPlaneState): boolean {
41
+ return listEnabledAddonIds(state.homeDir).includes("admin");
42
+ }
43
+
44
+ // ── Env File Management ──────────────────────────────────────────────
45
+
46
+ /**
47
+ * Return the env files used for docker compose --env-file args.
48
+ * These are the live vault env files.
49
+ *
50
+ * Order: stack.env -> user.env -> guardian.env
51
+ */
52
+ export function buildEnvFiles(state: ControlPlaneState): string[] {
53
+ return [
54
+ `${state.vaultDir}/stack/stack.env`,
55
+ `${state.vaultDir}/user/user.env`,
56
+ `${state.vaultDir}/stack/guardian.env`,
57
+ ].filter(existsSync);
58
+ }
59
+
60
+ /**
61
+ * Write system-managed values to vault/stack/stack.env.
62
+ *
63
+ * Channel HMAC secrets are NOT written here — they belong in guardian.env.
64
+ * Use writeChannelSecrets() for channel secrets.
65
+ */
66
+ export function writeSystemEnv(state: ControlPlaneState): void {
67
+ mkdirSync(`${state.vaultDir}/stack`, { recursive: true });
68
+
69
+ const systemEnvPath = `${state.vaultDir}/stack/stack.env`;
70
+
71
+ let base = "";
72
+ if (existsSync(systemEnvPath)) {
73
+ base = readFileSync(systemEnvPath, "utf-8");
74
+ } else {
75
+ base = generateFallbackSystemEnv(state);
76
+ }
77
+
78
+ // Preserve existing OP_SETUP_COMPLETE=true
79
+ const alreadyComplete = /^OP_SETUP_COMPLETE=true$/mi.test(base);
80
+
81
+ const adminManaged: Record<string, string> = {
82
+ OP_SETUP_COMPLETE: alreadyComplete ? "true" : "false"
83
+ };
84
+
85
+ const content = mergeEnvContent(base, adminManaged, {
86
+ sectionHeader: "# ── Admin-managed ──────────────────────────────────────────────────"
87
+ });
88
+
89
+ writeFileSync(systemEnvPath, content);
90
+ }
91
+
92
+ function generateFallbackSystemEnv(state: ControlPlaneState): string {
93
+ const uid = typeof process.getuid === "function" ? (process.getuid() ?? 1000) : 1000;
94
+ const gid = typeof process.getgid === "function" ? (process.getgid() ?? 1000) : 1000;
95
+
96
+ return [
97
+ "# OpenPalm — System Configuration (managed by CLI/admin)",
98
+ "# Auto-generated fallback.",
99
+ "",
100
+ "# ── Authentication ──────────────────────────────────────────────────",
101
+ `OP_ADMIN_TOKEN=\${OP_ADMIN_TOKEN}`,
102
+ `OP_ASSISTANT_TOKEN=\${OP_ASSISTANT_TOKEN}`,
103
+ "",
104
+ "# ── Service Auth ─────────────────────────────────────────────────────",
105
+ `OP_MEMORY_TOKEN=${process.env.OP_MEMORY_TOKEN ?? ""}`,
106
+ "OP_OPENCODE_PASSWORD=",
107
+ "",
108
+ "# ── Paths ──────────────────────────────────────────────────────────",
109
+ `OP_HOME=${state.homeDir}`,
110
+ `OP_UID=${uid}`,
111
+ `OP_GID=${gid}`,
112
+ `OP_DOCKER_SOCK=${process.env.OP_DOCKER_SOCK ?? "/var/run/docker.sock"}`,
113
+ "",
114
+ "# ── Images ──────────────────────────────────────────────────────────",
115
+ `OP_IMAGE_NAMESPACE=${process.env.OP_IMAGE_NAMESPACE ?? "openpalm"}`,
116
+ `OP_IMAGE_TAG=${DEFAULT_IMAGE_TAG}`,
117
+ "",
118
+ "# ── Ports (38XX range) ──────────────────────────────────────────────",
119
+ `OP_ASSISTANT_PORT=3800`,
120
+ `OP_ADMIN_PORT=3880`,
121
+ `OP_ADMIN_OPENCODE_PORT=3881`,
122
+ `OP_MEMORY_PORT=3898`,
123
+ `OP_GUARDIAN_PORT=3899`,
124
+ ""
125
+ ].join("\n");
126
+ }
127
+
128
+ // ── Stack Overlay Discovery ────────────────────────────────────────────
129
+
130
+ /**
131
+ * Discover compose overlays from the stack directory.
132
+ * Returns full paths: [stack/core.compose.yml, stack/addons/{name}/compose.yml].
133
+ */
134
+ export function discoverStackOverlays(stackDir: string): string[] {
135
+ const files: string[] = [];
136
+
137
+ const coreYml = `${stackDir}/core.compose.yml`;
138
+ if (existsSync(coreYml)) files.push(coreYml);
139
+
140
+ const addonsDir = `${stackDir}/addons`;
141
+ if (existsSync(addonsDir)) {
142
+ const entries = readdirSync(addonsDir, { withFileTypes: true })
143
+ .filter((e) => e.isDirectory())
144
+ .sort((a, b) => a.name.localeCompare(b.name));
145
+ for (const entry of entries) {
146
+ const addonCompose = `${addonsDir}/${entry.name}/compose.yml`;
147
+ if (existsSync(addonCompose)) files.push(addonCompose);
148
+ }
149
+ }
150
+
151
+ return files;
152
+ }
153
+
154
+ // ── Top-Level Operations ─────────────────────────────────────────────
155
+
156
+ export function resolveRuntimeFiles(): {
157
+ compose: string;
158
+ } {
159
+ return {
160
+ compose: readCoreCompose(),
161
+ };
162
+ }
163
+
164
+ // ── Runtime File Metadata ──────────────────────────────────────────────
165
+
166
+ export function buildRuntimeFileMeta(artifacts: {
167
+ compose: string;
168
+ }): ArtifactMeta[] {
169
+ const now = new Date().toISOString();
170
+ return (["compose"] as const).map((name) => ({
171
+ name,
172
+ sha256: sha256(artifacts[name]),
173
+ generatedAt: now,
174
+ bytes: Buffer.byteLength(artifacts[name])
175
+ }));
176
+ }
177
+
178
+ // ── Channel Secrets ────────────────────────────────────────────────────
179
+ // Channel HMAC secrets live exclusively in vault/stack/guardian.env.
180
+
181
+ const CHANNEL_SECRET_RE = /^CHANNEL_([A-Z0-9_]+)_SECRET$/;
182
+
183
+ /** Extract channel secrets from parsed env entries. */
184
+ function extractChannelSecrets(parsed: Record<string, string>): Record<string, string> {
185
+ const result: Record<string, string> = {};
186
+ for (const [key, value] of Object.entries(parsed)) {
187
+ const match = key.match(CHANNEL_SECRET_RE);
188
+ if (match?.[1] && value) result[match[1].toLowerCase()] = value;
189
+ }
190
+ return result;
191
+ }
192
+
193
+ /**
194
+ * Read channel HMAC secrets from vault/stack/guardian.env.
195
+ */
196
+ export function readChannelSecrets(vaultDir: string): Record<string, string> {
197
+ return extractChannelSecrets(parseEnvFile(`${vaultDir}/stack/guardian.env`));
198
+ }
199
+
200
+ /**
201
+ * Write channel HMAC secrets to vault/stack/guardian.env.
202
+ * Merges with existing content; does not overwrite unrelated entries.
203
+ */
204
+ export function writeChannelSecrets(vaultDir: string, secrets: Record<string, string>): void {
205
+ const guardianPath = `${vaultDir}/stack/guardian.env`;
206
+ mkdirSync(`${vaultDir}/stack`, { recursive: true });
207
+
208
+ let base = "";
209
+ if (existsSync(guardianPath)) {
210
+ base = readFileSync(guardianPath, "utf-8");
211
+ } else {
212
+ base = "# Guardian channel HMAC secrets — managed by openpalm\n";
213
+ }
214
+
215
+ const updates: Record<string, string> = {};
216
+ for (const [ch, secret] of Object.entries(secrets)) {
217
+ updates[`CHANNEL_${ch.toUpperCase()}_SECRET`] = secret;
218
+ }
219
+
220
+ const content = mergeEnvContent(base, updates);
221
+ writeFileSync(guardianPath, content, { mode: 0o600 });
222
+ // Ensure correct permissions even if file already existed with wrong mode
223
+ chmodSync(guardianPath, 0o600);
224
+ }
225
+
226
+ // ── Persistence (direct-write to live paths) ────────────────────────
227
+
228
+ export function writeRuntimeFiles(
229
+ state: ControlPlaneState
230
+ ): void {
231
+ // Write core compose to stack/
232
+ const stackDir = `${state.homeDir}/stack`;
233
+ mkdirSync(stackDir, { recursive: true });
234
+ writeFileSync(`${stackDir}/core.compose.yml`, state.artifacts.compose);
235
+
236
+ // Load persisted channel HMAC secrets from guardian.env,
237
+ // then generate new ones for new channel addons.
238
+ const channelSecrets = readChannelSecrets(state.vaultDir);
239
+ const addonStackDir = `${state.homeDir}/stack`;
240
+ for (const addon of listEnabledAddonIds(state.homeDir)) {
241
+ const composePath = `${addonStackDir}/addons/${addon}/compose.yml`;
242
+ if (isChannelAddon(composePath) && !channelSecrets[addon]) {
243
+ channelSecrets[addon] = randomHex(16);
244
+ }
245
+ }
246
+
247
+ // Write channel secrets to guardian.env (the canonical source)
248
+ writeChannelSecrets(state.vaultDir, channelSecrets);
249
+
250
+ // Write system.env (no channel secrets — those live in guardian.env)
251
+ writeSystemEnv(state);
252
+
253
+ // Ensure env schema directories exist
254
+ ensureUserEnvSchema();
255
+ ensureSystemEnvSchema();
256
+
257
+ const spec = readStackSpec(state.configDir);
258
+ // Write OP_CAP_* capability vars to stack.env from stack spec
259
+ if (spec) {
260
+ writeCapabilityVars(spec, state.vaultDir);
261
+ }
262
+
263
+ // Generate redact.env.schema from canonical mappings
264
+ const systemEnv = readStackEnv(state.vaultDir);
265
+ const redactDir = `${state.dataDir}/secrets`;
266
+ mkdirSync(redactDir, { recursive: true });
267
+ writeFileSync(`${redactDir}/redact.env.schema`, generateRedactSchema(systemEnv));
268
+
269
+ state.artifactMeta = buildRuntimeFileMeta(state.artifacts);
270
+ }
@@ -1,278 +1,102 @@
1
1
  /**
2
- * Core asset management for the OpenPalm control plane.
2
+ * Core runtime asset management for the OpenPalm control plane.
3
3
  *
4
- * Manages DATA_HOME source-of-truth files: Caddyfile and docker-compose.yml.
5
- * All asset content is provided by a CoreAssetProvider (injected), not by
6
- * Vite $assets imports making this module portable across Bun/Node/Vite.
4
+ * Manages source-of-truth files for the ~/.openpalm/ layout:
5
+ * stack/ — compose runtime assets (core.compose.yml)
6
+ * vault/ env schemas
7
+ *
8
+ * This module manages runtime-owned core files only.
9
+ * Registry catalog refresh is handled separately in registry.ts.
10
+ * All ensure* functions verify that the expected files exist at OP_HOME.
11
+ * They create directories as needed but do NOT write file content — that
12
+ * is the responsibility of `refreshCoreAssets()` (GitHub download) or
13
+ * the CLI install command (which downloads assets before calling setup).
7
14
  */
8
- import { mkdirSync, writeFileSync, readFileSync, existsSync, copyFileSync, renameSync } from "node:fs";
9
- import { createHash } from "node:crypto";
15
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, copyFileSync } from "node:fs";
10
16
  import { dirname, join } from "node:path";
11
- import { resolveDataHome } from "./paths.js";
17
+ import { resolveDataDir, resolveVaultDir, resolveOpenPalmHome, resolveBackupsDir } from "./home.js";
12
18
  import { createLogger } from "../logger.js";
13
- import type { CoreAssetProvider } from "./core-asset-provider.js";
19
+ import { sha256 } from "./crypto.js";
14
20
 
15
21
  const logger = createLogger("core-assets");
16
22
 
17
- // ── Constants ──────────────────────────────────────────────────────────
18
-
19
- const PUBLIC_ACCESS_IMPORT = "import public_access";
20
- const LAN_ONLY_IMPORT = "import lan_only";
21
-
22
- /** IP ranges for each access scope mode */
23
- const HOST_ONLY_IPS = "127.0.0.0/8 ::1";
24
- const LAN_IPS = "10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 127.0.0.0/8 ::1 fc00::/7 fe80::/10";
25
- const REMOTE_IP_LINE_RE = /@denied not remote_ip [^\n]+/;
26
-
27
- // Re-export for use by staging.ts Caddyfile staging
28
- export { PUBLIC_ACCESS_IMPORT, LAN_ONLY_IMPORT };
29
-
30
- /** SHA-256 hex digest of a string. */
31
- function sha256(content: string): string {
32
- return createHash("sha256").update(content).digest("hex");
33
- }
23
+ // ── Env Schema Files (vault/) ────────────────────────────────────────
34
24
 
35
25
  /**
36
- * Write content to a file if it has changed, backing up the old version.
26
+ * Ensure the user env schema directory exists and return the expected
27
+ * schema file path. The file itself may not exist yet — it is written
28
+ * by refreshCoreAssets() or the CLI install command.
37
29
  */
38
- function writeIfChanged(path: string, content: string): void {
39
- if (!existsSync(path)) {
40
- writeFileSync(path, content);
41
- return;
42
- }
43
- const existing = readFileSync(path, "utf-8");
44
- if (sha256(existing) === sha256(content)) return;
45
-
46
- const backupDir = join(dirname(path), "backups");
47
- mkdirSync(backupDir, { recursive: true });
48
- const ts = new Date().toISOString().replace(/[:.]/g, "-");
49
- const basename = path.split("/").pop()!;
50
- copyFileSync(path, join(backupDir, `${basename}.${ts}`));
51
- writeFileSync(path, content);
52
- }
53
-
54
- // ── Core Caddyfile (DATA_HOME source of truth) ─────────────────────────
55
-
56
- function coreCaddyfilePath(): string {
57
- return `${resolveDataHome()}/caddy/Caddyfile`;
30
+ export function ensureUserEnvSchema(): string {
31
+ const vaultDir = resolveVaultDir();
32
+ const dir = `${vaultDir}/user`;
33
+ mkdirSync(dir, { recursive: true });
34
+ const path = `${dir}/user.env.schema`;
35
+ return path;
58
36
  }
59
37
 
60
38
  /**
61
- * Ensure the system-managed core Caddyfile exists in DATA_HOME.
62
- * Seeds the bundled asset on first run. On subsequent runs, leaves the
63
- * existing file intact (user may have customized access scope).
39
+ * Ensure the system env schema directory exists and return the expected
40
+ * schema file path. The file itself may not exist yet it is written
41
+ * by refreshCoreAssets() or the CLI install command.
64
42
  */
65
- export function ensureCoreCaddyfile(assets: CoreAssetProvider): string {
66
- const path = coreCaddyfilePath();
67
- mkdirSync(dirname(path), { recursive: true });
68
- if (!existsSync(path)) {
69
- writeFileSync(path, assets.caddyfile());
70
- }
71
- return path;
72
- }
73
-
74
- export function readCoreCaddyfile(assets: CoreAssetProvider): string {
75
- const path = ensureCoreCaddyfile(assets);
76
- return readFileSync(path, "utf-8");
77
- }
78
-
79
- // ── Env Schema Files (DATA_HOME root) ────────────────────────────────
80
-
81
- export function ensureSecretsSchema(assets: CoreAssetProvider): string {
82
- const path = `${resolveDataHome()}/secrets.env.schema`;
83
- if (!existsSync(path)) {
84
- writeFileSync(path, assets.secretsSchema());
85
- }
86
- return path;
87
- }
88
-
89
- export function ensureStackSchema(assets: CoreAssetProvider): string {
90
- const path = `${resolveDataHome()}/stack.env.schema`;
91
- if (!existsSync(path)) {
92
- writeFileSync(path, assets.stackSchema());
93
- }
43
+ export function ensureSystemEnvSchema(): string {
44
+ const vaultDir = resolveVaultDir();
45
+ const dir = `${vaultDir}/stack`;
46
+ mkdirSync(dir, { recursive: true });
47
+ const path = `${dir}/stack.env.schema`;
94
48
  return path;
95
49
  }
96
50
 
97
- export function detectAccessScope(rawCaddyfile: string): "host" | "lan" | "custom" {
98
- const match = rawCaddyfile.match(REMOTE_IP_LINE_RE);
99
- if (!match) return "custom";
100
- const ips = match[0].replace("@denied not remote_ip", "").trim();
101
- if (ips === HOST_ONLY_IPS) return "host";
102
- if (ips === LAN_IPS) return "lan";
103
- return "custom";
104
- }
105
-
106
- export function setCoreCaddyAccessScope(
107
- scope: "host" | "lan",
108
- assets: CoreAssetProvider
109
- ): { ok: true } | { ok: false; error: string } {
110
- const path = ensureCoreCaddyfile(assets);
111
- const raw = readFileSync(path, "utf-8");
112
- if (!REMOTE_IP_LINE_RE.test(raw)) {
113
- return { ok: false, error: "core Caddyfile missing '@denied not remote_ip' line" };
114
- }
115
- const ips = scope === "host" ? HOST_ONLY_IPS : LAN_IPS;
116
- const updated = raw.replace(REMOTE_IP_LINE_RE, `@denied not remote_ip ${ips}`);
117
- writeFileSync(path, updated);
118
- return { ok: true };
119
- }
120
-
121
- // ── Memory data directory (DATA_HOME) ────────────────────────────────────
122
-
123
- export function ensureMemoryDir(): string {
124
- const dataHome = resolveDataHome();
125
- const dir = `${dataHome}/memory`;
126
- const legacyDir = `${dataHome}/openmemory`;
127
-
128
- if (!existsSync(dir) && existsSync(legacyDir)) {
129
- try {
130
- renameSync(legacyDir, dir);
131
- } catch (error) {
132
- const code = error instanceof Error && "code" in error ? String(error.code) : "unknown";
133
- const message = error instanceof Error ? error.message : String(error);
134
- logger.warn("failed to migrate legacy memory dir", { legacyDir, dir, code, message });
135
- }
136
- }
51
+ // ── Memory data directory ────────────────────────────────────────────
137
52
 
53
+ export function ensureMemoryDir(dataDir?: string): string {
54
+ const resolved = dataDir ?? resolveDataDir();
55
+ const dir = `${resolved}/memory`;
138
56
  mkdirSync(dir, { recursive: true });
139
57
  return dir;
140
58
  }
141
59
 
142
- // ── Core Compose (DATA_HOME source of truth) ──────────────────────────
60
+ // ── Core Compose (stack/) ─────────────────────────────────────────────
143
61
 
144
62
  function coreComposePath(): string {
145
- return `${resolveDataHome()}/docker-compose.yml`;
63
+ return `${resolveOpenPalmHome()}/stack/core.compose.yml`;
146
64
  }
147
65
 
148
- export function ensureCoreCompose(assets: CoreAssetProvider): string {
66
+ export function ensureCoreCompose(): string {
149
67
  const path = coreComposePath();
150
- const content = assets.coreCompose();
151
- mkdirSync(dirname(path), { recursive: true });
152
- if (!existsSync(path)) {
153
- writeFileSync(path, content);
154
- } else if (sha256(readFileSync(path, "utf-8")) !== sha256(content)) {
155
- const backupDir = join(dirname(path), "backups");
156
- mkdirSync(backupDir, { recursive: true });
157
- const ts = new Date().toISOString().replace(/[:.]/g, "-");
158
- copyFileSync(path, join(backupDir, `docker-compose.${ts}.yml`));
159
- writeFileSync(path, content);
160
- }
161
- return path;
162
- }
163
-
164
- export function readCoreCompose(assets: CoreAssetProvider): string {
165
- const path = ensureCoreCompose(assets);
166
- return readFileSync(path, "utf-8");
167
- }
168
-
169
- // ── Ollama Compose Overlay (DATA_HOME source of truth) ──────────────
170
-
171
- function ollamaComposePath(): string {
172
- return `${resolveDataHome()}/ollama.yml`;
173
- }
174
-
175
- export function ensureOllamaCompose(assets: CoreAssetProvider): string {
176
- const path = ollamaComposePath();
177
- const content = assets.ollamaCompose();
178
- mkdirSync(dirname(path), { recursive: true });
179
- if (!existsSync(path)) {
180
- writeFileSync(path, content);
181
- } else if (sha256(readFileSync(path, "utf-8")) !== sha256(content)) {
182
- const backupDir = join(dirname(path), "backups");
183
- mkdirSync(backupDir, { recursive: true });
184
- const ts = new Date().toISOString().replace(/[:.]/g, "-");
185
- copyFileSync(path, join(backupDir, `ollama.${ts}.yml`));
186
- writeFileSync(path, content);
187
- }
188
- return path;
189
- }
190
-
191
- export function readOllamaCompose(assets: CoreAssetProvider): string {
192
- const path = ensureOllamaCompose(assets);
193
- return readFileSync(path, "utf-8");
194
- }
195
-
196
- // ── Admin Compose Overlay (DATA_HOME source of truth) ────────────────
197
-
198
- function adminComposePath(): string {
199
- return `${resolveDataHome()}/admin.yml`;
200
- }
201
-
202
- export function ensureAdminCompose(assets: CoreAssetProvider): string {
203
- const path = adminComposePath();
204
- const content = assets.adminCompose();
205
68
  mkdirSync(dirname(path), { recursive: true });
206
- if (!existsSync(path)) {
207
- writeFileSync(path, content);
208
- } else if (sha256(readFileSync(path, "utf-8")) !== sha256(content)) {
209
- const backupDir = join(dirname(path), "backups");
210
- mkdirSync(backupDir, { recursive: true });
211
- const ts = new Date().toISOString().replace(/[:.]/g, "-");
212
- copyFileSync(path, join(backupDir, `admin.${ts}.yml`));
213
- writeFileSync(path, content);
214
- }
215
69
  return path;
216
70
  }
217
71
 
218
- export function readAdminCompose(assets: CoreAssetProvider): string {
219
- const path = ensureAdminCompose(assets);
72
+ export function readCoreCompose(): string {
73
+ const path = coreComposePath();
220
74
  return readFileSync(path, "utf-8");
221
75
  }
222
76
 
223
- // ── OpenCode System Config (DATA_HOME source of truth) ──────────────
224
-
225
- export function ensureOpenCodeSystemConfig(assets: CoreAssetProvider): void {
226
- const dir = `${resolveDataHome()}/assistant`;
227
- mkdirSync(dir, { recursive: true });
228
- writeIfChanged(`${dir}/opencode.jsonc`, assets.opencodeConfig());
229
- writeIfChanged(`${dir}/AGENTS.md`, assets.agentsMd());
230
- }
231
-
232
- export function ensureAdminOpenCodeConfig(assets: CoreAssetProvider): void {
233
- const dir = `${resolveDataHome()}/admin`;
234
- mkdirSync(dir, { recursive: true });
235
- writeIfChanged(`${dir}/opencode.jsonc`, assets.adminOpencodeConfig());
236
- writeIfChanged(`${dir}/AGENTS.md`, assets.agentsMd());
237
- }
238
-
239
- // ── Core Automations (DATA_HOME source of truth) ────────────────────
77
+ // ── OpenCode System Config ──────────────────────────────────────────
240
78
 
241
- export function ensureCoreAutomations(assets: CoreAssetProvider): void {
242
- const dir = `${resolveDataHome()}/automations`;
79
+ export function ensureOpenCodeSystemConfig(): void {
80
+ const dir = `${resolveDataDir()}/assistant`;
243
81
  mkdirSync(dir, { recursive: true });
244
-
245
- const coreAutomations = [
246
- { filename: "cleanup-logs.yml", content: assets.cleanupLogs() },
247
- { filename: "cleanup-data.yml", content: assets.cleanupData() },
248
- { filename: "validate-config.yml", content: assets.validateConfig() },
249
- ];
250
-
251
- for (const { filename, content } of coreAutomations) {
252
- writeIfChanged(join(dir, filename), content);
253
- }
254
82
  }
255
83
 
256
84
  // ── Asset Refresh (GitHub download) ──────────────────────────────────
257
85
 
258
86
  const REPO = "itlackey/openpalm";
259
- const VERSION = process.env.OPENPALM_ASSET_VERSION ?? "main";
260
-
261
- const MANAGED_ASSETS: { dataRelPath: string; githubFilename: string }[] = [
262
- { dataRelPath: "docker-compose.yml", githubFilename: "docker-compose.yml" },
263
- { dataRelPath: "caddy/Caddyfile", githubFilename: "Caddyfile" },
264
- { dataRelPath: "assistant/opencode.jsonc", githubFilename: "opencode.jsonc" },
265
- { dataRelPath: "admin/opencode.jsonc", githubFilename: "admin-opencode.jsonc" },
266
- { dataRelPath: "assistant/AGENTS.md", githubFilename: "AGENTS.md" },
267
- { dataRelPath: "ollama.yml", githubFilename: "ollama.yml" },
268
- { dataRelPath: "admin.yml", githubFilename: "admin.yml" },
269
- { dataRelPath: "secrets.env.schema", githubFilename: "secrets.env.schema" },
270
- { dataRelPath: "stack.env.schema", githubFilename: "stack.env.schema" }
87
+ const VERSION = process.env.OP_ASSET_VERSION ?? "main";
88
+
89
+ const MANAGED_ASSETS: { relPath: string; githubFilename: string }[] = [
90
+ { relPath: "stack/core.compose.yml", githubFilename: ".openpalm/stack/core.compose.yml" },
91
+ { relPath: "data/assistant/opencode.jsonc", githubFilename: "core/assistant/opencode/opencode.jsonc" },
92
+ { relPath: "data/assistant/AGENTS.md", githubFilename: "core/assistant/opencode/AGENTS.md" },
93
+ { relPath: "vault/user/user.env.schema", githubFilename: ".openpalm/vault/user/user.env.schema" },
94
+ { relPath: "vault/stack/stack.env.schema", githubFilename: ".openpalm/vault/stack/stack.env.schema" },
271
95
  ];
272
96
 
273
97
  async function downloadAsset(filename: string): Promise<string> {
274
98
  const releaseUrl = `https://github.com/${REPO}/releases/download/${VERSION}/${filename}`;
275
- const rawUrl = `https://raw.githubusercontent.com/${REPO}/${VERSION}/assets/${filename}`;
99
+ const rawUrl = `https://raw.githubusercontent.com/${REPO}/${VERSION}/${filename}`;
276
100
 
277
101
  for (const url of [releaseUrl, rawUrl]) {
278
102
  try {
@@ -289,13 +113,13 @@ export async function refreshCoreAssets(): Promise<{
289
113
  backupDir: string | null;
290
114
  updated: string[];
291
115
  }> {
292
- const dataHome = resolveDataHome();
116
+ const homeDir = resolveOpenPalmHome();
293
117
  const updated: string[] = [];
294
118
  let backupDir: string | null = null;
295
119
 
296
120
  for (const asset of MANAGED_ASSETS) {
297
121
  const freshContent = await downloadAsset(asset.githubFilename);
298
- const targetPath = join(dataHome, asset.dataRelPath);
122
+ const targetPath = join(homeDir, asset.relPath);
299
123
 
300
124
  if (existsSync(targetPath)) {
301
125
  const currentContent = readFileSync(targetPath, "utf-8");
@@ -304,16 +128,16 @@ export async function refreshCoreAssets(): Promise<{
304
128
  }
305
129
 
306
130
  if (!backupDir) {
307
- backupDir = join(dataHome, "backups", new Date().toISOString().replace(/[:.]/g, "-"));
131
+ backupDir = join(resolveBackupsDir(), new Date().toISOString().replace(/[:.]/g, "-"));
308
132
  }
309
- const backupPath = join(backupDir, asset.dataRelPath);
133
+ const backupPath = join(backupDir, asset.relPath);
310
134
  mkdirSync(dirname(backupPath), { recursive: true });
311
135
  copyFileSync(targetPath, backupPath);
312
136
  }
313
137
 
314
138
  mkdirSync(dirname(targetPath), { recursive: true });
315
139
  writeFileSync(targetPath, freshContent);
316
- updated.push(asset.dataRelPath);
140
+ updated.push(asset.relPath);
317
141
  }
318
142
 
319
143
  return { backupDir, updated };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Shared cryptographic utilities for the control plane.
3
+ */
4
+ import { createHash, randomBytes } from "node:crypto";
5
+
6
+ /** SHA-256 hex digest of a string. */
7
+ export function sha256(content: string): string {
8
+ return createHash("sha256").update(content).digest("hex");
9
+ }
10
+
11
+ /** Generate a hex string using Node's crypto.randomBytes (CSPRNG). */
12
+ export function randomHex(bytes: number): string {
13
+ return randomBytes(bytes).toString("hex");
14
+ }