@better-openclaw/core 1.0.19 → 1.0.21

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 (61) hide show
  1. package/.github/dependabot.yml +32 -0
  2. package/.github/workflows/ci.yml +8 -8
  3. package/.github/workflows/publish-core.yml +4 -4
  4. package/SECURITY.md +62 -0
  5. package/dist/deployers/coolify.cjs +61 -50
  6. package/dist/deployers/coolify.cjs.map +1 -1
  7. package/dist/deployers/coolify.d.cts +4 -11
  8. package/dist/deployers/coolify.d.cts.map +1 -1
  9. package/dist/deployers/coolify.d.mts +4 -11
  10. package/dist/deployers/coolify.d.mts.map +1 -1
  11. package/dist/deployers/coolify.mjs +62 -50
  12. package/dist/deployers/coolify.mjs.map +1 -1
  13. package/dist/deployers/dokploy.cjs +106 -29
  14. package/dist/deployers/dokploy.cjs.map +1 -1
  15. package/dist/deployers/dokploy.d.cts +2 -1
  16. package/dist/deployers/dokploy.d.cts.map +1 -1
  17. package/dist/deployers/dokploy.d.mts +2 -1
  18. package/dist/deployers/dokploy.d.mts.map +1 -1
  19. package/dist/deployers/dokploy.mjs +107 -29
  20. package/dist/deployers/dokploy.mjs.map +1 -1
  21. package/dist/deployers/index.cjs.map +1 -1
  22. package/dist/deployers/index.d.cts +2 -2
  23. package/dist/deployers/index.d.cts.map +1 -1
  24. package/dist/deployers/index.d.mts +2 -2
  25. package/dist/deployers/index.d.mts.map +1 -1
  26. package/dist/deployers/index.mjs.map +1 -1
  27. package/dist/deployers/strip-host-ports.cjs +138 -0
  28. package/dist/deployers/strip-host-ports.cjs.map +1 -0
  29. package/dist/deployers/strip-host-ports.d.cts +62 -0
  30. package/dist/deployers/strip-host-ports.d.cts.map +1 -0
  31. package/dist/deployers/strip-host-ports.d.mts +62 -0
  32. package/dist/deployers/strip-host-ports.d.mts.map +1 -0
  33. package/dist/deployers/strip-host-ports.mjs +133 -0
  34. package/dist/deployers/strip-host-ports.mjs.map +1 -0
  35. package/dist/deployers/strip-host-ports.test.cjs +89 -0
  36. package/dist/deployers/strip-host-ports.test.cjs.map +1 -0
  37. package/dist/deployers/strip-host-ports.test.d.cts +1 -0
  38. package/dist/deployers/strip-host-ports.test.d.mts +1 -0
  39. package/dist/deployers/strip-host-ports.test.mjs +90 -0
  40. package/dist/deployers/strip-host-ports.test.mjs.map +1 -0
  41. package/dist/deployers/types.d.cts +173 -2
  42. package/dist/deployers/types.d.cts.map +1 -1
  43. package/dist/deployers/types.d.mts +173 -2
  44. package/dist/deployers/types.d.mts.map +1 -1
  45. package/dist/index.d.cts +2 -2
  46. package/dist/index.d.mts +2 -2
  47. package/dist/services/definitions/usesend.cjs +4 -4
  48. package/dist/services/definitions/usesend.cjs.map +1 -1
  49. package/dist/services/definitions/usesend.mjs +4 -4
  50. package/dist/services/definitions/usesend.mjs.map +1 -1
  51. package/package.json +4 -4
  52. package/src/__snapshots__/composer.snapshot.test.ts.snap +248 -38
  53. package/src/deployers/coolify.ts +198 -103
  54. package/src/deployers/dokploy.ts +209 -55
  55. package/src/deployers/index.ts +1 -0
  56. package/src/deployers/strip-host-ports.test.ts +100 -0
  57. package/src/deployers/strip-host-ports.ts +187 -0
  58. package/src/deployers/types.ts +185 -1
  59. package/src/index.ts +19 -4
  60. package/src/services/definitions/usesend.ts +4 -4
  61. package/tsconfig.tsbuildinfo +1 -0
