cdd-cli 3.1.4 → 3.1.6

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 (140) hide show
  1. package/.eslintrc.json +30 -0
  2. package/.github/workflows/docs.yml +35 -0
  3. package/CHANGELOG.md +37 -4
  4. package/README.md +26 -0
  5. package/_config.yml +13 -0
  6. package/dist/App.js +20 -3
  7. package/dist/components/ContainerCreationPrompt.js +27 -1
  8. package/dist/components/ContainerList.js +11 -15
  9. package/dist/components/ContainerRow.js +73 -14
  10. package/dist/components/ContainerSection.js +16 -2
  11. package/dist/components/Footer.js +7 -1
  12. package/dist/components/Header.js +27 -3
  13. package/dist/components/LogViewer.js +21 -3
  14. package/dist/components/MessageFeedback.js +15 -1
  15. package/dist/components/PromptField.js +31 -1
  16. package/dist/components/StatsBar.js +47 -8
  17. package/dist/components/UsageMenu.js +30 -1
  18. package/dist/helpers/actionHelpers.js +17 -6
  19. package/dist/helpers/appInfo.js +21 -0
  20. package/dist/helpers/constants.js +30 -0
  21. package/dist/helpers/dockerService/dockerService.js +19 -0
  22. package/dist/helpers/dockerService/serviceComponents/containerActions.js +194 -35
  23. package/dist/helpers/dockerService/serviceComponents/containerList.js +45 -8
  24. package/dist/helpers/dockerService/serviceComponents/containerLogs.js +16 -6
  25. package/dist/helpers/dockerService/serviceComponents/containerStats.js +28 -9
  26. package/dist/helpers/exitWithMessage.js +2 -1
  27. package/dist/helpers/logger.js +126 -0
  28. package/dist/helpers/safeCall.js +21 -0
  29. package/dist/helpers/validationHelpers.js +20 -1
  30. package/dist/hooks/creation/useContainerActions.js +2 -1
  31. package/dist/hooks/creation/useContainerCreation.js +5 -4
  32. package/dist/hooks/creation/useLogsViewer.js +3 -2
  33. package/dist/hooks/useContainers.js +6 -3
  34. package/dist/hooks/useControls.js +253 -122
  35. package/dist/hooks/useLogsStream.js +3 -2
  36. package/dist/index.js +1 -0
  37. package/docs/_config.yml +13 -0
  38. package/docs/components_ContainerCreationPrompt.jsx.html +114 -0
  39. package/docs/components_ContainerRow.jsx.html +151 -0
  40. package/docs/components_ContainerSection.jsx.html +75 -0
  41. package/docs/components_Footer.jsx.html +66 -0
  42. package/docs/components_Header.jsx.html +84 -0
  43. package/docs/components_LogViewer.jsx.html +91 -0
  44. package/docs/components_MessageFeedback.jsx.html +76 -0
  45. package/docs/components_PromptField.jsx.html +99 -0
  46. package/docs/components_StatsBar.jsx.html +105 -0
  47. package/docs/components_UsageMenu.jsx.html +73 -0
  48. package/docs/fonts/OpenSans-Bold-webfont.eot +0 -0
  49. package/docs/fonts/OpenSans-Bold-webfont.svg +1830 -0
  50. package/docs/fonts/OpenSans-Bold-webfont.woff +0 -0
  51. package/docs/fonts/OpenSans-BoldItalic-webfont.eot +0 -0
  52. package/docs/fonts/OpenSans-BoldItalic-webfont.svg +1830 -0
  53. package/docs/fonts/OpenSans-BoldItalic-webfont.woff +0 -0
  54. package/docs/fonts/OpenSans-Italic-webfont.eot +0 -0
  55. package/docs/fonts/OpenSans-Italic-webfont.svg +1830 -0
  56. package/docs/fonts/OpenSans-Italic-webfont.woff +0 -0
  57. package/docs/fonts/OpenSans-Light-webfont.eot +0 -0
  58. package/docs/fonts/OpenSans-Light-webfont.svg +1831 -0
  59. package/docs/fonts/OpenSans-Light-webfont.woff +0 -0
  60. package/docs/fonts/OpenSans-LightItalic-webfont.eot +0 -0
  61. package/docs/fonts/OpenSans-LightItalic-webfont.svg +1835 -0
  62. package/docs/fonts/OpenSans-LightItalic-webfont.woff +0 -0
  63. package/docs/fonts/OpenSans-Regular-webfont.eot +0 -0
  64. package/docs/fonts/OpenSans-Regular-webfont.svg +1831 -0
  65. package/docs/fonts/OpenSans-Regular-webfont.woff +0 -0
  66. package/docs/global.html +6145 -0
  67. package/docs/helpers_actionHelpers.js.html +94 -0
  68. package/docs/helpers_constants.js.html +81 -0
  69. package/docs/helpers_dockerService_dockerService.js.html +79 -0
  70. package/docs/helpers_dockerService_serviceComponents_containerActions.js.html +153 -0
  71. package/docs/helpers_dockerService_serviceComponents_containerList.js.html +81 -0
  72. package/docs/helpers_dockerService_serviceComponents_containerLogs.js.html +85 -0
  73. package/docs/helpers_dockerService_serviceComponents_containerStats.js.html +107 -0
  74. package/docs/helpers_dockerService_serviceComponents_imageUtils.js.html +82 -0
  75. package/docs/helpers_exitWithMessage.js.html +77 -0
  76. package/docs/helpers_safeCall.js.html +68 -0
  77. package/docs/helpers_validationHelpers.js.html +96 -0
  78. package/docs/hooks_creation_useContainerActions.js.html +120 -0
  79. package/docs/hooks_creation_useContainerCreation.js.html +152 -0
  80. package/docs/hooks_creation_useLogsViewer.js.html +102 -0
  81. package/docs/hooks_useControls.js.html +298 -0
  82. package/docs/hooks_useLogsStream.js.html +82 -0
  83. package/docs/index.html +65 -0
  84. package/docs/index.js.html +65 -0
  85. package/docs/module-index.html +171 -0
  86. package/docs/scripts/linenumber.js +25 -0
  87. package/docs/scripts/prettify/Apache-License-2.0.txt +202 -0
  88. package/docs/scripts/prettify/lang-css.js +2 -0
  89. package/docs/scripts/prettify/prettify.js +28 -0
  90. package/docs/styles/jsdoc-default.css +358 -0
  91. package/docs/styles/prettify-jsdoc.css +111 -0
  92. package/docs/styles/prettify-tomorrow.css +132 -0
  93. package/fix-imports.cjs +33 -6
  94. package/jest.config.cjs +2 -1
  95. package/package.json +19 -9
  96. package/src/App.jsx +16 -2
  97. package/src/components/ContainerCreationPrompt.jsx +26 -0
  98. package/src/components/ContainerList.jsx +12 -13
  99. package/src/components/ContainerRow.jsx +55 -20
  100. package/src/components/ContainerSection.jsx +15 -1
  101. package/src/components/Footer.jsx +6 -1
  102. package/src/components/Header.jsx +27 -3
  103. package/src/components/LogViewer.jsx +19 -2
  104. package/src/components/MessageFeedback.jsx +14 -0
  105. package/src/components/PromptField.jsx +30 -0
  106. package/src/components/StatsBar.jsx +47 -8
  107. package/src/components/UsageMenu.jsx +38 -8
  108. package/src/helpers/actionHelpers.js +16 -7
  109. package/src/helpers/appInfo.js +23 -0
  110. package/src/helpers/constants.js +30 -0
  111. package/src/helpers/dockerService/dockerService.js +19 -0
  112. package/src/helpers/dockerService/serviceComponents/containerActions.js +123 -12
  113. package/src/helpers/dockerService/serviceComponents/containerList.js +61 -23
  114. package/src/helpers/dockerService/serviceComponents/containerLogs.js +20 -6
  115. package/src/helpers/dockerService/serviceComponents/containerStats.js +24 -5
  116. package/src/helpers/dockerService/serviceComponents/imageUtils.js +1 -1
  117. package/src/helpers/exitWithMessage.js +2 -1
  118. package/src/helpers/logger.js +113 -0
  119. package/src/helpers/safeCall.js +18 -0
  120. package/src/helpers/validationHelpers.js +21 -3
  121. package/src/hooks/creation/useContainerActions.js +4 -3
  122. package/src/hooks/creation/useContainerCreation.js +5 -4
  123. package/src/hooks/creation/useLogsViewer.js +2 -1
  124. package/src/hooks/useContainers.js +6 -3
  125. package/src/hooks/useControls.js +244 -91
  126. package/src/hooks/useLogsStream.js +2 -1
  127. package/src/index.js +1 -0
  128. package/test/actionHelpers.test.js +106 -0
  129. package/test/containerActions.test.js +138 -0
  130. package/test/containerList.test.js +79 -0
  131. package/test/jest.setup.js +7 -0
  132. package/test/safeCall.test.js +25 -0
  133. package/test/useContainerCreation.dom.test.js +97 -0
  134. package/test/validationHelpers.test.js +2 -2
  135. package/dist/cdd.bundle.js +0 -8
  136. package/dist/components/ContainerCreator.js +0 -81
  137. package/dist/components/StatsViewer.js +0 -0
  138. package/dist/helpers/dockerActions.js +0 -64
  139. package/dist/helpers/dockerService.js +0 -284
  140. package/dist/hooks/useStatsPolling.js +0 -54
