diff --git a/.github/workflows/bump-sdk.yml b/.github/workflows/bump-sdk.yml new file mode 100644 index 0000000..1886bf4 --- /dev/null +++ b/.github/workflows/bump-sdk.yml @@ -0,0 +1,71 @@ +name: Bump SDK and Create PR + +on: + schedule: + - cron: "0 2 * * *" # daily at 02:00 UTC + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + bump-sdk: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ vars.CENSYS_ARTIFACT_RELEASER_APP_ID }} + private-key: ${{ secrets.CENSYS_ARTIFACT_RELEASER_PRIVATE_KEY }} + repositories: | + cencli + + - uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: true + + - name: Configure git + run: | + git config user.name "${GITHUB_ACTOR}" + git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" + + - name: Create branch and bump SDK + id: bump + run: | + BRANCH="bump-sdk-$(date +%Y%m%d%H%M%S)" + echo "branch=$BRANCH" >> $GITHUB_OUTPUT + git checkout -b $BRANCH + + go get github.com/censys/censys-sdk-go@latest + go mod tidy + + if git diff --quiet; then + echo "has_changes=false" >> $GITHUB_OUTPUT + else + echo "has_changes=true" >> $GITHUB_OUTPUT + fi + + - name: Commit and push changes + if: steps.bump.outputs.has_changes == 'true' + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + git add go.mod go.sum + git commit -m "chore: bump SDK version" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git push origin HEAD + + - name: Create pull request + if: steps.bump.outputs.has_changes == 'true' + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh pr create \ + --base main \ + --head ${{ steps.bump.outputs.branch }} \ + --title "chore: bump SDK version" \ + --body "Automated bump of SDK version" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 61bdba3..5a99d49 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -2,7 +2,7 @@ name: E2E Tests on: pull_request: - branches: [ main ] + branches: [ main, release/* ] paths-ignore: - '**/*.md' - '.github/**' diff --git a/.github/workflows/pr-check-gen.yml b/.github/workflows/pr-check-gen.yml index 6719f69..109b89e 100644 --- a/.github/workflows/pr-check-gen.yml +++ b/.github/workflows/pr-check-gen.yml @@ -2,7 +2,7 @@ name: PR - Ensure Generated Code is Up-to-Date on: pull_request: - branches: [ main ] + branches: [ main, release/* ] paths: - '**/*.go' workflow_dispatch: diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml index ee5c11f..88f9b5c 100644 --- a/.github/workflows/pr-lint.yml +++ b/.github/workflows/pr-lint.yml @@ -2,7 +2,7 @@ name: PR - Lint on: pull_request: - branches: [ main ] + branches: [ main, release/* ] paths: - '**/*.go' workflow_dispatch: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a1d57a8..a04aa2d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,11 +2,11 @@ name: Run Tests and Check Coverage on: pull_request: - branches: [ main ] + branches: [ main, release/* ] paths: - '**/*.go' push: - branches: [ main ] + branches: [ main, release/* ] paths-ignore: - '**/*.md' workflow_dispatch: diff --git a/README.md b/README.md index 05e4cbf..5683849 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ The `view` command allows you to fetch information about a particular host, cert ![view](examples/view/view.gif) -You can also use the `--short` flag to render output using templates, which can be customized. See the [templating documentation](./docs/commands/VIEW.md#templates) for more details. +You can also use `-O short` (or `--output-format short`) to render output using templates, which can be customized. See the [templating documentation](./docs/commands/VIEW.md#templates) for more details. ### Search @@ -116,6 +116,8 @@ This is a WIP. See the [history command docs](./docs/commands/HISTORY.md) for mo ### Other Commands +- `$ censys org`: manage and view organization details. See the [org command docs](./docs/commands/ORG.md) for more details. +- `$ censys credits`: display credit details for your free user Censys account. See the [credits command docs](./docs/commands/CREDITS.md) for more details. - `$ censys completion `: generates shell completion scripts - `$ censys version`: prints version information diff --git a/cmd/cencli/e2e/fixtures/aggregate.go b/cmd/cencli/e2e/fixtures/aggregate.go index 0d94402..d0b4016 100644 --- a/cmd/cencli/e2e/fixtures/aggregate.go +++ b/cmd/cencli/e2e/fixtures/aggregate.go @@ -34,8 +34,22 @@ var aggregateFixtures = []Fixture{ }, }, { - Name: "basic-raw", - Args: []string{"host.services.protocol=SSH", "host.services.port", "-n", "5", "--raw"}, + Name: "output-short-default", + Args: []string{"host.services.protocol=SSH", "host.services.port", "-n", "5"}, + ExitCode: 0, + Timeout: 5 * time.Second, + NeedsAuth: true, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertHas200(t, stderr) + // Default output is short format (table), just verify it has content + assert.Greater(t, len(stdout), 0) + assert.Contains(t, string(stdout), "host.services.protocol=SSH") + assert.Contains(t, string(stdout), "host.services.port") + }, + }, + { + Name: "output-json", + Args: []string{"host.services.protocol=SSH", "host.services.port", "-n", "5", "--output-format", "json"}, ExitCode: 0, Timeout: 5 * time.Second, NeedsAuth: true, @@ -48,4 +62,29 @@ var aggregateFixtures = []Fixture{ assert.Len(t, v, 5) }, }, + { + Name: "output-yaml", + Args: []string{"host.services.protocol=SSH", "host.services.port", "-n", "3", "--output-format", "yaml"}, + ExitCode: 0, + Timeout: 5 * time.Second, + NeedsAuth: true, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertHas200(t, stderr) + // Verify YAML output format + assert.Contains(t, string(stdout), "key:") + assert.Contains(t, string(stdout), "count:") + }, + }, + { + Name: "output-template-unsupported", + Args: []string{"host.services.protocol=SSH", "host.services.port", "--output-format", "template"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + // Should fail with error about unsupported output format + assert.Contains(t, string(stderr), "template") + assert.Contains(t, string(stderr), "not supported") + }, + }, } diff --git a/cmd/cencli/e2e/fixtures/censeye.go b/cmd/cencli/e2e/fixtures/censeye.go index 86615b5..d59d0ba 100644 --- a/cmd/cencli/e2e/fixtures/censeye.go +++ b/cmd/cencli/e2e/fixtures/censeye.go @@ -35,8 +35,21 @@ var censeyeFixtures = []Fixture{ }, }, { - Name: "basic-raw", - Args: []string{"145.131.8.169", "--raw"}, + Name: "output-short-default", + Args: []string{"145.131.8.169", "--rarity-min", "2", "--rarity-max", "125"}, + ExitCode: 0, + Timeout: 12 * time.Second, + NeedsAuth: true, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertHas200(t, stderr) + // Default output is short format (table), just verify it has content + assert.Greater(t, len(stdout), 0) + assert.Contains(t, string(stdout), "=== CensEye Results for 145.131.8.169 ===") + }, + }, + { + Name: "output-json", + Args: []string{"145.131.8.169", "--output-format", "json"}, ExitCode: 0, Timeout: 12 * time.Second, NeedsAuth: true, @@ -50,4 +63,16 @@ var censeyeFixtures = []Fixture{ assert.Greater(t, len(v), 1) }, }, + { + Name: "output-template-unsupported", + Args: []string{"145.131.8.169", "--output-format", "template"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + // Should fail with error about unsupported output format + assert.Contains(t, string(stderr), "template") + assert.Contains(t, string(stderr), "not supported") + }, + }, } diff --git a/cmd/cencli/e2e/fixtures/credits.go b/cmd/cencli/e2e/fixtures/credits.go new file mode 100644 index 0000000..5c7311c --- /dev/null +++ b/cmd/cencli/e2e/fixtures/credits.go @@ -0,0 +1,37 @@ +package fixtures + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/censys/cencli/cmd/cencli/e2e/fixtures/golden" + "github.com/censys/cencli/internal/app/credits" +) + +var creditsFixtures = []Fixture{ + { + Name: "help", + Args: []string{"--help"}, + ExitCode: 0, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertGoldenFile(t, golden.CreditsHelpStdout, stdout, 0) + }, + }, + { + Name: "basic", + Args: []string{"--output-format", "json"}, + ExitCode: 0, + Timeout: 5 * time.Second, + NeedsAuth: true, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertHas200(t, stderr) + data := unmarshalJSONAny[credits.UserCreditDetails](t, stdout) + assert.Greater(t, data.Balance, int64(0)) + assert.NotNil(t, data.ResetsAt) + }, + }, +} diff --git a/cmd/cencli/e2e/fixtures/fixtures.go b/cmd/cencli/e2e/fixtures/fixtures.go index 524b499..87fe79d 100644 --- a/cmd/cencli/e2e/fixtures/fixtures.go +++ b/cmd/cencli/e2e/fixtures/fixtures.go @@ -29,5 +29,7 @@ func Fixtures() map[string][]Fixture { "search": searchFixtures, "censeye": censeyeFixtures, "history": historyFixtures, + "credits": creditsFixtures, + "org": orgFixtures, } } diff --git a/cmd/cencli/e2e/fixtures/golden/aggregate_help.out b/cmd/cencli/e2e/fixtures/golden/aggregate_help.out index 5602133..010e466 100644 --- a/cmd/cencli/e2e/fixtures/golden/aggregate_help.out +++ b/cmd/cencli/e2e/fixtures/golden/aggregate_help.out @@ -7,6 +7,7 @@ Usage: Examples: censys aggregate "host.services.protocol=SSH" "host.services.port" censys aggregate -c "services.service_name:HTTP" "services.port" + censys aggregate "services.service_name:HTTP" "services.port" --output-format json Flags: -c, --collection-id string collection to aggregate within (optional) @@ -16,13 +17,13 @@ Flags: -i, --interactive display results in an interactive table (TUI) -n, --num-buckets int number of buckets to split results into (default 25) -o, --org-id string override the configured organization ID - -r, --raw output raw data Global Flags: --debug enable debug logging --no-color disable ANSI colors and styles --no-spinner disable spinner during operations - -O, --output-format string output format (json|yaml|ndjson|tree) (default "json") + -O, --output-format string output format (json|yaml|tree|short|template) (default "short") -q, --quiet suppress non-essential output + -S, --streaming enable streaming output mode (NDJSON) for commands that support it --timeout-http duration per-request timeout for HTTP requests (e.g. 10s, 1m) - use 0 to disable diff --git a/cmd/cencli/e2e/fixtures/golden/censeye_help.out b/cmd/cencli/e2e/fixtures/golden/censeye_help.out index f8d7fe5..c07fd48 100644 --- a/cmd/cencli/e2e/fixtures/golden/censeye_help.out +++ b/cmd/cencli/e2e/fixtures/golden/censeye_help.out @@ -9,7 +9,8 @@ Usage: Examples: censys censeye 8.8.8.8 censys censeye --rarity-min 2 --rarity-max 25 1.1.1.1 - censys censeye --raw --include-url 192.168.1.1 + censys censeye --interactive 192.168.1.1 + censys censeye --output-format json --include-url 192.168.1.1 Flags: -h, --help help for censeye @@ -19,13 +20,13 @@ Flags: -o, --org-id string override the configured organization ID -M, --rarity-max int maximum host count for interesting results (must be non-zero) (default 100) -m, --rarity-min int minimum host count for interesting results (must be non-zero) (default 2) - -r, --raw output raw data Global Flags: --debug enable debug logging --no-color disable ANSI colors and styles --no-spinner disable spinner during operations - -O, --output-format string output format (json|yaml|ndjson|tree) (default "json") + -O, --output-format string output format (json|yaml|tree|short|template) (default "short") -q, --quiet suppress non-essential output + -S, --streaming enable streaming output mode (NDJSON) for commands that support it --timeout-http duration per-request timeout for HTTP requests (e.g. 10s, 1m) - use 0 to disable diff --git a/cmd/cencli/e2e/fixtures/golden/credits_help.out b/cmd/cencli/e2e/fixtures/golden/credits_help.out new file mode 100644 index 0000000..263599b --- /dev/null +++ b/cmd/cencli/e2e/fixtures/golden/credits_help.out @@ -0,0 +1,23 @@ +Display credit details for your Free user Censys account. + +Note: This command only shows free user credits. If you want to see organization credits, +run "censys org credits" instead. + +Usage: + censys credits [flags] + +Examples: + censys credits # Show free user credits + +Flags: + -h, --help help for credits + +Global Flags: + --debug enable debug logging + --no-color disable ANSI colors and styles + --no-spinner disable spinner during operations + -O, --output-format string output format (json|yaml|tree|short|template) (default "short") + -q, --quiet suppress non-essential output + -S, --streaming enable streaming output mode (NDJSON) for commands that support it + --timeout-http duration per-request timeout for HTTP requests (e.g. 10s, 1m) - use 0 to disable + diff --git a/cmd/cencli/e2e/fixtures/golden/golden.go b/cmd/cencli/e2e/fixtures/golden/golden.go index 2a1a9fb..95e348b 100644 --- a/cmd/cencli/e2e/fixtures/golden/golden.go +++ b/cmd/cencli/e2e/fixtures/golden/golden.go @@ -17,4 +17,14 @@ var ( HistoryHelpStdout []byte //go:embed root.out RootStdout []byte + //go:embed credits_help.out + CreditsHelpStdout []byte + //go:embed org_details_help.out + OrgDetailsHelpStdout []byte + //go:embed org_members_help.out + OrgMembersHelpStdout []byte + //go:embed org_credits_help.out + OrgCreditsHelpStdout []byte + //go:embed org_help.out + OrgHelpStdout []byte ) diff --git a/cmd/cencli/e2e/fixtures/golden/history_help.out b/cmd/cencli/e2e/fixtures/golden/history_help.out index 0f8c4d5..99fb9bd 100644 --- a/cmd/cencli/e2e/fixtures/golden/history_help.out +++ b/cmd/cencli/e2e/fixtures/golden/history_help.out @@ -26,7 +26,8 @@ Global Flags: --debug enable debug logging --no-color disable ANSI colors and styles --no-spinner disable spinner during operations - -O, --output-format string output format (json|yaml|ndjson|tree) (default "json") + -O, --output-format string output format (json|yaml|tree|short|template) (default "json") -q, --quiet suppress non-essential output + -S, --streaming enable streaming output mode (NDJSON) for commands that support it --timeout-http duration per-request timeout for HTTP requests (e.g. 10s, 1m) - use 0 to disable diff --git a/cmd/cencli/e2e/fixtures/golden/org_credits_help.out b/cmd/cencli/e2e/fixtures/golden/org_credits_help.out new file mode 100644 index 0000000..0bf2385 --- /dev/null +++ b/cmd/cencli/e2e/fixtures/golden/org_credits_help.out @@ -0,0 +1,28 @@ +Display credit details for your organization. + +This command shows your organization's credit balance, auto-replenish configuration, +and any credit expirations. + +By default, the stored organization ID is used. Use --org-id to query a specific +organization. + +Usage: + censys org credits [flags] + +Examples: + censys org credits # Show credits for your stored organization + censys org credits --org-id # Show credits for a specific organization + +Flags: + -h, --help help for credits + -o, --org-id string override the configured organization ID + +Global Flags: + --debug enable debug logging + --no-color disable ANSI colors and styles + --no-spinner disable spinner during operations + -O, --output-format string output format (json|yaml|tree|short|template) (default "short") + -q, --quiet suppress non-essential output + -S, --streaming enable streaming output mode (NDJSON) for commands that support it + --timeout-http duration per-request timeout for HTTP requests (e.g. 10s, 1m) - use 0 to disable + diff --git a/cmd/cencli/e2e/fixtures/golden/org_details_help.out b/cmd/cencli/e2e/fixtures/golden/org_details_help.out new file mode 100644 index 0000000..a10f7fc --- /dev/null +++ b/cmd/cencli/e2e/fixtures/golden/org_details_help.out @@ -0,0 +1,29 @@ +Display details about your organization. + +This command shows organization information including name, ID, creation date, +and member counts. + +By default, the stored organization ID is used. Use --org-id to query a specific +organization. + +Usage: + censys org details [flags] + +Examples: + censys org details # Show details for your stored organization + censys org details --org-id # Show details for a specific organization + censys org details --output-format json # Output as JSON + +Flags: + -h, --help help for details + -o, --org-id string override the configured organization ID + +Global Flags: + --debug enable debug logging + --no-color disable ANSI colors and styles + --no-spinner disable spinner during operations + -O, --output-format string output format (json|yaml|tree|short|template) (default "short") + -q, --quiet suppress non-essential output + -S, --streaming enable streaming output mode (NDJSON) for commands that support it + --timeout-http duration per-request timeout for HTTP requests (e.g. 10s, 1m) - use 0 to disable + diff --git a/cmd/cencli/e2e/fixtures/golden/org_help.out b/cmd/cencli/e2e/fixtures/golden/org_help.out new file mode 100644 index 0000000..7516bb9 --- /dev/null +++ b/cmd/cencli/e2e/fixtures/golden/org_help.out @@ -0,0 +1,30 @@ +Manage and view organization details including credits, members, and organization +information. + +By default, these commands use your stored organization ID. If no organization ID is +stored, +or you want to query a different organization, use the --org-id flag on each subcommand. + +To set your default organization ID, run: censys config org-id set + +Usage: + censys org [flags] + censys org [command] + +Available Commands: + credits Display credit details for your organization + details Display organization details + members List organization members + +Flags: + -h, --help help for org + +Global Flags: + --debug enable debug logging + --no-color disable ANSI colors and styles + --no-spinner disable spinner during operations + -O, --output-format string output format (json|yaml|tree|short|template) (default "short") + -q, --quiet suppress non-essential output + -S, --streaming enable streaming output mode (NDJSON) for commands that support it + --timeout-http duration per-request timeout for HTTP requests (e.g. 10s, 1m) - use 0 to disable + diff --git a/cmd/cencli/e2e/fixtures/golden/org_members_help.out b/cmd/cencli/e2e/fixtures/golden/org_members_help.out new file mode 100644 index 0000000..ca11603 --- /dev/null +++ b/cmd/cencli/e2e/fixtures/golden/org_members_help.out @@ -0,0 +1,31 @@ +List members in your organization. + +This command displays all members including their email, name, roles, and last login time. + +By default, the stored organization ID is used. Use --org-id to query a specific +organization. +Use --interactive for a navigable table view. + +Usage: + censys org members [flags] + +Examples: + censys org members # List members for your stored organization + censys org members --interactive # List members in an interactive table + censys org members --org-id # List members for a specific organization + censys org members --output-format json # Output as JSON + +Flags: + -h, --help help for members + -i, --interactive display results in an interactive table (TUI) + -o, --org-id string override the configured organization ID + +Global Flags: + --debug enable debug logging + --no-color disable ANSI colors and styles + --no-spinner disable spinner during operations + -O, --output-format string output format (json|yaml|tree|short|template) (default "short") + -q, --quiet suppress non-essential output + -S, --streaming enable streaming output mode (NDJSON) for commands that support it + --timeout-http duration per-request timeout for HTTP requests (e.g. 10s, 1m) - use 0 to disable + diff --git a/cmd/cencli/e2e/fixtures/golden/root.out b/cmd/cencli/e2e/fixtures/golden/root.out index f5cfbf9..dc19f3a 100644 --- a/cmd/cencli/e2e/fixtures/golden/root.out +++ b/cmd/cencli/e2e/fixtures/golden/root.out @@ -11,7 +11,9 @@ Available Commands: censeye Analyze a host and generate pivotable queries with rarity bounds completion Generate shell completion scripts config Manage configuration + credits Display credit details for your Censys account history Retrieve historical data for hosts, web properties, and certificates + org Manage and view organization details search Execute a search query across Censys data version Print version information view Retrieve information about hosts, certificates, and web properties diff --git a/cmd/cencli/e2e/fixtures/golden/search_help.out b/cmd/cencli/e2e/fixtures/golden/search_help.out index 0f196de..0f3d453 100644 --- a/cmd/cencli/e2e/fixtures/golden/search_help.out +++ b/cmd/cencli/e2e/fixtures/golden/search_help.out @@ -18,13 +18,13 @@ Flags: -p, --max-pages int maximum number of pages to fetch (-1 for all pages) (default 1) -o, --org-id string override the configured organization ID -n, --page-size int number of results to return per page (default 100) - -s, --short render data through a configurable template Global Flags: --debug enable debug logging --no-color disable ANSI colors and styles --no-spinner disable spinner during operations - -O, --output-format string output format (json|yaml|ndjson|tree) (default "json") + -O, --output-format string output format (json|yaml|tree|short|template) (default "json") -q, --quiet suppress non-essential output + -S, --streaming enable streaming output mode (NDJSON) for commands that support it --timeout-http duration per-request timeout for HTTP requests (e.g. 10s, 1m) - use 0 to disable diff --git a/cmd/cencli/e2e/fixtures/golden/update.sh b/cmd/cencli/e2e/fixtures/golden/update.sh new file mode 100755 index 0000000..402fc84 --- /dev/null +++ b/cmd/cencli/e2e/fixtures/golden/update.sh @@ -0,0 +1,31 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../../.." && pwd)" +BINARY="$PROJECT_ROOT/bin/censys" + +if [ ! -f "$BINARY" ]; then + echo "Error: Binary not found at $BINARY" + echo "Please run 'make censys' first" + exit 1 +fi + +cd "$SCRIPT_DIR" + +echo "Updating golden fixtures..." + +"$BINARY" view --help > view_help.out +"$BINARY" aggregate --help > aggregate_help.out +"$BINARY" search --help > search_help.out +"$BINARY" censeye --help > censeye_help.out +"$BINARY" history --help > history_help.out +"$BINARY" credits --help > credits_help.out +"$BINARY" org details --help > org_details_help.out +"$BINARY" org members --help > org_members_help.out +"$BINARY" org credits --help > org_credits_help.out +"$BINARY" org --help > org_help.out +"$BINARY" > root.out + +echo "✅ All golden fixtures updated" + diff --git a/cmd/cencli/e2e/fixtures/golden/view_help.out b/cmd/cencli/e2e/fixtures/golden/view_help.out index 0d14390..4a96bf5 100644 --- a/cmd/cencli/e2e/fixtures/golden/view_help.out +++ b/cmd/cencli/e2e/fixtures/golden/view_help.out @@ -13,7 +13,7 @@ Examples: censys view --input-file hosts.txt censys view --input-file - # read assets from STDIN censys view platform.censys.io:80 --at-time 2025-09-15T14:30:00Z - censys view 8.8.8.8 --short + censys view 8.8.8.8 --output-format short Flags: -a, --at string Alias for --at-time @@ -21,13 +21,13 @@ Flags: -h, --help help for view -i, --input-file string file to read the assets from. Overrides the positional argument. -o, --org-id string override the configured organization ID - -s, --short render data through a configurable template Global Flags: --debug enable debug logging --no-color disable ANSI colors and styles --no-spinner disable spinner during operations - -O, --output-format string output format (json|yaml|ndjson|tree) (default "json") + -O, --output-format string output format (json|yaml|tree|short|template) (default "json") -q, --quiet suppress non-essential output + -S, --streaming enable streaming output mode (NDJSON) for commands that support it --timeout-http duration per-request timeout for HTTP requests (e.g. 10s, 1m) - use 0 to disable diff --git a/cmd/cencli/e2e/fixtures/history.go b/cmd/cencli/e2e/fixtures/history.go index 1eb14d6..1aa0c21 100644 --- a/cmd/cencli/e2e/fixtures/history.go +++ b/cmd/cencli/e2e/fixtures/history.go @@ -36,5 +36,47 @@ var historyFixtures = []Fixture{ assert.Greater(t, len(v), 1) }, }, + // Output format tests + { + Name: "output-json-default", + Args: []string{"platform.censys.io:80", "--duration", "2d"}, + ExitCode: 0, + Timeout: 12 * time.Second, + NeedsAuth: true, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertHas200(t, stderr) + // Verify default JSON output + v := unmarshalJSONAny[[]struct { + Time time.Time `json:"time"` + Data any `json:"data"` + Exists bool `json:"exists"` + }](t, stdout) + assert.Greater(t, len(v), 1) + }, + }, + { + Name: "output-short-unsupported", + Args: []string{"platform.censys.io:80", "--duration", "2d", "--output-format", "short"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + // Should fail with error about unsupported output format + assert.Contains(t, string(stderr), "short") + assert.Contains(t, string(stderr), "not supported") + }, + }, + { + Name: "output-template-unsupported", + Args: []string{"platform.censys.io:80", "--duration", "2d", "--output-format", "template"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + // Should fail with error about unsupported output format + assert.Contains(t, string(stderr), "template") + assert.Contains(t, string(stderr), "not supported") + }, + }, // TODO: certificate and host history } diff --git a/cmd/cencli/e2e/fixtures/org.go b/cmd/cencli/e2e/fixtures/org.go new file mode 100644 index 0000000..d63e8ff --- /dev/null +++ b/cmd/cencli/e2e/fixtures/org.go @@ -0,0 +1,118 @@ +package fixtures + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/censys/cencli/cmd/cencli/e2e/fixtures/golden" + "github.com/censys/cencli/internal/app/credits" + "github.com/censys/cencli/internal/app/organizations" +) + +var orgFixtures = []Fixture{ + { + Name: "help", + Args: []string{"--help"}, + ExitCode: 0, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertGoldenFile(t, golden.OrgHelpStdout, stdout, 0) + }, + }, + { + Name: "help with no args", + Args: []string{}, + ExitCode: 0, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertGoldenFile(t, golden.OrgHelpStdout, stdout, 0) + }, + }, + // ========== credits subcommand ========== + { + Name: "credits help", + Args: []string{"credits", "--help"}, + ExitCode: 0, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertGoldenFile(t, golden.OrgCreditsHelpStdout, stdout, 0) + }, + }, + { + Name: "credits basic", + Args: []string{"credits", "--output-format", "json"}, + ExitCode: 0, + Timeout: 5 * time.Second, + NeedsAuth: true, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertHas200(t, stderr) + data := unmarshalJSONAny[credits.OrganizationCreditDetails](t, stdout) + assert.Greater(t, data.Balance, int64(0)) + assert.NotNil(t, data.AutoReplenishConfig) + assert.NotNil(t, data.CreditExpirations) + assert.Greater(t, len(data.CreditExpirations), 0) + for _, creditExpiration := range data.CreditExpirations { + assert.Greater(t, creditExpiration.Balance, int64(0)) + assert.NotNil(t, creditExpiration.CreationDate) + assert.NotNil(t, creditExpiration.ExpirationDate) + } + }, + }, + // ========== members subcommand ========== + { + Name: "members help", + Args: []string{"members", "--help"}, + ExitCode: 0, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertGoldenFile(t, golden.OrgMembersHelpStdout, stdout, 0) + }, + }, + { + Name: "members basic", + Args: []string{"members", "--output-format", "json"}, + ExitCode: 0, + Timeout: 5 * time.Second, + NeedsAuth: true, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertHas200(t, stderr) + data := unmarshalJSONAny[organizations.OrganizationMembers](t, stdout) + assert.Greater(t, len(data.Members), 0) + for _, member := range data.Members { + assert.NotEmpty(t, member.Email) + assert.NotEmpty(t, member.Roles) + } + }, + }, + // ========== details subcommand ========== + { + Name: "details help", + Args: []string{"details", "--help"}, + ExitCode: 0, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertGoldenFile(t, golden.OrgDetailsHelpStdout, stdout, 0) + }, + }, + { + Name: "details basic", + Args: []string{"details", "--output-format", "json"}, + ExitCode: 0, + Timeout: 5 * time.Second, + NeedsAuth: true, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertHas200(t, stderr) + data := unmarshalJSONAny[organizations.OrganizationDetails](t, stdout) + assert.NotEmpty(t, data.Name) + assert.NotEmpty(t, data.CreatedAt) + assert.NotEmpty(t, data.MemberCounts) + }, + }, +} diff --git a/cmd/cencli/e2e/fixtures/search.go b/cmd/cencli/e2e/fixtures/search.go index 77e6e27..8638950 100644 --- a/cmd/cencli/e2e/fixtures/search.go +++ b/cmd/cencli/e2e/fixtures/search.go @@ -35,8 +35,33 @@ var searchFixtures = []Fixture{ }, }, { - Name: "short", - Args: []string{"host.services: (protocol=SSH)", "--short", "-n", "2", "-p", "2"}, + Name: "output-json-default", + Args: []string{"host.services: (protocol=SSH)", "-n", "2", "-p", "1"}, + ExitCode: 0, + Timeout: 5 * time.Second, + NeedsAuth: true, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertHas200(t, stderr) + // Default is JSON output + v := unmarshalJSONAny[[]map[string]any](t, stdout) + assert.Len(t, v, 2) + }, + }, + { + Name: "output-short", + Args: []string{"host.services: (protocol=SSH)", "-n", "2", "--output-format", "short"}, + ExitCode: 0, + Timeout: 5 * time.Second, + NeedsAuth: true, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertHas200(t, stderr) + // Just verify short output exists (don't validate content) + assert.Greater(t, len(stdout), 0) + }, + }, + { + Name: "output-template", + Args: []string{"host.services: (protocol=SSH)", "-n", "2", "-p", "2", "--output-format", "template"}, ExitCode: 0, Timeout: 5 * time.Second, NeedsAuth: true, @@ -45,4 +70,16 @@ var searchFixtures = []Fixture{ assertRenderedTemplate(t, templates.SearchResultTemplate, stdout) }, }, + { + Name: "output-yaml", + Args: []string{"host.services: (protocol=SSH)", "-n", "2", "--output-format", "yaml"}, + ExitCode: 0, + Timeout: 5 * time.Second, + NeedsAuth: true, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertHas200(t, stderr) + // Verify YAML output format + assert.Contains(t, string(stdout), "host:") + }, + }, } diff --git a/cmd/cencli/e2e/fixtures/view.go b/cmd/cencli/e2e/fixtures/view.go index 8f83e29..eb1a582 100644 --- a/cmd/cencli/e2e/fixtures/view.go +++ b/cmd/cencli/e2e/fixtures/view.go @@ -41,16 +41,16 @@ var viewFixtures = []Fixture{ // Host fixtures // ================================================ { - Name: "host-basic", + Name: "host-json-default", Args: []string{"1.1.1.1"}, ExitCode: 0, Timeout: 5 * time.Second, NeedsAuth: true, Assert: func(t *testing.T, stdout, stderr []byte) { + assertHas200(t, stderr) v := unmarshalJSONAny[[]map[string]any](t, stdout) assert.Len(t, v, 1) assert.Equal(t, "1.1.1.1", v[0]["ip"]) - assertHas200(t, stderr) }, }, { @@ -68,7 +68,20 @@ var viewFixtures = []Fixture{ }, { Name: "host-short", - Args: []string{"1.1.1.1", "--short"}, + Args: []string{"1.1.1.1", "--output-format", "short"}, + ExitCode: 0, + Timeout: 5 * time.Second, + NeedsAuth: true, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertHas200(t, stderr) + // Just verify short output exists + assert.Greater(t, len(stdout), 0) + assert.Contains(t, string(stdout), "1.1.1.1") + }, + }, + { + Name: "host-template", + Args: []string{"1.1.1.1", "--output-format", "template"}, ExitCode: 0, Timeout: 5 * time.Second, NeedsAuth: true, @@ -124,7 +137,19 @@ var viewFixtures = []Fixture{ }, { Name: "certificate-short", - Args: []string{"3daf2843a77b6f4e6af43cd9b6f6746053b8c928e056e8a724808db8905a94cf", "--short"}, + Args: []string{"3daf2843a77b6f4e6af43cd9b6f6746053b8c928e056e8a724808db8905a94cf", "--output-format", "short"}, + ExitCode: 0, + Timeout: 5 * time.Second, + NeedsAuth: true, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertHas200(t, stderr) + // Just verify short output exists + assert.Greater(t, len(stdout), 0) + }, + }, + { + Name: "certificate-template", + Args: []string{"3daf2843a77b6f4e6af43cd9b6f6746053b8c928e056e8a724808db8905a94cf", "--output-format", "template"}, ExitCode: 0, Timeout: 5 * time.Second, NeedsAuth: true, @@ -195,13 +220,25 @@ var viewFixtures = []Fixture{ }, { Name: "web-property-short", - Args: []string{"platform.censys.io:80", "--short"}, + Args: []string{"platform.censys.io:80", "--output-format", "short"}, + ExitCode: 0, + Timeout: 5 * time.Second, + NeedsAuth: true, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertHas200(t, stderr) + // Just verify short output exists + assert.Greater(t, len(stdout), 0) + }, + }, + { + Name: "web-property-template", + Args: []string{"platform.censys.io:80", "--output-format", "template"}, ExitCode: 0, Timeout: 5 * time.Second, NeedsAuth: true, Assert: func(t *testing.T, stdout, stderr []byte) { - assertRenderedTemplate(t, templates.WebPropertyTemplate, stdout) assertHas200(t, stderr) + assertRenderedTemplate(t, templates.WebPropertyTemplate, stdout) }, }, { @@ -234,4 +271,18 @@ var viewFixtures = []Fixture{ assertHas200(t, stderr) }, }, + // Output format tests + { + Name: "output-ndjson", + Args: []string{"1.1.1.1", "--streaming"}, + ExitCode: 0, + Timeout: 5 * time.Second, + NeedsAuth: true, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertHas200(t, stderr) + // Verify NDJSON format (one JSON object per line) + lines := strings.Split(strings.TrimSpace(string(stdout)), "\n") + assert.Equal(t, 1, len(lines)) + }, + }, } diff --git a/cmd/cencli/main.go b/cmd/cencli/main.go index d48d053..44cde28 100644 --- a/cmd/cencli/main.go +++ b/cmd/cencli/main.go @@ -64,7 +64,7 @@ func run() int { // Build client and app services (optional to allow config/init before auth) sdkCtx, sdkCancel := context.WithTimeout(context.Background(), 5*time.Second) defer sdkCancel() - sdkClient, err := client.NewCensysSDK(sdkCtx, ds, cfg.Timeouts.HTTP, cfg.RetryStrategy) + sdkClient, err := client.NewCensysSDK(sdkCtx, ds, cfg.Timeouts.HTTP, cfg.RetryStrategy, cfg.Debug) if err != nil { if errors.Is(err, authdom.ErrAuthNotFound) { // user hasn't configured enough to initialize the client diff --git a/cmd/examples/main.go b/cmd/examples/main.go index 5b87fd0..10a0372 100644 --- a/cmd/examples/main.go +++ b/cmd/examples/main.go @@ -4,7 +4,9 @@ import ( "context" "fmt" "os" + "os/signal" "path/filepath" + "syscall" "time" "github.com/censys/cencli/internal/command/aggregate" @@ -27,9 +29,15 @@ type recordableCommand interface { } func main() { + // Get absolute path to the locally built binary + binPath, err := filepath.Abs("./bin/censys") + if err != nil { + panic(err) + } + r, err := tape.NewTapeRecorder( "vhs", - "censys", + binPath, map[string]string{ "FORCE_COLOR": "1", }, @@ -65,6 +73,9 @@ func main() { targetCommands = commands } + sigCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt) + defer stop() + for dir, cmd := range targetCommands { outputDir := filepath.Join(baseDir, dir) // special case for root command @@ -72,7 +83,7 @@ func main() { outputDir = baseDir } for _, t := range cmd.Tapes(r) { - ctx, cancel := context.WithTimeout(context.Background(), timeout) + ctx, cancel := context.WithTimeout(sigCtx, timeout) stop := spinner.Start(ctx.Done(), false, spinner.WithMessage(fmt.Sprintf("Recording tape for %s...", t.Name))) err = r.CreateTape(ctx, t, outputDir) cancel() diff --git a/docs/GLOBAL_CONFIGURATION.md b/docs/GLOBAL_CONFIGURATION.md index 404bd70..4e3251e 100644 --- a/docs/GLOBAL_CONFIGURATION.md +++ b/docs/GLOBAL_CONFIGURATION.md @@ -28,10 +28,38 @@ Default output format for command results. **Flag:** `--output-format`, `-O` **Environment Variable:** `CENCLI_OUTPUT_FORMAT` **Type:** `string` -**Default:** `json` -**Valid Values:** `json`, `yaml`, `ndjson`, `tree` +**Default:** `json` (globally), but individual commands may default to `short` +**Valid Values:** `json`, `yaml`, `tree`, `short`, `template` -Controls how data is formatted when printed to stdout. The `tree` format provides a hierarchical view of nested data structures. +Controls how data is formatted when printed to stdout: + +- **`json`** - Structured JSON output (default for most commands) +- **`yaml`** - Structured YAML output +- **`tree`** - Hierarchical tree view of nested data structures +- **`short`** - Human-readable formatted output (available on select commands like `aggregate`, `censeye`, `search`, `view`) +- **`template`** - Render using custom Handlebars templates (available on `search` and `view` commands) + +**Note:** Some commands default to `short` output instead of `json` to provide a better user experience. For example, the `aggregate` and `censeye` commands show formatted tables by default. You can always override this with `--output-format json` or another format. + +### `--streaming`, `-S` + +Enable streaming output mode. + +**Flag:** `--streaming`, `-S` +**Environment Variable:** `CENCLI_STREAMING` +**Type:** `boolean` +**Default:** `false` + +When enabled, commands that support streaming will output results as NDJSON (newline-delimited JSON) with each record emitted immediately as data is fetched. This provides several benefits for large result sets: + +- **Output begins before all pages are fetched** - You see results as soon as the first page is retrieved +- **Memory usage stays bounded** - Results are written immediately rather than accumulated in memory +- **Partial results are preserved on interruption** - If you press Ctrl-C or an error occurs, all previously emitted records remain intact +- **Safe for large queries** - Ideal for use with `--max-pages -1` when fetching potentially unbounded result sets + +**Supported commands:** `search`, `view`, `history` + +**Note:** `--streaming` cannot be used together with `--output-format`. When streaming mode is enabled, output is always NDJSON. If you set `streaming: true` in your config file, it will be silently ignored for commands that don't support streaming. ### `--no-color` @@ -190,7 +218,32 @@ For the complete, authoritative list of supported timezones, see [timezones.go]( ## Templates -Template paths for formatted output. Each asset type has its own template file. See [the view command docs](commands/VIEW.md#templates) for more details. +Template paths for formatted output using `--output-format template`. Templates are stored in `~/.config/cencli/templates/` (or `$CENCLI_DATA_DIR/templates/`) and are automatically created with sensible defaults on first use. + +Each template corresponds to a specific data type: + +| Template | File | Used By | +|----------|------|---------| +| Host | `host.hbs` | `view` command (host assets) | +| Certificate | `certificate.hbs` | `view` command (certificate assets) | +| Web Property | `webproperty.hbs` | `view` command (web property assets) | +| Search Result | `searchresult.hbs` | `search` command | + +You can customize templates by editing the files in your templates directory. The path to each template can also be overridden in `config.yaml`: + +```yaml +templates: + host: + path: /path/to/custom/host.hbs + certificate: + path: /path/to/custom/certificate.hbs + webproperty: + path: /path/to/custom/webproperty.hbs + searchresult: + path: /path/to/custom/searchresult.hbs +``` + +See [the view command docs](commands/VIEW.md#templates) for more details on creating and customizing templates. ## Standard Environment Variables diff --git a/docs/commands/AGGREGATE.md b/docs/commands/AGGREGATE.md index 99bfaf9..46f35f3 100644 --- a/docs/commands/AGGREGATE.md +++ b/docs/commands/AGGREGATE.md @@ -99,18 +99,29 @@ Display results in an interactive table (TUI) that allows you to navigate, searc $ censys aggregate "host.services.port=22" "host.services.protocol" --interactive ``` -**Note:** Cannot be used with `--raw`. +## Output Formats -### `--raw`, `-r` +The `aggregate` command defaults to **`short`** output format, which displays results as a formatted table. You can override this with the `--output-format` flag (or `-O`). -Output raw JSON data instead of a formatted table. This will respect the [output format](../GLOBAL_CONFIGURATION.md#--output-format--o) configuration. +**Default:** `short` (table view) +**Supported formats:** `json`, `yaml`, `tree`, `short` -**Type:** `boolean` -**Default:** `false` +### Examples ```bash -$ censys aggregate "host.services.port=22" "host.services.protocol" --raw +# Default: formatted table view +$ censys aggregate "host.services.protocol=SSH" "host.services.port" + +# JSON output +$ censys aggregate "host.services.protocol=SSH" "host.services.port" --output-format json +$ censys aggregate "host.services.protocol=SSH" "host.services.port" -O json + +# YAML output +$ censys aggregate "host.services.protocol=SSH" "host.services.port" --output-format yaml + +# Interactive table (works with short format) +$ censys aggregate "host.services.protocol=SSH" "host.services.port" --interactive ``` -**Note:** Cannot be used with `--interactive`. +**Note:** The `--interactive` flag provides an enhanced interactive table view when using the `short` output format. diff --git a/docs/commands/CENSEYE.md b/docs/commands/CENSEYE.md index 99f7534..2150a97 100644 --- a/docs/commands/CENSEYE.md +++ b/docs/commands/CENSEYE.md @@ -110,32 +110,43 @@ In interactive mode, you can: - Press Enter to open a query in your default browser - Search and filter within results -**Note:** Cannot be used with `--raw`. -### `--raw`, `-r` +### `--include-url` -Output raw JSON data instead of a formatted table. This will respect the [output format](../GLOBAL_CONFIGURATION.md#--output-format--o) configuration. +Include the `search_url` field in the output, which provides a direct link to view the query results in the Censys Platform web UI. This is particularly useful when using structured output formats (JSON, YAML) for scripting purposes. **Type:** `boolean` **Default:** `false` ```bash -$ censys censeye 8.8.8.8 --raw +$ censys censeye 8.8.8.8 --output-format json --include-url ``` -**Note:** Cannot be used with `--interactive`. +## Output Formats -### `--include-url` +The `censeye` command defaults to **`short`** output format, which displays results as a formatted table. You can override this with the `--output-format` flag (or `-O`). -Include the `search_url` field in the output, which provides a direct link to view the query results in the Censys Platform web UI. This is particularly useful when using `--raw` for scripting purposes. +**Default:** `short` (table view) +**Supported formats:** `json`, `yaml`, `tree`, `short` -**Type:** `boolean` -**Default:** `false` +### Examples ```bash -$ censys censeye 8.8.8.8 --raw --include-url +# Default: formatted table view +$ censys censeye 8.8.8.8 + +# JSON output +$ censys censeye 8.8.8.8 --output-format json +$ censys censeye 8.8.8.8 -O json + +# YAML output +$ censys censeye 8.8.8.8 --output-format yaml + +# Interactive table (works with short format) +$ censys censeye 8.8.8.8 --interactive ``` +**Note:** The `--interactive` flag provides an enhanced interactive table view when using the `short` output format. ## Understanding the Output diff --git a/docs/commands/CONFIG.md b/docs/commands/CONFIG.md index e265999..c9382f4 100644 --- a/docs/commands/CONFIG.md +++ b/docs/commands/CONFIG.md @@ -21,13 +21,29 @@ Use `censys config auth add` to open an interactive prompt to add a token. Use ` There also exists a non-interactive mode for adding tokens, where you can provide the token value, name, and optionally activate the token. ```bash -$ censys config auth -a # list all tokens +$ censys config auth # view/manage tokens (interactive TUI) $ censys config auth add --value "your-token" --name "my-token" # add a token (non-interactive) $ censys config auth add --value-file token.txt --name "my-token" # add from file -$ censys config auth activate # activate a specific token by ID -$ censys config auth delete # delete a token by ID +$ censys config auth activate # activate a specific token by ID +$ censys config auth delete # delete a token by ID ``` +#### Flags for `config auth` + +**`--accessible`, `-a`**: Enable accessible mode (non-redrawing). This disables animations and screen updates that may not work well with screen readers or certain terminal configurations. + +#### Flags for `config auth add` + +**`--value`**: The personal access token value (for non-interactive mode). + +**`--value-file`**: Read the token value from a file, or use `-` to read from stdin. + +**`--name`, `-n`**: A friendly name/description for this token. **Default:** `"ci"` + +**`--activate`**: Mark the added token as active immediately. **Default:** `true` + +**`--accessible`, `-a`**: Enable accessible mode (non-redrawing). + ### `config org-id` Manage organization IDs used for API requests. The active organization ID is automatically attached to requests that support organization-scoped operations. @@ -37,13 +53,28 @@ Use `censys config org-id add` to open an interactive prompt to add an organizat There also exists a non-interactive mode for adding organization IDs, where you can provide the organization ID value, name, and optionally activate the organization ID. ```bash -$ censys config org-id -a # list all org IDs +$ censys config org-id # view/manage org IDs (interactive TUI) $ censys config org-id add --value "uuid" --name "production" # add an org ID (non-interactive) $ censys config org-id add --value-file orgid.txt --name "production" # add from file $ censys config org-id activate # activate a specific org ID by ID $ censys config org-id delete # delete an org ID by ID ``` +#### Flags for `config org-id` + +**`--accessible`, `-a`**: Enable accessible mode (non-redrawing). This disables animations and screen updates that may not work well with screen readers or certain terminal configurations. + +#### Flags for `config org-id add` + +**`--value`**: The organization ID value (UUID format, for non-interactive mode). + +**`--value-file`**: Read the organization ID value from a file, or use `-` to read from stdin. + +**`--name`, `-n`**: A friendly name/description for this organization ID. **Default:** `"ci"` + +**`--activate`**: Mark the added organization ID as active immediately. **Default:** `true` + +**`--accessible`, `-a`**: Enable accessible mode (non-redrawing). **Note:** If no organization ID is configured, requests will use your free-user wallet by default. You can also override the active organization ID for individual commands using the `--org-id` flag. diff --git a/docs/commands/CREDITS.md b/docs/commands/CREDITS.md new file mode 100644 index 0000000..eb1f042 --- /dev/null +++ b/docs/commands/CREDITS.md @@ -0,0 +1,45 @@ +# Credits Command + +The `credits` command displays credit details for your free user Censys account. + +## Usage + +```bash +$ censys credits # show free user credits +``` + +## Description + +This command shows your personal free user credit balance and usage information. Free user credits are associated with your individual Censys account, not an organization. + +**Note:** This command only shows free user credits. If you want to see organization credits for a paid account, use [`censys org credits`](ORG.md#org-credits) instead. + +## Output Formats + +The `credits` command defaults to **`short`** output format, which displays results in a human-readable format. You can override this with the `--output-format` flag (or `-O`). + +**Default:** `short` +**Supported formats:** `json`, `yaml`, `tree`, `short` + +### Examples + +```bash +# Default: human-readable output +$ censys credits + +# JSON output +$ censys credits --output-format json +$ censys credits -O json + +# YAML output +$ censys credits --output-format yaml +``` + +## Free User vs Organization Credits + +| Command | Description | +|---------|-------------| +| `censys credits` | Shows free user credits for your personal account | +| `censys org credits` | Shows organization credits for paid accounts | + +If you have a paid organization account and want to check your organization's credit balance, use `censys org credits` instead. diff --git a/docs/commands/HISTORY.md b/docs/commands/HISTORY.md index 18818db..4789abd 100644 --- a/docs/commands/HISTORY.md +++ b/docs/commands/HISTORY.md @@ -2,6 +2,8 @@ The `history` command allows you to retrieve historical data for hosts, web properties, and certificates from the Censys Platform. This command provides time-series data showing how assets have changed over time. +**Note:** To retrieve certificate history, you must have access to the Threat Hunting module. + ![history](../../examples/history/history.gif) ## Usage @@ -18,7 +20,7 @@ The `history` command automatically detects the asset type based on the input fo ## Flags -This section describes the flags available for the `history` command. To see global flags and how they might affect this command, see the [global configuration docs](mdc:../GLOBAL_CONFIGURATION.md). +This section describes the flags available for the `history` command. To see global flags and how they might affect this command, see the [global configuration docs](../GLOBAL_CONFIGURATION.md). ### `--start`, `-s` @@ -84,6 +86,28 @@ Specify the organization ID to use for the request. This overrides the default o $ censys history 8.8.8.8 --org-id 00000000-0000-0000-0000-000000000001 ``` +## Output Formats + +The `history` command defaults to **`json`** output format (or the global config value). Unlike other commands, history only supports structured data formats. + +**Default:** `json` (or configured global default) +**Supported formats:** `json`, `yaml`, `ndjson`, `tree` + +**Note:** The `short` and `template` output formats are **not supported** for the history command due to the time-series nature of the data. + +### Examples + +```bash +# Default: JSON output +$ censys history 8.8.8.8 --duration 30d + +# YAML output +$ censys history 8.8.8.8 --duration 30d --output-format yaml + +# NDJSON output (one event per line) +$ censys history 8.8.8.8 --duration 30d --output-format ndjson +``` + ## Output Format The `history` command outputs raw JSON arrays containing the historical information. The structure varies by asset type: diff --git a/docs/commands/ORG.md b/docs/commands/ORG.md new file mode 100644 index 0000000..d27aa1c --- /dev/null +++ b/docs/commands/ORG.md @@ -0,0 +1,85 @@ +# Org Command + +The `org` command allows you to manage and view organization details including credits, members, and organization information. + +## Usage + +```bash +$ censys org credits # display credit details for your organization +$ censys org details # display organization details +$ censys org members # list organization members +``` + +By default, these commands use your stored organization ID. If no organization ID is stored, or you want to query a different organization, use the `--org-id` flag on each subcommand. + +To set your default organization ID, run: `censys config org-id add` + +## Subcommands + +### `org credits` + +Display credit details for your organization, including credit balance, auto-replenish configuration, and any credit expirations. + +```bash +$ censys org credits # show credits for your stored organization +$ censys org credits --org-id # show credits for a specific organization +$ censys org credits --output-format json # output as JSON +``` + +#### Flags + +**`--org-id`, `-o`**: Override the configured organization ID. + +**Type:** `string` (UUID format) +**Default:** Uses the configured organization ID + +### `org details` + +Display details about your organization, including name, ID, creation date, and member counts. + +```bash +$ censys org details # show details for your stored organization +$ censys org details --org-id # show details for a specific organization +$ censys org details --output-format json # output as JSON +``` + +#### Flags + +**`--org-id`, `-o`**: Override the configured organization ID. + +**Type:** `string` (UUID format) +**Default:** Uses the configured organization ID + +### `org members` + +List all members in your organization, including their email, name, roles, and last login time. + +```bash +$ censys org members # list members for your stored organization +$ censys org members --interactive # list members in an interactive table +$ censys org members --org-id # list members for a specific organization +$ censys org members --output-format json # output as JSON +``` + +#### Flags + +**`--org-id`, `-o`**: Override the configured organization ID. + +**Type:** `string` (UUID format) +**Default:** Uses the configured organization ID + +**`--interactive`, `-i`**: Display results in an interactive table (TUI) that allows you to navigate through the member list. + +**Type:** `boolean` +**Default:** `false` + +## Output Formats + +The `org` subcommands default to **`short`** output format, which displays results in a human-readable format. You can override this with the `--output-format` flag (or `-O`). + +**Default:** `short` +**Supported formats:** `json`, `yaml`, `tree`, `short` + +## Note on Free User Credits + +The `org credits` command shows organization credits for paid accounts. If you want to see your free user credits instead, use the [`censys credits`](CREDITS.md) command. diff --git a/docs/commands/SEARCH.md b/docs/commands/SEARCH.md index a629847..68c8da5 100644 --- a/docs/commands/SEARCH.md +++ b/docs/commands/SEARCH.md @@ -84,17 +84,77 @@ $ censys search "services.service_name: HTTP" --max-pages -1 # fetch all result **Note:** Using `--max-pages -1` will fetch all available results, which may result in many API calls and take considerable time depending on the query. -### `--short`, `-s` +## Output Formats -Render output using templates for a concise, human-readable summary instead of raw output. See the [using templates](#using-templates) section for more details. +The `search` command defaults to **`json`** output format (or the global config value). You can override this with the `--output-format` flag (or `-O`). -**Type:** `boolean` -**Default:** `false` +**Default:** `json` (or configured global default) +**Supported formats:** `json`, `yaml`, `tree`, `short`, `template` + +### Format Descriptions + +- **`json`** - Structured JSON output (default) +- **`yaml`** - Structured YAML output +- **`tree`** - Hierarchical tree view +- **`short`** - Concise summary view of search results +- **`template`** - Render using Handlebars templates + +### Examples + +```bash +# Default: JSON output +$ censys search "services.port: 443" + +# Short format: concise summary +$ censys search "services.port: 443" --output-format short +$ censys search "services.port: 443" -O short + +# Template format: custom Handlebars rendering +$ censys search "services.port: 443" --output-format template + +# YAML output +$ censys search "services.port: 443" --output-format yaml +``` + +For more information on customizing templates, see the [view command templates documentation](VIEW.md#templates). + +## Streaming Output + +When using `--streaming` (or `-S`), results are **streamed immediately** as NDJSON (newline-delimited JSON) as they are fetched from the API. This provides several benefits for large result sets: + +- **Output begins before all pages are fetched** - You see results as soon as the first page is retrieved +- **Memory usage stays bounded** - Results are written immediately rather than accumulated in memory +- **Partial results are preserved on interruption** - If you press Ctrl-C or an error occurs, all previously emitted records remain intact +- **Safe for large queries** - Ideal for use with `--max-pages -1` when fetching potentially unbounded result sets + +### Streaming Example ```bash -$ censys search "services.port: 443" --short +# Stream all SSH hosts to a file +$ censys search "services.port: 22" --max-pages -1 --streaming > ssh_hosts.jsonl + +# Process results as they arrive using jq +$ censys search "services.port: 443" --max-pages 10 -S | jq -r '.host.ip' + +# Count results without storing them all in memory +$ censys search "services.service_name: HTTP" --max-pages -1 -S | wc -l ``` +### When to Use Streaming + +Use `--streaming` when: +- Fetching many pages of results (`--max-pages -1` or large values) +- Piping output to other tools that process line-by-line +- Writing large result sets to files +- You want to see results as they arrive rather than waiting for completion + +Use other formats (`json`, `yaml`, `tree`) when: +- Working with small, bounded result sets +- You need the complete data structure (e.g., for JSON array processing) +- Displaying results in a terminal for human reading + +**Note:** `--streaming` cannot be used together with `--output-format`. You can also enable streaming globally by setting `streaming: true` in your config file. + ## Configuration You can set default values for pagination flags in your [configuration file](../GLOBAL_CONFIGURATION.md#configuration-file): diff --git a/docs/commands/VIEW.md b/docs/commands/VIEW.md index 3a500df..ccf6464 100644 --- a/docs/commands/VIEW.md +++ b/docs/commands/VIEW.md @@ -89,28 +89,63 @@ View data as of a specific point in time. The timestamp must be in RFC3339 forma $ censys view 8.8.8.8 --at-time 2025-09-15T14:30:00Z ``` -### `--short`, `-s` +## Output Formats -Render output using templates for a concise, human-readable summary instead of raw output. See the [using templates](#using-templates) section for more details. +The `view` command defaults to **`json`** output format (or the global config value). You can override this with the `--output-format` flag (or `-O`). -**Type:** `boolean` -**Default:** `false` +**Default:** `json` (or configured global default) +**Supported formats:** `json`, `yaml`, `tree`, `short`, `template` + +### Format Descriptions + +- **`json`** - Structured JSON output (default) +- **`yaml`** - Structured YAML output +- **`tree`** - Hierarchical tree view +- **`short`** - Concise summary view of assets +- **`template`** - Render using asset-specific Handlebars templates (see [Templates](#templates) section) + +### Streaming Output + +Use `--streaming` (or `-S`) to enable streaming mode, which outputs results as NDJSON (newline-delimited JSON) with one asset per line emitted immediately as data is fetched. This is useful when viewing many assets at once. See [global configuration](../GLOBAL_CONFIGURATION.md#--streaming--s) for more details. + +### Examples ```bash -$ censys view 8.8.8.8 --short +# Default: JSON output +$ censys view 8.8.8.8 + +# Short format: concise summary +$ censys view 8.8.8.8 --output-format short +$ censys view 8.8.8.8 -O short + +# Template format: custom Handlebars rendering +$ censys view 8.8.8.8 --output-format template +$ censys view example.com:443 --output-format template + +# YAML output +$ censys view 8.8.8.8 --output-format yaml ``` ## Templates -`cencli` supports enables template-based rendering of raw data, which allows you to define how you view your data. Templates are powered by **Handlebars v3** (via the [raymond](https://github.com/aymerick/raymond) Handlebars implementation). +`cencli` supports template-based rendering of raw data using the `--output-format template` flag, which allows you to define how you view your data. Templates are powered by **Handlebars v3** (via the [raymond](https://github.com/aymerick/raymond) Handlebars implementation). ![view-short](../../examples/view/view-short.gif) -When you first run `cencli`, default templates can be found in the configuration directory (typically `~/.config/censys/templates/`) and are automatically created with sensible defaults on first use. Each asset type has its own template: +When you first run `cencli`, default templates can be found in the configuration directory (typically `~/.config/cencli/templates/`) and are automatically created with sensible defaults on first use. Each asset type has its own template: + +- **Host:** `host.hbs` (used by `view` command) +- **Certificate:** `certificate.hbs` (used by `view` command) +- **Web Property:** `webproperty.hbs` (used by `view` command) +- **Search Result:** `searchresult.hbs` (used by `search` command) -- **Host:** `host.hbs` -- **Certificate:** `certificate.hbs` -- **Web Property:** `webproperty.hbs` +To use templates, specify `--output-format template` (or `-O template`) when running the `view` or `search` commands: + +```bash +$ censys view 8.8.8.8 --output-format template +$ censys view example.com:443 -O template +$ censys search "services.port: 443" -O template +``` ### Customizing Templates diff --git a/examples/aggregate/aggregate.gif b/examples/aggregate/aggregate.gif index dce1f9b..4c27062 100644 Binary files a/examples/aggregate/aggregate.gif and b/examples/aggregate/aggregate.gif differ diff --git a/examples/cencli.gif b/examples/cencli.gif index 0a3b085..5d5a919 100644 Binary files a/examples/cencli.gif and b/examples/cencli.gif differ diff --git a/examples/censeye/censeye-interactive.gif b/examples/censeye/censeye-interactive.gif index 8605fc1..380d804 100644 Binary files a/examples/censeye/censeye-interactive.gif and b/examples/censeye/censeye-interactive.gif differ diff --git a/examples/censeye/censeye-json.gif b/examples/censeye/censeye-json.gif new file mode 100644 index 0000000..aefebf3 Binary files /dev/null and b/examples/censeye/censeye-json.gif differ diff --git a/examples/censeye/censeye.gif b/examples/censeye/censeye.gif index 60951b4..5958630 100644 Binary files a/examples/censeye/censeye.gif and b/examples/censeye/censeye.gif differ diff --git a/examples/history/history.gif b/examples/history/history.gif index 5ef121a..b86e2df 100644 Binary files a/examples/history/history.gif and b/examples/history/history.gif differ diff --git a/examples/search/search.gif b/examples/search/search.gif index 22e8cb4..06df088 100644 Binary files a/examples/search/search.gif and b/examples/search/search.gif differ diff --git a/examples/view/view-short.gif b/examples/view/view-short.gif index 0879a9b..946e266 100644 Binary files a/examples/view/view-short.gif and b/examples/view/view-short.gif differ diff --git a/examples/view/view.gif b/examples/view/view.gif index 8af6a46..64ac42f 100644 Binary files a/examples/view/view.gif and b/examples/view/view.gif differ diff --git a/gen/app/credits/mocks/creditservice_mock.go b/gen/app/credits/mocks/creditservice_mock.go new file mode 100644 index 0000000..038e640 --- /dev/null +++ b/gen/app/credits/mocks/creditservice_mock.go @@ -0,0 +1,74 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/censys/cencli/internal/app/credits (interfaces: Service) +// +// Generated by this command: +// +// mockgen -destination=../../../gen/app/credits/mocks/creditservice_mock.go -package=mocks -mock_names Service=MockCreditsService . Service +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + credits "github.com/censys/cencli/internal/app/credits" + cenclierrors "github.com/censys/cencli/internal/pkg/cenclierrors" + identifiers "github.com/censys/cencli/internal/pkg/domain/identifiers" + gomock "go.uber.org/mock/gomock" +) + +// MockCreditsService is a mock of Service interface. +type MockCreditsService struct { + ctrl *gomock.Controller + recorder *MockCreditsServiceMockRecorder + isgomock struct{} +} + +// MockCreditsServiceMockRecorder is the mock recorder for MockCreditsService. +type MockCreditsServiceMockRecorder struct { + mock *MockCreditsService +} + +// NewMockCreditsService creates a new mock instance. +func NewMockCreditsService(ctrl *gomock.Controller) *MockCreditsService { + mock := &MockCreditsService{ctrl: ctrl} + mock.recorder = &MockCreditsServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockCreditsService) EXPECT() *MockCreditsServiceMockRecorder { + return m.recorder +} + +// GetOrganizationCreditDetails mocks base method. +func (m *MockCreditsService) GetOrganizationCreditDetails(ctx context.Context, orgID identifiers.OrganizationID) (credits.OrganizationCreditDetailsResult, cenclierrors.CencliError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOrganizationCreditDetails", ctx, orgID) + ret0, _ := ret[0].(credits.OrganizationCreditDetailsResult) + ret1, _ := ret[1].(cenclierrors.CencliError) + return ret0, ret1 +} + +// GetOrganizationCreditDetails indicates an expected call of GetOrganizationCreditDetails. +func (mr *MockCreditsServiceMockRecorder) GetOrganizationCreditDetails(ctx, orgID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationCreditDetails", reflect.TypeOf((*MockCreditsService)(nil).GetOrganizationCreditDetails), ctx, orgID) +} + +// GetUserCreditDetails mocks base method. +func (m *MockCreditsService) GetUserCreditDetails(ctx context.Context) (credits.UserCreditDetailsResult, cenclierrors.CencliError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserCreditDetails", ctx) + ret0, _ := ret[0].(credits.UserCreditDetailsResult) + ret1, _ := ret[1].(cenclierrors.CencliError) + return ret0, ret1 +} + +// GetUserCreditDetails indicates an expected call of GetUserCreditDetails. +func (mr *MockCreditsServiceMockRecorder) GetUserCreditDetails(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserCreditDetails", reflect.TypeOf((*MockCreditsService)(nil).GetUserCreditDetails), ctx) +} diff --git a/gen/app/organizations/mocks/organizationservice_mock.go b/gen/app/organizations/mocks/organizationservice_mock.go new file mode 100644 index 0000000..11415e7 --- /dev/null +++ b/gen/app/organizations/mocks/organizationservice_mock.go @@ -0,0 +1,75 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/censys/cencli/internal/app/organizations (interfaces: Service) +// +// Generated by this command: +// +// mockgen -destination=../../../gen/app/organizations/mocks/organizationservice_mock.go -package=mocks -mock_names Service=MockOrganizationsService . Service +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + organizations "github.com/censys/cencli/internal/app/organizations" + cenclierrors "github.com/censys/cencli/internal/pkg/cenclierrors" + identifiers "github.com/censys/cencli/internal/pkg/domain/identifiers" + mo "github.com/samber/mo" + gomock "go.uber.org/mock/gomock" +) + +// MockOrganizationsService is a mock of Service interface. +type MockOrganizationsService struct { + ctrl *gomock.Controller + recorder *MockOrganizationsServiceMockRecorder + isgomock struct{} +} + +// MockOrganizationsServiceMockRecorder is the mock recorder for MockOrganizationsService. +type MockOrganizationsServiceMockRecorder struct { + mock *MockOrganizationsService +} + +// NewMockOrganizationsService creates a new mock instance. +func NewMockOrganizationsService(ctrl *gomock.Controller) *MockOrganizationsService { + mock := &MockOrganizationsService{ctrl: ctrl} + mock.recorder = &MockOrganizationsServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockOrganizationsService) EXPECT() *MockOrganizationsServiceMockRecorder { + return m.recorder +} + +// GetOrganizationDetails mocks base method. +func (m *MockOrganizationsService) GetOrganizationDetails(ctx context.Context, orgID identifiers.OrganizationID) (organizations.OrganizationDetailsResult, cenclierrors.CencliError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOrganizationDetails", ctx, orgID) + ret0, _ := ret[0].(organizations.OrganizationDetailsResult) + ret1, _ := ret[1].(cenclierrors.CencliError) + return ret0, ret1 +} + +// GetOrganizationDetails indicates an expected call of GetOrganizationDetails. +func (mr *MockOrganizationsServiceMockRecorder) GetOrganizationDetails(ctx, orgID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationDetails", reflect.TypeOf((*MockOrganizationsService)(nil).GetOrganizationDetails), ctx, orgID) +} + +// ListOrganizationMembers mocks base method. +func (m *MockOrganizationsService) ListOrganizationMembers(ctx context.Context, orgID identifiers.OrganizationID, pageSize, maxPages mo.Option[uint]) (organizations.OrganizationMembersResult, cenclierrors.CencliError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListOrganizationMembers", ctx, orgID, pageSize, maxPages) + ret0, _ := ret[0].(organizations.OrganizationMembersResult) + ret1, _ := ret[1].(cenclierrors.CencliError) + return ret0, ret1 +} + +// ListOrganizationMembers indicates an expected call of ListOrganizationMembers. +func (mr *MockOrganizationsServiceMockRecorder) ListOrganizationMembers(ctx, orgID, pageSize, maxPages any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListOrganizationMembers", reflect.TypeOf((*MockOrganizationsService)(nil).ListOrganizationMembers), ctx, orgID, pageSize, maxPages) +} diff --git a/gen/client/mocks/accountmanagement_mock.go b/gen/client/mocks/accountmanagement_mock.go new file mode 100644 index 0000000..0bfcee1 --- /dev/null +++ b/gen/client/mocks/accountmanagement_mock.go @@ -0,0 +1,104 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/censys/cencli/internal/pkg/clients/censys (interfaces: AccountManagementClient) +// +// Generated by this command: +// +// mockgen -destination=../../../../gen/client/mocks/accountmanagement_mock.go -package=mocks github.com/censys/cencli/internal/pkg/clients/censys AccountManagementClient +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + censys "github.com/censys/cencli/internal/pkg/clients/censys" + components "github.com/censys/censys-sdk-go/models/components" + mo "github.com/samber/mo" + gomock "go.uber.org/mock/gomock" +) + +// MockAccountManagementClient is a mock of AccountManagementClient interface. +type MockAccountManagementClient struct { + ctrl *gomock.Controller + recorder *MockAccountManagementClientMockRecorder + isgomock struct{} +} + +// MockAccountManagementClientMockRecorder is the mock recorder for MockAccountManagementClient. +type MockAccountManagementClientMockRecorder struct { + mock *MockAccountManagementClient +} + +// NewMockAccountManagementClient creates a new mock instance. +func NewMockAccountManagementClient(ctrl *gomock.Controller) *MockAccountManagementClient { + mock := &MockAccountManagementClient{ctrl: ctrl} + mock.recorder = &MockAccountManagementClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAccountManagementClient) EXPECT() *MockAccountManagementClientMockRecorder { + return m.recorder +} + +// GetOrganizationCreditDetails mocks base method. +func (m *MockAccountManagementClient) GetOrganizationCreditDetails(ctx context.Context, orgID string) (censys.Result[components.OrganizationCredits], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOrganizationCreditDetails", ctx, orgID) + ret0, _ := ret[0].(censys.Result[components.OrganizationCredits]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// GetOrganizationCreditDetails indicates an expected call of GetOrganizationCreditDetails. +func (mr *MockAccountManagementClientMockRecorder) GetOrganizationCreditDetails(ctx, orgID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationCreditDetails", reflect.TypeOf((*MockAccountManagementClient)(nil).GetOrganizationCreditDetails), ctx, orgID) +} + +// GetOrganizationDetails mocks base method. +func (m *MockAccountManagementClient) GetOrganizationDetails(ctx context.Context, orgID string) (censys.Result[components.OrganizationDetails], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOrganizationDetails", ctx, orgID) + ret0, _ := ret[0].(censys.Result[components.OrganizationDetails]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// GetOrganizationDetails indicates an expected call of GetOrganizationDetails. +func (mr *MockAccountManagementClientMockRecorder) GetOrganizationDetails(ctx, orgID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationDetails", reflect.TypeOf((*MockAccountManagementClient)(nil).GetOrganizationDetails), ctx, orgID) +} + +// GetUserCreditDetails mocks base method. +func (m *MockAccountManagementClient) GetUserCreditDetails(ctx context.Context) (censys.Result[components.UserCredits], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserCreditDetails", ctx) + ret0, _ := ret[0].(censys.Result[components.UserCredits]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// GetUserCreditDetails indicates an expected call of GetUserCreditDetails. +func (mr *MockAccountManagementClientMockRecorder) GetUserCreditDetails(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserCreditDetails", reflect.TypeOf((*MockAccountManagementClient)(nil).GetUserCreditDetails), ctx) +} + +// ListOrganizationMembers mocks base method. +func (m *MockAccountManagementClient) ListOrganizationMembers(ctx context.Context, orgID string, pageSize mo.Option[int], pageToken mo.Option[string]) (censys.Result[components.OrganizationMembersList], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListOrganizationMembers", ctx, orgID, pageSize, pageToken) + ret0, _ := ret[0].(censys.Result[components.OrganizationMembersList]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// ListOrganizationMembers indicates an expected call of ListOrganizationMembers. +func (mr *MockAccountManagementClientMockRecorder) ListOrganizationMembers(ctx, orgID, pageSize, pageToken any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListOrganizationMembers", reflect.TypeOf((*MockAccountManagementClient)(nil).ListOrganizationMembers), ctx, orgID, pageSize, pageToken) +} diff --git a/gen/client/mocks/censys_client_mock.go b/gen/client/mocks/censys_client_mock.go index cd2b4c3..9b5a042 100644 --- a/gen/client/mocks/censys_client_mock.go +++ b/gen/client/mocks/censys_client_mock.go @@ -119,6 +119,51 @@ func (mr *MockClientMockRecorder) GetHosts(ctx, orgID, hostIDs, atTime any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHosts", reflect.TypeOf((*MockClient)(nil).GetHosts), ctx, orgID, hostIDs, atTime) } +// GetOrganizationCreditDetails mocks base method. +func (m *MockClient) GetOrganizationCreditDetails(ctx context.Context, orgID string) (censys.Result[components.OrganizationCredits], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOrganizationCreditDetails", ctx, orgID) + ret0, _ := ret[0].(censys.Result[components.OrganizationCredits]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// GetOrganizationCreditDetails indicates an expected call of GetOrganizationCreditDetails. +func (mr *MockClientMockRecorder) GetOrganizationCreditDetails(ctx, orgID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationCreditDetails", reflect.TypeOf((*MockClient)(nil).GetOrganizationCreditDetails), ctx, orgID) +} + +// GetOrganizationDetails mocks base method. +func (m *MockClient) GetOrganizationDetails(ctx context.Context, orgID string) (censys.Result[components.OrganizationDetails], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOrganizationDetails", ctx, orgID) + ret0, _ := ret[0].(censys.Result[components.OrganizationDetails]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// GetOrganizationDetails indicates an expected call of GetOrganizationDetails. +func (mr *MockClientMockRecorder) GetOrganizationDetails(ctx, orgID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationDetails", reflect.TypeOf((*MockClient)(nil).GetOrganizationDetails), ctx, orgID) +} + +// GetUserCreditDetails mocks base method. +func (m *MockClient) GetUserCreditDetails(ctx context.Context) (censys.Result[components.UserCredits], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserCreditDetails", ctx) + ret0, _ := ret[0].(censys.Result[components.UserCredits]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// GetUserCreditDetails indicates an expected call of GetUserCreditDetails. +func (mr *MockClientMockRecorder) GetUserCreditDetails(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserCreditDetails", reflect.TypeOf((*MockClient)(nil).GetUserCreditDetails), ctx) +} + // GetValueCounts mocks base method. func (m *MockClient) GetValueCounts(ctx context.Context, orgID, query mo.Option[string], andCountConditions []components.CountCondition) (censys.Result[components.ValueCountsResponse], censys.ClientError) { m.ctrl.T.Helper() @@ -178,6 +223,21 @@ func (mr *MockClientMockRecorder) HostTimeline(ctx, orgID, hostID, fromTime, toT return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HostTimeline", reflect.TypeOf((*MockClient)(nil).HostTimeline), ctx, orgID, hostID, fromTime, toTime) } +// ListOrganizationMembers mocks base method. +func (m *MockClient) ListOrganizationMembers(ctx context.Context, orgID string, pageSize mo.Option[int], pageToken mo.Option[string]) (censys.Result[components.OrganizationMembersList], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListOrganizationMembers", ctx, orgID, pageSize, pageToken) + ret0, _ := ret[0].(censys.Result[components.OrganizationMembersList]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// ListOrganizationMembers indicates an expected call of ListOrganizationMembers. +func (mr *MockClientMockRecorder) ListOrganizationMembers(ctx, orgID, pageSize, pageToken any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListOrganizationMembers", reflect.TypeOf((*MockClient)(nil).ListOrganizationMembers), ctx, orgID, pageSize, pageToken) +} + // Search mocks base method. func (m *MockClient) Search(ctx context.Context, orgID mo.Option[string], query string, fields []string, pageSize mo.Option[int64], pageToken mo.Option[string]) (censys.Result[components.SearchQueryResponse], censys.ClientError) { m.ctrl.T.Helper() diff --git a/go.mod b/go.mod index 9e81dd1..e9dbfcf 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.1 require ( github.com/aymerick/raymond v2.0.2+incompatible - github.com/censys/censys-sdk-go v0.22.3 + github.com/censys/censys-sdk-go v0.25.2 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/huh v0.7.0 diff --git a/go.sum b/go.sum index 6f5216e..6681980 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,8 @@ github.com/aymerick/raymond v2.0.2+incompatible h1:VEp3GpgdAnv9B2GFyTvqgcKvY+mfK github.com/aymerick/raymond v2.0.2+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/censys/censys-sdk-go v0.22.3 h1:CuTV5pS9HhUmrDuKa+qTnD+kCsfqfA8lqZllAZjSw2o= -github.com/censys/censys-sdk-go v0.22.3/go.mod h1:vyRClQGsBluBX6rSJoHhUn9LQMWtHpNiCXB+aZfgBqI= +github.com/censys/censys-sdk-go v0.25.2 h1:mTTpO75XX2nzsOrHIYiAEO5QTqIO2BDz2aXwS6fvd3U= +github.com/censys/censys-sdk-go v0.25.2/go.mod h1:vyRClQGsBluBX6rSJoHhUn9LQMWtHpNiCXB+aZfgBqI= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= diff --git a/internal/app/credits/dto.go b/internal/app/credits/dto.go new file mode 100644 index 0000000..74456f7 --- /dev/null +++ b/internal/app/credits/dto.go @@ -0,0 +1,86 @@ +package credits + +import ( + "time" + + "github.com/censys/censys-sdk-go/models/components" + "github.com/samber/mo" + + "github.com/censys/cencli/internal/pkg/domain/responsemeta" +) + +type OrganizationCreditDetailsResult struct { + Meta *responsemeta.ResponseMeta + Data OrganizationCreditDetails +} + +type OrganizationCreditDetails struct { + Balance int64 `json:"balance"` + CreditExpirations []CreditExpiration `json:"credit_expirations"` + AutoReplenishConfig AutoReplenishConfig `json:"auto_replenish_config"` +} + +type CreditExpiration struct { + Balance int64 `json:"balance"` + CreationDate mo.Option[time.Time] `json:"creation_date,omitzero"` + ExpirationDate mo.Option[time.Time] `json:"expiration_date,omitzero"` +} + +type AutoReplenishConfig struct { + Enabled bool `json:"enabled"` + Threshold mo.Option[int64] `json:"threshold,omitzero"` + Amount mo.Option[int64] `json:"amount,omitzero"` +} + +func parseOrganizationCreditDetails(credits *components.OrganizationCredits) OrganizationCreditDetails { + autoReplenishConfig := AutoReplenishConfig{ + Enabled: credits.AutoReplenishConfig.Enabled, + } + if credits.GetAutoReplenishConfig().Threshold != nil { + autoReplenishConfig.Threshold = mo.Some(*credits.GetAutoReplenishConfig().Threshold) + } + if credits.GetAutoReplenishConfig().Amount != nil { + autoReplenishConfig.Amount = mo.Some(*credits.GetAutoReplenishConfig().Amount) + } + var creditExpirations []CreditExpiration + if len(credits.GetCreditExpirations()) > 0 { + creditExpirations = make([]CreditExpiration, 0, len(credits.GetCreditExpirations())) + for _, creditExpiration := range credits.GetCreditExpirations() { + ce := CreditExpiration{ + Balance: creditExpiration.Balance, + } + if creditExpiration.CreatedAt != nil { + ce.CreationDate = mo.Some(*creditExpiration.CreatedAt) + } + if creditExpiration.ExpiresAt != nil { + ce.ExpirationDate = mo.Some(*creditExpiration.ExpiresAt) + } + creditExpirations = append(creditExpirations, ce) + } + } + return OrganizationCreditDetails{ + Balance: credits.Balance, + CreditExpirations: creditExpirations, + AutoReplenishConfig: autoReplenishConfig, + } +} + +type UserCreditDetailsResult struct { + Meta *responsemeta.ResponseMeta + Data UserCreditDetails +} + +type UserCreditDetails struct { + Balance int64 `json:"balance"` + ResetsAt mo.Option[time.Time] `json:"resets_at,omitzero"` +} + +func parseUserCreditDetails(credits *components.UserCredits) UserCreditDetails { + ucd := UserCreditDetails{ + Balance: credits.Balance, + } + if credits.ResetsAt != nil { + ucd.ResetsAt = mo.Some(*credits.ResetsAt) + } + return ucd +} diff --git a/internal/app/credits/service.go b/internal/app/credits/service.go new file mode 100644 index 0000000..1e9dd02 --- /dev/null +++ b/internal/app/credits/service.go @@ -0,0 +1,58 @@ +package credits + +import ( + "context" + + "github.com/censys/cencli/internal/pkg/cenclierrors" + client "github.com/censys/cencli/internal/pkg/clients/censys" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/domain/responsemeta" +) + +//go:generate mockgen -destination=../../../gen/app/credits/mocks/creditservice_mock.go -package=mocks -mock_names Service=MockCreditsService . Service + +// Service provides credit details capabilities. +type Service interface { + // GetOrganizationCreditDetails retrieves the credit details for an organization. + GetOrganizationCreditDetails( + ctx context.Context, + orgID identifiers.OrganizationID, + ) (OrganizationCreditDetailsResult, cenclierrors.CencliError) + // GetUserCreditDetails retrieves the credit details for the current user. + GetUserCreditDetails( + ctx context.Context, + ) (UserCreditDetailsResult, cenclierrors.CencliError) +} + +type creditsService struct { + client client.Client +} + +func New(client client.Client) Service { + return &creditsService{client: client} +} + +func (s *creditsService) GetOrganizationCreditDetails( + ctx context.Context, + orgID identifiers.OrganizationID, +) (OrganizationCreditDetailsResult, cenclierrors.CencliError) { + res, err := s.client.GetOrganizationCreditDetails(ctx, orgID.String()) + if err != nil { + return OrganizationCreditDetailsResult{}, err + } + return OrganizationCreditDetailsResult{ + Meta: responsemeta.NewResponseMeta(res.Metadata.Request, res.Metadata.Response, res.Metadata.Latency, res.Metadata.Attempts), + Data: parseOrganizationCreditDetails(res.Data), + }, nil +} + +func (s *creditsService) GetUserCreditDetails(ctx context.Context) (UserCreditDetailsResult, cenclierrors.CencliError) { + res, err := s.client.GetUserCreditDetails(ctx) + if err != nil { + return UserCreditDetailsResult{}, err + } + return UserCreditDetailsResult{ + Meta: responsemeta.NewResponseMeta(res.Metadata.Request, res.Metadata.Response, res.Metadata.Latency, res.Metadata.Attempts), + Data: parseUserCreditDetails(res.Data), + }, nil +} diff --git a/internal/app/credits/service_test.go b/internal/app/credits/service_test.go new file mode 100644 index 0000000..887fc0d --- /dev/null +++ b/internal/app/credits/service_test.go @@ -0,0 +1,695 @@ +package credits + +import ( + "context" + "errors" + "net/http" + "testing" + "time" + + "github.com/censys/censys-sdk-go/models/components" + "github.com/censys/censys-sdk-go/models/sdkerrors" + "github.com/google/uuid" + "github.com/samber/mo" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/censys/cencli/gen/client/mocks" + "github.com/censys/cencli/internal/pkg/cenclierrors" + client "github.com/censys/cencli/internal/pkg/clients/censys" + "github.com/censys/cencli/internal/pkg/domain/identifiers" +) + +func TestCreditsService_GetOrganizationCreditDetails(t *testing.T) { + testCases := []struct { + name string + client func(ctrl *gomock.Controller) client.Client + orgID uuid.UUID + ctx func() context.Context + assert func(t *testing.T, res OrganizationCreditDetailsResult, err cenclierrors.CencliError) + }{ + { + name: "success - basic organization credits", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + mockClient.EXPECT().GetOrganizationCreditDetails( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + ).Return(client.Result[components.OrganizationCredits]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 100 * time.Millisecond, + Attempts: 1, + }, + Data: &components.OrganizationCredits{ + Balance: 1000, + CreditExpirations: []components.CreditExpiration{}, + AutoReplenishConfig: components.AutoReplenishConfig{ + Enabled: false, + }, + }, + }, nil) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationCreditDetailsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.NotNil(t, res.Meta) + require.Equal(t, 100*time.Millisecond, res.Meta.Latency) + require.Equal(t, int64(1000), res.Data.Balance) + require.Len(t, res.Data.CreditExpirations, 0) + require.False(t, res.Data.AutoReplenishConfig.Enabled) + require.False(t, res.Data.AutoReplenishConfig.Threshold.IsPresent()) + require.False(t, res.Data.AutoReplenishConfig.Amount.IsPresent()) + }, + }, + { + name: "success - with credit expirations", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + createdAt := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + expiresAt := time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC) + mockClient.EXPECT().GetOrganizationCreditDetails( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + ).Return(client.Result[components.OrganizationCredits]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 150 * time.Millisecond, + Attempts: 1, + }, + Data: &components.OrganizationCredits{ + Balance: 5000, + CreditExpirations: []components.CreditExpiration{ + { + Balance: 2000, + CreatedAt: &createdAt, + ExpiresAt: &expiresAt, + }, + { + Balance: 3000, + CreatedAt: &createdAt, + ExpiresAt: &expiresAt, + }, + }, + AutoReplenishConfig: components.AutoReplenishConfig{ + Enabled: false, + }, + }, + }, nil) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationCreditDetailsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.NotNil(t, res.Meta) + require.Equal(t, 150*time.Millisecond, res.Meta.Latency) + require.Equal(t, int64(5000), res.Data.Balance) + require.Len(t, res.Data.CreditExpirations, 2) + require.Equal(t, int64(2000), res.Data.CreditExpirations[0].Balance) + require.True(t, res.Data.CreditExpirations[0].CreationDate.IsPresent()) + require.True(t, res.Data.CreditExpirations[0].ExpirationDate.IsPresent()) + }, + }, + { + name: "success - with auto replenish config enabled", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + threshold := int64(100) + amount := int64(500) + mockClient.EXPECT().GetOrganizationCreditDetails( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + ).Return(client.Result[components.OrganizationCredits]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 100 * time.Millisecond, + Attempts: 1, + }, + Data: &components.OrganizationCredits{ + Balance: 2500, + CreditExpirations: []components.CreditExpiration{}, + AutoReplenishConfig: components.AutoReplenishConfig{ + Enabled: true, + Threshold: &threshold, + Amount: &amount, + }, + }, + }, nil) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationCreditDetailsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.NotNil(t, res.Meta) + require.Equal(t, int64(2500), res.Data.Balance) + require.True(t, res.Data.AutoReplenishConfig.Enabled) + require.True(t, res.Data.AutoReplenishConfig.Threshold.IsPresent()) + require.Equal(t, int64(100), res.Data.AutoReplenishConfig.Threshold.MustGet()) + require.True(t, res.Data.AutoReplenishConfig.Amount.IsPresent()) + require.Equal(t, int64(500), res.Data.AutoReplenishConfig.Amount.MustGet()) + }, + }, + { + name: "error - structured client error", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + detail := "Organization not found" + status := int64(404) + structuredErr := client.NewCensysClientStructuredError(&sdkerrors.ErrorModel{ + Detail: &detail, + Status: &status, + }) + mockClient.EXPECT().GetOrganizationCreditDetails( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + ).Return(client.Result[components.OrganizationCredits]{}, structuredErr) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationCreditDetailsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Equal(t, OrganizationCreditDetailsResult{}, res) + + var structuredErr client.ClientStructuredError + require.True(t, errors.As(err, &structuredErr)) + require.True(t, structuredErr.StatusCode().IsPresent()) + require.Equal(t, int64(404), structuredErr.StatusCode().MustGet()) + }, + }, + { + name: "error - generic client error", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + genericErr := client.NewCensysClientGenericError(&sdkerrors.SDKError{ + Message: "Internal server error", + StatusCode: 500, + Body: "Server temporarily unavailable", + }) + mockClient.EXPECT().GetOrganizationCreditDetails( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + ).Return(client.Result[components.OrganizationCredits]{}, genericErr) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationCreditDetailsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Equal(t, OrganizationCreditDetailsResult{}, res) + + var genericErr client.ClientGenericError + require.True(t, errors.As(err, &genericErr)) + require.Equal(t, int64(500), genericErr.StatusCode().MustGet()) + }, + }, + { + name: "error - unknown client error", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + unknownErr := client.NewClientError(errors.New("network timeout")) + mockClient.EXPECT().GetOrganizationCreditDetails( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + ).Return(client.Result[components.OrganizationCredits]{}, unknownErr) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationCreditDetailsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Equal(t, OrganizationCreditDetailsResult{}, res) + + var unknownErr client.ClientError + require.True(t, errors.As(err, &unknownErr)) + require.Contains(t, err.Error(), "network timeout") + }, + }, + { + name: "context cancellation - cancelled context", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + mockClient.EXPECT().GetOrganizationCreditDetails( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + ).DoAndReturn(func(ctx context.Context, orgID string) (client.Result[components.OrganizationCredits], client.ClientError) { + select { + case <-ctx.Done(): + return client.Result[components.OrganizationCredits]{}, client.NewClientError(ctx.Err()) + default: + t.Error("Expected context to be cancelled") + return client.Result[components.OrganizationCredits]{}, client.NewClientError(errors.New("context should have been cancelled")) + } + }) + return mockClient + }, + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx + }, + assert: func(t *testing.T, res OrganizationCreditDetailsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Equal(t, OrganizationCreditDetailsResult{}, res) + require.Contains(t, err.Error(), "the operation's context was cancelled before it completed") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + c := tc.client(ctrl) + svc := New(c) + + ctx := context.Background() + if tc.ctx != nil { + ctx = tc.ctx() + } + + res, err := svc.GetOrganizationCreditDetails(ctx, identifiers.NewOrganizationID(tc.orgID)) + tc.assert(t, res, err) + }) + } +} + +func TestCreditsService_GetUserCreditDetails(t *testing.T) { + testCases := []struct { + name string + client func(ctrl *gomock.Controller) client.Client + ctx func() context.Context + assert func(t *testing.T, res UserCreditDetailsResult, err cenclierrors.CencliError) + }{ + { + name: "success - basic user credits", + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + mockClient.EXPECT().GetUserCreditDetails( + gomock.Any(), + ).Return(client.Result[components.UserCredits]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 80 * time.Millisecond, + Attempts: 1, + }, + Data: &components.UserCredits{ + Balance: 500, + }, + }, nil) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res UserCreditDetailsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.NotNil(t, res.Meta) + require.Equal(t, 80*time.Millisecond, res.Meta.Latency) + require.Equal(t, int64(500), res.Data.Balance) + require.False(t, res.Data.ResetsAt.IsPresent()) + }, + }, + { + name: "success - with resets_at", + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + resetsAt := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC) + mockClient.EXPECT().GetUserCreditDetails( + gomock.Any(), + ).Return(client.Result[components.UserCredits]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 90 * time.Millisecond, + Attempts: 1, + }, + Data: &components.UserCredits{ + Balance: 1000, + ResetsAt: &resetsAt, + }, + }, nil) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res UserCreditDetailsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.NotNil(t, res.Meta) + require.Equal(t, 90*time.Millisecond, res.Meta.Latency) + require.Equal(t, int64(1000), res.Data.Balance) + require.True(t, res.Data.ResetsAt.IsPresent()) + require.Equal(t, time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC), res.Data.ResetsAt.MustGet()) + }, + }, + { + name: "success - zero balance", + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + mockClient.EXPECT().GetUserCreditDetails( + gomock.Any(), + ).Return(client.Result[components.UserCredits]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 50 * time.Millisecond, + Attempts: 1, + }, + Data: &components.UserCredits{ + Balance: 0, + }, + }, nil) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res UserCreditDetailsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.NotNil(t, res.Meta) + require.Equal(t, int64(0), res.Data.Balance) + }, + }, + { + name: "error - structured client error (unauthorized)", + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + detail := "Unauthorized access" + status := int64(401) + structuredErr := client.NewCensysClientStructuredError(&sdkerrors.ErrorModel{ + Detail: &detail, + Status: &status, + }) + mockClient.EXPECT().GetUserCreditDetails( + gomock.Any(), + ).Return(client.Result[components.UserCredits]{}, structuredErr) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res UserCreditDetailsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Equal(t, UserCreditDetailsResult{}, res) + + var structuredErr client.ClientStructuredError + require.True(t, errors.As(err, &structuredErr)) + require.True(t, structuredErr.StatusCode().IsPresent()) + require.Equal(t, int64(401), structuredErr.StatusCode().MustGet()) + }, + }, + { + name: "error - generic client error", + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + genericErr := client.NewCensysClientGenericError(&sdkerrors.SDKError{ + Message: "Service unavailable", + StatusCode: 503, + Body: "Try again later", + }) + mockClient.EXPECT().GetUserCreditDetails( + gomock.Any(), + ).Return(client.Result[components.UserCredits]{}, genericErr) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res UserCreditDetailsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Equal(t, UserCreditDetailsResult{}, res) + + var genericErr client.ClientGenericError + require.True(t, errors.As(err, &genericErr)) + require.Equal(t, int64(503), genericErr.StatusCode().MustGet()) + }, + }, + { + name: "error - unknown client error", + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + unknownErr := client.NewClientError(errors.New("connection refused")) + mockClient.EXPECT().GetUserCreditDetails( + gomock.Any(), + ).Return(client.Result[components.UserCredits]{}, unknownErr) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res UserCreditDetailsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Equal(t, UserCreditDetailsResult{}, res) + + var unknownErr client.ClientError + require.True(t, errors.As(err, &unknownErr)) + require.Contains(t, err.Error(), "connection refused") + }, + }, + { + name: "context cancellation - cancelled context", + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + mockClient.EXPECT().GetUserCreditDetails( + gomock.Any(), + ).DoAndReturn(func(ctx context.Context) (client.Result[components.UserCredits], client.ClientError) { + select { + case <-ctx.Done(): + return client.Result[components.UserCredits]{}, client.NewClientError(ctx.Err()) + default: + t.Error("Expected context to be cancelled") + return client.Result[components.UserCredits]{}, client.NewClientError(errors.New("context should have been cancelled")) + } + }) + return mockClient + }, + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx + }, + assert: func(t *testing.T, res UserCreditDetailsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Equal(t, UserCreditDetailsResult{}, res) + require.Contains(t, err.Error(), "the operation's context was cancelled before it completed") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + c := tc.client(ctrl) + svc := New(c) + + ctx := context.Background() + if tc.ctx != nil { + ctx = tc.ctx() + } + + res, err := svc.GetUserCreditDetails(ctx) + tc.assert(t, res, err) + }) + } +} + +// TestParseOrganizationCreditDetails tests the organization credit details parsing functionality +func TestParseOrganizationCreditDetails(t *testing.T) { + testCases := []struct { + name string + input *components.OrganizationCredits + expected OrganizationCreditDetails + }{ + { + name: "empty credit expirations and disabled auto replenish", + input: &components.OrganizationCredits{ + Balance: 1000, + CreditExpirations: []components.CreditExpiration{}, + AutoReplenishConfig: components.AutoReplenishConfig{ + Enabled: false, + }, + }, + expected: OrganizationCreditDetails{ + Balance: 1000, + CreditExpirations: nil, + AutoReplenishConfig: AutoReplenishConfig{ + Enabled: false, + }, + }, + }, + { + name: "with credit expirations and dates", + input: func() *components.OrganizationCredits { + createdAt := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + expiresAt := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC) + return &components.OrganizationCredits{ + Balance: 5000, + CreditExpirations: []components.CreditExpiration{ + { + Balance: 2500, + CreatedAt: &createdAt, + ExpiresAt: &expiresAt, + }, + }, + AutoReplenishConfig: components.AutoReplenishConfig{ + Enabled: false, + }, + } + }(), + expected: func() OrganizationCreditDetails { + return OrganizationCreditDetails{ + Balance: 5000, + CreditExpirations: []CreditExpiration{ + { + Balance: 2500, + CreationDate: mo.Some(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)), + ExpirationDate: mo.Some(time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)), + }, + }, + AutoReplenishConfig: AutoReplenishConfig{ + Enabled: false, + }, + } + }(), + }, + { + name: "with auto replenish enabled", + input: func() *components.OrganizationCredits { + threshold := int64(100) + amount := int64(1000) + return &components.OrganizationCredits{ + Balance: 3000, + CreditExpirations: []components.CreditExpiration{}, + AutoReplenishConfig: components.AutoReplenishConfig{ + Enabled: true, + Threshold: &threshold, + Amount: &amount, + }, + } + }(), + expected: func() OrganizationCreditDetails { + return OrganizationCreditDetails{ + Balance: 3000, + CreditExpirations: nil, + AutoReplenishConfig: AutoReplenishConfig{ + Enabled: true, + Threshold: mo.Some(int64(100)), + Amount: mo.Some(int64(1000)), + }, + } + }(), + }, + { + name: "credit expiration without dates", + input: &components.OrganizationCredits{ + Balance: 1500, + CreditExpirations: []components.CreditExpiration{ + { + Balance: 1500, + }, + }, + AutoReplenishConfig: components.AutoReplenishConfig{ + Enabled: false, + }, + }, + expected: OrganizationCreditDetails{ + Balance: 1500, + CreditExpirations: []CreditExpiration{ + { + Balance: 1500, + }, + }, + AutoReplenishConfig: AutoReplenishConfig{ + Enabled: false, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := parseOrganizationCreditDetails(tc.input) + require.Equal(t, tc.expected.Balance, result.Balance) + require.Equal(t, tc.expected.AutoReplenishConfig.Enabled, result.AutoReplenishConfig.Enabled) + require.Equal(t, tc.expected.AutoReplenishConfig.Threshold.IsPresent(), result.AutoReplenishConfig.Threshold.IsPresent()) + require.Equal(t, tc.expected.AutoReplenishConfig.Amount.IsPresent(), result.AutoReplenishConfig.Amount.IsPresent()) + if tc.expected.AutoReplenishConfig.Threshold.IsPresent() { + require.Equal(t, tc.expected.AutoReplenishConfig.Threshold.MustGet(), result.AutoReplenishConfig.Threshold.MustGet()) + } + if tc.expected.AutoReplenishConfig.Amount.IsPresent() { + require.Equal(t, tc.expected.AutoReplenishConfig.Amount.MustGet(), result.AutoReplenishConfig.Amount.MustGet()) + } + require.Len(t, result.CreditExpirations, len(tc.expected.CreditExpirations)) + for i, ce := range tc.expected.CreditExpirations { + require.Equal(t, ce.Balance, result.CreditExpirations[i].Balance) + require.Equal(t, ce.CreationDate.IsPresent(), result.CreditExpirations[i].CreationDate.IsPresent()) + require.Equal(t, ce.ExpirationDate.IsPresent(), result.CreditExpirations[i].ExpirationDate.IsPresent()) + } + }) + } +} + +// TestParseUserCreditDetails tests the user credit details parsing functionality +func TestParseUserCreditDetails(t *testing.T) { + testCases := []struct { + name string + input *components.UserCredits + expected UserCreditDetails + }{ + { + name: "basic balance without resets_at", + input: &components.UserCredits{ + Balance: 500, + }, + expected: UserCreditDetails{ + Balance: 500, + }, + }, + { + name: "with resets_at", + input: func() *components.UserCredits { + resetsAt := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC) + return &components.UserCredits{ + Balance: 1000, + ResetsAt: &resetsAt, + } + }(), + expected: func() UserCreditDetails { + return UserCreditDetails{ + Balance: 1000, + ResetsAt: mo.Some(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)), + } + }(), + }, + { + name: "zero balance", + input: &components.UserCredits{ + Balance: 0, + }, + expected: UserCreditDetails{ + Balance: 0, + }, + }, + { + name: "large balance", + input: &components.UserCredits{ + Balance: 9223372036854775807, + }, + expected: UserCreditDetails{ + Balance: 9223372036854775807, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := parseUserCreditDetails(tc.input) + require.Equal(t, tc.expected.Balance, result.Balance) + require.Equal(t, tc.expected.ResetsAt.IsPresent(), result.ResetsAt.IsPresent()) + if tc.expected.ResetsAt.IsPresent() { + require.Equal(t, tc.expected.ResetsAt.MustGet(), result.ResetsAt.MustGet()) + } + }) + } +} diff --git a/internal/app/history/certificate.go b/internal/app/history/certificate.go index 5e0d48e..3855df3 100644 --- a/internal/app/history/certificate.go +++ b/internal/app/history/certificate.go @@ -8,6 +8,7 @@ import ( "github.com/samber/mo" "github.com/censys/cencli/internal/app/progress" + "github.com/censys/cencli/internal/app/streaming" "github.com/censys/cencli/internal/pkg/cenclierrors" utilconvert "github.com/censys/cencli/internal/pkg/convertutil" "github.com/censys/cencli/internal/pkg/domain/assets" @@ -34,7 +35,6 @@ func (s *historyService) GetCertificateHistory( pages := uint64(0) pageToken := mo.None[string]() - totalObservations := 0 // Format date range for display dateRange := fmt.Sprintf("%s to %s", fromTime.Format("2006-01-02"), toTime.Format("2006-01-02")) @@ -45,7 +45,7 @@ func (s *historyService) GetCertificateHistory( contextErr := cenclierrors.ParseContextError(err) // Return partial results with context error - if pages > 0 { + if pages > 0 || streaming.IsStreaming(ctx) { latency := time.Since(start) if lastMeta != nil { lastMeta.Latency = latency @@ -65,7 +65,7 @@ func (s *historyService) GetCertificateHistory( if pages == 1 { progress.ReportMessage(ctx, progress.StageFetch, fmt.Sprintf("Fetching certificate observations for %s (%s)...", certIDStr, dateRange)) } else { - progress.ReportMessage(ctx, progress.StageFetch, fmt.Sprintf("Fetching certificate observations for %s (%s, page %d, %d observations so far)...", certIDStr, dateRange, pages, totalObservations)) + progress.ReportMessage(ctx, progress.StageFetch, fmt.Sprintf("Fetching certificate observations for %s (%s, page %d, %d observations so far)...", certIDStr, dateRange, pages, len(allRanges))) } // fetch observations page @@ -96,11 +96,23 @@ func (s *historyService) GetCertificateHistory( ranges := res.Data.GetRanges() - // append ranges to result + // Either stream or accumulate ranges for i := range ranges { - allRanges = append(allRanges, &ranges[i]) + rangeItem := &ranges[i] + var emitErr error + allRanges, emitErr = streaming.EmitOrCollect(ctx, rangeItem, allRanges) + if emitErr != nil { + if lastMeta != nil { + lastMeta.Latency = time.Since(start) + lastMeta.PageCount = pages + } + return CertificateHistoryResult{ + Meta: lastMeta, + Ranges: nil, + PartialError: cenclierrors.ToPartialError(cenclierrors.NewCencliError(emitErr)), + }, nil + } } - totalObservations = len(allRanges) // check if there's a next page nextToken := res.Data.GetNextPageToken() diff --git a/internal/app/history/host.go b/internal/app/history/host.go index 9f3b4e1..77a7638 100644 --- a/internal/app/history/host.go +++ b/internal/app/history/host.go @@ -8,6 +8,7 @@ import ( "github.com/samber/mo" "github.com/censys/cencli/internal/app/progress" + "github.com/censys/cencli/internal/app/streaming" "github.com/censys/cencli/internal/pkg/cenclierrors" utilconvert "github.com/censys/cencli/internal/pkg/convertutil" "github.com/censys/cencli/internal/pkg/domain/assets" @@ -54,7 +55,7 @@ func (s *historyService) GetHostHistory( contextErr := cenclierrors.ParseContextError(err) // Return partial results with context error - if pages > 0 { + if pages > 0 || streaming.IsStreaming(ctx) { latency := time.Since(start) if lastMeta != nil { lastMeta.Latency = latency @@ -101,9 +102,22 @@ func (s *historyService) GetHostHistory( break } - // append events to result + // Either stream or accumulate events for i := range events { - allEvents = append(allEvents, &events[i].Resource) + event := &events[i].Resource + var emitErr error + allEvents, emitErr = streaming.EmitOrCollect(ctx, event, allEvents) + if emitErr != nil { + if lastMeta != nil { + lastMeta.Latency = time.Since(start) + lastMeta.PageCount = pages + } + return HostHistoryResult{ + Meta: lastMeta, + Events: nil, + PartialError: cenclierrors.ToPartialError(cenclierrors.NewCencliError(emitErr)), + }, nil + } } // if we got fewer than maxEventsPerPage events, we've reached the end diff --git a/internal/app/history/webproperty.go b/internal/app/history/webproperty.go index 7ee4100..150d029 100644 --- a/internal/app/history/webproperty.go +++ b/internal/app/history/webproperty.go @@ -8,6 +8,7 @@ import ( "github.com/samber/mo" "github.com/censys/cencli/internal/app/progress" + "github.com/censys/cencli/internal/app/streaming" "github.com/censys/cencli/internal/pkg/cenclierrors" utilconvert "github.com/censys/cencli/internal/pkg/convertutil" "github.com/censys/cencli/internal/pkg/domain/assets" @@ -48,7 +49,7 @@ func (s *historyService) GetWebPropertyHistory( contextErr := cenclierrors.ParseContextError(err) // Return partial results with context error - if totalRequests > 0 { + if totalRequests > 0 || streaming.IsStreaming(ctx) { latency := time.Since(start) if lastMeta != nil { lastMeta.Latency = latency @@ -76,6 +77,7 @@ func (s *historyService) GetWebPropertyHistory( mo.Some(current), ) + var snapshot *WebPropertySnapshot if err != nil { // If this is the first request, return the error immediately if totalRequests == 1 { @@ -89,11 +91,11 @@ func (s *historyService) GetWebPropertyHistory( // Report the first error so users are aware something went wrong progress.ReportError(ctx, progress.StageFetch, err) } - allSnapshots = append(allSnapshots, &WebPropertySnapshot{ + snapshot = &WebPropertySnapshot{ Time: current, Data: nil, Exists: false, - }) + } } else { // store metadata from the last successful request lastMeta = responsemeta.NewResponseMeta(res.Metadata.Request, res.Metadata.Response, res.Metadata.Latency, res.Metadata.Attempts) @@ -107,11 +109,26 @@ func (s *historyService) GetWebPropertyHistory( exists = webPropertyHasMeaningfulData(webProp) } - allSnapshots = append(allSnapshots, &WebPropertySnapshot{ + snapshot = &WebPropertySnapshot{ Time: current, Data: webProp, Exists: exists, - }) + } + } + + // Either stream or accumulate snapshot + var emitErr error + allSnapshots, emitErr = streaming.EmitOrCollect(ctx, snapshot, allSnapshots) + if emitErr != nil { + if lastMeta != nil { + lastMeta.Latency = time.Since(start) + lastMeta.PageCount = totalRequests + } + return WebPropertyHistoryResult{ + Meta: lastMeta, + Snapshots: nil, + PartialError: cenclierrors.ToPartialError(cenclierrors.NewCencliError(emitErr)), + }, nil } // Move to next day diff --git a/internal/app/organizations/dto.go b/internal/app/organizations/dto.go new file mode 100644 index 0000000..3d4d33d --- /dev/null +++ b/internal/app/organizations/dto.go @@ -0,0 +1,113 @@ +package organizations + +import ( + "time" + + "github.com/censys/censys-sdk-go/models/components" + "github.com/google/uuid" + "github.com/samber/mo" + + "github.com/censys/cencli/internal/pkg/domain/responsemeta" +) + +type OrganizationDetailsResult struct { + Meta *responsemeta.ResponseMeta + Data OrganizationDetails +} + +type OrganizationDetails struct { + ID uuid.UUID `json:"id"` + CreatedAt mo.Option[time.Time] `json:"created_at,omitzero"` + Name string `json:"name"` + MemberCounts *components.MemberCounts `json:"member_counts,omitempty"` + Preferences *components.OrganizationPreferences `json:"preferences,omitempty"` +} + +func parseOrganizationDetails(details *components.OrganizationDetails) OrganizationDetails { + var id uuid.UUID = uuid.Nil + if uid, err := uuid.Parse(details.UID); err == nil { + id = uid + } + var createdAt mo.Option[time.Time] + if details.CreatedAt != nil { + createdAt = mo.Some(*details.CreatedAt) + } + return OrganizationDetails{ + ID: id, + CreatedAt: createdAt, + Name: details.Name, + MemberCounts: details.MemberCounts, + Preferences: details.Preferences, + } +} + +type OrganizationMembersResult struct { + Meta *responsemeta.ResponseMeta + Data OrganizationMembers +} + +type OrganizationMembers struct { + Members []OrganizationMember `json:"members"` +} + +type OrganizationMember struct { + ID uuid.UUID `json:"id"` + CreatedAt mo.Option[time.Time] `json:"created_at,omitzero"` + Email mo.Option[string] `json:"email,omitzero"` + FirstName mo.Option[string] `json:"first_name,omitzero"` + LastName mo.Option[string] `json:"last_name,omitzero"` + Roles []string `json:"roles,omitempty"` + LatestLoginTime mo.Option[time.Time] `json:"latest_login_time,omitzero"` + FirstLoginTime mo.Option[time.Time] `json:"first_login_time,omitzero"` +} + +func parseOrganizationMembers(members *components.OrganizationMembersList) OrganizationMembers { + om := OrganizationMembers{ + Members: make([]OrganizationMember, 0, len(members.Members)), + } + for _, member := range members.Members { + om.Members = append(om.Members, parseOrganizationMember(member)) + } + return om +} + +func parseOrganizationMember(member components.OrganizationMember) OrganizationMember { + var id uuid.UUID = uuid.Nil + if uid, err := uuid.Parse(member.UID); err == nil { + id = uid + } + var createdAt mo.Option[time.Time] + if member.CreatedAt != nil { + createdAt = mo.Some(*member.CreatedAt) + } + var email mo.Option[string] + if member.Email != "" { + email = mo.Some(member.Email) + } + var firstName mo.Option[string] + if member.FirstName != "" { + firstName = mo.Some(member.FirstName) + } + var lastName mo.Option[string] + if member.LastName != "" { + lastName = mo.Some(member.LastName) + } + var latestLoginTime mo.Option[time.Time] + if member.LatestLoginTime != nil { + latestLoginTime = mo.Some(*member.LatestLoginTime) + } + var firstLoginTime mo.Option[time.Time] + if member.FirstLoginTime != nil { + firstLoginTime = mo.Some(*member.FirstLoginTime) + } + return OrganizationMember{ + ID: id, + CreatedAt: createdAt, + Email: email, + FirstName: firstName, + LastName: lastName, + Roles: member.Roles, + LatestLoginTime: latestLoginTime, + FirstLoginTime: firstLoginTime, + } +} diff --git a/internal/app/organizations/service.go b/internal/app/organizations/service.go new file mode 100644 index 0000000..85b05da --- /dev/null +++ b/internal/app/organizations/service.go @@ -0,0 +1,118 @@ +package organizations + +import ( + "context" + + "github.com/samber/mo" + + "github.com/censys/cencli/internal/pkg/cenclierrors" + client "github.com/censys/cencli/internal/pkg/clients/censys" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/domain/responsemeta" +) + +//go:generate mockgen -destination=../../../gen/app/organizations/mocks/organizationservice_mock.go -package=mocks -mock_names Service=MockOrganizationsService . Service + +// Service provides organization and member details capabilities. +type Service interface { + // GetOrganizationDetails retrieves the details for an organization. + GetOrganizationDetails( + ctx context.Context, + orgID identifiers.OrganizationID, + ) (OrganizationDetailsResult, cenclierrors.CencliError) + // ListOrganizationMembers retrieves the members for an organization. + // If no pagination is provided, the client will return all members. + ListOrganizationMembers( + ctx context.Context, + orgID identifiers.OrganizationID, + pageSize mo.Option[uint], + maxPages mo.Option[uint], + ) (OrganizationMembersResult, cenclierrors.CencliError) +} + +type organizationsService struct { + client client.Client +} + +func New(client client.Client) Service { + return &organizationsService{client: client} +} + +func (s *organizationsService) GetOrganizationDetails( + ctx context.Context, + orgID identifiers.OrganizationID, +) (OrganizationDetailsResult, cenclierrors.CencliError) { + res, err := s.client.GetOrganizationDetails(ctx, orgID.String()) + if err != nil { + return OrganizationDetailsResult{}, err + } + return OrganizationDetailsResult{ + Meta: responsemeta.NewResponseMeta(res.Metadata.Request, res.Metadata.Response, res.Metadata.Latency, res.Metadata.Attempts), + Data: parseOrganizationDetails(res.Data), + }, nil +} + +func (s *organizationsService) ListOrganizationMembers( + ctx context.Context, + orgID identifiers.OrganizationID, + pageSize mo.Option[uint], + maxPages mo.Option[uint], +) (OrganizationMembersResult, cenclierrors.CencliError) { + var allMembers []OrganizationMember + var lastMeta *responsemeta.ResponseMeta + var pagesProcessed uint + pageToken := mo.None[string]() + + // Convert pageSize from uint to int for the client + var clientPageSize mo.Option[int] + if pageSize.IsPresent() { + clientPageSize = mo.Some(int(pageSize.MustGet())) + } + + for { + // Check if we've reached maxPages + if maxPages.IsPresent() && pagesProcessed >= maxPages.MustGet() { + break + } + + // Fetch a page of members + res, err := s.client.ListOrganizationMembers(ctx, orgID.String(), clientPageSize, pageToken) + if err != nil { + // Return error immediately - no partial results for this endpoint + return OrganizationMembersResult{}, err + } + + // Store metadata from the last successful request + if res.Metadata.Request != nil || res.Metadata.Response != nil { + lastMeta = responsemeta.NewResponseMeta( + res.Metadata.Request, + res.Metadata.Response, + res.Metadata.Latency, + res.Metadata.Attempts, + ) + } + + // Parse and append members from this page + if res.Data != nil { + pageMembers := parseOrganizationMembers(res.Data) + allMembers = append(allMembers, pageMembers.Members...) + } + + pagesProcessed++ + + // Check if there's a next page + if res.Data == nil || res.Data.Pagination.GetNextPageToken() == nil || *res.Data.Pagination.GetNextPageToken() == "" { + break + } + + // Set the next page token + pageToken = mo.Some(*res.Data.Pagination.GetNextPageToken()) + } + + return OrganizationMembersResult{ + Meta: lastMeta, + Data: OrganizationMembers{ + Members: allMembers, + }, + }, nil +} diff --git a/internal/app/organizations/service_test.go b/internal/app/organizations/service_test.go new file mode 100644 index 0000000..c2a8340 --- /dev/null +++ b/internal/app/organizations/service_test.go @@ -0,0 +1,624 @@ +package organizations + +import ( + "context" + "errors" + "net/http" + "testing" + "time" + + "github.com/censys/censys-sdk-go/models/components" + "github.com/censys/censys-sdk-go/models/sdkerrors" + "github.com/google/uuid" + "github.com/samber/mo" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/censys/cencli/gen/client/mocks" + "github.com/censys/cencli/internal/pkg/cenclierrors" + client "github.com/censys/cencli/internal/pkg/clients/censys" + "github.com/censys/cencli/internal/pkg/domain/identifiers" +) + +func TestOrganizationsService_GetOrganizationDetails(t *testing.T) { + testCases := []struct { + name string + client func(ctrl *gomock.Controller) client.Client + orgID uuid.UUID + ctx func() context.Context + assert func(t *testing.T, res OrganizationDetailsResult, err cenclierrors.CencliError) + }{ + { + name: "success - basic organization details", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + createdAt := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + mockClient.EXPECT().GetOrganizationDetails( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + ).Return(client.Result[components.OrganizationDetails]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 100 * time.Millisecond, + Attempts: 1, + }, + Data: &components.OrganizationDetails{ + UID: "f47ac10b-58cc-4372-a567-0e02b2c3d479", + CreatedAt: &createdAt, + Name: "Test Organization", + }, + }, nil) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationDetailsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.NotNil(t, res.Meta) + require.Equal(t, 100*time.Millisecond, res.Meta.Latency) + require.Equal(t, uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), res.Data.ID) + require.Equal(t, "Test Organization", res.Data.Name) + require.True(t, res.Data.CreatedAt.IsPresent()) + require.Equal(t, time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), res.Data.CreatedAt.MustGet()) + }, + }, + { + name: "error - organization not found", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + detail := "Organization not found" + status := int64(404) + structuredErr := client.NewCensysClientStructuredError(&sdkerrors.ErrorModel{ + Detail: &detail, + Status: &status, + }) + mockClient.EXPECT().GetOrganizationDetails( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + ).Return(client.Result[components.OrganizationDetails]{}, structuredErr) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationDetailsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Equal(t, OrganizationDetailsResult{}, res) + + var structuredErr client.ClientStructuredError + require.True(t, errors.As(err, &structuredErr)) + require.True(t, structuredErr.StatusCode().IsPresent()) + require.Equal(t, int64(404), structuredErr.StatusCode().MustGet()) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + c := tc.client(ctrl) + svc := New(c) + + ctx := context.Background() + if tc.ctx != nil { + ctx = tc.ctx() + } + + res, err := svc.GetOrganizationDetails(ctx, identifiers.NewOrganizationID(tc.orgID)) + tc.assert(t, res, err) + }) + } +} + +func TestOrganizationsService_ListOrganizationMembers(t *testing.T) { + testCases := []struct { + name string + client func(ctrl *gomock.Controller) client.Client + orgID uuid.UUID + pageSize mo.Option[uint] + maxPages mo.Option[uint] + ctx func() context.Context + assert func(t *testing.T, res OrganizationMembersResult, err cenclierrors.CencliError) + }{ + { + name: "success - single page with members", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + pageSize: mo.None[uint](), + maxPages: mo.None[uint](), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + createdAt := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + mockClient.EXPECT().ListOrganizationMembers( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + mo.None[int](), + mo.None[string](), + ).Return(client.Result[components.OrganizationMembersList]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 100 * time.Millisecond, + Attempts: 1, + }, + Data: &components.OrganizationMembersList{ + Members: []components.OrganizationMember{ + { + UID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + CreatedAt: &createdAt, + Email: "user1@example.com", + FirstName: "John", + LastName: "Doe", + Roles: []string{"admin", "viewer"}, + }, + { + UID: "b2c3d4e5-f6a7-8901-bcde-f12345678901", + Email: "user2@example.com", + FirstName: "Jane", + LastName: "Smith", + Roles: []string{"viewer"}, + }, + }, + Pagination: components.PaginationInfo{ + NextPageToken: nil, + PageSize: 10, + }, + }, + }, nil) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationMembersResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.NotNil(t, res.Meta) + require.Equal(t, 100*time.Millisecond, res.Meta.Latency) + require.Len(t, res.Data.Members, 2) + + // Check first member + require.Equal(t, uuid.MustParse("a1b2c3d4-e5f6-7890-abcd-ef1234567890"), res.Data.Members[0].ID) + require.True(t, res.Data.Members[0].Email.IsPresent()) + require.Equal(t, "user1@example.com", res.Data.Members[0].Email.MustGet()) + require.True(t, res.Data.Members[0].FirstName.IsPresent()) + require.Equal(t, "John", res.Data.Members[0].FirstName.MustGet()) + require.True(t, res.Data.Members[0].LastName.IsPresent()) + require.Equal(t, "Doe", res.Data.Members[0].LastName.MustGet()) + require.Equal(t, []string{"admin", "viewer"}, res.Data.Members[0].Roles) + + // Check second member + require.Equal(t, uuid.MustParse("b2c3d4e5-f6a7-8901-bcde-f12345678901"), res.Data.Members[1].ID) + require.Equal(t, "user2@example.com", res.Data.Members[1].Email.MustGet()) + require.Equal(t, []string{"viewer"}, res.Data.Members[1].Roles) + }, + }, + { + name: "success - empty members list", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + pageSize: mo.None[uint](), + maxPages: mo.None[uint](), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + mockClient.EXPECT().ListOrganizationMembers( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + mo.None[int](), + mo.None[string](), + ).Return(client.Result[components.OrganizationMembersList]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 50 * time.Millisecond, + Attempts: 1, + }, + Data: &components.OrganizationMembersList{ + Members: []components.OrganizationMember{}, + Pagination: components.PaginationInfo{ + NextPageToken: nil, + PageSize: 10, + }, + }, + }, nil) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationMembersResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.NotNil(t, res.Meta) + require.Len(t, res.Data.Members, 0) + }, + }, + { + name: "success - multiple pages with maxPages", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + pageSize: mo.Some(uint(2)), + maxPages: mo.Some(uint(2)), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + + // First page + token1 := "token1" + mockClient.EXPECT().ListOrganizationMembers( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + mo.Some(2), + mo.None[string](), + ).Return(client.Result[components.OrganizationMembersList]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 100 * time.Millisecond, + Attempts: 1, + }, + Data: &components.OrganizationMembersList{ + Members: []components.OrganizationMember{ + { + UID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + Email: "user1@example.com", + FirstName: "User", + LastName: "One", + Roles: []string{"admin"}, + }, + { + UID: "b2c3d4e5-f6a7-8901-bcde-f12345678901", + Email: "user2@example.com", + FirstName: "User", + LastName: "Two", + Roles: []string{"viewer"}, + }, + }, + Pagination: components.PaginationInfo{ + NextPageToken: &token1, + PageSize: 2, + }, + }, + }, nil) + + // Second page + token2 := "token2" + mockClient.EXPECT().ListOrganizationMembers( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + mo.Some(2), + mo.Some("token1"), + ).Return(client.Result[components.OrganizationMembersList]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 100 * time.Millisecond, + Attempts: 1, + }, + Data: &components.OrganizationMembersList{ + Members: []components.OrganizationMember{ + { + UID: "c3d4e5f6-a7b8-9012-cdef-123456789012", + Email: "user3@example.com", + FirstName: "User", + LastName: "Three", + Roles: []string{"editor"}, + }, + { + UID: "d4e5f6a7-b8c9-0123-def1-234567890123", + Email: "user4@example.com", + FirstName: "User", + LastName: "Four", + Roles: []string{"viewer"}, + }, + }, + Pagination: components.PaginationInfo{ + NextPageToken: &token2, // Has more pages but we stop at maxPages + PageSize: 2, + }, + }, + }, nil) + + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationMembersResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.NotNil(t, res.Meta) + require.Len(t, res.Data.Members, 4) + require.Equal(t, "user1@example.com", res.Data.Members[0].Email.MustGet()) + require.Equal(t, "user2@example.com", res.Data.Members[1].Email.MustGet()) + require.Equal(t, "user3@example.com", res.Data.Members[2].Email.MustGet()) + require.Equal(t, "user4@example.com", res.Data.Members[3].Email.MustGet()) + }, + }, + { + name: "success - pagination until no next token", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + pageSize: mo.Some(uint(1)), + maxPages: mo.None[uint](), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + + // First page + token1 := "token1" + mockClient.EXPECT().ListOrganizationMembers( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + mo.Some(1), + mo.None[string](), + ).Return(client.Result[components.OrganizationMembersList]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 50 * time.Millisecond, + Attempts: 1, + }, + Data: &components.OrganizationMembersList{ + Members: []components.OrganizationMember{ + { + UID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + Email: "user1@example.com", + FirstName: "User", + LastName: "One", + Roles: []string{"admin"}, + }, + }, + Pagination: components.PaginationInfo{ + NextPageToken: &token1, + PageSize: 1, + }, + }, + }, nil) + + // Second page (last page) + mockClient.EXPECT().ListOrganizationMembers( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + mo.Some(1), + mo.Some("token1"), + ).Return(client.Result[components.OrganizationMembersList]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 50 * time.Millisecond, + Attempts: 1, + }, + Data: &components.OrganizationMembersList{ + Members: []components.OrganizationMember{ + { + UID: "b2c3d4e5-f6a7-8901-bcde-f12345678901", + Email: "user2@example.com", + FirstName: "User", + LastName: "Two", + Roles: []string{"viewer"}, + }, + }, + Pagination: components.PaginationInfo{ + NextPageToken: nil, // No more pages + PageSize: 1, + }, + }, + }, nil) + + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationMembersResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.NotNil(t, res.Meta) + require.Len(t, res.Data.Members, 2) + require.Equal(t, "user1@example.com", res.Data.Members[0].Email.MustGet()) + require.Equal(t, "user2@example.com", res.Data.Members[1].Email.MustGet()) + }, + }, + { + name: "success - empty string next token treated as no next page", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + pageSize: mo.None[uint](), + maxPages: mo.None[uint](), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + emptyToken := "" + mockClient.EXPECT().ListOrganizationMembers( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + mo.None[int](), + mo.None[string](), + ).Return(client.Result[components.OrganizationMembersList]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 50 * time.Millisecond, + Attempts: 1, + }, + Data: &components.OrganizationMembersList{ + Members: []components.OrganizationMember{ + { + UID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + Email: "user1@example.com", + FirstName: "User", + LastName: "One", + Roles: []string{"admin"}, + }, + }, + Pagination: components.PaginationInfo{ + NextPageToken: &emptyToken, // Empty string should be treated as no next page + PageSize: 10, + }, + }, + }, nil) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationMembersResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.NotNil(t, res.Meta) + require.Len(t, res.Data.Members, 1) + }, + }, + { + name: "error - first page fails", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + pageSize: mo.None[uint](), + maxPages: mo.None[uint](), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + detail := "Organization not found" + status := int64(404) + structuredErr := client.NewCensysClientStructuredError(&sdkerrors.ErrorModel{ + Detail: &detail, + Status: &status, + }) + mockClient.EXPECT().ListOrganizationMembers( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + mo.None[int](), + mo.None[string](), + ).Return(client.Result[components.OrganizationMembersList]{}, structuredErr) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationMembersResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Equal(t, OrganizationMembersResult{}, res) + + var structuredErr client.ClientStructuredError + require.True(t, errors.As(err, &structuredErr)) + require.True(t, structuredErr.StatusCode().IsPresent()) + require.Equal(t, int64(404), structuredErr.StatusCode().MustGet()) + }, + }, + { + name: "error - second page fails", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + pageSize: mo.Some(uint(1)), + maxPages: mo.None[uint](), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + + // First page succeeds + token1 := "token1" + mockClient.EXPECT().ListOrganizationMembers( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + mo.Some(1), + mo.None[string](), + ).Return(client.Result[components.OrganizationMembersList]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 50 * time.Millisecond, + Attempts: 1, + }, + Data: &components.OrganizationMembersList{ + Members: []components.OrganizationMember{ + { + UID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + Email: "user1@example.com", + FirstName: "User", + LastName: "One", + Roles: []string{"admin"}, + }, + }, + Pagination: components.PaginationInfo{ + NextPageToken: &token1, + PageSize: 1, + }, + }, + }, nil) + + // Second page fails + genericErr := client.NewCensysClientGenericError(&sdkerrors.SDKError{ + Message: "Internal server error", + StatusCode: 500, + Body: "Server error", + }) + mockClient.EXPECT().ListOrganizationMembers( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + mo.Some(1), + mo.Some("token1"), + ).Return(client.Result[components.OrganizationMembersList]{}, genericErr) + + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationMembersResult, err cenclierrors.CencliError) { + // Should return error, not partial results + require.Error(t, err) + require.Equal(t, OrganizationMembersResult{}, res) + + var genericErr client.ClientGenericError + require.True(t, errors.As(err, &genericErr)) + require.Equal(t, int64(500), genericErr.StatusCode().MustGet()) + }, + }, + { + name: "error - generic client error", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + pageSize: mo.None[uint](), + maxPages: mo.None[uint](), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + genericErr := client.NewCensysClientGenericError(&sdkerrors.SDKError{ + Message: "Service unavailable", + StatusCode: 503, + Body: "Try again later", + }) + mockClient.EXPECT().ListOrganizationMembers( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + mo.None[int](), + mo.None[string](), + ).Return(client.Result[components.OrganizationMembersList]{}, genericErr) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationMembersResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Equal(t, OrganizationMembersResult{}, res) + + var genericErr client.ClientGenericError + require.True(t, errors.As(err, &genericErr)) + require.Equal(t, int64(503), genericErr.StatusCode().MustGet()) + }, + }, + { + name: "error - unknown client error", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + pageSize: mo.None[uint](), + maxPages: mo.None[uint](), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + unknownErr := client.NewClientError(errors.New("network timeout")) + mockClient.EXPECT().ListOrganizationMembers( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + mo.None[int](), + mo.None[string](), + ).Return(client.Result[components.OrganizationMembersList]{}, unknownErr) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationMembersResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Equal(t, OrganizationMembersResult{}, res) + + var unknownErr client.ClientError + require.True(t, errors.As(err, &unknownErr)) + require.Contains(t, err.Error(), "network timeout") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + c := tc.client(ctrl) + svc := New(c) + + ctx := context.Background() + if tc.ctx != nil { + ctx = tc.ctx() + } + + res, err := svc.ListOrganizationMembers(ctx, identifiers.NewOrganizationID(tc.orgID), tc.pageSize, tc.maxPages) + tc.assert(t, res, err) + }) + } +} diff --git a/internal/app/search/service.go b/internal/app/search/service.go index 3cb8834..df11ab9 100644 --- a/internal/app/search/service.go +++ b/internal/app/search/service.go @@ -10,6 +10,7 @@ import ( "github.com/censys/censys-sdk-go/models/components" "github.com/censys/cencli/internal/app/progress" + "github.com/censys/cencli/internal/app/streaming" "github.com/censys/cencli/internal/pkg/cenclierrors" client "github.com/censys/cencli/internal/pkg/clients/censys" utilconvert "github.com/censys/cencli/internal/pkg/convertutil" @@ -106,7 +107,7 @@ func (s *searchService) searchWithPagination( } return Result{ Meta: lastMeta, - Hits: allHits, + Hits: allHits, // empty if streaming TotalHits: totalHits, PartialError: cenclierrors.ToPartialError(contextErr), }, nil @@ -139,9 +140,29 @@ func (s *searchService) searchWithPagination( } pageHits := parseHits(result.Data.Hits) - allHits = append(allHits, pageHits...) - totalHits = int64(result.Data.TotalHits) + // Either stream (with asset type wrapping) or accumulate hits + if streaming.IsStreaming(ctx) { + for _, hit := range pageHits { + wrapped := map[string]any{hit.AssetType().String(): hit} + if emitErr := streaming.Emit(ctx, wrapped); emitErr != nil { + if lastMeta != nil { + lastMeta.Latency = time.Since(start) + lastMeta.PageCount = pagesProcessed + } + return Result{ + Meta: lastMeta, + Hits: nil, + TotalHits: totalHits, + PartialError: cenclierrors.ToPartialError(cenclierrors.NewCencliError(emitErr)), + }, nil + } + } + } else { + allHits = append(allHits, pageHits...) + } + + totalHits = int64(result.Data.TotalHits) pagesProcessed++ nextPageToken := result.Data.GetNextPageToken() @@ -163,7 +184,7 @@ func (s *searchService) searchWithPagination( return Result{ Meta: lastMeta, - Hits: allHits, + Hits: allHits, // empty if streaming TotalHits: totalHits, PartialError: cenclierrors.ToPartialError(firstError), }, nil diff --git a/internal/app/streaming/streaming.go b/internal/app/streaming/streaming.go new file mode 100644 index 0000000..5731017 --- /dev/null +++ b/internal/app/streaming/streaming.go @@ -0,0 +1,136 @@ +package streaming + +import ( + "context" + "errors" + "sync" +) + +// Item represents a single piece of data emitted through the streaming channel. +type Item struct { + // Data is the actual payload being streamed. + Data any + // Err indicates an error that occurred during streaming (non-fatal). + Err error + // Done signals that streaming has completed. + Done bool +} + +// Emitter sends data items to a consumer. +type Emitter interface { + // Emit sends a data item through the stream. + // Returns an error if the context is canceled or the emitter is closed. + Emit(ctx context.Context, data any) error + // Close signals that no more items will be sent. + // The optional error is passed to the consumer as the final error state. + Close(err error) +} + +// ErrEmitterClosed is returned when attempting to emit after the emitter is closed. +var ErrEmitterClosed = errors.New("streaming emitter closed") + +const defaultBufferSize = 1 + +// NewChannelEmitter creates a new channel-based emitter. +// The buffer parameter controls the channel buffer size; values <= 0 use the default. +// Returns the emitter and a receive-only channel for consuming items. +func NewChannelEmitter(buffer int) (Emitter, <-chan Item) { + if buffer <= 0 { + buffer = defaultBufferSize + } + ch := make(chan Item, buffer) + return &channelEmitter{ch: ch}, ch +} + +type channelEmitter struct { + ch chan Item + once sync.Once + mu sync.Mutex + closed bool +} + +// Emit sends a data item through the channel. +func (e *channelEmitter) Emit(ctx context.Context, data any) error { + e.mu.Lock() + closed := e.closed + e.mu.Unlock() + if closed { + return ErrEmitterClosed + } + select { + case <-ctx.Done(): + return ctx.Err() + case e.ch <- Item{Data: data}: + return nil + } +} + +// Close signals completion and closes the channel. +func (e *channelEmitter) Close(err error) { + e.once.Do(func() { + e.mu.Lock() + e.closed = true + e.mu.Unlock() + + final := Item{Done: true, Err: err} + select { + case e.ch <- final: + default: + } + close(e.ch) + }) +} + +type emitterContextKey struct{} + +// WithEmitter attaches an emitter to the context. +// If the emitter is nil, the context is returned unchanged. +func WithEmitter(ctx context.Context, emitter Emitter) context.Context { + if emitter == nil { + return ctx + } + return context.WithValue(ctx, emitterContextKey{}, emitter) +} + +// FromContext retrieves the emitter from the context, if present. +func FromContext(ctx context.Context) (Emitter, bool) { + emitter, ok := ctx.Value(emitterContextKey{}).(Emitter) + return emitter, ok +} + +// Emit sends data through the context-bound emitter. +// Returns nil if no emitter is present in the context. +// Returns an error if emission fails (context canceled, emitter closed, etc.). +func Emit(ctx context.Context, data any) error { + emitter, ok := FromContext(ctx) + if !ok { + return nil + } + return emitter.Emit(ctx, data) +} + +// IsStreaming returns true if a streaming emitter is attached to the context. +func IsStreaming(ctx context.Context) bool { + _, ok := FromContext(ctx) + return ok +} + +// EmitOrCollect either emits the item (if streaming) or appends it to the slice. +// Returns the updated slice and any error from emission. +// This helper reduces duplication in services that support both streaming and buffered output. +// +// Usage: +// +// items, err := streaming.EmitOrCollect(ctx, item, items) +// if err != nil { +// return partialResult, nil +// } +func EmitOrCollect[T any](ctx context.Context, item T, slice []T) ([]T, error) { + if IsStreaming(ctx) { + if err := Emit(ctx, item); err != nil { + return slice, err + } + return slice, nil + } + return append(slice, item), nil +} diff --git a/internal/app/streaming/streaming_test.go b/internal/app/streaming/streaming_test.go new file mode 100644 index 0000000..72b828c --- /dev/null +++ b/internal/app/streaming/streaming_test.go @@ -0,0 +1,365 @@ +package streaming + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewChannelEmitter(t *testing.T) { + t.Run("creates emitter with default buffer", func(t *testing.T) { + emitter, ch := NewChannelEmitter(0) + require.NotNil(t, emitter) + require.NotNil(t, ch) + emitter.Close(nil) + }) + + t.Run("creates emitter with custom buffer", func(t *testing.T) { + emitter, ch := NewChannelEmitter(5) + require.NotNil(t, emitter) + require.NotNil(t, ch) + emitter.Close(nil) + }) +} + +func TestEmitter_Emit(t *testing.T) { + t.Run("emits data successfully", func(t *testing.T) { + emitter, ch := NewChannelEmitter(1) + ctx := context.Background() + + go func() { + err := emitter.Emit(ctx, "test-data") + assert.NoError(t, err) + emitter.Close(nil) + }() + + item := <-ch + if item.Data != "test-data" { + t.Errorf("expected data 'test-data', got %v", item.Data) + } + if item.Done { + t.Error("expected Done to be false") + } + if item.Err != nil { + t.Errorf("expected nil error, got %v", item.Err) + } + + // Drain final item + <-ch + }) + + t.Run("returns error when emitter is closed", func(t *testing.T) { + emitter, _ := NewChannelEmitter(1) + ctx := context.Background() + + emitter.Close(nil) + + err := emitter.Emit(ctx, "test-data") + require.ErrorIs(t, err, ErrEmitterClosed) + }) + + t.Run("returns error when context is canceled and channel is blocked", func(t *testing.T) { + // Use a buffer of 1 and fill it first so the next emit blocks + emitter, ch := NewChannelEmitter(1) + ctx, cancel := context.WithCancel(context.Background()) + + // Fill the buffer + _ = emitter.Emit(ctx, "first") + + // Now cancel the context + cancel() + + // This emit should fail because buffer is full and context is canceled + err := emitter.Emit(ctx, "second") + require.ErrorIs(t, err, context.Canceled) + + // Drain and close + <-ch + emitter.Close(nil) + }) +} + +func TestEmitter_Close(t *testing.T) { + t.Run("sends done signal", func(t *testing.T) { + emitter, ch := NewChannelEmitter(1) + + emitter.Close(nil) + + item := <-ch + require.True(t, item.Done) + require.NoError(t, item.Err) + }) + + t.Run("sends error with done signal", func(t *testing.T) { + emitter, ch := NewChannelEmitter(1) + testErr := errors.New("test error") + + emitter.Close(testErr) + + item := <-ch + require.True(t, item.Done) + require.ErrorIs(t, item.Err, testErr) + }) + + t.Run("close is idempotent", func(t *testing.T) { + emitter, ch := NewChannelEmitter(1) + + // Close multiple times should not panic + emitter.Close(nil) + emitter.Close(nil) + emitter.Close(nil) + + // Should only receive one done signal + item := <-ch + require.True(t, item.Done) + _, ok := <-ch + require.False(t, ok) + }) + + t.Run("closes channel after done", func(t *testing.T) { + emitter, ch := NewChannelEmitter(1) + + emitter.Close(nil) + + <-ch // Drain done signal + + // Verify channel is closed + _, ok := <-ch + require.False(t, ok) + }) +} + +func TestContext_WithEmitter(t *testing.T) { + t.Run("attaches emitter to context", func(t *testing.T) { + emitter, _ := NewChannelEmitter(1) + ctx := context.Background() + + ctx = WithEmitter(ctx, emitter) + + retrieved, ok := FromContext(ctx) + require.True(t, ok) + require.Equal(t, emitter, retrieved) + emitter.Close(nil) + }) + + t.Run("returns original context for nil emitter", func(t *testing.T) { + ctx := context.Background() + + newCtx := WithEmitter(ctx, nil) + + require.Equal(t, ctx, newCtx) + }) +} + +func TestContext_FromContext(t *testing.T) { + t.Run("returns false when no emitter", func(t *testing.T) { + ctx := context.Background() + + _, ok := FromContext(ctx) + require.False(t, ok) + }) +} + +func TestEmit(t *testing.T) { + t.Run("emits data through context emitter", func(t *testing.T) { + emitter, ch := NewChannelEmitter(1) + ctx := WithEmitter(context.Background(), emitter) + + go func() { + err := Emit(ctx, "context-data") + assert.NoError(t, err) + emitter.Close(nil) + }() + + item := <-ch + require.Equal(t, "context-data", item.Data) + + <-ch // Drain done + }) + + t.Run("returns nil when no emitter in context", func(t *testing.T) { + ctx := context.Background() + + err := Emit(ctx, "data") + require.NoError(t, err) + }) +} + +func TestIsStreaming(t *testing.T) { + t.Run("returns true when emitter present", func(t *testing.T) { + emitter, _ := NewChannelEmitter(1) + ctx := WithEmitter(context.Background(), emitter) + + require.True(t, IsStreaming(ctx)) + emitter.Close(nil) + }) + + t.Run("returns false when no emitter", func(t *testing.T) { + ctx := context.Background() + + require.False(t, IsStreaming(ctx)) + }) +} + +func TestEmitter_ConcurrentEmit(t *testing.T) { + t.Run("handles concurrent emits safely", func(t *testing.T) { + emitter, ch := NewChannelEmitter(10) + ctx := context.Background() + + const numGoroutines = 5 + const itemsPerGoroutine = 10 + + var wg sync.WaitGroup + wg.Add(numGoroutines) + + for i := range numGoroutines { + go func(id int) { + defer wg.Done() + for j := range itemsPerGoroutine { + _ = emitter.Emit(ctx, id*100+j) + } + }(i) + } + + // Consume items in a separate goroutine + received := make(chan int, numGoroutines*itemsPerGoroutine+1) + go func() { + for item := range ch { + if item.Done { + break + } + if v, ok := item.Data.(int); ok { + received <- v + } + } + close(received) + }() + + wg.Wait() + emitter.Close(nil) + + count := 0 + for range received { + count++ + } + + require.Equal(t, numGoroutines*itemsPerGoroutine, count) + }) +} + +func TestEmitter_NoDeadlock(t *testing.T) { + t.Run("does not deadlock with slow consumer", func(t *testing.T) { + emitter, ch := NewChannelEmitter(1) + ctx := context.Background() + + done := make(chan struct{}) + go func() { + defer close(done) + for item := range ch { + if item.Done { + break + } + // Simulate slow consumer + time.Sleep(10 * time.Millisecond) + } + }() + + // Send multiple items faster than consumer can process + for i := range 5 { + err := emitter.Emit(ctx, i) + assert.NoError(t, err) + } + emitter.Close(nil) + + // Wait with timeout to detect deadlock + select { + case <-done: + // Success + case <-time.After(5 * time.Second): + require.Fail(t, "deadlock detected: consumer did not finish") + } + }) +} + +func TestEmitOrCollect(t *testing.T) { + t.Run("appends to slice when not streaming", func(t *testing.T) { + ctx := context.Background() // No emitter attached + + slice := []string{"a", "b"} + result, err := EmitOrCollect(ctx, "c", slice) + + require.NoError(t, err) + require.Equal(t, []string{"a", "b", "c"}, result) + }) + + t.Run("emits and returns unchanged slice when streaming", func(t *testing.T) { + emitter, ch := NewChannelEmitter(1) + ctx := WithEmitter(context.Background(), emitter) + + go func() { + // Consume emitted item + item := <-ch + assert.Equal(t, "streamed", item.Data) + emitter.Close(nil) + }() + + slice := []string{"existing"} + result, err := EmitOrCollect(ctx, "streamed", slice) + + require.NoError(t, err) + // Slice should not grow when streaming + require.Equal(t, []string{"existing"}, result) + + // Drain close signal + <-ch + }) + + t.Run("returns error on emit failure", func(t *testing.T) { + emitter, ch := NewChannelEmitter(1) + ctx := WithEmitter(context.Background(), emitter) + + // Fill buffer so next emit blocks + _ = emitter.Emit(ctx, "fill") + + // Cancel to cause emit failure + ctx, cancel := context.WithCancel(ctx) + cancel() + + slice := []int{1, 2} + result, err := EmitOrCollect(ctx, 3, slice) + + require.ErrorIs(t, err, context.Canceled) + require.Equal(t, []int{1, 2}, result) // Slice unchanged on error + + <-ch + emitter.Close(nil) + }) + + t.Run("works with nil slice", func(t *testing.T) { + ctx := context.Background() + + var slice []int + result, err := EmitOrCollect(ctx, 42, slice) + + require.NoError(t, err) + require.Equal(t, []int{42}, result) + }) + + t.Run("works with pointer types", func(t *testing.T) { + ctx := context.Background() + + type Data struct{ Value int } + slice := []*Data{{Value: 1}} + result, err := EmitOrCollect(ctx, &Data{Value: 2}, slice) + + require.NoError(t, err) + require.Len(t, result, 2) + require.Equal(t, 1, result[0].Value) + require.Equal(t, 2, result[1].Value) + }) +} diff --git a/internal/app/view/service.go b/internal/app/view/service.go index 439312c..c53457b 100644 --- a/internal/app/view/service.go +++ b/internal/app/view/service.go @@ -8,6 +8,7 @@ import ( "github.com/samber/mo" "github.com/censys/cencli/internal/app/progress" + "github.com/censys/cencli/internal/app/streaming" "github.com/censys/cencli/internal/pkg/cenclierrors" client "github.com/censys/cencli/internal/pkg/clients/censys" utilconvert "github.com/censys/cencli/internal/pkg/convertutil" @@ -67,7 +68,7 @@ func (s *viewService) GetHosts( contextErr := cenclierrors.ParseContextError(err) // Return partial results with context error - if len(allHosts) > 0 { + if len(allHosts) > 0 || streaming.IsStreaming(ctx) { if lastMeta != nil { lastMeta.Latency = time.Since(start) lastMeta.PageCount = uint64(batchesProcessed) @@ -114,10 +115,22 @@ func (s *viewService) GetHosts( // Store metadata from the last successful request lastMeta = responsemeta.NewResponseMeta(res.Metadata.Request, res.Metadata.Response, res.Metadata.Latency, res.Metadata.Attempts) - // Convert and append to results + // Convert and either stream or accumulate results for _, host := range *res.Data { domainHost := assets.NewHost(host) - allHosts = append(allHosts, &domainHost) + var emitErr error + allHosts, emitErr = streaming.EmitOrCollect(ctx, &domainHost, allHosts) + if emitErr != nil { + if lastMeta != nil { + lastMeta.Latency = time.Since(start) + lastMeta.PageCount = uint64(batchesProcessed) + } + return HostsResult{ + Meta: lastMeta, + Hosts: nil, + PartialError: cenclierrors.ToPartialError(cenclierrors.NewCencliError(emitErr)), + }, nil + } } batchesProcessed++ @@ -159,7 +172,7 @@ func (s *viewService) GetCertificates( contextErr := cenclierrors.ParseContextError(err) // Return partial results with context error - if len(allCertificates) > 0 { + if len(allCertificates) > 0 || streaming.IsStreaming(ctx) { if lastMeta != nil { lastMeta.Latency = time.Since(start) lastMeta.PageCount = uint64(batchesProcessed) @@ -197,10 +210,22 @@ func (s *viewService) GetCertificates( // Store metadata from the last successful request lastMeta = responsemeta.NewResponseMeta(res.Metadata.Request, res.Metadata.Response, res.Metadata.Latency, res.Metadata.Attempts) - // Convert and append to results + // Convert and either stream or accumulate results for _, certificate := range *res.Data { domainCertificate := assets.NewCertificate(certificate) - allCertificates = append(allCertificates, &domainCertificate) + var emitErr error + allCertificates, emitErr = streaming.EmitOrCollect(ctx, &domainCertificate, allCertificates) + if emitErr != nil { + if lastMeta != nil { + lastMeta.Latency = time.Since(start) + lastMeta.PageCount = uint64(batchesProcessed) + } + return CertificatesResult{ + Meta: lastMeta, + Certificates: nil, + PartialError: cenclierrors.ToPartialError(cenclierrors.NewCencliError(emitErr)), + }, nil + } } batchesProcessed++ @@ -243,7 +268,7 @@ func (s *viewService) GetWebProperties( contextErr := cenclierrors.ParseContextError(err) // Return partial results with context error - if len(allWebProperties) > 0 { + if len(allWebProperties) > 0 || streaming.IsStreaming(ctx) { if lastMeta != nil { lastMeta.Latency = time.Since(start) lastMeta.PageCount = uint64(batchesProcessed) @@ -289,10 +314,22 @@ func (s *viewService) GetWebProperties( // Store metadata from the last successful request lastMeta = responsemeta.NewResponseMeta(res.Metadata.Request, res.Metadata.Response, res.Metadata.Latency, res.Metadata.Attempts) - // Convert and append to results + // Convert and either stream or accumulate results for _, webProperty := range *res.Data { domainWebProperty := assets.NewWebProperty(webProperty) - allWebProperties = append(allWebProperties, &domainWebProperty) + var emitErr error + allWebProperties, emitErr = streaming.EmitOrCollect(ctx, &domainWebProperty, allWebProperties) + if emitErr != nil { + if lastMeta != nil { + lastMeta.Latency = time.Since(start) + lastMeta.PageCount = uint64(batchesProcessed) + } + return WebPropertiesResult{ + Meta: lastMeta, + WebProperties: nil, + PartialError: cenclierrors.ToPartialError(cenclierrors.NewCencliError(emitErr)), + }, nil + } } batchesProcessed++ diff --git a/internal/command/aggregate/aggregate.go b/internal/command/aggregate/aggregate.go index a35e2be..fb6f501 100644 --- a/internal/command/aggregate/aggregate.go +++ b/internal/command/aggregate/aggregate.go @@ -44,7 +44,8 @@ type Command struct { countByLevel mo.Option[aggregate.CountByLevel] filterByQuery bool interactive bool - raw bool + // result stores the fetched aggregation data for rendering + result aggregate.Result } type aggregateCommandFlags struct { @@ -54,7 +55,6 @@ type aggregateCommandFlags struct { countByLevel flags.StringFlag filterByQuery flags.BoolFlag interactive flags.BoolFlag - raw flags.BoolFlag } var _ command.Command = (*Command)(nil) @@ -85,9 +85,18 @@ func (c *Command) Examples() []string { return []string{ `"host.services.protocol=SSH" "host.services.port"`, `-c "services.service_name:HTTP" "services.port"`, + `"services.service_name:HTTP" "services.port" --output-format json`, } } +func (c *Command) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *Command) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeData, command.OutputTypeShort} +} + func (c *Command) Init() error { c.flags.orgID = flags.NewOrgIDFlag(c.Flags(), "") c.flags.collectionID = flags.NewUUIDFlag( @@ -130,13 +139,6 @@ func (c *Command) Init() error { false, "display results in an interactive table (TUI)", ) - c.flags.raw = flags.NewBoolFlag( - c.Flags(), - "raw", - "r", - false, - "output raw data", - ) return nil } @@ -188,15 +190,6 @@ func (c *Command) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliE if err != nil { return err } - // validate raw (if present) - c.raw, err = c.flags.raw.Value() - if err != nil { - return err - } - // validate that raw and interactive are not both set - if c.raw && c.interactive { - return flags.NewConflictingFlagsError("raw", "interactive") - } return nil } @@ -210,14 +203,13 @@ func (c *Command) Run(cmd *cobra.Command, args []string) cenclierrors.CencliErro "countByLevel_set", c.countByLevel.IsPresent(), "filterByQuery", c.filterByQuery, ) - var result aggregate.Result err := c.WithProgress( cmd.Context(), logger, "Fetching aggregation results...", func(pctx context.Context) cenclierrors.CencliError { var fetchErr cenclierrors.CencliError - result, fetchErr = c.fetchAggregateResult(pctx) + c.result, fetchErr = c.fetchAggregateResult(pctx) return fetchErr }, ) @@ -225,7 +217,11 @@ func (c *Command) Run(cmd *cobra.Command, args []string) cenclierrors.CencliErro logger.Debug("fetch failed", "error", err) return err } - return c.renderAggregateResult(result) + + // Print response metadata + c.PrintAppResponseMeta(c.result.Meta) + + return c.PrintData(c, c.result.Buckets) } func (c *Command) fetchAggregateResult(ctx context.Context) (aggregate.Result, cenclierrors.CencliError) { @@ -250,17 +246,12 @@ func (c *Command) buildAggregateParams() aggregate.Params { } } -func (c *Command) renderAggregateResult(result aggregate.Result) cenclierrors.CencliError { - c.PrintAppResponseMeta(result.Meta) +func (c *Command) RenderShort() cenclierrors.CencliError { if c.interactive { - return c.showInteractiveTable(result) - } - if c.raw { - c.PrintAppResponseMeta(result.Meta) - return c.PrintData(result.Buckets) + return c.showInteractiveTable(c.result) } // Default: show raw table - return c.showRawTable(result) + return c.showRawTable(c.result) } // buildTableTitle constructs a title string that includes the query, count-by-level, and filter-by-query settings. @@ -354,7 +345,7 @@ func (*Command) Tapes(recorder *tape.Recorder) []tape.Tape { tape.WithClearAfter(), ), recorder.Type( - "aggregate 'host.services.port=22' host.services.protocol --raw", + "aggregate 'host.services.port=22' host.services.protocol --output-format json", tape.WithSleepAfter(3), tape.WithClearAfter(), ), diff --git a/internal/command/aggregate/aggregate_test.go b/internal/command/aggregate/aggregate_test.go index cd67df6..8ca9b14 100644 --- a/internal/command/aggregate/aggregate_test.go +++ b/internal/command/aggregate/aggregate_test.go @@ -18,7 +18,6 @@ import ( "github.com/censys/cencli/internal/config" "github.com/censys/cencli/internal/pkg/cenclierrors" "github.com/censys/cencli/internal/pkg/domain/responsemeta" - "github.com/censys/cencli/internal/pkg/flags" "github.com/censys/cencli/internal/pkg/formatter" "github.com/censys/cencli/internal/store" ) @@ -501,7 +500,7 @@ func TestAggregateCommand(t *testing.T) { // Output format tests { - name: "success - raw flag outputs JSON", + name: "success - output-format json outputs JSON", store: func(ctrl *gomock.Controller) store.Store { return storemocks.NewMockStore(ctrl) }, @@ -519,7 +518,7 @@ func TestAggregateCommand(t *testing.T) { }, nil) return mockSvc }, - args: []string{"--raw", "services.service_name:HTTP", "services.port"}, + args: []string{"--output-format", "json", "services.service_name:HTTP", "services.port"}, assert: func(t *testing.T, stdout, stderr string, err error) { require.NoError(t, err) // Should contain JSON output @@ -530,7 +529,7 @@ func TestAggregateCommand(t *testing.T) { }, }, { - name: "success - raw flag short form outputs JSON", + name: "success - output-format json short form outputs JSON", store: func(ctrl *gomock.Controller) store.Store { return storemocks.NewMockStore(ctrl) }, @@ -547,7 +546,7 @@ func TestAggregateCommand(t *testing.T) { }, nil) return mockSvc }, - args: []string{"-r", "protocol:SSH", "port"}, + args: []string{"-O", "json", "protocol:SSH", "port"}, assert: func(t *testing.T, stdout, stderr string, err error) { require.NoError(t, err) // Should contain JSON output @@ -557,7 +556,7 @@ func TestAggregateCommand(t *testing.T) { }, }, { - name: "success - default outputs raw table", + name: "success - default outputs short table", store: func(ctrl *gomock.Controller) store.Store { return storemocks.NewMockStore(ctrl) }, @@ -669,36 +668,6 @@ func TestAggregateCommand(t *testing.T) { require.Contains(t, stdout, "filtered: false") }, }, - - // Flag conflict tests - { - name: "error - raw and interactive flags together", - store: func(ctrl *gomock.Controller) store.Store { - return storemocks.NewMockStore(ctrl) - }, - service: func(ctrl *gomock.Controller) aggregate.Service { - return aggregatemocks.NewMockAggregateService(ctrl) - }, - args: []string{"--raw", "--interactive", "query", "field"}, - assert: func(t *testing.T, stdout, stderr string, err error) { - require.Error(t, err) - require.Equal(t, flags.NewConflictingFlagsError("raw", "interactive"), err) - }, - }, - { - name: "error - raw and interactive flags together (short form)", - store: func(ctrl *gomock.Controller) store.Store { - return storemocks.NewMockStore(ctrl) - }, - service: func(ctrl *gomock.Controller) aggregate.Service { - return aggregatemocks.NewMockAggregateService(ctrl) - }, - args: []string{"-r", "-i", "query", "field"}, - assert: func(t *testing.T, stdout, stderr string, err error) { - require.Error(t, err) - require.Contains(t, err.Error(), "cannot use --raw and --interactive flags together") - }, - }, } for _, tc := range testCases { diff --git a/internal/command/base.go b/internal/command/base.go index 7173d35..f9e469b 100644 --- a/internal/command/base.go +++ b/internal/command/base.go @@ -31,6 +31,8 @@ func (b *BaseCommand) Flags() *pflag.FlagSet { return b.rootCmd.Flags() } func (b *BaseCommand) PersistentFlags() *pflag.FlagSet { return b.rootCmd.PersistentFlags() } +func (b *BaseCommand) InheritedFlags() *pflag.FlagSet { return b.rootCmd.InheritedFlags() } + func (b *BaseCommand) AddSubCommands(cmds ...Command) error { for _, cmd := range cmds { c, err := toCobra(cmd) @@ -38,6 +40,10 @@ func (b *BaseCommand) AddSubCommands(cmds ...Command) error { return fmt.Errorf("failed to build command %s: %w", cmd.Use(), err) } b.rootCmd.AddCommand(c) + + if err := applyOutputFormatDefaultsRecursive(c, cmd); err != nil { + return fmt.Errorf("failed to apply output format defaults for %s: %w", cmd.Use(), err) + } } return nil } @@ -66,12 +72,53 @@ func (b *BaseCommand) Examples() []string { return []string{} } func (b *BaseCommand) Long() string { return "" } +func (b *BaseCommand) DefaultOutputType() OutputType { + return OutputTypeData +} + +func (b *BaseCommand) SupportedOutputTypes() []OutputType { + return []OutputType{OutputTypeData} +} + +func (b *BaseCommand) SupportsStreaming() bool { + return false +} + +func (b *BaseCommand) RenderShort() cenclierrors.CencliError { + // this should theoretically never happen, since the command should not be executed if the output format is not supported + return cenclierrors.NewCencliError(fmt.Errorf("short output not supported for this command")) +} + +func (b *BaseCommand) RenderTemplate() cenclierrors.CencliError { + // this should theoretically never happen, since the command should not be executed if the output format is not supported + return cenclierrors.NewCencliError(fmt.Errorf("template output not supported for this command")) +} + func (b *BaseCommand) init(cmd Command) { b.rootCmd.PersistentPreRunE = func(cobraCmd *cobra.Command, args []string) error { // unmarshal the config so it is available to the command if err := b.Config().Unmarshal(); err != nil { return err } + + // Update color settings after config is re-unmarshaled to respect command-line flags + b.Context.updateColorSettings() + + // Validate streaming mode for conflicts and support + if err := validateStreamingMode(cobraCmd, cmd, b.config.Streaming); err != nil { + return err + } + + // special case for output format + // we need to manually inspect the command's flags to see if the user explicitly set the output format + // since there are some shenanigans with the flag binding and the default value being set after unmarshal + b.config.OutputFormat = getOutputFormatValue(cobraCmd, cmd, b.config.OutputFormat) + + // Validate output format before command execution + if err := validateOutputFormat(b.config.OutputFormat, cmd); err != nil { + return err + } + // set the logger b.SetLogger(applog.New(b.Config().Debug, nil)) return nil diff --git a/internal/command/censeye/censeye.go b/internal/command/censeye/censeye.go index ad766ba..4d3713d 100644 --- a/internal/command/censeye/censeye.go +++ b/internal/command/censeye/censeye.go @@ -43,9 +43,10 @@ type Command struct { rarityMin uint64 rarityMax uint64 interactive bool - raw bool includeURL bool hostID string + // result stored for rendering + result censeye.InvestigateHostResult } type censeyeCommandFlags struct { @@ -54,7 +55,6 @@ type censeyeCommandFlags struct { rarityMin flags.IntegerFlag rarityMax flags.IntegerFlag interactive flags.BoolFlag - raw flags.BoolFlag includeURL flags.BoolFlag } @@ -81,7 +81,8 @@ func (c *Command) Examples() []string { return []string{ "8.8.8.8", "--rarity-min 2 --rarity-max 25 1.1.1.1", - "--raw --include-url 192.168.1.1", + "--interactive 192.168.1.1", + "--output-format json --include-url 192.168.1.1", } } @@ -121,13 +122,6 @@ func (c *Command) Init() error { false, "display results in an interactive table (TUI)", ) - c.flags.raw = flags.NewBoolFlag( - c.Flags(), - "raw", - "r", - false, - "output raw data", - ) c.flags.includeURL = flags.NewBoolFlag( c.Flags(), "include-url", @@ -138,6 +132,14 @@ func (c *Command) Init() error { return nil } +func (c *Command) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *Command) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeData, command.OutputTypeShort} +} + func (c *Command) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { var err cenclierrors.CencliError c.orgID, err = c.flags.orgID.Value() @@ -189,20 +191,11 @@ func (c *Command) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliE if err != nil { return err } - // validate raw (if present) - c.raw, err = c.flags.raw.Value() - if err != nil { - return err - } // validate includeURL (if present) c.includeURL, err = c.flags.includeURL.Value() if err != nil { return err } - // validate that raw and interactive are not both set - if c.raw && c.interactive { - return flags.NewConflictingFlagsError("raw", "interactive") - } // resolve services err = c.resolveServices() if err != nil { @@ -214,7 +207,6 @@ func (c *Command) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliE func (c *Command) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { logger := c.Logger(cmdName).With("hostID", c.hostID) - var result censeye.InvestigateHostResult if err := c.WithProgress( cmd.Context(), logger, @@ -234,14 +226,28 @@ func (c *Command) Run(cmd *cobra.Command, args []string) cenclierrors.CencliErro if investigateErr != nil { return investigateErr } - result = res + c.result = res return nil }, ); err != nil { return err } - return c.renderResult(result) + // Print response metadata + c.PrintAppResponseMeta(c.result.Meta) + + return c.PrintData(c, c.result.Entries) +} + +// RenderShort renders the censeye results as a human-readable table. +// If the interactive flag is set, displays an interactive TUI table. +// Otherwise, displays a static styled table with pivots. +func (c *Command) RenderShort() cenclierrors.CencliError { + if c.interactive { + return c.showInteractiveTable(c.result) + } + // Default: show raw table + return c.showRawTable(c.result) } func (c *Command) resolveServices() cenclierrors.CencliError { @@ -316,10 +322,10 @@ func (*Command) Tapes(recorder *tape.Recorder) []tape.Tape { recorder.SpamPress("j", 50), recorder.Sleep(5), ), - tape.NewTape("censeye-raw", + tape.NewTape("censeye-json", tape.DefaultTapeConfig(), recorder.Type( - "censeye 145.131.8.169 --raw --include-url", + "censeye 145.131.8.169 --output-format json --include-url", tape.WithSleepAfter(15), ), ), diff --git a/internal/command/censeye/censeye_test.go b/internal/command/censeye/censeye_test.go index 1763fe5..8496e12 100644 --- a/internal/command/censeye/censeye_test.go +++ b/internal/command/censeye/censeye_test.go @@ -127,7 +127,7 @@ func TestCenseyeCommand(t *testing.T) { }, }, { - name: "success - raw output", + name: "success - json output", viewSvc: func(ctrl *gomock.Controller) view.Service { ms := viewmocks.NewMockViewService(ctrl) hostID, _ := assets.NewHostID("8.8.8.8") @@ -156,7 +156,7 @@ func TestCenseyeCommand(t *testing.T) { ms.EXPECT().InvestigateHost(gomock.Any(), mo.None[identifiers.OrganizationID](), gomock.Any(), uint64(2), uint64(100)).Return(result, nil) return ms }, - args: []string{"8.8.8.8", "--raw"}, + args: []string{"8.8.8.8", "--output-format", "json"}, assert: func(t *testing.T, stdout, stderr string, err error) { require.NoError(t, err) // Verify valid JSON @@ -167,12 +167,10 @@ func TestCenseyeCommand(t *testing.T) { require.Equal(t, int64(10), entries[0].Count) require.Equal(t, `services.port=80`, entries[0].Query) require.True(t, entries[0].Interesting) - // search_url should be stripped in raw output by default - require.Empty(t, entries[0].SearchURL) }, }, { - name: "success - raw output with url", + name: "success - json output with url", viewSvc: func(ctrl *gomock.Controller) view.Service { ms := viewmocks.NewMockViewService(ctrl) hostID, _ := assets.NewHostID("8.8.8.8") @@ -201,7 +199,7 @@ func TestCenseyeCommand(t *testing.T) { ms.EXPECT().InvestigateHost(gomock.Any(), mo.None[identifiers.OrganizationID](), gomock.Any(), uint64(2), uint64(100)).Return(result, nil) return ms }, - args: []string{"8.8.8.8", "--raw", "--include-url"}, + args: []string{"8.8.8.8", "--output-format", "json", "--include-url"}, assert: func(t *testing.T, stdout, stderr string, err error) { require.NoError(t, err) // Verify valid JSON @@ -531,7 +529,7 @@ func TestCenseyeCommand(t *testing.T) { }, }, { - name: "help message", + name: "help message - no raw flag", viewSvc: func(ctrl *gomock.Controller) view.Service { return viewmocks.NewMockViewService(ctrl) }, @@ -545,14 +543,15 @@ func TestCenseyeCommand(t *testing.T) { require.Contains(t, stdout, "censeye ") require.Contains(t, stdout, "rarity-min") require.Contains(t, stdout, "rarity-max") - require.Contains(t, stdout, "raw") require.Contains(t, stdout, "include-url") + // Should not have --raw flag anymore + require.NotContains(t, stdout, "--raw") }, }, // Output format tests { - name: "success - raw flag outputs JSON", + name: "success - output-format json flag outputs JSON", viewSvc: func(ctrl *gomock.Controller) view.Service { ms := viewmocks.NewMockViewService(ctrl) hostID, _ := assets.NewHostID("8.8.8.8") @@ -581,7 +580,7 @@ func TestCenseyeCommand(t *testing.T) { ms.EXPECT().InvestigateHost(gomock.Any(), mo.None[identifiers.OrganizationID](), gomock.Any(), uint64(2), uint64(100)).Return(result, nil) return ms }, - args: []string{"--raw", "8.8.8.8"}, + args: []string{"--output-format", "json", "8.8.8.8"}, assert: func(t *testing.T, stdout, stderr string, err error) { require.NoError(t, err) // Should contain JSON output @@ -596,7 +595,7 @@ func TestCenseyeCommand(t *testing.T) { }, }, { - name: "success - raw flag with include-url", + name: "success - output-format json with include-url", viewSvc: func(ctrl *gomock.Controller) view.Service { ms := viewmocks.NewMockViewService(ctrl) hostID, _ := assets.NewHostID("8.8.8.8") @@ -625,7 +624,7 @@ func TestCenseyeCommand(t *testing.T) { ms.EXPECT().InvestigateHost(gomock.Any(), mo.None[identifiers.OrganizationID](), gomock.Any(), uint64(2), uint64(100)).Return(result, nil) return ms }, - args: []string{"--raw", "--include-url", "8.8.8.8"}, + args: []string{"--output-format", "json", "--include-url", "8.8.8.8"}, assert: func(t *testing.T, stdout, stderr string, err error) { require.NoError(t, err) // Should contain JSON output with search_url @@ -637,7 +636,7 @@ func TestCenseyeCommand(t *testing.T) { }, }, { - name: "success - default outputs raw table", + name: "success - default outputs short table", viewSvc: func(ctrl *gomock.Controller) view.Service { ms := viewmocks.NewMockViewService(ctrl) hostID, _ := assets.NewHostID("8.8.8.8") @@ -680,23 +679,8 @@ func TestCenseyeCommand(t *testing.T) { }, }, - // Flag conflict tests { - name: "error - raw and interactive flags together", - viewSvc: func(ctrl *gomock.Controller) view.Service { - return viewmocks.NewMockViewService(ctrl) - }, - censeyeSvc: func(ctrl *gomock.Controller) censeye.Service { - return censeyemocks.NewMockCenseyeService(ctrl) - }, - args: []string{"--raw", "--interactive", "8.8.8.8"}, - assert: func(t *testing.T, stdout, stderr string, err error) { - require.Error(t, err) - require.Equal(t, flags.NewConflictingFlagsError("raw", "interactive"), err) - }, - }, - { - name: "success - raw flag with input-file short form", + name: "success - output-format json with input-file short form", viewSvc: func(ctrl *gomock.Controller) view.Service { ms := viewmocks.NewMockViewService(ctrl) hostID, _ := assets.NewHostID("8.8.8.8") @@ -724,7 +708,7 @@ func TestCenseyeCommand(t *testing.T) { require.NoError(t, os.WriteFile(tempDir+"/test.txt", []byte("8.8.8.8\n"), 0o644)) (*args)[len(*args)-1] = tempDir + "/test.txt" }, - args: []string{"-r", "-i", "test.txt"}, + args: []string{"--output-format", "json", "-i", "test.txt"}, assert: func(t *testing.T, stdout, stderr string, err error) { require.NoError(t, err) var entries []censeye.ReportEntry diff --git a/internal/command/censeye/render.go b/internal/command/censeye/render.go index 4fa63ca..04024ea 100644 --- a/internal/command/censeye/render.go +++ b/internal/command/censeye/render.go @@ -16,26 +16,6 @@ import ( "github.com/censys/cencli/internal/pkg/ui/table" ) -// renderResult prints response metadata and routes to the appropriate rendering method -// based on the configured output mode (interactive, raw, or default table). -func (c *Command) renderResult(result censeye.InvestigateHostResult) cenclierrors.CencliError { - c.PrintAppResponseMeta(result.Meta) - if c.interactive { - return c.showInteractiveTable(result) - } - if c.raw { - entries := result.Entries - if !c.includeURL { - for i := range entries { - entries[i].SearchURL = "" - } - } - return c.PrintData(entries) - } - // Default: show raw table - return c.showRawTable(result) -} - // showInteractiveTable displays an interactive table where users can navigate with arrow keys // and open queries in their browser by pressing Enter. func (c *Command) showInteractiveTable(result censeye.InvestigateHostResult) cenclierrors.CencliError { diff --git a/internal/command/command.go b/internal/command/command.go index 832ac1a..31df1d1 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -65,6 +65,22 @@ type Command interface { // Used for modifying persistent flags on the command. // Should not be implemented. PersistentFlags() *pflag.FlagSet + // InheritedFlags returns the underlying inherited flag set for the command. + // Used for modifying inherited flags on the command. + // Should not be implemented. + InheritedFlags() *pflag.FlagSet + // DefaultOutputType returns the default output type for this command. + DefaultOutputType() OutputType + // SupportedOutputTypes returns the output types this command supports. + // OutputTypeData includes json, yaml, and tree formats (buffered output). + SupportedOutputTypes() []OutputType + // SupportsStreaming returns true if this command supports streaming output mode. + // Commands that return true must use WithStreamingOutput in their Run implementation. + SupportsStreaming() bool + // RenderShort renders the command output in short format. + RenderShort() cenclierrors.CencliError + // RenderTemplate renders the command output using a template. + RenderTemplate() cenclierrors.CencliError // init is used to internally initialize the command. // For example, it will set the persistent pre-run function to unmarshal the config // so it is available to the command. @@ -93,11 +109,21 @@ func toCobra(cmd Command) (*cobra.Command, error) { return nil }) + // Custom flag error handler to wrap flag parsing errors as CencliError + // This ensures usage information is printed for flag errors like unknown flags + cobraCmd.SetFlagErrorFunc(func(c *cobra.Command, err error) error { + return cenclierrors.NewUsageError(err) + }) + if err := cmd.Init(); err != nil { return nil, fmt.Errorf("failed during Init(): %w", err) } cmd.init(cmd) + if err := applyOutputFormatDefaults(cobraCmd, cmd); err != nil { + return nil, fmt.Errorf("failed to apply output format defaults: %w", err) + } + cobraCmd.Use = cmd.Use() if cobraCmd.Use == "" { return nil, fmt.Errorf("Use() is empty") diff --git a/internal/command/command_test.go b/internal/command/command_test.go index ddc2cb5..b870c41 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -20,14 +20,17 @@ import ( type testCommand struct { *BaseCommand - useFn func() string - shortFn func() string - longFn func() string - argsFn func() PositionalArgs - preRunFn func(cmd *cobra.Command, args []string) cenclierrors.CencliError - runFn func(cmd *cobra.Command, args []string) cenclierrors.CencliError - postRunFn func(cmd *cobra.Command, args []string) cenclierrors.CencliError - initFn func(c Command) error + useFn func() string + shortFn func() string + longFn func() string + argsFn func() PositionalArgs + preRunFn func(cmd *cobra.Command, args []string) cenclierrors.CencliError + runFn func(cmd *cobra.Command, args []string) cenclierrors.CencliError + postRunFn func(cmd *cobra.Command, args []string) cenclierrors.CencliError + initFn func(c Command) error + defaultOutputTypeFn func() OutputType + supportedOutputTypesFn func() []OutputType + supportsStreamingFn func() bool } var _ Command = &testCommand{} @@ -49,6 +52,27 @@ func (c *testCommand) PostRun(cmd *cobra.Command, args []string) cenclierrors.Ce } func (c *testCommand) Init() error { return c.initFn(c) } +func (c *testCommand) DefaultOutputType() OutputType { + if c.defaultOutputTypeFn != nil { + return c.defaultOutputTypeFn() + } + return c.BaseCommand.DefaultOutputType() +} + +func (c *testCommand) SupportedOutputTypes() []OutputType { + if c.supportedOutputTypesFn != nil { + return c.supportedOutputTypesFn() + } + return c.BaseCommand.SupportedOutputTypes() +} + +func (c *testCommand) SupportsStreaming() bool { + if c.supportsStreamingFn != nil { + return c.supportsStreamingFn() + } + return c.BaseCommand.SupportsStreaming() +} + func newTestCommand(cmdContext *Context) *testCommand { return &testCommand{ BaseCommand: NewBaseCommand(cmdContext), @@ -151,7 +175,7 @@ func TestCommand(t *testing.T) { } require.NoError(t, err) - require.NoError(t, config.BindGlobalFlags(rootCmd.PersistentFlags())) + require.NoError(t, config.BindGlobalFlags(rootCmd.PersistentFlags(), cfg)) rootCmd.SetArgs(tc.args) var stdout, stderr bytes.Buffer diff --git a/internal/command/completion/completion.go b/internal/command/completion/completion.go index 209adc6..b22f100 100644 --- a/internal/command/completion/completion.go +++ b/internal/command/completion/completion.go @@ -29,6 +29,14 @@ func (c *Command) Args() command.PositionalArgs { return command.RangeArgs(1, 1) } +func (c *Command) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *Command) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort} +} + func (c *Command) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { return nil } diff --git a/internal/command/config/auth.go b/internal/command/config/auth.go index 1f5e0a5..76ab528 100644 --- a/internal/command/config/auth.go +++ b/internal/command/config/auth.go @@ -55,6 +55,15 @@ func (c *authCommand) Init() error { } func (c *authCommand) Args() command.PositionalArgs { return command.ExactArgs(0) } + +func (c *authCommand) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *authCommand) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort} +} + func (c *authCommand) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { var err cenclierrors.CencliError c.accessible, err = c.flags.accessible.Value() diff --git a/internal/command/config/auth_manage.go b/internal/command/config/auth_manage.go index c5faf7c..16292f8 100644 --- a/internal/command/config/auth_manage.go +++ b/internal/command/config/auth_manage.go @@ -47,6 +47,14 @@ func (c *addAuthCommand) Long() string { return "Add a new personal access toke func (c *addAuthCommand) Args() command.PositionalArgs { return command.ExactArgs(0) } +func (c *addAuthCommand) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *addAuthCommand) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort} +} + func (c *addAuthCommand) Init() error { c.flags.accessible = flags.NewBoolFlag( c.Flags(), @@ -207,6 +215,14 @@ func (c *deleteAuthCommand) Short() string { return "Delete a personal access to func (c *deleteAuthCommand) Args() command.PositionalArgs { return command.ExactArgs(1) } +func (c *deleteAuthCommand) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *deleteAuthCommand) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort} +} + func (c *deleteAuthCommand) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { return nil } @@ -244,6 +260,14 @@ func (c *activateAuthCommand) Short() string { return "Set the active personal a func (c *activateAuthCommand) Args() command.PositionalArgs { return command.ExactArgs(1) } +func (c *activateAuthCommand) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *activateAuthCommand) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort} +} + func (c *activateAuthCommand) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { return nil } diff --git a/internal/command/config/config.go b/internal/command/config/config.go index 2f03a30..9e4e05e 100644 --- a/internal/command/config/config.go +++ b/internal/command/config/config.go @@ -34,6 +34,15 @@ func (c *Command) Init() error { } func (c *Command) Args() command.PositionalArgs { return command.ExactArgs(0) } + +func (c *Command) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *Command) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort} +} + func (c *Command) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { return nil } diff --git a/internal/command/config/orgid.go b/internal/command/config/orgid.go index 1fc6ac7..fc1206d 100644 --- a/internal/command/config/orgid.go +++ b/internal/command/config/orgid.go @@ -44,6 +44,14 @@ func (c *organizationIDCommand) Long() string { func (c *organizationIDCommand) Args() command.PositionalArgs { return command.ExactArgs(0) } +func (c *organizationIDCommand) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *organizationIDCommand) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort} +} + func (c *organizationIDCommand) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { var err cenclierrors.CencliError c.accessible, err = c.flags.accessible.Value() diff --git a/internal/command/config/orgid_manage.go b/internal/command/config/orgid_manage.go index 9de4df2..26bcc03 100644 --- a/internal/command/config/orgid_manage.go +++ b/internal/command/config/orgid_manage.go @@ -50,6 +50,14 @@ func (c *addOrganizationIDCommand) Long() string { func (c *addOrganizationIDCommand) Args() command.PositionalArgs { return command.ExactArgs(0) } +func (c *addOrganizationIDCommand) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *addOrganizationIDCommand) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort} +} + func (c *addOrganizationIDCommand) Init() error { c.flags.accessible = flags.NewBoolFlag( c.Flags(), @@ -212,6 +220,14 @@ func (c *deleteOrganizationIDCommand) Short() string { return "Delete a stored o func (c *deleteOrganizationIDCommand) Args() command.PositionalArgs { return command.ExactArgs(1) } +func (c *deleteOrganizationIDCommand) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *deleteOrganizationIDCommand) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort} +} + func (c *deleteOrganizationIDCommand) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { return nil } @@ -258,6 +274,14 @@ func (c *activateOrganizationIDCommand) Short() string { return "Set the active func (c *activateOrganizationIDCommand) Args() command.PositionalArgs { return command.ExactArgs(1) } +func (c *activateOrganizationIDCommand) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *activateOrganizationIDCommand) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort} +} + func (c *activateOrganizationIDCommand) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { return nil } diff --git a/internal/command/config/print.go b/internal/command/config/print.go index 063bb8b..2fc1548 100644 --- a/internal/command/config/print.go +++ b/internal/command/config/print.go @@ -25,6 +25,14 @@ func (c *printCommand) PreRun(cmd *cobra.Command, args []string) cenclierrors.Ce return nil } +func (c *printCommand) DefaultOutputType() command.OutputType { + return command.OutputTypeData +} + +func (c *printCommand) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeData} +} + func (c *printCommand) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { return c.PrintYAML(c.Config()) } diff --git a/internal/command/context.go b/internal/command/context.go index 0cbb2c1..3805814 100644 --- a/internal/command/context.go +++ b/internal/command/context.go @@ -2,16 +2,25 @@ package command import ( "context" + "errors" "log/slog" + "sync" + + "github.com/google/uuid" + "github.com/samber/mo" "github.com/censys/cencli/internal/app/aggregate" "github.com/censys/cencli/internal/app/censeye" + "github.com/censys/cencli/internal/app/credits" "github.com/censys/cencli/internal/app/history" + "github.com/censys/cencli/internal/app/organizations" "github.com/censys/cencli/internal/app/search" + "github.com/censys/cencli/internal/app/streaming" "github.com/censys/cencli/internal/app/view" "github.com/censys/cencli/internal/config" "github.com/censys/cencli/internal/pkg/cenclierrors" client "github.com/censys/cencli/internal/pkg/clients/censys" + "github.com/censys/cencli/internal/pkg/domain/identifiers" "github.com/censys/cencli/internal/pkg/domain/responsemeta" "github.com/censys/cencli/internal/pkg/formatter" "github.com/censys/cencli/internal/pkg/styles" @@ -32,6 +41,8 @@ type Context struct { aggregateSvc aggregate.Service historySvc history.Service censeyeSvc censeye.Service + creditsSvc credits.Service + orgSvc organizations.Service } // ContextOpts are functional options for configuring Context @@ -46,6 +57,13 @@ func NewCommandContext( for _, opt := range opts { opt(c) } + c.updateColorSettings() + return c +} + +// updateColorSettings evaluates and updates the color settings based on current config. +// This should be called after config is loaded or re-unmarshaled. +func (c *Context) updateColorSettings() { if c.config.NoColor || styles.ColorDisabled() { // globally disable lipgloss styles styles.DisableStyles() @@ -55,12 +73,15 @@ func NewCommandContext( } else { if c.config.NoColor || styles.ColorDisabled() || !formatter.StdoutIsTTY() { c.colorDisabledStdout = true + } else { + c.colorDisabledStdout = false } if c.config.NoColor || styles.ColorDisabled() || !formatter.StderrIsTTY() { c.colorDisabledStderr = true + } else { + c.colorDisabledStderr = false } } - return c } func (c *Context) Config() *config.Config { return c.config } @@ -72,6 +93,29 @@ func (c *Context) SetLogger(l *slog.Logger) { c.logger = l } // SetClient sets the Context's client so that it can be used to initialize services. func (c *Context) SetCensysClient(cli client.Client) { c.censysClient = cli } +// HasOrgID returns true if the context has a configured organization ID. +func (c *Context) HasOrgID() bool { + return c.censysClient != nil && c.censysClient.HasOrgID() +} + +// GetStoredOrgID retrieves the stored organization ID from the store. +// Returns the org ID if found, or None if not configured. +func (c *Context) GetStoredOrgID(ctx context.Context) (mo.Option[identifiers.OrganizationID], cenclierrors.CencliError) { + zero := mo.None[identifiers.OrganizationID]() + storedOrgID, err := c.store.GetLastUsedGlobalByName(ctx, config.OrgIDGlobalName) + if err != nil { + if errors.Is(err, store.ErrGlobalNotFound) { + return zero, nil + } + return zero, cenclierrors.NewCencliError(err) + } + parsedUUID, parseErr := uuid.Parse(storedOrgID.Value) + if parseErr != nil { + return zero, cenclierrors.NewCencliError(parseErr) + } + return mo.Some(identifiers.NewOrganizationID(parsedUUID)), nil +} + // Logger returns a logger pre-populated with the command name field. func (c *Context) Logger(cmdName string) *slog.Logger { return c.logger.With("cmd", cmdName) @@ -108,9 +152,28 @@ func (c *Context) WithProgress( return err } -// PrintData renders data according to the configured output format. -func (c *Context) PrintData(data any) cenclierrors.CencliError { - return cenclierrors.NewCencliError(formatter.PrintByFormat(data, c.config.OutputFormat, !c.colorDisabledStdout)) +func (c *Context) PrintData(cmd Command, data any) cenclierrors.CencliError { + // Streaming formats are handled by WithStreamingOutput - nothing to do here + if c.config.Streaming { + return nil + } + + switch c.config.OutputFormat { + case formatter.OutputFormatShort: + if c.colorDisabledStdout { + enable := styles.TemporarilyDisableStyles() + defer enable() + } + return cmd.RenderShort() + case formatter.OutputFormatTemplate: + if c.colorDisabledStdout { + enable := styles.TemporarilyDisableStyles() + defer enable() + } + return cmd.RenderTemplate() + default: + return formatter.PrintByFormat(data, c.config.OutputFormat, !c.colorDisabledStdout) + } } // PrintYAML renders data as YAML. @@ -136,6 +199,58 @@ func (c *Context) PrintAppResponseMeta(meta *responsemeta.ResponseMeta) { } } +// WithStreamingOutput sets up streaming output infrastructure when streaming mode is enabled. +// For non-streaming mode, this is a no-op. +// +// Returns a context with a streaming emitter attached (if streaming) and a stop function +// that must be called to properly clean up resources. The stop function should be deferred +// immediately after calling WithStreamingOutput. +// +// Example usage: +// +// ctx, stopStreaming := c.WithStreamingOutput(cmd.Context(), logger) +// defer stopStreaming(nil) +func (c *Context) WithStreamingOutput( + ctx context.Context, + logger *slog.Logger, +) (context.Context, func(error)) { + // No-op for non-streaming formats + if !c.config.Streaming { + return ctx, func(error) {} + } + + emitter, items := streaming.NewChannelEmitter(1) + ctx = streaming.WithEmitter(ctx, emitter) + + // Start goroutine to consume and write items immediately + done := make(chan struct{}) + go func() { + defer close(done) + for item := range items { + if item.Done { + break + } + if item.Err != nil { + logger.Debug("streaming item error", "error", item.Err) + continue + } + if err := formatter.WriteNDJSONItem(formatter.Stdout, item.Data, !c.colorDisabledStdout); err != nil { + logger.Debug("failed to write streaming item", "error", err) + } + } + }() + + var once sync.Once + stop := func(finalErr error) { + once.Do(func() { + emitter.Close(finalErr) + <-done + }) + } + + return ctx, stop +} + // ===================== // Service-specific // ===================== @@ -242,3 +357,45 @@ func (c *Context) AggregateService() (aggregate.Service, cenclierrors.CencliErro func WithAggregateService(svc aggregate.Service) ContextOpts { return func(c *Context) { c.aggregateSvc = svc } } + +// CreditsService attempts to provide a CreditsService to the caller. +// If it is not already set and is unable to be instantiated, it will return an error. +func (c *Context) CreditsService() (credits.Service, cenclierrors.CencliError) { + if c.creditsSvc != nil { + return c.creditsSvc, nil + } + if c.censysClient == nil { + return nil, client.NewCensysClientNotConfiguredError() + } + // Memoize the service instance since it's stateless and thread-safe for reuse + c.creditsSvc = credits.New(c.censysClient) + return c.creditsSvc, nil +} + +// WithCreditsService injects an instantiated CreditsService to the Context. +// This should only be used in tests, as in the application, +// the CreditsService will be instantiated on demand. +func WithCreditsService(svc credits.Service) ContextOpts { + return func(c *Context) { c.creditsSvc = svc } +} + +// OrganizationsService attempts to provide an OrganizationsService to the caller. +// If it is not already set and is unable to be instantiated, it will return an error. +func (c *Context) OrganizationsService() (organizations.Service, cenclierrors.CencliError) { + if c.orgSvc != nil { + return c.orgSvc, nil + } + if c.censysClient == nil { + return nil, client.NewCensysClientNotConfiguredError() + } + // Memoize the service instance since it's stateless and thread-safe for reuse + c.orgSvc = organizations.New(c.censysClient) + return c.orgSvc, nil +} + +// WithOrganizationsService injects an instantiated OrganizationsService to the Context. +// This should only be used in tests, as in the application, +// the OrganizationsService will be instantiated on demand. +func WithOrganizationsService(svc organizations.Service) ContextOpts { + return func(c *Context) { c.orgSvc = svc } +} diff --git a/internal/command/credits/credits.go b/internal/command/credits/credits.go new file mode 100644 index 0000000..5d41bf2 --- /dev/null +++ b/internal/command/credits/credits.go @@ -0,0 +1,98 @@ +package credits + +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/app/credits" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/pkg/cenclierrors" +) + +const cmdName = "credits" + +type Command struct { + *command.BaseCommand + // services the command uses + creditsSvc credits.Service + // result stored for rendering + result credits.UserCreditDetailsResult +} + +var _ command.Command = (*Command)(nil) + +func NewCreditsCommand(cmdContext *command.Context) *Command { + return &Command{ + BaseCommand: command.NewBaseCommand(cmdContext), + } +} + +func (c *Command) Use() string { + return cmdName +} + +func (c *Command) Short() string { + return "Display credit details for your Censys account" +} + +func (c *Command) Long() string { + return `Display credit details for your Free user Censys account. + +Note: This command only shows free user credits. If you want to see organization credits, +run "censys org credits" instead.` +} + +func (c *Command) Args() command.PositionalArgs { + return command.ExactArgs(0) +} + +func (c *Command) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *Command) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeData, command.OutputTypeShort} +} + +func (c *Command) Examples() []string { + return []string{ + "# Show free user credits", + } +} + +func (c *Command) Init() error { + return nil +} + +func (c *Command) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + var err cenclierrors.CencliError + c.creditsSvc, err = c.CreditsService() + if err != nil { + return err + } + return nil +} + +func (c *Command) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + err := c.WithProgress( + cmd.Context(), + c.Logger(cmdName), + "Fetching user credits...", + func(pctx context.Context) cenclierrors.CencliError { + var fetchErr cenclierrors.CencliError + c.result, fetchErr = c.creditsSvc.GetUserCreditDetails(pctx) + return fetchErr + }, + ) + if err != nil { + return err + } + + c.PrintAppResponseMeta(c.result.Meta) + return c.PrintData(c, c.result.Data) +} + +func (c *Command) RenderShort() cenclierrors.CencliError { + return c.showUserCredits(c.result) +} diff --git a/internal/command/credits/credits_test.go b/internal/command/credits/credits_test.go new file mode 100644 index 0000000..a1f5ba2 --- /dev/null +++ b/internal/command/credits/credits_test.go @@ -0,0 +1,173 @@ +package credits + +import ( + "bytes" + "errors" + "net/http" + "testing" + "time" + + "github.com/samber/mo" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + creditsmocks "github.com/censys/cencli/gen/app/credits/mocks" + storemocks "github.com/censys/cencli/gen/store/mocks" + "github.com/censys/cencli/internal/app/credits" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/config" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/domain/responsemeta" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/store" +) + +func TestCreditsCommand(t *testing.T) { + testCases := []struct { + name string + store func(ctrl *gomock.Controller) store.Store + service func(ctrl *gomock.Controller) credits.Service + args []string + assert func(t *testing.T, stdout, stderr string, err error) + }{ + // Success cases - free user credits + { + name: "success - default free user credits", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) credits.Service { + mockSvc := creditsmocks.NewMockCreditsService(ctrl) + mockSvc.EXPECT().GetUserCreditDetails( + gomock.Any(), + ).Return(credits.UserCreditDetailsResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 100*time.Millisecond, 1), + Data: credits.UserCreditDetails{ + Balance: 500, + ResetsAt: mo.Some(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)), + }, + }, nil) + return mockSvc + }, + args: []string{"--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"balance"`) + require.Contains(t, stdout, "500") + }, + }, + { + name: "success - free user credits with balance and reset date", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) credits.Service { + mockSvc := creditsmocks.NewMockCreditsService(ctrl) + mockSvc.EXPECT().GetUserCreditDetails( + gomock.Any(), + ).Return(credits.UserCreditDetailsResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 80*time.Millisecond, 1), + Data: credits.UserCreditDetails{ + Balance: 1000, + ResetsAt: mo.Some(time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC)), + }, + }, nil) + return mockSvc + }, + args: []string{"--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"balance"`) + require.Contains(t, stdout, "1000") + }, + }, + + // Error cases + { + name: "error - too many arguments", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) credits.Service { + return creditsmocks.NewMockCreditsService(ctrl) + }, + args: []string{"extra-arg", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "accepts 0 arg(s), received 1") + }, + }, + { + name: "error - user credits service returns error", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) credits.Service { + mockSvc := creditsmocks.NewMockCreditsService(ctrl) + mockSvc.EXPECT().GetUserCreditDetails( + gomock.Any(), + ).Return(credits.UserCreditDetailsResult{}, cenclierrors.NewCencliError(errors.New("unauthorized"))) + return mockSvc + }, + args: []string{}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "unauthorized") + }, + }, + + // Edge cases + { + name: "success - zero balance user credits", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) credits.Service { + mockSvc := creditsmocks.NewMockCreditsService(ctrl) + mockSvc.EXPECT().GetUserCreditDetails( + gomock.Any(), + ).Return(credits.UserCreditDetailsResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 50*time.Millisecond, 1), + Data: credits.UserCreditDetails{ + Balance: 0, + }, + }, nil) + return mockSvc + }, + args: []string{"--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"balance"`) + require.Contains(t, stdout, `: 0`) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tempDir := t.TempDir() + viper.Reset() + cfg, err := config.New(tempDir) + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + formatter.Stdout = &stdout + formatter.Stderr = &stderr + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + creditsSvc := tc.service(ctrl) + opts := []command.ContextOpts{command.WithCreditsService(creditsSvc)} + + cmdContext := command.NewCommandContext(cfg, tc.store(ctrl), opts...) + rootCmd, err := command.RootCommandToCobra(NewCreditsCommand(cmdContext)) + require.NoError(t, err) + + rootCmd.SetArgs(tc.args) + execErr := rootCmd.Execute() + tc.assert(t, stdout.String(), stderr.String(), cenclierrors.NewCencliError(execErr)) + }) + } +} diff --git a/internal/command/credits/short.go b/internal/command/credits/short.go new file mode 100644 index 0000000..43aeafe --- /dev/null +++ b/internal/command/credits/short.go @@ -0,0 +1,39 @@ +package credits + +import ( + "fmt" + "strings" + + "github.com/censys/cencli/internal/app/credits" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/pkg/formatter/short" + "github.com/censys/cencli/internal/pkg/styles" +) + +func (c *Command) showUserCredits(result credits.UserCreditDetailsResult) cenclierrors.CencliError { + var out strings.Builder + data := result.Data + + // Header + out.WriteRune('\n') + out.WriteString(styles.GlobalStyles.Signature.Render("━━━ Your Free User Credit Details ━━━")) + out.WriteRune('\n') + out.WriteRune('\n') + + // Balance + balanceLabel := styles.GlobalStyles.Primary.Render("Balance") + balanceValue := styles.GlobalStyles.Info.Bold(true).Render(short.FormatNumber(data.Balance)) + fmt.Fprintf(&out, " %s: %s credits", balanceLabel, balanceValue) + + // Resets At + if data.ResetsAt.IsPresent() { + resetTime := data.ResetsAt.MustGet() + resetStr := fmt.Sprintf("(resets %s)", resetTime.Format("2006-01-02")) + fmt.Fprintf(&out, " %s", styles.GlobalStyles.Comment.Render(resetStr)) + } + + out.WriteRune('\n') + formatter.Println(formatter.Stdout, out.String()) + return nil +} diff --git a/internal/command/help.go b/internal/command/help.go index 74e82e7..9caa5f9 100644 --- a/internal/command/help.go +++ b/internal/command/help.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" + "github.com/censys/cencli/internal/pkg/formatter" "github.com/censys/cencli/internal/pkg/styles" ) @@ -77,13 +78,20 @@ func usageTemplate(cmd *cobra.Command, examples []string) string { } } } - if cmd.HasAvailableLocalFlags() { + // HACK: Move --output-format to Global Flags section even when locally overridden. + // When a command overrides the default output format, we add a local persistent flag + // that shadows the inherited one. Cobra treats this as a local flag, but we want it + // to always appear in "Global Flags" for consistency. This is a workaround for Cobra's + // limitation where local flags can't be displayed in the inherited flags section. + localFlags, globalFlags := separateOutputFormatFlag(cmd) + + if hasAvailableFlags(localFlags) { b.WriteString("\n" + styles.GlobalStyles.Info.Render("Flags:") + "\n") - b.WriteString(styledFlagUsages(cmd.LocalFlags())) + b.WriteString(styledFlagUsages(localFlags)) } - if cmd.HasAvailableInheritedFlags() { + if hasAvailableFlags(globalFlags) { b.WriteString("\n" + styles.GlobalStyles.Info.Render("Global Flags:") + "\n") - b.WriteString(styledFlagUsages(cmd.InheritedFlags())) + b.WriteString(styledFlagUsages(globalFlags)) } if cmd.HasHelpSubCommands() { b.WriteString("\n" + styles.GlobalStyles.Info.Render("Additional help topics:") + "\n") @@ -163,6 +171,68 @@ func renderExampleLine(cmdPath, example string) string { return fmt.Sprintf("%s %s", base, beforeComment) } +// separateOutputFormatFlag separates local and inherited flags, moving --output-format +// to the inherited flags set even if it was defined locally. This is a hack to work around +// Cobra's limitation where we can't control which section a flag appears in when we need +// to override its default value. +// +// When a command has a custom DefaultOutputType(), we add a local persistent --output-format +// flag that shadows the inherited one. This allows the command to have a different default +// value without affecting sibling commands. However, Cobra will then show this flag in the +// "Flags:" section instead of "Global Flags:", which is confusing for users since it's +// conceptually a global flag that happens to have a command-specific default. +// +// This function creates new flag sets where --output-format is always in the "global" set. +func separateOutputFormatFlag(cmd *cobra.Command) (local *pflag.FlagSet, global *pflag.FlagSet) { + local = pflag.NewFlagSet("local", pflag.ContinueOnError) + global = pflag.NewFlagSet("global", pflag.ContinueOnError) + + // Start with inherited flags in global + cmd.InheritedFlags().VisitAll(func(f *pflag.Flag) { + global.AddFlag(f) + }) + + // Check if --output-format exists in local flags (meaning it was overridden) + var outputFormatFlag *pflag.Flag + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + if f.Name == formatter.OutputFormatFlagName { + outputFormatFlag = f + } else { + local.AddFlag(f) + } + }) + + // If --output-format was found locally, add it to global (replacing inherited version) + if outputFormatFlag != nil { + // Remove the inherited version if it exists + if inheritedFlag := global.Lookup(formatter.OutputFormatFlagName); inheritedFlag != nil { + // We can't actually remove it, so we'll re-create the global set without it + newGlobal := pflag.NewFlagSet("global", pflag.ContinueOnError) + global.VisitAll(func(f *pflag.Flag) { + if f.Name != formatter.OutputFormatFlagName { + newGlobal.AddFlag(f) + } + }) + global = newGlobal + } + // Add the local version to global + global.AddFlag(outputFormatFlag) + } + + return local, global +} + +// hasAvailableFlags returns true if the flag set has any non-hidden flags. +func hasAvailableFlags(fs *pflag.FlagSet) bool { + hasFlags := false + fs.VisitAll(func(f *pflag.Flag) { + if !f.Hidden { + hasFlags = true + } + }) + return hasFlags +} + // ============================================================================= // The below functions are almost entirely vendored from pflag/flag.go // since there's no way to add styling to the output. diff --git a/internal/command/history/history.go b/internal/command/history/history.go index 6d1fee3..591d9b4 100644 --- a/internal/command/history/history.go +++ b/internal/command/history/history.go @@ -86,6 +86,18 @@ func (c *Command) Init() error { func (c *Command) Args() command.PositionalArgs { return command.ExactArgs(1) } +func (c *Command) DefaultOutputType() command.OutputType { + return command.OutputTypeData +} + +func (c *Command) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeData} +} + +func (c *Command) SupportsStreaming() bool { + return true +} + func (c *Command) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { // gather assets rawAssets := cmdutil.SplitString(args[0]) @@ -141,9 +153,13 @@ func (c *Command) Run(cmd *cobra.Command, args []string) cenclierrors.CencliErro "end", c.end.Format(time.RFC3339), ) - var result interface{} + // Set up streaming output (no-op for non-streaming formats) + ctx, stopStreaming := c.WithStreamingOutput(cmd.Context(), logger) + defer stopStreaming(nil) + + var result any err := c.WithProgress( - cmd.Context(), + ctx, logger, fmt.Sprintf("Fetching history for %s...", c.assetID), func(pctx context.Context) cenclierrors.CencliError { @@ -167,27 +183,27 @@ func (c *Command) Run(cmd *cobra.Command, args []string) cenclierrors.CencliErro return err } - // Print response metadata and output raw JSON (even if partial) + // Print response metadata and output (PrintData handles streaming vs buffered automatically) var partialError cenclierrors.CencliError switch c.assetType { case assets.AssetTypeHost: hostResult := result.(history.HostHistoryResult) c.PrintAppResponseMeta(hostResult.Meta) - if printErr := c.PrintData(hostResult.Events); printErr != nil { + if printErr := c.PrintData(c, hostResult.Events); printErr != nil { return printErr } partialError = hostResult.PartialError case assets.AssetTypeCertificate: certResult := result.(history.CertificateHistoryResult) c.PrintAppResponseMeta(certResult.Meta) - if printErr := c.PrintData(certResult.Ranges); printErr != nil { + if printErr := c.PrintData(c, certResult.Ranges); printErr != nil { return printErr } partialError = certResult.PartialError case assets.AssetTypeWebProperty: webPropResult := result.(history.WebPropertyHistoryResult) c.PrintAppResponseMeta(webPropResult.Meta) - if printErr := c.PrintData(webPropResult.Snapshots); printErr != nil { + if printErr := c.PrintData(c, webPropResult.Snapshots); printErr != nil { return printErr } partialError = webPropResult.PartialError diff --git a/internal/command/org/credits/credits.go b/internal/command/org/credits/credits.go new file mode 100644 index 0000000..00b0dc9 --- /dev/null +++ b/internal/command/org/credits/credits.go @@ -0,0 +1,137 @@ +package credits + +import ( + "context" + + "github.com/spf13/cobra" + + appcredits "github.com/censys/cencli/internal/app/credits" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/flags" +) + +const cmdName = "credits" + +// Command displays credit details for an organization. +type Command struct { + *command.BaseCommand + // services + creditsSvc appcredits.Service + // flags + flags creditsFlags + // state + orgID identifiers.OrganizationID + // result + result appcredits.OrganizationCreditDetailsResult +} + +type creditsFlags struct { + orgID flags.OrgIDFlag +} + +var _ command.Command = (*Command)(nil) + +// NewCreditsCommand creates a new org credits command. +func NewCreditsCommand(cmdContext *command.Context) *Command { + return &Command{ + BaseCommand: command.NewBaseCommand(cmdContext), + } +} + +func (c *Command) Use() string { + return cmdName +} + +func (c *Command) Short() string { + return "Display credit details for your organization" +} + +func (c *Command) Long() string { + return `Display credit details for your organization. + +This command shows your organization's credit balance, auto-replenish configuration, +and any credit expirations. + +By default, the stored organization ID is used. Use --org-id to query a specific organization.` +} + +func (c *Command) Args() command.PositionalArgs { + return command.ExactArgs(0) +} + +func (c *Command) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *Command) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeData, command.OutputTypeShort} +} + +func (c *Command) Examples() []string { + return []string{ + "# Show credits for your stored organization", + "--org-id # Show credits for a specific organization", + } +} + +func (c *Command) Init() error { + c.flags.orgID = flags.NewOrgIDFlag( + c.Flags(), + "", + ) + return nil +} + +func (c *Command) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + var err cenclierrors.CencliError + c.creditsSvc, err = c.CreditsService() + if err != nil { + return err + } + + orgIDFromFlag, err := c.flags.orgID.Value() + if err != nil { + return err + } + if orgIDFromFlag.IsPresent() { + c.orgID = orgIDFromFlag.MustGet() + } else { + storedOrgID, err := c.GetStoredOrgID(cmd.Context()) + if err != nil { + return err + } + if storedOrgID.IsPresent() { + c.orgID = storedOrgID.MustGet() + } + } + // if no org ID is found, return an error + if c.orgID.IsZero() { + return cenclierrors.NewNoOrgIDError() + } + return nil +} + +func (c *Command) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + err := c.WithProgress( + cmd.Context(), + c.Logger(cmdName), + "Fetching organization credits...", + func(pctx context.Context) cenclierrors.CencliError { + var fetchErr cenclierrors.CencliError + c.result, fetchErr = c.creditsSvc.GetOrganizationCreditDetails(pctx, c.orgID) + return fetchErr + }, + ) + if err != nil { + return err + } + + c.PrintAppResponseMeta(c.result.Meta) + return c.PrintData(c, c.result.Data) +} + +func (c *Command) RenderShort() cenclierrors.CencliError { + return c.showOrgCredits(c.result) +} diff --git a/internal/command/org/credits/credits_test.go b/internal/command/org/credits/credits_test.go new file mode 100644 index 0000000..5b31c19 --- /dev/null +++ b/internal/command/org/credits/credits_test.go @@ -0,0 +1,287 @@ +package credits + +import ( + "bytes" + "errors" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "github.com/samber/mo" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + creditsmocks "github.com/censys/cencli/gen/app/credits/mocks" + clientmocks "github.com/censys/cencli/gen/client/mocks" + storemocks "github.com/censys/cencli/gen/store/mocks" + appcredits "github.com/censys/cencli/internal/app/credits" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/config" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/domain/responsemeta" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/store" +) + +func TestOrgCreditsCommand(t *testing.T) { + testCases := []struct { + name string + store func(ctrl *gomock.Controller) store.Store + service func(ctrl *gomock.Controller) appcredits.Service + client func(ctrl *gomock.Controller) *clientmocks.MockClient + args []string + assert func(t *testing.T, stdout, stderr string, err error) + }{ + // Success cases + { + name: "success - org credits with --org-id flag", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) appcredits.Service { + mockSvc := creditsmocks.NewMockCreditsService(ctrl) + mockSvc.EXPECT().GetOrganizationCreditDetails( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")), + ).Return(appcredits.OrganizationCreditDetailsResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 100*time.Millisecond, 1), + Data: appcredits.OrganizationCreditDetails{ + Balance: 5000, + CreditExpirations: []appcredits.CreditExpiration{}, + AutoReplenishConfig: appcredits.AutoReplenishConfig{ + Enabled: false, + }, + }, + }, nil) + return mockSvc + }, + client: nil, + args: []string{"--org-id", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"balance"`) + require.Contains(t, stdout, "5000") + }, + }, + { + name: "success - org credits with stored org ID", + store: func(ctrl *gomock.Controller) store.Store { + mockStore := storemocks.NewMockStore(ctrl) + mockStore.EXPECT().GetLastUsedGlobalByName( + gomock.Any(), + "org-id", + ).Return(&store.ValueForGlobal{ + ID: 1, + Name: "org-id", + Value: "58857aac-4b76-46ec-a567-0e02b2c3d479", + }, nil) + return mockStore + }, + service: func(ctrl *gomock.Controller) appcredits.Service { + mockSvc := creditsmocks.NewMockCreditsService(ctrl) + mockSvc.EXPECT().GetOrganizationCreditDetails( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("58857aac-4b76-46ec-a567-0e02b2c3d479")), + ).Return(appcredits.OrganizationCreditDetailsResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 150*time.Millisecond, 1), + Data: appcredits.OrganizationCreditDetails{ + Balance: 7500, + CreditExpirations: []appcredits.CreditExpiration{}, + AutoReplenishConfig: appcredits.AutoReplenishConfig{ + Enabled: false, + }, + }, + }, nil) + return mockSvc + }, + client: nil, + args: []string{"--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"balance"`) + require.Contains(t, stdout, "7500") + }, + }, + { + name: "success - org credits with credit expirations", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) appcredits.Service { + mockSvc := creditsmocks.NewMockCreditsService(ctrl) + mockSvc.EXPECT().GetOrganizationCreditDetails( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")), + ).Return(appcredits.OrganizationCreditDetailsResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 100*time.Millisecond, 1), + Data: appcredits.OrganizationCreditDetails{ + Balance: 3000, + CreditExpirations: []appcredits.CreditExpiration{ + { + Balance: 1500, + CreationDate: mo.Some(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)), + ExpirationDate: mo.Some(time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)), + }, + }, + AutoReplenishConfig: appcredits.AutoReplenishConfig{ + Enabled: false, + }, + }, + }, nil) + return mockSvc + }, + client: nil, + args: []string{"--org-id", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"balance"`) + require.Contains(t, stdout, "3000") + require.Contains(t, stdout, `"credit_expirations"`) + require.Contains(t, stdout, "1500") + }, + }, + { + name: "success - org credits with auto-replenish enabled", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) appcredits.Service { + mockSvc := creditsmocks.NewMockCreditsService(ctrl) + mockSvc.EXPECT().GetOrganizationCreditDetails( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")), + ).Return(appcredits.OrganizationCreditDetailsResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 100*time.Millisecond, 1), + Data: appcredits.OrganizationCreditDetails{ + Balance: 2000, + CreditExpirations: []appcredits.CreditExpiration{}, + AutoReplenishConfig: appcredits.AutoReplenishConfig{ + Enabled: true, + Threshold: mo.Some(int64(100)), + Amount: mo.Some(int64(500)), + }, + }, + }, nil) + return mockSvc + }, + client: nil, + args: []string{"--org-id", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"balance"`) + require.Contains(t, stdout, "2000") + require.Contains(t, stdout, `"auto_replenish_config"`) + require.Contains(t, stdout, `"enabled": true`) + }, + }, + + // Error cases + { + name: "error - no org ID configured", + store: func(ctrl *gomock.Controller) store.Store { + mockStore := storemocks.NewMockStore(ctrl) + mockStore.EXPECT().GetLastUsedGlobalByName( + gomock.Any(), + "org-id", + ).Return(nil, store.ErrGlobalNotFound) + return mockStore + }, + service: func(ctrl *gomock.Controller) appcredits.Service { + return creditsmocks.NewMockCreditsService(ctrl) + }, + client: nil, + args: []string{"--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "no organization ID configured") + }, + }, + { + name: "error - invalid org ID format", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) appcredits.Service { + return creditsmocks.NewMockCreditsService(ctrl) + }, + client: nil, + args: []string{"--org-id", "invalid-uuid", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "invalid uuid") + }, + }, + { + name: "error - service returns error", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) appcredits.Service { + mockSvc := creditsmocks.NewMockCreditsService(ctrl) + mockSvc.EXPECT().GetOrganizationCreditDetails( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")), + ).Return(appcredits.OrganizationCreditDetailsResult{}, cenclierrors.NewCencliError(errors.New("organization not found"))) + return mockSvc + }, + client: nil, + args: []string{"--org-id", "f47ac10b-58cc-4372-a567-0e02b2c3d479"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "organization not found") + }, + }, + { + name: "error - too many arguments", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) appcredits.Service { + return creditsmocks.NewMockCreditsService(ctrl) + }, + client: nil, + args: []string{"extra-arg", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "accepts 0 arg(s), received 1") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tempDir := t.TempDir() + viper.Reset() + cfg, err := config.New(tempDir) + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + formatter.Stdout = &stdout + formatter.Stderr = &stderr + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + creditsSvc := tc.service(ctrl) + opts := []command.ContextOpts{command.WithCreditsService(creditsSvc)} + + if tc.client != nil { + mockClient := tc.client(ctrl) + opts = append(opts, func(c *command.Context) { + c.SetCensysClient(mockClient) + }) + } + + cmdContext := command.NewCommandContext(cfg, tc.store(ctrl), opts...) + rootCmd, err := command.RootCommandToCobra(NewCreditsCommand(cmdContext)) + require.NoError(t, err) + + rootCmd.SetArgs(tc.args) + execErr := rootCmd.Execute() + tc.assert(t, stdout.String(), stderr.String(), cenclierrors.NewCencliError(execErr)) + }) + } +} diff --git a/internal/command/org/credits/short.go b/internal/command/org/credits/short.go new file mode 100644 index 0000000..f177810 --- /dev/null +++ b/internal/command/org/credits/short.go @@ -0,0 +1,75 @@ +package credits + +import ( + "fmt" + "strings" + + appcredits "github.com/censys/cencli/internal/app/credits" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/pkg/formatter/short" + "github.com/censys/cencli/internal/pkg/styles" +) + +func (c *Command) showOrgCredits(result appcredits.OrganizationCreditDetailsResult) cenclierrors.CencliError { + var out strings.Builder + data := result.Data + + // Header + out.WriteRune('\n') + out.WriteString(styles.GlobalStyles.Signature.Render("━━━ Organization Credit Details ━━━")) + out.WriteRune('\n') + out.WriteRune('\n') + + // Balance - big and bold + balanceLabel := styles.GlobalStyles.Primary.Render("Balance") + balanceValue := styles.GlobalStyles.Info.Bold(true).Render(short.FormatNumber(data.Balance)) + fmt.Fprintf(&out, " %s: %s credits\n", balanceLabel, balanceValue) + + // Auto Replenish Config + out.WriteRune('\n') + out.WriteString(styles.GlobalStyles.Primary.Render(" Auto Replenish")) + out.WriteRune('\n') + + if data.AutoReplenishConfig.Enabled { + fmt.Fprintf(&out, " %s: %s\n", + styles.GlobalStyles.Comment.Render("Status"), + styles.GlobalStyles.Secondary.Render("✓ Enabled")) + if data.AutoReplenishConfig.Threshold.IsPresent() { + fmt.Fprintf(&out, " %s: %s\n", + styles.GlobalStyles.Comment.Render("Threshold"), + styles.GlobalStyles.Tertiary.Render(short.FormatNumber(data.AutoReplenishConfig.Threshold.MustGet()))) + } + if data.AutoReplenishConfig.Amount.IsPresent() { + fmt.Fprintf(&out, " %s: %s\n", + styles.GlobalStyles.Comment.Render("Amount"), + styles.GlobalStyles.Tertiary.Render(short.FormatNumber(data.AutoReplenishConfig.Amount.MustGet()))) + } + } else { + fmt.Fprintf(&out, " %s: %s\n", + styles.GlobalStyles.Comment.Render("Status"), + styles.GlobalStyles.Comment.Render("✗ Disabled")) + } + + // Credit Expirations + if len(data.CreditExpirations) > 0 { + out.WriteString("\n") + out.WriteString(styles.GlobalStyles.Primary.Render(fmt.Sprintf(" Credit Expirations (%d)", len(data.CreditExpirations)))) + out.WriteString("\n") + + for _, exp := range data.CreditExpirations { + expBalance := styles.GlobalStyles.Info.Render(short.FormatNumber(exp.Balance)) + fmt.Fprintf(&out, " %s %s credits", styles.GlobalStyles.Tertiary.Render("•"), expBalance) + + if exp.ExpirationDate.IsPresent() { + expDate := exp.ExpirationDate.MustGet() + expStr := fmt.Sprintf("(expires %s)", expDate.Format("2006-01-02")) + fmt.Fprintf(&out, " %s", styles.GlobalStyles.Comment.Render(expStr)) + } + out.WriteString("\n") + } + } + + formatter.Println(formatter.Stdout, out.String()) + return nil +} diff --git a/internal/command/org/details/details.go b/internal/command/org/details/details.go new file mode 100644 index 0000000..a476da9 --- /dev/null +++ b/internal/command/org/details/details.go @@ -0,0 +1,138 @@ +package details + +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/app/organizations" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/flags" +) + +const cmdName = "details" + +// Command displays organization details. +type Command struct { + *command.BaseCommand + // services + orgSvc organizations.Service + // flags + flags detailsFlags + // state + orgID identifiers.OrganizationID + // result + result organizations.OrganizationDetailsResult +} + +type detailsFlags struct { + orgID flags.OrgIDFlag +} + +var _ command.Command = (*Command)(nil) + +// NewDetailsCommand creates a new org details command. +func NewDetailsCommand(cmdContext *command.Context) *Command { + return &Command{ + BaseCommand: command.NewBaseCommand(cmdContext), + } +} + +func (c *Command) Use() string { + return cmdName +} + +func (c *Command) Short() string { + return "Display organization details" +} + +func (c *Command) Long() string { + return `Display details about your organization. + +This command shows organization information including name, ID, creation date, +and member counts. + +By default, the stored organization ID is used. Use --org-id to query a specific organization.` +} + +func (c *Command) Args() command.PositionalArgs { + return command.ExactArgs(0) +} + +func (c *Command) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *Command) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeData, command.OutputTypeShort} +} + +func (c *Command) Examples() []string { + return []string{ + "# Show details for your stored organization", + "--org-id # Show details for a specific organization", + "--output-format json # Output as JSON", + } +} + +func (c *Command) Init() error { + c.flags.orgID = flags.NewOrgIDFlag( + c.Flags(), + "", + ) + return nil +} + +func (c *Command) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + var err cenclierrors.CencliError + c.orgSvc, err = c.OrganizationsService() + if err != nil { + return err + } + + orgIDFromFlag, err := c.flags.orgID.Value() + if err != nil { + return err + } + if orgIDFromFlag.IsPresent() { + c.orgID = orgIDFromFlag.MustGet() + } else { + storedOrgID, err := c.GetStoredOrgID(cmd.Context()) + if err != nil { + return err + } + if storedOrgID.IsPresent() { + c.orgID = storedOrgID.MustGet() + } + } + // if no org ID is found, return an error + if c.orgID.IsZero() { + return cenclierrors.NewNoOrgIDError() + } + return nil +} + +func (c *Command) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + err := c.WithProgress( + cmd.Context(), + c.Logger(cmdName), + "Fetching organization details...", + func(pctx context.Context) cenclierrors.CencliError { + var fetchErr cenclierrors.CencliError + c.result, fetchErr = c.orgSvc.GetOrganizationDetails(pctx, c.orgID) + return fetchErr + }, + ) + if err != nil { + return err + } + + c.PrintAppResponseMeta(c.result.Meta) + return c.PrintData(c, c.result.Data) +} + +func (c *Command) RenderShort() cenclierrors.CencliError { + return c.showOrgDetails(c.result) +} diff --git a/internal/command/org/details/details_test.go b/internal/command/org/details/details_test.go new file mode 100644 index 0000000..cdff1b2 --- /dev/null +++ b/internal/command/org/details/details_test.go @@ -0,0 +1,261 @@ +package details + +import ( + "bytes" + "errors" + "net/http" + "testing" + "time" + + "github.com/censys/censys-sdk-go/models/components" + "github.com/google/uuid" + "github.com/samber/mo" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + orgmocks "github.com/censys/cencli/gen/app/organizations/mocks" + storemocks "github.com/censys/cencli/gen/store/mocks" + "github.com/censys/cencli/internal/app/organizations" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/config" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/domain/responsemeta" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/store" +) + +func TestOrgDetailsCommand(t *testing.T) { + testCases := []struct { + name string + store func(ctrl *gomock.Controller) store.Store + service func(ctrl *gomock.Controller) organizations.Service + args []string + assert func(t *testing.T, stdout, stderr string, err error) + }{ + // Success cases + { + name: "success - get details with --org-id flag", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) organizations.Service { + mockSvc := orgmocks.NewMockOrganizationsService(ctrl) + createdAt := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + adminCount := int64(3) + mockSvc.EXPECT().GetOrganizationDetails( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")), + ).Return(organizations.OrganizationDetailsResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 100*time.Millisecond, 1), + Data: organizations.OrganizationDetails{ + ID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + CreatedAt: mo.Some(createdAt), + Name: "Test Organization", + MemberCounts: &components.MemberCounts{ + Total: 10, + ByRole: components.ByRole{ + Admin: &adminCount, + }, + }, + }, + }, nil) + return mockSvc + }, + args: []string{"--org-id", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"name"`) + require.Contains(t, stdout, "Test Organization") + require.Contains(t, stdout, "f47ac10b-58cc-4372-a567-0e02b2c3d479") + }, + }, + { + name: "success - get details with stored org ID", + store: func(ctrl *gomock.Controller) store.Store { + mockStore := storemocks.NewMockStore(ctrl) + mockStore.EXPECT().GetLastUsedGlobalByName( + gomock.Any(), + "org-id", + ).Return(&store.ValueForGlobal{ + ID: 1, + Name: "org-id", + Value: "58857aac-4b76-46ec-a567-0e02b2c3d479", + }, nil) + return mockStore + }, + service: func(ctrl *gomock.Controller) organizations.Service { + mockSvc := orgmocks.NewMockOrganizationsService(ctrl) + mockSvc.EXPECT().GetOrganizationDetails( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("58857aac-4b76-46ec-a567-0e02b2c3d479")), + ).Return(organizations.OrganizationDetailsResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 100*time.Millisecond, 1), + Data: organizations.OrganizationDetails{ + ID: uuid.MustParse("58857aac-4b76-46ec-a567-0e02b2c3d479"), + Name: "My Organization", + }, + }, nil) + return mockSvc + }, + args: []string{"--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "My Organization") + }, + }, + { + name: "success - get details with minimal data", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) organizations.Service { + mockSvc := orgmocks.NewMockOrganizationsService(ctrl) + mockSvc.EXPECT().GetOrganizationDetails( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")), + ).Return(organizations.OrganizationDetailsResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 50*time.Millisecond, 1), + Data: organizations.OrganizationDetails{ + ID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + Name: "Minimal Org", + }, + }, nil) + return mockSvc + }, + args: []string{"--org-id", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "Minimal Org") + }, + }, + { + name: "success - get details with preferences", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) organizations.Service { + mockSvc := orgmocks.NewMockOrganizationsService(ctrl) + mfaRequired := true + aiOptIn := false + mockSvc.EXPECT().GetOrganizationDetails( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")), + ).Return(organizations.OrganizationDetailsResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 100*time.Millisecond, 1), + Data: organizations.OrganizationDetails{ + ID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + Name: "Org With Preferences", + Preferences: &components.OrganizationPreferences{ + MfaRequired: &mfaRequired, + AiOptIn: &aiOptIn, + }, + }, + }, nil) + return mockSvc + }, + args: []string{"--org-id", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "Org With Preferences") + require.Contains(t, stdout, "preferences") + }, + }, + + // Error cases + { + name: "error - no org ID configured", + store: func(ctrl *gomock.Controller) store.Store { + mockStore := storemocks.NewMockStore(ctrl) + mockStore.EXPECT().GetLastUsedGlobalByName( + gomock.Any(), + "org-id", + ).Return(nil, store.ErrGlobalNotFound) + return mockStore + }, + service: func(ctrl *gomock.Controller) organizations.Service { + return orgmocks.NewMockOrganizationsService(ctrl) + }, + args: []string{"--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "no organization ID configured") + }, + }, + { + name: "error - invalid org ID format", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) organizations.Service { + return orgmocks.NewMockOrganizationsService(ctrl) + }, + args: []string{"--org-id", "invalid-uuid", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "invalid uuid") + }, + }, + { + name: "error - service returns error", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) organizations.Service { + mockSvc := orgmocks.NewMockOrganizationsService(ctrl) + mockSvc.EXPECT().GetOrganizationDetails( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")), + ).Return(organizations.OrganizationDetailsResult{}, cenclierrors.NewCencliError(errors.New("organization not found"))) + return mockSvc + }, + args: []string{"--org-id", "f47ac10b-58cc-4372-a567-0e02b2c3d479"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "organization not found") + }, + }, + { + name: "error - too many arguments", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) organizations.Service { + return orgmocks.NewMockOrganizationsService(ctrl) + }, + args: []string{"extra-arg", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "accepts 0 arg(s), received 1") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tempDir := t.TempDir() + viper.Reset() + cfg, err := config.New(tempDir) + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + formatter.Stdout = &stdout + formatter.Stderr = &stderr + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + orgSvc := tc.service(ctrl) + opts := []command.ContextOpts{command.WithOrganizationsService(orgSvc)} + + cmdContext := command.NewCommandContext(cfg, tc.store(ctrl), opts...) + rootCmd, err := command.RootCommandToCobra(NewDetailsCommand(cmdContext)) + require.NoError(t, err) + + rootCmd.SetArgs(tc.args) + execErr := rootCmd.Execute() + tc.assert(t, stdout.String(), stderr.String(), cenclierrors.NewCencliError(execErr)) + }) + } +} diff --git a/internal/command/org/details/short.go b/internal/command/org/details/short.go new file mode 100644 index 0000000..76794e9 --- /dev/null +++ b/internal/command/org/details/short.go @@ -0,0 +1,109 @@ +package details + +import ( + "fmt" + "strings" + + "github.com/censys/cencli/internal/app/organizations" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/pkg/styles" +) + +func (c *Command) showOrgDetails(result organizations.OrganizationDetailsResult) cenclierrors.CencliError { + var out strings.Builder + data := result.Data + + // Header + out.WriteRune('\n') + out.WriteString(styles.GlobalStyles.Signature.Render("━━━ Organization Details ━━━")) + out.WriteRune('\n') + out.WriteRune('\n') + + // Organization Name - big and bold + nameLabel := fmt.Sprintf("%-8s", "Name:") + nameLabelStyled := styles.GlobalStyles.Primary.Render(nameLabel) + nameValue := styles.GlobalStyles.Info.Bold(true).Render(data.Name) + fmt.Fprintf(&out, " %s %s\n", nameLabelStyled, nameValue) + + // Organization ID + idLabel := fmt.Sprintf("%-8s", "ID:") + idLabelStyled := styles.GlobalStyles.Primary.Render(idLabel) + idValue := styles.GlobalStyles.Comment.Render(data.ID.String()) + fmt.Fprintf(&out, " %s %s\n", idLabelStyled, idValue) + + // Created At + if data.CreatedAt.IsPresent() { + createdLabel := fmt.Sprintf("%-8s", "Created:") + createdLabelStyled := styles.GlobalStyles.Primary.Render(createdLabel) + createdValue := styles.GlobalStyles.Comment.Render(data.CreatedAt.MustGet().Format("2006-01-02 15:04:05 MST")) + fmt.Fprintf(&out, " %s %s\n", createdLabelStyled, createdValue) + } + + // Member Counts + if data.MemberCounts != nil { + out.WriteRune('\n') + out.WriteString(styles.GlobalStyles.Primary.Render(" Member Counts")) + out.WriteRune('\n') + + totalLabel := fmt.Sprintf("%-11s", "Total:") + fmt.Fprintf(&out, " %s %s\n", + styles.GlobalStyles.Comment.Render(totalLabel), + styles.GlobalStyles.Tertiary.Render(fmt.Sprintf("%d", data.MemberCounts.Total))) + + // Show role breakdown if available + if data.MemberCounts.ByRole.Admin != nil { + adminsLabel := fmt.Sprintf("%-11s", "Admins:") + fmt.Fprintf(&out, " %s %s\n", + styles.GlobalStyles.Comment.Render(adminsLabel), + styles.GlobalStyles.Tertiary.Render(fmt.Sprintf("%d", *data.MemberCounts.ByRole.Admin))) + } + if data.MemberCounts.ByRole.APIAccess != nil { + apiAccessLabel := fmt.Sprintf("%-11s", "API Access:") + fmt.Fprintf(&out, " %s %s\n", + styles.GlobalStyles.Comment.Render(apiAccessLabel), + styles.GlobalStyles.Tertiary.Render(fmt.Sprintf("%d", *data.MemberCounts.ByRole.APIAccess))) + } + } + + // Preferences + if data.Preferences != nil { + out.WriteRune('\n') + out.WriteString(styles.GlobalStyles.Primary.Render(" Preferences")) + out.WriteRune('\n') + + if data.Preferences.MfaRequired != nil { + mfaStatus := "Disabled" + if *data.Preferences.MfaRequired { + mfaStatus = "Required" + } + mfaLabel := fmt.Sprintf("%-12s", "MFA:") + fmt.Fprintf(&out, " %s %s\n", + styles.GlobalStyles.Comment.Render(mfaLabel), + styles.GlobalStyles.Tertiary.Render(mfaStatus)) + } + if data.Preferences.AiOptIn != nil { + aiStatus := "Opted Out" + if *data.Preferences.AiOptIn { + aiStatus = "Opted In" + } + aiFeaturesLabel := fmt.Sprintf("%-12s", "AI Features:") + fmt.Fprintf(&out, " %s %s\n", + styles.GlobalStyles.Comment.Render(aiFeaturesLabel), + styles.GlobalStyles.Tertiary.Render(aiStatus)) + } + if data.Preferences.AiTraining != nil { + aiTrainingStatus := "Disabled" + if *data.Preferences.AiTraining { + aiTrainingStatus = "Enabled" + } + aiTrainingLabel := fmt.Sprintf("%-12s", "AI Training:") + fmt.Fprintf(&out, " %s %s\n", + styles.GlobalStyles.Comment.Render(aiTrainingLabel), + styles.GlobalStyles.Tertiary.Render(aiTrainingStatus)) + } + } + + formatter.Println(formatter.Stdout, out.String()) + return nil +} diff --git a/internal/command/org/members/members.go b/internal/command/org/members/members.go new file mode 100644 index 0000000..29e007c --- /dev/null +++ b/internal/command/org/members/members.go @@ -0,0 +1,164 @@ +package members + +import ( + "context" + + "github.com/samber/mo" + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/app/organizations" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/flags" +) + +const cmdName = "members" + +// Command displays organization members. +type Command struct { + *command.BaseCommand + // services + orgSvc organizations.Service + // flags + flags membersFlags + // state + orgID identifiers.OrganizationID + interactive bool + // result + result organizations.OrganizationMembersResult +} + +type membersFlags struct { + orgID flags.OrgIDFlag + interactive flags.BoolFlag +} + +var _ command.Command = (*Command)(nil) + +// NewMembersCommand creates a new org members command. +func NewMembersCommand(cmdContext *command.Context) *Command { + return &Command{ + BaseCommand: command.NewBaseCommand(cmdContext), + } +} + +func (c *Command) Use() string { + return cmdName +} + +func (c *Command) Short() string { + return "List organization members" +} + +func (c *Command) Long() string { + return `List members in your organization. + +This command displays all members including their email, name, roles, and last login time. + +By default, the stored organization ID is used. Use --org-id to query a specific organization. +Use --interactive for a navigable table view.` +} + +func (c *Command) Args() command.PositionalArgs { + return command.ExactArgs(0) +} + +func (c *Command) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *Command) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeData, command.OutputTypeShort} +} + +func (c *Command) Examples() []string { + return []string{ + "# List members for your stored organization", + "--interactive # List members in an interactive table", + "--org-id # List members for a specific organization", + "--output-format json # Output as JSON", + } +} + +func (c *Command) Init() error { + c.flags.orgID = flags.NewOrgIDFlag( + c.Flags(), + "", + ) + c.flags.interactive = flags.NewBoolFlag( + c.Flags(), + "interactive", + "i", + false, + "display results in an interactive table (TUI)", + ) + return nil +} + +func (c *Command) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + var err cenclierrors.CencliError + c.orgSvc, err = c.OrganizationsService() + if err != nil { + return err + } + + orgIDFromFlag, err := c.flags.orgID.Value() + if err != nil { + return err + } + if orgIDFromFlag.IsPresent() { + c.orgID = orgIDFromFlag.MustGet() + } else { + storedOrgID, err := c.GetStoredOrgID(cmd.Context()) + if err != nil { + return err + } + if storedOrgID.IsPresent() { + c.orgID = storedOrgID.MustGet() + } + } + // if no org ID is found, return an error + if c.orgID.IsZero() { + return cenclierrors.NewNoOrgIDError() + } + + // Get interactive flag + c.interactive, err = c.flags.interactive.Value() + if err != nil { + return err + } + + return nil +} + +func (c *Command) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + err := c.WithProgress( + cmd.Context(), + c.Logger(cmdName), + "Fetching organization members...", + func(pctx context.Context) cenclierrors.CencliError { + var fetchErr cenclierrors.CencliError + c.result, fetchErr = c.orgSvc.ListOrganizationMembers( + pctx, + c.orgID, + mo.None[uint](), // pageSize - get all + mo.None[uint](), // maxPages - no limit + ) + return fetchErr + }, + ) + if err != nil { + return err + } + + c.PrintAppResponseMeta(c.result.Meta) + return c.PrintData(c, c.result.Data) +} + +func (c *Command) RenderShort() cenclierrors.CencliError { + if c.interactive { + return c.showInteractiveTable(c.result) + } + return c.showRawTable(c.result) +} diff --git a/internal/command/org/members/members_test.go b/internal/command/org/members/members_test.go new file mode 100644 index 0000000..14f4bfc --- /dev/null +++ b/internal/command/org/members/members_test.go @@ -0,0 +1,251 @@ +package members + +import ( + "bytes" + "errors" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "github.com/samber/mo" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + orgmocks "github.com/censys/cencli/gen/app/organizations/mocks" + storemocks "github.com/censys/cencli/gen/store/mocks" + "github.com/censys/cencli/internal/app/organizations" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/config" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/domain/responsemeta" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/store" +) + +func TestOrgMembersCommand(t *testing.T) { + testCases := []struct { + name string + store func(ctrl *gomock.Controller) store.Store + service func(ctrl *gomock.Controller) organizations.Service + args []string + assert func(t *testing.T, stdout, stderr string, err error) + }{ + // Success cases + { + name: "success - list members with --org-id flag", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) organizations.Service { + mockSvc := orgmocks.NewMockOrganizationsService(ctrl) + createdAt := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + lastLogin := time.Date(2024, 6, 15, 10, 30, 0, 0, time.UTC) + mockSvc.EXPECT().ListOrganizationMembers( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")), + mo.None[uint](), + mo.None[uint](), + ).Return(organizations.OrganizationMembersResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 100*time.Millisecond, 1), + Data: organizations.OrganizationMembers{ + Members: []organizations.OrganizationMember{ + { + ID: uuid.MustParse("a1b2c3d4-e5f6-7890-abcd-ef1234567890"), + CreatedAt: mo.Some(createdAt), + Email: mo.Some("user1@example.com"), + FirstName: mo.Some("John"), + LastName: mo.Some("Doe"), + Roles: []string{"admin", "viewer"}, + LatestLoginTime: mo.Some(lastLogin), + }, + { + ID: uuid.MustParse("b2c3d4e5-f6a7-8901-bcde-f12345678901"), + Email: mo.Some("user2@example.com"), + FirstName: mo.Some("Jane"), + LastName: mo.Some("Smith"), + Roles: []string{"viewer"}, + }, + }, + }, + }, nil) + return mockSvc + }, + args: []string{"--org-id", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"members"`) + require.Contains(t, stdout, "user1@example.com") + require.Contains(t, stdout, "user2@example.com") + require.Contains(t, stdout, "John") + require.Contains(t, stdout, "admin") + }, + }, + { + name: "success - list members with stored org ID", + store: func(ctrl *gomock.Controller) store.Store { + mockStore := storemocks.NewMockStore(ctrl) + mockStore.EXPECT().GetLastUsedGlobalByName( + gomock.Any(), + "org-id", + ).Return(&store.ValueForGlobal{ + ID: 1, + Name: "org-id", + Value: "58857aac-4b76-46ec-a567-0e02b2c3d479", + }, nil) + return mockStore + }, + service: func(ctrl *gomock.Controller) organizations.Service { + mockSvc := orgmocks.NewMockOrganizationsService(ctrl) + mockSvc.EXPECT().ListOrganizationMembers( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("58857aac-4b76-46ec-a567-0e02b2c3d479")), + mo.None[uint](), + mo.None[uint](), + ).Return(organizations.OrganizationMembersResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 100*time.Millisecond, 1), + Data: organizations.OrganizationMembers{ + Members: []organizations.OrganizationMember{ + { + ID: uuid.MustParse("a1b2c3d4-e5f6-7890-abcd-ef1234567890"), + Email: mo.Some("user@example.com"), + Roles: []string{"admin"}, + }, + }, + }, + }, nil) + return mockSvc + }, + args: []string{"--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "user@example.com") + }, + }, + { + name: "success - empty members list", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) organizations.Service { + mockSvc := orgmocks.NewMockOrganizationsService(ctrl) + mockSvc.EXPECT().ListOrganizationMembers( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")), + mo.None[uint](), + mo.None[uint](), + ).Return(organizations.OrganizationMembersResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 50*time.Millisecond, 1), + Data: organizations.OrganizationMembers{ + Members: []organizations.OrganizationMember{}, + }, + }, nil) + return mockSvc + }, + args: []string{"--org-id", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"members"`) + }, + }, + + // Error cases + { + name: "error - no org ID configured", + store: func(ctrl *gomock.Controller) store.Store { + mockStore := storemocks.NewMockStore(ctrl) + mockStore.EXPECT().GetLastUsedGlobalByName( + gomock.Any(), + "org-id", + ).Return(nil, store.ErrGlobalNotFound) + return mockStore + }, + service: func(ctrl *gomock.Controller) organizations.Service { + return orgmocks.NewMockOrganizationsService(ctrl) + }, + args: []string{"--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "no organization ID configured") + }, + }, + { + name: "error - invalid org ID format", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) organizations.Service { + return orgmocks.NewMockOrganizationsService(ctrl) + }, + args: []string{"--org-id", "invalid-uuid", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "invalid uuid") + }, + }, + { + name: "error - service returns error", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) organizations.Service { + mockSvc := orgmocks.NewMockOrganizationsService(ctrl) + mockSvc.EXPECT().ListOrganizationMembers( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")), + mo.None[uint](), + mo.None[uint](), + ).Return(organizations.OrganizationMembersResult{}, cenclierrors.NewCencliError(errors.New("organization not found"))) + return mockSvc + }, + args: []string{"--org-id", "f47ac10b-58cc-4372-a567-0e02b2c3d479"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "organization not found") + }, + }, + { + name: "error - too many arguments", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) organizations.Service { + return orgmocks.NewMockOrganizationsService(ctrl) + }, + args: []string{"extra-arg", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "accepts 0 arg(s), received 1") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tempDir := t.TempDir() + viper.Reset() + cfg, err := config.New(tempDir) + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + formatter.Stdout = &stdout + formatter.Stderr = &stderr + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + orgSvc := tc.service(ctrl) + opts := []command.ContextOpts{command.WithOrganizationsService(orgSvc)} + + cmdContext := command.NewCommandContext(cfg, tc.store(ctrl), opts...) + rootCmd, err := command.RootCommandToCobra(NewMembersCommand(cmdContext)) + require.NoError(t, err) + + rootCmd.SetArgs(tc.args) + execErr := rootCmd.Execute() + tc.assert(t, stdout.String(), stderr.String(), cenclierrors.NewCencliError(execErr)) + }) + } +} diff --git a/internal/command/org/members/short.go b/internal/command/org/members/short.go new file mode 100644 index 0000000..713e541 --- /dev/null +++ b/internal/command/org/members/short.go @@ -0,0 +1,146 @@ +package members + +import ( + "fmt" + "strings" + + "github.com/censys/cencli/internal/app/organizations" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/pkg/styles" + "github.com/censys/cencli/internal/pkg/ui/rawtable" + "github.com/censys/cencli/internal/pkg/ui/table" +) + +func (c *Command) showRawTable(result organizations.OrganizationMembersResult) cenclierrors.CencliError { + if len(result.Data.Members) == 0 { + fmt.Fprintf(formatter.Stdout, "\nNo members found.\n") + return nil + } + + columns := []rawtable.Column[organizations.OrganizationMember]{ + { + Title: "Email", + String: func(m organizations.OrganizationMember) string { + if m.Email.IsPresent() { + return m.Email.MustGet() + } + return "-" + }, + Style: func(s string, m organizations.OrganizationMember) string { + return styles.NewStyle(styles.ColorTeal).Render(s) + }, + }, + { + Title: "Name", + String: formatName, + Style: func(s string, m organizations.OrganizationMember) string { + return styles.NewStyle(styles.ColorOffWhite).Render(s) + }, + }, + { + Title: "Roles", + String: func(m organizations.OrganizationMember) string { + if len(m.Roles) > 0 { + return strings.Join(m.Roles, ", ") + } + return "-" + }, + Style: func(s string, m organizations.OrganizationMember) string { + return styles.NewStyle(styles.ColorSage).Render(s) + }, + }, + { + Title: "First Login", + String: func(m organizations.OrganizationMember) string { + if m.FirstLoginTime.IsPresent() { + return m.FirstLoginTime.MustGet().Format("2006-01-02 15:04") + } + return "Never" + }, + Style: func(s string, m organizations.OrganizationMember) string { + return styles.NewStyle(styles.ColorGray).Render(s) + }, + }, + { + Title: "Last Login", + String: func(m organizations.OrganizationMember) string { + if m.LatestLoginTime.IsPresent() { + return m.LatestLoginTime.MustGet().Format("2006-01-02 15:04") + } + return "Never" + }, + Style: func(s string, m organizations.OrganizationMember) string { + return styles.NewStyle(styles.ColorGray).Render(s) + }, + }, + } + + tbl := rawtable.New( + columns, + rawtable.WithHeaderStyle[organizations.OrganizationMember](styles.NewStyle(styles.ColorOffWhite).Bold(true)), + rawtable.WithStylesDisabled[organizations.OrganizationMember](!formatter.StdoutIsTTY()), + ) + + title := styles.GlobalStyles.Signature.Bold(true).Render(fmt.Sprintf("Organization Members (%d)", len(result.Data.Members))) + fmt.Fprintf(formatter.Stdout, "\n%s\n\n", title) + fmt.Fprint(formatter.Stdout, tbl.Render(result.Data.Members)) + fmt.Fprintf(formatter.Stdout, "\n") + + return nil +} + +func (c *Command) showInteractiveTable(result organizations.OrganizationMembersResult) cenclierrors.CencliError { + if len(result.Data.Members) == 0 { + fmt.Fprintf(formatter.Stdout, "\nNo members found.\n") + return nil + } + + tbl := table.NewTable[organizations.OrganizationMember]( + []string{"Email", "Name", "Roles", "First Login", "Last Login"}, + func(m organizations.OrganizationMember) []string { + email := "-" + if m.Email.IsPresent() { + email = m.Email.MustGet() + } + name := formatName(m) + roles := "-" + if len(m.Roles) > 0 { + roles = strings.Join(m.Roles, ", ") + } + firstLogin := "Never" + if m.FirstLoginTime.IsPresent() { + firstLogin = m.FirstLoginTime.MustGet().Format("2006-01-02 15:04") + } + lastLogin := "Never" + if m.LatestLoginTime.IsPresent() { + lastLogin = m.LatestLoginTime.MustGet().Format("2006-01-02 15:04") + } + return []string{email, name, roles, firstLogin, lastLogin} + }, + table.WithColumnWidths[organizations.OrganizationMember]([]int{30, 25, 20, 20, 20}), + table.WithTitle[organizations.OrganizationMember](fmt.Sprintf("Organization Members (%d)", len(result.Data.Members))), + ) + + if err := tbl.Run(result.Data.Members); err != nil { + return cenclierrors.NewCencliError( + fmt.Errorf("failed to display interactive table: %w", err), + ) + } + return nil +} + +// formatName combines first and last name, handling optional values. +func formatName(m organizations.OrganizationMember) string { + var parts []string + if m.FirstName.IsPresent() { + parts = append(parts, m.FirstName.MustGet()) + } + if m.LastName.IsPresent() { + parts = append(parts, m.LastName.MustGet()) + } + if len(parts) == 0 { + return "-" + } + return strings.Join(parts, " ") +} diff --git a/internal/command/org/org.go b/internal/command/org/org.go new file mode 100644 index 0000000..ea72835 --- /dev/null +++ b/internal/command/org/org.go @@ -0,0 +1,72 @@ +package org + +import ( + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/command/org/credits" + "github.com/censys/cencli/internal/command/org/details" + "github.com/censys/cencli/internal/command/org/members" + "github.com/censys/cencli/internal/pkg/cenclierrors" +) + +// Command is the parent org command that groups organization-related subcommands. +type Command struct { + *command.BaseCommand +} + +var _ command.Command = (*Command)(nil) + +// NewOrgCommand creates a new org command with all subcommands. +func NewOrgCommand(cmdContext *command.Context) *Command { + return &Command{BaseCommand: command.NewBaseCommand(cmdContext)} +} + +func (c *Command) Use() string { + return "org" +} + +func (c *Command) Short() string { + return "Manage and view organization details" +} + +func (c *Command) Long() string { + return `Manage and view organization details including credits, members, and organization information. + +By default, these commands use your stored organization ID. If no organization ID is stored, +or you want to query a different organization, use the --org-id flag on each subcommand. + +To set your default organization ID, run: censys config org-id set ` +} + +func (c *Command) Args() command.PositionalArgs { + return command.ExactArgs(0) +} + +func (c *Command) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *Command) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort} +} + +func (c *Command) Init() error { + return c.AddSubCommands( + credits.NewCreditsCommand(c.Context), + members.NewMembersCommand(c.Context), + details.NewDetailsCommand(c.Context), + ) +} + +func (c *Command) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + return nil +} + +func (c *Command) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + // Parent command shows help when run without subcommands + if err := cmd.Help(); err != nil { + return cenclierrors.NewCencliError(err) + } + return nil +} diff --git a/internal/command/output.go b/internal/command/output.go new file mode 100644 index 0000000..13a2c2d --- /dev/null +++ b/internal/command/output.go @@ -0,0 +1,290 @@ +package command + +import ( + "fmt" + "slices" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/censys/cencli/internal/config" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/formatter" +) + +// OutputSupport defines what output formats a command supports and its default. +type OutputSupport struct { + Default OutputType +} + +// OutputType is the type of output a command supports. +type OutputType int + +const ( + // OutputTypeData is the output type for commands that output buffered raw data (json, yaml, tree) + OutputTypeData OutputType = iota + // OutputTypeShort is the output type for commands that output a short view (i.e. a custom rendering) + OutputTypeShort + // OutputTypeTemplate is the output type for commands that output a template view (i.e. a handlebars template) + OutputTypeTemplate +) + +func validateOutputFormat(format formatter.OutputFormat, cmd Command) cenclierrors.CencliError { + supportedTypes := cmd.SupportedOutputTypes() + + // Build list of supported formats for this command + var supportedFormats []string + for _, t := range supportedTypes { + switch t { + case OutputTypeData: + supportedFormats = append(supportedFormats, + formatter.OutputFormatJSON.String(), + formatter.OutputFormatYAML.String(), + formatter.OutputFormatTree.String(), + ) + case OutputTypeShort: + supportedFormats = append(supportedFormats, formatter.OutputFormatShort.String()) + case OutputTypeTemplate: + supportedFormats = append(supportedFormats, formatter.OutputFormatTemplate.String()) + } + } + + var requestedOutputType OutputType + switch format { + case formatter.OutputFormatJSON, formatter.OutputFormatYAML, formatter.OutputFormatTree: + requestedOutputType = OutputTypeData + case formatter.OutputFormatShort: + requestedOutputType = OutputTypeShort + case formatter.OutputFormatTemplate: + requestedOutputType = OutputTypeTemplate + default: + // Invalid format - show only formats supported by this command + return newInvalidOutputFormatError(format.String(), supportedFormats) + } + + // Check if command supports this type + if slices.Contains(supportedTypes, requestedOutputType) { + return nil + } + + // Valid format but not supported by this command + return newUnsupportedOutputFormatError(format.String(), supportedFormats) +} + +func getOutputFormatValue(cobraCmd *cobra.Command, cmd Command, valueFromConfig formatter.OutputFormat) formatter.OutputFormat { + flag := cobraCmd.Flag(formatter.OutputFormatFlagName) + + if flag == nil { + return valueFromConfig + } + + if flag.Changed { + return formatter.OutputFormat(flag.Value.String()) + } + + // User didn't explicitly set the output format, so apply the command's default if it has one + defaultOutputType := cmd.DefaultOutputType() + switch defaultOutputType { + case OutputTypeData: + return valueFromConfig + case OutputTypeShort: + return formatter.OutputFormatShort + case OutputTypeTemplate: + return formatter.OutputFormatTemplate + default: + return valueFromConfig + } +} + +// applyOutputFormatDefaultsRecursive applies output format defaults to a command +// and all its subcommands recursively. This must be called after the command has +// been added to its parent so inherited flags are available. +func applyOutputFormatDefaultsRecursive(cobraCmd *cobra.Command, cmd Command) error { + // Apply to this command first + if err := applyOutputFormatDefaults(cobraCmd, cmd); err != nil { + return err + } + + // Recursively apply to all subcommands + for _, subCmd := range cobraCmd.Commands() { + if err := applyOutputFormatDefaultsRecursive(subCmd, nil); err != nil { + return err + } + } + + return nil +} + +// applyOutputFormatDefaults adjusts the --output-format flag's default value +// based on the command's DefaultOutputType(). For commands with a custom default, +// we add a local flag that shadows the inherited one to avoid affecting siblings. +func applyOutputFormatDefaults(cobraCmd *cobra.Command, cmd Command) error { + if cmd == nil { + return nil + } + + var defaultFormat formatter.OutputFormat + switch cmd.DefaultOutputType() { + case OutputTypeData: + // no specialdefault output type, so we don't need to add a local flag + return nil + case OutputTypeShort: + defaultFormat = formatter.OutputFormatShort + case OutputTypeTemplate: + defaultFormat = formatter.OutputFormatTemplate + } + + // Check if the flag already exists (to avoid redefinition) + if cobraCmd.PersistentFlags().Lookup(formatter.OutputFormatFlagName) != nil { + return nil + } + + // Add a local persistent flag that shadows the inherited --output-format flag. + // This allows this command and its subcommands to have a different default + // without affecting sibling commands. + // + // NOTE: This creates a Cobra quirk where the flag appears as "local" rather than + // "inherited". We work around this by manually moving --output-format to the "Global Flags:" section + // when displaying flags in the help text. + cobraCmd.PersistentFlags().StringP(formatter.OutputFormatFlagName, "O", defaultFormat.String(), + fmt.Sprintf("output format (%s)", strings.Join(formatter.AvailableOutputFormats(), "|"))) + + // Bind the local flag to Viper. When Viper resolves a key with multiple bindings, + // the last binding wins. This means the local (most specific) flag will take + // precedence over the inherited flag from the root command. + if err := viper.BindPFlag(formatter.OutputFormatFlagName, + cobraCmd.PersistentFlags().Lookup(formatter.OutputFormatFlagName)); err != nil { + return fmt.Errorf("failed to bind local output-format flag: %w", err) + } + + return nil +} + +type outputFormatError struct { + provided string + supported []string +} + +type invalidOutputFormatError struct { + outputFormatError +} + +func newInvalidOutputFormatError(provided string, supported []string) *invalidOutputFormatError { + return &invalidOutputFormatError{ + outputFormatError: outputFormatError{ + provided: provided, + supported: supported, + }, + } +} + +var _ cenclierrors.CencliError = &invalidOutputFormatError{} + +func (e *invalidOutputFormatError) Error() string { + return fmt.Sprintf("invalid output format '%q'; supported formats: %s", e.provided, strings.Join(e.supported, ", ")) +} + +func (e *invalidOutputFormatError) Title() string { + return "Invalid Output Format" +} + +func (e *invalidOutputFormatError) ShouldPrintUsage() bool { + return true +} + +type unsupportedOutputFormatError struct { + outputFormatError +} + +var _ cenclierrors.CencliError = &unsupportedOutputFormatError{} + +func newUnsupportedOutputFormatError(provided string, supported []string) *unsupportedOutputFormatError { + return &unsupportedOutputFormatError{ + outputFormatError: outputFormatError{ + provided: provided, + supported: supported, + }, + } +} + +func (e *unsupportedOutputFormatError) Error() string { + return fmt.Sprintf("output format '%s' is not supported by this command -- supported formats: %s", e.provided, strings.Join(e.supported, ", ")) +} + +func (e *unsupportedOutputFormatError) Title() string { + return "Unsupported Output Format" +} + +func (e *unsupportedOutputFormatError) ShouldPrintUsage() bool { + return true +} + +// validateStreamingMode checks for conflicts between streaming mode and output format flags. +// Returns an error if: +// - streaming is enabled (via config or flag) AND output format flag is explicitly set +// - streaming flag is explicitly set but command doesn't support streaming +func validateStreamingMode(cobraCmd *cobra.Command, cmd Command, streamingFromConfig bool) cenclierrors.CencliError { + streamingFlag := cobraCmd.Flag(config.StreamingFlagName) + outputFormatFlag := cobraCmd.Flag(formatter.OutputFormatFlagName) + + // Determine if streaming is enabled (flag takes precedence over config) + streamingEnabled := streamingFromConfig + streamingFlagExplicit := streamingFlag != nil && streamingFlag.Changed + if streamingFlagExplicit { + streamingEnabled = streamingFlag.Value.String() == "true" + } + + // Check for conflict: streaming enabled + explicit output format + outputFormatExplicit := outputFormatFlag != nil && outputFormatFlag.Changed + if streamingEnabled && outputFormatExplicit { + return newStreamingConflictError() + } + + // Check if streaming flag was explicitly set on a command that doesn't support it + if streamingFlagExplicit && streamingEnabled && !cmd.SupportsStreaming() { + return newStreamingNotSupportedError() + } + + return nil +} + +type streamingConflictError struct{} + +var _ cenclierrors.CencliError = &streamingConflictError{} + +func newStreamingConflictError() *streamingConflictError { + return &streamingConflictError{} +} + +func (e *streamingConflictError) Error() string { + return fmt.Sprintf("--%s and --%s cannot be used together; streaming mode uses NDJSON output", config.StreamingFlagName, formatter.OutputFormatFlagName) +} + +func (e *streamingConflictError) Title() string { + return "Conflicting Flags" +} + +func (e *streamingConflictError) ShouldPrintUsage() bool { + return true +} + +type streamingNotSupportedError struct{} + +var _ cenclierrors.CencliError = &streamingNotSupportedError{} + +func newStreamingNotSupportedError() *streamingNotSupportedError { + return &streamingNotSupportedError{} +} + +func (e *streamingNotSupportedError) Error() string { + return "this command does not support streaming output" +} + +func (e *streamingNotSupportedError) Title() string { + return "Streaming Not Supported" +} + +func (e *streamingNotSupportedError) ShouldPrintUsage() bool { + return true +} diff --git a/internal/command/output_test.go b/internal/command/output_test.go new file mode 100644 index 0000000..5f36c80 --- /dev/null +++ b/internal/command/output_test.go @@ -0,0 +1,481 @@ +package command + +import ( + "bytes" + "testing" + + "github.com/samber/mo" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + storemocks "github.com/censys/cencli/gen/store/mocks" + "github.com/censys/cencli/internal/config" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/formatter" +) + +// TestOutputFormatBinding tests that output format flag bindings work correctly +// when commands have different default output types. +func TestOutputFormatBinding(t *testing.T) { + tests := []struct { + name string + setupCommands func(ctx *Context) (root Command, children []Command) + commandToExecute string // which command to run (empty = root) + args []string + expectedConfigFormat formatter.OutputFormat // what Config().OutputFormat should be + expectedFlagValue string // what the flag value should be + configFileFormat formatter.OutputFormat // what's in the config file + assertBeforeExecution func(t *testing.T, rootCmd *cobra.Command) + assertAfterUnmarshal func(t *testing.T, cfg *config.Config) + }{ + { + name: "root command with json default", + setupCommands: func(ctx *Context) (Command, []Command) { + root := newTestCommand(ctx) + root.useFn = func() string { return "root" } + return root, nil + }, + commandToExecute: "", + args: []string{}, + configFileFormat: formatter.OutputFormatJSON, + expectedConfigFormat: formatter.OutputFormatJSON, + expectedFlagValue: "json", + }, + { + name: "child command with short default - no user override", + setupCommands: func(ctx *Context) (Command, []Command) { + root := newTestCommand(ctx) + root.useFn = func() string { return "root" } + + child := newTestCommand(ctx) + child.useFn = func() string { return "child" } + child.defaultOutputTypeFn = func() OutputType { + return OutputTypeShort + } + child.supportedOutputTypesFn = func() []OutputType { + return []OutputType{OutputTypeData, OutputTypeShort} + } + + return root, []Command{child} + }, + commandToExecute: "child", + args: []string{}, + configFileFormat: formatter.OutputFormatJSON, + expectedConfigFormat: formatter.OutputFormatShort, // Should be short due to command default + expectedFlagValue: "short", + }, + { + name: "child command with short default - user overrides with json", + setupCommands: func(ctx *Context) (Command, []Command) { + root := newTestCommand(ctx) + root.useFn = func() string { return "root" } + + child := newTestCommand(ctx) + child.useFn = func() string { return "child" } + child.defaultOutputTypeFn = func() OutputType { + return OutputTypeShort + } + child.supportedOutputTypesFn = func() []OutputType { + return []OutputType{OutputTypeData, OutputTypeShort} + } + + return root, []Command{child} + }, + commandToExecute: "child", + args: []string{"--output-format", "json"}, + configFileFormat: formatter.OutputFormatJSON, + expectedConfigFormat: formatter.OutputFormatJSON, // User override wins + expectedFlagValue: "json", + }, + { + name: "child command with short default - yaml in config file", + setupCommands: func(ctx *Context) (Command, []Command) { + root := newTestCommand(ctx) + root.useFn = func() string { return "root" } + + child := newTestCommand(ctx) + child.useFn = func() string { return "child" } + child.defaultOutputTypeFn = func() OutputType { + return OutputTypeShort + } + child.supportedOutputTypesFn = func() []OutputType { + return []OutputType{OutputTypeData, OutputTypeShort} + } + + return root, []Command{child} + }, + commandToExecute: "child", + args: []string{}, + configFileFormat: formatter.OutputFormatYAML, + expectedConfigFormat: formatter.OutputFormatShort, // Command default wins over config file + expectedFlagValue: "short", + }, + { + name: "sibling commands with different defaults don't conflict", + setupCommands: func(ctx *Context) (Command, []Command) { + root := newTestCommand(ctx) + root.useFn = func() string { return "root" } + + child1 := newTestCommand(ctx) + child1.useFn = func() string { return "child1" } + child1.defaultOutputTypeFn = func() OutputType { + return OutputTypeShort + } + child1.supportedOutputTypesFn = func() []OutputType { + return []OutputType{OutputTypeData, OutputTypeShort} + } + + child2 := newTestCommand(ctx) + child2.useFn = func() string { return "child2" } + child2.defaultOutputTypeFn = func() OutputType { + return OutputTypeData + } + + return root, []Command{child1, child2} + }, + commandToExecute: "child2", + args: []string{}, + configFileFormat: formatter.OutputFormatJSON, + expectedConfigFormat: formatter.OutputFormatJSON, // child2 should use raw default (json) + expectedFlagValue: "json", + }, + { + name: "template default command", + setupCommands: func(ctx *Context) (Command, []Command) { + root := newTestCommand(ctx) + root.useFn = func() string { return "root" } + + child := newTestCommand(ctx) + child.useFn = func() string { return "templated" } + child.defaultOutputTypeFn = func() OutputType { + return OutputTypeTemplate + } + child.supportedOutputTypesFn = func() []OutputType { + return []OutputType{OutputTypeData, OutputTypeTemplate} + } + + return root, []Command{child} + }, + commandToExecute: "templated", + args: []string{}, + configFileFormat: formatter.OutputFormatJSON, + expectedConfigFormat: formatter.OutputFormatTemplate, + expectedFlagValue: "template", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Reset Viper for each test + viper.Reset() + + // Create temp directory and config + tempDir := t.TempDir() + cfg, err := config.New(tempDir) + require.NoError(t, err) + + // Set config file format + cfg.OutputFormat = tc.configFileFormat + + // Create mock store + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := storemocks.NewMockStore(ctrl) + + // Create command context + cmdContext := NewCommandContext(cfg, mockStore) + + // Setup commands + rootCmd, children := tc.setupCommands(cmdContext) + + // Convert root to cobra + cobraRoot, err := RootCommandToCobra(rootCmd) + require.NoError(t, err) + + // Bind global flags + require.NoError(t, config.BindGlobalFlags(cobraRoot.PersistentFlags(), cfg)) + + // Add children + for _, child := range children { + require.NoError(t, rootCmd.AddSubCommands(child)) + } + + // Optional assertions before execution + if tc.assertBeforeExecution != nil { + tc.assertBeforeExecution(t, cobraRoot) + } + + // Build command args + args := []string{} + if tc.commandToExecute != "" { + args = append(args, tc.commandToExecute) + } + args = append(args, tc.args...) + cobraRoot.SetArgs(args) + + // Capture output + var stdout, stderr bytes.Buffer + formatter.Stdout = &stdout + formatter.Stderr = &stderr + cobraRoot.SetOut(&stdout) + cobraRoot.SetErr(&stderr) + + // Execute command + cmdErr := cobraRoot.Execute() + require.NoError(t, cmdErr) + + // After execution, config should have been unmarshaled + // Check that Config().OutputFormat has the expected value + assert.Equal(t, tc.expectedConfigFormat, cfg.OutputFormat, + "Config().OutputFormat should be %s but got %s", + tc.expectedConfigFormat, cfg.OutputFormat) + + // Also verify the flag value matches + var targetCmd *cobra.Command + if tc.commandToExecute == "" { + targetCmd = cobraRoot + } else { + var findErr error + targetCmd, _, findErr = cobraRoot.Find([]string{tc.commandToExecute}) + require.NoError(t, findErr) + } + + flag := targetCmd.Flag("output-format") + require.NotNil(t, flag, "output-format flag should exist") + assert.Equal(t, tc.expectedFlagValue, flag.Value.String(), + "Flag value should be %s but got %s", + tc.expectedFlagValue, flag.Value.String()) + + // Optional assertions after unmarshal + if tc.assertAfterUnmarshal != nil { + tc.assertAfterUnmarshal(t, cfg) + } + }) + } +} + +// TestValidateStreamingMode tests the streaming mode validation logic +func TestValidateStreamingMode(t *testing.T) { + tests := []struct { + name string + streamingFromConfig bool + streamingFlag mo.Option[bool] // None = flag not set, Some(true/false) = flag set to value + outputFormatFlag mo.Option[bool] // None = flag not set, Some(true) = flag explicitly set + supportsStreaming bool + expectError bool + errorType string // "conflict" or "not_supported" + }{ + { + name: "no streaming - no error", + streamingFromConfig: false, + streamingFlag: mo.None[bool](), + outputFormatFlag: mo.None[bool](), + supportsStreaming: false, + expectError: false, + }, + { + name: "streaming config on non-streaming command - silently ignored", + streamingFromConfig: true, + streamingFlag: mo.None[bool](), + outputFormatFlag: mo.None[bool](), + supportsStreaming: false, + expectError: false, + }, + { + name: "streaming flag on non-streaming command - error", + streamingFromConfig: false, + streamingFlag: mo.Some(true), + outputFormatFlag: mo.None[bool](), + supportsStreaming: false, + expectError: true, + errorType: "not_supported", + }, + { + name: "streaming flag on streaming command - no error", + streamingFromConfig: false, + streamingFlag: mo.Some(true), + outputFormatFlag: mo.None[bool](), + supportsStreaming: true, + expectError: false, + }, + { + name: "streaming config + explicit output format - conflict error", + streamingFromConfig: true, + streamingFlag: mo.None[bool](), + outputFormatFlag: mo.Some(true), + supportsStreaming: true, + expectError: true, + errorType: "conflict", + }, + { + name: "streaming flag + explicit output format - conflict error", + streamingFromConfig: false, + streamingFlag: mo.Some(true), + outputFormatFlag: mo.Some(true), + supportsStreaming: true, + expectError: true, + errorType: "conflict", + }, + { + name: "streaming flag false - no error", + streamingFromConfig: false, + streamingFlag: mo.Some(false), + outputFormatFlag: mo.None[bool](), + supportsStreaming: false, + expectError: false, + }, + { + name: "streaming flag false + output format - no error", + streamingFromConfig: false, + streamingFlag: mo.Some(false), + outputFormatFlag: mo.Some(true), + supportsStreaming: true, + expectError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Create a minimal cobra command with the flags we need + cobraCmd := &cobra.Command{Use: "test"} + cobraCmd.Flags().Bool(config.StreamingFlagName, false, "") + cobraCmd.Flags().String(formatter.OutputFormatFlagName, "json", "") + + // Set flag values if specified + if tc.streamingFlag.IsPresent() { + value := "false" + if tc.streamingFlag.MustGet() { + value = "true" + } + err := cobraCmd.Flags().Set(config.StreamingFlagName, value) + require.NoError(t, err) + } + if tc.outputFormatFlag.IsPresent() { + err := cobraCmd.Flags().Set(formatter.OutputFormatFlagName, "yaml") + require.NoError(t, err) + } + + // Create a test command with the specified streaming support + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := storemocks.NewMockStore(ctrl) + + tempDir := t.TempDir() + viper.Reset() + cfg, err := config.New(tempDir) + require.NoError(t, err) + + cmdContext := NewCommandContext(cfg, mockStore) + testCmd := newTestCommand(cmdContext) + testCmd.supportsStreamingFn = func() bool { + return tc.supportsStreaming + } + + // Call validateStreamingMode + result := validateStreamingMode(cobraCmd, testCmd, tc.streamingFromConfig) + + if tc.expectError { + require.NotNil(t, result, "expected error but got nil") + switch tc.errorType { + case "conflict": + _, ok := result.(*streamingConflictError) + assert.True(t, ok, "expected streamingConflictError, got %T", result) + case "not_supported": + _, ok := result.(*streamingNotSupportedError) + assert.True(t, ok, "expected streamingNotSupportedError, got %T", result) + } + } else { + assert.Nil(t, result, "expected no error but got: %v", result) + } + }) + } +} + +// TestMultipleCommandsSequentially tests that running different commands +// sequentially doesn't cause binding conflicts +func TestMultipleCommandsSequentially(t *testing.T) { + // Reset Viper + viper.Reset() + + // Create temp directory and config + tempDir := t.TempDir() + cfg, err := config.New(tempDir) + require.NoError(t, err) + cfg.OutputFormat = formatter.OutputFormatYAML + + // Create mock store + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := storemocks.NewMockStore(ctrl) + + // Create command context + cmdContext := NewCommandContext(cfg, mockStore) + + // Create root command + root := newTestCommand(cmdContext) + root.useFn = func() string { return "root" } + + // Create child1 with short default + child1 := newTestCommand(cmdContext) + child1.useFn = func() string { return "child1" } + child1.defaultOutputTypeFn = func() OutputType { + return OutputTypeShort + } + child1.supportedOutputTypesFn = func() []OutputType { + return []OutputType{OutputTypeData, OutputTypeShort} + } + child1.runFn = func(cmd *cobra.Command, args []string) cenclierrors.CencliError { + // Should see "short" in config + assert.Equal(t, formatter.OutputFormatShort, child1.Config().OutputFormat) + return nil + } + + // Create child2 with raw default + child2 := newTestCommand(cmdContext) + child2.useFn = func() string { return "child2" } + child2.defaultOutputTypeFn = func() OutputType { + return OutputTypeData + } + child2.runFn = func(cmd *cobra.Command, args []string) cenclierrors.CencliError { + // Should see "yaml" from config file, not "short" from child1 + assert.Equal(t, formatter.OutputFormatYAML, child2.Config().OutputFormat) + return nil + } + + // Build command tree + cobraRoot, err := RootCommandToCobra(root) + require.NoError(t, err) + require.NoError(t, config.BindGlobalFlags(cobraRoot.PersistentFlags(), cfg)) + require.NoError(t, root.AddSubCommands(child1, child2)) + + // Test 1: Run child1 (should have short) + t.Run("run child1 with short default", func(t *testing.T) { + viper.Reset() + cfg.OutputFormat = formatter.OutputFormatYAML // Reset config + + var stdout, stderr bytes.Buffer + formatter.Stdout = &stdout + formatter.Stderr = &stderr + + cobraRoot.SetArgs([]string{"child1"}) + err := cobraRoot.Execute() + require.NoError(t, err) + }) + + // Test 2: Run child2 (should have yaml, not affected by child1) + t.Run("run child2 with raw default", func(t *testing.T) { + viper.Reset() + cfg.OutputFormat = formatter.OutputFormatYAML // Reset config + + var stdout, stderr bytes.Buffer + formatter.Stdout = &stdout + formatter.Stderr = &stderr + + cobraRoot.SetArgs([]string{"child2"}) + err := cobraRoot.Execute() + require.NoError(t, err) + }) +} diff --git a/internal/command/root/root.go b/internal/command/root/root.go index 922542d..051e142 100644 --- a/internal/command/root/root.go +++ b/internal/command/root/root.go @@ -12,7 +12,9 @@ import ( censeyecmd "github.com/censys/cencli/internal/command/censeye" completioncmd "github.com/censys/cencli/internal/command/completion" configcmd "github.com/censys/cencli/internal/command/config" + creditscmd "github.com/censys/cencli/internal/command/credits" historycmd "github.com/censys/cencli/internal/command/history" + orgcmd "github.com/censys/cencli/internal/command/org" searchcmd "github.com/censys/cencli/internal/command/search" versioncmd "github.com/censys/cencli/internal/command/versioncmd" "github.com/censys/cencli/internal/command/view" @@ -46,8 +48,16 @@ func (c *Command) Args() command.PositionalArgs { return command.ExactArgs(0) } +func (c *Command) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *Command) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort} +} + func (c *Command) Init() error { - if err := config.BindGlobalFlags(c.PersistentFlags()); err != nil { + if err := config.BindGlobalFlags(c.PersistentFlags(), c.Config()); err != nil { return fmt.Errorf("failed to bind global flags: %w", err) } @@ -60,6 +70,8 @@ func (c *Command) Init() error { searchcmd.NewSearchCommand(c.Context), aggregatecmd.NewAggregateCommand(c.Context), censeyecmd.NewCenseyeCommand(c.Context), + creditscmd.NewCreditsCommand(c.Context), + orgcmd.NewOrgCommand(c.Context), ) } @@ -140,7 +152,7 @@ func (*Command) Tapes(recorder *tape.Recorder) []tape.Tape { ), // view with template output recorder.Type( - "view platform.censys.io:80 --short", + "view platform.censys.io:80 --output-format short", tape.WithSleepAfter(3), tape.WithClearAfter(), ), diff --git a/internal/command/search/search.go b/internal/command/search/search.go index 0edce8c..bca89a1 100644 --- a/internal/command/search/search.go +++ b/internal/command/search/search.go @@ -15,6 +15,7 @@ import ( "github.com/censys/cencli/internal/pkg/domain/identifiers" "github.com/censys/cencli/internal/pkg/flags" "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/pkg/formatter/short" "github.com/censys/cencli/internal/pkg/styles" "github.com/censys/cencli/internal/pkg/tape" ) @@ -43,7 +44,8 @@ type Command struct { orgID mo.Option[identifiers.OrganizationID] pageSize mo.Option[uint64] maxPages mo.Option[uint64] - short bool + // result stores the search result for rendering + result search.Result } // searchCommandFlags contains all flag handles used by the search command. @@ -53,7 +55,6 @@ type searchCommandFlags struct { fields flags.StringSliceFlag pageSize flags.IntegerFlag maxPages flags.IntegerFlag - short flags.BoolFlag } var _ command.Command = (*Command)(nil) @@ -81,6 +82,18 @@ func (c *Command) Args() command.PositionalArgs { return command.ExactArgs(1) } +func (c *Command) DefaultOutputType() command.OutputType { + return command.OutputTypeData +} + +func (c *Command) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeData, command.OutputTypeTemplate, command.OutputTypeShort} +} + +func (c *Command) SupportsStreaming() bool { + return true +} + func (c *Command) Examples() []string { return []string{ `"host.ip: 1.1.1.1/16"`, @@ -139,7 +152,6 @@ func (c *Command) Init() error { mo.None[int64](), // allow custom validation in PreRun (to support -1) mo.None[int64](), // no maximum ) - c.flags.short = flags.NewShortFlag(c.Flags(), "") return nil } @@ -160,9 +172,6 @@ func (c *Command) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliE if err := c.parseFieldsFlag(); err != nil { return err } - if err := c.parseShortFlag(); err != nil { - return err - } return c.resolveSearchService() } @@ -182,15 +191,17 @@ func (c *Command) Run(cmd *cobra.Command, args []string) cenclierrors.CencliErro logger.Debug("fetching all pages", "message", msg) } - var result search.Result + // Set up streaming output (no-op for non-streaming formats) + ctx, stopStreaming := c.WithStreamingOutput(cmd.Context(), logger) + defer stopStreaming(nil) err := c.WithProgress( - cmd.Context(), + ctx, logger, "Fetching search results...", func(pctx context.Context) cenclierrors.CencliError { var fetchErr cenclierrors.CencliError - result, fetchErr = c.fetchSearchResult(pctx) + c.result, fetchErr = c.fetchSearchResult(pctx) return fetchErr }, ) @@ -199,14 +210,18 @@ func (c *Command) Run(cmd *cobra.Command, args []string) cenclierrors.CencliErro return err } - // Render the search results (even if partial) - if renderErr := c.renderSearchResult(result); renderErr != nil { + // Print response metadata + c.PrintAppResponseMeta(c.result.Meta) + + // PrintData handles streaming vs buffered automatically + data := c.prepareSearchData() + if renderErr := c.PrintData(c, data); renderErr != nil { return renderErr } // If there was a partial error, print it to stderr after rendering the data - if result.PartialError != nil { - formatter.PrintError(result.PartialError, cmd) + if c.result.PartialError != nil { + formatter.PrintError(c.result.PartialError, cmd) } return nil @@ -225,20 +240,28 @@ func (c *Command) fetchSearchResult(ctx context.Context) (search.Result, cenclie return c.searchSvc.Search(ctx, params) } -// renderSearchResult prints response metadata and the results, using a short template when requested. -func (c *Command) renderSearchResult(result search.Result) cenclierrors.CencliError { - c.PrintAppResponseMeta(result.Meta) - // wrap each hit with the type it is, to help differentiate in the output - data := make([]any, len(result.Hits)) - for i, hit := range result.Hits { +// prepareSearchData wraps each hit with its type to help differentiate in the output. +func (c *Command) prepareSearchData() []any { + data := make([]any, len(c.result.Hits)) + for i, hit := range c.result.Hits { data[i] = map[string]any{ hit.AssetType().String(): hit, } } - if c.short && !c.Config().Quiet { - return c.PrintDataWithTemplate(config.TemplateEntitySearchResult, data) - } - return c.PrintData(data) + return data +} + +// RenderTemplate renders search results using a handlebars template. +func (c *Command) RenderTemplate() cenclierrors.CencliError { + data := c.prepareSearchData() + return c.PrintDataWithTemplate(config.TemplateEntitySearchResult, data) +} + +// RenderShort renders search results in short format. +func (c *Command) RenderShort() cenclierrors.CencliError { + output := short.SearchHits(c.result.Hits) + formatter.Println(formatter.Stdout, output) + return nil } // resolveSearchService initializes the search service from the command context. @@ -313,16 +336,6 @@ func (c *Command) parseFieldsFlag() cenclierrors.CencliError { return nil } -// parseShortFlag parses the short flag into c.short. -func (c *Command) parseShortFlag() cenclierrors.CencliError { - var err cenclierrors.CencliError - c.short, err = c.flags.short.Value() - if err != nil { - return err - } - return nil -} - func (*Command) Tapes(recorder *tape.Recorder) []tape.Tape { return []tape.Tape{ tape.NewTape("search", diff --git a/internal/command/search/search_test.go b/internal/command/search/search_test.go index 01f4641..3177028 100644 --- a/internal/command/search/search_test.go +++ b/internal/command/search/search_test.go @@ -504,3 +504,57 @@ func TestSearchCommand_PartialError(t *testing.T) { require.Contains(t, stderr.String(), "some data was successfully retrieved", "should include partial error message") }) } + +func TestSearchCommand_Streaming(t *testing.T) { + t.Run("streams output with streaming config", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := storemocks.NewMockStore(ctrl) + mockSvc := searchmocks.NewMockSearchService(ctrl) + + // Service returns multiple results + mockSvc.EXPECT().Search( + gomock.Any(), + gomock.AssignableToTypeOf(search.Params{}), + ).Return( + search.Result{ + Meta: &responsemeta.ResponseMeta{ + Method: "POST", + URL: "https://api.censys.io/v1/search", + Status: 200, + Latency: 100 * time.Millisecond, + }, + // When streaming, hits are emitted through channel, not returned here + Hits: nil, + TotalHits: 2, + }, nil) + + tempDir := t.TempDir() + viper.Reset() + cfg, err := config.New(tempDir) + require.NoError(t, err) + // Enable streaming mode + cfg.Streaming = true + + cmdContext := command.NewCommandContext(cfg, mockStore, command.WithSearchService(mockSvc)) + + searchCmd := NewSearchCommand(cmdContext) + rootCmd, err := command.RootCommandToCobra(searchCmd) + require.NoError(t, err) + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + rootCmd.SetOut(stdout) + rootCmd.SetErr(stderr) + formatter.Stdout = stdout + formatter.Stderr = stderr + + rootCmd.SetArgs([]string{"host.ip: 127.0.0.1"}) + cmdErr := rootCmd.Execute() + + require.NoError(t, cmdErr) + // In streaming mode, the service emits via channels, so the result.Hits is empty + // This test verifies that the command runs successfully with streaming mode + }) +} diff --git a/internal/command/versioncmd/version.go b/internal/command/versioncmd/version.go index dfed9a7..3af8697 100644 --- a/internal/command/versioncmd/version.go +++ b/internal/command/versioncmd/version.go @@ -30,6 +30,14 @@ func (c *Command) Args() command.PositionalArgs { return command.RangeArgs(0, 0) } +func (c *Command) DefaultOutputType() command.OutputType { + return command.OutputTypeData +} + +func (c *Command) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeData} +} + func (c *Command) Init() error { return nil } @@ -40,5 +48,5 @@ func (c *Command) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliE func (c *Command) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { info := appversion.BuildInfo() - return c.PrintData(info) + return c.PrintData(c, info) } diff --git a/internal/command/view/view.go b/internal/command/view/view.go index 823734a..a2edae4 100644 --- a/internal/command/view/view.go +++ b/internal/command/view/view.go @@ -17,6 +17,7 @@ import ( "github.com/censys/cencli/internal/pkg/domain/responsemeta" "github.com/censys/cencli/internal/pkg/flags" "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/pkg/formatter/short" "github.com/censys/cencli/internal/pkg/input" "github.com/censys/cencli/internal/pkg/tape" ) @@ -36,14 +37,14 @@ type Command struct { assetType assets.AssetType orgID mo.Option[identifiers.OrganizationID] atTime mo.Option[time.Time] - short bool + // result stores the asset result for rendering + result assetResult } type viewCommandFlags struct { orgID flags.OrgIDFlag inputFile flags.FileFlag atTime flags.TimestampFlag - short flags.BoolFlag } var _ command.Command = (*Command)(nil) @@ -77,7 +78,7 @@ func (c *Command) Examples() []string { "--input-file hosts.txt", "--input-file - # read assets from STDIN", "platform.censys.io:80 --at-time 2025-09-15T14:30:00Z", - "8.8.8.8 --short", + "8.8.8.8 --output-format short", } } @@ -88,7 +89,6 @@ func (c *Command) Init() error { c.flags.atTime = flags.NewTimestampFlag(c.Flags(), false, "at-time", "", mo.None[time.Time](), "view data as of this time (certificates not supported)") // add aliases: --at and -a c.flags.atTime.AddAlias("at", "a", "Alias for --at-time") - c.flags.short = flags.NewShortFlag(c.Flags(), "") return nil } @@ -96,6 +96,18 @@ func (c *Command) Args() command.PositionalArgs { return command.RangeArgs(0, 1) } +func (c *Command) DefaultOutputType() command.OutputType { + return command.OutputTypeData +} + +func (c *Command) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeData, command.OutputTypeTemplate, command.OutputTypeShort} +} + +func (c *Command) SupportsStreaming() bool { + return true +} + func (c *Command) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { // parse flags first (avoid resolving service before validation) if err := c.parseAtTimeFlag(); err != nil { @@ -104,9 +116,6 @@ func (c *Command) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliE if err := c.parseOrgIDFlag(); err != nil { return err } - if err := c.parseShortFlag(); err != nil { - return err - } // gather assets and classify rawAssets, err := c.gatherRawAssets(cmd, args) if err != nil { @@ -155,16 +164,6 @@ func (c *Command) parseOrgIDFlag() cenclierrors.CencliError { return nil } -// parseShortFlag parses the short flag into c.short. -func (c *Command) parseShortFlag() cenclierrors.CencliError { - var err cenclierrors.CencliError - c.short, err = c.flags.short.Value() - if err != nil { - return err - } - return nil -} - // gatherRawAssets returns raw asset strings from file, stdin, or positional args. func (c *Command) gatherRawAssets(cmd *cobra.Command, args []string) ([]string, cenclierrors.CencliError) { if c.flags.inputFile.IsSet() { @@ -189,14 +188,17 @@ func (c *Command) Run(cmd *cobra.Command, args []string) cenclierrors.CencliErro "count", count, ) - var result assetResult + // Set up streaming output (no-op for non-streaming formats) + ctx, stopStreaming := c.WithStreamingOutput(cmd.Context(), logger) + defer stopStreaming(nil) + err := c.WithProgress( - cmd.Context(), + ctx, logger, "Fetching assets...", - func(ctx context.Context) cenclierrors.CencliError { + func(pctx context.Context) cenclierrors.CencliError { var fetchErr cenclierrors.CencliError - result, fetchErr = c.fetchAssetResult(ctx) + c.result, fetchErr = c.fetchAssetResult(pctx) return fetchErr }, ) @@ -205,14 +207,17 @@ func (c *Command) Run(cmd *cobra.Command, args []string) cenclierrors.CencliErro return err } - // Render the assets (even if partial) - if renderErr := c.renderAssets(result); renderErr != nil { + // Print response metadata + c.PrintAppResponseMeta(c.result.Meta) + + // PrintData handles streaming vs buffered automatically + if renderErr := c.PrintData(c, c.result.Data()); renderErr != nil { return renderErr } // If there was a partial error, print it to stderr after rendering the data - if result.PartialError != nil { - formatter.PrintError(result.PartialError, cmd) + if c.result.PartialError != nil { + formatter.PrintError(c.result.PartialError, cmd) } return nil @@ -244,13 +249,13 @@ func (r assetResult) Data() any { } } -// renderAssets prints response metadata and either a short summary or structured data. -func (c *Command) renderAssets(res assetResult) cenclierrors.CencliError { - c.PrintAppResponseMeta(res.Meta) - if c.short { - return c.printShort(res) +// RenderTemplate renders asset results using a handlebars template. +func (c *Command) RenderTemplate() cenclierrors.CencliError { + templateEntity, err := templateEntityFromAssetType(c.result.Type) + if err != nil { + return err } - return c.PrintData(res.Data()) + return c.PrintDataWithTemplate(templateEntity, c.result.Data()) } // assetInputCount returns the number of input assets based on the inferred asset type. @@ -308,14 +313,6 @@ func (c *Command) fetchAssetResult(ctx context.Context) (assetResult, cenclierro } } -func (c *Command) printShort(res assetResult) cenclierrors.CencliError { - templateEntity, err := templateEntityFromAssetType(res.Type) - if err != nil { - return err - } - return c.PrintDataWithTemplate(templateEntity, res.Data()) -} - func templateEntityFromAssetType(assetType assets.AssetType) (config.TemplateEntity, cenclierrors.CencliError) { switch assetType { case assets.AssetTypeHost: @@ -329,6 +326,24 @@ func templateEntityFromAssetType(assetType assets.AssetType) (config.TemplateEnt } } +func (c *Command) RenderShort() cenclierrors.CencliError { + var output string + + switch c.result.Type { + case assets.AssetTypeWebProperty: + output = short.WebProperties(c.result.WebProperties) + case assets.AssetTypeHost: + output = short.Hosts(c.result.Hosts) + case assets.AssetTypeCertificate: + output = short.Certificates(c.result.Certificates) + default: + return NewUnsupportedAssetTypeError(c.result.Type, "short output not supported for this asset type") + } + + formatter.Println(formatter.Stdout, output) + return nil +} + func (*Command) Tapes(recorder *tape.Recorder) []tape.Tape { tallerConfig := tape.DefaultTapeConfig() tallerConfig.Height = 800 @@ -354,17 +369,17 @@ func (*Command) Tapes(recorder *tape.Recorder) []tape.Tape { tape.NewTape("view-short", tallerConfig, recorder.Type( - "view --short 8.8.8.8 ", + "view -O short 8.8.8.8 ", tape.WithSleepAfter(3), tape.WithClearAfter(), ), recorder.Type( - "view --short platform.censys.io:80", + "view -O short platform.censys.io:80", tape.WithSleepAfter(3), tape.WithClearAfter(), ), recorder.Type( - "view --short 3daf2843a77b6f4e6af43cd9b6f6746053b8c928e056e8a724808db8905a94cf", + "view -O short 3daf2843a77b6f4e6af43cd9b6f6746053b8c928e056e8a724808db8905a94cf", tape.WithSleepAfter(3), tape.WithClearAfter(), ), diff --git a/internal/command/view/view_test.go b/internal/command/view/view_test.go index a967925..4d9e6a6 100644 --- a/internal/command/view/view_test.go +++ b/internal/command/view/view_test.go @@ -54,7 +54,7 @@ func TestViewCommand(t *testing.T) { ms.EXPECT().GetHosts(gomock.Any(), mo.None[identifiers.OrganizationID](), []assets.HostID{hostID}, mo.None[time.Time]()).Return(result, nil) return ms }, - args: []string{"8.8.8.8", "--short"}, + args: []string{"8.8.8.8", "--output-format", "short"}, assert: func(t *testing.T, stdout, stderr string, err error) { require.NoError(t, err) require.Contains(t, stdout, "8.8.8.8") @@ -74,7 +74,7 @@ func TestViewCommand(t *testing.T) { ms.EXPECT().GetWebProperties(gomock.Any(), mo.None[identifiers.OrganizationID](), []assets.WebPropertyID{wp}, mo.None[time.Time]()).Return(result, nil) return ms }, - args: []string{"platform.censys.io:443", "--short"}, + args: []string{"platform.censys.io:443", "--output-format", "short"}, assert: func(t *testing.T, stdout, stderr string, err error) { require.NoError(t, err) require.Contains(t, stdout, "platform.censys.io") @@ -94,7 +94,7 @@ func TestViewCommand(t *testing.T) { ms.EXPECT().GetHosts(gomock.Any(), mo.None[identifiers.OrganizationID](), []assets.HostID{hostID}, mo.None[time.Time]()).Return(result, nil) return ms }, - args: []string{"8.8.8.8", "--short"}, + args: []string{"8.8.8.8", "--output-format", "short"}, assert: func(t *testing.T, stdout, stderr string, err error) { require.NoError(t, err) require.Contains(t, stdout, "8.8.8.8") @@ -115,7 +115,7 @@ func TestViewCommand(t *testing.T) { ms.EXPECT().GetCertificates(gomock.Any(), mo.None[identifiers.OrganizationID](), []assets.CertificateID{certID}).Return(result, nil) return ms }, - args: []string{"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", "--short"}, + args: []string{"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", "--output-format", "short"}, assert: func(t *testing.T, stdout, stderr string, err error) { require.NoError(t, err) // Expect truncated format first16…last4 @@ -136,7 +136,7 @@ func TestViewCommand(t *testing.T) { ms.EXPECT().GetWebProperties(gomock.Any(), mo.None[identifiers.OrganizationID](), []assets.WebPropertyID{wp}, mo.None[time.Time]()).Return(result, nil) return ms }, - args: []string{"platform.censys.io:80", "--short"}, + args: []string{"platform.censys.io:80", "--output-format", "short"}, assert: func(t *testing.T, stdout, stderr string, err error) { require.NoError(t, err) require.Contains(t, stdout, "platform.censys.io") @@ -548,6 +548,9 @@ func TestViewCommand(t *testing.T) { rootCmd, err := command.RootCommandToCobra(NewViewCommand(cmdContext)) require.NoError(t, err) + // Bind global flags for tests + require.NoError(t, config.BindGlobalFlags(rootCmd.PersistentFlags(), cfg)) + rootCmd.SetArgs(tc.args) if tc.stdin != "" { rootCmd.SetIn(bytes.NewBufferString(tc.stdin)) @@ -612,6 +615,9 @@ func TestViewCommand_PartialError(t *testing.T) { rootCmd, err := command.RootCommandToCobra(viewCmd) require.NoError(t, err) + // Bind global flags for this test + require.NoError(t, config.BindGlobalFlags(rootCmd.PersistentFlags(), cfg)) + stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} rootCmd.SetOut(stdout) @@ -619,7 +625,7 @@ func TestViewCommand_PartialError(t *testing.T) { formatter.Stdout = stdout formatter.Stderr = stderr - rootCmd.SetArgs([]string{"8.8.8.8", "--short"}) + rootCmd.SetArgs([]string{"8.8.8.8", "--output-format", "short"}) cmdErr := rootCmd.Execute() require.NoError(t, cmdErr) diff --git a/internal/config/config.go b/internal/config/config.go index a3faaff..ec621dd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,7 +17,8 @@ import ( ) type Config struct { - OutputFormat formatter.OutputFormat `yaml:"output-format" mapstructure:"output-format" doc:"Default output format (json|yaml|ndjson|tree)"` + OutputFormat formatter.OutputFormat `yaml:"output-format" mapstructure:"output-format" doc:"Default output format (json|yaml|tree)"` + Streaming bool `yaml:"streaming" mapstructure:"streaming" doc:"Enable streaming output mode (NDJSON) for commands that support it"` NoColor bool `yaml:"no-color" mapstructure:"no-color" doc:"Disable ANSI colors and styles"` Spinner SpinnerConfig `yaml:"spinner" mapstructure:"spinner"` Quiet bool `yaml:"quiet" mapstructure:"quiet" doc:"Suppress non-essential output"` @@ -31,6 +32,7 @@ type Config struct { var defaultConfig = &Config{ OutputFormat: formatter.OutputFormatJSON, + Streaming: false, NoColor: false, Spinner: defaultSpinnerConfig, Quiet: false, @@ -48,6 +50,9 @@ const ( quietKey = "quiet" debugKey = "debug" timeoutHTTPKey = "timeout-http" + + // StreamingFlagName is the name of the --streaming flag. + StreamingFlagName = "streaming" ) func New(dataDir string) (*Config, cenclierrors.CencliError) { @@ -132,7 +137,7 @@ func (c *Config) Unmarshal() cenclierrors.CencliError { // BindGlobalFlags binds all global configuration flags to viper. // This should be called on the root command. -func BindGlobalFlags(persistentFlags *pflag.FlagSet) error { +func BindGlobalFlags(persistentFlags *pflag.FlagSet, cfg *Config) error { if err := addPersistentBoolAndBind(persistentFlags, noColorKey, false, "disable ANSI colors and styles", ""); err != nil { return fmt.Errorf("failed to bind no-color flag: %w", err) } @@ -150,9 +155,12 @@ func BindGlobalFlags(persistentFlags *pflag.FlagSet) error { if err := addPersistentDurationAndBindToPath(persistentFlags, timeoutHTTPKey, "timeouts.http", defaultConfig.Timeouts.HTTP, "per-request timeout for HTTP requests (e.g. 10s, 1m) - use 0 to disable"); err != nil { return fmt.Errorf("failed to bind timeout-http flag: %w", err) } - if err := formatter.BindOutputFormat(persistentFlags); err != nil { + if err := formatter.BindOutputFormat(persistentFlags, cfg.OutputFormat); err != nil { return fmt.Errorf("failed to bind output-format flag: %w", err) } + if err := addPersistentBoolAndBind(persistentFlags, StreamingFlagName, false, "enable streaming output mode (NDJSON) for commands that support it", "S"); err != nil { + return fmt.Errorf("failed to bind streaming flag: %w", err) + } return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b9ad0bb..6e1e403 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -51,17 +51,17 @@ func TestConfig(t *testing.T) { return os.WriteFile(configPath, []byte("output-format: yaml\n"), 0o644) }, override: func() error { - viper.Set("output-format", "ndjson") + viper.Set("output-format", "tree") return nil }, assert: func(t *testing.T, cfg *Config, tempDir string) { - assert.Equal(t, formatter.OutputFormatNDJSON, cfg.OutputFormat) - assert.Equal(t, "ndjson", viper.GetString("output-format")) + assert.Equal(t, formatter.OutputFormatTree, cfg.OutputFormat) + assert.Equal(t, "tree", viper.GetString("output-format")) configPath := filepath.Join(tempDir, "config.yaml") fileContent, err := os.ReadFile(configPath) require.NoError(t, err) // After our fix, the updated config should be written back to the file - assert.Contains(t, string(fileContent), "output-format: ndjson") + assert.Contains(t, string(fileContent), "output-format: tree") }, }, { @@ -410,7 +410,7 @@ func TestConfig_DocComments_InitialCreation(t *testing.T) { t.Logf("Generated config.yaml:\n%s", yamlStr) // Verify that doc comments are present for top-level fields - assert.Contains(t, yamlStr, "# Default output format (json|yaml|ndjson|tree)") + assert.Contains(t, yamlStr, "# Default output format (json|yaml|tree)") assert.Contains(t, yamlStr, "# Disable ANSI colors and styles") assert.Contains(t, yamlStr, "# Disable spinner during operations") assert.Contains(t, yamlStr, "# Show stopwatch in the spinner after this many seconds") diff --git a/internal/pkg/cenclierrors/errs.go b/internal/pkg/cenclierrors/errs.go index 531a390..9b0df8a 100644 --- a/internal/pkg/cenclierrors/errs.go +++ b/internal/pkg/cenclierrors/errs.go @@ -89,6 +89,36 @@ func (e *partialError) Unwrap() error { return e.err } +// NewUsageError creates a CencliError for command usage errors. +// This should be used for errors like invalid flags, missing arguments, etc. +// These errors will trigger usage information to be printed. +func NewUsageError(err error) CencliError { + if err == nil { + return nil + } + return &usageError{err: err} +} + +type usageError struct { + err error +} + +func (e *usageError) Error() string { + return e.err.Error() +} + +func (e *usageError) Title() string { + return "Usage Error" +} + +func (e *usageError) ShouldPrintUsage() bool { + return true +} + +func (e *usageError) Unwrap() error { + return e.err +} + // NewInterruptedError creates a CencliError for interrupted operations. // This should used exclusively for context.Canceled errors. func NewInterruptedError() CencliError { @@ -179,3 +209,21 @@ func IsInterrupted(err error) bool { } return errors.Is(err, context.Canceled) } + +type noOrgIDError struct{} + +func (e *noOrgIDError) Error() string { + return "no organization ID configured. Use --org-id flag or run 'censys config org-id set ' to set a default" +} + +func (e *noOrgIDError) Title() string { + return "No Organization ID" +} + +func (e *noOrgIDError) ShouldPrintUsage() bool { + return true +} + +func NewNoOrgIDError() CencliError { + return &noOrgIDError{} +} diff --git a/internal/pkg/cenclierrors/errs_test.go b/internal/pkg/cenclierrors/errs_test.go index d300186..e6b9929 100644 --- a/internal/pkg/cenclierrors/errs_test.go +++ b/internal/pkg/cenclierrors/errs_test.go @@ -55,3 +55,25 @@ func TestCencliError_AvoidDoubleWrapping(t *testing.T) { assert.Equal(t, firstWrap, secondWrap) } + +func TestNewUsageError(t *testing.T) { + baseErr := errors.New("unknown flag: --invalid") + usageErr := NewUsageError(baseErr) + + assert.NotNil(t, usageErr) + assert.Contains(t, usageErr.Error(), "unknown flag: --invalid") + assert.Equal(t, "Usage Error", usageErr.Title()) + assert.True(t, usageErr.ShouldPrintUsage()) +} + +func TestUsageError_NilHandling(t *testing.T) { + usageErr := NewUsageError(nil) + assert.Nil(t, usageErr) +} + +func TestUsageError_Unwrap(t *testing.T) { + baseErr := errors.New("test error") + usageErr := NewUsageError(baseErr) + + assert.True(t, errors.Is(usageErr, baseErr)) +} diff --git a/internal/pkg/censyscopy/censyscopy.go b/internal/pkg/censyscopy/censyscopy.go index 81c925f..da811d8 100644 --- a/internal/pkg/censyscopy/censyscopy.go +++ b/internal/pkg/censyscopy/censyscopy.go @@ -11,10 +11,13 @@ import ( type CencliLink string const ( - CencliRepo CencliLink = "https://github.com/censys/cencli" - CencliDocs CencliLink = "https://docs.censys.com/docs/platform-cli" - CensysPATInstructions CencliLink = "https://docs.censys.com/reference/get-started#step-2-create-a-personal-access-token" - CensysOrgIDInstructions CencliLink = "https://docs.censys.com/reference/get-started#step-3-find-and-use-your-organization-id-optional" + CencliRepo CencliLink = "https://github.com/censys/cencli" + CencliDocs CencliLink = "https://docs.censys.com/docs/platform-cli" + CensysPATInstructions CencliLink = "https://docs.censys.com/reference/get-started#step-2-create-a-personal-access-token" + CensysOrgIDInstructions CencliLink = "https://docs.censys.com/reference/get-started#step-3-find-and-use-your-organization-id-optional" + CensysHostLookupTemplate CencliLink = "https://platform.censys.io/hosts/{host_id}" + CensysCertificateLookupTemplate CencliLink = "https://platform.censys.io/certificates/{certificate_id}" + CensysWebPropertyLookupTemplate CencliLink = "https://platform.censys.io/web/{hostname:port}" ) func (l CencliLink) String() string { @@ -98,3 +101,18 @@ func DocumentationOrgID(w io.Writer) string { } return sb.String() } + +// CensysHostLookupLink creates a link to the Censys platform for a given host ID. +func CensysHostLookupLink(hostID string) CencliLink { + return CencliLink(strings.Replace(string(CensysHostLookupTemplate), "{host_id}", hostID, 1)) +} + +// CensysCertificateLookupLink creates a link to the Censys platform for a given certificate ID. +func CensysCertificateLookupLink(certID string) CencliLink { + return CencliLink(strings.Replace(string(CensysCertificateLookupTemplate), "{certificate_id}", certID, 1)) +} + +// CensysWebPropertyLookupLink creates a link to the Censys platform for a given web property (hostname:port). +func CensysWebPropertyLookupLink(hostport string) CencliLink { + return CencliLink(strings.Replace(string(CensysWebPropertyLookupTemplate), "{hostname:port}", hostport, 1)) +} diff --git a/internal/pkg/clients/censys/account_management.go b/internal/pkg/clients/censys/account_management.go new file mode 100644 index 0000000..bc77990 --- /dev/null +++ b/internal/pkg/clients/censys/account_management.go @@ -0,0 +1,160 @@ +package censys + +import ( + "context" + "time" + + "github.com/samber/mo" + + "github.com/censys/censys-sdk-go/models/components" + "github.com/censys/censys-sdk-go/models/operations" +) + +//go:generate mockgen -destination=../../../../gen/client/mocks/accountmanagement_mock.go -package=mocks github.com/censys/cencli/internal/pkg/clients/censys AccountManagementClient +type AccountManagementClient interface { + // https://github.com/censys/censys-sdk-go/tree/main/docs/sdks/accountmanagement#getorganizationcredits + GetOrganizationCreditDetails( + ctx context.Context, + orgID string, + ) (Result[components.OrganizationCredits], ClientError) + // https://github.com/censys/censys-sdk-go/tree/main/docs/sdks/accountmanagement#getusercredits + GetUserCreditDetails( + ctx context.Context, + ) (Result[components.UserCredits], ClientError) + // https://github.com/censys/censys-sdk-go/tree/main/docs/sdks/accountmanagement#getorganizationdetails + GetOrganizationDetails( + ctx context.Context, + orgID string, + ) (Result[components.OrganizationDetails], ClientError) + // https://github.com/censys/censys-sdk-go/tree/main/docs/sdks/accountmanagement#listorganizationmembers + ListOrganizationMembers( + ctx context.Context, + orgID string, + pageSize mo.Option[int], + pageToken mo.Option[string], + ) (Result[components.OrganizationMembersList], ClientError) +} + +type accountManagementSDK struct { + *censysSDK +} + +var _ AccountManagementClient = &accountManagementSDK{} + +func newAccountManagementSDK(censysSDK *censysSDK) *accountManagementSDK { + return &accountManagementSDK{censysSDK: censysSDK} +} + +func (a *accountManagementSDK) GetOrganizationCreditDetails( + ctx context.Context, + orgID string, +) (Result[components.OrganizationCredits], ClientError) { + start := time.Now() + var res *operations.V3AccountmanagementOrgCreditsResponse + err, attempts := a.executeWithRetry(ctx, func() ClientError { + var err error + res, err = a.censysSDK.client.AccountManagement.GetOrganizationCredits(ctx, operations.V3AccountmanagementOrgCreditsRequest{ + OrganizationID: orgID, + }) + if err != nil { + return NewClientError(err) + } + return nil + }) + latency := time.Since(start) + if err != nil { + zero := Result[components.OrganizationCredits]{} + return zero, err + } + return Result[components.OrganizationCredits]{ + Metadata: buildResponseMetadata(res, latency, attempts), + Data: res.GetResponseEnvelopeOrganizationCredits().GetResult(), + }, nil +} + +func (a *accountManagementSDK) GetUserCreditDetails( + ctx context.Context, +) (Result[components.UserCredits], ClientError) { + start := time.Now() + var res *operations.V3AccountmanagementUserCreditsResponse + err, attempts := a.executeWithRetry(ctx, func() ClientError { + var err error + res, err = a.censysSDK.client.AccountManagement.GetUserCredits(ctx) + if err != nil { + return NewClientError(err) + } + return nil + }) + latency := time.Since(start) + if err != nil { + zero := Result[components.UserCredits]{} + return zero, err + } + return Result[components.UserCredits]{ + Metadata: buildResponseMetadata(res, latency, attempts), + Data: res.GetResponseEnvelopeUserCredits().GetResult(), + }, nil +} + +func (a *accountManagementSDK) GetOrganizationDetails( + ctx context.Context, + orgID string, +) (Result[components.OrganizationDetails], ClientError) { + start := time.Now() + var res *operations.V3AccountmanagementOrgDetailsResponse + err, attempts := a.executeWithRetry(ctx, func() ClientError { + var err error + res, err = a.censysSDK.client.AccountManagement.GetOrganizationDetails(ctx, operations.V3AccountmanagementOrgDetailsRequest{ + OrganizationID: orgID, + IncludeMemberCounts: boolPtr(true), + }) + if err != nil { + return NewClientError(err) + } + return nil + }) + latency := time.Since(start) + if err != nil { + zero := Result[components.OrganizationDetails]{} + return zero, err + } + return Result[components.OrganizationDetails]{ + Metadata: buildResponseMetadata(res, latency, attempts), + Data: res.GetResponseEnvelopeOrganizationDetails().GetResult(), + }, nil +} + +func (a *accountManagementSDK) ListOrganizationMembers( + ctx context.Context, + orgID string, + pageSize mo.Option[int], + pageToken mo.Option[string], +) (Result[components.OrganizationMembersList], ClientError) { + start := time.Now() + var res *operations.V3AccountmanagementListOrgMembersResponse + err, attempts := a.executeWithRetry(ctx, func() ClientError { + var err error + res, err = a.censysSDK.client.AccountManagement.ListOrganizationMembers(ctx, operations.V3AccountmanagementListOrgMembersRequest{ + OrganizationID: orgID, + PageSize: pageSize.ToPointer(), + PageToken: pageToken.ToPointer(), + }) + if err != nil { + return NewClientError(err) + } + return nil + }) + latency := time.Since(start) + if err != nil { + zero := Result[components.OrganizationMembersList]{} + return zero, err + } + return Result[components.OrganizationMembersList]{ + Metadata: buildResponseMetadata(res, latency, attempts), + Data: res.GetResponseEnvelopeOrganizationMembersList().GetResult(), + }, nil +} + +func boolPtr(b bool) *bool { + return &b +} diff --git a/internal/pkg/clients/censys/censys.go b/internal/pkg/clients/censys/censys.go index fbb05a6..909400b 100644 --- a/internal/pkg/clients/censys/censys.go +++ b/internal/pkg/clients/censys/censys.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "runtime" "time" @@ -13,6 +14,7 @@ import ( "github.com/censys/cencli/internal/pkg/cenclierrors" clienthttp "github.com/censys/cencli/internal/pkg/clients/http" authdom "github.com/censys/cencli/internal/pkg/domain/auth" + applog "github.com/censys/cencli/internal/pkg/log" "github.com/censys/cencli/internal/store" "github.com/censys/cencli/internal/version" ) @@ -22,6 +24,7 @@ type Client interface { GlobalDataClient CollectionsClient ThreatHuntingClient + AccountManagementClient HasOrgID() bool } @@ -29,6 +32,7 @@ type censysSDK struct { client *censys.SDK retryStrategy config.RetryStrategy hasOrgID bool + logger *slog.Logger } func (c *censysSDK) HasOrgID() bool { @@ -40,6 +44,7 @@ type censysSDKImpl struct { GlobalDataClient CollectionsClient ThreatHuntingClient + AccountManagementClient } var _ Client = &censysSDKImpl{} @@ -49,9 +54,16 @@ func NewCensysSDK( ds store.Store, httpRequestTimeout time.Duration, retryStrategy config.RetryStrategy, + debug bool, ) (Client, error) { + // Create logger for HTTP and retry debugging (only logs when debug=true) + var logger *slog.Logger + if debug { + logger = applog.New(debug, nil) + } + sdkOpts := []censys.SDKOption{ - censys.WithClient(clienthttp.New(httpRequestTimeout, buildUserAgent())), + censys.WithClient(clienthttp.New(httpRequestTimeout, buildUserAgent(), logger)), } storedPAT, err := ds.GetLastUsedAuthByName(ctx, config.AuthName) @@ -76,13 +88,15 @@ func NewCensysSDK( client: censys.New(sdkOpts...), retryStrategy: retryStrategy, hasOrgID: hasOrgID, + logger: logger, } return &censysSDKImpl{ - censysSDK: censysSDK, - GlobalDataClient: newGlobalDataSDK(censysSDK), - CollectionsClient: newCollectionsSDK(censysSDK), - ThreatHuntingClient: newThreatHuntingSDK(censysSDK), + censysSDK: censysSDK, + GlobalDataClient: newGlobalDataSDK(censysSDK), + CollectionsClient: newCollectionsSDK(censysSDK), + ThreatHuntingClient: newThreatHuntingSDK(censysSDK), + AccountManagementClient: newAccountManagementSDK(censysSDK), }, nil } @@ -122,6 +136,13 @@ func (c *censysSDK) executeWithRetry(ctx context.Context, operationFn func() Cli } delay := calculateRetryDelay(baseDelay, c.retryStrategy.MaxDelay, c.retryStrategy.Backoff, attempt) + if c.logger != nil { + var statusCode int64 + if lastErr.StatusCode().IsPresent() { + statusCode = lastErr.StatusCode().MustGet() + } + c.logger.Debug("retrying request", "attempt", attempt, "max_attempts", maxAttempts, "status", statusCode, "delay", delay) + } timer := time.NewTimer(delay) select { case <-timer.C: diff --git a/internal/pkg/clients/censys/censys_test.go b/internal/pkg/clients/censys/censys_test.go index 9533186..f03b67c 100644 --- a/internal/pkg/clients/censys/censys_test.go +++ b/internal/pkg/clients/censys/censys_test.go @@ -39,7 +39,7 @@ func TestNewCensysSDK(t *testing.T) { LastUsedAt: time.Now(), }, nil) - client, err := NewCensysSDK(ctx, mockStore, 0, config.RetryStrategy{}) + client, err := NewCensysSDK(ctx, mockStore, 0, config.RetryStrategy{}, false) require.NoError(t, err) assert.NotNil(t, client) assert.True(t, client.HasOrgID()) @@ -59,7 +59,7 @@ func TestNewCensysSDK(t *testing.T) { mockStore.EXPECT().GetLastUsedGlobalByName(ctx, config.OrgIDGlobalName).Return((*store.ValueForGlobal)(nil), store.ErrGlobalNotFound) - client, err := NewCensysSDK(ctx, mockStore, 0, config.RetryStrategy{}) + client, err := NewCensysSDK(ctx, mockStore, 0, config.RetryStrategy{}, false) require.NoError(t, err) assert.NotNil(t, client) assert.False(t, client.HasOrgID()) @@ -73,7 +73,7 @@ func TestNewCensysSDK(t *testing.T) { mockStore.EXPECT().GetLastUsedAuthByName(ctx, config.AuthName).Return((*store.ValueForAuth)(nil), authdom.ErrAuthNotFound) - client, err := NewCensysSDK(ctx, mockStore, 0, config.RetryStrategy{}) + client, err := NewCensysSDK(ctx, mockStore, 0, config.RetryStrategy{}, false) assert.Error(t, err) assert.Nil(t, client) assert.True(t, errors.Is(err, authdom.ErrAuthNotFound)) @@ -87,7 +87,7 @@ func TestNewCensysSDK(t *testing.T) { mockStore.EXPECT().GetLastUsedAuthByName(ctx, config.AuthName).Return((*store.ValueForAuth)(nil), errors.New("db error")) - client, err := NewCensysSDK(ctx, mockStore, 0, config.RetryStrategy{}) + client, err := NewCensysSDK(ctx, mockStore, 0, config.RetryStrategy{}, false) assert.Error(t, err) assert.Nil(t, client) assert.Contains(t, err.Error(), "failed to get last used auth") @@ -107,7 +107,7 @@ func TestNewCensysSDK(t *testing.T) { mockStore.EXPECT().GetLastUsedGlobalByName(ctx, config.OrgIDGlobalName).Return((*store.ValueForGlobal)(nil), errors.New("db error")) - client, err := NewCensysSDK(ctx, mockStore, 0, config.RetryStrategy{}) + client, err := NewCensysSDK(ctx, mockStore, 0, config.RetryStrategy{}, false) assert.Error(t, err) assert.Nil(t, client) assert.Contains(t, err.Error(), "failed to get last used orgID") diff --git a/internal/pkg/clients/http/http.go b/internal/pkg/clients/http/http.go index b676406..59b17a2 100644 --- a/internal/pkg/clients/http/http.go +++ b/internal/pkg/clients/http/http.go @@ -1,6 +1,7 @@ package http import ( + "log/slog" "net" "net/http" "time" @@ -10,7 +11,9 @@ type Client struct { http.Client } -func New(requestTimeout time.Duration, userAgent string) *Client { +// New creates an HTTP client configured for CLI usage. +// If logger is non-nil, requests and responses will be logged at Debug level. +func New(requestTimeout time.Duration, userAgent string, logger *slog.Logger) *Client { // Custom base transport tuned for CLI usage base := &http.Transport{ Proxy: http.ProxyFromEnvironment, @@ -31,6 +34,7 @@ func New(requestTimeout time.Duration, userAgent string) *Client { Transport: &roundTripper{ RoundTripper: base, userAgent: userAgent, + logger: logger, }, Timeout: requestTimeout, }, @@ -40,6 +44,7 @@ func New(requestTimeout time.Duration, userAgent string) *Client { type roundTripper struct { http.RoundTripper userAgent string + logger *slog.Logger } func (r roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { @@ -49,5 +54,22 @@ func (r roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { } else { req.Header.Set("User-Agent", existingUserAgent+" "+r.userAgent) } - return r.RoundTripper.RoundTrip(req) + + if r.logger != nil { + r.logger.Debug("http request", "method", req.Method, "url", req.URL.String()) + } + + start := time.Now() + resp, err := r.RoundTripper.RoundTrip(req) + duration := time.Since(start) + + if r.logger != nil { + if err != nil { + r.logger.Debug("http error", "method", req.Method, "url", req.URL.String(), "error", err, "duration", duration) + } else { + r.logger.Debug("http response", "method", req.Method, "url", req.URL.String(), "status", resp.StatusCode, "duration", duration) + } + } + + return resp, err } diff --git a/internal/pkg/clients/http/http_test.go b/internal/pkg/clients/http/http_test.go index 3f69196..376522b 100644 --- a/internal/pkg/clients/http/http_test.go +++ b/internal/pkg/clients/http/http_test.go @@ -20,7 +20,7 @@ func TestUserAgentInjection_NoExisting(t *testing.T) { })) defer server.Close() - client := New(0, "cencli-test/0.1") + client := New(0, "cencli-test/0.1", nil) req, err := http.NewRequest(http.MethodGet, server.URL, nil) if err != nil { t.Fatalf("failed to create request: %v", err) @@ -45,7 +45,7 @@ func TestUserAgentInjection_AppendsExisting(t *testing.T) { })) defer server.Close() - client := New(0, "cencli-test/0.1") + client := New(0, "cencli-test/0.1", nil) req, err := http.NewRequest(http.MethodGet, server.URL, nil) if err != nil { t.Fatalf("failed to create request: %v", err) @@ -95,7 +95,7 @@ func TestUserAgentRoundTripper_AppendsOrSets(t *testing.T) { } func TestNew_SetsUserAgent_AndNoDefaultTimeout(t *testing.T) { - c := New(0, "cencli/ua") + c := New(0, "cencli/ua", nil) if c.Timeout != 0 { t.Fatalf("expected timeout 0 (disabled), got %v", c.Timeout) } diff --git a/internal/pkg/domain/identifiers/org.go b/internal/pkg/domain/identifiers/org.go index e68070c..27ed84a 100644 --- a/internal/pkg/domain/identifiers/org.go +++ b/internal/pkg/domain/identifiers/org.go @@ -9,6 +9,8 @@ type OrganizationID struct{ value uuid.UUID } func (i OrganizationID) String() string { return i.value.String() } +func (i OrganizationID) IsZero() bool { return i.value == uuid.Nil } + func NewOrganizationID(uuid uuid.UUID) OrganizationID { return OrganizationID{value: uuid} } diff --git a/internal/pkg/formatter/formatter.go b/internal/pkg/formatter/formatter.go index af9a39c..702e5dc 100644 --- a/internal/pkg/formatter/formatter.go +++ b/internal/pkg/formatter/formatter.go @@ -4,8 +4,8 @@ import ( "fmt" "io" "os" - "reflect" + "github.com/censys/cencli/internal/pkg/cenclierrors" "github.com/censys/cencli/internal/pkg/term" ) @@ -32,36 +32,24 @@ func Println(w io.Writer, a ...any) { // PrintByFormat prints data to stdout according to the provided output format. // Falls back to JSON when format is unrecognized. -func PrintByFormat(data any, format OutputFormat, colored bool) error { - switch format.String() { - case OutputFormatJSON.String(): - return PrintJSON(data, colored) - case OutputFormatNDJSON.String(): - return PrintNDJSON(asAnySlice(data), colored) - case OutputFormatYAML.String(): - return PrintYAML(data, colored) - case OutputFormatTree.String(): - return PrintTree(data, colored) +// +// Note: NDJSON is not supported here - it requires streaming via WithStreamingOutput. +// Commands that support NDJSON must use OutputTypeStreaming and skip PrintData when streaming. +func PrintByFormat(data any, format OutputFormat, colored bool) cenclierrors.CencliError { + switch format { + case OutputFormatJSON: + return cenclierrors.NewCencliError(PrintJSON(data, colored)) + case OutputFormatNDJSON: + // NDJSON requires streaming - this should never be reached if commands are implemented correctly + return cenclierrors.NewCencliError(fmt.Errorf("ndjson format requires streaming output")) + case OutputFormatYAML: + return cenclierrors.NewCencliError(PrintYAML(data, colored)) + case OutputFormatTree: + return cenclierrors.NewCencliError(PrintTree(data, colored)) + case OutputFormatShort, OutputFormatTemplate: + // these will be handled by the command + return cenclierrors.NewCencliError(fmt.Errorf("output format %s not supported", format)) default: - return PrintJSON(data, colored) + return cenclierrors.NewCencliError(PrintJSON(data, colored)) } } - -func asAnySlice(v any) []any { - // If it's already []any, return as-is - if s, ok := v.([]any); ok { - return s - } - // For any slice type, reflect to []any - rv := reflect.ValueOf(v) - if rv.IsValid() && rv.Kind() == reflect.Slice { - n := rv.Len() - out := make([]any, n) - for i := 0; i < n; i++ { - out[i] = rv.Index(i).Interface() - } - return out - } - // Fallback: single element slice - return []any{v} -} diff --git a/internal/pkg/formatter/formatter_test.go b/internal/pkg/formatter/formatter_test.go index 41dd3a4..a9d51c7 100644 --- a/internal/pkg/formatter/formatter_test.go +++ b/internal/pkg/formatter/formatter_test.go @@ -40,22 +40,6 @@ func TestPrintByFormat(t *testing.T) { isYAML: true, contains: []string{"key1: value1", "key2: value2"}, }, - { - name: "NDJSON format single item", - format: OutputFormatNDJSON, - data: testData, - expectedInJSON: true, - contains: []string{"key1", "value1"}, - }, - { - name: "NDJSON format array", - format: OutputFormatNDJSON, - data: []map[string]string{ - {"id": "1", "name": "first"}, - {"id": "2", "name": "second"}, - }, - contains: []string{`"id":"1"`, `"name":"first"`, `"id":"2"`, `"name":"second"`}, - }, { name: "Unknown format defaults to JSON", format: OutputFormat("unknown"), @@ -142,3 +126,12 @@ func TestPrintf(t *testing.T) { assert.Equal(t, "Number: 42, String: test", buf.String()) } + +func TestPrintByFormat_NDJSONReturnsError(t *testing.T) { + // NDJSON requires streaming and should not be used through PrintByFormat + testData := map[string]string{"key": "value"} + + err := PrintByFormat(testData, OutputFormatNDJSON, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "ndjson format requires streaming output") +} diff --git a/internal/pkg/formatter/json.go b/internal/pkg/formatter/json.go index 8f4658c..fe4a27f 100644 --- a/internal/pkg/formatter/json.go +++ b/internal/pkg/formatter/json.go @@ -2,57 +2,49 @@ package formatter import ( "encoding/json" + "io" "github.com/censys/cencli/internal/pkg/styles" jsoncolor "github.com/neilotoole/jsoncolor" ) // PrintJSON prints v as pretty JSON, optionally colored. +// Uses the standard library for marshaling (to support omitzero), +// then colorizes the output if requested. func PrintJSON(v any, colored bool) error { - enc := newEncoder(colored, true) - return enc.Encode(v) + return writeJSON(Stdout, v, colored, true) } -// PrintNDJSON prints one JSON object per line. -// If v is a slice, it prints each element on its own line. -// Otherwise, it prints v. -func PrintNDJSON(v any, colored bool) error { - enc := newEncoder(colored, false) - - switch s := v.(type) { - case []any: - for _, item := range s { - if err := enc.Encode(item); err != nil { - return err - } - } - return nil - default: - return enc.Encode(v) +// writeJSON writes v as JSON to w, optionally colored and pretty-printed. +// Uses the standard library for marshaling (to support omitzero), +// then colorizes the output if requested. +func writeJSON(w io.Writer, v any, colored, pretty bool) error { + var data []byte + var err error + if pretty { + data, err = json.MarshalIndent(v, "", " ") + } else { + data, err = json.Marshal(v) + } + if err != nil { + return err } -} - -// encoder is a type that can encode JSON. -type jsonEncoder interface { - Encode(v any) error -} -// newEncoder creates either a plain or color encoder. -// pretty controls whether SetIndent is applied. -func newEncoder(colored, pretty bool) jsonEncoder { if colored { - enc := jsoncolor.NewEncoder(Stdout) + var unmarshaled any + if err := json.Unmarshal(data, &unmarshaled); err != nil { + return err + } + enc := jsoncolor.NewEncoder(w) enc.SetColors(jsonColors()) if pretty { enc.SetIndent("", " ") } - return enc - } - enc := json.NewEncoder(Stdout) - if pretty { - enc.SetIndent("", " ") + return enc.Encode(unmarshaled) } - return enc + + _, err = w.Write(append(data, '\n')) + return err } // jsonColors defines the color scheme for jsoncolor. @@ -70,3 +62,12 @@ func jsonColors() *jsoncolor.Colors { res.TextMarshaler = styles.ANSIPrefix(styles.NewStyle(styles.ColorOrange)) // jq = white return res } + +// WriteNDJSONItem encodes a single item as NDJSON (one line of JSON) to the provided writer. +// This enables true streaming output where each item is written immediately. +// The item is encoded without pretty-printing (compact JSON on a single line). +// Uses the standard library for marshaling (to support omitzero), +// then colorizes the output if requested. +func WriteNDJSONItem(w io.Writer, item any, colored bool) error { + return writeJSON(w, item, colored, false) +} diff --git a/internal/pkg/formatter/json_test.go b/internal/pkg/formatter/json_test.go index be5476f..bab747a 100644 --- a/internal/pkg/formatter/json_test.go +++ b/internal/pkg/formatter/json_test.go @@ -54,7 +54,7 @@ func TestPrintJSON(t *testing.T) { } } -func TestPrintNDJSON(t *testing.T) { +func TestWriteNDJSONItem(t *testing.T) { tests := []struct { name string input any @@ -62,33 +62,24 @@ func TestPrintNDJSON(t *testing.T) { expected string }{ { - name: "slice of objects uncolored", - input: []any{map[string]any{"id": 1}, map[string]any{"id": 2}}, - colored: false, - expected: `{"id":1} -{"id":2} -`, + name: "single object uncolored", + input: map[string]any{"name": "test"}, + colored: false, + expected: "{\"name\":\"test\"}\n", }, { - name: "single object uncolored", - input: map[string]any{"name": "test"}, - colored: false, - expected: `{"name":"test"} -`, + name: "nested object uncolored", + input: map[string]any{"id": 1, "nested": map[string]any{"value": "x"}}, + colored: false, + expected: "{\"id\":1,\"nested\":{\"value\":\"x\"}}\n", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Capture output var buf bytes.Buffer - old := Stdout - Stdout = &buf - defer func() { Stdout = old }() - - err := PrintNDJSON(tt.input, tt.colored) + err := WriteNDJSONItem(&buf, tt.input, tt.colored) require.NoError(t, err) - assert.Equal(t, tt.expected, buf.String()) }) } diff --git a/internal/pkg/formatter/output_format.go b/internal/pkg/formatter/output_format.go index 031f376..f0140ba 100644 --- a/internal/pkg/formatter/output_format.go +++ b/internal/pkg/formatter/output_format.go @@ -10,17 +10,19 @@ import ( ) const ( - outputFormatKey = "output-format" - outputFormatShort = "O" + OutputFormatFlagName = "output-format" + outputFormatFlagShort = "O" ) type OutputFormat string const ( - OutputFormatJSON OutputFormat = "json" - OutputFormatYAML OutputFormat = "yaml" - OutputFormatNDJSON OutputFormat = "ndjson" - OutputFormatTree OutputFormat = "tree" + OutputFormatJSON OutputFormat = "json" + OutputFormatYAML OutputFormat = "yaml" + OutputFormatNDJSON OutputFormat = "ndjson" + OutputFormatTree OutputFormat = "tree" + OutputFormatShort OutputFormat = "short" + OutputFormatTemplate OutputFormat = "template" ) // ErrInvalidOutputFormat is returned when the provided output format is unsupported. @@ -39,18 +41,30 @@ func (o *OutputFormat) UnmarshalText(text []byte) error { *o = OutputFormatJSON case OutputFormatYAML.String(): *o = OutputFormatYAML - case OutputFormatNDJSON.String(): - *o = OutputFormatNDJSON case OutputFormatTree.String(): *o = OutputFormatTree + case OutputFormatShort.String(): + *o = OutputFormatShort + case OutputFormatTemplate.String(): + *o = OutputFormatTemplate default: return fmt.Errorf("%w: %s", ErrInvalidOutputFormat, s) } return nil } -func BindOutputFormat(persistentFlags *pflag.FlagSet) error { - // Bind the global --output-format flag (no short form) - persistentFlags.StringP(outputFormatKey, outputFormatShort, OutputFormatJSON.String(), "output format (json|yaml|ndjson|tree)") - return viper.BindPFlag(outputFormatKey, persistentFlags.Lookup(outputFormatKey)) +func AvailableOutputFormats() []string { + return []string{ + OutputFormatJSON.String(), + OutputFormatYAML.String(), + OutputFormatTree.String(), + OutputFormatShort.String(), + OutputFormatTemplate.String(), + } +} + +func BindOutputFormat(persistentFlags *pflag.FlagSet, defaultValue OutputFormat) error { + // Bind the global --output-format flag + persistentFlags.StringP(OutputFormatFlagName, outputFormatFlagShort, defaultValue.String(), "output format (json|yaml|tree|short|template)") + return viper.BindPFlag(OutputFormatFlagName, persistentFlags.Lookup(OutputFormatFlagName)) } diff --git a/internal/pkg/formatter/short/certificates.go b/internal/pkg/formatter/short/certificates.go new file mode 100644 index 0000000..2420779 --- /dev/null +++ b/internal/pkg/formatter/short/certificates.go @@ -0,0 +1,169 @@ +package short + +import ( + "fmt" + "strings" + "time" + + "github.com/censys/cencli/internal/pkg/censyscopy" + "github.com/censys/cencli/internal/pkg/domain/assets" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/pkg/styles" +) + +// Certificates renders certificates in short format +func Certificates(certificates []*assets.Certificate) string { + b := NewBlock() + + for i, cert := range certificates { + if i > 0 { + b.Newline() + } + b.SeparatorWithLabel(fmt.Sprintf("Certificate #%d", i+1)) + b.Write(renderCertificateShort(cert)) + } + + return b.String() +} + +// renderCertificateShort renders a single certificate +func renderCertificateShort(cert *assets.Certificate) string { + var out strings.Builder + + // Header + out.WriteString(certHeader(cert)) + + // Issuer and Subject DN + out.WriteString(certIssuer(cert)) + + // Validity + out.WriteString(certValidity(cert)) + + // TODO: Add labels section + + // Subject Alternative Names + out.WriteString(certSubjectAltNames(cert)) + + // Validation Level + out.WriteString(certMetadata(cert)) + + return out.String() +} + +// certHeader renders the certificate SHA256 fingerprint as the main header +func certHeader(cert *assets.Certificate) string { + fingerprint := Val(cert.FingerprintSha256, "") + link := censyscopy.CensysCertificateLookupLink(fingerprint) + + line := NewLine( + WithLabelStyle(styles.GlobalStyles.Signature), + ) + + if formatter.StdoutIsTTY() { + // Make fingerprint an underlined clickable link + underlinedFP := styles.GlobalStyles.Signature.Underline(true).Render(fingerprint) + line.Write("Certificate", link.Render(underlinedFP)) + } else { + // Plain fingerprint with separate Platform URL line + line.Write("Certificate", fingerprint) + line.Write("Platform URL", link.String()) + } + + return line.String() +} + +// certIssuer renders the issuer information +func certIssuer(cert *assets.Certificate) string { + if cert.Parsed == nil || cert.Parsed.Issuer == nil { + return "" + } + + issuerLine := NewLine(WithLabelStyle(styles.GlobalStyles.Primary)) + issuerLine.Newline() + issuerLine.Write("Issuer DN", Val(cert.Parsed.IssuerDn, "")) + subjectLine := NewLine() + subjectLine.Write("Subject DN", Val(cert.Parsed.SubjectDn, "")) + return issuerLine.String() + subjectLine.String() +} + +// certValidity renders the validity period +func certValidity(cert *assets.Certificate) string { + if cert.Parsed == nil || cert.Parsed.ValidityPeriod == nil { + return "" + } + + notBefore := Val(cert.Parsed.ValidityPeriod.NotBefore, "") + notAfter := Val(cert.Parsed.ValidityPeriod.NotAfter, "") + + if notBefore == "" && notAfter == "" { + return "" + } + + // Format dates nicely + notBeforeFormatted := formatCertDate(notBefore) + notAfterFormatted := formatCertDate(notAfter) + + validityStr := fmt.Sprintf("%s → %s", notBeforeFormatted, notAfterFormatted) + line := NewLine() + line.Write("Validity", validityStr) + return line.String() +} + +// formatCertDate formats a certificate date string into a human-readable format +func formatCertDate(dateStr string) string { + if dateStr == "" { + return "" + } + + // Try parsing common certificate date formats + formats := []string{ + time.RFC3339, + "2006-01-02T15:04:05Z", + "2006-01-02 15:04:05 UTC", + "2006-01-02", + } + + for _, format := range formats { + if t, err := time.Parse(format, dateStr); err == nil { + return t.Format("Jan 02, 2006") + } + } + + // Return original if parsing fails + return dateStr +} + +// certSubjectAltNames renders the subject alternative names section +func certSubjectAltNames(cert *assets.Certificate) string { + if cert.Parsed == nil || cert.Parsed.Extensions == nil || cert.Parsed.Extensions.SubjectAltName == nil { + return "" + } + + dnsNames := cert.Parsed.Extensions.SubjectAltName.DNSNames + if len(dnsNames) == 0 { + return "" + } + + var out strings.Builder + out.WriteString("\nSubject Alternative Names:\n") + + for _, name := range dnsNames { + out.WriteString(fmt.Sprintf(" - %s\n", styles.GlobalStyles.Tertiary.Render(name))) + } + + return out.String() +} + +// certMetadata renders validation level and parse status +func certMetadata(cert *assets.Certificate) string { + var out strings.Builder + + if cert.ValidationLevel != nil { + out.WriteString("\n") + line := NewLine() + line.Write("Validation Level", string(*cert.ValidationLevel)) + out.WriteString(line.String()) + } + + return out.String() +} diff --git a/internal/pkg/formatter/short/certificates_test.go b/internal/pkg/formatter/short/certificates_test.go new file mode 100644 index 0000000..c5fa788 --- /dev/null +++ b/internal/pkg/formatter/short/certificates_test.go @@ -0,0 +1,108 @@ +package short + +import ( + "strings" + "testing" + + "github.com/censys/censys-sdk-go/models/components" + "github.com/stretchr/testify/require" + + "github.com/censys/cencli/internal/pkg/domain/assets" +) + +func TestCertificates(t *testing.T) { + testCases := []struct { + name string + certificate *assets.Certificate + expectedOutput string + }{ + { + name: "rich certificate", + certificate: &assets.Certificate{ + Certificate: components.Certificate{ + FingerprintSha256: strPtr("abc123def456"), + FingerprintSha1: strPtr("abc123"), + FingerprintMd5: strPtr("def456"), + ParseStatus: components.ParseStatusSuccess.ToPointer(), + ValidationLevel: components.ValidationLevelDv.ToPointer(), + Parsed: &components.CertificateParsed{ + Subject: &components.DistinguishedName{ + CommonName: []string{"example.com"}, + Organization: []string{"Example Inc"}, + }, + Issuer: &components.DistinguishedName{ + CommonName: []string{"Let's Encrypt Authority X3"}, + Organization: []string{"Let's Encrypt"}, + }, + ValidityPeriod: &components.ValidityPeriod{ + NotBefore: strPtr("2024-01-01T00:00:00Z"), + NotAfter: strPtr("2024-12-31T23:59:59Z"), + }, + Extensions: &components.CertificateExtensions{ + SubjectAltName: &components.GeneralNames{ + DNSNames: []string{ + "example.com", + "www.example.com", + "api.example.com", + }, + }, + }, + }, + Ct: &components.Ct{ + Entries: map[string]components.CtRecord{ + "google_xenon2023": { + AddedToCtAt: strPtr("2024-01-01T12:00:00Z"), + }, + "cloudflare_nimbus2023": { + AddedToCtAt: strPtr("2024-01-01T12:05:00Z"), + }, + }, + }, + }, + }, + expectedOutput: ` +---------------------- Certificate #1 ---------------------- +Certificate: abc123def456 +Platform URL: https://platform.censys.io/certificates/abc123def456 + +Issuer DN +Subject DN +Validity: Jan 01, 2024 → Dec 31, 2024 + +Subject Alternative Names: + - example.com + - www.example.com + - api.example.com + +Validation Level: dv +`, + }, + { + name: "minimal certificate", + certificate: &assets.Certificate{ + Certificate: components.Certificate{ + FingerprintSha256: strPtr("minimal123"), + Parsed: &components.CertificateParsed{ + Subject: &components.DistinguishedName{ + CommonName: []string{"minimal.com"}, + }, + }, + }, + }, + expectedOutput: ` +---------------------- Certificate #1 ---------------------- +Certificate: minimal123 +Platform URL: https://platform.censys.io/certificates/minimal123 +`, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + actual := Certificates([]*assets.Certificate{tt.certificate}) + actualTrimmed := strings.TrimSpace(actual) + expectedTrimmed := strings.TrimSpace(tt.expectedOutput) + require.Equal(t, expectedTrimmed, actualTrimmed) + }) + } +} diff --git a/internal/pkg/formatter/short/hosts.go b/internal/pkg/formatter/short/hosts.go new file mode 100644 index 0000000..4231e40 --- /dev/null +++ b/internal/pkg/formatter/short/hosts.go @@ -0,0 +1,288 @@ +package short + +import ( + "fmt" + "sort" + "strings" + + "github.com/censys/censys-sdk-go/models/components" + + "github.com/censys/cencli/internal/pkg/censyscopy" + "github.com/censys/cencli/internal/pkg/domain/assets" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/pkg/styles" +) + +// Hosts renders hosts in short format. +func Hosts(hosts []*assets.Host) string { + b := NewBlock() + + for i, host := range hosts { + if i > 0 { + b.Newline() + } + b.SeparatorWithLabel(fmt.Sprintf("Host #%d", i+1)) + b.Write(renderHostShort(host)) + } + + return b.String() +} + +// renderHostShort renders a single host. +func renderHostShort(host *assets.Host) string { + var out strings.Builder + + // Header lines + out.WriteString(hostHeader(host)) + + // ASN / WHOIS / Location + out.WriteString(hostMetadata(host)) + + // Labels + out.WriteString(hostLabels(host.Labels)) + + // Reverse / Forward DNS + out.WriteString(hostDNS(host)) + + // Operating system + if os := host.OperatingSystem; os != nil { + out.WriteString(fmt.Sprintf("\nOperating System: %s\n", formatAttribute(*os))) + } + + // Services + out.WriteString(renderServices(host.Services)) + + return out.String() +} + +// hostHeader renders IP and platform link. +func hostHeader(host *assets.Host) string { + ip := Val(host.IP, "") + link := censyscopy.CensysHostLookupLink(ip) + + line := NewLine(WithLabelStyle(styles.GlobalStyles.Signature)) + + if formatter.StdoutIsTTY() { + // Make IP an underlined clickable link + underlinedIP := styles.GlobalStyles.Signature.Underline(true).Render(ip) + line.Write("IP", link.Render(underlinedIP)) + } else { + // Plain IP with separate Platform URL line + line.Write("IP", ip) + line.Write("Platform URL", link.String()) + } + + return line.String() +} + +// hostMetadata renders ASN, WHOIS org, and location. +func hostMetadata(host *assets.Host) string { + var out strings.Builder + + // ASN + if as := host.AutonomousSystem; as != nil { + asn := Val(as.Asn, 0) + asName := strings.ToUpper(Val(as.Name, "")) + if asn != 0 || asName != "" { + line := NewLine() + line.Write("ASN", fmt.Sprintf("%d (%s)", asn, asName)) + out.WriteString(line.String()) + } + } + + // WHOIS Org + if host.Whois != nil && host.Whois.Organization != nil { + org := Val(host.Whois.Organization.Name, "") + if org != "" { + line := NewLine() + line.Write("WHOIS Org", org) + out.WriteString(line.String()) + } + } + + // Location + if host.Location != nil { + loc := host.Location + var city, region, country, code string + city = Val(loc.City, "") + region = Val(loc.Province, "") + country = Val(loc.Country, "") + code = Val(loc.Continent, "") + + // If continent looks like a short code (e.g., US), use that; otherwise use country code if present. + countryDisplay := country + if code != "" { + countryDisplay = fmt.Sprintf("%s (%s)", countryDisplay, code) + } + + parts := make([]string, 0, 3) + if city != "" { + parts = append(parts, city) + } + if region != "" { + parts = append(parts, region) + } + if countryDisplay != "" { + parts = append(parts, countryDisplay) + } + + if len(parts) > 0 { + line := NewLine() + line.Write("Location", strings.Join(parts, ", ")) + out.WriteString(line.String()) + } + if loc.Coordinates != nil { + lat := Val(loc.Coordinates.Latitude, 0.0) + lon := Val(loc.Coordinates.Longitude, 0.0) + line := NewLine() + line.Write("Coordinates", fmt.Sprintf("%.4f°, %.4f°", lat, lon)) + out.WriteString(line.String()) + } + } + + return out.String() +} + +// hostDNS renders reverse and forward DNS sections (limited to 10 entries with ellipsis). +func hostDNS(host *assets.Host) string { + if host.DNS == nil { + return "" + } + + var out strings.Builder + + // Reverse DNS + if host.DNS.ReverseDNS != nil && len(host.DNS.ReverseDNS.Names) > 0 { + names := host.DNS.ReverseDNS.Names + out.WriteString(fmt.Sprintf("\nReverse DNS (%d):\n", len(names))) + out.WriteString(renderListWithLimit(names, 10)) + } + + // Forward DNS (keys of map) - sorted for deterministic output + if len(host.DNS.ForwardDNS) > 0 { + keys := make([]string, 0, len(host.DNS.ForwardDNS)) + for k := range host.DNS.ForwardDNS { + keys = append(keys, k) + } + // Sort for stable output + sort.Strings(keys) + + out.WriteString(fmt.Sprintf("\nForward DNS (%d):\n", len(keys))) + out.WriteString(renderListWithLimit(keys, 10)) + } + + return out.String() +} + +// hostLabels renders the labels section +func hostLabels(labels []components.Label) string { + if len(labels) == 0 { + return "" + } + + values := make([]string, 0, len(labels)) + for _, label := range labels { + if label.Value != nil { + values = append(values, *label.Value) + } + } + + if len(values) == 0 { + return "" + } + + line := NewLine() + line.Write("Labels", strings.Join(values, ", ")) + return line.String() +} + +// renderListWithLimit renders up to limit items, appending "..." if more remain. +func renderListWithLimit(items []string, limit int) string { + var out strings.Builder + for i, v := range items { + if i >= limit { + out.WriteString(" ...\n") + break + } + out.WriteString(fmt.Sprintf(" - %s\n", styles.GlobalStyles.Tertiary.Render(v))) + } + return out.String() +} + +// renderServices renders services section. +func renderServices(services []components.Service) string { + var out strings.Builder + + count := len(services) + out.WriteString(fmt.Sprintf("\nServices (%d):\n", count)) + if count == 0 { + return out.String() + } + + b := NewBlock() + for _, svc := range services { + proto := strings.ToUpper(Val(svc.Protocol, "")) + if proto == "" { + proto = "UNKNOWN" + } + port := Val(svc.Port, 0) + transport := string(Val(svc.TransportProtocol, components.ServiceTransportProtocol(""))) + title := fmt.Sprintf("%s %d/%s", styles.GlobalStyles.Signature.Render(proto), port, transport) + b.Item(title) + + // Software (limit to first 5 to avoid extremely long lists) + if len(svc.Software) > 0 { + softwareStr := renderServiceComponents(svc.Software, 5) + if softwareStr != "" { + b.ItemField("Software", softwareStr) + } + } + + // Hardware (limit to first 5 to avoid extremely long lists) + if len(svc.Hardware) > 0 { + hardwareStr := renderServiceComponents(svc.Hardware, 5) + if hardwareStr != "" { + b.ItemField("Hardware", hardwareStr) + } + } + + // TLS cert summary from service cert + if svc.Cert != nil && svc.Cert.Parsed != nil { + subj := Val(svc.Cert.Parsed.SubjectDn, "") + iss := Val(svc.Cert.Parsed.IssuerDn, "") + if subj != "" || iss != "" { + var certParts []string + if subj != "" { + certParts = append(certParts, fmt.Sprintf("Subject DN: %s", subj)) + } + if iss != "" { + certParts = append(certParts, fmt.Sprintf("Issuer DN: %s", iss)) + } + b.ItemField("Cert", strings.Join(certParts, ", ")) + } + } + + b.EndItem() + } + + out.WriteString(b.String()) + return out.String() +} + +// renderServiceComponents renders software/hardware components with a limit +func renderServiceComponents(attrs []components.Attribute, limit int) string { + parts := make([]string, 0, len(attrs)) + + for i, attr := range attrs { + if i >= limit { + parts = append(parts, "...") + break + } + formatted := formatAttribute(attr) + if formatted != "" { + parts = append(parts, formatted) + } + } + + return strings.Join(parts, ", ") +} diff --git a/internal/pkg/formatter/short/hosts_test.go b/internal/pkg/formatter/short/hosts_test.go new file mode 100644 index 0000000..e70ad0c --- /dev/null +++ b/internal/pkg/formatter/short/hosts_test.go @@ -0,0 +1,175 @@ +package short + +import ( + "strings" + "testing" + + "github.com/censys/censys-sdk-go/models/components" + "github.com/stretchr/testify/require" + + "github.com/censys/cencli/internal/pkg/domain/assets" +) + +func TestHosts(t *testing.T) { + testCases := []struct { + name string + host *assets.Host + expectedOutput string + }{ + { + name: "rich host", + host: &assets.Host{ + Host: components.Host{ + IP: strPtr("1.1.1.1"), + AutonomousSystem: &components.Routing{ + Asn: intPtr(13335), + Name: strPtr("Cloudflare"), + }, + Whois: &components.Whois{ + Organization: &components.Organization{ + Name: strPtr("Cloudflare"), + }, + }, + Location: &components.Location{ + City: strPtr("Mountain View"), + Province: strPtr("California"), + Country: strPtr("United States"), + Continent: strPtr("US"), + Coordinates: &components.Coordinates{ + Latitude: floatPtr(37.4056), + Longitude: floatPtr(-122.0775), + }, + }, + DNS: &components.HostDNS{ + ReverseDNS: &components.HostDNSReverseResolution{ + Names: []string{ + "one.one.one.one", + "1dot1dot1dot1.cloudflare-dns.com", + }, + }, + ForwardDNS: map[string]components.HostDNSForwardResolution{ + "example.com": {}, + "example.org": {}, + "example.net": {}, + "a1.com": {}, + "a2.com": {}, + "a3.com": {}, + "a4.com": {}, + "a5.com": {}, + "a6.com": {}, + "a7.com": {}, + "a8.com": {}, + "a9.com": {}, + }, + }, + OperatingSystem: &components.Attribute{ + Vendor: strPtr("linux"), + Product: strPtr("ubuntu"), + Version: strPtr("22.04"), + }, + Services: []components.Service{ + { + Protocol: strPtr("DNS"), + Port: intPtr(53), + TransportProtocol: components.ServiceTransportProtocolUDP.ToPointer(), + }, + { + Protocol: strPtr("UNKNOWN"), + Port: intPtr(443), + TransportProtocol: components.ServiceTransportProtocolQuic.ToPointer(), + }, + { + Protocol: strPtr("HTTP"), + Port: intPtr(443), + TransportProtocol: components.ServiceTransportProtocolTCP.ToPointer(), + Cert: &components.Certificate{ + Parsed: &components.CertificateParsed{ + SubjectDn: strPtr("CN=dns.google"), + IssuerDn: strPtr("C=US, O=Google Trust Services, CN=WR2"), + }, + }, + }, + { + Protocol: strPtr("UNKNOWN"), + Port: intPtr(853), + TransportProtocol: components.ServiceTransportProtocolTCP.ToPointer(), + Cert: &components.Certificate{ + Parsed: &components.CertificateParsed{ + SubjectDn: strPtr("CN=dns.google"), + IssuerDn: strPtr("C=US, O=Google Trust Services, CN=WR2"), + }, + }, + }, + }, + }, + }, + expectedOutput: ` +------------------------- Host #1 -------------------------- +IP: 1.1.1.1 +Platform URL: https://platform.censys.io/hosts/1.1.1.1 +ASN: 13335 (CLOUDFLARE) +WHOIS Org: Cloudflare +Location: Mountain View, California, United States (US) +Coordinates: 37.4056°, -122.0775° + +Reverse DNS (2): + - one.one.one.one + - 1dot1dot1dot1.cloudflare-dns.com + +Forward DNS (12): + - a1.com + - a2.com + - a3.com + - a4.com + - a5.com + - a6.com + - a7.com + - a8.com + - a9.com + - example.com + ... + +Operating System: Linux Ubuntu 22.04 + +Services (4): + - DNS 53/udp + + - UNKNOWN 443/quic + + - HTTP 443/tcp + Cert: Subject DN: CN=dns.google, Issuer DN: C=US, O=Google Trust Services, CN=WR2 + + - UNKNOWN 853/tcp + Cert: Subject DN: CN=dns.google, Issuer DN: C=US, O=Google Trust Services, CN=WR2 +`, + }, + { + name: "minimal host", + host: &assets.Host{ + Host: components.Host{ + IP: strPtr("2.2.2.2"), + }, + }, + expectedOutput: ` +------------------------- Host #1 -------------------------- +IP: 2.2.2.2 +Platform URL: https://platform.censys.io/hosts/2.2.2.2 + +Services (0): +`, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + actual := Hosts([]*assets.Host{tt.host}) + actualTrimmed := strings.TrimSpace(actual) + expectedTrimmed := strings.TrimSpace(tt.expectedOutput) + require.Equal(t, expectedTrimmed, actualTrimmed) + }) + } +} + +func strPtr(s string) *string { return &s } +func intPtr(i int) *int { return &i } +func floatPtr(f float64) *float64 { return &f } diff --git a/internal/pkg/formatter/short/search.go b/internal/pkg/formatter/short/search.go new file mode 100644 index 0000000..aeaf932 --- /dev/null +++ b/internal/pkg/formatter/short/search.go @@ -0,0 +1,55 @@ +package short + +import ( + "fmt" + + "github.com/censys/cencli/internal/pkg/domain/assets" +) + +// FIXME: make this perfect + +// SearchHits renders search hits in short format. +// Renders hits in the order received, adding numbered separators with asset type. +func SearchHits(hits []assets.Asset) string { + if len(hits) == 0 { + return "" + } + + b := NewBlock() + + for i, hit := range hits { + if i > 0 { + b.Newline() + } + + // Add separator with hit number and type + assetTypeName := formatAssetTypeName(hit.AssetType()) + b.SeparatorWithLabel(fmt.Sprintf("Hit #%d (%s)", i+1, assetTypeName)) + + // Render the hit based on its type (without their own separators) + switch h := hit.(type) { + case *assets.Host: + b.Write(renderHostShort(h)) + case *assets.Certificate: + b.Write(renderCertificateShort(h)) + case *assets.WebProperty: + b.Write(renderWebPropertyShort(h)) + } + } + + return b.String() +} + +// formatAssetTypeName returns a display name for the asset type +func formatAssetTypeName(assetType assets.AssetType) string { + switch assetType { + case assets.AssetTypeHost: + return "host" + case assets.AssetTypeCertificate: + return "certificate" + case assets.AssetTypeWebProperty: + return "web property" + default: + return string(assetType) + } +} diff --git a/internal/pkg/formatter/short/search_test.go b/internal/pkg/formatter/short/search_test.go new file mode 100644 index 0000000..a0684f6 --- /dev/null +++ b/internal/pkg/formatter/short/search_test.go @@ -0,0 +1,106 @@ +package short + +import ( + "strings" + "testing" + + "github.com/censys/censys-sdk-go/models/components" + "github.com/stretchr/testify/require" + + "github.com/censys/cencli/internal/pkg/domain/assets" +) + +func TestSearchHits(t *testing.T) { + testCases := []struct { + name string + hits []assets.Asset + expectedOutput string + }{ + { + name: "mixed asset types in order", + hits: []assets.Asset{ + // Two hosts + &assets.Host{ + Host: components.Host{ + IP: strPtr("1.1.1.1"), + }, + }, + &assets.Host{ + Host: components.Host{ + IP: strPtr("2.2.2.2"), + }, + }, + // One certificate + &assets.Certificate{ + Certificate: components.Certificate{ + FingerprintSha256: strPtr("abc123"), + Parsed: &components.CertificateParsed{ + Subject: &components.DistinguishedName{ + CommonName: []string{"test.com"}, + }, + }, + }, + }, + // One web property + &assets.WebProperty{ + Webproperty: components.Webproperty{ + Hostname: strPtr("example.com"), + Port: intPtr(443), + }, + }, + }, + expectedOutput: ` +---------------------- Hit #1 (host) ----------------------- +IP: 1.1.1.1 +Platform URL: https://platform.censys.io/hosts/1.1.1.1 + +Services (0): + +---------------------- Hit #2 (host) ----------------------- +IP: 2.2.2.2 +Platform URL: https://platform.censys.io/hosts/2.2.2.2 + +Services (0): + +------------------- Hit #3 (certificate) ------------------- +Certificate: abc123 +Platform URL: https://platform.censys.io/certificates/abc123 + +------------------ Hit #4 (web property) ------------------- +Hostname: example.com:443 +Platform URL: https://platform.censys.io/web/example.com:443 +`, + }, + { + name: "single asset type", + hits: []assets.Asset{ + &assets.Host{ + Host: components.Host{ + IP: strPtr("3.3.3.3"), + }, + }, + }, + expectedOutput: ` +---------------------- Hit #1 (host) ----------------------- +IP: 3.3.3.3 +Platform URL: https://platform.censys.io/hosts/3.3.3.3 + +Services (0): +`, + }, + { + name: "empty hits", + hits: []assets.Asset{}, + expectedOutput: ``, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + actual := SearchHits(tt.hits) + actualTrimmed := strings.TrimSpace(actual) + expectedTrimmed := strings.TrimSpace(tt.expectedOutput) + require.Equal(t, expectedTrimmed, actualTrimmed) + }) + } +} diff --git a/internal/pkg/formatter/short/short.go b/internal/pkg/formatter/short/short.go new file mode 100644 index 0000000..1abacaa --- /dev/null +++ b/internal/pkg/formatter/short/short.go @@ -0,0 +1,253 @@ +package short + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + + "github.com/censys/cencli/internal/pkg/styles" +) + +// Val returns the value of a pointer, or the zero value if the pointer is nil +func Val[T any](p *T, zero T) T { + if p == nil { + return zero + } + return *p +} + +// Separator is a standard separator line for short output +func Separator() string { + return "------------------------------------------------------------\n" +} + +// SeparatorWithLabel returns a separator with a centered label (e.g., "--- Host #1 ---") +func SeparatorWithLabel(label string) string { + const sepWidth = 60 + const minDashes = 3 + + if label == "" { + return Separator() + } + + labelLen := len(label) + totalDashes := sepWidth - labelLen - 2 // -2 for spaces around label + + if totalDashes < minDashes*2 { + return Separator() + } + + leftDashes := totalDashes / 2 + rightDashes := totalDashes - leftDashes + + return fmt.Sprintf("%s %s %s\n", + strings.Repeat("-", leftDashes), + label, + strings.Repeat("-", rightDashes)) +} + +type Block struct { + s strings.Builder + keyStyle lipgloss.Style + valueStyle lipgloss.Style + indent int + // item mode for list items with hyphen prefix + itemMode bool // we're inside an item + itemIndent int // indent for fields inside an item +} + +type BlockOption func(*Block) + +func WithKeyStyle(style lipgloss.Style) BlockOption { + return func(b *Block) { + b.keyStyle = style + } +} + +func WithValueStyle(style lipgloss.Style) BlockOption { + return func(b *Block) { + b.valueStyle = style + } +} + +func WithIndent(n int) BlockOption { + return func(b *Block) { + b.indent = n + } +} + +func NewBlock(opts ...BlockOption) *Block { + b := &Block{ + keyStyle: styles.GlobalStyles.Primary, + valueStyle: styles.GlobalStyles.Tertiary, + indent: 2, + itemIndent: 6, // default: indent + 4 + } + for _, opt := range opts { + opt(b) + } + // Update itemIndent based on final indent value + b.itemIndent = b.indent + 4 + return b +} + +func (b *Block) String() string { + return b.s.String() +} + +func (b *Block) Write(content string) { + b.s.WriteString(content) +} + +// WriteLine writes content followed by a newline to the block. +func (b *Block) WriteLine(content string) { + b.s.WriteString(content) + b.s.WriteString("\n") +} + +// Title writes a section title to the block. +func (b *Block) Title(t string) { + b.s.WriteString(b.keyStyle.Render(t)) + b.s.WriteString("\n") +} + +// Field writes a key-value pair to the block with proper indentation and styling. +// Automatically skips empty values. +func (b *Block) Field(k, v string) { + if strings.TrimSpace(v) == "" { + return + } + b.s.WriteString(strings.Repeat(" ", b.indent)) + b.s.WriteString(b.keyStyle.Render(k)) + b.s.WriteString(": ") + b.s.WriteString(b.valueStyle.Render(v)) + b.s.WriteString("\n") +} + +// Fieldf writes a formatted key-value pair to the block with proper indentation and styling. +// Automatically skips empty values. +func (b *Block) Fieldf(k, format string, args ...any) { + b.Field(k, fmt.Sprintf(format, args...)) +} + +// Fields writes multiple key-value pairs to the block. +func (b *Block) Fields(m map[string]string) { + for k, v := range m { + b.Field(k, v) + } +} + +// Separator writes a standard separator line to the block. +func (b *Block) Separator() { + b.s.WriteString(Separator()) +} + +// SeparatorWithLabel writes a separator with a label to the block. +func (b *Block) SeparatorWithLabel(label string) { + b.s.WriteString(SeparatorWithLabel(label)) +} + +// Newline writes a blank line to the block. +func (b *Block) Newline() { + b.s.WriteString("\n") +} + +// Item starts a new list item with a hyphen prefix. +// The title can be raw text or already-styled text. +func (b *Block) Item(title string) { + b.itemMode = true + + b.s.WriteString(strings.Repeat(" ", b.indent)) + b.s.WriteString("- ") + b.s.WriteString(title) // raw styled string from caller + b.s.WriteString("\n") +} + +// ItemField writes a key-value pair indented under the current item. +// Automatically skips empty values. +func (b *Block) ItemField(k, v string) { + if !b.itemMode || strings.TrimSpace(v) == "" { + return + } + + b.s.WriteString(strings.Repeat(" ", b.itemIndent)) + b.s.WriteString(b.keyStyle.Render(k)) + b.s.WriteString(": ") + b.s.WriteString(b.valueStyle.Render(v)) + b.s.WriteString("\n") +} + +// EndItem closes the current item and adds spacing. +func (b *Block) EndItem() { + if !b.itemMode { + return + } + b.s.WriteString("\n") + b.itemMode = false +} + +type Line struct { + s strings.Builder + labelStyle lipgloss.Style + valueStyle lipgloss.Style +} + +type LineOption func(*Line) + +func WithLabelStyle(style lipgloss.Style) LineOption { + return func(l *Line) { + l.labelStyle = style + } +} + +func WithLineValueStyle(style lipgloss.Style) LineOption { + return func(l *Line) { + l.valueStyle = style + } +} + +func NewLine(opts ...LineOption) *Line { + l := &Line{ + labelStyle: styles.GlobalStyles.Primary, + valueStyle: styles.GlobalStyles.Tertiary, + } + for _, opt := range opts { + opt(l) + } + return l +} + +func (l *Line) String() string { + return l.s.String() +} + +func (l *Line) Write(label, value string) { + l.s.WriteString(l.labelStyle.Render(label)) + if value != "" { + l.s.WriteString(": ") + l.s.WriteString(l.valueStyle.Render(value)) + } + l.s.WriteString("\n") +} + +// WriteInline writes a label and value without a trailing newline. +func (l *Line) WriteInline(label, value string) { + l.s.WriteString(l.labelStyle.Render(label)) + if value != "" { + l.s.WriteString(": ") + l.s.WriteString(l.valueStyle.Render(value)) + } +} + +func (l *Line) Newline() { + l.s.WriteString("\n") +} + +// Section is a convenience function that creates a block with a title and applies a function to it. +func Section(title string, f func(*Block)) string { + b := NewBlock() + b.Title(title) + f(b) + return b.String() +} diff --git a/internal/pkg/formatter/short/utils.go b/internal/pkg/formatter/short/utils.go new file mode 100644 index 0000000..a0268d3 --- /dev/null +++ b/internal/pkg/formatter/short/utils.go @@ -0,0 +1,26 @@ +package short + +import ( + "fmt" + "strings" +) + +// FormatNumber formats an int64 with comma separators. +func FormatNumber(n int64) string { + if n < 0 { + return "-" + FormatNumber(-n) + } + if n < 1000 { + return fmt.Sprintf("%d", n) + } + + s := fmt.Sprintf("%d", n) + var result strings.Builder + for i, c := range s { + if i > 0 && (len(s)-i)%3 == 0 { + result.WriteRune(',') + } + result.WriteRune(c) + } + return result.String() +} diff --git a/internal/pkg/formatter/short/webproperties.go b/internal/pkg/formatter/short/webproperties.go new file mode 100644 index 0000000..73fa989 --- /dev/null +++ b/internal/pkg/formatter/short/webproperties.go @@ -0,0 +1,285 @@ +package short + +import ( + "fmt" + "strings" + + "github.com/censys/censys-sdk-go/models/components" + + "github.com/censys/cencli/internal/pkg/censyscopy" + "github.com/censys/cencli/internal/pkg/domain/assets" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/pkg/styles" +) + +// WebProperties renders web properties in short format +func WebProperties(webProperties []*assets.WebProperty) string { + b := NewBlock() + + for i, wp := range webProperties { + if i > 0 { + b.Newline() + } + b.SeparatorWithLabel(fmt.Sprintf("Web Property #%d", i+1)) + b.Write(renderWebPropertyShort(wp)) + } + + return b.String() +} + +// renderWebPropertyShort renders a single web property in short format +func renderWebPropertyShort(wp *assets.WebProperty) string { + var out strings.Builder + + // Header + out.WriteString(wpHeader(wp)) + out.WriteString("\n") + + // Certificate section (if present) + if wp.Cert != nil { + out.WriteString(wpCertificate(wp.Cert)) + } + + // Labels section (if present) + if len(wp.Labels) > 0 { + out.WriteString(wpLabels(wp.Labels)) + } + + // Software section (if present) + if len(wp.Software) > 0 { + out.WriteString(wpComponents("Software", wp.Software)) + } + + // Hardware section (if present) + if len(wp.Hardware) > 0 { + out.WriteString(wpComponents("Hardware", wp.Hardware)) + } + + // Endpoints section + if len(wp.Endpoints) > 0 { + out.WriteString(wpEndpoints(wp.Endpoints)) + } + + return out.String() +} + +// wpHeader renders the header section (hostname:port and platform URL) +func wpHeader(wp *assets.WebProperty) string { + hostname := Val(wp.Hostname, "") + port := Val(wp.Port, 0) + hostPort := fmt.Sprintf("%s:%d", hostname, port) + link := censyscopy.CensysWebPropertyLookupLink(hostPort) + + line := NewLine( + WithLabelStyle(styles.GlobalStyles.Signature), + ) + + if formatter.StdoutIsTTY() { + // Make hostname:port an underlined clickable link + underlinedHostPort := styles.GlobalStyles.Signature.Underline(true).Render(hostPort) + line.Write("Hostname", link.Render(underlinedHostPort)) + } else { + // Plain hostname:port with separate Platform URL line + line.Write("Hostname", hostPort) + line.Write("Platform URL", link.String()) + } + + return line.String() +} + +// wpCertificate renders the certificate section +func wpCertificate(cert *components.Certificate) string { + var out strings.Builder + + b := NewBlock() + b.Title("Certificate:") + + b.Field("Fingerprint (SHA256)", Val(cert.FingerprintSha256, "")) + + if cert.Parsed != nil { + b.Field("Issuer", Val(cert.Parsed.IssuerDn, "")) + b.Field("Subject", Val(cert.Parsed.SubjectDn, "")) + } + + if len(cert.Names) > 0 { + b.Field("Names", strings.Join(cert.Names, ", ")) + } + + out.WriteString(b.String()) + + return out.String() +} + +// wpLabels renders the labels section +func wpLabels(labels []components.Label) string { + labelValues := renderLabels(labels, ", ") + if labelValues == "" { + return "" + } + + line := NewLine() + line.Write("Labels", labelValues) + return line.String() +} + +// wpComponents renders software or hardware components +func wpComponents(name string, components []components.Attribute) string { + rendered := renderComponentList(components) + if rendered == "" { + return "" + } + + line := NewLine() + line.Write(name, rendered) + return line.String() +} + +// wpEndpoints renders the endpoints section +func wpEndpoints(endpoints []components.EndpointScanState) string { + b := NewBlock() + + // Header line + line := NewLine() + line.Write("Endpoints", fmt.Sprintf("(%d)", len(endpoints))) + b.Write(line.String()) + + // Render each endpoint as a list item + for _, endpoint := range endpoints { + path := Val(endpoint.Path, "") + endpointType := Val(endpoint.EndpointType, "") + + // Start item with styled title + title := fmt.Sprintf("%s (%s)", + styles.GlobalStyles.Signature.Render(path), + styles.GlobalStyles.Tertiary.Render(endpointType)) + b.Item(title) + + // Add fields for this endpoint + b.ItemField("IP", Val(endpoint.IP, "")) + b.ItemField("Type", Val(endpoint.EndpointType, "")) + + // HTTP details + if endpoint.HTTP != nil { + http := endpoint.HTTP + + // Status with optional redirect + if http.StatusCode != nil { + statusLine := fmt.Sprintf("%d", *http.StatusCode) + if http.StatusReason != nil { + statusLine += " " + *http.StatusReason + } + + // Check for Location header (redirect) + if http.Headers != nil { + if location, ok := http.Headers["Location"]; ok && len(location.Headers) > 0 { + statusLine += " → " + location.Headers[0] + } + } + + b.ItemField("Status", statusLine) + } + + // Server header + if http.Headers != nil { + if server, ok := http.Headers["Server"]; ok && len(server.Headers) > 0 { + b.ItemField("Server", server.Headers[0]) + } + } + + // HTML Title + b.ItemField("HTML Title", Val(http.HTMLTitle, "")) + } + + // Scan time + b.ItemField("Scan Time", Val(endpoint.ScanTime, "")) + + b.EndItem() + } + + return b.String() +} + +// renderComponentList renders a list of software/hardware attributes +// Mimics the legacy render_components helper logic. +func renderComponentList(attrs []components.Attribute) string { + parts := make([]string, 0, len(attrs)) + + for _, attr := range attrs { + formatted := formatAttribute(attr) + if formatted != "" { + parts = append(parts, formatted) + } + } + + return strings.Join(parts, ", ") +} + +// formatAttribute formats a single attribute (vendor, product, version). +// Mimics the legacy render_components helper logic. +func formatAttribute(attr components.Attribute) string { + vendor := Val(attr.Vendor, "") + product := Val(attr.Product, "") + version := Val(attr.Version, "") + + // Skip if no vendor or product + if vendor == "" && product == "" { + return "" + } + + var parts []string + + // Determine what to display + switch { + case vendor != "" && product != "": + // If product starts with vendor, only show product + if strings.HasPrefix(product, vendor) { + parts = append(parts, formatComponentName(product)) + } else { + parts = append(parts, formatComponentName(vendor)) + parts = append(parts, formatComponentName(product)) + } + case vendor != "": + parts = append(parts, formatComponentName(vendor)) + case product != "": + parts = append(parts, formatComponentName(product)) + } + + // Add version at the end + if version != "" { + parts = append(parts, version) + } + + return strings.Join(parts, " ") +} + +// formatComponentName formats a component name by replacing underscores with spaces +// and capitalizing the first letter of each word. +func formatComponentName(name string) string { + if name == "" { + return "" + } + + // Replace underscores with spaces + name = strings.ReplaceAll(name, "_", " ") + + // Split into words and capitalize each + words := strings.Fields(name) + for i, word := range words { + if len(word) > 0 { + words[i] = strings.ToUpper(word[:1]) + word[1:] + } + } + + return strings.Join(words, " ") +} + +// renderLabels extracts label values and joins them with a delimiter. +func renderLabels(labels []components.Label, delimiter string) string { + values := make([]string, 0, len(labels)) + for _, label := range labels { + if label.Value != nil { + values = append(values, *label.Value) + } + } + return strings.Join(values, delimiter) +} diff --git a/internal/pkg/formatter/short/webproperties_test.go b/internal/pkg/formatter/short/webproperties_test.go new file mode 100644 index 0000000..24fcc96 --- /dev/null +++ b/internal/pkg/formatter/short/webproperties_test.go @@ -0,0 +1,108 @@ +package short + +import ( + "strings" + "testing" + + "github.com/censys/censys-sdk-go/models/components" + "github.com/stretchr/testify/require" + + "github.com/censys/cencli/internal/pkg/domain/assets" +) + +func TestWebProperties(t *testing.T) { + testCases := []struct { + webProperty *assets.WebProperty + name string + expectedOutput string + }{ + { + name: "web property", + webProperty: &assets.WebProperty{ + Webproperty: components.Webproperty{ + Hostname: strPtr("example.com"), + Port: intPtr(443), + Cert: &components.Certificate{ + FingerprintSha256: strPtr("ABC123"), + Names: []string{"example.com", "www.example.com"}, + Parsed: &components.CertificateParsed{ + IssuerDn: strPtr("CN=Test Issuer"), + SubjectDn: strPtr("CN=example.com"), + }, + }, + Labels: []components.Label{ + {Value: strPtr("production")}, + {Value: strPtr("external")}, + }, + Software: []components.Attribute{ + { + Vendor: strPtr("nginx"), + Product: strPtr("nginx_webserver"), + Version: strPtr("1.2.3"), + }, + }, + Hardware: []components.Attribute{ + { + Vendor: strPtr("supermicro"), + Product: strPtr("x10dri"), + Version: strPtr("rev2"), + }, + }, + Endpoints: []components.EndpointScanState{ + { + Path: strPtr("/"), + EndpointType: strPtr("HTTP"), + IP: strPtr("203.0.113.10"), + ScanTime: strPtr("2025-11-14T02:49:38Z"), + + HTTP: &components.HTTP{ + StatusCode: intPtr(403), + StatusReason: strPtr("Forbidden"), + HTMLTitle: strPtr("Just a moment..."), + Headers: map[string]components.HTTPRepeatedHeaders{ + "Server": { + Headers: []string{"cloudflare"}, + }, + "Location": { + Headers: []string{"https://example.com/redirect"}, + }, + }, + }, + }, + }, + }, + }, + expectedOutput: ` +--------------------- Web Property #1 ---------------------- +Hostname: example.com:443 +Platform URL: https://platform.censys.io/web/example.com:443 + +Certificate: + Fingerprint (SHA256): ABC123 + Issuer: CN=Test Issuer + Subject: CN=example.com + Names: example.com, www.example.com +Labels: production, external +Software: Nginx Webserver 1.2.3 +Hardware: Supermicro X10dri rev2 +Endpoints: (1) + - / (HTTP) + IP: 203.0.113.10 + Type: HTTP + Status: 403 Forbidden → https://example.com/redirect + Server: cloudflare + HTML Title: Just a moment... + Scan Time: 2025-11-14T02:49:38Z + `, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + actual := WebProperties([]*assets.WebProperty{tt.webProperty}) + actualTrimmed := strings.TrimSpace(actual) + expectedTrimmed := strings.TrimSpace(tt.expectedOutput) + require.Equal(t, expectedTrimmed, actualTrimmed) + }) + } +} diff --git a/internal/pkg/formatter/yaml.go b/internal/pkg/formatter/yaml.go index 772cbcb..9823fa1 100644 --- a/internal/pkg/formatter/yaml.go +++ b/internal/pkg/formatter/yaml.go @@ -1,6 +1,7 @@ package formatter import ( + "encoding/json" "fmt" "regexp" "strings" @@ -56,7 +57,19 @@ func newYamlSerializer() *yamlSerializer { } func (s *yamlSerializer) serialize(v any, colored bool) (string, error) { - b, err := yaml.Marshal(v) + // there isn't really a good way to drop null values from YAML, + // so we pass it through the JSON marshaller first + jsonBytes, err := json.Marshal(v) + if err != nil { + return "", err + } + + var cleaned any + if err := json.Unmarshal(jsonBytes, &cleaned); err != nil { + return "", err + } + + b, err := yaml.Marshal(cleaned) if err != nil { return "", err } diff --git a/internal/pkg/formatter/yaml_test.go b/internal/pkg/formatter/yaml_test.go index e063d2c..89f13fb 100644 --- a/internal/pkg/formatter/yaml_test.go +++ b/internal/pkg/formatter/yaml_test.go @@ -8,6 +8,22 @@ import ( "github.com/stretchr/testify/require" ) +// dummyStruct is a test struct with omitempty tags to verify null values are dropped +type dummyStruct struct { + Name string `json:"name"` + Age int `json:"age"` + Email *string `json:"email,omitempty"` + Phone *string `json:"phone,omitempty"` + Active bool `json:"active"` + Score *int `json:"score,omitempty"` + Nested *nestedStruct `json:"nested,omitempty"` +} + +type nestedStruct struct { + Value string `json:"value"` + Optional *string `json:"optional,omitempty"` +} + func TestPrintYAML_TableDriven(t *testing.T) { tests := []struct { name string @@ -97,3 +113,104 @@ negative: -10 }) } } + +func TestYamlSerializer_serialize_DropsNullValuesWithOmitempty(t *testing.T) { + tests := []struct { + name string + input any + colored bool + expected string + }{ + { + name: "drops null pointer fields with omitempty", + input: dummyStruct{ + Name: "Alice", + Age: 30, + Email: nil, // Should be dropped + Phone: nil, // Should be dropped + Active: true, + Score: nil, // Should be dropped + }, + colored: false, + expected: `active: true +age: 30 +name: Alice +`, + }, + { + name: "keeps non-null pointer fields with omitempty", + input: dummyStruct{ + Name: "Bob", + Age: 25, + Email: stringPtr("bob@example.com"), + Phone: nil, // Should be dropped + Active: false, + Score: intPtr(100), + }, + colored: false, + expected: `active: false +age: 25 +email: bob@example.com +name: Bob +score: 100 +`, + }, + { + name: "drops nested struct with omitempty when nil", + input: dummyStruct{ + Name: "Charlie", + Age: 35, + Email: stringPtr("charlie@example.com"), + Active: false, + Nested: nil, // Should be dropped + }, + colored: false, + expected: `active: false +age: 35 +email: charlie@example.com +name: Charlie +`, + }, + { + name: "keeps nested struct with omitempty when not nil", + input: dummyStruct{ + Name: "David", + Age: 40, + Email: stringPtr("david@example.com"), + Active: false, + Nested: &nestedStruct{ + Value: "test", + Optional: nil, // Should be dropped from nested struct + }, + }, + colored: false, + expected: `active: false +age: 40 +email: david@example.com +name: David +nested: + value: test +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + serializer := newYamlSerializer() + result, err := serializer.serialize(tt.input, tt.colored) + require.NoError(t, err) + + assert.Equal(t, tt.expected, result) + // Verify that "null" doesn't appear in the output + assert.NotContains(t, result, "null") + }) + } +} + +func stringPtr(s string) *string { + return &s +} + +func intPtr(i int) *int { + return &i +} diff --git a/internal/pkg/log/log.go b/internal/pkg/log/log.go index c595fa6..d6e552a 100644 --- a/internal/pkg/log/log.go +++ b/internal/pkg/log/log.go @@ -16,6 +16,6 @@ func New(debug bool, out io.Writer) *slog.Logger { if debug { level = slog.LevelDebug } - handler := slog.NewJSONHandler(out, &slog.HandlerOptions{Level: level}) + handler := slog.NewTextHandler(out, &slog.HandlerOptions{Level: level}) return slog.New(handler) } diff --git a/internal/pkg/styles/censys.go b/internal/pkg/styles/censys.go new file mode 100644 index 0000000..d20eb55 --- /dev/null +++ b/internal/pkg/styles/censys.go @@ -0,0 +1,37 @@ +package styles + +type CensysColorScheme struct{} + +var _ ColorScheme = CensysColorScheme{} + +func (c CensysColorScheme) Signature() Color { + return Color{Light: "#ed9134", Dark: "#FFAD5B"} +} + +func (c CensysColorScheme) Primary() Color { + return Color{Light: "#000000", Dark: "#FBFAF6"} +} + +func (c CensysColorScheme) Secondary() Color { + return Color{Light: "#53b8b4", Dark: "#B6D5D4"} +} + +func (c CensysColorScheme) Tertiary() Color { + return Color{Light: "#387782", Dark: "#387782"} +} + +func (c CensysColorScheme) Info() Color { + return Color{Light: "#38a7ab", Dark: "#38a7ab"} +} + +func (c CensysColorScheme) Warning() Color { + return Color{Light: "#a39a5f", Dark: "#BCB480"} +} + +func (c CensysColorScheme) Danger() Color { + return Color{Light: "#dc322f", Dark: "#dc322f"} +} + +func (c CensysColorScheme) Comment() Color { + return Color{Light: "#808080", Dark: "#808080"} +} diff --git a/internal/pkg/styles/styles.go b/internal/pkg/styles/styles.go index 466ca1d..9551659 100644 --- a/internal/pkg/styles/styles.go +++ b/internal/pkg/styles/styles.go @@ -23,12 +23,67 @@ const ( forceColorEnvVar = "FORCE_COLOR" ) +type Color = lipgloss.AdaptiveColor + +// Color variables initialized from the active color scheme. +// These can be used throughout the codebase. +var ( + ColorOrange Color + ColorOffWhite Color + ColorSage Color + ColorTeal Color + ColorAqua Color + ColorGold Color + ColorRed Color + ColorGray Color + + // Additional basic colors + ColorBlack = Color{Light: "#000000", Dark: "#000000"} + ColorBlue = Color{Light: "#3BBFDE", Dark: "#3BBFDE"} +) + +// DefaultColorScheme returns the default Censys-themed color scheme. +// To use a custom color scheme, replace DefaultColorScheme() with your own implementation. +func DefaultColorScheme() ColorScheme { + return CensysColorScheme{} +} + func init() { lipgloss.SetColorProfile(termenv.TrueColor) if isTestEnvironment() { lipgloss.SetColorProfile(termenv.Ascii) } - GlobalStyles = DefaultStyles() + scheme := DefaultColorScheme() + GlobalStyles = NewStyles(scheme) + + ColorOrange = scheme.Signature() + ColorOffWhite = scheme.Primary() + ColorSage = scheme.Secondary() + ColorTeal = scheme.Tertiary() + ColorAqua = scheme.Info() + ColorGold = scheme.Warning() + ColorRed = scheme.Danger() + ColorGray = scheme.Comment() +} + +// ColorScheme defines the interface for providing colors to the CLI. +type ColorScheme interface { + // Signature color is used for branding and signatures + Signature() lipgloss.AdaptiveColor + // Primary color is used for main content + Primary() lipgloss.AdaptiveColor + // Secondary color is used for supporting content + Secondary() lipgloss.AdaptiveColor + // Tertiary color is used for additional accents + Tertiary() lipgloss.AdaptiveColor + // Info color is used for informational messages + Info() lipgloss.AdaptiveColor + // Warning color is used for warning messages + Warning() lipgloss.AdaptiveColor + // Danger color is used for error messages + Danger() lipgloss.AdaptiveColor + // Comment color is used for less important text + Comment() lipgloss.AdaptiveColor } // Styles describes the color palette and layout styles used by the CLI. @@ -46,56 +101,22 @@ type Styles struct { Indent8 lipgloss.Style } -// DefaultStyles returns the default Censys-themed style palette. -func DefaultStyles() *Styles { +// NewStyles creates a new Styles instance from a ColorScheme. +func NewStyles(scheme ColorScheme) *Styles { return &Styles{ - Signature: lipgloss.NewStyle().Foreground(ColorOrange), - Primary: lipgloss.NewStyle().Foreground(ColorOffWhite), - Secondary: lipgloss.NewStyle().Foreground(ColorSage), - Tertiary: lipgloss.NewStyle().Foreground(ColorTeal), - Info: lipgloss.NewStyle().Foreground(ColorAqua), - Warning: lipgloss.NewStyle().Foreground(ColorGold), - Danger: lipgloss.NewStyle().Foreground(ColorRed), - Comment: lipgloss.NewStyle().Foreground(ColorGray), + Signature: lipgloss.NewStyle().Foreground(scheme.Signature()), + Primary: lipgloss.NewStyle().Foreground(scheme.Primary()), + Secondary: lipgloss.NewStyle().Foreground(scheme.Secondary()), + Tertiary: lipgloss.NewStyle().Foreground(scheme.Tertiary()), + Info: lipgloss.NewStyle().Foreground(scheme.Info()), + Warning: lipgloss.NewStyle().Foreground(scheme.Warning()), + Danger: lipgloss.NewStyle().Foreground(scheme.Danger()), + Comment: lipgloss.NewStyle().Foreground(scheme.Comment()), Indent4: lipgloss.NewStyle().PaddingLeft(4), Indent8: lipgloss.NewStyle().PaddingLeft(8), } } -type Color = lipgloss.AdaptiveColor - -var ( - ColorOrange = Color{Light: censysOrangeDarker, Dark: censysOrange} - ColorOffWhite = Color{Light: black, Dark: censysOffWhite} - ColorSage = Color{Light: censysSageDarker, Dark: censysSage} - ColorTeal = Color{Light: censysTeal, Dark: censysTeal} - ColorAqua = Color{Light: censysAqua, Dark: censysAqua} - ColorGold = Color{Light: censysGoldDarker, Dark: censysGold} - - ColorRed = Color{Light: red, Dark: red} - ColorGray = Color{Light: gray, Dark: gray} - ColorBlack = Color{Light: black, Dark: black} - ColorBlue = Color{Light: blue, Dark: blue} -) - -const ( - red = "#dc322f" - blue = "#3BBFDE" - gray = "#808080" - black = "#000000" - - censysOrange = "#FFAD5B" - censysOrangeDarker = "#ed9134" - censysTeal = "#387782" - censysAqua = "#38a7ab" - censysGold = "#BCB480" - censysGoldDarker = "#a39a5f" - censysSage = "#B6D5D4" - censysSageDarker = "#53b8b4" - censysBlue = "#3BBFDE" - censysOffWhite = "#FBFAF6" -) - // ColorDisabled returns true if colored output should be disabled. // This function is not responsible for determining if output is a TTY. // Callers should perform their own check and call DisableStyles if needed. @@ -115,7 +136,13 @@ func ColorForced() bool { } func isTestEnvironment() bool { - return strings.HasSuffix(os.Args[0], ".test") + if strings.HasSuffix(os.Args[0], ".test") { + return true + } + if len(os.Args) > 1 && os.Args[1] == "-test.run" { + return true + } + return false } // DisableStyles disables ANSI styling by switching to an ASCII color profile. diff --git a/internal/pkg/tape/recorder.go b/internal/pkg/tape/recorder.go index 1820344..be2e828 100644 --- a/internal/pkg/tape/recorder.go +++ b/internal/pkg/tape/recorder.go @@ -31,9 +31,30 @@ func NewTapeRecorder( if err != nil { return nil, fmt.Errorf("%s not found in PATH: %w", vhsPath, err) } - err = ensureBinary(cliPath) - if err != nil { - return nil, fmt.Errorf("%s not found in PATH: %w", cliPath, err) + + // If cliPath is an absolute path, temporarily add its directory to PATH + // so VHS can execute it by name only + cliName := cliPath + if filepath.IsAbs(cliPath) { + // Check if the file exists + if _, err := os.Stat(cliPath); err != nil { + return nil, fmt.Errorf("CLI binary not found at %s: %w", cliPath, err) + } + // Add the directory to PATH + binDir := filepath.Dir(cliPath) + currentPath := os.Getenv("PATH") + newPath := binDir + string(filepath.ListSeparator) + currentPath + if err := os.Setenv("PATH", newPath); err != nil { + return nil, fmt.Errorf("failed to update PATH: %w", err) + } + // Use just the basename for the command + cliName = filepath.Base(cliPath) + } else { + // For relative paths or names, check if it's in PATH + err = ensureBinary(cliPath) + if err != nil { + return nil, fmt.Errorf("%s not found in PATH: %w", cliPath, err) + } } for key, value := range env { @@ -44,7 +65,7 @@ func NewTapeRecorder( } e := &Recorder{ vhsPath: vhsPath, - cliPath: cliPath, + cliPath: cliName, } return e, nil }