@better-openclaw/core 1.0.24 → 1.0.26
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/dist/addon-stack.cjs +725 -0
- package/dist/addon-stack.cjs.map +1 -0
- package/dist/addon-stack.d.cts +23 -0
- package/dist/addon-stack.d.cts.map +1 -0
- package/dist/addon-stack.d.mts +23 -0
- package/dist/addon-stack.d.mts.map +1 -0
- package/dist/addon-stack.mjs +723 -0
- package/dist/addon-stack.mjs.map +1 -0
- package/dist/addon-stack.test.cjs +461 -0
- package/dist/addon-stack.test.cjs.map +1 -0
- package/dist/addon-stack.test.d.cts +1 -0
- package/dist/addon-stack.test.d.mts +1 -0
- package/dist/addon-stack.test.mjs +461 -0
- package/dist/addon-stack.test.mjs.map +1 -0
- package/dist/bare-metal-partition.test.cjs +20 -20
- package/dist/bare-metal-partition.test.cjs.map +1 -1
- package/dist/bare-metal-partition.test.mjs +2 -2
- package/dist/compose-validation.test.cjs +1 -1
- package/dist/composer.cjs +5 -1
- package/dist/composer.cjs.map +1 -1
- package/dist/composer.d.cts +24 -1
- package/dist/composer.d.cts.map +1 -1
- package/dist/composer.d.mts +24 -1
- package/dist/composer.d.mts.map +1 -1
- package/dist/composer.mjs +1 -1
- package/dist/composer.mjs.map +1 -1
- package/dist/composer.snapshot.test.cjs +20 -20
- package/dist/composer.snapshot.test.cjs.map +1 -1
- package/dist/composer.snapshot.test.mjs +2 -2
- package/dist/composer.test.cjs +54 -54
- package/dist/composer.test.cjs.map +1 -1
- package/dist/composer.test.mjs +2 -2
- package/dist/deployers/strip-host-ports.cjs +1 -1
- package/dist/deployers/strip-host-ports.test.cjs +26 -26
- package/dist/deployers/strip-host-ports.test.cjs.map +1 -1
- package/dist/deployers/strip-host-ports.test.mjs +1 -1
- package/dist/generate.cjs +3 -3
- package/dist/generate.mjs +3 -3
- package/dist/generate.test.cjs +56 -56
- package/dist/generate.test.cjs.map +1 -1
- package/dist/generate.test.mjs +1 -1
- package/dist/generators/bare-metal-install.test.cjs +18 -18
- package/dist/generators/bare-metal-install.test.cjs.map +1 -1
- package/dist/generators/bare-metal-install.test.mjs +1 -1
- package/dist/generators/caddy.test.cjs +13 -13
- package/dist/generators/caddy.test.cjs.map +1 -1
- package/dist/generators/caddy.test.mjs +1 -1
- package/dist/generators/clone-repos.test.cjs +27 -27
- package/dist/generators/clone-repos.test.cjs.map +1 -1
- package/dist/generators/clone-repos.test.mjs +1 -1
- package/dist/generators/env.cjs +1 -1
- package/dist/generators/env.test.cjs +17 -17
- package/dist/generators/env.test.cjs.map +1 -1
- package/dist/generators/env.test.mjs +1 -1
- package/dist/generators/health-check.test.cjs +39 -39
- package/dist/generators/health-check.test.cjs.map +1 -1
- package/dist/generators/health-check.test.mjs +1 -1
- package/dist/generators/postgres-init.cjs +5 -0
- package/dist/generators/postgres-init.cjs.map +1 -1
- package/dist/generators/postgres-init.d.cts.map +1 -1
- package/dist/generators/postgres-init.d.mts.map +1 -1
- package/dist/generators/postgres-init.mjs +5 -0
- package/dist/generators/postgres-init.mjs.map +1 -1
- package/dist/generators/scripts.test.cjs +39 -39
- package/dist/generators/scripts.test.cjs.map +1 -1
- package/dist/generators/scripts.test.mjs +1 -1
- package/dist/generators/skills.cjs +1 -1
- package/dist/generators/skills.d.cts.map +1 -1
- package/dist/generators/skills.d.mts.map +1 -1
- package/dist/generators/skills.mjs +141 -0
- package/dist/generators/skills.mjs.map +1 -1
- package/dist/generators/traefik.test.cjs +32 -32
- package/dist/generators/traefik.test.cjs.map +1 -1
- package/dist/generators/traefik.test.mjs +1 -1
- package/dist/index.cjs +21 -5
- package/dist/index.d.cts +5 -4
- package/dist/index.d.mts +5 -4
- package/dist/index.mjs +7 -6
- package/dist/migrations.test.cjs +16 -16
- package/dist/migrations.test.cjs.map +1 -1
- package/dist/migrations.test.mjs +1 -1
- package/dist/presets/presets.test.cjs +1 -1
- package/dist/presets/registry.test.cjs +14 -14
- package/dist/presets/registry.test.cjs.map +1 -1
- package/dist/presets/registry.test.mjs +1 -1
- package/dist/resolver.test.cjs +95 -95
- package/dist/resolver.test.cjs.map +1 -1
- package/dist/resolver.test.mjs +1 -1
- package/dist/{schema-eX44HhRp.d.mts → schema-BQnZrcw8.d.cts} +300 -2
- package/dist/schema-BQnZrcw8.d.cts.map +1 -0
- package/dist/{schema-tn5RK8CM.d.cts → schema-SBpL0bdI.d.mts} +300 -2
- package/dist/schema-SBpL0bdI.d.mts.map +1 -0
- package/dist/schema.cjs +148 -2
- package/dist/schema.cjs.map +1 -1
- package/dist/schema.d.cts +2 -2
- package/dist/schema.d.mts +2 -2
- package/dist/schema.mjs +139 -2
- package/dist/schema.mjs.map +1 -1
- package/dist/schema.test.cjs +86 -86
- package/dist/schema.test.cjs.map +1 -1
- package/dist/schema.test.mjs +1 -1
- package/dist/services/definitions/browserless.cjs +4 -1
- package/dist/services/definitions/browserless.cjs.map +1 -1
- package/dist/services/definitions/browserless.mjs +4 -1
- package/dist/services/definitions/browserless.mjs.map +1 -1
- package/dist/services/definitions/burnlink.cjs +142 -0
- package/dist/services/definitions/burnlink.cjs.map +1 -0
- package/dist/services/definitions/burnlink.d.cts +7 -0
- package/dist/services/definitions/burnlink.d.cts.map +1 -0
- package/dist/services/definitions/burnlink.d.mts +7 -0
- package/dist/services/definitions/burnlink.d.mts.map +1 -0
- package/dist/services/definitions/burnlink.mjs +141 -0
- package/dist/services/definitions/burnlink.mjs.map +1 -0
- package/dist/services/definitions/convex.cjs +43 -1
- package/dist/services/definitions/convex.cjs.map +1 -1
- package/dist/services/definitions/convex.mjs +43 -1
- package/dist/services/definitions/convex.mjs.map +1 -1
- package/dist/services/definitions/grafana.cjs +11 -1
- package/dist/services/definitions/grafana.cjs.map +1 -1
- package/dist/services/definitions/grafana.mjs +11 -1
- package/dist/services/definitions/grafana.mjs.map +1 -1
- package/dist/services/definitions/hindsight.cjs +130 -0
- package/dist/services/definitions/hindsight.cjs.map +1 -0
- package/dist/services/definitions/hindsight.d.cts +7 -0
- package/dist/services/definitions/hindsight.d.cts.map +1 -0
- package/dist/services/definitions/hindsight.d.mts +7 -0
- package/dist/services/definitions/hindsight.d.mts.map +1 -0
- package/dist/services/definitions/hindsight.mjs +129 -0
- package/dist/services/definitions/hindsight.mjs.map +1 -0
- package/dist/services/definitions/index.cjs +9 -0
- package/dist/services/definitions/index.cjs.map +1 -1
- package/dist/services/definitions/index.d.cts +4 -1
- package/dist/services/definitions/index.d.cts.map +1 -1
- package/dist/services/definitions/index.d.mts +4 -1
- package/dist/services/definitions/index.d.mts.map +1 -1
- package/dist/services/definitions/index.mjs +7 -1
- package/dist/services/definitions/index.mjs.map +1 -1
- package/dist/services/definitions/meilisearch.cjs +11 -1
- package/dist/services/definitions/meilisearch.cjs.map +1 -1
- package/dist/services/definitions/meilisearch.mjs +11 -1
- package/dist/services/definitions/meilisearch.mjs.map +1 -1
- package/dist/services/definitions/minio.cjs +3 -1
- package/dist/services/definitions/minio.cjs.map +1 -1
- package/dist/services/definitions/minio.mjs +3 -1
- package/dist/services/definitions/minio.mjs.map +1 -1
- package/dist/services/definitions/n8n.cjs +11 -1
- package/dist/services/definitions/n8n.cjs.map +1 -1
- package/dist/services/definitions/n8n.mjs +11 -1
- package/dist/services/definitions/n8n.mjs.map +1 -1
- package/dist/services/definitions/ollama.cjs +3 -1
- package/dist/services/definitions/ollama.cjs.map +1 -1
- package/dist/services/definitions/ollama.mjs +3 -1
- package/dist/services/definitions/ollama.mjs.map +1 -1
- package/dist/services/definitions/opensandbox.cjs +149 -0
- package/dist/services/definitions/opensandbox.cjs.map +1 -0
- package/dist/services/definitions/opensandbox.d.cts +7 -0
- package/dist/services/definitions/opensandbox.d.cts.map +1 -0
- package/dist/services/definitions/opensandbox.d.mts +7 -0
- package/dist/services/definitions/opensandbox.d.mts.map +1 -0
- package/dist/services/definitions/opensandbox.mjs +148 -0
- package/dist/services/definitions/opensandbox.mjs.map +1 -0
- package/dist/services/definitions/qdrant.cjs +3 -1
- package/dist/services/definitions/qdrant.cjs.map +1 -1
- package/dist/services/definitions/qdrant.mjs +3 -1
- package/dist/services/definitions/qdrant.mjs.map +1 -1
- package/dist/services/definitions/searxng.cjs +8 -1
- package/dist/services/definitions/searxng.cjs.map +1 -1
- package/dist/services/definitions/searxng.mjs +8 -1
- package/dist/services/definitions/searxng.mjs.map +1 -1
- package/dist/services/definitions/uptime-kuma.cjs +8 -1
- package/dist/services/definitions/uptime-kuma.cjs.map +1 -1
- package/dist/services/definitions/uptime-kuma.mjs +8 -1
- package/dist/services/definitions/uptime-kuma.mjs.map +1 -1
- package/dist/services/registry.test.cjs +36 -36
- package/dist/services/registry.test.cjs.map +1 -1
- package/dist/services/registry.test.mjs +1 -1
- package/dist/{skills-BlzpHmpH.cjs → skills-BSF7iNa4.cjs} +142 -1
- package/dist/{skills-BlzpHmpH.cjs.map → skills-BSF7iNa4.cjs.map} +1 -1
- package/dist/{vi.2VT5v0um-C_jmO7m2.mjs → test.CTcmp4Su-ClCHJ3FA.mjs} +6793 -6403
- package/dist/test.CTcmp4Su-ClCHJ3FA.mjs.map +1 -0
- package/dist/{vi.2VT5v0um-iVBt6Fyq.cjs → test.CTcmp4Su-DlzTarwH.cjs} +6793 -6403
- package/dist/test.CTcmp4Su-DlzTarwH.cjs.map +1 -0
- package/dist/track-analytics.test.cjs +28 -28
- package/dist/track-analytics.test.cjs.map +1 -1
- package/dist/track-analytics.test.mjs +1 -1
- package/dist/types.cjs.map +1 -1
- package/dist/types.d.cts +10 -2
- package/dist/types.d.cts.map +1 -1
- package/dist/types.d.mts +10 -2
- package/dist/types.d.mts.map +1 -1
- package/dist/types.mjs.map +1 -1
- package/dist/validator.cjs +1 -1
- package/dist/validator.test.cjs +15 -15
- package/dist/validator.test.cjs.map +1 -1
- package/dist/validator.test.mjs +2 -2
- package/dist/version-manager.test.cjs +37 -37
- package/dist/version-manager.test.cjs.map +1 -1
- package/dist/version-manager.test.mjs +1 -1
- package/package.json +4 -4
- package/src/__snapshots__/composer.snapshot.test.ts.snap +5 -0
- package/src/addon-stack.test.ts +648 -0
- package/src/addon-stack.ts +1046 -0
- package/src/composer.ts +4 -4
- package/src/generators/postgres-init.ts +2 -0
- package/src/generators/skills.ts +142 -0
- package/src/index.ts +20 -2
- package/src/schema.ts +190 -0
- package/src/services/definitions/browserless.ts +3 -0
- package/src/services/definitions/burnlink.ts +142 -0
- package/src/services/definitions/convex.ts +31 -0
- package/src/services/definitions/grafana.ts +9 -0
- package/src/services/definitions/hindsight.ts +131 -0
- package/src/services/definitions/index.ts +10 -0
- package/src/services/definitions/meilisearch.ts +9 -0
- package/src/services/definitions/minio.ts +2 -0
- package/src/services/definitions/n8n.ts +9 -0
- package/src/services/definitions/ollama.ts +2 -0
- package/src/services/definitions/opensandbox.ts +156 -0
- package/src/services/definitions/qdrant.ts +2 -0
- package/src/services/definitions/searxng.ts +3 -0
- package/src/services/definitions/uptime-kuma.ts +3 -0
- package/src/types.ts +18 -0
- package/dist/schema-eX44HhRp.d.mts.map +0 -1
- package/dist/schema-tn5RK8CM.d.cts.map +0 -1
- package/dist/vi.2VT5v0um-C_jmO7m2.mjs.map +0 -1
- package/dist/vi.2VT5v0um-iVBt6Fyq.cjs.map +0 -1
|
@@ -0,0 +1,1046 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { stringify } from "yaml";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import { buildCompanionService, buildPostgresSetup, quotedStr, YAML_OPTIONS } from "./composer.js";
|
|
5
|
+
import { getDbRequirements } from "./generators/postgres-init.js";
|
|
6
|
+
import { generateSkillFiles } from "./generators/skills.js";
|
|
7
|
+
import { resolve } from "./resolver.js";
|
|
8
|
+
import { AddonStackInputSchema, AddonStackUpdateInputSchema } from "./schema.js";
|
|
9
|
+
import { getServiceById } from "./services/registry.js";
|
|
10
|
+
import type {
|
|
11
|
+
AddonStackInput,
|
|
12
|
+
AddonStackResult,
|
|
13
|
+
AddonStackUpdateInput,
|
|
14
|
+
AddonStackUpdateResult,
|
|
15
|
+
ComposeOptions,
|
|
16
|
+
ProxyRoute,
|
|
17
|
+
ResolvedService,
|
|
18
|
+
ResolverOutput,
|
|
19
|
+
ServiceDefinition,
|
|
20
|
+
SkippedService,
|
|
21
|
+
} from "./types.js";
|
|
22
|
+
|
|
23
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/** Services that Clawexa's cloud-init already provisions (or are mandatory platform services). */
|
|
26
|
+
const INFRA_SERVICE_IDS = new Set([
|
|
27
|
+
"openclaw-gateway",
|
|
28
|
+
"openclaw-cli",
|
|
29
|
+
"redis",
|
|
30
|
+
"postgresql",
|
|
31
|
+
"open-webui",
|
|
32
|
+
"caddy",
|
|
33
|
+
"traefik",
|
|
34
|
+
"postgres-setup",
|
|
35
|
+
// Mandatory platform services provisioned by Clawexa cloud-init
|
|
36
|
+
"convex",
|
|
37
|
+
"convex-dashboard",
|
|
38
|
+
"mission-control",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/** Env keys managed by Clawexa's cloud-init — never include in addon env output. */
|
|
42
|
+
const CLAWEXA_MANAGED_ENV_KEYS = new Set([
|
|
43
|
+
"COMPOSE_FILE",
|
|
44
|
+
"COMPOSE_PROFILES",
|
|
45
|
+
"OPENCLAW_VERSION",
|
|
46
|
+
"OPENCLAW_GATEWAY_TOKEN",
|
|
47
|
+
"OPENCLAW_GATEWAY_PORT",
|
|
48
|
+
"OPENCLAW_BRIDGE_PORT",
|
|
49
|
+
"OPENCLAW_GATEWAY_BIND",
|
|
50
|
+
"OPENCLAW_CONFIG_DIR",
|
|
51
|
+
"OPENCLAW_WORKSPACE_DIR",
|
|
52
|
+
"REDIS_PASSWORD",
|
|
53
|
+
"REDIS_HOST",
|
|
54
|
+
"REDIS_PORT",
|
|
55
|
+
"POSTGRES_USER",
|
|
56
|
+
"POSTGRES_PASSWORD",
|
|
57
|
+
"POSTGRES_DB",
|
|
58
|
+
"POSTGRES_HOST",
|
|
59
|
+
"POSTGRES_PORT",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/** Sanitize instanceId into a valid Docker Compose project name. */
|
|
65
|
+
function sanitizeProjectName(instanceId: string): string {
|
|
66
|
+
return instanceId
|
|
67
|
+
.toLowerCase()
|
|
68
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
69
|
+
.replace(/^-+|-+$/g, "")
|
|
70
|
+
.replace(/-{2,}/g, "-")
|
|
71
|
+
.slice(0, 64) || "addon";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Generate a cryptographically secure hex secret of the given byte length. */
|
|
75
|
+
function generateHexSecret(bytes: number): string {
|
|
76
|
+
return randomBytes(bytes).toString("hex");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Generate a cryptographically secure base64url secret of the given byte length. */
|
|
80
|
+
function generateBase64UrlSecret(bytes: number): string {
|
|
81
|
+
return randomBytes(bytes).toString("base64url");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check if a service requires user-provided credentials that are missing.
|
|
86
|
+
* Returns the list of missing credential keys, or empty if all are satisfied.
|
|
87
|
+
*
|
|
88
|
+
* When `generateSecrets` is true, empty-default secrets are auto-generated
|
|
89
|
+
* (passwords, tokens, etc.) and are NOT considered missing. Only secrets
|
|
90
|
+
* with `validation` regex patterns (indicating specific format like API keys)
|
|
91
|
+
* are flagged as missing when not provided by the user.
|
|
92
|
+
*/
|
|
93
|
+
function getMissingCredentials(
|
|
94
|
+
def: ServiceDefinition,
|
|
95
|
+
userCredentials: Record<string, string> | undefined,
|
|
96
|
+
generateSecrets: boolean,
|
|
97
|
+
): string[] {
|
|
98
|
+
const missing: string[] = [];
|
|
99
|
+
for (const env of def.environment) {
|
|
100
|
+
// Skip if not required or not a secret
|
|
101
|
+
if (!env.required || !env.secret) continue;
|
|
102
|
+
// Skip if it has a non-empty default value or is a reference
|
|
103
|
+
if (env.defaultValue && env.defaultValue.length > 0) continue;
|
|
104
|
+
// Skip if user provided the credential
|
|
105
|
+
if (userCredentials?.[env.key]) continue;
|
|
106
|
+
// When generateSecrets is true, empty secrets are auto-generated
|
|
107
|
+
// Only flag as missing if the env var has a validation pattern
|
|
108
|
+
// (indicating it needs a specific format like an API key)
|
|
109
|
+
if (generateSecrets && !env.validation) continue;
|
|
110
|
+
|
|
111
|
+
missing.push(env.key);
|
|
112
|
+
}
|
|
113
|
+
return missing;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Apply env quirks (from service definition) to the generated env values.
|
|
118
|
+
* Handles: empty_string_crashes → set fixed value, min_length → generate longer secret,
|
|
119
|
+
* must_sync → ensure two keys have the same value.
|
|
120
|
+
*/
|
|
121
|
+
function applyEnvQuirks(
|
|
122
|
+
def: ServiceDefinition,
|
|
123
|
+
envValues: Map<string, string>,
|
|
124
|
+
generateSecrets: boolean,
|
|
125
|
+
): void {
|
|
126
|
+
if (!def.envQuirks) return;
|
|
127
|
+
|
|
128
|
+
for (const quirk of def.envQuirks) {
|
|
129
|
+
switch (quirk.issue) {
|
|
130
|
+
case "empty_string_crashes": {
|
|
131
|
+
if (quirk.fix.type === "set_value" && quirk.fix.value !== undefined) {
|
|
132
|
+
envValues.set(quirk.key, quirk.fix.value);
|
|
133
|
+
}
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
case "min_length": {
|
|
137
|
+
if (!generateSecrets) break;
|
|
138
|
+
const current = envValues.get(quirk.key) || "";
|
|
139
|
+
const minBytes = quirk.fix.minBytes || 24;
|
|
140
|
+
const minHexLen = minBytes * 2;
|
|
141
|
+
if (current.length < minHexLen) {
|
|
142
|
+
if (quirk.fix.type === "generate_hex") {
|
|
143
|
+
envValues.set(quirk.key, generateHexSecret(minBytes));
|
|
144
|
+
} else if (quirk.fix.type === "generate_base64url") {
|
|
145
|
+
envValues.set(quirk.key, generateBase64UrlSecret(minBytes));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
case "must_sync": {
|
|
151
|
+
if (quirk.fix.type === "sync_with" && quirk.fix.syncKey) {
|
|
152
|
+
const sourceValue = envValues.get(quirk.key) || envValues.get(quirk.fix.syncKey);
|
|
153
|
+
if (sourceValue) {
|
|
154
|
+
envValues.set(quirk.key, sourceValue);
|
|
155
|
+
envValues.set(quirk.fix.syncKey, sourceValue);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Build proxy routes from resolved addon services.
|
|
166
|
+
*/
|
|
167
|
+
function buildProxyRoutes(services: ResolvedService[]): ProxyRoute[] {
|
|
168
|
+
const routes: ProxyRoute[] = [];
|
|
169
|
+
for (const { definition: def } of services) {
|
|
170
|
+
const exposedPort = def.ports.find((p) => p.exposed);
|
|
171
|
+
if (!exposedPort) continue;
|
|
172
|
+
|
|
173
|
+
routes.push({
|
|
174
|
+
serviceId: def.id,
|
|
175
|
+
path: def.proxyPath || `/${def.id}`,
|
|
176
|
+
port: exposedPort.container,
|
|
177
|
+
protocol: "http",
|
|
178
|
+
stripPrefix: true,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return routes;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Build a minimal ComposeOptions suitable for addon stack generation.
|
|
186
|
+
* No proxy, no gateway, no hardening by default.
|
|
187
|
+
*/
|
|
188
|
+
function buildAddonComposeOptions(projectName: string, input: AddonStackInput): ComposeOptions {
|
|
189
|
+
return {
|
|
190
|
+
projectName,
|
|
191
|
+
proxy: "none",
|
|
192
|
+
gpu: false,
|
|
193
|
+
platform: input.platform ?? "linux/amd64",
|
|
194
|
+
deployment: "clawexa",
|
|
195
|
+
openclawVersion: input.openclawVersion ?? "latest",
|
|
196
|
+
openclawImage: "official",
|
|
197
|
+
hardened: false, // Clawexa default: no cap_drop/security_opt
|
|
198
|
+
openclawInstallMethod: "docker",
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Resolve port conflicts between addon services and reserved ports.
|
|
204
|
+
* Returns a map of serviceId → { originalPort → assignedPort }.
|
|
205
|
+
*/
|
|
206
|
+
function resolvePortConflicts(
|
|
207
|
+
addonServices: ResolvedService[],
|
|
208
|
+
reservedPorts: number[],
|
|
209
|
+
portOverrides?: Record<string, Record<string, number>>,
|
|
210
|
+
): { assignments: Record<string, number>; overrides: Record<string, Record<string, number>> } {
|
|
211
|
+
const usedPorts = new Set(reservedPorts);
|
|
212
|
+
const assignments: Record<string, number> = {};
|
|
213
|
+
const overrides: Record<string, Record<string, number>> = {};
|
|
214
|
+
|
|
215
|
+
for (const { definition: def } of addonServices) {
|
|
216
|
+
for (const port of def.ports) {
|
|
217
|
+
if (!port.exposed) continue;
|
|
218
|
+
|
|
219
|
+
// Check for user-specified override
|
|
220
|
+
const userOverride = portOverrides?.[def.id]?.[String(port.host)];
|
|
221
|
+
if (userOverride) {
|
|
222
|
+
usedPorts.add(userOverride);
|
|
223
|
+
assignments[`${def.id}:${port.container}`] = userOverride;
|
|
224
|
+
if (!overrides[def.id]) overrides[def.id] = {};
|
|
225
|
+
overrides[def.id][String(port.host)] = userOverride;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let assignedPort = port.host;
|
|
230
|
+
if (usedPorts.has(assignedPort)) {
|
|
231
|
+
// Auto-reassign to port+1000 range
|
|
232
|
+
assignedPort = port.host + 1000;
|
|
233
|
+
while (usedPorts.has(assignedPort)) {
|
|
234
|
+
assignedPort++;
|
|
235
|
+
}
|
|
236
|
+
if (!overrides[def.id]) overrides[def.id] = {};
|
|
237
|
+
overrides[def.id][String(port.host)] = assignedPort;
|
|
238
|
+
}
|
|
239
|
+
usedPorts.add(assignedPort);
|
|
240
|
+
assignments[`${def.id}:${port.container}`] = assignedPort;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { assignments, overrides };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Main: generateAddonStack ─────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Generates a Docker Compose override stack containing only addon services
|
|
251
|
+
* for Clawexa managed instances. Infrastructure services (gateway, redis,
|
|
252
|
+
* postgres, open-webui, caddy) are excluded since Clawexa's cloud-init
|
|
253
|
+
* already provisions them.
|
|
254
|
+
*
|
|
255
|
+
* This function never throws. Errors are reported via `warnings` and
|
|
256
|
+
* `metadata.skippedServices`.
|
|
257
|
+
*/
|
|
258
|
+
export function generateAddonStack(rawInput: AddonStackInput): AddonStackResult {
|
|
259
|
+
const warnings: string[] = [];
|
|
260
|
+
const skippedServices: SkippedService[] = [];
|
|
261
|
+
const generatedSecretKeys: string[] = [];
|
|
262
|
+
|
|
263
|
+
// 1. Parse & validate input
|
|
264
|
+
let input: AddonStackInput;
|
|
265
|
+
try {
|
|
266
|
+
input = AddonStackInputSchema.parse(rawInput);
|
|
267
|
+
} catch (err) {
|
|
268
|
+
return emptyResult(`Invalid input: ${err instanceof Error ? err.message : String(err)}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const projectName = sanitizeProjectName(input.instanceId);
|
|
272
|
+
|
|
273
|
+
// 2. Filter out infrastructure service IDs from request
|
|
274
|
+
const addonServiceIds = input.services.filter((id) => {
|
|
275
|
+
if (INFRA_SERVICE_IDS.has(id)) {
|
|
276
|
+
warnings.push(`Service "${id}" is managed by Clawexa infrastructure and was excluded.`);
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
return true;
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
if (addonServiceIds.length === 0) {
|
|
283
|
+
return emptyResult("No addon services requested (all were infrastructure services).");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 3. Validate all service IDs exist in registry
|
|
287
|
+
const validServiceIds: string[] = [];
|
|
288
|
+
for (const id of addonServiceIds) {
|
|
289
|
+
const svc = getServiceById(id);
|
|
290
|
+
if (!svc) {
|
|
291
|
+
skippedServices.push({
|
|
292
|
+
serviceId: id,
|
|
293
|
+
reason: "unknown_service",
|
|
294
|
+
details: `Service "${id}" does not exist in the registry.`,
|
|
295
|
+
});
|
|
296
|
+
} else {
|
|
297
|
+
validServiceIds.push(id);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (validServiceIds.length === 0) {
|
|
302
|
+
return {
|
|
303
|
+
...emptyResultBase(),
|
|
304
|
+
metadata: {
|
|
305
|
+
...emptyResultBase().metadata,
|
|
306
|
+
skippedServices,
|
|
307
|
+
},
|
|
308
|
+
warnings: [...warnings, "No valid addon services to deploy."],
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// 4. Resolve dependencies
|
|
313
|
+
let resolved: ResolverOutput;
|
|
314
|
+
try {
|
|
315
|
+
resolved = resolve({
|
|
316
|
+
services: validServiceIds,
|
|
317
|
+
skillPacks: input.skillPacks,
|
|
318
|
+
aiProviders: input.aiProviders,
|
|
319
|
+
platform: input.platform ?? "linux/amd64",
|
|
320
|
+
});
|
|
321
|
+
} catch (err) {
|
|
322
|
+
return {
|
|
323
|
+
...emptyResultBase(),
|
|
324
|
+
metadata: {
|
|
325
|
+
...emptyResultBase().metadata,
|
|
326
|
+
skippedServices,
|
|
327
|
+
},
|
|
328
|
+
warnings: [
|
|
329
|
+
...warnings,
|
|
330
|
+
`Dependency resolution failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
331
|
+
],
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Forward resolver warnings
|
|
336
|
+
for (const w of resolved.warnings) {
|
|
337
|
+
warnings.push(w.message);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 5. Filter resolved services: keep only addon services (not infra)
|
|
341
|
+
const addonResolved: ResolvedService[] = [];
|
|
342
|
+
for (const svc of resolved.services) {
|
|
343
|
+
if (INFRA_SERVICE_IDS.has(svc.definition.id)) continue;
|
|
344
|
+
addonResolved.push(svc);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 6. Check credentials, images, platform, GPU for each addon service
|
|
348
|
+
const deployableServices: ResolvedService[] = [];
|
|
349
|
+
for (const svc of addonResolved) {
|
|
350
|
+
const def = svc.definition;
|
|
351
|
+
|
|
352
|
+
// Check for git-based services without prebuilt image
|
|
353
|
+
if (def.gitSource && def.buildContext && !def.image) {
|
|
354
|
+
const prebuilt = def.prebuiltImage || input.prebuiltImages[def.id];
|
|
355
|
+
if (!prebuilt) {
|
|
356
|
+
skippedServices.push({
|
|
357
|
+
serviceId: def.id,
|
|
358
|
+
reason: "no_image",
|
|
359
|
+
details: `Service "${def.name}" requires building from source but no pre-built image is available.`,
|
|
360
|
+
});
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Check GPU requirement — skip if host has no GPU support
|
|
366
|
+
if (def.gpuRequired && !input.gpu) {
|
|
367
|
+
skippedServices.push({
|
|
368
|
+
serviceId: def.id,
|
|
369
|
+
reason: "gpu_required",
|
|
370
|
+
details: `Service "${def.name}" requires a GPU but the host does not have GPU support.`,
|
|
371
|
+
});
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Check user-provided credentials
|
|
376
|
+
const userCreds = input.credentials[def.id];
|
|
377
|
+
const missing = getMissingCredentials(def, userCreds, input.generateSecrets);
|
|
378
|
+
if (missing.length > 0) {
|
|
379
|
+
skippedServices.push({
|
|
380
|
+
serviceId: def.id,
|
|
381
|
+
reason: "missing_credentials",
|
|
382
|
+
details: `Service "${def.name}" requires credentials: ${missing.join(", ")}`,
|
|
383
|
+
requiredCredentials: missing,
|
|
384
|
+
});
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
deployableServices.push(svc);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (deployableServices.length === 0) {
|
|
392
|
+
return {
|
|
393
|
+
...emptyResultBase(),
|
|
394
|
+
metadata: {
|
|
395
|
+
...emptyResultBase().metadata,
|
|
396
|
+
skippedServices,
|
|
397
|
+
},
|
|
398
|
+
warnings: [...warnings, "No deployable addon services after filtering."],
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// 7. Resolve port conflicts (include ports from existingServices)
|
|
403
|
+
const allReservedPorts = [...input.reservedPorts];
|
|
404
|
+
for (const existingId of input.existingServices) {
|
|
405
|
+
const existingDef = getServiceById(existingId);
|
|
406
|
+
if (existingDef) {
|
|
407
|
+
for (const port of existingDef.ports) {
|
|
408
|
+
if (port.exposed) allReservedPorts.push(port.host);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const portConflicts = resolvePortConflicts(
|
|
413
|
+
deployableServices,
|
|
414
|
+
allReservedPorts,
|
|
415
|
+
input.portOverrides,
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
// Build a fake "full" resolved output for buildCompanionService
|
|
419
|
+
// It needs to see all services to resolve depends_on references
|
|
420
|
+
const addonResolvedOutput: ResolverOutput = {
|
|
421
|
+
services: deployableServices,
|
|
422
|
+
addedDependencies: resolved.addedDependencies,
|
|
423
|
+
removedConflicts: resolved.removedConflicts,
|
|
424
|
+
warnings: resolved.warnings,
|
|
425
|
+
errors: [],
|
|
426
|
+
isValid: true,
|
|
427
|
+
estimatedMemoryMB: deployableServices.reduce(
|
|
428
|
+
(sum, s) => sum + (s.definition.minMemoryMB ?? 128),
|
|
429
|
+
0,
|
|
430
|
+
),
|
|
431
|
+
aiProviders: input.aiProviders ?? [],
|
|
432
|
+
gsdRuntimes: [],
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
// 8. Build compose options (no hardening for Clawexa)
|
|
436
|
+
const composeOptions = buildAddonComposeOptions(projectName, input);
|
|
437
|
+
if (Object.keys(portConflicts.overrides).length > 0) {
|
|
438
|
+
composeOptions.portOverrides = portConflicts.overrides;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// 9. Build per-service entries
|
|
442
|
+
const services: Record<string, Record<string, unknown>> = {};
|
|
443
|
+
const allVolumes = new Set<string>();
|
|
444
|
+
const envValues = new Map<string, string>();
|
|
445
|
+
|
|
446
|
+
for (const svc of deployableServices) {
|
|
447
|
+
const def = svc.definition;
|
|
448
|
+
try {
|
|
449
|
+
// Handle prebuilt image substitution for git-based services
|
|
450
|
+
let effectiveDef = def;
|
|
451
|
+
if (def.gitSource && def.buildContext && !def.image) {
|
|
452
|
+
const prebuiltImage = def.prebuiltImage || input.prebuiltImages[def.id];
|
|
453
|
+
if (prebuiltImage) {
|
|
454
|
+
const [img, tag] = prebuiltImage.includes(":")
|
|
455
|
+
? prebuiltImage.split(":")
|
|
456
|
+
: [prebuiltImage, "latest"];
|
|
457
|
+
effectiveDef = {
|
|
458
|
+
...def,
|
|
459
|
+
image: img,
|
|
460
|
+
imageTag: tag,
|
|
461
|
+
gitSource: undefined,
|
|
462
|
+
buildContext: undefined,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const { entry, volumeNames } = buildCompanionService(
|
|
468
|
+
effectiveDef,
|
|
469
|
+
addonResolvedOutput,
|
|
470
|
+
composeOptions,
|
|
471
|
+
allVolumes,
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
// Remove profiles from the service entry
|
|
475
|
+
delete (entry as Record<string, unknown>).profiles;
|
|
476
|
+
|
|
477
|
+
// Remove depends_on references to infrastructure services
|
|
478
|
+
if (entry.depends_on) {
|
|
479
|
+
const deps = entry.depends_on as Record<string, { condition: string }>;
|
|
480
|
+
for (const depId of Object.keys(deps)) {
|
|
481
|
+
if (INFRA_SERVICE_IDS.has(depId)) {
|
|
482
|
+
delete deps[depId];
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if (Object.keys(deps).length === 0) {
|
|
486
|
+
delete entry.depends_on;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
services[def.id] = entry;
|
|
491
|
+
for (const v of volumeNames) allVolumes.add(v);
|
|
492
|
+
|
|
493
|
+
// Inject user-provided credentials into env
|
|
494
|
+
const userCreds = input.credentials[def.id];
|
|
495
|
+
if (userCreds) {
|
|
496
|
+
for (const [key, value] of Object.entries(userCreds)) {
|
|
497
|
+
envValues.set(key, value);
|
|
498
|
+
}
|
|
499
|
+
// Sync referenced keys: if a user provides e.g. DB_POSTGRESDB_PASSWORD
|
|
500
|
+
// and the env var's defaultValue is "${N8N_DB_PASSWORD}", sync the ref key
|
|
501
|
+
// so postgres-setup uses the same password.
|
|
502
|
+
for (const envVar of def.environment) {
|
|
503
|
+
if (
|
|
504
|
+
userCreds[envVar.key] &&
|
|
505
|
+
envVar.defaultValue?.startsWith("${") &&
|
|
506
|
+
envVar.defaultValue?.endsWith("}")
|
|
507
|
+
) {
|
|
508
|
+
const refKey = envVar.defaultValue.slice(2, -1);
|
|
509
|
+
envValues.set(refKey, userCreds[envVar.key]);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
} catch (err) {
|
|
514
|
+
skippedServices.push({
|
|
515
|
+
serviceId: def.id,
|
|
516
|
+
reason: "resolution_error",
|
|
517
|
+
details: `Failed to build compose entry: ${err instanceof Error ? err.message : String(err)}`,
|
|
518
|
+
});
|
|
519
|
+
warnings.push(`Failed to process service "${def.name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// 10. Build postgres-setup if any addon needs a DB
|
|
524
|
+
// We need to check if any of our deployable services require DB setup
|
|
525
|
+
// and if postgresql is in the infrastructure (it is for Clawexa)
|
|
526
|
+
const dbReqs = getDbRequirements(addonResolvedOutput);
|
|
527
|
+
if (dbReqs.length > 0) {
|
|
528
|
+
// Build a custom postgres-setup that references the existing PostgreSQL
|
|
529
|
+
// We can't use buildPostgresSetup directly because it checks for postgresql
|
|
530
|
+
// in the resolved services. Instead, build it manually.
|
|
531
|
+
const scriptLines = ["echo '=== PostgreSQL database setup (addon) ==='", "FAILED=0"];
|
|
532
|
+
|
|
533
|
+
for (const req of dbReqs) {
|
|
534
|
+
scriptLines.push(
|
|
535
|
+
`echo "Setting up database '${req.dbName}' with user '${req.dbUser}'..."`,
|
|
536
|
+
`psql -c "SELECT 1 FROM pg_roles WHERE rolname='${req.dbUser}'" | grep -q 1 || psql -c "CREATE ROLE ${req.dbUser} WITH LOGIN PASSWORD '$$${req.passwordEnvVar}'"`,
|
|
537
|
+
`psql -c "ALTER ROLE ${req.dbUser} WITH LOGIN PASSWORD '$$${req.passwordEnvVar}'"`,
|
|
538
|
+
`psql -tc "SELECT 1 FROM pg_database WHERE datname='${req.dbName}'" | grep -q 1 || psql -c "CREATE DATABASE ${req.dbName} OWNER ${req.dbUser}"`,
|
|
539
|
+
`psql -c "GRANT ALL PRIVILEGES ON DATABASE ${req.dbName} TO ${req.dbUser}" || FAILED=1`,
|
|
540
|
+
`echo " Done: ${req.dbName}"`,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
scriptLines.push("echo '=== All databases ready ==='", "exit $$FAILED");
|
|
544
|
+
|
|
545
|
+
const dbEnv: Record<string, string> = {
|
|
546
|
+
PGHOST: "postgresql",
|
|
547
|
+
PGUSER: "${POSTGRES_USER:-openclaw}",
|
|
548
|
+
PGDATABASE: "${POSTGRES_DB:-openclaw}",
|
|
549
|
+
PGPASSWORD: "${POSTGRES_PASSWORD}",
|
|
550
|
+
};
|
|
551
|
+
for (const req of dbReqs) {
|
|
552
|
+
dbEnv[req.passwordEnvVar] = `\${${req.passwordEnvVar}}`;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
services["postgres-setup"] = {
|
|
556
|
+
image: "postgres:17-alpine",
|
|
557
|
+
depends_on: {
|
|
558
|
+
postgresql: { condition: "service_healthy" },
|
|
559
|
+
},
|
|
560
|
+
environment: dbEnv,
|
|
561
|
+
entrypoint: ["/bin/sh", "-c"],
|
|
562
|
+
command: [scriptLines.join("\n")],
|
|
563
|
+
restart: quotedStr("no"),
|
|
564
|
+
networks: ["openclaw-network"],
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
// Update addon services that need DB to depend on postgres-setup
|
|
568
|
+
for (const req of dbReqs) {
|
|
569
|
+
const svcEntry = services[req.serviceId];
|
|
570
|
+
if (svcEntry) {
|
|
571
|
+
const deps = (svcEntry.depends_on as Record<string, { condition: string }>) || {};
|
|
572
|
+
deps["postgres-setup"] = { condition: "service_completed_successfully" };
|
|
573
|
+
svcEntry.depends_on = deps;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// 11. Generate secrets and env file
|
|
579
|
+
const envLines: string[] = [
|
|
580
|
+
"# ═══════════════════════════════════════════════════════════════════════════════",
|
|
581
|
+
"# OpenClaw Addon Stack Environment",
|
|
582
|
+
`# Instance: ${input.instanceId}`,
|
|
583
|
+
`# Generated at ${new Date().toISOString()}`,
|
|
584
|
+
"# ═══════════════════════════════════════════════════════════════════════════════",
|
|
585
|
+
"",
|
|
586
|
+
];
|
|
587
|
+
|
|
588
|
+
// DB passwords first
|
|
589
|
+
if (dbReqs.length > 0) {
|
|
590
|
+
envLines.push("# ── Per-Service Database Passwords ──────────────────────────────────────");
|
|
591
|
+
for (const req of dbReqs) {
|
|
592
|
+
const secretValue = input.generateSecrets ? generateHexSecret(24) : "";
|
|
593
|
+
envValues.set(req.passwordEnvVar, secretValue);
|
|
594
|
+
if (secretValue) generatedSecretKeys.push(req.passwordEnvVar);
|
|
595
|
+
envLines.push(`# PostgreSQL password for ${req.serviceName} (db: ${req.dbName}, user: ${req.dbUser})`);
|
|
596
|
+
envLines.push(`${req.passwordEnvVar}=${secretValue}`);
|
|
597
|
+
envLines.push("");
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Per-service env vars
|
|
602
|
+
const seenKeys = new Set<string>([...CLAWEXA_MANAGED_ENV_KEYS, ...dbReqs.map((r) => r.passwordEnvVar)]);
|
|
603
|
+
const envVarGroups: AddonStackResult["envVars"] = [];
|
|
604
|
+
|
|
605
|
+
for (const svc of deployableServices) {
|
|
606
|
+
const def = svc.definition;
|
|
607
|
+
const allEnvVars = [...def.environment, ...def.openclawEnvVars];
|
|
608
|
+
if (allEnvVars.length === 0) continue;
|
|
609
|
+
|
|
610
|
+
const groupVars: AddonStackResult["envVars"][number]["vars"] = [];
|
|
611
|
+
|
|
612
|
+
envLines.push(`# ── ${def.icon} ${def.name} ──────────────────────────────────────`);
|
|
613
|
+
|
|
614
|
+
for (const envVar of allEnvVars) {
|
|
615
|
+
if (seenKeys.has(envVar.key)) continue;
|
|
616
|
+
seenKeys.add(envVar.key);
|
|
617
|
+
|
|
618
|
+
// Check if user provided this credential
|
|
619
|
+
const userValue = input.credentials[def.id]?.[envVar.key];
|
|
620
|
+
let actualValue: string;
|
|
621
|
+
|
|
622
|
+
if (userValue !== undefined) {
|
|
623
|
+
actualValue = userValue;
|
|
624
|
+
} else if (envVar.secret) {
|
|
625
|
+
// Resolve references like ${N8N_DB_PASSWORD}
|
|
626
|
+
if (envVar.defaultValue.startsWith("${") && envVar.defaultValue.endsWith("}")) {
|
|
627
|
+
const refKey = envVar.defaultValue.slice(2, -1);
|
|
628
|
+
actualValue = envValues.get(refKey) || envVar.defaultValue;
|
|
629
|
+
} else if (input.generateSecrets) {
|
|
630
|
+
actualValue = generateHexSecret(24);
|
|
631
|
+
generatedSecretKeys.push(envVar.key);
|
|
632
|
+
} else {
|
|
633
|
+
actualValue = envVar.defaultValue;
|
|
634
|
+
}
|
|
635
|
+
} else {
|
|
636
|
+
actualValue = envVar.defaultValue;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
envValues.set(envVar.key, actualValue);
|
|
640
|
+
|
|
641
|
+
envLines.push(`# ${envVar.description}`);
|
|
642
|
+
envLines.push(`${envVar.key}=${actualValue}`);
|
|
643
|
+
envLines.push("");
|
|
644
|
+
|
|
645
|
+
groupVars.push({
|
|
646
|
+
key: envVar.key,
|
|
647
|
+
description: envVar.description,
|
|
648
|
+
value: actualValue,
|
|
649
|
+
secret: envVar.secret,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (groupVars.length > 0) {
|
|
654
|
+
envVarGroups.push({
|
|
655
|
+
serviceName: def.name,
|
|
656
|
+
vars: groupVars,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Apply env quirks after all values are generated
|
|
662
|
+
for (const svc of deployableServices) {
|
|
663
|
+
applyEnvQuirks(svc.definition, envValues, input.generateSecrets);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Rebuild env lines from envValues (quirks may have modified values or introduced new keys)
|
|
667
|
+
const quirkedKeys = new Set<string>();
|
|
668
|
+
const finalEnvLines: string[] = [];
|
|
669
|
+
for (const line of envLines) {
|
|
670
|
+
const trimmed = line.trim();
|
|
671
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
672
|
+
finalEnvLines.push(line);
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
const eqIdx = trimmed.indexOf("=");
|
|
676
|
+
if (eqIdx > 0) {
|
|
677
|
+
const key = trimmed.slice(0, eqIdx);
|
|
678
|
+
quirkedKeys.add(key);
|
|
679
|
+
const fixedValue = envValues.get(key);
|
|
680
|
+
if (fixedValue !== undefined) {
|
|
681
|
+
finalEnvLines.push(`${key}=${fixedValue}`);
|
|
682
|
+
} else {
|
|
683
|
+
finalEnvLines.push(line);
|
|
684
|
+
}
|
|
685
|
+
} else {
|
|
686
|
+
finalEnvLines.push(line);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
// Append any new keys introduced by quirks (e.g., must_sync creating a new key)
|
|
690
|
+
for (const [key, value] of envValues) {
|
|
691
|
+
if (!quirkedKeys.has(key) && !seenKeys.has(key) && !CLAWEXA_MANAGED_ENV_KEYS.has(key)) {
|
|
692
|
+
finalEnvLines.push(`# Synced by env quirk`);
|
|
693
|
+
finalEnvLines.push(`${key}=${value}`);
|
|
694
|
+
finalEnvLines.push("");
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const envFile = finalEnvLines.join("\n");
|
|
698
|
+
|
|
699
|
+
// 12. Generate skill files
|
|
700
|
+
const skillFiles = generateSkillFiles(addonResolvedOutput);
|
|
701
|
+
|
|
702
|
+
// 13. Build openclaw config patch
|
|
703
|
+
const skillEntries: Record<string, { enabled: boolean }> = {};
|
|
704
|
+
let skillCount = 0;
|
|
705
|
+
for (const svc of deployableServices) {
|
|
706
|
+
for (const skill of svc.definition.skills) {
|
|
707
|
+
if (skill.autoInstall) {
|
|
708
|
+
skillEntries[skill.skillId] = { enabled: true };
|
|
709
|
+
skillCount++;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// 14. Build proxy routes
|
|
715
|
+
const proxyRoutes = buildProxyRoutes(deployableServices);
|
|
716
|
+
|
|
717
|
+
// 14b. Build additional files (e.g. sandbox.toml for opensandbox)
|
|
718
|
+
const additionalFiles: Record<string, string> = {};
|
|
719
|
+
if (deployableServices.some((s) => s.definition.id === "opensandbox")) {
|
|
720
|
+
additionalFiles["sandbox.toml"] = [
|
|
721
|
+
"[server]",
|
|
722
|
+
'host = "0.0.0.0"',
|
|
723
|
+
"port = 8080",
|
|
724
|
+
'log_level = "INFO"',
|
|
725
|
+
'api_key = "${OPEN_SANDBOX_API_KEY}"',
|
|
726
|
+
"",
|
|
727
|
+
"[runtime]",
|
|
728
|
+
'type = "docker"',
|
|
729
|
+
'execd_image = "opensandbox/execd:v1.0.6"',
|
|
730
|
+
"",
|
|
731
|
+
"[docker]",
|
|
732
|
+
"network_mode = \"bridge\"",
|
|
733
|
+
'drop_capabilities = ["NET_ADMIN", "SYS_ADMIN", "SYS_PTRACE", "MKNOD", "NET_RAW", "SYS_RAWIO"]',
|
|
734
|
+
"no_new_privileges = true",
|
|
735
|
+
"pids_limit = 512",
|
|
736
|
+
"",
|
|
737
|
+
"[secure_runtime]",
|
|
738
|
+
'type = "gvisor"',
|
|
739
|
+
"",
|
|
740
|
+
].join("\n");
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// 14c. Build pre-pull images list
|
|
744
|
+
const prePullImages: Array<{ image: string; priority: 1 | 2 | 3 }> = [];
|
|
745
|
+
if (deployableServices.some((s) => s.definition.id === "opensandbox")) {
|
|
746
|
+
prePullImages.push(
|
|
747
|
+
// Priority 1: always pulled (core + Homespace)
|
|
748
|
+
{ image: "opensandbox/server:v1.0.6", priority: 1 },
|
|
749
|
+
{ image: "opensandbox/execd:v1.0.6", priority: 1 },
|
|
750
|
+
{ image: "opensandbox/desktop:latest", priority: 1 },
|
|
751
|
+
{ image: "opensandbox/chrome:latest", priority: 1 },
|
|
752
|
+
// Priority 2: recommended (common languages)
|
|
753
|
+
{ image: "opensandbox/code-interpreter:python", priority: 2 },
|
|
754
|
+
{ image: "opensandbox/code-interpreter:node", priority: 2 },
|
|
755
|
+
// Priority 3: optional (full multi-lang and IDE)
|
|
756
|
+
{ image: "opensandbox/code-interpreter:latest", priority: 3 },
|
|
757
|
+
{ image: "opensandbox/vscode:latest", priority: 3 },
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// 15. Compose single YAML
|
|
762
|
+
const volumeMap: Record<string, null> = {};
|
|
763
|
+
for (const v of allVolumes) {
|
|
764
|
+
volumeMap[v] = null;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const composeDoc: Record<string, unknown> = {
|
|
768
|
+
services,
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
if (Object.keys(volumeMap).length > 0) {
|
|
772
|
+
composeDoc.volumes = volumeMap;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
composeDoc.networks = {
|
|
776
|
+
"openclaw-network": {
|
|
777
|
+
external: true,
|
|
778
|
+
},
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
const composeOverride = stringify(composeDoc, YAML_OPTIONS);
|
|
782
|
+
|
|
783
|
+
// 16. Return result
|
|
784
|
+
return {
|
|
785
|
+
composeOverride,
|
|
786
|
+
envFile,
|
|
787
|
+
envVars: envVarGroups,
|
|
788
|
+
skillFiles,
|
|
789
|
+
openclawConfigPatch: {
|
|
790
|
+
skills: { entries: skillEntries },
|
|
791
|
+
},
|
|
792
|
+
proxyRoutes,
|
|
793
|
+
additionalFiles,
|
|
794
|
+
metadata: {
|
|
795
|
+
serviceCount: Object.keys(services).length,
|
|
796
|
+
skillCount,
|
|
797
|
+
estimatedMemoryMB: addonResolvedOutput.estimatedMemoryMB,
|
|
798
|
+
resolvedServices: deployableServices.map((s) => s.definition.id),
|
|
799
|
+
skippedServices,
|
|
800
|
+
generatedSecretKeys,
|
|
801
|
+
portAssignments: portConflicts.assignments,
|
|
802
|
+
prePullImages,
|
|
803
|
+
},
|
|
804
|
+
warnings,
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// ── Main: updateAddonStack ───────────────────────────────────────────────────
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Incrementally updates an existing addon stack by adding or removing services.
|
|
812
|
+
* Preserves existing env values (never overwrites user-customized values).
|
|
813
|
+
*
|
|
814
|
+
* This function never throws.
|
|
815
|
+
*/
|
|
816
|
+
export function updateAddonStack(rawInput: AddonStackUpdateInput): AddonStackUpdateResult {
|
|
817
|
+
const warnings: string[] = [];
|
|
818
|
+
|
|
819
|
+
// 1. Parse & validate
|
|
820
|
+
let input: AddonStackUpdateInput;
|
|
821
|
+
try {
|
|
822
|
+
input = AddonStackUpdateInputSchema.parse(rawInput);
|
|
823
|
+
} catch (err) {
|
|
824
|
+
return emptyUpdateResult(`Invalid input: ${err instanceof Error ? err.message : String(err)}`);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// 2. Parse existing compose YAML to extract current service list
|
|
828
|
+
let currentServiceIds: string[] = [];
|
|
829
|
+
try {
|
|
830
|
+
const existingCompose = parseYaml(input.currentCompose);
|
|
831
|
+
if (existingCompose?.services && typeof existingCompose.services === "object") {
|
|
832
|
+
currentServiceIds = Object.keys(existingCompose.services).filter(
|
|
833
|
+
(id) => id !== "postgres-setup",
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
} catch (err) {
|
|
837
|
+
warnings.push(
|
|
838
|
+
`Failed to parse existing compose YAML: ${err instanceof Error ? err.message : String(err)}`,
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// 3. Parse existing env into a map
|
|
843
|
+
const existingEnvMap = new Map<string, string>();
|
|
844
|
+
for (const line of input.currentEnv.split("\n")) {
|
|
845
|
+
const trimmed = line.trim();
|
|
846
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
847
|
+
const eqIdx = trimmed.indexOf("=");
|
|
848
|
+
if (eqIdx > 0) {
|
|
849
|
+
existingEnvMap.set(trimmed.slice(0, eqIdx), trimmed.slice(eqIdx + 1));
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// 4. Compute desired service list
|
|
854
|
+
const addSet = new Set(input.addServices);
|
|
855
|
+
const removeSet = new Set(input.removeServices);
|
|
856
|
+
const desiredServiceIds = [
|
|
857
|
+
...currentServiceIds.filter((id) => !removeSet.has(id)),
|
|
858
|
+
...input.addServices.filter((id) => !currentServiceIds.includes(id)),
|
|
859
|
+
];
|
|
860
|
+
|
|
861
|
+
// 5. Generate the full target state
|
|
862
|
+
const targetResult = generateAddonStack({
|
|
863
|
+
instanceId: input.instanceId,
|
|
864
|
+
services: desiredServiceIds,
|
|
865
|
+
skillPacks: [],
|
|
866
|
+
platform: input.platform,
|
|
867
|
+
openclawVersion: input.openclawVersion,
|
|
868
|
+
reservedPorts: input.reservedPorts,
|
|
869
|
+
generateSecrets: input.generateSecrets,
|
|
870
|
+
credentials: input.credentials,
|
|
871
|
+
portOverrides: input.portOverrides,
|
|
872
|
+
aiProviders: input.aiProviders,
|
|
873
|
+
prebuiltImages: input.prebuiltImages,
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
// 6. Merge env: preserve existing values, only add new ones
|
|
877
|
+
const mergedEnvLines: string[] = [];
|
|
878
|
+
for (const line of targetResult.envFile.split("\n")) {
|
|
879
|
+
const trimmed = line.trim();
|
|
880
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
881
|
+
mergedEnvLines.push(line);
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
const eqIdx = trimmed.indexOf("=");
|
|
885
|
+
if (eqIdx > 0) {
|
|
886
|
+
const key = trimmed.slice(0, eqIdx);
|
|
887
|
+
if (existingEnvMap.has(key)) {
|
|
888
|
+
// Preserve existing value
|
|
889
|
+
mergedEnvLines.push(`${key}=${existingEnvMap.get(key)}`);
|
|
890
|
+
} else {
|
|
891
|
+
mergedEnvLines.push(line);
|
|
892
|
+
}
|
|
893
|
+
} else {
|
|
894
|
+
mergedEnvLines.push(line);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// 7. Compute diffs
|
|
899
|
+
const currentSet = new Set(currentServiceIds);
|
|
900
|
+
const targetSet = new Set(targetResult.metadata.resolvedServices);
|
|
901
|
+
const added = [...targetSet].filter((id) => !currentSet.has(id));
|
|
902
|
+
const removed = [...currentSet].filter((id) => !targetSet.has(id));
|
|
903
|
+
const unchanged = [...currentSet].filter((id) => targetSet.has(id));
|
|
904
|
+
|
|
905
|
+
// 8. Compute skill diffs
|
|
906
|
+
const newSkillFiles: Record<string, string> = {};
|
|
907
|
+
const removedSkillSlugs: string[] = [];
|
|
908
|
+
|
|
909
|
+
// New skills from added services
|
|
910
|
+
for (const id of added) {
|
|
911
|
+
const def = getServiceById(id);
|
|
912
|
+
if (!def) continue;
|
|
913
|
+
for (const skill of def.skills) {
|
|
914
|
+
const skillPath = Object.keys(targetResult.skillFiles).find(
|
|
915
|
+
(path) => path.includes(skill.skillId),
|
|
916
|
+
);
|
|
917
|
+
if (skillPath) {
|
|
918
|
+
newSkillFiles[skillPath] = targetResult.skillFiles[skillPath];
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Removed skills from removed services
|
|
924
|
+
for (const id of removed) {
|
|
925
|
+
const def = getServiceById(id);
|
|
926
|
+
if (!def) continue;
|
|
927
|
+
for (const skill of def.skills) {
|
|
928
|
+
removedSkillSlugs.push(skill.skillId);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// 9. Proxy route diffs
|
|
933
|
+
const addProxyRoutes = targetResult.proxyRoutes.filter((r) => added.includes(r.serviceId));
|
|
934
|
+
const removeProxyRoutes = removed;
|
|
935
|
+
|
|
936
|
+
// 10. Images to pull for new services
|
|
937
|
+
const imagesToPull: string[] = [];
|
|
938
|
+
for (const id of added) {
|
|
939
|
+
const def = getServiceById(id);
|
|
940
|
+
if (def?.image && def?.imageTag) {
|
|
941
|
+
imagesToPull.push(`${def.image}:${def.imageTag}`);
|
|
942
|
+
} else if (def?.prebuiltImage) {
|
|
943
|
+
imagesToPull.push(def.prebuiltImage);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// 11. Estimate memory delta
|
|
948
|
+
let memoryDelta = 0;
|
|
949
|
+
for (const id of added) {
|
|
950
|
+
const def = getServiceById(id);
|
|
951
|
+
memoryDelta += def?.minMemoryMB ?? 128;
|
|
952
|
+
}
|
|
953
|
+
for (const id of removed) {
|
|
954
|
+
const def = getServiceById(id);
|
|
955
|
+
memoryDelta -= def?.minMemoryMB ?? 128;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Add existing skills to the config patch
|
|
959
|
+
const addSkillEntries: Record<string, { enabled: boolean }> = {};
|
|
960
|
+
for (const id of added) {
|
|
961
|
+
const def = getServiceById(id);
|
|
962
|
+
if (!def) continue;
|
|
963
|
+
for (const skill of def.skills) {
|
|
964
|
+
if (skill.autoInstall) {
|
|
965
|
+
addSkillEntries[skill.skillId] = { enabled: true };
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
return {
|
|
971
|
+
composeOverride: targetResult.composeOverride,
|
|
972
|
+
envFile: mergedEnvLines.join("\n"),
|
|
973
|
+
newSkillFiles,
|
|
974
|
+
removedSkillSlugs,
|
|
975
|
+
openclawConfigPatch: {
|
|
976
|
+
skills: {
|
|
977
|
+
add: addSkillEntries,
|
|
978
|
+
remove: removedSkillSlugs,
|
|
979
|
+
},
|
|
980
|
+
},
|
|
981
|
+
addProxyRoutes,
|
|
982
|
+
removeProxyRoutes,
|
|
983
|
+
imagesToPull,
|
|
984
|
+
restartRequired: added, // New services need starting, not restarting
|
|
985
|
+
metadata: {
|
|
986
|
+
added,
|
|
987
|
+
removed,
|
|
988
|
+
unchanged,
|
|
989
|
+
estimatedMemoryDelta: memoryDelta,
|
|
990
|
+
},
|
|
991
|
+
warnings: [...warnings, ...targetResult.warnings],
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// ── Empty Result Helpers ─────────────────────────────────────────────────────
|
|
996
|
+
|
|
997
|
+
function emptyResultBase(): AddonStackResult {
|
|
998
|
+
return {
|
|
999
|
+
composeOverride: "services: {}\n",
|
|
1000
|
+
envFile: "",
|
|
1001
|
+
envVars: [],
|
|
1002
|
+
skillFiles: {},
|
|
1003
|
+
openclawConfigPatch: { skills: { entries: {} } },
|
|
1004
|
+
proxyRoutes: [],
|
|
1005
|
+
additionalFiles: {},
|
|
1006
|
+
metadata: {
|
|
1007
|
+
serviceCount: 0,
|
|
1008
|
+
skillCount: 0,
|
|
1009
|
+
estimatedMemoryMB: 0,
|
|
1010
|
+
resolvedServices: [],
|
|
1011
|
+
skippedServices: [],
|
|
1012
|
+
generatedSecretKeys: [],
|
|
1013
|
+
portAssignments: {},
|
|
1014
|
+
prePullImages: [],
|
|
1015
|
+
},
|
|
1016
|
+
warnings: [],
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function emptyResult(warning: string): AddonStackResult {
|
|
1021
|
+
return {
|
|
1022
|
+
...emptyResultBase(),
|
|
1023
|
+
warnings: [warning],
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function emptyUpdateResult(warning: string): AddonStackUpdateResult {
|
|
1028
|
+
return {
|
|
1029
|
+
composeOverride: "services: {}\n",
|
|
1030
|
+
envFile: "",
|
|
1031
|
+
newSkillFiles: {},
|
|
1032
|
+
removedSkillSlugs: [],
|
|
1033
|
+
openclawConfigPatch: { skills: { add: {}, remove: [] } },
|
|
1034
|
+
addProxyRoutes: [],
|
|
1035
|
+
removeProxyRoutes: [],
|
|
1036
|
+
imagesToPull: [],
|
|
1037
|
+
restartRequired: [],
|
|
1038
|
+
metadata: {
|
|
1039
|
+
added: [],
|
|
1040
|
+
removed: [],
|
|
1041
|
+
unchanged: [],
|
|
1042
|
+
estimatedMemoryDelta: 0,
|
|
1043
|
+
},
|
|
1044
|
+
warnings: [warning],
|
|
1045
|
+
};
|
|
1046
|
+
}
|