docker-captain 0.0.1__tar.gz

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.
@@ -0,0 +1,185 @@
1
+ Metadata-Version: 2.3
2
+ Name: docker-captain
3
+ Version: 0.0.1
4
+ Summary: Add your description here
5
+ Requires-Dist: platformdirs>=4.5.0
6
+ Requires-Dist: pyyaml>=6.0.3
7
+ Requires-Dist: questionary>=2.1.1
8
+ Requires-Dist: rich>=14.2.0
9
+ Requires-Dist: sh>=2.2.2
10
+ Requires-Dist: typer>=0.19.2
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+
14
+ # ⚓ docker-captain
15
+
16
+ **docker-captain** is a friendly command-line tool that helps you manage multiple Docker Compose projects under a single folder.
17
+
18
+ It's powered by [Typer](https://typer.tiangolo.com/), [Questionary](https://github.com/tmbo/questionary), and [sh](https://amoffat.github.io/sh/).
19
+
20
+ ---
21
+
22
+ ## 🚀 Features
23
+
24
+ For a quick overview of the available commands, run `docker-captain --help`.
25
+
26
+ ### 🔍 Project Auto-Detection
27
+ `docker-captain` automatically detects any subfolder containing a Docker Compose file — such as `compose.yaml`, `compose.yml`, `docker-compose.yaml`, or `docker-compose.yml`.
28
+ It scans the folder specified in the configuration file, or passed in the `DOCKER_CAPTAIN_PROJECTS` environment variable, which you can export here:
29
+
30
+ ```bash
31
+ export DOCKER_CAPTAIN_PROJECTS=/path/to/your/deployments # takes precedence over the config file
32
+ ```
33
+
34
+ Detection is purely based on the file names — if a folder contains one of those Compose files, it’s recognized as a valid “project”, taking its name from the folder.
35
+
36
+ ### ⚙️ Project Management via `manage`
37
+
38
+ Use the `docker-captain manage` command to interactively select which projects should be considered *active*.
39
+ You’ll see a multi-select list of all detected projects — you can check or uncheck them using the keyboard, then confirm with Enter.
40
+
41
+ The selected projects become your *active fleet*, used by commands like `rally` and `abandon`.
42
+
43
+ ### 🚢 Easy Interaction with Single Projects
44
+
45
+ Need to start or stop one project?
46
+ Use these straightforward commands:
47
+
48
+ ```bash
49
+ docker-captain start calibre --detach --remove-orphans
50
+ docker-captain stop calibre --remove-orphans
51
+ docker-captain restart calibre
52
+ ```
53
+
54
+ They’re thin wrappers around standard `docker compose` commands (`up`, `down`, and `restart`), but automatically use the correct compose file and folder context.
55
+
56
+ Flags:
57
+
58
+ * `-d` / `--detach`: Run containers in background.
59
+ * `--remove-orphans`: Remove orphaned containers not defined in the compose file.
60
+
61
+ ### 📋 Listing Projects with `list`
62
+
63
+ See all detected projects neatly formatted in a Rich table:
64
+
65
+ ```bash
66
+ docker-captain list
67
+ ```
68
+
69
+ This shows:
70
+
71
+ * **Project** name
72
+ * Whether it’s **Active** (selected via `manage`)
73
+ * Whether it’s currently **Running** (`docker compose ls` is checked behind the scenes)
74
+
75
+ You can also view the compose file paths with `--verbose`:
76
+
77
+ ```bash
78
+ docker-captain list --verbose
79
+ ```
80
+
81
+ ### ⚓ Rally and Abandon Your Fleet
82
+
83
+ Once you’ve marked projects as *active* using `manage`, you can control them all together:
84
+
85
+ * **Start all active projects:**
86
+
87
+ ```bash
88
+ docker-captain rally --detach
89
+ ```
90
+ * **Stop all active projects:**
91
+
92
+ ```bash
93
+ docker-captain abandon
94
+ ```
95
+
96
+ These commands behave like `start` and `stop`, but apply to every active project in one go — perfect for booting up or shutting down your entire environment.
97
+
98
+ ---
99
+
100
+ ## 📦 Installation
101
+
102
+ You can install dependencies using `uv`, `pipx`, or plain `pip`.
103
+
104
+ ```bash
105
+ # Install with
106
+ uv tool install docker-captain
107
+ pipx install docker-captain
108
+
109
+ # or try it out with
110
+ uvx docker-captain
111
+ ```
112
+
113
+ ---
114
+
115
+ ## 🗒️ Configuration
116
+
117
+ `captain-docker` support a simple YAML config file with the following structure:
118
+
119
+ ```yaml
120
+ # ~/.config/docker-captain/config.yaml (on Linux)
121
+ projects_folder: /path/to/your/deployments # environment variable: DOCKER_CAPTAIN_PROJECTS_FOLDER
122
+ ```
123
+
124
+ ---
125
+
126
+
127
+ ## 🧭 Folder Structure Example
128
+
129
+ Your deployments might look like this:
130
+
131
+ ```
132
+ ~/Deployments/
133
+ ├── calibre/
134
+ │ └── compose.yaml
135
+ ├── immich/
136
+ │ ├── compose.yaml
137
+ │ └── immich.env
138
+ ├── paperless-ngx/
139
+ │ ├── compose.yaml
140
+ │ └── paperless-ngx.env
141
+ └── syncthing/
142
+ └── compose.yaml
143
+ ```
144
+
145
+ Each subfolder is automatically detected as a project if it has a Compose file.
146
+
147
+ ---
148
+
149
+ ## 🧠 Tech Stack
150
+
151
+ | Library | Purpose |
152
+ | -------------------------------------------------- | ---------------------------- |
153
+ | [Typer](https://typer.tiangolo.com/) | CLI framework |
154
+ | [Rich](https://rich.readthedocs.io/) | Beautiful terminal output |
155
+ | [Questionary](https://github.com/tmbo/questionary) | Interactive prompts |
156
+ | [sh](https://amoffat.github.io/sh/) | Simple subprocess management |
157
+ | [PyYAML](https://pyyaml.org/) | YAML parsing |
158
+
159
+ ---
160
+
161
+ ## 🐙 Example Workflow
162
+
163
+ ```bash
164
+ # Detect and list all projects
165
+ docker-captain list
166
+
167
+ # Choose which projects are active
168
+ docker-captain manage
169
+
170
+ # Start all active projects
171
+ docker-captain rally -d
172
+ ```
173
+
174
+ ---
175
+
176
+ ## 💡 Inspiration
177
+
178
+ I've been using `docker-compose.yaml` files to manage my home server for a while.
179
+ I found the internet is full of tools to observe docker deployments, but I couldn't find one to manage my Docker Compose files.
180
+ I wanted something simple, lightweight, and portable.
181
+
182
+ I stumbled across [jenssegers/captain](https://github.com/jenssegers/captain/), a Go project with a similar idea - a simple wrapper around `docker compose`, but only acting on one project at a time.
183
+ Given Python is my main language and the project hasn't seen any activity in 3 years, I decided to extend its scope and write `docker-captain`.
184
+
185
+ Hope this is useful to someone, happy sailing! ⛵
@@ -0,0 +1,172 @@
1
+ # ⚓ docker-captain
2
+
3
+ **docker-captain** is a friendly command-line tool that helps you manage multiple Docker Compose projects under a single folder.
4
+
5
+ It's powered by [Typer](https://typer.tiangolo.com/), [Questionary](https://github.com/tmbo/questionary), and [sh](https://amoffat.github.io/sh/).
6
+
7
+ ---
8
+
9
+ ## 🚀 Features
10
+
11
+ For a quick overview of the available commands, run `docker-captain --help`.
12
+
13
+ ### 🔍 Project Auto-Detection
14
+ `docker-captain` automatically detects any subfolder containing a Docker Compose file — such as `compose.yaml`, `compose.yml`, `docker-compose.yaml`, or `docker-compose.yml`.
15
+ It scans the folder specified in the configuration file, or passed in the `DOCKER_CAPTAIN_PROJECTS` environment variable, which you can export here:
16
+
17
+ ```bash
18
+ export DOCKER_CAPTAIN_PROJECTS=/path/to/your/deployments # takes precedence over the config file
19
+ ```
20
+
21
+ Detection is purely based on the file names — if a folder contains one of those Compose files, it’s recognized as a valid “project”, taking its name from the folder.
22
+
23
+ ### ⚙️ Project Management via `manage`
24
+
25
+ Use the `docker-captain manage` command to interactively select which projects should be considered *active*.
26
+ You’ll see a multi-select list of all detected projects — you can check or uncheck them using the keyboard, then confirm with Enter.
27
+
28
+ The selected projects become your *active fleet*, used by commands like `rally` and `abandon`.
29
+
30
+ ### 🚢 Easy Interaction with Single Projects
31
+
32
+ Need to start or stop one project?
33
+ Use these straightforward commands:
34
+
35
+ ```bash
36
+ docker-captain start calibre --detach --remove-orphans
37
+ docker-captain stop calibre --remove-orphans
38
+ docker-captain restart calibre
39
+ ```
40
+
41
+ They’re thin wrappers around standard `docker compose` commands (`up`, `down`, and `restart`), but automatically use the correct compose file and folder context.
42
+
43
+ Flags:
44
+
45
+ * `-d` / `--detach`: Run containers in background.
46
+ * `--remove-orphans`: Remove orphaned containers not defined in the compose file.
47
+
48
+ ### 📋 Listing Projects with `list`
49
+
50
+ See all detected projects neatly formatted in a Rich table:
51
+
52
+ ```bash
53
+ docker-captain list
54
+ ```
55
+
56
+ This shows:
57
+
58
+ * **Project** name
59
+ * Whether it’s **Active** (selected via `manage`)
60
+ * Whether it’s currently **Running** (`docker compose ls` is checked behind the scenes)
61
+
62
+ You can also view the compose file paths with `--verbose`:
63
+
64
+ ```bash
65
+ docker-captain list --verbose
66
+ ```
67
+
68
+ ### ⚓ Rally and Abandon Your Fleet
69
+
70
+ Once you’ve marked projects as *active* using `manage`, you can control them all together:
71
+
72
+ * **Start all active projects:**
73
+
74
+ ```bash
75
+ docker-captain rally --detach
76
+ ```
77
+ * **Stop all active projects:**
78
+
79
+ ```bash
80
+ docker-captain abandon
81
+ ```
82
+
83
+ These commands behave like `start` and `stop`, but apply to every active project in one go — perfect for booting up or shutting down your entire environment.
84
+
85
+ ---
86
+
87
+ ## 📦 Installation
88
+
89
+ You can install dependencies using `uv`, `pipx`, or plain `pip`.
90
+
91
+ ```bash
92
+ # Install with
93
+ uv tool install docker-captain
94
+ pipx install docker-captain
95
+
96
+ # or try it out with
97
+ uvx docker-captain
98
+ ```
99
+
100
+ ---
101
+
102
+ ## 🗒️ Configuration
103
+
104
+ `captain-docker` support a simple YAML config file with the following structure:
105
+
106
+ ```yaml
107
+ # ~/.config/docker-captain/config.yaml (on Linux)
108
+ projects_folder: /path/to/your/deployments # environment variable: DOCKER_CAPTAIN_PROJECTS_FOLDER
109
+ ```
110
+
111
+ ---
112
+
113
+
114
+ ## 🧭 Folder Structure Example
115
+
116
+ Your deployments might look like this:
117
+
118
+ ```
119
+ ~/Deployments/
120
+ ├── calibre/
121
+ │ └── compose.yaml
122
+ ├── immich/
123
+ │ ├── compose.yaml
124
+ │ └── immich.env
125
+ ├── paperless-ngx/
126
+ │ ├── compose.yaml
127
+ │ └── paperless-ngx.env
128
+ └── syncthing/
129
+ └── compose.yaml
130
+ ```
131
+
132
+ Each subfolder is automatically detected as a project if it has a Compose file.
133
+
134
+ ---
135
+
136
+ ## 🧠 Tech Stack
137
+
138
+ | Library | Purpose |
139
+ | -------------------------------------------------- | ---------------------------- |
140
+ | [Typer](https://typer.tiangolo.com/) | CLI framework |
141
+ | [Rich](https://rich.readthedocs.io/) | Beautiful terminal output |
142
+ | [Questionary](https://github.com/tmbo/questionary) | Interactive prompts |
143
+ | [sh](https://amoffat.github.io/sh/) | Simple subprocess management |
144
+ | [PyYAML](https://pyyaml.org/) | YAML parsing |
145
+
146
+ ---
147
+
148
+ ## 🐙 Example Workflow
149
+
150
+ ```bash
151
+ # Detect and list all projects
152
+ docker-captain list
153
+
154
+ # Choose which projects are active
155
+ docker-captain manage
156
+
157
+ # Start all active projects
158
+ docker-captain rally -d
159
+ ```
160
+
161
+ ---
162
+
163
+ ## 💡 Inspiration
164
+
165
+ I've been using `docker-compose.yaml` files to manage my home server for a while.
166
+ I found the internet is full of tools to observe docker deployments, but I couldn't find one to manage my Docker Compose files.
167
+ I wanted something simple, lightweight, and portable.
168
+
169
+ I stumbled across [jenssegers/captain](https://github.com/jenssegers/captain/), a Go project with a similar idea - a simple wrapper around `docker compose`, but only acting on one project at a time.
170
+ Given Python is my main language and the project hasn't seen any activity in 3 years, I decided to extend its scope and write `docker-captain`.
171
+
172
+ Hope this is useful to someone, happy sailing! ⛵
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "docker-captain"
3
+ version = "0.0.1"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "platformdirs>=4.5.0",
9
+ "pyyaml>=6.0.3",
10
+ "questionary>=2.1.1",
11
+ "rich>=14.2.0",
12
+ "sh>=2.2.2",
13
+ "typer>=0.19.2",
14
+ ]
15
+ [dependency-groups]
16
+ dev = [
17
+ "pyright>=1.1.406",
18
+ "ruff>=0.14.0",
19
+ ]
20
+
21
+ [project.scripts]
22
+ docker-captain = "docker_captain.main:main"
23
+
24
+ [build-system]
25
+ requires = ["uv_build>=0.9.2,<0.10.0"]
26
+ build-backend = "uv_build"
27
+
28
+ [[tool.uv.index]]
29
+ name = "testpypi"
30
+ url = "https://test.pypi.org/simple/"
31
+ publish-url = "https://test.pypi.org/legacy/"
32
+ explicit = true
33
+
34
+ [tool.uv.workspace]
35
+ members = [
36
+ "docker-captain",
37
+ ]
38
+
39
+ [tool.ruff]
40
+ line-length = 99
File without changes
@@ -0,0 +1,100 @@
1
+ """Module to help manage configuration and data files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, field, fields, is_dataclass
6
+ from pathlib import Path
7
+ from typing import ClassVar, Dict, List, Optional, Type, TypeVar
8
+
9
+ import yaml
10
+ from platformdirs import user_config_dir, user_data_dir
11
+ from rich.console import Console
12
+
13
+ console = Console()
14
+ T = TypeVar("T", bound="CaptainFile")
15
+
16
+
17
+ @dataclass
18
+ class CaptainFile:
19
+ """
20
+ Base class that serialises dataclasses to YAML.
21
+ Sub‑classes must be dataclasses and provide a ``DEFAULT_PATH``.
22
+ """
23
+
24
+ DEFAULT_PATH: ClassVar[Path] # overridden by subclasses
25
+
26
+ @classmethod
27
+ def _ensure_dataclass(cls) -> None:
28
+ if not is_dataclass(cls):
29
+ raise TypeError(f"{cls.__name__} must be a dataclass")
30
+
31
+ @classmethod
32
+ def load(cls: Type[T], path: Path | None = None) -> T:
33
+ """
34
+ Load an instance from *path* (or ``DEFAULT_PATH``). If the file
35
+ cannot be read or parsed, a warning is printed and a fresh instance
36
+ with default values is returned.
37
+ """
38
+ cls._ensure_dataclass()
39
+ path = Path(path) if path is not None else cls.DEFAULT_PATH
40
+
41
+ if not path.exists():
42
+ return cls()
43
+
44
+ try:
45
+ with path.open("r", encoding="utf-8") as f:
46
+ data = yaml.safe_load(f) or {}
47
+ except Exception as e:
48
+ console.print(f"[yellow]Warning: Failed to parse {path}: {e}[/yellow]")
49
+ return cls()
50
+
51
+ # Keep only fields defined on the dataclass
52
+ field_names = {f.name for f in fields(cls)}
53
+ filtered = {k: v for k, v in data.items() if k in field_names}
54
+ return cls(**filtered)
55
+
56
+ def save(self, path: Path | None = None) -> None:
57
+ """
58
+ Write the instance to *path* (or ``DEFAULT_PATH``) as YAML.
59
+ The parent directory is created automatically. Errors are
60
+ reported with a console warning but not re‑raised.
61
+ """
62
+ self.__class__._ensure_dataclass()
63
+ path = Path(path) if path is not None else self.__class__.DEFAULT_PATH
64
+ path.parent.mkdir(parents=True, exist_ok=True)
65
+
66
+ try:
67
+ with path.open("w", encoding="utf-8") as f:
68
+ yaml.safe_dump(
69
+ asdict(self),
70
+ f,
71
+ default_flow_style=False,
72
+ allow_unicode=True,
73
+ )
74
+ except Exception as e:
75
+ console.print(f"[yellow]Warning: Failed to write {path}: {e}[/yellow]")
76
+
77
+
78
+ @dataclass
79
+ class CaptainConfig(CaptainFile):
80
+ """Data model of the docker-captain configuration file."""
81
+
82
+ DEFAULT_PATH: ClassVar[Path] = (
83
+ Path(user_config_dir(appname="docker-captain", appauthor=False)) / "config.yaml"
84
+ )
85
+ ENVIROMENT: ClassVar[Dict] = {"projects_folder": "DOCKER_CAPTAIN_PROJECTS_FOLDER"}
86
+
87
+ projects_folder: Optional[Path] = field(
88
+ default=None, metadata={"env": "DOCKER_CAPTAIN_PROJECTS_FOLDER"}
89
+ )
90
+
91
+
92
+ @dataclass
93
+ class CaptainData(CaptainFile):
94
+ """Data model of the docker-captain data."""
95
+
96
+ DEFAULT_PATH: ClassVar[Path] = (
97
+ Path(user_data_dir(appname="docker-captain", appauthor=False)) / "data.yaml"
98
+ )
99
+
100
+ active_projects: List[str] = field(default_factory=list)
@@ -0,0 +1,118 @@
1
+ """Module to help wrapping 'docker compose' commands."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import List, Literal
6
+
7
+ import sh
8
+ from rich.console import Console
9
+
10
+ console = Console()
11
+
12
+
13
+ class DockerCompose:
14
+ """Wrapper around 'docker compose' commands."""
15
+
16
+ @staticmethod
17
+ def get_running_projects() -> List[str]:
18
+ """Return a list of running docker compose projects using `docker compose ls`.
19
+
20
+ Returns:
21
+ List[str]: Subset of the passed projects that are currently running.
22
+ """
23
+ try:
24
+ result = sh.docker.compose.ls(format="json", _ok_code=[0, 1]) # pyright: ignore[reportAttributeAccessIssue]
25
+ data = json.loads(str(result))
26
+ running = []
27
+ for item in data:
28
+ name = item.get("Name")
29
+ if name and item.get("Status", "").lower().startswith("running"):
30
+ running.append(name)
31
+ return running
32
+ except sh.CommandNotFound as e:
33
+ console.print(f"[red][bold]Error:[/bold] '{e}' command not found.[/red]")
34
+ exit(code=1)
35
+ except Exception as e:
36
+ console.print(f"[yellow]Warning: could not determine running projects ({e})[/yellow]")
37
+ return []
38
+
39
+ @staticmethod
40
+ def up(compose_file: Path, **kwargs) -> int:
41
+ """Run 'docker compose up' with the specified **kwargs.
42
+
43
+ Args:
44
+ compose_file (Path): Path to the compose YAML file.
45
+ **kwargs (Dict): Optional arguments to pass directly to docker compose.
46
+
47
+ Returns:
48
+ int: Exit code of the docker command.
49
+
50
+ """
51
+ return DockerCompose._docker_compose_run(compose_file=compose_file, action="up", **kwargs)
52
+
53
+ @staticmethod
54
+ def down(compose_file: Path, **kwargs) -> int:
55
+ """Run 'docker compose down' with the specified **kwargs.
56
+
57
+ Args:
58
+ compose_file (Path): Path to the compose YAML file.
59
+ **kwargs (Dict): Optional arguments to pass directly to docker compose.
60
+
61
+ Returns:
62
+ int: Exit code of the docker command.
63
+
64
+ """
65
+ return DockerCompose._docker_compose_run(
66
+ compose_file=compose_file, action="down", **kwargs
67
+ )
68
+
69
+ @staticmethod
70
+ def restart(compose_file: Path, **kwargs) -> int:
71
+ """Run 'docker compose restart' with the specified **kwargs.
72
+
73
+ Args:
74
+ compose_file (Path): Path to the compose YAML file.
75
+ **kwargs (Dict): Optional arguments to pass directly to docker compose.
76
+
77
+ Returns:
78
+ int: Exit code of the docker command.
79
+
80
+ """
81
+ return DockerCompose._docker_compose_run(
82
+ compose_file=compose_file, action="restart", **kwargs
83
+ )
84
+
85
+ @staticmethod
86
+ def _docker_compose_run(
87
+ compose_file: Path,
88
+ action: Literal["up", "down", "restart"],
89
+ **kwargs,
90
+ ) -> int:
91
+ """Internal wrapper to run docker compose command via sh.docker.compose.
92
+
93
+
94
+ Args:
95
+ compose_file (Path): Path to the compose YAML file.
96
+ action (str): 'up' or 'down', to indicate which docker compose command to run.
97
+ **kwargs (Dict): Optional arguments to pass directly to docker compose.
98
+
99
+ Returns:
100
+ int: Exit code of the docker command.
101
+
102
+ """
103
+ console.rule(f"[bold blue]{action.upper()} {compose_file.parent.name}[/bold blue]")
104
+ try:
105
+ sh.docker.compose(action, file=compose_file, _fg=True, **kwargs) # pyright: ignore[reportAttributeAccessIssue]
106
+ console.print(
107
+ f":white_check_mark: [green]{action} succeeded for {compose_file.parent.name}[/green]"
108
+ )
109
+ return 0
110
+ except sh.CommandNotFound as e:
111
+ console.print(f"[red]'{e}' command not found.[/red]")
112
+ return 1
113
+ except sh.ErrorReturnCode as e:
114
+ console.print(f"[red]Command failed with exit code {e.exit_code}[/red]")
115
+ return int(e.exit_code)
116
+ except Exception as e:
117
+ console.print(f"[red]Error executing docker compose: {e}[/red]")
118
+ return 2
@@ -0,0 +1,212 @@
1
+ """
2
+ A friendly CLI tool for managing multiple Docker Compose projects.
3
+
4
+ docker-captain detects projects automatically, lets you mark them as active,
5
+ and provides simple commands to start, stop, restart, or list your deployments
6
+ — individually or all at once.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import List
12
+
13
+ import questionary
14
+ import typer
15
+ from questionary import Choice
16
+ from rich import box
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+
20
+ from docker_captain.config import CaptainData
21
+ from docker_captain.docker import DockerCompose
22
+ from docker_captain.projects import CaptainProject
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Configuration
26
+ # ---------------------------------------------------------------------------
27
+
28
+ app = typer.Typer(no_args_is_help=True)
29
+ console = Console()
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Commands
33
+ # ---------------------------------------------------------------------------
34
+
35
+
36
+ @app.command()
37
+ def list(
38
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show compose file paths."),
39
+ ) -> None:
40
+ """List all discovered projects and show which ones are active and running.
41
+
42
+ Args:
43
+ verbose (bool): If True, also show the compose file path.
44
+ """
45
+ projects_folder = CaptainProject.projects_folder()
46
+ projects = CaptainProject.discover_projects()
47
+ captain_data: CaptainData = CaptainData.load()
48
+ running_projects: List[str] = DockerCompose.get_running_projects()
49
+
50
+ table = Table(title=f"Projects in {projects_folder}", box=box.SIMPLE_HEAVY)
51
+ table.add_column("Project", no_wrap=True)
52
+ table.add_column("Active", justify="center")
53
+ table.add_column("Running", justify="center")
54
+
55
+ if verbose:
56
+ table.add_column("Compose File")
57
+
58
+ for name, compose_path in projects.items():
59
+ active = "✓" if name in captain_data.active_projects else ""
60
+ running = "✓" if name in running_projects else ""
61
+ row = [name, active, running]
62
+ if verbose:
63
+ row.append(str(compose_path))
64
+ table.add_row(*row)
65
+
66
+ console.print(table)
67
+
68
+
69
+ @app.command()
70
+ def manage() -> None:
71
+ """Interactively select which projects are active."""
72
+ projects_folder = CaptainProject.projects_folder()
73
+ projects = CaptainProject.discover_projects()
74
+ names = sorted(projects.keys())
75
+ captain_data: CaptainData = CaptainData.load()
76
+
77
+ if not names:
78
+ console.print(f"[yellow]No projects found in {projects_folder}.[/yellow]")
79
+ raise typer.Exit(code=1)
80
+
81
+ # Build choices with checkmarks for active projects
82
+ choices = [
83
+ Choice(title=n, value=n, checked=(n in captain_data.active_projects)) for n in names
84
+ ]
85
+
86
+ answer = questionary.checkbox(
87
+ "Select active projects (space to toggle, enter to confirm):",
88
+ choices=choices,
89
+ ).ask()
90
+
91
+ if answer is None:
92
+ console.print("[yellow]Aborted (no changes made).[/yellow]")
93
+ raise typer.Exit(code=0)
94
+
95
+ captain_data.active_projects = sorted(answer)
96
+ captain_data.save()
97
+ console.print(
98
+ f"[green]Saved {len(captain_data.active_projects)} active project(s) to {captain_data.DEFAULT_PATH}[/green]"
99
+ )
100
+
101
+
102
+ @app.command()
103
+ def start(
104
+ project: str = typer.Argument(..., help="Project folder name (e.g. calibre)"),
105
+ detach: bool = typer.Option(False, "-d", "--detach", help="Run `docker compose up --detach`"),
106
+ remove_orphans: bool = typer.Option(
107
+ False, "--remove-orphans", help="Include --remove-orphans"
108
+ ),
109
+ ) -> None:
110
+ """Start a single project using `docker compose up`."""
111
+ projects = CaptainProject.discover_projects()
112
+ compose_file = CaptainProject.require_project_exists(project, projects)
113
+ rc = DockerCompose.up(
114
+ compose_file=compose_file,
115
+ detach=detach,
116
+ remove_orphans=remove_orphans,
117
+ )
118
+ raise typer.Exit(code=rc)
119
+
120
+
121
+ @app.command()
122
+ def stop(
123
+ project: str = typer.Argument(..., help="Project folder name (e.g. calibre)"),
124
+ remove_orphans: bool = typer.Option(
125
+ False, "--remove-orphans", help="Include --remove-orphans"
126
+ ),
127
+ ) -> None:
128
+ """Stop a single project using `docker compose down`."""
129
+ projects = CaptainProject.discover_projects()
130
+ compose_file = CaptainProject.require_project_exists(project, projects)
131
+ rc = DockerCompose.down(
132
+ compose_file=compose_file,
133
+ remove_orphans=remove_orphans,
134
+ )
135
+ raise typer.Exit(code=rc)
136
+
137
+
138
+ @app.command()
139
+ def restart(
140
+ project: str = typer.Argument(..., help="Project folder name (e.g. calibre)"),
141
+ ) -> None:
142
+ """Restart a single project using `docker compose restart`."""
143
+ projects = CaptainProject.discover_projects()
144
+ compose_file = CaptainProject.require_project_exists(project, projects)
145
+ rc = DockerCompose.restart(compose_file=compose_file)
146
+ raise typer.Exit(code=rc)
147
+
148
+
149
+ @app.command()
150
+ def rally(
151
+ detach: bool = typer.Option(False, "-d", "--detach", help="Run with --detach"),
152
+ remove_orphans: bool = typer.Option(
153
+ False, "--remove-orphans", help="Include --remove-orphans"
154
+ ),
155
+ ) -> None:
156
+ """Start all active projects."""
157
+ projects = CaptainProject.discover_projects()
158
+ captain_data = CaptainData.load()
159
+
160
+ if not captain_data.active_projects:
161
+ console.print(
162
+ f"[yellow]No active projects found in {captain_data.DEFAULT_PATH}. Run `docker-captain manage` first.[/yellow]"
163
+ )
164
+ raise typer.Exit(code=0)
165
+
166
+ exit_code = 0
167
+ for name in captain_data.active_projects:
168
+ if name not in projects:
169
+ console.print(f"[red]Skipping {name}: project not found.[/red]")
170
+ exit_code = exit_code or 1
171
+ continue
172
+ rc = DockerCompose.up(
173
+ compose_file=projects[name], detach=detach, remove_orphans=remove_orphans
174
+ )
175
+ exit_code = exit_code or rc
176
+ raise typer.Exit(code=exit_code)
177
+
178
+
179
+ @app.command()
180
+ def abandon(
181
+ remove_orphans: bool = typer.Option(
182
+ False, "--remove-orphans", help="Include --remove-orphans"
183
+ ),
184
+ ) -> None:
185
+ """Stop all active projects."""
186
+ projects = CaptainProject.discover_projects()
187
+ captain_data = CaptainData.load()
188
+
189
+ if not captain_data.active_projects:
190
+ console.print(
191
+ f"[yellow]No active projects found in {captain_data.DEFAULT_PATH}. Run `docker-captain manage` first.[/yellow]"
192
+ )
193
+ raise typer.Exit(code=0)
194
+
195
+ exit_code = 0
196
+ for name in captain_data.active_projects:
197
+ if name not in projects:
198
+ console.print(f"[red]Skipping {name}: project not found.[/red]")
199
+ exit_code = exit_code or 1
200
+ continue
201
+ rc = DockerCompose.down(compose_file=projects[name], remove_orphans=remove_orphans)
202
+ exit_code = exit_code or rc
203
+ raise typer.Exit(code=exit_code)
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # Entry point
208
+ # ---------------------------------------------------------------------------
209
+
210
+
211
+ def main():
212
+ app()
@@ -0,0 +1,95 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Dict, List, Optional
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from docker_captain.config import CaptainConfig
9
+
10
+ console = Console()
11
+
12
+
13
+ class CaptainProject:
14
+ @staticmethod
15
+ def projects_folder() -> Path:
16
+ """Get the root folder of the projects.
17
+
18
+ Either parse it from the environment variable (if provided), or from
19
+ the configuration file.
20
+ """
21
+ captain_config = CaptainConfig.load()
22
+ folder = (
23
+ os.getenv(CaptainConfig.ENVIROMENT["projects_folder"])
24
+ or captain_config.projects_folder
25
+ )
26
+ if not folder:
27
+ console.print(
28
+ f"[bold red]Error:[/bold red] Please set the path containing your Docker Compose projects.\n"
29
+ f"Either add it to the {CaptainConfig.DEFAULT_PATH} file, or set with:\n\n"
30
+ f" export {CaptainConfig.ENVIROMENT['projects_folder']}=/path/to/your/deployments\n"
31
+ )
32
+ exit(code=1)
33
+ folder = Path(folder)
34
+ if not folder.is_absolute():
35
+ console.print(
36
+ f"[bold red]Error:[/bold red] The configured projects folder {folder} "
37
+ "is not an absolute path."
38
+ )
39
+ exit(code=2)
40
+ if not folder.exists():
41
+ console.print(
42
+ f"[bold red]Error:[/bold red] The configured projects folder {folder} "
43
+ "does not exist."
44
+ )
45
+ exit(code=3)
46
+ return folder
47
+
48
+ @staticmethod
49
+ def discover_projects(root: Optional[Path] = None) -> Dict[str, Path]:
50
+ """Discover projects that contain a valid docker compose file.
51
+
52
+ Args:
53
+ root (Path): The root directory containing deployment folders.
54
+
55
+ Returns:
56
+ Dict[str, Path]: Mapping from project name to compose file path.
57
+ """
58
+ COMPOSE_FILENAMES: List[str] = [
59
+ "compose.yaml",
60
+ "compose.yml",
61
+ "docker-compose.yaml",
62
+ "docker-compose.yml",
63
+ ]
64
+ projects: Dict[str, Path] = {}
65
+ root = root or CaptainProject.projects_folder()
66
+ if not root.exists():
67
+ return projects
68
+ for child in sorted(root.iterdir()):
69
+ if not child.is_dir():
70
+ continue
71
+ for fname in COMPOSE_FILENAMES:
72
+ candidate = child / fname
73
+ if candidate.exists() and candidate.is_file():
74
+ projects[child.name] = candidate
75
+ break
76
+ return projects
77
+
78
+ @staticmethod
79
+ def require_project_exists(project: str, projects: Dict[str, Path]) -> Path:
80
+ """Ensure the given project exists among discovered ones.
81
+
82
+ Args:
83
+ project (str): Project name.
84
+ projects (Dict[str, Path]): Mapping of available projects.
85
+
86
+ Returns:
87
+ Path: Path to the compose file.
88
+
89
+ Raises:
90
+ typer.Exit: If the project does not exist.
91
+ """
92
+ if project not in projects:
93
+ console.print(f"[red]No such project: {project}[/red]")
94
+ raise typer.Exit(code=2)
95
+ return projects[project]