@openpalm/lib 0.10.2 → 0.11.0-beta.2
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 -2
- package/package.json +7 -3
- package/src/control-plane/admin-token.ts +73 -0
- package/src/control-plane/akm-vault.test.ts +105 -0
- package/src/control-plane/akm-vault.ts +307 -0
- package/src/control-plane/channels.ts +3 -3
- package/src/control-plane/cleanup-guardrails.test.ts +8 -9
- package/src/control-plane/compose-args.test.ts +25 -24
- 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 +103 -65
- package/src/control-plane/core-assets.test.ts +104 -0
- package/src/control-plane/core-assets.ts +54 -57
- package/src/control-plane/docker.ts +55 -21
- package/src/control-plane/env.test.ts +25 -1
- package/src/control-plane/env.ts +80 -0
- package/src/control-plane/home.ts +66 -69
- package/src/control-plane/host-opencode.test.ts +260 -0
- package/src/control-plane/host-opencode.ts +229 -0
- package/src/control-plane/install-edge-cases.test.ts +187 -289
- package/src/control-plane/install-lock.ts +157 -0
- package/src/control-plane/lifecycle.ts +34 -65
- package/src/control-plane/markdown-task.ts +200 -0
- package/src/control-plane/migrate-0110.test.ts +177 -0
- package/src/control-plane/migrate-0110.ts +99 -0
- package/src/control-plane/paths.ts +82 -0
- package/src/control-plane/provider-config.ts +2 -2
- package/src/control-plane/provider-models.ts +154 -0
- package/src/control-plane/registry-components.test.ts +105 -27
- package/src/control-plane/registry.test.ts +49 -47
- package/src/control-plane/registry.ts +71 -50
- package/src/control-plane/rollback.ts +17 -16
- package/src/control-plane/scheduler.ts +75 -262
- package/src/control-plane/secret-backend.test.ts +98 -111
- package/src/control-plane/secret-backend.ts +221 -181
- package/src/control-plane/secret-mappings.ts +4 -8
- package/src/control-plane/secrets.ts +93 -51
- package/src/control-plane/setup-config.schema.json +5 -17
- package/src/control-plane/setup-status.ts +9 -29
- package/src/control-plane/setup-validation.ts +23 -23
- package/src/control-plane/setup.test.ts +138 -239
- package/src/control-plane/setup.ts +215 -130
- package/src/control-plane/skeleton-guardrail.test.ts +151 -0
- package/src/control-plane/spec-to-env.test.ts +59 -58
- package/src/control-plane/spec-to-env.ts +52 -142
- package/src/control-plane/spec-validator.ts +2 -99
- package/src/control-plane/stack-spec.test.ts +21 -77
- package/src/control-plane/stack-spec.ts +7 -83
- package/src/control-plane/types.ts +12 -28
- package/src/control-plane/ui-assets.ts +349 -0
- package/src/control-plane/validate.ts +44 -79
- package/src/index.ts +86 -48
- package/src/logger.test.ts +228 -0
- package/src/logger.ts +71 -1
- package/src/provider-constants.ts +22 -1
- package/src/control-plane/audit.ts +0 -40
- package/src/control-plane/env-schema-validation.test.ts +0 -118
- package/src/control-plane/memory-config.ts +0 -298
- package/src/control-plane/redact-schema.ts +0 -50
|
@@ -3,95 +3,92 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Manages source-of-truth files for the ~/.openpalm/ layout:
|
|
5
5
|
* stack/ — compose runtime assets (core.compose.yml)
|
|
6
|
-
* vault/ — env schemas
|
|
7
6
|
*
|
|
8
7
|
* This module manages runtime-owned core files only.
|
|
9
8
|
* Registry catalog refresh is handled separately in registry.ts.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* is the responsibility of `refreshCoreAssets()` (GitHub download) or
|
|
13
|
-
* the CLI install command (which downloads assets before calling setup).
|
|
9
|
+
* Env validation has moved to `akm vault` + the in-house redactor — the
|
|
10
|
+
* historical `.env.schema` files (varlock format) were retired in #391.
|
|
14
11
|
*/
|
|
15
12
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, copyFileSync } from "node:fs";
|
|
16
|
-
import { dirname, join } from "node:path";
|
|
17
|
-
import {
|
|
13
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
14
|
+
import { resolveStateDir, resolveOpenPalmHome, resolveBackupsDir, resolveStashDir } from "./home.js";
|
|
18
15
|
import { createLogger } from "../logger.js";
|
|
19
16
|
import { sha256 } from "./crypto.js";
|
|
20
17
|
|
|
21
18
|
const logger = createLogger("core-assets");
|
|
22
19
|
|
|
23
|
-
// ── Env Schema Files (vault/) ────────────────────────────────────────
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Ensure the user env schema directory exists and return the expected
|
|
27
|
-
* schema file path. The file itself may not exist yet — it is written
|
|
28
|
-
* by refreshCoreAssets() or the CLI install command.
|
|
29
|
-
*/
|
|
30
|
-
export function ensureUserEnvSchema(): string {
|
|
31
|
-
const vaultDir = resolveVaultDir();
|
|
32
|
-
const dir = `${vaultDir}/user`;
|
|
33
|
-
mkdirSync(dir, { recursive: true });
|
|
34
|
-
const path = `${dir}/user.env.schema`;
|
|
35
|
-
return path;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Ensure the system env schema directory exists and return the expected
|
|
40
|
-
* schema file path. The file itself may not exist yet — it is written
|
|
41
|
-
* by refreshCoreAssets() or the CLI install command.
|
|
42
|
-
*/
|
|
43
|
-
export function ensureSystemEnvSchema(): string {
|
|
44
|
-
const vaultDir = resolveVaultDir();
|
|
45
|
-
const dir = `${vaultDir}/stack`;
|
|
46
|
-
mkdirSync(dir, { recursive: true });
|
|
47
|
-
const path = `${dir}/stack.env.schema`;
|
|
48
|
-
return path;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// ── Memory data directory ────────────────────────────────────────────
|
|
52
|
-
|
|
53
|
-
export function ensureMemoryDir(dataDir?: string): string {
|
|
54
|
-
const resolved = dataDir ?? resolveDataDir();
|
|
55
|
-
const dir = `${resolved}/memory`;
|
|
56
|
-
mkdirSync(dir, { recursive: true });
|
|
57
|
-
return dir;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
20
|
// ── Core Compose (stack/) ─────────────────────────────────────────────
|
|
61
21
|
|
|
62
|
-
function coreComposePath(): string {
|
|
63
|
-
return `${resolveOpenPalmHome()}/stack/core.compose.yml`;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
22
|
export function ensureCoreCompose(): string {
|
|
67
|
-
const path =
|
|
23
|
+
const path = `${resolveOpenPalmHome()}/config/stack/core.compose.yml`;
|
|
68
24
|
mkdirSync(dirname(path), { recursive: true });
|
|
69
25
|
return path;
|
|
70
26
|
}
|
|
71
27
|
|
|
72
28
|
export function readCoreCompose(): string {
|
|
73
|
-
|
|
74
|
-
return readFileSync(path, "utf-8");
|
|
29
|
+
return readFileSync(`${resolveOpenPalmHome()}/config/stack/core.compose.yml`, "utf-8");
|
|
75
30
|
}
|
|
76
31
|
|
|
77
32
|
// ── OpenCode System Config ──────────────────────────────────────────
|
|
78
33
|
|
|
79
34
|
export function ensureOpenCodeSystemConfig(): void {
|
|
80
|
-
const dir = `${
|
|
35
|
+
const dir = `${resolveStateDir()}/assistant`;
|
|
81
36
|
mkdirSync(dir, { recursive: true });
|
|
82
37
|
}
|
|
83
38
|
|
|
39
|
+
// ── Shared akm stash (skills / commands / agents) ────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Seed the shared akm stash with built-in skills / commands / agents.
|
|
43
|
+
*
|
|
44
|
+
* Idempotent: **never overwrites** an existing file — user edits to a
|
|
45
|
+
* seeded asset always win, which preserves the same "config doesn't
|
|
46
|
+
* overwrite user edits" contract that governs the rest of OP_HOME.
|
|
47
|
+
*
|
|
48
|
+
* Returns the list of stash-relative paths that were actually written
|
|
49
|
+
* (empty on re-run when every seed already exists on disk).
|
|
50
|
+
*
|
|
51
|
+
* `seeds` is a map of stash-relative path → file content. Keys MUST be
|
|
52
|
+
* forward-slash relative paths that stay inside `data/stash/`; any key
|
|
53
|
+
* that escapes the stash directory after canonicalization throws,
|
|
54
|
+
* preventing a malicious caller from writing arbitrary files. Source of
|
|
55
|
+
* truth for the seeded files lives at `.openpalm/stash/` in the
|
|
56
|
+
* repo; the CLI embeds them at build time and passes the embedded
|
|
57
|
+
* record directly.
|
|
58
|
+
*/
|
|
59
|
+
export function seedStashAssets(seeds: Record<string, string>): string[] {
|
|
60
|
+
const stashDir = resolveStashDir();
|
|
61
|
+
const normalizedStash = resolve(stashDir);
|
|
62
|
+
const written: string[] = [];
|
|
63
|
+
for (const [relPath, content] of Object.entries(seeds)) {
|
|
64
|
+
const targetPath = join(stashDir, relPath);
|
|
65
|
+
const normalizedTarget = resolve(targetPath);
|
|
66
|
+
if (
|
|
67
|
+
normalizedTarget !== normalizedStash &&
|
|
68
|
+
!normalizedTarget.startsWith(normalizedStash + sep)
|
|
69
|
+
) {
|
|
70
|
+
throw new Error(`Seed path escapes stash dir: ${relPath}`);
|
|
71
|
+
}
|
|
72
|
+
if (existsSync(targetPath)) continue;
|
|
73
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
74
|
+
writeFileSync(targetPath, content);
|
|
75
|
+
written.push(relPath);
|
|
76
|
+
}
|
|
77
|
+
return written;
|
|
78
|
+
}
|
|
79
|
+
|
|
84
80
|
// ── Asset Refresh (GitHub download) ──────────────────────────────────
|
|
85
81
|
|
|
86
82
|
const REPO = "itlackey/openpalm";
|
|
87
83
|
const VERSION = process.env.OP_ASSET_VERSION ?? "main";
|
|
88
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).
|
|
89
87
|
const MANAGED_ASSETS: { relPath: string; githubFilename: string }[] = [
|
|
90
|
-
{ relPath: "stack/core.compose.yml",
|
|
91
|
-
{ relPath: "
|
|
92
|
-
{ relPath: "
|
|
93
|
-
{ relPath: "
|
|
94
|
-
{ relPath: "vault/stack/stack.env.schema", githubFilename: ".openpalm/vault/stack/stack.env.schema" },
|
|
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" },
|
|
95
92
|
];
|
|
96
93
|
|
|
97
94
|
async function downloadAsset(filename: string): Promise<string> {
|
|
@@ -37,9 +37,16 @@ function run(
|
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the Docker Compose project name.
|
|
42
|
+
* Honors COMPOSE_PROJECT_NAME (Docker standard) and OP_PROJECT_NAME (legacy).
|
|
43
|
+
*/
|
|
41
44
|
export function resolveComposeProjectName(): string {
|
|
42
|
-
return
|
|
45
|
+
return (
|
|
46
|
+
process.env.OP_PROJECT_NAME?.trim() ||
|
|
47
|
+
process.env.COMPOSE_PROJECT_NAME?.trim() ||
|
|
48
|
+
"openpalm"
|
|
49
|
+
);
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
/** Check if Docker is available */
|
|
@@ -108,6 +115,24 @@ export async function composePreflight(
|
|
|
108
115
|
return run(args, undefined, 30_000, collectEnvOverrides(options.envFiles));
|
|
109
116
|
}
|
|
110
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Run compose config preflight validation before any mutation.
|
|
120
|
+
* Skipped when OP_SKIP_COMPOSE_PREFLIGHT is set (tests, CI).
|
|
121
|
+
*/
|
|
122
|
+
async function runPreflight(options: { files: string[]; envFiles?: string[] }): Promise<void> {
|
|
123
|
+
if (options.files.length === 0 || process.env.OP_SKIP_COMPOSE_PREFLIGHT) return;
|
|
124
|
+
const result = await composePreflight(options);
|
|
125
|
+
if (!result.ok) {
|
|
126
|
+
const project = resolveComposeProjectName();
|
|
127
|
+
const fileArgs = options.files.map((f) => `-f ${f}`).join(" ");
|
|
128
|
+
const envArgs = (options.envFiles ?? []).map((f) => `--env-file ${f}`).join(" ");
|
|
129
|
+
throw new Error(
|
|
130
|
+
`Compose preflight failed: ${result.stderr}\n` +
|
|
131
|
+
`Resolved command: docker compose ${fileArgs} --project-name ${project} ${envArgs} config --quiet`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
111
136
|
export async function composeConfigServices(
|
|
112
137
|
options: { files: string[]; envFiles?: string[] }
|
|
113
138
|
): Promise<{ ok: boolean; services: string[] }> {
|
|
@@ -133,6 +158,7 @@ export async function composeUp(
|
|
|
133
158
|
removeOrphans?: boolean;
|
|
134
159
|
}
|
|
135
160
|
): Promise<DockerResult> {
|
|
161
|
+
await runPreflight(options);
|
|
136
162
|
if (!existsSync(options.files[0])) {
|
|
137
163
|
return { ok: false, stdout: "", stderr: "Compose file not found", code: 1 };
|
|
138
164
|
}
|
|
@@ -156,6 +182,7 @@ export async function composeDown(
|
|
|
156
182
|
envFiles?: string[];
|
|
157
183
|
}
|
|
158
184
|
): Promise<DockerResult> {
|
|
185
|
+
await runPreflight(options);
|
|
159
186
|
if (!existsSync(options.files[0])) {
|
|
160
187
|
return { ok: false, stdout: "", stderr: "Compose file not found", code: 1 };
|
|
161
188
|
}
|
|
@@ -173,6 +200,7 @@ export async function composeRestart(
|
|
|
173
200
|
services: string[],
|
|
174
201
|
options: { files: string[]; envFiles?: string[] }
|
|
175
202
|
): Promise<DockerResult> {
|
|
203
|
+
await runPreflight(options);
|
|
176
204
|
const primaryFile = options.files[0];
|
|
177
205
|
if (!existsSync(primaryFile)) {
|
|
178
206
|
return {
|
|
@@ -196,6 +224,7 @@ export async function composeStop(
|
|
|
196
224
|
services: string[],
|
|
197
225
|
options: { files: string[]; envFiles?: string[] }
|
|
198
226
|
): Promise<DockerResult> {
|
|
227
|
+
await runPreflight(options);
|
|
199
228
|
const args = buildComposeArgs(options);
|
|
200
229
|
args.push("stop", ...services);
|
|
201
230
|
|
|
@@ -209,6 +238,7 @@ export async function composeStart(
|
|
|
209
238
|
services: string[],
|
|
210
239
|
options: { files: string[]; envFiles?: string[] }
|
|
211
240
|
): Promise<DockerResult> {
|
|
241
|
+
await runPreflight(options);
|
|
212
242
|
const args = buildComposeArgs(options);
|
|
213
243
|
// Use up -d for specific services to ensure they're created
|
|
214
244
|
args.push("up", "-d", ...services);
|
|
@@ -272,6 +302,7 @@ export async function composePullService(
|
|
|
272
302
|
service: string,
|
|
273
303
|
options: { files: string[]; envFiles?: string[] }
|
|
274
304
|
): Promise<DockerResult> {
|
|
305
|
+
await runPreflight(options);
|
|
275
306
|
const args = buildComposeArgs(options);
|
|
276
307
|
args.push("pull", service);
|
|
277
308
|
return run(args, undefined, 300_000, collectEnvOverrides(options.envFiles));
|
|
@@ -280,6 +311,7 @@ export async function composePullService(
|
|
|
280
311
|
export async function composePull(
|
|
281
312
|
options: { files: string[]; envFiles?: string[] }
|
|
282
313
|
): Promise<DockerResult> {
|
|
314
|
+
await runPreflight(options);
|
|
283
315
|
const args = buildComposeArgs(options);
|
|
284
316
|
args.push("pull");
|
|
285
317
|
return run(args, undefined, 300_000, collectEnvOverrides(options.envFiles));
|
|
@@ -315,25 +347,27 @@ export async function getDockerEvents(
|
|
|
315
347
|
return run(args, undefined, 15_000);
|
|
316
348
|
}
|
|
317
349
|
|
|
350
|
+
|
|
318
351
|
/**
|
|
319
|
-
*
|
|
352
|
+
* Query Docker for a container's running state by name.
|
|
353
|
+
* Returns "running" or "stopped". Falls back to "unknown" on error.
|
|
320
354
|
*/
|
|
321
|
-
export function
|
|
322
|
-
|
|
323
|
-
):
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
}
|
|
355
|
+
export function inspectContainerStatus(
|
|
356
|
+
containerName: string
|
|
357
|
+
): Promise<"running" | "stopped" | "unknown"> {
|
|
358
|
+
return new Promise((resolve) => {
|
|
359
|
+
execFile(
|
|
360
|
+
"docker",
|
|
361
|
+
["inspect", "--format", "{{.State.Status}}", containerName],
|
|
362
|
+
{ timeout: 5000 },
|
|
363
|
+
(error, stdout) => {
|
|
364
|
+
if (error) {
|
|
365
|
+
resolve("unknown");
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const status = (stdout ?? "").toString().trim();
|
|
369
|
+
resolve(status === "running" ? "running" : "stopped");
|
|
370
|
+
}
|
|
371
|
+
);
|
|
372
|
+
});
|
|
339
373
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import { parseEnvContent, mergeEnvContent } from "./env.js";
|
|
2
|
+
import { parseEnvContent, mergeEnvContent, removeEnvKey } from "./env.js";
|
|
3
3
|
|
|
4
4
|
// ── Special character round-trips ────────────────────────────────────────
|
|
5
5
|
// Values written by mergeEnvContent (which uses quoteEnvValue internally)
|
|
@@ -107,3 +107,27 @@ describe("mergeEnvContent updates existing keys with special char values", () =>
|
|
|
107
107
|
expect(parsed.ADMIN_TOKEN).toBe("new#value");
|
|
108
108
|
});
|
|
109
109
|
});
|
|
110
|
+
|
|
111
|
+
describe("removeEnvKey", () => {
|
|
112
|
+
it("removes a simple key", () => {
|
|
113
|
+
const out = removeEnvKey("FOO=1\nBAR=2\n", "FOO");
|
|
114
|
+
expect(parseEnvContent(out)).toEqual({ BAR: "2" });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("returns content unchanged when key is absent", () => {
|
|
118
|
+
const input = "FOO=1\nBAR=2\n";
|
|
119
|
+
expect(removeEnvKey(input, "MISSING")).toBe(input);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("handles the export prefix form", () => {
|
|
123
|
+
const out = removeEnvKey("export FOO=1\nBAR=2\n", "FOO");
|
|
124
|
+
expect(parseEnvContent(out)).toEqual({ BAR: "2" });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("leaves comments above the deleted key intact", () => {
|
|
128
|
+
const out = removeEnvKey("# header comment\nFOO=1\nBAR=2\n", "FOO");
|
|
129
|
+
expect(out).toContain("# header comment");
|
|
130
|
+
expect(parseEnvContent(out).FOO).toBeUndefined();
|
|
131
|
+
expect(parseEnvContent(out).BAR).toBe("2");
|
|
132
|
+
});
|
|
133
|
+
});
|
package/src/control-plane/env.ts
CHANGED
|
@@ -14,6 +14,15 @@ export function parseEnvFile(filePath: string): Record<string, string> {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Resolve `${VAR}` and `${VAR:-default}` patterns in a string against the
|
|
19
|
+
* provided variable map. Unknown vars without a default expand to an empty
|
|
20
|
+
* string — mirrors compose's variable substitution semantics.
|
|
21
|
+
*/
|
|
22
|
+
export function expandEnvVars(input: string, vars: Record<string, string>): string {
|
|
23
|
+
return input.replace(/\$\{([^}:]+)(?::-([^}]*))?\}/g, (_, name, def) => vars[name] ?? def ?? '');
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
function quoteEnvValue(value: string): string {
|
|
18
27
|
if (value.length === 0) return '';
|
|
19
28
|
const needsQuoting = /[#"'\\\n\r$]/.test(value) || value !== value.trim();
|
|
@@ -25,6 +34,77 @@ function quoteEnvValue(value: string): string {
|
|
|
25
34
|
return `"${escaped}"`;
|
|
26
35
|
}
|
|
27
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Remove a key from .env content. Comments above the line and the
|
|
39
|
+
* surrounding blank-line structure are preserved exactly as written so
|
|
40
|
+
* round-tripping the file through this helper is non-destructive.
|
|
41
|
+
* If the key is absent the input is returned unchanged.
|
|
42
|
+
*/
|
|
43
|
+
export function removeEnvKey(content: string, key: string): string {
|
|
44
|
+
const lines = content.split('\n');
|
|
45
|
+
const out: string[] = [];
|
|
46
|
+
let removed = false;
|
|
47
|
+
for (const line of lines) {
|
|
48
|
+
let testLine = line.trim();
|
|
49
|
+
if (testLine.startsWith('export ')) testLine = testLine.slice(7).trimStart();
|
|
50
|
+
const eq = testLine.indexOf('=');
|
|
51
|
+
if (eq > 0 && testLine.slice(0, eq).trim() === key) {
|
|
52
|
+
removed = true;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
out.push(line);
|
|
56
|
+
}
|
|
57
|
+
// If we matched, drop a trailing blank line that the deletion left behind so
|
|
58
|
+
// the file does not accumulate empty lines on repeated edits.
|
|
59
|
+
if (removed && out.length > 1 && out[out.length - 1] === '' && out[out.length - 2] === '') {
|
|
60
|
+
out.pop();
|
|
61
|
+
}
|
|
62
|
+
return out.join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Upserts a key=value pair in env file content. If the key exists, replaces the line;
|
|
67
|
+
* otherwise appends a new line.
|
|
68
|
+
*/
|
|
69
|
+
export function upsertEnvValue(content: string, key: string, value: string): string {
|
|
70
|
+
const escapedKey = key.replace(/[|\\{}()[\]^$+*?.-]/g, '\\$&');
|
|
71
|
+
const pattern = new RegExp(`^((?:export\\s+)?)${escapedKey}=.*$`, 'm');
|
|
72
|
+
if (pattern.test(content)) {
|
|
73
|
+
// Preserve the `export ` prefix if the original line had one
|
|
74
|
+
return content.replace(pattern, `$1${key}=${value}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const line = `${key}=${value}`;
|
|
78
|
+
const suffix = content.endsWith('\n') || content.length === 0 ? '' : '\n';
|
|
79
|
+
return `${content}${suffix}${line}\n`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const RELEASE_TAG_REGEX = /^v?\d+\.\d+\.\d+(?:[-+](?:[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*))?$/;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Normalizes a repository ref to an image tag. Returns null for non-release refs.
|
|
86
|
+
* E.g. "0.9.0" → "v0.9.0", "v0.9.0" → "v0.9.0", "main" → null.
|
|
87
|
+
*/
|
|
88
|
+
export function resolveRequestedImageTag(repoRef: string): string | null {
|
|
89
|
+
const trimmed = repoRef.trim();
|
|
90
|
+
if (!trimmed || trimmed === 'main') return null;
|
|
91
|
+
if (!RELEASE_TAG_REGEX.test(trimmed)) return null;
|
|
92
|
+
return trimmed.startsWith('v') ? trimmed : `v${trimmed}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Reconciles the OP_IMAGE_TAG value in stack.env content.
|
|
97
|
+
*/
|
|
98
|
+
export function reconcileStackEnvImageTag(
|
|
99
|
+
content: string,
|
|
100
|
+
repoRef: string,
|
|
101
|
+
explicitImageTag?: string,
|
|
102
|
+
): string {
|
|
103
|
+
const desiredImageTag = explicitImageTag || resolveRequestedImageTag(repoRef);
|
|
104
|
+
if (!desiredImageTag) return content;
|
|
105
|
+
return upsertEnvValue(content, 'OP_IMAGE_TAG', desiredImageTag);
|
|
106
|
+
}
|
|
107
|
+
|
|
28
108
|
export function mergeEnvContent(
|
|
29
109
|
content: string,
|
|
30
110
|
updates: Record<string, string>,
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Home directory layout for the OpenPalm control plane (v0.
|
|
2
|
+
* Home directory layout for the OpenPalm control plane (v0.11.0+).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* config/
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* Single ~/.openpalm/ root:
|
|
5
|
+
* config/ — user-editable config + system config files (auth.json, akm/)
|
|
6
|
+
* config/stack/ — compose runtime + stack config (stack.env, guardian.env, stack.yml, addons/)
|
|
7
|
+
* cache/ — regenerable/semi-persistent data (akm cache, guardian cache, rollback)
|
|
8
|
+
* state/ — persistent service data (assistant, admin, guardian, logs, backups, registry)
|
|
9
|
+
* stash/ — akm knowledge (skills, vaults, agents)
|
|
10
|
+
* workspace/ — shared assistant work area
|
|
11
|
+
* config/stack/ — compose runtime assets + stack config (stack.env, guardian.env, stack.yml)
|
|
11
12
|
*/
|
|
12
13
|
import { mkdirSync } from "node:fs";
|
|
13
14
|
import { homedir, tmpdir } from "node:os";
|
|
@@ -32,28 +33,37 @@ export function resolveConfigDir(): string {
|
|
|
32
33
|
return `${resolveOpenPalmHome()}/config`;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
export function
|
|
36
|
-
return `${resolveOpenPalmHome()}/
|
|
36
|
+
export function resolveStashDir(): string {
|
|
37
|
+
return `${resolveOpenPalmHome()}/stash`;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
export function
|
|
40
|
-
return `${resolveOpenPalmHome()}/
|
|
40
|
+
export function resolveWorkspaceDir(): string {
|
|
41
|
+
return `${resolveOpenPalmHome()}/workspace`;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
export function
|
|
44
|
-
return `${resolveOpenPalmHome()}/
|
|
44
|
+
export function resolveCacheDir(): string {
|
|
45
|
+
return `${resolveOpenPalmHome()}/cache`;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
export function
|
|
48
|
-
return `${
|
|
48
|
+
export function resolveStateDir(): string {
|
|
49
|
+
return `${resolveOpenPalmHome()}/state`;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
export function
|
|
52
|
-
return `${
|
|
52
|
+
export function resolveStackDir(): string {
|
|
53
|
+
return `${resolveConfigDir()}/stack`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Derived from stateDir — used by registry.ts, rollback.ts, backup.ts, core-assets.ts
|
|
57
|
+
export function resolveLogsDir(): string {
|
|
58
|
+
return `${resolveStateDir()}/logs`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resolveBackupsDir(): string {
|
|
62
|
+
return `${resolveStateDir()}/backups`;
|
|
53
63
|
}
|
|
54
64
|
|
|
55
65
|
export function resolveRegistryDir(): string {
|
|
56
|
-
return `${
|
|
66
|
+
return `${resolveStateDir()}/registry`;
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
export function resolveRegistryAddonsDir(): string {
|
|
@@ -64,69 +74,56 @@ export function resolveRegistryAutomationsDir(): string {
|
|
|
64
74
|
return `${resolveRegistryDir()}/automations`;
|
|
65
75
|
}
|
|
66
76
|
|
|
67
|
-
export function
|
|
68
|
-
return `${
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function resolveBackupsDir(): string {
|
|
72
|
-
return `${resolveOpenPalmHome()}/backups`;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function resolveWorkspaceDir(): string {
|
|
76
|
-
return `${resolveOpenPalmHome()}/data/workspace`;
|
|
77
|
+
export function resolveRollbackDir(): string {
|
|
78
|
+
return `${resolveCacheDir()}/rollback`;
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
// ── Directory Setup ──────────────────────────────────────────────────
|
|
80
82
|
|
|
81
83
|
/**
|
|
82
|
-
* Create the full ~/.openpalm/ directory tree
|
|
84
|
+
* Create the full ~/.openpalm/ directory tree.
|
|
83
85
|
*/
|
|
84
86
|
export function ensureHomeDirs(): void {
|
|
85
87
|
const home = resolveOpenPalmHome();
|
|
86
|
-
const cache = resolveCacheHome();
|
|
87
88
|
|
|
88
89
|
for (const dir of [
|
|
89
|
-
// config/ — user-editable
|
|
90
|
+
// config/ — user-editable config + system config files
|
|
90
91
|
`${home}/config`,
|
|
91
|
-
`${home}/config/automations`,
|
|
92
92
|
`${home}/config/assistant`,
|
|
93
93
|
`${home}/config/guardian`,
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
`${home}/
|
|
98
|
-
`${home}/
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
`${home}/
|
|
103
|
-
`${home}/
|
|
104
|
-
`${home}/
|
|
105
|
-
`${home}/
|
|
106
|
-
`${home}/
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
`${home}/
|
|
110
|
-
`${home}/
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
`${home}/registry`,
|
|
114
|
-
`${home}/registry/
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
`${home}/
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
`${home}/
|
|
126
|
-
|
|
127
|
-
// cache/ — ephemeral, regenerable
|
|
128
|
-
cache,
|
|
129
|
-
`${cache}/rollback`,
|
|
94
|
+
`${home}/config/akm`, // AKM_CONFIG_DIR — akm setup config.json lives here
|
|
95
|
+
|
|
96
|
+
// cache/ — regenerable/semi-persistent data
|
|
97
|
+
`${home}/cache`,
|
|
98
|
+
`${home}/cache/akm`, // akm registry index, downloaded artifacts
|
|
99
|
+
`${home}/cache/rollback`, // rollback snapshots
|
|
100
|
+
|
|
101
|
+
// state/ — persistent service data
|
|
102
|
+
`${home}/state`,
|
|
103
|
+
`${home}/state/assistant`, // assistant HOME bind mount
|
|
104
|
+
`${home}/state/admin`, // admin home bind mount
|
|
105
|
+
`${home}/state/guardian`, // guardian runtime data
|
|
106
|
+
`${home}/state/akm`, // shared akm operational data (NOT config)
|
|
107
|
+
`${home}/state/akm/data`,
|
|
108
|
+
`${home}/state/akm/state`,
|
|
109
|
+
`${home}/state/logs`,
|
|
110
|
+
`${home}/state/logs/opencode`,
|
|
111
|
+
`${home}/state/backups`,
|
|
112
|
+
`${home}/state/registry`,
|
|
113
|
+
`${home}/state/registry/addons`,
|
|
114
|
+
`${home}/state/registry/automations`,
|
|
115
|
+
|
|
116
|
+
// stash/ — akm knowledge (skills, vaults, agents); stash/tasks/ for scheduled automations
|
|
117
|
+
`${home}/stash`,
|
|
118
|
+
`${home}/stash/vaults`,
|
|
119
|
+
`${home}/stash/tasks`,
|
|
120
|
+
|
|
121
|
+
// workspace/ — shared assistant work area
|
|
122
|
+
`${home}/workspace`,
|
|
123
|
+
|
|
124
|
+
// config/stack/ — compose runtime (addon overlays + stack config files)
|
|
125
|
+
`${home}/config/stack`,
|
|
126
|
+
`${home}/config/stack/addons`,
|
|
130
127
|
]) {
|
|
131
128
|
mkdirSync(dir, { recursive: true });
|
|
132
129
|
}
|