@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.
Files changed (55) hide show
  1. package/README.md +2 -2
  2. package/package.json +7 -3
  3. package/src/control-plane/admin-token.ts +73 -0
  4. package/src/control-plane/akm-vault.test.ts +108 -0
  5. package/src/control-plane/akm-vault.ts +307 -0
  6. package/src/control-plane/audit.ts +3 -2
  7. package/src/control-plane/channels.ts +3 -3
  8. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  9. package/src/control-plane/compose-args.test.ts +25 -21
  10. package/src/control-plane/config-persistence.ts +103 -64
  11. package/src/control-plane/core-assets.test.ts +104 -0
  12. package/src/control-plane/core-assets.ts +54 -57
  13. package/src/control-plane/docker.ts +55 -21
  14. package/src/control-plane/env.test.ts +25 -1
  15. package/src/control-plane/env.ts +80 -0
  16. package/src/control-plane/home.ts +66 -69
  17. package/src/control-plane/host-opencode.test.ts +263 -0
  18. package/src/control-plane/host-opencode.ts +229 -0
  19. package/src/control-plane/install-edge-cases.test.ts +182 -244
  20. package/src/control-plane/install-lock.ts +157 -0
  21. package/src/control-plane/lifecycle.ts +57 -56
  22. package/src/control-plane/markdown-task.ts +200 -0
  23. package/src/control-plane/paths.ts +75 -0
  24. package/src/control-plane/provider-config.ts +2 -2
  25. package/src/control-plane/provider-models.ts +154 -0
  26. package/src/control-plane/registry-components.test.ts +102 -25
  27. package/src/control-plane/registry.test.ts +49 -47
  28. package/src/control-plane/registry.ts +71 -50
  29. package/src/control-plane/rollback.ts +17 -16
  30. package/src/control-plane/scheduler.ts +75 -262
  31. package/src/control-plane/secret-backend.test.ts +98 -108
  32. package/src/control-plane/secret-backend.ts +221 -181
  33. package/src/control-plane/secret-mappings.ts +3 -6
  34. package/src/control-plane/secrets.ts +83 -47
  35. package/src/control-plane/setup-config.schema.json +2 -14
  36. package/src/control-plane/setup-status.ts +4 -29
  37. package/src/control-plane/setup-validation.ts +21 -21
  38. package/src/control-plane/setup.test.ts +122 -227
  39. package/src/control-plane/setup.ts +224 -125
  40. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  41. package/src/control-plane/spec-to-env.test.ts +59 -58
  42. package/src/control-plane/spec-to-env.ts +39 -140
  43. package/src/control-plane/spec-validator.ts +2 -99
  44. package/src/control-plane/stack-spec.test.ts +21 -77
  45. package/src/control-plane/stack-spec.ts +7 -83
  46. package/src/control-plane/types.ts +17 -15
  47. package/src/control-plane/ui-assets.ts +349 -0
  48. package/src/control-plane/validate.ts +44 -79
  49. package/src/index.ts +77 -44
  50. package/src/logger.test.ts +228 -0
  51. package/src/logger.ts +71 -1
  52. package/src/provider-constants.ts +22 -1
  53. package/src/control-plane/env-schema-validation.test.ts +0 -118
  54. package/src/control-plane/memory-config.ts +0 -298
  55. package/src/control-plane/redact-schema.ts +0 -50
@@ -8,7 +8,7 @@ export type SecretProviderConfig = {
8
8
  };
9
9
 
10
10
  function providerConfigPath(state: ControlPlaneState): string {
11
- return `${state.dataDir}/secrets/provider.json`;
11
+ return `${state.stateDir}/secrets/provider.json`;
12
12
  }
13
13
 
14
14
  export function readSecretProviderConfig(state: ControlPlaneState): SecretProviderConfig | null {
@@ -28,7 +28,7 @@ export function readSecretProviderConfig(state: ControlPlaneState): SecretProvid
28
28
  }
29
29
 
30
30
  export function writeSecretProviderConfig(state: ControlPlaneState, config: SecretProviderConfig): void {
31
- const dir = `${state.dataDir}/secrets`;
31
+ const dir = `${state.stateDir}/secrets`;
32
32
  mkdirSync(dir, { recursive: true });
33
33
  writeFileSync(providerConfigPath(state), JSON.stringify(config, null, 2) + '\n');
34
34
  }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Provider model discovery and API key resolution.
