@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.
Files changed (59) hide show
  1. package/README.md +2 -2
  2. package/package.json +7 -3
  3. package/src/control-plane/admin-token.ts +73 -0
  4. package/src/control-plane/akm-vault.test.ts +105 -0
  5. package/src/control-plane/akm-vault.ts +307 -0
  6. package/src/control-plane/channels.ts +3 -3
  7. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  8. package/src/control-plane/compose-args.test.ts +25 -24
  9. package/src/control-plane/compose-errors.test.ts +106 -0
  10. package/src/control-plane/compose-errors.ts +117 -0
  11. package/src/control-plane/config-persistence.ts +103 -65
  12. package/src/control-plane/core-assets.test.ts +104 -0
  13. package/src/control-plane/core-assets.ts +54 -57
  14. package/src/control-plane/docker.ts +55 -21
  15. package/src/control-plane/env.test.ts +25 -1
  16. package/src/control-plane/env.ts +80 -0
  17. package/src/control-plane/home.ts +66 -69
  18. package/src/control-plane/host-opencode.test.ts +260 -0
  19. package/src/control-plane/host-opencode.ts +229 -0
  20. package/src/control-plane/install-edge-cases.test.ts +187 -289
  21. package/src/control-plane/install-lock.ts +157 -0
  22. package/src/control-plane/lifecycle.ts +34 -65
  23. package/src/control-plane/markdown-task.ts +200 -0
  24. package/src/control-plane/migrate-0110.test.ts +177 -0
  25. package/src/control-plane/migrate-0110.ts +99 -0
  26. package/src/control-plane/paths.ts +82 -0
  27. package/src/control-plane/provider-config.ts +2 -2
  28. package/src/control-plane/provider-models.ts +154 -0
  29. package/src/control-plane/registry-components.test.ts +105 -27
  30. package/src/control-plane/registry.test.ts +49 -47
  31. package/src/control-plane/registry.ts +71 -50
  32. package/src/control-plane/rollback.ts +17 -16
  33. package/src/control-plane/scheduler.ts +75 -262
  34. package/src/control-plane/secret-backend.test.ts +98 -111
  35. package/src/control-plane/secret-backend.ts +221 -181
  36. package/src/control-plane/secret-mappings.ts +4 -8
  37. package/src/control-plane/secrets.ts +93 -51
  38. package/src/control-plane/setup-config.schema.json +5 -17
  39. package/src/control-plane/setup-status.ts +9 -29
  40. package/src/control-plane/setup-validation.ts +23 -23
  41. package/src/control-plane/setup.test.ts +138 -239
  42. package/src/control-plane/setup.ts +215 -130
  43. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  44. package/src/control-plane/spec-to-env.test.ts +59 -58
  45. package/src/control-plane/spec-to-env.ts +52 -142
  46. package/src/control-plane/spec-validator.ts +2 -99
  47. package/src/control-plane/stack-spec.test.ts +21 -77
  48. package/src/control-plane/stack-spec.ts +7 -83
  49. package/src/control-plane/types.ts +12 -28
  50. package/src/control-plane/ui-assets.ts +349 -0
  51. package/src/control-plane/validate.ts +44 -79
  52. package/src/index.ts +86 -48
  53. package/src/logger.test.ts +228 -0
  54. package/src/logger.ts +71 -1
  55. package/src/provider-constants.ts +22 -1
  56. package/src/control-plane/audit.ts +0 -40
  57. package/src/control-plane/env-schema-validation.test.ts +0 -118
  58. package/src/control-plane/memory-config.ts +0 -298
  59. 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
