@openpalm/lib 0.10.2 → 0.11.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +4 -2
  2. package/package.json +11 -3
  3. package/src/control-plane/akm-vault.test.ts +105 -0
  4. package/src/control-plane/akm-vault.ts +311 -0
  5. package/src/control-plane/channels.ts +11 -9
  6. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  7. package/src/control-plane/compose-args.test.ts +25 -33
  8. package/src/control-plane/compose-args.ts +0 -4
  9. package/src/control-plane/compose-errors.test.ts +106 -0
  10. package/src/control-plane/compose-errors.ts +117 -0
  11. package/src/control-plane/config-persistence.ts +148 -73
  12. package/src/control-plane/core-assets.test.ts +104 -0
  13. package/src/control-plane/core-assets.ts +111 -58
  14. package/src/control-plane/docker.ts +70 -25
  15. package/src/control-plane/env.test.ts +25 -1
  16. package/src/control-plane/env.ts +84 -1
  17. package/src/control-plane/home.ts +66 -69
  18. package/src/control-plane/host-opencode.test.ts +260 -0
  19. package/src/control-plane/host-opencode.ts +229 -0
  20. package/src/control-plane/install-edge-cases.test.ts +190 -292
  21. package/src/control-plane/install-lock.ts +157 -0
  22. package/src/control-plane/lifecycle.ts +65 -75
  23. package/src/control-plane/markdown-task.ts +200 -0
  24. package/src/control-plane/migrate-0110.test.ts +177 -0
  25. package/src/control-plane/migrate-0110.ts +99 -0
  26. package/src/control-plane/operator-ids.test.ts +130 -0
  27. package/src/control-plane/operator-ids.ts +89 -0
  28. package/src/control-plane/paths.ts +80 -0
  29. package/src/control-plane/provider-models.ts +154 -0
  30. package/src/control-plane/registry-components.test.ts +105 -27
  31. package/src/control-plane/registry.test.ts +247 -51
  32. package/src/control-plane/registry.ts +404 -54
  33. package/src/control-plane/rollback.ts +17 -16
  34. package/src/control-plane/scheduler.ts +75 -262
  35. package/src/control-plane/secret-mappings.ts +4 -8
  36. package/src/control-plane/secrets.ts +97 -55
  37. package/src/control-plane/setup-config.schema.json +5 -17
  38. package/src/control-plane/setup-status.ts +9 -29
  39. package/src/control-plane/setup-validation.ts +23 -23
  40. package/src/control-plane/setup.test.ts +143 -244
  41. package/src/control-plane/setup.ts +216 -133
  42. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  43. package/src/control-plane/spec-to-env.test.ts +75 -60
  44. package/src/control-plane/spec-to-env.ts +68 -153
  45. package/src/control-plane/stack-spec.test.ts +22 -84
  46. package/src/control-plane/stack-spec.ts +9 -89
  47. package/src/control-plane/types.ts +9 -29
  48. package/src/control-plane/ui-assets.ts +385 -0
  49. package/src/control-plane/validate.ts +44 -79
  50. package/src/index.ts +102 -56
  51. package/src/logger.test.ts +228 -0
  52. package/src/logger.ts +71 -1
  53. package/src/provider-constants.ts +22 -1
  54. package/src/control-plane/audit.ts +0 -40
  55. package/src/control-plane/env-schema-validation.test.ts +0 -118
  56. package/src/control-plane/lock.test.ts +0 -194
  57. package/src/control-plane/lock.ts +0 -176
  58. package/src/control-plane/memory-config.ts +0 -298
  59. package/src/control-plane/provider-config.ts +0 -34
  60. package/src/control-plane/redact-schema.ts +0 -50
  61. package/src/control-plane/secret-backend.test.ts +0 -359
  62. package/src/control-plane/secret-backend.ts +0 -322
  63. package/src/control-plane/spec-validator.ts +0 -159
@@ -5,35 +5,47 @@
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";
9
- import { randomBytes } from "node:crypto";
8
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
9
+ import { join } from "node:path";
10
10
  import { createLogger } from "../logger.js";
