autodeploy-ai 1.5.0__py3-none-any.whl

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.
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.4
2
+ Name: autodeploy-ai
3
+ Version: 1.5.0
4
+ Summary: Git Init · AI Commit · Auto Push — one command from any terminal
5
+ Author: dhiksn
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/dhiksn/auto-deploy
8
+ Project-URL: Repository, https://github.com/dhiksn/auto-deploy
9
+ Project-URL: Bug Tracker, https://github.com/dhiksn/auto-deploy/issues
10
+ Keywords: git,deploy,ai,commit,cli,automation
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Software Development :: Version Control :: Git
18
+ Classifier: Topic :: Utilities
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: rich>=13.0.0
22
+ Requires-Dist: prompt_toolkit>=3.0.0
23
+ Requires-Dist: pyfiglet>=1.0.0
24
+
25
+ # ✦ AutoDeploy AI
26
+
27
+ > Git Init · AI Commit · Auto Push — dari terminal mana pun, satu command.
28
+
29
+ ![Tampilan Awal](Tawal.png)
30
+
31
+ ![Tampilan Akhir](Takhir.png)
32
+
33
+ ---
34
+
35
+ ## Fitur
36
+
37
+ - **Auto `git init`** kalau project belum punya `.git`
38
+ - **Set remote otomatis** dari GitHub URL yang lo kasih
39
+ - **Validasi repo** — cek apakah repo GitHub benar-benar ada sebelum deploy
40
+ - **AI generate commit message** pakai Groq, OpenAI, atau Ollama
41
+ - **Spinner animasi** di tiap step — staging, generating, commit, push
42
+ - **Global CLI** — bisa dipanggil dari folder project mana pun tanpa copy file
43
+
44
+ ---
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install autodeploy-ai
50
+ ```
51
+
52
+ Setelah install, command `deploy` langsung tersedia dari terminal mana pun.
53
+
54
+ ---
55
+
56
+ ## Setup
57
+
58
+ Buat file `.env` di folder manapun lo mau deploy, atau di home directory:
59
+
60
+ ```bash
61
+ # Windows
62
+ copy .env.example .env
63
+
64
+ # Linux / Mac
65
+ cp .env.example .env
66
+ ```
67
+
68
+ Edit `.env` sesuai provider AI yang lo pakai:
69
+
70
+ ```env
71
+ # Pilih provider: groq | openai | ollama
72
+ AI_PROVIDER=groq
73
+
74
+ # Groq (gratis, cepat) — https://console.groq.com/keys
75
+ GROQ_API_KEY=gsk_...
76
+ GROQ_MODEL=llama-3.1-8b-instant
77
+
78
+ # Ollama (lokal, tidak perlu API key)
79
+ # OLLAMA_URL=http://localhost:11434
80
+ # OLLAMA_MODEL=llama3.2:latest
81
+
82
+ # OpenAI
83
+ # OPENAI_API_KEY=sk-...
84
+ # OPENAI_MODEL=gpt-4o-mini
85
+ ```
86
+
87
+ > **Penting:** jangan pernah commit file `.env` karena berisi API key.
88
+
89
+ ---
90
+
91
+ ## Cara Pakai
92
+
93
+ ```bash
94
+ # Project baru — belum ada .git
95
+ deploy https://github.com/username/repo-name
96
+
97
+ # Project yang sudah punya remote
98
+ deploy
99
+ ```
100
+
101
+ ### Flow yang terjadi
102
+
103
+ ```
104
+ ✓ Staging changes → git add .
105
+ ✓ Generating commit message → AI generate via Groq / OpenAI / Ollama
106
+ └─ konfirmasi atau ketik manual
107
+ ✓ Creating commit → git commit -m "<pesan>"
108
+ ✓ Pushing to GitHub → git push -u origin <branch>
109
+ ```
110
+
111
+ Kalau project belum ada `.git`, sebelum flow di atas akan otomatis:
112
+ 1. Validasi repo GitHub — pastiin sudah dibuat di [github.com/new](https://github.com/new)
113
+ 2. `git init`
114
+ 3. `git remote add origin <url>`
115
+
116
+ ---
117
+
118
+ ## Provider AI
119
+
120
+ | Provider | Model | Keterangan | API Key |
121
+ |---|---|---|---|
122
+ | `groq` | `llama-3.1-8b-instant` | Online, gratis, cepat | [console.groq.com](https://console.groq.com/keys) |
123
+ | `ollama` | `llama3.2:latest` | Lokal, gratis, butuh Ollama running | Tidak perlu |
124
+ | `openai` | `gpt-4o-mini` | Online, berbayar | [platform.openai.com](https://platform.openai.com/api-keys) |
125
+
126
+ Untuk ganti provider, ubah `AI_PROVIDER` di file `.env`.
127
+
128
+ ---
129
+
130
+ ## Requirements
131
+
132
+ - Python 3.10+
133
+ - Git
134
+ - Salah satu AI provider di atas
@@ -0,0 +1,6 @@
1
+ deploy.py,sha256=YTfOcWqJonTVtnW-kPYWpCaAUbKiV7pjyRMOvFvTpQo,27855
2
+ autodeploy_ai-1.5.0.dist-info/METADATA,sha256=bZFojWQGyOw_nPfcSHO8fydmj9a-d-riJ6j4maOQajY,3681
3
+ autodeploy_ai-1.5.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
4
+ autodeploy_ai-1.5.0.dist-info/entry_points.txt,sha256=Znle1bbw5QrVW7ZYZS9RrK_uV-D38B6667_X1IaVVv0,43
5
+ autodeploy_ai-1.5.0.dist-info/top_level.txt,sha256=xvFnZzSzWjwmJ0zc3txKNOrT0LgVq20Gr9SlilW68Tw,7
6
+ autodeploy_ai-1.5.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ deploy = deploy:main_cli
@@ -0,0 +1 @@
1
+ deploy
deploy.py ADDED
@@ -0,0 +1,701 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ┌────────────────────────────────────────────────┐
4
+ │ AutoDeploy CLI │
5
+ │ Git Init · AI Commit · Auto Push │
6
+ │ │
7
+ │ Requires: │
8
+ │ pip install rich prompt_toolkit │
9
+ └────────────────────────────────────────────────┘
10
+
11
+ """
12
+
13
+ # ── Standard library ──────────────────────────────────────────────────────────
14
+ import os
15
+ import sys
16
+ import json
17
+ import subprocess
18
+ import time
19
+ import threading
20
+ from pathlib import Path
21
+
22
+ # ── Auto-install dependencies ─────────────────────────────────────────────────
23
+ def _ensure(pkg: str, import_as: str | None = None):
24
+ import importlib
25
+ name = import_as or pkg
26
+ try:
27
+ return importlib.import_module(name)
28
+ except ImportError:
29
+ subprocess.check_call(
30
+ [sys.executable, "-m", "pip", "install", pkg, "-q"],
31
+ stdout=subprocess.DEVNULL,
32
+ )
33
+ return importlib.import_module(name)
34
+
35
+ _ensure("rich")
36
+ _ensure("prompt_toolkit", "prompt_toolkit")
37
+
38
+ from rich.console import Console
39
+ from rich.panel import Panel
40
+ from rich.table import Table
41
+ from rich.text import Text
42
+ from rich.align import Align
43
+ from rich.box import DOUBLE_EDGE, SQUARE, MINIMAL
44
+ from rich.progress import Progress, BarColumn, TextColumn
45
+ from rich.markup import escape
46
+ from prompt_toolkit import prompt as pt_prompt
47
+ from prompt_toolkit.styles import Style as PTStyle
48
+ from prompt_toolkit.formatted_text import HTML
49
+
50
+ # ── Console ───────────────────────────────────────────────────────────────────
51
+ console = Console(highlight=False, soft_wrap=True)
52
+
53
+ APP_VERSION = "1.5.0"
54
+
55
+ # ── Theme: teal + amber (no purple, no blue) ──────────────────────────────────
56
+ C_HEAD = "bold turquoise2"
57
+ C_LINE = "grey42"
58
+ C_LABEL = "grey62"
59
+ C_VAL = "wheat1"
60
+ C_OK = "sea_green2"
61
+ C_WARN = "dark_orange"
62
+ C_ERR = "bright_red"
63
+ C_DIM = "grey35"
64
+ C_TEXT = "white"
65
+ C_ACCENT = "turquoise2"
66
+ C_DIM_ANSI = "2" # ANSI dim untuk sys.stdout.write
67
+
68
+ PT_STYLE = PTStyle.from_dict({
69
+ "prompt": "bold ansigreen",
70
+ "placeholder": "ansibrightblack",
71
+ "": "ansiwhite",
72
+ })
73
+
74
+ LOGO = "Slant" # pyfiglet font
75
+
76
+ # ── Load .env ─────────────────────────────────────────────────────────────────
77
+ def load_env():
78
+ env_file = Path(__file__).parent / ".env"
79
+ if env_file.exists():
80
+ with open(env_file) as f:
81
+ for line in f:
82
+ line = line.strip()
83
+ if line and not line.startswith("#") and "=" in line:
84
+ key, _, value = line.partition("=")
85
+ # Selalu override dari .env, jangan pakai setdefault
86
+ os.environ[key.strip()] = value.strip().strip('"').strip("'")
87
+
88
+ load_env()
89
+
90
+ # ── Config ────────────────────────────────────────────────────────────────────
91
+ AI_PROVIDER = os.environ.get("AI_PROVIDER", "openai")
92
+ OPENAI_KEY = os.environ.get("OPENAI_API_KEY", "")
93
+ GROQ_KEY = os.environ.get("GROQ_API_KEY", "")
94
+ OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
95
+ OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "llama3")
96
+ OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o-mini")
97
+ GROQ_MODEL = os.environ.get("GROQ_MODEL", "llama3-8b-8192")
98
+ DEFAULT_BRANCH = os.environ.get("DEFAULT_BRANCH", "main")
99
+
100
+
101
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
102
+ # UI HELPERS
103
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
104
+
105
+ def ui_clear():
106
+ os.system("cls" if os.name == "nt" else "clear")
107
+
108
+
109
+ def ui_banner():
110
+ _ensure("pyfiglet")
111
+ import pyfiglet
112
+ fig = pyfiglet.figlet_format("AutoDeploy", font=LOGO, justify="center",
113
+ width=console.width or 100)
114
+ console.print()
115
+ console.print(f"[{C_HEAD}]{fig.rstrip()}[/]")
116
+ console.print()
117
+ console.print(Align.center(Text(f"v{APP_VERSION} · Git Init · AI Commit · Auto Push", style=C_DIM)))
118
+ console.print()
119
+ console.print(Align.center(Text("─" * 56, style=C_LINE)))
120
+
121
+
122
+ def ui_splash():
123
+ ui_clear()
124
+ ui_banner()
125
+ labels = ["Initialising", "Reading workspace", "Loading config", "Ready"]
126
+ term_width = os.get_terminal_size().columns if hasattr(os, "get_terminal_size") else 80
127
+ for label in labels:
128
+ text = f"[ {label} ]"
129
+ padded = text.center(term_width)
130
+ sys.stdout.write(f"\033[2K\r{padded}")
131
+ sys.stdout.flush()
132
+ time.sleep(0.15)
133
+ sys.stdout.write("\033[2K\r")
134
+ sys.stdout.flush()
135
+
136
+
137
+ def ui_header(repo_dir: str, branch: str, remote: str, ai_provider: str):
138
+ body = Table.grid(padding=(0, 2))
139
+ body.add_column(justify="right", style=C_LABEL, min_width=10)
140
+ body.add_column(style=C_VAL)
141
+
142
+ body.add_row("REMOTE", escape(remote or "not set"))
143
+ body.add_row("BRANCH", f"[{C_ACCENT}]{escape(branch or DEFAULT_BRANCH)}[/]")
144
+ body.add_row("AI", f"[{C_OK}]{ai_provider.upper()}[/] [{C_DIM}]{_ai_model_label()}[/]")
145
+
146
+ console.print(Panel(
147
+ body,
148
+ title="[bold white] SESSION [/]",
149
+ title_align="left",
150
+ border_style=C_LINE,
151
+ box=SQUARE,
152
+ padding=(1, 2),
153
+ ))
154
+
155
+
156
+ def _ai_model_label() -> str:
157
+ if AI_PROVIDER == "openai":
158
+ return OPENAI_MODEL
159
+ if AI_PROVIDER == "groq":
160
+ return GROQ_MODEL
161
+ if AI_PROVIDER == "ollama":
162
+ return OLLAMA_MODEL
163
+ return "unknown"
164
+
165
+
166
+ def ui_success(commit_msg: str, branch: str, remote: str):
167
+ body = Table.grid(padding=(0, 2))
168
+ body.add_column(justify="right", style=C_LABEL, min_width=10)
169
+ body.add_column(style=C_TEXT)
170
+ body.add_row("COMMIT", escape(commit_msg))
171
+ body.add_row("BRANCH", f"[{C_ACCENT}]{escape(branch)}[/]")
172
+ body.add_row("REMOTE", escape(remote))
173
+
174
+ console.print()
175
+ console.print(Panel(
176
+ body,
177
+ title=f"[bold {C_OK}] DEPLOY COMPLETE [/]",
178
+ title_align="left",
179
+ border_style=C_OK,
180
+ box=DOUBLE_EDGE,
181
+ padding=(1, 2),
182
+ ))
183
+ console.print()
184
+
185
+
186
+ def ui_error(message: str):
187
+ console.print()
188
+ console.print(Panel(
189
+ f"[{C_TEXT}]{escape(str(message))}[/]",
190
+ title=f"[bold {C_ERR}] ERROR [/]",
191
+ title_align="left",
192
+ border_style=C_ERR,
193
+ box=DOUBLE_EDGE,
194
+ padding=(1, 2),
195
+ ))
196
+ console.print()
197
+
198
+
199
+ def ui_warn(message: str):
200
+ console.print(f"\n [bold {C_WARN}]![/] [{C_WARN}]{escape(message)}[/]\n")
201
+
202
+
203
+ def ui_step(label: str, value: str = ""):
204
+ val_str = f" [{C_DIM}]{escape(value)}[/]" if value else ""
205
+ console.print(f" [{C_ACCENT}]>[/] [{C_TEXT}]{escape(label)}[/]{val_str}")
206
+
207
+
208
+ def ui_tips():
209
+ console.print(Panel(
210
+ f"[{C_LABEL}]Enter[/] [{C_DIM}]terima commit message AI · [/]"
211
+ f"[{C_LABEL}]ketik pesan[/] [{C_DIM}]untuk override · [/]"
212
+ f"[{C_LABEL}]Ctrl+C[/] [{C_DIM}]batal kapan saja[/]",
213
+ border_style=C_DIM,
214
+ box=MINIMAL,
215
+ padding=(0, 1),
216
+ ))
217
+ console.print()
218
+
219
+
220
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
221
+ # GIT HELPERS
222
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
223
+
224
+ def run(cmd: list[str], capture: bool = False) -> subprocess.CompletedProcess:
225
+ return subprocess.run(
226
+ cmd,
227
+ capture_output=capture,
228
+ text=True,
229
+ encoding="utf-8",
230
+ errors="replace",
231
+ )
232
+
233
+
234
+ def spin_run(cmd: list[str], label: str) -> subprocess.CompletedProcess:
235
+ """Jalankan command sambil tampilkan spinner animasi, lalu print status akhir."""
236
+ import threading
237
+ frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
238
+ stop = threading.Event()
239
+ result_box: list[subprocess.CompletedProcess] = []
240
+
241
+ def _spin():
242
+ i = 0
243
+ while not stop.is_set():
244
+ sys.stdout.write(f"\r \033[{C_DIM_ANSI}m{frames[i % len(frames)]}\033[0m {label}...")
245
+ sys.stdout.flush()
246
+ time.sleep(0.08)
247
+ i += 1
248
+
249
+ t = threading.Thread(target=_spin, daemon=True)
250
+ t.start()
251
+ try:
252
+ result_box.append(subprocess.run(
253
+ cmd, capture_output=True, text=True,
254
+ encoding="utf-8", errors="replace",
255
+ ))
256
+ finally:
257
+ stop.set()
258
+ t.join()
259
+ sys.stdout.write("\r" + " " * 60 + "\r")
260
+ sys.stdout.flush()
261
+
262
+ result = result_box[0]
263
+ if result.returncode == 0:
264
+ console.print(f" [{C_OK}]✓[/] {label}")
265
+ else:
266
+ console.print(f" [{C_ERR}]✗[/] {label}")
267
+ return result
268
+
269
+
270
+ def git_has_changes() -> bool:
271
+ return bool((run(["git", "status", "--porcelain"], capture=True).stdout or "").strip())
272
+
273
+
274
+ def git_current_branch() -> str:
275
+ result = run(["git", "rev-parse", "--abbrev-ref", "HEAD"], capture=True)
276
+ return (result.stdout or "").strip() or DEFAULT_BRANCH
277
+
278
+
279
+ def git_remote_url() -> str:
280
+ result = run(["git", "remote", "get-url", "origin"], capture=True)
281
+ return (result.stdout or "").strip()
282
+
283
+
284
+ def git_diff_staged() -> str:
285
+ stat = (run(["git", "diff", "--staged", "--stat"], capture=True).stdout or "").strip()
286
+ diff = (run(["git", "diff", "--staged", "--unified=3"], capture=True).stdout or "").strip()
287
+ MAX = 3500
288
+ if len(diff) > MAX:
289
+ diff = diff[:MAX] + "\n...(truncated)"
290
+ return f"{stat}\n\n{diff}".strip()
291
+
292
+
293
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
294
+ # AI COMMIT MESSAGE
295
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
296
+
297
+ def _build_prompt(diff: str) -> str:
298
+ return (
299
+ "You are a Git commit message generator. "
300
+ "Based on the following staged diff, write ONE concise commit message in English.\n"
301
+ "Format: <type>(<scope>): <short description>\n"
302
+ "Types: feat, fix, docs, style, refactor, chore, test\n"
303
+ "Rules: single line only, no extra explanation, max 72 characters.\n\n"
304
+ f"DIFF:\n{diff}"
305
+ )
306
+
307
+
308
+ def _call_openai(diff: str) -> str:
309
+ import urllib.request
310
+ payload = json.dumps({
311
+ "model": OPENAI_MODEL,
312
+ "messages": [{"role": "user", "content": _build_prompt(diff)}],
313
+ "max_tokens": 100,
314
+ "temperature": 0.3,
315
+ }).encode()
316
+ req = urllib.request.Request(
317
+ "https://api.openai.com/v1/chat/completions",
318
+ data=payload,
319
+ headers={
320
+ "Content-Type": "application/json",
321
+ "Authorization": f"Bearer {OPENAI_KEY}",
322
+ },
323
+ )
324
+ with urllib.request.urlopen(req, timeout=30) as resp:
325
+ data = json.loads(resp.read())
326
+ return data["choices"][0]["message"]["content"].strip().strip('"')
327
+
328
+
329
+ def _call_groq(diff: str) -> str:
330
+ import urllib.request
331
+ payload = json.dumps({
332
+ "model": GROQ_MODEL,
333
+ "messages": [{"role": "user", "content": _build_prompt(diff)}],
334
+ "max_tokens": 100,
335
+ "temperature": 0.3,
336
+ }).encode()
337
+ req = urllib.request.Request(
338
+ "https://api.groq.com/openai/v1/chat/completions",
339
+ data=payload,
340
+ headers={
341
+ "Content-Type": "application/json",
342
+ "Authorization": f"Bearer {GROQ_KEY}",
343
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
344
+ },
345
+ )
346
+ with urllib.request.urlopen(req, timeout=30) as resp:
347
+ data = json.loads(resp.read())
348
+ return data["choices"][0]["message"]["content"].strip().strip('"')
349
+
350
+
351
+ def _call_ollama(diff: str) -> str:
352
+ import urllib.request
353
+ payload = json.dumps({
354
+ "model": OLLAMA_MODEL,
355
+ "prompt": _build_prompt(diff),
356
+ "stream": False,
357
+ }).encode()
358
+ req = urllib.request.Request(
359
+ f"{OLLAMA_URL}/api/generate",
360
+ data=payload,
361
+ headers={"Content-Type": "application/json"},
362
+ )
363
+ with urllib.request.urlopen(req, timeout=60) as resp:
364
+ data = json.loads(resp.read())
365
+ return data.get("response", "").strip().strip('"')
366
+
367
+
368
+ def generate_commit_message(diff: str) -> str:
369
+ """Generate AI commit message pakai spinner, lalu user confirm atau override."""
370
+ ai_msg = ""
371
+ stop = threading.Event()
372
+ frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
373
+
374
+ def _spin():
375
+ i = 0
376
+ while not stop.is_set():
377
+ sys.stdout.write(f"\r \033[2m{frames[i % len(frames)]}\033[0m Generating commit message...")
378
+ sys.stdout.flush()
379
+ time.sleep(0.08)
380
+ i += 1
381
+
382
+ t = threading.Thread(target=_spin, daemon=True)
383
+ t.start()
384
+ try:
385
+ if AI_PROVIDER == "openai":
386
+ if not OPENAI_KEY:
387
+ raise ValueError("OPENAI_API_KEY tidak ada di .env")
388
+ ai_msg = _call_openai(diff)
389
+ elif AI_PROVIDER == "groq":
390
+ if not GROQ_KEY:
391
+ raise ValueError("GROQ_API_KEY tidak ada di .env")
392
+ ai_msg = _call_groq(diff)
393
+ elif AI_PROVIDER == "ollama":
394
+ ai_msg = _call_ollama(diff)
395
+ else:
396
+ raise ValueError(f"AI provider tidak dikenal: {AI_PROVIDER}")
397
+ except Exception as exc:
398
+ ai_msg = ""
399
+ stop.set()
400
+ t.join()
401
+ sys.stdout.write("\r" + " " * 60 + "\r")
402
+ sys.stdout.flush()
403
+ console.print(f" [{C_ERR}]✗[/] Generating commit message")
404
+ ui_warn(f"AI gagal: {str(exc)}")
405
+ else:
406
+ stop.set()
407
+ t.join()
408
+ sys.stdout.write("\r" + " " * 60 + "\r")
409
+ sys.stdout.flush()
410
+ if ai_msg:
411
+ console.print(f" [{C_OK}]✓[/] Generating commit message")
412
+
413
+ if ai_msg:
414
+ console.print()
415
+ console.print(Panel(
416
+ f"[bold {C_TEXT}]{escape(ai_msg)}[/]",
417
+ title="[bold white] SUGGESTED COMMIT [/]",
418
+ title_align="left",
419
+ border_style=C_LINE,
420
+ box=SQUARE,
421
+ padding=(0, 2),
422
+ ))
423
+ console.print()
424
+ try:
425
+ override = pt_prompt(
426
+ HTML('<ansibrightblack> &gt; </ansibrightblack><ansigreen>commit </ansigreen>'),
427
+ style=PT_STYLE,
428
+ placeholder=" press Enter to accept, or type your own message",
429
+ ).strip()
430
+ except EOFError:
431
+ return ai_msg
432
+ except KeyboardInterrupt:
433
+ raise
434
+ return override if override else ai_msg
435
+
436
+ # AI failed — manual fallback
437
+ ui_warn("AI tidak bisa generate commit message. Masukkan manual:")
438
+ try:
439
+ msg = pt_prompt(
440
+ HTML('<ansibrightblack> &gt; </ansibrightblack><ansigreen>commit </ansigreen>'),
441
+ style=PT_STYLE,
442
+ placeholder=" e.g. feat(auth): add login endpoint",
443
+ ).strip()
444
+ except EOFError:
445
+ msg = ""
446
+ except KeyboardInterrupt:
447
+ raise
448
+ return msg
449
+
450
+
451
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
452
+ # VALIDATION
453
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
454
+
455
+ def validate_github_url(url: str) -> tuple[bool, str]:
456
+ """Cek apakah repo GitHub benar-benar ada. Return (valid, pesan_error)."""
457
+ import urllib.request
458
+ import urllib.error
459
+
460
+ # Normalisasi URL — ambil bagian username/repo saja
461
+ url = url.strip().rstrip("/")
462
+ if url.endswith(".git"):
463
+ url = url[:-4]
464
+
465
+ # Ekstrak path dari URL
466
+ # Support: https://github.com/user/repo atau git@github.com:user/repo
467
+ if url.startswith("git@github.com:"):
468
+ path = url.replace("git@github.com:", "")
469
+ elif "github.com/" in url:
470
+ path = url.split("github.com/")[-1]
471
+ else:
472
+ return False, "URL bukan GitHub. Gunakan format https://github.com/username/repo"
473
+
474
+ parts = path.strip("/").split("/")
475
+ if len(parts) < 2 or not parts[0] or not parts[1]:
476
+ return False, "Format URL tidak valid. Contoh: https://github.com/username/repo"
477
+
478
+ api_url = f"https://api.github.com/repos/{parts[0]}/{parts[1]}"
479
+ try:
480
+ req = urllib.request.Request(
481
+ api_url,
482
+ headers={"User-Agent": "AutoDeploy-CLI/1.5"}
483
+ )
484
+ with urllib.request.urlopen(req, timeout=10) as resp:
485
+ if resp.status == 200:
486
+ return True, ""
487
+ except urllib.error.HTTPError as e:
488
+ if e.code == 404:
489
+ return False, f"Repo tidak ditemukan: [bold]{parts[0]}/{parts[1]}[/bold]\nBuat repo baru di [cyan]https://github.com/new[/cyan] terlebih dahulu."
490
+ elif e.code == 403:
491
+ # Rate limited tapi repo kemungkinan ada
492
+ return True, ""
493
+ return False, f"GitHub mengembalikan error {e.code}."
494
+ except Exception:
495
+ # Tidak bisa cek (offline, timeout) — lanjut saja
496
+ return True, ""
497
+
498
+ return False, "Tidak bisa memvalidasi URL."
499
+
500
+
501
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
502
+ # MAIN DEPLOY FLOW
503
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
504
+
505
+ def deploy(github_url: str = ""):
506
+ ui_splash()
507
+
508
+ cwd = os.getcwd()
509
+ is_git_repo = Path(".git").is_dir()
510
+ remote = ""
511
+ branch = DEFAULT_BRANCH
512
+
513
+ # ── Inisialisasi repo baru ────────────────────────────────────────────────
514
+ if not is_git_repo:
515
+ while True:
516
+ if not github_url:
517
+ ui_clear()
518
+ ui_banner()
519
+ console.print()
520
+ console.print(Panel(
521
+ f"[{C_DIM}]Project ini belum punya [/][{C_ACCENT}].git[/][{C_DIM}].[/]\n\n"
522
+ f"[{C_WARN}]![/] [{C_VAL}]Pastikan kamu sudah membuat repo baru di GitHub terlebih dahulu.[/]\n"
523
+ f" [{C_DIM}]Buka [/][{C_ACCENT}]https://github.com/new[/][{C_DIM}] → buat repo → copy URL-nya.[/]\n\n"
524
+ f"[{C_DIM}]Lalu masukkan URL repo tersebut di bawah.[/]",
525
+ title="[bold white] NEW REPOSITORY [/]",
526
+ title_align="left",
527
+ border_style=C_LINE,
528
+ box=SQUARE,
529
+ padding=(1, 2),
530
+ ))
531
+ console.print()
532
+ try:
533
+ github_url = pt_prompt(
534
+ HTML('<ansibrightblack> &gt; </ansibrightblack><ansigreen>github url </ansigreen>'),
535
+ style=PT_STYLE,
536
+ placeholder=" https://github.com/username/repo",
537
+ ).strip()
538
+ except (KeyboardInterrupt, EOFError):
539
+ raise KeyboardInterrupt
540
+
541
+ if not github_url:
542
+ ui_warn("URL tidak boleh kosong.")
543
+ time.sleep(1)
544
+ continue
545
+
546
+ # Validasi repo
547
+ sys.stdout.write(f"\r \033[2m⠸\033[0m Checking repository...")
548
+ sys.stdout.flush()
549
+ valid, err_msg = validate_github_url(github_url)
550
+ sys.stdout.write("\r" + " " * 60 + "\r")
551
+ sys.stdout.flush()
552
+
553
+ if valid:
554
+ console.print(f" [{C_OK}]✓[/] Repository found")
555
+ break
556
+ else:
557
+ # Tampil error, tunggu Enter, lalu ulang dari awal
558
+ console.print()
559
+ console.print(Panel(
560
+ f"[{C_ERR}]✗ Repo tidak ditemukan[/]\n\n"
561
+ f"[{C_TEXT}]{err_msg}[/]",
562
+ border_style=C_ERR,
563
+ box=SQUARE,
564
+ padding=(1, 2),
565
+ ))
566
+ console.print()
567
+ try:
568
+ console.print(f" [{C_DIM}]Tekan Enter untuk coba lagi...[/]", end="")
569
+ input()
570
+ except (KeyboardInterrupt, EOFError):
571
+ raise KeyboardInterrupt
572
+ github_url = "" # reset, ulang dari awal
573
+
574
+ if not github_url:
575
+ ui_error("GitHub URL diperlukan untuk repo baru.")
576
+ sys.exit(1)
577
+
578
+ ui_step("git init")
579
+ run(["git", "init"])
580
+ run(["git", "checkout", "-b", DEFAULT_BRANCH])
581
+
582
+ ui_step("git remote add origin", github_url)
583
+ run(["git", "remote", "add", "origin", github_url])
584
+ remote = github_url
585
+
586
+ else:
587
+ remote = git_remote_url()
588
+ branch = git_current_branch()
589
+
590
+ # Repo ada tapi remote belum di-set
591
+ if not remote:
592
+ if not github_url:
593
+ console.print()
594
+ console.print(Panel(
595
+ f"[{C_DIM}]Repo ditemukan tapi belum punya remote origin. "
596
+ f"Masukkan GitHub URL.[/]",
597
+ title="[bold white] SET REMOTE [/]",
598
+ title_align="left",
599
+ border_style=C_LINE,
600
+ box=SQUARE,
601
+ padding=(1, 2),
602
+ ))
603
+ console.print()
604
+ try:
605
+ github_url = pt_prompt(
606
+ HTML('<ansibrightblack> &gt; </ansibrightblack><ansigreen>github url </ansigreen>'),
607
+ style=PT_STYLE,
608
+ placeholder=" https://github.com/username/repo",
609
+ ).strip()
610
+ except (KeyboardInterrupt, EOFError):
611
+ raise KeyboardInterrupt
612
+
613
+ if not github_url:
614
+ ui_error("GitHub URL diperlukan.")
615
+ sys.exit(1)
616
+
617
+ ui_step("git remote add origin", github_url)
618
+ run(["git", "remote", "add", "origin", github_url])
619
+ remote = github_url
620
+
621
+ branch = git_current_branch()
622
+
623
+ # ── Tampilkan header ──────────────────────────────────────────────────────
624
+ ui_clear()
625
+ ui_banner()
626
+ ui_header(cwd, branch, remote, AI_PROVIDER)
627
+ ui_tips()
628
+
629
+ # ── Cek perubahan ─────────────────────────────────────────────────────────
630
+ if not git_has_changes():
631
+ console.print(Panel(
632
+ f"[{C_DIM}]Semua file sudah up to date. Tidak ada yang perlu di-commit.[/]",
633
+ title=f"[bold {C_WARN}] NO CHANGES [/]",
634
+ title_align="left",
635
+ border_style=C_WARN,
636
+ box=SQUARE,
637
+ padding=(1, 2),
638
+ ))
639
+ console.print()
640
+ try:
641
+ console.print(f" [{C_DIM}]Press Enter to exit...[/]", end="")
642
+ input()
643
+ except (KeyboardInterrupt, EOFError):
644
+ pass
645
+ sys.exit(0)
646
+
647
+ # ── git add . ─────────────────────────────────────────────────────────────
648
+ result = spin_run(["git", "add", "."], "Staging changes")
649
+
650
+ # ── Ambil diff ────────────────────────────────────────────────────────────
651
+ diff = git_diff_staged()
652
+
653
+ # ── AI commit message ─────────────────────────────────────────────────────
654
+ commit_msg = generate_commit_message(diff)
655
+ if not commit_msg:
656
+ ui_error("Commit message kosong. Deploy dibatalkan.")
657
+ sys.exit(1)
658
+ console.print()
659
+
660
+ # ── git commit ────────────────────────────────────────────────────────────
661
+ result = spin_run(["git", "commit", "-m", commit_msg], "Creating commit")
662
+ if result.returncode != 0:
663
+ ui_error(f"git commit gagal.\n{(result.stderr or '').strip()}")
664
+ sys.exit(1)
665
+
666
+ # ── git push ──────────────────────────────────────────────────────────────
667
+ result = spin_run(["git", "push", "-u", "origin", branch], "Pushing to GitHub")
668
+ if result.returncode != 0:
669
+ ui_error(
670
+ f"git push gagal.\n\n"
671
+ f"[{C_DIM}]Kemungkinan penyebab:[/]\n"
672
+ f"- Remote URL salah\n"
673
+ f"- Belum ada akses ke repo\n"
674
+ f"- Branch belum ada di remote\n\n"
675
+ f"[{C_DIM}]{escape((result.stderr or '').strip())}[/]"
676
+ )
677
+ sys.exit(1)
678
+
679
+ ui_success(commit_msg, branch, remote)
680
+
681
+
682
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
683
+ # ENTRYPOINT
684
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
685
+
686
+ def main_cli():
687
+ """Entrypoint untuk global CLI command `deploy`."""
688
+ github_url = sys.argv[1] if len(sys.argv) > 1 else ""
689
+ try:
690
+ deploy(github_url)
691
+ except KeyboardInterrupt:
692
+ console.print()
693
+ console.print(Align.center(Text("─" * 56, style=C_DIM)))
694
+ console.print(Align.center(Text("Deploy dibatalkan", style=C_DIM)))
695
+ console.print(Align.center(Text("─" * 56, style=C_DIM)))
696
+ console.print()
697
+ sys.exit(0)
698
+
699
+
700
+ if __name__ == "__main__":
701
+ main_cli()