biller-cli 0.1.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.
Files changed (33) hide show
  1. biller_cli/__init__.py +0 -0
  2. biller_cli/ai/__init__.py +397 -0
  3. biller_cli/ai/ingest.py +394 -0
  4. biller_cli/commands/down.py +96 -0
  5. biller_cli/commands/downgrade.py +142 -0
  6. biller_cli/commands/init.py +210 -0
  7. biller_cli/commands/up.py +138 -0
  8. biller_cli/commands/upgrade.py +271 -0
  9. biller_cli/main.py +34 -0
  10. biller_cli/templates/biller-user-layer/pom.xml +100 -0
  11. biller_cli/templates/biller-user-layer/src/main/java/bharat/connect/biller/ApplicationContext.java +13 -0
  12. biller_cli/templates/biller-user-layer/src/main/java/bharat/connect/biller/config/DatabaseConfig.java +35 -0
  13. biller_cli/templates/biller-user-layer/src/main/java/bharat/connect/biller/config/WebCorsConfig.java +21 -0
  14. biller_cli/templates/biller-user-layer/src/main/java/bharat/connect/biller/controller/BbpsRequestController.java +98 -0
  15. biller_cli/templates/biller-user-layer/src/main/java/bharat/connect/biller/dao/impl/BillFetchDaoImpl.java +135 -0
  16. biller_cli/templates/biller-user-layer/src/main/java/bharat/connect/biller/dao/impl/BillPaymentDaoImpl.java +100 -0
  17. biller_cli/templates/biller-user-layer/src/main/java/bharat/connect/biller/provider/BillingProviderConfig.java +34 -0
  18. biller_cli/templates/biller-user-layer/src/main/java/bharat/connect/biller/provider/BillingProviderProperties.java +92 -0
  19. biller_cli/templates/biller-user-layer/src/main/java/bharat/connect/biller/provider/impl/CsvBillingProvider.java +257 -0
  20. biller_cli/templates/biller-user-layer/src/main/java/bharat/connect/biller/provider/impl/ExcelBillingProvider.java +261 -0
  21. biller_cli/templates/biller-user-layer/src/main/java/bharat/connect/biller/provider/impl/PostgresBillingProvider.java +210 -0
  22. biller_cli/templates/biller-user-layer/src/main/java/bharat/connect/biller/service/impl/BillFetchServiceImpl.java +131 -0
  23. biller_cli/templates/biller-user-layer/src/main/java/bharat/connect/biller/service/impl/BillPaymentServiceImpl.java +175 -0
  24. biller_cli/templates/biller-user-layer/src/main/java/bharat/connect/biller/service/impl/HeartbeatServiceImpl.java +136 -0
  25. biller_cli/templates/biller-user-layer/src/main/resources/application.properties +88 -0
  26. biller_cli/templates/pom.xml +33 -0
  27. biller_cli/utils/preflight.py +151 -0
  28. biller_cli/utils/secrets.py +37 -0
  29. biller_cli/utils/version_pin.py +67 -0
  30. biller_cli-0.1.0.dist-info/METADATA +17 -0
  31. biller_cli-0.1.0.dist-info/RECORD +33 -0
  32. biller_cli-0.1.0.dist-info/WHEEL +4 -0
  33. biller_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,210 @@
