@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.
- 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 -109
- 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,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:-
|
|
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() || '
|
|
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
|
-
|
|
536
|
+
const variant = resolveHardwareProfileVariant(profile.id);
|
|
537
|
+
if (variant === 'cpu') {
|
|
529
538
|
result = { available: true };
|
|
530
|
-
} else if (
|
|
539
|
+
} else if (variant === 'cuda') {
|
|
531
540
|
result = await probeCuda();
|
|
532
|
-
} else if (
|
|
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
|
|
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(
|
|
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", "
|
|
643
|
-
join(homeDir, "
|
|
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
|
-
|
|
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
|
|
667
|
-
if (!trimmed) throw new Error(
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
|
736
|
-
if (!
|
|
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
|
|
744
|
-
if (existsSync(
|
|
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(
|
|
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
|
|
760
|
-
if (!existsSync(
|
|
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(
|
|
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
|
|
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 })
|