rig-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. rig/__init__.py +6 -0
  2. rig/__main__.py +8 -0
  3. rig/cli.py +107 -0
  4. rig/commands/__init__.py +23 -0
  5. rig/commands/check.py +125 -0
  6. rig/commands/common.py +149 -0
  7. rig/commands/dispatch.py +87 -0
  8. rig/commands/down/__init__.py +139 -0
  9. rig/commands/down/runner.py +136 -0
  10. rig/commands/init.py +148 -0
  11. rig/commands/logs.py +146 -0
  12. rig/commands/prune.py +142 -0
  13. rig/commands/ps.py +149 -0
  14. rig/commands/status.py +149 -0
  15. rig/commands/up/__init__.py +111 -0
  16. rig/commands/up/context.py +36 -0
  17. rig/commands/up/loop.py +150 -0
  18. rig/commands/up/relink.py +88 -0
  19. rig/commands/up/rollback.py +49 -0
  20. rig/commands/up/runner.py +118 -0
  21. rig/commands/up/service.py +136 -0
  22. rig/compose/__init__.py +1 -0
  23. rig/compose/client.py +144 -0
  24. rig/compose/context.py +56 -0
  25. rig/compose/discovery.py +121 -0
  26. rig/compose/docker.py +117 -0
  27. rig/compose/starter.py +145 -0
  28. rig/compose/stopper.py +71 -0
  29. rig/compose/supervisor.py +78 -0
  30. rig/core/__init__.py +1 -0
  31. rig/core/constants.py +65 -0
  32. rig/core/env.py +83 -0
  33. rig/core/errors.py +74 -0
  34. rig/core/identity.py +141 -0
  35. rig/core/locks.py +112 -0
  36. rig/core/state.py +150 -0
  37. rig/core/terminal.py +145 -0
  38. rig/manifest/__init__.py +1 -0
  39. rig/manifest/detector.py +138 -0
  40. rig/manifest/inspect.py +18 -0
  41. rig/manifest/loader.py +146 -0
  42. rig/manifest/models.py +129 -0
  43. rig/manifest/parser.py +123 -0
  44. rig/manifest/schema.py +89 -0
  45. rig/net/__init__.py +1 -0
  46. rig/net/health.py +76 -0
  47. rig/net/ports.py +141 -0
  48. rig/net/probe.py +56 -0
  49. rig/net/registry.py +132 -0
  50. rig/parser.py +72 -0
  51. rig/proc/__init__.py +1 -0
  52. rig/proc/process.py +133 -0
  53. rig/proc/record.py +54 -0
  54. rig/proc/spawn.py +130 -0
  55. rig/proc/teardown.py +139 -0
  56. rig_cli-1.0.0.dist-info/METADATA +503 -0
  57. rig_cli-1.0.0.dist-info/RECORD +59 -0
  58. rig_cli-1.0.0.dist-info/WHEEL +4 -0
  59. rig_cli-1.0.0.dist-info/entry_points.txt +3 -0
