@openpalm/lib 0.10.1 → 0.11.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +2 -2
  2. package/package.json +7 -3
  3. package/src/control-plane/admin-token.ts +73 -0
  4. package/src/control-plane/akm-vault.test.ts +108 -0
  5. package/src/control-plane/akm-vault.ts +307 -0
  6. package/src/control-plane/audit.ts +3 -2
  7. package/src/control-plane/channels.ts +3 -3
  8. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  9. package/src/control-plane/compose-args.test.ts +25 -21
  10. package/src/control-plane/config-persistence.ts +103 -64
  11. package/src/control-plane/core-assets.test.ts +104 -0
  12. package/src/control-plane/core-assets.ts +54 -57
  13. package/src/control-plane/docker.ts +55 -21
  14. package/src/control-plane/env.test.ts +25 -1
  15. package/src/control-plane/env.ts +80 -0
  16. package/src/control-plane/home.ts +66 -69
  17. package/src/control-plane/host-opencode.test.ts +263 -0
  18. package/src/control-plane/host-opencode.ts +229 -0
  19. package/src/control-plane/install-edge-cases.test.ts +182 -244
  20. package/src/control-plane/install-lock.ts +157 -0
  21. package/src/control-plane/lifecycle.ts +57 -56
  22. package/src/control-plane/markdown-task.ts +200 -0
  23. package/src/control-plane/paths.ts +75 -0
  24. package/src/control-plane/provider-config.ts +2 -2
  25. package/src/control-plane/provider-models.ts +154 -0
  26. package/src/control-plane/registry-components.test.ts +102 -25
  27. package/src/control-plane/registry.test.ts +49 -47
  28. package/src/control-plane/registry.ts +71 -50
  29. package/src/control-plane/rollback.ts +17 -16
  30. package/src/control-plane/scheduler.ts +75 -262
  31. package/src/control-plane/secret-backend.test.ts +98 -108
  32. package/src/control-plane/secret-backend.ts +221 -181
  33. package/src/control-plane/secret-mappings.ts +3 -6
  34. package/src/control-plane/secrets.ts +83 -47
  35. package/src/control-plane/setup-config.schema.json +2 -14
  36. package/src/control-plane/setup-status.ts +4 -29
  37. package/src/control-plane/setup-validation.ts +21 -21
  38. package/src/control-plane/setup.test.ts +122 -227
  39. package/src/control-plane/setup.ts +224 -125
  40. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  41. package/src/control-plane/spec-to-env.test.ts +59 -58
  42. package/src/control-plane/spec-to-env.ts +39 -140
  43. package/src/control-plane/spec-validator.ts +2 -99
  44. package/src/control-plane/stack-spec.test.ts +21 -77
  45. package/src/control-plane/stack-spec.ts +7 -83
  46. package/src/control-plane/types.ts +17 -15
  47. package/src/control-plane/ui-assets.ts +349 -0
  48. package/src/control-plane/validate.ts +44 -79
  49. package/src/index.ts +77 -44
  50. package/src/logger.test.ts +228 -0
  51. package/src/logger.ts +71 -1
  52. package/src/provider-constants.ts +22 -1
  53. package/src/control-plane/env-schema-validation.test.ts +0 -118
  54. package/src/control-plane/memory-config.ts +0 -298
  55. package/src/control-plane/redact-schema.ts +0 -50
@@ -2,20 +2,9 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
2
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { deriveSystemEnvFromSpec, writeCapabilityVars } from "./spec-to-env.js";
6
- import type { StackSpec } from "./stack-spec.js";
7
-
8
- function makeSpec(overrides?: Partial<StackSpec>): StackSpec {
9
- return {
10
- version: 2,
11
- capabilities: {
12
- llm: "openai/gpt-4o",
13
- embeddings: { provider: "openai", model: "text-embedding-3-small", dims: 1536 },
14
- memory: { userId: "default_user" },
15
- },
16
- ...overrides,
17
- };
18
- }
5
+ import { deriveSystemEnvFromSpec, writeVoiceVars } from "./spec-to-env.js";
6
+
7
+ const MINIMAL_SPEC = { version: 2 as const };
19
8
 
20
9
  let tempDir = "";
21
10
 
