@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.
Files changed (226) hide show
  1. package/dist/addon-stack.cjs +725 -0
  2. package/dist/addon-stack.cjs.map +1 -0
  3. package/dist/addon-stack.d.cts +23 -0
  4. package/dist/addon-stack.d.cts.map +1 -0
  5. package/dist/addon-stack.d.mts +23 -0
  6. package/dist/addon-stack.d.mts.map +1 -0
  7. package/dist/addon-stack.mjs +723 -0
  8. package/dist/addon-stack.mjs.map +1 -0
  9. package/dist/addon-stack.test.cjs +461 -0
  10. package/dist/addon-stack.test.cjs.map +1 -0
  11. package/dist/addon-stack.test.d.cts +1 -0
  12. package/dist/addon-stack.test.d.mts +1 -0
  13. package/dist/addon-stack.test.mjs +461 -0
  14. package/dist/addon-stack.test.mjs.map +1 -0
  15. package/dist/bare-metal-partition.test.cjs +20 -20
  16. package/dist/bare-metal-partition.test.cjs.map +1 -1
  17. package/dist/bare-metal-partition.test.mjs +2 -2
  18. package/dist/compose-validation.test.cjs +1 -1
  19. package/dist/composer.cjs +5 -1
  20. package/dist/composer.cjs.map +1 -1
  21. package/dist/composer.d.cts +24 -1
  22. package/dist/composer.d.cts.map +1 -1
  23. package/dist/composer.d.mts +24 -1
  24. package/dist/composer.d.mts.map +1 -1
  25. package/dist/composer.mjs +1 -1
  26. package/dist/composer.mjs.map +1 -1
  27. package/dist/composer.snapshot.test.cjs +20 -20
  28. package/dist/composer.snapshot.test.cjs.map +1 -1
  29. package/dist/composer.snapshot.test.mjs +2 -2
  30. package/dist/composer.test.cjs +54 -54
  31. package/dist/composer.test.cjs.map +1 -1
  32. package/dist/composer.test.mjs +2 -2
  33. package/dist/deployers/strip-host-ports.cjs +1 -1
  34. package/dist/deployers/strip-host-ports.test.cjs +26 -26
  35. package/dist/deployers/strip-host-ports.test.cjs.map +1 -1
  36. package/dist/deployers/strip-host-ports.test.mjs +1 -1
  37. package/dist/generate.cjs +3 -3
  38. package/dist/generate.mjs +3 -3
  39. package/dist/generate.test.cjs +56 -56
  40. package/dist/generate.test.cjs.map +1 -1
  41. package/dist/generate.test.mjs +1 -1
  42. package/dist/generators/bare-metal-install.test.cjs +18 -18
  43. package/dist/generators/bare-metal-install.test.cjs.map +1 -1
  44. package/dist/generators/bare-metal-install.test.mjs +1 -1
  45. package/dist/generators/caddy.test.cjs +13 -13
  46. package/dist/generators/caddy.test.cjs.map +1 -1
  47. package/dist/generators/caddy.test.mjs +1 -1
  48. package/dist/generators/clone-repos.test.cjs +27 -27
  49. package/dist/generators/clone-repos.test.cjs.map +1 -1
  50. package/dist/generators/clone-repos.test.mjs +1 -1
  51. package/dist/generators/env.cjs +1 -1
  52. package/dist/generators/env.test.cjs +17 -17
  53. package/dist/generators/env.test.cjs.map +1 -1
  54. package/dist/generators/env.test.mjs +1 -1
  55. package/dist/generators/health-check.test.cjs +39 -39
  56. package/dist/generators/health-check.test.cjs.map +1 -1
  57. package/dist/generators/health-check.test.mjs +1 -1
  58. package/dist/generators/postgres-init.cjs +5 -0
  59. package/dist/generators/postgres-init.cjs.map +1 -1
  60. package/dist/generators/postgres-init.d.cts.map +1 -1
  61. package/dist/generators/postgres-init.d.mts.map +1 -1
  62. package/dist/generators/postgres-init.mjs +5 -0
  63. package/dist/generators/postgres-init.mjs.map +1 -1
  64. package/dist/generators/scripts.test.cjs +39 -39
  65. package/dist/generators/scripts.test.cjs.map +1 -1
  66. package/dist/generators/scripts.test.mjs +1 -1
  67. package/dist/generators/skills.cjs +1 -1
  68. package/dist/generators/skills.d.cts.map +1 -1
  69. package/dist/generators/skills.d.mts.map +1 -1
  70. package/dist/generators/skills.mjs +141 -0
  71. package/dist/generators/skills.mjs.map +1 -1
  72. package/dist/generators/traefik.test.cjs +32 -32
  73. package/dist/generators/traefik.test.cjs.map +1 -1
  74. package/dist/generators/traefik.test.mjs +1 -1
  75. package/dist/index.cjs +21 -5
  76. package/dist/index.d.cts +5 -4
  77. package/dist/index.d.mts +5 -4
  78. package/dist/index.mjs +7 -6
  79. package/dist/migrations.test.cjs +16 -16
  80. package/dist/migrations.test.cjs.map +1 -1
  81. package/dist/migrations.test.mjs +1 -1
  82. package/dist/presets/presets.test.cjs +1 -1
  83. package/dist/presets/registry.test.cjs +14 -14
  84. package/dist/presets/registry.test.cjs.map +1 -1
  85. package/dist/presets/registry.test.mjs +1 -1
  86. package/dist/resolver.test.cjs +95 -95
  87. package/dist/resolver.test.cjs.map +1 -1
  88. package/dist/resolver.test.mjs +1 -1
  89. package/dist/{schema-eX44HhRp.d.mts → schema-BQnZrcw8.d.cts} +300 -2
  90. package/dist/schema-BQnZrcw8.d.cts.map +1 -0
  91. package/dist/{schema-tn5RK8CM.d.cts → schema-SBpL0bdI.d.mts} +300 -2
  92. package/dist/schema-SBpL0bdI.d.mts.map +1 -0
  93. package/dist/schema.cjs +148 -2
  94. package/dist/schema.cjs.map +1 -1
  95. package/dist/schema.d.cts +2 -2
  96. package/dist/schema.d.mts +2 -2
  97. package/dist/schema.mjs +139 -2
  98. package/dist/schema.mjs.map +1 -1
  99. package/dist/schema.test.cjs +86 -86
  100. package/dist/schema.test.cjs.map +1 -1
  101. package/dist/schema.test.mjs +1 -1
  102. package/dist/services/definitions/browserless.cjs +4 -1
  103. package/dist/services/definitions/browserless.cjs.map +1 -1
  104. package/dist/services/definitions/browserless.mjs +4 -1
  105. package/dist/services/definitions/browserless.mjs.map +1 -1
  106. package/dist/services/definitions/burnlink.cjs +142 -0
  107. package/dist/services/definitions/burnlink.cjs.map +1 -0
  108. package/dist/services/definitions/burnlink.d.cts +7 -0
  109. package/dist/services/definitions/burnlink.d.cts.map +1 -0
  110. package/dist/services/definitions/burnlink.d.mts +7 -0
  111. package/dist/services/definitions/burnlink.d.mts.map +1 -0
  112. package/dist/services/definitions/burnlink.mjs +141 -0
  113. package/dist/services/definitions/burnlink.mjs.map +1 -0
  114. package/dist/services/definitions/convex.cjs +43 -1
  115. package/dist/services/definitions/convex.cjs.map +1 -1
  116. package/dist/services/definitions/convex.mjs +43 -1
  117. package/dist/services/definitions/convex.mjs.map +1 -1
  118. package/dist/services/definitions/grafana.cjs +11 -1
  119. package/dist/services/definitions/grafana.cjs.map +1 -1
  120. package/dist/services/definitions/grafana.mjs +11 -1
  121. package/dist/services/definitions/grafana.mjs.map +1 -1
  122. package/dist/services/definitions/hindsight.cjs +130 -0
  123. package/dist/services/definitions/hindsight.cjs.map +1 -0
  124. package/dist/services/definitions/hindsight.d.cts +7 -0
  125. package/dist/services/definitions/hindsight.d.cts.map +1 -0
  126. package/dist/services/definitions/hindsight.d.mts +7 -0
  127. package/dist/services/definitions/hindsight.d.mts.map +1 -0
  128. package/dist/services/definitions/hindsight.mjs +129 -0
  129. package/dist/services/definitions/hindsight.mjs.map +1 -0
  130. package/dist/services/definitions/index.cjs +9 -0
  131. package/dist/services/definitions/index.cjs.map +1 -1
  132. package/dist/services/definitions/index.d.cts +4 -1
  133. package/dist/services/definitions/index.d.cts.map +1 -1
  134. package/dist/services/definitions/index.d.mts +4 -1
  135. package/dist/services/definitions/index.d.mts.map +1 -1
  136. package/dist/services/definitions/index.mjs +7 -1
  137. package/dist/services/definitions/index.mjs.map +1 -1
  138. package/dist/services/definitions/meilisearch.cjs +11 -1
  139. package/dist/services/definitions/meilisearch.cjs.map +1 -1
  140. package/dist/services/definitions/meilisearch.mjs +11 -1
  141. package/dist/services/definitions/meilisearch.mjs.map +1 -1
  142. package/dist/services/definitions/minio.cjs +3 -1
  143. package/dist/services/definitions/minio.cjs.map +1 -1
  144. package/dist/services/definitions/minio.mjs +3 -1
  145. package/dist/services/definitions/minio.mjs.map +1 -1
  146. package/dist/services/definitions/n8n.cjs +11 -1
  147. package/dist/services/definitions/n8n.cjs.map +1 -1
  148. package/dist/services/definitions/n8n.mjs +11 -1
  149. package/dist/services/definitions/n8n.mjs.map +1 -1
  150. package/dist/services/definitions/ollama.cjs +3 -1
  151. package/dist/services/definitions/ollama.cjs.map +1 -1
  152. package/dist/services/definitions/ollama.mjs +3 -1
  153. package/dist/services/definitions/ollama.mjs.map +1 -1
  154. package/dist/services/definitions/opensandbox.cjs +149 -0
  155. package/dist/services/definitions/opensandbox.cjs.map +1 -0
  156. package/dist/services/definitions/opensandbox.d.cts +7 -0
  157. package/dist/services/definitions/opensandbox.d.cts.map +1 -0
  158. package/dist/services/definitions/opensandbox.d.mts +7 -0
  159. package/dist/services/definitions/opensandbox.d.mts.map +1 -0
  160. package/dist/services/definitions/opensandbox.mjs +148 -0
  161. package/dist/services/definitions/opensandbox.mjs.map +1 -0
  162. package/dist/services/definitions/qdrant.cjs +3 -1
  163. package/dist/services/definitions/qdrant.cjs.map +1 -1
  164. package/dist/services/definitions/qdrant.mjs +3 -1
  165. package/dist/services/definitions/qdrant.mjs.map +1 -1
  166. package/dist/services/definitions/searxng.cjs +8 -1
  167. package/dist/services/definitions/searxng.cjs.map +1 -1
  168. package/dist/services/definitions/searxng.mjs +8 -1
  169. package/dist/services/definitions/searxng.mjs.map +1 -1
  170. package/dist/services/definitions/uptime-kuma.cjs +8 -1
  171. package/dist/services/definitions/uptime-kuma.cjs.map +1 -1
  172. package/dist/services/definitions/uptime-kuma.mjs +8 -1
  173. package/dist/services/definitions/uptime-kuma.mjs.map +1 -1
  174. package/dist/services/registry.test.cjs +36 -36
  175. package/dist/services/registry.test.cjs.map +1 -1
  176. package/dist/services/registry.test.mjs +1 -1
  177. package/dist/{skills-BlzpHmpH.cjs → skills-BSF7iNa4.cjs} +142 -1
  178. package/dist/{skills-BlzpHmpH.cjs.map → skills-BSF7iNa4.cjs.map} +1 -1
  179. package/dist/{vi.2VT5v0um-C_jmO7m2.mjs → test.CTcmp4Su-ClCHJ3FA.mjs} +6793 -6403
  180. package/dist/test.CTcmp4Su-ClCHJ3FA.mjs.map +1 -0
  181. package/dist/{vi.2VT5v0um-iVBt6Fyq.cjs → test.CTcmp4Su-DlzTarwH.cjs} +6793 -6403
  182. package/dist/test.CTcmp4Su-DlzTarwH.cjs.map +1 -0
  183. package/dist/track-analytics.test.cjs +28 -28
  184. package/dist/track-analytics.test.cjs.map +1 -1
  185. package/dist/track-analytics.test.mjs +1 -1
  186. package/dist/types.cjs.map +1 -1
  187. package/dist/types.d.cts +10 -2
  188. package/dist/types.d.cts.map +1 -1
  189. package/dist/types.d.mts +10 -2
  190. package/dist/types.d.mts.map +1 -1
  191. package/dist/types.mjs.map +1 -1
  192. package/dist/validator.cjs +1 -1
  193. package/dist/validator.test.cjs +15 -15
  194. package/dist/validator.test.cjs.map +1 -1
  195. package/dist/validator.test.mjs +2 -2
  196. package/dist/version-manager.test.cjs +37 -37
  197. package/dist/version-manager.test.cjs.map +1 -1
  198. package/dist/version-manager.test.mjs +1 -1
  199. package/package.json +4 -4
  200. package/src/__snapshots__/composer.snapshot.test.ts.snap +5 -0
  201. package/src/addon-stack.test.ts +648 -0
  202. package/src/addon-stack.ts +1046 -0
  203. package/src/composer.ts +4 -4
  204. package/src/generators/postgres-init.ts +2 -0
  205. package/src/generators/skills.ts +142 -0
  206. package/src/index.ts +20 -2
  207. package/src/schema.ts +190 -0
  208. package/src/services/definitions/browserless.ts +3 -0
  209. package/src/services/definitions/burnlink.ts +142 -0
  210. package/src/services/definitions/convex.ts +31 -0
  211. package/src/services/definitions/grafana.ts +9 -0
  212. package/src/services/definitions/hindsight.ts +131 -0
  213. package/src/services/definitions/index.ts +10 -0
  214. package/src/services/definitions/meilisearch.ts +9 -0
  215. package/src/services/definitions/minio.ts +2 -0
  216. package/src/services/definitions/n8n.ts +9 -0
  217. package/src/services/definitions/ollama.ts +2 -0
  218. package/src/services/definitions/opensandbox.ts +156 -0
  219. package/src/services/definitions/qdrant.ts +2 -0
  220. package/src/services/definitions/searxng.ts +3 -0
  221. package/src/services/definitions/uptime-kuma.ts +3 -0
  222. package/src/types.ts +18 -0
  223. package/dist/schema-eX44HhRp.d.mts.map +0 -1
  224. package/dist/schema-tn5RK8CM.d.cts.map +0 -1
  225. package/dist/vi.2VT5v0um-C_jmO7m2.mjs.map +0 -1
  226. 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
+ }