@openpalm/lib 0.10.2 → 0.11.0-beta.10

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 (63) hide show
  1. package/README.md +4 -2
  2. package/package.json +11 -3
  3. package/src/control-plane/akm-vault.test.ts +105 -0
  4. package/src/control-plane/akm-vault.ts +311 -0
  5. package/src/control-plane/channels.ts +11 -9
  6. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  7. package/src/control-plane/compose-args.test.ts +25 -33
  8. package/src/control-plane/compose-args.ts +0 -4
  9. package/src/control-plane/compose-errors.test.ts +106 -0
  10. package/src/control-plane/compose-errors.ts +117 -0
  11. package/src/control-plane/config-persistence.ts +148 -73
  12. package/src/control-plane/core-assets.test.ts +104 -0
  13. package/src/control-plane/core-assets.ts +111 -58
  14. package/src/control-plane/docker.ts +70 -25
  15. package/src/control-plane/env.test.ts +25 -1
  16. package/src/control-plane/env.ts +84 -1
  17. package/src/control-plane/home.ts +66 -69
  18. package/src/control-plane/host-opencode.test.ts +260 -0
  19. package/src/control-plane/host-opencode.ts +229 -0
  20. package/src/control-plane/install-edge-cases.test.ts +190 -292
  21. package/src/control-plane/install-lock.ts +157 -0
  22. package/src/control-plane/lifecycle.ts +65 -75
  23. package/src/control-plane/markdown-task.ts +200 -0
  24. package/src/control-plane/migrate-0110.test.ts +177 -0
  25. package/src/control-plane/migrate-0110.ts +99 -0
  26. package/src/control-plane/operator-ids.test.ts +130 -0
  27. package/src/control-plane/operator-ids.ts +89 -0
  28. package/src/control-plane/paths.ts +80 -0
  29. package/src/control-plane/provider-models.ts +154 -0
  30. package/src/control-plane/registry-components.test.ts +105 -27
  31. package/src/control-plane/registry.test.ts +247 -51
  32. package/src/control-plane/registry.ts +404 -54
  33. package/src/control-plane/rollback.ts +17 -16
  34. package/src/control-plane/scheduler.ts +75 -262
  35. package/src/control-plane/secret-mappings.ts +4 -8
  36. package/src/control-plane/secrets.ts +97 -55
  37. package/src/control-plane/setup-config.schema.json +5 -17
  38. package/src/control-plane/setup-status.ts +9 -29
  39. package/src/control-plane/setup-validation.ts +23 -23
  40. package/src/control-plane/setup.test.ts +143 -244
  41. package/src/control-plane/setup.ts +216 -133
  42. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  43. package/src/control-plane/spec-to-env.test.ts +75 -60
  44. package/src/control-plane/spec-to-env.ts +68 -153
  45. package/src/control-plane/stack-spec.test.ts +22 -84
  46. package/src/control-plane/stack-spec.ts +9 -89
  47. package/src/control-plane/types.ts +9 -29
  48. package/src/control-plane/ui-assets.ts +385 -0
  49. package/src/control-plane/validate.ts +44 -79
  50. package/src/index.ts +102 -56
  51. package/src/logger.test.ts +228 -0
  52. package/src/logger.ts +71 -1
  53. package/src/provider-constants.ts +22 -1
  54. package/src/control-plane/audit.ts +0 -40
  55. package/src/control-plane/env-schema-validation.test.ts +0 -118
  56. package/src/control-plane/lock.test.ts +0 -194
  57. package/src/control-plane/lock.ts +0 -176
  58. package/src/control-plane/memory-config.ts +0 -298
  59. package/src/control-plane/provider-config.ts +0 -34
  60. package/src/control-plane/redact-schema.ts +0 -50
  61. package/src/control-plane/secret-backend.test.ts +0 -359
  62. package/src/control-plane/secret-backend.ts +0 -322
  63. package/src/control-plane/spec-validator.ts +0 -159
