cdd-cli 3.1.7 → 3.2.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 (136) hide show
  1. package/.eslintrc.json +1 -1
  2. package/.github/workflows/ci.yml +3 -0
  3. package/.github/workflows/docs.yml +8 -1
  4. package/CHANGELOG.md +40 -0
  5. package/CONTRIBUTING.md +28 -15
  6. package/README.md +56 -88
  7. package/babel.config.cjs +18 -1
  8. package/coverage/clover.xml +604 -0
  9. package/coverage/coverage-final.json +17 -0
  10. package/coverage/lcov-report/base.css +224 -0
  11. package/coverage/lcov-report/block-navigation.js +87 -0
  12. package/coverage/lcov-report/favicon.png +0 -0
  13. package/coverage/lcov-report/helpers/constants.js.html +235 -0
  14. package/coverage/lcov-report/helpers/containerOptionsBuilder.js.html +211 -0
  15. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerActions.js.html +853 -0
  16. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerList.js.html +289 -0
  17. package/coverage/lcov-report/helpers/dockerService/serviceComponents/index.html +131 -0
  18. package/coverage/lcov-report/helpers/index.html +176 -0
  19. package/coverage/lcov-report/helpers/logger.js.html +424 -0
  20. package/coverage/lcov-report/helpers/safeCall.js.html +139 -0
  21. package/coverage/lcov-report/helpers/validationHelpers.js.html +394 -0
  22. package/coverage/lcov-report/hooks/creation/index.html +146 -0
  23. package/coverage/lcov-report/hooks/creation/useContainerActions.js.html +292 -0
  24. package/coverage/lcov-report/hooks/creation/useContainerCreation.js.html +478 -0
  25. package/coverage/lcov-report/hooks/creation/useLogsViewer.js.html +238 -0
  26. package/coverage/lcov-report/hooks/debug/index.html +116 -0
  27. package/coverage/lcov-report/hooks/debug/useDebugLogs.js.html +172 -0
  28. package/coverage/lcov-report/hooks/index.html +161 -0
  29. package/coverage/lcov-report/hooks/navigation/index.html +116 -0
  30. package/coverage/lcov-report/hooks/navigation/useContainerSelection.js.html +160 -0
  31. package/coverage/lcov-report/hooks/useContainerCommandRouter.js.html +415 -0
  32. package/coverage/lcov-report/hooks/useControls.js.html +685 -0
  33. package/coverage/lcov-report/hooks/useEraseConfirmation.js.html +202 -0
  34. package/coverage/lcov-report/hooks/useExitHandler.js.html +283 -0
  35. package/coverage/lcov-report/index.html +191 -0
  36. package/coverage/lcov-report/prettify.css +1 -0
  37. package/coverage/lcov-report/prettify.js +2 -0
  38. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  39. package/coverage/lcov-report/sorter.js +210 -0
  40. package/coverage/lcov.info +1216 -0
  41. package/dist/components/ContainerRow.js +5 -78
  42. package/dist/components/LogViewer.js +4 -9
  43. package/dist/helpers/containerOptionsBuilder.js +58 -0
  44. package/dist/hooks/creation/useContainerCreation.js +16 -1
  45. package/dist/hooks/debug/useDebugLogs.js +46 -0
  46. package/dist/hooks/navigation/useContainerSelection.js +36 -0
  47. package/dist/hooks/useContainerCommandRouter.js +151 -0
  48. package/dist/{helpers/actionHelpers.js → hooks/useContainerStats.js} +76 -78
  49. package/dist/hooks/useControls.js +83 -342
  50. package/dist/hooks/useEraseConfirmation.js +47 -0
  51. package/dist/hooks/useExitHandler.js +68 -0
  52. package/jest.config.cjs +1 -1
  53. package/package.json +2 -3
  54. package/src/components/ContainerRow.jsx +3 -38
  55. package/src/components/LogViewer.jsx +4 -10
  56. package/src/helpers/constants.js +20 -0
  57. package/src/helpers/containerOptionsBuilder.js +42 -0
  58. package/src/helpers/dockerService/serviceComponents/containerActions.js +55 -8
  59. package/src/helpers/validationHelpers.js +75 -18
  60. package/src/hooks/creation/useContainerCreation.js +34 -4
  61. package/src/hooks/debug/useDebugLogs.js +29 -0
  62. package/src/hooks/navigation/useContainerSelection.js +25 -0
  63. package/src/hooks/useContainerCommandRouter.js +110 -0
  64. package/src/hooks/useContainerStats.js +44 -0
  65. package/src/hooks/useControls.js +75 -274
  66. package/src/hooks/useEraseConfirmation.js +39 -0
  67. package/src/hooks/useExitHandler.js +66 -0
  68. package/test/containerActions.test.js +104 -13
  69. package/test/containerOptionsBuilder.test.js +36 -0
  70. package/test/useContainerCreation.dom.test.js +46 -2
  71. package/test/useControls.dom.test.js +117 -0
  72. package/test/validationHelpers.test.js +51 -0
  73. package/.babelrc +0 -6
  74. package/FIXES_APPLIED.md +0 -419
  75. package/SECURITY.md +0 -5
  76. package/dist/hooks/useLogsStream.js +0 -41
  77. package/docs/_config.yml +0 -13
  78. package/docs/components_ContainerCreationPrompt.jsx.html +0 -114
  79. package/docs/components_ContainerRow.jsx.html +0 -151
  80. package/docs/components_ContainerSection.jsx.html +0 -75
  81. package/docs/components_Footer.jsx.html +0 -66
  82. package/docs/components_Header.jsx.html +0 -84
  83. package/docs/components_LogViewer.jsx.html +0 -91
  84. package/docs/components_MessageFeedback.jsx.html +0 -76
  85. package/docs/components_PromptField.jsx.html +0 -99
  86. package/docs/components_StatsBar.jsx.html +0 -105
  87. package/docs/components_UsageMenu.jsx.html +0 -73
  88. package/docs/fonts/OpenSans-Bold-webfont.eot +0 -0
  89. package/docs/fonts/OpenSans-Bold-webfont.svg +0 -1830
  90. package/docs/fonts/OpenSans-Bold-webfont.woff +0 -0
  91. package/docs/fonts/OpenSans-BoldItalic-webfont.eot +0 -0
  92. package/docs/fonts/OpenSans-BoldItalic-webfont.svg +0 -1830
  93. package/docs/fonts/OpenSans-BoldItalic-webfont.woff +0 -0
  94. package/docs/fonts/OpenSans-Italic-webfont.eot +0 -0
  95. package/docs/fonts/OpenSans-Italic-webfont.svg +0 -1830
  96. package/docs/fonts/OpenSans-Italic-webfont.woff +0 -0
  97. package/docs/fonts/OpenSans-Light-webfont.eot +0 -0
  98. package/docs/fonts/OpenSans-Light-webfont.svg +0 -1831
  99. package/docs/fonts/OpenSans-Light-webfont.woff +0 -0
  100. package/docs/fonts/OpenSans-LightItalic-webfont.eot +0 -0
  101. package/docs/fonts/OpenSans-LightItalic-webfont.svg +0 -1835
  102. package/docs/fonts/OpenSans-LightItalic-webfont.woff +0 -0
  103. package/docs/fonts/OpenSans-Regular-webfont.eot +0 -0
  104. package/docs/fonts/OpenSans-Regular-webfont.svg +0 -1831
  105. package/docs/fonts/OpenSans-Regular-webfont.woff +0 -0
  106. package/docs/global.html +0 -6145
  107. package/docs/helpers_actionHelpers.js.html +0 -94
  108. package/docs/helpers_constants.js.html +0 -81
  109. package/docs/helpers_dockerService_dockerService.js.html +0 -79
  110. package/docs/helpers_dockerService_serviceComponents_containerActions.js.html +0 -153
  111. package/docs/helpers_dockerService_serviceComponents_containerList.js.html +0 -81
  112. package/docs/helpers_dockerService_serviceComponents_containerLogs.js.html +0 -85
  113. package/docs/helpers_dockerService_serviceComponents_containerStats.js.html +0 -107
  114. package/docs/helpers_dockerService_serviceComponents_imageUtils.js.html +0 -82
  115. package/docs/helpers_exitWithMessage.js.html +0 -77
  116. package/docs/helpers_safeCall.js.html +0 -68
  117. package/docs/helpers_validationHelpers.js.html +0 -96
  118. package/docs/hooks_creation_useContainerActions.js.html +0 -120
  119. package/docs/hooks_creation_useContainerCreation.js.html +0 -152
  120. package/docs/hooks_creation_useLogsViewer.js.html +0 -102
  121. package/docs/hooks_useControls.js.html +0 -298
  122. package/docs/hooks_useLogsStream.js.html +0 -82
  123. package/docs/index.html +0 -65
  124. package/docs/index.js.html +0 -65
  125. package/docs/module-index.html +0 -171
  126. package/docs/scripts/linenumber.js +0 -25
  127. package/docs/scripts/prettify/Apache-License-2.0.txt +0 -202
  128. package/docs/scripts/prettify/lang-css.js +0 -2
  129. package/docs/scripts/prettify/prettify.js +0 -28
  130. package/docs/styles/jsdoc-default.css +0 -358
  131. package/docs/styles/prettify-jsdoc.css +0 -111
  132. package/docs/styles/prettify-tomorrow.css +0 -132
  133. package/esbuild.config.cjs +0 -11
  134. package/src/helpers/actionHelpers.js +0 -52
  135. package/src/hooks/useLogsStream.js +0 -31
  136. package/test/actionHelpers.test.js +0 -106
