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.
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-sync-agent
3
- Version: 0.1.3
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 key setup ────────────────────────────────────────────────────
211
- click.echo(click.style(" Setting up SSH key for GitHub...", bold=True))
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.add_github_host_key()
223
+ setup.add_github_host_keys()
224
224
 
225
- if not setup.ssh_key_on_github():
226
- click.echo(" Uploading SSH key to GitHub...")
227
- if setup.upload_ssh_key_to_github():
228
- click.echo(" ✓ SSH key added to GitHub")
229
- else:
230
- click.echo(click.style(" ✗ Failed to upload SSH key.", fg="yellow"))
231
- click.echo(" Run manually: gh ssh-key add ~/.ssh/id_ed25519.pub")
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(" SSH key already on GitHub")
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.1.3
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
 
@@ -2,6 +2,7 @@ README.md
2
2
  pyproject.toml
3
3
  github_sync/__init__.py
4
4
  github_sync/agent.py
5
+ github_sync/api.py
5
6
  github_sync/cli.py
6
7
  github_sync/config.py
7
8
  github_sync/setup.py
@@ -0,0 +1,6 @@
1
+ pyyaml>=6.0
2
+ click>=8.0
3
+
4
+ [api]
5
+ fastapi>=0.100
6
+ uvicorn>=0.23
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "github-sync-agent"
7
- version = "0.1.3"
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
@@ -1,2 +0,0 @@
1
- pyyaml>=6.0
2
- click>=8.0