github-sync-agent 0.1.2__tar.gz → 0.1.4__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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-sync-agent
3
- Version: 0.1.2
3
+ Version: 0.1.4
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
@@ -49,8 +49,11 @@ The wizard will:
49
49
  - Authenticate with GitHub (`gh auth login`)
50
50
  - Ask which folder to scan for projects
51
51
  - Set up your SSH key automatically
52
+ - **Verify SSH works** (auto-fixes port 443 and ssh-agent if needed)
52
53
  - Write a config file to `~/.config/github-sync/config.yaml`
53
54
 
55
+ Setup will **not** complete until `ssh -T git@github.com` succeeds.
56
+
54
57
  ---
55
58
 
56
59
  ## Usage
@@ -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
@@ -0,0 +1 @@
1
+ __version__ = "0.1.4"
@@ -1,4 +1,5 @@
1
1
  import logging
2
+ import platform
2
3
  import sys
3
4
  from pathlib import Path
4
5
 
@@ -35,10 +36,16 @@ def _setup_logging(cfg: dict) -> None:
35
36
  def cli(ctx: click.Context, config_path: Path | None) -> None:
36
37
  """GitHub Sync Agent — sync local projects to GitHub."""
37
38
  ctx.ensure_object(dict)
39
+
40
+ # init creates the config — it must not require one to exist first
41
+ if ctx.invoked_subcommand == "init":
42
+ return
43
+
38
44
  try:
39
45
  ctx.obj["cfg"] = config.load(config_path)
40
46
  except (FileNotFoundError, ValueError) as exc:
41
47
  click.echo(f"Error: {exc}", err=True)
48
+ click.echo("Run 'github-sync init' to create a config file.", err=True)
42
49
  sys.exit(1)
43
50
 
44
51
 
@@ -123,6 +130,15 @@ def status(ctx: click.Context) -> None:
123
130
  # init
124
131
  # ---------------------------------------------------------------------------
125
132
 
133
+ def _install_hint(tool: str) -> str:
134
+ system = platform.system()
135
+ if system == "Darwin":
136
+ return f"brew install {tool}"
137
+ if system == "Linux":
138
+ return f"sudo apt-get install -y {tool}"
139
+ return f"Install {tool} from your system package manager"
140
+
141
+
126
142
  @cli.command()
127
143
  def init() -> None:
128
144
  """Interactive setup wizard — run this first on a new machine."""
@@ -136,7 +152,7 @@ def init() -> None:
136
152
  click.echo(f" ✓ git found: {git_ver}")
137
153
  else:
138
154
  click.echo(click.style(" ✗ git is not installed.", fg="red"))
139
- click.echo(" Install it with: sudo apt-get install -y git")
155
+ click.echo(f" Install it with: {_install_hint('git')}")
140
156
  sys.exit(1)
141
157
 
142
158
  # ── 2. Check gh ─────────────────────────────────────────────────────────
@@ -145,7 +161,7 @@ def init() -> None:
145
161
  click.echo(f" ✓ gh found: {gh_ver}")
146
162
  else:
147
163
  click.echo(click.style(" ✗ gh (GitHub CLI) is not installed.", fg="red"))
148
- click.echo(" Install it with: sudo apt-get install -y gh")
164
+ click.echo(f" Install it with: {_install_hint('gh')}")
149
165
  sys.exit(1)
150
166
 
151
167
  # ── 3. GitHub authentication ─────────────────────────────────────────────
@@ -191,8 +207,8 @@ def init() -> None:
191
207
 
192
208
  click.echo()
193
209
 
194
- # ── 6. SSH key setup ────────────────────────────────────────────────────
195
- 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))
196
212
 
197
213
  if not setup.ssh_key_exists():
198
214
  click.echo(" No SSH key found — generating one...")
@@ -204,17 +220,25 @@ def init() -> None:
204
220
  else:
205
221
  click.echo(" ✓ SSH key already exists")
206
222
 
207
- setup.add_github_host_key()
223
+ setup.add_github_host_keys()
208
224
 