@@ -1,4 +1,5 @@
1
1
  import { useState, useRef } from "react";
2
+ import { safeCall } from "../../helpers/safeCall.js";
2
3
 
3
4
  /**
4
5
  * Custom hook to manage the logs viewer state and stream reference.
@@ -33,7 +34,7 @@ export function useLogsViewer() {
33
34
  setShowLogs(false);
34
35
  setLogs([]);
35
36
  if (logsStreamRef.current) {
36
- logsStreamRef.current.destroy?.();
37
+ safeCall(logsStreamRef.current.destroy?.bind(logsStreamRef.current));
37
38
  logsStreamRef.current = null;
38
39
  }
39
40
  }
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * React hook to manage Docker containers state.
3
3
  *
4
- * @returns {[Array, Function]} [containers, refresh] / [contenedores, refrescar]
4
+ * @returns {{containers: Array<Object>}} An object containing the current
5
+ * containers array. The hook starts a periodic refresh and fetches the
6
+ * container list immediately on mount.
5
7
  * @example
6
8
  * // Use in a component
7
- * const [containers, refresh] = useContainers();
9
+ * const { containers } = useContainers();
8
10
  */
9
11
  import React, { useState, useEffect } from "react";
10
12
  import { getContainers } from "../helpers/dockerService/serviceComponents/containerList";
