@openpalm/lib 0.11.0-beta.11 → 0.11.0-beta.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/control-plane/akm-user-env.test.ts +113 -0
  4. package/src/control-plane/akm-user-env.ts +144 -0
  5. package/src/control-plane/backup.ts +14 -5
  6. package/src/control-plane/channels.ts +48 -29
  7. package/src/control-plane/cleanup-guardrails.test.ts +1 -1
  8. package/src/control-plane/compose-args.test.ts +90 -31
  9. package/src/control-plane/compose-args.ts +119 -9
  10. package/src/control-plane/config-persistence.ts +87 -133
  11. package/src/control-plane/core-assets.test.ts +9 -9
  12. package/src/control-plane/core-assets.ts +24 -8
  13. package/src/control-plane/docker.ts +15 -14
  14. package/src/control-plane/env.test.ts +10 -10
  15. package/src/control-plane/env.ts +1 -1
  16. package/src/control-plane/extends-support.test.ts +8 -8
  17. package/src/control-plane/home.ts +34 -46
  18. package/src/control-plane/host-opencode.test.ts +82 -10
  19. package/src/control-plane/host-opencode.ts +42 -13
  20. package/src/control-plane/install-edge-cases.test.ts +94 -102
  21. package/src/control-plane/install-lock.ts +7 -7
  22. package/src/control-plane/lifecycle.ts +36 -34
  23. package/src/control-plane/markdown-task.ts +30 -50
  24. package/src/control-plane/paths.ts +62 -42
  25. package/src/control-plane/profile-ids.ts +21 -0
  26. package/src/control-plane/provider-models.ts +3 -3
  27. package/src/control-plane/registry.test.ts +97 -88
  28. package/src/control-plane/registry.ts +142 -109
  29. package/src/control-plane/rollback.ts +8 -38
  30. package/src/control-plane/scheduler.ts +7 -7
  31. package/src/control-plane/secret-audit.test.ts +159 -0
  32. package/src/control-plane/secret-audit.ts +255 -0
  33. package/src/control-plane/secret-mappings.ts +2 -2
  34. package/src/control-plane/secrets-files.test.ts +60 -0
  35. package/src/control-plane/secrets-files.ts +66 -0
  36. package/src/control-plane/secrets.ts +113 -86
  37. package/src/control-plane/setup-config.schema.json +1 -1
  38. package/src/control-plane/setup-status.ts +6 -11
  39. package/src/control-plane/setup.test.ts +42 -40
  40. package/src/control-plane/setup.ts +36 -31
  41. package/src/control-plane/skeleton-guardrail.test.ts +64 -55
  42. package/src/control-plane/spec-to-env.test.ts +22 -17
  43. package/src/control-plane/spec-to-env.ts +7 -2
  44. package/src/control-plane/stack-spec.test.ts +10 -0
  45. package/src/control-plane/stack-spec.ts +28 -1
  46. package/src/control-plane/types.ts +2 -4
  47. package/src/control-plane/ui-assets.ts +60 -58
  48. package/src/control-plane/validate.ts +13 -15
  49. package/src/index.ts +47 -15
  50. package/src/control-plane/akm-vault.test.ts +0 -105
  51. package/src/control-plane/akm-vault.ts +0 -311
  52. package/src/control-plane/migrate-0110.test.ts +0 -177
  53. package/src/control-plane/migrate-0110.ts +0 -99
  54. package/src/control-plane/registry-components.test.ts +0 -391
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Registry catalog discovery and refresh.
2
+ * Built-in addon/profile discovery and legacy registry helpers.
3
3
  *
4
- * `OP_HOME/state/registry` is the only persistent catalog location.
5
- * Install seeds it once; refresh replaces it explicitly.
4
+ * Runtime addon enablement is recorded in stack.yml and resolved to Compose
5
+ * profiles. The fixed compose files under config/stack are the runtime source
6
+ * of truth.
6
7
  */
