@mks2508/coolify-mks-cli-mcp 0.8.0 → 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 (45) hide show
  1. package/dist/cli/coolify-state.d.ts +12 -4
  2. package/dist/cli/coolify-state.d.ts.map +1 -1
  3. package/dist/cli/index.js +8886 -7957
  4. package/dist/coolify/config.d.ts +25 -0
  5. package/dist/coolify/config.d.ts.map +1 -1
  6. package/dist/coolify/index.d.ts +118 -10
  7. package/dist/coolify/index.d.ts.map +1 -1
  8. package/dist/coolify/types.d.ts +61 -1
  9. package/dist/coolify/types.d.ts.map +1 -1
  10. package/dist/index.cjs +2267 -227
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.js +2289 -227
  13. package/dist/index.js.map +1 -1
  14. package/dist/sdk.d.ts +56 -8
  15. package/dist/sdk.d.ts.map +1 -1
  16. package/dist/server/stdio.js +253 -100
  17. package/dist/tools/definitions.d.ts.map +1 -1
  18. package/dist/tools/handlers.d.ts.map +1 -1
  19. package/dist/utils/env-parser.d.ts +24 -0
  20. package/dist/utils/env-parser.d.ts.map +1 -0
  21. package/dist/utils/format.d.ts +32 -0
  22. package/dist/utils/format.d.ts.map +1 -1
  23. package/package.json +2 -1
  24. package/src/cli/commands/create.ts +279 -37
  25. package/src/cli/commands/env.ts +348 -54
  26. package/src/cli/commands/init.ts +69 -15
  27. package/src/cli/commands/main-menu.ts +1 -1
  28. package/src/cli/commands/projects.ts +3 -3
  29. package/src/cli/commands/show.ts +39 -10
  30. package/src/cli/commands/status.ts +23 -7
  31. package/src/cli/commands/svc.ts +7 -1
  32. package/src/cli/commands/update.ts +52 -0
  33. package/src/cli/commands/volumes.ts +293 -0
  34. package/src/cli/coolify-state.ts +42 -4
  35. package/src/cli/index.ts +50 -4
  36. package/src/cli/ui/banner.ts +3 -3
  37. package/src/cli/ui/screen.ts +26 -2
  38. package/src/coolify/config.ts +75 -0
  39. package/src/coolify/index.ts +325 -106
  40. package/src/coolify/types.ts +62 -1
  41. package/src/sdk.ts +87 -39
  42. package/src/tools/definitions.ts +22 -0
  43. package/src/tools/handlers.ts +19 -0
  44. package/src/utils/env-parser.ts +45 -0
  45. package/src/utils/format.ts +178 -0
@@ -9,6 +9,7 @@
9
9
  import { isErr } from "@mks2508/no-throw";
10
10
  import chalk from "chalk";
11
11
  import { getCoolifyService } from "../../coolify/index.js";
12
+ import { getCachedAppSettings } from "../../coolify/config.js";
12
13
  import { resolveUuid } from "../coolify-state.js";
13
14
  import { resolveAppNameOrUuid } from "../name-resolver.js";
14
15
 
@@ -38,22 +39,14 @@ export async function showCommand(uuid?: string) {
38
39
  return;
39
40
  }
40
41
 
41
- // We need to add getApplication method to CoolifyService
42
- // For now, use listApplications and filter
43
- const result = await coolify.listApplications();
42
+ const result = await coolify.getApplication(uuid);
44
43
 
45
44
  if (isErr(result)) {
46
45
  console.error(chalk.red(`Error: ${result.error.message}`));
47
46
  return;
48
47
  }
49
48
 
50
- const apps = result.value;
51
- const app = apps.find((a) => a.uuid === uuid || a.uuid.startsWith(uuid));
52
-
53
- if (!app) {
54
- console.error(chalk.red(`Application not found: ${uuid}`));
55
- return;
56
- }
49
+ const app = result.value;
57
50
 
58
51
  console.log(chalk.cyan("Application Details:"));
59
52
  console.log("");