@@ -6,7 +6,6 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { tmpdir } from "node:os";
8
8
  import {
9
- COMPOSE_PROJECT_NAME,
10
9
  buildComposeOptions,
11
10
  buildComposeCliArgs,
12
11
  } from "./compose-args.js";
@@ -15,47 +14,42 @@ import type { ControlPlaneState } from "./types.js";
15
14
  let tempDir: string;
16
15
 
17
16
  function makeState(overrides: Partial<ControlPlaneState> = {}): ControlPlaneState {
17
+ const configDir = join(tempDir, "config");
18
18
  return {
19
- adminToken: "test",
20
- assistantToken: "test",
21
- setupToken: "test",
22
19
  homeDir: tempDir,
23
- configDir: join(tempDir, "config"),
24
- vaultDir: join(tempDir, "vault"),
25
- dataDir: join(tempDir, "data"),
26
- logsDir: join(tempDir, "logs"),
20
+ configDir,
21
+ stashDir: join(tempDir, "stash"),
22
+ workspaceDir: join(tempDir, "workspace"),
27
23
  cacheDir: join(tempDir, "cache"),
24
+ stateDir: join(tempDir, "state"),
25
+ stackDir: join(configDir, "stack"),
28
26
  services: {},
29
27
  artifacts: { compose: "" },
30
28
  artifactMeta: [],
31
- audit: [],
32
29
  ...overrides,
33
30
  };
34
31
  }
35
32
 
36
33
  function seedCoreCompose(): void {
37
- const stackDir = join(tempDir, "stack");
34
+ const stackDir = join(tempDir, "config", "stack");
38
35
  mkdirSync(stackDir, { recursive: true });
39
36
  writeFileSync(join(stackDir, "core.compose.yml"), "services: {}");
40
37
  }
41
38
 
42
- function seedEnvFiles(files: { stack?: boolean; user?: boolean; guardian?: boolean } = {}): void {
39
+ function seedEnvFiles(files: { stack?: boolean; guardian?: boolean } = {}): void {
40
+ const stackDir = join(tempDir, "config", "stack");
43
41
  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");
42
+ mkdirSync(stackDir, { recursive: true });
43
+ writeFileSync(join(stackDir, "stack.env"), "KEY=val");
50
44
  }
51
45
  if (files.guardian) {
52
- mkdirSync(join(tempDir, "vault", "stack"), { recursive: true });
53
- writeFileSync(join(tempDir, "vault", "stack", "guardian.env"), "CHANNEL_CHAT_SECRET=abc");
46
+ mkdirSync(stackDir, { recursive: true });
47
+ writeFileSync(join(stackDir, "guardian.env"), "CHANNEL_CHAT_SECRET=abc");
54
48
  }
55
49
  }
56
50
 
57
51
  function seedAddon(name: string): void {
58
- const addonDir = join(tempDir, "stack", "addons", name);
52
+ const addonDir = join(tempDir, "config", "stack", "addons", name);
59
53
  mkdirSync(addonDir, { recursive: true });
60
54
  writeFileSync(join(addonDir, "compose.yml"), "services: {}");
61
55
  }
@@ -68,14 +62,6 @@ afterEach(() => {
68
62
  rmSync(tempDir, { recursive: true, force: true });
69
63
  });
70
64
 
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
65
  // ── buildComposeOptions ──────────────────────────────────────────────────
80
66
 
