@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,648 @@
1
+ import { parse } from "yaml";
2
+ import { describe, expect, it } from "vitest";
3
+ import { generateAddonStack, updateAddonStack } from "./addon-stack.js";
4
+ import { AddonStackInputSchema } from "./schema.js";
5
+
6
+ describe("generateAddonStack", () => {
7
+ it("generates valid compose YAML with a single service (qdrant)", () => {
8
+ const result = generateAddonStack({
9
+ instanceId: "test-instance",
10
+ services: ["qdrant"],
11
+ });
12
+
13
+ // Resolver may generate warnings about recommended services; that's fine
14
+ expect(result.metadata.serviceCount).toBeGreaterThan(0);
15
+ expect(result.metadata.resolvedServices).toContain("qdrant");
16
+
17
+ // Parse YAML to verify it's valid
18
+ const composed = parse(result.composeOverride);
19
+ expect(composed.services).toHaveProperty("qdrant");
20
+
21
+ // Should NOT contain infrastructure services
22
+ expect(composed.services).not.toHaveProperty("openclaw-gateway");
23
+ expect(composed.services).not.toHaveProperty("openclaw-cli");
24
+ expect(composed.services).not.toHaveProperty("redis");
25
+ expect(composed.services).not.toHaveProperty("postgresql");
26
+ expect(composed.services).not.toHaveProperty("open-webui");
27
+ expect(composed.services).not.toHaveProperty("caddy");
28
+
29
+ // Should have openclaw-network as external
30
+ expect(composed.networks).toHaveProperty("openclaw-network");
31
+ expect(composed.networks["openclaw-network"].external).toBe(true);
32
+ });
33
+
34
+ it("does not include profiles on any service", () => {
35
+ const result = generateAddonStack({
36
+ instanceId: "test-instance",
37
+ services: ["qdrant", "meilisearch"],
38
+ });
39
+
40
+ const composed = parse(result.composeOverride);
41
+ for (const [, svc] of Object.entries(composed.services)) {
42
+ expect(svc).not.toHaveProperty("profiles");
43
+ }
44
+ });
45
+
46
+ it("does not apply cap_drop or security_opt by default", () => {
47
+ const result = generateAddonStack({
48
+ instanceId: "test-instance",
49
+ services: ["qdrant"],
50
+ });
51
+
52
+ const composed = parse(result.composeOverride);
53
+ const qdrant = composed.services.qdrant;
54
+ expect(qdrant).not.toHaveProperty("cap_drop");
55
+ expect(qdrant).not.toHaveProperty("security_opt");
56
+ });
57
+
58
+ it("includes postgres-setup when a DB-dependent service is requested (n8n)", () => {
59
+ const result = generateAddonStack({
60
+ instanceId: "test-instance",
61
+ services: ["n8n"],
62
+ });
63
+
64
+ const composed = parse(result.composeOverride);
65
+ expect(composed.services).toHaveProperty("n8n");
66
+ expect(composed.services).toHaveProperty("postgres-setup");
67
+
68
+ // postgres-setup should depend on existing postgresql
69
+ expect(composed.services["postgres-setup"].depends_on).toHaveProperty("postgresql");
70
+
71
+ // n8n should depend on postgres-setup
72
+ expect(composed.services.n8n.depends_on).toHaveProperty("postgres-setup");
73
+ });
74
+
75
+ it("generates DB passwords in env file for DB-dependent services", () => {
76
+ const result = generateAddonStack({
77
+ instanceId: "test-instance",
78
+ services: ["n8n"],
79
+ generateSecrets: true,
80
+ });
81
+
82
+ expect(result.envFile).toContain("N8N_DB_PASSWORD=");
83
+ // Password should be non-empty (48 hex chars = 24 bytes)
84
+ const match = result.envFile.match(/N8N_DB_PASSWORD=([a-f0-9]+)/);
85
+ expect(match).not.toBeNull();
86
+ expect(match![1].length).toBe(48);
87
+ });
88
+
89
+ it("gracefully handles unknown service IDs without throwing", () => {
90
+ const result = generateAddonStack({
91
+ instanceId: "test-instance",
92
+ services: ["nonexistent-service", "qdrant"],
93
+ });
94
+
95
+ // Should NOT throw
96
+ expect(result.metadata.skippedServices).toEqual(
97
+ expect.arrayContaining([
98
+ expect.objectContaining({
99
+ serviceId: "nonexistent-service",
100
+ reason: "unknown_service",
101
+ }),
102
+ ]),
103
+ );
104
+
105
+ // Valid service should still be included
106
+ expect(result.metadata.resolvedServices).toContain("qdrant");
107
+ });
108
+
109
+ it("excludes infrastructure services from the request", () => {
110
+ const result = generateAddonStack({
111
+ instanceId: "test-instance",
112
+ services: ["redis", "postgresql", "qdrant"],
113
+ });
114
+
115
+ expect(result.warnings).toEqual(
116
+ expect.arrayContaining([
117
+ expect.stringContaining("redis"),
118
+ expect.stringContaining("postgresql"),
119
+ ]),
120
+ );
121
+ expect(result.metadata.resolvedServices).not.toContain("redis");
122
+ expect(result.metadata.resolvedServices).not.toContain("postgresql");
123
+ expect(result.metadata.resolvedServices).toContain("qdrant");
124
+ });
125
+
126
+ it("generates proxy routes from proxyPath", () => {
127
+ const result = generateAddonStack({
128
+ instanceId: "test-instance",
129
+ services: ["n8n"],
130
+ });
131
+
132
+ const n8nRoute = result.proxyRoutes.find((r) => r.serviceId === "n8n");
133
+ expect(n8nRoute).toBeDefined();
134
+ expect(n8nRoute!.path).toBe("/n8n");
135
+ expect(n8nRoute!.port).toBe(5678);
136
+ });
137
+
138
+ it("generates skill files and openclaw config patch", () => {
139
+ const result = generateAddonStack({
140
+ instanceId: "test-instance",
141
+ services: ["n8n"],
142
+ });
143
+
144
+ // n8n has skill binding: n8n-trigger
145
+ expect(result.openclawConfigPatch.skills.entries).toHaveProperty("n8n-trigger");
146
+ expect(result.openclawConfigPatch.skills.entries["n8n-trigger"].enabled).toBe(true);
147
+ });
148
+
149
+ it("resolves port conflicts with reserved ports", () => {
150
+ const result = generateAddonStack({
151
+ instanceId: "test-instance",
152
+ services: ["n8n"],
153
+ reservedPorts: [5678], // n8n's default port
154
+ });
155
+
156
+ // Port should be reassigned
157
+ const portAssignments = result.metadata.portAssignments;
158
+ const n8nAssignment = Object.entries(portAssignments).find(([key]) =>
159
+ key.startsWith("n8n:"),
160
+ );
161
+ expect(n8nAssignment).toBeDefined();
162
+ expect(n8nAssignment![1]).not.toBe(5678);
163
+ });
164
+
165
+ it("sanitizes project name from instanceId", () => {
166
+ const result = generateAddonStack({
167
+ instanceId: "My_Instance_123",
168
+ services: ["qdrant"],
169
+ });
170
+
171
+ // Should succeed without error
172
+ expect(result.metadata.serviceCount).toBeGreaterThan(0);
173
+ });
174
+
175
+ it("returns env vars grouped by service", () => {
176
+ const result = generateAddonStack({
177
+ instanceId: "test-instance",
178
+ services: ["qdrant"],
179
+ });
180
+
181
+ expect(result.envVars.length).toBeGreaterThanOrEqual(0);
182
+ });
183
+
184
+ it("returns empty result for no services", () => {
185
+ const result = generateAddonStack({
186
+ instanceId: "test-instance",
187
+ services: [],
188
+ });
189
+
190
+ expect(result.composeOverride).toContain("services: {}");
191
+ expect(result.warnings.length).toBeGreaterThan(0);
192
+ });
193
+
194
+ it("returns empty result when all services are infrastructure", () => {
195
+ const result = generateAddonStack({
196
+ instanceId: "test-instance",
197
+ services: ["redis", "postgresql", "caddy"],
198
+ });
199
+
200
+ expect(result.metadata.serviceCount).toBe(0);
201
+ expect(result.warnings.length).toBeGreaterThan(0);
202
+ });
203
+
204
+ it("applies env quirks to envFile output (meilisearch MEILI_MASTER_KEY min_length)", () => {
205
+ const result = generateAddonStack({
206
+ instanceId: "test-instance",
207
+ services: ["meilisearch"],
208
+ generateSecrets: true,
209
+ });
210
+
211
+ // MEILI_MASTER_KEY should be a base64url string of at least 32 chars (24 bytes)
212
+ const match = result.envFile.match(/MEILI_MASTER_KEY=([^\n]+)/);
213
+ expect(match).not.toBeNull();
214
+ expect(match![1].length).toBeGreaterThanOrEqual(32);
215
+ // Should not be empty
216
+ expect(match![1]).not.toBe("");
217
+ });
218
+
219
+ it("applies env quirks to envFile output (grafana GF_SECURITY_ADMIN_PASSWORD)", () => {
220
+ const result = generateAddonStack({
221
+ instanceId: "test-instance",
222
+ services: ["grafana"],
223
+ generateSecrets: true,
224
+ });
225
+
226
+ const match = result.envFile.match(/GF_SECURITY_ADMIN_PASSWORD=([^\n]+)/);
227
+ expect(match).not.toBeNull();
228
+ // Should be at least 22 chars (16 bytes base64url)
229
+ expect(match![1].length).toBeGreaterThanOrEqual(22);
230
+ });
231
+
232
+ it("syncs DB_POSTGRESDB_PASSWORD with N8N_DB_PASSWORD via must_sync quirk", () => {
233
+ const result = generateAddonStack({
234
+ instanceId: "test-instance",
235
+ services: ["n8n"],
236
+ generateSecrets: true,
237
+ });
238
+
239
+ const n8nDbPw = result.envFile.match(/N8N_DB_PASSWORD=([^\n]+)/);
240
+ const dbPostgresPw = result.envFile.match(/DB_POSTGRESDB_PASSWORD=([^\n]+)/);
241
+ expect(n8nDbPw).not.toBeNull();
242
+ expect(dbPostgresPw).not.toBeNull();
243
+ // Both passwords must match due to must_sync quirk
244
+ expect(n8nDbPw![1]).toBe(dbPostgresPw![1]);
245
+ });
246
+
247
+ it("syncs user-provided credential with DB password reference", () => {
248
+ const customPassword = "my_custom_secure_password";
249
+ const result = generateAddonStack({
250
+ instanceId: "test-instance",
251
+ services: ["n8n"],
252
+ generateSecrets: true,
253
+ credentials: {
254
+ n8n: { DB_POSTGRESDB_PASSWORD: customPassword },
255
+ },
256
+ });
257
+
258
+ // User value should be used for both the service env var and the ref key
259
+ expect(result.envFile).toContain(`DB_POSTGRESDB_PASSWORD=${customPassword}`);
260
+ expect(result.envFile).toContain(`N8N_DB_PASSWORD=${customPassword}`);
261
+ });
262
+
263
+ it("uses existingServices ports for conflict detection", () => {
264
+ const result = generateAddonStack({
265
+ instanceId: "test-instance",
266
+ services: ["n8n"],
267
+ // n8n uses port 5678, qdrant uses 6333 — qdrant as existing should not conflict
268
+ existingServices: ["qdrant"],
269
+ });
270
+
271
+ // n8n should still get its default port (no conflict with qdrant)
272
+ const portAssignments = result.metadata.portAssignments;
273
+ const n8nAssignment = Object.entries(portAssignments).find(([key]) =>
274
+ key.startsWith("n8n:"),
275
+ );
276
+ expect(n8nAssignment).toBeDefined();
277
+ expect(n8nAssignment![1]).toBe(5678);
278
+ });
279
+
280
+ it("does not dual-list GPU services in both skipped and resolved", () => {
281
+ const result = generateAddonStack({
282
+ instanceId: "test-instance",
283
+ services: ["ollama"],
284
+ gpu: false,
285
+ });
286
+
287
+ // ollama requires GPU — without gpu: true, it should be skipped only
288
+ const isSkipped = result.metadata.skippedServices.some(
289
+ (s) => s.serviceId === "ollama" && s.reason === "gpu_required",
290
+ );
291
+ const isResolved = result.metadata.resolvedServices.includes("ollama");
292
+
293
+ // Must be in exactly one list, not both
294
+ if (isSkipped) {
295
+ expect(isResolved).toBe(false);
296
+ }
297
+ });
298
+
299
+ it("includes GPU services when gpu: true is set", () => {
300
+ const result = generateAddonStack({
301
+ instanceId: "test-instance",
302
+ services: ["ollama"],
303
+ gpu: true,
304
+ });
305
+
306
+ // Should NOT be skipped when GPU support is available
307
+ const isSkipped = result.metadata.skippedServices.some(
308
+ (s) => s.serviceId === "ollama" && s.reason === "gpu_required",
309
+ );
310
+ expect(isSkipped).toBe(false);
311
+ expect(result.metadata.resolvedServices).toContain("ollama");
312
+ });
313
+
314
+ // ── OpenSandbox ────────────────────────────────────────────────────────
315
+
316
+ it("generates valid compose YAML for opensandbox", () => {
317
+ const result = generateAddonStack({
318
+ instanceId: "test-instance",
319
+ services: ["opensandbox"],
320
+ });
321
+
322
+ expect(result.metadata.resolvedServices).toContain("opensandbox");
323
+ const composed = parse(result.composeOverride);
324
+ expect(composed.services).toHaveProperty("opensandbox");
325
+
326
+ // Should have Docker socket mount
327
+ const volumes = composed.services.opensandbox.volumes as string[];
328
+ expect(volumes.some((v: string) => v.includes("/var/run/docker.sock"))).toBe(true);
329
+
330
+ // Should have config bind mount
331
+ expect(volumes.some((v: string) => v.includes("sandbox.toml"))).toBe(true);
332
+ });
333
+
334
+ it("generates OPEN_SANDBOX_API_KEY with min 32 bytes via env quirk", () => {
335
+ const result = generateAddonStack({
336
+ instanceId: "test-instance",
337
+ services: ["opensandbox"],
338
+ generateSecrets: true,
339
+ });
340
+
341
+ const match = result.envFile.match(/OPEN_SANDBOX_API_KEY=([^\n]+)/);
342
+ expect(match).not.toBeNull();
343
+ // 32 bytes base64url ≈ 43 chars
344
+ expect(match![1].length).toBeGreaterThanOrEqual(43);
345
+ });
346
+
347
+ it("generates code-sandbox skill file and config patch", () => {
348
+ const result = generateAddonStack({
349
+ instanceId: "test-instance",
350
+ services: ["opensandbox"],
351
+ });
352
+
353
+ // Skill config patch
354
+ expect(result.openclawConfigPatch.skills.entries).toHaveProperty("code-sandbox");
355
+ expect(result.openclawConfigPatch.skills.entries["code-sandbox"].enabled).toBe(true);
356
+
357
+ // Skill file content should exist and contain key actions
358
+ const skillFile = Object.values(result.skillFiles).find((content) =>
359
+ content.includes("code-sandbox"),
360
+ );
361
+ expect(skillFile).toBeDefined();
362
+ expect(skillFile).toContain("execute_code");
363
+ expect(skillFile).toContain("create_desktop");
364
+ expect(skillFile).toContain("get_preview_url");
365
+ });
366
+
367
+ it("generates proxy route for opensandbox at /sandbox", () => {
368
+ const result = generateAddonStack({
369
+ instanceId: "test-instance",
370
+ services: ["opensandbox"],
371
+ });
372
+
373
+ const route = result.proxyRoutes.find((r) => r.serviceId === "opensandbox");
374
+ expect(route).toBeDefined();
375
+ expect(route!.path).toBe("/sandbox");
376
+ expect(route!.port).toBe(8080);
377
+ });
378
+
379
+ it("generates sandbox.toml in additionalFiles", () => {
380
+ const result = generateAddonStack({
381
+ instanceId: "test-instance",
382
+ services: ["opensandbox"],
383
+ });
384
+
385
+ expect(result.additionalFiles).toHaveProperty("sandbox.toml");
386
+ const toml = result.additionalFiles["sandbox.toml"];
387
+ expect(toml).toContain('[server]');
388
+ expect(toml).toContain('api_key = "${OPEN_SANDBOX_API_KEY}"');
389
+ expect(toml).toContain('[runtime]');
390
+ expect(toml).toContain('[docker]');
391
+ expect(toml).toContain('[secure_runtime]');
392
+ expect(toml).toContain('type = "gvisor"');
393
+ });
394
+
395
+ it("populates prePullImages with 8 images across 3 priority tiers", () => {
396
+ const result = generateAddonStack({
397
+ instanceId: "test-instance",
398
+ services: ["opensandbox"],
399
+ });
400
+
401
+ const images = result.metadata.prePullImages;
402
+ expect(images.length).toBe(8);
403
+
404
+ // Priority 1: core + Homespace
405
+ const p1 = images.filter((i) => i.priority === 1);
406
+ expect(p1.length).toBe(4);
407
+ expect(p1.map((i) => i.image)).toContain("opensandbox/server:v1.0.6");
408
+ expect(p1.map((i) => i.image)).toContain("opensandbox/execd:v1.0.6");
409
+ expect(p1.map((i) => i.image)).toContain("opensandbox/desktop:latest");
410
+ expect(p1.map((i) => i.image)).toContain("opensandbox/chrome:latest");
411
+
412
+ // Priority 2: common languages
413
+ const p2 = images.filter((i) => i.priority === 2);
414
+ expect(p2.length).toBe(2);
415
+ expect(p2.map((i) => i.image)).toContain("opensandbox/code-interpreter:python");
416
+ expect(p2.map((i) => i.image)).toContain("opensandbox/code-interpreter:node");
417
+
418
+ // Priority 3: optional
419
+ const p3 = images.filter((i) => i.priority === 3);
420
+ expect(p3.length).toBe(2);
421
+ expect(p3.map((i) => i.image)).toContain("opensandbox/code-interpreter:latest");
422
+ expect(p3.map((i) => i.image)).toContain("opensandbox/vscode:latest");
423
+ });
424
+
425
+ it("resolves port conflict between opensandbox and searxng (both 8080)", () => {
426
+ const result = generateAddonStack({
427
+ instanceId: "test-instance",
428
+ services: ["opensandbox", "searxng"],
429
+ });
430
+
431
+ expect(result.metadata.resolvedServices).toContain("opensandbox");
432
+ expect(result.metadata.resolvedServices).toContain("searxng");
433
+
434
+ // Both should have port assignments, but they should differ
435
+ const assignments = result.metadata.portAssignments;
436
+ const opensandboxPort = assignments["opensandbox:8080"];
437
+ const searxngPort = assignments["searxng:8080"];
438
+ expect(opensandboxPort).toBeDefined();
439
+ expect(searxngPort).toBeDefined();
440
+ expect(opensandboxPort).not.toBe(searxngPort);
441
+ });
442
+
443
+ it("does not include opensandbox in prePullImages when not selected", () => {
444
+ const result = generateAddonStack({
445
+ instanceId: "test-instance",
446
+ services: ["qdrant"],
447
+ });
448
+
449
+ expect(result.metadata.prePullImages.length).toBe(0);
450
+ expect(result.additionalFiles).not.toHaveProperty("sandbox.toml");
451
+ });
452
+
453
+ it("opensandbox does not require postgresql (no postgres-setup)", () => {
454
+ const result = generateAddonStack({
455
+ instanceId: "test-instance",
456
+ services: ["opensandbox"],
457
+ });
458
+
459
+ const composed = parse(result.composeOverride);
460
+ expect(composed.services).not.toHaveProperty("postgres-setup");
461
+ });
462
+
463
+ it("accounts for 768MB memory in estimatedMemoryMB", () => {
464
+ const result = generateAddonStack({
465
+ instanceId: "test-instance",
466
+ services: ["opensandbox"],
467
+ });
468
+
469
+ expect(result.metadata.estimatedMemoryMB).toBeGreaterThanOrEqual(768);
470
+ });
471
+ });
472
+
473
+ describe("updateAddonStack", () => {
474
+ it("adds a service to an existing stack", () => {
475
+ // First generate a base stack
476
+ const base = generateAddonStack({
477
+ instanceId: "test-instance",
478
+ services: ["qdrant"],
479
+ });
480
+
481
+ // Then add meilisearch
482
+ const result = updateAddonStack({
483
+ instanceId: "test-instance",
484
+ currentCompose: base.composeOverride,
485
+ currentEnv: base.envFile,
486
+ addServices: ["meilisearch"],
487
+ });
488
+
489
+ expect(result.metadata.added).toContain("meilisearch");
490
+ expect(result.metadata.unchanged).toContain("qdrant");
491
+
492
+ const composed = parse(result.composeOverride);
493
+ expect(composed.services).toHaveProperty("qdrant");
494
+ expect(composed.services).toHaveProperty("meilisearch");
495
+ });
496
+
497
+ it("removes a service from an existing stack", () => {
498
+ const base = generateAddonStack({
499
+ instanceId: "test-instance",
500
+ services: ["qdrant", "meilisearch"],
501
+ });
502
+
503
+ const result = updateAddonStack({
504
+ instanceId: "test-instance",
505
+ currentCompose: base.composeOverride,
506
+ currentEnv: base.envFile,
507
+ removeServices: ["meilisearch"],
508
+ });
509
+
510
+ expect(result.metadata.removed).toContain("meilisearch");
511
+ expect(result.metadata.unchanged).toContain("qdrant");
512
+
513
+ const composed = parse(result.composeOverride);
514
+ expect(composed.services).toHaveProperty("qdrant");
515
+ expect(composed.services).not.toHaveProperty("meilisearch");
516
+ });
517
+
518
+ it("preserves existing env values when adding a service", () => {
519
+ const base = generateAddonStack({
520
+ instanceId: "test-instance",
521
+ services: ["qdrant"],
522
+ generateSecrets: true,
523
+ });
524
+
525
+ // Simulate user editing an env value
526
+ const customEnv = base.envFile.replace(
527
+ /QDRANT_API_KEY=[a-f0-9]+/,
528
+ "QDRANT_API_KEY=my_custom_key",
529
+ );
530
+
531
+ const result = updateAddonStack({
532
+ instanceId: "test-instance",
533
+ currentCompose: base.composeOverride,
534
+ currentEnv: customEnv,
535
+ addServices: ["meilisearch"],
536
+ generateSecrets: true,
537
+ });
538
+
539
+ // If QDRANT_API_KEY was in the original env, it should be preserved
540
+ if (customEnv.includes("QDRANT_API_KEY=my_custom_key")) {
541
+ expect(result.envFile).toContain("QDRANT_API_KEY=my_custom_key");
542
+ }
543
+ });
544
+
545
+ it("returns images to pull for added services", () => {
546
+ const base = generateAddonStack({
547
+ instanceId: "test-instance",
548
+ services: ["qdrant"],
549
+ });
550
+
551
+ const result = updateAddonStack({
552
+ instanceId: "test-instance",
553
+ currentCompose: base.composeOverride,
554
+ currentEnv: base.envFile,
555
+ addServices: ["meilisearch"],
556
+ });
557
+
558
+ expect(result.imagesToPull.length).toBeGreaterThan(0);
559
+ expect(result.imagesToPull.some((img) => img.includes("meilisearch"))).toBe(true);
560
+ });
561
+
562
+ it("handles empty update gracefully", () => {
563
+ const base = generateAddonStack({
564
+ instanceId: "test-instance",
565
+ services: ["qdrant"],
566
+ });
567
+
568
+ const result = updateAddonStack({
569
+ instanceId: "test-instance",
570
+ currentCompose: base.composeOverride,
571
+ currentEnv: base.envFile,
572
+ });
573
+
574
+ expect(result.metadata.added).toEqual([]);
575
+ expect(result.metadata.removed).toEqual([]);
576
+ expect(result.metadata.unchanged).toContain("qdrant");
577
+ });
578
+
579
+ it("respects generateSecrets: false during update", () => {
580
+ const base = generateAddonStack({
581
+ instanceId: "test-instance",
582
+ services: ["qdrant"],
583
+ generateSecrets: true,
584
+ });
585
+
586
+ // With generateSecrets: false, services requiring secrets (like meilisearch)
587
+ // may be skipped as missing_credentials. Provide credentials explicitly.
588
+ const result = updateAddonStack({
589
+ instanceId: "test-instance",
590
+ currentCompose: base.composeOverride,
591
+ currentEnv: base.envFile,
592
+ addServices: ["meilisearch"],
593
+ generateSecrets: false,
594
+ credentials: {
595
+ meilisearch: { MEILI_MASTER_KEY: "test-master-key-1234567890" },
596
+ },
597
+ });
598
+
599
+ // Meilisearch should be added since we provided the required credential
600
+ expect(result.metadata.added).toContain("meilisearch");
601
+ });
602
+
603
+ it("forwards portOverrides during update", () => {
604
+ const base = generateAddonStack({
605
+ instanceId: "test-instance",
606
+ services: ["qdrant"],
607
+ });
608
+
609
+ const result = updateAddonStack({
610
+ instanceId: "test-instance",
611
+ currentCompose: base.composeOverride,
612
+ currentEnv: base.envFile,
613
+ addServices: ["n8n"],
614
+ portOverrides: { n8n: { "5678": 9999 } },
615
+ });
616
+
617
+ // n8n should be present with the port override
618
+ expect(result.metadata.added).toContain("n8n");
619
+ });
620
+ });
621
+
622
+ describe("AddonStackInputSchema", () => {
623
+ it("accepts valid input", () => {
624
+ const result = AddonStackInputSchema.safeParse({
625
+ instanceId: "test-instance",
626
+ services: ["qdrant", "n8n"],
627
+ });
628
+ expect(result.success).toBe(true);
629
+ });
630
+
631
+ it("requires instanceId", () => {
632
+ const result = AddonStackInputSchema.safeParse({
633
+ services: ["qdrant"],
634
+ });
635
+ expect(result.success).toBe(false);
636
+ });
637
+
638
+ it("defaults empty arrays and booleans", () => {
639
+ const result = AddonStackInputSchema.parse({
640
+ instanceId: "test",
641
+ services: ["qdrant"],
642
+ });
643
+ expect(result.skillPacks).toEqual([]);
644
+ expect(result.reservedPorts).toEqual([]);
645
+ expect(result.generateSecrets).toBe(true);
646
+ expect(result.platform).toBe("linux/amd64");
647
+ });
648
+ });