@openpalm/lib 0.11.0-beta.9 → 0.11.0-rc.1
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-sources.test.ts +206 -0
- package/src/control-plane/akm-sources.ts +234 -0
- package/src/control-plane/akm-user-env.test.ts +142 -0
- package/src/control-plane/akm-user-env.ts +167 -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 +67 -30
- package/src/control-plane/compose-args.ts +63 -8
- package/src/control-plane/config-persistence.ts +95 -136
- package/src/control-plane/core-assets.ts +21 -44
- 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/fs-atomic.ts +15 -0
- package/src/control-plane/home.ts +34 -46
- package/src/control-plane/host-akm-sharing.test.ts +145 -0
- package/src/control-plane/host-akm-sharing.ts +129 -0
- 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 +98 -105
- package/src/control-plane/install-lock.ts +7 -7
- package/src/control-plane/lifecycle.ts +37 -36
- package/src/control-plane/markdown-task.ts +30 -50
- package/src/control-plane/opencode-client.ts +1 -1
- package/src/control-plane/paths.ts +61 -46
- 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 +107 -90
- package/src/control-plane/registry.ts +288 -109
- package/src/control-plane/rollback.ts +8 -38
- package/src/control-plane/scheduler.ts +10 -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 +99 -0
- package/src/control-plane/secrets-files.ts +113 -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 +140 -44
- package/src/control-plane/setup.ts +85 -62
- package/src/control-plane/skeleton-guardrail.test.ts +64 -55
- package/src/control-plane/spec-to-env.test.ts +63 -26
- package/src/control-plane/spec-to-env.ts +49 -12
- package/src/control-plane/stack-spec.test.ts +15 -11
- package/src/control-plane/stack-spec.ts +31 -10
- package/src/control-plane/task-files.test.ts +45 -0
- package/src/control-plane/task-files.ts +51 -0
- package/src/control-plane/types.ts +2 -4
- package/src/control-plane/ui-assets.test.ts +130 -0
- package/src/control-plane/ui-assets.ts +132 -57
- package/src/control-plane/validate.ts +13 -15
- package/src/index.ts +86 -16
- package/src/control-plane/akm-vault.test.ts +0 -105
- package/src/control-plane/akm-vault.ts +0 -311
- package/src/control-plane/core-assets.test.ts +0 -104
- 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
|
@@ -11,12 +11,14 @@
|
|
|
11
11
|
* - auth.json is copied byte-for-byte and chmodded 0o600. Its contents
|
|
12
12
|
* are never parsed, logged, or returned to callers.
|
|
13
13
|
* - opencode.json is parsed to strip plugin/mcp/permission keys before
|
|
14
|
-
* writing;
|
|
14
|
+
* writing; provider definitions are always kept, and top-level model
|
|
15
|
+
* defaults are imported only when OP_HOME does not already define them.
|
|
15
16
|
* - Conflict detection compares provider IDs; existing credentials are
|
|
16
17
|
* preserved unless overwriteConflicts=true.
|
|
17
18
|
*/
|
|
18
19
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, copyFileSync } from "node:fs";
|
|
19
20
|
import { homedir } from "node:os";
|
|
21
|
+
import { dirname } from "node:path";
|
|
20
22
|
import type { ControlPlaneState } from "./types.js";
|
|
21
23
|
import { authJsonPath, assistantConfigDir } from "./paths.js";
|
|
22
24
|
|
|
@@ -31,6 +33,8 @@ export type HostOpenCodeStatus = {
|
|
|
31
33
|
providerCount: number;
|
|
32
34
|
/** Number of credential entries in auth.json (0 when not found) */
|
|
33
35
|
credentialCount: number;
|
|
36
|
+
/** Model preferences from the host's opencode.json, if present */
|
|
37
|
+
modelPreferences?: { model?: string; small_model?: string };
|
|
34
38
|
};
|
|
35
39
|
|
|
36
40
|
export type HostImportResult = {
|
|
@@ -64,7 +68,7 @@ function hostAuthJsonPath(): string {
|
|
|
64
68
|
|
|
65
69
|
// ── opencode.json parsing ────────────────────────────────────────────────────
|
|
66
70
|
|
|
67
|
-
/** Keys that are safe to import from host opencode.json into OP_HOME config */
|
|
71
|
+
/** Keys that are safe to import from host opencode.json into OP_HOME config. */
|
|
68
72
|
const ALLOWED_CONFIG_KEYS = new Set(["$schema", "provider", "model", "small_model", "disabled_providers"]);
|
|
69
73
|
|
|
70
74
|
type OpenCodeJson = Record<string, unknown>;
|
|
@@ -78,8 +82,18 @@ function readJsonFileSafe(path: string): OpenCodeJson | null {
|
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
function stripDisallowedKeys(obj: OpenCodeJson): OpenCodeJson {
|
|
85
|
+
const next: OpenCodeJson = {};
|
|
86
|
+
if (typeof obj.$schema === 'string') next.$schema = obj.$schema;
|
|
87
|
+
if (obj.provider && typeof obj.provider === 'object' && !Array.isArray(obj.provider)) {
|
|
88
|
+
next.provider = obj.provider;
|
|
89
|
+
}
|
|
90
|
+
if (typeof obj.model === 'string') next.model = obj.model;
|
|
91
|
+
if (typeof obj.small_model === 'string') next.small_model = obj.small_model;
|
|
92
|
+
if (Array.isArray(obj.disabled_providers) && obj.disabled_providers.every((entry) => typeof entry === 'string')) {
|
|
93
|
+
next.disabled_providers = obj.disabled_providers;
|
|
94
|
+
}
|
|
81
95
|
return Object.fromEntries(
|
|
82
|
-
Object.entries(
|
|
96
|
+
Object.entries(next).filter(([k]) => ALLOWED_CONFIG_KEYS.has(k))
|
|
83
97
|
);
|
|
84
98
|
}
|
|
85
99
|
|
|
@@ -116,9 +130,16 @@ export function detectHostOpenCode(): HostOpenCodeStatus {
|
|
|
116
130
|
}
|
|
117
131
|
|
|
118
132
|
let providerCount = 0;
|
|
133
|
+
let modelPreferences: { model?: string; small_model?: string } | undefined;
|
|
119
134
|
if (configExists) {
|
|
120
135
|
const parsed = readJsonFileSafe(configPath);
|
|
121
136
|
providerCount = parsed ? countProviders(parsed) : 0;
|
|
137
|
+
if (parsed) {
|
|
138
|
+
const prefs: { model?: string; small_model?: string } = {};
|
|
139
|
+
if (typeof parsed.model === 'string' && parsed.model) prefs.model = parsed.model;
|
|
140
|
+
if (typeof parsed.small_model === 'string' && parsed.small_model) prefs.small_model = parsed.small_model;
|
|
141
|
+
if (prefs.model || prefs.small_model) modelPreferences = prefs;
|
|
142
|
+
}
|
|
122
143
|
}
|
|
123
144
|
|
|
124
145
|
let credentialCount = 0;
|
|
@@ -131,6 +152,7 @@ export function detectHostOpenCode(): HostOpenCodeStatus {
|
|
|
131
152
|
authPath: authExists ? authPath : undefined,
|
|
132
153
|
providerCount,
|
|
133
154
|
credentialCount,
|
|
155
|
+
...(modelPreferences ? { modelPreferences } : {}),
|
|
134
156
|
};
|
|
135
157
|
}
|
|
136
158
|
|
|
@@ -181,21 +203,28 @@ export function importHostOpenCode(
|
|
|
181
203
|
}
|
|
182
204
|
}
|
|
183
205
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
206
|
+
const merged: OpenCodeJson = {
|
|
207
|
+
...existing,
|
|
208
|
+
...(typeof existing.$schema === 'undefined' && typeof sanitized.$schema !== 'undefined'
|
|
209
|
+
? { $schema: sanitized.$schema }
|
|
210
|
+
: {}),
|
|
211
|
+
...(Object.keys(mergedProviders).length > 0 ? { provider: mergedProviders } : {}),
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
for (const key of ["model", "small_model", "disabled_providers"] as const) {
|
|
215
|
+
if (typeof merged[key] === 'undefined' && typeof sanitized[key] !== 'undefined') {
|
|
216
|
+
merged[key] = sanitized[key];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
writeFileSync(destPath, JSON.stringify(merged, null, 2) + "\n");
|
|
221
|
+
}
|
|
192
222
|
}
|
|
193
223
|
|
|
194
224
|
// ── auth.json ──────────────────────────────────────────────────────────
|
|
195
225
|
if (status.authPath) {
|
|
196
226
|
const destPath = authJsonPath(state);
|
|
197
|
-
|
|
198
|
-
mkdirSync(destDir, { recursive: true });
|
|
227
|
+
mkdirSync(dirname(destPath), { recursive: true, mode: 0o700 });
|
|
199
228
|
|
|
200
229
|
if (existsSync(destPath) && !overwriteConflicts) {
|
|
201
230
|
// Merge: copy only keys that do not already exist in OP_HOME auth.json
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Edge-case tests for the OpenPalm install and setup flow.
|
|
3
3
|
*
|
|
4
4
|
* Each test creates its own temp directory tree mimicking the single
|
|
5
|
-
* ~/.openpalm/ root layout (config,
|
|
5
|
+
* ~/.openpalm/ root layout (config, knowledge, data, logs), then runs the
|
|
6
6
|
* actual library functions against it. No mocks of code under test.
|
|
7
7
|
*/
|
|
8
8
|
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
|
@@ -23,11 +23,11 @@ import {
|
|
|
23
23
|
performSetup,
|
|
24
24
|
buildSecretsFromSetup,
|
|
25
25
|
buildAuthJsonFromSetup,
|
|
26
|
-
buildSystemSecretsFromSetup,
|
|
27
26
|
} from "./setup.js";
|
|
28
27
|
import type { SetupSpec, SetupConnection } from "./setup.js";
|
|
29
28
|
import type { ControlPlaneState } from "./types.js";
|
|
30
29
|
import { STACK_SPEC_FILENAME, readStackSpec } from "./stack-spec.js";
|
|
30
|
+
import { readSecret } from './secrets-files.js';
|
|
31
31
|
|
|
32
32
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
33
33
|
|
|
@@ -55,70 +55,69 @@ function makeValidSpec(overrides?: Partial<SetupSpec>): SetupSpec {
|
|
|
55
55
|
function seedRequiredAssets(homeDir: string): void {
|
|
56
56
|
mkdirSync(join(homeDir, "config", "stack"), { recursive: true });
|
|
57
57
|
writeFileSync(join(homeDir, "config", "stack", "core.compose.yml"), "services:\n assistant:\n image: assistant:latest\n");
|
|
58
|
-
mkdirSync(join(homeDir, "
|
|
59
|
-
writeFileSync(join(homeDir, "
|
|
60
|
-
writeFileSync(join(homeDir, "
|
|
61
|
-
mkdirSync(join(homeDir, "
|
|
62
|
-
// Automations live in
|
|
63
|
-
mkdirSync(join(homeDir, "
|
|
64
|
-
writeFileSync(join(homeDir, "
|
|
65
|
-
writeFileSync(join(homeDir, "
|
|
66
|
-
writeFileSync(join(homeDir, "
|
|
58
|
+
mkdirSync(join(homeDir, "data", "assistant"), { recursive: true });
|
|
59
|
+
writeFileSync(join(homeDir, "data", "assistant", "opencode.jsonc"), '{"$schema":"https://opencode.ai/config.json"}\n');
|
|
60
|
+
writeFileSync(join(homeDir, "data", "assistant", "AGENTS.md"), "# Agents\n");
|
|
61
|
+
mkdirSync(join(homeDir, "data"), { recursive: true });
|
|
62
|
+
// Automations live in knowledge/tasks as AKM-owned task files.
|
|
63
|
+
mkdirSync(join(homeDir, "knowledge", "tasks"), { recursive: true });
|
|
64
|
+
writeFileSync(join(homeDir, "knowledge", "tasks", "cleanup-logs.yml"), "schedule: \"0 4 * * 0\"\ndescription: cleanup logs\ncommand: [\"echo\",\"clean\"]\n");
|
|
65
|
+
writeFileSync(join(homeDir, "knowledge", "tasks", "cleanup-data.yml"), "schedule: \"0 5 * * 0\"\ndescription: cleanup data\ncommand: [\"echo\",\"clean\"]\n");
|
|
66
|
+
writeFileSync(join(homeDir, "knowledge", "tasks", "validate-config.yml"), "schedule: \"0 3 * * *\"\ndescription: validate config\ncommand: [\"echo\",\"clean\"]\n");
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
// ── Shared test fixture ──────────────────────────────────────────────────
|
|
70
70
|
|
|
71
71
|
let homeDir: string;
|
|
72
72
|
let configDir: string;
|
|
73
|
-
let
|
|
73
|
+
let dataDir: string;
|
|
74
74
|
let stackDir: string;
|
|
75
|
-
let cacheDir: string;
|
|
76
75
|
|
|
77
76
|
const savedEnv: Record<string, string | undefined> = {};
|
|
78
77
|
|
|
79
78
|
function saveAndSetEnv(): void {
|
|
80
79
|
savedEnv.OP_HOME = process.env.OP_HOME;
|
|
80
|
+
savedEnv.OP_UI_LOGIN_PASSWORD = process.env.OP_UI_LOGIN_PASSWORD;
|
|
81
|
+
savedEnv.OP_OPENCODE_PASSWORD = process.env.OP_OPENCODE_PASSWORD;
|
|
81
82
|
process.env.OP_HOME = homeDir;
|
|
83
|
+
delete process.env.OP_UI_LOGIN_PASSWORD;
|
|
84
|
+
delete process.env.OP_OPENCODE_PASSWORD;
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
function restoreEnv(): void {
|
|
85
|
-
|
|
88
|
+
if (savedEnv.OP_HOME === undefined) delete process.env.OP_HOME;
|
|
89
|
+
else process.env.OP_HOME = savedEnv.OP_HOME;
|
|
90
|
+
if (savedEnv.OP_UI_LOGIN_PASSWORD === undefined) delete process.env.OP_UI_LOGIN_PASSWORD;
|
|
91
|
+
else process.env.OP_UI_LOGIN_PASSWORD = savedEnv.OP_UI_LOGIN_PASSWORD;
|
|
92
|
+
if (savedEnv.OP_OPENCODE_PASSWORD === undefined) delete process.env.OP_OPENCODE_PASSWORD;
|
|
93
|
+
else process.env.OP_OPENCODE_PASSWORD = savedEnv.OP_OPENCODE_PASSWORD;
|
|
86
94
|
}
|
|
87
95
|
|
|
88
96
|
/** Create a full directory tree matching ensureHomeDirs() output. */
|
|
89
97
|
function createFullDirTree(): void {
|
|
90
98
|
homeDir = mkdtempSync(join(tmpdir(), "openpalm-edge-"));
|
|
91
99
|
configDir = join(homeDir, "config");
|
|
92
|
-
|
|
100
|
+
dataDir = join(homeDir, "data");
|
|
93
101
|
stackDir = join(configDir, "stack");
|
|
94
|
-
cacheDir = join(homeDir, "cache");
|
|
95
102
|
|
|
96
103
|
for (const dir of [
|
|
97
104
|
homeDir,
|
|
98
105
|
configDir,
|
|
99
|
-
join(homeDir, "state", "registry", "automations"),
|
|
100
106
|
join(configDir, "assistant"),
|
|
101
107
|
join(configDir, "akm"),
|
|
102
|
-
join(homeDir, "
|
|
108
|
+
join(homeDir, "knowledge"),
|
|
109
|
+
join(homeDir, "knowledge", "env"),
|
|
110
|
+
join(homeDir, "knowledge", "secrets"),
|
|
103
111
|
join(homeDir, "workspace"),
|
|
104
112
|
stackDir,
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
join(
|
|
108
|
-
join(
|
|
109
|
-
join(
|
|
110
|
-
join(
|
|
111
|
-
join(
|
|
112
|
-
join(
|
|
113
|
-
join(stateDir, "registry", "addons"),
|
|
114
|
-
join(stateDir, "backups"),
|
|
115
|
-
join(stateDir, "akm"),
|
|
116
|
-
join(stateDir, "akm", "data"),
|
|
117
|
-
join(stateDir, "akm", "state"),
|
|
118
|
-
cacheDir,
|
|
119
|
-
join(cacheDir, "akm"),
|
|
120
|
-
join(cacheDir, "guardian"),
|
|
121
|
-
join(cacheDir, "rollback"),
|
|
113
|
+
dataDir,
|
|
114
|
+
join(dataDir, "assistant"),
|
|
115
|
+
join(dataDir, "guardian"),
|
|
116
|
+
join(dataDir, "akm", "cache"),
|
|
117
|
+
join(dataDir, "akm", "data"),
|
|
118
|
+
join(dataDir, "logs"),
|
|
119
|
+
join(dataDir, "backups"),
|
|
120
|
+
join(dataDir, "rollback"),
|
|
122
121
|
]) {
|
|
123
122
|
mkdirSync(dir, { recursive: true });
|
|
124
123
|
}
|
|
@@ -132,16 +131,10 @@ function seedMinimalEnvFiles(): void {
|
|
|
132
131
|
mkdirSync(stackDir, { recursive: true });
|
|
133
132
|
|
|
134
133
|
writeFileSync(
|
|
135
|
-
join(
|
|
134
|
+
join(homeDir, "knowledge", "env", "stack.env"),
|
|
136
135
|
[
|
|
137
136
|
"# OpenPalm — Stack Configuration",
|
|
138
|
-
"OP_UI_LOGIN_PASSWORD=",
|
|
139
|
-
"OPENAI_API_KEY=",
|
|
140
137
|
"OPENAI_BASE_URL=",
|
|
141
|
-
"ANTHROPIC_API_KEY=",
|
|
142
|
-
"GROQ_API_KEY=",
|
|
143
|
-
"MISTRAL_API_KEY=",
|
|
144
|
-
"GOOGLE_API_KEY=",
|
|
145
138
|
"OP_OWNER_NAME=",
|
|
146
139
|
"OP_OWNER_EMAIL=",
|
|
147
140
|
"",
|
|
@@ -166,16 +159,15 @@ describe("Fresh Install", () => {
|
|
|
166
159
|
rmSync(homeDir, { recursive: true, force: true });
|
|
167
160
|
});
|
|
168
161
|
|
|
169
|
-
// Scenario 1: ensureSecrets does NOT seed user.env (see akm-
|
|
162
|
+
// Scenario 1: ensureSecrets does NOT seed user.env (see akm-user-env) but
|
|
170
163
|
// does create stack.env with required keys when files do not exist.
|
|
171
|
-
it("ensureSecrets creates
|
|
164
|
+
it("ensureSecrets creates stack.env with required keys on fresh install", () => {
|
|
172
165
|
const state: ControlPlaneState = {
|
|
173
166
|
homeDir,
|
|
174
167
|
configDir,
|
|
175
|
-
stashDir: join(homeDir, "
|
|
168
|
+
stashDir: join(homeDir, "knowledge"),
|
|
176
169
|
workspaceDir: join(homeDir, "workspace"),
|
|
177
|
-
|
|
178
|
-
stateDir,
|
|
170
|
+
dataDir,
|
|
179
171
|
stackDir,
|
|
180
172
|
services: {},
|
|
181
173
|
artifacts: { compose: "" },
|
|
@@ -184,17 +176,18 @@ describe("Fresh Install", () => {
|
|
|
184
176
|
|
|
185
177
|
ensureSecrets(state);
|
|
186
178
|
|
|
187
|
-
//
|
|
188
|
-
const stackContent = readFileSync(join(
|
|
189
|
-
expect(stackContent).toContain("OPENAI_API_KEY=");
|
|
190
|
-
expect(stackContent).toContain("
|
|
179
|
+
// stack.env only carries non-secret setup/config keys.
|
|
180
|
+
const stackContent = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), "utf-8");
|
|
181
|
+
expect(stackContent).not.toContain("OPENAI_API_KEY=");
|
|
182
|
+
expect(stackContent).toContain("OP_SETUP_COMPLETE=false");
|
|
183
|
+
expect(readSecret(stackDir, 'op_ui_login_password')).toBeNull();
|
|
191
184
|
});
|
|
192
185
|
|
|
193
186
|
// Scenario 2: isSetupComplete returns false before setup
|
|
194
187
|
it("isSetupComplete returns false when stack.env has OP_SETUP_COMPLETE=false", () => {
|
|
195
|
-
mkdirSync(
|
|
188
|
+
mkdirSync(dataDir, { recursive: true });
|
|
196
189
|
writeFileSync(
|
|
197
|
-
join(
|
|
190
|
+
join(homeDir, "knowledge", "env", "stack.env"),
|
|
198
191
|
"OP_SETUP_COMPLETE=false\n"
|
|
199
192
|
);
|
|
200
193
|
|
|
@@ -223,7 +216,7 @@ describe("Fresh Install", () => {
|
|
|
223
216
|
|
|
224
217
|
await performSetup(makeValidSpec());
|
|
225
218
|
|
|
226
|
-
const stackEnv = readFileSync(join(
|
|
219
|
+
const stackEnv = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), "utf-8");
|
|
227
220
|
const parsed = parseEnvContent(stackEnv);
|
|
228
221
|
// Either entirely absent, or still the seeded "false" — never "true".
|
|
229
222
|
expect(parsed.OP_SETUP_COMPLETE === undefined || parsed.OP_SETUP_COMPLETE === "false").toBe(true);
|
|
@@ -246,18 +239,17 @@ describe("Existing Install", () => {
|
|
|
246
239
|
rmSync(homeDir, { recursive: true, force: true });
|
|
247
240
|
});
|
|
248
241
|
|
|
249
|
-
// Scenario 5: ensureSecrets
|
|
250
|
-
it("ensureSecrets
|
|
251
|
-
mkdirSync(
|
|
252
|
-
writeFileSync(join(
|
|
242
|
+
// Scenario 5: ensureSecrets creates file-based secrets without stack.env tokens
|
|
243
|
+
it("ensureSecrets creates file-based system secrets", () => {
|
|
244
|
+
mkdirSync(dataDir, { recursive: true });
|
|
245
|
+
writeFileSync(join(homeDir, "knowledge", "env", "stack.env"), "OP_SETUP_COMPLETE=false\n");
|
|
253
246
|
|
|
254
247
|
const state: ControlPlaneState = {
|
|
255
248
|
homeDir,
|
|
256
249
|
configDir,
|
|
257
|
-
stashDir: join(homeDir, "
|
|
250
|
+
stashDir: join(homeDir, "knowledge"),
|
|
258
251
|
workspaceDir: join(homeDir, "workspace"),
|
|
259
|
-
|
|
260
|
-
stateDir,
|
|
252
|
+
dataDir,
|
|
261
253
|
stackDir,
|
|
262
254
|
services: {},
|
|
263
255
|
artifacts: { compose: "" },
|
|
@@ -266,25 +258,23 @@ describe("Existing Install", () => {
|
|
|
266
258
|
|
|
267
259
|
ensureSecrets(state);
|
|
268
260
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
expect(
|
|
261
|
+
const afterContent = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), "utf-8");
|
|
262
|
+
expect(afterContent).not.toContain("OP_UI_LOGIN_PASSWORD=");
|
|
263
|
+
expect(readSecret(stackDir, 'op_ui_login_password')).toBeNull();
|
|
272
264
|
});
|
|
273
265
|
|
|
274
266
|
// Scenario 6: performSetup re-run rewrites OP_UI_LOGIN_PASSWORD when the
|
|
275
267
|
// operator supplies a new one in the spec. This is intentional — the
|
|
276
268
|
// wizard "rerun" path is how an operator rotates the password. The
|
|
277
269
|
// legacy OP_ASSISTANT_TOKEN preservation test was removed with the token.
|
|
278
|
-
it("performSetup re-run rewrites OP_UI_LOGIN_PASSWORD when spec changes", async () => {
|
|
270
|
+
it("performSetup re-run rewrites OP_UI_LOGIN_PASSWORD secret file when spec changes", async () => {
|
|
279
271
|
await performSetup(makeValidSpec({ security: { uiLoginPassword: "first-password-12345" } }));
|
|
280
272
|
|
|
281
|
-
|
|
282
|
-
expect(afterFirst).toContain("OP_UI_LOGIN_PASSWORD=first-password-12345");
|
|
273
|
+
expect(readSecret(stackDir, 'op_ui_login_password')).toBe("first-password-12345\n");
|
|
283
274
|
|
|
284
275
|
await performSetup(makeValidSpec({ security: { uiLoginPassword: "second-password-12345" } }));
|
|
285
276
|
|
|
286
|
-
|
|
287
|
-
expect(afterSecond).toContain("OP_UI_LOGIN_PASSWORD=second-password-12345");
|
|
277
|
+
expect(readSecret(stackDir, 'op_ui_login_password')).toBe("second-password-12345\n");
|
|
288
278
|
});
|
|
289
279
|
|
|
290
280
|
// Scenario 7: performSetup must NOT mark OP_SETUP_COMPLETE — see scenario
|
|
@@ -294,7 +284,7 @@ describe("Existing Install", () => {
|
|
|
294
284
|
await performSetup(makeValidSpec());
|
|
295
285
|
|
|
296
286
|
const stackEnv = readFileSync(
|
|
297
|
-
join(
|
|
287
|
+
join(homeDir, "knowledge", "env", "stack.env"),
|
|
298
288
|
"utf-8"
|
|
299
289
|
);
|
|
300
290
|
const parsed = parseEnvContent(stackEnv);
|
|
@@ -328,9 +318,8 @@ describe("Existing Install", () => {
|
|
|
328
318
|
expect(specAfterSecond).not.toBeNull();
|
|
329
319
|
expect(specAfterSecond!.version).toBe(2);
|
|
330
320
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
expect(secrets).toContain("GROQ_API_KEY");
|
|
321
|
+
const auth = JSON.parse(readFileSync(join(homeDir, "knowledge", "secrets", "auth.json"), "utf-8"));
|
|
322
|
+
expect(auth.groq.key).toBe("gsk-test-key-456");
|
|
334
323
|
});
|
|
335
324
|
});
|
|
336
325
|
|
|
@@ -351,16 +340,15 @@ describe("Broken/Corrupt State", () => {
|
|
|
351
340
|
|
|
352
341
|
// Scenario 9: ensureSecrets is idempotent on repeated calls
|
|
353
342
|
it("ensureSecrets is idempotent — second call does not overwrite existing stack.env", () => {
|
|
354
|
-
mkdirSync(
|
|
355
|
-
writeFileSync(join(
|
|
343
|
+
mkdirSync(dataDir, { recursive: true });
|
|
344
|
+
writeFileSync(join(homeDir, "knowledge", "env", "stack.env"), "OP_SETUP_COMPLETE=false\n");
|
|
356
345
|
|
|
357
346
|
const state: ControlPlaneState = {
|
|
358
347
|
homeDir,
|
|
359
348
|
configDir,
|
|
360
|
-
stashDir: join(homeDir, "
|
|
349
|
+
stashDir: join(homeDir, "knowledge"),
|
|
361
350
|
workspaceDir: join(homeDir, "workspace"),
|
|
362
|
-
|
|
363
|
-
stateDir,
|
|
351
|
+
dataDir,
|
|
364
352
|
stackDir,
|
|
365
353
|
services: {},
|
|
366
354
|
artifacts: { compose: "" },
|
|
@@ -369,9 +357,10 @@ describe("Broken/Corrupt State", () => {
|
|
|
369
357
|
|
|
370
358
|
ensureSecrets(state);
|
|
371
359
|
|
|
372
|
-
// Existing
|
|
373
|
-
const content = readFileSync(join(
|
|
374
|
-
expect(content).toContain("
|
|
360
|
+
// Existing non-secret stack config must be preserved.
|
|
361
|
+
const content = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), "utf-8");
|
|
362
|
+
expect(content).toContain("OP_SETUP_COMPLETE=false");
|
|
363
|
+
expect(content).not.toContain("OP_UI_LOGIN_PASSWORD=");
|
|
375
364
|
});
|
|
376
365
|
|
|
377
366
|
// Scenario 10: env file with malformed lines
|
|
@@ -388,10 +377,10 @@ describe("Broken/Corrupt State", () => {
|
|
|
388
377
|
" # indented comment",
|
|
389
378
|
].join("\n");
|
|
390
379
|
|
|
391
|
-
mkdirSync(
|
|
392
|
-
writeFileSync(join(
|
|
380
|
+
mkdirSync(dataDir, { recursive: true });
|
|
381
|
+
writeFileSync(join(dataDir, "test.env"), malformedContent);
|
|
393
382
|
|
|
394
|
-
const parsed = parseEnvFile(join(
|
|
383
|
+
const parsed = parseEnvFile(join(dataDir, "test.env"));
|
|
395
384
|
expect(parsed.VALID_KEY).toBe("valid_value");
|
|
396
385
|
expect(parsed.EXPORTED_KEY).toBe("exported_value");
|
|
397
386
|
expect(parsed.ANOTHER_VALID).toBe("value");
|
|
@@ -400,23 +389,25 @@ describe("Broken/Corrupt State", () => {
|
|
|
400
389
|
// Scenario 11: stack.env missing OP_SETUP_COMPLETE
|
|
401
390
|
it("isSetupComplete falls back to token check when OP_SETUP_COMPLETE missing", () => {
|
|
402
391
|
// stack.env without OP_SETUP_COMPLETE
|
|
403
|
-
mkdirSync(
|
|
392
|
+
mkdirSync(dataDir, { recursive: true });
|
|
404
393
|
writeFileSync(
|
|
405
|
-
join(
|
|
394
|
+
join(homeDir, "knowledge", "env", "stack.env"),
|
|
406
395
|
"OP_IMAGE_TAG=latest\n"
|
|
407
396
|
);
|
|
408
397
|
|
|
409
398
|
expect(isSetupComplete(stackDir)).toBe(false);
|
|
410
399
|
});
|
|
411
400
|
|
|
412
|
-
it("isSetupComplete
|
|
413
|
-
mkdirSync(
|
|
401
|
+
it("isSetupComplete returns false when OP_UI_LOGIN_PASSWORD is set but OP_SETUP_COMPLETE is missing", () => {
|
|
402
|
+
mkdirSync(dataDir, { recursive: true });
|
|
414
403
|
writeFileSync(
|
|
415
|
-
join(
|
|
404
|
+
join(homeDir, "knowledge", "env", "stack.env"),
|
|
416
405
|
"OP_IMAGE_TAG=latest\nexport OP_UI_LOGIN_PASSWORD=my-real-password\n"
|
|
417
406
|
);
|
|
418
407
|
|
|
419
|
-
|
|
408
|
+
// Password alone is no longer a proxy for setup completion.
|
|
409
|
+
// Only OP_SETUP_COMPLETE=true counts.
|
|
410
|
+
expect(isSetupComplete(stackDir)).toBe(false);
|
|
420
411
|
});
|
|
421
412
|
|
|
422
413
|
// Scenario 12: API key with special characters round-trips
|
|
@@ -441,13 +432,13 @@ describe("Broken/Corrupt State", () => {
|
|
|
441
432
|
expect(spec).toBeNull();
|
|
442
433
|
});
|
|
443
434
|
|
|
444
|
-
// Scenario 14:
|
|
435
|
+
// Scenario 14: knowledge/tasks dir missing (performSetup should recreate it via ensureHomeDirs)
|
|
445
436
|
it("performSetup creates missing subdirectories", async () => {
|
|
446
437
|
// Seed the minimal env files first
|
|
447
438
|
seedMinimalEnvFiles();
|
|
448
439
|
|
|
449
|
-
// Remove
|
|
450
|
-
rmSync(join(homeDir, "
|
|
440
|
+
// Remove knowledge/tasks dir (performSetup should recreate it via ensureHomeDirs)
|
|
441
|
+
rmSync(join(homeDir, "knowledge", "tasks"), { recursive: true, force: true });
|
|
451
442
|
|
|
452
443
|
const result = await performSetup(
|
|
453
444
|
makeValidSpec()
|
|
@@ -458,8 +449,8 @@ describe("Broken/Corrupt State", () => {
|
|
|
458
449
|
expect(existsSync(join(homeDir, "config", "stack", "core.compose.yml"))).toBe(
|
|
459
450
|
true
|
|
460
451
|
);
|
|
461
|
-
//
|
|
462
|
-
expect(existsSync(join(homeDir, "
|
|
452
|
+
// knowledge/tasks dir should be recreated by ensureHomeDirs
|
|
453
|
+
expect(existsSync(join(homeDir, "knowledge", "tasks"))).toBe(true);
|
|
463
454
|
});
|
|
464
455
|
|
|
465
456
|
// Scenario 15: openpalm.yaml with old version
|
|
@@ -489,15 +480,15 @@ describe("Environment Edge Cases", () => {
|
|
|
489
480
|
rmSync(homeDir, { recursive: true, force: true });
|
|
490
481
|
});
|
|
491
482
|
|
|
492
|
-
// Scenario 16: isSetupComplete
|
|
493
|
-
it("isSetupComplete
|
|
494
|
-
mkdirSync(
|
|
483
|
+
// Scenario 16: isSetupComplete requires explicit OP_SETUP_COMPLETE=true
|
|
484
|
+
it("isSetupComplete returns false when only OP_UI_LOGIN_PASSWORD is set", () => {
|
|
485
|
+
mkdirSync(dataDir, { recursive: true });
|
|
495
486
|
writeFileSync(
|
|
496
|
-
join(
|
|
487
|
+
join(homeDir, "knowledge", "env", "stack.env"),
|
|
497
488
|
"SOME_OTHER_KEY=value\nexport OP_UI_LOGIN_PASSWORD=real-password-here\n"
|
|
498
489
|
);
|
|
499
490
|
|
|
500
|
-
expect(isSetupComplete(stackDir)).toBe(
|
|
491
|
+
expect(isSetupComplete(stackDir)).toBe(false);
|
|
501
492
|
});
|
|
502
493
|
|
|
503
494
|
// Scenario 17: export prefix on env vars
|
|
@@ -667,11 +658,10 @@ describe("performSetup end-to-end artifacts", () => {
|
|
|
667
658
|
).toBe(true);
|
|
668
659
|
});
|
|
669
660
|
|
|
670
|
-
it("writes the UI login password to
|
|
661
|
+
it("writes the UI login password to a secret file", async () => {
|
|
671
662
|
await performSetup(makeValidSpec());
|
|
672
663
|
|
|
673
|
-
|
|
674
|
-
expect(secrets.OP_UI_LOGIN_PASSWORD).toBe("test-admin-token-12345");
|
|
664
|
+
expect(readSecret(stackDir, 'op_ui_login_password')).toBe("test-admin-token-12345\n");
|
|
675
665
|
});
|
|
676
666
|
|
|
677
667
|
it("writes akm config with llm provider and model", async () => {
|
|
@@ -679,8 +669,11 @@ describe("performSetup end-to-end artifacts", () => {
|
|
|
679
669
|
|
|
680
670
|
const akmConfigPath = join(homeDir, "config", "akm", "config.json");
|
|
681
671
|
const config = JSON.parse(readFileSync(akmConfigPath, "utf-8"));
|
|
682
|
-
|
|
683
|
-
expect(config.llm
|
|
672
|
+
// Canonical akm 0.8.0 shape (I-3): profiles.llm.default + defaults.llm.
|
|
673
|
+
expect(config.llm).toBeUndefined();
|
|
674
|
+
expect(config.profiles.llm.default.provider).toBe("openai");
|
|
675
|
+
expect(config.profiles.llm.default.model).toBe("gpt-4o");
|
|
676
|
+
expect(config.defaults.llm).toBe("default");
|
|
684
677
|
expect(config.embedding.model).toBe("text-embedding-3-small");
|
|
685
678
|
});
|
|
686
679
|
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Both `performSetup` (config writes) and `startDeploy` (Docker work) need an
|
|
5
5
|
* exclusive lock against concurrent installs. The lock file lives at
|
|
6
|
-
* `<
|
|
6
|
+
* `<dataDir>/.install.lock` and contains `<pid>\n<timestamp>\n`.
|
|
7
7
|
*
|
|
8
8
|
* Self-healing rules:
|
|
9
9
|
* - On EEXIST, parse the holder PID. If the process is gone (`process.kill(pid, 0)`
|
|
@@ -89,23 +89,23 @@ function tryCreate(path: string): boolean {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
|
-
* Try to acquire the install lock under `
|
|
92
|
+
* Try to acquire the install lock under `dataDir`. Returns a handle on
|
|
93
93
|
* success or null if the lock is held by a live, recent install (or on any
|
|
94
94
|
* unexpected filesystem error — caller should surface "install_in_progress").
|
|
95
95
|
*
|
|
96
96
|
* Callers MUST call `releaseInstallLock()` in a finally block when done.
|
|
97
97
|
*/
|
|
98
|
-
export function acquireInstallLock(
|
|
98
|
+
export function acquireInstallLock(dataDir: string): InstallLockHandle | null {
|
|
99
99
|
try {
|
|
100
|
-
mkdirSync(
|
|
100
|
+
mkdirSync(dataDir, { recursive: true });
|
|
101
101
|
} catch (err) {
|
|
102
|
-
logger.warn("failed to ensure
|
|
103
|
-
|
|
102
|
+
logger.warn("failed to ensure data dir for install lock", {
|
|
103
|
+
dataDir,
|
|
104
104
|
error: err instanceof Error ? err.message : String(err),
|
|
105
105
|
});
|
|
106
106
|
return null;
|
|
107
107
|
}
|
|
108
|
-
const path = join(
|
|
108
|
+
const path = join(dataDir, ".install.lock");
|
|
109
109
|
|
|
110
110
|
try {
|
|
111
111
|
if (tryCreate(path)) return { path };
|