@openpalm/lib 0.11.0-beta.9 → 0.11.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/control-plane/akm-sources.test.ts +206 -0
- package/src/control-plane/akm-sources.ts +234 -0
- package/src/control-plane/akm-user-env.test.ts +142 -0
- package/src/control-plane/akm-user-env.ts +167 -0
- package/src/control-plane/backup.ts +14 -5
- package/src/control-plane/channels.ts +48 -29
- package/src/control-plane/cleanup-guardrails.test.ts +1 -1
- package/src/control-plane/compose-args.test.ts +67 -30
- package/src/control-plane/compose-args.ts +63 -8
- package/src/control-plane/config-persistence.ts +95 -136
- package/src/control-plane/core-assets.ts +21 -44
- package/src/control-plane/docker.ts +15 -14
- package/src/control-plane/env.test.ts +10 -10
- package/src/control-plane/env.ts +1 -1
- package/src/control-plane/extends-support.test.ts +8 -8
- package/src/control-plane/fs-atomic.ts +15 -0
- package/src/control-plane/home.ts +34 -46
- package/src/control-plane/host-akm-sharing.test.ts +145 -0
- package/src/control-plane/host-akm-sharing.ts +129 -0
- package/src/control-plane/host-opencode.test.ts +82 -10
- package/src/control-plane/host-opencode.ts +42 -13
- package/src/control-plane/install-edge-cases.test.ts +98 -105
- package/src/control-plane/install-lock.ts +7 -7
- package/src/control-plane/lifecycle.ts +37 -36
- package/src/control-plane/markdown-task.ts +30 -50
- package/src/control-plane/opencode-client.ts +1 -1
- package/src/control-plane/paths.ts +61 -46
- package/src/control-plane/profile-ids.ts +21 -0
- package/src/control-plane/provider-models.ts +3 -3
- package/src/control-plane/registry.test.ts +107 -90
- package/src/control-plane/registry.ts +288 -109
- package/src/control-plane/rollback.ts +8 -38
- package/src/control-plane/scheduler.ts +10 -7
- package/src/control-plane/secret-audit.test.ts +159 -0
- package/src/control-plane/secret-audit.ts +255 -0
- package/src/control-plane/secret-mappings.ts +2 -2
- package/src/control-plane/secrets-files.test.ts +99 -0
- package/src/control-plane/secrets-files.ts +113 -0
- package/src/control-plane/secrets.ts +113 -86
- package/src/control-plane/setup-config.schema.json +1 -1
- package/src/control-plane/setup-status.ts +6 -11
- package/src/control-plane/setup.test.ts +140 -44
- package/src/control-plane/setup.ts +85 -62
- package/src/control-plane/skeleton-guardrail.test.ts +64 -55
- package/src/control-plane/spec-to-env.test.ts +63 -26
- package/src/control-plane/spec-to-env.ts +49 -12
- package/src/control-plane/stack-spec.test.ts +15 -11
- package/src/control-plane/stack-spec.ts +31 -10
- package/src/control-plane/task-files.test.ts +45 -0
- package/src/control-plane/task-files.ts +51 -0
- package/src/control-plane/types.ts +2 -4
- package/src/control-plane/ui-assets.test.ts +130 -0
- package/src/control-plane/ui-assets.ts +132 -57
- package/src/control-plane/validate.ts +13 -15
- package/src/index.ts +86 -16
- package/src/control-plane/akm-vault.test.ts +0 -105
- package/src/control-plane/akm-vault.ts +0 -311
- package/src/control-plane/core-assets.test.ts +0 -104
- package/src/control-plane/migrate-0110.test.ts +0 -177
- package/src/control-plane/migrate-0110.ts +0 -99
- package/src/control-plane/registry-components.test.ts +0 -391
|
@@ -3,58 +3,53 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Writes and derives live runtime files (compose, env, schemas).
|
|
5
5
|
* Files are validated in-place before writing; rollback is handled by
|
|
6
|
-
* the rollback module (snapshot to
|
|
6
|
+
* the rollback module (snapshot to OP_HOME/data/rollback/).
|
|
7
7
|
*/
|
|
8
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync,
|
|
9
|
-
import { dirname } from "node:path";
|
|
8
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync } from "node:fs";
|
|
9
|
+
import { dirname, resolve as resolvePath } from "node:path";
|
|
10
10
|
import { parse as yamlParse } from "yaml";
|
|
11
|
-
import { parseEnvFile, mergeEnvContent, expandEnvVars } from './env.js';
|
|
11
|
+
import { parseEnvContent, parseEnvFile, mergeEnvContent, expandEnvVars } from './env.js';
|
|
12
|
+
import { assertNoSecretLikeStackEnvKeys, isSecretLikeStackEnvKey } from './secrets.js';
|
|
13
|
+
import { ensureSecret } from './secrets-files.js';
|
|
12
14
|
import type { ControlPlaneState, ArtifactMeta } from "./types.js";
|
|
13
|
-
import { isChannelAddon } from "./channels.js";
|
|
14
15
|
import { listEnabledAddonIds } from "./registry.js";
|
|
15
16
|
import { resolveOperatorIds, hasUsableOperatorId } from "./operator-ids.js";
|
|
17
|
+
import { SPEC_DEFAULTS } from "./stack-spec.js";
|
|
16
18
|
|
|
17
19
|
import {
|
|
18
20
|
readCoreCompose,
|
|
21
|
+
readBundledStackAsset,
|
|
19
22
|
} from "./core-assets.js";
|
|
20
23
|
export { sha256, randomHex } from "./crypto.js";
|
|
21
24
|
import { sha256, randomHex } from "./crypto.js";
|
|
22
25
|
|
|
23
|
-
const DEFAULT_IMAGE_TAG =
|
|
26
|
+
const DEFAULT_IMAGE_TAG = "latest";
|
|
24
27
|
|
|
25
28
|
// ── Env File Management ──────────────────────────────────────────────
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
31
|
* Return the env files used for docker compose --env-file args.
|
|
29
|
-
* These are the live vault env files.
|
|
30
32
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* `vault:user` store and are sourced by the assistant entrypoint at
|
|
36
|
-
* container startup. The legacy file is migrated into akm and deleted
|
|
37
|
-
* on upgrade; subsequent `docker compose` invocations must not reference
|
|
38
|
-
* it (compose interpolates `${VAR}` against the merged --env-file
|
|
39
|
-
* contents, and a stale user.env would shadow the akm-sourced values).
|
|
33
|
+
* Only `knowledge/env/stack.env` (non-secret system config). Secret values
|
|
34
|
+
* live in `knowledge/secrets/<ENV_KEY>` and are granted to services as Compose
|
|
35
|
+
* file secrets. The user env (`knowledge/env/user.env`) is NOT a compose
|
|
36
|
+
* env_file — it is sourced by the assistant entrypoint at container startup.
|
|
40
37
|
*/
|
|
41
38
|
export function buildEnvFiles(state: ControlPlaneState): string[] {
|
|
42
39
|
return [
|
|
43
|
-
`${state.
|
|
44
|
-
`${state.stackDir}/guardian.env`,
|
|
40
|
+
`${state.stashDir}/env/stack.env`,
|
|
45
41
|
].filter(existsSync);
|
|
46
42
|
}
|
|
47
43
|
|
|
48
44
|
/**
|
|
49
|
-
* Write system-managed values to
|
|
45
|
+
* Write system-managed values to knowledge/env/stack.env.
|
|
50
46
|
*
|
|
51
|
-
*
|
|
52
|
-
* Use
|
|
47
|
+
* Secret-like keys are NOT written here — they belong in knowledge/secrets/.
|
|
48
|
+
* Use ensureChannelSecret() for channel secrets.
|
|
53
49
|
*/
|
|
54
50
|
export function writeSystemEnv(state: ControlPlaneState): void {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const systemEnvPath = `${state.stackDir}/stack.env`;
|
|
51
|
+
const systemEnvPath = `${state.stashDir}/env/stack.env`;
|
|
52
|
+
mkdirSync(`${state.stashDir}/env`, { recursive: true, mode: 0o700 });
|
|
58
53
|
|
|
59
54
|
let base = "";
|
|
60
55
|
if (existsSync(systemEnvPath)) {
|
|
@@ -63,11 +58,12 @@ export function writeSystemEnv(state: ControlPlaneState): void {
|
|
|
63
58
|
base = generateFallbackSystemEnv(state);
|
|
64
59
|
}
|
|
65
60
|
|
|
66
|
-
// Preserve existing OP_SETUP_COMPLETE
|
|
67
|
-
|
|
68
|
-
|
|
61
|
+
// Preserve the existing OP_SETUP_COMPLETE flag as-is.
|
|
62
|
+
// Only the wizard completion path (buildSystemSecretsFromSetup) writes "true".
|
|
63
|
+
// Defaulting to "false" here ensures a fresh install always shows the wizard.
|
|
64
|
+
const parsed = parseEnvFile(systemEnvPath);
|
|
69
65
|
const adminManaged: Record<string, string> = {
|
|
70
|
-
OP_SETUP_COMPLETE:
|
|
66
|
+
OP_SETUP_COMPLETE: parsed.OP_SETUP_COMPLETE === "true" ? "true" : "false",
|
|
71
67
|
};
|
|
72
68
|
|
|
73
69
|
// Backfill OP_UID/OP_GID when the existing stack.env was written by an
|
|
@@ -76,13 +72,20 @@ export function writeSystemEnv(state: ControlPlaneState): void {
|
|
|
76
72
|
// missing or zero — an operator who manually set OP_UID=2000 (e.g.
|
|
77
73
|
// because they're running on a host with a non-1000 service account)
|
|
78
74
|
// must not be silently changed.
|
|
79
|
-
const parsed = parseEnvFile(systemEnvPath);
|
|
80
75
|
const ids = resolveOperatorIds(state.homeDir);
|
|
81
76
|
if (ids) {
|
|
82
77
|
if (!hasUsableOperatorId(parsed, "OP_UID")) adminManaged.OP_UID = String(ids.uid);
|
|
83
78
|
if (!hasUsableOperatorId(parsed, "OP_GID")) adminManaged.OP_GID = String(ids.gid);
|
|
84
79
|
}
|
|
85
80
|
|
|
81
|
+
// Backfill OP_HOME when missing — compose files reference ${OP_HOME}
|
|
82
|
+
// for all volume mounts. Without this, Docker Compose defaults to blank.
|
|
83
|
+
if (!parsed.OP_HOME) adminManaged.OP_HOME = state.homeDir;
|
|
84
|
+
|
|
85
|
+
base = stripSecretLikeEnvKeys(base);
|
|
86
|
+
assertNoSecretLikeStackEnvKeys(parseEnvContent(base));
|
|
87
|
+
assertNoSecretLikeStackEnvKeys(adminManaged);
|
|
88
|
+
|
|
86
89
|
const content = mergeEnvContent(base, adminManaged, {
|
|
87
90
|
sectionHeader: "# ── Admin-managed ──────────────────────────────────────────────────"
|
|
88
91
|
});
|
|
@@ -91,6 +94,19 @@ export function writeSystemEnv(state: ControlPlaneState): void {
|
|
|
91
94
|
chmodSync(systemEnvPath, 0o600);
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
function stripSecretLikeEnvKeys(content: string): string {
|
|
98
|
+
return content
|
|
99
|
+
.split('\n')
|
|
100
|
+
.filter((line) => {
|
|
101
|
+
let trimmed = line.trim();
|
|
102
|
+
if (trimmed.startsWith('export ')) trimmed = trimmed.slice(7).trimStart();
|
|
103
|
+
const eq = trimmed.indexOf('=');
|
|
104
|
+
if (eq <= 0) return true;
|
|
105
|
+
return !isSecretLikeStackEnvKey(trimmed.slice(0, eq).trim());
|
|
106
|
+
})
|
|
107
|
+
.join('\n');
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
function generateFallbackSystemEnv(state: ControlPlaneState): string {
|
|
95
111
|
// Operator UID/GID — auto-detect from OP_HOME owner (or process UID).
|
|
96
112
|
// Skipped on Windows where containers run in WSL2 and OP_UID has no
|
|
@@ -104,12 +120,6 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
|
|
|
104
120
|
"# OpenPalm — System Configuration (managed by CLI/admin)",
|
|
105
121
|
"# Auto-generated fallback.",
|
|
106
122
|
"",
|
|
107
|
-
"# ── Authentication ──────────────────────────────────────────────────",
|
|
108
|
-
`OP_UI_LOGIN_PASSWORD=\${OP_UI_LOGIN_PASSWORD}`,
|
|
109
|
-
"",
|
|
110
|
-
"# ── Service Auth ─────────────────────────────────────────────────────",
|
|
111
|
-
"OP_OPENCODE_PASSWORD=",
|
|
112
|
-
"",
|
|
113
123
|
"# ── Paths ──────────────────────────────────────────────────────────",
|
|
114
124
|
`OP_HOME=${state.homeDir}`,
|
|
115
125
|
...idLines,
|
|
@@ -121,9 +131,8 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
|
|
|
121
131
|
"# ── Ports (38XX range) ──────────────────────────────────────────────",
|
|
122
132
|
"# Guardian is network-only (no host port) — channels reach it via",
|
|
123
133
|
"# http://guardian:8080 over the channel_lan Docker network.",
|
|
124
|
-
`OP_ASSISTANT_PORT
|
|
125
|
-
`
|
|
126
|
-
`OP_ADMIN_OPENCODE_PORT=3881`,
|
|
134
|
+
`OP_ASSISTANT_PORT=${SPEC_DEFAULTS.ports.assistant}`,
|
|
135
|
+
`OP_HOST_UI_PORT=${SPEC_DEFAULTS.ports.hostUi}`,
|
|
127
136
|
""
|
|
128
137
|
].join("\n");
|
|
129
138
|
}
|
|
@@ -131,37 +140,25 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
|
|
|
131
140
|
// ── Stack Overlay Discovery ────────────────────────────────────────────
|
|
132
141
|
|
|
133
142
|
/**
|
|
134
|
-
* Discover compose overlays
|
|
135
|
-
* Returns
|
|
143
|
+
* Discover active compose overlays.
|
|
144
|
+
* Returns the fixed compose stack: core, services, channels, and custom.
|
|
145
|
+
* First-party services are profile-gated inside services.compose.yml and
|
|
146
|
+
* channels.compose.yml.
|
|
147
|
+
*
|
|
148
|
+
* Host AKM sharing is NOT a compose overlay: the assistant always mounts
|
|
149
|
+
* `/host-stash` (core.compose.yml, with an empty-dir fallback), and "sharing"
|
|
150
|
+
* is purely a writable secondary source entry in config/akm/config.json. No
|
|
151
|
+
* conditional overlay file is involved.
|
|
136
152
|
*/
|
|
137
|
-
export function discoverStackOverlays(stackDir: string): string[] {
|
|
153
|
+
export function discoverStackOverlays(stackDir: string, _homeDir?: string): string[] {
|
|
138
154
|
const files: string[] = [];
|
|
139
155
|
|
|
140
156
|
const coreYml = `${stackDir}/core.compose.yml`;
|
|
141
157
|
if (existsSync(coreYml)) files.push(coreYml);
|
|
142
158
|
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
.filter((e) => e.isDirectory())
|
|
147
|
-
.sort((a, b) => a.name.localeCompare(b.name));
|
|
148
|
-
for (const entry of entries) {
|
|
149
|
-
const dir = `${addonsDir}/${entry.name}`;
|
|
150
|
-
// Pick up compose.yml plus any compose.<variant>.yml sibling
|
|
151
|
-
// overlays (e.g. compose.cdi.yml generated by /admin/voice on
|
|
152
|
-
// CDI hosts). Stable sort: compose.yml first, then siblings
|
|
153
|
-
// alphabetically, so the base file's keys are the defaults and
|
|
154
|
-
// overlays merge on top in deterministic order.
|
|
155
|
-
const overlays = readdirSync(dir, { withFileTypes: true })
|
|
156
|
-
.filter((e) => e.isFile() && /^compose(\.[A-Za-z0-9_-]+)?\.ya?ml$/.test(e.name))
|
|
157
|
-
.map((e) => e.name)
|
|
158
|
-
.sort((a, b) => {
|
|
159
|
-
if (a === "compose.yml" || a === "compose.yaml") return -1;
|
|
160
|
-
if (b === "compose.yml" || b === "compose.yaml") return 1;
|
|
161
|
-
return a.localeCompare(b);
|
|
162
|
-
});
|
|
163
|
-
for (const name of overlays) files.push(`${dir}/${name}`);
|
|
164
|
-
}
|
|
159
|
+
for (const name of ['services.compose.yml', 'channels.compose.yml', 'custom.compose.yml']) {
|
|
160
|
+
const composePath = `${stackDir}/${name}`;
|
|
161
|
+
if (existsSync(composePath)) files.push(composePath);
|
|
165
162
|
}
|
|
166
163
|
|
|
167
164
|
return files;
|
|
@@ -192,79 +189,38 @@ export function buildRuntimeFileMeta(artifacts: {
|
|
|
192
189
|
}
|
|
193
190
|
|
|
194
191
|
// ── Channel Secrets ────────────────────────────────────────────────────
|
|
195
|
-
// Channel HMAC secrets live exclusively in vault/stack/guardian.env.
|
|
196
|
-
|
|
197
|
-
const CHANNEL_SECRET_RE = /^CHANNEL_([A-Z0-9_]+)_SECRET$/;
|
|
198
|
-
|
|
199
|
-
/** Extract channel secrets from parsed env entries. */
|
|
200
|
-
function extractChannelSecrets(parsed: Record<string, string>): Record<string, string> {
|
|
201
|
-
const result: Record<string, string> = {};
|
|
202
|
-
for (const [key, value] of Object.entries(parsed)) {
|
|
203
|
-
const match = key.match(CHANNEL_SECRET_RE);
|
|
204
|
-
if (match?.[1] && value) result[match[1].toLowerCase()] = value;
|
|
205
|
-
}
|
|
206
|
-
return result;
|
|
207
|
-
}
|
|
208
192
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
*/
|
|
212
|
-
export function readChannelSecrets(stackDir: string): Record<string, string> {
|
|
213
|
-
return extractChannelSecrets(parseEnvFile(`${stackDir}/guardian.env`));
|
|
193
|
+
export function channelSecretName(addon: string): string {
|
|
194
|
+
return `channel_${addon.replace(/-/g, '_')}_secret`;
|
|
214
195
|
}
|
|
215
196
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
* Merges with existing content; does not overwrite unrelated entries.
|
|
219
|
-
*/
|
|
220
|
-
export function writeChannelSecrets(stackDir: string, secrets: Record<string, string>): void {
|
|
221
|
-
const guardianPath = `${stackDir}/guardian.env`;
|
|
222
|
-
mkdirSync(stackDir, { recursive: true });
|
|
223
|
-
|
|
224
|
-
let base = "";
|
|
225
|
-
if (existsSync(guardianPath)) {
|
|
226
|
-
base = readFileSync(guardianPath, "utf-8");
|
|
227
|
-
} else {
|
|
228
|
-
base = "# Guardian channel HMAC secrets — managed by openpalm\n";
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const updates: Record<string, string> = {};
|
|
232
|
-
for (const [ch, secret] of Object.entries(secrets)) {
|
|
233
|
-
updates[`CHANNEL_${ch.toUpperCase()}_SECRET`] = secret;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const content = mergeEnvContent(base, updates);
|
|
237
|
-
writeFileSync(guardianPath, content, { mode: 0o600 });
|
|
238
|
-
// Ensure correct permissions even if file already existed with wrong mode
|
|
239
|
-
chmodSync(guardianPath, 0o600);
|
|
197
|
+
export function ensureChannelSecret(stackDir: string, addon: string): string {
|
|
198
|
+
return ensureSecret(stackDir, channelSecretName(addon), () => randomHex(16));
|
|
240
199
|
}
|
|
241
200
|
|
|
242
201
|
// ── Volume Mount Targets ───────────────────────────────────────────────
|
|
243
202
|
|
|
244
203
|
/**
|
|
245
|
-
* Parse
|
|
246
|
-
*
|
|
247
|
-
* them as root-owned, which causes EACCES inside non-root
|
|
248
|
-
*
|
|
249
|
-
* For file mounts (basename contains a `.`), creates an empty file.
|
|
250
|
-
* For directory mounts (basename has no `.`), creates the directory.
|
|
251
|
-
*
|
|
252
|
-
* Heuristic: a basename containing a `.` is treated as a file. This
|
|
253
|
-
* intentionally includes leading-dot files (e.g. `.env`) because Docker
|
|
254
|
-
* bind mounts to them must be regular files. Bare directory names like
|
|
255
|
-
* `stack` or `addons` lack extensions and are created as directories.
|
|
204
|
+
* Parse enabled compose files and pre-create host-side volume mount
|
|
205
|
+
* targets under OP_HOME as the current user. This prevents Docker from
|
|
206
|
+
* creating them as root-owned, which causes EACCES inside non-root
|
|
207
|
+
* containers.
|
|
256
208
|
*
|
|
257
209
|
* Only mount sources under `state.homeDir` are touched; external paths
|
|
258
210
|
* (e.g. `/var/run/docker.sock`) are left alone.
|
|
211
|
+
*
|
|
212
|
+
* The file-vs-directory distinction is best-effort and only applies to
|
|
213
|
+
* explicit OP_HOME paths.
|
|
259
214
|
*/
|
|
260
215
|
export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
|
|
261
|
-
const composeFiles = discoverStackOverlays(state.stackDir);
|
|
216
|
+
const composeFiles = discoverStackOverlays(state.stackDir, state.homeDir);
|
|
262
217
|
if (composeFiles.length === 0) return;
|
|
263
218
|
|
|
264
219
|
const envVars: Record<string, string> = {
|
|
265
220
|
...(process.env as Record<string, string>),
|
|
266
|
-
...parseEnvFile(`${state.
|
|
221
|
+
...parseEnvFile(`${state.stashDir}/env/stack.env`),
|
|
267
222
|
};
|
|
223
|
+
const homeRoot = resolvePath(state.homeDir);
|
|
268
224
|
|
|
269
225
|
for (const file of composeFiles) {
|
|
270
226
|
let doc: Record<string, unknown>;
|
|
@@ -291,18 +247,20 @@ export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
|
|
|
291
247
|
|
|
292
248
|
const hostPath = expandEnvVars(rawSource, envVars);
|
|
293
249
|
if (!hostPath || !hostPath.startsWith('/')) continue;
|
|
294
|
-
|
|
250
|
+
const resolvedHostPath = resolvePath(hostPath);
|
|
251
|
+
if (!resolvedHostPath.startsWith(`${homeRoot}/`) && resolvedHostPath !== homeRoot) continue;
|
|
252
|
+
if (existsSync(resolvedHostPath)) continue;
|
|
295
253
|
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
const basename =
|
|
254
|
+
// Only create mounts under OP_HOME. For now, treat existing explicit
|
|
255
|
+
// file paths as files and directory paths as directories.
|
|
256
|
+
const basename = resolvedHostPath.split('/').pop() ?? '';
|
|
299
257
|
const isFile = basename.includes('.');
|
|
300
258
|
|
|
301
259
|
if (isFile) {
|
|
302
|
-
mkdirSync(dirname(
|
|
303
|
-
writeFileSync(
|
|
260
|
+
mkdirSync(dirname(resolvedHostPath), { recursive: true });
|
|
261
|
+
writeFileSync(resolvedHostPath, '');
|
|
304
262
|
} else {
|
|
305
|
-
mkdirSync(
|
|
263
|
+
mkdirSync(resolvedHostPath, { recursive: true });
|
|
306
264
|
}
|
|
307
265
|
}
|
|
308
266
|
}
|
|
@@ -322,24 +280,25 @@ export function writeRuntimeFiles(
|
|
|
322
280
|
writeFileSync(composePath, state.artifacts.compose);
|
|
323
281
|
}
|
|
324
282
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
283
|
+
for (const name of ['services.compose.yml', 'channels.compose.yml', 'custom.compose.yml']) {
|
|
284
|
+
const path = `${state.stackDir}/${name}`;
|
|
285
|
+
if (!existsSync(path)) writeFileSync(path, readBundledStackAsset(name));
|
|
286
|
+
}
|
|
287
|
+
|
|
328
288
|
for (const addon of listEnabledAddonIds(state.homeDir)) {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
289
|
+
if (['api', 'chat', 'discord', 'slack'].includes(addon)) {
|
|
290
|
+
for (const channel of ['api', 'chat', 'discord', 'slack']) {
|
|
291
|
+
ensureChannelSecret(state.stackDir, channel);
|
|
292
|
+
}
|
|
293
|
+
break;
|
|
332
294
|
}
|
|
333
295
|
}
|
|
334
296
|
|
|
335
|
-
// Write
|
|
336
|
-
writeChannelSecrets(state.stackDir, channelSecrets);
|
|
337
|
-
|
|
338
|
-
// Write system.env (no channel secrets — those live in guardian.env)
|
|
297
|
+
// Write stack.env (no secrets — those live in knowledge/secrets/)
|
|
339
298
|
writeSystemEnv(state);
|
|
340
299
|
|
|
341
300
|
// Ensure state directory exists
|
|
342
|
-
mkdirSync(state.
|
|
301
|
+
mkdirSync(state.dataDir, { recursive: true });
|
|
343
302
|
|
|
344
303
|
state.artifactMeta = buildRuntimeFileMeta(state.artifacts);
|
|
345
304
|
}
|
|
@@ -5,19 +5,24 @@
|
|
|
5
5
|
* stack/ — compose runtime assets (core.compose.yml)
|
|
6
6
|
*
|
|
7
7
|
* This module manages runtime-owned core files only.
|
|
8
|
-
*
|
|
8
|
+
* Addon compose bundle generation and registry catalog refresh are handled
|
|
9
|
+
* separately in registry.ts.
|
|
9
10
|
* Env validation has moved to `akm vault` + the in-house redactor — the
|
|
10
11
|
* historical `.env.schema` files (varlock format) were retired in #391.
|
|
11
12
|
*/
|
|
12
13
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, copyFileSync } from "node:fs";
|
|
13
|
-
import { dirname, join
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
14
15
|
import { fileURLToPath } from "node:url";
|
|
15
|
-
import {
|
|
16
|
+
import { resolveDataDir, resolveOpenPalmHome, resolveBackupsDir } from "./home.js";
|
|
16
17
|
import { createLogger } from "../logger.js";
|
|
17
18
|
import { sha256 } from "./crypto.js";
|
|
18
19
|
|
|
19
20
|
const logger = createLogger("core-assets");
|
|
20
21
|
|
|
22
|
+
function bundledAssetPath(relPath: string): string {
|
|
23
|
+
return join(dirname(fileURLToPath(import.meta.url)), '../../../../.openpalm', relPath);
|
|
24
|
+
}
|
|
25
|
+
|
|
21
26
|
// ── Core Compose (stack/) ─────────────────────────────────────────────
|
|
22
27
|
|
|
23
28
|
export function ensureCoreCompose(): string {
|
|
@@ -27,57 +32,26 @@ export function ensureCoreCompose(): string {
|
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
export function readCoreCompose(): string {
|
|
30
|
-
|
|
35
|
+
const livePath = `${resolveOpenPalmHome()}/config/stack/core.compose.yml`;
|
|
36
|
+
if (existsSync(livePath)) {
|
|
37
|
+
return readFileSync(livePath, 'utf-8');
|
|
38
|
+
}
|
|
39
|
+
return readFileSync(bundledAssetPath('config/stack/core.compose.yml'), 'utf-8');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function readBundledStackAsset(name: string): string {
|
|
43
|
+
return readFileSync(bundledAssetPath(`config/stack/${name}`), 'utf-8');
|
|
31
44
|
}
|
|
32
45
|
|
|
33
46
|
// ── OpenCode System Config ──────────────────────────────────────────
|
|
34
47
|
|
|
35
48
|
export function ensureOpenCodeSystemConfig(): void {
|
|
36
|
-
const dir = `${
|
|
49
|
+
const dir = `${resolveDataDir()}/assistant`;
|
|
37
50
|
mkdirSync(dir, { recursive: true });
|
|
38
51
|
}
|
|
39
52
|
|
|
40
53
|
// ── Shared akm stash (skills / commands / agents) ────────────────────
|
|
41
54
|
|
|
42
|
-
/**
|
|
43
|
-
* Seed the shared akm stash with built-in skills / commands / agents.
|
|
44
|
-
*
|
|
45
|
-
* Idempotent: **never overwrites** an existing file — user edits to a
|
|
46
|
-
* seeded asset always win, which preserves the same "config doesn't
|
|
47
|
-
* overwrite user edits" contract that governs the rest of OP_HOME.
|
|
48
|
-
*
|
|
49
|
-
* Returns the list of stash-relative paths that were actually written
|
|
50
|
-
* (empty on re-run when every seed already exists on disk).
|
|
51
|
-
*
|
|
52
|
-
* `seeds` is a map of stash-relative path → file content. Keys MUST be
|
|
53
|
-
* forward-slash relative paths that stay inside `data/stash/`; any key
|
|
54
|
-
* that escapes the stash directory after canonicalization throws,
|
|
55
|
-
* preventing a malicious caller from writing arbitrary files. Source of
|
|
56
|
-
* truth for the seeded files lives at `.openpalm/stash/` in the
|
|
57
|
-
* repo; the CLI embeds them at build time and passes the embedded
|
|
58
|
-
* record directly.
|
|
59
|
-
*/
|
|
60
|
-
export function seedStashAssets(seeds: Record<string, string>): string[] {
|
|
61
|
-
const stashDir = resolveStashDir();
|
|
62
|
-
const normalizedStash = resolve(stashDir);
|
|
63
|
-
const written: string[] = [];
|
|
64
|
-
for (const [relPath, content] of Object.entries(seeds)) {
|
|
65
|
-
const targetPath = join(stashDir, relPath);
|
|
66
|
-
const normalizedTarget = resolve(targetPath);
|
|
67
|
-
if (
|
|
68
|
-
normalizedTarget !== normalizedStash &&
|
|
69
|
-
!normalizedTarget.startsWith(normalizedStash + sep)
|
|
70
|
-
) {
|
|
71
|
-
throw new Error(`Seed path escapes stash dir: ${relPath}`);
|
|
72
|
-
}
|
|
73
|
-
if (existsSync(targetPath)) continue;
|
|
74
|
-
mkdirSync(dirname(targetPath), { recursive: true });
|
|
75
|
-
writeFileSync(targetPath, content);
|
|
76
|
-
written.push(relPath);
|
|
77
|
-
}
|
|
78
|
-
return written;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
55
|
// ── Asset Refresh (GitHub download) ──────────────────────────────────
|
|
82
56
|
|
|
83
57
|
const REPO = "itlackey/openpalm";
|
|
@@ -100,12 +74,15 @@ const VERSION = resolveAssetVersion();
|
|
|
100
74
|
// overwritten) via seedOpenPalmDir (skipExisting) or SEEDED_ASSETS below.
|
|
101
75
|
const MANAGED_ASSETS: { relPath: string; githubFilename: string }[] = [
|
|
102
76
|
{ relPath: "config/stack/core.compose.yml", githubFilename: ".openpalm/config/stack/core.compose.yml" },
|
|
77
|
+
{ relPath: "config/stack/services.compose.yml", githubFilename: ".openpalm/config/stack/services.compose.yml" },
|
|
78
|
+
{ relPath: "config/stack/channels.compose.yml", githubFilename: ".openpalm/config/stack/channels.compose.yml" },
|
|
103
79
|
];
|
|
104
80
|
|
|
105
81
|
// Seeded once — written only when the file does not exist yet.
|
|
106
82
|
// User edits always win; upgrade never touches these files.
|
|
107
83
|
const SEEDED_ASSETS: { relPath: string; githubFilename: string }[] = [
|
|
108
84
|
{ relPath: "config/assistant/opencode.jsonc", githubFilename: ".openpalm/config/assistant/opencode.jsonc" },
|
|
85
|
+
{ relPath: "config/stack/custom.compose.yml", githubFilename: ".openpalm/config/stack/custom.compose.yml" },
|
|
109
86
|
];
|
|
110
87
|
|
|
111
88
|
async function downloadAsset(filename: string): Promise<string> {
|
|
@@ -39,10 +39,12 @@ function run(
|
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* Resolve the Docker Compose project name.
|
|
42
|
-
* Honors
|
|
42
|
+
* Honors OP_PROJECT_NAME first for OpenPalm stacks, then COMPOSE_PROJECT_NAME.
|
|
43
43
|
*/
|
|
44
|
-
export function resolveComposeProjectName(): string {
|
|
44
|
+
export function resolveComposeProjectName(envOverrides: Record<string, string> = {}): string {
|
|
45
45
|
return (
|
|
46
|
+
envOverrides.OP_PROJECT_NAME?.trim() ||
|
|
47
|
+
envOverrides.COMPOSE_PROJECT_NAME?.trim() ||
|
|
46
48
|
process.env.OP_PROJECT_NAME?.trim() ||
|
|
47
49
|
process.env.COMPOSE_PROJECT_NAME?.trim() ||
|
|
48
50
|
"openpalm"
|
|
@@ -87,12 +89,14 @@ export async function checkDockerCompose(): Promise<DockerResult> {
|
|
|
87
89
|
});
|
|
88
90
|
}
|
|
89
91
|
|
|
90
|
-
/** Build common prefix: compose -f ... --project-name ... --env-file ... */
|
|
91
|
-
function buildComposeArgs(options: { files: string[]; envFiles?: string[] }): string[] {
|
|
92
|
-
const
|
|
92
|
+
/** Build common prefix: compose -f ... --project-name ... --env-file ... --profile ... */
|
|
93
|
+
function buildComposeArgs(options: { files: string[]; envFiles?: string[]; profiles?: string[] }): string[] {
|
|
94
|
+
const envOverrides = collectEnvOverrides(options.envFiles);
|
|
95
|
+
const args = ["compose", ...options.files.flatMap((f) => ["-f", f]), "--project-name", resolveComposeProjectName(envOverrides)];
|
|
93
96
|
for (const ef of options.envFiles ?? []) {
|
|
94
97
|
if (existsSync(ef)) args.push("--env-file", ef);
|
|
95
98
|
}
|
|
99
|
+
for (const p of options.profiles ?? []) args.push("--profile", p);
|
|
96
100
|
return args;
|
|
97
101
|
}
|
|
98
102
|
|
|
@@ -108,7 +112,7 @@ function collectEnvOverrides(envFiles?: string[]): Record<string, string> {
|
|
|
108
112
|
* Must be called before any lifecycle mutation (install/apply/update).
|
|
109
113
|
*/
|
|
110
114
|
export async function composePreflight(
|
|
111
|
-
options: { files: string[]; envFiles?: string[] }
|
|
115
|
+
options: { files: string[]; envFiles?: string[]; profiles?: string[] }
|
|
112
116
|
): Promise<DockerResult> {
|
|
113
117
|
const args = buildComposeArgs(options);
|
|
114
118
|
args.push("config", "--quiet");
|
|
@@ -119,22 +123,23 @@ export async function composePreflight(
|
|
|
119
123
|
* Run compose config preflight validation before any mutation.
|
|
120
124
|
* Skipped when OP_SKIP_COMPOSE_PREFLIGHT is set (tests, CI).
|
|
121
125
|
*/
|
|
122
|
-
async function runPreflight(options: { files: string[]; envFiles?: string[] }): Promise<void> {
|
|
126
|
+
async function runPreflight(options: { files: string[]; envFiles?: string[]; profiles?: string[] }): Promise<void> {
|
|
123
127
|
if (options.files.length === 0 || process.env.OP_SKIP_COMPOSE_PREFLIGHT) return;
|
|
124
128
|
const result = await composePreflight(options);
|
|
125
129
|
if (!result.ok) {
|
|
126
|
-
const project = resolveComposeProjectName();
|
|
130
|
+
const project = resolveComposeProjectName(collectEnvOverrides(options.envFiles));
|
|
127
131
|
const fileArgs = options.files.map((f) => `-f ${f}`).join(" ");
|
|
128
132
|
const envArgs = (options.envFiles ?? []).map((f) => `--env-file ${f}`).join(" ");
|
|
133
|
+
const profileArgs = (options.profiles ?? []).map((p) => `--profile ${p}`).join(" ");
|
|
129
134
|
throw new Error(
|
|
130
135
|
`Compose preflight failed: ${result.stderr}\n` +
|
|
131
|
-
`Resolved command: docker compose ${fileArgs} --project-name ${project} ${envArgs} config --quiet`
|
|
136
|
+
`Resolved command: docker compose ${fileArgs} --project-name ${project} ${envArgs} ${profileArgs} config --quiet`
|
|
132
137
|
);
|
|
133
138
|
}
|
|
134
139
|
}
|
|
135
140
|
|
|
136
141
|
export async function composeConfigServices(
|
|
137
|
-
options: { files: string[]; envFiles?: string[] }
|
|
142
|
+
options: { files: string[]; envFiles?: string[]; profiles?: string[] }
|
|
138
143
|
): Promise<{ ok: boolean; services: string[] }> {
|
|
139
144
|
const args = buildComposeArgs(options);
|
|
140
145
|
args.push("config", "--services");
|
|
@@ -163,7 +168,6 @@ export async function composeUp(
|
|
|
163
168
|
return { ok: false, stdout: "", stderr: "Compose file not found", code: 1 };
|
|
164
169
|
}
|
|
165
170
|
const args = buildComposeArgs(options);
|
|
166
|
-
for (const p of options.profiles ?? []) args.push("--profile", p);
|
|
167
171
|
args.push("up", "-d");
|
|
168
172
|
if (options.forceRecreate) args.push("--force-recreate");
|
|
169
173
|
if (options.removeOrphans) args.push("--remove-orphans");
|
|
@@ -187,7 +191,6 @@ export async function composeDown(
|
|
|
187
191
|
return { ok: false, stdout: "", stderr: "Compose file not found", code: 1 };
|
|
188
192
|
}
|
|
189
193
|
const args = buildComposeArgs(options);
|
|
190
|
-
for (const p of options.profiles ?? []) args.push("--profile", p);
|
|
191
194
|
args.push("down");
|
|
192
195
|
if (options.removeVolumes) args.push("-v");
|
|
193
196
|
return run(args, undefined);
|
|
@@ -313,7 +316,6 @@ export async function composePullService(
|
|
|
313
316
|
): Promise<DockerResult> {
|
|
314
317
|
await runPreflight(options);
|
|
315
318
|
const args = buildComposeArgs(options);
|
|
316
|
-
for (const p of options.profiles ?? []) args.push("--profile", p);
|
|
317
319
|
args.push("pull", service);
|
|
318
320
|
return run(args, undefined, PULL_TIMEOUT_MS, collectEnvOverrides(options.envFiles));
|
|
319
321
|
}
|
|
@@ -323,7 +325,6 @@ export async function composePull(
|
|
|
323
325
|
): Promise<DockerResult> {
|
|
324
326
|
await runPreflight(options);
|
|
325
327
|
const args = buildComposeArgs(options);
|
|
326
|
-
for (const p of options.profiles ?? []) args.push("--profile", p);
|
|
327
328
|
args.push("pull");
|
|
328
329
|
return run(args, undefined, PULL_TIMEOUT_MS, collectEnvOverrides(options.envFiles));
|
|
329
330
|
}
|
|
@@ -86,25 +86,25 @@ describe("quoteEnvValue quoting strategy (via mergeEnvContent)", () => {
|
|
|
86
86
|
|
|
87
87
|
describe("mergeEnvContent updates existing keys with special char values", () => {
|
|
88
88
|
it("updates an existing key to a value with =", () => {
|
|
89
|
-
const input = "export
|
|
90
|
-
const result = mergeEnvContent(input, {
|
|
89
|
+
const input = "export TEST_VALUE=old_value\n";
|
|
90
|
+
const result = mergeEnvContent(input, { TEST_VALUE: "new=value=here" });
|
|
91
91
|
const parsed = parseEnvContent(result);
|
|
92
|
-
expect(parsed.
|
|
92
|
+
expect(parsed.TEST_VALUE).toBe("new=value=here");
|
|
93
93
|
});
|
|
94
94
|
|
|
95
95
|
it("updates an existing key to a value with $", () => {
|
|
96
|
-
const input = "export
|
|
97
|
-
const result = mergeEnvContent(input, {
|
|
96
|
+
const input = "export TEST_VALUE=old_value\n";
|
|
97
|
+
const result = mergeEnvContent(input, { TEST_VALUE: "tok$en" });
|
|
98
98
|
const parsed = parseEnvContent(result);
|
|
99
|
-
expect(parsed.
|
|
99
|
+
expect(parsed.TEST_VALUE).toBe("tok$en");
|
|
100
100
|
});
|
|
101
101
|
|
|
102
102
|
it("preserves export prefix when updating with special chars", () => {
|
|
103
|
-
const input = "export
|
|
104
|
-
const result = mergeEnvContent(input, {
|
|
105
|
-
expect(result).toMatch(/^export
|
|
103
|
+
const input = "export TEST_VALUE=old_value\n";
|
|
104
|
+
const result = mergeEnvContent(input, { TEST_VALUE: "new#value" });
|
|
105
|
+
expect(result).toMatch(/^export TEST_VALUE=/m);
|
|
106
106
|
const parsed = parseEnvContent(result);
|
|
107
|
-
expect(parsed.
|
|
107
|
+
expect(parsed.TEST_VALUE).toBe("new#value");
|
|
108
108
|
});
|
|
109
109
|
});
|
|
110
110
|
|
package/src/control-plane/env.ts
CHANGED
|
@@ -26,7 +26,7 @@ export function expandEnvVars(input: string, vars: Record<string, string>): stri
|
|
|
26
26
|
return input.replace(/\$\{([^}:]+)(?::-([^}]*))?\}/g, (_, name, def) => vars[name] ?? def ?? '');
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
function quoteEnvValue(value: string): string {
|
|
29
|
+
export function quoteEnvValue(value: string): string {
|
|
30
30
|
if (value.length === 0) return '';
|
|
31
31
|
const needsQuoting = /[#"'\\\n\r$]/.test(value) || value !== value.trim();
|
|
32
32
|
if (!needsQuoting) return value;
|