@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
@@ -44,6 +44,8 @@ export interface ICoolifyAppOptions {
44
44
  projectUuid: string;
45
45
  /** Environment UUID */
46
46
  environmentUuid: string;
47
+ /** Environment name (required by API alongside environment_uuid) */
48
+ environmentName?: string;
47
49
  /** Server UUID */
48
50
  serverUuid: string;
49
51
  /** Destination UUID (optional, for specific destination targeting) */
@@ -52,7 +54,7 @@ export interface ICoolifyAppOptions {
52
54
  type?: TCoolifyApplicationType;
53
55
  /** GitHub App UUID (required for private-github-app type — get from listGithubApps) */
54
56
  githubAppUuid?: string;
55
- /** GitHub repository URL (for git-based apps) */
57
+ /** Git repository URL (for git-based apps) */
56
58
  githubRepoUrl?: string;
57
59
  /** Git branch */
58
60
  branch?: string;
@@ -72,6 +74,8 @@ export interface ICoolifyAppOptions {
72
74
  dockerfileLocation?: string;
73
75
  /** Base directory for build context (default: "/") */
74
76
  baseDirectory?: string;
77
+ /** Private key UUID (required for private-deploy-key type) */
78
+ privateKeyUuid?: string;
75
79
  }
76
80
 
77
81
  /**
@@ -102,10 +106,15 @@ export interface ICoolifyUpdateOptions {
102
106
  domains?: string;
103
107
  /** Docker Compose domains - JSON object: { "service-name": { "domain": "https://..." } } — for dockercompose apps */
104
108
  dockerComposeDomains?: string;
109
+ /** Raw docker-compose YAML content — for dockercompose buildPack only.
110
+ * Pass the full compose string; Coolify will replace the existing one. */
111
+ dockerComposeRaw?: string;
105
112
  /** Force HTTPS redirect */
106
113
  isForceHttpsEnabled?: boolean;
107
114
  /** Enable auto deploy on git push */
108
115
  isAutoDeployEnabled?: boolean;
116
+ /** Watch paths for selective auto-deploy (newline-separated globs, e.g. "src/**\npackages/**") */
117
+ watchPaths?: string | null;
109
118
  /** Enable health check for the application */
110
119
  healthCheckEnabled?: boolean;
111
120
  /** Health check endpoint path (e.g., "/health") */
@@ -240,6 +249,17 @@ export interface ICoolifyApplication {
240
249
  dockerfile_location?: string | null;
241
250
  /** Base directory */
242
251
  base_directory?: string | null;
252
+ /** Watch paths for selective auto-deploy (newline-separated globs) */
253
+ watch_paths?: string | null;
254
+ /** Raw docker-compose YAML content (dockercompose buildPack only) */
255
+ docker_compose_raw?: string | null;
256
+ /** Application settings (returned from GET /applications/{uuid}) */
257
+ settings?: {
258
+ is_auto_deploy_enabled?: boolean;
259
+ is_force_https_enabled?: boolean;
260
+ is_preview_deployments_enabled?: boolean;
261
+ [key: string]: unknown;
262
+ } | null;
243
263
  /** Server status (boolean) */
244
264
  server_status?: boolean;
245
265
  /** Environment ID (numeric) */
@@ -541,3 +561,44 @@ export interface ICoolifyInfrastructureTree {
541
561
  unhealthy: number;
542
562
  };
543
563
  }
564
+
565
+ /**
566
+ * GitHub App configuration from Coolify API (snake_case).
567
+ * OpenAPI schema: GET /github-apps returns ~16 fields.
568
+ *
569
+ * @see https://docs.coolify.io/api-reference/github-apps
570
+ */
571
+ export interface ICoolifyGithubApp {
572
+ /** Coolify internal ID */
573
+ id: number;
574
+ /** Coolify UUID */
575
+ uuid: string;
576
+ /** App display name */
577
+ name: string;
578
+ /** GitHub organization or null for personal apps */
579
+ organization: string | null;
580
+ /** GitHub API URL (e.g., https://api.github.com) */
581
+ api_url: string;
582
+ /** GitHub App web URL (e.g., https://github.com/apps/my-app) */
583
+ html_url: string;
584
+ /** Custom SSH user for git operations (default: "git") */
585
+ custom_user: string;
586
+ /** Custom SSH port (default: 22) */
587
+ custom_port: number;
588
+ /** GitHub App ID (different from Coolify's internal id) */
589
+ app_id: number;
590
+ /** GitHub Installation ID */
591
+ installation_id: number;
592
+ /** GitHub OAuth App Client ID */
593
+ client_id: string;
594
+ /** GitHub private key ID on the Coolify server */
595
+ private_key_id: number;
596
+ /** Whether app is installed across all org repos (not just selected ones) */
597
+ is_system_wide: boolean;
598
+ /** Whether this is a public GitHub App (public installer) vs private */
599
+ is_public: boolean;
600
+ /** Coolify team ID that owns this app */
601
+ team_id: number;
602
+ /** App type string */
603
+ type: string;
604
+ }
package/src/sdk.ts CHANGED
@@ -32,6 +32,7 @@ import type {
32
32
  IProgressCallback,
33
33
  } from "./coolify/types.js";
34
34
  import type { ICoolifyEnvVar } from "./coolify/index.js";
35
+ import { parseEnvContent } from "./utils/env-parser.js";
35
36
 
36
37
  /** SDK configuration options. */
37
38
  export interface ICoolifyOptions {
@@ -83,6 +84,11 @@ class ApplicationsResource {
83
84
  return unwrap(await this.svc.listApplicationSummaries());
84
85
  }
85
86
 
87
+ /** Get a single application by UUID with full details (including settings and watch_paths). */
88
+ async get(uuid: string): Promise<ICoolifyApplication> {
89
+ return unwrap(await this.svc.getApplication(uuid));
90
+ }
91
+
86
92
  /** Resolve application by name, domain, or UUID. */
87
93
  async resolve(query: string): Promise<ICoolifyApplication> {
88
94
  return unwrap(await this.svc.resolveApplication(query));
@@ -212,15 +218,19 @@ class ApplicationsResource {
212
218
  }> {
213
219
  const { filePath, dryRun = false, prune = false, onProgress } = options;
214
220
 
215
- // 1. Read and parse .env file
221
+ // 1. Read and parse .env file (absolute path resolution + Node fs fallback)
216
222
  let envContent: string;
223
+ const { readFileSync } = await import('node:fs');
224
+ const { resolve, isAbsolute } = await import('node:path');
225
+ const target = filePath || '.env';
226
+ const absoluteTarget = isAbsolute(target) ? target : resolve(process.cwd(), target);
217
227
  try {
218
- envContent = await Bun.file(filePath || '.env').text();
219
- } catch {
228
+ // Prefer Node fs (more portable across Bun versions + clearer errors than Bun.file()).
229
+ envContent = readFileSync(absoluteTarget, 'utf-8');
230
+ } catch (err) {
231
+ const msg = err instanceof Error ? err.message : String(err);
220
232
  throw new Error(
221
- filePath
222
- ? `Cannot read file: ${filePath}`
223
- : 'No .env file found in current directory',
233
+ `Cannot read env file at ${absoluteTarget} (resolved from ${target}): ${msg}`,
224
234
  );
225
235
  }
226
236
 
@@ -304,43 +314,14 @@ class ApplicationsResource {
304
314
 
305
315
  /**
306
316
  * Parse .env file content into a Map.
307
- * Handles comments, empty lines, and quoted values.
317
+ * Delegates to the shared utility in `./utils/env-parser.js` so the SDK
318
+ * and CLI use a single implementation.
308
319
  *
309
320
  * @param content - The .env file content
310
321
  * @returns Map of environment variables
311
322
  */
312
323
  private parseEnvContent(content: string): Map<string, string> {
313
- const envVars = new Map<string, string>();
314
- const lines = content.split('\n');
315
-
316
- for (const line of lines) {
317
- const trimmedLine = line.trim();
318
- // Skip comments and empty lines
319
- if (!trimmedLine || trimmedLine.startsWith('#')) {
320
- continue;
321
- }
322
-
323
- // Parse KEY=VALUE format
324
- const eqIndex = trimmedLine.indexOf('=');
325
- if (eqIndex === -1) {
326
- continue; // Skip invalid lines
327
- }
328
-
329
- const key = trimmedLine.slice(0, eqIndex).trim();
330
- let value = trimmedLine.slice(eqIndex + 1).trim();
331
-
332
- // Remove quotes if present
333
- if (
334
- (value.startsWith('"') && value.endsWith('"')) ||
335
- (value.startsWith("'") && value.endsWith("'"))
336
- ) {
337
- value = value.slice(1, -1);
338
- }
339
-
340
- envVars.set(key, value);
341
- }
342
-
343
- return envVars;
324
+ return parseEnvContent(content);
344
325
  }
345
326
  }
346
327
 
@@ -399,6 +380,36 @@ class DatabasesResource {
399
380
  async deleteBackup(dbUuid: string, backupUuid: string) {
400
381
  return unwrap(await this.svc.deleteDatabaseBackup(dbUuid, backupUuid));
401
382
  }
383
+
384
+ /** List env vars for a database. */
385
+ async envVars(uuid: string): Promise<ICoolifyEnvVar[]> {
386
+ return unwrap(await this.svc.listDatabaseEnvVars(uuid));
387
+ }
388
+
389
+ /**
390
+ * Bulk set (create-or-update) env vars for a database.
391
+ *
392
+ * Note: the database schema is narrower than applications — only
393
+ * `is_literal`, `is_multiline`, `is_shown_once` are accepted. No
394
+ * `is_preview` / `is_buildtime` / `is_runtime`.
395
+ */
396
+ async bulkSetEnv(
397
+ uuid: string,
398
+ vars: Array<{
399
+ key: string;
400
+ value: string;
401
+ is_literal?: boolean;
402
+ is_multiline?: boolean;
403
+ is_shown_once?: boolean;
404
+ }>,
405
+ ) {
406
+ return unwrap(await this.svc.bulkUpdateDatabaseEnvVars(uuid, vars));
407
+ }
408
+
409
+ /** Delete a database env var by key. Resolves key → UUID, then DELETE. */
410
+ async deleteEnv(uuid: string, key: string) {
411
+ return unwrap(await this.svc.deleteDatabaseEnvVar(uuid, key));
412
+ }
402
413
  }
403
414
 
404
415
  class ServicesResource {
@@ -443,11 +454,48 @@ class ServicesResource {
443
454
  return unwrap(await this.svc.listServiceEnvVars(uuid));
444
455
  }
445
456
 
457
+ /**
458
+ * Sets (creates or updates) a single env var for a service.
459
+ *
460
+ * Delegates to the bulk endpoint `PATCH /services/{uuid}/envs/bulk`,
461
+ * which has create-or-update semantics — calling this for the same key
462
+ * a second time updates the override value rather than failing with
463
+ * 409 "already exists" (which is what the raw `POST /services/{uuid}/envs`
464
+ * endpoint does). Mirrors `ApplicationsResource.setEnv`.
465
+ *
466
+ * @param uuid - Service UUID
467
+ * @param data - Env var data (key, value, is_preview)
468
+ */
446
469
  async setEnv(
447
470
  uuid: string,
448
471
  data: { key: string; value: string; is_preview?: boolean },
449
472
  ) {
450
- return unwrap(await this.svc.createServiceEnvVar(uuid, data));
473
+ return unwrap(await this.svc.bulkUpdateServiceEnvVars(uuid, [data]));
474
+ }
475
+
476
+ /**
477
+ * Bulk set (create-or-update) env vars for a service.
478
+ * Mirrors `ApplicationsResource.bulkSetEnv`.
479
+ *
480
+ * @param uuid - Service UUID
481
+ * @param vars - Array of env var definitions to upsert
482
+ */
483
+ async bulkSetEnv(
484
+ uuid: string,
485
+ vars: Array<{
486
+ key: string;
487
+ value: string;
488
+ is_preview?: boolean;
489
+ is_buildtime?: boolean;
490
+ is_runtime?: boolean;
491
+ }>,
492
+ ) {
493
+ return unwrap(await this.svc.bulkUpdateServiceEnvVars(uuid, vars));
494
+ }
495
+
496
+ /** Delete a service env var by key. Resolves key → UUID, then DELETE. */
497
+ async deleteEnv(uuid: string, key: string) {
498
+ return unwrap(await this.svc.deleteServiceEnvVar(uuid, key));
451
499
  }
452
500
  }
453
501
 
@@ -306,6 +306,12 @@ Redeploy after updating to apply changes.`,
306
306
  type: "string" as const,
307
307
  description: 'Base directory for build context (default: "/")',
308
308
  },
309
+ watchPaths: {
310
+ type: "string" as const,
311
+ description:
312
+ 'Watch paths for selective auto-deploy. Newline-separated globs (e.g. "src/**\\npackages/**"). Set to empty string or null to clear.',
313
+ nullable: true,
314
+ },
309
315
  dockerComposeDomains: {
310
316
  type: "string" as const,
311
317
  description:
@@ -315,6 +321,22 @@ Redeploy after updating to apply changes.`,
315
321
  required: ["uuid"],
316
322
  },
317
323
  },
324
+ {
325
+ name: "get_application",
326
+ description: `Get detailed information about a Coolify application including settings (auto-deploy, force HTTPS) and watch paths.
327
+
328
+ Returns full application details that list_applications doesn't include.`,
329
+ inputSchema: {
330
+ type: "object" as const,
331
+ properties: {
332
+ uuid: {
333
+ type: "string" as const,
334
+ description: "Application UUID",
335
+ },
336
+ },
337
+ required: ["uuid"],
338
+ },
339
+ },
318
340
  {
319
341
  name: "set_domains",
320
342
  description: `Set domains/FQDN for a Coolify application.
@@ -159,6 +159,7 @@ export async function handleToolCall(
159
159
  domains: a.domains,
160
160
  isForceHttpsEnabled: a.isForceHttpsEnabled,
161
161
  isAutoDeployEnabled: a.isAutoDeployEnabled,
162
+ watchPaths: a.watchPaths,
162
163
  })
163
164
  .then((app) => ({
164
165
  message: `Application ${a.uuid} updated`,
@@ -166,6 +167,24 @@ export async function handleToolCall(
166
167
  })),
167
168
  "Failed to update application",
168
169
  );
170
+ case "get_application":
171
+ return mcpCall(
172
+ (s) =>
173
+ s.applications.get(a.uuid).then((app) => ({
174
+ uuid: app.uuid,
175
+ name: app.name,
176
+ status: app.status,
177
+ fqdn: app.fqdn,
178
+ git_repository: app.git_repository,
179
+ git_branch: app.git_branch,
180
+ build_pack: app.build_pack,
181
+ dockerfile_location: app.dockerfile_location,
182
+ base_directory: app.base_directory,
183
+ watch_paths: app.watch_paths,
184
+ settings: app.settings,
185
+ })),
186
+ "Failed to get application",
187
+ );
169
188
  case "get_application_logs":
170
189
  return mcpCall(
171
190
  (s) =>
@@ -0,0 +1,45 @@
1
+ /**
2
+ * .env file parser shared between the SDK (syncEnv) and the CLI (--sync stdin).
3
+ *
4
+ * Kept as a standalone exported function (not a class method) so both the
5
+ * SDK's ApplicationsResource and the CLI's stdin sync handler can use the
6
+ * same implementation without coupling to either.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ /**
12
+ * Parses .env-style content into a Map.
13
+ *
14
+ * Handles:
15
+ * - Comments (lines starting with `#`)
16
+ * - Empty lines (skipped)
17
+ * - Quoted values (`"value"` or `'value'`)
18
+ * - Values containing `=` (only the first `=` is the separator)
19
+ * - Lines without `=` (skipped — invalid)
20
+ *
21
+ * @param content - File contents in KEY=VALUE format
22
+ * @returns Map of key → value (empty string for `KEY=` with no value)
23
+ */
24
+ export function parseEnvContent(content: string): Map<string, string> {
25
+ const envVars = new Map<string, string>();
26
+ for (const line of content.split("\n")) {
27
+ const trimmed = line.trim();
28
+ // Skip comments and empty lines.
29
+ if (!trimmed || trimmed.startsWith("#")) continue;
30
+ const eq = trimmed.indexOf("=");
31
+ // Skip invalid lines (no `=`).
32
+ if (eq === -1) continue;
33
+ const key = trimmed.slice(0, eq).trim();
34
+ let value = trimmed.slice(eq + 1).trim();
35
+ // Strip matching surrounding quotes.
36
+ if (
37
+ (value.startsWith('"') && value.endsWith('"')) ||
38
+ (value.startsWith("'") && value.endsWith("'"))
39
+ ) {
40
+ value = value.slice(1, -1);
41
+ }
42
+ if (key) envVars.set(key, value);
43
+ }
44
+ return envVars;
45
+ }
@@ -86,6 +86,184 @@ export function formatBytes(bytes: number): string {
86
86
  return `${bytes.toFixed(1)} ${units[i]}`;
87
87
  }
88
88
 
89
+ // ─── Validation ─────────────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Validates a port number string (single or comma-separated).
93
+ * Valid: "3000", "3000,3001", "80,443,8080"
94
+ * Invalid: "abc", "99999", "0", "-1", "3000,", ",3000"
95
+ *
96
+ * @param ports - Port string to validate
97
+ * @returns Object with valid flag, parsed ports, and error message
98
+ */
99
+ export function validatePorts(ports: string): {
100
+ valid: boolean;
101
+ ports: number[];
102
+ error?: string;
103
+ } {
104
+ if (!ports || !ports.trim()) {
105
+ return { valid: false, ports: [], error: "Port string is empty" };
106
+ }
107
+
108
+ const parts = ports.split(",").map((p) => p.trim());
109
+ const parsed: number[] = [];
110
+
111
+ for (const part of parts) {
112
+ if (!/^\d+$/.test(part)) {
113
+ return {
114
+ valid: false,
115
+ ports: [],
116
+ error: `Invalid port "${part}" — must be a number`,
117
+ };
118
+ }
119
+ const num = parseInt(part, 10);
120
+ if (num < 1 || num > 65535) {
121
+ return {
122
+ valid: false,
123
+ ports: [],
124
+ error: `Port ${num} out of range (1-65535)`,
125
+ };
126
+ }
127
+ parsed.push(num);
128
+ }
129
+
130
+ const dupes = parsed.filter((p, i) => parsed.indexOf(p) !== i);
131
+ if (dupes.length > 0) {
132
+ return {
133
+ valid: false,
134
+ ports: parsed,
135
+ error: `Duplicate port: ${dupes[0]}`,
136
+ };
137
+ }
138
+
139
+ return { valid: true, ports: parsed };
140
+ }
141
+
142
+ /**
143
+ * Parses EXPOSE directives from a Dockerfile.
144
+ *
145
+ * @param dockerfilePath - Path to the Dockerfile
146
+ * @returns Array of exposed port numbers, empty if none found or file unreadable
147
+ */
148
+ export function parseDockerfileExpose(dockerfilePath: string): number[] {
149
+ try {
150
+ const content = require("node:fs").readFileSync(dockerfilePath, "utf-8");
151
+ const ports: number[] = [];
152
+ for (const line of content.split("\n")) {
153
+ const match = line.match(/^\s*EXPOSE\s+(.+)/i);
154
+ if (match) {
155
+ for (const token of match[1].split(/\s+/)) {
156
+ // Handle "3000/tcp", "3000/udp", or just "3000"
157
+ const portStr = token.split("/")[0].trim();
158
+ const num = parseInt(portStr, 10);
159
+ if (!isNaN(num) && num >= 1 && num <= 65535) {
160
+ ports.push(num);
161
+ }
162
+ }
163
+ }
164
+ }
165
+ return [...new Set(ports)];
166
+ } catch {
167
+ return [];
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Validates a .coolify.json state object (single-app format).
173
+ * Checks required fields, types, and port validity.
174
+ *
175
+ * @param state - The parsed JSON object
176
+ * @returns Object with valid flag, warnings, and errors
177
+ */
178
+ export function validateCoolifyState(state: Record<string, unknown>): {
179
+ valid: boolean;
180
+ errors: string[];
181
+ warnings: string[];
182
+ } {
183
+ const errors: string[] = [];
184
+ const warnings: string[] = [];
185
+
186
+ // Detect format
187
+ const isMultiApp = Array.isArray(state.apps);
188
+ const isSingleApp = typeof state.appUuid === "string";
189
+
190
+ if (!isMultiApp && !isSingleApp) {
191
+ errors.push(
192
+ 'Invalid format: must have "appUuid" (single-app) or "apps" array (multi-app)',
193
+ );
194
+ return { valid: false, errors, warnings };
195
+ }
196
+
197
+ // Common required fields
198
+ const commonRequired = ["serverUuid", "projectUuid", "environmentUuid"];
199
+ for (const field of commonRequired) {
200
+ if (!state[field] || typeof state[field] !== "string") {
201
+ errors.push(`Missing or invalid required field: ${field}`);
202
+ }
203
+ }
204
+
205
+ if (isSingleApp) {
206
+ // Validate single-app specific fields
207
+ if (
208
+ state.portsExposes &&
209
+ typeof state.portsExposes === "string"
210
+ ) {
211
+ const portResult = validatePorts(state.portsExposes as string);
212
+ if (!portResult.valid) {
213
+ warnings.push(`portsExposes: ${portResult.error}`);
214
+ }
215
+ }
216
+
217
+ if (state.buildPack && typeof state.buildPack === "string") {
218
+ const validBuildPacks = [
219
+ "dockerfile",
220
+ "nixpacks",
221
+ "static",
222
+ "dockercompose",
223
+ ];
224
+ if (!validBuildPacks.includes(state.buildPack as string)) {
225
+ warnings.push(
226
+ `Unknown buildPack "${state.buildPack}" — expected: ${validBuildPacks.join(", ")}`,
227
+ );
228
+ }
229
+ }
230
+ }
231
+
232
+ if (isMultiApp) {
233
+ const apps = state.apps as Array<Record<string, unknown>>;
234
+ if (apps.length === 0) {
235
+ errors.push("Multi-app format requires at least one app");
236
+ }
237
+ for (let i = 0; i < apps.length; i++) {
238
+ const app = apps[i];
239
+ if (!app.uuid || typeof app.uuid !== "string") {
240
+ errors.push(`apps[${i}]: missing or invalid "uuid"`);
241
+ }
242
+ if (!app.name || typeof app.name !== "string") {
243
+ errors.push(`apps[${i}]: missing or invalid "name"`);
244
+ }
245
+ if (!app.service || typeof app.service !== "string") {
246
+ errors.push(`apps[${i}]: missing or invalid "service"`);
247
+ }
248
+ if (app.port !== undefined) {
249
+ const port = app.port as number;
250
+ if (typeof port !== "number" || port < 1 || port > 65535) {
251
+ warnings.push(`apps[${i}] (${app.name}): invalid port ${port}`);
252
+ }
253
+ }
254
+ }
255
+
256
+ // Check for duplicate UUIDs
257
+ const uuids = apps.map((a) => a.uuid).filter(Boolean);
258
+ const dupeUuids = uuids.filter((u, i) => uuids.indexOf(u) !== i);
259
+ if (dupeUuids.length > 0) {
260
+ errors.push(`Duplicate app UUIDs: ${dupeUuids.join(", ")}`);
261
+ }
262
+ }
263
+
264
+ return { valid: errors.length === 0, errors, warnings };
265
+ }
266
+
89
267
  /**
90
268
  * Formats timestamp to relative time.
91
269
  *