rig/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """rig: Zero-dependency local dev environment and process runner."""
2
+
3
+ from rig.cli import main
4
+ from rig.core.constants import __version__
5
+
6
+ __all__ = ["__version__", "main"]
rig/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """rig CLI entry point when invoked via python -m rig."""
2
+
3
+ import sys
4
+
5
+ from rig.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
rig/cli.py ADDED
@@ -0,0 +1,107 @@
1
+ """Generic local stack orchestrator and command-line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import sys
7
+ from collections.abc import Sequence
8
+ from types import ModuleType
9
+ from typing import Any
10
+
11
+ from rig.commands.dispatch import _dispatch_command, _handle_exception, _prepare_args
12
+ from rig.core import constants
13
+ from rig.core.errors import RigError
14
+ from rig.parser import build_parser
15
+
16
+ _SUBMODULES = (
17
+ "rig.core.constants",
18
+ "rig.core.errors",
19
+ "rig.core.identity",
20
+ "rig.core.locks",
21
+ "rig.core.state",
22
+ "rig.core.env",
23
+ "rig.core.terminal",
24
+ "rig.net.ports",
25
+ "rig.net.probe",
26
+ "rig.net.health",
27
+ "rig.proc.process",
28
+ "rig.proc.teardown",
29
+ "rig.proc.spawn",
30
+ "rig.proc.record",
31
+ "rig.compose.client",
32
+ "rig.compose.context",
33
+ "rig.compose.docker",
34
+ "rig.compose.discovery",
35
+ "rig.compose.supervisor",
36
+ "rig.compose.starter",
37
+ "rig.compose.stopper",
38
+ "rig.manifest.models",
39
+ "rig.manifest.parser",
40
+ "rig.manifest.detector",
41
+ "rig.manifest.inspect",
42
+ "rig.manifest.loader",
43
+ "rig.manifest.schema",
44
+ "rig.commands.common",
45
+ "rig.commands.dispatch",
46
+ "rig.commands.up",
47
+ "rig.commands.up.context",
48
+ "rig.commands.up.service",
49
+ "rig.commands.up.runner",
50
+ "rig.commands.up.rollback",
51
+ "rig.commands.up.relink",
52
+ "rig.commands.up.loop",
53
+ "rig.commands.down",
54
+ "rig.commands.down.runner",
55
+ "rig.commands.status",
56
+ "rig.commands.ps",
57
+ "rig.commands.prune",
58
+ "rig.commands.check",
59
+ "rig.commands.init",
60
+ "rig.commands.logs",
61
+ "rig.parser",
62
+ )
63
+
64
+ for _mod in _SUBMODULES:
65
+ _m = importlib.import_module(_mod)
66
+ for _k, _v in _m.__dict__.items():
67
+ if not _k.startswith("__"):
68
+ globals()[_k] = _v
69
+
70
+
71
+ class _CliModule(ModuleType):
72
+ def __setattr__(self, name: str, value: Any) -> None:
73
+ super().__setattr__(name, value)
74
+ for mod_name, mod in list(sys.modules.items()):
75
+ if mod_name.startswith("rig.") and mod is not self and name in mod.__dict__:
76
+ setattr(mod, name, value)
77
+
78
+ def __delattr__(self, name: str) -> None:
79
+ super().__delattr__(name)
80
+ for mod_name, mod in list(sys.modules.items()):
81
+ if mod_name.startswith("rig.") and mod is not self and name in mod.__dict__:
82
+ mod.__dict__.pop(name, None)
83
+
84
+
85
+ sys.modules[__name__].__class__ = _CliModule
86
+
87
+
88
+ def main(argv: Sequence[str] | None = None) -> int:
89
+ raw_argv = list(sys.argv[1:] if argv is None else argv)
90
+ as_json = "--json" in raw_argv
91
+ if as_json:
92
+ raw_argv = [a for a in raw_argv if a != "--json"]
93
+ parser = build_parser(as_json=as_json)
94
+ try:
95
+ args = parser.parse_args(raw_argv)
96
+ except SystemExit as exc:
97
+ return exc.code if isinstance(exc.code, int) else constants.EXIT_USAGE
98
+
99
+ _prepare_args(args, as_json)
100
+ try:
101
+ return _dispatch_command(args.command, args)
102
+ except (RigError, TimeoutError, KeyboardInterrupt, OSError, RuntimeError, ValueError) as exc:
103
+ return _handle_exception(exc, args.command, as_json)
104
+
105
+
106
+ if __name__ == "__main__":
107
+ sys.exit(main())
@@ -0,0 +1,23 @@
1
+ """Command handlers for the rig CLI."""
2
+
3
+ from rig.commands.check import cmd_check
4
+ from rig.commands.down import cmd_down
5
+ from rig.commands.init import cmd_init
6
+ from rig.commands.logs import cmd_logs
7
+ from rig.commands.prune import cmd_prune
8
+ from rig.commands.ps import cmd_ps
9
+ from rig.commands.status import cmd_status
10
+ from rig.commands.up import cmd_up
11
+ from rig.manifest.schema import cmd_schema
12
+
13
+ __all__ = [
14
+ "cmd_check",
15
+ "cmd_down",
16
+ "cmd_init",
17
+ "cmd_logs",
18
+ "cmd_prune",
19
+ "cmd_ps",
20
+ "cmd_schema",
21
+ "cmd_status",
22
+ "cmd_up",
23
+ ]
rig/commands/check.py ADDED
@@ -0,0 +1,125 @@
1
+ """Static project and manifest verification command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from rig.core.constants import EXIT_OK, EXIT_USAGE
12
+ from rig.core.env import render
13
+ from rig.core.errors import RigError, print_json_envelope
14
+ from rig.core.terminal import get_theme
15
+ from rig.manifest.inspect import _resolve_executable
16
+ from rig.manifest.loader import load_manifest
17
+
18
+ MANIFEST_EXCEPTIONS = (RigError, OSError, ValueError, KeyError)
19
+
20
+
21
+ def _check_binary(
22
+ spec: str, cwd: Path, ctx: tuple[dict[str, Any], str, list[dict[str, Any]]]
23
+ ) -> None:
24
+ render_vals, tag, issues = ctx
25
+ bin_str = str(render(spec, render_vals))
26
+ actual = _resolve_executable(bin_str, cwd)
27
+ if actual is None:
28
+ msg = f"executable '{spec}' not found"
29
+ issues.append({"level": "error", "check": f"{tag}:binary", "message": msg})
30
+ elif not os.access(actual, os.X_OK):
31
+ msg = f"file '{spec}' is not executable"
32
+ issues.append({"level": "error", "check": f"{tag}:binary", "message": msg})
33
+
34
+
35
+ def _check_proc_service(
36
+ svc: Any, info: tuple[Path, Path, str], issues: list[dict[str, Any]]
37
+ ) -> None:
38
+ root, cwd, tag = info
39
+ if not shutil.which("lsof"):
40
+ msg = "'lsof' not found on PATH"
41
+ issues.append({"level": "error", "check": f"{tag}:lsof", "message": msg})
42
+ vals = {"root": str(root), "cwd": str(cwd), "python": sys.executable}
43
+ bin_ctx = (vals, tag, issues)
44
+ if svc.command:
45
+ _check_binary(svc.command[0], cwd, bin_ctx)
46
+ elif svc.type == "fd" and svc.python:
47
+ _check_binary(svc.python, cwd, bin_ctx)
48
+
49
+
50
+ def _check_compose_service(svc: Any, info: tuple[Path, str], issues: list[dict[str, Any]]) -> None:
51
+ root, tag = info
52
+ if not shutil.which("docker"):
53
+ msg = "'docker' not found on PATH"
54
+ issues.append({"level": "error", "check": f"{tag}:docker", "message": msg})
55
+ if svc.compose_file and not (p := (root / svc.compose_file).resolve()).is_file():
56
+ msg = f"compose file '{p}' does not exist"
57
+ issues.append({"level": "error", "check": f"{tag}:compose_file", "message": msg})
58
+
59
+
60
+ def _check_service(svc: Any, root: Path, ctx: tuple[str, str, list[dict[str, Any]]]) -> None:
61
+ sname, mode_tag, issues = ctx
62
+ cwd = (root / svc.cwd).resolve()
63
+ tag = f"{mode_tag} service:{sname}".strip()
64
+ if not cwd.is_dir():
65
+ msg = f"working directory '{cwd}' does not exist"
66
+ issues.append({"level": "error", "check": f"{tag}:cwd", "message": msg})
67
+ if svc.type in ("fd", "port"):
68
+ _check_proc_service(svc, (root, cwd, tag), issues)
69
+ elif svc.type == "compose":
70
+ _check_compose_service(svc, (root, tag), issues)
71
+
72
+
73
+ def _check_modes(
74
+ manifest: Any, modes: list[str | None], ctx: tuple[Path, list[dict[str, Any]]]
75
+ ) -> None:
76
+ root, issues = ctx
77
+ for m in modes:
78
+ try:
79
+ m_manifest = manifest.for_mode(m)
80
+ except MANIFEST_EXCEPTIONS as exc:
81
+ issues.append({"level": "error", "check": f"mode:{m}", "message": str(exc)})
82
+ continue
83
+ tag = f"[{m}]" if m else ""
84
+ for sname, svc in m_manifest.services.items():
85
+ _check_service(svc, root, (sname, tag, issues))
86
+
87
+
88
+ def _report_check_results(
89
+ manifest: Any, issues: list[dict[str, Any]], opts: tuple[int, bool, Path]
90
+ ) -> int:
91
+ mode_count, as_json, manifest_path = opts
92
+ has_errors = any(i["level"] == "error" for i in issues)
93
+ if as_json:
94
+ data = {"ok": not has_errors, "project": manifest.project, "issues": issues}
95
+ print_json_envelope("check", data)
96
+ return EXIT_USAGE if has_errors else EXIT_OK
97
+ th = get_theme()
98
+ for i in issues:
99
+ prefix = f"{th.red}✖ FAIL{th.r}" if i["level"] == "error" else f"{th.yellow}▲ WARN{th.r}"
100
+ print(f"{prefix} {i['check']}: {i['message']}", file=sys.stderr)
101
+ if not issues:
102
+ msg = f"manifest '{manifest_path}' is valid for {mode_count} mode(s)."
103
+ print(f"{th.green}✓ OK{th.r} check passed: {msg}")
104
+ return EXIT_USAGE if has_errors else EXIT_OK
105
+
106
+
107
+ def cmd_check(root: Path, manifest_path: Path, *args: Any, **kwargs: Any) -> int:
108
+ argv = list(args)
109
+ mode = kwargs.get("mode") or (argv.pop(0) if argv else None)
110
+ as_json = bool(kwargs.get("as_json") or (argv.pop(0) if argv else False))
111
+ issues: list[dict[str, Any]] = []
112
+ resolved_root = Path(root).resolve()
113
+ try:
114
+ manifest = load_manifest(manifest_path)
115
+ except MANIFEST_EXCEPTIONS as exc:
116
+ if as_json:
117
+ issue = {"level": "error", "check": "manifest", "message": str(exc)}
118
+ print_json_envelope("check", {"ok": False, "issues": [issue]})
119
+ else:
120
+ print(f"FAIL manifest: {exc}", file=sys.stderr)
121
+ return EXIT_USAGE
122
+
123
+ modes = [mode] if mode else (list(manifest.modes.keys()) if manifest.modes else [None])
124
+ _check_modes(manifest, modes, (resolved_root, issues))
125
+ return _report_check_results(manifest, issues, (len(modes), as_json, manifest_path))
rig/commands/common.py ADDED
@@ -0,0 +1,149 @@
1
+ """Common command helpers, dependency ordering, and state inspection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from collections.abc import Mapping
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from rig.compose.stopper import compose_stop_record
11
+ from rig.compose.supervisor import compose_record_alive, compose_record_status
12
+ from rig.core.constants import TEARDOWN_TIMEOUT_SECS
13
+ from rig.core.identity import instance_id
14
+ from rig.core.locks import _lock_path, ensure_runtime_dir
15
+ from rig.core.state import _state_path
16
+ from rig.manifest.loader import load_manifest
17
+ from rig.manifest.models import Manifest
18
+ from rig.net.ports import port_is_free
19
+ from rig.proc.process import identity_matches, pid_alive
20
+ from rig.proc.teardown import pgid_alive, terminate_record
21
+
22
+ _COMPOSE_STATUS_LABELS: dict[str, str] = {"alive": "running", "error": "error"}
23
+
24
+
25
+ def record_alive(record: Mapping[str, Any], root: Path) -> bool:
26
+ if record.get("type") == "compose":
27
+ return compose_record_alive(record, root)
28
+ return identity_matches(record)
29
+
30
+
31
+ def record_status(record: Mapping[str, Any], root: Path) -> str:
32
+ """Return 'running', 'stopped' or 'error' for one recorded service."""
33
+ if record.get("type") != "compose":
34
+ return "running" if identity_matches(record) else "stopped"
35
+ return _COMPOSE_STATUS_LABELS.get(compose_record_status(record, root), "stopped")
36
+
37
+
38
+ def is_service_verifiable_alive(record: Mapping[str, Any], root: Path) -> bool:
39
+ if record.get("type") == "compose":
40
+ return compose_record_alive(record, root)
41
+ pid = record.get("pid")
42
+ return isinstance(pid, int) and pid_alive(pid) and identity_matches(record)
43
+
44
+
45
+ def is_service_active_in_mode(record: Mapping[str, Any], root: Path) -> bool:
46
+ if record.get("type") == "compose":
47
+ return compose_record_status(record, root) != "absent"
48
+ return (
49
+ is_service_verifiable_alive(record, root)
50
+ or (isinstance(record.get("pgid"), int) and pgid_alive(record["pgid"]))
51
+ or (isinstance(record.get("pid"), int) and pid_alive(record["pid"]))
52
+ )
53
+
54
+
55
+ def _is_dead(rec: Mapping[str, Any], root: Path) -> bool:
56
+ if rec.get("type") == "compose":
57
+ return compose_record_status(rec, root) == "absent"
58
+ pgid_ok = isinstance(rec.get("pgid"), int) and pgid_alive(rec["pgid"])
59
+ return not identity_matches(rec) and not pgid_ok
60
+
61
+
62
+ def prune_state(state: dict[str, Any], root: Path) -> list[str]:
63
+ dropped = [n for n, r in list(state.get("services", {}).items()) if _is_dead(r, root)]
64
+ for name in dropped:
65
+ rec = state["services"].pop(name, None) or {}
66
+ if (port := rec.get("port")) and not port_is_free(int(port)):
67
+ warn = f" warning: {name} port {port} in use (pid {rec.get('pid')} may be orphaned)"
68
+ print(warn, file=sys.stderr)
69
+ return dropped
70
+
71
+
72
+ def _values_for(state: Mapping[str, Any], root: Path, instance: str) -> dict[str, Any]:
73
+ values: dict[str, Any] = {
74
+ "root": str(root),
75
+ "instance": instance,
76
+ "data_dir": str(Path(root) / "data"),
77
+ "python": sys.executable,
78
+ }
79
+ for name, record in state.get("services", {}).items():
80
+ if record.get("port"):
81
+ values[f"{name}_port"] = record["port"]
82
+ values[f"{name}_url"] = record.get("url") or f"http://127.0.0.1:{record['port']}"
83
+ return values
84
+
85
+
86
+ def record_depends_on(record: Mapping[str, Any]) -> list[str]:
87
+ raw = record.get("depends_on")
88
+ return [i for i in raw if isinstance(i, str)] if isinstance(raw, (list, tuple, set)) else []
89
+
90
+
91
+ def _merged_depends_on(
92
+ name: str, record: Mapping[str, Any], manifest: Manifest | None = None
93
+ ) -> set[str]:
94
+ deps = set(record_depends_on(record))
95
+ if manifest is not None and name in manifest.services:
96
+ deps.update(manifest.services[name].depends_on)
97
+ return deps - {name}
98
+
99
+
100
+ def _consumers_of(
101
+ name: str, services: Mapping[str, Mapping[str, Any]], manifest: Manifest | None = None
102
+ ) -> set[str]:
103
+ consumers = {
104
+ other for other, rec in services.items() if name in _merged_depends_on(other, rec, manifest)
105
+ }
106
+ if manifest is not None:
107
+ consumers.update(manifest.dependents(name))
108
+ return consumers - {name}
109
+
110
+
111
+ def _drain_ready_deps(deps: set[str], in_degree: dict[str, int]) -> list[str]:
112
+ in_degree.update({d: in_degree[d] - 1 for d in deps})
113
+ return [d for d in deps if in_degree[d] == 0]
114
+
115
+
116
+ def reverse_dependency_order(services: Mapping[str, Mapping[str, Any]]) -> list[str]:
117
+ in_degree = {k: 0 for k in services}
118
+ dep_map = {k: set() for k in services}
119
+ for name, rec in services.items():
120
+ for d in {d for d in record_depends_on(rec) if d in services and d != name}:
121
+ dep_map[name].add(d)
122
+ in_degree[d] += 1
123
+ queue = [k for k, deg in in_degree.items() if deg == 0]
124
+ order = []
125
+ while queue:
126
+ curr = queue.pop(0)
127
+ order.append(curr)
128
+ queue.extend(_drain_ready_deps(dep_map[curr], in_degree))
129
+ return order + [k for k in services if k not in order]
130
+
131
+
132
+ def _stop_record(record: Mapping[str, Any], root: Path, remove: bool = True) -> str:
133
+ if record.get("type") == "compose":
134
+ return compose_stop_record(record, root, remove=remove)
135
+ return terminate_record(record, TEARDOWN_TIMEOUT_SECS)
136
+
137
+
138
+ def _resolve_manifest_context(root: Path, manifest_path: Path):
139
+ raw_manifest = load_manifest(manifest_path)
140
+ resolved_root = Path(root).resolve()
141
+ ensure_runtime_dir(resolved_root)
142
+ instance = instance_id(raw_manifest.project, resolved_root)
143
+ return (
144
+ raw_manifest,
145
+ resolved_root,
146
+ instance,
147
+ _state_path(resolved_root, instance=instance),
148
+ _lock_path(resolved_root, instance=instance),
149
+ )
@@ -0,0 +1,87 @@
1
+ """Command dispatching and top-level CLI error handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from rig import commands
10
+ from rig.core import constants
11
+ from rig.core.errors import RigError, format_human_error, print_json_error
12
+ from rig.core.identity import find_default_manifest, find_project_root
13
+ from rig.core.terminal import get_theme
14
+
15
+
16
+ def _dispatch_command(cmd: str, args: argparse.Namespace) -> int:
17
+ root, mf, j = args.root_dir, args.manifest_file, args.as_json
18
+ dispatch = {
19
+ "up": lambda: commands.cmd_up(
20
+ root, mf, scope=args.scope, mode=args.mode, switch=args.switch, as_json=j
21
+ ),
22
+ "down": lambda: commands.cmd_down(
23
+ root,
24
+ mf,
25
+ scope=args.scope,
26
+ target=args.target,
27
+ all_instances=args.all_instances,
28
+ as_json=j,
29
+ ),
30
+ "status": lambda: commands.cmd_status(root, mf, as_json=j),
31
+ "ps": lambda: commands.cmd_ps(
32
+ health=args.health, as_json=j, wide=getattr(args, "wide", False)
33
+ ),
34
+ "ls": lambda: commands.cmd_ps(
35
+ health=args.health, as_json=j, wide=getattr(args, "wide", False)
36
+ ),
37
+ "list": lambda: commands.cmd_ps(
38
+ health=args.health, as_json=j, wide=getattr(args, "wide", False)
39
+ ),
40
+ "prune": lambda: commands.cmd_prune(force=args.force, as_json=j),
41
+ "check": lambda: commands.cmd_check(root, mf, mode=args.mode, as_json=j),
42
+ "init": lambda: commands.cmd_init(
43
+ root, dry_run=args.dry_run, force=args.force, up=args.up, as_json=j
44
+ ),
45
+ "schema": lambda: commands.cmd_schema(as_json=j),
46
+ "logs": lambda: commands.cmd_logs(
47
+ root, mf, service=args.service, tail=args.tail, mode=args.mode, as_json=j
48
+ ),
49
+ }
50
+ action = dispatch.get(cmd)
51
+ return action() if action else constants.EXIT_OK
52
+
53
+
54
+ def _print_rig_error(exc: RigError) -> None:
55
+ th = get_theme()
56
+ print(format_human_error(exc, th), end="", file=sys.stderr)
57
+
58
+
59
+ def _handle_exception(exc: Exception, cmd: str, as_json: bool) -> int:
60
+ if isinstance(exc, RigError):
61
+ if as_json:
62
+ print_json_error(exc, command=cmd)
63
+ else:
64
+ _print_rig_error(exc)
65
+ return exc.exit_code
66
+ if isinstance(exc, TimeoutError):
67
+ err = RigError(str(exc), code="E_LOCK_TIMEOUT", exit_code=constants.EXIT_MUTEX_CONFLICT)
68
+ return _handle_exception(err, cmd, as_json)
69
+ if isinstance(exc, KeyboardInterrupt):
70
+ err = RigError(
71
+ "operation cancelled by user",
72
+ code="E_INTERRUPTED",
73
+ exit_code=constants.EXIT_INTERRUPTED,
74
+ )
75
+ return _handle_exception(err, cmd, as_json)
76
+ err = RigError(
77
+ f"unexpected error: {exc}", code="E_INTERNAL", exit_code=constants.EXIT_OP_FAILED
78
+ )
79
+ return _handle_exception(err, cmd, as_json)
80
+
81
+
82
+ def _prepare_args(args: argparse.Namespace, as_json: bool) -> None:
83
+ args.as_json = as_json
84
+ r_arg = getattr(args, "root", None)
85
+ args.root_dir = Path(r_arg).resolve() if r_arg else find_project_root()
86
+ m_arg = getattr(args, "manifest", None)
87
+ args.manifest_file = Path(m_arg).resolve() if m_arg else find_default_manifest(args.root_dir)
@@ -0,0 +1,139 @@
1
+ """Teardown command for stopping individual checkouts or all machine instances."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from rig.commands.common import (
9
+ _merged_depends_on,
10
+ _stop_record,
11
+ reverse_dependency_order,
12
+ )
13
+ from rig.commands.down.runner import down_checkout
14
+ from rig.core.constants import (
15
+ EXIT_NOT_FOUND,
16
+ EXIT_OK,
17
+ EXIT_OP_FAILED,
18
+ LOCK_FILE_NAME,
19
+ STATE_FILE_NAME,
20
+ )
21
+ from rig.core.errors import RigError, print_json_envelope
22
+ from rig.core.identity import get_instances_dir
23
+ from rig.core.locks import exclusive_lock
24
+ from rig.core.state import read_state, write_state
25
+
26
+
27
+ def _stop_instance_records(
28
+ services: dict[str, Any], root: Path, state: dict[str, Any]
29
+ ) -> tuple[list[str], list[str]]:
30
+ stopped, failed, failed_svcs = [], [], set()
31
+ for name in reverse_dependency_order(services):
32
+ if deps := [d for d in failed_svcs if name in _merged_depends_on(d, services[d])]:
33
+ failed.append(f"{name}: refused (needed by running dependent {', '.join(deps)})")
34
+ failed_svcs.add(name)
35
+ continue
36
+ outcome = _stop_record(services[name], root)
37
+ if outcome in ("terminated", "killed", "stale"):
38
+ state["services"].pop(name, None)
39
+ stopped.append(name)
40
+ else:
41
+ failed_svcs.add(name)
42
+ failed.append(f"{name}: {outcome}")
43
+ return stopped, failed
44
+
45
+
46
+ def _stop_instance(inst_dir: Path) -> dict[str, Any]:
47
+ state_file = inst_dir / STATE_FILE_NAME
48
+ if not state_file.exists():
49
+ return {"instance": inst_dir.name, "status": "no_state", "stopped": [], "failed": []}
50
+ with exclusive_lock(inst_dir / LOCK_FILE_NAME):
51
+ state = read_state(state_file)
52
+ root = Path(state["root"]).resolve() if state.get("root") else inst_dir
53
+ stopped, failed = _stop_instance_records(dict(state.get("services", {})), root, state)
54
+ state["generation"] = int(state.get("generation", 0)) + 1
55
+ write_state(state_file, state)
56
+ proj = state.get("project", inst_dir.name)
57
+ return {"instance": inst_dir.name, "project": proj, "stopped": stopped, "failed": failed}
58
+
59
+
60
+ def _down_all_instances(as_json: bool) -> int:
61
+ instances_dir = get_instances_dir()
62
+ if not instances_dir.is_dir():
63
+ if as_json:
64
+ print_json_envelope("down", {"instances": [], "all": True})
65
+ else:
66
+ print("No active rig instances to stop.")
67
+ return EXIT_OK
68
+ results = [
69
+ _stop_instance(d)
70
+ for d in sorted(instances_dir.iterdir())
71
+ if d.is_dir() and (d / STATE_FILE_NAME).is_file()
72
+ ]
73
+ failed_any = any(r.get("failed") for r in results)
74
+ if as_json:
75
+ print_json_envelope("down", {"instances": results, "all": True}, ok=not failed_any)
76
+ return EXIT_OP_FAILED if failed_any else EXIT_OK
77
+
78
+
79
+ def _matches_target(d: Path, target: str) -> bool:
80
+ if not (d.is_dir() and (d / STATE_FILE_NAME).is_file()):
81
+ return False
82
+ proj = read_state(d / STATE_FILE_NAME).get("project") or d.name.rsplit("-", 1)[0]
83
+ return target.lower() in (d.name.lower(), proj.lower()) or d.name == target
84
+
85
+
86
+ def _find_matching_instances(instances_dir: Path, target: str) -> list[Path]:
87
+ if not instances_dir.is_dir():
88
+ return []
89
+ return [d for d in sorted(instances_dir.iterdir()) if _matches_target(d, target)]
90
+
91
+
92
+ def _find_target(target: str) -> Path:
93
+ matched = _find_matching_instances(get_instances_dir(), target)
94
+ if not matched:
95
+ msg = f"no instance found matching {target!r}"
96
+ head = f"No active or recorded instance matches {target!r}"
97
+ hint = "Check running instances with 'rig ps'"
98
+ raise RigError(msg, code="E_NOT_FOUND", exit_code=EXIT_NOT_FOUND, headline=head, hint=hint)
99
+ if len(matched) > 1:
100
+ names = ", ".join(d.name for d in matched)
101
+ msg = f"ambiguous target {target!r}; matches: {names}"
102
+ head = f"Multiple instances match {target!r}"
103
+ ctx = f"Matching instances:\n{names}"
104
+ hint = "Specify the full instance identifier (e.g. from 'rig ps')"
105
+ raise RigError(
106
+ msg,
107
+ code="E_AMBIGUOUS",
108
+ exit_code=EXIT_NOT_FOUND,
109
+ headline=head,
110
+ context=ctx,
111
+ hint=hint,
112
+ )
113
+ return matched[0]
114
+
115
+
116
+ def cmd_down(
117
+ root: Path | None = None,
118
+ manifest_path: Path | None = None,
119
+ scope: str = "full",
120
+ *args: Any,
121
+ **kwargs: Any,
122
+ ) -> int:
123
+ argv = list(args)
124
+ target = kwargs.get("target") or (argv.pop(0) if argv else None)
125
+ all_inst = bool(kwargs.get("all_instances") or (argv.pop(0) if argv else False))
126
+ as_json = bool(kwargs.get("as_json") or (argv.pop(0) if argv else False))
127
+ if all_inst:
128
+ return _down_all_instances(as_json)
129
+ if target:
130
+ res = _stop_instance(_find_target(target))
131
+ if as_json:
132
+ print_json_envelope("down", res, ok=not res.get("failed"))
133
+ return EXIT_OP_FAILED if res.get("failed") else EXIT_OK
134
+ if root and manifest_path:
135
+ return down_checkout(root, manifest_path, scope, as_json=as_json)
136
+ return EXIT_OK
137
+
138
+
139
+ __all__ = ["_down_all_instances", "_find_target", "_stop_instance", "cmd_down", "down_checkout"]