@@ -1,12 +1,17 @@
1
1
  import React from "react";
2
+ import { useInput } from "ink";
2
3
  import { useContainerActions } from "./creation/useContainerActions";
3
4
  import { useContainerCreation } from "./creation/useContainerCreation";
4
5
  import { useLogsViewer } from "./creation/useLogsViewer";
5
- import { useInput, useApp } from "ink";
6
+ import { useContainerSelection } from "./navigation/useContainerSelection";
7
+ import { useDebugLogs } from "./debug/useDebugLogs";
8
+ import { useEraseConfirmation } from "./useEraseConfirmation";
9
+ import { useExitHandler } from "./useExitHandler";
10
+ import { useContainerCommandRouter } from "./useContainerCommandRouter";
6
11
  import { getLogsStream } from "../helpers/dockerService/serviceComponents/containerLogs.js";
7
- import { EXIT_DELAY } from "../helpers/constants.js";
8
12
  import { createContainer as svcCreateContainer } from "../helpers/dockerService/serviceComponents/containerActions.js";
9
- import { logger } from "../helpers/logger.js";
13
+ import { buildContainerOptions } from "../helpers/containerOptionsBuilder.js";
14
+ import { DB_IMAGES } from "../helpers/constants.js";
10
15
 
11
16
  // Principal hook to manage user inputs and control the app state
