@mks2508/coolify-mks-cli-mcp 0.6.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/cli/coolify-state.d.ts +101 -5
  2. package/dist/cli/coolify-state.d.ts.map +1 -1
  3. package/dist/cli/index.js +23165 -11543
  4. package/dist/cli/ui/highlighter.d.ts +28 -0
  5. package/dist/cli/ui/highlighter.d.ts.map +1 -0
  6. package/dist/cli/ui/index.d.ts +9 -0
  7. package/dist/cli/ui/index.d.ts.map +1 -0
  8. package/dist/cli/ui/spinners.d.ts +100 -0
  9. package/dist/cli/ui/spinners.d.ts.map +1 -0
  10. package/dist/cli/ui/tables.d.ts +103 -0
  11. package/dist/cli/ui/tables.d.ts.map +1 -0
  12. package/dist/coolify/config.d.ts +25 -0
  13. package/dist/coolify/config.d.ts.map +1 -1
  14. package/dist/coolify/index.d.ts +139 -12
  15. package/dist/coolify/index.d.ts.map +1 -1
  16. package/dist/coolify/types.d.ts +160 -2
  17. package/dist/coolify/types.d.ts.map +1 -1
  18. package/dist/examples/demo-ui.d.ts +8 -0
  19. package/dist/examples/demo-ui.d.ts.map +1 -0
  20. package/dist/index.cjs +2580 -230
  21. package/dist/index.cjs.map +1 -1
  22. package/dist/index.js +2598 -226
  23. package/dist/index.js.map +1 -1
  24. package/dist/sdk.d.ts +96 -7
  25. package/dist/sdk.d.ts.map +1 -1
  26. package/dist/server/stdio.js +475 -73
  27. package/dist/tools/definitions.d.ts.map +1 -1
  28. package/dist/tools/handlers.d.ts.map +1 -1
  29. package/dist/utils/env-parser.d.ts +24 -0
  30. package/dist/utils/env-parser.d.ts.map +1 -0
  31. package/dist/utils/format.d.ts +32 -0
  32. package/dist/utils/format.d.ts.map +1 -1
  33. package/package.json +17 -4
  34. package/src/cli/actions.ts +9 -2
  35. package/src/cli/commands/create.ts +332 -24
  36. package/src/cli/commands/db.ts +37 -0
  37. package/src/cli/commands/delete.ts +6 -2
  38. package/src/cli/commands/deploy.ts +347 -49
  39. package/src/cli/commands/deployments.ts +6 -2
  40. package/src/cli/commands/diagnose.ts +3 -3
  41. package/src/cli/commands/env.ts +424 -31
  42. package/src/cli/commands/exec.ts +6 -2
  43. package/src/cli/commands/init.ts +991 -0
  44. package/src/cli/commands/logs.ts +224 -24
  45. package/src/cli/commands/main-menu.ts +21 -0
  46. package/src/cli/commands/projects.ts +312 -29
  47. package/src/cli/commands/restart.ts +6 -2
  48. package/src/cli/commands/service-logs.ts +14 -0
  49. package/src/cli/commands/show.ts +45 -12
  50. package/src/cli/commands/start.ts +6 -2
  51. package/src/cli/commands/status.ts +554 -0
  52. package/src/cli/commands/stop.ts +6 -2
  53. package/src/cli/commands/svc.ts +7 -1
  54. package/src/cli/commands/update.ts +79 -2
  55. package/src/cli/commands/volumes.ts +293 -0
  56. package/src/cli/coolify-state.ts +203 -12
  57. package/src/cli/index.ts +138 -11
  58. package/src/cli/name-resolver.ts +228 -0
  59. package/src/cli/ui/banner.ts +276 -0
  60. package/src/cli/ui/highlighter.ts +176 -0
  61. package/src/cli/ui/index.ts +9 -0
  62. package/src/cli/ui/prompts.ts +155 -0
  63. package/src/cli/ui/screen.ts +630 -0
  64. package/src/cli/ui/select.ts +280 -0
  65. package/src/cli/ui/spinners.ts +256 -0
  66. package/src/cli/ui/tables.ts +407 -0
  67. package/src/coolify/config.ts +75 -0
  68. package/src/coolify/index.ts +565 -101
  69. package/src/coolify/types.ts +165 -2
  70. package/src/examples/demo-ui.ts +78 -0
  71. package/src/sdk.ts +211 -1
  72. package/src/tools/definitions.ts +22 -0
  73. package/src/tools/handlers.ts +19 -0
  74. package/src/utils/env-parser.ts +45 -0
  75. package/src/utils/format.ts +178 -0