1
+ import os
2
+ import shutil
3
+ import typer
4
+ import yaml
5
+ import requests
6
+ from pathlib import Path
7
+ from rich.console import Console
8
+ from rich.prompt import Prompt, Confirm
9
+
10
+ # Import our custom utilities
11
+ from biller_cli.utils.preflight import run_preflight, RuntimeMode
12
+ from biller_cli.utils.secrets import write_env_file
13
+
14
+ app = typer.Typer(help="Scaffold a new project.")
15
+ console = Console()
16
+
17
+ # Fallback registry URL (we will build the real FastAPI registry in Phase 2)
18
+ REGISTRY_URL = os.getenv("BILLER_REGISTRY_URL", "https://biller-registry-production.up.railway.app")
19
+
20
+ def _fetch_bous() -> list[dict]:
21
+ """Fetch available BOUs from the central registry."""
22
+ try:
23
+ resp = requests.get(f"{REGISTRY_URL}/api/v1/bous", timeout=5)
24
+ resp.raise_for_status()
25
+ return resp.json().get("bous", [])
26
+ except requests.RequestException:
27
+ console.print(f"[yellow]⚠ Cannot reach registry at {REGISTRY_URL}[/yellow]")
28
+ console.print("[dim]Using fallback local BOU list for offline development.[/dim]")
29
+ return [
30
+ {
31
+ "id": "nsdl",
32
+ "name": "NSDL Payments Bank",
33
+ "auth_header": "X-NSDL-Auth",
34
+ "environment": {
35
+ "dev": "https://uat-bou.nsdlbank.com/bbps/callback",
36
+ "prod": "https://bou.nsdlbank.com/bbps/callback"
37
+ }
38
+ },
39
+ {
40
+ "id": "fino",
41
+ "name": "Fino Payments Bank",
42
+ "auth_header": "X-Fino-Auth",
43
+ "environment": {
44
+ "dev": "https://uat-bou.finopayments.com/bbps/callback",
45
+ "prod": "https://bou.finopayments.com/bbps/callback"
46
+ }
47
+ }
48
+ ]
49
+
50
+ def _select_bou(bous: list[dict], environment: str) -> dict:
51
+ """Show a numbered list of BOUs and let the developer pick one."""
52
+ console.print("\n[bold]Select your target BOU:[/bold]")
53
+ for i, bou in enumerate(bous, 1):
54
+ console.print(f" {i}. {bou['name']}")
55
+
56
+ while True:
57
+ raw = Prompt.ask(">", default="1")
58
+ try:
59
+ idx = int(raw) - 1
60
+ if 0 <= idx < len(bous):
61
+ selected = bous[idx]
62
+ callback_url = selected["environment"].get(
63
+ environment,
64
+ selected["environment"].get("dev", "URL_NOT_FOUND")
65
+ )
66
+ console.print(
67
+ f"\n[green]✓[/green] {selected['name']} selected\n"
68
+ f" Callback URL ({environment}): [dim]{callback_url}[/dim]\n"
69
+ )
70
+ return {**selected, "resolved_callback_url": callback_url}
71
+ except (ValueError, IndexError):
72
+ pass
73
+ console.print("[red]Invalid selection. Enter a valid number from the list.[/red]")
74
+
75
+ @app.callback(invoke_without_command=True)
76
+ def init_project(
77
+ output_dir: Path = typer.Option(Path("."), "--output", "-o", help="Directory to create the project in."),
78
+ ):
79
+ """Scaffold a new Biller Integrator project interactively."""
80
+ runtime = run_preflight()
81
+
82
+ console.rule("[bold blue]Biller Integrator Setup Wizard[/bold blue]")
83
+
84
+ if runtime == RuntimeMode.NATIVE:
85
+ console.print("[yellow]Notice: Docker not found. Project will be configured for native Maven mode.[/yellow]\n")
86
+
87
+ # 1. Base Prompts
88
+ biller_name = Prompt.ask("Biller Name (e.g., electricity-board)")
89
+ environment = Prompt.ask("Environment", choices=["dev", "staging", "prod"], default="dev")
90
+
91
+ # 1b. Runtime Selection
92
+ runtime_choice = Prompt.ask("Runtime mode", choices=["docker", "native"], default="native")
93
+
94
+ # 2. BOU Selection
95
+ console.print("\n[dim]Fetching available BOUs from registry...[/dim]")
96
+ bous = _fetch_bous()
97
+ if not bous:
98
+ console.print("[red]Error: No BOUs available. Cannot proceed.[/red]")
99
+ raise typer.Exit(1)
100
+
101
+ selected_bou = _select_bou(bous, environment)
102
+
103
+ # 3. BBPS Specifics & Optional Secrets
104
+ biller_id = Prompt.ask(
105
+ "Biller ID [dim](Press Enter to set later — assigned by BBPS after registration)[/dim]",
106
+ default="PENDING",
107
+ )
108
+
109
+ console.print("\n[dim]Sensitive credentials (can be left blank for Day 0 setup):[/dim]")
110
+ bou_auth_token = Prompt.ask("BOU Auth Token [dim](Press Enter to skip)[/dim]", password=True, default="")
111
+ bbps_api_key = Prompt.ask("BBPS API Key [dim](Press Enter to skip)[/dim]", password=True, default="")
112
+
113
+ secrets = {
114
+ "bou_auth_token": bou_auth_token if bou_auth_token else "PENDING",
115
+ "bbps_api_key": bbps_api_key if bbps_api_key else "PENDING",
116
+ "bbps_cert_path": "CERT_PATH_PLACEHOLDER" # Replaced after target_path is resolved
117
+ }
118
+
119
+ # 4. Overwrite Protection
120
+ target_path = output_dir / biller_name
121
+ if target_path.exists():
122
+ if not Confirm.ask(f"[yellow]Directory '{target_path}' already exists. Overwrite contents?[/yellow]", default=False):
123
+ console.print("Aborting.")
124
+ raise typer.Exit(0)
125
+ else:
126
+ target_path.mkdir(parents=True)
127
+ secrets["bbps_cert_path"] = str(target_path.absolute() / "certs" / "ousigner.p12")
128
+
129
+ # 5. Scaffolding Java Template
130
+ template_dir = Path(__file__).parent.parent / "templates"
131
+ if not template_dir.exists():
132
+ console.print(f"[red]Fatal: Template directory not found at {template_dir}[/red]")
133
+ raise typer.Exit(1)
134
+
135
+ console.print(f"\n[dim]Copying template to {target_path}...[/dim]")
136
+ shutil.copytree(template_dir, target_path, dirs_exist_ok=True)
137
+
138
+ # 6. Scaffold Certs Directory & Gitignore
139
+ certs_dir = target_path / "certs"
140
+ certs_dir.mkdir(exist_ok=True)
141
+ (certs_dir / ".gitignore").write_text("# Never commit certificates\n*.p12\n*.pfx\n")
142
+ console.print(f"[dim]Created isolated certs/ directory.[/dim]")
143
+
144
+ # 7. Generate docker-compose.yml dynamically
145
+ compose_content = f"""services:
146
+ biller-middleware:
147
+ build: .
148
+ env_file:
149
+ - .env.biller
150
+ environment:
151
+ - BILLER_NAME={biller_name}
152
+ - BILLER_ID={biller_id}
153
+ - BILLER_ENV={environment}
154
+ - BBPS_CERT_PATH=/app/certs/ousigner.p12
155
+ ports:
156
+ - "8080:8080"
157
+ volumes:
158
+ - ./certs:/app/certs:ro
159
+ """
160
+ (target_path / "docker-compose.yml").write_text(compose_content)
161
+
162
+ # 8. Generate basic Dockerfile for the User Layer
163
+ dockerfile_content = """FROM eclipse-temurin:17-jdk-alpine
164
+ VOLUME /tmp
165
+ COPY biller-user-layer/target/biller-user-layer-*.jar app.jar
166
+ ENTRYPOINT ["java","-jar","/app.jar"]
167
+ """
168
+ (target_path / "Dockerfile").write_text(dockerfile_content)
169
+
170
+ # 9. Write biller.yaml config
171
+ biller_yaml_data = {
172
+ "version": "1",
173
+ "biller": {
174
+ "name": biller_name,
175
+ "id": biller_id,
176
+ "environment": environment,
177
+ },
178
+ "bou": {
179
+ "id": selected_bou["id"],
180
+ "name": selected_bou["name"],
181
+ "callback_url": selected_bou["resolved_callback_url"],
182
+ "auth_header": selected_bou["auth_header"],
183
+ },
184
+ "core": {"version": "1.0.1"},
185
+ "runtime": runtime_choice,
186
+ }
187
+
188
+ yaml_path = target_path / "biller.yaml"
189
+ yaml_path.write_text(yaml.dump(biller_yaml_data, default_flow_style=False, sort_keys=False))
190
+
191
+ # 10. Manage Secrets
192
+ write_env_file(target_path, secrets)
193
+
194
+ # 11. Interpolate POM Pin
195
+ pom_path = target_path / "biller-user-layer" / "pom.xml"
196
+ if pom_path.exists():
197
+ pom_content = pom_path.read_text().replace("{{BILLER_CORE_VERSION}}", "1.0.1")
198
+ pom_path.write_text(pom_content)
199
+
200
+ # Wrap up UX
201
+ console.print(f"\n[bold green]✓ Project '{biller_name}' successfully scaffolded![/bold green]")
202
+
203
+ if biller_id == "PENDING" or not bou_auth_token:
204
+ console.print("\n[yellow]Day 0 Notice:[/yellow] Your Biller ID and/or secrets are PENDING.")
205
+ console.print(f"When ready, drop your certificate into [bold]./{target_path}/certs/ousigner.p12[/bold]")
206
+ console.print("Then update your credentials via:")
207
+ console.print(f" [bold]cd {target_path} && biller-cli config set biller.id YOUR_ID[/bold]\n")
208
+
209
+ console.print(f"Detected Runtime: [bold]{runtime.value}[/bold]")
210
+ console.print(f"Next step: [bold]cd {target_path} && biller-cli up[/bold]")
@@ -0,0 +1,138 @@
1
+ import os
2
+ import time
3
+ import subprocess
4
+ import yaml
5
+ import psutil
6
+ import typer
7
+ import requests
8
+ from pathlib import Path
9
+ from rich.console import Console
10
+
11
+ from biller_cli.utils.preflight import run_preflight, RuntimeMode
12
+
13
+ app = typer.Typer(help="Start the Biller middleware stack.")
14
+ console = Console()
15
+
16
+ PID_FILE_NAME = ".biller/biller.pid"
17
+ LOG_FILE_NAME = ".biller/biller.log"
18
+
19
+ def _wait_for_health(timeout: int = 90):
20
+ """Polls the Spring Boot actuator endpoint to ensure the service is up."""
21
+ deadline = time.time() + timeout
22
+ while time.time() < deadline:
23
+ try:
24
+ r = requests.get("http://localhost:8111/health", timeout=2)
25
+ if r.status_code == 200:
26
+ console.print("[green]✓ Biller stack is running and healthy.[/green]")
27
+ console.print("Health check: [bold]curl http://localhost:8111/health[/bold]")
28
+ return
29
+ except requests.RequestException:
30
+ pass
31
+ time.sleep(2)
32
+
33
+ console.print("[yellow]⚠ Stack may still be starting, or health check timed out.[/yellow]")
34
+ console.print("Check manually: [bold]curl http://localhost:8111/health[/bold]")
35
+
36
+ def _up_docker(project_dir: Path, detach: bool):
37
+ """Orchestrates the Docker Compose stack."""
38
+ compose_file = project_dir / "docker-compose.yml"
39
+ if not compose_file.exists():
40
+ console.print("[red]Fatal: docker-compose.yml not found.[/red]")
41
+ raise typer.Exit(1)
42
+
43
+ cmd = ["docker", "compose", "-f", str(compose_file), "up", "--build"]
44
+ if detach:
45
+ cmd.append("-d")
46
+
47
+ console.print("[blue]🚀 Starting Biller stack (Docker Mode)...[/blue]")
48
+ result = subprocess.run(cmd)
49
+
50
+ if result.returncode != 0:
51
+ console.print("[red]Docker Compose failed to start.[/red]")
52
+ console.print("Run without detach mode for full logs: [bold]biller-cli up --no-detach[/bold]")
53
+ raise typer.Exit(result.returncode)
54
+
55
+ if detach:
56
+ _wait_for_health(timeout=30)
57
+
58
+ def _up_native(project_dir: Path, detach: bool):
59
+ """Starts the application natively via Maven with PID management."""
60
+ biller_dir = project_dir / ".biller"
61
+ biller_dir.mkdir(exist_ok=True)
62
+
63
+ pid_file = project_dir / PID_FILE_NAME
64
+ log_file = project_dir / LOG_FILE_NAME
65
+
66
+ # Guard against duplicate starts
67
+ if pid_file.exists():
68
+ try:
69
+ existing_pid = int(pid_file.read_text().strip())
70
+ if psutil.pid_exists(existing_pid):
71
+ console.print(f"[yellow]Stack is already running (PID {existing_pid}).[/yellow]")
72
+ console.print("Run [bold]biller-cli down[/bold] first to restart.")
73
+ raise typer.Exit(0)
74
+ else:
75
+ pid_file.unlink() # Stale PID, clean it up
76
+ except ValueError:
77
+ pid_file.unlink()
78
+
79
+ # Load secrets into process environment
80
+ env = os.environ.copy()
81
+ env_file = project_dir / ".env.biller"
82
+ if env_file.exists():
83
+ for line in env_file.read_text().splitlines():
84
+ if "=" in line and not line.startswith("#"):
85
+ k, v = line.split("=", 1)
86
+ env[k.strip()] = v.strip()
87
+
88
+ cmd = ["mvn", "spring-boot:run"]
89
+ console.print("[blue]🚀 Starting Biller stack (Native Maven Mode)...[/blue]")
90
+ console.print("[dim]Note: First run compiles the project and takes ~30 seconds.[/dim]")
91
+
92
+ if detach:
93
+ with open(log_file, "w") as lf:
94
+ process = subprocess.Popen(
95
+ cmd,
96
+ cwd=str(project_dir / "biller-user-layer"),
97
+ env=env,
98
+ stdout=lf,
99
+ stderr=lf,
100
+ )
101
+ pid_file.write_text(str(process.pid))
102
+ console.print(f"[dim]Process running in background (PID {process.pid}). Logs stored in {log_file}[/dim]")
103
+ _wait_for_health(timeout=90)
104
+ else:
105
+ try:
106
+ subprocess.run(cmd, cwd=str(project_dir / "biller-user-layer"), env=env)
107
+ except KeyboardInterrupt:
108
+ console.print("\n[yellow]Shutting down native process...[/yellow]")
109
+
110
+ @app.callback(invoke_without_command=True)
111
+ def up(
112
+ project_dir: Path = typer.Option(Path("."), "--dir", "-d", help="Project directory"),
113
+ detach: bool = typer.Option(True, "--detach/--no-detach", help="Run in background"),
114
+ runtime_override: str = typer.Option(None, "--runtime", help="Force 'docker' or 'native'"),
115
+ ):
116
+ """Start the Biller middleware stack (Auto-detects Docker vs Native)."""
117
+ run_preflight(require_runtime=True)
118
+
119
+ biller_yaml_path = project_dir / "biller.yaml"
120
+ if not biller_yaml_path.exists():
121
+ console.print(f"[red]No biller.yaml found in {project_dir}. Run 'biller-cli init' first.[/red]")
122
+ raise typer.Exit(1)
123
+
124
+ try:
125
+ config = yaml.safe_load(biller_yaml_path.read_text())
126
+ except Exception as e:
127
+ console.print(f"[red]Failed to parse biller.yaml: {e}[/red]")
128
+ raise typer.Exit(1)
129
+
130
+ effective_runtime = runtime_override or config.get("runtime", "docker")
131
+
132
+ if effective_runtime == "docker":
133
+ _up_docker(project_dir, detach)
134
+ elif effective_runtime == "native":
135
+ _up_native(project_dir, detach)
136
+ else:
137
+ console.print(f"[red]Unknown runtime '{effective_runtime}' in biller.yaml. Use 'docker' or 'native'.[/red]")
138
+ raise typer.Exit(1)
@@ -0,0 +1,271 @@
1
+ """
2
+ biller-cli upgrade
3
+
4
+ Fetches migration rules from the registry, applies automatable ones,
5
+ runs a sandbox compile-check, then updates the version pin in
6
+ biller.yaml and pom.xml.
7
+
8
+ SCOPE OF SANDBOX:
9
+ Runs `mvn clean package -pl biller-user-layer --also-make`.
10
+ This is a COMPILE-ONLY gate. It does NOT prove the app boots,
11
+ that the DB schema is compatible, or that the cert path resolves.
12
+ A green sandbox means the code compiles against the new biller-core.
13
+ Boot correctness must be verified by running biller-cli up after upgrade.
14
+ """
15
+
16
+ import subprocess
17
+ import sys
18
+ import shutil
19
+ import typer
20
+ import requests
21
+ import yaml
22
+ from pathlib import Path
23
+ from rich.console import Console
24
+ from packaging.version import Version
25
+
26
+ from biller_cli.utils.version_pin import get_version_pin, set_version_pin
27
+
28
+ app = typer.Typer(help="Upgrade biller-core to a newer version.")
29
+ console = Console()
30
+
31
+ REGISTRY_URL_DEFAULT = "https://biller-registry-production.up.railway.app"
32
+
33
+
34
+ def _get_registry_url(project_dir: Path) -> str:
35
+ """Read BILLER_REGISTRY_URL from environment, then .env.biller, fallback to default."""
36
+ # Environment variable takes highest priority
37
+ import os
38
+ env_var = os.environ.get("BILLER_REGISTRY_URL")
39
+ if env_var:
40
+ return env_var
41
+ # Then .env.biller file
42
+ env_file = project_dir / ".env.biller"
43
+ if env_file.exists():
44
+ for line in env_file.read_text().splitlines():
45
+ if line.startswith("BILLER_REGISTRY_URL="):
46
+ return line.split("=", 1)[1].strip()
47
+ return REGISTRY_URL_DEFAULT
48
+
49
+
50
+ def _fetch_migration_path(registry_url: str, from_version: str, to_version: str) -> dict:
51
+ """Fetch ordered migration rules from the registry."""
52
+ url = f"{registry_url}/api/v1/manifest/migration-path"
53
+ try:
54
+ resp = requests.get(url, params={"from_version": from_version, "to_version": to_version}, timeout=10)
55
+ except requests.ConnectionError:
56
+ console.print(f"[red]Cannot reach registry at {registry_url}.[/red]")
57
+ console.print("Check your network or set BILLER_REGISTRY_URL in .env.biller.")
58
+ raise typer.Exit(1)
59
+
60
+ if resp.status_code == 400:
61
+ console.print(f"[red]{resp.json().get('detail', 'Bad request')}[/red]")
62
+ raise typer.Exit(1)
63
+ if resp.status_code == 404:
64
+ console.print(f"[red]Version not found in registry: {resp.json().get('detail')}[/red]")
65
+ raise typer.Exit(1)
66
+ if resp.status_code != 200:
67
+ console.print(f"[red]Registry error {resp.status_code}: {resp.text}[/red]")
68
+ raise typer.Exit(1)
69
+
70
+ return resp.json()
71
+
72
+
73
+ def _fetch_latest_version(registry_url: str) -> str:
74
+ """Fetch the latest published biller-core version from the registry."""
75
+ try:
76
+ resp = requests.get(f"{registry_url}/api/v1/manifest/latest", timeout=10)
77
+ resp.raise_for_status()
78
+ return resp.json()["version"]
79
+ except Exception as e:
80
+ console.print(f"[red]Failed to fetch latest version from registry: {e}[/red]")
81
+ raise typer.Exit(1)
82
+
83
+
84
+ def _apply_config_rename_rule(project_dir: Path, rule: dict) -> None:
85
+ """Apply a config_rename migration rule to biller.yaml."""
86
+ old_key = rule.get("old_key")
87
+ new_key = rule.get("new_key")
88
+ if not old_key or not new_key:
89
+ return
90
+
91
+ bp = project_dir / "biller.yaml"
92
+ cfg = yaml.safe_load(bp.read_text())
93
+
94
+ # Support dot-notation keys like "core.timeout"
95
+ def get_nested(d, keys):
96
+ for k in keys:
97
+ if not isinstance(d, dict) or k not in d:
98
+ return None, False
99
+ d = d[k]
100
+ return d, True
101
+
102
+ def set_nested(d, keys, value):
103
+ for k in keys[:-1]:
104
+ d = d.setdefault(k, {})
105
+ d[keys[-1]] = value
106
+
107
+ def del_nested(d, keys):
108
+ for k in keys[:-1]:
109
+ if k not in d:
110
+ return
111
+ d = d[k]
112
+ d.pop(keys[-1], None)
113
+
114
+ old_parts = old_key.split(".")
115
+ new_parts = new_key.split(".")
116
+
117
+ value, found = get_nested(cfg, old_parts)
118
+ if found:
119
+ set_nested(cfg, new_parts, value)
120
+ del_nested(cfg, old_parts)
121
+ bp.write_text(yaml.dump(cfg, default_flow_style=False, sort_keys=False))
122
+ console.print(f" [green]✓[/green] Renamed config key [bold]{old_key}[/bold] → [bold]{new_key}[/bold]")
123
+ else:
124
+ console.print(f" [yellow]⚠[/yellow] Config key [bold]{old_key}[/bold] not found — skipping rename")
125
+
126
+
127
+ def _run_sandbox_build(project_dir: Path, target_version: str) -> bool:
128
+ """
129
+ Create a Git worktree sandbox, pin the new version, and attempt compilation.
130
+ Returns True if build passes, False if it fails.
131
+ Always cleans up the worktree regardless of outcome.
132
+
133
+ COMPILE-ONLY: Does not test boot, DB connectivity, or cert resolution.
134
+ """
135
+ worktree_path = project_dir / ".biller" / f"sandbox-{target_version}"
136
+
137
+ # Require a git repo for worktree support
138
+ git_dir = project_dir / ".git"
139
+ if not git_dir.exists():
140
+ console.print("[yellow]⚠ Not a git repository — skipping sandbox build.[/yellow]")
141
+ console.print("[dim]Sandbox compile-check requires git. Proceeding without it.[/dim]")
142
+ return True
143
+
144
+ # Check Maven is available
145
+ if not shutil.which("mvn"):
146
+ console.print("[yellow]⚠ Maven (mvn) not found on PATH — skipping sandbox build.[/yellow]")
147
+ return True
148
+
149
+ try:
150
+ console.print(f"[dim]Creating sandbox worktree at {worktree_path}...[/dim]")
151
+ result = subprocess.run(
152
+ ["git", "worktree", "add", str(worktree_path), "HEAD"],
153
+ cwd=str(project_dir),
154
+ capture_output=True,
155
+ text=True,
156
+ )
157
+ if result.returncode != 0:
158
+ console.print(f"[yellow]⚠ Could not create sandbox worktree: {result.stderr.strip()}[/yellow]")
159
+ console.print("[dim]Proceeding without sandbox.[/dim]")
160
+ return True
161
+
162
+ # Pin the target version in the sandbox copy (not in the live project)
163
+ set_version_pin(worktree_path, target_version)
164
+
165
+ console.print("[dim]Running sandbox compile check (mvn clean package)...[/dim]")
166
+ console.print("[dim]Sandbox scope: compile-only. Boot correctness verified after upgrade.[/dim]")
167
+
168
+ build_result = subprocess.run(
169
+ ["mvn", "clean", "package", "-pl", "biller-user-layer", "--also-make", "-q"],
170
+ cwd=str(worktree_path),
171
+ )
172
+
173
+ if build_result.returncode != 0:
174
+ console.print("[red]✗ Sandbox build FAILED. Version pin NOT updated.[/red]")
175
+ console.print("Fix the compilation errors before upgrading.")
176
+ return False
177
+
178
+ console.print("[green]✓ Sandbox compile check passed.[/green]")
179
+ return True
180
+
181
+ finally:
182
+ # Always clean up worktree — success or failure
183
+ subprocess.run(
184
+ ["git", "worktree", "remove", "--force", str(worktree_path)],
185
+ cwd=str(project_dir),
186
+ capture_output=True,
187
+ )
188
+
189
+
190
+ @app.callback(invoke_without_command=True)
191
+ def upgrade(
192
+ project_dir: Path = typer.Option(Path("."), "--dir", "-d", help="Project directory"),
193
+ to: str = typer.Option(None, "--to", help="Target version (default: latest from registry)"),
194
+ skip_sandbox: bool = typer.Option(False, "--skip-sandbox", help="Skip the sandbox compile check"),
195
+ ):
196
+ """Upgrade biller-core to a newer version."""
197
+ registry_url = _get_registry_url(project_dir)
198
+
199
+ # Resolve current version
200
+ try:
201
+ current_version = get_version_pin(project_dir)
202
+ except (FileNotFoundError, KeyError) as e:
203
+ console.print(f"[red]{e}[/red]")
204
+ console.print("Run [bold]biller-cli init[/bold] first.")
205
+ raise typer.Exit(1)
206
+
207
+ # Resolve target version
208
+ target_version = to or _fetch_latest_version(registry_url)
209
+
210
+ # Guard: already on target
211
+ if Version(target_version) <= Version(current_version):
212
+ console.print(f"Already on [bold]{current_version}[/bold]. Nothing to upgrade.")
213
+ if Version(target_version) < Version(current_version):
214
+ console.print("To downgrade, use [bold]biller-cli downgrade[/bold].")
215
+ raise typer.Exit(0)
216
+
217
+ console.print(f"\nUpgrading biller-core: [bold]{current_version}[/bold] → [bold]{target_version}[/bold]")
218
+ console.print(f"Registry: {registry_url}\n")
219
+
220
+ # Fetch migration path
221
+ migration = _fetch_migration_path(registry_url, current_version, target_version)
222
+ rules = migration.get("rules", [])
223
+ path = migration.get("path", [])
224
+
225
+ if path:
226
+ console.print(f"Migration path: {' → '.join([current_version] + path)}")
227
+ else:
228
+ console.print("No intermediate versions — direct upgrade.")
229
+
230
+ if migration.get("has_breaking_changes"):
231
+ console.print("\n[red bold]⚠ Breaking changes detected in this upgrade path.[/red bold]")
232
+ typer.confirm("Continue?", abort=True)
233
+
234
+ # Apply automatable rules
235
+ auto_rules = [r for r in rules if r.get("type") == "config_rename"]
236
+ manual_rules = [r for r in rules if r.get("type") == "manual"]
237
+
238
+ if auto_rules:
239
+ console.print(f"\nApplying {len(auto_rules)} automated migration rule(s):")
240
+ for rule in auto_rules:
241
+ _apply_config_rename_rule(project_dir, rule)
242
+
243
+ if manual_rules:
244
+ console.print(f"\n[yellow bold]⚠ {len(manual_rules)} manual step(s) required:[/yellow bold]")
245
+ for rule in manual_rules:
246
+ console.print(f" → {rule.get('description', 'See docs')}")
247
+ if rule.get("docs_url"):
248
+ console.print(f" Docs: {rule['docs_url']}")
249
+ console.print("[yellow]These must be completed manually before running biller-cli up.[/yellow]")
250
+
251
+ # Sandbox compile check
252
+ if not skip_sandbox:
253
+ passed = _run_sandbox_build(project_dir, target_version)
254
+ if not passed:
255
+ raise typer.Exit(1)
256
+ else:
257
+ console.print("[yellow]⚠ Sandbox skipped.[/yellow]")
258
+
259
+ # All gates passed — update the live version pin
260
+ try:
261
+ set_version_pin(project_dir, target_version)
262
+ except (FileNotFoundError, RuntimeError) as e:
263
+ console.print(f"[red]Failed to update version pin: {e}[/red]")
264
+ raise typer.Exit(1)
265
+
266
+ console.print(f"\n[green bold]✓ Upgrade complete.[/green bold]")
267
+ console.print(f"biller-core pinned to [bold]{target_version}[/bold] in biller.yaml and pom.xml.")
268
+ console.print("\nNext: run [bold]biller-cli down && biller-cli up[/bold] to apply.")
269
+
270
+ if manual_rules:
271
+ console.print("[yellow]Reminder: complete the manual steps above before starting the stack.[/yellow]")
biller_cli/main.py ADDED
@@ -0,0 +1,34 @@
1
+ import typer
2
+ from rich.console import Console
3
+
4
+ # Import commands
5
+ from biller_cli.commands import init, up, down, upgrade, downgrade
6
+
7
+ app = typer.Typer(
8
+ name="biller-cli",
9
+ help="Manage your Biller Integrator lifecycle: from scaffold to production.",
10
+ add_completion=False,
11
+ )
12
+ console = Console()
13
+
14
+ # Register subcommands
15
+ app.add_typer(init.app, name="init")
16
+ app.add_typer(up.app, name="up")
17
+ app.add_typer(down.app, name="down")
18
+ app.add_typer(upgrade.app, name="upgrade")
19
+ app.add_typer(downgrade.app, name="downgrade")
20
+
21
+ @app.callback(invoke_without_command=True)
22
+ def main(
23
+ ctx: typer.Context,
24
+ version: bool = typer.Option(None, "--version", "-v", is_eager=True, help="Print version and exit."),
25
+ ):
26
+ if version:
27
+ console.print("biller-cli v0.1.0")
28
+ raise typer.Exit()
29
+
30
+ if ctx.invoked_subcommand is None:
31
+ console.print(ctx.get_help())
32
+
33
+ if __name__ == "__main__":
34
+ app()