@openpalm/lib 0.11.0-beta.10 → 0.11.0-beta.13
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-user-env.test.ts +113 -0
- package/src/control-plane/akm-user-env.ts +144 -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 +90 -31
- package/src/control-plane/compose-args.ts +119 -9
- package/src/control-plane/config-persistence.ts +87 -133
- package/src/control-plane/core-assets.test.ts +9 -9
- package/src/control-plane/core-assets.ts +24 -8
- 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/home.ts +34 -46
- 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 +94 -102
- package/src/control-plane/install-lock.ts +7 -7
- package/src/control-plane/lifecycle.ts +36 -34
- package/src/control-plane/markdown-task.ts +30 -50
- package/src/control-plane/paths.ts +62 -42
- 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 +97 -88
- package/src/control-plane/registry.ts +142 -110
- package/src/control-plane/rollback.ts +8 -38
- package/src/control-plane/scheduler.ts +7 -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 +60 -0
- package/src/control-plane/secrets-files.ts +66 -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 +60 -40
- package/src/control-plane/setup.ts +36 -31
- package/src/control-plane/skeleton-guardrail.test.ts +64 -55
- package/src/control-plane/spec-to-env.test.ts +22 -17
- package/src/control-plane/spec-to-env.ts +7 -2
- package/src/control-plane/stack-spec.test.ts +10 -0
- package/src/control-plane/stack-spec.ts +28 -1
- package/src/control-plane/types.ts +2 -4
- package/src/control-plane/ui-assets.ts +60 -58
- package/src/control-plane/validate.ts +13 -15
- package/src/index.ts +47 -15
- package/src/control-plane/akm-vault.test.ts +0 -105
- package/src/control-plane/akm-vault.ts +0 -311
- 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
|
@@ -23,11 +23,11 @@ import {
|
|
|
23
23
|
writeAuthJsonProviderKeys,
|
|
24
24
|
} from "./secrets.js";
|
|
25
25
|
import { createState } from "./lifecycle.js";
|
|
26
|
-
import { writeStackSpec } from "./stack-spec.js";
|
|
26
|
+
import { readStackSpec, writeStackSpec } from "./stack-spec.js";
|
|
27
27
|
import { writeVoiceVars } from "./spec-to-env.js";
|
|
28
28
|
import type { ControlPlaneState } from "./types.js";
|
|
29
29
|
import { validateSetupSpec } from "./setup-validation.js";
|
|
30
|
-
import { getRegistryAutomation, setAddonEnabled } from "./registry.js";
|
|
30
|
+
import { getRegistryAutomation, setAddonEnabled, setAddonProfileSelection } from "./registry.js";
|
|
31
31
|
export { validateSetupSpec } from "./setup-validation.js";
|
|
32
32
|
|
|
33
33
|
const logger = createLogger("setup");
|
|
@@ -69,15 +69,15 @@ export type SetupSpec = {
|
|
|
69
69
|
tts?: { enabled?: boolean; engine?: string; provider?: string; baseURL?: string; model?: string; voice?: string };
|
|
70
70
|
stt?: { enabled?: boolean; engine?: string; provider?: string; baseURL?: string; model?: string; language?: string };
|
|
71
71
|
/**
|
|
72
|
-
* Operator-supplied UI login password. Persisted
|
|
73
|
-
* `OP_UI_LOGIN_PASSWORD`. Replaces the legacy `adminToken` field
|
|
74
|
-
* (Phase 4 of docs/technical/auth-and-proxy-refactor-plan.md).
|
|
72
|
+
* Operator-supplied UI login password. Persisted as a file-based secret.
|
|
75
73
|
*/
|
|
76
74
|
security: { uiLoginPassword: string };
|
|
77
75
|
owner?: { name?: string; email?: string };
|
|
78
76
|
connections: SetupConnection[];
|
|
79
77
|
channelCredentials?: Record<string, Record<string, string>>;
|
|
80
78
|
addons?: Record<string, boolean>;
|
|
79
|
+
voiceProfile?: string;
|
|
80
|
+
ollamaProfile?: string;
|
|
81
81
|
imageTag?: string;
|
|
82
82
|
hostAkm?: boolean;
|
|
83
83
|
};
|
|
@@ -85,10 +85,8 @@ export type SetupSpec = {
|
|
|
85
85
|
// ── Secrets Builder ──────────────────────────────────────────────────────
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
|
-
* Build the stack.env update payload from a setup spec.
|
|
89
|
-
*
|
|
90
|
-
* (see buildAuthJsonFromSetup), not stack.env. This function returns
|
|
91
|
-
* only non-credential vars: owner identity and similar.
|
|
88
|
+
* Build the non-secret stack.env update payload from a setup spec.
|
|
89
|
+
* Provider API keys and channel credentials are written as file-based secrets.
|
|
92
90
|
*/
|
|
93
91
|
export function buildSecretsFromSetup(
|
|
94
92
|
connections: SetupConnection[],
|
|
@@ -124,7 +122,7 @@ export function buildAuthJsonFromSetup(
|
|
|
124
122
|
}
|
|
125
123
|
|
|
126
124
|
/**
|
|
127
|
-
* Build the system-secret
|
|
125
|
+
* Build the system-secret update for the wizard / CLI install path.
|
|
128
126
|
*
|
|
129
127
|
* Phase 4 of the auth/proxy refactor collapsed the legacy
|
|
130
128
|
* `OP_UI_TOKEN` / `OP_ASSISTANT_TOKEN` pair into a single operator login
|
|
@@ -132,8 +130,8 @@ export function buildAuthJsonFromSetup(
|
|
|
132
130
|
* password; `requireAdmin()` compares the cookie against
|
|
133
131
|
* `process.env.OP_UI_LOGIN_PASSWORD` via the existing `safeTokenCompare`.
|
|
134
132
|
*
|
|
135
|
-
|
|
136
|
-
|
|
133
|
+
* `OP_OPENCODE_PASSWORD` may be supplied explicitly as a file-based secret in
|
|
134
|
+
* `knowledge/secrets/op_opencode_password` when OpenCode auth is enabled.
|
|
137
135
|
*
|
|
138
136
|
* `existingSystemEnv` is unused now but the parameter is kept so callers
|
|
139
137
|
* compile unchanged. It can be removed in a follow-up cleanup.
|
|
@@ -192,13 +190,13 @@ export async function performSetup(
|
|
|
192
190
|
const validation = validateSetupSpec(input);
|
|
193
191
|
if (!validation.valid) return { ok: false, error: validation.errors.join("; ") };
|
|
194
192
|
|
|
195
|
-
const { llm, embedding, tts, stt, security, owner, connections, channelCredentials, addons, imageTag, hostAkm } = input;
|
|
193
|
+
const { llm, embedding, tts, stt, security, owner, connections, channelCredentials, addons, voiceProfile, ollamaProfile, imageTag, hostAkm } = input;
|
|
196
194
|
const state = opts?.state ?? createState();
|
|
197
195
|
|
|
198
196
|
// Acquire install lock to prevent two concurrent setup runs from racing on
|
|
199
|
-
// the same config directory. The lock lives in
|
|
197
|
+
// the same config directory. The lock lives in dataDir so it is co-located
|
|
200
198
|
// with runtime state and the same path startDeploy uses.
|
|
201
|
-
const lockHandle: InstallLockHandle | null = acquireInstallLock(state.
|
|
199
|
+
const lockHandle: InstallLockHandle | null = acquireInstallLock(state.dataDir);
|
|
202
200
|
if (lockHandle === null) {
|
|
203
201
|
return {
|
|
204
202
|
ok: false,
|
|
@@ -218,14 +216,15 @@ export async function performSetup(
|
|
|
218
216
|
ensureHomeDirs();
|
|
219
217
|
ensureSecrets(state);
|
|
220
218
|
const existingSystemEnv = readStackEnv(state.stackDir);
|
|
221
|
-
|
|
219
|
+
const channelSecretUpdates = channelCredentials ? buildChannelCredentialEnvVars(channelCredentials) : {};
|
|
222
220
|
// Pick up channel credential env vars not already provided in the spec
|
|
223
221
|
for (const mapping of Object.values(CHANNEL_CREDENTIAL_ENV_MAP)) {
|
|
224
222
|
for (const envKey of Object.values(mapping)) {
|
|
225
|
-
if (!
|
|
223
|
+
if (!channelSecretUpdates[envKey] && process.env[envKey]) channelSecretUpdates[envKey] = process.env[envKey];
|
|
226
224
|
}
|
|
227
225
|
}
|
|
228
226
|
updateSecretsEnv(state, updates);
|
|
227
|
+
updateSecretsEnv(state, channelSecretUpdates);
|
|
229
228
|
patchSecretsEnvFile(state.stackDir, buildSystemSecretsFromSetup(security.uiLoginPassword, existingSystemEnv));
|
|
230
229
|
// Provider API keys land in OpenCode's auth.json (bind-mounted into
|
|
231
230
|
// the assistant container) — never in stack.env.
|
|
@@ -240,13 +239,13 @@ export async function performSetup(
|
|
|
240
239
|
// single try/catch so that a disk-full or permission-denied mid-way returns a
|
|
241
240
|
// clean error rather than leaving a broken half-installed ~/.openpalm/.
|
|
242
241
|
try {
|
|
243
|
-
//
|
|
244
|
-
writeStackSpec(state.stackDir, { version: 2 });
|
|
242
|
+
// Preserve addon enablement while refreshing the stack schema marker.
|
|
243
|
+
writeStackSpec(state.stackDir, readStackSpec(state.stackDir) ?? { version: 2 });
|
|
245
244
|
|
|
246
245
|
// Write image tag and AKM mount paths to stack.env — atomic to avoid
|
|
247
246
|
// partial writes if the process is interrupted mid-write.
|
|
248
|
-
const systemEnvForAkm = existsSync(`${state.
|
|
249
|
-
? readFileSync(`${state.
|
|
247
|
+
const systemEnvForAkm = existsSync(`${state.stashDir}/env/stack.env`)
|
|
248
|
+
? readFileSync(`${state.stashDir}/env/stack.env`, "utf-8")
|
|
250
249
|
: "";
|
|
251
250
|
const akmUpdates: Record<string, string> = {};
|
|
252
251
|
if (imageTag) akmUpdates.OP_IMAGE_TAG = imageTag;
|
|
@@ -254,14 +253,11 @@ export async function performSetup(
|
|
|
254
253
|
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
255
254
|
if (home) {
|
|
256
255
|
akmUpdates.OP_AKM_STASH = `${home}/akm`;
|
|
257
|
-
akmUpdates.OP_AKM_DATA = `${home}/.local/share/akm`;
|
|
258
|
-
akmUpdates.OP_AKM_STATE = `${home}/.local/state/akm`;
|
|
259
|
-
akmUpdates.OP_AKM_CACHE = `${home}/.cache/akm`;
|
|
260
256
|
akmUpdates.OP_AKM_CONFIG = `${home}/.config/akm`;
|
|
261
257
|
}
|
|
262
258
|
}
|
|
263
259
|
if (Object.keys(akmUpdates).length > 0) {
|
|
264
|
-
writeFileAtomic(`${state.
|
|
260
|
+
writeFileAtomic(`${state.stashDir}/env/stack.env`, mergeEnvContent(systemEnvForAkm, akmUpdates), 0o600);
|
|
265
261
|
}
|
|
266
262
|
|
|
267
263
|
// Write akm config with LLM and embedding settings from setup — atomic.
|
|
@@ -302,24 +298,33 @@ export async function performSetup(
|
|
|
302
298
|
}
|
|
303
299
|
|
|
304
300
|
// Enable requested addons (channels like discord, slack, etc.)
|
|
305
|
-
// setAddonEnabled
|
|
301
|
+
// setAddonEnabled records explicit activation state and ensures channel secret files.
|
|
306
302
|
if (addons) {
|
|
307
303
|
for (const [name, enabled] of Object.entries(addons)) {
|
|
308
|
-
if (enabled) setAddonEnabled(state.homeDir, state.stackDir, name, true);
|
|
304
|
+
if (enabled) setAddonEnabled(state.homeDir, state.stackDir, name, true, state);
|
|
309
305
|
}
|
|
310
306
|
}
|
|
311
307
|
|
|
308
|
+
|
|
309
|
+
if (voiceProfile?.trim()) {
|
|
310
|
+
setAddonProfileSelection(state.stackDir, 'voice', voiceProfile.trim(), state);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (ollamaProfile?.trim()) {
|
|
314
|
+
setAddonProfileSelection(state.stackDir, 'ollama', ollamaProfile.trim(), state);
|
|
315
|
+
}
|
|
316
|
+
|
|
312
317
|
ensureOpenCodeConfig();
|
|
313
318
|
|
|
314
319
|
// Seed default automation into the AKM stash. Idempotent — existing files
|
|
315
320
|
// are left alone so user edits survive re-install and upgrade.
|
|
316
321
|
const tasksDir = join(state.stashDir, "tasks");
|
|
317
322
|
mkdirSync(tasksDir, { recursive: true });
|
|
318
|
-
const akmImproveDest = join(tasksDir, "akm-improve.
|
|
323
|
+
const akmImproveDest = join(tasksDir, "akm-improve.yml");
|
|
319
324
|
if (!existsSync(akmImproveDest)) {
|
|
320
|
-
const
|
|
321
|
-
if (
|
|
322
|
-
writeFileSync(akmImproveDest,
|
|
325
|
+
const akmImproveTask = getRegistryAutomation("akm-improve");
|
|
326
|
+
if (akmImproveTask) {
|
|
327
|
+
writeFileSync(akmImproveDest, akmImproveTask);
|
|
323
328
|
logger.info("seeded default automation", { name: "akm-improve" });
|
|
324
329
|
} else {
|
|
325
330
|
logger.warn("default automation missing from registry; skipping seed", {
|
|
@@ -15,9 +15,8 @@ const SKELETON_DIR = join(REPO_ROOT, ".openpalm");
|
|
|
15
15
|
// Allowed top-level dirs in .openpalm/ — mirrors the OP_HOME runtime layout
|
|
16
16
|
const ALLOWED_SOURCE_DIRS = new Set([
|
|
17
17
|
"config", // seed files for config/ (assistant, guardian, stack/, akm/)
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"cache", // empty cache dirs (.gitkeep — regenerable at runtime)
|
|
18
|
+
"knowledge", // knowledge source assets: skills/, env/, secrets/, tasks/
|
|
19
|
+
"data", // empty service dirs (.gitkeep)
|
|
21
20
|
"workspace", // empty workspace dir (.gitkeep)
|
|
22
21
|
]);
|
|
23
22
|
|
|
@@ -37,25 +36,37 @@ describe("skeleton: .openpalm/ top-level directories", () => {
|
|
|
37
36
|
expect(existsSync(join(SKELETON_DIR, "stack"))).toBe(false);
|
|
38
37
|
});
|
|
39
38
|
|
|
40
|
-
test("registry/ no longer exists
|
|
39
|
+
test("registry/ no longer exists", () => {
|
|
41
40
|
expect(existsSync(join(SKELETON_DIR, "registry"))).toBe(false);
|
|
42
41
|
});
|
|
43
42
|
|
|
44
|
-
test("stash-seeds/ no longer exists (moved to
|
|
43
|
+
test("stash-seeds/ no longer exists (moved to knowledge/)", () => {
|
|
45
44
|
expect(existsSync(join(SKELETON_DIR, "stash-seeds"))).toBe(false);
|
|
46
45
|
});
|
|
47
46
|
});
|
|
48
47
|
|
|
48
|
+
// ── power-user helper scripts ─────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
describe("skeleton: helper scripts", () => {
|
|
51
|
+
test("openpalm.sh and openpalm.ps1 ship at the skeleton root", () => {
|
|
52
|
+
expect(existsSync(join(SKELETON_DIR, "openpalm.sh"))).toBe(true);
|
|
53
|
+
expect(existsSync(join(SKELETON_DIR, "openpalm.ps1"))).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
49
57
|
// ── config/ subdirectory ──────────────────────────────────────────────
|
|
50
58
|
|
|
51
59
|
describe("skeleton: .openpalm/config/ structure", () => {
|
|
52
|
-
test("config/stack/ exists with
|
|
60
|
+
test("config/stack/ exists with fixed compose files and stack.yml", () => {
|
|
53
61
|
expect(existsSync(join(SKELETON_DIR, "config", "stack", "core.compose.yml"))).toBe(true);
|
|
62
|
+
expect(existsSync(join(SKELETON_DIR, "config", "stack", "services.compose.yml"))).toBe(true);
|
|
63
|
+
expect(existsSync(join(SKELETON_DIR, "config", "stack", "channels.compose.yml"))).toBe(true);
|
|
64
|
+
expect(existsSync(join(SKELETON_DIR, "config", "stack", "custom.compose.yml"))).toBe(true);
|
|
54
65
|
expect(existsSync(join(SKELETON_DIR, "config", "stack", "stack.yml"))).toBe(true);
|
|
55
66
|
});
|
|
56
67
|
|
|
57
|
-
test("config/stack/addons/
|
|
58
|
-
expect(existsSync(join(SKELETON_DIR, "config", "stack", "addons"))).toBe(
|
|
68
|
+
test("config/stack/addons/ does not exist", () => {
|
|
69
|
+
expect(existsSync(join(SKELETON_DIR, "config", "stack", "addons"))).toBe(false);
|
|
59
70
|
});
|
|
60
71
|
|
|
61
72
|
test("config/akm/ exists", () => {
|
|
@@ -63,86 +74,84 @@ describe("skeleton: .openpalm/config/ structure", () => {
|
|
|
63
74
|
});
|
|
64
75
|
|
|
65
76
|
test("config/assistant/ has seed files", () => {
|
|
66
|
-
expect(existsSync(join(SKELETON_DIR, "config", "assistant", "opencode.
|
|
77
|
+
expect(existsSync(join(SKELETON_DIR, "config", "assistant", "opencode.jsonc"))).toBe(true);
|
|
67
78
|
});
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
// ── state/registry/ subdirectory ─────────────────────────────────────
|
|
71
79
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const addonsDir = join(SKELETON_DIR, "state", "registry", "addons");
|
|
75
|
-
expect(existsSync(addonsDir)).toBe(true);
|
|
76
|
-
const addons = readdirSync(addonsDir);
|
|
77
|
-
expect(addons).toContain("chat");
|
|
78
|
-
expect(addons).toContain("api");
|
|
79
|
-
expect(addons).toContain("discord");
|
|
80
|
+
test("config/guardian/ has the OpenCode global config (mounted at /etc/opencode)", () => {
|
|
81
|
+
expect(existsSync(join(SKELETON_DIR, "config", "guardian", "opencode.jsonc"))).toBe(true);
|
|
80
82
|
});
|
|
81
83
|
|
|
82
|
-
test("
|
|
83
|
-
expect(existsSync(join(SKELETON_DIR, "
|
|
84
|
+
test("config/guardian/ ships the message-moderation instructions", () => {
|
|
85
|
+
expect(existsSync(join(SKELETON_DIR, "config", "guardian", "instructions", "moderation.md"))).toBe(true);
|
|
84
86
|
});
|
|
87
|
+
});
|
|
85
88
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
for (const addon of addons) {
|
|
92
|
-
expect(existsSync(join(addonsDir, addon, "compose.yml"))).toBe(true);
|
|
93
|
-
}
|
|
89
|
+
// ── no runtime registry ───────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
describe("skeleton: no runtime registry", () => {
|
|
92
|
+
test("data/registry/ does not exist", () => {
|
|
93
|
+
expect(existsSync(join(SKELETON_DIR, "data", "registry"))).toBe(false);
|
|
94
94
|
});
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
-
// ──
|
|
97
|
+
// ── knowledge/ subdirectory ───────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
describe("skeleton: .openpalm/knowledge/ structure", () => {
|
|
100
|
+
test("knowledge/skills/ exists with config-diagnostics skill", () => {
|
|
101
|
+
expect(existsSync(join(SKELETON_DIR, "knowledge", "skills", "config-diagnostics", "SKILL.md"))).toBe(true);
|
|
102
|
+
});
|
|
98
103
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
expect(existsSync(join(SKELETON_DIR, "stash", "skills", "config-diagnostics", "SKILL.md"))).toBe(true);
|
|
104
|
+
test("knowledge/env/ exists with user.env seed", () => {
|
|
105
|
+
expect(existsSync(join(SKELETON_DIR, "knowledge", "env", "user.env"))).toBe(true);
|
|
102
106
|
});
|
|
103
107
|
|
|
104
|
-
test("
|
|
105
|
-
expect(existsSync(join(SKELETON_DIR, "
|
|
108
|
+
test("knowledge/secrets/ exists", () => {
|
|
109
|
+
expect(existsSync(join(SKELETON_DIR, "knowledge", "secrets"))).toBe(true);
|
|
106
110
|
});
|
|
107
111
|
|
|
108
|
-
test("
|
|
109
|
-
expect(existsSync(join(SKELETON_DIR, "
|
|
112
|
+
test("knowledge/tasks/ exists", () => {
|
|
113
|
+
expect(existsSync(join(SKELETON_DIR, "knowledge", "tasks"))).toBe(true);
|
|
110
114
|
});
|
|
111
115
|
});
|
|
112
116
|
|
|
113
|
-
// ──
|
|
117
|
+
// ── data/ service dirs ────────────────────────────────────────────────
|
|
114
118
|
|
|
115
|
-
describe("skeleton: .openpalm/
|
|
116
|
-
const serviceDirs = ["assistant", "admin", "guardian"
|
|
119
|
+
describe("skeleton: .openpalm/data/ service directories", () => {
|
|
120
|
+
const serviceDirs = ["assistant", "admin", "guardian"];
|
|
117
121
|
|
|
118
122
|
for (const dir of serviceDirs) {
|
|
119
|
-
test(`
|
|
120
|
-
expect(existsSync(join(SKELETON_DIR, "
|
|
123
|
+
test(`data/${dir}/ exists`, () => {
|
|
124
|
+
expect(existsSync(join(SKELETON_DIR, "data", dir))).toBe(true);
|
|
121
125
|
});
|
|
122
126
|
}
|
|
123
127
|
|
|
124
|
-
test("
|
|
125
|
-
expect(existsSync(join(SKELETON_DIR, "
|
|
128
|
+
test("data/akm/ exists", () => {
|
|
129
|
+
expect(existsSync(join(SKELETON_DIR, "data", "akm"))).toBe(true);
|
|
126
130
|
});
|
|
127
131
|
|
|
128
|
-
test("
|
|
129
|
-
expect(existsSync(join(SKELETON_DIR, "
|
|
132
|
+
test("data/akm/cache and data/akm/data exist", () => {
|
|
133
|
+
expect(existsSync(join(SKELETON_DIR, "data", "akm", "cache"))).toBe(true);
|
|
134
|
+
expect(existsSync(join(SKELETON_DIR, "data", "akm", "data"))).toBe(true);
|
|
130
135
|
});
|
|
131
136
|
|
|
132
|
-
test("
|
|
133
|
-
expect(existsSync(join(SKELETON_DIR, "
|
|
137
|
+
test("data/logs/ exists", () => {
|
|
138
|
+
expect(existsSync(join(SKELETON_DIR, "data", "logs"))).toBe(true);
|
|
134
139
|
});
|
|
135
140
|
});
|
|
136
141
|
|
|
137
|
-
// ──
|
|
142
|
+
// ── data/rollback and workspace/ ──────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
describe("skeleton: .openpalm/data/rollback and workspace/", () => {
|
|
145
|
+
test("cache/ does not exist in the skeleton", () => {
|
|
146
|
+
expect(existsSync(join(SKELETON_DIR, "cache"))).toBe(false);
|
|
147
|
+
});
|
|
138
148
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
expect(existsSync(join(SKELETON_DIR, "cache", "akm"))).toBe(true);
|
|
149
|
+
test("data/backups/ exists", () => {
|
|
150
|
+
expect(existsSync(join(SKELETON_DIR, "data", "backups"))).toBe(true);
|
|
142
151
|
});
|
|
143
152
|
|
|
144
|
-
test("
|
|
145
|
-
expect(existsSync(join(SKELETON_DIR, "
|
|
153
|
+
test("data/rollback/ exists", () => {
|
|
154
|
+
expect(existsSync(join(SKELETON_DIR, "data", "rollback"))).toBe(true);
|
|
146
155
|
});
|
|
147
156
|
|
|
148
157
|
test("workspace/ exists", () => {
|
|
@@ -62,54 +62,59 @@ describe("deriveSystemEnvFromSpec", () => {
|
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
describe("writeVoiceVars", () => {
|
|
65
|
+
// writeVoiceVars takes a stackDir (<home>/config/stack) and writes the env
|
|
66
|
+
// to <home>/knowledge/env/stack.env. Build that layout per test.
|
|
67
|
+
let stackDir = "";
|
|
68
|
+
let stackEnv = "";
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
stackDir = join(tempDir, "config", "stack");
|
|
71
|
+
stackEnv = join(tempDir, "knowledge", "env", "stack.env");
|
|
72
|
+
mkdirSync(stackDir, { recursive: true });
|
|
73
|
+
mkdirSync(join(tempDir, "knowledge", "env"), { recursive: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
65
76
|
test("writes TTS vars to stack.env", () => {
|
|
66
|
-
|
|
67
|
-
writeFileSync(join(tempDir, "stack.env"), "# stack env\n");
|
|
77
|
+
writeFileSync(stackEnv, "# stack env\n");
|
|
68
78
|
|
|
69
79
|
writeVoiceVars({
|
|
70
80
|
tts: { baseURL: "https://tts.example.com/v1", model: "tts-1", voice: "alloy" },
|
|
71
|
-
},
|
|
81
|
+
}, stackDir);
|
|
72
82
|
|
|
73
|
-
const content = readFileSync(
|
|
83
|
+
const content = readFileSync(stackEnv, "utf-8");
|
|
74
84
|
expect(content).toContain("OP_TTS_BASE_URL=https://tts.example.com/v1");
|
|
75
85
|
expect(content).toContain("OP_TTS_MODEL=tts-1");
|
|
76
86
|
expect(content).toContain("OP_TTS_VOICE=alloy");
|
|
77
87
|
});
|
|
78
88
|
|
|
79
89
|
test("writes STT vars to stack.env", () => {
|
|
80
|
-
|
|
81
|
-
writeFileSync(join(tempDir, "stack.env"), "# stack env\n");
|
|
90
|
+
writeFileSync(stackEnv, "# stack env\n");
|
|
82
91
|
|
|
83
92
|
writeVoiceVars({
|
|
84
93
|
stt: { baseURL: "https://stt.example.com/v1", model: "whisper-1", language: "en" },
|
|
85
|
-
},
|
|
94
|
+
}, stackDir);
|
|
86
95
|
|
|
87
|
-
const content = readFileSync(
|
|
96
|
+
const content = readFileSync(stackEnv, "utf-8");
|
|
88
97
|
expect(content).toContain("OP_STT_BASE_URL=https://stt.example.com/v1");
|
|
89
98
|
expect(content).toContain("OP_STT_MODEL=whisper-1");
|
|
90
99
|
expect(content).toContain("OP_STT_LANGUAGE=en");
|
|
91
100
|
});
|
|
92
101
|
|
|
93
102
|
test("creates stack.env if it does not exist", () => {
|
|
94
|
-
mkdirSync(tempDir, { recursive: true });
|
|
95
|
-
|
|
96
103
|
writeVoiceVars({
|
|
97
104
|
tts: { baseURL: "https://tts.example.com/v1", model: "tts-1" },
|
|
98
|
-
},
|
|
105
|
+
}, stackDir);
|
|
99
106
|
|
|
100
|
-
const content = readFileSync(
|
|
107
|
+
const content = readFileSync(stackEnv, "utf-8");
|
|
101
108
|
expect(content).toContain("OP_TTS_BASE_URL=https://tts.example.com/v1");
|
|
102
109
|
});
|
|
103
110
|
|
|
104
111
|
test("is a no-op when no vars are provided", () => {
|
|
105
|
-
|
|
106
|
-
const stackEnvPath = join(tempDir, "stack.env");
|
|
107
|
-
writeFileSync(stackEnvPath, "EXISTING=value\n");
|
|
112
|
+
writeFileSync(stackEnv, "EXISTING=value\n");
|
|
108
113
|
|
|
109
|
-
writeVoiceVars({},
|
|
114
|
+
writeVoiceVars({}, stackDir);
|
|
110
115
|
|
|
111
116
|
// File should be unchanged
|
|
112
|
-
const content = readFileSync(
|
|
117
|
+
const content = readFileSync(stackEnv, "utf-8");
|
|
113
118
|
expect(content).toBe("EXISTING=value\n");
|
|
114
119
|
});
|
|
115
120
|
});
|
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { SPEC_DEFAULTS } from "./stack-spec.js";
|
|
9
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { dirname } from "node:path";
|
|
10
11
|
import { mergeEnvContent } from "./env.js";
|
|
11
12
|
import { resolveOperatorIds } from "./operator-ids.js";
|
|
13
|
+
import { assertNoSecretLikeStackEnvKeys } from './secrets.js';
|
|
14
|
+
import { stackEnvPathFromStackDir } from './paths.js';
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* Derive the system.env key-value pairs from the StackSpec.
|
|
@@ -75,7 +78,7 @@ export type VoiceVarsConfig = {
|
|
|
75
78
|
* engine without filling in URL/model still persists.
|
|
76
79
|
*/
|
|
77
80
|
export function writeVoiceVars(config: VoiceVarsConfig, stackDir: string): void {
|
|
78
|
-
const stackEnvPath =
|
|
81
|
+
const stackEnvPath = stackEnvPathFromStackDir(stackDir);
|
|
79
82
|
const base = existsSync(stackEnvPath) ? readFileSync(stackEnvPath, "utf-8") : "";
|
|
80
83
|
const vars: Record<string, string> = {};
|
|
81
84
|
|
|
@@ -101,10 +104,12 @@ export function writeVoiceVars(config: VoiceVarsConfig, stackDir: string): void
|
|
|
101
104
|
}
|
|
102
105
|
|
|
103
106
|
if (Object.keys(vars).length === 0) return;
|
|
107
|
+
assertNoSecretLikeStackEnvKeys(vars);
|
|
104
108
|
|
|
105
109
|
let content = mergeEnvContent(base, vars, {
|
|
106
110
|
sectionHeader: "# ── Voice Channel (TTS/STT) ──────────────────────────────────────────",
|
|
107
111
|
});
|
|
108
112
|
if (!content.endsWith("\n")) content += "\n";
|
|
113
|
+
mkdirSync(dirname(stackEnvPath), { recursive: true, mode: 0o700 });
|
|
109
114
|
writeFileSync(stackEnvPath, content, { mode: 0o600 });
|
|
110
115
|
}
|
|
@@ -36,6 +36,11 @@ describe("readStackSpec / writeStackSpec round-trip", () => {
|
|
|
36
36
|
expect(read!.version).toBe(2);
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
+
it("round-trips enabled addons", () => {
|
|
40
|
+
writeStackSpec(configDir, { version: 2, addons: ['chat', 'api'] });
|
|
41
|
+
expect(readStackSpec(configDir)).toEqual({ version: 2, addons: ['api', 'chat'] });
|
|
42
|
+
});
|
|
43
|
+
|
|
39
44
|
it("writes to the canonical filename", () => {
|
|
40
45
|
writeStackSpec(configDir, MINIMAL_SPEC);
|
|
41
46
|
const expectedPath = join(configDir, STACK_SPEC_FILENAME);
|
|
@@ -77,6 +82,11 @@ describe("readStackSpec edge cases", () => {
|
|
|
77
82
|
expect(spec).not.toBeNull();
|
|
78
83
|
expect(spec!.version).toBe(2);
|
|
79
84
|
});
|
|
85
|
+
|
|
86
|
+
it("ignores malformed addon names", () => {
|
|
87
|
+
writeFileSync(join(configDir, STACK_SPEC_FILENAME), "version: 2\naddons:\n - chat\n - ../bad\n - API\n");
|
|
88
|
+
expect(readStackSpec(configDir)).toEqual({ version: 2, addons: ['chat'] });
|
|
89
|
+
});
|
|
80
90
|
});
|
|
81
91
|
|
|
82
92
|
// ── STACK_SPEC_FILENAME ───────────────────────────────────────────────────
|
|
@@ -14,8 +14,11 @@ import { stringify as yamlStringify, parse as yamlParse } from "yaml";
|
|
|
14
14
|
|
|
15
15
|
export type StackSpec = {
|
|
16
16
|
version: 2;
|
|
17
|
+
addons?: string[];
|
|
17
18
|
};
|
|
18
19
|
|
|
20
|
+
const ADDON_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
21
|
+
|
|
19
22
|
// ── Constants ───────────────────────────────────────────────────────────
|
|
20
23
|
|
|
21
24
|
export const STACK_SPEC_FILENAME = "stack.yml";
|
|
@@ -59,5 +62,29 @@ export function readStackSpec(configDir: string): StackSpec | null {
|
|
|
59
62
|
if (typeof raw !== "object" || raw === null) return null;
|
|
60
63
|
const obj = raw as Record<string, unknown>;
|
|
61
64
|
if (obj.version !== 2) return null;
|
|
62
|
-
|
|
65
|
+
const spec: StackSpec = { version: 2 };
|
|
66
|
+
if (Array.isArray(obj.addons)) {
|
|
67
|
+
const addons = obj.addons
|
|
68
|
+
.filter((value): value is string => typeof value === 'string' && ADDON_NAME_RE.test(value))
|
|
69
|
+
.filter((value, index, all) => all.indexOf(value) === index)
|
|
70
|
+
.sort();
|
|
71
|
+
if (addons.length > 0) spec.addons = addons;
|
|
72
|
+
}
|
|
73
|
+
return spec;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function listStackSpecAddons(configDir: string): string[] {
|
|
77
|
+
return readStackSpec(configDir)?.addons ?? [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function setStackSpecAddon(configDir: string, name: string, enabled: boolean): void {
|
|
81
|
+
if (!ADDON_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
|
|
82
|
+
const current = readStackSpec(configDir) ?? { version: 2 };
|
|
83
|
+
const addons = new Set(current.addons ?? []);
|
|
84
|
+
if (enabled) addons.add(name);
|
|
85
|
+
else addons.delete(name);
|
|
86
|
+
const next: StackSpec = { version: 2 };
|
|
87
|
+
const sorted = [...addons].sort();
|
|
88
|
+
if (sorted.length > 0) next.addons = sorted;
|
|
89
|
+
writeStackSpec(configDir, next);
|
|
63
90
|
}
|
|
@@ -27,10 +27,9 @@ export type ArtifactMeta = {
|
|
|
27
27
|
export type ControlPlaneState = {
|
|
28
28
|
homeDir: string;
|
|
29
29
|
configDir: string;
|
|
30
|
-
stashDir: string; // homeDir/
|
|
30
|
+
stashDir: string; // homeDir/knowledge
|
|
31
31
|
workspaceDir: string; // homeDir/workspace
|
|
32
|
-
|
|
33
|
-
stateDir: string; // homeDir/state (service data + system state)
|
|
32
|
+
dataDir: string; // homeDir/data (service data + operational files)
|
|
34
33
|
stackDir: string; // configDir/stack (compose runtime + stack config)
|
|
35
34
|
services: Record<string, "running" | "stopped">;
|
|
36
35
|
artifacts: {
|
|
@@ -48,4 +47,3 @@ export const CORE_SERVICES: CoreServiceName[] = [
|
|
|
48
47
|
"assistant",
|
|
49
48
|
"guardian",
|
|
50
49
|
];
|
|
51
|
-
|