diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e482a00 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +permissions: + contents: read + +jobs: + validate: + name: Validate + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: go.mod + cache: true + + - name: Check formatting + run: | + if [ -n "$(gofmt -l .)" ]; then + echo "::error::Code is not formatted. Run 'gofmt -w .' to fix." + gofmt -l . + exit 1 + fi + + - name: Run go vet + run: go vet ./... + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 + with: + version: latest + + - name: Run tests + run: go test -race -cover -coverprofile=coverage.out ./... + + - name: Upload coverage + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: coverage + path: coverage.out + retention-days: 7 + + - name: Build + run: go build -o blob . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b8d97b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.claude/ +CLAUDE.md + +# Build output +/blob \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..ddd390f --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,230 @@ +# golangci-lint configuration v2 +# See: https://golangci-lint.run/usage/configuration/ +version: "2" + +# Linter configuration +linters: + # Start with the standard set and add more + default: standard + + # Enable additional linters for security and best practices + enable: + # Security linters + - gosec # Security scanner for Go code + - bodyclose # Ensure HTTP response bodies are closed + - noctx # Ensure HTTP requests use context + + # Error handling + - errcheck # Check for unchecked errors + - errname # Check error variable naming conventions + + # Code quality + - gocyclo # Cyclomatic complexity + - gocognit # Cognitive complexity + - goconst # Find repeated strings that could be constants + - gocritic # Comprehensive code review linter + - revive # Fast, configurable linter (golint replacement) + - unconvert # Detect unnecessary type conversions + - unparam # Detect unused function parameters + - misspell # Find commonly misspelled words + - prealloc # Suggest preallocating slices + - nilerr # Detect returning nil after checking error + - nilnil # Detect returning nil, nil + - predeclared # Detect shadowing of predeclared identifiers + - tparallel # Detect inappropriate usage of t.Parallel + - thelper # Detect test helpers without t.Helper() + - usestdlibvars # Detect using magic numbers instead of stdlib constants + - wastedassign # Find wasted assignments + - whitespace # Detect leading/trailing whitespace + - copyloopvar # Detect loop variable copies (Go 1.22+) + - intrange # Suggest using integer range loops (Go 1.22+) + - sloglint # Ensure consistent slog usage + - perfsprint # Detect fmt.Sprintf that can be replaced + + # Disable linters that are too noisy or not applicable + disable: + - depguard # Requires explicit configuration, enable if needed + + # Exclusion rules + exclusions: + # Built-in presets + presets: + - comments + - std-error-handling + # Custom rules + rules: + # Allow complexity in test files + - path: '_test\.go' + linters: + - gocyclo + - gocognit + - funlen + - goconst # String constants like "windows" are common in test skips + # Exclude gosec checks in test files (test code has different security requirements) + - path: '_test\.go' + linters: + - gosec + # Allow unchecked errors in test cleanup code + - path: '_test\.go' + linters: + - errcheck + # Allow issues in generated mock files + - path: 'mocks/' + linters: + - gocritic + - revive + - stylecheck + # Internal packages return unexported types accessed via interfaces + - path: 'internal/' + linters: + - revive + text: "unexported-return" + + # Linter-specific settings + settings: + # Security linter settings + gosec: + # Enable all rules by default + excludes: [] + # Severity levels: low, medium, high + severity: medium + # Confidence levels: low, medium, high + confidence: medium + # Include additional checks + config: + global: + audit: true + + # Error checking + errcheck: + # Check type assertions + check-type-assertions: true + # Check blank identifier assignments + check-blank: true + # Exclude common functions that are safe to ignore + exclude-functions: + - (io.Closer).Close + - (*os.File).Close + - (net.Conn).Close + - golang.org/x/term.Restore + - (io.Writer).Write + # Viper bindings in init() - errors indicate programmer error + - (*github.com/spf13/viper.Viper).BindPFlag + - github.com/spf13/viper.BindPFlag + + # Cyclomatic complexity threshold + gocyclo: + min-complexity: 15 + + # Cognitive complexity threshold + gocognit: + min-complexity: 20 + + # govet configuration + govet: + enable: + - shadow # Check for variable shadowing + - nilness # Check for redundant nil comparisons + - unusedwrite # Check for unused writes + + # gocritic comprehensive checks + gocritic: + enabled-tags: + - diagnostic + - style + - performance + - opinionated + disabled-checks: + - whyNoLint # Can be too strict + + # revive linter rules + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + - name: if-return + - name: increment-decrement + - name: var-naming + - name: var-declaration + - name: package-comments + disabled: true # Allow packages without comments + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + - name: empty-block + - name: superfluous-else + - name: unused-parameter + disabled: true # Conflicts with unparam + - name: unreachable-code + - name: redefines-builtin-id + + # misspell settings + misspell: + locale: US + + # slog linter settings + sloglint: + no-mixed-args: true + kv-only: false + attr-only: false + static-msg: false + no-raw-keys: false + key-naming-case: snake + +# Issue configuration +issues: + # Maximum issues per linter (0 = unlimited) + max-issues-per-linter: 50 + # Maximum identical issues (0 = unlimited) + max-same-issues: 3 + +# Runtime configuration +run: + # Timeout for analysis + timeout: 5m + # Include test files in analysis + tests: true + # Number of parallel workers + concurrency: 4 + # Go version to use (auto-detect from go.mod) + go: "" + # Build tags + build-tags: [] + # Modules download mode + modules-download-mode: readonly + +# Formatters configuration (v2 separates formatters from linters) +formatters: + enable: + - goimports # Format imports and code + - gofmt # Standard Go formatting + settings: + goimports: + # Group local imports separately + local-prefixes: + - github.com/meigma/blob-cli + +# Output configuration +output: + formats: + text: + # Print linter name in output + print-linter-name: true + # Print lines with issues + print-issued-lines: true + # Use colors + colors: true + # Sort results for consistent output + sort-order: + - linter + - file + show-stats: true diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..19062cb --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,842 @@ +# blob-cli Design Document + +> **Status:** Draft +> **Purpose:** Temporary design document to flesh out CLI design before implementation + +--- + +## Principles + +1. **User-friendly + automation-friendly** — Clean, minimal commands with friendly coloring/messaging that falls back to plain text in non-TTY environments +2. **Structured output** — All commands support `--output json` for machine consumption +3. **Standard configuration** — Cobra/Viper pattern for flags, environment variables, and config files +4. **XDG compliance** — All paths follow XDG Base Directory Specification + +--- + +## Commands + +### Core Commands + +| Command | Description | +|---------|-------------| +| `blob push ` | Push a directory to an OCI registry as a blob archive | +| `blob pull [path]` | Pull an entire archive to a local directory | +| `blob cp : ` | Copy specific file(s) or directories from an archive to local filesystem | +| `blob cat ...` | Print file contents to stdout (for viewing/piping) | +| `blob ls [path]` | List files/directories in an archive | + +### Inspection & Metadata + +| Command | Description | +|---------|-------------| +| `blob inspect ` | Show archive metadata (file count, size, signatures, attestations) | +| `blob tree [path]` | Display directory structure as a tree | + +### Security & Provenance + +| Command | Description | +|---------|-------------| +| `blob sign ` | Sign an archive with Sigstore | +| `blob verify ` | Verify signatures and attestations against policies | + +### Registry Management + +| Command | Description | +|---------|-------------| +| `blob tag ` | Tag an existing manifest with a new reference | + +### Cache Management + +| Command | Description | +|---------|-------------| +| `blob cache status` | Show cache sizes (individual and total) | +| `blob cache clear [type]` | Clear caches (all or specific type) | +| `blob cache path` | Show cache directory paths | + +### Aliases + +| Command | Description | +|---------|-------------| +| `blob alias list` | List all configured aliases | +| `blob alias set ` | Add or update an alias | +| `blob alias remove ` | Remove an alias | + +### Configuration + +| Command | Description | +|---------|-------------| +| `blob config show` | Display current configuration | +| `blob config path` | Show configuration file path | +| `blob config edit` | Open configuration in $EDITOR | + +--- + +## Command Details + +### `blob push` + +``` +blob push + +Push a directory to an OCI registry as a blob archive. + +Arguments: + Target reference (e.g., ghcr.io/org/repo:tag) + Source directory to archive + +Flags: + -c, --compression Compression type: none, zstd (default: zstd) + --skip-compressed Skip compressing already-compressed files (default: true) + --sign Sign the archive after pushing + --annotation Add annotation to manifest (repeatable) + +Examples: + blob push ghcr.io/acme/configs:v1.0.0 ./config + blob push --sign ghcr.io/acme/configs:latest ./config +``` + +### `blob pull` + +``` +blob pull [path] + +Pull an archive from an OCI registry to a local directory. + +Arguments: + Source reference, alias, or alias:tag + [path] Destination directory (default: current directory) + +Flags: + --policy Policy file for verification (can be repeated) + --policy-rego OPA Rego policy file + --policy-bundle OPA bundle for policy evaluation + --no-default-policy Skip policies from config file + +Examples: + blob pull ghcr.io/acme/configs:v1.0.0 ./local + blob pull foo:v1 ./local # Using alias + blob pull --policy policy.yaml ghcr.io/acme/configs:v1.0.0 + blob pull --no-default-policy foo:v1 ./local # Skip config policies +``` + +### `blob cp` + +``` +blob cp :... + +Copy files or directories from an archive to the local filesystem. +Uses HTTP range requests — does not download the full archive. + +Arguments: + : Source (reference or alias) and path within archive (can be repeated) + Local destination (file or directory) + +Flags: + -r, --recursive Copy directories recursively (default: true for directories) + --preserve Preserve file permissions from archive + +Behavior: + - Single file to file: blob cp reg/repo:v1:/config.json ./config.json + - Single file to dir: blob cp reg/repo:v1:/config.json ./output/ + - Multiple files to dir: blob cp reg/repo:v1:/a.json reg/repo:v1:/b.json ./output/ + - Directory to directory: blob cp reg/repo:v1:/etc/nginx ./nginx-config + +Examples: + blob cp ghcr.io/acme/configs:v1.0.0:/config.json ./config.json + blob cp ghcr.io/acme/configs:v1.0.0:/etc/nginx/ ./nginx/ + blob cp ghcr.io/acme/configs:v1.0.0:/a.json ghcr.io/acme/configs:v1.0.0:/b.json ./ +``` + +### `blob cat` + +``` +blob cat ... + +Print file contents to stdout. Useful for viewing, piping, or combining files. +Uses HTTP range requests — does not download the full archive. + +Arguments: + Source reference or alias + ... File path(s) within the archive + +Flags: + (none specific) + +Examples: + blob cat ghcr.io/acme/configs:v1.0.0 config.json + blob cat ghcr.io/acme/configs:v1.0.0 config.json | jq . + blob cat ghcr.io/acme/configs:v1.0.0 header.txt body.txt footer.txt > combined.txt +``` + +### `blob ls` + +``` +blob ls [path] + +List files and directories in an archive. + +Arguments: + Source reference + [path] Path within archive (default: root) + +Flags: + -l, --long Long format (permissions, size, hash) + -h, --human Human-readable sizes (use with -l) + --digest Show file digests + +Examples: + blob ls ghcr.io/acme/configs:v1.0.0 + blob ls -lh ghcr.io/acme/configs:v1.0.0 /etc +``` + +### `blob inspect` + +``` +blob inspect + +Show metadata about an archive without downloading it. + +Arguments: + Source reference + +Flags: + (none specific) + +Output includes: + - Manifest digest + - Total file count + - Total size (compressed/uncompressed) + - Compression type + - Signatures (if any) + - Attestations (if any) + - Annotations + +Examples: + blob inspect ghcr.io/acme/configs:v1.0.0 + blob inspect --output json ghcr.io/acme/configs:v1.0.0 +``` + +### `blob tree` + +``` +blob tree [path] + +Display directory structure as a tree. + +Arguments: + Source reference + [path] Path within archive (default: root) + +Flags: + -L, --level Descend only n levels deep + --dirsfirst List directories before files + +Examples: + blob tree ghcr.io/acme/configs:v1.0.0 + blob tree -L 2 ghcr.io/acme/configs:v1.0.0 /etc +``` + +### `blob sign` + +``` +blob sign + +Sign an archive using Sigstore keyless signing. + +Arguments: + Reference to sign + +Flags: + --key Sign with a private key instead of keyless + --output-signature Print signature to stdout instead of uploading + +Examples: + blob sign ghcr.io/acme/configs:v1.0.0 + blob sign --key cosign.key ghcr.io/acme/configs:v1.0.0 +``` + +### `blob verify` + +``` +blob verify + +Verify signatures and attestations on an archive. + +Arguments: + Reference to verify + +Flags: + --policy Policy file for verification (can be repeated) + --policy-rego OPA Rego policy file + --policy-bundle OPA bundle for policy evaluation + +Examples: + blob verify ghcr.io/acme/configs:v1.0.0 + blob verify --policy policy.yaml ghcr.io/acme/configs:v1.0.0 + blob verify --policy-rego custom.rego ghcr.io/acme/configs:v1.0.0 +``` + +### `blob tag` + +``` +blob tag + +Tag an existing manifest with a new reference. + +Arguments: + Source reference (must exist) + Destination reference (new tag) + +Examples: + blob tag ghcr.io/acme/configs:v1.0.0 ghcr.io/acme/configs:latest + blob tag ghcr.io/acme/configs@sha256:abc... ghcr.io/acme/configs:stable +``` + +### `blob cache` + +``` +blob cache + +Manage local caches. + +Subcommands: + status Show cache sizes + clear [type] Clear caches + path Show cache directory paths + +Cache types: + content File content cache (deduplicated across archives) + manifests OCI manifest cache + indexes Archive index cache + all All caches (default for clear) +``` + +### `blob cache status` + +``` +blob cache status + +Show cache sizes for all cache types. + +Example output: + Cache Status + ──────────────────────────────── + Content: 1.2 GB (4,231 files) + Manifests: 12 MB (89 entries) + Indexes: 8.4 MB (89 entries) + ──────────────────────────────── + Total: 1.2 GB + +Example (JSON): + blob cache status --output json + {"content": {"size": 1288490188, "files": 4231}, ...} +``` + +### `blob cache clear` + +``` +blob cache clear [type] + +Clear caches. Clears all caches by default. + +Arguments: + [type] Cache type to clear: content, manifests, indexes, all (default: all) + +Flags: + --force Skip confirmation prompt + +Examples: + blob cache clear # Clear all caches (prompts for confirmation) + blob cache clear --force # Clear all without prompting + blob cache clear content # Clear only content cache + blob cache clear manifests # Clear only manifest cache +``` + +### `blob cache path` + +``` +blob cache path + +Show cache directory paths. + +Example output: + Cache Paths + ───────────────────────────────────────────── + Root: ~/.cache/blob/ + Content: ~/.cache/blob/content/ + Manifests: ~/.cache/blob/manifests/ + Indexes: ~/.cache/blob/indexes/ +``` + +### `blob alias` + +``` +blob alias + +Manage reference aliases. + +Subcommands: + list List all aliases + set Add or update an alias + remove Remove an alias +``` + +### `blob alias list` + +``` +blob alias list + +List all configured aliases. + +Example output: + Aliases + ─────────────────────────────────────── + foo → ghcr.io/acme/repo/foo + bar → ghcr.io/acme/repo/bar + baz → ghcr.io/acme/repo/baz:stable +``` + +### `blob alias set` + +``` +blob alias set + +Add or update an alias. Writes to the config file. + +Arguments: + Alias name (short identifier) + Full reference (may include tag) + +Examples: + blob alias set foo ghcr.io/acme/repo/foo + blob alias set prod ghcr.io/acme/repo/app:stable +``` + +### `blob alias remove` + +``` +blob alias remove + +Remove an alias from the config file. + +Arguments: + Alias name to remove + +Examples: + blob alias remove foo +``` + +### `blob config` + +``` +blob config + +View and manage CLI configuration. + +Subcommands: + show Display current configuration (merged from all sources) + path Show configuration file path + edit Open configuration file in $EDITOR + +Examples: + blob config show # Display current config + blob config show --output json # As JSON + blob config path # Show config file location + blob config edit # Open in editor +``` + +### `blob config show` + +``` +blob config show + +Display the current configuration, merged from defaults, config file, +and environment variables. Shows effective values and their sources. + +Flags: + --resolved Show fully resolved values (expand env vars) + +Example output: + Configuration + ───────────────────────────────────────────── + output: text (default) + compression: zstd (config) + cache: + enabled: true (env: BLOB_CACHE_ENABLED) + max_size: 5GB (config) + + Aliases: + foo → ghcr.io/acme/repo/foo + + Policies: + ghcr\.io/acme/.* → signature (keyless), provenance (slsa) +``` + +### `blob config edit` + +``` +blob config edit + +Open the configuration file in your default editor. +Uses $EDITOR, falling back to $VISUAL, then vi. + +Creates the config file with defaults if it doesn't exist. +``` + +--- + +## Global Flags + +All commands support these flags: + +``` + --output Output format: text, json (default: text) + -v, --verbose Increase verbosity (can be repeated: -vv, -vvv) + -q, --quiet Suppress non-error output + --no-color Disable colored output + --config Path to config file +``` + +## Reference Arguments + +All commands that accept a `` argument support: + +- **Full reference:** `ghcr.io/acme/repo:v1.0.0` +- **Aliases:** `foo` or `foo:v1` (expanded via config) +- **Digest references:** `ghcr.io/acme/repo@sha256:abc...` + +See [Alias Resolution](#alias-resolution) for details. + +--- + +## Configuration + +### Precedence (highest to lowest) + +1. Command-line flags +2. Environment variables (`BLOB_*`) +3. Config file +4. Defaults + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `BLOB_CONFIG` | Path to config file | +| `BLOB_OUTPUT` | Default output format | +| `BLOB_NO_COLOR` | Disable colors (also: `NO_COLOR`) | +| `BLOB_CACHE_DIR` | Cache directory | +| `BLOB_USERNAME` | Registry username | +| `BLOB_PASSWORD` | Registry password | + +### XDG Paths + +| Purpose | Path | +|---------|------| +| Config | `$XDG_CONFIG_HOME/blob/config.yaml` (default: `~/.config/blob/config.yaml`) | +| Cache | `$XDG_CACHE_HOME/blob/` (default: `~/.cache/blob/`) | +| Data | `$XDG_DATA_HOME/blob/` (default: `~/.local/share/blob/`) | + +### Config File Format + +```yaml +# ~/.config/blob/config.yaml + +# Default output format +output: text + +# Cache settings +cache: + enabled: true + max_size: 5GB + +# Default compression for push +compression: zstd + +# Aliases for frequently used references +# Usage: blob pull foo:v1 → ghcr.io/acme/repo/foo:v1 +aliases: + foo: ghcr.io/acme/repo/foo + bar: ghcr.io/acme/repo/bar + # Can include tag (blob pull baz → ghcr.io/acme/repo/baz:stable) + baz: ghcr.io/acme/repo/baz:stable + +# Default policies applied by image pattern (regex) +# Matched against fully-expanded reference (after alias resolution) +# Multiple patterns can match; all matching policies are combined (AND) +policies: + - match: ghcr\.io/acme/.* + policy: + signature: + keyless: + issuer: https://token.actions.githubusercontent.com + identity: https://github.com/acme/*/.github/workflows/* + provenance: + slsa: + builder: https://github.com/slsa-framework/slsa-github-generator/.github/workflows/* + repository: acme/* + + - match: ghcr\.io/acme/repo/prod-.* + policy: + # Additional requirements for prod images + provenance: + slsa: + branch: main # Prod must come from main branch +``` + +**Authentication:** Registry credentials are read from Docker's config (`~/.docker/config.json`) and credential helpers. For CI environments, use `BLOB_USERNAME` and `BLOB_PASSWORD` environment variables. + +### Alias Resolution + +Aliases expand short names to full references: + +```bash +blob pull foo # → ghcr.io/acme/repo/foo:latest +blob pull foo:v1 # → ghcr.io/acme/repo/foo:v1 +blob pull foo@sha256:… # → ghcr.io/acme/repo/foo@sha256:… +``` + +If the alias already includes a tag (e.g., `baz: .../baz:stable`), it's used as the default: + +```bash +blob pull baz # → ghcr.io/acme/repo/baz:stable +blob pull baz:v2 # → ghcr.io/acme/repo/baz:v2 (override) +``` + +### Policy Pattern Matching + +Policies are matched against the **fully-expanded reference** (after alias resolution): + +```bash +blob pull foo:v1 +# 1. Expand alias: ghcr.io/acme/repo/foo:v1 +# 2. Match against policy patterns +# 3. ghcr.io/acme/repo/foo:v1 matches "ghcr\.io/acme/.*" → policy applied +``` + +Multiple matching policies are combined with AND logic. Explicit `--policy` flags are added to (not replaced by) config policies. + +To skip config policies for a single command: + +```bash +blob pull --no-default-policy ghcr.io/acme/repo/foo:v1 +``` + +--- + +## Policy Files + +Blob supports two policy formats: a simple YAML format for common verification patterns, and OPA/Rego for complex custom logic. + +### YAML Policy Format (recommended for most cases) + +```yaml +# policy.yaml + +# Require a Sigstore keyless signature +signature: + keyless: + issuer: https://token.actions.githubusercontent.com + # identity supports wildcards + identity: https://github.com/acme/configs/.github/workflows/* + +# Or require a key-based signature +# signature: +# key: +# path: /path/to/cosign.pub +# # or +# key: +# url: https://example.com/cosign.pub + +# Require SLSA provenance +provenance: + slsa: + builder: https://github.com/slsa-framework/slsa-github-generator/.github/workflows/* + repository: acme/configs + # Optional: restrict to specific branches/tags + branch: main + # tag: v* +``` + +### Combining Multiple Policies + +Multiple `--policy` flags are combined with AND logic: + +```bash +# Both policies must pass +blob pull --policy sig.yaml --policy provenance.yaml ghcr.io/acme/configs:v1 +``` + +For OR logic or more complex combinations, use OPA. + +### OPA/Rego Policies (for complex cases) + +For advanced use cases requiring custom logic: + +```bash +# Single Rego file +blob verify --policy-rego policy.rego ghcr.io/acme/configs:v1 + +# OPA bundle (for larger policy sets) +blob verify --policy-bundle bundle.tar.gz ghcr.io/acme/configs:v1 +``` + +Example Rego policy: + +```rego +# policy.rego +package blob.verify + +default allow = false + +# Allow if signed by either release or security team +allow { + some sig in input.signatures + sig.issuer == "https://token.actions.githubusercontent.com" + allowed_identities[sig.identity] +} + +allowed_identities := { + "https://github.com/acme/configs/.github/workflows/release.yaml@refs/heads/main", + "https://github.com/acme/security/.github/workflows/sign.yaml@refs/heads/main", +} +``` + +### Policy Input Schema (for OPA) + +OPA policies receive the following input: + +```json +{ + "reference": "ghcr.io/acme/configs:v1.0.0", + "digest": "sha256:abc123...", + "signatures": [ + { + "type": "sigstore-keyless", + "issuer": "https://token.actions.githubusercontent.com", + "identity": "https://github.com/acme/configs/.github/workflows/release.yaml@refs/heads/main", + "timestamp": "2024-01-15T10:30:00Z" + } + ], + "attestations": [ + { + "type": "slsa-provenance-v1", + "builder": "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@refs/tags/v1.9.0", + "repository": "acme/configs", + "ref": "refs/tags/v1.0.0", + "digest": "sha256:abc123..." + } + ], + "annotations": { + "org.opencontainers.image.source": "https://github.com/acme/configs" + } +} +``` + +--- + +## Output Formats + +### Text (default, TTY) + +Human-friendly output with colors and formatting: + +``` +$ blob inspect ghcr.io/acme/configs:v1.0.0 + +Reference: ghcr.io/acme/configs:v1.0.0 +Digest: sha256:abc123... +Files: 142 +Size: 2.4 MB (8.1 MB uncompressed) +Compression: zstd +Created: 2024-01-15T10:30:00Z + +Signatures: + ✓ Sigstore keyless (GitHub Actions) + Identity: https://github.com/acme/configs/.github/workflows/release.yaml@refs/tags/v1.0.0 + Issuer: https://token.actions.githubusercontent.com + +Attestations: + ✓ SLSA Provenance v1.0 + Builder: https://github.com/slsa-framework/slsa-github-generator +``` + +### Text (non-TTY) + +Plain text without colors: + +``` +$ blob inspect ghcr.io/acme/configs:v1.0.0 | cat + +Reference: ghcr.io/acme/configs:v1.0.0 +Digest: sha256:abc123... +Files: 142 +... +``` + +### JSON + +Machine-readable output: + +``` +$ blob inspect --output json ghcr.io/acme/configs:v1.0.0 + +{ + "reference": "ghcr.io/acme/configs:v1.0.0", + "digest": "sha256:abc123...", + "files": 142, + "size": { + "compressed": 2400000, + "uncompressed": 8100000 + }, + "compression": "zstd", + "created": "2024-01-15T10:30:00Z", + "signatures": [...], + "attestations": [...] +} +``` + +--- + +## Error Handling + +### Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | General error | +| 2 | Usage error (bad arguments/flags) | +| 3 | Authentication error | +| 4 | Not found (reference doesn't exist) | +| 5 | Verification failed (policy violation) | + +### Error Output + +Errors go to stderr. In JSON mode, errors are also JSON: + +``` +$ blob pull ghcr.io/acme/nonexistent:v1 +Error: reference not found: ghcr.io/acme/nonexistent:v1 + +$ blob pull --output json ghcr.io/acme/nonexistent:v1 +{"error": "reference not found", "reference": "ghcr.io/acme/nonexistent:v1", "code": 4} +``` + +--- + +## Future Enhancements (not in v1) + +- `blob login` / `blob logout` — Registry authentication management +- `blob copy` — Copy archives between registries (registry-to-registry) +- `blob diff` — Compare two archives +- Shell completions (`blob completion bash/zsh/fish`) +- Gittuf policy support — Source integrity verification (pending gittuf maturity) + +--- + +## Open Questions + +1. **Progress indicators** — Spinners? Progress bars? Both? +2. **Caching behavior** — On by default? Off by default? Per-command control? diff --git a/cmd/alias/alias.go b/cmd/alias/alias.go new file mode 100644 index 0000000..71aac67 --- /dev/null +++ b/cmd/alias/alias.go @@ -0,0 +1,21 @@ +package alias + +import ( + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "alias", + Short: "Manage reference aliases", + Long: `Manage reference aliases. + +Aliases allow you to use short names for frequently used references. +For example, you can create an alias "foo" for "ghcr.io/acme/repo/foo" +and then use "blob pull foo:v1" instead of the full reference.`, +} + +func init() { + Cmd.AddCommand(listCmd) + Cmd.AddCommand(setCmd) + Cmd.AddCommand(removeCmd) +} diff --git a/cmd/alias/list.go b/cmd/alias/list.go new file mode 100644 index 0000000..4f8361b --- /dev/null +++ b/cmd/alias/list.go @@ -0,0 +1,20 @@ +package alias + +import ( + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all configured aliases", + Long: `List all configured aliases. + +Displays all aliases defined in the configuration file along with +their target references.`, + Example: ` blob alias list + blob alias list --output json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} diff --git a/cmd/alias/remove.go b/cmd/alias/remove.go new file mode 100644 index 0000000..9c2d595 --- /dev/null +++ b/cmd/alias/remove.go @@ -0,0 +1,18 @@ +package alias + +import ( + "github.com/spf13/cobra" +) + +var removeCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove an alias", + Long: `Remove an alias from the configuration file. + +Deletes the specified alias. This action cannot be undone.`, + Example: ` blob alias remove foo`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} diff --git a/cmd/alias/set.go b/cmd/alias/set.go new file mode 100644 index 0000000..8623a13 --- /dev/null +++ b/cmd/alias/set.go @@ -0,0 +1,21 @@ +package alias + +import ( + "github.com/spf13/cobra" +) + +var setCmd = &cobra.Command{ + Use: "set ", + Short: "Add or update an alias", + Long: `Add or update an alias. + +Creates a new alias or updates an existing one. The alias maps +a short name to a full registry reference. The reference may +optionally include a tag.`, + Example: ` blob alias set foo ghcr.io/acme/repo/foo + blob alias set prod ghcr.io/acme/repo/app:stable`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} diff --git a/cmd/cache/cache.go b/cmd/cache/cache.go new file mode 100644 index 0000000..628f46b --- /dev/null +++ b/cmd/cache/cache.go @@ -0,0 +1,22 @@ +package cache + +import ( + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "cache", + Short: "Manage local caches", + Long: `Manage local caches. + +Blob maintains several caches to improve performance: + - content: File content cache (deduplicated across archives) + - manifests: OCI manifest cache + - indexes: Archive index cache`, +} + +func init() { + Cmd.AddCommand(statusCmd) + Cmd.AddCommand(clearCmd) + Cmd.AddCommand(pathCmd) +} diff --git a/cmd/cache/clear.go b/cmd/cache/clear.go new file mode 100644 index 0000000..b46932e --- /dev/null +++ b/cmd/cache/clear.go @@ -0,0 +1,29 @@ +package cache + +import ( + "github.com/spf13/cobra" +) + +var clearCmd = &cobra.Command{ + Use: "clear [type]", + Short: "Clear caches", + Long: `Clear caches. Clears all caches by default. + +Cache types: + content File content cache (deduplicated across archives) + manifests OCI manifest cache + indexes Archive index cache + all All caches (default)`, + Example: ` blob cache clear # Clear all caches (prompts for confirmation) + blob cache clear --force # Clear all without prompting + blob cache clear content # Clear only content cache + blob cache clear manifests # Clear only manifest cache`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} + +func init() { + clearCmd.Flags().Bool("force", false, "skip confirmation prompt") +} diff --git a/cmd/cache/path.go b/cmd/cache/path.go new file mode 100644 index 0000000..89f9692 --- /dev/null +++ b/cmd/cache/path.go @@ -0,0 +1,20 @@ +package cache + +import ( + "github.com/spf13/cobra" +) + +var pathCmd = &cobra.Command{ + Use: "path", + Short: "Show cache directory paths", + Long: `Show cache directory paths. + +Displays the paths for each cache type. Paths follow the XDG +Base Directory Specification.`, + Example: ` blob cache path + blob cache path --output json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} diff --git a/cmd/cache/status.go b/cmd/cache/status.go new file mode 100644 index 0000000..74dbc2b --- /dev/null +++ b/cmd/cache/status.go @@ -0,0 +1,20 @@ +package cache + +import ( + "github.com/spf13/cobra" +) + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Show cache sizes for all cache types", + Long: `Show cache sizes for all cache types. + +Displays the size and entry count for each cache type, as well +as the total cache size.`, + Example: ` blob cache status + blob cache status --output json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} diff --git a/cmd/cat.go b/cmd/cat.go new file mode 100644 index 0000000..698eabf --- /dev/null +++ b/cmd/cat.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var catCmd = &cobra.Command{ + Use: "cat ...", + Short: "Print file contents to stdout", + Long: `Print file contents to stdout. + +Useful for viewing, piping, or combining files from an archive. +Uses HTTP range requests to fetch only the requested files without +downloading the entire archive.`, + Example: ` blob cat ghcr.io/acme/configs:v1.0.0 config.json + blob cat ghcr.io/acme/configs:v1.0.0 config.json | jq . + blob cat ghcr.io/acme/configs:v1.0.0 header.txt body.txt footer.txt > combined.txt`, + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} diff --git a/cmd/config/config.go b/cmd/config/config.go new file mode 100644 index 0000000..9bd3fe8 --- /dev/null +++ b/cmd/config/config.go @@ -0,0 +1,23 @@ +package config + +import ( + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "config", + Short: "View and manage CLI configuration", + Long: `View and manage CLI configuration. + +Configuration is read from multiple sources in order of precedence: + 1. Command-line flags + 2. Environment variables (BLOB_*) + 3. Config file + 4. Defaults`, +} + +func init() { + Cmd.AddCommand(showCmd) + Cmd.AddCommand(pathCmd) + Cmd.AddCommand(editCmd) +} diff --git a/cmd/config/edit.go b/cmd/config/edit.go new file mode 100644 index 0000000..0601c02 --- /dev/null +++ b/cmd/config/edit.go @@ -0,0 +1,21 @@ +package config + +import ( + "github.com/spf13/cobra" +) + +var editCmd = &cobra.Command{ + Use: "edit", + Short: "Open configuration file in $EDITOR", + Long: `Open configuration file in $EDITOR. + +Opens the configuration file in your default editor. Uses $EDITOR, +falling back to $VISUAL, then vi. + +Creates the config file with defaults if it doesn't exist.`, + Example: ` blob config edit`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} diff --git a/cmd/config/path.go b/cmd/config/path.go new file mode 100644 index 0000000..fc68e16 --- /dev/null +++ b/cmd/config/path.go @@ -0,0 +1,19 @@ +package config + +import ( + "github.com/spf13/cobra" +) + +var pathCmd = &cobra.Command{ + Use: "path", + Short: "Show configuration file path", + Long: `Show configuration file path. + +Displays the path to the configuration file. The default location +follows the XDG Base Directory Specification.`, + Example: ` blob config path`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} diff --git a/cmd/config/show.go b/cmd/config/show.go new file mode 100644 index 0000000..948d356 --- /dev/null +++ b/cmd/config/show.go @@ -0,0 +1,26 @@ +package config + +import ( + "github.com/spf13/cobra" +) + +var showCmd = &cobra.Command{ + Use: "show", + Short: "Display current configuration", + Long: `Display current configuration. + +Shows the effective configuration merged from all sources (defaults, +config file, environment variables). Each value is annotated with +its source.`, + Example: ` blob config show + blob config show --output json + blob config show --resolved`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} + +func init() { + showCmd.Flags().Bool("resolved", false, "show fully resolved values (expand env vars)") +} diff --git a/cmd/cp.go b/cmd/cp.go new file mode 100644 index 0000000..ddabcf0 --- /dev/null +++ b/cmd/cp.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var cpCmd = &cobra.Command{ + Use: "cp :... ", + Short: "Copy files or directories from an archive to the local filesystem", + Long: `Copy files or directories from an archive to the local filesystem. + +Uses HTTP range requests to fetch only the requested files without +downloading the entire archive. Multiple source paths can be specified. + +Behavior: + - Single file to file: blob cp reg/repo:v1:/config.json ./config.json + - Single file to dir: blob cp reg/repo:v1:/config.json ./output/ + - Multiple files to dir: blob cp reg/repo:v1:/a.json reg/repo:v1:/b.json ./output/ + - Directory to directory: blob cp reg/repo:v1:/etc/nginx ./nginx-config`, + Example: ` blob cp ghcr.io/acme/configs:v1.0.0:/config.json ./config.json + blob cp ghcr.io/acme/configs:v1.0.0:/etc/nginx/ ./nginx/ + blob cp ghcr.io/acme/configs:v1.0.0:/a.json ghcr.io/acme/configs:v1.0.0:/b.json ./`, + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} + +func init() { + cpCmd.Flags().BoolP("recursive", "r", true, "copy directories recursively") + cpCmd.Flags().Bool("preserve", false, "preserve file permissions from archive") +} diff --git a/cmd/inspect.go b/cmd/inspect.go new file mode 100644 index 0000000..8d2639e --- /dev/null +++ b/cmd/inspect.go @@ -0,0 +1,26 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var inspectCmd = &cobra.Command{ + Use: "inspect ", + Short: "Show metadata about an archive", + Long: `Show metadata about an archive without downloading it. + +Displays information including: + - Manifest digest + - Total file count + - Total size (compressed/uncompressed) + - Compression type + - Signatures (if any) + - Attestations (if any) + - Annotations`, + Example: ` blob inspect ghcr.io/acme/configs:v1.0.0 + blob inspect --output json ghcr.io/acme/configs:v1.0.0`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} diff --git a/cmd/ls.go b/cmd/ls.go new file mode 100644 index 0000000..5502c1e --- /dev/null +++ b/cmd/ls.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var lsCmd = &cobra.Command{ + Use: "ls [path]", + Short: "List files and directories in an archive", + Long: `List files and directories in an archive. + +Lists the contents of an archive at the specified path. If no path +is provided, lists the root directory.`, + Example: ` blob ls ghcr.io/acme/configs:v1.0.0 + blob ls -lh ghcr.io/acme/configs:v1.0.0 /etc + blob ls --digest ghcr.io/acme/configs:v1.0.0`, + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} + +func init() { + lsCmd.Flags().BoolP("long", "l", false, "long format (permissions, size, hash)") + lsCmd.Flags().BoolP("human", "h", false, "human-readable sizes (use with -l)") + lsCmd.Flags().Bool("digest", false, "show file digests") +} diff --git a/cmd/pull.go b/cmd/pull.go new file mode 100644 index 0000000..b95e1ee --- /dev/null +++ b/cmd/pull.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var pullCmd = &cobra.Command{ + Use: "pull [path]", + Short: "Pull an archive from an OCI registry to a local directory", + Long: `Pull an archive from an OCI registry to a local directory. + +Downloads and extracts the blob archive to the specified destination +directory. If no path is provided, extracts to the current directory. + +Verification policies can be specified to enforce signature and +attestation requirements before extraction.`, + Example: ` blob pull ghcr.io/acme/configs:v1.0.0 ./local + blob pull foo:v1 ./local # Using alias + blob pull --policy policy.yaml ghcr.io/acme/configs:v1.0.0 + blob pull --no-default-policy foo:v1 ./local # Skip config policies`, + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} + +func init() { + pullCmd.Flags().StringArray("policy", nil, "policy file for verification (repeatable)") + pullCmd.Flags().String("policy-rego", "", "OPA Rego policy file") + pullCmd.Flags().String("policy-bundle", "", "OPA bundle for policy evaluation") + pullCmd.Flags().Bool("no-default-policy", false, "skip policies from config file") +} diff --git a/cmd/push.go b/cmd/push.go new file mode 100644 index 0000000..701f57d --- /dev/null +++ b/cmd/push.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var pushCmd = &cobra.Command{ + Use: "push ", + Short: "Push a directory to an OCI registry as a blob archive", + Long: `Push a directory to an OCI registry as a blob archive. + +The directory contents are archived and uploaded to the specified +registry reference. Files are compressed individually using zstd +by default for optimal random access performance.`, + Example: ` blob push ghcr.io/acme/configs:v1.0.0 ./config + blob push --sign ghcr.io/acme/configs:latest ./config + blob push --compression none ghcr.io/acme/data:v1 ./data`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} + +func init() { + pushCmd.Flags().StringP("compression", "c", "zstd", "compression type: none, zstd") + pushCmd.Flags().Bool("skip-compressed", true, "skip compressing already-compressed files") + pushCmd.Flags().Bool("sign", false, "sign the archive after pushing") + pushCmd.Flags().StringArray("annotation", nil, "add annotation to manifest (k=v, repeatable)") + + viper.BindPFlag("compression", pushCmd.Flags().Lookup("compression")) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..fc6fd4a --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,92 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/meigma/blob-cli/cmd/alias" + "github.com/meigma/blob-cli/cmd/cache" + "github.com/meigma/blob-cli/cmd/config" +) + +var cfgFile string + +var rootCmd = &cobra.Command{ + Use: "blob", + Short: "A CLI for working with blob archives in OCI registries", + Long: `blob is a command-line tool for pushing, pulling, and inspecting +blob archives stored in OCI-compliant container registries. + +Archives support random access via HTTP range requests, enabling efficient +retrieval of individual files without downloading the entire archive.`, + SilenceUsage: true, + SilenceErrors: true, +} + +func Execute() error { + return rootCmd.Execute() +} + +func init() { + cobra.OnInitialize(initConfig) + + // Global flags + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default: $XDG_CONFIG_HOME/blob/config.yaml)") + rootCmd.PersistentFlags().String("output", "text", "output format: text, json") + rootCmd.PersistentFlags().CountP("verbose", "v", "increase verbosity (can be repeated: -vv, -vvv)") + rootCmd.PersistentFlags().BoolP("quiet", "q", false, "suppress non-error output") + rootCmd.PersistentFlags().Bool("no-color", false, "disable colored output") + + // Bind flags to Viper + viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output")) + viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose")) + viper.BindPFlag("quiet", rootCmd.PersistentFlags().Lookup("quiet")) + viper.BindPFlag("no-color", rootCmd.PersistentFlags().Lookup("no-color")) + + // Add core commands + rootCmd.AddCommand(pushCmd) + rootCmd.AddCommand(pullCmd) + rootCmd.AddCommand(cpCmd) + rootCmd.AddCommand(catCmd) + rootCmd.AddCommand(lsCmd) + rootCmd.AddCommand(inspectCmd) + rootCmd.AddCommand(treeCmd) + rootCmd.AddCommand(signCmd) + rootCmd.AddCommand(verifyCmd) + rootCmd.AddCommand(tagCmd) + + // Add subcommand groups + rootCmd.AddCommand(cache.Cmd) + rootCmd.AddCommand(alias.Cmd) + rootCmd.AddCommand(config.Cmd) +} + +func initConfig() { + if cfgFile != "" { + viper.SetConfigFile(cfgFile) + } else { + configHome := os.Getenv("XDG_CONFIG_HOME") + if configHome == "" { + home, err := os.UserHomeDir() + if err != nil { + fmt.Fprintln(os.Stderr, "Warning: could not determine home directory:", err) + return + } + configHome = filepath.Join(home, ".config") + } + + viper.AddConfigPath(filepath.Join(configHome, "blob")) + viper.SetConfigName("config") + viper.SetConfigType("yaml") + } + + viper.SetEnvPrefix("BLOB") + viper.AutomaticEnv() + + // Config file is optional - don't fail if missing + viper.ReadInConfig() //nolint:errcheck // config file is optional +} diff --git a/cmd/sign.go b/cmd/sign.go new file mode 100644 index 0000000..c6b1ab2 --- /dev/null +++ b/cmd/sign.go @@ -0,0 +1,26 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var signCmd = &cobra.Command{ + Use: "sign ", + Short: "Sign an archive using Sigstore keyless signing", + Long: `Sign an archive using Sigstore keyless signing. + +Signs the specified archive reference using Sigstore. By default, +uses keyless signing which authenticates via OIDC. A private key +can be specified for key-based signing instead.`, + Example: ` blob sign ghcr.io/acme/configs:v1.0.0 + blob sign --key cosign.key ghcr.io/acme/configs:v1.0.0`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} + +func init() { + signCmd.Flags().String("key", "", "sign with a private key instead of keyless") + signCmd.Flags().Bool("output-signature", false, "print signature to stdout instead of uploading") +} diff --git a/cmd/tag.go b/cmd/tag.go new file mode 100644 index 0000000..e278e93 --- /dev/null +++ b/cmd/tag.go @@ -0,0 +1,21 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var tagCmd = &cobra.Command{ + Use: "tag ", + Short: "Tag an existing manifest with a new reference", + Long: `Tag an existing manifest with a new reference. + +Creates a new tag pointing to the same manifest as the source +reference. This operation does not copy data, only creates a +new reference to the existing content.`, + Example: ` blob tag ghcr.io/acme/configs:v1.0.0 ghcr.io/acme/configs:latest + blob tag ghcr.io/acme/configs@sha256:abc... ghcr.io/acme/configs:stable`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} diff --git a/cmd/tree.go b/cmd/tree.go new file mode 100644 index 0000000..e8cf576 --- /dev/null +++ b/cmd/tree.go @@ -0,0 +1,25 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var treeCmd = &cobra.Command{ + Use: "tree [path]", + Short: "Display directory structure as a tree", + Long: `Display directory structure as a tree. + +Shows the hierarchical structure of files and directories in an +archive, similar to the tree command.`, + Example: ` blob tree ghcr.io/acme/configs:v1.0.0 + blob tree -L 2 ghcr.io/acme/configs:v1.0.0 /etc`, + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} + +func init() { + treeCmd.Flags().IntP("level", "L", 0, "descend only n levels deep (0 = unlimited)") + treeCmd.Flags().Bool("dirsfirst", false, "list directories before files") +} diff --git a/cmd/verify.go b/cmd/verify.go new file mode 100644 index 0000000..fd70440 --- /dev/null +++ b/cmd/verify.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var verifyCmd = &cobra.Command{ + Use: "verify ", + Short: "Verify signatures and attestations on an archive", + Long: `Verify signatures and attestations on an archive. + +Checks that the archive meets the specified policy requirements +for signatures and attestations. Policies can be specified as +YAML files or OPA Rego policies.`, + Example: ` blob verify ghcr.io/acme/configs:v1.0.0 + blob verify --policy policy.yaml ghcr.io/acme/configs:v1.0.0 + blob verify --policy-rego custom.rego ghcr.io/acme/configs:v1.0.0`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} + +func init() { + verifyCmd.Flags().StringArray("policy", nil, "policy file for verification (repeatable)") + verifyCmd.Flags().String("policy-rego", "", "OPA Rego policy file") + verifyCmd.Flags().String("policy-bundle", "", "OPA bundle for policy evaluation") +} diff --git a/go.mod b/go.mod index 861331c..4d39d58 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,21 @@ module github.com/meigma/blob-cli go 1.25.4 + +require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5ad2cc6 --- /dev/null +++ b/go.sum @@ -0,0 +1,34 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/justfile b/justfile new file mode 100644 index 0000000..91aff09 --- /dev/null +++ b/justfile @@ -0,0 +1,55 @@ +# blob-cli build tasks +set shell := ["bash", "-euo", "pipefail", "-c"] + +# Binary name +binary := "blob" + +# Default recipe: validate code +default: fmt vet lint test + +# CI recipe: full validation pipeline +ci: fmt vet lint test build + +# Format check (fails if code needs formatting) +fmt: + @echo "Checking formatting..." + @test -z "$(gofmt -l .)" || (echo "Files need formatting:"; gofmt -l .; exit 1) + +# Run go vet +vet: + @echo "Running go vet..." + go vet ./... + +# Run golangci-lint +lint: + @echo "Running golangci-lint..." + golangci-lint run + +# Run tests +test: + @echo "Running tests..." + go test -race -cover ./... + +# Build the binary +build: + @echo "Building..." + go build -o {{binary}} . + +# Install development tools +tools: + @echo "Installing development tools..." + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + +# Format code (modifies files) +fmt-write: + @echo "Formatting code..." + gofmt -w . + +# Clean build artifacts +clean: + @echo "Cleaning build artifacts..." + rm -f {{binary}} + +# Show available recipes +help: + @just --list diff --git a/main.go b/main.go new file mode 100644 index 0000000..dd18070 --- /dev/null +++ b/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "os" + + "github.com/meigma/blob-cli/cmd" +) + +func main() { + if err := cmd.Execute(); err != nil { + os.Exit(1) + } +}