@openpalm/lib 0.11.0-beta.1 → 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 (49) hide show
  1. package/README.md +2 -0
  2. package/package.json +5 -1
  3. package/src/control-plane/akm-vault.test.ts +1 -4
  4. package/src/control-plane/akm-vault.ts +5 -1
  5. package/src/control-plane/channels.ts +8 -6
  6. package/src/control-plane/compose-args.test.ts +0 -12
  7. package/src/control-plane/compose-args.ts +0 -4
  8. package/src/control-plane/compose-errors.test.ts +106 -0
  9. package/src/control-plane/compose-errors.ts +117 -0
  10. package/src/control-plane/config-persistence.ts +49 -13
  11. package/src/control-plane/core-assets.ts +63 -7
  12. package/src/control-plane/docker.ts +15 -4
  13. package/src/control-plane/env.ts +4 -1
  14. package/src/control-plane/host-opencode.test.ts +0 -3
  15. package/src/control-plane/install-edge-cases.test.ts +29 -69
  16. package/src/control-plane/lifecycle.ts +39 -50
  17. package/src/control-plane/migrate-0110.test.ts +177 -0
  18. package/src/control-plane/migrate-0110.ts +99 -0
  19. package/src/control-plane/operator-ids.test.ts +130 -0
  20. package/src/control-plane/operator-ids.ts +89 -0
  21. package/src/control-plane/paths.ts +8 -3
  22. package/src/control-plane/registry-components.test.ts +3 -2
  23. package/src/control-plane/registry.test.ts +198 -4
  24. package/src/control-plane/registry.ts +333 -4
  25. package/src/control-plane/secret-mappings.ts +2 -3
  26. package/src/control-plane/secrets.ts +17 -11
  27. package/src/control-plane/setup-config.schema.json +3 -3
  28. package/src/control-plane/setup-status.ts +6 -1
  29. package/src/control-plane/setup-validation.ts +2 -2
  30. package/src/control-plane/setup.test.ts +24 -20
  31. package/src/control-plane/setup.ts +25 -41
  32. package/src/control-plane/spec-to-env.test.ts +30 -16
  33. package/src/control-plane/spec-to-env.ts +37 -21
  34. package/src/control-plane/stack-spec.test.ts +5 -11
  35. package/src/control-plane/stack-spec.ts +2 -6
  36. package/src/control-plane/types.ts +0 -22
  37. package/src/control-plane/ui-assets.ts +45 -9
  38. package/src/control-plane/validate.ts +1 -1
  39. package/src/index.ts +26 -13
  40. package/src/logger.test.ts +12 -12
  41. package/src/logger.ts +1 -1
  42. package/src/control-plane/admin-token.ts +0 -73
  43. package/src/control-plane/audit.ts +0 -41
  44. package/src/control-plane/lock.test.ts +0 -194
  45. package/src/control-plane/lock.ts +0 -176
  46. package/src/control-plane/provider-config.ts +0 -34
  47. package/src/control-plane/secret-backend.test.ts +0 -349
  48. package/src/control-plane/secret-backend.ts +0 -362
  49. package/src/control-plane/spec-validator.ts +0 -62
package/README.md CHANGED
@@ -3,6 +3,8 @@
3
3
  Shared control-plane library for OpenPalm.
4
4
  CLI, admin, and scheduler use this package so stack behavior stays consistent.
5
5
 
6
+ > **Bun required.** This package ships TypeScript source and relies on Bun's native TS execution. It does not compile to JavaScript and is not compatible with Node.js.
7
+
6
8
  The current model is direct-write over `~/.openpalm/` plus native Docker Compose.
7
9
  Compose files in `stack/` and env files in `vault/` are the live runtime inputs.
8
10
 
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "@openpalm/lib",
3
- "version": "0.11.0-beta.1",
3
+ "version": "0.11.0-beta.10",
4
4
  "license": "MPL-2.0",
5
5
  "type": "module",
6
6
  "description": "Shared control-plane library for OpenPalm — lifecycle, staging, secrets, channels, connections, scheduler",
7
+ "engines": {
8
+ "bun": ">=1.0.0"
9
+ },
7
10
  "scripts": {
8
11
  "test": "bun test"
9
12
  },
