dev-code 0.1.0__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.
- dev_code-0.1.0.dist-info/METADATA +5 -0
- dev_code-0.1.0.dist-info/RECORD +8 -0
- dev_code-0.1.0.dist-info/WHEEL +4 -0
- dev_code-0.1.0.dist-info/entry_points.txt +2 -0
- dev_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- dev_code.py +691 -0
- dev_code_templates/dev-code/.devcontainer/Dockerfile +56 -0
- dev_code_templates/dev-code/.devcontainer/devcontainer.json +20 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
dev_code.py,sha256=ft32yZbbyIFy3A3lKaglTAm-WDa40Jfh91qQw8UwSEc,23680
|
|
2
|
+
dev_code_templates/dev-code/.devcontainer/Dockerfile,sha256=drzCJYodMLgaYoEKT3GgQfUfduzqjEh0cf8Pce4tjuQ,1748
|
|
3
|
+
dev_code_templates/dev-code/.devcontainer/devcontainer.json,sha256=IHjjg1v151GsiT_KHNjUfTrp5JpiKH7lEvEsLIX9nTk,604
|
|
4
|
+
dev_code-0.1.0.dist-info/METADATA,sha256=balfl4DEGFufvM-SElHyGN7Kek5Izh6SfQEbVMR5z8E,98
|
|
5
|
+
dev_code-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
6
|
+
dev_code-0.1.0.dist-info/entry_points.txt,sha256=OzdJScH9Xuti6o-IJ0kK1EntOs9WiYWxv2_jHUN5JmI,43
|
|
7
|
+
dev_code-0.1.0.dist-info/licenses/LICENSE,sha256=Qy3ZbsaYLHGW7pDXkM5xKFicJrZrfjhysMEWJ-rmtIE,1072
|
|
8
|
+
dev_code-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nasser Alansari
|
|
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.
|
dev_code.py
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("dev-code")
|
|
13
|
+
|
|
14
|
+
BANNER = (
|
|
15
|
+
" _ _\n"
|
|
16
|
+
" | | | |\n"
|
|
17
|
+
" __| | _____ ________ ___ ___ __| | ___\n"
|
|
18
|
+
" / _` |/ _ \\ \\ / /______/ __/ _ \\ / _` |/ _ \\\n"
|
|
19
|
+
"| (_| | __/\\ V / | (_| (_) | (_| | __/\n"
|
|
20
|
+
" \\__,_|\\___| \\_/ \\___\\___/ \\__,_|\\___|\n"
|
|
21
|
+
" project · editor · container — simplified "
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _configure_logging(verbose: bool) -> None:
|
|
26
|
+
"""Configure the module logger. Guard prevents double-registration."""
|
|
27
|
+
if logger.handlers:
|
|
28
|
+
return
|
|
29
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
30
|
+
handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
|
|
31
|
+
logger.addHandler(handler)
|
|
32
|
+
logger.setLevel(logging.DEBUG if verbose else logging.WARNING)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_wsl() -> bool:
|
|
36
|
+
"""Detect WSL (but avoid Docker containers) by reading /proc/version."""
|
|
37
|
+
if "WSLENV" not in os.environ:
|
|
38
|
+
return False
|
|
39
|
+
try:
|
|
40
|
+
with open("/proc/version", "r") as f:
|
|
41
|
+
content = f.read()
|
|
42
|
+
return "Microsoft" in content or "WSL" in content
|
|
43
|
+
except Exception:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def wsl_to_windows(path: str) -> str:
|
|
48
|
+
"""Convert WSL path to Windows path using wslpath."""
|
|
49
|
+
try:
|
|
50
|
+
return subprocess.check_output(
|
|
51
|
+
["wslpath", "-w", path],
|
|
52
|
+
text=True
|
|
53
|
+
).strip()
|
|
54
|
+
except subprocess.CalledProcessError as e:
|
|
55
|
+
raise RuntimeError(f"Failed to convert path with wslpath: {path}") from e
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolve_template_dir() -> str:
|
|
59
|
+
"""Return the user template directory (DEVCODE_TEMPLATE_DIR or XDG default)."""
|
|
60
|
+
override = os.environ.get("DEVCODE_TEMPLATE_DIR")
|
|
61
|
+
if override:
|
|
62
|
+
return override
|
|
63
|
+
xdg = os.environ.get("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
|
|
64
|
+
return os.path.join(xdg, "dev-code", "templates")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_builtin_template_path(name: str) -> str | None:
|
|
68
|
+
"""Return absolute path to a bundled template directory, or None if not found."""
|
|
69
|
+
module_dir = os.path.dirname(os.path.abspath(__file__))
|
|
70
|
+
candidate = os.path.join(module_dir, "dev_code_templates", name)
|
|
71
|
+
if os.path.isdir(candidate):
|
|
72
|
+
return candidate
|
|
73
|
+
try:
|
|
74
|
+
import importlib.resources as pkg_resources
|
|
75
|
+
ref = pkg_resources.files("dev_code_templates").joinpath(name)
|
|
76
|
+
if ref.is_dir():
|
|
77
|
+
return str(ref)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
logger.debug("importlib.resources fallback failed for %r: %s", name, e)
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def resolve_template(name: str) -> str:
|
|
84
|
+
"""Return absolute path to template's devcontainer.json. Exits on failure."""
|
|
85
|
+
user_path = os.path.join(resolve_template_dir(), name, ".devcontainer", "devcontainer.json")
|
|
86
|
+
if os.path.exists(user_path):
|
|
87
|
+
return user_path
|
|
88
|
+
builtin = get_builtin_template_path(name)
|
|
89
|
+
if builtin:
|
|
90
|
+
path = os.path.join(builtin, ".devcontainer", "devcontainer.json")
|
|
91
|
+
if os.path.exists(path):
|
|
92
|
+
return path
|
|
93
|
+
logger.error("template not found: %s", name)
|
|
94
|
+
sys.exit(1)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_devcontainer_uri(host_path: str, config_file: str, container_folder: str) -> str:
|
|
98
|
+
# Handle WSL conversion
|
|
99
|
+
if is_wsl():
|
|
100
|
+
host_path = wsl_to_windows(host_path)
|
|
101
|
+
config_file = wsl_to_windows(config_file)
|
|
102
|
+
|
|
103
|
+
# Build JSON
|
|
104
|
+
data = {
|
|
105
|
+
"hostPath": host_path,
|
|
106
|
+
"configFile": {
|
|
107
|
+
"$mid": 1,
|
|
108
|
+
"path": config_file,
|
|
109
|
+
"scheme": "file"
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# Compact JSON (important!)
|
|
114
|
+
json_str = json.dumps(data, separators=(",", ":"))
|
|
115
|
+
|
|
116
|
+
# Hex encode
|
|
117
|
+
hex_str = json_str.encode("utf-8").hex()
|
|
118
|
+
|
|
119
|
+
# Build URI
|
|
120
|
+
return f"vscode-remote://dev-container+{hex_str}{container_folder}"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def parse_devcontainer_json(config_file: str):
|
|
125
|
+
"""Parse devcontainer.json. Returns (dict, cli_used: bool).
|
|
126
|
+
|
|
127
|
+
Tries in order: devcontainer CLI, jq, Python json+re fallback.
|
|
128
|
+
"""
|
|
129
|
+
# Strategy 1: devcontainer CLI (resolves ${localEnv:VAR} automatically)
|
|
130
|
+
if shutil.which("devcontainer"):
|
|
131
|
+
result = subprocess.run(
|
|
132
|
+
["devcontainer", "read-configuration", "--config", config_file],
|
|
133
|
+
capture_output=True, text=True
|
|
134
|
+
)
|
|
135
|
+
if result.returncode == 0:
|
|
136
|
+
try:
|
|
137
|
+
data = json.loads(result.stdout)
|
|
138
|
+
# read-configuration wraps output; extract .configuration if present
|
|
139
|
+
return data.get("configuration", data), True
|
|
140
|
+
except json.JSONDecodeError:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
# Strategy 2: jq
|
|
144
|
+
if shutil.which("jq"):
|
|
145
|
+
result = subprocess.run(
|
|
146
|
+
["jq", ".", config_file],
|
|
147
|
+
capture_output=True, text=True
|
|
148
|
+
)
|
|
149
|
+
if result.returncode == 0:
|
|
150
|
+
try:
|
|
151
|
+
return json.loads(result.stdout), False
|
|
152
|
+
except json.JSONDecodeError:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
# Strategy 3: Python json + re fallback
|
|
156
|
+
with open(config_file) as f:
|
|
157
|
+
content = f.read()
|
|
158
|
+
|
|
159
|
+
# Strip full-line // comments (re.MULTILINE makes ^ match start of each line)
|
|
160
|
+
content = re.sub(r"^\s*//[^\n]*\n?", "", content, flags=re.MULTILINE)
|
|
161
|
+
# Strip trailing commas before } or ]
|
|
162
|
+
content = re.sub(r",(\s*[}\]])", r"\1", content)
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
return json.loads(content), False
|
|
166
|
+
except json.JSONDecodeError as e:
|
|
167
|
+
logger.error("failed to parse %s: %s", config_file, e)
|
|
168
|
+
sys.exit(1)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def wait_for_container(config_file: str, project_path: str, timeout: int) -> str:
|
|
172
|
+
"""Poll Docker until a devcontainer for project_path starts. Returns container ID."""
|
|
173
|
+
label_value = wsl_to_windows(project_path) if is_wsl() else project_path
|
|
174
|
+
deadline = time.time() + timeout
|
|
175
|
+
|
|
176
|
+
while time.time() < deadline:
|
|
177
|
+
result = subprocess.run(
|
|
178
|
+
[
|
|
179
|
+
"docker", "container", "ls",
|
|
180
|
+
"--filter", f"label=devcontainer.local_folder={label_value}",
|
|
181
|
+
"--filter", f"label=devcontainer.config_file={config_file}",
|
|
182
|
+
"--format", "{{.ID}}",
|
|
183
|
+
],
|
|
184
|
+
capture_output=True, text=True,
|
|
185
|
+
)
|
|
186
|
+
ids = [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
187
|
+
if ids:
|
|
188
|
+
if len(ids) > 1:
|
|
189
|
+
logger.warning("multiple containers matched label; using first (%s)", ids[0])
|
|
190
|
+
return ids[0]
|
|
191
|
+
time.sleep(2)
|
|
192
|
+
|
|
193
|
+
logger.error(
|
|
194
|
+
"timed out waiting for container (label=devcontainer.local_folder=%s); "
|
|
195
|
+
"path format mismatch may be the cause (e.g. in WSL, Windows path vs Linux path)",
|
|
196
|
+
label_value,
|
|
197
|
+
)
|
|
198
|
+
sys.exit(1)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _substitute_env_vars(s: str):
|
|
202
|
+
"""Resolve ${localEnv:VAR} patterns. Returns resolved string, or None if any var unset/empty."""
|
|
203
|
+
for match in re.finditer(r"\$\{localEnv:([^}]+)\}", s):
|
|
204
|
+
var = match.group(1)
|
|
205
|
+
if not os.environ.get(var):
|
|
206
|
+
return None
|
|
207
|
+
return re.sub(r"\$\{localEnv:([^}]+)\}", lambda m: os.environ[m.group(1)], s)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _docker_run(cmd: list, label: str) -> bool:
|
|
211
|
+
"""Run a full docker command list. Logs warning and returns False on failure."""
|
|
212
|
+
result = subprocess.run(cmd, capture_output=True)
|
|
213
|
+
if result.returncode != 0:
|
|
214
|
+
logger.warning("%s failed (exit %d)", label, result.returncode)
|
|
215
|
+
if result.stderr:
|
|
216
|
+
logger.debug("%s stderr: %s", label, result.stderr.decode(errors="replace").strip())
|
|
217
|
+
return False
|
|
218
|
+
return True
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _list_dir_children(source_dir: str) -> list:
|
|
222
|
+
"""Return absolute paths of all children in source_dir (includes dot files)."""
|
|
223
|
+
return [os.path.join(source_dir, name) for name in os.listdir(source_dir)]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _process_entry(container_id: str, entry: dict, cli_used: bool, idx: int, config_dir: str) -> None:
|
|
227
|
+
"""Process a single customizations.dev-code.cp copy entry."""
|
|
228
|
+
# Warn on wrong-cased Override key
|
|
229
|
+
for key in entry:
|
|
230
|
+
if key.lower() == "override" and key != "Override":
|
|
231
|
+
logger.warning("entry %d uses '%s' — use 'Override' (capital O); ignoring", idx, key)
|
|
232
|
+
|
|
233
|
+
source = entry.get("source")
|
|
234
|
+
target = entry.get("target")
|
|
235
|
+
|
|
236
|
+
if not source or not target:
|
|
237
|
+
missing = "source" if not source else "target"
|
|
238
|
+
logger.warning("entry %d missing '%s', skipping", idx, missing)
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
# Step 1: Env var substitution + relative path resolution
|
|
242
|
+
if not cli_used:
|
|
243
|
+
resolved = _substitute_env_vars(source)
|
|
244
|
+
if resolved is None:
|
|
245
|
+
logger.warning("entry %d source env var unset or empty, skipping", idx)
|
|
246
|
+
return
|
|
247
|
+
source = resolved
|
|
248
|
+
|
|
249
|
+
dot_expand = source.endswith("/.")
|
|
250
|
+
if dot_expand:
|
|
251
|
+
source = source[:-2]
|
|
252
|
+
if not source:
|
|
253
|
+
source = "/" # source was "/." — strip of 2-char string leaves empty; treat as root
|
|
254
|
+
if not os.path.isabs(source):
|
|
255
|
+
source = os.path.abspath(os.path.join(config_dir, source))
|
|
256
|
+
if dot_expand:
|
|
257
|
+
source = source + "/."
|
|
258
|
+
|
|
259
|
+
# Step 2: Source expansion for dir-contents (source/.)
|
|
260
|
+
if source.endswith("/."):
|
|
261
|
+
if not target.endswith("/"):
|
|
262
|
+
logger.warning(
|
|
263
|
+
"entry %d source ends with '/.' but target '%s' has no trailing '/'; appending '/' to target",
|
|
264
|
+
idx, target,
|
|
265
|
+
)
|
|
266
|
+
target = target + "/"
|
|
267
|
+
actual_dir = source[:-2]
|
|
268
|
+
if not os.path.isdir(actual_dir):
|
|
269
|
+
logger.warning("entry %d source dir not found or not a directory: %s, skipping", idx, actual_dir)
|
|
270
|
+
return
|
|
271
|
+
# Empty dir is a silent no-op
|
|
272
|
+
for child_path in _list_dir_children(actual_dir):
|
|
273
|
+
child_entry = dict(entry)
|
|
274
|
+
child_entry["source"] = child_path
|
|
275
|
+
child_entry["target"] = target
|
|
276
|
+
_process_entry(container_id, child_entry, cli_used=True, idx=idx, config_dir=config_dir)
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
# Step 3: Check source exists
|
|
280
|
+
if not os.path.exists(source):
|
|
281
|
+
logger.warning("entry %d source not found: %s, skipping", idx, source)
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
# Step 4: Classify source type (file vs dir-itself; docker cp handles both natively)
|
|
285
|
+
# source_is_dir = os.path.isdir(source) # not branched on — docker cp handles both
|
|
286
|
+
|
|
287
|
+
# Step 5: Compute effective target (for override check and chown/chmod only)
|
|
288
|
+
if target.endswith("/"):
|
|
289
|
+
effective = target.rstrip("/") + "/" + os.path.basename(source)
|
|
290
|
+
else:
|
|
291
|
+
effective = target
|
|
292
|
+
|
|
293
|
+
# Step 6: Override check (before any side effects)
|
|
294
|
+
override = entry.get("Override", False)
|
|
295
|
+
if not override:
|
|
296
|
+
result = subprocess.run(
|
|
297
|
+
["docker", "exec", container_id, "test", "-e", effective],
|
|
298
|
+
capture_output=True,
|
|
299
|
+
)
|
|
300
|
+
if result.returncode == 0:
|
|
301
|
+
logger.info("entry %d effective target '%s' exists and Override=false, skipping", idx, effective)
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
# Step 7: Pre-create dirs
|
|
305
|
+
if target.endswith("/"):
|
|
306
|
+
_docker_run(["docker", "exec", container_id, "mkdir", "-p", target], f"entry {idx} mkdir {target}")
|
|
307
|
+
else:
|
|
308
|
+
parent = os.path.dirname(target)
|
|
309
|
+
if parent:
|
|
310
|
+
_docker_run(["docker", "exec", container_id, "mkdir", "-p", parent], f"entry {idx} mkdir {parent}")
|
|
311
|
+
|
|
312
|
+
# Step 8: Copy
|
|
313
|
+
ok = _docker_run(["docker", "cp", source, f"{container_id}:{target}"], f"entry {idx} cp")
|
|
314
|
+
if not ok:
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
# Step 9: chown / chmod (only if copy succeeded; applied to effective)
|
|
318
|
+
owner = entry.get("owner")
|
|
319
|
+
group = entry.get("group")
|
|
320
|
+
if owner and group:
|
|
321
|
+
_docker_run(
|
|
322
|
+
["docker", "exec", "-u", "root", container_id, "chown", "-R", f"{owner}:{group}", effective],
|
|
323
|
+
f"entry {idx} chown",
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
permissions = entry.get("permissions")
|
|
327
|
+
if permissions:
|
|
328
|
+
_docker_run(
|
|
329
|
+
["docker", "exec", "-u", "root", container_id, "chmod", "-R", permissions, effective],
|
|
330
|
+
f"entry {idx} chmod",
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def run_post_launch(config_file: str, project_path: str, timeout: int) -> None:
|
|
335
|
+
"""Parse devcontainer.json and run customizations.dev-code.cp copy entries."""
|
|
336
|
+
data, cli_used = parse_devcontainer_json(config_file)
|
|
337
|
+
|
|
338
|
+
dev_code_section = data.get("customizations", {}).get("dev-code")
|
|
339
|
+
if dev_code_section is None:
|
|
340
|
+
return
|
|
341
|
+
if not isinstance(dev_code_section, dict):
|
|
342
|
+
logger.error("customizations.dev-code must be a dict in %s", config_file)
|
|
343
|
+
sys.exit(1)
|
|
344
|
+
|
|
345
|
+
entries = dev_code_section.get("cp")
|
|
346
|
+
if not entries:
|
|
347
|
+
return
|
|
348
|
+
if not isinstance(entries, list):
|
|
349
|
+
logger.error("customizations.dev-code.cp must be a list in %s", config_file)
|
|
350
|
+
sys.exit(1)
|
|
351
|
+
|
|
352
|
+
container_id = wait_for_container(config_file, project_path, timeout)
|
|
353
|
+
|
|
354
|
+
config_dir = os.path.dirname(os.path.abspath(config_file))
|
|
355
|
+
for idx, entry in enumerate(entries):
|
|
356
|
+
_process_entry(container_id, entry, cli_used, idx, config_dir)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def cmd_open(args) -> None:
|
|
360
|
+
"""open subcommand: open a project in VS Code using a devcontainer template."""
|
|
361
|
+
config_file = resolve_template(args.template)
|
|
362
|
+
|
|
363
|
+
project_path = os.path.abspath(args.projectpath)
|
|
364
|
+
if project_path == "/":
|
|
365
|
+
logger.error("projectpath must not resolve to /")
|
|
366
|
+
sys.exit(1)
|
|
367
|
+
|
|
368
|
+
container_folder = args.container_folder or f"/workspaces/{os.path.basename(project_path)}"
|
|
369
|
+
uri = build_devcontainer_uri(project_path, config_file, container_folder)
|
|
370
|
+
|
|
371
|
+
if args.dry_run:
|
|
372
|
+
_cmd_open_dry_run(config_file, project_path, uri)
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
if not shutil.which("code"):
|
|
376
|
+
logger.error("'code' not found on PATH")
|
|
377
|
+
sys.exit(1)
|
|
378
|
+
|
|
379
|
+
subprocess.Popen(["code", "--folder-uri", uri], start_new_session=True)
|
|
380
|
+
run_post_launch(config_file, project_path, args.timeout)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _cmd_open_dry_run(config_file: str, project_path: str, uri: str) -> None:
|
|
384
|
+
"""Print dry-run plan to stdout without executing anything."""
|
|
385
|
+
print(f"Config: {config_file}")
|
|
386
|
+
print(f"URI: {uri}")
|
|
387
|
+
|
|
388
|
+
data, cli_used = parse_devcontainer_json(config_file)
|
|
389
|
+
dev_code_section = data.get("customizations", {}).get("dev-code")
|
|
390
|
+
entries = []
|
|
391
|
+
if isinstance(dev_code_section, dict):
|
|
392
|
+
raw = dev_code_section.get("cp")
|
|
393
|
+
if isinstance(raw, list):
|
|
394
|
+
entries = raw
|
|
395
|
+
|
|
396
|
+
if not entries:
|
|
397
|
+
print("(dry run — no copy entries)")
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
print("Copy plan:")
|
|
401
|
+
config_dir = os.path.dirname(os.path.abspath(config_file))
|
|
402
|
+
for idx, entry in enumerate(entries):
|
|
403
|
+
source = entry.get("source", "")
|
|
404
|
+
target = entry.get("target", "(no target)")
|
|
405
|
+
|
|
406
|
+
# Env var substitution
|
|
407
|
+
if not cli_used:
|
|
408
|
+
unset_vars = [m.group(1) for m in re.finditer(r"\$\{localEnv:([^}]+)\}", source)
|
|
409
|
+
if not os.environ.get(m.group(1))]
|
|
410
|
+
if unset_vars:
|
|
411
|
+
logger.warning("entry %d: env var unset: %s", idx, ", ".join(unset_vars))
|
|
412
|
+
print(f" [{idx}] <unset: {unset_vars[0]}> → {target}")
|
|
413
|
+
continue
|
|
414
|
+
source = re.sub(r"\$\{localEnv:([^}]+)\}", lambda m: os.environ[m.group(1)], source)
|
|
415
|
+
|
|
416
|
+
# Relative path resolution
|
|
417
|
+
dot_expand = source.endswith("/.")
|
|
418
|
+
if dot_expand:
|
|
419
|
+
source = source[:-2] or "/"
|
|
420
|
+
if not os.path.isabs(source):
|
|
421
|
+
source = os.path.abspath(os.path.join(config_dir, source))
|
|
422
|
+
if dot_expand:
|
|
423
|
+
source += "/."
|
|
424
|
+
|
|
425
|
+
annotation = " [missing]" if not os.path.exists(source.rstrip("/.")) else ""
|
|
426
|
+
print(f" [{idx}] {source}{annotation} → {target}")
|
|
427
|
+
|
|
428
|
+
print("(dry run — no operations executed)")
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def cmd_new(args) -> None:
|
|
432
|
+
"""Create a new template by copying a base template."""
|
|
433
|
+
template_dir = resolve_template_dir()
|
|
434
|
+
dest = os.path.join(template_dir, args.name)
|
|
435
|
+
|
|
436
|
+
# Step 1: fail if name already exists
|
|
437
|
+
if os.path.exists(dest):
|
|
438
|
+
logger.error("template '%s' already exists: %s", args.name, dest)
|
|
439
|
+
sys.exit(1)
|
|
440
|
+
|
|
441
|
+
# Step 2: resolve base (check before creating dirs)
|
|
442
|
+
base_name = args.base or "dev-code"
|
|
443
|
+
base_user = os.path.join(template_dir, base_name)
|
|
444
|
+
if os.path.isdir(base_user):
|
|
445
|
+
base_src = base_user
|
|
446
|
+
else:
|
|
447
|
+
builtin = get_builtin_template_path(base_name)
|
|
448
|
+
if builtin:
|
|
449
|
+
base_src = builtin
|
|
450
|
+
else:
|
|
451
|
+
logger.error("base template not found: %s", base_name)
|
|
452
|
+
sys.exit(1)
|
|
453
|
+
|
|
454
|
+
# Step 3-4: create template dir
|
|
455
|
+
try:
|
|
456
|
+
os.makedirs(template_dir, exist_ok=True)
|
|
457
|
+
except OSError as e:
|
|
458
|
+
logger.error("cannot create template dir %s: %s", template_dir, e)
|
|
459
|
+
sys.exit(1)
|
|
460
|
+
|
|
461
|
+
# Step 5: copy
|
|
462
|
+
shutil.copytree(base_src, dest)
|
|
463
|
+
print(f"Created template '{args.name}' at {dest}")
|
|
464
|
+
|
|
465
|
+
# Step 6: --edit
|
|
466
|
+
if args.edit:
|
|
467
|
+
open_args = argparse.Namespace(
|
|
468
|
+
template=args.name,
|
|
469
|
+
projectpath=dest,
|
|
470
|
+
container_folder=None,
|
|
471
|
+
timeout=300,
|
|
472
|
+
dry_run=False,
|
|
473
|
+
)
|
|
474
|
+
cmd_open(open_args)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def cmd_edit(args) -> None:
|
|
478
|
+
"""Open a template directory for editing using the built-in dev-code devcontainer."""
|
|
479
|
+
template_dir = resolve_template_dir()
|
|
480
|
+
|
|
481
|
+
if args.template is None:
|
|
482
|
+
if not os.path.isdir(template_dir):
|
|
483
|
+
logger.error("template dir not found: %s — run 'dev-code init' first", template_dir)
|
|
484
|
+
sys.exit(1)
|
|
485
|
+
project_path = template_dir
|
|
486
|
+
else:
|
|
487
|
+
project_path = os.path.join(template_dir, args.template)
|
|
488
|
+
if not os.path.isdir(project_path):
|
|
489
|
+
logger.error("template not found: %s", args.template)
|
|
490
|
+
sys.exit(1)
|
|
491
|
+
|
|
492
|
+
open_args = argparse.Namespace(
|
|
493
|
+
template="dev-code",
|
|
494
|
+
projectpath=project_path,
|
|
495
|
+
container_folder=None,
|
|
496
|
+
timeout=300,
|
|
497
|
+
dry_run=False,
|
|
498
|
+
)
|
|
499
|
+
cmd_open(open_args)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def cmd_init(args) -> None:
|
|
503
|
+
"""Seed the built-in dev-code template into the user template dir."""
|
|
504
|
+
builtin = get_builtin_template_path("dev-code")
|
|
505
|
+
if builtin is None:
|
|
506
|
+
logger.error("built-in template 'dev-code' not found — packaging error")
|
|
507
|
+
sys.exit(1)
|
|
508
|
+
|
|
509
|
+
template_dir = resolve_template_dir()
|
|
510
|
+
dest = os.path.join(template_dir, "dev-code")
|
|
511
|
+
|
|
512
|
+
if os.path.exists(dest):
|
|
513
|
+
print(f"Skipped 'dev-code': already exists at {dest}")
|
|
514
|
+
return
|
|
515
|
+
|
|
516
|
+
try:
|
|
517
|
+
os.makedirs(template_dir, exist_ok=True)
|
|
518
|
+
except OSError as e:
|
|
519
|
+
logger.error("cannot create template dir %s: %s", template_dir, e)
|
|
520
|
+
sys.exit(1)
|
|
521
|
+
|
|
522
|
+
try:
|
|
523
|
+
shutil.copytree(builtin, dest)
|
|
524
|
+
except Exception as e:
|
|
525
|
+
logger.error("copy failed: %s", e)
|
|
526
|
+
sys.exit(1)
|
|
527
|
+
|
|
528
|
+
print(f"Copied built-in 'dev-code' to {dest}")
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def cmd_list(args) -> None:
|
|
532
|
+
"""List available templates."""
|
|
533
|
+
# Collect built-ins
|
|
534
|
+
builtins = []
|
|
535
|
+
module_dir = os.path.dirname(os.path.abspath(__file__))
|
|
536
|
+
builtin_base = os.path.join(module_dir, "dev_code_templates")
|
|
537
|
+
if os.path.isdir(builtin_base):
|
|
538
|
+
for name in sorted(os.listdir(builtin_base)):
|
|
539
|
+
p = os.path.join(builtin_base, name)
|
|
540
|
+
if os.path.isdir(p):
|
|
541
|
+
builtins.append((name, p))
|
|
542
|
+
|
|
543
|
+
# Collect user templates
|
|
544
|
+
template_dir = resolve_template_dir()
|
|
545
|
+
user = []
|
|
546
|
+
if os.path.isdir(template_dir):
|
|
547
|
+
for name in sorted(os.listdir(template_dir)):
|
|
548
|
+
p = os.path.join(template_dir, name)
|
|
549
|
+
if os.path.isdir(p):
|
|
550
|
+
user.append((name, p))
|
|
551
|
+
|
|
552
|
+
if not args.long:
|
|
553
|
+
for name, _ in builtins:
|
|
554
|
+
print(name)
|
|
555
|
+
for name, _ in user:
|
|
556
|
+
print(name)
|
|
557
|
+
if not user and not builtins:
|
|
558
|
+
print("(no templates — run 'dev-code init' to get started)")
|
|
559
|
+
elif not user:
|
|
560
|
+
print("(no user templates — run 'dev-code init' to get started)")
|
|
561
|
+
return
|
|
562
|
+
|
|
563
|
+
# --long output
|
|
564
|
+
print(f"Template dir: {template_dir}")
|
|
565
|
+
print()
|
|
566
|
+
|
|
567
|
+
all_names = [n for n, _ in builtins] + [n for n, _ in user]
|
|
568
|
+
col_w = max((len(n) for n in all_names), default=8) + 2
|
|
569
|
+
|
|
570
|
+
if builtins:
|
|
571
|
+
print("BUILT-IN")
|
|
572
|
+
for name, path in builtins:
|
|
573
|
+
print(f" {name:<{col_w}}{path}")
|
|
574
|
+
print()
|
|
575
|
+
|
|
576
|
+
if user:
|
|
577
|
+
print("USER")
|
|
578
|
+
for name, path in user:
|
|
579
|
+
print(f" {name:<{col_w}}{path}")
|
|
580
|
+
else:
|
|
581
|
+
print(" (no user templates — run 'dev-code init' to get started)")
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _template_name_from_config(config_path: str) -> str:
|
|
585
|
+
"""Extract template name as the directory immediately above .devcontainer/."""
|
|
586
|
+
norm = os.path.normpath(config_path)
|
|
587
|
+
parts = norm.replace("\\", "/").split("/")
|
|
588
|
+
for i, part in enumerate(parts):
|
|
589
|
+
if part == ".devcontainer" and i > 0:
|
|
590
|
+
return parts[i - 1]
|
|
591
|
+
return os.path.basename(os.path.dirname(os.path.dirname(norm)))
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def cmd_ps(args) -> None:
|
|
595
|
+
"""List running devcontainers."""
|
|
596
|
+
fmt = "{{.ID}}\t{{.Label \"devcontainer.local_folder\"}}\t{{.Label \"devcontainer.config_file\"}}\t{{.Status}}"
|
|
597
|
+
result = subprocess.run(
|
|
598
|
+
["docker", "container", "ls",
|
|
599
|
+
"--filter", "label=devcontainer.local_folder",
|
|
600
|
+
"--format", fmt],
|
|
601
|
+
capture_output=True, text=True,
|
|
602
|
+
)
|
|
603
|
+
if result.returncode != 0:
|
|
604
|
+
logger.error("docker ps failed — is Docker running?")
|
|
605
|
+
sys.exit(1)
|
|
606
|
+
|
|
607
|
+
rows = [line.split("\t") for line in result.stdout.splitlines() if line.strip()]
|
|
608
|
+
if not rows:
|
|
609
|
+
print("no running devcontainers")
|
|
610
|
+
return
|
|
611
|
+
|
|
612
|
+
home = os.path.expanduser("~")
|
|
613
|
+
|
|
614
|
+
def fmt_path(p):
|
|
615
|
+
return "~" + p[len(home):] if p.startswith(home) else p
|
|
616
|
+
|
|
617
|
+
# Build display rows
|
|
618
|
+
display = []
|
|
619
|
+
for row in rows:
|
|
620
|
+
if len(row) < 4:
|
|
621
|
+
continue
|
|
622
|
+
cid, folder, config, status = row[0], row[1], row[2], row[3]
|
|
623
|
+
template = _template_name_from_config(config) if config else "(unknown)"
|
|
624
|
+
display.append((cid[:12], template, fmt_path(folder), status))
|
|
625
|
+
|
|
626
|
+
# Column widths
|
|
627
|
+
headers = ("CONTAINER ID", "TEMPLATE", "PROJECT PATH", "STATUS")
|
|
628
|
+
widths = [max(len(h), max((len(r[i]) for r in display), default=0)) for i, h in enumerate(headers)]
|
|
629
|
+
|
|
630
|
+
def fmt_row(r):
|
|
631
|
+
return " ".join(f"{v:<{widths[i]}}" for i, v in enumerate(r))
|
|
632
|
+
|
|
633
|
+
print(fmt_row(headers))
|
|
634
|
+
for row in display:
|
|
635
|
+
print(fmt_row(row))
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
class _BannerFormatter(argparse.RawDescriptionHelpFormatter):
|
|
639
|
+
def format_help(self):
|
|
640
|
+
return BANNER + "\n\n" + super().format_help()
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def main():
|
|
644
|
+
parser = argparse.ArgumentParser(
|
|
645
|
+
prog="dev-code",
|
|
646
|
+
formatter_class=_BannerFormatter,
|
|
647
|
+
)
|
|
648
|
+
parser.add_argument("-v", "--verbose", action="store_true")
|
|
649
|
+
subparsers = parser.add_subparsers(dest="subcommand")
|
|
650
|
+
|
|
651
|
+
p_open = subparsers.add_parser("open")
|
|
652
|
+
p_open.add_argument("template")
|
|
653
|
+
p_open.add_argument("projectpath")
|
|
654
|
+
p_open.add_argument("--container-folder")
|
|
655
|
+
p_open.add_argument("--timeout", type=int, default=300)
|
|
656
|
+
p_open.add_argument("--dry-run", action="store_true", dest="dry_run")
|
|
657
|
+
|
|
658
|
+
p_new = subparsers.add_parser("new")
|
|
659
|
+
p_new.add_argument("name")
|
|
660
|
+
p_new.add_argument("base", nargs="?")
|
|
661
|
+
p_new.add_argument("--edit", action="store_true")
|
|
662
|
+
|
|
663
|
+
p_edit = subparsers.add_parser("edit")
|
|
664
|
+
p_edit.add_argument("template", nargs="?")
|
|
665
|
+
|
|
666
|
+
subparsers.add_parser("init")
|
|
667
|
+
|
|
668
|
+
p_list = subparsers.add_parser("list")
|
|
669
|
+
p_list.add_argument("--long", action="store_true")
|
|
670
|
+
|
|
671
|
+
subparsers.add_parser("ps")
|
|
672
|
+
|
|
673
|
+
args = parser.parse_args()
|
|
674
|
+
if args.subcommand is None:
|
|
675
|
+
parser.print_help()
|
|
676
|
+
sys.exit(0)
|
|
677
|
+
_configure_logging(args.verbose)
|
|
678
|
+
|
|
679
|
+
dispatch = {
|
|
680
|
+
"open": cmd_open,
|
|
681
|
+
"new": cmd_new,
|
|
682
|
+
"edit": cmd_edit,
|
|
683
|
+
"init": cmd_init,
|
|
684
|
+
"list": cmd_list,
|
|
685
|
+
"ps": cmd_ps,
|
|
686
|
+
}
|
|
687
|
+
dispatch[args.subcommand](args)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
if __name__ == "__main__":
|
|
691
|
+
main()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
FROM mcr.microsoft.com/devcontainers/base:noble
|
|
2
|
+
|
|
3
|
+
# ===== Env =====
|
|
4
|
+
ARG USERNAME=vscode \
|
|
5
|
+
FZF_VERSION=0.68.0
|
|
6
|
+
|
|
7
|
+
ENV USERNAME=${USERNAME} \
|
|
8
|
+
USER_HOME=/home/${USERNAME} \
|
|
9
|
+
HISTFILE=/commandhistory/.history \
|
|
10
|
+
HISTSIZE=10000 \
|
|
11
|
+
EDITOR=vim \
|
|
12
|
+
VISUAL=vim
|
|
13
|
+
ENV SHELL=/bin/zsh
|
|
14
|
+
|
|
15
|
+
# ===== Install deps + fzf =====
|
|
16
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
17
|
+
fzf \
|
|
18
|
+
rsync \
|
|
19
|
+
vim \
|
|
20
|
+
gh \
|
|
21
|
+
&& rm -rf /var/lib/apt/lists/* \
|
|
22
|
+
&& ARCH="$(uname -m)" \
|
|
23
|
+
&& [ "$ARCH" = "x86_64" ] && ARCH=amd64 || ARCH=arm64 \
|
|
24
|
+
&& curl -fsSL "https://github.com/junegunn/fzf/releases/download/v${FZF_VERSION}/fzf-${FZF_VERSION}-linux_${ARCH}.tar.gz" \
|
|
25
|
+
| tar -xz -C /usr/local/bin
|
|
26
|
+
|
|
27
|
+
# ===== Setup history =====
|
|
28
|
+
RUN mkdir -p /commandhistory \
|
|
29
|
+
&& touch /commandhistory/.history \
|
|
30
|
+
&& chown -R ${USERNAME}:${USERNAME} /commandhistory
|
|
31
|
+
|
|
32
|
+
# ===== Install Powerlevel10k =====
|
|
33
|
+
RUN git clone --depth=1 https://github.com/romkatv/powerlevel10k.git \
|
|
34
|
+
${USER_HOME}/.oh-my-zsh/custom/themes/powerlevel10k \
|
|
35
|
+
&& chown -R ${USERNAME}:${USERNAME} ${USER_HOME}/.oh-my-zsh
|
|
36
|
+
|
|
37
|
+
# ===== Setup zsh config =====
|
|
38
|
+
|
|
39
|
+
RUN ZSHRC="${USER_HOME}/.zshrc" \
|
|
40
|
+
&& sed -i "s/ZSH_THEME=\".*/ZSH_THEME=\"powerlevel10k\/powerlevel10k\"/g" "$ZSHRC" \
|
|
41
|
+
&& cat <<EOF >> "$ZSHRC"
|
|
42
|
+
source <(fzf --zsh)
|
|
43
|
+
POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD=true
|
|
44
|
+
POWERLEVEL9K_SHORTEN_STRATEGY="truncate_to_last"
|
|
45
|
+
POWERLEVEL9K_LEFT_PROMPT_ELEMENTS=(user dir vcs status)
|
|
46
|
+
POWERLEVEL9K_RIGHT_PROMPT_ELEMENTS=()
|
|
47
|
+
POWERLEVEL9K_STATUS_OK=false
|
|
48
|
+
POWERLEVEL9K_STATUS_CROSS=true
|
|
49
|
+
POWERLEVEL9K_PROMPT_ON_NEWLINE=true
|
|
50
|
+
EOF
|
|
51
|
+
|
|
52
|
+
# ===== Fix USER HOME permissions =====
|
|
53
|
+
RUN chown -R ${USERNAME}:${USERNAME} ${USER_HOME}
|
|
54
|
+
|
|
55
|
+
# ===== Switch user =====
|
|
56
|
+
USER ${USERNAME}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
|
2
|
+
// README at: https://github.com/devcontainers/templates/tree/main/src/ubuntu
|
|
3
|
+
{
|
|
4
|
+
"name": "Dev",
|
|
5
|
+
// "image": "mcr.microsoft.com/devcontainers/base:noble",
|
|
6
|
+
"build": {
|
|
7
|
+
"dockerfile": "Dockerfile"
|
|
8
|
+
},
|
|
9
|
+
"runArgs": [
|
|
10
|
+
"--name", "dev-${localWorkspaceFolderBasename}-${devcontainerId}"
|
|
11
|
+
],
|
|
12
|
+
"features": {
|
|
13
|
+
"ghcr.io/devcontainers-extra/features/uv:1": {},
|
|
14
|
+
},
|
|
15
|
+
"mounts": [
|
|
16
|
+
"source=devcontainer-history-${devcontainerId},target=/commandhistory,type=volume",
|
|
17
|
+
],
|
|
18
|
+
"containerEnv": {},
|
|
19
|
+
"remoteUser": "vscode"
|
|
20
|
+
}
|