3
+ *
4
+ * Used by the admin capabilities test endpoint and the CLI setup wizard
5
+ * to enumerate the models a configured provider exposes.
6
+ */
7
+ import { readStackEnv } from "./secrets.js";
8
+ import { PROVIDER_DEFAULT_URLS } from "../provider-constants.js";
9
+
10
+ /** Static model list for Anthropic (no listing API available). */
11
+ const ANTHROPIC_MODELS = [
12
+ "claude-opus-4-6",
13
+ "claude-sonnet-4-6",
14
+ "claude-opus-4-20250514",
15
+ "claude-sonnet-4-20250514",
16
+ "claude-haiku-4-5-20251001",
17
+ "claude-3-5-sonnet-20241022",
18
+ "claude-3-5-haiku-20241022",
19
+ ];
20
+
21
+
22
+ /**
23
+ * Resolve an API key reference.
24
+ *
25
+ * - Empty input → empty string.
26
+ * - `env:NAME` form → looks up `NAME` in `process.env` first, then falls back
27
+ * to `config/stack/stack.env` resolved against `stackDir`.
28
+ * - Anything else → returned verbatim (treated as a literal key value).
29
+ */
30
+ function resolveApiKey(apiKeyRef: string, stackDir: string): string {
31
+ if (!apiKeyRef) return "";
32
+ if (!apiKeyRef.startsWith("env:")) return apiKeyRef;
33
+
34
+ const varName = apiKeyRef.slice(4);
35
+ if (process.env[varName]) return process.env[varName]!;
36
+
37
+ const secrets = readStackEnv(stackDir);
38
+ return secrets[varName] ?? "";
39
+ }
40
+
41
+
42
+ export type ModelDiscoveryReason =
43
+ | 'none'
44
+ | 'provider_static'
45
+ | 'provider_http'
46
+ | 'missing_base_url'
47
+ | 'timeout'
48
+ | 'network';
49
+
50
+ export type ProviderModelsResult = {
51
+ models: string[];
52
+ status: 'ok' | 'recoverable_error';
53
+ reason: ModelDiscoveryReason;
54
+ error?: string;
55
+ };
56
+
57
+ const HTTP_STATUS_LABELS: Record<number, string> = {
58
+ 401: 'Invalid or missing API key',
59
+ 403: 'Access denied — check API key permissions',
60
+ 404: 'Endpoint not found — verify the base URL',
61
+ 429: 'Rate limited — try again shortly',
62
+ 500: 'Provider internal error',
63
+ 502: 'Provider returned a bad gateway error',
64
+ 503: 'Provider is temporarily unavailable',
65
+ };
66
+
67
+ /**
68
+ * Enumerate available models for a provider. Returns an `ok` result with a
69
+ * sorted model list when the provider responds successfully, or a
70
+ * `recoverable_error` with a structured reason otherwise. Network and timeout
71
+ * failures are caught and mapped to a result rather than thrown.
72
+ */
73
+ export async function fetchProviderModels(
74
+ provider: string,
75
+ apiKeyRef: string,
76
+ baseUrl: string,
77
+ stackDir: string
78
+ ): Promise<ProviderModelsResult> {
79
+ try {
80
+ if (provider === "anthropic") {
81
+ return { models: [...ANTHROPIC_MODELS], status: 'ok', reason: 'provider_static' };
82
+ }
83
+
84
+ const resolvedKey = resolveApiKey(apiKeyRef, stackDir);
85
+
86
+ if (provider === "ollama") {
87
+ const base = baseUrl?.trim() || PROVIDER_DEFAULT_URLS.ollama;
88
+ const url = `${base.replace(/\/+$/, "")}/api/tags`;
89
+ const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
90
+ if (!res.ok) {
91
+ return {
92
+ models: [],
93
+ status: 'recoverable_error',
94
+ reason: 'provider_http',
95
+ error: `Ollama API returned ${res.status}: ${(HTTP_STATUS_LABELS[res.status] ?? `HTTP ${res.status}`)}`,
96
+ };
97
+ }
98
+ const data = (await res.json()) as { models?: { name: string }[] };
99
+ const models = (data.models ?? []).map((m) => m.name).sort();
100
+ return { models, status: 'ok', reason: 'none' };
101
+ }
102
+
103
+ const base = baseUrl?.trim() || PROVIDER_DEFAULT_URLS[provider] || "";
104
+ if (!base) {
105
+ return {
106
+ models: [],
107
+ status: 'recoverable_error',
108
+ reason: 'missing_base_url',
109
+ error: `No base URL configured for provider "${provider}"`,
110
+ };
111
+ }
112
+ const url = `${base.replace(/\/+$/, "")}/v1/models`;
113
+
114
+ const headers: Record<string, string> = {};
115
+ if (resolvedKey) {
116
+ headers["Authorization"] = `Bearer ${resolvedKey}`;
117
+ }
118
+
119
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(5000) });
120
+ if (!res.ok) {
121
+ let detail = '';
122
+ try {
123
+ const json = JSON.parse(await res.text()) as Record<string, unknown>;
124
+ const errObj = json.error as Record<string, unknown> | string | undefined;
125
+ detail = (typeof errObj === 'object' && errObj !== null && typeof errObj.message === 'string') ? errObj.message
126
+ : typeof errObj === 'string' ? errObj
127
+ : typeof json.message === 'string' ? json.message
128
+ : typeof json.detail === 'string' ? json.detail : '';
129
+ } catch { /* ignore parse errors */ }
130
+ return {
131
+ models: [],
132
+ status: 'recoverable_error',
133
+ reason: 'provider_http',
134
+ error: detail
135
+ ? `Provider API returned ${res.status}: ${detail}`
136
+ : `Provider API returned ${res.status}: ${(HTTP_STATUS_LABELS[res.status] ?? `HTTP ${res.status}`)}`,
137
+ };
138
+ }
139
+ const data = (await res.json()) as { data?: { id: string }[] };
140
+ const models = (data.data ?? []).map((m) => m.id).sort();
141
+ return { models, status: 'ok', reason: 'none' };
142
+ } catch (err) {
143
+ const message =
144
+ err instanceof Error && err.name === "TimeoutError"
145
+ ? "Request timed out after 5s"
146
+ : String(err);
147
+ return {
148
+ models: [],
149
+ status: 'recoverable_error',
150
+ reason: err instanceof Error && err.name === 'TimeoutError' ? 'timeout' : 'network',
151
+ error: message,
152
+ };
153
+ }
154
+ }
@@ -1,10 +1,23 @@
1
1
  /**
2
2
  * Tests for the registry component directory format.
3
3
  *
4
- * Validates that all components in .openpalm/registry/addons/ follow the
4
+ * Validates that all components in .openpalm/state/registry/addons/ follow the
5
5
  * component conventions: compose.yml with required labels, .env.schema
6
6
  * with documented variables, proper service naming, and no security
7
7
  * violations.
8
+ *
9
+ * Two component shapes are accepted:
10
+ *
11
+ * 1. Full addons — compose.yml + .env.schema. They introduce a new
12
+ * service, declare env vars, and must satisfy the full structural
13
+ * checklist (labels, network, healthcheck, restart policy, sensitive
14
+ * fields).
15
+ * 2. Overlay-only addons — compose.yml only. They patch existing
16
+ * services (ports, env, volumes) instead of introducing new ones,
17
+ * so they have no env vars to document and no service-shaped
18
+ * requirements. They still must satisfy the security invariants:
19
+ * no INSTANCE_ID, no container_name, no INSTANCE_DIR, no vault
20
+ * directory mounts, no docker socket.
8
21
  */
