@openpalm/lib 0.10.2 → 0.11.0-beta.10

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 (63) hide show
  1. package/README.md +4 -2
  2. package/package.json +11 -3
  3. package/src/control-plane/akm-vault.test.ts +105 -0
  4. package/src/control-plane/akm-vault.ts +311 -0
  5. package/src/control-plane/channels.ts +11 -9
  6. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  7. package/src/control-plane/compose-args.test.ts +25 -33
  8. package/src/control-plane/compose-args.ts +0 -4
  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 +148 -73
  12. package/src/control-plane/core-assets.test.ts +104 -0
  13. package/src/control-plane/core-assets.ts +111 -58
  14. package/src/control-plane/docker.ts +70 -25
  15. package/src/control-plane/env.test.ts +25 -1
  16. package/src/control-plane/env.ts +84 -1
  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 +190 -292
  21. package/src/control-plane/install-lock.ts +157 -0
  22. package/src/control-plane/lifecycle.ts +65 -75
  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/operator-ids.test.ts +130 -0
  27. package/src/control-plane/operator-ids.ts +89 -0
  28. package/src/control-plane/paths.ts +80 -0
  29. package/src/control-plane/provider-models.ts +154 -0
  30. package/src/control-plane/registry-components.test.ts +105 -27
  31. package/src/control-plane/registry.test.ts +247 -51
  32. package/src/control-plane/registry.ts +404 -54
  33. package/src/control-plane/rollback.ts +17 -16
  34. package/src/control-plane/scheduler.ts +75 -262
  35. package/src/control-plane/secret-mappings.ts +4 -8
  36. package/src/control-plane/secrets.ts +97 -55
  37. package/src/control-plane/setup-config.schema.json +5 -17
  38. package/src/control-plane/setup-status.ts +9 -29
  39. package/src/control-plane/setup-validation.ts +23 -23
  40. package/src/control-plane/setup.test.ts +143 -244
  41. package/src/control-plane/setup.ts +216 -133
  42. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  43. package/src/control-plane/spec-to-env.test.ts +75 -60
  44. package/src/control-plane/spec-to-env.ts +68 -153
  45. package/src/control-plane/stack-spec.test.ts +22 -84
  46. package/src/control-plane/stack-spec.ts +9 -89
  47. package/src/control-plane/types.ts +9 -29
  48. package/src/control-plane/ui-assets.ts +385 -0
  49. package/src/control-plane/validate.ts +44 -79
  50. package/src/index.ts +102 -56
  51. package/src/logger.test.ts +228 -0
  52. package/src/logger.ts +71 -1
  53. package/src/provider-constants.ts +22 -1
  54. package/src/control-plane/audit.ts +0 -40
  55. package/src/control-plane/env-schema-validation.test.ts +0 -118
  56. package/src/control-plane/lock.test.ts +0 -194
  57. package/src/control-plane/lock.ts +0 -176
  58. package/src/control-plane/memory-config.ts +0 -298
  59. package/src/control-plane/provider-config.ts +0 -34
  60. package/src/control-plane/redact-schema.ts +0 -50
  61. package/src/control-plane/secret-backend.test.ts +0 -359
  62. package/src/control-plane/secret-backend.ts +0 -322
  63. package/src/control-plane/spec-validator.ts +0 -159
@@ -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);
@@ -265,24 +295,37 @@ export async function composeLogs(
265
295
  return run(args, undefined);
266
296
  }
267
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
+
268
307
  /**
269
308
  * Pull image for a single service.
270
309
  */
271
310
  export async function composePullService(
272
311
  service: string,
273
- options: { files: string[]; envFiles?: string[] }
312
+ options: { files: string[]; envFiles?: string[]; profiles?: string[] }
274
313
  ): Promise<DockerResult> {
314
+ await runPreflight(options);
275
315
  const args = buildComposeArgs(options);
316
+ for (const p of options.profiles ?? []) args.push("--profile", p);
276
317
  args.push("pull", service);
277
- return run(args, undefined, 300_000, collectEnvOverrides(options.envFiles));
318
+ return run(args, undefined, PULL_TIMEOUT_MS, collectEnvOverrides(options.envFiles));
278
319
  }
279
320
 
280
321
  export async function composePull(
281
- options: { files: string[]; envFiles?: string[] }
322
+ options: { files: string[]; envFiles?: string[]; profiles?: string[] }
282
323
  ): Promise<DockerResult> {
324
+ await runPreflight(options);
283
325
  const args = buildComposeArgs(options);
326
+ for (const p of options.profiles ?? []) args.push("--profile", p);
284
327
  args.push("pull");
285
- return run(args, undefined, 300_000, collectEnvOverrides(options.envFiles));
328
+ return run(args, undefined, PULL_TIMEOUT_MS, collectEnvOverrides(options.envFiles));
286
329
  }
287
330
 