11
11
  import {
12
12
  PROVIDER_KEY_MAP,
13
- EMBEDDING_DIMS,
14
- OLLAMA_INSTACK_URL,
15
13
  } from "../provider-constants.js";
16
14
  import { mergeEnvContent } from "./env.js";
17
15
  import { ensureHomeDirs } from "./home.js";
16
+ import { acquireInstallLock, releaseInstallLock, type InstallLockHandle } from "./install-lock.js";
18
17
  import {
19
18
  ensureSecrets,
20
19
  updateSecretsEnv,
21
- updateSystemSecretsEnv,
20
+ patchSecretsEnvFile,
22
21
  ensureOpenCodeConfig,
23
22
  readStackEnv,
23
+ writeAuthJsonProviderKeys,
24
24
  } from "./secrets.js";
25
- import { ensureOpenCodeSystemConfig, ensureMemoryDir } from "./core-assets.js";
26
- import { createState, writeSetupTokenFile } from "./lifecycle.js";
25
+ import { createState } from "./lifecycle.js";
27
26
  import { writeStackSpec } from "./stack-spec.js";
28
- import type { StackSpec, StackSpecCapabilities } from "./stack-spec.js";
29
- import { writeCapabilityVars } from "./spec-to-env.js";
27
+ import { writeVoiceVars } from "./spec-to-env.js";
30
28
  import type { ControlPlaneState } from "./types.js";
31
29
  import { validateSetupSpec } from "./setup-validation.js";
32
- import { listEnabledAddonIds } from "./registry.js";
30
+ import { getRegistryAutomation, setAddonEnabled } from "./registry.js";
33
31
  export { validateSetupSpec } from "./setup-validation.js";
34
32
 
35
33
  const logger = createLogger("setup");
36
34
 
35
+ // ── Atomic write helper ──────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Write `content` to `path` atomically: write to `path.tmp` first, then
39
+ * rename over the target. On POSIX this rename is atomic — a reader always
40
+ * sees either the old file or the new file, never a partially-written one.
41
+ * If the tmp write fails the original file is untouched.
42
+ */
43
+ function writeFileAtomic(path: string, content: string | Uint8Array, mode?: number): void {
44
+ const tmp = `${path}.tmp`;
45
+ writeFileSync(tmp, content, mode !== undefined ? { mode } : {});
46
+ renameSync(tmp, path);
47
+ }
48
+
37
49
  // ── Types ────────────────────────────────────────────────────────────────
38
50
 
