feat: add initial CLI skeleton and scan command

This commit is contained in:
Eddie Nielsen 2026-03-22 11:53:15 +00:00
parent b892738845
commit cf630318db
9 changed files with 233 additions and 0 deletions

4
dockervault/__init__.py Normal file
View file

@ -0,0 +1,4 @@
"""DockerVault package."""
__all__ = ["__version__"]
__version__ = "0.1.0"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

77
dockervault/cli.py Normal file
View file

@ -0,0 +1,77 @@
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from dockervault.scanner import scan_projects
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="dockervault",
description="DockerVault CLI"
)
subparsers = parser.add_subparsers(dest="command", required=True)
scan_parser = subparsers.add_parser(
"scan",
help="Scan a folder for Docker Compose projects"
)
scan_parser.add_argument(
"path",
nargs="?",
default=".",
help="Base path to scan (default: current directory)"
)
scan_parser.add_argument(
"--json",
action="store_true",
help="Output scan results as JSON"
)
return parser
def render_text(projects: list) -> str:
if not projects:
return "No Docker Compose projects found."
lines: list[str] = []
lines.append(f"Found {len(projects)} project(s):")
for project in projects:
lines.append("")
lines.append(f"- {project.name}")
lines.append(f" Path: {project.root_path}")
lines.append(f" Compose files: {', '.join(project.compose_files) or '-'}")
lines.append(f" Services: {', '.join(project.services) or '-'}")
return "\n".join(lines)
def main() -> int:
parser = build_parser()
args = parser.parse_args()
if args.command == "scan":
try:
projects = scan_projects(Path(args.path))
except (FileNotFoundError, NotADirectoryError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 2
if args.json:
print(json.dumps([project.to_dict() for project in projects], indent=2))
else:
print(render_text(projects))
return 0
parser.print_help()
return 1
if __name__ == "__main__":
raise SystemExit(main())

27
dockervault/models.py Normal file
View file

@ -0,0 +1,27 @@
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from pathlib import Path
@dataclass(slots=True)
class ComposeProject:
name: str
root_path: str
compose_files: list[str] = field(default_factory=list)
services: list[str] = field(default_factory=list)
def to_dict(self) -> dict:
return asdict(self)
DEFAULT_COMPOSE_FILENAMES = {
"docker-compose.yml",
"docker-compose.yaml",
"compose.yml",
"compose.yaml",
}
def normalize_path(path: Path) -> str:
return str(path.resolve())

93
dockervault/scanner.py Normal file
View file

@ -0,0 +1,93 @@
from __future__ import annotations
import re
from pathlib import Path
from dockervault.models import ComposeProject, DEFAULT_COMPOSE_FILENAMES, normalize_path
SERVICE_LINE_RE = re.compile(r"^\s{2}([A-Za-z0-9_.-]+):\s*$")
def find_compose_files(base_path: Path) -> list[Path]:
"""Find likely Docker Compose files under base_path."""
matches: list[Path] = []
for path in base_path.rglob("*"):
if path.is_file() and path.name in DEFAULT_COMPOSE_FILENAMES:
matches.append(path)
return sorted(matches)
def parse_services_from_compose(compose_path: Path) -> list[str]:
"""
Light parser for service names.
Keeps dependencies minimal for v0.
It looks for the `services:` block and collects entries indented by two spaces.
"""
try:
lines = compose_path.read_text(encoding="utf-8").splitlines()
except UnicodeDecodeError:
lines = compose_path.read_text(encoding="utf-8", errors="ignore").splitlines()
in_services = False
services: list[str] = []
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if not in_services:
if stripped == "services:":
in_services = True
continue
# Leaving top-level services block
if not line.startswith(" ") and not line.startswith("\t"):
break
match = SERVICE_LINE_RE.match(line)
if match:
services.append(match.group(1))
return sorted(set(services))
def group_projects_by_folder(compose_files: list[Path]) -> list[ComposeProject]:
grouped: dict[Path, list[Path]] = {}
for compose_file in compose_files:
grouped.setdefault(compose_file.parent, []).append(compose_file)
projects: list[ComposeProject] = []
for folder, files in sorted(grouped.items()):
service_names: set[str] = set()
for compose_file in files:
service_names.update(parse_services_from_compose(compose_file))
projects.append(
ComposeProject(
name=folder.name,
root_path=normalize_path(folder),
compose_files=[file.name for file in sorted(files)],
services=sorted(service_names),
)
)
return projects
def scan_projects(base_path: Path) -> list[ComposeProject]:
if not base_path.exists():
raise FileNotFoundError(f"Path does not exist: {base_path}")
if not base_path.is_dir():
raise NotADirectoryError(f"Path is not a directory: {base_path}")
compose_files = find_compose_files(base_path)
return group_projects_by_folder(compose_files)

32
pyproject.toml Normal file
View file

@ -0,0 +1,32 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "dockervault"
version = "0.1.0"
description = "CLI backup discovery tool for Docker environments"
readme = "README.md"
requires-python = ">=3.10"
authors = [
{ name = "Ed & NodeFox" }
]
license = { text = "MIT" }
keywords = ["docker", "backup", "cli", "borg", "inventory"]
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.11",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent"
]
[project.scripts]
dockervault = "dockervault.cli:main"
[tool.setuptools]
package-dir = {"" = "."}
[tool.setuptools.packages.find]
where = ["."]
include = ["dockervault*"]