@openpalm/lib 0.11.0-beta.10 → 0.11.0-beta.13

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 -110
  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 +60 -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,14 +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';
428
- return `${namespace}/voice:${baseTag}-${variant}`;
435
+ return `${namespace}/voice:latest-${variant}`;
429
436
  }
430
437
 
431
438
  /**
@@ -525,11 +532,12 @@ export async function getAddonProfileAvailability(
525
532
 
526
533
  let result: AddonProfileAvailability;
527
534
  try {
528
- if (profile.id === 'cpu') {
535
+ const variant = resolveHardwareProfileVariant(profile.id);
536
+ if (variant === 'cpu') {
529
537
  result = { available: true };
530
- } else if (profile.id === 'cuda') {
538
+ } else if (variant === 'cuda') {
531
539
  result = await probeCuda();
532
- } else if (profile.id === 'rocm') {
540
+ } else if (variant === 'rocm') {
533
541
  result = await probeRocm();
534
542
  } else {
535
543
  // Unknown profile id — assume available; caller is responsible for
@@ -564,12 +572,10 @@ export async function annotateAddonProfileAvailability(
564
572
  return results;
565
573
  }
566
574
 
567
- function readAddonProfiles(composePath: string): AddonProfile[] {
568
- if (!existsSync(composePath)) return [];
569
-
575
+ function readAddonProfilesFromContent(composeContent: string, composePath: string): AddonProfile[] {
570
576
  let parsed: unknown;
571
577
  try {
572
- parsed = parseYaml(readFileSync(composePath, "utf-8"));
578
+ parsed = parseYaml(composeContent);
573
579
  } catch (error) {
574
580
  logger.warn("failed to parse addon compose profiles", {
575
581
  composePath,
@@ -616,6 +622,11 @@ function readAddonProfiles(composePath: string): AddonProfile[] {
616
622
  return [...byProfile.values()];
617
623
  }
618
624
 
625
+ function readAddonProfiles(composePath: string): AddonProfile[] {
626
+ if (!existsSync(composePath)) return [];
627
+ return readAddonProfilesFromContent(readFileSync(composePath, "utf-8"), composePath);
628
+ }
629
+
619
630
  function readServiceLabels(raw: unknown): Record<string, string> {
620
631
  if (!raw) return {};
621
632
  const out: Record<string, string> = {};
@@ -639,12 +650,26 @@ export function getAddonProfiles(homeDir: string, name: string): AddonProfile[]
639
650
  if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
640
651
 
641
652
  const composeCandidates = [
642
- join(homeDir, "config", "stack", "addons", name, "compose.yml"),
643
- join(homeDir, "state", "registry", "addons", name, "compose.yml"),
653
+ join(homeDir, "config", "stack", "channels.compose.yml"),
654
+ join(homeDir, "config", "stack", "services.compose.yml"),
655
+ join(homeDir, "config", "stack", "custom.compose.yml"),
644
656
  ];
645
657
 
658
+ const localOpenpalmDir = resolveLocalOpenpalmDir();
659
+ if (localOpenpalmDir) {
660
+ composeCandidates.push(join(localOpenpalmDir, 'config', 'stack', 'channels.compose.yml'));
661
+ composeCandidates.push(join(localOpenpalmDir, 'config', 'stack', 'services.compose.yml'));
662
+ composeCandidates.push(join(localOpenpalmDir, 'config', 'stack', 'custom.compose.yml'));
663
+ }
664
+
646
665
  for (const composePath of composeCandidates) {
647
- const profiles = readAddonProfiles(composePath);
666
+ const profiles = readAddonProfiles(composePath).filter((profile) => profile.id.startsWith(`addon.${name}`));
667
+ if (profiles.length > 0) return profiles;
668
+ }
669
+
670
+ for (const assetName of ["channels.compose.yml", "services.compose.yml", "custom.compose.yml"]) {
671
+ const profiles = readAddonProfilesFromContent(readBundledStackAsset(assetName), `bundled:${assetName}`)
672
+ .filter((profile) => profile.id.startsWith(`addon.${name}`));
648
673
  if (profiles.length > 0) return profiles;
649
674
  }
650
675
 
@@ -659,42 +684,46 @@ function profileEnvKey(name: string): string {
659
684
  export function getAddonProfileSelection(stackDir: string, name: string): string | null {
660
685
  const env = readStackEnv(stackDir);
661
686
  const value = env[profileEnvKey(name)];
662
- return value && value.trim() ? value.trim() : null;
687
+ const normalized = value ? canonicalAddonProfileSelection(name, value) : '';
688
+ return normalized ? normalized : null;
663
689
  }
664
690
 
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');
691
+ export function setAddonProfileSelection(stackDir: string, name: string, profile: string, state?: ControlPlaneState): void {
692
+ const trimmed = canonicalAddonProfileSelection(name, profile);
693
+ if (!trimmed) throw new Error(`Invalid canonical profile id for addon ${name}: ${profile}`);
668
694
  patchSecretsEnvFile(stackDir, { [profileEnvKey(name)]: trimmed });
695
+ if (state) writeRunScript(state);
669
696
  }
670
697
 
671
- function enableAddon(homeDir: string, name: string): MutationResult {
698
+ function enableAddon(homeDir: string, stackDir: string, name: string): MutationResult {
672
699
  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 });
700
+ if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
701
+ setStackSpecAddon(stackDir, name, true);
702
+ if (name === 'ssh') patchSecretsEnvFile(stackDir, { OPENCODE_ENABLE_SSH: '1' });
676
703
  return { ok: true };
677
704
  } catch (error) {
678
705
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
679
706
  }
680
707
  }
681
708
 
682
- function disableAddonByName(homeDir: string, name: string): MutationResult {
709
+ function disableAddonByName(homeDir: string, stackDir: string, name: string): MutationResult {
683
710
  try {
684
- removeEnabledAddon(homeDir, name);
711
+ if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
712
+ setStackSpecAddon(stackDir, name, false);
713
+ if (name === 'ssh') patchSecretsEnvFile(stackDir, { OPENCODE_ENABLE_SSH: '0' });
685
714
  return { ok: true };
686
715
  } catch (error) {
687
716
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
688
717
  }
689
718
  }
690
719
 
691
- export function setAddonEnabled(homeDir: string, stackDir: string, name: string, enabled: boolean): AddonMutationResult {
720
+ export function setAddonEnabled(homeDir: string, stackDir: string, name: string, enabled: boolean, state?: ControlPlaneState): AddonMutationResult {
692
721
  if (!VALID_NAME_RE.test(name)) {
693
722
  return { ok: false, error: `Invalid addon name: ${name}` };
694
723
  }
695
724
 
696
725
  if (!listAvailableAddonIds().includes(name)) {
697
- return { ok: false, error: `Addon "${name}" not found in registry` };
726
+ return { ok: false, error: `Addon "${name}" is not built in` };
698
727
  }
699
728
 
700
729
  const wasEnabled = listEnabledAddonIds(homeDir).includes(name);
@@ -709,16 +738,19 @@ export function setAddonEnabled(homeDir: string, stackDir: string, name: string,
709
738
  };
710
739
  }
711
740
 
712
- const mutation = enabled ? enableAddon(homeDir, name) : disableAddonByName(homeDir, name);
741
+ const mutation = enabled ? enableAddon(homeDir, stackDir, name) : disableAddonByName(homeDir, stackDir, name);
713
742
  if (!mutation.ok) return mutation;
714
743
 
715
744
  if (enabled) {
716
- const composePath = join(homeDir, "config", "stack", "addons", name, "compose.yml");
717
- if (isChannelAddon(composePath)) {
718
- writeChannelSecrets(stackDir, { [name]: randomHex(16) });
745
+ if (['api', 'chat', 'discord', 'slack'].includes(name)) {
746
+ for (const channel of ['api', 'chat', 'discord', 'slack']) {
747
+ ensureChannelSecret(stackDir, channel);
748
+ }
719
749
  }
720
750
  }
721
751
 
752
+ if (state) writeRunScript(state);
753
+
722
754
  return {
723
755
  ok: true,
724
756
  enabled,
@@ -732,20 +764,20 @@ export function installAutomationFromRegistry(name: string, stashDir: string): M
732
764
  return { ok: false, error: `Invalid automation name: ${name}` };
733
765
  }
734
766
 
735
- const markdownContent = getRegistryAutomation(name);
736
- if (!markdownContent) {
767
+ const taskContent = getRegistryAutomation(name);
768
+ if (!taskContent) {
737
769
  return { ok: false, error: `Automation "${name}" not found in registry` };
738
770
  }
739
771
 
740
772
  const tasksDir = join(stashDir, 'tasks');
741
773
  mkdirSync(tasksDir, { recursive: true });
742
774
 
743
- const mdPath = join(tasksDir, `${name}.md`);
744
- if (existsSync(mdPath)) {
775
+ const ymlPath = join(tasksDir, `${name}.yml`);
776
+ if (existsSync(ymlPath)) {
745
777
  return { ok: false, error: `Automation "${name}" is already installed` };
746
778
  }
747
779
 
748
- writeFileSync(mdPath, markdownContent);
780
+ writeFileSync(ymlPath, taskContent);
749
781
  // The assistant container's 60-second akm tasks sync loop picks up the new
750
782
  // file from the shared stash mount and registers it with OS cron.
751
783
  return { ok: true };
@@ -756,12 +788,12 @@ export function uninstallAutomation(name: string, stashDir: string): MutationRes
756
788
  return { ok: false, error: `Invalid automation name: ${name}` };
757
789
  }
758
790
 
759
- const mdPath = join(stashDir, 'tasks', `${name}.md`);
760
- if (!existsSync(mdPath)) {
791
+ const ymlPath = join(stashDir, 'tasks', `${name}.yml`);
792
+ if (!existsSync(ymlPath)) {
761
793
  return { ok: false, error: `Automation "${name}" is not installed` };
762
794
  }
763
795
 
764
- rmSync(mdPath, { force: true });
796
+ rmSync(ymlPath, { force: true });
765
797
  // The assistant container's 60-second akm tasks sync will notice the file
766
798
  // is gone and deregister it from OS cron on next sync.
767
799
  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 })