@@ -29,72 +18,84 @@ afterEach(() => {
29
18
 
30
19
  describe("deriveSystemEnvFromSpec", () => {
31
20
  test("produces OP_HOME", () => {
32
- const result = deriveSystemEnvFromSpec(makeSpec(), "/home/op");
21
+ const result = deriveSystemEnvFromSpec(MINIMAL_SPEC, "/home/op");
33
22
  expect(result.OP_HOME).toBe("/home/op");
34
23
  });
35
24
 
36
25
  test("produces default port values", () => {
37
- const result = deriveSystemEnvFromSpec(makeSpec(), "/home/op");
26
+ const result = deriveSystemEnvFromSpec(MINIMAL_SPEC, "/home/op");
38
27
  expect(result.OP_ASSISTANT_PORT).toBe("3800");
39
- expect(result.OP_MEMORY_PORT).toBe("3898");
40
28
  expect(result.OP_GUARDIAN_PORT).toBe("3899");
41
29
  });
42
30
 
43
- test("does not include LLM provider in system env (lives in OP_CAP_* vars in stack.env)", () => {
44
- const result = deriveSystemEnvFromSpec(makeSpec(), "/home/op");
45
- expect(result.SYSTEM_LLM_PROVIDER).toBeUndefined();
46
- expect(result.SYSTEM_LLM_MODEL).toBeUndefined();
31
+ test("does not include the retired memory service port", () => {
32
+ const result = deriveSystemEnvFromSpec(MINIMAL_SPEC, "/home/op");
33
+ const retired = "OP_" + "MEMORY_PORT";
34
+ expect(result[retired]).toBeUndefined();
47
35
  });
48
36
 
49
- test("does not include embedding config in system env (lives in OP_CAP_* vars in stack.env)", () => {
50
- const result = deriveSystemEnvFromSpec(makeSpec(), "/home/op");
51
- expect(result.EMBEDDING_MODEL).toBeUndefined();
52
- expect(result.EMBEDDING_DIMS).toBeUndefined();
37
+ test("does not include LLM provider in system env", () => {
38
+ const result = deriveSystemEnvFromSpec(MINIMAL_SPEC, "/home/op");
39
+ expect(result.SYSTEM_LLM_PROVIDER).toBeUndefined();
40
+ expect(result.SYSTEM_LLM_MODEL).toBeUndefined();
53
41
  });
54
42
 
55
43
  test("does not include removed feature flags", () => {
56
- const spec = makeSpec();
57
- const result = deriveSystemEnvFromSpec(spec, "/home/op");
44
+ const result = deriveSystemEnvFromSpec(MINIMAL_SPEC, "/home/op");
58
45
  expect(result.OP_OLLAMA_ENABLED).toBeUndefined();
59
46
  expect(result.OP_ADMIN_ENABLED).toBeUndefined();
60
47
  });
61
48
  });
62
49
 
63
- describe("writeCapabilityVars", () => {
64
- test("writes OP_CAP_* vars to stack.env", () => {
65
- const spec = makeSpec({
66
- capabilities: {
67
- llm: "openai/gpt-4o",
68
- embeddings: { provider: "openai", model: "text-embedding-3-small", dims: 1536 },
69
- memory: { userId: "default_user" },
70
- },
71
- });
72
-
73
- // Seed stack.env so writeCapabilityVars can read/merge it
74
- const vaultDir = join(tempDir, "vault");
75
- mkdirSync(join(vaultDir, "stack"), { recursive: true });
76
- writeFileSync(join(vaultDir, "stack", "stack.env"), "# stack env\n");
77
-
78
- writeCapabilityVars(spec, vaultDir);
79
-
80
- const stackEnvContent = readFileSync(join(vaultDir, "stack", "stack.env"), "utf-8");
81
- expect(stackEnvContent).toContain("OP_CAP_LLM_PROVIDER=openai");
82
- expect(stackEnvContent).toContain("OP_CAP_LLM_MODEL=gpt-4o");
83
- expect(stackEnvContent).toContain("OP_CAP_EMBEDDINGS_MODEL=text-embedding-3-small");
84
- expect(stackEnvContent).toContain("OP_CAP_EMBEDDINGS_DIMS=1536");
85
- expect(stackEnvContent).toContain("MEMORY_USER_ID=default_user");
50
+ describe("writeVoiceVars", () => {
51
+ test("writes TTS vars to stack.env", () => {
52
+ mkdirSync(tempDir, { recursive: true });
53
+ writeFileSync(join(tempDir, "stack.env"), "# stack env\n");
54
+
55
+ writeVoiceVars({
56
+ tts: { baseURL: "https://tts.example.com/v1", model: "tts-1", voice: "alloy" },
57
+ }, tempDir);
58
+
59
+ const content = readFileSync(join(tempDir, "stack.env"), "utf-8");
60
+ expect(content).toContain("TTS_BASE_URL=https://tts.example.com/v1");
61
+ expect(content).toContain("TTS_MODEL=tts-1");
62
+ expect(content).toContain("TTS_VOICE=alloy");
86
63
  });
87
64
 
88
- test("does not create managed.env files", () => {
89
- const spec = makeSpec();
65
+ test("writes STT vars to stack.env", () => {
66
+ mkdirSync(tempDir, { recursive: true });
67
+ writeFileSync(join(tempDir, "stack.env"), "# stack env\n");
68
+
69
+ writeVoiceVars({
70
+ stt: { baseURL: "https://stt.example.com/v1", model: "whisper-1", language: "en" },
71
+ }, tempDir);
72
+
73
+ const content = readFileSync(join(tempDir, "stack.env"), "utf-8");
74
+ expect(content).toContain("STT_BASE_URL=https://stt.example.com/v1");
75
+ expect(content).toContain("STT_MODEL=whisper-1");
76
+ expect(content).toContain("STT_LANGUAGE=en");
77
+ });
78
+
79
+ test("creates stack.env if it does not exist", () => {
80
+ mkdirSync(tempDir, { recursive: true });
81
+
82
+ writeVoiceVars({
83
+ tts: { baseURL: "https://tts.example.com/v1", model: "tts-1" },
84
+ }, tempDir);
85
+
86
+ const content = readFileSync(join(tempDir, "stack.env"), "utf-8");
87
+ expect(content).toContain("TTS_BASE_URL=https://tts.example.com/v1");
88
+ });
90
89
 
91
- const vaultDir = join(tempDir, "vault");
92
- mkdirSync(join(vaultDir, "stack"), { recursive: true });
93
- writeFileSync(join(vaultDir, "stack", "stack.env"), "# stack env\n");
90
+ test("is a no-op when no vars are provided", () => {
91
+ mkdirSync(tempDir, { recursive: true });
92
+ const stackEnvPath = join(tempDir, "stack.env");
93
+ writeFileSync(stackEnvPath, "EXISTING=value\n");
94
94
 
95
- writeCapabilityVars(spec, vaultDir);
95
+ writeVoiceVars({}, tempDir);
96
96
 
97
- const managedEnvPath = join(vaultDir, "stack", "services", "memory", "managed.env");
98
- expect(() => readFileSync(managedEnvPath)).toThrow();
97
+ // File should be unchanged
98
+ const content = readFileSync(stackEnvPath, "utf-8");
99
+ expect(content).toBe("EXISTING=value\n");
99
100
  });
100
101
  });
@@ -1,18 +1,14 @@
1
1
  /**
2
2
  * Config-to-env derivation pipeline.
3
3
  *
4
- * Reads a StackSpec v2 and deterministically produces:
5
- * 1. System env vars for stack.env (non-secret infrastructure config)
6
- * 2. Resolved capability vars (OP_CAP_*) written to stack.env
4
+ * Produces system env vars for stack.env (non-secret infrastructure config).
5
+ * Voice channel vars (TTS/STT) are written separately via writeVoiceVars.
7
6
  */
8
7
 
9
8
  import type { StackSpec } from "./stack-spec.js";
10
- import { SPEC_DEFAULTS, parseCapabilityString } from "./stack-spec.js";
9
+ import { SPEC_DEFAULTS } from "./stack-spec.js";
11
10
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
- import { dirname } from "node:path";
13
- import { mergeEnvContent, parseEnvContent } from "./env.js";
14
- import { PROVIDER_DEFAULT_URLS, PROVIDER_KEY_MAP, OLLAMA_INSTACK_URL } from "../provider-constants.js";
15
- import { listEnabledAddonIds } from "./registry.js";
11
+ import { mergeEnvContent } from "./env.js";
16
12
 
17
13
  /**
18
14
  * Derive the system.env key-value pairs from the StackSpec.
@@ -34,8 +30,6 @@ export function deriveSystemEnvFromSpec(
34
30
  result["OP_HOME"] = homeDir;
35
31
  result["OP_UID"] = String(uid);
36
32
  result["OP_GID"] = String(gid);
37
- result["OP_DOCKER_SOCK"] = process.env.OP_DOCKER_SOCK ?? "/var/run/docker.sock";
38
-
39
33
  // Image
40
34
  result["OP_IMAGE_NAMESPACE"] = image.namespace;
41
35
  result["OP_IMAGE_TAG"] = image.tag;
@@ -44,151 +38,56 @@ export function deriveSystemEnvFromSpec(
44
38
  result["OP_ASSISTANT_PORT"] = String(ports.assistant);
45
39
  result["OP_ADMIN_PORT"] = String(ports.admin);
46
40
  result["OP_ADMIN_OPENCODE_PORT"] = String(ports.adminOpencode);
47
- result["OP_MEMORY_PORT"] = String(ports.memory);
48
41
  result["OP_GUARDIAN_PORT"] = String(ports.guardian);
49
42
  result["OP_ASSISTANT_SSH_PORT"] = String(ports.assistantSsh);
50
43
 
44
+ void spec; // spec reserved for future use; ports/image come from SPEC_DEFAULTS
45
+
51
46
  return result;
52
47
  }
53
48
 
54
- // ── Capability Resolution ────────────────────────────────────────────────
55
-
56
- /**
57
- * Resolve all capabilities from stack.yml and write OP_CAP_* vars into stack.env.
58
- *
59
- * Reads raw API keys from the current stack.env, resolves provider → base URL → API key
60
- * for each capability, and merges the OP_CAP_* section into stack.env.
61
- *
62
- * Services consume these via compose ${VAR} substitution in their environment blocks.
63
- */
64
- export function writeCapabilityVars(spec: StackSpec, vaultDir: string): void {
65
- const stackEnvPath = `${vaultDir}/stack/stack.env`;
66
- const stackEnv = existsSync(stackEnvPath)
67
- ? parseEnvContent(readFileSync(stackEnvPath, "utf-8"))
68
- : {};
69
-
70
- const resolveKey = (provider: string): string => {
71
- const keyVar = PROVIDER_KEY_MAP[provider];
72
- return keyVar ? (stackEnv[keyVar] || "") : "";
73
- };
74
-
75
- /** Providers that do NOT use an OpenAI-compatible /v1 path prefix. */
76
- const NO_V1_SUFFIX = new Set(["ollama", "google"]);
77
-
78
- const ensureV1 = (url: string, provider: string): string => {
79
- if (!url || NO_V1_SUFFIX.has(provider)) return url;
80
- return url.endsWith("/v1") ? url : `${url.replace(/\/+$/, "")}/v1`;
81
- };
82
-
83
- /** Map provider → env var for user-configured base URL overrides. */
84
- const BASE_URL_ENV_MAP: Record<string, string> = {
85
- openai: "OPENAI_BASE_URL",
86
- anthropic: "ANTHROPIC_BASE_URL",
87
- groq: "GROQ_BASE_URL",
88
- mistral: "MISTRAL_BASE_URL",
89
- together: "TOGETHER_BASE_URL",
90
- deepseek: "DEEPSEEK_BASE_URL",
91
- xai: "XAI_BASE_URL",
92
- google: "GOOGLE_BASE_URL",
93
- huggingface: "HF_BASE_URL",
94
- ollama: "OLLAMA_BASE_URL",
95
- lmstudio: "LMSTUDIO_BASE_URL",
96
- "model-runner": "MODEL_RUNNER_BASE_URL",
97
- "openai-compatible": "OPENAI_COMPATIBLE_BASE_URL",
98
- };
49
+ // ── Voice Channel Env Vars ────────────────────────────────────────────────
99
50
 
100
- const resolveUrl = (provider: string): string => {
101
- if (provider === "ollama" && listEnabledAddonIds(dirname(vaultDir)).includes("ollama")) return OLLAMA_INSTACK_URL;
102
- // Check stack.env for a user-configured base URL override for any provider
103
- const urlEnvKey = BASE_URL_ENV_MAP[provider];
104
- if (urlEnvKey && stackEnv[urlEnvKey]) {
105
- return ensureV1(stackEnv[urlEnvKey], provider);
106
- }
107
- const defaultUrl = PROVIDER_DEFAULT_URLS[provider] || "";
108
- return ensureV1(defaultUrl, provider);
51
+ export type VoiceVarsConfig = {
52
+ tts?: {
53
+ enabled?: boolean;
54
+ baseURL?: string;
55
+ model?: string;
56
+ voice?: string;
109
57
  };
110
-
111
- const caps: Record<string, string> = {};
112
-
113
- /** Set a list of capability env vars to empty string (disabled capability). */
114
- const clearCapVars = (prefix: string, fields: string[]): void => {
115
- for (const f of fields) caps[`${prefix}_${f}`] = "";
58
+ stt?: {
59
+ enabled?: boolean;
60
+ baseURL?: string;
61
+ model?: string;
62
+ language?: string;
116
63
  };
64
+ };
117
65
 
118
- // ── LLM ──
119
- const { provider: llmP, model: llmM } = parseCapabilityString(spec.capabilities.llm);
120
- caps.OP_CAP_LLM_PROVIDER = llmP;
121
- caps.OP_CAP_LLM_MODEL = llmM;
122
- caps.OP_CAP_LLM_BASE_URL = resolveUrl(llmP);
123
- caps.OP_CAP_LLM_API_KEY = resolveKey(llmP);
124
-
125
- // ── SLM ──
126
- if (spec.capabilities.slm) {
127
- const { provider: slmP, model: slmM } = parseCapabilityString(spec.capabilities.slm);
128
- caps.OP_CAP_SLM_PROVIDER = slmP;
129
- caps.OP_CAP_SLM_MODEL = slmM;
130
- caps.OP_CAP_SLM_BASE_URL = resolveUrl(slmP);
131
- caps.OP_CAP_SLM_API_KEY = resolveKey(slmP);
132
- } else {
133
- clearCapVars("OP_CAP_SLM", ["PROVIDER", "MODEL", "BASE_URL", "API_KEY"]);
134
- }
135
-
136
- // ── Embeddings ──
137
- const emb = spec.capabilities.embeddings;
138
- caps.OP_CAP_EMBEDDINGS_PROVIDER = emb.provider;
139
- caps.OP_CAP_EMBEDDINGS_MODEL = emb.model;
140
- caps.OP_CAP_EMBEDDINGS_BASE_URL = resolveUrl(emb.provider);
141
- caps.OP_CAP_EMBEDDINGS_API_KEY = resolveKey(emb.provider);
142
- caps.OP_CAP_EMBEDDINGS_DIMS = String(emb.dims);
143
-
144
- // ── TTS ──
145
- const tts = spec.capabilities.tts;
146
- if (tts?.enabled) {
147
- const p = tts.provider || llmP;
148
- caps.OP_CAP_TTS_PROVIDER = p;
149
- caps.OP_CAP_TTS_MODEL = tts.model || "";
150
- caps.OP_CAP_TTS_BASE_URL = resolveUrl(p);
151
- caps.OP_CAP_TTS_API_KEY = resolveKey(p);
152
- caps.OP_CAP_TTS_VOICE = tts.voice || "";
153
- caps.OP_CAP_TTS_FORMAT = tts.format || "";
154
- } else {
155
- clearCapVars("OP_CAP_TTS", ["PROVIDER", "MODEL", "BASE_URL", "API_KEY", "VOICE", "FORMAT"]);
156
- }
66
+ /**
67
+ * Write TTS/STT env vars to stack.env for the voice channel container.
68
+ * Only vars with non-empty values are written; missing values are left unchanged.
69
+ */
70
+ export function writeVoiceVars(config: VoiceVarsConfig, stackDir: string): void {
71
+ const stackEnvPath = `${stackDir}/stack.env`;
72
+ const base = existsSync(stackEnvPath) ? readFileSync(stackEnvPath, "utf-8") : "";
73
+ const vars: Record<string, string> = {};
157
74
 
158
- // ── STT ──
159
- const stt = spec.capabilities.stt;
160
- if (stt?.enabled) {
161
- const p = stt.provider || llmP;
162
- caps.OP_CAP_STT_PROVIDER = p;
163
- caps.OP_CAP_STT_MODEL = stt.model || "";
164
- caps.OP_CAP_STT_BASE_URL = resolveUrl(p);
165
- caps.OP_CAP_STT_API_KEY = resolveKey(p);
166
- caps.OP_CAP_STT_LANGUAGE = stt.language || "";
167
- } else {
168
- clearCapVars("OP_CAP_STT", ["PROVIDER", "MODEL", "BASE_URL", "API_KEY", "LANGUAGE"]);
75
+ const { tts, stt } = config;
76
+ if (tts?.enabled !== false) {
77
+ if (tts?.baseURL) vars["TTS_BASE_URL"] = tts.baseURL;
78
+ if (tts?.model) vars["TTS_MODEL"] = tts.model;
79
+ if (tts?.voice) vars["TTS_VOICE"] = tts.voice;
169
80
  }
170
-
171
- // ── Reranking ──
172
- const rr = spec.capabilities.reranking;
173
- if (rr?.enabled) {
174
- const p = rr.provider || llmP;
175
- caps.OP_CAP_RERANKING_PROVIDER = p;
176
- caps.OP_CAP_RERANKING_MODEL = rr.model || "";
177
- caps.OP_CAP_RERANKING_BASE_URL = resolveUrl(p);
178
- caps.OP_CAP_RERANKING_API_KEY = resolveKey(p);
179
- caps.OP_CAP_RERANKING_TOP_K = rr.topK ? String(rr.topK) : "";
180
- caps.OP_CAP_RERANKING_TOP_N = rr.topN ? String(rr.topN) : "";
181
- } else {
182
- clearCapVars("OP_CAP_RERANKING", ["PROVIDER", "MODEL", "BASE_URL", "API_KEY", "TOP_K", "TOP_N"]);
81
+ if (stt?.enabled !== false) {
82
+ if (stt?.baseURL) vars["STT_BASE_URL"] = stt.baseURL;
83
+ if (stt?.model) vars["STT_MODEL"] = stt.model;
84
+ if (stt?.language) vars["STT_LANGUAGE"] = stt.language;
183
85
  }
184
86
 
185
- // ── Memory ──
186
- caps.MEMORY_USER_ID = spec.capabilities.memory.userId || "default_user";
87
+ if (Object.keys(vars).length === 0) return;
187
88
 
188
- // Merge into stack.env
189
- const base = existsSync(stackEnvPath) ? readFileSync(stackEnvPath, "utf-8") : "";
190
- let content = mergeEnvContent(base, caps, {
191
- sectionHeader: "# ── Resolved Capabilities (from stack.yml) ─────────────────────────",
89
+ let content = mergeEnvContent(base, vars, {
90
+ sectionHeader: "# ── Voice Channel (TTS/STT) ──────────────────────────────────────────",
192
91
  });
193
92
  if (!content.endsWith("\n")) content += "\n";
194
93
  writeFileSync(stackEnvPath, content, { mode: 0o600 });
@@ -5,7 +5,7 @@
5
5
  * so users can quickly identify and fix configuration issues.
6
6
  */
7
7
 
8
- import type { StackSpec, StackSpecCapabilities } from "./stack-spec.js";
8
+ import type { StackSpec } from "./stack-spec.js";
9
9
 
10
10
  export type ValidationError = {
11
11
  code: string;
@@ -41,18 +41,6 @@ export function validateStackSpec(input: unknown): ValidationError[] {
41
41
  return errors;
42
42
  }
43
43
 
44
- // Capabilities
45
- if (typeof spec.capabilities !== "object" || spec.capabilities === null) {
46
- errors.push({
47
- code: "OP-CFG-001",
48
- message: "No capabilities defined",
49
- path: "capabilities",
50
- hint: "Add capabilities.llm and capabilities.embeddings sections",
51
- });
52
- } else {
53
- validateCapabilities(spec.capabilities as StackSpecCapabilities, errors);
54
- }
55
-
56
44
  // Image
57
45
  if (spec.image && typeof spec.image === "object") {
58
46
  const img = spec.image as Record<string, unknown>;
@@ -69,91 +57,6 @@ export function validateStackSpec(input: unknown): ValidationError[] {
69
57
  }
70
58
  }
71
59
 
60
+ void (spec as unknown as StackSpec); // reserved for future validations
72
61
  return errors;
73
62
  }
74
-
75
- function validateCapabilities(
76
- capabilities: StackSpecCapabilities,
77
- errors: ValidationError[],
78
- ): void {
79
- // LLM (required, "provider/model" string)
80
- if (!capabilities.llm || typeof capabilities.llm !== "string") {
81
- errors.push({
82
- code: "OP-CFG-008",
83
- message: "capabilities.llm is required (format: provider/model)",
84
- path: "capabilities.llm",
85
- hint: 'Example: "anthropic/claude-sonnet-4-5" or "ollama/qwen2.5-coder:3b"',
86
- });
87
- } else if (!capabilities.llm.includes("/")) {
88
- errors.push({
89
- code: "OP-CFG-008",
90
- message: `capabilities.llm "${capabilities.llm}" must be in provider/model format`,
91
- path: "capabilities.llm",
92
- hint: 'Example: "anthropic/claude-sonnet-4-5"',
93
- });
94
- }
95
-
96
- // SLM (optional, same format)
97
- if (capabilities.slm !== undefined) {
98
- if (typeof capabilities.slm !== "string") {
99
- errors.push({
100
- code: "OP-CFG-008",
101
- message: "capabilities.slm must be a string (format: provider/model)",
102
- path: "capabilities.slm",
103
- });
104
- } else if (!capabilities.slm.includes("/")) {
105
- errors.push({
106
- code: "OP-CFG-008",
107
- message: `capabilities.slm "${capabilities.slm}" must be in provider/model format`,
108
- path: "capabilities.slm",
109
- });
110
- }
111
- }
112
-
113
- // Embeddings (required object)
114
- if (!capabilities.embeddings || typeof capabilities.embeddings !== "object") {
115
- errors.push({
116
- code: "OP-CFG-002",
117
- message: "capabilities.embeddings is required",
118
- path: "capabilities.embeddings",
119
- hint: "Add provider, model, and dims fields",
120
- });
121
- } else {
122
- const emb = capabilities.embeddings;
123
- if (!emb.provider || typeof emb.provider !== "string") {
124
- errors.push({
125
- code: "OP-CFG-004",
126
- message: "capabilities.embeddings.provider is required",
127
- path: "capabilities.embeddings.provider",
128
- });
129
- }
130
- if (!emb.model || typeof emb.model !== "string") {
131
- errors.push({
132
- code: "OP-CFG-008",
133
- message: "capabilities.embeddings.model is required",
134
- path: "capabilities.embeddings.model",
135
- });
136
- }
137
- if (
138
- emb.dims !== undefined &&
139
- (typeof emb.dims !== "number" || emb.dims < 1)
140
- ) {
141
- errors.push({
142
- code: "OP-CFG-009",
143
- message: "capabilities.embeddings.dims must be a positive integer",
144
- path: "capabilities.embeddings.dims",
145
- hint: "Common values: nomic-embed-text: 768, text-embedding-3-small: 1536",
146
- });
147
- }
148
- }
149
-
150
- // Memory (required object)
151
- if (!capabilities.memory || typeof capabilities.memory !== "object") {
152
- errors.push({
153
- code: "OP-CFG-002",
154
- message: "capabilities.memory is required",
155
- path: "capabilities.memory",
156
- hint: "Add at minimum a userId field",
157
- });
158
- }
159
- }
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * Stack spec parser tests.
3
3
  *
4
- * Verifies that readStackSpec / writeStackSpec produce consistent results
5
- * and that all addon resolution goes through the canonical lib functions.
4
+ * Verifies that readStackSpec / writeStackSpec produce consistent results.
6
5
  */
7
6
  import { describe, it, expect, beforeEach, afterEach } from "bun:test";
8
7
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
@@ -13,9 +12,6 @@ import {
13
12
  writeStackSpec,
14
13
  STACK_SPEC_FILENAME,
15
14
  stackSpecPath,
16
- parseCapabilityString,
17
- formatCapabilityString,
18
- updateCapability,
19
15
  } from "./stack-spec.js";
20
16
  import type { StackSpec } from "./stack-spec.js";
21
17
 
@@ -29,38 +25,35 @@ afterEach(() => {
29
25
  rmSync(configDir, { recursive: true, force: true });
30
26
  });
31
27
 
32
- // ── Helpers ─────────────────────────────────────────────────────────────
33
-
34
- function makeSpec(): StackSpec {
35
- return {
36
- version: 2,
37
- capabilities: {
38
- llm: "openai/gpt-4o",
39
- embeddings: { provider: "openai", model: "text-embedding-3-small", dims: 1536 },
40
- memory: { userId: "test-user" },
41
- },
42
- };
43
- }
28
+ const MINIMAL_SPEC: StackSpec = { version: 2 };
44
29
 
45
30
  // ── readStackSpec / writeStackSpec round-trip ────────────────────────────
46
31
 
47
32
  describe("readStackSpec / writeStackSpec round-trip", () => {
48
- it("round-trips a spec with capabilities only", () => {
49
- const spec = makeSpec();
50
- writeStackSpec(configDir, spec);
33
+ it("round-trips a minimal spec", () => {
34
+ writeStackSpec(configDir, MINIMAL_SPEC);
51
35
  const read = readStackSpec(configDir);
52
36
  expect(read).not.toBeNull();
53
37
  expect(read!.version).toBe(2);
54
- expect(read!.capabilities.llm).toBe("openai/gpt-4o");
55
38
  });
56
39
 
57
40
  it("writes to the canonical filename", () => {
58
- writeStackSpec(configDir, makeSpec());
41
+ writeStackSpec(configDir, MINIMAL_SPEC);
59
42
  const expectedPath = join(configDir, STACK_SPEC_FILENAME);
60
43
  const read = readStackSpec(configDir);
61
44
  expect(read).not.toBeNull();
62
45
  expect(stackSpecPath(configDir)).toBe(expectedPath);
63
46
  });
47
+
48
+ it("ignores legacy capabilities fields on read", () => {
49
+ // On upgraded installs, old stack.yml may have capabilities — should still parse
50
+ writeFileSync(join(configDir, STACK_SPEC_FILENAME),
51
+ "version: 2\ncapabilities:\n llm: openai/gpt-4o\n embeddings:\n provider: openai\n model: text-embedding-3-small\n dims: 1536\n"
52
+ );
53
+ const read = readStackSpec(configDir);
54
+ expect(read).not.toBeNull();
55
+ expect(read!.version).toBe(2);
56
+ });
64
57
  });
65
58
 
66
59
  // ── readStackSpec edge cases ────────────────────────────────────────────
@@ -80,71 +73,22 @@ describe("readStackSpec edge cases", () => {
80
73
  expect(readStackSpec(configDir)).toBeNull();
81
74
  });
82
75
 
83
- it("returns null when capabilities is missing", () => {
76
+ it("returns valid spec for version 2 with no other fields", () => {
84
77
  writeFileSync(join(configDir, STACK_SPEC_FILENAME), "version: 2\n");
85
- expect(readStackSpec(configDir)).toBeNull();
86
- });
87
- });
88
-
89
- // ── Capability helpers ──────────────────────────────────────────────────
90
-
91
- describe("parseCapabilityString", () => {
92
- it("splits provider/model", () => {
93
- expect(parseCapabilityString("openai/gpt-4o")).toEqual({ provider: "openai", model: "gpt-4o" });
94
- });
95
-
96
- it("handles model with slashes", () => {
97
- expect(parseCapabilityString("ollama/qwen/2.5-coder:3b")).toEqual({ provider: "ollama", model: "qwen/2.5-coder:3b" });
98
- });
99
-
100
- it("handles missing slash", () => {
101
- expect(parseCapabilityString("openai")).toEqual({ provider: "openai", model: "" });
102
- });
103
- });
104
-
105
- describe("formatCapabilityString", () => {
106
- it("joins provider and model", () => {
107
- expect(formatCapabilityString("openai", "gpt-4o")).toBe("openai/gpt-4o");
108
- });
109
- });
110
-
111
- // ── updateCapability ────────────────────────────────────────────────────
112
-
113
- describe("updateCapability", () => {
114
- it("updates a single capability key", () => {
115
- writeStackSpec(configDir, makeSpec());
116
- updateCapability(configDir, "llm", "anthropic/claude-sonnet-4");
117
- const read = readStackSpec(configDir);
118
- expect(read).not.toBeNull();
119
- expect(read!.capabilities.llm).toBe("anthropic/claude-sonnet-4");
120
- });
121
-
122
- it("throws when spec is missing", () => {
123
- expect(() => updateCapability(configDir, "llm", "test")).toThrow("stack.yml not found or invalid");
78
+ const spec = readStackSpec(configDir);
79
+ expect(spec).not.toBeNull();
80
+ expect(spec!.version).toBe(2);
124
81
  });
125
82
  });
126
83
 
127
84
  // ── stackSpecPath / STACK_SPEC_FILENAME ──────────────────────────────────
128
85
 
129
86
  describe("stackSpecPath", () => {
130
- it("returns configDir/stack.yml", () => {
131
- expect(stackSpecPath("/foo/config")).toBe("/foo/config/stack.yml");
87
+ it("returns stackDir/stack.yml", () => {
88
+ expect(stackSpecPath("/foo/config/stack")).toBe("/foo/config/stack/stack.yml");
132
89
  });
133
90
 
134
91
  it("uses STACK_SPEC_FILENAME constant", () => {
135
92
  expect(STACK_SPEC_FILENAME).toBe("stack.yml");
136
- expect(stackSpecPath(configDir)).toBe(`${configDir}/${STACK_SPEC_FILENAME}`);
137
- });
138
- });
139
-
140
- // ── writeStackSpec creates directory ─────────────────────────────────────
141
-
142
- describe("writeStackSpec", () => {
143
- it("creates configDir if it does not exist", () => {
144
- const nestedDir = join(configDir, "nested", "deep");
145
- writeStackSpec(nestedDir, makeSpec());
146
- const read = readStackSpec(nestedDir);
147
- expect(read).not.toBeNull();
148
- expect(read!.version).toBe(2);
149
93
  });
150
94
  });