do-again 0.1.1__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.
- {do_again-0.1.1 → do_again-0.2.0}/.github/workflows/npm-release.yml +16 -0
- {do_again-0.1.1 → do_again-0.2.0}/PKG-INFO +24 -6
- {do_again-0.1.1 → do_again-0.2.0}/README.md +23 -5
- {do_again-0.1.1 → do_again-0.2.0}/package-lock.json +2 -2
- {do_again-0.1.1 → do_again-0.2.0}/package.json +3 -2
- {do_again-0.1.1 → do_again-0.2.0}/pyproject.toml +1 -1
- do_again-0.2.0/src/do_again/cli.py +137 -0
- do_again-0.2.0/src/do_again/service/__init__.py +3 -0
- do_again-0.2.0/src/do_again/service/runtime.py +372 -0
- do_again-0.2.0/templates/policy.json +137 -0
- do_again-0.2.0/tests/test_service.py +62 -0
- do_again-0.1.1/src/do_again/cli.py +0 -63
- do_again-0.1.1/src/do_again/service/__init__.py +0 -0
- {do_again-0.1.1 → do_again-0.2.0}/.github/dependabot.yml +0 -0
- {do_again-0.1.1 → do_again-0.2.0}/.github/workflows/ci.yml +0 -0
- {do_again-0.1.1 → do_again-0.2.0}/.github/workflows/release.yml +0 -0
- {do_again-0.1.1 → do_again-0.2.0}/.gitignore +0 -0
- {do_again-0.1.1 → do_again-0.2.0}/.npmignore +0 -0
- {do_again-0.1.1 → do_again-0.2.0}/LICENSE +0 -0
- {do_again-0.1.1 → do_again-0.2.0}/bin/do-again.js +0 -0
- {do_again-0.1.1 → do_again-0.2.0}/npm-tests/launcher.test.js +0 -0
- {do_again-0.1.1 → do_again-0.2.0}/src/do_again/__init__.py +0 -0
- {do_again-0.1.1 → do_again-0.2.0}/src/do_again/core/__init__.py +0 -0
- {do_again-0.1.1 → do_again-0.2.0}/src/do_again/core/agent.py +0 -0
- {do_again-0.1.1 → do_again-0.2.0}/src/do_again/core/executor.py +0 -0
- {do_again-0.1.1 → do_again-0.2.0}/src/do_again/core/schema.py +0 -0
- /do_again-0.1.1/templates/policy.json → /do_again-0.2.0/src/do_again/default_policy.json +0 -0
- {do_again-0.1.1 → do_again-0.2.0}/src/do_again/platforms/__init__.py +0 -0
- {do_again-0.1.1 → do_again-0.2.0}/src/do_again/platforms/base.py +0 -0
- {do_again-0.1.1 → do_again-0.2.0}/src/do_again/platforms/detect.py +0 -0
- {do_again-0.1.1 → do_again-0.2.0}/src/do_again/platforms/linux.py +0 -0
- {do_again-0.1.1 → do_again-0.2.0}/src/do_again/platforms/macos.py +0 -0
- {do_again-0.1.1 → do_again-0.2.0}/src/do_again/platforms/windows.py +0 -0
- {do_again-0.1.1 → do_again-0.2.0}/tests/test_executor.py +0 -0
- {do_again-0.1.1 → do_again-0.2.0}/tests/test_platforms.py +0 -0
- {do_again-0.1.1 → do_again-0.2.0}/tools/check_release_version.py +0 -0
|
@@ -32,3 +32,19 @@ jobs:
|
|
|
32
32
|
- run: npm ci
|
|
33
33
|
- run: npm test
|
|
34
34
|
- run: npm publish
|
|
35
|
+
- name: Wait for npm registry availability
|
|
36
|
+
shell: bash
|
|
37
|
+
run: |
|
|
38
|
+
version=$(node -p "require('./package.json').version")
|
|
39
|
+
for attempt in $(seq 1 120); do
|
|
40
|
+
published=$(npm view "do-again@$version" version 2>/dev/null || true)
|
|
41
|
+
latest=$(npm view do-again dist-tags.latest 2>/dev/null || true)
|
|
42
|
+
if [ "$published" = "$version" ] && [ "$latest" = "$version" ]; then
|
|
43
|
+
echo "do-again@$version is available from the public npm registry"
|
|
44
|
+
exit 0
|
|
45
|
+
fi
|
|
46
|
+
echo "Waiting for npm registry propagation ($attempt/120)..."
|
|
47
|
+
sleep 15
|
|
48
|
+
done
|
|
49
|
+
echo "Timed out waiting for do-again@$version to become publicly available" >&2
|
|
50
|
+
exit 1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: do-again
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Policy-controlled local execution for agent-driven development workflows
|
|
5
5
|
Project-URL: Homepage, https://github.com/Tran-Steven/do-again
|
|
6
6
|
Project-URL: Repository, https://github.com/Tran-Steven/do-again
|
|
@@ -94,9 +94,27 @@ The npm package ships the same Python runtime from this repository behind a smal
|
|
|
94
94
|
```bash
|
|
95
95
|
do-again doctor
|
|
96
96
|
do-again init
|
|
97
|
+
do-again install
|
|
97
98
|
do-again status
|
|
98
99
|
```
|
|
99
100
|
|
|
101
|
+
## Service lifecycle
|
|
102
|
+
|
|
103
|
+
On macOS, Do Again can install a per-repository launchd agent:
|
|
104
|
+
|
|
105
|
+
do-again install
|
|
106
|
+
do-again status
|
|
107
|
+
do-again restart
|
|
108
|
+
do-again stop
|
|
109
|
+
do-again uninstall
|
|
110
|
+
|
|
111
|
+
For attended or cross-platform use, run the agent in the foreground:
|
|
112
|
+
|
|
113
|
+
do-again run
|
|
114
|
+
do-again run --once
|
|
115
|
+
|
|
116
|
+
Each repository gets an isolated runtime, control worktree, state directory, policy copy, and service label under ~/.do_again. The installed service runs from a copied runtime so installs made through either PyPI or npm remain stable after the invoking shell exits.
|
|
117
|
+
|
|
100
118
|
## Why Do Again
|
|
101
119
|
|
|
102
120
|
- Explicit policy-based command permissions
|
|
@@ -111,11 +129,11 @@ do-again status
|
|
|
111
129
|
|
|
112
130
|
| Platform | Status | Service backend |
|
|
113
131
|
| --- | --- | --- |
|
|
114
|
-
| macOS |
|
|
115
|
-
| Linux |
|
|
116
|
-
| Windows |
|
|
132
|
+
| macOS | Service lifecycle working | launchd |
|
|
133
|
+
| Linux | Foreground runner working; service adapter scaffolded | systemd user service |
|
|
134
|
+
| Windows | Foreground runner working; service adapter scaffolded | Windows service |
|
|
117
135
|
|
|
118
|
-
Do Again is currently alpha software. macOS is the first
|
|
136
|
+
Do Again is currently alpha software. The foreground runner works across the supported Python platforms; macOS is the first platform with an installable background service while Linux and Windows service integration are being completed behind the same interface.
|
|
119
137
|
|
|
120
138
|
## Development
|
|
121
139
|
|
|
@@ -129,7 +147,7 @@ do-again doctor
|
|
|
129
147
|
|
|
130
148
|
## Release model
|
|
131
149
|
|
|
132
|
-
Releases use semantic versioning. Pushing a version tag such as `v0.
|
|
150
|
+
Releases use semantic versioning. Pushing a version tag such as `v0.2.0` validates the shared release version, publishes the Python distribution to PyPI and the Node launcher to npm through Trusted Publishing, and creates the matching GitHub Release.
|
|
133
151
|
|
|
134
152
|
PyPI distribution name: `do-again`
|
|
135
153
|
|
|
@@ -59,9 +59,27 @@ The npm package ships the same Python runtime from this repository behind a smal
|
|
|
59
59
|
```bash
|
|
60
60
|
do-again doctor
|
|
61
61
|
do-again init
|
|
62
|
+
do-again install
|
|
62
63
|
do-again status
|
|
63
64
|
```
|
|
64
65
|
|
|
66
|
+
## Service lifecycle
|
|
67
|
+
|
|
68
|
+
On macOS, Do Again can install a per-repository launchd agent:
|
|
69
|
+
|
|
70
|
+
do-again install
|
|
71
|
+
do-again status
|
|
72
|
+
do-again restart
|
|
73
|
+
do-again stop
|
|
74
|
+
do-again uninstall
|
|
75
|
+
|
|
76
|
+
For attended or cross-platform use, run the agent in the foreground:
|
|
77
|
+
|
|
78
|
+
do-again run
|
|
79
|
+
do-again run --once
|
|
80
|
+
|
|
81
|
+
Each repository gets an isolated runtime, control worktree, state directory, policy copy, and service label under ~/.do_again. The installed service runs from a copied runtime so installs made through either PyPI or npm remain stable after the invoking shell exits.
|
|
82
|
+
|
|
65
83
|
## Why Do Again
|
|
66
84
|
|
|
67
85
|
- Explicit policy-based command permissions
|
|
@@ -76,11 +94,11 @@ do-again status
|
|
|
76
94
|
|
|
77
95
|
| Platform | Status | Service backend |
|
|
78
96
|
| --- | --- | --- |
|
|
79
|
-
| macOS |
|
|
80
|
-
| Linux |
|
|
81
|
-
| Windows |
|
|
97
|
+
| macOS | Service lifecycle working | launchd |
|
|
98
|
+
| Linux | Foreground runner working; service adapter scaffolded | systemd user service |
|
|
99
|
+
| Windows | Foreground runner working; service adapter scaffolded | Windows service |
|
|
82
100
|
|
|
83
|
-
Do Again is currently alpha software. macOS is the first
|
|
101
|
+
Do Again is currently alpha software. The foreground runner works across the supported Python platforms; macOS is the first platform with an installable background service while Linux and Windows service integration are being completed behind the same interface.
|
|
84
102
|
|
|
85
103
|
## Development
|
|
86
104
|
|
|
@@ -94,7 +112,7 @@ do-again doctor
|
|
|
94
112
|
|
|
95
113
|
## Release model
|
|
96
114
|
|
|
97
|
-
Releases use semantic versioning. Pushing a version tag such as `v0.
|
|
115
|
+
Releases use semantic versioning. Pushing a version tag such as `v0.2.0` validates the shared release version, publishes the Python distribution to PyPI and the Node launcher to npm through Trusted Publishing, and creates the matching GitHub Release.
|
|
98
116
|
|
|
99
117
|
PyPI distribution name: `do-again`
|
|
100
118
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "do-again",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "do-again",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.2.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"bin": {
|
|
12
12
|
"do-again": "bin/do-again.js"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "do-again",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Policy-controlled local execution for AI coding agents",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Steven Tran",
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"bin/",
|
|
13
13
|
"src/do_again/**/*.py",
|
|
14
14
|
"README.md",
|
|
15
|
-
"LICENSE"
|
|
15
|
+
"LICENSE",
|
|
16
|
+
"src/do_again/default_policy.json"
|
|
16
17
|
],
|
|
17
18
|
"engines": {
|
|
18
19
|
"node": ">=18"
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .platforms.detect import detect_platform
|
|
9
|
+
from .service.runtime import (
|
|
10
|
+
ServiceError,
|
|
11
|
+
find_repo,
|
|
12
|
+
install_service,
|
|
13
|
+
restart_service,
|
|
14
|
+
run_foreground,
|
|
15
|
+
service_status,
|
|
16
|
+
stop_service,
|
|
17
|
+
uninstall_service,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def doctor() -> int:
|
|
22
|
+
info = detect_platform()
|
|
23
|
+
checks = {
|
|
24
|
+
"python": sys.version_info >= (3, 11),
|
|
25
|
+
"git": shutil.which("git") is not None,
|
|
26
|
+
"platform": info.supported,
|
|
27
|
+
}
|
|
28
|
+
for key, ok in checks.items():
|
|
29
|
+
print(f"{'OK' if ok else 'FAIL'} {key}")
|
|
30
|
+
print(f"platform_name={info.name}")
|
|
31
|
+
print(f"service_manager={info.service_manager}")
|
|
32
|
+
return 0 if all(checks.values()) else 1
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _print_service_status(value: dict[str, object]) -> None:
|
|
36
|
+
for key in (
|
|
37
|
+
"platform",
|
|
38
|
+
"service_manager",
|
|
39
|
+
"repo",
|
|
40
|
+
"label",
|
|
41
|
+
"installed",
|
|
42
|
+
"running",
|
|
43
|
+
"pid",
|
|
44
|
+
"runtime_dir",
|
|
45
|
+
"control_worktree",
|
|
46
|
+
):
|
|
47
|
+
if key in value:
|
|
48
|
+
print(f"{key}={value[key]}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def status(path: str = ".") -> int:
|
|
52
|
+
info = detect_platform()
|
|
53
|
+
try:
|
|
54
|
+
repo = find_repo(path)
|
|
55
|
+
except ServiceError:
|
|
56
|
+
print(f"platform={info.name}")
|
|
57
|
+
print(f"service_manager={info.service_manager}")
|
|
58
|
+
return 0
|
|
59
|
+
try:
|
|
60
|
+
value = service_status(repo)
|
|
61
|
+
except ServiceError as exc:
|
|
62
|
+
print(f"do-again: {exc}", file=sys.stderr)
|
|
63
|
+
return 1
|
|
64
|
+
_print_service_status(value)
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def init_project(path: str) -> int:
|
|
69
|
+
target = Path(path).expanduser().resolve()
|
|
70
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
config = target / "do-again.toml"
|
|
72
|
+
if config.exists():
|
|
73
|
+
print(str(config))
|
|
74
|
+
return 0
|
|
75
|
+
config.write_text('[do_again]\ncontrol_branch = "operator-control"\n')
|
|
76
|
+
print(str(config))
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _service_action(action: str, path: str) -> int:
|
|
81
|
+
try:
|
|
82
|
+
if action == "install":
|
|
83
|
+
value = install_service(path)
|
|
84
|
+
elif action == "stop":
|
|
85
|
+
value = stop_service(path)
|
|
86
|
+
elif action == "restart":
|
|
87
|
+
value = restart_service(path)
|
|
88
|
+
elif action == "uninstall":
|
|
89
|
+
value = uninstall_service(path)
|
|
90
|
+
else:
|
|
91
|
+
raise ServiceError(f"unsupported service action: {action}")
|
|
92
|
+
except ServiceError as exc:
|
|
93
|
+
print(f"do-again: {exc}", file=sys.stderr)
|
|
94
|
+
return 1
|
|
95
|
+
_print_service_status(value)
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main() -> int:
|
|
100
|
+
parser = argparse.ArgumentParser(prog="do-again")
|
|
101
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
102
|
+
sub.add_parser("doctor")
|
|
103
|
+
|
|
104
|
+
status_parser = sub.add_parser("status")
|
|
105
|
+
status_parser.add_argument("path", nargs="?", default=".")
|
|
106
|
+
|
|
107
|
+
init_parser = sub.add_parser("init")
|
|
108
|
+
init_parser.add_argument("path", nargs="?", default=".")
|
|
109
|
+
|
|
110
|
+
for name in ("install", "stop", "restart", "uninstall"):
|
|
111
|
+
command = sub.add_parser(name)
|
|
112
|
+
command.add_argument("path", nargs="?", default=".")
|
|
113
|
+
|
|
114
|
+
run_parser = sub.add_parser("run")
|
|
115
|
+
run_parser.add_argument("path", nargs="?", default=".")
|
|
116
|
+
run_parser.add_argument("--once", action="store_true")
|
|
117
|
+
|
|
118
|
+
args = parser.parse_args()
|
|
119
|
+
if args.command == "doctor":
|
|
120
|
+
return doctor()
|
|
121
|
+
if args.command == "status":
|
|
122
|
+
return status(args.path)
|
|
123
|
+
if args.command == "init":
|
|
124
|
+
return init_project(args.path)
|
|
125
|
+
if args.command in {"install", "stop", "restart", "uninstall"}:
|
|
126
|
+
return _service_action(args.command, args.path)
|
|
127
|
+
if args.command == "run":
|
|
128
|
+
try:
|
|
129
|
+
return run_foreground(args.path, once=args.once)
|
|
130
|
+
except ServiceError as exc:
|
|
131
|
+
print(f"do-again: {exc}", file=sys.stderr)
|
|
132
|
+
return 1
|
|
133
|
+
return 2
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import plistlib
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
import tomllib
|
|
13
|
+
from dataclasses import asdict, dataclass
|
|
14
|
+
from importlib import resources
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ..core.agent import Agent
|
|
19
|
+
from ..platforms.detect import detect_platform
|
|
20
|
+
from ..platforms.macos import service_definition_path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ServiceError(RuntimeError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class RuntimeLayout:
|
|
29
|
+
repo: Path
|
|
30
|
+
branch: str
|
|
31
|
+
key: str
|
|
32
|
+
label: str
|
|
33
|
+
root: Path
|
|
34
|
+
control_worktree: Path
|
|
35
|
+
state_dir: Path
|
|
36
|
+
policy_path: Path
|
|
37
|
+
runtime_source: Path
|
|
38
|
+
metadata_path: Path
|
|
39
|
+
stdout_log: Path
|
|
40
|
+
stderr_log: Path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _run(argv: list[str], *, cwd: Path | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
44
|
+
proc = subprocess.run(argv, cwd=cwd, text=True, capture_output=True)
|
|
45
|
+
if check and proc.returncode != 0:
|
|
46
|
+
detail = (proc.stderr or proc.stdout).strip()
|
|
47
|
+
raise ServiceError(f"{' '.join(argv)} failed ({proc.returncode}): {detail}")
|
|
48
|
+
return proc
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def find_repo(path: str | Path = ".") -> Path:
|
|
52
|
+
candidate = Path(path).expanduser().resolve()
|
|
53
|
+
proc = _run(["git", "-C", str(candidate), "rev-parse", "--show-toplevel"], check=False)
|
|
54
|
+
if proc.returncode != 0:
|
|
55
|
+
raise ServiceError(f"not inside a Git repository: {candidate}")
|
|
56
|
+
return Path(proc.stdout.strip()).resolve()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _config_branch(repo: Path) -> str:
|
|
60
|
+
config = repo / "do-again.toml"
|
|
61
|
+
if not config.is_file():
|
|
62
|
+
return "operator-control"
|
|
63
|
+
try:
|
|
64
|
+
value = tomllib.loads(config.read_text(encoding="utf-8"))
|
|
65
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
66
|
+
raise ServiceError(f"invalid Do Again config: {config}: {exc}") from exc
|
|
67
|
+
branch = str(value.get("do_again", {}).get("control_branch", "operator-control")).strip()
|
|
68
|
+
if not re.fullmatch(r"[A-Za-z0-9._/-]{1,120}", branch) or branch.startswith("-") or ".." in branch:
|
|
69
|
+
raise ServiceError(f"invalid control branch: {branch!r}")
|
|
70
|
+
return branch
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def runtime_layout(repo: Path) -> RuntimeLayout:
|
|
74
|
+
repo = repo.resolve()
|
|
75
|
+
branch = _config_branch(repo)
|
|
76
|
+
key = hashlib.sha256(str(repo).encode("utf-8")).hexdigest()[:12]
|
|
77
|
+
label = f"io.github.tran-steven.do-again.{key}"
|
|
78
|
+
home = Path(os.environ.get("DO_AGAIN_HOME", str(Path.home() / ".do_again"))).expanduser().resolve()
|
|
79
|
+
root = home / "projects" / key
|
|
80
|
+
return RuntimeLayout(
|
|
81
|
+
repo=repo,
|
|
82
|
+
branch=branch,
|
|
83
|
+
key=key,
|
|
84
|
+
label=label,
|
|
85
|
+
root=root,
|
|
86
|
+
control_worktree=root / "control",
|
|
87
|
+
state_dir=root / "state",
|
|
88
|
+
policy_path=root / "policy.json",
|
|
89
|
+
runtime_source=root / "runtime",
|
|
90
|
+
metadata_path=root / "runtime.json",
|
|
91
|
+
stdout_log=root / "logs" / "agent.out.log",
|
|
92
|
+
stderr_log=root / "logs" / "agent.err.log",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _default_policy() -> dict[str, Any]:
|
|
97
|
+
text = resources.files("do_again").joinpath("default_policy.json").read_text(encoding="utf-8")
|
|
98
|
+
value = json.loads(text)
|
|
99
|
+
if not isinstance(value, dict):
|
|
100
|
+
raise ServiceError("bundled default policy is invalid")
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _ensure_remote_control_branch(layout: RuntimeLayout) -> None:
|
|
105
|
+
remote = _run(
|
|
106
|
+
["git", "-C", str(layout.repo), "ls-remote", "--exit-code", "--heads", "origin", layout.branch],
|
|
107
|
+
check=False,
|
|
108
|
+
)
|
|
109
|
+
if remote.returncode != 0:
|
|
110
|
+
_run(["git", "-C", str(layout.repo), "push", "origin", f"HEAD:refs/heads/{layout.branch}"])
|
|
111
|
+
_run(["git", "-C", str(layout.repo), "fetch", "--quiet", "origin", layout.branch])
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _ensure_control_worktree(layout: RuntimeLayout) -> None:
|
|
115
|
+
if layout.control_worktree.exists():
|
|
116
|
+
check = _run(
|
|
117
|
+
["git", "-C", str(layout.control_worktree), "rev-parse", "--show-toplevel"],
|
|
118
|
+
check=False,
|
|
119
|
+
)
|
|
120
|
+
if check.returncode != 0:
|
|
121
|
+
raise ServiceError(f"control path exists but is not a Git worktree: {layout.control_worktree}")
|
|
122
|
+
dirty = _run(["git", "-C", str(layout.control_worktree), "status", "--porcelain"])
|
|
123
|
+
if dirty.stdout.strip():
|
|
124
|
+
raise ServiceError(f"control worktree is dirty: {layout.control_worktree}")
|
|
125
|
+
return
|
|
126
|
+
layout.control_worktree.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
_run(
|
|
128
|
+
[
|
|
129
|
+
"git",
|
|
130
|
+
"-C",
|
|
131
|
+
str(layout.repo),
|
|
132
|
+
"worktree",
|
|
133
|
+
"add",
|
|
134
|
+
"--detach",
|
|
135
|
+
str(layout.control_worktree),
|
|
136
|
+
f"origin/{layout.branch}",
|
|
137
|
+
]
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _copy_runtime(layout: RuntimeLayout) -> None:
|
|
142
|
+
package_root = Path(__file__).resolve().parents[1]
|
|
143
|
+
target = layout.runtime_source / "do_again"
|
|
144
|
+
if target.exists():
|
|
145
|
+
shutil.rmtree(target)
|
|
146
|
+
layout.runtime_source.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
shutil.copytree(
|
|
148
|
+
package_root,
|
|
149
|
+
target,
|
|
150
|
+
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo"),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def prepare_runtime(repo: Path) -> RuntimeLayout:
|
|
155
|
+
layout = runtime_layout(repo)
|
|
156
|
+
layout.state_dir.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
layout.stdout_log.parent.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
_ensure_remote_control_branch(layout)
|
|
159
|
+
_ensure_control_worktree(layout)
|
|
160
|
+
|
|
161
|
+
policy = _default_policy()
|
|
162
|
+
policy["control_branch"] = layout.branch
|
|
163
|
+
policy["agent_launchd_label"] = layout.label
|
|
164
|
+
layout.policy_path.write_text(json.dumps(policy, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
165
|
+
_copy_runtime(layout)
|
|
166
|
+
|
|
167
|
+
metadata = {
|
|
168
|
+
"schema_version": 1,
|
|
169
|
+
"repo": str(layout.repo),
|
|
170
|
+
"control_branch": layout.branch,
|
|
171
|
+
"control_worktree": str(layout.control_worktree),
|
|
172
|
+
"state_dir": str(layout.state_dir),
|
|
173
|
+
"policy": str(layout.policy_path),
|
|
174
|
+
"service_label": layout.label,
|
|
175
|
+
}
|
|
176
|
+
layout.metadata_path.write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
177
|
+
return layout
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _domain() -> str:
|
|
181
|
+
return f"gui/{os.getuid()}"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _launchd_plist(layout: RuntimeLayout) -> dict[str, Any]:
|
|
185
|
+
path = os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin")
|
|
186
|
+
return {
|
|
187
|
+
"Label": layout.label,
|
|
188
|
+
"ProgramArguments": [
|
|
189
|
+
sys.executable,
|
|
190
|
+
"-m",
|
|
191
|
+
"do_again.core.agent",
|
|
192
|
+
"--repo",
|
|
193
|
+
str(layout.repo),
|
|
194
|
+
"--control-worktree",
|
|
195
|
+
str(layout.control_worktree),
|
|
196
|
+
"--branch",
|
|
197
|
+
layout.branch,
|
|
198
|
+
"--policy",
|
|
199
|
+
str(layout.policy_path),
|
|
200
|
+
"--state-dir",
|
|
201
|
+
str(layout.state_dir),
|
|
202
|
+
],
|
|
203
|
+
"WorkingDirectory": str(layout.repo),
|
|
204
|
+
"EnvironmentVariables": {
|
|
205
|
+
"PATH": path,
|
|
206
|
+
"PYTHONPATH": str(layout.runtime_source),
|
|
207
|
+
"PYTHONDONTWRITEBYTECODE": "1",
|
|
208
|
+
"PYTHONUNBUFFERED": "1",
|
|
209
|
+
},
|
|
210
|
+
"RunAtLoad": True,
|
|
211
|
+
"KeepAlive": True,
|
|
212
|
+
"ProcessType": "Background",
|
|
213
|
+
"ThrottleInterval": 5,
|
|
214
|
+
"StandardOutPath": str(layout.stdout_log),
|
|
215
|
+
"StandardErrorPath": str(layout.stderr_log),
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _require_macos() -> None:
|
|
220
|
+
info = detect_platform()
|
|
221
|
+
if info.name != "macos":
|
|
222
|
+
raise ServiceError(
|
|
223
|
+
f"background service lifecycle is currently supported on macOS only; detected {info.name}"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _launchctl(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
228
|
+
return _run(["launchctl", *args], check=check)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _loaded(layout: RuntimeLayout) -> bool:
|
|
232
|
+
return _launchctl("print", f"{_domain()}/{layout.label}", check=False).returncode == 0
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _wait_loaded(layout: RuntimeLayout, expected: bool, timeout: float = 8.0) -> None:
|
|
236
|
+
deadline = time.monotonic() + timeout
|
|
237
|
+
while time.monotonic() < deadline:
|
|
238
|
+
if _loaded(layout) is expected:
|
|
239
|
+
return
|
|
240
|
+
time.sleep(0.1)
|
|
241
|
+
state = "loaded" if expected else "unloaded"
|
|
242
|
+
raise ServiceError(f"launchd service did not become {state}: {layout.label}")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def service_status(path: str | Path = ".") -> dict[str, Any]:
|
|
246
|
+
repo = find_repo(path)
|
|
247
|
+
layout = runtime_layout(repo)
|
|
248
|
+
info = detect_platform()
|
|
249
|
+
result: dict[str, Any] = {
|
|
250
|
+
"repo": str(repo),
|
|
251
|
+
"platform": info.name,
|
|
252
|
+
"service_manager": info.service_manager,
|
|
253
|
+
"label": layout.label,
|
|
254
|
+
"runtime_dir": str(layout.root),
|
|
255
|
+
"control_worktree": str(layout.control_worktree),
|
|
256
|
+
"installed": False,
|
|
257
|
+
"running": False,
|
|
258
|
+
"pid": None,
|
|
259
|
+
}
|
|
260
|
+
if info.name != "macos":
|
|
261
|
+
return result
|
|
262
|
+
plist = service_definition_path(layout.label)
|
|
263
|
+
result["installed"] = plist.is_file()
|
|
264
|
+
probe = _launchctl("print", f"{_domain()}/{layout.label}", check=False)
|
|
265
|
+
if probe.returncode == 0:
|
|
266
|
+
result["running"] = True
|
|
267
|
+
match = re.search(r"\bpid\s*=\s*(\d+)", probe.stdout)
|
|
268
|
+
if match:
|
|
269
|
+
result["pid"] = int(match.group(1))
|
|
270
|
+
return result
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def install_service(path: str | Path = ".") -> dict[str, Any]:
|
|
274
|
+
_require_macos()
|
|
275
|
+
repo = find_repo(path)
|
|
276
|
+
existing = runtime_layout(repo)
|
|
277
|
+
if _loaded(existing):
|
|
278
|
+
proc = _launchctl("bootout", f"{_domain()}/{existing.label}", check=False)
|
|
279
|
+
if proc.returncode != 0:
|
|
280
|
+
raise ServiceError(f"launchctl bootout failed: {(proc.stderr or proc.stdout).strip()}")
|
|
281
|
+
_wait_loaded(existing, False)
|
|
282
|
+
|
|
283
|
+
layout = prepare_runtime(repo)
|
|
284
|
+
plist = service_definition_path(layout.label)
|
|
285
|
+
plist.parent.mkdir(parents=True, exist_ok=True)
|
|
286
|
+
plist.write_bytes(plistlib.dumps(_launchd_plist(layout), sort_keys=True))
|
|
287
|
+
os.chmod(plist, 0o644)
|
|
288
|
+
|
|
289
|
+
boot = _launchctl("bootstrap", _domain(), str(plist), check=False)
|
|
290
|
+
if boot.returncode != 0:
|
|
291
|
+
raise ServiceError(f"launchctl bootstrap failed: {(boot.stderr or boot.stdout).strip()}")
|
|
292
|
+
_wait_loaded(layout, True)
|
|
293
|
+
return service_status(repo)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def stop_service(path: str | Path = ".") -> dict[str, Any]:
|
|
297
|
+
_require_macos()
|
|
298
|
+
repo = find_repo(path)
|
|
299
|
+
layout = runtime_layout(repo)
|
|
300
|
+
before = _loaded(layout)
|
|
301
|
+
proc = _launchctl("bootout", f"{_domain()}/{layout.label}", check=False)
|
|
302
|
+
if before and proc.returncode != 0:
|
|
303
|
+
raise ServiceError(f"launchctl bootout failed: {(proc.stderr or proc.stdout).strip()}")
|
|
304
|
+
if before:
|
|
305
|
+
_wait_loaded(layout, False)
|
|
306
|
+
return service_status(repo)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def restart_service(path: str | Path = ".") -> dict[str, Any]:
|
|
310
|
+
_require_macos()
|
|
311
|
+
repo = find_repo(path)
|
|
312
|
+
layout = runtime_layout(repo)
|
|
313
|
+
plist = service_definition_path(layout.label)
|
|
314
|
+
if not plist.is_file():
|
|
315
|
+
raise ServiceError("Do Again is not installed for this repository; run do-again install first")
|
|
316
|
+
current = service_status(repo)
|
|
317
|
+
if current["running"]:
|
|
318
|
+
proc = _launchctl("kickstart", "-k", f"{_domain()}/{layout.label}", check=False)
|
|
319
|
+
else:
|
|
320
|
+
proc = _launchctl("bootstrap", _domain(), str(plist), check=False)
|
|
321
|
+
if proc.returncode != 0:
|
|
322
|
+
raise ServiceError(f"launchctl restart failed: {(proc.stderr or proc.stdout).strip()}")
|
|
323
|
+
_wait_loaded(layout, True)
|
|
324
|
+
return service_status(repo)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def uninstall_service(path: str | Path = ".") -> dict[str, Any]:
|
|
328
|
+
_require_macos()
|
|
329
|
+
repo = find_repo(path)
|
|
330
|
+
layout = runtime_layout(repo)
|
|
331
|
+
if _loaded(layout):
|
|
332
|
+
proc = _launchctl("bootout", f"{_domain()}/{layout.label}", check=False)
|
|
333
|
+
if proc.returncode != 0:
|
|
334
|
+
raise ServiceError(f"launchctl bootout failed: {(proc.stderr or proc.stdout).strip()}")
|
|
335
|
+
_wait_loaded(layout, False)
|
|
336
|
+
plist = service_definition_path(layout.label)
|
|
337
|
+
try:
|
|
338
|
+
plist.unlink()
|
|
339
|
+
except FileNotFoundError:
|
|
340
|
+
pass
|
|
341
|
+
if layout.control_worktree.exists():
|
|
342
|
+
_run(
|
|
343
|
+
["git", "-C", str(repo), "worktree", "remove", "--force", str(layout.control_worktree)],
|
|
344
|
+
check=False,
|
|
345
|
+
)
|
|
346
|
+
if layout.root.exists():
|
|
347
|
+
shutil.rmtree(layout.root)
|
|
348
|
+
return {
|
|
349
|
+
"repo": str(repo),
|
|
350
|
+
"label": layout.label,
|
|
351
|
+
"installed": False,
|
|
352
|
+
"running": False,
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def run_foreground(path: str | Path = ".", *, once: bool = False) -> int:
|
|
357
|
+
repo = find_repo(path)
|
|
358
|
+
if detect_platform().name == "macos" and service_status(repo)["running"]:
|
|
359
|
+
raise ServiceError("background service is already running; stop it before using foreground mode")
|
|
360
|
+
layout = prepare_runtime(repo)
|
|
361
|
+
agent = Agent(
|
|
362
|
+
repo=repo,
|
|
363
|
+
control_worktree=layout.control_worktree,
|
|
364
|
+
branch=layout.branch,
|
|
365
|
+
policy_path=layout.policy_path,
|
|
366
|
+
state_dir=layout.state_dir,
|
|
367
|
+
)
|
|
368
|
+
return agent.run(once=once)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def layout_as_dict(path: str | Path = ".") -> dict[str, Any]:
|
|
372
|
+
return asdict(runtime_layout(find_repo(path)))
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"control_branch": "operator-control",
|
|
4
|
+
"poll_seconds": 3,
|
|
5
|
+
"max_request_ttl_seconds": 3600,
|
|
6
|
+
"default_timeout_seconds": 120,
|
|
7
|
+
"absolute_timeout_seconds": 3600,
|
|
8
|
+
"max_output_bytes": 262144,
|
|
9
|
+
"allowed_operations": [
|
|
10
|
+
"status",
|
|
11
|
+
"read_file",
|
|
12
|
+
"run_tests",
|
|
13
|
+
"repo_script",
|
|
14
|
+
"control_plane_launcher",
|
|
15
|
+
"deploy_cp1_bundle_at_stop",
|
|
16
|
+
"scratch_script",
|
|
17
|
+
"extended_exec",
|
|
18
|
+
"process_signal",
|
|
19
|
+
"launchctl_action",
|
|
20
|
+
"self_update",
|
|
21
|
+
"artifact_get",
|
|
22
|
+
"artifact_put",
|
|
23
|
+
"capture_screenshot",
|
|
24
|
+
"full_process_list"
|
|
25
|
+
],
|
|
26
|
+
"approved_cwd_roots": [
|
|
27
|
+
"$REPO",
|
|
28
|
+
"$HOME/.do_again_control_plane",
|
|
29
|
+
"$HOME/.do_again_bridge",
|
|
30
|
+
"$HOME/.do_again",
|
|
31
|
+
"/tmp"
|
|
32
|
+
],
|
|
33
|
+
"read_roots": [
|
|
34
|
+
"$REPO",
|
|
35
|
+
"$HOME/.do_again_control_plane",
|
|
36
|
+
"$HOME/.do_again_bridge",
|
|
37
|
+
"$HOME/.do_again",
|
|
38
|
+
"/tmp"
|
|
39
|
+
],
|
|
40
|
+
"repo_script_prefixes": [
|
|
41
|
+
"tools/",
|
|
42
|
+
"control_plane/",
|
|
43
|
+
"scripts/"
|
|
44
|
+
],
|
|
45
|
+
"allowed_launcher_subcommands": [
|
|
46
|
+
"session-intake",
|
|
47
|
+
"session-resolution"
|
|
48
|
+
],
|
|
49
|
+
"extended_exec_binaries": [
|
|
50
|
+
"python3",
|
|
51
|
+
"git",
|
|
52
|
+
"bash",
|
|
53
|
+
"zsh",
|
|
54
|
+
"sh",
|
|
55
|
+
"ps",
|
|
56
|
+
"jq",
|
|
57
|
+
"cat",
|
|
58
|
+
"tail",
|
|
59
|
+
"head",
|
|
60
|
+
"grep",
|
|
61
|
+
"rg",
|
|
62
|
+
"find",
|
|
63
|
+
"sed",
|
|
64
|
+
"ls",
|
|
65
|
+
"stat",
|
|
66
|
+
"shasum",
|
|
67
|
+
"date",
|
|
68
|
+
"sleep",
|
|
69
|
+
"wc",
|
|
70
|
+
"sort",
|
|
71
|
+
"uniq",
|
|
72
|
+
"cut",
|
|
73
|
+
"tr",
|
|
74
|
+
"pwd",
|
|
75
|
+
"uname",
|
|
76
|
+
"df",
|
|
77
|
+
"du",
|
|
78
|
+
"lsof",
|
|
79
|
+
"vm_stat",
|
|
80
|
+
"networkQuality"
|
|
81
|
+
],
|
|
82
|
+
"git_allowed_subcommands": [
|
|
83
|
+
"status",
|
|
84
|
+
"fetch",
|
|
85
|
+
"pull",
|
|
86
|
+
"log",
|
|
87
|
+
"show",
|
|
88
|
+
"diff",
|
|
89
|
+
"rev-parse",
|
|
90
|
+
"branch",
|
|
91
|
+
"switch",
|
|
92
|
+
"checkout",
|
|
93
|
+
"worktree",
|
|
94
|
+
"merge-base",
|
|
95
|
+
"cat-file",
|
|
96
|
+
"ls-tree",
|
|
97
|
+
"remote"
|
|
98
|
+
],
|
|
99
|
+
"launchctl_allowed_subcommands": [
|
|
100
|
+
"print",
|
|
101
|
+
"kickstart",
|
|
102
|
+
"bootstrap",
|
|
103
|
+
"bootout"
|
|
104
|
+
],
|
|
105
|
+
"launchctl_label_prefixes": [
|
|
106
|
+
"io.github.tran-steven."
|
|
107
|
+
],
|
|
108
|
+
"process_signal_allowlist": [
|
|
109
|
+
"TERM",
|
|
110
|
+
"INT",
|
|
111
|
+
"HUP",
|
|
112
|
+
"KILL"
|
|
113
|
+
],
|
|
114
|
+
"python_allowed_modules": [
|
|
115
|
+
"unittest",
|
|
116
|
+
"py_compile"
|
|
117
|
+
],
|
|
118
|
+
"hard_denied_binaries": [
|
|
119
|
+
"sudo",
|
|
120
|
+
"su",
|
|
121
|
+
"doas",
|
|
122
|
+
"rm",
|
|
123
|
+
"dd",
|
|
124
|
+
"diskutil",
|
|
125
|
+
"fdisk",
|
|
126
|
+
"mkfs",
|
|
127
|
+
"mount",
|
|
128
|
+
"umount",
|
|
129
|
+
"docker",
|
|
130
|
+
"podman"
|
|
131
|
+
],
|
|
132
|
+
"max_scratch_script_bytes": 65536,
|
|
133
|
+
"max_inline_artifact_bytes": 450000,
|
|
134
|
+
"max_screenshot_width": 1440,
|
|
135
|
+
"screenshot_jpeg_quality": 55,
|
|
136
|
+
"agent_launchd_label": "io.github.tran-steven.do-again"
|
|
137
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
from do_again.platforms.base import PlatformInfo
|
|
10
|
+
from do_again.service.runtime import (
|
|
11
|
+
ServiceError,
|
|
12
|
+
_default_policy,
|
|
13
|
+
_launchd_plist,
|
|
14
|
+
runtime_layout,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ServiceRuntimeTests(unittest.TestCase):
|
|
19
|
+
def test_runtime_layout_is_stable_and_project_scoped(self) -> None:
|
|
20
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
21
|
+
root = Path(tmp)
|
|
22
|
+
repo = root / "repo"
|
|
23
|
+
repo.mkdir()
|
|
24
|
+
with patch.dict(os.environ, {"DO_AGAIN_HOME": str(root / "home")}):
|
|
25
|
+
first = runtime_layout(repo)
|
|
26
|
+
second = runtime_layout(repo)
|
|
27
|
+
self.assertEqual(first.key, second.key)
|
|
28
|
+
self.assertEqual(first.label, second.label)
|
|
29
|
+
self.assertTrue(first.label.startswith("io.github.tran-steven.do-again."))
|
|
30
|
+
self.assertEqual(first.root.parent, (root / "home" / "projects").resolve())
|
|
31
|
+
|
|
32
|
+
def test_default_policy_is_packaged(self) -> None:
|
|
33
|
+
policy = _default_policy()
|
|
34
|
+
self.assertEqual(policy["schema_version"], 1)
|
|
35
|
+
self.assertIn("scratch_script", policy["allowed_operations"])
|
|
36
|
+
self.assertTrue(policy["agent_launchd_label"].startswith("io.github.tran-steven.do-again"))
|
|
37
|
+
|
|
38
|
+
def test_launchd_plist_uses_copied_runtime(self) -> None:
|
|
39
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
40
|
+
root = Path(tmp)
|
|
41
|
+
repo = root / "repo"
|
|
42
|
+
repo.mkdir()
|
|
43
|
+
with patch.dict(os.environ, {"DO_AGAIN_HOME": str(root / "home")}):
|
|
44
|
+
layout = runtime_layout(repo)
|
|
45
|
+
value = _launchd_plist(layout)
|
|
46
|
+
self.assertEqual(value["Label"], layout.label)
|
|
47
|
+
self.assertEqual(value["WorkingDirectory"], str(repo.resolve()))
|
|
48
|
+
self.assertEqual(value["EnvironmentVariables"]["PYTHONPATH"], str(layout.runtime_source))
|
|
49
|
+
self.assertIn("do_again.core.agent", value["ProgramArguments"])
|
|
50
|
+
self.assertTrue(value["KeepAlive"])
|
|
51
|
+
|
|
52
|
+
@patch("do_again.service.runtime.detect_platform")
|
|
53
|
+
def test_background_lifecycle_rejects_non_macos(self, detect) -> None:
|
|
54
|
+
detect.return_value = PlatformInfo(name="linux", service_manager="systemd", supported=True)
|
|
55
|
+
from do_again.service.runtime import install_service
|
|
56
|
+
|
|
57
|
+
with self.assertRaises(ServiceError):
|
|
58
|
+
install_service(".")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
unittest.main()
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import argparse
|
|
4
|
-
import shutil
|
|
5
|
-
import subprocess
|
|
6
|
-
import sys
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
|
|
9
|
-
from .platforms.detect import detect_platform
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def doctor() -> int:
|
|
13
|
-
info = detect_platform()
|
|
14
|
-
checks = {
|
|
15
|
-
"python": sys.version_info >= (3, 11),
|
|
16
|
-
"git": shutil.which("git") is not None,
|
|
17
|
-
"platform": info.supported,
|
|
18
|
-
}
|
|
19
|
-
for key, ok in checks.items():
|
|
20
|
-
print(f"{'OK' if ok else 'FAIL'} {key}")
|
|
21
|
-
print(f"platform_name={info.name}")
|
|
22
|
-
print(f"service_manager={info.service_manager}")
|
|
23
|
-
return 0 if all(checks.values()) else 1
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def status() -> int:
|
|
27
|
-
info = detect_platform()
|
|
28
|
-
print(f"platform={info.name}")
|
|
29
|
-
print(f"service_manager={info.service_manager}")
|
|
30
|
-
return 0
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def init_project(path: str) -> int:
|
|
34
|
-
target = Path(path).expanduser().resolve()
|
|
35
|
-
target.mkdir(parents=True, exist_ok=True)
|
|
36
|
-
config = target / "do-again.toml"
|
|
37
|
-
if config.exists():
|
|
38
|
-
print(str(config))
|
|
39
|
-
return 0
|
|
40
|
-
config.write_text('[do_again]\ncontrol_branch = "operator-control"\n')
|
|
41
|
-
print(str(config))
|
|
42
|
-
return 0
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def main() -> int:
|
|
46
|
-
parser = argparse.ArgumentParser(prog="do-again")
|
|
47
|
-
sub = parser.add_subparsers(dest="command", required=True)
|
|
48
|
-
sub.add_parser("doctor")
|
|
49
|
-
sub.add_parser("status")
|
|
50
|
-
init_parser = sub.add_parser("init")
|
|
51
|
-
init_parser.add_argument("path", nargs="?", default=".")
|
|
52
|
-
args = parser.parse_args()
|
|
53
|
-
if args.command == "doctor":
|
|
54
|
-
return doctor()
|
|
55
|
-
if args.command == "status":
|
|
56
|
-
return status()
|
|
57
|
-
if args.command == "init":
|
|
58
|
-
return init_project(args.path)
|
|
59
|
-
return 2
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if __name__ == "__main__":
|
|
63
|
-
raise SystemExit(main())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|