@openpalm/lib 0.11.0-beta.9 → 0.11.0-rc.2
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-sources.test.ts +206 -0
- package/src/control-plane/akm-sources.ts +234 -0
- package/src/control-plane/akm-user-env.test.ts +142 -0
- package/src/control-plane/akm-user-env.ts +167 -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 +67 -30
- package/src/control-plane/compose-args.ts +63 -8
- package/src/control-plane/config-persistence.ts +95 -136
- package/src/control-plane/core-assets.ts +21 -44
- 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/fs-atomic.ts +15 -0
- package/src/control-plane/home.ts +34 -46
- package/src/control-plane/host-akm-sharing.test.ts +145 -0
- package/src/control-plane/host-akm-sharing.ts +129 -0
- 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 +98 -105
- package/src/control-plane/install-lock.ts +7 -7
- package/src/control-plane/lifecycle.ts +37 -36
- package/src/control-plane/markdown-task.ts +30 -50
- package/src/control-plane/opencode-client.ts +1 -1
- package/src/control-plane/paths.ts +61 -46
- 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 +107 -90
- package/src/control-plane/registry.ts +288 -109
- package/src/control-plane/rollback.ts +8 -38
- package/src/control-plane/scheduler.ts +10 -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 +99 -0
- package/src/control-plane/secrets-files.ts +113 -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 +140 -44
- package/src/control-plane/setup.ts +85 -62
- package/src/control-plane/skeleton-guardrail.test.ts +64 -55
- package/src/control-plane/spec-to-env.test.ts +63 -26
- package/src/control-plane/spec-to-env.ts +49 -12
- package/src/control-plane/stack-spec.test.ts +15 -11
- package/src/control-plane/stack-spec.ts +31 -10
- package/src/control-plane/task-files.test.ts +45 -0
- package/src/control-plane/task-files.ts +51 -0
- package/src/control-plane/types.ts +2 -4
- package/src/control-plane/ui-assets.test.ts +130 -0
- package/src/control-plane/ui-assets.ts +132 -57
- package/src/control-plane/validate.ts +13 -15
- package/src/index.ts +86 -16
- package/src/control-plane/akm-vault.test.ts +0 -105
- package/src/control-plane/akm-vault.ts +0 -311
- package/src/control-plane/core-assets.test.ts +0 -104
- 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,164 @@ 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 { readBundledStackAsset } from './core-assets.js';
|
|
18
|
+
import { canonicalAddonProfileSelection, resolveHardwareProfileVariant } from './profile-ids.js';
|
|
19
|
+
import { listStackSpecAddons, setStackSpecAddon } from './stack-spec.js';
|
|
20
|
+
import type { ControlPlaneState } from './types.js';
|
|
16
21
|
import {
|
|
17
22
|
resolveRegistryAddonsDir,
|
|
18
23
|
resolveRegistryAutomationsDir,
|
|
19
24
|
resolveRegistryDir,
|
|
25
|
+
resolveStashDir,
|
|
20
26
|
} from './home.js';
|
|
21
27
|
|
|
22
28
|
const BRANCH_RE = /^[a-zA-Z0-9._\/-]+$/;
|
|
23
29
|
const URL_RE = /^(https:\/\/|git@)/;
|
|
24
30
|
const VALID_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
25
31
|
const logger = createLogger('registry');
|
|
32
|
+
const BUILTIN_ADDONS = ['api', 'chat', 'discord', 'ollama', 'slack', 'ssh', 'voice'] as const;
|
|
33
|
+
|
|
34
|
+
// Credential/config field definitions for the first-party addons, parsed by the
|
|
35
|
+
// admin Secrets/Addons UI (`# @sensitive` → password+masked, `KEY=DEFAULT`).
|
|
36
|
+
// The file-based registry was removed from the skeleton, so these live in-code.
|
|
37
|
+
// `ssh` is compose/profile-only (no configurable env) and is intentionally absent.
|
|
38
|
+
const BUILTIN_ADDON_ENV_SCHEMAS: Record<string, string> = {
|
|
39
|
+
api: `# API Gateway channel configuration
|
|
40
|
+
# ---
|
|
41
|
+
|
|
42
|
+
# HMAC secret for the API channel. Auto-generated during setup if left blank;
|
|
43
|
+
# stored as knowledge/secrets/channel_api_secret.
|
|
44
|
+
# @required @sensitive
|
|
45
|
+
CHANNEL_API_SECRET=
|
|
46
|
+
`,
|
|
47
|
+
chat: `# Web Chat channel configuration
|
|
48
|
+
# ---
|
|
49
|
+
|
|
50
|
+
# HMAC secret for the chat channel. Auto-generated during setup if left blank;
|
|
51
|
+
# stored as knowledge/secrets/channel_chat_secret.
|
|
52
|
+
# @required @sensitive
|
|
53
|
+
CHANNEL_CHAT_SECRET=
|
|
54
|
+
`,
|
|
55
|
+
discord: `# Discord bot configuration
|
|
56
|
+
# ---
|
|
57
|
+
|
|
58
|
+
# HMAC secret for the Discord channel. Auto-generated during setup if left blank.
|
|
59
|
+
# @required @sensitive
|
|
60
|
+
CHANNEL_DISCORD_SECRET=
|
|
61
|
+
|
|
62
|
+
# ---
|
|
63
|
+
# Discord credentials
|
|
64
|
+
# ---
|
|
65
|
+
|
|
66
|
+
# Application ID from the Discord Developer Portal.
|
|
67
|
+
# https://discord.com/developers/applications
|
|
68
|
+
# @required
|
|
69
|
+
DISCORD_APPLICATION_ID=
|
|
70
|
+
|
|
71
|
+
# Bot token from the Discord Developer Portal (Bot → Token).
|
|
72
|
+
# @required @sensitive
|
|
73
|
+
DISCORD_BOT_TOKEN=
|
|
74
|
+
|
|
75
|
+
# ---
|
|
76
|
+
# Access control
|
|
77
|
+
# ---
|
|
78
|
+
|
|
79
|
+
# Comma-separated allowed guild (server) IDs. Empty = all joined guilds.
|
|
80
|
+
DISCORD_ALLOWED_GUILDS=
|
|
81
|
+
|
|
82
|
+
# Comma-separated allowed role IDs.
|
|
83
|
+
DISCORD_ALLOWED_ROLES=
|
|
84
|
+
|
|
85
|
+
# Comma-separated allowed user IDs.
|
|
86
|
+
DISCORD_ALLOWED_USERS=
|
|
87
|
+
|
|
88
|
+
# Comma-separated blocked user IDs (denied even if otherwise allowed).
|
|
89
|
+
DISCORD_BLOCKED_USERS=
|
|
90
|
+
|
|
91
|
+
# ---
|
|
92
|
+
# Behavior
|
|
93
|
+
# ---
|
|
94
|
+
|
|
95
|
+
# Register slash commands on startup.
|
|
96
|
+
DISCORD_REGISTER_COMMANDS=true
|
|
97
|
+
|
|
98
|
+
# JSON array of custom slash command definitions.
|
|
99
|
+
DISCORD_CUSTOM_COMMANDS=
|
|
100
|
+
|
|
101
|
+
# Hours before a conversation thread expires.
|
|
102
|
+
DISCORD_THREAD_TTL_HOURS=24
|
|
103
|
+
|
|
104
|
+
# Milliseconds to wait before forwarding a message (0 = immediate).
|
|
105
|
+
DISCORD_FORWARD_TIMEOUT_MS=0
|
|
106
|
+
`,
|
|
107
|
+
slack: `# Slack bot configuration
|
|
108
|
+
# ---
|
|
109
|
+
|
|
110
|
+
# HMAC secret for the Slack channel. Auto-generated during setup if left blank.
|
|
111
|
+
# @required @sensitive
|
|
112
|
+
CHANNEL_SLACK_SECRET=
|
|
113
|
+
|
|
114
|
+
# ---
|
|
115
|
+
# Slack credentials
|
|
116
|
+
# ---
|
|
117
|
+
|
|
118
|
+
# Bot User OAuth Token (OAuth & Permissions → Bot User OAuth Token).
|
|
119
|
+
# @required @sensitive
|
|
120
|
+
SLACK_BOT_TOKEN=
|
|
121
|
+
|
|
122
|
+
# App-Level Token with connections:write (Basic Information → App-Level Tokens).
|
|
123
|
+
# @required @sensitive
|
|
124
|
+
SLACK_APP_TOKEN=
|
|
125
|
+
|
|
126
|
+
# ---
|
|
127
|
+
# Access control
|
|
128
|
+
# ---
|
|
129
|
+
|
|
130
|
+
# Comma-separated allowed channel IDs. Empty = all channels the bot is in.
|
|
131
|
+
SLACK_ALLOWED_CHANNELS=
|
|
132
|
+
|
|
133
|
+
# Comma-separated allowed user IDs.
|
|
134
|
+
SLACK_ALLOWED_USERS=
|
|
135
|
+
|
|
136
|
+
# Comma-separated blocked user IDs.
|
|
137
|
+
SLACK_BLOCKED_USERS=
|
|
138
|
+
|
|
139
|
+
# ---
|
|
140
|
+
# Behavior
|
|
141
|
+
# ---
|
|
142
|
+
|
|
143
|
+
# Hours before a conversation thread expires.
|
|
144
|
+
SLACK_THREAD_TTL_HOURS=24
|
|
145
|
+
|
|
146
|
+
# Milliseconds to allow for guardian forwarding before timing out (default 30m).
|
|
147
|
+
SLACK_FORWARD_TIMEOUT_MS=1800000
|
|
148
|
+
`,
|
|
149
|
+
ollama: `# Ollama component configuration
|
|
150
|
+
# ---
|
|
151
|
+
|
|
152
|
+
# Bind address for the Ollama HTTP API (default: localhost only).
|
|
153
|
+
# @required
|
|
154
|
+
OP_OLLAMA_BIND_ADDRESS=127.0.0.1
|
|
155
|
+
`,
|
|
156
|
+
voice: `# OpenPalm Voice (Kokoro TTS + Whisper STT) configuration
|
|
157
|
+
# ---
|
|
158
|
+
# Local inference server — no upstream API or key. Values are optional; the
|
|
159
|
+
# compose overlay supplies safe defaults.
|
|
160
|
+
|
|
161
|
+
# faster-whisper model id. Default base.en is baked into the image.
|
|
162
|
+
# @required
|
|
163
|
+
OP_VOICE_WHISPER_MODEL=base.en
|
|
164
|
+
|
|
165
|
+
# Default Kokoro voice id (54 bundled voices, e.g. af_heart, am_michael).
|
|
166
|
+
OP_VOICE_KOKORO_VOICE=bf_isabella
|
|
167
|
+
|
|
168
|
+
# Python logging level: debug, info, warning, error.
|
|
169
|
+
OP_VOICE_LOG_LEVEL=info
|
|
170
|
+
`,
|
|
171
|
+
};
|
|
26
172
|
|
|
27
173
|
let warnedMissingRegistryAddonsDir = false;
|
|
28
174
|
|
|
@@ -107,8 +253,8 @@ function countValidAutomations(rootDir: string): number {
|
|
|
107
253
|
const automationsDir = join(rootDir, 'automations');
|
|
108
254
|
if (!existsSync(automationsDir)) return 0;
|
|
109
255
|
return readdirSync(automationsDir).filter((file) => {
|
|
110
|
-
if (!file.endsWith('.
|
|
111
|
-
return isValidComponentName(file.replace(/\.
|
|
256
|
+
if (!file.endsWith('.yml')) return false;
|
|
257
|
+
return isValidComponentName(file.replace(/\.yml$/, ''));
|
|
112
258
|
}).length;
|
|
113
259
|
}
|
|
114
260
|
|
|
@@ -127,8 +273,8 @@ export function verifyRegistryCatalog(rootDir = resolveRegistryDir()): RegistryC
|
|
|
127
273
|
}
|
|
128
274
|
|
|
129
275
|
export function materializeRegistryCatalog(sourceRoot: string): string {
|
|
130
|
-
const sourceAddonsDir = join(sourceRoot, '.openpalm', '
|
|
131
|
-
const sourceAutomationsDir = join(sourceRoot, '.openpalm', '
|
|
276
|
+
const sourceAddonsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons');
|
|
277
|
+
const sourceAutomationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
|
|
132
278
|
const tempRoot = mkdtempSync(join(tmpdir(), 'openpalm-registry-materialize-'));
|
|
133
279
|
|
|
134
280
|
try {
|
|
@@ -205,29 +351,28 @@ export function discoverRegistryComponents(): Record<string, RegistryComponentEn
|
|
|
205
351
|
}
|
|
206
352
|
|
|
207
353
|
export function discoverRegistryAutomations(stashDir: string): RegistryAutomationEntry[] {
|
|
208
|
-
const
|
|
354
|
+
const localOpenpalmDir = resolveLocalOpenpalmDir();
|
|
355
|
+
const automationsDir = localOpenpalmDir
|
|
356
|
+
? join(localOpenpalmDir, 'knowledge', 'tasks')
|
|
357
|
+
: join(stashDir, 'tasks');
|
|
209
358
|
if (!existsSync(automationsDir)) return [];
|
|
210
359
|
|
|
211
360
|
return readdirSync(automationsDir)
|
|
212
|
-
.filter((file) => file.endsWith('.
|
|
361
|
+
.filter((file) => file.endsWith('.yml'))
|
|
213
362
|
.map((file) => {
|
|
214
|
-
const name = file.replace(/\.
|
|
363
|
+
const name = file.replace(/\.yml$/, '');
|
|
215
364
|
if (!VALID_NAME_RE.test(name)) return null;
|
|
216
365
|
|
|
217
366
|
const content = readFileSync(join(automationsDir, file), 'utf-8');
|
|
218
367
|
let description = '';
|
|
219
368
|
let schedule = '';
|
|
220
369
|
|
|
221
|
-
// Extract
|
|
370
|
+
// Extract YAML metadata.
|
|
222
371
|
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
|
-
}
|
|
372
|
+
const parsed = parseYaml(content);
|
|
373
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
374
|
+
description = (parsed as Record<string, unknown>).description as string ?? '';
|
|
375
|
+
schedule = (parsed as Record<string, unknown>).schedule as string ?? '';
|
|
231
376
|
}
|
|
232
377
|
} catch {
|
|
233
378
|
// best-effort metadata extraction
|
|
@@ -246,75 +391,75 @@ export function discoverRegistryAutomations(stashDir: string): RegistryAutomatio
|
|
|
246
391
|
|
|
247
392
|
export function getRegistryAutomation(name: string): string | null {
|
|
248
393
|
if (!VALID_NAME_RE.test(name)) return null;
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
394
|
+
const localOpenpalmDir = resolveLocalOpenpalmDir();
|
|
395
|
+
const candidates = [
|
|
396
|
+
localOpenpalmDir ? join(localOpenpalmDir, 'knowledge', 'tasks', `${name}.yml`) : '',
|
|
397
|
+
join(resolveStashDir(), 'tasks', `${name}.yml`),
|
|
398
|
+
join(resolveRegistryAutomationsDir(), `${name}.yml`),
|
|
399
|
+
].filter(Boolean);
|
|
400
|
+
for (const ymlPath of candidates) {
|
|
401
|
+
if (existsSync(ymlPath)) return readFileSync(ymlPath, 'utf-8');
|
|
402
|
+
}
|
|
403
|
+
return null;
|
|
252
404
|
}
|
|
253
405
|
|
|
254
|
-
export function getRegistryAddonConfig(
|
|
406
|
+
export function getRegistryAddonConfig(_homeDir: string, name: string): RegistryAddonConfig {
|
|
255
407
|
if (!VALID_NAME_RE.test(name)) {
|
|
256
408
|
throw new Error(`Invalid addon name: ${name}`);
|
|
257
409
|
}
|
|
258
410
|
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
411
|
+
// Resolve the addon's `.env.schema` (credential/config field definitions):
|
|
412
|
+
// 1. A materialized registry copy at OP_HOME/data/registry/addons (custom
|
|
413
|
+
// addons installed from a registry win).
|
|
414
|
+
// 2. The built-in schema embedded below (the first-party addons). The
|
|
415
|
+
// file-based registry was removed from the skeleton, so the built-in
|
|
416
|
+
// addon credential schemas live in-code rather than as bundled files.
|
|
417
|
+
const materialized = join(resolveRegistryAddonsDir(), name, '.env.schema');
|
|
418
|
+
if (existsSync(materialized)) {
|
|
419
|
+
return { schemaPath: materialized, userEnvPath: 'knowledge/env/stack.env', envSchema: readFileSync(materialized, 'utf-8') };
|
|
420
|
+
}
|
|
263
421
|
return {
|
|
264
|
-
schemaPath,
|
|
265
|
-
userEnvPath: '
|
|
266
|
-
envSchema:
|
|
422
|
+
schemaPath: '',
|
|
423
|
+
userEnvPath: 'knowledge/env/stack.env',
|
|
424
|
+
envSchema: BUILTIN_ADDON_ENV_SCHEMAS[name] ?? '',
|
|
267
425
|
};
|
|
268
426
|
}
|
|
269
427
|
|
|
270
428
|
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();
|
|
429
|
+
return [...BUILTIN_ADDONS].sort();
|
|
277
430
|
}
|
|
278
431
|
|
|
279
432
|
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`);
|
|
433
|
+
const enabled = new Set(listStackSpecAddons(join(homeDir, 'config', 'stack')));
|
|
434
|
+
const env = readStackEnv(join(homeDir, 'config', 'stack'));
|
|
435
|
+
const profiles = new Set((env.COMPOSE_PROFILES ?? '').split(',').map((p) => p.trim()).filter(Boolean));
|
|
436
|
+
for (const key of ['OP_VOICE_PROFILE', 'OP_OLLAMA_PROFILE']) {
|
|
437
|
+
const profile = env[key]?.trim();
|
|
438
|
+
if (profile) profiles.add(profile);
|
|
297
439
|
}
|
|
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 });
|
|
440
|
+
for (const profile of profiles) {
|
|
441
|
+
const match = profile.match(/^addon\.([a-z0-9-]+)(?:\.|$)/);
|
|
442
|
+
if (match?.[1]) enabled.add(match[1]);
|
|
443
|
+
}
|
|
444
|
+
return [...enabled].sort();
|
|
308
445
|
}
|
|
309
446
|
|
|
310
|
-
function
|
|
311
|
-
if (!existsSync(composePath)) return [];
|
|
312
|
-
|
|
447
|
+
function readAddonServiceNamesFromContent(composeContent: string, composePath: string, addonName?: string): string[] {
|
|
313
448
|
try {
|
|
314
|
-
const parsed = parseYaml(
|
|
449
|
+
const parsed = parseYaml(composeContent);
|
|
315
450
|
const services = parsed && typeof parsed === "object" ? (parsed as { services?: unknown }).services : undefined;
|
|
316
451
|
if (!services || typeof services !== "object" || Array.isArray(services)) return [];
|
|
317
|
-
|
|
452
|
+
const entries = Object.entries(services as Record<string, unknown>);
|
|
453
|
+
if (!addonName) return entries.map(([name]) => name);
|
|
454
|
+
return entries
|
|
455
|
+
.filter(([serviceName, raw]) => {
|
|
456
|
+
if (serviceName === 'guardian') return false;
|
|
457
|
+
if (serviceName === addonName || serviceName.startsWith(`${addonName}-`)) return true;
|
|
458
|
+
if (!raw || typeof raw !== 'object') return false;
|
|
459
|
+
const profiles = (raw as { profiles?: unknown }).profiles;
|
|
460
|
+
return Array.isArray(profiles) && profiles.some((p) => typeof p === 'string' && p.startsWith(`addon.${addonName}`));
|
|
461
|
+
})
|
|
462
|
+
.map(([serviceName]) => serviceName);
|
|
318
463
|
} catch (error) {
|
|
319
464
|
logger.warn("failed to parse addon compose services", {
|
|
320
465
|
composePath,
|
|
@@ -324,16 +469,27 @@ function readAddonServiceNames(composePath: string): string[] {
|
|
|
324
469
|
}
|
|
325
470
|
}
|
|
326
471
|
|
|
472
|
+
function readAddonServiceNames(composePath: string, addonName?: string): string[] {
|
|
473
|
+
if (!existsSync(composePath)) return [];
|
|
474
|
+
return readAddonServiceNamesFromContent(readFileSync(composePath, "utf-8"), composePath, addonName);
|
|
475
|
+
}
|
|
476
|
+
|
|
327
477
|
export function getAddonServiceNames(homeDir: string, name: string): string[] {
|
|
328
478
|
if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
|
|
329
479
|
|
|
330
480
|
const composeCandidates = [
|
|
331
|
-
join(homeDir, "config", "stack", "
|
|
332
|
-
join(homeDir, "
|
|
481
|
+
join(homeDir, "config", "stack", "channels.compose.yml"),
|
|
482
|
+
join(homeDir, "config", "stack", "services.compose.yml"),
|
|
483
|
+
join(homeDir, "config", "stack", "custom.compose.yml"),
|
|
333
484
|
];
|
|
334
485
|
|
|
335
486
|
for (const composePath of composeCandidates) {
|
|
336
|
-
const services = readAddonServiceNames(composePath);
|
|
487
|
+
const services = readAddonServiceNames(composePath, name);
|
|
488
|
+
if (services.length > 0) return services;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
for (const assetName of ["channels.compose.yml", "services.compose.yml", "custom.compose.yml"]) {
|
|
492
|
+
const services = readAddonServiceNamesFromContent(readBundledStackAsset(assetName), `bundled:${assetName}`, name);
|
|
337
493
|
if (services.length > 0) return services;
|
|
338
494
|
}
|
|
339
495
|
|
|
@@ -418,13 +574,13 @@ function execFileNoThrow(
|
|
|
418
574
|
/**
|
|
419
575
|
* Compute the openpalm/voice image ref for a given GPU variant, matching
|
|
420
576
|
* the substitution chain in the addon compose file:
|
|
421
|
-
* ${OP_IMAGE_NAMESPACE:-openpalm}/voice:${OP_VOICE_IMAGE_TAG:-${OP_IMAGE_TAG:-
|
|
577
|
+
* ${OP_IMAGE_NAMESPACE:-openpalm}/voice:${OP_VOICE_IMAGE_TAG:-${OP_IMAGE_TAG:-latest}-<variant>}
|
|
422
578
|
*/
|
|
423
579
|
function voiceImageRef(variant: 'cpu' | 'cu121' | 'rocm6'): string {
|
|
424
580
|
const namespace = process.env.OP_IMAGE_NAMESPACE?.trim() || 'openpalm';
|
|
425
581
|
const explicit = process.env.OP_VOICE_IMAGE_TAG?.trim();
|
|
426
582
|
if (explicit) return `${namespace}/voice:${explicit}`;
|
|
427
|
-
const baseTag = process.env.OP_IMAGE_TAG?.trim() || '
|
|
583
|
+
const baseTag = process.env.OP_IMAGE_TAG?.trim() || 'latest';
|
|
428
584
|
return `${namespace}/voice:${baseTag}-${variant}`;
|
|
429
585
|
}
|
|
430
586
|
|
|
@@ -525,11 +681,12 @@ export async function getAddonProfileAvailability(
|
|
|
525
681
|
|
|
526
682
|
let result: AddonProfileAvailability;
|
|
527
683
|
try {
|
|
528
|
-
|
|
684
|
+
const variant = resolveHardwareProfileVariant(profile.id);
|
|
685
|
+
if (variant === 'cpu') {
|
|
529
686
|
result = { available: true };
|
|
530
|
-
} else if (
|
|
687
|
+
} else if (variant === 'cuda') {
|
|
531
688
|
result = await probeCuda();
|
|
532
|
-
} else if (
|
|
689
|
+
} else if (variant === 'rocm') {
|
|
533
690
|
result = await probeRocm();
|
|
534
691
|
} else {
|
|
535
692
|
// Unknown profile id — assume available; caller is responsible for
|
|
@@ -564,12 +721,10 @@ export async function annotateAddonProfileAvailability(
|
|
|
564
721
|
return results;
|
|
565
722
|
}
|
|
566
723
|
|
|
567
|
-
function
|
|
568
|
-
if (!existsSync(composePath)) return [];
|
|
569
|
-
|
|
724
|
+
function readAddonProfilesFromContent(composeContent: string, composePath: string): AddonProfile[] {
|
|
570
725
|
let parsed: unknown;
|
|
571
726
|
try {
|
|
572
|
-
parsed = parseYaml(
|
|
727
|
+
parsed = parseYaml(composeContent);
|
|
573
728
|
} catch (error) {
|
|
574
729
|
logger.warn("failed to parse addon compose profiles", {
|
|
575
730
|
composePath,
|
|
@@ -616,6 +771,11 @@ function readAddonProfiles(composePath: string): AddonProfile[] {
|
|
|
616
771
|
return [...byProfile.values()];
|
|
617
772
|
}
|
|
618
773
|
|
|
774
|
+
function readAddonProfiles(composePath: string): AddonProfile[] {
|
|
775
|
+
if (!existsSync(composePath)) return [];
|
|
776
|
+
return readAddonProfilesFromContent(readFileSync(composePath, "utf-8"), composePath);
|
|
777
|
+
}
|
|
778
|
+
|
|
619
779
|
function readServiceLabels(raw: unknown): Record<string, string> {
|
|
620
780
|
if (!raw) return {};
|
|
621
781
|
const out: Record<string, string> = {};
|
|
@@ -639,12 +799,26 @@ export function getAddonProfiles(homeDir: string, name: string): AddonProfile[]
|
|
|
639
799
|
if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
|
|
640
800
|
|
|
641
801
|
const composeCandidates = [
|
|
642
|
-
join(homeDir, "config", "stack", "
|
|
643
|
-
join(homeDir, "
|
|
802
|
+
join(homeDir, "config", "stack", "channels.compose.yml"),
|
|
803
|
+
join(homeDir, "config", "stack", "services.compose.yml"),
|
|
804
|
+
join(homeDir, "config", "stack", "custom.compose.yml"),
|
|
644
805
|
];
|
|
645
806
|
|
|
807
|
+
const localOpenpalmDir = resolveLocalOpenpalmDir();
|
|
808
|
+
if (localOpenpalmDir) {
|
|
809
|
+
composeCandidates.push(join(localOpenpalmDir, 'config', 'stack', 'channels.compose.yml'));
|
|
810
|
+
composeCandidates.push(join(localOpenpalmDir, 'config', 'stack', 'services.compose.yml'));
|
|
811
|
+
composeCandidates.push(join(localOpenpalmDir, 'config', 'stack', 'custom.compose.yml'));
|
|
812
|
+
}
|
|
813
|
+
|
|
646
814
|
for (const composePath of composeCandidates) {
|
|
647
|
-
const profiles = readAddonProfiles(composePath);
|
|
815
|
+
const profiles = readAddonProfiles(composePath).filter((profile) => profile.id.startsWith(`addon.${name}`));
|
|
816
|
+
if (profiles.length > 0) return profiles;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
for (const assetName of ["channels.compose.yml", "services.compose.yml", "custom.compose.yml"]) {
|
|
820
|
+
const profiles = readAddonProfilesFromContent(readBundledStackAsset(assetName), `bundled:${assetName}`)
|
|
821
|
+
.filter((profile) => profile.id.startsWith(`addon.${name}`));
|
|
648
822
|
if (profiles.length > 0) return profiles;
|
|
649
823
|
}
|
|
650
824
|
|
|
@@ -659,42 +833,45 @@ function profileEnvKey(name: string): string {
|
|
|
659
833
|
export function getAddonProfileSelection(stackDir: string, name: string): string | null {
|
|
660
834
|
const env = readStackEnv(stackDir);
|
|
661
835
|
const value = env[profileEnvKey(name)];
|
|
662
|
-
|
|
836
|
+
const normalized = value ? canonicalAddonProfileSelection(name, value) : '';
|
|
837
|
+
return normalized ? normalized : null;
|
|
663
838
|
}
|
|
664
839
|
|
|
665
|
-
export function setAddonProfileSelection(stackDir: string, name: string, profile: string): void {
|
|
666
|
-
const trimmed = profile
|
|
667
|
-
if (!trimmed) throw new Error(
|
|
840
|
+
export function setAddonProfileSelection(stackDir: string, name: string, profile: string, state?: ControlPlaneState): void {
|
|
841
|
+
const trimmed = canonicalAddonProfileSelection(name, profile);
|
|
842
|
+
if (!trimmed) throw new Error(`Invalid canonical profile id for addon ${name}: ${profile}`);
|
|
668
843
|
patchSecretsEnvFile(stackDir, { [profileEnvKey(name)]: trimmed });
|
|
669
844
|
}
|
|
670
845
|
|
|
671
|
-
function enableAddon(homeDir: string, name: string): MutationResult {
|
|
846
|
+
function enableAddon(homeDir: string, stackDir: string, name: string): MutationResult {
|
|
672
847
|
try {
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
848
|
+
if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
|
|
849
|
+
setStackSpecAddon(stackDir, name, true);
|
|
850
|
+
if (name === 'ssh') patchSecretsEnvFile(stackDir, { OPENCODE_ENABLE_SSH: '1' });
|
|
676
851
|
return { ok: true };
|
|
677
852
|
} catch (error) {
|
|
678
853
|
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
679
854
|
}
|
|
680
855
|
}
|
|
681
856
|
|
|
682
|
-
function disableAddonByName(homeDir: string, name: string): MutationResult {
|
|
857
|
+
function disableAddonByName(homeDir: string, stackDir: string, name: string): MutationResult {
|
|
683
858
|
try {
|
|
684
|
-
|
|
859
|
+
if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
|
|
860
|
+
setStackSpecAddon(stackDir, name, false);
|
|
861
|
+
if (name === 'ssh') patchSecretsEnvFile(stackDir, { OPENCODE_ENABLE_SSH: '0' });
|
|
685
862
|
return { ok: true };
|
|
686
863
|
} catch (error) {
|
|
687
864
|
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
688
865
|
}
|
|
689
866
|
}
|
|
690
867
|
|
|
691
|
-
export function setAddonEnabled(homeDir: string, stackDir: string, name: string, enabled: boolean): AddonMutationResult {
|
|
868
|
+
export function setAddonEnabled(homeDir: string, stackDir: string, name: string, enabled: boolean, state?: ControlPlaneState): AddonMutationResult {
|
|
692
869
|
if (!VALID_NAME_RE.test(name)) {
|
|
693
870
|
return { ok: false, error: `Invalid addon name: ${name}` };
|
|
694
871
|
}
|
|
695
872
|
|
|
696
873
|
if (!listAvailableAddonIds().includes(name)) {
|
|
697
|
-
return { ok: false, error: `Addon "${name}" not
|
|
874
|
+
return { ok: false, error: `Addon "${name}" is not built in` };
|
|
698
875
|
}
|
|
699
876
|
|
|
700
877
|
const wasEnabled = listEnabledAddonIds(homeDir).includes(name);
|
|
@@ -709,16 +886,18 @@ export function setAddonEnabled(homeDir: string, stackDir: string, name: string,
|
|
|
709
886
|
};
|
|
710
887
|
}
|
|
711
888
|
|
|
712
|
-
const mutation = enabled ? enableAddon(homeDir, name) : disableAddonByName(homeDir, name);
|
|
889
|
+
const mutation = enabled ? enableAddon(homeDir, stackDir, name) : disableAddonByName(homeDir, stackDir, name);
|
|
713
890
|
if (!mutation.ok) return mutation;
|
|
714
891
|
|
|
715
892
|
if (enabled) {
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
893
|
+
if (['api', 'chat', 'discord', 'slack'].includes(name)) {
|
|
894
|
+
for (const channel of ['api', 'chat', 'discord', 'slack']) {
|
|
895
|
+
ensureChannelSecret(stackDir, channel);
|
|
896
|
+
}
|
|
719
897
|
}
|
|
720
898
|
}
|
|
721
899
|
|
|
900
|
+
|
|
722
901
|
return {
|
|
723
902
|
ok: true,
|
|
724
903
|
enabled,
|
|
@@ -732,20 +911,20 @@ export function installAutomationFromRegistry(name: string, stashDir: string): M
|
|
|
732
911
|
return { ok: false, error: `Invalid automation name: ${name}` };
|
|
733
912
|
}
|
|
734
913
|
|
|
735
|
-
const
|
|
736
|
-
if (!
|
|
914
|
+
const taskContent = getRegistryAutomation(name);
|
|
915
|
+
if (!taskContent) {
|
|
737
916
|
return { ok: false, error: `Automation "${name}" not found in registry` };
|
|
738
917
|
}
|
|
739
918
|
|
|
740
919
|
const tasksDir = join(stashDir, 'tasks');
|
|
741
920
|
mkdirSync(tasksDir, { recursive: true });
|
|
742
921
|
|
|
743
|
-
const
|
|
744
|
-
if (existsSync(
|
|
922
|
+
const ymlPath = join(tasksDir, `${name}.yml`);
|
|
923
|
+
if (existsSync(ymlPath)) {
|
|
745
924
|
return { ok: false, error: `Automation "${name}" is already installed` };
|
|
746
925
|
}
|
|
747
926
|
|
|
748
|
-
writeFileSync(
|
|
927
|
+
writeFileSync(ymlPath, taskContent);
|
|
749
928
|
// The assistant container's 60-second akm tasks sync loop picks up the new
|
|
750
929
|
// file from the shared stash mount and registers it with OS cron.
|
|
751
930
|
return { ok: true };
|
|
@@ -756,12 +935,12 @@ export function uninstallAutomation(name: string, stashDir: string): MutationRes
|
|
|
756
935
|
return { ok: false, error: `Invalid automation name: ${name}` };
|
|
757
936
|
}
|
|
758
937
|
|
|
759
|
-
const
|
|
760
|
-
if (!existsSync(
|
|
938
|
+
const ymlPath = join(stashDir, 'tasks', `${name}.yml`);
|
|
939
|
+
if (!existsSync(ymlPath)) {
|
|
761
940
|
return { ok: false, error: `Automation "${name}" is not installed` };
|
|
762
941
|
}
|
|
763
942
|
|
|
764
|
-
rmSync(
|
|
943
|
+
rmSync(ymlPath, { force: true });
|
|
765
944
|
// The assistant container's 60-second akm tasks sync will notice the file
|
|
766
945
|
// is gone and deregister it from OS cron on next sync.
|
|
767
946
|
return { ok: true };
|