cdd-cli 3.2.0 → 4.0.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/.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/.~lock.ROADMAP.md# +1 -0
  7. package/CHANGELOG.md +80 -0
  8. package/README.es.md +198 -0
  9. package/README.md +81 -23
  10. package/ROADMAP.md +647 -0
  11. package/SECURITY.md +5 -0
  12. package/__mocks__/ink.cjs +17 -0
  13. package/coverage/clover.xml +471 -500
  14. package/coverage/coverage-final.json +16 -16
  15. package/coverage/lcov-report/ContainerCreationPrompt.jsx.html +400 -0
  16. package/coverage/lcov-report/ControlsHUD.jsx.html +256 -0
  17. package/coverage/lcov-report/components/ContainerCreationPrompt.jsx.html +394 -0
  18. package/coverage/lcov-report/components/PromptField.jsx.html +229 -0
  19. package/coverage/lcov-report/components/SuggestionPanel.jsx.html +268 -0
  20. package/coverage/lcov-report/components/index.html +146 -0
  21. package/coverage/lcov-report/helpers/constants.js.html +139 -22
  22. package/coverage/lcov-report/helpers/containerOptionsBuilder.js.html +75 -42
  23. package/coverage/lcov-report/helpers/dockerHubService.js.html +310 -0
  24. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerActions.js.html +162 -84
  25. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerList.js.html +50 -32
  26. package/coverage/lcov-report/helpers/dockerService/serviceComponents/index.html +21 -21
  27. package/coverage/lcov-report/helpers/imageNameUtils.js.html +181 -0
  28. package/coverage/lcov-report/helpers/index.html +84 -54
  29. package/coverage/lcov-report/helpers/logger.js.html +68 -68
  30. package/coverage/lcov-report/helpers/safeCall.js.html +15 -15
  31. package/coverage/lcov-report/helpers/validationHelpers.js.html +66 -114
  32. package/coverage/lcov-report/hooks/creation/index.html +20 -20
  33. package/coverage/lcov-report/hooks/creation/useContainerActions.js.html +38 -17
  34. package/coverage/lcov-report/hooks/creation/useContainerCreation.js.html +593 -89
  35. package/coverage/lcov-report/hooks/creation/useLogsViewer.js.html +7 -7
  36. package/coverage/lcov-report/hooks/debug/index.html +5 -5
  37. package/coverage/lcov-report/hooks/debug/useDebugLogs.js.html +35 -20
  38. package/coverage/lcov-report/hooks/index.html +40 -40
  39. package/coverage/lcov-report/hooks/navigation/index.html +19 -19
  40. package/coverage/lcov-report/hooks/navigation/useContainerSelection.js.html +38 -29
  41. package/coverage/lcov-report/hooks/useContainerCommandRouter.js.html +119 -80
  42. package/coverage/lcov-report/hooks/useControls.js.html +306 -123
  43. package/coverage/lcov-report/hooks/useEraseConfirmation.js.html +8 -8
  44. package/coverage/lcov-report/hooks/useExitHandler.js.html +50 -41
  45. package/coverage/lcov-report/index.html +52 -67
  46. package/coverage/lcov.info +899 -952
  47. package/dist/App.js +18 -12
  48. package/dist/components/ContainerCreationPrompt.js +50 -13
  49. package/dist/components/ContainerList.js +3 -3
  50. package/dist/components/ContainerRow.js +20 -20
  51. package/dist/components/ContainerSection.js +3 -3
  52. package/dist/components/ControlsHUD.js +106 -0
  53. package/dist/components/Footer.js +2 -2
  54. package/dist/components/Header.js +7 -7
  55. package/dist/components/LogViewer.js +3 -3
  56. package/dist/components/MessageFeedback.js +2 -2
  57. package/dist/components/PromptField.js +4 -4
  58. package/dist/components/StatsBar.js +5 -5
  59. package/dist/components/SuggestionPanel.js +53 -0
  60. package/dist/components/UsageMenu.js +3 -3
  61. package/dist/helpers/appInfo.js +7 -7
  62. package/dist/helpers/constants.js +154 -1
  63. package/dist/helpers/containerOptionsBuilder.js +3 -3
  64. package/dist/helpers/dockerHubService.js +111 -0
  65. package/dist/helpers/dockerService/dockerService.js +1 -1
  66. package/dist/helpers/dockerService/serviceComponents/containerActions.js +99 -40
  67. package/dist/helpers/dockerService/serviceComponents/containerList.js +11 -11
  68. package/dist/helpers/dockerService/serviceComponents/containerLogs.js +9 -9
  69. package/dist/helpers/dockerService/serviceComponents/containerStats.js +5 -5
  70. package/dist/helpers/dockerService/serviceComponents/imageUtils.js +1 -1
  71. package/dist/helpers/exitWithMessage.js +10 -10
  72. package/dist/helpers/imageNameUtils.js +32 -0
  73. package/dist/helpers/logger.js +13 -13
  74. package/dist/helpers/safeCall.js +2 -2
  75. package/dist/helpers/validationHelpers.js +76 -21
  76. package/dist/hooks/creation/useContainerActions.js +28 -11
  77. package/dist/hooks/creation/useContainerCreation.js +452 -62
  78. package/dist/hooks/creation/useLogsViewer.js +2 -2
  79. package/dist/hooks/debug/useDebugLogs.js +4 -4
  80. package/dist/hooks/useContainerCommandRouter.js +14 -14
  81. package/dist/hooks/useContainers.js +3 -3
  82. package/dist/hooks/useControls.js +85 -38
  83. package/dist/hooks/useEraseConfirmation.js +3 -3
  84. package/dist/index.js +9 -2
  85. package/jest.config.cjs +7 -0
  86. package/package.json +7 -3
  87. package/src/App.jsx +39 -16
  88. package/src/components/ContainerCreationPrompt.jsx +54 -10
  89. package/src/components/ContainerList.jsx +3 -3
  90. package/src/components/ContainerRow.jsx +19 -16
  91. package/src/components/ContainerSection.jsx +3 -3
  92. package/src/components/ControlsHUD.jsx +66 -0
  93. package/src/components/Footer.jsx +2 -2
  94. package/src/components/Header.jsx +7 -7
  95. package/src/components/LogViewer.jsx +5 -3
  96. package/src/components/MessageFeedback.jsx +2 -2
  97. package/src/components/PromptField.jsx +4 -4
  98. package/src/components/StatsBar.jsx +11 -7
  99. package/src/components/SuggestionPanel.jsx +61 -0
  100. package/src/components/UsageMenu.jsx +18 -13
  101. package/src/helpers/appInfo.js +7 -7
  102. package/src/helpers/constants.js +48 -9
  103. package/src/helpers/containerOptionsBuilder.js +16 -5
  104. package/src/helpers/dockerHubService.js +75 -0
  105. package/src/helpers/dockerService/dockerService.js +1 -1
  106. package/src/helpers/dockerService/serviceComponents/containerActions.js +87 -61
  107. package/src/helpers/dockerService/serviceComponents/containerList.js +31 -25
  108. package/src/helpers/dockerService/serviceComponents/containerLogs.js +41 -26
  109. package/src/helpers/dockerService/serviceComponents/containerStats.js +13 -11
  110. package/src/helpers/dockerService/serviceComponents/imageUtils.js +5 -4
  111. package/src/helpers/exitWithMessage.js +13 -7
  112. package/src/helpers/imageNameUtils.js +32 -0
  113. package/src/helpers/logger.js +15 -16
  114. package/src/helpers/safeCall.js +2 -2
  115. package/src/helpers/validationHelpers.js +13 -29
  116. package/src/hooks/creation/useContainerActions.js +32 -12
  117. package/src/hooks/creation/useContainerCreation.js +293 -47
  118. package/src/hooks/creation/useLogsViewer.js +2 -2
  119. package/src/hooks/debug/useDebugLogs.js +15 -10
  120. package/src/hooks/navigation/useContainerSelection.js +17 -14
  121. package/src/hooks/useContainerCommandRouter.js +71 -58
  122. package/src/hooks/useContainers.js +4 -4
  123. package/src/hooks/useControls.js +135 -72
  124. package/src/hooks/useEraseConfirmation.js +3 -3
  125. package/src/hooks/useExitHandler.js +29 -26
  126. package/src/index.js +8 -4
  127. package/test/ContainerCreationPrompt.dom.test.js +313 -0
  128. package/test/ControlsHUD.dom.test.js +125 -0
  129. package/test/SuggestionPanel.dom.test.js +83 -0
  130. package/test/constants.test.js +41 -0
  131. package/test/dockerHubService.test.js +213 -0
  132. package/test/logger.test.js +58 -0
  133. package/test/resolveImageTag.test.js +26 -0
  134. package/test/useContainerCreation.dom.test.js +428 -2
  135. package/test/useContainerCreationHub.dom.test.js +184 -0
  136. package/test/useControls.dom.test.js +406 -4
