feat: add three-profile STEM container workflow

This commit is contained in:
mpabi
2026-07-14 17:49:36 +02:00
parent 9c0b833371
commit 61d00a6992
12 changed files with 1819 additions and 882 deletions
+372 -248
View File
@@ -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)