7
8
  import { cpSync, existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
8
9
  import { execFile, execFileSync } from 'node:child_process';
@@ -10,19 +11,26 @@ import { join } from 'node:path';
10
11
  import { tmpdir } from 'node:os';
11
12
  import { parse as parseYaml } from 'yaml';
12
13
  import { createLogger } from '../logger.js';
13
- import { isChannelAddon } from './channels.js';
14
- import { randomHex, writeChannelSecrets } from './config-persistence.js';
14
+ import { resolveLocalOpenpalmDir } from './ui-assets.js';
15
+ import { ensureChannelSecret } from './config-persistence.js';
15
16
  import { patchSecretsEnvFile, readStackEnv } from './secrets.js';
17
+ import { writeRunScript } from './compose-args.js';
18
+ import { readBundledStackAsset } from './core-assets.js';
19
+ import { canonicalAddonProfileSelection, resolveHardwareProfileVariant } from './profile-ids.js';
20
+ import { listStackSpecAddons, setStackSpecAddon } from './stack-spec.js';
21
+ import type { ControlPlaneState } from './types.js';
16
22
  import {
17
23
  resolveRegistryAddonsDir,
18
24
  resolveRegistryAutomationsDir,
19
25
  resolveRegistryDir,
26
+ resolveStashDir,
20
27
  } from './home.js';
21
28
 
22
29
  const BRANCH_RE = /^[a-zA-Z0-9._\/-]+$/;
23
30
  const URL_RE = /^(https:\/\/|git@)/;
24
31
  const VALID_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
25
32
  const logger = createLogger('registry');
33
+ const BUILTIN_ADDONS = ['api', 'chat', 'discord', 'ollama', 'slack', 'ssh', 'voice'] as const;
26
34
 
27
35
  let warnedMissingRegistryAddonsDir = false;
28
36
 
@@ -107,8 +115,8 @@ function countValidAutomations(rootDir: string): number {
107
115
  const automationsDir = join(rootDir, 'automations');
108
116
  if (!existsSync(automationsDir)) return 0;
109
117
  return readdirSync(automationsDir).filter((file) => {
110
- if (!file.endsWith('.md')) return false;
111
- return isValidComponentName(file.replace(/\.md$/, ''));
118
+ if (!file.endsWith('.yml')) return false;
119
+ return isValidComponentName(file.replace(/\.yml$/, ''));
112
120
  }).length;
113
121
  }
114
122
 
@@ -127,8 +135,8 @@ export function verifyRegistryCatalog(rootDir = resolveRegistryDir()): RegistryC
127
135
  }
128
136
 
