cdd-cli 3.2.0 → 3.2.2

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 (120) hide show
  1. package/.atl/skill-registry.md +30 -0
  2. package/.editorconfig +12 -0
  3. package/.eslintrc.json +1 -1
  4. package/.prettierignore +4 -0
  5. package/.prettierrc +8 -0
  6. package/CHANGELOG.md +29 -0
  7. package/SECURITY.md +5 -0
  8. package/__mocks__/ink.cjs +17 -0
  9. package/coverage/clover.xml +526 -436
  10. package/coverage/coverage-final.json +19 -15
  11. package/coverage/lcov-report/components/ContainerCreationPrompt.jsx.html +376 -0
  12. package/coverage/lcov-report/components/PromptField.jsx.html +229 -0
  13. package/coverage/lcov-report/components/SuggestionPanel.jsx.html +244 -0
  14. package/coverage/lcov-report/components/index.html +146 -0
  15. package/coverage/lcov-report/helpers/constants.js.html +80 -17
  16. package/coverage/lcov-report/helpers/containerOptionsBuilder.js.html +41 -8
  17. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerActions.js.html +162 -84
  18. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerList.js.html +50 -32
  19. package/coverage/lcov-report/helpers/dockerService/serviceComponents/index.html +21 -21
  20. package/coverage/lcov-report/helpers/imageNameUtils.js.html +181 -0
  21. package/coverage/lcov-report/helpers/index.html +27 -12
  22. package/coverage/lcov-report/helpers/logger.js.html +19 -19
  23. package/coverage/lcov-report/helpers/safeCall.js.html +3 -3
  24. package/coverage/lcov-report/helpers/validationHelpers.js.html +28 -76
  25. package/coverage/lcov-report/hooks/creation/index.html +20 -20
  26. package/coverage/lcov-report/hooks/creation/useContainerActions.js.html +38 -17
  27. package/coverage/lcov-report/hooks/creation/useContainerCreation.js.html +416 -89
  28. package/coverage/lcov-report/hooks/creation/useLogsViewer.js.html +7 -7
  29. package/coverage/lcov-report/hooks/debug/index.html +5 -5
  30. package/coverage/lcov-report/hooks/debug/useDebugLogs.js.html +35 -20
  31. package/coverage/lcov-report/hooks/index.html +40 -40
  32. package/coverage/lcov-report/hooks/navigation/index.html +19 -19
  33. package/coverage/lcov-report/hooks/navigation/useContainerSelection.js.html +38 -29
  34. package/coverage/lcov-report/hooks/useContainerCommandRouter.js.html +119 -80
  35. package/coverage/lcov-report/hooks/useControls.js.html +263 -122
  36. package/coverage/lcov-report/hooks/useEraseConfirmation.js.html +8 -8
  37. package/coverage/lcov-report/hooks/useExitHandler.js.html +50 -41
  38. package/coverage/lcov-report/index.html +67 -52
  39. package/coverage/lcov.info +991 -805
  40. package/dist/App.js +11 -11
  41. package/dist/components/ContainerCreationPrompt.js +31 -10
  42. package/dist/components/ContainerList.js +3 -3
  43. package/dist/components/ContainerRow.js +20 -20
  44. package/dist/components/ContainerSection.js +3 -3
  45. package/dist/components/Footer.js +2 -2
  46. package/dist/components/Header.js +7 -7
  47. package/dist/components/LogViewer.js +3 -3
  48. package/dist/components/MessageFeedback.js +2 -2
  49. package/dist/components/PromptField.js +4 -4
  50. package/dist/components/StatsBar.js +5 -5
  51. package/dist/components/SuggestionPanel.js +44 -0
  52. package/dist/components/UsageMenu.js +3 -3
  53. package/dist/helpers/appInfo.js +7 -7
  54. package/dist/helpers/constants.js +114 -0
  55. package/dist/helpers/containerOptionsBuilder.js +3 -3
  56. package/dist/helpers/dockerService/dockerService.js +1 -1
  57. package/dist/helpers/dockerService/serviceComponents/containerActions.js +99 -40
  58. package/dist/helpers/dockerService/serviceComponents/containerList.js +11 -11
  59. package/dist/helpers/dockerService/serviceComponents/containerLogs.js +9 -9
  60. package/dist/helpers/dockerService/serviceComponents/containerStats.js +5 -5
  61. package/dist/helpers/dockerService/serviceComponents/imageUtils.js +1 -1
  62. package/dist/helpers/exitWithMessage.js +10 -10
  63. package/dist/helpers/imageNameUtils.js +32 -0
  64. package/dist/helpers/logger.js +13 -13
  65. package/dist/helpers/safeCall.js +2 -2
  66. package/dist/helpers/validationHelpers.js +76 -21
  67. package/dist/hooks/creation/useContainerActions.js +9 -9
  68. package/dist/hooks/creation/useContainerCreation.js +138 -36
  69. package/dist/hooks/creation/useLogsViewer.js +2 -2
  70. package/dist/hooks/debug/useDebugLogs.js +4 -4
  71. package/dist/hooks/useContainerCommandRouter.js +13 -13
  72. package/dist/hooks/useContainers.js +3 -3
  73. package/dist/hooks/useControls.js +66 -35
  74. package/dist/hooks/useEraseConfirmation.js +3 -3
  75. package/dist/index.js +2 -2
  76. package/jest.config.cjs +7 -0
  77. package/package.json +7 -3
  78. package/src/App.jsx +33 -16
  79. package/src/components/ContainerCreationPrompt.jsx +43 -9
  80. package/src/components/ContainerList.jsx +3 -3
  81. package/src/components/ContainerRow.jsx +19 -16
  82. package/src/components/ContainerSection.jsx +3 -3
  83. package/src/components/Footer.jsx +2 -2
  84. package/src/components/Header.jsx +7 -7
  85. package/src/components/LogViewer.jsx +5 -3
  86. package/src/components/MessageFeedback.jsx +2 -2
  87. package/src/components/PromptField.jsx +4 -4
  88. package/src/components/StatsBar.jsx +11 -7
  89. package/src/components/SuggestionPanel.jsx +53 -0
  90. package/src/components/UsageMenu.jsx +18 -13
  91. package/src/helpers/appInfo.js +7 -7
  92. package/src/helpers/constants.js +30 -9
  93. package/src/helpers/containerOptionsBuilder.js +16 -5
  94. package/src/helpers/dockerService/dockerService.js +1 -1
  95. package/src/helpers/dockerService/serviceComponents/containerActions.js +87 -61
  96. package/src/helpers/dockerService/serviceComponents/containerList.js +31 -25
  97. package/src/helpers/dockerService/serviceComponents/containerLogs.js +41 -26
  98. package/src/helpers/dockerService/serviceComponents/containerStats.js +13 -11
  99. package/src/helpers/dockerService/serviceComponents/imageUtils.js +5 -4
  100. package/src/helpers/exitWithMessage.js +13 -7
  101. package/src/helpers/imageNameUtils.js +32 -0
  102. package/src/helpers/logger.js +15 -15
  103. package/src/helpers/safeCall.js +2 -2
  104. package/src/helpers/validationHelpers.js +13 -29
  105. package/src/hooks/creation/useContainerActions.js +19 -12
  106. package/src/hooks/creation/useContainerCreation.js +150 -41
  107. package/src/hooks/creation/useLogsViewer.js +2 -2
  108. package/src/hooks/debug/useDebugLogs.js +15 -10
  109. package/src/hooks/navigation/useContainerSelection.js +17 -14
  110. package/src/hooks/useContainerCommandRouter.js +71 -58
  111. package/src/hooks/useContainers.js +4 -4
  112. package/src/hooks/useControls.js +115 -68
  113. package/src/hooks/useEraseConfirmation.js +3 -3
  114. package/src/hooks/useExitHandler.js +29 -26
  115. package/src/index.js +4 -4
  116. package/test/ContainerCreationPrompt.dom.test.js +68 -0
  117. package/test/SuggestionPanel.dom.test.js +53 -0
  118. package/test/constants.test.js +34 -0
  119. package/test/useContainerCreation.dom.test.js +123 -0
  120. package/test/useControls.dom.test.js +198 -2
