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,394 @@
1
+ """
2
+ biller_cli/ai/ingest.py
3
+
4
+ Chunks bbps_biller_integrator_codebase.md and writes embeddings to ChromaDB.
5
+
6
+ Split strategy:
7
+ Pass 1 — split on file boundary markers (^## src/)
8
+ Pass 2 — split oversized chunks (>400 tokens) at method/blank boundaries
9
+ with 50-token overlap
10
+
11
+ Exclusions — dead scaffolding deleted in Phase 0:
12
+ BillerController, UserService, UserServiceImpl,
13
+ UserDao, UserDaoImpl, User.java
14
+
15
+ Run:
16
+ biller-cli ingest --source ~/Desktop/bbps/AIGateway/docs/bbps_biller_integrator_codebase.md
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ import subprocess
23
+ import sys
24
+ from pathlib import Path
25
+ from typing import Generator
26
+
27
+ import chromadb
28
+ import tiktoken
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Constants
32
+ # ---------------------------------------------------------------------------
33
+
34
+ CHROMA_DIR = Path.home() / ".biller-cli" / "chroma"
35
+ COLLECTION_NAME = "biller_codebase"
36
+ EMBED_MODEL = "nomic-embed-text"
37
+ MAX_TOKENS = 256
38
+ OVERLAP_TOKENS = 32
39
+
40
+ # Regex that matches file-level headers only.
41
+ # Matches: ## src/main/java/... and ## src/main/resources/...
42
+ # Does NOT match: ## Java Source Files, ## Resource Files, ## AGENT_REBUILD_GUIDE, etc.
43
+ FILE_HEADER_RE = re.compile(r"^## (src/\S+)", re.MULTILINE)
44
+
45
+ # Dead scaffolding — explicitly deleted in Phase 0.
46
+ # These paths exist in the dump (generated from biller-audit, not the clean tree).
47
+ # They must never be indexed.
48
+ EXCLUDE_PATHS: frozenset[str] = frozenset(
49
+ {
50
+ "src/main/java/bharat/connect/biller/controller/BillerController.java",
51
+ "src/main/java/bharat/connect/biller/service/UserService.java",
52
+ "src/main/java/bharat/connect/biller/service/impl/UserServiceImpl.java",
53
+ "src/main/java/bharat/connect/biller/dao/UserDao.java",
54
+ "src/main/java/bharat/connect/biller/dao/impl/UserDaoImpl.java",
55
+ "src/main/java/bharat/connect/biller/model/User.java",
56
+ }
57
+ )
58
+
59
+ # tiktoken encoder — cl100k_base is accurate enough for token budgeting on Java.
60
+ # It is NOT the nomic-embed-text tokeniser, but the counts are close enough for
61
+ # a 400-token ceiling. Do not use len(text.split()) — that undercounts by ~30%.
62
+ _ENC = tiktoken.get_encoding("cl100k_base")
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Pre-flight checks
67
+ # ---------------------------------------------------------------------------
68
+
69
+
70
+ def _check_ollama_model(model: str) -> None:
71
+ """Exit with an actionable error if the required Ollama model is not pulled."""
72
+ try:
73
+ result = subprocess.run(
74
+ ["ollama", "list"], capture_output=True, text=True, timeout=10
75
+ )
76
+ if model not in result.stdout:
77
+ print(
78
+ f"\nError: Ollama model '{model}' is not available.\n"
79
+ f"Pull it with: ollama pull {model}\n"
80
+ )
81
+ sys.exit(1)
82
+ except FileNotFoundError:
83
+ print(
84
+ "\nError: Ollama is not installed or not on PATH.\n"
85
+ "Install from: https://ollama.com\n"
86
+ f"Then run: ollama pull {model}\n"
87
+ )
88
+ sys.exit(1)
89
+ except subprocess.TimeoutExpired:
90
+ print(
91
+ "\nError: Ollama did not respond within 10 seconds.\n"
92
+ "Start it with: ollama serve\n"
93
+ )
94
+ sys.exit(1)
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Parsing — Pass 1
99
+ # ---------------------------------------------------------------------------
100
+
101
+
102
+ def _doc_type(file_path: str) -> str:
103
+ """Return 'source' for Java files, 'resource' for everything else."""
104
+ return "source" if file_path.endswith(".java") else "resource"
105
+
106
+
107
+ def _parse_file_sections(text: str) -> Generator[tuple[str, str], None, None]:
108
+ """
109
+ Yield (file_path, content) pairs for every ## src/ section in the dump.
110
+
111
+ Skips sections whose file_path is in EXCLUDE_PATHS.
112
+ Content includes everything between the file header and the next ## src/ header
113
+ (or end of document), minus the header line itself.
114
+ """
115
+ matches = list(FILE_HEADER_RE.finditer(text))
116
+ for i, match in enumerate(matches):
117
+ file_path = match.group(1)
118
+
119
+ if file_path in EXCLUDE_PATHS:
120
+ continue
121
+
122
+ # Content runs from end of this header line to start of next ## src/ header.
123
+ content_start = match.end()
124
+ content_end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
125
+ content = text[content_start:content_end].strip()
126
+
127
+ if content:
128
+ yield file_path, content
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Chunking — Pass 2
133
+ # ---------------------------------------------------------------------------
134
+
135
+
136
+ def _token_count(text: str) -> int:
137
+ return len(_ENC.encode(text))
138
+
139
+
140
+ def _split_oversized(content: str, file_path: str) -> list[str]:
141
+ sub_chunks = []
142
+ for delimiter in (r"\}\n\n", r"\n\n"):
143
+ parts = re.split(delimiter, content)
144
+ if len(parts) > 1:
145
+ sub_chunks = _merge_parts(parts, file_path)
146
+ break
147
+ else:
148
+ sub_chunks = [content]
149
+
150
+ # Safety pass — force hard split on any chunk still over ceiling.
151
+ # Handles JAXB-generated classes with no natural delimiters.
152
+ final = []
153
+ for chunk in sub_chunks:
154
+ if _token_count(chunk) > MAX_TOKENS:
155
+ final.extend(_hard_split(chunk))
156
+ else:
157
+ final.append(chunk)
158
+ return final
159
+
160
+
161
+ def _merge_parts(parts: list[str], file_path: str) -> list[str]:
162
+ """
163
+ Greedily merge split parts into chunks that stay under MAX_TOKENS.
164
+ When a chunk would exceed MAX_TOKENS, close it and start a new one
165
+ seeded with OVERLAP_TOKENS of the previous chunk's tail for context.
166
+ """
167
+ chunks: list[str] = []
168
+ current = ""
169
+
170
+ for part in parts:
171
+ candidate = (current + "\n\n" + part).strip() if current else part.strip()
172
+ if _token_count(candidate) <= MAX_TOKENS:
173
+ current = candidate
174
+ else:
175
+ if current:
176
+ chunks.append(current)
177
+ # Seed next chunk with overlap from tail of current.
178
+ tail_tokens = _ENC.encode(current)[-OVERLAP_TOKENS:]
179
+ overlap_text = _ENC.decode(tail_tokens)
180
+ current = (overlap_text + "\n\n" + part.strip()).strip()
181
+ else:
182
+ # Single part already exceeds MAX_TOKENS — keep it intact.
183
+ # Splitting a single method mid-line is worse than an oversized chunk.
184
+ chunks.append(part.strip())
185
+ current = ""
186
+
187
+ if current:
188
+ chunks.append(current)
189
+
190
+ return [c for c in chunks if c]
191
+
192
+
193
+ def _hard_split(content: str) -> list[str]:
194
+ """Token-boundary split for content with no natural delimiters."""
195
+ tokens = _ENC.encode(content)
196
+ chunks: list[str] = []
197
+ step = MAX_TOKENS - OVERLAP_TOKENS
198
+ for start in range(0, len(tokens), step):
199
+ chunk_tokens = tokens[start : start + MAX_TOKENS]
200
+ chunks.append(_ENC.decode(chunk_tokens))
201
+ return chunks
202
+
203
+
204
+ def _chunk_file(file_path: str, content: str) -> list[dict]:
205
+ """
206
+ Return a list of chunk dicts ready for ChromaDB insertion.
207
+
208
+ Each dict has keys: text, file_path, chunk_index, language, token_count, doc_type
209
+ """
210
+ token_count = _token_count(content)
211
+
212
+ if token_count <= MAX_TOKENS:
213
+ sub_chunks = [content]
214
+ else:
215
+ sub_chunks = _split_oversized(content, file_path)
216
+
217
+ language = "java" if file_path.endswith(".java") else (
218
+ "xml" if file_path.endswith(".xsd") else (
219
+ "sql" if file_path.endswith(".sql") else "properties"
220
+ )
221
+ )
222
+
223
+ result = []
224
+ for idx, chunk_text in enumerate(sub_chunks):
225
+ result.append(
226
+ {
227
+ "text": chunk_text,
228
+ "file_path": file_path,
229
+ "chunk_index": idx,
230
+ "language": language,
231
+ "token_count": _token_count(chunk_text),
232
+ "doc_type": _doc_type(file_path),
233
+ }
234
+ )
235
+ return result
236
+
237
+
238
+ # ---------------------------------------------------------------------------
239
+ # Embedding — via Ollama HTTP API
240
+ # ---------------------------------------------------------------------------
241
+
242
+
243
+ def _embed_batch(texts: list[str]) -> list[list[float]]:
244
+ """
245
+ Embed a batch of texts using nomic-embed-text via the Ollama Python library.
246
+ Returns a list of embedding vectors in the same order as input texts.
247
+ """
248
+ import ollama # imported here so the rest of the module is importable without ollama
249
+
250
+ embeddings = []
251
+ for text in texts:
252
+ response = ollama.embeddings(model=EMBED_MODEL, prompt=text)
253
+ embeddings.append(response["embedding"])
254
+ return embeddings
255
+
256
+
257
+ # ---------------------------------------------------------------------------
258
+ # ChromaDB write
259
+ # ---------------------------------------------------------------------------
260
+
261
+
262
+ def _get_collection(client: chromadb.PersistentClient) -> chromadb.Collection:
263
+ """
264
+ Delete and recreate the collection on every ingest run.
265
+ This prevents duplicate chunk accumulation when re-ingesting
266
+ after a codebase update. No partial-update strategy — full rebuild only.
267
+ """
268
+ try:
269
+ client.delete_collection(COLLECTION_NAME)
270
+ except Exception:
271
+ pass # Collection did not exist — first run.
272
+ return client.create_collection(
273
+ name=COLLECTION_NAME,
274
+ metadata={"hnsw:space": "cosine"},
275
+ )
276
+
277
+
278
+ BATCH_SIZE = 50 # ChromaDB add() performance degrades with very large single batches.
279
+
280
+
281
+ def _write_to_chroma(
282
+ collection: chromadb.Collection, chunks: list[dict]
283
+ ) -> None:
284
+ """Write all chunks to ChromaDB in batches."""
285
+ total = len(chunks)
286
+ for batch_start in range(0, total, BATCH_SIZE):
287
+ batch = chunks[batch_start : batch_start + BATCH_SIZE]
288
+ texts = [f"{c['file_path']}\n\n{c['text']}" for c in batch]
289
+ embeddings = _embed_batch(texts)
290
+ ids = [
291
+ f"{c['file_path']}::chunk_{c['chunk_index']}" for c in batch
292
+ ]
293
+ metadatas = [
294
+ {
295
+ "file_path": c["file_path"],
296
+ "chunk_index": c["chunk_index"],
297
+ "language": c["language"],
298
+ "token_count": c["token_count"],
299
+ "doc_type": c["doc_type"],
300
+ }
301
+ for c in batch
302
+ ]
303
+ collection.add(
304
+ ids=ids,
305
+ embeddings=embeddings,
306
+ documents=texts,
307
+ metadatas=metadatas,
308
+ )
309
+ done = min(batch_start + BATCH_SIZE, total)
310
+ print(f" Embedded and stored {done}/{total} chunks...", end="\r")
311
+
312
+ print() # newline after the progress line
313
+
314
+
315
+ # ---------------------------------------------------------------------------
316
+ # Public entry point
317
+ # ---------------------------------------------------------------------------
318
+
319
+
320
+ def run_ingest(source_path: Path) -> None:
321
+ """
322
+ Full ingest pipeline. Called by `biller-cli ingest --source <path>`.
323
+
324
+ Steps:
325
+ 1. Pre-flight: verify Ollama + nomic-embed-text available
326
+ 2. Read source file
327
+ 3. Parse file sections (Pass 1)
328
+ 4. Chunk oversized sections (Pass 2)
329
+ 5. Write to ChromaDB (delete + recreate collection)
330
+ """
331
+ # --- 1. Pre-flight ---
332
+ print("Checking Ollama availability...")
333
+ _check_ollama_model(EMBED_MODEL)
334
+ print(f" OK: '{EMBED_MODEL}' is available.\n")
335
+
336
+ # --- 2. Read source ---
337
+ if not source_path.exists():
338
+ print(f"Error: Source file not found: {source_path}")
339
+ sys.exit(1)
340
+
341
+ print(f"Reading: {source_path}")
342
+ text = source_path.read_text(encoding="utf-8")
343
+ print(f" {len(text):,} characters loaded.\n")
344
+
345
+ # --- 3. Parse ---
346
+ print("Parsing file sections...")
347
+ all_chunks: list[dict] = []
348
+ excluded_count = 0
349
+ section_count = 0
350
+
351
+ for file_path, content in _parse_file_sections(text):
352
+ section_count += 1
353
+ file_chunks = _chunk_file(file_path, content)
354
+ all_chunks.extend(file_chunks)
355
+
356
+ # Count excluded sections separately for the summary.
357
+ for match in FILE_HEADER_RE.finditer(text):
358
+ if match.group(1) in EXCLUDE_PATHS:
359
+ excluded_count += 1
360
+
361
+ print(f" {section_count} sections ingested, {excluded_count} excluded (dead scaffolding).")
362
+ print(f" {len(all_chunks)} total chunks after Pass 2 splitting.\n")
363
+
364
+ if not all_chunks:
365
+ print("Error: No chunks produced. Verify the source file format.")
366
+ print("Expected headers matching: ## src/<path>")
367
+ sys.exit(1)
368
+
369
+ # --- 4 & 5. Embed + write ---
370
+ print(f"Initialising ChromaDB at: {CHROMA_DIR}")
371
+ CHROMA_DIR.mkdir(parents=True, exist_ok=True)
372
+ client = chromadb.PersistentClient(path=str(CHROMA_DIR))
373
+ collection = _get_collection(client)
374
+ print(f" Collection '{COLLECTION_NAME}' ready (previous data cleared).\n")
375
+
376
+ print(f"Embedding and storing {len(all_chunks)} chunks...")
377
+ print(" This will take several minutes on first run.\n")
378
+ _write_to_chroma(collection, all_chunks)
379
+
380
+ # --- Summary ---
381
+ final_count = collection.count()
382
+ print(f"\nIngest complete.")
383
+ print(f" Collection : {COLLECTION_NAME}")
384
+ print(f" Location : {CHROMA_DIR}")
385
+ print(f" Chunks : {final_count}")
386
+ print(f" Excluded : {excluded_count} dead scaffolding files\n")
387
+ print("Smoke test:")
388
+ print(' python -c "')
389
+ print(' import chromadb')
390
+ print(f' c = chromadb.PersistentClient(path=\\"{CHROMA_DIR}\\")')
391
+ print(f' col = c.get_collection(\\"{COLLECTION_NAME}\\")')
392
+ print(' r = col.query(query_texts=[\\\"BillFetchService implementation\\\"], n_results=3)')
393
+ print(' [print(m[\\\"file_path\\\"]) for m in r[\\\"metadatas\\\"][0]]')
394
+ print(' "')
@@ -0,0 +1,96 @@
1
+ import os
2
+ import signal
3
+ import time
4
+ import yaml
5
+ import psutil
6
+ import typer
7
+ import subprocess
8
+ from pathlib import Path
9
+ from rich.console import Console
10
+
11
+ app = typer.Typer(help="Stop the Biller middleware stack.")
12
+ console = Console()
13
+
14
+ PID_FILE_NAME = ".biller/biller.pid"
15
+
16
+ def _down_docker(project_dir: Path):
17
+ compose_file = project_dir / "docker-compose.yml"
18
+ if not compose_file.exists():
19
+ console.print("[yellow]docker-compose.yml not found. Nothing to stop.[/yellow]")
20
+ return
21
+
22
+ console.print("[blue]Stopping Docker stack...[/blue]")
23
+ result = subprocess.run(["docker", "compose", "-f", str(compose_file), "down"])
24
+ if result.returncode == 0:
25
+ console.print("[green]✓ Stack stopped successfully.[/green]")
26
+ else:
27
+ console.print("[red]Failed to stop Docker compose stack.[/red]")
28
+ raise typer.Exit(result.returncode)
29
+
30
+ def _down_native(project_dir: Path):
31
+ pid_file = project_dir / PID_FILE_NAME
32
+
33
+ if not pid_file.exists():
34
+ console.print("[yellow]No running native stack found (no PID file).[/yellow]")
35
+ return
36
+
37
+ try:
38
+ pid = int(pid_file.read_text().strip())
39
+ except ValueError:
40
+ console.print("[yellow]Corrupted PID file. Removing it.[/yellow]")
41
+ pid_file.unlink()
42
+ return
43
+
44
+ if not psutil.pid_exists(pid):
45
+ console.print("[yellow]Process is already dead. Cleaning up stale PID file.[/yellow]")
46
+ pid_file.unlink()
47
+ return
48
+
49
+ try:
50
+ console.print(f"[blue]Sending SIGTERM to PID {pid}...[/blue]")
51
+ os.kill(pid, signal.SIGTERM)
52
+
53
+ # Wait gracefully for up to 10 seconds
54
+ deadline = time.time() + 10
55
+ while time.time() < deadline:
56
+ if not psutil.pid_exists(pid):
57
+ break
58
+ time.sleep(0.5)
59
+
60
+ # Escalate to SIGKILL if it refuses to die
61
+ if psutil.pid_exists(pid):
62
+ console.print("[yellow]Process did not terminate cleanly. Force killing (SIGKILL)...[/yellow]")
63
+ os.kill(pid, signal.SIGKILL)
64
+
65
+ pid_file.unlink()
66
+ console.print("[green]✓ Native stack stopped successfully.[/green]")
67
+
68
+ except ProcessLookupError:
69
+ pid_file.unlink()
70
+ console.print("[green]✓ Already stopped.[/green]")
71
+ except PermissionError:
72
+ console.print(f"[red]Permission denied. Try running: kill -9 {pid}[/red]")
73
+
74
+ @app.callback(invoke_without_command=True)
75
+ def down(
76
+ project_dir: Path = typer.Option(Path("."), "--dir", "-d", help="Project directory"),
77
+ ):
78
+ """Stop the Biller middleware stack."""
79
+ biller_yaml_path = project_dir / "biller.yaml"
80
+ if not biller_yaml_path.exists():
81
+ console.print("[red]No biller.yaml found. Are you in the project directory?[/red]")
82
+ raise typer.Exit(1)
83
+
84
+ try:
85
+ config = yaml.safe_load(biller_yaml_path.read_text())
86
+ except Exception:
87
+ config = {}
88
+
89
+ runtime = config.get("runtime", "docker")
90
+
91
+ if runtime == "docker":
92
+ _down_docker(project_dir)
93
+ elif runtime == "native":
94
+ _down_native(project_dir)
95
+ else:
96
+ console.print(f"[red]Unknown runtime '{runtime}'.[/red]")
@@ -0,0 +1,142 @@
1
+ """
2
+ biller-cli downgrade
3
+
4
+ Rolls back the biller-core version pin in biller.yaml and pom.xml.
5
+
6
+ DATABASE WARNING:
7
+ Downgrade does NOT touch the database. If the upgrade applied schema
8
+ migrations, a downgrade will leave the DB in a state incompatible
9
+ with the older code. You must roll back the database manually.
10
+ This warning is printed before the confirmation prompt — always.
11
+ """
12
+
13
+ import subprocess
14
+ import sys
15
+ import typer
16
+ import yaml
17
+ from pathlib import Path
18
+ from rich.console import Console
19
+ from packaging.version import Version
20
+
21
+ from biller_cli.utils.version_pin import get_version_pin, set_version_pin
22
+
23
+ app = typer.Typer(help="Downgrade biller-core to an older version.")
24
+ console = Console()
25
+
26
+ REGISTRY_URL_DEFAULT = "https://biller-registry-production.up.railway.app"
27
+
28
+
29
+ def _get_registry_url(project_dir: Path) -> str:
30
+ """Read BILLER_REGISTRY_URL from environment, then .env.biller, fallback to default."""
31
+ # Environment variable takes highest priority
32
+ import os
33
+ env_var = os.environ.get("BILLER_REGISTRY_URL")
34
+ if env_var:
35
+ return env_var
36
+ # Then .env.biller file
37
+ env_file = project_dir / ".env.biller"
38
+ if env_file.exists():
39
+ for line in env_file.read_text().splitlines():
40
+ if line.startswith("BILLER_REGISTRY_URL="):
41
+ return line.split("=", 1)[1].strip()
42
+ return REGISTRY_URL_DEFAULT
43
+
44
+
45
+ def _version_exists_in_registry(registry_url: str, version: str) -> bool:
46
+ """Return True if the version exists in the registry manifest."""
47
+ import requests
48
+ try:
49
+ resp = requests.get(f"{registry_url}/api/v1/manifest", timeout=10)
50
+ if resp.status_code != 200:
51
+ return True # Fail open — don't block downgrade on registry unavailability
52
+ manifest = resp.json()
53
+ return version in manifest.get("versions", {})
54
+ except Exception:
55
+ return True # Fail open
56
+
57
+
58
+ def _restart_stack(project_dir: Path) -> None:
59
+ """
60
+ Restart the stack using the biller-cli executable found via sys.executable.
61
+ Avoids PATH lookup failures when installed in a venv (e.g. via pipx).
62
+ """
63
+ biller_cli_exe = Path(sys.executable).parent / "biller-cli"
64
+ if not biller_cli_exe.exists():
65
+ console.print("[red]Cannot find biller-cli executable for auto-restart.[/red]")
66
+ console.print("Run manually: [bold]biller-cli down && biller-cli up[/bold]")
67
+ return
68
+
69
+ dir_str = str(project_dir)
70
+
71
+ result_down = subprocess.run([str(biller_cli_exe), "down", "--dir", dir_str])
72
+ if result_down.returncode != 0:
73
+ console.print("[yellow]⚠ biller-cli down exited with an error. Proceeding to up anyway.[/yellow]")
74
+
75
+ result_up = subprocess.run([str(biller_cli_exe), "up", "--dir", dir_str])
76
+ if result_up.returncode != 0:
77
+ console.print("[red]biller-cli up failed after downgrade.[/red]")
78
+ console.print("Check logs and run [bold]biller-cli up[/bold] manually.")
79
+
80
+
81
+ @app.callback(invoke_without_command=True)
82
+ def downgrade(
83
+ project_dir: Path = typer.Option(Path("."), "--dir", "-d", help="Project directory"),
84
+ to: str = typer.Option(..., "--to", help="Target version to downgrade to"),
85
+ restart: bool = typer.Option(False, "--restart", help="Restart the stack after downgrade"),
86
+ ):
87
+ """Downgrade biller-core to an older version."""
88
+ # Resolve current version
89
+ try:
90
+ current_version = get_version_pin(project_dir)
91
+ except (FileNotFoundError, KeyError) as e:
92
+ console.print(f"[red]{e}[/red]")
93
+ console.print("Run [bold]biller-cli init[/bold] first.")
94
+ raise typer.Exit(1)
95
+
96
+ # Guard: same version
97
+ if Version(to) == Version(current_version):
98
+ console.print(f"Already on [bold]{current_version}[/bold]. Nothing to do.")
99
+ raise typer.Exit(0)
100
+
101
+ # Guard: target is newer
102
+ if Version(to) > Version(current_version):
103
+ console.print(f"[red]{to} is newer than {current_version}.[/red]")
104
+ console.print("Use [bold]biller-cli upgrade[/bold] to upgrade.")
105
+ raise typer.Exit(1)
106
+
107
+ # Validate target version exists in registry
108
+ registry_url = _get_registry_url(project_dir)
109
+ if not _version_exists_in_registry(registry_url, to):
110
+ console.print(f"[red]Version {to} is not in the registry manifest.[/red]")
111
+ console.print(f"Check available versions: {registry_url}/api/v1/manifest")
112
+ raise typer.Exit(1)
113
+
114
+ # DB warning — always shown before confirmation
115
+ console.print("\n[yellow bold]⚠ DATABASE WARNING[/yellow bold]")
116
+ console.print(
117
+ "Downgrade does [bold]NOT[/bold] roll back database schema changes.\n"
118
+ "If the upgrade applied migrations, the older code may fail to boot\n"
119
+ "or behave incorrectly against the current schema.\n"
120
+ "Roll back your database manually before proceeding."
121
+ )
122
+
123
+ console.print(
124
+ f"\nDowngrading biller-core: [bold]{current_version}[/bold] → [bold]{to}[/bold]"
125
+ )
126
+ typer.confirm("Continue with downgrade?", abort=True)
127
+
128
+ # Update version pin
129
+ try:
130
+ set_version_pin(project_dir, to)
131
+ except (FileNotFoundError, RuntimeError) as e:
132
+ console.print(f"[red]Failed to update version pin: {e}[/red]")
133
+ raise typer.Exit(1)
134
+
135
+ console.print(f"\n[green bold]✓ Downgrade complete.[/green bold]")
136
+ console.print(f"biller-core pinned to [bold]{to}[/bold] in biller.yaml and pom.xml.")
137
+
138
+ if restart:
139
+ console.print("\nRestarting stack...")
140
+ _restart_stack(project_dir)
141
+ else:
142
+ console.print("\nRun [bold]biller-cli down && biller-cli up[/bold] to apply the downgrade.")