13
+ import { REFRESH_INTERVALS } from "../helpers/constants.js";
11
14
 
12
15
  export function useContainers() {
13
16
  const [containers, setContainers] = useState([]);
@@ -15,7 +18,7 @@ export function useContainers() {
15
18
  useEffect(() => {
16
19
  const fetch = async () => setContainers(await getContainers());
17
20
  fetch();
18
- const timer = setInterval(fetch, 3000);
21
+ const timer = setInterval(fetch, REFRESH_INTERVALS.CONTAINER_LIST);
19
22
  return () => clearInterval(timer);
20
23
  }, []);
21
24
 
@@ -2,9 +2,11 @@ import React from "react";
2
2
  import { useContainerActions } from "./creation/useContainerActions";
3
3
  import { useContainerCreation } from "./creation/useContainerCreation";
4
4
  import { useLogsViewer } from "./creation/useLogsViewer";
5
- import { useInput } from "ink";
5
+ import { useInput, useApp } from "ink";
6
6
  import { getLogsStream } from "../helpers/dockerService/serviceComponents/containerLogs.js";
7
+ import { EXIT_DELAY } from "../helpers/constants.js";
7
8
  import { createContainer as svcCreateContainer } from "../helpers/dockerService/serviceComponents/containerActions.js";
9
+ import { logger } from "../helpers/logger.js";
8
10
 
9
11
  // Principal hook to manage user inputs and control the app state
10
12
  /**
@@ -29,12 +31,18 @@ import { createContainer as svcCreateContainer } from "../helpers/dockerService/
29
31
  * @property {Object} creation - The creation hook API (setters and helpers)
30
32
  * @property {Object} actions - The actions hook API (helpers to start/stop/remove)
31
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
32
37
  */
33
38
  export function useControls(containers = []) {
34
39
  const [selected, setSelected] = React.useState(0);
35
40
  const [creatingContainer, setCreatingContainer] = React.useState(false);
36
41
  const [confirmErase, setConfirmErase] = React.useState(false);
42
+ const [showDebugLogs, setShowDebugLogs] = React.useState(false);
43
+ const [debugLogs, setDebugLogs] = React.useState([]);
37
44
  const total = containers.length;
45
+ const { exit } = useApp();
38
46
 
39
47
  // Modular hooks
40
48
  const actions = useContainerActions({ containers });
@@ -60,10 +68,10 @@ export function useControls(containers = []) {
60
68
  if (Object.keys(ExposedPorts).length) options.ExposedPorts = ExposedPorts;
61
69
  if (Object.keys(PortBindings).length) options.HostConfig = { PortBindings };
62
70
  if (env.length) options.Env = env;
63
- if (containerName) options.name = containerName;
71
+ if (containerName) options.name = containerName;
64
72
 
65
- actions.setMessage(`Creating container ${imageName}...`);
66
- actions.setMessageColor("yellow");
73
+ actions.setMessage(`Creating container ${imageName}...`);
74
+ actions.setMessageColor("yellow");
67
75
  try {
68
76
  const id = await svcCreateContainer(imageName, options);
69
77
  actions.setMessage(`Created container ${id}`);
@@ -83,90 +91,158 @@ export function useControls(containers = []) {
83
91
  // Handler to exit logs (delegated to logsViewer)
84
92
  const exitLogs = logsViewer.closeLogs;
85
93
 
86
- useInput((input, key) => {
87
- // Erase confirmation
88
- if (confirmErase) {
89
- if (input === "y" || input === "Y") {
90
- actions.handleAction({
91
- actionFn: async (id) => await actions.removeContainer(id),
92
- actionLabel: "Erasing",
93
- selected,
94
- });
95
- setConfirmErase(false);
96
- actions.setMessageColor("yellow");
97
- return;
98
- } else if (input === "n" || input === "N" || key.escape) {
99
- setConfirmErase(false);
100
- actions.setMessage("");
101
- actions.setMessageColor("");
102
- return;
103
- } else {
104
- actions.setMessage("Are you sure you want to erase this container? (y/n)");
105
- actions.setMessageColor("yellow");
106
- return;
107
- }
94
+ /**
95
+ * Pipe container logs into the viewer while applying a hard limit.
96
+ * @param {string} containerId - Container identifier used by Docker.
97
+ */
98
+ const startLogsStream = React.useCallback((containerId) => {
99
+ getLogsStream(
100
+ containerId,
101
+ (data) => logsViewer.setLogs((prev) => {
102
+ const newLogs = [...prev, ...data.split("\n").filter(Boolean)];
103
+ return newLogs.slice(-1000);
104
+ }),
105
+ () => {},
106
+ (err) => logsViewer.setLogs((prev) => [...prev, `Error: ${err.message}`])
107
+ );
108
+ }, [logsViewer]);
109
+
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);
121
+ actions.setMessageColor("yellow");
122
+ return;
108
123
  }
109
124
 
110
- // Logs viewer
111
- if (logsViewer.showLogs) {
112
- if (input === "q" || key.escape) {
113
- logsViewer.closeLogs();
114
- }
125
+ if (input === "n" || input === "N" || key.escape) {
126
+ setConfirmErase(false);
127
+ actions.setMessage("");
128
+ actions.setMessageColor("");
115
129
  return;
116
130
  }
117
131
 
118
- // Container creation flow: delegate input to creation hook
119
- if (creatingContainer) {
120
- const step = creation.step;
121
- const appendCharToField = (setter, value, ch) => {
122
- setter((value || "") + ch);
123
- };
124
- // Escape -> cancel
125
- if (key.escape) {
126
- creation.cancelCreation();
127
- setCreatingContainer(false);
128
- return;
129
- }
130
- // Enter -> next step
131
- if (input === "\r" || input === "\n") {
132
- creation.nextStep();
133
- return;
134
- }
135
- // Backspace/Delete support
136
- if (key.backspace || key.delete) {
137
- if (step === 0) creation.setImageName((v) => (v || "").slice(0, -1));
138
- if (step === 1) creation.setContainerName((v) => (v || "").slice(0, -1));
139
- if (step === 2) creation.setPortInput((v) => (v || "").slice(0, -1));
140
- if (step === 3) creation.setEnvInput((v) => (v || "").slice(0, -1));
141
- return;
142
- }
143
- // Printable character input (append)
144
- if (input && input.length === 1 && !key.ctrl && !key.meta) {
145
- if (step === 0) appendCharToField(creation.setImageName, creation.imageName, input);
146
- if (step === 1) appendCharToField(creation.setContainerName, creation.containerName, input);
147
- if (step === 2) appendCharToField(creation.setPortInput, creation.portInput, input);
148
- if (step === 3) appendCharToField(creation.setEnvInput, creation.envInput, input);
149
- return;
150
- }
151
- // Otherwise ignore
132
+ actions.setMessage("Are you sure you want to erase this container? (y/n)");
133
+ actions.setMessageColor("yellow");
134
+ }, [actions, selected]);
135
+
136
+ /**
137
+ * Handle inputs while the logs viewer is active.
138
+ */
139
+ const processLogsInput = React.useCallback((input, key) => {
140
+ if (input === "q" || key.escape) {
141
+ logsViewer.closeLogs();
142
+ }
143
+ }, [logsViewer]);
144
+
145
+ /**
146
+ * Route keystrokes to the container creation wizard.
147
+ */
148
+ const processCreationInput = React.useCallback((input, key) => {
149
+ const step = creation.step;
150
+
151
+ const removeLastChar = (setter) => setter((value) => (value || "").slice(0, -1));
152
+ const appendChar = (setter, current, ch) => setter((current || "") + ch);
153
+
154
+ if (key.escape) {
155
+ creation.cancelCreation();
156
+ setCreatingContainer(false);
152
157
  return;
153
158
  }
154
159
 
155
- //==========================================================
156
- // Menu Navigation
157
- //==========================================================
158
- if (key.upArrow && total > 0) setSelected((i) => (i === 0 ? total - 1 : i - 1));
159
- if (key.downArrow && total > 0) setSelected((i) => (i === total - 1 ? 0 : i + 1));
160
- if (input === "q") {
161
- actions.setMessage("Exiting...");
162
- actions.setMessageColor("yellow");
163
- setTimeout(() => process.exit(0), 500);
160
+ if (input === "\r" || input === "\n") {
161
+ creation.nextStep();
162
+ return;
163
+ }
164
+
165
+ if (key.backspace || key.delete) {
166
+ if (step === 0) removeLastChar(creation.setImageName);
167
+ if (step === 1) removeLastChar(creation.setContainerName);
168
+ if (step === 2) removeLastChar(creation.setPortInput);
169
+ if (step === 3) removeLastChar(creation.setEnvInput);
164
170
  return;
165
171
  }
166
172
 
167
- //==========================================================
168
- // Docker commands
169
- //==========================================================
173
+ if (input && input.length === 1 && !key.ctrl && !key.meta) {
174
+ if (step === 0) appendChar(creation.setImageName, creation.imageName, input);
175
+ if (step === 1) appendChar(creation.setContainerName, creation.containerName, input);
176
+ if (step === 2) appendChar(creation.setPortInput, creation.portInput, input);
177
+ if (step === 3) appendChar(creation.setEnvInput, creation.envInput, input);
178
+ }
179
+ }, [creation, setCreatingContainer]);
180
+
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
+
170
246
  if (input === "i") {
171
247
  actions.handleAction({
172
248
  actionFn: async (id) => await actions.startContainer(id),
@@ -174,7 +250,9 @@ export function useControls(containers = []) {
174
250
  selected,
175
251
  stateCheck: (c) => (c.state === "running" || c.status === "running") && "Container is already running."
176
252
  });
253
+ return true;
177
254
  }
255
+
178
256
  if (input === "p") {
179
257
  actions.handleAction({
180
258
  actionFn: async (id) => await actions.stopContainer(id),
@@ -182,34 +260,39 @@ export function useControls(containers = []) {
182
260
  selected,
183
261
  stateCheck: (c) => (c.state === "exited" || c.status === "exited" || c.state === "stopped" || c.status === "stopped") && "Container is already stopped."
184
262
  });
263
+ return true;
185
264
  }
265
+
186
266
  if (input === "r") {
187
267
  actions.handleAction({
188
268
  actionFn: async (id) => await actions.restartContainer(id),
189
269
  actionLabel: "Restarting",
190
270
  selected,
191
271
  });
272
+ return true;
192
273
  }
193
- if (input === "e" && containers[selected]) {
274
+
275
+ if (input === "e") {
276
+ if (!container) {
277
+ return true;
278
+ }
279
+
194
280
  setConfirmErase(true);
195
281
  actions.setMessage("Are you sure you want to erase this container? (y/n)");
196
282
  actions.setMessageColor("yellow");
197
- return;
283
+ return true;
198
284
  }
199
- if (input === "l" && containers[selected]) {
285
+
286
+ if (input === "l") {
287
+ if (!container) {
288
+ return true;
289
+ }
290
+
200
291
  logsViewer.openLogs();
201
- getLogsStream(
202
- containers[selected].id,
203
- (data) => logsViewer.setLogs((prev) => {
204
- const newLogs = [...prev, ...data.split("\n").filter(Boolean)];
205
- // Limit to last 1000 lines to prevent memory leak
206
- return newLogs.slice(-1000);
207
- }),
208
- () => {},
209
- (err) => logsViewer.setLogs((prev) => [...prev, `Error: ${err.message}`])
210
- );
211
- return;
292
+ startLogsStream(container.id);
293
+ return true;
212
294
  }
295
+
213
296
  if (input === "c") {
214
297
  setCreatingContainer(true);
215
298
  creation.setStep(0);
@@ -219,9 +302,77 @@ export function useControls(containers = []) {
219
302
  creation.setEnvInput("");
220
303
  creation.setMessage("Insert the name of the image to create: ");
221
304
  creation.setMessageColor("yellow");
305
+ return true;
222
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
+
331
+ useInput((input, key) => {
332
+ if (confirmErase) {
333
+ processEraseConfirmation(input, key);
334
+ return;
335
+ }
336
+
337
+ if (logsViewer.showLogs) {
338
+ processLogsInput(input, key);
339
+ return;
340
+ }
341
+
342
+ if (creatingContainer) {
343
+ processCreationInput(input, key);
344
+ return;
345
+ }
346
+
347
+ if (showDebugLogs && key.escape) {
348
+ setShowDebugLogs(false);
349
+ return;
350
+ }
351
+
352
+ processGlobalInput(input, key);
223
353
  });
224
354
 
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
+
225
376
  return {
226
377
  selected,
227
378
  setSelected,
@@ -242,5 +393,7 @@ export function useControls(containers = []) {
242
393
  actions,
243
394
  logsViewer,
244
395
  confirmErase,
396
+ showDebugLogs,
397
+ debugLogs,
245
398
  };
246
399
  }
@@ -1,5 +1,6 @@
1
1
  import React, { useRef, useCallback } from "react";
2
2
  import { getLogsStream } from "../helpers/dockerService/serviceComponents/containerLogs";
3
+ import { safeCall } from "../helpers/safeCall";
3
4
 
4
5
  /**
5
6
  * Hook that manages opening and closing a logs stream for a container.
@@ -21,7 +22,7 @@ export function useLogsStream() {
21
22
 
22
23
  const closeLogs = useCallback(() => {
23
24
  if (logsStreamRef.current) {
24
- logsStreamRef.current.destroy?.();
25
+ safeCall(logsStreamRef.current.destroy?.bind(logsStreamRef.current));
25
26
  logsStreamRef.current = null;
26
27
  }
27
28
  }, []);
package/src/index.js CHANGED
@@ -12,4 +12,5 @@ import React from "react";
12
12
  import { render } from "ink";
13
13
  import App from './App';
14
14
 
15
+ console.clear();
15
16
  render(<App />);
@@ -0,0 +1,106 @@
1
+ import { jest } from '@jest/globals';
2
+ import { handleAction } from '../src/helpers/actionHelpers.js';
3
+ import { MESSAGE_TIMEOUTS } from '../src/helpers/constants.js';
4
+
5
+ describe('handleAction', () => {
6
+ beforeEach(() => {
7
+ jest.useFakeTimers();
8
+ });
9
+
10
+ afterEach(() => {
11
+ jest.useRealTimers();
12
+ jest.clearAllMocks();
13
+ });
14
+
15
+ test('returns early when no container selected', async () => {
16
+ const setMessage = jest.fn();
17
+ const setMessageColor = jest.fn();
18
+ const actionFn = jest.fn();
19
+
20
+ await handleAction({
21
+ containers: [],
22
+ selected: 0,
23
+ actionFn,
24
+ actionLabel: 'Start',
25
+ setMessage,
26
+ setMessageColor,
27
+ });
28
+
29
+ expect(actionFn).not.toHaveBeenCalled();
30
+ expect(setMessage).not.toHaveBeenCalled();
31
+ expect(setMessageColor).not.toHaveBeenCalled();
32
+ });
33
+
34
+ test('uses stateCheck and shows error then clears message after SHORT timeout', async () => {
35
+ const setMessage = jest.fn();
36
+ const setMessageColor = jest.fn();
37
+ const actionFn = jest.fn();
38
+
39
+ const containers = [{ id: 'c1', name: 'one' }];
40
+
41
+ const stateCheck = (_c) => 'Already running';
42
+
43
+ await handleAction({
44
+ containers,
45
+ selected: 0,
46
+ actionFn,
47
+ actionLabel: 'Start',
48
+ setMessage,
49
+ setMessageColor,
50
+ stateCheck,
51
+ });
52
+
53
+ expect(setMessage).toHaveBeenCalledWith('Already running');
54
+ expect(setMessageColor).toHaveBeenCalledWith('red');
55
+ expect(actionFn).not.toHaveBeenCalled();
56
+
57
+ // advance timers to allow message clear
58
+ jest.advanceTimersByTime(MESSAGE_TIMEOUTS.SHORT + 10);
59
+ expect(setMessage).toHaveBeenCalledWith('');
60
+ });
61
+
62
+ test('on success sets success message and clears after DEFAULT timeout', async () => {
63
+ const setMessage = jest.fn();
64
+ const setMessageColor = jest.fn();
65
+ const actionFn = jest.fn().mockResolvedValue(undefined);
66
+
67
+ const containers = [{ id: 'c1', name: 'one' }];
68
+
69
+ await handleAction({
70
+ containers,
71
+ selected: 0,
72
+ actionFn,
73
+ actionLabel: 'Start',
74
+ setMessage,
75
+ setMessageColor,
76
+ });
77
+
78
+ expect(actionFn).toHaveBeenCalledWith('c1');
79
+ expect(setMessage).toHaveBeenCalledWith('Start container completed successfully');
80
+ expect(setMessageColor).toHaveBeenCalledWith('green');
81
+
82
+ jest.advanceTimersByTime(MESSAGE_TIMEOUTS.DEFAULT + 10);
83
+ expect(setMessage).toHaveBeenCalledWith('');
84
+ });
85
+
86
+ test('on failure sets failed message and color red', async () => {
87
+ const setMessage = jest.fn();
88
+ const setMessageColor = jest.fn();
89
+ const actionFn = jest.fn().mockRejectedValue(new Error('boom'));
90
+
91
+ const containers = [{ id: 'c1', name: 'one' }];
92
+
93
+ await handleAction({
94
+ containers,
95
+ selected: 0,
96
+ actionFn,
97
+ actionLabel: 'Stop',
98
+ setMessage,
99
+ setMessageColor,
100
+ });
101
+
102
+ expect(actionFn).toHaveBeenCalledWith('c1');
103
+ expect(setMessage).toHaveBeenCalledWith('Failed to stop container.');
104
+ expect(setMessageColor).toHaveBeenCalledWith('red');
105
+ });
106
+ });