devctk 0.1.0__tar.gz → 0.2.0__tar.gz

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.
@@ -0,0 +1,34 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.12"
16
+ - run: pip install build
17
+ - run: python -m build
18
+ - uses: actions/upload-artifact@v4
19
+ with:
20
+ name: dist
21
+ path: dist/
22
+
23
+ publish:
24
+ needs: build
25
+ runs-on: ubuntu-latest
26
+ environment: pypi
27
+ permissions:
28
+ id-token: write
29
+ steps:
30
+ - uses: actions/download-artifact@v4
31
+ with:
32
+ name: dist
33
+ path: dist/
34
+ - uses: pypa/gh-action-pypi-publish@release/v1
devctk-0.2.0/DESIGN.md ADDED
@@ -0,0 +1,95 @@
1
+ # devctk
2
+
3
+ One-command rootless Podman dev containers, managed by systemd user services.
4
+
5
+ ```
6
+ devctk init --image ubuntu:24.04
7
+ devctk init --image ubuntu:24.04 --ssh --authorized-keys-file ~/.ssh/authorized_keys
8
+ devctk init --image alpine:latest --nix --agent claude --mirror --workspace ~/projects/myapp
9
+ ```
10
+
11
+ You get: a running container with your UID mapped in, workspace bind-mounted, passwordless sudo, auto-start via systemd, and optionally SSH + Nix tools + agent configs.
12
+
13
+ ## Distribution
14
+
15
+ - PyPI package, runnable via `uvx devctk`
16
+ - Pure Python, minimum 3.11
17
+
18
+ ## Host Requirements
19
+
20
+ Podman (rootless), systemd, loginctl. Refuses to run as root.
21
+
22
+ ## Commands
23
+
24
+ **`init`** (default) — create and start a dev container.
25
+
26
+ - `--image` (required)
27
+ - `--name` (default `<user>-dev`)
28
+ - `--ssh` — enable SSH access (requires `--authorized-keys` or `--authorized-keys-file`)
29
+ - `--port` (default 39000, requires `--ssh`)
30
+ - `--nix` — mount Nix store + profiles, set PATH
31
+ - `--agent claude|codex` (repeatable) — mount agent config dirs
32
+ - `--mirror` — workspace at same absolute path as host
33
+ - `--workspace PATH` (default `~/devctk/<name>`)
34
+ - `--no-workspace` — skip workspace mount
35
+ - `--mount`, `--device` (repeatable), extra podman flags after `--`
36
+
37
+ **`ls`** — list managed containers with status.
38
+
39
+ **`rm [NAME] [--all]`** — stop and remove container, units, and state.
40
+
41
+ ## What `init` Does
42
+
43
+ 1. Write a bootstrap script (container entrypoint) to the state dir
44
+ 2. Create rootless Podman container (`--userns keep-id`, `--init`, bootstrap as entrypoint)
45
+ 3. Bootstrap runs on every container start (idempotent):
46
+ - Detect package manager (apt/apk), install sudo + bash if missing
47
+ - Create user matching host UID/GID with passwordless sudo
48
+ - If `--nix`: write `/etc/profile.d/99-devctk-nix.sh`
49
+ - If `--ssh`: install sshd, configure key-only auth, write sshd config
50
+ - Signal readiness via `/run/devctk-ready`, then exec `sleep infinity`
51
+ 4. Install systemd user unit for the container
52
+ 5. If `--ssh`: install a second unit for sshd (waits for bootstrap readiness)
53
+ 6. Enable and start
54
+
55
+ ## Features
56
+
57
+ ### SSH (`--ssh`)
58
+
59
+ SSH access via `ssh user@localhost -p PORT`. Requires authorized keys. Installs sshd inside the container, binds to 127.0.0.1 only. Managed by a separate systemd unit that depends on the container unit.
60
+
61
+ Without `--ssh`, access the container via `podman exec -it NAME bash`.
62
+
63
+ ### Nix (`--nix`)
64
+
65
+ Mounts `/nix/store`, `/etc/profiles/per-user/<user>`, and `/run/current-system` read-only. Uses unresolved symlink-tree paths (not `.resolve()`) so mounts survive `nixos-rebuild` + garbage collection. Writes PATH to `/etc/profile.d/` for SSH login shells.
66
+
67
+ ### Agent configs (`--agent claude|codex`)
68
+
69
+ Mounts agent config directories into the container user's home (read-write):
70
+ - `--agent claude`: `~/.claude/` + `~/.claude.json`
71
+ - `--agent codex`: `~/.codex/`
72
+
73
+ No preprocessing — mount as-is. Container detection for scripts: check `/run/.containerenv` (podman auto-creates this).
74
+
75
+ ### Mirror mode (`--mirror`)
76
+
77
+ Mounts workspace at the same absolute path in host and container. Enables agent session continuity (e.g., Claude's project history is keyed by absolute path). Refuses to mount `$HOME` itself. Default workspace in mirror mode: current directory.
78
+
79
+ ## File Layout
80
+
81
+ ```
82
+ ~/.config/systemd/user/<name>.service[, <name>-sshd.service]
83
+ ~/.local/state/devctk/<name>.json, <name>-container.sh, <name>-bootstrap.sh[, <name>-sshd.sh]
84
+ ```
85
+
86
+ ## Supported Images
87
+
88
+ Debian/Ubuntu (apt) and Alpine (apk). Other images work if sshd + sudo are pre-installed.
89
+
90
+ ## Constraints
91
+
92
+ - Rootless only (no root)
93
+ - SSH bound to 127.0.0.1 (when enabled)
94
+ - Container user is always the host user (same name, UID, GID)
95
+ - One-shot CLI; systemd handles lifecycle
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devctk
3
- Version: 0.1.0
4
- Summary: One-command SSH-accessible rootless Podman dev containers
3
+ Version: 0.2.0
4
+ Summary: One-command rootless Podman dev containers with Nix and agent support
5
5
  Author: y
6
6
  License-Expression: MIT
7
7
  Classifier: Environment :: Console
8
8
  Classifier: Operating System :: POSIX :: Linux
9
9
  Classifier: Topic :: Software Development
10
- Requires-Python: >=3.10
10
+ Requires-Python: >=3.11
@@ -4,9 +4,9 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "devctk"
7
- version = "0.1.0"
8
- description = "One-command SSH-accessible rootless Podman dev containers"
9
- requires-python = ">=3.10"
7
+ version = "0.2.0"
8
+ description = "One-command rootless Podman dev containers with Nix and agent support"
9
+ requires-python = ">=3.11"
10
10
  license = "MIT"
11
11
  authors = [{ name = "y" }]
12
12
  classifiers = [
@@ -16,10 +16,10 @@ classifiers = [
16
16
  ]
17
17
 
18
18
  [tool.hatch.build.targets.wheel]
19
- packages = ["src/z_ctk"]
19
+ packages = ["src/devctk"]
20
20
 
21
21
  [dependency-groups]
22
22
  dev = ["pytest"]
23
23
 
24
24
  [project.scripts]
25
- devctk = "z_ctk.cli:main"
25
+ devctk = "devctk.cli:main"
@@ -0,0 +1,3 @@
1
+ """devctk: one-command rootless Podman dev containers."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,3 @@
1
+ from devctk.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,38 @@
1
+ """Agent config dir mounting for Claude Code and Codex."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ HOME = Path.home()
8
+
9
+ AGENTS: dict[str, dict] = {
10
+ "claude": {
11
+ "dirs": [HOME / ".claude"],
12
+ "files": [HOME / ".claude.json"],
13
+ },
14
+ "codex": {
15
+ "dirs": [HOME / ".codex"],
16
+ "files": [],
17
+ },
18
+ }
19
+
20
+
21
+ def agent_mounts(agents: list[str], container_home: str) -> list[tuple[str, str, str]]:
22
+ """Return (host_path, container_path, mode) tuples for agent config mounts.
23
+
24
+ Directories are created on the host if missing (so bind mounts work).
25
+ Files are skipped if they don't exist yet (agent creates them on first run).
26
+ """
27
+ mounts: list[tuple[str, str, str]] = []
28
+ for name in agents:
29
+ spec = AGENTS.get(name)
30
+ if not spec:
31
+ continue
32
+ for d in spec["dirs"]:
33
+ d.mkdir(parents=True, exist_ok=True)
34
+ mounts.append((str(d), f"{container_home}/{d.name}", "rw"))
35
+ for f in spec["files"]:
36
+ if f.is_file():
37
+ mounts.append((str(f), f"{container_home}/{f.name}", "rw"))
38
+ return mounts
@@ -2,7 +2,6 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import os
5
- import pathlib
6
5
  import re
7
6
  import sys
8
7
 
@@ -12,8 +11,8 @@ COMMANDS = {"init", "ls", "rm"}
12
11
 
13
12
  def build_parser() -> argparse.ArgumentParser:
14
13
  parser = argparse.ArgumentParser(
15
- prog="z-ctk",
16
- description="One-command SSH-accessible rootless Podman dev containers.",
14
+ prog="devctk",
15
+ description="One-command rootless Podman dev containers.",
17
16
  )
18
17
  sub = parser.add_subparsers(dest="command")
19
18
  sub.required = True
@@ -21,14 +20,27 @@ def build_parser() -> argparse.ArgumentParser:
21
20
  # --- init ---
22
21
  p_init = sub.add_parser("init", help="Create and enable a dev container.")
23
22
  p_init.add_argument("--image", required=True)
23
+ p_init.add_argument("--name", dest="container_name")
24
+
25
+ # SSH (opt-in)
26
+ p_init.add_argument("--ssh", action="store_true", help="Enable SSH access")
24
27
  p_init.add_argument("--port", type=int, default=39000)
25
- keys = p_init.add_mutually_exclusive_group(required=True)
28
+ keys = p_init.add_mutually_exclusive_group()
26
29
  keys.add_argument("--authorized-keys", dest="authorized_keys_text")
27
30
  keys.add_argument("--authorized-keys-file", dest="authorized_keys_file")
28
- p_init.add_argument("--container-name")
29
- p_init.add_argument("--container-user")
31
+
32
+ # Features
33
+ p_init.add_argument("--nix", action="store_true", help="Mount Nix store and set PATH")
34
+ p_init.add_argument("--agent", action="append", default=[], choices=["claude", "codex"],
35
+ help="Mount agent config dirs (repeatable)")
36
+
37
+ # Workspace
30
38
  p_init.add_argument("--workspace")
31
39
  p_init.add_argument("--no-workspace", action="store_true")
40
+ p_init.add_argument("--mirror", action="store_true",
41
+ help="Mount workspace at same absolute path as host")
42
+
43
+ # Extra podman flags
32
44
  p_init.add_argument("--mount", action="append", default=[])
33
45
  p_init.add_argument("--device", action="append", default=[])
34
46
 
@@ -44,7 +56,7 @@ def build_parser() -> argparse.ArgumentParser:
44
56
 
45
57
 
46
58
  def normalize_argv(argv: list[str]) -> list[str]:
47
- """Treat bare args (no subcommand) as `init`."""
59
+ """Treat bare args (no subcommand) as ``init``."""
48
60
  if not argv:
49
61
  return argv
50
62
  if argv[0] in COMMANDS or argv[0] in {"-h", "--help"}:
@@ -75,7 +87,7 @@ def validate_passthrough(args: list[str]) -> None:
75
87
 
76
88
 
77
89
  def main() -> int:
78
- from z_ctk.commands import cmd_init, cmd_ls, cmd_rm
90
+ from devctk.commands import cmd_init, cmd_ls, cmd_rm
79
91
 
80
92
  if os.geteuid() == 0:
81
93
  raise SystemExit("refuse to run as root")
@@ -87,9 +99,26 @@ def main() -> int:
87
99
 
88
100
  if args.command == "init":
89
101
  validate_passthrough(passthrough)
102
+
103
+ # SSH flag dependencies
104
+ if args.ssh:
105
+ if not args.authorized_keys_text and not args.authorized_keys_file:
106
+ raise SystemExit("--ssh requires --authorized-keys or --authorized-keys-file")
107
+ else:
108
+ if args.authorized_keys_text or args.authorized_keys_file:
109
+ raise SystemExit("--authorized-keys requires --ssh")
110
+
111
+ # Workspace conflicts
112
+ if args.no_workspace and args.workspace:
113
+ raise SystemExit("--workspace and --no-workspace conflict")
114
+ if args.no_workspace and args.mirror:
115
+ raise SystemExit("--mirror and --no-workspace conflict")
116
+
90
117
  return cmd_init(args, passthrough)
118
+
91
119
  if args.command == "ls":
92
120
  return cmd_ls()
121
+
93
122
  if args.command == "rm":
94
123
  return cmd_rm(args)
95
124
 
@@ -0,0 +1,354 @@
1
+ """Command implementations: init, ls, rm."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import pathlib
9
+ import stat
10
+ import sys
11
+
12
+ from devctk.paths import ManagedPaths, managed_paths, state_root, STATE_DIR_NAME
13
+ from devctk.helpers import render_bootstrap, render_container_helper, render_sshd_helper
14
+ from devctk.systemd import render_unit
15
+ from devctk.util import require_binary, run, write_text, unlink_if_exists
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # init
20
+ # ---------------------------------------------------------------------------
21
+
22
+ def resolve_authorized_keys(args: argparse.Namespace) -> tuple[pathlib.Path | None, str | None]:
23
+ """Return (file_path | None, text | None)."""
24
+ if args.authorized_keys_file is not None:
25
+ p = pathlib.Path(args.authorized_keys_file).expanduser().resolve()
26
+ if not p.is_file():
27
+ raise SystemExit(f"authorized_keys file not found: {p}")
28
+ if p.stat().st_size == 0:
29
+ raise SystemExit(f"authorized_keys file is empty: {p}")
30
+ return p, None
31
+ text = args.authorized_keys_text
32
+ if not text or not text.strip():
33
+ raise SystemExit("authorized_keys text is empty")
34
+ return None, text
35
+
36
+
37
+ def build_workspace_mount(
38
+ workspace: str | None,
39
+ mirror: bool,
40
+ user: str,
41
+ container_name: str,
42
+ container_home: str,
43
+ ) -> str:
44
+ """Return a podman ``--mount`` spec string for the workspace."""
45
+ if mirror:
46
+ src = pathlib.Path(workspace).expanduser().resolve() if workspace else pathlib.Path.cwd().resolve()
47
+ if src == pathlib.Path.home():
48
+ raise SystemExit("--mirror: refusing to mount entire home directory")
49
+ src.mkdir(parents=True, exist_ok=True)
50
+ return f"type=bind,src={src},target={src},rw"
51
+
52
+ if workspace:
53
+ src = pathlib.Path(workspace).expanduser().resolve()
54
+ else:
55
+ src = pathlib.Path.home() / "devctk" / container_name
56
+ src.mkdir(parents=True, exist_ok=True)
57
+ return f"type=bind,src={src},target={container_home}/workspace,rw"
58
+
59
+
60
+ def cmd_init(args: argparse.Namespace, passthrough: list[str]) -> int:
61
+ from devctk.cli import NAME_RE
62
+
63
+ user = os.environ.get("USER") or pathlib.Path.home().name
64
+ container_name = args.container_name or f"{user}-dev"
65
+ container_home = f"/home/{user}"
66
+ uid = os.getuid()
67
+ gid = os.getgid()
68
+
69
+ if not NAME_RE.match(container_name):
70
+ raise SystemExit(f"invalid container name: {container_name}")
71
+ if args.ssh and not 1 <= args.port <= 65535:
72
+ raise SystemExit(f"invalid port: {args.port}")
73
+
74
+ # Authorized keys
75
+ ak_file: pathlib.Path | None = None
76
+ ak_text: str | None = None
77
+ if args.ssh:
78
+ ak_file, ak_text = resolve_authorized_keys(args)
79
+
80
+ # Workspace
81
+ workspace_mount: str | None = None
82
+ if not args.no_workspace:
83
+ workspace_mount = build_workspace_mount(
84
+ args.workspace, args.mirror, user, container_name, container_home,
85
+ )
86
+
87
+ # --- Collect all container mounts ---
88
+ container_mounts: list[str] = []
89
+
90
+ # Workspace (first — it's the outermost bind under $HOME)
91
+ if workspace_mount:
92
+ container_mounts.append(workspace_mount)
93
+
94
+ # Nix
95
+ nix_profile_content = ""
96
+ if args.nix:
97
+ from devctk.nix import nix_mounts, nix_profile_script
98
+ for host, target, mode in nix_mounts(user):
99
+ container_mounts.append(f"type=bind,src={host},target={target},{mode}")
100
+ nix_profile_content = nix_profile_script(user)
101
+
102
+ # Agent config dirs
103
+ if args.agent:
104
+ from devctk.agent import agent_mounts
105
+ for host, target, mode in agent_mounts(args.agent, container_home):
106
+ container_mounts.append(f"type=bind,src={host},target={target},{mode}")
107
+
108
+ # User extra mounts
109
+ container_mounts.extend(args.mount)
110
+
111
+ # --- Binaries ---
112
+ podman = require_binary("podman")
113
+ systemctl = require_binary("systemctl")
114
+ loginctl = require_binary("loginctl")
115
+
116
+ # --- Conflict checks ---
117
+ if run([podman, "container", "exists", container_name], check=False).returncode == 0:
118
+ raise SystemExit(f"container already exists: {container_name}")
119
+
120
+ paths = managed_paths(container_name)
121
+ managed = [paths.container_unit, paths.container_helper, paths.bootstrap_helper, paths.metadata]
122
+ if args.ssh:
123
+ managed.extend([paths.sshd_unit, paths.sshd_helper])
124
+ for p in managed:
125
+ if p.exists():
126
+ raise SystemExit(f"managed files already exist for {container_name}")
127
+
128
+ # --- Write bootstrap script (container entrypoint) ---
129
+ write_text(
130
+ paths.bootstrap_helper,
131
+ render_bootstrap(
132
+ user=user,
133
+ uid=uid,
134
+ gid=gid,
135
+ home=container_home,
136
+ ssh=args.ssh,
137
+ nix_profile=nix_profile_content,
138
+ authorized_keys_file="/tmp/devctk-authorized-keys" if ak_file else None,
139
+ authorized_keys_text=ak_text,
140
+ ),
141
+ 0o755,
142
+ )
143
+
144
+ # --- Internal mounts (bootstrap script + optional authorized-keys file) ---
145
+ internal_mounts = [
146
+ f"type=bind,src={paths.bootstrap_helper},target=/devctk-bootstrap.sh,ro",
147
+ ]
148
+ if ak_file:
149
+ internal_mounts.append(f"type=bind,src={ak_file},target=/tmp/devctk-authorized-keys,ro")
150
+
151
+ all_mounts = internal_mounts + container_mounts
152
+
153
+ # --- Write container helper ---
154
+ write_text(
155
+ paths.container_helper,
156
+ render_container_helper(
157
+ podman=podman,
158
+ name=container_name,
159
+ image=args.image,
160
+ mounts=all_mounts,
161
+ devices=args.device,
162
+ extra=passthrough,
163
+ ssh_port=args.port if args.ssh else None,
164
+ ),
165
+ stat.S_IRWXU,
166
+ )
167
+
168
+ # --- Write container unit ---
169
+ write_text(
170
+ paths.container_unit,
171
+ render_unit(
172
+ "container",
173
+ container_name=container_name,
174
+ container_helper=str(paths.container_helper),
175
+ ),
176
+ )
177
+
178
+ # --- SSH-specific files ---
179
+ if args.ssh:
180
+ write_text(
181
+ paths.sshd_helper,
182
+ render_sshd_helper(podman=podman, name=container_name),
183
+ stat.S_IRWXU,
184
+ )
185
+ write_text(
186
+ paths.sshd_unit,
187
+ render_unit(
188
+ "sshd",
189
+ container_name=container_name,
190
+ container_unit=paths.container_unit.name,
191
+ sshd_helper=str(paths.sshd_helper),
192
+ ),
193
+ )
194
+
195
+ # --- Metadata ---
196
+ write_text(paths.metadata, json.dumps({
197
+ "container_name": container_name,
198
+ "image": args.image,
199
+ "ssh": args.ssh,
200
+ "port": args.port if args.ssh else None,
201
+ "container_home": container_home,
202
+ "workspace_mount": workspace_mount or "",
203
+ "mirror": args.mirror,
204
+ "nix": args.nix,
205
+ "agents": args.agent,
206
+ }, indent=2, sort_keys=True) + "\n")
207
+
208
+ # --- Enable and start ---
209
+ try:
210
+ run([systemctl, "--user", "daemon-reload"])
211
+ run([systemctl, "--user", "enable", "--now", paths.container_unit.name])
212
+ if args.ssh:
213
+ run([systemctl, "--user", "enable", "--now", paths.sshd_unit.name])
214
+ except Exception:
215
+ print(f"startup failed, cleaning up {container_name}", file=sys.stderr)
216
+ _cleanup(podman, systemctl, paths, container_name)
217
+ raise
218
+
219
+ # --- Success ---
220
+ print(f"started {container_name}")
221
+ if args.ssh:
222
+ print(f" ssh {user}@localhost -p {args.port}")
223
+ print(f" podman exec -it {container_name} bash")
224
+
225
+ # Linger check
226
+ res = run([loginctl, "show-user", user, "-p", "Linger"], check=False, capture=True)
227
+ if res.returncode == 0 and res.stdout.strip().endswith("no"):
228
+ print(f" hint: sudo loginctl enable-linger {user}", file=sys.stderr)
229
+
230
+ return 0
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # ls
235
+ # ---------------------------------------------------------------------------
236
+
237
+ def cmd_ls() -> int:
238
+ names = list_names()
239
+ if not names:
240
+ print("no devctk containers")
241
+ return 0
242
+
243
+ podman = require_binary("podman")
244
+
245
+ for name in names:
246
+ paths = managed_paths(name)
247
+ meta = _read_meta(paths.metadata)
248
+ parts = [
249
+ name,
250
+ f"podman={_container_status(podman, name)}",
251
+ f"image={meta.get('image', '-')}",
252
+ ]
253
+ if meta.get("ssh"):
254
+ parts.append(f"port={meta.get('port', '-')}")
255
+ if meta.get("nix"):
256
+ parts.append("nix")
257
+ agents = meta.get("agents", [])
258
+ if agents:
259
+ parts.append(f"agents={','.join(agents)}")
260
+ if meta.get("mirror"):
261
+ parts.append("mirror")
262
+ print(" ".join(parts))
263
+ return 0
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # rm
268
+ # ---------------------------------------------------------------------------
269
+
270
+ def cmd_rm(args: argparse.Namespace) -> int:
271
+ user = os.environ.get("USER") or pathlib.Path.home().name
272
+
273
+ if args.all and args.container_name:
274
+ raise SystemExit("rm accepts either a name or --all, not both")
275
+
276
+ if args.all:
277
+ names = list_names()
278
+ if not names:
279
+ print("no devctk containers")
280
+ return 0
281
+ else:
282
+ names = [args.container_name or f"{user}-dev"]
283
+
284
+ podman = require_binary("podman")
285
+ systemctl = require_binary("systemctl")
286
+
287
+ for name in names:
288
+ paths = managed_paths(name)
289
+ all_paths = [
290
+ paths.metadata, paths.container_helper, paths.bootstrap_helper,
291
+ paths.sshd_helper, paths.container_unit, paths.sshd_unit,
292
+ ]
293
+ exists = any(p.exists() for p in all_paths)
294
+ exists = exists or run([podman, "container", "exists", name], check=False).returncode == 0
295
+
296
+ if not exists:
297
+ if args.all:
298
+ continue
299
+ raise SystemExit(f"not found: {name}")
300
+
301
+ _cleanup(podman, systemctl, paths, name)
302
+ print(f"removed {name}")
303
+
304
+ return 0
305
+
306
+
307
+ # ---------------------------------------------------------------------------
308
+ # helpers
309
+ # ---------------------------------------------------------------------------
310
+
311
+ def _cleanup(podman: str, systemctl: str, paths: ManagedPaths, name: str) -> None:
312
+ """Stop services, remove container, delete managed files."""
313
+ run([systemctl, "--user", "disable", "--now", paths.sshd_unit.name], check=False, capture=True)
314
+ run([systemctl, "--user", "disable", "--now", paths.container_unit.name], check=False, capture=True)
315
+ res = run([podman, "rm", "-f", "--ignore", name], check=False, capture=True)
316
+ if res.returncode != 0:
317
+ print(f"warning: podman rm failed for {name}: {res.stderr.strip()}", file=sys.stderr)
318
+
319
+ for p in [paths.container_unit, paths.sshd_unit, paths.sshd_helper,
320
+ paths.container_helper, paths.bootstrap_helper, paths.metadata]:
321
+ unlink_if_exists(p)
322
+
323
+ if paths.helper_dir.exists() and not any(paths.helper_dir.iterdir()):
324
+ paths.helper_dir.rmdir()
325
+
326
+ run([systemctl, "--user", "daemon-reload"], check=False)
327
+
328
+
329
+ def list_names() -> list[str]:
330
+ d = state_root() / STATE_DIR_NAME
331
+ if not d.exists():
332
+ return []
333
+ names: set[str] = set()
334
+ for p in d.glob("*.json"):
335
+ names.add(p.stem)
336
+ for p in d.glob("*-container.sh"):
337
+ names.add(p.name.removesuffix("-container.sh"))
338
+ for p in d.glob("*-bootstrap.sh"):
339
+ names.add(p.name.removesuffix("-bootstrap.sh"))
340
+ return sorted(names)
341
+
342
+
343
+ def _read_meta(path: pathlib.Path) -> dict:
344
+ if not path.exists():
345
+ return {}
346
+ try:
347
+ return json.loads(path.read_text())
348
+ except json.JSONDecodeError:
349
+ return {}
350
+
351
+
352
+ def _container_status(podman: str, name: str) -> str:
353
+ res = run([podman, "inspect", "-f", "{{.State.Status}}", name], check=False, capture=True)
354
+ return res.stdout.strip() if res.returncode == 0 else "missing"