@@ -1,6 +1,6 @@
1
- import React from "react";
2
- import { Text } from "ink";
3
- import chalk from "chalk";
1
+ import React from 'react';
2
+ import { Text } from 'ink';
3
+ import chalk from 'chalk';
4
4
  import PropTypes from 'prop-types';
5
5
 
6
6
  const BAR_WIDTH = 8;
@@ -19,7 +19,9 @@ export default function StatsBar({ cpu, mem }) {
19
19
 
20
20
  return (
21
21
  <Text>
22
- CPU:{cpuBar}{colorize(cpu, ` ${formatPercent(cpu)}%`)} MEM:{memBar}{colorize(mem, ` ${formatPercent(mem)}%`)}
22
+ CPU:{cpuBar}
23
+ {colorize(cpu, ` ${formatPercent(cpu)}%`)} MEM:{memBar}
24
+ {colorize(mem, ` ${formatPercent(mem)}%`)}
23
25
  </Text>
24
26
  );
25
27
  }
@@ -38,10 +40,12 @@ StatsBar.propTypes = {
38
40
  */
39
41
  function makeBar(value, width) {
40
42
  const safeWidth = Math.max(1, Math.round(width));
41
- const normalized = Number.isFinite(value) ? Math.min(Math.max(value, 0), 100) : 0;
43
+ const normalized = Number.isFinite(value)
44
+ ? Math.min(Math.max(value, 0), 100)
45
+ : 0;
42
46
  const filled = Math.round((normalized / 100) * safeWidth);
43
47
  const empty = Math.max(0, safeWidth - filled);
44
- const bar = "".repeat(filled) + "".repeat(empty);
48
+ const bar = ''.repeat(filled) + ''.repeat(empty);
45
49
  return colorize(normalized, bar);
46
50
  }
47
51
 
@@ -49,7 +53,7 @@ function formatPercent(value) {
49
53
  if (!Number.isFinite(value)) return 0;
50
54
  const normalized = Math.min(Math.max(value, 0), 100);
51
55
  if (normalized >= 10) return Math.round(normalized);
52
- return normalized.toFixed(1).replace(/\.0$/, "");
56
+ return normalized.toFixed(1).replace(/\.0$/, '');
53
57
  }
54
58
 
55
59
  /**
@@ -0,0 +1,53 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import PropTypes from 'prop-types';
4
+
5
+ /**
6
+ * Renders an autocomplete suggestion panel as a boxed list below the image input.
7
+ * Displays a window of `maxVisible` items starting at `visibleOffset`.
8
+ * The focused row (selectedIndex) is highlighted cyan with a › prefix.
9
+ *
10
+ * @param {Object} props
11
+ * @param {string[]} props.items - Full list of suggestion strings
12
+ * @param {number} props.selectedIndex - Currently focused index (-1 = none)
13
+ * @param {number} props.visibleOffset - First item index of the visible window
14
+ * @param {number} [props.maxVisible=6] - Maximum number of items visible at once
15
+ * @returns {JSX.Element|null}
16
+ */
17
+ export function SuggestionPanel({
18
+ items,
19
+ selectedIndex,
20
+ visibleOffset,
21
+ maxVisible = 6,
22
+ }) {
23
+ if (!items || items.length === 0) return null;
24
+
25
+ const visible = items.slice(visibleOffset, visibleOffset + maxVisible);
26
+
27
+ return (
28
+ <Box
29
+ flexDirection="column"
30
+ borderStyle="single"
31
+ borderColor="cyan"
32
+ paddingX={1}
33
+ data-testid="suggestion-panel"
34
+ >
35
+ {visible.map((item, localIdx) => {
36
+ const globalIdx = visibleOffset + localIdx;
37
+ const isFocused = globalIdx === selectedIndex;
38
+ return (
39
+ <Text key={item} color={isFocused ? 'cyan' : undefined} dimColor={!isFocused}>
40
+ {isFocused ? `› ${item}` : ` ${item}`}
41
+ </Text>
42
+ );
43
+ })}
44
+ </Box>
45
+ );
46
+ }
47
+
48
+ SuggestionPanel.propTypes = {
49
+ items: PropTypes.arrayOf(PropTypes.string).isRequired,
50
+ selectedIndex: PropTypes.number.isRequired,
51
+ visibleOffset: PropTypes.number.isRequired,
52
+ maxVisible: PropTypes.number,
53
+ };
@@ -1,16 +1,16 @@
1
- import React from "react";
2
- import { Box, Text } from "ink";
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
3
 
4
4
  const SHORTCUTS = [
5
- "Use ↑/↓ for navigation",
6
- "I to initiate selected container",
7
- "P to stop selected container",
8
- "R to restart selected container",
9
- "C to create a container",
10
- "L to view logs of selected container",
11
- "D to toggle debug log panel",
12
- "E to remove selected container",
13
- "Q to quit",
5
+ 'Use ↑/↓ for navigation',
6
+ 'I to initiate selected container',
7
+ 'P to stop selected container',
8
+ 'R to restart selected container',
9
+ 'C to create a container',
10
+ 'L to view logs of selected container',
11
+ 'D to toggle debug log panel',
12
+ 'E to remove selected container',
13
+ 'Q to quit',
14
14
  ];
15
15
 
16
16
  const GRID_COLUMNS = 3;
@@ -24,7 +24,10 @@ const GRID_COLUMNS = 3;
24
24
  export default function UsageMenu() {
25
25
  const rowsPerColumn = Math.ceil(SHORTCUTS.length / GRID_COLUMNS);
26
26
  const columns = Array.from({ length: GRID_COLUMNS }, (_, columnIndex) =>
27
- SHORTCUTS.slice(columnIndex * rowsPerColumn, (columnIndex + 1) * rowsPerColumn)
27
+ SHORTCUTS.slice(
28
+ columnIndex * rowsPerColumn,
29
+ (columnIndex + 1) * rowsPerColumn
30
+ )
28
31
  ).filter((column) => column.length > 0);
29
32
 
30
33
  return (
@@ -37,7 +40,9 @@ export default function UsageMenu() {
37
40
  marginRight={columnIndex === columns.length - 1 ? 0 : 3}
38
41
  >
39
42
  {column.map((shortcut, rowIndex) => (
40
- <Text key={`shortcut-${columnIndex}-${rowIndex}`}>{shortcut}</Text>
43
+ <Text key={`shortcut-${columnIndex}-${rowIndex}`}>
44
+ {shortcut}
45
+ </Text>
41
46
  ))}
42
47
  </Box>
43
48
  ))}
@@ -1,6 +1,6 @@
1
- import { readFileSync } from "fs";
2
- import path from "path";
3
- import { fileURLToPath } from "url";
1
+ import { readFileSync } from 'fs';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
4
 
5
5
  let cachedVersion;
6
6
 
@@ -12,12 +12,12 @@ export function getAppVersion() {
12
12
  try {
13
13
  const __filename = fileURLToPath(import.meta.url);
14
14
  const moduleDir = path.dirname(__filename);
15
- const packageJsonPath = path.resolve(moduleDir, "../../package.json");
16
- const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
17
- cachedVersion = packageJson.version ?? "unknown";
15
+ const packageJsonPath = path.resolve(moduleDir, '../../package.json');
16
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
17
+ cachedVersion = packageJson.version ?? 'unknown';
18
18
  return cachedVersion;
19
19
  } catch (error) {
20
- cachedVersion = "unknown";
20
+ cachedVersion = 'unknown';
21
21
  return cachedVersion;
22
22
  }
23
23
  }
@@ -32,19 +32,40 @@ export const TIMEOUTS = {
32
32
  /**
33
33
  * List of well-known database image names (without tags or registry prefixes).
34
34
  */
35
- export const DB_IMAGES = ["mysql", "mariadb", "postgres", "mongo", "mssql", "redis"];
35
+ export const DB_IMAGES = [
36
+ 'mysql',
37
+ 'mariadb',
38
+ 'postgres',
39
+ 'mongo',
40
+ 'mssql',
41
+ 'redis',
42
+ ];
36
43
 
37
44
  /**
38
- * Per-image profiles with required env vars and default exposed port.
45
+ * Per-image profiles with required env vars, default exposed port, and suggested env vars.
39
46
  * Keys are normalized image names (no tag, no registry prefix).
40
47
  *
41
- * @type {Record<string, { requiredEnv: string[], defaultPort: string }>}
48
+ * @type {Record<string, { requiredEnv: string[], defaultPort: string, suggestedEnv: string[] }>}
42
49
  */
43
50
  export const IMAGE_PROFILES = {
44
- mysql: { requiredEnv: ["MYSQL_ROOT_PASSWORD"], defaultPort: "3306" },
45
- mariadb: { requiredEnv: ["MARIADB_ROOT_PASSWORD"], defaultPort: "3306" },
46
- postgres: { requiredEnv: ["POSTGRES_PASSWORD"], defaultPort: "5432" },
47
- mongo: { requiredEnv: [], defaultPort: "27017" },
48
- mssql: { requiredEnv: ["ACCEPT_EULA", "SA_PASSWORD"], defaultPort: "1433" },
49
- redis: { requiredEnv: [], defaultPort: "6379" },
51
+ nginx: { requiredEnv: [], defaultPort: '80', suggestedEnv: [] },
52
+ httpd: { requiredEnv: [], defaultPort: '80', suggestedEnv: [] },
53
+ caddy: { requiredEnv: [], defaultPort: '80', suggestedEnv: [] },
54
+ node: { requiredEnv: [], defaultPort: '3000', suggestedEnv: ['NODE_ENV=production'] },
55
+ python: { requiredEnv: [], defaultPort: '8000', suggestedEnv: ['PYTHONUNBUFFERED=1'] },
56
+ openjdk: { requiredEnv: [], defaultPort: '8080', suggestedEnv: ['JAVA_OPTS=-Xms256m -Xmx512m'] },
57
+ golang: { requiredEnv: [], defaultPort: '8080', suggestedEnv: ['GOMAXPROCS=2'] },
58
+ redis: { requiredEnv: [], defaultPort: '6379', suggestedEnv: ['REDIS_PASSWORD=change-me'] },
59
+ memcached: { requiredEnv: [], defaultPort: '11211', suggestedEnv: [] },
60
+ postgres: { requiredEnv: ['POSTGRES_PASSWORD'], defaultPort: '5432', suggestedEnv: ['POSTGRES_USER=postgres', 'POSTGRES_DB=app'] },
61
+ mysql: { requiredEnv: ['MYSQL_ROOT_PASSWORD'], defaultPort: '3306', suggestedEnv: ['MYSQL_DATABASE=app', 'MYSQL_USER=app', 'MYSQL_PASSWORD=app123'] },
62
+ mariadb: { requiredEnv: ['MARIADB_ROOT_PASSWORD'], defaultPort: '3306', suggestedEnv: ['MARIADB_DATABASE=app', 'MARIADB_USER=app', 'MARIADB_PASSWORD=app123'] },
63
+ mongo: { requiredEnv: [], defaultPort: '27017', suggestedEnv: ['MONGO_INITDB_ROOT_USERNAME=admin', 'MONGO_INITDB_ROOT_PASSWORD=secret'] },
64
+ mssql: { requiredEnv: ['ACCEPT_EULA', 'SA_PASSWORD'], defaultPort: '1433', suggestedEnv: ['ACCEPT_EULA=Y', 'MSSQL_PID=Developer'] },
65
+ rabbitmq: { requiredEnv: [], defaultPort: '5672', suggestedEnv: ['RABBITMQ_DEFAULT_USER=guest', 'RABBITMQ_DEFAULT_PASS=guest'] },
66
+ kafka: { requiredEnv: [], defaultPort: '9092', suggestedEnv: ['KAFKA_CFG_LISTENERS=PLAINTEXT://:9092', 'KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092'] },
67
+ zookeeper: { requiredEnv: [], defaultPort: '2181', suggestedEnv: ['ALLOW_ANONYMOUS_LOGIN=yes'] },
68
+ elasticsearch: { requiredEnv: [], defaultPort: '9200', suggestedEnv: ['discovery.type=single-node', 'xpack.security.enabled=false'] },
69
+ minio: { requiredEnv: ['MINIO_ROOT_USER', 'MINIO_ROOT_PASSWORD'], defaultPort: '9000', suggestedEnv: ['MINIO_CONSOLE_ADDRESS=:9001'] },
70
+ wordpress: { requiredEnv: ['WORDPRESS_DB_HOST', 'WORDPRESS_DB_USER', 'WORDPRESS_DB_PASSWORD', 'WORDPRESS_DB_NAME'], defaultPort: '8080', suggestedEnv: ['WORDPRESS_TABLE_PREFIX=wp_'] },
50
71
  };
@@ -13,15 +13,26 @@
13
13
  * (e.g. "NODE_ENV=production,PORT=3000")
14
14
  * @returns {object} Docker container options ready to pass to createContainer
15
15
  */
16
- export function buildContainerOptions({ imageName: _imageName, containerName, portInput, envInput }) {
17
- const env = (envInput || "").split(",").map(s => s.trim()).filter(Boolean);
18
- const ports = (portInput || "").split(",").map(s => s.trim()).filter(Boolean);
16
+ export function buildContainerOptions({
17
+ imageName: _imageName,
18
+ containerName,
19
+ portInput,
20
+ envInput,
21
+ }) {
22
+ const env = (envInput || '')
23
+ .split(',')
24
+ .map((s) => s.trim())
25
+ .filter(Boolean);
26
+ const ports = (portInput || '')
27
+ .split(',')
28
+ .map((s) => s.trim())
29
+ .filter(Boolean);
19
30
 
20
31
  const ExposedPorts = {};
21
32
  const PortBindings = {};
22
33
 
23
- ports.forEach(pair => {
24
- const [host, cont] = pair.split(":");
34
+ ports.forEach((pair) => {
35
+ const [host, cont] = pair.split(':');
25
36
  if (!host || !cont) return;
26
37
  const key = `${cont}/tcp`;
27
38
  ExposedPorts[key] = {};
@@ -1,4 +1,4 @@
1
- import Docker from "dockerode";
1
+ import Docker from 'dockerode';
2
2
 
3
3
  // Use default dockerode configuration which automatically handles:
4
4
  // - /var/run/docker.sock on Linux/Mac
@@ -1,7 +1,8 @@
1
- import { docker } from "../dockerService.js";
2
- import { imageExists, pullImage } from "./imageUtils.js";
3
- import { TIMEOUTS, IMAGE_PROFILES } from "../../constants.js";
4
- import { logger } from "../../logger.js";
1
+ import { docker } from '../dockerService.js';
2
+ import { imageExists, pullImage } from './imageUtils.js';
3
+ import { TIMEOUTS, IMAGE_PROFILES } from '../../constants.js';
4
+ import { logger } from '../../logger.js';
5
+ import { normalizeImageName } from '../../imageNameUtils.js';
5
6
 
6
7
  /**
7
8
  * Helper to add timeout to promises. If the provided promise does not settle
@@ -15,9 +16,9 @@ import { logger } from "../../logger.js";
15
16
  function withTimeout(promise, ms = TIMEOUTS.CONTAINER_OP) {
16
17
  return Promise.race([
17
18
  promise,
18
- new Promise((_, reject) =>
19
+ new Promise((_, reject) =>
19
20
  setTimeout(() => reject(new Error('Operation timed out')), ms)
20
- )
21
+ ),
21
22
  ]);
22
23
  }
23
24
 
@@ -29,34 +30,17 @@ function withTimeout(promise, ms = TIMEOUTS.CONTAINER_OP) {
29
30
  * @throws {Error} If Docker reports an error
30
31
  */
31
32
  export async function removeContainer(containerId) {
32
- logger.debug("Removing container %s", containerId);
33
+ logger.debug('Removing container %s', containerId);
33
34
  const container = docker.getContainer(containerId);
34
35
  try {
35
36
  await withTimeout(container.remove({ force: true }), TIMEOUTS.CONTAINER_OP);
36
- logger.info("Removed container %s", containerId);
37
+ logger.info('Removed container %s', containerId);
37
38
  } catch (err) {
38
- logger.error("Failed to remove container %s", containerId, err);
39
+ logger.error('Failed to remove container %s', containerId, err);
39
40
  throw new Error('Error removing container: ' + err.message);
40
41
  }
41
42
  }
42
43
 
43
- /**
44
- * Normalize image name to base name (strips registry prefix and tag).
45
- * e.g. "docker.io/library/postgres:16" → "postgres"
46
- *
47
- * @param {string} imageName - Raw image name
48
- * @returns {string} Base name (lowercase)
49
- */
50
- function normalizeImageName(imageName) {
51
- if (!imageName) return "";
52
- let name = imageName;
53
- const slashIdx = name.lastIndexOf("/");
54
- if (slashIdx !== -1) name = name.slice(slashIdx + 1);
55
- const colonIdx = name.indexOf(":");
56
- if (colonIdx !== -1) name = name.slice(0, colonIdx);
57
- return name.toLowerCase();
58
- }
59
-
60
44
  /**
61
45
  * Create a new container from an image. If the image is missing locally, it will be pulled.
62
46
  *
@@ -66,22 +50,26 @@ function normalizeImageName(imageName) {
66
50
  * @returns {Promise<{id: string, ports: Array<{containerPort: string, hostPort: string, protocol: string, source: string}>}>}
67
51
  * @throws {Error} If image listing/pull or creation fails
68
52
  */
69
- export async function createContainer(imageName, options = {}, imageProfiles = IMAGE_PROFILES) {
70
- logger.info("Creating container from image %s", imageName);
53
+ export async function createContainer(
54
+ imageName,
55
+ options = {},
56
+ imageProfiles = IMAGE_PROFILES
57
+ ) {
58
+ logger.info('Creating container from image %s', imageName);
71
59
  let exists;
72
60
  try {
73
61
  exists = await withTimeout(imageExists(imageName), 10000);
74
62
  } catch (err) {
75
- logger.error("Could not check local images for %s", imageName, err);
63
+ logger.error('Could not check local images for %s', imageName, err);
76
64
  throw new Error('Error listing local images: ' + err.message);
77
65
  }
78
66
  if (!exists) {
79
- logger.info("Image %s not found locally, pulling", imageName);
67
+ logger.info('Image %s not found locally, pulling', imageName);
80
68
  try {
81
69
  await withTimeout(pullImage(imageName), TIMEOUTS.PULL_IMAGE);
82
- logger.info("Pulled image %s", imageName);
70
+ logger.info('Pulled image %s', imageName);
83
71
  } catch (err) {
84
- logger.error("Failed to pull image %s", imageName, err);
72
+ logger.error('Failed to pull image %s', imageName, err);
85
73
  throw new Error('Could not pull image: ' + err.message);
86
74
  }
87
75
  }
@@ -91,7 +79,9 @@ export async function createContainer(imageName, options = {}, imageProfiles = I
91
79
  ...options,
92
80
  };
93
81
 
94
- const hasUserDefinedPorts = Boolean(createOpts.ExposedPorts && Object.keys(createOpts.ExposedPorts).length);
82
+ const hasUserDefinedPorts = Boolean(
83
+ createOpts.ExposedPorts && Object.keys(createOpts.ExposedPorts).length
84
+ );
95
85
 
96
86
  /** @type {Array<{containerPort: string, hostPort: string, protocol: string, source: string}>} */
97
87
  const assignedPorts = [];
@@ -99,11 +89,14 @@ export async function createContainer(imageName, options = {}, imageProfiles = I
99
89
  if (!hasUserDefinedPorts) {
100
90
  try {
101
91
  const image = docker.getImage(imageName);
102
- if (image && typeof image.inspect === "function") {
92
+ if (image && typeof image.inspect === 'function') {
103
93
  let usedHostPorts = new Set();
104
- if (typeof docker.listContainers === "function") {
94
+ if (typeof docker.listContainers === 'function') {
105
95
  try {
106
- const containers = await withTimeout(docker.listContainers({ all: true }), TIMEOUTS.CONTAINER_OP);
96
+ const containers = await withTimeout(
97
+ docker.listContainers({ all: true }),
98
+ TIMEOUTS.CONTAINER_OP
99
+ );
107
100
  containers.forEach((container) => {
108
101
  (container.Ports || []).forEach((portInfo) => {
109
102
  if (portInfo && portInfo.PublicPort) {
@@ -112,7 +105,10 @@ export async function createContainer(imageName, options = {}, imageProfiles = I
112
105
  });
113
106
  });
114
107
  } catch (err) {
115
- logger.warn("Could not inspect existing containers to auto-map ports", err);
108
+ logger.warn(
109
+ 'Could not inspect existing containers to auto-map ports',
110
+ err
111
+ );
116
112
  }
117
113
  }
118
114
  const reservePort = (port) => {
@@ -143,8 +139,14 @@ export async function createContainer(imageName, options = {}, imageProfiles = I
143
139
  return String(candidate);
144
140
  };
145
141
 
146
- const inspectData = await withTimeout(image.inspect(), TIMEOUTS.CONTAINER_OP);
147
- let exposed = inspectData?.Config?.ExposedPorts || inspectData?.ContainerConfig?.ExposedPorts || {};
142
+ const inspectData = await withTimeout(
143
+ image.inspect(),
144
+ TIMEOUTS.CONTAINER_OP
145
+ );
146
+ let exposed =
147
+ inspectData?.Config?.ExposedPorts ||
148
+ inspectData?.ContainerConfig?.ExposedPorts ||
149
+ {};
148
150
  let exposedKeys = Object.keys(exposed || {});
149
151
 
150
152
  // If no ExposedPorts in image, fall back to IMAGE_PROFILES defaultPort
@@ -161,45 +163,69 @@ export async function createContainer(imageName, options = {}, imageProfiles = I
161
163
  if (exposedKeys.length) {
162
164
  createOpts.ExposedPorts = createOpts.ExposedPorts || {};
163
165
  createOpts.HostConfig = { ...(createOpts.HostConfig || {}) };
164
- createOpts.HostConfig.PortBindings = { ...(createOpts.HostConfig.PortBindings || {}) };
166
+ createOpts.HostConfig.PortBindings = {
167
+ ...(createOpts.HostConfig.PortBindings || {}),
168
+ };
165
169
  exposedKeys.forEach((portKey) => {
166
170
  if (!createOpts.ExposedPorts[portKey]) {
167
171
  createOpts.ExposedPorts[portKey] = exposed[portKey] || {};
168
172
  }
169
- if (!createOpts.HostConfig.PortBindings[portKey] || !createOpts.HostConfig.PortBindings[portKey].length) {
170
- const parts = portKey.split("/");
173
+ if (
174
+ !createOpts.HostConfig.PortBindings[portKey] ||
175
+ !createOpts.HostConfig.PortBindings[portKey].length
176
+ ) {
177
+ const parts = portKey.split('/');
171
178
  const containerPort = parts[0];
172
- const protocol = parts[1] || "tcp";
179
+ const protocol = parts[1] || 'tcp';
173
180
  const hostPort = pickNextAvailablePort(containerPort);
174
- createOpts.HostConfig.PortBindings[portKey] = [{ HostPort: hostPort }];
175
- assignedPorts.push({ containerPort, hostPort, protocol, source: "auto" });
181
+ createOpts.HostConfig.PortBindings[portKey] = [
182
+ { HostPort: hostPort },
183
+ ];
184
+ assignedPorts.push({
185
+ containerPort,
186
+ hostPort,
187
+ protocol,
188
+ source: 'auto',
189
+ });
176
190
  }
177
191
  });
178
192
  }
179
193
  }
180
194
  } catch (err) {
181
- logger.warn("Could not inspect image %s for default ports", imageName, err);
195
+ logger.warn(
196
+ 'Could not inspect image %s for default ports',
197
+ imageName,
198
+ err
199
+ );
182
200
  }
183
201
  } else {
184
202
  // User-defined ports: collect them with source: "user"
185
203
  const portBindings = createOpts.HostConfig?.PortBindings || {};
186
204
  Object.entries(portBindings).forEach(([portKey, bindings]) => {
187
- const parts = portKey.split("/");
205
+ const parts = portKey.split('/');
188
206
  const containerPort = parts[0];
189
- const protocol = parts[1] || "tcp";
207
+ const protocol = parts[1] || 'tcp';
190
208
  (bindings || []).forEach((b) => {
191
- assignedPorts.push({ containerPort, hostPort: b.HostPort, protocol, source: "user" });
209
+ assignedPorts.push({
210
+ containerPort,
211
+ hostPort: b.HostPort,
212
+ protocol,
213
+ source: 'user',
214
+ });
192
215
  });
193
216
  });
194
217
  }
195
218
 
196
219
  try {
197
- const container = await withTimeout(docker.createContainer(createOpts), TIMEOUTS.CONTAINER_OP);
220
+ const container = await withTimeout(
221
+ docker.createContainer(createOpts),
222
+ TIMEOUTS.CONTAINER_OP
223
+ );
198
224
  const containerId = container.id || container.Id;
199
- logger.info("Created container %s from image %s", containerId, imageName);
225
+ logger.info('Created container %s from image %s', containerId, imageName);
200
226
  return { id: containerId, ports: assignedPorts };
201
227
  } catch (err) {
202
- logger.error("Failed to create container from image %s", imageName, err);
228
+ logger.error('Failed to create container from image %s', imageName, err);
203
229
  throw new Error('Error creating container: ' + err.message);
204
230
  }
205
231
  }
@@ -211,12 +237,12 @@ export async function createContainer(imageName, options = {}, imageProfiles = I
211
237
  */
212
238
  export async function startContainer(containerId) {
213
239
  const container = docker.getContainer(containerId);
214
- logger.debug("Starting container %s", containerId);
240
+ logger.debug('Starting container %s', containerId);
215
241
  try {
216
242
  await withTimeout(container.start(), TIMEOUTS.CONTAINER_OP);
217
- logger.info("Started container %s", containerId);
243
+ logger.info('Started container %s', containerId);
218
244
  } catch (err) {
219
- logger.error("Failed to start container %s", containerId, err);
245
+ logger.error('Failed to start container %s', containerId, err);
220
246
  throw err;
221
247
  }
222
248
  }
@@ -228,12 +254,12 @@ export async function startContainer(containerId) {
228
254
  */
229
255
  export async function stopContainer(containerId) {
230
256
  const container = docker.getContainer(containerId);
231
- logger.debug("Stopping container %s", containerId);
257
+ logger.debug('Stopping container %s', containerId);
232
258
  try {
233
259
  await withTimeout(container.stop(), TIMEOUTS.CONTAINER_OP);
234
- logger.info("Stopped container %s", containerId);
260
+ logger.info('Stopped container %s', containerId);
235
261
  } catch (err) {
236
- logger.error("Failed to stop container %s", containerId, err);
262
+ logger.error('Failed to stop container %s', containerId, err);
237
263
  throw err;
238
264
  }
239
265
  }
@@ -245,12 +271,12 @@ export async function stopContainer(containerId) {
245
271
  */
246
272
  export async function restartContainer(containerId) {
247
273
  const container = docker.getContainer(containerId);
248
- logger.debug("Restarting container %s", containerId);
274
+ logger.debug('Restarting container %s', containerId);
249
275
  try {
250
276
  await withTimeout(container.restart(), TIMEOUTS.CONTAINER_OP);
251
- logger.info("Restarted container %s", containerId);
277
+ logger.info('Restarted container %s', containerId);
252
278
  } catch (err) {
253
- logger.error("Failed to restart container %s", containerId, err);
279
+ logger.error('Failed to restart container %s', containerId, err);
254
280
  throw err;
255
281
  }
256
282
  }
@@ -1,7 +1,7 @@
1
- import { docker } from "../dockerService.js";
2
- import { logger } from "../../logger.js";
1
+ import { docker } from '../dockerService.js';
2
+ import { logger } from '../../logger.js';
3
3
 
4
- const DEFAULT_CONTAINER_NAME = "Unknown";
4
+ const DEFAULT_CONTAINER_NAME = 'Unknown';
5
5
 
6
6
  /**
7
7
  * Produce a safe, display-friendly container name from the raw Docker payload.
@@ -13,21 +13,28 @@ function normalizeContainerName(container) {
13
13
  const names = container?.Names;
14
14
 
15
15
  let rawName;
16
- if (Array.isArray(names) && names.length > 0 && typeof names[0] === "string") {
16
+ if (
17
+ Array.isArray(names) &&
18
+ names.length > 0 &&
19
+ typeof names[0] === 'string'
20
+ ) {
17
21
  rawName = names[0];
18
- } else if (typeof names === "string" && names.trim().length > 0) {
22
+ } else if (typeof names === 'string' && names.trim().length > 0) {
19
23
  rawName = names;
20
- } else if (typeof container?.Name === "string" && container.Name.trim().length > 0) {
24
+ } else if (
25
+ typeof container?.Name === 'string' &&
26
+ container.Name.trim().length > 0
27
+ ) {
21
28
  rawName = container.Name;
22
29
  } else {
23
30
  rawName = DEFAULT_CONTAINER_NAME;
24
31
  }
25
32
 
26
- if (typeof rawName !== "string") {
33
+ if (typeof rawName !== 'string') {
27
34
  return DEFAULT_CONTAINER_NAME;
28
35
  }
29
36
 
30
- const cleanedName = rawName.replace(/^\/+/u, "").trim();
37
+ const cleanedName = rawName.replace(/^\/+/u, '').trim();
31
38
  return cleanedName.length > 0 ? cleanedName : DEFAULT_CONTAINER_NAME;
32
39
  }
33
40
 
@@ -36,33 +43,32 @@ function normalizeContainerName(container) {
36
43
  * @returns {Promise<Array<Object>>}
37
44
  */
38
45
  export async function getContainers() {
39
- logger.debug("Listing containers");
46
+ logger.debug('Listing containers');
40
47
  try {
41
48
  const containers = await docker.listContainers({ all: true });
42
- logger.debug("Retrieved %d containers", containers.length);
49
+ logger.debug('Retrieved %d containers', containers.length);
43
50
  return containers.map((container) => ({
44
51
  id: container.Id,
45
52
  name: normalizeContainerName(container),
46
53
  image: container.Image,
47
54
  state: container.State,
48
55
  status: container.Status,
49
- ports:
50
- (() => {
51
- const publicPorts = container.Ports.filter((port) => port.PublicPort).map(
52
- (port) => `${port.PublicPort}:${port.PrivatePort}`
53
- );
54
- if (publicPorts.length > 0) {
55
- return [...new Set(publicPorts)];
56
- }
57
- // Fall back to exposed private ports when there are no bindings.
58
- const privatePorts = container.Ports.filter((port) => port.PrivatePort).map(
59
- (port) => `${port.PrivatePort}`
60
- );
61
- return [...new Set(privatePorts)];
62
- })()
56
+ ports: (() => {
57
+ const publicPorts = container.Ports.filter(
58
+ (port) => port.PublicPort
59
+ ).map((port) => `${port.PublicPort}:${port.PrivatePort}`);
60
+ if (publicPorts.length > 0) {
61
+ return [...new Set(publicPorts)];
62
+ }
63
+ // Fall back to exposed private ports when there are no bindings.
64
+ const privatePorts = container.Ports.filter(
65
+ (port) => port.PrivatePort
66
+ ).map((port) => `${port.PrivatePort}`);
67
+ return [...new Set(privatePorts)];
68
+ })(),
63
69
  }));
64
70
  } catch (err) {
65
- logger.error("Failed to list containers", err);
71
+ logger.error('Failed to list containers', err);
66
72
  throw err;
67
73
  }
68
74
  }