@mks2508/coolify-mks-cli-mcp 0.6.2 → 0.8.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 (60) hide show
  1. package/dist/cli/coolify-state.d.ts +92 -4
  2. package/dist/cli/coolify-state.d.ts.map +1 -1
  3. package/dist/cli/index.js +22228 -11529
  4. package/dist/cli/ui/highlighter.d.ts +28 -0
  5. package/dist/cli/ui/highlighter.d.ts.map +1 -0
  6. package/dist/cli/ui/index.d.ts +9 -0
  7. package/dist/cli/ui/index.d.ts.map +1 -0
  8. package/dist/cli/ui/spinners.d.ts +100 -0
  9. package/dist/cli/ui/spinners.d.ts.map +1 -0
  10. package/dist/cli/ui/tables.d.ts +103 -0
  11. package/dist/cli/ui/tables.d.ts.map +1 -0
  12. package/dist/coolify/index.d.ts +22 -3
  13. package/dist/coolify/index.d.ts.map +1 -1
  14. package/dist/coolify/types.d.ts +99 -1
  15. package/dist/coolify/types.d.ts.map +1 -1
  16. package/dist/examples/demo-ui.d.ts +8 -0
  17. package/dist/examples/demo-ui.d.ts.map +1 -0
  18. package/dist/index.cjs +322 -12
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.js +322 -12
  21. package/dist/index.js.map +1 -1
  22. package/dist/sdk.d.ts +41 -0
  23. package/dist/sdk.d.ts.map +1 -1
  24. package/dist/server/stdio.js +258 -9
  25. package/package.json +19 -4
  26. package/src/cli/actions.ts +9 -2
  27. package/src/cli/commands/create.ts +71 -5
  28. package/src/cli/commands/db.ts +37 -0
  29. package/src/cli/commands/delete.ts +6 -2
  30. package/src/cli/commands/deploy.ts +347 -49
  31. package/src/cli/commands/deployments.ts +6 -2
  32. package/src/cli/commands/diagnose.ts +3 -3
  33. package/src/cli/commands/env.ts +121 -22
  34. package/src/cli/commands/exec.ts +6 -2
  35. package/src/cli/commands/init.ts +937 -0
  36. package/src/cli/commands/logs.ts +224 -24
  37. package/src/cli/commands/main-menu.ts +21 -0
  38. package/src/cli/commands/projects.ts +312 -29
  39. package/src/cli/commands/restart.ts +6 -2
  40. package/src/cli/commands/service-logs.ts +14 -0
  41. package/src/cli/commands/show.ts +6 -2
  42. package/src/cli/commands/start.ts +6 -2
  43. package/src/cli/commands/status.ts +538 -0
  44. package/src/cli/commands/stop.ts +6 -2
  45. package/src/cli/commands/update.ts +27 -2
  46. package/src/cli/coolify-state.ts +164 -11
  47. package/src/cli/index.ts +91 -10
  48. package/src/cli/name-resolver.ts +228 -0
  49. package/src/cli/ui/banner.ts +276 -0
  50. package/src/cli/ui/highlighter.ts +176 -0
  51. package/src/cli/ui/index.ts +9 -0
  52. package/src/cli/ui/prompts.ts +155 -0
  53. package/src/cli/ui/screen.ts +606 -0
  54. package/src/cli/ui/select.ts +280 -0
  55. package/src/cli/ui/spinners.ts +256 -0
  56. package/src/cli/ui/tables.ts +407 -0
  57. package/src/coolify/index.ts +257 -12
  58. package/src/coolify/types.ts +103 -1
  59. package/src/examples/demo-ui.ts +78 -0
  60. package/src/sdk.ts +162 -0