288
331
  /**
@@ -315,25 +358,27 @@ export async function getDockerEvents(
315
358
  return run(args, undefined, 15_000);
316
359
  }
317
360
 
361
+
318
362
  /**
319
- * Fire-and-forget recreation of the admin container.
363
+ * Query Docker for a container's running state by name.
364
+ * Returns "running" or "stopped". Falls back to "unknown" on error.
320
365
  */
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
- }
366
+ export function inspectContainerStatus(
367
+ containerName: string
368
+ ): Promise<"running" | "stopped" | "unknown"> {
369
+ return new Promise((resolve) => {
370
+ execFile(
371
+ "docker",
372
+ ["inspect", "--format", "{{.State.Status}}", containerName],
373
+ { timeout: 5000 },
374
+ (error, stdout) => {
375
+ if (error) {
376
+ resolve("unknown");
377
+ return;
378
+ }
379
+ const status = (stdout ?? "").toString().trim();
380
+ resolve(status === "running" ? "running" : "stopped");
381
+ }
382
+ );
383
+ });
339
384
  }
@@ -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
+ });
@@ -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,10 +10,22 @@ 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
  }
16
19
 
20
+ /**
21
+ * Resolve `${VAR}` and `${VAR:-default}` patterns in a string against the
22
+ * provided variable map. Unknown vars without a default expand to an empty
23
+ * string — mirrors compose's variable substitution semantics.
24
+ */
25
+ export function expandEnvVars(input: string, vars: Record<string, string>): string {
26
+ return input.replace(/\$\{([^}:]+)(?::-([^}]*))?\}/g, (_, name, def) => vars[name] ?? def ?? '');
27
+ }
28
+
17
29
  function quoteEnvValue(value: string): string {
18
30
  if (value.length === 0) return '';
19
31
  const needsQuoting = /[#"'\\\n\r$]/.test(value) || value !== value.trim();
@@ -25,6 +37,77 @@ function quoteEnvValue(value: string): string {
25
37
  return `"${escaped}"`;
26
38
  }
27
39
 
40
+ /**
41
+ * Remove a key from .env content. Comments above the line and the
42
+ * surrounding blank-line structure are preserved exactly as written so
43
+ * round-tripping the file through this helper is non-destructive.
44
+ * If the key is absent the input is returned unchanged.
45
+ */
46
+ export function removeEnvKey(content: string, key: string): string {
47
+ const lines = content.split('\n');
48
+ const out: string[] = [];
49
+ let removed = false;
50
+ for (const line of lines) {
51
+ let testLine = line.trim();
52
+ if (testLine.startsWith('export ')) testLine = testLine.slice(7).trimStart();
53
+ const eq = testLine.indexOf('=');
54
+ if (eq > 0 && testLine.slice(0, eq).trim() === key) {
55
+ removed = true;
56
+ continue;
57
+ }
58
+ out.push(line);
59
+ }
60
+ // If we matched, drop a trailing blank line that the deletion left behind so
61
+ // the file does not accumulate empty lines on repeated edits.
62
+ if (removed && out.length > 1 && out[out.length - 1] === '' && out[out.length - 2] === '') {
63
+ out.pop();
64
+ }
65
+ return out.join('\n');
66
+ }
67
+
68
+ /**
69
+ * Upserts a key=value pair in env file content. If the key exists, replaces the line;
70
+ * otherwise appends a new line.
71
+ */
72
+ export function upsertEnvValue(content: string, key: string, value: string): string {
73
+ const escapedKey = key.replace(/[|\\{}()[\]^$+*?.-]/g, '\\$&');
74
+ const pattern = new RegExp(`^((?:export\\s+)?)${escapedKey}=.*$`, 'm');
75
+ if (pattern.test(content)) {
76
+ // Preserve the `export ` prefix if the original line had one
77
+ return content.replace(pattern, `$1${key}=${value}`);
78
+ }
79
+
80
+ const line = `${key}=${value}`;
81
+ const suffix = content.endsWith('\n') || content.length === 0 ? '' : '\n';
82
+ return `${content}${suffix}${line}\n`;
83
+ }
84
+
85
+ export const RELEASE_TAG_REGEX = /^v?\d+\.\d+\.\d+(?:[-+](?:[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*))?$/;
86
+
87
+ /**
88
+ * Normalizes a repository ref to an image tag. Returns null for non-release refs.
89
+ * E.g. "0.9.0" → "v0.9.0", "v0.9.0" → "v0.9.0", "main" → null.
90
+ */
91
+ export function resolveRequestedImageTag(repoRef: string): string | null {
92
+ const trimmed = repoRef.trim();
93
+ if (!trimmed || trimmed === 'main') return null;
94
+ if (!RELEASE_TAG_REGEX.test(trimmed)) return null;
95
+ return trimmed.startsWith('v') ? trimmed : `v${trimmed}`;
96
+ }
97
+
98
+ /**
99
+ * Reconciles the OP_IMAGE_TAG value in stack.env content.
100
+ */
101
+ export function reconcileStackEnvImageTag(
102
+ content: string,
103
+ repoRef: string,
104
+ explicitImageTag?: string,
105
+ ): string {
106
+ const desiredImageTag = explicitImageTag || resolveRequestedImageTag(repoRef);
107
+ if (!desiredImageTag) return content;
108
+ return upsertEnvValue(content, 'OP_IMAGE_TAG', desiredImageTag);
109
+ }
110
+
28
111
  export function mergeEnvContent(
29
112
  content: string,
30
113
  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
  }