merge: fix/ci-ruff-lint into freeze/rc-1

CI lint fixes, CVE security mitigations, sync status UI (#120),
bugbot Forgejo token fallback (#118), npm audit, mnemo compose stub.
This commit is contained in:
pyr0ball 2026-06-14 12:16:40 -07:00
commit 6db1fe1546
76 changed files with 705 additions and 312 deletions

View file

@ -35,7 +35,8 @@ OPENAI_COMPAT_URL=
OPENAI_COMPAT_KEY= OPENAI_COMPAT_KEY=
# Feedback button — Forgejo issue filing # Feedback button — Forgejo issue filing
FORGEJO_API_TOKEN= FORGEJO_API_TOKEN= # dev/admin token (your personal account)
FORGEJO_BOT_TOKEN= # cf-bugbot bot token — used for in-app feedback; falls back to FORGEJO_API_TOKEN
FORGEJO_REPO=pyr0ball/peregrine FORGEJO_REPO=pyr0ball/peregrine
FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1 FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1
# GITHUB_TOKEN= # future — enable when public mirror is active # GITHUB_TOKEN= # future — enable when public mirror is active
@ -64,8 +65,21 @@ CF_ORCH_AGENT_PORT=7701
# Cloud multi-tenancy (compose.cloud.yml only — do not set for local installs) # Cloud multi-tenancy (compose.cloud.yml only — do not set for local installs)
CLOUD_MODE=false CLOUD_MODE=false
CLOUD_DATA_ROOT=/devl/menagerie-data CLOUD_DATA_ROOT=/devl/menagerie-data
SYNC_DB_PATH= # optional; defaults to CLOUD_DATA_ROOT/sync.db
SYNC_DB_KEY= # optional; SQLCipher key for at-rest encryption
DIRECTUS_JWT_SECRET= # must match website/.env DIRECTUS_SECRET value DIRECTUS_JWT_SECRET= # must match website/.env DIRECTUS_SECRET value
CF_SERVER_SECRET= # random 64-char hex — generate: openssl rand -hex 32 CF_SERVER_SECRET= # random 64-char hex — generate: openssl rand -hex 32
PLATFORM_DB_URL=postgresql://cf_platform:<password>@host.docker.internal:5433/circuitforge_platform PLATFORM_DB_URL=postgresql://cf_platform:<password>@host.docker.internal:5433/circuitforge_platform
HEIMDALL_URL=http://cf-license:8000 # internal Docker URL; override for external access HEIMDALL_URL=http://cf-license:8000 # internal Docker URL; override for external access
HEIMDALL_ADMIN_TOKEN= # must match ADMIN_TOKEN in circuitforge-license .env HEIMDALL_ADMIN_TOKEN= # must match ADMIN_TOKEN in circuitforge-license .env
# ── Memory (mnemo sidecar) — opt-in, requires --profile memory ───────────────
# Launch with: docker compose --profile memory --profile <gpu-profile> up -d
# Mnemo builds a persistent knowledge graph from conversations and injects
# relevant context back into LLM prompts. Uses the ollama service as its LLM.
MNEMO_HOST=mnemo # internal service name; change for external sidecar
MNEMO_PORT=8080
MNEMO_LLM_PROVIDER=ollama # ollama | openai | anthropic | custom
MNEMO_LLM_BASE_URL=http://ollama:11434/v1 # override for external LLM
MNEMO_LLM_API_KEY=ollama # "ollama" is a dummy value for local Ollama
MNEMO_LLM_MODEL=llama3.2:3b # must be pulled in the ollama container

View file

@ -29,6 +29,9 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pip install -r requirements.txt run: pip install -r requirements.txt
- name: Install lint tools
run: pip install ruff
- name: Lint - name: Lint
run: ruff check . run: ruff check .

View file

@ -1,6 +1,7 @@
# Mirror push to GitHub and Codeberg on every push to main or tag. # Mirror push to GitHub and Codeberg on every push to main or tag.
# Copied from Circuit-Forge/cf-agents workflows/mirror.yml # Copied from Circuit-Forge/cf-agents workflows/mirror.yml
# Required secrets: GITHUB_MIRROR_TOKEN, CODEBERG_MIRROR_TOKEN # Required secrets: GH_MIRROR_TOKEN, CODEBERG_MIRROR_TOKEN
# Note: Forgejo reserves the GITHUB_* prefix for secret names — use GH_* instead.
name: Mirror name: Mirror
@ -19,10 +20,10 @@ jobs:
- name: Mirror to GitHub - name: Mirror to GitHub
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_MIRROR_TOKEN }} GH_MIRROR_PAT: ${{ secrets.GH_MIRROR_TOKEN }}
REPO: ${{ github.event.repository.name }} REPO: ${{ github.event.repository.name }}
run: | run: |
git remote add github "https://x-access-token:${GITHUB_TOKEN}@github.com/CircuitForgeLLC/${REPO}.git" git remote add github "https://x-access-token:${GH_MIRROR_PAT}@github.com/CircuitForgeLLC/${REPO}.git"
git push github --mirror git push github --mirror
- name: Mirror to Codeberg - name: Mirror to Codeberg

View file

