@openpalm/lib 0.11.0-beta.11 → 0.11.0-beta.14

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 (54) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/control-plane/akm-user-env.test.ts +113 -0
  4. package/src/control-plane/akm-user-env.ts +144 -0
  5. package/src/control-plane/backup.ts +14 -5
  6. package/src/control-plane/channels.ts +48 -29
  7. package/src/control-plane/cleanup-guardrails.test.ts +1 -1
  8. package/src/control-plane/compose-args.test.ts +90 -31
  9. package/src/control-plane/compose-args.ts +119 -9
  10. package/src/control-plane/config-persistence.ts +87 -133
  11. package/src/control-plane/core-assets.test.ts +9 -9
  12. package/src/control-plane/core-assets.ts +24 -8
  13. package/src/control-plane/docker.ts +15 -14
  14. package/src/control-plane/env.test.ts +10 -10
  15. package/src/control-plane/env.ts +1 -1
  16. package/src/control-plane/extends-support.test.ts +8 -8
  17. package/src/control-plane/home.ts +34 -46
  18. package/src/control-plane/host-opencode.test.ts +82 -10
  19. package/src/control-plane/host-opencode.ts +42 -13
  20. package/src/control-plane/install-edge-cases.test.ts +94 -102
  21. package/src/control-plane/install-lock.ts +7 -7
  22. package/src/control-plane/lifecycle.ts +36 -34
  23. package/src/control-plane/markdown-task.ts +30 -50
  24. package/src/control-plane/paths.ts +62 -42
  25. package/src/control-plane/profile-ids.ts +21 -0
  26. package/src/control-plane/provider-models.ts +3 -3
  27. package/src/control-plane/registry.test.ts +97 -88
  28. package/src/control-plane/registry.ts +142 -109
  29. package/src/control-plane/rollback.ts +8 -38
  30. package/src/control-plane/scheduler.ts +7 -7
  31. package/src/control-plane/secret-audit.test.ts +159 -0
  32. package/src/control-plane/secret-audit.ts +255 -0
  33. package/src/control-plane/secret-mappings.ts +2 -2
  34. package/src/control-plane/secrets-files.test.ts +60 -0
  35. package/src/control-plane/secrets-files.ts +66 -0
  36. package/src/control-plane/secrets.ts +113 -86
  37. package/src/control-plane/setup-config.schema.json +1 -1
  38. package/src/control-plane/setup-status.ts +6 -11
  39. package/src/control-plane/setup.test.ts +42 -40
  40. package/src/control-plane/setup.ts +36 -31
  41. package/src/control-plane/skeleton-guardrail.test.ts +64 -55
  42. package/src/control-plane/spec-to-env.test.ts +22 -17
  43. package/src/control-plane/spec-to-env.ts +7 -2
  44. package/src/control-plane/stack-spec.test.ts +10 -0
  45. package/src/control-plane/stack-spec.ts +28 -1
  46. package/src/control-plane/types.ts +2 -4
  47. package/src/control-plane/ui-assets.ts +60 -58
  48. package/src/control-plane/validate.ts +13 -15
  49. package/src/index.ts +47 -15
  50. package/src/control-plane/akm-vault.test.ts +0 -105
  51. package/src/control-plane/akm-vault.ts +0 -311
  52. package/src/control-plane/migrate-0110.test.ts +0 -177
  53. package/src/control-plane/migrate-0110.ts +0 -99
  54. package/src/control-plane/registry-components.test.ts +0 -391
@@ -5,19 +5,24 @@
5
5
  * stack/ — compose runtime assets (core.compose.yml)
6
6
  *
7
7
  * This module manages runtime-owned core files only.
8
- * Registry catalog refresh is handled separately in registry.ts.
8
+ * Addon compose bundle generation and registry catalog refresh are handled
9
+ * separately in registry.ts.
9
10
  * Env validation has moved to `akm vault` + the in-house redactor — the
10
11
  * historical `.env.schema` files (varlock format) were retired in #391.
11
12
  */
12
13
  import { mkdirSync, writeFileSync, readFileSync, existsSync, copyFileSync } from "node:fs";
13
14
  import { dirname, join, resolve, sep } from "node:path";
