diff --git a/CLAUDE.md b/CLAUDE.md index 47dc3e3..c317064 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md \ No newline at end of file +AGENTS.md diff --git a/deploy/command/update.py b/deploy/command/update.py index 896d8aa..ce3a0ef 100644 --- a/deploy/command/update.py +++ b/deploy/command/update.py @@ -44,6 +44,13 @@ default=None, help="Subdirectory within the repo to use as the service root (for monorepos).", ) +@click.option( + "--watch", + "watch", + is_flag=True, + default=False, + help="Stream service logs with journalctl after a successful update.", +) @click.pass_context def update( # noqa: C901 ctx: click.Context, @@ -54,6 +61,7 @@ def update( # noqa: C901 ssh_port: int | None, ignore_hooks: bool, repo_subdir: str | None, + watch: bool, ) -> None: """Update an existing deployment instance.""" cfg = load_config(ctx.obj["config"], instance_name) @@ -189,3 +197,10 @@ def run_hooks(hook_name: str) -> bool: run_hooks("post-update-success") click.secho(f"\nInstance {instance_name!r} updated successfully.", fg="green") + + if watch: + click.secho("\nWatching service logs (Ctrl+C to stop)…", fg="cyan") + try: + executor.stream(f"journalctl --user -u {instance_name} -f") + except KeyboardInterrupt: + click.echo() diff --git a/deploy/utils/executor.py b/deploy/utils/executor.py index 618bfbd..f93e18b 100644 --- a/deploy/utils/executor.py +++ b/deploy/utils/executor.py @@ -108,6 +108,19 @@ def capture(self, command: str, cwd: str | None = None) -> str: return result.stdout.strip() + def stream(self, command: str, cwd: str | None = None) -> None: + """Run a long-lived streaming command (e.g. journalctl -f). + + Output goes directly to the terminal. Returns when the process exits + or is interrupted (Ctrl+C). + """ + argv = self._build_argv(command, cwd) + is_remote = isinstance(argv, list) + if self.verbose: + display = argv[-1] if is_remote else command + click.echo(f"$ {display}", err=True) + subprocess.run(argv, shell=not is_remote, cwd=cwd if not is_remote else None) # noqa: S603 + def write_file(self, content: str, remote_path: str) -> None: """Write *content* to *remote_path* on the target host. diff --git a/tests/test_update_watch.py b/tests/test_update_watch.py new file mode 100644 index 0000000..3bbb85b --- /dev/null +++ b/tests/test_update_watch.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from deploy.command.update import update + + +@pytest.fixture +def runner(): + return CliRunner() + + +def _executor_mock(): + mock = MagicMock() + mock.capture.return_value = "/home/deploy" + return mock + + +def _invoke(runner, extra_args: list[str]): + with ( + patch("deploy.command.update.Executor") as MockExecutor, + patch("deploy.command.update.load_config", return_value={}), + ): + mock_exec = _executor_mock() + MockExecutor.return_value = mock_exec + result = runner.invoke( + update, + ["service-myapp-production", "--type", "service", *extra_args], + obj={"config": "", "verbose": False}, + ) + return result, mock_exec + + +def test_watch_streams_journalctl_after_success(runner): + result, mock_exec = _invoke(runner, ["--watch"]) + + assert result.exit_code == 0 + mock_exec.stream.assert_called_once_with("journalctl --user -u service-myapp-production -f") + + +def test_no_watch_does_not_stream(runner): + result, mock_exec = _invoke(runner, []) + + assert result.exit_code == 0 + mock_exec.stream.assert_not_called() + + +def test_watch_handles_keyboard_interrupt(runner): + with ( + patch("deploy.command.update.Executor") as MockExecutor, + patch("deploy.command.update.load_config", return_value={}), + ): + mock_exec = _executor_mock() + mock_exec.stream.side_effect = KeyboardInterrupt + MockExecutor.return_value = mock_exec + + result = runner.invoke( + update, + ["service-myapp-production", "--type", "service", "--watch"], + obj={"config": "", "verbose": False}, + ) + + assert result.exit_code == 0 + mock_exec.stream.assert_called_once()