@openpalm/lib 0.9.8 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -71
- package/package.json +1 -1
- package/src/control-plane/audit.ts +4 -4
- package/src/control-plane/backup.ts +31 -0
- package/src/control-plane/channels.ts +88 -156
- package/src/control-plane/cleanup-guardrails.test.ts +289 -0
- package/src/control-plane/compose-args.test.ts +170 -0
- package/src/control-plane/compose-args.ts +57 -0
- package/src/control-plane/config-persistence.ts +270 -0
- package/src/control-plane/core-assets.ts +58 -234
- package/src/control-plane/crypto.ts +14 -0
- package/src/control-plane/docker.ts +94 -204
- package/src/control-plane/env-schema-validation.test.ts +118 -0
- package/src/control-plane/extends-support.test.ts +105 -0
- package/src/control-plane/home.ts +133 -0
- package/src/control-plane/install-edge-cases.test.ts +314 -717
- package/src/control-plane/lifecycle.ts +215 -233
- package/src/control-plane/lock.test.ts +194 -0
- package/src/control-plane/lock.ts +176 -0
- package/src/control-plane/memory-config.ts +34 -160
- package/src/control-plane/opencode-client.test.ts +154 -0
- package/src/control-plane/opencode-client.ts +113 -0
- package/src/control-plane/provider-config.ts +34 -0
- package/src/control-plane/redact-schema.ts +50 -0
- package/src/control-plane/registry-components.test.ts +313 -0
- package/src/control-plane/registry.test.ts +414 -0
- package/src/control-plane/registry.ts +418 -0
- package/src/control-plane/rollback.ts +128 -0
- package/src/control-plane/scheduler.ts +18 -190
- package/src/control-plane/secret-backend.test.ts +359 -0
- package/src/control-plane/secret-backend.ts +322 -0
- package/src/control-plane/secret-mappings.ts +185 -0
- package/src/control-plane/secrets.ts +186 -112
- package/src/control-plane/setup-config.schema.json +306 -0
- package/src/control-plane/setup-status.ts +15 -8
- package/src/control-plane/setup-validation.ts +90 -0
- package/src/control-plane/setup.test.ts +336 -929
- package/src/control-plane/setup.ts +159 -849
- package/src/control-plane/spec-to-env.test.ts +100 -0
- package/src/control-plane/spec-to-env.ts +195 -0
- package/src/control-plane/spec-validator.ts +159 -0
- package/src/control-plane/stack-spec.test.ts +150 -0
- package/src/control-plane/stack-spec.ts +101 -22
- package/src/control-plane/types.ts +6 -99
- package/src/control-plane/validate.ts +107 -0
- package/src/index.ts +101 -159
- package/src/provider-constants.ts +2 -31
- package/src/control-plane/connection-mapping.ts +0 -191
- package/src/control-plane/connection-migration-flags.ts +0 -40
- package/src/control-plane/connection-profiles.ts +0 -317
- package/src/control-plane/core-asset-provider.ts +0 -21
- package/src/control-plane/fs-asset-provider.ts +0 -65
- package/src/control-plane/fs-registry-provider.ts +0 -46
- package/src/control-plane/paths.ts +0 -77
- package/src/control-plane/registry-provider.ts +0 -19
- package/src/control-plane/staging.ts +0 -399
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
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
|
+
}
|
|
19
|
+
|
|
20
|
+
let tempDir = "";
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
tempDir = mkdtempSync(join(tmpdir(), "openpalm-spec-env-"));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("deriveSystemEnvFromSpec", () => {
|
|
31
|
+
test("produces OP_HOME", () => {
|
|
32
|
+
const result = deriveSystemEnvFromSpec(makeSpec(), "/home/op");
|
|
33
|
+
expect(result.OP_HOME).toBe("/home/op");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("produces default port values", () => {
|
|
37
|
+
const result = deriveSystemEnvFromSpec(makeSpec(), "/home/op");
|
|
38
|
+
expect(result.OP_ASSISTANT_PORT).toBe("3800");
|
|
39
|
+
expect(result.OP_MEMORY_PORT).toBe("3898");
|
|
40
|
+
expect(result.OP_GUARDIAN_PORT).toBe("3899");
|
|
41
|
+
});
|
|
42
|
+
|
|
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();
|
|
47
|
+
});
|
|
48
|
+
|
|
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();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("does not include removed feature flags", () => {
|
|
56
|
+
const spec = makeSpec();
|
|
57
|
+
const result = deriveSystemEnvFromSpec(spec, "/home/op");
|
|
58
|
+
expect(result.OP_OLLAMA_ENABLED).toBeUndefined();
|
|
59
|
+
expect(result.OP_ADMIN_ENABLED).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
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");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("does not create managed.env files", () => {
|
|
89
|
+
const spec = makeSpec();
|
|
90
|
+
|
|
91
|
+
const vaultDir = join(tempDir, "vault");
|
|
92
|
+
mkdirSync(join(vaultDir, "stack"), { recursive: true });
|
|
93
|
+
writeFileSync(join(vaultDir, "stack", "stack.env"), "# stack env\n");
|
|
94
|
+
|
|
95
|
+
writeCapabilityVars(spec, vaultDir);
|
|
96
|
+
|
|
97
|
+
const managedEnvPath = join(vaultDir, "stack", "services", "memory", "managed.env");
|
|
98
|
+
expect(() => readFileSync(managedEnvPath)).toThrow();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config-to-env derivation pipeline.
|
|
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
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { StackSpec } from "./stack-spec.js";
|
|
10
|
+
import { SPEC_DEFAULTS, parseCapabilityString } from "./stack-spec.js";
|
|
11
|
+
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";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Derive the system.env key-value pairs from the StackSpec.
|
|
19
|
+
* Secrets (tokens, API keys, HMAC) are NOT included — the caller merges them.
|
|
20
|
+
*/
|
|
21
|
+
export function deriveSystemEnvFromSpec(
|
|
22
|
+
spec: StackSpec,
|
|
23
|
+
homeDir: string,
|
|
24
|
+
): Record<string, string> {
|
|
25
|
+
const uid = typeof process.getuid === "function" ? (process.getuid() ?? 1000) : 1000;
|
|
26
|
+
const gid = typeof process.getgid === "function" ? (process.getgid() ?? 1000) : 1000;
|
|
27
|
+
|
|
28
|
+
const ports = SPEC_DEFAULTS.ports;
|
|
29
|
+
const image = SPEC_DEFAULTS.image;
|
|
30
|
+
|
|
31
|
+
const result: Record<string, string> = {};
|
|
32
|
+
|
|
33
|
+
// Paths
|
|
34
|
+
result["OP_HOME"] = homeDir;
|
|
35
|
+
result["OP_UID"] = String(uid);
|
|
36
|
+
result["OP_GID"] = String(gid);
|
|
37
|
+
result["OP_DOCKER_SOCK"] = process.env.OP_DOCKER_SOCK ?? "/var/run/docker.sock";
|
|
38
|
+
|
|
39
|
+
// Image
|
|
40
|
+
result["OP_IMAGE_NAMESPACE"] = image.namespace;
|
|
41
|
+
result["OP_IMAGE_TAG"] = image.tag;
|
|
42
|
+
|
|
43
|
+
// Ports
|
|
44
|
+
result["OP_ASSISTANT_PORT"] = String(ports.assistant);
|
|
45
|
+
result["OP_ADMIN_PORT"] = String(ports.admin);
|
|
46
|
+
result["OP_ADMIN_OPENCODE_PORT"] = String(ports.adminOpencode);
|
|
47
|
+
result["OP_MEMORY_PORT"] = String(ports.memory);
|
|
48
|
+
result["OP_GUARDIAN_PORT"] = String(ports.guardian);
|
|
49
|
+
result["OP_ASSISTANT_SSH_PORT"] = String(ports.assistantSsh);
|
|
50
|
+
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
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
|
+
};
|
|
99
|
+
|
|
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);
|
|
109
|
+
};
|
|
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}`] = "";
|
|
116
|
+
};
|
|
117
|
+
|
|
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
|
+
}
|
|
157
|
+
|
|
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"]);
|
|
169
|
+
}
|
|
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"]);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Memory ──
|
|
186
|
+
caps.MEMORY_USER_ID = spec.capabilities.memory.userId || "default_user";
|
|
187
|
+
|
|
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) ─────────────────────────",
|
|
192
|
+
});
|
|
193
|
+
if (!content.endsWith("\n")) content += "\n";
|
|
194
|
+
writeFileSync(stackEnvPath, content, { mode: 0o600 });
|
|
195
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StackSpec v2 validation.
|
|
3
|
+
*
|
|
4
|
+
* Returns structured, actionable error messages with codes
|
|
5
|
+
* so users can quickly identify and fix configuration issues.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { StackSpec, StackSpecCapabilities } from "./stack-spec.js";
|
|
9
|
+
|
|
10
|
+
export type ValidationError = {
|
|
11
|
+
code: string;
|
|
12
|
+
message: string;
|
|
13
|
+
path?: string;
|
|
14
|
+
hint?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const IMAGE_NS_RE = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
|
|
18
|
+
|
|
19
|
+
export function validateStackSpec(input: unknown): ValidationError[] {
|
|
20
|
+
const errors: ValidationError[] = [];
|
|
21
|
+
|
|
22
|
+
if (typeof input !== "object" || input === null) {
|
|
23
|
+
errors.push({
|
|
24
|
+
code: "OP-CFG-000",
|
|
25
|
+
message: "Configuration must be an object",
|
|
26
|
+
hint: "Check that the YAML file starts with valid configuration keys",
|
|
27
|
+
});
|
|
28
|
+
return errors;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const spec = input as Record<string, unknown>;
|
|
32
|
+
|
|
33
|
+
// Version check
|
|
34
|
+
if (spec.version !== 2) {
|
|
35
|
+
errors.push({
|
|
36
|
+
code: "OP-CFG-020",
|
|
37
|
+
message: `Expected version: 2, got: ${spec.version ?? "(missing)"}`,
|
|
38
|
+
path: "version",
|
|
39
|
+
hint: "Set version: 2 at the top of your config file",
|
|
40
|
+
});
|
|
41
|
+
return errors;
|
|
42
|
+
}
|
|
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
|
+
// Image
|
|
57
|
+
if (spec.image && typeof spec.image === "object") {
|
|
58
|
+
const img = spec.image as Record<string, unknown>;
|
|
59
|
+
if (
|
|
60
|
+
typeof img.namespace === "string" &&
|
|
61
|
+
!IMAGE_NS_RE.test(img.namespace)
|
|
62
|
+
) {
|
|
63
|
+
errors.push({
|
|
64
|
+
code: "OP-CFG-012",
|
|
65
|
+
message: `image.namespace "${img.namespace}" contains invalid characters`,
|
|
66
|
+
path: "image.namespace",
|
|
67
|
+
hint: "Use lowercase letters, numbers, dots, hyphens, or underscores",
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return errors;
|
|
73
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stack spec parser tests.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that readStackSpec / writeStackSpec produce consistent results
|
|
5
|
+
* and that all addon resolution goes through the canonical lib functions.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
8
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import {
|
|
12
|
+
readStackSpec,
|
|
13
|
+
writeStackSpec,
|
|
14
|
+
STACK_SPEC_FILENAME,
|
|
15
|
+
stackSpecPath,
|
|
16
|
+
parseCapabilityString,
|
|
17
|
+
formatCapabilityString,
|
|
18
|
+
updateCapability,
|
|
19
|
+
} from "./stack-spec.js";
|
|
20
|
+
import type { StackSpec } from "./stack-spec.js";
|
|
21
|
+
|
|
22
|
+
let configDir: string;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
configDir = mkdtempSync(join(tmpdir(), "stack-spec-test-"));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
rmSync(configDir, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
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
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── readStackSpec / writeStackSpec round-trip ────────────────────────────
|
|
46
|
+
|
|
47
|
+
describe("readStackSpec / writeStackSpec round-trip", () => {
|
|
48
|
+
it("round-trips a spec with capabilities only", () => {
|
|
49
|
+
const spec = makeSpec();
|
|
50
|
+
writeStackSpec(configDir, spec);
|
|
51
|
+
const read = readStackSpec(configDir);
|
|
52
|
+
expect(read).not.toBeNull();
|
|
53
|
+
expect(read!.version).toBe(2);
|
|
54
|
+
expect(read!.capabilities.llm).toBe("openai/gpt-4o");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("writes to the canonical filename", () => {
|
|
58
|
+
writeStackSpec(configDir, makeSpec());
|
|
59
|
+
const expectedPath = join(configDir, STACK_SPEC_FILENAME);
|
|
60
|
+
const read = readStackSpec(configDir);
|
|
61
|
+
expect(read).not.toBeNull();
|
|
62
|
+
expect(stackSpecPath(configDir)).toBe(expectedPath);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ── readStackSpec edge cases ────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
describe("readStackSpec edge cases", () => {
|
|
69
|
+
it("returns null for missing file", () => {
|
|
70
|
+
expect(readStackSpec(configDir)).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns null for v1 format (connections array)", () => {
|
|
74
|
+
writeFileSync(join(configDir, STACK_SPEC_FILENAME), "version: 1\nconnections: []\n");
|
|
75
|
+
expect(readStackSpec(configDir)).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns null for corrupt YAML", () => {
|
|
79
|
+
writeFileSync(join(configDir, STACK_SPEC_FILENAME), "{{invalid yaml");
|
|
80
|
+
expect(readStackSpec(configDir)).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("returns null when capabilities is missing", () => {
|
|
84
|
+
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");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── stackSpecPath / STACK_SPEC_FILENAME ──────────────────────────────────
|
|
128
|
+
|
|
129
|
+
describe("stackSpecPath", () => {
|
|
130
|
+
it("returns configDir/stack.yml", () => {
|
|
131
|
+
expect(stackSpecPath("/foo/config")).toBe("/foo/config/stack.yml");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("uses STACK_SPEC_FILENAME constant", () => {
|
|
135
|
+
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
|
+
});
|
|
150
|
+
});
|