@openpalm/lib 0.9.9 → 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 +158 -886
- 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,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cleanup guardrail tests — prevent reintroduction of cleaned-up patterns.
|
|
3
|
+
*
|
|
4
|
+
* These tests verify the 0.10.0 cleanup contract:
|
|
5
|
+
* 1. No runtime config/components references
|
|
6
|
+
* 2. No hardcoded compose project names in orchestration code
|
|
7
|
+
* 3. Lifecycle preflight runs compose config before mutation
|
|
8
|
+
* 4. Service discovery is compose-derived, not filename-derived
|
|
9
|
+
*/
|
|
10
|
+
import { describe, test, expect } from "bun:test";
|
|
11
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { resolveComposeProjectName } from "./docker.js";
|
|
14
|
+
|
|
15
|
+
// ── Helpers ───────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
const LIB_CONTROL_PLANE_DIR = join(import.meta.dir);
|
|
18
|
+
|
|
19
|
+
/** Read all .ts source files (not tests, not .d.ts) in control-plane/ */
|
|
20
|
+
function readSourceFiles(): { path: string; content: string }[] {
|
|
21
|
+
const files = readdirSync(LIB_CONTROL_PLANE_DIR);
|
|
22
|
+
return files
|
|
23
|
+
.filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts") && !f.endsWith(".vitest.ts") && !f.endsWith(".d.ts"))
|
|
24
|
+
.map((f) => ({
|
|
25
|
+
path: join(LIB_CONTROL_PLANE_DIR, f),
|
|
26
|
+
content: readFileSync(join(LIB_CONTROL_PLANE_DIR, f), "utf-8"),
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Guardrail 1: No config/components in active runtime code ──────────
|
|
31
|
+
|
|
32
|
+
describe("guardrail: no config/components runtime references", () => {
|
|
33
|
+
test("source files do not reference config/components in active code", () => {
|
|
34
|
+
const files = readSourceFiles();
|
|
35
|
+
const violations: string[] = [];
|
|
36
|
+
|
|
37
|
+
for (const { path, content } of files) {
|
|
38
|
+
const filename = path.split("/").pop()!;
|
|
39
|
+
// Skip this test file itself
|
|
40
|
+
if (filename === "cleanup-guardrails.test.ts") continue;
|
|
41
|
+
|
|
42
|
+
const lines = content.split("\n");
|
|
43
|
+
for (let i = 0; i < lines.length; i++) {
|
|
44
|
+
const line = lines[i];
|
|
45
|
+
// Skip comments and @deprecated stubs
|
|
46
|
+
if (line.trim().startsWith("//") || line.trim().startsWith("*") || line.trim().startsWith("/**")) continue;
|
|
47
|
+
// Skip string literals in deprecation messages
|
|
48
|
+
if (line.includes("@deprecated")) continue;
|
|
49
|
+
|
|
50
|
+
if (line.includes("config/components") || line.includes("config\\components")) {
|
|
51
|
+
violations.push(`${filename}:${i + 1}: ${line.trim()}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
expect(violations).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ── Guardrail 2: No hardcoded project names in compose orchestration ──
|
|
61
|
+
|
|
62
|
+
describe("guardrail: no hardcoded compose project names", () => {
|
|
63
|
+
test("docker.ts does not contain hardcoded --project-name openpalm", () => {
|
|
64
|
+
const dockerTs = readFileSync(join(LIB_CONTROL_PLANE_DIR, "docker.ts"), "utf-8");
|
|
65
|
+
// The only allowed reference is the DEFAULT inside resolveComposeProjectName()
|
|
66
|
+
const matches = dockerTs.match(/--project-name.*openpalm/g) ?? [];
|
|
67
|
+
expect(matches.length).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("resolveComposeProjectName respects OP_PROJECT_NAME", () => {
|
|
71
|
+
const original = process.env.OP_PROJECT_NAME;
|
|
72
|
+
try {
|
|
73
|
+
process.env.OP_PROJECT_NAME = "custom-project";
|
|
74
|
+
expect(resolveComposeProjectName()).toBe("custom-project");
|
|
75
|
+
} finally {
|
|
76
|
+
if (original !== undefined) {
|
|
77
|
+
process.env.OP_PROJECT_NAME = original;
|
|
78
|
+
} else {
|
|
79
|
+
delete process.env.OP_PROJECT_NAME;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("resolveComposeProjectName defaults to openpalm", () => {
|
|
85
|
+
const original = process.env.OP_PROJECT_NAME;
|
|
86
|
+
try {
|
|
87
|
+
delete process.env.OP_PROJECT_NAME;
|
|
88
|
+
expect(resolveComposeProjectName()).toBe("openpalm");
|
|
89
|
+
} finally {
|
|
90
|
+
if (original !== undefined) {
|
|
91
|
+
process.env.OP_PROJECT_NAME = original;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ── Guardrail 3: Compose preflight is called before mutation ──────────
|
|
98
|
+
|
|
99
|
+
describe("guardrail: compose preflight before mutation", () => {
|
|
100
|
+
test("composePreflight is exported from docker.ts", async () => {
|
|
101
|
+
const mod = await import("./docker.js");
|
|
102
|
+
expect(typeof mod.composePreflight).toBe("function");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("composeConfigServices is exported from docker.ts", async () => {
|
|
106
|
+
const mod = await import("./docker.js");
|
|
107
|
+
expect(typeof mod.composeConfigServices).toBe("function");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("lifecycle.ts reconcileCore calls composePreflight before snapshotCurrentState", () => {
|
|
111
|
+
const lifecycleTs = readFileSync(join(LIB_CONTROL_PLANE_DIR, "lifecycle.ts"), "utf-8");
|
|
112
|
+
// Verify composePreflight is imported
|
|
113
|
+
expect(lifecycleTs).toContain("composePreflight");
|
|
114
|
+
// Verify preflight appears BEFORE snapshot in the source
|
|
115
|
+
const preflightIdx = lifecycleTs.indexOf("composePreflight({ files, envFiles })");
|
|
116
|
+
const snapshotIdx = lifecycleTs.indexOf("snapshotCurrentState(state)");
|
|
117
|
+
expect(preflightIdx).toBeGreaterThan(0);
|
|
118
|
+
expect(snapshotIdx).toBeGreaterThan(0);
|
|
119
|
+
expect(preflightIdx).toBeLessThan(snapshotIdx);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("preflight error includes resolved command string", () => {
|
|
123
|
+
const lifecycleTs = readFileSync(join(LIB_CONTROL_PLANE_DIR, "lifecycle.ts"), "utf-8");
|
|
124
|
+
expect(lifecycleTs).toContain("Resolved command:");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ── Guardrail 4: Service discovery is not filename-derived ────────────
|
|
129
|
+
|
|
130
|
+
describe("guardrail: compose-derived service discovery", () => {
|
|
131
|
+
test("lifecycle.ts buildManagedServices uses composeConfigServices", () => {
|
|
132
|
+
const lifecycleTs = readFileSync(join(LIB_CONTROL_PLANE_DIR, "lifecycle.ts"), "utf-8");
|
|
133
|
+
// Must use compose-derived discovery
|
|
134
|
+
expect(lifecycleTs).toContain("composeConfigServices");
|
|
135
|
+
// Should not contain filename-scanning patterns
|
|
136
|
+
expect(lifecycleTs).not.toContain('.replace(/\\.yml$/');
|
|
137
|
+
// Should not reference discoverComponentOverlays
|
|
138
|
+
expect(lifecycleTs).not.toContain("discoverComponentOverlays");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("channels.ts does not scan config/components for channel discovery", () => {
|
|
142
|
+
const channelsTs = readFileSync(join(LIB_CONTROL_PLANE_DIR, "channels.ts"), "utf-8");
|
|
143
|
+
// Active code should not reference config/components path
|
|
144
|
+
const activeLines = channelsTs.split("\n").filter(
|
|
145
|
+
(l) => !l.trim().startsWith("//") && !l.trim().startsWith("*") && !l.includes("@deprecated")
|
|
146
|
+
);
|
|
147
|
+
const hasConfigComponents = activeLines.some((l) => l.includes("config/components"));
|
|
148
|
+
expect(hasConfigComponents).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ── Guardrail 5: Env schema paths are correct ──────────────────────────
|
|
153
|
+
|
|
154
|
+
describe("guardrail: env schema validation paths", () => {
|
|
155
|
+
test("validate.ts uses correct nested vault schema paths", () => {
|
|
156
|
+
const validateTs = readFileSync(join(LIB_CONTROL_PLANE_DIR, "validate.ts"), "utf-8");
|
|
157
|
+
// Must use nested paths
|
|
158
|
+
expect(validateTs).toContain("vaultDir}/user/user.env.schema");
|
|
159
|
+
expect(validateTs).toContain("vaultDir}/stack/stack.env.schema");
|
|
160
|
+
// Must NOT use flat paths
|
|
161
|
+
expect(validateTs).not.toContain("vaultDir}/user.env.schema");
|
|
162
|
+
expect(validateTs).not.toContain("vaultDir}/system.env.schema");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── Guardrail 6: No deprecated split-root env vars in non-test source ──
|
|
167
|
+
|
|
168
|
+
describe("guardrail: no deprecated OP_CONFIG_HOME/OP_STATE_HOME/OP_DATA_HOME", () => {
|
|
169
|
+
test("source files do not reference split-root env vars", () => {
|
|
170
|
+
const files = readSourceFiles();
|
|
171
|
+
const deprecated = ["OP_CONFIG_HOME", "OP_STATE_HOME", "OP_DATA_HOME"];
|
|
172
|
+
const violations: string[] = [];
|
|
173
|
+
|
|
174
|
+
for (const { path, content } of files) {
|
|
175
|
+
const filename = path.split("/").pop()!;
|
|
176
|
+
if (filename === "cleanup-guardrails.test.ts") continue;
|
|
177
|
+
// home.ts may contain backward-compat resolution — skip it
|
|
178
|
+
if (filename === "home.ts") continue;
|
|
179
|
+
|
|
180
|
+
const lines = content.split("\n");
|
|
181
|
+
for (let i = 0; i < lines.length; i++) {
|
|
182
|
+
const line = lines[i];
|
|
183
|
+
if (line.trim().startsWith("//") || line.trim().startsWith("*")) continue;
|
|
184
|
+
for (const d of deprecated) {
|
|
185
|
+
if (line.includes(d)) {
|
|
186
|
+
violations.push(`${filename}:${i + 1}: ${d} — ${line.trim()}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
expect(violations).toEqual([]);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ── Guardrail 7: No secrets.env references in active source ──────────
|
|
197
|
+
|
|
198
|
+
describe("guardrail: no secrets.env references", () => {
|
|
199
|
+
test("source files do not reference secrets.env in active code", () => {
|
|
200
|
+
const files = readSourceFiles();
|
|
201
|
+
const violations: string[] = [];
|
|
202
|
+
|
|
203
|
+
for (const { path, content } of files) {
|
|
204
|
+
const filename = path.split("/").pop()!;
|
|
205
|
+
if (filename === "cleanup-guardrails.test.ts") continue;
|
|
206
|
+
|
|
207
|
+
const lines = content.split("\n");
|
|
208
|
+
for (let i = 0; i < lines.length; i++) {
|
|
209
|
+
const line = lines[i];
|
|
210
|
+
if (line.trim().startsWith("//") || line.trim().startsWith("*")) continue;
|
|
211
|
+
if (line.includes("@deprecated")) continue;
|
|
212
|
+
// Allow string mentions in error messages that reference user.env or stack.env
|
|
213
|
+
if (line.includes("secrets.env")) {
|
|
214
|
+
violations.push(`${filename}:${i + 1}: ${line.trim()}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
expect(violations).toEqual([]);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ── Guardrail 8: .openpalm/stack/start.sh does not exist ─────────────
|
|
224
|
+
|
|
225
|
+
describe("guardrail: .openpalm/stack/start.sh is absent", () => {
|
|
226
|
+
test(".openpalm/stack/start.sh does not exist in repo", () => {
|
|
227
|
+
// start.sh was deleted as part of P1-5 (0.10.0 cleanup).
|
|
228
|
+
// All compose orchestration goes through @openpalm/lib backed CLI/admin paths.
|
|
229
|
+
// This test prevents accidental reintroduction.
|
|
230
|
+
const repoRoot = join(import.meta.dir, "../../../../..");
|
|
231
|
+
const legacyScript = join(repoRoot, ".openpalm/stack/start.sh");
|
|
232
|
+
let exists = false;
|
|
233
|
+
try {
|
|
234
|
+
readFileSync(legacyScript);
|
|
235
|
+
exists = true;
|
|
236
|
+
} catch {
|
|
237
|
+
// expected: file does not exist
|
|
238
|
+
}
|
|
239
|
+
expect(exists).toBe(false);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("control-plane source files do not reference .openpalm/stack/start.sh", () => {
|
|
243
|
+
const files = readSourceFiles();
|
|
244
|
+
const violations: string[] = [];
|
|
245
|
+
|
|
246
|
+
for (const { path, content } of files) {
|
|
247
|
+
const filename = path.split("/").pop()!;
|
|
248
|
+
if (filename === "cleanup-guardrails.test.ts") continue;
|
|
249
|
+
|
|
250
|
+
const lines = content.split("\n");
|
|
251
|
+
for (let i = 0; i < lines.length; i++) {
|
|
252
|
+
const line = lines[i];
|
|
253
|
+
if (line.trim().startsWith("//") || line.trim().startsWith("*")) continue;
|
|
254
|
+
if (line.includes(".openpalm/stack/start.sh") || line.includes("openpalm/stack/start.sh")) {
|
|
255
|
+
violations.push(`${filename}:${i + 1}: ${line.trim()}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
expect(violations).toEqual([]);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// ── Guardrail 9: component/instance system removed ──────────────────
|
|
265
|
+
|
|
266
|
+
describe("guardrail: component/instance system removed", () => {
|
|
267
|
+
test("components.ts no longer exists", () => {
|
|
268
|
+
const exists = readdirSync(LIB_CONTROL_PLANE_DIR).includes("components.ts");
|
|
269
|
+
expect(exists).toBe(false);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("instance-lifecycle.ts no longer exists", () => {
|
|
273
|
+
const exists = readdirSync(LIB_CONTROL_PLANE_DIR).includes("instance-lifecycle.ts");
|
|
274
|
+
expect(exists).toBe(false);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("component-secrets.ts no longer exists", () => {
|
|
278
|
+
const exists = readdirSync(LIB_CONTROL_PLANE_DIR).includes("component-secrets.ts");
|
|
279
|
+
expect(exists).toBe(false);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("no source file references data/components or data/catalog", () => {
|
|
283
|
+
const sources = readSourceFiles();
|
|
284
|
+
for (const { path, content } of sources) {
|
|
285
|
+
expect(content).not.toContain("data/components");
|
|
286
|
+
expect(content).not.toContain("data/catalog");
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for canonical compose argument builder.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
5
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import {
|
|
9
|
+
COMPOSE_PROJECT_NAME,
|
|
10
|
+
buildComposeOptions,
|
|
11
|
+
buildComposeCliArgs,
|
|
12
|
+
} from "./compose-args.js";
|
|
13
|
+
import type { ControlPlaneState } from "./types.js";
|
|
14
|
+
|
|
15
|
+
let tempDir: string;
|
|
16
|
+
|
|
17
|
+
function makeState(overrides: Partial<ControlPlaneState> = {}): ControlPlaneState {
|
|
18
|
+
return {
|
|
19
|
+
adminToken: "test",
|
|
20
|
+
assistantToken: "test",
|
|
21
|
+
setupToken: "test",
|
|
22
|
+
homeDir: tempDir,
|
|
23
|
+
configDir: join(tempDir, "config"),
|
|
24
|
+
vaultDir: join(tempDir, "vault"),
|
|
25
|
+
dataDir: join(tempDir, "data"),
|
|
26
|
+
logsDir: join(tempDir, "logs"),
|
|
27
|
+
cacheDir: join(tempDir, "cache"),
|
|
28
|
+
services: {},
|
|
29
|
+
artifacts: { compose: "" },
|
|
30
|
+
artifactMeta: [],
|
|
31
|
+
audit: [],
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function seedCoreCompose(): void {
|
|
37
|
+
const stackDir = join(tempDir, "stack");
|
|
38
|
+
mkdirSync(stackDir, { recursive: true });
|
|
39
|
+
writeFileSync(join(stackDir, "core.compose.yml"), "services: {}");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function seedEnvFiles(files: { stack?: boolean; user?: boolean; guardian?: boolean } = {}): void {
|
|
43
|
+
if (files.stack) {
|
|
44
|
+
mkdirSync(join(tempDir, "vault", "stack"), { recursive: true });
|
|
45
|
+
writeFileSync(join(tempDir, "vault", "stack", "stack.env"), "KEY=val");
|
|
46
|
+
}
|
|
47
|
+
if (files.user) {
|
|
48
|
+
mkdirSync(join(tempDir, "vault", "user"), { recursive: true });
|
|
49
|
+
writeFileSync(join(tempDir, "vault", "user", "user.env"), "SECRET=val");
|
|
50
|
+
}
|
|
51
|
+
if (files.guardian) {
|
|
52
|
+
mkdirSync(join(tempDir, "vault", "stack"), { recursive: true });
|
|
53
|
+
writeFileSync(join(tempDir, "vault", "stack", "guardian.env"), "CHANNEL_CHAT_SECRET=abc");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function seedAddon(name: string): void {
|
|
58
|
+
const addonDir = join(tempDir, "stack", "addons", name);
|
|
59
|
+
mkdirSync(addonDir, { recursive: true });
|
|
60
|
+
writeFileSync(join(addonDir, "compose.yml"), "services: {}");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
tempDir = mkdtempSync(join(tmpdir(), "compose-args-test-"));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ── COMPOSE_PROJECT_NAME ─────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
describe("COMPOSE_PROJECT_NAME", () => {
|
|
74
|
+
it("is 'openpalm'", () => {
|
|
75
|
+
expect(COMPOSE_PROJECT_NAME).toBe("openpalm");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ── buildComposeOptions ──────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
describe("buildComposeOptions", () => {
|
|
82
|
+
it("returns core compose file when present", () => {
|
|
83
|
+
seedCoreCompose();
|
|
84
|
+
const state = makeState();
|
|
85
|
+
const opts = buildComposeOptions(state);
|
|
86
|
+
expect(opts.files).toHaveLength(1);
|
|
87
|
+
expect(opts.files[0]).toContain("core.compose.yml");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("includes addon overlays when compose files are present in stack/addons", () => {
|
|
91
|
+
seedCoreCompose();
|
|
92
|
+
seedAddon("chat");
|
|
93
|
+
|
|
94
|
+
const state = makeState();
|
|
95
|
+
const opts = buildComposeOptions(state);
|
|
96
|
+
expect(opts.files).toHaveLength(2);
|
|
97
|
+
expect(opts.files[1]).toContain("chat");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("returns env files in correct order", () => {
|
|
101
|
+
seedEnvFiles({ stack: true, user: true, guardian: true });
|
|
102
|
+
const state = makeState();
|
|
103
|
+
const opts = buildComposeOptions(state);
|
|
104
|
+
expect(opts.envFiles).toHaveLength(3);
|
|
105
|
+
expect(opts.envFiles[0]).toContain("stack.env");
|
|
106
|
+
expect(opts.envFiles[1]).toContain("user.env");
|
|
107
|
+
expect(opts.envFiles[2]).toContain("guardian.env");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("excludes missing env files", () => {
|
|
111
|
+
// No env files seeded
|
|
112
|
+
const state = makeState();
|
|
113
|
+
const opts = buildComposeOptions(state);
|
|
114
|
+
expect(opts.envFiles).toHaveLength(0);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ── buildComposeCliArgs ──────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
describe("buildComposeCliArgs", () => {
|
|
121
|
+
it("starts with --project-name openpalm", () => {
|
|
122
|
+
seedCoreCompose();
|
|
123
|
+
const state = makeState();
|
|
124
|
+
const args = buildComposeCliArgs(state);
|
|
125
|
+
expect(args[0]).toBe("--project-name");
|
|
126
|
+
expect(args[1]).toBe("openpalm");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("includes -f flags for compose files", () => {
|
|
130
|
+
seedCoreCompose();
|
|
131
|
+
const state = makeState();
|
|
132
|
+
const args = buildComposeCliArgs(state);
|
|
133
|
+
const fIdx = args.indexOf("-f");
|
|
134
|
+
expect(fIdx).toBeGreaterThan(-1);
|
|
135
|
+
expect(args[fIdx + 1]).toContain("core.compose.yml");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("includes --env-file flags for env files that exist", () => {
|
|
139
|
+
seedCoreCompose();
|
|
140
|
+
seedEnvFiles({ stack: true, user: true });
|
|
141
|
+
const state = makeState();
|
|
142
|
+
const args = buildComposeCliArgs(state);
|
|
143
|
+
const envFileIndices = args.reduce<number[]>((acc, arg, i) => {
|
|
144
|
+
if (arg === "--env-file") acc.push(i);
|
|
145
|
+
return acc;
|
|
146
|
+
}, []);
|
|
147
|
+
expect(envFileIndices).toHaveLength(2);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("does not include --env-file for missing files", () => {
|
|
151
|
+
seedCoreCompose();
|
|
152
|
+
const state = makeState();
|
|
153
|
+
const args = buildComposeCliArgs(state);
|
|
154
|
+
expect(args).not.toContain("--env-file");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("includes addon overlays in -f flags", () => {
|
|
158
|
+
seedCoreCompose();
|
|
159
|
+
seedAddon("chat");
|
|
160
|
+
|
|
161
|
+
const state = makeState();
|
|
162
|
+
const args = buildComposeCliArgs(state);
|
|
163
|
+
const fFlags = args.reduce<string[]>((acc, arg, i) => {
|
|
164
|
+
if (arg === "-f" && args[i + 1]) acc.push(args[i + 1]);
|
|
165
|
+
return acc;
|
|
166
|
+
}, []);
|
|
167
|
+
expect(fFlags).toHaveLength(2);
|
|
168
|
+
expect(fFlags[1]).toContain("chat");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical compose argument builder.
|
|
3
|
+
*
|
|
4
|
+
* Consolidates compose file/env-file resolution and CLI argument
|
|
5
|
+
* construction into a single shared module. Both CLI and admin
|
|
6
|
+
* routes use these functions instead of assembling args inline.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import type { ControlPlaneState } from "./types.js";
|
|
10
|
+
import { buildComposeFileList } from "./lifecycle.js";
|
|
11
|
+
import { buildEnvFiles } from "./config-persistence.js";
|
|
12
|
+
import { resolveComposeProjectName } from "./docker.js";
|
|
13
|
+
|
|
14
|
+
// ── Constants ────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export const COMPOSE_PROJECT_NAME = "openpalm";
|
|
17
|
+
|
|
18
|
+
// ── Types ────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export type ComposeOptions = {
|
|
21
|
+
files: string[];
|
|
22
|
+
envFiles: string[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// ── Builders ─────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build the compose file and env file lists for a given state.
|
|
29
|
+
* Returns the resolved files and env files for use with docker.ts functions.
|
|
30
|
+
*
|
|
31
|
+
* Note: env files are already filtered to only existing paths by
|
|
32
|
+
* `buildEnvFiles()` in config-persistence.ts.
|
|
33
|
+
*/
|
|
34
|
+
export function buildComposeOptions(state: ControlPlaneState): ComposeOptions {
|
|
35
|
+
return {
|
|
36
|
+
files: buildComposeFileList(state),
|
|
37
|
+
envFiles: buildEnvFiles(state),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build the full docker compose CLI argument array for a given state.
|
|
43
|
+
*
|
|
44
|
+
* Returns: ['--project-name', 'openpalm', '-f', file1, '-f', file2, '--env-file', env1, ...]
|
|
45
|
+
*
|
|
46
|
+
* Only includes env files that exist on disk.
|
|
47
|
+
*/
|
|
48
|
+
export function buildComposeCliArgs(state: ControlPlaneState): string[] {
|
|
49
|
+
const { files, envFiles } = buildComposeOptions(state);
|
|
50
|
+
|
|
51
|
+
return [
|
|
52
|
+
"--project-name",
|
|
53
|
+
resolveComposeProjectName(),
|
|
54
|
+
...files.flatMap((f) => ["-f", f]),
|
|
55
|
+
...envFiles.filter((f) => existsSync(f)).flatMap((f) => ["--env-file", f]),
|
|
56
|
+
];
|
|
57
|
+
}
|