14
15
  import { fileURLToPath } from "node:url";
15
- import { resolveStateDir, resolveOpenPalmHome, resolveBackupsDir, resolveStashDir } from "./home.js";
16
+ import { resolveDataDir, resolveOpenPalmHome, resolveBackupsDir, resolveStashDir } from "./home.js";
16
17
  import { createLogger } from "../logger.js";
17
18
  import { sha256 } from "./crypto.js";
18
19
 
19
20
  const logger = createLogger("core-assets");
20
21
 
22
+ function bundledAssetPath(relPath: string): string {
23
+ return join(dirname(fileURLToPath(import.meta.url)), '../../../../.openpalm', relPath);
24
+ }
25
+
21
26
  // ── Core Compose (stack/) ─────────────────────────────────────────────
22
27
 
23
28
  export function ensureCoreCompose(): string {
@@ -27,13 +32,21 @@ export function ensureCoreCompose(): string {
27
32
  }
28
33
 
29
34
  export function readCoreCompose(): string {
30
- return readFileSync(`${resolveOpenPalmHome()}/config/stack/core.compose.yml`, "utf-8");
35
+ const livePath = `${resolveOpenPalmHome()}/config/stack/core.compose.yml`;
36
+ if (existsSync(livePath)) {
37
+ return readFileSync(livePath, 'utf-8');
38
+ }
39
+ return readFileSync(bundledAssetPath('config/stack/core.compose.yml'), 'utf-8');
40
+ }
41
+
42
+ export function readBundledStackAsset(name: string): string {
43
+ return readFileSync(bundledAssetPath(`config/stack/${name}`), 'utf-8');
31
44
  }
32
45
 
33
46
  // ── OpenCode System Config ──────────────────────────────────────────
34
47
 
35
48
  export function ensureOpenCodeSystemConfig(): void {
36
- const dir = `${resolveStateDir()}/assistant`;
49
+ const dir = `${resolveDataDir()}/assistant`;
37
50
  mkdirSync(dir, { recursive: true });
38
51
  }
39
52
 
@@ -49,11 +62,11 @@ export function ensureOpenCodeSystemConfig(): void {
49
62
  * Returns the list of stash-relative paths that were actually written
50
63
  * (empty on re-run when every seed already exists on disk).
51
64
  *
52
- * `seeds` is a map of stash-relative path → file content. Keys MUST be
53
- * forward-slash relative paths that stay inside `data/stash/`; any key
54
- * that escapes the stash directory after canonicalization throws,
65
+ * `seeds` is a map of knowledge-relative path → file content. Keys MUST be
66
+ * forward-slash relative paths that stay inside `knowledge/`; any key
67
+ * that escapes the knowledge directory after canonicalization throws,
55
68
  * preventing a malicious caller from writing arbitrary files. Source of
56
- * truth for the seeded files lives at `.openpalm/stash/` in the
69
+ * truth for the seeded files lives at `.openpalm/knowledge/` in the
57
70
  * repo; the CLI embeds them at build time and passes the embedded
58
71
  * record directly.
59
72
  */
@@ -100,12 +113,15 @@ const VERSION = resolveAssetVersion();
100
113
  // overwritten) via seedOpenPalmDir (skipExisting) or SEEDED_ASSETS below.
101
114
  const MANAGED_ASSETS: { relPath: string; githubFilename: string }[] = [
102
115
  { relPath: "config/stack/core.compose.yml", githubFilename: ".openpalm/config/stack/core.compose.yml" },
116
+ { relPath: "config/stack/services.compose.yml", githubFilename: ".openpalm/config/stack/services.compose.yml" },
117
+ { relPath: "config/stack/channels.compose.yml", githubFilename: ".openpalm/config/stack/channels.compose.yml" },
103
118
  ];
104
119
 
105
120
  // Seeded once — written only when the file does not exist yet.
106
121
  // User edits always win; upgrade never touches these files.
107
122
  const SEEDED_ASSETS: { relPath: string; githubFilename: string }[] = [
108
123
  { relPath: "config/assistant/opencode.jsonc", githubFilename: ".openpalm/config/assistant/opencode.jsonc" },
124
+ { relPath: "config/stack/custom.compose.yml", githubFilename: ".openpalm/config/stack/custom.compose.yml" },
109
125
  ];
110
126
 
111
127
  async function downloadAsset(filename: string): Promise<string> {
@@ -39,10 +39,12 @@ function run(
39
39
 
40
40
  /**
41
41
  * Resolve the Docker Compose project name.
42
- * Honors COMPOSE_PROJECT_NAME (Docker standard) and OP_PROJECT_NAME (legacy).
42
+ * Honors OP_PROJECT_NAME first for OpenPalm stacks, then COMPOSE_PROJECT_NAME.
43
43
  */
44
- export function resolveComposeProjectName(): string {
44
+ export function resolveComposeProjectName(envOverrides: Record<string, string> = {}): string {
45
45
  return (
46
+ envOverrides.OP_PROJECT_NAME?.trim() ||
47
+ envOverrides.COMPOSE_PROJECT_NAME?.trim() ||
46
48
  process.env.OP_PROJECT_NAME?.trim() ||
47
49
  process.env.COMPOSE_PROJECT_NAME?.trim() ||
48
50
  "openpalm"
@@ -87,12 +89,14 @@ export async function checkDockerCompose(): Promise<DockerResult> {
87
89
  });
88
90
  }
89
91
 
90
- /** Build common prefix: compose -f ... --project-name ... --env-file ... */
91
- function buildComposeArgs(options: { files: string[]; envFiles?: string[] }): string[] {
92
- const args = ["compose", ...options.files.flatMap((f) => ["-f", f]), "--project-name", resolveComposeProjectName()];
92
+ /** Build common prefix: compose -f ... --project-name ... --env-file ... --profile ... */
93
+ function buildComposeArgs(options: { files: string[]; envFiles?: string[]; profiles?: string[] }): string[] {
94
+ const envOverrides = collectEnvOverrides(options.envFiles);
95
+ const args = ["compose", ...options.files.flatMap((f) => ["-f", f]), "--project-name", resolveComposeProjectName(envOverrides)];
93
96
  for (const ef of options.envFiles ?? []) {
94
97
  if (existsSync(ef)) args.push("--env-file", ef);
95
98
  }
99
+ for (const p of options.profiles ?? []) args.push("--profile", p);
96
100
  return args;
97
101
  }
98
102
 
@@ -108,7 +112,7 @@ function collectEnvOverrides(envFiles?: string[]): Record<string, string> {
108
112
  * Must be called before any lifecycle mutation (install/apply/update).
109
113
  */
110
114
  export async function composePreflight(
111
- options: { files: string[]; envFiles?: string[] }
115
+ options: { files: string[]; envFiles?: string[]; profiles?: string[] }
112
116
  ): Promise<DockerResult> {
113
117
  const args = buildComposeArgs(options);
114
118
  args.push("config", "--quiet");
@@ -119,22 +123,23 @@ export async function composePreflight(
119
123
  * Run compose config preflight validation before any mutation.
120
124
  * Skipped when OP_SKIP_COMPOSE_PREFLIGHT is set (tests, CI).
121
125
  */
122
- async function runPreflight(options: { files: string[]; envFiles?: string[] }): Promise<void> {
126
+ async function runPreflight(options: { files: string[]; envFiles?: string[]; profiles?: string[] }): Promise<void> {
123
127
  if (options.files.length === 0 || process.env.OP_SKIP_COMPOSE_PREFLIGHT) return;
124
128
  const result = await composePreflight(options);
125
129
  if (!result.ok) {
126
- const project = resolveComposeProjectName();
130
+ const project = resolveComposeProjectName(collectEnvOverrides(options.envFiles));
127
131
  const fileArgs = options.files.map((f) => `-f ${f}`).join(" ");
128
132
  const envArgs = (options.envFiles ?? []).map((f) => `--env-file ${f}`).join(" ");
133
+ const profileArgs = (options.profiles ?? []).map((p) => `--profile ${p}`).join(" ");
129
134
  throw new Error(
130
135
  `Compose preflight failed: ${result.stderr}\n` +
131
- `Resolved command: docker compose ${fileArgs} --project-name ${project} ${envArgs} config --quiet`
136
+ `Resolved command: docker compose ${fileArgs} --project-name ${project} ${envArgs} ${profileArgs} config --quiet`
132
137
  );
133
138
  }
134
139
  }
135
140
 
136
141
  export async function composeConfigServices(
137
- options: { files: string[]; envFiles?: string[] }
142
+ options: { files: string[]; envFiles?: string[]; profiles?: string[] }
138
143
  ): Promise<{ ok: boolean; services: string[] }> {
139
144
  const args = buildComposeArgs(options);
140
145
  args.push("config", "--services");
@@ -163,7 +168,6 @@ export async function composeUp(
163
168
  return { ok: false, stdout: "", stderr: "Compose file not found", code: 1 };
164
169
  }
165
170
  const args = buildComposeArgs(options);
166
- for (const p of options.profiles ?? []) args.push("--profile", p);
167
171
  args.push("up", "-d");
168
172
  if (options.forceRecreate) args.push("--force-recreate");
169
173
  if (options.removeOrphans) args.push("--remove-orphans");
@@ -187,7 +191,6 @@ export async function composeDown(
187
191
  return { ok: false, stdout: "", stderr: "Compose file not found", code: 1 };
188
192
  }
189
193
  const args = buildComposeArgs(options);
190
- for (const p of options.profiles ?? []) args.push("--profile", p);
191
194
  args.push("down");
192
195
  if (options.removeVolumes) args.push("-v");
193
196
  return run(args, undefined);
@@ -313,7 +316,6 @@ export async function composePullService(
313
316
  ): Promise<DockerResult> {
314
317
  await runPreflight(options);
315
318
  const args = buildComposeArgs(options);
316
- for (const p of options.profiles ?? []) args.push("--profile", p);
317
319
  args.push("pull", service);
318
320
  return run(args, undefined, PULL_TIMEOUT_MS, collectEnvOverrides(options.envFiles));
319
321
  }
@@ -323,7 +325,6 @@ export async function composePull(
323
325
  ): Promise<DockerResult> {
324
326
  await runPreflight(options);
325
327
  const args = buildComposeArgs(options);
326
- for (const p of options.profiles ?? []) args.push("--profile", p);
327
328
  args.push("pull");
328
329
  return run(args, undefined, PULL_TIMEOUT_MS, collectEnvOverrides(options.envFiles));
329
330
  }
@@ -86,25 +86,25 @@ describe("quoteEnvValue quoting strategy (via mergeEnvContent)", () => {
86
86
 
87
87
  describe("mergeEnvContent updates existing keys with special char values", () => {
88
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" });
89
+ const input = "export TEST_VALUE=old_value\n";
90
+ const result = mergeEnvContent(input, { TEST_VALUE: "new=value=here" });
91
91
  const parsed = parseEnvContent(result);
92
- expect(parsed.ADMIN_TOKEN).toBe("new=value=here");
92
+ expect(parsed.TEST_VALUE).toBe("new=value=here");
93
93
  });
94
94
 
95
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" });
96
+ const input = "export TEST_VALUE=old_value\n";
97
+ const result = mergeEnvContent(input, { TEST_VALUE: "tok$en" });
98
98
  const parsed = parseEnvContent(result);
99
- expect(parsed.ADMIN_TOKEN).toBe("tok$en");
99
+ expect(parsed.TEST_VALUE).toBe("tok$en");
100
100
  });
101
101
 
102
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);
103
+ const input = "export TEST_VALUE=old_value\n";
104
+ const result = mergeEnvContent(input, { TEST_VALUE: "new#value" });
105
+ expect(result).toMatch(/^export TEST_VALUE=/m);
106
106
  const parsed = parseEnvContent(result);
107
- expect(parsed.ADMIN_TOKEN).toBe("new#value");
107
+ expect(parsed.TEST_VALUE).toBe("new#value");
108
108
  });
109
109
  });
110
110
 
@@ -26,7 +26,7 @@ export function expandEnvVars(input: string, vars: Record<string, string>): stri
26
26
  return input.replace(/\$\{([^}:]+)(?::-([^}]*))?\}/g, (_, name, def) => vars[name] ?? def ?? '');
27
27
  }
28
28
 
29
- function quoteEnvValue(value: string): string {
29
+ export function quoteEnvValue(value: string): string {
30
30
  if (value.length === 0) return '';
31
31
  const needsQuoting = /[#"'\\\n\r$]/.test(value) || value !== value.trim();
32
32
  if (!needsQuoting) return value;
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Verify that Compose `extends` is supported as an optional addon pattern.
2
+ * Verify that Compose `extends` is supported in the custom compose file.
3
3
  *
4
4
  * This is a narrow smoke test proving the canonical compose resolution
5
- * works when an addon uses Compose `extends` to inherit from a base service.
5
+ * works when custom.compose.yml uses Compose `extends` to inherit from a base service.
6
6
  */
7
7
  import { describe, test, expect, beforeAll, afterAll } from "bun:test";
8
8
  import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs";
@@ -15,7 +15,7 @@ describe("compose extends support", () => {
15
15
 
16
16
  beforeAll(() => {
17
17
  fixtureDir = join(tmpdir(), `openpalm-extends-test-${Date.now()}`);
18
- mkdirSync(join(fixtureDir, "stack/addons/extended-addon"), { recursive: true });
18
+ mkdirSync(join(fixtureDir, "stack"), { recursive: true });
19
19
 
20
20
  // Write a minimal core compose
21
21
  writeFileSync(
@@ -30,9 +30,9 @@ describe("compose extends support", () => {
30
30
  ].join("\n")
31
31
  );
32
32
 
33
- // Write an addon that uses `extends`
33
+ // Write custom compose content that uses `extends`
34
34
  writeFileSync(
35
- join(fixtureDir, "stack/addons/extended-addon/compose.yml"),
35
+ join(fixtureDir, "stack/custom.compose.yml"),
36
36
  [
37
37
  "services:",
38
38
  " extended-service:",
@@ -54,16 +54,16 @@ describe("compose extends support", () => {
54
54
 
55
55
  test("fixture files exist", () => {
56
56
  expect(existsSync(join(fixtureDir, "stack/core.compose.yml"))).toBe(true);
57
- expect(existsSync(join(fixtureDir, "stack/addons/extended-addon/compose.yml"))).toBe(true);
57
+ expect(existsSync(join(fixtureDir, "stack/custom.compose.yml"))).toBe(true);
58
58
  });
59
59
 
60
- test("extends addon composes correctly with discoverStackOverlays", async () => {
60
+ test("extends custom compose works with discoverStackOverlays", async () => {
61
61
  const { discoverStackOverlays } = await import("./config-persistence.js");
62
62
  const overlays = discoverStackOverlays(join(fixtureDir, "stack"));
63
63
 
64
64
  expect(overlays.length).toBe(2);
65
65
  expect(overlays[0]).toContain("core.compose.yml");
66
- expect(overlays[1]).toContain("extended-addon/compose.yml");
66
+ expect(overlays[1]).toContain("custom.compose.yml");
67
67
  });
68
68
 
69
69
  test.skipIf(skipDockerAssertions)("extends addon passes docker compose config preflight (requires Docker)", async () => {
@@ -2,13 +2,12 @@
2
2
  * Home directory layout for the OpenPalm control plane (v0.11.0+).
3
3
  *
4
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)
5
+ * config/ — user-editable config + system config files (akm/)
6
+ * config/stack/ — compose runtime + stack config (stack.env, stack.yml, auth.json, fixed compose files)
7
+ * data/ — persistent service data, logs, backups, rollback
8
+ * knowledge/ akm knowledge (skills, vaults, agents)
10
9
  * workspace/ — shared assistant work area
11
- * config/stack/ — compose runtime assets + stack config (stack.env, guardian.env, stack.yml)
10
+ * config/stack/ — compose runtime assets + stack config (stack.env, stack.yml)
12
11
  */
13
12
  import { mkdirSync } from "node:fs";
14
13
  import { homedir, tmpdir } from "node:os";
@@ -34,36 +33,31 @@ export function resolveConfigDir(): string {
34
33
  }
35
34
 
36
35
  export function resolveStashDir(): string {
37
- return `${resolveOpenPalmHome()}/stash`;
36
+ return `${resolveOpenPalmHome()}/knowledge`;
38
37
  }
39
38
 
40
39
  export function resolveWorkspaceDir(): string {
41
40
  return `${resolveOpenPalmHome()}/workspace`;
42
41
  }
43
42
 
44
- export function resolveCacheDir(): string {
45
- return `${resolveOpenPalmHome()}/cache`;
46
- }
47
-
48
- export function resolveStateDir(): string {
49
- return `${resolveOpenPalmHome()}/state`;
43
+ export function resolveDataDir(): string {
44
+ return `${resolveOpenPalmHome()}/data`;
50
45
  }
51
46
 
52
47
  export function resolveStackDir(): string {
53
48
  return `${resolveConfigDir()}/stack`;
54
49
  }
55
50
 
56
- // Derived from stateDir — used by registry.ts, rollback.ts, backup.ts, core-assets.ts
57
51
  export function resolveLogsDir(): string {
58
- return `${resolveStateDir()}/logs`;
52
+ return `${resolveDataDir()}/logs`;
59
53
  }
60
54
 
61
55
  export function resolveBackupsDir(): string {
62
- return `${resolveStateDir()}/backups`;
56
+ return `${resolveDataDir()}/backups`;
63
57
  }
64
58
 
65
59
  export function resolveRegistryDir(): string {
66
- return `${resolveStateDir()}/registry`;
60
+ return `${resolveDataDir()}/registry`;
67
61
  }
68
62
 
69
63
  export function resolveRegistryAddonsDir(): string {
@@ -75,7 +69,7 @@ export function resolveRegistryAutomationsDir(): string {
75
69
  }
76
70
 
77
71
  export function resolveRollbackDir(): string {
78
- return `${resolveCacheDir()}/rollback`;
72
+ return `${resolveDataDir()}/rollback`;
79
73
  }
80
74
 
81
75
  // ── Directory Setup ──────────────────────────────────────────────────
@@ -91,39 +85,33 @@ export function ensureHomeDirs(): void {
91
85
  `${home}/config`,
92
86
  `${home}/config/assistant`,
93
87
  `${home}/config/guardian`,
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`,
88
+ `${home}/config/akm`, // akm XDG config directory
89
+
90
+ // data/ — persistent service data
91
+ `${home}/data`,
92
+ `${home}/data/assistant`, // assistant HOME bind mount
93
+ `${home}/data/assistant/.cache`,
94
+ `${home}/data/assistant/.local/bin`,
95
+ `${home}/data/assistant/.local/share/opencode`,
96
+ `${home}/data/assistant/.local/state/opencode`,
97
+ `${home}/data/admin`, // admin home bind mount
98
+ `${home}/data/guardian`, // guardian runtime data
99
+ `${home}/data/akm/cache`, // akm cache
100
+ `${home}/data/akm/data`, // akm durable data
101
+ `${home}/data/logs`, // service logs and audit files
102
+ `${home}/data/backups`, // lifecycle backup snapshots
103
+ `${home}/data/rollback`, // deploy rollback snapshots
104
+ // knowledge/ — akm knowledge (skills, env, secrets, agents); knowledge/tasks/ for scheduled automations
105
+ `${home}/knowledge`,
106
+ `${home}/knowledge/env`,
107
+ `${home}/knowledge/secrets`,
108
+ `${home}/knowledge/tasks`,
120
109
 
121
110
  // workspace/ — shared assistant work area
122
111
  `${home}/workspace`,
123
112
 
124
- // config/stack/ — compose runtime (addon overlays + stack config files)
113
+ // config/stack/ — compose runtime + stack config files
125
114
  `${home}/config/stack`,
126
- `${home}/config/stack/addons`,
127
115
  ]) {
128
116
  mkdirSync(dir, { recursive: true });
129
117
  }
@@ -16,10 +16,9 @@ function makeState(homeDir: string): ControlPlaneState {
16
16
  return {
17
17
  homeDir,
18
18
  configDir: join(homeDir, "config"),
19
- stashDir: join(homeDir, "stash"),
19
+ stashDir: join(homeDir, "knowledge"),
20
20
  workspaceDir: join(homeDir, "workspace"),
21
- cacheDir: join(homeDir, "cache"),
22
- stateDir: join(homeDir, "state"),
21
+ dataDir: join(homeDir, "data"),
23
22
  stackDir: join(homeDir, "config/stack"),
24
23
  services: {},
25
24
  artifacts: { compose: "" },
@@ -104,6 +103,48 @@ describe("detectHostOpenCode", () => {
104
103
  expect(status.providerCount).toBe(0);
105
104
  });
106
105
  });
106
+
107
+ it("returns modelPreferences when model and small_model are set", () => {
108
+ const configDir = join(xdgRoot, "config", "opencode");
109
+ mkdirSync(configDir, { recursive: true });
110
+ writeFileSync(join(configDir, "opencode.json"), JSON.stringify({
111
+ provider: { groq: {} },
112
+ model: "groq/llama-3.3-70b-versatile",
113
+ small_model: "groq/llama-3.1-8b-instant",
114
+ }));
115
+ withXdgEnv(`${xdgRoot}/config`, `${xdgRoot}/data`, () => {
116
+ const status = detectHostOpenCode();
117
+ expect(status.modelPreferences).toBeDefined();
118
+ expect(status.modelPreferences?.model).toBe("groq/llama-3.3-70b-versatile");
119
+ expect(status.modelPreferences?.small_model).toBe("groq/llama-3.1-8b-instant");
120
+ });
121
+ });
122
+
123
+ it("omits modelPreferences when no model fields are set", () => {
124
+ const configDir = join(xdgRoot, "config", "opencode");
125
+ mkdirSync(configDir, { recursive: true });
126
+ writeFileSync(join(configDir, "opencode.json"), JSON.stringify({
127
+ provider: { groq: {} },
128
+ }));
129
+ withXdgEnv(`${xdgRoot}/config`, `${xdgRoot}/data`, () => {
130
+ const status = detectHostOpenCode();
131
+ expect(status.modelPreferences).toBeUndefined();
132
+ });
133
+ });
134
+
135
+ it("returns partial modelPreferences when only model is set", () => {
136
+ const configDir = join(xdgRoot, "config", "opencode");
137
+ mkdirSync(configDir, { recursive: true });
138
+ writeFileSync(join(configDir, "opencode.json"), JSON.stringify({
139
+ provider: { anthropic: {} },
140
+ model: "anthropic/claude-sonnet-4-5",
141
+ }));
142
+ withXdgEnv(`${xdgRoot}/config`, `${xdgRoot}/data`, () => {
143
+ const status = detectHostOpenCode();
144
+ expect(status.modelPreferences?.model).toBe("anthropic/claude-sonnet-4-5");
145
+ expect(status.modelPreferences?.small_model).toBeUndefined();
146
+ });
147
+ });
107
148
  });
108
149
 
109
150
  // ── importHostOpenCode ────────────────────────────────────────────────────────
@@ -132,6 +173,8 @@ describe("importHostOpenCode", () => {
132
173
  writeFileSync(join(hostConfigDir, "opencode.json"), JSON.stringify({
133
174
  provider: { anthropic: { name: "Anthropic" }, groq: {} },
134
175
  model: "anthropic/claude-3-5-sonnet",
176
+ small_model: "openai/gpt-4o-mini",
177
+ disabled_providers: ["groq"],
135
178
  // These should be stripped:
136
179
  plugin: [{ module: "some-plugin" }],
137
180
  mcp: { server: {} },
@@ -153,14 +196,16 @@ describe("importHostOpenCode", () => {
153
196
  const destConfig = JSON.parse(readFileSync(join(opHome, "config", "assistant", "opencode.json"), "utf-8"));
154
197
  expect(destConfig.provider).toEqual({ anthropic: { name: "Anthropic" }, groq: {} });
155
198
  expect(destConfig.model).toBe("anthropic/claude-3-5-sonnet");
199
+ expect(destConfig.small_model).toBe("openai/gpt-4o-mini");
200
+ expect(destConfig.disabled_providers).toEqual(["groq"]);
156
201
  expect(destConfig.plugin).toBeUndefined();
157
202
  expect(destConfig.mcp).toBeUndefined();
158
203
 
159
204
  // Verify auth.json was written
160
- expect(existsSync(join(opHome, "config", "auth.json"))).toBe(true);
205
+ expect(existsSync(join(opHome, "knowledge", "secrets", "auth.json"))).toBe(true);
161
206
 
162
207
  // Verify auth.json permissions are 0o600
163
- const authStat = statSync(join(opHome, "config", "auth.json"));
208
+ const authStat = statSync(join(opHome, "knowledge", "secrets", "auth.json"));
164
209
  // On Linux, mode & 0o777 extracts permission bits
165
210
  expect(authStat.mode & 0o777).toBe(0o600);
166
211
  });
@@ -217,6 +262,34 @@ describe("importHostOpenCode", () => {
217
262
  expect(written.provider.anthropic.name).toBe("Host Anthropic");
218
263
  });
219
264
 
265
+ it("keeps existing model defaults and fills only missing host fields", () => {
266
+ const hostConfigDir = join(xdgRoot, "config", "opencode");
267
+ mkdirSync(hostConfigDir, { recursive: true });
268
+ writeFileSync(join(hostConfigDir, "opencode.json"), JSON.stringify({
269
+ provider: { openai: { name: "Host OpenAI" } },
270
+ model: "openai/gpt-4.1",
271
+ small_model: "openai/gpt-4.1-mini",
272
+ disabled_providers: ["groq"],
273
+ }));
274
+
275
+ const state = makeState(opHome);
276
+ const destDir = join(opHome, "config", "assistant");
277
+ mkdirSync(destDir, { recursive: true });
278
+ writeFileSync(join(destDir, "opencode.json"), JSON.stringify({
279
+ provider: { anthropic: { name: "Existing Anthropic" } },
280
+ model: "anthropic/claude-sonnet-4",
281
+ }));
282
+
283
+ withXdgEnv(`${xdgRoot}/config`, `${xdgRoot}/data`, () => {
284
+ importHostOpenCode(state);
285
+ });
286
+
287
+ const written = JSON.parse(readFileSync(join(destDir, "opencode.json"), "utf-8"));
288
+ expect(written.model).toBe("anthropic/claude-sonnet-4");
289
+ expect(written.small_model).toBe("openai/gpt-4.1-mini");
290
+ expect(written.disabled_providers).toEqual(["groq"]);
291
+ });
292
+
220
293
  it("returns zero counts when no host config is present", () => {
221
294
  const state = makeState(opHome);
222
295
  withXdgEnv(`${xdgRoot}/config`, `${xdgRoot}/data`, () => {
@@ -228,10 +301,9 @@ describe("importHostOpenCode", () => {
228
301
  });
229
302
 
230
303
  it("partial-merge auth: does not overwrite existing credential, adds new one", () => {
231
- // Pre-seed OP_HOME/config/auth.json with one existing credential
232
- const opConfigDir = join(opHome, "config");
233
- mkdirSync(opConfigDir, { recursive: true });
234
- writeFileSync(join(opConfigDir, "auth.json"), JSON.stringify({
304
+ // Pre-seed OP_HOME/knowledge/secrets/auth.json with one existing credential
305
+ mkdirSync(join(opHome, "knowledge", "secrets"), { recursive: true });
306
+ writeFileSync(join(opHome, "knowledge", "secrets", "auth.json"), JSON.stringify({
235
307
  azure: { type: "api", key: "existing" },
236
308
  }));
237
309
 
@@ -252,7 +324,7 @@ describe("importHostOpenCode", () => {
252
324
  });
253
325
 
254
326
  // Verify azure key was NOT overwritten
255
- const written = JSON.parse(readFileSync(join(opConfigDir, "auth.json"), "utf-8")) as Record<string, { key: string }>;
327
+ const written = JSON.parse(readFileSync(join(opHome, "knowledge", "secrets", "auth.json"), "utf-8")) as Record<string, { key: string }>;
256
328
  expect(written.azure.key).toBe("existing");
257
329
  // Verify groq was added
258
330
  expect(written.groq.key).toBe("gsk-host");