- * All ensure* functions verify that the expected files exist at OP_HOME.
11
- * They create directories as needed but do NOT write file content — that
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 { resolveDataDir, resolveVaultDir, resolveOpenPalmHome, resolveBackupsDir } from "./home.js";
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 = coreComposePath();
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
- const path = coreComposePath();
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 = `${resolveDataDir()}/assistant`;
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", githubFilename: ".openpalm/stack/core.compose.yml" },
91
- { relPath: "data/assistant/opencode.jsonc", githubFilename: "core/assistant/opencode/opencode.jsonc" },
92
- { relPath: "data/assistant/AGENTS.md", githubFilename: "core/assistant/opencode/AGENTS.md" },
93
- { relPath: "vault/user/user.env.schema", githubFilename: ".openpalm/vault/user/user.env.schema" },
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
- /** Resolve the Docker Compose project name. Respects OP_PROJECT_NAME env var. */
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 process.env.OP_PROJECT_NAME?.trim() || "openpalm";
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
- * Fire-and-forget recreation of the admin container.
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 selfRecreateAdmin(
322
- options: { files: string[]; envFiles?: string[] }
323
- ): void {
324
- const args = buildComposeArgs(options);
325
- args.push("--profile", "admin", "up", "-d", "--force-recreate", "--remove-orphans", "admin");
326
- try {
327
- const child = spawn("docker", args, {
328
- stdio: "ignore",
329
- detached: true,
330
- env: { ...process.env, ...collectEnvOverrides(options.envFiles) }
331
- });
332
- child.on("error", (err) => {
333
- logger.error("selfRecreateAdmin spawn error", { error: err.message });
334
- });
335
- child.unref();
336
- } catch (err) {
337
- logger.error("selfRecreateAdmin failed to spawn", { error: err instanceof Error ? err.message : String(err) });
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
+ });
@@ -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.10.0+).
2
+ * Home directory layout for the OpenPalm control plane (v0.11.0+).
3
3
  *
4
- * Replaces the XDG three-tier model with a single ~/.openpalm/ root:
5
- * config/ — user-editable, non-secret configuration
6
- * vault/ secrets boundary (user.env, system.env)
7
- * data/ service-managed persistent data
8
- * logs/ consolidated audit/debug output
9
- *
10
- * Cache and rollback data live in ~/.cache/openpalm/ (ephemeral).
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 resolveVaultDir(): string {
36
- return `${resolveOpenPalmHome()}/vault`;
36
+ export function resolveStashDir(): string {
37
+ return `${resolveOpenPalmHome()}/stash`;
37
38
  }
38
39
 
39
- export function resolveDataDir(): string {
40
- return `${resolveOpenPalmHome()}/data`;
40
+ export function resolveWorkspaceDir(): string {
41
+ return `${resolveOpenPalmHome()}/workspace`;
41
42
  }
42
43
 
43
- export function resolveLogsDir(): string {
44
- return `${resolveOpenPalmHome()}/logs`;
44
+ export function resolveCacheDir(): string {
45
+ return `${resolveOpenPalmHome()}/cache`;
45
46
  }
46
47
 
47
- export function resolveCacheHome(): string {
48
- return `${resolveHome()}/.cache/openpalm`;
48
+ export function resolveStateDir(): string {
49
+ return `${resolveOpenPalmHome()}/state`;
49
50
  }
50
51
 
51
- export function resolveRollbackDir(): string {
52
- return `${resolveCacheHome()}/rollback`;
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 `${resolveOpenPalmHome()}/registry`;
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 resolveStackDir(): string {
68
- return `${resolveOpenPalmHome()}/stack`;
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 and cache directories.
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, non-secret
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
- // vault/ — secrets boundary
96
- `${home}/vault`,
97
- `${home}/vault/stack`,
98
- `${home}/vault/user`,
99
-
100
- // data/ — service-managed persistent data
101
- `${home}/data`,
102
- `${home}/data/assistant`,
103
- `${home}/data/admin`,
104
- `${home}/data/memory`,
105
- `${home}/data/guardian`,
106
- `${home}/data/stash`,
107
-
108
- // stack/ — compose files
109
- `${home}/stack`,
110
- `${home}/stack/addons`,
111
-
112
- // registry/ — available catalog
113
- `${home}/registry`,
114
- `${home}/registry/addons`,
115
- `${home}/registry/automations`,
116
-
117
- // backups/ — user backups
118
- `${home}/backups`,
119
-
120
- // data/workspace/ — shared assistant workspace (compose: $OP_HOME/data/workspace:/work)
121
- `${home}/data/workspace`,
122
-
123
- // logs/ — consolidated audit/debug
124
- `${home}/logs`,
125
- `${home}/logs/opencode`,
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
  }