@@ -83,6 +76,42 @@ export async function showCommand(uuid?: string) {
83
76
  chalk.gray("Base Dir: ") + chalk.white(app.base_directory || "N/A"),
84
77
  );
85
78
  console.log("");
79
+ console.log(chalk.cyan("Deploy Settings:"));
80
+ // Coolify API GET does not return settings.is_auto_deploy_enabled (stored in
81
+ // application_settings table, not eager-loaded by the API). We check:
82
+ // 1. API response (future-proof for when Coolify fixes this)
83
+ // 2. Local cache (set when user runs `update --auto-deploy/--no-auto-deploy`)
84
+ // 3. Default: ON (Coolify default)
85
+ const cached = await getCachedAppSettings(uuid);
86
+ const autoDeployFromApi = app.settings?.is_auto_deploy_enabled;
87
+ const autoDeployFromCache = cached?.isAutoDeployEnabled;
88
+ const autoDeployEnabled = autoDeployFromApi ?? autoDeployFromCache;
89
+
90
+ if (autoDeployEnabled !== undefined) {
91
+ const source = autoDeployFromApi ? "" : chalk.dim(" (cached)");
92
+ console.log(
93
+ chalk.gray("Auto-deploy:") +
94
+ (autoDeployEnabled ? chalk.green(" ON") : chalk.red(" OFF")) +
95
+ source,
96
+ );
97
+ } else {
98
+ console.log(
99
+ chalk.gray("Auto-deploy:") +
100
+ chalk.dim(" unknown (default: ON, set via --auto-deploy/--no-auto-deploy)"),
101
+ );
102
+ }
103
+ if (app.watch_paths) {
104
+ console.log(chalk.gray("Watch paths:"));
105
+ for (const p of app.watch_paths.split("\n").filter(Boolean)) {
106
+ console.log(chalk.gray(` ${p}`));
107
+ }
108
+ } else {
109
+ console.log(
110
+ chalk.gray("Watch paths:") +
111
+ chalk.yellow(" none (deploys on all changes)"),
112
+ );
113
+ }
114
+ console.log("");
86
115
  console.log(chalk.cyan("Destination:"));
