diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f6ff624 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,41 @@ +# Speeds up `docker build` and keeps the builder stage tidy. Everything listed +# here is excluded from the build context. + +# VCS +.git +.gitignore +.gitattributes + +# Local venvs / caches +.venv +venv +__pycache__ +*.pyc +.pytest_cache +.mypy_cache +.ruff_cache +.coverage +htmlcov + +# Build artefacts +build +dist +*.egg-info + +# IDE / editor +.idea +.vscode +.DS_Store + +# Local-only configuration +.env +.env.* +!.env.example + +# Tests, docs, examples — not needed at runtime +tests +docs +examples + +# Compose / Docker artefacts (avoid recursive context) +docker-compose.override.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index eb83b3e..1cc7629 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,10 +1,9 @@ name: Tests on: - push: - branches: [main, develop] pull_request: branches: [main, develop] + workflow_dispatch: permissions: contents: read @@ -62,6 +61,18 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install -e . + - name: wheel builds and bundles the data directory + # The editable install above never builds a wheel, so a broken wheel + # config (e.g. a force-include that duplicates files the package + # already ships) would pass every other step and fail only at + # release or `docker build`. Build it and check the data is inside. + run: | + python -m pip wheel --no-deps -w /tmp/wheel . + files=$(python -m zipfile -l /tmp/wheel/*.whl) + for f in gendia/data/forges.json gendia/data/standards/core.json \ + gendia/data/scaffolds/bare.json gendia/data/scaffolds/_common/.gitignore; do + echo "$files" | grep -qF "$f" || { echo "missing from wheel: $f"; exit 1; } + done - name: gendia --version run: gendia --version - name: gendia setup --detect (clean host returns 1) @@ -88,3 +99,38 @@ jobs: if [ "$rc" != "1" ]; then echo "expected exit 1 (warnings), got $rc"; exit 1 fi + + - name: standards engine — list profiles + # Should print the 16 bundled rule packs from bundled JSON resources. + # Regresses if `_BUNDLED_PROFILES` falls out of sync with the data. + working-directory: /tmp + run: | + count=$(gendia conventions --list-profiles | grep -cE '^ [a-z]') + if [ "$count" -lt 16 ]; then + echo "expected at least 16 bundled profiles, got $count"; exit 1 + fi + + - name: standards engine — list scaffolds + # Should print the 13 bundled scaffolds from bundled JSON resources. + working-directory: /tmp + run: | + count=$(gendia init --list-scaffolds | grep -cE '^ [a-z]') + if [ "$count" -lt 13 ]; then + echo "expected at least 13 bundled scaffolds, got $count"; exit 1 + fi + + - name: standards engine — --fix --dry-run on a fresh empty dir + # Regression guard for `--fix` + `--dry-run` argparse wiring and the + # bundled fix kinds. Expects at least one `would-fix` line for an + # empty repo (R001 README is missing, etc.). + run: | + rm -rf /tmp/fix-smoke && mkdir /tmp/fix-smoke + gendia conventions /tmp/fix-smoke --no-legacy --profile core --fix --dry-run \ + | grep -q 'would-fix' \ + || (echo "expected '--fix --dry-run' to print 'would-fix' lines"; exit 1) + + - name: bin/gendia-menu --list parses cleanly + # Bash-syntax + resolution smoke for the interactive menu. + run: | + bash -n bin/gendia-menu + bin/gendia-menu --list | grep -q '\[INSPECT\]' diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a4b266..cbd48fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ All notable changes to `gendia` follow [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added — Standards engine, scaffolds, autofix + +- **JSON-driven standards engine** (`src/gendia/standards.py`, ~1280 LOC): a new layer on top of the existing `conventions` verb that lints repos against rule packs declared in JSON. New CLI surface: `gendia conventions --list-profiles`, `--list-rules --profile `, `--explain `, `--no-legacy` (skip the 10 hardcoded checks), `--profile ` (repeatable). **16 bundled rule packs / 71 rules / 7 check kinds** — `file_exists`, `file_absent`, `regex_in_file`, `directory_exists`, `license_spdx`, `glob_exists`, `one_of_files`. Plugin syntax: a rule's `check:` field may be `module:function` to load org-internal checks. +- **`gendia conventions --fix`** (with `--dry-run`): **21 fix kinds** spanning the universal essentials (README / .gitignore / .editorconfig / .gitattributes), community health (CONTRIBUTING / CODE_OF_CONDUCT / SECURITY / CHANGELOG), GitHub workflows (CODEOWNERS / PR template / Dependabot / CodeQL / Scorecard), OpenSSF Security Insights, and **5 JSON-mutating fixes for `package.json`** (`name`, `version`, `license`, `engines.node`, `packageManager`). All fixes are idempotent; mutating fixes use atomic writes via tmpfile + `os.replace` and refuse symlinked targets. `set_package_license` auto-detects SPDX from an existing LICENSE file (`SPDX-License-Identifier:` line wins; falls back to a 17-marker body-text table). +- **`gendia init --scaffold`** (`src/gendia/scaffolds.py`, ~860 LOC): renders bundled scaffold templates with `${var}` substitution + post-actions (`git_init`, `license_text`, `audit`). **13 bundled scaffolds**: `bare`, `python-uv`, `python-poetry`, `rust`, `go`, `node-pnpm`, `node-bun`, `php-composer`, `ruby-gem`, `dotnet`, `docs-mkdocs`, `monorepo-pnpm-turbo`, `java-gradle`. **17 bundled license texts** for the `license_text` post-action. Security guards: path-traversal refusal (`is_relative_to`), symlink refusal, unresolved-variable detection. +- **`bin/gendia-menu`** + `make menu`: interactive numbered menu wrapping all 18 verbs with drill-down by category (Inspect / Release & Sync / Standards / Scaffold / Config). `--list` for non-interactive option dump. Shipped inside the Docker image so `make docker-exec` lands in a working toolkit shell. +- **Makefile extensions**: `make check` runs the full CI gate (lint + format-check + typecheck + tests), `make menu`, `make docker-up/down/ps/logs/exec/host-shell` for compose lifecycle, plus the existing `docker-build/run/push/shell`. `PYTHON` auto-detects `python3.12+` so it works on hosts where `python3` resolves to 3.10. +- **`.dockerignore`**: trims the build context (excludes `.venv/`, caches, `tests/`, `docs/`, `examples/`, build artefacts) so `docker build` is fast and tidy. +- **205 new unit tests** for the standards engine + scaffolds + autofix (running total: 389 across all changes in this Unreleased section). Coverage includes schema, every check kind, every fix kind (round-trip clears findings), every bundled rule pack, every bundled scaffold, every license text (via parametrize), security guards, JSON-mutating idempotency / malformed-JSON / missing-file / symlink-refusal, SPDX detection from LICENSE files, slugify edge cases. +- **Docs**: README sections for the standards engine, scaffolds, autofix and the interactive menu. The design driver doc and phase tracker used to plan this work are kept outside the repository. + +### Added — SSH-config awareness layer + +- **SSH-config awareness layer** (`src/gendia/ssh/`): parser (`SSHConfig`, `SSHHost`, `SSHHostList`) handles `Host` / `Match` / `Include` directives, multi-host lines, and per-host `IdentityFile` / `Port` / `User` / `ProxyJump`. JSON-driven forge registry (`ForgeRegistry`) with detection signals (exact hostname, substring pattern, comment hint, `User git` heuristic). Key-health auditor (`audit_keys`) flags loose permissions, DSA / weak-RSA, and shared-key reuse. Account synthesizer (`propose_accounts`) projects detected hosts onto gendia account stubs. +- **Bundled forge registry** (`src/gendia/data/forges.json`) shipped via `package_data`. Override per project with the new top-level `forges_file` key in `gendia.json`. See `examples/forges.json`. +- **Three new providers**: `gitea` (covers Gitea / Forgejo / Codeberg with one wire format), `azure` (Azure DevOps; PAT over Basic-auth), and `bitbucket-server` (Atlassian Data Center, distinct from Bitbucket Cloud). GitHub Enterprise now honoured via `Account.host` (`/api/v3` REST root, host-aware clone URLs). +- **Three new operations**: `gendia generate` (emit copy-paste git commands per detected SSH host in `text` / `script` / `json` / `markdown`; `--execute` runs the bash form), `gendia scan` (filesystem walk classifying every git repo as `clean` / `dirty` / `orphan` / `https-candidate`), and `gendia convert` (flip URLs between SSH and HTTPS, alias-aware via SSH config and `~/.gitconfig` `insteadOf` rules). +- **`gendia ssh` command group**: `ssh list`, `ssh test`, `ssh inspect`, `ssh bootstrap [--write]`. `ssh bootstrap` synthesizes account stubs from `~/.ssh/config` and merges them into `~/.config/gendia/gendia.json` on demand. +- **`gendia setup --from-ssh`**: same synthesis as `ssh bootstrap`, wired into the first-run wizard. +- **`gendia doctor`** now includes `audit_keys` findings (key permissions, weak-key types, shared-key reuse) under a new `ssh keys` section. +- **`Account.manual_repos`** schema field for repos the provider's API can't (or shouldn't) discover. JSON shape: `[{"host": "alias", "org": "scope", "repos": ["repo1", ...]}]`. Picked up by `generate` and reflected in the proposed config from `ssh bootstrap`. +- **Util helpers**: `gendia.util.url` (parse / convert SSH ↔ HTTPS, alias + `insteadOf` aware) and `gendia.util.repo_scan` (filesystem scanner returning `ScannedRepo` with classification helpers). +- **51 new unit tests for the SSH layer** (running total: 184 + 205 = 389 across all changes in this Unreleased section) covering SSH config parsing, forge detection, URL conversion, the filesystem scanner, the new providers, GitHub Enterprise host honouring, and end-to-end coverage of `generate` / `scan` / `convert`. +- **Examples**: `examples/forges.json` (forge registry overlay), `examples/ssh-bootstrap.json` (config showing `forges_file`, `manual_repos`, and the new providers). + +### Notes + +- Migrated from the standalone `git-helpers` script. The single-file `generate_git_commands.py` was decomposed across the new layers (parser, registry, providers, operations) with no behavioural regressions; the `git-commands.txt` artefact it produced is now reachable via `gendia generate --output git-commands.txt`. `git-helpers` is removed as a separate project; gendia is the supported replacement. + ## [0.1.0] - 2026-05-06 ### Added diff --git a/Dockerfile b/Dockerfile index eb44d46..f82bbbb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,9 +25,11 @@ RUN apt-get update \ COPY --from=builder /src/dist/*.whl /tmp/ RUN pip install --no-cache-dir /tmp/*.whl && rm -rf /tmp/*.whl -# Ship the env-loader bridge inside the image for Docker / Compose / k8s use. -COPY bin/gendia-env /usr/local/bin/gendia-env -RUN chmod +x /usr/local/bin/gendia-env +# Ship the env-loader bridge + interactive menu inside the image so +# `make docker-exec` can drop a user into a working toolkit shell. +COPY bin/gendia-env /usr/local/bin/gendia-env +COPY bin/gendia-menu /usr/local/bin/gendia-menu +RUN chmod +x /usr/local/bin/gendia-env /usr/local/bin/gendia-menu # XDG-compliant config location; mount your config volume here. ENV XDG_CONFIG_HOME=/config \ diff --git a/Makefile b/Makefile index 309a9a4..9dae878 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,25 @@ -.PHONY: help install install-dev test lint typecheck status sync inventory release audit cleanup verify mirror init conventions docker-build docker-shell docker-push clean - -PYTHON ?= python3 +.PHONY: help menu install install-dev test lint format format-check typecheck check status sync inventory release audit cleanup verify mirror init conventions \ + docker-build docker-shell docker-push docker-run \ + docker-up docker-down docker-logs docker-ps docker-exec docker-host-shell \ + clean + +# Pick the first available Python 3.12+ interpreter. Some hosts ship a `python3` +# that resolves to 3.10 (e.g. MAMP, system Python on older macOS) and won't have +# pytest / mypy installed — overrideable via PYTHON=path/to/bin. +PYTHON ?= $(shell command -v python3.12 || command -v python3.13 || command -v python3) UV ?= uv ARGS ?= +# Container name used by docker-exec / docker-logs / docker-down. The compose +# service is `gendia`; the default container name follows Compose conventions. +COMPOSE_SERVICE ?= gendia +CONTAINER ?= gendia-$(COMPOSE_SERVICE)-1 + help: ## Show available targets - @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk -F':.*## ' '{printf " %-20s %s\n", $$1, $$2}' + @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk -F':.*## ' '{printf " %-22s %s\n", $$1, $$2}' + +menu: ## Launch the interactive gendia menu (bin/gendia-menu) + @./bin/gendia-menu install: ## Install gendia from source (uv preferred, pip fallback) @if command -v $(UV) >/dev/null 2>&1; then \ @@ -27,9 +41,17 @@ test: ## Run pytest lint: ## Run ruff lint $(PYTHON) -m ruff check src tests +format: ## Apply ruff format to src + tests (rewrites files) + $(PYTHON) -m ruff format src tests + +format-check: ## Check ruff format compliance without writing (mirrors CI) + $(PYTHON) -m ruff format --check src tests + typecheck: ## Run mypy $(PYTHON) -m mypy src +check: lint format-check typecheck test ## Run lint + format-check + typecheck + tests (CI gates) + # --- Operations --------------------------------------------------------------- status: ## gendia status [ARGS=...] @@ -46,12 +68,12 @@ release: ## REPO=myorg/core VERSION=1.0.1 make release inventory audit cleanup verify mirror init conventions: ## gendia [ARGS=...] gendia $@ $(ARGS) -# --- Docker ------------------------------------------------------------------- +# --- Docker (host-side: build / push / one-shot run) ------------------------- docker-build: ## Build the gendia Docker image docker build -t gendia:latest . -docker-shell: ## Drop into a gendia container with config + .env mounted +docker-shell: ## Drop into a one-shot gendia container with config + .env mounted docker run --rm -it \ --env-file "$(HOME)/.config/gendia/.env" \ -v "$(HOME)/.config/gendia:/config:ro" \ @@ -59,7 +81,7 @@ docker-shell: ## Drop into a gendia container with config + .env mounted -v "$(PWD):/work" \ --entrypoint bash gendia:latest -docker-run: ## VERB=sync make docker-run — run any gendia verb in container +docker-run: ## VERB=sync make docker-run — run any gendia verb in a one-shot container @test -n "$(VERB)" || (echo "VERB=... required (e.g. VERB=sync)"; exit 1) docker run --rm \ --env-file "$(HOME)/.config/gendia/.env" \ @@ -73,6 +95,36 @@ docker-push: ## REGISTRY=ghcr.io/your-org make docker-push docker tag gendia:latest $(REGISTRY)/gendia:latest docker push $(REGISTRY)/gendia:latest +# --- Docker Compose (long-lived service: up / exec / down) ------------------- + +docker-up: ## Start the gendia compose service in the background + docker compose up -d + +docker-down: ## Stop the gendia compose service (preserves volumes) + docker compose down + +docker-ps: ## Show compose service status + docker compose ps + +docker-logs: ## Follow logs for the gendia compose service + docker compose logs -f $(COMPOSE_SERVICE) + +docker-exec: ## Open an interactive shell inside the running gendia container + @docker compose ps --status running --services 2>/dev/null | grep -qx "$(COMPOSE_SERVICE)" \ + || (echo "$(COMPOSE_SERVICE) service is not running. Try 'make docker-up' first."; exit 1) + docker compose exec $(COMPOSE_SERVICE) bash + +docker-host-shell: ## Open a host-side shell on the Docker daemon's host (no-op on Docker Desktop) + @if command -v limactl >/dev/null 2>&1 && limactl list 2>/dev/null | grep -q running; then \ + echo "Using Lima shell..."; limactl shell default; \ + elif command -v multipass >/dev/null 2>&1 && multipass list 2>/dev/null | grep -q docker; then \ + echo "Using Multipass shell..."; multipass shell docker; \ + else \ + echo "No remote Docker host detected (Lima / Multipass). On Docker Desktop the daemon"; \ + echo "runs inside a Linux VM that isn't directly shell-accessible — use 'make docker-exec'"; \ + echo "to enter the running gendia container instead."; \ + fi + clean: ## Remove build artifacts rm -rf build dist *.egg-info .pytest_cache .mypy_cache .ruff_cache .coverage htmlcov find . -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null || true diff --git a/README.md b/README.md index 1a757ab..45a4817 100644 --- a/README.md +++ b/README.md @@ -25,10 +25,11 @@ Existing multi-repo tools (`mr`, `mu-repo`, `gita`) stop at clone / pull / statu - **CI/CD-first design**: identical CLI on dev box, VPS cron, and Docker-based runners. JSON log format for structured log shippers; exit code reflects all-ok / failed. - **Packagist-aware out of the box**: `sync` and `release` know how to fire the Packagist update endpoint when tags get pushed. npm and PyPI included as first-class push registries. -- **Three platforms from day one**: GitHub, GitLab (cloud or self-hosted), Bitbucket Cloud. +- **Six platforms from day one**: GitHub (cloud + Enterprise), GitLab (cloud + self-hosted), Bitbucket Cloud + Server/Data Center, Gitea (Forgejo, Codeberg via the same wire format), and Azure DevOps. +- **SSH-config aware**: detects which forges your `~/.ssh/config` already lists, audits IdentityFile permissions + key strength, generates copy-paste git commands, converts URLs between SSH and HTTPS (alias + `insteadOf` aware), and bootstraps gendia accounts straight from your SSH setup. - **Two transports**: SSH (with optional explicit private-key path, `IdentitiesOnly=yes` to block agent enumeration) or HTTPS-with-API-token (via `GIT_ASKPASS`, never via URL embed, never in argv). - **Multi-org / multi-account**: any number of accounts in one config; each repo binds to one account. A leak in one account's credentials can't touch another's repos. -- **Fourteen first-class verbs**: `status`, `sync`, `inventory`, `audit`, `cleanup`, `verify`, `release`, `mirror`, `init`, `config`, `setup`, `doctor`, `identity`, `conventions` — fleet operations, first-run wizards, per-account git-identity management, and JSON-driven repo-hygiene linting. +- **Eighteen first-class verbs**: `status`, `sync`, `inventory`, `audit`, `cleanup`, `verify`, `release`, `mirror`, `init`, `config`, `setup`, `doctor`, `identity`, `conventions`, `generate`, `scan`, `convert`, `ssh` — fleet operations, first-run wizards, per-account git-identity management, JSON-driven repo-hygiene linting, SSH-config-driven command generation, on-disk repo discovery, URL conversion, and SSH-config inspection / bootstrap. - **Dry-run on everything**: `--dry-run` is honoured by every mutating operation. - **Zero runtime dependencies**: stdlib only. `keyring` is an optional extra for OS-keychain-backed secrets. - **Docker-native**: slim image (~80 MB), runs as non-root, mounts your `~/.config/gendia` and SSH keys; `docker compose run gendia sync` is the deployable unit. @@ -173,6 +174,20 @@ gendia conventions [PATH] [--rules FILE] [--strict] [--json] # lint repo hygiene: GitHub-special files, naming, # ban-list glyphs, spec filenames, sub-folder readmes, # shell-script shebangs (JSON-driven, fully tunable) +gendia conventions --list-profiles # 16 bundled rule packs +gendia conventions --explain R005 --profile core +gendia conventions . --no-legacy --profile core --profile lang-node --fix [--dry-run] + # JSON-driven standards engine + autofix + # (21 rules with fix:; idempotent + atomic writes) + +# Scaffolds: +gendia init --list-scaffolds # 13 bundled scaffolds +gendia init --scaffold python-uv --target ./foo --var name=foo --var module=foo \ + --var description="A package" # render template + git init + license_text + audit + +# Interactive menu (every verb with drill-down by category): +./bin/gendia-menu # or: make menu +./bin/gendia-menu --list # non-interactive option dump (CI / docs) # Modifiers (work with most commands): --dry-run show what would happen, do nothing @@ -236,19 +251,83 @@ Override anything via JSON. Every key is optional; omit it to keep the default. Exit codes match `gendia doctor`: `0` = ok, `1` = warnings only (with `--strict`), `2` = at least one error. +## JSON-driven standards engine + autofix + +Layered on top of the legacy rules above, `gendia conventions` ships a **JSON-driven standards engine** with 16 bundled rule packs (71 rules) and `--fix` automation for 21 of them. Add a rule by dropping a JSON file in `src/gendia/data/standards/`; add a check kind by registering one Python function in `standards.py`. No DSL. + +```bash +# Explore the bundled packs +gendia conventions --list-profiles # 16 packs (core, community, lang-node, …) +gendia conventions --list-rules --profile core # rules in the pack +gendia conventions --explain R005 --profile core # rule details + +# Run against any path, composing multiple packs +gendia conventions . --no-legacy \ + --profile core --profile community --profile platform-github --profile lang-node + +# Autofix where the rule pack has a fix kind +gendia conventions . --no-legacy --profile core --profile community --fix --dry-run +gendia conventions . --no-legacy --profile core --profile community --fix +``` + +**Fix-kind coverage today**: 21 rules across 6 packs auto-fix to good defaults. Universal essentials (README / .gitignore / .editorconfig / `* text=auto` in .gitattributes), community health (CONTRIBUTING / CODE_OF_CONDUCT / SECURITY / CHANGELOG with Keep-a-Changelog skeleton), GitHub workflows (CODEOWNERS / PR template / Dependabot / CodeQL / OpenSSF Scorecard / Security Insights), and **5 JSON-mutating fixes for `package.json`**: `name` (slugified directory basename), `version` (`0.1.0`), `license` (auto-detected from existing LICENSE file), `engines.node`, `packageManager`. All fixes are idempotent; mutating fixes use atomic writes + refuse symlinks + tolerate malformed JSON. + +## Repo scaffolds + +`gendia init --scaffold` renders bundled templates with `${var}` substitution and post-actions (`git_init`, `license_text`, `audit`). + +```bash +gendia init --list-scaffolds # 13 scaffolds +gendia init --scaffold python-uv --target ~/projects/foo \ + --var name=foo --var module=foo --var description="A package" +``` + +| Scaffold | Stack | +|---|---| +| `bare` | Universal-set repo + `git_init` + `license_text` | +| `python-uv` / `python-poetry` | Python package, src/ layout, ruff + mypy + pytest | +| `rust` / `go` | Cargo + clippy + rustfmt / `go.mod` + golangci-lint | +| `node-pnpm` / `node-bun` | TypeScript + Biome + Vitest / Bun | +| `php-composer` / `ruby-gem` | Composer + PHPStan + PHPUnit / Gemspec + RSpec | +| `dotnet` | .NET SDK + solution + xUnit | +| `docs-mkdocs` | MkDocs Material | +| `monorepo-pnpm-turbo` | pnpm workspaces + Turborepo | +| `java-gradle` | Gradle Kotlin DSL + JUnit 5 (run `gradle wrapper` once after scaffold) | + +**17 bundled license texts** for the `license_text` post-action: MIT, Apache-2.0, BSD-2/3-Clause, ISC, Unlicense, GPL/LGPL/AGPL-3.0-or-later, MPL-2.0, MIT-0, Zlib, EUPL-1.2, CC-BY-4.0, BSL-1.1, OFL-1.1, Artistic-2.0. + +## Interactive menu + +`bin/gendia-menu` is a numbered drill-down menu wrapping all 18 verbs by category (Inspect / Release & Sync / Standards / Scaffold / Config). It auto-resolves the local `bin/gendia` shim during development and falls back to whatever `gendia` is on `PATH`. + +```bash +make menu # launch the menu +./bin/gendia-menu # equivalent +./bin/gendia-menu --list # non-interactive option dump (CI / docs) +``` + +The menu is shipped inside the Docker image, so `make docker-exec` drops you into a working toolkit shell. + ## Make targets ```bash make help # list targets +make menu # launch the interactive numbered menu make install # uv tool install (or pip fallback) make install-dev # editable install with [dev] extras make test # pytest -make lint # ruff +make lint # ruff check +make format # ruff format (rewrites files) +make format-check # ruff format --check (mirrors CI) make typecheck # mypy +make check # lint + format-check + typecheck + test (CI gate) make sync # gendia sync (extra args via ARGS=...) make release REPO=core VERSION=1.0.1 make audit / cleanup / verify / mirror / init / status / inventory -make docker-build / docker-shell / docker-push REGISTRY=ghcr.io/your-org +# One-shot containers: +make docker-build / docker-shell / docker-run VERB=sync / docker-push REGISTRY=ghcr.io/your-org +# Long-lived compose service: +make docker-up / docker-exec / docker-logs / docker-ps / docker-down / docker-host-shell ``` ## Docker / VPS diff --git a/bin/gendia-menu b/bin/gendia-menu new file mode 100755 index 0000000..9d32f53 --- /dev/null +++ b/bin/gendia-menu @@ -0,0 +1,257 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2034 # MENU_* / TOP_LEVEL are read via `local -n` namerefs +# +# bin/gendia-menu — interactive numbered menu wrapping the gendia CLI. +# +# Usage: +# ./bin/gendia-menu # top-level menu +# ./bin/gendia-menu --list # non-interactive — print every option as a flat list +# ./bin/gendia-menu --help # short help +# +# Picks the local `bin/gendia` shim if present (development), falling back to +# whatever `gendia` is on PATH (installed wheel). Override with GENDIA_BIN. + +set -euo pipefail + +# --- resolution ------------------------------------------------------------- + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ -n "${GENDIA_BIN:-}" ]]; then + GENDIA="${GENDIA_BIN}" +elif [[ -x "${SCRIPT_DIR}/gendia" ]]; then + GENDIA="${SCRIPT_DIR}/gendia" +elif command -v gendia >/dev/null 2>&1; then + GENDIA="$(command -v gendia)" +else + echo "gendia-menu: cannot find the gendia executable." >&2 + echo " Tried: \$GENDIA_BIN, ${SCRIPT_DIR}/gendia, command -v gendia" >&2 + echo " Install with 'make install' or run from the repo root." >&2 + exit 127 +fi + +# --- styling ---------------------------------------------------------------- + +if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then + BOLD=$'\033[1m'; DIM=$'\033[2m'; CYAN=$'\033[36m'; YELLOW=$'\033[33m'; GREEN=$'\033[32m'; RESET=$'\033[0m' +else + BOLD=""; DIM=""; CYAN=""; YELLOW=""; GREEN=""; RESET="" +fi + +banner() { + printf '\n%s%s%s\n' "${BOLD}${CYAN}" "$1" "${RESET}" + printf '%s%s%s\n' "${DIM}" "$(printf '%*s' "${#1}" '' | tr ' ' '─')" "${RESET}" +} + +prompt() { printf '%s%s%s ' "${YELLOW}" "$1" "${RESET}"; } + +run() { + printf '\n%s$ %s %s%s\n' "${GREEN}" "$(basename "${GENDIA}")" "$*" "${RESET}" + "${GENDIA}" "$@" || true + printf '\n%s(press Enter to return to the menu)%s ' "${DIM}" "${RESET}" + read -r _ +} + +# --- option catalog --------------------------------------------------------- +# +# Each MENU_* array entry is "Label::verb [args...]". An entry whose verb +# starts with `@` is a sub-menu dispatcher (handled in dispatch()). + +MENU_INSPECT=( + "Status (branch / clean / ahead / latest-tag per repo)::status" + "Inventory (synced / pending / untracked / missing)::inventory" + "Doctor (system-wide health check)::doctor" + "Audit (hygiene: clean tree, tags, composer.json, …)::audit" + "Scan a directory tree for git repos::@scan" +) + +MENU_RELEASE=( + "Sync (push commits + tags, notify registry)::sync" + "Release a single repo::@release" + "Mirror (clone every repo to a target directory)::@mirror" + "Verify (run each repo's verify[] commands)::verify" + "Cleanup (drop vendor/, node_modules/, …)::cleanup" +) + +MENU_STANDARDS=( + "List bundled rule packs::conventions --list-profiles" + "List rules in a pack::@conventions-list-rules" + "Explain a single rule::@conventions-explain" + "Audit this dir against a pack::@conventions-run" + "Audit + auto-fix (writes files)::@conventions-fix" +) + +MENU_SCAFFOLD=( + "List bundled scaffolds::init --list-scaffolds" + "Scaffold a new repo (interactive)::@init-scaffold" + "Scaffold gendia.json into current dir::init" +) + +MENU_CONFIG=( + "Config (list / get / set / edit / path / doctor)::@config" + "Setup (local / vps / docker / k8s / ci)::@setup" + "Identity (per-account git identity)::@identity" + "SSH (list / test / inspect / bootstrap)::@ssh" + "Generate (emit per-host git commands)::@generate" + "Convert (flip git URLs SSH ⇄ HTTPS)::@convert" +) + +TOP_LEVEL=( + "Inspect (read-only)::@menu-inspect" + "Release & Sync (mutations)::@menu-release" + "Standards audit (JSON-driven --profile / --fix)::@menu-standards" + "Scaffold new repos::@menu-scaffold" + "Config / SSH / Identity::@menu-config" + "Run gendia --help::--help" +) + +# --- list mode -------------------------------------------------------------- + +list_mode() { + printf '%sgendia-menu — full option list%s\n' "${BOLD}" "${RESET}" + printf 'Resolved gendia: %s\n\n' "${GENDIA}" + for grp in INSPECT RELEASE STANDARDS SCAFFOLD CONFIG; do + printf '%s[%s]%s\n' "${BOLD}" "${grp}" "${RESET}" + local -n arr="MENU_${grp}" + for entry in "${arr[@]}"; do + printf ' • %s\n' "${entry%%::*}" + done + printf '\n' + done +} + +# --- interactive prompts for verbs that need args -------------------------- + +dispatch() { + case "$1" in + @quit) return 1 ;; + @menu-inspect) show_menu "Inspect" MENU_INSPECT ;; + @menu-release) show_menu "Release & Sync" MENU_RELEASE ;; + @menu-standards) show_menu "Standards audit" MENU_STANDARDS ;; + @menu-scaffold) show_menu "Scaffold" MENU_SCAFFOLD ;; + @menu-config) show_menu "Config" MENU_CONFIG ;; + @scan) + prompt "Path to scan [.]:"; read -r path + run scan "${path:-.}" + ;; + @release) + prompt "Repo (e.g. myorg/core):"; read -r repo + prompt "Version (e.g. 1.0.1):"; read -r version + [[ -z "$repo" || -z "$version" ]] && { echo "Both repo and version are required."; return; } + run release "$repo" "$version" + ;; + @mirror) + prompt "Target directory:"; read -r target + [[ -z "$target" ]] && { echo "Target is required."; return; } + run mirror "$target" + ;; + @conventions-list-rules) + prompt "Profile name (e.g. core):"; read -r pack + run conventions --list-rules --profile "${pack:-core}" + ;; + @conventions-explain) + prompt "Rule id (e.g. R005):"; read -r rule + prompt "Profile (default: core):"; read -r pack + run conventions --explain "$rule" --profile "${pack:-core}" + ;; + @conventions-run) + prompt "Path to audit [.]:"; read -r path + prompt "Profiles (space-separated; default: core):"; read -r profiles + local -a args=(conventions "${path:-.}" --no-legacy) + for p in ${profiles:-core}; do args+=(--profile "$p"); done + run "${args[@]}" + ;; + @conventions-fix) + prompt "Path to audit + fix [.]:"; read -r path + prompt "Profiles (space-separated; default: core):"; read -r profiles + prompt "Dry run? [y/N]:"; read -r dry + local -a args=(conventions "${path:-.}" --no-legacy --fix) + for p in ${profiles:-core}; do args+=(--profile "$p"); done + [[ "$dry" =~ ^[Yy] ]] && args+=(--dry-run) + run "${args[@]}" + ;; + @init-scaffold) + "${GENDIA}" init --list-scaffolds + prompt "Scaffold id:"; read -r sid + prompt "Target directory:"; read -r target + prompt "name=value pairs (space-separated, e.g. name=foo module=foo):"; read -r vars + [[ -z "$sid" || -z "$target" ]] && { echo "Scaffold id and target are required."; return; } + local -a args=(init --scaffold "$sid" --target "$target") + for kv in $vars; do args+=(--var "$kv"); done + run "${args[@]}" + ;; + @config) prompt "Subcommand (list/get/set/unset/edit/path/doctor):"; read -r sub; run config "$sub" ;; + @setup) prompt "Shape (local/vps/project/docker/k8s/ci):"; read -r shape; run setup --shape "$shape" ;; + @identity) prompt "Subcommand (list/check/apply/setup):"; read -r sub; run identity "$sub" ;; + @ssh) prompt "Subcommand (list/test/inspect/bootstrap):"; read -r sub; run ssh "$sub" ;; + @generate) prompt "Format (text/script/json/markdown) [text]:"; read -r fmt; run generate --format "${fmt:-text}" ;; + @convert) prompt "Direction (ssh/https):"; read -r dir; run convert --to "$dir" ;; + --help) run --help ;; + *) + # Verb passed verbatim (e.g. "status", "conventions --list-profiles") + # shellcheck disable=SC2086 + run $1 + ;; + esac +} + +show_menu() { + local title="$1" + local -n options="$2" + while true; do + banner "$title" + local i=1 + for entry in "${options[@]}"; do + printf ' %s%2d.%s %s\n' "${CYAN}" "$i" "${RESET}" "${entry%%::*}" + ((i++)) + done + if [[ "$title" != "gendia" ]]; then + printf ' %s%2s.%s Back\n' "${CYAN}" "0" "${RESET}" + else + printf ' %s%2s.%s Quit\n' "${CYAN}" "0" "${RESET}" + fi + printf '\n' + prompt "Choose [1-${#options[@]}, 0 to go back, q to quit]:" + read -r choice + case "$choice" in + q|Q) exit 0 ;; + 0|"") return ;; + *[!0-9]*) echo "Not a number: $choice" ;; + *) + if (( choice >= 1 && choice <= ${#options[@]} )); then + local entry="${options[$((choice - 1))]}" + local action="${entry##*::}" + dispatch "$action" + else + echo "Out of range: $choice" + fi + ;; + esac + done +} + +# --- main ------------------------------------------------------------------- + +case "${1:-}" in + --list|-l) list_mode; exit 0 ;; + --help|-h) + cat < argparse.ArgumentParser: help="Limit to one account name (default: all configured accounts).", ) - # init [--force] + # init [--force] [--scaffold ID --var KEY=VALUE...] p_init = sub.add_parser("init", help=_DESCRIPTIONS["init"]) - p_init.add_argument("--force", action="store_true", help="Overwrite existing gendia.json") + p_init.add_argument("--force", action="store_true", help="Overwrite existing files") p_init.add_argument("--out", type=Path, default=Path("gendia.json"), help="Path to write") + p_init.add_argument( + "--scaffold", + default=None, + help="Bundled scaffold id (e.g. `bare`, `python-uv`). See --list-scaffolds.", + ) + p_init.add_argument( + "--list-scaffolds", + action="store_true", + help="Print bundled scaffold ids and exit.", + ) + p_init.add_argument( + "--var", + dest="scaffold_vars", + action="append", + default=[], + metavar="KEY=VALUE", + help="Scaffold variable (repeatable). Example: --var name=my-pkg", + ) + p_init.add_argument( + "--target", + type=Path, + default=None, + help="Scaffold target directory (default: ./).", + ) + p_init.add_argument( + "--merge", + action="store_true", + help="Scaffold mode: skip files that already exist (retrofit-friendly).", + ) + p_init.add_argument( + "--dry-run", + action="store_true", + help="Print what would be written without touching disk.", + ) # config ... config_cmd.add_subparser(sub) @@ -145,6 +193,20 @@ def build_parser() -> argparse.ArgumentParser: # conventions [PATH] [--rules FILE] [--strict] [--json] conventions_op.add_subparser(sub) + # generate [--format ...] [--output FILE] [--execute] ... + generate_op.add_subparser(sub) + + # scan [PATH] [--check-dirty] [--json] + scan_op.add_subparser(sub) + + # convert URL... [--to ssh|https] + convert_op.add_subparser(sub) + + # ssh {list, test, inspect, bootstrap} + from gendia.cli.commands import ssh as ssh_cmd # noqa: PLC0415 + + ssh_cmd.add_subparser(sub) + return parser @@ -189,6 +251,12 @@ def _csv(value: str) -> frozenset[str]: "Lint repo hygiene: GitHub-special files, naming, ban-list glyphs, " "spec filename rules, sub-folder readmes, shell-script shebangs." ), + "generate": ( + "Emit copy-paste git commands per detected SSH host " + "(text/script/json/markdown; --execute runs the bash form)." + ), + "scan": "Find git repos on disk and classify each (clean / dirty / orphan / https).", + "convert": "Flip git URLs between SSH and HTTPS (alias + insteadOf aware).", } @@ -224,6 +292,13 @@ def dispatch(args: argparse.Namespace) -> int: # noqa: PLR0911 — many sidecar return identity_cmd.dispatch(args) if args.command == "conventions": return conventions_op.dispatch(args) + if args.command == "ssh": + from gendia.cli.commands import ssh as ssh_cmd # noqa: PLC0415 + + return ssh_cmd.dispatch(args) + + if args.command in {"generate", "scan", "convert"}: + return _run_project_optional(args) try: config = load_config(project_path=args.config) @@ -297,8 +372,122 @@ def _build_credentials(args: argparse.Namespace) -> CredentialResolver: return CredentialResolver(*backends) +def _run_project_optional(args: argparse.Namespace) -> int: + """Run `generate` / `scan` / `convert`, which don't require a project block. + + These ops still want a config (for accounts + forges_file). When loading + fails or no project exists, we fall back to a stub project so the existing + OperationContext shape stays usable. + """ + config: GendiaConfig | None = None + try: + config = load_config(project_path=args.config) + except ConfigError as exc: + _log.warning("config load failed; continuing without it", extra={"error": str(exc)}) + + if config is None or config.project is None: + # Build a stub config that still preserves any accounts the user + # already configured (so generate can discover repos via providers). + accounts = config.accounts if config else {} + registries = config.registries if config else {} + defaults = config.defaults if config else Defaults() + forges_file = config.forges_file if config else None + stub_project = Project( + name="", + account=next(iter(accounts), "") or "", + root=Path.cwd(), + repos=(RepoSpec(dir="."),), + ) + if not accounts: + accounts = { + "": Account( + name="", + platform="github", + credential_ref="STUB", + org="", + ) + } + config = GendiaConfig( + accounts=accounts, + registries=registries, + project=stub_project, + defaults=defaults, + forges_file=forges_file, + ) + + credentials = _build_credentials(args) + project = config.project + if project is None: + # Defensive: the rebuild above guarantees a stub project, but mypy + # can't see that across the conditional reassignment. + raise RuntimeError("internal: project should not be None at this point") + + account_name = project.account + if account_name not in config.accounts: + # Pick any available account as the provider seed; ops that don't + # need provider calls will still function. + account_name = next(iter(config.accounts)) + account = config.account(account_name) + provider = build_provider(account, credentials) + registry = ( + build_registry(config.registry(project.registry), credentials) if project.registry else None + ) + + ctx = OperationContext( + config=config, + project=project, + provider=provider, + registry=registry, + dry_run=getattr(args, "dry_run", False), + workers=(getattr(args, "concurrency", None) or config.defaults.concurrency), + only=getattr(args, "only", frozenset()) or frozenset(), + skip=getattr(args, "skip", frozenset()) or frozenset(), + ) + + operation: Operation + suppress_summary = False + if args.command == "generate": + forges_path = Path(config.forges_file).expanduser() if config.forges_file else None + operation = GenerateOperation( + ctx, + request=generate_op.request_from_args(args, forges_file=forges_path), + ) + # The operation writes its own structured output; the summary line + # would only confuse json/script consumers. + suppress_summary = getattr(args, "fmt", "text") in {"json", "script"} or bool( + getattr(args, "output", None) + ) + elif args.command == "scan": + operation = ScanOperation(ctx, request=scan_op.request_from_args(args)) + suppress_summary = bool(getattr(args, "json_out", False)) + elif args.command == "convert": + operation = ConvertOperation(ctx, request=convert_op.request_from_args(args)) + suppress_summary = bool(getattr(args, "json_out", False)) + else: + raise ValueError(f"unexpected command: {args.command}") + + result = operation.run() + if not suppress_summary: + _print_result(result, args) + return 0 if result.all_ok else 1 + + def _run_init(args: argparse.Namespace) -> int: """Init doesn't need a config; build a minimal stub context.""" + if getattr(args, "list_scaffolds", False): + from gendia import scaffolds # noqa: PLC0415 — keep cli/cold-start path small + + ids = scaffolds.list_bundled_scaffolds() + if not ids: + print("(no bundled scaffolds found)") + return 1 + print("Bundled scaffolds (use --scaffold ID):") + for sid in ids: + spec = scaffolds.load_bundled_scaffold(sid) + description = spec.description if spec else "" + print(f" {sid:<20} {description}") + return 0 + stub_account = Account(name="stub", platform="github", credential_ref="STUB", org="stub") stub_project = Project( name="stub", @@ -325,10 +514,29 @@ def clone_url(self, *_a: object, **_kw: object) -> str: project=stub_project, provider=_Stub(), # type: ignore[arg-type] registry=None, - dry_run=False, + dry_run=getattr(args, "dry_run", False), workers=1, ) - op = InitOperation(ctx, target=args.out, force=args.force) + + scaffold_request = None + if getattr(args, "scaffold", None): + from gendia import scaffolds # noqa: PLC0415 + from gendia.operations.init import ScaffoldRequest # noqa: PLC0415 + + try: + user_vars = scaffolds.parse_var_assignments(args.scaffold_vars or []) + except ValueError as exc: + print(f"init: {exc}", file=sys.stderr) + return 2 + scaffold_request = ScaffoldRequest( + scaffold_id=args.scaffold, + target=args.target, + variables=user_vars, + merge=bool(getattr(args, "merge", False)), + force=bool(getattr(args, "force", False)), + ) + + op = InitOperation(ctx, target=args.out, force=args.force, scaffold=scaffold_request) result = op.run() _print_result(result, args) return 0 if result.all_ok else 1 diff --git a/src/gendia/cli/commands/doctor.py b/src/gendia/cli/commands/doctor.py index 363e3a6..6560d4c 100644 --- a/src/gendia/cli/commands/doctor.py +++ b/src/gendia/cli/commands/doctor.py @@ -110,6 +110,7 @@ def dispatch(args: argparse.Namespace) -> int: _check_env_file(report, args.env_file or env_file_path()) _check_binaries(report) _check_ssh(report, args.config) + _check_ssh_keys(report) _check_credentials(report, args.config, args.env_file or env_file_path()) report.render() @@ -263,6 +264,25 @@ def _check_ssh(report: Report, project_config: Path | None) -> None: report.add("ok", section, f"account {account.name!r}: key {key} mode {mode:o}") +def _check_ssh_keys(report: Report) -> None: + """Audit IdentityFiles referenced in ~/.ssh/config (mode + strength + reuse).""" + section = "ssh keys" + from gendia.ssh.config import SSHConfig # noqa: PLC0415 + from gendia.ssh.inspector import audit_keys # noqa: PLC0415 + + cfg = SSHConfig.load() + findings = audit_keys(cfg.hosts().exclude_wildcards()) + if not findings: + return # silent when there are no IdentityFile entries to audit + for finding in findings: + report.add( + severity=finding.severity, + section=section, + message=f"{finding.path}: {finding.message}", + fix=finding.fix, + ) + + def _check_credentials(report: Report, project_config: Path | None, env_path: Path) -> None: section = "credentials" try: diff --git a/src/gendia/cli/commands/setup.py b/src/gendia/cli/commands/setup.py index 63837d9..42a93c5 100644 --- a/src/gendia/cli/commands/setup.py +++ b/src/gendia/cli/commands/setup.py @@ -22,6 +22,7 @@ from dataclasses import dataclass from importlib import resources from pathlib import Path +from typing import Any from gendia.auth.env_file import secure_create_dir from gendia.observability.logger import get_logger @@ -55,6 +56,14 @@ def add_subparser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser action="store_true", help="Print what would be written, don't actually create files.", ) + p.add_argument( + "--from-ssh", + action="store_true", + help=( + "Synthesize accounts from ~/.ssh/config and merge them into " + "~/.config/gendia/gendia.json (use --dry-run to preview)." + ), + ) _HANDLERS_WITH_FLAGS = {"local", "vps", "project", "docker"} @@ -65,6 +74,9 @@ def dispatch(args: argparse.Namespace) -> int: if args.detect: return _print_detection() + if args.from_ssh: + return _setup_from_ssh(force=args.force, dry_run=args.dry_run) + shape = args.shape or _resolve_default_shape(force=args.force) if shape in _HANDLERS_WITH_FLAGS: @@ -417,6 +429,62 @@ def _setup_ci() -> int: # --- helpers ----------------------------------------------------------------- +def _setup_from_ssh(*, force: bool, dry_run: bool) -> int: + """Synthesize accounts from ~/.ssh/config and merge into gendia.json.""" + import json # noqa: PLC0415 — keep stdlib imports local to the rare path + + from gendia.ssh.bootstrap import propose_accounts, render_proposed_config # noqa: PLC0415 + from gendia.ssh.config import SSHConfig # noqa: PLC0415 + from gendia.ssh.forges import load_registry # noqa: PLC0415 + + cfg = SSHConfig.load() + if not cfg.hosts(): + print("no Host entries in ~/.ssh/config; nothing to bootstrap") + return 1 + + stubs = propose_accounts(config=cfg, registry=load_registry()) + if not stubs: + print( + "no recognized forge hosts in ~/.ssh/config " + "(use `gendia ssh list` to inspect the parser's view)." + ) + return 1 + + proposal = render_proposed_config(stubs) + if dry_run: + sys.stdout.write(proposal) + print(f"\n(dry-run) would merge {len(stubs)} account(s) into ~/.config/gendia/gendia.json") + return 0 + + target = Path.home() / ".config" / "gendia" / "gendia.json" + target.parent.mkdir(parents=True, exist_ok=True) + + existing: dict[str, Any] = {} + if target.is_file(): + try: + existing = json.loads(target.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + if not force: + print(f" ✗ {target} is not valid JSON: {exc}", file=sys.stderr) + print(" pass --force to overwrite the unparseable file", file=sys.stderr) + return 2 + print(f" ⚠ {target} is invalid JSON; --force given, overwriting") + + accounts = dict(existing.get("accounts") or {}) + proposed = json.loads(proposal) + accounts.update(proposed["accounts"]) + existing["accounts"] = accounts + + target.write_text(json.dumps(existing, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(f" ✓ wrote {len(stubs)} account(s) to {target}") + print() + print("Next steps:") + print(f" 1. $EDITOR {target} # review the proposal, fill in real scopes") + print(" 2. gendia config set GIT_TOKEN_