cdd-cli 3.1.1 → 3.1.3

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 (42) hide show
  1. package/AUDIT_REPORT.md +1004 -0
  2. package/CHANGELOG.md +22 -0
  3. package/FIXES_APPLIED.md +421 -0
  4. package/dist/App.js +7 -9
  5. package/dist/components/ContainerCreationPrompt.js +1 -1
  6. package/dist/components/ContainerList.js +2 -4
  7. package/dist/components/ContainerRow.js +20 -14
  8. package/dist/components/ContainerSection.js +1 -1
  9. package/dist/helpers/actionHelpers.js +14 -1
  10. package/dist/helpers/dockerService/dockerService.js +6 -3
  11. package/dist/helpers/dockerService/serviceComponents/containerActions.js +60 -10
  12. package/dist/helpers/dockerService/serviceComponents/containerList.js +7 -2
  13. package/dist/helpers/dockerService/serviceComponents/containerLogs.js +33 -20
  14. package/dist/helpers/dockerService/serviceComponents/containerStats.js +12 -3
  15. package/dist/helpers/dockerService/serviceComponents/imageUtils.js +12 -0
  16. package/dist/helpers/exitWithMessage.js +11 -0
  17. package/dist/helpers/validationHelpers.js +15 -2
  18. package/dist/hooks/creation/useContainerCreation.js +2 -6
  19. package/dist/hooks/useContainers.js +1 -3
  20. package/dist/hooks/useControls.js +27 -2
  21. package/dist/hooks/useLogsStream.js +6 -0
  22. package/dist/index.js +1 -4
  23. package/fix-imports.cjs +3 -0
  24. package/package.json +1 -1
  25. package/src/App.jsx +0 -2
  26. package/src/components/ContainerList.jsx +1 -3
  27. package/src/components/ContainerRow.jsx +18 -6
  28. package/src/helpers/actionHelpers.js +14 -1
  29. package/src/helpers/dockerService/dockerService.js +7 -1
  30. package/src/helpers/dockerService/serviceComponents/containerActions.js +55 -9
  31. package/src/helpers/dockerService/serviceComponents/containerList.js +6 -2
  32. package/src/helpers/dockerService/serviceComponents/containerLogs.js +27 -15
  33. package/src/helpers/dockerService/serviceComponents/containerStats.js +15 -1
  34. package/src/helpers/dockerService/serviceComponents/imageUtils.js +10 -0
  35. package/src/helpers/exitWithMessage.js +10 -0
  36. package/src/helpers/validationHelpers.js +14 -2
  37. package/src/hooks/creation/useContainerCreation.js +2 -6
  38. package/src/hooks/useContainers.js +1 -3
  39. package/src/hooks/useControls.js +29 -2
  40. package/src/hooks/useLogsStream.js +5 -0
  41. package/src/index.js +1 -5
  42. package/test/validationHelpers.test.js +32 -0
@@ -1,24 +1,55 @@
1
+ import { docker } from "../dockerService";
2
+ import { imageExists, pullImage } from "./imageUtils.js";
3
+
4
+ /**
5
+ * Helper to add timeout to promises
6
+ * @param {Promise} promise - Promise to wrap
7
+ * @param {number} ms - Timeout in milliseconds
8
+ * @returns {Promise}
9
+ */
10
+ function withTimeout(promise, ms = 30000) {
11
+ return Promise.race([
12
+ promise,
13
+ new Promise((_, reject) =>
14
+ setTimeout(() => reject(new Error('Operation timed out')), ms)
15
+ )
16
+ ]);
17
+ }
18
+
19
+ /**
20
+ * Remove (delete) a container by id. Force removal so running containers are stopped first.
21
+ *
22
+ * @param {string} containerId - Docker container id
23
+ * @returns {Promise<void>} Resolves when removal completes
24
+ * @throws {Error} If Docker reports an error
25
+ */
1
26
  export async function removeContainer(containerId) {
2
27
  const container = docker.getContainer(containerId);
3
28
  try {
4
- await container.remove({ force: true });
29
+ await withTimeout(container.remove({ force: true }), 30000);
5
30
  } catch (err) {
6
31
  throw new Error('Error removing container: ' + err.message);
7
32
  }
8
33
  }
