From 0765fa5877913a77455cba373e5028c120a6980b Mon Sep 17 00:00:00 2001 From: Ethan Byrd Date: Sat, 18 Jul 2026 09:53:48 -0700 Subject: [PATCH] Add Rust CLI port --- .gitattributes | 4 + .github/workflows/go-release.yml | 2 +- .github/workflows/node-release.yml | 5 + .github/workflows/python-release.yml | 2 +- .github/workflows/rust-cli-release.yml | 505 + .github/workflows/sdk-checks.yml | 294 +- .gitignore | 5 +- AGENTS.md | 8 +- CLAUDE.md | 3 +- Makefile | 349 +- README.md | 37 +- RELEASE.md | 39 +- cli-node/README.md | 4 + cli-node/src/oclif/api-command.ts | 11 +- cli-node/src/oclif/commands/login.ts | 46 +- cli-node/src/oclif/commands/payloads.ts | 5 +- cli-node/tests/oclif/api-command.test.ts | 23 + cli-rust/Cargo.lock | 1999 ++ cli-rust/Cargo.toml | 42 + cli-rust/README.md | 154 + cli-rust/src/agent_commands.rs | 280 + cli-rust/src/api.rs | 1905 ++ cli-rust/src/auth_commands.rs | 4358 ++++ cli-rust/src/bin/prim.rs | 3 + cli-rust/src/bin/primitive.rs | 3 + cli-rust/src/client.rs | 147 + cli-rust/src/completion_commands.rs | 486 + cli-rust/src/config.rs | 1420 + cli-rust/src/doctor_commands.rs | 626 + cli-rust/src/domains_commands.rs | 616 + cli-rust/src/emails_commands.rs | 1644 ++ cli-rust/src/friendly.rs | 1058 + cli-rust/src/functions_commands.rs | 3675 +++ cli-rust/src/help_commands.rs | 559 + cli-rust/src/help_snapshots.generated.rs | 5296 ++++ cli-rust/src/help_snapshots.rs | 1 + cli-rust/src/inbox_commands.rs | 959 + cli-rust/src/lib.rs | 210 + cli-rust/src/mail_commands.rs | 3337 +++ cli-rust/src/main.rs | 3 + cli-rust/src/manifest.rs | 410 + cli-rust/src/memories_commands.rs | 902 + cli-rust/src/operation-manifest.json | 14059 ++++++++++ cli-rust/src/org_secrets.rs | 875 + cli-rust/src/payloads.rs | 1104 + cli-rust/src/payments.rs | 1999 ++ cli-rust/src/root_startup.rs | 548 + cli-rust/src/routes_commands.rs | 868 + cli-rust/src/search_commands.rs | 781 + cli-rust/src/wake_commands.rs | 1102 + cli-rust/src/x402.rs | 782 + cli-rust/tests/agent_commands.rs | 107 + cli-rust/tests/api_commands.rs | 315 + cli-rust/tests/auth_commands.rs | 2343 ++ cli-rust/tests/broken_pipe.rs | 19 + cli-rust/tests/completion_commands.rs | 122 + cli-rust/tests/config.rs | 1731 ++ cli-rust/tests/doctor_commands.rs | 362 + cli-rust/tests/domains_commands.rs | 292 + cli-rust/tests/emails_commands.rs | 808 + cli-rust/tests/functions_commands.rs | 1431 ++ cli-rust/tests/help_commands.rs | 508 + cli-rust/tests/inbox_commands.rs | 405 + cli-rust/tests/mail_commands.rs | 2125 ++ cli-rust/tests/memories_commands.rs | 621 + cli-rust/tests/org_secrets.rs | 510 + cli-rust/tests/payloads.rs | 168 + cli-rust/tests/payloads_auth.rs | 230 + cli-rust/tests/payments.rs | 711 + cli-rust/tests/routes_commands.rs | 548 + cli-rust/tests/search_commands.rs | 288 + cli-rust/tests/wake_commands.rs | 635 + cli-rust/tests/x402.rs | 285 + docs/architecture.md | 33 +- docs/repo-model.md | 17 +- openapi/primitive-api.codegen.json | 10 +- openapi/primitive-api.yaml | 16 +- packages/api-core/package.json | 5 +- .../scripts/fix-generated-api-imports.ts | 32 +- .../scripts/generate-openapi-artifacts.ts | 116 +- packages/api-core/src/api/types.gen.ts | 16 +- packages/api-core/src/client.ts | 9 +- packages/api-core/src/index.ts | 37 +- .../api-core/src/openapi/openapi.generated.ts | 10 +- .../src/openapi/operations.generated.ts | 287 +- packages/api-core/tsconfig.json | 4 +- scripts/assert-cli-command-surface-parity.mjs | 203 + .../assert-cli-operation-request-coverage.mjs | 428 + scripts/assert-rust-cli-linux-portability.mjs | 215 + ...ssert-rust-operation-manifest-bytewise.mjs | 73 + scripts/generate-rust-cli-help-snapshots.mjs | 252 + scripts/install-rust-cli.ps1 | 174 + scripts/install-rust-cli.sh | 203 + scripts/package-rust-cli.mjs | 291 + scripts/run-cli-help-sweep.mjs | 1288 + scripts/run-cli-parity.mjs | 1407 + scripts/run-rust-cli-live-smoke.mjs | 1184 + sdk-go/api/oas_schemas_gen.go | 18 +- sdk-go/errors.go | 4 + sdk-go/events_test.go | 23 + sdk-go/webhook.go | 58 +- sdk-go/webhook_test.go | 26 +- sdk-node/README.md | 9 +- sdk-node/package.json | 6 +- sdk-node/src/contract/contract.ts | 23 +- sdk-node/src/payloads/index.ts | 56 +- sdk-node/src/webhook/errors.ts | 5 + sdk-node/src/webhook/index.ts | 54 +- sdk-node/src/x402/sign.test.ts | 41 + sdk-node/tests/api/openapi.test.ts | 39 + sdk-node/tests/contract/contract.test.ts | 109 + .../parser-contract.integration.test.ts | 80 + sdk-node/tests/payloads/client.test.ts | 59 +- sdk-node/tests/payloads/conformance.test.ts | 66 +- sdk-node/tests/webhook/events.test.ts | 54 + sdk-node/tests/webhook/index.test.ts | 10 +- .../src/primitive/api/models/account.py | 2 +- .../api/models/create_function_result.py | 6 +- .../primitive/api/models/function_detail.py | 6 +- .../api/models/function_list_item.py | 6 +- .../api/models/function_secret_list_item.py | 4 +- .../primitive/api/models/send_mail_input.py | 2 +- .../api/models/send_mail_payload_ref.py | 6 +- sdk-python/src/primitive/errors.py | 9 +- sdk-python/src/primitive/webhook.py | 47 +- sdk-python/tests/test_webhook.py | 9 +- test-fixtures/cli-parity/cases.json | 21442 ++++++++++++++++ test-fixtures/parse-webhook-event/cases.json | 17 +- .../zero-byte-conformance-vector.json | 19 + 129 files changed, 96499 insertions(+), 243 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/rust-cli-release.yml create mode 100644 cli-rust/Cargo.lock create mode 100644 cli-rust/Cargo.toml create mode 100644 cli-rust/README.md create mode 100644 cli-rust/src/agent_commands.rs create mode 100644 cli-rust/src/api.rs create mode 100644 cli-rust/src/auth_commands.rs create mode 100644 cli-rust/src/bin/prim.rs create mode 100644 cli-rust/src/bin/primitive.rs create mode 100644 cli-rust/src/client.rs create mode 100644 cli-rust/src/completion_commands.rs create mode 100644 cli-rust/src/config.rs create mode 100644 cli-rust/src/doctor_commands.rs create mode 100644 cli-rust/src/domains_commands.rs create mode 100644 cli-rust/src/emails_commands.rs create mode 100644 cli-rust/src/friendly.rs create mode 100644 cli-rust/src/functions_commands.rs create mode 100644 cli-rust/src/help_commands.rs create mode 100644 cli-rust/src/help_snapshots.generated.rs create mode 100644 cli-rust/src/help_snapshots.rs create mode 100644 cli-rust/src/inbox_commands.rs create mode 100644 cli-rust/src/lib.rs create mode 100644 cli-rust/src/mail_commands.rs create mode 100644 cli-rust/src/main.rs create mode 100644 cli-rust/src/manifest.rs create mode 100644 cli-rust/src/memories_commands.rs create mode 100644 cli-rust/src/operation-manifest.json create mode 100644 cli-rust/src/org_secrets.rs create mode 100644 cli-rust/src/payloads.rs create mode 100644 cli-rust/src/payments.rs create mode 100644 cli-rust/src/root_startup.rs create mode 100644 cli-rust/src/routes_commands.rs create mode 100644 cli-rust/src/search_commands.rs create mode 100644 cli-rust/src/wake_commands.rs create mode 100644 cli-rust/src/x402.rs create mode 100644 cli-rust/tests/agent_commands.rs create mode 100644 cli-rust/tests/api_commands.rs create mode 100644 cli-rust/tests/auth_commands.rs create mode 100644 cli-rust/tests/broken_pipe.rs create mode 100644 cli-rust/tests/completion_commands.rs create mode 100644 cli-rust/tests/config.rs create mode 100644 cli-rust/tests/doctor_commands.rs create mode 100644 cli-rust/tests/domains_commands.rs create mode 100644 cli-rust/tests/emails_commands.rs create mode 100644 cli-rust/tests/functions_commands.rs create mode 100644 cli-rust/tests/help_commands.rs create mode 100644 cli-rust/tests/inbox_commands.rs create mode 100644 cli-rust/tests/mail_commands.rs create mode 100644 cli-rust/tests/memories_commands.rs create mode 100644 cli-rust/tests/org_secrets.rs create mode 100644 cli-rust/tests/payloads.rs create mode 100644 cli-rust/tests/payloads_auth.rs create mode 100644 cli-rust/tests/payments.rs create mode 100644 cli-rust/tests/routes_commands.rs create mode 100644 cli-rust/tests/search_commands.rs create mode 100644 cli-rust/tests/wake_commands.rs create mode 100644 cli-rust/tests/x402.rs create mode 100644 scripts/assert-cli-command-surface-parity.mjs create mode 100644 scripts/assert-cli-operation-request-coverage.mjs create mode 100644 scripts/assert-rust-cli-linux-portability.mjs create mode 100644 scripts/assert-rust-operation-manifest-bytewise.mjs create mode 100644 scripts/generate-rust-cli-help-snapshots.mjs create mode 100644 scripts/install-rust-cli.ps1 create mode 100755 scripts/install-rust-cli.sh create mode 100644 scripts/package-rust-cli.mjs create mode 100644 scripts/run-cli-help-sweep.mjs create mode 100755 scripts/run-cli-parity.mjs create mode 100644 scripts/run-rust-cli-live-smoke.mjs create mode 100644 sdk-node/tests/api/openapi.test.ts create mode 100644 sdk-node/tests/parser/parser-contract.integration.test.ts create mode 100644 test-fixtures/cli-parity/cases.json create mode 100644 test-fixtures/payloads/zero-byte-conformance-vector.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..0489d2dc --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +/openapi/primitive-api.codegen.json text eol=lf +/packages/api-core/src/api/** text eol=lf +/packages/api-core/src/openapi/*.ts text eol=lf +/cli-rust/src/operation-manifest.json text eol=lf diff --git a/.github/workflows/go-release.yml b/.github/workflows/go-release.yml index 4c4ae3ff..5823fd9d 100644 --- a/.github/workflows/go-release.yml +++ b/.github/workflows/go-release.yml @@ -17,7 +17,7 @@ permissions: jobs: release: name: Publish Go SDK - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 with: diff --git a/.github/workflows/node-release.yml b/.github/workflows/node-release.yml index df152782..74fa656a 100644 --- a/.github/workflows/node-release.yml +++ b/.github/workflows/node-release.yml @@ -1,6 +1,7 @@ name: Node Release env: + COREPACK_ENABLE_AUTO_PIN: "0" FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true on: @@ -75,6 +76,10 @@ jobs: if: ${{ steps.version.outputs.changed == 'true' && steps.npm_state.outputs.published != 'true' }} run: make node-install + - name: Run Node SDK release checks + if: ${{ steps.version.outputs.changed == 'true' && steps.npm_state.outputs.published != 'true' }} + run: make node-check node-smoke + - name: Publish package to npm if: ${{ steps.version.outputs.changed == 'true' && steps.npm_state.outputs.published != 'true' }} run: scripts/npm-publish-with-retry.sh --package @primitivedotdev/sdk --version "${{ steps.version.outputs.current }}" --directory sdk-node diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index 3e1b514c..08234f11 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -19,7 +19,7 @@ permissions: jobs: release: name: Publish Python SDK - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 with: diff --git a/.github/workflows/rust-cli-release.yml b/.github/workflows/rust-cli-release.yml new file mode 100644 index 00000000..84a5908c --- /dev/null +++ b/.github/workflows/rust-cli-release.yml @@ -0,0 +1,505 @@ +name: Rust CLI Release + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +on: + workflow_dispatch: + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +permissions: + contents: read + +jobs: + version: + name: Detect Rust CLI Version + runs-on: ubuntu-24.04 + outputs: + changed: ${{ steps.version.outputs.changed }} + current: ${{ steps.version.outputs.current }} + steps: + - name: Require main for manual release + if: ${{ github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/main' }} + run: | + echo "Manual Rust CLI releases must run from main." >&2 + exit 1 + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + fetch-depth: 0 + + - name: Detect version change + id: version + run: | + current_version=$(sed -n '/^\[package\]/,/^\[/p' cli-rust/Cargo.toml | sed -n 's/^version = "\(.*\)"/\1/p' | head -n 1) + if [ -z "$current_version" ]; then + echo "Could not read cli-rust package version" >&2 + exit 1 + fi + if ! printf '%s\n' "$current_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "Rust CLI release versions must be semver without Cargo build metadata: $current_version" >&2 + exit 1 + fi + printf 'current=%s\n' "$current_version" >> "$GITHUB_OUTPUT" + + if [ "${{ github.event_name }}" = 'workflow_dispatch' ] || [ -z "${{ github.event.before }}" ]; then + printf 'changed=true\n' >> "$GITHUB_OUTPUT" + exit 0 + fi + + previous_version= + + if git cat-file -e "${{ github.event.before }}:cli-rust/Cargo.toml" 2>/dev/null; then + previous_version=$(git show "${{ github.event.before }}:cli-rust/Cargo.toml" | sed -n '/^\[package\]/,/^\[/p' | sed -n 's/^version = "\(.*\)"/\1/p' | head -n 1) + fi + + if [ "$current_version" = "$previous_version" ]; then + printf 'changed=false\n' >> "$GITHUB_OUTPUT" + exit 0 + fi + + printf 'changed=true\n' >> "$GITHUB_OUTPUT" + + build: + name: Build Rust CLI (${{ matrix.target }}) + needs: version + if: ${{ needs.version.outputs.changed == 'true' }} + runs-on: ${{ matrix.runner }} + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc + CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc + CC_aarch64_unknown_linux_musl: musl-gcc + CC_x86_64_unknown_linux_musl: musl-gcc + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + target: linux-x64 + rust_target: x86_64-unknown-linux-musl + - runner: ubuntu-24.04-arm + target: linux-arm64 + rust_target: aarch64-unknown-linux-musl + - runner: macos-15-intel + target: macos-x64 + rust_target: '' + - runner: macos-15 + target: macos-arm64 + rust_target: '' + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + fetch-depth: 0 + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 + with: + version: 10 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install Rust toolchain + run: | + rustup toolchain install stable --profile minimal --component rustfmt --component clippy + rustup default stable + if [ -n "${{ matrix.rust_target }}" ]; then + rustup target add "${{ matrix.rust_target }}" + fi + + - name: Install Linux musl tools + if: ${{ startsWith(matrix.target, 'linux-') }} + run: sudo apt-get update && sudo apt-get install -y musl-tools + + - name: Check Linux musl runner architecture + if: ${{ startsWith(matrix.target, 'linux-') }} + env: + RUST_CLI_TARGET: ${{ matrix.target }} + run: | + case "$RUST_CLI_TARGET:$(uname -m)" in + linux-x64:x86_64|linux-arm64:aarch64) ;; + *) + echo "Target $RUST_CLI_TARGET must run on its native Linux architecture when using musl-gcc." >&2 + exit 1 + ;; + esac + + - name: Install dependencies + run: make cli-install + + - name: Run Rust CLI release checks + run: | + export RUST_CLI_CARGO_TARGET="${{ matrix.rust_target }}" + make rust-cli-script-check rust-cli-release-platform-check + + - name: Build release asset + run: | + export RUST_CLI_CARGO_TARGET="${{ matrix.rust_target }}" + if [ -n "$RUST_CLI_CARGO_TARGET" ]; then + export RUST_CLI_DIST_BIN_DIR="$PWD/cli-rust/target/$RUST_CLI_CARGO_TARGET/release" + fi + rm -rf cli-rust/dist + make rust-cli-dist RUST_CLI_DIST_VERSION="${{ needs.version.outputs.current }}" RUST_CLI_DIST_TARGET="${{ matrix.target }}" + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + if-no-files-found: error + name: rust-cli-${{ matrix.target }} + path: cli-rust/dist/* + + build-windows: + name: Build Rust CLI (windows-x64) + needs: version + if: ${{ needs.version.outputs.changed == 'true' }} + runs-on: windows-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + fetch-depth: 0 + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 + with: + version: 10 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install Rust toolchain + run: | + rustup toolchain install stable --profile minimal --component rustfmt --component clippy + rustup default stable + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate Rust CLI artifacts + run: | + $env:COREPACK_ENABLE_AUTO_PIN = "0" + pnpm --dir packages/api-core generate + + - name: Check generated Rust CLI artifacts + run: | + node scripts/assert-rust-operation-manifest-bytewise.mjs + $paths = @("openapi/primitive-api.codegen.json", "packages/api-core/src/api", "packages/api-core/src/openapi/openapi.generated.ts", "packages/api-core/src/openapi/operations.generated.ts", "cli-rust/src/operation-manifest.json") + git diff --exit-code -- $paths + $status = git status --porcelain -- $paths + if ($status) { + $status + exit 1 + } + + - name: Run Rust CLI checks + run: | + cargo fmt --manifest-path cli-rust/Cargo.toml --check + cargo check --manifest-path cli-rust/Cargo.toml --locked + cargo clippy --manifest-path cli-rust/Cargo.toml --locked --all-targets -- -D warnings + $env:RUSTDOCFLAGS = "-D warnings" + cargo doc --manifest-path cli-rust/Cargo.toml --locked --no-deps + cargo test --manifest-path cli-rust/Cargo.toml --locked + + - name: Package Rust CLI crate + run: cargo package --manifest-path cli-rust/Cargo.toml --locked --allow-dirty + + - name: Build release binaries + run: cargo build --release --manifest-path cli-rust/Cargo.toml --locked + + - name: Run local-only Rust CLI live-smoke harness + run: node scripts/run-rust-cli-live-smoke.mjs --rust-bin "cli-rust/target/release/primitive.exe" --no-key-only + + - name: Run Windows Node/Rust CLI parity + run: | + pnpm config set script-shell "C:\Program Files\Git\bin\bash.exe" + pnpm --dir cli-node build + $nodeEntryPath = (Resolve-Path "cli-node/bin/run.js").Path + $nodeEntry = ($nodeEntryPath -replace "\\", "/") + $nodeEntryUrl = ([System.Uri]$nodeEntryPath).AbsoluteUri + $nodePrimEntry = Join-Path $env:RUNNER_TEMP "prim" + Set-Content -NoNewline -Path $nodePrimEntry -Value "import '$nodeEntryUrl';`n" + $nodePrimEntry = ($nodePrimEntry -replace "\\", "/") + $rustBin = ((Resolve-Path "cli-rust/target/release/primitive.exe").Path -replace "\\", "/") + $rustPrimBin = ((Resolve-Path "cli-rust/target/release/prim.exe").Path -replace "\\", "/") + node scripts/run-cli-help-sweep.mjs --compare-flags --compare-copy --node-bin "node $nodeEntry" --rust-bin "$rustBin" + node scripts/run-cli-parity.mjs --node-bin "node $nodeEntry" --rust-bin "$rustBin" + node scripts/run-cli-help-sweep.mjs --compare-flags --compare-copy --node-bin "node $nodePrimEntry" --rust-bin "$rustPrimBin" + node scripts/run-cli-parity.mjs --node-bin "node $nodePrimEntry" --rust-bin "$rustPrimBin" + + - name: Validate Rust CLI scripts + run: | + node --check scripts/assert-rust-operation-manifest-bytewise.mjs + node --check scripts/package-rust-cli.mjs + $null = [scriptblock]::Create((Get-Content -Raw scripts/install-rust-cli.ps1)) + + - name: Build release asset + run: | + Remove-Item -Recurse -Force cli-rust/dist -ErrorAction SilentlyContinue + node scripts/package-rust-cli.mjs --version "${{ needs.version.outputs.current }}" --target "windows-x64" --archive-format zip --out-dir "cli-rust/dist" + + - name: Smoke test Windows installer + run: | + $installDir = Join-Path $env:RUNNER_TEMP "primitive-rust-cli-bin" + ./scripts/install-rust-cli.ps1 -Version "${{ needs.version.outputs.current }}" -Target "windows-x64" -BaseUrl "cli-rust/dist" -InstallDir $installDir + & (Join-Path $installDir "primitive.exe") --version | Select-String "primitive/" + & (Join-Path $installDir "prim.exe") --version | Select-String "prim/" + & (Join-Path $installDir "primitive.exe") list-operations | Select-String '"operationId": "getAccount"' + & (Join-Path $installDir "prim.exe") list-operations | Select-String '"operationId": "getAccount"' + & (Join-Path $installDir "prim.exe") describe sending:send-email | Select-String '"operationId": "sendEmail"' + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + if-no-files-found: error + name: rust-cli-windows-x64 + path: cli-rust/dist/* + + live-smoke: + name: Live Smoke Rust CLI Release Artifact + needs: + - version + - build + if: ${{ needs.version.outputs.changed == 'true' }} + runs-on: ubuntu-24.04 + env: + PRIMITIVE_API_KEY: ${{ secrets.PRIMITIVE_API_KEY }} + PRIMITIVE_LIVE_SMOKE_SHOW_OUTPUT: "0" + PRIMITIVE_RUST_LIVE_INCLUDE_EMAIL_E2E: "0" + PRIMITIVE_RUST_LIVE_INCLUDE_MUTATING: "0" + PRIMITIVE_RUST_LIVE_INCLUDE_SECRET_ARGV: "0" + PRIMITIVE_RUST_LIVE_NO_KEY_ONLY: "0" + VERSION: ${{ needs.version.outputs.current }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + fetch-depth: 0 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: 24 + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: rust-cli-linux-x64 + path: cli-rust/live-smoke-artifact + + - name: Check live API key + run: | + if [ -z "$PRIMITIVE_API_KEY" ]; then + echo "PRIMITIVE_API_KEY repository secret is required for Rust CLI release live smoke." + exit 2 + fi + + - name: Run release artifact live smoke + run: | + extract_dir="$RUNNER_TEMP/rust-cli-live-smoke" + mkdir -p "$extract_dir" + tar -C "$extract_dir" -xzf "cli-rust/live-smoke-artifact/primitive-rust-cli-v$VERSION-linux-x64.tar.gz" + "$extract_dir/primitive" --version | grep -q -- 'primitive/' + node scripts/run-rust-cli-live-smoke.mjs --rust-bin "$extract_dir/primitive" + + release: + name: Publish Rust CLI + needs: + - version + - build + - build-windows + - live-smoke + if: ${{ needs.version.outputs.changed == 'true' }} + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + fetch-depth: 0 + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + merge-multiple: true + path: cli-rust/release-assets + pattern: rust-cli-* + + - name: Validate release tag target + env: + GH_TOKEN: ${{ github.token }} + TAG: cli-rust-v${{ needs.version.outputs.current }} + run: | + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + tag_sha=$(git rev-list -n 1 "$TAG") + if [ "$tag_sha" != "$GITHUB_SHA" ]; then + echo "Release tag $TAG points at $tag_sha, expected $GITHUB_SHA" >&2 + exit 1 + fi + fi + + - name: Verify release asset set + env: + VERSION: ${{ needs.version.outputs.current }} + run: | + expected_assets=" + primitive-rust-cli-v$VERSION-linux-x64.tar.gz + primitive-rust-cli-v$VERSION-linux-x64.tar.gz.sha256 + primitive-rust-cli-v$VERSION-linux-arm64.tar.gz + primitive-rust-cli-v$VERSION-linux-arm64.tar.gz.sha256 + primitive-rust-cli-v$VERSION-macos-x64.tar.gz + primitive-rust-cli-v$VERSION-macos-x64.tar.gz.sha256 + primitive-rust-cli-v$VERSION-macos-arm64.tar.gz + primitive-rust-cli-v$VERSION-macos-arm64.tar.gz.sha256 + primitive-rust-cli-v$VERSION-windows-x64.zip + primitive-rust-cli-v$VERSION-windows-x64.zip.sha256 + " + for asset in $expected_assets; do + test -f "cli-rust/release-assets/$asset" || { + echo "Missing release asset: $asset" + exit 1 + } + done + + actual_count=$(find cli-rust/release-assets -maxdepth 1 -type f | wc -l | tr -d ' ') + if [ "$actual_count" != "10" ]; then + find cli-rust/release-assets -maxdepth 1 -type f -print + echo "Expected exactly 10 release asset files, found $actual_count" + exit 1 + fi + + ( + cd cli-rust/release-assets + for checksum in *.sha256; do + sha256sum -c "$checksum" + done + ) + + - name: Create draft GitHub release and tag + env: + GH_TOKEN: ${{ github.token }} + TAG: cli-rust-v${{ needs.version.outputs.current }} + VERSION: ${{ needs.version.outputs.current }} + run: | + if gh release view "$TAG" >/dev/null 2>&1; then + echo "GitHub release already exists: $TAG" + else + gh release create "$TAG" \ + --target "$GITHUB_SHA" \ + --title "Rust CLI v$VERSION" \ + --draft \ + --generate-notes + fi + + - name: Upload and verify release assets + env: + GH_TOKEN: ${{ github.token }} + TAG: cli-rust-v${{ needs.version.outputs.current }} + VERSION: ${{ needs.version.outputs.current }} + run: | + expected_assets=" + primitive-rust-cli-v$VERSION-linux-x64.tar.gz + primitive-rust-cli-v$VERSION-linux-x64.tar.gz.sha256 + primitive-rust-cli-v$VERSION-linux-arm64.tar.gz + primitive-rust-cli-v$VERSION-linux-arm64.tar.gz.sha256 + primitive-rust-cli-v$VERSION-macos-x64.tar.gz + primitive-rust-cli-v$VERSION-macos-x64.tar.gz.sha256 + primitive-rust-cli-v$VERSION-macos-arm64.tar.gz + primitive-rust-cli-v$VERSION-macos-arm64.tar.gz.sha256 + primitive-rust-cli-v$VERSION-windows-x64.zip + primitive-rust-cli-v$VERSION-windows-x64.zip.sha256 + " + release_is_draft=$(gh release view "$TAG" --json isDraft --jq '.isDraft') + remote_assets=$(mktemp) + gh release view "$TAG" --json assets --jq '.assets[].name' > "$remote_assets" + + verify_remote_asset() { + asset="$1" + remote_dir=$(mktemp -d) + gh release download "$TAG" --pattern "$asset" --dir "$remote_dir" + if ! cmp -s "cli-rust/release-assets/$asset" "$remote_dir/$asset"; then + echo "Existing release asset does not match local artifact: $asset" >&2 + exit 1 + fi + } + + while IFS= read -r remote_asset; do + if [ -z "$remote_asset" ]; then + continue + fi + found=false + for expected in $expected_assets; do + if [ "$remote_asset" = "$expected" ]; then + found=true + break + fi + done + if [ "$found" != "true" ]; then + echo "Unexpected existing release asset: $remote_asset" >&2 + exit 1 + fi + verify_remote_asset "$remote_asset" + done < "$remote_assets" + + for asset in $expected_assets; do + if grep -Fxq "$asset" "$remote_assets"; then + echo "Release asset already present and verified: $asset" + else + if [ "$release_is_draft" != "true" ]; then + echo "Refusing to add missing asset to already public release: $asset" >&2 + exit 1 + fi + gh release upload "$TAG" "cli-rust/release-assets/$asset" + fi + done + + final_assets=$(mktemp) + gh release view "$TAG" --json assets --jq '.assets[].name' > "$final_assets" + final_count=$(wc -l < "$final_assets" | tr -d ' ') + if [ "$final_count" != "10" ]; then + cat "$final_assets" + echo "Expected exactly 10 remote release assets, found $final_count" >&2 + exit 1 + fi + for asset in $expected_assets; do + if ! grep -Fxq "$asset" "$final_assets"; then + echo "Missing remote release asset after upload: $asset" >&2 + exit 1 + fi + verify_remote_asset "$asset" + done + + - name: Publish verified GitHub release + env: + GH_TOKEN: ${{ github.token }} + TAG: cli-rust-v${{ needs.version.outputs.current }} + run: | + if [ "$(gh release view "$TAG" --json isDraft --jq '.isDraft')" = "true" ]; then + gh release edit "$TAG" --draft=false + else + echo "GitHub release is already public and assets are verified: $TAG" + fi + + - name: Smoke test default installer URL + env: + VERSION: ${{ needs.version.outputs.current }} + run: | + install_dir="$RUNNER_TEMP/primitive-rust-cli-default-url" + scripts/install-rust-cli.sh --version "$VERSION" --target linux-x64 --install-dir "$install_dir" + "$install_dir/primitive" --version | grep -q -- 'primitive/' + "$install_dir/prim" list-operations | grep -q -- '"operationId": "getAccount"' + + windows-default-installer-smoke: + name: Windows Default Installer Smoke + needs: + - version + - release + if: ${{ needs.version.outputs.changed == 'true' }} + runs-on: windows-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + + - name: Smoke test PowerShell default installer URL + shell: pwsh + run: | + $installDir = Join-Path $env:RUNNER_TEMP "primitive-rust-cli-default-url" + ./scripts/install-rust-cli.ps1 -Version "${{ needs.version.outputs.current }}" -Target "windows-x64" -InstallDir $installDir + & (Join-Path $installDir "primitive.exe") --version | Select-String "primitive/" + & (Join-Path $installDir "prim.exe") list-operations | Select-String '"operationId": "getAccount"' diff --git a/.github/workflows/sdk-checks.yml b/.github/workflows/sdk-checks.yml index 703a71a6..7bc71fd3 100644 --- a/.github/workflows/sdk-checks.yml +++ b/.github/workflows/sdk-checks.yml @@ -10,17 +10,24 @@ on: pull_request: merge_group: workflow_dispatch: + inputs: + run_rust_cli_live_smoke: + description: "Run Rust CLI live smoke with the PRIMITIVE_API_KEY secret on main" + required: false + type: boolean + default: false jobs: changes: name: Detect Changed SDKs - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 permissions: contents: read pull-requests: read outputs: node: ${{ steps.full.outputs.node || steps.filter.outputs.node }} cli: ${{ steps.full.outputs.cli || steps.filter.outputs.cli }} + rust: ${{ steps.full.outputs.rust || steps.filter.outputs.rust }} python: ${{ steps.full.outputs.python || steps.filter.outputs.python }} go: ${{ steps.full.outputs.go || steps.filter.outputs.go }} shared: ${{ steps.full.outputs.shared || steps.filter.outputs.shared }} @@ -32,6 +39,7 @@ jobs: run: | printf 'node=true\n' >> "$GITHUB_OUTPUT" printf 'cli=true\n' >> "$GITHUB_OUTPUT" + printf 'rust=true\n' >> "$GITHUB_OUTPUT" printf 'python=true\n' >> "$GITHUB_OUTPUT" printf 'go=true\n' >> "$GITHUB_OUTPUT" printf 'shared=true\n' >> "$GITHUB_OUTPUT" @@ -53,6 +61,25 @@ jobs: - 'openapi/**' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' + rust: + - 'cli-rust/**' + - 'cli-node/**' + - 'packages/api-core/**' + - 'openapi/**' + - 'scripts/assert-cli-command-surface-parity.mjs' + - 'scripts/assert-cli-operation-request-coverage.mjs' + - 'scripts/assert-rust-cli-linux-portability.mjs' + - 'scripts/assert-rust-operation-manifest-bytewise.mjs' + - 'scripts/install-rust-cli.ps1' + - 'scripts/install-rust-cli.sh' + - 'scripts/package-rust-cli.mjs' + - 'scripts/run-cli-help-sweep.mjs' + - 'scripts/run-cli-parity.mjs' + - 'scripts/run-rust-cli-live-smoke.mjs' + - 'test-fixtures/cli-parity/**' + - 'Makefile' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' python: - 'sdk-python/**' - 'openapi/**' @@ -70,7 +97,7 @@ jobs: name: Node (${{ matrix.node-version }}) needs: changes if: ${{ needs.changes.outputs.node == 'true' || needs.changes.outputs.shared == 'true' }} - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: @@ -110,7 +137,7 @@ jobs: name: CLI (${{ matrix.node-version }}) needs: changes if: ${{ needs.changes.outputs.cli == 'true' || needs.changes.outputs.shared == 'true' }} - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: @@ -146,11 +173,262 @@ jobs: - name: Collect cli coverage run: make cli-coverage + rust: + name: Rust CLI + needs: changes + if: ${{ needs.changes.outputs.rust == 'true' || needs.changes.outputs.cli == 'true' || needs.changes.outputs.shared == 'true' }} + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 + with: + version: 10 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install Rust toolchain + run: | + rustup toolchain install stable --profile minimal --component rustfmt --component clippy + rustup default stable + + - name: Install node dependencies + run: make cli-install + + - name: Run rust CLI checks + run: make rust-cli-ci-check + + rust-platform: + name: Rust CLI Platform (${{ matrix.target }}) + needs: changes + if: ${{ needs.changes.outputs.rust == 'true' || needs.changes.outputs.cli == 'true' || needs.changes.outputs.shared == 'true' }} + runs-on: ${{ matrix.runner }} + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc + CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc + CC_aarch64_unknown_linux_musl: musl-gcc + CC_x86_64_unknown_linux_musl: musl-gcc + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + target: linux-x64 + rust_target: x86_64-unknown-linux-musl + - runner: ubuntu-24.04-arm + target: linux-arm64 + rust_target: aarch64-unknown-linux-musl + - runner: macos-15-intel + target: macos-x64 + rust_target: '' + - runner: macos-15 + target: macos-arm64 + rust_target: '' + - runner: windows-latest + target: windows-x64 + rust_target: '' + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 + with: + version: 10 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install Rust toolchain + shell: bash + run: | + rustup toolchain install stable --profile minimal + rustup default stable + if [ -n "${{ matrix.rust_target }}" ]; then + rustup target add "${{ matrix.rust_target }}" + fi + + - name: Install Linux musl tools + if: ${{ startsWith(matrix.target, 'linux-') }} + run: sudo apt-get update && sudo apt-get install -y musl-tools + + - name: Check Linux musl runner architecture + if: ${{ startsWith(matrix.target, 'linux-') }} + shell: bash + env: + RUST_CLI_TARGET: ${{ matrix.target }} + run: | + case "$RUST_CLI_TARGET:$(uname -m)" in + linux-x64:x86_64|linux-arm64:aarch64) ;; + *) + echo "Target $RUST_CLI_TARGET must run on its native Linux architecture when using musl-gcc." >&2 + exit 1 + ;; + esac + + - name: Install node dependencies + run: pnpm install --frozen-lockfile + + - name: Generate Rust CLI operation manifest + env: + COREPACK_ENABLE_AUTO_PIN: "0" + run: pnpm --dir packages/api-core generate + + - name: Check generated Rust CLI artifacts + shell: bash + run: | + node scripts/assert-rust-operation-manifest-bytewise.mjs + paths=( + openapi/primitive-api.codegen.json + packages/api-core/src/api + packages/api-core/src/openapi/openapi.generated.ts + packages/api-core/src/openapi/operations.generated.ts + cli-rust/src/operation-manifest.json + ) + git diff --exit-code -- "${paths[@]}" + test -z "$(git status --porcelain -- "${paths[@]}")" + + - name: Build and test Rust CLI + env: + RUSTDOCFLAGS: -D warnings + run: | + cargo check --manifest-path cli-rust/Cargo.toml --locked + cargo doc --manifest-path cli-rust/Cargo.toml --locked --no-deps + cargo test --manifest-path cli-rust/Cargo.toml --locked + cargo build --release --manifest-path cli-rust/Cargo.toml --locked + + - name: Build Linux musl release binary + if: ${{ startsWith(matrix.target, 'linux-') }} + run: cargo build --release --manifest-path cli-rust/Cargo.toml --locked --target "${{ matrix.rust_target }}" + + - name: Check Linux binary portability + if: ${{ startsWith(matrix.target, 'linux-') }} + run: node scripts/assert-rust-cli-linux-portability.mjs --bin "cli-rust/target/${{ matrix.rust_target }}/release/primitive" --expect-static + + - name: Run Unix Node/Rust CLI parity + if: ${{ runner.os != 'Windows' }} + shell: bash + run: | + pnpm --dir cli-node build + rust_bin="cli-rust/target/release/primitive" + if [ -n "${{ matrix.rust_target }}" ]; then + rust_bin="cli-rust/target/${{ matrix.rust_target }}/release/primitive" + fi + rust_prim_bin="${rust_bin%/primitive}/prim" + node_prim_bin="$RUNNER_TEMP/prim" + ln -s "$PWD/cli-node/bin/run.js" "$node_prim_bin" + node scripts/run-cli-help-sweep.mjs --compare-flags --compare-copy --node-bin "node cli-node/bin/run.js" --rust-bin "$rust_bin" + node scripts/run-cli-parity.mjs --node-bin "node cli-node/bin/run.js" --rust-bin "$rust_bin" + node scripts/run-cli-help-sweep.mjs --compare-flags --compare-copy --node-bin "node $node_prim_bin" --rust-bin "$rust_prim_bin" + node scripts/run-cli-parity.mjs --node-bin "node $node_prim_bin" --rust-bin "$rust_prim_bin" + + - name: Run Windows Node/Rust CLI parity + if: ${{ runner.os == 'Windows' }} + shell: pwsh + run: | + pnpm config set script-shell "C:\Program Files\Git\bin\bash.exe" + pnpm --dir cli-node build + $nodeEntryPath = (Resolve-Path "cli-node/bin/run.js").Path + $nodeEntry = ($nodeEntryPath -replace "\\", "/") + $nodeEntryUrl = ([System.Uri]$nodeEntryPath).AbsoluteUri + $nodePrimEntry = Join-Path $env:RUNNER_TEMP "prim" + Set-Content -NoNewline -Path $nodePrimEntry -Value "import '$nodeEntryUrl';`n" + $nodePrimEntry = ($nodePrimEntry -replace "\\", "/") + $rustBin = ((Resolve-Path "cli-rust/target/release/primitive.exe").Path -replace "\\", "/") + $rustPrimBin = ((Resolve-Path "cli-rust/target/release/prim.exe").Path -replace "\\", "/") + node scripts/run-cli-help-sweep.mjs --compare-flags --compare-copy --node-bin "node $nodeEntry" --rust-bin "$rustBin" + node scripts/run-cli-parity.mjs --node-bin "node $nodeEntry" --rust-bin "$rustBin" + node scripts/run-cli-help-sweep.mjs --compare-flags --compare-copy --node-bin "node $nodePrimEntry" --rust-bin "$rustPrimBin" + node scripts/run-cli-parity.mjs --node-bin "node $nodePrimEntry" --rust-bin "$rustPrimBin" + + - name: Package and install Unix archive + if: ${{ runner.os != 'Windows' }} + shell: bash + run: | + dist_dir="$RUNNER_TEMP/rust-cli-dist" + install_dir="$RUNNER_TEMP/rust-cli-bin" + package_args=(--target "${{ matrix.target }}" --out-dir "$dist_dir" --json) + if [ -n "${{ matrix.rust_target }}" ]; then + package_args+=(--bin-dir "cli-rust/target/${{ matrix.rust_target }}/release") + fi + node scripts/package-rust-cli.mjs "${package_args[@]}" > "$RUNNER_TEMP/rust-cli-package.json" + version=$(node -e "const fs=require('fs'); process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1], 'utf8')).version);" "$RUNNER_TEMP/rust-cli-package.json") + scripts/install-rust-cli.sh --version "$version" --target "${{ matrix.target }}" --base-url "file://$dist_dir" --install-dir "$install_dir" + "$install_dir/primitive" --version | grep -q -- 'primitive/' + "$install_dir/prim" --version | grep -q -- 'prim/' + "$install_dir/primitive" list-operations | grep -q -- '"operationId": "getAccount"' + "$install_dir/prim" list-operations | grep -q -- '"operationId": "getAccount"' + "$install_dir/prim" describe sending:send-email | grep -q -- '"operationId": "sendEmail"' + + - name: Package and install Windows archive + if: ${{ runner.os == 'Windows' }} + shell: pwsh + run: | + $distDir = Join-Path $env:RUNNER_TEMP "rust-cli-dist" + $installDir = Join-Path $env:RUNNER_TEMP "rust-cli-bin" + New-Item -ItemType Directory -Path $distDir -Force | Out-Null + node scripts/package-rust-cli.mjs --target "${{ matrix.target }}" --archive-format zip --out-dir "$distDir" --json | Set-Content -LiteralPath (Join-Path $env:RUNNER_TEMP "rust-cli-package.json") + $package = Get-Content -Raw -LiteralPath (Join-Path $env:RUNNER_TEMP "rust-cli-package.json") | ConvertFrom-Json + ./scripts/install-rust-cli.ps1 -Version $package.version -Target "${{ matrix.target }}" -BaseUrl $distDir -InstallDir $installDir + & (Join-Path $installDir "primitive.exe") --version | Select-String "primitive/" + & (Join-Path $installDir "prim.exe") --version | Select-String "prim/" + & (Join-Path $installDir "primitive.exe") list-operations | Select-String '"operationId": "getAccount"' + & (Join-Path $installDir "prim.exe") list-operations | Select-String '"operationId": "getAccount"' + & (Join-Path $installDir "prim.exe") describe sending:send-email | Select-String '"operationId": "sendEmail"' + + rust-live-smoke: + name: Rust CLI Live Smoke + needs: rust + if: ${{ github.event_name == 'workflow_dispatch' && inputs.run_rust_cli_live_smoke && github.ref == 'refs/heads/main' }} + runs-on: ubuntu-24.04 + env: + PRIMITIVE_API_KEY: ${{ secrets.PRIMITIVE_API_KEY }} + PRIMITIVE_LIVE_SMOKE_SHOW_OUTPUT: "0" + PRIMITIVE_RUST_LIVE_INCLUDE_EMAIL_E2E: "0" + PRIMITIVE_RUST_LIVE_INCLUDE_MUTATING: "0" + PRIMITIVE_RUST_LIVE_INCLUDE_SECRET_ARGV: "0" + PRIMITIVE_RUST_LIVE_NO_KEY_ONLY: "0" + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 + with: + version: 10 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install Rust toolchain + run: | + rustup toolchain install stable --profile minimal + rustup default stable + + - name: Install node dependencies + run: make cli-install + + - name: Check live API key + run: | + if [ -z "$PRIMITIVE_API_KEY" ]; then + echo "PRIMITIVE_API_KEY repository secret is required for Rust CLI live smoke." + exit 2 + fi + + - name: Run rust CLI live smoke + run: make rust-cli-live-smoke + python: name: Python (${{ matrix.python-version }}) needs: changes if: ${{ needs.changes.outputs.python == 'true' || needs.changes.outputs.shared == 'true' }} - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: @@ -181,7 +459,7 @@ jobs: name: Go (${{ matrix.go-version }}) needs: changes if: ${{ needs.changes.outputs.go == 'true' || needs.changes.outputs.shared == 'true' }} - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: @@ -211,7 +489,7 @@ jobs: name: Shared needs: changes if: ${{ needs.changes.outputs.shared == 'true' }} - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 @@ -246,13 +524,13 @@ jobs: ci: name: CI - needs: [changes, node, cli, python, go, shared] + needs: [changes, node, cli, rust, rust-platform, rust-live-smoke, python, go, shared] if: always() runs-on: ubuntu-latest steps: - name: Check results run: | - results=("${{ needs.changes.result }}" "${{ needs.node.result }}" "${{ needs.cli.result }}" "${{ needs.python.result }}" "${{ needs.go.result }}" "${{ needs.shared.result }}") + results=("${{ needs.changes.result }}" "${{ needs.node.result }}" "${{ needs.cli.result }}" "${{ needs.rust.result }}" "${{ needs.rust-platform.result }}" "${{ needs.rust-live-smoke.result }}" "${{ needs.python.result }}" "${{ needs.go.result }}" "${{ needs.shared.result }}") for r in "${results[@]}"; do if [[ "$r" != "success" && "$r" != "skipped" ]]; then echo "Job failed with result: $r" diff --git a/.gitignore b/.gitignore index eba40ab0..3eab0b5f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ /flake.nix /flake.lock node_modules/ +cli-rust/target/ +cli-rust/dist/ +cli-rust/release-assets/ sdk-node/node_modules/ sdk-node/dist/ sdk-node/*.tsbuildinfo @@ -17,4 +20,4 @@ sdk-python/**/*.pyc sdk-python/**/__pycache__/ .internal/ .vite/ -dist/ \ No newline at end of file +dist/ diff --git a/AGENTS.md b/AGENTS.md index 97b3f826..111dfca6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,7 @@ make node-generate python-generate go-generate # Run the per-language full check (includes generated-file-sync gate) make node-check # @primitivedotdev/sdk + packages/api-core make cli-check # primitive +make rust-cli-check # Rust CLI port make python-check # primitivedotdev (PyPI) make go-check # sdk-go @@ -57,11 +58,14 @@ make shared-check # Build artifacts make node-build cli-build python-build go-build +# Optional Rust CLI port checks +make rust-cli-full-check + # Convenience aggregator -make check # runs all of the above except the smokes +make check # runs the default SDK + Node CLI checks ``` -Before pushing any branch, the minimum is `make node-check cli-check go-check shared-check`. If you touched the JSON schema OR the OpenAPI spec, also run `make python-check`. +Before pushing any branch, the minimum is `make node-check cli-check go-check shared-check`. If you touched the optional Rust CLI port, also run `make rust-cli-full-check`. If you touched the JSON schema OR the OpenAPI spec, also run `make python-check`. ## Generated files are committed diff --git a/CLAUDE.md b/CLAUDE.md index 593971bd..903f8ea4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,7 @@ make node-generate python-generate go-generate # Run all checks (lint, typecheck, tests, generated-file-sync) make node-check +make cli-check make python-check make go-check @@ -26,7 +27,7 @@ make go-build make python-build ``` -At minimum, run `make node-check cli-check go-check shared-check` before every push. If you changed the JSON schema, you MUST also run `make python-check`. +At minimum, run `make node-check cli-check go-check shared-check` before every push. If you changed the optional Rust CLI port, also run `make rust-cli-full-check`. If you changed the JSON schema, you MUST also run `make python-check`. `cli-node/` is the `primitive` package; it has its own `pnpm-lock.yaml` and `make cli-*` targets. It depends on `@primitivedotdev/sdk` at the published npm version; local-dev cross-package changes that require a new SDK surface need a coordinated release. diff --git a/Makefile b/Makefile index 1b2bd2e0..6fa1eb7d 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,16 @@ -.PHONY: node-install node-generate node-check-generated node-test node-check node-build node-smoke node-tarball-isolation node-coverage -.PHONY: cli-install cli-test cli-check cli-build cli-smoke cli-tarball-isolation cli-coverage +.PHONY: api-core-check node-install node-generate node-check-generated node-test node-check node-build node-smoke node-tarball-isolation node-coverage +.PHONY: cli-install cli-test cli-check cli-build cli-smoke cli-tarball-isolation cli-coverage rust-cli-generate rust-cli-manifest-bytewise rust-cli-help-snapshots-bytewise rust-cli-check-generated rust-cli-check rust-cli-doc rust-cli-package rust-cli-build rust-cli-release-build rust-cli-dist rust-cli-smoke rust-cli-release-smoke rust-cli-linux-portability-smoke rust-cli-archive-smoke rust-cli-windows-archive-smoke rust-cli-install-smoke rust-cli-live-smoke rust-cli-live-smoke-no-key rust-cli-script-check rust-cli-release-platform-check rust-cli-full-check rust-cli-ci-check cli-command-surface-parity cli-operation-coverage cli-help-parity cli-archive-parity cli-parity .PHONY: python-sync python-generate python-check-generated python-test python-check python-build python-smoke python-coverage .PHONY: go-generate go-check-generated go-check go-build go-coverage .PHONY: shared-check check build release-check ci PYTHON := $(shell if command -v python3 >/dev/null 2>&1; then printf python3; else printf python; fi) +export COREPACK_ENABLE_AUTO_PIN := 0 +NODE_GENERATED_PATHS := openapi/primitive-api.codegen.json sdk-node/src/schema.generated.ts sdk-node/src/types.generated.ts sdk-node/src/generated/email-received-event.validator.generated.ts packages/api-core/src/api packages/api-core/src/openapi/openapi.generated.ts packages/api-core/src/openapi/operations.generated.ts +RUST_CLI_GENERATED_PATHS := openapi/primitive-api.codegen.json packages/api-core/src/api packages/api-core/src/openapi/openapi.generated.ts packages/api-core/src/openapi/operations.generated.ts cli-rust/src/operation-manifest.json cli-rust/src/help_snapshots.generated.rs + +api-core-check: + pnpm --dir packages/api-core check node-install: pnpm install --frozen-lockfile @@ -13,13 +19,13 @@ node-generate: pnpm --filter @primitivedotdev/sdk generate node-check-generated: - pnpm --filter @primitivedotdev/sdk generate && git diff --exit-code -- openapi/primitive-api.codegen.json sdk-node/src/schema.generated.ts sdk-node/src/types.generated.ts sdk-node/src/generated/email-received-event.validator.generated.ts packages/api-core/src/api packages/api-core/src/openapi/openapi.generated.ts packages/api-core/src/openapi/operations.generated.ts + pnpm --filter @primitivedotdev/sdk generate && git diff --exit-code -- $(NODE_GENERATED_PATHS) && test -z "$$(git status --porcelain -- $(NODE_GENERATED_PATHS))" node-test: pnpm --dir sdk-node test -node-check: node-check-generated - if command -v biome >/dev/null 2>&1; then cd sdk-node && biome check --error-on-warnings src/index.ts src/validation.ts src/types.ts src/webhook src/contract src/parser src/api/index.ts src/openapi/index.ts src/payloads tests/; else pnpm --dir sdk-node lint; fi +node-check: node-check-generated api-core-check + if command -v biome >/dev/null 2>&1; then cd sdk-node && biome check --error-on-warnings src/index.ts src/validation.ts src/types.ts src/webhook src/contract src/parser src/api/index.ts src/x402 src/openapi/index.ts src/payloads tests/; else pnpm --dir sdk-node lint; fi pnpm --dir sdk-node typecheck $(MAKE) node-test @@ -30,7 +36,15 @@ node-tarball-isolation: node scripts/assert-tarball-isolation.mjs sdk-node "primitive" "@primitivedotdev/cli" node-smoke: node-build node-tarball-isolation - pack_dir=$$(mktemp -d) && smoke_dir=$$(mktemp -d) && tarball=$$(cd sdk-node && npm pack --silent --pack-destination "$$pack_dir" | node -e "let data=''; process.stdin.on('data', chunk => data += chunk); process.stdin.on('end', () => { const matches = data.match(/[A-Za-z0-9._-]+\.tgz/g); if (!matches || matches.length === 0) { throw new Error('could not locate tarball name in npm pack output'); } process.stdout.write(matches[matches.length - 1]); });") && cd "$$smoke_dir" && npm init -y && npm install "$$pack_dir/$$tarball" && node --input-type=module -e "const root = await import('@primitivedotdev/sdk'); const webhook = await import('@primitivedotdev/sdk/webhook'); const api = await import('@primitivedotdev/sdk/api'); const openapi = await import('@primitivedotdev/sdk/openapi'); const contract = await import('@primitivedotdev/sdk/contract'); const parser = await import('@primitivedotdev/sdk/parser'); if (typeof root.handleWebhook !== 'function') throw new Error('missing root handleWebhook export'); if (typeof webhook.handleWebhook !== 'function') throw new Error('missing webhook handleWebhook export'); if (typeof api.createPrimitiveApiClient !== 'function') throw new Error('missing api client factory'); if (typeof openapi.openapiDocument !== 'object') throw new Error('missing openapi document export'); if (typeof contract.buildEmailReceivedEvent !== 'function') throw new Error('missing contract buildEmailReceivedEvent export'); if (typeof parser.parseEmail !== 'function') throw new Error('missing parser parseEmail export');" && if [ -e "$$smoke_dir/node_modules/.bin/primitive" ]; then echo "SDK tarball must not install a primitive bin (the CLI is the separate primitive package)"; exit 1; fi && if [ -d "$$smoke_dir/node_modules/@primitivedotdev/api-core" ]; then echo "SDK tarball pulled @primitivedotdev/api-core into node_modules; the workspace-internal package must be bundled inline, not declared as a runtime dep."; exit 1; fi + pack_dir=$$(mktemp -d) && \ + smoke_dir=$$(mktemp -d) && \ + tarball=$$(cd sdk-node && npm pack --silent --pack-destination "$$pack_dir" | node -e "let data=''; process.stdin.on('data', chunk => data += chunk); process.stdin.on('end', () => { const matches = data.match(/[A-Za-z0-9._-]+\.tgz/g); if (!matches || matches.length === 0) { throw new Error('could not locate tarball name in npm pack output'); } process.stdout.write(matches[matches.length - 1]); });") && \ + cd "$$smoke_dir" && \ + npm init -y && \ + npm install "$$pack_dir/$$tarball" && \ + node --input-type=module -e 'const root = await import("@primitivedotdev/sdk"); const webhook = await import("@primitivedotdev/sdk/webhook"); const api = await import("@primitivedotdev/sdk/api"); const openapi = await import("@primitivedotdev/sdk/openapi"); const contract = await import("@primitivedotdev/sdk/contract"); const parser = await import("@primitivedotdev/sdk/parser"); const parserAddress = await import("@primitivedotdev/sdk/parser/address"); const x402 = await import("@primitivedotdev/sdk/x402"); const payloads = await import("@primitivedotdev/sdk/payloads"); const sendEmail = openapi.operationManifest.find((op) => op.operationId === "sendEmail"); if (typeof root.handleWebhook !== "function") throw new Error("missing root handleWebhook export"); if (typeof webhook.handleWebhook !== "function") throw new Error("missing webhook handleWebhook export"); if (typeof api.createPrimitiveApiClient !== "function") throw new Error("missing api client factory"); if (typeof openapi.openapiDocument !== "object") throw new Error("missing openapi document export"); if (!Array.isArray(openapi.operationManifest)) throw new Error("missing openapi operation manifest export"); if (!sendEmail?.requestSchema || !sendEmail?.responseSchema || !sendEmail.headerParams.some((param) => param.name === "Idempotency-Key" && param.minLength === 1 && param.maxLength === 255)) throw new Error("missing sendEmail manifest metadata"); if (typeof contract.buildEmailReceivedEvent !== "function") throw new Error("missing contract buildEmailReceivedEvent export"); if (typeof parser.parseEmail !== "function") throw new Error("missing parser parseEmail export"); if (typeof parserAddress.parseFromHeader !== "function") throw new Error("missing parser/address parseFromHeader export"); if (typeof x402.createX402Client !== "function") throw new Error("missing x402 createX402Client export"); if (typeof payloads.pushFile !== "function" || typeof payloads.pullFile !== "function") throw new Error("missing payloads pushFile/pullFile exports");' && \ + if [ -e "$$smoke_dir/node_modules/.bin/primitive" ]; then echo "SDK tarball must not install a primitive bin (the CLI is the separate primitive package)"; exit 1; fi && \ + if [ -d "$$smoke_dir/node_modules/@primitivedotdev/api-core" ]; then echo "SDK tarball pulled @primitivedotdev/api-core into node_modules; the workspace-internal package must be bundled inline, not declared as a runtime dep."; exit 1; fi node-coverage: pnpm --dir sdk-node test:coverage @@ -68,10 +82,15 @@ cli-smoke: cli-build cli-tarball-isolation node -e 'const pkg = require(process.argv[1]); const oclif = pkg.oclif || {}; const warning = oclif["warn-if-update-available"] || {}; if (!Array.isArray(oclif.plugins) || !oclif.plugins.includes("@oclif/plugin-warn-if-update-available")) throw new Error("missing update warning plugin"); if (warning.timeoutInDays !== 1 || warning.frequency !== 1 || warning.frequencyUnit !== "days") throw new Error("update warning is not daily"); if (!String(warning.message || "").includes("npm install -g primitive@latest")) throw new Error("missing npm update command");' "$$smoke_dir/node_modules/primitive/package.json" && \ export PRIMITIVE_SKIP_NEW_VERSION_CHECK=1 && \ bin="$$smoke_dir/node_modules/.bin/primitive" && \ + prim_bin="$$smoke_dir/node_modules/.bin/prim" && \ "$$bin" list-operations >/dev/null && \ + "$$prim_bin" list-operations >/dev/null && \ "$$bin" completion fish >/dev/null && \ + "$$prim_bin" completion fish >/dev/null && \ "$$bin" completion bash >/dev/null && \ + "$$prim_bin" completion bash >/dev/null && \ "$$bin" send --help | grep -q -- "--attachment" && \ + "$$prim_bin" send --help | grep -q -- "--attachment" && \ "$$bin" reply --help | grep -q -- "--wait" && \ "$$bin" reply --help | grep -q -- "--attachment" && \ if "$$bin" reply --help | grep -q -- "--wait-timeout-ms"; then echo "reply help must not advertise unsupported --wait-timeout-ms"; exit 1; fi && \ @@ -236,6 +255,318 @@ cli-smoke: cli-build cli-tarball-isolation cli-coverage: pnpm --dir cli-node test:coverage +rust-cli-generate: + COREPACK_ENABLE_AUTO_PIN=0 pnpm --dir packages/api-core generate + COREPACK_ENABLE_AUTO_PIN=0 pnpm --dir cli-node build + node scripts/generate-rust-cli-help-snapshots.mjs --node-bin "node $(CURDIR)/cli-node/bin/run.js" + +rust-cli-manifest-bytewise: rust-cli-generate + node scripts/assert-rust-operation-manifest-bytewise.mjs + +rust-cli-help-snapshots-bytewise: rust-cli-generate + node scripts/generate-rust-cli-help-snapshots.mjs --node-bin "node $(CURDIR)/cli-node/bin/run.js" --check + +rust-cli-check-generated: rust-cli-manifest-bytewise rust-cli-help-snapshots-bytewise + git diff --exit-code -- $(RUST_CLI_GENERATED_PATHS) && test -z "$$(git status --porcelain -- $(RUST_CLI_GENERATED_PATHS))" + +rust-cli-check: rust-cli-check-generated + cargo fmt --manifest-path cli-rust/Cargo.toml --check + cargo check --manifest-path cli-rust/Cargo.toml --locked + cargo clippy --manifest-path cli-rust/Cargo.toml --locked --all-targets -- -D warnings + RUSTDOCFLAGS="-D warnings" cargo doc --manifest-path cli-rust/Cargo.toml --locked --no-deps + cargo test --manifest-path cli-rust/Cargo.toml --locked + +rust-cli-doc: rust-cli-generate + RUSTDOCFLAGS="-D warnings" cargo doc --manifest-path cli-rust/Cargo.toml --locked --no-deps + +rust-cli-package: rust-cli-generate + cargo package --manifest-path cli-rust/Cargo.toml --locked --allow-dirty + +rust-cli-build: rust-cli-generate + cargo build --manifest-path cli-rust/Cargo.toml --locked + +rust-cli-release-build: rust-cli-generate + if [ -n "$${RUST_CLI_CARGO_TARGET:-}" ]; then cargo build --release --manifest-path cli-rust/Cargo.toml --locked --target "$$RUST_CLI_CARGO_TARGET"; else cargo build --release --manifest-path cli-rust/Cargo.toml --locked; fi + +rust-cli-dist: rust-cli-release-build + bin_dir="$(CURDIR)/cli-rust/target/release" && \ + dist_target="$${RUST_CLI_DIST_TARGET:-}" && \ + if [ -n "$${RUST_CLI_CARGO_TARGET:-}" ]; then \ + bin_dir="$(CURDIR)/cli-rust/target/$$RUST_CLI_CARGO_TARGET/release"; \ + if [ -z "$$dist_target" ]; then \ + case "$$RUST_CLI_CARGO_TARGET" in \ + x86_64-unknown-linux-musl) dist_target="linux-x64" ;; \ + aarch64-unknown-linux-musl) dist_target="linux-arm64" ;; \ + x86_64-apple-darwin) dist_target="macos-x64" ;; \ + aarch64-apple-darwin) dist_target="macos-arm64" ;; \ + x86_64-pc-windows-msvc) dist_target="windows-x64" ;; \ + *) echo "Set RUST_CLI_DIST_TARGET for RUST_CLI_CARGO_TARGET=$$RUST_CLI_CARGO_TARGET"; exit 1 ;; \ + esac; \ + fi; \ + fi && \ + if [ -n "$${RUST_CLI_DIST_BIN_DIR:-}" ]; then bin_dir="$$RUST_CLI_DIST_BIN_DIR"; fi && \ + if [ -n "$$dist_target" ]; then \ + node scripts/package-rust-cli.mjs --version "$${RUST_CLI_DIST_VERSION:-}" --target "$$dist_target" --out-dir "$${RUST_CLI_DIST_DIR:-cli-rust/dist}" --bin-dir "$$bin_dir"; \ + else \ + node scripts/package-rust-cli.mjs --version "$${RUST_CLI_DIST_VERSION:-}" --out-dir "$${RUST_CLI_DIST_DIR:-cli-rust/dist}" --bin-dir "$$bin_dir"; \ + fi + +rust-cli-smoke: rust-cli-build + smoke_dir=$$(mktemp -d) && \ + cargo install --quiet --path cli-rust --locked --root "$$smoke_dir" --debug && \ + "$$smoke_dir/bin/primitive-rust" --version > "$$smoke_dir/version.txt" && \ + grep -q -- 'primitive-rust/' "$$smoke_dir/version.txt" && \ + "$$smoke_dir/bin/primitive" --version > "$$smoke_dir/primitive-version.txt" && \ + grep -q -- 'primitive/' "$$smoke_dir/primitive-version.txt" && \ + "$$smoke_dir/bin/prim" --version > "$$smoke_dir/prim-version.txt" && \ + grep -q -- 'prim/' "$$smoke_dir/prim-version.txt" && \ + "$$smoke_dir/bin/primitive" list-operations > "$$smoke_dir/list-operations.json" && \ + grep -q -- '"operationId": "getAccount"' "$$smoke_dir/list-operations.json" && \ + "$$smoke_dir/bin/prim" list-operations > "$$smoke_dir/prim-list-operations.json" && \ + grep -q -- '"operationId": "getAccount"' "$$smoke_dir/prim-list-operations.json" && \ + "$$smoke_dir/bin/primitive" describe sending:send-email > "$$smoke_dir/describe-send-email.json" && \ + grep -q -- '"operationId": "sendEmail"' "$$smoke_dir/describe-send-email.json" + +rust-cli-release-smoke: rust-cli-release-build + smoke_dir=$$(mktemp -d) && \ + target_dir="$(CURDIR)/cli-rust/target/release" && \ + if [ -n "$${RUST_CLI_CARGO_TARGET:-}" ]; then target_dir="$(CURDIR)/cli-rust/target/$$RUST_CLI_CARGO_TARGET/release"; fi && \ + bin="$$target_dir/primitive-rust" && \ + "$$bin" --version > "$$smoke_dir/version.txt" && \ + grep -q -- 'primitive-rust/' "$$smoke_dir/version.txt" && \ + "$$target_dir/primitive" --version > "$$smoke_dir/primitive-version.txt" && \ + grep -q -- 'primitive/' "$$smoke_dir/primitive-version.txt" && \ + "$$target_dir/prim" --version > "$$smoke_dir/prim-version.txt" && \ + grep -q -- 'prim/' "$$smoke_dir/prim-version.txt" && \ + "$$bin" list-operations > "$$smoke_dir/list-operations.json" && \ + grep -q -- '"operationId": "getAccount"' "$$smoke_dir/list-operations.json" && \ + "$$target_dir/prim" list-operations > "$$smoke_dir/prim-list-operations.json" && \ + grep -q -- '"operationId": "getAccount"' "$$smoke_dir/prim-list-operations.json" && \ + "$$target_dir/primitive" describe sending:send-email > "$$smoke_dir/describe-send-email.json" && \ + grep -q -- '"operationId": "sendEmail"' "$$smoke_dir/describe-send-email.json" + +rust-cli-linux-portability-smoke: rust-cli-release-build + target_dir="$(CURDIR)/cli-rust/target/release" && \ + if [ -n "$${RUST_CLI_CARGO_TARGET:-}" ]; then target_dir="$(CURDIR)/cli-rust/target/$$RUST_CLI_CARGO_TARGET/release"; fi && \ + case "$${RUST_CLI_CARGO_TARGET:-}" in \ + *-unknown-linux-musl) node scripts/assert-rust-cli-linux-portability.mjs --bin "$$target_dir/primitive" --expect-static ;; \ + *) node scripts/assert-rust-cli-linux-portability.mjs --bin "$$target_dir/primitive" --max-glibc "$${RUST_CLI_MAX_GLIBC:-2.39}" ;; \ + esac + +rust-cli-archive-smoke: rust-cli-release-build + smoke_dir=$$(mktemp -d) && \ + extract_dir="$$smoke_dir/extract" && \ + check_dir="$$smoke_dir/check" && \ + run_dir="$$smoke_dir/run" && \ + home_dir="$$smoke_dir/home" && \ + xdg_dir="$$smoke_dir/xdg" && \ + config_dir="$$smoke_dir/primitive" && \ + mkdir -p "$$extract_dir" "$$check_dir" "$$run_dir" "$$home_dir" "$$xdg_dir" "$$config_dir" && \ + bin_dir="$(CURDIR)/cli-rust/target/release" && \ + dist_target="$${RUST_CLI_DIST_TARGET:-}" && \ + if [ -n "$${RUST_CLI_CARGO_TARGET:-}" ]; then \ + bin_dir="$(CURDIR)/cli-rust/target/$$RUST_CLI_CARGO_TARGET/release"; \ + if [ -z "$$dist_target" ]; then \ + case "$$RUST_CLI_CARGO_TARGET" in \ + x86_64-unknown-linux-musl) dist_target="linux-x64" ;; \ + aarch64-unknown-linux-musl) dist_target="linux-arm64" ;; \ + x86_64-apple-darwin) dist_target="macos-x64" ;; \ + aarch64-apple-darwin) dist_target="macos-arm64" ;; \ + x86_64-pc-windows-msvc) dist_target="windows-x64" ;; \ + *) echo "Set RUST_CLI_DIST_TARGET for RUST_CLI_CARGO_TARGET=$$RUST_CLI_CARGO_TARGET"; exit 1 ;; \ + esac; \ + fi; \ + fi && \ + if [ -n "$${RUST_CLI_DIST_BIN_DIR:-}" ]; then bin_dir="$$RUST_CLI_DIST_BIN_DIR"; fi && \ + case "$$dist_target" in windows-*) echo "rust-cli-archive-smoke uses Unix archive extraction and execution; use rust-cli-windows-archive-smoke for $$dist_target"; exit 1 ;; esac && \ + if [ -n "$$dist_target" ]; then \ + node scripts/package-rust-cli.mjs --target "$$dist_target" --out-dir "$$smoke_dir/dist" --bin-dir "$$bin_dir" --json > "$$smoke_dir/package.json"; \ + else \ + node scripts/package-rust-cli.mjs --out-dir "$$smoke_dir/dist" --bin-dir "$$bin_dir" --json > "$$smoke_dir/package.json"; \ + fi && \ + archive_path=$$(node -e "const fs=require('fs'); process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1], 'utf8')).archive);" "$$smoke_dir/package.json") && \ + checksum_path=$$(node -e "const fs=require('fs'); process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1], 'utf8')).checksum);" "$$smoke_dir/package.json") && \ + checksum_name=$$(basename "$$archive_path") && \ + grep -q -- " $$checksum_name" "$$checksum_path" && \ + cp "$$archive_path" "$$check_dir/$$checksum_name" && \ + cp "$$checksum_path" "$$check_dir/$$checksum_name.sha256" && \ + case "$$(uname -s)" in \ + Darwin) cd "$$check_dir" && shasum -a 256 -c "$$checksum_name.sha256" ;; \ + *) cd "$$check_dir" && sha256sum -c "$$checksum_name.sha256" ;; \ + esac && \ + tar -tzf "$$archive_path" > "$$smoke_dir/tar-list.txt" && \ + grep -qx -- 'primitive' "$$smoke_dir/tar-list.txt" && \ + grep -qx -- 'prim' "$$smoke_dir/tar-list.txt" && \ + grep -qx -- 'README.md' "$$smoke_dir/tar-list.txt" && \ + grep -qx -- 'LICENSE' "$$smoke_dir/tar-list.txt" && \ + tar -C "$$extract_dir" -xzf "$$archive_path" && \ + test -s "$$extract_dir/README.md" && \ + test -s "$$extract_dir/LICENSE" && \ + grep -q -- 'Primitive Rust CLI' "$$extract_dir/README.md" && \ + case "$$(uname -s)" in \ + Darwin) otool -L "$$extract_dir/primitive" > "$$smoke_dir/native-deps.txt" && if grep -E 'lib(ssl|crypto|z)\\.' "$$smoke_dir/native-deps.txt"; then echo "Rust CLI archive unexpectedly links OpenSSL/libz"; exit 1; fi ;; \ + Linux) if command -v ldd >/dev/null 2>&1; then if ldd "$$extract_dir/primitive" > "$$smoke_dir/native-deps.txt" 2> "$$smoke_dir/native-deps.err"; then if grep -E 'lib(ssl|crypto|z)\\.so' "$$smoke_dir/native-deps.txt"; then echo "Rust CLI archive unexpectedly links OpenSSL/libz"; exit 1; fi; elif grep -Eiq 'not a dynamic executable|statically linked' "$$smoke_dir/native-deps.txt" "$$smoke_dir/native-deps.err"; then :; else cat "$$smoke_dir/native-deps.txt" "$$smoke_dir/native-deps.err"; exit 1; fi; fi ;; \ + esac && \ + cd "$$run_dir" && \ + HOME="$$home_dir" XDG_CONFIG_HOME="$$xdg_dir" PRIMITIVE_CONFIG_DIR="$$config_dir" PRIMITIVE_API_KEY= "$$extract_dir/primitive" --version > "$$smoke_dir/version.txt" && \ + grep -q -- 'primitive/' "$$smoke_dir/version.txt" && \ + HOME="$$home_dir" XDG_CONFIG_HOME="$$xdg_dir" PRIMITIVE_CONFIG_DIR="$$config_dir" PRIMITIVE_API_KEY= "$$extract_dir/prim" --version > "$$smoke_dir/prim-version.txt" && \ + grep -q -- 'prim/' "$$smoke_dir/prim-version.txt" && \ + HOME="$$home_dir" XDG_CONFIG_HOME="$$xdg_dir" PRIMITIVE_CONFIG_DIR="$$config_dir" PRIMITIVE_API_KEY= "$$extract_dir/primitive" list-operations > "$$smoke_dir/list-operations.json" && \ + grep -q -- '"operationId": "getAccount"' "$$smoke_dir/list-operations.json" && \ + HOME="$$home_dir" XDG_CONFIG_HOME="$$xdg_dir" PRIMITIVE_CONFIG_DIR="$$config_dir" PRIMITIVE_API_KEY= "$$extract_dir/prim" list-operations > "$$smoke_dir/prim-list-operations.json" && \ + grep -q -- '"operationId": "getAccount"' "$$smoke_dir/prim-list-operations.json" && \ + HOME="$$home_dir" XDG_CONFIG_HOME="$$xdg_dir" PRIMITIVE_CONFIG_DIR="$$config_dir" PRIMITIVE_API_KEY= "$$extract_dir/prim" describe sending:send-email > "$$smoke_dir/prim-describe-send-email.json" && \ + grep -q -- '"operationId": "sendEmail"' "$$smoke_dir/prim-describe-send-email.json" && \ + HOME="$$home_dir" XDG_CONFIG_HOME="$$xdg_dir" PRIMITIVE_CONFIG_DIR="$$config_dir" PRIMITIVE_API_KEY= "$$extract_dir/primitive" describe sending:send-email > "$$smoke_dir/describe-send-email.json" && \ + grep -q -- '"operationId": "sendEmail"' "$$smoke_dir/describe-send-email.json" + +rust-cli-windows-archive-smoke: rust-cli-release-build + smoke_dir=$$(mktemp -d) && \ + bin_dir="$$smoke_dir/bin" && \ + check_dir="$$smoke_dir/check" && \ + mkdir -p "$$bin_dir" "$$check_dir" && \ + cp "$(CURDIR)/cli-rust/target/release/primitive" "$$bin_dir/primitive.exe" && \ + cp "$(CURDIR)/cli-rust/target/release/prim" "$$bin_dir/prim.exe" && \ + node scripts/package-rust-cli.mjs --target windows-x64 --archive-format zip --bin-dir "$$bin_dir" --out-dir "$$smoke_dir/dist" --json > "$$smoke_dir/package.json" && \ + archive_path=$$(node -e "const fs=require('fs'); process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1], 'utf8')).archive);" "$$smoke_dir/package.json") && \ + checksum_path=$$(node -e "const fs=require('fs'); process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1], 'utf8')).checksum);" "$$smoke_dir/package.json") && \ + archive_name=$$(basename "$$archive_path") && \ + case "$$archive_name" in primitive-rust-cli-v*-windows-x64.zip) ;; *) echo "Unexpected Windows archive name: $$archive_name"; exit 1 ;; esac && \ + grep -q -- " $$archive_name" "$$checksum_path" && \ + cp "$$archive_path" "$$check_dir/$$archive_name" && \ + cp "$$checksum_path" "$$check_dir/$$archive_name.sha256" && \ + case "$$(uname -s)" in \ + Darwin) cd "$$check_dir" && shasum -a 256 -c "$$archive_name.sha256" ;; \ + *) cd "$$check_dir" && sha256sum -c "$$archive_name.sha256" ;; \ + esac && \ + printf '%s\n' LICENSE README.md prim.exe primitive.exe | sort > "$$smoke_dir/expected-zip-members.txt" && \ + if command -v zipinfo >/dev/null 2>&1; then \ + zipinfo -1 "$$archive_path"; \ + else \ + unzip -Z1 "$$archive_path"; \ + fi | sort > "$$smoke_dir/actual-zip-members.txt" && \ + diff -u "$$smoke_dir/expected-zip-members.txt" "$$smoke_dir/actual-zip-members.txt" + +rust-cli-install-smoke: rust-cli-release-build + smoke_dir=$$(mktemp -d) && \ + install_dir="$$smoke_dir/bin" && \ + home_dir="$$smoke_dir/home" && \ + xdg_dir="$$smoke_dir/xdg" && \ + config_dir="$$smoke_dir/primitive" && \ + mkdir -p "$$install_dir" "$$home_dir" "$$xdg_dir" "$$config_dir" && \ + bin_dir="$(CURDIR)/cli-rust/target/release" && \ + dist_target="$${RUST_CLI_DIST_TARGET:-}" && \ + if [ -n "$${RUST_CLI_CARGO_TARGET:-}" ]; then \ + bin_dir="$(CURDIR)/cli-rust/target/$$RUST_CLI_CARGO_TARGET/release"; \ + if [ -z "$$dist_target" ]; then \ + case "$$RUST_CLI_CARGO_TARGET" in \ + x86_64-unknown-linux-musl) dist_target="linux-x64" ;; \ + aarch64-unknown-linux-musl) dist_target="linux-arm64" ;; \ + x86_64-apple-darwin) dist_target="macos-x64" ;; \ + aarch64-apple-darwin) dist_target="macos-arm64" ;; \ + x86_64-pc-windows-msvc) dist_target="windows-x64" ;; \ + *) echo "Set RUST_CLI_DIST_TARGET for RUST_CLI_CARGO_TARGET=$$RUST_CLI_CARGO_TARGET"; exit 1 ;; \ + esac; \ + fi; \ + fi && \ + if [ -n "$${RUST_CLI_DIST_BIN_DIR:-}" ]; then bin_dir="$$RUST_CLI_DIST_BIN_DIR"; fi && \ + case "$$dist_target" in windows-*) echo "rust-cli-install-smoke uses the Unix installer; use scripts/install-rust-cli.ps1 for $$dist_target"; exit 1 ;; esac && \ + if [ -n "$$dist_target" ]; then \ + node scripts/package-rust-cli.mjs --target "$$dist_target" --out-dir "$$smoke_dir/dist" --bin-dir "$$bin_dir" --json > "$$smoke_dir/package.json"; \ + else \ + node scripts/package-rust-cli.mjs --out-dir "$$smoke_dir/dist" --bin-dir "$$bin_dir" --json > "$$smoke_dir/package.json"; \ + fi && \ + version=$$(node -e "const fs=require('fs'); process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1], 'utf8')).version);" "$$smoke_dir/package.json") && \ + target=$$(node -e "const fs=require('fs'); process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1], 'utf8')).target);" "$$smoke_dir/package.json") && \ + scripts/install-rust-cli.sh --version "$$version" --target "$$target" --base-url "file://$$smoke_dir/dist" --install-dir "$$install_dir" > "$$smoke_dir/install.txt" && \ + grep -q -- "Installed primitive and prim to $$install_dir" "$$smoke_dir/install.txt" && \ + HOME="$$home_dir" XDG_CONFIG_HOME="$$xdg_dir" PRIMITIVE_CONFIG_DIR="$$config_dir" PRIMITIVE_API_KEY= "$$install_dir/primitive" --version > "$$smoke_dir/primitive-version.txt" && \ + grep -q -- 'primitive/' "$$smoke_dir/primitive-version.txt" && \ + HOME="$$home_dir" XDG_CONFIG_HOME="$$xdg_dir" PRIMITIVE_CONFIG_DIR="$$config_dir" PRIMITIVE_API_KEY= "$$install_dir/prim" --version > "$$smoke_dir/prim-version.txt" && \ + grep -q -- 'prim/' "$$smoke_dir/prim-version.txt" && \ + HOME="$$home_dir" XDG_CONFIG_HOME="$$xdg_dir" PRIMITIVE_CONFIG_DIR="$$config_dir" PRIMITIVE_API_KEY= "$$install_dir/primitive" list-operations > "$$smoke_dir/list-operations.json" && \ + grep -q -- '"operationId": "getAccount"' "$$smoke_dir/list-operations.json" && \ + HOME="$$home_dir" XDG_CONFIG_HOME="$$xdg_dir" PRIMITIVE_CONFIG_DIR="$$config_dir" PRIMITIVE_API_KEY= "$$install_dir/prim" list-operations > "$$smoke_dir/prim-list-operations.json" && \ + grep -q -- '"operationId": "getAccount"' "$$smoke_dir/prim-list-operations.json" && \ + HOME="$$home_dir" XDG_CONFIG_HOME="$$xdg_dir" PRIMITIVE_CONFIG_DIR="$$config_dir" PRIMITIVE_API_KEY= "$$install_dir/prim" describe sending:send-email > "$$smoke_dir/prim-describe-send-email.json" && \ + grep -q -- '"operationId": "sendEmail"' "$$smoke_dir/prim-describe-send-email.json" && \ + HOME="$$home_dir" XDG_CONFIG_HOME="$$xdg_dir" PRIMITIVE_CONFIG_DIR="$$config_dir" PRIMITIVE_API_KEY= "$$install_dir/primitive" describe sending:send-email > "$$smoke_dir/describe-send-email.json" && \ + grep -q -- '"operationId": "sendEmail"' "$$smoke_dir/describe-send-email.json" + +rust-cli-live-smoke: rust-cli-build + node scripts/run-rust-cli-live-smoke.mjs --rust-bin "$(CURDIR)/cli-rust/target/debug/primitive" $${RUST_CLI_LIVE_SMOKE_ARGS:-} + +rust-cli-live-smoke-no-key: rust-cli-build + node scripts/run-rust-cli-live-smoke.mjs --rust-bin "$(CURDIR)/cli-rust/target/debug/primitive" --no-key-only + +rust-cli-script-check: + bash -n scripts/install-rust-cli.sh + if command -v pwsh >/dev/null 2>&1; then pwsh -NoProfile -NonInteractive -Command '$$null = [scriptblock]::Create((Get-Content -Raw scripts/install-rust-cli.ps1))'; fi + node --check scripts/assert-cli-command-surface-parity.mjs + node --check scripts/assert-cli-operation-request-coverage.mjs + node --check scripts/assert-rust-cli-linux-portability.mjs + node --check scripts/assert-rust-operation-manifest-bytewise.mjs + node --check scripts/package-rust-cli.mjs + node --check scripts/run-cli-help-sweep.mjs + node --check scripts/run-cli-parity.mjs + node --check scripts/run-rust-cli-live-smoke.mjs + +cli-command-surface-parity: rust-cli-check-generated + node scripts/assert-cli-command-surface-parity.mjs + +cli-operation-coverage: + node scripts/assert-cli-operation-request-coverage.mjs --require-all --require-generated-alias-fixtures --require-generated-command-fixtures --require-canonical-generated-command-fixtures + +cli-help-parity: cli-command-surface-parity rust-cli-build + node scripts/run-cli-help-sweep.mjs --compare-flags --compare-copy --node-bin "$${NODE_CLI_BIN:-node $(CURDIR)/cli-node/bin/run.js}" --rust-bin "$${RUST_CLI_BIN:-$(CURDIR)/cli-rust/target/debug/primitive}" + shim_dir=$$(mktemp -d) && \ + node_prim_bin="$${NODE_CLI_PRIM_BIN:-}" && \ + if [ -z "$$node_prim_bin" ]; then ln -s "$(CURDIR)/cli-node/bin/run.js" "$$shim_dir/prim" && node_prim_bin="node $$shim_dir/prim"; fi && \ + node scripts/run-cli-help-sweep.mjs --compare-flags --compare-copy --node-bin "$$node_prim_bin" --rust-bin "$${RUST_CLI_PRIM_BIN:-$(CURDIR)/cli-rust/target/debug/prim}" + +cli-archive-parity: cli-command-surface-parity cli-operation-coverage rust-cli-release-build + smoke_dir=$$(mktemp -d) && \ + extract_dir="$$smoke_dir/extract" && \ + mkdir -p "$$extract_dir" && \ + bin_dir="$(CURDIR)/cli-rust/target/release" && \ + dist_target="$${RUST_CLI_DIST_TARGET:-}" && \ + if [ -n "$${RUST_CLI_CARGO_TARGET:-}" ]; then \ + bin_dir="$(CURDIR)/cli-rust/target/$$RUST_CLI_CARGO_TARGET/release"; \ + if [ -z "$$dist_target" ]; then \ + case "$$RUST_CLI_CARGO_TARGET" in \ + x86_64-unknown-linux-musl) dist_target="linux-x64" ;; \ + aarch64-unknown-linux-musl) dist_target="linux-arm64" ;; \ + x86_64-apple-darwin) dist_target="macos-x64" ;; \ + aarch64-apple-darwin) dist_target="macos-arm64" ;; \ + x86_64-pc-windows-msvc) dist_target="windows-x64" ;; \ + *) echo "Set RUST_CLI_DIST_TARGET for RUST_CLI_CARGO_TARGET=$$RUST_CLI_CARGO_TARGET"; exit 1 ;; \ + esac; \ + fi; \ + fi && \ + if [ -n "$${RUST_CLI_DIST_BIN_DIR:-}" ]; then bin_dir="$$RUST_CLI_DIST_BIN_DIR"; fi && \ + case "$$dist_target" in windows-*) echo "cli-archive-parity uses Unix archive extraction and execution; use Windows workflow parity for $$dist_target"; exit 1 ;; esac && \ + if [ -n "$$dist_target" ]; then \ + node scripts/package-rust-cli.mjs --target "$$dist_target" --out-dir "$$smoke_dir/dist" --bin-dir "$$bin_dir" --json > "$$smoke_dir/package.json"; \ + else \ + node scripts/package-rust-cli.mjs --out-dir "$$smoke_dir/dist" --bin-dir "$$bin_dir" --json > "$$smoke_dir/package.json"; \ + fi && \ + archive_path=$$(node -e "const fs=require('fs'); process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1], 'utf8')).archive);" "$$smoke_dir/package.json") && \ + tar -C "$$extract_dir" -xzf "$$archive_path" && \ + node scripts/run-cli-help-sweep.mjs --compare-flags --compare-copy --node-bin "$${NODE_CLI_BIN:-node $(CURDIR)/cli-node/bin/run.js}" --rust-bin "$$extract_dir/primitive" && \ + node scripts/run-cli-parity.mjs --node-bin "$${NODE_CLI_BIN:-node $(CURDIR)/cli-node/bin/run.js}" --rust-bin "$$extract_dir/primitive" && \ + node_prim_bin="$${NODE_CLI_PRIM_BIN:-}" && \ + if [ -z "$$node_prim_bin" ]; then ln -s "$(CURDIR)/cli-node/bin/run.js" "$$smoke_dir/prim" && node_prim_bin="node $$smoke_dir/prim"; fi && \ + node scripts/run-cli-help-sweep.mjs --compare-flags --compare-copy --node-bin "$$node_prim_bin" --rust-bin "$$extract_dir/prim" && \ + node scripts/run-cli-parity.mjs --node-bin "$$node_prim_bin" --rust-bin "$$extract_dir/prim" + +cli-parity: cli-help-parity cli-operation-coverage + node scripts/run-cli-parity.mjs --node-bin "$${NODE_CLI_BIN:-node $(CURDIR)/cli-node/bin/run.js}" --rust-bin "$${RUST_CLI_BIN:-$(CURDIR)/cli-rust/target/debug/primitive}" + shim_dir=$$(mktemp -d) && \ + node_prim_bin="$${NODE_CLI_PRIM_BIN:-}" && \ + if [ -z "$$node_prim_bin" ]; then ln -s "$(CURDIR)/cli-node/bin/run.js" "$$shim_dir/prim" && node_prim_bin="node $$shim_dir/prim"; fi && \ + node scripts/run-cli-parity.mjs --node-bin "$$node_prim_bin" --rust-bin "$${RUST_CLI_PRIM_BIN:-$(CURDIR)/cli-rust/target/debug/prim}" + python-sync: cd sdk-python && uv sync --dev @@ -289,6 +620,12 @@ check: node-check cli-check python-check go-check shared-check build: node-build cli-build python-build go-build +rust-cli-release-platform-check: rust-cli-check rust-cli-package rust-cli-smoke rust-cli-live-smoke-no-key rust-cli-release-smoke rust-cli-linux-portability-smoke rust-cli-archive-smoke rust-cli-install-smoke cli-archive-parity + +rust-cli-full-check: rust-cli-check rust-cli-package rust-cli-build rust-cli-smoke rust-cli-live-smoke-no-key rust-cli-release-smoke rust-cli-linux-portability-smoke rust-cli-archive-smoke rust-cli-windows-archive-smoke rust-cli-install-smoke cli-parity cli-archive-parity + +rust-cli-ci-check: rust-cli-script-check rust-cli-full-check + release-check: node-check node-build node-smoke cli-check cli-build cli-smoke python-check python-build python-smoke go-check go-build shared-check ci: diff --git a/README.md b/README.md index 22a39a4d..cfcd2c83 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,21 @@ small default workflow: | Python | `pip install primitivedotdev` | `sdk-python/README.md` | | Go | `go get github.com/primitivedotdev/sdks/sdk-go@latest` | `sdk-go/README.md` | +## CLI + +The supported public CLI is the Node-distributed `primitive` command from +`cli-node/`. Install it with Homebrew or npm: + +```bash +brew install primitivedotdev/tap/primitive +npm install -g primitive +``` + +`cli-rust/` contains the Rust port under parity validation and GitHub release +archive preparation. Source installs expose `primitive-rust`, `primitive`, and +`prim`; release archives contain the user-facing `primitive` and `prim` +commands. + ## Default API shape Across the SDKs, the default story is: @@ -79,8 +94,10 @@ focuses on the inbound/outbound automation flow above. ```text sdks/ .github/workflows/ - openapi/ + cli-node/ + cli-rust/ json-schema/ + openapi/ sdk-go/ sdk-node/ sdk-python/ @@ -98,6 +115,10 @@ make build make shared-check ``` +The Rust CLI port is opt-in from the root while it is under parity validation. +Use `make rust-cli-full-check` to run the Rust compiler/tests, archive smokes, +and Node/Rust CLI parity suite. + The `Makefile` wraps each SDK's native commands. You can still run them directly from each SDK directory when needed: @@ -118,10 +139,10 @@ cd sdk-go && go test ./... && go test -run TestSharedCompatibilityFixtures ./... Everything for building on [primitive.dev](https://www.primitive.dev), the email API for AI agents: -- **Primitive API documentation** — [docs.primitive.dev/docs](https://docs.primitive.dev/docs) (REST reference: [/docs/api](https://docs.primitive.dev/docs/api)) -- **Primitive OpenAPI spec** — [primitive.dev/openapi.json](https://www.primitive.dev/openapi.json) -- **Primitive authentication guide** — [/docs/auth](https://docs.primitive.dev/docs/auth) -- **Primitive webhooks** — [/docs/endpoints](https://docs.primitive.dev/docs/endpoints) -- **Primitive MCP server** (Claude & ChatGPT connector) — [/docs/connectors](https://docs.primitive.dev/docs/connectors) -- **Primitive CLI** — `npm install -g primitive` -- **Full developer-resources index** — [primitive.dev/developers](https://www.primitive.dev/developers) +- **Primitive API documentation**: [docs.primitive.dev/docs](https://docs.primitive.dev/docs) (REST reference: [/docs/api](https://docs.primitive.dev/docs/api)) +- **Primitive OpenAPI spec**: [primitive.dev/openapi.json](https://www.primitive.dev/openapi.json) +- **Primitive authentication guide**: [/docs/auth](https://docs.primitive.dev/docs/auth) +- **Primitive webhooks**: [/docs/endpoints](https://docs.primitive.dev/docs/endpoints) +- **Primitive MCP server**: [/docs/connectors](https://docs.primitive.dev/docs/connectors) +- **Primitive CLI**: `brew install primitivedotdev/tap/primitive` or `npm install -g primitive` +- **Full developer-resources index**: [primitive.dev/developers](https://www.primitive.dev/developers) diff --git a/RELEASE.md b/RELEASE.md index be0ab249..66c3581d 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,9 +1,10 @@ # Release Process -This repository publishes three language SDKs plus a Node-only CLI from one shared webhook contract and one shared API contract. +This repository publishes three language SDKs and the official Node-distributed CLI from one shared webhook contract and one shared API contract. The Rust CLI port can also produce GitHub release archives, but that release path is manual while the port remains optional. - Node SDK: `@primitivedotdev/sdk` - Node CLI: `primitive` (also mirrored as `primcli` and the legacy scoped `@primitivedotdev/cli`) +- Rust CLI: `cli-rust/`, optional GitHub release archives exposing `primitive` and `prim` - Python: `primitivedotdev` - Go: `github.com/primitivedotdev/sdks/sdk-go` @@ -12,7 +13,8 @@ Use this process when cutting a release for one or more packages. Releases are automated from `main`. - If a PR merges with a new `sdk-node/package.json` version, GitHub Actions publishes the Node SDK. -- If a PR merges with a new `cli-node/package.json` version, GitHub Actions publishes the CLI. +- If a PR merges with a new `cli-node/package.json` version, GitHub Actions publishes the Node-distributed CLI. +- Rust CLI archives are created only by manually running the `Rust CLI Release` workflow. - If a PR merges with a new `sdk-python/pyproject.toml` version, GitHub Actions publishes the Python SDK. - If a PR merges with a new `sdk-go/VERSION` value, GitHub Actions creates the Go module tag and GitHub release. @@ -31,9 +33,9 @@ Releases are automated from `main`. 2. Merge that PR into `main`. 3. The `Node Release` workflow verifies the version bump, publishes to npm through trusted publishing/OIDC, and creates the `sdk-node/vX.Y.Z` tag plus a GitHub release. 4. Verify the package contents with `npm view @primitivedotdev/sdk version`. -5. Confirm the packed artifact exposes `@primitivedotdev/sdk`, `@primitivedotdev/sdk/webhook`, `@primitivedotdev/sdk/api`, `@primitivedotdev/sdk/openapi`, `@primitivedotdev/sdk/contract`, and `@primitivedotdev/sdk/parser`, and that it does NOT install a `primitive` bin (the CLI lives in the separate `primitive` package). +5. Confirm the packed artifact exposes `@primitivedotdev/sdk`, `@primitivedotdev/sdk/webhook`, `@primitivedotdev/sdk/api`, `@primitivedotdev/sdk/openapi`, `@primitivedotdev/sdk/contract`, `@primitivedotdev/sdk/parser`, `@primitivedotdev/sdk/parser/address`, `@primitivedotdev/sdk/x402`, and `@primitivedotdev/sdk/payloads`, and that it does NOT install a `primitive` bin (the CLI lives in the separate `primitive` package). -## CLI Release +## Node CLI Release 1. Open a PR that bumps `cli-node/package.json` to the target version. 2. Merge that PR into `main`. @@ -51,6 +53,33 @@ Both npm packages use npm trusted publishing from GitHub Actions. Do not add npm Each mirror (`primcli` and `@primitivedotdev/cli`) needs its own npm trusted publisher (same `.github/workflows/cli-release.yml`). All three names already have trusted publishers configured for this workflow (each published from it before or after the rename), so no npm-side changes are needed; the workflow keeps the mirrors in lockstep. For any future new mirror name: npm trusted publishing requires the package to already exist, so claim the name with a one-time manual `npm publish` first (`primcli` was claimed at `primcli@1.2.0`). +## Rust CLI Release + +1. Open a PR that bumps `cli-rust/Cargo.toml` to the target version. +2. Merge that PR into `main`. +3. Manually run the `Rust CLI Release` workflow from `main`. The workflow verifies the version, runs the Rust CLI checks on each release platform, runs authenticated live smoke against the packaged Linux x64 release artifact, creates the `cli-rust-vX.Y.Z` tag plus a GitHub release, and uploads Linux x64, Linux arm64, macOS x64, macOS arm64, and Windows x64 archives plus their `.sha256` checksums. Linux archives are built from musl Rust targets while keeping the public `linux-x64` and `linux-arm64` archive names. Each archive keeps the top-level `primitive`/`prim` binaries and includes `LICENSE` plus a concise `README.md` install note. +4. Install the archive for your platform with `scripts/install-rust-cli.sh --version X.Y.Z` on macOS/Linux or `scripts/install-rust-cli.ps1 -Version X.Y.Z` on Windows. If the installer reports that the install directory is not on `PATH`, add it or invoke the full printed path, then confirm `primitive --version`, `prim --version`, and `primitive list-operations` succeed. + +The release workflow requires a `PRIMITIVE_API_KEY` repository secret for the live smoke gate. Keep that gate read-only; run mutating or email E2E smoke manually with a disposable account when needed. + +Every CLI-facing release PR should keep the Rust port green: + +```bash +make rust-cli-check +make rust-cli-package +make rust-cli-smoke +make rust-cli-release-smoke +make rust-cli-linux-portability-smoke +make rust-cli-archive-smoke +make rust-cli-windows-archive-smoke +make rust-cli-install-smoke +make rust-cli-dist +make cli-parity +make cli-archive-parity +``` + +`rust-cli-linux-portability-smoke` checks Linux release binaries for unexpected OpenSSL/libz linkage and can enforce a static musl binary for release-platform builds. `rust-cli-archive-smoke` validates the release binary after packaging it as extracted `primitive` and `prim` commands, with `README.md` and `LICENSE` present. `rust-cli-windows-archive-smoke` validates the Windows `.zip` archive, checksum shape, and bundled `README.md`/`LICENSE` files. `rust-cli-install-smoke` validates the macOS/Linux installer against that archive and checksum shape. `cli-archive-parity` runs the full Node/Rust help sweep and request/response parity suite against the extracted release archive binary. `rust-cli-dist` writes the same archive shape to `cli-rust/dist/` for release uploads. + ## Python Release 1. Open a PR that bumps `sdk-python/pyproject.toml` to the target version. @@ -80,5 +109,5 @@ If a release includes schema or shared-fixture changes: If a release includes API spec changes: 1. Update `openapi/primitive-api.yaml`. -2. Regenerate the Node, Python, and Go API clients. +2. Regenerate the Node, Python, and Go API clients plus `cli-rust/src/operation-manifest.json`. 3. Ensure the PR passes `SDK Checks` again before merging. diff --git a/cli-node/README.md b/cli-node/README.md index 0a8e7cf5..4897e10a 100644 --- a/cli-node/README.md +++ b/cli-node/README.md @@ -2,6 +2,10 @@ Official Primitive CLI. Deploy Primitive Functions, send and inspect mail, manage endpoints, all from the terminal. +This README documents the supported npm and Homebrew CLI package. The Rust port +lives in `cli-rust/` and is released through GitHub archives with `primitive` +and `prim` binaries. + ```bash brew install primitivedotdev/tap/primitive primitive whoami diff --git a/cli-node/src/oclif/api-command.ts b/cli-node/src/oclif/api-command.ts index 19d3b764..0cca83bb 100644 --- a/cli-node/src/oclif/api-command.ts +++ b/cli-node/src/oclif/api-command.ts @@ -38,7 +38,7 @@ type OperationExecutor = (options: Record) => Promise<{ }>; function flagName(parameterName: string): string { - return parameterName.replace(/_/g, "-"); + return parameterName.replace(/_/g, "-").toLowerCase(); } function flagDescription(parameter: PrimitiveParameterManifest): string { @@ -641,7 +641,7 @@ export function resolveCliAuthFromFlags( // Reserved flag names the body-field expander must never overwrite. // `--raw-body` and `--body-file` are the JSON escape hatches. // `--api-key`, `--api-base-url`, `--output` are -// infra. Path and query params get added before body fields and take +// infra. Path, query, and header params get added before body fields and take // precedence. // // Note: `--body` is intentionally NOT reserved here. The naive @@ -740,7 +740,11 @@ function buildFlags(operation: PrimitiveOperationManifest): { }); } - for (const parameter of [...operation.pathParams, ...operation.queryParams]) { + for (const parameter of [ + ...operation.pathParams, + ...operation.queryParams, + ...operation.headerParams, + ]) { flags[flagName(parameter.name)] = flagForParameter(parameter); } @@ -1078,6 +1082,7 @@ export function createOperationCommand( client: apiClient.client, parseAs: operation.binaryResponse ? "blob" : "auto", path: collectValues(operation.pathParams, parsedFlags), + headers: collectValues(operation.headerParams, parsedFlags), query: collectValues(operation.queryParams, parsedFlags), responseStyle: "fields", }); diff --git a/cli-node/src/oclif/commands/login.ts b/cli-node/src/oclif/commands/login.ts index d4f529a4..27c89411 100644 --- a/cli-node/src/oclif/commands/login.ts +++ b/cli-node/src/oclif/commands/login.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { appendFileSync } from "node:fs"; import { hostname } from "node:os"; import { Command, Errors, Flags } from "@oclif/core"; import type { @@ -43,7 +44,32 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +function recordAuthEvent(kind: string, value: string): void { + const target = process.env.PRIMITIVE_AUTH_EVENT_LOG; + if (!target) return; + try { + appendFileSync(target, `${kind} ${JSON.stringify(value)}\n`, "utf8"); + } catch { + // Best-effort parity instrumentation only. + } +} + +function recordBrowserOpenTrap(url: string): boolean { + if (process.env.PRIMITIVE_BROWSER_OPEN_DIRECT_TRAP !== "1") return false; + const target = process.env.PRIMITIVE_BROWSER_OPEN_TRAP_LOG; + if (target) { + try { + appendFileSync(target, `browser-open ${url}\n`, "utf8"); + } catch { + // Best-effort parity instrumentation only. + } + } + return true; +} + function openBrowser(url: string): void { + recordAuthEvent("browser-open", url); + if (recordBrowserOpenTrap(url)) return; const command = process.platform === "darwin" ? "open" @@ -295,15 +321,23 @@ class LoginCommand extends Command { throw cliError("Primitive API returned an empty CLI login response."); } - process.stderr.write(`Your sign-in code is: ${start.user_code}\n`); + const codeLine = `Your sign-in code is: ${start.user_code}\n`; + process.stderr.write(codeLine); if (!flags["no-browser"]) { + recordAuthEvent("stderr", codeLine); openBrowser(start.verification_uri_complete); - process.stderr.write("Opening Primitive in your browser...\n"); + const openingLine = "Opening Primitive in your browser...\n"; + process.stderr.write(openingLine); + recordAuthEvent("stderr", openingLine); + } + const fallbackLine = `If the browser did not open, visit: ${start.verification_uri_complete}\n`; + process.stderr.write(fallbackLine); + const waitingLine = "Waiting for browser approval...\n"; + process.stderr.write(waitingLine); + if (!flags["no-browser"]) { + recordAuthEvent("stderr", fallbackLine); + recordAuthEvent("stderr", waitingLine); } - process.stderr.write( - `If the browser did not open, visit: ${start.verification_uri_complete}\n`, - ); - process.stderr.write("Waiting for browser approval...\n"); const deadline = Date.now() + start.expires_in * 1000; let interval = Math.min( diff --git a/cli-node/src/oclif/commands/payloads.ts b/cli-node/src/oclif/commands/payloads.ts index f8854de9..8234010c 100644 --- a/cli-node/src/oclif/commands/payloads.ts +++ b/cli-node/src/oclif/commands/payloads.ts @@ -35,8 +35,11 @@ function resolveClient( "Not authenticated: set PRIMITIVE_API_KEY, pass --api-key, or run `primitive login`.", ); } + let baseUrl = auth.apiBaseUrl ?? DEFAULT_API_BASE_URL; + while (baseUrl.endsWith("/")) baseUrl = baseUrl.slice(0, -1); + if (baseUrl.endsWith("/v1")) baseUrl = baseUrl.slice(0, -3); return { - baseUrl: auth.apiBaseUrl ?? DEFAULT_API_BASE_URL, + baseUrl, apiKey: auth.apiKey, }; } diff --git a/cli-node/tests/oclif/api-command.test.ts b/cli-node/tests/oclif/api-command.test.ts index 87b17742..ab59630b 100644 --- a/cli-node/tests/oclif/api-command.test.ts +++ b/cli-node/tests/oclif/api-command.test.ts @@ -690,6 +690,7 @@ describe("isIncompleteDomainVerification", () => { command: "verify", description: "Verify domain", hasJsonBody: false, + headerParams: [], method: "POST", operationId: "verifyDomain", path: "/domains/{id}/verify", @@ -757,6 +758,7 @@ describe("createOperationCommand description", () => { command: "fake-op", description: "Fake operation", hasJsonBody: false, + headerParams: [], method: "GET", operationId: "fakeOp", path: "/fake", @@ -899,6 +901,27 @@ describe("createOperationCommand description", () => { expect(flag.allowNo).toBe(true); }); + it("registers generated header params as lowercase flags", () => { + const Cmd = createOperationCommand( + makeOperation({ + headerParams: [ + { + description: "Optional idempotency key", + enum: null, + name: "Idempotency-Key", + required: false, + type: "string", + }, + ], + }), + ) as unknown as { + flags: Record; + }; + + expect(Cmd.flags["idempotency-key"]).toBeDefined(); + expect(Cmd.flags["Idempotency-Key"]).toBeUndefined(); + }); + it("adds --envelope to non-binary generated commands", () => { const Cmd = createOperationCommand(makeOperation()) as unknown as { flags: Record; diff --git a/cli-rust/Cargo.lock b/cli-rust/Cargo.lock new file mode 100644 index 00000000..3aa5fda6 --- /dev/null +++ b/cli-rust/Cargo.lock @@ -0,0 +1,1999 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2", + "signature", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primitive-rust" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "anyhow", + "base64", + "chrono", + "dirs", + "flate2", + "hkdf", + "k256", + "libc", + "rand 0.8.7", + "reqwest", + "serde", + "serde_json", + "sha2", + "urlencoding", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/cli-rust/Cargo.toml b/cli-rust/Cargo.toml new file mode 100644 index 00000000..abe79beb --- /dev/null +++ b/cli-rust/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "primitive-rust" +version = "0.1.0" +edition = "2021" +default-run = "primitive-rust" +description = "Rust port of the Primitive CLI for local parity and release-readiness validation." +license = "MIT" +repository = "https://github.com/primitivedotdev/sdks" +homepage = "https://primitive.dev" +readme = "README.md" +keywords = ["primitive", "cli", "email", "agents"] +publish = false +exclude = ["dist/**"] + +[[bin]] +name = "primitive-rust" +path = "src/main.rs" + +[[bin]] +name = "primitive" +path = "src/bin/primitive.rs" + +[[bin]] +name = "prim" +path = "src/bin/prim.rs" + +[dependencies] +anyhow = "1.0.98" +aes-gcm = "0.10.3" +base64 = "0.22.1" +dirs = "5.0.1" +flate2 = { version = "1.0.35", default-features = false, features = ["rust_backend"] } +hkdf = "0.12.4" +k256 = { version = "0.13.4", features = ["ecdsa"] } +libc = "0.2.186" +rand = "0.8.5" +reqwest = { version = "0.12.22", default-features = false, features = ["blocking", "json", "rustls-tls"] } +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.140" +sha2 = "0.10.9" +chrono = { version = "0.4.38", default-features = false, features = ["clock", "std"] } +urlencoding = "2.1.3" diff --git a/cli-rust/README.md b/cli-rust/README.md new file mode 100644 index 00000000..c00c84d3 --- /dev/null +++ b/cli-rust/README.md @@ -0,0 +1,154 @@ +# primitive-rust + +`cli-rust/` is the Rust port of the Primitive CLI. It is checked for command +surface and behavior parity with the supported Node-distributed CLI. The GitHub +release archive installs the user-facing `primitive` and `prim` commands. + +The source checkout also keeps the explicit `primitive-rust` binary name so +local builds can be distinguished from the Node-distributed CLI. + +## Local Install + +From the repository root: + +```bash +cargo install --path cli-rust --bin primitive-rust --locked +primitive-rust --help +``` + +For an all-bin release-name local install: + +```bash +cargo install --path cli-rust --locked +primitive --version +prim --version +``` + +Uninstall the local source install with: + +```bash +cargo uninstall primitive-rust +``` + +## Authentication + +`primitive` uses the same config directory and credential file shapes as the +Node CLI. Use the same environment variables for non-interactive use: + +```bash +PRIMITIVE_API_KEY=prim_... primitive whoami +PRIMITIVE_API_KEY=prim_... primitive send --to alice@example.com --body "Hello" +``` + +## Checks + +The root Makefile owns the Rust CLI verification path: + +```bash +make rust-cli-check +make rust-cli-package +make rust-cli-smoke +make rust-cli-release-smoke +make rust-cli-archive-smoke +make rust-cli-windows-archive-smoke +make rust-cli-install-smoke +make cli-archive-parity +make cli-parity +``` + +`make cli-parity` runs the generated command-surface check, a black-box help +sweep for every Node-accepted command spelling, and request/response fixture +parity through the exact command shapes users type. + +`make rust-cli-archive-smoke` packages the optimized binary as extracted +`primitive` and `prim` commands, checks that `README.md` and `LICENSE` are +present, checks for unexpected native OpenSSL/libz linkage where the platform +tooling is available, and smoke-tests that archive from an isolated temporary +working directory. +On Linux, release-platform CI builds the public `linux-x64` and `linux-arm64` +archives from `x86_64-unknown-linux-musl` and `aarch64-unknown-linux-musl` +Rust targets and runs a static-link portability check against those binaries. + +Build a local release archive in `cli-rust/dist/` with: + +```bash +make rust-cli-dist +``` + +The release workflow uploads that archive plus its `.sha256` checksum to the +`cli-rust-vX.Y.Z` GitHub release. It builds Linux x64, Linux arm64, +macOS x64, macOS arm64, and Windows x64 archives. Unix archives are `.tar.gz`; +Windows archives are `.zip` files containing `primitive.exe` and `prim.exe`. +Every archive also includes a top-level `LICENSE` and concise `README.md` +install note. Linux archives are musl-linked and do not require glibc. +Before publishing, the release workflow extracts the packaged Linux x64 archive +and runs authenticated live smoke through that exact `primitive` binary. That +gate requires the `PRIMITIVE_API_KEY` repository secret and stays read-only. + +Install an uploaded macOS or Linux archive with the checked-in installer: + +```bash +curl -fsSL https://raw.githubusercontent.com/primitivedotdev/sdks/main/scripts/install-rust-cli.sh | bash -s -- --version 0.1.0 +``` + +Install a Windows archive from PowerShell: + +```powershell +iwr https://raw.githubusercontent.com/primitivedotdev/sdks/main/scripts/install-rust-cli.ps1 -OutFile install-rust-cli.ps1 +.\install-rust-cli.ps1 -Version 0.1.0 +``` + +The installers detect the local platform, verify the `.sha256` checksum, and +install `primitive`/`prim` into `$HOME/.local/bin` by default. On Windows they +install `primitive.exe` and `prim.exe`; Windows ARM64 hosts use the Windows x64 +archive until a native ARM64 release artifact exists. Re-run the installer with +a newer version to update. If the install directory is not on `PATH`, the +installer prints the exact directory to add or the full command path to run. +Remove those installed files to uninstall. + +## Live Smoke + +The live smoke script always starts with local no-key checks for version, help, +completion, manifest, describe, and config behavior. If `PRIMITIVE_API_KEY` is +missing, those checks run and the script exits `2` before any authenticated +request. + +For a local no-auth success exit, run `make rust-cli-live-smoke-no-key`. +The harness uses an isolated temp config directory and removes it by default. +Do not pass `--keep-tmp` with a real key unless you plan to delete the kept +directory after debugging. The default authenticated suite does not pass the +live key through child process arguments. + +Read-only live smoke requires an API key: + +```bash +PRIMITIVE_API_KEY=prim_... make rust-cli-live-smoke +``` + +Opt into mutating product-path checks only with a disposable key: + +```bash +PRIMITIVE_API_KEY=prim_... RUST_CLI_LIVE_SMOKE_ARGS=--include-mutating make rust-cli-live-smoke +``` + +The mutating suite writes and deletes a temporary memory key and uploads then +downloads small and zero-byte encrypted payloads. + +Opt into the `--api-key` flag case only on a trusted local machine: + +```bash +PRIMITIVE_API_KEY=prim_... RUST_CLI_LIVE_SMOKE_ARGS=--include-secret-argv make rust-cli-live-smoke +``` + +Opt into a real send-path smoke only with disposable test addresses: + +```bash +PRIMITIVE_API_KEY=prim_... \ +PRIMITIVE_RUST_LIVE_SEND_FROM=agent@example.com \ +PRIMITIVE_RUST_LIVE_SEND_TO=smoke@example.net \ +RUST_CLI_LIVE_SMOKE_ARGS=--include-email-e2e \ +make rust-cli-live-smoke +``` + +The email E2E suite uses the real `primitive send --wait` product path. It does +not synthesize inbound mail or use raw SMTP injection. diff --git a/cli-rust/src/agent_commands.rs b/cli-rust/src/agent_commands.rs new file mode 100644 index 00000000..f90d59e1 --- /dev/null +++ b/cli-rust/src/agent_commands.rs @@ -0,0 +1,280 @@ +use crate::{client, config}; +use anyhow::{anyhow, Context, Result}; +use reqwest::Method; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{self, BufRead, Write}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentUpgradePlan { + pub auth: BTreeMap, + pub code: Option, + pub email: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApiRequest { + pub method: String, + pub path: String, + pub body: Value, +} + +#[derive(Debug, Default)] +struct ParsedArgs { + bool_flags: BTreeSet, + flags: BTreeMap, + positionals: Vec, +} + +pub fn dispatch(args: &[String]) -> Result<()> { + if args.is_empty() || matches!(args[0].as_str(), "--help" | "-h") { + print_help(); + return Ok(()); + } + match args[0].as_str() { + "upgrade" => { + if args.len() > 1 && matches!(args[1].as_str(), "--help" | "-h") { + print_upgrade_help(); + return Ok(()); + } + execute_upgrade(&build_agent_upgrade_plan(&args[1..])?) + } + other => Err(anyhow!("Unknown agent command `{other}`")), + } +} + +pub fn execute_command(command: &str, args: &[String]) -> Result<()> { + match command { + "agent:upgrade" => { + if args + .first() + .is_some_and(|arg| matches!(arg.as_str(), "--help" | "-h")) + { + print_upgrade_help(); + return Ok(()); + } + execute_upgrade(&build_agent_upgrade_plan(args)?) + } + other => Err(anyhow!("Unknown agent command `{other}`")), + } +} + +pub fn is_agent_friendly_command(command: &str) -> bool { + matches!(command, "agent:upgrade") +} + +pub fn build_agent_upgrade_plan(args: &[String]) -> Result { + let parsed = parse_args(args, &["api-base-url", "api-key", "code", "email"], &[])?; + if !parsed.positionals.is_empty() { + return Err(anyhow!( + "Unexpected argument: {}. Use --email and --code with agent upgrade.", + parsed.positionals[0] + )); + } + let auth = auth_flags(&parsed); + Ok(AgentUpgradePlan { + auth, + code: parsed.flags.get("code").cloned(), + email: parsed.flags.get("email").cloned(), + }) +} + +pub fn build_start_agent_claim_request(email: &str) -> ApiRequest { + ApiRequest { + method: "POST".to_string(), + path: "/agent/claim/start".to_string(), + body: json!({ "email": email }), + } +} + +pub fn build_verify_agent_claim_request(code: &str) -> ApiRequest { + ApiRequest { + method: "POST".to_string(), + path: "/agent/claim/verify".to_string(), + body: json!({ "verification_code": code }), + } +} + +fn execute_upgrade(plan: &AgentUpgradePlan) -> Result<()> { + let auth = config::resolve_auth(&plan.auth)?; + if auth.api_key.is_none() { + return Err(anyhow!( + "Not authenticated: set PRIMITIVE_API_KEY, pass --api-key, or run `primitive login`." + )); + } + + let email = match &plan.email { + Some(email) => email.clone(), + None => prompt_required("Email to confirm: ")?, + }; + let start = execute_agent_request(&build_start_agent_claim_request(&email), &auth)?; + if start.get("data").is_none() { + return Err(anyhow!( + "{}", + serde_json::to_string_pretty(&start).unwrap_or_else(|_| start.to_string()) + )); + } + eprintln!("Verification code sent to {email}."); + + let code = match &plan.code { + Some(code) => code.clone(), + None => prompt_required("Verification code: ")?, + }; + let verified = execute_agent_request(&build_verify_agent_claim_request(&code), &auth)?; + let result = verified + .get("data") + .cloned() + .filter(|value| !value.is_null()) + .ok_or_else(|| { + anyhow!( + "{}", + serde_json::to_string_pretty(&verified).unwrap_or_else(|_| verified.to_string()) + ) + })?; + println!("{}", serde_json::to_string_pretty(&result)?); + let plan_name = result + .get("plan") + .and_then(Value::as_str) + .unwrap_or("developer"); + eprintln!( + "Upgraded to {plan_name}. Your API key and managed inbox carry over; the send cap is lifted." + ); + Ok(()) +} + +fn execute_agent_request(request: &ApiRequest, auth: &config::ResolvedAuth) -> Result { + let http = client::http_client()?; + let method: Method = request.method.parse()?; + let url = format!( + "{}{}", + auth.api_base_url.trim_end_matches('/'), + request.path.as_str() + ); + let mut builder = http.request(method, url); + builder = client::apply_headers(builder, auth, true, &[], true)?; + builder = builder.json(&request.body); + let response = builder.send().context("Could not reach Primitive API")?; + let (status, bytes, json) = client::parse_response(response)?; + if status >= 400 { + return Err(client::error_for_status_with_hints( + status, + json.as_ref(), + &bytes, + )); + } + json.ok_or_else(|| anyhow!("Primitive API returned an empty response")) +} + +fn prompt_required(question: &str) -> Result { + let stdin = io::stdin(); + let mut reader = stdin.lock(); + prompt_required_from(question, &mut reader, &mut io::stderr()) +} + +pub fn prompt_required_from( + question: &str, + reader: &mut impl BufRead, + writer: &mut impl Write, +) -> Result { + loop { + write!(writer, "{question}")?; + writer.flush()?; + let mut answer = String::new(); + let bytes = reader.read_line(&mut answer)?; + if bytes == 0 { + return Err(anyhow!("No input received for prompt.")); + } + let answer = answer.trim(); + if !answer.is_empty() { + return Ok(answer.to_string()); + } + } +} + +fn parse_args(args: &[String], value_flags: &[&str], bool_flags: &[&str]) -> Result { + let value_flags: BTreeSet<&str> = value_flags.iter().copied().collect(); + let bool_flags: BTreeSet<&str> = bool_flags.iter().copied().collect(); + let mut parsed = ParsedArgs::default(); + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + if !arg.starts_with("--") { + parsed.positionals.push(arg.clone()); + index += 1; + continue; + } + let raw = arg.trim_start_matches("--"); + let (name, inline_value) = match raw.split_once('=') { + Some((name, value)) => (name, Some(value.to_string())), + None => (raw, None), + }; + if value_flags.contains(name) { + let value = match inline_value { + Some(value) => value, + None => { + index += 1; + args.get(index) + .cloned() + .ok_or_else(|| anyhow!("Missing value for --{name}"))? + } + }; + parsed.flags.insert(name.to_string(), value); + index += 1; + continue; + } + if bool_flags.contains(name) { + if inline_value.is_some() { + return Err(anyhow!("Flag --{name} does not take a value")); + } + parsed.bool_flags.insert(name.to_string()); + index += 1; + continue; + } + return Err(anyhow!("Unknown flag --{name}")); + } + Ok(parsed) +} + +fn auth_flags(parsed: &ParsedArgs) -> BTreeMap { + let mut flags = BTreeMap::new(); + for name in ["api-base-url", "api-key"] { + if let Some(value) = parsed.flags.get(name) { + flags.insert(name.to_string(), value.clone()); + } + } + flags +} + +fn print_help() { + let bin = crate::display_bin_name(); + println!("Primitive agent commands:"); + println!(" {bin} agent upgrade --email [--code ]"); + println!(" {bin} agent claim --email "); + println!(" {bin} agent claim-verify --code "); + println!(" {bin} agent claim-link"); + println!(" {bin} agent create [flags]"); +} + +fn print_upgrade_help() { + print!("{}", agent_upgrade_help_text()); +} + +pub fn agent_upgrade_help_text() -> String { + let bin = crate::display_bin_name(); + format!( + r#"Upgrade an agent account to developer (email confirmation) + +USAGE + {bin} agent upgrade [--email ] [--code ] [--api-key ] [--api-base-url ] + +FLAGS + --api-base-url Override the API base URL. + --api-key Agent API key. + --code Verification code from the email. Prompted if omitted. + --email Email to confirm. Prompted if omitted. + +EXAMPLES + {bin} agent upgrade --email you@example.com +"# + ) +} diff --git a/cli-rust/src/api.rs b/cli-rust/src/api.rs new file mode 100644 index 00000000..2ad8822c --- /dev/null +++ b/cli-rust/src/api.rs @@ -0,0 +1,1905 @@ +use crate::client; +use crate::config; +use crate::manifest::{self, flag_name, OperationManifest, ParameterManifest}; +use anyhow::{anyhow, Result}; +use chrono::{DateTime, SecondsFormat, Utc}; +use serde_json::{json, Map, Number, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::error::Error; +use std::fs; +use std::time::{Duration, Instant, SystemTime}; + +#[derive(Debug, Default)] +pub struct Invocation { + pub flags: BTreeMap, + pub bool_flags: BTreeMap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FlagKind { + Bool { allow_no: bool }, + Value, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct FlagSpec { + canonical: String, + kind: FlagKind, +} + +pub const UNAUTHORIZED_ERROR_HINT: &str = "Hint: run `primitive signin`, pass --api-key explicitly, or set PRIMITIVE_API_KEY in your environment. `primitive whoami` is the fastest way to verify auth is live."; +pub const INCOMPLETE_DOMAIN_VERIFICATION_HINT: &str = "Domain verification is incomplete. Add or fix the DNS records shown above, or run `primitive domains zone-file --id ` to download the complete zone file, then retry `primitive domains verify --id `."; +pub const GENERIC_NETWORK_ERROR_HINT: &str = "Hint: the request could not reach Primitive. Check network egress, DNS, firewall, and proxy settings. `primitive doctor` reports the local environment in one shot."; +const API_KEY_FLAG_DESCRIPTION: &str = + "Primitive API key override (defaults to PRIMITIVE_API_KEY or saved OAuth login credentials)"; +const ENVELOPE_FLAG_DESCRIPTION: &str = "Print the full response envelope, including pagination metadata such as meta.cursor. Defaults to printing only the data payload for backward compatibility."; +const JSON_FLAG_DESCRIPTION: &str = + "Accepted for consistency with task-focused commands. Generated API commands already print JSON by default."; +const TIME_FLAG_DESCRIPTION: &str = "Print the wall-clock duration of this command to stderr after it completes (e.g. `[time: 1.34s]`). Useful for measuring `--wait` send latency, comparing CLI overhead, or capturing timing in scripts."; +const RAW_BODY_FLAG_DESCRIPTION: &str = "Full request body as raw JSON. Escape hatch for nested or complex fields (e.g. arrays); prefer per-field flags (e.g. --to, --from, --body-text) when available."; +const BODY_FILE_FLAG_DESCRIPTION: &str = + "Path to a JSON file used as the request body. Same role as --raw-body for callers passing a saved payload."; +const OUTPUT_FLAG_DESCRIPTION: &str = "Write binary response bytes to a file"; + +const NETWORK_UNREACHABLE_HINT: &str = "Hint: the network is unreachable. Check egress rules, firewall policy, and proxy configuration. `primitive doctor` reports the local environment in one shot."; +const NETWORK_REFUSED_HINT: &str = "Hint: the server refused the connection. Check that your firewall allows egress to *.primitive.dev and that PRIMITIVE_API_BASE_URL overrides point at a reachable host. `primitive doctor` reports the local environment in one shot."; +const NETWORK_TIMEOUT_HINT: &str = "Hint: the connection timed out. Check egress rules and proxy configuration. `primitive doctor` reports the local environment in one shot."; +const NETWORK_DNS_HINT: &str = "Hint: DNS lookup failed. Check resolver configuration and try `curl -v https://api.primitive.dev/v1/account` to confirm the host resolves. `primitive doctor` reports the local environment in one shot."; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FunctionEndpointMatch { + pub endpoint_id: String, + pub function_id: String, +} + +pub fn parse_invocation(args: &[String]) -> Result { + let mut invocation = Invocation::default(); + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + if !arg.starts_with("--") { + return Err(crate::usage_error(format!("Unexpected argument: {arg}"))); + } + if let Some(name) = arg.strip_prefix("--no-") { + invocation.bool_flags.insert(name.to_string(), false); + index += 1; + continue; + } + let raw = arg.trim_start_matches("--"); + if let Some((name, value)) = raw.split_once('=') { + invocation.flags.insert(name.to_string(), value.to_string()); + index += 1; + continue; + } + let name = raw.to_string(); + if matches!(name.as_str(), "json" | "envelope" | "time") { + invocation.bool_flags.insert(name, true); + index += 1; + continue; + } + if index + 1 < args.len() && !args[index + 1].starts_with("--") { + invocation.flags.insert(name, args[index + 1].clone()); + index += 2; + } else { + invocation.bool_flags.insert(name, true); + index += 1; + } + } + Ok(invocation) +} + +pub fn execute_operation(operation: &OperationManifest, args: &[String]) -> Result { + if is_help_request(args) { + print!("{}", operation_help_text(operation)); + return Ok(0); + } + let invocation = parse_operation_invocation(operation, args)?; + let start = Instant::now(); + let auth = config::resolve_auth(&invocation.flags)?; + let body = build_body(operation, &invocation)?; + let url = build_url(operation, &auth.api_base_url, &invocation)?; + let header_values = collect_header_values(operation, &invocation)?; + let client = client::http_client()?; + let method = operation.method.parse()?; + let mut request = client.request(method, url); + let has_body = body.is_some(); + request = client::apply_headers( + request, + &auth, + manifest::operation_requires_auth(operation), + &header_values, + has_body, + )?; + if let Some(body) = &body { + request = request.json(body); + } + + let response = request.send(); + let response = match response { + Ok(response) => response, + Err(error) => { + let code = network_error_code(&error); + let hint_key = format!("{code} {error}"); + eprintln!( + "{}", + json!({ + "code": code, + "message": error.to_string(), + }) + ); + eprintln!( + "{}", + network_error_hint_for_text(&hint_key).unwrap_or(GENERIC_NETWORK_ERROR_HINT) + ); + return Ok(1); + } + }; + + let (status, bytes, json) = client::parse_response(response)?; + if status >= 400 { + let error_code = extract_error_code(json.as_ref()).map(str::to_string); + eprintln!( + "{}", + client::error_for_status(status, json.as_ref(), &bytes) + ); + if let Some(hint) = error_hint_for_payload(Some(status), json.as_ref()) { + eprintln!("{hint}"); + } + if let Some(redirect) = maybe_fetch_function_endpoint_redirect( + operation, + &invocation, + &auth, + &client, + error_code.as_deref(), + ) { + eprintln!("{redirect}"); + } + return Ok(1); + } + + if operation.binary_response { + if let Some(output) = invocation.flags.get("output") { + fs::write(output, bytes)?; + } else { + use std::io::Write; + std::io::stdout().write_all(&bytes)?; + } + print_time(&invocation, start); + return Ok(0); + } + + let envelope = json.unwrap_or(Value::Null); + if let Some(cursor) = envelope + .get("meta") + .and_then(|meta| meta.get("cursor")) + .and_then(Value::as_str) + { + eprintln!("next cursor: {cursor}"); + } + + let data = envelope.get("data").cloned().unwrap_or(Value::Null); + if data.as_array().is_some_and(Vec::is_empty) { + if let Some(hint) = empty_result_hint(&operation.sdk_name) { + eprintln!("{hint}"); + } + } + write_idempotent_replay_banner(&data); + + if operation.sdk_name == "verifyAgentSignup" { + maybe_save_signup_credentials(&data, &auth)?; + } + + let incomplete_domain_verification = is_incomplete_domain_verification(operation, &envelope); + let output = if invocation.bool_flags.get("envelope") == Some(&true) { + envelope + } else { + data + }; + println!("{}", serde_json::to_string_pretty(&output)?); + let exit_code = if incomplete_domain_verification { + eprintln!("{INCOMPLETE_DOMAIN_VERIFICATION_HINT}"); + 1 + } else { + 0 + }; + print_time(&invocation, start); + Ok(exit_code) +} + +fn is_help_request(args: &[String]) -> bool { + args.iter() + .any(|arg| matches!(arg.as_str(), "--help" | "-h")) +} + +fn parse_operation_invocation( + operation: &OperationManifest, + args: &[String], +) -> Result { + parse_invocation_with_allowed_flags(args, &operation_flag_kinds(operation)) +} + +fn parse_invocation_with_allowed_flags( + args: &[String], + allowed: &BTreeMap, +) -> Result { + let mut invocation = Invocation::default(); + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + if !arg.starts_with("--") || arg == "--" { + return Err(crate::usage_error(format!("Unexpected argument: {arg}"))); + } + if let Some(name) = arg.strip_prefix("--no-") { + match allowed.get(name) { + Some(FlagSpec { + canonical, + kind: FlagKind::Bool { allow_no: true }, + }) => { + insert_bool_flag(&mut invocation, canonical, false)?; + index += 1; + continue; + } + Some(_) | None => { + return Err(crate::usage_error(format!("Nonexistent flag: --no-{name}"))); + } + } + } + + let raw = arg.trim_start_matches("--"); + if let Some((name, value)) = raw.split_once('=') { + match allowed.get(name) { + Some(FlagSpec { + canonical, + kind: FlagKind::Value, + }) => { + insert_value_flag(&mut invocation, canonical, value.to_string())?; + } + Some(FlagSpec { + canonical, + kind: FlagKind::Bool { .. }, + }) => { + insert_bool_flag(&mut invocation, canonical, true)?; + return Err(crate::usage_error(format!("Unexpected argument: {value}"))); + } + None => return Err(crate::usage_error(format!("Nonexistent flag: --{name}"))), + } + index += 1; + continue; + } + + let name = raw.to_string(); + match allowed.get(name.as_str()) { + Some(FlagSpec { + canonical, + kind: FlagKind::Bool { .. }, + }) => { + insert_bool_flag(&mut invocation, canonical, true)?; + index += 1; + } + Some(FlagSpec { + canonical, + kind: FlagKind::Value, + }) => { + if index + 1 >= args.len() || args[index + 1].starts_with("--") { + return Err(crate::usage_error(format!("Flag --{name} expects a value"))); + } + insert_value_flag(&mut invocation, canonical, args[index + 1].clone())?; + index += 2; + } + None => return Err(crate::usage_error(format!("Nonexistent flag: --{name}"))), + } + } + Ok(invocation) +} + +fn insert_value_flag(invocation: &mut Invocation, canonical: &str, value: String) -> Result<()> { + reject_duplicate_flag(invocation, canonical)?; + invocation.flags.insert(canonical.to_string(), value); + Ok(()) +} + +fn insert_bool_flag(invocation: &mut Invocation, canonical: &str, value: bool) -> Result<()> { + if invocation.flags.contains_key(canonical) { + return Err(crate::usage_error(format!( + "Flag --{canonical} can only be specified once" + ))); + } + invocation.bool_flags.insert(canonical.to_string(), value); + Ok(()) +} + +fn reject_duplicate_flag(invocation: &Invocation, canonical: &str) -> Result<()> { + if invocation.flags.contains_key(canonical) || invocation.bool_flags.contains_key(canonical) { + return Err(crate::usage_error(format!( + "Flag --{canonical} can only be specified once" + ))); + } + Ok(()) +} + +fn operation_flag_kinds(operation: &OperationManifest) -> BTreeMap { + let mut flags = BTreeMap::new(); + insert_flag_spec(&mut flags, "api-key", "api-key", FlagKind::Value); + insert_flag_spec(&mut flags, "api-base-url", "api-base-url", FlagKind::Value); + insert_flag_spec( + &mut flags, + "time", + "time", + FlagKind::Bool { allow_no: false }, + ); + + if operation.binary_response { + insert_flag_spec(&mut flags, "output", "output", FlagKind::Value); + } else { + insert_flag_spec( + &mut flags, + "json", + "json", + FlagKind::Bool { allow_no: false }, + ); + insert_flag_spec( + &mut flags, + "envelope", + "envelope", + FlagKind::Bool { allow_no: false }, + ); + } + + for parameter in operation + .path_params + .iter() + .chain(operation.query_params.iter()) + .chain(operation.header_params.iter()) + { + let kind = if parameter.type_name == "boolean" { + FlagKind::Bool { allow_no: false } + } else { + FlagKind::Value + }; + let name = flag_name(¶meter.name); + insert_flag_spec(&mut flags, &name, &name, kind); + } + + if operation.has_json_body { + insert_flag_spec(&mut flags, "raw-body", "raw-body", FlagKind::Value); + insert_flag_spec(&mut flags, "body-file", "body-file", FlagKind::Value); + for (property, schema) in request_body_properties(operation) { + let canonical = flag_name(&property); + let kind = if body_scalar_type(schema).as_deref() == Some("boolean") { + FlagKind::Bool { allow_no: true } + } else { + FlagKind::Value + }; + insert_flag_spec(&mut flags, &canonical, &canonical, kind); + for alias in body_flag_aliases(&operation.sdk_name, &property) { + insert_flag_spec(&mut flags, &alias, &canonical, kind); + } + } + } + + flags +} + +fn insert_flag_spec( + flags: &mut BTreeMap, + name: &str, + canonical: &str, + kind: FlagKind, +) { + flags.insert( + name.to_string(), + FlagSpec { + canonical: canonical.to_string(), + kind, + }, + ); +} + +pub fn operation_help_text(operation: &OperationManifest) -> String { + operation_help_text_for_command(operation, &manifest::operation_id(operation)) +} + +pub fn operation_help_text_for_command(operation: &OperationManifest, command: &str) -> String { + let command = command.split(':').collect::>().join(" "); + let description = operation_description(operation); + let mut lines = Vec::new(); + lines.push( + operation + .summary + .as_deref() + .unwrap_or_else(|| { + operation + .description + .as_deref() + .unwrap_or("Primitive API operation") + }) + .to_string(), + ); + lines.push(String::new()); + lines.push("USAGE".to_string()); + lines.push(format!( + " $ {} {command}{}", + crate::display_bin_name(), + operation_usage_suffix(operation) + )); + lines.push(String::new()); + lines.push("FLAGS".to_string()); + for flag in operation_help_flags(operation) { + lines.push(flag.render()); + } + lines.push(String::new()); + lines.push("DESCRIPTION".to_string()); + for line in description.lines() { + if line.is_empty() { + lines.push(String::new()); + } else { + lines.push(format!(" {line}")); + } + } + lines.push(String::new()); + lines.join("\n") +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HelpFlagValue { + Bool, + BoolAllowNo, + Value, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct HelpFlag { + default_value: Option, + description: String, + enum_values: Vec, + name: String, + required: bool, + value: HelpFlagValue, +} + +impl HelpFlag { + fn usage_token(&self) -> String { + let token = match self.value { + HelpFlagValue::Bool | HelpFlagValue::BoolAllowNo => format!("--{}", self.name), + HelpFlagValue::Value => format!("--{} {}", self.name, self.usage_value_label()), + }; + if self.required { + token + } else { + format!("[{token}]") + } + } + + fn render(&self) -> String { + let declaration = match self.value { + HelpFlagValue::Bool => format!("--{}", self.name), + HelpFlagValue::BoolAllowNo => format!("--[no-]{}", self.name), + HelpFlagValue::Value => format!("--{}={}", self.name, self.declaration_value_label()), + }; + let required = if self.required { "(required) " } else { "" }; + let default = self + .default_value + .as_ref() + .map(|value| format!("[default: {value}] ")) + .unwrap_or_default(); + let options = if self.enum_values.is_empty() { + String::new() + } else { + format!(" ", self.enum_values.join("|")) + }; + let description = format!("{required}{default}{}{options}", self.description); + let mut description_lines = description.lines(); + let first_description = description_lines.next().unwrap_or(""); + let first = if self.name == "api-key" { + format!(" {declaration} [env: PRIMITIVE_API_KEY] {first_description}") + } else { + format!(" {declaration} {first_description}") + }; + let continuation_indent = format!(" {}", " ".repeat(declaration.len() + 2)); + let rest = description_lines + .map(|line| format!("{continuation_indent}{line}")) + .collect::>(); + if rest.is_empty() { + return first; + } + format!("{first}\n{}", rest.join("\n")) + } + + fn usage_value_label(&self) -> String { + if self.enum_values.is_empty() { + "".to_string() + } else { + self.enum_values.join("|") + } + } + + fn declaration_value_label(&self) -> &'static str { + if self.enum_values.is_empty() { + "" + } else { + "