devlaunch 0.0.1__py2.py3-none-any.whl
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.
- devlaunch/__init__.py +0 -0
- devlaunch/completion.py +100 -0
- devlaunch/completion_loader.py +12 -0
- devlaunch/completions/__init__.py +0 -0
- devlaunch/completions/dl.bash +78 -0
- devlaunch/dl.py +684 -0
- devlaunch-0.0.1.dist-info/METADATA +134 -0
- devlaunch-0.0.1.dist-info/RECORD +11 -0
- devlaunch-0.0.1.dist-info/WHEEL +5 -0
- devlaunch-0.0.1.dist-info/entry_points.txt +2 -0
- devlaunch-0.0.1.dist-info/licenses/LICENSE +21 -0
devlaunch/__init__.py
ADDED
|
File without changes
|
devlaunch/completion.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Utilities for installing shell autocompletion scripts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from .completion_loader import load_completion_script
|
|
11
|
+
|
|
12
|
+
_RC_BLOCK_START = "# >>> devlaunch completions >>>"
|
|
13
|
+
_RC_BLOCK_END = "# <<< devlaunch completions <<<"
|
|
14
|
+
|
|
15
|
+
# Legacy blocks to clean up from previous installations
|
|
16
|
+
_LEGACY_BLOCKS = {
|
|
17
|
+
"# dl completion": "# end dl completion",
|
|
18
|
+
"# dp completion": "# end dp completion",
|
|
19
|
+
_RC_BLOCK_START: _RC_BLOCK_END,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
_LEGACY_SINGLE_LINES = {
|
|
23
|
+
"complete -F _dl_completion dl",
|
|
24
|
+
"complete -F _dp_completion dp",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _completion_file_path() -> pathlib.Path:
|
|
29
|
+
"""Return the path where the completion script should be stored."""
|
|
30
|
+
override = os.environ.get("DEVLAUNCH_COMPLETION_FILE")
|
|
31
|
+
if override:
|
|
32
|
+
return pathlib.Path(override).expanduser()
|
|
33
|
+
return pathlib.Path.home() / ".config" / "devlaunch" / "completions.sh"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def install_completions(rc_path: Optional[pathlib.Path] = None) -> int:
|
|
37
|
+
"""Install or refresh completion scripts for dl."""
|
|
38
|
+
completion_path = _completion_file_path().expanduser()
|
|
39
|
+
rc_target = (rc_path if rc_path is not None else pathlib.Path.home() / ".bashrc").expanduser()
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
completion_path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
|
|
44
|
+
# Write the dl completion script
|
|
45
|
+
completion_script = load_completion_script("dl").rstrip() + "\n"
|
|
46
|
+
completion_path.write_text(completion_script, encoding="utf-8")
|
|
47
|
+
logging.info("Wrote completion script to %s", completion_path)
|
|
48
|
+
|
|
49
|
+
rc_target.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
existing_content = ""
|
|
51
|
+
if rc_target.exists():
|
|
52
|
+
existing_content = rc_target.read_text(encoding="utf-8")
|
|
53
|
+
|
|
54
|
+
# Remove any existing completion block and legacy blocks before appending
|
|
55
|
+
def remove_block(text: str, start: str, end: str) -> str:
|
|
56
|
+
lines = text.splitlines()
|
|
57
|
+
out = []
|
|
58
|
+
skip = False
|
|
59
|
+
for line in lines:
|
|
60
|
+
if line.strip() == start:
|
|
61
|
+
skip = True
|
|
62
|
+
continue
|
|
63
|
+
if skip and line.strip() == end:
|
|
64
|
+
skip = False
|
|
65
|
+
continue
|
|
66
|
+
if not skip:
|
|
67
|
+
out.append(line)
|
|
68
|
+
return "\n".join(out)
|
|
69
|
+
|
|
70
|
+
# Remove main block
|
|
71
|
+
cleaned_content = remove_block(existing_content, _RC_BLOCK_START, _RC_BLOCK_END)
|
|
72
|
+
# Remove legacy blocks
|
|
73
|
+
for block_start, block_end in _LEGACY_BLOCKS.items():
|
|
74
|
+
cleaned_content = remove_block(cleaned_content, block_start, block_end)
|
|
75
|
+
# Remove legacy single lines
|
|
76
|
+
cleaned_lines = []
|
|
77
|
+
for line in cleaned_content.splitlines():
|
|
78
|
+
if not any(line.strip().startswith(single) for single in _LEGACY_SINGLE_LINES):
|
|
79
|
+
cleaned_lines.append(line)
|
|
80
|
+
cleaned_content = "\n".join(cleaned_lines).strip()
|
|
81
|
+
escaped_path = str(completion_path).replace('"', r"\"")
|
|
82
|
+
source_line = f'source "{escaped_path}"'
|
|
83
|
+
block = "\n".join([_RC_BLOCK_START, source_line, _RC_BLOCK_END])
|
|
84
|
+
|
|
85
|
+
if cleaned_content:
|
|
86
|
+
new_content = f"{cleaned_content}\n\n{block}\n"
|
|
87
|
+
else:
|
|
88
|
+
new_content = f"{block}\n"
|
|
89
|
+
|
|
90
|
+
rc_target.write_text(new_content, encoding="utf-8")
|
|
91
|
+
logging.info("Added completion source block to %s", rc_target)
|
|
92
|
+
logging.info("Run 'source %s' or restart your terminal to enable completion", rc_target)
|
|
93
|
+
print(
|
|
94
|
+
"[devlaunch] Autocomplete has been updated. Run 'source %s' or restart your terminal to enable completion."
|
|
95
|
+
% rc_target
|
|
96
|
+
)
|
|
97
|
+
return 0
|
|
98
|
+
except OSError as error:
|
|
99
|
+
logging.error("Failed to install completions: %s", error)
|
|
100
|
+
return 1
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Helpers for loading embedded completion scripts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import resources
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_completion_script(name: str) -> str:
|
|
9
|
+
"""Return the text of the embedded completion script."""
|
|
10
|
+
package = "devlaunch.completions"
|
|
11
|
+
resource = resources.files(package).joinpath(f"{name}.bash")
|
|
12
|
+
return resource.read_text(encoding="utf-8")
|
|
File without changes
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# dl completion
|
|
2
|
+
_dl_completion() {
|
|
3
|
+
local cur prev opts
|
|
4
|
+
COMPREPLY=()
|
|
5
|
+
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
6
|
+
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
|
7
|
+
|
|
8
|
+
# Command options
|
|
9
|
+
opts="--ls --repos --stop --rm --code --status --recreate --reset --install --help"
|
|
10
|
+
|
|
11
|
+
# Flag completion
|
|
12
|
+
if [[ ${cur} == -* ]]; then
|
|
13
|
+
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
|
|
14
|
+
return 0
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
# Cache file location (honors XDG_CACHE_HOME)
|
|
18
|
+
local cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/dl"
|
|
19
|
+
local cache_file="$cache_dir/completions.bash"
|
|
20
|
+
|
|
21
|
+
# Initialize completion variables
|
|
22
|
+
local DL_WORKSPACES=""
|
|
23
|
+
local DL_REPOS=""
|
|
24
|
+
local DL_OWNERS=""
|
|
25
|
+
|
|
26
|
+
# Source the bash cache file (fast, no jq needed)
|
|
27
|
+
if [[ -f "$cache_file" ]]; then
|
|
28
|
+
source "$cache_file"
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
# Commands that need workspace completion
|
|
32
|
+
if [[ "$prev" == "--stop" || "$prev" == "--rm" || "$prev" == "--code" || "$prev" == "--status" || "$prev" == "--recreate" || "$prev" == "--reset" ]]; then
|
|
33
|
+
if [[ -n "$DL_WORKSPACES" ]]; then
|
|
34
|
+
COMPREPLY=( $(compgen -W "${DL_WORKSPACES}" -- ${cur}) )
|
|
35
|
+
fi
|
|
36
|
+
return 0
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
# First positional argument: workspace, owner/repo, or path
|
|
40
|
+
if [[ ${COMP_CWORD} -eq 1 ]]; then
|
|
41
|
+
# Don't add space after completion to allow @branch suffix
|
|
42
|
+
compopt -o nospace
|
|
43
|
+
|
|
44
|
+
# If typing a path, complete files/directories
|
|
45
|
+
if [[ "$cur" == ./* || "$cur" == /* || "$cur" == ~/* ]]; then
|
|
46
|
+
compopt +o nospace
|
|
47
|
+
COMPREPLY=( $(compgen -d -- ${cur}) )
|
|
48
|
+
return 0
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
# Check if completing owner/repo format (contains /)
|
|
52
|
+
if [[ "$cur" == */* ]]; then
|
|
53
|
+
# Complete from known repos
|
|
54
|
+
if [[ -n "$DL_REPOS" ]]; then
|
|
55
|
+
COMPREPLY=( $(compgen -W "${DL_REPOS}" -- ${cur}) )
|
|
56
|
+
fi
|
|
57
|
+
return 0
|
|
58
|
+
fi
|
|
59
|
+
|
|
60
|
+
# Default: complete workspace names and offer owner/ completion
|
|
61
|
+
local completions="$DL_WORKSPACES"
|
|
62
|
+
|
|
63
|
+
# Add owners with trailing slash
|
|
64
|
+
for owner in $DL_OWNERS; do
|
|
65
|
+
completions="$completions ${owner}/"
|
|
66
|
+
done
|
|
67
|
+
|
|
68
|
+
if [[ -n "$completions" ]]; then
|
|
69
|
+
COMPREPLY=( $(compgen -W "${completions}" -- ${cur}) )
|
|
70
|
+
fi
|
|
71
|
+
return 0
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
return 0
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
complete -F _dl_completion dl
|
|
78
|
+
# end dl completion
|
devlaunch/dl.py
ADDED
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dl - DevLaunch CLI
|
|
3
|
+
|
|
4
|
+
A streamlined CLI for devpod with intuitive autocomplete and fzf fuzzy selection.
|
|
5
|
+
Provides an renv-like UX for managing devcontainer workspaces.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
dl # fzf selector for existing workspaces
|
|
9
|
+
dl <workspace> # open/create workspace, attach shell
|
|
10
|
+
dl <workspace> <command> # run command in workspace
|
|
11
|
+
dl owner/repo # create from git repo (github.com)
|
|
12
|
+
dl owner/repo@branch # specific branch
|
|
13
|
+
dl ./path # create from local path
|
|
14
|
+
dl --ls # list workspaces
|
|
15
|
+
dl --stop <workspace> # stop workspace
|
|
16
|
+
dl --rm <workspace> # delete workspace
|
|
17
|
+
dl --code <workspace> # open in VS Code
|
|
18
|
+
dl --install # install completions
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import sys
|
|
22
|
+
import subprocess
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import os
|
|
26
|
+
import pathlib
|
|
27
|
+
import re
|
|
28
|
+
from typing import List, Optional, Dict, Any
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
from .completion import install_completions
|
|
32
|
+
|
|
33
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_cache_dir() -> pathlib.Path:
|
|
37
|
+
"""Get the cache directory, honoring XDG_CACHE_HOME."""
|
|
38
|
+
xdg_cache = os.environ.get("XDG_CACHE_HOME")
|
|
39
|
+
if xdg_cache:
|
|
40
|
+
return pathlib.Path(xdg_cache) / "dl"
|
|
41
|
+
return pathlib.Path.home() / ".cache" / "dl"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Cache configuration (honors XDG_CACHE_HOME)
|
|
45
|
+
CACHE_DIR = _get_cache_dir()
|
|
46
|
+
CACHE_FILE = CACHE_DIR / "completions.json"
|
|
47
|
+
BASH_CACHE_FILE = CACHE_DIR / "completions.bash"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_cache_path() -> pathlib.Path:
|
|
51
|
+
"""Get the path to the completion cache file."""
|
|
52
|
+
return CACHE_FILE
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def read_completion_cache() -> Optional[Dict[str, Any]]:
|
|
56
|
+
"""Read completion data from cache file."""
|
|
57
|
+
cache_path = get_cache_path()
|
|
58
|
+
if not cache_path.exists():
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
with open(cache_path, encoding="utf-8") as f:
|
|
62
|
+
return json.load(f)
|
|
63
|
+
except (OSError, json.JSONDecodeError):
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def write_completion_cache(data: Dict[str, Any]) -> None:
|
|
68
|
+
"""Write completion data to cache file (JSON format)."""
|
|
69
|
+
cache_path = get_cache_path()
|
|
70
|
+
try:
|
|
71
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
with open(cache_path, "w", encoding="utf-8") as f:
|
|
73
|
+
json.dump(data, f)
|
|
74
|
+
except OSError:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def write_bash_completion_cache(data: Dict[str, Any]) -> None:
|
|
79
|
+
"""Write completion data as a sourceable bash file."""
|
|
80
|
+
try:
|
|
81
|
+
BASH_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
workspaces = " ".join(data.get("workspaces", []))
|
|
83
|
+
repos = " ".join(data.get("repos", []))
|
|
84
|
+
owners = " ".join(data.get("owners", []))
|
|
85
|
+
lines = [
|
|
86
|
+
"# Auto-generated by dl - do not edit",
|
|
87
|
+
f'DL_WORKSPACES="{workspaces}"',
|
|
88
|
+
f'DL_REPOS="{repos}"',
|
|
89
|
+
f'DL_OWNERS="{owners}"',
|
|
90
|
+
]
|
|
91
|
+
with open(BASH_CACHE_FILE, "w", encoding="utf-8") as f:
|
|
92
|
+
f.write("\n".join(lines) + "\n")
|
|
93
|
+
except OSError:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def update_completion_cache() -> Dict[str, Any]:
|
|
98
|
+
"""Update the completion cache with current data."""
|
|
99
|
+
workspaces = list_workspaces()
|
|
100
|
+
workspace_ids = [ws.id for ws in workspaces]
|
|
101
|
+
repos = discover_repos_from_workspaces(workspaces)
|
|
102
|
+
|
|
103
|
+
# Flatten repos to list of owner/repo strings
|
|
104
|
+
known_repos = []
|
|
105
|
+
for owner, repo_list in sorted(repos.items()):
|
|
106
|
+
for repo in sorted(repo_list):
|
|
107
|
+
known_repos.append(f"{owner}/{repo}")
|
|
108
|
+
|
|
109
|
+
# Extract unique owners
|
|
110
|
+
owners = sorted(repos.keys())
|
|
111
|
+
|
|
112
|
+
data = {
|
|
113
|
+
"workspaces": workspace_ids,
|
|
114
|
+
"repos": known_repos,
|
|
115
|
+
"owners": owners,
|
|
116
|
+
}
|
|
117
|
+
write_completion_cache(data)
|
|
118
|
+
write_bash_completion_cache(data)
|
|
119
|
+
return data
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def update_cache_background() -> None:
|
|
123
|
+
"""Update completion cache in background."""
|
|
124
|
+
try:
|
|
125
|
+
# pylint: disable=consider-using-with
|
|
126
|
+
subprocess.Popen(
|
|
127
|
+
[sys.executable, "-m", "devlaunch.dl", "--update-cache"],
|
|
128
|
+
stdout=subprocess.DEVNULL,
|
|
129
|
+
stderr=subprocess.DEVNULL,
|
|
130
|
+
start_new_session=True,
|
|
131
|
+
)
|
|
132
|
+
except OSError:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# Regex to match owner/repo[@branch] format (not a path, not already a URL)
|
|
137
|
+
|
|
138
|
+
OWNER_REPO_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+(@[a-zA-Z0-9_./%-]+)?$")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def is_path_spec(spec: str) -> bool:
|
|
142
|
+
"""Check if spec looks like a filesystem path."""
|
|
143
|
+
return spec.startswith("./") or spec.startswith("/") or spec.startswith("~")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def is_git_spec(spec: str) -> bool:
|
|
147
|
+
"""Check if spec looks like a git repo (owner/repo or URL)."""
|
|
148
|
+
# Paths are not git specs
|
|
149
|
+
if is_path_spec(spec):
|
|
150
|
+
return False
|
|
151
|
+
if "://" in spec:
|
|
152
|
+
return True
|
|
153
|
+
if spec.startswith("github.com/") or spec.startswith("gitlab.com/"):
|
|
154
|
+
return True
|
|
155
|
+
return bool(OWNER_REPO_PATTERN.match(spec))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def expand_workspace_spec(spec: str) -> str:
|
|
159
|
+
"""Expand owner/repo[@branch] to github.com/owner/repo[@branch] for devpod."""
|
|
160
|
+
# Don't expand if it's a path
|
|
161
|
+
if is_path_spec(spec):
|
|
162
|
+
return spec
|
|
163
|
+
# Don't expand if it already looks like a URL
|
|
164
|
+
if "://" in spec or spec.startswith("github.com/") or spec.startswith("gitlab.com/"):
|
|
165
|
+
return spec
|
|
166
|
+
# Check if it matches owner/repo[@branch] pattern
|
|
167
|
+
if OWNER_REPO_PATTERN.match(spec):
|
|
168
|
+
return f"github.com/{spec}"
|
|
169
|
+
# Otherwise return as-is (existing workspace name)
|
|
170
|
+
return spec
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def spec_to_workspace_id(spec: str) -> str:
|
|
174
|
+
"""Derive the workspace ID that devpod will use for a given spec.
|
|
175
|
+
|
|
176
|
+
Devpod uses:
|
|
177
|
+
- For git repos: the repo name (e.g., owner/repo -> repo)
|
|
178
|
+
- For paths: the directory name (e.g., ./my-project -> my-project)
|
|
179
|
+
- For existing IDs: the ID as-is
|
|
180
|
+
"""
|
|
181
|
+
# Strip @branch suffix if present
|
|
182
|
+
base_spec = spec.split("@")[0]
|
|
183
|
+
|
|
184
|
+
# For paths, use the directory name
|
|
185
|
+
if is_path_spec(base_spec):
|
|
186
|
+
return pathlib.Path(base_spec).expanduser().resolve().name
|
|
187
|
+
|
|
188
|
+
# For git URLs or owner/repo, extract repo name
|
|
189
|
+
if is_git_spec(base_spec):
|
|
190
|
+
# Handle various formats: github.com/owner/repo, owner/repo, https://...
|
|
191
|
+
parts = base_spec.rstrip("/").split("/")
|
|
192
|
+
repo_name = parts[-1]
|
|
193
|
+
# Remove .git suffix if present
|
|
194
|
+
if repo_name.endswith(".git"):
|
|
195
|
+
repo_name = repo_name[:-4]
|
|
196
|
+
return repo_name
|
|
197
|
+
|
|
198
|
+
# Otherwise assume it's already a workspace ID
|
|
199
|
+
return spec
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def validate_workspace_spec(spec: str, existing_ids: List[str]) -> Optional[str]:
|
|
203
|
+
"""Validate workspace spec and return error message if invalid."""
|
|
204
|
+
# Valid if it's an existing workspace
|
|
205
|
+
if spec in existing_ids:
|
|
206
|
+
return None
|
|
207
|
+
# Valid if it's a path
|
|
208
|
+
if is_path_spec(spec):
|
|
209
|
+
return None
|
|
210
|
+
# Valid if it's a git spec (owner/repo or URL)
|
|
211
|
+
if is_git_spec(spec):
|
|
212
|
+
return None
|
|
213
|
+
# Invalid - provide helpful error
|
|
214
|
+
return f"Unknown workspace '{spec}'. Use 'dl --ls' to list workspaces, or specify owner/repo or ./path"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass
|
|
218
|
+
class Workspace:
|
|
219
|
+
"""Represents a devpod workspace."""
|
|
220
|
+
|
|
221
|
+
id: str
|
|
222
|
+
source_type: str # "local" or "git"
|
|
223
|
+
source: str
|
|
224
|
+
last_used: str
|
|
225
|
+
provider: str
|
|
226
|
+
ide: str
|
|
227
|
+
|
|
228
|
+
@classmethod
|
|
229
|
+
def from_json(cls, data: Dict[str, Any]) -> "Workspace":
|
|
230
|
+
"""Parse workspace from devpod JSON output."""
|
|
231
|
+
source = data.get("source", {})
|
|
232
|
+
if "localFolder" in source:
|
|
233
|
+
source_type = "local"
|
|
234
|
+
source_path = source["localFolder"]
|
|
235
|
+
elif "gitRepository" in source:
|
|
236
|
+
source_type = "git"
|
|
237
|
+
source_path = source["gitRepository"]
|
|
238
|
+
else:
|
|
239
|
+
source_type = "unknown"
|
|
240
|
+
source_path = str(source)
|
|
241
|
+
|
|
242
|
+
return cls(
|
|
243
|
+
id=data.get("id", ""),
|
|
244
|
+
source_type=source_type,
|
|
245
|
+
source=source_path,
|
|
246
|
+
last_used=data.get("lastUsed", ""),
|
|
247
|
+
provider=data.get("provider", {}).get("name", ""),
|
|
248
|
+
ide=data.get("ide", {}).get("name", ""),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# Regex patterns for parsing git URLs
|
|
253
|
+
GIT_URL_PATTERNS = [
|
|
254
|
+
# git@github.com:owner/repo.git
|
|
255
|
+
re.compile(r"git@github\.com:([^/]+)/([^/]+?)(?:\.git)?$"),
|
|
256
|
+
# https://github.com/owner/repo.git or https://github.com/owner/repo
|
|
257
|
+
re.compile(r"https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?$"),
|
|
258
|
+
# github.com/owner/repo
|
|
259
|
+
re.compile(r"^github\.com/([^/]+)/([^/]+?)(?:\.git)?$"),
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def parse_owner_repo_from_url(url: str) -> Optional[tuple]:
|
|
264
|
+
"""Extract (owner, repo) from a git URL."""
|
|
265
|
+
for pattern in GIT_URL_PATTERNS:
|
|
266
|
+
match = pattern.match(url)
|
|
267
|
+
if match:
|
|
268
|
+
return (match.group(1), match.group(2))
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def get_git_remote_url(path: str) -> Optional[str]:
|
|
273
|
+
"""Get the origin remote URL from a git repository."""
|
|
274
|
+
try:
|
|
275
|
+
result = subprocess.run(
|
|
276
|
+
["git", "-C", path, "remote", "get-url", "origin"],
|
|
277
|
+
capture_output=True,
|
|
278
|
+
text=True,
|
|
279
|
+
check=False,
|
|
280
|
+
)
|
|
281
|
+
if result.returncode == 0:
|
|
282
|
+
return result.stdout.strip()
|
|
283
|
+
except (OSError, subprocess.SubprocessError):
|
|
284
|
+
pass
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def get_git_branches(path: str) -> List[str]:
|
|
289
|
+
"""Get list of branches from a git repository."""
|
|
290
|
+
try:
|
|
291
|
+
result = subprocess.run(
|
|
292
|
+
["git", "-C", path, "branch", "-r"],
|
|
293
|
+
capture_output=True,
|
|
294
|
+
text=True,
|
|
295
|
+
check=False,
|
|
296
|
+
)
|
|
297
|
+
if result.returncode == 0:
|
|
298
|
+
branches = []
|
|
299
|
+
for line in result.stdout.strip().split("\n"):
|
|
300
|
+
line = line.strip()
|
|
301
|
+
if line and "origin/" in line and "HEAD" not in line:
|
|
302
|
+
branch = line.replace("origin/", "")
|
|
303
|
+
branches.append(branch)
|
|
304
|
+
return branches
|
|
305
|
+
except (OSError, subprocess.SubprocessError):
|
|
306
|
+
pass
|
|
307
|
+
return []
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def discover_repos_from_workspaces(workspaces: List[Workspace]) -> Dict[str, List[str]]:
|
|
311
|
+
"""Discover owner/repo from workspace git remotes.
|
|
312
|
+
|
|
313
|
+
Returns dict mapping owner -> list of repos.
|
|
314
|
+
"""
|
|
315
|
+
repos: Dict[str, List[str]] = {}
|
|
316
|
+
|
|
317
|
+
for ws in workspaces:
|
|
318
|
+
owner_repo = None
|
|
319
|
+
|
|
320
|
+
# For git workspaces, parse the source URL directly
|
|
321
|
+
if ws.source_type == "git":
|
|
322
|
+
owner_repo = parse_owner_repo_from_url(ws.source)
|
|
323
|
+
|
|
324
|
+
# For local workspaces, try to get git remote
|
|
325
|
+
elif ws.source_type == "local" and ws.source:
|
|
326
|
+
remote_url = get_git_remote_url(ws.source)
|
|
327
|
+
if remote_url:
|
|
328
|
+
owner_repo = parse_owner_repo_from_url(remote_url)
|
|
329
|
+
|
|
330
|
+
if owner_repo:
|
|
331
|
+
owner, repo = owner_repo
|
|
332
|
+
if owner not in repos:
|
|
333
|
+
repos[owner] = []
|
|
334
|
+
if repo not in repos[owner]:
|
|
335
|
+
repos[owner].append(repo)
|
|
336
|
+
|
|
337
|
+
return repos
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def get_known_repos() -> List[str]:
|
|
341
|
+
"""Get list of known owner/repo strings from workspaces."""
|
|
342
|
+
workspaces = list_workspaces()
|
|
343
|
+
repos = discover_repos_from_workspaces(workspaces)
|
|
344
|
+
result = []
|
|
345
|
+
for owner, repo_list in sorted(repos.items()):
|
|
346
|
+
for repo in sorted(repo_list):
|
|
347
|
+
result.append(f"{owner}/{repo}")
|
|
348
|
+
return result
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def run_devpod(args: List[str], capture: bool = False) -> subprocess.CompletedProcess:
|
|
352
|
+
"""Run a devpod command.
|
|
353
|
+
|
|
354
|
+
Security note: Using list form of subprocess.run (not shell=True) prevents
|
|
355
|
+
command injection. Each list element is passed as a separate argument to
|
|
356
|
+
the executable, so special characters are not interpreted by a shell.
|
|
357
|
+
"""
|
|
358
|
+
cmd = ["devpod"] + args
|
|
359
|
+
logging.debug("Running: %s", " ".join(cmd))
|
|
360
|
+
if capture:
|
|
361
|
+
# nosec B603 - using list form, not shell=True; no command injection risk
|
|
362
|
+
return subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
363
|
+
# nosec B603 - using list form, not shell=True; no command injection risk
|
|
364
|
+
return subprocess.run(cmd, check=False)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def list_workspaces() -> List[Workspace]:
|
|
368
|
+
"""List all devpod workspaces."""
|
|
369
|
+
result = run_devpod(["list", "--output", "json"], capture=True)
|
|
370
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
371
|
+
return []
|
|
372
|
+
try:
|
|
373
|
+
data = json.loads(result.stdout)
|
|
374
|
+
return [Workspace.from_json(ws) for ws in data]
|
|
375
|
+
except json.JSONDecodeError:
|
|
376
|
+
logging.error("Failed to parse devpod output")
|
|
377
|
+
return []
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def get_workspace_ids() -> List[str]:
|
|
381
|
+
"""Get list of workspace IDs for completion."""
|
|
382
|
+
return [ws.id for ws in list_workspaces()]
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def print_workspaces():
|
|
386
|
+
"""Print workspace list in a nice format."""
|
|
387
|
+
workspaces = list_workspaces()
|
|
388
|
+
if not workspaces:
|
|
389
|
+
print("No workspaces found.")
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
# Calculate column widths
|
|
393
|
+
id_width = max(len(ws.id) for ws in workspaces)
|
|
394
|
+
type_width = max(len(ws.source_type) for ws in workspaces)
|
|
395
|
+
source_width = max(len(ws.source) for ws in workspaces)
|
|
396
|
+
|
|
397
|
+
# Print header
|
|
398
|
+
print(
|
|
399
|
+
f"{'WORKSPACE':<{id_width}} {'TYPE':<{type_width}} {'SOURCE':<{source_width}} LAST USED"
|
|
400
|
+
)
|
|
401
|
+
print("-" * (id_width + type_width + source_width + 30))
|
|
402
|
+
|
|
403
|
+
# Print rows
|
|
404
|
+
for ws in workspaces:
|
|
405
|
+
last_used = ws.last_used[:19].replace("T", " ") if ws.last_used else "never"
|
|
406
|
+
print(
|
|
407
|
+
f"{ws.id:<{id_width}} {ws.source_type:<{type_width}} {ws.source:<{source_width}} {last_used}"
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def fuzzy_select_workspace() -> Optional[str]:
|
|
412
|
+
"""Interactive fuzzy finder for workspace selection."""
|
|
413
|
+
try:
|
|
414
|
+
from iterfzf import iterfzf
|
|
415
|
+
except ImportError:
|
|
416
|
+
logging.error("iterfzf not available. Install with: pip install iterfzf")
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
workspaces = list_workspaces()
|
|
420
|
+
if not workspaces:
|
|
421
|
+
logging.info("No workspaces found. Create one with: dl owner/repo or dl ./path")
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
# Format options for display: "id | type | source"
|
|
425
|
+
options = []
|
|
426
|
+
ws_map = {}
|
|
427
|
+
for ws in workspaces:
|
|
428
|
+
label = f"{ws.id} | {ws.source_type} | {ws.source}"
|
|
429
|
+
options.append(label)
|
|
430
|
+
ws_map[label] = ws.id
|
|
431
|
+
|
|
432
|
+
print("Select workspace (type to filter):")
|
|
433
|
+
try:
|
|
434
|
+
selected = iterfzf(options, multi=False)
|
|
435
|
+
except KeyboardInterrupt:
|
|
436
|
+
return None
|
|
437
|
+
if selected:
|
|
438
|
+
return ws_map.get(selected)
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def workspace_up(
|
|
443
|
+
workspace: str, ide: Optional[str] = None, recreate: bool = False, reset: bool = False
|
|
444
|
+
):
|
|
445
|
+
"""Start or create a workspace."""
|
|
446
|
+
args = ["up", workspace]
|
|
447
|
+
if ide:
|
|
448
|
+
args.extend(["--ide", ide])
|
|
449
|
+
if recreate:
|
|
450
|
+
args.append("--recreate")
|
|
451
|
+
if reset:
|
|
452
|
+
args.append("--reset")
|
|
453
|
+
return run_devpod(args)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def workspace_ssh(workspace: str, command: Optional[str] = None) -> int:
|
|
457
|
+
"""SSH into a workspace, optionally running a command."""
|
|
458
|
+
args = ["ssh", workspace]
|
|
459
|
+
if command:
|
|
460
|
+
args.extend(["--command", command])
|
|
461
|
+
result = run_devpod(args)
|
|
462
|
+
return result.returncode
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def workspace_stop(workspace: str) -> int:
|
|
466
|
+
"""Stop a workspace."""
|
|
467
|
+
result = run_devpod(["stop", workspace])
|
|
468
|
+
return result.returncode
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def workspace_delete(workspace: str) -> int:
|
|
472
|
+
"""Delete a workspace."""
|
|
473
|
+
result = run_devpod(["delete", workspace])
|
|
474
|
+
return result.returncode
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def workspace_status(workspace: str) -> int:
|
|
478
|
+
"""Get status of a workspace."""
|
|
479
|
+
result = run_devpod(["status", workspace])
|
|
480
|
+
return result.returncode
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def print_help():
|
|
484
|
+
"""Print usage help."""
|
|
485
|
+
help_text = """dl - DevLaunch CLI
|
|
486
|
+
|
|
487
|
+
Usage:
|
|
488
|
+
dl Interactive workspace selector (fzf)
|
|
489
|
+
dl <workspace> Start workspace and attach shell
|
|
490
|
+
dl <workspace> <command> Run command in workspace
|
|
491
|
+
dl owner/repo Create workspace from GitHub repo
|
|
492
|
+
dl owner/repo@branch Create workspace from specific branch
|
|
493
|
+
dl ./path Create workspace from local path
|
|
494
|
+
|
|
495
|
+
Commands:
|
|
496
|
+
--ls List all workspaces
|
|
497
|
+
--stop <workspace> Stop a workspace
|
|
498
|
+
--rm <workspace> Delete a workspace
|
|
499
|
+
--code <workspace> Open workspace in VS Code
|
|
500
|
+
--status <workspace> Show workspace status
|
|
501
|
+
--recreate <workspace> Recreate workspace container
|
|
502
|
+
--reset <workspace> Reset workspace (clean slate)
|
|
503
|
+
--install Install shell completions
|
|
504
|
+
--help, -h Show this help
|
|
505
|
+
|
|
506
|
+
Examples:
|
|
507
|
+
dl # Select workspace with fzf
|
|
508
|
+
dl myproject # Open existing workspace
|
|
509
|
+
dl loft-sh/devpod # Create from GitHub
|
|
510
|
+
dl blooop/devlaunch@main # Create from specific branch
|
|
511
|
+
dl ./my-project # Create from local folder
|
|
512
|
+
dl --code myproject # Open in VS Code
|
|
513
|
+
dl myproject 'make test' # Run command in workspace
|
|
514
|
+
"""
|
|
515
|
+
print(help_text)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def main() -> int:
|
|
519
|
+
"""Main entry point for dl CLI."""
|
|
520
|
+
args = sys.argv[1:]
|
|
521
|
+
|
|
522
|
+
# Handle help
|
|
523
|
+
if not args or (len(args) == 1 and args[0] in ("--help", "-h")):
|
|
524
|
+
if not args:
|
|
525
|
+
# No args - try fzf selection
|
|
526
|
+
selected = fuzzy_select_workspace()
|
|
527
|
+
if not selected:
|
|
528
|
+
print_help()
|
|
529
|
+
return 1
|
|
530
|
+
workspace_up(selected)
|
|
531
|
+
return workspace_ssh(selected)
|
|
532
|
+
print_help()
|
|
533
|
+
return 0
|
|
534
|
+
|
|
535
|
+
# Handle flags
|
|
536
|
+
if args[0] == "--ls":
|
|
537
|
+
print_workspaces()
|
|
538
|
+
return 0
|
|
539
|
+
|
|
540
|
+
if args[0] == "--repos":
|
|
541
|
+
# Output known repos for bash completion (uses cache if available)
|
|
542
|
+
cache = read_completion_cache()
|
|
543
|
+
if cache and "repos" in cache:
|
|
544
|
+
for repo in cache["repos"]:
|
|
545
|
+
print(repo)
|
|
546
|
+
else:
|
|
547
|
+
for repo in get_known_repos():
|
|
548
|
+
print(repo)
|
|
549
|
+
return 0
|
|
550
|
+
|
|
551
|
+
if args[0] == "--update-cache":
|
|
552
|
+
# Update completion cache (called in background)
|
|
553
|
+
update_completion_cache()
|
|
554
|
+
return 0
|
|
555
|
+
|
|
556
|
+
if args[0] == "--completion-data":
|
|
557
|
+
# Output all completion data as JSON (fast, from cache)
|
|
558
|
+
cache = read_completion_cache()
|
|
559
|
+
if cache:
|
|
560
|
+
print(json.dumps(cache))
|
|
561
|
+
else:
|
|
562
|
+
# No cache, generate and cache it
|
|
563
|
+
data = update_completion_cache()
|
|
564
|
+
print(json.dumps(data))
|
|
565
|
+
return 0
|
|
566
|
+
|
|
567
|
+
if args[0] == "--install":
|
|
568
|
+
rc_path = None
|
|
569
|
+
if len(args) > 1:
|
|
570
|
+
rc_path = pathlib.Path(args[1])
|
|
571
|
+
# Generate cache so completions work immediately
|
|
572
|
+
update_completion_cache()
|
|
573
|
+
return install_completions(rc_path)
|
|
574
|
+
|
|
575
|
+
if args[0] == "--stop":
|
|
576
|
+
if len(args) < 2:
|
|
577
|
+
workspace = fuzzy_select_workspace()
|
|
578
|
+
if not workspace:
|
|
579
|
+
logging.error("Usage: dl --stop <workspace>")
|
|
580
|
+
return 1
|
|
581
|
+
else:
|
|
582
|
+
workspace = args[1]
|
|
583
|
+
return workspace_stop(workspace)
|
|
584
|
+
|
|
585
|
+
if args[0] == "--rm":
|
|
586
|
+
if len(args) < 2:
|
|
587
|
+
workspace = fuzzy_select_workspace()
|
|
588
|
+
if not workspace:
|
|
589
|
+
logging.error("Usage: dl --rm <workspace>")
|
|
590
|
+
return 1
|
|
591
|
+
else:
|
|
592
|
+
workspace = args[1]
|
|
593
|
+
return workspace_delete(workspace)
|
|
594
|
+
|
|
595
|
+
if args[0] == "--status":
|
|
596
|
+
if len(args) < 2:
|
|
597
|
+
workspace = fuzzy_select_workspace()
|
|
598
|
+
if not workspace:
|
|
599
|
+
logging.error("Usage: dl --status <workspace>")
|
|
600
|
+
return 1
|
|
601
|
+
else:
|
|
602
|
+
workspace = args[1]
|
|
603
|
+
return workspace_status(workspace)
|
|
604
|
+
|
|
605
|
+
if args[0] == "--code":
|
|
606
|
+
if len(args) < 2:
|
|
607
|
+
workspace = fuzzy_select_workspace()
|
|
608
|
+
if not workspace:
|
|
609
|
+
logging.error("Usage: dl --code <workspace>")
|
|
610
|
+
return 1
|
|
611
|
+
else:
|
|
612
|
+
workspace = args[1]
|
|
613
|
+
result = workspace_up(workspace, ide="vscode")
|
|
614
|
+
return result.returncode
|
|
615
|
+
|
|
616
|
+
if args[0] == "--recreate":
|
|
617
|
+
if len(args) < 2:
|
|
618
|
+
workspace = fuzzy_select_workspace()
|
|
619
|
+
if not workspace:
|
|
620
|
+
logging.error("Usage: dl --recreate <workspace>")
|
|
621
|
+
return 1
|
|
622
|
+
else:
|
|
623
|
+
workspace = args[1]
|
|
624
|
+
workspace_spec = expand_workspace_spec(workspace)
|
|
625
|
+
workspace_id = spec_to_workspace_id(workspace)
|
|
626
|
+
result = workspace_up(workspace_spec, recreate=True)
|
|
627
|
+
if result.returncode != 0:
|
|
628
|
+
return result.returncode
|
|
629
|
+
return workspace_ssh(workspace_id)
|
|
630
|
+
|
|
631
|
+
if args[0] == "--reset":
|
|
632
|
+
if len(args) < 2:
|
|
633
|
+
workspace = fuzzy_select_workspace()
|
|
634
|
+
if not workspace:
|
|
635
|
+
logging.error("Usage: dl --reset <workspace>")
|
|
636
|
+
return 1
|
|
637
|
+
else:
|
|
638
|
+
workspace = args[1]
|
|
639
|
+
workspace_spec = expand_workspace_spec(workspace)
|
|
640
|
+
workspace_id = spec_to_workspace_id(workspace)
|
|
641
|
+
result = workspace_up(workspace_spec, reset=True)
|
|
642
|
+
if result.returncode != 0:
|
|
643
|
+
return result.returncode
|
|
644
|
+
return workspace_ssh(workspace_id)
|
|
645
|
+
|
|
646
|
+
# Default: workspace name and optional command
|
|
647
|
+
raw_spec = args[0]
|
|
648
|
+
command = " ".join(args[1:]) if len(args) > 1 else None
|
|
649
|
+
|
|
650
|
+
# Validate the workspace spec
|
|
651
|
+
existing_ids = get_workspace_ids()
|
|
652
|
+
error = validate_workspace_spec(raw_spec, existing_ids)
|
|
653
|
+
if error:
|
|
654
|
+
logging.error(error)
|
|
655
|
+
return 1
|
|
656
|
+
|
|
657
|
+
# Use raw spec as-is if it's an existing workspace ID, otherwise expand
|
|
658
|
+
# This prevents owner/repo-style workspace IDs from being rewritten
|
|
659
|
+
if raw_spec in existing_ids:
|
|
660
|
+
workspace_spec = raw_spec
|
|
661
|
+
workspace_id = raw_spec
|
|
662
|
+
else:
|
|
663
|
+
workspace_spec = expand_workspace_spec(raw_spec)
|
|
664
|
+
workspace_id = spec_to_workspace_id(raw_spec)
|
|
665
|
+
|
|
666
|
+
# Start the workspace
|
|
667
|
+
result = workspace_up(workspace_spec)
|
|
668
|
+
if result.returncode != 0:
|
|
669
|
+
return result.returncode
|
|
670
|
+
|
|
671
|
+
# Attach to workspace using the ID (not the full spec)
|
|
672
|
+
ret = workspace_ssh(workspace_id, command)
|
|
673
|
+
|
|
674
|
+
# Update cache in background after workspace operations
|
|
675
|
+
update_cache_background()
|
|
676
|
+
|
|
677
|
+
return ret
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
if __name__ == "__main__":
|
|
681
|
+
try:
|
|
682
|
+
sys.exit(main())
|
|
683
|
+
except KeyboardInterrupt:
|
|
684
|
+
sys.exit(130)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devlaunch
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: DevLaunch - A streamlined CLI for devpod workspaces
|
|
5
|
+
Project-URL: Source, https://github.com/blooop/devlaunch
|
|
6
|
+
Project-URL: Home, https://github.com/blooop/devlaunch
|
|
7
|
+
Author-email: Austin Gregg-Smith <blooop@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: iterfzf>=1.0.0
|
|
11
|
+
Provides-Extra: test
|
|
12
|
+
Requires-Dist: coverage<=7.13.1,>=7.5.4; extra == 'test'
|
|
13
|
+
Requires-Dist: hypothesis<=6.150.2,>=6.104.2; extra == 'test'
|
|
14
|
+
Requires-Dist: prek<0.3.0,>=0.2.28; extra == 'test'
|
|
15
|
+
Requires-Dist: pylint<=4.0.4,>=3.2.5; extra == 'test'
|
|
16
|
+
Requires-Dist: pytest-cov<=7.0.0,>=4.1; extra == 'test'
|
|
17
|
+
Requires-Dist: pytest<=9.0.2,>=7.4; extra == 'test'
|
|
18
|
+
Requires-Dist: ruff<=0.14.13,>=0.5.0; extra == 'test'
|
|
19
|
+
Requires-Dist: ty<=0.0.12,>=0.0.12; extra == 'test'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# devlaunch
|
|
23
|
+
|
|
24
|
+
A streamlined CLI for [devpod](https://devpod.sh) with intuitive autocomplete and fzf fuzzy selection.
|
|
25
|
+
|
|
26
|
+
## Continuous Integration Status
|
|
27
|
+
|
|
28
|
+
[](https://github.com/blooop/devlaunch/actions/workflows/ci.yml?query=branch%3Amain)
|
|
29
|
+
[](https://codecov.io/gh/blooop/devlaunch)
|
|
30
|
+
[](https://GitHub.com/blooop/devlaunch/issues/)
|
|
31
|
+
[](https://github.com/blooop/devlaunch/pulls?q=is%3Amerged)
|
|
32
|
+
[](https://GitHub.com/blooop/devlaunch/releases/)
|
|
33
|
+
[](https://opensource.org/license/mit/)
|
|
34
|
+
[](https://www.python.org/downloads/)
|
|
35
|
+
[](https://pixi.sh)
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Using pixi (recommended)
|
|
41
|
+
pixi global install devlaunch
|
|
42
|
+
|
|
43
|
+
# Using pip
|
|
44
|
+
pip install devlaunch
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
After installation, set up shell completions:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
dl --install
|
|
51
|
+
source ~/.bashrc # or restart your terminal
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
dl # Interactive workspace selector (fzf)
|
|
58
|
+
dl <workspace> # Start workspace and attach shell
|
|
59
|
+
dl <workspace> <command> # Run command in workspace
|
|
60
|
+
dl owner/repo # Create workspace from GitHub repo
|
|
61
|
+
dl owner/repo@branch # Create workspace from specific branch
|
|
62
|
+
dl ./path # Create workspace from local path
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Commands
|
|
66
|
+
|
|
67
|
+
| Command | Description |
|
|
68
|
+
|---------|-------------|
|
|
69
|
+
| `dl --ls` | List all workspaces |
|
|
70
|
+
| `dl --stop <workspace>` | Stop a workspace |
|
|
71
|
+
| `dl --rm <workspace>` | Delete a workspace |
|
|
72
|
+
| `dl --code <workspace>` | Open workspace in VS Code |
|
|
73
|
+
| `dl --status <workspace>` | Show workspace status |
|
|
74
|
+
| `dl --recreate <workspace>` | Recreate workspace container |
|
|
75
|
+
| `dl --reset <workspace>` | Reset workspace (clean slate) |
|
|
76
|
+
| `dl --install` | Install shell completions |
|
|
77
|
+
| `dl --help` | Show help |
|
|
78
|
+
|
|
79
|
+
## Examples
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# Select workspace interactively with fzf
|
|
83
|
+
dl
|
|
84
|
+
|
|
85
|
+
# Open an existing workspace
|
|
86
|
+
dl myproject
|
|
87
|
+
|
|
88
|
+
# Create workspace from GitHub repository
|
|
89
|
+
dl loft-sh/devpod
|
|
90
|
+
|
|
91
|
+
# Create workspace from specific branch
|
|
92
|
+
dl blooop/devlaunch@main
|
|
93
|
+
|
|
94
|
+
# Create workspace from local folder
|
|
95
|
+
dl ./my-project
|
|
96
|
+
|
|
97
|
+
# Open workspace in VS Code
|
|
98
|
+
dl --code myproject
|
|
99
|
+
|
|
100
|
+
# Run a command in workspace
|
|
101
|
+
dl myproject 'make test'
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Features
|
|
105
|
+
|
|
106
|
+
- **Fuzzy Selection**: When called without arguments, uses fzf for interactive workspace selection
|
|
107
|
+
- **Smart Completion**: Tab completion for workspaces, GitHub repos (owner/repo format), and paths
|
|
108
|
+
- **GitHub Shorthand**: Use `owner/repo` instead of full URLs - automatically expands to `github.com/owner/repo`
|
|
109
|
+
- **Branch Support**: Specify branches with `owner/repo@branch` syntax
|
|
110
|
+
- **Fast Autocomplete**: Completion cache for ~3ms response time (vs ~700ms without cache)
|
|
111
|
+
|
|
112
|
+
## Shell Completion
|
|
113
|
+
|
|
114
|
+
After running `dl --install`, you get intelligent tab completion:
|
|
115
|
+
|
|
116
|
+
- Workspace names from your devpod list
|
|
117
|
+
- Known GitHub owners and repositories from your workspaces
|
|
118
|
+
- File/directory paths when starting with `./`, `/`, or `~`
|
|
119
|
+
- All command flags (`--ls`, `--stop`, etc.)
|
|
120
|
+
|
|
121
|
+
## Development
|
|
122
|
+
|
|
123
|
+
This project uses [pixi](https://pixi.sh) for environment management.
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
# Run tests
|
|
127
|
+
pixi run test
|
|
128
|
+
|
|
129
|
+
# Run full CI suite
|
|
130
|
+
pixi run ci
|
|
131
|
+
|
|
132
|
+
# Format and lint
|
|
133
|
+
pixi run style
|
|
134
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
devlaunch/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
devlaunch/completion.py,sha256=qUcniRSrKqYcaRZN2UQghEv0LPEEU5DOTqGj6qPAaRA,3803
|
|
3
|
+
devlaunch/completion_loader.py,sha256=WnRpd54O6xvhNOTbASEcRuBJp0QiatZFXm4wCSZa0Mg,384
|
|
4
|
+
devlaunch/dl.py,sha256=qtS-P0LsYxd6Xn4pXKqwmt-sRqoXjLXbPm-sGtGNBhI,21995
|
|
5
|
+
devlaunch/completions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
devlaunch/completions/dl.bash,sha256=EYRsRDCPbgXadaXMIdMykANhTVMdE1nFstxeNBofEsc,2347
|
|
7
|
+
devlaunch-0.0.1.dist-info/METADATA,sha256=cQg_GsXAPRTP_URzNVLDTh0EWpLPwju3ggbOOuweBE4,4625
|
|
8
|
+
devlaunch-0.0.1.dist-info/WHEEL,sha256=aha0VrrYvgDJ3Xxl3db_g_MDIW-ZexDdrc_m-Hk8YY4,105
|
|
9
|
+
devlaunch-0.0.1.dist-info/entry_points.txt,sha256=w3g7P_f852z_BJGEkOBQzg734meOv1GXWInNpjaTUKY,41
|
|
10
|
+
devlaunch-0.0.1.dist-info/licenses/LICENSE,sha256=BELp7Z_UlGhotfEDzDkWiCw7MtJ12i7B3rhIjebfk0k,1075
|
|
11
|
+
devlaunch-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Austin Gregg-Smith
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|