opun8 0.1.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.
Files changed (50) hide show
  1. opun8-0.1.0/LICENSE +0 -0
  2. opun8-0.1.0/PKG-INFO +17 -0
  3. opun8-0.1.0/README.md +0 -0
  4. opun8-0.1.0/pyproject.toml +32 -0
  5. opun8-0.1.0/setup.cfg +4 -0
  6. opun8-0.1.0/src/opun8/__init__.py +9 -0
  7. opun8-0.1.0/src/opun8/assets/__init__.py +0 -0
  8. opun8-0.1.0/src/opun8/auth/__init__.py +51 -0
  9. opun8-0.1.0/src/opun8/auth/github_oauth.py +363 -0
  10. opun8-0.1.0/src/opun8/cli.py +260 -0
  11. opun8-0.1.0/src/opun8/commands/__init__.py +9 -0
  12. opun8-0.1.0/src/opun8/commands/badges.py +199 -0
  13. opun8-0.1.0/src/opun8/commands/deploy.py +851 -0
  14. opun8-0.1.0/src/opun8/commands/detect.py +524 -0
  15. opun8-0.1.0/src/opun8/commands/doctor.py +73 -0
  16. opun8-0.1.0/src/opun8/commands/history.py +855 -0
  17. opun8-0.1.0/src/opun8/config/__init__.py +9 -0
  18. opun8-0.1.0/src/opun8/config/settings.py +18 -0
  19. opun8-0.1.0/src/opun8/core/__init__.py +8 -0
  20. opun8-0.1.0/src/opun8/core/detector.py +127 -0
  21. opun8-0.1.0/src/opun8/core/environment.py +204 -0
  22. opun8-0.1.0/src/opun8/core/templates.py +299 -0
  23. opun8-0.1.0/src/opun8/detector/__init__.py +0 -0
  24. opun8-0.1.0/src/opun8/exceptions/__init__.py +0 -0
  25. opun8-0.1.0/src/opun8/main.py +13 -0
  26. opun8-0.1.0/src/opun8/models/__init__.py +0 -0
  27. opun8-0.1.0/src/opun8/providers/__init__.py +0 -0
  28. opun8-0.1.0/src/opun8/providers/github/__init__.py +0 -0
  29. opun8-0.1.0/src/opun8/providers/render/__init__.py +0 -0
  30. opun8-0.1.0/src/opun8/providers/vercel/__init__.py +11 -0
  31. opun8-0.1.0/src/opun8/providers/vercel/auth.py +962 -0
  32. opun8-0.1.0/src/opun8/providers/vercel/deploy.py +1030 -0
  33. opun8-0.1.0/src/opun8/scanner/__init__.py +0 -0
  34. opun8-0.1.0/src/opun8/services/__init__.py +7 -0
  35. opun8-0.1.0/src/opun8/services/deployment_history.py +533 -0
  36. opun8-0.1.0/src/opun8/services/git_service.py +713 -0
  37. opun8-0.1.0/src/opun8/services/navigation.py +81 -0
  38. opun8-0.1.0/src/opun8/services/recent_projects.py +51 -0
  39. opun8-0.1.0/src/opun8/ui/__init__.py +5 -0
  40. opun8-0.1.0/src/opun8/ui/console.py +9 -0
  41. opun8-0.1.0/src/opun8/ui/messages.py +341 -0
  42. opun8-0.1.0/src/opun8/utils/__init__.py +0 -0
  43. opun8-0.1.0/src/opun8/version.py +9 -0
  44. opun8-0.1.0/src/opun8.egg-info/PKG-INFO +17 -0
  45. opun8-0.1.0/src/opun8.egg-info/SOURCES.txt +48 -0
  46. opun8-0.1.0/src/opun8.egg-info/dependency_links.txt +1 -0
  47. opun8-0.1.0/src/opun8.egg-info/entry_points.txt +2 -0
  48. opun8-0.1.0/src/opun8.egg-info/requires.txt +7 -0
  49. opun8-0.1.0/src/opun8.egg-info/top_level.txt +1 -0
  50. opun8-0.1.0/tests/test_deploy_skip_github_contract.py +62 -0