12
17
  /**
@@ -15,66 +20,25 @@ import { logger } from "../helpers/logger.js";
15
20
  *
16
21
  * @param {Array<Object>} containers - Current list of Docker containers
17
22
  * @returns {Object} controls - API for the App component
18
- * @property {number} selected - Index of the currently selected container
19
- * @property {function} setSelected - Setter for selected index
20
- * @property {string} message - Current feedback message (creation or actions)
21
- * @property {string} messageColor - Color to show for the feedback message
22
- * @property {boolean} showLogs - Whether the logs viewer is active
23
- * @property {Array<string>} logs - Array of log lines currently collected
24
- * @property {function} exitLogs - Helper to close the logs viewer
25
- * @property {boolean} creatingContainer - Whether the create-container prompt is open
26
- * @property {number} creationStep - Current step in the creation flow
27
- * @property {string} imageNameInput - Current value of the image name field
28
- * @property {string} containerNameInput - Current value of the container name field
29
- * @property {string} portInput - Current value of the port input field
30
- * @property {string} envInput - Current value of the env input field
31
- * @property {Object} creation - The creation hook API (setters and helpers)
32
- * @property {Object} actions - The actions hook API (helpers to start/stop/remove)
33
- * @property {Object} logsViewer - The logs viewer hook API
34
- * @property {boolean} showDebugLogs - Whether the debug log panel is visible
35
- * @property {Array<string>} debugLogs - Buffered log lines captured from the shared logger
36
- * @property {boolean} confirmErase - Whether the erase confirmation dialog is active
37
23
  */
