@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.
Files changed (56) hide show
  1. package/README.md +31 -71
  2. package/package.json +1 -1
  3. package/src/control-plane/audit.ts +4 -4
  4. package/src/control-plane/backup.ts +31 -0
  5. package/src/control-plane/channels.ts +88 -156
  6. package/src/control-plane/cleanup-guardrails.test.ts +289 -0
  7. package/src/control-plane/compose-args.test.ts +170 -0
  8. package/src/control-plane/compose-args.ts +57 -0
  9. package/src/control-plane/config-persistence.ts +270 -0
  10. package/src/control-plane/core-assets.ts +58 -234
  11. package/src/control-plane/crypto.ts +14 -0
  12. package/src/control-plane/docker.ts +94 -204
  13. package/src/control-plane/env-schema-validation.test.ts +118 -0
  14. package/src/control-plane/extends-support.test.ts +105 -0
  15. package/src/control-plane/home.ts +133 -0
  16. package/src/control-plane/install-edge-cases.test.ts +314 -717
  17. package/src/control-plane/lifecycle.ts +215 -233
  18. package/src/control-plane/lock.test.ts +194 -0
  19. package/src/control-plane/lock.ts +176 -0
  20. package/src/control-plane/memory-config.ts +34 -160
  21. package/src/control-plane/opencode-client.test.ts +154 -0
  22. package/src/control-plane/opencode-client.ts +113 -0
  23. package/src/control-plane/provider-config.ts +34 -0
  24. package/src/control-plane/redact-schema.ts +50 -0
  25. package/src/control-plane/registry-components.test.ts +313 -0
  26. package/src/control-plane/registry.test.ts +414 -0
  27. package/src/control-plane/registry.ts +418 -0
  28. package/src/control-plane/rollback.ts +128 -0
  29. package/src/control-plane/scheduler.ts +18 -190
  30. package/src/control-plane/secret-backend.test.ts +359 -0
  31. package/src/control-plane/secret-backend.ts +322 -0
  32. package/src/control-plane/secret-mappings.ts +185 -0
  33. package/src/control-plane/secrets.ts +186 -112
  34. package/src/control-plane/setup-config.schema.json +306 -0
  35. package/src/control-plane/setup-status.ts +15 -8
  36. package/src/control-plane/setup-validation.ts +90 -0
  37. package/src/control-plane/setup.test.ts +336 -929
  38. package/src/control-plane/setup.ts +158 -886
  39. package/src/control-plane/spec-to-env.test.ts +100 -0
  40. package/src/control-plane/spec-to-env.ts +195 -0
  41. package/src/control-plane/spec-validator.ts +159 -0
  42. package/src/control-plane/stack-spec.test.ts +150 -0
  43. package/src/control-plane/stack-spec.ts +101 -22
  44. package/src/control-plane/types.ts +6 -99
  45. package/src/control-plane/validate.ts +107 -0
  46. package/src/index.ts +101 -159
  47. package/src/provider-constants.ts +2 -31
  48. package/src/control-plane/connection-mapping.ts +0 -191
  49. package/src/control-plane/connection-migration-flags.ts +0 -40
  50. package/src/control-plane/connection-profiles.ts +0 -317
  51. package/src/control-plane/core-asset-provider.ts +0 -21
  52. package/src/control-plane/fs-asset-provider.ts +0 -65
  53. package/src/control-plane/fs-registry-provider.ts +0 -46
  54. package/src/control-plane/paths.ts +0 -77
  55. package/src/control-plane/registry-provider.ts +0 -19
  56. 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
+ }