@openpalm/lib 0.11.0-beta.9 → 0.11.0-rc.18
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 +69 -30
- package/src/control-plane/compose-args.ts +62 -8
- package/src/control-plane/config-persistence.ts +102 -136
- package/src/control-plane/core-assets.ts +45 -60
- package/src/control-plane/defaults.ts +16 -0
- package/src/control-plane/docker.ts +15 -14
- package/src/control-plane/env.test.ts +10 -10
- package/src/control-plane/env.ts +16 -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 +100 -136
- package/src/control-plane/install-lock.ts +7 -7
- package/src/control-plane/lifecycle.ts +45 -40
- package/src/control-plane/markdown-task.ts +30 -50
- package/src/control-plane/migrations.test.ts +272 -0
- package/src/control-plane/migrations.ts +423 -0
- 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 +301 -110
- 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 +137 -61
- package/src/control-plane/setup.ts +82 -63
- package/src/control-plane/skeleton-guardrail.test.ts +66 -56
- package/src/control-plane/spec-to-env.test.ts +63 -26
- package/src/control-plane/spec-to-env.ts +51 -14
- 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 +333 -0
- package/src/control-plane/ui-assets.ts +290 -142
- package/src/control-plane/validate.ts +13 -15
- package/src/index.ts +96 -26
- 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
- package/src/control-plane/stack-spec.test.ts +0 -94
- package/src/control-plane/stack-spec.ts +0 -67
|
@@ -3,58 +3,54 @@
|
|
|
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 "./defaults.js";
|
|
18
|
+
import { CURRENT_LAYOUT_VERSION } from "./migrations.js";
|
|
16
19
|
|
|
17
20
|
import {
|
|
18
21
|
readCoreCompose,
|
|
22
|
+
readBundledStackAsset,
|
|
19
23
|
} from "./core-assets.js";
|
|
20
24
|
export { sha256, randomHex } from "./crypto.js";
|
|
21
25
|
import { sha256, randomHex } from "./crypto.js";
|
|
22
26
|
|
|
23
|
-
const DEFAULT_IMAGE_TAG =
|
|
27
|
+
const DEFAULT_IMAGE_TAG = "latest";
|
|
24
28
|
|
|
25
29
|
// ── Env File Management ──────────────────────────────────────────────
|
|
26
30
|
|
|
27
31
|
/**
|
|
28
32
|
* Return the env files used for docker compose --env-file args.
|
|
29
|
-
* These are the live vault env files.
|
|
30
33
|
*
|
|
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).
|
|
34
|
+
* Only `knowledge/env/stack.env` (non-secret system config). Secret values
|
|
35
|
+
* live in `knowledge/secrets/<ENV_KEY>` and are granted to services as Compose
|
|
36
|
+
* file secrets. The user env (`knowledge/env/user.env`) is NOT a compose
|
|
37
|
+
* env_file — it is sourced by the assistant entrypoint at container startup.
|
|
40
38
|
*/
|
|
41
39
|
export function buildEnvFiles(state: ControlPlaneState): string[] {
|
|
42
40
|
return [
|
|
43
|
-
`${state.
|
|
44
|
-
`${state.stackDir}/guardian.env`,
|
|
41
|
+
`${state.stashDir}/env/stack.env`,
|
|
45
42
|
].filter(existsSync);
|
|
46
43
|
}
|
|
47
44
|
|
|
48
45
|
/**
|
|
49
|
-
* Write system-managed values to
|
|
46
|
+
* Write system-managed values to knowledge/env/stack.env.
|
|
50
47
|
*
|
|
51
|
-
*
|
|
52
|
-
* Use
|
|
48
|
+
* Secret-like keys are NOT written here — they belong in knowledge/secrets/.
|
|
49
|
+
* Use ensureChannelSecret() for channel secrets.
|
|
53
50
|
*/
|
|
54
51
|
export function writeSystemEnv(state: ControlPlaneState): void {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const systemEnvPath = `${state.stackDir}/stack.env`;
|
|
52
|
+
const systemEnvPath = `${state.stashDir}/env/stack.env`;
|
|
53
|
+
mkdirSync(`${state.stashDir}/env`, { recursive: true, mode: 0o700 });
|
|
58
54
|
|
|
59
55
|
let base = "";
|
|
60
56
|
if (existsSync(systemEnvPath)) {
|
|
@@ -63,11 +59,12 @@ export function writeSystemEnv(state: ControlPlaneState): void {
|
|
|
63
59
|
base = generateFallbackSystemEnv(state);
|
|
64
60
|
}
|
|
65
61
|
|
|
66
|
-
// Preserve existing OP_SETUP_COMPLETE
|
|
67
|
-
|
|
68
|
-
|
|
62
|
+
// Preserve the existing OP_SETUP_COMPLETE flag as-is.
|
|
63
|
+
// Only the wizard completion path (buildSystemSecretsFromSetup) writes "true".
|
|
64
|
+
// Defaulting to "false" here ensures a fresh install always shows the wizard.
|
|
65
|
+
const parsed = parseEnvFile(systemEnvPath);
|
|
69
66
|
const adminManaged: Record<string, string> = {
|
|
70
|
-
OP_SETUP_COMPLETE:
|
|
67
|
+
OP_SETUP_COMPLETE: parsed.OP_SETUP_COMPLETE === "true" ? "true" : "false",
|
|
71
68
|
};
|
|
72
69
|
|
|
73
70
|
// Backfill OP_UID/OP_GID when the existing stack.env was written by an
|
|
@@ -76,13 +73,20 @@ export function writeSystemEnv(state: ControlPlaneState): void {
|
|
|
76
73
|
// missing or zero — an operator who manually set OP_UID=2000 (e.g.
|
|
77
74
|
// because they're running on a host with a non-1000 service account)
|
|
78
75
|
// must not be silently changed.
|
|
79
|
-
const parsed = parseEnvFile(systemEnvPath);
|
|
80
76
|
const ids = resolveOperatorIds(state.homeDir);
|
|
81
77
|
if (ids) {
|
|
82
78
|
if (!hasUsableOperatorId(parsed, "OP_UID")) adminManaged.OP_UID = String(ids.uid);
|
|
83
79
|
if (!hasUsableOperatorId(parsed, "OP_GID")) adminManaged.OP_GID = String(ids.gid);
|
|
84
80
|
}
|
|
85
81
|
|
|
82
|
+
// Backfill OP_HOME when missing — compose files reference ${OP_HOME}
|
|
83
|
+
// for all volume mounts. Without this, Docker Compose defaults to blank.
|
|
84
|
+
if (!parsed.OP_HOME) adminManaged.OP_HOME = state.homeDir;
|
|
85
|
+
|
|
86
|
+
base = stripSecretLikeEnvKeys(base);
|
|
87
|
+
assertNoSecretLikeStackEnvKeys(parseEnvContent(base));
|
|
88
|
+
assertNoSecretLikeStackEnvKeys(adminManaged);
|
|
89
|
+
|
|
86
90
|
const content = mergeEnvContent(base, adminManaged, {
|
|
87
91
|
sectionHeader: "# ── Admin-managed ──────────────────────────────────────────────────"
|
|
88
92
|
});
|
|
@@ -91,6 +95,19 @@ export function writeSystemEnv(state: ControlPlaneState): void {
|
|
|
91
95
|
chmodSync(systemEnvPath, 0o600);
|
|
92
96
|
}
|
|
93
97
|
|
|
98
|
+
function stripSecretLikeEnvKeys(content: string): string {
|
|
99
|
+
return content
|
|
100
|
+
.split('\n')
|
|
101
|
+
.filter((line) => {
|
|
102
|
+
let trimmed = line.trim();
|
|
103
|
+
if (trimmed.startsWith('export ')) trimmed = trimmed.slice(7).trimStart();
|
|
104
|
+
const eq = trimmed.indexOf('=');
|
|
105
|
+
if (eq <= 0) return true;
|
|
106
|
+
return !isSecretLikeStackEnvKey(trimmed.slice(0, eq).trim());
|
|
107
|
+
})
|
|
108
|
+
.join('\n');
|
|
109
|
+
}
|
|
110
|
+
|
|
94
111
|
function generateFallbackSystemEnv(state: ControlPlaneState): string {
|
|
95
112
|
// Operator UID/GID — auto-detect from OP_HOME owner (or process UID).
|
|
96
113
|
// Skipped on Windows where containers run in WSL2 and OP_UID has no
|
|
@@ -104,12 +121,6 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
|
|
|
104
121
|
"# OpenPalm — System Configuration (managed by CLI/admin)",
|
|
105
122
|
"# Auto-generated fallback.",
|
|
106
123
|
"",
|
|
107
|
-
"# ── Authentication ──────────────────────────────────────────────────",
|
|
108
|
-
`OP_UI_LOGIN_PASSWORD=\${OP_UI_LOGIN_PASSWORD}`,
|
|
109
|
-
"",
|
|
110
|
-
"# ── Service Auth ─────────────────────────────────────────────────────",
|
|
111
|
-
"OP_OPENCODE_PASSWORD=",
|
|
112
|
-
"",
|
|
113
124
|
"# ── Paths ──────────────────────────────────────────────────────────",
|
|
114
125
|
`OP_HOME=${state.homeDir}`,
|
|
115
126
|
...idLines,
|
|
@@ -118,12 +129,17 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
|
|
|
118
129
|
`OP_IMAGE_NAMESPACE=${process.env.OP_IMAGE_NAMESPACE ?? "openpalm"}`,
|
|
119
130
|
`OP_IMAGE_TAG=${DEFAULT_IMAGE_TAG}`,
|
|
120
131
|
"",
|
|
132
|
+
"# ── Layout (on-disk schema version; managed by the migration harness) ──",
|
|
133
|
+
`OP_LAYOUT_VERSION=${CURRENT_LAYOUT_VERSION}`,
|
|
134
|
+
"",
|
|
135
|
+
"# ── Enabled addons (comma-separated; managed via the Add-ons UI / CLI) ──",
|
|
136
|
+
"OP_ENABLED_ADDONS=",
|
|
137
|
+
"",
|
|
121
138
|
"# ── Ports (38XX range) ──────────────────────────────────────────────",
|
|
122
139
|
"# Guardian is network-only (no host port) — channels reach it via",
|
|
123
140
|
"# http://guardian:8080 over the channel_lan Docker network.",
|
|
124
|
-
`OP_ASSISTANT_PORT
|
|
125
|
-
`
|
|
126
|
-
`OP_ADMIN_OPENCODE_PORT=3881`,
|
|
141
|
+
`OP_ASSISTANT_PORT=${SPEC_DEFAULTS.ports.assistant}`,
|
|
142
|
+
`OP_HOST_UI_PORT=${SPEC_DEFAULTS.ports.hostUi}`,
|
|
127
143
|
""
|
|
128
144
|
].join("\n");
|
|
129
145
|
}
|
|
@@ -131,37 +147,25 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
|
|
|
131
147
|
// ── Stack Overlay Discovery ────────────────────────────────────────────
|
|
132
148
|
|
|
133
149
|
/**
|
|
134
|
-
* Discover compose overlays
|
|
135
|
-
* Returns
|
|
150
|
+
* Discover active compose overlays.
|
|
151
|
+
* Returns the fixed compose stack: core, services, channels, and custom.
|
|
152
|
+
* First-party services are profile-gated inside services.compose.yml and
|
|
153
|
+
* channels.compose.yml.
|
|
154
|
+
*
|
|
155
|
+
* Host AKM sharing is NOT a compose overlay: the assistant always mounts
|
|
156
|
+
* `/host-stash` (core.compose.yml, with an empty-dir fallback), and "sharing"
|
|
157
|
+
* is purely a writable secondary source entry in config/akm/config.json. No
|
|
158
|
+
* conditional overlay file is involved.
|
|
136
159
|
*/
|
|
137
|
-
export function discoverStackOverlays(stackDir: string): string[] {
|
|
160
|
+
export function discoverStackOverlays(stackDir: string, _homeDir?: string): string[] {
|
|
138
161
|
const files: string[] = [];
|
|
139
162
|
|
|
140
163
|
const coreYml = `${stackDir}/core.compose.yml`;
|
|
141
164
|
if (existsSync(coreYml)) files.push(coreYml);
|
|
142
165
|
|
|
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
|
-
}
|
|
166
|
+
for (const name of ['services.compose.yml', 'channels.compose.yml', 'custom.compose.yml']) {
|
|
167
|
+
const composePath = `${stackDir}/${name}`;
|
|
168
|
+
if (existsSync(composePath)) files.push(composePath);
|
|
165
169
|
}
|
|
166
170
|
|
|
167
171
|
return files;
|
|
@@ -192,79 +196,38 @@ export function buildRuntimeFileMeta(artifacts: {
|
|
|
192
196
|
}
|
|
193
197
|
|
|
194
198
|
// ── 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
199
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
*/
|
|
212
|
-
export function readChannelSecrets(stackDir: string): Record<string, string> {
|
|
213
|
-
return extractChannelSecrets(parseEnvFile(`${stackDir}/guardian.env`));
|
|
200
|
+
export function channelSecretName(addon: string): string {
|
|
201
|
+
return `channel_${addon.replace(/-/g, '_')}_secret`;
|
|
214
202
|
}
|
|
215
203
|
|
|
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);
|
|
204
|
+
export function ensureChannelSecret(stackDir: string, addon: string): string {
|
|
205
|
+
return ensureSecret(stackDir, channelSecretName(addon), () => randomHex(16));
|
|
240
206
|
}
|
|
241
207
|
|
|
242
208
|
// ── Volume Mount Targets ───────────────────────────────────────────────
|
|
243
209
|
|
|
244
210
|
/**
|
|
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.
|
|
211
|
+
* Parse enabled compose files and pre-create host-side volume mount
|
|
212
|
+
* targets under OP_HOME as the current user. This prevents Docker from
|
|
213
|
+
* creating them as root-owned, which causes EACCES inside non-root
|
|
214
|
+
* containers.
|
|
256
215
|
*
|
|
257
216
|
* Only mount sources under `state.homeDir` are touched; external paths
|
|
258
217
|
* (e.g. `/var/run/docker.sock`) are left alone.
|
|
218
|
+
*
|
|
219
|
+
* The file-vs-directory distinction is best-effort and only applies to
|
|
220
|
+
* explicit OP_HOME paths.
|
|
259
221
|
*/
|
|
260
222
|
export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
|
|
261
|
-
const composeFiles = discoverStackOverlays(state.stackDir);
|
|
223
|
+
const composeFiles = discoverStackOverlays(state.stackDir, state.homeDir);
|
|
262
224
|
if (composeFiles.length === 0) return;
|
|
263
225
|
|
|
264
226
|
const envVars: Record<string, string> = {
|
|
265
227
|
...(process.env as Record<string, string>),
|
|
266
|
-
...parseEnvFile(`${state.
|
|
228
|
+
...parseEnvFile(`${state.stashDir}/env/stack.env`),
|
|
267
229
|
};
|
|
230
|
+
const homeRoot = resolvePath(state.homeDir);
|
|
268
231
|
|
|
269
232
|
for (const file of composeFiles) {
|
|
270
233
|
let doc: Record<string, unknown>;
|
|
@@ -291,18 +254,20 @@ export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
|
|
|
291
254
|
|
|
292
255
|
const hostPath = expandEnvVars(rawSource, envVars);
|
|
293
256
|
if (!hostPath || !hostPath.startsWith('/')) continue;
|
|
294
|
-
|
|
257
|
+
const resolvedHostPath = resolvePath(hostPath);
|
|
258
|
+
if (!resolvedHostPath.startsWith(`${homeRoot}/`) && resolvedHostPath !== homeRoot) continue;
|
|
259
|
+
if (existsSync(resolvedHostPath)) continue;
|
|
295
260
|
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
const basename =
|
|
261
|
+
// Only create mounts under OP_HOME. For now, treat existing explicit
|
|
262
|
+
// file paths as files and directory paths as directories.
|
|
263
|
+
const basename = resolvedHostPath.split('/').pop() ?? '';
|
|
299
264
|
const isFile = basename.includes('.');
|
|
300
265
|
|
|
301
266
|
if (isFile) {
|
|
302
|
-
mkdirSync(dirname(
|
|
303
|
-
writeFileSync(
|
|
267
|
+
mkdirSync(dirname(resolvedHostPath), { recursive: true });
|
|
268
|
+
writeFileSync(resolvedHostPath, '');
|
|
304
269
|
} else {
|
|
305
|
-
mkdirSync(
|
|
270
|
+
mkdirSync(resolvedHostPath, { recursive: true });
|
|
306
271
|
}
|
|
307
272
|
}
|
|
308
273
|
}
|
|
@@ -322,24 +287,25 @@ export function writeRuntimeFiles(
|
|
|
322
287
|
writeFileSync(composePath, state.artifacts.compose);
|
|
323
288
|
}
|
|
324
289
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
290
|
+
for (const name of ['services.compose.yml', 'channels.compose.yml', 'custom.compose.yml']) {
|
|
291
|
+
const path = `${state.stackDir}/${name}`;
|
|
292
|
+
if (!existsSync(path)) writeFileSync(path, readBundledStackAsset(name));
|
|
293
|
+
}
|
|
294
|
+
|
|
328
295
|
for (const addon of listEnabledAddonIds(state.homeDir)) {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
296
|
+
if (['api', 'chat', 'discord', 'slack'].includes(addon)) {
|
|
297
|
+
for (const channel of ['api', 'chat', 'discord', 'slack']) {
|
|
298
|
+
ensureChannelSecret(state.stackDir, channel);
|
|
299
|
+
}
|
|
300
|
+
break;
|
|
332
301
|
}
|
|
333
302
|
}
|
|
334
303
|
|
|
335
|
-
// Write
|
|
336
|
-
writeChannelSecrets(state.stackDir, channelSecrets);
|
|
337
|
-
|
|
338
|
-
// Write system.env (no channel secrets — those live in guardian.env)
|
|
304
|
+
// Write stack.env (no secrets — those live in knowledge/secrets/)
|
|
339
305
|
writeSystemEnv(state);
|
|
340
306
|
|
|
341
307
|
// Ensure state directory exists
|
|
342
|
-
mkdirSync(state.
|
|
308
|
+
mkdirSync(state.dataDir, { recursive: true });
|
|
343
309
|
|
|
344
310
|
state.artifactMeta = buildRuntimeFileMeta(state.artifacts);
|
|
345
311
|
}
|
|
@@ -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,90 +32,69 @@ 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";
|
|
84
58
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
59
|
+
// The version to download assets for is ALWAYS passed in by the caller (the
|
|
60
|
+
// upgrade flow resolves the canonical platform tag — the newest published
|
|
61
|
+
// `openpalm/assistant` Docker tag, e.g. "v0.11.0-rc.6" — and threads it here).
|
|
62
|
+
// This module intentionally does NOT resolve the version itself: no env var, no
|
|
63
|
+
// `import.meta.url` package.json read (which breaks when the lib is bundled into
|
|
64
|
+
// the UI/electron), and never a silent "main" fallback (main's asset layout can
|
|
65
|
+
// differ from a released install). Bundler-agnostic by construction.
|
|
66
|
+
|
|
67
|
+
function normalizeAssetRef(version: string): string {
|
|
68
|
+
const v = version.trim();
|
|
69
|
+
if (!v) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
"Cannot download OpenPalm stack assets: no version provided. " +
|
|
72
|
+
"The caller must pass the target release tag (e.g. \"v0.11.0-rc.6\")."
|
|
90
73
|
);
|
|
91
|
-
return `v${pkgJson.version}`;
|
|
92
|
-
} catch {
|
|
93
|
-
return "main";
|
|
94
74
|
}
|
|
75
|
+
// GitHub release/raw refs are `vX.Y.Z`; accept a bare semver and add the `v`.
|
|
76
|
+
return /^\d/.test(v) ? `v${v}` : v;
|
|
95
77
|
}
|
|
96
|
-
const VERSION = resolveAssetVersion();
|
|
97
78
|
|
|
98
79
|
// Persona files (openpalm.md, system.md), stash seeds, and user-editable config
|
|
99
80
|
// files are intentionally NOT in this list. They are seeded once (never
|
|
100
81
|
// overwritten) via seedOpenPalmDir (skipExisting) or SEEDED_ASSETS below.
|
|
101
82
|
const MANAGED_ASSETS: { relPath: string; githubFilename: string }[] = [
|
|
102
83
|
{ relPath: "config/stack/core.compose.yml", githubFilename: ".openpalm/config/stack/core.compose.yml" },
|
|
84
|
+
{ relPath: "config/stack/services.compose.yml", githubFilename: ".openpalm/config/stack/services.compose.yml" },
|
|
85
|
+
{ relPath: "config/stack/channels.compose.yml", githubFilename: ".openpalm/config/stack/channels.compose.yml" },
|
|
103
86
|
];
|
|
104
87
|
|
|
105
88
|
// Seeded once — written only when the file does not exist yet.
|
|
106
89
|
// User edits always win; upgrade never touches these files.
|
|
107
90
|
const SEEDED_ASSETS: { relPath: string; githubFilename: string }[] = [
|
|
108
91
|
{ relPath: "config/assistant/opencode.jsonc", githubFilename: ".openpalm/config/assistant/opencode.jsonc" },
|
|
92
|
+
{ relPath: "config/stack/custom.compose.yml", githubFilename: ".openpalm/config/stack/custom.compose.yml" },
|
|
109
93
|
];
|
|
110
94
|
|
|
111
|
-
async function downloadAsset(filename: string): Promise<string> {
|
|
112
|
-
const releaseUrl = `https://github.com/${REPO}/releases/download/${
|
|
113
|
-
const rawUrl = `https://raw.githubusercontent.com/${REPO}/${
|
|
95
|
+
async function downloadAsset(filename: string, version: string): Promise<string> {
|
|
96
|
+
const releaseUrl = `https://github.com/${REPO}/releases/download/${version}/${filename}`;
|
|
97
|
+
const rawUrl = `https://raw.githubusercontent.com/${REPO}/${version}/${filename}`;
|
|
114
98
|
|
|
115
99
|
for (const url of [releaseUrl, rawUrl]) {
|
|
116
100
|
try {
|
|
@@ -120,19 +104,20 @@ async function downloadAsset(filename: string): Promise<string> {
|
|
|
120
104
|
// try next URL
|
|
121
105
|
}
|
|
122
106
|
}
|
|
123
|
-
throw new Error(`Failed to download ${filename} from GitHub (tried release and raw URLs for version "${
|
|
107
|
+
throw new Error(`Failed to download ${filename} from GitHub (tried release and raw URLs for version "${version}")`);
|
|
124
108
|
}
|
|
125
109
|
|
|
126
|
-
export async function refreshCoreAssets(): Promise<{
|
|
110
|
+
export async function refreshCoreAssets(version: string): Promise<{
|
|
127
111
|
backupDir: string | null;
|
|
128
112
|
updated: string[];
|
|
129
113
|
}> {
|
|
114
|
+
const ref = normalizeAssetRef(version);
|
|
130
115
|
const homeDir = resolveOpenPalmHome();
|
|
131
116
|
const updated: string[] = [];
|
|
132
117
|
let backupDir: string | null = null;
|
|
133
118
|
|
|
134
119
|
for (const asset of MANAGED_ASSETS) {
|
|
135
|
-
const freshContent = await downloadAsset(asset.githubFilename);
|
|
120
|
+
const freshContent = await downloadAsset(asset.githubFilename, ref);
|
|
136
121
|
const targetPath = join(homeDir, asset.relPath);
|
|
137
122
|
|
|
138
123
|
if (existsSync(targetPath)) {
|
|
@@ -158,7 +143,7 @@ export async function refreshCoreAssets(): Promise<{
|
|
|
158
143
|
for (const asset of SEEDED_ASSETS) {
|
|
159
144
|
const targetPath = join(homeDir, asset.relPath);
|
|
160
145
|
if (existsSync(targetPath)) continue;
|
|
161
|
-
const freshContent = await downloadAsset(asset.githubFilename);
|
|
146
|
+
const freshContent = await downloadAsset(asset.githubFilename, ref);
|
|
162
147
|
mkdirSync(dirname(targetPath), { recursive: true });
|
|
163
148
|
writeFileSync(targetPath, freshContent);
|
|
164
149
|
updated.push(asset.relPath);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stack defaults (ports + image). Formerly in stack-spec.ts; kept after the
|
|
3
|
+
* stack.yml removal because these are the canonical fallback values used when a
|
|
4
|
+
* key is absent from stack.env.
|
|
5
|
+
*/
|
|
6
|
+
export const SPEC_DEFAULTS = {
|
|
7
|
+
ports: {
|
|
8
|
+
assistant: 3800,
|
|
9
|
+
hostUi: 3880,
|
|
10
|
+
assistantSsh: 2222,
|
|
11
|
+
},
|
|
12
|
+
image: {
|
|
13
|
+
namespace: "openpalm",
|
|
14
|
+
tag: "latest",
|
|
15
|
+
},
|
|
16
|
+
} as const;
|