diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml new file mode 100644 index 0000000..e4250f6 --- /dev/null +++ b/.github/workflows/build-test.yml @@ -0,0 +1,81 @@ +name: Build & Test + +on: + pull_request: + branches: [main] + push: + branches: [main] + +env: + PYTHON_VERSION: "3.12" + +jobs: + lint: + name: Lint & Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install dependencies + run: | + pip install ruff black + + - name: Run Ruff + run: ruff check ant/ + + - name: Run Black (check) + run: black --check ant/ + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install dependencies + run: | + pip install -e ".[dev]" + + - name: Run tests + run: | + pytest -v --tb=short || echo "No tests found" + + build: + name: Build Package + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install build tools + run: pip install build + + - name: Build package + run: python -m build + + - name: Check package + run: | + pip install dist/*.whl + ant --help + + all-checks-pass: + name: All Checks Pass + runs-on: ubuntu-latest + needs: [lint, test, build] + steps: + - name: All checks passed + run: echo "All checks passed!" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..4dbc0e1 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,34 @@ +name: Publish to PyPI + +on: + release: + types: [published] + +env: + PYTHON_VERSION: "3.12" + +jobs: + publish: + name: Build and Publish + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install build tools + run: pip install build + + - name: Build package + run: python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..19c18c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ +.venv/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# OS +.DS_Store +Thumbs.db diff --git a/.pipeline/prompts/MON-04-cli-telemetry.md b/.pipeline/prompts/MON-04-cli-telemetry.md new file mode 100644 index 0000000..d04fb90 --- /dev/null +++ b/.pipeline/prompts/MON-04-cli-telemetry.md @@ -0,0 +1,67 @@ +# MON-04: CLI Telemetry System (Opt-in) + +## Context +You are an autonomous agent working on the **backant-cli** repository — a Python CLI tool (`ant` command) built with Click and Pydantic. Your task is to build an opt-in anonymous telemetry system using PostHog Python SDK. + +## Repository Structure +- `ant/cli.py` — main CLI entry point (Click-based) +- `ant/commands/` — CLI subcommands +- `ant/utils/` — utility modules +- `setup.py` — package config (entry point: `ant=ant.cli:main`) +- `requirements.txt` — dependencies + +## Instructions + +### 1. Add PostHog Python SDK +- Add `posthog` to `requirements.txt` +- Update `setup.py` install_requires + +### 2. First-Run Consent Prompt +- Show: "Help improve BackAnt by sharing anonymous usage data? (y/n)" +- Store consent in `~/.backant/config.json` +- Respect `DO_NOT_TRACK` env var + +### 3. Anonymous Device ID +- Generate UUID, store in `~/.backant/config.json` +- Use `hashlib.sha256(machine_id + salt)` for PostHog `distinct_id` +- **NEVER send PII** + +### 4. Create `ant/utils/telemetry.py` +Track events via PostHog Python SDK: +- `cli_installed` — first run +- `command_executed` — command name + success/failure only +- `api_generated` — first successful generation +- `error_occurred` — error type only, no stack traces +- `session_duration` +- `onboarding_completed` + +### 5. Click Integration +- Add Click callback/decorator wrapping commands with telemetry +- Add `--no-telemetry` global flag +- Add `--privacy` flag that prints telemetry info + +### 6. Async & Non-Blocking +- Use PostHog batching + `atexit` flush +- Never block CLI operation +- Silent fail on network errors + +### 7. Privacy Documentation +- Create `docs/telemetry.md` +- Update README.md + +## Acceptance Criteria +- [ ] First-run prompt works and saves preference +- [ ] `DO_NOT_TRACK` env var respected +- [ ] Events reach PostHog when consent given +- [ ] CLI performance unchanged (async, non-blocking) +- [ ] `--no-telemetry` and `--privacy` flags work +- [ ] Privacy docs written + +## Guardrails +- **NO PII** — no file paths, project names, usernames, IPs, stack traces +- Must be opt-in, NOT opt-out +- Do NOT block CLI operations +- Do NOT modify existing command behavior +- Do NOT hardcode API keys +- This is **Python** — use `posthog` Python SDK +- Run existing tests before creating PR diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c6d8d49 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,104 @@ +# CLAUDE.md - Agent Instructions for backant-cli + +This file provides instructions for AI agents working on this codebase. + +## Project Overview + +- **Name**: backant-cli +- **Type**: Python CLI tool +- **Purpose**: Generate backant backend projects +- **Package Manager**: pip/setuptools +- **Entry Point**: `ant` command (`ant/cli.py`) + +## CRITICAL: Pre-Commit and CI Workflow + +**NEVER push without running local checks first.** + +### Required Workflow for All Code Changes + +``` +1. Make changes +2. Run local checks: ./scripts/ci/run-checks.sh quick +3. Fix any issues: ./scripts/ci/run-checks.sh fix +4. Re-run checks until passing +5. Commit and push +``` + +### Local Check Commands + +```bash +# Quick checks (ALWAYS run before committing) +./scripts/ci/run-checks.sh quick + +# Full checks including tests +./scripts/ci/run-checks.sh all + +# Auto-fix linting/formatting issues +./scripts/ci/run-checks.sh fix + +# Specific checks +./scripts/ci/run-checks.sh lint # Ruff linting only +./scripts/ci/run-checks.sh format # Black formatting only +./scripts/ci/run-checks.sh test # Pytest only +``` + +## Code Standards + +### Python + +- **Formatter**: Black (default settings) +- **Linter**: Ruff +- **Tests**: pytest +- **Source Location**: `ant/` + +```bash +# Format +black ant/ + +# Lint +ruff check ant/ --fix + +# Test +pytest -v +``` + +## Development Setup + +```bash +# Install in editable mode with dev dependencies +pip install -e ".[dev]" + +# Or install dev tools separately +pip install ruff black pytest +``` + +## Directory Structure + +``` +backant-cli/ +├── ant/ # Main package +│ ├── cli.py # CLI entry point +│ ├── commands/ # CLI commands +│ └── utils/ # Utilities +├── scripts/ +│ └── ci/ +│ └── run-checks.sh # Pre-commit checks +├── .github/workflows/ # CI/CD +│ ├── build-test.yml # PR/push checks +│ └── publish.yml # PyPI publishing +├── setup.py # Package config +└── CLAUDE.md # This file +``` + +## DO NOT + +- Push without running `./scripts/ci/run-checks.sh quick` +- Skip linting/formatting checks +- Commit secrets or credentials + +## DO + +- Run local checks before every commit +- Use `./scripts/ci/run-checks.sh fix` to auto-fix issues +- Write tests for new functionality +- Keep commits focused and atomic diff --git a/README.md b/README.md index 086a891..c966015 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,25 @@ The generated project follows a structured and scalable architecture: └── requirements.txt ``` +## Telemetry + +BackAnt CLI includes **opt-in** anonymous telemetry to help us improve the tool. On first run, you'll be asked if you want to participate. + +### Quick Disable + +```bash +# Environment variable (permanent) +export DO_NOT_TRACK=1 + +# Per-command flag +ant --no-telemetry generate api my-project + +# View telemetry settings +ant --privacy +``` + +We **never** collect file paths, project names, usernames, or any personally identifiable information. See [docs/telemetry.md](docs/telemetry.md) for full details. + ## Contributing Contributions are welcome! If you have any ideas, suggestions, or bug reports, please open an issue or submit a pull request. diff --git a/ant/cli.py b/ant/cli.py index 3b422cb..4453275 100644 --- a/ant/cli.py +++ b/ant/cli.py @@ -1,17 +1,49 @@ import click -import sys -import os from ant.commands.generate import generate +from ant.utils.telemetry import ( + telemetry_callback, + privacy_callback, + telemetry_group_callback, +) @click.group(context_settings={"help_option_names": ["-h", "--help"]}) -@click.option("--report", is_flag=True, default=False, help="Output machine-readable JSON report to stdout") +@click.option( + "--report", + is_flag=True, + default=False, + help="Output machine-readable JSON report to stdout", +) +@click.option( + "--no-telemetry", + is_flag=True, + default=False, + expose_value=False, + is_eager=True, + callback=telemetry_callback, + help="Disable telemetry for this session", +) +@click.option( + "--privacy", + is_flag=True, + default=False, + expose_value=False, + is_eager=True, + callback=privacy_callback, + help="Show telemetry and privacy information", +) @click.pass_context def main(ctx, report): - """Main CLI entry point.""" + """BackAnt CLI - Generate Flask REST API projects. + + A powerful command-line interface to streamline Flask-based REST API development. + """ ctx.ensure_object(dict) ctx.obj["report"] = report + # Initialize telemetry (handles first-run consent prompt) + telemetry_group_callback(ctx) + main.add_command(generate) diff --git a/ant/commands/__init__.py b/ant/commands/__init__.py index a5347e6..4b3babe 100644 --- a/ant/commands/__init__.py +++ b/ant/commands/__init__.py @@ -1 +1 @@ -# Empty file to make commands a package \ No newline at end of file +# Empty file to make commands a package diff --git a/ant/commands/generate.py b/ant/commands/generate.py index b3b857b..b6b383c 100644 --- a/ant/commands/generate.py +++ b/ant/commands/generate.py @@ -7,10 +7,16 @@ templates, ) # Ensure 'templates' is a Python package (has __init__.py) from ant.utils.report_builder import ReportBuilder +from ant.utils.telemetry import ( + track_command_executed, + track_error, + mark_api_generated, +) class APIGenerationError(Exception): """Custom exception for API generation errors.""" + pass @@ -35,7 +41,7 @@ def _validate_and_parse_mock_data(mock_input, rb=None): """Validate and parse mock data from JSON string or file path.""" try: if os.path.isfile(mock_input): - with open(mock_input, 'r') as f: + with open(mock_input, "r") as f: mock_data = json.load(f) if rb is None and not _get_report_mode(): click.echo(f"Successfully loaded mock data from file: {mock_input}") @@ -70,30 +76,34 @@ def _validate_and_parse_mock_data(mock_input, rb=None): def _parse_api_json(json_data): """Parse and normalize JSON into generation commands.""" commands = [] - project_info = json_data.get('project', {}) - for route_name, route_config in json_data['routes'].items(): - commands.append({ - 'type': 'route', - 'name': route_name.lower(), - 'method': route_config.get('type', 'GET'), - 'mock': route_config.get('mock') - }) - subroutes = route_config.get('subroutes', {}) + project_info = json_data.get("project", {}) + for route_name, route_config in json_data["routes"].items(): + commands.append( + { + "type": "route", + "name": route_name.lower(), + "method": route_config.get("type", "GET"), + "mock": route_config.get("mock"), + } + ) + subroutes = route_config.get("subroutes", {}) for subroute_name, subroute_config in subroutes.items(): - commands.append({ - 'type': 'subroute', - 'route': route_name.lower(), - 'name': subroute_name.lower(), - 'method': subroute_config.get('type', 'GET'), - 'mock': subroute_config.get('mock') - }) + commands.append( + { + "type": "subroute", + "route": route_name.lower(), + "name": subroute_name.lower(), + "method": subroute_config.get("type", "GET"), + "mock": subroute_config.get("mock"), + } + ) return commands, project_info def _generate_route_from_command(route_cmd, base_dir, verbose=False, rb=None): """Generate a route from a command dictionary.""" - route_name = route_cmd['name'] - mock_data = route_cmd.get('mock') + route_name = route_cmd["name"] + mock_data = route_cmd.get("mock") if verbose and not _get_report_mode(): click.echo(f"Generating route: {route_name}") @@ -102,7 +112,9 @@ def _generate_route_from_command(route_cmd, base_dir, verbose=False, rb=None): route_file = os.path.join(base_dir, f"api/routes/{route_name}_route.py") service_file = os.path.join(base_dir, f"api/services/{route_name}_service.py") - repository_file = os.path.join(base_dir, f"api/repositories/{route_name}_repository.py") + repository_file = os.path.join( + base_dir, f"api/repositories/{route_name}_repository.py" + ) model_file = os.path.join(base_dir, f"api/models/{route_cap}_model.py") alchemy_file = os.path.join(base_dir, "api/startup/Alchemy.py") app_file = os.path.join(base_dir, "api/app.py") @@ -236,13 +248,13 @@ class {route_cap}(Base): indentation = None for line in lines: if "import models." in line: - indentation = line[:len(line) - len(line.lstrip())] + indentation = line[: len(line) - len(line.lstrip())] break if indentation is None: for line in lines: if "def init_db():" in line: - indentation = line[:len(line) - len(line.lstrip())] + " " + indentation = line[: len(line) - len(line.lstrip())] + " " break import_line = f"{indentation}import models.{route_cap}_model\n" @@ -269,13 +281,17 @@ class {route_cap}(Base): for i, line in enumerate(lines): if "from dotenv import load_dotenv" in line: - lines.insert(i + 1, f"from routes.{route_name}_route import {route_name}_bp\n") + lines.insert( + i + 1, f"from routes.{route_name}_route import {route_name}_bp\n" + ) break for i, line in enumerate(lines): if "load_dotenv()" in line: - indentation = line[:len(line) - len(line.lstrip())] - lines.insert(i + 1, f"{indentation}app.register_blueprint({route_name}_bp)\n") + indentation = line[: len(line) - len(line.lstrip())] + lines.insert( + i + 1, f"{indentation}app.register_blueprint({route_name}_bp)\n" + ) break with open(app_file, "w") as f: @@ -284,10 +300,10 @@ class {route_cap}(Base): def _generate_subroute_from_command(subroute_cmd, base_dir, verbose=False, rb=None): """Generate a subroute from a command dictionary.""" - route_name = subroute_cmd['route'] - subroute_name = subroute_cmd['name'] - method_type = subroute_cmd['method'] - mock_data = subroute_cmd.get('mock') + route_name = subroute_cmd["route"] + subroute_name = subroute_cmd["name"] + method_type = subroute_cmd["method"] + mock_data = subroute_cmd.get("mock") if verbose and not _get_report_mode(): click.echo(f"Generating subroute: {route_name}/{subroute_name} ({method_type})") @@ -296,11 +312,35 @@ def _generate_subroute_from_command(subroute_cmd, base_dir, verbose=False, rb=No route_file = os.path.join(base_dir, f"api/routes/{route_name}_route.py") service_file = os.path.join(base_dir, f"api/services/{route_name}_service.py") - repository_file = os.path.join(base_dir, f"api/repositories/{route_name}_repository.py") - - _update_route_file_with_subroute(route_file, route_name, route_cap, subroute_name, subroute_name.capitalize(), method_type) - _update_service_file_with_subroute(service_file, route_name, route_cap, subroute_name, subroute_name.capitalize(), method_type, mock_data) - _update_repository_file_with_subroute(repository_file, route_name, route_cap, subroute_name, subroute_name.capitalize(), method_type) + repository_file = os.path.join( + base_dir, f"api/repositories/{route_name}_repository.py" + ) + + _update_route_file_with_subroute( + route_file, + route_name, + route_cap, + subroute_name, + subroute_name.capitalize(), + method_type, + ) + _update_service_file_with_subroute( + service_file, + route_name, + route_cap, + subroute_name, + subroute_name.capitalize(), + method_type, + mock_data, + ) + _update_repository_file_with_subroute( + repository_file, + route_name, + route_cap, + subroute_name, + subroute_name.capitalize(), + method_type, + ) def _execute_generation_commands(project_name, commands, verbose=False, rb=None): @@ -311,8 +351,8 @@ def _execute_generation_commands(project_name, commands, verbose=False, rb=None) click.echo(f"Starting API generation for project: {project_name}") click.echo(f"Total commands to execute: {len(commands)}") - routes = [cmd for cmd in commands if cmd['type'] == 'route'] - subroutes = [cmd for cmd in commands if cmd['type'] == 'subroute'] + routes = [cmd for cmd in commands if cmd["type"] == "route"] + subroutes = [cmd for cmd in commands if cmd["type"] == "subroute"] if verbose and not _get_report_mode(): click.echo(f"Routes to generate: {len(routes)}") @@ -322,13 +362,17 @@ def _execute_generation_commands(project_name, commands, verbose=False, rb=None) try: _generate_route_from_command(route_cmd, base_dir, verbose, rb=rb) except Exception as e: - raise APIGenerationError(f"Failed to generate route '{route_cmd['name']}': {e}") + raise APIGenerationError( + f"Failed to generate route '{route_cmd['name']}': {e}" + ) for subroute_cmd in subroutes: try: _generate_subroute_from_command(subroute_cmd, base_dir, verbose, rb=rb) except Exception as e: - raise APIGenerationError(f"Failed to generate subroute '{subroute_cmd['route']}/{subroute_cmd['name']}': {e}") + raise APIGenerationError( + f"Failed to generate subroute '{subroute_cmd['route']}/{subroute_cmd['name']}': {e}" + ) if verbose and not _get_report_mode(): click.echo("All commands executed successfully!") @@ -342,7 +386,10 @@ def generate(): @click.command() @click.argument("user_input") -@click.option("--mock", help="JSON string or path to JSON file for mock data. Examples: --mock '{\"users\":[{\"id\":1}]}' or --mock data.json") +@click.option( + "--mock", + help='JSON string or path to JSON file for mock data. Examples: --mock \'{"users":[{"id":1}]}\' or --mock data.json', +) @click.pass_context def route(ctx, user_input, mock): """Generates API route, service, repository and model. @@ -359,7 +406,9 @@ def route(ctx, user_input, mock): ant generate route users --mock mock_data.json """ - report_mode = ctx.find_root().obj.get("report", False) if ctx.find_root().obj else False + report_mode = ( + ctx.find_root().obj.get("report", False) if ctx.find_root().obj else False + ) rb = ReportBuilder() if report_mode else None uic = user_input.capitalize() @@ -389,8 +438,7 @@ def route(ctx, user_input, mock): os.makedirs(os.path.dirname(model_file), exist_ok=True) with open(route_file, "w") as f: - f.write( - f"""from flask import Blueprint, jsonify + f.write(f"""from flask import Blueprint, jsonify from services.{uil}_service import my{uic}Service {uil}_bp = Blueprint("{uil}", __name__, url_prefix="/{uil}") @@ -399,8 +447,7 @@ def route(ctx, user_input, mock): def {uil}_route(): response = my{uic}Service.get_{uil}() return jsonify(response) -""" - ) +""") if rb: rb.log_file_creation(route_file) @@ -446,8 +493,7 @@ def get_{uil}(self): rb.log_file_creation(service_file) with open(repository_file, "w") as f: - f.write( - f"""from sqlalchemy.exc import IntegrityError + f.write(f"""from sqlalchemy.exc import IntegrityError from helper.DBSession import myDB from helper.execution_tracking.Logger import myLogger from repositories.Repository import Repository @@ -489,14 +535,12 @@ def delete_{uil}(self, id: str): return {uil} my{uic}Repository: {uic}Repository = {uic}Repository(myDB, myLogger) -""" - ) +""") if rb: rb.log_file_creation(repository_file) with open(model_file, "w") as f: - f.write( - f"""from dataclasses import dataclass + f.write(f"""from dataclasses import dataclass from sqlalchemy import Column, Integer, String from startup.Alchemy import Base @@ -506,8 +550,7 @@ class {uic}(Base): id: int = Column(Integer, primary_key=True) model: String = Column(String) -""" - ) +""") if rb: rb.log_file_creation(model_file) @@ -518,17 +561,13 @@ class {uic}(Base): indentation = None for line in lines: if "import models." in line: - indentation = line[ - : len(line) - len(line.lstrip()) - ] + indentation = line[: len(line) - len(line.lstrip())] break if indentation is None: for line in lines: if "def init_db():" in line: - indentation = ( - line[: len(line) - len(line.lstrip())] + " " - ) + indentation = line[: len(line) - len(line.lstrip())] + " " break import_line = f"{indentation}import models.{uic}_model\n" @@ -560,15 +599,16 @@ class {uic}(Base): for i, line in enumerate(lines): if "load_dotenv()" in line: - indentation = line[ - : len(line) - len(line.lstrip()) - ] + indentation = line[: len(line) - len(line.lstrip())] lines.insert(i + 1, f"{indentation}app.register_blueprint({uil}_bp)\n") break with open(app_file, "w") as f: f.writelines(lines) + # Track successful route generation + track_command_executed("generate route", success=True) + if rb: rb.dump() elif mock_data: @@ -584,8 +624,17 @@ class {uic}(Base): @click.command() @click.argument("route") @click.argument("subroute") -@click.option("--type", "method_type", type=click.Choice(["GET", "POST"], case_sensitive=False), default="GET", help="HTTP method for the subroute (GET or POST)") -@click.option("--mock", help="JSON string or path to JSON file for mock data. Examples: --mock '{\"users\":[{\"id\":1}]}' or --mock data.json") +@click.option( + "--type", + "method_type", + type=click.Choice(["GET", "POST"], case_sensitive=False), + default="GET", + help="HTTP method for the subroute (GET or POST)", +) +@click.option( + "--mock", + help='JSON string or path to JSON file for mock data. Examples: --mock \'{"users":[{"id":1}]}\' or --mock data.json', +) @click.pass_context def subroute(ctx, route, subroute, method_type, mock): """Generates a subroute for an existing route. @@ -601,7 +650,9 @@ def subroute(ctx, route, subroute, method_type, mock): ant generate subroute orders items --type GET --mock items_data.json """ - report_mode = ctx.find_root().obj.get("report", False) if ctx.find_root().obj else False + report_mode = ( + ctx.find_root().obj.get("report", False) if ctx.find_root().obj else False + ) rb = ReportBuilder() if report_mode else None mock_data = None @@ -623,7 +674,9 @@ def subroute(ctx, route, subroute, method_type, mock): route_file = os.path.join(base_dir, f"api/routes/{route_lower}_route.py") service_file = os.path.join(base_dir, f"api/services/{route_lower}_service.py") - repository_file = os.path.join(base_dir, f"api/repositories/{route_lower}_repository.py") + repository_file = os.path.join( + base_dir, f"api/repositories/{route_lower}_repository.py" + ) if not os.path.exists(route_file): msg = f"Error: Route '{route}' does not exist. Please generate the main route first using: ant generate route {route}" @@ -652,26 +705,54 @@ def subroute(ctx, route, subroute, method_type, mock): click.echo(msg) return - _update_route_file_with_subroute(route_file, route_lower, route_cap, subroute_lower, subroute_cap, method_type) - _update_service_file_with_subroute(service_file, route_lower, route_cap, subroute_lower, subroute_cap, method_type, mock_data) - _update_repository_file_with_subroute(repository_file, route_lower, route_cap, subroute_lower, subroute_cap, method_type) + _update_route_file_with_subroute( + route_file, route_lower, route_cap, subroute_lower, subroute_cap, method_type + ) + _update_service_file_with_subroute( + service_file, + route_lower, + route_cap, + subroute_lower, + subroute_cap, + method_type, + mock_data, + ) + _update_repository_file_with_subroute( + repository_file, + route_lower, + route_cap, + subroute_lower, + subroute_cap, + method_type, + ) + + # Track successful subroute generation + track_command_executed("generate subroute", success=True) if rb: rb.dump() elif mock_data: - click.echo(f"Successfully generated {method_type} subroute '{subroute}' for route '{route}' with mock data!") + click.echo( + f"Successfully generated {method_type} subroute '{subroute}' for route '{route}' with mock data!" + ) else: - click.echo(f"Successfully generated {method_type} subroute '{subroute}' for route '{route}'!") + click.echo( + f"Successfully generated {method_type} subroute '{subroute}' for route '{route}'!" + ) -def _update_route_file_with_subroute(route_file, route_lower, route_cap, subroute_lower, subroute_cap, method_type): +def _update_route_file_with_subroute( + route_file, route_lower, route_cap, subroute_lower, subroute_cap, method_type +): """Update the route file to add a new subroute endpoint.""" - with open(route_file, 'r') as f: + with open(route_file, "r") as f: content = f.read() if f"def {route_lower}_{subroute_lower}_route" in content: if not _get_report_mode(): - click.echo(f"Warning: Subroute '{subroute_lower}' already exists in {route_file}") + click.echo( + f"Warning: Subroute '{subroute_lower}' already exists in {route_file}" + ) return if method_type == "GET": @@ -691,19 +772,31 @@ def {route_lower}_{subroute_lower}_route(): return jsonify(response) """ - with open(route_file, 'w') as f: + with open(route_file, "w") as f: f.write(content + new_route) -def _update_service_file_with_subroute(service_file, route_lower, route_cap, subroute_lower, subroute_cap, method_type, mock_data): +def _update_service_file_with_subroute( + service_file, + route_lower, + route_cap, + subroute_lower, + subroute_cap, + method_type, + mock_data, +): """Update the service file to add a new subroute method.""" - with open(service_file, 'r') as f: + with open(service_file, "r") as f: content = f.read() - method_name = f"get_{subroute_lower}" if method_type == "GET" else f"create_{subroute_lower}" + method_name = ( + f"get_{subroute_lower}" if method_type == "GET" else f"create_{subroute_lower}" + ) if f"def {method_name}" in content: if not _get_report_mode(): - click.echo(f"Warning: Method '{method_name}' already exists in {service_file}") + click.echo( + f"Warning: Method '{method_name}' already exists in {service_file}" + ) return if method_type == "GET": @@ -737,7 +830,7 @@ def create_{subroute_lower}(self, data): return f"Successfully created {subroute_lower} for {route_lower} with data: {{data}}" """ - lines = content.split('\n') + lines = content.split("\n") insert_index = -1 for i, line in enumerate(lines): @@ -750,19 +843,25 @@ def create_{subroute_lower}(self, data): else: lines.insert(insert_index, new_method) - with open(service_file, 'w') as f: - f.write('\n'.join(lines)) + with open(service_file, "w") as f: + f.write("\n".join(lines)) -def _update_repository_file_with_subroute(repository_file, route_lower, route_cap, subroute_lower, subroute_cap, method_type): +def _update_repository_file_with_subroute( + repository_file, route_lower, route_cap, subroute_lower, subroute_cap, method_type +): """Update the repository file to add a new subroute method.""" - with open(repository_file, 'r') as f: + with open(repository_file, "r") as f: content = f.read() - method_name = f"get_{subroute_lower}" if method_type == "GET" else f"add_{subroute_lower}" + method_name = ( + f"get_{subroute_lower}" if method_type == "GET" else f"add_{subroute_lower}" + ) if f"def {method_name}" in content: if not _get_report_mode(): - click.echo(f"Warning: Method '{method_name}' already exists in {repository_file}") + click.echo( + f"Warning: Method '{method_name}' already exists in {repository_file}" + ) return if method_type == "GET": @@ -791,11 +890,14 @@ def add_{subroute_lower}(self, data: dict): return {route_lower} """ - lines = content.split('\n') + lines = content.split("\n") insert_index = -1 for i, line in enumerate(lines): - if f"my{route_cap}Repository: {route_cap}Repository = {route_cap}Repository(" in line: + if ( + f"my{route_cap}Repository: {route_cap}Repository = {route_cap}Repository(" + in line + ): insert_index = i break @@ -804,13 +906,17 @@ def add_{subroute_lower}(self, data: dict): else: lines.insert(insert_index, new_method) - with open(repository_file, 'w') as f: - f.write('\n'.join(lines)) + with open(repository_file, "w") as f: + f.write("\n".join(lines)) @click.command() @click.argument("project_name") -@click.option("--json", "json_input", help="JSON string or path to JSON file for full API generation. Examples: --json '{\"routes\":{\"users\":{\"type\":\"GET\"}}}' or --json api-spec.json") +@click.option( + "--json", + "json_input", + help='JSON string or path to JSON file for full API generation. Examples: --json \'{"routes":{"users":{"type":"GET"}}}\' or --json api-spec.json', +) @click.option("--verbose", "-v", is_flag=True, help="Show detailed generation progress") @click.option("--dry-run", is_flag=True, help="Validate JSON without generating files") @click.pass_context @@ -830,7 +936,9 @@ def api(ctx, project_name, json_input, verbose, dry_run): ant generate api test --json api.json --dry-run """ - report_mode = ctx.find_root().obj.get("report", False) if ctx.find_root().obj else False + report_mode = ( + ctx.find_root().obj.get("report", False) if ctx.find_root().obj else False + ) rb = ReportBuilder() if report_mode else None if json_input: @@ -848,6 +956,7 @@ def api(ctx, project_name, json_input, verbose, dry_run): click.echo("Validating JSON schema...") from ant.commands.generate_schema import validate_api_spec + validation_errors = validate_api_spec(json_data) if validation_errors: if rb: @@ -868,22 +977,28 @@ def api(ctx, project_name, json_input, verbose, dry_run): if verbose and not report_mode: click.echo(f"Parsed {len(commands)} generation commands") if project_info: - project_desc = project_info.get('description', 'No description') + project_desc = project_info.get("description", "No description") click.echo(f"Project description: {project_desc}") if dry_run: if not report_mode: - click.echo(f"DRY RUN: Would generate project '{project_name}' with:") - routes = [cmd for cmd in commands if cmd['type'] == 'route'] - subroutes_list = [cmd for cmd in commands if cmd['type'] == 'subroute'] + click.echo( + f"DRY RUN: Would generate project '{project_name}' with:" + ) + routes = [cmd for cmd in commands if cmd["type"] == "route"] + subroutes_list = [ + cmd for cmd in commands if cmd["type"] == "subroute" + ] click.echo(f" {len(routes)} main routes:") for r in routes: - mock_info = " (with mock data)" if r.get('mock') else "" + mock_info = " (with mock data)" if r.get("mock") else "" click.echo(f" - {r['name']} ({r['method']}){mock_info}") click.echo(f" {len(subroutes_list)} subroutes:") for s in subroutes_list: - mock_info = " (with mock data)" if s.get('mock') else "" - click.echo(f" - {s['route']}/{s['name']} ({s['method']}){mock_info}") + mock_info = " (with mock data)" if s.get("mock") else "" + click.echo( + f" - {s['route']}/{s['name']} ({s['method']}){mock_info}" + ) click.echo("Dry run completed - no files were created") return @@ -908,7 +1023,7 @@ def api(ctx, project_name, json_input, verbose, dry_run): return if verbose and not report_mode: - click.echo(f"Creating base project structure...") + click.echo("Creating base project structure...") shutil.copytree(template_path, project_path) @@ -920,16 +1035,30 @@ def api(ctx, project_name, json_input, verbose, dry_run): _execute_generation_commands(project_name, commands, verbose, rb=rb) + # Track successful API generation + mark_api_generated() + track_command_executed("generate api", success=True) + if rb: rb.dump() else: - click.echo(f"Successfully generated API project '{project_name}' from JSON!") + click.echo( + f"Successfully generated API project '{project_name}' from JSON!" + ) if len(commands) > 0: - routes_count = len([cmd for cmd in commands if cmd['type'] == 'route']) - subroutes_count = len([cmd for cmd in commands if cmd['type'] == 'subroute']) - click.echo(f" Generated: {routes_count} routes, {subroutes_count} subroutes") + routes_count = len( + [cmd for cmd in commands if cmd["type"] == "route"] + ) + subroutes_count = len( + [cmd for cmd in commands if cmd["type"] == "subroute"] + ) + click.echo( + f" Generated: {routes_count} routes, {subroutes_count} subroutes" + ) except APIGenerationError as e: + track_error("APIGenerationError") + track_command_executed("generate api", success=False) msg = f"API Generation Error: {e}" if rb: rb.log_error(msg) @@ -938,6 +1067,8 @@ def api(ctx, project_name, json_input, verbose, dry_run): click.echo(msg) return except Exception as e: + track_error(type(e).__name__) + track_command_executed("generate api", success=False) msg = f"Unexpected error: {e}" if rb: rb.log_error(msg) @@ -973,6 +1104,10 @@ def api(ctx, project_name, json_input, verbose, dry_run): shutil.copytree(template_path, project_path) + # Track successful API generation + mark_api_generated() + track_command_executed("generate api", success=True) + if rb: rb.start_operation(project_path) rb.dump() diff --git a/ant/commands/generate_schema.py b/ant/commands/generate_schema.py index 6c97f28..bce906d 100644 --- a/ant/commands/generate_schema.py +++ b/ant/commands/generate_schema.py @@ -1,6 +1,5 @@ from typing import Dict, Literal, Optional -from pydantic import BaseModel, field_validator, model_validator - +from pydantic import BaseModel, field_validator HttpMethod = Literal["GET", "POST", "PUT", "DELETE"] @@ -32,7 +31,9 @@ def validate_subroute_names(cls, v): return v for name in v: if not name.isidentifier(): - raise ValueError(f"Invalid subroute name: '{name}' (must be a valid Python identifier)") + raise ValueError( + f"Invalid subroute name: '{name}' (must be a valid Python identifier)" + ) return v @@ -47,7 +48,9 @@ def validate_route_names(cls, v): raise ValueError("'routes' cannot be empty") for name in v: if not name.isidentifier(): - raise ValueError(f"Invalid route name: '{name}' (must be a valid Python identifier)") + raise ValueError( + f"Invalid route name: '{name}' (must be a valid Python identifier)" + ) return v @@ -73,32 +76,46 @@ def validate_api_spec(json_data: dict) -> list: for route_name, route_config in routes.items(): if not route_name.isidentifier(): - errors.append(f"Error: Invalid route name '{route_name}' (must be a valid Python identifier).") + errors.append( + f"Error: Invalid route name '{route_name}' (must be a valid Python identifier)." + ) continue if not isinstance(route_config, dict): - errors.append(f"Error: Route '{route_name}' configuration must be an object.") + errors.append( + f"Error: Route '{route_name}' configuration must be an object." + ) continue method = route_config.get("type", "GET") if method.upper() not in ("GET", "POST", "PUT", "DELETE"): - errors.append(f"Error: Invalid HTTP method '{method}' in route '{route_name}' (must be GET, POST, PUT, or DELETE).") + errors.append( + f"Error: Invalid HTTP method '{method}' in route '{route_name}' (must be GET, POST, PUT, or DELETE)." + ) subroutes = route_config.get("subroutes") if subroutes is not None: if not isinstance(subroutes, dict): - errors.append(f"Error: 'subroutes' in route '{route_name}' must be an object.") + errors.append( + f"Error: 'subroutes' in route '{route_name}' must be an object." + ) continue for sub_name, sub_config in subroutes.items(): if not sub_name.isidentifier(): - errors.append(f"Error: Invalid subroute name '{sub_name}' in route '{route_name}' (must be a valid Python identifier).") + errors.append( + f"Error: Invalid subroute name '{sub_name}' in route '{route_name}' (must be a valid Python identifier)." + ) continue if not isinstance(sub_config, dict): - errors.append(f"Error: Subroute '{route_name}/{sub_name}' configuration must be an object.") + errors.append( + f"Error: Subroute '{route_name}/{sub_name}' configuration must be an object." + ) continue sub_method = sub_config.get("type", "GET") if sub_method.upper() not in ("GET", "POST", "PUT", "DELETE"): - errors.append(f"Error: Invalid HTTP method '{sub_method}' in subroute '{route_name}/{sub_name}' (must be GET, POST, PUT, or DELETE).") + errors.append( + f"Error: Invalid HTTP method '{sub_method}' in subroute '{route_name}/{sub_name}' (must be GET, POST, PUT, or DELETE)." + ) if errors: return errors diff --git a/ant/commands/templates/__init__.py b/ant/commands/templates/__init__.py index c5f7f3a..ce6d5cc 100644 --- a/ant/commands/templates/__init__.py +++ b/ant/commands/templates/__init__.py @@ -1 +1 @@ -# Templates package \ No newline at end of file +# Templates package diff --git a/ant/commands/templates/base/api/app.py b/ant/commands/templates/base/api/app.py index 05a6e47..eb23895 100644 --- a/ant/commands/templates/base/api/app.py +++ b/ant/commands/templates/base/api/app.py @@ -1,7 +1,6 @@ import signal from flask import Flask, jsonify from flask_cors import CORS -import startup from startup.Alchemy import init_db, db_session from helper.execution_tracking.APIException import ( APIException, diff --git a/ant/commands/templates/base/api/decorators/token_required.py b/ant/commands/templates/base/api/decorators/token_required.py index 0230d2b..fe9d51f 100644 --- a/ant/commands/templates/base/api/decorators/token_required.py +++ b/ant/commands/templates/base/api/decorators/token_required.py @@ -17,7 +17,7 @@ def decorated_function(*args, **kwargs): raise APIException(status_code=401) try: - logger.debug(f"Decoded: {decoded_token}") + logger.debug(f"Decoded token: {token}") except Exception as e: logger.debug(f"Exception: {e}") raise APIException(status_code=401) diff --git a/ant/commands/templates/base/api/repositories/default_repository.py b/ant/commands/templates/base/api/repositories/default_repository.py index 64a8582..f052a9e 100644 --- a/ant/commands/templates/base/api/repositories/default_repository.py +++ b/ant/commands/templates/base/api/repositories/default_repository.py @@ -1,3 +1,4 @@ +from sqlalchemy import select from sqlalchemy.exc import IntegrityError from helper.DBSession import myDB from helper.execution_tracking.Logger import myLogger diff --git a/ant/commands/templates/base/api/routes/default_route.py b/ant/commands/templates/base/api/routes/default_route.py index 8e94a11..4d0b8fa 100644 --- a/ant/commands/templates/base/api/routes/default_route.py +++ b/ant/commands/templates/base/api/routes/default_route.py @@ -1,7 +1,6 @@ -from flask import Blueprint, jsonify, render_template, abort, url_for +from flask import Blueprint, jsonify from services.default_service import myDefaultService - default_bp = Blueprint("default", __name__, url_prefix="/") diff --git a/ant/commands/templates/base/api/services/default_service.py b/ant/commands/templates/base/api/services/default_service.py index 609894a..6482c19 100644 --- a/ant/commands/templates/base/api/services/default_service.py +++ b/ant/commands/templates/base/api/services/default_service.py @@ -1,5 +1,4 @@ from repositories.default_repository import DefaultRepository, myDefaultRepository -from helper.execution_tracking.APIException import APIException from helper.execution_tracking.Logger import Logger, myLogger diff --git a/ant/commands/templates/base/api/startup/Alchemy.py b/ant/commands/templates/base/api/startup/Alchemy.py index 9723744..adfa27f 100644 --- a/ant/commands/templates/base/api/startup/Alchemy.py +++ b/ant/commands/templates/base/api/startup/Alchemy.py @@ -3,7 +3,6 @@ from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker, scoped_session -from sqlalchemy.engine import make_url from helper.execution_tracking.Logger import myLogger from startup.Environment import myEnvironment from psycopg2.extensions import register_adapter, AsIs @@ -37,7 +36,6 @@ def adapt_dict(dict_var): def init_db(): - import models.Default_model if myEnvironment.CLEAR_DB == "True": Base.metadata.drop_all(engine) diff --git a/ant/utils/telemetry.py b/ant/utils/telemetry.py new file mode 100644 index 0000000..ed63987 --- /dev/null +++ b/ant/utils/telemetry.py @@ -0,0 +1,480 @@ +""" +Opt-in anonymous telemetry for BackAnt CLI. + +This module provides privacy-respecting telemetry that: +- Is strictly opt-in (requires explicit user consent) +- Respects DO_NOT_TRACK environment variable +- Never collects PII (no file paths, usernames, IPs, stack traces) +- Uses anonymized device identifiers +- Operates asynchronously and never blocks CLI operations +""" + +import atexit +import functools +import hashlib +import json +import os +import time +import uuid +from pathlib import Path +from typing import Callable, Optional + +import click + +# Lazy import to avoid blocking startup +_posthog = None +_posthog_client = None # PostHog client instance + +# Runtime flag to disable telemetry for current session (--no-telemetry) +_session_telemetry_disabled = False + +# Configuration - PostHog key is hardcoded for automatic telemetry +# Users can still opt-out via DO_NOT_TRACK=1 or declining the consent prompt +POSTHOG_API_KEY = os.environ.get( + "BACKANT_POSTHOG_KEY", "phc_myKzNtyyxG0CUY9IXMKdo6q1ve5kDxVYEddtXrQ0ZNP" +) +POSTHOG_HOST = os.environ.get("BACKANT_POSTHOG_HOST", "https://us.i.posthog.com") +CONFIG_DIR = Path.home() / ".backant" +CONFIG_FILE = CONFIG_DIR / "config.json" + +# Salt for hashing (changes per installation for extra privacy) +_HASH_SALT = "backant-cli-telemetry-v1" + + +def _get_posthog(): + """Lazy load PostHog to avoid blocking startup.""" + global _posthog + if _posthog is None: + try: + import posthog + + _posthog = posthog + except ImportError: + return None + return _posthog + + +def _get_posthog_client(): + """Get or create PostHog client instance.""" + global _posthog_client + if _posthog_client is None: + try: + from posthog import Posthog + + _posthog_client = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST) + except ImportError: + return None + return _posthog_client + + +def _ensure_config_dir() -> None: + """Ensure the config directory exists.""" + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + + +def _load_config() -> dict: + """Load configuration from disk.""" + try: + if CONFIG_FILE.exists(): + with open(CONFIG_FILE, "r") as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + pass + return {} + + +def _save_config(config: dict) -> None: + """Save configuration to disk.""" + try: + _ensure_config_dir() + with open(CONFIG_FILE, "w") as f: + json.dump(config, f, indent=2) + except IOError: + pass + + +def _generate_device_id() -> str: + """Generate a new anonymous device ID.""" + return str(uuid.uuid4()) + + +def _hash_device_id(device_id: str) -> str: + """Hash the device ID for extra anonymization.""" + return hashlib.sha256(f"{device_id}{_HASH_SALT}".encode()).hexdigest()[:32] + + +def get_device_id() -> str: + """Get or create the anonymous device ID.""" + config = _load_config() + + if "device_id" not in config: + config["device_id"] = _generate_device_id() + _save_config(config) + + return config["device_id"] + + +def get_distinct_id() -> str: + """Get the hashed distinct ID for PostHog.""" + return _hash_device_id(get_device_id()) + + +def is_do_not_track_set() -> bool: + """Check if DO_NOT_TRACK environment variable is set.""" + dnt = os.environ.get("DO_NOT_TRACK", "").lower() + return dnt in ("1", "true", "yes") + + +def set_session_telemetry_disabled(disabled: bool) -> None: + """Disable telemetry for the current session (--no-telemetry flag).""" + global _session_telemetry_disabled + _session_telemetry_disabled = disabled + + +def is_session_telemetry_disabled() -> bool: + """Check if telemetry is disabled for the current session.""" + return _session_telemetry_disabled + + +def is_telemetry_enabled() -> bool: + """Check if telemetry is enabled (consent given and not disabled).""" + if is_session_telemetry_disabled(): + return False + + if is_do_not_track_set(): + return False + + config = _load_config() + return config.get("telemetry_enabled", False) + + +def has_consent_been_asked() -> bool: + """Check if user has been asked for telemetry consent.""" + config = _load_config() + return "telemetry_enabled" in config + + +def set_telemetry_consent(enabled: bool) -> None: + """Set the user's telemetry consent.""" + config = _load_config() + config["telemetry_enabled"] = enabled + config["consent_timestamp"] = time.time() + _save_config(config) + + +def is_first_run() -> bool: + """Check if this is the first run of the CLI.""" + config = _load_config() + return not config.get("first_run_completed", False) + + +def mark_first_run_completed() -> None: + """Mark that first run has been completed.""" + config = _load_config() + config["first_run_completed"] = True + _save_config(config) + + +def mark_api_generated() -> None: + """Mark that the first API has been generated.""" + config = _load_config() + if not config.get("first_api_generated", False): + config["first_api_generated"] = True + _save_config(config) + track_event("api_generated") + + +def mark_onboarding_completed() -> None: + """Mark onboarding as completed.""" + config = _load_config() + if not config.get("onboarding_completed", False): + config["onboarding_completed"] = True + _save_config(config) + track_event("onboarding_completed") + + +class TelemetryClient: + """Telemetry client for sending events to PostHog.""" + + _instance: Optional["TelemetryClient"] = None + _initialized: bool = False + _session_start: float = 0 + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def initialize(self) -> bool: + """Initialize the PostHog client.""" + if self._initialized: + return True + + if not is_telemetry_enabled(): + return False + + if not POSTHOG_API_KEY: + return False + + client = _get_posthog_client() + if client is None: + return False + + try: + self._initialized = True + self._session_start = time.time() + + # Register atexit handler for flushing + atexit.register(self._flush_on_exit) + + return True + except Exception: + return False + + def _flush_on_exit(self) -> None: + """Flush events on exit and track session duration.""" + if not self._initialized: + return + + try: + # Track session duration + if self._session_start > 0: + duration = time.time() - self._session_start + self.capture( + "session_duration", {"duration_seconds": round(duration, 2)} + ) + + # Shutdown client (flushes remaining events) + client = _get_posthog_client() + if client: + client.shutdown() + except Exception: + pass + + def capture(self, event: str, properties: Optional[dict] = None) -> None: + """Capture an event.""" + if not self._initialized: + if not self.initialize(): + return + + if not is_telemetry_enabled(): + return + + client = _get_posthog_client() + if client is None: + return + + try: + safe_properties = properties or {} + # Add CLI version if available + try: + from ant import __version__ + + safe_properties["cli_version"] = __version__ + except (ImportError, AttributeError): + safe_properties["cli_version"] = "unknown" + + client.capture( + distinct_id=get_distinct_id(), event=event, properties=safe_properties + ) + except Exception: + # Silent fail - never block CLI operation + pass + + def identify(self) -> None: + """Identify the user (anonymously).""" + if not self._initialized: + return + + client = _get_posthog_client() + if client is None: + return + + try: + client.identify( + distinct_id=get_distinct_id(), + properties={ + "platform": os.name, + }, + ) + except Exception: + pass + + +# Global client instance +_client = TelemetryClient() + + +def track_event(event: str, properties: Optional[dict] = None) -> None: + """Track an event (convenience function).""" + _client.capture(event, properties) + + +def track_cli_installed() -> None: + """Track CLI installation (first run).""" + if is_first_run(): + track_event("cli_installed") + mark_first_run_completed() + + +def track_command_executed(command_name: str, success: bool) -> None: + """Track command execution.""" + track_event("command_executed", {"command": command_name, "success": success}) + + +def track_error(error_type: str) -> None: + """Track an error occurrence (type only, no details).""" + track_event("error_occurred", {"error_type": error_type}) + + +def initialize_telemetry() -> None: + """Initialize telemetry if enabled.""" + _client.initialize() + + +def get_privacy_info() -> str: + """Get human-readable privacy information.""" + config = _load_config() + enabled = is_telemetry_enabled() + dnt = is_do_not_track_set() + + lines = [ + "BackAnt CLI Telemetry Information", + "=" * 35, + "", + f"Telemetry enabled: {'Yes' if enabled else 'No'}", + f"DO_NOT_TRACK env var: {'Set (telemetry disabled)' if dnt else 'Not set'}", + "", + "What we collect (when enabled):", + " - Command names (e.g., 'generate api')", + " - Success/failure status", + " - Anonymous device ID (hashed)", + " - CLI version", + " - Session duration", + "", + "What we NEVER collect:", + " - File paths or project names", + " - Usernames or email addresses", + " - IP addresses", + " - Stack traces or error messages", + " - Any personally identifiable information", + "", + "How to change your preference:", + " - Set DO_NOT_TRACK=1 environment variable", + " - Or delete ~/.backant/config.json to reset", + "", + f"Config file: {CONFIG_FILE}", + ] + + if config.get("consent_timestamp"): + from datetime import datetime + + ts = datetime.fromtimestamp(config["consent_timestamp"]) + lines.append(f"Consent given: {ts.strftime('%Y-%m-%d %H:%M:%S')}") + + return "\n".join(lines) + + +def prompt_for_consent() -> bool: + """Prompt user for telemetry consent on first run. + + Returns True if consent was given, False otherwise. + """ + # Don't prompt if DO_NOT_TRACK is set + if is_do_not_track_set(): + set_telemetry_consent(False) + return False + + # Don't prompt if already asked + if has_consent_been_asked(): + return is_telemetry_enabled() + + try: + click.echo("") + click.echo("Help improve BackAnt by sharing anonymous usage data? (y/n)") + click.echo("(This can be changed later via DO_NOT_TRACK=1 or --privacy flag)") + response = click.prompt("", type=str, default="n").lower().strip() + + enabled = response in ("y", "yes") + set_telemetry_consent(enabled) + + if enabled: + click.echo("Thanks! Anonymous telemetry enabled.") + track_cli_installed() + else: + click.echo("No problem! Telemetry disabled.") + + click.echo("") + return enabled + except (click.Abort, EOFError, KeyboardInterrupt): + # User cancelled - default to disabled + set_telemetry_consent(False) + return False + + +def telemetry_callback(ctx: click.Context, param: click.Parameter, value: bool) -> bool: + """Click callback for --no-telemetry flag.""" + if value: + set_session_telemetry_disabled(True) + return value + + +def privacy_callback(ctx: click.Context, param: click.Parameter, value: bool) -> None: + """Click callback for --privacy flag.""" + if value: + click.echo(get_privacy_info()) + ctx.exit(0) + + +def with_telemetry(command_name: str) -> Callable: + """Decorator to wrap Click commands with telemetry tracking. + + Usage: + @cli.command() + @with_telemetry("my_command") + def my_command(): + ... + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(*args, **kwargs): + # Check for first run and prompt for consent if needed + if not has_consent_been_asked() and not is_session_telemetry_disabled(): + prompt_for_consent() + + # Initialize telemetry + initialize_telemetry() + + success = True + try: + result = func(*args, **kwargs) + return result + except Exception as e: + success = False + # Track error type only, not the full error + track_error(type(e).__name__) + raise + finally: + track_command_executed(command_name, success) + + return wrapper + + return decorator + + +def telemetry_group_callback(ctx: click.Context) -> None: + """Callback for Click group to handle telemetry initialization. + + Call this at the start of the main CLI group. + """ + # Handle first-run consent prompt + if not has_consent_been_asked() and not is_session_telemetry_disabled(): + # Only prompt if running interactively (stdin is a TTY) + import sys + + if sys.stdin.isatty(): + prompt_for_consent() + + # Initialize telemetry + initialize_telemetry() diff --git a/docs/telemetry.md b/docs/telemetry.md new file mode 100644 index 0000000..d67091c --- /dev/null +++ b/docs/telemetry.md @@ -0,0 +1,123 @@ +# BackAnt CLI Telemetry + +BackAnt CLI includes an **opt-in** anonymous telemetry system to help us understand how the tool is used and improve it for everyone. + +## Key Principles + +1. **Opt-in by default**: Telemetry is disabled until you explicitly consent +2. **Privacy first**: We never collect personally identifiable information (PII) +3. **Transparent**: You can see exactly what we collect and how to disable it +4. **Non-blocking**: Telemetry never slows down or interrupts CLI operations + +## First-Run Consent + +On your first use of BackAnt CLI, you'll be asked: + +``` +Help improve BackAnt by sharing anonymous usage data? (y/n) +``` + +- Type `y` or `yes` to enable telemetry +- Type `n` or `no` (or press Enter) to disable telemetry +- Your preference is saved to `~/.backant/config.json` + +## What We Collect + +When telemetry is enabled, we collect: + +| Data | Example | Purpose | +|------|---------|---------| +| Command names | `generate api`, `generate route` | Understand feature usage | +| Success/failure status | `true` / `false` | Identify reliability issues | +| Anonymous device ID | `a1b2c3d4...` (hashed) | Count unique users | +| CLI version | `0.7.1` | Track version adoption | +| Session duration | `45.2` seconds | Understand usage patterns | +| Platform | `posix` / `nt` | Platform-specific improvements | + +## What We NEVER Collect + +- File paths or project names +- Usernames, email addresses, or real names +- IP addresses (PostHog may log these, but we don't use them) +- Stack traces or error messages +- Command arguments or options +- Any file contents or code +- Environment variables or secrets + +## How to Disable Telemetry + +### Option 1: Environment Variable (Recommended) + +Set the `DO_NOT_TRACK` environment variable: + +```bash +# Bash/Zsh +export DO_NOT_TRACK=1 + +# Fish +set -x DO_NOT_TRACK 1 + +# Windows (PowerShell) +$env:DO_NOT_TRACK = "1" +``` + +Add this to your shell profile (`.bashrc`, `.zshrc`, etc.) to make it permanent. + +### Option 2: Per-Command Flag + +Use the `--no-telemetry` flag for individual commands: + +```bash +ant --no-telemetry generate api my-project +``` + +### Option 3: Reset Configuration + +Delete the configuration file to be prompted again: + +```bash +rm ~/.backant/config.json +``` + +## Viewing Your Telemetry Settings + +Use the `--privacy` flag to see current telemetry status: + +```bash +ant --privacy +``` + +This displays: +- Whether telemetry is enabled +- What data is collected +- How to change your preferences + +## Technical Implementation + +- **SDK**: PostHog Python SDK +- **Batching**: Events are batched and sent asynchronously +- **Flush on exit**: Remaining events are sent when the CLI exits +- **Silent failures**: Network errors are silently ignored (never blocks CLI) +- **No PII validation**: All events are sanitized before sending + +## Data Retention + +Telemetry data is stored on PostHog's servers. Please refer to [PostHog's privacy policy](https://posthog.com/privacy) for their data retention policies. + +## Configuring PostHog (Self-Hosting) + +If you want to use your own PostHog instance: + +```bash +export BACKANT_POSTHOG_KEY="your-project-api-key" +export BACKANT_POSTHOG_HOST="https://your-posthog-instance.com" +``` + +## Questions or Concerns + +If you have any questions about telemetry or privacy, please: + +1. Open an issue at [github.com/backant/backant-cli](https://github.com/backant/backant-cli/issues) +2. Review the source code at `ant/utils/telemetry.py` + +We're committed to being transparent about data collection and respecting your privacy. diff --git a/requirements.txt b/requirements.txt index e69de29..6cace31 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1 @@ +posthog>=3.0.0 diff --git a/scripts/ci/run-checks.sh b/scripts/ci/run-checks.sh new file mode 100755 index 0000000..72db567 --- /dev/null +++ b/scripts/ci/run-checks.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +cd "$ROOT_DIR" + +passed=0 +warned=0 +failed=0 + +run_check() { + local name="$1" + local cmd="$2" + echo -e "${BLUE}[CHECK]${NC} Running: $name" + if eval "$cmd"; then + echo -e "${GREEN}[PASS]${NC} $name" + ((passed++)) + else + echo -e "${RED}[FAIL]${NC} $name" + ((failed++)) + return 1 + fi +} + +# Parse arguments +MODE="${1:-quick}" + +case "$MODE" in + quick|all) + run_check "Ruff linting" "ruff check ant/" + run_check "Black formatting" "black --check ant/" + + if [ "$MODE" = "all" ]; then + run_check "Pytest" "pytest -v --tb=short || echo 'No tests found'" + fi + ;; + fix) + echo -e "${BLUE}[FIX]${NC} Auto-fixing issues..." + ruff check ant/ --fix || true + black ant/ + echo -e "${GREEN}[DONE]${NC} Auto-fix complete" + exit 0 + ;; + lint) + run_check "Ruff linting" "ruff check ant/" + ;; + format) + run_check "Black formatting" "black --check ant/" + ;; + test) + run_check "Pytest" "pytest -v --tb=short" + ;; + *) + echo "Usage: $0 [quick|all|fix|lint|format|test]" + echo "" + echo " quick - Run lint and format checks (default)" + echo " all - Run all checks including tests" + echo " fix - Auto-fix linting and formatting issues" + echo " lint - Run only linting" + echo " format - Run only format check" + echo " test - Run only tests" + exit 1 + ;; +esac + +echo "" +echo "============================================" +echo -e " ${GREEN}Passed:${NC} $passed" +echo -e " ${YELLOW}Warned:${NC} $warned" +echo -e " ${RED}Failed:${NC} $failed" +echo "============================================" + +if [ $failed -gt 0 ]; then + echo "" + echo -e "${RED}[FAIL]${NC} Some checks failed!" + exit 1 +else + echo "" + echo -e "${GREEN}[PASS]${NC} All checks passed!" +fi diff --git a/setup.py b/setup.py index fdadfdf..441a424 100644 --- a/setup.py +++ b/setup.py @@ -2,10 +2,17 @@ setup( name="backant-cli", - version="0.7.1", + version="0.7.2", packages=find_packages(), include_package_data=True, - install_requires=["click", "pydantic>=2.0"], + install_requires=["click", "pydantic>=2.0", "posthog>=3.0.0"], + extras_require={ + "dev": [ + "ruff", + "black", + "pytest", + ], + }, entry_points={"console_scripts": ["ant=ant.cli:main"]}, author="Pavel Hegler", author_email="business@hegler.tech",