@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.
- package/dist/cli/coolify-state.d.ts +12 -4
- package/dist/cli/coolify-state.d.ts.map +1 -1
- package/dist/cli/index.js +8886 -7957
- package/dist/coolify/config.d.ts +25 -0
- package/dist/coolify/config.d.ts.map +1 -1
- package/dist/coolify/index.d.ts +118 -10
- package/dist/coolify/index.d.ts.map +1 -1
- package/dist/coolify/types.d.ts +61 -1
- package/dist/coolify/types.d.ts.map +1 -1
- package/dist/index.cjs +2267 -227
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +2289 -227
- package/dist/index.js.map +1 -1
- package/dist/sdk.d.ts +56 -8
- package/dist/sdk.d.ts.map +1 -1
- package/dist/server/stdio.js +253 -100
- package/dist/tools/definitions.d.ts.map +1 -1
- package/dist/tools/handlers.d.ts.map +1 -1
- package/dist/utils/env-parser.d.ts +24 -0
- package/dist/utils/env-parser.d.ts.map +1 -0
- package/dist/utils/format.d.ts +32 -0
- package/dist/utils/format.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/cli/commands/create.ts +279 -37
- package/src/cli/commands/env.ts +348 -54
- package/src/cli/commands/init.ts +69 -15
- package/src/cli/commands/main-menu.ts +1 -1
- package/src/cli/commands/projects.ts +3 -3
- package/src/cli/commands/show.ts +39 -10
- package/src/cli/commands/status.ts +23 -7
- package/src/cli/commands/svc.ts +7 -1
- package/src/cli/commands/update.ts +52 -0
- package/src/cli/commands/volumes.ts +293 -0
- package/src/cli/coolify-state.ts +42 -4
- package/src/cli/index.ts +50 -4
- package/src/cli/ui/banner.ts +3 -3
- package/src/cli/ui/screen.ts +26 -2
- package/src/coolify/config.ts +75 -0
- package/src/coolify/index.ts +325 -106
- package/src/coolify/types.ts +62 -1
- package/src/sdk.ts +87 -39
- package/src/tools/definitions.ts +22 -0
- package/src/tools/handlers.ts +19 -0
- package/src/utils/env-parser.ts +45 -0
- package/src/utils/format.ts +178 -0
package/src/coolify/types.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
|
package/src/tools/definitions.ts
CHANGED
|
@@ -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.
|
package/src/tools/handlers.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/format.ts
CHANGED
|
@@ -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
|
*
|