Skip to content

Commit 72ece32

Browse files
Merge pull request #33 from herdux/feat/docker-commands
feat: add hdx docker list/start/stop, fix inspect .tar support (v0.7.0)
2 parents 807b307 + ca3d0a8 commit 72ece32

File tree

12 files changed

+1086
-6
lines changed

12 files changed

+1086
-6
lines changed

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,8 @@ Summary:
254254

255255
**Exception — offline commands:** Commands that operate on local files without a live database connection (e.g., `inspect`) do NOT call `resolveEngineAndConnection()` or `checkClientVersion()`. They may use `src/infra/engines/` helpers directly, but must still keep all binary calls inside `infra/`.
256256

257+
**Exception — Docker commands:** `hdx docker` manages containers via the Docker daemon and does not use `IDatabaseEngine` or `resolveEngineAndConnection()`. All Docker binary calls go through `src/infra/docker/docker.service.ts` using `runCommand()` from `command-runner.ts`.
258+
257259
---
258260

259261
## ADDING A NEW ENGINE

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and
66

77
---
88

9+
## [0.7.0] - 2026-03-02
10+
11+
### Added
12+
13+
- `hdx docker list` command: lists running postgres, mysql, and mariadb containers. Displays name, engine type, mapped host port, and status in a table. Accepts `--all` to include stopped containers.
14+
- `hdx docker start <name>` command: starts a stopped database container.
15+
- `hdx docker stop <name>` command: stops a running container. Accepts `--remove` to also remove it after stopping.
16+
17+
---
18+
919
## [0.6.0] - 2026-03-02
1020

1121
### Added

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
A fast, interactive CLI that removes friction from daily local database workflows, especially when juggling multiple instances and large datasets.
1414

