diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml new file mode 100644 index 0000000..e8ac054 --- /dev/null +++ b/.github/workflows/develop.yml @@ -0,0 +1,206 @@ +name: Develop Build + +on: + push: + branches: + - develop + workflow_dispatch: + inputs: + environment: + description: 'Environment to build against' + required: true + type: choice + default: qc + options: + - qc + - qc-stable + - beta + +permissions: + contents: read + +jobs: + build: + name: Build non-production artifacts + runs-on: ubuntu-latest + timeout-minutes: 60 + + outputs: + artifact-name: ${{ steps.env.outputs.artifact-name }} + + steps: + - name: Check out repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + # Each environment has its own goreleaser config, which bakes in the + # matching build tag and binary name (see main.go). Pushes to develop + # always build QC; other environments are dispatched manually. + - name: Resolve build environment + id: env + env: + ENVIRONMENT: ${{ inputs.environment || 'qc' }} + run: | + set -e + config=".goreleaser/config-${ENVIRONMENT}.yaml" + if [ ! -f "$config" ]; then + echo "::error::No goreleaser config for environment '$ENVIRONMENT' ($config)" + exit 1 + fi + echo "Building the $ENVIRONMENT environment from $config" + echo "config=$config" >> "$GITHUB_OUTPUT" + echo "artifact-name=${ENVIRONMENT}-artifacts-${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT" + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Run GoReleaser (all platforms, unsigned) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CONFIG: ${{ steps.env.outputs.config }} + run: | + docker run --rm \ + -v "${PWD}:/workspace" \ + -w /workspace \ + -e GITHUB_TOKEN \ + ghcr.io/goreleaser/goreleaser-cross:v1.25-v2.12.7 \ + release --snapshot --config "$CONFIG" --clean --parallelism 2 + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.env.outputs.artifact-name }} + if-no-files-found: error + path: | + dist/*.tar.gz + dist/*.zip + dist/checksums.txt + dist/artifacts.json + dist/metadata.json + + sign-macos: + name: Sign macOS artifacts + runs-on: macos-latest + needs: build + timeout-minutes: 30 + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Check for Apple signing secrets + id: secrets_check + env: + APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} + run: | + if [ -n "$APPLE_CERT_P12" ]; then + echo "has_signing_cert=true" >> "$GITHUB_OUTPUT" + else + echo "has_signing_cert=false" >> "$GITHUB_OUTPUT" + echo "::warning::APPLE_CERT_P12 not configured — skipping Apple code signing." + fi + + - name: Import Apple certificate + if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' }} + env: + APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} + APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + set -e + for var_name in APPLE_CERT_PASSWORD KEYCHAIN_PASSWORD; do + if [ -z "${!var_name}" ]; then + echo "::error::$var_name is required when APPLE_CERT_P12 is configured." + exit 1 + fi + done + + printf '%s' "$APPLE_CERT_P12" | base64 -D > cert.p12 + + KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security import cert.p12 -k "$KEYCHAIN_PATH" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"' | xargs) + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build.outputs.artifact-name }} + path: dist + + - name: Sign macOS artifacts + if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' }} + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + KEYCHAIN_PATH: ${{ runner.temp }}/build.keychain-db + run: | + set -e + if [ -z "$APPLE_SIGNING_IDENTITY" ]; then + echo "::error::APPLE_SIGNING_IDENTITY is required for signing." + exit 1 + fi + if [ ! -f dist/checksums.txt ]; then + echo "::error::dist/checksums.txt not found." + exit 1 + fi + + for archive in dist/*-darwin-*.tar.gz; do + [ -f "$archive" ] || continue + tmpdir="$(mktemp -d)" + orig_members=$(tar -tzf "$archive") + tar -xzf "$archive" -C "$tmpdir" + + while IFS= read -r -d '' bin; do + codesign --force --options runtime --timestamp \ + --keychain "$KEYCHAIN_PATH" \ + --sign "$APPLE_SIGNING_IDENTITY" "$bin" + codesign --verify --deep --strict "$bin" + done < <(find "$tmpdir" -type f -perm -111 -print0) + + # shellcheck disable=SC2086 + tar -czf "${archive}.signed" -C "$tmpdir" $orig_members + mv "${archive}.signed" "$archive" + rm -rf "$tmpdir" + + filename="$(basename "$archive")" + sha="$(shasum -a 256 "$archive" | awk '{print $1}')" + grep -v " $filename\$" dist/checksums.txt > dist/checksums.new || true + printf "%s %s\n" "$sha" "$filename" >> dist/checksums.new + mv dist/checksums.new dist/checksums.txt + + PATCH_NAME="$filename" PATCH_SHA="$sha" python3 << 'PYEOF' + import json, os + data = json.load(open('dist/artifacts.json')) + name = os.environ['PATCH_NAME'] + checksum = os.environ['PATCH_SHA'] + for a in data: + if a.get('name') == name: + a.setdefault('extra', {})['Checksum'] = 'sha256:' + checksum + with open('dist/artifacts.json', 'w') as f: + json.dump(data, f, indent=2) + PYEOF + done + + - name: Upload signed macOS artifacts + if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.build.outputs.artifact-name }}-macos-signed + if-no-files-found: error + path: | + dist/*-darwin-amd64.tar.gz + dist/*-darwin-arm64.tar.gz + dist/checksums.txt + + - name: Clean up keychain and cert + if: always() + run: | + security delete-keychain "$RUNNER_TEMP/build.keychain-db" 2>/dev/null || true + rm -f cert.p12 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..1a560e2 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,32 @@ +name: Quality Checks + +on: pull_request + +permissions: + contents: read + issues: read + checks: write + pull-requests: write + +jobs: + test: + name: Verify build and run unit tests + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Download modules + run: go mod download + + - name: Verify build + run: go build + + - name: Run tests + run: go test ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..81c938d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,630 @@ +name: Release + +on: + push: + tags: + # v0.* is intentional: prevents accidental production releases during + # initial development. Update to v* when ready to ship v1.0. + # + # A semver prerelease suffix selects the environment to build: + # v0.1.0 -> production (apimetrics) + # v0.1.0-beta[.N] -> beta (apimetrics-beta) + # v0.1.0-qc[.N] -> qc (apimetrics-qc) + # v0.1.0-qc-stable[.N] -> qc-stable (apimetrics-qc-stable) + # Any other suffix (e.g. v0.1.0-rc.1) is treated as production. + - v0.* + workflow_dispatch: + inputs: + environment: + description: 'Environment to build (ignored when running from a tag)' + required: false + type: choice + default: qc + options: + - qc + - qc-stable + - beta + - production + snapshot: + description: 'Run goreleaser in --snapshot mode (developer build, no publish)' + required: false + type: boolean + default: false + + +permissions: + contents: write # required to create releases and upload assets + +jobs: + release: + name: Build and publish release artifacts + runs-on: ubuntu-latest + timeout-minutes: 60 + + outputs: + environment: ${{ steps.env.outputs.environment }} + binary: ${{ steps.env.outputs.binary }} + is-production: ${{ steps.env.outputs.is-production }} + prerelease-flag: ${{ steps.env.outputs.prerelease-flag }} + + steps: + - name: Check out repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + # Which APImetrics environment this release targets. On a tag it comes + # from the semver prerelease suffix, so goreleaser still sees a plain + # semver tag and needs no special handling; on a manual dispatch it comes + # from the `environment` input. + - name: Resolve target environment + id: env + env: + INPUT_ENVIRONMENT: ${{ inputs.environment }} + run: | + set -e + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + tag="${GITHUB_REF_NAME#v}" + suffix="${tag#*-}" + [ "$suffix" = "$tag" ] && suffix="" + # Drop a trailing iteration counter so both -beta-3 (the convention + # used by this repo's existing tags) and -beta.3 resolve to "beta". + base="$(printf '%s' "$suffix" | sed -E 's/[-.][0-9]+$//')" + case "$base" in + beta|qc|qc-stable) environment="$base" ;; + *) environment="production" ;; + esac + else + environment="${INPUT_ENVIRONMENT:-qc}" + fi + + case "$environment" in + production) config=".goreleaser/config.yaml"; binary="apimetrics" ;; + beta) config=".goreleaser/config-beta.yaml"; binary="apimetrics-beta" ;; + qc) config=".goreleaser/config-qc.yaml"; binary="apimetrics-qc" ;; + qc-stable) config=".goreleaser/config-qc-stable.yaml"; binary="apimetrics-qc-stable" ;; + *) + echo "::error::Unknown environment '$environment'" + exit 1 + ;; + esac + + if [ ! -f "$config" ]; then + echo "::error::Missing goreleaser config $config for environment $environment" + exit 1 + fi + + # Non-production releases are marked as GitHub prereleases so they + # never show up as "Latest release" for people installing the CLI. + if [ "$environment" = "production" ]; then + is_production=true + prerelease_flag="" + else + is_production=false + prerelease_flag="--prerelease" + fi + + echo "Building the $environment environment ($binary) from $config" + { + echo "environment=$environment" + echo "config=$config" + echo "binary=$binary" + echo "is-production=$is_production" + echo "prerelease-flag=$prerelease_flag" + } >> "$GITHUB_OUTPUT" + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Verify release branch + env: + IS_PRODUCTION: ${{ steps.env.outputs.is-production }} + SNAPSHOT: ${{ inputs.snapshot }} + run: | + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + # Only production releases are pinned to main. Environment builds + # are cut from develop (or a feature branch) by design. + if [ "$IS_PRODUCTION" != "true" ]; then + echo "Non-production tag $GITHUB_REF_NAME — skipping the main branch check." + exit 0 + fi + # Tag pushes don't populate origin/* remote-tracking refs, so fetch + # the main branches explicitly before checking containment. + git fetch --quiet origin '+refs/heads/main:refs/remotes/origin/main' '+refs/heads/main-*:refs/remotes/origin/main-*' || true + # Allow main or a main- branch (e.g. main-hotfix) for emergency releases. + if ! git branch -r --contains "$GITHUB_SHA" | grep -qE '^\s*origin/main($|-)'; then + echo "::error::Tag $GITHUB_REF_NAME must be on the main branch (or a main- branch)." + exit 1 + fi + elif [[ "$GITHUB_REF" != "refs/heads/main" && "$GITHUB_REF" != refs/heads/main-* && "$SNAPSHOT" != "true" ]]; then + echo "::error::Release workflow must be dispatched from the main branch (or a main- branch, or use snapshot=true for ad-hoc builds)." + exit 1 + fi + + - name: Validate dispatch inputs + if: github.event_name == 'workflow_dispatch' + env: + SNAPSHOT: ${{ inputs.snapshot }} + run: | + if [[ "$GITHUB_REF" != refs/tags/* ]] && [ "$SNAPSHOT" != "true" ]; then + echo "::error::Manual dispatch from a non-tag ref requires snapshot=true to avoid creating a broken release." + exit 1 + fi + + - name: Ensure mac signing secrets for tag releases + if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true }} + env: + APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} + APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + missing=0 + for var_name in APPLE_CERT_P12 APPLE_CERT_PASSWORD KEYCHAIN_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID APPLE_ID_PASSWORD APPLE_TEAM_ID; do + if [ -z "${!var_name}" ]; then + echo "::warning::$var_name secret not configured; macOS signing/finalization steps will be skipped." + missing=1 + fi + done + if [ "$missing" -ne 0 ]; then + echo "Proceeding with Linux/Windows build only." + fi + + - name: Run GoReleaser (Linux) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SNAPSHOT: ${{ inputs.snapshot }} + CONFIG: ${{ steps.env.outputs.config }} + ENVIRONMENT: ${{ steps.env.outputs.environment }} + run: | + set -e + EXTRA="" + if [ "$SNAPSHOT" = "true" ]; then + EXTRA="--snapshot --skip homebrew" + echo "Running goreleaser in snapshot mode ($ENVIRONMENT build, no publish)" + elif [[ "$GITHUB_REF" == refs/tags/* ]]; then + EXTRA="--skip publish,homebrew" + echo "Building artifacts without publishing; GitHub release and GCS upload deferred to finalization" + fi + + docker run --rm \ + -v "${PWD}:/workspace" \ + -w /workspace \ + -e GITHUB_TOKEN \ + ghcr.io/goreleaser/goreleaser-cross:v1.25-v2.12.7 \ + release $EXTRA --config "$CONFIG" --clean --parallelism 2 + + - name: Create GitHub draft release + if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ENVIRONMENT: ${{ steps.env.outputs.environment }} + IS_PRODUCTION: ${{ steps.env.outputs.is-production }} + PRERELEASE: ${{ steps.env.outputs.prerelease-flag }} + run: | + set -e + # Production keeps the bare tag as its title; environment builds are + # labelled so they can't be mistaken for the production CLI. + TITLE="$GITHUB_REF_NAME" + if [ "$IS_PRODUCTION" != "true" ]; then + TITLE="$GITHUB_REF_NAME ($ENVIRONMENT)" + fi + + # $PRERELEASE is either empty or "--prerelease"; unquoted on purpose. + if gh release view "$GITHUB_REF_NAME" &>/dev/null; then + echo "Draft release $GITHUB_REF_NAME already exists (rerun); re-uploading artifacts." + gh release upload "$GITHUB_REF_NAME" \ + dist/*-linux-*.tar.gz dist/*-windows-*.zip dist/checksums.txt \ + --clobber + elif [ -f dist/CHANGELOG.md ]; then + gh release create "$GITHUB_REF_NAME" \ + dist/*-linux-*.tar.gz dist/*-windows-*.zip dist/checksums.txt \ + --draft $PRERELEASE \ + --title "$TITLE" \ + --notes-file dist/CHANGELOG.md + else + gh release create "$GITHUB_REF_NAME" \ + dist/*-linux-*.tar.gz dist/*-windows-*.zip dist/checksums.txt \ + --draft $PRERELEASE \ + --title "$TITLE" \ + --generate-notes + fi + + - name: Upload release artifacts + if: ${{ inputs.snapshot != true }} + uses: actions/upload-artifact@v4 + with: + name: release-artifacts + if-no-files-found: error + path: | + dist/*.tar.gz + dist/*.zip + dist/checksums.txt + dist/artifacts.json + dist/metadata.json + + # Snapshot builds: upload each asset as its own artifact so the + # Actions run lists them individually rather than as one combined zip. + - name: Upload snapshot — macOS (Intel) + if: ${{ inputs.snapshot == true }} + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.env.outputs.binary }}-darwin-amd64 + if-no-files-found: error + path: dist/*-darwin-amd64.tar.gz + + - name: Upload snapshot — macOS (Apple Silicon) + if: ${{ inputs.snapshot == true }} + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.env.outputs.binary }}-darwin-arm64 + if-no-files-found: error + path: dist/*-darwin-arm64.tar.gz + + - name: Upload snapshot — Linux (amd64) + if: ${{ inputs.snapshot == true }} + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.env.outputs.binary }}-linux-amd64 + if-no-files-found: error + path: dist/*-linux-amd64.tar.gz + + - name: Upload snapshot — Linux (arm64) + if: ${{ inputs.snapshot == true }} + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.env.outputs.binary }}-linux-arm64 + if-no-files-found: error + path: dist/*-linux-arm64.tar.gz + + - name: Upload snapshot — Windows (amd64) + if: ${{ inputs.snapshot == true }} + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.env.outputs.binary }}-windows-amd64 + if-no-files-found: error + path: dist/*-windows-amd64.zip + + - name: Upload snapshot — checksums + if: ${{ inputs.snapshot == true }} + uses: actions/upload-artifact@v4 + with: + name: checksums.txt + if-no-files-found: error + path: dist/checksums.txt + + release-macos: + name: Sign and notarize macOS artifacts + runs-on: macos-latest + needs: release + timeout-minutes: 30 + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Check for Apple signing secrets + id: secrets_check + env: + APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} + run: | + if [ -n "$APPLE_CERT_P12" ]; then + echo "has_signing_cert=true" >> "$GITHUB_OUTPUT" + else + echo "has_signing_cert=false" >> "$GITHUB_OUTPUT" + echo "::warning::APPLE_CERT_P12 not configured — skipping Apple code signing." + fi + + - name: Import Apple certificate + if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' }} + env: + APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} + APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + set -e + for var_name in APPLE_CERT_PASSWORD KEYCHAIN_PASSWORD; do + if [ -z "${!var_name}" ]; then + echo "::error::$var_name is required when APPLE_CERT_P12 is configured." + exit 1 + fi + done + + printf '%s' "$APPLE_CERT_P12" | base64 -D > cert.p12 + + KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security import cert.p12 -k "$KEYCHAIN_PATH" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"' | xargs) + + - name: Download release artifacts + if: ${{ inputs.snapshot != true }} + uses: actions/download-artifact@v4 + with: + name: release-artifacts + path: dist + + # Snapshot builds upload each asset as its own artifact; pull them all + # back (flattened) so the signing step finds the darwin archives. + - name: Download snapshot artifacts + if: ${{ inputs.snapshot == true }} + uses: actions/download-artifact@v4 + with: + merge-multiple: true + path: dist + + - name: Sign and notarize macOS artifacts + if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' }} + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + KEYCHAIN_PATH: ${{ runner.temp }}/build.keychain-db + run: | + set -e + for var_name in APPLE_SIGNING_IDENTITY APPLE_ID APPLE_ID_PASSWORD APPLE_TEAM_ID; do + if [ -z "${!var_name}" ]; then + echo "::error::$var_name is required for signing and notarization." + exit 1 + fi + done + if [ ! -f dist/checksums.txt ]; then + echo "::error::dist/checksums.txt not found; expected macOS artifacts from Linux job." + exit 1 + fi + + for archive in dist/*-darwin-*.tar.gz; do + [ -f "$archive" ] || continue + tmpdir="$(mktemp -d)" + orig_members=$(tar -tzf "$archive") + tar -xzf "$archive" -C "$tmpdir" + + while IFS= read -r -d '' bin; do + codesign --force --options runtime --timestamp \ + --keychain "$KEYCHAIN_PATH" \ + --sign "$APPLE_SIGNING_IDENTITY" "$bin" + codesign --verify --deep --strict "$bin" + + # Notarize each binary via a zip (notarytool requires zip/dmg/pkg, not a raw binary) + zip_dir="$(mktemp -d)" + zip_path="$zip_dir/binary.zip" + zip -j "$zip_path" "$bin" + xcrun notarytool submit "$zip_path" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_ID_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait + rm -rf "$zip_dir" + done < <(find "$tmpdir" -type f -perm -111 -print0) + + # shellcheck disable=SC2086 + tar -czf "${archive}.signed" -C "$tmpdir" $orig_members + mv "${archive}.signed" "$archive" + rm -rf "$tmpdir" + + filename="$(basename "$archive")" + sha="$(shasum -a 256 "$archive" | awk '{print $1}')" + grep -v " $filename\$" dist/checksums.txt > dist/checksums.new || true + printf "%s %s\n" "$sha" "$filename" >> dist/checksums.new + mv dist/checksums.new dist/checksums.txt + + # Keep artifacts.json in sync with the signed checksums for archival + # accuracy. Only present for full releases, not snapshot builds. + if [ -f dist/artifacts.json ]; then + PATCH_NAME="$filename" PATCH_SHA="$sha" python3 << 'PYEOF' + import json, os + data = json.load(open('dist/artifacts.json')) + name = os.environ['PATCH_NAME'] + checksum = os.environ['PATCH_SHA'] + for a in data: + if a.get('name') == name: + a.setdefault('extra', {})['Checksum'] = 'sha256:' + checksum + with open('dist/artifacts.json', 'w') as f: + json.dump(data, f, indent=2) + PYEOF + fi + done + + - name: Upload signed macOS artifacts + if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' && inputs.snapshot != true }} + uses: actions/upload-artifact@v4 + with: + name: macos-signed-artifacts + if-no-files-found: error + path: | + dist/*-darwin-amd64.tar.gz + dist/*-darwin-arm64.tar.gz + dist/checksums.txt + + # Snapshot builds: replace the unsigned darwin placeholders uploaded by + # the build job with the signed archives, listed as individual assets. + - name: Upload signed snapshot — macOS (Intel) + if: ${{ inputs.snapshot == true && steps.secrets_check.outputs.has_signing_cert == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.release.outputs.binary }}-darwin-amd64 + overwrite: true + if-no-files-found: error + path: dist/*-darwin-amd64.tar.gz + + - name: Upload signed snapshot — macOS (Apple Silicon) + if: ${{ inputs.snapshot == true && steps.secrets_check.outputs.has_signing_cert == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.release.outputs.binary }}-darwin-arm64 + overwrite: true + if-no-files-found: error + path: dist/*-darwin-arm64.tar.gz + + - name: Upload signed snapshot — checksums + if: ${{ inputs.snapshot == true && steps.secrets_check.outputs.has_signing_cert == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: checksums.txt + overwrite: true + if-no-files-found: error + path: dist/checksums.txt + + - name: Replace macOS assets on GitHub release + if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + gh release upload "$GITHUB_REF_NAME" \ + dist/*-darwin-amd64.tar.gz \ + dist/*-darwin-arm64.tar.gz \ + dist/checksums.txt \ + --clobber + + - name: Publish finalized GitHub release + if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + gh release edit "$GITHUB_REF_NAME" --draft=false + + - name: Generate Homebrew tap token + id: tap-token + if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' && needs.release.outputs.is-production == 'true' }} + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + repositories: homebrew-tap + + - name: Publish Homebrew formula + if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' && needs.release.outputs.is-production == 'true' }} + env: + HOMEBREW_TAP_GITHUB_TOKEN: ${{ steps.tap-token.outputs.token }} + run: | + set -e + VERSION="${GITHUB_REF_NAME#v}" + BASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}" + AMD64_FILE="apimetrics-${VERSION}-darwin-amd64.tar.gz" + ARM64_FILE="apimetrics-${VERSION}-darwin-arm64.tar.gz" + + AMD64_SHA=$(awk -v f="${AMD64_FILE}" '$2 == f {print $1}' dist/checksums.txt) + ARM64_SHA=$(awk -v f="${ARM64_FILE}" '$2 == f {print $1}' dist/checksums.txt) + + if [ -z "$AMD64_SHA" ] || [ -z "$ARM64_SHA" ]; then + echo "::error::Could not find darwin checksums in dist/checksums.txt" + exit 1 + fi + + git clone "https://x-access-token:${HOMEBREW_TAP_GITHUB_TOKEN}@github.com/APImetrics/homebrew-tap.git" tap-repo + + cat > tap-repo/Formula/apimetrics.rb << FORMULA_END + # typed: false + # frozen_string_literal: true + + class Apimetrics < Formula + desc "APImetrics CLI" + homepage "https://apimetrics.io" + version "${VERSION}" + license "MIT" + + on_macos do + on_intel do + url "${BASE_URL}/${AMD64_FILE}" + sha256 "${AMD64_SHA}" + end + on_arm do + url "${BASE_URL}/${ARM64_FILE}" + sha256 "${ARM64_SHA}" + end + end + + def install + bin.install "apimetrics" + end + + test do + system "#{bin}/apimetrics", "--version" + end + end + FORMULA_END + + cd tap-repo + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Formula/apimetrics.rb + if git diff --cached --quiet; then + echo "Homebrew formula is already up to date." + else + git commit -m "apimetrics ${VERSION}" + git push + fi + + - name: Clean up keychain and cert + if: always() + run: | + security delete-keychain "$RUNNER_TEMP/build.keychain-db" 2>/dev/null || true + rm -f cert.p12 + + release-winget: + name: Submit WinGet package update + runs-on: windows-latest + needs: [release, release-macos] + if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true && needs.release.outputs.is-production == 'true' }} + timeout-minutes: 15 + + steps: + - name: Check WinGet token + id: winget_check + shell: bash + env: + WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} + run: | + if [ -n "$WINGET_GITHUB_TOKEN" ]; then + echo "has_token=true" >> "$GITHUB_OUTPUT" + else + echo "has_token=false" >> "$GITHUB_OUTPUT" + echo "::warning::WINGET_GITHUB_TOKEN not configured — skipping WinGet submission." + fi + + - name: Check release is published + id: release_check + if: ${{ steps.winget_check.outputs.has_token == 'true' }} + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + is_draft=$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft') + if [ "$is_draft" = "true" ]; then + echo "is_published=false" >> "$GITHUB_OUTPUT" + echo "::warning::GitHub release $GITHUB_REF_NAME is still a draft (Apple signing secrets may not be configured) — skipping WinGet submission." + else + echo "is_published=true" >> "$GITHUB_OUTPUT" + fi + + - name: Submit WinGet package update + if: ${{ steps.winget_check.outputs.has_token == 'true' && steps.release_check.outputs.is_published == 'true' }} + shell: pwsh + env: + WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} + run: | + $ErrorActionPreference = 'Stop' + # Requires APIContext.APImetricsCLI to already exist in microsoft/winget-pkgs. + # See RELEASING.md § 4 ("WinGet package") for the one-time manual submission required before this runs. + $version = $env:GITHUB_REF_NAME -replace '^v', '' + $installerUrl = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/$env:GITHUB_REF_NAME/apimetrics-$version-windows-amd64.zip" + + Invoke-WebRequest -Uri "https://github.com/microsoft/winget-create/releases/download/v1.12.8.0/wingetcreate.exe" -OutFile wingetcreate.exe + .\wingetcreate.exe update APIContext.APImetricsCLI ` + --version $version ` + --urls $installerUrl ` + --submit ` + --token $env:WINGET_GITHUB_TOKEN diff --git a/.gitignore b/.gitignore index 4c9fca0..6677c9f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ dist TODO* launch.json +apimetrics +gcp-secret.json +.DS_Store diff --git a/.goreleaser.yml b/.goreleaser.yml deleted file mode 100644 index 21b5759..0000000 --- a/.goreleaser.yml +++ /dev/null @@ -1,80 +0,0 @@ -version: 2 - -project_name: apimetrics - -before: - hooks: - - go mod download - # TODO: Figure out how to test with CGO on all platforms. - - go test -v ./... - -builds: - - id: apimetrics-darwin-amd64 - goos: - - darwin - goarch: - - amd64 - env: - - CGO_ENABLED=1 - - CC=o64-clang - - CXX=o64-clang++ - - - id: apimetrics-darwin-arm64 - goos: - - darwin - goarch: - - arm64 - env: - - CGO_ENABLED=1 - - CC=oa64-clang - - CXX=oa64-clang++ - - - id: apimetrics-linux-amd64 - goos: - - linux - goarch: - - amd64 - env: - - CGO_ENABLED=1 - - CC=x86_64-linux-gnu-gcc - - CXX=x86_64-linux-gnu-g++ - - - id: apimetrics-linux-arm64 - goos: - - linux - goarch: - - arm64 - env: - - CGO_ENABLED=1 - - CC=aarch64-linux-gnu-gcc - - CXX=aarch64-linux-gnu-g++ - - - id: apimetrics-windows-amd64 - goos: - - windows - goarch: - - amd64 - env: - - CGO_ENABLED=1 - - CC=x86_64-w64-mingw32-gcc - - CXX=x86_64-w64-mingw32-g++ - -archives: - - name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}" - format_overrides: - - goos: windows - formats: - - zip - -checksum: - name_template: "checksums.txt" - -snapshot: - version_template: "{{ .Tag }}-next" - -changelog: - sort: asc - filters: - exclude: - - "^docs:" - - "^test:" diff --git a/.goreleaser/config-beta.yaml b/.goreleaser/config-beta.yaml new file mode 100644 index 0000000..3a1755c --- /dev/null +++ b/.goreleaser/config-beta.yaml @@ -0,0 +1,138 @@ +version: 2 +project_name: apimetrics-beta +builds: + - id: apimetrics-beta-darwin-amd64 + goos: + - darwin + goarch: + - amd64 + targets: + - darwin_amd64_v1 + dir: . + main: . + binary: apimetrics-beta + builder: go + tool: go + command: build + flags: + - -tags=beta + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=o64-clang + - CXX=o64-clang++ + - id: apimetrics-beta-darwin-arm64 + goos: + - darwin + goarch: + - arm64 + targets: + - darwin_arm64_v8.0 + dir: . + main: . + binary: apimetrics-beta + builder: go + tool: go + command: build + flags: + - -tags=beta + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=oa64-clang + - CXX=oa64-clang++ + - id: apimetrics-beta-linux-amd64 + goos: + - linux + goarch: + - amd64 + targets: + - linux_amd64_v1 + dir: . + main: . + binary: apimetrics-beta + builder: go + tool: go + command: build + flags: + - -tags=beta + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=x86_64-linux-gnu-gcc + - CXX=x86_64-linux-gnu-g++ + - id: apimetrics-beta-linux-arm64 + goos: + - linux + goarch: + - arm64 + targets: + - linux_arm64_v8.0 + dir: . + main: . + binary: apimetrics-beta + builder: go + tool: go + command: build + flags: + - -tags=beta + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=aarch64-linux-gnu-gcc + - CXX=aarch64-linux-gnu-g++ + - id: apimetrics-beta-windows-amd64 + goos: + - windows + goarch: + - amd64 + targets: + - windows_amd64_v1 + dir: . + main: . + binary: apimetrics-beta + builder: go + tool: go + command: build + flags: + - -tags=beta + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=x86_64-w64-mingw32-gcc + - CXX=x86_64-w64-mingw32-g++ +archives: + - id: default + builds_info: + mode: 493 + name_template: '{{ .ProjectName }}-{{ if .IsSnapshot }}{{ .Version }}{{ else }}{{ .Major }}.{{ .Minor }}.{{ .Patch }}{{ end }}-{{ .Os }}-{{ .Arch }}' + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + files: + - src: license* + - src: LICENSE* + - src: readme* + - src: README* +snapshot: + version_template: '{{ .ShortCommit }}' +checksum: + name_template: checksums.txt + algorithm: sha256 +dist: dist +gomod: + gobinary: go +before: + hooks: + - go mod download + - go test -v ./... +git: + tag_sort: -version:refname diff --git a/.goreleaser/config-qc-stable.yaml b/.goreleaser/config-qc-stable.yaml new file mode 100644 index 0000000..064bc61 --- /dev/null +++ b/.goreleaser/config-qc-stable.yaml @@ -0,0 +1,138 @@ +version: 2 +project_name: apimetrics-qc-stable +builds: + - id: apimetrics-qc-stable-darwin-amd64 + goos: + - darwin + goarch: + - amd64 + targets: + - darwin_amd64_v1 + dir: . + main: . + binary: apimetrics-qc-stable + builder: go + tool: go + command: build + flags: + - -tags=qcstable + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=o64-clang + - CXX=o64-clang++ + - id: apimetrics-qc-stable-darwin-arm64 + goos: + - darwin + goarch: + - arm64 + targets: + - darwin_arm64_v8.0 + dir: . + main: . + binary: apimetrics-qc-stable + builder: go + tool: go + command: build + flags: + - -tags=qcstable + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=oa64-clang + - CXX=oa64-clang++ + - id: apimetrics-qc-stable-linux-amd64 + goos: + - linux + goarch: + - amd64 + targets: + - linux_amd64_v1 + dir: . + main: . + binary: apimetrics-qc-stable + builder: go + tool: go + command: build + flags: + - -tags=qcstable + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=x86_64-linux-gnu-gcc + - CXX=x86_64-linux-gnu-g++ + - id: apimetrics-qc-stable-linux-arm64 + goos: + - linux + goarch: + - arm64 + targets: + - linux_arm64_v8.0 + dir: . + main: . + binary: apimetrics-qc-stable + builder: go + tool: go + command: build + flags: + - -tags=qcstable + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=aarch64-linux-gnu-gcc + - CXX=aarch64-linux-gnu-g++ + - id: apimetrics-qc-stable-windows-amd64 + goos: + - windows + goarch: + - amd64 + targets: + - windows_amd64_v1 + dir: . + main: . + binary: apimetrics-qc-stable + builder: go + tool: go + command: build + flags: + - -tags=qcstable + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=x86_64-w64-mingw32-gcc + - CXX=x86_64-w64-mingw32-g++ +archives: + - id: default + builds_info: + mode: 493 + name_template: '{{ .ProjectName }}-{{ if .IsSnapshot }}{{ .Version }}{{ else }}{{ .Major }}.{{ .Minor }}.{{ .Patch }}{{ end }}-{{ .Os }}-{{ .Arch }}' + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + files: + - src: license* + - src: LICENSE* + - src: readme* + - src: README* +snapshot: + version_template: '{{ .ShortCommit }}' +checksum: + name_template: checksums.txt + algorithm: sha256 +dist: dist +gomod: + gobinary: go +before: + hooks: + - go mod download + - go test -v ./... +git: + tag_sort: -version:refname diff --git a/.goreleaser/config-qc.yaml b/.goreleaser/config-qc.yaml new file mode 100644 index 0000000..a944838 --- /dev/null +++ b/.goreleaser/config-qc.yaml @@ -0,0 +1,128 @@ +version: 2 +project_name: apimetrics-qc +builds: + - id: apimetrics-qc-darwin-amd64 + goos: + - darwin + goarch: + - amd64 + targets: + - darwin_amd64_v1 + dir: . + main: . + binary: apimetrics-qc + builder: go + tool: go + command: build + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=o64-clang + - CXX=o64-clang++ + - id: apimetrics-qc-darwin-arm64 + goos: + - darwin + goarch: + - arm64 + targets: + - darwin_arm64_v8.0 + dir: . + main: . + binary: apimetrics-qc + builder: go + tool: go + command: build + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=oa64-clang + - CXX=oa64-clang++ + - id: apimetrics-qc-linux-amd64 + goos: + - linux + goarch: + - amd64 + targets: + - linux_amd64_v1 + dir: . + main: . + binary: apimetrics-qc + builder: go + tool: go + command: build + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=x86_64-linux-gnu-gcc + - CXX=x86_64-linux-gnu-g++ + - id: apimetrics-qc-linux-arm64 + goos: + - linux + goarch: + - arm64 + targets: + - linux_arm64_v8.0 + dir: . + main: . + binary: apimetrics-qc + builder: go + tool: go + command: build + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=aarch64-linux-gnu-gcc + - CXX=aarch64-linux-gnu-g++ + - id: apimetrics-qc-windows-amd64 + goos: + - windows + goarch: + - amd64 + targets: + - windows_amd64_v1 + dir: . + main: . + binary: apimetrics-qc + builder: go + tool: go + command: build + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=x86_64-w64-mingw32-gcc + - CXX=x86_64-w64-mingw32-g++ +archives: + - id: default + builds_info: + mode: 493 + name_template: '{{ .ProjectName }}-{{ if .IsSnapshot }}{{ .Version }}{{ else }}{{ .Major }}.{{ .Minor }}.{{ .Patch }}{{ end }}-{{ .Os }}-{{ .Arch }}' + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + files: + - src: license* + - src: LICENSE* + - src: readme* + - src: README* +snapshot: + version_template: '{{ .ShortCommit }}' +checksum: + name_template: checksums.txt + algorithm: sha256 +dist: dist +gomod: + gobinary: go +before: + hooks: + - go mod download + - go test -v ./... +git: + tag_sort: -version:refname diff --git a/.goreleaser/config.yaml b/.goreleaser/config.yaml new file mode 100644 index 0000000..a418153 --- /dev/null +++ b/.goreleaser/config.yaml @@ -0,0 +1,282 @@ +version: 2 +project_name: apimetrics +release: + github: + owner: APImetrics + name: APImetrics-cli + name_template: '{{.Tag}}' +builds: + - id: apimetrics-darwin-amd64 + goos: + - darwin + goarch: + - amd64 + goamd64: + - v1 + go386: + - sse2 + goarm: + - "6" + goarm64: + - v8.0 + gomips: + - hardfloat + goppc64: + - power8 + goriscv64: + - rva20u64 + targets: + - darwin_amd64_v1 + dir: . + main: . + binary: apimetrics + builder: go + tool: go + command: build + flags: + - -tags=prod + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=o64-clang + - CXX=o64-clang++ + - id: apimetrics-darwin-arm64 + goos: + - darwin + goarch: + - arm64 + goamd64: + - v1 + go386: + - sse2 + goarm: + - "6" + goarm64: + - v8.0 + gomips: + - hardfloat + goppc64: + - power8 + goriscv64: + - rva20u64 + targets: + - darwin_arm64_v8.0 + dir: . + main: . + binary: apimetrics + builder: go + tool: go + command: build + flags: + - -tags=prod + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=oa64-clang + - CXX=oa64-clang++ + - id: apimetrics-linux-amd64 + goos: + - linux + goarch: + - amd64 + goamd64: + - v1 + go386: + - sse2 + goarm: + - "6" + goarm64: + - v8.0 + gomips: + - hardfloat + goppc64: + - power8 + goriscv64: + - rva20u64 + targets: + - linux_amd64_v1 + dir: . + main: . + binary: apimetrics + builder: go + tool: go + command: build + flags: + - -tags=prod + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=x86_64-linux-gnu-gcc + - CXX=x86_64-linux-gnu-g++ + - id: apimetrics-linux-arm64 + goos: + - linux + goarch: + - arm64 + goamd64: + - v1 + go386: + - sse2 + goarm: + - "6" + goarm64: + - v8.0 + gomips: + - hardfloat + goppc64: + - power8 + goriscv64: + - rva20u64 + targets: + - linux_arm64_v8.0 + dir: . + main: . + binary: apimetrics + builder: go + tool: go + command: build + flags: + - -tags=prod + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=aarch64-linux-gnu-gcc + - CXX=aarch64-linux-gnu-g++ + - id: apimetrics-windows-amd64 + goos: + - windows + goarch: + - amd64 + goamd64: + - v1 + go386: + - sse2 + goarm: + - "6" + goarm64: + - v8.0 + gomips: + - hardfloat + goppc64: + - power8 + goriscv64: + - rva20u64 + targets: + - windows_amd64_v1 + dir: . + main: . + binary: apimetrics + builder: go + tool: go + command: build + flags: + - -tags=prod + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=x86_64-w64-mingw32-gcc + - CXX=x86_64-w64-mingw32-g++ +archives: + - id: default + builds_info: + mode: 493 + name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}' + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + files: + - src: license* + - src: LICENSE* + - src: readme* + - src: README* + - src: changelog* + - src: CHANGELOG* +snapshot: + version_template: '{{ .Tag }}-next' +checksum: + name_template: checksums.txt + algorithm: sha256 +docker_digest: + name_template: digests.txt +changelog: + filters: + exclude: + - '^docs:' + - '^test:' + sort: asc + format: '{{ .SHA }} {{ .Message }}' +dist: dist +env_files: + github_token: ~/.config/goreleaser/github_token + gitlab_token: ~/.config/goreleaser/gitlab_token + gitea_token: ~/.config/goreleaser/gitea_token +before: + hooks: + - go mod download + - go test -v ./... +source: + name_template: '{{ .ProjectName }}-{{ .Version }}' + format: tar.gz +gomod: + gobinary: go +announce: + twitter: + message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}' + mastodon: + message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}' + server: "" + reddit: + title_template: '{{ .ProjectName }} {{ .Tag }} is out!' + url_template: '{{ .ReleaseURL }}' + slack: + message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}' + username: GoReleaser + discord: + message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}' + author: GoReleaser + color: "3888754" + icon_url: https://goreleaser.com/static/avatar.png + teams: + title_template: '{{ .ProjectName }} {{ .Tag }} is out!' + message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}' + color: '#2D313E' + icon_url: https://goreleaser.com/static/avatar.png + smtp: + subject_template: '{{ .ProjectName }} {{ .Tag }} is out!' + body_template: 'You can view details from: {{ .ReleaseURL }}' + mattermost: + message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}' + title_template: '{{ .ProjectName }} {{ .Tag }} is out!' + username: GoReleaser + linkedin: + message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}' + telegram: + message_template: '{{ mdv2escape .ProjectName }} {{ mdv2escape .Tag }} is out{{ mdv2escape "!" }} Check it out at {{ mdv2escape .ReleaseURL }}' + parse_mode: MarkdownV2 + webhook: + message_template: '{ "message": "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"}' + content_type: application/json; charset=utf-8 + expected_status_codes: + - 200 + - 201 + - 202 + - 204 + opencollective: + title_template: '{{ .Tag }}' + message_template: '{{ .ProjectName }} {{ .Tag }} is out!
Check it out at {{ .ReleaseURL }}' + bluesky: + message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}' +git: + tag_sort: -version:refname +github_urls: + download: https://github.com +gitlab_urls: + download: https://gitlab.com diff --git a/README.md b/README.md index d8995b9..a7d1db0 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,267 @@ -![Restish Logo](https://user-images.githubusercontent.com/106826/82109918-ec5b2300-96ee-11ea-9af0-8515329d5965.png) - -[![Works With Restish](https://img.shields.io/badge/Works%20With-Restish-ff5f87)](https://rest.sh/) [![User Guide](https://img.shields.io/badge/Docs-Guide-5fafd7)](https://rest.sh/#/guide) [![CI](https://github.com/rest-sh/restish/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/rest-sh/restish/actions/workflows/ci.yaml) [![codecov](https://codecov.io/gh/rest-sh/restish/branch/main/graph/badge.svg)](https://codecov.io/gh/rest-sh/restish) [![Docs](https://img.shields.io/badge/godoc-reference-5fafd7)](https://pkg.go.dev/github.com/rest-sh/restish?tab=subdirectories) [![Go Report Card](https://goreportcard.com/badge/github.com/rest-sh/restish)](https://goreportcard.com/report/github.com/rest-sh/restish) - -[Restish](https://rest.sh/) is a CLI for interacting with [REST](https://apisyouwonthate.com/blog/rest-and-hypermedia-in-2019)-ish HTTP APIs with some nice features built-in — like always having the latest API resources, fields, and operations available when they go live on the API without needing to install or update anything. -Check out [how Restish compares to cURL & HTTPie](https://rest.sh/#/comparison). - -See the [user guide](https://rest.sh/#/guide) for how to install Restish and get started. - -Features include: - -- HTTP/2 ([RFC 7540](https://tools.ietf.org/html/rfc7540)) with TLS by _default_ with fallback to HTTP/1.1 -- Generic head/get/post/put/patch/delete verbs like `curl` or [HTTPie](https://httpie.org/) -- Generated commands for CLI operations, e.g. `restish my-api list-users` - - Automatically discovers API descriptions - - [RFC 8631](https://tools.ietf.org/html/rfc8631) `service-desc` link relation - - [RFC 5988](https://tools.ietf.org/html/rfc5988#section-6.2.2) `describedby` link relation - - Supported formats - - OpenAPI [3.0](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md) / [3.1](https://spec.openapis.org/oas/v3.1.0.html) and [JSON Schema](https://json-schema.org/) - - Automatic configuration of API auth if advertised by the API - - Shell command completion for Bash, Fish, Zsh, Powershell -- Automatic pagination of resource collections via [RFC 5988](https://tools.ietf.org/html/rfc5988) `prev` and `next` hypermedia links -- API endpoint-based auth built-in with support for profiles: - - HTTP Basic - - API key via header or query param - - OAuth2 client credentials flow (machine-to-machine, [RFC 6749](https://tools.ietf.org/html/rfc6749)) - - OAuth2 authorization code (with PKCE [RFC 7636](https://tools.ietf.org/html/rfc7636)) flow - - On the fly authorization through external tools for custom API signature mechanisms -- Content negotiation, decoding & unmarshalling built-in: - - JSON ([RFC 8259](https://tools.ietf.org/html/rfc8259), ) - - YAML () - - CBOR ([RFC 7049](https://tools.ietf.org/html/rfc7049), ) - - MessagePack () - - Amazon Ion () - - Gzip ([RFC 1952](https://tools.ietf.org/html/rfc1952)), Deflate ([RFC 1951](https://datatracker.ietf.org/doc/html/rfc1951)), and Brotli ([RFC 7932](https://tools.ietf.org/html/rfc7932)) content encoding -- Automatic retries with support for [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) and `X-Retry-In` headers when APIs are rate-limited. -- Standardized [hypermedia](https://smartbear.com/learn/api-design/what-is-hypermedia/) parsing into queryable/followable response links: - - HTTP Link relation headers ([RFC 5988](https://tools.ietf.org/html/rfc5988#section-6.2.2)) - - [HAL](http://stateless.co/hal_specification.html) - - [Siren](https://github.com/kevinswiber/siren) - - [Terrifically Simple JSON](https://github.com/mpnally/Terrifically-Simple-JSON) - - [JSON:API](https://jsonapi.org/) -- Local caching that respects [RFC 7234](https://tools.ietf.org/html/rfc7234) `Cache-Control` and `Expires` headers -- CLI [shorthand](https://github.com/danielgtaylor/openapi-cli-generator/tree/master/shorthand#cli-shorthand-syntax) for structured data input (e.g. for JSON) -- [Shorthand query](https://github.com/danielgtaylor/shorthand#querying) response filtering & projection -- Colorized prettified readable output -- Fast native zero-dependency binary - -Articles: - -- [A CLI for REST APIs](https://dev.to/danielgtaylor/a-cli-for-rest-apis-part-1-104b) -- [Mapping OpenAPI to the CLI](https://dev.to/danielgtaylor/mapping-openapi-to-the-cli-37pb) - -This project started life as a fork of [OpenAPI CLI Generator](https://github.com/danielgtaylor/openapi-cli-generator). +# APImetrics CLI + +A command-line interface for managing API monitors, schedules, SLOs, and more on the [APImetrics](https://apimetrics.io) platform, by [APIContext Inc](https://apicontext.com). + +## Documentation + +For more information, please visit our main documentation site, available at [https://docs.apicontext.com/cli](https://docs.apicontext.com/cli). + + +## Installation + +## Manual installation + +The binaries for the APImetrics CLI can be downloaded from the [releases page](https://github.com/APImetrics/APImetrics-cli/releases). Choose the appropriate version for your operating system and put the executable in your path for easy access from the command line. + +## MacOS + +You can install the APImetrics CLI using Homebrew: + +```bash +brew install apimetrics/tap/apimetrics +``` + +## Windows + +You can install the APImetrics CLI using the Windows Package Manager (winget): + +```powershell +winget install APIContext.APImetricsCLI +``` + +## Linux + +You can install the APImetrics CLI by downloading the binary from the [releases page](https://github.com/APImetrics/APImetrics-cli/releases). + +```bash +curl -LO https://github.com/APImetrics/APImetrics-cli/releases/latest/download/apimetrics-[version]-linux-amd64.tar.gz +tar -xzf apimetrics-[version]-linux-amd64.tar.gz +sudo mv apimetrics /usr/local/bin/apimetrics +``` +Note: [version] should be replaced with the specific version you want to download, e.g., `0.0.1`. + +## Getting Started + +### 1. Log in + +```bash +apimetrics login +``` + +This opens your browser for OAuth2 authentication. Your credentials are cached locally and refreshed automatically. + +### 2. Select a project + +All commands require an active project. Run the following to choose one: + +```bash +apimetrics project select +``` + +To confirm which project is currently active: + +```bash +apimetrics project show +``` + +### 3. Run your first command + +```bash +# List all API calls in the current project +apimetrics list-calls + +# List all schedules +apimetrics list-schedules +``` + +To see all supported commands, run +```bash +apimetrics --help +``` + +## Passing Input + +All create and update commands read a JSON body from **stdin** using a heredoc. There is no `--body`, `--data`, or `-d` flag. + +```bash +apimetrics create-call <<'EOF' +{ + "meta": { + "name": "My API Check" + }, + "request": { + "method": "GET", + "url": "https://api.example.com/health" + } +} +EOF +``` + +You can also use **CLI Shorthand** as a concise alternative to JSON: + +```bash +apimetrics create-call meta.name: "My API Check", request.method: GET, request.url: https://api.example.com/health +``` + +Or pipe from a file: + +```bash +apimetrics create-call < my-call.json +``` + +## Output Formats + +Use `-o` / `--rsh-output-format` to control output: + +| Format | Description | +|---|---| +| `auto` (default) | Pretty in terminal, JSON when piped | +| `json` | Standard JSON | +| `yaml` | YAML | +| `table` | Tabular layout (best for lists) | +| `readable` | Colorized human-readable format | +| `gron` | Grep-friendly flattened format | + +```bash +# Table output +apimetrics list-calls -o table + +# Raw JSON for scripting +apimetrics list-calls -o json + +# Filter results with a query expression +apimetrics list-calls -f body[0].meta.name +``` + +When output is redirected to a pipe or file, color is disabled and only the body is printed as JSON automatically. + +## Global Flags + +These flags work with every command: + +| Flag | Short | Description | +|---|---|---| +| `--rsh-output-format` | `-o` | Output format (auto/json/yaml/table/readable/gron) | +| `--rsh-filter` | `-f` | Filter/project response using a query expression | +| `--rsh-raw` | `-r` | Raw output (strips quotes from strings) | +| `--rsh-verbose` | `-v` | Verbose logging | +| `--rsh-header` | `-H` | Add a request header (repeatable) | +| `--rsh-query` | `-q` | Add a query parameter (repeatable) | +| `--rsh-profile` | `-p` | Use a named auth profile | +| `--rsh-server` | `-s` | Override the API server base URL | +| `--rsh-no-cache` | | Disable HTTP caching | +| `--rsh-no-paginate` | | Disable automatic pagination | +| `--rsh-retry` | | Retry count (default 2) | +| `--rsh-timeout` | `-t` | HTTP request timeout | +| `--rsh-insecure` | | Disable TLS verification | + +## Shell Completion + +Enable tab completion for your shell: + +```bash +# Bash +apimetrics completion bash >> ~/.bash_profile + +# Zsh +apimetrics completion zsh >> ~/.zshrc + +# Fish +apimetrics completion fish > ~/.config/fish/completions/apimetrics.fish + +# PowerShell +apimetrics completion powershell >> $PROFILE +``` + +Once enabled, press `tab` to explore available commands and their arguments. + +## AI Agent Integration + +The CLI includes built-in skills for AI coding agents (e.g. Claude Code). These skills teach agents how to create and configure monitors using the CLI. + +**Install skills into Claude Code:** + +```bash +apimetrics skills install --claude-code +``` + +**Print all skills to stdout** (for any agent or model that can read context): + +```bash +apimetrics onboard +``` + +Available skills: +- `setup-api-monitor` — Create an API (HTTP) monitor, attach a schedule, and verify it +- `setup-browser-monitor` — Create a browser monitor, attach a schedule, and verify it +- `setup-mcp-monitor` — Create an MCP protocol monitor with session steps + +## Configuration + +Configuration and cached tokens are stored in platform-specific locations: + +| OS | Config | Cache | +|---|---|---| +| macOS | `~/Library/Application Support/apimetrics/` | `~/Library/Caches/apimetrics/` | +| Linux | `~/.config/apimetrics/` | `~/.cache/apimetrics/` | +| Windows | `%AppData%\apimetrics\` | `%LocalAppData%\apimetrics\` | + +Override these locations with environment variables: + +```bash +export APIMETRICS_CONFIG_DIR=/path/to/config +export APIMETRICS_CACHE_DIR=/path/to/cache +``` + +### Non-production builds + +Builds for the non-production APImetrics environments install alongside the +production CLI: each has its own command name and its own config and cache +directories, so they can be logged in to different environments at the same +time. + +| Environment | Command | API host | Config / cache directory | +|---|---|---|---| +| production | `apimetrics` | `client.apimetrics.io` | `apimetrics` | +| beta | `apimetrics-beta` | `beta-client.apimetrics.io` | `apimetrics-beta` | +| qc | `apimetrics-qc` | `qc-client.apimetrics.io` | `apimetrics-qc` | +| qc-stable | `apimetrics-qc-stable` | `qc-stable.apimetrics.io` | `apimetrics-qc-stable` | +| dev | `apimetrics-dev` | `localhost:8080` | `apimetrics-dev` | + +The directory-override environment variables follow the command name, with +hyphens replaced by underscores — e.g. `APIMETRICS_QC_STABLE_CONFIG_DIR`. + +Tagged environment builds are published on the +[releases page](https://github.com/APImetrics/APImetrics-cli/releases) as +prereleases, and are signed and notarized just like the production build, so +they run on macOS without a Gatekeeper warning. Homebrew and WinGet always +install the production CLI. + +`--version` reports which environment a binary was built against: + +```console +$ apimetrics-qc-stable --version +apimetrics-qc-stable version 0.1.0 +Environment: qc-stable (https://qc-stable.apimetrics.io) +Config directory: /Users/you/Library/Application Support/apimetrics-qc-stable +... +``` + +Note that the beta build authenticates against **production** Auth0, so logging +in to it uses your real production credentials. + +## Logout + +```bash +apimetrics logout +``` + +This removes cached tokens. Run `apimetrics login` again to re-authenticate. + +# Issues and Contributing + +For support questions, visit [APImetrics Support](https://apicontext.com/support) and open a request. + +For reporting bugs or requesting features, please open an issue on the [GitHub repository](https://github.com/APImetrics/APImetrics-cli/issues). + +We welcome contributions! Feel free to fork the repository, make changes, and submit pull requests. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..f9412c6 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,245 @@ +# Release Process + +This document covers the one-time infrastructure setup required to run the release workflow, and how to cut a release once everything is configured. + +## How releases work + +Pushing a `v0.x.y` tag triggers the release workflow (`.github/workflows/release.yml`), which: + +1. **Linux job** — cross-compiles all platform binaries inside `goreleaser-cross` (Docker), builds archives, and creates a GitHub draft release with the Linux and Windows assets. +2. **macOS job** — signs and notarizes the Darwin binaries using an Apple Developer certificate, re-archives them, replaces the macOS assets on the draft release, publishes the release, then updates the Homebrew tap formula. + +All binaries are distributed as **GitHub release assets** — Homebrew and WinGet download directly from `https://github.com/APImetrics/APImetrics-cli/releases/download//`. + +Snapshot builds (no publish) can be triggered via `workflow_dispatch` with `snapshot: true`. + +## Build environments + +Which APImetrics environment a binary talks to is fixed at build time by a Go +build tag, with one `.goreleaser` config per environment. Each environment also +gets its own binary name and its own config/cache directory, so the builds can +be installed side by side and hold independent logins (see the *Configuration* +section of `README.md`). + +| Environment | Build tag | GoReleaser config | Binary | +|---|---|---|---| +| production | `prod` | `.goreleaser/config.yaml` | `apimetrics` | +| beta | `beta` | `.goreleaser/config-beta.yaml` | `apimetrics-beta` | +| qc | *(none — default)* | `.goreleaser/config-qc.yaml` | `apimetrics-qc` | +| qc-stable | `qcstable` | `.goreleaser/config-qc-stable.yaml` | `apimetrics-qc-stable` | +| dev | `dev` | *(local builds only)* | `apimetrics-dev` | + +To add another environment: add a `config_.go` guarded by a new build tag +(and exclude that tag from `config_qc.go`, which is the default), copy a +`.goreleaser` config, then register the environment in both workflows — the +choice list in `develop.yml`, and the `case` statements in the *Resolve target +environment* step of `release.yml`. + +### Which workflow builds what + +| | Trigger | Signed | Notarized | Published | +|---|---|---|---|---| +| **Develop Build** | push to `develop`, or manual dispatch with an environment | macOS only | no | Actions artifacts | +| **Release** | a `v0.*` tag, or manual dispatch | macOS only | **yes** | GitHub release assets | + +Day-to-day merges to `develop` produce quick unsigned/unnotarized artifacts for +internal use. When you want a build that testers can download and run without +fighting Gatekeeper, **tag it** — every tagged build goes through the full +sign + notarize pipeline, whatever environment it targets. + +### Tagging an environment release + +The environment is selected by the semver prerelease suffix on the tag, so +GoReleaser still sees an ordinary semver tag: + +| Tag | Environment | GitHub release | +|---|---|---| +| `v0.1.0` | production | normal release, Homebrew + WinGet updated | +| `v0.1.0-beta` / `v0.1.0-beta-2` | beta | prerelease | +| `v0.1.0-qc` / `v0.1.0-qc-3` | qc | prerelease | +| `v0.1.0-qc-stable` / `v0.1.0-qc-stable-2` | qc-stable | prerelease | +| `v0.1.0-rc-1` | production | normal release (unrecognized suffixes fall back to production) | + +Add a numeric counter when you need to re-cut a build for the same base +version. Both the `-2` style used by this repo's existing tags and a `.2` +semver-dotted counter are recognized. + +```bash +git tag v0.3.0-qc-stable-1 +git push origin v0.3.0-qc-stable-1 +``` + +> **Note:** `-beta-N` now selects the **beta environment**. The historical +> `v0.0.1-beta-1..3` tags predate this and were production builds; if you want a +> pre-1.0 production milestone rather than a beta-environment build, use a +> suffix that isn't an environment name (e.g. `-rc-1`). + +Differences for non-production tags: + +- The **main branch check is skipped** — environment builds are expected to be + cut from `develop` or a feature branch. +- The GitHub release is marked as a **prerelease** and titled + ` ()`, so it never appears as "Latest release" to someone + installing the production CLI. +- **Homebrew and WinGet are not updated.** Those always point at production; + environment builds are downloaded from the release assets directly. +- Archive filenames drop the prerelease suffix, since the environment is + already in the project name — `v0.3.0-qc-stable-1` produces + `apimetrics-qc-stable-0.3.0-darwin-arm64.tar.gz`. The full tag is still baked + into the binary, so `apimetrics-qc-stable --version` reports + `0.3.0-qc-stable-1`. Snapshot builds keep their short-commit filenames. + +Everything else is identical to a production release: the same Developer ID +certificate, hardened runtime, notarization via `notarytool`, and the draft +release is only published once signing succeeds. + +--- + +## One-time infrastructure setup + +> Binaries are distributed as GitHub release assets, so **the repository must be +> public** (or the download URLs used by Homebrew/WinGet must otherwise be +> reachable by end users). No cloud storage or service-account setup is required. + +### 1. GitHub App — Homebrew tap access + +The workflow uses a GitHub App to push formula updates to `APImetrics/homebrew-tap`. This avoids a long-lived personal access token. + +1. Go to **GitHub → APImetrics org settings → Developer settings → GitHub Apps → New GitHub App**. +2. Name it something like `apimetrics-release-bot`. +3. Under **Permissions → Repository permissions**, set **Contents** to `Read & write`. No other permissions needed. +4. Uncheck **Webhooks** (not needed). +5. Set **Where can this GitHub App be installed?** to `Only on this account`. +6. Create the app and note the **App ID**. +7. Under **Private keys**, generate and download a private key (`.pem` file). +8. Go to **Install App** and install it on `APImetrics/homebrew-tap` only. + +**Secrets to add to `APImetrics/APImetrics-cli`:** + +| Secret | Value | +|--------|-------| +| `GH_APP_ID` | Numeric App ID shown on the app settings page | +| `GH_APP_PRIVATE_KEY` | Full contents of the downloaded `.pem` file, including the `-----BEGIN RSA PRIVATE KEY-----` and `-----END RSA PRIVATE KEY-----` header/footer lines | + +--- + +### 2. Apple code signing and notarization + +macOS binaries must be signed and notarized with an Apple Developer ID certificate so they run without Gatekeeper warnings. + +**Prerequisites:** +- An [Apple Developer Program](https://developer.apple.com/programs/) membership. +- A **Developer ID Application** certificate. Export it as a `.p12` file from Keychain Access (include the private key, set a strong export password). +- An [app-specific password](https://support.apple.com/en-us/102654) for the Apple ID used to notarize. + +**Encode the certificate:** + +```bash +base64 -i certificate.p12 | pbcopy # copies base64 to clipboard +``` + +**Secrets to add to `APImetrics/APImetrics-cli`:** + +| Secret | Value | +|--------|-------| +| `APPLE_CERT_P12` | Base64-encoded `.p12` certificate (see above) | +| `APPLE_CERT_PASSWORD` | Password set when exporting the `.p12` | +| `KEYCHAIN_PASSWORD` | Any strong random string (used for the temporary CI keychain) | +| `APPLE_SIGNING_IDENTITY` | Certificate common name, e.g. `Developer ID Application: APImetrics Inc (XXXXXXXXXX)` | +| `APPLE_ID` | Apple ID email used for notarization | +| `APPLE_ID_PASSWORD` | App-specific password for that Apple ID | +| `APPLE_TEAM_ID` | 10-character Apple Developer Team ID | + +--- + +### 3. Homebrew tap repository + +The Homebrew formula is pushed to `APImetrics/homebrew-tap`. Ensure: + +- The repository exists. +- A `Formula/` directory exists in the repository root (create it with a `.gitkeep` if needed — the release workflow will create `Formula/apimetrics.rb` on first release). +- The GitHub App from step 1 is installed on this repository. + +--- + +### 4. WinGet package + +The workflow uses `wingetcreate` to submit a PR to [`microsoft/winget-pkgs`](https://github.com/microsoft/winget-pkgs) after each release, keeping the Windows Package Manager entry up to date. + +#### Initial package submission (first release only) + +The package must exist in `microsoft/winget-pkgs` before automated updates can run. Create the initial manifest with `wingetcreate`: + +```powershell +# Download wingetcreate.exe (Windows only — run this on a Windows machine) +Invoke-WebRequest -Uri "https://github.com/microsoft/winget-create/releases/download/v1.12.8.0/wingetcreate.exe" -OutFile wingetcreate.exe + +$version = "0.1.0" # set to the first published version +$installerUrl = "https://github.com/APImetrics/APImetrics-cli/releases/download/v$version/apimetrics-$version-windows-amd64.zip" + +.\wingetcreate.exe new $installerUrl ` + --id APIContext.APImetricsCLI ` + --version $version ` + --name "APImetrics CLI" ` + --publisher "APIContext" ` + --token +``` + +Review the generated manifest files, then submit the PR manually. Once merged, subsequent releases are automated. + +#### GitHub PAT for automated updates + +The `wingetcreate update --submit` command creates a PR on `microsoft/winget-pkgs` using a GitHub account you control. + +- **Classic PAT (recommended):** `public_repo` scope is sufficient. +- **Fine-grained PAT:** grant access to your fork of `winget-pkgs` and enable **Contents: read/write** and **Pull requests: read/write**. Note that `wingetcreate` creates the fork on first run — the fork must exist before you can pre-select it when generating the token. + +**Secret to add to `APImetrics/APImetrics-cli`:** + +| Secret | Value | +|--------|-------| +| `WINGET_GITHUB_TOKEN` | Token used by `wingetcreate` to push to your fork and open PRs on `microsoft/winget-pkgs` | + +--- + +## Complete secrets reference + +All secrets required on `APImetrics/APImetrics-cli`: + +| Secret | Purpose | +|--------|---------| +| `GH_APP_ID` | GitHub App ID for Homebrew tap access | +| `GH_APP_PRIVATE_KEY` | GitHub App private key for Homebrew tap access | +| `APPLE_CERT_P12` | Base64 Apple Developer ID certificate | +| `APPLE_CERT_PASSWORD` | Password for the p12 certificate | +| `KEYCHAIN_PASSWORD` | Temporary CI keychain password | +| `APPLE_SIGNING_IDENTITY` | Apple signing identity string | +| `APPLE_ID` | Apple ID for notarization | +| `APPLE_ID_PASSWORD` | App-specific password for notarization | +| `APPLE_TEAM_ID` | Apple Developer Team ID | +| `WINGET_GITHUB_TOKEN` | PAT for submitting WinGet PRs to `microsoft/winget-pkgs` | + +--- + +## Cutting a release + +Once all secrets are configured: + +```bash +git tag v0.x.y +git push origin v0.x.y +``` + +Monitor progress in the [Actions tab](https://github.com/APImetrics/APImetrics-cli/actions). The full pipeline (Linux build + macOS sign/notarize) takes approximately 20–30 minutes. + +### Snapshot builds (no publish) + +To build all artifacts without publishing anywhere: + +1. Go to **Actions → Release → Run workflow**. +2. Check **Run goreleaser in --snapshot mode**. +3. Run from any branch. + +### Tag pattern note + +The workflow only triggers on `v0.*` tags. This is intentional during initial development to prevent accidental production releases. Update the tag pattern in `.github/workflows/release.yml` to `v*` when ready to ship v1.0. diff --git a/bench_test.go b/bench_test.go index a4ed1a0..2bf729a 100644 --- a/bench_test.go +++ b/bench_test.go @@ -6,10 +6,10 @@ import ( "net/http" "testing" - "github.com/amzn/ion-go/ion" - "github.com/fxamacker/cbor/v2" "apicontext.com/apimetrics/cli" "apicontext.com/apimetrics/openapi" + "github.com/amzn/ion-go/ion" + "github.com/fxamacker/cbor/v2" "github.com/shamaton/msgpack/v2" "github.com/spf13/cobra" ) diff --git a/bulk/commands_test.go b/bulk/commands_test.go index 1d96f0f..3690921 100644 --- a/bulk/commands_test.go +++ b/bulk/commands_test.go @@ -127,6 +127,7 @@ func TestWorkflow(t *testing.T) { cli.Init("test", "1.0.0") cli.Defaults() Init(cli.Root) + require.NoError(t, cli.SaveState(cli.State{ProjectID: "test-project"})) // Init // ==== @@ -377,6 +378,7 @@ func TestPullFailure(t *testing.T) { cli.Init("test", "1.0.0") cli.Defaults() Init(cli.Root) + require.NoError(t, cli.SaveState(cli.State{ProjectID: "test-project"})) // Init // ==== @@ -423,6 +425,7 @@ func TestPushFailure(t *testing.T) { cli.Init("test", "1.0.0") cli.Defaults() Init(cli.Root) + require.NoError(t, cli.SaveState(cli.State{ProjectID: "test-project"})) // Init // ==== diff --git a/cli/api.go b/cli/api.go index 2c574d9..49d4328 100644 --- a/cli/api.go +++ b/cli/api.go @@ -28,6 +28,7 @@ var LoadedAPI API // a Loader and cached by the CLI in-between runs when possible. type API struct { CLIVersion string `json:"cli_version" yaml:"cli_version"` + SpecVersion string `json:"spec_version,omitempty" yaml:"spec_version,omitempty"` Short string `json:"short" yaml:"short"` Long string `json:"long,omitempty" yaml:"long,omitempty"` Operations []Operation `json:"operations,omitempty" yaml:"operations,omitempty"` @@ -46,6 +47,10 @@ func (a *API) Merge(other API) { a.Long = other.Long } + if a.SpecVersion == "" { + a.SpecVersion = other.SpecVersion + } + a.Operations = append(a.Operations, other.Operations...) } @@ -98,6 +103,7 @@ func cacheAPI(name string, api *API) { } Cache.Set(name+".expires", time.Now().Add(24*time.Hour)) + Cache.Set(name+".checked", time.Now()) Cache.WriteConfig() b, err := cbor.Marshal(api) @@ -110,6 +116,42 @@ func cacheAPI(name string, api *API) { } } +// versionExtraInfo returns the extra lines shown by `--version`: the +// environment this binary was built against, plus the cached +// OpenAPI document version and the date/time the spec was last refreshed +// (fetched from the server or loaded from configured spec_files). It reads the +// on-disk cache directly (rather than requiring a successful load) so it still +// works when the machine is offline. The cache is only trusted when it was written by the current CLI version, mirroring the +// validity check in Load, so `--version` never reports a spec that the CLI +// would otherwise refetch (e.g. after a binary upgrade). +func versionExtraInfo() string { + specVer := "unknown" + checked := "never" + + if name := viper.GetString("api-name"); name != "" { + filename := filepath.Join(getCacheDir(), name+".cbor") + if data, err := os.ReadFile(filename); err == nil { + var cached API + if err := cbor.Unmarshal(data, &cached); err == nil && cached.CLIVersion == Root.Version { + if cached.SpecVersion != "" { + specVer = cached.SpecVersion + } + if t := Cache.GetTime(name + ".checked"); !t.IsZero() { + checked = t.Local().Format("2006-01-02 15:04:05 MST") + } + } + } + } + + env := buildCfg.Environment + if env == "" { + env = "unknown" + } + + return fmt.Sprintf("Environment: %s (%s)\nConfig directory: %s\nAPI spec version: %s\nSpec last updated: %s", + env, buildCfg.BaseURL, viper.GetString("config-directory"), specVer, checked) +} + // Load will hydrate the command tree for an API, possibly refreshing the // API spec if the cache is out of date. func Load(entrypoint string, root *cobra.Command) (API, error) { @@ -211,7 +253,7 @@ func Load(entrypoint string, root *cobra.Command) (API, error) { // the parsed API cache, but do want to use a cached response from // the server. client := &http.Client{Transport: InvalidateCachedTransport()} - httpResp, err := MakeRequest(req, WithClient(client), IgnoreCLIParams()) + httpResp, err := MakeRequest(req, WithClient(client), IgnoreCLIParams(), IgnoreAuth()) if err != nil { return API{}, err } @@ -251,7 +293,7 @@ func Load(entrypoint string, root *cobra.Command) (API, error) { return API{}, err } - resp, err := MakeRequest(req, WithClient(client), IgnoreCLIParams()) + resp, err := MakeRequest(req, WithClient(client), IgnoreCLIParams(), IgnoreAuth()) if err != nil { return API{}, err } @@ -279,6 +321,7 @@ func Load(entrypoint string, root *cobra.Command) (API, error) { } api, err := load(root, *opsBase, *resolved, resp, name, l) if err == nil { + api.CLIVersion = root.Version cacheAPI(name, &api) } return api, err diff --git a/cli/api_test.go b/cli/api_test.go index b577762..fc9e064 100644 --- a/cli/api_test.go +++ b/cli/api_test.go @@ -3,8 +3,12 @@ package cli import ( "net/http" "net/url" + "os" + "path/filepath" "testing" + "time" + "github.com/fxamacker/cbor/v2" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/stretchr/testify/assert" @@ -74,3 +78,39 @@ func TestBadSpecURL(t *testing.T) { _, err := Load("https://api.example.com", &cobra.Command{}) assert.Error(t, err) } + +func TestVersionExtraInfo(t *testing.T) { + // Isolate the cache dir (getCacheDir uses _CACHE_DIR; app-name is + // "test" via reset -> Init). + os.Setenv("TEST_CACHE_DIR", t.TempDir()) + defer os.Unsetenv("TEST_CACHE_DIR") + + reset(false) + viper.Set("api-name", "vtest") + + writeCache := func(cliVersion, specVersion string) { + b, err := cbor.Marshal(&API{CLIVersion: cliVersion, SpecVersion: specVersion}) + assert.NoError(t, err) + assert.NoError(t, os.WriteFile(filepath.Join(getCacheDir(), "vtest.cbor"), b, 0o600)) + Cache.Set("vtest.checked", time.Now()) + assert.NoError(t, Cache.WriteConfig()) + } + + // No cache on disk yet -> unknown / never. + out := versionExtraInfo() + assert.Contains(t, out, "API spec version: unknown") + assert.Contains(t, out, "Spec last updated: never") + + // Cache written by the current CLI version -> reported. + writeCache(Root.Version, "9.9.9") + out = versionExtraInfo() + assert.Contains(t, out, "API spec version: 9.9.9") + assert.NotContains(t, out, "Spec last updated: never") + + // Cache written by a different CLI version (e.g. after a binary upgrade) is + // stale and must not be reported. + writeCache("some-older-version", "1.2.3") + out = versionExtraInfo() + assert.Contains(t, out, "API spec version: unknown") + assert.Contains(t, out, "Spec last updated: never") +} diff --git a/cli/apiconfig.go b/cli/apiconfig.go index 789a6af..3be7143 100644 --- a/cli/apiconfig.go +++ b/cli/apiconfig.go @@ -13,6 +13,7 @@ import ( // buildCfg holds the API endpoint and auth parameters baked in at build time. var buildCfg struct { + Environment string BaseURL string AuthURL string TokenURL string @@ -30,6 +31,13 @@ func SetBuildConfig(baseURL, authURL, tokenURL, authAudience, clientID string) { buildCfg.ClientID = clientID } +// SetEnvironment records which APImetrics environment this binary was built +// against (e.g. "production", "qc"). Shown by `--version` so parallel installs +// can be told apart. Must be called before cli.Init. +func SetEnvironment(env string) { + buildCfg.Environment = env +} + // APIAuth describes the auth type and parameters for an API. type APIAuth struct { Name string `json:"name" yaml:"name"` @@ -129,11 +137,11 @@ func findAPI(uri string) (string, *APIConfig) { if strings.HasPrefix(uri, config.Profiles[profile].Base) { return name, config } - } else if strings.HasPrefix(uri, config.Base) { + } else if config.Base != "" && strings.HasPrefix(uri, config.Base) { return name, config } } else { - if strings.HasPrefix(uri, config.Base) { + if config.Base != "" && strings.HasPrefix(uri, config.Base) { // TODO: find the longest matching base? return name, config } diff --git a/cli/apiconfig_test.go b/cli/apiconfig_test.go index e29ed03..ae4114c 100644 --- a/cli/apiconfig_test.go +++ b/cli/apiconfig_test.go @@ -7,70 +7,6 @@ import ( "github.com/stretchr/testify/assert" ) -func TestAPIContentTypes(t *testing.T) { - captured := run("api content-types") - assert.Contains(t, captured, "application/json") - assert.Contains(t, captured, "table") - assert.Contains(t, captured, "readable") -} - -func TestAPIShow(t *testing.T) { - for tn, tc := range map[string]struct { - color bool - want string - }{ - "no color": {false, "{\n \"base\": \"https://api.example.com\"\n}\n"}, - "color": {true, "\x1b[38;5;247m{\x1b[0m\n \x1b[38;5;74m\"base\"\x1b[0m\x1b[38;5;247m:\x1b[0m \x1b[38;5;150m\"https://api.example.com\"\x1b[0m\n\x1b[38;5;247m}\x1b[0m\n"}, - } { - t.Run(tn, func(t *testing.T) { - reset(tc.color) - configs["test"] = &APIConfig{ - name: "test", - Base: "https://api.example.com", - } - captured := runNoReset("api show test") - assert.Equal(t, captured, tc.want) - }) - } -} - -func TestAPIClearCache(t *testing.T) { - reset(false) - - configs["test"] = &APIConfig{ - name: "test", - Base: "https://api.example.com", - } - Cache.Set("test:default.token", "abc123") - - runNoReset("api clear-auth-cache test") - - assert.Equal(t, "", Cache.GetString("test:default.token")) -} - -func TestAPIClearCacheProfile(t *testing.T) { - reset(false) - - configs["test"] = &APIConfig{ - name: "test", - Base: "https://api.example.com", - } - Cache.Set("test:default.token", "abc123") - Cache.Set("test:other.token", "def456") - - runNoReset("api clear-auth-cache test -p other") - - assert.Equal(t, "abc123", Cache.GetString("test:default.token")) - assert.Equal(t, "", Cache.GetString("test:other.token")) -} - -func TestAPIClearCacheMissing(t *testing.T) { - reset(false) - - captured := runNoReset("api clear-auth-cache missing-api") - assert.Contains(t, captured, "API missing-api not found") -} - func TestEditAPIsMissingEditor(t *testing.T) { os.Setenv("EDITOR", "") os.Setenv("VISUAL", "") diff --git a/cli/cli.go b/cli/cli.go index 5c4860d..d83e5e1 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -83,6 +83,8 @@ func Init(name string, version string) { Formatter = NewDefaultFormatter(tty, useColor) + cobra.AddTemplateFunc("versionExtra", versionExtraInfo) + cobra.AddTemplateFunc("highlight", func(s string) string { // Highlighting is expensive, so only do this when the user actually asks // for help via this template func and a custom help template. @@ -121,6 +123,9 @@ func Init(name string, version string) { Root.SetHelpTemplate(`{{with (or .Long .Short)}}{{. | trimTrailingWhitespaces | highlight}} {{end}}{{if or .Runnable .HasSubCommands}}{{.UsageString}}{{end}}`) + Root.SetVersionTemplate(`{{with .Name}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}} +{{versionExtra}} +`) GlobalFlags = pflag.NewFlagSet("eager-flags", pflag.ContinueOnError) GlobalFlags.ParseErrorsWhitelist.UnknownFlags = true @@ -129,6 +134,10 @@ func Init(name string, version string) { // Ensure parsing doesn't stop if the help flag is set // (help seems to be special cased from ParseErrorsWhitelist.UnknownFlags) GlobalFlags.BoolP("help", "h", false, "") + // Mirror Cobra's auto-registered version flag here so we can detect it + // during the eager parse below, before the command tree is loaded. This + // handles every bool form (`--version`, `--version=true`, `--version=false`). + GlobalFlags.Bool("version", false, "") AddGlobalFlag("rsh-verbose", "v", "Enable verbose log output", false, false) AddGlobalFlag("rsh-output-format", "o", "Output format [auto, json, table, ...]", "auto", false) @@ -174,8 +183,15 @@ func userHomeDir() string { return home } +// envVarName turns an app name into an environment variable prefix, e.g. +// "apimetrics-qc" -> "APIMETRICS_QC". Hyphens can't appear in a portable shell +// variable name, so they become underscores. +func envVarName(appName, suffix string) string { + return strings.ToUpper(strings.ReplaceAll(appName, "-", "_")) + suffix +} + func getConfigDir(appName string) string { - configDirEnv := strings.ToUpper(appName) + "_CONFIG_DIR" + configDirEnv := envVarName(appName, "_CONFIG_DIR") configDir := os.Getenv(configDirEnv) if configDir == "" { @@ -217,7 +233,7 @@ func getConfigDir(appName string) string { func getCacheDir() string { appName := viper.GetString("app-name") - cacheDirEnv := strings.ToUpper(appName) + "_CACHE_DIR" + cacheDirEnv := envVarName(appName, "_CACHE_DIR") cacheDir := os.Getenv(cacheDirEnv) @@ -358,6 +374,13 @@ func Run() (returnErr error) { enableVerbose = true } + // `--version` is a reporting command used for support/debugging, so it + // must work even when the API is unreachable. Report the cached spec state + // from disk rather than triggering a network load (which would also prompt + // for auth). Cobra prints the version for `--version`/`--version=true`; the + // `-v` shorthand is taken by `rsh-verbose`. + wantVersion, _ := GlobalFlags.GetBool("version") + // Load all configured API operations directly onto the root command. for name, cfg := range configs { currentConfig = cfg @@ -366,6 +389,13 @@ func Run() (returnErr error) { if p := cfg.Profiles[profile]; p != nil && p.Base != "" { currentBase = p.Base } + if currentBase == "" { + continue + } + if wantVersion { + // Skip the network load; versionExtraInfo reads the on-disk cache. + break + } api, err := Load(currentBase, Root) if err != nil { panic(err) diff --git a/cli/cli_test.go b/cli/cli_test.go index 923ee0c..5a76015 100644 --- a/cli/cli_test.go +++ b/cli/cli_test.go @@ -1,16 +1,13 @@ package cli import ( - "net/http" "os" - "path/filepath" "strings" "testing" "time" "github.com/spf13/cobra" "github.com/spf13/viper" - "github.com/stretchr/testify/assert" "gopkg.in/h2non/gock.v1" ) @@ -30,16 +27,6 @@ func reset(color bool) { Defaults() } -func run(cmd string, color ...bool) string { - if len(color) == 0 || !color[0] { - reset(false) - } else { - reset(true) - } - - return runNoReset(cmd) -} - func runNoReset(cmd string) string { capture := &strings.Builder{} Stdout = capture @@ -51,166 +38,6 @@ func runNoReset(cmd string) string { return capture.String() } -func expectJSON(t *testing.T, cmd string, expected string) { - captured := run("-o json -f body " + cmd) - assert.JSONEq(t, expected, captured) -} - -func expectExitCode(t *testing.T, expected int) { - assert.Equal(t, expected, GetExitCode()) -} - -func TestGetURI(t *testing.T) { - defer gock.Off() - - gock.New("http://example.com").Get("/foo").Reply(200).JSON(map[string]any{ - "Hello": "World", - }) - - expectJSON(t, "http://example.com/foo", `{ - "Hello": "World" - }`) - expectExitCode(t, 0) -} - -func TestPostURI(t *testing.T) { - defer gock.Off() - - gock.New("http://example.com").Post("/foo").Reply(200).JSON(map[string]any{ - "id": 1, - "value": 123, - }) - - expectJSON(t, "post http://example.com/foo value: 123", `{ - "id": 1, - "value": 123 - }`) -} - -func TestPutURI400(t *testing.T) { - defer gock.Off() - - gock.New("http://example.com").Put("/foo/1").Reply(422).JSON(map[string]any{ - "detail": "Invalid input", - }) - - expectJSON(t, "put http://example.com/foo/1 value: 123", `{ - "detail": "Invalid input" - }`) - expectExitCode(t, 4) -} - -func TestIgnoreStatusCodeExit(t *testing.T) { - defer gock.Off() - - gock.New("http://example.com").Put("/foo/1").Reply(400).JSON(map[string]any{ - "detail": "Invalid input", - }) - - expectJSON(t, "put http://example.com/foo/1 value: 123 --rsh-ignore-status-code", `{ - "detail": "Invalid input" - }`) - expectExitCode(t, 0) -} - -func TestHeaderWithComma(t *testing.T) { - defer gock.Off() - - gock.New("http://example.com").Get("/").MatchHeader("Foo", "a,b,c").Reply(204) - - out := run("http://example.com/ -H Foo:a,b,c") - assert.Contains(t, out, "204 No Content") -} - -type TestAuth struct{} - -// Parameters returns a list of OAuth2 Authorization Code inputs. -func (h *TestAuth) Parameters() []AuthParam { - return []AuthParam{} -} - -// OnRequest gets run before the request goes out on the wire. -func (h *TestAuth) OnRequest(request *http.Request, key string, params map[string]string) error { - request.Header.Set("Authorization", "abc123") - return nil -} - -func TestAuthHeader(t *testing.T) { - reset(false) - - AddAuth("test-auth", &TestAuth{}) - - configs["test-auth-header"] = &APIConfig{ - name: "test-auth-header", - Base: "https://auth-header-test.example.com", - Profiles: map[string]*APIProfile{ - "default": { - Auth: &APIAuth{ - Name: "test-auth", - }, - }, - "no-auth": {}, - }, - } - - captured := runNoReset("auth-header bad-api") - assert.Contains(t, captured, "no matched API") - - captured = runNoReset("auth-header test-auth-header") - assert.Equal(t, "abc123\n", captured) - - captured = runNoReset("auth-header test-auth-header -p bad") - assert.Contains(t, captured, "invalid profile bad") - - captured = runNoReset("auth-header test-auth-header -p no-auth") - assert.Contains(t, captured, "no auth set up") -} - -func TestLinks(t *testing.T) { - defer gock.Off() - - gock.New("http://example.com").Get("/foo").Reply(204).SetHeader("Link", "; rel=\"item\"") - - captured := run("links http://example.com/foo") - assert.JSONEq(t, `{ - "item": [ - { - "rel": "item", - "uri": "http://example.com/bar" - } - ] - }`, captured) -} - -func TestDefaultOutput(t *testing.T) { - defer gock.Off() - - gock.New("http://example.com").Get("/foo").Reply(200).JSON(map[string]any{ - "hello": "world", - }) - - captured := run("http://example.com/foo", true) - assert.Equal(t, "\x1b[38;5;204mHTTP\x1b[0m/\x1b[38;5;172m1.1\x1b[0m \x1b[38;5;172m200\x1b[0m \x1b[38;5;74mOK\x1b[0m\n\x1b[38;5;74mContent-Type\x1b[0m: application/json\n\n\x1b[38;5;172m{\x1b[0m\n \x1b[38;5;74mhello\x1b[0m\x1b[38;5;247m:\x1b[0m \x1b[38;5;150m\"world\"\x1b[0m\x1b[38;5;247m\n\x1b[0m\x1b[38;5;172m}\x1b[0m\n", captured) -} - -func TestHelp(t *testing.T) { - captured := run("--help", false) - assert.Contains(t, captured, "api") - assert.Contains(t, captured, "get") - assert.Contains(t, captured, "put") - assert.Contains(t, captured, "delete") - assert.Contains(t, captured, "edit") -} - -func TestHelpHighlight(t *testing.T) { - captured := run("--help", true) - assert.Contains(t, captured, "api") - assert.Contains(t, captured, "get") - assert.Contains(t, captured, "put") - assert.Contains(t, captured, "delete") - assert.Contains(t, captured, "edit") -} - func TestLoadCache(t *testing.T) { // Invalidate any existin cache. Cache.Set("cache-test.expires", time.Now().Add(-24*time.Hour)) @@ -252,129 +79,3 @@ func TestLoadCache(t *testing.T) { runNoReset("cache-test --help") runNoReset("cache-test --help") } - -func TestAPISync(t *testing.T) { - defer gock.Off() - - gock.New("https://sync-test.example.com/").Reply(404) - gock.New("https://sync-test.example.com/openapi.json").Reply(404) - - reset(false) - - configs["sync-test"] = &APIConfig{ - name: "sync-test", - Base: "https://sync-test.example.com", - Profiles: map[string]*APIProfile{ - "default": {}, - }, - } - - runNoReset("api sync sync-test") -} - -func TestDuplicateAPIBase(t *testing.T) { - defer func() { - os.Remove(filepath.Join(getConfigDir("test"), "apis.json")) - reset(false) - }() - reset(false) - - configs["dupe1"] = &APIConfig{ - name: "dupe1", - Base: "https://dupe.example.com", - Profiles: map[string]*APIProfile{ - "default": {}, - }, - } - configs["dupe2"] = &APIConfig{ - name: "dupe2", - Base: "https://dupe.example.com", - Profiles: map[string]*APIProfile{ - "default": {}, - }, - } - - configs["dupe1"].Save() - configs["dupe2"].Save() - - assert.Panics(t, func() { - run("--help") - }) -} - -func TestCompletion(t *testing.T) { - defer gock.Off() - - gock.New("https://api.example.com/").Reply(http.StatusNotFound) - gock.New("https://api.example.com/openapi.json").Reply(http.StatusOK) - - Init("Completion test", "1.0.0") - Defaults() - - configs["comptest"] = &APIConfig{ - name: "comptest", - Base: "https://api.example.com", - } - - Root.AddCommand(&cobra.Command{ - Use: "comptest", - }) - - AddLoader(&testLoader{ - API: API{ - Operations: []Operation{ - { - Method: http.MethodGet, - URITemplate: "https://api.example.com/users", - }, - { - Method: http.MethodGet, - URITemplate: "https://api.example.com/users/{user-id}", - }, - { - Short: "List item tags", - Method: http.MethodGet, - URITemplate: "https://api.example.com/items/{item-id}/tags", - }, - { - Short: "Get tag details", - Method: http.MethodGet, - URITemplate: "https://api.example.com/items/{item-id}/tags/{tag-id}", - }, - { - Method: http.MethodDelete, - URITemplate: "https://api.example.com/items/{item-id}/tags/{tag-id}", - }, - }, - }, - }) - - // Force a cache-reload if needed. - viper.Set("rsh-no-cache", true) - Load("https://api.example.com/", &cobra.Command{}) - viper.Set("rsh-no-cache", false) - - currentConfig = nil - - // Show APIs - possible, _ := completeGenericCmd(http.MethodGet, true)(nil, []string{}, "") - assert.Equal(t, []string{ - "comptest", - }, possible) - - currentConfig = configs["comptest"] - - // Short-name URL completion with variables filled in. - possible, _ = completeGenericCmd(http.MethodGet, false)(nil, []string{}, "comptest/items/my-item") - assert.Equal(t, []string{ - "comptest/items/my-item/tags\tList item tags", - "comptest/items/my-item/tags/{tag-id}\tGet tag details", - }, possible) - - // URL without scheme - possible, _ = completeGenericCmd(http.MethodGet, false)(nil, []string{}, "api.example.com/items/my-item") - assert.Equal(t, []string{ - "api.example.com/items/my-item/tags\tList item tags", - "api.example.com/items/my-item/tags/{tag-id}\tGet tag details", - }, possible) -} diff --git a/cli/config.go b/cli/config.go index 29dcbc3..73e159c 100644 --- a/cli/config.go +++ b/cli/config.go @@ -92,7 +92,7 @@ func projectShowCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { state := loadState() if state.ProjectID == "" { - fmt.Fprintln(Stdout, "No active project set. Run `apimetrics project select` to choose one.") + fmt.Fprintf(Stdout, "No active project set. Run `%s project select` to choose one.\n", Root.Name()) return nil } if showID { diff --git a/cli/interactive_test.go b/cli/interactive_test.go index fcf378d..dd683cc 100644 --- a/cli/interactive_test.go +++ b/cli/interactive_test.go @@ -40,6 +40,7 @@ func TestInteractive(t *testing.T) { os.Remove(filepath.Join(getConfigDir("test"), "cache.json")) reset(false) + AddAuth("http-basic", &BasicAuth{}) defer gock.Off() diff --git a/cli/request.go b/cli/request.go index f26bcc8..feed371 100644 --- a/cli/request.go +++ b/cli/request.go @@ -79,6 +79,7 @@ type requestConfig struct { disableLog bool ignoreStatus bool ignoreCLIParams bool + ignoreAuth bool } type requestOption func(*requestConfig) @@ -111,6 +112,15 @@ func IgnoreCLIParams() requestOption { } } +// IgnoreAuth skips applying the profile's auth to the request. Used when +// fetching the (public) API description so that loading the command tree +// never triggers an interactive login. +func IgnoreAuth() requestOption { + return func(conf *requestConfig) { + conf.ignoreAuth = true + } +} + // MakeRequest makes an HTTP request using the default client. It adds the // user-agent, auth, and any passed headers or query params to the request // before sending it out on the wire. If verbose mode is enabled, it will @@ -236,7 +246,7 @@ func MakeRequest(req *http.Request, options ...requestOption) (*http.Response, e } // Add auth if needed. - if profile.Auth != nil && profile.Auth.Name != "" { + if !requestConf.ignoreAuth && profile.Auth != nil && profile.Auth.Name != "" { auth, ok := authHandlers[profile.Auth.Name] if ok { err := auth.OnRequest(req, name+":"+viper.GetString("rsh-profile"), profile.Auth.Params) diff --git a/cli/request_test.go b/cli/request_test.go index 7aed307..aaabdeb 100644 --- a/cli/request_test.go +++ b/cli/request_test.go @@ -72,6 +72,7 @@ func (a *authHookFailure) OnRequest(req *http.Request, key string, params map[st func TestAuthHookFailure(t *testing.T) { configs["auth-hook-fail"] = &APIConfig{ + Base: "http://auth-hook-fail.example.com", Profiles: map[string]*APIProfile{ "default": { Auth: &APIAuth{ @@ -83,12 +84,42 @@ func TestAuthHookFailure(t *testing.T) { authHandlers["hook-fail"] = &authHookFailure{} - r, _ := http.NewRequest(http.MethodGet, "/test", nil) + r, _ := http.NewRequest(http.MethodGet, "http://auth-hook-fail.example.com/test", nil) assert.PanicsWithError(t, "some-error", func() { MakeRequest(r) }) } +func TestIgnoreAuth(t *testing.T) { + defer gock.Off() + gock.New("http://ignore-auth.example.com"). + Get("/test"). + Reply(200). + JSON(map[string]any{"ok": true}) + + viper.Set("rsh-profile", "default") + configs["ignore-auth"] = &APIConfig{ + Base: "http://ignore-auth.example.com", + Profiles: map[string]*APIProfile{ + "default": { + Auth: &APIAuth{ + Name: "hook-fail", + }, + }, + }, + } + authHandlers["hook-fail"] = &authHookFailure{} + + r, _ := http.NewRequest(http.MethodGet, "http://ignore-auth.example.com/test", nil) + // The auth hook would panic (see TestAuthHookFailure); IgnoreAuth must skip + // it so loading the (public) API description never triggers a login. + assert.NotPanics(t, func() { + resp, err := MakeRequest(r, IgnoreAuth()) + assert.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + }) +} + func TestGetStatus(t *testing.T) { defer gock.Off() @@ -170,7 +201,7 @@ func TestRequestRetryAfter(t *testing.T) { Put("/"). Times(1). Reply(http.StatusTooManyRequests). - SetHeader("Retry-After", time.Now().Format(http.TimeFormat)) + SetHeader("Retry-After", time.Now().UTC().Format(http.TimeFormat)) gock.New("http://example.com"). Put("/"). diff --git a/config_beta.go b/config_beta.go new file mode 100644 index 0000000..096643e --- /dev/null +++ b/config_beta.go @@ -0,0 +1,15 @@ +//go:build beta && !prod && !dev && !qcstable + +package main + +// Beta runs against production Auth0 (same tenant, audience and client ID as +// config_prod.go) but points at the beta API host, so a beta login is a real +// production login. It still gets its own config directory, so the token is +// stored separately from the production build's. +var envName = "beta" +var appName = "apimetrics-beta" +var baseURL = "https://beta-client.apimetrics.io" +var authURL = "https://auth.apimetrics.io/authorize" +var tokenURL = "https://auth.apimetrics.io/oauth/token" +var authAudience = "https://client.apimetrics.io" +var clientID = "dPbV4VPvioF4nZ3oGQMn7n1vE2pFNAAI" diff --git a/config_dev.go b/config_dev.go new file mode 100644 index 0000000..1757a60 --- /dev/null +++ b/config_dev.go @@ -0,0 +1,11 @@ +//go:build dev && !prod + +package main + +var envName = "dev" +var appName = "apimetrics-dev" +var baseURL = "http://localhost:8080" +var authURL = "https://local-apimetrics.auth0.com/authorize" +var tokenURL = "https://local-apimetrics.auth0.com/oauth/token" +var authAudience = "https://apimetrics-qc.appspot.com" +var clientID = "bpplXMDCn187JipRLl6Y9KrsTVZCJTbS" diff --git a/config_prod.go b/config_prod.go new file mode 100644 index 0000000..bfd98dd --- /dev/null +++ b/config_prod.go @@ -0,0 +1,11 @@ +//go:build prod + +package main + +var envName = "production" +var appName = "apimetrics" +var baseURL = "https://client.apimetrics.io" +var authURL = "https://auth.apimetrics.io/authorize" +var tokenURL = "https://auth.apimetrics.io/oauth/token" +var authAudience = "https://client.apimetrics.io" +var clientID = "dPbV4VPvioF4nZ3oGQMn7n1vE2pFNAAI" diff --git a/config_qc.go b/config_qc.go new file mode 100644 index 0000000..f27ab13 --- /dev/null +++ b/config_qc.go @@ -0,0 +1,11 @@ +//go:build !prod && !dev && !qcstable && !beta + +package main + +var envName = "qc" +var appName = "apimetrics-qc" +var baseURL = "https://qc-client.apimetrics.io" +var authURL = "https://qc-auth.apimetrics.io/authorize" +var tokenURL = "https://qc-auth.apimetrics.io/oauth/token" +var authAudience = "https://client.apimetrics.io" +var clientID = "4fhqu4lEH5ExaRh00X1B9WJSkjTnUmuK" diff --git a/config_qc_stable.go b/config_qc_stable.go new file mode 100644 index 0000000..05a4113 --- /dev/null +++ b/config_qc_stable.go @@ -0,0 +1,11 @@ +//go:build qcstable && !prod && !dev + +package main + +var envName = "qc-stable" +var appName = "apimetrics-qc-stable" +var baseURL = "https://qc-stable.apimetrics.io" +var authURL = "https://qc-auth.apimetrics.io/authorize" +var tokenURL = "https://qc-auth.apimetrics.io/oauth/token" +var authAudience = "https://client.apimetrics.io" +var clientID = "4fhqu4lEH5ExaRh00X1B9WJSkjTnUmuK" diff --git a/main.go b/main.go index fe38132..68f6700 100644 --- a/main.go +++ b/main.go @@ -7,20 +7,23 @@ import ( "apicontext.com/apimetrics/cli" "apicontext.com/apimetrics/oauth" "apicontext.com/apimetrics/openapi" + "apicontext.com/apimetrics/skills" ) var version string = "dev" -var commit string -var date string -// Build-time API configuration — override with -ldflags at build time: +// envName, appName, baseURL, authURL, tokenURL, authAudience and clientID are +// defined in exactly one config_*.go, selected by build tag: // -// go build -ldflags "-X main.baseURL=https://client.apimetrics.io ..." -var baseURL = "https://qc-client.apimetrics.io" -var authURL = "https://qc-auth.apimetrics.io/authorize" -var tokenURL = "https://qc-auth.apimetrics.io/oauth/token" -var authAudience = "https://client.apimetrics.io" -var clientID = "bj0yh0AjBMzfeOpffmCj5UP8FbmYDwcM" +// (none) config_qc.go qc apimetrics-qc +// prod config_prod.go production apimetrics +// beta config_beta.go beta apimetrics-beta +// qcstable config_qc_stable.go qc-stable apimetrics-qc-stable +// dev config_dev.go dev apimetrics-dev +// +// appName is both the installed command name and the name of the config and +// cache directories, so builds for different environments can be installed +// side by side and hold independent logins. func main() { if version == "dev" { @@ -32,12 +35,14 @@ func main() { } cli.SetBuildConfig(baseURL, authURL, tokenURL, authAudience, clientID) - cli.Init("apimetrics", version) + cli.SetEnvironment(envName) + cli.Init(appName, version) // Register default encodings, content type handlers, and link parsers. cli.Defaults() bulk.Init(cli.Root) + skills.Init(cli.Root) // Register format loaders to auto-discover API descriptions cli.AddLoader(openapi.New()) diff --git a/oauth/authcode.go b/oauth/authcode.go index bde8881..927a6c4 100644 --- a/oauth/authcode.go +++ b/oauth/authcode.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/base64" "fmt" + "html" "net/http" "net/url" "os" @@ -16,111 +17,157 @@ import ( "context" - "github.com/mattn/go-isatty" "apicontext.com/apimetrics/cli" + "github.com/mattn/go-isatty" "golang.org/x/oauth2" ) var htmlSuccess = ` - + + + + + + Login Successful — APImetrics + + + + -
-
-