38
24
  export function useControls(containers = []) {
39
- const [selected, setSelected] = React.useState(0);
40
25
  const [creatingContainer, setCreatingContainer] = React.useState(false);
41
- const [confirmErase, setConfirmErase] = React.useState(false);
42
- const [showDebugLogs, setShowDebugLogs] = React.useState(false);
43
- const [debugLogs, setDebugLogs] = React.useState([]);
44
- const total = containers.length;
45
- const { exit } = useApp();
46
26
 
47
- // Modular hooks
27
+ // Modular hooks
48
28
  const actions = useContainerActions({ containers });
29
+
49
30
  const creation = useContainerCreation({
50
31
  onCreate: async ({ imageName, containerName, portInput, envInput }) => {
51
- // Build Docker options
52
- const env = (envInput || "").split(",").map(s => s.trim()).filter(Boolean);
53
- const ports = (portInput || "").split(",").map(s => s.trim()).filter(Boolean);
54
- const ExposedPorts = {};
55
- const PortBindings = {};
56
- ports.forEach(pair => {
57
- const [host, cont] = pair.split(":");
58
- if (!host || !cont) return;
59
- const key = `${cont}/tcp`;
60
- ExposedPorts[key] = {};
61
- PortBindings[key] = PortBindings[key] || [];
62
- PortBindings[key].push({ HostPort: `${host}` });
63
- });
64
-
65
- const options = {
66
- Tty: true,
67
- };
68
- if (Object.keys(ExposedPorts).length) options.ExposedPorts = ExposedPorts;
69
- if (Object.keys(PortBindings).length) options.HostConfig = { PortBindings };
70
- if (env.length) options.Env = env;
71
- if (containerName) options.name = containerName;
72
-
73
- actions.setMessage(`Creating container ${imageName}...`);
74
- actions.setMessageColor("yellow");
32
+ const options = buildContainerOptions({ imageName, containerName, portInput, envInput });
33
+ actions.setMessage(`Creating container ${imageName}...`);
34
+ actions.setMessageColor("yellow");
75
35
  try {
76
- const id = await svcCreateContainer(imageName, options);
77
- actions.setMessage(`Created container ${id}`);
36
+ const { id, ports } = await svcCreateContainer(imageName, options);
37
+ let portMsg = "";
38
+ if (ports && ports.length) {
39
+ portMsg = " | Ports: " + ports.map(p => `${p.hostPort}→${p.containerPort}/${p.protocol}`).join(", ");
40
+ }
41
+ actions.setMessage(`Created container ${id}${portMsg}`);
78
42
  actions.setMessageColor("green");
79
43
  } catch (err) {
80
44
  actions.setMessage(`Error creating container: ${err.message}`);
@@ -84,12 +48,27 @@ export function useControls(containers = []) {
84
48
  }
85
49
  },
86
50
  onCancel: () => setCreatingContainer(false),
87
- dbImages: ["mysql", "mariadb", "postgres", "mongo", "mssql", "redis"]
51
+ dbImages: DB_IMAGES,
88
52
  });
53
+
89
54
  const logsViewer = useLogsViewer();
55
+ const selection = useContainerSelection(containers.length);
56
+ const debugLogs = useDebugLogs();
90
57
 
91
- // Handler to exit logs (delegated to logsViewer)
92
- const exitLogs = logsViewer.closeLogs;
58
+ const eraseConfirmation = useEraseConfirmation({
59
+ onConfirm: () => {
60
+ actions.handleAction({
61
+ actionFn: async (id) => await actions.removeContainer(id),
62
+ actionLabel: "Erasing",
63
+ selected: selection.selected,
64
+ });
65
+ actions.setMessageColor("yellow");
66
+ },
67
+ onCancel: () => {
68
+ actions.setMessage("");
69
+ actions.setMessageColor("");
70
+ },
71
+ });
93
72
 
94
73
  /**
95
74
  * Pipe container logs into the viewer while applying a hard limit.
@@ -107,40 +86,30 @@ export function useControls(containers = []) {
107
86
  );
108
87
  }, [logsViewer]);
109
88
 
110
- /**
111
- * Manage the erase confirmation short-circuit flow.
112
- */
113
- const processEraseConfirmation = React.useCallback((input, key) => {
114
- if (input === "y" || input === "Y") {
115
- actions.handleAction({
116
- actionFn: async (id) => await actions.removeContainer(id),
117
- actionLabel: "Erasing",
118
- selected,
119
- });
120
- setConfirmErase(false);
89
+ const commandRouter = useContainerCommandRouter({
90
+ actions,
91
+ containers,
92
+ selected: selection.selected,
93
+ creation,
94
+ logsViewer,
95
+ startLogsStream,
96
+ onStartErase: () => {
97
+ eraseConfirmation.startErase();
98
+ actions.setMessage("Are you sure you want to erase this container? (y/n)");
121
99
  actions.setMessageColor("yellow");
122
- return;
123
- }
124
-
125
- if (input === "n" || input === "N" || key.escape) {
126
- setConfirmErase(false);
127
- actions.setMessage("");
128
- actions.setMessageColor("");
129
- return;
130
- }
131
-
132
- actions.setMessage("Are you sure you want to erase this container? (y/n)");
133
- actions.setMessageColor("yellow");
134
- }, [actions, selected]);
100
+ },
101
+ onToggleDebug: () => debugLogs.setShowDebugLogs((prev) => !prev),
102
+ onStartCreate: () => setCreatingContainer(true),
103
+ });
135
104
 
136
- /**
137
- * Handle inputs while the logs viewer is active.
138
- */
139
- const processLogsInput = React.useCallback((input, key) => {
140
- if (input === "q" || key.escape) {
105
+ const exitHandler = useExitHandler({
106
+ onBeforeExit: () => {
107
+ actions.setMessage("Exiting...");
108
+ actions.setMessageColor("yellow");
141
109
  logsViewer.closeLogs();
142
- }
143
- }, [logsViewer]);
110
+ debugLogs.setShowDebugLogs(false);
111
+ },
112
+ });
144
113
 
145
114
  /**
146
115
  * Route keystrokes to the container creation wizard.
@@ -178,164 +147,17 @@ export function useControls(containers = []) {
178
147
  }
179
148
  }, [creation, setCreatingContainer]);
180
149
 
181
- /**
182
- * Navigate the container list using arrow keys.
183
- * @returns {boolean} True when the input was consumed.
184
- */
185
- const handleNavigation = React.useCallback((input, key) => {
186
- if (total === 0) {
187
- return false;
188
- }
189
-
190
- if (key.upArrow) {
191
- setSelected((i) => (i === 0 ? total - 1 : i - 1));
192
- return true;
193
- }
194
-
195
- if (key.downArrow) {
196
- setSelected((i) => (i === total - 1 ? 0 : i + 1));
197
- return true;
198
- }
199
-
200
- return false;
201
- }, [total]);
202
-
203
- /**
204
- * Handle the quit command.
205
- * @returns {boolean} True when the input was consumed.
206
- */
207
- const handleExitCommand = React.useCallback((input) => {
208
- if (input !== "q") {
209
- return false;
210
- }
211
-
212
- actions.setMessage("Exiting...");
213
- actions.setMessageColor("yellow");
214
- logsViewer.closeLogs();
215
- setShowDebugLogs(false);
216
-
217
- const clearTerminal = () => {
218
- try {
219
- if (process.stdout && process.stdout.isTTY) {
220
- process.stdout.write("\u001Bc");
221
- } else {
222
- console.clear();
223
- }
224
- } catch (err) {
225
- // Ignore clear errors to avoid blocking exit.
226
- }
227
- };
228
-
229
- setTimeout(() => {
230
- clearTerminal();
231
- exit();
232
- if (process.env.NODE_ENV !== "test") {
233
- setTimeout(() => process.exit(0), 50);
234
- }
235
- }, EXIT_DELAY);
236
- return true;
237
- }, [actions, exit, logsViewer]);
238
-
239
- /**
240
- * Execute Docker-related commands and prompt transitions.
241
- * @returns {boolean} True when the input was consumed.
242
- */
243
- const handleDockerCommands = React.useCallback((input) => {
244
- const container = containers[selected];
245
-
246
- if (input === "i") {
247
- actions.handleAction({
248
- actionFn: async (id) => await actions.startContainer(id),
249
- actionLabel: "Starting",
250
- selected,
251
- stateCheck: (c) => (c.state === "running" || c.status === "running") && "Container is already running."
252
- });
253
- return true;
254
- }
255
-
256
- if (input === "p") {
257
- actions.handleAction({
258
- actionFn: async (id) => await actions.stopContainer(id),
259
- actionLabel: "Stopping",
260
- selected,
261
- stateCheck: (c) => (c.state === "exited" || c.status === "exited" || c.state === "stopped" || c.status === "stopped") && "Container is already stopped."
262
- });
263
- return true;
264
- }
265
-
266
- if (input === "r") {
267
- actions.handleAction({
268
- actionFn: async (id) => await actions.restartContainer(id),
269
- actionLabel: "Restarting",
270
- selected,
271
- });
272
- return true;
273
- }
274
-
275
- if (input === "e") {
276
- if (!container) {
277
- return true;
278
- }
279
-
280
- setConfirmErase(true);
281
- actions.setMessage("Are you sure you want to erase this container? (y/n)");
282
- actions.setMessageColor("yellow");
283
- return true;
284
- }
285
-
286
- if (input === "l") {
287
- if (!container) {
288
- return true;
289
- }
290
-
291
- logsViewer.openLogs();
292
- startLogsStream(container.id);
293
- return true;
294
- }
295
-
296
- if (input === "c") {
297
- setCreatingContainer(true);
298
- creation.setStep(0);
299
- creation.setImageName("");
300
- creation.setContainerName("");
301
- creation.setPortInput("");
302
- creation.setEnvInput("");
303
- creation.setMessage("Insert the name of the image to create: ");
304
- creation.setMessageColor("yellow");
305
- return true;
306
- }
307
-
308
- if (input === "d") {
309
- setShowDebugLogs((prev) => !prev);
310
- return true;
311
- }
312
-
313
- return false;
314
- }, [actions, containers, creation, logsViewer, selected, setConfirmErase, setCreatingContainer, startLogsStream, setShowDebugLogs]);
315
-
316
- /**
317
- * Default handler executed when no special mode is active.
318
- */
319
- const processGlobalInput = React.useCallback((input, key) => {
320
- if (handleNavigation(input, key)) {
321
- return;
322
- }
323
-
324
- if (handleExitCommand(input)) {
325
- return;
326
- }
327
-
328
- handleDockerCommands(input);
329
- }, [handleDockerCommands, handleExitCommand, handleNavigation]);
330
-
150
+ // Single keyboard entry point
331
151
  useInput((input, key) => {
332
- if (confirmErase) {
333
- processEraseConfirmation(input, key);
152
+ if (eraseConfirmation.confirmErase) {
153
+ eraseConfirmation.processEraseConfirmation(input, key);
334
154
  return;
335
155
  }
336
156
 
337
157
  if (logsViewer.showLogs) {
338
- processLogsInput(input, key);
158
+ if (input === "q" || key.escape) {
159
+ logsViewer.closeLogs();
160
+ }
339
161
  return;
340
162
  }
341
163
 
@@ -344,46 +166,25 @@ export function useControls(containers = []) {
344
166
  return;
345
167
  }
346
168
 
347
- if (showDebugLogs && key.escape) {
348
- setShowDebugLogs(false);
169
+ if (debugLogs.showDebugLogs && key.escape) {
170
+ debugLogs.setShowDebugLogs(false);
349
171
  return;
350
172
  }
351
173
 
352
- processGlobalInput(input, key);
174
+ commandRouter.handleDockerCommands(input);
175
+ exitHandler.handleExitCommand(input);
176
+ selection.handleNavigation(input, key);
353
177
  });
354
178
 
355
- // Mirror global logger entries so the D shortcut can surface them without leaving the CLI.
356
- React.useEffect(() => {
357
- const unsubscribe = logger.subscribe((entry) => {
358
- setDebugLogs((prev) => {
359
- const extras = (entry.args || []).map((arg) => {
360
- if (typeof arg === "string") return arg;
361
- if (typeof arg === "number" || typeof arg === "boolean") return String(arg);
362
- try {
363
- return JSON.stringify(arg);
364
- } catch (err) {
365
- return "[unserializable]";
366
- }
367
- }).filter(Boolean);
368
- const line = extras.length ? `${entry.formatted} ${extras.join(" ")}` : entry.formatted;
369
- const next = [...prev, line];
370
- return next.slice(-200);
371
- });
372
- });
373
- return unsubscribe;
374
- }, []);
375
-
376
179
  return {
377
- selected,
378
- setSelected,
379
- // Map messaging to creation when creating, otherwise to actions
180
+ selected: selection.selected,
181
+ setSelected: selection.setSelected,
380
182
  message: creatingContainer ? creation.message : actions.message,
381
183
  messageColor: creatingContainer ? creation.messageColor : actions.messageColor,
382
184
  showLogs: logsViewer.showLogs,
383
185
  logs: logsViewer.logs,
384
- exitLogs,
186
+ exitLogs: logsViewer.closeLogs,
385
187
  creatingContainer,
386
- // Expose creation fields in the shape App expects
387
188
  creationStep: creation.step,
388
189
  imageNameInput: creation.imageName,
389
190
  containerNameInput: creation.containerName,
@@ -392,8 +193,8 @@ export function useControls(containers = []) {
392
193
  creation,
393
194
  actions,
394
195
  logsViewer,
395
- confirmErase,
396
- showDebugLogs,
397
- debugLogs,
196
+ confirmErase: eraseConfirmation.confirmErase,
197
+ showDebugLogs: debugLogs.showDebugLogs,
198
+ debugLogs: debugLogs.debugLogs,
398
199
  };
399
200
  }
@@ -0,0 +1,39 @@
1
+ import { useState, useCallback } from "react";
2
+
3
+ /**
4
+ * Hook to manage the erase confirmation dialog flow.
5
+ *
6
+ * Encapsulates the `confirmErase` state and the input handler that resolves it.
7
+ * The caller is responsible for what happens on confirm/cancel via the callbacks.
8
+ *
9
+ * @param {Object} params
10
+ * @param {Function} params.onConfirm - Called when the user presses y/Y. Should trigger the erase action.
11
+ * @param {Function} params.onCancel - Called when the user presses n/N or Escape. Should clean up messages.
12
+ * @returns {{ confirmErase: boolean, startErase: Function, processEraseConfirmation: Function }}
13
+ */
14
+ export function useEraseConfirmation({ onConfirm, onCancel }) {
15
+ const [confirmErase, setConfirmErase] = useState(false);
16
+
17
+ const startErase = useCallback(() => {
18
+ setConfirmErase(true);
19
+ }, []);
20
+
21
+ const processEraseConfirmation = useCallback(
22
+ (input, key) => {
23
+ if (input === "y" || input === "Y") {
24
+ onConfirm();
25
+ setConfirmErase(false);
26
+ return;
27
+ }
28
+
29
+ if (input === "n" || input === "N" || key.escape) {
30
+ setConfirmErase(false);
31
+ onCancel();
32
+ return;
33
+ }
34
+ },
35
+ [onConfirm, onCancel]
36
+ );
37
+
38
+ return { confirmErase, startErase, processEraseConfirmation };
39
+ }
@@ -0,0 +1,66 @@
1
+ import { useCallback } from 'react';
2
+ import { useApp } from 'ink';
3
+ import { EXIT_DELAY } from '../helpers/constants.js';
4
+
5
+ /**
6
+ * Hook that encapsulates the application exit sequence triggered by pressing `q`.
7
+ *
8
+ * Responsibilities:
9
+ * - Detect the `q` keypress
10
+ * - Invoke an optional `onBeforeExit` callback for resource cleanup (e.g. closing logs)
11
+ * - Clear the terminal after a short delay
12
+ * - Call Ink's `exit()` and, outside of test environments, `process.exit(0)`
13
+ *
14
+ * @param {Object} [options]
15
+ * @param {Function} [options.onBeforeExit] - Optional callback invoked synchronously
16
+ * before the exit delay starts. Use it to close logs, reset UI state, etc.
17
+ * @returns {{ handleExitCommand: Function }}
18
+ */
19
+ export function useExitHandler({ onBeforeExit } = {}) {
20
+ const { exit } = useApp();
21
+
22
+ /**
23
+ * Handle the quit command.
24
+ *
25
+ * Mirrors the exact logic of `handleExitCommand` in `useControls.js`:
26
+ * 1. Guard: return false if input is not 'q'
27
+ * 2. Call onBeforeExit (replaces inline logsViewer.closeLogs + setShowDebugLogs(false))
28
+ * 3. After EXIT_DELAY: clear terminal, call exit(), then process.exit(0) outside tests
29
+ *
30
+ * @param {string} input - Raw character input from Ink's useInput
31
+ * @returns {boolean} True when the input was consumed, false otherwise
32
+ */
33
+ const handleExitCommand = useCallback((input) => {
34
+ if (input !== 'q') {
35
+ return false;
36
+ }
37
+
38
+ if (typeof onBeforeExit === 'function') {
39
+ onBeforeExit();
40
+ }
41
+
42
+ const clearTerminal = () => {
43
+ try {
44
+ if (process.stdout && process.stdout.isTTY) {
45
+ process.stdout.write('\u001Bc');
46
+ } else {
47
+ console.clear();
48
+ }
49
+ } catch (err) {
50
+ // Ignore clear errors to avoid blocking exit.
51
+ }
52
+ };
53
+
54
+ setTimeout(() => {
55
+ clearTerminal();
56
+ exit();
57
+ if (process.env.NODE_ENV !== 'test') {
58
+ setTimeout(() => process.exit(0), 50);
59
+ }
60
+ }, EXIT_DELAY);
61
+
62
+ return true;
63
+ }, [exit, onBeforeExit]);
64
+
65
+ return { handleExitCommand };
66
+ }
@@ -3,14 +3,13 @@ import { jest } from '@jest/globals';
3
3
  describe('containerActions service functions (mocked ESM imports)', () => {
4
4
  afterEach(() => jest.resetModules());
5
5
 
6
- test('createContainer returns id when image exists', async () => {
7
- // mock imageUtils and dockerService before importing the module under test
6
+ test('createContainer returns { id, ports } when image exists', async () => {
8
7
  const imageUtilsMock = {
9
8
  imageExists: jest.fn().mockResolvedValue(true),
10
9
  pullImage: jest.fn()
11
10
  };
12
11
 
13
- const inspectMock = jest.fn().mockResolvedValue({ Config: { ExposedPorts: {} } });
12
+ const inspectMock = jest.fn().mockResolvedValue({ Config: { ExposedPorts: { '3306/tcp': {} } } });
14
13
  const dockerMock = {
15
14
  createContainer: jest.fn().mockResolvedValue({ id: 'cid-123' }),
16
15
  getImage: jest.fn().mockReturnValue({ inspect: inspectMock }),
@@ -27,16 +26,107 @@ describe('containerActions service functions (mocked ESM imports)', () => {
27
26
 
28
27
  const mod = await import('../src/helpers/dockerService/serviceComponents/containerActions.js');
29
28
 
30
- const id = await mod.createContainer('nginx:latest', { Name: 'test' });
31
- expect(id).toBe('cid-123');
32
- const imageUtils = await import('../src/helpers/dockerService/serviceComponents/imageUtils.js');
33
- expect(imageUtils.imageExists).toHaveBeenCalledWith('nginx:latest');
34
- const ds = await import('../src/helpers/dockerService/dockerService.js');
35
- expect(ds.docker.createContainer).toHaveBeenCalled();
36
- expect(ds.docker.getImage).toHaveBeenCalledWith('nginx:latest');
29
+ const result = await mod.createContainer('mysql:8', { Name: 'test' });
30
+ expect(result).toHaveProperty('id', 'cid-123');
31
+ expect(result).toHaveProperty('ports');
32
+ expect(Array.isArray(result.ports)).toBe(true);
33
+ });
34
+
35
+ test('createContainer ports contain containerPort, hostPort, protocol, source fields', async () => {
36
+ const imageUtilsMock = {
37
+ imageExists: jest.fn().mockResolvedValue(true),
38
+ pullImage: jest.fn()
39
+ };
40
+
41
+ const inspectMock = jest.fn().mockResolvedValue({ Config: { ExposedPorts: { '80/tcp': {} } } });
42
+ const dockerMock = {
43
+ createContainer: jest.fn().mockResolvedValue({ id: 'cid-ports' }),
44
+ getImage: jest.fn().mockReturnValue({ inspect: inspectMock }),
45
+ listContainers: jest.fn().mockResolvedValue([])
46
+ };
47
+
48
+ await jest.unstable_mockModule('../src/helpers/dockerService/serviceComponents/imageUtils.js', () => ({
49
+ ...imageUtilsMock
50
+ }));
51
+
52
+ await jest.unstable_mockModule('../src/helpers/dockerService/dockerService.js', () => ({
53
+ docker: dockerMock
54
+ }));
55
+
56
+ const mod = await import('../src/helpers/dockerService/serviceComponents/containerActions.js');
57
+ const result = await mod.createContainer('nginx:latest', {});
58
+ expect(result.ports.length).toBeGreaterThan(0);
59
+ const port = result.ports[0];
60
+ expect(port).toHaveProperty('containerPort');
61
+ expect(port).toHaveProperty('hostPort');
62
+ expect(port).toHaveProperty('protocol');
63
+ expect(port).toHaveProperty('source');
64
+ });
65
+
66
+ test('createContainer uses IMAGE_PROFILES defaultPort as fallback when ExposedPorts empty', async () => {
67
+ const imageUtilsMock = {
68
+ imageExists: jest.fn().mockResolvedValue(true),
69
+ pullImage: jest.fn()
70
+ };
71
+
72
+ // inspect returns empty ExposedPorts
73
+ const inspectMock = jest.fn().mockResolvedValue({ Config: { ExposedPorts: {} } });
74
+ const dockerMock = {
75
+ createContainer: jest.fn().mockResolvedValue({ id: 'cid-fallback' }),
76
+ getImage: jest.fn().mockReturnValue({ inspect: inspectMock }),
77
+ listContainers: jest.fn().mockResolvedValue([])
78
+ };
79
+
80
+ await jest.unstable_mockModule('../src/helpers/dockerService/serviceComponents/imageUtils.js', () => ({
81
+ ...imageUtilsMock
82
+ }));
83
+
84
+ await jest.unstable_mockModule('../src/helpers/dockerService/dockerService.js', () => ({
85
+ docker: dockerMock
86
+ }));
87
+
88
+ const { IMAGE_PROFILES } = await import('../src/helpers/constants.js');
89
+ const mod = await import('../src/helpers/dockerService/serviceComponents/containerActions.js');
90
+ const result = await mod.createContainer('mysql:8', {}, IMAGE_PROFILES);
91
+
92
+ expect(result.id).toBe('cid-fallback');
93
+ // Should have auto port from defaultPort fallback
94
+ const mysqlDefault = IMAGE_PROFILES.mysql.defaultPort;
95
+ const portEntry = result.ports.find(p => p.containerPort === mysqlDefault);
96
+ expect(portEntry).toBeDefined();
97
+ expect(portEntry.source).toBe('auto');
98
+ });
99
+
100
+ test('createContainer returns { id, ports: [] } when no ports and image not in profiles', async () => {
101
+ const imageUtilsMock = {
102
+ imageExists: jest.fn().mockResolvedValue(true),
103
+ pullImage: jest.fn()
104
+ };
105
+
106
+ const inspectMock = jest.fn().mockResolvedValue({ Config: { ExposedPorts: {} } });
107
+ const dockerMock = {
108
+ createContainer: jest.fn().mockResolvedValue({ id: 'cid-noport' }),
109
+ getImage: jest.fn().mockReturnValue({ inspect: inspectMock }),
110
+ listContainers: jest.fn().mockResolvedValue([])
111
+ };
112
+
113
+ await jest.unstable_mockModule('../src/helpers/dockerService/serviceComponents/imageUtils.js', () => ({
114
+ ...imageUtilsMock
115
+ }));
116
+
117
+ await jest.unstable_mockModule('../src/helpers/dockerService/dockerService.js', () => ({
118
+ docker: dockerMock
119
+ }));
120
+
121
+ const { IMAGE_PROFILES } = await import('../src/helpers/constants.js');
122
+ const mod = await import('../src/helpers/dockerService/serviceComponents/containerActions.js');
123
+ const result = await mod.createContainer('unknownimage:latest', {}, IMAGE_PROFILES);
124
+
125
+ expect(result.id).toBe('cid-noport');
126
+ expect(result.ports).toEqual([]);
37
127
  });
38
128
 
39
- test('createContainer pulls image when not present and returns Id', async () => {
129
+ test('createContainer pulls image when not present and returns { id, ports }', async () => {
40
130
  const imageUtilsMock = {
41
131
  imageExists: jest.fn().mockResolvedValue(false),
42
132
  pullImage: jest.fn().mockResolvedValue(true)
@@ -58,8 +148,9 @@ describe('containerActions service functions (mocked ESM imports)', () => {
58
148
  }));
59
149
 
60
150
  const mod = await import('../src/helpers/dockerService/serviceComponents/containerActions.js');
61
- const id = await mod.createContainer('busybox:1.0');
62
- expect(id).toBe('CID-456');
151
+ const result = await mod.createContainer('busybox:1.0');
152
+ expect(result.id).toBe('CID-456');
153
+ expect(result).toHaveProperty('ports');
63
154
  const imageUtils = await import('../src/helpers/dockerService/serviceComponents/imageUtils.js');
64
155
  expect(imageUtils.pullImage).toHaveBeenCalledWith('busybox:1.0');
65
156
  });