@openpalm/lib 0.11.0-beta.11 → 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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/control-plane/akm-user-env.test.ts +113 -0
- package/src/control-plane/akm-user-env.ts +144 -0
- package/src/control-plane/backup.ts +14 -5
- package/src/control-plane/channels.ts +48 -29
- package/src/control-plane/cleanup-guardrails.test.ts +1 -1
- package/src/control-plane/compose-args.test.ts +90 -31
- package/src/control-plane/compose-args.ts +119 -9
- package/src/control-plane/config-persistence.ts +87 -133
- package/src/control-plane/core-assets.test.ts +9 -9
- package/src/control-plane/core-assets.ts +24 -8
- package/src/control-plane/docker.ts +15 -14
- package/src/control-plane/env.test.ts +10 -10
- package/src/control-plane/env.ts +1 -1
- package/src/control-plane/extends-support.test.ts +8 -8
- package/src/control-plane/home.ts +34 -46
- package/src/control-plane/host-opencode.test.ts +82 -10
- package/src/control-plane/host-opencode.ts +42 -13
- package/src/control-plane/install-edge-cases.test.ts +94 -102
- package/src/control-plane/install-lock.ts +7 -7
- package/src/control-plane/lifecycle.ts +36 -34
- package/src/control-plane/markdown-task.ts +30 -50
- package/src/control-plane/paths.ts +62 -42
- package/src/control-plane/profile-ids.ts +21 -0
- package/src/control-plane/provider-models.ts +3 -3
- package/src/control-plane/registry.test.ts +97 -88
- package/src/control-plane/registry.ts +142 -110
- package/src/control-plane/rollback.ts +8 -38
- package/src/control-plane/scheduler.ts +7 -7
- package/src/control-plane/secret-audit.test.ts +159 -0
- package/src/control-plane/secret-audit.ts +255 -0
- package/src/control-plane/secret-mappings.ts +2 -2
- package/src/control-plane/secrets-files.test.ts +60 -0
- package/src/control-plane/secrets-files.ts +66 -0
- package/src/control-plane/secrets.ts +113 -86
- package/src/control-plane/setup-config.schema.json +1 -1
- package/src/control-plane/setup-status.ts +6 -11
- package/src/control-plane/setup.test.ts +42 -40
- package/src/control-plane/setup.ts +36 -31
- package/src/control-plane/skeleton-guardrail.test.ts +64 -55
- package/src/control-plane/spec-to-env.test.ts +22 -17
- package/src/control-plane/spec-to-env.ts +7 -2
- package/src/control-plane/stack-spec.test.ts +10 -0
- package/src/control-plane/stack-spec.ts +28 -1
- package/src/control-plane/types.ts +2 -4
- package/src/control-plane/ui-assets.ts +60 -58
- package/src/control-plane/validate.ts +13 -15
- package/src/index.ts +47 -15
- package/src/control-plane/akm-vault.test.ts +0 -105
- package/src/control-plane/akm-vault.ts +0 -311
- package/src/control-plane/migrate-0110.test.ts +0 -177
- package/src/control-plane/migrate-0110.ts +0 -99
- package/src/control-plane/registry-components.test.ts +0 -391
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Built-in addon/profile discovery and legacy registry helpers.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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 {
|
|
14
|
-
import {
|
|
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('.
|
|
111
|
-
return isValidComponentName(file.replace(/\.
|
|
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', '
|
|
131
|
-
const sourceAutomationsDir = join(sourceRoot, '.openpalm', '
|
|
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
|
|
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('.
|
|
223
|
+
.filter((file) => file.endsWith('.yml'))
|
|
213
224
|
.map((file) => {
|
|
214
|
-
const name = file.replace(/\.
|
|
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
|
|
232
|
+
// Extract YAML metadata.
|
|
222
233
|
try {
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
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(
|
|
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: '
|
|
266
|
-
envSchema:
|
|
274
|
+
schemaPath: '',
|
|
275
|
+
userEnvPath: 'knowledge/env/stack.env',
|
|
276
|
+
envSchema: '',
|
|
267
277
|
};
|
|
268
278
|
}
|
|
269
279
|
|
|
270
280
|
export function listAvailableAddonIds(): string[] {
|
|
271
|
-
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
|
311
|
-
if (!existsSync(composePath)) return [];
|
|
312
|
-
|
|
299
|
+
function readAddonServiceNamesFromContent(composeContent: string, composePath: string, addonName?: string): string[] {
|
|
313
300
|
try {
|
|
314
|
-
const parsed = parseYaml(
|
|
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
|
-
|
|
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", "
|
|
332
|
-
join(homeDir, "
|
|
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:-
|
|
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
|
-
|
|
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
|
-
|
|
535
|
+
const variant = resolveHardwareProfileVariant(profile.id);
|
|
536
|
+
if (variant === 'cpu') {
|
|
529
537
|
result = { available: true };
|
|
530
|
-
} else if (
|
|
538
|
+
} else if (variant === 'cuda') {
|
|
531
539
|
result = await probeCuda();
|
|
532
|
-
} else if (
|
|
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
|
|
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(
|
|
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", "
|
|
643
|
-
join(homeDir, "
|
|
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
|
-
|
|
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
|
|
667
|
-
if (!trimmed) throw new Error(
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
|
736
|
-
if (!
|
|
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
|
|
744
|
-
if (existsSync(
|
|
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(
|
|
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
|
|
760
|
-
if (!existsSync(
|
|
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(
|
|
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
|
|
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,
|
|
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
|
-
"
|
|
18
|
-
"config/stack/
|
|
19
|
-
"config/
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
115
|
+
dataDir: string,
|
|
116
116
|
limit: number = 50,
|
|
117
117
|
): string[] {
|
|
118
|
-
const taskId = id.replace(/\.md$/, "");
|
|
119
|
-
const logDir = join(
|
|
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 })
|