@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.
- package/README.md +31 -71
- package/package.json +1 -1
- package/src/control-plane/audit.ts +4 -4
- package/src/control-plane/backup.ts +31 -0
- package/src/control-plane/channels.ts +88 -156
- package/src/control-plane/cleanup-guardrails.test.ts +289 -0
- package/src/control-plane/compose-args.test.ts +170 -0
- package/src/control-plane/compose-args.ts +57 -0
- package/src/control-plane/config-persistence.ts +270 -0
- package/src/control-plane/core-assets.ts +58 -234
- package/src/control-plane/crypto.ts +14 -0
- package/src/control-plane/docker.ts +94 -204
- package/src/control-plane/env-schema-validation.test.ts +118 -0
- package/src/control-plane/extends-support.test.ts +105 -0
- package/src/control-plane/home.ts +133 -0
- package/src/control-plane/install-edge-cases.test.ts +314 -717
- package/src/control-plane/lifecycle.ts +215 -233
- package/src/control-plane/lock.test.ts +194 -0
- package/src/control-plane/lock.ts +176 -0
- package/src/control-plane/memory-config.ts +34 -160
- package/src/control-plane/opencode-client.test.ts +154 -0
- package/src/control-plane/opencode-client.ts +113 -0
- package/src/control-plane/provider-config.ts +34 -0
- package/src/control-plane/redact-schema.ts +50 -0
- package/src/control-plane/registry-components.test.ts +313 -0
- package/src/control-plane/registry.test.ts +414 -0
- package/src/control-plane/registry.ts +418 -0
- package/src/control-plane/rollback.ts +128 -0
- package/src/control-plane/scheduler.ts +18 -190
- package/src/control-plane/secret-backend.test.ts +359 -0
- package/src/control-plane/secret-backend.ts +322 -0
- package/src/control-plane/secret-mappings.ts +185 -0
- package/src/control-plane/secrets.ts +186 -112
- package/src/control-plane/setup-config.schema.json +306 -0
- package/src/control-plane/setup-status.ts +15 -8
- package/src/control-plane/setup-validation.ts +90 -0
- package/src/control-plane/setup.test.ts +336 -929
- package/src/control-plane/setup.ts +159 -849
- package/src/control-plane/spec-to-env.test.ts +100 -0
- package/src/control-plane/spec-to-env.ts +195 -0
- package/src/control-plane/spec-validator.ts +159 -0
- package/src/control-plane/stack-spec.test.ts +150 -0
- package/src/control-plane/stack-spec.ts +101 -22
- package/src/control-plane/types.ts +6 -99
- package/src/control-plane/validate.ts +107 -0
- package/src/index.ts +101 -159
- package/src/provider-constants.ts +2 -31
- package/src/control-plane/connection-mapping.ts +0 -191
- package/src/control-plane/connection-migration-flags.ts +0 -40
- package/src/control-plane/connection-profiles.ts +0 -317
- package/src/control-plane/core-asset-provider.ts +0 -21
- package/src/control-plane/fs-asset-provider.ts +0 -65
- package/src/control-plane/fs-registry-provider.ts +0 -46
- package/src/control-plane/paths.ts +0 -77
- package/src/control-plane/registry-provider.ts +0 -19
- 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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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 {
|
|
17
|
+
import { resolveDataDir, resolveVaultDir, resolveOpenPalmHome, resolveBackupsDir } from "./home.js";
|
|
12
18
|
import { createLogger } from "../logger.js";
|
|
13
|
-
import
|
|
19
|
+
import { sha256 } from "./crypto.js";
|
|
14
20
|
|
|
15
21
|
const logger = createLogger("core-assets");
|
|
16
22
|
|
|
17
|
-
// ──
|
|
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
|
-
*
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
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
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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 (
|
|
60
|
+
// ── Core Compose (stack/) ─────────────────────────────────────────────
|
|
143
61
|
|
|
144
62
|
function coreComposePath(): string {
|
|
145
|
-
return `${
|
|
63
|
+
return `${resolveOpenPalmHome()}/stack/core.compose.yml`;
|
|
146
64
|
}
|
|
147
65
|
|
|
148
|
-
export function ensureCoreCompose(
|
|
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
|
|
219
|
-
const path =
|
|
72
|
+
export function readCoreCompose(): string {
|
|
73
|
+
const path = coreComposePath();
|
|
220
74
|
return readFileSync(path, "utf-8");
|
|
221
75
|
}
|
|
222
76
|
|
|
223
|
-
// ── OpenCode System Config
|
|
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
|
|
242
|
-
const dir = `${
|
|
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.
|
|
260
|
-
|
|
261
|
-
const MANAGED_ASSETS: {
|
|
262
|
-
{
|
|
263
|
-
{
|
|
264
|
-
{
|
|
265
|
-
{
|
|
266
|
-
{
|
|
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}
|
|
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
|
|
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(
|
|
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(
|
|
131
|
+
backupDir = join(resolveBackupsDir(), new Date().toISOString().replace(/[:.]/g, "-"));
|
|
308
132
|
}
|
|
309
|
-
const backupPath = join(backupDir, asset.
|
|
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.
|
|
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
|
+
}
|