@better-openclaw/core 1.0.18 → 1.0.19

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.
@@ -78,40 +78,45 @@ var CoolifyDeployer = class {
78
78
  }
79
79
  }
80
80
  async deploy(input) {
81
+ const step1 = {
82
+ step: "Discover server",
83
+ status: "pending"
84
+ };
85
+ const step2 = {
86
+ step: "Create project",
87
+ status: "pending"
88
+ };
89
+ const step3 = {
90
+ step: "Create compose service",
91
+ status: "pending"
92
+ };
93
+ const step4 = {
94
+ step: "Set environment variables",
95
+ status: "pending"
96
+ };
97
+ const step5 = {
98
+ step: "Trigger deployment",
99
+ status: "pending"
100
+ };
81
101
  const steps = [
82
- {
83
- step: "Discover server",
84
- status: "pending"
85
- },
86
- {
87
- step: "Create project",
88
- status: "pending"
89
- },
90
- {
91
- step: "Create compose service",
92
- status: "pending"
93
- },
94
- {
95
- step: "Set environment variables",
96
- status: "pending"
97
- },
98
- {
99
- step: "Trigger deployment",
100
- status: "pending"
101
- }
102
+ step1,
103
+ step2,
104
+ step3,
105
+ step4,
106
+ step5
102
107
  ];
103
108
  const result = {
104
109
  success: false,
105
110
  steps
106
111
  };
107
112
  try {
108
- steps[0].status = "running";
113
+ step1.status = "running";
109
114
  const servers = await coolifyFetch(input.target, "/servers");
110
115
  if (!servers || servers.length === 0) throw new Error("No servers found in Coolify instance");
111
116
  const server = servers[0];
112
- steps[0].status = "done";
113
- steps[0].detail = `Server: ${server.name} (${server.ip})`;
114
- steps[1].status = "running";
117
+ step1.status = "done";
118
+ step1.detail = `Server: ${server.name} (${server.ip})`;
119
+ step2.status = "running";
115
120
  const project = await coolifyFetch(input.target, "/projects", {
116
121
  method: "POST",
117
122
  body: {
@@ -120,13 +125,13 @@ var CoolifyDeployer = class {
120
125
  }
121
126
  });
122
127
  result.projectId = project.uuid;
123
- steps[1].status = "done";
124
- steps[1].detail = `Project: ${project.uuid}`;
128
+ step2.status = "done";
129
+ step2.detail = `Project: ${project.uuid}`;
125
130
  const projectDetail = await coolifyFetch(input.target, `/projects/${project.uuid}`);
126
131
  const envUuid = projectDetail.environments?.[0]?.uuid;
127
132
  const envName = projectDetail.environments?.[0]?.name ?? "production";
128
133
  if (!envUuid) throw new Error("No default environment found in project");
129
- steps[2].status = "running";
134
+ step3.status = "running";
130
135
  const service = await coolifyFetch(input.target, "/services", {
131
136
  method: "POST",
132
137
  body: {
@@ -141,27 +146,27 @@ var CoolifyDeployer = class {
141
146
  }
142
147
  });
143
148
  result.composeId = service.uuid;
144
- steps[2].status = "done";
145
- steps[2].detail = `Service: ${service.uuid}`;
146
- steps[3].status = "running";
149
+ step3.status = "done";
150
+ step3.detail = `Service: ${service.uuid}`;
151
+ step4.status = "running";
147
152
  const envVars = parseEnvContent(input.envContent);
148
153
  if (envVars.length > 0) await coolifyFetch(input.target, `/services/${service.uuid}/envs`, {
149
154
  method: "PATCH",
150
155
  body: envVars
151
156
  });
152
- steps[3].status = "done";
153
- steps[3].detail = `${envVars.length} variables set`;
154
- steps[4].status = "running";
157
+ step4.status = "done";
158
+ step4.detail = `${envVars.length} variables set`;
159
+ step5.status = "running";
155
160
  const deployments = await coolifyFetch(input.target, `/deploy?uuid=${service.uuid}&force=true`);
156
- steps[4].status = "done";
157
- steps[4].detail = deployments?.deployments?.[0]?.deployment_uuid ?? "Deployment triggered";
161
+ step5.status = "done";
162
+ step5.detail = deployments?.deployments?.[0]?.deployment_uuid ?? "Deployment triggered";
158
163
  result.success = true;
159
164
  result.dashboardUrl = `${input.target.instanceUrl.replace(/\/+$/, "")}/project/${project.uuid}`;
160
165
  } catch (err) {
161
- const failedIdx = steps.findIndex((s) => s.status === "running");
162
- if (failedIdx >= 0) {
163
- steps[failedIdx].status = "error";
164
- steps[failedIdx].detail = err instanceof Error ? err.message : String(err);
166
+ const failedStep = steps.find((s) => s.status === "running");
167
+ if (failedStep) {
168
+ failedStep.status = "error";
169
+ failedStep.detail = err instanceof Error ? err.message : String(err);
165
170
  }
166
171
  result.error = err instanceof Error ? err.message : String(err);
167
172
  }
@@ -1 +1 @@
1
- {"version":3,"file":"coolify.cjs","names":[],"sources":["../../src/deployers/coolify.ts"],"sourcesContent":["/**\n * Coolify PaaS deployer — deploys Docker Compose stacks via the Coolify v4 REST API.\n *\n * API docs: https://coolify.io/docs/api-reference/api/\n * Auth: Authorization: Bearer <token>\n * Base path: /api/v1\n */\n\nimport type { DeployInput, DeployResult, DeployStep, DeployTarget, PaasDeployer } from \"./types.js\";\n\n/** Shape returned by Coolify's project endpoints. */\ninterface CoolifyProject {\n\tuuid: string;\n\tname: string;\n\tenvironments?: { uuid: string; name: string }[];\n}\n\n/** Shape returned by Coolify's server listing. */\ninterface CoolifyServer {\n\tuuid: string;\n\tname: string;\n\tip: string;\n}\n\n/** Shape returned when creating a Coolify service (compose stack). */\ninterface CoolifyService {\n\tuuid: string;\n}\n\n/** Shape returned by Coolify's deploy trigger endpoint. */\ninterface CoolifyDeployment {\n\tmessage: string;\n\tresource_uuid: string;\n\tdeployment_uuid: string;\n}\n\n/** Build a full Coolify API URL (base + /api/v1 + path). */\nfunction apiUrl(target: DeployTarget, path: string): string {\n\tconst base = target.instanceUrl.replace(/\\/+$/, \"\");\n\treturn `${base}/api/v1${path}`;\n}\n\n/**\n * Typed fetch wrapper for the Coolify v4 API.\n * Handles JSON serialisation, Bearer auth, and error extraction.\n */\nasync function coolifyFetch<T>(\n\ttarget: DeployTarget,\n\tpath: string,\n\toptions: { method?: string; body?: unknown } = {},\n): Promise<T> {\n\tconst res = await fetch(apiUrl(target, path), {\n\t\tmethod: options.method ?? \"GET\",\n\t\theaders: {\n\t\t\t\"Content-Type\": \"application/json\",\n\t\t\tAuthorization: `Bearer ${target.apiKey}`,\n\t\t},\n\t\tbody: options.body ? JSON.stringify(options.body) : undefined,\n\t});\n\n\tif (!res.ok) {\n\t\tconst text = await res.text().catch(() => \"\");\n\t\tlet detail = text;\n\t\ttry {\n\t\t\tconst json = JSON.parse(text);\n\t\t\tdetail = json.message || json.error || text;\n\t\t} catch {\n\t\t\t// use raw text\n\t\t}\n\t\tthrow new Error(`Coolify API ${res.status}: ${detail}`);\n\t}\n\n\tconst text = await res.text();\n\tif (!text) return undefined as T;\n\treturn JSON.parse(text) as T;\n}\n\n/**\n * Parse .env content into key-value pairs for Coolify's bulk env API.\n */\nfunction parseEnvContent(envContent: string): {\n\tkey: string;\n\tvalue: string;\n\tis_preview: boolean;\n\tis_build_time: boolean;\n\tis_literal: boolean;\n}[] {\n\tconst result: {\n\t\tkey: string;\n\t\tvalue: string;\n\t\tis_preview: boolean;\n\t\tis_build_time: boolean;\n\t\tis_literal: boolean;\n\t}[] = [];\n\n\tfor (const line of envContent.split(\"\\n\")) {\n\t\tconst trimmed = line.trim();\n\t\tif (!trimmed || trimmed.startsWith(\"#\")) continue;\n\n\t\tconst eqIdx = trimmed.indexOf(\"=\");\n\t\tif (eqIdx <= 0) continue;\n\n\t\tconst key = trimmed.slice(0, eqIdx);\n\t\tconst value = trimmed.slice(eqIdx + 1);\n\n\t\tresult.push({\n\t\t\tkey,\n\t\t\tvalue,\n\t\t\tis_preview: false,\n\t\t\tis_build_time: false,\n\t\t\tis_literal: true,\n\t\t});\n\t}\n\n\treturn result;\n}\n\n/**\n * Deploys Docker Compose stacks to a Coolify v4 instance.\n *\n * Deploy flow (5 steps):\n * 1. Discover the first available server\n * 2. Create a Coolify project\n * 3. Create a compose service with the raw docker-compose YAML\n * 4. Push .env variables via the bulk env API\n * 5. Trigger the deployment\n */\nexport class CoolifyDeployer implements PaasDeployer {\n\treadonly name = \"Coolify\";\n\treadonly id = \"coolify\";\n\n\tasync testConnection(target: DeployTarget): Promise<{ ok: boolean; error?: string }> {\n\t\ttry {\n\t\t\tawait coolifyFetch<unknown>(target, \"/version\");\n\t\t\treturn { ok: true };\n\t\t} catch (err) {\n\t\t\treturn { ok: false, error: err instanceof Error ? err.message : String(err) };\n\t\t}\n\t}\n\n\tasync deploy(input: DeployInput): Promise<DeployResult> {\n\t\tconst steps: DeployStep[] = [\n\t\t\t{ step: \"Discover server\", status: \"pending\" },\n\t\t\t{ step: \"Create project\", status: \"pending\" },\n\t\t\t{ step: \"Create compose service\", status: \"pending\" },\n\t\t\t{ step: \"Set environment variables\", status: \"pending\" },\n\t\t\t{ step: \"Trigger deployment\", status: \"pending\" },\n\t\t];\n\n\t\tconst result: DeployResult = { success: false, steps };\n\n\t\ttry {\n\t\t\t// Step 1: Discover default server\n\t\t\tsteps[0].status = \"running\";\n\t\t\tconst servers = await coolifyFetch<CoolifyServer[]>(input.target, \"/servers\");\n\t\t\tif (!servers || servers.length === 0) {\n\t\t\t\tthrow new Error(\"No servers found in Coolify instance\");\n\t\t\t}\n\t\t\tconst server = servers[0];\n\t\t\tsteps[0].status = \"done\";\n\t\t\tsteps[0].detail = `Server: ${server.name} (${server.ip})`;\n\n\t\t\t// Step 2: Create project\n\t\t\tsteps[1].status = \"running\";\n\t\t\tconst project = await coolifyFetch<CoolifyProject>(input.target, \"/projects\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: {\n\t\t\t\t\tname: input.projectName,\n\t\t\t\t\tdescription: input.description ?? `OpenClaw stack: ${input.projectName}`,\n\t\t\t\t},\n\t\t\t});\n\t\t\tresult.projectId = project.uuid;\n\t\t\tsteps[1].status = \"done\";\n\t\t\tsteps[1].detail = `Project: ${project.uuid}`;\n\n\t\t\t// Get the default environment\n\t\t\tconst projectDetail = await coolifyFetch<CoolifyProject>(\n\t\t\t\tinput.target,\n\t\t\t\t`/projects/${project.uuid}`,\n\t\t\t);\n\t\t\tconst envUuid = projectDetail.environments?.[0]?.uuid;\n\t\t\tconst envName = projectDetail.environments?.[0]?.name ?? \"production\";\n\t\t\tif (!envUuid) {\n\t\t\t\tthrow new Error(\"No default environment found in project\");\n\t\t\t}\n\n\t\t\t// Step 3: Create compose service with docker_compose_raw\n\t\t\tsteps[2].status = \"running\";\n\t\t\tconst service = await coolifyFetch<CoolifyService>(input.target, \"/services\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: {\n\t\t\t\t\tproject_uuid: project.uuid,\n\t\t\t\t\tserver_uuid: server.uuid,\n\t\t\t\t\tenvironment_name: envName,\n\t\t\t\t\tenvironment_uuid: envUuid,\n\t\t\t\t\tdocker_compose_raw: input.composeYaml,\n\t\t\t\t\tname: input.projectName,\n\t\t\t\t\tdescription: input.description ?? \"Deployed via OpenClaw web builder\",\n\t\t\t\t\tinstant_deploy: false,\n\t\t\t\t},\n\t\t\t});\n\t\t\tresult.composeId = service.uuid;\n\t\t\tsteps[2].status = \"done\";\n\t\t\tsteps[2].detail = `Service: ${service.uuid}`;\n\n\t\t\t// Step 4: Set environment variables\n\t\t\tsteps[3].status = \"running\";\n\t\t\tconst envVars = parseEnvContent(input.envContent);\n\t\t\tif (envVars.length > 0) {\n\t\t\t\tawait coolifyFetch(input.target, `/services/${service.uuid}/envs`, {\n\t\t\t\t\tmethod: \"PATCH\",\n\t\t\t\t\tbody: envVars,\n\t\t\t\t});\n\t\t\t}\n\t\t\tsteps[3].status = \"done\";\n\t\t\tsteps[3].detail = `${envVars.length} variables set`;\n\n\t\t\t// Step 5: Trigger deployment\n\t\t\tsteps[4].status = \"running\";\n\t\t\tconst deployments = await coolifyFetch<{ deployments: CoolifyDeployment[] }>(\n\t\t\t\tinput.target,\n\t\t\t\t`/deploy?uuid=${service.uuid}&force=true`,\n\t\t\t);\n\t\t\tsteps[4].status = \"done\";\n\t\t\tsteps[4].detail = deployments?.deployments?.[0]?.deployment_uuid ?? \"Deployment triggered\";\n\n\t\t\tresult.success = true;\n\t\t\tconst base = input.target.instanceUrl.replace(/\\/+$/, \"\");\n\t\t\tresult.dashboardUrl = `${base}/project/${project.uuid}`;\n\t\t} catch (err) {\n\t\t\tconst failedIdx = steps.findIndex((s) => s.status === \"running\");\n\t\t\tif (failedIdx >= 0) {\n\t\t\t\tsteps[failedIdx].status = \"error\";\n\t\t\t\tsteps[failedIdx].detail = err instanceof Error ? err.message : String(err);\n\t\t\t}\n\t\t\tresult.error = err instanceof Error ? err.message : String(err);\n\t\t}\n\n\t\treturn result;\n\t}\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"coolify.cjs","names":[],"sources":["../../src/deployers/coolify.ts"],"sourcesContent":["/**\n * Coolify PaaS deployer — deploys Docker Compose stacks via the Coolify v4 REST API.\n *\n * API docs: https://coolify.io/docs/api-reference/api/\n * Auth: Authorization: Bearer <token>\n * Base path: /api/v1\n */\n\nimport type { DeployInput, DeployResult, DeployStep, DeployTarget, PaasDeployer } from \"./types.js\";\n\n/** Shape returned by Coolify's project endpoints. */\ninterface CoolifyProject {\n\tuuid: string;\n\tname: string;\n\tenvironments?: { uuid: string; name: string }[];\n}\n\n/** Shape returned by Coolify's server listing. */\ninterface CoolifyServer {\n\tuuid: string;\n\tname: string;\n\tip: string;\n}\n\n/** Shape returned when creating a Coolify service (compose stack). */\ninterface CoolifyService {\n\tuuid: string;\n}\n\n/** Shape returned by Coolify's deploy trigger endpoint. */\ninterface CoolifyDeployment {\n\tmessage: string;\n\tresource_uuid: string;\n\tdeployment_uuid: string;\n}\n\n/** Build a full Coolify API URL (base + /api/v1 + path). */\nfunction apiUrl(target: DeployTarget, path: string): string {\n\tconst base = target.instanceUrl.replace(/\\/+$/, \"\");\n\treturn `${base}/api/v1${path}`;\n}\n\n/**\n * Typed fetch wrapper for the Coolify v4 API.\n * Handles JSON serialisation, Bearer auth, and error extraction.\n */\nasync function coolifyFetch<T>(\n\ttarget: DeployTarget,\n\tpath: string,\n\toptions: { method?: string; body?: unknown } = {},\n): Promise<T> {\n\tconst res = await fetch(apiUrl(target, path), {\n\t\tmethod: options.method ?? \"GET\",\n\t\theaders: {\n\t\t\t\"Content-Type\": \"application/json\",\n\t\t\tAuthorization: `Bearer ${target.apiKey}`,\n\t\t},\n\t\tbody: options.body ? JSON.stringify(options.body) : undefined,\n\t});\n\n\tif (!res.ok) {\n\t\tconst text = await res.text().catch(() => \"\");\n\t\tlet detail = text;\n\t\ttry {\n\t\t\tconst json = JSON.parse(text);\n\t\t\tdetail = json.message || json.error || text;\n\t\t} catch {\n\t\t\t// use raw text\n\t\t}\n\t\tthrow new Error(`Coolify API ${res.status}: ${detail}`);\n\t}\n\n\tconst text = await res.text();\n\tif (!text) return undefined as T;\n\treturn JSON.parse(text) as T;\n}\n\n/**\n * Parse .env content into key-value pairs for Coolify's bulk env API.\n */\nfunction parseEnvContent(envContent: string): {\n\tkey: string;\n\tvalue: string;\n\tis_preview: boolean;\n\tis_build_time: boolean;\n\tis_literal: boolean;\n}[] {\n\tconst result: {\n\t\tkey: string;\n\t\tvalue: string;\n\t\tis_preview: boolean;\n\t\tis_build_time: boolean;\n\t\tis_literal: boolean;\n\t}[] = [];\n\n\tfor (const line of envContent.split(\"\\n\")) {\n\t\tconst trimmed = line.trim();\n\t\tif (!trimmed || trimmed.startsWith(\"#\")) continue;\n\n\t\tconst eqIdx = trimmed.indexOf(\"=\");\n\t\tif (eqIdx <= 0) continue;\n\n\t\tconst key = trimmed.slice(0, eqIdx);\n\t\tconst value = trimmed.slice(eqIdx + 1);\n\n\t\tresult.push({\n\t\t\tkey,\n\t\t\tvalue,\n\t\t\tis_preview: false,\n\t\t\tis_build_time: false,\n\t\t\tis_literal: true,\n\t\t});\n\t}\n\n\treturn result;\n}\n\n/**\n * Deploys Docker Compose stacks to a Coolify v4 instance.\n *\n * Deploy flow (5 steps):\n * 1. Discover the first available server\n * 2. Create a Coolify project\n * 3. Create a compose service with the raw docker-compose YAML\n * 4. Push .env variables via the bulk env API\n * 5. Trigger the deployment\n */\nexport class CoolifyDeployer implements PaasDeployer {\n\treadonly name = \"Coolify\";\n\treadonly id = \"coolify\";\n\n\tasync testConnection(target: DeployTarget): Promise<{ ok: boolean; error?: string }> {\n\t\ttry {\n\t\t\tawait coolifyFetch<unknown>(target, \"/version\");\n\t\t\treturn { ok: true };\n\t\t} catch (err) {\n\t\t\treturn { ok: false, error: err instanceof Error ? err.message : String(err) };\n\t\t}\n\t}\n\n\tasync deploy(input: DeployInput): Promise<DeployResult> {\n\t\tconst step1: DeployStep = { step: \"Discover server\", status: \"pending\" };\n\t\tconst step2: DeployStep = { step: \"Create project\", status: \"pending\" };\n\t\tconst step3: DeployStep = { step: \"Create compose service\", status: \"pending\" };\n\t\tconst step4: DeployStep = { step: \"Set environment variables\", status: \"pending\" };\n\t\tconst step5: DeployStep = { step: \"Trigger deployment\", status: \"pending\" };\n\t\tconst steps: DeployStep[] = [step1, step2, step3, step4, step5];\n\n\t\tconst result: DeployResult = { success: false, steps };\n\n\t\ttry {\n\t\t\t// Step 1: Discover default server\n\t\t\tstep1.status = \"running\";\n\t\t\tconst servers = await coolifyFetch<CoolifyServer[]>(input.target, \"/servers\");\n\t\t\tif (!servers || servers.length === 0) {\n\t\t\t\tthrow new Error(\"No servers found in Coolify instance\");\n\t\t\t}\n\t\t\tconst server = servers[0] as CoolifyServer;\n\t\t\tstep1.status = \"done\";\n\t\t\tstep1.detail = `Server: ${server.name} (${server.ip})`;\n\n\t\t\t// Step 2: Create project\n\t\t\tstep2.status = \"running\";\n\t\t\tconst project = await coolifyFetch<CoolifyProject>(input.target, \"/projects\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: {\n\t\t\t\t\tname: input.projectName,\n\t\t\t\t\tdescription: input.description ?? `OpenClaw stack: ${input.projectName}`,\n\t\t\t\t},\n\t\t\t});\n\t\t\tresult.projectId = project.uuid;\n\t\t\tstep2.status = \"done\";\n\t\t\tstep2.detail = `Project: ${project.uuid}`;\n\n\t\t\t// Get the default environment\n\t\t\tconst projectDetail = await coolifyFetch<CoolifyProject>(\n\t\t\t\tinput.target,\n\t\t\t\t`/projects/${project.uuid}`,\n\t\t\t);\n\t\t\tconst envUuid = projectDetail.environments?.[0]?.uuid;\n\t\t\tconst envName = projectDetail.environments?.[0]?.name ?? \"production\";\n\t\t\tif (!envUuid) {\n\t\t\t\tthrow new Error(\"No default environment found in project\");\n\t\t\t}\n\n\t\t\t// Step 3: Create compose service with docker_compose_raw\n\t\t\tstep3.status = \"running\";\n\t\t\tconst service = await coolifyFetch<CoolifyService>(input.target, \"/services\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: {\n\t\t\t\t\tproject_uuid: project.uuid,\n\t\t\t\t\tserver_uuid: server.uuid,\n\t\t\t\t\tenvironment_name: envName,\n\t\t\t\t\tenvironment_uuid: envUuid,\n\t\t\t\t\tdocker_compose_raw: input.composeYaml,\n\t\t\t\t\tname: input.projectName,\n\t\t\t\t\tdescription: input.description ?? \"Deployed via OpenClaw web builder\",\n\t\t\t\t\tinstant_deploy: false,\n\t\t\t\t},\n\t\t\t});\n\t\t\tresult.composeId = service.uuid;\n\t\t\tstep3.status = \"done\";\n\t\t\tstep3.detail = `Service: ${service.uuid}`;\n\n\t\t\t// Step 4: Set environment variables\n\t\t\tstep4.status = \"running\";\n\t\t\tconst envVars = parseEnvContent(input.envContent);\n\t\t\tif (envVars.length > 0) {\n\t\t\t\tawait coolifyFetch(input.target, `/services/${service.uuid}/envs`, {\n\t\t\t\t\tmethod: \"PATCH\",\n\t\t\t\t\tbody: envVars,\n\t\t\t\t});\n\t\t\t}\n\t\t\tstep4.status = \"done\";\n\t\t\tstep4.detail = `${envVars.length} variables set`;\n\n\t\t\t// Step 5: Trigger deployment\n\t\t\tstep5.status = \"running\";\n\t\t\tconst deployments = await coolifyFetch<{ deployments: CoolifyDeployment[] }>(\n\t\t\t\tinput.target,\n\t\t\t\t`/deploy?uuid=${service.uuid}&force=true`,\n\t\t\t);\n\t\t\tstep5.status = \"done\";\n\t\t\tstep5.detail = deployments?.deployments?.[0]?.deployment_uuid ?? \"Deployment triggered\";\n\n\t\t\tresult.success = true;\n\t\t\tconst base = input.target.instanceUrl.replace(/\\/+$/, \"\");\n\t\t\tresult.dashboardUrl = `${base}/project/${project.uuid}`;\n\t\t} catch (err) {\n\t\t\tconst failedStep = steps.find((s) => s.status === \"running\");\n\t\t\tif (failedStep) {\n\t\t\t\tfailedStep.status = \"error\";\n\t\t\t\tfailedStep.detail = err instanceof Error ? err.message : String(err);\n\t\t\t}\n\t\t\tresult.error = err instanceof Error ? err.message : String(err);\n\t\t}\n\n\t\treturn result;\n\t}\n}\n"],"mappings":"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"}
@@ -76,40 +76,45 @@ var CoolifyDeployer = class {
76
76
  }
77
77
  }
78
78
  async deploy(input) {
79
+ const step1 = {
80
+ step: "Discover server",
81
+ status: "pending"
82
+ };
83
+ const step2 = {
84
+ step: "Create project",
85
+ status: "pending"
86
+ };
87
+ const step3 = {
88
+ step: "Create compose service",
89
+ status: "pending"
90
+ };
91
+ const step4 = {
92
+ step: "Set environment variables",
93
+ status: "pending"
94
+ };
95
+ const step5 = {
96
+ step: "Trigger deployment",
97
+ status: "pending"
98
+ };
79
99
  const steps = [
80
- {
81
- step: "Discover server",
82
- status: "pending"
83
- },
84
- {
85
- step: "Create project",
86
- status: "pending"
87
- },
88
- {
89
- step: "Create compose service",
90
- status: "pending"
91
- },
92
- {
93
- step: "Set environment variables",
94
- status: "pending"
95
- },
96
- {
97
- step: "Trigger deployment",
98
- status: "pending"
99
- }
100
+ step1,
101
+ step2,
102
+ step3,
103
+ step4,
104
+ step5
100
105
  ];
101
106
  const result = {
102
107
  success: false,
103
108
  steps
104
109
  };
105
110
  try {
106
- steps[0].status = "running";
111
+ step1.status = "running";
107
112
  const servers = await coolifyFetch(input.target, "/servers");
108
113
  if (!servers || servers.length === 0) throw new Error("No servers found in Coolify instance");
109
114
  const server = servers[0];
110
- steps[0].status = "done";
111
- steps[0].detail = `Server: ${server.name} (${server.ip})`;
112
- steps[1].status = "running";
115
+ step1.status = "done";
116
+ step1.detail = `Server: ${server.name} (${server.ip})`;
117
+ step2.status = "running";
113
118
  const project = await coolifyFetch(input.target, "/projects", {
114
119
  method: "POST",
115
120
  body: {
@@ -118,13 +123,13 @@ var CoolifyDeployer = class {
118
123
  }
119
124
  });
120
125
  result.projectId = project.uuid;
121
- steps[1].status = "done";
122
- steps[1].detail = `Project: ${project.uuid}`;
126
+ step2.status = "done";
127
+ step2.detail = `Project: ${project.uuid}`;
123
128
  const projectDetail = await coolifyFetch(input.target, `/projects/${project.uuid}`);
124
129
  const envUuid = projectDetail.environments?.[0]?.uuid;
125
130
  const envName = projectDetail.environments?.[0]?.name ?? "production";
126
131
  if (!envUuid) throw new Error("No default environment found in project");
127
- steps[2].status = "running";
132
+ step3.status = "running";
128
133
  const service = await coolifyFetch(input.target, "/services", {
129
134
  method: "POST",
130
135
  body: {
@@ -139,27 +144,27 @@ var CoolifyDeployer = class {
139
144
  }
140
145
  });
141
146
  result.composeId = service.uuid;
142
- steps[2].status = "done";
143
- steps[2].detail = `Service: ${service.uuid}`;
144
- steps[3].status = "running";
147
+ step3.status = "done";
148
+ step3.detail = `Service: ${service.uuid}`;
149
+ step4.status = "running";
145
150
  const envVars = parseEnvContent(input.envContent);
146
151
  if (envVars.length > 0) await coolifyFetch(input.target, `/services/${service.uuid}/envs`, {
147
152
  method: "PATCH",
148
153
  body: envVars
149
154
  });
150
- steps[3].status = "done";
151
- steps[3].detail = `${envVars.length} variables set`;
152
- steps[4].status = "running";
155
+ step4.status = "done";
156
+ step4.detail = `${envVars.length} variables set`;
157
+ step5.status = "running";
153
158
  const deployments = await coolifyFetch(input.target, `/deploy?uuid=${service.uuid}&force=true`);
154
- steps[4].status = "done";
155
- steps[4].detail = deployments?.deployments?.[0]?.deployment_uuid ?? "Deployment triggered";
159
+ step5.status = "done";
160
+ step5.detail = deployments?.deployments?.[0]?.deployment_uuid ?? "Deployment triggered";
156
161
  result.success = true;
157
162
  result.dashboardUrl = `${input.target.instanceUrl.replace(/\/+$/, "")}/project/${project.uuid}`;
158
163
  } catch (err) {
159
- const failedIdx = steps.findIndex((s) => s.status === "running");
160
- if (failedIdx >= 0) {
161
- steps[failedIdx].status = "error";
162
- steps[failedIdx].detail = err instanceof Error ? err.message : String(err);
164
+ const failedStep = steps.find((s) => s.status === "running");
165
+ if (failedStep) {
166
+ failedStep.status = "error";
167
+ failedStep.detail = err instanceof Error ? err.message : String(err);
163
168
  }
164
169
  result.error = err instanceof Error ? err.message : String(err);
165
170
  }
@@ -1 +1 @@
1
- {"version":3,"file":"coolify.mjs","names":[],"sources":["../../src/deployers/coolify.ts"],"sourcesContent":["/**\n * Coolify PaaS deployer — deploys Docker Compose stacks via the Coolify v4 REST API.\n *\n * API docs: https://coolify.io/docs/api-reference/api/\n * Auth: Authorization: Bearer <token>\n * Base path: /api/v1\n */\n\nimport type { DeployInput, DeployResult, DeployStep, DeployTarget, PaasDeployer } from \"./types.js\";\n\n/** Shape returned by Coolify's project endpoints. */\ninterface CoolifyProject {\n\tuuid: string;\n\tname: string;\n\tenvironments?: { uuid: string; name: string }[];\n}\n\n/** Shape returned by Coolify's server listing. */\ninterface CoolifyServer {\n\tuuid: string;\n\tname: string;\n\tip: string;\n}\n\n/** Shape returned when creating a Coolify service (compose stack). */\ninterface CoolifyService {\n\tuuid: string;\n}\n\n/** Shape returned by Coolify's deploy trigger endpoint. */\ninterface CoolifyDeployment {\n\tmessage: string;\n\tresource_uuid: string;\n\tdeployment_uuid: string;\n}\n\n/** Build a full Coolify API URL (base + /api/v1 + path). */\nfunction apiUrl(target: DeployTarget, path: string): string {\n\tconst base = target.instanceUrl.replace(/\\/+$/, \"\");\n\treturn `${base}/api/v1${path}`;\n}\n\n/**\n * Typed fetch wrapper for the Coolify v4 API.\n * Handles JSON serialisation, Bearer auth, and error extraction.\n */\nasync function coolifyFetch<T>(\n\ttarget: DeployTarget,\n\tpath: string,\n\toptions: { method?: string; body?: unknown } = {},\n): Promise<T> {\n\tconst res = await fetch(apiUrl(target, path), {\n\t\tmethod: options.method ?? \"GET\",\n\t\theaders: {\n\t\t\t\"Content-Type\": \"application/json\",\n\t\t\tAuthorization: `Bearer ${target.apiKey}`,\n\t\t},\n\t\tbody: options.body ? JSON.stringify(options.body) : undefined,\n\t});\n\n\tif (!res.ok) {\n\t\tconst text = await res.text().catch(() => \"\");\n\t\tlet detail = text;\n\t\ttry {\n\t\t\tconst json = JSON.parse(text);\n\t\t\tdetail = json.message || json.error || text;\n\t\t} catch {\n\t\t\t// use raw text\n\t\t}\n\t\tthrow new Error(`Coolify API ${res.status}: ${detail}`);\n\t}\n\n\tconst text = await res.text();\n\tif (!text) return undefined as T;\n\treturn JSON.parse(text) as T;\n}\n\n/**\n * Parse .env content into key-value pairs for Coolify's bulk env API.\n */\nfunction parseEnvContent(envContent: string): {\n\tkey: string;\n\tvalue: string;\n\tis_preview: boolean;\n\tis_build_time: boolean;\n\tis_literal: boolean;\n}[] {\n\tconst result: {\n\t\tkey: string;\n\t\tvalue: string;\n\t\tis_preview: boolean;\n\t\tis_build_time: boolean;\n\t\tis_literal: boolean;\n\t}[] = [];\n\n\tfor (const line of envContent.split(\"\\n\")) {\n\t\tconst trimmed = line.trim();\n\t\tif (!trimmed || trimmed.startsWith(\"#\")) continue;\n\n\t\tconst eqIdx = trimmed.indexOf(\"=\");\n\t\tif (eqIdx <= 0) continue;\n\n\t\tconst key = trimmed.slice(0, eqIdx);\n\t\tconst value = trimmed.slice(eqIdx + 1);\n\n\t\tresult.push({\n\t\t\tkey,\n\t\t\tvalue,\n\t\t\tis_preview: false,\n\t\t\tis_build_time: false,\n\t\t\tis_literal: true,\n\t\t});\n\t}\n\n\treturn result;\n}\n\n/**\n * Deploys Docker Compose stacks to a Coolify v4 instance.\n *\n * Deploy flow (5 steps):\n * 1. Discover the first available server\n * 2. Create a Coolify project\n * 3. Create a compose service with the raw docker-compose YAML\n * 4. Push .env variables via the bulk env API\n * 5. Trigger the deployment\n */\nexport class CoolifyDeployer implements PaasDeployer {\n\treadonly name = \"Coolify\";\n\treadonly id = \"coolify\";\n\n\tasync testConnection(target: DeployTarget): Promise<{ ok: boolean; error?: string }> {\n\t\ttry {\n\t\t\tawait coolifyFetch<unknown>(target, \"/version\");\n\t\t\treturn { ok: true };\n\t\t} catch (err) {\n\t\t\treturn { ok: false, error: err instanceof Error ? err.message : String(err) };\n\t\t}\n\t}\n\n\tasync deploy(input: DeployInput): Promise<DeployResult> {\n\t\tconst steps: DeployStep[] = [\n\t\t\t{ step: \"Discover server\", status: \"pending\" },\n\t\t\t{ step: \"Create project\", status: \"pending\" },\n\t\t\t{ step: \"Create compose service\", status: \"pending\" },\n\t\t\t{ step: \"Set environment variables\", status: \"pending\" },\n\t\t\t{ step: \"Trigger deployment\", status: \"pending\" },\n\t\t];\n\n\t\tconst result: DeployResult = { success: false, steps };\n\n\t\ttry {\n\t\t\t// Step 1: Discover default server\n\t\t\tsteps[0].status = \"running\";\n\t\t\tconst servers = await coolifyFetch<CoolifyServer[]>(input.target, \"/servers\");\n\t\t\tif (!servers || servers.length === 0) {\n\t\t\t\tthrow new Error(\"No servers found in Coolify instance\");\n\t\t\t}\n\t\t\tconst server = servers[0];\n\t\t\tsteps[0].status = \"done\";\n\t\t\tsteps[0].detail = `Server: ${server.name} (${server.ip})`;\n\n\t\t\t// Step 2: Create project\n\t\t\tsteps[1].status = \"running\";\n\t\t\tconst project = await coolifyFetch<CoolifyProject>(input.target, \"/projects\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: {\n\t\t\t\t\tname: input.projectName,\n\t\t\t\t\tdescription: input.description ?? `OpenClaw stack: ${input.projectName}`,\n\t\t\t\t},\n\t\t\t});\n\t\t\tresult.projectId = project.uuid;\n\t\t\tsteps[1].status = \"done\";\n\t\t\tsteps[1].detail = `Project: ${project.uuid}`;\n\n\t\t\t// Get the default environment\n\t\t\tconst projectDetail = await coolifyFetch<CoolifyProject>(\n\t\t\t\tinput.target,\n\t\t\t\t`/projects/${project.uuid}`,\n\t\t\t);\n\t\t\tconst envUuid = projectDetail.environments?.[0]?.uuid;\n\t\t\tconst envName = projectDetail.environments?.[0]?.name ?? \"production\";\n\t\t\tif (!envUuid) {\n\t\t\t\tthrow new Error(\"No default environment found in project\");\n\t\t\t}\n\n\t\t\t// Step 3: Create compose service with docker_compose_raw\n\t\t\tsteps[2].status = \"running\";\n\t\t\tconst service = await coolifyFetch<CoolifyService>(input.target, \"/services\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: {\n\t\t\t\t\tproject_uuid: project.uuid,\n\t\t\t\t\tserver_uuid: server.uuid,\n\t\t\t\t\tenvironment_name: envName,\n\t\t\t\t\tenvironment_uuid: envUuid,\n\t\t\t\t\tdocker_compose_raw: input.composeYaml,\n\t\t\t\t\tname: input.projectName,\n\t\t\t\t\tdescription: input.description ?? \"Deployed via OpenClaw web builder\",\n\t\t\t\t\tinstant_deploy: false,\n\t\t\t\t},\n\t\t\t});\n\t\t\tresult.composeId = service.uuid;\n\t\t\tsteps[2].status = \"done\";\n\t\t\tsteps[2].detail = `Service: ${service.uuid}`;\n\n\t\t\t// Step 4: Set environment variables\n\t\t\tsteps[3].status = \"running\";\n\t\t\tconst envVars = parseEnvContent(input.envContent);\n\t\t\tif (envVars.length > 0) {\n\t\t\t\tawait coolifyFetch(input.target, `/services/${service.uuid}/envs`, {\n\t\t\t\t\tmethod: \"PATCH\",\n\t\t\t\t\tbody: envVars,\n\t\t\t\t});\n\t\t\t}\n\t\t\tsteps[3].status = \"done\";\n\t\t\tsteps[3].detail = `${envVars.length} variables set`;\n\n\t\t\t// Step 5: Trigger deployment\n\t\t\tsteps[4].status = \"running\";\n\t\t\tconst deployments = await coolifyFetch<{ deployments: CoolifyDeployment[] }>(\n\t\t\t\tinput.target,\n\t\t\t\t`/deploy?uuid=${service.uuid}&force=true`,\n\t\t\t);\n\t\t\tsteps[4].status = \"done\";\n\t\t\tsteps[4].detail = deployments?.deployments?.[0]?.deployment_uuid ?? \"Deployment triggered\";\n\n\t\t\tresult.success = true;\n\t\t\tconst base = input.target.instanceUrl.replace(/\\/+$/, \"\");\n\t\t\tresult.dashboardUrl = `${base}/project/${project.uuid}`;\n\t\t} catch (err) {\n\t\t\tconst failedIdx = steps.findIndex((s) => s.status === \"running\");\n\t\t\tif (failedIdx >= 0) {\n\t\t\t\tsteps[failedIdx].status = \"error\";\n\t\t\t\tsteps[failedIdx].detail = err instanceof Error ? err.message : String(err);\n\t\t\t}\n\t\t\tresult.error = err instanceof Error ? err.message : String(err);\n\t\t}\n\n\t\treturn result;\n\t}\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"coolify.mjs","names":[],"sources":["../../src/deployers/coolify.ts"],"sourcesContent":["/**\n * Coolify PaaS deployer — deploys Docker Compose stacks via the Coolify v4 REST API.\n *\n * API docs: https://coolify.io/docs/api-reference/api/\n * Auth: Authorization: Bearer <token>\n * Base path: /api/v1\n */\n\nimport type { DeployInput, DeployResult, DeployStep, DeployTarget, PaasDeployer } from \"./types.js\";\n\n/** Shape returned by Coolify's project endpoints. */\ninterface CoolifyProject {\n\tuuid: string;\n\tname: string;\n\tenvironments?: { uuid: string; name: string }[];\n}\n\n/** Shape returned by Coolify's server listing. */\ninterface CoolifyServer {\n\tuuid: string;\n\tname: string;\n\tip: string;\n}\n\n/** Shape returned when creating a Coolify service (compose stack). */\ninterface CoolifyService {\n\tuuid: string;\n}\n\n/** Shape returned by Coolify's deploy trigger endpoint. */\ninterface CoolifyDeployment {\n\tmessage: string;\n\tresource_uuid: string;\n\tdeployment_uuid: string;\n}\n\n/** Build a full Coolify API URL (base + /api/v1 + path). */\nfunction apiUrl(target: DeployTarget, path: string): string {\n\tconst base = target.instanceUrl.replace(/\\/+$/, \"\");\n\treturn `${base}/api/v1${path}`;\n}\n\n/**\n * Typed fetch wrapper for the Coolify v4 API.\n * Handles JSON serialisation, Bearer auth, and error extraction.\n */\nasync function coolifyFetch<T>(\n\ttarget: DeployTarget,\n\tpath: string,\n\toptions: { method?: string; body?: unknown } = {},\n): Promise<T> {\n\tconst res = await fetch(apiUrl(target, path), {\n\t\tmethod: options.method ?? \"GET\",\n\t\theaders: {\n\t\t\t\"Content-Type\": \"application/json\",\n\t\t\tAuthorization: `Bearer ${target.apiKey}`,\n\t\t},\n\t\tbody: options.body ? JSON.stringify(options.body) : undefined,\n\t});\n\n\tif (!res.ok) {\n\t\tconst text = await res.text().catch(() => \"\");\n\t\tlet detail = text;\n\t\ttry {\n\t\t\tconst json = JSON.parse(text);\n\t\t\tdetail = json.message || json.error || text;\n\t\t} catch {\n\t\t\t// use raw text\n\t\t}\n\t\tthrow new Error(`Coolify API ${res.status}: ${detail}`);\n\t}\n\n\tconst text = await res.text();\n\tif (!text) return undefined as T;\n\treturn JSON.parse(text) as T;\n}\n\n/**\n * Parse .env content into key-value pairs for Coolify's bulk env API.\n */\nfunction parseEnvContent(envContent: string): {\n\tkey: string;\n\tvalue: string;\n\tis_preview: boolean;\n\tis_build_time: boolean;\n\tis_literal: boolean;\n}[] {\n\tconst result: {\n\t\tkey: string;\n\t\tvalue: string;\n\t\tis_preview: boolean;\n\t\tis_build_time: boolean;\n\t\tis_literal: boolean;\n\t}[] = [];\n\n\tfor (const line of envContent.split(\"\\n\")) {\n\t\tconst trimmed = line.trim();\n\t\tif (!trimmed || trimmed.startsWith(\"#\")) continue;\n\n\t\tconst eqIdx = trimmed.indexOf(\"=\");\n\t\tif (eqIdx <= 0) continue;\n\n\t\tconst key = trimmed.slice(0, eqIdx);\n\t\tconst value = trimmed.slice(eqIdx + 1);\n\n\t\tresult.push({\n\t\t\tkey,\n\t\t\tvalue,\n\t\t\tis_preview: false,\n\t\t\tis_build_time: false,\n\t\t\tis_literal: true,\n\t\t});\n\t}\n\n\treturn result;\n}\n\n/**\n * Deploys Docker Compose stacks to a Coolify v4 instance.\n *\n * Deploy flow (5 steps):\n * 1. Discover the first available server\n * 2. Create a Coolify project\n * 3. Create a compose service with the raw docker-compose YAML\n * 4. Push .env variables via the bulk env API\n * 5. Trigger the deployment\n */\nexport class CoolifyDeployer implements PaasDeployer {\n\treadonly name = \"Coolify\";\n\treadonly id = \"coolify\";\n\n\tasync testConnection(target: DeployTarget): Promise<{ ok: boolean; error?: string }> {\n\t\ttry {\n\t\t\tawait coolifyFetch<unknown>(target, \"/version\");\n\t\t\treturn { ok: true };\n\t\t} catch (err) {\n\t\t\treturn { ok: false, error: err instanceof Error ? err.message : String(err) };\n\t\t}\n\t}\n\n\tasync deploy(input: DeployInput): Promise<DeployResult> {\n\t\tconst step1: DeployStep = { step: \"Discover server\", status: \"pending\" };\n\t\tconst step2: DeployStep = { step: \"Create project\", status: \"pending\" };\n\t\tconst step3: DeployStep = { step: \"Create compose service\", status: \"pending\" };\n\t\tconst step4: DeployStep = { step: \"Set environment variables\", status: \"pending\" };\n\t\tconst step5: DeployStep = { step: \"Trigger deployment\", status: \"pending\" };\n\t\tconst steps: DeployStep[] = [step1, step2, step3, step4, step5];\n\n\t\tconst result: DeployResult = { success: false, steps };\n\n\t\ttry {\n\t\t\t// Step 1: Discover default server\n\t\t\tstep1.status = \"running\";\n\t\t\tconst servers = await coolifyFetch<CoolifyServer[]>(input.target, \"/servers\");\n\t\t\tif (!servers || servers.length === 0) {\n\t\t\t\tthrow new Error(\"No servers found in Coolify instance\");\n\t\t\t}\n\t\t\tconst server = servers[0] as CoolifyServer;\n\t\t\tstep1.status = \"done\";\n\t\t\tstep1.detail = `Server: ${server.name} (${server.ip})`;\n\n\t\t\t// Step 2: Create project\n\t\t\tstep2.status = \"running\";\n\t\t\tconst project = await coolifyFetch<CoolifyProject>(input.target, \"/projects\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: {\n\t\t\t\t\tname: input.projectName,\n\t\t\t\t\tdescription: input.description ?? `OpenClaw stack: ${input.projectName}`,\n\t\t\t\t},\n\t\t\t});\n\t\t\tresult.projectId = project.uuid;\n\t\t\tstep2.status = \"done\";\n\t\t\tstep2.detail = `Project: ${project.uuid}`;\n\n\t\t\t// Get the default environment\n\t\t\tconst projectDetail = await coolifyFetch<CoolifyProject>(\n\t\t\t\tinput.target,\n\t\t\t\t`/projects/${project.uuid}`,\n\t\t\t);\n\t\t\tconst envUuid = projectDetail.environments?.[0]?.uuid;\n\t\t\tconst envName = projectDetail.environments?.[0]?.name ?? \"production\";\n\t\t\tif (!envUuid) {\n\t\t\t\tthrow new Error(\"No default environment found in project\");\n\t\t\t}\n\n\t\t\t// Step 3: Create compose service with docker_compose_raw\n\t\t\tstep3.status = \"running\";\n\t\t\tconst service = await coolifyFetch<CoolifyService>(input.target, \"/services\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: {\n\t\t\t\t\tproject_uuid: project.uuid,\n\t\t\t\t\tserver_uuid: server.uuid,\n\t\t\t\t\tenvironment_name: envName,\n\t\t\t\t\tenvironment_uuid: envUuid,\n\t\t\t\t\tdocker_compose_raw: input.composeYaml,\n\t\t\t\t\tname: input.projectName,\n\t\t\t\t\tdescription: input.description ?? \"Deployed via OpenClaw web builder\",\n\t\t\t\t\tinstant_deploy: false,\n\t\t\t\t},\n\t\t\t});\n\t\t\tresult.composeId = service.uuid;\n\t\t\tstep3.status = \"done\";\n\t\t\tstep3.detail = `Service: ${service.uuid}`;\n\n\t\t\t// Step 4: Set environment variables\n\t\t\tstep4.status = \"running\";\n\t\t\tconst envVars = parseEnvContent(input.envContent);\n\t\t\tif (envVars.length > 0) {\n\t\t\t\tawait coolifyFetch(input.target, `/services/${service.uuid}/envs`, {\n\t\t\t\t\tmethod: \"PATCH\",\n\t\t\t\t\tbody: envVars,\n\t\t\t\t});\n\t\t\t}\n\t\t\tstep4.status = \"done\";\n\t\t\tstep4.detail = `${envVars.length} variables set`;\n\n\t\t\t// Step 5: Trigger deployment\n\t\t\tstep5.status = \"running\";\n\t\t\tconst deployments = await coolifyFetch<{ deployments: CoolifyDeployment[] }>(\n\t\t\t\tinput.target,\n\t\t\t\t`/deploy?uuid=${service.uuid}&force=true`,\n\t\t\t);\n\t\t\tstep5.status = \"done\";\n\t\t\tstep5.detail = deployments?.deployments?.[0]?.deployment_uuid ?? \"Deployment triggered\";\n\n\t\t\tresult.success = true;\n\t\t\tconst base = input.target.instanceUrl.replace(/\\/+$/, \"\");\n\t\t\tresult.dashboardUrl = `${base}/project/${project.uuid}`;\n\t\t} catch (err) {\n\t\t\tconst failedStep = steps.find((s) => s.status === \"running\");\n\t\t\tif (failedStep) {\n\t\t\t\tfailedStep.status = \"error\";\n\t\t\t\tfailedStep.detail = err instanceof Error ? err.message : String(err);\n\t\t\t}\n\t\t\tresult.error = err instanceof Error ? err.message : String(err);\n\t\t}\n\n\t\treturn result;\n\t}\n}\n"],"mappings":"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"}
@@ -55,30 +55,34 @@ var DokployDeployer = class {
55
55
  }
56
56
  }
57
57
  async deploy(input) {
58
+ const step1 = {
59
+ step: "Create project",
60
+ status: "pending"
61
+ };
62
+ const step2 = {
63
+ step: "Create compose stack",
64
+ status: "pending"
65
+ };
66
+ const step3 = {
67
+ step: "Set environment variables",
68
+ status: "pending"
69
+ };
70
+ const step4 = {
71
+ step: "Trigger deployment",
72
+ status: "pending"
73
+ };
58
74
  const steps = [
59
- {
60
- step: "Create project",
61
- status: "pending"
62
- },
63
- {
64
- step: "Create compose stack",
65
- status: "pending"
66
- },
67
- {
68
- step: "Set environment variables",
69
- status: "pending"
70
- },
71
- {
72
- step: "Trigger deployment",
73
- status: "pending"
74
- }
75
+ step1,
76
+ step2,
77
+ step3,
78
+ step4
75
79
  ];
76
80
  const result = {
77
81
  success: false,
78
82
  steps
79
83
  };
80
84
  try {
81
- steps[0].status = "running";
85
+ step1.status = "running";
82
86
  const project = await dokployFetch(input.target, "project.create", {
83
87
  method: "POST",
84
88
  body: {
@@ -87,11 +91,11 @@ var DokployDeployer = class {
87
91
  }
88
92
  });
89
93
  result.projectId = project.projectId;
90
- steps[0].status = "done";
91
- steps[0].detail = `Project ID: ${project.projectId}`;
94
+ step1.status = "done";
95
+ step1.detail = `Project ID: ${project.projectId}`;
92
96
  const envId = (await dokployFetch(input.target, `project.one?projectId=${project.projectId}`)).environments?.[0]?.environmentId;
93
97
  if (!envId) throw new Error("No default environment found in project");
94
- steps[1].status = "running";
98
+ step2.status = "running";
95
99
  const compose = await dokployFetch(input.target, "compose.create", {
96
100
  method: "POST",
97
101
  body: {
@@ -101,9 +105,9 @@ var DokployDeployer = class {
101
105
  }
102
106
  });
103
107
  result.composeId = compose.composeId;
104
- steps[1].status = "done";
105
- steps[1].detail = `Compose ID: ${compose.composeId}`;
106
- steps[2].status = "running";
108
+ step2.status = "done";
109
+ step2.detail = `Compose ID: ${compose.composeId}`;
110
+ step3.status = "running";
107
111
  await dokployFetch(input.target, "compose.update", {
108
112
  method: "POST",
109
113
  body: {
@@ -111,8 +115,8 @@ var DokployDeployer = class {
111
115
  env: input.envContent
112
116
  }
113
117
  });
114
- steps[2].status = "done";
115
- steps[3].status = "running";
118
+ step3.status = "done";
119
+ step4.status = "running";
116
120
  await dokployFetch(input.target, "compose.deploy", {
117
121
  method: "POST",
118
122
  body: {
@@ -121,14 +125,14 @@ var DokployDeployer = class {
121
125
  description: input.description ?? "Deployed via OpenClaw web builder"
122
126
  }
123
127
  });
124
- steps[3].status = "done";
128
+ step4.status = "done";
125
129
  result.success = true;
126
130
  result.dashboardUrl = `${input.target.instanceUrl.replace(/\/+$/, "")}/dashboard/project/${project.projectId}`;
127
131
  } catch (err) {
128
- const failedIdx = steps.findIndex((s) => s.status === "running");
129
- if (failedIdx >= 0) {
130
- steps[failedIdx].status = "error";
131
- steps[failedIdx].detail = err instanceof Error ? err.message : String(err);
132
+ const failedStep = steps.find((s) => s.status === "running");
133
+ if (failedStep) {
134
+ failedStep.status = "error";
135
+ failedStep.detail = err instanceof Error ? err.message : String(err);
132
136
  }
133
137
  result.error = err instanceof Error ? err.message : String(err);
134
138
  }
@@ -1 +1 @@
1
- {"version":3,"file":"dokploy.cjs","names":[],"sources":["../../src/deployers/dokploy.ts"],"sourcesContent":["/**\n * Dokploy PaaS deployer — deploys Docker Compose stacks via the Dokploy REST API.\n *\n * API docs: https://docs.dokploy.com/docs/api\n * Auth: x-api-key header\n * Endpoints use dot-notation (e.g. /api/project.create, /api/compose.deploy)\n */\n\nimport type { DeployInput, DeployResult, DeployStep, DeployTarget, PaasDeployer } from \"./types.js\";\n\n/** Shape returned by Dokploy's project endpoints. */\ninterface DokployProject {\n\tprojectId: string;\n\tname: string;\n\tdescription: string;\n\tenvironments?: { environmentId: string; name: string }[];\n}\n\n/** Shape returned by Dokploy's compose endpoints. */\ninterface DokployCompose {\n\tcomposeId: string;\n\tname: string;\n\tstatus?: string;\n}\n\n/** Build a full Dokploy API URL from a dot-notation endpoint (e.g. \"project.create\"). */\nfunction apiUrl(target: DeployTarget, endpoint: string): string {\n\tconst base = target.instanceUrl.replace(/\\/+$/, \"\");\n\treturn `${base}/api/${endpoint}`;\n}\n\n/**\n * Typed fetch wrapper for the Dokploy API.\n * Handles JSON serialisation, x-api-key auth, and error extraction.\n */\nasync function dokployFetch<T>(\n\ttarget: DeployTarget,\n\tendpoint: string,\n\toptions: { method?: string; body?: unknown } = {},\n): Promise<T> {\n\tconst res = await fetch(apiUrl(target, endpoint), {\n\t\tmethod: options.method ?? \"GET\",\n\t\theaders: {\n\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\"x-api-key\": target.apiKey,\n\t\t},\n\t\tbody: options.body ? JSON.stringify(options.body) : undefined,\n\t});\n\n\tif (!res.ok) {\n\t\tconst text = await res.text().catch(() => \"\");\n\t\tlet detail = text;\n\t\ttry {\n\t\t\tconst json = JSON.parse(text);\n\t\t\tdetail = json.message || json.error || text;\n\t\t} catch {\n\t\t\t// use raw text\n\t\t}\n\t\tthrow new Error(`Dokploy API ${res.status}: ${detail}`);\n\t}\n\n\tconst text = await res.text();\n\tif (!text) return undefined as T;\n\treturn JSON.parse(text) as T;\n}\n\n/**\n * Deploys Docker Compose stacks to a Dokploy instance.\n *\n * Deploy flow (4 steps):\n * 1. Create a Dokploy project\n * 2. Create a compose stack inside the project's default environment\n * 3. Push .env variables to the compose stack\n * 4. Trigger the deployment\n */\nexport class DokployDeployer implements PaasDeployer {\n\treadonly name = \"Dokploy\";\n\treadonly id = \"dokploy\";\n\n\tasync testConnection(target: DeployTarget): Promise<{ ok: boolean; error?: string }> {\n\t\ttry {\n\t\t\tawait dokployFetch<DokployProject[]>(target, \"project.all\");\n\t\t\treturn { ok: true };\n\t\t} catch (err) {\n\t\t\treturn { ok: false, error: err instanceof Error ? err.message : String(err) };\n\t\t}\n\t}\n\n\tasync deploy(input: DeployInput): Promise<DeployResult> {\n\t\tconst steps: DeployStep[] = [\n\t\t\t{ step: \"Create project\", status: \"pending\" },\n\t\t\t{ step: \"Create compose stack\", status: \"pending\" },\n\t\t\t{ step: \"Set environment variables\", status: \"pending\" },\n\t\t\t{ step: \"Trigger deployment\", status: \"pending\" },\n\t\t];\n\n\t\tconst result: DeployResult = { success: false, steps };\n\n\t\ttry {\n\t\t\t// Step 1: Create project\n\t\t\tsteps[0].status = \"running\";\n\t\t\tconst project = await dokployFetch<DokployProject>(input.target, \"project.create\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: {\n\t\t\t\t\tname: input.projectName,\n\t\t\t\t\tdescription: input.description ?? `OpenClaw stack: ${input.projectName}`,\n\t\t\t\t},\n\t\t\t});\n\t\t\tresult.projectId = project.projectId;\n\t\t\tsteps[0].status = \"done\";\n\t\t\tsteps[0].detail = `Project ID: ${project.projectId}`;\n\n\t\t\t// Get the default environment ID\n\t\t\tconst projectDetail = await dokployFetch<DokployProject>(\n\t\t\t\tinput.target,\n\t\t\t\t`project.one?projectId=${project.projectId}`,\n\t\t\t);\n\t\t\tconst envId = projectDetail.environments?.[0]?.environmentId;\n\t\t\tif (!envId) {\n\t\t\t\tthrow new Error(\"No default environment found in project\");\n\t\t\t}\n\n\t\t\t// Step 2: Create compose stack\n\t\t\tsteps[1].status = \"running\";\n\t\t\tconst compose = await dokployFetch<DokployCompose>(input.target, \"compose.create\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: {\n\t\t\t\t\tname: input.projectName,\n\t\t\t\t\tenvironmentId: envId,\n\t\t\t\t\tcomposeFile: input.composeYaml,\n\t\t\t\t},\n\t\t\t});\n\t\t\tresult.composeId = compose.composeId;\n\t\t\tsteps[1].status = \"done\";\n\t\t\tsteps[1].detail = `Compose ID: ${compose.composeId}`;\n\n\t\t\t// Step 3: Set environment variables\n\t\t\tsteps[2].status = \"running\";\n\t\t\tawait dokployFetch(input.target, \"compose.update\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: {\n\t\t\t\t\tcomposeId: compose.composeId,\n\t\t\t\t\tenv: input.envContent,\n\t\t\t\t},\n\t\t\t});\n\t\t\tsteps[2].status = \"done\";\n\n\t\t\t// Step 4: Trigger deployment\n\t\t\tsteps[3].status = \"running\";\n\t\t\tawait dokployFetch(input.target, \"compose.deploy\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: {\n\t\t\t\t\tcomposeId: compose.composeId,\n\t\t\t\t\ttitle: `Initial deploy: ${input.projectName}`,\n\t\t\t\t\tdescription: input.description ?? \"Deployed via OpenClaw web builder\",\n\t\t\t\t},\n\t\t\t});\n\t\t\tsteps[3].status = \"done\";\n\n\t\t\tresult.success = true;\n\t\t\tconst base = input.target.instanceUrl.replace(/\\/+$/, \"\");\n\t\t\tresult.dashboardUrl = `${base}/dashboard/project/${project.projectId}`;\n\t\t} catch (err) {\n\t\t\tconst failedIdx = steps.findIndex((s) => s.status === \"running\");\n\t\t\tif (failedIdx >= 0) {\n\t\t\t\tsteps[failedIdx].status = \"error\";\n\t\t\t\tsteps[failedIdx].detail = err instanceof Error ? err.message : String(err);\n\t\t\t}\n\t\t\tresult.error = err instanceof Error ? err.message : String(err);\n\t\t}\n\n\t\treturn result;\n\t}\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"dokploy.cjs","names":[],"sources":["../../src/deployers/dokploy.ts"],"sourcesContent":["/**\n * Dokploy PaaS deployer — deploys Docker Compose stacks via the Dokploy REST API.\n *\n * API docs: https://docs.dokploy.com/docs/api\n * Auth: x-api-key header\n * Endpoints use dot-notation (e.g. /api/project.create, /api/compose.deploy)\n */\n\nimport type { DeployInput, DeployResult, DeployStep, DeployTarget, PaasDeployer } from \"./types.js\";\n\n/** Shape returned by Dokploy's project endpoints. */\ninterface DokployProject {\n\tprojectId: string;\n\tname: string;\n\tdescription: string;\n\tenvironments?: { environmentId: string; name: string }[];\n}\n\n/** Shape returned by Dokploy's compose endpoints. */\ninterface DokployCompose {\n\tcomposeId: string;\n\tname: string;\n\tstatus?: string;\n}\n\n/** Build a full Dokploy API URL from a dot-notation endpoint (e.g. \"project.create\"). */\nfunction apiUrl(target: DeployTarget, endpoint: string): string {\n\tconst base = target.instanceUrl.replace(/\\/+$/, \"\");\n\treturn `${base}/api/${endpoint}`;\n}\n\n/**\n * Typed fetch wrapper for the Dokploy API.\n * Handles JSON serialisation, x-api-key auth, and error extraction.\n */\nasync function dokployFetch<T>(\n\ttarget: DeployTarget,\n\tendpoint: string,\n\toptions: { method?: string; body?: unknown } = {},\n): Promise<T> {\n\tconst res = await fetch(apiUrl(target, endpoint), {\n\t\tmethod: options.method ?? \"GET\",\n\t\theaders: {\n\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\"x-api-key\": target.apiKey,\n\t\t},\n\t\tbody: options.body ? JSON.stringify(options.body) : undefined,\n\t});\n\n\tif (!res.ok) {\n\t\tconst text = await res.text().catch(() => \"\");\n\t\tlet detail = text;\n\t\ttry {\n\t\t\tconst json = JSON.parse(text);\n\t\t\tdetail = json.message || json.error || text;\n\t\t} catch {\n\t\t\t// use raw text\n\t\t}\n\t\tthrow new Error(`Dokploy API ${res.status}: ${detail}`);\n\t}\n\n\tconst text = await res.text();\n\tif (!text) return undefined as T;\n\treturn JSON.parse(text) as T;\n}\n\n/**\n * Deploys Docker Compose stacks to a Dokploy instance.\n *\n * Deploy flow (4 steps):\n * 1. Create a Dokploy project\n * 2. Create a compose stack inside the project's default environment\n * 3. Push .env variables to the compose stack\n * 4. Trigger the deployment\n */\nexport class DokployDeployer implements PaasDeployer {\n\treadonly name = \"Dokploy\";\n\treadonly id = \"dokploy\";\n\n\tasync testConnection(target: DeployTarget): Promise<{ ok: boolean; error?: string }> {\n\t\ttry {\n\t\t\tawait dokployFetch<DokployProject[]>(target, \"project.all\");\n\t\t\treturn { ok: true };\n\t\t} catch (err) {\n\t\t\treturn { ok: false, error: err instanceof Error ? err.message : String(err) };\n\t\t}\n\t}\n\n\tasync deploy(input: DeployInput): Promise<DeployResult> {\n\t\tconst step1: DeployStep = { step: \"Create project\", status: \"pending\" };\n\t\tconst step2: DeployStep = { step: \"Create compose stack\", status: \"pending\" };\n\t\tconst step3: DeployStep = { step: \"Set environment variables\", status: \"pending\" };\n\t\tconst step4: DeployStep = { step: \"Trigger deployment\", status: \"pending\" };\n\t\tconst steps: DeployStep[] = [step1, step2, step3, step4];\n\n\t\tconst result: DeployResult = { success: false, steps };\n\n\t\ttry {\n\t\t\t// Step 1: Create project\n\t\t\tstep1.status = \"running\";\n\t\t\tconst project = await dokployFetch<DokployProject>(input.target, \"project.create\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: {\n\t\t\t\t\tname: input.projectName,\n\t\t\t\t\tdescription: input.description ?? `OpenClaw stack: ${input.projectName}`,\n\t\t\t\t},\n\t\t\t});\n\t\t\tresult.projectId = project.projectId;\n\t\t\tstep1.status = \"done\";\n\t\t\tstep1.detail = `Project ID: ${project.projectId}`;\n\n\t\t\t// Get the default environment ID\n\t\t\tconst projectDetail = await dokployFetch<DokployProject>(\n\t\t\t\tinput.target,\n\t\t\t\t`project.one?projectId=${project.projectId}`,\n\t\t\t);\n\t\t\tconst envId = projectDetail.environments?.[0]?.environmentId;\n\t\t\tif (!envId) {\n\t\t\t\tthrow new Error(\"No default environment found in project\");\n\t\t\t}\n\n\t\t\t// Step 2: Create compose stack\n\t\t\tstep2.status = \"running\";\n\t\t\tconst compose = await dokployFetch<DokployCompose>(input.target, \"compose.create\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: {\n\t\t\t\t\tname: input.projectName,\n\t\t\t\t\tenvironmentId: envId,\n\t\t\t\t\tcomposeFile: input.composeYaml,\n\t\t\t\t},\n\t\t\t});\n\t\t\tresult.composeId = compose.composeId;\n\t\t\tstep2.status = \"done\";\n\t\t\tstep2.detail = `Compose ID: ${compose.composeId}`;\n\n\t\t\t// Step 3: Set environment variables\n\t\t\tstep3.status = \"running\";\n\t\t\tawait dokployFetch(input.target, \"compose.update\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: {\n\t\t\t\t\tcomposeId: compose.composeId,\n\t\t\t\t\tenv: input.envContent,\n\t\t\t\t},\n\t\t\t});\n\t\t\tstep3.status = \"done\";\n\n\t\t\t// Step 4: Trigger deployment\n\t\t\tstep4.status = \"running\";\n\t\t\tawait dokployFetch(input.target, \"compose.deploy\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: {\n\t\t\t\t\tcomposeId: compose.composeId,\n\t\t\t\t\ttitle: `Initial deploy: ${input.projectName}`,\n\t\t\t\t\tdescription: input.description ?? \"Deployed via OpenClaw web builder\",\n\t\t\t\t},\n\t\t\t});\n\t\t\tstep4.status = \"done\";\n\n\t\t\tresult.success = true;\n\t\t\tconst base = input.target.instanceUrl.replace(/\\/+$/, \"\");\n\t\t\tresult.dashboardUrl = `${base}/dashboard/project/${project.projectId}`;\n\t\t} catch (err) {\n\t\t\tconst failedStep = steps.find((s) => s.status === \"running\");\n\t\t\tif (failedStep) {\n\t\t\t\tfailedStep.status = \"error\";\n\t\t\t\tfailedStep.detail = err instanceof Error ? err.message : String(err);\n\t\t\t}\n\t\t\tresult.error = err instanceof Error ? err.message : String(err);\n\t\t}\n\n\t\treturn result;\n\t}\n}\n"],"mappings":"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"}
@@ -53,30 +53,34 @@ var DokployDeployer = class {
53
53
  }
54
54
  }
55
55
  async deploy(input) {
56
+ const step1 = {
57
+ step: "Create project",
58
+ status: "pending"
59
+ };
60
+ const step2 = {
61
+ step: "Create compose stack",
62
+ status: "pending"
63
+ };
64
+ const step3 = {
65
+ step: "Set environment variables",
66
+ status: "pending"
67
+ };
68
+ const step4 = {
69
+ step: "Trigger deployment",
70
+ status: "pending"
71
+ };
56
72
  const steps = [
57
- {
58
- step: "Create project",
59
- status: "pending"
60
- },
61
- {
62
- step: "Create compose stack",
63
- status: "pending"
64
- },
65
- {
66
- step: "Set environment variables",
67
- status: "pending"
68
- },
69
- {
70
- step: "Trigger deployment",
71
- status: "pending"
72
- }
73
+ step1,
74
+ step2,
75
+ step3,
76
+ step4
73
77
  ];
74
78
  const result = {
75
79
  success: false,
76
80
  steps
77
81
  };
78
82
  try {
79
- steps[0].status = "running";
83
+ step1.status = "running";
80
84
  const project = await dokployFetch(input.target, "project.create", {
81
85
  method: "POST",
82
86
  body: {
@@ -85,11 +89,11 @@ var DokployDeployer = class {
85
89
  }
86
90
  });
87
91
  result.projectId = project.projectId;
88
- steps[0].status = "done";
89
- steps[0].detail = `Project ID: ${project.projectId}`;
92
+ step1.status = "done";
93
+ step1.detail = `Project ID: ${project.projectId}`;
90
94
  const envId = (await dokployFetch(input.target, `project.one?projectId=${project.projectId}`)).environments?.[0]?.environmentId;
91
95
  if (!envId) throw new Error("No default environment found in project");
92
- steps[1].status = "running";
96
+ step2.status = "running";
93
97
  const compose = await dokployFetch(input.target, "compose.create", {
94
98
  method: "POST",
95
99
  body: {
@@ -99,9 +103,9 @@ var DokployDeployer = class {
99
103
  }
100
104
  });
101
105
  result.composeId = compose.composeId;
102
- steps[1].status = "done";
103
- steps[1].detail = `Compose ID: ${compose.composeId}`;
104
- steps[2].status = "running";
106
+ step2.status = "done";
107
+ step2.detail = `Compose ID: ${compose.composeId}`;
108
+ step3.status = "running";
105
109
  await dokployFetch(input.target, "compose.update", {
106
110
  method: "POST",
107
111
  body: {
@@ -109,8 +113,8 @@ var DokployDeployer = class {
109
113
  env: input.envContent
110
114
  }
111
115
  });
112
- steps[2].status = "done";
113
- steps[3].status = "running";
116
+ step3.status = "done";
117
+ step4.status = "running";
114
118
  await dokployFetch(input.target, "compose.deploy", {
115
119
  method: "POST",
116
120
  body: {
@@ -119,14 +123,14 @@ var DokployDeployer = class {
119
123
  description: input.description ?? "Deployed via OpenClaw web builder"
120
124
  }
121
125
  });
122
- steps[3].status = "done";
126
+ step4.status = "done";
123
127
  result.success = true;
124
128
  result.dashboardUrl = `${input.target.instanceUrl.replace(/\/+$/, "")}/dashboard/project/${project.projectId}`;
125
129
  } catch (err) {
126
- const failedIdx = steps.findIndex((s) => s.status === "running");
127
- if (failedIdx >= 0) {
128
- steps[failedIdx].status = "error";
129
- steps[failedIdx].detail = err instanceof Error ? err.message : String(err);
130
+ const failedStep = steps.find((s) => s.status === "running");
131
+ if (failedStep) {
132
+ failedStep.status = "error";
133
+ failedStep.detail = err instanceof Error ? err.message : String(err);
130
134
  }
131
135
  result.error = err instanceof Error ? err.message : String(err);
132
136
  }
@@ -1 +1 @@
1
- {"version":3,"file":"dokploy.mjs","names":[],"sources":["../../src/deployers/dokploy.ts"],"sourcesContent":["/**\n * Dokploy PaaS deployer — deploys Docker Compose stacks via the Dokploy REST API.\n *\n * API docs: https://docs.dokploy.com/docs/api\n * Auth: x-api-key header\n * Endpoints use dot-notation (e.g. /api/project.create, /api/compose.deploy)\n */\n\nimport type { DeployInput, DeployResult, DeployStep, DeployTarget, PaasDeployer } from \"./types.js\";\n\n/** Shape returned by Dokploy's project endpoints. */\ninterface DokployProject {\n\tprojectId: string;\n\tname: string;\n\tdescription: string;\n\tenvironments?: { environmentId: string; name: string }[];\n}\n\n/** Shape returned by Dokploy's compose endpoints. */\ninterface DokployCompose {\n\tcomposeId: string;\n\tname: string;\n\tstatus?: string;\n}\n\n/** Build a full Dokploy API URL from a dot-notation endpoint (e.g. \"project.create\"). */\nfunction apiUrl(target: DeployTarget, endpoint: string): string {\n\tconst base = target.instanceUrl.replace(/\\/+$/, \"\");\n\treturn `${base}/api/${endpoint}`;\n}\n\n/**\n * Typed fetch wrapper for the Dokploy API.\n * Handles JSON serialisation, x-api-key auth, and error extraction.\n */\nasync function dokployFetch<T>(\n\ttarget: DeployTarget,\n\tendpoint: string,\n\toptions: { method?: string; body?: unknown } = {},\n): Promise<T> {\n\tconst res = await fetch(apiUrl(target, endpoint), {\n\t\tmethod: options.method ?? \"GET\",\n\t\theaders: {\n\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\"x-api-key\": target.apiKey,\n\t\t},\n\t\tbody: options.body ? JSON.stringify(options.body) : undefined,\n\t});\n\n\tif (!res.ok) {\n\t\tconst text = await res.text().catch(() => \"\");\n\t\tlet detail = text;\n\t\ttry {\n\t\t\tconst json = JSON.parse(text);\n\t\t\tdetail = json.message || json.error || text;\n\t\t} catch {\n\t\t\t// use raw text\n\t\t}\n\t\tthrow new Error(`Dokploy API ${res.status}: ${detail}`);\n\t}\n\n\tconst text = await res.text();\n\tif (!text) return undefined as T;\n\treturn JSON.parse(text) as T;\n}\n\n/**\n * Deploys Docker Compose stacks to a Dokploy instance.\n *\n * Deploy flow (4 steps):\n * 1. Create a Dokploy project\n * 2. Create a compose stack inside the project's default environment\n * 3. Push .env variables to the compose stack\n * 4. Trigger the deployment\n */\nexport class DokployDeployer implements PaasDeployer {\n\treadonly name = \"Dokploy\";\n\treadonly id = \"dokploy\";\n\n\tasync testConnection(target: DeployTarget): Promise<{ ok: boolean; error?: string }> {\n\t\ttry {\n\t\t\tawait dokployFetch<DokployProject[]>(target, \"project.all\");\n\t\t\treturn { ok: true };\n\t\t} catch (err) {\n\t\t\treturn { ok: false, error: err instanceof Error ? err.message : String(err) };\n\t\t}\n\t}\n\n\tasync deploy(input: DeployInput): Promise<DeployResult> {\n\t\tconst steps: DeployStep[] = [\n\t\t\t{ step: \"Create project\", status: \"pending\" },\n\t\t\t{ step: \"Create compose stack\", status: \"pending\" },\n\t\t\t{ step: \"Set environment variables\", status: \"pending\" },\n\t\t\t{ step: \"Trigger deployment\", status: \"pending\" },\n\t\t];\n\n\t\tconst result: DeployResult = { success: false, steps };\n\n\t\ttry {\n\t\t\t// Step 1: Create project\n\t\t\tsteps[0].status = \"running\";\n\t\t\tconst project = await dokployFetch<DokployProject>(input.target, \"project.create\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: {\n\t\t\t\t\tname: input.projectName,\n\t\t\t\t\tdescription: input.description ?? `OpenClaw stack: ${input.projectName}`,\n\t\t\t\t},\n\t\t\t});\n\t\t\tresult.projectId = project.projectId;\n\t\t\tsteps[0].status = \"done\";\n\t\t\tsteps[0].detail = `Project ID: ${project.projectId}`;\n\n\t\t\t// Get the default environment ID\n\t\t\tconst projectDetail = await dokployFetch<DokployProject>(\n\t\t\t\tinput.target,\n\t\t\t\t`project.one?projectId=${project.projectId}`,\n\t\t\t);\n\t\t\tconst envId = projectDetail.environments?.[0]?.environmentId;\n\t\t\tif (!envId) {\n\t\t\t\tthrow new Error(\"No default environment found in project\");\n\t\t\t}\n\n\t\t\t// Step 2: Create compose stack\n\t\t\tsteps[1].status = \"running\";\n\t\t\tconst compose = await dokployFetch<DokployCompose>(input.target, \"compose.create\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: {\n\t\t\t\t\tname: input.projectName,\n\t\t\t\t\tenvironmentId: envId,\n\t\t\t\t\tcomposeFile: input.composeYaml,\n\t\t\t\t},\n\t\t\t});\n\t\t\tresult.composeId = compose.composeId;\n\t\t\tsteps[1].status = \"done\";\n\t\t\tsteps[1].detail = `Compose ID: ${compose.composeId}`;\n\n\t\t\t// Step 3: Set environment variables\n\t\t\tsteps[2].status = \"running\";\n\t\t\tawait dokployFetch(input.target, \"compose.update\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: {\n\t\t\t\t\tcomposeId: compose.composeId,\n\t\t\t\t\tenv: input.envContent,\n\t\t\t\t},\n\t\t\t});\n\t\t\tsteps[2].status = \"done\";\n\n\t\t\t// Step 4: Trigger deployment\n\t\t\tsteps[3].status = \"running\";\n\t\t\tawait dokployFetch(input.target, \"compose.deploy\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: {\n\t\t\t\t\tcomposeId: compose.composeId,\n\t\t\t\t\ttitle: `Initial deploy: ${input.projectName}`,\n\t\t\t\t\tdescription: input.description ?? \"Deployed via OpenClaw web builder\",\n\t\t\t\t},\n\t\t\t});\n\t\t\tsteps[3].status = \"done\";\n\n\t\t\tresult.success = true;\n\t\t\tconst base = input.target.instanceUrl.replace(/\\/+$/, \"\");\n\t\t\tresult.dashboardUrl = `${base}/dashboard/project/${project.projectId}`;\n\t\t} catch (err) {\n\t\t\tconst failedIdx = steps.findIndex((s) => s.status === \"running\");\n\t\t\tif (failedIdx >= 0) {\n\t\t\t\tsteps[failedIdx].status = \"error\";\n\t\t\t\tsteps[failedIdx].detail = err instanceof Error ? err.message : String(err);\n\t\t\t}\n\t\t\tresult.error = err instanceof Error ? err.message : String(err);\n\t\t}\n\n\t\treturn result;\n\t}\n}\n"],"mappings":";;AA0BA,SAAS,OAAO,QAAsB,UAA0B;AAE/D,QAAO,GADM,OAAO,YAAY,QAAQ,QAAQ,GAAG,CACpC,OAAO;;;;;;AAOvB,eAAe,aACd,QACA,UACA,UAA+C,EAAE,EACpC;CACb,MAAM,MAAM,MAAM,MAAM,OAAO,QAAQ,SAAS,EAAE;EACjD,QAAQ,QAAQ,UAAU;EAC1B,SAAS;GACR,gBAAgB;GAChB,aAAa,OAAO;GACpB;EACD,MAAM,QAAQ,OAAO,KAAK,UAAU,QAAQ,KAAK,GAAG;EACpD,CAAC;AAEF,KAAI,CAAC,IAAI,IAAI;EACZ,MAAM,OAAO,MAAM,IAAI,MAAM,CAAC,YAAY,GAAG;EAC7C,IAAI,SAAS;AACb,MAAI;GACH,MAAM,OAAO,KAAK,MAAM,KAAK;AAC7B,YAAS,KAAK,WAAW,KAAK,SAAS;UAChC;AAGR,QAAM,IAAI,MAAM,eAAe,IAAI,OAAO,IAAI,SAAS;;CAGxD,MAAM,OAAO,MAAM,IAAI,MAAM;AAC7B,KAAI,CAAC,KAAM,QAAO;AAClB,QAAO,KAAK,MAAM,KAAK;;;;;;;;;;;AAYxB,IAAa,kBAAb,MAAqD;CACpD,AAAS,OAAO;CAChB,AAAS,KAAK;CAEd,MAAM,eAAe,QAAgE;AACpF,MAAI;AACH,SAAM,aAA+B,QAAQ,cAAc;AAC3D,UAAO,EAAE,IAAI,MAAM;WACX,KAAK;AACb,UAAO;IAAE,IAAI;IAAO,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,IAAI;IAAE;;;CAI/E,MAAM,OAAO,OAA2C;EACvD,MAAM,QAAsB;GAC3B;IAAE,MAAM;IAAkB,QAAQ;IAAW;GAC7C;IAAE,MAAM;IAAwB,QAAQ;IAAW;GACnD;IAAE,MAAM;IAA6B,QAAQ;IAAW;GACxD;IAAE,MAAM;IAAsB,QAAQ;IAAW;GACjD;EAED,MAAM,SAAuB;GAAE,SAAS;GAAO;GAAO;AAEtD,MAAI;AAEH,SAAM,GAAG,SAAS;GAClB,MAAM,UAAU,MAAM,aAA6B,MAAM,QAAQ,kBAAkB;IAClF,QAAQ;IACR,MAAM;KACL,MAAM,MAAM;KACZ,aAAa,MAAM,eAAe,mBAAmB,MAAM;KAC3D;IACD,CAAC;AACF,UAAO,YAAY,QAAQ;AAC3B,SAAM,GAAG,SAAS;AAClB,SAAM,GAAG,SAAS,eAAe,QAAQ;GAOzC,MAAM,SAJgB,MAAM,aAC3B,MAAM,QACN,yBAAyB,QAAQ,YACjC,EAC2B,eAAe,IAAI;AAC/C,OAAI,CAAC,MACJ,OAAM,IAAI,MAAM,0CAA0C;AAI3D,SAAM,GAAG,SAAS;GAClB,MAAM,UAAU,MAAM,aAA6B,MAAM,QAAQ,kBAAkB;IAClF,QAAQ;IACR,MAAM;KACL,MAAM,MAAM;KACZ,eAAe;KACf,aAAa,MAAM;KACnB;IACD,CAAC;AACF,UAAO,YAAY,QAAQ;AAC3B,SAAM,GAAG,SAAS;AAClB,SAAM,GAAG,SAAS,eAAe,QAAQ;AAGzC,SAAM,GAAG,SAAS;AAClB,SAAM,aAAa,MAAM,QAAQ,kBAAkB;IAClD,QAAQ;IACR,MAAM;KACL,WAAW,QAAQ;KACnB,KAAK,MAAM;KACX;IACD,CAAC;AACF,SAAM,GAAG,SAAS;AAGlB,SAAM,GAAG,SAAS;AAClB,SAAM,aAAa,MAAM,QAAQ,kBAAkB;IAClD,QAAQ;IACR,MAAM;KACL,WAAW,QAAQ;KACnB,OAAO,mBAAmB,MAAM;KAChC,aAAa,MAAM,eAAe;KAClC;IACD,CAAC;AACF,SAAM,GAAG,SAAS;AAElB,UAAO,UAAU;AAEjB,UAAO,eAAe,GADT,MAAM,OAAO,YAAY,QAAQ,QAAQ,GAAG,CAC3B,qBAAqB,QAAQ;WACnD,KAAK;GACb,MAAM,YAAY,MAAM,WAAW,MAAM,EAAE,WAAW,UAAU;AAChE,OAAI,aAAa,GAAG;AACnB,UAAM,WAAW,SAAS;AAC1B,UAAM,WAAW,SAAS,eAAe,QAAQ,IAAI,UAAU,OAAO,IAAI;;AAE3E,UAAO,QAAQ,eAAe,QAAQ,IAAI,UAAU,OAAO,IAAI;;AAGhE,SAAO"}
1
+ {"version":3,"file":"dokploy.mjs","names":[],"sources":["../../src/deployers/dokploy.ts"],"sourcesContent":["/**\n * Dokploy PaaS deployer — deploys Docker Compose stacks via the Dokploy REST API.\n *\n * API docs: https://docs.dokploy.com/docs/api\n * Auth: x-api-key header\n * Endpoints use dot-notation (e.g. /api/project.create, /api/compose.deploy)\n */\n\nimport type { DeployInput, DeployResult, DeployStep, DeployTarget, PaasDeployer } from \"./types.js\";\n\n/** Shape returned by Dokploy's project endpoints. */\ninterface DokployProject {\n\tprojectId: string;\n\tname: string;\n\tdescription: string;\n\tenvironments?: { environmentId: string; name: string }[];\n}\n\n/** Shape returned by Dokploy's compose endpoints. */\ninterface DokployCompose {\n\tcomposeId: string;\n\tname: string;\n\tstatus?: string;\n}\n\n/** Build a full Dokploy API URL from a dot-notation endpoint (e.g. \"project.create\"). */\nfunction apiUrl(target: DeployTarget, endpoint: string): string {\n\tconst base = target.instanceUrl.replace(/\\/+$/, \"\");\n\treturn `${base}/api/${endpoint}`;\n}\n\n/**\n * Typed fetch wrapper for the Dokploy API.\n * Handles JSON serialisation, x-api-key auth, and error extraction.\n */\nasync function dokployFetch<T>(\n\ttarget: DeployTarget,\n\tendpoint: string,\n\toptions: { method?: string; body?: unknown } = {},\n): Promise<T> {\n\tconst res = await fetch(apiUrl(target, endpoint), {\n\t\tmethod: options.method ?? \"GET\",\n\t\theaders: {\n\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\"x-api-key\": target.apiKey,\n\t\t},\n\t\tbody: options.body ? JSON.stringify(options.body) : undefined,\n\t});\n\n\tif (!res.ok) {\n\t\tconst text = await res.text().catch(() => \"\");\n\t\tlet detail = text;\n\t\ttry {\n\t\t\tconst json = JSON.parse(text);\n\t\t\tdetail = json.message || json.error || text;\n\t\t} catch {\n\t\t\t// use raw text\n\t\t}\n\t\tthrow new Error(`Dokploy API ${res.status}: ${detail}`);\n\t}\n\n\tconst text = await res.text();\n\tif (!text) return undefined as T;\n\treturn JSON.parse(text) as T;\n}\n\n/**\n * Deploys Docker Compose stacks to a Dokploy instance.\n *\n * Deploy flow (4 steps):\n * 1. Create a Dokploy project\n * 2. Create a compose stack inside the project's default environment\n * 3. Push .env variables to the compose stack\n * 4. Trigger the deployment\n */\nexport class DokployDeployer implements PaasDeployer {\n\treadonly name = \"Dokploy\";\n\treadonly id = \"dokploy\";\n\n\tasync testConnection(target: DeployTarget): Promise<{ ok: boolean; error?: string }> {\n\t\ttry {\n\t\t\tawait dokployFetch<DokployProject[]>(target, \"project.all\");\n\t\t\treturn { ok: true };\n\t\t} catch (err) {\n\t\t\treturn { ok: false, error: err instanceof Error ? err.message : String(err) };\n\t\t}\n\t}\n\n\tasync deploy(input: DeployInput): Promise<DeployResult> {\n\t\tconst step1: DeployStep = { step: \"Create project\", status: \"pending\" };\n\t\tconst step2: DeployStep = { step: \"Create compose stack\", status: \"pending\" };\n\t\tconst step3: DeployStep = { step: \"Set environment variables\", status: \"pending\" };\n\t\tconst step4: DeployStep = { step: \"Trigger deployment\", status: \"pending\" };\n\t\tconst steps: DeployStep[] = [step1, step2, step3, step4];\n\n\t\tconst result: DeployResult = { success: false, steps };\n\n\t\ttry {\n\t\t\t// Step 1: Create project\n\t\t\tstep1.status = \"running\";\n\t\t\tconst project = await dokployFetch<DokployProject>(input.target, \"project.create\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: {\n\t\t\t\t\tname: input.projectName,\n\t\t\t\t\tdescription: input.description ?? `OpenClaw stack: ${input.projectName}`,\n\t\t\t\t},\n\t\t\t});\n\t\t\tresult.projectId = project.projectId;\n\t\t\tstep1.status = \"done\";\n\t\t\tstep1.detail = `Project ID: ${project.projectId}`;\n\n\t\t\t// Get the default environment ID\n\t\t\tconst projectDetail = await dokployFetch<DokployProject>(\n\t\t\t\tinput.target,\n\t\t\t\t`project.one?projectId=${project.projectId}`,\n\t\t\t);\n\t\t\tconst envId = projectDetail.environments?.[0]?.environmentId;\n\t\t\tif (!envId) {\n\t\t\t\tthrow new Error(\"No default environment found in project\");\n\t\t\t}\n\n\t\t\t// Step 2: Create compose stack\n\t\t\tstep2.status = \"running\";\n\t\t\tconst compose = await dokployFetch<DokployCompose>(input.target, \"compose.create\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: {\n\t\t\t\t\tname: input.projectName,\n\t\t\t\t\tenvironmentId: envId,\n\t\t\t\t\tcomposeFile: input.composeYaml,\n\t\t\t\t},\n\t\t\t});\n\t\t\tresult.composeId = compose.composeId;\n\t\t\tstep2.status = \"done\";\n\t\t\tstep2.detail = `Compose ID: ${compose.composeId}`;\n\n\t\t\t// Step 3: Set environment variables\n\t\t\tstep3.status = \"running\";\n\t\t\tawait dokployFetch(input.target, \"compose.update\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: {\n\t\t\t\t\tcomposeId: compose.composeId,\n\t\t\t\t\tenv: input.envContent,\n\t\t\t\t},\n\t\t\t});\n\t\t\tstep3.status = \"done\";\n\n\t\t\t// Step 4: Trigger deployment\n\t\t\tstep4.status = \"running\";\n\t\t\tawait dokployFetch(input.target, \"compose.deploy\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: {\n\t\t\t\t\tcomposeId: compose.composeId,\n\t\t\t\t\ttitle: `Initial deploy: ${input.projectName}`,\n\t\t\t\t\tdescription: input.description ?? \"Deployed via OpenClaw web builder\",\n\t\t\t\t},\n\t\t\t});\n\t\t\tstep4.status = \"done\";\n\n\t\t\tresult.success = true;\n\t\t\tconst base = input.target.instanceUrl.replace(/\\/+$/, \"\");\n\t\t\tresult.dashboardUrl = `${base}/dashboard/project/${project.projectId}`;\n\t\t} catch (err) {\n\t\t\tconst failedStep = steps.find((s) => s.status === \"running\");\n\t\t\tif (failedStep) {\n\t\t\t\tfailedStep.status = \"error\";\n\t\t\t\tfailedStep.detail = err instanceof Error ? err.message : String(err);\n\t\t\t}\n\t\t\tresult.error = err instanceof Error ? err.message : String(err);\n\t\t}\n\n\t\treturn result;\n\t}\n}\n"],"mappings":"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"}
@@ -40,7 +40,7 @@ async function isPortAvailable(port) {
40
40
  })) return false;
41
41
  return await new Promise((resolve) => {
42
42
  const server = net.createServer();
43
- server.once("error", (err) => {
43
+ server.once("error", (_err) => {
44
44
  resolve(false);
45
45
  });
46
46
  server.listen(port, "0.0.0.0", () => {
@@ -1 +1 @@
1
- {"version":3,"file":"port-scanner.cjs","names":[],"sources":["../src/port-scanner.ts"],"sourcesContent":["import type { ServiceDefinition } from \"./types.js\";\n\n/**\n * Scans system for ports in use and suggests alternatives for conflicts\n */\nexport interface PortConflict {\n\tport: number;\n\tserviceId: string;\n\tdescription: string;\n\tsuggestedPort: number;\n}\n\n/**\n * Check if a port is available using a two-phase approach:\n * 1. TCP connect — detects if something is already listening\n * 2. Bind check — detects OS-reserved/excluded port ranges (e.g. Hyper-V on Windows)\n *\n * The connect-only approach misses Windows excluded port ranges because\n * ECONNREFUSED (nothing listening) looks the same as \"available\" even though\n * Docker's bind() will fail on those reserved ranges.\n */\nasync function isPortAvailable(port: number): Promise<boolean> {\n\ttry {\n\t\tconst net = await import(\"node:net\");\n\n\t\t// Phase 1: TCP connect check — is something actively listening?\n\t\tconst isListening = await new Promise<boolean>((resolve) => {\n\t\t\tconst socket = new net.Socket();\n\t\t\tlet resolved = false;\n\n\t\t\tconst cleanup = () => {\n\t\t\t\tif (!resolved) {\n\t\t\t\t\tresolved = true;\n\t\t\t\t\tsocket.removeAllListeners();\n\t\t\t\t\tsocket.destroy();\n\t\t\t\t}\n\t\t\t};\n\n\t\t\tsocket.once(\"connect\", () => {\n\t\t\t\tcleanup();\n\t\t\t\tresolve(true);\n\t\t\t});\n\n\t\t\tsocket.once(\"error\", () => {\n\t\t\t\tcleanup();\n\t\t\t\tresolve(false);\n\t\t\t});\n\n\t\t\tsocket.setTimeout(500);\n\t\t\tsocket.once(\"timeout\", () => {\n\t\t\t\tcleanup();\n\t\t\t\tresolve(false);\n\t\t\t});\n\n\t\t\tsocket.connect(port, \"127.0.0.1\");\n\t\t});\n\n\t\tif (isListening) return false; // Port is in use\n\n\t\t// Phase 2: Bind check — catches OS-reserved/excluded port ranges\n\t\t// On Windows, Hyper-V/WSL reserve dynamic port ranges that TCP connect\n\t\t// cannot detect. Attempting to bind reveals these reservations.\n\t\tconst canBind = await new Promise<boolean>((resolve) => {\n\t\t\tconst server = net.createServer();\n\n\t\t\tserver.once(\"error\", (err: NodeJS.ErrnoException) => {\n\t\t\t\t// EACCES / EADDRINUSE / EPERM = port is reserved or in use\n\t\t\t\tresolve(false);\n\t\t\t});\n\n\t\t\tserver.listen(port, \"0.0.0.0\", () => {\n\t\t\t\t// Successfully bound — port is truly available\n\t\t\t\tserver.close(() => resolve(true));\n\t\t\t});\n\t\t});\n\n\t\treturn canBind;\n\t} catch (error) {\n\t\tconsole.warn(`Port scanner error for port ${port}:`, error);\n\t\treturn true; // Failsafe: assume available\n\t}\n}\n\n/**\n * Find next available port starting from a base port.\n * Also skips ports already claimed by other services in this generation.\n */\nasync function findNextAvailablePort(\n\tstartPort: number,\n\tclaimedPorts: Set<number>,\n\tmaxAttempts = 100,\n): Promise<number> {\n\tfor (let i = 0; i < maxAttempts; i++) {\n\t\tconst port = startPort + i;\n\t\tif (port > 65535) break;\n\t\tif (claimedPorts.has(port)) continue;\n\t\tif (await isPortAvailable(port)) {\n\t\t\treturn port;\n\t\t}\n\t}\n\t// Fallback: return a high random port\n\treturn 50000 + Math.floor(Math.random() * 10000);\n}\n\n/**\n * Scan for port conflicts and suggest alternatives.\n *\n * Detects two types of conflicts:\n * 1. Host conflicts — port is already in use or reserved by the OS\n * 2. Inter-service conflicts — multiple selected services claim the same host port\n */\nexport async function scanPortConflicts(\n\tservices: ServiceDefinition[],\n): Promise<Map<string, Map<number, number>>> {\n\tconst portReassignments = new Map<string, Map<number, number>>();\n\n\t// Track which host ports are already claimed by services in this stack.\n\t// First service to claim a port wins; subsequent services get reassigned.\n\tconst claimedPorts = new Map<number, string>(); // port → serviceId that claimed it\n\n\tfor (const service of services) {\n\t\tif (!service.ports || service.ports.length === 0) continue;\n\n\t\tconst serviceReassignments = new Map<number, number>();\n\n\t\tfor (const portDef of service.ports) {\n\t\t\tif (!portDef.exposed) continue;\n\n\t\t\tconst port = portDef.host;\n\n\t\t\t// Check inter-service conflict first\n\t\t\tconst claimedBy = claimedPorts.get(port);\n\t\t\tif (claimedBy && claimedBy !== service.id) {\n\t\t\t\t// Another service already claimed this port — must reassign\n\t\t\t\tconst newPort = await findNextAvailablePort(port + 1000, new Set(claimedPorts.keys()));\n\t\t\t\tserviceReassignments.set(port, newPort);\n\t\t\t\tclaimedPorts.set(newPort, service.id);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Check host availability (TCP connect + bind check)\n\t\t\tconst isAvailable = await isPortAvailable(port);\n\n\t\t\tif (!isAvailable) {\n\t\t\t\tconst newPort = await findNextAvailablePort(port + 1000, new Set(claimedPorts.keys()));\n\t\t\t\tserviceReassignments.set(port, newPort);\n\t\t\t\tclaimedPorts.set(newPort, service.id);\n\t\t\t} else {\n\t\t\t\t// Port is available and not claimed — claim it\n\t\t\t\tclaimedPorts.set(port, service.id);\n\t\t\t}\n\t\t}\n\n\t\tif (serviceReassignments.size > 0) {\n\t\t\tportReassignments.set(service.id, serviceReassignments);\n\t\t}\n\t}\n\n\treturn portReassignments;\n}\n\n/**\n * Get conflicts in a user-friendly format\n */\nexport function formatPortConflicts(\n\tservices: ServiceDefinition[],\n\treassignments: Map<string, Map<number, number>>,\n): PortConflict[] {\n\tconst conflicts: PortConflict[] = [];\n\n\tfor (const service of services) {\n\t\tconst serviceReassignments = reassignments.get(service.id);\n\t\tif (!serviceReassignments) continue;\n\n\t\tfor (const portDef of service.ports || []) {\n\t\t\tconst newPort = serviceReassignments.get(portDef.host);\n\t\t\tif (newPort) {\n\t\t\t\tconflicts.push({\n\t\t\t\t\tport: portDef.host,\n\t\t\t\t\tserviceId: service.id,\n\t\t\t\t\tdescription: portDef.description || `${service.name} port`,\n\t\t\t\t\tsuggestedPort: newPort,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\treturn conflicts;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"port-scanner.cjs","names":[],"sources":["../src/port-scanner.ts"],"sourcesContent":["import type { ServiceDefinition } from \"./types.js\";\n\n/**\n * Scans system for ports in use and suggests alternatives for conflicts\n */\nexport interface PortConflict {\n\tport: number;\n\tserviceId: string;\n\tdescription: string;\n\tsuggestedPort: number;\n}\n\n/**\n * Check if a port is available using a two-phase approach:\n * 1. TCP connect — detects if something is already listening\n * 2. Bind check — detects OS-reserved/excluded port ranges (e.g. Hyper-V on Windows)\n *\n * The connect-only approach misses Windows excluded port ranges because\n * ECONNREFUSED (nothing listening) looks the same as \"available\" even though\n * Docker's bind() will fail on those reserved ranges.\n */\nasync function isPortAvailable(port: number): Promise<boolean> {\n\ttry {\n\t\tconst net = await import(\"node:net\");\n\n\t\t// Phase 1: TCP connect check — is something actively listening?\n\t\tconst isListening = await new Promise<boolean>((resolve) => {\n\t\t\tconst socket = new net.Socket();\n\t\t\tlet resolved = false;\n\n\t\t\tconst cleanup = () => {\n\t\t\t\tif (!resolved) {\n\t\t\t\t\tresolved = true;\n\t\t\t\t\tsocket.removeAllListeners();\n\t\t\t\t\tsocket.destroy();\n\t\t\t\t}\n\t\t\t};\n\n\t\t\tsocket.once(\"connect\", () => {\n\t\t\t\tcleanup();\n\t\t\t\tresolve(true);\n\t\t\t});\n\n\t\t\tsocket.once(\"error\", () => {\n\t\t\t\tcleanup();\n\t\t\t\tresolve(false);\n\t\t\t});\n\n\t\t\tsocket.setTimeout(500);\n\t\t\tsocket.once(\"timeout\", () => {\n\t\t\t\tcleanup();\n\t\t\t\tresolve(false);\n\t\t\t});\n\n\t\t\tsocket.connect(port, \"127.0.0.1\");\n\t\t});\n\n\t\tif (isListening) return false; // Port is in use\n\n\t\t// Phase 2: Bind check — catches OS-reserved/excluded port ranges\n\t\t// On Windows, Hyper-V/WSL reserve dynamic port ranges that TCP connect\n\t\t// cannot detect. Attempting to bind reveals these reservations.\n\t\tconst canBind = await new Promise<boolean>((resolve) => {\n\t\t\tconst server = net.createServer();\n\n\t\t\tserver.once(\"error\", (_err: NodeJS.ErrnoException) => {\n\t\t\t\t// EACCES / EADDRINUSE / EPERM = port is reserved or in use\n\t\t\t\tresolve(false);\n\t\t\t});\n\n\t\t\tserver.listen(port, \"0.0.0.0\", () => {\n\t\t\t\t// Successfully bound — port is truly available\n\t\t\t\tserver.close(() => resolve(true));\n\t\t\t});\n\t\t});\n\n\t\treturn canBind;\n\t} catch (error) {\n\t\tconsole.warn(`Port scanner error for port ${port}:`, error);\n\t\treturn true; // Failsafe: assume available\n\t}\n}\n\n/**\n * Find next available port starting from a base port.\n * Also skips ports already claimed by other services in this generation.\n */\nasync function findNextAvailablePort(\n\tstartPort: number,\n\tclaimedPorts: Set<number>,\n\tmaxAttempts = 100,\n): Promise<number> {\n\tfor (let i = 0; i < maxAttempts; i++) {\n\t\tconst port = startPort + i;\n\t\tif (port > 65535) break;\n\t\tif (claimedPorts.has(port)) continue;\n\t\tif (await isPortAvailable(port)) {\n\t\t\treturn port;\n\t\t}\n\t}\n\t// Fallback: return a high random port\n\treturn 50000 + Math.floor(Math.random() * 10000);\n}\n\n/**\n * Scan for port conflicts and suggest alternatives.\n *\n * Detects two types of conflicts:\n * 1. Host conflicts — port is already in use or reserved by the OS\n * 2. Inter-service conflicts — multiple selected services claim the same host port\n */\nexport async function scanPortConflicts(\n\tservices: ServiceDefinition[],\n): Promise<Map<string, Map<number, number>>> {\n\tconst portReassignments = new Map<string, Map<number, number>>();\n\n\t// Track which host ports are already claimed by services in this stack.\n\t// First service to claim a port wins; subsequent services get reassigned.\n\tconst claimedPorts = new Map<number, string>(); // port → serviceId that claimed it\n\n\tfor (const service of services) {\n\t\tif (!service.ports || service.ports.length === 0) continue;\n\n\t\tconst serviceReassignments = new Map<number, number>();\n\n\t\tfor (const portDef of service.ports) {\n\t\t\tif (!portDef.exposed) continue;\n\n\t\t\tconst port = portDef.host;\n\n\t\t\t// Check inter-service conflict first\n\t\t\tconst claimedBy = claimedPorts.get(port);\n\t\t\tif (claimedBy && claimedBy !== service.id) {\n\t\t\t\t// Another service already claimed this port — must reassign\n\t\t\t\tconst newPort = await findNextAvailablePort(port + 1000, new Set(claimedPorts.keys()));\n\t\t\t\tserviceReassignments.set(port, newPort);\n\t\t\t\tclaimedPorts.set(newPort, service.id);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Check host availability (TCP connect + bind check)\n\t\t\tconst isAvailable = await isPortAvailable(port);\n\n\t\t\tif (!isAvailable) {\n\t\t\t\tconst newPort = await findNextAvailablePort(port + 1000, new Set(claimedPorts.keys()));\n\t\t\t\tserviceReassignments.set(port, newPort);\n\t\t\t\tclaimedPorts.set(newPort, service.id);\n\t\t\t} else {\n\t\t\t\t// Port is available and not claimed — claim it\n\t\t\t\tclaimedPorts.set(port, service.id);\n\t\t\t}\n\t\t}\n\n\t\tif (serviceReassignments.size > 0) {\n\t\t\tportReassignments.set(service.id, serviceReassignments);\n\t\t}\n\t}\n\n\treturn portReassignments;\n}\n\n/**\n * Get conflicts in a user-friendly format\n */\nexport function formatPortConflicts(\n\tservices: ServiceDefinition[],\n\treassignments: Map<string, Map<number, number>>,\n): PortConflict[] {\n\tconst conflicts: PortConflict[] = [];\n\n\tfor (const service of services) {\n\t\tconst serviceReassignments = reassignments.get(service.id);\n\t\tif (!serviceReassignments) continue;\n\n\t\tfor (const portDef of service.ports || []) {\n\t\t\tconst newPort = serviceReassignments.get(portDef.host);\n\t\t\tif (newPort) {\n\t\t\t\tconflicts.push({\n\t\t\t\t\tport: portDef.host,\n\t\t\t\t\tserviceId: service.id,\n\t\t\t\t\tdescription: portDef.description || `${service.name} port`,\n\t\t\t\t\tsuggestedPort: newPort,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\treturn conflicts;\n}\n"],"mappings":";;;;;;;;;;;;AAqBA,eAAe,gBAAgB,MAAgC;AAC9D,KAAI;EACH,MAAM,MAAM,MAAM,OAAO;AAkCzB,MA/BoB,MAAM,IAAI,SAAkB,YAAY;GAC3D,MAAM,SAAS,IAAI,IAAI,QAAQ;GAC/B,IAAI,WAAW;GAEf,MAAM,gBAAgB;AACrB,QAAI,CAAC,UAAU;AACd,gBAAW;AACX,YAAO,oBAAoB;AAC3B,YAAO,SAAS;;;AAIlB,UAAO,KAAK,iBAAiB;AAC5B,aAAS;AACT,YAAQ,KAAK;KACZ;AAEF,UAAO,KAAK,eAAe;AAC1B,aAAS;AACT,YAAQ,MAAM;KACb;AAEF,UAAO,WAAW,IAAI;AACtB,UAAO,KAAK,iBAAiB;AAC5B,aAAS;AACT,YAAQ,MAAM;KACb;AAEF,UAAO,QAAQ,MAAM,YAAY;IAChC,CAEe,QAAO;AAmBxB,SAdgB,MAAM,IAAI,SAAkB,YAAY;GACvD,MAAM,SAAS,IAAI,cAAc;AAEjC,UAAO,KAAK,UAAU,SAAgC;AAErD,YAAQ,MAAM;KACb;AAEF,UAAO,OAAO,MAAM,iBAAiB;AAEpC,WAAO,YAAY,QAAQ,KAAK,CAAC;KAChC;IACD;UAGM,OAAO;AACf,UAAQ,KAAK,+BAA+B,KAAK,IAAI,MAAM;AAC3D,SAAO;;;;;;;AAQT,eAAe,sBACd,WACA,cACA,cAAc,KACI;AAClB,MAAK,IAAI,IAAI,GAAG,IAAI,aAAa,KAAK;EACrC,MAAM,OAAO,YAAY;AACzB,MAAI,OAAO,MAAO;AAClB,MAAI,aAAa,IAAI,KAAK,CAAE;AAC5B,MAAI,MAAM,gBAAgB,KAAK,CAC9B,QAAO;;AAIT,QAAO,MAAQ,KAAK,MAAM,KAAK,QAAQ,GAAG,IAAM;;;;;;;;;AAUjD,eAAsB,kBACrB,UAC4C;CAC5C,MAAM,oCAAoB,IAAI,KAAkC;CAIhE,MAAM,+BAAe,IAAI,KAAqB;AAE9C,MAAK,MAAM,WAAW,UAAU;AAC/B,MAAI,CAAC,QAAQ,SAAS,QAAQ,MAAM,WAAW,EAAG;EAElD,MAAM,uCAAuB,IAAI,KAAqB;AAEtD,OAAK,MAAM,WAAW,QAAQ,OAAO;AACpC,OAAI,CAAC,QAAQ,QAAS;GAEtB,MAAM,OAAO,QAAQ;GAGrB,MAAM,YAAY,aAAa,IAAI,KAAK;AACxC,OAAI,aAAa,cAAc,QAAQ,IAAI;IAE1C,MAAM,UAAU,MAAM,sBAAsB,OAAO,KAAM,IAAI,IAAI,aAAa,MAAM,CAAC,CAAC;AACtF,yBAAqB,IAAI,MAAM,QAAQ;AACvC,iBAAa,IAAI,SAAS,QAAQ,GAAG;AACrC;;AAMD,OAAI,CAFgB,MAAM,gBAAgB,KAAK,EAE7B;IACjB,MAAM,UAAU,MAAM,sBAAsB,OAAO,KAAM,IAAI,IAAI,aAAa,MAAM,CAAC,CAAC;AACtF,yBAAqB,IAAI,MAAM,QAAQ;AACvC,iBAAa,IAAI,SAAS,QAAQ,GAAG;SAGrC,cAAa,IAAI,MAAM,QAAQ,GAAG;;AAIpC,MAAI,qBAAqB,OAAO,EAC/B,mBAAkB,IAAI,QAAQ,IAAI,qBAAqB;;AAIzD,QAAO;;;;;AAMR,SAAgB,oBACf,UACA,eACiB;CACjB,MAAM,YAA4B,EAAE;AAEpC,MAAK,MAAM,WAAW,UAAU;EAC/B,MAAM,uBAAuB,cAAc,IAAI,QAAQ,GAAG;AAC1D,MAAI,CAAC,qBAAsB;AAE3B,OAAK,MAAM,WAAW,QAAQ,SAAS,EAAE,EAAE;GAC1C,MAAM,UAAU,qBAAqB,IAAI,QAAQ,KAAK;AACtD,OAAI,QACH,WAAU,KAAK;IACd,MAAM,QAAQ;IACd,WAAW,QAAQ;IACnB,aAAa,QAAQ,eAAe,GAAG,QAAQ,KAAK;IACpD,eAAe;IACf,CAAC;;;AAKL,QAAO"}
@@ -38,7 +38,7 @@ async function isPortAvailable(port) {
38
38
  })) return false;
39
39
  return await new Promise((resolve) => {
40
40
  const server = net.createServer();
41
- server.once("error", (err) => {
41
+ server.once("error", (_err) => {
42
42
  resolve(false);
43
43
  });
44
44
  server.listen(port, "0.0.0.0", () => {
@@ -1 +1 @@
1
- {"version":3,"file":"port-scanner.mjs","names":[],"sources":["../src/port-scanner.ts"],"sourcesContent":["import type { ServiceDefinition } from \"./types.js\";\n\n/**\n * Scans system for ports in use and suggests alternatives for conflicts\n */\nexport interface PortConflict {\n\tport: number;\n\tserviceId: string;\n\tdescription: string;\n\tsuggestedPort: number;\n}\n\n/**\n * Check if a port is available using a two-phase approach:\n * 1. TCP connect — detects if something is already listening\n * 2. Bind check — detects OS-reserved/excluded port ranges (e.g. Hyper-V on Windows)\n *\n * The connect-only approach misses Windows excluded port ranges because\n * ECONNREFUSED (nothing listening) looks the same as \"available\" even though\n * Docker's bind() will fail on those reserved ranges.\n */\nasync function isPortAvailable(port: number): Promise<boolean> {\n\ttry {\n\t\tconst net = await import(\"node:net\");\n\n\t\t// Phase 1: TCP connect check — is something actively listening?\n\t\tconst isListening = await new Promise<boolean>((resolve) => {\n\t\t\tconst socket = new net.Socket();\n\t\t\tlet resolved = false;\n\n\t\t\tconst cleanup = () => {\n\t\t\t\tif (!resolved) {\n\t\t\t\t\tresolved = true;\n\t\t\t\t\tsocket.removeAllListeners();\n\t\t\t\t\tsocket.destroy();\n\t\t\t\t}\n\t\t\t};\n\n\t\t\tsocket.once(\"connect\", () => {\n\t\t\t\tcleanup();\n\t\t\t\tresolve(true);\n\t\t\t});\n\n\t\t\tsocket.once(\"error\", () => {\n\t\t\t\tcleanup();\n\t\t\t\tresolve(false);\n\t\t\t});\n\n\t\t\tsocket.setTimeout(500);\n\t\t\tsocket.once(\"timeout\", () => {\n\t\t\t\tcleanup();\n\t\t\t\tresolve(false);\n\t\t\t});\n\n\t\t\tsocket.connect(port, \"127.0.0.1\");\n\t\t});\n\n\t\tif (isListening) return false; // Port is in use\n\n\t\t// Phase 2: Bind check — catches OS-reserved/excluded port ranges\n\t\t// On Windows, Hyper-V/WSL reserve dynamic port ranges that TCP connect\n\t\t// cannot detect. Attempting to bind reveals these reservations.\n\t\tconst canBind = await new Promise<boolean>((resolve) => {\n\t\t\tconst server = net.createServer();\n\n\t\t\tserver.once(\"error\", (err: NodeJS.ErrnoException) => {\n\t\t\t\t// EACCES / EADDRINUSE / EPERM = port is reserved or in use\n\t\t\t\tresolve(false);\n\t\t\t});\n\n\t\t\tserver.listen(port, \"0.0.0.0\", () => {\n\t\t\t\t// Successfully bound — port is truly available\n\t\t\t\tserver.close(() => resolve(true));\n\t\t\t});\n\t\t});\n\n\t\treturn canBind;\n\t} catch (error) {\n\t\tconsole.warn(`Port scanner error for port ${port}:`, error);\n\t\treturn true; // Failsafe: assume available\n\t}\n}\n\n/**\n * Find next available port starting from a base port.\n * Also skips ports already claimed by other services in this generation.\n */\nasync function findNextAvailablePort(\n\tstartPort: number,\n\tclaimedPorts: Set<number>,\n\tmaxAttempts = 100,\n): Promise<number> {\n\tfor (let i = 0; i < maxAttempts; i++) {\n\t\tconst port = startPort + i;\n\t\tif (port > 65535) break;\n\t\tif (claimedPorts.has(port)) continue;\n\t\tif (await isPortAvailable(port)) {\n\t\t\treturn port;\n\t\t}\n\t}\n\t// Fallback: return a high random port\n\treturn 50000 + Math.floor(Math.random() * 10000);\n}\n\n/**\n * Scan for port conflicts and suggest alternatives.\n *\n * Detects two types of conflicts:\n * 1. Host conflicts — port is already in use or reserved by the OS\n * 2. Inter-service conflicts — multiple selected services claim the same host port\n */\nexport async function scanPortConflicts(\n\tservices: ServiceDefinition[],\n): Promise<Map<string, Map<number, number>>> {\n\tconst portReassignments = new Map<string, Map<number, number>>();\n\n\t// Track which host ports are already claimed by services in this stack.\n\t// First service to claim a port wins; subsequent services get reassigned.\n\tconst claimedPorts = new Map<number, string>(); // port → serviceId that claimed it\n\n\tfor (const service of services) {\n\t\tif (!service.ports || service.ports.length === 0) continue;\n\n\t\tconst serviceReassignments = new Map<number, number>();\n\n\t\tfor (const portDef of service.ports) {\n\t\t\tif (!portDef.exposed) continue;\n\n\t\t\tconst port = portDef.host;\n\n\t\t\t// Check inter-service conflict first\n\t\t\tconst claimedBy = claimedPorts.get(port);\n\t\t\tif (claimedBy && claimedBy !== service.id) {\n\t\t\t\t// Another service already claimed this port — must reassign\n\t\t\t\tconst newPort = await findNextAvailablePort(port + 1000, new Set(claimedPorts.keys()));\n\t\t\t\tserviceReassignments.set(port, newPort);\n\t\t\t\tclaimedPorts.set(newPort, service.id);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Check host availability (TCP connect + bind check)\n\t\t\tconst isAvailable = await isPortAvailable(port);\n\n\t\t\tif (!isAvailable) {\n\t\t\t\tconst newPort = await findNextAvailablePort(port + 1000, new Set(claimedPorts.keys()));\n\t\t\t\tserviceReassignments.set(port, newPort);\n\t\t\t\tclaimedPorts.set(newPort, service.id);\n\t\t\t} else {\n\t\t\t\t// Port is available and not claimed — claim it\n\t\t\t\tclaimedPorts.set(port, service.id);\n\t\t\t}\n\t\t}\n\n\t\tif (serviceReassignments.size > 0) {\n\t\t\tportReassignments.set(service.id, serviceReassignments);\n\t\t}\n\t}\n\n\treturn portReassignments;\n}\n\n/**\n * Get conflicts in a user-friendly format\n */\nexport function formatPortConflicts(\n\tservices: ServiceDefinition[],\n\treassignments: Map<string, Map<number, number>>,\n): PortConflict[] {\n\tconst conflicts: PortConflict[] = [];\n\n\tfor (const service of services) {\n\t\tconst serviceReassignments = reassignments.get(service.id);\n\t\tif (!serviceReassignments) continue;\n\n\t\tfor (const portDef of service.ports || []) {\n\t\t\tconst newPort = serviceReassignments.get(portDef.host);\n\t\t\tif (newPort) {\n\t\t\t\tconflicts.push({\n\t\t\t\t\tport: portDef.host,\n\t\t\t\t\tserviceId: service.id,\n\t\t\t\t\tdescription: portDef.description || `${service.name} port`,\n\t\t\t\t\tsuggestedPort: newPort,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\treturn conflicts;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"port-scanner.mjs","names":[],"sources":["../src/port-scanner.ts"],"sourcesContent":["import type { ServiceDefinition } from \"./types.js\";\n\n/**\n * Scans system for ports in use and suggests alternatives for conflicts\n */\nexport interface PortConflict {\n\tport: number;\n\tserviceId: string;\n\tdescription: string;\n\tsuggestedPort: number;\n}\n\n/**\n * Check if a port is available using a two-phase approach:\n * 1. TCP connect — detects if something is already listening\n * 2. Bind check — detects OS-reserved/excluded port ranges (e.g. Hyper-V on Windows)\n *\n * The connect-only approach misses Windows excluded port ranges because\n * ECONNREFUSED (nothing listening) looks the same as \"available\" even though\n * Docker's bind() will fail on those reserved ranges.\n */\nasync function isPortAvailable(port: number): Promise<boolean> {\n\ttry {\n\t\tconst net = await import(\"node:net\");\n\n\t\t// Phase 1: TCP connect check — is something actively listening?\n\t\tconst isListening = await new Promise<boolean>((resolve) => {\n\t\t\tconst socket = new net.Socket();\n\t\t\tlet resolved = false;\n\n\t\t\tconst cleanup = () => {\n\t\t\t\tif (!resolved) {\n\t\t\t\t\tresolved = true;\n\t\t\t\t\tsocket.removeAllListeners();\n\t\t\t\t\tsocket.destroy();\n\t\t\t\t}\n\t\t\t};\n\n\t\t\tsocket.once(\"connect\", () => {\n\t\t\t\tcleanup();\n\t\t\t\tresolve(true);\n\t\t\t});\n\n\t\t\tsocket.once(\"error\", () => {\n\t\t\t\tcleanup();\n\t\t\t\tresolve(false);\n\t\t\t});\n\n\t\t\tsocket.setTimeout(500);\n\t\t\tsocket.once(\"timeout\", () => {\n\t\t\t\tcleanup();\n\t\t\t\tresolve(false);\n\t\t\t});\n\n\t\t\tsocket.connect(port, \"127.0.0.1\");\n\t\t});\n\n\t\tif (isListening) return false; // Port is in use\n\n\t\t// Phase 2: Bind check — catches OS-reserved/excluded port ranges\n\t\t// On Windows, Hyper-V/WSL reserve dynamic port ranges that TCP connect\n\t\t// cannot detect. Attempting to bind reveals these reservations.\n\t\tconst canBind = await new Promise<boolean>((resolve) => {\n\t\t\tconst server = net.createServer();\n\n\t\t\tserver.once(\"error\", (_err: NodeJS.ErrnoException) => {\n\t\t\t\t// EACCES / EADDRINUSE / EPERM = port is reserved or in use\n\t\t\t\tresolve(false);\n\t\t\t});\n\n\t\t\tserver.listen(port, \"0.0.0.0\", () => {\n\t\t\t\t// Successfully bound — port is truly available\n\t\t\t\tserver.close(() => resolve(true));\n\t\t\t});\n\t\t});\n\n\t\treturn canBind;\n\t} catch (error) {\n\t\tconsole.warn(`Port scanner error for port ${port}:`, error);\n\t\treturn true; // Failsafe: assume available\n\t}\n}\n\n/**\n * Find next available port starting from a base port.\n * Also skips ports already claimed by other services in this generation.\n */\nasync function findNextAvailablePort(\n\tstartPort: number,\n\tclaimedPorts: Set<number>,\n\tmaxAttempts = 100,\n): Promise<number> {\n\tfor (let i = 0; i < maxAttempts; i++) {\n\t\tconst port = startPort + i;\n\t\tif (port > 65535) break;\n\t\tif (claimedPorts.has(port)) continue;\n\t\tif (await isPortAvailable(port)) {\n\t\t\treturn port;\n\t\t}\n\t}\n\t// Fallback: return a high random port\n\treturn 50000 + Math.floor(Math.random() * 10000);\n}\n\n/**\n * Scan for port conflicts and suggest alternatives.\n *\n * Detects two types of conflicts:\n * 1. Host conflicts — port is already in use or reserved by the OS\n * 2. Inter-service conflicts — multiple selected services claim the same host port\n */\nexport async function scanPortConflicts(\n\tservices: ServiceDefinition[],\n): Promise<Map<string, Map<number, number>>> {\n\tconst portReassignments = new Map<string, Map<number, number>>();\n\n\t// Track which host ports are already claimed by services in this stack.\n\t// First service to claim a port wins; subsequent services get reassigned.\n\tconst claimedPorts = new Map<number, string>(); // port → serviceId that claimed it\n\n\tfor (const service of services) {\n\t\tif (!service.ports || service.ports.length === 0) continue;\n\n\t\tconst serviceReassignments = new Map<number, number>();\n\n\t\tfor (const portDef of service.ports) {\n\t\t\tif (!portDef.exposed) continue;\n\n\t\t\tconst port = portDef.host;\n\n\t\t\t// Check inter-service conflict first\n\t\t\tconst claimedBy = claimedPorts.get(port);\n\t\t\tif (claimedBy && claimedBy !== service.id) {\n\t\t\t\t// Another service already claimed this port — must reassign\n\t\t\t\tconst newPort = await findNextAvailablePort(port + 1000, new Set(claimedPorts.keys()));\n\t\t\t\tserviceReassignments.set(port, newPort);\n\t\t\t\tclaimedPorts.set(newPort, service.id);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Check host availability (TCP connect + bind check)\n\t\t\tconst isAvailable = await isPortAvailable(port);\n\n\t\t\tif (!isAvailable) {\n\t\t\t\tconst newPort = await findNextAvailablePort(port + 1000, new Set(claimedPorts.keys()));\n\t\t\t\tserviceReassignments.set(port, newPort);\n\t\t\t\tclaimedPorts.set(newPort, service.id);\n\t\t\t} else {\n\t\t\t\t// Port is available and not claimed — claim it\n\t\t\t\tclaimedPorts.set(port, service.id);\n\t\t\t}\n\t\t}\n\n\t\tif (serviceReassignments.size > 0) {\n\t\t\tportReassignments.set(service.id, serviceReassignments);\n\t\t}\n\t}\n\n\treturn portReassignments;\n}\n\n/**\n * Get conflicts in a user-friendly format\n */\nexport function formatPortConflicts(\n\tservices: ServiceDefinition[],\n\treassignments: Map<string, Map<number, number>>,\n): PortConflict[] {\n\tconst conflicts: PortConflict[] = [];\n\n\tfor (const service of services) {\n\t\tconst serviceReassignments = reassignments.get(service.id);\n\t\tif (!serviceReassignments) continue;\n\n\t\tfor (const portDef of service.ports || []) {\n\t\t\tconst newPort = serviceReassignments.get(portDef.host);\n\t\t\tif (newPort) {\n\t\t\t\tconflicts.push({\n\t\t\t\t\tport: portDef.host,\n\t\t\t\t\tserviceId: service.id,\n\t\t\t\t\tdescription: portDef.description || `${service.name} port`,\n\t\t\t\t\tsuggestedPort: newPort,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\treturn conflicts;\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-openclaw/core",
3
- "version": "1.0.18",
3
+ "version": "1.0.19",
4
4
  "private": false,
5
5
  "description": "Core logic for better-openclaw: schemas, service registry, resolver, composer, validators and generators",
6
6
  "author": "bidew.io <bachir@bidew.io>",
@@ -139,29 +139,28 @@ export class CoolifyDeployer implements PaasDeployer {
139
139
  }
140
140
 
141
141
  async deploy(input: DeployInput): Promise<DeployResult> {
142
- const steps: DeployStep[] = [
143
- { step: "Discover server", status: "pending" },
144
- { step: "Create project", status: "pending" },
145
- { step: "Create compose service", status: "pending" },
146
- { step: "Set environment variables", status: "pending" },
147
- { step: "Trigger deployment", status: "pending" },
148
- ];
142
+ const step1: DeployStep = { step: "Discover server", status: "pending" };
143
+ const step2: DeployStep = { step: "Create project", status: "pending" };
144
+ const step3: DeployStep = { step: "Create compose service", status: "pending" };
145
+ const step4: DeployStep = { step: "Set environment variables", status: "pending" };
146
+ const step5: DeployStep = { step: "Trigger deployment", status: "pending" };
147
+ const steps: DeployStep[] = [step1, step2, step3, step4, step5];
149
148
 
150
149
  const result: DeployResult = { success: false, steps };
151
150
 
152
151
  try {
153
152
  // Step 1: Discover default server
154
- steps[0].status = "running";
153
+ step1.status = "running";
155
154
  const servers = await coolifyFetch<CoolifyServer[]>(input.target, "/servers");
156
155
  if (!servers || servers.length === 0) {
157
156
  throw new Error("No servers found in Coolify instance");
158
157
  }
159
- const server = servers[0];
160
- steps[0].status = "done";
161
- steps[0].detail = `Server: ${server.name} (${server.ip})`;
158
+ const server = servers[0] as CoolifyServer;
159
+ step1.status = "done";
160
+ step1.detail = `Server: ${server.name} (${server.ip})`;
162
161
 
163
162
  // Step 2: Create project
164
- steps[1].status = "running";
163
+ step2.status = "running";
165
164
  const project = await coolifyFetch<CoolifyProject>(input.target, "/projects", {
166
165
  method: "POST",
167
166
  body: {
@@ -170,8 +169,8 @@ export class CoolifyDeployer implements PaasDeployer {
170
169
  },
171
170
  });
172
171
  result.projectId = project.uuid;
173
- steps[1].status = "done";
174
- steps[1].detail = `Project: ${project.uuid}`;
172
+ step2.status = "done";
173
+ step2.detail = `Project: ${project.uuid}`;
175
174
 
176
175
  // Get the default environment
177
176
  const projectDetail = await coolifyFetch<CoolifyProject>(
@@ -185,7 +184,7 @@ export class CoolifyDeployer implements PaasDeployer {
185
184
  }
186
185
 
187
186
  // Step 3: Create compose service with docker_compose_raw
188
- steps[2].status = "running";
187
+ step3.status = "running";
189
188
  const service = await coolifyFetch<CoolifyService>(input.target, "/services", {
190
189
  method: "POST",
191
190
  body: {
@@ -200,11 +199,11 @@ export class CoolifyDeployer implements PaasDeployer {
200
199
  },
201
200
  });
202
201
  result.composeId = service.uuid;
203
- steps[2].status = "done";
204
- steps[2].detail = `Service: ${service.uuid}`;
202
+ step3.status = "done";
203
+ step3.detail = `Service: ${service.uuid}`;
205
204
 
206
205
  // Step 4: Set environment variables
207
- steps[3].status = "running";
206
+ step4.status = "running";
208
207
  const envVars = parseEnvContent(input.envContent);
209
208
  if (envVars.length > 0) {
210
209
  await coolifyFetch(input.target, `/services/${service.uuid}/envs`, {
@@ -212,26 +211,26 @@ export class CoolifyDeployer implements PaasDeployer {
212
211
  body: envVars,
213
212
  });
214
213
  }
215
- steps[3].status = "done";
216
- steps[3].detail = `${envVars.length} variables set`;
214
+ step4.status = "done";
215
+ step4.detail = `${envVars.length} variables set`;
217
216
 
218
217
  // Step 5: Trigger deployment
219
- steps[4].status = "running";
218
+ step5.status = "running";
220
219
  const deployments = await coolifyFetch<{ deployments: CoolifyDeployment[] }>(
221
220
  input.target,
222
221
  `/deploy?uuid=${service.uuid}&force=true`,
223
222
  );
224
- steps[4].status = "done";
225
- steps[4].detail = deployments?.deployments?.[0]?.deployment_uuid ?? "Deployment triggered";
223
+ step5.status = "done";
224
+ step5.detail = deployments?.deployments?.[0]?.deployment_uuid ?? "Deployment triggered";
226
225
 
227
226
  result.success = true;
228
227
  const base = input.target.instanceUrl.replace(/\/+$/, "");
229
228
  result.dashboardUrl = `${base}/project/${project.uuid}`;
230
229
  } catch (err) {
231
- const failedIdx = steps.findIndex((s) => s.status === "running");
232
- if (failedIdx >= 0) {
233
- steps[failedIdx].status = "error";
234
- steps[failedIdx].detail = err instanceof Error ? err.message : String(err);
230
+ const failedStep = steps.find((s) => s.status === "running");
231
+ if (failedStep) {
232
+ failedStep.status = "error";
233
+ failedStep.detail = err instanceof Error ? err.message : String(err);
235
234
  }
236
235
  result.error = err instanceof Error ? err.message : String(err);
237
236
  }
@@ -87,18 +87,17 @@ export class DokployDeployer implements PaasDeployer {
87
87
  }
88
88
 
89
89
  async deploy(input: DeployInput): Promise<DeployResult> {
90
- const steps: DeployStep[] = [
91
- { step: "Create project", status: "pending" },
92
- { step: "Create compose stack", status: "pending" },
93
- { step: "Set environment variables", status: "pending" },
94
- { step: "Trigger deployment", status: "pending" },
95
- ];
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];
96
95
 
97
96
  const result: DeployResult = { success: false, steps };
98
97
 
99
98
  try {
100
99
  // Step 1: Create project
101
- steps[0].status = "running";
100
+ step1.status = "running";
102
101
  const project = await dokployFetch<DokployProject>(input.target, "project.create", {
103
102
  method: "POST",
104
103
  body: {
@@ -107,8 +106,8 @@ export class DokployDeployer implements PaasDeployer {
107
106
  },
108
107
  });
109
108
  result.projectId = project.projectId;
110
- steps[0].status = "done";
111
- steps[0].detail = `Project ID: ${project.projectId}`;
109
+ step1.status = "done";
110
+ step1.detail = `Project ID: ${project.projectId}`;
112
111
 
113
112
  // Get the default environment ID
114
113
  const projectDetail = await dokployFetch<DokployProject>(
@@ -121,7 +120,7 @@ export class DokployDeployer implements PaasDeployer {
121
120
  }
122
121
 
123
122
  // Step 2: Create compose stack
124
- steps[1].status = "running";
123
+ step2.status = "running";
125
124
  const compose = await dokployFetch<DokployCompose>(input.target, "compose.create", {
126
125
  method: "POST",
127
126
  body: {
@@ -131,11 +130,11 @@ export class DokployDeployer implements PaasDeployer {
131
130
  },
132
131
  });
133
132
  result.composeId = compose.composeId;
134
- steps[1].status = "done";
135
- steps[1].detail = `Compose ID: ${compose.composeId}`;
133
+ step2.status = "done";
134
+ step2.detail = `Compose ID: ${compose.composeId}`;
136
135
 
137
136
  // Step 3: Set environment variables
138
- steps[2].status = "running";
137
+ step3.status = "running";
139
138
  await dokployFetch(input.target, "compose.update", {
140
139
  method: "POST",
141
140
  body: {
@@ -143,10 +142,10 @@ export class DokployDeployer implements PaasDeployer {
143
142
  env: input.envContent,
144
143
  },
145
144
  });
146
- steps[2].status = "done";
145
+ step3.status = "done";
147
146
 
148
147
  // Step 4: Trigger deployment
149
- steps[3].status = "running";
148
+ step4.status = "running";
150
149
  await dokployFetch(input.target, "compose.deploy", {
151
150
  method: "POST",
152
151
  body: {
@@ -155,16 +154,16 @@ export class DokployDeployer implements PaasDeployer {
155
154
  description: input.description ?? "Deployed via OpenClaw web builder",
156
155
  },
157
156
  });
158
- steps[3].status = "done";
157
+ step4.status = "done";
159
158
 
160
159
  result.success = true;
161
160
  const base = input.target.instanceUrl.replace(/\/+$/, "");
162
161
  result.dashboardUrl = `${base}/dashboard/project/${project.projectId}`;
163
162
  } catch (err) {
164
- const failedIdx = steps.findIndex((s) => s.status === "running");
165
- if (failedIdx >= 0) {
166
- steps[failedIdx].status = "error";
167
- steps[failedIdx].detail = err instanceof Error ? err.message : String(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);
168
167
  }
169
168
  result.error = err instanceof Error ? err.message : String(err);
170
169
  }
@@ -63,7 +63,7 @@ async function isPortAvailable(port: number): Promise<boolean> {
63
63
  const canBind = await new Promise<boolean>((resolve) => {
64
64
  const server = net.createServer();
65
65
 
66
- server.once("error", (err: NodeJS.ErrnoException) => {
66
+ server.once("error", (_err: NodeJS.ErrnoException) => {
67
67
  // EACCES / EADDRINUSE / EPERM = port is reserved or in use
68
68
  resolve(false);
69
69
  });