@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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/control-plane/akm-user-env.test.ts +113 -0
- package/src/control-plane/akm-user-env.ts +144 -0
- package/src/control-plane/backup.ts +14 -5
- package/src/control-plane/channels.ts +48 -29
- package/src/control-plane/cleanup-guardrails.test.ts +1 -1
- package/src/control-plane/compose-args.test.ts +90 -31
- package/src/control-plane/compose-args.ts +119 -9
- package/src/control-plane/config-persistence.ts +87 -133
- package/src/control-plane/core-assets.test.ts +9 -9
- package/src/control-plane/core-assets.ts +24 -8
- package/src/control-plane/docker.ts +15 -14
- package/src/control-plane/env.test.ts +10 -10
- package/src/control-plane/env.ts +1 -1
- package/src/control-plane/extends-support.test.ts +8 -8
- package/src/control-plane/home.ts +34 -46
- package/src/control-plane/host-opencode.test.ts +82 -10
- package/src/control-plane/host-opencode.ts +42 -13
- package/src/control-plane/install-edge-cases.test.ts +94 -102
- package/src/control-plane/install-lock.ts +7 -7
- package/src/control-plane/lifecycle.ts +36 -34
- package/src/control-plane/markdown-task.ts +30 -50
- package/src/control-plane/paths.ts +62 -42
- package/src/control-plane/profile-ids.ts +21 -0
- package/src/control-plane/provider-models.ts +3 -3
- package/src/control-plane/registry.test.ts +97 -88
- package/src/control-plane/registry.ts +142 -109
- package/src/control-plane/rollback.ts +8 -38
- package/src/control-plane/scheduler.ts +7 -7
- package/src/control-plane/secret-audit.test.ts +159 -0
- package/src/control-plane/secret-audit.ts +255 -0
- package/src/control-plane/secret-mappings.ts +2 -2
- package/src/control-plane/secrets-files.test.ts +60 -0
- package/src/control-plane/secrets-files.ts +66 -0
- package/src/control-plane/secrets.ts +113 -86
- package/src/control-plane/setup-config.schema.json +1 -1
- package/src/control-plane/setup-status.ts +6 -11
- package/src/control-plane/setup.test.ts +42 -40
- package/src/control-plane/setup.ts +36 -31
- package/src/control-plane/skeleton-guardrail.test.ts +64 -55
- package/src/control-plane/spec-to-env.test.ts +22 -17
- package/src/control-plane/spec-to-env.ts +7 -2
- package/src/control-plane/stack-spec.test.ts +10 -0
- package/src/control-plane/stack-spec.ts +28 -1
- package/src/control-plane/types.ts +2 -4
- package/src/control-plane/ui-assets.ts +60 -58
- package/src/control-plane/validate.ts +13 -15
- package/src/index.ts +47 -15
- package/src/control-plane/akm-vault.test.ts +0 -105
- package/src/control-plane/akm-vault.ts +0 -311
- package/src/control-plane/migrate-0110.test.ts +0 -177
- package/src/control-plane/migrate-0110.ts +0 -99
- 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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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 = `${
|
|
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
|
|
53
|
-
* forward-slash relative paths that stay inside `
|
|
54
|
-
* that escapes the
|
|
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/
|
|
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
|
|
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
|
|
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
|
|
90
|
-
const result = mergeEnvContent(input, {
|
|
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.
|
|
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
|
|
97
|
-
const result = mergeEnvContent(input, {
|
|
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.
|
|
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
|
|
104
|
-
const result = mergeEnvContent(input, {
|
|
105
|
-
expect(result).toMatch(/^export
|
|
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.
|
|
107
|
+
expect(parsed.TEST_VALUE).toBe("new#value");
|
|
108
108
|
});
|
|
109
109
|
});
|
|
110
110
|
|
package/src/control-plane/env.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
33
|
+
// Write custom compose content that uses `extends`
|
|
34
34
|
writeFileSync(
|
|
35
|
-
join(fixtureDir, "stack/
|
|
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/
|
|
57
|
+
expect(existsSync(join(fixtureDir, "stack/custom.compose.yml"))).toBe(true);
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
test("extends
|
|
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("
|
|
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 (
|
|
6
|
-
* config/stack/ — compose runtime + stack config (stack.env,
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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,
|
|
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()}/
|
|
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
|
|
45
|
-
return `${resolveOpenPalmHome()}/
|
|
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 `${
|
|
52
|
+
return `${resolveDataDir()}/logs`;
|
|
59
53
|
}
|
|
60
54
|
|
|
61
55
|
export function resolveBackupsDir(): string {
|
|
62
|
-
return `${
|
|
56
|
+
return `${resolveDataDir()}/backups`;
|
|
63
57
|
}
|
|
64
58
|
|
|
65
59
|
export function resolveRegistryDir(): string {
|
|
66
|
-
return `${
|
|
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 `${
|
|
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`, //
|
|
95
|
-
|
|
96
|
-
//
|
|
97
|
-
`${home}/
|
|
98
|
-
`${home}/
|
|
99
|
-
`${home}/cache
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
`${home}/state`,
|
|
103
|
-
`${home}/
|
|
104
|
-
`${home}/
|
|
105
|
-
`${home}/
|
|
106
|
-
`${home}/
|
|
107
|
-
`${home}/
|
|
108
|
-
`${home}/
|
|
109
|
-
`${home}/
|
|
110
|
-
|
|
111
|
-
`${home}/
|
|
112
|
-
`${home}/
|
|
113
|
-
`${home}/
|
|
114
|
-
`${home}/
|
|
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
|
|
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, "
|
|
19
|
+
stashDir: join(homeDir, "knowledge"),
|
|
20
20
|
workspaceDir: join(homeDir, "workspace"),
|
|
21
|
-
|
|
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, "
|
|
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, "
|
|
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/
|
|
232
|
-
|
|
233
|
-
|
|
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(
|
|
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");
|