feat: add three-profile STEM container workflow
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""rvctl command-line implementation."""
|
||||
"""STEM workspace launcher (``stemctl`` with ``rvctl`` compatibility)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,6 +10,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, replace
|
||||
@@ -111,30 +112,79 @@ REPO_PERMISSION_FIELDS = [
|
||||
]
|
||||
|
||||
ENV_PROFILES = {
|
||||
"rv32i": {
|
||||
"service": "env",
|
||||
"image": "edu-inf/rv32i-hazard3-env:latest",
|
||||
"purpose": "RV32I/Hazard3 nvim + gdb-multiarch",
|
||||
"native-amd64": {
|
||||
"service": "native-amd64",
|
||||
"image": "localhost/stem/dev-native-amd64:local",
|
||||
"purpose": "native AMD64 C/C++ tests, sanitizers and GDB/LLDB",
|
||||
},
|
||||
"host": {
|
||||
"service": "host",
|
||||
"image": "edu-inf/rv32i-hazard3-host-env:latest",
|
||||
"purpose": "native host clang/gcc + gdb/lldb",
|
||||
"hazard3-sim": {
|
||||
"service": "hazard3-sim",
|
||||
"image": "localhost/stem/dev-hazard3-sim:local",
|
||||
"purpose": "Hazard3 RTL, RV32 bare metal/FreeRTOS and GDB",
|
||||
},
|
||||
"rp2350": {
|
||||
"service": "rp2350",
|
||||
"image": "localhost/stem/dev-rp2350:local",
|
||||
"purpose": "physical RP2350 RISC-V or ARM build, deploy and debug",
|
||||
},
|
||||
}
|
||||
|
||||
ENV_PROFILE_ALIASES = {
|
||||
"host": "native-amd64",
|
||||
"native": "native-amd64",
|
||||
"amd64": "native-amd64",
|
||||
"rv32i": "hazard3-sim",
|
||||
"riscv": "hazard3-sim",
|
||||
"riscv32": "hazard3-sim",
|
||||
"hazard3": "hazard3-sim",
|
||||
}
|
||||
|
||||
ENV_PROFILE_CHOICES = sorted(set(ENV_PROFILES) | set(ENV_PROFILE_ALIASES))
|
||||
PROFILE_DEFAULT_TARGETS = {
|
||||
"native-amd64": "native",
|
||||
"hazard3-sim": "hazard3-baremetal",
|
||||
"rp2350": "rp2350-rv",
|
||||
}
|
||||
|
||||
|
||||
def default_runtime_socket_root(workspace_root: Path) -> Path:
|
||||
xdg_runtime = os.environ.get("XDG_RUNTIME_DIR", "").strip()
|
||||
if xdg_runtime:
|
||||
return Path(xdg_runtime).expanduser().resolve() / "stem"
|
||||
run_user = Path(f"/run/user/{os.getuid()}")
|
||||
if run_user.is_dir():
|
||||
return run_user / "stem"
|
||||
return workspace_root / ".runtime" / "stem"
|
||||
|
||||
|
||||
def load_config(config_path: Path) -> WorkspaceConfig:
|
||||
raw = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
base_dir = config_path.parent
|
||||
|
||||
original_root = expand_path(raw.get("original_root", "~/dev/edu/repos/rv"), base_dir)
|
||||
workspace_root = expand_path(raw.get("workspace_root", "~/dev/workspace/rv"), base_dir)
|
||||
original_root = expand_path(raw.get("original_root", "~/dev/edu/repos/stem"), base_dir)
|
||||
configured_workspace = os.environ.get("STEM_WORKSPACE_ROOT") or raw.get(
|
||||
"workspace_root", "~/dev/workspace/stem"
|
||||
)
|
||||
workspace_root = expand_path(str(configured_workspace), base_dir)
|
||||
legacy_workspace_root = expand_path(
|
||||
str(raw.get("legacy_workspace_root", "~/dev/workspace/rv")), base_dir
|
||||
)
|
||||
if (
|
||||
"STEM_WORKSPACE_ROOT" not in os.environ
|
||||
and not workspace_root.exists()
|
||||
and legacy_workspace_root.exists()
|
||||
):
|
||||
workspace_root = legacy_workspace_root
|
||||
git_raw = raw.get("git", {})
|
||||
base_url = git_raw.get("base_url", "http://77.90.8.171:3001").rstrip("/")
|
||||
original_series_root = expand_path(raw.get("original_series_root", str(original_root / "series")), base_dir)
|
||||
series_root = expand_path(raw.get("series_root", str(workspace_root / "series")), base_dir)
|
||||
socket_root = expand_path(raw.get("socket_root", str(workspace_root / "sockets")), base_dir)
|
||||
configured_socket_root = os.environ.get("STEM_SOCKET_ROOT") or raw.get("socket_root")
|
||||
socket_root = (
|
||||
expand_path(str(configured_socket_root), base_dir)
|
||||
if configured_socket_root
|
||||
else default_runtime_socket_root(workspace_root)
|
||||
)
|
||||
token_path = expand_path(raw.get("token_file", str(workspace_root / "tokens" / "tokens.json")), base_dir)
|
||||
workspace_info_path = expand_path(
|
||||
raw.get("workspace_info_path", str(workspace_root / "meta" / "workspace-info" / "workspace.json")),
|
||||
@@ -146,13 +196,18 @@ def load_config(config_path: Path) -> WorkspaceConfig:
|
||||
candidate_values = [
|
||||
raw.get("tools_root", str(workspace_root / "tools" / "rv32i-hazard3-student-env")),
|
||||
str(workspace_root / "tools" / "rv32i-hazard3-env"),
|
||||
str(Path.home() / "dev" / "workspace" / "stem" / "tools" / "rv32i-hazard3-student-env"),
|
||||
str(Path.home() / "dev" / "workspace" / "rv" / "tools" / "rv32i-hazard3-student-env"),
|
||||
str(original_root / "rv32i-hazard3-student-env"),
|
||||
str(original_root / "rv32i-hazard3-env"),
|
||||
]
|
||||
tools_root_candidates = [expand_path(value, base_dir) for value in candidate_values]
|
||||
adjacent_tools = (base_dir.parent / "rv32i-hazard3-student-env").resolve()
|
||||
if adjacent_tools.is_dir() and adjacent_tools not in tools_root_candidates:
|
||||
tools_root_candidates.insert(0, adjacent_tools)
|
||||
|
||||
tools_root = next((path for path in tools_root_candidates if path.exists()), tools_root_candidates[0])
|
||||
env_tool_url = raw.get("env_tool_url", f"{base_url}/edu-inf/rv32i-hazard3-student-env.git")
|
||||
env_tool_url = raw.get("env_tool_url", f"{base_url}/edu-tools/rv32i-hazard3-student-env.git")
|
||||
defaults = raw.get("defaults", {})
|
||||
workspace_info_url = raw.get("workspace_info_url", f"{base_url}/edu-workspace/workspace-info.git")
|
||||
git = GitConfig(
|
||||
@@ -3347,152 +3402,15 @@ def tmux_target_exists(session_name: str) -> bool:
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def current_user_slug() -> str:
|
||||
home_dir = Path(os.environ.get("HOME") or str(Path.home()))
|
||||
return slug_value(os.environ.get("USER") or home_dir.name or f"uid{os.getuid()}", "user name")
|
||||
|
||||
|
||||
def display_user_slug(raw_user: str) -> str:
|
||||
match = re.fullmatch(r"u(\d+)", raw_user)
|
||||
if match:
|
||||
return f"u{int(match.group(1)):02d}"
|
||||
return raw_user
|
||||
|
||||
|
||||
def runtime_name_slug(raw_value: str, field_name: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "_", raw_value.strip().lower()).strip("_")
|
||||
if not slug:
|
||||
raise SystemExit(f"Invalid {field_name}: {raw_value!r}")
|
||||
return slug
|
||||
|
||||
|
||||
def display_profile_slug(profile: str) -> str:
|
||||
if profile == "host":
|
||||
return "_host"
|
||||
return profile
|
||||
|
||||
|
||||
def runtime_name(user_name: str, profile: str, selector: str, window_name: str) -> str:
|
||||
selector_parts = selector.split("/")
|
||||
name_parts = [display_user_slug(user_name), display_profile_slug(profile)]
|
||||
name_parts.extend(runtime_name_slug(part, "card selector") for part in selector_parts)
|
||||
name_parts.append(runtime_name_slug(window_name, "tmux window name"))
|
||||
return "_".join(name_parts)
|
||||
|
||||
|
||||
def build_container_command(
|
||||
config: WorkspaceConfig,
|
||||
selector: str,
|
||||
card_path: Path,
|
||||
instance: str,
|
||||
profile: str,
|
||||
session_name: str,
|
||||
window_name: str,
|
||||
) -> str:
|
||||
profile = normalize_env_profile(profile)
|
||||
service = ENV_PROFILES[profile]["service"]
|
||||
tool_root = config.tools_root
|
||||
compose_file = tool_root / "docker-compose.yml"
|
||||
if not (tool_root / "rv").is_file():
|
||||
candidates = "\n".join(f" - {path}" for path in config.tools_root_candidates)
|
||||
raise SystemExit(f"Missing rv launcher. Checked:\n{candidates}")
|
||||
if not compose_file.is_file():
|
||||
raise SystemExit(f"Missing docker compose file: {compose_file}")
|
||||
|
||||
home_dir = Path(os.environ.get("HOME") or str(Path.home()))
|
||||
card_path_text = str(card_path)
|
||||
user_name = current_user_slug()
|
||||
host_uid = str(os.getuid())
|
||||
host_gid = str(os.getgid())
|
||||
container_name = runtime_name(user_name, profile, selector, window_name)
|
||||
shell_rcfile = tool_root / "configs" / "rv-shell.bashrc"
|
||||
env_parts = [
|
||||
f"WORKSPACE_ROOT={shlex.quote(str(config.workspace_root))}",
|
||||
f"RV_CARDS_ROOT={shlex.quote(str(config.series_root))}",
|
||||
f"RV_PROFILE={profile}",
|
||||
f"RV_REPO={shlex.quote(card_path_text)}",
|
||||
f"RV_REPO_PATH={shlex.quote(str(card_path))}",
|
||||
f"RV_REPO_HOST={shlex.quote(str(card_path))}",
|
||||
f"RV_CARD={shlex.quote(selector)}",
|
||||
f"RV_INSTANCE={shlex.quote(instance)}",
|
||||
f"RV_HOST_USER={shlex.quote(user_name)}",
|
||||
f"RV_HOST_UID={host_uid}",
|
||||
f"RV_HOST_GID={host_gid}",
|
||||
"RV_CODEX=0",
|
||||
]
|
||||
compose_args = [
|
||||
"docker",
|
||||
"compose",
|
||||
"-f",
|
||||
str(compose_file),
|
||||
"run",
|
||||
"--rm",
|
||||
"--name",
|
||||
container_name,
|
||||
"--user",
|
||||
f"{os.getuid()}:{os.getgid()}",
|
||||
"--workdir",
|
||||
card_path_text,
|
||||
"-e",
|
||||
f"HOME={home_dir}",
|
||||
"-e",
|
||||
f"USER={user_name}",
|
||||
"-e",
|
||||
f"LOGNAME={user_name}",
|
||||
"-e",
|
||||
f"RV_PROFILE={profile}",
|
||||
"-e",
|
||||
f"RV_HOST_USER={user_name}",
|
||||
"-e",
|
||||
f"RV_HOST_UID={host_uid}",
|
||||
"-e",
|
||||
f"RV_HOST_GID={host_gid}",
|
||||
"-e",
|
||||
f"RV_REPO={card_path_text}",
|
||||
"-e",
|
||||
f"WORKSPACE_ROOT={config.workspace_root}",
|
||||
"-e",
|
||||
f"RV_CARDS_ROOT={config.series_root}",
|
||||
"-e",
|
||||
f"RV_REPO_PATH={card_path_text}",
|
||||
"-e",
|
||||
f"RV_REPO_HOST={card_path_text}",
|
||||
"-e",
|
||||
f"RV_CARD={selector}",
|
||||
"-e",
|
||||
f"RV_INSTANCE={instance}",
|
||||
"-e",
|
||||
"RV_CODEX=0",
|
||||
"-v",
|
||||
f"{card_path_text}:{card_path_text}",
|
||||
]
|
||||
for identity_file in ["/etc/passwd", "/etc/group"]:
|
||||
if Path(identity_file).is_file():
|
||||
compose_args.extend(["-v", f"{identity_file}:{identity_file}:ro"])
|
||||
shell_args = [service, "shell"]
|
||||
if shell_rcfile.is_file():
|
||||
compose_args.extend(["-v", f"{shell_rcfile}:/tmp/rv-shell.bashrc:ro"])
|
||||
shell_args.extend(["--rcfile", "/tmp/rv-shell.bashrc"])
|
||||
compose_args.extend(shell_args)
|
||||
return " && ".join(
|
||||
[
|
||||
f"cd {shlex.quote(str(tool_root))}",
|
||||
" ".join(env_parts + [shlex.join(compose_args)]),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def run_tmux_container(config: WorkspaceConfig, args: argparse.Namespace) -> None:
|
||||
series_name, card_name = resolve_selector(config, args.series, args.card)
|
||||
selector = f"{series_name}/{card_name}"
|
||||
card_path = resolve_card_path(config, series_name, card_name)
|
||||
|
||||
profile = normalize_env_profile(args.profile)
|
||||
user_name = current_user_slug()
|
||||
default_session_name = runtime_name(user_name, profile, selector, args.window or config.defaults.get("tmux_window", "rv"))
|
||||
session_name = args.session or default_session_name
|
||||
task_name = resolve_submission_task(config, card_path, None)
|
||||
instance = args.instance or default_env_instance(profile, series_name, card_name, task_name, "shell")
|
||||
window_name = args.window or config.defaults.get("tmux_window", "rv")
|
||||
instance = args.instance or config.defaults.get("instance", "shell")
|
||||
session_name = args.session or slug_value(f"stem-{instance}", "tmux session")
|
||||
|
||||
if tmux_target_exists(session_name):
|
||||
raise SystemExit(
|
||||
@@ -3500,7 +3418,18 @@ def run_tmux_container(config: WorkspaceConfig, args: argparse.Namespace) -> Non
|
||||
"Use another --session name or remove the existing session first."
|
||||
)
|
||||
|
||||
shell_command = build_container_command(config, selector, card_path, instance, profile, session_name, window_name)
|
||||
# Compatibility command: keep the outer host tmux session, but delegate
|
||||
# container creation/mounts/labels entirely to the canonical rootless
|
||||
# ./stem runtime. Do not maintain a second Docker Compose lifecycle here.
|
||||
shell_command, details = env_action_command(
|
||||
config,
|
||||
profile,
|
||||
"shell",
|
||||
series_name=series_name,
|
||||
card_name=card_name,
|
||||
task_name=task_name,
|
||||
instance=instance,
|
||||
)
|
||||
tmux_command = [
|
||||
"tmux",
|
||||
"new-session",
|
||||
@@ -3516,10 +3445,13 @@ def run_tmux_container(config: WorkspaceConfig, args: argparse.Namespace) -> Non
|
||||
print("Selector:", selector)
|
||||
print("Card path:", card_path)
|
||||
print("Tools root:", config.tools_root)
|
||||
print("Runtime: rootless stem/Podman")
|
||||
print("Instance:", instance)
|
||||
print("Pane 0 command:")
|
||||
print(shell_command)
|
||||
print("tmux command:")
|
||||
print(shlex.join(tmux_command))
|
||||
print_env_plan(details)
|
||||
return
|
||||
|
||||
subprocess.run(tmux_command, check=True)
|
||||
@@ -3535,12 +3467,7 @@ def run_tmux_container(config: WorkspaceConfig, args: argparse.Namespace) -> Non
|
||||
|
||||
|
||||
def normalize_env_profile(profile: str) -> str:
|
||||
aliases = {
|
||||
"riscv": "rv32i",
|
||||
"riscv32": "rv32i",
|
||||
"native": "host",
|
||||
}
|
||||
normalized = aliases.get(profile, profile)
|
||||
normalized = ENV_PROFILE_ALIASES.get(profile, profile)
|
||||
if normalized not in ENV_PROFILES:
|
||||
available = ", ".join(sorted(ENV_PROFILES))
|
||||
raise SystemExit(f"Unsupported environment profile '{profile}'. Available: {available}")
|
||||
@@ -3548,18 +3475,19 @@ def normalize_env_profile(profile: str) -> str:
|
||||
|
||||
|
||||
def env_tool_command_path(config: WorkspaceConfig) -> Path:
|
||||
return config.tools_root / "rv"
|
||||
stem_script = config.tools_root / "stem"
|
||||
return stem_script if stem_script.is_file() else config.tools_root / "rv"
|
||||
|
||||
|
||||
def ensure_env_tool(config: WorkspaceConfig) -> Path:
|
||||
rv_script = env_tool_command_path(config)
|
||||
if rv_script.is_file():
|
||||
return rv_script
|
||||
env_script = env_tool_command_path(config)
|
||||
if env_script.is_file():
|
||||
return env_script
|
||||
candidates = "\n".join(f" - {path}" for path in config.tools_root_candidates)
|
||||
raise SystemExit(
|
||||
"Missing rv32i-hazard3-student-env launcher script 'rv'.\n"
|
||||
"Missing environment launcher script 'stem' (or compatible 'rv').\n"
|
||||
f"Checked:\n{candidates}\n"
|
||||
"Run: ./rvctl env sync"
|
||||
"Run: ./stemctl env sync"
|
||||
)
|
||||
|
||||
|
||||
@@ -3701,7 +3629,8 @@ def send_command_to_tmux_pane(shell_command: str, raw_target: str, dry_run: bool
|
||||
|
||||
def default_env_instance(profile: str, series_name: str, card_name: str, task_name: str, action: str) -> str:
|
||||
task_part = task_branch_part(task_name).lower()
|
||||
return slug_value(f"{profile}-{series_name}-{card_name}-{task_part}-{action}", "environment instance")
|
||||
del action
|
||||
return slug_value(f"{profile}-{series_name}-{card_name}-{task_part}", "environment instance")
|
||||
|
||||
|
||||
def env_action_command(
|
||||
@@ -3713,15 +3642,25 @@ def env_action_command(
|
||||
task_name: str | None = None,
|
||||
instance: str | None = None,
|
||||
editor: str | None = None,
|
||||
target: str | None = None,
|
||||
device: str | None = None,
|
||||
deploy_backend: str | None = None,
|
||||
allow_existing_firmware: bool = False,
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
profile = normalize_env_profile(profile)
|
||||
rv_script = ensure_env_tool(config)
|
||||
env_script = ensure_env_tool(config)
|
||||
selected_target = target or PROFILE_DEFAULT_TARGETS[profile]
|
||||
env_vars = {
|
||||
"STEM_WORKSPACE_ROOT": str(config.workspace_root),
|
||||
"STEM_CARDS_ROOT": str(config.series_root),
|
||||
"STEM_SOCKET_ROOT": str(config.socket_root),
|
||||
"STEM_PROFILE": profile,
|
||||
"STEM_TARGET": selected_target,
|
||||
"WORKSPACE_ROOT": str(config.workspace_root),
|
||||
"RV_CARDS_ROOT": str(config.series_root),
|
||||
"RV_PROFILE": profile,
|
||||
}
|
||||
command_args = ["bash", "./rv"]
|
||||
command_args = ["bash", f"./{env_script.name}"]
|
||||
|
||||
card_path: Path | None = None
|
||||
selector = ""
|
||||
@@ -3730,6 +3669,9 @@ def env_action_command(
|
||||
selector = f"{series_name}/{card_name}"
|
||||
env_vars.update(
|
||||
{
|
||||
"STEM_REPO_PATH": str(card_path),
|
||||
"STEM_REPO_HOST": str(card_path),
|
||||
"STEM_CARD": card_path.name,
|
||||
"RV_REPO_PATH": str(card_path),
|
||||
"RV_REPO_HOST": str(card_path),
|
||||
"RV_CARD": card_path.name,
|
||||
@@ -3737,48 +3679,55 @@ def env_action_command(
|
||||
)
|
||||
|
||||
if instance:
|
||||
env_vars["STEM_INSTANCE"] = instance
|
||||
env_vars["RV_INSTANCE"] = instance
|
||||
elif series_name and card_name and task_name:
|
||||
env_vars["RV_INSTANCE"] = default_env_instance(profile, series_name, card_name, task_name, action)
|
||||
generated_instance = default_env_instance(profile, series_name, card_name, task_name, action)
|
||||
env_vars["STEM_INSTANCE"] = generated_instance
|
||||
env_vars["RV_INSTANCE"] = generated_instance
|
||||
|
||||
if editor:
|
||||
env_vars["STEM_EDITOR"] = editor
|
||||
env_vars["RV_EDITOR"] = editor
|
||||
|
||||
if action == "build":
|
||||
command_args.append("build")
|
||||
elif action == "shell":
|
||||
command_args.append("shell")
|
||||
elif action == "debug":
|
||||
if device:
|
||||
env_vars["STEM_PROBE_DEVICE"] = device
|
||||
if deploy_backend:
|
||||
env_vars["STEM_DEPLOY_BACKEND"] = deploy_backend
|
||||
|
||||
if action in {"build", "test", "run", "debug", "deploy"}:
|
||||
if not task_name:
|
||||
raise SystemExit("debug requires a task selector.")
|
||||
if profile == "rv32i":
|
||||
command_args.extend(["nvim", task_name])
|
||||
else:
|
||||
command_args.extend(["debug", task_name])
|
||||
elif action == "run":
|
||||
if not task_name:
|
||||
raise SystemExit("run requires a task selector.")
|
||||
if profile == "rv32i":
|
||||
raise SystemExit("rv32i run is not separate yet. Use: ./rvctl debug rv32i ...")
|
||||
command_args.extend(["run", task_name])
|
||||
raise SystemExit(f"{action} requires a task selector.")
|
||||
command_args.extend([action, "--target", selected_target, task_name])
|
||||
if device:
|
||||
command_args.extend(["--device", device])
|
||||
if action == "deploy" and deploy_backend:
|
||||
command_args.extend(["--backend", deploy_backend])
|
||||
if allow_existing_firmware:
|
||||
command_args.append("--allow-existing-firmware")
|
||||
elif action in {"shell", "start", "status", "attach", "stop", "rm"}:
|
||||
command_args.extend([action, "--target", selected_target])
|
||||
else:
|
||||
raise SystemExit(f"Unsupported environment action: {action}")
|
||||
|
||||
env_prefix = " ".join(f"{key}={shlex.quote(value)}" for key, value in env_vars.items())
|
||||
shell_command = " && ".join(
|
||||
[
|
||||
f"cd {shlex.quote(str(rv_script.parent))}",
|
||||
f"cd {shlex.quote(str(env_script.parent))}",
|
||||
" ".join([env_prefix, *[shlex.quote(part) for part in command_args]]),
|
||||
]
|
||||
)
|
||||
details = {
|
||||
"profile": profile,
|
||||
"service": ENV_PROFILES[profile]["service"],
|
||||
"target": selected_target,
|
||||
"selector": selector,
|
||||
"task": task_name or "",
|
||||
"card_path": str(card_path or ""),
|
||||
"tools_root": str(config.tools_root),
|
||||
"instance": env_vars.get("RV_INSTANCE", ""),
|
||||
"device": device or "",
|
||||
"backend": deploy_backend or "",
|
||||
"command": shell_command,
|
||||
}
|
||||
return shell_command, details
|
||||
@@ -3788,11 +3737,14 @@ def print_env_plan(details: dict[str, str]) -> None:
|
||||
keys = [
|
||||
"profile",
|
||||
"service",
|
||||
"target",
|
||||
"selector",
|
||||
"task",
|
||||
"card_path",
|
||||
"tools_root",
|
||||
"instance",
|
||||
"device",
|
||||
"backend",
|
||||
"pane",
|
||||
"tmux_target",
|
||||
"tmux_resolved",
|
||||
@@ -3820,19 +3772,97 @@ def run_env(config: WorkspaceConfig, args: argparse.Namespace) -> None:
|
||||
return
|
||||
if args.env_command == "build":
|
||||
profile = normalize_env_profile(args.profile)
|
||||
shell_command, details = env_action_command(config, profile, "build")
|
||||
env_script = ensure_env_tool(config)
|
||||
shell_command = " && ".join(
|
||||
[
|
||||
f"cd {shlex.quote(str(env_script.parent))}",
|
||||
" ".join(
|
||||
[
|
||||
f"STEM_PROFILE={shlex.quote(profile)}",
|
||||
f"STEM_WORKSPACE_ROOT={shlex.quote(str(config.workspace_root))}",
|
||||
f"STEM_SOCKET_ROOT={shlex.quote(str(config.socket_root))}",
|
||||
"bash",
|
||||
shlex.quote(f"./{env_script.name}"),
|
||||
"profile-build",
|
||||
shlex.quote(profile),
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
if args.dry_run:
|
||||
print_env_plan(details)
|
||||
print(f"profile\t{profile}")
|
||||
print(f"command\t{shell_command}")
|
||||
return
|
||||
subprocess.run(["bash", "-lc", shell_command], check=True)
|
||||
return
|
||||
if args.env_command == "ensure":
|
||||
profile = normalize_env_profile(args.profile)
|
||||
env_script = ensure_env_tool(config)
|
||||
env_vars = os.environ.copy()
|
||||
env_vars.update(
|
||||
{
|
||||
"STEM_WORKSPACE_ROOT": str(config.workspace_root),
|
||||
"STEM_SOCKET_ROOT": str(config.socket_root),
|
||||
"STEM_PROFILE": profile,
|
||||
}
|
||||
)
|
||||
command = ["bash", str(env_script), "ensure", profile]
|
||||
if args.dry_run:
|
||||
print(f"profile\t{profile}")
|
||||
print(f"command\t{shlex.join(command)}")
|
||||
return
|
||||
subprocess.run(command, cwd=env_script.parent, env=env_vars, check=True)
|
||||
return
|
||||
raise SystemExit(f"Unsupported env command: {args.env_command}")
|
||||
|
||||
|
||||
def run_probe(config: WorkspaceConfig, args: argparse.Namespace) -> None:
|
||||
env_script = ensure_env_tool(config)
|
||||
command = ["bash", str(env_script), "probe", "list"]
|
||||
if args.json:
|
||||
command.append("--json")
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"STEM_WORKSPACE_ROOT": str(config.workspace_root),
|
||||
"STEM_SOCKET_ROOT": str(config.socket_root),
|
||||
"STEM_PROFILE": "rp2350",
|
||||
}
|
||||
)
|
||||
subprocess.run(command, cwd=env_script.parent, env=env, check=True)
|
||||
|
||||
|
||||
def run_mcp(config: WorkspaceConfig, args: argparse.Namespace) -> None:
|
||||
"""Replace stemctl with the MCP server bound to one verified instance."""
|
||||
env_script = ensure_env_tool(config)
|
||||
wrapper = env_script.parent / "scripts" / f"mcp-{args.mcp_kind}.sh"
|
||||
if not wrapper.is_file():
|
||||
raise SystemExit(f"Missing MCP wrapper in environment sources: {wrapper}")
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"STEM_WORKSPACE_ROOT": str(config.workspace_root),
|
||||
"STEM_SOCKET_ROOT": str(config.socket_root),
|
||||
}
|
||||
)
|
||||
os.execvpe(str(wrapper), [str(wrapper), args.instance], env)
|
||||
|
||||
|
||||
def run_profile_action(config: WorkspaceConfig, args: argparse.Namespace, action: str) -> None:
|
||||
profile = normalize_env_profile(args.profile)
|
||||
selector_values, pane_target = split_pane_selector(args.selector, getattr(args, "pane", None))
|
||||
series_name, card_name, task_name = resolve_env_target(config, selector_values, getattr(args, "task", None))
|
||||
instance = getattr(args, "instance", None)
|
||||
instance_only_actions = {"status", "attach", "stop", "rm"}
|
||||
if instance and action in instance_only_actions and not selector_values:
|
||||
# A stable logical instance is sufficient for lifecycle operations on
|
||||
# an existing container. Resolving workspace defaults here would make
|
||||
# the operation depend on the currently selected (or still checked
|
||||
# out) card, even though the environment launcher does not need it.
|
||||
series_name = card_name = task_name = None
|
||||
else:
|
||||
series_name, card_name, task_name = resolve_env_target(
|
||||
config, selector_values, getattr(args, "task", None)
|
||||
)
|
||||
shell_command, details = env_action_command(
|
||||
config,
|
||||
profile,
|
||||
@@ -3840,8 +3870,12 @@ def run_profile_action(config: WorkspaceConfig, args: argparse.Namespace, action
|
||||
series_name=series_name,
|
||||
card_name=card_name,
|
||||
task_name=task_name,
|
||||
instance=getattr(args, "instance", None),
|
||||
instance=instance,
|
||||
editor=getattr(args, "editor", None),
|
||||
target=getattr(args, "target", None),
|
||||
device=getattr(args, "device", None),
|
||||
deploy_backend=getattr(args, "backend", None),
|
||||
allow_existing_firmware=getattr(args, "allow_existing_firmware", False),
|
||||
)
|
||||
if pane_target:
|
||||
pane_details = send_command_to_tmux_pane(shell_command, pane_target, dry_run=args.dry_run)
|
||||
@@ -3921,6 +3955,61 @@ def run_workspace(config: WorkspaceConfig, args: argparse.Namespace) -> None:
|
||||
print(f"status\tready")
|
||||
return
|
||||
|
||||
if args.workspace_command == "migrate":
|
||||
migrated = 0
|
||||
already_ready = 0
|
||||
candidates = sorted(path for path in config.series_root.glob("*/*") if path.is_dir())
|
||||
for card_path in candidates:
|
||||
legacy_state = card_path / ".rv"
|
||||
stem_state = card_path / ".stem"
|
||||
if stem_state.exists() or stem_state.is_symlink():
|
||||
already_ready += 1
|
||||
continue
|
||||
if not legacy_state.is_dir():
|
||||
continue
|
||||
print(f"state\t{stem_state} -> .rv")
|
||||
migrated += 1
|
||||
if not args.dry_run:
|
||||
stem_state.symlink_to(".rv", target_is_directory=True)
|
||||
print(f"migrated\t{migrated}")
|
||||
print(f"already_ready\t{already_ready}")
|
||||
print(f"mode\t{'dry-run' if args.dry_run else 'applied'}")
|
||||
return
|
||||
|
||||
if args.workspace_command == "doctor":
|
||||
requested_runtime = os.environ.get("STEM_RUNTIME", "").strip()
|
||||
runtime = requested_runtime or ("podman" if shutil.which("podman") else "")
|
||||
runtime_ok = runtime == "podman" and shutil.which("podman") is not None
|
||||
if requested_runtime and requested_runtime != "podman":
|
||||
runtime_detail = f"{requested_runtime} is not supported for the rootless student workflow"
|
||||
else:
|
||||
runtime_detail = runtime or "missing podman"
|
||||
checks: list[tuple[str, bool, str]] = [
|
||||
("workspace", config.workspace_root.is_dir(), str(config.workspace_root)),
|
||||
("series", config.series_root.is_dir(), str(config.series_root)),
|
||||
("env-tool", env_tool_command_path(config).is_file(), str(env_tool_command_path(config))),
|
||||
("runtime", runtime_ok, runtime_detail),
|
||||
]
|
||||
socket_probe = config.socket_root / ("t" * 12) / ("i" * 12) / ("c" * 12) / "n.sock"
|
||||
checks.append(("socket-path", len(os.fsencode(socket_probe)) <= 100, f"{len(os.fsencode(socket_probe))} bytes"))
|
||||
if runtime_ok:
|
||||
probe = subprocess.run(
|
||||
["podman", "info", "--format", "{{.Host.Security.Rootless}}"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
checks.append(("rootless", probe.returncode == 0 and probe.stdout.strip() == "true", probe.stdout.strip() or probe.stderr.strip()))
|
||||
else:
|
||||
checks.append(("rootless", False, "rootless Podman is required"))
|
||||
print("check\tstatus\tdetail")
|
||||
for name, ok, detail in checks:
|
||||
print(f"{name}\t{'ok' if ok else 'FAIL'}\t{detail}")
|
||||
if not all(ok for _, ok, _ in checks):
|
||||
raise SystemExit(1)
|
||||
return
|
||||
|
||||
raise SystemExit(f"Unsupported workspace command: {args.workspace_command}")
|
||||
|
||||
|
||||
@@ -4001,29 +4090,31 @@ def print_config_summary(config_path: Path) -> None:
|
||||
|
||||
|
||||
def print_main_overview(config_path: Path) -> None:
|
||||
print("RVCTL - launcher workspace RV")
|
||||
print("STEMCTL - launcher kart pracy STEM")
|
||||
print()
|
||||
print("Usage:")
|
||||
print(" ./rvctl <command> [options]")
|
||||
print(" ./rvctl tokens <command> [options]")
|
||||
print(" ./rvctl <command> --help")
|
||||
print(" ./stemctl <command> [options]")
|
||||
print(" ./stemctl tokens <command> [options]")
|
||||
print(" ./stemctl <command> --help")
|
||||
print()
|
||||
print("Commands:")
|
||||
print_table(
|
||||
["command", "arguments", "purpose"],
|
||||
[
|
||||
["show-config", "", "show resolved paths and Git defaults"],
|
||||
["workspace", "show|sync", "show or update meta/workspace-info"],
|
||||
["workspace", "show|sync|migrate|doctor", "manage and diagnose workspace"],
|
||||
["series", "list|use|cards|fetch|tasks", "work with series, cards and tasks"],
|
||||
["card", "use [series] card", "set the default card, optionally with the default series"],
|
||||
["tasks", "list|show|switch", "shortcut for tasks in the default card"],
|
||||
["env", "list|sync|build", "manage rv32i/host container environment"],
|
||||
["debug", "rv32i|host [selector]", "open nvim + gdb in the selected container profile"],
|
||||
["run", "host [selector]", "build and run selected task natively in host container"],
|
||||
["shell", "rv32i|host [selector]", "open a shell in the selected container profile"],
|
||||
["env", "list|sync|ensure|build", "build three container profiles from Dockerfile"],
|
||||
["build/test/run/debug", "PROFILE [selector]", "execute a card action"],
|
||||
["deploy", "rp2350 [selector] --target TARGET", "flash a physical RP2350"],
|
||||
["start/status/attach/stop/rm", "PROFILE [selector]", "manage a persistent rootless container"],
|
||||
["probe", "list", "discover RP2350 probes and BOOTSEL targets"],
|
||||
["mcp", "tmux|nvim INSTANCE", "serve one verified container session over stdio"],
|
||||
["list-series", "", "compat alias for series list"],
|
||||
["list-cards", "[series]", "compat alias for series cards list"],
|
||||
["tmux-container", "[series] [card]", "start tmux with container in pane 0"],
|
||||
["tmux-container", "[series] [card]", "compat: run canonical rootless stem shell in pane 0"],
|
||||
["submission", "[series] [card] --class K [--task T]", "prepare answer repo and student branch"],
|
||||
["tokens", "list|compare|cmp|sync|update|remove|rm|add|read|stats|write", "manage tokens.json and Git remote credentials"],
|
||||
],
|
||||
@@ -4033,17 +4124,13 @@ def print_main_overview(config_path: Path) -> None:
|
||||
print_table(
|
||||
["step", "command", "result"],
|
||||
[
|
||||
["1", "./rvctl tokens compare", "compare repo remotes with tokens.json"],
|
||||
["2", "./rvctl workspace sync", "clone or update workspace-info"],
|
||||
["3", "./rvctl series list", "choose a series"],
|
||||
["4", "./rvctl series use inf", "set the default series"],
|
||||
["5", "./rvctl series cards list inf", "choose a card"],
|
||||
["6", "./rvctl card use bss", "set the default card"],
|
||||
["7", "./rvctl series cards fetch inf bss", "fetch card and prepare r1a"],
|
||||
["8", "./rvctl tasks list", "list tasks from the default card"],
|
||||
["9", "./rvctl tasks switch 4", "switch to the answer branch for task 4"],
|
||||
["10", "./rvctl debug rv32i 4", "debug task 4 in RV32I/Hazard3"],
|
||||
["11", "./rvctl debug host 4", "debug task 4 natively in a host container"],
|
||||
["1", "./stemctl workspace sync", "clone or update workspace-info"],
|
||||
["2", "./stemctl series cards fetch inf bss", "fetch the card on the host"],
|
||||
["3", "./stemctl env ensure native-amd64", "build or reuse the local native profile"],
|
||||
["4", "./stemctl test native-amd64 1", "run native golden/sanitizer tests"],
|
||||
["5", "./stemctl test hazard3-sim 1", "run the same vectors on Hazard3"],
|
||||
["6", "./stemctl debug hazard3-sim 1", "open common tmux/nvim/GDB UI"],
|
||||
["7", "./stemctl deploy rp2350 1 --target rp2350-rv --device /dev/bus/usb/001/006", "flash during a practical class"],
|
||||
],
|
||||
)
|
||||
print()
|
||||
@@ -4126,8 +4213,8 @@ def extract_config_path(argv: list[str]) -> tuple[Path, list[str]]:
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="rvctl",
|
||||
description="rvctl for rv cards and tools.",
|
||||
prog=os.environ.get("STEM_CLI_NAME", Path(sys.argv[0]).name),
|
||||
description="STEM card launcher for native AMD64, Hazard3 simulation and RP2350.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
@@ -4149,40 +4236,76 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
workspace_subparsers.add_parser("show", help="Show workspace-info configuration.")
|
||||
workspace_sync_parser = workspace_subparsers.add_parser("sync", help="Clone or update workspace-info into meta/.")
|
||||
workspace_sync_parser.add_argument("--dry-run", action="store_true", help="Print plan without cloning or pulling.")
|
||||
workspace_migrate_parser = workspace_subparsers.add_parser(
|
||||
"migrate", help="Expose legacy .rv state as .stem without deleting data."
|
||||
)
|
||||
workspace_migrate_parser.add_argument("--dry-run", action="store_true", help="Only list state directories.")
|
||||
workspace_subparsers.add_parser("doctor", help="Check workspace, rootless runtime and MCP socket budget.")
|
||||
|
||||
env_parser = subparsers.add_parser("env", help="Manage rv32i/host container environments.")
|
||||
env_parser = subparsers.add_parser("env", help="Manage the three canonical container environments.")
|
||||
env_subparsers = env_parser.add_subparsers(dest="env_command", required=True)
|
||||
env_subparsers.add_parser("list", help="List supported environment profiles.")
|
||||
env_sync_parser = env_subparsers.add_parser("sync", help="Clone or update rv32i-hazard3-student-env into tools/.")
|
||||
env_sync_parser.add_argument("--dry-run", action="store_true", help="Print plan without cloning or pulling.")
|
||||
env_build_parser = env_subparsers.add_parser("build", help="Build one container profile.")
|
||||
env_build_parser.add_argument("profile", choices=sorted(ENV_PROFILES), help="Environment profile.")
|
||||
env_build_parser.add_argument("profile", choices=ENV_PROFILE_CHOICES, help="Environment profile.")
|
||||
env_build_parser.add_argument("--dry-run", action="store_true", help="Print command without building.")
|
||||
env_ensure_parser = env_subparsers.add_parser("ensure", help="Reuse or locally build a profile from Dockerfile.")
|
||||
env_ensure_parser.add_argument("profile", choices=ENV_PROFILE_CHOICES, help="Environment profile.")
|
||||
env_ensure_parser.add_argument("--dry-run", action="store_true", help="Print command without building.")
|
||||
|
||||
debug_parser = subparsers.add_parser("debug", help="Open selected task in nvim + debugger inside a container profile.")
|
||||
debug_parser.add_argument("profile", choices=sorted(ENV_PROFILES), help="Environment profile: rv32i or host.")
|
||||
debug_parser.add_argument("selector", nargs="*", help="Optional selector: [task], [card task] or [series card task].")
|
||||
debug_parser.add_argument("--task", help="Task override. Defaults to selector or defaults.task.")
|
||||
debug_parser.add_argument("--instance", help="Container/tmux instance name.")
|
||||
debug_parser.add_argument("--editor", choices=["nvim", "vim"], help="Editor inside the container.")
|
||||
debug_parser.add_argument("--pane", help="Send the command to a host tmux pane, for example 0, node:0, :node.0 or %3.")
|
||||
debug_parser.add_argument("--dry-run", action="store_true", help="Print command without starting Docker.")
|
||||
for action, help_text in (
|
||||
("build", "Build the selected task inside a profile."),
|
||||
("test", "Run the selected task tests inside a profile."),
|
||||
("run", "Run the selected task without implicit deployment."),
|
||||
("debug", "Open the selected task in the common tmux/nvim debugger UI."),
|
||||
("deploy", "Deploy an RP2350 artifact using the explicitly selected probe."),
|
||||
("shell", "Open a shell in the persistent profile container."),
|
||||
("start", "Create or start the persistent profile container."),
|
||||
("status", "Show status of the selected profile container."),
|
||||
("attach", "Attach to the selected profile container UI."),
|
||||
("stop", "Stop the selected profile container."),
|
||||
("rm", "Remove the selected profile container; sources remain on the host."),
|
||||
):
|
||||
action_parser = subparsers.add_parser(action, help=help_text)
|
||||
action_parser.add_argument("profile", choices=ENV_PROFILE_CHOICES, help="Environment profile.")
|
||||
action_parser.add_argument("selector", nargs="*", help="Optional selector: [task], [card task] or [series card task].")
|
||||
action_parser.add_argument("--task", help="Task override. Defaults to selector or defaults.task.")
|
||||
action_parser.add_argument("--target", help="Target override, for example rp2350-rv or rp2350-arm.")
|
||||
action_parser.add_argument("--instance", help="Stable logical container instance name.")
|
||||
action_parser.add_argument("--pane", help="Send command to a host tmux pane (for example 0 or %%3).")
|
||||
action_parser.add_argument("--dry-run", action="store_true", help="Print command without changing state.")
|
||||
if action in {"debug", "shell", "attach"}:
|
||||
action_parser.add_argument("--editor", choices=["nvim", "vim"], help="Editor inside the container.")
|
||||
if action in {"debug", "deploy"}:
|
||||
action_parser.add_argument(
|
||||
"--device",
|
||||
help="Exact host device path reported by 'stemctl probe list'.",
|
||||
)
|
||||
if action == "deploy":
|
||||
action_parser.add_argument(
|
||||
"--backend",
|
||||
choices=["probe", "bootsel"],
|
||||
default="probe",
|
||||
help="Deployment transport (default: probe).",
|
||||
)
|
||||
if action == "run":
|
||||
action_parser.add_argument(
|
||||
"--allow-existing-firmware",
|
||||
action="store_true",
|
||||
help="For RP2350 only: run firmware whose deployment cannot be verified.",
|
||||
)
|
||||
|
||||
run_parser = subparsers.add_parser("run", help="Build and run selected task inside a container profile.")
|
||||
run_parser.add_argument("profile", choices=sorted(ENV_PROFILES), help="Environment profile. Currently host supports run.")
|
||||
run_parser.add_argument("selector", nargs="*", help="Optional selector: [task], [card task] or [series card task].")
|
||||
run_parser.add_argument("--task", help="Task override. Defaults to selector or defaults.task.")
|
||||
run_parser.add_argument("--instance", help="Container instance name.")
|
||||
run_parser.add_argument("--pane", help="Send the command to a host tmux pane, for example 0, node:0, :node.0 or %3.")
|
||||
run_parser.add_argument("--dry-run", action="store_true", help="Print command without starting Docker.")
|
||||
probe_parser = subparsers.add_parser("probe", help="Discover RP2350 debug probes and BOOTSEL devices.")
|
||||
probe_subparsers = probe_parser.add_subparsers(dest="probe_command", required=True)
|
||||
probe_list_parser = probe_subparsers.add_parser("list", help="List usable probe/target devices without sudo.")
|
||||
probe_list_parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
|
||||
|
||||
shell_parser = subparsers.add_parser("shell", help="Open a shell inside a container profile.")
|
||||
shell_parser.add_argument("profile", choices=sorted(ENV_PROFILES), help="Environment profile: rv32i or host.")
|
||||
shell_parser.add_argument("selector", nargs="*", help="Optional selector: [card] or [series card].")
|
||||
shell_parser.add_argument("--task", help="Task used only for instance naming.")
|
||||
shell_parser.add_argument("--instance", help="Container instance name.")
|
||||
shell_parser.add_argument("--pane", help="Send the command to a host tmux pane, for example 0, node:0, :node.0 or %3.")
|
||||
shell_parser.add_argument("--dry-run", action="store_true", help="Print command without starting Docker.")
|
||||
mcp_parser = subparsers.add_parser("mcp", help="Run the container-bundled MCP server for one logical instance.")
|
||||
mcp_subparsers = mcp_parser.add_subparsers(dest="mcp_kind", required=True)
|
||||
for kind in ("tmux", "nvim"):
|
||||
kind_parser = mcp_subparsers.add_parser(kind, help=f"Serve {kind} tools over MCP stdio.")
|
||||
kind_parser.add_argument("instance", help="Stable logical instance name (required to avoid cross-container selection).")
|
||||
|
||||
tasks_parser = subparsers.add_parser("tasks", help="Shortcut for tasks in the default card.")
|
||||
tasks_subparsers = tasks_parser.add_subparsers(dest="tasks_command", required=True)
|
||||
@@ -4205,14 +4328,14 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
tmux_parser = subparsers.add_parser(
|
||||
"tmux-container",
|
||||
help="Create a tmux session with pane 0 running the container shell for a selected card.",
|
||||
help="Compatibility wrapper: create host tmux with the canonical rootless stem shell in pane 0.",
|
||||
)
|
||||
tmux_parser.add_argument("series", nargs="?", help="Series id or full selector like 'inf/bss'.")
|
||||
tmux_parser.add_argument("card", nargs="?", help="Card id or unique fragment, for example 'bss'.")
|
||||
tmux_parser.add_argument("--profile", choices=sorted(ENV_PROFILES), default="rv32i", help="Container profile: rv32i or host.")
|
||||
tmux_parser.add_argument("--profile", choices=ENV_PROFILE_CHOICES, default="hazard3-sim", help="Container profile.")
|
||||
tmux_parser.add_argument("--session", help="tmux session name.")
|
||||
tmux_parser.add_argument("--window", help="tmux window name.")
|
||||
tmux_parser.add_argument("--instance", help="RV_INSTANCE passed to the tool launcher.")
|
||||
tmux_parser.add_argument("--instance", help="Stable STEM_INSTANCE passed to the canonical runtime.")
|
||||
tmux_parser.add_argument("--attach", action="store_true", help="Attach to the session after creation.")
|
||||
tmux_parser.add_argument("--dry-run", action="store_true", help="Print commands instead of running tmux.")
|
||||
|
||||
@@ -4431,7 +4554,8 @@ def main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
config = load_config(args.config)
|
||||
if args.command in {"list-series", "list-cards", "series", "card", "tasks", "tmux-container", "submission", "debug", "run", "shell"}:
|
||||
profile_actions = {"build", "test", "run", "debug", "deploy", "shell", "start", "status", "attach", "stop", "rm"}
|
||||
if args.command in {"list-series", "list-cards", "series", "card", "tasks", "tmux-container", "submission"} | profile_actions:
|
||||
ensure_workspace_info(config)
|
||||
|
||||
if args.command == "show-config":
|
||||
@@ -4449,14 +4573,14 @@ def main() -> int:
|
||||
if args.command == "env":
|
||||
run_env(config, args)
|
||||
return 0
|
||||
if args.command == "debug":
|
||||
run_profile_action(config, args, "debug")
|
||||
if args.command == "probe":
|
||||
run_probe(config, args)
|
||||
return 0
|
||||
if args.command == "run":
|
||||
run_profile_action(config, args, "run")
|
||||
if args.command == "mcp":
|
||||
run_mcp(config, args)
|
||||
return 0
|
||||
if args.command == "shell":
|
||||
run_profile_action(config, args, "shell")
|
||||
if args.command in profile_actions:
|
||||
run_profile_action(config, args, args.command)
|
||||
return 0
|
||||
if args.command == "tasks":
|
||||
run_tasks(config, args)
|
||||
|
||||
Reference in New Issue
Block a user