@@ -0,0 +1,538 @@
1
+ /**
2
+ * Status + interactive navigation — custom ANSI selectors throughout.
3
+ * No @clack/prompts for selection — all custom for integrated TUI feel.
4
+ *
5
+ * @module
6
+ */
7
+
8
+ import * as p from "@clack/prompts";
9
+ import { isErr } from "@mks2508/no-throw";
10
+ import chalk from "chalk";
11
+ import { getCoolifyService } from "../../coolify/index.js";
12
+ import type {
13
+ ICoolifyInfrastructureTree,
14
+ ICoolifyApplication,
15
+ ICoolifyResource,
16
+ } from "../../coolify/types.js";
17
+ import {
18
+ renderScreen,
19
+ clearScreen,
20
+ renderResourceHeader,
21
+ renderPreviewAt,
22
+ buildProjectPreview,
23
+ buildResourcePreview,
24
+ waitForKey,
25
+ type IScreenContext,
26
+ } from "../ui/screen.js";
27
+ import { showStatusDashboard, formatStatus } from "../ui/tables.js";
28
+ import { loadCoolifyState, loadMultiAppState } from "../coolify-state.js";
29
+ import { loadConfig } from "../../coolify/config.js";
30
+ import {
31
+ inlineSelect,
32
+ fullSelect,
33
+ textInput,
34
+ confirm as customConfirm,
35
+ type ISelectOption,
36
+ } from "../ui/select.js";
37
+ import { fetchLogPreview } from "./logs.js";
38
+
39
+ const isTTY = process.stdout.isTTY === true;
40
+
41
+ // ─── CWD detection ───────────────────────────────────────────────────────────
42
+
43
+ function detectCwdContext() {
44
+ const single = loadCoolifyState();
45
+ if (single) return { projectName: single.projectName, projectUuid: single.projectUuid, appUuids: [single.appUuid] };
46
+ const multi = loadMultiAppState();
47
+ if (multi) return { projectName: multi.projectName, projectUuid: multi.projectUuid, appUuids: multi.apps.map((a) => a.uuid) };
48
+ return { appUuids: [] as string[], projectName: undefined, projectUuid: undefined };
49
+ }
50
+
51
+ // ─── Entry ───────────────────────────────────────────────────────────────────
52
+
53
+ export async function statusCommand(options?: { watch?: boolean }): Promise<void> {
54
+ const coolify = getCoolifyService();
55
+ const initResult = await coolify.init();
56
+ if (isErr(initResult)) { console.error(chalk.red(`Error: ${initResult.error.message}`)); return; }
57
+
58
+ const fetchTree = async () => {
59
+ const r = await coolify.getInfrastructureTree();
60
+ return isErr(r) ? null : r.value;
61
+ };
62
+ const cwdCtx = detectCwdContext();
63
+
64
+ if (options?.watch) {
65
+ let count = 0;
66
+ const render = async () => { const d = await fetchTree(); if (d) { clearScreen(); console.log(chalk.gray(` refresh #${++count}`)); showStatusDashboard(d, cwdCtx.projectUuid); } };
67
+ await render();
68
+ const interval = setInterval(render, 5000);
69
+ process.on("SIGINT", () => { clearInterval(interval); process.exit(0); });
70
+ return;
71
+ }
72
+ if (!isTTY) { const d = await fetchTree(); if (d) showStatusDashboard(d, cwdCtx.projectUuid); return; }
73
+
74
+ clearScreen();
75
+ console.log(chalk.gray(" Loading..."));
76
+ const tree = await fetchTree();
77
+ if (!tree) { console.error(chalk.red(" Error fetching infrastructure")); return; }
78
+
79
+ await navigate(tree, cwdCtx.projectUuid);
80
+ }
81
+
82
+ // ─── Navigation ──────────────────────────────────────────────────────────────
83
+
84
+ async function navigate(tree: ICoolifyInfrastructureTree, cwdProjectUuid?: string): Promise<void> {
85
+ const hasCwd = cwdProjectUuid && tree.projects.some((pr) => pr.uuid === cwdProjectUuid);
86
+
87
+ if (hasCwd) {
88
+ const ctx: IScreenContext = { tree, cwdProjectUuid, focusedProjectUuid: cwdProjectUuid, promptInline: true };
89
+ const layout = renderScreen(ctx);
90
+
91
+ const resources = collectResources(tree, cwdProjectUuid);
92
+ const opts: ISelectOption[] = resources.map((res) => {
93
+ const tag = res.kind === "database" ? "[db] " : res.kind === "service" ? "[svc] " : "";
94
+ return { label: `${tag}${res.name}`, value: `res:${res.uuid}:${res.kind}:${res.name}`, hint: formatStatus(res.status) };
95
+ });
96
+ opts.push({ label: "", value: "_sep" });
97
+ opts.push({ label: "All projects", value: "overview" });
98
+ opts.push({ label: "Refresh", value: "refresh" });
99
+ opts.push({ label: "Exit", value: "exit" });
100
+
101
+ // Live preview: when hovering a resource, show its detail on the right
102
+ const previewCol = layout.promptCol + 28;
103
+ const choice = await inlineSelect(opts, layout.promptRow, layout.promptCol, async (value) => {
104
+ if (value.startsWith("res:")) {
105
+ const [, resUuid] = value.split(":");
106
+ const res = resources.find((r) => r.uuid === resUuid);
107
+ if (res) {
108
+ // Try to build a quick preview from what we have
109
+ const lines = [
110
+ `${chalk.bold(res.name)}`,
111
+ chalk.gray(res.status),
112
+ "",
113
+ res.fqdn ? `${chalk.gray("Domain:")} ${res.fqdn.replace(/^https?:\/\//, "").split(",")[0]}` : "",
114
+ res.kind !== "app" ? `${chalk.gray("Type:")} ${res.kind}` : "",
115
+ ].filter(Boolean);
116
+ renderPreviewAt(layout.promptRow, previewCol, lines, opts.length);
117
+ }
118
+ } else {
119
+ // Clear preview for non-resource items
120
+ renderPreviewAt(layout.promptRow, previewCol, [], opts.length);
121
+ }
122
+ });
123
+ if (!choice || choice === "exit" || choice === "_sep") return;
124
+ if (choice === "refresh") return await refreshAndNavigate(cwdProjectUuid);
125
+ if (choice === "overview") return await projectPicker(tree, cwdProjectUuid);
126
+ if (choice.startsWith("res:")) {
127
+ const [, uuid, kind, ...np] = choice.split(":");
128
+ const res = resources.find((r) => r.uuid === uuid);
129
+ await navigateResource(tree, uuid, np.join(":"), kind, cwdProjectUuid, res, cwdProjectUuid);
130
+ await navigate(tree, cwdProjectUuid);
131
+ }
132
+ return;
133
+ }
134
+
135
+ // No CWD project: header + full-width project picker (no tree)
136
+ const ctx: IScreenContext = { tree, cwdProjectUuid, promptInline: true };
137
+ renderScreen(ctx);
138
+ await projectPicker(tree, cwdProjectUuid);
139
+ }
140
+
141
+ async function projectPicker(
142
+ tree: ICoolifyInfrastructureTree,
143
+ cwdProjectUuid?: string,
144
+ layout?: { promptRow: number; promptCol: number },
145
+ ): Promise<void> {
146
+ const opts: ISelectOption[] = tree.projects.map((proj) => {
147
+ const total = proj.environments.reduce((s, e) => s + e.resources.length, 0);
148
+ const statuses = proj.environments.flatMap((e) => e.resources.map((r) => r.status));
149
+ const h = statuses.filter((s) => s.includes("healthy") && !s.includes("unhealthy")).length;
150
+ const x = statuses.filter((s) => s.includes("exited")).length;
151
+ const parts = [
152
+ h > 0 ? `${chalk.green("●")}${chalk.hex("#cccccc")(String(h))}` : "",
153
+ x > 0 ? `${chalk.red("✗")}${chalk.hex("#cccccc")(String(x))}` : "",
154
+ chalk.hex("#888888")(`${total}res`),
155
+ ].filter(Boolean).join(" ");
156
+ return { label: proj.uuid === cwdProjectUuid ? chalk.cyan(proj.name) : proj.name, value: proj.uuid, hint: parts };
157
+ });
158
+ opts.push({ label: "", value: "_sep" });
159
+ opts.push({ label: "Refresh", value: "_refresh" });
160
+ opts.push({ label: "Exit", value: "_exit" });
161
+
162
+ // Live preview: show project details on the right when hovering
163
+ const pCol = 38;
164
+ const maxPreviewW = Math.max(20, (process.stdout.columns || 80) - pCol - 4);
165
+
166
+ const choice = layout
167
+ ? await inlineSelect(opts, layout.promptRow, layout.promptCol, (value) => {
168
+ const proj = tree.projects.find((pr) => pr.uuid === value);
169
+ if (proj) renderPreviewAt(layout.promptRow, pCol, buildProjectPreview(proj, maxPreviewW), opts.length + 2);
170
+ })
171
+ : await fullSelect("Select a project:", opts, (value, _idx, startRow) => {
172
+ const proj = tree.projects.find((pr) => pr.uuid === value);
173
+ if (proj) renderPreviewAt(Math.max(1, startRow - 1), pCol, buildProjectPreview(proj, maxPreviewW), opts.length + 4);
174
+ });
175
+
176
+ if (!choice || choice === "_exit" || choice === "_sep") return;
177
+ if (choice === "_refresh") return await refreshAndNavigate(cwdProjectUuid);
178
+ await navigateProject(tree, choice, cwdProjectUuid);
179
+ await navigate(tree, cwdProjectUuid);
180
+ }
181
+
182
+ async function refreshAndNavigate(cwdProjectUuid?: string): Promise<void> {
183
+ clearScreen();
184
+ console.log(chalk.gray(" Refreshing...\n"));
185
+ const coolify = getCoolifyService();
186
+ const r = await coolify.getInfrastructureTree();
187
+ if (!isErr(r)) await navigate(r.value, cwdProjectUuid);
188
+ }
189
+
190
+ /** Render screen with no project expanded — all collapsed overview. */
191
+ function renderOverviewScreen(tree: ICoolifyInfrastructureTree, cwdProjectUuid?: string): void {
192
+ const ctx: IScreenContext = { tree, cwdProjectUuid };
193
+ renderScreen(ctx);
194
+ }
195
+
196
+ // ─── Project navigation ──────────────────────────────────────────────────────
197
+
198
+ async function navigateProject(tree: ICoolifyInfrastructureTree, projectUuid: string, cwdProjectUuid?: string): Promise<void> {
199
+ const project = tree.projects.find((pr) => pr.uuid === projectUuid);
200
+ if (!project) return;
201
+
202
+ const ctx: IScreenContext = { tree, cwdProjectUuid, focusedProjectUuid: projectUuid, promptInline: true };
203
+ const layout = renderScreen(ctx);
204
+
205
+ const resources = collectResources(tree, projectUuid);
206
+ const opts: ISelectOption[] = resources.map((res) => {
207
+ const tag = res.kind === "database" ? "[db] " : res.kind === "service" ? "[svc] " : "";
208
+ return { label: `${tag}${res.name}`, value: `${res.uuid}:${res.kind}:${res.name}`, hint: formatStatus(res.status) };
209
+ });
210
+ opts.push({ label: "", value: "_sep" });
211
+ opts.push({ label: "← Back", value: "back" });
212
+
213
+ const choice = await inlineSelect(opts, layout.promptRow, layout.promptCol);
214
+ if (!choice || choice === "back" || choice === "_sep") return;
215
+
216
+ const [uuid, kind, ...np] = choice.split(":");
217
+ const res = resources.find((r) => r.uuid === uuid);
218
+ await navigateResource(tree, uuid, np.join(":"), kind, cwdProjectUuid, res, projectUuid);
219
+ await navigateProject(tree, projectUuid, cwdProjectUuid);
220
+ }
221
+
222
+ // ─── Resource navigation ─────────────────────────────────────────────────────
223
+
224
+ async function navigateResource(
225
+ tree: ICoolifyInfrastructureTree, uuid: string, name: string, kind: string,
226
+ cwdProjectUuid?: string, resource?: ICoolifyResource, parentProjectUuid?: string,
227
+ ): Promise<void> {
228
+ const projUuid = parentProjectUuid || findProjectForResource(tree, uuid) || cwdProjectUuid;
229
+ const projName = tree.projects.find((pr) => pr.uuid === projUuid)?.name;
230
+
231
+ let appDetail: ICoolifyApplication | undefined;
232
+ let logPreview: string[] | undefined;
233
+ if (kind === "app") {
234
+ const coolify = getCoolifyService();
235
+ const [dr, lp] = await Promise.all([coolify.getApplication(uuid), fetchLogPreview(uuid, 6)]);
236
+ if (!isErr(dr)) appDetail = dr.value;
237
+ if (lp.length > 0 && !lp[0].includes("unavailable")) logPreview = lp;
238
+ }
239
+
240
+ const ctx: IScreenContext = {
241
+ tree, cwdProjectUuid, focusedProjectUuid: projUuid, activeResourceUuid: uuid,
242
+ breadcrumb: projName ? [projName, name] : [name], activeAppDetail: appDetail, logPreview,
243
+ promptInline: true, // Action menu renders inline to the right
244
+ };
245
+ const layout = renderScreen(ctx);
246
+ if (!appDetail) renderResourceHeader(name, kind, resource?.status || "unknown", resource?.fqdn);
247
+
248
+ // Sub-menus render inline at the right column position
249
+ const pos = { row: layout.promptRow, col: layout.promptCol };
250
+
251
+ if (kind === "app") await appActions(tree, uuid, name, cwdProjectUuid, resource, parentProjectUuid, appDetail, pos);
252
+ else if (kind === "database") await dbActions(tree, uuid, name, cwdProjectUuid, parentProjectUuid, pos);
253
+ else if (kind === "service") await svcActions(tree, uuid, name, cwdProjectUuid, parentProjectUuid, pos);
254
+ }
255
+
256
+ // ─── App actions ─────────────────────────────────────────────────────────────
257
+
258
+ async function appActions(
259
+ tree: ICoolifyInfrastructureTree, uuid: string, name: string,
260
+ cwdProjectUuid?: string, resource?: ICoolifyResource, parentProjectUuid?: string,
261
+ appDetail?: ICoolifyApplication, pos?: { row: number; col: number },
262
+ ): Promise<void> {
263
+ const menuOpts: ISelectOption[] = [
264
+ { label: "Logs", value: "logs", hint: "Follow, filter, tmux" },
265
+ { label: "Redeploy", value: "deploy" },
266
+ { label: "Deployments", value: "deployments" },
267
+ { label: "Env variables", value: "env", hint: "Set, delete, sync" },
268
+ { label: "Exec command", value: "exec", hint: "Via SSH" },
269
+ { label: "Restart", value: "restart" },
270
+ { label: "Start", value: "start" },
271
+ { label: "Stop", value: "stop" },
272
+ { label: "Build logs", value: "build-logs", hint: "Last deploy" },
273
+ { label: "Diagnose", value: "diagnose", hint: "AI analysis" },
274
+ { label: chalk.red("Delete"), value: "delete" },
275
+ { label: "← Back", value: "back" },
276
+ ];
277
+ const action = pos
278
+ ? await inlineSelect(menuOpts, pos.row, pos.col)
279
+ : await fullSelect(`${name}:`, menuOpts);
280
+
281
+ if (!action || action === "back") return;
282
+
283
+ // Sub-menus that have their own inline selector — stay in layout
284
+ if (action === "logs") { await logsSubMenu(uuid, name, pos); }
285
+ else if (action === "env") { await envSubMenu(uuid, name, pos); }
286
+ else if (action === "exec") { await execSubMenu(uuid, name, pos); }
287
+ else {
288
+ // Actions that produce output: clear screen first, then run, wait, re-render
289
+ clearScreen();
290
+ console.log(chalk.hex("#a875ff")(` ${name} — ${action}\n`));
291
+
292
+ switch (action) {
293
+ case "deploy": await (await import("./deploy.js")).deployCommand(uuid, {}); break;
294
+ case "deployments": await (await import("./deployments.js")).deploymentsCommand(uuid, {}); break;
295
+ case "start": await (await import("./start.js")).startCommand(uuid); break;
296
+ case "stop": await (await import("./stop.js")).stopCommand(uuid); break;
297
+ case "restart": await (await import("./restart.js")).restartCommand(uuid); break;
298
+ case "build-logs": {
299
+ const coolify = getCoolifyService();
300
+ const deploys = await coolify.getApplicationDeploymentHistory(uuid);
301
+ if (isErr(deploys) || deploys.value.length === 0) console.log(chalk.yellow(" No deployments found"));
302
+ else await (await import("./build-logs.js")).buildLogsCommand(deploys.value[deploys.value.length - 1].uuid, {});
303
+ break;
304
+ }
305
+ case "diagnose": await (await import("./diagnose.js")).diagnoseAppCommand(uuid); break;
306
+ case "delete": {
307
+ const yes = await customConfirm(`Delete ${name}? This cannot be undone.`);
308
+ if (yes) { await (await import("./delete.js")).deleteCommand(uuid, { yes: true }); await waitForKey(); return; }
309
+ break;
310
+ }
311
+ }
312
+ await waitForKey();
313
+ }
314
+ await navigateResource(tree, uuid, name, "app", cwdProjectUuid, resource, parentProjectUuid);
315
+ }
316
+
317
+ // ─── Logs sub-menu ───────────────────────────────────────────────────────────
318
+
319
+ async function logsSubMenu(uuid: string, appName: string, pos?: { row: number; col: number }): Promise<void> {
320
+ const opts: ISelectOption[] = [
321
+ { label: "Follow (live)", value: "follow", hint: "Ctrl+C stop" },
322
+ { label: "Last 50 lines", value: "50" },
323
+ { label: "Last 200 lines", value: "200" },
324
+ { label: "Errors only", value: "errors" },
325
+ { label: "Last 1 hour", value: "since-1h" },
326
+ { label: "Open in tmux", value: "tmux", hint: "Background" },
327
+ { label: "← Back", value: "back" },
328
+ ];
329
+ const action = pos
330
+ ? await inlineSelect(opts, pos.row, pos.col)
331
+ : await fullSelect(`${appName} — Logs:`, opts);
332
+ if (!action || action === "back") return;
333
+
334
+ // All log actions produce output — clear screen first
335
+ clearScreen();
336
+ console.log(chalk.hex("#a875ff")(` ${appName} — Logs\n`));
337
+
338
+ const { logsCommand } = await import("./logs.js");
339
+ switch (action) {
340
+ case "follow": await logsCommand(uuid, { follow: true }); break;
341
+ case "50": await logsCommand(uuid, { lines: 50 }); await waitForKey(); break;
342
+ case "200": await logsCommand(uuid, { lines: 200 }); await waitForKey(); break;
343
+ case "errors": await logsCommand(uuid, { lines: 100, errors: true }); await waitForKey(); break;
344
+ case "since-1h": await logsCommand(uuid, { lines: 200, since: "1h" }); await waitForKey(); break;
345
+ case "tmux": {
346
+ const sessionName = `coolify-logs-${appName.replace(/[^a-zA-Z0-9-]/g, "-")}`;
347
+ try {
348
+ const { spawnSync } = await import("child_process");
349
+ const bunPath = process.argv[0] || "bun";
350
+ 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`;
353
+ spawnSync("tmux", ["kill-session", "-t", sessionName], { stdio: "ignore" });
354
+ spawnSync("tmux", ["new-session", "-d", "-s", sessionName, cmd]);
355
+ const check = spawnSync("tmux", ["has-session", "-t", sessionName]);
356
+ if (check.status === 0) {
357
+ console.log(chalk.green(`\n ✓ Session "${sessionName}" created`));
358
+ console.log(chalk.gray(` tmux attach -t ${sessionName}`));
359
+ } else {
360
+ console.log(chalk.yellow(" Session may have exited. Try manually:"));
361
+ console.log(chalk.gray(` tmux new-session -s ${sessionName} "${cmd}"`));
362
+ }
363
+ } catch (e) { console.error(chalk.red(` Failed: ${e}`)); }
364
+ await waitForKey(); break;
365
+ }
366
+ }
367
+ // Return to parent — navigateResource will re-render screen and show appActions again
368
+ }
369
+
370
+ // ─── Env sub-menu ────────────────────────────────────────────────────────────
371
+
372
+ async function envSubMenu(uuid: string, appName: string, pos?: { row: number; col: number }): Promise<void> {
373
+ const opts: ISelectOption[] = [
374
+ { label: "List all", value: "list" },
375
+ { label: "Set variable", value: "set", hint: "KEY=VALUE" },
376
+ { label: "Delete variable", value: "delete" },
377
+ { label: "Sync from .env", value: "sync" },
378
+ { label: "Sync (dry-run)", value: "sync-dry", hint: "Preview" },
379
+ { label: "← Back", value: "back" },
380
+ ];
381
+ const action = pos
382
+ ? await inlineSelect(opts, pos.row, pos.col)
383
+ : await fullSelect(`${appName} — Env:`, opts);
384
+ if (!action || action === "back") return;
385
+
386
+ // Env actions produce output — clear screen
387
+ clearScreen();
388
+ console.log(chalk.hex("#a875ff")(` ${appName} — Env Variables\n`));
389
+
390
+ const { envCommand } = await import("./env.js");
391
+ switch (action) {
392
+ case "list": await envCommand(uuid, {}); await waitForKey(); break;
393
+ case "set": {
394
+ const input = await textInput("KEY=VALUE:");
395
+ if (input) { await envCommand(uuid, { set: input }); await waitForKey(); }
396
+ break;
397
+ }
398
+ case "delete": {
399
+ const key = await textInput("Variable name:");
400
+ if (key) { await envCommand(uuid, { delete: key }); await waitForKey(); }
401
+ break;
402
+ }
403
+ case "sync": await envCommand(uuid, { sync: true }); await waitForKey(); break;
404
+ case "sync-dry": await envCommand(uuid, { sync: true, "dry-run": true }); await waitForKey(); break;
405
+ }
406
+ }
407
+
408
+ // ─── Exec sub-menu ───────────────────────────────────────────────────────────
409
+
410
+ async function execSubMenu(uuid: string, appName: string, pos?: { row: number; col: number }): Promise<void> {
411
+ const containerName = `${appName}-${uuid}`;
412
+ const opts: ISelectOption[] = [
413
+ { label: "Copy docker exec cmd", value: "copy", hint: "Manual SSH" },
414
+ { label: "SSH + exec in tmux", value: "tmux", hint: "Background" },
415
+ { label: "← Back", value: "back" },
416
+ ];
417
+ const action = pos
418
+ ? await inlineSelect(opts, pos.row, pos.col)
419
+ : await fullSelect(`${appName} — Exec:`, opts);
420
+ if (!action || action === "back") return;
421
+
422
+ // Clear screen before output
423
+ clearScreen();
424
+ console.log(chalk.hex("#a875ff")(` ${appName} — Execute\n`));
425
+
426
+ if (action === "copy") {
427
+ console.log(chalk.gray(" Run on your Coolify server:\n"));
428
+ console.log(` ${chalk.cyan(`docker exec -it ${containerName} bash`)}`);
429
+ console.log(chalk.gray(`\n Or: docker exec -it ${containerName} sh`));
430
+ await waitForKey();
431
+ }
432
+ if (action === "tmux") {
433
+ const coolify = getCoolifyService();
434
+ const tr = await coolify.getInfrastructureTree();
435
+ const ip = !isErr(tr) ? tr.value.server.ip : undefined;
436
+ if (!ip) { console.error(chalk.red(" Could not find server IP")); await waitForKey(); return; }
437
+ const session = `coolify-exec-${appName.replace(/[^a-zA-Z0-9-]/g, "-")}`;
438
+ const sshCmd = `ssh root@${ip} -t 'docker exec -it ${containerName} bash || docker exec -it ${containerName} sh'`;
439
+ try {
440
+ const { spawnSync } = await import("child_process");
441
+ spawnSync("tmux", ["kill-session", "-t", session], { stdio: "ignore" });
442
+ spawnSync("tmux", ["new-session", "-d", "-s", session, sshCmd]);
443
+ console.log(chalk.green(`\n ✓ Session "${session}" created`));
444
+ console.log(chalk.gray(` tmux attach -t ${session}`));
445
+ } catch { console.log(chalk.gray(`\n Manual: ${sshCmd}`)); }
446
+ await waitForKey();
447
+ }
448
+ }
449
+
450
+ // ─── DB actions ──────────────────────────────────────────────────────────────
451
+
452
+ async function dbActions(
453
+ tree: ICoolifyInfrastructureTree, uuid: string, name: string,
454
+ cwdProjectUuid?: string, parentProjectUuid?: string, pos?: { row: number; col: number },
455
+ ): Promise<void> {
456
+ const opts: ISelectOption[] = [
457
+ { label: "Start", value: "start" },
458
+ { label: "Stop", value: "stop" },
459
+ { label: "Restart", value: "restart" },
460
+ { label: "Backups", value: "backups" },
461
+ { label: chalk.red("Delete"), value: "delete" },
462
+ { label: "← Back", value: "back" },
463
+ ];
464
+ const action = pos
465
+ ? await inlineSelect(opts, pos.row, pos.col)
466
+ : await fullSelect(`[db] ${name}:`, opts);
467
+ if (!action || action === "back") return;
468
+
469
+ clearScreen();
470
+ console.log(chalk.hex("#a875ff")(` [db] ${name} — ${action}\n`));
471
+
472
+ const { dbStartCommand, dbStopCommand, dbRestartCommand, dbBackupsCommand, dbDeleteCommand } = await import("./db.js");
473
+ switch (action) {
474
+ case "start": await dbStartCommand(uuid); break;
475
+ case "stop": await dbStopCommand(uuid); break;
476
+ case "restart": await dbRestartCommand(uuid); break;
477
+ case "backups": await dbBackupsCommand(uuid); break;
478
+ case "delete": {
479
+ const yes = await customConfirm(`Delete database ${name}?`);
480
+ if (yes) { await dbDeleteCommand(uuid); await waitForKey(); return; }
481
+ break;
482
+ }
483
+ }
484
+ await waitForKey();
485
+ await navigateResource(tree, uuid, name, "database", cwdProjectUuid, undefined, parentProjectUuid);
486
+ }
487
+
488
+ // ─── Service actions ─────────────────────────────────────────────────────────
489
+
490
+ async function svcActions(
491
+ tree: ICoolifyInfrastructureTree, uuid: string, name: string,
492
+ cwdProjectUuid?: string, parentProjectUuid?: string, pos?: { row: number; col: number },
493
+ ): Promise<void> {
494
+ const opts: ISelectOption[] = [
495
+ { label: "Start", value: "start" },
496
+ { label: "Stop", value: "stop" },
497
+ { label: "Restart", value: "restart" },
498
+ { label: "Env variables", value: "env" },
499
+ { label: chalk.red("Delete"), value: "delete" },
500
+ { label: "← Back", value: "back" },
501
+ ];
502
+ const action = pos
503
+ ? await inlineSelect(opts, pos.row, pos.col)
504
+ : await fullSelect(`[svc] ${name}:`, opts);
505
+ if (!action || action === "back") return;
506
+
507
+ clearScreen();
508
+ console.log(chalk.hex("#a875ff")(` [svc] ${name} — ${action}\n`));
509
+
510
+ const { svcStartCommand, svcStopCommand, svcRestartCommand, svcEnvCommand, svcDeleteCommand } = await import("./svc.js");
511
+ switch (action) {
512
+ case "start": await svcStartCommand(uuid); break;
513
+ case "stop": await svcStopCommand(uuid); break;
514
+ case "restart": await svcRestartCommand(uuid); break;
515
+ case "env": await svcEnvCommand(uuid); break;
516
+ case "delete": {
517
+ const yes = await customConfirm(`Delete service ${name}?`);
518
+ if (yes) { await svcDeleteCommand(uuid); await waitForKey(); return; }
519
+ break;
520
+ }
521
+ }
522
+ await waitForKey();
523
+ await navigateResource(tree, uuid, name, "service", cwdProjectUuid, undefined, parentProjectUuid);
524
+ }
525
+
526
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
527
+
528
+ function collectResources(tree: ICoolifyInfrastructureTree, projectUuid?: string): ICoolifyResource[] {
529
+ const resources: ICoolifyResource[] = [];
530
+ const projects = projectUuid ? tree.projects.filter((pr) => pr.uuid === projectUuid) : tree.projects;
531
+ for (const proj of projects) for (const env of proj.environments) for (const res of env.resources) resources.push(res);
532
+ return resources;
533
+ }
534
+
535
+ function findProjectForResource(tree: ICoolifyInfrastructureTree, resourceUuid: string): string | undefined {
536
+ for (const proj of tree.projects) for (const env of proj.environments) if (env.resources.some((r) => r.uuid === resourceUuid)) return proj.uuid;
537
+ return undefined;
538
+ }
@@ -9,6 +9,7 @@ import ora from "ora";
9
9
  import chalk from "chalk";
10
10
  import { getCoolifyService } from "../../coolify/index.js";
11
11
  import { resolveUuid } from "../coolify-state.js";
12
+ import { resolveAppNameOrUuid } from "../name-resolver.js";
12
13
 
13
14
  /**
14
15
  * Stop command handler.
@@ -17,10 +18,13 @@ import { resolveUuid } from "../coolify-state.js";
17
18
  * @param uuid - Application UUID (optional if .coolify.json exists)
18
19
  */
19
20
  export async function stopCommand(uuid?: string) {
20
- const resolvedUuid = resolveUuid(uuid);
21
+ let resolvedUuid = resolveUuid(uuid);
22
+ if (!resolvedUuid && uuid) {
23
+ resolvedUuid = await resolveAppNameOrUuid(uuid);
24
+ }
21
25
  if (!resolvedUuid) {
22
26
  console.error(
23
- chalk.red("Error: No UUID provided and no .coolify.json found"),
27
+ chalk.red("Error: No UUID/name provided and no .coolify.json found"),
24
28
  );
25
29
  return;
26
30
  }
@@ -11,6 +11,7 @@ import chalk from "chalk";
11
11
  import { getCoolifyService } from "../../coolify/index.js";
12
12
  import type { ICoolifyUpdateOptions } from "../../coolify/types.js";
13
13
  import { resolveUuid } from "../coolify-state.js";
14
+ import { resolveAppNameOrUuid } from "../name-resolver.js";
14
15
 
15
16
  /**
16
17
  * Options for the update command.
@@ -30,6 +31,13 @@ interface IUpdateCommandOptions {
30
31
  domains?: string;
31
32
  autoDeploy?: boolean;
32
33
  forceHttps?: boolean;
34
+ healthCheckEnabled?: boolean;
35
+ healthCheckPath?: string;
36
+ healthCheckPort?: string;
37
+ healthCheckInterval?: string;
38
+ healthCheckTimeout?: string;
39
+ healthCheckRetries?: string;
40
+ healthCheckStartPeriod?: string;
33
41
  }
34
42
 
35
43
  /**
@@ -40,10 +48,13 @@ interface IUpdateCommandOptions {
40
48
  export async function updateCommand(
41
49
  options: IUpdateCommandOptions,
42
50
  ): Promise<void> {
43
- const uuid = resolveUuid(options.uuid);
51
+ let uuid = resolveUuid(options.uuid);
52
+ if (!uuid && options.uuid) {
53
+ uuid = await resolveAppNameOrUuid(options.uuid);
54
+ }
44
55
  if (!uuid) {
45
56
  console.error(
46
- chalk.red("Error: No UUID provided and no .coolify.json found"),
57
+ chalk.red("Error: No UUID/name provided and no .coolify.json found"),
47
58
  );
48
59
  return;
49
60
  }
@@ -83,6 +94,20 @@ export async function updateCommand(
83
94
  if (options.autoDeploy !== undefined)
84
95
  updateOptions.isAutoDeployEnabled = options.autoDeploy;
85
96
  if (options.forceHttps) updateOptions.isForceHttpsEnabled = true;
97
+ if (options.healthCheckEnabled !== undefined)
98
+ updateOptions.healthCheckEnabled = options.healthCheckEnabled;
99
+ if (options.healthCheckPath)
100
+ updateOptions.healthCheckPath = options.healthCheckPath;
101
+ if (options.healthCheckPort)
102
+ updateOptions.healthCheckPort = options.healthCheckPort;
103
+ if (options.healthCheckInterval)
104
+ updateOptions.healthCheckInterval = parseInt(options.healthCheckInterval, 10);
105
+ if (options.healthCheckTimeout)
106
+ updateOptions.healthCheckTimeout = parseInt(options.healthCheckTimeout, 10);
107
+ if (options.healthCheckRetries)
108
+ updateOptions.healthCheckRetries = parseInt(options.healthCheckRetries, 10);
109
+ if (options.healthCheckStartPeriod)
110
+ updateOptions.healthCheckStartPeriod = parseInt(options.healthCheckStartPeriod, 10);
86
111
 
87
112
  if (Object.keys(updateOptions).length === 0) {
88
113
  console.warn(