@@ -1,14 +1,14 @@
1
- import React from "react";
2
- import { Box, Text } from "ink";
3
- import StatsBar from "./StatsBar.jsx";
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import StatsBar from './StatsBar.jsx';
4
4
  import PropTypes from 'prop-types';
5
5
  import { useContainerStats } from '../hooks/useContainerStats.js';
6
6
 
7
7
  const stateText = (state) => {
8
- if (state === "running") return { text: "🟢 RUNNING", color: "green" };
9
- if (state === "exited") return { text: "🔴 EXITED", color: "red" };
10
- if (state === "paused") return { text: "🟠 PAUSED", color: "yellow" };
11
- return { text: state.toUpperCase(), color: "gray" };
8
+ if (state === 'running') return { text: '🟢 RUNNING', color: 'green' };
9
+ if (state === 'exited') return { text: '🔴 EXITED', color: 'red' };
10
+ if (state === 'paused') return { text: '🟠 PAUSED', color: 'yellow' };
11
+ return { text: state.toUpperCase(), color: 'gray' };
12
12
  };
13
13
 
14
14
  /**
@@ -24,17 +24,17 @@ export default function ContainerRow({ container, isSelected = false }) {
24
24
 
25
25
  // Format ports for display (no leading space to avoid layout shifts)
26
26
  const formatPorts = (ports) => {
27
- if (!ports || ports.length === 0) return "";
27
+ if (!ports || ports.length === 0) return '';
28
28
  if (Array.isArray(ports)) {
29
- return ports.map((p, _i) => `🔗 ${p}`).join(" ");
29
+ return ports.map((p, _i) => `🔗 ${p}`).join(' ');
30
30
  }
31
31
  return `🔗 ${ports}`;
32
32
  };
33
33
 
34
34
  // Helper to truncate long strings to a max length without adding trailing spaces
35
35
  const truncate = (s, max = 20) => {
36
- if (!s) return "";
37
- return s.length > max ? s.slice(0, max - 1) + "" : s;
36
+ if (!s) return '';
37
+ return s.length > max ? s.slice(0, max - 1) + '' : s;
38
38
  };
39
39
 
40
40
  const stateInfo = stateText(state);
@@ -42,8 +42,8 @@ export default function ContainerRow({ container, isSelected = false }) {
42
42
  <Box flexDirection="column" marginBottom={1}>
43
43
  <Box flexDirection="row" alignItems="center">
44
44
  <Box width={2} minWidth={2} justifyContent="flex-end" paddingRight={1}>
45
- <Text color={isSelected ? "green" : undefined}>
46
- {isSelected ? "" : " "}
45
+ <Text color={isSelected ? 'green' : undefined}>
46
+ {isSelected ? '' : ' '}
47
47
  </Text>
48
48
  </Box>
49
49
  <Box width={18} flexShrink={1} paddingRight={1}>
@@ -59,8 +59,11 @@ export default function ContainerRow({ container, isSelected = false }) {
59
59
  <Text color="yellow">{formatPorts(container.ports)}</Text>
60
60
  </Box>
61
61
  <Box flexShrink={0} paddingLeft={1}>
62
- {state === "running" ? (
63
- <StatsBar cpu={parseFloat(stats.cpuPercent)} mem={parseFloat(stats.memPercent)} />
62
+ {state === 'running' ? (
63
+ <StatsBar
64
+ cpu={parseFloat(stats.cpuPercent)}
65
+ mem={parseFloat(stats.memPercent)}
66
+ />
64
67
  ) : null}
65
68
  </Box>
66
69
  </Box>
@@ -75,7 +78,7 @@ ContainerRow.propTypes = {
75
78
  name: PropTypes.string.isRequired,
76
79
  image: PropTypes.string,
77
80
  state: PropTypes.string,
78
- ports: PropTypes.oneOfType([PropTypes.array, PropTypes.string])
81
+ ports: PropTypes.oneOfType([PropTypes.array, PropTypes.string]),
79
82
  }).isRequired,
80
83
  isSelected: PropTypes.bool,
81
84
  };
@@ -1,6 +1,6 @@
1
- import React from "react";
2
- import { Text } from "ink";
3
- import ContainerList from "./ContainerList.jsx";
1
+ import React from 'react';
2
+ import { Text } from 'ink';
3
+ import ContainerList from './ContainerList.jsx';
4
4
  import PropTypes from 'prop-types';
5
5
 
6
6
  /**
@@ -0,0 +1,66 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ /**
5
+ * Returns the contextual keyboard hints for the current wizard state.
6
+ *
7
+ * @param {number} step - Current wizard step (0-3)
8
+ * @param {boolean} hasSuggestions - Whether suggestions/hub results are visible
9
+ * @param {boolean} isSearchingHub - Whether a Hub search is in progress
10
+ * @param {boolean} [hasSuggestedEnv=false] - Whether the image has suggestedEnv entries
11
+ * @returns {{ key: string, label: string }[]}
12
+ */
13
+ function getHints(step, hasSuggestions, isSearchingHub, hasSuggestedEnv = false) {
14
+ if (step === 0) {
15
+ if (hasSuggestions) {
16
+ return [
17
+ { key: '↑↓', label: 'Navigate' },
18
+ { key: 'Enter', label: 'Select' },
19
+ { key: 'Esc', label: 'Cancel' },
20
+ ];
21
+ }
22
+ const hints = [];
23
+ if (!isSearchingHub) hints.push({ key: 'Tab', label: 'Search Hub' });
24
+ hints.push({ key: '↑↓', label: 'Browse' });
25
+ hints.push({ key: 'Enter', label: 'Confirm' });
26
+ hints.push({ key: 'Esc', label: 'Cancel' });
27
+ return hints;
28
+ }
29
+ if (step === 3) {
30
+ const hints = [];
31
+ if (hasSuggestedEnv) hints.push({ key: 'Tab', label: 'Insert next env' });
32
+ hints.push({ key: 'Enter', label: 'Continue' });
33
+ hints.push({ key: 'Esc', label: 'Cancel' });
34
+ return hints;
35
+ }
36
+ return [
37
+ { key: 'Enter', label: 'Continue' },
38
+ { key: 'Esc', label: 'Cancel' },
39
+ ];
40
+ }
41
+
42
+ /**
43
+ * Displays a row of contextual keyboard shortcut hints for the active wizard step.
44
+ *
45
+ * @param {Object} props
46
+ * @param {number} props.step - Current wizard step
47
+ * @param {boolean} [props.hasSuggestions=false] - Whether suggestions/hub results are visible
48
+ * @param {boolean} [props.isSearchingHub=false] - Whether a Hub search is in progress
49
+ * @param {boolean} [props.hasSuggestedEnv=false] - Whether the image has suggestedEnv entries
50
+ */
51
+ function ControlsHUD({ step, hasSuggestions = false, isSearchingHub = false, hasSuggestedEnv = false }) {
52
+ const hints = getHints(step, hasSuggestions, isSearchingHub, hasSuggestedEnv);
53
+ return (
54
+ <Box flexDirection="row" columnGap={2}>
55
+ {hints.map(({ key, label }) => (
56
+ <Box key={key}>
57
+ <Text color="cyan">[{key}]</Text>
58
+ <Text dimColor> {label}</Text>
59
+ </Box>
60
+ ))}
61
+ </Box>
62
+ );
63
+ }
64
+
65
+ export default ControlsHUD;
66
+ export { getHints, ControlsHUD };
@@ -1,5 +1,5 @@
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
  /**
5
5
  * Simple footer displayed at the bottom of the interface.
@@ -1,8 +1,8 @@
1
- import React from "react";
2
- import { Box, Text } from "ink";
3
- import chalk from "chalk";
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import chalk from 'chalk';
4
4
  import PropTypes from 'prop-types';
5
- import { getAppVersion } from "../helpers/appInfo.js";
5
+ import { getAppVersion } from '../helpers/appInfo.js';
6
6
 
7
7
  /**
8
8
  * Header component displayed at the top of the CLI UI.
@@ -18,18 +18,18 @@ import { getAppVersion } from "../helpers/appInfo.js";
18
18
  const VERSION = getAppVersion();
19
19
 
20
20
  export default function Header({ count }) {
21
- const formattedVersion = VERSION === "unknown" ? "unknown" : `v${VERSION}`;
21
+ const formattedVersion = VERSION === 'unknown' ? 'unknown' : `v${VERSION}`;
22
22
 
23
23
  return (
24
24
  <Box justifyContent="space-between">
25
25
  <Text color="cyanBright">
26
- 🐳 {chalk.bold("CDD")}
26
+ 🐳 {chalk.bold('CDD')}
27
27
  <Text color="gray"> — CLI Docker Dashboard</Text>
28
28
  </Text>
29
29
  <Box flexDirection="column" alignItems="flex-end">
30
30
  <Text color="gray">{formattedVersion}</Text>
31
31
  <Text color="gray">
32
- {count} container{count === 1 ? "" : "s"} found
32
+ {count} container{count === 1 ? '' : 's'} found
33
33
  </Text>
34
34
  </Box>
35
35
  </Box>
@@ -1,5 +1,5 @@
1
- import React from "react";
2
- import { Text } from "ink";
1
+ import React from 'react';
2
+ import { Text } from 'ink';
3
3
  import PropTypes from 'prop-types';
4
4
 
5
5
  /**
@@ -17,7 +17,9 @@ export default function LogViewer({ logs, onExit: _onExit, container }) {
17
17
 
18
18
  return (
19
19
  <>
20
- <Text color="green">{container?.name ?? "Container"} logs, press ESC to exit</Text>
20
+ <Text color="green">
21
+ {container?.name ?? 'Container'} logs, press ESC to exit
22
+ </Text>
21
23
  {visibleLogs.length === 0 ? (
22
24
  <Text dimColor>No logs...</Text>
23
25
  ) : (
@@ -1,5 +1,5 @@
1
- import React from "react";
2
- import { Box, Text } from "ink";
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
3
  import PropTypes from 'prop-types';
4
4
 
5
5
  /**
@@ -1,5 +1,5 @@
1
- import React from "react";
2
- import { Text } from "ink";
1
+ import React from 'react';
2
+ import { Text } from 'ink';
3
3
  import PropTypes from 'prop-types';
4
4
 
5
5
  /**
@@ -18,7 +18,7 @@ export function PromptField({ label, value, required }) {
18
18
  return (
19
19
  <>
20
20
  <Text>{label}</Text>
21
- <Text color={isEmpty ? "red" : "cyan"}>{value}_</Text>
21
+ <Text color={isEmpty ? 'red' : 'cyan'}>{value}_</Text>
22
22
  </>
23
23
  );
24
24
  }
@@ -33,7 +33,7 @@ export function PromptField({ label, value, required }) {
33
33
  */