15-
![Version](https://img.shields.io/badge/version-0.6.0-blue.svg)
15+
![Version](https://img.shields.io/badge/version-0.7.0-blue.svg)
1616
![License](https://img.shields.io/badge/license-MIT-green.svg)
1717
![Node](https://img.shields.io/badge/node-18%2B-43853d.svg)
1818
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-316192?style=flat&logo=postgresql&logoColor=white)
@@ -242,6 +242,20 @@ hdx inspect mydb.db # SQLite schema
242242

243243
---
244244

245+
### `herdux docker`
246+
247+
Manages database containers running via Docker. Does not require a live database connection.
248+
249+
```bash
250+
hdx docker list # List running postgres/mysql containers
251+
hdx docker list --all # Include stopped containers
252+
hdx docker start pg-dev # Start a stopped container
253+
hdx docker stop pg-dev # Stop a running container
254+
hdx docker stop pg-dev --remove # Stop and remove the container
255+
```
256+
257+
---
258+
245259
## Configuration & Server Profiles
246260

247261
Configuration is stored at `~/.herdux/config.json`.

README.pt-BR.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
Uma CLI rápida e interativa que remove a fricção dos workflows diários com bancos de dados locais, especialmente ao lidar com múltiplas instâncias e grandes datasets.
1414

15-
![Version](https://img.shields.io/badge/version-0.6.0-blue.svg)
15+
![Version](https://img.shields.io/badge/version-0.7.0-blue.svg)
1616
![License](https://img.shields.io/badge/license-MIT-green.svg)
1717
![Node](https://img.shields.io/badge/node-18%2B-43853d.svg)
1818
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-316192?style=flat&logo=postgresql&logoColor=white)
@@ -242,6 +242,20 @@ hdx inspect mydb.db # Schema do SQLite
242242

243243
---
244244

245+
### `herdux docker`
246+
247+
Gerencia containers de banco de dados rodando via Docker. Nao requer conexao ativa com o banco.
248+
249+
```bash
250+
hdx docker list # Lista containers postgres/mysql em execucao
251+
hdx docker list --all # Inclui containers parados
252+
hdx docker start pg-dev # Inicia um container parado
253+
hdx docker stop pg-dev # Para um container em execucao
254+
hdx docker stop pg-dev --remove # Para e remove o container
255+
```
256+
257+
---
258+
245259
## Configuração e Perfis de Servidor
246260

247261
A configuração é armazenada em `~/.herdux/config.json`.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "herdux-cli",
3-
"version": "0.6.0",
3+
"version": "0.7.0",
44
"description": "Modern Database workflow CLI focused on developer experience, safety, and automation.",
55
"repository": {
66
"type": "git",

src/commands/docker.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import type { Command } from "commander";
2+
import chalk from "chalk";
3+
import ora from "ora";
4+
import {
5+
isDockerAvailable,
6+
listDatabaseContainers,
7+
startContainer,
8+
stopContainer,
9+
} from "../infra/docker/docker.service.js";
10+
11+
async function assertDockerAvailable(): Promise<void> {
12+
const available = await isDockerAvailable();
13+
if (!available) {
14+
console.error(
15+
chalk.red(
16+
"\n✖ Docker is not available. Make sure Docker is installed and the daemon is running.\n",
17+
),
18+
);
19+
process.exit(1);
20+
}
21+
}
22+
23+
export function registerDockerCommand(program: Command): void {
24+
const dockerCmd = program
25+
.command("docker")
26+
.helpCommand(false)
27+
.description("Manage database containers (postgres, mysql, mariadb)")
28+
.addHelpText(
29+
"after",
30+
`
31+
Examples:
32+
hdx docker list
33+
hdx docker list --all
34+
hdx docker start pg-dev
35+
hdx docker stop pg-dev
36+
hdx docker stop pg-dev --remove`,
37+
);
38+
39+
dockerCmd
40+
.command("list")
41+
.alias("ls")
42+
.description("List running database containers")
43+
.option("-a, --all", "Include stopped containers")
44+
.action(async (opts: { all?: boolean }) => {
45+
try {
46+
await assertDockerAvailable();
47+
48+
const spinner = ora("Fetching database containers...").start();
49+
const containers = await listDatabaseContainers(opts.all);
50+
51+
if (containers.length === 0) {
52+
spinner.warn(
53+
opts.all
54+
? "No database containers found"
55+
: "No running database containers found",
56+
);
57+
console.log(
58+
chalk.gray(
59+
" Only postgres, mysql, and mariadb images are listed.\n",
60+
),
61+
);
62+
return;
63+
}
64+
65+
const label = opts.all ? "container(s) found" : "running container(s)";
66+
spinner.succeed(`Found ${containers.length} ${label}\n`);
67+
68+
const nameWidth =
69+
Math.max(16, ...containers.map((c) => c.name.length)) + 2;
70+
const engineWidth = 12;
71+
const portWidth = 8;
72+
73+
const header = ` ${"NAME".padEnd(nameWidth)}${"ENGINE".padEnd(engineWidth)}${"PORT".padEnd(portWidth)}STATUS`;
74+
console.log(chalk.bold(header));
75+
console.log(
76+
chalk.gray(
77+
` ${"─".repeat(nameWidth + engineWidth + portWidth + 12)}`,
78+
),
79+
);
80+
81+
for (const c of containers) {
82+
const portDisplay = c.hostPort
83+
? chalk.cyan(c.hostPort.padEnd(portWidth))
84+
: chalk.gray("─".padEnd(portWidth));
85+
const isRunning = c.status.toLowerCase().startsWith("up");
86+
const statusDisplay = isRunning
87+
? chalk.green(c.status)
88+
: chalk.gray(c.status);
89+
console.log(
90+
` ${chalk.cyan(c.name.padEnd(nameWidth))}${c.engineType.padEnd(engineWidth)}${portDisplay}${statusDisplay}`,
91+
);
92+
}
93+
94+
console.log();
95+
} catch (err) {
96+
const message = err instanceof Error ? err.message : String(err);
97+
console.error(chalk.red(`\n✖ ${message}\n`));
98+
process.exit(1);
99+
}
100+
});
101+
102+
dockerCmd
103+
.command("start <name>")
104+
.description("Start a stopped database container")
105+
.action(async (name: string) => {
106+
try {
107+
await assertDockerAvailable();
108+
const spinner = ora(`Starting container "${name}"...`).start();
109+
await startContainer(name);
110+
spinner.succeed(`Container "${name}" started\n`);
111+
} catch (err) {
112+
const message = err instanceof Error ? err.message : String(err);
113+
console.error(chalk.red(`\n✖ ${message}\n`));
114+
process.exit(1);
115+
}
116+
});
117+
118+
dockerCmd
119+
.command("stop <name>")
120+
.description("Stop a running database container")
121+
.option("-r, --remove", "Remove the container after stopping")
122+
.action(async (name: string, opts: { remove?: boolean }) => {
123+
try {
124+
await assertDockerAvailable();
125+
const action = opts.remove
126+
? `Stopping and removing container "${name}"...`
127+
: `Stopping container "${name}"...`;
128+
const spinner = ora(action).start();
129+
await stopContainer(name, opts.remove);
130+
const done = opts.remove
131+
? `Container "${name}" stopped and removed\n`
132+
: `Container "${name}" stopped\n`;
133+
spinner.succeed(done);
134+
} catch (err) {
135+
const message = err instanceof Error ? err.message : String(err);
136+
console.error(chalk.red(`\n✖ ${message}\n`));
137+
process.exit(1);
138+
}
139+
});
140+
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { registerConfigCommand } from "./commands/config.js";
1212
import { registerCleanCommand } from "./commands/clean.js";
1313
import { registerDoctorCommand } from "./commands/doctor.js";
1414
import { registerInspectCommand } from "./commands/inspect.js";
15+
import { registerDockerCommand } from "./commands/docker.js";
1516

1617
import { join, dirname } from "path";
1718
import { fileURLToPath } from "url";
@@ -83,5 +84,6 @@ registerConfigCommand(program);
8384
registerCleanCommand(program);
8485
registerDoctorCommand(program);
8586
registerInspectCommand(program);
87+
registerDockerCommand(program);
8688

8789
program.parse();

src/infra/docker/docker.service.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { runCommand } from "../command-runner.js";
2+
import { binaryExists } from "../utils/detect-binary.js";
3+
4+
export interface DatabaseContainer {
5+
id: string;
6+
name: string;
7+
image: string;
8+
hostPort: string;
9+
containerPort: string;
10+
status: string;
11+
engineType: "postgres" | "mysql" | "unknown";
12+
}
13+
14+
const IMAGE_ENGINE_MAP: Record<
15+
string,
16+
{ engineType: "postgres" | "mysql"; containerPort: string }
17+
> = {
18+
postgres: { engineType: "postgres", containerPort: "5432" },
19+
mysql: { engineType: "mysql", containerPort: "3306" },
20+
mariadb: { engineType: "mysql", containerPort: "3306" },
21+
};
22+
23+
function detectEngine(image: string): {
24+
engineType: "postgres" | "mysql" | "unknown";
25+
containerPort: string;
26+
} {
27+
const lower = image.toLowerCase();
28+
for (const [key, value] of Object.entries(IMAGE_ENGINE_MAP)) {
29+
if (lower.includes(key)) return value;
30+
}
31+
return { engineType: "unknown", containerPort: "" };
32+
}
33+
34+
function parseHostPort(portsStr: string): string {
35+
// Matches patterns like: 0.0.0.0:5432->5432/tcp or :::3306->3306/tcp
36+
const match = portsStr.match(/:(\d+)->\d+\/tcp/);
37+
return match ? match[1] : "";
38+
}
39+
40+
export async function isDockerAvailable(): Promise<boolean> {
41+
const hasBinary = await binaryExists("docker");
42+
if (!hasBinary) return false;
43+
const result = await runCommand(
44+
"docker",
45+
["info", "--format", "{{.ServerVersion}}"],
46+
{ timeout: 5_000 },
47+
);
48+
return result.exitCode === 0;
49+
}
50+
51+
export async function listDatabaseContainers(
52+
showAll = false,
53+
): Promise<DatabaseContainer[]> {
54+
const args = [
55+
"ps",
56+
"--format",
57+
"{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Ports}}\t{{.Status}}",
58+
];
59+
if (showAll) args.push("--all");
60+
61+
const result = await runCommand("docker", args);
62+
if (result.exitCode !== 0) {
63+
throw new Error(result.stderr || "docker ps failed");
64+
}
65+
66+
const containers: DatabaseContainer[] = [];
67+
68+
for (const line of result.stdout.split("\n")) {
69+
if (!line.trim()) continue;
70+
71+
const parts = line.split("\t");
72+
const id = parts[0] ?? "";
73+
const name = parts[1] ?? "";
74+
const image = parts[2] ?? "";
75+
const ports = parts[3] ?? "";
76+
const status = parts.slice(4).join("\t");
77+
78+
const { engineType, containerPort } = detectEngine(image);
79+
if (engineType === "unknown") continue;
80+
81+
containers.push({
82+
id,
83+
name,
84+
image,
85+
hostPort: parseHostPort(ports),
86+
containerPort,
87+
status,
88+
engineType,
89+
});
90+
}
91+
92+
return containers;
93+
}
94+
95+
export async function startContainer(name: string): Promise<void> {
96+
const result = await runCommand("docker", ["start", name]);
97+
if (result.exitCode !== 0) {
98+
throw new Error(result.stderr || `Failed to start container "${name}"`);
99+
}
100+
}
101+
102+
export async function stopContainer(
103+
name: string,
104+
remove = false,
105+
): Promise<void> {
106+
const stopResult = await runCommand("docker", ["stop", name]);
107+
if (stopResult.exitCode !== 0) {
108+
throw new Error(stopResult.stderr || `Failed to stop container "${name}"`);
109+
}
110+
111+
if (remove) {
112+
const rmResult = await runCommand("docker", ["rm", name]);
113+
if (rmResult.exitCode !== 0) {
114+
throw new Error(
115+
rmResult.stderr || `Failed to remove container "${name}"`,
116+
);
117+
}
118+
}
119+
}

src/infra/engines/inspect-backup.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { execa } from "execa";
66
* Inspects the contents of a database backup file without a live connection.
77
*
88
* Supported formats:
9-
* .dump PostgreSQL custom format — pg_restore --list
9+
* .dump / .tar PostgreSQL dump formats — pg_restore --list
1010
* .sql Plain SQL (any engine) — extracts CREATE statements
1111
* .db / .sqlite SQLite database file — sqlite3 .schema
1212
*
@@ -22,7 +22,7 @@ export async function inspectBackupFile(filePath: string): Promise<string> {
2222

2323
const ext = extname(resolvedPath).toLowerCase();
2424

25-
if (ext === ".dump") {
25+
if (ext === ".dump" || ext === ".tar") {
2626
return inspectPostgresDump(resolvedPath);
2727
}
2828

@@ -35,7 +35,7 @@ export async function inspectBackupFile(filePath: string): Promise<string> {
3535
}
3636

3737
throw new Error(
38-
`Unsupported file type "${ext}". Supported extensions: .dump (PostgreSQL), .sql (any engine), .db / .sqlite (SQLite)`,
38+
`Unsupported file type "${ext}". Supported extensions: .dump / .tar (PostgreSQL), .sql (any engine), .db / .sqlite (SQLite)`,
3939
);
4040
}
4141

0 commit comments

Comments
 (0)