13
+ "types": "./src/index.ts",
10
14
  "exports": {
11
15
  ".": "./src/index.ts",
12
16
  "./provider-constants": "./src/provider-constants.ts",
@@ -21,19 +21,16 @@ import type { ControlPlaneState } from "./types.js";
21
21
 
22
22
  function makeState(homeDir: string): ControlPlaneState {
23
23
  return {
24
- adminToken: "test-admin",
25
- assistantToken: "test-assistant",
26
24
  homeDir,
27
25
  configDir: join(homeDir, "config"),
28
26
  stashDir: join(homeDir, "stash"),
29
27
  workspaceDir: join(homeDir, "workspace"),
30
- servicesDir: join(homeDir, "services"),
28
+ cacheDir: join(homeDir, "cache"),
31
29
  stateDir: join(homeDir, "state"),
32
30
  stackDir: join(homeDir, "stack"),
33
31
  services: {},
34
32
  artifacts: { compose: "" },
35
33
  artifactMeta: [],
36
- audit: [],
37
34
  };
38
35
  }
39
36
 
@@ -244,7 +244,11 @@ export async function deleteAkmVaultKey(
244
244
  const vaultPath = await ensureAkmUserVault(state, env);
245
245
  if (!vaultPath) return false;
246
246
  try {
247
- await execAkm(["vault", "unset", AKM_USER_VAULT_REF, key], env);
247
+ // --yes: newer akm versions require explicit confirmation for any
248
+ // destructive operation in non-interactive mode. Without this flag
249
+ // the command exits with NON_INTERACTIVE_REQUIRES_YES and our
250
+ // delete looks like a hard failure instead of an idempotent unset.
251
+ await execAkm(["vault", "unset", "--yes", AKM_USER_VAULT_REF, key], env);
248
252
  } catch (err) {
249
253
  // `unset` of a missing key is a benign no-op; many akm versions exit 0
250
254
  // anyway. If akm hard-fails (non-zero, non-empty stderr) we surface it.
@@ -19,9 +19,11 @@ function isValidChannelName(name: string): boolean {
19
19
  // ── Channel Discovery ─────────────────────────────────────────────────
20
20
 
21
21
  /**
22
- * Check if a compose file defines a channel service (has CHANNEL_NAME or GUARDIAN_URL).
23
- * This is compose-derived: we parse the actual compose content rather than
24
- * relying on filename patterns or directory naming conventions.
22
+ * Check if a compose file defines a channel service (has CHANNEL_NAME).
23
+ * Compose-derived: we parse the actual compose content rather than rely on
24
+ * filename or directory naming conventions. (GUARDIAN_URL used to be a
25
+ * fallback signal — it's been removed since channels-sdk now hardcodes the
26
+ * in-network guardian URL.)
25
27
  */
26
28
  export function isChannelAddon(composePath: string): boolean {
27
29
  try {
@@ -36,9 +38,9 @@ export function isChannelAddon(composePath: string): boolean {
36
38
  const env = (svcDef as Record<string, unknown>).environment;
37
39
  if (typeof env === "object" && env !== null) {
38
40
  if (Array.isArray(env)) {
39
- if (env.some((e: unknown) => typeof e === "string" && (e.startsWith("CHANNEL_NAME=") || e.startsWith("GUARDIAN_URL=")))) return true;
41
+ if (env.some((e: unknown) => typeof e === "string" && e.startsWith("CHANNEL_NAME="))) return true;
40
42
  } else {
41
- if ("CHANNEL_NAME" in (env as Record<string, unknown>) || "GUARDIAN_URL" in (env as Record<string, unknown>)) return true;
43
+ if ("CHANNEL_NAME" in (env as Record<string, unknown>)) return true;
42
44
  }
43
45
  }
44
46
  }
@@ -51,7 +53,7 @@ export function isChannelAddon(composePath: string): boolean {
51
53
  /**
52
54
  * Discover installed channels by scanning stack/addons/ for channel addons.
53
55
  * A channel addon is identified by compose-derived truth: its compose.yml
54
- * defines services with CHANNEL_NAME or GUARDIAN_URL environment variables.
56
+ * defines services with a CHANNEL_NAME environment variable.
55
57
  *
56
58
  * Non-channel addons (admin, ollama, etc.) are excluded.
57
59
  *
@@ -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";
@@ -17,8 +16,6 @@ let tempDir: string;
17
16
  function makeState(overrides: Partial<ControlPlaneState> = {}): ControlPlaneState {
18
17
  const configDir = join(tempDir, "config");
19
18
  return {
20
- adminToken: "test",
21
- assistantToken: "test",
22
19
  homeDir: tempDir,
23
20
  configDir,
24
21
  stashDir: join(tempDir, "stash"),
@@ -29,7 +26,6 @@ function makeState(overrides: Partial<ControlPlaneState> = {}): ControlPlaneStat
29
26
  services: {},
30
27
  artifacts: { compose: "" },
31
28
  artifactMeta: [],
32
- audit: [],
33
29
  ...overrides,
34
30
  };
35
31
  }
@@ -66,14 +62,6 @@ afterEach(() => {
66
62
  rmSync(tempDir, { recursive: true, force: true });
67
63
  });
68
64
 
69
- // ── COMPOSE_PROJECT_NAME ─────────────────────────────────────────────────
70
-
71
- describe("COMPOSE_PROJECT_NAME", () => {
72
- it("is 'openpalm'", () => {
73
- expect(COMPOSE_PROJECT_NAME).toBe("openpalm");
74
- });
75
- });
76
-
77
65
  // ── buildComposeOptions ──────────────────────────────────────────────────
78
66
 
79
67
  describe("buildComposeOptions", () => {
@@ -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
+ }
@@ -12,6 +12,7 @@ import { parseEnvFile, mergeEnvContent, expandEnvVars } from './env.js';
12
12
  import type { ControlPlaneState, ArtifactMeta } from "./types.js";
13
13
  import { isChannelAddon } from "./channels.js";
14
14
  import { listEnabledAddonIds } from "./registry.js";
15
+ import { resolveOperatorIds, hasUsableOperatorId } from "./operator-ids.js";
15
16
 
16
17
  import {
17
18
  readCoreCompose,
@@ -69,42 +70,60 @@ export function writeSystemEnv(state: ControlPlaneState): void {
69
70
  OP_SETUP_COMPLETE: alreadyComplete ? "true" : "false"
70
71
  };
71
72
 
73
+ // Backfill OP_UID/OP_GID when the existing stack.env was written by an
74
+ // older code path that hard-coded 1000, or when the file was created
75
+ // with missing/zero values. We only override when the current value is
76
+ // missing or zero — an operator who manually set OP_UID=2000 (e.g.
77
+ // because they're running on a host with a non-1000 service account)
78
+ // must not be silently changed.
79
+ const parsed = parseEnvFile(systemEnvPath);
80
+ const ids = resolveOperatorIds(state.homeDir);
81
+ if (ids) {
82
+ if (!hasUsableOperatorId(parsed, "OP_UID")) adminManaged.OP_UID = String(ids.uid);
83
+ if (!hasUsableOperatorId(parsed, "OP_GID")) adminManaged.OP_GID = String(ids.gid);
84
+ }
85
+
72
86
  const content = mergeEnvContent(base, adminManaged, {
73
87
  sectionHeader: "# ── Admin-managed ──────────────────────────────────────────────────"
74
88
  });
75
89
 
76
- writeFileSync(systemEnvPath, content);
90
+ writeFileSync(systemEnvPath, content, { mode: 0o600 });
91
+ chmodSync(systemEnvPath, 0o600);
77
92
  }
78
93
 
79
94
  function generateFallbackSystemEnv(state: ControlPlaneState): string {
80
- const uid = typeof process.getuid === "function" ? (process.getuid() ?? 1000) : 1000;
81
- const gid = typeof process.getgid === "function" ? (process.getgid() ?? 1000) : 1000;
95
+ // Operator UID/GID auto-detect from OP_HOME owner (or process UID).
96
+ // Skipped on Windows where containers run in WSL2 and OP_UID has no
97
+ // meaning on the host process.
98
+ const ids = resolveOperatorIds(state.homeDir);
99
+ const idLines: string[] = ids
100
+ ? [`OP_UID=${ids.uid}`, `OP_GID=${ids.gid}`]
101
+ : [];
82
102
 
83
103
  return [
84
104
  "# OpenPalm — System Configuration (managed by CLI/admin)",
85
105
  "# Auto-generated fallback.",
86
106
  "",
87
107
  "# ── Authentication ──────────────────────────────────────────────────",
88
- `OP_UI_TOKEN=\${OP_UI_TOKEN}`,
89
- `OP_ASSISTANT_TOKEN=\${OP_ASSISTANT_TOKEN}`,
108
+ `OP_UI_LOGIN_PASSWORD=\${OP_UI_LOGIN_PASSWORD}`,
90
109
  "",
91
110
  "# ── Service Auth ─────────────────────────────────────────────────────",
92
111
  "OP_OPENCODE_PASSWORD=",
93
112
  "",
94
113
  "# ── Paths ──────────────────────────────────────────────────────────",
95
114
  `OP_HOME=${state.homeDir}`,
96
- `OP_UID=${uid}`,
97
- `OP_GID=${gid}`,
115
+ ...idLines,
98
116
  "",
99
117
  "# ── Images ──────────────────────────────────────────────────────────",
100
118
  `OP_IMAGE_NAMESPACE=${process.env.OP_IMAGE_NAMESPACE ?? "openpalm"}`,
101
119
  `OP_IMAGE_TAG=${DEFAULT_IMAGE_TAG}`,
102
120
  "",
103
121
  "# ── Ports (38XX range) ──────────────────────────────────────────────",
122
+ "# Guardian is network-only (no host port) — channels reach it via",
123
+ "# http://guardian:8080 over the channel_lan Docker network.",
104
124
  `OP_ASSISTANT_PORT=3800`,
105
125
  `OP_ADMIN_PORT=3880`,
106
126
  `OP_ADMIN_OPENCODE_PORT=3881`,
107
- `OP_GUARDIAN_PORT=3899`,
108
127
  ""
109
128
  ].join("\n");
110
129
  }
@@ -127,8 +146,21 @@ export function discoverStackOverlays(stackDir: string): string[] {
127
146
  .filter((e) => e.isDirectory())
128
147
  .sort((a, b) => a.name.localeCompare(b.name));
129
148
  for (const entry of entries) {
130
- const addonCompose = `${addonsDir}/${entry.name}/compose.yml`;
131
- if (existsSync(addonCompose)) files.push(addonCompose);
149
+ const dir = `${addonsDir}/${entry.name}`;
150
+ // Pick up compose.yml plus any compose.<variant>.yml sibling
151
+ // overlays (e.g. compose.cdi.yml generated by /admin/voice on
152
+ // CDI hosts). Stable sort: compose.yml first, then siblings
153
+ // alphabetically, so the base file's keys are the defaults and
154
+ // overlays merge on top in deterministic order.
155
+ const overlays = readdirSync(dir, { withFileTypes: true })
156
+ .filter((e) => e.isFile() && /^compose(\.[A-Za-z0-9_-]+)?\.ya?ml$/.test(e.name))
157
+ .map((e) => e.name)
158
+ .sort((a, b) => {
159
+ if (a === "compose.yml" || a === "compose.yaml") return -1;
160
+ if (b === "compose.yml" || b === "compose.yaml") return 1;
161
+ return a.localeCompare(b);
162
+ });
163
+ for (const name of overlays) files.push(`${dir}/${name}`);
132
164
  }
133
165
  }
134
166
 
@@ -226,7 +258,7 @@ export function writeChannelSecrets(stackDir: string, secrets: Record<string, st
226
258
  * (e.g. `/var/run/docker.sock`) are left alone.
227
259
  */
228
260
  export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
229
- const composeFiles = discoverStackOverlays(`${state.homeDir}/stack`);
261
+ const composeFiles = discoverStackOverlays(state.stackDir);
230
262
  if (composeFiles.length === 0) return;
231
263
 
232
264
  const envVars: Record<string, string> = {
@@ -282,9 +314,13 @@ export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
282
314
  export function writeRuntimeFiles(
283
315
  state: ControlPlaneState
284
316
  ): void {
285
- // Write core compose to config/stack/
317
+ // Write core compose to config/stack/ only on first install —
318
+ // refreshCoreAssets() is the canonical writer on update.
286
319
  mkdirSync(state.stackDir, { recursive: true });
287
- writeFileSync(`${state.stackDir}/core.compose.yml`, state.artifacts.compose);
320
+ const composePath = `${state.stackDir}/core.compose.yml`;
321
+ if (!existsSync(composePath)) {
322
+ writeFileSync(composePath, state.artifacts.compose);
323
+ }
288
324
 
289
325
  // Load persisted channel HMAC secrets from guardian.env,
290
326
  // then generate new ones for new channel addons.
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { mkdirSync, writeFileSync, readFileSync, existsSync, copyFileSync } from "node:fs";
13
13
  import { dirname, join, resolve, sep } from "node:path";
14
+ import { fileURLToPath } from "node:url";
14
15
  import { resolveStateDir, resolveOpenPalmHome, resolveBackupsDir, resolveStashDir } from "./home.js";
15
16
  import { createLogger } from "../logger.js";
16
17
  import { sha256 } from "./crypto.js";
@@ -80,15 +81,31 @@ export function seedStashAssets(seeds: Record<string, string>): string[] {
80
81
  // ── Asset Refresh (GitHub download) ──────────────────────────────────
81
82
 
82
83
  const REPO = "itlackey/openpalm";
83
- const VERSION = process.env.OP_ASSET_VERSION ?? "main";
84
84
 
85
- // Stash seeds are intentionally NOT in this list — they use seedStashAssets()
86
- // which never overwrites existing files (user edits win on re-install).
85
+ function resolveAssetVersion(): string {
86
+ if (process.env.OP_ASSET_VERSION) return process.env.OP_ASSET_VERSION;
87
+ try {
88
+ const pkgJson = JSON.parse(
89
+ readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../package.json"), "utf-8")
90
+ );
91
+ return `v${pkgJson.version}`;
92
+ } catch {
93
+ return "main";
94
+ }
95
+ }
96
+ const VERSION = resolveAssetVersion();
97
+
98
+ // Persona files (openpalm.md, system.md), stash seeds, and user-editable config
99
+ // files are intentionally NOT in this list. They are seeded once (never
100
+ // overwritten) via seedOpenPalmDir (skipExisting) or SEEDED_ASSETS below.
87
101
  const MANAGED_ASSETS: { relPath: string; githubFilename: string }[] = [
88
- { relPath: "config/stack/core.compose.yml", githubFilename: ".openpalm/config/stack/core.compose.yml" },
89
- { relPath: "config/assistant/opencode.jsonc", githubFilename: ".openpalm/config/assistant/opencode.jsonc" },
90
- { relPath: "config/assistant/openpalm.md", githubFilename: ".openpalm/config/assistant/openpalm.md" },
91
- { relPath: "config/assistant/system.md", githubFilename: ".openpalm/config/assistant/system.md" },
102
+ { relPath: "config/stack/core.compose.yml", githubFilename: ".openpalm/config/stack/core.compose.yml" },
103
+ ];
104
+
105
+ // Seeded once — written only when the file does not exist yet.
106
+ // User edits always win; upgrade never touches these files.
107
+ const SEEDED_ASSETS: { relPath: string; githubFilename: string }[] = [
108
+ { relPath: "config/assistant/opencode.jsonc", githubFilename: ".openpalm/config/assistant/opencode.jsonc" },
92
109
  ];
93
110
 
94
111
  async function downloadAsset(filename: string): Promise<string> {
@@ -137,5 +154,44 @@ export async function refreshCoreAssets(): Promise<{
137
154
  updated.push(asset.relPath);
138
155
  }
139
156
 
157
+ // Seed user-editable assets only when missing — never overwrite.
158
+ for (const asset of SEEDED_ASSETS) {
159
+ const targetPath = join(homeDir, asset.relPath);
160
+ if (existsSync(targetPath)) continue;
161
+ const freshContent = await downloadAsset(asset.githubFilename);
162
+ mkdirSync(dirname(targetPath), { recursive: true });
163
+ writeFileSync(targetPath, freshContent);
164
+ updated.push(asset.relPath);
165
+ }
166
+
140
167
  return { backupDir, updated };
141
168
  }
169
+
170
+ // ── Assistant Persona File Seeding ────────────────────────────────────
171
+
172
+ /**
173
+ * Seed assistant persona files (openpalm.md, system.md) into OP_HOME.
174
+ *
175
+ * Idempotent: **never overwrites** an existing file — user edits always
176
+ * win. This preserves the "config/ is user-owned" contract: persona files
177
+ * are seeded once on first install and never touched again on update.
178
+ *
179
+ * `seeds` maps relative path keys (e.g. `"config/assistant/openpalm.md"`)
180
+ * to file content. Each file is written to `resolveOpenPalmHome()/<relPath>`
181
+ * only if the file does not already exist.
182
+ *
183
+ * Returns the list of relative paths that were actually written (empty on
184
+ * re-run when every seed already exists on disk).
185
+ */
186
+ export function seedAssistantPersonaFiles(seeds: Record<string, string>): string[] {
187
+ const homeDir = resolveOpenPalmHome();
188
+ const written: string[] = [];
189
+ for (const [relPath, content] of Object.entries(seeds)) {
190
+ const targetPath = join(homeDir, relPath);
191
+ if (existsSync(targetPath)) continue;
192
+ mkdirSync(dirname(targetPath), { recursive: true });
193
+ writeFileSync(targetPath, content);
194
+ written.push(relPath);
195
+ }
196
+ return written;
197
+ }
@@ -295,26 +295,37 @@ export async function composeLogs(
295
295
  return run(args, undefined);
296
296
  }
297
297
 
298
+ // 60-minute pull timeout. Voice addon ships a ~2.4 GB image (CPU) /
299
+ // ~7.6 GB (CUDA); on a 1-2 Mbps home connection these legitimately take
300
+ // 30+ minutes. The previous 5-min cap silently killed pulls mid-stream
301
+ // on first install, surfacing as an opaque "pull failed". The wizard's
302
+ // retry layer wraps this, so an actually-hung pull is bounded by the
303
+ // outer retry budget; this just gives any progressing pull room to
304
+ // finish on slow connections.
305
+ const PULL_TIMEOUT_MS = 60 * 60_000;
306
+
298
307
  /**
299
308
  * Pull image for a single service.
300
309
  */
301
310
  export async function composePullService(
302
311
  service: string,
303
- options: { files: string[]; envFiles?: string[] }
312
+ options: { files: string[]; envFiles?: string[]; profiles?: string[] }
304
313
  ): Promise<DockerResult> {
305
314
  await runPreflight(options);
306
315
  const args = buildComposeArgs(options);
316
+ for (const p of options.profiles ?? []) args.push("--profile", p);
307
317
  args.push("pull", service);
308
- return run(args, undefined, 300_000, collectEnvOverrides(options.envFiles));
318
+ return run(args, undefined, PULL_TIMEOUT_MS, collectEnvOverrides(options.envFiles));
309
319
  }
310
320
 
311
321
  export async function composePull(
312
- options: { files: string[]; envFiles?: string[] }
322
+ options: { files: string[]; envFiles?: string[]; profiles?: string[] }
313
323
  ): Promise<DockerResult> {
314
324
  await runPreflight(options);
315
325
  const args = buildComposeArgs(options);
326
+ for (const p of options.profiles ?? []) args.push("--profile", p);
316
327
  args.push("pull");
317
- return run(args, undefined, 300_000, collectEnvOverrides(options.envFiles));
328
+ return run(args, undefined, PULL_TIMEOUT_MS, collectEnvOverrides(options.envFiles));
318
329
  }
319
330
 
320
331
  /**
@@ -1,5 +1,5 @@
1
1
  import { parse as dotenvParse } from 'dotenv';
2
- import { readFileSync, existsSync } from 'node:fs';
2
+ import { readFileSync, existsSync, copyFileSync } from 'node:fs';
3
3
 
4
4
  export function parseEnvContent(content: string): Record<string, string> {
5
5
  return dotenvParse(content);
@@ -10,6 +10,9 @@ export function parseEnvFile(filePath: string): Record<string, string> {
10
10
  try {
11
11
  return dotenvParse(readFileSync(filePath, 'utf-8'));
12
12
  } catch {
13
+ // File is unreadable or malformed — back it up before returning empty so
14
+ // the next write doesn't silently discard all existing values.
15
+ try { copyFileSync(filePath, `${filePath}.corrupt-${Date.now()}`); } catch { /* best-effort */ }
13
16
  return {};
14
17
  }
15
18
  }