34
34
  export function PromptMessage({ message, color }) {
35
35
  if (!message) return null;
36
- return <Text color={color || "yellow"}>{message}</Text>;
36
+ return <Text color={color || 'yellow'}>{message}</Text>;
37
37
  }
38
38
 
39
39
  PromptField.propTypes = {
@@ -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,61 @@
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
+ isLoading = false,
23
+ }) {
24
+ const hasItems = items && items.length > 0;
25
+ if (!hasItems && !isLoading) return null;
26
+
27
+ const visible = hasItems ? items.slice(visibleOffset, visibleOffset + maxVisible) : [];
28
+
29
+ return (
30
+ <Box
31
+ flexDirection="column"
32
+ borderStyle="single"
33
+ borderColor="cyan"
34
+ paddingX={1}
35
+ data-testid="suggestion-panel"
36
+ >
37
+ {visible.map((item, localIdx) => {
38
+ const globalIdx = visibleOffset + localIdx;
39
+ const isFocused = globalIdx === selectedIndex;
40
+ return (
41
+ <Text key={item} color={isFocused ? 'cyan' : undefined} dimColor={!isFocused}>
42
+ {isFocused ? `› ${item}` : ` ${item}`}
43
+ </Text>
44
+ );
45
+ })}
46
+ {isLoading && (
47
+ <Text dimColor color="gray">
48
+ {' [searching Docker Hub...]'}
49
+ </Text>
50
+ )}
51
+ </Box>
52
+ );
53
+ }
54
+
55
+ SuggestionPanel.propTypes = {
56
+ items: PropTypes.arrayOf(PropTypes.string).isRequired,
57
+ selectedIndex: PropTypes.number.isRequired,
58
+ visibleOffset: PropTypes.number.isRequired,
59
+ maxVisible: PropTypes.number,
60
+ isLoading: PropTypes.bool,
61
+ };
@@ -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,58 @@ 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: [], defaultTag: '1.27-alpine' },
52
+ httpd: { requiredEnv: [], defaultPort: '80', suggestedEnv: [], defaultTag: '2.4-alpine' },
53
+ caddy: { requiredEnv: [], defaultPort: '80', suggestedEnv: [], defaultTag: '2-alpine' },
54
+ node: { requiredEnv: [], defaultPort: '3000', suggestedEnv: ['NODE_ENV=production'], defaultTag: '22-alpine' },
55
+ python: { requiredEnv: [], defaultPort: '8000', suggestedEnv: ['PYTHONUNBUFFERED=1'], defaultTag: '3.12-slim' },
56
+ openjdk: { requiredEnv: [], defaultPort: '8080', suggestedEnv: ['JAVA_OPTS=-Xms256m -Xmx512m'], defaultTag: '21-slim' },
57
+ golang: { requiredEnv: [], defaultPort: '8080', suggestedEnv: ['GOMAXPROCS=2'], defaultTag: '1.22-alpine' },
58
+ redis: { requiredEnv: [], defaultPort: '6379', suggestedEnv: ['REDIS_PASSWORD=change-me'], defaultTag: '7-alpine' },
59
+ memcached: { requiredEnv: [], defaultPort: '11211', suggestedEnv: [], defaultTag: '1.6-alpine' },
60
+ postgres: { requiredEnv: ['POSTGRES_PASSWORD'], defaultPort: '5432', suggestedEnv: ['POSTGRES_PASSWORD=secret', 'POSTGRES_USER=postgres', 'POSTGRES_DB=app'], defaultTag: '17-alpine' },
61
+ mysql: { requiredEnv: ['MYSQL_ROOT_PASSWORD'], defaultPort: '3306', suggestedEnv: ['MYSQL_ROOT_PASSWORD=secret', 'MYSQL_DATABASE=app', 'MYSQL_USER=app', 'MYSQL_PASSWORD=app123'], defaultTag: '8.0' },
62
+ mariadb: { requiredEnv: ['MARIADB_ROOT_PASSWORD'], defaultPort: '3306', suggestedEnv: ['MARIADB_ROOT_PASSWORD=secret', 'MARIADB_DATABASE=app', 'MARIADB_USER=app', 'MARIADB_PASSWORD=app123'], defaultTag: '11-alpine' },
63
+ mongo: { requiredEnv: [], defaultPort: '27017', suggestedEnv: ['MONGO_INITDB_ROOT_USERNAME=admin', 'MONGO_INITDB_ROOT_PASSWORD=secret'], defaultTag: '7.0' },
64
+ mssql: { requiredEnv: ['ACCEPT_EULA', 'SA_PASSWORD'], defaultPort: '1433', suggestedEnv: ['ACCEPT_EULA=Y', 'SA_PASSWORD=Strong!Passw0rd', 'MSSQL_PID=Developer'], defaultTag: '2022-latest' },
65
+ rabbitmq: { requiredEnv: [], defaultPort: '5672', suggestedEnv: ['RABBITMQ_DEFAULT_USER=guest', 'RABBITMQ_DEFAULT_PASS=guest'], defaultTag: '3-management-alpine' },
66
+ kafka: { requiredEnv: [], defaultPort: '9092', suggestedEnv: ['KAFKA_CFG_LISTENERS=PLAINTEXT://:9092', 'KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092'], defaultTag: '3.7' },
67
+ zookeeper: { requiredEnv: [], defaultPort: '2181', suggestedEnv: ['ALLOW_ANONYMOUS_LOGIN=yes'], defaultTag: '3.9' },
68
+ elasticsearch: { requiredEnv: [], defaultPort: '9200', suggestedEnv: ['discovery.type=single-node', 'xpack.security.enabled=false'], defaultTag: '8.13.0' },
69
+ minio: { requiredEnv: ['MINIO_ROOT_USER', 'MINIO_ROOT_PASSWORD'], defaultPort: '9000', suggestedEnv: ['MINIO_ROOT_USER=admin', 'MINIO_ROOT_PASSWORD=secret', 'MINIO_CONSOLE_ADDRESS=:9001'], defaultTag: 'latest' },
70
+ wordpress: { requiredEnv: ['WORDPRESS_DB_HOST', 'WORDPRESS_DB_USER', 'WORDPRESS_DB_PASSWORD', 'WORDPRESS_DB_NAME'], defaultPort: '8080', suggestedEnv: ['WORDPRESS_DB_HOST=db', 'WORDPRESS_DB_USER=wp', 'WORDPRESS_DB_PASSWORD=secret', 'WORDPRESS_DB_NAME=wordpress', 'WORDPRESS_TABLE_PREFIX=wp_'], defaultTag: '6.5-apache' },
50
71
  };