@@ -21,6 +21,7 @@ import {
21
21
  type ICoolifyDeployResult,
22
22
  type ICoolifyDestination,
23
23
  type ICoolifyEnvironment,
24
+ type ICoolifyGithubApp,
24
25
  type ICoolifyLogs,
25
26
  type ICoolifyLogsOptions,
26
27
  type ICoolifyPrivateKey,
@@ -32,6 +33,10 @@ import {
32
33
  type ICoolifyTeam,
33
34
  type ICoolifyUpdateOptions,
34
35
  type ICoolifyVersion,
36
+ type ICoolifyInfrastructureTree,
37
+ type ICoolifyProjectNode,
38
+ type ICoolifyEnvironmentNode,
39
+ type ICoolifyResource,
35
40
  type IProgressCallback,
36
41
  } from "./types.js";
37
42
 
@@ -180,7 +185,12 @@ export class CoolifyService {
180
185
 
181
186
  try {
182
187
  data = text ? JSON.parse(text) : undefined;
183
- } catch {
188
+ } catch (parseErr) {
189
+ const preview = text ? text.slice(0, 200) : "(empty body)";
190
+ const method = options.method ?? "GET";
191
+ log.warn(
192
+ `Failed to parse JSON response from ${method} ${endpoint} (status ${response.status}): ${parseErr instanceof Error ? parseErr.message : String(parseErr)}. Body preview: ${preview}`,
193
+ );
184
194
  if (!response.ok) {
185
195
  return {
186
196
  error: text || `HTTP ${response.status}`,
@@ -188,6 +198,13 @@ export class CoolifyService {
188
198
  durationMs,
189
199
  };
190
200
  }
201
+ // OK status but body is not JSON — synthesize an error so the caller's error path triggers.
202
+ // Avoids silently returning undefined data which causes cryptic downstream crashes.
203
+ return {
204
+ error: `Response was not valid JSON (status ${response.status}): ${preview}`,
205
+ status: response.status,
206
+ durationMs,
207
+ };
191
208
  }
192
209
 
193
210
  if (!response.ok) {
@@ -316,8 +333,8 @@ export class CoolifyService {
316
333
  "private-github-app": "/applications/private-github-app",
317
334
  "private-deploy-key": "/applications/private-deploy-key",
318
335
  dockerfile: "/applications/dockerfile",
319
- "docker-image": "/applications/docker-image",
320
- "docker-compose": "/applications/docker-compose",
336
+ "docker-image": "/applications/dockerimage",
337
+ "docker-compose": "/applications/dockercompose",
321
338
  dockerimage: "/applications/dockerimage",
322
339
  dockercompose: "/applications/dockercompose",
323
340
  };
@@ -330,43 +347,53 @@ export class CoolifyService {
330
347
  description: options.description,
331
348
  project_uuid: options.projectUuid,
332
349
  environment_uuid: options.environmentUuid,
350
+ environment_name: options.environmentName,
333
351
  server_uuid: options.serverUuid,
334
352
  };
335
353
 
354
+ // Git repository — applies to all types that have a repo URL.
355
+ // Coolify's API expects the full URL (https://, http://, git://, or git@).
356
+ if (options.githubRepoUrl) {
357
+ body.git_repository = options.githubRepoUrl;
358
+ }
359
+
336
360
  // Type-specific fields
337
361
  if (
338
362
  appType === "public" ||
339
363
  appType === "private-github-app" ||
340
364
  appType === "private-deploy-key"
341
365
  ) {
342
- if (options.githubRepoUrl) {
343
- // Coolify expects 'user/repo' format, not full URL
344
- body.git_repository = options.githubRepoUrl
345
- .replace(/^https?:\/\/github\.com\//, "")
346
- .replace(/\.git$/, "");
347
- }
348
366
  if (options.githubAppUuid) {
349
367
  body.github_app_uuid = options.githubAppUuid;
350
368
  }
369
+ // private_key_uuid is required for private-deploy-key type
370
+ if (appType === "private-deploy-key" && options.privateKeyUuid) {
371
+ body.private_key_uuid = options.privateKeyUuid;
372
+ }
351
373
  body.git_branch = options.branch || "main";
352
374
  body.build_pack = options.buildPack || "dockerfile";
353
375
  if (options.portsExposes) {
354
376
  body.ports_exposes = options.portsExposes;
355
377
  }
356
378
  // Dockerfile / Docker Compose configuration
379
+ // Coolify validates these paths against /^\/.*$/ regex — prepend leading slash
380
+ // if caller passed a relative path (CLI --help suggests "apps/x/Dockerfile" without /)
357
381
  if (options.dockerfileLocation) {
358
- body.dockerfile_location = options.dockerfileLocation;
382
+ const p = options.dockerfileLocation;
383
+ body.dockerfile_location = p.startsWith("/") ? p : `/${p}`;
359
384
  }
360
385
  if (options.dockerComposeLocation) {
361
- body.docker_compose_location = options.dockerComposeLocation;
386
+ const p = options.dockerComposeLocation;
387
+ body.docker_compose_location = p.startsWith("/") ? p : `/${p}`;
362
388
  }
363
389
  if (options.baseDirectory) {
364
- body.base_directory = options.baseDirectory;
390
+ const p = options.baseDirectory;
391
+ body.base_directory = p.startsWith("/") ? p : `/${p}`;
365
392
  }
366
393
  } else if (appType === "docker-image" && options.dockerImage) {
367
- body.docker_image = options.dockerImage;
394
+ body.docker_registry_image_name = options.dockerImage;
368
395
  } else if (appType === "docker-compose" && options.dockerCompose) {
369
- body.docker_compose = options.dockerCompose;
396
+ body.docker_compose_raw = options.dockerCompose;
370
397
  }
371
398
 
372
399
  log.debug(`Create application body: ${JSON.stringify(body, null, 2)}`);
@@ -394,6 +421,15 @@ export class CoolifyService {
394
421
  /**
395
422
  * Sets environment variables for an application.
396
423
  *
424
+ * Delegates to {@link bulkUpdateEnvironmentVariables} so the same
425
+ * create-or-update logic applies. This fixes the post-delete scenario
426
+ * (where the variable was deleted via the API and only its base value
427
+ * from docker-compose/git remains visible) and prevents duplicates
428
+ * that the singular POST endpoint would create when called for a key
429
+ * that already exists. Also eliminates the N+1 API calls and the
430
+ * TOCTOU race between the DELETE and the re-POST that the previous
431
+ * per-key POST -> DELETE -> re-POST dance had.
432
+ *
397
433
  * @param appUuid - Application UUID
398
434
  * @param envVars - Environment variables to set
399
435
  * @returns Result indicating success or error
@@ -406,43 +442,19 @@ export class CoolifyService {
406
442
  `Setting ${Object.keys(envVars).length} environment variables for ${appUuid}`,
407
443
  );
408
444
 
409
- // Coolify API: POST to create, PATCH to update existing
410
- for (const [key, value] of Object.entries(envVars)) {
411
- const result = await this.request(`/applications/${appUuid}/envs`, {
412
- method: "POST",
413
- body: JSON.stringify({ key, value, is_preview: false }),
414
- });
445
+ const vars = Object.entries(envVars).map(([key, value]) => ({
446
+ key,
447
+ value,
448
+ is_buildtime: false,
449
+ is_runtime: true,
450
+ }));
415
451
 
416
- if (result.error && result.error.includes("already exists")) {
417
- // Var exists — DELETE + re-POST (Coolify PATCH on envs returns 404)
418
- const listResult = await this.request<
419
- Array<{ uuid: string; key: string }>
420
- >(`/applications/${appUuid}/envs`);
421
- const existing = listResult.data?.find((v) => v.key === key);
422
- if (existing) {
423
- await this.request(`/applications/${appUuid}/envs/${existing.uuid}`, {
424
- method: "DELETE",
425
- });
426
- const repost = await this.request(`/applications/${appUuid}/envs`, {
427
- method: "POST",
428
- body: JSON.stringify({ key, value, is_preview: false }),
429
- });
430
- if (repost.error) {
431
- log.error(`Failed to update env var ${key}: ${repost.error}`);
432
- return err(new Error(`Failed to update ${key}: ${repost.error}`));
433
- }
434
- log.debug(`Updated existing env var: ${key}`);
435
- } else {
436
- log.error(`Failed to set env var ${key}: ${result.error}`);
437
- return err(new Error(`Failed to set ${key}: ${result.error}`));
438
- }
439
- } else if (result.error) {
440
- log.error(`Failed to set env var ${key}: ${result.error}`);
441
- return err(new Error(`Failed to set ${key}: ${result.error}`));
442
- }
452
+ const result = await this.bulkUpdateEnvironmentVariables(appUuid, vars);
453
+ if (isErr(result)) {
454
+ return err(result.error);
443
455
  }
444
456
 
445
- log.success(`${Object.keys(envVars).length} environment variables set`);
457
+ log.success(`${vars.length} environment variables set`);
446
458
  return ok(undefined);
447
459
  }
448
460
 
@@ -472,60 +484,41 @@ export class CoolifyService {
472
484
 
473
485
  /**
474
486
  * Sets a single environment variable for an application.
475
- * Uses PATCH if variable exists, POST if it doesn't.
487
+ *
488
+ * Delegates to {@link bulkUpdateEnvironmentVariables} so the same
489
+ * create-or-update logic applies. This fixes the post-delete scenario
490
+ * (where the variable was deleted via the API and only its base value
491
+ * from docker-compose/git remains visible) and prevents duplicates
492
+ * that the singular PATCH endpoint would create when called without
493
+ * the variable's UUID.
476
494
  *
477
495
  * @param appUuid - Application UUID
478
496
  * @param key - Variable name
479
497
  * @param value - Variable value
480
- * @param isBuildTime - Whether the variable is available at build time (only for new vars)
498
+ * @param isBuildTime - Whether the variable is available at build time
499
+ * (only for new vars; existing runtime-only vars will be flipped to
500
+ * build-time and vice versa).
481
501
  * @returns Result indicating success or error
482
502
  */
483
503
  async setEnvironmentVariable(
484
504
  appUuid: string,
485
505
  key: string,
486
506
  value: string,
487
- _isBuildTime: boolean = false,
507
+ isBuildTime: boolean = false,
488
508
  ): Promise<Result<void, Error>> {
489
- log.info(`Setting environment variable ${key} for ${appUuid}`);
490
-
491
- // Check if variable already exists
492
- const existingVars = await this.getEnvironmentVariables(appUuid);
493
- if (isErr(existingVars)) {
494
- return err(existingVars.error);
495
- }
496
-
497
- const exists = existingVars.value.some((ev) => ev.key === key);
509
+ log.info(`Setting environment variable ${key} for ${appUuid} (buildtime: ${isBuildTime})`);
498
510
 
499
- if (exists) {
500
- // Use PATCH to update existing variable
501
- log.debug(`Variable ${key} exists, using PATCH to update`);
502
- const result = await this.request<{ uuid: string }>(
503
- `/applications/${appUuid}/envs`,
504
- {
505
- method: "PATCH",
506
- body: JSON.stringify({ key, value }),
507
- },
508
- );
509
-
510
- if (result.error) {
511
- log.error(`Failed to update env var: ${result.error}`);
512
- return err(new Error(result.error));
513
- }
514
- } else {
515
- // Use POST to create new variable
516
- log.debug(`Variable ${key} does not exist, using POST to create`);
517
- const result = await this.request<{ uuid: string }>(
518
- `/applications/${appUuid}/envs`,
519
- {
520
- method: "POST",
521
- body: JSON.stringify({ key, value }),
522
- },
523
- );
511
+ const result = await this.bulkUpdateEnvironmentVariables(appUuid, [
512
+ {
513
+ key,
514
+ value,
515
+ is_buildtime: isBuildTime,
516
+ is_runtime: !isBuildTime,
517
+ },
518
+ ]);
524
519
 
525
- if (result.error) {
526
- log.error(`Failed to create env var: ${result.error}`);
527
- return err(new Error(result.error));
528
- }
520
+ if (isErr(result)) {
521
+ return err(result.error);
529
522
  }
530
523
 
531
524
  log.success(`Environment variable ${key} set for ${appUuid}`);
@@ -649,12 +642,7 @@ export class CoolifyService {
649
642
  async listGithubApps(
650
643
  page?: number,
651
644
  perPage?: number,
652
- ): Promise<
653
- Result<
654
- Array<{ id: number; uuid: string; name: string; is_public: boolean }>,
655
- Error
656
- >
657
- > {
645
+ ): Promise<Result<ICoolifyGithubApp[], Error>> {
658
646
  let endpoint = "/github-apps";
659
647
  const params = new URLSearchParams();
660
648
  if (page) params.set("page", page.toString());
@@ -663,14 +651,40 @@ export class CoolifyService {
663
651
  endpoint += `?${params.toString()}`;
664
652
  }
665
653
 
666
- const result =
667
- await this.request<
668
- Array<{ id: number; uuid: string; name: string; is_public: boolean }>
669
- >(endpoint);
654
+ const result = await this.request<ICoolifyGithubApp[]>(endpoint);
670
655
  if (result.error) return err(new Error(result.error));
671
656
  return ok(result.data || []);
672
657
  }
673
658
 
659
+ /**
660
+ * Lists all GitHub Apps configured in Coolify.
661
+ * Fetches all pages internally and returns a flat list.
662
+ *
663
+ * @param perPage - Items per page for each request (default: 50)
664
+ * @returns Result with all GitHub Apps or error
665
+ */
666
+ async listGithubAppsAll(
667
+ perPage = 50,
668
+ ): Promise<Result<ICoolifyGithubApp[], Error>> {
669
+ const allApps: ICoolifyGithubApp[] = [];
670
+ let page = 1;
671
+
672
+ while (true) {
673
+ const result = await this.listGithubApps(page, perPage);
674
+ if (isErr(result)) return err(result.error);
675
+ const apps = result.value;
676
+
677
+ if (apps.length === 0) break;
678
+ allApps.push(...apps);
679
+
680
+ // If we got fewer than perPage, we've reached the last page
681
+ if (apps.length < perPage) break;
682
+ page++;
683
+ }
684
+
685
+ return ok(allApps);
686
+ }
687
+
674
688
  /**
675
689
  * Lists all projects.
676
690
  *
@@ -906,10 +920,32 @@ export class CoolifyService {
906
920
  if (options.domains) body.domains = options.domains;
907
921
  if (options.dockerComposeDomains)
908
922
  body.docker_compose_domains = options.dockerComposeDomains;
923
+ if (options.dockerComposeRaw !== undefined)
924
+ body.docker_compose_raw = options.dockerComposeRaw;
909
925
  if (options.isForceHttpsEnabled !== undefined)
910
926
  body.is_force_https_enabled = options.isForceHttpsEnabled;
911
927
  if (options.isAutoDeployEnabled !== undefined)
912
928
  body.is_auto_deploy_enabled = options.isAutoDeployEnabled;
929
+ if (options.watchPaths !== undefined)
930
+ body.watch_paths = options.watchPaths;
931
+ if (options.healthCheckEnabled !== undefined)
932
+ body.health_check_enabled = options.healthCheckEnabled;
933
+ if (options.healthCheckPath)
934
+ body.health_check_path = options.healthCheckPath;
935
+ if (options.healthCheckPort)
936
+ body.health_check_port = String(options.healthCheckPort);
937
+ if (options.healthCheckMethod)
938
+ body.health_check_method = options.healthCheckMethod;
939
+ if (options.healthCheckInterval)
940
+ body.health_check_interval = options.healthCheckInterval;
941
+ if (options.healthCheckTimeout)
942
+ body.health_check_timeout = options.healthCheckTimeout;
943
+ if (options.healthCheckRetries)
944
+ body.health_check_retries = options.healthCheckRetries;
945
+ if (options.healthCheckStartPeriod)
946
+ body.health_check_start_period = options.healthCheckStartPeriod;
947
+ if (options.healthCheckReturnCode)
948
+ body.health_check_return_code = options.healthCheckReturnCode;
913
949
 
914
950
  const result = await this.request<ICoolifyApplication>(
915
951
  `/applications/${appUuid}`,
@@ -1004,8 +1040,15 @@ export class CoolifyService {
1004
1040
  /**
1005
1041
  * Bulk updates environment variables for an application.
1006
1042
  *
1043
+ * Uses the bulk endpoint `PATCH /applications/{appUuid}/envs/bulk` which
1044
+ * has create-or-update semantics: if the variable exists in the override
1045
+ * table, its value is updated; otherwise a new override is created. This
1046
+ * is the only reliable way to update a variable that has been deleted
1047
+ * (i.e. its base value from docker-compose/git is still visible in
1048
+ * `getEnvironmentVariables` but no override row exists).
1049
+ *
1007
1050
  * @param appUuid - Application UUID
1008
- * @param envVars - Array of { key, value, is_preview? } objects
1051
+ * @param envVars - Array of variable definitions to upsert
1009
1052
  * @returns Result indicating success or error
1010
1053
  */
1011
1054
  async bulkUpdateEnvironmentVariables(
@@ -1014,15 +1057,19 @@ export class CoolifyService {
1014
1057
  key: string;
1015
1058
  value: string;
1016
1059
  is_preview?: boolean;
1060
+ is_buildtime?: boolean;
1061
+ is_runtime?: boolean;
1017
1062
  }>,
1018
1063
  ): Promise<Result<{ message: string }, Error>> {
1019
1064
  log.info(`Bulk updating ${envVars.length} env vars for ${appUuid}`);
1020
1065
 
1066
+ // Coolify bulk endpoint requires { data: [...] } envelope, not a raw array.
1067
+ // Sending a raw array yields 400 "Bulk data is required." from the API.
1021
1068
  const result = await this.request<{ message: string }>(
1022
1069
  `/applications/${appUuid}/envs/bulk`,
1023
1070
  {
1024
1071
  method: "PATCH",
1025
- body: JSON.stringify(envVars),
1072
+ body: JSON.stringify({ data: envVars }),
1026
1073
  },
1027
1074
  );
1028
1075
 
@@ -1035,6 +1082,50 @@ export class CoolifyService {
1035
1082
  return ok(result.data || { message: "Environment variables updated" });
1036
1083
  }
1037
1084
 
1085
+ /**
1086
+ * Bulk updates environment variables for a database.
1087
+ *
1088
+ * Uses the bulk endpoint `PATCH /databases/{databaseUuid}/envs/bulk` which
1089
+ * has create-or-update semantics. Note: the database schema is narrower
1090
+ * than applications — it accepts `key`, `value`, `is_literal`,
1091
+ * `is_multiline`, `is_shown_once` but does NOT accept `is_preview`,
1092
+ * `is_buildtime` or `is_runtime` (those flags are application-only).
1093
+ *
1094
+ * @param databaseUuid - Database UUID
1095
+ * @param envVars - Array of variable definitions to upsert
1096
+ * @returns Result indicating success or error
1097
+ */
1098
+ async bulkUpdateDatabaseEnvVars(
1099
+ databaseUuid: string,
1100
+ envVars: Array<{
1101
+ key: string;
1102
+ value: string;
1103
+ is_literal?: boolean;
1104
+ is_multiline?: boolean;
1105
+ is_shown_once?: boolean;
1106
+ }>,
1107
+ ): Promise<Result<{ message: string }, Error>> {
1108
+ log.info(`Bulk updating ${envVars.length} env vars for database ${databaseUuid}`);
1109
+
1110
+ // Coolify bulk endpoint requires { data: [...] } envelope, not a raw array.
1111
+ // Sending a raw array yields 400 "Bulk data is required." from the API.
1112
+ const result = await this.request<{ message: string }>(
1113
+ `/databases/${databaseUuid}/envs/bulk`,
1114
+ {
1115
+ method: "PATCH",
1116
+ body: JSON.stringify({ data: envVars }),
1117
+ },
1118
+ );
1119
+
1120
+ if (result.error) {
1121
+ log.error(`Failed to bulk update database env vars: ${result.error}`);
1122
+ return err(new Error(result.error));
1123
+ }
1124
+
1125
+ log.success(`Bulk updated ${envVars.length} env vars for database ${databaseUuid}`);
1126
+ return ok(result.data || { message: "Environment variables updated" });
1127
+ }
1128
+
1038
1129
  /**
1039
1130
  * Gets deployment history for an application.
1040
1131
  *
@@ -1620,6 +1711,186 @@ export class CoolifyService {
1620
1711
  return ok({ success: true, message: "Service deleted" });
1621
1712
  }
1622
1713
 
1714
+ /**
1715
+ * Gets the full infrastructure tree: Projects → Environments → Resources.
1716
+ *
1717
+ * Fetches all projects, apps, databases, and services in parallel,
1718
+ * then groups them by environment_id into a hierarchical tree.
1719
+ *
1720
+ * @returns Result with the full infrastructure tree or error
1721
+ */
1722
+ async getInfrastructureTree(): Promise<
1723
+ Result<ICoolifyInfrastructureTree, Error>
1724
+ > {
1725
+ log.info("Building infrastructure tree");
1726
+
1727
+ // Fetch all data in parallel
1728
+ const [projectsResult, appsResult, dbsResult, svcsResult, serversResult] =
1729
+ await Promise.all([
1730
+ this.listProjects(),
1731
+ this.listApplications(),
1732
+ this.listDatabases(),
1733
+ this.listServices(),
1734
+ this.listServers(),
1735
+ ]);
1736
+
1737
+ if (isErr(projectsResult)) return err(projectsResult.error);
1738
+ if (isErr(appsResult)) return err(appsResult.error);
1739
+ if (isErr(serversResult)) return err(serversResult.error);
1740
+
1741
+ const projects = projectsResult.value;
1742
+ const apps = appsResult.value;
1743
+ const dbs = isErr(dbsResult) ? [] : dbsResult.value;
1744
+ const svcs = isErr(svcsResult) ? [] : svcsResult.value;
1745
+ const servers = serversResult.value;
1746
+
1747
+ // Fetch environments for each project in parallel
1748
+ const envResults = await Promise.allSettled(
1749
+ projects.map((p) => this.getProjectEnvironments(p.uuid)),
1750
+ );
1751
+
1752
+ // Build environment_id → { projectUuid, envName, envUuid } mapping
1753
+ const envIdMap = new Map<
1754
+ number,
1755
+ { projectUuid: string; envName: string; envUuid: string }
1756
+ >();
1757
+
1758
+ for (let i = 0; i < projects.length; i++) {
1759
+ const envResult = envResults[i];
1760
+ if (envResult.status === "fulfilled" && !isErr(envResult.value)) {
1761
+ for (const env of envResult.value.value) {
1762
+ envIdMap.set(env.id, {
1763
+ projectUuid: projects[i].uuid,
1764
+ envName: env.name,
1765
+ envUuid: env.uuid,
1766
+ });
1767
+ }
1768
+ }
1769
+ }
1770
+
1771
+ // Build project nodes
1772
+ const projectNodes: ICoolifyProjectNode[] = projects.map((p) => ({
1773
+ uuid: p.uuid,
1774
+ name: p.name,
1775
+ description: p.description,
1776
+ environments: [],
1777
+ }));
1778
+
1779
+ const projectMap = new Map<string, ICoolifyProjectNode>();
1780
+ for (const node of projectNodes) {
1781
+ projectMap.set(node.uuid, node);
1782
+ }
1783
+
1784
+ // Populate environments from envIdMap
1785
+ const envNodeMap = new Map<number, ICoolifyEnvironmentNode>();
1786
+ for (const [envId, info] of envIdMap) {
1787
+ const project = projectMap.get(info.projectUuid);
1788
+ if (!project) continue;
1789
+
1790
+ let envNode = project.environments.find((e) => e.id === envId);
1791
+ if (!envNode) {
1792
+ envNode = {
1793
+ id: envId,
1794
+ uuid: info.envUuid,
1795
+ name: info.envName,
1796
+ resources: [],
1797
+ };
1798
+ project.environments.push(envNode);
1799
+ }
1800
+ envNodeMap.set(envId, envNode);
1801
+ }
1802
+
1803
+ // Assign apps to environments
1804
+ for (const app of apps) {
1805
+ const envNode = app.environment_id
1806
+ ? envNodeMap.get(app.environment_id)
1807
+ : undefined;
1808
+ const resource: ICoolifyResource = {
1809
+ uuid: app.uuid,
1810
+ name: app.name,
1811
+ kind: "app",
1812
+ status: app.status,
1813
+ fqdn: app.fqdn,
1814
+ };
1815
+ if (envNode) {
1816
+ envNode.resources.push(resource);
1817
+ }
1818
+ }
1819
+
1820
+ // Assign databases to environments
1821
+ for (const db of dbs) {
1822
+ const envNode = (db as any).environment_id
1823
+ ? envNodeMap.get((db as any).environment_id)
1824
+ : undefined;
1825
+ const resource: ICoolifyResource = {
1826
+ uuid: db.uuid,
1827
+ name: db.name,
1828
+ kind: "database",
1829
+ status: db.status,
1830
+ dbType: db.type,
1831
+ };
1832
+ if (envNode) {
1833
+ envNode.resources.push(resource);
1834
+ }
1835
+ }
1836
+
1837
+ // Assign services to environments
1838
+ for (const svc of svcs) {
1839
+ const envNode = (svc as any).environment_id
1840
+ ? envNodeMap.get((svc as any).environment_id)
1841
+ : undefined;
1842
+ const resource: ICoolifyResource = {
1843
+ uuid: svc.uuid,
1844
+ name: svc.name,
1845
+ kind: "service",
1846
+ status: svc.status,
1847
+ };
1848
+ if (envNode) {
1849
+ envNode.resources.push(resource);
1850
+ }
1851
+ }
1852
+
1853
+ // Filter out empty projects
1854
+ const populatedProjects = projectNodes.filter(
1855
+ (p) => p.environments.some((e) => e.resources.length > 0),
1856
+ );
1857
+
1858
+ // Aggregate counts
1859
+ const allStatuses = [
1860
+ ...apps.map((a) => a.status),
1861
+ ...dbs.map((d) => d.status),
1862
+ ...svcs.map((s) => s.status),
1863
+ ];
1864
+ const counts = {
1865
+ projects: populatedProjects.length,
1866
+ apps: apps.length,
1867
+ databases: dbs.length,
1868
+ services: svcs.length,
1869
+ healthy: allStatuses.filter((s) => s.includes("healthy")).length,
1870
+ running: allStatuses.filter(
1871
+ (s) => s.startsWith("running") && !s.includes("healthy"),
1872
+ ).length,
1873
+ stopped: allStatuses.filter((s) => s.includes("exited")).length,
1874
+ unhealthy: allStatuses.filter((s) => s.includes("unhealthy")).length,
1875
+ };
1876
+
1877
+ const server = servers[0] || { name: "Unknown" };
1878
+
1879
+ log.success(
1880
+ `Infrastructure tree built: ${counts.projects} projects, ${counts.apps} apps, ${counts.databases} dbs, ${counts.services} svcs`,
1881
+ );
1882
+
1883
+ return ok({
1884
+ server: {
1885
+ name: server.name,
1886
+ ip: server.ip,
1887
+ uuid: server.uuid,
1888
+ },
1889
+ projects: populatedProjects,
1890
+ counts,
1891
+ });
1892
+ }
1893
+
1623
1894
  /**
1624
1895
  * Starts a service.
1625
1896
  * Note: Coolify API uses GET for service start/stop/restart.
@@ -1701,9 +1972,65 @@ export class CoolifyService {
1701
1972
  return ok(result.data || []);
1702
1973
  }
1703
1974
 
1975
+ /**
1976
+ * Bulk updates environment variables for a service.
1977
+ *
1978
+ * Uses the bulk endpoint `PATCH /services/{uuid}/envs/bulk` which has
1979
+ * create-or-update semantics: if the variable exists in the override
1980
+ * table, its value is updated; otherwise a new override is created.
1981
+ *
1982
+ * This is the only reliable way to update a variable that has been
1983
+ * deleted (i.e. its base value from docker-compose/git is still visible
1984
+ * in `listServiceEnvVars` but no override row exists), and the only way
1985
+ * to set the same key twice without the `POST /services/{uuid}/envs`
1986
+ * returning 409 "already exists".
1987
+ *
1988
+ * @param serviceUuid - Service UUID
1989
+ * @param envVars - Array of variable definitions to upsert
1990
+ * @returns Result indicating success or error
1991
+ */
1992
+ async bulkUpdateServiceEnvVars(
1993
+ serviceUuid: string,
1994
+ envVars: Array<{
1995
+ key: string;
1996
+ value: string;
1997
+ is_preview?: boolean;
1998
+ is_buildtime?: boolean;
1999
+ is_runtime?: boolean;
2000
+ }>,
2001
+ ): Promise<Result<{ message: string }, Error>> {
2002
+ log.info(`Bulk updating ${envVars.length} env vars for service ${serviceUuid}`);
2003
+
2004
+ // Coolify bulk endpoint requires { data: [...] } envelope, not a raw array.
2005
+ // Sending a raw array yields 400 "Bulk data is required." from the API.
2006
+ const result = await this.request<{ message: string }>(
2007
+ `/services/${serviceUuid}/envs/bulk`,
2008
+ {
2009
+ method: "PATCH",
2010
+ body: JSON.stringify({ data: envVars }),
2011
+ },
2012
+ );
2013
+
2014
+ if (result.error) {
2015
+ log.error(`Failed to bulk update service env vars: ${result.error}`);
2016
+ return err(new Error(result.error));
2017
+ }
2018
+
2019
+ log.success(`Bulk updated ${envVars.length} env vars for service ${serviceUuid}`);
2020
+ return ok(result.data || { message: "Environment variables updated" });
2021
+ }
2022
+
1704
2023
  /**
1705
2024
  * Creates an environment variable for a service.
1706
2025
  *
2026
+ * NOTE: Prefer `bulkUpdateServiceEnvVars` for any non-trivial use case.
2027
+ * This endpoint uses `POST /services/{uuid}/envs` and returns 409
2028
+ * "already exists" when the key is already present as an override,
2029
+ * with no recovery path on the server side. Bulk has create-or-update
2030
+ * semantics and handles both new and existing keys reliably.
2031
+ *
2032
+ * Kept for callers that explicitly want a strict create-only operation.
2033
+ *
1707
2034
  * @param uuid - Service UUID
1708
2035
  * @param data - Env var data (key, value, is_preview)
1709
2036
  * @returns Result with created env var UUID or error
@@ -1721,6 +2048,110 @@ export class CoolifyService {
1721
2048
  return ok(result.data!);
1722
2049
  }
1723
2050
 
2051
+ /**
2052
+ * Deletes an environment variable from a service.
2053
+ *
2054
+ * @param serviceUuid - Service UUID
2055
+ * @param key - Variable name to delete
2056
+ * @returns Result indicating success or error
2057
+ */
2058
+ async deleteServiceEnvVar(
2059
+ serviceUuid: string,
2060
+ key: string,
2061
+ ): Promise<Result<void, Error>> {
2062
+ log.info(`Deleting environment variable ${key} from service ${serviceUuid}`);
2063
+
2064
+ // First get all env vars to find the UUID of the one to delete
2065
+ const envVarsResult = await this.listServiceEnvVars(serviceUuid);
2066
+ if (isErr(envVarsResult)) {
2067
+ return err(envVarsResult.error);
2068
+ }
2069
+
2070
+ const envVar = envVarsResult.value.find((ev) => ev.key === key);
2071
+ if (!envVar) {
2072
+ log.error(`Environment variable ${key} not found on service ${serviceUuid}`);
2073
+ return err(new Error(`Environment variable ${key} not found`));
2074
+ }
2075
+
2076
+ const result = await this.request(
2077
+ `/services/${serviceUuid}/envs/${envVar.uuid}`,
2078
+ {
2079
+ method: "DELETE",
2080
+ },
2081
+ );
2082
+
2083
+ if (result.error) {
2084
+ log.error(`Failed to delete service env var: ${result.error}`);
2085
+ return err(new Error(result.error));
2086
+ }
2087
+
2088
+ log.success(`Environment variable ${key} deleted from service ${serviceUuid}`);
2089
+ return ok(undefined);
2090
+ }
2091
+
2092
+ /**
2093
+ * Lists environment variables for a database.
2094
+ *
2095
+ * @param databaseUuid - Database UUID
2096
+ * @returns Result with env vars list or error
2097
+ */
2098
+ async listDatabaseEnvVars(
2099
+ databaseUuid: string,
2100
+ ): Promise<Result<ICoolifyEnvVar[], Error>> {
2101
+ log.info(`Listing env vars for database ${databaseUuid}`);
2102
+
2103
+ const result = await this.request<ICoolifyEnvVar[]>(
2104
+ `/databases/${databaseUuid}/envs`,
2105
+ );
2106
+ if (result.error) {
2107
+ log.error(`Failed to list database env vars: ${result.error}`);
2108
+ return err(new Error(result.error));
2109
+ }
2110
+
2111
+ return ok(result.data || []);
2112
+ }
2113
+
2114
+ /**
2115
+ * Deletes an environment variable from a database.
2116
+ *
2117
+ * @param databaseUuid - Database UUID
2118
+ * @param key - Variable name to delete
2119
+ * @returns Result indicating success or error
2120
+ */
2121
+ async deleteDatabaseEnvVar(
2122
+ databaseUuid: string,
2123
+ key: string,
2124
+ ): Promise<Result<void, Error>> {
2125
+ log.info(`Deleting environment variable ${key} from database ${databaseUuid}`);
2126
+
2127
+ // First get all env vars to find the UUID of the one to delete
2128
+ const envVarsResult = await this.listDatabaseEnvVars(databaseUuid);
2129
+ if (isErr(envVarsResult)) {
2130
+ return err(envVarsResult.error);
2131
+ }
2132
+
2133
+ const envVar = envVarsResult.value.find((ev) => ev.key === key);
2134
+ if (!envVar) {
2135
+ log.error(`Environment variable ${key} not found on database ${databaseUuid}`);
2136
+ return err(new Error(`Environment variable ${key} not found`));
2137
+ }
2138
+
2139
+ const result = await this.request(
2140
+ `/databases/${databaseUuid}/envs/${envVar.uuid}`,
2141
+ {
2142
+ method: "DELETE",
2143
+ },
2144
+ );
2145
+
2146
+ if (result.error) {
2147
+ log.error(`Failed to delete database env var: ${result.error}`);
2148
+ return err(new Error(result.error));
2149
+ }
2150
+
2151
+ log.success(`Environment variable ${key} deleted from database ${databaseUuid}`);
2152
+ return ok(undefined);
2153
+ }
2154
+
1724
2155
  // ===========================================================================
1725
2156
  // Additional Server endpoints
1726
2157
  // ===========================================================================
@@ -2295,6 +2726,35 @@ export class CoolifyService {
2295
2726
  // Smart Resolution Helpers
2296
2727
  // ===========================================================================
2297
2728
 
2729
+ /**
2730
+ * Gets full application details by UUID.
2731
+ *
2732
+ * Makes a direct GET request to /api/v1/applications/{uuid} which returns
2733
+ * complete application data including project_uuid and environment_uuid.
2734
+ *
2735
+ * @param appUuid - Application UUID
2736
+ * @returns Result with full application details or error
2737
+ */
2738
+ async getApplication(
2739
+ appUuid: string,
2740
+ ): Promise<Result<ICoolifyApplication, Error>> {
2741
+ log.info(`Getting application details: ${appUuid}`);
2742
+
2743
+ const result = await this.request<ICoolifyApplication>(
2744
+ `/applications/${appUuid}`,
2745
+ );
2746
+
2747
+ if (result.error) {
2748
+ return err(new Error(result.error));
2749
+ }
2750
+
2751
+ if (!result.data) {
2752
+ return err(new Error("Application not found"));
2753
+ }
2754
+
2755
+ return ok(result.data);
2756
+ }
2757
+
2298
2758
  /**
2299
2759
  * Resolves an application by UUID, name, or domain (FQDN).
2300
2760
  *
@@ -2878,4 +3338,8 @@ export type {
2878
3338
  ICoolifyLogsOptions,
2879
3339
  ICoolifyLogs,
2880
3340
  IProgressCallback,
3341
+ ICoolifyInfrastructureTree,
3342
+ ICoolifyProjectNode,
3343
+ ICoolifyEnvironmentNode,
3344
+ ICoolifyResource,
2881
3345
  } from "./types.js";