9
22
  import { describe, expect, it } from "bun:test";
10
23
  import {
@@ -18,7 +31,7 @@ import { join, resolve } from "node:path";
18
31
 
19
32
  /** Resolve path from repo root */
20
33
  const REPO_ROOT = resolve(import.meta.dir, "../../../..");
21
- const REGISTRY_DIR = join(REPO_ROOT, ".openpalm/registry/addons");
34
+ const REGISTRY_DIR = join(REPO_ROOT, ".openpalm/state/registry/addons");
22
35
 
23
36
  /** List all component directories in the registry */
24
37
  function listComponentDirs(): string[] {
@@ -28,6 +41,19 @@ function listComponentDirs(): string[] {
28
41
  .map((d) => d.name);
29
42
  }
30
43
 
44
+ /** Overlay-only addons ship compose.yml only — no .env.schema. */
45
+ function isOverlayOnly(componentId: string): boolean {
46
+ return !existsSync(join(REGISTRY_DIR, componentId, ".env.schema"));
47
+ }
48
+
49
+ function listFullAddonIds(componentIds: string[]): string[] {
50
+ return componentIds.filter((id) => !isOverlayOnly(id));
51
+ }
52
+
53
+ function listOverlayOnlyAddonIds(componentIds: string[]): string[] {
54
+ return componentIds.filter(isOverlayOnly);
55
+ }
56
+
31
57
  /** Read a file from a component directory */
32
58
  function readComponentFile(componentId: string, filename: string): string {
33
59
  return readFileSync(join(REGISTRY_DIR, componentId, filename), "utf-8");
@@ -118,24 +144,67 @@ describe("registry component discovery", () => {
118
144
 
119
145
  describe("registry component required files", () => {
120
146
  const componentIds = listComponentDirs();
147
+ const fullAddonIds = listFullAddonIds(componentIds);
121
148
 
122
149
  for (const id of componentIds) {
123
150
  it(`${id}: has compose.yml`, () => {
124
151
  expect(existsSync(join(REGISTRY_DIR, id, "compose.yml"))).toBe(true);
125
152
  });
153
+ }
126
154
 
127
- it(`${id}: has .env.schema`, () => {
155
+ for (const id of fullAddonIds) {
156
+ it(`${id}: has .env.schema (full addon)`, () => {
128
157
  expect(existsSync(join(REGISTRY_DIR, id, ".env.schema"))).toBe(true);
129
158
  });
130
159
  }
131
160
  });
132
161
 
162
+ // ── Overlay-only Addon Tests ─────────────────────────────────────────────
163
+
164
+ describe("registry overlay-only addons", () => {
165
+ const componentIds = listComponentDirs();
166
+ const overlayIds = listOverlayOnlyAddonIds(componentIds);
167
+
168
+ it("at least one overlay-only addon (ssh) is recognized as valid", () => {
169
+ expect(overlayIds).toContain("ssh");
170
+ });
171
+
172
+ for (const id of overlayIds) {
173
+ describe(id, () => {
174
+ it("ships only compose.yml (no .env.schema, no entrypoint, no Dockerfile)", () => {
175
+ const dirEntries = readdirSync(join(REGISTRY_DIR, id));
176
+ // compose.yml is required; an optional README.md is allowed; nothing
177
+ // else (no .env.schema, no entrypoint*, no Dockerfile, no scripts).
178
+ const allowed = new Set(["compose.yml", "README.md"]);
179
+ for (const file of dirEntries) {
180
+ expect(allowed.has(file)).toBe(true);
181
+ }
182
+ });
183
+
184
+ it("compose.yml does not introduce a new service (no image: or build:)", () => {
185
+ // Overlay-only addons may patch existing services with new ports/env,
186
+ // but they MUST NOT introduce a new service that needs its own
187
+ // network/healthcheck/restart contract — those would belong in a
188
+ // full addon. Reject service definition keys that imply a new
189
+ // service body. A pure overlay only sets `ports:`, `environment:`,
190
+ // `volumes:`, etc. on already-defined services.
191
+ const compose = readComponentFile(id, "compose.yml");
192
+ expect(compose).not.toMatch(/^\s+image:\s/m);
193
+ expect(compose).not.toMatch(/^\s+build:\s/m);
194
+ });
195
+ });
196
+ }
197
+ });
198
+
133
199
  // ── Compose Overlay Validation Tests ─────────────────────────────────────
134
200
 
135
201
  describe("registry compose.yml validation", () => {
136
202
  const componentIds = listComponentDirs();
203
+ const fullAddonIds = listFullAddonIds(componentIds);
137
204
 
138
- for (const id of componentIds) {
205
+ // Full-addon-only assertions: anything that requires a service body
206
+ // (labels, network, healthcheck, restart policy) is checked here.
207
+ for (const id of fullAddonIds) {
139
208
  describe(id, () => {
140
209
  const compose = readComponentFile(id, "compose.yml");
141
210
 
@@ -147,18 +216,6 @@ describe("registry compose.yml validation", () => {
147
216
  expect(compose).toMatch(/openpalm\.description:/);
148
217
  });
149
218
 
150
- it("uses static service name (no INSTANCE_ID)", () => {
151
- expect(compose).not.toContain("${INSTANCE_ID}");
152
- });
153
-
154
- it("does not use container_name", () => {
155
- expect(compose).not.toMatch(/container_name:/);
156
- });
157
-
158
- it("does not reference INSTANCE_DIR", () => {
159
- expect(compose).not.toContain("${INSTANCE_DIR}");
160
- });
161
-
162
219
  it("joins a valid stack network", () => {
163
220
  const hasValidNetwork = compose.includes("channel_lan") || compose.includes("channel_public") || compose.includes("assistant_net");
164
221
  expect(hasValidNetwork).toBe(true);
@@ -171,9 +228,28 @@ describe("registry compose.yml validation", () => {
171
228
  it("has healthcheck", () => {
172
229
  expect(compose).toMatch(/healthcheck:/);
173
230
  });
231
+ });
232
+ }
233
+
234
+ // Security/hygiene assertions apply to ALL addons (full and overlay-only).
235
+ for (const id of componentIds) {
236
+ describe(`${id} (security)`, () => {
237
+ const compose = readComponentFile(id, "compose.yml");
238
+
239
+ it("uses static service name (no INSTANCE_ID)", () => {
240
+ expect(compose).not.toContain("${INSTANCE_ID}");
241
+ });
242
+
243
+ it("does not use container_name", () => {
244
+ expect(compose).not.toMatch(/container_name:/);
245
+ });
246
+
247
+ it("does not reference INSTANCE_DIR", () => {
248
+ expect(compose).not.toContain("${INSTANCE_DIR}");
249
+ });
174
250
 
175
251
  it("does not mount vault directory (single-file mounts allowed)", () => {
176
- // Directory-level vault mounts are a security violation — only admin gets full vault access.
252
+ // Directory-level vault mounts are a security violation — no container may mount the full vault.
177
253
  // Single-file mounts like vault/user/ov.conf are allowed (the source must end with a filename).
178
254
  const lines = compose.split("\n");
179
255
  for (const line of lines) {
@@ -193,8 +269,6 @@ describe("registry compose.yml validation", () => {
193
269
  });
194
270
 
195
271
  it("does not mount docker socket", () => {
196
- // admin component is exempt — docker-socket-proxy IS the docker socket accessor by design
197
- if (id === "admin") return;
198
272
  expect(compose).not.toContain("/var/run/docker.sock");
199
273
  });
200
274
 
@@ -209,8 +283,9 @@ describe("registry compose.yml validation", () => {
209
283
 
210
284
  describe("registry .env.schema validation", () => {
211
285
  const componentIds = listComponentDirs();
286
+ const fullAddonIds = listFullAddonIds(componentIds);
212
287
 
213
- for (const id of componentIds) {
288
+ for (const id of fullAddonIds) {
214
289
  describe(id, () => {
215
290
  const schema = readComponentFile(id, ".env.schema");
216
291
  const entries = parseEnvSchema(schema);
@@ -264,8 +339,9 @@ describe("registry .env.schema validation", () => {
264
339
 
265
340
  describe("registry component sensitive fields", () => {
266
341
  const componentIds = listComponentDirs();
342
+ const fullAddonIds = listFullAddonIds(componentIds);
267
343
 
268
- for (const id of componentIds) {
344
+ for (const id of fullAddonIds) {
269
345
  it(`${id}: has at least one @sensitive field (channel secret)`, () => {
270
346
  // ollama is a local inference server — no channel secret or API key needed
271
347
  if (id === "ollama") return;
@@ -283,10 +359,11 @@ describe("registry component sensitive fields", () => {
283
359
 
284
360
  describe("cross-component consistency", () => {
285
361
  const componentIds = listComponentDirs();
362
+ const fullAddonIds = listFullAddonIds(componentIds);
286
363
 
287
- it("no duplicate openpalm.name labels across components", () => {
364
+ it("no duplicate openpalm.name labels across full addons", () => {
288
365
  const names = new Set<string>();
289
- for (const id of componentIds) {
366
+ for (const id of fullAddonIds) {
290
367
  const compose = readComponentFile(id, "compose.yml");
291
368
  const nameMatch = compose.match(/openpalm\.name:\s*(.+)/);
292
369
  expect(nameMatch).not.toBeNull();
@@ -296,8 +373,8 @@ describe("cross-component consistency", () => {
296
373
  }
297
374
  });
298
375
 
299
- it("all components join a valid stack network", () => {
300
- for (const id of componentIds) {
376
+ it("all full addons join a valid stack network", () => {
377
+ for (const id of fullAddonIds) {
301
378
  const compose = readComponentFile(id, "compose.yml");
302
379
  const hasValidNetwork = compose.includes("channel_lan") || compose.includes("channel_public") || compose.includes("assistant_net");
303
380
  expect(hasValidNetwork).toBe(true);
@@ -201,75 +201,76 @@ describe("materialized registry catalog", () => {
201
201
 
202
202
  it("materializes addons and automations into OP_HOME/registry", () => {
203
203
  const sourceRoot = join(tmpDir, 'repo');
204
- const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
205
- const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
204
+ const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
205
+ const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
206
206
 
207
207
  mkdirSync(addonDir, { recursive: true });
208
208
  mkdirSync(automationsDir, { recursive: true });
209
209
  writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
210
210
  writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
211
- writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: daily\n');
211
+ writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
212
212
 
213
213
  const root = materializeRegistryCatalog(sourceRoot);
214
214
 
215
- expect(root).toBe(join(process.env.OP_HOME!, 'registry'));
215
+ expect(root).toBe(join(process.env.OP_HOME!, 'state', 'registry'));
216
216
  expect(existsSync(join(root, 'addons', 'chat', 'compose.yml'))).toBe(true);
217
217
  expect(existsSync(join(root, 'addons', 'chat', '.env.schema'))).toBe(true);
218
- expect(readFileSync(join(root, 'automations', 'cleanup.yml'), 'utf-8')).toContain('Cleanup');
218
+ expect(readFileSync(join(root, 'automations', 'cleanup.md'), 'utf-8')).toContain('Cleanup');
219
219
  });
220
220
 
221
221
  it("discovers materialized registry entries", () => {
222
222
  const sourceRoot = join(tmpDir, 'repo');
223
- const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
224
- const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
223
+ const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
224
+ const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
225
225
 
226
226
  mkdirSync(addonDir, { recursive: true });
227
227
  mkdirSync(automationsDir, { recursive: true });
228
228
  writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
229
229
  writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
230
- writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: daily\n');
230
+ writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
231
231
 
232
232
  materializeRegistryCatalog(sourceRoot);
233
233
 
234
234
  const components = discoverRegistryComponents();
235
- const automations = discoverRegistryAutomations();
235
+ const stashDir = join(process.env.OP_HOME!, 'stash');
236
+ const automations = discoverRegistryAutomations(stashDir);
236
237
 
237
238
  expect(Object.keys(components)).toEqual(['chat']);
238
239
  expect(components.chat?.schema).toContain('CHANNEL_CHAT_SECRET');
239
240
  expect(automations.map((entry) => entry.name)).toEqual(['cleanup']);
240
- expect(getRegistryAutomation('cleanup')).toContain('schedule: daily');
241
+ expect(getRegistryAutomation('cleanup')).toContain('schedule: "0 3 * * *"');
241
242
  });
242
243
 
243
244
  it("returns addon config metadata from the materialized registry", () => {
244
245
  const sourceRoot = join(tmpDir, 'repo');
245
- const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
246
- const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
246
+ const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
247
+ const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
247
248
 
248
249
  mkdirSync(addonDir, { recursive: true });
249
250
  mkdirSync(automationsDir, { recursive: true });
250
251
  writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
251
252
  writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
252
- writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: daily\n');
253
+ writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
253
254
 
254
255
  materializeRegistryCatalog(sourceRoot);
255
256
 
256
257
  expect(getRegistryAddonConfig(process.env.OP_HOME!, 'chat')).toEqual({
257
- schemaPath: 'registry/addons/chat/.env.schema',
258
- userEnvPath: 'vault/user/user.env',
258
+ schemaPath: 'state/registry/addons/chat/.env.schema',
259
+ userEnvPath: 'config/stack/stack.env',
259
260
  envSchema: 'CHANNEL_CHAT_SECRET=\n',
260
261
  });
261
262
  });
262
263
 
263
264
  it("verifies the materialized registry and returns counts", () => {
264
265
  const sourceRoot = join(tmpDir, 'repo');
265
- const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
266
- const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
266
+ const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
267
+ const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
267
268
 
268
269
  mkdirSync(addonDir, { recursive: true });
269
270
  mkdirSync(automationsDir, { recursive: true });
270
271
  writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
271
272
  writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
272
- writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: daily\n');
273
+ writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
273
274
 
274
275
  const root = materializeRegistryCatalog(sourceRoot);
275
276
 
@@ -286,77 +287,77 @@ describe("materialized registry catalog", () => {
286
287
 
287
288
  it("fails when source catalog is incomplete", () => {
288
289
  const sourceRoot = join(tmpDir, 'repo');
289
- mkdirSync(join(sourceRoot, '.openpalm', 'registry', 'addons'), { recursive: true });
290
- mkdirSync(join(sourceRoot, '.openpalm', 'registry', 'automations'), { recursive: true });
290
+ mkdirSync(join(sourceRoot, '.openpalm', 'state', 'registry', 'addons'), { recursive: true });
291
+ mkdirSync(join(sourceRoot, '.openpalm', 'state', 'registry', 'automations'), { recursive: true });
291
292
 
292
293
  expect(() => materializeRegistryCatalog(sourceRoot)).toThrow('Registry catalog is incomplete');
293
294
  });
294
295
 
295
296
  it("enables and disables addons through the runtime stack directory", () => {
296
297
  const sourceRoot = join(tmpDir, 'repo');
297
- const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
298
- const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
298
+ const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
299
+ const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
299
300
 
300
301
  mkdirSync(addonDir, { recursive: true });
301
302
  mkdirSync(automationsDir, { recursive: true });
302
303
  writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
303
304
  writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
304
- writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: daily\n');
305
+ writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
305
306
 
306
307
  materializeRegistryCatalog(sourceRoot);
307
308
 
308
309
  expect(enableAddon(process.env.OP_HOME!, 'chat')).toEqual({ ok: true });
309
- expect(existsSync(join(process.env.OP_HOME!, 'stack', 'addons', 'chat', 'compose.yml'))).toBe(true);
310
+ expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat', 'compose.yml'))).toBe(true);
310
311
 
311
312
  expect(disableAddonByName(process.env.OP_HOME!, 'chat')).toEqual({ ok: true });
312
- expect(existsSync(join(process.env.OP_HOME!, 'stack', 'addons', 'chat'))).toBe(false);
313
+ expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat'))).toBe(false);
313
314
  });
314
315
 
315
316
  it("returns addon service names from stack or registry compose files", () => {
316
317
  const sourceRoot = join(tmpDir, 'repo');
317
- const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'admin');
318
- const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
318
+ const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'proxy-test');
319
+ const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
319
320
 
320
321
  mkdirSync(addonDir, { recursive: true });
321
322
  mkdirSync(automationsDir, { recursive: true });
322
- writeFileSync(join(addonDir, 'compose.yml'), 'services:\n docker-socket-proxy:\n image: proxy\n admin:\n image: admin\n');
323
- writeFileSync(join(addonDir, '.env.schema'), 'OP_ADMIN_TOKEN=\n');
324
- writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: daily\n');
323
+ writeFileSync(join(addonDir, 'compose.yml'), 'services:\n svc-a:\n image: image-a\n svc-b:\n image: image-b\n');
324
+ writeFileSync(join(addonDir, '.env.schema'), 'PROXY_TOKEN=\n');
325
+ writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
325
326
 
326
327
  materializeRegistryCatalog(sourceRoot);
327
328
 
328
- expect(getAddonServiceNames(process.env.OP_HOME!, 'admin')).toEqual(['docker-socket-proxy', 'admin']);
329
+ expect(getAddonServiceNames(process.env.OP_HOME!, 'proxy-test')).toEqual(['svc-a', 'svc-b']);
329
330
  });
330
331
 
331
332
  it("toggles addons and generates channel secrets when enabling channel addons", () => {
332
333
  const sourceRoot = join(tmpDir, 'repo');
333
- const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
334
- const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
334
+ const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
335
+ const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
335
336
 
336
337
  mkdirSync(addonDir, { recursive: true });
337
338
  mkdirSync(automationsDir, { recursive: true });
338
339
  writeFileSync(join(addonDir, 'compose.yml'), 'services:\n chat:\n image: test\n environment:\n CHANNEL_NAME: "Chat"\n CHANNEL_ID: "chat"\n');
339
340
  writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
340
- writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: daily\n');
341
+ writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
341
342
 
342
343
  materializeRegistryCatalog(sourceRoot);
343
344
 
344
- expect(setAddonEnabled(process.env.OP_HOME!, join(process.env.OP_HOME!, 'vault'), 'chat', true)).toEqual({
345
+ expect(setAddonEnabled(process.env.OP_HOME!, join(process.env.OP_HOME!, 'config', 'stack'), 'chat', true)).toEqual({
345
346
  ok: true,
346
347
  enabled: true,
347
348
  changed: true,
348
349
  services: ['chat'],
349
350
  });
350
- expect(existsSync(join(process.env.OP_HOME!, 'stack', 'addons', 'chat', 'compose.yml'))).toBe(true);
351
- expect(readFileSync(join(process.env.OP_HOME!, 'vault', 'stack', 'guardian.env'), 'utf-8')).toMatch(/CHANNEL_CHAT_SECRET=/);
351
+ expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat', 'compose.yml'))).toBe(true);
352
+ expect(readFileSync(join(process.env.OP_HOME!, 'config', 'stack', 'guardian.env'), 'utf-8')).toMatch(/CHANNEL_CHAT_SECRET=/);
352
353
 
353
- expect(setAddonEnabled(process.env.OP_HOME!, join(process.env.OP_HOME!, 'vault'), 'chat', false)).toEqual({
354
+ expect(setAddonEnabled(process.env.OP_HOME!, join(process.env.OP_HOME!, 'config', 'stack'), 'chat', false)).toEqual({
354
355
  ok: true,
355
356
  enabled: false,
356
357
  changed: true,
357
358
  services: ['chat'],
358
359
  });
359
- expect(existsSync(join(process.env.OP_HOME!, 'stack', 'addons', 'chat'))).toBe(false);
360
+ expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat'))).toBe(false);
360
361
  });
361
362
 
362
363
  it("backs up OP_HOME without recursively copying backups", () => {
@@ -390,10 +391,10 @@ describe("materialized registry catalog", () => {
390
391
  expect(existsSync(join(otherHome, 'backups', 'config', 'stack.yml'))).toBe(false);
391
392
  });
392
393
 
393
- it("installs and uninstalls automations through config/automations", () => {
394
+ it("installs and uninstalls automations through stash/tasks", () => {
394
395
  const sourceRoot = join(tmpDir, 'repo');
395
- const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
396
- const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
396
+ const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
397
+ const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
397
398
  const configDir = join(process.env.OP_HOME!, 'config');
398
399
 
399
400
  mkdirSync(addonDir, { recursive: true });
@@ -401,14 +402,15 @@ describe("materialized registry catalog", () => {
401
402
  mkdirSync(configDir, { recursive: true });
402
403
  writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
403
404
  writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
404
- writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: daily\n');
405
+ writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
405
406
 
406
407
  materializeRegistryCatalog(sourceRoot);
407
408
 
408
- expect(installAutomationFromRegistry('cleanup', configDir)).toEqual({ ok: true });
409
- expect(readFileSync(join(configDir, 'automations', 'cleanup.yml'), 'utf-8')).toContain('Cleanup');
409
+ const stashDir = join(process.env.OP_HOME!, 'stash');
410
+ expect(installAutomationFromRegistry('cleanup', stashDir)).toEqual({ ok: true });
411
+ expect(readFileSync(join(stashDir, 'tasks', 'cleanup.md'), 'utf-8')).toContain('Cleanup');
410
412
 
411
- expect(uninstallAutomation('cleanup', configDir)).toEqual({ ok: true });
412
- expect(existsSync(join(configDir, 'automations', 'cleanup.yml'))).toBe(false);
413
+ expect(uninstallAutomation('cleanup', stashDir)).toEqual({ ok: true });
414
+ expect(existsSync(join(stashDir, 'tasks', 'cleanup.md'))).toBe(false);
413
415
  });
414
416
  });