diff --git a/.gitignore b/.gitignore index 4fd37b7..3b1494c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ # Test *.test coverage.out + +# Local release-notes drafts (fed to `gh release create --notes-file`) +/release-notes.md diff --git a/.markdownlint.jsonc b/.markdownlint.jsonc new file mode 100644 index 0000000..1215ab9 --- /dev/null +++ b/.markdownlint.jsonc @@ -0,0 +1,19 @@ +{ + "default": true, + "MD013": { + "line_length": 85, + "heading_line_length": 85, + "code_blocks": false, + "tables": false, + "stern": true + }, + "MD033": false, + "MD041": false, + "MD060": false, + "ignores": [ + ".github/**", + "vendor/**", + "node_modules/**", + "release-notes.md" + ] +} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 97de372..758ea1b 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -41,5 +41,5 @@ an individual is officially representing the project in public spaces. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), -version 2.0. +This Code of Conduct is adapted from the +[Contributor Covenant](https://www.contributor-covenant.org/), version 2.0. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cc90ceb..3ef9a2a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,10 +1,12 @@ # Contributing to dibs -Thanks for your interest in contributing to dibs! This document provides guidelines and information for contributors. +Thanks for your interest in contributing to dibs! This document provides +guidelines and information for contributors. ## Code of Conduct -By participating in this project, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md). +By participating in this project, you agree to abide by our +[Code of Conduct](CODE_OF_CONDUCT.md). ## How to Contribute @@ -12,7 +14,8 @@ By participating in this project, you agree to abide by our [Code of Conduct](CO Before submitting a bug report: -1. Check the [existing issues](https://github.com/sitapix/dibs/issues) to avoid duplicates +1. Check the [existing issues](https://github.com/sitapix/dibs/issues) to avoid + duplicates 2. Make sure you're using the latest version When submitting a bug report, include: @@ -60,12 +63,15 @@ Requires Go 1.26+. ### Git hooks (opt-in) -Hooks live in `.githooks/` and are only active after you run `make setup`, which points `core.hooksPath` at that directory. The hooks are advisory: +Hooks live in `.githooks/` and are only active after you run `make setup`, +which points `core.hooksPath` at that directory. The hooks are advisory: - **pre-commit** runs `gofmt`, `go vet`, and (if installed) `golangci-lint` - **pre-push** runs `go build`, `go mod verify`, and `go test -race -short ./...` -If you already use another hook manager (lefthook, pre-commit.com, husky, a global hooks directory), `make setup` will print a warning and show you how to restore your previous `core.hooksPath` before overriding it. +If you already use another hook manager (lefthook, pre-commit.com, husky, a +global hooks directory), `make setup` will print a warning and show you how to +restore your previous `core.hooksPath` before overriding it. Bypass the hooks for a WIP commit or an emergency fix: @@ -74,7 +80,9 @@ git commit --no-verify git push --no-verify ``` -Use sparingly — the pre-release gate (`make release-check`) re-runs everything the hooks check plus more, so anything the hooks would have caught will still block a tag. +Use sparingly. The pre-release gate (`make release-check`) re-runs everything +the hooks check plus more, so anything the hooks would have caught will still +block a tag. ## Coding Standards @@ -82,7 +90,9 @@ Use sparingly — the pre-release gate (`make release-check`) re-runs everything - Keep functions focused and small - Handle errors explicitly; don't ignore them - Add tests for new functionality -- Minimize external dependencies. dibs uses only the Go standard library plus `golang.org/x/net` (for the Public Suffix List); justify any new dependency in the PR description +- Minimize external dependencies. dibs uses only the Go standard library plus + `golang.org/x/net` (for the Public Suffix List); justify any new dependency + in the PR description ### Commit Messages @@ -92,7 +102,8 @@ Use sparingly — the pre-release gate (`make release-check`) re-runs everything - Reference issues when applicable: "Fix DNS timeout (#123)" Example: -``` + +```text Add CSV output format option - Implement --csv flag for spreadsheet-compatible output @@ -126,14 +137,34 @@ Before tagging a release, run the full pre-flight gate: # Requires: go install golang.org/x/tools/cmd/deadcode@latest # go install golang.org/x/vuln/cmd/govulncheck@latest # golangci-lint (https://golangci-lint.run/usage/install/) +# brew install markdownlint-cli2 make release-check ``` -`release-check` is a strict superset of the pre-commit and pre-push hooks: `gofmt`, `go vet`, `golangci-lint`, full race test suite (not `-short`), deadcode detection, govulncheck, a `go mod tidy -diff` drift check (non-mutating), and a reproducibility build that verifies the release ldflags still wire `main.version` correctly. Requires network access. Don't push a tag until it passes. +`release-check` is a strict superset of the pre-commit and pre-push hooks: +`gofmt`, `go vet`, `golangci-lint`, full race test suite (not `-short`), +deadcode detection, govulncheck, a `go mod tidy -diff` drift check +(non-mutating), and a reproducibility build that verifies the release ldflags +still wire `main.version` correctly. Requires network access. Don't push a tag +until it passes. -## Project Structure +## Regenerating the demo GIF +The README demo (`demo/demo.gif`) is scripted with +[vhs](https://github.com/charmbracelet/vhs). To update it: + +```bash +brew install vhs # one-time +make build # produce ./dibs +vhs demo/demo.tape # writes demo/demo.gif ``` + +Edit `demo/demo.tape` to change what's shown, then re-render and commit both +the tape and the resulting GIF. + +## Project Structure + +```text dibs/ ├── main.go # CLI entry point and orchestration ├── fetch.go # TLD list and RDAP bootstrap fetching/caching @@ -143,6 +174,7 @@ dibs/ ├── output/ # Terminal, JSON, CSV renderers ├── rdap/ # RDAP verification client ├── tlds/ # TLD list parsing, caching, filtering +├── demo/ # vhs tape and rendered GIF for the README ├── Formula/ # Homebrew formula ├── .github/workflows/ # CI and release automation ├── install.sh # Binary installer script @@ -158,7 +190,8 @@ Releases are managed by maintainers. The process: 1. Create a git tag: `git tag -a v1.x.x -m "Release v1.x.x"` 2. Push tag: `git push origin v1.x.x` -4. CI automatically builds binaries, creates a GitHub release, and updates the Homebrew formula +3. CI automatically builds binaries, creates a GitHub release, and updates the + Homebrew formula ## Questions? diff --git a/Makefile b/Makefile index 5143f4b..3942b66 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test clean lint setup install fmt coverage release-check +.PHONY: build test clean lint docs-lint setup install fmt coverage release-check VERSION ?= dev @@ -15,6 +15,10 @@ lint: go vet ./... golangci-lint run ./... +docs-lint: + @command -v markdownlint-cli2 >/dev/null || { echo "install: brew install markdownlint-cli2"; exit 1; } + markdownlint-cli2 "**/*.md" "!.github/**" "!release-notes.md" "!vendor/**" + setup: @prev=$$(git config --get core.hooksPath || true); \ if [ -n "$$prev" ] && [ "$$prev" != ".githooks" ]; then \ @@ -47,6 +51,9 @@ release-check: @echo "→ golangci-lint" @command -v golangci-lint >/dev/null || { echo "install: https://golangci-lint.run/usage/install/"; exit 1; } @golangci-lint run ./... + @echo "→ markdownlint" + @command -v markdownlint-cli2 >/dev/null || { echo "install: brew install markdownlint-cli2"; exit 1; } + @markdownlint-cli2 "**/*.md" "!.github/**" "!release-notes.md" "!vendor/**" @echo "→ go test -race (full)" @go test -race -count=1 ./... @echo "→ deadcode" diff --git a/README.md b/README.md index 4d45d91..9bdb6a4 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,19 @@ # dibs [![CI](https://github.com/sitapix/dibs/actions/workflows/ci.yml/badge.svg)](https://github.com/sitapix/dibs/actions/workflows/ci.yml) +[![Latest release](https://img.shields.io/github/v/release/sitapix/dibs?display_name=tag&sort=semver)](https://github.com/sitapix/dibs/releases/latest) +[![Go Reference](https://pkg.go.dev/badge/github.com/sitapix/dibs.svg)](https://pkg.go.dev/github.com/sitapix/dibs) +[![Go Report Card](https://goreportcard.com/badge/github.com/sitapix/dibs)](https://goreportcard.com/report/github.com/sitapix/dibs) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) -**Check domain availability across every TLD, right from your terminal.** +**Check domain availability across every ICANN TLD, right from your terminal.** -dibs checks if a domain is available across all 1400+ ICANN TLDs, or against a single domain like `vi.be` or `foo.co.uk`. It queries DNS over HTTPS instead of scraping WHOIS, and `--verify` cross-checks results against registry data via RDAP. +![dibs demo](demo/demo.gif) -``` -$ dibs --verify columns - - ✓ columns.dev available - ✗ columns.com taken - ✓ columns.space available - ✓ columns.tech available - ✗ columns.org taken - ━━━━━━━━━━━━━━━━ 25/25 domains 100% - -Verifying 8 available domains via RDAP... -Found 8 available domains out of 25 checked (32%) -Verified 6 of 8 via RDAP (2 domains without RDAP coverage) -``` - -## Features - -- **One domain or many.** `dibs mybrand` sweeps the top 25 TLDs; `dibs vi.be` or `dibs foo.co.uk` checks just that domain. Multi-label TLDs like `.co.uk` parse correctly via the Public Suffix List. -- **DNS over HTTPS by default.** Queries go over HTTPS using [RFC 8484](https://datatracker.ietf.org/doc/html/rfc8484) wire format. You can fall back to system DNS with `--no-doh` if you prefer. -- **Privacy on the wire.** DoH queries are padded to 128-byte blocks ([RFC 7830](https://datatracker.ietf.org/doc/rfc7830)/[RFC 8467](https://datatracker.ietf.org/doc/rfc8467)) so passive observers can't fingerprint name length by ciphertext size, and the `User-Agent` header is suppressed so resolvers don't see which runtime is asking. -- **RDAP verification.** Use `--verify` to check results against the actual registry data. Catches domains that are registered but have no DNS set up. -- **Fast.** Runs 100 parallel lookups by default, and you can crank it up to 500. -- **Smart defaults.** Checks the top 25 popular TLDs out of the box (com, org, net, io, dev, app, ai, etc.) -- **All TLDs.** Or just search all 1400+ ICANN TLDs with `--all`. -- **Multiple output formats.** Terminal with colors and a progress bar, JSON, or CSV. -- **Batch processing.** Throw a list of domain names in a file and check them all at once. -- **Filtering.** Filter by TLD length, sort alphabetically or by length, limit how many you check. -- **Configurable.** Drop a config file at `~/.config/dibs/config` and your defaults are set. -- **Single binary.** Just the Go standard library plus `golang.org/x/net` for correct multi-label TLD parsing. No third-party runtime deps. +dibs checks if a domain is available across all 1400+ ICANN TLDs, or against a +single domain like `bas.il` or `foo.co.uk`. It queries DNS over HTTPS instead +of scraping WHOIS, and `--verify` cross-checks results against registry data +via RDAP. ## Installation @@ -81,7 +59,8 @@ go install github.com/sitapix/dibs@latest ### Download binary -Grab the latest binary for your platform from the [releases page](https://github.com/sitapix/dibs/releases), then: +Grab the latest binary for your platform from the +[releases page](https://github.com/sitapix/dibs/releases), then: ```bash chmod +x dibs-* @@ -92,72 +71,82 @@ sudo mv dibs-* /usr/local/bin/dibs ```bash # check top 25 popular TLDs -dibs mybrand +dibs orbit # check ALL 1400+ TLDs -dibs --all mybrand +dibs --all orbit # check one specific domain (single-domain mode: any argument with a dot) -dibs vi.be +dibs bas.il dibs foo.co.uk # verify a specific domain against registry data -dibs vi.be --verify +dibs bas.il --verify # verify available domains from a sweep -dibs --verify mybrand +dibs --verify orbit # both -dibs --all --verify mybrand +dibs --all --verify orbit # only show available domains -dibs --quiet mybrand +dibs --quiet orbit # interactive mode (prompts for domain name) dibs ``` -Any argument with a dot triggers single-domain mode, which bypasses the TLD -sweep. The [Public Suffix List](https://publicsuffix.org/) handles multi-label -TLDs like `.co.uk`, `.com.br`, and `.ac.uk`. dibs rejects subdomains -(`mail.google.com` → use `google.com`), fake TLDs, and PSL private suffixes -like `.github.io`. Single-domain mode conflicts with `--all`, `--tlds`, -`--file`, `--limit`, `--sort`, and `--min/max-length`. - ### Output formats ```bash # JSON (for scripting) -dibs --json mybrand +dibs --json orbit # CSV (for spreadsheets) -dibs --csv mybrand +dibs --csv orbit # JSON with verification data -dibs --json --verify mybrand +dibs --json --verify orbit # disable colors (also respects NO_COLOR env var) -dibs --no-color mybrand +dibs --no-color orbit +``` + +JSON output shape: + +```json +{ + "query": "orbit", + "checked": 25, + "partial": false, + "available": [ + { "domain": "orbit.dev", "tld": "dev" } + ], + "taken": [ + { "domain": "orbit.com", "tld": "com" } + ], + "errors": [] +} ``` ### Filtering ```bash # only short TLDs (2-3 characters) -dibs --max-length 3 mybrand +dibs --max-length 3 orbit # only TLDs with 4+ characters -dibs --min-length 4 mybrand +dibs --min-length 4 orbit # specific TLDs only -dibs --tlds com,io,dev,app mybrand +dibs --tlds com,io,dev,app orbit # sort results -dibs --sort alpha mybrand -dibs --sort length mybrand +dibs --sort alpha orbit +dibs --sort length orbit # limit how many TLDs to check -dibs --limit 50 --all mybrand +dibs --limit 50 --all orbit ``` ### Batch mode @@ -167,95 +156,154 @@ dibs --file domains.txt ``` `domains.txt` format (one per line, `#` comments supported): -``` -mybrand -myproject -mycompany + +```text +orbit +atlas +harbor ``` ### Performance ```bash # increase parallel connections (default: 100, max: 500) -dibs --parallel 200 --all mybrand +dibs --parallel 200 --all orbit # adjust timeout per query (default: 5s) -dibs --timeout 3 mybrand +dibs --timeout 3 orbit # retry failed DNS queries (default: 1) -dibs --retries 3 --all mybrand +dibs --retries 3 --all orbit # force refresh cached TLD list -dibs --refresh --all mybrand +dibs --refresh --all orbit ``` ### DNS providers ```bash # use Mullvad DoH instead of Quad9 (default) -dibs --provider mullvad mybrand +dibs --provider mullvad orbit # use another built-in non-registrar resolver -dibs --provider nextdns mybrand -dibs --provider adguard mybrand +dibs --provider nextdns orbit +dibs --provider adguard orbit # rotate between all providers -dibs --rotate mybrand +dibs --rotate orbit # use your own DoH server (for example Cloudflare or Google) -dibs --doh-url https://dns.example.com/dns-query mybrand +dibs --doh-url https://dns.example.com/dns-query orbit # use system DNS instead of DoH (faster, plaintext) -dibs --no-doh mybrand +dibs --no-doh orbit ``` +## Features + +- **Single binary, no runtime deps.** Go standard library plus + `golang.org/x/net` for Public Suffix List parsing. Nothing else. +- **One domain or many.** `dibs orbit` sweeps the top 25 TLDs; `dibs bas.il` + or `dibs foo.co.uk` checks just that domain. Multi-label TLDs like `.co.uk` + parse correctly via the Public Suffix List. +- **DNS over HTTPS, padded.** Queries use + [RFC 8484](https://datatracker.ietf.org/doc/html/rfc8484) wire format over + HTTPS, padded to 128-byte blocks + ([RFC 7830](https://datatracker.ietf.org/doc/rfc7830) / + [RFC 8467](https://datatracker.ietf.org/doc/rfc8467)) + so passive observers can't fingerprint query length by ciphertext size. + `User-Agent` is suppressed. Fall back to system DNS with `--no-doh` if you + prefer. +- **RDAP verification.** `--verify` cross-checks available domains against the + actual registry + ([RFC 7480](https://datatracker.ietf.org/doc/html/rfc7480)). Catches domains + that are registered but have no DNS set up. +- **Fast.** 100 parallel DNS queries by default, up to 500. Smart defaults + check the top 25 TLDs; `--all` sweeps all 1400+ ICANN TLDs. +- **Flexible output.** Terminal with colors and progress bar, JSON, or CSV. + Filter by TLD length, sort, limit, or pick specific TLDs. +- **Batch and interactive.** Read domains from a file, or run with no args for + an interactive prompt. Drop a config at `~/.config/dibs/config` to set your + defaults. + ## How it works ### DNS scan -1. Grabs the official TLD list from [IANA](https://data.iana.org/TLD/tlds-alpha-by-domain.txt) +1. Grabs the official TLD list from + [IANA](https://data.iana.org/TLD/tlds-alpha-by-domain.txt) 2. Caches it locally for 24 hours (`~/.cache/dibs/tlds.txt`) -3. Fires off parallel DNS A-record lookups via DoH ([RFC 8484](https://datatracker.ietf.org/doc/html/rfc8484) wire format) or system DNS +3. Fires off parallel DNS A-record lookups via DoH + ([RFC 8484](https://datatracker.ietf.org/doc/html/rfc8484) wire format) or + system DNS 4. NXDOMAIN means available, NOERROR means taken -DNS is fast but it's really just a first pass. A domain can be registered without having any DNS set up, so it would look available when it's actually not. +DNS is fast but it's really just a first pass. A domain can be registered +without having any DNS set up, so it would look available when it's actually +not. ### Single-domain mode -When the argument contains a dot (e.g. `vi.be`, `foo.co.uk`), dibs skips the TLD sweep. The [Public Suffix List](https://publicsuffix.org/) (baked into the binary via `golang.org/x/net/publicsuffix`) splits the input correctly, so multi-label TLDs like `.co.uk` and `.com.br` parse as one TLD instead of splitting on the last dot. dibs rejects non-registrable inputs up front: subdomains (`mail.google.com` → use `google.com`), fake TLDs like `.tld`, and PSL private suffixes like `.github.io`. The DNS and RDAP paths are identical to sweep mode. +When the argument contains a dot (e.g. `bas.il`, `foo.co.uk`), dibs skips the +TLD sweep. The [Public Suffix List](https://publicsuffix.org/) (baked into the +binary via `golang.org/x/net/publicsuffix`) splits the input correctly, so +multi-label TLDs like `.co.uk` and `.com.br` parse as one TLD instead of +splitting on the last dot. dibs rejects non-registrable inputs up front: +subdomains (`mail.google.com` → use `google.com`), fake TLDs like `.tld`, and +PSL private suffixes like `.github.io`. The DNS and RDAP paths are identical +to sweep mode. Single-domain mode conflicts with `--all`, `--tlds`, `--file`, +`--limit`, `--sort`, and `--min/max-length`. ### RDAP verification (`--verify`) -With `--verify`, dibs goes back and double-checks the domains that DNS said were available: +With `--verify`, dibs goes back and double-checks the domains that DNS said +were available: + +1. Grabs the [IANA RDAP bootstrap](https://data.iana.org/rdap/dns.json) (also + cached for 24 hours) +2. For each "available" domain, asks the TLD's registry directly over HTTPS + ([RFC 7480](https://datatracker.ietf.org/doc/html/rfc7480)) +3. If the registry says it exists (HTTP 200), it gets corrected to taken. If + the registry says it doesn't (HTTP 404), it's confirmed available. -1. Grabs the [IANA RDAP bootstrap](https://data.iana.org/rdap/dns.json) (also cached for 24 hours) -2. For each "available" domain, asks the TLD's registry directly over HTTPS ([RFC 7480](https://datatracker.ietf.org/doc/html/rfc7480)) -3. If the registry says it exists (HTTP 200), it gets corrected to taken. If the registry says it doesn't (HTTP 404), it's confirmed available. +dibs talks to registries directly using the IANA bootstrap files, not through +a redirect service. All gTLDs support RDAP, but some ccTLDs don't, so those +results stay unverified. See [rdap.org](https://about.rdap.org) if you want to +learn more about the protocol. -dibs talks to registries directly using the IANA bootstrap files, not through a redirect service. All gTLDs support RDAP, but some ccTLDs don't, so those results stay unverified. See [rdap.org](https://about.rdap.org) if you want to learn more about the protocol. +## Caveats -### Can a domain pass both checks and still be taken? +**A domain can pass both DNS and RDAP checks and still not be available to +buy.** Registries can hold names back for premium pricing, trademark +protection (TMCH), or other policy reasons without ever creating DNS records +or RDAP entries for them. When you go to actually buy one of these, the +registrar will either reject it or hit you with a higher price. -Yes. Sometimes a domain looks available in both DNS and RDAP but you still can't register it. Registries can hold names back for premium pricing, trademark protection (TMCH), or other policy reasons without ever creating DNS records or RDAP entries for them. When you go to actually buy one of these, the registrar will either reject it or hit you with a higher price. dibs tells you what's probably available, but the registrar always has the final say. +Some ccTLDs also restrict second-level registrations to local residents or +registered entities (e.g. `.il`, `.ve`), even if the name shows as available +in both DNS and RDAP. dibs tells you what's probably available; the registrar +always has the final say. ## DoH providers -| Provider | Default | Notes | -|----------|---------|-------| -| **Quad9** | Yes | Non-profit. [quad9.net](https://quad9.net) | -| **Mullvad** | | [mullvad.net](https://mullvad.net/en/help/dns-over-https-and-dns-over-tls) | -| **NextDNS** | | [nextdns.io](https://nextdns.io) | -| **AdGuard** | | Unfiltered endpoint. [adguard-dns.io](https://adguard-dns.io) | +| Provider | Default | Notes | +| ----------- | ------- | ------------------------------------------------------------------------- | +| **Quad9** | Yes | Non-profit. [quad9.net](https://quad9.net) | +| **Mullvad** | | [mullvad.net](https://mullvad.net/en/help/dns-over-https-and-dns-over-tls) | +| **NextDNS** | | [nextdns.io](https://nextdns.io) | +| **AdGuard** | | Unfiltered endpoint. [adguard-dns.io](https://adguard-dns.io) | -Built-in providers are limited to the non-registrar set above. Use `--doh-url` if you want a different resolver. +Built-in providers are limited to the non-registrar set above. Use `--doh-url` +if you want a different resolver. -Use `--rotate` to spread queries across all four providers, or `--no-doh` to skip DoH entirely and use system DNS (faster, but plaintext). +Use `--rotate` to spread queries across all four providers, or `--no-doh` to +skip DoH entirely and use system DNS (faster, but plaintext). ## Configuration You can create a config file at `~/.config/dibs/config` to set your defaults: -``` +```ini # max concurrent DNS queries (default: 100, max: 500) parallel=100 @@ -274,7 +322,8 @@ CLI flags override config file values. ## Contributing -Contributions are welcome! Check out [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. +Contributions are welcome! Check out [CONTRIBUTING.md](CONTRIBUTING.md) for +guidelines. ## License diff --git a/config/config_test.go b/config/config_test.go index 0123624..b15f9ae 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -255,7 +255,7 @@ func TestValidate_InvalidProviderErrors(t *testing.T) { } func TestParseFile_UnknownKeyErrors(t *testing.T) { - path := writeTempConfig(t, "paralell=50\n") + path := writeTempConfig(t, "bogus=50\n") _, err := config.ParseFile(path) if err == nil { t.Error("expected error for unknown key, got nil") diff --git a/demo/demo.gif b/demo/demo.gif new file mode 100644 index 0000000..b90ae10 Binary files /dev/null and b/demo/demo.gif differ diff --git a/demo/demo.tape b/demo/demo.tape new file mode 100644 index 0000000..a260de2 --- /dev/null +++ b/demo/demo.tape @@ -0,0 +1,62 @@ +# Render from the repo root: +# make build && vhs demo/demo.tape +Output demo/demo.gif + +Require dibs + +Set Shell "zsh" +# Code Saver is a paid font (https://www.dbanks.design/code-saver). +# Swap for any installed monospace (e.g. "JetBrains Mono", "Fira Code") +# if you're re-rendering and don't have it. +Set FontFamily "Code Saver" +Set FontSize 22 +Set LineHeight 1.2 +Set Width 1200 +Set Height 750 +Set Padding 28 +Set Margin 20 +Set MarginFill "#1a1b26" +Set BorderRadius 12 +Set Theme "GitHub Dark" +Set WindowBar Colorful +Set CursorBlink true + +Set Framerate 50 +Set TypingSpeed 60ms +Set WaitTimeout 30s +Set LoopOffset 8% + +# Use the repo-local ./dibs so the demo matches the current source. +Hide +Type "export PATH=$PWD:$PATH" Enter +Type "clear" Enter +Sleep 400ms +Show + +Type "dibs orbit" +Sleep 400ms +Enter +Wait +Sleep 3000ms + +Hide +Type "clear" Enter +Sleep 300ms +Show + +Type "dibs --verify lumen" +Sleep 400ms +Enter +Wait +Sleep 3500ms + +Hide +Type "clear" Enter +Sleep 300ms +Show + +Type "dibs bas.il" +Sleep 400ms +Enter +Wait +Sleep 3000ms diff --git a/main.go b/main.go index 59b7314..11ac9a9 100644 --- a/main.go +++ b/main.go @@ -171,6 +171,7 @@ func run(args []string, stdout, stderr io.Writer, stdin io.Reader) int { } if len(available) > 0 { + renderer.BeginVerification(len(available)) corrections, stats := runRDAPVerify(ctx, available, cfg, stderr) renderer.ApplyVerification(corrections, stats) } @@ -331,6 +332,9 @@ func collectDomains(cfg config.Config, fs *flag.FlagSet, stdin io.Reader, stderr } else { var name string if fs.NArg() > 0 { + if fs.NArg() > 1 { + return nil, nil, tooManyArgsError(fs) + } name = strings.TrimSpace(fs.Arg(0)) } else { fmt.Fprint(stderr, "Enter domain name to check: ") @@ -432,6 +436,29 @@ func rejectSingleDomainConflicts(fs *flag.FlagSet) error { return nil } +// tooManyArgsError reports extra positional arguments with a targeted hint +// when the first arg matches a known flag name (e.g. `dibs verify foo` → +// suggest `dibs --verify "foo"`). Values are quoted via %q so args with +// spaces round-trip cleanly when the user copies the suggestion. +func tooManyArgsError(fs *flag.FlagSet) error { + args := fs.Args() + first := args[0] + if fs.Lookup(first) != nil { + return fmt.Errorf("unexpected arguments after %q; did you mean: %s --%s %s", first, appName, first, quoteArgs(args[1:])) + } + return fmt.Errorf("%s accepts at most one name; got %d: %s", appName, fs.NArg(), quoteArgs(args)) +} + +// quoteArgs joins args with spaces, wrapping each with %q so the result +// round-trips through a shell when any arg contains whitespace or quotes. +func quoteArgs(args []string) string { + parts := make([]string, len(args)) + for i, a := range args { + parts[i] = fmt.Sprintf("%q", a) + } + return strings.Join(parts, " ") +} + // parseFullDomain splits a full domain like "vi.be" or "foo.co.uk" into its // registrable label and TLD via the Public Suffix List. It normalizes input // (trim whitespace, strip trailing FQDN dot, lowercase), rejects subdomain @@ -591,9 +618,9 @@ func cacheFilePath() string { } // runRDAPVerify checks available domains via RDAP and returns corrections. +// The "Verifying..." banner is printed by the renderer via BeginVerification +// before this is called, so the in-place progress bar can be cleared cleanly. func runRDAPVerify(ctx context.Context, available []dns.Result, cfg config.Config, stderr io.Writer) ([]dns.Result, output.VerifyStats) { - fmt.Fprintf(stderr, "Verifying %d available domains via RDAP...\n", len(available)) - bootstrap := fetchRDAPBootstrap(cfg.Refresh, stderr) if bootstrap == nil { fmt.Fprintf(stderr, "Warning: could not load RDAP bootstrap, skipping verification\n") diff --git a/main_test.go b/main_test.go index 4e29886..03fdfbb 100644 --- a/main_test.go +++ b/main_test.go @@ -100,6 +100,49 @@ func TestJSONAndCSVMutuallyExclusive(t *testing.T) { } } +// TestRejectsExtraPositionalArgs pins the fix for silently ignoring trailing +// args. `dibs verify lumen` used to check "verify" and drop "lumen" on the +// floor; now it errors out and, because "verify" is a known flag, suggests +// the --verify spelling. +func TestRejectsExtraPositionalArgs(t *testing.T) { + tests := []struct { + name string + args []string + wantInErr []string + }{ + { + name: "flag-name typo suggests --flag", + args: []string{"verify", "lumen"}, + wantInErr: []string{"did you mean", `--verify "lumen"`}, + }, + { + name: "plain extra name lists both inputs", + args: []string{"foo", "bar"}, + wantInErr: []string{"at most one name", `"foo" "bar"`}, + }, + { + name: "arg with whitespace is quoted so suggestion round-trips", + args: []string{"verify", "foo bar"}, + wantInErr: []string{"did you mean", `--verify "foo bar"`}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isolateConfig(t) + var stderr strings.Builder + code := run(tt.args, io.Discard, &stderr, strings.NewReader("")) + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + for _, needle := range tt.wantInErr { + if !strings.Contains(stderr.String(), needle) { + t.Errorf("stderr missing %q; got:\n%s", needle, stderr.String()) + } + } + }) + } +} + func TestBuildDomainList(t *testing.T) { tlds := []string{"com", "org", "net"} domains := buildDomainList("mybrand", tlds) diff --git a/output/csv.go b/output/csv.go index fecbd73..a19baea 100644 --- a/output/csv.go +++ b/output/csv.go @@ -35,6 +35,10 @@ func (r *CSVRenderer) Render(result dns.Result) { r.results = append(r.results, result) } +// BeginVerification: CSV is buffered; any "verifying..." banner would only +// appear in the error stream and is skipped for machine-parseable output. +func (r *CSVRenderer) BeginVerification(_ int) {} + // ApplyVerification corrects buffered results from available to taken. func (r *CSVRenderer) ApplyVerification(corrections []dns.Result, _ VerifyStats) { r.mu.Lock() diff --git a/output/json.go b/output/json.go index 24de775..c909622 100644 --- a/output/json.go +++ b/output/json.go @@ -85,6 +85,10 @@ func (r *JSONRenderer) Render(result dns.Result) { } } +// BeginVerification: JSON is buffered; any "verifying..." banner would only +// appear in the error stream and is skipped for machine-parseable output. +func (r *JSONRenderer) BeginVerification(_ int) {} + // ApplyVerification moves corrected domains from Available to Taken and stores stats. func (r *JSONRenderer) ApplyVerification(corrections []dns.Result, stats VerifyStats) { r.mu.Lock() diff --git a/output/renderer.go b/output/renderer.go index 3beaf42..b2aa034 100644 --- a/output/renderer.go +++ b/output/renderer.go @@ -5,13 +5,18 @@ import "github.com/sitapix/dibs/dns" // Renderer is the interface implemented by all output formats. // Implementations must be safe for concurrent use: Render may be called // from multiple goroutines simultaneously. Start is called once before -// any Render calls, and ApplyVerification and Finish are called -// sequentially after all Render calls complete. +// any Render calls; BeginVerification, ApplyVerification, and Finish +// are called sequentially after all Render calls complete. type Renderer interface { // Start is called once before any results, with the search query and total domain count. Start(query string, total int) // Render is called once per lookup result. Must be safe for concurrent use. Render(result dns.Result) + // BeginVerification signals that RDAP verification is about to run on + // `count` available domains. The terminal renderer uses this to clear + // the in-place progress bar before the "Verifying..." banner prints, + // so the two don't collide on the same line. No-op for buffered formats. + BeginVerification(count int) // ApplyVerification applies RDAP verification corrections and stores stats. ApplyVerification(corrections []dns.Result, stats VerifyStats) // Finish is called after all results. partial is true when the run was interrupted. diff --git a/output/terminal.go b/output/terminal.go index 5750dd2..263762d 100644 --- a/output/terminal.go +++ b/output/terminal.go @@ -12,7 +12,9 @@ import ( const ( colorReset = "\x1b[0m" - colorGreen = "\x1b[32m" + // Bright green (ANSI 92) rather than basic green (32): looks vivid across + // muted themes (TokyoNight, Solarized) where 32 renders as a dull teal. + colorGreen = "\x1b[92m" colorDim = "\x1b[2m" clearLine = "\r\x1b[2K" barWidth = 16 @@ -134,6 +136,17 @@ func (r *TerminalRenderer) Finish(partial bool) { } } +// BeginVerification clears the in-place progress bar before printing the +// banner so the two don't collide on the same line. +func (r *TerminalRenderer) BeginVerification(count int) { + r.mu.Lock() + defer r.mu.Unlock() + if r.isTTY { + fmt.Fprint(r.w, clearLine) + } + fmt.Fprintf(r.w, "Verifying %d available domains via RDAP...\n", count) +} + // ApplyVerification prints RDAP corrections and stores stats for Finish. func (r *TerminalRenderer) ApplyVerification(corrections []dns.Result, stats VerifyStats) { r.mu.Lock() diff --git a/output/terminal_test.go b/output/terminal_test.go index bca70fd..13a69d3 100644 --- a/output/terminal_test.go +++ b/output/terminal_test.go @@ -111,6 +111,47 @@ func TestTerminal_ProgressBarFormat(t *testing.T) { } } +// TestTerminalRenderer_BeginVerificationClearsProgressBarOnTTY pins the +// ordering fix: when writing to a TTY, the clearLine escape must appear +// before the "Verifying..." banner so they don't collide on the same line +// as the in-place progress bar. On non-TTY output the escape is suppressed. +func TestTerminalRenderer_BeginVerificationClearsProgressBarOnTTY(t *testing.T) { + t.Run("tty emits clearLine before banner", func(t *testing.T) { + var buf bytes.Buffer + // Bypass NewTerminalRenderer's auto-detect (a bytes.Buffer is never a + // TTY) by constructing the struct directly with isTTY=true. + r := &TerminalRenderer{w: &buf, isTTY: true} + r.BeginVerification(7) + + out := buf.String() + clearIdx := strings.Index(out, clearLine) + bannerIdx := strings.Index(out, "Verifying 7 available domains via RDAP...") + if clearIdx < 0 { + t.Fatalf("expected clearLine escape in output, got: %q", out) + } + if bannerIdx < 0 { + t.Fatalf("expected banner in output, got: %q", out) + } + if clearIdx >= bannerIdx { + t.Errorf("clearLine must precede banner: clearIdx=%d bannerIdx=%d, got: %q", clearIdx, bannerIdx, out) + } + }) + + t.Run("non-tty skips clearLine", func(t *testing.T) { + var buf bytes.Buffer + r := &TerminalRenderer{w: &buf, isTTY: false} + r.BeginVerification(3) + + out := buf.String() + if strings.Contains(out, clearLine) { + t.Errorf("non-TTY output should not contain clearLine escape, got: %q", out) + } + if !strings.Contains(out, "Verifying 3 available domains via RDAP...") { + t.Errorf("expected banner in output, got: %q", out) + } + }) +} + func TestTerminalRenderer_ApplyVerification(t *testing.T) { var buf bytes.Buffer r := NewTerminalRenderer(&buf, false, true)