@openpalm/lib 0.10.1 → 0.11.0-beta.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 +2 -2
- package/package.json +7 -3
- package/src/control-plane/admin-token.ts +73 -0
- package/src/control-plane/akm-vault.test.ts +108 -0
- package/src/control-plane/akm-vault.ts +307 -0
- package/src/control-plane/audit.ts +3 -2
- package/src/control-plane/channels.ts +3 -3
- package/src/control-plane/cleanup-guardrails.test.ts +8 -9
- package/src/control-plane/compose-args.test.ts +25 -21
- package/src/control-plane/config-persistence.ts +103 -64
- package/src/control-plane/core-assets.test.ts +104 -0
- package/src/control-plane/core-assets.ts +54 -57
- package/src/control-plane/docker.ts +55 -21
- package/src/control-plane/env.test.ts +25 -1
- package/src/control-plane/env.ts +80 -0
- package/src/control-plane/home.ts +66 -69
- package/src/control-plane/host-opencode.test.ts +263 -0
- package/src/control-plane/host-opencode.ts +229 -0
- package/src/control-plane/install-edge-cases.test.ts +182 -244
- package/src/control-plane/install-lock.ts +157 -0
- package/src/control-plane/lifecycle.ts +57 -56
- package/src/control-plane/markdown-task.ts +200 -0
- package/src/control-plane/paths.ts +75 -0
- package/src/control-plane/provider-config.ts +2 -2
- package/src/control-plane/provider-models.ts +154 -0
- package/src/control-plane/registry-components.test.ts +102 -25
- package/src/control-plane/registry.test.ts +49 -47
- package/src/control-plane/registry.ts +71 -50
- package/src/control-plane/rollback.ts +17 -16
- package/src/control-plane/scheduler.ts +75 -262
- package/src/control-plane/secret-backend.test.ts +98 -108
- package/src/control-plane/secret-backend.ts +221 -181
- package/src/control-plane/secret-mappings.ts +3 -6
- package/src/control-plane/secrets.ts +83 -47
- package/src/control-plane/setup-config.schema.json +2 -14
- package/src/control-plane/setup-status.ts +4 -29
- package/src/control-plane/setup-validation.ts +21 -21
- package/src/control-plane/setup.test.ts +122 -227
- package/src/control-plane/setup.ts +224 -125
- package/src/control-plane/skeleton-guardrail.test.ts +151 -0
- package/src/control-plane/spec-to-env.test.ts +59 -58
- package/src/control-plane/spec-to-env.ts +39 -140
- package/src/control-plane/spec-validator.ts +2 -99
- package/src/control-plane/stack-spec.test.ts +21 -77
- package/src/control-plane/stack-spec.ts +7 -83
- package/src/control-plane/types.ts +17 -15
- package/src/control-plane/ui-assets.ts +349 -0
- package/src/control-plane/validate.ts +44 -79
- package/src/index.ts +77 -44
- package/src/logger.test.ts +228 -0
- package/src/logger.ts +71 -1
- package/src/provider-constants.ts +22 -1
- package/src/control-plane/env-schema-validation.test.ts +0 -118
- package/src/control-plane/memory-config.ts +0 -298
- package/src/control-plane/redact-schema.ts +0 -50
|
@@ -5,35 +5,49 @@
|
|
|
5
5
|
* This module does NOT include Docker operations (compose up, image pull, etc.)
|
|
6
6
|
* — those happen separately in the caller after setup completes.
|
|
7
7
|
*/
|
|
8
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
9
10
|
import { randomBytes } from "node:crypto";
|
|
10
11
|
import { createLogger } from "../logger.js";
|
|
11
12
|
import {
|
|
12
13
|
PROVIDER_KEY_MAP,
|
|
13
|
-
EMBEDDING_DIMS,
|
|
14
|
-
OLLAMA_INSTACK_URL,
|
|
15
14
|
} from "../provider-constants.js";
|
|
16
15
|
import { mergeEnvContent } from "./env.js";
|
|
17
16
|
import { ensureHomeDirs } from "./home.js";
|
|
17
|
+
import { acquireInstallLock, releaseInstallLock, type InstallLockHandle } from "./install-lock.js";
|
|
18
18
|
import {
|
|
19
19
|
ensureSecrets,
|
|
20
20
|
updateSecretsEnv,
|
|
21
21
|
updateSystemSecretsEnv,
|
|
22
22
|
ensureOpenCodeConfig,
|
|
23
23
|
readStackEnv,
|
|
24
|
+
writeAuthJsonProviderKeys,
|
|
24
25
|
} from "./secrets.js";
|
|
25
|
-
import { ensureOpenCodeSystemConfig
|
|
26
|
-
import { createState
|
|
26
|
+
import { ensureOpenCodeSystemConfig } from "./core-assets.js";
|
|
27
|
+
import { createState } from "./lifecycle.js";
|
|
27
28
|
import { writeStackSpec } from "./stack-spec.js";
|
|
28
|
-
import
|
|
29
|
-
import { writeCapabilityVars } from "./spec-to-env.js";
|
|
29
|
+
import { writeVoiceVars } from "./spec-to-env.js";
|
|
30
30
|
import type { ControlPlaneState } from "./types.js";
|
|
31
31
|
import { validateSetupSpec } from "./setup-validation.js";
|
|
32
|
-
import {
|
|
32
|
+
import { getRegistryAutomation, setAddonEnabled } from "./registry.js";
|
|
33
33
|
export { validateSetupSpec } from "./setup-validation.js";
|
|
34
34
|
|
|
35
35
|
const logger = createLogger("setup");
|
|
36
36
|
|
|
37
|
+
// ── Atomic write helper ──────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Write `content` to `path` atomically: write to `path.tmp` first, then
|
|
41
|
+
* rename over the target. On POSIX this rename is atomic — a reader always
|
|
42
|
+
* sees either the old file or the new file, never a partially-written one.
|
|
43
|
+
* If the tmp write fails the original file is untouched.
|
|
44
|
+
*/
|
|
45
|
+
function writeFileAtomic(path: string, content: string | Uint8Array, mode?: number): void {
|
|
46
|
+
const tmp = `${path}.tmp`;
|
|
47
|
+
writeFileSync(tmp, content, mode !== undefined ? { mode } : {});
|
|
48
|
+
renameSync(tmp, path);
|
|
49
|
+
}
|
|
50
|
+
|
|
37
51
|
// ── Types ────────────────────────────────────────────────────────────────
|
|
38
52
|
|
|
39
53
|
export type SetupConnection = {
|
|
@@ -52,35 +66,27 @@ export type SetupResult = {
|
|
|
52
66
|
|
|
53
67
|
export type SetupSpec = {
|
|
54
68
|
version: 2;
|
|
55
|
-
|
|
69
|
+
llm?: { provider: string; model: string; baseUrl?: string };
|
|
70
|
+
embedding?: { provider: string; model: string; dims: number; baseUrl?: string };
|
|
71
|
+
tts?: { enabled?: boolean; engine?: string; provider?: string; baseURL?: string; model?: string; voice?: string };
|
|
72
|
+
stt?: { enabled?: boolean; engine?: string; provider?: string; baseURL?: string; model?: string; language?: string };
|
|
56
73
|
security: { adminToken: string };
|
|
57
74
|
owner?: { name?: string; email?: string };
|
|
58
75
|
connections: SetupConnection[];
|
|
59
76
|
channelCredentials?: Record<string, Record<string, string>>;
|
|
77
|
+
addons?: Record<string, boolean>;
|
|
78
|
+
imageTag?: string;
|
|
79
|
+
hostAkm?: boolean;
|
|
60
80
|
};
|
|
61
81
|
|
|
62
82
|
// ── Secrets Builder ──────────────────────────────────────────────────────
|
|
63
83
|
|
|
64
84
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
85
|
+
* Build the stack.env update payload from a setup spec. Provider API
|
|
86
|
+
* keys are NOT included here — credentials live in OpenCode's auth.json
|
|
87
|
+
* (see buildAuthJsonFromSetup), not stack.env. This function returns
|
|
88
|
+
* only non-credential vars: owner identity and similar.
|
|
67
89
|
*/
|
|
68
|
-
const PROVIDER_BASE_URL_ENV: Record<string, string> = {
|
|
69
|
-
openai: "OPENAI_BASE_URL",
|
|
70
|
-
anthropic: "ANTHROPIC_BASE_URL",
|
|
71
|
-
groq: "GROQ_BASE_URL",
|
|
72
|
-
mistral: "MISTRAL_BASE_URL",
|
|
73
|
-
together: "TOGETHER_BASE_URL",
|
|
74
|
-
deepseek: "DEEPSEEK_BASE_URL",
|
|
75
|
-
xai: "XAI_BASE_URL",
|
|
76
|
-
google: "GOOGLE_BASE_URL",
|
|
77
|
-
huggingface: "HF_BASE_URL",
|
|
78
|
-
ollama: "OLLAMA_BASE_URL",
|
|
79
|
-
lmstudio: "LMSTUDIO_BASE_URL",
|
|
80
|
-
"model-runner": "MODEL_RUNNER_BASE_URL",
|
|
81
|
-
"openai-compatible": "OPENAI_COMPATIBLE_BASE_URL",
|
|
82
|
-
};
|
|
83
|
-
|
|
84
90
|
export function buildSecretsFromSetup(
|
|
85
91
|
connections: SetupConnection[],
|
|
86
92
|
owner?: { name?: string; email?: string },
|
|
@@ -90,60 +96,66 @@ export function buildSecretsFromSetup(
|
|
|
90
96
|
const ownerEmail = (owner?.email?.trim() ?? "").replace(/[\r\n\0]/g, "").slice(0, 200);
|
|
91
97
|
if (ownerName) updates.OWNER_NAME = ownerName;
|
|
92
98
|
if (ownerEmail) updates.OWNER_EMAIL = ownerEmail;
|
|
93
|
-
|
|
94
|
-
for (const cap of connections) {
|
|
95
|
-
// API key: spec value takes precedence, then fall back to environment
|
|
96
|
-
const envVar = PROVIDER_KEY_MAP[cap.provider];
|
|
97
|
-
if (envVar) {
|
|
98
|
-
const key = cap.apiKey || process.env[envVar] || "";
|
|
99
|
-
if (key) updates[envVar] = key;
|
|
100
|
-
}
|
|
101
|
-
// Persist user-configured base URL for any provider so writeCapabilityVars can resolve it
|
|
102
|
-
if (cap.baseUrl) {
|
|
103
|
-
const urlEnv = PROVIDER_BASE_URL_ENV[cap.provider];
|
|
104
|
-
if (urlEnv) updates[urlEnv] = cap.baseUrl;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
99
|
+
void connections;
|
|
107
100
|
return updates;
|
|
108
101
|
}
|
|
109
102
|
|
|
110
103
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
104
|
+
* Build the auth.json payload from a setup spec. Returns a record of
|
|
105
|
+
* `{ providerId: apiKey }` ready to feed into writeAuthJsonProviderKeys.
|
|
106
|
+
* Pulls keys from the spec first, falling back to the host process
|
|
107
|
+
* environment for the canonical env var name (e.g. OPENAI_API_KEY for
|
|
108
|
+
* provider "openai") so operators can preload keys via env before
|
|
109
|
+
* running the wizard.
|
|
114
110
|
*/
|
|
115
|
-
export function
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
for (const [provider, entry] of Object.entries(auth)) {
|
|
124
|
-
if (!entry || typeof entry !== "object") continue;
|
|
125
|
-
const record = entry as Record<string, unknown>;
|
|
126
|
-
// OpenCode stores API keys as { token: "..." } or { apiKey: "..." }
|
|
127
|
-
const token = (record.token ?? record.apiKey ?? record.api_key ?? record.key) as string | undefined;
|
|
128
|
-
if (token && typeof token === "string") {
|
|
129
|
-
const envVar = PROVIDER_KEY_MAP[provider];
|
|
130
|
-
if (envVar) updates[envVar] = token;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
return updates;
|
|
134
|
-
} catch {
|
|
135
|
-
return {};
|
|
111
|
+
export function buildAuthJsonFromSetup(
|
|
112
|
+
connections: SetupConnection[],
|
|
113
|
+
): Record<string, string> {
|
|
114
|
+
const keys: Record<string, string> = {};
|
|
115
|
+
for (const cap of connections) {
|
|
116
|
+
const envVar = PROVIDER_KEY_MAP[cap.provider];
|
|
117
|
+
const key = cap.apiKey || (envVar ? process.env[envVar] : undefined) || "";
|
|
118
|
+
if (key) keys[cap.provider] = key;
|
|
136
119
|
}
|
|
120
|
+
return keys;
|
|
137
121
|
}
|
|
138
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Build the system-secret env update.
|
|
125
|
+
*
|
|
126
|
+
* `OP_ASSISTANT_TOKEN` is critical: rotating it on a running stack would
|
|
127
|
+
* invalidate every container's auth. We therefore distinguish three cases:
|
|
128
|
+
* - existing system env has a non-empty token → reuse it (idempotent rerun).
|
|
129
|
+
* - existing system env explicitly contains `OP_ASSISTANT_TOKEN=` (blank) →
|
|
130
|
+
* throw rather than silently rotate. This means a user edited stack.env
|
|
131
|
+
* or a previous run wrote it blank; either way silent rotation breaks the
|
|
132
|
+
* running stack.
|
|
133
|
+
* - the key is absent entirely → generate a fresh token (first install).
|
|
134
|
+
*
|
|
135
|
+
* If you legitimately need to rotate the token, delete the OP_ASSISTANT_TOKEN
|
|
136
|
+
* line from stack.env (rather than blanking it) before re-running setup.
|
|
137
|
+
*/
|
|
139
138
|
export function buildSystemSecretsFromSetup(
|
|
140
139
|
adminToken: string,
|
|
141
140
|
existingSystemEnv: Record<string, string> = {}
|
|
142
141
|
): Record<string, string> {
|
|
142
|
+
const hasKey = Object.prototype.hasOwnProperty.call(existingSystemEnv, "OP_ASSISTANT_TOKEN");
|
|
143
|
+
const existing = existingSystemEnv.OP_ASSISTANT_TOKEN;
|
|
144
|
+
let token: string;
|
|
145
|
+
if (existing) {
|
|
146
|
+
token = existing;
|
|
147
|
+
} else if (hasKey) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
"OP_ASSISTANT_TOKEN is present but blank in config/stack/stack.env. " +
|
|
150
|
+
"Refusing to silently rotate the token (it would break the running stack). " +
|
|
151
|
+
"Restore the previous value or remove the line entirely to generate a fresh one.",
|
|
152
|
+
);
|
|
153
|
+
} else {
|
|
154
|
+
token = randomBytes(32).toString("hex");
|
|
155
|
+
}
|
|
143
156
|
return {
|
|
144
|
-
|
|
145
|
-
OP_ASSISTANT_TOKEN:
|
|
146
|
-
OP_MEMORY_TOKEN: existingSystemEnv.OP_MEMORY_TOKEN || randomBytes(32).toString("hex"),
|
|
157
|
+
OP_UI_TOKEN: adminToken,
|
|
158
|
+
OP_ASSISTANT_TOKEN: token,
|
|
147
159
|
};
|
|
148
160
|
}
|
|
149
161
|
|
|
@@ -192,75 +204,162 @@ export async function performSetup(
|
|
|
192
204
|
const validation = validateSetupSpec(input);
|
|
193
205
|
if (!validation.valid) return { ok: false, error: validation.errors.join("; ") };
|
|
194
206
|
|
|
195
|
-
const {
|
|
207
|
+
const { llm, embedding, tts, stt, security, owner, connections, channelCredentials, addons, imageTag, hostAkm } = input;
|
|
196
208
|
const state = opts?.state ?? createState(security.adminToken);
|
|
197
|
-
const ollamaEnabled = listEnabledAddonIds(state.homeDir).includes("ollama");
|
|
198
|
-
|
|
199
|
-
logger.info("performing setup", { capabilityCount: connections.length, ollamaEnabled });
|
|
200
|
-
|
|
201
|
-
// Apply Ollama in-stack URL override when addon is enabled
|
|
202
|
-
const effectiveConnections = ollamaEnabled
|
|
203
|
-
? connections.map((c) => c.provider === "ollama" ? { ...c, baseUrl: OLLAMA_INSTACK_URL } : c)
|
|
204
|
-
: connections;
|
|
205
|
-
const updates = buildSecretsFromSetup(effectiveConnections, owner);
|
|
206
209
|
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
210
|
+
// Acquire install lock to prevent two concurrent setup runs from racing on
|
|
211
|
+
// the same config directory. The lock lives in stateDir so it is co-located
|
|
212
|
+
// with runtime state and the same path startDeploy uses.
|
|
213
|
+
const lockHandle: InstallLockHandle | null = acquireInstallLock(state.stateDir);
|
|
214
|
+
if (lockHandle === null) {
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
error:
|
|
218
|
+
"install_in_progress: Another install is in progress. Wait for it to finish, or remove state/.install.lock if you're sure no install is running.",
|
|
219
|
+
};
|
|
213
220
|
}
|
|
214
221
|
|
|
215
|
-
|
|
222
|
+
logger.info("performing setup", { connectionCount: connections.length });
|
|
223
|
+
const updates = buildSecretsFromSetup(connections, owner);
|
|
224
|
+
const providerKeys = buildAuthJsonFromSetup(connections);
|
|
225
|
+
|
|
226
|
+
// Wrap all persistence work in try/finally so the lock is ALWAYS released.
|
|
216
227
|
try {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
228
|
+
// Persist vault env files + OpenCode auth.json
|
|
229
|
+
try {
|
|
230
|
+
ensureHomeDirs();
|
|
231
|
+
ensureSecrets(state);
|
|
232
|
+
const existingSystemEnv = readStackEnv(state.stackDir);
|
|
233
|
+
if (channelCredentials) Object.assign(updates, buildChannelCredentialEnvVars(channelCredentials));
|
|
234
|
+
// Pick up channel credential env vars not already provided in the spec
|
|
235
|
+
for (const mapping of Object.values(CHANNEL_CREDENTIAL_ENV_MAP)) {
|
|
236
|
+
for (const envKey of Object.values(mapping)) {
|
|
237
|
+
if (!updates[envKey] && process.env[envKey]) updates[envKey] = process.env[envKey];
|
|
238
|
+
}
|
|
225
239
|
}
|
|
240
|
+
updateSecretsEnv(state, updates);
|
|
241
|
+
updateSystemSecretsEnv(state, buildSystemSecretsFromSetup(security.adminToken, existingSystemEnv));
|
|
242
|
+
// Provider API keys land in OpenCode's auth.json (bind-mounted into
|
|
243
|
+
// the assistant container) — never in stack.env.
|
|
244
|
+
writeAuthJsonProviderKeys(state, providerKeys);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
247
|
+
logger.error("failed to persist setup outputs", { error: message });
|
|
248
|
+
return { ok: false, error: `Failed to persist setup outputs: ${message}` };
|
|
226
249
|
}
|
|
227
|
-
updateSecretsEnv(state, updates);
|
|
228
|
-
updateSystemSecretsEnv(state, buildSystemSecretsFromSetup(security.adminToken, existingSystemEnv));
|
|
229
|
-
} catch (err) {
|
|
230
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
231
|
-
logger.error("failed to update vault env files", { error: message });
|
|
232
|
-
return { ok: false, error: `Failed to update vault env files: ${message}` };
|
|
233
|
-
}
|
|
234
250
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
writeSetupTokenFile(state);
|
|
251
|
+
state.adminToken = security.adminToken;
|
|
252
|
+
state.assistantToken = readStackEnv(state.stackDir).OP_ASSISTANT_TOKEN ?? state.assistantToken;
|
|
238
253
|
|
|
239
|
-
|
|
240
|
-
|
|
254
|
+
// Everything from here through the OP_SETUP_COMPLETE write is wrapped in a
|
|
255
|
+
// single try/catch so that a disk-full or permission-denied mid-way returns a
|
|
256
|
+
// clean error rather than leaving a broken half-installed ~/.openpalm/.
|
|
257
|
+
try {
|
|
258
|
+
// Write stack.yml (version marker only)
|
|
259
|
+
writeStackSpec(state.stackDir, { version: 2 });
|
|
241
260
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
261
|
+
// Write image tag and AKM mount paths to stack.env — atomic to avoid
|
|
262
|
+
// partial writes if the process is interrupted mid-write.
|
|
263
|
+
const systemEnvForAkm = existsSync(`${state.stackDir}/stack.env`)
|
|
264
|
+
? readFileSync(`${state.stackDir}/stack.env`, "utf-8")
|
|
265
|
+
: "";
|
|
266
|
+
const akmUpdates: Record<string, string> = {};
|
|
267
|
+
if (imageTag) akmUpdates.OP_IMAGE_TAG = imageTag;
|
|
268
|
+
if (hostAkm) {
|
|
269
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
270
|
+
if (home) {
|
|
271
|
+
akmUpdates.OP_AKM_STASH = `${home}/akm`;
|
|
272
|
+
akmUpdates.OP_AKM_DATA = `${home}/.local/share/akm`;
|
|
273
|
+
akmUpdates.OP_AKM_STATE = `${home}/.local/state/akm`;
|
|
274
|
+
akmUpdates.OP_AKM_CACHE = `${home}/.cache/akm`;
|
|
275
|
+
akmUpdates.OP_AKM_CONFIG = `${home}/.config/akm`;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (Object.keys(akmUpdates).length > 0) {
|
|
279
|
+
writeFileAtomic(`${state.stackDir}/stack.env`, mergeEnvContent(systemEnvForAkm, akmUpdates), 0o600);
|
|
280
|
+
}
|
|
245
281
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
282
|
+
// Write akm config with LLM and embedding settings from setup — atomic.
|
|
283
|
+
if (llm || embedding) {
|
|
284
|
+
const akmConfigDir = join(state.configDir, "akm");
|
|
285
|
+
mkdirSync(akmConfigDir, { recursive: true });
|
|
286
|
+
const akmConfigPath = join(akmConfigDir, "config.json");
|
|
287
|
+
let existing: Record<string, unknown> = {};
|
|
288
|
+
if (existsSync(akmConfigPath)) {
|
|
289
|
+
try { existing = JSON.parse(readFileSync(akmConfigPath, "utf-8")); } catch { /* ignore corrupt */ }
|
|
290
|
+
}
|
|
291
|
+
const updated = { ...existing };
|
|
292
|
+
if (llm) {
|
|
293
|
+
const base = llm.baseUrl ? llm.baseUrl.replace(/\/+$/, "") : "";
|
|
294
|
+
updated.llm = {
|
|
295
|
+
...((existing.llm as Record<string, unknown>) ?? {}),
|
|
296
|
+
endpoint: base ? `${base}/chat/completions` : "",
|
|
297
|
+
model: llm.model,
|
|
298
|
+
provider: llm.provider,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
if (embedding) {
|
|
302
|
+
const base = embedding.baseUrl ? embedding.baseUrl.replace(/\/+$/, "") : "";
|
|
303
|
+
updated.embedding = {
|
|
304
|
+
...((existing.embedding as Record<string, unknown>) ?? {}),
|
|
305
|
+
endpoint: base ? `${base}/embeddings` : "",
|
|
306
|
+
model: embedding.model,
|
|
307
|
+
provider: embedding.provider,
|
|
308
|
+
dimension: embedding.dims,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
writeFileAtomic(akmConfigPath, JSON.stringify(updated, null, 2), 0o600);
|
|
312
|
+
}
|
|
250
313
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
314
|
+
// Write TTS/STT vars to stack.env for the voice channel
|
|
315
|
+
if (tts || stt) {
|
|
316
|
+
writeVoiceVars({ tts, stt }, state.stackDir);
|
|
317
|
+
}
|
|
254
318
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
319
|
+
// Enable requested addons (channels like discord, slack, etc.)
|
|
320
|
+
// setAddonEnabled copies the compose overlay AND generates CHANNEL_<NAME>_SECRET in guardian.env
|
|
321
|
+
if (addons) {
|
|
322
|
+
for (const [name, enabled] of Object.entries(addons)) {
|
|
323
|
+
if (enabled) setAddonEnabled(state.homeDir, state.stackDir, name, true);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
259
326
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
327
|
+
ensureOpenCodeConfig();
|
|
328
|
+
ensureOpenCodeSystemConfig();
|
|
329
|
+
|
|
330
|
+
// Seed default automation into the AKM stash. Idempotent — existing files
|
|
331
|
+
// are left alone so user edits survive re-install and upgrade.
|
|
332
|
+
const tasksDir = join(state.stashDir, "tasks");
|
|
333
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
334
|
+
const akmImproveDest = join(tasksDir, "akm-improve.md");
|
|
335
|
+
if (!existsSync(akmImproveDest)) {
|
|
336
|
+
const akmImproveMd = getRegistryAutomation("akm-improve");
|
|
337
|
+
if (akmImproveMd) {
|
|
338
|
+
writeFileSync(akmImproveDest, akmImproveMd);
|
|
339
|
+
logger.info("seeded default automation", { name: "akm-improve" });
|
|
340
|
+
} else {
|
|
341
|
+
logger.warn("default automation missing from registry; skipping seed", {
|
|
342
|
+
name: "akm-improve",
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// NOTE: OP_SETUP_COMPLETE is intentionally NOT written here. Writing it
|
|
348
|
+
// before the Docker deploy succeeds would mark setup "complete" even
|
|
349
|
+
// when containers fail to start, sending the user to a broken admin UI
|
|
350
|
+
// with no path back to the wizard. The flag is now written by
|
|
351
|
+
// setup-deploy.ts:startDeploy AFTER pollContainerHealth confirms every
|
|
352
|
+
// container is healthy.
|
|
353
|
+
} catch (err) {
|
|
354
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
355
|
+
logger.error("failed to complete setup persistence", { error: message });
|
|
356
|
+
return { ok: false, error: `Setup persistence failed: ${message}` };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
logger.info("setup complete", { connectionCount: connections.length });
|
|
360
|
+
return { ok: true };
|
|
361
|
+
} finally {
|
|
362
|
+
// Always release the install lock, whether setup succeeded or failed.
|
|
363
|
+
releaseInstallLock(lockHandle);
|
|
364
|
+
}
|
|
266
365
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skeleton guardrail tests — validate .openpalm/ directory structure matches v0.11.0.
|
|
3
|
+
*
|
|
4
|
+
* The .openpalm/ directory is the repo-shipped OP_HOME skeleton. These tests
|
|
5
|
+
* prevent reintroduction of pre-v0.11.0 directories (stack/, registry/,
|
|
6
|
+
* stash-seeds/) and ensure the v0.11.0 structure stays intact.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, test, expect } from "bun:test";
|
|
9
|
+
import { readdirSync, statSync, existsSync } from "node:fs";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
|
|
12
|
+
const REPO_ROOT = resolve(import.meta.dir, "../../../..");
|
|
13
|
+
const SKELETON_DIR = join(REPO_ROOT, ".openpalm");
|
|
14
|
+
|
|
15
|
+
// Allowed top-level dirs in .openpalm/ — mirrors the OP_HOME runtime layout
|
|
16
|
+
const ALLOWED_SOURCE_DIRS = new Set([
|
|
17
|
+
"config", // seed files for config/ (assistant, guardian, stack/, akm/)
|
|
18
|
+
"stash", // stash source assets: skills/ and vaults/
|
|
19
|
+
"state", // state/registry/ + empty service dirs (.gitkeep)
|
|
20
|
+
"cache", // empty cache dirs (.gitkeep — regenerable at runtime)
|
|
21
|
+
"workspace", // empty workspace dir (.gitkeep)
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
// ── Top-level structure ───────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
describe("skeleton: .openpalm/ top-level directories", () => {
|
|
27
|
+
test("only allowed directories exist", () => {
|
|
28
|
+
const entries = readdirSync(SKELETON_DIR);
|
|
29
|
+
const dirs = entries.filter(e => {
|
|
30
|
+
try { return statSync(join(SKELETON_DIR, e)).isDirectory(); } catch { return false; }
|
|
31
|
+
});
|
|
32
|
+
const unexpected = dirs.filter(d => !ALLOWED_SOURCE_DIRS.has(d));
|
|
33
|
+
expect(unexpected).toEqual([]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("stack/ no longer exists (moved to config/stack/)", () => {
|
|
37
|
+
expect(existsSync(join(SKELETON_DIR, "stack"))).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("registry/ no longer exists (moved to state/registry/)", () => {
|
|
41
|
+
expect(existsSync(join(SKELETON_DIR, "registry"))).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("stash-seeds/ no longer exists (moved to stash/)", () => {
|
|
45
|
+
expect(existsSync(join(SKELETON_DIR, "stash-seeds"))).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ── config/ subdirectory ──────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
describe("skeleton: .openpalm/config/ structure", () => {
|
|
52
|
+
test("config/stack/ exists with core.compose.yml and stack.yml", () => {
|
|
53
|
+
expect(existsSync(join(SKELETON_DIR, "config", "stack", "core.compose.yml"))).toBe(true);
|
|
54
|
+
expect(existsSync(join(SKELETON_DIR, "config", "stack", "stack.yml"))).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("config/stack/addons/ exists", () => {
|
|
58
|
+
expect(existsSync(join(SKELETON_DIR, "config", "stack", "addons"))).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("config/akm/ exists", () => {
|
|
62
|
+
expect(existsSync(join(SKELETON_DIR, "config", "akm"))).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("config/assistant/ has seed files", () => {
|
|
66
|
+
expect(existsSync(join(SKELETON_DIR, "config", "assistant", "opencode.json"))).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ── state/registry/ subdirectory ─────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
describe("skeleton: .openpalm/state/registry/ structure", () => {
|
|
73
|
+
test("state/registry/addons/ exists with addon subdirectories", () => {
|
|
74
|
+
const addonsDir = join(SKELETON_DIR, "state", "registry", "addons");
|
|
75
|
+
expect(existsSync(addonsDir)).toBe(true);
|
|
76
|
+
const addons = readdirSync(addonsDir);
|
|
77
|
+
expect(addons).toContain("chat");
|
|
78
|
+
expect(addons).toContain("api");
|
|
79
|
+
expect(addons).toContain("discord");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("state/registry/automations/ exists", () => {
|
|
83
|
+
expect(existsSync(join(SKELETON_DIR, "state", "registry", "automations"))).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("each addon has compose.yml", () => {
|
|
87
|
+
const addonsDir = join(SKELETON_DIR, "state", "registry", "addons");
|
|
88
|
+
const addons = readdirSync(addonsDir).filter(e => {
|
|
89
|
+
try { return statSync(join(addonsDir, e)).isDirectory(); } catch { return false; }
|
|
90
|
+
});
|
|
91
|
+
for (const addon of addons) {
|
|
92
|
+
expect(existsSync(join(addonsDir, addon, "compose.yml"))).toBe(true);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ── stash/ subdirectory ───────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
describe("skeleton: .openpalm/stash/ structure", () => {
|
|
100
|
+
test("stash/skills/ exists with config-diagnostics skill", () => {
|
|
101
|
+
expect(existsSync(join(SKELETON_DIR, "stash", "skills", "config-diagnostics", "SKILL.md"))).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("stash/vaults/ exists", () => {
|
|
105
|
+
expect(existsSync(join(SKELETON_DIR, "stash", "vaults"))).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("stash/tasks/ exists", () => {
|
|
109
|
+
expect(existsSync(join(SKELETON_DIR, "stash", "tasks"))).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ── state/ service dirs ───────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
describe("skeleton: .openpalm/state/ service directories", () => {
|
|
116
|
+
const serviceDirs = ["assistant", "admin", "guardian", "logs", "backups"];
|
|
117
|
+
|
|
118
|
+
for (const dir of serviceDirs) {
|
|
119
|
+
test(`state/${dir}/ exists`, () => {
|
|
120
|
+
expect(existsSync(join(SKELETON_DIR, "state", dir))).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
test("state/akm/data/ exists", () => {
|
|
125
|
+
expect(existsSync(join(SKELETON_DIR, "state", "akm", "data"))).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("state/akm/state/ exists", () => {
|
|
129
|
+
expect(existsSync(join(SKELETON_DIR, "state", "akm", "state"))).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("state/logs/opencode/ exists", () => {
|
|
133
|
+
expect(existsSync(join(SKELETON_DIR, "state", "logs", "opencode"))).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ── cache/ and workspace/ ─────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
describe("skeleton: .openpalm/cache/ and workspace/", () => {
|
|
140
|
+
test("cache/akm/ exists", () => {
|
|
141
|
+
expect(existsSync(join(SKELETON_DIR, "cache", "akm"))).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("cache/rollback/ exists", () => {
|
|
145
|
+
expect(existsSync(join(SKELETON_DIR, "cache", "rollback"))).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("workspace/ exists", () => {
|
|
149
|
+
expect(existsSync(join(SKELETON_DIR, "workspace"))).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
});
|