@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.
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 +159 -849
  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
@@ -1,15 +1,10 @@
1
- /**
2
- * Docker integration — executes real docker compose commands.
3
- *
4
- * This module shells out to `docker compose` for lifecycle operations.
5
- * It reads the generated docker-compose.yml from the state directory
6
- * and uses it for all operations.
7
- *
8
- * Security: All commands use execFile with argument arrays to prevent
9
- * command injection. No user input is ever interpolated into shell strings.
10
- */
1
+ /** Docker integration — executes docker compose commands via execFile (no shell). */
11
2
  import { execFile, spawn } from "node:child_process";
12
- import { existsSync, readFileSync } from "node:fs";
3
+ import { existsSync } from "node:fs";
4
+ import { parseEnvFile } from "./env.js";
5
+ import { createLogger } from "../logger.js";
6
+
7
+ const logger = createLogger("lib:docker");
13
8
 
14
9
  export type DockerResult = {
15
10
  ok: boolean;
@@ -18,29 +13,6 @@ export type DockerResult = {
18
13
  code: number;
19
14
  };
20
15
 
21
- /**
22
- * Parse a dotenv file into a key-value map.
23
- * Handles `KEY=value` lines; ignores comments and blank lines.
24
- */
25
- function parseEnvFile(path: string): Record<string, string> {
26
- const vars: Record<string, string> = {};
27
- try {
28
- const content = readFileSync(path, "utf-8");
29
- for (const line of content.split("\n")) {
30
- let trimmed = line.trim();
31
- if (!trimmed || trimmed.startsWith("#")) continue;
32
- trimmed = trimmed.replace(/^export\s+/, '');
33
- const eqIdx = trimmed.indexOf("=");
34
- if (eqIdx > 0) {
35
- vars[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
36
- }
37
- }
38
- } catch {
39
- // File not readable — skip
40
- }
41
- return vars;
42
- }
43
-
44
16
  /** Execute docker with an argument array — no shell interpolation. */
45
17
  function run(
46
18
  args: string[],
@@ -65,9 +37,9 @@ function run(
65
37
  });
66
38
  }
67
39
 
68
- /** Get the compose file path from state directory */
69
- function composeFile(stateDir: string): string {
70
- return `${stateDir}/artifacts/docker-compose.yml`;
40
+ /** Resolve the Docker Compose project name. Respects OP_PROJECT_NAME env var. */
41
+ export function resolveComposeProjectName(): string {
42
+ return process.env.OP_PROJECT_NAME?.trim() || "openpalm";
71
43
  }
72
44
 
73
45
  /** Check if Docker is available */
@@ -108,142 +80,100 @@ export async function checkDockerCompose(): Promise<DockerResult> {
108
80
  });
109
81
  }
110
82
 
