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/core/identity.py ADDED
@@ -0,0 +1,141 @@
1
+ """Instance identity and filesystem path resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fcntl
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import re
10
+ import stat
11
+ import subprocess
12
+ from pathlib import Path
13
+
14
+ from rig.core.constants import DIR_MODE_PRIVATE, FILE_MODE_PRIVATE
15
+
16
+
17
+ def instance_id(project: str, root: Path | str) -> str:
18
+ """Return a stable identifier unique to this project *and* this checkout path."""
19
+ canonical = str(Path(root).resolve())
20
+ digest = hashlib.sha256(canonical.encode()).hexdigest()[:8]
21
+ slug = re.sub(r"[^a-z0-9_-]+", "-", project.lower()).strip("-_") or "stack"
22
+ if not slug[0].isalnum():
23
+ slug = f"s{slug}"
24
+ return f"{slug}-{digest}"
25
+
26
+
27
+ def get_state_home() -> Path:
28
+ """Return root directory for rig global state."""
29
+ override = os.environ.get("RIG_STATE_HOME")
30
+ if override:
31
+ return Path(override).expanduser().resolve()
32
+ xdg = os.environ.get("XDG_STATE_HOME")
33
+ if xdg:
34
+ return (Path(xdg).expanduser() / "rig").resolve()
35
+ return (Path.home() / ".local" / "state" / "rig").resolve()
36
+
37
+
38
+ def get_instances_dir() -> Path:
39
+ """Return directory containing all machine-wide instance registries."""
40
+ return get_state_home() / "instances"
41
+
42
+
43
+ def get_instance_dir(ident: str) -> Path:
44
+ """Return state directory path for a specific instance."""
45
+ return get_instances_dir() / ident
46
+
47
+
48
+ def ensure_instance_dir(ident: str) -> Path:
49
+ """Create and secure owner-only state directory for an instance."""
50
+ inst_dir = get_instance_dir(ident)
51
+ inst_dir.mkdir(parents=True, mode=DIR_MODE_PRIVATE, exist_ok=True)
52
+ if stat.S_IMODE(inst_dir.stat().st_mode) != DIR_MODE_PRIVATE:
53
+ os.chmod(inst_dir, DIR_MODE_PRIVATE)
54
+ return inst_dir
55
+
56
+
57
+ def get_boot_id() -> str:
58
+ """Return OS boot identifier to detect system reboots."""
59
+ linux_boot = Path("/proc/sys/kernel/random/boot_id")
60
+ if linux_boot.exists():
61
+ try:
62
+ return linux_boot.read_text().strip()
63
+ except OSError:
64
+ pass
65
+ try:
66
+ res = subprocess.run(
67
+ ["sysctl", "-n", "kern.boottime"],
68
+ capture_output=True,
69
+ text=True,
70
+ timeout=1.0,
71
+ check=False,
72
+ )
73
+ if res.returncode == 0 and res.stdout.strip():
74
+ return res.stdout.strip()
75
+ except (subprocess.SubprocessError, OSError):
76
+ pass
77
+ return "unknown"
78
+
79
+
80
+ def is_locked(path: Path) -> bool:
81
+ """Return True if path is currently held under exclusive advisory lock."""
82
+ if not (target := Path(path)).exists():
83
+ return False
84
+ try:
85
+ fd = os.open(target, os.O_RDWR | os.O_CLOEXEC | os.O_NOFOLLOW, FILE_MODE_PRIVATE)
86
+ except OSError:
87
+ return False
88
+ try:
89
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
90
+ fcntl.flock(fd, fcntl.LOCK_UN)
91
+ except (BlockingIOError, InterruptedError):
92
+ return True
93
+ else:
94
+ return False
95
+ finally:
96
+ os.close(fd)
97
+
98
+
99
+ _PROJECT_CANDIDATES = (
100
+ "rig.json",
101
+ "scripts/rig.json",
102
+ ".config/rig.json",
103
+ "stack.json",
104
+ "scripts/stack.json",
105
+ ".config/stack.json",
106
+ )
107
+
108
+
109
+ def _has_root_marker(directory: Path) -> bool:
110
+ return any((directory / c).exists() for c in (*_PROJECT_CANDIDATES, ".git"))
111
+
112
+
113
+ def find_project_root(start: Path | None = None) -> Path:
114
+ """Find project root by walking upward from current working directory."""
115
+ current = (start or Path.cwd()).resolve()
116
+ search_path = [current, *current.parents]
117
+ return next((p for p in search_path if _has_root_marker(p)), current)
118
+
119
+
120
+ def find_default_manifest(root: Path) -> Path:
121
+ """Resolve default manifest path, checking rig.json then stack.json candidates."""
122
+ candidates = (root / rel for rel in _PROJECT_CANDIDATES)
123
+ return next((c for c in candidates if c.is_file()), root / _PROJECT_CANDIDATES[0])
124
+
125
+
126
+ def _read_project_from_manifest(manifest_path: Path) -> str | None:
127
+ if not manifest_path.is_file():
128
+ return None
129
+ try:
130
+ data = json.loads(manifest_path.read_text())
131
+ except (OSError, json.JSONDecodeError):
132
+ return None
133
+ if isinstance(data, dict) and data.get("project"):
134
+ return str(data["project"])
135
+ return None
136
+
137
+
138
+ def _get_project_name(root: Path) -> str:
139
+ root = Path(root).resolve()
140
+ names = (_read_project_from_manifest(root / rel) for rel in _PROJECT_CANDIDATES)
141
+ return next((name for name in names if name), root.name)
rig/core/locks.py ADDED
@@ -0,0 +1,112 @@
1
+ """Lifecycle mutex and runtime directory management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import errno
7
+ import fcntl
8
+ import os
9
+ import stat
10
+ import time
11
+ from pathlib import Path
12
+
13
+ from rig.core.constants import (
14
+ DIR_MODE_PRIVATE,
15
+ FILE_MODE_PRIVATE,
16
+ LOCK_FILE_NAME,
17
+ LOCK_TIMEOUT_SECS,
18
+ LOG_DIR_NAME,
19
+ RUNTIME_DIR_NAME,
20
+ )
21
+ from rig.core.errors import RigError
22
+ from rig.core.identity import _get_project_name, ensure_instance_dir, instance_id
23
+
24
+
25
+ def ensure_runtime_dir(root: Path) -> Path:
26
+ """Ensure ``.local-run`` exists with private permissions."""
27
+ runtime = Path(root) / RUNTIME_DIR_NAME
28
+ if runtime.is_symlink():
29
+ raise RigError(f"{runtime} is a symlink; refusing to use it as a runtime directory")
30
+ runtime.mkdir(mode=DIR_MODE_PRIVATE, exist_ok=True)
31
+ info = runtime.lstat()
32
+ if not stat.S_ISDIR(info.st_mode):
33
+ raise RigError(f"{runtime} is not a directory")
34
+ if info.st_uid != os.getuid():
35
+ raise RigError(f"{runtime} is owned by another user")
36
+ if stat.S_IMODE(info.st_mode) != DIR_MODE_PRIVATE:
37
+ os.chmod(runtime, DIR_MODE_PRIVATE)
38
+ (runtime / LOG_DIR_NAME).mkdir(mode=DIR_MODE_PRIVATE, exist_ok=True)
39
+ (Path(root) / "data").mkdir(parents=True, exist_ok=True)
40
+ return runtime
41
+
42
+
43
+ def _lock_path(target: Path | str, instance: str | None = None) -> Path:
44
+ if isinstance(target, str) and "/" not in target and "\\" not in target:
45
+ return ensure_instance_dir(target) / LOCK_FILE_NAME
46
+ if instance is not None:
47
+ return ensure_instance_dir(instance) / LOCK_FILE_NAME
48
+ root_path = Path(target).resolve()
49
+ proj = _get_project_name(root_path)
50
+ inst = instance_id(proj, root_path)
51
+ return ensure_instance_dir(inst) / LOCK_FILE_NAME
52
+
53
+
54
+ def _validate_lock_fd(fd: int, path_obj: Path) -> None:
55
+ info = os.fstat(fd)
56
+ if not stat.S_ISREG(info.st_mode):
57
+ raise RigError(f"{path_obj} is not a regular file")
58
+ if info.st_uid != os.getuid():
59
+ raise RigError(f"{path_obj} is owned by another user")
60
+ if info.st_nlink != 1:
61
+ raise RigError(f"{path_obj} has {info.st_nlink} links; refusing to lock it")
62
+
63
+
64
+ def _acquire_blocking(fd: int, path_obj: Path, timeout: float) -> None:
65
+ deadline = time.monotonic() + timeout
66
+ while True:
67
+ try:
68
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
69
+ except (BlockingIOError, InterruptedError):
70
+ pass
71
+ else:
72
+ return
73
+ remaining = deadline - time.monotonic()
74
+ if remaining <= 0:
75
+ msg = f"another stack command holds {path_obj}; timed out after {timeout:g}s"
76
+ raise TimeoutError(msg)
77
+ time.sleep(min(0.05, remaining))
78
+
79
+
80
+ def _acquire_lock(fd: int, path_obj: Path, timeout: float) -> None:
81
+ if timeout <= 0:
82
+ try:
83
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
84
+ except (BlockingIOError, InterruptedError):
85
+ raise BlockingIOError(f"{path_obj} is currently locked by another process") from None
86
+ else:
87
+ _acquire_blocking(fd, path_obj, timeout)
88
+
89
+
90
+ def _open_lock_fd(path_obj: Path) -> int:
91
+ path_obj.parent.mkdir(parents=True, mode=DIR_MODE_PRIVATE, exist_ok=True)
92
+ try:
93
+ return os.open(
94
+ path_obj, os.O_CREAT | os.O_RDWR | os.O_CLOEXEC | os.O_NOFOLLOW, FILE_MODE_PRIVATE
95
+ )
96
+ except OSError as exc:
97
+ if exc.errno in (errno.ELOOP, errno.EMLINK):
98
+ raise RigError(f"{path_obj} is a symlink; refusing to lock it") from None
99
+ raise RigError(f"cannot open lock file {path_obj}: {exc}") from None
100
+
101
+
102
+ @contextlib.contextmanager
103
+ def exclusive_lock(path: Path, timeout: float = LOCK_TIMEOUT_SECS, blocking: bool = True):
104
+ """Hold an exclusive advisory lock on ``path`` or raise ``TimeoutError``."""
105
+ path_obj = Path(path)
106
+ fd = _open_lock_fd(path_obj)
107
+ try:
108
+ _validate_lock_fd(fd, path_obj)
109
+ _acquire_lock(fd, path_obj, timeout if blocking else 0.0)
110
+ yield fd
111
+ finally:
112
+ os.close(fd)
rig/core/state.py ADDED
@@ -0,0 +1,150 @@
1
+ """State file reading, atomic persistence, and redaction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import os
8
+ import shutil
9
+ import tempfile
10
+ from collections.abc import Mapping
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from rig.core.constants import (
15
+ REDACTED,
16
+ RUNTIME_DIR_NAME,
17
+ SECRET_NAME_PATTERN,
18
+ STATE_FILE_NAME,
19
+ )
20
+ from rig.core.identity import (
21
+ _get_project_name,
22
+ ensure_instance_dir,
23
+ get_instances_dir,
24
+ instance_id,
25
+ )
26
+
27
+
28
+ def empty_state() -> dict[str, Any]:
29
+ return {"generation": 0, "services": {}}
30
+
31
+
32
+ def read_state(path: Path) -> dict[str, Any]:
33
+ """Return persisted state, or an empty stack when it is absent or unreadable."""
34
+ try:
35
+ state = json.loads(Path(path).read_text())
36
+ except (OSError, json.JSONDecodeError):
37
+ return empty_state()
38
+ if not isinstance(state, dict):
39
+ return empty_state()
40
+ state.setdefault("generation", 0)
41
+ services = state.get("services")
42
+ state["services"] = services if isinstance(services, dict) else {}
43
+ if "ports" in state and not isinstance(state["ports"], dict):
44
+ state["ports"] = {}
45
+ return state
46
+
47
+
48
+ def resolve_state_file(path: Path) -> Path:
49
+ p = Path(path)
50
+ if p.is_symlink():
51
+ with contextlib.suppress(OSError):
52
+ return p.resolve()
53
+ return p
54
+
55
+
56
+ def _write_temp_state(target_path: Path, state: Mapping[str, Any]) -> str:
57
+ target_path.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
58
+ with tempfile.NamedTemporaryFile(
59
+ mode="w",
60
+ dir=target_path.parent,
61
+ delete=False,
62
+ prefix=f".{target_path.name}.tmp.",
63
+ ) as handle:
64
+ json.dump(state, handle, indent=2, sort_keys=True)
65
+ handle.write("\n")
66
+ handle.flush()
67
+ os.fsync(handle.fileno())
68
+ return handle.name
69
+
70
+
71
+ def write_state(path: Path, state: Mapping[str, Any]) -> None:
72
+ """Publish state atomically so no reader observes a partial generation."""
73
+ target_path = resolve_state_file(path)
74
+ tmp: str | None = None
75
+ try:
76
+ tmp = _write_temp_state(target_path, state)
77
+ os.chmod(tmp, 0o600)
78
+ os.replace(tmp, target_path)
79
+ except BaseException:
80
+ if tmp is not None:
81
+ with contextlib.suppress(OSError):
82
+ os.unlink(tmp)
83
+ raise
84
+
85
+
86
+ def redact(value: Any) -> Any:
87
+ """Return ``value`` with secret-looking mapping entries masked."""
88
+ if isinstance(value, Mapping):
89
+ return {
90
+ k: REDACTED if isinstance(k, str) and SECRET_NAME_PATTERN.search(k) else redact(v)
91
+ for k, v in value.items()
92
+ }
93
+ if isinstance(value, list):
94
+ return [redact(item) for item in value]
95
+ return value
96
+
97
+
98
+ def _relink_symlink(local_state: Path, target: Path) -> None:
99
+ with contextlib.suppress(OSError):
100
+ if local_state.resolve() != target.resolve():
101
+ local_state.unlink()
102
+ local_state.symlink_to(target)
103
+
104
+
105
+ def _relink_file(local_state: Path, target: Path) -> None:
106
+ with contextlib.suppress(OSError):
107
+ if not target.exists():
108
+ shutil.copy2(local_state, target)
109
+ local_state.unlink()
110
+ local_state.symlink_to(target)
111
+
112
+
113
+ def _sync_state_symlink(local_state: Path, authoritative_state: Path) -> None:
114
+ if local_state.is_symlink():
115
+ _relink_symlink(local_state, authoritative_state)
116
+ elif local_state.is_file():
117
+ _relink_file(local_state, authoritative_state)
118
+ elif not local_state.exists():
119
+ with contextlib.suppress(OSError):
120
+ local_state.symlink_to(authoritative_state)
121
+
122
+
123
+ def _resolve_instance(target: str) -> Path | None:
124
+ instances_dir = get_instances_dir()
125
+ if not instances_dir.is_dir():
126
+ return None
127
+ direct = instances_dir / target
128
+ if direct.is_dir():
129
+ return direct
130
+ for item in instances_dir.iterdir():
131
+ sf = item / STATE_FILE_NAME
132
+ if item.is_dir() and sf.is_file() and read_state(sf).get("project") == target:
133
+ return item
134
+ return None
135
+
136
+
137
+ def _state_path(root: Path | str, instance: str | None = None) -> Path:
138
+ if isinstance(root, str) and "/" not in root and "\\" not in root and instance is None:
139
+ inst_dir = _resolve_instance(root) or ensure_instance_dir(root)
140
+ return inst_dir / STATE_FILE_NAME
141
+ root_path = Path(root).resolve()
142
+ if instance is None:
143
+ instance = instance_id(_get_project_name(root_path), root_path)
144
+ inst_dir = ensure_instance_dir(instance)
145
+ authoritative_state = inst_dir / STATE_FILE_NAME
146
+
147
+ runtime = root_path / RUNTIME_DIR_NAME
148
+ if runtime.is_dir():
149
+ _sync_state_symlink(runtime / STATE_FILE_NAME, authoritative_state)
150
+ return authoritative_state
rig/core/terminal.py ADDED
@@ -0,0 +1,145 @@
1
+ """Terminal formatting, ANSI color support, and monospace table alignment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import sys
8
+ import unicodedata
9
+ from collections.abc import Sequence
10
+ from dataclasses import dataclass
11
+
12
+ ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
13
+
14
+
15
+ def supports_color() -> bool:
16
+ """Return True if stdout supports ANSI escape sequences."""
17
+ if not sys.stdout.isatty():
18
+ return False
19
+ if os.environ.get("NO_COLOR"):
20
+ return False
21
+ return os.environ.get("TERM") != "dumb"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Theme:
26
+ """ANSI styling token container with automatic color suppression."""
27
+
28
+ r: str = ""
29
+ b: str = ""
30
+ d: str = ""
31
+ green: str = ""
32
+ red: str = ""
33
+ yellow: str = ""
34
+ cyan: str = ""
35
+ magenta: str = ""
36
+
37
+
38
+ def get_theme(color: bool | None = None) -> Theme:
39
+ """Return active Theme instance with ANSI codes or empty strings."""
40
+ enabled = supports_color() if color is None else color
41
+ if not enabled:
42
+ return Theme()
43
+ return Theme(
44
+ r="\033[0m",
45
+ b="\033[1m",
46
+ d="\033[2m",
47
+ green="\033[32m",
48
+ red="\033[31m",
49
+ yellow="\033[33m",
50
+ cyan="\033[36m",
51
+ magenta="\033[35m",
52
+ )
53
+
54
+
55
+ def strip_ansi(text: str) -> str:
56
+ """Remove ANSI escape sequences from text."""
57
+ return ANSI_RE.sub("", text)
58
+
59
+
60
+ def visible_width(text: str) -> int:
61
+ """Return visual monospace column width of text, ignoring ANSI escape codes."""
62
+ clean = strip_ansi(text)
63
+ return sum(
64
+ 2 if unicodedata.east_asian_width(c) in ("W", "F") else 0 if unicodedata.combining(c) else 1
65
+ for c in clean
66
+ )
67
+
68
+
69
+ def pad_cell(text: str, width: int, align: str = "left") -> str:
70
+ """Pad text to visual width, preserving embedded ANSI escape sequences."""
71
+ pad = " " * max(0, width - visible_width(text))
72
+ return pad + text if align == "right" else text + pad
73
+
74
+
75
+ def contract_path(path_str: str) -> str:
76
+ """Contract home directory path to ~ prefix."""
77
+ if not path_str or path_str == "n/a":
78
+ return path_str
79
+ home = os.path.expanduser("~")
80
+ return f"~{path_str[len(home) :]}" if path_str.startswith(home) else path_str
81
+
82
+
83
+ MIN_ELLIPSIS_LEN = 4
84
+
85
+
86
+ def middle_truncate(text: str, max_len: int) -> str:
87
+ """Truncate middle of text with ellipsis if exceeding max_len."""
88
+ if len(text) <= max_len:
89
+ return text
90
+ if max_len < MIN_ELLIPSIS_LEN:
91
+ return text[:max_len]
92
+ left = (max_len - 1) // 2
93
+ return f"{text[:left]}…{text[-(max_len - 1 - left) :]}"
94
+
95
+
96
+ def format_table(
97
+ headers: Sequence[str],
98
+ rows: Sequence[Sequence[str]],
99
+ gutter: int = 4,
100
+ ) -> list[str]:
101
+ """Format tabular data with dynamically calculated column widths and alignment."""
102
+ th = get_theme()
103
+ cols = len(headers)
104
+ all_rows = [headers, *rows]
105
+ widths = [max(visible_width(r[i]) for r in all_rows) for i in range(cols)]
106
+ space = " " * gutter
107
+ hdr_line = space.join(pad_cell(f"{th.d}{h}{th.r}", widths[i]) for i, h in enumerate(headers))
108
+ div_line = f"{th.d}{'─' * (sum(widths) + gutter * (cols - 1))}{th.r}"
109
+ row_lines = [space.join(pad_cell(r[i], widths[i]) for i in range(cols)) for r in rows]
110
+ return [hdr_line, div_line, *row_lines]
111
+
112
+
113
+ def highlight_log_line(line: str, th: Theme) -> str:
114
+ """Highlight log levels and dim timestamps in log lines without altering content."""
115
+ if not th.r:
116
+ return line
117
+ line = re.sub(
118
+ r"(\[(?:error|fatal|fail)\]|\b(?:ERROR|FATAL|CRITICAL)\b)",
119
+ f"{th.red}\\1{th.r}",
120
+ line,
121
+ flags=re.I,
122
+ )
123
+ line = re.sub(
124
+ r"(\[(?:warn|warning)\]|\b(?:WARN|WARNING)\b)",
125
+ f"{th.yellow}\\1{th.r}",
126
+ line,
127
+ flags=re.I,
128
+ )
129
+ line = re.sub(
130
+ r"(\[(?:info|notice)\]|\b(?:INFO|NOTICE)\b)",
131
+ f"{th.green}\\1{th.r}",
132
+ line,
133
+ flags=re.I,
134
+ )
135
+ line = re.sub(
136
+ r"(\[(?:debug|trace)\]|\b(?:DEBUG|TRACE)\b)",
137
+ f"{th.d}\\1{th.r}",
138
+ line,
139
+ flags=re.I,
140
+ )
141
+ return re.sub(
142
+ r"^(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?)",
143
+ f"{th.d}\\1{th.r}",
144
+ line,
145
+ )
@@ -0,0 +1 @@
1
+ """Manifest loading, models, and detection heuristics for rig."""
@@ -0,0 +1,138 @@
1
+ """Compose service image detection and datastore classification heuristics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ POSTGRES_IMAGES = ("postgres", "postgresql", "postgis", "timescaledb")
11
+ POSTGRES_NAMES = ("db", "database", "postgres", "postgresql")
12
+ REDIS_IMAGES = ("redis", "valkey")
13
+ REDIS_NAMES = ("redis", "valkey", "cache")
14
+
15
+ _IMAGE_KINDS = {
16
+ **dict.fromkeys(POSTGRES_IMAGES, "postgres"),
17
+ **dict.fromkeys(REDIS_IMAGES, "redis"),
18
+ }
19
+ _NAME_KINDS = {
20
+ **dict.fromkeys(POSTGRES_NAMES, "postgres"),
21
+ **dict.fromkeys(REDIS_NAMES, "redis"),
22
+ }
23
+ _PACKAGE_MANAGERS = (
24
+ ("pnpm-lock.yaml", "pnpm"),
25
+ ("yarn.lock", "yarn"),
26
+ ("bun.lockb", "bun"),
27
+ )
28
+
29
+
30
+ def _is_top_level_section(line: str) -> bool:
31
+ return bool(re.match(r"^[a-zA-Z0-9_-]+\s*:\s*$", line) and not line.startswith(" "))
32
+
33
+
34
+ def _parse_service_header(line: str) -> str | None:
35
+ m = re.match(r"^ {2}([a-zA-Z0-9_-]+)\s*:\s*$", line)
36
+ return m.group(1) if m else None
37
+
38
+
39
+ def _append_service_line(
40
+ services: dict[str, list[str]], current_svc: str | None, line: str
41
+ ) -> str | None:
42
+ svc_name = _parse_service_header(line)
43
+ if svc_name:
44
+ services[svc_name] = []
45
+ return svc_name
46
+ if current_svc and line.startswith((" ", "\t")):
47
+ services[current_svc].append(line)
48
+ return current_svc
49
+
50
+
51
+ def _extract_compose_services(content: str) -> dict[str, str]:
52
+ """Return each top-level Compose service name mapped to its own block."""
53
+ services: dict[str, list[str]] = {}
54
+ in_services = False
55
+ current_svc = None
56
+
57
+ for line in content.splitlines():
58
+ trimmed = line.strip()
59
+ if not trimmed or trimmed.startswith("#"):
60
+ continue
61
+ if re.match(r"^services\s*:\s*$", line):
62
+ in_services, current_svc = True, None
63
+ continue
64
+ if in_services and _is_top_level_section(line):
65
+ in_services, current_svc = False, None
66
+ continue
67
+ if in_services:
68
+ current_svc = _append_service_line(services, current_svc, line)
69
+
70
+ return {k: "\n".join(v).lower() for k, v in services.items()}
71
+
72
+
73
+ def _compose_service_image(block: str) -> str | None:
74
+ """Return the image name declared in one Compose service block."""
75
+ match = re.search(r"^\s{2,}image\s*:\s*[\"']?([^\"'\s#]+)", block, re.MULTILINE)
76
+ if not match:
77
+ return None
78
+ reference = match.group(1)
79
+ return reference.rsplit("/", 1)[-1].split(":", 1)[0]
80
+
81
+
82
+ def classify_compose_service(name: str, block: str) -> str | None:
83
+ """Return 'postgres', 'redis' or None for one Compose service."""
84
+ image = _compose_service_image(block)
85
+ return _IMAGE_KINDS.get(image) if image is not None else _NAME_KINDS.get(name.lower())
86
+
87
+
88
+ def _find_fastapi_app(root: Path) -> str:
89
+ if (root / "app" / "main.py").is_file():
90
+ return "app.main:app"
91
+ return "src.main:app" if (root / "src" / "main.py").is_file() else "main:app"
92
+
93
+
94
+ def detect_backend(
95
+ root: Path, native_services: dict[str, Any], base_services: dict[str, Any]
96
+ ) -> bool:
97
+ if (root / "manage.py").is_file():
98
+ native_services["backend"] = {
99
+ "type": "port",
100
+ "cwd": ".",
101
+ "command": ["python", "manage.py", "runserver", "127.0.0.1:{port}"],
102
+ "healthcheck_path": "/",
103
+ "description": "Django web application",
104
+ }
105
+ return True
106
+ if (root / "pyproject.toml").is_file() or (root / "requirements.txt").is_file():
107
+ spec: dict[str, Any] = {
108
+ "type": "fd",
109
+ "cwd": ".",
110
+ "python": sys.executable,
111
+ "app": _find_fastapi_app(root),
112
+ "healthcheck_path": "/healthz",
113
+ "description": "FastAPI / ASGI backend application",
114
+ }
115
+ if "postgres" in base_services:
116
+ spec["depends_on"] = ["postgres"]
117
+ native_services["backend"] = spec
118
+ return True
119
+ return False
120
+
121
+
122
+ def detect_frontend(root: Path, native_services: dict[str, Any], has_backend: bool) -> None:
123
+ if not (root / "package.json").is_file():
124
+ return
125
+ pm = next(
126
+ (manager for lock, manager in _PACKAGE_MANAGERS if (root / lock).is_file()),
127
+ "npm",
128
+ )
129
+ spec: dict[str, Any] = {
130
+ "type": "port",
131
+ "cwd": ".",
132
+ "command": [pm, "run", "dev", "--", "--port", "{port}"],
133
+ "healthcheck_path": "/",
134
+ "description": "Frontend development server",
135
+ }
136
+ if has_backend:
137
+ spec["depends_on"] = ["backend"]
138
+ native_services["frontend"] = spec