diff --git a/.github/readme-source.json b/.github/readme-source.json index e713697..fd84e14 100644 --- a/.github/readme-source.json +++ b/.github/readme-source.json @@ -1,6 +1,6 @@ { "version": 1, - "sha256": "c492ea98e92ec200d47523e13a7cc8af93cd6f081c7352ff1959f202b8622c50", + "sha256": "fded3f18b290aa29d4d317358c5296e9477be3c5b785449f0b0066a6e3d3f08c", "history": { "forbidden_commits": [ "3cbdabb71650fbca119b17e89ceed577f6bf81cd" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07035a1..d777bd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,25 +1,32 @@ name: CI - -on: +'on': pull_request: push: - branches: - - main - + branches: [main] +permissions: + contents: read +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true jobs: - cli: + native-cli: + name: Native CLI + uses: ./.github/workflows/cli-native.yml + permissions: + contents: read + quality: + name: Rust formatting and lint runs-on: ubuntu-24.04 + timeout-minutes: 15 steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - - name: Test CLI - run: go test ./... - - - name: Build CLI - run: make build + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Install pinned Rust + run: rustup toolchain install 1.94.0 --profile minimal --component rustfmt --component clippy + - name: Check source + run: | + set -euo pipefail + cargo +1.94.0 fmt --check + cargo +1.94.0 clippy --locked --all-targets -- -D warnings diff --git a/.github/workflows/cli-native.yml b/.github/workflows/cli-native.yml new file mode 100644 index 0000000..0ccb5f1 --- /dev/null +++ b/.github/workflows/cli-native.yml @@ -0,0 +1,107 @@ +name: Native CLI +'on': + workflow_call: + inputs: + release: + type: boolean + default: false + version: + type: string + default: dev + date: + type: string + default: unknown +permissions: + contents: read +jobs: + native: + name: CLI ${{ matrix.platform }} ${{ matrix.arch }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + rust-target: x86_64-unknown-linux-musl + platform: Linux + arch: x86_64 + extension: tar.gz + - runner: ubuntu-24.04-arm + rust-target: aarch64-unknown-linux-musl + platform: Linux + arch: arm64 + extension: tar.gz + - runner: macos-15-intel + rust-target: x86_64-apple-darwin + platform: Darwin + arch: x86_64 + extension: tar.gz + - runner: macos-15 + rust-target: aarch64-apple-darwin + platform: Darwin + arch: arm64 + extension: tar.gz + - runner: windows-2025 + rust-target: x86_64-pc-windows-msvc + platform: Windows + arch: x86_64 + extension: zip + - runner: windows-11-arm + rust-target: aarch64-pc-windows-msvc + platform: Windows + arch: arm64 + extension: zip + defaults: + run: + shell: bash + env: + VERSION: ${{ inputs.version }} + COMMIT: ${{ github.sha }} + DATE: ${{ inputs.date }} + CARGO_INCREMENTAL: '0' + RUST_TARGET: ${{ matrix.rust-target }} + CC_x86_64_unknown_linux_musl: musl-gcc + CC_aarch64_unknown_linux_musl: musl-gcc + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Install pinned Rust + run: rustup toolchain install 1.94.0 --profile minimal --target "$RUST_TARGET" + - name: Link Windows C runtime statically + if: runner.os == 'Windows' + run: echo 'RUSTFLAGS=-C target-feature=+crt-static' >> "$GITHUB_ENV" + - name: Install native musl linker + if: runner.os == 'Linux' + run: | + set -euo pipefail + sudo apt-get update -qq + sudo apt-get install -y musl-tools + - name: Test native CLI + run: | + set -euo pipefail + cargo +1.94.0 test --locked --target "$RUST_TARGET" + cargo +1.94.0 build --locked --target "$RUST_TARGET" + binary="target/$RUST_TARGET/debug/pxlb" + [ "$RUNNER_OS" != Windows ] || binary+=.exe + PXLB_TEST_BINARY="$binary" python3 tests/cli_test.py + - name: Build release archive + if: inputs.release + env: + RELEASE_PLATFORM: ${{ matrix.platform }} + RELEASE_ARCH: ${{ matrix.arch }} + RELEASE_EXTENSION: ${{ matrix.extension }} + run: | + set -euo pipefail + cargo +1.94.0 build --release --locked --target "$RUST_TARGET" + python3 scripts/package-cli.py + - name: Upload native archive + if: inputs.release + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 + with: + name: native-cli-${{ matrix.platform }}-${{ matrix.arch }} + path: dist/pxlb_${{ matrix.platform }}_${{ matrix.arch }}.${{ matrix.extension }} + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f877e84..06a3704 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,120 +1,117 @@ name: Release - -on: +'on': workflow_dispatch: inputs: bump: description: Version bump required: true type: choice - options: - - patch - - minor - - major - -permissions: - contents: write - + options: [patch, minor, major] +permissions: {} concurrency: release-${{ github.repository }} - jobs: - release: + identity: + name: Select immutable release identity runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + outputs: + version: ${{ steps.release.outputs.version }} + tag: ${{ steps.release.outputs.tag }} + date: ${{ steps.release.outputs.date }} steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - - name: Determine release version - id: release - env: - BUMP: ${{ inputs.bump }} - run: | - set -euo pipefail - if [ "$GITHUB_REF" != "refs/heads/main" ]; then - echo 'Run releases from the main branch.' >&2 - exit 1 - fi - git fetch --tags --force - latest="$(git tag --list 'v[0-9]*' | sed -E 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1 || true)" - if [ -z "$latest" ]; then - VERSION="0.0.1" - else - IFS=. read -r major minor patch <<< "$latest" - case "$BUMP" in - major) major=$((major + 1)); minor=0; patch=0 ;; - minor) minor=$((minor + 1)); patch=0 ;; - patch) patch=$((patch + 1)) ;; - *) echo "Unsupported bump: $BUMP" >&2; exit 1 ;; - esac - VERSION="${major}.${minor}.${patch}" - fi - TAG="v${VERSION}" - if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then - echo "Tag already exists: $TAG" >&2 - exit 1 - fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - - - name: Test CLI - run: go test ./... - - - name: Build release artifacts - env: - VERSION: ${{ steps.release.outputs.version }} - COMMIT: ${{ github.sha }} - run: | - set -euo pipefail - DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - mkdir -p dist - - build() { - goos="$1" - goarch="$2" - os_label="$3" - arch_label="$4" - archive_ext="$5" - exe="" - if [ "$goos" = "windows" ]; then - exe=".exe" - fi - - name="pxlb_${os_label}_${arch_label}" - out_dir="dist/$name" - mkdir -p "$out_dir" - out_path="$PWD/$out_dir/pxlb$exe" - GOOS="$goos" GOARCH="$goarch" CGO_ENABLED=0 go build \ - -ldflags "-s -w -X main.version=$VERSION -X main.commit=$COMMIT -X main.date=$DATE" \ - -o "$out_path" . - cp README.md "$out_dir/README.md" - - if [ "$archive_ext" = "zip" ]; then - (cd "$out_dir" && zip -q "../$name.zip" "pxlb$exe" README.md) - else - tar -C "$out_dir" -czf "dist/$name.tar.gz" "pxlb$exe" README.md - fi - } - - build linux amd64 Linux x86_64 tar.gz - build linux arm64 Linux arm64 tar.gz - build darwin amd64 Darwin x86_64 tar.gz - build darwin arm64 Darwin arm64 tar.gz - build windows amd64 Windows x86_64 zip - build windows arm64 Windows arm64 zip - - (cd dist && sha256sum *.tar.gz *.zip > checksums.txt) - - - name: Create GitHub Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ steps.release.outputs.tag }} - run: | - set -euo pipefail - gh release create "$TAG" dist/*.tar.gz dist/*.zip dist/checksums.txt --target "$GITHUB_SHA" --title "$TAG" --notes "Release $TAG" + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Determine release version + id: release + env: + BUMP: ${{ inputs.bump }} + run: | + set -euo pipefail + test "$GITHUB_REF" = refs/heads/main || { echo 'Run releases from main.' >&2; exit 1; } + prefix=v + git fetch --tags --force + latest="$(git tag --list "${prefix}[0-9]*" | sed -E "s/^${prefix}//" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1 || true)" + test -n "$latest" || latest=0.0.0 + IFS=. read -r major minor patch <<< "$latest" + case "$BUMP" in + major) major=$((major + 1)); minor=0; patch=0 ;; + minor) minor=$((minor + 1)); patch=0 ;; + patch) patch=$((patch + 1)) ;; + *) exit 1 ;; + esac + version="$major.$minor.$patch" + tag="$prefix$version" + if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then + echo "Release tag already exists; refusing to replace it." >&2 + exit 1 + fi + printf 'version=%s\ntag=%s\ndate=%s\n' "$version" "$tag" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + build-cli: + name: Build native CLI + needs: identity + uses: ./.github/workflows/cli-native.yml + permissions: + contents: read + with: + release: true + version: ${{ needs.identity.outputs.version }} + date: ${{ needs.identity.outputs.date }} + accept-cli: + name: Accept CLI release archives + needs: build-cli + permissions: + contents: read + attestations: write + id-token: write + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Download native archives + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 + with: + pattern: native-cli-* + merge-multiple: true + path: dist + - name: Require all six platforms and record checksums + run: | + set -euo pipefail + cd dist + for platform in Linux Darwin Windows; do + extension=tar.gz + [ "$platform" != Windows ] || extension=zip + for arch in x86_64 arm64; do + test -s "pxlb_${platform}_${arch}.${extension}" + done + done + test "$(find . -maxdepth 1 -type f | wc -l)" -eq 6 + sha256sum -- *.tar.gz *.zip > checksums.txt + - name: Attest accepted release archives + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 + with: + subject-path: dist/* + - name: Upload accepted archives + id: accepted + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 + with: + name: accepted-cli-${{ github.sha }}-${{ github.run_attempt }} + path: dist/* + if-no-files-found: error + retention-days: 7 + outputs: + artifact-id: ${{ steps.accepted.outputs.artifact-id }} + publish: + name: Publish verified archives + needs: [identity, accept-cli] + uses: nicodes/tools/.github/workflows/cli-publish.yml@9590742a455154ac5967a67952c5758d79d476b9 + permissions: + contents: write + with: + tools-ref: 9590742a455154ac5967a67952c5758d79d476b9 + binary: pxlb + tag: ${{ needs.identity.outputs.tag }} + artifact-id: ${{ needs.accept-cli.outputs.artifact-id }} diff --git a/.gitignore b/.gitignore index 1926130..f6ce047 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,8 @@ tmp # Native runtime files generated from functional inputs. /SKILL.md + +/target/ +/.artifacts/ +/.ci-tools/ +.env diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..fd5c3ee --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1331 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[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.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +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.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +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 = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + +[[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 = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxlb" +version = "0.0.0" +dependencies = [ + "base64", + "reqwest", + "rustls", + "serde", + "serde_json", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +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", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +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 = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +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 = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[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 = "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 = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +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.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +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 = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[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 = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +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.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9fe0b54 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "pxlb" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" + +[dependencies] +base64 = "=0.23.1" +reqwest = { version = "=0.13.5", default-features = false, features = ["blocking", "rustls-no-provider", "query"] } +rustls = { version = "0.23", default-features = false, features = ["std", "tls12", "ring"] } +serde = { version = "=1.0.229", features = ["derive"] } +serde_json = "=1.0.154" + +[profile.release] +lto = "thin" +strip = true diff --git a/Makefile b/Makefile index 9dff9cf..3355d30 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,19 @@ .PHONY: build install test VERSION ?= dev -COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || printf none) +COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || printf none) DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) -LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE) +export VERSION COMMIT DATE build: + cargo build --release --locked mkdir -p bin - go build -ldflags '$(LDFLAGS)' -o bin/pxlb . + cp "$${CARGO_TARGET_DIR:-target}/release/pxlb" bin/pxlb install: - mkdir -p bin - mkdir -p "$$(go env GOPATH)/bin" - go build -ldflags '$(LDFLAGS)' -o bin/pxlb . - cp bin/pxlb "$$(go env GOPATH)/bin/pxlb" + cargo install --path . --locked test: - go test ./... + cargo test --locked + cargo build --locked + python3 tests/cli_test.py diff --git a/README.md b/README.md index 62a6777..6dc2ef3 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ - + # pxlb -`pxlb` is a thin Go CLI for the [PixelLab v2 API](https://api.pixellab.ai/v2/docs). It exposes PixelLab routes directly, keeps request fields as simple flags, supports async job polling, and can save returned images or binary downloads. +`pxlb` is a thin Rust CLI for the [PixelLab v2 API](https://api.pixellab.ai/v2/docs). It exposes PixelLab routes directly, keeps request fields as simple flags, supports async job polling, and can save returned images or binary downloads. ## Install @@ -325,3 +325,7 @@ python3 scripts/prepare-runtime.py ``` This creates the native discovery files from the tracked functional inputs. Run it again after pulling an update; if a generated file has local edits, preserve those edits before replacing it. + +## Build from source + +Requires Rust 1.94.0. Run `cargo build --release --locked` to build `target/release/pxlb` (`pxlb.exe` on Windows). `make build` also copies it to `bin/pxlb`; `make install` installs into Cargo’s binary directory. Run `make test` for the CLI contracts. GitHub Actions verifies all six release targets, including static Linux builds. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..b97c592 --- /dev/null +++ b/build.rs @@ -0,0 +1,34 @@ +use std::{env, process::Command}; +fn main() { + for name in ["VERSION", "COMMIT", "DATE"] { + println!("cargo:rerun-if-env-changed={name}"); + } + let commit = env::var("COMMIT") + .ok() + .or_else(|| { + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().into()) + }) + .unwrap_or_else(|| "none".into()); + for (name, value) in [ + ( + "PXLB_VERSION", + env::var("VERSION").unwrap_or_else(|_| "dev".into()), + ), + ("PXLB_COMMIT", commit), + ( + "PXLB_DATE", + env::var("DATE").unwrap_or_else(|_| "unknown".into()), + ), + ] { + assert!(!value.contains(['\r', '\n'])); + println!("cargo:rustc-env={name}={value}"); + } + println!("cargo:rerun-if-changed=.git"); +} diff --git a/go.mod b/go.mod deleted file mode 100644 index 3254428..0000000 --- a/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module github.com/aviorstudio/pixellab-cli - -go 1.24 diff --git a/install.sh b/install.sh index ed4d1b7..061f155 100755 --- a/install.sh +++ b/install.sh @@ -120,7 +120,7 @@ curl -fsSL "$BASE_URL/$ARTIFACT" -o "$TMP_DIR/$ARTIFACT" curl -fsSL "$BASE_URL/checksums.txt" -o "$TMP_DIR/checksums.txt" verify_checksum "$TMP_DIR/$ARTIFACT" "$TMP_DIR/checksums.txt" -tar -xzf "$TMP_DIR/$ARTIFACT" -C "$TMP_DIR" pxlb +tar --no-same-owner -xzf "$TMP_DIR/$ARTIFACT" -C "$TMP_DIR" pxlb DEST_DIR="$(pick_install_dir)" mkdir -p "$DEST_DIR" diff --git a/main.go b/main.go deleted file mode 100644 index 2c60e12..0000000 --- a/main.go +++ /dev/null @@ -1,1193 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "time" -) - -const defaultBaseURL = "https://api.pixellab.ai/v2" - -var ( - version = "dev" - commit = "none" - date = "unknown" -) - -type RunOptions struct { - Env map[string]string - Stdout io.Writer - Stderr io.Writer -} - -type EndpointSpec struct { - Method string - Path string - RequiredBody []string - QueryParams []string -} - -type endpointMatch struct { - Spec EndpointSpec - RequestPath string -} - -type cliConfig struct { - Token string - BaseURL string - Method string - JSON bool - Out string - Wait bool - PollInterval time.Duration - Timeout time.Duration - BodyJSON string - Quiet bool - Flags map[string][]string -} - -type apiError struct { - Status int - Body []byte -} - -func (e apiError) Error() string { - message := strings.TrimSpace(string(e.Body)) - var payload map[string]any - if json.Unmarshal(e.Body, &payload) == nil { - if detail, ok := payload["detail"]; ok { - message = fmt.Sprint(detail) - } else if errText, ok := payload["error"]; ok { - message = fmt.Sprint(errText) - } - } - if message == "" { - message = http.StatusText(e.Status) - } - return fmt.Sprintf("API error %d: %s", e.Status, message) -} - -func main() { - os.Exit(Run(context.Background(), os.Args[1:], RunOptions{ - Env: envMap(os.Environ()), - Stdout: os.Stdout, - Stderr: os.Stderr, - })) -} - -func Run(ctx context.Context, args []string, opts RunOptions) int { - stdout := opts.Stdout - if stdout == nil { - stdout = io.Discard - } - stderr := opts.Stderr - if stderr == nil { - stderr = io.Discard - } - if opts.Env == nil { - opts.Env = envMap(os.Environ()) - } - - if len(args) < 1 { - fmt.Fprintln(stderr, "usage: pxlb [flags]") - return 2 - } - if args[0] == "--version" || args[0] == "-v" || args[0] == "version" { - fmt.Fprintf(stdout, "pxlb %s\ncommit: %s\nbuilt: %s\n", version, commit, date) - return 0 - } - if args[0] == "--help" || args[0] == "-h" || args[0] == "help" { - printHelp(stdout) - return 0 - } - pathTemplate := normalizeRouteArg(args[0]) - - cfg, err := parseFlags(args[1:], opts.Env) - if err != nil { - fmt.Fprintln(stderr, err) - return 2 - } - match, err := resolveEndpoint(pathTemplate, cfg) - if err != nil { - fmt.Fprintln(stderr, err) - return 2 - } - spec := match.Spec - method := spec.Method - if err := rejectPathParamFlags(spec, cfg.Flags); err != nil { - fmt.Fprintln(stderr, err) - return 2 - } - - requestPath, err := fillPathParams(match.RequestPath, cfg.Flags) - if err != nil { - fmt.Fprintln(stderr, err) - return 2 - } - - body, err := buildBody(spec, cfg) - if err != nil { - fmt.Fprintln(stderr, err) - return 2 - } - if cfg.BodyJSON == "" { - if err := validateRequired(spec, body); err != nil { - fmt.Fprintln(stderr, err) - return 2 - } - } - - query := buildQuery(spec, cfg.Flags) - responseBody, contentType, err := executeRequest(ctx, method, cfg.BaseURL, requestPath, query, cfg.Token, body) - if err != nil { - fmt.Fprintln(stderr, err) - return 1 - } - - if cfg.Out != "" && !isJSONContent(contentType) { - if err := writeBytesOutput(cfg.Out, responseBody); err != nil { - fmt.Fprintln(stderr, err) - return 1 - } - return 0 - } - - jsonPayload, err := decodeJSON(responseBody) - if err != nil { - if cfg.Out != "" { - if err := writeBytesOutput(cfg.Out, responseBody); err != nil { - fmt.Fprintln(stderr, err) - return 1 - } - return 0 - } - fmt.Fprintln(stdout, string(responseBody)) - return 0 - } - - if cfg.Wait { - jsonPayload, err = waitForJob(ctx, cfg, jsonPayload) - if err != nil { - fmt.Fprintln(stderr, err) - return 1 - } - } - - if cfg.Out != "" { - if err := writeImagesFromPayload(cfg.Out, jsonPayload); err != nil { - fmt.Fprintln(stderr, err) - return 1 - } - } - - if !cfg.Quiet { - data, _ := json.MarshalIndent(jsonPayload, "", " ") - fmt.Fprintln(stdout, string(data)) - } - return 0 -} - -func EndpointSpecs() []EndpointSpec { - return []EndpointSpec{ - {Method: "DELETE", Path: "/characters/{character_id}"}, - {Method: "DELETE", Path: "/objects/{object_id}"}, - {Method: "GET", Path: "/background-jobs/{job_id}"}, - {Method: "GET", Path: "/balance"}, - {Method: "GET", Path: "/characters", QueryParams: []string{"limit", "offset"}}, - {Method: "GET", Path: "/characters/{character_id}"}, - {Method: "GET", Path: "/characters/{character_id}/zip"}, - {Method: "GET", Path: "/isometric-tiles", QueryParams: []string{"limit", "offset"}}, - {Method: "GET", Path: "/isometric-tiles/{tile_id}"}, - {Method: "GET", Path: "/llms.txt"}, - {Method: "GET", Path: "/objects", QueryParams: []string{"limit", "offset"}}, - {Method: "GET", Path: "/objects/{object_id}"}, - {Method: "GET", Path: "/tiles-pro/{tile_id}"}, - {Method: "GET", Path: "/tilesets", QueryParams: []string{"limit", "offset"}}, - {Method: "GET", Path: "/tilesets/{tileset_id}"}, - {Method: "PATCH", Path: "/characters/{character_id}/tags", RequiredBody: []string{"tags"}}, - {Method: "PATCH", Path: "/objects/{object_id}/tags", RequiredBody: []string{"tags"}}, - {Method: "POST", Path: "/animate-character", RequiredBody: []string{"character_id"}}, - {Method: "POST", Path: "/animate-with-skeleton", RequiredBody: []string{"image_size", "reference_image"}}, - {Method: "POST", Path: "/animate-with-text", RequiredBody: []string{"image_size", "description", "action", "reference_image"}}, - {Method: "POST", Path: "/animate-with-text-v2", RequiredBody: []string{"reference_image", "reference_image_size", "action", "image_size"}}, - {Method: "POST", Path: "/animate-with-text-v3", RequiredBody: []string{"first_frame", "action"}}, - {Method: "POST", Path: "/characters/animations", RequiredBody: []string{"character_id"}}, - {Method: "POST", Path: "/create-1-direction-object", RequiredBody: []string{"description"}}, - {Method: "POST", Path: "/create-8-direction-object", RequiredBody: []string{"description"}}, - {Method: "POST", Path: "/create-character-pro", RequiredBody: []string{"description", "image_size"}}, - {Method: "POST", Path: "/create-character-state", RequiredBody: []string{"character_id", "edit_description"}}, - {Method: "POST", Path: "/create-character-v3", RequiredBody: []string{"description"}}, - {Method: "POST", Path: "/create-character-with-4-directions", RequiredBody: []string{"description", "image_size"}}, - {Method: "POST", Path: "/create-character-with-8-directions", RequiredBody: []string{"description", "image_size"}}, - {Method: "POST", Path: "/create-image-bitforge", RequiredBody: []string{"description", "image_size"}}, - {Method: "POST", Path: "/create-image-pixen", RequiredBody: []string{"description", "image_size"}}, - {Method: "POST", Path: "/create-image-pixflux", RequiredBody: []string{"description", "image_size"}}, - {Method: "POST", Path: "/create-isometric-tile", RequiredBody: []string{"description", "image_size"}}, - {Method: "POST", Path: "/create-tiles-pro", RequiredBody: []string{"description"}}, - {Method: "POST", Path: "/create-tileset", RequiredBody: []string{"lower_description", "upper_description"}}, - {Method: "POST", Path: "/create-tileset-sidescroller", RequiredBody: []string{"lower_description"}}, - {Method: "POST", Path: "/edit-animation-v2", RequiredBody: []string{"description", "frames", "image_size"}}, - {Method: "POST", Path: "/edit-image", RequiredBody: []string{"image", "image_size", "description", "width", "height"}}, - {Method: "POST", Path: "/edit-images-v2", RequiredBody: []string{"edit_images", "image_size"}}, - {Method: "POST", Path: "/enhance-animation-v3-prompt", RequiredBody: []string{"first_frame", "action"}}, - {Method: "POST", Path: "/enhance-character-v3-prompt", RequiredBody: []string{"description", "image_size"}}, - {Method: "POST", Path: "/enhance-pixen-prompt", RequiredBody: []string{"description", "image_size"}}, - {Method: "POST", Path: "/estimate-skeleton"}, - {Method: "POST", Path: "/generate-8-rotations-v2", RequiredBody: []string{"image_size"}}, - {Method: "POST", Path: "/generate-8-rotations-v3", RequiredBody: []string{"first_frame"}}, - {Method: "POST", Path: "/generate-image-v2", RequiredBody: []string{"description", "image_size"}}, - {Method: "POST", Path: "/generate-ui-v2", RequiredBody: []string{"description"}}, - {Method: "POST", Path: "/generate-with-style-v2", RequiredBody: []string{"style_images", "description", "image_size"}}, - {Method: "POST", Path: "/image-to-pixelart", RequiredBody: []string{"image", "image_size", "output_size"}}, - {Method: "POST", Path: "/inpaint", RequiredBody: []string{"description", "image_size", "inpainting_image", "mask_image"}}, - {Method: "POST", Path: "/inpaint-v3", RequiredBody: []string{"description", "inpainting_image", "mask_image"}}, - {Method: "POST", Path: "/interpolation-v2", RequiredBody: []string{"start_image", "end_image", "action", "image_size"}}, - {Method: "POST", Path: "/map-objects", RequiredBody: []string{"description"}}, - {Method: "POST", Path: "/objects/{object_id}/animations"}, - {Method: "POST", Path: "/objects/{object_id}/dismiss-review"}, - {Method: "POST", Path: "/objects/{object_id}/select-frames", RequiredBody: []string{"indices"}}, - {Method: "POST", Path: "/objects/{object_id}/states", RequiredBody: []string{"edit_description"}}, - {Method: "POST", Path: "/remove-background", RequiredBody: []string{"image", "image_size"}}, - {Method: "POST", Path: "/resize", RequiredBody: []string{"description", "reference_image", "reference_image_size", "target_size"}}, - {Method: "POST", Path: "/rotate", RequiredBody: []string{"image_size", "from_image"}}, - {Method: "POST", Path: "/tilesets", RequiredBody: []string{"lower_description", "upper_description"}}, - {Method: "POST", Path: "/tilesets-sidescroller", RequiredBody: []string{"lower_description"}}, - {Method: "POST", Path: "/transfer-outfit-v2", RequiredBody: []string{"reference_image", "frames", "image_size"}}, - } -} - -func printHelp(w io.Writer) { - fmt.Fprintln(w, "pxlb - PixelLab v2 API CLI") - fmt.Fprintln(w) - fmt.Fprintln(w, "Usage:") - fmt.Fprintln(w, " pxlb [flags]") - fmt.Fprintln(w, " pxlb --version") - fmt.Fprintln(w) - fmt.Fprintln(w, "Global flags:") - fmt.Fprintln(w, " --token PixelLab API key; overrides PIXELLAB_API_KEY and .env.") - fmt.Fprintln(w, " --base-url API base URL; defaults to https://api.pixellab.ai/v2.") - fmt.Fprintln(w, " --http-method Override inferred HTTP method for colliding routes.") - fmt.Fprintln(w, " --body-json Send a raw JSON request body.") - fmt.Fprintln(w, " --wait Poll returned background jobs until completion.") - fmt.Fprintln(w, " --out Save returned binary or base64 image outputs.") - fmt.Fprintln(w, " --quiet Suppress normal JSON output.") - fmt.Fprintln(w) - fmt.Fprintln(w, "Routes:") - - entries := helpEntries() - width := 0 - for _, entry := range entries { - if len(entry.Command) > width { - width = len(entry.Command) - } - } - for _, entry := range entries { - fmt.Fprintf(w, " %-*s %s\n", width, entry.Command, entry.Description) - } -} - -type helpEntry struct { - Command string - Description string -} - -func helpEntries() []helpEntry { - entries := make([]helpEntry, 0, len(EndpointSpecs())) - for _, spec := range EndpointSpecs() { - entries = append(entries, helpEntry{ - Command: helpCommand(spec), - Description: routeDescription(spec), - }) - } - sort.Slice(entries, func(i, j int) bool { - if entries[i].Command == entries[j].Command { - return entries[i].Description < entries[j].Description - } - return entries[i].Command < entries[j].Command - }) - return entries -} - -func helpCommand(spec EndpointSpec) string { - route := displayRoute(spec.Path) - if spec.Method == "DELETE" { - return route + " --http-method delete" - } - if spec.Path == "/tilesets" && spec.Method == "POST" { - return route + " --lower-description ... --upper-description ..." - } - return route -} - -func displayRoute(path string) string { - parts := strings.Split(strings.TrimPrefix(path, "/"), "/") - for i, part := range parts { - if isPlaceholderSegment(part) { - parts[i] = "<" + part[1:len(part)-1] + ">" - } - } - return strings.Join(parts, "/") -} - -func routeDescription(spec EndpointSpec) string { - switch spec.Method + " " + spec.Path { - case "DELETE /characters/{character_id}": - return "Delete a character and its associated data." - case "DELETE /objects/{object_id}": - return "Delete an object and its associated rotations, animations, and tags." - case "GET /background-jobs/{job_id}": - return "Check the status and result of an asynchronous background job." - case "GET /balance": - return "Show account credits and subscription generation balance." - case "GET /characters": - return "List characters with pagination." - case "GET /characters/{character_id}": - return "Show character details, rotations, animations, and download links." - case "GET /characters/{character_id}/zip": - return "Download a character ZIP archive." - case "GET /isometric-tiles": - return "List generated isometric tiles." - case "GET /isometric-tiles/{tile_id}": - return "Show an isometric tile or its generation status." - case "GET /llms.txt": - return "Print PixelLab's LLM-oriented API documentation." - case "GET /objects": - return "List objects with pagination." - case "GET /objects/{object_id}": - return "Show object details, rotations, animations, and download links." - case "GET /tiles-pro/{tile_id}": - return "Show generated pro tile data and storage URLs." - case "GET /tilesets": - return "List generated top-down tilesets." - case "GET /tilesets/{tileset_id}": - return "Show a tileset, download links, and generation status." - case "PATCH /characters/{character_id}/tags": - return "Replace a character's tags." - case "PATCH /objects/{object_id}/tags": - return "Replace an object's tags." - case "POST /animate-character": - return "Queue a character animation from a template or text description." - case "POST /animate-with-skeleton": - return "Generate animation frames from a reference image and skeleton keypoints." - case "POST /animate-with-text": - return "Generate legacy text-guided animation frames from a reference image." - case "POST /animate-with-text-v2": - return "Queue pro text-guided animation from a reference image." - case "POST /animate-with-text-v3": - return "Generate v3 text-guided animation from first and optional last frames." - case "POST /characters/animations": - return "Queue a character animation using the character animation API." - case "POST /create-1-direction-object": - return "Create one-direction pixel art objects, optionally as review candidates." - case "POST /create-8-direction-object": - return "Create an object rendered from eight directions." - case "POST /create-character-pro": - return "Create a pro-mode character." - case "POST /create-character-state": - return "Create an edited state of an existing character." - case "POST /create-character-v3": - return "Create a v3 character." - case "POST /create-character-with-4-directions": - return "Create a four-direction character." - case "POST /create-character-with-8-directions": - return "Create an eight-direction character." - case "POST /create-image-bitforge": - return "Create pixel art with the Bitforge image model." - case "POST /create-image-pixen": - return "Create pixel art with the Pixen image model." - case "POST /create-image-pixflux": - return "Create pixel art with the Pixflux image model." - case "POST /create-isometric-tile": - return "Create an isometric pixel art tile." - case "POST /create-tiles-pro": - return "Create multiple pro tile variations." - case "POST /create-tileset": - return "Create a top-down Wang tileset with terrain transitions." - case "POST /create-tileset-sidescroller": - return "Create a sidescroller platformer tileset." - case "POST /edit-animation-v2": - return "Edit existing animation frames with a text instruction." - case "POST /edit-image": - return "Edit a single image with text guidance." - case "POST /edit-images-v2": - return "Edit multiple images with text guidance." - case "POST /enhance-animation-v3-prompt": - return "Enhance an animation action prompt for v3 generation." - case "POST /enhance-character-v3-prompt": - return "Enhance a character creation prompt." - case "POST /enhance-pixen-prompt": - return "Enhance an image generation prompt." - case "POST /estimate-skeleton": - return "Estimate skeleton keypoints from an image." - case "POST /generate-8-rotations-v2": - return "Queue pro generation of eight rotations for an image." - case "POST /generate-8-rotations-v3": - return "Generate eight rotations from a first frame." - case "POST /generate-image-v2": - return "Queue pro image generation." - case "POST /generate-ui-v2": - return "Queue UI asset generation." - case "POST /generate-with-style-v2": - return "Queue image generation matching style reference images." - case "POST /image-to-pixelart": - return "Convert an image to pixel art." - case "POST /inpaint": - return "Inpaint an image region using a mask." - case "POST /inpaint-v3": - return "Inpaint an image region with the v3 inpainting model." - case "POST /interpolation-v2": - return "Interpolate animation frames between start and end images." - case "POST /map-objects": - return "Create a map object, optionally style-matched to a background." - case "POST /objects/{object_id}/animations": - return "Queue an animation for an existing object." - case "POST /objects/{object_id}/dismiss-review": - return "Discard all review candidates for an object." - case "POST /objects/{object_id}/select-frames": - return "Promote selected review candidates into completed objects." - case "POST /objects/{object_id}/states": - return "Create an edited state of an existing object." - case "POST /remove-background": - return "Remove the background from an image." - case "POST /resize": - return "Resize and regenerate an image to a target size." - case "POST /rotate": - return "Rotate an image from one direction to another." - case "POST /tilesets": - return "Create a top-down Wang tileset with terrain transitions." - case "POST /tilesets-sidescroller": - return "Create a sidescroller platformer tileset." - case "POST /transfer-outfit-v2": - return "Transfer outfit or appearance from a reference onto frames." - default: - return "Call this PixelLab API route." - } -} - -func findEndpoint(method, path string) (EndpointSpec, bool) { - for _, spec := range EndpointSpecs() { - if spec.Method == method && spec.Path == path { - return spec, true - } - } - return EndpointSpec{}, false -} - -func normalizeRouteArg(route string) string { - if strings.HasPrefix(route, "/") { - return route - } - return "/" + route -} - -func resolveEndpoint(path string, cfg cliConfig) (endpointMatch, error) { - matches := endpointsForPath(path) - if cfg.Method != "" { - for _, match := range matches { - if match.Spec.Method == cfg.Method { - return match, nil - } - } - if len(matches) == 0 { - return endpointMatch{}, fmt.Errorf("unknown endpoint: %s", path) - } - return endpointMatch{}, fmt.Errorf("endpoint %s does not support --http-method %s; available methods: %s", path, strings.ToLower(cfg.Method), availableMethods(matches)) - } - if len(matches) == 0 { - return endpointMatch{}, fmt.Errorf("unknown endpoint: %s", path) - } - if len(matches) == 1 { - return matches[0], nil - } - if bodyInputPresent(cfg, matches) { - var bodyMatches []endpointMatch - for _, match := range matches { - if match.Spec.Method != "GET" && match.Spec.Method != "DELETE" { - bodyMatches = append(bodyMatches, match) - } - } - if len(bodyMatches) == 1 { - return bodyMatches[0], nil - } - } - for _, match := range matches { - if match.Spec.Method == "GET" { - return match, nil - } - } - return endpointMatch{}, fmt.Errorf("ambiguous endpoint: %s; use --http-method with one of: %s", path, availableMethods(matches)) -} - -func endpointsForPath(path string) []endpointMatch { - var matches []endpointMatch - for _, spec := range EndpointSpecs() { - requestPath, ok := matchEndpointPath(spec.Path, path) - if ok { - matches = append(matches, endpointMatch{Spec: spec, RequestPath: requestPath}) - } - } - return matches -} - -func matchEndpointPath(template string, path string) (string, bool) { - templateParts := strings.Split(strings.Trim(template, "/"), "/") - pathParts := strings.Split(strings.Trim(path, "/"), "/") - if len(templateParts) != len(pathParts) { - return "", false - } - requestParts := make([]string, len(pathParts)) - for i := range templateParts { - if isUserPlaceholderSegment(pathParts[i]) { - return "", false - } - if isPlaceholderSegment(templateParts[i]) { - if pathParts[i] == "" { - return "", false - } - requestParts[i] = url.PathEscape(pathParts[i]) - continue - } - if templateParts[i] != pathParts[i] { - return "", false - } - requestParts[i] = templateParts[i] - } - return "/" + strings.Join(requestParts, "/"), true -} - -func isPlaceholderSegment(segment string) bool { - return strings.HasPrefix(segment, "{") && strings.HasSuffix(segment, "}") && len(segment) > 2 -} - -func isUserPlaceholderSegment(segment string) bool { - return isPlaceholderSegment(segment) || (strings.HasPrefix(segment, "<") && strings.HasSuffix(segment, ">") && len(segment) > 2) -} - -func bodyInputPresent(cfg cliConfig, matches []endpointMatch) bool { - if cfg.BodyJSON != "" { - return true - } - queryNames := map[string]bool{} - for _, match := range matches { - for _, name := range match.Spec.QueryParams { - queryNames[snakeToKebab(name)] = true - } - } - for flagName := range cfg.Flags { - if queryNames[flagName] { - continue - } - return true - } - return false -} - -func availableMethods(matches []endpointMatch) string { - methods := make([]string, 0, len(matches)) - for _, match := range matches { - methods = append(methods, strings.ToLower(match.Spec.Method)) - } - return strings.Join(methods, ", ") -} - -func rejectPathParamFlags(spec EndpointSpec, flags map[string][]string) error { - for _, name := range pathParamNames(spec.Path) { - flagName := snakeToKebab(name) - if len(flags[flagName]) > 0 { - return fmt.Errorf("route parameter %s belongs in the route, for example: %s", flagName, displayRoute(spec.Path)) - } - } - return nil -} - -func pathParamNames(path string) []string { - parts := strings.Split(strings.Trim(path, "/"), "/") - names := make([]string, 0, len(parts)) - for _, part := range parts { - if isPlaceholderSegment(part) { - names = append(names, part[1:len(part)-1]) - } - } - return names -} - -func parseFlags(args []string, env map[string]string) (cliConfig, error) { - cfg := cliConfig{ - Token: tokenFromEnv(env), - BaseURL: defaultBaseURL, - PollInterval: 5 * time.Second, - Flags: map[string][]string{}, - } - for i := 0; i < len(args); i++ { - arg := args[i] - if !strings.HasPrefix(arg, "--") { - return cfg, fmt.Errorf("unexpected positional argument: %s", arg) - } - name := strings.TrimPrefix(arg, "--") - value := "true" - if i+1 < len(args) && !strings.HasPrefix(args[i+1], "--") { - value = args[i+1] - i++ - } - switch name { - case "token": - cfg.Token = value - case "base-url": - cfg.BaseURL = value - case "http-method": - method := strings.ToUpper(value) - switch method { - case "GET", "POST", "PATCH", "DELETE": - cfg.Method = method - default: - return cfg, fmt.Errorf("invalid --http-method: %s", value) - } - case "json": - cfg.JSON = parseBool(value) - case "out": - cfg.Out = value - case "wait": - cfg.Wait = parseBool(value) - case "poll-interval": - d, err := time.ParseDuration(value) - if err != nil { - return cfg, fmt.Errorf("invalid --poll-interval: %w", err) - } - cfg.PollInterval = d - case "timeout": - d, err := time.ParseDuration(value) - if err != nil { - return cfg, fmt.Errorf("invalid --timeout: %w", err) - } - cfg.Timeout = d - case "body-json": - cfg.BodyJSON = value - case "quiet": - cfg.Quiet = parseBool(value) - default: - cfg.Flags[name] = append(cfg.Flags[name], value) - } - } - return cfg, nil -} - -func fillPathParams(path string, flags map[string][]string) (string, error) { - for { - start := strings.Index(path, "{") - if start == -1 { - return path, nil - } - end := strings.Index(path[start:], "}") - if end == -1 { - return "", fmt.Errorf("invalid path template: %s", path) - } - name := path[start+1 : start+end] - flagName := snakeToKebab(name) - values := flags[flagName] - if len(values) == 0 { - return "", fmt.Errorf("missing --%s", flagName) - } - path = path[:start] + url.PathEscape(values[len(values)-1]) + path[start+end+1:] - } -} - -func buildQuery(spec EndpointSpec, flags map[string][]string) url.Values { - query := url.Values{} - for _, name := range spec.QueryParams { - flagName := snakeToKebab(name) - if values := flags[flagName]; len(values) > 0 { - query.Set(name, values[len(values)-1]) - } - } - return query -} - -func buildBody(spec EndpointSpec, cfg cliConfig) (map[string]any, error) { - if cfg.BodyJSON != "" { - return readJSONBody(cfg.BodyJSON) - } - if spec.Method == "GET" || spec.Method == "DELETE" { - return nil, nil - } - body := map[string]any{} - queryNames := map[string]bool{} - for _, name := range spec.QueryParams { - queryNames[snakeToKebab(name)] = true - } - for flagName, values := range cfg.Flags { - if queryNames[flagName] || isPathFlag(flagName, spec.Path) { - continue - } - if err := applyFlagToBody(spec, body, flagName, values); err != nil { - return nil, err - } - } - return body, nil -} - -func applyFlagToBody(spec EndpointSpec, body map[string]any, flagName string, values []string) error { - if len(values) == 0 { - return nil - } - switch flagName { - case "tag": - body["tags"] = stringArray(values) - return nil - case "index": - indices := make([]int, 0, len(values)) - for _, value := range values { - index, err := strconv.Atoi(value) - if err != nil { - return fmt.Errorf("invalid --index: %w", err) - } - indices = append(indices, index) - } - body["indices"] = indices - return nil - case "item-description": - body["item_descriptions"] = stringArray(values) - return nil - case "directions": - body["directions"] = splitCSV(values[len(values)-1]) - return nil - case "style-image": - if spec.Path == "/generate-with-style-v2" || spec.Path == "/create-1-direction-object" { - images := make([]any, 0, len(values)) - for _, value := range values { - image, err := fileToImage(value) - if err != nil { - return err - } - images = append(images, image) - } - body["style_images"] = images - return nil - } - } - - jsonName := kebabToSnake(flagName) - if strings.HasSuffix(flagName, "-json") { - jsonName = kebabToSnake(strings.TrimSuffix(flagName, "-json")) - var value any - if err := json.Unmarshal([]byte(readMaybeFile(values[len(values)-1])), &value); err != nil { - return fmt.Errorf("invalid --%s JSON: %w", flagName, err) - } - body[jsonName] = value - return nil - } - - if isImageFlag(flagName) || strings.HasSuffix(flagName, "-frame") { - image, err := fileToImage(values[len(values)-1]) - if err != nil { - return err - } - body[jsonName] = image - return nil - } - - if isRepeatedBodyFlag(flagName) || len(values) > 1 { - var array []any - for _, value := range values { - array = append(array, inferValue(value)) - } - body[jsonName] = array - return nil - } - - value := values[len(values)-1] - if isSizeFlag(flagName) { - parsed, err := parseSizeOrNumber(value) - if err != nil { - return fmt.Errorf("invalid --%s: %w", flagName, err) - } - body[jsonName] = parsed - return nil - } - body[jsonName] = inferValue(value) - return nil -} - -func validateRequired(spec EndpointSpec, body map[string]any) error { - if len(spec.RequiredBody) == 0 { - return nil - } - for _, name := range spec.RequiredBody { - if body == nil || body[name] == nil { - return fmt.Errorf("missing --%s", snakeToKebab(name)) - } - } - return nil -} - -func executeRequest(ctx context.Context, method, baseURL, path string, query url.Values, token string, body map[string]any) ([]byte, string, error) { - endpoint := strings.TrimRight(baseURL, "/") + path - if len(query) > 0 { - endpoint += "?" + query.Encode() - } - var reader io.Reader - if body != nil { - data, err := json.Marshal(body) - if err != nil { - return nil, "", err - } - reader = bytes.NewReader(data) - } - req, err := http.NewRequestWithContext(ctx, method, endpoint, reader) - if err != nil { - return nil, "", err - } - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - if token != "" { - req.Header.Set("Authorization", "Bearer "+token) - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, "", err - } - defer resp.Body.Close() - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, "", err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, "", apiError{Status: resp.StatusCode, Body: data} - } - return data, resp.Header.Get("Content-Type"), nil -} - -func waitForJob(ctx context.Context, cfg cliConfig, initial map[string]any) (map[string]any, error) { - jobID := extractJobID(initial) - if jobID == "" { - return initial, nil - } - deadline := time.Time{} - if cfg.Timeout > 0 { - deadline = time.Now().Add(cfg.Timeout) - } - for { - if !deadline.IsZero() && time.Now().After(deadline) { - return nil, fmt.Errorf("timeout waiting for job %s", jobID) - } - data, _, err := executeRequest(ctx, http.MethodGet, cfg.BaseURL, "/background-jobs/"+url.PathEscape(jobID), nil, cfg.Token, nil) - if err != nil { - return nil, err - } - payload, err := decodeJSON(data) - if err != nil { - return nil, err - } - status := strings.ToLower(fmt.Sprint(payload["status"])) - switch status { - case "completed", "complete", "succeeded", "success": - if last, ok := payload["last_response"].(map[string]any); ok { - return last, nil - } - return payload, nil - case "failed", "error": - return nil, fmt.Errorf("job %s failed: %v", jobID, payload) - } - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(cfg.PollInterval): - } - } -} - -func writeImagesFromPayload(out string, payload map[string]any) error { - images := collectImages(payload) - if len(images) == 0 { - return nil - } - if len(images) == 1 && filepath.Ext(out) != "" { - return writeDecodedImage(out, images[0], 0) - } - if err := os.MkdirAll(out, 0o755); err != nil { - return err - } - for i, image := range images { - format := fmt.Sprint(image["format"]) - if format == "" || format == "" { - format = "png" - } - path := filepath.Join(out, fmt.Sprintf("image_%02d.%s", i, strings.TrimPrefix(format, "."))) - if err := writeDecodedImage(path, image, i); err != nil { - return err - } - } - return nil -} - -func writeDecodedImage(path string, image map[string]any, _ int) error { - raw, ok := image["base64"].(string) - if !ok || raw == "" { - return nil - } - if comma := strings.Index(raw, ","); comma != -1 && strings.Contains(raw[:comma], "base64") { - raw = raw[comma+1:] - } - data, err := base64.StdEncoding.DecodeString(raw) - if err != nil { - return err - } - return writeBytesOutput(path, data) -} - -func writeBytesOutput(out string, data []byte) error { - if filepath.Ext(out) == "" { - if err := os.MkdirAll(out, 0o755); err != nil { - return err - } - out = filepath.Join(out, "response.bin") - } else if dir := filepath.Dir(out); dir != "." { - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - } - return os.WriteFile(out, data, 0o600) -} - -func collectImages(payload any) []map[string]any { - var images []map[string]any - switch value := payload.(type) { - case map[string]any: - if base64Value, ok := value["base64"].(string); ok && base64Value != "" { - images = append(images, value) - } - for _, child := range value { - images = append(images, collectImages(child)...) - } - case []any: - for _, child := range value { - images = append(images, collectImages(child)...) - } - } - return images -} - -func extractJobID(payload map[string]any) string { - for _, key := range []string{"background_job_id", "job_id"} { - if value, ok := payload[key].(string); ok && value != "" { - return value - } - } - if value, ok := payload["id"].(string); ok && strings.Contains(strings.ToLower(fmt.Sprint(payload["status"])), "process") { - return value - } - for _, value := range payload { - if jobID := extractNestedJobID(value); jobID != "" { - return jobID - } - } - return "" -} - -func extractNestedJobID(value any) string { - switch typed := value.(type) { - case map[string]any: - return extractJobID(typed) - case []any: - for _, item := range typed { - if jobID := extractNestedJobID(item); jobID != "" { - return jobID - } - } - } - return "" -} - -func decodeJSON(data []byte) (map[string]any, error) { - var payload map[string]any - if err := json.Unmarshal(data, &payload); err != nil { - return nil, err - } - return payload, nil -} - -func fileToImage(path string) (map[string]any, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - format := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".") - if format == "jpg" { - format = "jpeg" - } - if format == "" { - format = "png" - } - return map[string]any{ - "type": "base64", - "base64": base64.StdEncoding.EncodeToString(data), - "format": format, - }, nil -} - -func readJSONBody(value string) (map[string]any, error) { - raw := readMaybeFile(value) - var body map[string]any - if err := json.Unmarshal([]byte(raw), &body); err != nil { - return nil, fmt.Errorf("invalid --body-json: %w", err) - } - return body, nil -} - -func readMaybeFile(value string) string { - if strings.HasPrefix(strings.TrimSpace(value), "{") || strings.HasPrefix(strings.TrimSpace(value), "[") { - return value - } - data, err := os.ReadFile(value) - if err == nil { - return string(data) - } - return value -} - -func parseSizeOrNumber(value string) (any, error) { - if strings.Contains(value, "x") { - parts := strings.Split(value, "x") - if len(parts) != 2 { - return nil, errors.New("expected WIDTHxHEIGHT") - } - width, err := strconv.Atoi(parts[0]) - if err != nil { - return nil, err - } - height, err := strconv.Atoi(parts[1]) - if err != nil { - return nil, err - } - return map[string]any{"width": width, "height": height}, nil - } - return inferValue(value), nil -} - -func inferValue(value string) any { - if value == "true" || value == "false" { - return parseBool(value) - } - if integer, err := strconv.Atoi(value); err == nil { - return integer - } - if number, err := strconv.ParseFloat(value, 64); err == nil && strings.Contains(value, ".") { - return number - } - return value -} - -func isPathFlag(flagName string, path string) bool { - return strings.Contains(path, "{"+kebabToSnake(flagName)+"}") -} - -func isImageFlag(flagName string) bool { - return flagName == "image" || strings.HasSuffix(flagName, "-image") || strings.HasSuffix(flagName, "-images") || flagName == "from-image" -} - -func isSizeFlag(flagName string) bool { - return flagName == "size" || strings.HasSuffix(flagName, "-size") -} - -func isRepeatedBodyFlag(flagName string) bool { - switch flagName { - case "style-image", "reference-image", "item-description": - return true - } - return false -} - -func isJSONContent(contentType string) bool { - return strings.Contains(strings.ToLower(contentType), "json") || contentType == "" -} - -func parseBool(value string) bool { - parsed, err := strconv.ParseBool(value) - if err != nil { - return true - } - return parsed -} - -func splitCSV(value string) []string { - parts := strings.Split(value, ",") - out := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - out = append(out, part) - } - } - return out -} - -func stringArray(values []string) []string { - out := make([]string, len(values)) - copy(out, values) - return out -} - -func kebabToSnake(value string) string { - return strings.ReplaceAll(value, "-", "_") -} - -func snakeToKebab(value string) string { - return strings.ReplaceAll(value, "_", "-") -} - -func envMap(env []string) map[string]string { - out := map[string]string{} - for _, item := range env { - key, value, ok := strings.Cut(item, "=") - if ok { - out[key] = value - } - } - return out -} - -func tokenFromEnv(env map[string]string) string { - if token := env["PIXELLAB_API_KEY"]; token != "" { - return token - } - dotenv := loadDotEnv(".env") - return dotenv["PIXELLAB_API_KEY"] -} - -func loadDotEnv(path string) map[string]string { - data, err := os.ReadFile(path) - if err != nil { - return map[string]string{} - } - values := map[string]string{} - for _, line := range strings.Split(string(data), "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - line = strings.TrimPrefix(line, "export ") - key, value, ok := strings.Cut(line, "=") - if !ok { - continue - } - key = strings.TrimSpace(key) - value = strings.TrimSpace(value) - value = strings.Trim(value, "\"'") - if key != "" { - values[key] = value - } - } - return values -} diff --git a/main_test.go b/main_test.go deleted file mode 100644 index ea3f7a5..0000000 --- a/main_test.go +++ /dev/null @@ -1,752 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "sort" - "strings" - "testing" - "time" -) - -type capturedRequest struct { - Method string - Path string - Query string - Auth string - Body map[string]any -} - -func runTestCLI(t *testing.T, args []string, env map[string]string) (int, string, string) { - t.Helper() - var stdout bytes.Buffer - var stderr bytes.Buffer - code := Run(context.Background(), args, RunOptions{ - Env: env, - Stdout: &stdout, - Stderr: &stderr, - }) - return code, stdout.String(), stderr.String() -} - -func newAPIServer(t *testing.T, handler func(capturedRequest) (int, string)) *httptest.Server { - t.Helper() - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var body map[string]any - if r.Body != nil { - data, err := io.ReadAll(r.Body) - if err != nil { - t.Fatalf("read request body: %v", err) - } - if len(bytes.TrimSpace(data)) > 0 { - if err := json.Unmarshal(data, &body); err != nil { - t.Fatalf("request body is not json: %s: %v", string(data), err) - } - } - } - status, response := handler(capturedRequest{ - Method: r.Method, - Path: r.URL.Path, - Query: r.URL.RawQuery, - Auth: r.Header.Get("Authorization"), - Body: body, - }) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _, _ = w.Write([]byte(response)) - })) -} - -func TestEndpointRegistryCoversEveryOpenAPIOperation(t *testing.T) { - expected := []string{ - "DELETE /characters/{character_id}", - "DELETE /objects/{object_id}", - "GET /background-jobs/{job_id}", - "GET /balance", - "GET /characters", - "GET /characters/{character_id}", - "GET /characters/{character_id}/zip", - "GET /isometric-tiles", - "GET /isometric-tiles/{tile_id}", - "GET /llms.txt", - "GET /objects", - "GET /objects/{object_id}", - "GET /tiles-pro/{tile_id}", - "GET /tilesets", - "GET /tilesets/{tileset_id}", - "PATCH /characters/{character_id}/tags", - "PATCH /objects/{object_id}/tags", - "POST /animate-character", - "POST /animate-with-skeleton", - "POST /animate-with-text", - "POST /animate-with-text-v2", - "POST /animate-with-text-v3", - "POST /characters/animations", - "POST /create-1-direction-object", - "POST /create-8-direction-object", - "POST /create-character-pro", - "POST /create-character-state", - "POST /create-character-v3", - "POST /create-character-with-4-directions", - "POST /create-character-with-8-directions", - "POST /create-image-bitforge", - "POST /create-image-pixen", - "POST /create-image-pixflux", - "POST /create-isometric-tile", - "POST /create-tiles-pro", - "POST /create-tileset", - "POST /create-tileset-sidescroller", - "POST /edit-animation-v2", - "POST /edit-image", - "POST /edit-images-v2", - "POST /enhance-animation-v3-prompt", - "POST /enhance-character-v3-prompt", - "POST /enhance-pixen-prompt", - "POST /estimate-skeleton", - "POST /generate-8-rotations-v2", - "POST /generate-8-rotations-v3", - "POST /generate-image-v2", - "POST /generate-ui-v2", - "POST /generate-with-style-v2", - "POST /image-to-pixelart", - "POST /inpaint", - "POST /inpaint-v3", - "POST /interpolation-v2", - "POST /map-objects", - "POST /objects/{object_id}/animations", - "POST /objects/{object_id}/dismiss-review", - "POST /objects/{object_id}/select-frames", - "POST /objects/{object_id}/states", - "POST /remove-background", - "POST /resize", - "POST /rotate", - "POST /tilesets", - "POST /tilesets-sidescroller", - "POST /transfer-outfit-v2", - } - - var actual []string - for _, spec := range EndpointSpecs() { - actual = append(actual, spec.Method+" "+spec.Path) - } - sort.Strings(expected) - sort.Strings(actual) - if strings.Join(actual, "\n") != strings.Join(expected, "\n") { - t.Fatalf("endpoint registry mismatch\nexpected:\n%s\nactual:\n%s", strings.Join(expected, "\n"), strings.Join(actual, "\n")) - } -} - -func TestVersionCommandPrintsBuildMetadata(t *testing.T) { - code, stdout, stderr := runTestCLI(t, []string{"--version"}, nil) - if code != 0 { - t.Fatalf("expected success, got code %d stderr %s", code, stderr) - } - if !strings.Contains(stdout, "pxlb ") || !strings.Contains(stdout, "commit:") || !strings.Contains(stdout, "built:") { - t.Fatalf("expected version output, got %q", stdout) - } -} - -func TestHelpListsRoutesWithDescriptions(t *testing.T) { - code, stdout, stderr := runTestCLI(t, []string{"--help"}, nil) - if code != 0 { - t.Fatalf("expected success, got code %d stderr %s", code, stderr) - } - for _, want := range []string{ - "pxlb [flags]", - "balance", - "Show account credits and subscription generation balance.", - "objects/ --http-method delete", - "Delete an object and its associated rotations, animations, and tags.", - "create-image-pixen", - "Create pixel art with the Pixen image model.", - } { - if !strings.Contains(stdout, want) { - t.Fatalf("expected help to contain %q, got:\n%s", want, stdout) - } - } -} - -func TestAllRoutesHaveHelpDescriptions(t *testing.T) { - if got, want := len(helpEntries()), len(EndpointSpecs()); got != want { - t.Fatalf("help entry count mismatch: got %d want %d", got, want) - } - for _, spec := range EndpointSpecs() { - description := routeDescription(spec) - if description == "" || description == "Call this PixelLab API route." { - t.Fatalf("missing help description for %s %s", spec.Method, spec.Path) - } - } -} - -func TestGetBalanceSendsAuthAndNoBody(t *testing.T) { - server := newAPIServer(t, func(req capturedRequest) (int, string) { - if req.Method != http.MethodGet || req.Path != "/balance" { - t.Fatalf("unexpected request: %s %s", req.Method, req.Path) - } - if req.Auth != "Bearer test-token" { - t.Fatalf("missing auth header: %q", req.Auth) - } - if req.Body != nil { - t.Fatalf("GET /balance should not send a body: %#v", req.Body) - } - return http.StatusOK, `{"credits":12.5,"generations":42}` - }) - defer server.Close() - - code, stdout, stderr := runTestCLI(t, []string{"balance", "--base-url", server.URL}, map[string]string{"PIXELLAB_API_KEY": "test-token"}) - if code != 0 { - t.Fatalf("expected success, got code %d stderr %s", code, stderr) - } - if !strings.Contains(stdout, "credits") || !strings.Contains(stdout, "generations") { - t.Fatalf("expected balance output, got %q", stdout) - } -} - -func TestPostPixenBuildsTypedJSONBody(t *testing.T) { - server := newAPIServer(t, func(req capturedRequest) (int, string) { - if req.Method != http.MethodPost || req.Path != "/create-image-pixen" { - t.Fatalf("unexpected request: %s %s", req.Method, req.Path) - } - assertJSONValue(t, req.Body, "description", "cute dragon") - assertJSONValue(t, req.Body, "outline", "selective outline") - assertJSONValue(t, req.Body, "no_background", true) - assertJSONValue(t, req.Body, "enhance_prompt", true) - assertJSONValue(t, req.Body, "seed", float64(123)) - assertSizeObject(t, req.Body["image_size"], 128, 64) - return http.StatusOK, `{"images":[{"base64":"abcd","format":"png"}]}` - }) - defer server.Close() - - code, _, stderr := runTestCLI(t, []string{ - "create-image-pixen", - "--base-url", server.URL, - "--description", "cute dragon", - "--image-size", "128x64", - "--outline", "selective outline", - "--no-background", - "--enhance-prompt", - "--seed", "123", - }, map[string]string{"PIXELLAB_API_KEY": "test-token"}) - if code != 0 { - t.Fatalf("expected success, got code %d stderr %s", code, stderr) - } -} - -func TestPathParamsQueryParamsAndPatchTags(t *testing.T) { - server := newAPIServer(t, func(req capturedRequest) (int, string) { - if req.Method != http.MethodPatch || req.Path != "/objects/object-123/tags" { - t.Fatalf("unexpected request: %s %s", req.Method, req.Path) - } - tags, ok := req.Body["tags"].([]any) - if !ok || len(tags) != 2 || tags[0] != "prop" || tags[1] != "barrel" { - t.Fatalf("unexpected tags body: %#v", req.Body) - } - return http.StatusOK, `{"ok":true}` - }) - defer server.Close() - - code, _, stderr := runTestCLI(t, []string{ - "objects/object-123/tags", - "--base-url", server.URL, - "--tag", "prop", - "--tag", "barrel", - }, map[string]string{"PIXELLAB_API_KEY": "test-token"}) - if code != 0 { - t.Fatalf("expected success, got code %d stderr %s", code, stderr) - } -} - -func TestListObjectsUsesQueryParams(t *testing.T) { - server := newAPIServer(t, func(req capturedRequest) (int, string) { - if req.Method != http.MethodGet || req.Path != "/objects" { - t.Fatalf("unexpected request: %s %s", req.Method, req.Path) - } - if req.Query != "limit=10&offset=20" && req.Query != "offset=20&limit=10" { - t.Fatalf("unexpected query: %q", req.Query) - } - return http.StatusOK, `{"objects":[]}` - }) - defer server.Close() - - code, _, stderr := runTestCLI(t, []string{"objects", "--base-url", server.URL, "--limit", "10", "--offset", "20"}, map[string]string{"PIXELLAB_API_KEY": "test-token"}) - if code != 0 { - t.Fatalf("expected success, got code %d stderr %s", code, stderr) - } -} - -func TestCollidingGetDeleteRouteDefaultsToSafeGet(t *testing.T) { - server := newAPIServer(t, func(req capturedRequest) (int, string) { - if req.Method != http.MethodGet || req.Path != "/objects/object-123" { - t.Fatalf("unexpected request: %s %s", req.Method, req.Path) - } - return http.StatusOK, `{"id":"object-123"}` - }) - defer server.Close() - - code, _, stderr := runTestCLI(t, []string{"objects/object-123", "--base-url", server.URL}, map[string]string{"PIXELLAB_API_KEY": "test-token"}) - if code != 0 { - t.Fatalf("expected success, got code %d stderr %s", code, stderr) - } -} - -func TestMethodFlagSelectsDeleteForCollidingRoute(t *testing.T) { - server := newAPIServer(t, func(req capturedRequest) (int, string) { - if req.Method != http.MethodDelete || req.Path != "/objects/object-123" { - t.Fatalf("unexpected request: %s %s", req.Method, req.Path) - } - return http.StatusOK, `{"deleted":true}` - }) - defer server.Close() - - code, _, stderr := runTestCLI(t, []string{"objects/object-123", "--base-url", server.URL, "--http-method", "delete"}, map[string]string{"PIXELLAB_API_KEY": "test-token"}) - if code != 0 { - t.Fatalf("expected success, got code %d stderr %s", code, stderr) - } -} - -func TestTemplatedPathParamsAreRejected(t *testing.T) { - server := newAPIServer(t, func(req capturedRequest) (int, string) { - t.Fatalf("templated route should not send request") - return http.StatusOK, `{}` - }) - defer server.Close() - - code, _, stderr := runTestCLI(t, []string{"objects/{object_id}", "--base-url", server.URL, "--object-id", "object-123"}, map[string]string{"PIXELLAB_API_KEY": "test-token"}) - if code == 0 { - t.Fatalf("expected templated route to fail") - } - if !strings.Contains(stderr, "unknown endpoint") { - t.Fatalf("expected unknown endpoint error, got %q", stderr) - } -} - -func TestDuplicatePathParamFlagIsRejected(t *testing.T) { - server := newAPIServer(t, func(req capturedRequest) (int, string) { - t.Fatalf("duplicate path parameter should not send request") - return http.StatusOK, `{}` - }) - defer server.Close() - - code, _, stderr := runTestCLI(t, []string{"objects/object-123", "--base-url", server.URL, "--object-id", "object-123"}, map[string]string{"PIXELLAB_API_KEY": "test-token"}) - if code == 0 { - t.Fatalf("expected duplicate path parameter flag to fail") - } - if !strings.Contains(stderr, "belongs in the route") { - t.Fatalf("expected route parameter error, got %q", stderr) - } -} - -func TestBodyFlagsSelectPostForCollidingRoute(t *testing.T) { - server := newAPIServer(t, func(req capturedRequest) (int, string) { - if req.Method != http.MethodPost || req.Path != "/tilesets" { - t.Fatalf("unexpected request: %s %s", req.Method, req.Path) - } - assertJSONValue(t, req.Body, "lower_description", "ocean") - assertJSONValue(t, req.Body, "upper_description", "beach") - return http.StatusAccepted, `{"background_job_id":"job-tileset"}` - }) - defer server.Close() - - code, _, stderr := runTestCLI(t, []string{"tilesets", "--base-url", server.URL, "--lower-description", "ocean", "--upper-description", "beach"}, map[string]string{"PIXELLAB_API_KEY": "test-token"}) - if code != 0 { - t.Fatalf("expected success, got code %d stderr %s", code, stderr) - } -} - -func TestBodyJSONInlineAndFile(t *testing.T) { - for _, tc := range []struct { - name string - arg string - }{ - {name: "inline", arg: `{"description":"inline dragon","image_size":{"width":32,"height":32}}`}, - {name: "file", arg: writeTempJSON(t, `{"description":"file dragon","image_size":{"width":64,"height":64}}`)}, - } { - t.Run(tc.name, func(t *testing.T) { - server := newAPIServer(t, func(req capturedRequest) (int, string) { - if _, ok := req.Body["description"].(string); !ok { - t.Fatalf("body-json did not populate request body: %#v", req.Body) - } - return http.StatusOK, `{"ok":true}` - }) - defer server.Close() - - code, _, stderr := runTestCLI(t, []string{"create-image-pixen", "--base-url", server.URL, "--body-json", tc.arg}, map[string]string{"PIXELLAB_API_KEY": "test-token"}) - if code != 0 { - t.Fatalf("expected success, got code %d stderr %s", code, stderr) - } - }) - } -} - -func TestImagePathFlagsEncodeBase64ImageObjects(t *testing.T) { - imagePath := writeTempBytes(t, "sprite.png", []byte{0x89, 'P', 'N', 'G', 1, 2, 3}) - server := newAPIServer(t, func(req capturedRequest) (int, string) { - image, ok := req.Body["image"].(map[string]any) - if !ok { - t.Fatalf("image field was not encoded as object: %#v", req.Body["image"]) - } - assertJSONValue(t, image, "type", "base64") - assertJSONValue(t, image, "format", "png") - want := base64.StdEncoding.EncodeToString([]byte{0x89, 'P', 'N', 'G', 1, 2, 3}) - assertJSONValue(t, image, "base64", want) - assertSizeObject(t, req.Body["image_size"], 7, 1) - return http.StatusOK, `{"ok":true}` - }) - defer server.Close() - - code, _, stderr := runTestCLI(t, []string{ - "remove-background", - "--base-url", server.URL, - "--image", imagePath, - "--image-size", "7x1", - }, map[string]string{"PIXELLAB_API_KEY": "test-token"}) - if code != 0 { - t.Fatalf("expected success, got code %d stderr %s", code, stderr) - } -} - -func TestRepeatedImageFlagsBuildArrays(t *testing.T) { - styleA := writeTempBytes(t, "style-a.png", []byte("a")) - styleB := writeTempBytes(t, "style-b.png", []byte("b")) - server := newAPIServer(t, func(req capturedRequest) (int, string) { - images, ok := req.Body["style_images"].([]any) - if !ok || len(images) != 2 { - t.Fatalf("expected two style images, got %#v", req.Body["style_images"]) - } - return http.StatusAccepted, `{"background_job_id":"job-1","status":"processing"}` - }) - defer server.Close() - - code, _, stderr := runTestCLI(t, []string{ - "generate-with-style-v2", - "--base-url", server.URL, - "--style-image", styleA, - "--style-image", styleB, - "--description", "matching style", - "--image-size", "64x64", - }, map[string]string{"PIXELLAB_API_KEY": "test-token"}) - if code != 0 { - t.Fatalf("expected success, got code %d stderr %s", code, stderr) - } -} - -func TestAsyncWaitPollsJobAndWritesImages(t *testing.T) { - var calls int - imageBytes := []byte("png data") - encoded := base64.StdEncoding.EncodeToString(imageBytes) - outDir := t.TempDir() - - server := newAPIServer(t, func(req capturedRequest) (int, string) { - switch req.Path { - case "/generate-image-v2": - return http.StatusAccepted, `{"background_job_id":"job-123","status":"processing"}` - case "/background-jobs/job-123": - calls++ - if calls == 1 { - return http.StatusOK, `{"status":"processing"}` - } - return http.StatusOK, fmt.Sprintf(`{"status":"completed","last_response":{"images":[{"base64":%q,"format":"png"}]}}`, encoded) - default: - t.Fatalf("unexpected path while waiting: %s", req.Path) - } - return http.StatusInternalServerError, `{}` - }) - defer server.Close() - - code, _, stderr := runTestCLI(t, []string{ - "generate-image-v2", - "--base-url", server.URL, - "--description", "crystal sword", - "--image-size", "32x32", - "--wait", - "--poll-interval", "1ms", - "--timeout", "1s", - "--out", outDir, - }, map[string]string{"PIXELLAB_API_KEY": "test-token"}) - if code != 0 { - t.Fatalf("expected success, got code %d stderr %s", code, stderr) - } - if calls != 2 { - t.Fatalf("expected two job polls, got %d", calls) - } - files, err := os.ReadDir(outDir) - if err != nil { - t.Fatalf("read out dir: %v", err) - } - if len(files) != 1 { - t.Fatalf("expected one output image, got %d", len(files)) - } - got, err := os.ReadFile(filepath.Join(outDir, files[0].Name())) - if err != nil { - t.Fatalf("read output image: %v", err) - } - if !bytes.Equal(got, imageBytes) { - t.Fatalf("unexpected output bytes: %q", string(got)) - } -} - -func TestAsyncWaitFindsNestedSubmissionJobID(t *testing.T) { - var polled bool - server := newAPIServer(t, func(req capturedRequest) (int, string) { - switch req.Path { - case "/objects/object-1/animations": - return http.StatusOK, `{"submissions":[{"background_job_id":"nested-job","status":"queued"}]}` - case "/background-jobs/nested-job": - polled = true - return http.StatusOK, `{"status":"completed","last_response":{"ok":true}}` - default: - t.Fatalf("unexpected path: %s", req.Path) - } - return http.StatusInternalServerError, `{}` - }) - defer server.Close() - - code, _, stderr := runTestCLI(t, []string{ - "objects/object-1/animations", - "--base-url", server.URL, - "--animation-description", "slash", - "--wait", - "--poll-interval", "1ms", - "--timeout", "1s", - }, map[string]string{"PIXELLAB_API_KEY": "test-token"}) - if code != 0 { - t.Fatalf("expected success, got code %d stderr %s", code, stderr) - } - if !polled { - t.Fatalf("expected nested background job to be polled") - } -} - -func TestWaitTimeoutReturnsNonZero(t *testing.T) { - server := newAPIServer(t, func(req capturedRequest) (int, string) { - if req.Path == "/generate-image-v2" { - return http.StatusAccepted, `{"background_job_id":"job-timeout","status":"processing"}` - } - return http.StatusOK, `{"status":"processing"}` - }) - defer server.Close() - - code, _, stderr := runTestCLI(t, []string{ - "generate-image-v2", - "--base-url", server.URL, - "--description", "slow job", - "--image-size", "32x32", - "--wait", - "--poll-interval", "1ms", - "--timeout", "5ms", - }, map[string]string{"PIXELLAB_API_KEY": "test-token"}) - if code == 0 { - t.Fatalf("expected timeout failure") - } - if !strings.Contains(strings.ToLower(stderr), "timeout") { - t.Fatalf("expected timeout in stderr, got %q", stderr) - } -} - -func TestHTTPErrorReturnsNonZeroAndPrintsMessage(t *testing.T) { - server := newAPIServer(t, func(req capturedRequest) (int, string) { - return http.StatusUnauthorized, `{"detail":"Invalid API token"}` - }) - defer server.Close() - - code, _, stderr := runTestCLI(t, []string{"balance", "--base-url", server.URL}, map[string]string{"PIXELLAB_API_KEY": "bad-token"}) - if code == 0 { - t.Fatalf("expected failure exit code") - } - if !strings.Contains(stderr, "401") || !strings.Contains(stderr, "Invalid API token") { - t.Fatalf("expected status and API message in stderr, got %q", stderr) - } -} - -func TestMissingRequiredFlagFailsBeforeRequest(t *testing.T) { - called := false - server := newAPIServer(t, func(req capturedRequest) (int, string) { - called = true - return http.StatusOK, `{}` - }) - defer server.Close() - - code, _, stderr := runTestCLI(t, []string{"create-image-pixen", "--base-url", server.URL, "--image-size", "32x32"}, map[string]string{"PIXELLAB_API_KEY": "test-token"}) - if code == 0 { - t.Fatalf("expected validation failure") - } - if called { - t.Fatalf("request should not be sent when required flags are missing") - } - if !strings.Contains(stderr, "--description") { - t.Fatalf("expected missing flag in stderr, got %q", stderr) - } -} - -func TestUnknownEndpointFailsBeforeRequest(t *testing.T) { - server := newAPIServer(t, func(req capturedRequest) (int, string) { - t.Fatalf("unknown endpoint should not send request") - return http.StatusOK, `{}` - }) - defer server.Close() - - code, _, stderr := runTestCLI(t, []string{"not-real", "--base-url", server.URL}, map[string]string{"PIXELLAB_API_KEY": "test-token"}) - if code == 0 { - t.Fatalf("expected unknown endpoint failure") - } - if !strings.Contains(stderr, "/not-real") { - t.Fatalf("expected endpoint path in stderr, got %q", stderr) - } -} - -func TestTokenFlagOverridesEnvironment(t *testing.T) { - server := newAPIServer(t, func(req capturedRequest) (int, string) { - if req.Auth != "Bearer flag-token" { - t.Fatalf("expected flag token to win, got %q", req.Auth) - } - return http.StatusOK, `{"ok":true}` - }) - defer server.Close() - - code, _, stderr := runTestCLI(t, []string{"balance", "--base-url", server.URL, "--token", "flag-token"}, map[string]string{"PIXELLAB_API_KEY": "env-token"}) - if code != 0 { - t.Fatalf("expected success, got code %d stderr %s", code, stderr) - } -} - -func TestDotEnvPixellabAPIKeyIsUsedWhenEnvironmentIsUnset(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, ".env"), []byte("PIXELLAB_API_KEY=dotenv-key\n"), 0o600); err != nil { - t.Fatalf("write .env: %v", err) - } - t.Chdir(dir) - - server := newAPIServer(t, func(req capturedRequest) (int, string) { - if req.Auth != "Bearer dotenv-key" { - t.Fatalf("expected .env token, got %q", req.Auth) - } - return http.StatusOK, `{"ok":true}` - }) - defer server.Close() - - code, _, stderr := runTestCLI(t, []string{"balance", "--base-url", server.URL}, map[string]string{}) - if code != 0 { - t.Fatalf("expected success, got code %d stderr %s", code, stderr) - } -} - -func TestTokenPrecedenceFlagEnvironmentThenDotEnv(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, ".env"), []byte("PIXELLAB_API_KEY=dotenv-key\n"), 0o600); err != nil { - t.Fatalf("write .env: %v", err) - } - t.Chdir(dir) - - server := newAPIServer(t, func(req capturedRequest) (int, string) { - if req.Auth != "Bearer env-token" { - t.Fatalf("expected env token to override .env, got %q", req.Auth) - } - return http.StatusOK, `{"ok":true}` - }) - defer server.Close() - - code, _, stderr := runTestCLI(t, []string{"balance", "--base-url", server.URL}, map[string]string{"PIXELLAB_API_KEY": "env-token"}) - if code != 0 { - t.Fatalf("expected success, got code %d stderr %s", code, stderr) - } -} - -func TestOutputFileReceivesBinaryZipResponse(t *testing.T) { - zipBytes := []byte("zip content") - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet || r.URL.Path != "/characters/char-1/zip" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - w.Header().Set("Content-Type", "application/zip") - _, _ = w.Write(zipBytes) - })) - defer server.Close() - - outPath := filepath.Join(t.TempDir(), "character.zip") - code, _, stderr := runTestCLI(t, []string{ - "characters/char-1/zip", - "--base-url", server.URL, - "--out", outPath, - }, map[string]string{"PIXELLAB_API_KEY": "test-token"}) - if code != 0 { - t.Fatalf("expected success, got code %d stderr %s", code, stderr) - } - got, err := os.ReadFile(outPath) - if err != nil { - t.Fatalf("read zip: %v", err) - } - if !bytes.Equal(got, zipBytes) { - t.Fatalf("unexpected zip bytes: %q", string(got)) - } -} - -func TestDurationFlagsAreParsed(t *testing.T) { - start := time.Now() - server := newAPIServer(t, func(req capturedRequest) (int, string) { - if req.Path == "/generate-image-v2" { - return http.StatusAccepted, `{"background_job_id":"job-duration","status":"processing"}` - } - return http.StatusOK, `{"status":"failed","error":"boom"}` - }) - defer server.Close() - - code, _, _ := runTestCLI(t, []string{ - "generate-image-v2", - "--base-url", server.URL, - "--description", "duration test", - "--image-size", "32x32", - "--wait", - "--poll-interval", "10ms", - "--timeout", "250ms", - }, map[string]string{"PIXELLAB_API_KEY": "test-token"}) - if code == 0 { - t.Fatalf("expected failed job to return non-zero") - } - if time.Since(start) > time.Second { - t.Fatalf("duration flags were not respected") - } -} - -func assertJSONValue(t *testing.T, object map[string]any, key string, want any) { - t.Helper() - if got := object[key]; got != want { - t.Fatalf("%s: got %#v want %#v in body %#v", key, got, want, object) - } -} - -func assertSizeObject(t *testing.T, value any, width int, height int) { - t.Helper() - object, ok := value.(map[string]any) - if !ok { - t.Fatalf("size value is not object: %#v", value) - } - if object["width"] != float64(width) || object["height"] != float64(height) { - t.Fatalf("unexpected size object: %#v", object) - } -} - -func writeTempJSON(t *testing.T, content string) string { - t.Helper() - path := filepath.Join(t.TempDir(), "body.json") - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatalf("write json: %v", err) - } - return path -} - -func writeTempBytes(t *testing.T, name string, content []byte) string { - t.Helper() - path := filepath.Join(t.TempDir(), name) - if err := os.WriteFile(path, content, 0o600); err != nil { - t.Fatalf("write bytes: %v", err) - } - return path -} diff --git a/scripts/native_linkage.py b/scripts/native_linkage.py new file mode 100644 index 0000000..322382e --- /dev/null +++ b/scripts/native_linkage.py @@ -0,0 +1,35 @@ +"""Reject release executables that require an extra C runtime installation.""" +from pathlib import Path +import struct +import subprocess + + +def verify_linkage(path, system): + if system == 'Linux': + assert 'INTERP' not in subprocess.check_output(['readelf', '-l', str(path)], text=True) + assert '(NEEDED)' not in subprocess.check_output(['readelf', '-d', str(path)], text=True) + elif system == 'Windows': + data = Path(path).read_bytes() + assert data[:2] == b'MZ' + pe = struct.unpack_from(' Value { + match value { + "true" => json!(true), + "false" => json!(false), + _ => { + if let Ok(number) = value.parse::() { + json!(number) + } else if value.contains('.') + && let Ok(number) = value.parse::() + && number.is_finite() + { + json!(number) + } else { + json!(value) + } + } + } +} +pub fn maybe_file(value: &str) -> String { + if value.trim_start().starts_with(['{', '[']) { + value.into() + } else { + fs::read_to_string(value).unwrap_or_else(|_| value.into()) + } +} +pub fn extension(path: &Path) -> String { + path.file_name() + .map(|name| { + let name = name.to_string_lossy(); + name.rfind('.') + .map(|index| name[index..].to_owned()) + .unwrap_or_default() + }) + .unwrap_or_default() +} +fn image(path: &str) -> Result { + let bytes = fs::read(path)?; + let format = extension(Path::new(path)) + .trim_start_matches('.') + .to_ascii_lowercase(); + let format = match format.as_str() { + "jpg" => "jpeg", + "" => "png", + other => other, + }; + Ok(json!({"type":"base64","base64":STANDARD.encode(bytes),"format":format})) +} +fn size(value: &str) -> Result { + if value.contains('x') { + let parts: Vec<_> = value.split('x').collect(); + if parts.len() != 2 { + return Err("expected WIDTHxHEIGHT".into()); + } + Ok(json!({"width":parts[0].parse::()?,"height":parts[1].parse::()?})) + } else { + Ok(infer(value)) + } +} +pub fn build(spec: &Endpoint, config: &Config) -> Result>> { + for part in spec.path.split('/').filter(|part| part.starts_with('{')) { + let name = part.trim_matches(['{', '}']).replace('_', "-"); + if config.flags.contains_key(&name) { + return Err(format!( + "route parameter {name} belongs in the route, for example: {}", + display(&spec.path) + ) + .into()); + } + } + if !config.body.is_empty() { + let value = serde_json::from_str::(&maybe_file(&config.body))?; + return match value { + Value::Object(value) => Ok(Some(value)), + Value::Null => Ok(None), + _ => Err("invalid --body-json: expected an object".into()), + }; + } + if matches!(spec.method.as_str(), "GET" | "DELETE") { + return Ok(None); + } + let mut body = Map::new(); + for (flag, values) in &config.flags { + if spec.query.iter().any(|key| key.replace('_', "-") == *flag) + || spec + .path + .contains(&format!("{{{}}}", flag.replace('-', "_"))) + { + continue; + } + let value = values.last().map(String::as_str).unwrap_or(""); + let (key, value) = match flag.as_str() { + "tag" => ("tags".into(), json!(values)), + "index" => ( + "indices".into(), + json!( + values + .iter() + .map(|value| value.parse::()) + .collect::, _>>()? + ), + ), + "item-description" => ("item_descriptions".into(), json!(values)), + "directions" => ( + "directions".into(), + json!( + value + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect::>() + ), + ), + "style-image" + if matches!( + spec.path.as_str(), + "/generate-with-style-v2" | "/create-1-direction-object" + ) => + { + ( + "style_images".into(), + Value::Array( + values + .iter() + .map(|value| image(value)) + .collect::>>()?, + ), + ) + } + _ => { + let key = flag.replace('-', "_"); + if let Some(name) = flag.strip_suffix("-json") { + ( + name.replace('-', "_"), + serde_json::from_str(&maybe_file(value))?, + ) + } else if flag == "image" + || flag.ends_with("-image") + || flag.ends_with("-images") + || flag.ends_with("-frame") + { + (key, image(value)?) + } else if values.len() > 1 + || matches!(flag.as_str(), "style-image" | "reference-image") + { + ( + key, + Value::Array(values.iter().map(|value| infer(value)).collect()), + ) + } else if flag == "size" || flag.ends_with("-size") { + (key, size(value)?) + } else { + (key, infer(value)) + } + } + }; + body.insert(key, value); + } + for required in &spec.required { + if body.get(required).is_none_or(Value::is_null) { + return Err(format!("missing --{}", required.replace('_', "-")).into()); + } + } + Ok(Some(body)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn scalar_and_size_inference_preserve_existing_wire_types() { + assert_eq!(infer("1e3"), json!("1e3")); + assert_eq!(infer("1.5"), json!(1.5)); + assert_eq!(infer("false"), json!(false)); + assert_eq!(size("64x32").unwrap(), json!({"width":64,"height":32})); + assert_eq!(extension(Path::new(".hidden")), ".hidden"); + } +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..2039a96 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,308 @@ +use crate::{ + Result, + body::extension, + config::{Config, Endpoint, escape}, +}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use reqwest::{Method, blocking::Client}; +use serde_json::{Map, Value}; +use std::{ + fs::{self, OpenOptions}, + io::{Read, Write}, + path::{Path, PathBuf}, + time::{Duration, Instant}, +}; + +pub fn create() -> Result { + let _ = rustls::crypto::ring::default_provider().install_default(); + Ok(Client::builder() + .user_agent(concat!("pxlb/", env!("PXLB_VERSION"))) + .retry(reqwest::retry::never()) + .timeout(None) + .connect_timeout(Duration::from_secs(30)) + .build()?) +} +fn go_value(value: &Value) -> String { + match value { + Value::String(value) => value.clone(), + Value::Null => "".into(), + Value::Array(values) => format!( + "[{}]", + values.iter().map(go_value).collect::>().join(" ") + ), + Value::Object(values) => format!( + "map[{}]", + values + .iter() + .map(|(key, value)| format!("{key}:{}", go_value(value))) + .collect::>() + .join(" ") + ), + other => other.to_string(), + } +} + +pub fn execute( + client: &Client, + config: &Config, + spec: &Endpoint, + path: &str, + body: Option<&Map>, + remaining: Option, +) -> Result<(Vec, String)> { + let url = format!("{}{path}", config.base.trim_end_matches('/')); + let mut request = client.request(Method::from_bytes(spec.method.as_bytes())?, url); + let query: Vec<_> = spec + .query + .iter() + .filter_map(|name| { + config + .flags + .get(&name.replace('_', "-")) + .and_then(|values| values.last()) + .map(|value| (name, value)) + }) + .collect(); + request = request.query(&query); + if let Some(body) = body { + request = request + .header("Content-Type", "application/json") + .body(serde_json::to_vec(body)?); + } + if !config.token.is_empty() { + request = request.bearer_auth(&config.token); + } + if let Some(remaining) = remaining { + request = request.timeout(remaining); + } + let mut response = match request.send() { + Ok(response) => response, + Err(error) if error.is_timeout() => return Err("request timeout".into()), + Err(error) => return Err(error.without_url().into()), + }; + let status = response.status(); + let content_type = response + .headers() + .get("Content-Type") + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_owned(); + let mut bytes = Vec::new(); + std::io::Read::take(&mut response, (512 << 20) + 1).read_to_end(&mut bytes)?; + if bytes.len() > 512 << 20 { + return Err("API response exceeds 512 MiB".into()); + } + if !status.is_success() { + let text = String::from_utf8_lossy(&bytes); + let mut message = text.trim().to_owned(); + if let Ok(value) = serde_json::from_slice::(&bytes) + && let Some(detail) = value.get("detail").or_else(|| value.get("error")) + { + message = go_value(detail); + } + if message.is_empty() { + message = status.canonical_reason().unwrap_or("").into(); + } + return Err(format!("API error {}: {message}", status.as_u16()).into()); + } + Ok((bytes, content_type)) +} + +pub fn job_id(value: &Value) -> Option<&str> { + match value { + Value::Object(values) => { + for key in ["background_job_id", "job_id"] { + if let Some(id) = values + .get(key) + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + { + return Some(id); + } + } + if values + .get("status") + .map(go_value) + .unwrap_or_default() + .to_ascii_lowercase() + .contains("process") + && let Some(id) = values.get("id").and_then(Value::as_str) + { + return Some(id); + } + values.values().find_map(job_id) + } + Value::Array(values) => values.iter().find_map(job_id), + _ => None, + } +} + +pub fn wait(client: &Client, config: &Config, initial: Value) -> Result { + let Some(id) = job_id(&initial) else { + return Ok(initial); + }; + let id = id.to_owned(); + let deadline = if config.timeout.is_zero() { + None + } else { + Some( + Instant::now() + .checked_add(config.timeout) + .ok_or("job timeout is too large")?, + ) + }; + let spec = Endpoint { + method: "GET".into(), + path: String::new(), + required: Vec::new(), + query: Vec::new(), + description: String::new(), + }; + loop { + let remaining = if let Some(deadline) = deadline { + Some( + deadline + .checked_duration_since(Instant::now()) + .ok_or_else(|| format!("timeout waiting for job {id}"))?, + ) + } else { + None + }; + let (bytes, _) = execute( + client, + config, + &spec, + &format!("/background-jobs/{}", escape(&id)), + None, + remaining, + )?; + let payload: Value = serde_json::from_slice(&bytes)?; + if !payload.is_object() { + return Err("job status is not a JSON object".into()); + } + match payload["status"] + .as_str() + .unwrap_or("") + .to_ascii_lowercase() + .as_str() + { + "completed" | "complete" | "succeeded" | "success" => { + return Ok(if payload["last_response"].is_object() { + payload["last_response"].clone() + } else { + payload + }); + } + "failed" | "error" => { + return Err(format!("job {id} failed: {}", go_value(&payload)).into()); + } + _ => {} + } + let delay = if let Some(deadline) = deadline { + config + .poll + .min(deadline.saturating_duration_since(Instant::now())) + } else { + config.poll + }; + std::thread::sleep(delay); + } +} + +fn directory(path: &Path) -> Result<()> { + if path.as_os_str().is_empty() { + return Ok(()); + } + let mut builder = fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o755); + } + builder.create(path)?; + Ok(()) +} +pub fn write_bytes(path: &Path, bytes: &[u8]) -> Result<()> { + let path = if extension(path).is_empty() { + directory(path)?; + path.join("response.bin") + } else { + if let Some(parent) = path.parent() { + directory(parent)?; + } + path.to_owned() + }; + let mut options = OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path)?; + file.write_all(bytes)?; + file.flush()?; + Ok(()) +} +fn images<'a>(payload: &'a Value, found: &mut Vec<&'a Map>) { + match payload { + Value::Object(values) => { + if values + .get("base64") + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty()) + { + found.push(values); + } + for value in values.values() { + images(value, found); + } + } + Value::Array(values) => { + for value in values { + images(value, found); + } + } + _ => {} + } +} +pub fn write_images(out: &str, payload: &Value) -> Result<()> { + let mut found = Vec::new(); + images(payload, &mut found); + if found.is_empty() { + return Ok(()); + } + let single = found.len() == 1 && !extension(Path::new(out)).is_empty(); + if !single { + directory(Path::new(out))?; + } + for (index, image) in found.iter().enumerate() { + let mut raw = image["base64"].as_str().unwrap_or(""); + if let Some((prefix, data)) = raw.split_once(',') + && prefix.contains("base64") + { + raw = data; + } + let bytes = STANDARD.decode(raw.replace(['\r', '\n'], ""))?; + let path = if single { + PathBuf::from(out) + } else { + let format = image + .get("format") + .map(go_value) + .filter(|value| !value.is_empty() && value != "") + .unwrap_or_else(|| "png".into()); + let format = format.strip_prefix('.').unwrap_or(&format); + if format.is_empty() + || format.len() > 16 + || !format.bytes().all(|byte| byte.is_ascii_alphanumeric()) + { + return Err("API returned an invalid image format".into()); + } + Path::new(out).join(format!("image_{index:02}.{format}")) + }; + write_bytes(&path, &bytes)?; + } + Ok(()) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..d880a2b --- /dev/null +++ b/src/config.rs @@ -0,0 +1,277 @@ +use crate::Result; +use serde::Deserialize; +use std::{collections::BTreeMap, fs, time::Duration}; + +#[derive(Clone, Deserialize)] +pub struct Endpoint { + pub method: String, + pub path: String, + pub required: Vec, + pub query: Vec, + pub description: String, +} +pub fn endpoints() -> Vec { + serde_json::from_str(include_str!("endpoints.json")).expect("validated endpoint registry") +} +pub const BASE: &str = "https://api.pixellab.ai/v2"; +pub struct Config { + pub token: String, + pub base: String, + pub method: String, + pub out: String, + pub wait: bool, + pub poll: Duration, + pub timeout: Duration, + pub body: String, + pub quiet: bool, + pub flags: BTreeMap>, +} +pub fn boolean(value: &str) -> bool { + !matches!(value.to_ascii_lowercase().as_str(), "false" | "f" | "0") +} +fn duration(value: &str) -> Result { + if value == "0" { + return Ok(Duration::ZERO); + } + if value.is_empty() { + return Err("empty duration".into()); + } + let negative = value.starts_with('-'); + let mut rest = value.trim_start_matches(['-', '+']); + let mut seconds = 0.; + if rest.is_empty() { + return Err("invalid duration".into()); + } + while !rest.is_empty() { + let count = rest + .find(|c: char| !c.is_ascii_digit() && c != '.') + .ok_or("duration needs a unit")?; + let amount: f64 = rest[..count].parse()?; + rest = &rest[count..]; + let (unit, scale) = [ + ("ns", 1e-9), + ("us", 1e-6), + ("µs", 1e-6), + ("μs", 1e-6), + ("ms", 1e-3), + ("s", 1.), + ("m", 60.), + ("h", 3600.), + ] + .into_iter() + .find(|(unit, _)| rest.starts_with(unit)) + .ok_or("invalid duration unit")?; + seconds += amount * scale; + rest = &rest[unit.len()..]; + } + if !seconds.is_finite() || seconds > i64::MAX as f64 / 1e9 { + return Err("duration exceeds supported range".into()); + } + Ok(if negative { + Duration::ZERO + } else { + Duration::try_from_secs_f64(seconds)? + }) +} +pub fn token() -> String { + if let Ok(token) = std::env::var("PIXELLAB_API_KEY") + && !token.is_empty() + { + return token; + } + let Ok(text) = fs::read_to_string(".env") else { + return String::new(); + }; + let mut token = String::new(); + for line in text.lines() { + let line = line.trim(); + if line.starts_with('#') { + continue; + } + let line = line.strip_prefix("export ").unwrap_or(line); + if let Some((key, value)) = line.split_once('=') + && key.trim() == "PIXELLAB_API_KEY" + { + token = value.trim().trim_matches(['\"', '\'']).into(); + } + } + token +} +impl Config { + pub fn parse(args: &[String]) -> Result { + let mut config = Self { + token: token(), + base: BASE.into(), + method: String::new(), + out: String::new(), + wait: false, + poll: Duration::from_secs(5), + timeout: Duration::ZERO, + body: String::new(), + quiet: false, + flags: BTreeMap::new(), + }; + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + index += 1; + let Some(name) = arg.strip_prefix("--") else { + return Err(format!("unexpected positional argument: {arg}").into()); + }; + let value = if args + .get(index) + .is_some_and(|value| !value.starts_with("--")) + { + let value = args[index].clone(); + index += 1; + value + } else { + "true".into() + }; + match name { + "token" => config.token = value, + "base-url" => config.base = value, + "http-method" => { + config.method = value.to_ascii_uppercase(); + if !matches!(config.method.as_str(), "GET" | "POST" | "PATCH" | "DELETE") { + return Err(format!("invalid --http-method: {value}").into()); + } + } + "json" => {} + "out" => config.out = value, + "wait" => config.wait = boolean(&value), + "poll-interval" => config.poll = duration(&value)?, + "timeout" => config.timeout = duration(&value)?, + "body-json" => config.body = value, + "quiet" => config.quiet = boolean(&value), + _ => config.flags.entry(name.into()).or_default().push(value), + } + } + Ok(config) + } +} +pub fn placeholder(value: &str) -> bool { + (value.starts_with('{') && value.ends_with('}') + || value.starts_with('<') && value.ends_with('>')) + && value.len() > 2 +} +pub fn escape(value: &str) -> String { + let mut out = String::new(); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || b"-_.~$&+:=@".contains(&byte) { + out.push(byte as char); + } else { + out.push_str(&format!("%{byte:02X}")); + } + } + out +} +pub fn resolve(route: &str, config: &Config) -> Result<(Endpoint, String)> { + let route = format!("/{}", route.trim_start_matches('/')); + let path: Vec<_> = route.trim_matches('/').split('/').collect(); + let mut matches = Vec::new(); + for spec in endpoints() { + let template: Vec<_> = spec.path.trim_matches('/').split('/').collect(); + if template.len() != path.len() { + continue; + } + let mut parts = Vec::new(); + let mut valid = true; + for (template, actual) in template.iter().zip(&path) { + if placeholder(actual) || actual.is_empty() { + valid = false; + break; + } + if template.starts_with('{') { + parts.push(escape(actual)); + } else if template == actual { + parts.push(template.to_string()); + } else { + valid = false; + break; + } + } + if valid { + matches.push((spec, format!("/{}", parts.join("/")))); + } + } + if matches.is_empty() { + return Err(format!("unknown endpoint: {route}").into()); + } + let available = matches + .iter() + .map(|(spec, _)| spec.method.to_ascii_lowercase()) + .collect::>() + .join(", "); + if !config.method.is_empty() { + return matches.into_iter().find(|(spec,_)|spec.method==config.method).ok_or_else(||format!("endpoint {route} does not support --http-method {}; available methods: {available}",config.method.to_ascii_lowercase()).into()); + } + if matches.len() == 1 { + return Ok(matches.remove(0)); + } + let query: Vec<_> = matches + .iter() + .flat_map(|(spec, _)| spec.query.iter().map(|v| v.replace('_', "-"))) + .collect(); + if !config.body.is_empty() || config.flags.keys().any(|key| !query.contains(key)) { + let count = matches + .iter() + .filter(|(spec, _)| !matches!(spec.method.as_str(), "GET" | "DELETE")) + .count(); + if count == 1 { + return Ok(matches + .into_iter() + .find(|(spec, _)| !matches!(spec.method.as_str(), "GET" | "DELETE")) + .unwrap()); + } + } + matches + .into_iter() + .find(|(spec, _)| spec.method == "GET") + .ok_or_else(|| { + format!("ambiguous endpoint: {route}; use --http-method with one of: {available}") + .into() + }) +} +pub fn display(path: &str) -> String { + path.trim_start_matches('/') + .split('/') + .map(|part| { + if placeholder(part) { + format!("<{}>", &part[1..part.len() - 1]) + } else { + part.into() + } + }) + .collect::>() + .join("/") +} +pub fn help() -> String { + let mut text="pxlb - PixelLab v2 API CLI\n\nUsage:\n pxlb [flags]\n pxlb --version\n\nGlobal flags:\n --token PixelLab API key; overrides PIXELLAB_API_KEY and .env.\n --base-url API base URL; defaults to https://api.pixellab.ai/v2.\n --http-method Override inferred HTTP method for colliding routes.\n --body-json Send a raw JSON request body.\n --wait Poll returned background jobs until completion.\n --poll-interval