111
- /**
112
- * Build the `-f file1 -f file2 ...` args for docker compose.
113
- * Returns a flat array like ["-f", "path1", "-f", "path2"].
114
- */
115
- function composeFileArgs(stateDir: string, files?: string[]): string[] {
116
- const fileList = files ?? [composeFile(stateDir)];
117
- return fileList.flatMap((f) => ["-f", f]);
118
- }
119
-
120
- /**
121
- * Append `--env-file <path>` args for each existing env file.
122
- * Files that do not exist are silently skipped.
123
- */
124
- function pushEnvFileArgs(args: string[], envFiles?: string[]): void {
125
- for (const ef of envFiles ?? []) {
83
+ /** Build common prefix: compose -f ... --project-name ... --env-file ... */
84
+ function buildComposeArgs(options: { files: string[]; envFiles?: string[] }): string[] {
85
+ const args = ["compose", ...options.files.flatMap((f) => ["-f", f]), "--project-name", resolveComposeProjectName()];
86
+ for (const ef of options.envFiles ?? []) {
126
87
  if (existsSync(ef)) args.push("--env-file", ef);
127
88
  }
89
+ return args;
90
+ }
91
+
92
+ /** Merge all env files into a single overrides object for process env. */
93
+ function collectEnvOverrides(envFiles?: string[]): Record<string, string> {
94
+ const overrides: Record<string, string> = {};
95
+ for (const ef of envFiles ?? []) Object.assign(overrides, parseEnvFile(ef));
96
+ return overrides;
128
97
  }
129
98
 
130
99
  /**
131
- * Build the common prefix args for all docker compose commands:
132
- * docker compose -f <file> ... --project-name openpalm [--env-file ...]
100
+ * Run `docker compose config` to validate compose file merge and variable substitution.
101
+ * Must be called before any lifecycle mutation (install/apply/update).
133
102
  */
134
- function buildComposeArgs(stateDir: string, options: { files?: string[]; envFiles?: string[] } = {}): string[] {
135
- const args = ["compose", ...composeFileArgs(stateDir, options.files), "--project-name", "openpalm"];
136
- pushEnvFileArgs(args, options.envFiles);
137
- return args;
103
+ export async function composePreflight(
104
+ options: { files: string[]; envFiles?: string[] }
105
+ ): Promise<DockerResult> {
106
+ const args = buildComposeArgs(options);
107
+ args.push("config", "--quiet");
108
+ return run(args, undefined, 30_000, collectEnvOverrides(options.envFiles));
109
+ }
110
+
111
+ export async function composeConfigServices(
112
+ options: { files: string[]; envFiles?: string[] }
113
+ ): Promise<{ ok: boolean; services: string[] }> {
114
+ const args = buildComposeArgs(options);
115
+ args.push("config", "--services");
116
+ const result = await run(args, undefined, 30_000, collectEnvOverrides(options.envFiles));
117
+ if (!result.ok) return { ok: false, services: [] };
118
+ const services = result.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
119
+ return { ok: true, services };
138
120
  }
139
121
 
140
122
  /**
141
123
  * Run `docker compose up -d` with the generated compose file(s).
142
- * Pass `files` to merge multiple compose overlays (e.g. core + channel files).
124
+ * Pass `files` to merge multiple compose overlays (e.g. core + addon files).
143
125
  */
144
126
  export async function composeUp(
145
- stateDir: string,
146
127
  options: {
147
- files?: string[];
128
+ files: string[];
148
129
  profiles?: string[];
149
130
  services?: string[];
150
131
  envFiles?: string[];
151
132
  forceRecreate?: boolean;
152
133
  removeOrphans?: boolean;
153
- } = {}
154
- ): Promise<DockerResult> {
155
- const primaryFile = options.files?.[0] ?? composeFile(stateDir);
156
- if (!existsSync(primaryFile)) {
157
- return {
158
- ok: false,
159
- stdout: "",
160
- stderr: "Compose file not found",
161
- code: 1
162
- };
163
134
  }
164
-
165
- const args = buildComposeArgs(stateDir, options);
166
-
167
- if (options.profiles) {
168
- for (const p of options.profiles) {
169
- args.push("--profile", p);
170
- }
135
+ ): Promise<DockerResult> {
136
+ if (!existsSync(options.files[0])) {
137
+ return { ok: false, stdout: "", stderr: "Compose file not found", code: 1 };
171
138
  }
172
-
139
+ const args = buildComposeArgs(options);
140
+ for (const p of options.profiles ?? []) args.push("--profile", p);
173
141
  args.push("up", "-d");
174
-
175
- if (options.forceRecreate) {
176
- args.push("--force-recreate");
177
- }
178
-
179
- if (options.removeOrphans) {
180
- args.push("--remove-orphans");
181
- }
182
-
183
- if (options.services && options.services.length > 0) {
184
- args.push(...options.services);
185
- }
186
-
187
- // Merge env file values into the process environment so Docker Compose
188
- // resolves ${VAR} from fresh env files, not stale admin process env.
189
- // Process env takes precedence over --env-file in Docker Compose,
190
- // so we must override it explicitly.
191
- const envOverrides: Record<string, string> = {};
192
- for (const ef of options.envFiles ?? []) {
193
- Object.assign(envOverrides, parseEnvFile(ef));
194
- }
195
-
196
- return run(args, stateDir, 300_000, envOverrides);
142
+ if (options.forceRecreate) args.push("--force-recreate");
143
+ if (options.removeOrphans) args.push("--remove-orphans");
144
+ if (options.services?.length) args.push(...options.services);
145
+ return run(args, undefined, 300_000, collectEnvOverrides(options.envFiles));
197
146
  }
198
147
 
199
148
  /**
200
149
  * Run `docker compose down` to stop and remove containers.
201
150
  */
202
151
  export async function composeDown(
203
- stateDir: string,
204
152
  options: {
205
- files?: string[];
153
+ files: string[];
206
154
  profiles?: string[];
207
155
  removeVolumes?: boolean;
208
156
  envFiles?: string[];
209
- } = {}
210
- ): Promise<DockerResult> {
211
- const primaryFile = options.files?.[0] ?? composeFile(stateDir);
212
- if (!existsSync(primaryFile)) {
213
- return {
214
- ok: false,
215
- stdout: "",
216
- stderr: "Compose file not found",
217
- code: 1
218
- };
219
157
  }
220
-
221
- const args = buildComposeArgs(stateDir, options);
222
-
223
- if (options.profiles) {
224
- for (const p of options.profiles) {
225
- args.push("--profile", p);
226
- }
158
+ ): Promise<DockerResult> {
159
+ if (!existsSync(options.files[0])) {
160
+ return { ok: false, stdout: "", stderr: "Compose file not found", code: 1 };
227
161
  }
228
-
162
+ const args = buildComposeArgs(options);
163
+ for (const p of options.profiles ?? []) args.push("--profile", p);
229
164
  args.push("down");
230
-
231
- if (options.removeVolumes) {
232
- args.push("-v");
233
- }
234
-
235
- return run(args, stateDir);
165
+ if (options.removeVolumes) args.push("-v");
166
+ return run(args, undefined);
236
167
  }
237
168
 
238
169
  /**
239
170
  * Restart specific services.
240
171
  */
241
172
  export async function composeRestart(
242
- stateDir: string,
243
173
  services: string[],
244
- options: { files?: string[]; envFiles?: string[] } = {}
174
+ options: { files: string[]; envFiles?: string[] }
245
175
  ): Promise<DockerResult> {
246
- const primaryFile = options.files?.[0] ?? composeFile(stateDir);
176
+ const primaryFile = options.files[0];
247
177
  if (!existsSync(primaryFile)) {
248
178
  return {
249
179
  ok: false,
@@ -253,79 +183,75 @@ export async function composeRestart(
253
183
  };
254
184
  }
255
185
 
256
- const args = buildComposeArgs(stateDir, options);
186
+ const args = buildComposeArgs(options);
257
187
  args.push("restart", ...services);
258
188
 
259
- return run(args, stateDir);
189
+ return run(args, undefined);
260
190
  }
261
191
 
262
192
  /**
263
193
  * Stop specific services.
264
194
  */
265
195
  export async function composeStop(
266
- stateDir: string,
267
196
  services: string[],
268
- options: { files?: string[]; envFiles?: string[] } = {}
197
+ options: { files: string[]; envFiles?: string[] }
269
198
  ): Promise<DockerResult> {
270
- const args = buildComposeArgs(stateDir, options);
199
+ const args = buildComposeArgs(options);
271
200
  args.push("stop", ...services);
272
201
 
273
- return run(args, stateDir);
202
+ return run(args, undefined);
274
203
  }
275
204
 
276
205
  /**
277
206
  * Start specific services (must already be created).
278
207
  */
279
208
  export async function composeStart(
280
- stateDir: string,
281
209
  services: string[],
282
- options: { files?: string[]; envFiles?: string[] } = {}
210
+ options: { files: string[]; envFiles?: string[] }
283
211
  ): Promise<DockerResult> {
284
- const args = buildComposeArgs(stateDir, options);
212
+ const args = buildComposeArgs(options);
285
213
  // Use up -d for specific services to ensure they're created
286
214
  args.push("up", "-d", ...services);
287
215
 
288
- return run(args, stateDir);
216
+ return run(args, undefined);
289
217
  }
290
218
 
291
219
  /**
292
220
  * Get the status of all containers in the project.
293
221
  */
294
222
  export async function composePs(
295
- stateDir: string,
296
- options: { files?: string[]; envFiles?: string[] } = {}
223
+ options: { files: string[]; envFiles?: string[] }
297
224
  ): Promise<DockerResult> {
298
- const primaryFile = options.files?.[0] ?? composeFile(stateDir);
225
+ const primaryFile = options.files[0];
299
226
  if (!existsSync(primaryFile)) {
300
227
  // If no compose file, just list containers with the project label
301
228
  return run(
302
229
  [
303
230
  "ps",
304
231
  "--filter",
305
- "label=com.docker.compose.project=openpalm",
232
+ `label=com.docker.compose.project=${resolveComposeProjectName()}`,
306
233
  "--format",
307
234
  "json"
308
235
  ],
309
- stateDir
236
+ undefined
310
237
  );
311
238
  }
312
239
 
313
- const args = buildComposeArgs(stateDir, options);
240
+ const args = buildComposeArgs(options);
314
241
  args.push("ps", "--format", "json");
315
242
 
316
- return run(args, stateDir);
243
+ return run(args, undefined);
317
244
  }
318
245
 
319
246
  /**
320
247
  * Get logs for specific services or all services.
321
248
  */
322
249
  export async function composeLogs(
323
- stateDir: string,
324
- services?: string[],
325
- tail = 100,
326
- options: { files?: string[]; envFiles?: string[]; since?: string } = {}
250
+ services: string[] | undefined,
251
+ tail: number,
252
+ options: { files: string[]; envFiles?: string[]; since?: string }
327
253
  ): Promise<DockerResult> {
328
- const args = buildComposeArgs(stateDir, options);
254
+ const args = buildComposeArgs(options);
329
255
  args.push("logs", "--tail", String(tail));
330
256
 
331
257
  if (options.since) {
@@ -336,67 +262,39 @@ export async function composeLogs(
336
262
  args.push(...services);
337
263
  }
338
264
 
339
- return run(args, stateDir);
340
- }
341
-
342
- /**
343
- * Reload Caddy configuration by restarting the container.
344
- */
345
- export async function caddyReload(
346
- stateDir: string,
347
- options: { files?: string[]; envFiles?: string[] } = {}
348
- ): Promise<DockerResult> {
349
- return composeRestart(stateDir, ["caddy"], options);
265
+ return run(args, undefined);
350
266
  }
351
267
 
352
268
  /**
353
269
  * Pull image for a single service.
354
270
  */
355
271
  export async function composePullService(
356
- stateDir: string,
357
272
  service: string,
358
- options: { files?: string[]; envFiles?: string[] } = {}
273
+ options: { files: string[]; envFiles?: string[] }
359
274
  ): Promise<DockerResult> {
360
- const args = buildComposeArgs(stateDir, options);
275
+ const args = buildComposeArgs(options);
361
276
  args.push("pull", service);
362
-
363
- const envOverrides: Record<string, string> = {};
364
- for (const ef of options.envFiles ?? []) {
365
- Object.assign(envOverrides, parseEnvFile(ef));
366
- }
367
-
368
- return run(args, stateDir, 300_000, envOverrides);
277
+ return run(args, undefined, 300_000, collectEnvOverrides(options.envFiles));
369
278
  }
370
279
 
371
- /**
372
- * Pull latest images for all services.
373
- */
374
280
  export async function composePull(
375
- stateDir: string,
376
- options: { files?: string[]; envFiles?: string[] } = {}
281
+ options: { files: string[]; envFiles?: string[] }
377
282
  ): Promise<DockerResult> {
378
- const args = buildComposeArgs(stateDir, options);
283
+ const args = buildComposeArgs(options);
379
284
  args.push("pull");
380
-
381
- const envOverrides: Record<string, string> = {};
382
- for (const ef of options.envFiles ?? []) {
383
- Object.assign(envOverrides, parseEnvFile(ef));
384
- }
385
-
386
- return run(args, stateDir, 300_000, envOverrides);
285
+ return run(args, undefined, 300_000, collectEnvOverrides(options.envFiles));
387
286
  }
388
287
 
389
288
  /**
390
289
  * Get resource usage stats for all containers in the project.
391
290
  */
392
291
  export async function composeStats(
393
- stateDir: string,
394
- options: { files?: string[]; envFiles?: string[] } = {}
292
+ options: { files: string[]; envFiles?: string[] }
395
293
  ): Promise<DockerResult> {
396
- const args = buildComposeArgs(stateDir, options);
294
+ const args = buildComposeArgs(options);
397
295
  args.push("stats", "--no-stream", "--format", "json");
398
296
 
399
- return run(args, stateDir);
297
+ return run(args, undefined);
400
298
  }
401
299
 
402
300
  /**
@@ -421,29 +319,21 @@ export async function getDockerEvents(
421
319
  * Fire-and-forget recreation of the admin container.
422
320
  */
423
321
  export function selfRecreateAdmin(
424
- stateDir: string,
425
- options: { files?: string[]; envFiles?: string[] } = {}
322
+ options: { files: string[]; envFiles?: string[] }
426
323
  ): void {
427
- const args = buildComposeArgs(stateDir, options);
324
+ const args = buildComposeArgs(options);
428
325
  args.push("--profile", "admin", "up", "-d", "--force-recreate", "--remove-orphans", "admin");
429
-
430
- const envOverrides: Record<string, string> = {};
431
- for (const ef of options.envFiles ?? []) {
432
- Object.assign(envOverrides, parseEnvFile(ef));
433
- }
434
-
435
326
  try {
436
327
  const child = spawn("docker", args, {
437
- cwd: stateDir,
438
328
  stdio: "ignore",
439
329
  detached: true,
440
- env: { ...process.env, ...envOverrides }
330
+ env: { ...process.env, ...collectEnvOverrides(options.envFiles) }
441
331
  });
442
332
  child.on("error", (err) => {
443
- console.error("[selfRecreateAdmin] spawn error:", err.message);
333
+ logger.error("selfRecreateAdmin spawn error", { error: err.message });
444
334
  });
445
335
  child.unref();
446
336
  } catch (err) {
447
- console.error("[selfRecreateAdmin] failed to spawn:", err instanceof Error ? err.message : err);
337
+ logger.error("selfRecreateAdmin failed to spawn", { error: err instanceof Error ? err.message : String(err) });
448
338
  }
449
339
  }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Test that env schema validation uses the correct nested vault paths.
3
+ */
4
+ import { describe, test, expect, beforeAll, afterAll } from "bun:test";
5
+ import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { tmpdir } from "node:os";
8
+ import type { ControlPlaneState } from "./types.js";
9
+
10
+ describe("env schema validation paths", () => {
11
+ let tmpDir: string;
12
+ let state: ControlPlaneState;
13
+
14
+ beforeAll(() => {
15
+ tmpDir = join(tmpdir(), `openpalm-schema-test-${Date.now()}`);
16
+ mkdirSync(join(tmpDir, "vault/user"), { recursive: true });
17
+ mkdirSync(join(tmpDir, "vault/stack"), { recursive: true });
18
+ mkdirSync(join(tmpDir, "data"), { recursive: true });
19
+ mkdirSync(join(tmpDir, "logs"), { recursive: true });
20
+ mkdirSync(join(tmpDir, "config"), { recursive: true });
21
+
22
+ state = {
23
+ adminToken: "test-token",
24
+ assistantToken: "test-assistant",
25
+ setupToken: "test-setup",
26
+ homeDir: tmpDir,
27
+ configDir: join(tmpDir, "config"),
28
+ vaultDir: join(tmpDir, "vault"),
29
+ dataDir: join(tmpDir, "data"),
30
+ logsDir: join(tmpDir, "logs"),
31
+ cacheDir: join(tmpDir, "cache"),
32
+ services: {},
33
+ artifacts: { compose: "" },
34
+ artifactMeta: [],
35
+ audit: [],
36
+ };
37
+ });
38
+
39
+ afterAll(() => {
40
+ if (tmpDir && existsSync(tmpDir)) {
41
+ rmSync(tmpDir, { recursive: true, force: true });
42
+ }
43
+ });
44
+
45
+ test("validation succeeds when no schema files exist (skip mode)", async () => {
46
+ const { validateProposedState } = await import("./validate.js");
47
+ const result = await validateProposedState(state);
48
+ // When schema files don't exist, validation is skipped (no errors)
49
+ expect(result.ok).toBe(true);
50
+ expect(result.errors).toEqual([]);
51
+ });
52
+
53
+ test("schema paths match canonical vault layout", () => {
54
+ const expectedUserSchema = join(tmpDir, "vault/user/user.env.schema");
55
+ const expectedStackSchema = join(tmpDir, "vault/stack/stack.env.schema");
56
+
57
+ writeFileSync(expectedUserSchema, "# test schema\n");
58
+ writeFileSync(expectedStackSchema, "# test schema\n");
59
+
60
+ expect(existsSync(expectedUserSchema)).toBe(true);
61
+ expect(existsSync(expectedStackSchema)).toBe(true);
62
+
63
+ // Old flat paths must NOT exist
64
+ expect(existsSync(join(tmpDir, "vault/user.env.schema"))).toBe(false);
65
+ expect(existsSync(join(tmpDir, "vault/system.env.schema"))).toBe(false);
66
+ });
67
+
68
+ test("validate.ts reads from nested paths, not flat paths", async () => {
69
+ // Write schemas at OLD flat paths — should be ignored
70
+ writeFileSync(join(tmpDir, "vault/user.env.schema"), "OPENAI_API_KEY\n");
71
+ writeFileSync(join(tmpDir, "vault/system.env.schema"), "OP_ADMIN_TOKEN\n");
72
+ // Write env files
73
+ writeFileSync(join(tmpDir, "vault/user/user.env"), "# empty\n");
74
+ writeFileSync(join(tmpDir, "vault/stack/stack.env"), "# empty\n");
75
+ // Delete nested schemas to prove flat paths are ignored
76
+ try { rmSync(join(tmpDir, "vault/user/user.env.schema")); } catch { /* may not exist */ }
77
+ try { rmSync(join(tmpDir, "vault/stack/stack.env.schema")); } catch { /* may not exist */ }
78
+
79
+ const { validateProposedState } = await import("./validate.js");
80
+ const result = await validateProposedState(state);
81
+ // Should pass because nested schemas don't exist (skipped), not because flat schemas were read
82
+ expect(result.ok).toBe(true);
83
+ });
84
+
85
+ test("validation reports warnings for missing required schema keys", async () => {
86
+ // Seed a schema that requires OPENAI_API_KEY
87
+ writeFileSync(join(tmpDir, "vault/user/user.env.schema"), "OPENAI_API_KEY=string\nOWNER_NAME=string\n");
88
+ // Seed an env file that is missing those keys
89
+ writeFileSync(join(tmpDir, "vault/user/user.env"), "# empty env\nSOME_OTHER_KEY=value\n");
90
+
91
+ const { validateProposedState } = await import("./validate.js");
92
+ const result = await validateProposedState(state);
93
+ // The validator should report warnings for missing keys (not errors — env validation is advisory)
94
+ expect(result.warnings.length).toBeGreaterThanOrEqual(0);
95
+ });
96
+
97
+ test("validation handles malformed env file gracefully", async () => {
98
+ writeFileSync(join(tmpDir, "vault/user/user.env.schema"), "OPENAI_API_KEY=string\n");
99
+ // Malformed: no = sign, just random text
100
+ writeFileSync(join(tmpDir, "vault/user/user.env"), "this is not a valid env file\n===\n");
101
+
102
+ const { validateProposedState } = await import("./validate.js");
103
+ const result = await validateProposedState(state);
104
+ // Should not throw — graceful handling
105
+ expect(typeof result.ok).toBe("boolean");
106
+ });
107
+
108
+ test("validation handles empty schema file gracefully", async () => {
109
+ writeFileSync(join(tmpDir, "vault/user/user.env.schema"), "");
110
+ writeFileSync(join(tmpDir, "vault/user/user.env"), "OPENAI_API_KEY=sk-test\n");
111
+
112
+ const { validateProposedState } = await import("./validate.js");
113
+ const result = await validateProposedState(state);
114
+ // Empty schema may cause varlock to report an error — that's fine,
115
+ // the important thing is it doesn't throw/crash
116
+ expect(typeof result.ok).toBe("boolean");
117
+ });
118
+ });