@openpalm/lib 0.9.6 → 0.9.7
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/package.json +1 -1
- package/src/control-plane/channels.ts +3 -0
- package/src/control-plane/connection-mapping.ts +2 -2
- package/src/control-plane/core-asset-provider.ts +1 -0
- package/src/control-plane/core-assets.ts +28 -0
- package/src/control-plane/docker.ts +2 -1
- package/src/control-plane/env.test.ts +109 -0
- package/src/control-plane/env.ts +2 -2
- package/src/control-plane/fs-asset-provider.ts +4 -0
- package/src/control-plane/install-edge-cases.test.ts +1214 -0
- package/src/control-plane/lifecycle.ts +11 -2
- package/src/control-plane/model-runner.ts +27 -2
- package/src/control-plane/setup-status.ts +1 -1
- package/src/control-plane/setup.test.ts +720 -1
- package/src/control-plane/setup.ts +597 -115
- package/src/control-plane/stack-spec.ts +64 -0
- package/src/control-plane/staging.ts +29 -6
- package/src/control-plane/types.ts +2 -3
- package/src/index.ts +30 -0
- package/src/provider-constants.ts +13 -2
package/package.json
CHANGED
|
@@ -64,6 +64,9 @@ export function isAllowedService(value: string, stateDir?: string): boolean {
|
|
|
64
64
|
if (value === "ollama" && stateDir) {
|
|
65
65
|
return existsSync(`${stateDir}/artifacts/ollama.yml`);
|
|
66
66
|
}
|
|
67
|
+
if ((value === "admin" || value === "caddy" || value === "docker-socket-proxy") && stateDir) {
|
|
68
|
+
return existsSync(`${stateDir}/artifacts/admin.yml`);
|
|
69
|
+
}
|
|
67
70
|
if (value.startsWith("channel-")) {
|
|
68
71
|
const ch = value.slice("channel-".length);
|
|
69
72
|
if (!isValidChannelName(ch)) return false;
|
|
@@ -143,10 +143,10 @@ export function buildMem0Mapping(input: Mem0ConnectionMappingInput): Mem0Connect
|
|
|
143
143
|
config: embedConfig,
|
|
144
144
|
},
|
|
145
145
|
vector_store: {
|
|
146
|
-
provider: '
|
|
146
|
+
provider: 'sqlite-vec',
|
|
147
147
|
config: {
|
|
148
148
|
collection_name: 'memory',
|
|
149
|
-
|
|
149
|
+
db_path: '/data/memory.db',
|
|
150
150
|
embedding_model_dims: input.embeddingDims,
|
|
151
151
|
},
|
|
152
152
|
},
|
|
@@ -193,6 +193,33 @@ export function readOllamaCompose(assets: CoreAssetProvider): string {
|
|
|
193
193
|
return readFileSync(path, "utf-8");
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
// ── Admin Compose Overlay (DATA_HOME source of truth) ────────────────
|
|
197
|
+
|
|
198
|
+
function adminComposePath(): string {
|
|
199
|
+
return `${resolveDataHome()}/admin.yml`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function ensureAdminCompose(assets: CoreAssetProvider): string {
|
|
203
|
+
const path = adminComposePath();
|
|
204
|
+
const content = assets.adminCompose();
|
|
205
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
206
|
+
if (!existsSync(path)) {
|
|
207
|
+
writeFileSync(path, content);
|
|
208
|
+
} else if (sha256(readFileSync(path, "utf-8")) !== sha256(content)) {
|
|
209
|
+
const backupDir = join(dirname(path), "backups");
|
|
210
|
+
mkdirSync(backupDir, { recursive: true });
|
|
211
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
212
|
+
copyFileSync(path, join(backupDir, `admin.${ts}.yml`));
|
|
213
|
+
writeFileSync(path, content);
|
|
214
|
+
}
|
|
215
|
+
return path;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function readAdminCompose(assets: CoreAssetProvider): string {
|
|
219
|
+
const path = ensureAdminCompose(assets);
|
|
220
|
+
return readFileSync(path, "utf-8");
|
|
221
|
+
}
|
|
222
|
+
|
|
196
223
|
// ── OpenCode System Config (DATA_HOME source of truth) ──────────────
|
|
197
224
|
|
|
198
225
|
export function ensureOpenCodeSystemConfig(assets: CoreAssetProvider): void {
|
|
@@ -238,6 +265,7 @@ const MANAGED_ASSETS: { dataRelPath: string; githubFilename: string }[] = [
|
|
|
238
265
|
{ dataRelPath: "admin/opencode.jsonc", githubFilename: "admin-opencode.jsonc" },
|
|
239
266
|
{ dataRelPath: "assistant/AGENTS.md", githubFilename: "AGENTS.md" },
|
|
240
267
|
{ dataRelPath: "ollama.yml", githubFilename: "ollama.yml" },
|
|
268
|
+
{ dataRelPath: "admin.yml", githubFilename: "admin.yml" },
|
|
241
269
|
{ dataRelPath: "secrets.env.schema", githubFilename: "secrets.env.schema" },
|
|
242
270
|
{ dataRelPath: "stack.env.schema", githubFilename: "stack.env.schema" }
|
|
243
271
|
];
|
|
@@ -27,8 +27,9 @@ function parseEnvFile(path: string): Record<string, string> {
|
|
|
27
27
|
try {
|
|
28
28
|
const content = readFileSync(path, "utf-8");
|
|
29
29
|
for (const line of content.split("\n")) {
|
|
30
|
-
|
|
30
|
+
let trimmed = line.trim();
|
|
31
31
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
32
|
+
trimmed = trimmed.replace(/^export\s+/, '');
|
|
32
33
|
const eqIdx = trimmed.indexOf("=");
|
|
33
34
|
if (eqIdx > 0) {
|
|
34
35
|
vars[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { parseEnvContent, mergeEnvContent } from "./env.js";
|
|
3
|
+
|
|
4
|
+
// ── Special character round-trips ────────────────────────────────────────
|
|
5
|
+
// Values written by mergeEnvContent (which uses quoteEnvValue internally)
|
|
6
|
+
// must survive a parseEnvContent round-trip — the same path admin tokens
|
|
7
|
+
// and API keys follow when written to secrets.env and read back.
|
|
8
|
+
|
|
9
|
+
describe("special characters in env values", () => {
|
|
10
|
+
/** Write a value via mergeEnvContent, parse it back, assert identity. */
|
|
11
|
+
function roundTrip(key: string, value: string): string {
|
|
12
|
+
const written = mergeEnvContent("", { [key]: value });
|
|
13
|
+
const parsed = parseEnvContent(written);
|
|
14
|
+
expect(parsed[key]).toBe(value);
|
|
15
|
+
return written;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
it("round-trips values containing = (common in base64 API keys)", () => {
|
|
19
|
+
roundTrip("TOKEN", "abc=def=ghi");
|
|
20
|
+
roundTrip("TOKEN", "dGVzdA==");
|
|
21
|
+
roundTrip("TOKEN", "key=value=extra=");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("round-trips values containing $ (must not expand)", () => {
|
|
25
|
+
roundTrip("TOKEN", "price$100");
|
|
26
|
+
roundTrip("TOKEN", "$HOME/path");
|
|
27
|
+
roundTrip("TOKEN", "a]$b$c");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("round-trips values containing double quotes", () => {
|
|
31
|
+
roundTrip("TOKEN", 'say "hello"');
|
|
32
|
+
roundTrip("TOKEN", '"quoted"');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("round-trips values containing single quotes", () => {
|
|
36
|
+
roundTrip("TOKEN", "it's a token");
|
|
37
|
+
roundTrip("TOKEN", "don't stop");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("round-trips values containing newlines", () => {
|
|
41
|
+
roundTrip("CERT", "line1\nline2");
|
|
42
|
+
roundTrip("CERT", "a\nb\nc");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("round-trips values with + and / (base64 characters)", () => {
|
|
46
|
+
roundTrip("KEY", "abc+def/ghi=");
|
|
47
|
+
roundTrip("KEY", "sk-proj-A1b2C3+xyz/ZZZ==");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("round-trips realistic API key with special chars", () => {
|
|
51
|
+
roundTrip("OPENAI_API_KEY", "sk-proj-abc123+def/456==");
|
|
52
|
+
roundTrip("ANTHROPIC_API_KEY", "sk-ant-api03-Abc$Def=Ghi");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ── quoteEnvValue quoting strategy ───────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
describe("quoteEnvValue quoting strategy (via mergeEnvContent)", () => {
|
|
59
|
+
it("does not quote simple values", () => {
|
|
60
|
+
const result = mergeEnvContent("", { KEY: "simple123" });
|
|
61
|
+
expect(result).toContain("KEY=simple123");
|
|
62
|
+
expect(result).not.toMatch(/KEY=["']/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("single-quotes values with # (no single quote in value)", () => {
|
|
66
|
+
const result = mergeEnvContent("", { KEY: "val#ue" });
|
|
67
|
+
expect(result).toContain("KEY='val#ue'");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("double-quotes values with $ when no single quote present", () => {
|
|
71
|
+
const result = mergeEnvContent("", { KEY: "val$ue" });
|
|
72
|
+
// Should use single quotes (preferred) since no single quote in value
|
|
73
|
+
const parsed = parseEnvContent(result);
|
|
74
|
+
expect(parsed.KEY).toBe("val$ue");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("does not quote values that only contain =", () => {
|
|
78
|
+
// = is safe unquoted in dotenv values
|
|
79
|
+
const result = mergeEnvContent("", { KEY: "abc=def" });
|
|
80
|
+
expect(result).toContain("KEY=abc=def");
|
|
81
|
+
expect(result).not.toMatch(/KEY=["']/);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── Update-in-place with special characters ──────────────────────────────
|
|
86
|
+
|
|
87
|
+
describe("mergeEnvContent updates existing keys with special char values", () => {
|
|
88
|
+
it("updates an existing key to a value with =", () => {
|
|
89
|
+
const input = "export ADMIN_TOKEN=old_value\n";
|
|
90
|
+
const result = mergeEnvContent(input, { ADMIN_TOKEN: "new=value=here" });
|
|
91
|
+
const parsed = parseEnvContent(result);
|
|
92
|
+
expect(parsed.ADMIN_TOKEN).toBe("new=value=here");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("updates an existing key to a value with $", () => {
|
|
96
|
+
const input = "export ADMIN_TOKEN=old_value\n";
|
|
97
|
+
const result = mergeEnvContent(input, { ADMIN_TOKEN: "tok$en" });
|
|
98
|
+
const parsed = parseEnvContent(result);
|
|
99
|
+
expect(parsed.ADMIN_TOKEN).toBe("tok$en");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("preserves export prefix when updating with special chars", () => {
|
|
103
|
+
const input = "export ADMIN_TOKEN=old_value\n";
|
|
104
|
+
const result = mergeEnvContent(input, { ADMIN_TOKEN: "new#value" });
|
|
105
|
+
expect(result).toMatch(/^export ADMIN_TOKEN=/m);
|
|
106
|
+
const parsed = parseEnvContent(result);
|
|
107
|
+
expect(parsed.ADMIN_TOKEN).toBe("new#value");
|
|
108
|
+
});
|
|
109
|
+
});
|
package/src/control-plane/env.ts
CHANGED
|
@@ -16,12 +16,12 @@ export function parseEnvFile(filePath: string): Record<string, string> {
|
|
|
16
16
|
|
|
17
17
|
function quoteEnvValue(value: string): string {
|
|
18
18
|
if (value.length === 0) return '';
|
|
19
|
-
const needsQuoting = /[#"'\\\n\r]/.test(value) || value !== value.trim();
|
|
19
|
+
const needsQuoting = /[#"'\\\n\r$]/.test(value) || value !== value.trim();
|
|
20
20
|
if (!needsQuoting) return value;
|
|
21
21
|
|
|
22
22
|
if (!value.includes("'")) return `'${value}'`;
|
|
23
23
|
|
|
24
|
-
const escaped = value.replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
|
24
|
+
const escaped = value.replace(/\\/g, '\\\\').replace(/\$/g, '\\$').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
|
25
25
|
return `"${escaped}"`;
|
|
26
26
|
}
|
|
27
27
|
|