@openpalm/lib 0.11.0-beta.1 → 0.11.0-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +5 -1
- package/src/control-plane/akm-vault.test.ts +1 -4
- package/src/control-plane/akm-vault.ts +5 -1
- package/src/control-plane/channels.ts +8 -6
- package/src/control-plane/compose-args.test.ts +0 -12
- package/src/control-plane/compose-args.ts +0 -4
- package/src/control-plane/compose-errors.test.ts +106 -0
- package/src/control-plane/compose-errors.ts +117 -0
- package/src/control-plane/config-persistence.ts +49 -13
- package/src/control-plane/core-assets.ts +63 -7
- package/src/control-plane/docker.ts +15 -4
- package/src/control-plane/env.ts +4 -1
- package/src/control-plane/host-opencode.test.ts +0 -3
- package/src/control-plane/install-edge-cases.test.ts +29 -69
- package/src/control-plane/lifecycle.ts +39 -50
- package/src/control-plane/migrate-0110.test.ts +177 -0
- package/src/control-plane/migrate-0110.ts +99 -0
- package/src/control-plane/operator-ids.test.ts +130 -0
- package/src/control-plane/operator-ids.ts +89 -0
- package/src/control-plane/paths.ts +8 -3
- package/src/control-plane/registry-components.test.ts +3 -2
- package/src/control-plane/registry.test.ts +198 -4
- package/src/control-plane/registry.ts +333 -4
- package/src/control-plane/secret-mappings.ts +2 -3
- package/src/control-plane/secrets.ts +17 -11
- package/src/control-plane/setup-config.schema.json +3 -3
- package/src/control-plane/setup-status.ts +6 -1
- package/src/control-plane/setup-validation.ts +2 -2
- package/src/control-plane/setup.test.ts +42 -20
- package/src/control-plane/setup.ts +25 -41
- package/src/control-plane/spec-to-env.test.ts +30 -16
- package/src/control-plane/spec-to-env.ts +37 -21
- package/src/control-plane/stack-spec.test.ts +5 -11
- package/src/control-plane/stack-spec.ts +2 -6
- package/src/control-plane/types.ts +0 -22
- package/src/control-plane/ui-assets.ts +45 -9
- package/src/control-plane/validate.ts +1 -1
- package/src/index.ts +26 -13
- package/src/logger.test.ts +12 -12
- package/src/logger.ts +1 -1
- package/src/control-plane/admin-token.ts +0 -73
- package/src/control-plane/audit.ts +0 -41
- package/src/control-plane/lock.test.ts +0 -194
- package/src/control-plane/lock.ts +0 -176
- package/src/control-plane/provider-config.ts +0 -34
- package/src/control-plane/secret-backend.test.ts +0 -349
- package/src/control-plane/secret-backend.ts +0 -362
- 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.
|
|
3
|
+
"version": "0.11.0-beta.11",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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" &&
|
|
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>)
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
|
131
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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",
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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,
|
|
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,
|
|
328
|
+
return run(args, undefined, PULL_TIMEOUT_MS, collectEnvOverrides(options.envFiles));
|
|
318
329
|
}
|
|
319
330
|
|
|
320
331
|
/**
|
package/src/control-plane/env.ts
CHANGED
|
@@ -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
|
}
|