72
+
73
+ /**
74
+ * Given an image name (with or without tag), returns the image name with a
75
+ * default tag appended when the image matches a known profile and has no
76
+ * explicit tag. Returns the input unchanged otherwise.
77
+ *
78
+ * @param {string} imageName - e.g. 'postgres', 'postgres:15', 'unknownimage'
79
+ * @param {Record<string, { defaultTag: string }>} profiles
80
+ * @returns {string}
81
+ */
82
+ export function resolveImageTag(imageName, profiles = IMAGE_PROFILES) {
83
+ if (!imageName) return imageName;
84
+ if (imageName.includes(':')) return imageName;
85
+ const baseName = imageName.trim().toLowerCase().split('/').pop();
86
+ const profile = profiles[baseName];
87
+ if (!profile?.defaultTag) return imageName;
88
+ return `${imageName}:${profile.defaultTag}`;
89
+ }
@@ -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] = {};
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Docker Hub search helper.
3
+ * Pure function — no side effects beyond HTTP. All state management
4
+ * lives in the calling hook (useContainerCreation).
5
+ */
6
+
7
+ const DOCKER_HUB_SEARCH_URL =
8
+ 'https://hub.docker.com/v2/search/repositories/';
9
+
10
+ /**
11
+ * Format a Docker Hub search result object into a display string.
12
+ *
13
+ * Format rules:
14
+ * - Official: "name [★ official] [Npulls+]" (if pullCount > 0)
15
+ * - Non-official with pulls: "name [Npulls+]"
16
+ * - Otherwise: "name"
17
+ *
18
+ * Pull count suffixes: ≥1B → "1B+", ≥1M → "1M+", ≥1K → "1K+", else raw number
19
+ *
20
+ * @param {{ name: string, isOfficial: boolean, pullCount: number }} result
21
+ * @returns {string}
22
+ */
23
+ export function formatHubResult(result) {
24
+ const { name, isOfficial, pullCount } = result;
25
+
26
+ let pullSuffix = '';
27
+ if (pullCount >= 1e9) {
28
+ pullSuffix = ` [1B+ pulls]`;
29
+ } else if (pullCount >= 1e6) {
30
+ pullSuffix = ` [${Math.floor(pullCount / 1e6)}M+ pulls]`;
31
+ } else if (pullCount >= 1e3) {
32
+ pullSuffix = ` [${Math.floor(pullCount / 1e3)}K+ pulls]`;
33
+ } else if (pullCount > 0) {
34
+ pullSuffix = ` [${pullCount} pulls]`;
35
+ }
36
+
37
+ if (isOfficial) {
38
+ return `${name} [★ official]${pullSuffix}`;
39
+ }
40
+
41
+ return `${name}${pullSuffix}`;
42
+ }
43
+
44
+ /**
45
+ * Search Docker Hub for images matching the given query.
46
+ *
47
+ * @param {string} query - Search term (will be trimmed and encoded)
48
+ * @param {Object} [options]
49
+ * @param {AbortSignal} [options.signal] - Abort signal; defaults to AbortSignal.timeout(5000)
50
+ * @returns {Promise<Array<{ name: string, isOfficial: boolean, pullCount: number, description: string }>>}
51
+ * @throws {Error} If response.ok is false or the network call rejects
52
+ */
53
+ export async function searchDockerHub(query, options = {}) {
54
+ const q = query.trim();
55
+ if (!q) return [];
56
+
57
+ const url = `${DOCKER_HUB_SEARCH_URL}?query=${encodeURIComponent(q)}&page_size=10`;
58
+ const signal = options.signal ?? AbortSignal.timeout(5000);
59
+
60
+ const response = await fetch(url, { signal });
61
+
62
+ if (!response.ok) {
63
+ throw new Error(`Docker Hub search failed: ${response.status}`);
64
+ }
65
+
66
+ const data = await response.json();
67
+ const results = data.results ?? [];
68
+
69
+ return results.map((item) => ({
70
+ name: item.repo_name,
71
+ isOfficial: item.is_official,
72
+ pullCount: item.pull_count,
73
+ description: item.short_description,
74
+ }));
75
+ }
@@ -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