@ -180,4 +180,8 @@ Peregrine uses a split license:
Fine-tuned model weights are proprietary and per-user — not redistributable. Fine-tuned model weights are proprietary and per-user — not redistributable.
---
Humans own design, architecture, code review, testing, and verification. LLMs are part of our development workflow. [Our positions on LLM use →](https://circuitforge.tech/positions)
© 2026 Circuit Forge LLC © 2026 Circuit Forge LLC

View file

@ -23,6 +23,8 @@ services:
- GPU_SERVER_URL=${GPU_SERVER_URL:-${CF_ORCH_URL:-http://host.docker.internal:7700}} - GPU_SERVER_URL=${GPU_SERVER_URL:-${CF_ORCH_URL:-http://host.docker.internal:7700}}
- CF_ORCH_URL=${CF_ORCH_URL:-${GPU_SERVER_URL:-http://host.docker.internal:7700}} - CF_ORCH_URL=${CF_ORCH_URL:-${GPU_SERVER_URL:-http://host.docker.internal:7700}}
- CF_APP_NAME=peregrine - CF_APP_NAME=peregrine
- MNEMO_HOST=${MNEMO_HOST:-mnemo}
- MNEMO_PORT=${MNEMO_PORT:-8080}
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
@ -116,6 +118,28 @@ services:
profiles: [single-gpu, dual-gpu-ollama, dual-gpu-vllm, dual-gpu-mixed] profiles: [single-gpu, dual-gpu-ollama, dual-gpu-vllm, dual-gpu-mixed]
restart: unless-stopped restart: unless-stopped
mnemo:
image: ghcr.io/zaydmulani09/mnemo:latest
ports:
- "${MNEMO_PORT:-8080}:8080"
volumes:
- mnemo-data:/data
environment:
- MNEMO_DB_PATH=/data/mnemo.db
- MNEMO_LLM_PROVIDER=${MNEMO_LLM_PROVIDER:-ollama}
- MNEMO_LLM_BASE_URL=${MNEMO_LLM_BASE_URL:-http://ollama:11434/v1}
- MNEMO_LLM_API_KEY=${MNEMO_LLM_API_KEY:-ollama}
- MNEMO_LLM_MODEL=${MNEMO_LLM_MODEL:-llama3.2:3b}
depends_on:
- ollama
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"]
interval: 15s
timeout: 5s
retries: 3
profiles: [memory]
restart: unless-stopped
finetune: finetune:
build: build:
context: . context: .
@ -131,3 +155,6 @@ services:
- OLLAMA_MODELS_OLLAMA_PATH=/root/.ollama - OLLAMA_MODELS_OLLAMA_PATH=/root/.ollama
profiles: [finetune] profiles: [finetune]
restart: "no" restart: "no"
volumes:
mnemo-data:

View file

@ -8,13 +8,13 @@ import imaplib
import json import json
import logging import logging
import os import os
import ipaddress
import re import re
import socket import socket
import sqlite3 import sqlite3
import ssl as ssl_mod import ssl as ssl_mod
import subprocess import subprocess
import sys import sys
import threading
from contextvars import ContextVar from contextvars import ContextVar
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@ -26,7 +26,7 @@ import yaml
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Query, Request, Response, UploadFile from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel
@ -39,13 +39,38 @@ if str(PEREGRINE_ROOT) not in sys.path:
from circuitforge_core.api import make_feedback_router as _make_feedback_router # noqa: E402 from circuitforge_core.api import make_feedback_router as _make_feedback_router # noqa: E402
from circuitforge_core.config.settings import load_env as _load_env # noqa: E402 from circuitforge_core.config.settings import load_env as _load_env # noqa: E402
from scripts.credential_store import get_credential, set_credential, delete_credential # noqa: E402 from circuitforge_core.sync import SyncConfig, make_sync_router # noqa: E402
from scripts.credential_store import get_credential, set_credential # noqa: E402
DB_PATH = os.environ.get("STAGING_DB", "/devl/job-seeker/staging.db") DB_PATH = os.environ.get("STAGING_DB", "/devl/job-seeker/staging.db")
_CLOUD_MODE = os.environ.get("CLOUD_MODE", "").lower() in ("1", "true") _CLOUD_MODE = os.environ.get("CLOUD_MODE", "").lower() in ("1", "true")
_CLOUD_DATA_ROOT = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/menagerie-data")) _CLOUD_DATA_ROOT = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/menagerie-data"))
_DIRECTUS_SECRET = os.environ.get("DIRECTUS_JWT_SECRET", "") _DIRECTUS_SECRET = os.environ.get("DIRECTUS_JWT_SECRET", "")
# Allowlist for cloud user_id values — UUID format only (prevents path traversal)
_VALID_USER_ID_RE = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE)
# RFC-1918 + loopback + link-local blocks blocked from IMAP SSRF
_PRIVATE_NETS = [
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
]
def _is_ssrf_host(host: str) -> bool:
"""Return True if host resolves to a private/loopback address (SSRF guard)."""
try:
addr = ipaddress.ip_address(socket.gethostbyname(host))
return any(addr in net for net in _PRIVATE_NETS)
except Exception:
return True # fail closed on resolution errors
IS_DEMO: bool = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes") IS_DEMO: bool = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes")
# Resolve GPU inference server URL. # Resolve GPU inference server URL.
@ -127,6 +152,43 @@ _feedback_router = _make_feedback_router(
) )
app.include_router(_feedback_router, prefix="/api/feedback") app.include_router(_feedback_router, prefix="/api/feedback")
# ── Cross-device sync (cf-core sync module, Paid+ only) ──────────────────────
class _SyncUser:
"""Minimal user object expected by the cf-core sync router."""
def __init__(self, user_id: str) -> None:
self.user_id = user_id
def _get_sync_session() -> _SyncUser:
"""FastAPI dependency: resolves user_id from the per-request DB ContextVar.
Returns a fixed 'local' user in single-tenant mode so the prefs/delete
endpoints still work for self-hosted users.
"""
db_path = _request_db.get()
if db_path:
try:
user_id = Path(db_path).parts[-3]
except IndexError:
raise HTTPException(status_code=401, detail="Invalid session")
else:
user_id = "local"
return _SyncUser(user_id)
def _require_paid_sync() -> _SyncUser:
"""FastAPI dependency: raises 403 unless the resolved tier is paid or premium."""
tier = _resolve_cloud_tier()
if tier not in ("paid", "premium"):
raise HTTPException(status_code=403, detail="Cross-device sync requires a Paid or Premium subscription.")
return _get_sync_session()
_sync_router = make_sync_router(
product="peregrine",
get_session=_get_sync_session,
require_paid=_require_paid_sync,
config=SyncConfig.from_env("peregrine"),
)
app.include_router(_sync_router, prefix="/sync", tags=["sync"])
_log = logging.getLogger("peregrine.session") _log = logging.getLogger("peregrine.session")
# ── Structured auth logging ─────────────────────────────────────────────────── # ── Structured auth logging ───────────────────────────────────────────────────
@ -221,6 +283,10 @@ async def cloud_session_middleware(request: Request, call_next):
if _CLOUD_MODE and _DIRECTUS_SECRET: if _CLOUD_MODE and _DIRECTUS_SECRET:
cookie_header = request.headers.get("X-CF-Session", "") cookie_header = request.headers.get("X-CF-Session", "")
user_id = _resolve_cf_user_id(cookie_header) user_id = _resolve_cf_user_id(cookie_header)
if user_id:
if not _VALID_USER_ID_RE.match(user_id):
_log.warning("cloud_session_middleware: rejected non-UUID user_id: %s", user_id[:40])
user_id = None
if user_id: if user_id:
first_access = user_id not in _seen_users first_access = user_id not in _seen_users
if first_access: if first_access:
@ -738,7 +804,6 @@ def preview_resume_review(job_id: int, body: ResumeReviewBody):
3. render_resume_text() renders to plain text for the preview panel 3. render_resume_text() renders to plain text for the preview panel
Returns: {preview_text, preview_struct} struct preserved for the approve step. Returns: {preview_text, preview_struct} struct preserved for the approve step.
""" """
import json as _json
from scripts.db import get_resume_draft as _get_draft from scripts.db import get_resume_draft as _get_draft
from scripts.resume_optimizer import ( from scripts.resume_optimizer import (
apply_review_decisions, frame_skill_gaps, render_resume_text, apply_review_decisions, frame_skill_gaps, render_resume_text,
@ -759,7 +824,6 @@ def preview_resume_review(job_id: int, body: ResumeReviewBody):
# Step 2: inject gap framing for rejected skills (adjacent / learning) # Step 2: inject gap framing for rejected skills (adjacent / learning)
framings = [f.model_dump() for f in body.gap_framings if f.mode in ("adjacent", "learning")] framings = [f.model_dump() for f in body.gap_framings if f.mode in ("adjacent", "learning")]
if framings: if framings:
db_path_obj = Path(_request_db.get() or DB_PATH)
job_row = _get_db().execute( job_row = _get_db().execute(
"SELECT title, company FROM jobs WHERE id=?", (job_id,) "SELECT title, company FROM jobs WHERE id=?", (job_id,)
).fetchone() ).fetchone()
@ -829,7 +893,6 @@ def approve_resume(job_id: int, body: dict):
saved_resume_id: int | None = None saved_resume_id: int | None = None
if body.get("save_to_library"): if body.get("save_to_library"):
from scripts.db import create_resume as _create_r from scripts.db import create_resume as _create_r
import json as _json2
resume_name = (body.get("resume_name") or "").strip() or f"Optimized for job {job_id}" resume_name = (body.get("resume_name") or "").strip() or f"Optimized for job {job_id}"
saved = _create_r( saved = _create_r(
db_path, db_path,
@ -926,7 +989,7 @@ def create_resume_endpoint(body: dict):
@app.post("/api/resumes/import") @app.post("/api/resumes/import")
async def import_resume_endpoint(file: UploadFile, name: str = ""): async def import_resume_endpoint(file: UploadFile, name: str = ""):
import os, tempfile, json as _json import json as _json
from scripts.db import create_resume as _create from scripts.db import create_resume as _create
db_path = Path(_request_db.get() or DB_PATH) db_path = Path(_request_db.get() or DB_PATH)
content = await file.read() content = await file.read()
@ -1128,6 +1191,35 @@ def set_job_resume_endpoint(job_id: int, body: dict):
# context. Avocet then routes these prompts through different local models to # context. Avocet then routes these prompts through different local models to
# compare generation quality against the real Peregrine pipeline. # compare generation quality against the real Peregrine pipeline.
_SYNTHETIC_JOB = {
"id": 0,
"title": "Senior Software Engineer",
"company": "Acme Corp",
"description": (
"We are looking for a Senior Software Engineer to join our platform team. "
"You will design and build scalable backend services in Python and Go, "
"contribute to our event-driven architecture using Kafka and Redis, and "
"mentor junior engineers. We value clear communication, strong code review "
"practices, and an ownership mindset.\n\n"
"Requirements:\n"
"- 5+ years of backend engineering experience\n"
"- Proficiency in Python or Go; experience with both is a plus\n"
"- Solid understanding of distributed systems and API design (REST/gRPC)\n"
"- Experience with containerization (Docker/Kubernetes)\n"
"- Comfort working in a remote-first, async team environment\n\n"
"Nice to have:\n"
"- Experience with Kafka or other message-queue systems\n"
"- Open-source contributions\n"
"- Familiarity with observability tooling (Prometheus, Grafana)\n"
),
"status": "applied",
"cover_letter": "",
"raw_output": "",
"company_brief": "",
"ats_gap_report": "",
"talking_points": "",
}
def _imitate_load_profile(): def _imitate_load_profile():
"""Load UserProfile from config/user.yaml, or None if missing.""" """Load UserProfile from config/user.yaml, or None if missing."""
try: try:
@ -1157,6 +1249,9 @@ def _imitate_cover_letter(db, profile, limit: int) -> dict:
except Exception: except Exception:
corpus = [] corpus = []
if not rows:
rows = [_SYNTHETIC_JOB]
samples = [] samples = []
for r in rows: for r in rows:
desc = r["description"] or "" desc = r["description"] or ""
@ -1213,6 +1308,9 @@ def _imitate_company_research(db, profile, limit: int) -> dict:
except Exception: except Exception:
pass pass
if not rows:
rows = [_SYNTHETIC_JOB]
samples = [] samples = []
for r in rows: for r in rows:
jd = (r["description"] or "")[:1500].strip() jd = (r["description"] or "")[:1500].strip()
@ -1270,6 +1368,10 @@ def _imitate_interview_prep(db, profile, limit: int) -> dict:
).fetchall() ).fetchall()
name = profile.name if profile else "the candidate" name = profile.name if profile else "the candidate"
if not rows:
rows = [_SYNTHETIC_JOB]
samples = [] samples = []
for r in rows: for r in rows:
system_prompt = ( system_prompt = (
@ -1324,6 +1426,9 @@ def _imitate_ats_resume(db, profile, limit: int) -> dict:
pass pass
resume_block = f"\n## Current Resume\n{resume_text}" if resume_text else "" resume_block = f"\n## Current Resume\n{resume_text}" if resume_text else ""
if not rows:
rows = [_SYNTHETIC_JOB]
samples = [] samples = []
for r in rows: for r in rows:
desc = (r["description"] or "")[:1500].strip() desc = (r["description"] or "")[:1500].strip()
@ -1462,14 +1567,8 @@ def calendar_push(job_id: int):
# ── Survey endpoints ───────────────────────────────────────────────────────── # ── Survey endpoints ─────────────────────────────────────────────────────────
# Module-level imports so tests can patch dev_api.LLMRouter etc. # Module-level imports so tests can patch dev_api.LLMRouter etc.
from scripts.llm_router import LLMRouter from scripts.db import insert_survey_response, get_survey_responses # noqa: E402
from scripts.db import insert_survey_response, get_survey_responses
from scripts.survey_assistant import (
SURVEY_SYSTEM as _SURVEY_SYSTEM,
build_text_prompt as _build_text_prompt,
build_image_prompt as _build_image_prompt,
)
@app.get("/api/vision/health") @app.get("/api/vision/health")
@ -2690,7 +2789,7 @@ def config_user():
# ── Settings: My Profile endpoints ─────────────────────────────────────────── # ── Settings: My Profile endpoints ───────────────────────────────────────────
from scripts.user_profile import load_user_profile, save_user_profile from scripts.user_profile import load_user_profile, save_user_profile # noqa: E402
def _user_yaml_path() -> str: def _user_yaml_path() -> str:
@ -3564,6 +3663,8 @@ def test_email(payload: dict):
username = payload.get("username", "") username = payload.get("username", "")
if not all([host, username, password]): if not all([host, username, password]):
return {"ok": False, "error": "Missing host, username, or password"} return {"ok": False, "error": "Missing host, username, or password"}
if _is_ssrf_host(host):
return {"ok": False, "error": "IMAP host must be a public address"}
if use_ssl: if use_ssl:
ctx = ssl_mod.create_default_context() ctx = ssl_mod.create_default_context()
conn = imaplib.IMAP4_SSL(host, port, ssl_context=ctx) conn = imaplib.IMAP4_SSL(host, port, ssl_context=ctx)
@ -4352,7 +4453,8 @@ def _fetch_cforch_nodes() -> list[dict]:
if not url: if not url:
return [] return []
try: try:
import urllib.request, json as _json import urllib.request
import json as _json
req = urllib.request.Request(f"{url}/api/nodes", headers={"Accept": "application/json"}) req = urllib.request.Request(f"{url}/api/nodes", headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=3) as resp: with urllib.request.urlopen(req, timeout=3) as resp:
data = _json.loads(resp.read()) data = _json.loads(resp.read())

View file

@ -23,8 +23,8 @@ dependencies:
- undetected-chromedriver - undetected-chromedriver
- webdriver-manager - webdriver-manager
- beautifulsoup4 - beautifulsoup4
- requests - requests>=2.33.0 # CVE-2026-25645
- curl_cffi # Chrome TLS fingerprint — bypasses Cloudflare on The Ladders - curl_cffi>=0.15.0 # CVE-2026-33752
- fake-useragent # company scraper rotation - fake-useragent # company scraper rotation
# ── LLM / AI backends ───────────────────────────────────────────────────── # ── LLM / AI backends ─────────────────────────────────────────────────────
@ -55,13 +55,13 @@ dependencies:
- google-auth>=2.0 - google-auth>=2.0
# ── Document handling ───────────────────────────────────────────────────── # ── Document handling ─────────────────────────────────────────────────────
- pypdf - pypdf>=6.12.0 # 12 CVEs in 6.7.x (CVE-2026-27628 through CVE-2026-48156)
- pdfminer-six - pdfminer-six
- pyyaml>=6.0 - pyyaml>=6.0
- python-dotenv - python-dotenv>=1.2.2 # CVE-2026-28684
# ── Auth / licensing ────────────────────────────────────────────────────── # ── Auth / licensing ──────────────────────────────────────────────────────
- PyJWT>=2.8 - PyJWT>=2.13.0 # 2.11 has sig bypass CVEs (PYSEC-2026-120/175-179); used for cloud session routing
# ── Utilities ───────────────────────────────────────────────────────────── # ── Utilities ─────────────────────────────────────────────────────────────
- sqlalchemy - sqlalchemy
@ -71,6 +71,18 @@ dependencies:
- tenacity - tenacity
- httpx - httpx
# ── Security pins (transitive deps with known CVEs) ───────────────────────
- starlette>=1.0.1 # PYSEC-2026-161 (FastAPI foundation)
- python-multipart>=0.0.27 # CVE-2026-40347/42561 file upload parsing
- aiohttp>=3.14.0 # 12 CVEs (CVE-2026-34513 through CVE-2026-34993)
- tornado>=6.5.5 # CVE-2026-35536
- cryptography>=46.0.7 # PYSEC-2026-35/36
- langsmith>=0.8.0 # CVE-2026-41182/45134
- gitpython>=3.1.50 # CVE-2026-42215/42284/44244
- lxml>=6.1.0 # PYSEC-2026-87 (XXE)
- idna>=3.15 # CVE-2026-45409
- markdownify>=0.14.1 # CVE-2025-46656
# ── Testing ─────────────────────────────────────────────────────────────── # ── Testing ───────────────────────────────────────────────────────────────
- pytest>=9.0 - pytest>=9.0
- pytest-cov - pytest-cov

32
pyproject.toml Normal file
View file

@ -0,0 +1,32 @@
[tool.ruff]
# app/ is the deprecated Streamlit UI (replaced by Vue+FastAPI).
# No new work goes there; exclude from linting rather than accumulate suppressions.
exclude = ["app/"]
[tool.ruff.lint.per-file-ignores]
# dev-api.py / dev_api.py (symlink): E702 semicolons in compact Pydantic model
# definitions — intentional style for dense data models with many simple fields.
"dev-api.py" = ["E702"]
"dev_api.py" = ["E702"]
# finetune_local.py: E402 ML libs (torch, datasets, trl) are imported after
# runtime CUDA / Unsloth availability checks — conditional import pattern.
"scripts/finetune_local.py" = ["E402", "E741"]
# scripts/: E402 mid-file imports used for lazy loading or post-env-setup imports.
"scripts/task_runner.py" = ["E402"]
"scripts/migrate.py" = ["E741"]
# scrapers/: third-party script; minimal changes policy.
"scrapers/companyScraper.py" = ["E722"]
# tools/: deprecated label tool copy (canonical in avocet); suppress style warnings.
"tools/label_tool.py" = ["E741"]
# tests/: F841 unused variables are the standard mock-patch capture pattern
# (e.g., `original_fn = obj.method` before monkeypatching).
# E741 ambiguous `l` names and E402 conditional imports are common in test fixtures.
# E702 compact `con.commit(); con.close()` is a common SQLite test helper idiom.
"tests/**" = ["F841", "E741", "E402", "E702"]
"tests/test_wizard_steps.py" = ["F841", "E741", "E402", "E702"]
"scripts/test_email_classify.py" = ["E402", "F841"]

View file

@ -10,23 +10,15 @@ Usage — add to main.py once:
from app.cloud_session import session_middleware_dep from app.cloud_session import session_middleware_dep
app = FastAPI(..., dependencies=[Depends(session_middleware_dep)]) app = FastAPI(..., dependencies=[Depends(session_middleware_dep)])
From that point, any route (and every service/llm function it calls) Writing model is resolved from Heimdall's resolve response (user_preferences
has access to the current user context via llm.get_request_*() helpers. JSON column, projected as custom_writing_model in the response). Assign models
via the admin UI at /account/admin/model-assignments.
Writing model resolution order (first match wins):
1. USER_WRITING_MODELS env var JSON dict mapping Directus UUID model name
e.g. USER_WRITING_MODELS={"5b99ca9f-...": "meghan-letter-writer:latest"}
Use this for Monday; no Heimdall changes required.
2. session.meta["custom_writing_model"] returned by Heimdall resolve endpoint
once Heimdall is updated to expose user_preferences fields.
""" """
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import os
from fastapi import Depends, Request, Response from fastapi import Request, Response
from circuitforge_core.cloud_session import CloudSessionFactory, CloudUser, detect_byok from circuitforge_core.cloud_session import CloudSessionFactory, CloudUser, detect_byok
@ -34,21 +26,6 @@ log = logging.getLogger(__name__)
__all__ = ["CloudUser", "get_session", "require_tier", "session_middleware_dep"] __all__ = ["CloudUser", "get_session", "require_tier", "session_middleware_dep"]
# JSON dict mapping Directus user UUID → custom writing model name.
# Used until Heimdall's resolve endpoint exposes user_preferences.
def _load_user_writing_models() -> dict[str, str]:
raw = os.environ.get("USER_WRITING_MODELS", "").strip()
if not raw:
return {}
try:
return json.loads(raw)
except json.JSONDecodeError:
log.warning("USER_WRITING_MODELS is not valid JSON — ignoring")
return {}
_USER_WRITING_MODELS: dict[str, str] = _load_user_writing_models()
_factory = CloudSessionFactory( _factory = CloudSessionFactory(
product="peregrine", product="peregrine",
byok_detector=detect_byok, byok_detector=detect_byok,
@ -81,9 +58,4 @@ def session_middleware_dep(request: Request, response: Response) -> None:
set_request_user_id(user_id) set_request_user_id(user_id)
set_request_tier(session.tier) set_request_tier(session.tier)
# Resolution order: env-var map (Monday path) → Heimdall meta (future path) set_request_writing_model(session.meta.get("custom_writing_model") or None)
writing_model = (
_USER_WRITING_MODELS.get(session.user_id)
or session.meta.get("custom_writing_model")
)
set_request_writing_model(writing_model)

View file

@ -152,6 +152,62 @@ async def _allocate_orch_async(
logging.debug("cf-orch release failed (non-fatal): %s", exc) logging.debug("cf-orch release failed (non-fatal): %s", exc)
@asynccontextmanager
async def _allocate_by_task(
coordinator_url: str,
product: str,
task: str,
ttl_s: float,
caller: str,
):
"""Allocate via the task-model assignment layer (POST /api/inference/task).
Resolves product+task model_id service+node automatically.
Falls back gracefully: if the coordinator returns 404 (no assignment),
raises RuntimeError so the caller can fall back to model_candidates routing.
"""
async with httpx.AsyncClient(timeout=120.0) as client:
payload: dict[str, Any] = {
"product": product,
"task": task,
"payload": {"ttl_s": ttl_s, "caller": caller},
}
uid = get_request_user_id()
if uid:
payload["payload"]["user_id"] = uid
resp = await client.post(
f"{coordinator_url.rstrip('/')}/api/inference/task",
json=payload,
)
if resp.status_code == 404:
raise RuntimeError(
f"No task assignment for product={product!r} task={task!r}; "
"falling back to model_candidates routing"
)
if not resp.is_success:
raise RuntimeError(
f"cf-orch task allocation failed for {product}/{task}: "
f"HTTP {resp.status_code}{resp.text[:200]}"
)
data = resp.json()
service = data.get("service_type", "vllm")
alloc = _OrchAllocation(
allocation_id=data["allocation_id"],
url=data["url"],
service=service,
)
try:
yield alloc
finally:
try:
await client.delete(
f"{coordinator_url.rstrip('/')}/api/services/{service}/allocations/{alloc.allocation_id}",
timeout=10.0,
)
except Exception as exc:
logging.debug("cf-orch task release failed (non-fatal): %s", exc)
def _normalize_api_base(provider: str, api_base: str | None) -> str | None: def _normalize_api_base(provider: str, api_base: str | None) -> str | None:
"""Normalize api_base for LiteLLM provider-specific expectations. """Normalize api_base for LiteLLM provider-specific expectations.
@ -497,11 +553,41 @@ async def complete(
config: LLMConfig | None = None, config: LLMConfig | None = None,
max_tokens: int = 4096, max_tokens: int = 4096,
temperature: float = 0.7, temperature: float = 0.7,
task_name: str | None = None,
) -> str: ) -> str:
"""Make a completion request to the LLM.""" """Make a completion request to the LLM.
When task_name is provided and CF_ORCH_URL is set, routing is resolved via
the task-model assignment layer (POST /api/inference/task) instead of using
hardcoded model_candidates. Falls back to model_candidates routing if the
assignment is missing, then to the default config if cf-orch is unavailable.
"""
if config is None: if config is None:
cf_orch_url = os.environ.get("CF_ORCH_URL", "").strip() cf_orch_url = os.environ.get("CF_ORCH_URL", "").strip()
if cf_orch_url: if cf_orch_url:
# Task-routing path: preferred when a task name is known.
if task_name:
try:
async with _allocate_by_task(
cf_orch_url,
product="peregrine",
task=task_name,
ttl_s=300.0,
caller="peregrine-resume-matcher",
) as alloc:
orch_config = LLMConfig(
provider="openai",
model="__auto__",
api_key="any",
api_base=alloc.url.rstrip("/") + "/v1",
)
return await complete(prompt, system_prompt, orch_config, max_tokens, temperature)
except RuntimeError as exc:
logging.warning(
"cf-orch task routing failed for %r, falling back to model_candidates: %s",
task_name, exc,
)
# Model-candidates path: legacy routing or task fallback.
try: try:
# Premium/ultra users get their personal fine-tuned writing model as the # Premium/ultra users get their personal fine-tuned writing model as the
# first candidate; the base model is the fallback so cf-orch can # first candidate; the base model is the fallback so cf-orch can

View file

@ -14,7 +14,6 @@ Enhanced features:
import argparse import argparse
import csv import csv
import json
import os import os
import random import random
import re import re

View file

@ -31,7 +31,6 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
from scripts.classifier_adapters import ( from scripts.classifier_adapters import (
LABELS, LABELS,
LABEL_DESCRIPTIONS,
ClassifierAdapter, ClassifierAdapter,
GLiClassAdapter, GLiClassAdapter,
RerankerAdapter, RerankerAdapter,

View file

@ -5,7 +5,6 @@ push updates the existing event rather than creating a duplicate.
""" """
from __future__ import annotations from __future__ import annotations
import uuid
import yaml import yaml
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path

View file

@ -121,6 +121,17 @@ CREATE TABLE IF NOT EXISTS survey_responses (
); );
""" """
CREATE_RESUME_CORRECTIONS = """
CREATE TABLE IF NOT EXISTS resume_optimizer_corrections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL REFERENCES jobs(id),
section TEXT NOT NULL,
proposed_json TEXT NOT NULL,
accepted_json TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
"""
CREATE_DIGEST_QUEUE = """ CREATE_DIGEST_QUEUE = """
CREATE TABLE IF NOT EXISTS digest_queue ( CREATE TABLE IF NOT EXISTS digest_queue (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
@ -205,9 +216,10 @@ def _migrate_db(db_path: Path) -> None:
conn.execute("ALTER TABLE background_tasks ADD COLUMN params TEXT") conn.execute("ALTER TABLE background_tasks ADD COLUMN params TEXT")
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass # column already exists pass # column already exists
# Ensure references tables exist (CREATE IF NOT EXISTS is idempotent) # Ensure tables that can't be added via ALTER TABLE exist (all idempotent).
conn.execute(CREATE_REFERENCES) conn.execute(CREATE_REFERENCES)
conn.execute(CREATE_JOB_REFERENCES) conn.execute(CREATE_JOB_REFERENCES)
conn.execute(CREATE_RESUME_CORRECTIONS)
conn.commit() conn.commit()
conn.close() conn.close()
@ -223,6 +235,7 @@ def init_db(db_path: Path = DEFAULT_DB) -> None:
conn.execute(CREATE_DIGEST_QUEUE) conn.execute(CREATE_DIGEST_QUEUE)
conn.execute(CREATE_REFERENCES) conn.execute(CREATE_REFERENCES)
conn.execute(CREATE_JOB_REFERENCES) conn.execute(CREATE_JOB_REFERENCES)
conn.execute(CREATE_RESUME_CORRECTIONS)
conn.commit() conn.commit()
conn.close() conn.close()
_migrate_db(db_path) _migrate_db(db_path)
@ -1241,3 +1254,76 @@ def set_training_exclusion(db_path: Path, job_id: int, excluded: bool) -> None:
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()
# ── Resume optimizer corrections ──────────────────────────────────────────────
def save_resume_correction(
db_path: Path,
job_id: int,
section: str,
proposed: object,
accepted: object,
) -> None:
"""Persist a (proposed, accepted) correction pair from the resume review UI.
Called when a user edits an LLM-proposed value and accepts it. The pair
becomes a supervised fine-tuning (SFT) candidate routed through Avocet.
Args:
section: 'summary' or 'experience:<title>|<company>'
proposed: Original LLM output (string for summary, list for bullets).
accepted: User-edited value (same type as proposed).
"""
import json as _json
conn = sqlite3.connect(db_path)
try:
conn.execute(
"""INSERT INTO resume_optimizer_corrections
(job_id, section, proposed_json, accepted_json)
VALUES (?, ?, ?, ?)""",
(job_id, section, _json.dumps(proposed), _json.dumps(accepted)),
)
conn.commit()
finally:
conn.close()
def get_resume_corrections(
db_path: Path,
limit: int = 200,
job_id: int | None = None,
) -> list[dict]:
"""Return pending resume corrections for Avocet export.
Args:
limit: Maximum rows to return.
job_id: If set, filter to corrections for a specific job.
"""
import json as _json
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
if job_id is not None:
rows = conn.execute(
"SELECT * FROM resume_optimizer_corrections WHERE job_id=? ORDER BY created_at DESC LIMIT ?",
(job_id, limit),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM resume_optimizer_corrections ORDER BY created_at DESC LIMIT ?",
(limit,),
).fetchall()
finally:
conn.close()
return [
{
"id": r["id"],
"job_id": r["job_id"],
"section": r["section"],
"proposed": _json.loads(r["proposed_json"]),
"accepted": _json.loads(r["accepted_json"]),
"created_at": r["created_at"],
}
for r in rows
]

View file

@ -163,7 +163,8 @@ def _ensure_labels(
def create_forgejo_issue(title: str, body: str, labels: list[str]) -> dict: def create_forgejo_issue(title: str, body: str, labels: list[str]) -> dict:
"""Create a Forgejo issue. Returns {"number": int, "url": str}.""" """Create a Forgejo issue. Returns {"number": int, "url": str}."""
token = os.environ.get("FORGEJO_API_TOKEN", "") # Use the bot token when set; fall back to the main API token for dev/self-hosted.
token = os.environ.get("FORGEJO_BOT_TOKEN") or os.environ.get("FORGEJO_API_TOKEN", "")
repo = os.environ.get("FORGEJO_REPO", "pyr0ball/peregrine") repo = os.environ.get("FORGEJO_REPO", "pyr0ball/peregrine")
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1") base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"} headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
@ -183,7 +184,7 @@ def upload_attachment(
issue_number: int, image_bytes: bytes, filename: str = "screenshot.png" issue_number: int, image_bytes: bytes, filename: str = "screenshot.png"
) -> str: ) -> str:
"""Upload a screenshot to an existing Forgejo issue. Returns attachment URL.""" """Upload a screenshot to an existing Forgejo issue. Returns attachment URL."""
token = os.environ.get("FORGEJO_API_TOKEN", "") token = os.environ.get("FORGEJO_BOT_TOKEN") or os.environ.get("FORGEJO_API_TOKEN", "")
repo = os.environ.get("FORGEJO_REPO", "pyr0ball/peregrine") repo = os.environ.get("FORGEJO_REPO", "pyr0ball/peregrine")
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1") base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
headers = {"Authorization": f"token {token}"} headers = {"Authorization": f"token {token}"}

View file

@ -73,7 +73,7 @@ if not LETTERS_JSONL.exists():
sys.exit(f"ERROR: Dataset not found at {LETTERS_JSONL}\n" sys.exit(f"ERROR: Dataset not found at {LETTERS_JSONL}\n"
"Run: make prepare-training (or: python scripts/prepare_training_data.py)") "Run: make prepare-training (or: python scripts/prepare_training_data.py)")
records = [json.loads(l) for l in LETTERS_JSONL.read_text().splitlines() if l.strip()] records = [json.loads(line) for line in LETTERS_JSONL.read_text().splitlines() if line.strip()]
print(f"Loaded {len(records)} training examples.") print(f"Loaded {len(records)} training examples.")
# Convert to chat format expected by SFTTrainer # Convert to chat format expected by SFTTrainer
@ -323,6 +323,6 @@ if gguf_path and gguf_path.exists():
else: else:
print(f"\n{'='*60}") print(f"\n{'='*60}")
print(" Adapter saved (no GGUF produced).") print(" Adapter saved (no GGUF produced).")
print(f" Re-run without --no-gguf to generate a GGUF for Ollama registration.") print(" Re-run without --no-gguf to generate a GGUF for Ollama registration.")
print(f" Adapter path: {adapter_path}") print(f" Adapter path: {adapter_path}")
print(f"{'='*60}\n") print(f"{'='*60}\n")

View file

@ -186,7 +186,7 @@ def build_prompt(
) )
parts.append(f"{recruiter_note}\n") parts.append(f"{recruiter_note}\n")
parts.append(f"Now write a new cover letter for:") parts.append("Now write a new cover letter for:")
parts.append(f" Role: {title}") parts.append(f" Role: {title}")
parts.append(f" Company: {company}") parts.append(f" Company: {company}")
if description: if description:

View file

@ -1,5 +1,5 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime
from scripts.integrations.base import IntegrationBase from scripts.integrations.base import IntegrationBase

View file

@ -25,7 +25,6 @@ import argparse
import shutil import shutil
import sys import sys
from pathlib import Path from pathlib import Path
from textwrap import dedent
import yaml import yaml

View file

@ -348,14 +348,14 @@ def write_compose_override(ports: dict[str, dict]) -> None:
for name, info in to_disable.items(): for name, info in to_disable.items():
lines += [ lines += [
f" {name}: # adopted — host service on :{info['resolved']}", f" {name}: # adopted — host service on :{info['resolved']}",
f" entrypoint: [\"/bin/sh\", \"-c\", \"sleep infinity\"]", " entrypoint: [\"/bin/sh\", \"-c\", \"sleep infinity\"]",
f" ports: []", " ports: []",
f" healthcheck:", " healthcheck:",
f" test: [\"CMD\", \"true\"]", " test: [\"CMD\", \"true\"]",
f" interval: 1s", " interval: 1s",
f" timeout: 1s", " timeout: 1s",
f" start_period: 0s", " start_period: 0s",
f" retries: 1", " retries: 1",
] ]
OVERRIDE_YML.write_text("\n".join(lines) + "\n") OVERRIDE_YML.write_text("\n".join(lines) + "\n")

View file

@ -19,7 +19,6 @@ from __future__ import annotations
import json import json
import logging import logging
import re import re
from pathlib import Path
from typing import Any from typing import Any
log = logging.getLogger(__name__) log = logging.getLogger(__name__)

View file

@ -9,11 +9,9 @@ Falls back to empty dict on unrecoverable errors — caller shows the form build
from __future__ import annotations from __future__ import annotations
import io import io
import json
import logging import logging
import re import re
import zipfile import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
import pdfplumber import pdfplumber

View file

@ -7,7 +7,6 @@ FastAPI application. Callable directly or via the survey_analyze background task
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional

View file

@ -341,7 +341,6 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int,
prioritize_gaps, prioritize_gaps,
rewrite_for_ats, rewrite_for_ats,
hallucination_check, hallucination_check,
render_resume_text,
) )
from scripts.user_profile import load_user_profile from scripts.user_profile import load_user_profile

View file

@ -15,14 +15,13 @@ Public API (unchanged — callers do not need to change):
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import threading import threading
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional
from circuitforge_core.tasks.scheduler import ( from circuitforge_core.tasks.scheduler import (
TaskSpec, # re-export unchanged
LocalScheduler as _CoreTaskScheduler, LocalScheduler as _CoreTaskScheduler,
TaskSpec, # noqa: F401 — re-exported as part of public API; tests import from here
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -12,7 +12,7 @@ import pytest
from dotenv import load_dotenv from dotenv import load_dotenv
from playwright.sync_api import Page, BrowserContext from playwright.sync_api import Page, BrowserContext
from tests.e2e.models import ErrorRecord, ModeConfig, diff_errors from tests.e2e.models import ErrorRecord, ModeConfig
from tests.e2e.modes.demo import DEMO from tests.e2e.modes.demo import DEMO
from tests.e2e.modes.cloud import CLOUD from tests.e2e.modes.cloud import CLOUD
from tests.e2e.modes.local import LOCAL from tests.e2e.modes.local import LOCAL

View file

@ -9,9 +9,9 @@ from __future__ import annotations
import pytest import pytest
from tests.e2e.conftest import ( from tests.e2e.conftest import (
wait_for_streamlit, get_page_errors, screenshot_on_fail, wait_for_streamlit, screenshot_on_fail,
) )
from tests.e2e.models import ModeConfig, diff_errors from tests.e2e.models import diff_errors
from tests.e2e.pages.home_page import HomePage from tests.e2e.pages.home_page import HomePage
from tests.e2e.pages.job_review_page import JobReviewPage from tests.e2e.pages.job_review_page import JobReviewPage
from tests.e2e.pages.apply_page import ApplyPage from tests.e2e.pages.apply_page import ApplyPage

View file

@ -7,8 +7,7 @@ Run: pytest tests/e2e/test_smoke.py --mode=demo
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from tests.e2e.conftest import wait_for_streamlit, get_page_errors, get_console_errors, screenshot_on_fail from tests.e2e.conftest import wait_for_streamlit, screenshot_on_fail
from tests.e2e.models import ModeConfig
from tests.e2e.pages.home_page import HomePage from tests.e2e.pages.home_page import HomePage
from tests.e2e.pages.job_review_page import JobReviewPage from tests.e2e.pages.job_review_page import JobReviewPage
from tests.e2e.pages.apply_page import ApplyPage from tests.e2e.pages.apply_page import ApplyPage

View file

@ -1,4 +1,3 @@
from pathlib import Path
import yaml import yaml
from scripts.user_profile import UserProfile from scripts.user_profile import UserProfile

View file

@ -1,12 +1,10 @@
"""Tests for scripts/backup.py — create, list, restore, and multi-instance support.""" """Tests for scripts/backup.py — create, list, restore, and multi-instance support."""
from __future__ import annotations from __future__ import annotations
import json
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest
from scripts.backup import ( from scripts.backup import (
_decrypt_db_to_bytes, _decrypt_db_to_bytes,

View file

@ -1,5 +1,4 @@
"""Tests for BYOK cloud backend detection.""" """Tests for BYOK cloud backend detection."""
import pytest
from scripts.byok_guard import is_cloud_backend, cloud_backends from scripts.byok_guard import is_cloud_backend, cloud_backends

View file

@ -8,7 +8,6 @@ from datetime import timezone
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))

View file

@ -1,7 +1,4 @@
import pytest from unittest.mock import patch
import os
from unittest.mock import patch, MagicMock
from pathlib import Path
def test_resolve_session_is_noop_in_local_mode(monkeypatch): def test_resolve_session_is_noop_in_local_mode(monkeypatch):

View file

@ -1,6 +1,4 @@
# tests/test_cover_letter.py # tests/test_cover_letter.py
import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
@ -90,7 +88,7 @@ def test_find_similar_letters_returns_top_k():
def test_load_corpus_returns_list(): def test_load_corpus_returns_list():
"""load_corpus returns a list (empty if LETTERS_DIR absent) without crashing.""" """load_corpus returns a list (empty if LETTERS_DIR absent) without crashing."""
from scripts.generate_cover_letter import load_corpus, LETTERS_DIR from scripts.generate_cover_letter import load_corpus
corpus = load_corpus() corpus = load_corpus()
assert isinstance(corpus, list) assert isinstance(corpus, list)

View file

@ -95,7 +95,6 @@ class TestTaskRunnerCoverLetterParams:
patch("sqlite3.connect") as mock_conn, \ patch("sqlite3.connect") as mock_conn, \
patch("scripts.task_runner.generate_cover_letter_fn", mock_generate, create=True): patch("scripts.task_runner.generate_cover_letter_fn", mock_generate, create=True):
import sqlite3
mock_row = MagicMock() mock_row = MagicMock()
mock_row.__iter__ = lambda s: iter(job.items()) mock_row.__iter__ = lambda s: iter(job.items())
mock_row.keys = lambda: job.keys() mock_row.keys = lambda: job.keys()

View file

@ -4,7 +4,6 @@ from email.utils import format_datetime
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
import pytest
import requests import requests

View file

@ -1,7 +1,4 @@
import pytest
import sqlite3 import sqlite3
from pathlib import Path
from unittest.mock import patch
def test_init_db_creates_jobs_table(tmp_path): def test_init_db_creates_jobs_table(tmp_path):

View file

@ -1,7 +1,6 @@
"""Tests for scripts/db_migrate.py — numbered SQL migration runner.""" """Tests for scripts/db_migrate.py — numbered SQL migration runner."""
import sqlite3 import sqlite3
import textwrap
from pathlib import Path from pathlib import Path
import pytest import pytest

View file

@ -1,7 +1,5 @@
"""Tests for resume library db helpers.""" """Tests for resume library db helpers."""
import sqlite3 import sqlite3
import tempfile
from pathlib import Path
import pytest import pytest

View file

@ -1,6 +1,5 @@
"""IS_DEMO write-block guard tests.""" """IS_DEMO write-block guard tests."""
import importlib import importlib
import os
import sqlite3 import sqlite3
import pytest import pytest

View file

@ -1,7 +1,6 @@
"""Tests for app/components/demo_toolbar.py.""" """Tests for app/components/demo_toolbar.py."""
import sys import sys
from pathlib import Path from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))

View file

@ -1,6 +1,5 @@
"""Tests for digest queue API endpoints.""" """Tests for digest queue API endpoints."""
import sqlite3 import sqlite3
import os
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient

View file

@ -1,7 +1,5 @@
"""Tests for new dev-api.py endpoints: stage signals, email sync, signal dismiss.""" """Tests for new dev-api.py endpoints: stage signals, email sync, signal dismiss."""
import sqlite3 import sqlite3
import tempfile
import os
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient

View file

@ -1,5 +1,4 @@
"""Tests for interview prep endpoints: research GET/generate/task, contacts GET.""" """Tests for interview prep endpoints: research GET/generate/task, contacts GET."""
import json
import pytest import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@ -17,7 +16,6 @@ def client():
def test_get_research_found(client): def test_get_research_found(client):
"""Returns research row (minus raw_output) when present.""" """Returns research row (minus raw_output) when present."""
import sqlite3
mock_row = { mock_row = {
"job_id": 1, "job_id": 1,
"company_brief": "Acme Corp makes anvils.", "company_brief": "Acme Corp makes anvils.",

View file

@ -1,10 +1,9 @@
"""Tests for all settings API endpoints added in Tasks 18.""" """Tests for all settings API endpoints added in Tasks 18."""
import os import os
import sys
import yaml import yaml
import pytest import pytest
from pathlib import Path from pathlib import Path
from unittest.mock import patch, MagicMock from unittest.mock import patch
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
# credential_store.py was merged to main repo — no worktree path manipulation needed # credential_store.py was merged to main repo — no worktree path manipulation needed

View file

@ -1,6 +1,5 @@
import sys import sys
from pathlib import Path from pathlib import Path
import yaml
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))

View file

@ -1,8 +1,6 @@
# tests/test_discover.py # tests/test_discover.py
import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
import pandas as pd import pandas as pd
from pathlib import Path
SAMPLE_JOB = { SAMPLE_JOB = {
"title": "Customer Success Manager", "title": "Customer Success Manager",

View file

@ -1,5 +1,4 @@
"""Unit tests for E2E harness models and helper utilities.""" """Unit tests for E2E harness models and helper utilities."""
import fnmatch
import pytest import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
import time import time

View file

@ -1,7 +1,5 @@
"""Tests for the feedback API backend.""" """Tests for the feedback API backend."""
import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from pathlib import Path
# ── mask_pii ────────────────────────────────────────────────────────────────── # ── mask_pii ──────────────────────────────────────────────────────────────────

View file

@ -1,5 +1,4 @@
"""Tests for imap_sync helpers (no live IMAP connection required).""" """Tests for imap_sync helpers (no live IMAP connection required)."""
import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
@ -510,7 +509,7 @@ def test_search_folder_special_gmail_name():
def test_get_existing_message_ids_excludes_null(tmp_path): def test_get_existing_message_ids_excludes_null(tmp_path):
"""NULL message_id rows are excluded from the returned set.""" """NULL message_id rows are excluded from the returned set."""
import sqlite3 import sqlite3
from scripts.db import init_db, insert_job, add_contact from scripts.db import init_db, insert_job
from scripts.imap_sync import _get_existing_message_ids from scripts.imap_sync import _get_existing_message_ids
db_path = tmp_path / "test.db" db_path = tmp_path / "test.db"
@ -980,7 +979,6 @@ def test_scan_todo_label_stage_signal_set_for_non_neutral(tmp_path):
def test_scan_todo_label_body_fallback_matches(tmp_path): def test_scan_todo_label_body_fallback_matches(tmp_path):
"""Company name only in body[:300] still triggers a match (body fallback).""" """Company name only in body[:300] still triggers a match (body fallback)."""
from scripts.db import get_contacts
from scripts.imap_sync import _scan_todo_label from scripts.imap_sync import _scan_todo_label
db_path = tmp_path / "test.db" db_path = tmp_path / "test.db"
@ -1110,7 +1108,6 @@ def test_parse_message_large_body_not_truncated():
def test_parse_message_binary_attachment_no_crash(): def test_parse_message_binary_attachment_no_crash():
"""Email with binary attachment returns a valid dict without crashing.""" """Email with binary attachment returns a valid dict without crashing."""
from scripts.imap_sync import _parse_message from scripts.imap_sync import _parse_message
import email as _email
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.application import MIMEApplication from email.mime.application import MIMEApplication

View file

@ -72,7 +72,6 @@ def test_fields_returns_list_of_dicts():
def test_save_and_load_config(tmp_path): def test_save_and_load_config(tmp_path):
"""save_config writes yaml; load_config reads it back.""" """save_config writes yaml; load_config reads it back."""
from scripts.integrations.base import IntegrationBase from scripts.integrations.base import IntegrationBase
import yaml
class TestIntegration(IntegrationBase): class TestIntegration(IntegrationBase):
name = "savetest" name = "savetest"

View file

@ -1,7 +1,6 @@
import json import json
import pytest import pytest
from pathlib import Path from pathlib import Path
from unittest.mock import patch, MagicMock
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
import jwt as pyjwt import jwt as pyjwt

View file

@ -1,8 +1,6 @@
import json import json
import pytest import pytest
from pathlib import Path
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
import jwt as pyjwt import jwt as pyjwt

View file

@ -1,4 +1,3 @@
from pathlib import Path
import yaml import yaml
from scripts.user_profile import UserProfile from scripts.user_profile import UserProfile
from scripts.generate_llm_config import apply_service_urls from scripts.generate_llm_config import apply_service_urls

View file

@ -110,7 +110,7 @@ def test_complete_without_images_skips_vision_service(tmp_path):
"""When images=None, vision_service backend is skipped.""" """When images=None, vision_service backend is skipped."""
import yaml import yaml
from scripts.llm_router import LLMRouter from scripts.llm_router import LLMRouter
from unittest.mock import patch, MagicMock from unittest.mock import patch
cfg = { cfg = {
"fallback_order": ["vision_service"], "fallback_order": ["vision_service"],

View file

@ -1,7 +1,7 @@
"""Tests for Peregrine's LLMRouter shim — priority fallback logic.""" """Tests for Peregrine's LLMRouter shim — priority fallback logic."""
import sys import sys
from pathlib import Path from pathlib import Path
from unittest.mock import patch, MagicMock, call from unittest.mock import patch, MagicMock
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
@ -54,7 +54,6 @@ def test_uses_local_yaml_when_present():
def test_falls_through_to_env_when_no_yamls(): def test_falls_through_to_env_when_no_yamls():
"""When no yaml files exist, super().__init__ is called with no args (env-var path).""" """When no yaml files exist, super().__init__ is called with no args (env-var path)."""
import scripts.llm_router as shim_mod
from circuitforge_core.llm import LLMRouter as _CoreLLMRouter from circuitforge_core.llm import LLMRouter as _CoreLLMRouter
captured = {} captured = {}

View file

@ -1,4 +1,3 @@
import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock

View file

@ -1,6 +1,4 @@
"""Integration tests for messaging endpoints.""" """Integration tests for messaging endpoints."""
import os
from pathlib import Path
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient

View file

@ -4,7 +4,6 @@ import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
import yaml
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))

View file

@ -1,10 +1,8 @@
"""Tests for scripts/preflight.py additions: dual-GPU service table, size warning, VRAM check.""" """Tests for scripts/preflight.py additions: dual-GPU service table, size warning, VRAM check."""
import pytest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import yaml import yaml
import tempfile import tempfile
import os
# ── Service table ────────────────────────────────────────────────────────────── # ── Service table ──────────────────────────────────────────────────────────────

View file

@ -1,7 +1,7 @@
"""Tests: preflight writes OLLAMA_HOST to .env when Ollama is adopted from host.""" """Tests: preflight writes OLLAMA_HOST to .env when Ollama is adopted from host."""
import sys import sys
from pathlib import Path from pathlib import Path
from unittest.mock import patch, call from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))

View file

@ -4,7 +4,6 @@
Set CF_RERANKER_MOCK=1 to avoid loading real model weights during tests. Set CF_RERANKER_MOCK=1 to avoid loading real model weights during tests.
""" """
import os import os
import pytest
from unittest.mock import patch from unittest.mock import patch
os.environ["CF_RERANKER_MOCK"] = "1" os.environ["CF_RERANKER_MOCK"] = "1"

View file

@ -1,8 +1,7 @@
# tests/test_resume_optimizer.py # tests/test_resume_optimizer.py
"""Tests for scripts/resume_optimizer.py""" """Tests for scripts/resume_optimizer.py"""
import json import json
import pytest from unittest.mock import patch
from unittest.mock import MagicMock, patch
# ── Fixtures ───────────────────────────────────────────────────────────────── # ── Fixtures ─────────────────────────────────────────────────────────────────

View file

@ -1,6 +1,4 @@
"""Unit tests for scripts.resume_sync — format transform between library and profile.""" """Unit tests for scripts.resume_sync — format transform between library and profile."""
import json
import pytest
from scripts.resume_sync import ( from scripts.resume_sync import (
library_to_profile_content, library_to_profile_content,
profile_to_library, profile_to_library,

View file

@ -1,7 +1,5 @@
"""Integration tests for resume library<->profile sync endpoints.""" """Integration tests for resume library<->profile sync endpoints."""
import json import json
import os
from pathlib import Path
import pytest import pytest
import yaml import yaml

View file

@ -1,9 +1,6 @@
"""Tests for /api/resumes/* endpoints.""" """Tests for /api/resumes/* endpoints."""
import json
import io import io
import sqlite3 import sqlite3
import tempfile
from pathlib import Path
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient

View file

@ -1,7 +1,5 @@
# tests/test_sync.py # tests/test_sync.py
import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from pathlib import Path
SAMPLE_FM = { SAMPLE_FM = {

View file

@ -1,7 +1,5 @@
import threading
import time import time
import pytest import pytest
from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import sqlite3 import sqlite3
@ -178,7 +176,6 @@ def test_submit_task_actually_completes(tmp_path):
def test_run_task_enrich_craigslist_success(tmp_path): def test_run_task_enrich_craigslist_success(tmp_path):
"""enrich_craigslist task calls enrich_craigslist_fields and marks completed.""" """enrich_craigslist task calls enrich_craigslist_fields and marks completed."""
from scripts.db import init_db, insert_job, insert_task, get_task_for_job from scripts.db import init_db, insert_job, insert_task, get_task_for_job
from unittest.mock import MagicMock
db = tmp_path / "test.db" db = tmp_path / "test.db"
init_db(db) init_db(db)
job_id = insert_job(db, { job_id = insert_job(db, {
@ -200,7 +197,7 @@ def test_run_task_enrich_craigslist_success(tmp_path):
def test_scrape_url_submits_enrich_craigslist_for_craigslist_job(tmp_path): def test_scrape_url_submits_enrich_craigslist_for_craigslist_job(tmp_path):
"""After scrape_url completes for a craigslist job with empty company, enrich_craigslist is queued.""" """After scrape_url completes for a craigslist job with empty company, enrich_craigslist is queued."""
from scripts.db import init_db, insert_job, insert_task, get_task_for_job from scripts.db import init_db, insert_job, insert_task
db = tmp_path / "test.db" db = tmp_path / "test.db"
init_db(db) init_db(db)
job_id = insert_job(db, { job_id = insert_job(db, {
@ -285,7 +282,7 @@ def test_wizard_generate_null_params_fails(tmp_path):
def test_wizard_generate_stores_result_as_json(tmp_path): def test_wizard_generate_stores_result_as_json(tmp_path):
"""wizard_generate stores result JSON in error field on success.""" """wizard_generate stores result JSON in error field on success."""
from unittest.mock import patch, MagicMock from unittest.mock import patch
db = tmp_path / "t.db" db = tmp_path / "t.db"
from scripts.db import init_db, insert_task from scripts.db import init_db, insert_task
init_db(db) init_db(db)
@ -311,7 +308,7 @@ def test_wizard_generate_stores_result_as_json(tmp_path):
def test_wizard_generate_feedback_appended_to_prompt(tmp_path): def test_wizard_generate_feedback_appended_to_prompt(tmp_path):
"""feedback and previous_result fields in input_data are appended to the prompt.""" """feedback and previous_result fields in input_data are appended to the prompt."""
from unittest.mock import patch, MagicMock from unittest.mock import patch
db = tmp_path / "t.db" db = tmp_path / "t.db"
from scripts.db import init_db, insert_task from scripts.db import init_db, insert_task
init_db(db) init_db(db)

View file

@ -3,7 +3,6 @@
import sqlite3 import sqlite3
import threading import threading
from collections import deque from collections import deque
from pathlib import Path
import pytest import pytest
@ -192,7 +191,6 @@ def test_max_queue_depth_logs_warning(tmp_db, caplog):
"""Queue depth overflow logs a WARNING.""" """Queue depth overflow logs a WARNING."""
import logging import logging
from scripts.db import insert_task from scripts.db import insert_task
from scripts.task_scheduler import TaskSpec
s = TaskScheduler(tmp_db, _noop_run_task) s = TaskScheduler(tmp_db, _noop_run_task)
s._max_queue_depth = 0 # immediately at limit s._max_queue_depth = 0 # immediately at limit

View file

@ -1,6 +1,4 @@
import pytest from unittest.mock import patch, MagicMock
import os
from unittest.mock import patch, MagicMock, call
def test_no_op_in_local_mode(monkeypatch): def test_no_op_in_local_mode(monkeypatch):

View file

@ -1,7 +1,7 @@
# tests/test_user_profile.py # tests/test_user_profile.py
import pytest import pytest
from pathlib import Path from pathlib import Path
import tempfile, yaml import yaml
from scripts.user_profile import UserProfile from scripts.user_profile import UserProfile
@pytest.fixture @pytest.fixture

View file

@ -4,7 +4,7 @@ from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
from app.wizard.tiers import can_use, tier_label, TIERS, FEATURES, BYOK_UNLOCKABLE from app.wizard.tiers import can_use, tier_label, TIERS, BYOK_UNLOCKABLE
def test_tiers_list(): def test_tiers_list():

View file

@ -352,8 +352,8 @@ with tab_fetch:
if not accounts: if not accounts:
st.warning( st.warning(
f"No accounts configured. Copy `config/label_tool.yaml.example` → " "No accounts configured. Copy `config/label_tool.yaml.example` → "
f"`config/label_tool.yaml` and add your IMAP accounts.", "`config/label_tool.yaml` and add your IMAP accounts.",
icon="⚠️", icon="⚠️",
) )
else: else:
@ -625,7 +625,7 @@ with tab_stats:
st.markdown(f"**{len(labeled)} labeled emails total**") st.markdown(f"**{len(labeled)} labeled emails total**")
# Show known labels first, then any custom labels # Show known labels first, then any custom labels
all_display_labels = list(LABELS) + [l for l in counts if l not in LABELS] all_display_labels = list(LABELS) + [lbl for lbl in counts if lbl not in LABELS]
max_count = max(counts.values()) if counts else 1 max_count = max(counts.values()) if counts else 1
for lbl in all_display_labels: for lbl in all_display_labels:
if lbl not in counts: if lbl not in counts:

275
web/package-lock.json generated
View file

@ -346,9 +346,9 @@
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -363,9 +363,9 @@
} }
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -380,9 +380,9 @@
} }
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -397,9 +397,9 @@
} }
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -414,9 +414,9 @@
} }
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -431,9 +431,9 @@
} }
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -448,9 +448,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -465,9 +465,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -482,9 +482,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -499,9 +499,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -516,9 +516,9 @@
} }
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -533,9 +533,9 @@
} }
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@ -550,9 +550,9 @@
} }
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
@ -567,9 +567,9 @@
} }
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -584,9 +584,9 @@
} }
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -601,9 +601,9 @@
} }
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -618,9 +618,9 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -635,9 +635,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-arm64": { "node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -652,9 +652,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -669,9 +669,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-arm64": { "node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -686,9 +686,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -703,9 +703,9 @@
} }
}, },
"node_modules/@esbuild/openharmony-arm64": { "node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -720,9 +720,9 @@
} }
}, },
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -737,9 +737,9 @@
} }
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -754,9 +754,9 @@
} }
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -771,9 +771,9 @@
} }
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2728,9 +2728,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2949,9 +2949,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/defu": { "node_modules/defu": {
"version": "6.1.4", "version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -3032,9 +3032,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
@ -3045,32 +3045,32 @@
"node": ">=18" "node": ">=18"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.4", "@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.4", "@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.4", "@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.4", "@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.4", "@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.4", "@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.4", "@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.4", "@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.4", "@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.4", "@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.4" "@esbuild/win32-x64": "0.27.7"
} }
}, },
"node_modules/estree-walker": { "node_modules/estree-walker": {
@ -3325,14 +3325,11 @@
} }
}, },
"node_modules/js-cookie": { "node_modules/js-cookie": {
"version": "3.0.5", "version": "3.0.8",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"engines": {
"node": ">=14"
}
}, },
"node_modules/jsdom": { "node_modules/jsdom": {
"version": "28.1.0", "version": "28.1.0",
@ -3500,9 +3497,9 @@
} }
}, },
"node_modules/marked": { "node_modules/marked": {
"version": "18.0.0", "version": "18.0.5",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.0.tgz", "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz",
"integrity": "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==", "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"marked": "bin/marked.js" "marked": "bin/marked.js"
@ -3586,9 +3583,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -3787,9 +3784,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@ -3831,9 +3828,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.8", "version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@ -3850,7 +3847,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.12",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@ -4484,9 +4481,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.1", "version": "7.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -4987,9 +4984,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.8.2", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"

View file

@ -0,0 +1,57 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApiFetch } from '../../composables/useApi'
export const SYNC_DATA_CLASSES = [
{ key: 'peregrine:dismissed', label: 'Dismissed job IDs', description: 'Hides jobs you have already reviewed across devices.' },
{ key: 'peregrine:drafts', label: 'Cover letter drafts', description: 'Saves in-progress drafts so you can continue on another device.' },
] as const
export type SyncDataClass = typeof SYNC_DATA_CLASSES[number]['key']
export interface SyncPref {
data_class: string
enabled: boolean
}
export const useSyncStore = defineStore('sync', () => {
const prefs = ref<Record<string, boolean>>({})
const loading = ref(false)
const saving = ref<string | null>(null)
const wiping = ref(false)
const error = ref<string | null>(null)
async function loadPrefs() {
loading.value = true
error.value = null
const { data, error: err } = await useApiFetch<SyncPref[]>('/sync/prefs')
loading.value = false
if (err) { error.value = 'Failed to load sync preferences.'; return }
const map: Record<string, boolean> = {}
for (const p of data ?? []) map[p.data_class] = p.enabled
prefs.value = map
}
async function setPref(dataClass: string, enabled: boolean) {
saving.value = dataClass
error.value = null
const { error: err } = await useApiFetch('/sync/prefs', {
method: 'PATCH',
body: JSON.stringify({ data_class: dataClass, enabled }),
})
saving.value = null
if (err) { error.value = `Failed to update sync preference for ${dataClass}.`; return }
prefs.value = { ...prefs.value, [dataClass]: enabled }
}
async function wipeAll() {
wiping.value = true
error.value = null
const { error: err } = await useApiFetch('/sync/all', { method: 'DELETE' })
wiping.value = false
if (err) { error.value = 'Failed to delete sync data.'; return }
prefs.value = {}
}
return { prefs, loading, saving, wiping, error, loadPrefs, setPref, wipeAll }
})

View file

@ -1,7 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useDataStore } from '../../stores/settings/data' import { useDataStore } from '../../stores/settings/data'
import { useSyncStore, SYNC_DATA_CLASSES } from '../../stores/settings/sync'
import { useAppConfigStore } from '../../stores/appConfig'
const store = useDataStore() const store = useDataStore()
const { backupPath, backupFileCount, backupSizeBytes, creatingBackup, backupError } = storeToRefs(store) const { backupPath, backupFileCount, backupSizeBytes, creatingBackup, backupError } = storeToRefs(store)
@ -9,6 +11,13 @@ const includeDb = ref(false)
const showRestoreConfirm = ref(false) const showRestoreConfirm = ref(false)
const restoreFile = ref<File | null>(null) const restoreFile = ref<File | null>(null)
const sync = useSyncStore()
const config = useAppConfigStore()
const canSync = config.isCloud && ['paid', 'premium'].includes(config.tier)
onMounted(() => { if (config.isCloud) sync.loadPrefs() })
function formatBytes(b: number) { function formatBytes(b: number) {
if (b < 1024) return `${b} B` if (b < 1024) return `${b} B`
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB` if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`
@ -77,5 +86,71 @@ function formatBytes(b: number) {
</div> </div>
</Teleport> </Teleport>
</section> </section>
<!-- Cross-device sync cloud accounts only -->
<section v-if="config.isCloud" class="form-section">
<h3>Cross-device Sync <span class="tier-badge">Paid</span></h3>
<p class="section-note">
Sync selected data to your cloud account so it follows you across devices.
Each category is opt-in nothing is uploaded until you enable it.
</p>
<div v-if="sync.loading" class="sync-loading">Loading sync preferences</div>
<template v-else-if="canSync">
<div v-for="dc in SYNC_DATA_CLASSES" :key="dc.key" class="sync-row">
<label class="sync-toggle-label">
<input
type="checkbox"
:checked="sync.prefs[dc.key] ?? false"
:disabled="sync.saving === dc.key"
@change="sync.setPref(dc.key, ($event.target as HTMLInputElement).checked)"
/>
<span class="sync-label-text">
<strong>{{ dc.label }}</strong>
<span class="sync-label-desc">{{ dc.description }}</span>
</span>
</label>
</div>
<p v-if="sync.error" class="error-msg">{{ sync.error }}</p>
</template>
<p v-else class="tier-gate-note">
Cross-device sync is available on the Paid and Premium plans.
</p>
<!-- Delete all tier-free, always shown to cloud users -->
<div class="form-actions sync-delete-row">
<button
class="btn-danger"
:disabled="sync.wiping"
@click="sync.wipeAll()"
>
{{ sync.wiping ? 'Deleting…' : 'Delete all sync data' }}
</button>
<span class="section-note">Removes all uploaded sync data immediately. Preferences are also reset.</span>
</div>
</section>
</div> </div>
</template> </template>
<style scoped>
.tier-badge {
font-size: 0.7rem;
font-weight: 600;
padding: 0.15em 0.5em;
border-radius: 4px;
background: var(--color-accent, #6c63ff);
color: #fff;
vertical-align: middle;
margin-left: 0.4em;
}
.sync-loading { color: var(--color-text-muted); font-size: 0.9rem; margin: 0.5rem 0; }
.sync-row { margin: 0.75rem 0; }
.sync-toggle-label { display: flex; align-items: flex-start; gap: 0.6rem; cursor: pointer; }
.sync-label-text { display: flex; flex-direction: column; gap: 0.1rem; }
.sync-label-desc { font-size: 0.8rem; color: var(--color-text-muted); }
.sync-delete-row { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-top: 1rem; }
.sync-delete-row .section-note { margin: 0; }
.tier-gate-note { font-size: 0.85rem; color: var(--color-text-muted); margin: 0.5rem 0; }
</style>