39
51
  export type SetupConnection = {
@@ -52,35 +64,32 @@ export type SetupResult = {
52
64
 
53
65
  export type SetupSpec = {
54
66
  version: 2;
55
- capabilities: StackSpecCapabilities;
56
- security: { adminToken: string };
67
+ llm?: { provider: string; model: string; baseUrl?: string };
68
+ embedding?: { provider: string; model: string; dims: number; baseUrl?: string };
69
+ tts?: { enabled?: boolean; engine?: string; provider?: string; baseURL?: string; model?: string; voice?: string };
70
+ stt?: { enabled?: boolean; engine?: string; provider?: string; baseURL?: string; model?: string; language?: string };
71
+ /**
72
+ * Operator-supplied UI login password. Persisted to stack.env as
73
+ * `OP_UI_LOGIN_PASSWORD`. Replaces the legacy `adminToken` field
74
+ * (Phase 4 of docs/technical/auth-and-proxy-refactor-plan.md).
75
+ */
76
+ security: { uiLoginPassword: string };
57
77
  owner?: { name?: string; email?: string };
58
78
  connections: SetupConnection[];
59
79
  channelCredentials?: Record<string, Record<string, string>>;
80
+ addons?: Record<string, boolean>;
81
+ imageTag?: string;
82
+ hostAkm?: boolean;
60
83
  };
61
84
 
62
85
  // ── Secrets Builder ──────────────────────────────────────────────────────
63
86
 
64
87
  /**
65
- * Map provider id env var for a custom base URL override.
66
- * Allows writeCapabilityVars to resolve non-default endpoints.
88
+ * Build the stack.env update payload from a setup spec. Provider API
89
+ * keys are NOT included here — credentials live in OpenCode's auth.json
90
+ * (see buildAuthJsonFromSetup), not stack.env. This function returns
91
+ * only non-credential vars: owner identity and similar.
67
92
  */
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
93
  export function buildSecretsFromSetup(
85
94
  connections: SetupConnection[],
86
95
  owner?: { name?: string; email?: string },
@@ -88,62 +97,53 @@ export function buildSecretsFromSetup(
88
97
  const updates: Record<string, string> = {};
89
98
  const ownerName = (owner?.name?.trim() ?? "").replace(/[\r\n\0]/g, "").slice(0, 200);
90
99
  const ownerEmail = (owner?.email?.trim() ?? "").replace(/[\r\n\0]/g, "").slice(0, 200);
91
- if (ownerName) updates.OWNER_NAME = ownerName;
92
- 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
- }
100
+ if (ownerName) updates.OP_OWNER_NAME = ownerName;
101
+ if (ownerEmail) updates.OP_OWNER_EMAIL = ownerEmail;
102
+ void connections;
107
103
  return updates;
108
104
  }
109
105
 
110
106
  /**
111
- * Read auth.json and extract API keys for OAuth-authenticated providers.
112
- * This fills the gap where OAuth auth writes tokens to auth.json but
113
- * not to stack.env the memory service needs them as env vars.
107
+ * Build the auth.json payload from a setup spec. Returns a record of
108
+ * `{ providerId: apiKey }` ready to feed into writeAuthJsonProviderKeys.
109
+ * Pulls keys from the spec first, falling back to the host process
110
+ * environment for the canonical env var name (e.g. OPENAI_API_KEY for
111
+ * provider "openai") so operators can preload keys via env before
112
+ * running the wizard.
114
113
  */
115
- export function extractAuthJsonKeys(vaultDir: string): Record<string, string> {
116
- const authJsonPath = `${vaultDir}/stack/auth.json`;
117
- if (!existsSync(authJsonPath)) return {};
118
- try {
119
- const raw = readFileSync(authJsonPath, "utf-8").trim();
120
- if (!raw || raw === "{}") return {};
121
- const auth = JSON.parse(raw) as Record<string, unknown>;
122
- const updates: Record<string, string> = {};
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 {};
114
+ export function buildAuthJsonFromSetup(
115
+ connections: SetupConnection[],
116
+ ): Record<string, string> {
117
+ const keys: Record<string, string> = {};
118
+ for (const cap of connections) {
119
+ const envVar = PROVIDER_KEY_MAP[cap.provider];
120
+ const key = cap.apiKey || (envVar ? process.env[envVar] : undefined) || "";
121
+ if (key) keys[cap.provider] = key;
136
122
  }
123
+ return keys;
137
124
  }
138
125
 
126
+ /**
127
+ * Build the system-secret env update for the wizard / CLI install path.
128
+ *
129
+ * Phase 4 of the auth/proxy refactor collapsed the legacy
130
+ * `OP_UI_TOKEN` / `OP_ASSISTANT_TOKEN` pair into a single operator login
131
+ * secret (`OP_UI_LOGIN_PASSWORD`). The browser stores the cookie value =
132
+ * password; `requireAdmin()` compares the cookie against
133
+ * `process.env.OP_UI_LOGIN_PASSWORD` via the existing `safeTokenCompare`.
134
+ *
135
+ * `OP_OPENCODE_PASSWORD` is generated by `ensureSystemSecrets()` on first
136
+ * run and persists across reruns — it is not regenerated here.
137
+ *
138
+ * `existingSystemEnv` is unused now but the parameter is kept so callers
139
+ * compile unchanged. It can be removed in a follow-up cleanup.
140
+ */
139
141
  export function buildSystemSecretsFromSetup(
140
- adminToken: string,
141
- existingSystemEnv: Record<string, string> = {}
142
+ uiLoginPassword: string,
143
+ _existingSystemEnv: Record<string, string> = {}
142
144
  ): Record<string, string> {
143
145
  return {
144
- OP_ADMIN_TOKEN: adminToken,
145
- OP_ASSISTANT_TOKEN: existingSystemEnv.OP_ASSISTANT_TOKEN || randomBytes(32).toString("hex"),
146
- OP_MEMORY_TOKEN: existingSystemEnv.OP_MEMORY_TOKEN || randomBytes(32).toString("hex"),
146
+ OP_UI_LOGIN_PASSWORD: uiLoginPassword,
147
147
  };
148
148
  }
149
149
 
@@ -192,75 +192,158 @@ export async function performSetup(
192
192
  const validation = validateSetupSpec(input);
193
193
  if (!validation.valid) return { ok: false, error: validation.errors.join("; ") };
194
194
 
195
- const { capabilities, security, owner, connections, channelCredentials } = input;
196
- 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 });
195
+ const { llm, embedding, tts, stt, security, owner, connections, channelCredentials, addons, imageTag, hostAkm } = input;
196
+ const state = opts?.state ?? createState();
200
197
 
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
-
207
- // Merge OAuth-authenticated provider keys from auth.json
208
- // (OAuth flows store tokens in auth.json, not in the setup payload)
209
- const oauthKeys = extractAuthJsonKeys(state.vaultDir);
210
- for (const [key, value] of Object.entries(oauthKeys)) {
211
- // Only fill in keys that weren't already provided via API key entry
212
- if (!updates[key]) updates[key] = value;
198
+ // Acquire install lock to prevent two concurrent setup runs from racing on
199
+ // the same config directory. The lock lives in stateDir so it is co-located
200
+ // with runtime state and the same path startDeploy uses.
201
+ const lockHandle: InstallLockHandle | null = acquireInstallLock(state.stateDir);
202
+ if (lockHandle === null) {
203
+ return {
204
+ ok: false,
205
+ error:
206
+ "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.",
207
+ };
213
208
  }
214
209
 
215
- // Persist vault env files
210
+ logger.info("performing setup", { connectionCount: connections.length });
211
+ const updates = buildSecretsFromSetup(connections, owner);
212
+ const providerKeys = buildAuthJsonFromSetup(connections);
213
+
214
+ // Wrap all persistence work in try/finally so the lock is ALWAYS released.
216
215
  try {
217
- ensureHomeDirs();
218
- ensureSecrets(state);
219
- const existingSystemEnv = readStackEnv(state.vaultDir);
220
- if (channelCredentials) Object.assign(updates, buildChannelCredentialEnvVars(channelCredentials));
221
- // Pick up channel credential env vars not already provided in the spec
222
- for (const mapping of Object.values(CHANNEL_CREDENTIAL_ENV_MAP)) {
223
- for (const envKey of Object.values(mapping)) {
224
- if (!updates[envKey] && process.env[envKey]) updates[envKey] = process.env[envKey];
216
+ // Persist vault env files + OpenCode auth.json
217
+ try {
218
+ ensureHomeDirs();
219
+ ensureSecrets(state);
220
+ const existingSystemEnv = readStackEnv(state.stackDir);
221
+ if (channelCredentials) Object.assign(updates, buildChannelCredentialEnvVars(channelCredentials));
222
+ // Pick up channel credential env vars not already provided in the spec
223
+ for (const mapping of Object.values(CHANNEL_CREDENTIAL_ENV_MAP)) {
224
+ for (const envKey of Object.values(mapping)) {
225
+ if (!updates[envKey] && process.env[envKey]) updates[envKey] = process.env[envKey];
226
+ }
225
227
  }
228
+ updateSecretsEnv(state, updates);
229
+ patchSecretsEnvFile(state.stackDir, buildSystemSecretsFromSetup(security.uiLoginPassword, existingSystemEnv));
230
+ // Provider API keys land in OpenCode's auth.json (bind-mounted into
231
+ // the assistant container) — never in stack.env.
232
+ writeAuthJsonProviderKeys(state, providerKeys);
233
+ } catch (err) {
234
+ const message = err instanceof Error ? err.message : String(err);
235
+ logger.error("failed to persist setup outputs", { error: message });
236
+ return { ok: false, error: `Failed to persist setup outputs: ${message}` };
226
237
  }
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
238
 
235
- state.adminToken = security.adminToken;
236
- state.assistantToken = readStackEnv(state.vaultDir).OP_ASSISTANT_TOKEN ?? state.assistantToken;
237
- writeSetupTokenFile(state);
239
+ // Everything from here through the OP_SETUP_COMPLETE write is wrapped in a
240
+ // single try/catch so that a disk-full or permission-denied mid-way returns a
241
+ // clean error rather than leaving a broken half-installed ~/.openpalm/.
242
+ try {
243
+ // Write stack.yml (version marker only)
244
+ writeStackSpec(state.stackDir, { version: 2 });
245
+
246
+ // Write image tag and AKM mount paths to stack.env — atomic to avoid
247
+ // partial writes if the process is interrupted mid-write.
248
+ const systemEnvForAkm = existsSync(`${state.stackDir}/stack.env`)
249
+ ? readFileSync(`${state.stackDir}/stack.env`, "utf-8")
250
+ : "";
251
+ const akmUpdates: Record<string, string> = {};
252
+ if (imageTag) akmUpdates.OP_IMAGE_TAG = imageTag;
253
+ if (hostAkm) {
254
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
255
+ if (home) {
256
+ akmUpdates.OP_AKM_STASH = `${home}/akm`;
257
+ akmUpdates.OP_AKM_DATA = `${home}/.local/share/akm`;
258
+ akmUpdates.OP_AKM_STATE = `${home}/.local/state/akm`;
259
+ akmUpdates.OP_AKM_CACHE = `${home}/.cache/akm`;
260
+ akmUpdates.OP_AKM_CONFIG = `${home}/.config/akm`;
261
+ }
262
+ }
263
+ if (Object.keys(akmUpdates).length > 0) {
264
+ writeFileAtomic(`${state.stackDir}/stack.env`, mergeEnvContent(systemEnvForAkm, akmUpdates), 0o600);
265
+ }
266
+
267
+ // Write akm config with LLM and embedding settings from setup — atomic.
268
+ if (llm || embedding) {
269
+ const akmConfigDir = join(state.configDir, "akm");
270
+ mkdirSync(akmConfigDir, { recursive: true });
271
+ const akmConfigPath = join(akmConfigDir, "config.json");
272
+ let existing: Record<string, unknown> = {};
273
+ if (existsSync(akmConfigPath)) {
274
+ try { existing = JSON.parse(readFileSync(akmConfigPath, "utf-8")); } catch { /* ignore corrupt */ }
275
+ }
276
+ const updated = { ...existing };
277
+ if (llm) {
278
+ const base = llm.baseUrl ? llm.baseUrl.replace(/\/+$/, "") : "";
279
+ updated.llm = {
280
+ ...((existing.llm as Record<string, unknown>) ?? {}),
281
+ endpoint: base ? `${base}/chat/completions` : "",
282
+ model: llm.model,
283
+ provider: llm.provider,
284
+ };
285
+ }
286
+ if (embedding) {
287
+ const base = embedding.baseUrl ? embedding.baseUrl.replace(/\/+$/, "") : "";
288
+ updated.embedding = {
289
+ ...((existing.embedding as Record<string, unknown>) ?? {}),
290
+ endpoint: base ? `${base}/embeddings` : "",
291
+ model: embedding.model,
292
+ provider: embedding.provider,
293
+ dimension: embedding.dims,
294
+ };
295
+ }
296
+ writeFileAtomic(akmConfigPath, JSON.stringify(updated, null, 2), 0o600);
297
+ }
238
298
 
239
- // Write stack.yml and OP_CAP_* capability vars to stack.env
240
- writeMemoryAndStackConfigs({ version: 2, capabilities }, state);
299
+ // Write TTS/STT vars to stack.env for the voice channel
300
+ if (tts || stt) {
301
+ writeVoiceVars({ tts, stt }, state.stackDir);
302
+ }
241
303
 
242
- ensureOpenCodeConfig();
243
- ensureOpenCodeSystemConfig();
244
- ensureMemoryDir();
304
+ // Enable requested addons (channels like discord, slack, etc.)
305
+ // setAddonEnabled copies the compose overlay AND generates CHANNEL_<NAME>_SECRET in guardian.env
306
+ if (addons) {
307
+ for (const [name, enabled] of Object.entries(addons)) {
308
+ if (enabled) setAddonEnabled(state.homeDir, state.stackDir, name, true);
309
+ }
310
+ }
245
311
 
246
- // Mark setup complete in vault/stack/stack.env (where isSetupComplete reads it)
247
- const systemEnvPath = `${state.vaultDir}/stack/stack.env`;
248
- const systemBase = existsSync(systemEnvPath) ? readFileSync(systemEnvPath, "utf-8") : "";
249
- writeFileSync(systemEnvPath, mergeEnvContent(systemBase, { OP_SETUP_COMPLETE: "true" }), { mode: 0o600 });
312
+ ensureOpenCodeConfig();
250
313
 
251
- logger.info("setup complete", { capabilityCount: connections.length });
252
- return { ok: true };
253
- }
314
+ // Seed default automation into the AKM stash. Idempotent — existing files
315
+ // are left alone so user edits survive re-install and upgrade.
316
+ const tasksDir = join(state.stashDir, "tasks");
317
+ mkdirSync(tasksDir, { recursive: true });
318
+ const akmImproveDest = join(tasksDir, "akm-improve.md");
319
+ if (!existsSync(akmImproveDest)) {
320
+ const akmImproveMd = getRegistryAutomation("akm-improve");
321
+ if (akmImproveMd) {
322
+ writeFileSync(akmImproveDest, akmImproveMd);
323
+ logger.info("seeded default automation", { name: "akm-improve" });
324
+ } else {
325
+ logger.warn("default automation missing from registry; skipping seed", {
326
+ name: "akm-improve",
327
+ });
328
+ }
329
+ }
254
330
 
255
- /** Write stack.yml and OP_CAP_* capability vars to stack.env from the spec's capabilities. */
256
- function writeMemoryAndStackConfigs(spec: StackSpec, state: ControlPlaneState): void {
257
- const { provider: embProvider, model: embModel } = spec.capabilities.embeddings;
258
- const resolvedDims = spec.capabilities.embeddings.dims || EMBEDDING_DIMS[`${embProvider}/${embModel}`] || 1536;
331
+ // NOTE: OP_SETUP_COMPLETE is intentionally NOT written here. Writing it
332
+ // before the Docker deploy succeeds would mark setup "complete" even
333
+ // when containers fail to start, sending the user to a broken admin UI
334
+ // with no path back to the wizard. The flag is now written by
335
+ // setup-deploy.ts:startDeploy AFTER pollContainerHealth confirms every
336
+ // container is healthy.
337
+ } catch (err) {
338
+ const message = err instanceof Error ? err.message : String(err);
339
+ logger.error("failed to complete setup persistence", { error: message });
340
+ return { ok: false, error: `Setup persistence failed: ${message}` };
341
+ }
259
342
 
260
- const specToWrite: StackSpec = {
261
- ...spec,
262
- capabilities: { ...spec.capabilities, embeddings: { ...spec.capabilities.embeddings, dims: resolvedDims } },
263
- };
264
- writeStackSpec(state.configDir, specToWrite);
265
- writeCapabilityVars(specToWrite, state.vaultDir);
343
+ logger.info("setup complete", { connectionCount: connections.length });
344
+ return { ok: true };
345
+ } finally {
346
+ // Always release the install lock, whether setup succeeded or failed.
347
+ releaseInstallLock(lockHandle);
348
+ }
266
349
  }
@@ -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
+ });