209
- if not setup.ssh_key_on_github():
210
- click.echo(" Uploading SSH key to GitHub...")
211
- if setup.upload_ssh_key_to_github():
212
- click.echo(" ✓ SSH key added to GitHub")
213
- else:
214
- click.echo(click.style(" ✗ Failed to upload SSH key.", fg="yellow"))
215
- 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}")
216
234
  else:
217
- 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)
218
242
 
219
243
  click.echo()
220
244
 
@@ -3,6 +3,8 @@ Setup helpers used by `github-sync init`.
3
3
  Checks prerequisites, detects credentials, sets up SSH.
4
4
  """
5
5
 
6
+ import platform
7
+ import re
6
8
  import shutil
7
9
  import subprocess
8
10
  from pathlib import Path
@@ -49,6 +51,16 @@ def get_github_username() -> str | None:
49
51
  # ---------------------------------------------------------------------------
50
52
 
51
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
+ """
52
64
 
53
65
 
54
66
  def ssh_key_exists() -> bool:
@@ -56,6 +68,7 @@ def ssh_key_exists() -> bool:
56
68
 
57
69
 
58
70
  def generate_ssh_key(passphrase: str = "") -> bool:
71
+ Path.home().joinpath(".ssh").mkdir(mode=0o700, exist_ok=True)
59
72
  result = subprocess.run(
60
73
  ["ssh-keygen", "-t", "ed25519", "-C", "github-sync", "-f", str(DEFAULT_KEY), "-N", passphrase],
61
74
  capture_output=True, text=True,
@@ -63,29 +76,101 @@ def generate_ssh_key(passphrase: str = "") -> bool:
63
76
  return result.returncode == 0
64
77
 
65
78
 
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:
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():
72
87
  return False
88
+
73
89
  known_hosts = Path.home() / ".ssh" / "known_hosts"
74
90
  known_hosts.parent.mkdir(mode=0o700, exist_ok=True)
75
91
  existing = known_hosts.read_text() if known_hosts.exists() else ""
76
- if "github.com" not in existing:
92
+ if hostname not in existing:
77
93
  with open(known_hosts, "a") as f:
78
94
  f.write(result.stdout)
79
95
  return True
80
96
 
81
97
 
82
- def ssh_key_on_github() -> bool:
83
- """Test if the current SSH key authenticates to GitHub."""
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)."""
84
106
  result = subprocess.run(
85
- ["ssh", "-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "git@github.com"],
107
+ [
108
+ "ssh", "-T",
109
+ "-o", "BatchMode=yes",
110
+ "-o", "ConnectTimeout=15",
111
+ "-o", "StrictHostKeyChecking=accept-new",
112
+ "git@github.com",
113
+ ],
86
114
  capture_output=True, text=True,
87
115
  )
88
- return "successfully authenticated" in (result.stdout + result.stderr)
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
89
174
 
90
175
 
91
176
  def upload_ssh_key_to_github(title: str = "github-sync") -> bool:
@@ -99,6 +184,55 @@ def upload_ssh_key_to_github(title: str = "github-sync") -> bool:
99
184
  return result.returncode == 0
100
185
 
101
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
+
102
236
  # ---------------------------------------------------------------------------
103
237
  # Config writer
104
238
  # ---------------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-sync-agent
3
- Version: 0.1.2
3
+ Version: 0.1.4
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
@@ -49,8 +49,11 @@ The wizard will:
49
49
  - Authenticate with GitHub (`gh auth login`)
50
50
  - Ask which folder to scan for projects
51
51
  - Set up your SSH key automatically
52
+ - **Verify SSH works** (auto-fixes port 443 and ssh-agent if needed)
52
53
  - Write a config file to `~/.config/github-sync/config.yaml`
53
54
 
55
+ Setup will **not** complete until `ssh -T git@github.com` succeeds.
56
+
54
57
  ---
55
58
 
56
59
  ## Usage
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "github-sync-agent"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "Sync all your local projects to GitHub automatically"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1 +0,0 @@
1
- __version__ = "0.1.2"