81
67
  describe("buildComposeOptions", () => {
@@ -98,13 +84,16 @@ describe("buildComposeOptions", () => {
98
84
  });
99
85
 
100
86
  it("returns env files in correct order", () => {
101
- seedEnvFiles({ stack: true, user: true, guardian: true });
87
+ // Note: vault/user/user.env is no longer a
88
+ // compose env_file. The runtime env file list is: stack.env, guardian.env.
89
+ // Even when a legacy user.env is present on disk, it is intentionally
90
+ // excluded from the compose args.
91
+ seedEnvFiles({ stack: true, guardian: true });
102
92
  const state = makeState();
103
93
  const opts = buildComposeOptions(state);
104
- expect(opts.envFiles).toHaveLength(3);
94
+ expect(opts.envFiles).toHaveLength(2);
105
95
  expect(opts.envFiles[0]).toContain("stack.env");
106
- expect(opts.envFiles[1]).toContain("user.env");
107
- expect(opts.envFiles[2]).toContain("guardian.env");
96
+ expect(opts.envFiles[1]).toContain("guardian.env");
108
97
  });
109
98
 
110
99
  it("excludes missing env files", () => {
@@ -136,8 +125,11 @@ describe("buildComposeCliArgs", () => {
136
125
  });
137
126
 
138
127
  it("includes --env-file flags for env files that exist", () => {
128
+ // Note: vault/user/user.env is no longer
129
+ // listed in the compose env_file set. Only stack.env and guardian.env
130
+ // (when present) are passed via --env-file.
139
131
  seedCoreCompose();
140
- seedEnvFiles({ stack: true, user: true });
132
+ seedEnvFiles({ stack: true, guardian: true });
141
133
  const state = makeState();
142
134
  const args = buildComposeCliArgs(state);
143
135
  const envFileIndices = args.reduce<number[]>((acc, arg, i) => {
@@ -11,10 +11,6 @@ import { buildComposeFileList } from "./lifecycle.js";
11
11
  import { buildEnvFiles } from "./config-persistence.js";
12
12
  import { resolveComposeProjectName } from "./docker.js";
13
13
 
14
- // ── Constants ────────────────────────────────────────────────────────────
15
-
16
- export const COMPOSE_PROJECT_NAME = "openpalm";
17
-
18
14
  // ── Types ────────────────────────────────────────────────────────────────
19
15
 
20
16
  export type ComposeOptions = {
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ parseComposeStderr,
4
+ summarizeComposeStderr,
5
+ } from "./compose-errors.js";
6
+
7
+ describe("parseComposeStderr", () => {
8
+ it("returns empty for empty input", () => {
9
+ expect(parseComposeStderr("")).toEqual([]);
10
+ expect(parseComposeStderr("\n\n")).toEqual([]);
11
+ });
12
+
13
+ it("extracts pull access denied for a single service", () => {
14
+ const stderr = [
15
+ " Network openpalm_default Created",
16
+ " voice Pulling",
17
+ " voice Error pull access denied for openpalm/voice, repository does not exist or may require 'docker login'",
18
+ "Error response from daemon: pull access denied for openpalm/voice, repository does not exist or may require 'docker login': denied: requested access to the resource is denied",
19
+ ].join("\n");
20
+
21
+ const failures = parseComposeStderr(stderr);
22
+ expect(failures.length).toBeGreaterThanOrEqual(1);
23
+ expect(failures[0].service).toBe("voice");
24
+ expect(failures[0].reason).toMatch(/pull access denied/);
25
+ });
26
+
27
+ it("handles spinner / status prefix glyphs", () => {
28
+ const stderr = " ⠿ voice Error pull access denied for openpalm/voice";
29
+ const failures = parseComposeStderr(stderr);
30
+ expect(failures).toHaveLength(1);
31
+ expect(failures[0].service).toBe("voice");
32
+ expect(failures[0].reason).toMatch(/pull access denied/);
33
+ });
34
+
35
+ it("captures quoted Service failed lines", () => {
36
+ const stderr =
37
+ 'Service "discord" failed to build: failed to solve: process did not complete';
38
+ const failures = parseComposeStderr(stderr);
39
+ expect(failures).toHaveLength(1);
40
+ expect(failures[0].service).toBe("discord");
41
+ expect(failures[0].reason).toMatch(/failed to solve/);
42
+ });
43
+
44
+ it("deduplicates identical (service, reason) pairs", () => {
45
+ const stderr = [
46
+ "voice Error pull access denied for openpalm/voice",
47
+ "voice Error pull access denied for openpalm/voice",
48
+ ].join("\n");
49
+ const failures = parseComposeStderr(stderr);
50
+ expect(failures).toHaveLength(1);
51
+ });
52
+
53
+ it("returns multiple distinct failures", () => {
54
+ const stderr = [
55
+ "voice Error pull access denied for openpalm/voice",
56
+ "discord Error no such image: openpalm/discord:latest",
57
+ ].join("\n");
58
+ const failures = parseComposeStderr(stderr);
59
+ expect(failures).toHaveLength(2);
60
+ expect(failures.map((f) => f.service).sort()).toEqual(["discord", "voice"]);
61
+ });
62
+
63
+ it("falls back to image name when only daemon error is present", () => {
64
+ const stderr =
65
+ "Error response from daemon: pull access denied for openpalm/voice, repository does not exist";
66
+ const failures = parseComposeStderr(stderr);
67
+ expect(failures).toHaveLength(1);
68
+ expect(failures[0].service).toBe("openpalm/voice");
69
+ expect(failures[0].reason).toMatch(/pull access denied/);
70
+ });
71
+
72
+ it("ignores non-error noise (Pulling/Created/Started)", () => {
73
+ const stderr = [
74
+ " Network openpalm_default Created",
75
+ " Container openpalm-guardian-1 Started",
76
+ " assistant Pulling",
77
+ ].join("\n");
78
+ expect(parseComposeStderr(stderr)).toEqual([]);
79
+ });
80
+
81
+ it("does not treat 'Error response from daemon' as a service name", () => {
82
+ const stderr = "Error response from daemon: something bad happened";
83
+ // No service-prefixed line, no pull access denied, no quoted service —
84
+ // parser should NOT invent a service called "Error".
85
+ expect(parseComposeStderr(stderr)).toEqual([]);
86
+ });
87
+ });
88
+
89
+ describe("summarizeComposeStderr", () => {
90
+ it("returns first non-empty line", () => {
91
+ expect(summarizeComposeStderr("\n\n hello world \nnext line")).toBe(
92
+ "hello world"
93
+ );
94
+ });
95
+
96
+ it("truncates long lines", () => {
97
+ const long = "x".repeat(800);
98
+ const out = summarizeComposeStderr(long, 100);
99
+ expect(out.length).toBe(100);
100
+ expect(out.endsWith("…")).toBe(true);
101
+ });
102
+
103
+ it("returns empty string for empty input", () => {
104
+ expect(summarizeComposeStderr("")).toBe("");
105
+ });
106
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Parse `docker compose` stderr for per-service failures.
3
+ *
4
+ * `docker compose up -d` reports its progress on stderr — one or more
5
+ * status lines per service, plus a daemon-level "Error response from daemon"
6
+ * summary. When a single addon service fails to pull or start, the rest of
7
+ * the stack often comes up fine, so the only signal that anything is wrong
8
+ * is whatever appears on stderr. This helper extracts the per-service
9
+ * failure messages so callers can surface them to operators.
10
+ */
11
+ export type ComposeServiceFailure = {
12
+ service: string;
13
+ reason: string;
14
+ };
15
+
16
+ /**
17
+ * Lines we recognise as per-service failure indicators. The compose CLI
18
+ * has rendered these in a few different shapes across versions:
19
+ *
20
+ * "voice Error pull access denied for openpalm/voice ..."
21
+ * " ⠿ voice Error pull access denied for openpalm/voice ..."
22
+ * "Service \"voice\" failed to build: ..."
23
+ *
24
+ * We also pick up the bare daemon error and attribute it to the service
25
+ * named in nearby lines when no service-prefixed line is present.
26
+ */
27
+ const SERVICE_ERROR_RE = /^[\s⠦⠧⠇⠏⠋⠙⠹⠸⠼⠴⠿✔✘×]*\s*([A-Za-z0-9._-]+)\s+(Error|Failed|failed)\s+(.+)$/;
28
+ const SERVICE_FAILED_QUOTED_RE = /Service\s+["']([A-Za-z0-9._-]+)["']\s+failed[^:]*:\s*(.+)$/i;
29
+ const SERVICE_NOT_FOUND_RE = /no such service:\s*([A-Za-z0-9._-]+)/i;
30
+ const PULL_ACCESS_DENIED_RE = /pull access denied for\s+([^\s,]+)/i;
31
+
32
+ function pushUnique(
33
+ failures: ComposeServiceFailure[],
34
+ entry: ComposeServiceFailure
35
+ ): void {
36
+ const trimmed = { service: entry.service.trim(), reason: entry.reason.trim() };
37
+ if (!trimmed.service || !trimmed.reason) return;
38
+ const dup = failures.find(
39
+ (f) => f.service === trimmed.service && f.reason === trimmed.reason
40
+ );
41
+ if (!dup) failures.push(trimmed);
42
+ }
43
+
44
+ /**
45
+ * Best-effort extraction of failures from compose stderr.
46
+ *
47
+ * - Returns one entry per (service, reason) pair, in stderr order.
48
+ * - Does NOT fabricate service names: if a daemon error appears without
49
+ * any nearby service-prefixed line, the caller's intended-services list
50
+ * is used by the route, not this parser.
51
+ */
52
+ export function parseComposeStderr(stderr: string): ComposeServiceFailure[] {
53
+ const failures: ComposeServiceFailure[] = [];
54
+ if (!stderr) return failures;
55
+
56
+ const lines = stderr.split(/\r?\n/);
57
+
58
+ for (const raw of lines) {
59
+ const line = raw.replace(/\s+$/, "");
60
+ if (!line.trim()) continue;
61
+
62
+ const quoted = SERVICE_FAILED_QUOTED_RE.exec(line);
63
+ if (quoted) {
64
+ pushUnique(failures, { service: quoted[1], reason: quoted[2] });
65
+ continue;
66
+ }
67
+
68
+ const m = SERVICE_ERROR_RE.exec(line);
69
+ if (m) {
70
+ // Skip generic prefixes that look like services but aren't
71
+ // (e.g. "Error response from daemon ..." would match if the parser
72
+ // is too lenient — the verb word would be the second token).
73
+ const candidate = m[1];
74
+ if (candidate.toLowerCase() === "error") continue;
75
+ pushUnique(failures, { service: candidate, reason: m[3] });
76
+ continue;
77
+ }
78
+
79
+ const notFound = SERVICE_NOT_FOUND_RE.exec(line);
80
+ if (notFound) {
81
+ pushUnique(failures, {
82
+ service: notFound[1],
83
+ reason: `no such service: ${notFound[1]}`,
84
+ });
85
+ continue;
86
+ }
87
+ }
88
+
89
+ // If we still found nothing but the stderr clearly mentions a pull
90
+ // access denied, surface the offending image as the "service" identifier
91
+ // — better than swallowing the failure entirely.
92
+ if (failures.length === 0) {
93
+ const denied = PULL_ACCESS_DENIED_RE.exec(stderr);
94
+ if (denied) {
95
+ pushUnique(failures, {
96
+ service: denied[1],
97
+ reason: `pull access denied for ${denied[1]}`,
98
+ });
99
+ }
100
+ }
101
+
102
+ return failures;
103
+ }
104
+
105
+ /**
106
+ * Summarise compose stderr in a single short line, suitable for log
107
+ * envelopes / API error messages when no per-service parse succeeded.
108
+ * Returns the first non-empty stderr line, capped.
109
+ */
110
+ export function summarizeComposeStderr(stderr: string, maxLen = 500): string {
111
+ if (!stderr) return "";
112
+ const first = stderr
113
+ .split(/\r?\n/)
114
+ .map((l) => l.trim())
115
+ .find((l) => l.length > 0) ?? "";
116
+ return first.length > maxLen ? first.slice(0, maxLen - 1) + "…" : first;
117
+ }