diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..1291a34 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,63 @@ +#!/bin/sh +# Pre-push gate for the web/ app. Runs ESLint + Prettier-check before a push +# touches the Next.js site; a failure aborts the push so unformatted or +# lint-broken web code never reaches the remote. (Go code is gated by CI.) +# +# Change-aware: if the push range contains no web/ changes, this exits +# immediately so Go-only pushes don't pay the npm startup cost. +# +# Enable once per clone: git config core.hooksPath .githooks +# Bypass in an emergency: git push --no-verify +set -eu + +REPO_ROOT=$(git rev-parse --show-toplevel) +WEB_DIR="$REPO_ROOT/web" + +# git feeds the hook " " lines +# on stdin, one per ref being pushed. We collect the local shas to diff. +z40=0000000000000000000000000000000000000000 +changed_web=0 + +while read -r _local_ref local_sha _remote_ref remote_sha; do + # Skip a branch deletion (local sha is all-zero). + [ "$local_sha" = "$z40" ] && continue + + if [ "$remote_sha" = "$z40" ]; then + # New branch on the remote: diff against the upstream default so we only + # consider this branch's own commits. If origin/main is unknown locally we + # cannot compute a real range — fail closed and run the checks rather than + # diffing against the working tree (which could skip committed web/ changes). + if git rev-parse --quiet --verify origin/main >/dev/null 2>&1; then + range="origin/main..$local_sha" + else + changed_web=1 + break + fi + else + range="$remote_sha..$local_sha" + fi + + # Any web/ files in this range? + if git diff --name-only "$range" 2>/dev/null | grep -q '^web/'; then + changed_web=1 + break + fi +done + +if [ "$changed_web" -eq 0 ]; then + echo "pre-push: no web/ changes in this push — skipping web checks." + exit 0 +fi + +echo "pre-push: web/ changed — running lint + format check…" +cd "$WEB_DIR" + +if [ ! -d node_modules ]; then + echo "pre-push: web/node_modules missing — run 'npm install' in web/ first." >&2 + exit 1 +fi + +npm run lint || { echo "pre-push: ESLint failed — fix it or run 'npm run lint:fix'." >&2; exit 1; } +npm run format:check || { echo "pre-push: formatting issues — run 'npm run format'." >&2; exit 1; } + +echo "pre-push: web checks passed." diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..0897da9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,63 @@ +name: Bug report +description: Something siphon did wrong — a crash, wrong output, or a failed operation. +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for the report. Please include enough to reproduce — siphon shells + out to native dump/restore tools, so the exact command and engine matter. + - type: textarea + id: what-happened + attributes: + label: What happened + description: What you ran and what went wrong. Paste the command and the error. + placeholder: | + $ siphon backup prod + ✗ ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: What you expected + validations: + required: true + - type: input + id: version + attributes: + label: siphon version + description: Output of `siphon --version`. + placeholder: "v1.0.0" + validations: + required: true + - type: dropdown + id: engine + attributes: + label: Database engine + options: + - PostgreSQL + - MySQL + - MariaDB + - Cross-engine (specify in details) + - Not engine-specific + validations: + required: true + - type: dropdown + id: os + attributes: + label: OS + options: [Linux, macOS, Windows] + validations: + required: true + - type: input + id: exit-code + attributes: + label: Exit code + description: siphon uses a POSIX exit-code taxonomy; the number helps classify the failure. + placeholder: "e.g. 1 (user), 70 (system), 65 (integrity)" + - type: textarea + id: details + attributes: + label: Anything else + description: Profile config (redact secrets), storage backend, logs, repro steps. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..e107a26 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Question or discussion + url: https://github.com/nixrajput/siphon/discussions + about: For usage questions and ideas, start a discussion instead of an issue. + - name: Security vulnerability + url: https://github.com/nixrajput/siphon/security/policy + about: Please report security issues privately, not as a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..c78b29c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,37 @@ +name: Feature request +description: Suggest a capability or improvement. +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: The problem + description: What are you trying to do that siphon makes hard or impossible today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: What you'd like siphon to do. A command sketch helps. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: dropdown + id: area + attributes: + label: Area + options: + - Backup / restore + - Sync / CDC / cross-engine + - Storage backends + - Retention + - Secrets / auth / audit + - TUI + - Drivers (new engine) + - Other + validations: + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..7918e5a --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## What & why + + + +## How it was verified + + + +- [ ] `make test` passes +- [ ] `make lint` passes (0 issues) +- [ ] `make test-integration` (if the change touches a live DB / storage path) +- [ ] Docs updated (README / `docs/*.md` / CHANGELOG) for any user-facing change +- [ ] `web/` builds (`npm run build`) — only if the site changed + +## Notes for reviewers + + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..044f364 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,90 @@ +name: Release + +# Auto-releases when a version tag (vX.Y.Z) is pushed. GoReleaser builds the +# cross-platform binaries, SHA-256 checksums, and cosign-keyless signatures, +# publishes a GitHub Release, and — when the tap repos + tokens exist — pushes +# the Homebrew formula and Scoop manifest. +# +# A validation gate (test + lint + config check) runs first, so a broken tag +# never publishes. workflow_dispatch runs a snapshot dry-run (no publish) to +# rehearse a release without tagging. +on: + push: + tags: + - "v*" + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + # Gate: the same checks CI runs, plus goreleaser config validation. The + # release job depends on this, so a failing test/lint aborts before publish. + validate: + name: Validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false # don't leave the token in .git/config + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod # reproducible: pin the toolchain to go.mod + cache: true + - name: Test + run: go test ./... github.com/nixrajput/siphon/internal/driver/_mysqlcommon + - name: Lint + uses: golangci/golangci-lint-action@v6 + - name: GoReleaser check + uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: check + + release: + name: Release + needs: validate + runs-on: ubuntu-latest + permissions: + contents: write # create the GitHub Release + id-token: write # cosign keyless signing via GitHub OIDC + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false # don't leave the token in .git/config # GoReleaser needs full history + tags for the changelog + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod # reproducible: pin the toolchain to go.mod + cache: true + + - name: Install cosign + uses: sigstore/cosign-installer@v3 + + # On a tag push: full release. On manual dispatch: snapshot dry-run that + # builds everything locally and publishes nothing (rehearsal). + - name: Set release args + id: args + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + echo "args=release --clean" >> "$GITHUB_OUTPUT" + else + echo "args=release --clean --snapshot" >> "$GITHUB_OUTPUT" + fi + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: ${{ steps.args.outputs.args }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Tap-push tokens. These secrets may be unset on early releases; a + # GitHub Actions secret expression yields "" when absent, so the + # GoReleaser token template still resolves and skip_upload: auto + # quietly skips the brew/scoop push instead of erroring. Provision + # these (PATs with repo scope on the tap repos) to enable publishing. + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + SCOOP_TAP_GITHUB_TOKEN: ${{ secrets.SCOOP_TAP_GITHUB_TOKEN }} diff --git a/.golangci.yml b/.golangci.yml index 25219a8..b6d4e9d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -57,6 +57,12 @@ linters: - pkg: "github.com/nixrajput/siphon/internal/app" desc: "Drivers must not depend on Application — dependency flows down" exclusions: + # web/ is the Next.js site; its node_modules can vendor stray .go files + # (e.g. flatted/golang) that are not siphon code. Keep the Go linters off + # the JS subtree. (node_modules is gitignored, so CI never sees it on a + # fresh checkout; this keeps local lint consistent with CI.) + paths: + - web rules: - path: _test\.go linters: diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..ada2ad7 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,109 @@ +# GoReleaser v2 configuration — https://goreleaser.com +# Drives the release: cross-platform binaries, archives, SHA-256 checksums, +# cosign-keyless signatures, a GitHub Release, and (when the tap repos + +# token exist) a Homebrew formula and Scoop manifest. +# +# Run locally (no publish): goreleaser release --snapshot --clean +# Validate config: goreleaser check +version: 2 + +project_name: siphon + +# No `go mod tidy` before-hook: the release path stays read-only so the +# published binary is built from exactly the committed dependency graph at the +# tagged revision (CI enforces tidiness separately). + +builds: + - id: siphon + main: ./cmd/siphon + binary: siphon + env: + - CGO_ENABLED=0 + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + # Match the version symbol the Makefile already injects, so a released + # binary's `siphon --version` reports the tag (not "0.0.1-dev"). + ldflags: + - -s -w + - -X github.com/nixrajput/siphon/internal/cli.Version={{.Version}} + +archives: + - id: siphon + formats: [tar.gz] + # Windows users expect .zip. + format_overrides: + - goos: windows + formats: [zip] + name_template: >- + {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} + files: + - LICENSE + - README.md + +checksum: + name_template: "checksums.txt" + algorithm: sha256 + +# Cosign keyless signing of the checksums file. The release workflow grants +# id-token: write so cosign authenticates via GitHub's OIDC — no key to manage. +# Verify with: cosign verify-blob --certificate checksums.txt.pem \ +# --signature checksums.txt.sig --certificate-identity-regexp ... checksums.txt +signs: + - cmd: cosign + artifacts: checksum + output: true + certificate: "${artifact}.pem" + args: + - sign-blob + - "--output-certificate=${certificate}" + - "--output-signature=${signature}" + - "${artifact}" + - "--yes" + +changelog: + use: github + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" + - "Merge pull request" + +release: + github: + owner: nixrajput + name: siphon + prerelease: auto # tags like v1.0.0-rc1 → marked prerelease automatically + +# Homebrew tap. Publishes to a SEPARATE repo (nixrajput/homebrew-siphon) using +# a PAT in HOMEBREW_TAP_GITHUB_TOKEN. skip_upload: auto means snapshots and +# prereleases do NOT push the formula, so the core release succeeds even before +# the tap repo / token exist — light up brew on a later stable release. +brews: + - name: siphon + repository: + owner: nixrajput + name: homebrew-siphon + token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" + homepage: "https://github.com/nixrajput/siphon" + description: "Sync any database, anywhere — backup, restore, and sync across engines." + license: "MIT" + skip_upload: auto + install: | + bin.install "siphon" + test: | + system "#{bin}/siphon", "--version" + +# Scoop bucket (Windows). Publishes to nixrajput/scoop-siphon via +# SCOOP_TAP_GITHUB_TOKEN; same skip_upload: auto tolerance as brew. +scoops: + - name: siphon + repository: + owner: nixrajput + name: scoop-siphon + token: "{{ .Env.SCOOP_TAP_GITHUB_TOKEN }}" + homepage: "https://github.com/nixrajput/siphon" + description: "Sync any database, anywhere — backup, restore, and sync across engines." + license: "MIT" + skip_upload: auto diff --git a/CHANGELOG.md b/CHANGELOG.md index 455366d..07f6db1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Phase H (distribution) — release tooling + landing/docs site (publishing awaits the `v1.0.0` tag + owner provisioning): + - **GoReleaser** (`.goreleaser.yaml`, v2): cross-platform builds (linux/darwin/windows × amd64/arm64), tar.gz/zip archives, a SHA-256 `checksums.txt`, and **cosign-keyless** signing of the checksums (GitHub OIDC, no key to manage). The version ldflags match the symbol the Makefile injects, so a released binary's `--version` reports its tag. + - **Release workflow** (`.github/workflows/release.yml`): triggers on a `v*` tag, pins GoReleaser ~> v2, installs cosign, and grants `contents: write` + `id-token: write`. Tap-push tokens resolve to empty when unset so a first release without taps doesn't error (`skip_upload: auto`). + - **Homebrew tap + Scoop bucket** blocks publishing to `nixrajput/homebrew-siphon` and `nixrajput/scoop-siphon`. + - **Install script** (`scripts/install.sh`): POSIX `curl | sh` that detects OS/arch, resolves the latest release, and **verifies the archive's SHA-256 before installing** (refuses on mismatch). + - **Landing + docs site** (`web/`): a Next.js (App Router) + Tailwind app deployed on Vercel. The docs pages render the repository's own `docs/*.md` at build time (single source of truth). README install section, CHANGELOG, and `web/README.md` (with the owner provisioning checklist) updated. + - Phase G (ops) — **multi-backend secrets** (final G cycle): two new secret-ref backends on the existing resolver seam, so passwords can come from a credential store instead of config. - **OS keychain** (`keychain://` or `keychain:///`) via `go-keyring` — macOS Keychain, Windows Credential Manager, Linux Secret Service. No config, no network; short form looks up service `siphon`. - **AWS Secrets Manager** (`awssm://`, or `awssm://#` to pull one field of a JSON secret) via the AWS SDK, reusing the same credential chain as S3 storage. Off by default; enable with `secrets.awssm: true` (+ optional `awssm_region`). diff --git a/Makefile b/Makefile index 4d625f8..aff8f03 100644 --- a/Makefile +++ b/Makefile @@ -41,4 +41,14 @@ tidy: ## Run go mod tidy clean: ## Remove build artifacts rm -rf bin/ dist/ coverage.out coverage.html -.PHONY: help lint test test-verbose test-integration build run install tidy clean +hooks: ## Enable the committed git hooks (one-time, per clone) + git config core.hooksPath .githooks + @echo "git hooks enabled (core.hooksPath=.githooks)" + +web-lint: ## Lint the web/ app (ESLint) + cd web && npm run lint + +web-format: ## Format the web/ app (Prettier) + cd web && npm run format + +.PHONY: help lint test test-verbose test-integration build run install tidy clean hooks web-lint web-format diff --git a/README.md b/README.md index 4c9255c..c114f30 100644 --- a/README.md +++ b/README.md @@ -47,20 +47,20 @@ A single binary that turns the painful, error-prone sprawl of `pg_dump` → `pg_ ## Project status -| Phase | What it delivers | Status | -| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| **A** — Skeleton | Go module, Cobra CLI, TUI placeholder, `Driver` interface + registry, `errs`/`config`/`secrets`/`profile` packages, golangci-lint + depguard, cross-platform CI | ✅ Complete | -| **B** — Postgres walking skeleton | `backup`, `restore`, `sync`, `verify`, `inspect`, `dumps`, `config`, `profile` working end-to-end against PostgreSQL | ✅ Complete | -| **C** — TUI dashboard | Multi-panel Bubble Tea dashboard (profiles · dumps · jobs) with live job progress, backup/restore modal forms, and snapshot tests | ✅ Complete | -| **D** — Driver hardening | Shared cross-driver test harness (`RunDriverSuite`), capability-gating helper (`RequireCapability`), Postgres connection-probe retry, and a `docs/DRIVERS.md` contributor guide | ✅ Complete | -| **E** — MySQL + MariaDB | Both drivers via a shared `_mysqlcommon` package (shared `Conn`, `mysqldump`/`mariadb-dump` backup, client-pipe restore), exercised by the Phase D `RunDriverSuite` harness | ✅ Complete | -| **F** — Advanced transfer | All four advanced-transfer modes work end-to-end: bounded-buffer streaming sync; **incremental** backup/restore (`backup --incremental --base ` captures a bounded change set via Postgres logical decoding / MySQL-MariaDB binlog, `restore` replays the base→incremental chain, Postgres orphan-slot sweep); **cross-engine** sync (`sync --cross-engine` — typed `SchemaInspector` introspection → canonical type-mapping, e.g. Postgres → MySQL); and **CDC** (`siphon cdc` / `sync --continuous` — unbounded change streaming with snapshot→stream handoff, resumable, same- and cross-engine). Live DB paths are integration-tested in CI — see [docs/INCREMENTAL.md](docs/INCREMENTAL.md), [docs/CROSS_ENGINE.md](docs/CROSS_ENGINE.md), [docs/CDC.md](docs/CDC.md) | ✅ Complete | -| **G** — Ops features | **Cloud storage** (✅): the dump catalog can live in an S3 / S3-compatible bucket via a pluggable `storage.Store` backend (local + S3; GCS/Azure are a fast-follow), `storage:` config block, SHA-256 integrity end-to-end — see [docs/STORAGE.md](docs/STORAGE.md). **Retention** (✅): chain-aware pruning (`siphon dumps prune`) with keep-last-N / max-age / GFS rules, per-profile `retention:` config; see [docs/RETENTION.md](docs/RETENTION.md). **Ops suite** (✅): an append-only **audit log** of destructive ops (`audit:` config), **2FA/group gating** (a profile group can require a typed confirmation and/or TOTP before destructive ops), opt-in aggregate **telemetry** (`telemetry:` config), **`siphon schedule`** (manages recurring backups in your crontab), and **`siphon tunnel`** (SSH local-forward to a DB via a bastion). **Multi-backend secrets** (✅): `keychain://` (OS credential store) and `awssm://` (AWS Secrets Manager) join `env:` as secret-ref schemes. See [docs/OPS.md](docs/OPS.md). | ✅ Complete | -| **H** — Distribution | GoReleaser, Homebrew tap, Scoop bucket, install script, docs site | ⏳ Planned | +| Phase | What it delivers | Status | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | +| **A** — Skeleton | Go module, Cobra CLI, TUI placeholder, `Driver` interface + registry, `errs`/`config`/`secrets`/`profile` packages, golangci-lint + depguard, cross-platform CI | ✅ Complete | +| **B** — Postgres walking skeleton | `backup`, `restore`, `sync`, `verify`, `inspect`, `dumps`, `config`, `profile` working end-to-end against PostgreSQL | ✅ Complete | +| **C** — TUI dashboard | Multi-panel Bubble Tea dashboard (profiles · dumps · jobs) with live job progress, backup/restore modal forms, and snapshot tests | ✅ Complete | +| **D** — Driver hardening | Shared cross-driver test harness (`RunDriverSuite`), capability-gating helper (`RequireCapability`), Postgres connection-probe retry, and a `docs/DRIVERS.md` contributor guide | ✅ Complete | +| **E** — MySQL + MariaDB | Both drivers via a shared `_mysqlcommon` package (shared `Conn`, `mysqldump`/`mariadb-dump` backup, client-pipe restore), exercised by the Phase D `RunDriverSuite` harness | ✅ Complete | +| **F** — Advanced transfer | All four advanced-transfer modes work end-to-end: bounded-buffer streaming sync; **incremental** backup/restore (`backup --incremental --base ` captures a bounded change set via Postgres logical decoding / MySQL-MariaDB binlog, `restore` replays the base→incremental chain, Postgres orphan-slot sweep); **cross-engine** sync (`sync --cross-engine` — typed `SchemaInspector` introspection → canonical type-mapping, e.g. Postgres → MySQL); and **CDC** (`siphon cdc` / `sync --continuous` — unbounded change streaming with snapshot→stream handoff, resumable, same- and cross-engine). Live DB paths are integration-tested in CI — see [docs/INCREMENTAL.md](docs/INCREMENTAL.md), [docs/CROSS_ENGINE.md](docs/CROSS_ENGINE.md), [docs/CDC.md](docs/CDC.md) | ✅ Complete | +| **G** — Ops features | **Cloud storage** (✅): the dump catalog can live in an S3 / S3-compatible bucket via a pluggable `storage.Store` backend (local + S3; GCS/Azure are a fast-follow), `storage:` config block, SHA-256 integrity end-to-end — see [docs/STORAGE.md](docs/STORAGE.md). **Retention** (✅): chain-aware pruning (`siphon dumps prune`) with keep-last-N / max-age / GFS rules, per-profile `retention:` config; see [docs/RETENTION.md](docs/RETENTION.md). **Ops suite** (✅): an append-only **audit log** of destructive ops (`audit:` config), **2FA/group gating** (a profile group can require a typed confirmation and/or TOTP before destructive ops), opt-in aggregate **telemetry** (`telemetry:` config), **`siphon schedule`** (manages recurring backups in your crontab), and **`siphon tunnel`** (SSH local-forward to a DB via a bastion). **Multi-backend secrets** (✅): `keychain://` (OS credential store) and `awssm://` (AWS Secrets Manager) join `env:` as secret-ref schemes. See [docs/OPS.md](docs/OPS.md). | ✅ Complete | +| **H** — Distribution | GoReleaser (cross-platform binaries, checksums, cosign-keyless signatures, tag-triggered release workflow), Homebrew tap + Scoop bucket, a checksum-verifying `curl \| sh` install script, and a Next.js landing + docs site (in `web/`, deployed on Vercel, rendering the repo Markdown). Tooling is in the repo; publishing awaits the `v1.0.0` tag + owner provisioning (tap repos/tokens, Vercel) — see [`web/README.md`](web/README.md). | 🟡 In progress | ## Requirements -- **[Go](https://go.dev/dl/) 1.26 or newer** — to build from source (the only install method until Phase H). +- **[Go](https://go.dev/dl/) 1.26 or newer** — only needed to build from source; prebuilt binaries are available via the install script, Homebrew, and Scoop (see [Install](#install)). - **Database client tools** — siphon shells out to the native dump/restore tools; it does not embed a client. You only need the tools for the engines you actually use: - **PostgreSQL** profiles need `pg_dump`, `pg_restore`, `psql`. - **MySQL** profiles need `mysqldump`, `mysql`. @@ -77,16 +77,51 @@ A single binary that turns the painful, error-prone sprawl of `pg_dump` → `pg_ ## Install -> Pre-1.0: no Homebrew tap or prebuilt binaries yet. Build from source. +> The install script, Homebrew tap, and Scoop bucket below go live with the first `v1.0.0` release. Until then, [build from source](#from-source). + +**Linux / macOS** — the install script downloads the right release binary and verifies its SHA-256 before installing: + +```bash +curl -fsSL https://raw.githubusercontent.com/nixrajput/siphon/main/scripts/install.sh | sh +``` + +Override the target with `SIPHON_INSTALL_DIR=…` or pin a version with `SIPHON_VERSION=v1.0.0`. (Once the site is live, `https://siphon.dev/install.sh` redirects here.) + +**Homebrew:** + +```bash +brew install nixrajput/siphon/siphon +``` + +**Scoop (Windows):** + +```powershell +scoop bucket add siphon https://github.com/nixrajput/scoop-siphon +scoop install siphon +``` + +**Prebuilt binaries** for every OS/arch are attached to each [release](https://github.com/nixrajput/siphon/releases), with a `checksums.txt` and a cosign keyless signature. Verify provenance — pin the issuer **and** the signer identity, or a forged Fulcio cert would still pass: + +```bash +cosign verify-blob \ + --certificate checksums.txt.pem \ + --signature checksums.txt.sig \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + --certificate-identity "https://github.com/nixrajput/siphon/.github/workflows/release.yml@refs/tags/v1.0.0" \ + checksums.txt +``` + +Substitute the tag you downloaded for `v1.0.0`. Then check a binary's archive against the verified `checksums.txt`. + +**From source:** ```bash git clone https://github.com/nixrajput/siphon.git -cd siphon -make build +cd siphon && make build ./bin/siphon --version ``` -This produces `./bin/siphon`. Move it onto your `PATH` (e.g. `sudo install -m 0755 bin/siphon /usr/local/bin/siphon`) to call it as `siphon`. +This produces `./bin/siphon`; move it onto your `PATH` (e.g. `sudo install -m 0755 bin/siphon /usr/local/bin/siphon`). ## Quick start @@ -172,11 +207,11 @@ By default the dump catalog lives on local disk at `defaults.dump_dir`. To store ```yaml storage: - type: s3 # "local" (default) | "s3" + type: s3 # "local" (default) | "s3" bucket: my-siphon-dumps # required for s3 - prefix: prod # optional key prefix + prefix: prod # optional key prefix region: us-east-1 - endpoint: "" # optional: custom endpoint for MinIO / R2 + endpoint: "" # optional: custom endpoint for MinIO / R2 ``` Credentials are resolved from the standard AWS chain (env vars, `~/.aws`, instance role) — never stored in the config file. See [docs/STORAGE.md](docs/STORAGE.md) for details. @@ -187,7 +222,7 @@ A `retention:` block (default + optional per-profile override) drives `siphon du defaults: retention: keep_last: 7 - max_age: 720h # 30 days + max_age: 720h # 30 days gfs: { daily: 7, weekly: 4, monthly: 6 } ``` diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..4d523a3 --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,165 @@ +# Configuration reference + +siphon reads one YAML config file. Find or edit it with `siphon config path` / +`siphon config edit`. Location (XDG-compliant; override with `SIPHON_CONFIG_HOME`): + +- **Linux:** `$XDG_CONFIG_HOME/siphon/config.yaml` → `~/.config/siphon/config.yaml` +- **macOS:** `~/.config/siphon/config.yaml` +- **Windows:** `%APPDATA%\siphon\config.yaml` + +The file is safe to commit **as long as every secret is a reference**, not a +literal (see [Secret references](#secret-references)). + +## Table of contents + +- [Top-level shape](#top-level-shape) +- [defaults](#defaults) +- [profiles](#profiles) +- [Secret references](#secret-references) +- [storage](#storage) +- [retention](#retention) +- [audit](#audit) +- [telemetry](#telemetry) +- [secrets](#secrets) +- [groups](#groups) + +## Top-level shape + +```yaml +version: 1 +defaults: { … } # cross-profile defaults +storage: { … } # where dumps live (local | s3) +audit: { … } # destructive-op audit log +telemetry: { … } # opt-in aggregate metrics +secrets: { … } # optional secret backends +profiles: { … } # named connections +groups: { … } # profile groups (gating policy) +``` + +Every block except `profiles` is optional; omitted blocks use safe defaults. + +## defaults + +```yaml +defaults: + dump_dir: ~/.local/share/siphon/dumps # local catalog path (when storage is local) + jobs: 4 # parallel workers where supported + compression: 1 # dump compression level + retention: { … } # default retention policy (see below) +``` + +## profiles + +A named connection. The map key is the profile name. + +```yaml +profiles: + prod: + driver: postgres # postgres | mysql | mariadb + host: db.example.com + port: 5432 + user: app_user + password: env:PROD_DB_PASS # a secret reference (see below) + database: app_prod + sslmode: require + group: critical # optional; ties to a groups: entry + retention: { … } # optional per-profile override (replaces defaults) + tunnel: # optional SSH bastion + bastion: jump@bastion.example.com + local_port: 15432 # defaults to the DB port +``` + +## Secret references + +Any secret field (today: `password`, and a group's `totp_secret`) is resolved at +runtime by scheme: + +| Scheme | Example | Source | +| --- | --- | --- | +| `env` | `env:PROD_DB_PASS` | environment variable | +| `keychain` | `keychain://prod-db` · `keychain://svc/acct` | OS credential store | +| `awssm` | `awssm://prod/db#password` | AWS Secrets Manager (a `#key` selects a JSON field) | +| *(none)* | `hunter2` | literal value — **don't commit this** | + +`keychain://` is always available; `awssm://` must be enabled under +[`secrets`](#secrets). See [docs/OPS.md](OPS.md#secret-backends) for detail. + +## storage + +Where the dump catalog physically lives. Omitted = local at `defaults.dump_dir`. + +```yaml +storage: + type: s3 # "local" (default) | "s3" + bucket: my-siphon-dumps # required for s3 + prefix: prod # optional key prefix within the bucket + region: us-east-1 + endpoint: "" # optional: custom endpoint for MinIO / R2 +``` + +S3 credentials come from the standard AWS chain, never from config. Full detail: +[docs/STORAGE.md](STORAGE.md). + +## retention + +Drives `siphon dumps prune`. A profile's block **replaces** the defaults block +wholesale. An empty/omitted policy keeps everything. + +```yaml +defaults: + retention: + keep_last: 7 # keep the N newest chains + max_age: 720h # keep chains younger than this (Go duration) + gfs: { daily: 7, weekly: 4, monthly: 6 } +``` + +A chain is kept if it satisfies **any** active rule. Full detail: +[docs/RETENTION.md](RETENTION.md). + +## audit + +Append-only JSONL log of destructive operations. Off by default. + +```yaml +audit: + enabled: true + path: ~/.local/state/siphon/audit.log # optional; this is the default +``` + +## telemetry + +Opt-in aggregate per-op counts and error tallies (op name + outcome only — never +identifying data). Off by default. + +```yaml +telemetry: + enabled: true + path: ~/.local/state/siphon/telemetry.json # optional; this is the default +``` + +## secrets + +Enables optional secret backends. `keychain://` works with no config; AWS +Secrets Manager is gated here because constructing it loads AWS config. + +```yaml +secrets: + awssm: true # enable the awssm:// backend + awssm_region: us-east-1 # optional; defaults to the AWS chain's region +``` + +## groups + +A group applies a gating policy to its member profiles before destructive ops. + +```yaml +groups: + critical: + confirm_destructive: true # operator must retype the profile name + require_2fa: true # operator must enter a current TOTP code + totp_secret: env:SIPHON_PROD_TOTP # base32 RFC-6238 secret (a secret-ref) + color: red # TUI accent +``` + +`require_2fa` with no resolvable `totp_secret` fails closed. Full detail: +[docs/OPS.md](OPS.md#2fa--group-gating). diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md new file mode 100644 index 0000000..83ba295 --- /dev/null +++ b/docs/GETTING_STARTED.md @@ -0,0 +1,117 @@ +# Getting started + +This walks from install to your first backup, restore, and sync. It assumes you +have the native client tools for your engine on `PATH` (siphon shells out to +them — `pg_dump`/`pg_restore`, `mysqldump`/`mysql`, or `mariadb-dump`/`mariadb`). + +## Table of contents + +- [Install](#install) +- [Register a profile](#register-a-profile) +- [Back up](#back-up) +- [Verify](#verify) +- [Restore](#restore) +- [Sync](#sync) +- [Exit codes](#exit-codes) +- [Where to go next](#where-to-go-next) + +## Install + +```bash +curl -fsSL https://siphon.dev/install.sh | sh # Linux / macOS +brew install nixrajput/siphon/siphon # Homebrew +scoop install siphon # Scoop (Windows) +``` + +Confirm it's on your `PATH`: + +```bash +siphon --version +``` + +## Register a profile + +A profile is a named connection. Store the password as a **secret reference** +(`env:`, `keychain://`, `awssm://`) so the config file never holds plaintext. + +```bash +export PROD_DB_PASS='…' +siphon profile add prod \ + --driver postgres \ + --host db.example.com \ + --user app_user \ + --password 'env:PROD_DB_PASS' \ + --database app_prod \ + --sslmode require + +siphon profile list +``` + +Inspect the schema to confirm the connection works: + +```bash +siphon inspect prod # tables, row estimates, on-disk sizes +``` + +## Back up + +```bash +siphon backup prod # writes a checksummed dump to the catalog +siphon dumps list # newest first; note the dump id +``` + +Each dump is a single file prefixed with a metadata envelope and recorded with a +SHA-256 checksum in a sidecar. + +## Verify + +```bash +siphon verify # re-hashes the dump against its recorded checksum +``` + +A mismatch exits with the integrity code (see below), so CI can catch corruption +or tampering. + +## Restore + +```bash +siphon restore --profile staging --dump --clean +``` + +`--clean` drops and recreates objects before loading. For an incremental dump, +`restore` resolves and replays the whole base→incremental chain in order. + +## Sync + +Back up the source and restore into the target in one streamed pass — no +intermediate file on disk: + +```bash +siphon sync prod staging +``` + +A backup failure propagates to the restore side, so a truncated dump is never +committed as if it were clean. + +## Exit codes + +siphon uses a POSIX-friendly taxonomy so scripts and CI behave correctly: + +| Code | Meaning | +| --- | --- | +| `0` | success | +| `1` | user error (bad input, missing profile, failed confirmation) | +| `2` | system error (I/O, network, the underlying tool failed) | +| `3` | integrity failure (a checksum did not match) | +| `130` | cancelled (Ctrl-C) | + +So `siphon backup prod && upload-somewhere` only uploads on a clean backup. + +## Where to go next + +- [Configuration reference](CONFIGURATION.md) — the full config-file schema. +- [Incremental backup](INCREMENTAL.md), [cross-engine sync](CROSS_ENGINE.md), + and [CDC](CDC.md) — the advanced transfer modes. +- [Storage backends](STORAGE.md) and [retention](RETENTION.md) — where dumps + live and how they're pruned. +- [Operational features](OPS.md) — audit log, 2FA, telemetry, schedule, tunnel. diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..5bc6cf8 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,131 @@ +#!/bin/sh +# siphon installer — downloads a released binary, verifies its SHA-256 against +# the release checksums file, and installs it. +# +# curl -fsSL https://raw.githubusercontent.com/nixrajput/siphon/main/scripts/install.sh | sh +# +# Environment overrides: +# SIPHON_VERSION install a specific tag (e.g. v1.0.0); default: latest +# SIPHON_INSTALL_DIR install location; default: /usr/local/bin +# +# POSIX sh, no bashisms. Verifies integrity before extracting; refuses to +# install on a checksum mismatch. +set -eu + +REPO="nixrajput/siphon" +INSTALL_DIR="${SIPHON_INSTALL_DIR:-/usr/local/bin}" + +err() { printf 'install: %s\n' "$1" >&2; exit 1; } +need() { command -v "$1" >/dev/null 2>&1 || err "required command not found: $1"; } + +# --- prerequisites -------------------------------------------------------- +need uname +need tar +# A downloader: prefer curl, fall back to wget. +if command -v curl >/dev/null 2>&1; then + DL="curl -fsSL" + DL_O="curl -fsSL -o" +elif command -v wget >/dev/null 2>&1; then + DL="wget -qO-" + DL_O="wget -qO" +else + err "need curl or wget" +fi +# A SHA-256 tool: sha256sum (Linux) or shasum (macOS). +if command -v sha256sum >/dev/null 2>&1; then + SHA="sha256sum" +elif command -v shasum >/dev/null 2>&1; then + SHA="shasum -a 256" +else + err "need sha256sum or shasum" +fi + +# --- detect OS / arch (match GoReleaser's naming) ------------------------- +os=$(uname -s) +case "$os" in + Linux) OS="linux" ;; + Darwin) OS="darwin" ;; + *) err "unsupported OS: $os (use the Windows build or build from source)" ;; +esac + +arch=$(uname -m) +case "$arch" in + x86_64|amd64) ARCH="amd64" ;; + aarch64|arm64) ARCH="arm64" ;; + *) err "unsupported architecture: $arch" ;; +esac + +# --- resolve version ------------------------------------------------------ +VERSION="${SIPHON_VERSION:-}" +if [ -z "$VERSION" ]; then + # Resolve the latest release tag from the GitHub API. + VERSION=$($DL "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep '"tag_name":' | head -1 | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/') + [ -n "$VERSION" ] || err "could not determine the latest release tag" +fi +# GoReleaser archive names use the version WITHOUT the leading "v". +NUM_VERSION=$(printf '%s' "$VERSION" | sed 's/^v//') + +ARCHIVE="siphon_${NUM_VERSION}_${OS}_${ARCH}.tar.gz" +BASE="https://github.com/${REPO}/releases/download/${VERSION}" + +# --- download + verify in a temp dir -------------------------------------- +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +printf 'install: downloading %s (%s)\n' "$ARCHIVE" "$VERSION" >&2 +$DL_O "$TMP/$ARCHIVE" "$BASE/$ARCHIVE" || err "download failed: $BASE/$ARCHIVE" +$DL_O "$TMP/checksums.txt" "$BASE/checksums.txt" || err "could not download checksums.txt" + +# Authenticate checksums.txt itself when cosign is available. Without this, an +# attacker who swaps release assets could replace the archive AND its checksums +# together and go undetected. cosign verify-blob (keyless) confirms checksums.txt +# was signed by this repo's release workflow at THIS tag. +# +# When cosign IS present we fail closed: the release pipeline always produces the +# signature assets, so a missing/invalid signature means a tampered or malformed +# release, not a benign older one. Only when cosign is absent do we fall back to +# checksum-only integrity (requiring cosign would break the common install). +if command -v cosign >/dev/null 2>&1; then + $DL_O "$TMP/checksums.txt.sig" "$BASE/checksums.txt.sig" 2>/dev/null \ + || err "release is missing checksums.txt.sig — refusing to install (cosign is present, so a signature is expected)" + $DL_O "$TMP/checksums.txt.pem" "$BASE/checksums.txt.pem" 2>/dev/null \ + || err "release is missing checksums.txt.pem — refusing to install" + # Pin the exact signer identity: this repo's release.yml at the tag being + # installed, not just any workflow/any tag. + identity="https://github.com/${REPO}/.github/workflows/release.yml@refs/tags/${VERSION}" + cosign verify-blob \ + --certificate "$TMP/checksums.txt.pem" \ + --signature "$TMP/checksums.txt.sig" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + --certificate-identity "$identity" \ + "$TMP/checksums.txt" >/dev/null 2>&1 \ + || err "cosign signature verification failed for checksums.txt — refusing to install" +else + printf 'install: cosign not found; using checksum-only verification (install cosign to verify provenance)\n' >&2 +fi + +# Verify the archive's SHA-256 against the line for it in checksums.txt. +expected=$(grep " ${ARCHIVE}\$" "$TMP/checksums.txt" | awk '{print $1}') +[ -n "$expected" ] || err "no checksum entry for $ARCHIVE" +actual=$(cd "$TMP" && $SHA "$ARCHIVE" | awk '{print $1}') +if [ "$expected" != "$actual" ]; then + err "checksum mismatch for $ARCHIVE (expected $expected, got $actual) — refusing to install" +fi + +# --- extract + install ---------------------------------------------------- +tar -xzf "$TMP/$ARCHIVE" -C "$TMP" siphon || err "could not extract siphon from $ARCHIVE" + +if [ -w "$INSTALL_DIR" ] 2>/dev/null || mkdir -p "$INSTALL_DIR" 2>/dev/null; then + install -m 0755 "$TMP/siphon" "$INSTALL_DIR/siphon" 2>/dev/null \ + || { mv "$TMP/siphon" "$INSTALL_DIR/siphon" && chmod 0755 "$INSTALL_DIR/siphon"; } +else + # Need elevated permissions for a system dir like /usr/local/bin. + printf 'install: %s is not writable; retrying with sudo\n' "$INSTALL_DIR" >&2 + need sudo + sudo install -m 0755 "$TMP/siphon" "$INSTALL_DIR/siphon" \ + || err "could not install to $INSTALL_DIR" +fi + +printf 'install: siphon %s installed to %s/siphon\n' "$VERSION" "$INSTALL_DIR" >&2 +"$INSTALL_DIR/siphon" --version >&2 2>/dev/null || true diff --git a/web/.eslintrc.json b/web/.eslintrc.json new file mode 100644 index 0000000..284304e --- /dev/null +++ b/web/.eslintrc.json @@ -0,0 +1 @@ +{ "extends": "next/core-web-vitals" } diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..317e69c --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,6 @@ +/node_modules +/.next +/out +*.tsbuildinfo +# next-env.d.ts is committed (tsconfig.json includes it; a fresh checkout needs +# it before the first `next build` regenerates it). diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 0000000..b622e63 --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,5 @@ +node_modules +.next +out +next-env.d.ts +package-lock.json diff --git a/web/.prettierrc.json b/web/.prettierrc.json new file mode 100644 index 0000000..6e35829 --- /dev/null +++ b/web/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..ad9efc8 --- /dev/null +++ b/web/README.md @@ -0,0 +1,85 @@ +# siphon web — landing page & docs + +The marketing landing page and documentation site for siphon, built with +Next.js (App Router) + Tailwind and deployed on Vercel. The docs pages render +the repository's own `docs/*.md` at build time, so the site never drifts from +the shipped documentation. + +## Develop + +```bash +cd web +npm install +npm run dev # http://localhost:3000 +npm run build # production build (also what Vercel runs) + +npm run lint # ESLint +npm run lint:fix # ESLint, autofix +npm run format # Prettier, write +npm run format:check # Prettier, check only (used by the pre-push hook) +``` + +The docs pipeline (`lib/docs.ts`) reads `../docs/*.md` relative to `web/`, so +run commands from inside `web/` with the repo checked out around it. + +## Pre-push hook (lint + format gate) + +A committed git hook (`.githooks/pre-push`) runs `npm run lint` and +`npm run format:check` before any push that touches `web/`, and **aborts the +push if either fails**. Pushes that don't change `web/` skip the check (so +Go-only pushes stay fast). Enable it once per clone — from the repo root: + +```bash +make hooks # = git config core.hooksPath .githooks +``` + +Bypass in an emergency with `git push --no-verify`. The repo root also exposes +`make web-lint` and `make web-format` as shortcuts into this app. + +## Deploy (Vercel) — owner setup + +Vercel deploys from this subdirectory via its git integration (no GitHub Action +needed). One-time setup on the Vercel account: + +1. **Import the repo** into a new Vercel project. +2. Set **Root Directory** = `web`. +3. Framework preset: **Next.js** (auto-detected). Build command `next build`, + output handled by Vercel. +4. **Custom domain** — add `siphon.nixrajput.com` to the project and point a + CNAME at Vercel. The site's canonical URL, sitemap, robots, and Open Graph + tags are already set to this domain (`lib/site.ts` → `SITE_URL`). The install + command uses the raw GitHub URL, so no path rewrite is needed. + +Pushes to `main` then deploy production; PRs get preview deployments. + +## SEO / indexing + +- Canonical URL, Open Graph + Twitter cards, and JSON-LD (`SoftwareApplication` + - `Person`) are emitted from `app/layout.tsx`, keyed off `SITE_URL`. +- `app/sitemap.ts` and `app/robots.ts` generate `/sitemap.xml` + `/robots.txt` + at build time; the sitemap is derived from the same `docNav()` source the + pages render from, so it never drifts. +- `public/og.svg` is the share image. **Note:** several social crawlers don't + rasterize SVG OG images — swap in a 1200×630 PNG (or an `opengraph-image.tsx` + route) before launch if rich social unfurls matter. +- After deploy, submit the sitemap in Google Search Console for the domain. + +## Phase H release provisioning checklist (owner) + +The release tooling in the repo root (`.goreleaser.yaml`, +`.github/workflows/release.yml`, `scripts/install.sh`) is wired but needs these +account-level actions before a `v1.0.0` release publishes everywhere: + +- [ ] **Tag and push `v1.0.0`** — triggers the release workflow (cross-platform + binaries, checksums, cosign-keyless signatures, GitHub Release). This + alone works with no further setup; `skip_upload: auto` means the brew/scoop + steps no-op until their repos+tokens exist. +- [ ] **Homebrew tap** — create the public repo `nixrajput/homebrew-siphon`, mint + a PAT with `repo` scope on it, and add it as the `HOMEBREW_TAP_GITHUB_TOKEN` + secret on this repo. Then `brew install nixrajput/siphon/siphon` works. +- [ ] **Scoop bucket** — create `nixrajput/scoop-siphon`, add a PAT as + `SCOOP_TAP_GITHUB_TOKEN`. +- [ ] **Actions permissions** — ensure the repo's Actions can use + `contents: write` + `id-token: write` (already requested in the workflow; + org settings may need to allow it). +- [ ] **Vercel** — the steps above, to publish this site. diff --git a/web/app/docs/[slug]/page.tsx b/web/app/docs/[slug]/page.tsx new file mode 100644 index 0000000..69ec834 --- /dev/null +++ b/web/app/docs/[slug]/page.tsx @@ -0,0 +1,25 @@ +import { notFound } from "next/navigation"; +import { docNav, getDoc } from "@/lib/docs"; +import { Markdown } from "@/components/Markdown"; + +// Statically generate one page per doc at build time. +export function generateStaticParams() { + return docNav().map((d) => ({ slug: d.slug })); +} + +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const doc = getDoc(slug); + return { title: doc ? `${doc.title} — siphon` : "siphon docs" }; +} + +export default async function DocPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const doc = getDoc(slug); + if (!doc) notFound(); + return ( +
+ +
+ ); +} diff --git a/web/app/docs/layout.tsx b/web/app/docs/layout.tsx new file mode 100644 index 0000000..2e3b941 --- /dev/null +++ b/web/app/docs/layout.tsx @@ -0,0 +1,35 @@ +import { SiteHeader } from "@/components/SiteHeader"; +import { SiteFooter } from "@/components/SiteFooter"; +import { DocsNavLink } from "@/components/DocsNavLink"; +import { docNav } from "@/lib/docs"; + +// Shared docs shell: site header + a sticky sidebar nav over the concept docs. +export default function DocsLayout({ children }: { children: React.ReactNode }) { + const nav = docNav(); + return ( + <> + +
+ +
{children}
+
+ + + ); +} diff --git a/web/app/docs/page.tsx b/web/app/docs/page.tsx new file mode 100644 index 0000000..ed2c94d --- /dev/null +++ b/web/app/docs/page.tsx @@ -0,0 +1,86 @@ +import Link from "next/link"; +import { docNav, DOC_GROUPS } from "@/lib/docs"; +import { InstallCommand } from "@/components/InstallCommand"; +import { ExtLink } from "@/components/ExtLink"; +import { INSTALL_CMD, REPO_URL } from "@/lib/site"; + +export const metadata = { + title: "Documentation", + description: + "Concepts and guides for siphon — install, configure, and run backup, restore, sync, incremental, cross-engine, and CDC across PostgreSQL, MySQL, and MariaDB.", + alternates: { canonical: "/docs" }, +}; + +export default function DocsIndex() { + const nav = docNav(); + + return ( +
+

documentation

+

Concepts & guides

+

+ Everything you need to run siphon in anger — from your first backup to continuous + cross-engine replication. These pages render the same Markdown that ships in the repository, + so they track the latest source. +

+ + {/* Quick start: get someone to a working install without leaving the + Overview, then point them at the first guide. */} +
+

quick start

+

+ Install the binary, then head to{" "} + + Getting started + {" "} + for your first backup and sync. +

+ +
+ + {/* Grouped guide index: each card carries a real description, not just a + title, so the page is scannable and tells you where to go. */} + {DOC_GROUPS.map((group) => { + const items = nav.filter((d) => d.group === group); + return ( +
+

{group}

+ {/* Self-bordered cards with real gap: an odd item count leaves clean + empty space instead of a phantom filler cell (which a seam-grid + with a colored background would show). */} +
+ {items.map((d) => ( + + + {d.title} + + → + + +

{d.blurb}

+ + ))} +
+
+ ); + })} + +
+ + Found a gap or a bug?{" "} + + Open an issue + + . + + + Browse the source ↗ + +
+
+ ); +} diff --git a/web/app/globals.css b/web/app/globals.css new file mode 100644 index 0000000..b36630c --- /dev/null +++ b/web/app/globals.css @@ -0,0 +1,376 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* ── siphon design system ─────────────────────────────────────────────── + Thesis: "the pipe." siphon moves data through a conduit, so the visual + language is plumbing/flow — a deep slate base (the fluid world, not generic + near-black), a two-tone teal→cyan "liquid flow" accent used only on + connective elements, and amber reserved for the single install action. + Monospace is the structural voice (eyebrows, labels, data) because in this + product's world it is the native tongue. +*/ +:root { + --ink: #0e1726; + --ink-2: #16223a; + --ink-3: #1d2c49; + --flow: #38e0c8; + --flow-2: #4aa8ff; + --amber: #f6b73c; + --paper: #e8eef6; + --muted: #7e8ca3; + --line: #25324d; + + --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, + sans-serif; + --font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace; +} + +@layer base { + html { + scroll-behavior: smooth; + } + body { + background: var(--ink); + color: var(--paper); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + } + h1, + h2, + h3 { + font-weight: 700; + letter-spacing: -0.025em; + line-height: 1.05; + } + .mono { + font-family: var(--font-mono); + } + .eyebrow { + font-family: var(--font-mono); + font-size: 0.72rem; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--flow); + } + a { + color: var(--flow); + text-decoration: none; + } + a:hover { + text-decoration: underline; + text-underline-offset: 3px; + } + :focus-visible { + outline: 2px solid var(--flow); + outline-offset: 2px; + border-radius: 2px; + } +} + +/* Nav link: an underline that grows from the left on hover and sits full-width + when the route is active. The transform-origin flip means hover-out retracts + to the right, giving the link a directional "flow" consistent with the page. */ +.navlink { + position: relative; + color: var(--muted); + transition: color 0.2s ease; +} +.navlink::after { + content: ""; + position: absolute; + left: 0; + bottom: -6px; + height: 2px; + width: 100%; + background: linear-gradient(to right, var(--flow), var(--flow-2)); + transform: scaleX(0); + transform-origin: right; + transition: transform 0.28s cubic-bezier(0.2, 0.7, 0.2, 1); +} +.navlink:hover { + color: var(--paper); +} +.navlink:hover::after { + transform: scaleX(1); + transform-origin: left; +} +.navlink[data-active="true"] { + color: var(--paper); +} +.navlink[data-active="true"]::after { + transform: scaleX(1); + transform-origin: left; +} + +/* Live status dot next to the version badge: a soft teal pulse signalling + "current / shipping". Stilled under reduced-motion. */ +@keyframes pulse-dot { + 0%, + 100% { + opacity: 1; + box-shadow: 0 0 0 0 rgba(56, 224, 200, 0.5); + } + 50% { + opacity: 0.6; + box-shadow: 0 0 0 4px rgba(56, 224, 200, 0); + } +} +.pulse-dot { + animation: pulse-dot 2.4s ease-in-out infinite; +} + +/* The signature: a thin teal→cyan rule that runs down the page like data + moving through a siphon. The dash animates downward; reduced-motion stills it. */ +@keyframes flow-down { + to { + background-position: 0 -200px; + } +} +.flowline { + width: 2px; + background-image: linear-gradient( + to bottom, + transparent, + var(--flow) 18%, + var(--flow-2) 50%, + var(--flow) 82%, + transparent + ); + background-size: 2px 200px; + animation: flow-down 6s linear infinite; +} +/* Orchestrated entrance: hero pieces settle in on load, staggered via + --rise-delay. Scroll-revealed elements (.reveal) rise once when they enter + the viewport, reinforcing the page's top-to-bottom "flow" metaphor. */ +@keyframes rise { + from { + opacity: 0; + transform: translateY(14px); + } + to { + opacity: 1; + transform: none; + } +} +.rise { + animation: rise 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) both; + animation-delay: var(--rise-delay, 0s); +} +/* Scroll reveal. SAFETY: the .reveal class is applied ONLY by the Reveal + component's effect, and only once it has confirmed JS + IntersectionObserver + + no reduced-motion (see Reveal.tsx). So this hidden base state never reaches a + no-JS render — SSR emits no .reveal class, leaving content visible. The + observer then adds .is-in to animate it in. */ +.reveal { + opacity: 0; + transform: translateY(16px); + transition: + opacity 0.6s ease, + transform 0.6s cubic-bezier(0.2, 0.7, 0.2, 1); + transition-delay: var(--rise-delay, 0s); +} +.reveal.is-in { + opacity: 1; + transform: none; +} + +/* Hero backdrop: a faint conduit grid that drifts slowly, evoking fluid moving + through pipes behind the content. Pure CSS, GPU-cheap, sits at -z and never + intercepts clicks. */ +@keyframes grid-drift { + to { + background-position: + 0 -56px, + -56px 0; + } +} +.flowgrid { + background-image: linear-gradient(var(--line) 1px, transparent 1px), + linear-gradient(90deg, var(--line) 1px, transparent 1px); + background-size: + 56px 56px, + 56px 56px; + opacity: 0.25; + animation: grid-drift 18s linear infinite; + mask-image: radial-gradient(ellipse 80% 60% at 50% 0%, #000 30%, transparent 75%); + -webkit-mask-image: radial-gradient(ellipse 80% 60% at 50% 0%, #000 30%, transparent 75%); +} + +/* Terminal stream: each line fades up in sequence, so the transcript reads like + it's being produced live. Driven by inline --i (line index) for the stagger. */ +@keyframes line-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: none; + } +} +.term-line { + opacity: 0; + animation: line-in 0.4s ease forwards; + animation-delay: calc(var(--i, 0) * 0.32s + 0.4s); +} + +/* A soft caret that blinks at the end of the transcript. */ +@keyframes blink { + 50% { + opacity: 0; + } +} +.caret { + animation: blink 1.1s step-end infinite; +} + +/* Loading skeleton: a placeholder block with a teal-tinted shimmer sweeping + across it, signalling "data is on the way". The base block stays visible when + motion is reduced (below) — only the sweep stops. */ +@keyframes shimmer { + 100% { + background-position: -200% 0; + } +} +.skeleton { + background-color: var(--ink-3); + background-image: linear-gradient( + 90deg, + transparent 0%, + rgba(56, 224, 200, 0.12) 50%, + transparent 100% + ); + background-size: 200% 100%; + background-repeat: no-repeat; + border-radius: 4px; + animation: shimmer 1.4s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .flowline, + .flowgrid, + .pulse-dot, + .caret, + .skeleton { + animation: none; + } + .term-line { + animation: none; + opacity: 1; + transform: none; + } + .navlink::after { + transition: none; + } + html { + scroll-behavior: auto; + } + /* No motion: everything is simply present, never hidden. */ + .rise, + .reveal { + animation: none; + transition: none; + opacity: 1; + transform: none; + } +} + +/* Docs prose: render the repo Markdown legibly on the dark base. */ +.prose { + max-width: 46rem; + line-height: 1.7; + color: var(--paper); +} +.prose h1 { + font-size: 2rem; + margin: 0 0 1rem; +} +.prose h2 { + font-size: 1.4rem; + margin: 2.4rem 0 0.8rem; +} +.prose h3 { + font-size: 1.1rem; + margin: 1.8rem 0 0.6rem; + color: var(--flow); +} +.prose p, +.prose li { + color: #c4d0e0; +} +.prose a { + color: var(--flow-2); +} +.prose code { + font-family: var(--font-mono); + font-size: 0.86em; + background: var(--ink-2); + border: 1px solid var(--line); + border-radius: 4px; + padding: 0.1em 0.35em; +} +.prose pre { + background: #0a111e; + border: 1px solid var(--line); + border-radius: 8px; + padding: 1rem 1.1rem; + overflow-x: auto; + margin: 1.2rem 0; +} +.prose pre code { + background: none; + border: none; + padding: 0; + font-size: 0.84rem; + line-height: 1.6; +} +.prose table { + width: 100%; + border-collapse: collapse; + margin: 1.2rem 0; + font-size: 0.9rem; + display: block; + overflow-x: auto; +} +.prose th, +.prose td { + border: 1px solid var(--line); + padding: 0.5rem 0.7rem; + text-align: left; +} +.prose th { + background: var(--ink-2); + font-family: var(--font-mono); + font-size: 0.78rem; + letter-spacing: 0.04em; +} +.prose blockquote { + border-left: 2px solid var(--amber); + margin: 1.2rem 0; + padding: 0.2rem 0 0.2rem 1rem; + color: var(--muted); +} +.prose hr { + border: none; + border-top: 1px solid var(--line); + margin: 2rem 0; +} +.hljs-keyword, +.hljs-selector-tag, +.hljs-built_in { + color: var(--flow); +} +.hljs-string, +.hljs-attr { + color: var(--amber); +} +.hljs-comment { + color: var(--muted); + font-style: italic; +} +.hljs-number, +.hljs-literal { + color: var(--flow-2); +} diff --git a/web/app/icon.svg b/web/app/icon.svg new file mode 100644 index 0000000..0e3de5a --- /dev/null +++ b/web/app/icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/web/app/layout.tsx b/web/app/layout.tsx new file mode 100644 index 0000000..81e0f55 --- /dev/null +++ b/web/app/layout.tsx @@ -0,0 +1,106 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import { + SITE_URL, + SITE_NAME, + SITE_TAGLINE, + SITE_DESCRIPTION, + REPO_URL, + DEVELOPER, +} from "@/lib/site"; + +// metadataBase makes every relative OG/canonical URL resolve against the custom +// domain, so social cards and search engines see siphon.nixrajput.com — not a +// Vercel preview host. Title uses a template so child pages read "X — siphon". +export const metadata: Metadata = { + metadataBase: new URL(SITE_URL), + title: { + default: `${SITE_NAME} — sync any database, anywhere`, + template: `%s — ${SITE_NAME}`, + }, + description: SITE_DESCRIPTION, + applicationName: SITE_NAME, + keywords: [ + "database backup", + "database sync", + "postgresql backup", + "mysql backup", + "mariadb backup", + "pg_dump alternative", + "change data capture", + "cdc", + "cross-engine migration", + "incremental backup", + "cli", + "golang", + "devops", + ], + authors: [{ name: DEVELOPER.name, url: DEVELOPER.portfolio }], + creator: DEVELOPER.name, + alternates: { canonical: "/" }, + openGraph: { + type: "website", + siteName: SITE_NAME, + url: SITE_URL, + title: `${SITE_NAME} — ${SITE_TAGLINE}`, + description: SITE_DESCRIPTION, + images: [{ url: "/og.svg", width: 1200, height: 630, alt: `${SITE_NAME} — ${SITE_TAGLINE}` }], + }, + twitter: { + card: "summary_large_image", + title: `${SITE_NAME} — ${SITE_TAGLINE}`, + description: SITE_DESCRIPTION, + images: ["/og.svg"], + }, + robots: { + index: true, + follow: true, + googleBot: { index: true, follow: true, "max-image-preview": "large" }, + }, +}; + +// JSON-LD: tells Google this page documents a downloadable developer tool. The +// SoftwareApplication + Person graph is what powers a rich result and ties the +// project to its author (nixrajput) for entity/"geo" understanding. +const JSON_LD = { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "SoftwareApplication", + name: SITE_NAME, + url: SITE_URL, + description: SITE_DESCRIPTION, + applicationCategory: "DeveloperApplication", + operatingSystem: "Linux, macOS, Windows", + offers: { "@type": "Offer", price: "0", priceCurrency: "USD" }, + license: "https://opensource.org/licenses/MIT", + codeRepository: REPO_URL, + author: { + "@type": "Person", + name: DEVELOPER.name, + alternateName: DEVELOPER.handle, + url: DEVELOPER.portfolio, + }, + }, + { + "@type": "WebSite", + name: SITE_NAME, + url: SITE_URL, + }, + ], +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} +