opun8-0.1.0/LICENSE ADDED
File without changes
opun8-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: opun8
3
+ Version: 0.1.0
4
+ Summary: Universal Deployment Platform for developers.
5
+ Author-email: Kakes David <codewithkakes@gmail.com>
6
+ License: MIT
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: typer
11
+ Requires-Dist: rich
12
+ Requires-Dist: httpx
13
+ Requires-Dist: python-dotenv
14
+ Requires-Dist: pydantic
15
+ Requires-Dist: gitpython
16
+ Requires-Dist: keyring
17
+ Dynamic: license-file
opun8-0.1.0/README.md ADDED
File without changes
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=70.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "opun8"
7
+ version = "0.1.0"
8
+ description = "Universal Deployment Platform for developers."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ authors = [
12
+ { name = "Kakes David", email = "codewithkakes@gmail.com" }
13
+ ]
14
+ license = { text = "MIT" }
15
+ dependencies = [
16
+ "typer",
17
+ "rich",
18
+ "httpx",
19
+ "python-dotenv",
20
+ "pydantic",
21
+ "gitpython",
22
+ "keyring"
23
+ ]
24
+
25
+ [tool.setuptools]
26
+ package-dir = {"" = "src"}
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
30
+
31
+ [project.scripts]
32
+ opun8 = "opun8.cli:app"
opun8-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ """
2
+ Opun8 - Universal Deployment Platform.
3
+
4
+ Build. Deploy. Scale.
5
+ """
6
+
7
+ from opun8.version import __version__
8
+
9
+ __all__ = ["__version__"]
File without changes
@@ -0,0 +1,51 @@
1
+ """
2
+ Authentication module for Opun8.
3
+ """
4
+
5
+ from opun8.auth.github_oauth import (
6
+ login_to_github,
7
+ get_github_token,
8
+ is_authenticated,
9
+ logout,
10
+ save_github_token,
11
+ get_authenticated_user,
12
+ get_github_user,
13
+ list_github_repos,
14
+ create_github_repo,
15
+ )
16
+
17
+ # Re-export Vercel auth functions from the providers module
18
+ from opun8.providers.vercel.auth import (
19
+ login_to_vercel,
20
+ get_vercel_token,
21
+ is_vercel_authenticated,
22
+ logout_vercel,
23
+ show_vercel_projects,
24
+ switch_vercel_team,
25
+ set_deploy_callback,
26
+ get_vercel_user,
27
+ get_vercel_scope,
28
+ )
29
+
30
+ __all__ = [
31
+ # GitHub
32
+ "login_to_github",
33
+ "get_github_token",
34
+ "is_authenticated",
35
+ "logout",
36
+ "save_github_token",
37
+ "get_authenticated_user",
38
+ "get_github_user",
39
+ "list_github_repos",
40
+ "create_github_repo",
41
+ # Vercel
42
+ "login_to_vercel",
43
+ "get_vercel_token",
44
+ "is_vercel_authenticated",
45
+ "logout_vercel",
46
+ "show_vercel_projects",
47
+ "switch_vercel_team",
48
+ "set_deploy_callback",
49
+ "get_vercel_user",
50
+ "get_vercel_scope",
51
+ ]
@@ -0,0 +1,363 @@
1
+ """
2
+ GitHub OAuth authentication for Opun8.
3
+ """
4
+
5
+ import os
6
+ import webbrowser
7
+ import requests
8
+ import json
9
+ import threading
10
+ import secrets
11
+ import urllib.parse
12
+ from http.server import BaseHTTPRequestHandler, HTTPServer
13
+ from pathlib import Path
14
+ from typing import Optional, Dict, List
15
+ from rich.console import Console
16
+ from rich.panel import Panel
17
+ from rich.prompt import Prompt
18
+
19
+ from dotenv import load_dotenv
20
+
21
+ load_dotenv()
22
+
23
+ console = Console()
24
+
25
+ # ------------------------------------------------------------------------------
26
+ # OAuth Configuration — READ FROM .env ONLY — NO HARDCODED VALUES
27
+ # ------------------------------------------------------------------------------
28
+
29
+ CLIENT_ID = os.environ.get("GITHUB_CLIENT_ID")
30
+ CLIENT_SECRET = os.environ.get("GITHUB_CLIENT_SECRET")
31
+
32
+ # Actually honor GITHUB_REDIRECT_URI from .env instead of silently ignoring it.
33
+ REDIRECT_URI = os.environ.get("GITHUB_REDIRECT_URI", "http://localhost:8080/callback")
34
+
35
+ _parsed_redirect = urllib.parse.urlparse(REDIRECT_URI)
36
+ CALLBACK_HOST = _parsed_redirect.hostname or "localhost"
37
+ CALLBACK_PORT = _parsed_redirect.port or 8080
38
+ CALLBACK_PATH = _parsed_redirect.path or "/callback"
39
+
40
+ SCOPES = "repo,workflow" # GitHub accepts comma-delimited scopes.
41
+
42
+ AUTHORIZATION_ENDPOINT = "https://github.com/login/oauth/authorize"
43
+ TOKEN_ENDPOINT = "https://github.com/login/oauth/access_token"
44
+
45
+ if not CLIENT_ID or not CLIENT_SECRET:
46
+ console.print("[red]❌ GitHub credentials not found in .env file.[/red]")
47
+ console.print("[dim]Please ensure GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET are set in .env[/dim]")
48
+
49
+ TOKEN_FILE = Path.home() / ".opun8" / "github_token.json"
50
+
51
+
52
+ def _build_authorize_url(state: str) -> str:
53
+ params = {
54
+ "client_id": CLIENT_ID,
55
+ "redirect_uri": REDIRECT_URI,
56
+ "scope": SCOPES,
57
+ "state": state,
58
+ }
59
+ return f"{AUTHORIZATION_ENDPOINT}?{urllib.parse.urlencode(params)}"
60
+
61
+
62
+ # ------------------------------------------------------------------------------
63
+ # Local callback server — catches the redirect instead of asking the user
64
+ # to copy/paste a code out of a dead browser tab.
65
+ # ------------------------------------------------------------------------------
66
+
67
+ class _CallbackResult:
68
+ code: Optional[str] = None
69
+ state: Optional[str] = None
70
+ error: Optional[str] = None
71
+
72
+
73
+ def _make_handler(result: _CallbackResult, done_event: threading.Event):
74
+ class Handler(BaseHTTPRequestHandler):
75
+ def do_GET(self):
76
+ parsed = urllib.parse.urlparse(self.path)
77
+ if parsed.path != CALLBACK_PATH:
78
+ self.send_response(404)
79
+ self.end_headers()
80
+ return
81
+
82
+ params = urllib.parse.parse_qs(parsed.query)
83
+ result.code = params.get("code", [None])[0]
84
+ result.state = params.get("state", [None])[0]
85
+ result.error = params.get("error_description", params.get("error", [None]))[0]
86
+
87
+ self.send_response(200)
88
+ self.send_header("Content-Type", "text/html")
89
+ self.end_headers()
90
+ if result.code:
91
+ self.wfile.write(
92
+ b"<html><body><h2>GitHub authorization complete.</h2>"
93
+ b"<p>You can close this tab and return to the terminal.</p></body></html>"
94
+ )
95
+ else:
96
+ self.wfile.write(
97
+ b"<html><body><h2>Authorization failed.</h2>"
98
+ b"<p>You can close this tab and return to the terminal.</p></body></html>"
99
+ )
100
+ done_event.set()
101
+
102
+ def log_message(self, format, *args):
103
+ pass # silence default HTTP server logging
104
+
105
+ return Handler
106
+
107
+
108
+ def _wait_for_callback(timeout: int = 180) -> _CallbackResult:
109
+ """Start a local server, wait for GitHub's redirect, then shut down."""
110
+ result = _CallbackResult()
111
+ done_event = threading.Event()
112
+ handler = _make_handler(result, done_event)
113
+
114
+ server = HTTPServer((CALLBACK_HOST, CALLBACK_PORT), handler)
115
+ server_thread = threading.Thread(target=server.serve_forever, daemon=True)
116
+ server_thread.start()
117
+
118
+ got_it = done_event.wait(timeout=timeout)
119
+ server.shutdown()
120
+ server_thread.join()
121
+
122
+ if not got_it:
123
+ result.error = "timed out waiting for GitHub to redirect back"
124
+
125
+ return result
126
+
127
+
128
+ # ------------------------------------------------------------------------------
129
+ # Token Storage
130
+ # ------------------------------------------------------------------------------
131
+
132
+ def get_github_token() -> Optional[str]:
133
+ if TOKEN_FILE.exists():
134
+ try:
135
+ with open(TOKEN_FILE, "r") as f:
136
+ return json.load(f).get("access_token")
137
+ except Exception:
138
+ return None
139
+ return None
140
+
141
+
142
+ def get_github_user() -> Optional[Dict]:
143
+ if TOKEN_FILE.exists():
144
+ try:
145
+ with open(TOKEN_FILE, "r") as f:
146
+ return json.load(f).get("user")
147
+ except Exception:
148
+ return None
149
+ return None
150
+
151
+
152
+ def save_github_token(token: str, user_info: Dict) -> None:
153
+ TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
154
+ with open(TOKEN_FILE, "w") as f:
155
+ json.dump({"access_token": token, "user": user_info}, f, indent=2)
156
+
157
+
158
+ # ------------------------------------------------------------------------------
159
+ # Login Flow
160
+ # ------------------------------------------------------------------------------
161
+
162
+ def login_to_github() -> Optional[str]:
163
+ console.print()
164
+ console.print(Panel(
165
+ "[bold cyan]🔐 GitHub Authentication[/bold cyan]\n\n"
166
+ "Opun8 needs access to GitHub to:\n"
167
+ " • Create repositories\n"
168
+ " • Push your code\n"
169
+ " • Enable auto-deploy\n\n"
170
+ "[dim]Your browser will open for authorization.[/dim]",
171
+ border_style="cyan",
172
+ padding=(1, 2),
173
+ width=60,
174
+ ))
175
+ console.print()
176
+ console.print("[bold]1[/] 🔑 [white]Login with GitHub[/white] [dim](opens browser)[/dim]")
177
+ console.print("[bold]2[/] ⏭️ [white]Skip[/white] [dim](deploy without GitHub)[/dim]")
178
+ console.print()
179
+
180
+ choice = Prompt.ask(
181
+ "[bold cyan]➜[/] Select an option",
182
+ choices=["1", "2"],
183
+ default="1",
184
+ show_choices=False,
185
+ )
186
+
187
+ if choice == "2":
188
+ console.print("\n[yellow]Skipping GitHub authentication.[/yellow]")
189
+ return None
190
+
191
+ if not CLIENT_ID or not CLIENT_SECRET:
192
+ console.print("[red]❌ Missing GitHub credentials in .env file.[/red]")
193
+ return None
194
+
195
+ state = secrets.token_urlsafe(32)
196
+ authorize_url = _build_authorize_url(state)
197
+
198
+ console.print()
199
+ console.print("[dim]🌐 Opening browser for GitHub authorization...[/dim]")
200
+ console.print(f"[dim]Waiting on {REDIRECT_URI} for the redirect...[/dim]")
201
+ console.print()
202
+
203
+ webbrowser.open(authorize_url)
204
+
205
+ console.print("[bold]Waiting for GitHub to redirect back...[/bold]")
206
+ console.print("[dim]This happens automatically — no need to paste anything.[/dim]")
207
+ console.print()
208
+
209
+ result = _wait_for_callback()
210
+
211
+ if result.error and not result.code:
212
+ console.print(f"[red]❌ Authorization failed: {result.error}[/red]")
213
+ return None
214
+
215
+ if result.state != state:
216
+ console.print("[red]❌ State mismatch — possible CSRF, aborting.[/red]")
217
+ return None
218
+
219
+ token = exchange_github_code_for_token(result.code)
220
+ if not token:
221
+ console.print("[red]❌ Failed to exchange code for a token.[/red]")
222
+ return token
223
+
224
+
225
+ def exchange_github_code_for_token(code: str) -> Optional[str]:
226
+ try:
227
+ if not code:
228
+ console.print("[red]❌ No authorization code received.[/red]")
229
+ return None
230
+
231
+ if not CLIENT_SECRET:
232
+ console.print("[red]❌ GITHUB_CLIENT_SECRET not found in .env file.[/red]")
233
+ return None
234
+
235
+ response = requests.post(
236
+ TOKEN_ENDPOINT,
237
+ headers={"Accept": "application/json"},
238
+ data={
239
+ "client_id": CLIENT_ID,
240
+ "client_secret": CLIENT_SECRET,
241
+ "code": code,
242
+ "redirect_uri": REDIRECT_URI,
243
+ },
244
+ timeout=30,
245
+ )
246
+
247
+ data = response.json()
248
+
249
+ if "access_token" in data:
250
+ token = data["access_token"]
251
+
252
+ user = get_github_user_info(token)
253
+ if user:
254
+ save_github_token(token, user)
255
+ console.print()
256
+ console.print(f"[bold green]✅ Connected as: {user.get('login', 'Unknown')}[/bold green]")
257
+ console.print("[dim]Token saved securely for future use.[/dim]")
258
+ else:
259
+ save_github_token(token, {"login": "Unknown"})
260
+
261
+ return token
262
+ else:
263
+ error_msg = data.get("error_description", data.get("error", "Unknown error"))
264
+ console.print(f"[red]Failed to get token: {error_msg}[/red]")
265
+ return None
266
+
267
+ except Exception as e:
268
+ console.print(f"[red]Error exchanging code: {e}[/red]")
269
+ return None
270
+
271
+
272
+ def get_github_user_info(token: str) -> Optional[Dict]:
273
+ try:
274
+ response = requests.get(
275
+ "https://api.github.com/user",
276
+ headers={"Authorization": f"Bearer {token}"},
277
+ timeout=10,
278
+ )
279
+ return response.json() if response.status_code == 200 else None
280
+ except Exception:
281
+ return None
282
+
283
+
284
+ def is_authenticated() -> bool:
285
+ return get_github_token() is not None
286
+
287
+
288
+ def logout() -> None:
289
+ if TOKEN_FILE.exists():
290
+ TOKEN_FILE.unlink()
291
+ console.print("[green]✅ Logged out of GitHub.[/green]")
292
+ else:
293
+ console.print("[yellow]Not logged in.[/yellow]")
294
+
295
+
296
+ def get_authenticated_user() -> Optional[str]:
297
+ user = get_github_user()
298
+ if user:
299
+ return user.get("login")
300
+ return None
301
+
302
+
303
+ def list_github_repos(token: Optional[str] = None) -> List[Dict]:
304
+ if token is None:
305
+ token = get_github_token()
306
+
307
+ if not token:
308
+ return []
309
+
310
+ try:
311
+ response = requests.get(
312
+ "https://api.github.com/user/repos",
313
+ headers={"Authorization": f"Bearer {token}"},
314
+ params={"per_page": 50, "sort": "updated"},
315
+ timeout=10,
316
+ )
317
+
318
+ if response.status_code == 200:
319
+ repos = response.json()
320
+ return [
321
+ {
322
+ "name": repo["name"],
323
+ "full_name": repo["full_name"],
324
+ "private": repo["private"],
325
+ "url": repo["html_url"],
326
+ "description": repo.get("description", ""),
327
+ "updated_at": repo.get("updated_at", ""),
328
+ }
329
+ for repo in repos
330
+ ]
331
+ return []
332
+ except Exception:
333
+ return []
334
+
335
+
336
+ def create_github_repo(token: Optional[str], name: str, description: str = "", private: bool = False) -> Optional[Dict]:
337
+ if token is None:
338
+ token = get_github_token()
339
+
340
+ if not token:
341
+ return None
342
+
343
+ try:
344
+ response = requests.post(
345
+ "https://api.github.com/user/repos",
346
+ headers={
347
+ "Authorization": f"Bearer {token}",
348
+ "Content-Type": "application/json",
349
+ },
350
+ json={
351
+ "name": name,
352
+ "description": description,
353
+ "private": private,
354
+ "auto_init": True,
355
+ },
356
+ timeout=30,
357
+ )
358
+
359
+ if response.status_code == 201:
360
+ return response.json()
361
+ return None
362
+ except Exception:
363
+ return None