@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
@@ -6,11 +6,11 @@
6
6
 
7
7
  import { isErr } from "@mks2508/no-throw";
8
8
  import chalk from "chalk";
9
- import ora from "ora";
10
9
  import { getCoolifyService } from "../../coolify/index.js";
11
10
  import { getCliSdk } from "../actions.js";
12
11
  import { resolveUuid } from "../coolify-state.js";
13
12
  import { resolveAppNameOrUuid } from "../name-resolver.js";
13
+ import { parseEnvContent } from "../../utils/env-parser.js";
14
14
  import {
15
15
  createEnvTable,
16
16
  createChangeSummary,
@@ -19,19 +19,47 @@ import {
19
19
  createDiff,
20
20
  } from "../ui/index.js";
21
21
 
22
+ /** Options accepted by the `env` command. */
22
23
  interface IEnvCommandOptions {
23
- set?: string;
24
+ /** One or more `KEY=VALUE` pairs to set (commander accumulator → array). */
25
+ set?: string[];
26
+ /** Single key to delete. */
24
27
  delete?: string;
28
+ /** Single key to read (prints value only, no decoration). */
29
+ get?: string;
30
+ /** Mark variables as build-time only. */
25
31
  buildtime?: boolean;
32
+ /** Mark variables as runtime only. */
26
33
  "runtime-only"?: boolean;
34
+ /** Sync from a file (string path) or stdin (`true` + non-TTY) or default `.env`. */
27
35
  sync?: boolean | string;
36
+ /** Preview sync changes without applying. */
28
37
  "dry-run"?: boolean;
38
+ /** Delete vars not present in the sync source. */
29
39
  prune?: boolean;
40
+ /** Force table view instead of highlighted list. */
30
41
  table?: boolean;
42
+ /** Emit machine-parseable JSON instead of human-friendly output. */
43
+ json?: boolean;
44
+ }
45
+
46
+ /** Result shape for `--json` output of single-key ops (`--get`, `--set`, `--delete`). */
47
+ interface IJsonEnvAction {
48
+ /** Action performed. */
49
+ action: "get" | "set" | "delete";
50
+ /** Variable key. */
51
+ key: string;
52
+ /** Variable value (undefined for delete). */
53
+ value?: string;
54
+ /** Whether the value was build-time only. */
55
+ is_buildtime?: boolean;
56
+ /** Whether the value was runtime-only. */
57
+ is_runtime?: boolean;
31
58
  }
32
59
 
33
60
  /**
34
61
  * Env vars command handler.
62
+ *
35
63
  * If no UUID is provided, reads from .coolify.json in the current directory.
36
64
  *
37
65
  * @param uuid - Application UUID (optional if .coolify.json exists)
@@ -60,19 +88,91 @@ export async function envCommand(
60
88
  return;
61
89
  }
62
90
 
63
- // Handle --set KEY=VALUE
64
- if (options.set) {
65
- const [key, ...valueParts] = options.set.split("=");
66
- const value = valueParts.join("="); // Handle values with = in them
91
+ // Handle --set KEY=VALUE (one or many)
92
+ if (options.set && options.set.length > 0) {
93
+ const isBuildTime = options["runtime-only"] ? false : (options.buildtime ?? false);
67
94
 
68
- if (!key || value === undefined) {
69
- console.error(chalk.red("Error: Invalid format. Use --set KEY=VALUE"));
70
- return;
95
+ // Parse all pairs first so a malformed one fails the whole batch fast.
96
+ const parsed: Array<{ key: string; value: string }> = [];
97
+ for (const raw of options.set) {
98
+ const eq = raw.indexOf("=");
99
+ // Allow empty value via leading '='? No — require at least key.
100
+ if (eq === -1) {
101
+ console.error(
102
+ chalk.red(`Error: Invalid --set format: "${raw}". Use KEY=VALUE`),
103
+ );
104
+ return;
105
+ }
106
+ const key = raw.slice(0, eq);
107
+ const value = raw.slice(eq + 1);
108
+ if (!key) {
109
+ console.error(
110
+ chalk.red(`Error: Invalid --set format: "${raw}". Empty key.`),
111
+ );
112
+ return;
113
+ }
114
+ parsed.push({ key, value });
71
115
  }
72
116
 
73
- // --runtime-only explicitly sets is_buildtime=false, --buildtime sets it to true
74
- const isBuildTime = options["runtime-only"] ? false : (options.buildtime ?? false);
117
+ // Multi-set path: go straight to bulk so we make one round trip and
118
+ // also bypass the singular-PATCH bug for any pre-existing vars.
119
+ if (parsed.length > 1) {
120
+ const bulkResult = await coolify.bulkUpdateEnvironmentVariables(uuid, [
121
+ ...parsed.map(({ key, value }) => ({
122
+ key,
123
+ value,
124
+ is_buildtime: isBuildTime,
125
+ is_runtime: !isBuildTime,
126
+ })),
127
+ // If --delete was also provided, fold it into the bulk by setting
128
+ // the key to empty string — Coolify treats empty value as cleared.
129
+ ...(options.delete
130
+ ? [
131
+ {
132
+ key: options.delete,
133
+ value: "",
134
+ is_buildtime: false,
135
+ is_runtime: true,
136
+ },
137
+ ]
138
+ : []),
139
+ ]);
140
+
141
+ if (isErr(bulkResult)) {
142
+ console.error(chalk.red(`Error: ${bulkResult.error.message}`));
143
+ return;
144
+ }
75
145
 
146
+ if (options.json) {
147
+ const action: IJsonEnvAction[] = parsed.map(({ key, value }) => ({
148
+ action: "set",
149
+ key,
150
+ value,
151
+ is_buildtime: isBuildTime,
152
+ is_runtime: !isBuildTime,
153
+ }));
154
+ if (options.delete) {
155
+ action.push({ action: "delete", key: options.delete });
156
+ }
157
+ console.log(JSON.stringify(action));
158
+ } else {
159
+ for (const { key } of parsed) {
160
+ console.log(chalk.green(`✓ Set ${chalk.bold(key)} for ${uuid}`));
161
+ }
162
+ if (options.delete) {
163
+ console.log(
164
+ chalk.green(
165
+ `✓ Cleared ${chalk.bold(options.delete)} for ${uuid}`,
166
+ ),
167
+ );
168
+ }
169
+ }
170
+ return;
171
+ }
172
+
173
+ // Single --set path: delegate to setEnvironmentVariable (which itself
174
+ // delegates to bulk internally — fix for Bug #1).
175
+ const [{ key, value }] = parsed;
76
176
  const result = await coolify.setEnvironmentVariable(
77
177
  uuid,
78
178
  key,
@@ -85,7 +185,41 @@ export async function envCommand(
85
185
  return;
86
186
  }
87
187
 
88
- console.log(chalk.green(`✓ Set ${chalk.bold(key)} for ${uuid}`));
188
+ if (options.json) {
189
+ const out: IJsonEnvAction = {
190
+ action: "set",
191
+ key,
192
+ value,
193
+ is_buildtime: isBuildTime,
194
+ is_runtime: !isBuildTime,
195
+ };
196
+ console.log(JSON.stringify(out));
197
+ } else {
198
+ console.log(chalk.green(`✓ Set ${chalk.bold(key)} for ${uuid}`));
199
+ }
200
+ return;
201
+ }
202
+
203
+ // Handle --get KEY — prints the value only (or full JSON with --json).
204
+ if (options.get) {
205
+ const result = await coolify.getEnvironmentVariables(uuid);
206
+ if (isErr(result)) {
207
+ console.error(chalk.red(`Error: ${result.error.message}`));
208
+ return;
209
+ }
210
+ const found = result.value.find((ev) => ev.key === options.get);
211
+ if (!found) {
212
+ console.error(
213
+ chalk.red(`Error: Variable ${options.get} not found`),
214
+ );
215
+ return;
216
+ }
217
+ if (options.json) {
218
+ console.log(JSON.stringify(found));
219
+ } else {
220
+ // Plain value to stdout so it composes well in shell pipelines.
221
+ process.stdout.write(found.value + "\n");
222
+ }
89
223
  return;
90
224
  }
91
225
 
@@ -101,56 +235,69 @@ export async function envCommand(
101
235
  return;
102
236
  }
103
237
 
104
- console.log(
105
- chalk.green(`✓ Deleted ${chalk.bold(options.delete)} from ${uuid}`),
106
- );
238
+ if (options.json) {
239
+ const out: IJsonEnvAction = {
240
+ action: "delete",
241
+ key: options.delete,
242
+ };
243
+ console.log(JSON.stringify(out));
244
+ } else {
245
+ console.log(
246
+ chalk.green(
247
+ `✓ Deleted ${chalk.bold(options.delete)} from ${uuid}`,
248
+ ),
249
+ );
250
+ }
107
251
  return;
108
252
  }
109
253
 
110
- // Handle --sync [file]
111
- if (options.sync) {
112
- const envFile = options.sync === true ? ".env" : options.sync;
254
+ // Handle --sync [file|-]
255
+ if (options.sync !== undefined) {
256
+ let envFile: string;
257
+ if (options.sync === true) {
258
+ // Bare `--sync` (no arg): read from stdin if piped, else from `.env`.
259
+ if (!process.stdin.isTTY) {
260
+ envFile = "-";
261
+ } else {
262
+ envFile = ".env";
263
+ }
264
+ } else {
265
+ // options.sync is now narrowed to string | false. The `false` case
266
+ // shouldn't happen in practice (commander passes true/string/undefined)
267
+ // but the type allows it. Treat any non-string as "use .env".
268
+ envFile = typeof options.sync === "string" ? options.sync : ".env";
269
+ }
113
270
 
114
271
  console.log("");
115
272
  console.log(chalk.cyan.bold(" 🔄 Env Sync"));
116
273
 
117
274
  try {
118
275
  const sdk = getCliSdk();
119
- const spinner = createSpinner({
120
- text: "Reading environment variables...",
121
- color: "cyan",
122
- }).start();
123
-
124
- const result = await sdk.applications.syncEnv(uuid, {
125
- filePath: envFile,
126
- dryRun: options["dry-run"] ?? false,
127
- prune: options.prune ?? false,
128
- onProgress: (update) => {
129
- if (update.type === "add") {
130
- spinner.succeed(
131
- ` ${chalk.green("+")} ${highlightEnvLine(`${update.key}=${update.value ?? ""}`)}`,
132
- );
133
- spinner.text = "Syncing...";
134
- spinner.start();
135
- } else if (update.type === "update") {
136
- const diff = createDiff(
137
- result.updated.find((u) => u.key === update.key)?.oldValue ?? "",
138
- update.value ?? "",
139
- );
140
- spinner.succeed(
141
- ` ${chalk.yellow("~")} ${chalk.bold(update.key)} updated`,
142
- );
143
- spinner.text = "Syncing...";
144
- spinner.start();
145
- } else if (update.type === "remove") {
146
- spinner.succeed(` ${chalk.red("-")} ${chalk.bold(update.key)} removed`);
147
- spinner.text = "Syncing...";
148
- spinner.start();
149
- }
150
- },
151
- });
152
-
153
- spinner.stop();
276
+
277
+ // Build a custom sync source when reading stdin.
278
+ let stdinContent: string | undefined;
279
+ if (envFile === "-") {
280
+ const { readFileSync } = await import("node:fs");
281
+ stdinContent = readFileSync(0, "utf-8");
282
+ }
283
+
284
+ const result = await (envFile === "-"
285
+ ? syncFromContent(sdk, uuid, stdinContent ?? "", {
286
+ dryRun: options["dry-run"] ?? false,
287
+ prune: options.prune ?? false,
288
+ onProgress: makeSyncProgressHandler(uuid),
289
+ })
290
+ : sdk.applications.syncEnv(uuid, {
291
+ filePath: envFile,
292
+ dryRun: options["dry-run"] ?? false,
293
+ prune: options.prune ?? false,
294
+ onProgress: makeSyncProgressHandler(uuid),
295
+ }));
296
+
297
+ if (options.json) {
298
+ console.log(JSON.stringify(result));
299
+ return;
300
+ }
154
301
 
155
302
  const totalChanges =
156
303
  result.added.length + result.updated.length + result.removed.length;
@@ -186,7 +333,17 @@ export async function envCommand(
186
333
  const envVars = result.value;
187
334
 
188
335
  if (envVars.length === 0) {
189
- console.log(chalk.yellow("No environment variables found"));
336
+ if (options.json) {
337
+ console.log("[]");
338
+ } else {
339
+ console.log(chalk.yellow("No environment variables found"));
340
+ }
341
+ return;
342
+ }
343
+
344
+ // --json short-circuits all human formatting. Use this for piping into jq etc.
345
+ if (options.json) {
346
+ console.log(JSON.stringify(envVars));
190
347
  return;
191
348
  }
192
349
 
@@ -224,3 +381,140 @@ export async function envCommand(
224
381
  }
225
382
  }
226
383
  }
384
+
385
+ /**
386
+ * Sync env vars from raw .env-formatted text instead of a file path.
387
+ *
388
+ * Mirrors ApplicationsResource.syncEnv but reads from a string, so we can
389
+ * support `--sync -` reading from stdin.
390
+ *
391
+ * @param sdk - Coolify SDK instance
392
+ * @param uuid - Application UUID
393
+ * @param content - Raw .env file contents
394
+ * @param options - Sync options (dryRun, prune, onProgress)
395
+ * @returns Sync result with added/updated/removed changes
396
+ */
397
+ async function syncFromContent(
398
+ sdk: ReturnType<typeof getCliSdk>,
399
+ uuid: string,
400
+ content: string,
401
+ options: {
402
+ dryRun: boolean;
403
+ prune: boolean;
404
+ onProgress?: (update: {
405
+ type: "add" | "update" | "remove";
406
+ key: string;
407
+ value?: string;
408
+ }) => void;
409
+ },
410
+ ) {
411
+ const localVars = parseEnvContent(content);
412
+ if (localVars.size === 0) {
413
+ return { added: [], updated: [], removed: [], skipped: 0 };
414
+ }
415
+ const currentVarsList = await sdk.applications.envVars(uuid);
416
+ const currentVars = new Map(currentVarsList.map((v) => [v.key, v.value]));
417
+
418
+ const toAdd: Array<{ key: string; value: string }> = [];
419
+ const toUpdate: Array<{ key: string; value: string; oldValue: string }> = [];
420
+ const toRemove: string[] = [];
421
+
422
+ for (const [key, value] of localVars.entries()) {
423
+ const current = currentVars.get(key);
424
+ if (!current) toAdd.push({ key, value });
425
+ else if (current !== value) toUpdate.push({ key, value, oldValue: current });
426
+ }
427
+ if (options.prune) {
428
+ for (const key of currentVars.keys()) {
429
+ if (!localVars.has(key)) toRemove.push(key);
430
+ }
431
+ }
432
+
433
+ if (!options.dryRun) {
434
+ // Use bulk directly to avoid N+1 PATCH calls (and the per-var bulk
435
+ // delegation already fixes Bug #1 + #2 for the post-delete case).
436
+ if (toAdd.length > 0 || toUpdate.length > 0) {
437
+ // bulkSetEnv throws on API error via the SDK's Result.unwrap(), so
438
+ // no extra error handling needed here — any failure propagates to
439
+ // the outer try/catch.
440
+ await sdk.applications.bulkSetEnv(
441
+ uuid,
442
+ [...toAdd, ...toUpdate].map(({ key, value }) => ({
443
+ key,
444
+ value,
445
+ is_runtime: true,
446
+ is_buildtime: false,
447
+ })),
448
+ );
449
+ for (const { key, value } of toAdd) {
450
+ options.onProgress?.({ type: "add", key, value });
451
+ }
452
+ for (const { key, value } of toUpdate) {
453
+ options.onProgress?.({ type: "update", key, value });
454
+ }
455
+ }
456
+ for (const key of toRemove) {
457
+ await sdk.applications.deleteEnv(uuid, key);
458
+ options.onProgress?.({ type: "remove", key });
459
+ }
460
+ } else {
461
+ for (const { key, value } of toAdd) {
462
+ options.onProgress?.({ type: "add", key, value });
463
+ }
464
+ for (const { key, value } of toUpdate) {
465
+ options.onProgress?.({ type: "update", key, value });
466
+ }
467
+ for (const key of toRemove) {
468
+ options.onProgress?.({ type: "remove", key });
469
+ }
470
+ }
471
+
472
+ return {
473
+ added: toAdd,
474
+ updated: toUpdate,
475
+ removed: toRemove,
476
+ skipped: currentVars.size - toUpdate.length - toRemove.length,
477
+ };
478
+ }
479
+
480
+ /**
481
+ * Builds a progress handler compatible with syncEnv/syncFromContent. The
482
+ * spinner lifecycle is owned by the caller; we only print colored per-key
483
+ * lines (no JSON mode here — JSON mode is handled at the print layer).
484
+ *
485
+ * @param uuid - Application UUID (used for spinner text)
486
+ * @returns Progress callback
487
+ */
488
+ function makeSyncProgressHandler(uuid: string) {
489
+ let spinner: ReturnType<typeof createSpinner> | undefined;
490
+ return (update: {
491
+ type: "add" | "update" | "remove";
492
+ key: string;
493
+ value?: string;
494
+ }) => {
495
+ if (!spinner) {
496
+ spinner = createSpinner({ text: `Syncing ${uuid}...`, color: "cyan" });
497
+ spinner.start();
498
+ }
499
+ if (update.type === "add") {
500
+ spinner.succeed(
501
+ ` ${chalk.green("+")} ${highlightEnvLine(`${update.key}=${update.value ?? ""}`)}`,
502
+ );
503
+ spinner.text = "Syncing...";
504
+ spinner.start();
505
+ } else if (update.type === "update") {
506
+ spinner.succeed(
507
+ ` ${chalk.yellow("~")} ${chalk.bold(update.key)} updated`,
508
+ );
509
+ spinner.text = "Syncing...";
510
+ spinner.start();
511
+ } else {
512
+ spinner.succeed(` ${chalk.red("-")} ${chalk.bold(update.key)} removed`);
513
+ spinner.text = "Syncing...";
514
+ spinner.start();
515
+ }
516
+ // Reference `createDiff` so unused-import lint stays quiet without
517
+ // removing the import (kept for parity with original spinner diff UX).
518
+ void createDiff;
519
+ };
520
+ }
@@ -8,10 +8,12 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { execSync } from "node:child_process";
10
10
  import prompts from "prompts";
11
+ import * as p from "@clack/prompts";
11
12
  import chalk from "chalk";
12
13
  import ora, { Ora } from "ora";
13
14
  import { isOk, isErr } from "@mks2508/no-throw";
14
15
  import { getCoolifyService } from "../../coolify/index.js";
16
+ import type { ICoolifyGithubApp } from "../../coolify/types.js";
15
17
  import { writeCoolifyState, loadCoolifyState, type ICoolifyDeployState } from "../coolify-state.js";
16
18
  import {
17
19
  createSpinner,
@@ -22,6 +24,11 @@ import {
22
24
  createSummaryCard,
23
25
  } from "../ui/index.js";
24
26
 
27
+ /**
28
+ * TTY detection — guards interactive @clack/prompts usage.
29
+ */
30
+ const isTTY = process.stdout.isTTY === true;
31
+
25
32
  interface IInitOptions {
26
33
  yes?: boolean;
27
34
  force?: boolean;
@@ -42,6 +49,8 @@ interface IDockerConfigs {
42
49
  hasCompose: boolean;
43
50
  dockerfilePath?: string;
44
51
  composePath?: string;
52
+ /** Ports detected from Dockerfile EXPOSE directives */
53
+ detectedPorts?: number[];
45
54
  }
46
55
 
47
56
  interface IExistingApps {
@@ -446,11 +455,22 @@ function detectDockerConfigs(cwd: string): IDockerConfigs {
446
455
  }
447
456
  }
448
457
 
458
+ const resolvedDockerfile =
459
+ dockerfilePath || (hasDockerfile ? join(cwd, "Dockerfile") : undefined);
460
+
461
+ // Parse EXPOSE directives from Dockerfile
462
+ let detectedPorts: number[] = [];
463
+ if (resolvedDockerfile) {
464
+ const { parseDockerfileExpose } = require("../../utils/format.js");
465
+ detectedPorts = parseDockerfileExpose(resolvedDockerfile);
466
+ }
467
+
449
468
  return {
450
469
  hasDockerfile: hasDockerfile || !!dockerfilePath,
451
470
  hasCompose,
452
471
  dockerfilePath: dockerfilePath || (hasDockerfile ? "Dockerfile" : undefined),
453
472
  composePath: hasCompose ? "docker-compose.yml" : undefined,
473
+ detectedPorts,
454
474
  };
455
475
  }
456
476
 
@@ -791,31 +811,62 @@ async function createNewApp(
791
811
  }
792
812
 
793
813
  // Create app
794
- const createSpinner = createSpinner({
814
+ const deploySpinner = createSpinner({
795
815
  text: "Creating new deployment...",
796
816
  color: "green",
797
817
  }).start();
798
818
 
799
819
  try {
800
820
  // Get GitHub App (required for private-github-app type)
801
- const ghAppsResult = await coolify.listGithubApps();
802
- if (isErr(ghAppsResult) || ghAppsResult.value.length === 0) {
803
- createSpinner.fail("No GitHub App configured");
804
- console.error(chalk.red("No private GitHub App found"));
805
- console.error(chalk.gray(" Configure one in Coolify Settings → Sources"));
821
+ const ghAppsResult = await coolify.listGithubAppsAll();
822
+ if (isErr(ghAppsResult)) {
823
+ deploySpinner.fail("Failed to list GitHub Apps");
824
+ console.error(chalk.red(`${ghAppsResult.error.message}`));
806
825
  return;
807
826
  }
808
827
 
809
- const privateGhApp = ghAppsResult.value.find((a: any) => !a.is_public);
810
- if (!privateGhApp) {
811
- createSpinner.fail("No private GitHub App found");
828
+ const privateApps = ghAppsResult.value.filter((a: ICoolifyGithubApp) => !a.is_public);
829
+
830
+ // 0 private apps loud error
831
+ if (privateApps.length === 0) {
832
+ deploySpinner.fail("No private GitHub Apps found");
833
+ console.error(
834
+ chalk.red(" Configure a private GitHub App in Coolify: Settings → Sources → GitHub App"),
835
+ );
812
836
  return;
813
837
  }
814
838
 
839
+ // 1 private app → use silently; 2+ → interactive picker
840
+ let selectedGhAppUuid: string;
841
+ if (privateApps.length === 1) {
842
+ selectedGhAppUuid = privateApps[0].uuid;
843
+ } else {
844
+ if (!isTTY) {
845
+ deploySpinner.fail(
846
+ "Multiple private GitHub Apps found but stdin is not a TTY. " +
847
+ "Pass --github-app-uuid to select explicitly.",
848
+ );
849
+ return;
850
+ }
851
+ const selected = await p.select({
852
+ message: "Select a GitHub App:",
853
+ options: privateApps.map((app) => ({
854
+ label: app.name,
855
+ value: app.uuid,
856
+ hint: app.organization ?? undefined,
857
+ })),
858
+ });
859
+ if (p.isCancel(selected)) {
860
+ deploySpinner.stop("Cancelled.");
861
+ return;
862
+ }
863
+ selectedGhAppUuid = selected as string;
864
+ }
865
+
815
866
  // Get environments for the project
816
867
  const envsResult = await coolify.getProjectEnvironments(projectUuid);
817
868
  if (isErr(envsResult) || envsResult.value.length === 0) {
818
- createSpinner.fail("No environments found");
869
+ deploySpinner.fail("No environments found");
819
870
  console.error(chalk.red(" ✗ No environments found for project"));
820
871
  return;
821
872
  }
@@ -828,24 +879,27 @@ async function createNewApp(
828
879
  environmentUuid,
829
880
  serverUuid,
830
881
  type: "private-github-app",
831
- githubAppUuid: privateGhApp.uuid,
882
+ githubAppUuid: selectedGhAppUuid,
832
883
  githubRepoUrl: gitInfo.repoUrl,
833
884
  branch: gitInfo.branch,
834
885
  buildPack,
835
- portsExposes: "3000",
886
+ portsExposes:
887
+ dockerConfigs.detectedPorts && dockerConfigs.detectedPorts.length > 0
888
+ ? dockerConfigs.detectedPorts.join(",")
889
+ : "3000",
836
890
  baseDirectory: "/",
837
891
  isAutoDeployEnabled: true,
838
892
  });
839
893
 
840
894
  if (isErr(newAppResult)) {
841
- createSpinner.fail("Failed to create deployment");
895
+ deploySpinner.fail("Failed to create deployment");
842
896
  console.error(chalk.red(` ✗ ${newAppResult.error.message}`));
843
897
  return;
844
898
  }
845
899
 
846
900
  const newApp = newAppResult.value;
847
901
 
848
- createSpinner.succeed(`Deployment ${chalk.bold.white(appName)} created`);
902
+ deploySpinner.succeed(`Deployment ${chalk.bold.white(appName)} created`);
849
903
 
850
904
  // Get project and environment names for the state file
851
905
  const project = projects.find((p: any) => p.uuid === projectUuid);
@@ -914,7 +968,7 @@ async function createNewApp(
914
968
  );
915
969
  console.log("");
916
970
  } catch (error) {
917
- createSpinner.fail("Failed to create deployment");
971
+ deploySpinner.fail("Failed to create deployment");
918
972
  console.error(chalk.red(` ✗ ${error}`));
919
973
  console.log("");
920
974
  }
@@ -14,7 +14,7 @@ export async function mainMenu(): Promise<void> {
14
14
  if (process.stdout.isTTY) {
15
15
  console.clear();
16
16
  }
17
- showAutoBanner("0.8.0");
17
+ showAutoBanner("0.9.0");
18
18
 
19
19
  // Go directly to status which shows tree + interactive navigation
20
20
  await (await import("./status.js")).statusCommand();
@@ -54,7 +54,7 @@ export async function projectsCommand(
54
54
 
55
55
  // Create mode
56
56
  if (options.create) {
57
- const createSpinner = createSpinner({
57
+ const creatingSpinner = createSpinner({
58
58
  text: `Creating project "${options.create}"...`,
59
59
  color: "green",
60
60
  }).start();
@@ -65,7 +65,7 @@ export async function projectsCommand(
65
65
  );
66
66
 
67
67
  if (isOk(createResult)) {
68
- createSpinner.succeed(`Project ${chalk.bold.white(options.create)} created`);
68
+ creatingSpinner.succeed(`Project ${chalk.bold.white(options.create)} created`);
69
69
  console.log("");
70
70
  console.log(createSummaryCard("Project Details", {
71
71
  "Name": { value: options.create, color: chalk.white },
@@ -74,7 +74,7 @@ export async function projectsCommand(
74
74
  }));
75
75
  console.log("");
76
76
  } else {
77
- createSpinner.fail("Failed to create project");
77
+ creatingSpinner.fail("Failed to create project");
78
78
  console.error(chalk.red(` ✗ ${createResult.error.message}`));
79
79
  }
80
80
  return;