@@ -6,21 +6,37 @@
6
6
  * Endpoints use dot-notation (e.g. /api/project.create, /api/compose.deploy)
7
7
  */
8
8
 
9
- import type { DeployInput, DeployResult, DeployStep, DeployTarget, PaasDeployer } from "./types.js";
9
+ import { sanitizeComposeForPaas } from "./strip-host-ports.js";
10
+ import type {
11
+ DeployInput,
12
+ DeployResult,
13
+ DeployStep,
14
+ DeployTarget,
15
+ DokployEnvironment,
16
+ PaasDeployer,
17
+ PaasServer,
18
+ } from "./types.js";
10
19
 
11
- /** Shape returned by Dokploy's project endpoints. */
12
20
  interface DokployProject {
13
21
  projectId: string;
14
22
  name: string;
15
23
  description: string;
16
- environments?: { environmentId: string; name: string }[];
24
+ environments?: DokployEnvironment[];
17
25
  }
18
26
 
19
- /** Shape returned by Dokploy's compose endpoints. */
20
27
  interface DokployCompose {
21
28
  composeId: string;
22
29
  name: string;
23
30
  status?: string;
31
+ compose?: string;
32
+ }
33
+
34
+ interface ProjectCreateResult {
35
+ project: DokployProject;
36
+ projectId: string;
37
+ name: string;
38
+ description: string;
39
+ environments?: { environmentId: string; name: string }[];
24
40
  }
25
41
 
26
42
  /** Build a full Dokploy API URL from a dot-notation endpoint (e.g. "project.create"). */
@@ -28,7 +44,6 @@ function apiUrl(target: DeployTarget, endpoint: string): string {
28
44
  const base = target.instanceUrl.replace(/\/+$/, "");
29
45
  return `${base}/api/${endpoint}`;
30
46
  }
31
-
32
47
  /**
33
48
  * Typed fetch wrapper for the Dokploy API.
34
49
  * Handles JSON serialisation, x-api-key auth, and error extraction.
@@ -50,20 +65,35 @@ async function dokployFetch<T>(
50
65
  if (!res.ok) {
51
66
  const text = await res.text().catch(() => "");
52
67
  let detail = text;
68
+
53
69
  try {
54
70
  const json = JSON.parse(text);
55
71
  detail = json.message || json.error || text;
56
- } catch {
57
- // use raw text
58
- }
72
+ } catch {}
73
+
59
74
  throw new Error(`Dokploy API ${res.status}: ${detail}`);
60
75
  }
61
76
 
62
77
  const text = await res.text();
63
78
  if (!text) return undefined as T;
79
+
64
80
  return JSON.parse(text) as T;
65
81
  }
66
82
 
83
+ /**
84
+ * Simple hash for compose diff detection
85
+ */
86
+ function hashString(str: string) {
87
+ let hash = 0;
88
+
89
+ for (let i = 0; i < str.length; i++) {
90
+ hash = (hash << 5) - hash + str.charCodeAt(i);
91
+ hash |= 0;
92
+ }
93
+
94
+ return hash;
95
+ }
96
+
67
97
  /**
68
98
  * Deploys Docker Compose stacks to a Dokploy instance.
69
99
  *
@@ -73,6 +103,7 @@ async function dokployFetch<T>(
73
103
  * 3. Push .env variables to the compose stack
74
104
  * 4. Trigger the deployment
75
105
  */
