github-sync-agent 0.1.3__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.
- {github_sync_agent-0.1.3 → github_sync_agent-0.2.0}/PKG-INFO +69 -2
- {github_sync_agent-0.1.3 → github_sync_agent-0.2.0}/README.md +64 -0
- github_sync_agent-0.2.0/github_sync/__init__.py +1 -0
- github_sync_agent-0.2.0/github_sync/api.py +111 -0
- {github_sync_agent-0.1.3 → github_sync_agent-0.2.0}/github_sync/cli.py +54 -11
- {github_sync_agent-0.1.3 → github_sync_agent-0.2.0}/github_sync/config.py +3 -0
- github_sync_agent-0.2.0/github_sync/setup.py +297 -0
- {github_sync_agent-0.1.3 → github_sync_agent-0.2.0}/github_sync_agent.egg-info/PKG-INFO +69 -2
- {github_sync_agent-0.1.3 → github_sync_agent-0.2.0}/github_sync_agent.egg-info/SOURCES.txt +1 -0
- github_sync_agent-0.2.0/github_sync_agent.egg-info/requires.txt +6 -0
- {github_sync_agent-0.1.3 → github_sync_agent-0.2.0}/pyproject.toml +8 -2
- github_sync_agent-0.1.3/github_sync/__init__.py +0 -1
- github_sync_agent-0.1.3/github_sync/setup.py +0 -158
- github_sync_agent-0.1.3/github_sync_agent.egg-info/requires.txt +0 -2
- {github_sync_agent-0.1.3 → github_sync_agent-0.2.0}/github_sync/agent.py +0 -0
- {github_sync_agent-0.1.3 → github_sync_agent-0.2.0}/github_sync_agent.egg-info/dependency_links.txt +0 -0
- {github_sync_agent-0.1.3 → github_sync_agent-0.2.0}/github_sync_agent.egg-info/entry_points.txt +0 -0
- {github_sync_agent-0.1.3 → github_sync_agent-0.2.0}/github_sync_agent.egg-info/top_level.txt +0 -0
- {github_sync_agent-0.1.3 → github_sync_agent-0.2.0}/setup.cfg +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: github-sync-agent
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Sync all your local projects to GitHub automatically
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/yuvalavni/Agents
|
|
7
7
|
Project-URL: Repository, https://github.com/yuvalavni/Agents
|
|
8
8
|
Project-URL: Bug Tracker, https://github.com/yuvalavni/Agents/issues
|
|
9
|
-
Keywords: github,git,sync,backup,automation,cli
|
|
9
|
+
Keywords: github,git,sync,backup,automation,cli,api
|
|
10
10
|
Classifier: Programming Language :: Python :: 3
|
|
11
11
|
Classifier: License :: OSI Approved :: MIT License
|
|
12
12
|
Classifier: Operating System :: POSIX :: Linux
|
|
@@ -16,6 +16,9 @@ Requires-Python: >=3.10
|
|
|
16
16
|
Description-Content-Type: text/markdown
|
|
17
17
|
Requires-Dist: pyyaml>=6.0
|
|
18
18
|
Requires-Dist: click>=8.0
|
|
19
|
+
Provides-Extra: api
|
|
20
|
+
Requires-Dist: fastapi>=0.100; extra == "api"
|
|
21
|
+
Requires-Dist: uvicorn>=0.23; extra == "api"
|
|
19
22
|
|
|
20
23
|
# GitHub Sync Agent
|
|
21
24
|
|
|
@@ -49,8 +52,11 @@ The wizard will:
|
|
|
49
52
|
- Authenticate with GitHub (`gh auth login`)
|
|
50
53
|
- Ask which folder to scan for projects
|
|
51
54
|
- Set up your SSH key automatically
|
|
55
|
+
- **Verify SSH works** (auto-fixes port 443 and ssh-agent if needed)
|
|
52
56
|
- Write a config file to `~/.config/github-sync/config.yaml`
|
|
53
57
|
|
|
58
|
+
Setup will **not** complete until `ssh -T git@github.com` succeeds.
|
|
59
|
+
|
|
54
60
|
---
|
|
55
61
|
|
|
56
62
|
## Usage
|
|
@@ -71,6 +77,64 @@ github-sync status
|
|
|
71
77
|
|
|
72
78
|
---
|
|
73
79
|
|
|
80
|
+
## HTTP API
|
|
81
|
+
|
|
82
|
+
Install API dependencies:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pip install 'github-sync-agent[api]'
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Start the server:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
github-sync serve
|
|
92
|
+
# → http://127.0.0.1:8741
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Endpoints
|
|
96
|
+
|
|
97
|
+
| Method | Path | Description |
|
|
98
|
+
|--------|------|-------------|
|
|
99
|
+
| `GET` | `/health` | Health check (no auth) |
|
|
100
|
+
| `GET` | `/status` | Sync status for all projects |
|
|
101
|
+
| `POST` | `/sync` | Sync all projects |
|
|
102
|
+
| `POST` | `/sync/{project}` | Sync one project |
|
|
103
|
+
|
|
104
|
+
Optional body for POST: `{"dry_run": false}`
|
|
105
|
+
|
|
106
|
+
### Examples
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
curl http://127.0.0.1:8741/health
|
|
110
|
+
curl http://127.0.0.1:8741/status
|
|
111
|
+
curl -X POST http://127.0.0.1:8741/sync
|
|
112
|
+
curl -X POST http://127.0.0.1:8741/sync/Agents
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### API authentication (optional)
|
|
116
|
+
|
|
117
|
+
Add to `config.yaml`:
|
|
118
|
+
|
|
119
|
+
```yaml
|
|
120
|
+
api_key: "your-secret-key"
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Then pass the key on every request (except `/health`):
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
curl -H "Authorization: Bearer your-secret-key" http://127.0.0.1:8741/status
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Run as a systemd service
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
cp systemd/github-sync-api.service ~/.config/systemd/user/
|
|
133
|
+
systemctl --user enable --now github-sync-api.service
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
74
138
|
## Configuration (`~/.config/github-sync/config.yaml`)
|
|
75
139
|
|
|
76
140
|
Generated by `github-sync init`. You can edit it at any time.
|
|
@@ -85,6 +149,9 @@ Generated by `github-sync init`. You can edit it at any time.
|
|
|
85
149
|
| `exclude` | `[]` | Directory names to skip |
|
|
86
150
|
| `log_file` | `~/.local/share/github-sync/github-sync.log` | Log file path |
|
|
87
151
|
| `log_level` | `INFO` | `DEBUG` / `INFO` / `WARNING` / `ERROR` |
|
|
152
|
+
| `api_host` | `127.0.0.1` | API bind address |
|
|
153
|
+
| `api_port` | `8741` | API port |
|
|
154
|
+
| `api_key` | _(empty)_ | Optional Bearer token for API auth |
|
|
88
155
|
|
|
89
156
|
### Excluding a project
|
|
90
157
|
|
|
@@ -30,8 +30,11 @@ The wizard will:
|
|
|
30
30
|
- Authenticate with GitHub (`gh auth login`)
|
|
31
31
|
- Ask which folder to scan for projects
|
|
32
32
|
- Set up your SSH key automatically
|
|
33
|
+
- **Verify SSH works** (auto-fixes port 443 and ssh-agent if needed)
|
|
33
34
|
- Write a config file to `~/.config/github-sync/config.yaml`
|
|
34
35
|
|
|
36
|
+
Setup will **not** complete until `ssh -T git@github.com` succeeds.
|
|
37
|
+
|
|
35
38
|
---
|
|
36
39
|
|
|
37
40
|
## Usage
|
|
@@ -52,6 +55,64 @@ github-sync status
|
|
|
52
55
|
|
|
53
56
|
---
|
|
54
57
|
|
|
58
|
+
## HTTP API
|
|
59
|
+
|
|
60
|
+
Install API dependencies:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install 'github-sync-agent[api]'
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Start the server:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
github-sync serve
|
|
70
|
+
# → http://127.0.0.1:8741
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Endpoints
|
|
74
|
+
|
|
75
|
+
| Method | Path | Description |
|
|
76
|
+
|--------|------|-------------|
|
|
77
|
+
| `GET` | `/health` | Health check (no auth) |
|
|
78
|
+
| `GET` | `/status` | Sync status for all projects |
|
|
79
|
+
| `POST` | `/sync` | Sync all projects |
|
|
80
|
+
| `POST` | `/sync/{project}` | Sync one project |
|
|
81
|
+
|
|
82
|
+
Optional body for POST: `{"dry_run": false}`
|
|
83
|
+
|
|
84
|
+
### Examples
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
curl http://127.0.0.1:8741/health
|
|
88
|
+
curl http://127.0.0.1:8741/status
|
|
89
|
+
curl -X POST http://127.0.0.1:8741/sync
|
|
90
|
+
curl -X POST http://127.0.0.1:8741/sync/Agents
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### API authentication (optional)
|
|
94
|
+
|
|
95
|
+
Add to `config.yaml`:
|
|
96
|
+
|
|
97
|
+
```yaml
|
|
98
|
+
api_key: "your-secret-key"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Then pass the key on every request (except `/health`):
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
curl -H "Authorization: Bearer your-secret-key" http://127.0.0.1:8741/status
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Run as a systemd service
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
cp systemd/github-sync-api.service ~/.config/systemd/user/
|
|
111
|
+
systemctl --user enable --now github-sync-api.service
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
55
116
|
## Configuration (`~/.config/github-sync/config.yaml`)
|
|
56
117
|
|
|
57
118
|
Generated by `github-sync init`. You can edit it at any time.
|
|
@@ -66,6 +127,9 @@ Generated by `github-sync init`. You can edit it at any time.
|
|
|
66
127
|
| `exclude` | `[]` | Directory names to skip |
|
|
67
128
|
| `log_file` | `~/.local/share/github-sync/github-sync.log` | Log file path |
|
|
68
129
|
| `log_level` | `INFO` | `DEBUG` / `INFO` / `WARNING` / `ERROR` |
|
|
130
|
+
| `api_host` | `127.0.0.1` | API bind address |
|
|
131
|
+
| `api_port` | `8741` | API port |
|
|
132
|
+
| `api_key` | _(empty)_ | Optional Bearer token for API auth |
|
|
69
133
|
|
|
70
134
|
### Excluding a project
|
|
71
135
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""HTTP API for the GitHub Sync Agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from fastapi import Depends, FastAPI, HTTPException, Security
|
|
9
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from . import agent
|
|
14
|
+
|
|
15
|
+
LOG = logging.getLogger(__name__)
|
|
16
|
+
_security = HTTPBearer(auto_error=False)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SyncRequest(BaseModel):
|
|
20
|
+
dry_run: bool = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SyncResult(BaseModel):
|
|
24
|
+
project: str
|
|
25
|
+
success: bool
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SyncResponse(BaseModel):
|
|
29
|
+
success: bool
|
|
30
|
+
synced: int
|
|
31
|
+
failed: int
|
|
32
|
+
results: list[SyncResult]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class StatusRow(BaseModel):
|
|
36
|
+
project: str
|
|
37
|
+
root: str
|
|
38
|
+
git: bool
|
|
39
|
+
remote: bool
|
|
40
|
+
changes: bool
|
|
41
|
+
on_github: bool
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class StatusResponse(BaseModel):
|
|
45
|
+
github_user: str
|
|
46
|
+
projects_root: list[str]
|
|
47
|
+
projects: list[StatusRow]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def create_app(cfg: dict) -> FastAPI:
|
|
51
|
+
api_key: str = cfg.get("api_key") or ""
|
|
52
|
+
|
|
53
|
+
app = FastAPI(
|
|
54
|
+
title="GitHub Sync Agent API",
|
|
55
|
+
description="Trigger and inspect GitHub sync operations over HTTP.",
|
|
56
|
+
version=__version__,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
async def require_auth(
|
|
60
|
+
credentials: HTTPAuthorizationCredentials | None = Security(_security),
|
|
61
|
+
) -> None:
|
|
62
|
+
if not api_key:
|
|
63
|
+
return
|
|
64
|
+
if credentials is None or credentials.credentials != api_key:
|
|
65
|
+
raise HTTPException(status_code=401, detail="Invalid or missing API key")
|
|
66
|
+
|
|
67
|
+
@app.get("/health")
|
|
68
|
+
async def health() -> dict[str, str]:
|
|
69
|
+
return {"status": "ok", "version": __version__}
|
|
70
|
+
|
|
71
|
+
@app.get("/status", response_model=StatusResponse, dependencies=[Depends(require_auth)])
|
|
72
|
+
async def status() -> StatusResponse:
|
|
73
|
+
rows = await asyncio.to_thread(agent.get_status, cfg)
|
|
74
|
+
return StatusResponse(
|
|
75
|
+
github_user=cfg["github_user"],
|
|
76
|
+
projects_root=cfg["projects_root"],
|
|
77
|
+
projects=[StatusRow(**row) for row in rows],
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@app.post("/sync", response_model=SyncResponse, dependencies=[Depends(require_auth)])
|
|
81
|
+
async def sync_all(body: SyncRequest = SyncRequest()) -> SyncResponse:
|
|
82
|
+
return await _run_sync(cfg, project=None, dry_run=body.dry_run)
|
|
83
|
+
|
|
84
|
+
@app.post("/sync/{project}", response_model=SyncResponse, dependencies=[Depends(require_auth)])
|
|
85
|
+
async def sync_one(project: str, body: SyncRequest = SyncRequest()) -> SyncResponse:
|
|
86
|
+
return await _run_sync(cfg, project=project, dry_run=body.dry_run)
|
|
87
|
+
|
|
88
|
+
return app
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def _run_sync(cfg: dict, project: str | None, dry_run: bool) -> SyncResponse:
|
|
92
|
+
try:
|
|
93
|
+
projects = await asyncio.to_thread(agent.discover_projects, cfg, project)
|
|
94
|
+
except ValueError as exc:
|
|
95
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
96
|
+
|
|
97
|
+
results: list[SyncResult] = []
|
|
98
|
+
for path in projects:
|
|
99
|
+
ok = await asyncio.to_thread(agent.sync_project, path, cfg, dry_run)
|
|
100
|
+
results.append(SyncResult(project=path.name, success=ok))
|
|
101
|
+
level = logging.INFO if ok else logging.ERROR
|
|
102
|
+
LOG.log(level, "%s %s", path.name, "synced" if ok else "FAILED")
|
|
103
|
+
|
|
104
|
+
synced = sum(1 for r in results if r.success)
|
|
105
|
+
failed = len(results) - synced
|
|
106
|
+
return SyncResponse(
|
|
107
|
+
success=failed == 0,
|
|
108
|
+
synced=synced,
|
|
109
|
+
failed=failed,
|
|
110
|
+
results=results,
|
|
111
|
+
)
|
|
@@ -207,8 +207,8 @@ def init() -> None:
|
|
|
207
207
|
|
|
208
208
|
click.echo()
|
|
209
209
|
|
|
210
|
-
# ── 6. SSH
|
|
211
|
-
click.echo(click.style(" Setting up SSH
|
|
210
|
+
# ── 6. SSH setup + verification ─────────────────────────────────────────
|
|
211
|
+
click.echo(click.style(" Setting up SSH for GitHub...", bold=True))
|
|
212
212
|
|
|
213
213
|
if not setup.ssh_key_exists():
|
|
214
214
|
click.echo(" No SSH key found — generating one...")
|
|
@@ -220,17 +220,25 @@ def init() -> None:
|
|
|
220
220
|
else:
|
|
221
221
|
click.echo(" ✓ SSH key already exists")
|
|
222
222
|
|
|
223
|
-
setup.
|
|
223
|
+
setup.add_github_host_keys()
|
|
224
224
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
225
|
+
click.echo(" Verifying SSH connection to GitHub...")
|
|
226
|
+
ok, msg, fixes = setup.ensure_github_ssh()
|
|
227
|
+
|
|
228
|
+
for fix in fixes:
|
|
229
|
+
click.echo(f" → applied fix: {fix}")
|
|
230
|
+
|
|
231
|
+
if ok:
|
|
232
|
+
user = setup.ssh_auth_username(msg) or github_user
|
|
233
|
+
click.echo(f" ✓ SSH verified as {user}")
|
|
232
234
|
else:
|
|
233
|
-
click.echo("
|
|
235
|
+
click.echo(click.style(" ✗ SSH verification failed.", fg="red"))
|
|
236
|
+
click.echo(f" {msg}")
|
|
237
|
+
click.echo()
|
|
238
|
+
click.echo(" Manual checks:")
|
|
239
|
+
click.echo(" ssh -T git@github.com")
|
|
240
|
+
click.echo(" gh ssh-key add ~/.ssh/id_ed25519.pub")
|
|
241
|
+
sys.exit(1)
|
|
234
242
|
|
|
235
243
|
click.echo()
|
|
236
244
|
|
|
@@ -245,8 +253,43 @@ def init() -> None:
|
|
|
245
253
|
click.echo(f" github-sync status # see all your projects")
|
|
246
254
|
click.echo(f" github-sync sync --dry-run # preview what will be synced")
|
|
247
255
|
click.echo(f" github-sync sync # sync everything to GitHub")
|
|
256
|
+
click.echo(f" github-sync serve # start HTTP API (requires [api] extra)")
|
|
257
|
+
click.echo()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
# serve
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
@cli.command()
|
|
265
|
+
@click.option("--host", default=None, help="Bind address (default: api_host from config)")
|
|
266
|
+
@click.option("--port", default=None, type=int, help="Port (default: api_port from config)")
|
|
267
|
+
@click.pass_context
|
|
268
|
+
def serve(ctx: click.Context, host: str | None, port: int | None) -> None:
|
|
269
|
+
"""Start the HTTP API server."""
|
|
270
|
+
try:
|
|
271
|
+
import uvicorn
|
|
272
|
+
except ImportError:
|
|
273
|
+
click.echo("Error: API dependencies not installed.", err=True)
|
|
274
|
+
click.echo("Run: pip install 'github-sync-agent[api]'", err=True)
|
|
275
|
+
sys.exit(1)
|
|
276
|
+
|
|
277
|
+
cfg = ctx.obj["cfg"]
|
|
278
|
+
_setup_logging(cfg)
|
|
279
|
+
|
|
280
|
+
from .api import create_app
|
|
281
|
+
|
|
282
|
+
bind_host = host or cfg.get("api_host", "127.0.0.1")
|
|
283
|
+
bind_port = port or int(cfg.get("api_port", 8741))
|
|
284
|
+
app = create_app(cfg)
|
|
285
|
+
|
|
286
|
+
auth_note = "API key required" if cfg.get("api_key") else "no auth (localhost only recommended)"
|
|
287
|
+
click.echo(f"Starting GitHub Sync API on http://{bind_host}:{bind_port} ({auth_note})")
|
|
288
|
+
click.echo("Endpoints: GET /health GET /status POST /sync POST /sync/{project}")
|
|
248
289
|
click.echo()
|
|
249
290
|
|
|
291
|
+
uvicorn.run(app, host=bind_host, port=bind_port, log_level=cfg.get("log_level", "info").lower())
|
|
292
|
+
|
|
250
293
|
|
|
251
294
|
# ---------------------------------------------------------------------------
|
|
252
295
|
# Entry point
|
|
@@ -26,6 +26,9 @@ def _apply_defaults(cfg: dict) -> None:
|
|
|
26
26
|
cfg.setdefault("exclude", [])
|
|
27
27
|
cfg.setdefault("log_file", str(Path.home() / ".local" / "share" / "github-sync" / "github-sync.log"))
|
|
28
28
|
cfg.setdefault("log_level", "INFO")
|
|
29
|
+
cfg.setdefault("api_host", "127.0.0.1")
|
|
30
|
+
cfg.setdefault("api_port", 8741)
|
|
31
|
+
cfg.setdefault("api_key", "")
|
|
29
32
|
|
|
30
33
|
if not cfg.get("github_user"):
|
|
31
34
|
raise ValueError("Missing required config key: 'github_user'")
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Setup helpers used by `github-sync init`.
|
|
3
|
+
Checks prerequisites, detects credentials, sets up SSH.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import platform
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Dependency checks
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
def check_git() -> tuple[bool, str]:
|
|
18
|
+
"""Returns (installed, version_string)."""
|
|
19
|
+
if not shutil.which("git"):
|
|
20
|
+
return False, ""
|
|
21
|
+
result = subprocess.run(["git", "--version"], capture_output=True, text=True)
|
|
22
|
+
return True, result.stdout.strip()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def check_gh() -> tuple[bool, str]:
|
|
26
|
+
"""Returns (installed, version_string)."""
|
|
27
|
+
if not shutil.which("gh"):
|
|
28
|
+
return False, ""
|
|
29
|
+
result = subprocess.run(["gh", "--version"], capture_output=True, text=True)
|
|
30
|
+
return True, result.stdout.splitlines()[0].strip()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def gh_authenticated() -> bool:
|
|
34
|
+
result = subprocess.run(["gh", "auth", "status"], capture_output=True, text=True)
|
|
35
|
+
return result.returncode == 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_github_username() -> str | None:
|
|
39
|
+
"""Detect the currently authenticated GitHub username."""
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
["gh", "api", "user", "--jq", ".login"],
|
|
42
|
+
capture_output=True, text=True,
|
|
43
|
+
)
|
|
44
|
+
if result.returncode == 0:
|
|
45
|
+
return result.stdout.strip()
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# SSH helpers
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
DEFAULT_KEY = Path.home() / ".ssh" / "id_ed25519"
|
|
54
|
+
SSH_CONFIG = Path.home() / ".ssh" / "config"
|
|
55
|
+
|
|
56
|
+
GITHUB_PORT443_BLOCK = """
|
|
57
|
+
# Added by github-sync — use port 443 when port 22 is blocked
|
|
58
|
+
Host github.com
|
|
59
|
+
HostName ssh.github.com
|
|
60
|
+
Port 443
|
|
61
|
+
User git
|
|
62
|
+
IdentityFile ~/.ssh/id_ed25519
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def ssh_key_exists() -> bool:
|
|
67
|
+
return DEFAULT_KEY.exists()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def generate_ssh_key(passphrase: str = "") -> bool:
|
|
71
|
+
Path.home().joinpath(".ssh").mkdir(mode=0o700, exist_ok=True)
|
|
72
|
+
result = subprocess.run(
|
|
73
|
+
["ssh-keygen", "-t", "ed25519", "-C", "github-sync", "-f", str(DEFAULT_KEY), "-N", passphrase],
|
|
74
|
+
capture_output=True, text=True,
|
|
75
|
+
)
|
|
76
|
+
return result.returncode == 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _append_known_hosts(hostname: str, port: int | None = None) -> bool:
|
|
80
|
+
cmd = ["ssh-keyscan", "-t", "ed25519"]
|
|
81
|
+
if port:
|
|
82
|
+
cmd.extend(["-p", str(port)])
|
|
83
|
+
cmd.append(hostname)
|
|
84
|
+
|
|
85
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
86
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
known_hosts = Path.home() / ".ssh" / "known_hosts"
|
|
90
|
+
known_hosts.parent.mkdir(mode=0o700, exist_ok=True)
|
|
91
|
+
existing = known_hosts.read_text() if known_hosts.exists() else ""
|
|
92
|
+
if hostname not in existing:
|
|
93
|
+
with open(known_hosts, "a") as f:
|
|
94
|
+
f.write(result.stdout)
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def add_github_host_keys() -> None:
|
|
99
|
+
"""Trust GitHub host keys for both port 22 and port 443."""
|
|
100
|
+
_append_known_hosts("github.com")
|
|
101
|
+
_append_known_hosts("ssh.github.com", port=443)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_github_ssh() -> tuple[bool, str]:
|
|
105
|
+
"""Test SSH auth to GitHub. Returns (success, combined output)."""
|
|
106
|
+
result = subprocess.run(
|
|
107
|
+
[
|
|
108
|
+
"ssh", "-T",
|
|
109
|
+
"-o", "BatchMode=yes",
|
|
110
|
+
"-o", "ConnectTimeout=15",
|
|
111
|
+
"-o", "StrictHostKeyChecking=accept-new",
|
|
112
|
+
"git@github.com",
|
|
113
|
+
],
|
|
114
|
+
capture_output=True, text=True,
|
|
115
|
+
)
|
|
116
|
+
output = (result.stdout + result.stderr).strip()
|
|
117
|
+
ok = "successfully authenticated" in output.lower()
|
|
118
|
+
return ok, output
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def ssh_auth_username(output: str) -> str | None:
|
|
122
|
+
match = re.search(r"Hi ([^!]+)!", output)
|
|
123
|
+
return match.group(1) if match else None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def is_ssh_connection_error(message: str) -> bool:
|
|
127
|
+
lower = message.lower()
|
|
128
|
+
return any(
|
|
129
|
+
phrase in lower
|
|
130
|
+
for phrase in ("connection closed", "connection reset", "timed out", "network is unreachable")
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def is_ssh_auth_error(message: str) -> bool:
|
|
135
|
+
lower = message.lower()
|
|
136
|
+
return "permission denied" in lower or "publickey" in lower
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def has_github_port443_config() -> bool:
|
|
140
|
+
if not SSH_CONFIG.exists():
|
|
141
|
+
return False
|
|
142
|
+
content = SSH_CONFIG.read_text()
|
|
143
|
+
return "ssh.github.com" in content and "443" in content
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def configure_github_ssh_port_443() -> bool:
|
|
147
|
+
"""Configure ~/.ssh/config to reach GitHub over port 443."""
|
|
148
|
+
if has_github_port443_config():
|
|
149
|
+
return True
|
|
150
|
+
|
|
151
|
+
SSH_CONFIG.parent.mkdir(mode=0o700, exist_ok=True)
|
|
152
|
+
with open(SSH_CONFIG, "a") as f:
|
|
153
|
+
f.write(GITHUB_PORT443_BLOCK)
|
|
154
|
+
SSH_CONFIG.chmod(0o600)
|
|
155
|
+
_append_known_hosts("ssh.github.com", port=443)
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def add_ssh_key_to_agent() -> bool:
|
|
160
|
+
"""Load the default key into ssh-agent (required on some macOS setups)."""
|
|
161
|
+
if not DEFAULT_KEY.exists():
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
commands = []
|
|
165
|
+
if platform.system() == "Darwin":
|
|
166
|
+
commands.append(["ssh-add", "--apple-use-keychain", str(DEFAULT_KEY)])
|
|
167
|
+
commands.append(["ssh-add", str(DEFAULT_KEY)])
|
|
168
|
+
|
|
169
|
+
for cmd in commands:
|
|
170
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
171
|
+
if result.returncode == 0:
|
|
172
|
+
return True
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def upload_ssh_key_to_github(title: str = "github-sync") -> bool:
|
|
177
|
+
pub_key = Path(str(DEFAULT_KEY) + ".pub")
|
|
178
|
+
if not pub_key.exists():
|
|
179
|
+
return False
|
|
180
|
+
result = subprocess.run(
|
|
181
|
+
["gh", "ssh-key", "add", str(pub_key), "--title", title],
|
|
182
|
+
capture_output=True, text=True,
|
|
183
|
+
)
|
|
184
|
+
return result.returncode == 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def ssh_key_on_github() -> bool:
|
|
188
|
+
"""Back-compat wrapper."""
|
|
189
|
+
ok, _ = test_github_ssh()
|
|
190
|
+
return ok
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def ensure_github_ssh() -> tuple[bool, str, list[str]]:
|
|
194
|
+
"""
|
|
195
|
+
Verify GitHub SSH works; apply fixes as needed.
|
|
196
|
+
Returns (success, last_message, fixes_applied).
|
|
197
|
+
"""
|
|
198
|
+
fixes: list[str] = []
|
|
199
|
+
|
|
200
|
+
ok, msg = test_github_ssh()
|
|
201
|
+
if ok:
|
|
202
|
+
return True, msg, fixes
|
|
203
|
+
|
|
204
|
+
if is_ssh_connection_error(msg):
|
|
205
|
+
configure_github_ssh_port_443()
|
|
206
|
+
fixes.append("configured port 443")
|
|
207
|
+
ok, msg = test_github_ssh()
|
|
208
|
+
if ok:
|
|
209
|
+
return True, msg, fixes
|
|
210
|
+
|
|
211
|
+
if is_ssh_auth_error(msg):
|
|
212
|
+
if upload_ssh_key_to_github():
|
|
213
|
+
fixes.append("uploaded SSH key")
|
|
214
|
+
ok, msg = test_github_ssh()
|
|
215
|
+
if ok:
|
|
216
|
+
return True, msg, fixes
|
|
217
|
+
|
|
218
|
+
if add_ssh_key_to_agent():
|
|
219
|
+
fixes.append("loaded key into ssh-agent")
|
|
220
|
+
ok, msg = test_github_ssh()
|
|
221
|
+
if ok:
|
|
222
|
+
return True, msg, fixes
|
|
223
|
+
|
|
224
|
+
# Last resort: port 443 + agent together
|
|
225
|
+
if is_ssh_connection_error(msg) and not has_github_port443_config():
|
|
226
|
+
configure_github_ssh_port_443()
|
|
227
|
+
add_ssh_key_to_agent()
|
|
228
|
+
fixes.append("port 443 + ssh-agent")
|
|
229
|
+
ok, msg = test_github_ssh()
|
|
230
|
+
if ok:
|
|
231
|
+
return True, msg, fixes
|
|
232
|
+
|
|
233
|
+
return False, msg, fixes
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
# Config writer
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
CONFIG_DIR = Path.home() / ".config" / "github-sync"
|
|
241
|
+
CONFIG_PATH = CONFIG_DIR / "config.yaml"
|
|
242
|
+
|
|
243
|
+
CONFIG_TEMPLATE = """\
|
|
244
|
+
# GitHub Sync Agent — configuration
|
|
245
|
+
# Generated by `github-sync init`
|
|
246
|
+
|
|
247
|
+
github_user: {github_user}
|
|
248
|
+
|
|
249
|
+
# Directories to scan for projects.
|
|
250
|
+
# Every subdirectory inside each root is treated as a project to sync.
|
|
251
|
+
# You can specify one path (string) or multiple paths (list):
|
|
252
|
+
#
|
|
253
|
+
# Single path:
|
|
254
|
+
# projects_root: ~/projects
|
|
255
|
+
#
|
|
256
|
+
# Multiple paths:
|
|
257
|
+
# projects_root:
|
|
258
|
+
# - ~/projects
|
|
259
|
+
# - ~/work
|
|
260
|
+
# - /opt/company-code
|
|
261
|
+
projects_root:
|
|
262
|
+
- {projects_root}
|
|
263
|
+
|
|
264
|
+
# Daily schedule time (used by the systemd timer / cron)
|
|
265
|
+
schedule_time: "02:00"
|
|
266
|
+
|
|
267
|
+
# Default visibility for newly created GitHub repos: private | public
|
|
268
|
+
default_visibility: {visibility}
|
|
269
|
+
|
|
270
|
+
# Commit message used for auto-commits
|
|
271
|
+
auto_commit_message: "chore: auto-sync"
|
|
272
|
+
|
|
273
|
+
# Project directories to skip (names only, not full paths)
|
|
274
|
+
exclude: []
|
|
275
|
+
|
|
276
|
+
# Logging
|
|
277
|
+
log_file: {log_file}
|
|
278
|
+
log_level: INFO
|
|
279
|
+
|
|
280
|
+
# HTTP API (github-sync serve)
|
|
281
|
+
api_host: "127.0.0.1"
|
|
282
|
+
api_port: 8741
|
|
283
|
+
# api_key: "change-me" # uncomment to require: Authorization: Bearer <key>
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def write_config(github_user: str, projects_root: str, visibility: str) -> Path:
|
|
288
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
289
|
+
log_file = str(Path.home() / ".local" / "share" / "github-sync" / "github-sync.log")
|
|
290
|
+
content = CONFIG_TEMPLATE.format(
|
|
291
|
+
github_user=github_user,
|
|
292
|
+
projects_root=projects_root,
|
|
293
|
+
visibility=visibility,
|
|
294
|
+
log_file=log_file,
|
|
295
|
+
)
|
|
296
|
+
CONFIG_PATH.write_text(content)
|
|
297
|
+
return CONFIG_PATH
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: github-sync-agent
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Sync all your local projects to GitHub automatically
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/yuvalavni/Agents
|
|
7
7
|
Project-URL: Repository, https://github.com/yuvalavni/Agents
|
|
8
8
|
Project-URL: Bug Tracker, https://github.com/yuvalavni/Agents/issues
|
|
9
|
-
Keywords: github,git,sync,backup,automation,cli
|
|
9
|
+
Keywords: github,git,sync,backup,automation,cli,api
|
|
10
10
|
Classifier: Programming Language :: Python :: 3
|
|
11
11
|
Classifier: License :: OSI Approved :: MIT License
|
|
12
12
|
Classifier: Operating System :: POSIX :: Linux
|
|
@@ -16,6 +16,9 @@ Requires-Python: >=3.10
|
|
|
16
16
|
Description-Content-Type: text/markdown
|
|
17
17
|
Requires-Dist: pyyaml>=6.0
|
|
18
18
|
Requires-Dist: click>=8.0
|
|
19
|
+
Provides-Extra: api
|
|
20
|
+
Requires-Dist: fastapi>=0.100; extra == "api"
|
|
21
|
+
Requires-Dist: uvicorn>=0.23; extra == "api"
|
|
19
22
|
|
|
20
23
|
# GitHub Sync Agent
|
|
21
24
|
|
|
@@ -49,8 +52,11 @@ The wizard will:
|
|
|
49
52
|
- Authenticate with GitHub (`gh auth login`)
|
|
50
53
|
- Ask which folder to scan for projects
|
|
51
54
|
- Set up your SSH key automatically
|
|
55
|
+
- **Verify SSH works** (auto-fixes port 443 and ssh-agent if needed)
|
|
52
56
|
- Write a config file to `~/.config/github-sync/config.yaml`
|
|
53
57
|
|
|
58
|
+
Setup will **not** complete until `ssh -T git@github.com` succeeds.
|
|
59
|
+
|
|
54
60
|
---
|
|
55
61
|
|
|
56
62
|
## Usage
|
|
@@ -71,6 +77,64 @@ github-sync status
|
|
|
71
77
|
|
|
72
78
|
---
|
|
73
79
|
|
|
80
|
+
## HTTP API
|
|
81
|
+
|
|
82
|
+
Install API dependencies:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pip install 'github-sync-agent[api]'
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Start the server:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
github-sync serve
|
|
92
|
+
# → http://127.0.0.1:8741
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Endpoints
|
|
96
|
+
|
|
97
|
+
| Method | Path | Description |
|
|
98
|
+
|--------|------|-------------|
|
|
99
|
+
| `GET` | `/health` | Health check (no auth) |
|
|
100
|
+
| `GET` | `/status` | Sync status for all projects |
|
|
101
|
+
| `POST` | `/sync` | Sync all projects |
|
|
102
|
+
| `POST` | `/sync/{project}` | Sync one project |
|
|
103
|
+
|
|
104
|
+
Optional body for POST: `{"dry_run": false}`
|
|
105
|
+
|
|
106
|
+
### Examples
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
curl http://127.0.0.1:8741/health
|
|
110
|
+
curl http://127.0.0.1:8741/status
|
|
111
|
+
curl -X POST http://127.0.0.1:8741/sync
|
|
112
|
+
curl -X POST http://127.0.0.1:8741/sync/Agents
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### API authentication (optional)
|
|
116
|
+
|
|
117
|
+
Add to `config.yaml`:
|
|
118
|
+
|
|
119
|
+
```yaml
|
|
120
|
+
api_key: "your-secret-key"
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Then pass the key on every request (except `/health`):
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
curl -H "Authorization: Bearer your-secret-key" http://127.0.0.1:8741/status
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Run as a systemd service
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
cp systemd/github-sync-api.service ~/.config/systemd/user/
|
|
133
|
+
systemctl --user enable --now github-sync-api.service
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
74
138
|
## Configuration (`~/.config/github-sync/config.yaml`)
|
|
75
139
|
|
|
76
140
|
Generated by `github-sync init`. You can edit it at any time.
|
|
@@ -85,6 +149,9 @@ Generated by `github-sync init`. You can edit it at any time.
|
|
|
85
149
|
| `exclude` | `[]` | Directory names to skip |
|
|
86
150
|
| `log_file` | `~/.local/share/github-sync/github-sync.log` | Log file path |
|
|
87
151
|
| `log_level` | `INFO` | `DEBUG` / `INFO` / `WARNING` / `ERROR` |
|
|
152
|
+
| `api_host` | `127.0.0.1` | API bind address |
|
|
153
|
+
| `api_port` | `8741` | API port |
|
|
154
|
+
| `api_key` | _(empty)_ | Optional Bearer token for API auth |
|
|
88
155
|
|
|
89
156
|
### Excluding a project
|
|
90
157
|
|
|
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "github-sync-agent"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Sync all your local projects to GitHub automatically"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
license = { text = "MIT" }
|
|
12
12
|
authors = []
|
|
13
|
-
keywords = ["github", "git", "sync", "backup", "automation", "cli"]
|
|
13
|
+
keywords = ["github", "git", "sync", "backup", "automation", "cli", "api"]
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Programming Language :: Python :: 3",
|
|
16
16
|
"License :: OSI Approved :: MIT License",
|
|
@@ -23,6 +23,12 @@ dependencies = [
|
|
|
23
23
|
"click>=8.0",
|
|
24
24
|
]
|
|
25
25
|
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
api = [
|
|
28
|
+
"fastapi>=0.100",
|
|
29
|
+
"uvicorn>=0.23",
|
|
30
|
+
]
|
|
31
|
+
|
|
26
32
|
[project.urls]
|
|
27
33
|
Homepage = "https://github.com/yuvalavni/Agents"
|
|
28
34
|
Repository = "https://github.com/yuvalavni/Agents"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.3"
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Setup helpers used by `github-sync init`.
|
|
3
|
-
Checks prerequisites, detects credentials, sets up SSH.
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
import shutil
|
|
7
|
-
import subprocess
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
# ---------------------------------------------------------------------------
|
|
12
|
-
# Dependency checks
|
|
13
|
-
# ---------------------------------------------------------------------------
|
|
14
|
-
|
|
15
|
-
def check_git() -> tuple[bool, str]:
|
|
16
|
-
"""Returns (installed, version_string)."""
|
|
17
|
-
if not shutil.which("git"):
|
|
18
|
-
return False, ""
|
|
19
|
-
result = subprocess.run(["git", "--version"], capture_output=True, text=True)
|
|
20
|
-
return True, result.stdout.strip()
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def check_gh() -> tuple[bool, str]:
|
|
24
|
-
"""Returns (installed, version_string)."""
|
|
25
|
-
if not shutil.which("gh"):
|
|
26
|
-
return False, ""
|
|
27
|
-
result = subprocess.run(["gh", "--version"], capture_output=True, text=True)
|
|
28
|
-
return True, result.stdout.splitlines()[0].strip()
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def gh_authenticated() -> bool:
|
|
32
|
-
result = subprocess.run(["gh", "auth", "status"], capture_output=True, text=True)
|
|
33
|
-
return result.returncode == 0
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def get_github_username() -> str | None:
|
|
37
|
-
"""Detect the currently authenticated GitHub username."""
|
|
38
|
-
result = subprocess.run(
|
|
39
|
-
["gh", "api", "user", "--jq", ".login"],
|
|
40
|
-
capture_output=True, text=True,
|
|
41
|
-
)
|
|
42
|
-
if result.returncode == 0:
|
|
43
|
-
return result.stdout.strip()
|
|
44
|
-
return None
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
# ---------------------------------------------------------------------------
|
|
48
|
-
# SSH helpers
|
|
49
|
-
# ---------------------------------------------------------------------------
|
|
50
|
-
|
|
51
|
-
DEFAULT_KEY = Path.home() / ".ssh" / "id_ed25519"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def ssh_key_exists() -> bool:
|
|
55
|
-
return DEFAULT_KEY.exists()
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def generate_ssh_key(passphrase: str = "") -> bool:
|
|
59
|
-
result = subprocess.run(
|
|
60
|
-
["ssh-keygen", "-t", "ed25519", "-C", "github-sync", "-f", str(DEFAULT_KEY), "-N", passphrase],
|
|
61
|
-
capture_output=True, text=True,
|
|
62
|
-
)
|
|
63
|
-
return result.returncode == 0
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def add_github_host_key() -> bool:
|
|
67
|
-
result = subprocess.run(
|
|
68
|
-
["ssh-keyscan", "-t", "ed25519", "github.com"],
|
|
69
|
-
capture_output=True, text=True,
|
|
70
|
-
)
|
|
71
|
-
if result.returncode != 0:
|
|
72
|
-
return False
|
|
73
|
-
known_hosts = Path.home() / ".ssh" / "known_hosts"
|
|
74
|
-
known_hosts.parent.mkdir(mode=0o700, exist_ok=True)
|
|
75
|
-
existing = known_hosts.read_text() if known_hosts.exists() else ""
|
|
76
|
-
if "github.com" not in existing:
|
|
77
|
-
with open(known_hosts, "a") as f:
|
|
78
|
-
f.write(result.stdout)
|
|
79
|
-
return True
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def ssh_key_on_github() -> bool:
|
|
83
|
-
"""Test if the current SSH key authenticates to GitHub."""
|
|
84
|
-
result = subprocess.run(
|
|
85
|
-
["ssh", "-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "git@github.com"],
|
|
86
|
-
capture_output=True, text=True,
|
|
87
|
-
)
|
|
88
|
-
return "successfully authenticated" in (result.stdout + result.stderr)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def upload_ssh_key_to_github(title: str = "github-sync") -> bool:
|
|
92
|
-
pub_key = Path(str(DEFAULT_KEY) + ".pub")
|
|
93
|
-
if not pub_key.exists():
|
|
94
|
-
return False
|
|
95
|
-
result = subprocess.run(
|
|
96
|
-
["gh", "ssh-key", "add", str(pub_key), "--title", title],
|
|
97
|
-
capture_output=True, text=True,
|
|
98
|
-
)
|
|
99
|
-
return result.returncode == 0
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
# ---------------------------------------------------------------------------
|
|
103
|
-
# Config writer
|
|
104
|
-
# ---------------------------------------------------------------------------
|
|
105
|
-
|
|
106
|
-
CONFIG_DIR = Path.home() / ".config" / "github-sync"
|
|
107
|
-
CONFIG_PATH = CONFIG_DIR / "config.yaml"
|
|
108
|
-
|
|
109
|
-
CONFIG_TEMPLATE = """\
|
|
110
|
-
# GitHub Sync Agent — configuration
|
|
111
|
-
# Generated by `github-sync init`
|
|
112
|
-
|
|
113
|
-
github_user: {github_user}
|
|
114
|
-
|
|
115
|
-
# Directories to scan for projects.
|
|
116
|
-
# Every subdirectory inside each root is treated as a project to sync.
|
|
117
|
-
# You can specify one path (string) or multiple paths (list):
|
|
118
|
-
#
|
|
119
|
-
# Single path:
|
|
120
|
-
# projects_root: ~/projects
|
|
121
|
-
#
|
|
122
|
-
# Multiple paths:
|
|
123
|
-
# projects_root:
|
|
124
|
-
# - ~/projects
|
|
125
|
-
# - ~/work
|
|
126
|
-
# - /opt/company-code
|
|
127
|
-
projects_root:
|
|
128
|
-
- {projects_root}
|
|
129
|
-
|
|
130
|
-
# Daily schedule time (used by the systemd timer / cron)
|
|
131
|
-
schedule_time: "02:00"
|
|
132
|
-
|
|
133
|
-
# Default visibility for newly created GitHub repos: private | public
|
|
134
|
-
default_visibility: {visibility}
|
|
135
|
-
|
|
136
|
-
# Commit message used for auto-commits
|
|
137
|
-
auto_commit_message: "chore: auto-sync"
|
|
138
|
-
|
|
139
|
-
# Project directories to skip (names only, not full paths)
|
|
140
|
-
exclude: []
|
|
141
|
-
|
|
142
|
-
# Logging
|
|
143
|
-
log_file: {log_file}
|
|
144
|
-
log_level: INFO
|
|
145
|
-
"""
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
def write_config(github_user: str, projects_root: str, visibility: str) -> Path:
|
|
149
|
-
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
150
|
-
log_file = str(Path.home() / ".local" / "share" / "github-sync" / "github-sync.log")
|
|
151
|
-
content = CONFIG_TEMPLATE.format(
|
|
152
|
-
github_user=github_user,
|
|
153
|
-
projects_root=projects_root,
|
|
154
|
-
visibility=visibility,
|
|
155
|
-
log_file=log_file,
|
|
156
|
-
)
|
|
157
|
-
CONFIG_PATH.write_text(content)
|
|
158
|
-
return CONFIG_PATH
|
|
File without changes
|
{github_sync_agent-0.1.3 → github_sync_agent-0.2.0}/github_sync_agent.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{github_sync_agent-0.1.3 → github_sync_agent-0.2.0}/github_sync_agent.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{github_sync_agent-0.1.3 → github_sync_agent-0.2.0}/github_sync_agent.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|