diff --git a/devlaunch/completions/dl.bash b/devlaunch/completions/dl.bash index 507b596e..e3741c43 100644 --- a/devlaunch/completions/dl.bash +++ b/devlaunch/completions/dl.bash @@ -5,14 +5,11 @@ _dl_completion() { cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" - # Command options - opts="--ls --repos --stop --rm --code --status --recreate --reset --install --help" + # Global command options (only valid as first arg) + local global_opts="--ls --install --help -h --version" - # Flag completion - if [[ ${cur} == -* ]]; then - COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) - return 0 - fi + # Workspace subcommands + local ws_cmds="stop rm code restart recreate reset --" # Cache file location (honors XDG_CACHE_HOME) local cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/dl" @@ -28,16 +25,14 @@ _dl_completion() { source "$cache_file" fi - # Commands that need workspace completion - if [[ "$prev" == "--stop" || "$prev" == "--rm" || "$prev" == "--code" || "$prev" == "--status" || "$prev" == "--recreate" || "$prev" == "--reset" ]]; then - if [[ -n "$DL_WORKSPACES" ]]; then - COMPREPLY=( $(compgen -W "${DL_WORKSPACES}" -- ${cur}) ) + # First argument: global flags, workspaces, repos, owners, or paths + if [[ ${COMP_CWORD} -eq 1 ]]; then + # Global flags + if [[ ${cur} == -* ]]; then + COMPREPLY=( $(compgen -W "${global_opts}" -- ${cur}) ) + return 0 fi - return 0 - fi - # First positional argument: workspace, owner/repo, or path - if [[ ${COMP_CWORD} -eq 1 ]]; then # Don't add space after completion to allow @branch suffix compopt -o nospace @@ -71,6 +66,19 @@ _dl_completion() { return 0 fi + # Second argument (after workspace): subcommands + if [[ ${COMP_CWORD} -eq 2 ]]; then + # Don't complete after global flags + local first="${COMP_WORDS[1]}" + if [[ "$first" == --* ]]; then + return 0 + fi + + COMPREPLY=( $(compgen -W "${ws_cmds}" -- ${cur}) ) + return 0 + fi + + # After "--": no completion (user types shell command) return 0 } diff --git a/devlaunch/dl.py b/devlaunch/dl.py index 4bb8306f..49d14815 100644 --- a/devlaunch/dl.py +++ b/devlaunch/dl.py @@ -25,11 +25,21 @@ import os import pathlib import re +from importlib.metadata import version as pkg_version, PackageNotFoundError from typing import List, Optional, Dict, Any from dataclasses import dataclass from .completion import install_completions + +def get_version() -> str: + """Get the package version.""" + try: + return pkg_version("devlaunch") + except PackageNotFoundError: + return "unknown" + + logging.basicConfig(level=logging.INFO, format="%(message)s") @@ -485,32 +495,41 @@ def print_help(): help_text = """dl - DevLaunch CLI Usage: - dl Interactive workspace selector (fzf) - dl Start workspace and attach shell - dl Run command in workspace - dl owner/repo Create workspace from GitHub repo - dl owner/repo@branch Create workspace from specific branch - dl ./path Create workspace from local path - -Commands: - --ls List all workspaces - --stop Stop a workspace - --rm Delete a workspace - --code Open workspace in VS Code - --status Show workspace status - --recreate Recreate workspace container - --reset Reset workspace (clean slate) - --install Install shell completions - --help, -h Show this help + dl Interactive workspace selector (fzf) + dl Start workspace and attach shell + dl Run workspace command (stop, code, etc.) + dl -- Run shell command in workspace + +Workspace sources: + dl myproject Existing workspace by name + dl user/repo Create from GitHub repo + dl user/repo@branch Create from specific branch + dl ./path Create from local path + +Workspace commands: + dl stop Stop the workspace + dl rm, prune Delete the workspace + dl code Open in VS Code + dl restart Stop and start (no rebuild) + dl recreate Recreate container + dl reset Clean slate (remove all, recreate) + dl -- Run shell command in workspace + +Global commands: + dl --ls List all workspaces + dl --install Install shell completions + dl --help, -h Show this help + dl --version Show version Examples: - dl # Select workspace with fzf - dl myproject # Open existing workspace - dl loft-sh/devpod # Create from GitHub - dl blooop/devlaunch@main # Create from specific branch - dl ./my-project # Create from local folder - dl --code myproject # Open in VS Code - dl myproject 'make test' # Run command in workspace + dl # Select workspace with fzf + dl devpod # Open existing workspace + dl loft-sh/devpod # Create from GitHub + dl blooop/devlaunch@main # Create from specific branch + dl ./my-project # Create from local folder + dl blooop/devlaunch code # Open in VS Code + dl blooop/devlaunch -- make test # Run command in workspace + dl blooop/devlaunch stop # Stop workspace """ print(help_text) @@ -519,20 +538,24 @@ def main() -> int: """Main entry point for dl CLI.""" args = sys.argv[1:] - # Handle help - if not args or (len(args) == 1 and args[0] in ("--help", "-h")): - if not args: - # No args - try fzf selection - selected = fuzzy_select_workspace() - if not selected: - print_help() - return 1 - workspace_up(selected) - return workspace_ssh(selected) + # No args - try fzf selection + if not args: + selected = fuzzy_select_workspace() + if not selected: + print_help() + return 1 + workspace_up(selected) + return workspace_ssh(selected) + + # Global commands (no workspace required) + if args[0] in ("--help", "-h"): print_help() return 0 - # Handle flags + if args[0] == "--version": + print(f"dl {get_version()}") + return 0 + if args[0] == "--ls": print_workspaces() return 0 @@ -572,80 +595,9 @@ def main() -> int: update_completion_cache() return install_completions(rc_path) - if args[0] == "--stop": - if len(args) < 2: - workspace = fuzzy_select_workspace() - if not workspace: - logging.error("Usage: dl --stop ") - return 1 - else: - workspace = args[1] - return workspace_stop(workspace) - - if args[0] == "--rm": - if len(args) < 2: - workspace = fuzzy_select_workspace() - if not workspace: - logging.error("Usage: dl --rm ") - return 1 - else: - workspace = args[1] - return workspace_delete(workspace) - - if args[0] == "--status": - if len(args) < 2: - workspace = fuzzy_select_workspace() - if not workspace: - logging.error("Usage: dl --status ") - return 1 - else: - workspace = args[1] - return workspace_status(workspace) - - if args[0] == "--code": - if len(args) < 2: - workspace = fuzzy_select_workspace() - if not workspace: - logging.error("Usage: dl --code ") - return 1 - else: - workspace = args[1] - result = workspace_up(workspace, ide="vscode") - return result.returncode - - if args[0] == "--recreate": - if len(args) < 2: - workspace = fuzzy_select_workspace() - if not workspace: - logging.error("Usage: dl --recreate ") - return 1 - else: - workspace = args[1] - workspace_spec = expand_workspace_spec(workspace) - workspace_id = spec_to_workspace_id(workspace) - result = workspace_up(workspace_spec, recreate=True) - if result.returncode != 0: - return result.returncode - return workspace_ssh(workspace_id) - - if args[0] == "--reset": - if len(args) < 2: - workspace = fuzzy_select_workspace() - if not workspace: - logging.error("Usage: dl --reset ") - return 1 - else: - workspace = args[1] - workspace_spec = expand_workspace_spec(workspace) - workspace_id = spec_to_workspace_id(workspace) - result = workspace_up(workspace_spec, reset=True) - if result.returncode != 0: - return result.returncode - return workspace_ssh(workspace_id) - - # Default: workspace name and optional command + # Workspace commands: dl [subcommand] [-- command] raw_spec = args[0] - command = " ".join(args[1:]) if len(args) > 1 else None + subcommand = args[1] if len(args) > 1 else None # Validate the workspace spec existing_ids = get_workspace_ids() @@ -654,8 +606,7 @@ def main() -> int: logging.error(error) return 1 - # Use raw spec as-is if it's an existing workspace ID, otherwise expand - # This prevents owner/repo-style workspace IDs from being rewritten + # Resolve workspace spec and ID if raw_spec in existing_ids: workspace_spec = raw_spec workspace_id = raw_spec @@ -663,13 +614,58 @@ def main() -> int: workspace_spec = expand_workspace_spec(raw_spec) workspace_id = spec_to_workspace_id(raw_spec) - # Start the workspace + # Handle workspace subcommands + if subcommand == "stop": + return workspace_stop(workspace_id) + + if subcommand in ("rm", "prune"): + return workspace_delete(workspace_id) + + if subcommand == "code": + result = workspace_up(workspace_spec, ide="vscode") + return result.returncode + + if subcommand == "recreate": + result = workspace_up(workspace_spec, recreate=True) + if result.returncode != 0: + return result.returncode + return workspace_ssh(workspace_id) + + if subcommand == "restart": + # Stop and start without rebuilding + stop_ret = workspace_stop(workspace_id) + if stop_ret != 0: + return stop_ret + result = workspace_up(workspace_spec) + if result.returncode != 0: + return result.returncode + return workspace_ssh(workspace_id) + + if subcommand == "reset": + # Clean slate - remove everything and recreate + result = workspace_up(workspace_spec, reset=True) + if result.returncode != 0: + return result.returncode + return workspace_ssh(workspace_id) + + # Check for shell command (after --) + shell_command = None + if subcommand == "--" and len(args) > 2: + shell_command = " ".join(args[2:]) + elif subcommand is not None and subcommand != "--": + # Unknown subcommand - treat as error + logging.error( + f"Unknown command '{subcommand}'. Use 'dl {raw_spec} -- {subcommand}' to run a shell command." + ) + return 1 + + # Default: start workspace and attach shell result = workspace_up(workspace_spec) if result.returncode != 0: return result.returncode - # Attach to workspace using the ID (not the full spec) - ret = workspace_ssh(workspace_id, command) + # Attach to workspace + ret = workspace_ssh(workspace_id, shell_command) # Update cache in background after workspace operations update_cache_background() diff --git a/pixi.lock b/pixi.lock index 02f59a75..e4eab6d1 100644 --- a/pixi.lock +++ b/pixi.lock @@ -382,8 +382,8 @@ packages: requires_python: '>=3.10' - pypi: ./ name: devlaunch - version: 0.0.1 - sha256: 29de11213c212d6485b0184e65470b78cf7c2769be434d6e131844549e3fafc2 + version: 0.0.2 + sha256: 93ade45ee736ac6d8740aad89fdf34015b108de4c3e8bba8f6bc186cfc770af3 requires_dist: - iterfzf>=1.0.0 - pylint>=3.2.5,<=4.0.4 ; extra == 'test' diff --git a/pyproject.toml b/pyproject.toml index 737002e6..914b81db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "devlaunch" -version = "0.0.1" +version = "0.0.2" authors = [{ name = "Austin Gregg-Smith", email = "blooop@gmail.com" }] description = "DevLaunch - A streamlined CLI for devpod workspaces" readme = "README.md" @@ -109,7 +109,7 @@ extension-pkg-whitelist = ["numpy"] jobs = 16 #detect number of cores [tool.pylint.'MESSAGES CONTROL'] -disable = "C,logging-fstring-interpolation,line-too-long,fixme,broad-exception-caught,missing-module-docstring,too-many-instance-attributes,too-few-public-methods,too-many-arguments,too-many-locals,too-many-branches,too-many-statements,too-many-return-statements,use-dict-literal,cyclic-import,duplicate-code,too-many-public-methods" +disable = "C,logging-fstring-interpolation,line-too-long,fixme,broad-exception-caught,missing-module-docstring,too-many-instance-attributes,too-few-public-methods,too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-branches,too-many-statements,too-many-return-statements,use-dict-literal,cyclic-import,duplicate-code,too-many-public-methods" enable = "no-else-return,consider-using-in" [tool.coverage.run] diff --git a/test/test_dl.py b/test/test_dl.py index a956abed..596e3787 100644 --- a/test/test_dl.py +++ b/test/test_dl.py @@ -1,6 +1,9 @@ """Tests for dl (DevLaunch CLI) functionality.""" import json +import sys +import tempfile +import pathlib from unittest.mock import patch, MagicMock from devlaunch.dl import ( @@ -15,6 +18,17 @@ list_workspaces, get_workspace_ids, OWNER_REPO_PATTERN, + spec_to_workspace_id, + get_version, + read_completion_cache, + write_completion_cache, + write_bash_completion_cache, + main, + print_help, + print_workspaces, + workspace_stop, + workspace_delete, + run_devpod, ) @@ -457,3 +471,392 @@ def test_get_known_repos_empty(self, mock_list): mock_list.return_value = [] repos = get_known_repos() assert repos == [] + + +class TestGetVersion: + """Tests for get_version function.""" + + def test_get_version_returns_string(self): + """Test that get_version returns a string.""" + version = get_version() + assert isinstance(version, str) + assert len(version) > 0 + + @patch("devlaunch.dl.pkg_version") + def test_get_version_package_not_found(self, mock_pkg_version): + """Test get_version returns 'unknown' when package not found.""" + from importlib.metadata import PackageNotFoundError + + mock_pkg_version.side_effect = PackageNotFoundError("devlaunch") + version = get_version() + assert version == "unknown" + + +class TestSpecToWorkspaceId: + """Tests for spec_to_workspace_id function.""" + + def test_owner_repo_extracts_repo(self): + """Test owner/repo extracts repo name.""" + assert spec_to_workspace_id("blooop/devlaunch") == "devlaunch" + + def test_owner_repo_with_branch(self): + """Test owner/repo@branch extracts repo name.""" + assert spec_to_workspace_id("blooop/devlaunch@main") == "devlaunch" + + def test_github_url(self): + """Test github.com/owner/repo extracts repo name.""" + assert spec_to_workspace_id("github.com/loft-sh/devpod") == "devpod" + + def test_https_url(self): + """Test https URL extracts repo name.""" + assert spec_to_workspace_id("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/owner/repo") == "repo" + + def test_url_with_git_suffix(self): + """Test URL with .git suffix strips it.""" + assert spec_to_workspace_id("github.com/owner/repo.git") == "repo" + + def test_path_extracts_directory_name(self): + """Test path extracts directory name.""" + result = spec_to_workspace_id("./my-project") + assert result == "my-project" + + def test_existing_workspace_id(self): + """Test existing workspace ID is returned as-is.""" + assert spec_to_workspace_id("myworkspace") == "myworkspace" + + +class TestCacheFunctions: + """Tests for cache read/write functions.""" + + def test_write_and_read_completion_cache(self): + """Test writing and reading completion cache.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch("devlaunch.dl.CACHE_FILE", pathlib.Path(tmpdir) / "cache.json"): + data = {"workspaces": ["ws1", "ws2"], "repos": ["a/b"], "owners": ["a"]} + write_completion_cache(data) + result = read_completion_cache() + assert result == data + + def test_read_nonexistent_cache(self): + """Test reading nonexistent cache returns None.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch("devlaunch.dl.CACHE_FILE", pathlib.Path(tmpdir) / "nonexistent.json"): + result = read_completion_cache() + assert result is None + + def test_write_bash_completion_cache(self): + """Test writing bash completion cache.""" + with tempfile.TemporaryDirectory() as tmpdir: + bash_file = pathlib.Path(tmpdir) / "completions.bash" + with patch("devlaunch.dl.BASH_CACHE_FILE", bash_file): + data = {"workspaces": ["ws1", "ws2"], "repos": ["a/b"], "owners": ["a"]} + write_bash_completion_cache(data) + content = bash_file.read_text() + assert 'DL_WORKSPACES="ws1 ws2"' in content + assert 'DL_REPOS="a/b"' in content + assert 'DL_OWNERS="a"' in content + + +class TestRunDevpod: + """Tests for run_devpod function.""" + + @patch("devlaunch.dl.subprocess.run") + def test_run_devpod_basic(self, mock_run): + """Test basic devpod command execution.""" + mock_run.return_value = MagicMock(returncode=0) + result = run_devpod(["list"]) + mock_run.assert_called_once() + assert result.returncode == 0 + + @patch("devlaunch.dl.subprocess.run") + def test_run_devpod_capture(self, mock_run): + """Test devpod command with capture.""" + mock_run.return_value = MagicMock(returncode=0, stdout="output") + run_devpod(["list"], capture=True) + mock_run.assert_called_once() + call_kwargs = mock_run.call_args[1] + assert call_kwargs["capture_output"] is True + + +class TestWorkspaceOperations: + """Tests for workspace operation functions.""" + + @patch("devlaunch.dl.run_devpod") + def test_workspace_stop(self, mock_run): + """Test workspace_stop calls devpod stop.""" + mock_run.return_value = MagicMock(returncode=0) + result = workspace_stop("myworkspace") + mock_run.assert_called_once_with(["stop", "myworkspace"]) + assert result == 0 + + @patch("devlaunch.dl.run_devpod") + def test_workspace_delete(self, mock_run): + """Test workspace_delete calls devpod delete.""" + mock_run.return_value = MagicMock(returncode=0) + result = workspace_delete("myworkspace") + mock_run.assert_called_once_with(["delete", "myworkspace"]) + assert result == 0 + + +class TestPrintFunctions: + """Tests for print functions.""" + + def test_print_help(self, capsys): + """Test print_help outputs help text.""" + print_help() + captured = capsys.readouterr() + assert "dl - DevLaunch CLI" in captured.out + assert "Usage:" in captured.out + assert "--ls" in captured.out + + @patch("devlaunch.dl.list_workspaces") + def test_print_workspaces(self, mock_list, capsys): + """Test print_workspaces outputs workspace table.""" + mock_list.return_value = [ + Workspace("ws1", "local", "/path/to/ws1", "2024-01-01", "docker", "vscode"), + ] + print_workspaces() + captured = capsys.readouterr() + assert "ws1" in captured.out + + @patch("devlaunch.dl.list_workspaces") + def test_print_workspaces_empty(self, mock_list, capsys): + """Test print_workspaces with no workspaces.""" + mock_list.return_value = [] + print_workspaces() + captured = capsys.readouterr() + assert "No workspaces found" in captured.out + + +class TestMainCLI: + """Tests for main() CLI entry point.""" + + def test_main_help_flag(self, capsys): + """Test --help flag shows help.""" + with patch.object(sys, "argv", ["dl", "--help"]): + result = main() + assert result == 0 + captured = capsys.readouterr() + assert "dl - DevLaunch CLI" in captured.out + + def test_main_h_flag(self, capsys): + """Test -h flag shows help.""" + with patch.object(sys, "argv", ["dl", "-h"]): + result = main() + assert result == 0 + captured = capsys.readouterr() + assert "dl - DevLaunch CLI" in captured.out + + def test_main_version_flag(self, capsys): + """Test --version flag shows version.""" + with patch.object(sys, "argv", ["dl", "--version"]): + result = main() + assert result == 0 + captured = capsys.readouterr() + assert "dl " in captured.out + + @patch("devlaunch.dl.list_workspaces") + def test_main_ls_flag(self, mock_list, capsys): + """Test --ls flag lists workspaces.""" + mock_list.return_value = [] + with patch.object(sys, "argv", ["dl", "--ls"]): + result = main() + assert result == 0 + captured = capsys.readouterr() + assert "No workspaces found" in captured.out + + @patch("devlaunch.dl.read_completion_cache") + def test_main_repos_flag(self, mock_cache, capsys): + """Test --repos flag outputs repos.""" + mock_cache.return_value = {"repos": ["owner/repo1", "owner/repo2"]} + with patch.object(sys, "argv", ["dl", "--repos"]): + result = main() + assert result == 0 + captured = capsys.readouterr() + assert "owner/repo1" in captured.out + + @patch("devlaunch.dl.update_completion_cache") + def test_main_update_cache_flag(self, mock_update): + """Test --update-cache flag updates cache.""" + mock_update.return_value = {} + with patch.object(sys, "argv", ["dl", "--update-cache"]): + result = main() + assert result == 0 + mock_update.assert_called_once() + + @patch("devlaunch.dl.read_completion_cache") + def test_main_completion_data_flag(self, mock_cache, capsys): + """Test --completion-data flag outputs JSON.""" + mock_cache.return_value = {"workspaces": ["ws1"], "repos": [], "owners": []} + with patch.object(sys, "argv", ["dl", "--completion-data"]): + result = main() + assert result == 0 + captured = capsys.readouterr() + data = json.loads(captured.out) + assert "workspaces" in data + + @patch("devlaunch.dl.update_completion_cache") + @patch("devlaunch.dl.install_completions") + def test_main_install_flag(self, mock_install, mock_update): + """Test --install flag installs completions.""" + mock_install.return_value = 0 + mock_update.return_value = {} + with patch.object(sys, "argv", ["dl", "--install"]): + result = main() + assert result == 0 + mock_install.assert_called_once() + + @patch("devlaunch.dl.get_workspace_ids") + @patch("devlaunch.dl.workspace_stop") + def test_main_workspace_stop(self, mock_stop, mock_ids): + """Test workspace stop command.""" + mock_ids.return_value = ["myws"] + mock_stop.return_value = 0 + with patch.object(sys, "argv", ["dl", "myws", "stop"]): + result = main() + assert result == 0 + mock_stop.assert_called_once_with("myws") + + @patch("devlaunch.dl.get_workspace_ids") + @patch("devlaunch.dl.workspace_delete") + def test_main_workspace_rm(self, mock_delete, mock_ids): + """Test workspace rm command.""" + mock_ids.return_value = ["myws"] + mock_delete.return_value = 0 + with patch.object(sys, "argv", ["dl", "myws", "rm"]): + result = main() + assert result == 0 + mock_delete.assert_called_once_with("myws") + + @patch("devlaunch.dl.get_workspace_ids") + @patch("devlaunch.dl.workspace_delete") + def test_main_workspace_prune(self, mock_delete, mock_ids): + """Test workspace prune command (alias for rm).""" + mock_ids.return_value = ["myws"] + mock_delete.return_value = 0 + with patch.object(sys, "argv", ["dl", "myws", "prune"]): + result = main() + assert result == 0 + mock_delete.assert_called_once() + + @patch("devlaunch.dl.get_workspace_ids") + @patch("devlaunch.dl.workspace_up") + def test_main_workspace_code(self, mock_up, mock_ids): + """Test workspace code command.""" + mock_ids.return_value = ["myws"] + mock_up.return_value = MagicMock(returncode=0) + with patch.object(sys, "argv", ["dl", "myws", "code"]): + result = main() + assert result == 0 + mock_up.assert_called_once_with("myws", ide="vscode") + + @patch("devlaunch.dl.get_workspace_ids") + @patch("devlaunch.dl.workspace_up") + @patch("devlaunch.dl.workspace_ssh") + def test_main_workspace_recreate(self, mock_ssh, mock_up, mock_ids): + """Test workspace recreate command.""" + mock_ids.return_value = ["myws"] + mock_up.return_value = MagicMock(returncode=0) + mock_ssh.return_value = 0 + with patch.object(sys, "argv", ["dl", "myws", "recreate"]): + result = main() + assert result == 0 + mock_up.assert_called_once_with("myws", recreate=True) + + @patch("devlaunch.dl.get_workspace_ids") + @patch("devlaunch.dl.workspace_stop") + @patch("devlaunch.dl.workspace_up") + @patch("devlaunch.dl.workspace_ssh") + def test_main_workspace_restart(self, mock_ssh, mock_up, mock_stop, mock_ids): + """Test workspace restart command.""" + mock_ids.return_value = ["myws"] + mock_stop.return_value = 0 + mock_up.return_value = MagicMock(returncode=0) + mock_ssh.return_value = 0 + with patch.object(sys, "argv", ["dl", "myws", "restart"]): + result = main() + assert result == 0 + mock_stop.assert_called_once() + mock_up.assert_called_once() + + @patch("devlaunch.dl.get_workspace_ids") + @patch("devlaunch.dl.workspace_up") + @patch("devlaunch.dl.workspace_ssh") + def test_main_workspace_reset(self, mock_ssh, mock_up, mock_ids): + """Test workspace reset command.""" + mock_ids.return_value = ["myws"] + mock_up.return_value = MagicMock(returncode=0) + mock_ssh.return_value = 0 + with patch.object(sys, "argv", ["dl", "myws", "reset"]): + result = main() + assert result == 0 + mock_up.assert_called_once_with("myws", reset=True) + + @patch("devlaunch.dl.get_workspace_ids") + def test_main_unknown_command_error(self, mock_ids, caplog): + """Test unknown subcommand returns error.""" + mock_ids.return_value = ["myws"] + with patch.object(sys, "argv", ["dl", "myws", "badcmd"]): + result = main() + assert result == 1 + assert "Unknown command" in caplog.text + + @patch("devlaunch.dl.get_workspace_ids") + def test_main_invalid_workspace_error(self, mock_ids, caplog): + """Test invalid workspace spec returns error.""" + mock_ids.return_value = [] + with patch.object(sys, "argv", ["dl", "nonexistent"]): + result = main() + assert result == 1 + assert "Unknown workspace" in caplog.text + + @patch("devlaunch.dl.get_workspace_ids") + @patch("devlaunch.dl.workspace_up") + @patch("devlaunch.dl.workspace_ssh") + @patch("devlaunch.dl.update_cache_background") + def test_main_workspace_shell_command(self, _cache, mock_ssh, mock_up, mock_ids): + """Test running shell command with -- separator.""" + mock_ids.return_value = ["myws"] + mock_up.return_value = MagicMock(returncode=0) + mock_ssh.return_value = 0 + with patch.object(sys, "argv", ["dl", "myws", "--", "echo", "hello"]): + result = main() + assert result == 0 + mock_ssh.assert_called_once_with("myws", "echo hello") + + @patch("devlaunch.dl.get_workspace_ids") + @patch("devlaunch.dl.workspace_up") + @patch("devlaunch.dl.workspace_ssh") + @patch("devlaunch.dl.update_cache_background") + def test_main_workspace_default(self, _cache, mock_ssh, mock_up, mock_ids): + """Test default workspace start and attach.""" + mock_ids.return_value = ["myws"] + mock_up.return_value = MagicMock(returncode=0) + mock_ssh.return_value = 0 + with patch.object(sys, "argv", ["dl", "myws"]): + result = main() + assert result == 0 + mock_up.assert_called_once() + mock_ssh.assert_called_once() + + @patch("devlaunch.dl.get_workspace_ids") + @patch("devlaunch.dl.expand_workspace_spec") + @patch("devlaunch.dl.spec_to_workspace_id") + @patch("devlaunch.dl.workspace_up") + @patch("devlaunch.dl.workspace_ssh") + @patch("devlaunch.dl.update_cache_background") + def test_main_new_workspace_from_repo( + self, _cache, mock_ssh, mock_up, mock_spec_id, mock_expand, mock_ids + ): + """Test creating workspace from owner/repo.""" + mock_ids.return_value = [] # Not existing + mock_expand.return_value = "github.com/owner/repo" + mock_spec_id.return_value = "repo" + mock_up.return_value = MagicMock(returncode=0) + mock_ssh.return_value = 0 + with patch.object(sys, "argv", ["dl", "owner/repo"]): + result = main() + assert result == 0 + mock_expand.assert_called() + mock_up.assert_called_once_with("github.com/owner/repo") + mock_ssh.assert_called_once_with("repo", None)