@openpalm/lib 0.11.0-beta.9 → 0.11.0-rc.18
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 +69 -30
- package/src/control-plane/compose-args.ts +62 -8
- package/src/control-plane/config-persistence.ts +102 -136
- package/src/control-plane/core-assets.ts +45 -60
- package/src/control-plane/defaults.ts +16 -0
- package/src/control-plane/docker.ts +15 -14
- package/src/control-plane/env.test.ts +10 -10
- package/src/control-plane/env.ts +16 -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 +100 -136
- package/src/control-plane/install-lock.ts +7 -7
- package/src/control-plane/lifecycle.ts +45 -40
- package/src/control-plane/markdown-task.ts +30 -50
- package/src/control-plane/migrations.test.ts +272 -0
- package/src/control-plane/migrations.ts +423 -0
- 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 +301 -110
- 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 +137 -61
- package/src/control-plane/setup.ts +82 -63
- package/src/control-plane/skeleton-guardrail.test.ts +66 -56
- package/src/control-plane/spec-to-env.test.ts +63 -26
- package/src/control-plane/spec-to-env.ts +51 -14
- 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 +333 -0
- package/src/control-plane/ui-assets.ts +290 -142
- package/src/control-plane/validate.ts +13 -15
- package/src/index.ts +96 -26
- 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
- package/src/control-plane/stack-spec.test.ts +0 -94
- package/src/control-plane/stack-spec.ts +0 -67
|
@@ -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 as OP_ENABLED_ADDONS in stack.env and
|
|
5
|
+
* resolved to Compose profiles. The fixed compose files under config/stack are
|
|
6
|
+
* the runtime source 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 { parseEnabledAddons } from './env.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 env = readStackEnv(join(homeDir, 'config', 'stack'));
|
|
434
|
+
const enabled = new Set(parseEnabledAddons(env.OP_ENABLED_ADDONS));
|
|
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,14 +574,18 @@ 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
|
|
577
|
+
* ${OP_IMAGE_NAMESPACE:-openpalm}/voice:${OP_VOICE_IMAGE_TAG:-latest-<variant>}
|
|
578
|
+
*
|
|
579
|
+
* Voice images are published OUT OF BAND (publish-voice.yml), decoupled from the
|
|
580
|
+
* platform OP_IMAGE_TAG — they are heavy and rarely change. So the default is
|
|
581
|
+
* the moving `latest-<variant>` voice tag; operators pin a specific build by
|
|
582
|
+
* setting OP_VOICE_IMAGE_TAG (e.g. `v1.0.0-cpu`).
|
|
422
583
|
*/
|
|
423
584
|
function voiceImageRef(variant: 'cpu' | 'cu121' | 'rocm6'): string {
|
|
424
585
|
const namespace = process.env.OP_IMAGE_NAMESPACE?.trim() || 'openpalm';
|
|
425
586
|
const explicit = process.env.OP_VOICE_IMAGE_TAG?.trim();
|
|
426
587
|
if (explicit) return `${namespace}/voice:${explicit}`;
|
|
427
|
-
|
|
428
|
-
return `${namespace}/voice:${baseTag}-${variant}`;
|
|
588
|
+
return `${namespace}/voice:latest-${variant}`;
|
|
429
589
|
}
|
|
430
590
|
|
|
431
591
|
/**
|
|
@@ -525,11 +685,12 @@ export async function getAddonProfileAvailability(
|
|
|
525
685
|
|
|
526
686
|
let result: AddonProfileAvailability;
|
|
527
687
|
try {
|
|
528
|
-
|
|
688
|
+
const variant = resolveHardwareProfileVariant(profile.id);
|
|
689
|
+
if (variant === 'cpu') {
|
|
529
690
|
result = { available: true };
|
|
530
|
-
} else if (
|
|
691
|
+
} else if (variant === 'cuda') {
|
|
531
692
|
result = await probeCuda();
|
|
532
|
-
} else if (
|
|
693
|
+
} else if (variant === 'rocm') {
|
|
533
694
|
result = await probeRocm();
|
|
534
695
|
} else {
|
|
535
696
|
// Unknown profile id — assume available; caller is responsible for
|
|
@@ -564,12 +725,10 @@ export async function annotateAddonProfileAvailability(
|
|
|
564
725
|
return results;
|
|
565
726
|
}
|
|
566
727
|
|
|
567
|
-
function
|
|
568
|
-
if (!existsSync(composePath)) return [];
|
|
569
|
-
|
|
728
|
+
function readAddonProfilesFromContent(composeContent: string, composePath: string): AddonProfile[] {
|
|
570
729
|
let parsed: unknown;
|
|
571
730
|
try {
|
|
572
|
-
parsed = parseYaml(
|
|
731
|
+
parsed = parseYaml(composeContent);
|
|
573
732
|
} catch (error) {
|
|
574
733
|
logger.warn("failed to parse addon compose profiles", {
|
|
575
734
|
composePath,
|
|
@@ -616,6 +775,11 @@ function readAddonProfiles(composePath: string): AddonProfile[] {
|
|
|
616
775
|
return [...byProfile.values()];
|
|
617
776
|
}
|
|
618
777
|
|
|
778
|
+
function readAddonProfiles(composePath: string): AddonProfile[] {
|
|
779
|
+
if (!existsSync(composePath)) return [];
|
|
780
|
+
return readAddonProfilesFromContent(readFileSync(composePath, "utf-8"), composePath);
|
|
781
|
+
}
|
|
782
|
+
|
|
619
783
|
function readServiceLabels(raw: unknown): Record<string, string> {
|
|
620
784
|
if (!raw) return {};
|
|
621
785
|
const out: Record<string, string> = {};
|
|
@@ -639,12 +803,26 @@ export function getAddonProfiles(homeDir: string, name: string): AddonProfile[]
|
|
|
639
803
|
if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
|
|
640
804
|
|
|
641
805
|
const composeCandidates = [
|
|
642
|
-
join(homeDir, "config", "stack", "
|
|
643
|
-
join(homeDir, "
|
|
806
|
+
join(homeDir, "config", "stack", "channels.compose.yml"),
|
|
807
|
+
join(homeDir, "config", "stack", "services.compose.yml"),
|
|
808
|
+
join(homeDir, "config", "stack", "custom.compose.yml"),
|
|
644
809
|
];
|
|
645
810
|
|
|
811
|
+
const localOpenpalmDir = resolveLocalOpenpalmDir();
|
|
812
|
+
if (localOpenpalmDir) {
|
|
813
|
+
composeCandidates.push(join(localOpenpalmDir, 'config', 'stack', 'channels.compose.yml'));
|
|
814
|
+
composeCandidates.push(join(localOpenpalmDir, 'config', 'stack', 'services.compose.yml'));
|
|
815
|
+
composeCandidates.push(join(localOpenpalmDir, 'config', 'stack', 'custom.compose.yml'));
|
|
816
|
+
}
|
|
817
|
+
|
|
646
818
|
for (const composePath of composeCandidates) {
|
|
647
|
-
const profiles = readAddonProfiles(composePath);
|
|
819
|
+
const profiles = readAddonProfiles(composePath).filter((profile) => profile.id.startsWith(`addon.${name}`));
|
|
820
|
+
if (profiles.length > 0) return profiles;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
for (const assetName of ["channels.compose.yml", "services.compose.yml", "custom.compose.yml"]) {
|
|
824
|
+
const profiles = readAddonProfilesFromContent(readBundledStackAsset(assetName), `bundled:${assetName}`)
|
|
825
|
+
.filter((profile) => profile.id.startsWith(`addon.${name}`));
|
|
648
826
|
if (profiles.length > 0) return profiles;
|
|
649
827
|
}
|
|
650
828
|
|
|
@@ -659,42 +837,53 @@ function profileEnvKey(name: string): string {
|
|
|
659
837
|
export function getAddonProfileSelection(stackDir: string, name: string): string | null {
|
|
660
838
|
const env = readStackEnv(stackDir);
|
|
661
839
|
const value = env[profileEnvKey(name)];
|
|
662
|
-
|
|
840
|
+
const normalized = value ? canonicalAddonProfileSelection(name, value) : '';
|
|
841
|
+
return normalized ? normalized : null;
|
|
663
842
|
}
|
|
664
843
|
|
|
665
|
-
export function setAddonProfileSelection(stackDir: string, name: string, profile: string): void {
|
|
666
|
-
const trimmed = profile
|
|
667
|
-
if (!trimmed) throw new Error(
|
|
844
|
+
export function setAddonProfileSelection(stackDir: string, name: string, profile: string, state?: ControlPlaneState): void {
|
|
845
|
+
const trimmed = canonicalAddonProfileSelection(name, profile);
|
|
846
|
+
if (!trimmed) throw new Error(`Invalid canonical profile id for addon ${name}: ${profile}`);
|
|
668
847
|
patchSecretsEnvFile(stackDir, { [profileEnvKey(name)]: trimmed });
|
|
669
848
|
}
|
|
670
849
|
|
|
671
|
-
|
|
850
|
+
/** Add/remove an addon id in the OP_ENABLED_ADDONS list in stack.env. */
|
|
851
|
+
function setEnabledAddonState(stackDir: string, name: string, enabled: boolean): void {
|
|
852
|
+
const current = new Set(parseEnabledAddons(readStackEnv(stackDir).OP_ENABLED_ADDONS));
|
|
853
|
+
if (enabled) current.add(name);
|
|
854
|
+
else current.delete(name);
|
|
855
|
+
patchSecretsEnvFile(stackDir, { OP_ENABLED_ADDONS: [...current].sort().join(',') });
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function enableAddon(homeDir: string, stackDir: string, name: string): MutationResult {
|
|
672
859
|
try {
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
860
|
+
if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
|
|
861
|
+
setEnabledAddonState(stackDir, name, true);
|
|
862
|
+
if (name === 'ssh') patchSecretsEnvFile(stackDir, { OPENCODE_ENABLE_SSH: '1' });
|
|
676
863
|
return { ok: true };
|
|
677
864
|
} catch (error) {
|
|
678
865
|
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
679
866
|
}
|
|
680
867
|
}
|
|
681
868
|
|
|
682
|
-
function disableAddonByName(homeDir: string, name: string): MutationResult {
|
|
869
|
+
function disableAddonByName(homeDir: string, stackDir: string, name: string): MutationResult {
|
|
683
870
|
try {
|
|
684
|
-
|
|
871
|
+
if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
|
|
872
|
+
setEnabledAddonState(stackDir, name, false);
|
|
873
|
+
if (name === 'ssh') patchSecretsEnvFile(stackDir, { OPENCODE_ENABLE_SSH: '0' });
|
|
685
874
|
return { ok: true };
|
|
686
875
|
} catch (error) {
|
|
687
876
|
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
688
877
|
}
|
|
689
878
|
}
|
|
690
879
|
|
|
691
|
-
export function setAddonEnabled(homeDir: string, stackDir: string, name: string, enabled: boolean): AddonMutationResult {
|
|
880
|
+
export function setAddonEnabled(homeDir: string, stackDir: string, name: string, enabled: boolean, state?: ControlPlaneState): AddonMutationResult {
|
|
692
881
|
if (!VALID_NAME_RE.test(name)) {
|
|
693
882
|
return { ok: false, error: `Invalid addon name: ${name}` };
|
|
694
883
|
}
|
|
695
884
|
|
|
696
885
|
if (!listAvailableAddonIds().includes(name)) {
|
|
697
|
-
return { ok: false, error: `Addon "${name}" not
|
|
886
|
+
return { ok: false, error: `Addon "${name}" is not built in` };
|
|
698
887
|
}
|
|
699
888
|
|
|
700
889
|
const wasEnabled = listEnabledAddonIds(homeDir).includes(name);
|
|
@@ -709,16 +898,18 @@ export function setAddonEnabled(homeDir: string, stackDir: string, name: string,
|
|
|
709
898
|
};
|
|
710
899
|
}
|
|
711
900
|
|
|
712
|
-
const mutation = enabled ? enableAddon(homeDir, name) : disableAddonByName(homeDir, name);
|
|
901
|
+
const mutation = enabled ? enableAddon(homeDir, stackDir, name) : disableAddonByName(homeDir, stackDir, name);
|
|
713
902
|
if (!mutation.ok) return mutation;
|
|
714
903
|
|
|
715
904
|
if (enabled) {
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
905
|
+
if (['api', 'chat', 'discord', 'slack'].includes(name)) {
|
|
906
|
+
for (const channel of ['api', 'chat', 'discord', 'slack']) {
|
|
907
|
+
ensureChannelSecret(stackDir, channel);
|
|
908
|
+
}
|
|
719
909
|
}
|
|
720
910
|
}
|
|
721
911
|
|
|
912
|
+
|
|
722
913
|
return {
|
|
723
914
|
ok: true,
|
|
724
915
|
enabled,
|
|
@@ -732,20 +923,20 @@ export function installAutomationFromRegistry(name: string, stashDir: string): M
|
|
|
732
923
|
return { ok: false, error: `Invalid automation name: ${name}` };
|
|
733
924
|
}
|
|
734
925
|
|
|
735
|
-
const
|
|
736
|
-
if (!
|
|
926
|
+
const taskContent = getRegistryAutomation(name);
|
|
927
|
+
if (!taskContent) {
|
|
737
928
|
return { ok: false, error: `Automation "${name}" not found in registry` };
|
|
738
929
|
}
|
|
739
930
|
|
|
740
931
|
const tasksDir = join(stashDir, 'tasks');
|
|
741
932
|
mkdirSync(tasksDir, { recursive: true });
|
|
742
933
|
|
|
743
|
-
const
|
|
744
|
-
if (existsSync(
|
|
934
|
+
const ymlPath = join(tasksDir, `${name}.yml`);
|
|
935
|
+
if (existsSync(ymlPath)) {
|
|
745
936
|
return { ok: false, error: `Automation "${name}" is already installed` };
|
|
746
937
|
}
|
|
747
938
|
|
|
748
|
-
writeFileSync(
|
|
939
|
+
writeFileSync(ymlPath, taskContent);
|
|
749
940
|
// The assistant container's 60-second akm tasks sync loop picks up the new
|
|
750
941
|
// file from the shared stash mount and registers it with OS cron.
|
|
751
942
|
return { ok: true };
|
|
@@ -756,12 +947,12 @@ export function uninstallAutomation(name: string, stashDir: string): MutationRes
|
|
|
756
947
|
return { ok: false, error: `Invalid automation name: ${name}` };
|
|
757
948
|
}
|
|
758
949
|
|
|
759
|
-
const
|
|
760
|
-
if (!existsSync(
|
|
950
|
+
const ymlPath = join(stashDir, 'tasks', `${name}.yml`);
|
|
951
|
+
if (!existsSync(ymlPath)) {
|
|
761
952
|
return { ok: false, error: `Automation "${name}" is not installed` };
|
|
762
953
|
}
|
|
763
954
|
|
|
764
|
-
rmSync(
|
|
955
|
+
rmSync(ymlPath, { force: true });
|
|
765
956
|
// The assistant container's 60-second akm tasks sync will notice the file
|
|
766
957
|
// is gone and deregister it from OS cron on next sync.
|
|
767
958
|
return { ok: true };
|