9
- import { docker } from "../dockerService";
10
- import { imageExists, pullImage } from "./imageUtils.js";
11
34
 
35
+ /**
36
+ * Create a new container from an image. If the image is missing locally, it will be pulled.
37
+ *
38
+ * @param {string} imageName - Image name (e.g. 'nginx:alpine')
39
+ * @param {Object} [options] - Docker create options (Env, ExposedPorts, HostConfig, name, etc.)
40
+ * @returns {Promise<string>} The created container id
41
+ * @throws {Error} If image listing/pull or creation fails
42
+ */
12
43
  export async function createContainer(imageName, options = {}) {
13
44
  let exists;
14
45
  try {
15
- exists = await imageExists(imageName);
46
+ exists = await withTimeout(imageExists(imageName), 10000);
16
47
  } catch (err) {
17
48
  throw new Error('Error listing local images: ' + err.message);
18
49
  }
19
50
  if (!exists) {
20
51
  try {
21
- await pullImage(imageName);
52
+ await withTimeout(pullImage(imageName), 300000); // 5 minutes for pull
22
53
  } catch (err) {
23
54
  throw new Error('Could not pull image: ' + err.message);
24
55
  }
@@ -29,24 +60,39 @@ export async function createContainer(imageName, options = {}) {
29
60
  ...options,
30
61
  };
31
62
  try {
32
- const container = await docker.createContainer(createOpts);
63
+ const container = await withTimeout(docker.createContainer(createOpts), 30000);
33
64
  return container.id || container.Id;
34
65
  } catch (err) {
35
66
  throw new Error('Error creating container: ' + err.message);
36
67
  }
37
68
  }
38
69
 
70
+ /**
71
+ * Start a container by id.
72
+ * @param {string} containerId - Docker container id
73
+ * @returns {Promise<void>}
74
+ */
39
75
  export async function startContainer(containerId) {
40
76
  const container = docker.getContainer(containerId);
41
- await container.start();
77
+ await withTimeout(container.start(), 30000);
42
78
  }
43
79
 
80
+ /**
81
+ * Stop a container by id.
82
+ * @param {string} containerId - Docker container id
83
+ * @returns {Promise<void>}
84
+ */
44
85
  export async function stopContainer(containerId) {
45
86
  const container = docker.getContainer(containerId);
46
- await container.stop();
87
+ await withTimeout(container.stop(), 30000);
47
88
  }
48
89
 
90
+ /**
91
+ * Restart a container by id.
92
+ * @param {string} containerId - Docker container id
93
+ * @returns {Promise<void>}
94
+ */
49
95
  export async function restartContainer(containerId) {
50
96
  const container = docker.getContainer(containerId);
51
- await container.restart();
97
+ await withTimeout(container.restart(), 30000);
52
98
  }
@@ -1,10 +1,14 @@
1
1
  import { docker } from "../dockerService";
2
2
 
3
+ /**
4
+ * Return a list of containers with normalized fields for the UI.
5
+ * @returns {Promise<Array<Object>>}
6
+ */
3
7
  export async function getContainers() {
4
8
  const containers = await docker.listContainers({ all: true });
5
9
  return containers.map((container) => ({
6
10
  id: container.Id,
7
- name: container.Names[0].replace("/", ""),
11
+ name: (container.Names && container.Names[0] || 'Unknown').replace("/", ""),
8
12
  image: container.Image,
9
13
  state: container.State,
10
14
  status: container.Status,
@@ -16,7 +20,7 @@ export async function getContainers() {
16
20
  if (publicPorts.length > 0) {
17
21
  return [...new Set(publicPorts)];
18
22
  }
19
- // Si no hay puertos públicos, mostrar los privados expuestos
23
+ // If there are no public ports, show private exposed ports
20
24
  const privatePorts = container.Ports.filter((port) => port.PrivatePort).map(
21
25
  (port) => `:${port.PrivatePort}`
22
26
  );
@@ -1,19 +1,31 @@
1
1
  import { docker } from "../dockerService";
2
2
 
3
+ /**
4
+ * Return a stream of logs from a container and call callbacks for events.
5
+ *
6
+ * @param {string} containerId - Docker container id
7
+ * @param {Function} onData - Called with chunk string when data arrives
8
+ * @param {Function} onEnd - Called when stream ends
9
+ * @param {Function} onError - Called on error
10
+ */
3
11
  export function getLogsStream(containerId, onData, onEnd, onError) {
4
- const container = docker.getContainer(containerId);
5
- container.logs({
6
- follow: true,
7
- stdout: true,
8
- stderr: true,
9
- tail: 100
10
- }, (err, stream) => {
11
- if (err) {
12
- onError?.(err);
13
- return;
14
- }
15
- stream.on('data', chunk => onData?.(chunk.toString()));
16
- stream.on('end', () => onEnd?.());
17
- stream.on('error', err => onError?.(err));
18
- });
12
+ try {
13
+ const container = docker.getContainer(containerId);
14
+ container.logs({
15
+ follow: true,
16
+ stdout: true,
17
+ stderr: true,
18
+ tail: 100
19
+ }, (err, stream) => {
20
+ if (err) {
21
+ onError?.(err);
22
+ return;
23
+ }
24
+ stream.on('data', chunk => onData?.(chunk.toString()));
25
+ stream.on('end', () => onEnd?.());
26
+ stream.on('error', err => onError?.(err));
27
+ });
28
+ } catch (err) {
29
+ onError?.(err);
30
+ }
19
31
  }
@@ -1,5 +1,11 @@
1
1
  import { docker } from "../dockerService";
2
2
 
3
+ /**
4
+ * Retrieve a snapshot of container resource usage (CPU, memory, network).
5
+ *
6
+ * @param {string} containerId - Docker container id
7
+ * @returns {Promise<Object>} Object with cpuPercent, memPercent and netIO {rx,tx}
8
+ */
3
9
  export async function getStats(containerId) {
4
10
  const container = docker.getContainer(containerId);
5
11
  const stream = await container.stats({ stream: false });
@@ -9,7 +15,15 @@ export async function getStats(containerId) {
9
15
  stream.precpu_stats.cpu_usage.total_usage;
10
16
  const systemDelta =
11
17
  stream.cpu_stats.system_cpu_usage - stream.precpu_stats.system_cpu_usage;
12
- const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * 100 : 0;
18
+
19
+ // Get number of CPUs for proper normalization
20
+ const numCpus = stream.cpu_stats.online_cpus ||
21
+ stream.cpu_stats.cpu_usage.percpu_usage?.length || 1;
22
+
23
+ // Calculate normalized CPU percentage
24
+ const cpuPercent = systemDelta > 0
25
+ ? ((cpuDelta / systemDelta) * numCpus * 100)
26
+ : 0;
13
27
 
14
28
  const memUsage = stream.memory_stats.usage || 0;
15
29
  const memLimit = stream.memory_stats.limit || 1;
@@ -1,5 +1,10 @@
1
1
  import { docker } from "../dockerService";
2
2
 
3
+ /**
4
+ * Check whether an image exists locally.
5
+ * @param {string} imageName - Image name or tag
6
+ * @returns {Promise<boolean>}
7
+ */
3
8
  export async function imageExists(imageName) {
4
9
  const images = await docker.listImages();
5
10
  return images.some(img =>
@@ -8,6 +13,11 @@ export async function imageExists(imageName) {
8
13
  );
9
14
  }
10
15
 
16
+ /**
17
+ * Pull an image from the registry.
18
+ * @param {string} imageName - Image name to pull
19
+ * @returns {Promise<void>}
20
+ */
11
21
  export async function pullImage(imageName) {
12
22
  await new Promise((resolve, reject) => {
13
23
  docker.pull(imageName, (err, stream) => {
@@ -1,5 +1,15 @@
1
1
  import { spawn } from "child_process";
2
2
 
3
+ /**
4
+ * Show an exit message using provided setters, clear the terminal and exit after a delay.
5
+ *
6
+ * @param {Object} params
7
+ * @param {Function} params.setMessage - Setter for message text
8
+ * @param {Function} params.setMessageColor - Setter for message color
9
+ * @param {string} [params.message] - Message to display
10
+ * @param {string} [params.color] - Color for the message
11
+ * @param {number} [params.delay] - Delay in milliseconds before exiting
12
+ */
3
13
  export function exitWithMessage({ setMessage, setMessageColor, message = "Exiting...", color = "yellow", delay = 1500 }) {
4
14
  setMessage(message);
5
15
  setMessageColor(color);
@@ -11,6 +11,18 @@ export function validatePorts(portInput) {
11
11
  }
12
12
 
13
13
  export function validateEnvVars(envInput) {
14
- // Future specific validations
15
- return true;
14
+ if (!envInput || !envInput.trim()) return true; // Empty is valid
15
+
16
+ const vars = envInput.split(",").map(v => v.trim()).filter(Boolean);
17
+ const invalid = vars.find(v => {
18
+ const parts = v.split("=");
19
+ // Must have at least VAR=value format
20
+ if (parts.length < 2) return true;
21
+ const varName = parts[0].trim();
22
+ // Variable names should be alphanumeric with underscores
23
+ if (!/^[A-Z_][A-Z0-9_]*$/i.test(varName)) return true;
24
+ return false;
25
+ });
26
+
27
+ return !invalid;
16
28
  }
@@ -42,12 +42,8 @@ export function useContainerCreation({ onCreate, onCancel, dbImages = [] }) {
42
42
  return;
43
43
  }
44
44
  if (step === 2) {
45
- if (!portInput.trim()) {
46
- setMessage("You must specify at least one port to expose (e.g. 8080:80)");
47
- setMessageColor("red");
48
- return;
49
- }
50
- if (!validatePorts(portInput)) {
45
+ // Ports are now optional - only validate if provided
46
+ if (portInput.trim() && !validatePorts(portInput)) {
51
47
  setMessage("Port format must be host:container and both must be numbers (e.g. 8080:80)");
52
48
  setMessageColor("red");
53
49
  return;
@@ -1,11 +1,9 @@
1
1
  /**
2
2
  * React hook to manage Docker containers state.
3
- * Hook de React para gestionar el estado de contenedores Docker.
4
3
  *
5
4
  * @returns {[Array, Function]} [containers, refresh] / [contenedores, refrescar]
6
5
  * @example
7
- * // EN: Use in a component
8
- * // ES: Usar en un componente
6
+ * // Use in a component
9
7
  * const [containers, refresh] = useContainers();
10
8
  */
11
9
  import React, { useState, useEffect } from "react";
@@ -7,6 +7,29 @@ import { getLogsStream } from "../helpers/dockerService/serviceComponents/contai
7
7
  import { createContainer as svcCreateContainer } from "../helpers/dockerService/serviceComponents/containerActions.js";
8
8
 
9
9
  // Principal hook to manage user inputs and control the app state
10
+ /**
11
+ * Main hook that wires user input, creation, actions and logs viewing.
12
+ * It coordinates the modular hooks and exposes a compact API consumed by the App.
13
+ *
14
+ * @param {Array<Object>} containers - Current list of Docker containers
15
+ * @returns {Object} controls - API for the App component
16
+ * @property {number} selected - Index of the currently selected container
17
+ * @property {function} setSelected - Setter for selected index
18
+ * @property {string} message - Current feedback message (creation or actions)
19
+ * @property {string} messageColor - Color to show for the feedback message
20
+ * @property {boolean} showLogs - Whether the logs viewer is active
21
+ * @property {Array<string>} logs - Array of log lines currently collected
22
+ * @property {function} exitLogs - Helper to close the logs viewer
23
+ * @property {boolean} creatingContainer - Whether the create-container prompt is open
24
+ * @property {number} creationStep - Current step in the creation flow
25
+ * @property {string} imageNameInput - Current value of the image name field
26
+ * @property {string} containerNameInput - Current value of the container name field
27
+ * @property {string} portInput - Current value of the port input field
28
+ * @property {string} envInput - Current value of the env input field
29
+ * @property {Object} creation - The creation hook API (setters and helpers)
30
+ * @property {Object} actions - The actions hook API (helpers to start/stop/remove)
31
+ * @property {Object} logsViewer - The logs viewer hook API
32
+ */
10
33
  export function useControls(containers = []) {
11
34
  const [selected, setSelected] = React.useState(0);
12
35
  const [creatingContainer, setCreatingContainer] = React.useState(false);
@@ -61,7 +84,7 @@ export function useControls(containers = []) {
61
84
  const exitLogs = logsViewer.closeLogs;
62
85
 
63
86
  useInput((input, key) => {
64
- // Confirmación de borrado
87
+ // Erase confirmation
65
88
  if (confirmErase) {
66
89
  if (input === "y" || input === "Y") {
67
90
  actions.handleAction({
@@ -177,7 +200,11 @@ export function useControls(containers = []) {
177
200
  logsViewer.openLogs();
178
201
  getLogsStream(
179
202
  containers[selected].id,
180
- (data) => logsViewer.setLogs((prev) => [...prev, ...data.split("\n").filter(Boolean)]),
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
+ }),
181
208
  () => {},
182
209
  (err) => logsViewer.setLogs((prev) => [...prev, `Error: ${err.message}`])
183
210
  );
@@ -1,6 +1,11 @@
1
1
  import React, { useRef, useCallback } from "react";
2
2
  import { getLogsStream } from "../helpers/dockerService/serviceComponents/containerLogs";
3
3
 
4
+ /**
5
+ * Hook that manages opening and closing a logs stream for a container.
6
+ *
7
+ * @returns {Object} { openLogs, closeLogs }
8
+ */
4
9
  export function useLogsStream() {
5
10
  const logsStreamRef = useRef(null);
6
11
 
package/src/index.js CHANGED
@@ -1,17 +1,13 @@
1
1
  #!/usr/bin/env node
2
-
3
2
  /**
4
3
  * Entry point for the CDD CLI application.
5
- * Punto de entrada para la aplicación CLI de CDD.
6
4
  *
7
5
  * @module index
8
6
  * @example
9
- * // EN: Run the CLI
10
- * // ES: Ejecutar el CLI
7
+ * // Run the CLI
11
8
  * node index.js
12
9
  */
13
10
 
14
-
15
11
  import React from "react";
16
12
  import { render } from "ink";
17
13
  import App from './App';
@@ -1,8 +1,10 @@
1
1
  let validatePorts;
2
+ let validateEnvVars;
2
3
 
3
4
  beforeAll(async () => {
4
5
  const mod = await import('../src/helpers/validationHelpers.js');
5
6
  validatePorts = mod.validatePorts;
7
+ validateEnvVars = mod.validateEnvVars;
6
8
  });
7
9
 
8
10
  describe('validatePorts', () => {
@@ -26,3 +28,33 @@ describe('validatePorts', () => {
26
28
  expect(validatePorts('')).toBe(false);
27
29
  });
28
30
  });
31
+
32
+ describe('validateEnvVars', () => {
33
+ test('empty input is valid', () => {
34
+ expect(validateEnvVars('')).toBe(true);
35
+ });
36
+
37
+ test('valid single env var', () => {
38
+ expect(validateEnvVars('NODE_ENV=production')).toBe(true);
39
+ });
40
+
41
+ test('valid multiple env vars', () => {
42
+ expect(validateEnvVars('NODE_ENV=production,PORT=3000')).toBe(true);
43
+ });
44
+
45
+ test('valid env var with underscores', () => {
46
+ expect(validateEnvVars('MY_VAR_NAME=value')).toBe(true);
47
+ });
48
+
49
+ test('invalid env var without equals sign', () => {
50
+ expect(validateEnvVars('NOEQUALS')).toBe(false);
51
+ });
52
+
53
+ test('invalid env var with invalid name', () => {
54
+ expect(validateEnvVars('123INVALID=value')).toBe(false);
55
+ });
56
+
57
+ test('invalid env var with special characters in name', () => {
58
+ expect(validateEnvVars('MY-VAR=value')).toBe(false);
59
+ });
60
+ });