129
137
  export function materializeRegistryCatalog(sourceRoot: string): string {
130
- const sourceAddonsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons');
131
- const sourceAutomationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
138
+ const sourceAddonsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons');
139
+ const sourceAutomationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
132
140
  const tempRoot = mkdtempSync(join(tmpdir(), 'openpalm-registry-materialize-'));
133
141
 
134
142
  try {
@@ -205,29 +213,28 @@ export function discoverRegistryComponents(): Record<string, RegistryComponentEn
205
213
  }
206
214
 
207
215
  export function discoverRegistryAutomations(stashDir: string): RegistryAutomationEntry[] {
208
- const automationsDir = resolveRegistryAutomationsDir();
216
+ const localOpenpalmDir = resolveLocalOpenpalmDir();
217
+ const automationsDir = localOpenpalmDir
218
+ ? join(localOpenpalmDir, 'knowledge', 'tasks')
219
+ : join(stashDir, 'tasks');
209
220
  if (!existsSync(automationsDir)) return [];
210
221
 
211
222
  return readdirSync(automationsDir)
212
- .filter((file) => file.endsWith('.md'))
223
+ .filter((file) => file.endsWith('.yml'))
213
224
  .map((file) => {
214
- const name = file.replace(/\.md$/, '');
225
+ const name = file.replace(/\.yml$/, '');
215
226
  if (!VALID_NAME_RE.test(name)) return null;
216
227
 
217
228
  const content = readFileSync(join(automationsDir, file), 'utf-8');
218
229
  let description = '';
219
230
  let schedule = '';
220
231
 
221
- // Extract frontmatter metadata (between --- delimiters)
232
+ // Extract YAML metadata.
222
233
  try {
223
- const after = content.startsWith('---') ? content.slice(3) : '';
224
- const end = after.indexOf('\n---');
225
- if (end !== -1) {
226
- const parsed = parseYaml(after.slice(0, end));
227
- if (parsed && typeof parsed === 'object') {
228
- description = (parsed as Record<string, unknown>).description as string ?? '';
229
- schedule = (parsed as Record<string, unknown>).schedule as string ?? '';
230
- }
234
+ const parsed = parseYaml(content);
235
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
236
+ description = (parsed as Record<string, unknown>).description as string ?? '';
237
+ schedule = (parsed as Record<string, unknown>).schedule as string ?? '';
231
238
  }
232
239
  } catch {
233
240
  // best-effort metadata extraction
@@ -246,75 +253,65 @@ export function discoverRegistryAutomations(stashDir: string): RegistryAutomatio
246
253
 
247
254
  export function getRegistryAutomation(name: string): string | null {
248
255
  if (!VALID_NAME_RE.test(name)) return null;
249
- const mdPath = join(resolveRegistryAutomationsDir(), `${name}.md`);
250
- if (!existsSync(mdPath)) return null;
251
- return readFileSync(mdPath, 'utf-8');
256
+ const localOpenpalmDir = resolveLocalOpenpalmDir();
257
+ const candidates = [
258
+ localOpenpalmDir ? join(localOpenpalmDir, 'knowledge', 'tasks', `${name}.yml`) : '',
259
+ join(resolveStashDir(), 'tasks', `${name}.yml`),
260
+ join(resolveRegistryAutomationsDir(), `${name}.yml`),
261
+ ].filter(Boolean);
262
+ for (const ymlPath of candidates) {
263
+ if (existsSync(ymlPath)) return readFileSync(ymlPath, 'utf-8');
264
+ }
265
+ return null;
252
266
  }
253
267
 
254
- export function getRegistryAddonConfig(homeDir: string, name: string): RegistryAddonConfig {
268
+ export function getRegistryAddonConfig(_homeDir: string, name: string): RegistryAddonConfig {
255
269
  if (!VALID_NAME_RE.test(name)) {
256
270
  throw new Error(`Invalid addon name: ${name}`);
257
271
  }
258
272
 
259
- // Overlay-only addons (compose.yml only, no .env.schema) have no env vars
260
- // to render, so the schema reads as an empty string.
261
- const schemaPath = `state/registry/addons/${name}/.env.schema`;
262
- const schemaFile = join(homeDir, schemaPath);
263
273
  return {
264
- schemaPath,
265
- userEnvPath: 'config/stack/stack.env',
266
- envSchema: existsSync(schemaFile) ? readFileSync(schemaFile, 'utf-8') : '',
274
+ schemaPath: '',
275
+ userEnvPath: 'knowledge/env/stack.env',
276
+ envSchema: '',
267
277
  };
268
278
  }
269
279
 
270
280
  export function listAvailableAddonIds(): string[] {
271
- const addonsDir = resolveRegistryAddonsDir();
272
- if (!existsSync(addonsDir) && !warnedMissingRegistryAddonsDir) {
273
- warnedMissingRegistryAddonsDir = true;
274
- logger.warn('registry addons directory is missing', { addonsDir });
275
- }
276
- return Object.keys(discoverRegistryComponents()).sort();
281
+ return [...BUILTIN_ADDONS].sort();
277
282
  }
278
283
 
279
284
  export function listEnabledAddonIds(homeDir: string): string[] {
280
- const addonsDir = join(homeDir, 'config', 'stack', 'addons');
281
- if (!existsSync(addonsDir)) return [];
282
-
283
- return readdirSync(addonsDir, { withFileTypes: true })
284
- .filter((entry) => entry.isDirectory() && existsSync(join(addonsDir, entry.name, 'compose.yml')))
285
- .map((entry) => entry.name)
286
- .sort();
287
- }
288
-
289
- function copyAddonFromRegistry(homeDir: string, name: string): void {
290
- if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
291
-
292
- const sourceDir = join(resolveRegistryAddonsDir(), name);
293
- // compose.yml is the only required file. Overlay-only addons may omit
294
- // .env.schema entirely.
295
- if (!existsSync(join(sourceDir, 'compose.yml'))) {
296
- throw new Error(`Addon "${name}" not found in registry`);
285
+ const enabled = new Set(listStackSpecAddons(join(homeDir, 'config', 'stack')));
286
+ const env = readStackEnv(join(homeDir, 'config', 'stack'));
287
+ const profiles = new Set((env.COMPOSE_PROFILES ?? '').split(',').map((p) => p.trim()).filter(Boolean));
288
+ for (const key of ['OP_VOICE_PROFILE', 'OP_OLLAMA_PROFILE']) {
289
+ const profile = env[key]?.trim();
290
+ if (profile) profiles.add(profile);
297
291
  }
298
-
299
- const targetDir = join(homeDir, 'config', 'stack', 'addons', name);
300
- rmSync(targetDir, { recursive: true, force: true });
301
- mkdirSync(join(homeDir, 'config', 'stack', 'addons'), { recursive: true });
302
- cpSync(sourceDir, targetDir, { recursive: true });
303
- }
304
-
305
- function removeEnabledAddon(homeDir: string, name: string): void {
306
- if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
307
- rmSync(join(homeDir, 'config', 'stack', 'addons', name), { recursive: true, force: true });
292
+ for (const profile of profiles) {
293
+ const match = profile.match(/^addon\.([a-z0-9-]+)(?:\.|$)/);
294
+ if (match?.[1]) enabled.add(match[1]);
295
+ }
296
+ return [...enabled].sort();
308
297
  }
309
298
 
310
- function readAddonServiceNames(composePath: string): string[] {
311
- if (!existsSync(composePath)) return [];
312
-
299
+ function readAddonServiceNamesFromContent(composeContent: string, composePath: string, addonName?: string): string[] {
313
300
  try {
314
- const parsed = parseYaml(readFileSync(composePath, "utf-8"));
301
+ const parsed = parseYaml(composeContent);
315
302
  const services = parsed && typeof parsed === "object" ? (parsed as { services?: unknown }).services : undefined;
316
303
  if (!services || typeof services !== "object" || Array.isArray(services)) return [];
317
- return Object.keys(services as Record<string, unknown>);
304
+ const entries = Object.entries(services as Record<string, unknown>);
305
+ if (!addonName) return entries.map(([name]) => name);
306
+ return entries
307
+ .filter(([serviceName, raw]) => {
308
+ if (serviceName === 'guardian') return false;
309
+ if (serviceName === addonName || serviceName.startsWith(`${addonName}-`)) return true;
310
+ if (!raw || typeof raw !== 'object') return false;
311
+ const profiles = (raw as { profiles?: unknown }).profiles;
312
+ return Array.isArray(profiles) && profiles.some((p) => typeof p === 'string' && p.startsWith(`addon.${addonName}`));
313
+ })
314
+ .map(([serviceName]) => serviceName);
318
315
  } catch (error) {
319
316
  logger.warn("failed to parse addon compose services", {
320
317
  composePath,
@@ -324,16 +321,27 @@ function readAddonServiceNames(composePath: string): string[] {
324
321
  }
325
322
  }
326
323
 
324
+ function readAddonServiceNames(composePath: string, addonName?: string): string[] {
325
+ if (!existsSync(composePath)) return [];
326
+ return readAddonServiceNamesFromContent(readFileSync(composePath, "utf-8"), composePath, addonName);
327
+ }
328
+
327
329
  export function getAddonServiceNames(homeDir: string, name: string): string[] {
328
330
  if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
329
331
 
330
332
  const composeCandidates = [
331
- join(homeDir, "config", "stack", "addons", name, "compose.yml"),
332
- join(homeDir, "state", "registry", "addons", name, "compose.yml"),
333
+ join(homeDir, "config", "stack", "channels.compose.yml"),
334
+ join(homeDir, "config", "stack", "services.compose.yml"),
335
+ join(homeDir, "config", "stack", "custom.compose.yml"),
333
336
  ];
334
337
 
335
338
  for (const composePath of composeCandidates) {
336
- const services = readAddonServiceNames(composePath);
339
+ const services = readAddonServiceNames(composePath, name);
340
+ if (services.length > 0) return services;
341
+ }
342
+
343
+ for (const assetName of ["channels.compose.yml", "services.compose.yml", "custom.compose.yml"]) {
344
+ const services = readAddonServiceNamesFromContent(readBundledStackAsset(assetName), `bundled:${assetName}`, name);
337
345
  if (services.length > 0) return services;
338
346
  }
339
347
 
@@ -418,13 +426,13 @@ function execFileNoThrow(
418
426
  /**
419
427
  * Compute the openpalm/voice image ref for a given GPU variant, matching
420
428
  * the substitution chain in the addon compose file:
421
- * ${OP_IMAGE_NAMESPACE:-openpalm}/voice:${OP_VOICE_IMAGE_TAG:-${OP_IMAGE_TAG:-v0.11.0}-<variant>}
429
+ * ${OP_IMAGE_NAMESPACE:-openpalm}/voice:${OP_VOICE_IMAGE_TAG:-${OP_IMAGE_TAG:-latest}-<variant>}
422
430
  */
423
431
  function voiceImageRef(variant: 'cpu' | 'cu121' | 'rocm6'): string {
424
432
  const namespace = process.env.OP_IMAGE_NAMESPACE?.trim() || 'openpalm';
425
433
  const explicit = process.env.OP_VOICE_IMAGE_TAG?.trim();
426
434
  if (explicit) return `${namespace}/voice:${explicit}`;
427
- const baseTag = process.env.OP_IMAGE_TAG?.trim() || 'v0.11.0';
435
+ const baseTag = process.env.OP_IMAGE_TAG?.trim() || 'latest';
428
436
  return `${namespace}/voice:${baseTag}-${variant}`;
429
437
  }
430
438
 
@@ -525,11 +533,12 @@ export async function getAddonProfileAvailability(
525
533
 
526
534
  let result: AddonProfileAvailability;
527
535
  try {
528
- if (profile.id === 'cpu') {
536
+ const variant = resolveHardwareProfileVariant(profile.id);
537
+ if (variant === 'cpu') {
529
538
  result = { available: true };
530
- } else if (profile.id === 'cuda') {
539
+ } else if (variant === 'cuda') {
531
540
  result = await probeCuda();
532
- } else if (profile.id === 'rocm') {
541
+ } else if (variant === 'rocm') {
533
542
  result = await probeRocm();
534
543
  } else {
535
544
  // Unknown profile id — assume available; caller is responsible for
@@ -564,12 +573,10 @@ export async function annotateAddonProfileAvailability(
564
573
  return results;
565
574
  }
566
575
 
567
- function readAddonProfiles(composePath: string): AddonProfile[] {
568
- if (!existsSync(composePath)) return [];
569
-
576
+ function readAddonProfilesFromContent(composeContent: string, composePath: string): AddonProfile[] {
570
577
  let parsed: unknown;
571
578
  try {
572
- parsed = parseYaml(readFileSync(composePath, "utf-8"));
579
+ parsed = parseYaml(composeContent);
573
580
  } catch (error) {
574
581
  logger.warn("failed to parse addon compose profiles", {
575
582
  composePath,
@@ -616,6 +623,11 @@ function readAddonProfiles(composePath: string): AddonProfile[] {
616
623
  return [...byProfile.values()];
617
624
  }
618
625
 
626
+ function readAddonProfiles(composePath: string): AddonProfile[] {
627
+ if (!existsSync(composePath)) return [];
628
+ return readAddonProfilesFromContent(readFileSync(composePath, "utf-8"), composePath);
629
+ }
630
+
619
631
  function readServiceLabels(raw: unknown): Record<string, string> {
620
632
  if (!raw) return {};
621
633
  const out: Record<string, string> = {};
@@ -639,12 +651,26 @@ export function getAddonProfiles(homeDir: string, name: string): AddonProfile[]
639
651
  if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
640
652
 
641
653
  const composeCandidates = [
642
- join(homeDir, "config", "stack", "addons", name, "compose.yml"),
643
- join(homeDir, "state", "registry", "addons", name, "compose.yml"),
654
+ join(homeDir, "config", "stack", "channels.compose.yml"),
655
+ join(homeDir, "config", "stack", "services.compose.yml"),
656
+ join(homeDir, "config", "stack", "custom.compose.yml"),
644
657
  ];
645
658
 
659
+ const localOpenpalmDir = resolveLocalOpenpalmDir();
660
+ if (localOpenpalmDir) {
661
+ composeCandidates.push(join(localOpenpalmDir, 'config', 'stack', 'channels.compose.yml'));
662
+ composeCandidates.push(join(localOpenpalmDir, 'config', 'stack', 'services.compose.yml'));
663
+ composeCandidates.push(join(localOpenpalmDir, 'config', 'stack', 'custom.compose.yml'));
664
+ }
665
+
646
666
  for (const composePath of composeCandidates) {
647
- const profiles = readAddonProfiles(composePath);
667
+ const profiles = readAddonProfiles(composePath).filter((profile) => profile.id.startsWith(`addon.${name}`));
668
+ if (profiles.length > 0) return profiles;
669
+ }
670
+
671
+ for (const assetName of ["channels.compose.yml", "services.compose.yml", "custom.compose.yml"]) {
672
+ const profiles = readAddonProfilesFromContent(readBundledStackAsset(assetName), `bundled:${assetName}`)
673
+ .filter((profile) => profile.id.startsWith(`addon.${name}`));
648
674
  if (profiles.length > 0) return profiles;
649
675
  }
650
676
 
@@ -659,42 +685,46 @@ function profileEnvKey(name: string): string {
659
685
  export function getAddonProfileSelection(stackDir: string, name: string): string | null {
660
686
  const env = readStackEnv(stackDir);
661
687
  const value = env[profileEnvKey(name)];
662
- return value && value.trim() ? value.trim() : null;
688
+ const normalized = value ? canonicalAddonProfileSelection(name, value) : '';
689
+ return normalized ? normalized : null;
663
690
  }
664
691
 
665
- export function setAddonProfileSelection(stackDir: string, name: string, profile: string): void {
666
- const trimmed = profile.trim();
667
- if (!trimmed) throw new Error('Profile id cannot be empty');
692
+ export function setAddonProfileSelection(stackDir: string, name: string, profile: string, state?: ControlPlaneState): void {
693
+ const trimmed = canonicalAddonProfileSelection(name, profile);
694
+ if (!trimmed) throw new Error(`Invalid canonical profile id for addon ${name}: ${profile}`);
668
695
  patchSecretsEnvFile(stackDir, { [profileEnvKey(name)]: trimmed });
696
+ if (state) writeRunScript(state);
669
697
  }
670
698
 
671
- function enableAddon(homeDir: string, name: string): MutationResult {
699
+ function enableAddon(homeDir: string, stackDir: string, name: string): MutationResult {
672
700
  try {
673
- copyAddonFromRegistry(homeDir, name);
674
- // Pre-create the addon services directory so Docker doesn't create it as root
675
- mkdirSync(join(homeDir, 'services', name), { recursive: true });
701
+ if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
702
+ setStackSpecAddon(stackDir, name, true);
703
+ if (name === 'ssh') patchSecretsEnvFile(stackDir, { OPENCODE_ENABLE_SSH: '1' });
676
704
  return { ok: true };
677
705
  } catch (error) {
678
706
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
679
707
  }
680
708
  }
681
709
 
682
- function disableAddonByName(homeDir: string, name: string): MutationResult {
710
+ function disableAddonByName(homeDir: string, stackDir: string, name: string): MutationResult {
683
711
  try {
684
- removeEnabledAddon(homeDir, name);
712
+ if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
713
+ setStackSpecAddon(stackDir, name, false);
714
+ if (name === 'ssh') patchSecretsEnvFile(stackDir, { OPENCODE_ENABLE_SSH: '0' });
685
715
  return { ok: true };
686
716
  } catch (error) {
687
717
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
688
718
  }
689
719
  }
690
720
 
691
- export function setAddonEnabled(homeDir: string, stackDir: string, name: string, enabled: boolean): AddonMutationResult {
721
+ export function setAddonEnabled(homeDir: string, stackDir: string, name: string, enabled: boolean, state?: ControlPlaneState): AddonMutationResult {
692
722
  if (!VALID_NAME_RE.test(name)) {
693
723
  return { ok: false, error: `Invalid addon name: ${name}` };
694
724
  }
695
725
 
696
726
  if (!listAvailableAddonIds().includes(name)) {
697
- return { ok: false, error: `Addon "${name}" not found in registry` };
727
+ return { ok: false, error: `Addon "${name}" is not built in` };
698
728
  }
699
729
 
700
730
  const wasEnabled = listEnabledAddonIds(homeDir).includes(name);
@@ -709,16 +739,19 @@ export function setAddonEnabled(homeDir: string, stackDir: string, name: string,
709
739
  };
710
740
  }
711
741
 
712
- const mutation = enabled ? enableAddon(homeDir, name) : disableAddonByName(homeDir, name);
742
+ const mutation = enabled ? enableAddon(homeDir, stackDir, name) : disableAddonByName(homeDir, stackDir, name);
713
743
  if (!mutation.ok) return mutation;
714
744
 
715
745
  if (enabled) {
716
- const composePath = join(homeDir, "config", "stack", "addons", name, "compose.yml");
717
- if (isChannelAddon(composePath)) {
718
- writeChannelSecrets(stackDir, { [name]: randomHex(16) });
746
+ if (['api', 'chat', 'discord', 'slack'].includes(name)) {
747
+ for (const channel of ['api', 'chat', 'discord', 'slack']) {
748
+ ensureChannelSecret(stackDir, channel);
749
+ }
719
750
  }
720
751
  }
721
752
 
753
+ if (state) writeRunScript(state);
754
+
722
755
  return {
723
756
  ok: true,
724
757
  enabled,
@@ -732,20 +765,20 @@ export function installAutomationFromRegistry(name: string, stashDir: string): M
732
765
  return { ok: false, error: `Invalid automation name: ${name}` };
733
766
  }
734
767
 
735
- const markdownContent = getRegistryAutomation(name);
736
- if (!markdownContent) {
768
+ const taskContent = getRegistryAutomation(name);
769
+ if (!taskContent) {
737
770
  return { ok: false, error: `Automation "${name}" not found in registry` };
738
771
  }
739
772
 
740
773
  const tasksDir = join(stashDir, 'tasks');
741
774
  mkdirSync(tasksDir, { recursive: true });
742
775
 
743
- const mdPath = join(tasksDir, `${name}.md`);
744
- if (existsSync(mdPath)) {
776
+ const ymlPath = join(tasksDir, `${name}.yml`);
777
+ if (existsSync(ymlPath)) {
745
778
  return { ok: false, error: `Automation "${name}" is already installed` };
746
779
  }
747
780
 
748
- writeFileSync(mdPath, markdownContent);
781
+ writeFileSync(ymlPath, taskContent);
749
782
  // The assistant container's 60-second akm tasks sync loop picks up the new
750
783
  // file from the shared stash mount and registers it with OS cron.
751
784
  return { ok: true };
@@ -756,12 +789,12 @@ export function uninstallAutomation(name: string, stashDir: string): MutationRes
756
789
  return { ok: false, error: `Invalid automation name: ${name}` };
757
790
  }
758
791
 
759
- const mdPath = join(stashDir, 'tasks', `${name}.md`);
760
- if (!existsSync(mdPath)) {
792
+ const ymlPath = join(stashDir, 'tasks', `${name}.yml`);
793
+ if (!existsSync(ymlPath)) {
761
794
  return { ok: false, error: `Automation "${name}" is not installed` };
762
795
  }
763
796
 
764
- rmSync(mdPath, { force: true });
797
+ rmSync(ymlPath, { force: true });
765
798
  // The assistant container's 60-second akm tasks sync will notice the file
766
799
  // is gone and deregister it from OS cron on next sync.
767
800
  return { ok: true };
@@ -2,10 +2,10 @@
2
2
  * Snapshot-based rollback for the OpenPalm control plane.
3
3
  *
4
4
  * Before writing validated changes to live paths, the current state
5
- * is snapshotted to ~/.cache/openpalm/rollback/. On deploy failure
5
+ * is snapshotted to OP_HOME/data/rollback/. On deploy failure
6
6
  * (or manual `openpalm rollback`), the snapshot is restored.
7
7
  */
8
- import { mkdirSync, copyFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { mkdirSync, copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { join, dirname } from "node:path";
10
10
  import type { ControlPlaneState } from "./types.js";
11
11
  import { resolveRollbackDir } from "./home.js";
@@ -14,9 +14,11 @@ import { resolveRollbackDir } from "./home.js";
14
14
  * Only config/ system files are included — user-editable config files
15
15
  * are never overwritten by lifecycle operations. */
16
16
  const SNAPSHOT_FILES = [
17
- "config/stack/stack.env",
18
- "config/stack/guardian.env",
19
- "config/auth.json",
17
+ "knowledge/env/stack.env",
18
+ "config/stack/services.compose.yml",
19
+ "config/stack/channels.compose.yml",
20
+ "config/stack/custom.compose.yml",
21
+ "knowledge/secrets/auth.json",
20
22
  ];
21
23
 
22
24
  /**
@@ -30,8 +32,7 @@ function safeCopy(src: string, dest: string): void {
30
32
 
31
33
  /**
32
34
  * Save the current live configuration files to the rollback directory.
33
- * Also snapshots stack/core.compose.yml and all addon compose.yml files
34
- * under stack/addons/.
35
+ * Also snapshots stack/core.compose.yml.
35
36
  */
36
37
  export function snapshotCurrentState(state: ControlPlaneState): void {
37
38
  const rollbackDir = resolveRollbackDir();
@@ -48,22 +49,6 @@ export function snapshotCurrentState(state: ControlPlaneState): void {
48
49
  const coreCompose = join(state.homeDir, "config/stack/core.compose.yml");
49
50
  safeCopy(coreCompose, join(rollbackDir, "config/stack/core.compose.yml"));
50
51
 
51
- // Snapshot config/stack/addons/*/compose.yml
52
- const addonsDir = join(state.homeDir, "config/stack/addons");
53
- if (existsSync(addonsDir)) {
54
- for (const entry of readdirSync(addonsDir, { withFileTypes: true })) {
55
- if (entry.isDirectory()) {
56
- const addonCompose = join(addonsDir, entry.name, "compose.yml");
57
- if (existsSync(addonCompose)) {
58
- safeCopy(
59
- addonCompose,
60
- join(rollbackDir, "config/stack/addons", entry.name, "compose.yml"),
61
- );
62
- }
63
- }
64
- }
65
- }
66
-
67
52
  // Write a timestamp marker
68
53
  writeFileSync(
69
54
  join(rollbackDir, ".snapshot-ts"),
@@ -94,21 +79,6 @@ export function restoreSnapshot(state: ControlPlaneState): void {
94
79
  safeCopy(srcCoreCompose, join(state.homeDir, "config/stack/core.compose.yml"));
95
80
  }
96
81
 
97
- // Restore config/stack/addons/*/compose.yml
98
- const srcAddons = join(rollbackDir, "config/stack/addons");
99
- if (existsSync(srcAddons)) {
100
- for (const entry of readdirSync(srcAddons, { withFileTypes: true })) {
101
- if (entry.isDirectory()) {
102
- const srcAddonCompose = join(srcAddons, entry.name, "compose.yml");
103
- if (existsSync(srcAddonCompose)) {
104
- safeCopy(
105
- srcAddonCompose,
106
- join(state.homeDir, "config/stack/addons", entry.name, "compose.yml"),
107
- );
108
- }
109
- }
110
- }
111
- }
112
82
  }
113
83
 
114
84
  /**
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Automation scheduler — types and akm CLI integration.
3
3
  *
4
- * Automations are AKM markdown task files at ${stashDir}/tasks/*.md.
4
+ * Automations are AKM task files at ${stashDir}/tasks/*.yml.
5
5
  * Scheduling is handled by the OS cron daemon (via `akm tasks sync`).
6
6
  * Execution is handled by `akm tasks run <id>`.
7
7
  */
@@ -69,8 +69,8 @@ export async function executeAutomation(
69
69
  id: string,
70
70
  akmEnv: NodeJS.ProcessEnv,
71
71
  ): Promise<AutomationRunResult> {
72
- // Strip .md suffix if caller passes the full filename
73
- const taskId = id.replace(/\.md$/, "");
72
+ // Strip file suffix if caller passes the full filename.
73
+ const taskId = id.replace(/\.(?:ya?ml|md)$/, "");
74
74
  return new Promise((resolve) => {
75
75
  execFile(
76
76
  "akm",
@@ -89,7 +89,7 @@ export async function executeAutomation(
89
89
  });
90
90
  }
91
91
 
92
- // ── Sync crontab with stash/tasks/*.md ───────────────────────────────────
92
+ // ── Sync crontab with knowledge/tasks/*.yml ──────────────────────────────────
93
93
 
94
94
  export async function syncAutomations(akmEnv: NodeJS.ProcessEnv): Promise<void> {
95
95
  return new Promise((resolve, reject) => {
@@ -112,11 +112,11 @@ export async function syncAutomations(akmEnv: NodeJS.ProcessEnv): Promise<void>
112
112
 
113
113
  export function readAutomationLogs(
114
114
  id: string,
115
- cacheDir: string,
115
+ dataDir: string,
116
116
  limit: number = 50,
117
117
  ): string[] {
118
- const taskId = id.replace(/\.md$/, "");
119
- const logDir = join(cacheDir, "akm", "tasks", "logs", taskId);
118
+ const taskId = id.replace(/\.(?:ya?ml|md)$/, "");
119
+ const logDir = join(dataDir, "akm", "cache", "tasks", "logs", taskId);
120
120
  if (!existsSync(logDir)) return [];
121
121
 
122
122
  const logFiles = readdirSync(logDir, { withFileTypes: true })