Login Successful!

- Please return to the terminal. You may now close this window. -

+
+ +
+

Login Successful!

+

Please return to the terminal. You may now close this window.

` var htmlError = ` - + + + + + + Login Failed — APImetrics + + + + -
-
-

Error: $ERROR

- $DETAILS -

+
+ +
+

Login Failed

+

$ERROR
$DETAILS

@@ -187,7 +234,9 @@ func (h authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if err := r.URL.Query().Get("error"); err != "" { details := r.URL.Query().Get("error_description") - rendered := strings.Replace(strings.Replace(htmlError, "$ERROR", err, 1), "$DETAILS", details, 1) + // Escape the query params before interpolating into HTML to avoid + // reflected XSS via the localhost redirect URL. + rendered := strings.Replace(strings.Replace(htmlError, "$ERROR", html.EscapeString(err), 1), "$DETAILS", html.EscapeString(details), 1) w.Write([]byte(rendered)) h.c <- "" return @@ -285,10 +334,33 @@ func (ac *AuthorizationCodeTokenSource) Token() (*oauth2.Token, error) { } }() - // Open auth URL in browser, print for manual use in case open fails. - fmt.Fprintln(os.Stderr, "Open your browser to log in using the URL:") - fmt.Fprintln(os.Stderr, authorizeURL.String()) - open(authorizeURL.String()) + // Print welcome banner, show login URL, and ask before opening browser. + fmt.Fprint(os.Stderr, ` + _ ___ ___ _ _ + /_\ | _ \_ _|_ __ ___| |_ _ _(_)__ ___ + / _ \| _/| || ' \/ -_| _| '_| / _(_-< + /_/ \_\_| |___|_|_|_\___|\__|_| |_\__/__/ +`) + fmt.Fprintln(os.Stderr, "Welcome to APImetrics CLI!") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "To log in, open the following URL in your browser:") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, " "+authorizeURL.String()) + fmt.Fprintln(os.Stderr, "") + // Only prompt interactively if stdin is a live terminal. If a file or + // command has been piped in it is likely the request body to use after + // auth, so we must not consume it here (see the manual-code path below). + if isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd()) { + fmt.Fprint(os.Stderr, "Open your browser now? [Y/n]: ") + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer == "" || answer == "y" || answer == "yes" { + open(authorizeURL.String()) + } + } else { + open(authorizeURL.String()) + } // Provide a way to manually enter the code, e.g. for remote SSH sessions. // Only read from stdin if it is a live terminal, if a file or command has diff --git a/openapi/openapi.go b/openapi/openapi.go index 22f289b..a8a932a 100644 --- a/openapi/openapi.go +++ b/openapi/openapi.go @@ -699,15 +699,18 @@ func loadOpenAPI3(cfg Resolver, cmd *cobra.Command, location *url.URL, resp *htt short := "" long := "" + specVersion := "" if model.Info != nil { short = getExtOr(model.Info.Extensions, ExtName, model.Info.Title) long = getExtOr(model.Info.Extensions, ExtDescription, model.Info.Description) + specVersion = model.Info.Version } api := cli.API{ - Short: short, - Long: long, - Operations: operations, + Short: short, + Long: long, + SpecVersion: specVersion, + Operations: operations, } if len(authSchemes) > 0 { diff --git a/openapi/testdata/auto_config/output.yaml b/openapi/testdata/auto_config/output.yaml index 7e058b8..83c2abe 100644 --- a/openapi/testdata/auto_config/output.yaml +++ b/openapi/testdata/auto_config/output.yaml @@ -1,3 +1,4 @@ +spec_version: 1.0.0 short: Test API operations: [] auth: diff --git a/openapi/testdata/extensions/output.yaml b/openapi/testdata/extensions/output.yaml index cdaeeba..1967094 100644 --- a/openapi/testdata/extensions/output.yaml +++ b/openapi/testdata/extensions/output.yaml @@ -1,3 +1,4 @@ +spec_version: 1.0.0 short: Test API operations: - name: getItem diff --git a/openapi/testdata/group-resp/output.yaml b/openapi/testdata/group-resp/output.yaml index 9f80ac2..5410435 100644 --- a/openapi/testdata/group-resp/output.yaml +++ b/openapi/testdata/group-resp/output.yaml @@ -1,3 +1,4 @@ +spec_version: 1.0.0 short: Test API operations: - name: get-test diff --git a/openapi/testdata/long_example/output.yaml b/openapi/testdata/long_example/output.yaml index 2710cd6..9c73aa8 100644 --- a/openapi/testdata/long_example/output.yaml +++ b/openapi/testdata/long_example/output.yaml @@ -1,3 +1,4 @@ +spec_version: 1.0.0 short: Test API operations: - name: put-item diff --git a/openapi/testdata/petstore/output.yaml b/openapi/testdata/petstore/output.yaml index 18bdba2..2a74e42 100644 --- a/openapi/testdata/petstore/output.yaml +++ b/openapi/testdata/petstore/output.yaml @@ -1,3 +1,4 @@ +spec_version: 1.0.0 short: Swagger Petstore operations: - name: create-pets diff --git a/openapi/testdata/request/output.yaml b/openapi/testdata/request/output.yaml index c5bb225..ac2d860 100644 --- a/openapi/testdata/request/output.yaml +++ b/openapi/testdata/request/output.yaml @@ -1,3 +1,4 @@ +spec_version: 1.0.0 short: Test API operations: - name: delete-items-item-id diff --git a/skills/embed/setup-api-monitor.md b/skills/embed/setup-api-monitor.md new file mode 100644 index 0000000..4fb4baf --- /dev/null +++ b/skills/embed/setup-api-monitor.md @@ -0,0 +1,131 @@ +--- +name: setup-api-monitor +description: > + Set up an API monitor (HTTP call) in APImetrics: create the monitor, + attach or create a schedule, run on-demand, and verify the result. + Use when asked to create, configure, or test an API monitor or API call. +--- + +## Input format + +All `apimetrics` create commands read JSON from stdin. Use heredoc syntax: + +```bash +apimetrics create-call <<'EOF' +{ ... } +EOF +``` + +There is no `--body`, `--data`, or `-d` flag on any `apimetrics` command. + +## Steps + +### 1. Create the API monitor + +```bash +apimetrics create-call <<'EOF' +{ + "meta": { + "name": "" + }, + "request": { + "method": "GET", + "url": "" + } +} +EOF +``` + +Save the returned `id` — used in all subsequent steps. + +To add request headers or auth: +```bash +apimetrics create-call <<'EOF' +{ + "meta": { "name": "" }, + "request": { + "method": "GET", + "url": "", + "headers": [{"key": "Accept", "value": "application/json"}], + "auth_id": "" + } +} +EOF +``` + +**Validation gate:** Response must contain an `id` field. If creation fails with 400, check that `meta.name`, `request.url`, and `request.method` are all present. + +### 2. Attach a schedule + +Always offer scheduling after creating the monitor, unless the user has explicitly said they do not want one. + +First, list existing schedules so the user can choose: +```bash +apimetrics list-schedules +``` + +Present the options to the user: +- Attach to one of the existing schedules +- Create a new schedule and attach this monitor to it +- Skip scheduling for now + +Wait for the user's choice before proceeding. + +**To attach to an existing schedule** (both IDs are positional arguments): +```bash +apimetrics add-call-to-schedule +``` + +**To create a new schedule:** +```bash +apimetrics create-schedule <<'EOF' +{ + "name": "", + "frequency": 300, + "targets": [""] +} +EOF +``` + +`frequency` is in seconds. Common values: `60` (1 min), `300` (5 min), `3600` (1 hour). + +**Validation gate:** Confirm the schedule lists the monitor ID in its targets before proceeding. + +### 3. Run the monitor on-demand + +`run-call` takes the call ID as a positional argument: +```bash +apimetrics run-call +``` + +The response contains a `result_id`. Save it for the next step. + +**Validation gate:** Response must contain `result_id`. A 422 response means the project is out of quota. + +### 4. Poll results to verify + +```bash +apimetrics list-call-results +``` + +A successful result has `result.success: true` and an HTTP status code in the 2xx range. Poll until the result from step 3 appears (match by `result_id`). Use `-f` to narrow output: + +```bash +apimetrics list-call-results -f body.results[0] +``` + +**Validation gate:** Confirm `result.success` is `true`. If `false`, inspect `result.failure_reason` and the response body for details. + +## Hard rules + +- Always verify the call ID before attaching to a schedule — attaching the wrong ID silently succeeds. +- Do not poll results in a tight loop. Wait 5–10 seconds between checks; on-demand runs typically complete within 30 seconds. +- `frequency` on schedules is in seconds, not minutes. +- `add-call-to-schedule` takes two positional args: schedule ID first, then target ID. + +## Error recovery + +- **400 on create:** Missing required fields. Check `meta.name`, `request.url`, and `request.method` are all present and non-empty. +- **401/403:** Confirm `--api-key` or project is configured. Run `apimetrics project show` to check the active project. +- **422 on run:** Project is out of quota. Check billing or reduce monitor frequency. +- **No result after 60s:** The run may be queued behind other runs. Increase wait time or check monitor status. diff --git a/skills/embed/setup-browser-monitor.md b/skills/embed/setup-browser-monitor.md new file mode 100644 index 0000000..93e36d1 --- /dev/null +++ b/skills/embed/setup-browser-monitor.md @@ -0,0 +1,123 @@ +--- +name: setup-browser-monitor +description: > + Set up a Browser monitor in APImetrics: create the monitor, attach or + create a schedule, run on-demand, and verify the result. + Use when asked to create, configure, or test a browser monitor. +--- + +## Input format + +All `apimetrics` create commands read JSON from stdin. Use heredoc syntax: + +```bash +apimetrics create-browser-monitor <<'EOF' +{ ... } +EOF +``` + +There is no `--body`, `--data`, or `-d` flag on any `apimetrics` command. + +## Steps + +### 1. Create the browser monitor + +```bash +apimetrics create-browser-monitor <<'EOF' +{ + "name": "", + "url": "" +} +EOF +``` + +Save the returned `id` — used in all subsequent steps. + +Optional fields: +- `"description"` — human-readable description +- `"browser_types"` — list of browsers the monitor is permitted to run on +- `"tags"` — list of tags for grouping +- `"user_agent"` — override the User-Agent header +- `"workspace"` — workspace ID if using workspaces + +**Validation gate:** Response must contain an `id` field. If creation fails with 400, confirm `name` and `url` are both present and that `url` includes the scheme (`https://`). + +### 2. Attach a schedule + +Always offer scheduling after creating the monitor, unless the user has explicitly said they do not want one. + +First, list existing schedules so the user can choose: +```bash +apimetrics list-schedules +``` + +Present the options to the user: +- Attach to one of the existing schedules +- Create a new schedule and attach this monitor to it +- Skip scheduling for now + +Wait for the user's choice before proceeding. + +**To attach to an existing schedule** (both IDs are positional arguments): +```bash +apimetrics add-call-to-schedule +``` + +**To create a new schedule:** +```bash +apimetrics create-schedule <<'EOF' +{ + "name": "", + "frequency": 300, + "targets": [""] +} +EOF +``` + +`frequency` is in seconds. Common values: `60` (1 min), `300` (5 min), `3600` (1 hour). + +**Validation gate:** Confirm the schedule lists the monitor ID in its targets before proceeding. + +### 3. Run the monitor on-demand + +`run-monitor` takes the monitor ID as a positional argument and requires a JSON body: +```bash +apimetrics run-monitor <<'EOF' +{} +EOF +``` + +Save the `result_id` from the response — used in the next step. + +**Validation gate:** Response must include a `result_id`. A missing result ID means the run was not queued. + +### 4. Poll result to verify + +```bash +apimetrics get-result +``` + +Poll until `result` is no longer `QUEUED`. Wait 10–15 seconds between checks (browser monitors typically complete within 60 seconds). + +**Validation gate:** `result` must be `PASS`. Values of `FAIL`, `WARN`, `ERROR`, or `TIMEOUT` indicate a problem — inspect the response for details. + +To get a screenshot of the page at the time of the run: +```bash +apimetrics get-result-screenshot +``` + +## Hard rules + +- Always verify the monitor ID before attaching to a schedule — attaching the wrong ID silently succeeds. +- `run-monitor` and `get-result` take positional arguments — do not use `--monitor-id` or `--result-id` flags. +- `add-call-to-schedule` takes two positional args: schedule ID first, then target ID. +- Browser monitors take longer to complete than API monitors — wait at least 60 seconds before polling results. +- Do not poll results in a tight loop. Wait 10–15 seconds between checks. +- `frequency` on schedules is in seconds, not minutes. + +## Error recovery + +- **400 on create:** Confirm `name` and `url` are both provided and that the URL includes the scheme (`https://`). +- **401/403:** Confirm `--api-key` or project is configured. Run `apimetrics project show` to check the active project. +- **No result after 120s:** Browser monitors may take longer in high-load periods. Check the monitor with `apimetrics read-browser-monitor ` and retry. +- **Screenshot unavailable:** Not all result types include screenshots. Fall back to `get-result-content`. diff --git a/skills/embed/setup-mcp-monitor.md b/skills/embed/setup-mcp-monitor.md new file mode 100644 index 0000000..fd6df47 --- /dev/null +++ b/skills/embed/setup-mcp-monitor.md @@ -0,0 +1,133 @@ +--- +name: setup-mcp-monitor +description: > + Set up an MCP monitor in APImetrics: create the monitor with session steps, + attach or create a schedule, run on-demand, and verify the result. + Use when asked to create, configure, or test an MCP monitor. +--- + +## Input format + +All `apimetrics` create commands read JSON from stdin. Use heredoc syntax: + +```bash +apimetrics create-mcp-monitor <<'EOF' +{ ... } +EOF +``` + +There is no `--body`, `--data`, or `-d` flag on any `apimetrics` command. + +## Steps + +### 1. Create the MCP monitor + +```bash +apimetrics create-mcp-monitor <<'EOF' +{ + "name": "", + "url": "" +} +EOF +``` + +Save the returned `id` — used in all subsequent steps. + +Optional fields: +- `"steps"` — steps to execute during the MCP session +- `"auth_id"` — Auth Settings ID if the MCP server requires authentication +- `"token_id"` — Auth Token ID for token-based auth +- `"overall_timeout_ms"` — session timeout in milliseconds (default: 30000) +- `"description"` — human-readable description +- `"tags"` — list of tags for grouping +- `"workspace"` — workspace ID if using workspaces + +Example with steps: +```bash +apimetrics create-mcp-monitor <<'EOF' +{ + "name": "Check tools endpoint", + "url": "https://mcp.example.com/sse", + "steps": [{"step_type": "list_tools", "timeout_ms": 10000}] +} +EOF +``` + +**Validation gate:** Response must contain an `id` field. If creation fails with 400, confirm `name` and `url` are both present and that the URL points to a reachable MCP SSE endpoint. + +### 2. Attach a schedule + +Always offer scheduling after creating the monitor, unless the user has explicitly said they do not want one. + +First, list existing schedules so the user can choose: +```bash +apimetrics list-schedules +``` + +Present the options to the user: +- Attach to one of the existing schedules +- Create a new schedule and attach this monitor to it +- Skip scheduling for now + +Wait for the user's choice before proceeding. + +**To attach to an existing schedule** (both IDs are positional arguments): +```bash +apimetrics add-call-to-schedule +``` + +**To create a new schedule:** +```bash +apimetrics create-schedule <<'EOF' +{ + "name": "", + "frequency": 300, + "targets": [""] +} +EOF +``` + +`frequency` is in seconds. Common values: `60` (1 min), `300` (5 min), `3600` (1 hour). + +**Validation gate:** Confirm the schedule lists the monitor ID in its targets before proceeding. + +### 3. Run the monitor on-demand + +`run-monitor` takes the monitor ID as a positional argument and requires a JSON body: +```bash +apimetrics run-monitor <<'EOF' +{} +EOF +``` + +Save the `result_id` from the response — used in the next step. + +**Validation gate:** Response must include a `result_id`. A 422 means the project is out of quota. + +### 4. Poll result to verify + +```bash +apimetrics get-result +``` + +Poll until `result` is no longer `QUEUED`. Allow up to the `overall_timeout_ms` value plus processing time. Wait 10–15 seconds between checks. + +**Validation gate:** `result` must be `PASS`. Values of `FAIL`, `WARN`, `ERROR`, or `TIMEOUT` indicate a problem. Common failure causes: unreachable server, auth failure, or a step that did not return the expected tool response. + +## Hard rules + +- Always verify the monitor ID before attaching to a schedule — attaching the wrong ID silently succeeds. +- `run-monitor` and `get-result` take positional arguments — do not use `--monitor-id` or `--result-id` flags. +- `add-call-to-schedule` takes two positional args: schedule ID first, then target ID. +- MCP monitor runs are bounded by `overall_timeout_ms`. Sessions exceeding this limit are terminated and reported as failures. +- Do not poll results in a tight loop. Wait 10–15 seconds between checks. +- `frequency` on schedules is in seconds, not minutes. +- The `url` must be an SSE endpoint, not a plain HTTP endpoint. + +## Error recovery + +- **400 on create:** Confirm `name` and `url` are both provided and that the URL is a valid SSE endpoint. +- **401/403:** Confirm `--api-key` or project is configured. Run `apimetrics project show` to check the active project. +- **Session timeout failures:** Increase `overall_timeout_ms` and re-run. +- **422 on run:** Project is out of quota. Check billing or reduce monitor frequency. +- **No result after 120s:** Verify the MCP server URL is reachable before retrying. diff --git a/skills/skills.go b/skills/skills.go new file mode 100644 index 0000000..d7163ff --- /dev/null +++ b/skills/skills.go @@ -0,0 +1,246 @@ +package skills + +import ( + "bytes" + "embed" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "apicontext.com/apimetrics/cli" + "github.com/spf13/cobra" +) + +//go:embed embed/*.md +var skillFiles embed.FS + +type agent int + +const ( + agentCustom agent = iota + agentClaudeCode + agentCodex +) + +// Init registers the skills command group and the top-level onboard command. +func Init(cmd *cobra.Command) { + skills := cobra.Command{ + Use: "skills", + Short: "Manage agent skills for APImetrics", + } + + install := cobra.Command{ + Use: "install", + Short: "Install APImetrics skills into an agent skills directory", + Example: " " + os.Args[0] + " skills install --claude-code\n" + + " " + os.Args[0] + " skills install --codex\n" + + " " + os.Args[0] + " skills install --dir ./custom", + RunE: func(cmd *cobra.Command, args []string) error { + claudeCode, _ := cmd.Flags().GetBool("claude-code") + codex, _ := cmd.Flags().GetBool("codex") + dir, _ := cmd.Flags().GetString("dir") + + target, kind, err := resolveTarget(claudeCode, codex, dir) + if err != nil { + return err + } + + return installSkills(target, kind) + }, + } + + install.Flags().Bool("claude-code", false, "Install into .claude/commands/ as slash commands and write .claude/agents.md") + install.Flags().Bool("codex", false, "Install into .codex/skills/") + install.Flags().StringP("dir", "d", "", "Install into a custom directory path") + + onboard := cobra.Command{ + Use: "onboard", + Short: "Print all APImetrics skills to stdout for agent onboarding", + Long: "Prints all embedded skill workflows to stdout.\n" + + "Run this command and include the output in your agent's context\n" + + "to onboard it on the correct APImetrics CLI workflows.", + RunE: func(cmd *cobra.Command, args []string) error { + return printSkills() + }, + } + + skills.AddCommand(&install) + cmd.AddCommand(&skills) + cmd.AddCommand(&onboard) +} + +func resolveTarget(claudeCode, codex bool, dir string) (string, agent, error) { + count := 0 + if claudeCode { + count++ + } + if codex { + count++ + } + if dir != "" { + count++ + } + + if count == 0 { + return "", agentCustom, fmt.Errorf("specify a target: --claude-code, --codex, or --dir ") + } + if count > 1 { + return "", agentCustom, fmt.Errorf("specify only one of --claude-code, --codex, or --dir") + } + + switch { + case claudeCode: + return ".claude/commands", agentClaudeCode, nil + case codex: + return ".codex/skills", agentCodex, nil + default: + return dir, agentCustom, nil + } +} + +func installSkills(target string, kind agent) error { + if err := os.MkdirAll(target, 0755); err != nil { + return fmt.Errorf("creating directory %s: %w", target, err) + } + + entries, err := fs.ReadDir(skillFiles, "embed") + if err != nil { + return fmt.Errorf("reading embedded skills: %w", err) + } + + var names []string + for _, entry := range entries { + if entry.IsDir() { + continue + } + + data, err := skillFiles.ReadFile("embed/" + entry.Name()) + if err != nil { + return fmt.Errorf("reading skill %s: %w", entry.Name(), err) + } + + dest := filepath.Join(target, entry.Name()) + if err := os.WriteFile(dest, data, 0644); err != nil { + return fmt.Errorf("writing skill %s: %w", dest, err) + } + + fmt.Fprintf(cli.Stdout, "Installed %s\n", dest) + names = append(names, strings.TrimSuffix(entry.Name(), ".md")) + } + + fmt.Fprintf(cli.Stdout, "\nSkills installed to %s\n", target) + + switch kind { + case agentClaudeCode: + fmt.Fprintf(cli.Stdout, "\nAvailable as slash commands in Claude Code:\n") + for _, name := range names { + fmt.Fprintf(cli.Stdout, " /%s\n", name) + } + if err := writeAgentsMD(names, os.Args[0]); err != nil { + return err + } + if err := updateClaudeMD(); err != nil { + return err + } + case agentCodex: + fmt.Fprintf(cli.Stdout, "\nReference these files from your agent's context configuration.\n") + } + + fmt.Fprintf(cli.Stdout, "\nTo onboard any agent directly, run: %s onboard\n", os.Args[0]) + return nil +} + +func writeAgentsMD(skillNames []string, bin string) error { + const path = ".claude/agents.md" + + content := buildAgentsMD(skillNames, bin) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + return fmt.Errorf("writing %s: %w", path, err) + } + + fmt.Fprintf(cli.Stdout, "\nWritten %s\n", path) + return nil +} + +// updateClaudeMD appends an @.claude/agents.md reference to CLAUDE.md if not already present. +func updateClaudeMD() error { + const path = "CLAUDE.md" + const ref = "@.claude/agents.md" + + existing, _ := os.ReadFile(path) + + if bytes.Contains(existing, []byte(ref)) { + return nil + } + + updated := existing + if len(updated) > 0 && updated[len(updated)-1] != '\n' { + updated = append(updated, '\n') + } + if len(updated) > 0 { + updated = append(updated, '\n') + } + updated = append(updated, []byte(ref+"\n")...) + + if err := os.WriteFile(path, updated, 0644); err != nil { + return fmt.Errorf("updating CLAUDE.md: %w", err) + } + + fmt.Fprintf(cli.Stdout, "Added @.claude/agents.md reference to CLAUDE.md\n") + return nil +} + +func buildAgentsMD(skillNames []string, bin string) string { + var b strings.Builder + + b.WriteString("# APImetrics CLI Agent Guide\n\n") + b.WriteString(fmt.Sprintf("Use `%s` for all apimetrics commands in this project.\n\n", bin)) + + b.WriteString("## Prerequisites\n\n") + b.WriteString("Before running any command:\n\n") + b.WriteString(fmt.Sprintf("1. Run `%s project show` to confirm a project is active — all commands fail without one. If none is set, run `%s project select`.\n", bin, bin)) + b.WriteString("2. Invoke the relevant skill for the task.\n\n") + + b.WriteString("## Critical facts\n\n") + b.WriteString("- Commands are flat, not grouped: use `create-call`, not `calls create`\n") + b.WriteString("- All create commands read JSON from stdin using heredoc syntax — there is no `--body`, `--data`, or `-d` flag\n") + b.WriteString(fmt.Sprintf("- Correct pattern: `%s create-call <<'EOF' ... EOF`\n\n", bin)) + + b.WriteString("## Skills\n\n") + for _, name := range skillNames { + b.WriteString(fmt.Sprintf("- `/%s`\n", name)) + } + b.WriteString(fmt.Sprintf("\nIf slash commands are unavailable or you need to refresh context, run:\n\n```bash\n%s onboard\n```\n\nThis works for any agent and always reflects the current skill set.\n", bin)) + + return b.String() +} + +func printSkills() error { + entries, err := fs.ReadDir(skillFiles, "embed") + if err != nil { + return fmt.Errorf("reading embedded skills: %w", err) + } + + first := true + for _, entry := range entries { + if entry.IsDir() { + continue + } + + data, err := skillFiles.ReadFile("embed/" + entry.Name()) + if err != nil { + return fmt.Errorf("reading skill %s: %w", entry.Name(), err) + } + + if !first { + fmt.Fprintln(cli.Stdout) + } + first = false + + fmt.Fprint(cli.Stdout, string(data)) + } + + return nil +}