106
+
76
107
  export class DokployDeployer implements PaasDeployer {
77
108
  readonly name = "Dokploy";
78
109
  readonly id = "dokploy";
@@ -80,94 +111,217 @@ export class DokployDeployer implements PaasDeployer {
80
111
  async testConnection(target: DeployTarget): Promise<{ ok: boolean; error?: string }> {
81
112
  try {
82
113
  await dokployFetch<DokployProject[]>(target, "project.all");
114
+
83
115
  return { ok: true };
84
116
  } catch (err) {
85
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
117
+ return {
118
+ ok: false,
119
+ error: err instanceof Error ? err.message : String(err),
120
+ };
121
+ }
122
+ }
123
+
124
+ async listServers(target: DeployTarget): Promise<PaasServer[]> {
125
+ try {
126
+ const servers = await dokployFetch<{ serverId: string; name: string; ipAddress: string }[]>(
127
+ target,
128
+ "server.all",
129
+ );
130
+ return servers.map((s) => ({
131
+ id: s.serverId,
132
+ name: s.name,
133
+ ip: s.ipAddress,
134
+ }));
135
+ } catch {
136
+ // Return empty list if server API is not available
137
+ return [];
86
138
  }
87
139
  }
88
140
 
89
141
  async deploy(input: DeployInput): Promise<DeployResult> {
90
- const step1: DeployStep = { step: "Create project", status: "pending" };
91
- const step2: DeployStep = { step: "Create compose stack", status: "pending" };
92
- const step3: DeployStep = { step: "Set environment variables", status: "pending" };
93
- const step4: DeployStep = { step: "Trigger deployment", status: "pending" };
94
- const steps: DeployStep[] = [step1, step2, step3, step4];
142
+ const step1: DeployStep = {
143
+ step: "Find or create project",
144
+ status: "pending",
145
+ };
146
+ const step2: DeployStep = {
147
+ step: "Find default environment",
148
+ status: "pending",
149
+ };
150
+ const step3: DeployStep = {
151
+ step: "Find or create compose stack",
152
+ status: "pending",
153
+ };
154
+ const step4: DeployStep = {
155
+ step: "Update stack configuration",
156
+ status: "pending",
157
+ };
158
+ const step5: DeployStep = { step: "Deploy stack", status: "pending" };
159
+ const steps: DeployStep[] = [step1, step2, step3, step4, step5];
95
160
 
96
161
  const result: DeployResult = { success: false, steps };
97
162
 
163
+ // Strip host port bindings — Dokploy routes via Traefik,
164
+ // so host ports are unnecessary and cause "port already allocated" errors.
165
+ const composeYaml = sanitizeComposeForPaas(input.composeYaml);
166
+
98
167
  try {
99
- // Step 1: Create project
168
+ /**
169
+ * STEP 1
170
+ * Find or create project
171
+ */
172
+
100
173
  step1.status = "running";
101
- const project = await dokployFetch<DokployProject>(input.target, "project.create", {
102
- method: "POST",
103
- body: {
104
- name: input.projectName,
105
- description: input.description ?? `OpenClaw stack: ${input.projectName}`,
106
- },
107
- });
174
+
175
+ const projects = await dokployFetch<DokployProject[]>(input.target, "project.all");
176
+
177
+ let project = projects.find((p) => p.name === input.projectName);
178
+
179
+ if (!project) {
180
+ const created = await dokployFetch<ProjectCreateResult>(input.target, "project.create", {
181
+ method: "POST",
182
+ body: {
183
+ name: input.projectName,
184
+ description: input.description ?? `OpenClaw stack: ${input.projectName}`,
185
+ },
186
+ });
187
+
188
+ project = created.project;
189
+ }
190
+
108
191
  result.projectId = project.projectId;
192
+
109
193
  step1.status = "done";
110
194
  step1.detail = `Project ID: ${project.projectId}`;
111
195
 
112
- // Get the default environment ID
196
+ /**
197
+ * STEP 2
198
+ * Find default environment
199
+ */
200
+
201
+ step2.status = "running";
202
+
113
203
  const projectDetail = await dokployFetch<DokployProject>(
114
204
  input.target,
115
205
  `project.one?projectId=${project.projectId}`,
116
206
  );
117
- const envId = projectDetail.environments?.[0]?.environmentId;
118
- if (!envId) {
119
- throw new Error("No default environment found in project");
120
- }
121
207
 
122
- // Step 2: Create compose stack
123
- step2.status = "running";
124
- const compose = await dokployFetch<DokployCompose>(input.target, "compose.create", {
125
- method: "POST",
126
- body: {
127
- name: input.projectName,
128
- environmentId: envId,
129
- composeFile: input.composeYaml,
130
- },
131
- });
132
- result.composeId = compose.composeId;
208
+ const env = projectDetail.environments?.find((e) => e.isDefault);
209
+
210
+ if (!env) throw new Error("No default environment");
211
+
133
212
  step2.status = "done";
134
- step2.detail = `Compose ID: ${compose.composeId}`;
213
+ step2.detail = env.environmentId;
214
+
215
+ /**
216
+ * STEP 3
217
+ * Find or create compose stack
218
+ */
135
219
 
136
- // Step 3: Set environment variables
137
220
  step3.status = "running";
138
- await dokployFetch(input.target, "compose.update", {
221
+
222
+ let stack: DokployCompose | null = null;
223
+
224
+ stack = await dokployFetch<DokployCompose>(input.target, "compose.create", {
139
225
  method: "POST",
140
226
  body: {
141
- composeId: compose.composeId,
142
- env: input.envContent,
227
+ name: input.projectName,
228
+ description: input.description ?? `Stack ${input.projectName}`,
229
+ environmentId: env.environmentId,
230
+ composeType: "docker-compose",
231
+ composeFile: composeYaml,
232
+ ...(input.serverId ? { serverId: input.serverId } : {}),
143
233
  },
144
234
  });
235
+
236
+ // Dokploy's compose.create schema does NOT accept sourceType;
237
+ // it defaults to "github". We must update it to "raw" so the
238
+ // deploy step writes the compose file from the stored YAML
239
+ // instead of attempting to clone from a Git provider.
240
+ if (stack?.composeId) {
241
+ await dokployFetch(input.target, "compose.update", {
242
+ method: "POST",
243
+ body: {
244
+ composeId: stack.composeId,
245
+ sourceType: "raw",
246
+ },
247
+ });
248
+ }
249
+
250
+ result.composeId = stack?.composeId;
145
251
  step3.status = "done";
252
+ step3.detail = stack?.composeId;
253
+
254
+ /**
255
+ * STEP 4
256
+ * Update stack if compose changed
257
+ */
146
258
 
147
- // Step 4: Trigger deployment
148
259
  step4.status = "running";
260
+
261
+ const existingStack = await dokployFetch<DokployCompose>(
262
+ input.target,
263
+ `compose.one?composeId=${stack?.composeId}`,
264
+ );
265
+
266
+ const newHash = hashString(composeYaml);
267
+ const oldHash = hashString(existingStack.compose ?? "");
268
+
269
+ if (newHash !== oldHash) {
270
+ await dokployFetch(input.target, "compose.update", {
271
+ method: "POST",
272
+ body: {
273
+ composeId: stack?.composeId,
274
+ composeFile: composeYaml,
275
+ env: input.envContent ?? "",
276
+ },
277
+ });
278
+
279
+ step4.detail = "Stack updated";
280
+ } else {
281
+ step4.detail = "No compose changes";
282
+ }
283
+
284
+ step4.status = "done";
285
+
286
+ /**
287
+ * STEP 5
288
+ * Deploy
289
+ */
290
+
291
+ step5.status = "running";
292
+
149
293
  await dokployFetch(input.target, "compose.deploy", {
150
294
  method: "POST",
151
295
  body: {
152
- composeId: compose.composeId,
153
- title: `Initial deploy: ${input.projectName}`,
154
- description: input.description ?? "Deployed via OpenClaw web builder",
296
+ composeId: stack?.composeId,
297
+
298
+ title: `Deploy ${input.projectName}`,
299
+
300
+ description: "CI deployment",
155
301
  },
156
302
  });
157
- step4.status = "done";
303
+
304
+ step5.status = "done";
158
305
 
159
306
  result.success = true;
307
+
160
308
  const base = input.target.instanceUrl.replace(/\/+$/, "");
161
- result.dashboardUrl = `${base}/dashboard/project/${project.projectId}`;
309
+
310
+ result.dashboardUrl = `${base}/dashboard/project/${project.projectId}/environment/${env.environmentId}/services/compose/${stack?.composeId}?tab=deployments`;
311
+
312
+ return result;
162
313
  } catch (err) {
163
- const failedStep = steps.find((s) => s.status === "running");
164
- if (failedStep) {
165
- failedStep.status = "error";
166
- failedStep.detail = err instanceof Error ? err.message : String(err);
314
+ const running = steps.find((s) => s.status === "running");
315
+
316
+ if (running) {
317
+ running.status = "error";
318
+
319
+ running.detail = err instanceof Error ? err.message : String(err);
167
320
  }
321
+
168
322
  result.error = err instanceof Error ? err.message : String(err);
169
- }
170
323
 
171
- return result;
324
+ return result;
325
+ }
172
326
  }
173
327
  }
@@ -13,6 +13,7 @@ export type {
13
13
  DeployStep,
14
14
  DeployTarget,
15
15
  PaasDeployer,
16
+ PaasServer,
16
17
  } from "./types.js";
17
18
 
18
19
  import { CoolifyDeployer } from "./coolify.js";
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { stripHostPorts } from "./strip-host-ports.js";
3
+
4
+ describe("stripHostPorts", () => {
5
+ it("strips host port from short syntax (host:container)", () => {
6
+ const yaml = `
7
+ services:
8
+ web:
9
+ image: nginx
10
+ ports:
11
+ - "8080:80"
12
+ - "443:443"
13
+ `;
14
+ const result = stripHostPorts(yaml);
15
+ expect(result).toContain('"80"');
16
+ expect(result).toContain('"443"');
17
+ expect(result).not.toContain("8080");
18
+ });
19
+
20
+ it("strips host IP and port from extended short syntax", () => {
21
+ const yaml = `
22
+ services:
23
+ web:
24
+ image: nginx
25
+ ports:
26
+ - "0.0.0.0:8080:80"
27
+ `;
28
+ const result = stripHostPorts(yaml);
29
+ expect(result).toContain('"80"');
30
+ expect(result).not.toContain("8080");
31
+ expect(result).not.toContain("0.0.0.0");
32
+ });
33
+
34
+ it("preserves protocol suffix", () => {
35
+ const yaml = `
36
+ services:
37
+ web:
38
+ image: nginx
39
+ ports:
40
+ - "8080:80/tcp"
41
+ - "5353:53/udp"
42
+ `;
43
+ const result = stripHostPorts(yaml);
44
+ expect(result).toContain("80/tcp");
45
+ expect(result).toContain("53/udp");
46
+ expect(result).not.toContain("8080");
47
+ expect(result).not.toContain("5353");
48
+ });
49
+
50
+ it("keeps container-only ports unchanged", () => {
51
+ const yaml = `
52
+ services:
53
+ web:
54
+ image: nginx
55
+ ports:
56
+ - "80"
57
+ `;
58
+ const result = stripHostPorts(yaml);
59
+ expect(result).toContain('"80"');
60
+ });
61
+
62
+ it("handles multiple services", () => {
63
+ const yaml = `
64
+ services:
65
+ web:
66
+ image: nginx
67
+ ports:
68
+ - "8080:80"
69
+ redis:
70
+ image: redis
71
+ ports:
72
+ - "6379:6379"
73
+ searxng:
74
+ image: searxng/searxng
75
+ ports:
76
+ - "8888:8080"
77
+ `;
78
+ const result = stripHostPorts(yaml);
79
+ expect(result).toContain('"80"');
80
+ expect(result).toContain('"6379"');
81
+ expect(result).toContain('"8080"');
82
+ expect(result).not.toContain("8888");
83
+ });
84
+
85
+ it("returns original YAML if no services section", () => {
86
+ const yaml = `version: "3"`;
87
+ const result = stripHostPorts(yaml);
88
+ expect(result).toBe(yaml);
89
+ });
90
+
91
+ it("handles services with no ports", () => {
92
+ const yaml = `
93
+ services:
94
+ web:
95
+ image: nginx
96
+ `;
97
+ const result = stripHostPorts(yaml);
98
+ expect(result).toContain("nginx");
99
+ });
100
+ });
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Strips host port bindings from a Docker Compose YAML string.
3
+ *
4
+ * When deploying to a PaaS like Dokploy or Coolify, services don't need
5
+ * host port mappings because routing is handled by the platform's built-in
6
+ * reverse proxy (Traefik). Binding to host ports causes "port already
7
+ * allocated" errors when ports are in use by other services on the server.
8
+ *
9
+ * Transforms port mappings:
10
+ * "8080:8080" → "8080" (container port only)
11
+ * "0.0.0.0:8080:80" → "80" (container port only)
12
+ * "8080:80/tcp" → "80/tcp" (preserves protocol)
13
+ * { published: 8080, target: 80 } → { target: 80 }
14
+ *
15
+ * The `expose` field is left untouched since it only defines internal ports.
16
+ */
17
+
18
+ import { parse, stringify } from "yaml";
19
+
20
+ interface ComposeService {
21
+ ports?: (string | PortObject)[];
22
+ volumes?: (string | Record<string, unknown>)[];
23
+ [key: string]: unknown;
24
+ }
25
+
26
+ interface PortObject {
27
+ target: number;
28
+ published?: number | string;
29
+ host_ip?: string;
30
+ protocol?: string;
31
+ [key: string]: unknown;
32
+ }
33
+
34
+ interface ComposeFile {
35
+ services?: Record<string, ComposeService>;
36
+ [key: string]: unknown;
37
+ }
38
+
39
+ /**
40
+ * Strips host port bindings from a Docker Compose YAML string,
41
+ * keeping only the container (target) ports.
42
+ *
43
+ * Returns the modified YAML string.
44
+ */
45
+ export function stripHostPorts(composeYaml: string): string {
46
+ const doc = parse(composeYaml) as ComposeFile;
47
+
48
+ if (!doc?.services) return composeYaml;
49
+
50
+ for (const [, service] of Object.entries(doc.services)) {
51
+ if (!service.ports || !Array.isArray(service.ports)) continue;
52
+
53
+ service.ports = service.ports.map((port) => {
54
+ if (typeof port === "string") {
55
+ return stripStringPort(port);
56
+ }
57
+ if (typeof port === "object" && port !== null) {
58
+ return stripObjectPort(port);
59
+ }
60
+ return port;
61
+ });
62
+ }
63
+
64
+ return stringify(doc, { lineWidth: 200 });
65
+ }
66
+
67
+ /**
68
+ * Strips host portion from a string port mapping.
69
+ *
70
+ * "8080:80" → "80"
71
+ * "0.0.0.0:8080:80" → "80"
72
+ * "80" → "80" (no change)
73
+ * "80/tcp" → "80/tcp"
74
+ * "8080:80/tcp" → "80/tcp"
75
+ */
76
+ function stripStringPort(port: string): string {
77
+ // Split off protocol if present (e.g. "/tcp", "/udp")
78
+ const protocolIdx = port.lastIndexOf("/");
79
+ let protocol = "";
80
+ let portSpec = port;
81
+
82
+ if (protocolIdx > 0) {
83
+ protocol = port.substring(protocolIdx); // includes the "/"
84
+ portSpec = port.substring(0, protocolIdx);
85
+ }
86
+
87
+ // Split by ":" — formats are:
88
+ // "80" → container only
89
+ // "8080:80" → host:container
90
+ // "0.0.0.0:8080:80" → ip:host:container
91
+ const parts = portSpec.split(":");
92
+
93
+ // Take the last part as the container port
94
+ const containerPort = parts[parts.length - 1];
95
+
96
+ return `${containerPort}${protocol}`;
97
+ }
98
+
99
+ /**
100
+ * Strips host/published from an object port mapping.
101
+ *
102
+ * { target: 80, published: 8080 } → { target: 80 }
103
+ */
104
+ function stripObjectPort(port: PortObject): PortObject {
105
+ const { published: _, host_ip: __, ...rest } = port;
106
+ return rest;
107
+ }
108
+
109
+ /**
110
+ * Strips local bind mounts (paths starting with `./`) from a Docker
111
+ * Compose YAML string.
112
+ *
113
+ * When deploying to a PaaS like Dokploy or Coolify as a raw compose
114
+ * stack, there is no cloned repository — so host-relative volume mounts
115
+ * like `./postgres/init-databases.sh:/docker-entrypoint-initdb.d/...`
116
+ * will fail because the file doesn't exist on the remote server.
117
+ *
118
+ * Named volumes (e.g. `redis-data:/data`) and absolute system paths
119
+ * (e.g. `/var/run/docker.sock`) are kept intact.
120
+ */
121
+ export function stripLocalBindMounts(composeYaml: string): string {
122
+ const doc = parse(composeYaml) as ComposeFile;
123
+
124
+ if (!doc?.services) return composeYaml;
125
+
126
+ for (const [, service] of Object.entries(doc.services)) {
127
+ if (!service.volumes || !Array.isArray(service.volumes)) continue;
128
+
129
+ service.volumes = (service.volumes as (string | Record<string, unknown>)[]).filter((vol) => {
130
+ if (typeof vol === "string") {
131
+ // Bind mounts starting with "./" reference local files
132
+ return !vol.startsWith("./");
133
+ }
134
+ // Object-form: { type: "bind", source: "./..." }
135
+ if (typeof vol === "object" && vol !== null) {
136
+ const src = (vol as Record<string, unknown>).source;
137
+ return !(typeof src === "string" && src.startsWith("./"));
138
+ }
139
+ return true;
140
+ });
141
+
142
+ // Remove empty volumes array to keep YAML clean
143
+ if (service.volumes.length === 0) {
144
+ delete service.volumes;
145
+ }
146
+ }
147
+
148
+ return stringify(doc, { lineWidth: 200 });
149
+ }
150
+
151
+ /**
152
+ * Strips security hardening options (`cap_drop`, `cap_add`, `security_opt`)
153
+ * from all services in a Docker Compose YAML string.
154
+ *
155
+ * Many Docker images (PostgreSQL, Redis, MinIO, etc.) start as root and
156
+ * use `gosu`/`su-exec` to drop privileges to a non-root user. This
157
+ * requires `SETUID`, `SETGID`, and other capabilities. The hardened
158
+ * compose output adds `cap_drop: ALL` + `no-new-privileges`, which
159
+ * prevents this user switch and causes containers to crash with:
160
+ * "failed switching to 'postgres': operation not permitted"
161
+ *
162
+ * PaaS platforms (Dokploy, Coolify) manage their own container security,
163
+ * so these options are unnecessary and should be removed.
164
+ */
165
+ export function stripSecurityHardening(composeYaml: string): string {
166
+ const doc = parse(composeYaml) as ComposeFile;
167
+
168
+ if (!doc?.services) return composeYaml;
169
+
170
+ for (const [, service] of Object.entries(doc.services)) {
171
+ delete service.cap_drop;
172
+ delete service.cap_add;
173
+ delete service.security_opt;
174
+ }
175
+
176
+ return stringify(doc, { lineWidth: 200 });
177
+ }
178
+
179
+ /**
180
+ * Applies all PaaS-specific sanitisations to a Docker Compose YAML string:
181
+ * 1. Strips host port bindings (avoids "port already allocated" errors)
182
+ * 2. Strips local bind mounts (files don't exist on remote PaaS servers)
183
+ * 3. Strips security hardening (cap_drop/security_opt break user switching)
184
+ */
185
+ export function sanitizeComposeForPaas(composeYaml: string): string {
186
+ return stripSecurityHardening(stripLocalBindMounts(stripHostPorts(composeYaml)));
187
+ }