synapseForge 0.1.24.dev2__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 @@
1
+ """Distribution builder — compile, PyInstaller, zip."""
@@ -0,0 +1,701 @@
1
+ #!/usr/bin/env python3
2
+ """synapseForge - Distribution builder for agent repos.
3
+
4
+ Creates a self-contained zip with compiled backend, frontend, venv,
5
+ launcher executable, .env, LICENSE, and README.
6
+
7
+ Usage:
8
+ python forge.py <repo_path> "<exe_name>" [--compile]
9
+
10
+ Example:
11
+ python forge.py D:\\ia-san-juan\\4_reinas "<cliente>nombre_cliente</cliente>"
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import os
19
+ import re
20
+ import shutil
21
+ import subprocess
22
+ import sys
23
+ import tempfile
24
+ import urllib.error
25
+ import urllib.request
26
+ import zipfile
27
+ from datetime import datetime
28
+ from pathlib import Path
29
+ from string import Template
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Constants
34
+ # ---------------------------------------------------------------------------
35
+ TEMPLATES_DIR = Path(__file__).parent / "templates"
36
+ BUILD_DIR_NAME = "__forge_build__"
37
+ EMBEDDED_PYTHON_VERSION = "3.12.0"
38
+ EMBEDDED_PYTHON_URL = (
39
+ f"https://www.python.org/ftp/python/{EMBEDDED_PYTHON_VERSION}/"
40
+ f"python-{EMBEDDED_PYTHON_VERSION}-embed-amd64.zip"
41
+ )
42
+ GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py"
43
+ CACHE_DIR = Path(__file__).parent / ".cache"
44
+
45
+ # Files/folders to exclude from backend after compilation
46
+ BACKEND_EXCLUDED_DIRS: set[str] = {
47
+ "__pycache__",
48
+ }
49
+ BACKEND_EXCLUDED_FILES: set[str] = {
50
+ "agent.db",
51
+ }
52
+ # Only keep .md/.txt inside these relative paths
53
+ BACKEND_KEEP_MD_TXT_PATHS: set[str] = {
54
+ "agent/prompts",
55
+ }
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Logging helper
60
+ # ---------------------------------------------------------------------------
61
+ def _log(msg: str, *, err: bool = False) -> None:
62
+ tag = "ERR" if err else "INF"
63
+ print(f"[{tag}] {msg}", file=sys.stderr if err else sys.stdout)
64
+
65
+
66
+ def _step(n: int, total: int, label: str) -> None:
67
+ print(f"\n{'=' * 60}")
68
+ print(f" Step {n}/{total}: {label}")
69
+ print(f"{'=' * 60}")
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Path helpers
74
+ # ---------------------------------------------------------------------------
75
+ def _repo_name(repo_path: str) -> str:
76
+ """Extract the last directory component as the repo name."""
77
+ return Path(repo_path).resolve().name
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Version extraction from frontend/package.json
82
+ # ---------------------------------------------------------------------------
83
+ def _get_version(repo_path: str) -> str:
84
+ """Read version from frontend/package.json, fallback to '0.1.0'."""
85
+ pkg = Path(repo_path) / "frontend" / "package.json"
86
+ if pkg.is_file():
87
+ try:
88
+ data = json.loads(pkg.read_text(encoding="utf-8"))
89
+ return data.get("version", "0.1.0")
90
+ except (json.JSONDecodeError, OSError):
91
+ pass
92
+ return "0.1.0"
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Step: Download and setup embedded Python
97
+ # ---------------------------------------------------------------------------
98
+ def _download_file(url: str, dest: Path) -> None:
99
+ """Download a file with progress indicator."""
100
+ _log(f" Downloading {url.split('/')[-1]} ...")
101
+ try:
102
+ urllib.request.urlretrieve(url, str(dest))
103
+ except urllib.error.URLError as e:
104
+ _log(f"Download failed: {e}", err=True)
105
+ sys.exit(1)
106
+
107
+
108
+ def _setup_embedded_python(repo_path: str, build_dir: Path) -> Path:
109
+ """Download, configure, and install deps into embedded Python.
110
+
111
+ Returns the path to the configured python/ directory.
112
+ """
113
+ python_dir = build_dir / "dist" / "python"
114
+ if python_dir.is_dir():
115
+ _log("Embedded Python already configured, skipping.")
116
+ return python_dir
117
+
118
+ # Ensure cache dir
119
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
120
+
121
+ # Step A: Download embedded Python zip (cache it)
122
+ embed_zip = CACHE_DIR / f"python-{EMBEDDED_PYTHON_VERSION}-embed-amd64.zip"
123
+ if not embed_zip.is_file():
124
+ _download_file(EMBEDDED_PYTHON_URL, embed_zip)
125
+ else:
126
+ _log(f" Using cached: {embed_zip.name}")
127
+
128
+ # Step B: Extract
129
+ _log(" Extracting embedded Python ...")
130
+ shutil.unpack_archive(str(embed_zip), str(python_dir))
131
+
132
+ # Step C: Configure _pth file
133
+ pth_file = None
134
+ for f in python_dir.iterdir():
135
+ if f.name.endswith("._pth"):
136
+ pth_file = f
137
+ break
138
+ if pth_file is None:
139
+ _log("No _pth file found in embedded Python!", err=True)
140
+ sys.exit(1)
141
+
142
+ _log(f" Configuring {pth_file.name} ...")
143
+ pth_lines = pth_file.read_text(encoding="utf-8").splitlines()
144
+ new_lines: list[str] = []
145
+ has_site_packages = False
146
+ for line in pth_lines:
147
+ stripped = line.strip()
148
+ if stripped == "#import site":
149
+ new_lines.append("import site")
150
+ elif stripped == "Lib\\site-packages":
151
+ has_site_packages = True
152
+ new_lines.append(line)
153
+ else:
154
+ new_lines.append(line)
155
+ if not has_site_packages:
156
+ new_lines.append("Lib\\site-packages")
157
+ pth_file.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
158
+
159
+ # Step D: Ensure empty DLLs dir (some packages need it)
160
+ dlls_dir = python_dir / "DLLs"
161
+ dlls_dir.mkdir(exist_ok=True)
162
+
163
+ # Step E: Install pip
164
+ get_pip = CACHE_DIR / "get-pip.py"
165
+ if not get_pip.is_file():
166
+ _download_file(GET_PIP_URL, get_pip)
167
+
168
+ _log(" Installing pip ...")
169
+ subprocess.run(
170
+ [str(python_dir / "python.exe"), str(get_pip)],
171
+ check=True, capture_output=True, text=True,
172
+ )
173
+
174
+ # Step F: Install dependencies from repo requirements.txt
175
+ req_file = Path(repo_path) / "requirements.txt"
176
+ if req_file.is_file():
177
+ _log(" Installing dependencies from requirements.txt ...")
178
+ result = subprocess.run(
179
+ [
180
+ str(python_dir / "python.exe"), "-m", "pip", "install",
181
+ "--target", str(python_dir / "Lib" / "site-packages"),
182
+ "-r", str(req_file),
183
+ ],
184
+ capture_output=True, text=True,
185
+ )
186
+ if result.returncode != 0:
187
+ _log("pip install failed:", err=True)
188
+ for line in result.stderr.splitlines():
189
+ _log(f" {line}", err=True)
190
+ sys.exit(1)
191
+ # Show installed packages count
192
+ lines = [l for l in result.stdout.splitlines() if "Installed" in l or "installed" in l]
193
+ for l in lines:
194
+ _log(f" {l.strip()}")
195
+ else:
196
+ _log("No requirements.txt found, skipping dep install.", err=True)
197
+
198
+ _log(f"Embedded Python ready: {python_dir}")
199
+ return python_dir
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # Step 1: Build frontend
204
+ # ---------------------------------------------------------------------------
205
+ def _build_frontend(repo_path: str) -> None:
206
+ """Run npm run build in the repo's frontend directory."""
207
+ frontend_dir = Path(repo_path) / "frontend"
208
+ dist_dir = frontend_dir / "dist"
209
+
210
+ if not (frontend_dir / "package.json").is_file():
211
+ _log("No frontend/package.json found — skipping npm build.", err=True)
212
+ return
213
+
214
+ _log("Running npm install...")
215
+ subprocess.run(
216
+ "npm install",
217
+ cwd=str(frontend_dir),
218
+ check=True,
219
+ capture_output=True,
220
+ text=True,
221
+ shell=True,
222
+ )
223
+
224
+ _log("Running npm run build (MODE=prod)...")
225
+ env = os.environ.copy()
226
+ env["VITE_MODE"] = "prod"
227
+ subprocess.run(
228
+ "npm run build",
229
+ cwd=str(frontend_dir),
230
+ check=True,
231
+ capture_output=True,
232
+ text=True,
233
+ shell=True,
234
+ env=env,
235
+ )
236
+
237
+ if not dist_dir.is_dir():
238
+ _log("Frontend build completed but dist/ not found!", err=True)
239
+ sys.exit(1)
240
+
241
+ _log(f"Frontend built: {dist_dir}")
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # Step 2: Compile backend to .pyc
246
+ # ---------------------------------------------------------------------------
247
+ def _compile_backend(repo_path: str) -> None:
248
+ """Compile all .py files to legacy .pyc alongside the originals."""
249
+ backend_dir = Path(repo_path) / "backend"
250
+ if not backend_dir.is_dir():
251
+ _log(f"backend/ not found at {backend_dir}", err=True)
252
+ sys.exit(1)
253
+
254
+ _log("Compiling backend/ to .pyc ...")
255
+ result = subprocess.run(
256
+ [sys.executable, "-m", "compileall", "-b", str(backend_dir)],
257
+ capture_output=True,
258
+ text=True,
259
+ )
260
+ if result.returncode != 0:
261
+ _log("Compilation completed with errors (may be ok).", err=True)
262
+ for line in result.stderr.splitlines():
263
+ _log(f" {line}", err=True)
264
+ else:
265
+ _log("Compilation OK.")
266
+
267
+
268
+ # ---------------------------------------------------------------------------
269
+ # Step 3: Create clean backend copy (exclude unwanted files)
270
+ # ---------------------------------------------------------------------------
271
+ def _clean_backend_copy(repo_path: str, *, compiled: bool) -> Path:
272
+ """Copy backend, excluding unwanted files.
273
+
274
+ When ``compiled`` is True only ``.pyc`` files are kept (``.py`` excluded).
275
+ Otherwise the original ``.py`` sources are kept (``.pyc`` excluded).
276
+
277
+ Returns the path to the clean backend directory.
278
+ """
279
+ src = Path(repo_path) / "backend"
280
+ dst = Path(repo_path) / BUILD_DIR_NAME / "dist" / "backend"
281
+
282
+ if dst.exists():
283
+ shutil.rmtree(dst)
284
+
285
+ _log(f"Copying backend (clean) -> {dst} ...")
286
+
287
+ for root, dirs, files in os.walk(str(src)):
288
+ rel = Path(root).relative_to(src)
289
+
290
+ # Skip excluded directories
291
+ dirs[:] = [d for d in dirs if d not in BACKEND_EXCLUDED_DIRS]
292
+
293
+ # Determine destination path
294
+ dest_dir = dst / rel
295
+ dest_dir.mkdir(parents=True, exist_ok=True)
296
+
297
+ for fname in files:
298
+ src_file = Path(root) / fname
299
+ dest_file = dest_dir / fname
300
+
301
+ # Compiled build: skip .py sources (only keep .pyc).
302
+ # Source build: skip .pyc (only keep .py).
303
+ if compiled and fname.endswith(".py") and not fname.endswith(".pyc"):
304
+ _log(f" Excluding: {rel / fname}")
305
+ continue
306
+ if not compiled and fname.endswith(".pyc"):
307
+ _log(f" Excluding: {rel / fname}")
308
+ continue
309
+
310
+ # Skip excluded files
311
+ if fname in BACKEND_EXCLUDED_FILES:
312
+ _log(f" Excluding: {rel / fname}")
313
+ continue
314
+
315
+ # For .md and .txt: only keep if in prompts/ or allowed paths
316
+ if fname.endswith((".md", ".txt")):
317
+ rel_str = str(rel.as_posix())
318
+ keep = any(rel_str.startswith(allowed) for allowed in BACKEND_KEEP_MD_TXT_PATHS)
319
+ if not keep:
320
+ _log(f" Excluding: {rel / fname}")
321
+ continue
322
+
323
+ shutil.copy2(str(src_file), str(dest_file))
324
+
325
+ _log(f"Clean backend copy: {dst}")
326
+ return dst
327
+
328
+
329
+ # ---------------------------------------------------------------------------
330
+ # Step 4: Generate launcher and build executable
331
+ # ---------------------------------------------------------------------------
332
+ def _generate_launcher(repo_path: str, exe_name: str) -> str:
333
+ """Generate a customized launcher.py from the template.
334
+
335
+ Returns the path to the generated launcher file.
336
+ """
337
+ template_file = TEMPLATES_DIR / "launcher.py"
338
+ if not template_file.is_file():
339
+ _log(f"Launcher template not found: {template_file}", err=True)
340
+ sys.exit(1)
341
+
342
+ template_src = template_file.read_text(encoding="utf-8")
343
+
344
+ # Replace placeholders
345
+ launcher_src = (
346
+ template_src
347
+ .replace("{{APP_MODULE}}", "backend.main:app")
348
+ .replace("{{PORT}}", "8000")
349
+ .replace("{{EXE_NAME}}", exe_name)
350
+ )
351
+
352
+ build_dir = Path(repo_path) / BUILD_DIR_NAME
353
+ build_dir.mkdir(parents=True, exist_ok=True)
354
+ launcher_path = build_dir / "launcher.py"
355
+ launcher_path.write_text(launcher_src, encoding="utf-8")
356
+
357
+ _log(f"Launcher generated: {launcher_path}")
358
+ return str(launcher_path)
359
+
360
+
361
+ def _build_executable(repo_path: str, launcher_path: str, exe_name: str) -> Path:
362
+ """Run PyInstaller to create the executable.
363
+
364
+ Returns the path to the generated executable.
365
+ """
366
+ build_dir = Path(repo_path) / BUILD_DIR_NAME
367
+
368
+ # Determine icon path (try common locations)
369
+ icon_candidates = [
370
+ Path(repo_path) / "frontend" / "src" / "assets" / " logo_cliente.ico",
371
+ Path(repo_path) / "frontend" / "public" / "logo.ico",
372
+ Path(repo_path) / "logo.ico",
373
+ ]
374
+ icon_path = None
375
+ for cand in icon_candidates:
376
+ if cand.is_file():
377
+ icon_path = cand
378
+ break
379
+
380
+ _log(f"Building executable '{exe_name}.exe' with PyInstaller ...")
381
+
382
+ cmd = [
383
+ sys.executable, "-m", "PyInstaller",
384
+ "--clean",
385
+ "--onefile",
386
+ "--noconsole",
387
+ "--name", exe_name,
388
+ "--distpath", str(build_dir / "dist"),
389
+ "--workpath", str(build_dir / "temp"),
390
+ "--specpath", str(build_dir / "temp"),
391
+ ]
392
+ if icon_path:
393
+ cmd.extend(["--icon", str(icon_path)])
394
+
395
+ cmd.append(launcher_path)
396
+
397
+ _log(f" PyInstaller command: {' '.join(cmd)}")
398
+
399
+ result = subprocess.run(cmd, capture_output=True, text=True)
400
+ if result.returncode != 0:
401
+ _log("PyInstaller build FAILED:", err=True)
402
+ for line in result.stderr.splitlines():
403
+ _log(f" {line}", err=True)
404
+ for line in result.stdout.splitlines():
405
+ if "ERROR" in line or "Error" in line or "Traceback" in line:
406
+ _log(f" {line}", err=True)
407
+ sys.exit(1)
408
+
409
+ exe_path = build_dir / "dist" / f"{exe_name}.exe"
410
+ if not exe_path.is_file():
411
+ _log(f"Executable not found at {exe_path}", err=True)
412
+ sys.exit(1)
413
+
414
+ _log(f"Executable: {exe_path} ({exe_path.stat().st_size / 1024 / 1024:.1f} MB)")
415
+ return exe_path
416
+
417
+
418
+ # ---------------------------------------------------------------------------
419
+ # Step 5: Build zip
420
+ # ---------------------------------------------------------------------------
421
+ def _build_zip(repo_path: str, exe_path: Path, exe_name: str) -> Path:
422
+ """Package everything into a zip file.
423
+
424
+ Contents:
425
+ - {exe_name}.exe
426
+ - backend/ (compiled .pyc, cleaned)
427
+ - frontend/dist/ (from repo)
428
+ - python/ (embedded Python + deps)
429
+ - .env (from repo)
430
+ - LICENSE (from repo)
431
+ - README.md (from repo)
432
+ """
433
+ repo = Path(repo_path)
434
+ version = _get_version(repo_path)
435
+ repo_name = _repo_name(repo_path)
436
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
437
+ zip_name = f"{repo_name}-v{version}.zip"
438
+ zip_path = Path(repo_path) / BUILD_DIR_NAME / zip_name
439
+
440
+ _log(f"Creating zip: {zip_name} ...")
441
+
442
+ with zipfile.ZipFile(str(zip_path), "w", zipfile.ZIP_DEFLATED) as zf:
443
+ entry_count = 0
444
+
445
+ # 1. Executable
446
+ zf.write(str(exe_path), exe_path.name)
447
+ entry_count += 1
448
+ _log(f" Added: {exe_path.name}")
449
+
450
+ # 2. backend/ (cleaned copy)
451
+ backend_src = Path(repo_path) / BUILD_DIR_NAME / "dist" / "backend"
452
+ if backend_src.is_dir():
453
+ for root, dirs, files in os.walk(str(backend_src)):
454
+ rel = Path(root).relative_to(backend_src)
455
+ for fname in files:
456
+ arcname = f"backend/{rel / fname}"
457
+ zf.write(os.path.join(root, fname), arcname)
458
+ entry_count += 1
459
+ _log(f" Added: backend/ (cleaned, .pyc)")
460
+
461
+ # 3. frontend/dist/
462
+ frontend_dist = repo / "frontend" / "dist"
463
+ if frontend_dist.is_dir():
464
+ for root, dirs, files in os.walk(str(frontend_dist)):
465
+ rel = Path(root).relative_to(frontend_dist)
466
+ for fname in files:
467
+ arcname = f"frontend/dist/{rel / fname}"
468
+ zf.write(os.path.join(root, fname), arcname)
469
+ entry_count += 1
470
+ _log(f" Added: frontend/dist/")
471
+
472
+ # 4. Embedded Python (portable, no hardcoded paths)
473
+ python_src = Path(repo_path) / BUILD_DIR_NAME / "dist" / "python"
474
+ if python_src.is_dir():
475
+ for root, dirs, files in os.walk(str(python_src)):
476
+ rel = Path(root).relative_to(python_src)
477
+ # Skip __pycache__
478
+ dirs[:] = [d for d in dirs if d != "__pycache__"]
479
+ for fname in files:
480
+ arcname = f"python/{rel / fname}"
481
+ zf.write(os.path.join(root, fname), arcname)
482
+ entry_count += 1
483
+ _log(f" Added: python/ (embedded Python + deps)")
484
+
485
+ # 5. .env
486
+ env_file = repo / ".env"
487
+ if env_file.is_file():
488
+ zf.write(str(env_file), ".env")
489
+ entry_count += 1
490
+ _log(f" Added: .env")
491
+
492
+ # 6. LICENSE
493
+ license_file = repo / "LICENSE"
494
+ if license_file.is_file():
495
+ zf.write(str(license_file), "LICENSE")
496
+ entry_count += 1
497
+ _log(f" Added: LICENSE")
498
+
499
+ # 7. README.md
500
+ readme_file = repo / "README.md"
501
+ if readme_file.is_file():
502
+ zf.write(str(readme_file), "README.md")
503
+ entry_count += 1
504
+ _log(f" Added: README.md")
505
+
506
+ _log(f" Total entries: {entry_count}")
507
+
508
+ _log(f"\nZip created: {zip_path} ({zip_path.stat().st_size / 1024 / 1024:.1f} MB)")
509
+ return zip_path
510
+
511
+
512
+ # ---------------------------------------------------------------------------
513
+ # Cleanup
514
+ # ---------------------------------------------------------------------------
515
+ def _cleanup(repo_path: str, *, keep_zip: bool = True, compiled: bool = True) -> Path | None:
516
+ """Remove build artifacts from repo. Returns final zip path if kept."""
517
+ build_dir = Path(repo_path) / BUILD_DIR_NAME
518
+ final_zip: Path | None = None
519
+
520
+ if not build_dir.is_dir():
521
+ return None
522
+
523
+ # Keep only the zip if requested
524
+ if keep_zip:
525
+ zip_files = list(build_dir.glob("*.zip"))
526
+ # Move zip files out first, THEN delete the build directory
527
+ for zf in zip_files:
528
+ dest = Path(repo_path) / zf.name
529
+ shutil.move(str(zf), str(dest))
530
+ final_zip = dest
531
+ _log(f"Zip moved to: {dest}")
532
+ shutil.rmtree(str(build_dir))
533
+
534
+ # Also clean .pyc files from original backend (only if we compiled them)
535
+ backend_dir = Path(repo_path) / "backend"
536
+ if compiled and backend_dir.is_dir():
537
+ _log("Cleaning .pyc files from original backend/ ...")
538
+ for pyc in backend_dir.rglob("*.pyc"):
539
+ pyc.unlink(missing_ok=True)
540
+
541
+ return final_zip
542
+
543
+
544
+ # ---------------------------------------------------------------------------
545
+ # Main orchestrator
546
+ # ---------------------------------------------------------------------------
547
+ def build(
548
+ repo_path: str,
549
+ exe_name: str,
550
+ *,
551
+ skip_frontend: bool = False,
552
+ use_embed: bool = True,
553
+ compile_backend: bool = False,
554
+ ) -> Path | None:
555
+ """Run the full build pipeline.
556
+
557
+ Args:
558
+ repo_path: Absolute path to the repository root.
559
+ exe_name: Name for the generated executable.
560
+ skip_frontend: Skip npm build and reuse existing ``frontend/dist``.
561
+ use_embed: Download/configure embedded Python instead of using a venv.
562
+ compile_backend: Compile ``backend/`` to ``.pyc`` (default: ship
563
+ the ``.py`` sources as-is).
564
+
565
+ Returns the path to the generated zip file, or None if build failed.
566
+ """
567
+ repo_path = str(Path(repo_path).resolve())
568
+ total_steps = 7 if not skip_frontend else 6
569
+ final_zip: Path | None = None
570
+ build_dir = Path(repo_path) / BUILD_DIR_NAME
571
+
572
+ _log(f"Starting build for: {repo_path}")
573
+ _log(f"Executable name: {exe_name}")
574
+ _log(f"Repo name: {_repo_name(repo_path)}")
575
+ _log(f"Version: {_get_version(repo_path)}")
576
+ _log(f"Python: embedded ({EMBEDDED_PYTHON_VERSION})" if use_embed else f"Python: venv (.{{repo}})")
577
+ _log(f"Download: {EMBEDDED_PYTHON_URL}" if use_embed else "")
578
+ _log(f"Backend: {'compiled (.pyc)' if compile_backend else 'source (.py)'}")
579
+
580
+ # Validate
581
+ if not Path(repo_path).is_dir():
582
+ _log(f"Repo path not found: {repo_path}", err=True)
583
+ sys.exit(1)
584
+
585
+ try:
586
+ current_step = 0
587
+
588
+ # Step 1 — Build frontend
589
+ if not skip_frontend:
590
+ current_step += 1
591
+ _step(current_step, total_steps, "Build frontend (npm run build)")
592
+ _build_frontend(repo_path)
593
+
594
+ # Step 2 — Compile backend (optional)
595
+ if compile_backend:
596
+ current_step += 1
597
+ _step(current_step, total_steps, "Compile backend to .pyc")
598
+ _compile_backend(repo_path)
599
+
600
+ # Step 3 — Clean backend copy
601
+ current_step += 1
602
+ _step(current_step, total_steps, "Create clean backend copy")
603
+ _clean_backend_copy(repo_path, compiled=compile_backend)
604
+
605
+ # Step 4 — Setup embedded Python (download, pip, deps)
606
+ if use_embed:
607
+ current_step += 1
608
+ _step(current_step, total_steps, "Setup embedded Python + dependencies")
609
+ _setup_embedded_python(repo_path, build_dir)
610
+ else:
611
+ _log("Using existing venv (--no-embed).")
612
+
613
+ # Step 5 — Generate launcher
614
+ current_step += 1
615
+ _step(current_step, total_steps, "Generate launcher and build executable")
616
+ launcher_path = _generate_launcher(repo_path, exe_name)
617
+
618
+ # Step 6 — Build executable
619
+ current_step += 1
620
+ _step(current_step, total_steps, "Build executable with PyInstaller")
621
+ exe_path = _build_executable(repo_path, launcher_path, exe_name)
622
+
623
+ # Step 7 — Create zip & cleanup
624
+ current_step += 1
625
+ _step(current_step, total_steps, "Package distribution zip")
626
+ zip_path = _build_zip(repo_path, exe_path, exe_name)
627
+ _log("Cleaning build artifacts ...")
628
+ final_zip = _cleanup(repo_path, keep_zip=True, compiled=compile_backend)
629
+
630
+ print(f"\n{'=' * 60}")
631
+ print(f" BUILD COMPLETE")
632
+ print(f" Output: {final_zip}")
633
+ print(f"{'=' * 60}\n")
634
+
635
+ except Exception:
636
+ _log("Build FAILED — cleaning temporary files ...", err=True)
637
+ _cleanup(repo_path, keep_zip=False, compiled=compile_backend)
638
+ raise
639
+
640
+ return final_zip
641
+
642
+
643
+ # ---------------------------------------------------------------------------
644
+ # CLI entry point
645
+ # ---------------------------------------------------------------------------
646
+ def main() -> None:
647
+ parser = argparse.ArgumentParser(
648
+ description="synapseForge - Build distribution zip for agent repos",
649
+ formatter_class=argparse.RawDescriptionHelpFormatter,
650
+ epilog=(
651
+ "Examples:\n"
652
+ " python forge.py D:\\ia-san-juan\\4_reinas \"<cliente>nombre_cliente</cliente>\"\n"
653
+ " python forge.py /home/user/my-repo \"Mi App\" --skip-frontend\n"
654
+ ),
655
+ )
656
+ parser.add_argument(
657
+ "repo_path",
658
+ help="Absolute path to the repository root",
659
+ )
660
+ parser.add_argument(
661
+ "exe_name",
662
+ help="Name for the executable (e.g. '<cliente>nombre_cliente</cliente>')",
663
+ )
664
+ parser.add_argument(
665
+ "--skip-frontend",
666
+ action="store_true",
667
+ help="Skip frontend build (use existing dist/)",
668
+ )
669
+ parser.add_argument(
670
+ "--no-embed",
671
+ action="store_true",
672
+ help="Use existing venv instead of downloading embedded Python",
673
+ )
674
+ parser.add_argument(
675
+ "--compile",
676
+ "-c",
677
+ action="store_true",
678
+ help="Compile backend/ to .pyc (default: ship .py sources as-is)",
679
+ )
680
+ parser.add_argument(
681
+ "--output",
682
+ "-o",
683
+ help="Output directory for the zip (default: repo root)",
684
+ )
685
+
686
+ args = parser.parse_args()
687
+ try:
688
+ build(
689
+ args.repo_path,
690
+ args.exe_name,
691
+ skip_frontend=args.skip_frontend,
692
+ use_embed=not args.no_embed,
693
+ compile_backend=args.compile,
694
+ )
695
+ except Exception as e:
696
+ _log(f"ERROR: {e}", err=True)
697
+ sys.exit(1)
698
+
699
+
700
+ if __name__ == "__main__":
701
+ main()