87
116
  if (app.destination) {
88
117
  console.log(chalk.gray(" UUID: ") + chalk.white(app.destination.uuid));
@@ -26,7 +26,7 @@ import {
26
26
  } from "../ui/screen.js";
27
27
  import { showStatusDashboard, formatStatus } from "../ui/tables.js";
28
28
  import { loadCoolifyState, loadMultiAppState } from "../coolify-state.js";
29
- import { loadConfig } from "../../coolify/config.js";
29
+ import { loadConfig, getCachedAppSettings } from "../../coolify/config.js";
30
30
  import {
31
31
  inlineSelect,
32
32
  fullSelect,
@@ -232,8 +232,20 @@ async function navigateResource(
232
232
  let logPreview: string[] | undefined;
233
233
  if (kind === "app") {
234
234
  const coolify = getCoolifyService();
235
- const [dr, lp] = await Promise.all([coolify.getApplication(uuid), fetchLogPreview(uuid, 6)]);
236
- if (!isErr(dr)) appDetail = dr.value;
235
+ const [dr, lp, cached] = await Promise.all([
236
+ coolify.getApplication(uuid),
237
+ fetchLogPreview(uuid, 6),
238
+ getCachedAppSettings(uuid),
239
+ ]);
240
+ if (!isErr(dr)) {
241
+ appDetail = dr.value;
242
+ // Augment with cached settings (API GET doesn't return settings)
243
+ if (cached && !appDetail.settings) {
244
+ appDetail.settings = {
245
+ is_auto_deploy_enabled: cached.isAutoDeployEnabled,
246
+ };
247
+ }
248
+ }
237
249
  if (lp.length > 0 && !lp[0].includes("unavailable")) logPreview = lp;
238
250
  }
239
251
 
@@ -348,10 +360,14 @@ async function logsSubMenu(uuid: string, appName: string, pos?: { row: number; c
348
360
  const { spawnSync } = await import("child_process");
349
361
  const bunPath = process.argv[0] || "bun";
350
362
  const scriptPath = process.argv[1] || "coolify-cli";
351
- const envVars = `COOLIFY_URL='${process.env.COOLIFY_URL || ""}' COOLIFY_TOKEN='${process.env.COOLIFY_TOKEN || ""}'`;
352
- const cmd = `${envVars} '${bunPath}' '${scriptPath}' logs ${uuid} -f`;
363
+ const cmd = `'${bunPath}' '${scriptPath}' logs ${uuid} -f`;
364
+ const childEnv = {
365
+ ...process.env,
366
+ COOLIFY_URL: process.env.COOLIFY_URL ?? "",
367
+ COOLIFY_TOKEN: process.env.COOLIFY_TOKEN ?? "",
368
+ };
353
369
  spawnSync("tmux", ["kill-session", "-t", sessionName], { stdio: "ignore" });
354
- spawnSync("tmux", ["new-session", "-d", "-s", sessionName, cmd]);
370
+ spawnSync("tmux", ["new-session", "-d", "-s", sessionName, cmd], { env: childEnv });
355
371
  const check = spawnSync("tmux", ["has-session", "-t", sessionName]);
356
372
  if (check.status === 0) {
357
373
  console.log(chalk.green(`\n ✓ Session "${sessionName}" created`));
@@ -392,7 +408,7 @@ async function envSubMenu(uuid: string, appName: string, pos?: { row: number; co
392
408
  case "list": await envCommand(uuid, {}); await waitForKey(); break;
393
409
  case "set": {
394
410
  const input = await textInput("KEY=VALUE:");
395
- if (input) { await envCommand(uuid, { set: input }); await waitForKey(); }
411
+ if (input) { await envCommand(uuid, { set: [input] }); await waitForKey(); }
396
412
  break;
397
413
  }
398
414
  case "delete": {
@@ -83,7 +83,13 @@ export const svcEnvCommand = (uuid: string) =>
83
83
  { header: "Runtime", value: (e) => (e.is_runtime ? "Yes" : "No") },
84
84
  ]);
85
85
 
86
- /** Set env var for a service. */
86
+ /**
87
+ * Set env var for a service.
88
+ *
89
+ * Note: `services.setEnv` delegates to the bulk endpoint internally, so
90
+ * calling this for a key that already exists as an override UPDATES the
91
+ * value instead of failing with 409. Repeatable across deploys.
92
+ */
87
93
  export async function svcSetEnvCommand(
88
94
  uuid: string,
89
95
  keyValue: string,
@@ -9,6 +9,7 @@
9
9
  import { isErr } from "@mks2508/no-throw";
10
10
  import chalk from "chalk";
11
11
  import { getCoolifyService } from "../../coolify/index.js";
12
+ import { cacheAppSettings } from "../../coolify/config.js";
12
13
  import type { ICoolifyUpdateOptions } from "../../coolify/types.js";
13
14
  import { resolveUuid } from "../coolify-state.js";
14
15
  import { resolveAppNameOrUuid } from "../name-resolver.js";
@@ -30,6 +31,8 @@ interface IUpdateCommandOptions {
30
31
  baseDirectory?: string;
31
32
  domains?: string;
32
33
  autoDeploy?: boolean;
34
+ watchPaths?: string;
35
+ clearWatchPaths?: boolean;
33
36
  forceHttps?: boolean;
34
37
  healthCheckEnabled?: boolean;
35
38
  healthCheckPath?: string;
@@ -93,6 +96,11 @@ export async function updateCommand(
93
96
  if (options.domains) updateOptions.domains = options.domains;
94
97
  if (options.autoDeploy !== undefined)
95
98
  updateOptions.isAutoDeployEnabled = options.autoDeploy;
99
+ if (options.clearWatchPaths) {
100
+ updateOptions.watchPaths = null;
101
+ } else if (options.watchPaths) {
102
+ updateOptions.watchPaths = options.watchPaths.replace(/\\n/g, "\n");
103
+ }
96
104
  if (options.forceHttps) updateOptions.isForceHttpsEnabled = true;
97
105
  if (options.healthCheckEnabled !== undefined)
98
106
  updateOptions.healthCheckEnabled = options.healthCheckEnabled;
@@ -133,4 +141,48 @@ export async function updateCommand(
133
141
  if (result.value.description) {
134
142
  console.log(chalk.gray(`Description: ${result.value.description}`));
135
143
  }
144
+
145
+ // Cache and display auto-deploy and watch_paths settings
146
+ if (
147
+ options.autoDeploy !== undefined ||
148
+ options.watchPaths ||
149
+ options.clearWatchPaths
150
+ ) {
151
+ // Cache settings locally (API GET doesn't return is_auto_deploy_enabled)
152
+ const settingsToCache: Record<string, unknown> = {};
153
+ if (options.autoDeploy !== undefined) {
154
+ settingsToCache.isAutoDeployEnabled = options.autoDeploy;
155
+ }
156
+ if (options.clearWatchPaths) {
157
+ settingsToCache.watchPaths = null;
158
+ } else if (options.watchPaths) {
159
+ settingsToCache.watchPaths = updateOptions.watchPaths;
160
+ }
161
+ await cacheAppSettings(options.uuid, settingsToCache);
162
+
163
+ // Display what was set
164
+ if (options.autoDeploy !== undefined) {
165
+ console.log(
166
+ chalk.gray(`Auto-deploy: `) +
167
+ (options.autoDeploy ? chalk.green("ON") : chalk.red("OFF")),
168
+ );
169
+ }
170
+
171
+ // Verify watch_paths from API (this field IS returned by GET)
172
+ const verifyResult = await service.getApplication(options.uuid);
173
+ if (!isErr(verifyResult)) {
174
+ const app = verifyResult.value;
175
+ if (app.watch_paths) {
176
+ console.log(chalk.gray(`Watch paths:`));
177
+ for (const p of app.watch_paths.split("\n").filter(Boolean)) {
178
+ console.log(chalk.gray(` ${p}`));
179
+ }
180
+ } else {
181
+ console.log(
182
+ chalk.gray(`Watch paths: `) +
183
+ chalk.yellow("none (deploys on all changes)"),
184
+ );
185
+ }
186
+ }
187
+ }
136
188
  }
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Volumes subcommands for CLI — add/list/remove bind mounts on docker-compose apps.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import { getCliSdk, chalk, Table } from "../actions.js";
8
+
9
+ interface IComposeService {
10
+ image?: string;
11
+ volumes?: string[];
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ interface IComposeFile {
16
+ services?: Record<string, IComposeService>;
17
+ [key: string]: unknown;
18
+ }
19
+
20
+ /**
21
+ * Parse a volume string like "/host/path:/container/path" or "named_vol:/container/path".
22
+ */
23
+ function parseVolumeEntry(v: string): { source: string; target: string } {
24
+ const colonIdx = v.indexOf(":");
25
+ if (colonIdx === -1) return { source: "", target: v };
26
+ return { source: v.slice(0, colonIdx), target: v.slice(colonIdx + 1) };
27
+ }
28
+
29
+ /**
30
+ * List volumes for an application.
31
+ */
32
+ export async function volumesListCommand(
33
+ uuid: string,
34
+ options: { service?: string },
35
+ ): Promise<void> {
36
+ try {
37
+ const sdk = getCliSdk();
38
+ const app = await sdk.applications.get(uuid);
39
+
40
+ if (!app.docker_compose_raw) {
41
+ console.log(chalk.yellow("Application has no docker_compose_raw (not a docker-compose app)."));
42
+ return;
43
+ }
44
+
45
+ let compose: IComposeFile;
46
+ try {
47
+ compose = Bun.YAML.parse(app.docker_compose_raw) as IComposeFile;
48
+ } catch {
49
+ console.error(chalk.red("Failed to parse docker_compose_raw YAML."));
50
+ return;
51
+ }
52
+
53
+ const services = compose.services ?? {};
54
+ const serviceNames = Object.keys(services);
55
+
56
+ if (serviceNames.length === 0) {
57
+ console.log(chalk.yellow("No services found in docker-compose."));
58
+ return;
59
+ }
60
+
61
+ // Filter by service if specified
62
+ const displayServices = options.service
63
+ ? serviceNames.filter((s) => s === options.service)
64
+ : serviceNames;
65
+
66
+ if (options.service && displayServices.length === 0) {
67
+ console.error(
68
+ chalk.red(`Service "${options.service}" not found. Available: ${serviceNames.join(", ")}`),
69
+ );
70
+ return;
71
+ }
72
+
73
+ for (const svcName of displayServices) {
74
+ const svc = services[svcName];
75
+ const volumes = svc?.volumes ?? [];
76
+
77
+ console.log(chalk.cyan(`\nService: ${chalk.bold(svcName)}`));
78
+
79
+ if (volumes.length === 0) {
80
+ console.log(chalk.gray(" No volumes configured."));
81
+ continue;
82
+ }
83
+
84
+ const table = new Table({
85
+ head: [chalk.cyan("Source"), chalk.cyan("Target"), chalk.cyan("Type")],
86
+ });
87
+
88
+ for (const vol of volumes) {
89
+ const { source, target } = parseVolumeEntry(vol);
90
+ const volType = source.startsWith("/") ? "bind" : "named";
91
+ table.push([source || "(no source)", target, volType]);
92
+ }
93
+
94
+ console.log(table.toString());
95
+ }
96
+ } catch (error) {
97
+ console.error(
98
+ chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`),
99
+ );
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Add a volume (bind mount) to an application.
105
+ */
106
+ export async function volumesAddCommand(
107
+ uuid: string,
108
+ options: {
109
+ source: string;
110
+ target: string;
111
+ service?: string;
112
+ noRestart?: boolean;
113
+ },
114
+ ): Promise<void> {
115
+ try {
116
+ const sdk = getCliSdk();
117
+ const app = await sdk.applications.get(uuid);
118
+
119
+ if (!app.docker_compose_raw) {
120
+ console.log(
121
+ chalk.yellow("Application has no docker_compose_raw. Only docker-compose buildPack apps support volume management via CLI."),
122
+ );
123
+ return;
124
+ }
125
+
126
+ let compose: IComposeFile;
127
+ try {
128
+ compose = Bun.YAML.parse(app.docker_compose_raw) as IComposeFile;
129
+ } catch {
130
+ console.error(chalk.red("Failed to parse docker_compose_raw YAML."));
131
+ return;
132
+ }
133
+
134
+ const services = compose.services ?? {};
135
+ const serviceNames = Object.keys(services);
136
+
137
+ // Determine target service
138
+ let targetService = options.service;
139
+ if (!targetService) {
140
+ if (serviceNames.length === 1) {
141
+ targetService = serviceNames[0];
142
+ } else {
143
+ console.error(
144
+ chalk.red(
145
+ `Multiple services found (${serviceNames.join(", ")}). Use --service to specify which one.`,
146
+ ),
147
+ );
148
+ return;
149
+ }
150
+ }
151
+
152
+ if (!services[targetService]) {
153
+ console.error(
154
+ chalk.red(`Service "${targetService}" not found. Available: ${serviceNames.join(", ")}`),
155
+ );
156
+ return;
157
+ }
158
+
159
+ const svc = services[targetService];
160
+ const volumes = svc.volumes ?? [];
161
+
162
+ // Check if volume already exists
163
+ const existing = volumes.find((v) => {
164
+ const { target: t } = parseVolumeEntry(v);
165
+ return t === options.target;
166
+ });
167
+
168
+ if (existing) {
169
+ console.log(chalk.yellow(`Volume with target "${options.target}" already exists — skipping.`));
170
+ return;
171
+ }
172
+
173
+ // Add the volume
174
+ const volStr = `${options.source}:${options.target}`;
175
+ svc.volumes = [...volumes, volStr];
176
+
177
+ // Serialize back to YAML
178
+ const updatedYaml = Bun.YAML.stringify(compose);
179
+
180
+ // PATCH the application
181
+ console.log(chalk.cyan(`Adding volume: ${chalk.bold(volStr)} to service "${targetService}"...`));
182
+ await sdk.applications.update(uuid, { dockerComposeRaw: updatedYaml });
183
+ console.log(chalk.green("Volume added successfully."));
184
+
185
+ // Deploy if not --no-restart
186
+ if (!options.noRestart) {
187
+ console.log(chalk.cyan("Redeploying to apply changes..."));
188
+ await sdk.applications.deploy(uuid);
189
+ console.log(chalk.green("Redeploy triggered."));
190
+ } else {
191
+ console.log(chalk.gray("Skipped redeploy (--no-restart). Changes apply on next deploy."));
192
+ }
193
+ } catch (error) {
194
+ console.error(
195
+ chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`),
196
+ );
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Remove a volume from an application.
202
+ */
203
+ export async function volumesRemoveCommand(
204
+ uuid: string,
205
+ options: {
206
+ target: string;
207
+ service?: string;
208
+ noRestart?: boolean;
209
+ },
210
+ ): Promise<void> {
211
+ try {
212
+ const sdk = getCliSdk();
213
+ const app = await sdk.applications.get(uuid);
214
+
215
+ if (!app.docker_compose_raw) {
216
+ console.log(
217
+ chalk.yellow("Application has no docker_compose_raw. Only docker-compose buildPack apps support volume management via CLI."),
218
+ );
219
+ return;
220
+ }
221
+
222
+ let compose: IComposeFile;
223
+ try {
224
+ compose = Bun.YAML.parse(app.docker_compose_raw) as IComposeFile;
225
+ } catch {
226
+ console.error(chalk.red("Failed to parse docker_compose_raw YAML."));
227
+ return;
228
+ }
229
+
230
+ const services = compose.services ?? {};
231
+ const serviceNames = Object.keys(services);
232
+
233
+ // Determine target service
234
+ let targetService = options.service;
235
+ if (!targetService) {
236
+ if (serviceNames.length === 1) {
237
+ targetService = serviceNames[0];
238
+ } else {
239
+ console.error(
240
+ chalk.red(
241
+ `Multiple services found (${serviceNames.join(", ")}). Use --service to specify which one.`,
242
+ ),
243
+ );
244
+ return;
245
+ }
246
+ }
247
+
248
+ if (!services[targetService]) {
249
+ console.error(
250
+ chalk.red(`Service "${targetService}" not found. Available: ${serviceNames.join(", ")}`),
251
+ );
252
+ return;
253
+ }
254
+
255
+ const svc = services[targetService];
256
+ const volumes = svc.volumes ?? [];
257
+
258
+ // Find volume by target path
259
+ const idx = volumes.findIndex((v) => {
260
+ const { target: t } = parseVolumeEntry(v);
261
+ return t === options.target;
262
+ });
263
+
264
+ if (idx === -1) {
265
+ console.error(chalk.red(`No volume found with target "${options.target}".`));
266
+ return;
267
+ }
268
+
269
+ const removed = volumes[idx];
270
+ svc.volumes = volumes.filter((_, i) => i !== idx);
271
+
272
+ // Serialize back to YAML
273
+ const updatedYaml = Bun.YAML.stringify(compose);
274
+
275
+ // PATCH the application
276
+ console.log(chalk.cyan(`Removing volume: ${chalk.bold(removed)} from service "${targetService}"...`));
277
+ await sdk.applications.update(uuid, { dockerComposeRaw: updatedYaml });
278
+ console.log(chalk.green("Volume removed successfully."));
279
+
280
+ // Deploy if not --no-restart
281
+ if (!options.noRestart) {
282
+ console.log(chalk.cyan("Redeploying to apply changes..."));
283
+ await sdk.applications.deploy(uuid);
284
+ console.log(chalk.green("Redeploy triggered."));
285
+ } else {
286
+ console.log(chalk.gray("Skipped redeploy (--no-restart). Changes apply on next deploy."));
287
+ }
288
+ } catch (error) {
289
+ console.error(
290
+ chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`),
291
+ );
292
+ }
293
+ }
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { join } from "node:path";
13
+ import { validateCoolifyState } from "../utils/format.js";
13
14
 
14
15
  /**
15
16
  * State stored in .coolify.json (generated by create-bunspace deployer or init command).
@@ -50,8 +51,12 @@ export interface ICoolifyDeployState {
50
51
  type?: string;
51
52
  /** Build pack (dockerfile, nixpacks, static, dockercompose) */
52
53
  buildPack?: string;
53
- /** Auto-deploy enabled */
54
+ /** Ports exposed (comma-separated) */
55
+ portsExposes?: string;
56
+ /** Auto-deploy enabled (Coolify default: true) */
54
57
  autoDeployEnabled?: boolean;
58
+ /** Watch paths for selective auto-deploy (newline-separated globs, null = all changes) */
59
+ watchPaths?: string | null;
55
60
  /** When this state was last updated */
56
61
  updatedAt?: string;
57
62
  /** Coolify instance URL */
@@ -72,12 +77,16 @@ export interface ICoolifyMultiAppState {
72
77
  name: string;
73
78
  /** Service role (e.g. "backend", "admin", "docs", "proxy") */
74
79
  service: string;
75
- /** Domain if configured */
76
- domain?: string;
80
+ /** Domain if configured (with protocol). Null for internal services. */
81
+ domain?: string | null;
77
82
  /** Dockerfile path relative to repo root */
78
- dockerfile?: string;
83
+ dockerfile?: string | null;
79
84
  /** Exposed port */
80
85
  port?: number;
86
+ /** Auto-deploy enabled (Coolify default: true) */
87
+ autoDeployEnabled?: boolean;
88
+ /** Watch paths for selective auto-deploy (newline-separated globs, null = all changes) */
89
+ watchPaths?: string | null;
81
90
  }>;
82
91
  /** Server UUID */
83
92
  serverUuid: string;
@@ -151,6 +160,19 @@ export function loadCoolifyState(): ICoolifyDeployState | null {
151
160
  return null;
152
161
  }
153
162
 
163
+ // Validate state against schema rules
164
+ const validation = validateCoolifyState(state);
165
+ if (!validation.valid) {
166
+ console.error(
167
+ `Warning: .coolify.json has errors: ${validation.errors.join("; ")}`,
168
+ );
169
+ }
170
+ if (validation.warnings.length > 0) {
171
+ for (const w of validation.warnings) {
172
+ console.error(`Warning: .coolify.json — ${w}`);
173
+ }
174
+ }
175
+
154
176
  return state as ICoolifyDeployState;
155
177
  } catch {
156
178
  return null;
@@ -191,6 +213,14 @@ export function loadMultiAppState(): ICoolifyMultiAppState | null {
191
213
  * @param state - The multi-app state to write
192
214
  */
193
215
  export function writeMultiAppState(state: ICoolifyMultiAppState): void {
216
+ const validation = validateCoolifyState(
217
+ state as unknown as Record<string, unknown>,
218
+ );
219
+ if (!validation.valid) {
220
+ console.error(
221
+ `Warning: writing .coolify.json with errors: ${validation.errors.join("; ")}`,
222
+ );
223
+ }
194
224
  const statePath = join(process.cwd(), STATE_FILE);
195
225
  writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n", "utf-8");
196
226
  }
@@ -201,6 +231,14 @@ export function writeMultiAppState(state: ICoolifyMultiAppState): void {
201
231
  * @param state - The state to write
202
232
  */
203
233
  export function writeCoolifyState(state: ICoolifyDeployState): void {
234
+ const validation = validateCoolifyState(
235
+ state as unknown as Record<string, unknown>,
236
+ );
237
+ if (!validation.valid) {
238
+ console.error(
239
+ `Warning: writing .coolify.json with errors: ${validation.errors.join("; ")}`,
240
+ );
241
+ }
204
242
  const statePath = join(process.cwd(), STATE_FILE);
205
243
  writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n", "utf-8");
206
244
  }