@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpalm/lib",
3
- "version": "0.9.6",
3
+ "version": "0.9.7",
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",
@@ -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: 'qdrant',
146
+ provider: 'sqlite-vec',
147
147
  config: {
148
148
  collection_name: 'memory',
149
- path: '/data/qdrant',
149
+ db_path: '/data/memory.db',
150
150
  embedding_model_dims: input.embeddingDims,
151
151
  },
152
152
  },
@@ -9,6 +9,7 @@ export interface CoreAssetProvider {
9
9
  coreCompose(): string;
10
10
  caddyfile(): string;
11
11
  ollamaCompose(): string;
12
+ adminCompose(): string;
12
13
  agentsMd(): string;
13
14
  opencodeConfig(): string;
14
15
  adminOpencodeConfig(): string;
@@ -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
- const trimmed = line.trim();
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
+ });
@@ -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
 
@@ -27,6 +27,10 @@ export class FilesystemAssetProvider implements CoreAssetProvider {
27
27
  return this.read("ollama.yml");
28
28
  }
29
29
 
30
+ adminCompose(): string {
31
+ return this.read("admin.yml");
32
+ }
33
+
30
34
  agentsMd(): string {
31
35
  return this.read("assistant/AGENTS.md");
32
36
  }