Full Code of majd/ipatool for AI

main e91415603665 cached
93 files
189.3 KB
59.2k tokens
214 symbols
1 requests
Download .txt
Showing preview only (209K chars total). Download the full file or copy to clipboard to get everything.
Repository: majd/ipatool
Branch: main
Commit: e91415603665
Files: 93
Total size: 189.3 KB

Directory structure:
gitextract_bcs5uo8x/

├── .github/
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug-report.yaml
│   │   └── feature-request.yaml
│   └── workflows/
│       ├── dry-build.yml
│       ├── integration-tests.yml
│       ├── lint.yml
│       ├── release.yml
│       └── unit-tests.yml
├── .gitignore
├── .golangci.yml
├── AGENTS.md
├── LICENSE
├── README.md
├── cmd/
│   ├── auth.go
│   ├── common.go
│   ├── constants.go
│   ├── download.go
│   ├── get_version_metadata.go
│   ├── list_versions.go
│   ├── output_format.go
│   ├── purchase.go
│   ├── root.go
│   └── search.go
├── go.mod
├── go.sum
├── main.go
├── pkg/
│   ├── appstore/
│   │   ├── account.go
│   │   ├── app.go
│   │   ├── app_test.go
│   │   ├── appstore.go
│   │   ├── appstore_account_info.go
│   │   ├── appstore_account_info_test.go
│   │   ├── appstore_bag.go
│   │   ├── appstore_bag_test.go
│   │   ├── appstore_download.go
│   │   ├── appstore_download_test.go
│   │   ├── appstore_get_version_metadata.go
│   │   ├── appstore_get_version_metadata_test.go
│   │   ├── appstore_list_versions.go
│   │   ├── appstore_list_versions_test.go
│   │   ├── appstore_login.go
│   │   ├── appstore_login_test.go
│   │   ├── appstore_lookup.go
│   │   ├── appstore_lookup_test.go
│   │   ├── appstore_purchase.go
│   │   ├── appstore_purchase_test.go
│   │   ├── appstore_replicate_sinf.go
│   │   ├── appstore_replicate_sinf_test.go
│   │   ├── appstore_revoke.go
│   │   ├── appstore_revoke_test.go
│   │   ├── appstore_search.go
│   │   ├── appstore_search_test.go
│   │   ├── appstore_test.go
│   │   ├── constants.go
│   │   ├── error.go
│   │   └── storefront.go
│   ├── http/
│   │   ├── client.go
│   │   ├── client_test.go
│   │   ├── constants.go
│   │   ├── cookiejar.go
│   │   ├── http_test.go
│   │   ├── method.go
│   │   ├── payload.go
│   │   ├── payload_test.go
│   │   ├── request.go
│   │   └── result.go
│   ├── keychain/
│   │   ├── keychain.go
│   │   ├── keychain_get.go
│   │   ├── keychain_get_test.go
│   │   ├── keychain_remove.go
│   │   ├── keychain_remove_test.go
│   │   ├── keychain_set.go
│   │   ├── keychain_set_test.go
│   │   ├── keychain_test.go
│   │   └── keyring.go
│   ├── log/
│   │   ├── log_test.go
│   │   ├── logger.go
│   │   ├── logger_test.go
│   │   ├── writer.go
│   │   └── writer_test.go
│   └── util/
│       ├── machine/
│       │   ├── machine.go
│       │   └── machine_test.go
│       ├── must.go
│       ├── must_test.go
│       ├── operatingsystem/
│       │   ├── operatingsystem.go
│       │   └── operatingsystem_test.go
│       ├── string.go
│       ├── string_test.go
│       ├── util_test.go
│       ├── zip.go
│       └── zip_test.go
├── tools/
│   └── sha256sum.sh
└── tools.go

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/FUNDING.yml
================================================
# github: [majd]
patreon: majd_dev


================================================
FILE: .github/ISSUE_TEMPLATE/bug-report.yaml
================================================
name: Bug Report
description: File a bug report
labels:
  - bug
body:
  - type: textarea
    id: description
    attributes:
      label: What happened?
      description: Also share, what did you expect to happen?
    validations:
      required: true
  - type: input
    id: version
    attributes:
      label: Version
      description: What version of ipatool are you running?
    validations:
      required: true
  - type: textarea
    id: logs
    attributes:
      label: Relevant log output
      description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
      render: shell


================================================
FILE: .github/ISSUE_TEMPLATE/feature-request.yaml
================================================
name: Feature Request
description: Submit a feature request
labels:
  - feature request
body:
  - type: textarea
    id: description
    attributes:
      label: Description
      description: Please provide details about the desired feature.
    validations:
      required: true


================================================
FILE: .github/workflows/dry-build.yml
================================================
name: Dry Build

on:
  pull_request:
    branches:
      - main

jobs:
  build_windows:
    name: Build for Windows
    runs-on: macos-latest
    strategy:
      fail-fast: false
      matrix:
        arch: [arm64, amd64]
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-go@v3
        with:
          go-version: "1.23.0"
          cache: true
      - run: go build -o ipatool-$GOOS-$GOARCH.exe
        env:
          GOOS: windows
          GOARCH: ${{ matrix.arch }}
  build_linux:
    name: Build for Linux
    runs-on: macos-latest
    strategy:
      fail-fast: false
      matrix:
        arch: [arm64, amd64]
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-go@v3
        with:
          go-version: "1.23.0"
          cache: true
      - run: go build -o ipatool-$GOOS-$GOARCH
        env:
          GOOS: linux
          GOARCH: ${{ matrix.arch }}
  build_macos:
    name: Build for macOS
    runs-on: macos-latest
    strategy:
      fail-fast: false
      matrix:
        arch: [arm64, amd64]
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-go@v3
        with:
          go-version: "1.23.0"
          cache: true
      - run: go build -o ipatool-$GOOS-$GOARCH
        env:
          GOOS: darwin
          GOARCH: ${{ matrix.arch }}
          CGO_CFLAGS: -mmacosx-version-min=10.15
          CGO_LDFLAGS: -mmacosx-version-min=10.15


================================================
FILE: .github/workflows/integration-tests.yml
================================================
name: Integration Tests

on:
  pull_request:
    branches:
      - main

jobs:
  build:
    name: Build
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-go@v3
        with:
          go-version: "1.23.0"
          cache: true
      - run: go build -o ipatool
        env:
          CGO_CFLAGS: -mmacosx-version-min=10.15
          CGO_LDFLAGS: -mmacosx-version-min=10.15
      - uses: actions/upload-artifact@v4
        with:
          name: ipatool
          path: ipatool
          if-no-files-found: error
  test:
    name: Test
    runs-on: macos-latest
    needs: [build]
    strategy:
      fail-fast: false
      matrix:
        command: [auth, download, purchase, search]
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: ipatool
          path: build
      - run: chmod +x ./build/ipatool
      - run: ./build/ipatool ${{ matrix.command }} --help


================================================
FILE: .github/workflows/lint.yml
================================================
name: Lint

on:
  pull_request:
    branches:
      - main

jobs:
  lint:
    name: Lint
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-go@v3
        with:
          go-version: "1.23.0"
          cache: true
      - run: go generate github.com/majd/ipatool/...
      - uses: golangci/golangci-lint-action@v8
        with:
          version: v2.1


================================================
FILE: .github/workflows/release.yml
================================================
name: Release

on:
  push:
    tags:
      - "v*"

jobs:
  get_version:
    name: Get version
    runs-on: ubuntu-latest
    steps:
      - id: set_output
        run: echo ::set-output name=version::${GITHUB_REF#refs/tags/v}
    outputs:
      version: ${{ steps.set_output.outputs.version }}
  test:
    name: Run tests
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-go@v3
        with:
          go-version: "1.23.0"
          cache: true
      - run: go generate github.com/majd/ipatool/...
      - run: go test -v github.com/majd/ipatool/...
  build:
    name: Build
    runs-on: macos-latest
    needs: [get_version, test]
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-go@v3
        with:
          go-version: "1.23.0"
          cache: true
      - run: go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o ipatool-$VERSION-windows-arm64.exe
        env:
          GOOS: windows
          GOARCH: arm64
          VERSION: ${{ needs.get_version.outputs.version }}
      - run: go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o ipatool-$VERSION-windows-amd64.exe
        env:
          GOOS: windows
          GOARCH: amd64
          VERSION: ${{ needs.get_version.outputs.version }}
      - run: go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o ipatool-$VERSION-linux-arm64
        env:
          GOOS: linux
          GOARCH: arm64
          VERSION: ${{ needs.get_version.outputs.version }}
      - run: go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o ipatool-$VERSION-linux-amd64
        env:
          GOOS: linux
          GOARCH: amd64
          VERSION: ${{ needs.get_version.outputs.version }}
      - run: go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o ipatool-$VERSION-macos-arm64
        env:
          GOOS: darwin
          GOARCH: arm64
          VERSION: ${{ needs.get_version.outputs.version }}
          CGO_CFLAGS: -mmacosx-version-min=10.15
          CGO_LDFLAGS: -mmacosx-version-min=10.15
          CGO_ENABLED: 1
      - run: go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o ipatool-$VERSION-macos-amd64
        env:
          GOOS: darwin
          GOARCH: amd64
          VERSION: ${{ needs.get_version.outputs.version }}
          CGO_CFLAGS: -mmacosx-version-min=10.15
          CGO_LDFLAGS: -mmacosx-version-min=10.15
          CGO_ENABLED: 1
      - uses: actions/upload-artifact@v4
        with:
          name: ipatool-${{ needs.get_version.outputs.version }}-windows-arm64.exe
          path: ipatool-${{ needs.get_version.outputs.version }}-windows-arm64.exe
          if-no-files-found: error
      - uses: actions/upload-artifact@v4
        with:
          name: ipatool-${{ needs.get_version.outputs.version }}-windows-amd64.exe
          path: ipatool-${{ needs.get_version.outputs.version }}-windows-amd64.exe
          if-no-files-found: error
      - uses: actions/upload-artifact@v4
        with:
          name: ipatool-${{ needs.get_version.outputs.version }}-linux-arm64
          path: ipatool-${{ needs.get_version.outputs.version }}-linux-arm64
          if-no-files-found: error
      - uses: actions/upload-artifact@v4
        with:
          name: ipatool-${{ needs.get_version.outputs.version }}-linux-amd64
          path: ipatool-${{ needs.get_version.outputs.version }}-linux-amd64
          if-no-files-found: error
      - uses: actions/upload-artifact@v4
        with:
          name: ipatool-${{ needs.get_version.outputs.version }}-macos-arm64
          path: ipatool-${{ needs.get_version.outputs.version }}-macos-arm64
          if-no-files-found: error
      - uses: actions/upload-artifact@v4
        with:
          name: ipatool-${{ needs.get_version.outputs.version }}-macos-amd64
          path: ipatool-${{ needs.get_version.outputs.version }}-macos-amd64
          if-no-files-found: error
  release_windows:
    name: Release for Windows
    runs-on: ubuntu-latest
    needs: [get_version, build]
    strategy:
      fail-fast: false
      matrix:
        arch: [arm64, amd64]
    steps:
      - uses: actions/checkout@v2
      - uses: actions/download-artifact@v4
        with:
          name: ipatool-${{ needs.get_version.outputs.version }}-windows-${{ matrix.arch }}.exe
          path: bin
      - run: tar -czvf $FILE.tar.gz bin/$FILE.exe
        env:
          FILE: ipatool-${{ needs.get_version.outputs.version }}-windows-${{ matrix.arch }}
      - run: ./tools/sha256sum.sh $TARBALL > $TARBALL.sha256sum
        env:
          TARBALL: ipatool-${{ needs.get_version.outputs.version }}-windows-${{ matrix.arch }}.tar.gz
      - uses: svenstaro/upload-release-action@v2
        with:
          repo_token: ${{ secrets.GITHUB_TOKEN }}
          file: ipatool-${{ needs.get_version.outputs.version }}-windows-${{ matrix.arch }}.*
          tag: ${{ github.ref }}
          overwrite: false
          file_glob: true
  release_linux:
    name: Release for Linux
    runs-on: ubuntu-latest
    needs: [get_version, build, release_windows]
    strategy:
      fail-fast: false
      matrix:
        arch: [arm64, amd64]
    steps:
      - uses: actions/checkout@v2
      - uses: actions/download-artifact@v4
        with:
          name: ipatool-${{ needs.get_version.outputs.version }}-linux-${{ matrix.arch }}
          path: bin
      - run: chmod +x bin/$FILE && tar -czvf $FILE.tar.gz bin/$FILE
        env:
          FILE: ipatool-${{ needs.get_version.outputs.version }}-linux-${{ matrix.arch }}
      - run: ./tools/sha256sum.sh $TARBALL > $TARBALL.sha256sum
        env:
          TARBALL: ipatool-${{ needs.get_version.outputs.version }}-linux-${{ matrix.arch }}.tar.gz
      - uses: svenstaro/upload-release-action@v2
        with:
          repo_token: ${{ secrets.GITHUB_TOKEN }}
          file: ipatool-${{ needs.get_version.outputs.version }}-linux-${{ matrix.arch }}.*
          tag: ${{ github.ref }}
          overwrite: false
          file_glob: true
  release_macos:
    name: Release for macOS
    runs-on: ubuntu-latest
    needs: [get_version, build, release_windows, release_linux]
    steps:
      - uses: actions/checkout@v2
        with:
          path: ./ipatool
      - uses: actions/download-artifact@v4
        with:
          name: ipatool-${{ needs.get_version.outputs.version }}-macos-arm64
          path: bin
      - run: chmod +x bin/$BIN && tar -czvf $BIN.tar.gz bin/$BIN && rm -rf bin/
        env:
          BIN: ipatool-${{ needs.get_version.outputs.version }}-macos-arm64
      - uses: actions/download-artifact@v4
        with:
          name: ipatool-${{ needs.get_version.outputs.version }}-macos-amd64
          path: bin
      - run: chmod +x bin/$FILE && tar -czvf $FILE.tar.gz bin/$FILE && rm -rf bin/
        env:
          FILE: ipatool-${{ needs.get_version.outputs.version }}-macos-amd64
      - id: sha256
        run: |
          SHA256_ARM64=$(./ipatool/tools/sha256sum.sh ipatool-${{ needs.get_version.outputs.version }}-macos-arm64.tar.gz)
          SHA256_AMD64=$(./ipatool/tools/sha256sum.sh ipatool-${{ needs.get_version.outputs.version }}-macos-amd64.tar.gz)
          echo $SHA256_ARM64 > ipatool-${{ needs.get_version.outputs.version }}-macos-arm64.tar.gz.sha256sum
          echo $SHA256_AMD64 > ipatool-${{ needs.get_version.outputs.version }}-macos-amd64.tar.gz.sha256sum
          echo ::set-output name=sha256_arm64::$SHA256_ARM64
          echo ::set-output name=sha256_amd64::$SHA256_AMD64
      - uses: svenstaro/upload-release-action@v2
        with:
          repo_token: ${{ secrets.GITHUB_TOKEN }}
          file: ipatool-${{ needs.get_version.outputs.version }}-macos-*
          tag: ${{ github.ref }}
          overwrite: false
          file_glob: true
      - uses: actions/checkout@v2
        with:
          repository: ${{ secrets.HOMEBREW_REPO }}
          ref: main
          token: ${{ secrets.GH_TOKEN }}
          path: homebrew-repo
      - run: |
          cd homebrew-repo
          sed -i "3s/.*/    sha256 \"$SHA256_ARM64\"/" Casks/ipatool.rb
          sed -i "4s/.*/    url \"https:\/\/github.com\/majd\/ipatool\/releases\/download\/v${{ needs.get_version.outputs.version }}\/ipatool-${{ needs.get_version.outputs.version }}-macos-arm64.tar.gz\"/" Casks/ipatool.rb
          sed -i "5s/.*/    binary \"bin\/ipatool-${{ needs.get_version.outputs.version }}-macos-arm64\", target: \"ipatool\"/" Casks/ipatool.rb
          sed -i "7s/.*/    sha256 \"$SHA256_AMD64\"/" Casks/ipatool.rb
          sed -i "8s/.*/    url \"https:\/\/github.com\/majd\/ipatool\/releases\/download\/v${{ needs.get_version.outputs.version }}\/ipatool-${{ needs.get_version.outputs.version }}-macos-amd64.tar.gz\"/" Casks/ipatool.rb
          sed -i "9s/.*/    binary \"bin\/ipatool-${{ needs.get_version.outputs.version }}-macos-amd64\", target: \"ipatool\"/" Casks/ipatool.rb
          sed -i "12s/.*/  version \"${{ needs.get_version.outputs.version }}\"/" Casks/ipatool.rb
          git config --local user.name ${{ secrets.GH_NAME }}
          git config --local user.email ${{ secrets.GH_EMAIL }}
          git add Casks/ipatool.rb
          git commit -m "Update ipatool to v${{ needs.get_version.outputs.version }}"
          git push "https://${{ secrets.GH_TOKEN }}@github.com/${{ secrets.HOMEBREW_REPO }}.git" --set-upstream "main"
        env:
          SHA256_ARM64: ${{ steps.sha256.outputs.sha256_arm64 }}
          SHA256_AMD64: ${{ steps.sha256.outputs.sha256_amd64 }}


================================================
FILE: .github/workflows/unit-tests.yml
================================================
name: Unit Tests

on:
  pull_request:
    branches:
      - main

jobs:
  run_tests:
    name: Run tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-go@v3
        with:
          go-version: "1.23.0"
          cache: true
      - run: go generate github.com/majd/ipatool/...
      - run: go test -v github.com/majd/ipatool/...


================================================
FILE: .gitignore
================================================
.DS_Store
.AppleDouble
.LSOverride
.vscode/
.idea/
**/*_mock.go
exp/

================================================
FILE: .golangci.yml
================================================
version: "2"
linters:
  enable:
    - ginkgolinter
    - godot
    - godox
    - importas
    - nlreturn
    - nonamedreturns
    - prealloc
    - predeclared
    - unconvert
    - unparam
    - usestdlibvars
    - wastedassign
    - wrapcheck
    - wsl
  exclusions:
    generated: lax
    presets:
      - comments
      - common-false-positives
      - legacy
      - std-error-handling
    paths:
      - third_party$
      - builtin$
      - examples$
formatters:
  enable:
    - gofmt
    - goimports
  exclusions:
    generated: lax
    paths:
      - third_party$
      - builtin$
      - examples$


================================================
FILE: AGENTS.md
================================================
# AGENTS.md

This file provides guidance for coding agents working in this repository.

## Repository overview
- Project: `ipatool`
- Language: Go
- Entry point: `main.go`
- CLI command implementations: `cmd/`

## Development workflow
1. Keep changes focused and minimal.
2. Prefer idiomatic Go and keep command behavior consistent with existing commands in `cmd/`.
3. Run formatting and tests before finalizing changes.

## Local checks
Use these commands from the repository root:

```bash
go generate ./...
go test ./...
go build ./...
```

## Coding conventions
- Follow standard Go formatting (`gofmt`).
- Avoid introducing new dependencies unless necessary.
- Keep user-facing text consistent with existing CLI help/output tone.
- Preserve backward compatibility for CLI flags and output formats unless explicitly asked to change them.

## Commit/PR guidance
- Write clear, scoped commit messages.
- Summarize what changed and why in PR descriptions.
- Include test/build results in your handoff.


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2021 Majd Alfhaily

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

================================================
FILE: README.md
================================================
# IPATool

[![Release](https://img.shields.io/github/release/majd/ipatool.svg?label=Release)](https://GitHub.com/majd/ipatool/releases/)
[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/majd/ipatool/blob/main/LICENSE)

`ipatool` is a command line tool that allows you to search for iOS apps on the [App Store](https://apps.apple.com) and download a copy of the app package, known as an _ipa_ file.

![Demo](./resources/demo.gif)

- [Requirements](#requirements)
- [Installation](#installation)
  - [Manual](#manual)
  - [Package Manager (macOS)](#package-manager-macos)
- [Usage](#usage)
- [Compiling](#compiling)
- [License](#license)
- [Releases](https://github.com/majd/ipatool/releases)
- [FAQ](https://github.com/majd/ipatool/wiki/FAQ)

## Requirements

- Supported operating system (Windows, Linux or macOS).
- Apple ID set up to use the App Store.

## Installation

### Manual

You can grab the latest version of `ipatool` from [GitHub releases](https://github.com/majd/ipatool/releases).

### Package Manager (macOS)

You can install `ipatool` using [Homebrew](https://brew.sh).

```shell
$ brew install ipatool
```

## Usage

To authenticate with the App Store, use the `auth` command.

```
Authenticate with the App Store

Usage:
  ipatool auth [command]

Available Commands:
  info        Show current account info
  login       Login to the App Store
  revoke      Revoke your App Store credentials

Flags:
  -h, --help   help for auth

Global Flags:
      --format format     sets output format for command; can be 'text', 'json' (default text)
      --non-interactive   run in non-interactive session
      --verbose           enables verbose logs

Use "ipatool auth [command] --help" for more information about a command.
```

To search for apps on the App Store, use the `search` command.

```
Search for iOS apps available on the App Store

Usage:
  ipatool search <term> [flags]

Flags:
  -h, --help        help for search
  -l, --limit int   maximum amount of search results to retrieve (default 5)

Global Flags:
      --format format     sets output format for command; can be 'text', 'json' (default text)
      --non-interactive   run in non-interactive session
      --verbose           enables verbose logs
```

To obtain a license for an app, use the `purchase` command.

```
Obtain a license for the app from the App Store

Usage:
  ipatool purchase [flags]

Flags:
  -b, --bundle-identifier string   Bundle identifier of the target iOS app (required)
  -h, --help                       help for purchase

Global Flags:
      --format format     sets output format for command; can be 'text', 'json' (default text)
      --non-interactive   run in non-interactive session
      --verbose           enables verbose logs
```

To obtain a list of availble app versions to download, use the `list-versions` command.

```
List the available versions of an iOS app

Usage:
  ipatool list-versions [flags]

Flags:
  -i, --app-id int                 ID of the target iOS app (required)
  -b, --bundle-identifier string   The bundle identifier of the target iOS app (overrides the app ID)
  -h, --help                       help for list-versions

Global Flags:
      --format format                sets output format for command; can be 'text', 'json' (default text)
      --keychain-passphrase string   passphrase for unlocking keychain
      --non-interactive              run in non-interactive session
      --verbose                      enables verbose logs
```

To download a copy of the ipa file, use the `download` command.

```
Download (encrypted) iOS app packages from the App Store

Usage:
  ipatool download [flags]

Flags:
  -i, --app-id int                   ID of the target iOS app (required)
  -b, --bundle-identifier string     The bundle identifier of the target iOS app (overrides the app ID)
      --external-version-id string   External version identifier of the target iOS app (defaults to latest version when not specified)
  -h, --help                         help for download
  -o, --output string                The destination path of the downloaded app package
      --purchase                     Obtain a license for the app if needed

Global Flags:
      --format format                sets output format for command; can be 'text', 'json' (default text)
      --keychain-passphrase string   passphrase for unlocking keychain
      --non-interactive              run in non-interactive session
      --verbose                      enables verbose logs
```

To resolve an external version identifier, returned by the `list-versions` command, use the `get-version-metadata` command.

```
Retrieves the metadata for a specific version of an app

Usage:
  ipatool get-version-metadata [flags]

Flags:
  -i, --app-id int                   ID of the target iOS app (required)
  -b, --bundle-identifier string     The bundle identifier of the target iOS app (overrides the app ID)
      --external-version-id string   External version identifier of the target iOS app (required)
  -h, --help                         help for get-version-metadata

Global Flags:
      --format format                sets output format for command; can be 'text', 'json' (default text)
      --keychain-passphrase string   passphrase for unlocking keychain
      --non-interactive              run in non-interactive session
      --verbose                      enables verbose logs
```

**Note:** the tool runs in interactive mode by default. Use the `--non-interactive` flag
if running in an automated environment.

## Compiling

The tool can be compiled using the Go toolchain.

```shell
$ go build -o ipatool
```

Unit tests can be executed with the following commands.

```shell
$ go generate github.com/majd/ipatool/...
$ go test -v github.com/majd/ipatool/...
```

## License

IPATool is released under the [MIT license](https://github.com/majd/ipatool/blob/main/LICENSE).


================================================
FILE: cmd/auth.go
================================================
package cmd

import (
	"bufio"
	"errors"
	"fmt"
	"os"
	"strings"
	"time"

	"github.com/avast/retry-go"
	"github.com/majd/ipatool/v2/pkg/appstore"
	"github.com/majd/ipatool/v2/pkg/util"
	"github.com/spf13/cobra"
	"golang.org/x/term"
)

func authCmd() *cobra.Command {
	cmd := &cobra.Command{
		Use:   "auth",
		Short: "Authenticate with the App Store",
	}

	cmd.AddCommand(loginCmd())
	cmd.AddCommand(infoCmd())
	cmd.AddCommand(revokeCmd())

	return cmd
}

func loginCmd() *cobra.Command {
	promptForAuthCode := func() (string, error) {
		authCode, err := bufio.NewReader(os.Stdin).ReadString('\n')
		if err != nil {
			return "", fmt.Errorf("failed to read string: %w", err)
		}

		authCode = strings.Trim(authCode, "\n")
		authCode = strings.Trim(authCode, "\r")

		return authCode, nil
	}

	var email, password, authCode string

	cmd := &cobra.Command{
		Use:   "login",
		Short: "Login to the App Store",
		RunE: func(cmd *cobra.Command, args []string) error {
			interactive := cmd.Context().Value("interactive").(bool)

			if password == "" && !interactive {
				return errors.New("password is required when not running in interactive mode; use the \"--password\" flag")
			}

			if password == "" && interactive {
				dependencies.Logger.Log().Msg("enter password:")

				bytes, err := term.ReadPassword(int(os.Stdin.Fd()))
				if err != nil {
					return fmt.Errorf("failed to read password: %w", err)
				}
				password = string(bytes)
			}

			var lastErr error

			// nolint:wrapcheck
			return retry.Do(func() error {
				if errors.Is(lastErr, appstore.ErrAuthCodeRequired) && interactive {
					dependencies.Logger.Log().Msg("enter 2FA code:")

					var err error
					authCode, err = promptForAuthCode()
					if err != nil {
						return fmt.Errorf("failed to read auth code: %w", err)
					}
				}

				dependencies.Logger.Verbose().
					Str("password", password).
					Str("email", email).
					Str("authCode", util.IfEmpty(authCode, "<nil>")).
					Msg("logging in")

				bag, err := dependencies.AppStore.Bag(appstore.BagInput{})
				if err != nil {
					return fmt.Errorf("failed to get bag: %w", err)
				}

				output, err := dependencies.AppStore.Login(appstore.LoginInput{
					Email:    email,
					Password: password,
					AuthCode: authCode,
					Endpoint: bag.AuthEndpoint,
				})
				if err != nil {
					if errors.Is(err, appstore.ErrAuthCodeRequired) && !interactive {
						dependencies.Logger.Log().Msg("2FA code is required; run the command again and supply a code using the `--auth-code` flag")

						return nil
					}

					return err
				}

				dependencies.Logger.Log().
					Str("name", output.Account.Name).
					Str("email", output.Account.Email).
					Bool("success", true).
					Send()

				return nil
			},
				retry.LastErrorOnly(true),
				retry.DelayType(retry.FixedDelay),
				retry.Delay(time.Millisecond),
				retry.Attempts(2),
				retry.RetryIf(func(err error) bool {
					lastErr = err

					return errors.Is(err, appstore.ErrAuthCodeRequired)
				}),
			)
		},
	}

	cmd.Flags().StringVarP(&email, "email", "e", "", "email address for the Apple ID (required)")
	cmd.Flags().StringVarP(&password, "password", "p", "", "password for the Apple ID (required)")
	cmd.Flags().StringVar(&authCode, "auth-code", "", "2FA code for the Apple ID")

	_ = cmd.MarkFlagRequired("email")

	return cmd
}

// nolint:wrapcheck
func infoCmd() *cobra.Command {
	return &cobra.Command{
		Use:   "info",
		Short: "Show current account info",
		RunE: func(cmd *cobra.Command, args []string) error {
			output, err := dependencies.AppStore.AccountInfo()
			if err != nil {
				return err
			}

			dependencies.Logger.Log().
				Str("name", output.Account.Name).
				Str("email", output.Account.Email).
				Bool("success", true).
				Send()

			return nil
		},
	}
}

// nolint:wrapcheck
func revokeCmd() *cobra.Command {
	return &cobra.Command{
		Use:   "revoke",
		Short: "Revoke your App Store credentials",
		RunE: func(cmd *cobra.Command, args []string) error {
			err := dependencies.AppStore.Revoke()
			if err != nil {
				return err
			}

			dependencies.Logger.Log().Bool("success", true).Send()

			return nil
		},
	}
}


================================================
FILE: cmd/common.go
================================================
package cmd

import (
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"

	"github.com/99designs/keyring"
	cookiejar "github.com/juju/persistent-cookiejar"
	"github.com/majd/ipatool/v2/pkg/appstore"
	"github.com/majd/ipatool/v2/pkg/http"
	"github.com/majd/ipatool/v2/pkg/keychain"
	"github.com/majd/ipatool/v2/pkg/log"
	"github.com/majd/ipatool/v2/pkg/util"
	"github.com/majd/ipatool/v2/pkg/util/machine"
	"github.com/majd/ipatool/v2/pkg/util/operatingsystem"
	"github.com/rs/zerolog"
	"github.com/spf13/cobra"
	"golang.org/x/term"
)

var dependencies = Dependencies{}
var keychainPassphrase string

type Dependencies struct {
	Logger    log.Logger
	OS        operatingsystem.OperatingSystem
	Machine   machine.Machine
	CookieJar http.CookieJar
	Keychain  keychain.Keychain
	AppStore  appstore.AppStore
}

// newLogger returns a new logger instance.
func newLogger(format OutputFormat, verbose bool) log.Logger {
	var writer io.Writer

	switch format {
	case OutputFormatJSON:
		writer = zerolog.SyncWriter(os.Stdout)
	case OutputFormatText:
		writer = log.NewWriter()
	}

	return log.NewLogger(log.Args{
		Verbose: verbose,
		Writer:  writer,
	},
	)
}

// newCookieJar returns a new cookie jar instance.
func newCookieJar(machine machine.Machine) http.CookieJar {
	return util.Must(cookiejar.New(&cookiejar.Options{
		Filename: filepath.Join(machine.HomeDirectory(), ConfigDirectoryName, CookieJarFileName),
	}))
}

// newKeychain returns a new keychain instance.
func newKeychain(machine machine.Machine, logger log.Logger, interactive bool) keychain.Keychain {
	ring := util.Must(keyring.Open(keyring.Config{
		AllowedBackends: []keyring.BackendType{
			keyring.KeychainBackend,
			keyring.SecretServiceBackend,
			keyring.FileBackend,
		},
		ServiceName: KeychainServiceName,
		FileDir:     filepath.Join(machine.HomeDirectory(), ConfigDirectoryName),
		FilePasswordFunc: func(s string) (string, error) {
			if keychainPassphrase == "" && !interactive {
				return "", errors.New("keychain passphrase is required when not running in interactive mode; use the \"--keychain-passphrase\" flag")
			}

			if keychainPassphrase != "" {
				return keychainPassphrase, nil
			}

			path := strings.Split(s, " unlock ")[1]
			logger.Log().Msgf("enter passphrase to unlock %s (this is separate from your Apple ID password): ", path)
			bytes, err := term.ReadPassword(int(os.Stdin.Fd()))
			if err != nil {
				return "", fmt.Errorf("failed to read password: %w", err)
			}

			password := string(bytes)
			password = strings.Trim(password, "\n")
			password = strings.Trim(password, "\r")

			return password, nil
		},
	}))

	return keychain.New(keychain.Args{Keyring: ring})
}

// initWithCommand initializes the dependencies of the command.
func initWithCommand(cmd *cobra.Command) {
	verbose := cmd.Flag("verbose").Value.String() == "true"
	interactive, _ := cmd.Context().Value("interactive").(bool)
	format := util.Must(OutputFormatFromString(cmd.Flag("format").Value.String()))

	dependencies.Logger = newLogger(format, verbose)
	dependencies.OS = operatingsystem.New()
	dependencies.Machine = machine.New(machine.Args{OS: dependencies.OS})
	dependencies.CookieJar = newCookieJar(dependencies.Machine)
	dependencies.Keychain = newKeychain(dependencies.Machine, dependencies.Logger, interactive)
	dependencies.AppStore = appstore.NewAppStore(appstore.Args{
		CookieJar:       dependencies.CookieJar,
		OperatingSystem: dependencies.OS,
		Keychain:        dependencies.Keychain,
		Machine:         dependencies.Machine,
	})

	util.Must("", createConfigDirectory(dependencies.OS, dependencies.Machine))
}

// createConfigDirectory creates the configuration directory for the CLI tool, if needed.
func createConfigDirectory(os operatingsystem.OperatingSystem, machine machine.Machine) error {
	configDirectoryPath := filepath.Join(machine.HomeDirectory(), ConfigDirectoryName)
	_, err := os.Stat(configDirectoryPath)

	if err != nil && os.IsNotExist(err) {
		err = os.MkdirAll(configDirectoryPath, 0700)
		if err != nil {
			return fmt.Errorf("failed to create config directory: %w", err)
		}
	} else if err != nil {
		return fmt.Errorf("could not read metadata: %w", err)
	}

	return nil
}


================================================
FILE: cmd/constants.go
================================================
package cmd

const (
	ConfigDirectoryName = ".ipatool"
	CookieJarFileName   = "cookies"
	KeychainServiceName = "ipatool-auth.service"
)


================================================
FILE: cmd/download.go
================================================
package cmd

import (
	"errors"
	"os"
	"time"

	"github.com/avast/retry-go"
	"github.com/majd/ipatool/v2/pkg/appstore"
	"github.com/schollz/progressbar/v3"
	"github.com/spf13/cobra"
)

// nolint:wrapcheck
func downloadCmd() *cobra.Command {
	var (
		acquireLicense    bool
		outputPath        string
		appID             int64
		bundleID          string
		externalVersionID string
	)

	cmd := &cobra.Command{
		Use:   "download",
		Short: "Download (encrypted) iOS app packages from the App Store",
		RunE: func(cmd *cobra.Command, args []string) error {
			if appID == 0 && bundleID == "" {
				return errors.New("either the app ID or the bundle identifier must be specified")
			}

			var lastErr error
			var acc appstore.Account
			purchased := false

			return retry.Do(func() error {
				infoResult, err := dependencies.AppStore.AccountInfo()
				if err != nil {
					return err
				}

				acc = infoResult.Account

				if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) {
					loginResult, err := dependencies.AppStore.Login(appstore.LoginInput{Email: acc.Email, Password: acc.Password})
					if err != nil {
						return err
					}

					acc = loginResult.Account
				}

				app := appstore.App{ID: appID}
				if bundleID != "" {
					lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{Account: acc, BundleID: bundleID})
					if err != nil {
						return err
					}

					app = lookupResult.App
				}

				if errors.Is(lastErr, appstore.ErrLicenseRequired) {
					err := dependencies.AppStore.Purchase(appstore.PurchaseInput{Account: acc, App: app})
					if err != nil {
						return err
					}
					purchased = true
					dependencies.Logger.Verbose().
						Bool("success", true).
						Msg("purchase")
				}

				interactive, _ := cmd.Context().Value("interactive").(bool)
				var progress *progressbar.ProgressBar
				if interactive {
					progress = progressbar.NewOptions64(1,
						progressbar.OptionSetDescription("downloading"),
						progressbar.OptionSetWriter(os.Stdout),
						progressbar.OptionShowBytes(true),
						progressbar.OptionSetWidth(20),
						progressbar.OptionFullWidth(),
						progressbar.OptionThrottle(65*time.Millisecond),
						progressbar.OptionShowCount(),
						progressbar.OptionClearOnFinish(),
						progressbar.OptionSpinnerType(14),
						progressbar.OptionSetRenderBlankState(true),
						progressbar.OptionSetElapsedTime(false),
						progressbar.OptionSetPredictTime(false),
					)
				}

				out, err := dependencies.AppStore.Download(appstore.DownloadInput{
					Account: acc, App: app, OutputPath: outputPath, Progress: progress, ExternalVersionID: externalVersionID})
				if err != nil {
					return err
				}

				err = dependencies.AppStore.ReplicateSinf(appstore.ReplicateSinfInput{Sinfs: out.Sinfs, PackagePath: out.DestinationPath})
				if err != nil {
					return err
				}

				dependencies.Logger.Log().
					Str("output", out.DestinationPath).
					Bool("purchased", purchased).
					Bool("success", true).
					Send()

				return nil
			},
				retry.LastErrorOnly(true),
				retry.DelayType(retry.FixedDelay),
				retry.Delay(time.Millisecond),
				retry.Attempts(2),
				retry.RetryIf(func(err error) bool {
					lastErr = err

					if errors.Is(err, appstore.ErrPasswordTokenExpired) {
						return true
					}

					if errors.Is(err, appstore.ErrLicenseRequired) && acquireLicense {
						return true
					}

					return false
				}),
			)
		},
	}

	cmd.Flags().Int64VarP(&appID, "app-id", "i", 0, "ID of the target iOS app (required)")
	cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "The bundle identifier of the target iOS app (overrides the app ID)")
	cmd.Flags().StringVarP(&outputPath, "output", "o", "", "The destination path of the downloaded app package")
	cmd.Flags().StringVar(&externalVersionID, "external-version-id", "", "External version identifier of the target iOS app (defaults to latest version when not specified)")
	cmd.Flags().BoolVar(&acquireLicense, "purchase", false, "Obtain a license for the app if needed")

	return cmd
}


================================================
FILE: cmd/get_version_metadata.go
================================================
package cmd

import (
	"errors"
	"time"

	"github.com/avast/retry-go"
	"github.com/majd/ipatool/v2/pkg/appstore"
	"github.com/spf13/cobra"
)

// nolint:wrapcheck
func getVersionMetadataCmd() *cobra.Command {
	var (
		appID             int64
		bundleID          string
		externalVersionID string
	)

	cmd := &cobra.Command{
		Use:   "get-version-metadata",
		Short: "Retrieves the metadata for a specific version of an app",
		RunE: func(cmd *cobra.Command, args []string) error {
			if appID == 0 && bundleID == "" {
				return errors.New("either the app ID or the bundle identifier must be specified")
			}

			var lastErr error
			var acc appstore.Account

			return retry.Do(func() error {
				infoResult, err := dependencies.AppStore.AccountInfo()
				if err != nil {
					return err
				}

				acc = infoResult.Account

				if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) {
					loginResult, err := dependencies.AppStore.Login(appstore.LoginInput{Email: acc.Email, Password: acc.Password})
					if err != nil {
						return err
					}

					acc = loginResult.Account
				}

				app := appstore.App{ID: appID}
				if bundleID != "" {
					lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{Account: acc, BundleID: bundleID})
					if err != nil {
						return err
					}

					app = lookupResult.App
				}

				out, err := dependencies.AppStore.GetVersionMetadata(appstore.GetVersionMetadataInput{
					Account:   acc,
					App:       app,
					VersionID: externalVersionID,
				})
				if err != nil {
					return err
				}

				dependencies.Logger.Log().
					Str("externalVersionID", externalVersionID).
					Str("displayVersion", out.DisplayVersion).
					Time("releaseDate", out.ReleaseDate).
					Bool("success", true).
					Send()

				return nil
			},
				retry.LastErrorOnly(true),
				retry.DelayType(retry.FixedDelay),
				retry.Delay(time.Millisecond),
				retry.Attempts(2),
				retry.RetryIf(func(err error) bool {
					lastErr = err

					return errors.Is(err, appstore.ErrPasswordTokenExpired)
				}),
			)
		},
	}

	cmd.Flags().Int64VarP(&appID, "app-id", "i", 0, "ID of the target iOS app (required)")
	cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "The bundle identifier of the target iOS app (overrides the app ID)")
	cmd.Flags().StringVar(&externalVersionID, "external-version-id", "", "External version identifier of the target iOS app (required)")

	_ = cmd.MarkFlagRequired("external-version-id")

	return cmd
}


================================================
FILE: cmd/list_versions.go
================================================
package cmd

import (
	"errors"
	"time"

	"github.com/avast/retry-go"
	"github.com/majd/ipatool/v2/pkg/appstore"
	"github.com/spf13/cobra"
)

// nolint:wrapcheck
func ListVersionsCmd() *cobra.Command {
	var (
		appID    int64
		bundleID string
	)

	cmd := &cobra.Command{
		Use:   "list-versions",
		Short: "List the available versions of an iOS app",
		RunE: func(cmd *cobra.Command, args []string) error {
			if appID == 0 && bundleID == "" {
				return errors.New("either the app ID or the bundle identifier must be specified")
			}

			var lastErr error
			var acc appstore.Account

			return retry.Do(func() error {
				infoResult, err := dependencies.AppStore.AccountInfo()
				if err != nil {
					return err
				}

				acc = infoResult.Account

				if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) {
					loginResult, err := dependencies.AppStore.Login(appstore.LoginInput{Email: acc.Email, Password: acc.Password})
					if err != nil {
						return err
					}

					acc = loginResult.Account
				}

				app := appstore.App{ID: appID}
				if bundleID != "" {
					lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{Account: acc, BundleID: bundleID})
					if err != nil {
						return err
					}

					app = lookupResult.App
				}

				out, err := dependencies.AppStore.ListVersions(appstore.ListVersionsInput{Account: acc, App: app})
				if err != nil {
					return err
				}

				dependencies.Logger.Log().
					Interface("externalVersionIdentifiers", out.ExternalVersionIdentifiers).
					Str("bundleID", app.BundleID).
					Bool("success", true).
					Send()

				return nil
			},
				retry.LastErrorOnly(true),
				retry.DelayType(retry.FixedDelay),
				retry.Delay(time.Millisecond),
				retry.Attempts(2),
				retry.RetryIf(func(err error) bool {
					lastErr = err

					return errors.Is(err, appstore.ErrPasswordTokenExpired)
				}),
			)
		},
	}

	cmd.Flags().Int64VarP(&appID, "app-id", "i", 0, "ID of the target iOS app (required)")
	cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "The bundle identifier of the target iOS app (overrides the app ID)")

	return cmd
}


================================================
FILE: cmd/output_format.go
================================================
package cmd

import (
	"fmt"

	"github.com/thediveo/enumflag/v2"
)

type OutputFormat enumflag.Flag

const (
	OutputFormatText OutputFormat = iota
	OutputFormatJSON
)

func OutputFormatFromString(value string) (OutputFormat, error) {
	switch value {
	case "json":
		return OutputFormatJSON, nil
	case "text":
		return OutputFormatText, nil
	default:
		return OutputFormatJSON, fmt.Errorf("invalid output format '%s'", value)
	}
}


================================================
FILE: cmd/purchase.go
================================================
package cmd

import (
	"errors"
	"time"

	"github.com/avast/retry-go"
	"github.com/majd/ipatool/v2/pkg/appstore"
	"github.com/spf13/cobra"
)

// nolint:wrapcheck
func purchaseCmd() *cobra.Command {
	var bundleID string

	cmd := &cobra.Command{
		Use:   "purchase",
		Short: "Obtain a license for the app from the App Store",
		RunE: func(cmd *cobra.Command, args []string) error {
			var lastErr error
			var acc appstore.Account

			return retry.Do(func() error {
				infoResult, err := dependencies.AppStore.AccountInfo()
				if err != nil {
					return err
				}

				acc = infoResult.Account

				if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) {
					loginResult, err := dependencies.AppStore.Login(appstore.LoginInput{Email: acc.Email, Password: acc.Password})
					if err != nil {
						return err
					}

					acc = loginResult.Account
				}

				lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{Account: acc, BundleID: bundleID})
				if err != nil {
					return err
				}

				err = dependencies.AppStore.Purchase(appstore.PurchaseInput{Account: acc, App: lookupResult.App})
				if err != nil {
					return err
				}

				dependencies.Logger.Log().Bool("success", true).Send()

				return nil
			},
				retry.LastErrorOnly(true),
				retry.DelayType(retry.FixedDelay),
				retry.Delay(time.Millisecond),
				retry.Attempts(2),
				retry.RetryIf(func(err error) bool {
					lastErr = err

					return errors.Is(err, appstore.ErrPasswordTokenExpired)
				}),
			)
		},
	}

	cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "Bundle identifier of the target iOS app (required)")
	_ = cmd.MarkFlagRequired("bundle-identifier")

	return cmd
}


================================================
FILE: cmd/root.go
================================================
package cmd

import (
	"errors"
	"reflect"

	"github.com/majd/ipatool/v2/pkg/appstore"
	"github.com/spf13/cobra"
	"github.com/thediveo/enumflag/v2"
	"golang.org/x/net/context"
)

var version = "dev"

func rootCmd() *cobra.Command {
	var (
		verbose        bool
		nonInteractive bool
		format         OutputFormat
	)

	cmd := &cobra.Command{
		Use:           "ipatool",
		Short:         "A cli tool for interacting with Apple's ipa files",
		SilenceErrors: true,
		SilenceUsage:  true,
		Version:       version,
		PersistentPreRun: func(cmd *cobra.Command, args []string) {
			ctx := context.WithValue(context.Background(), "interactive", !nonInteractive)
			cmd.SetContext(ctx)
			initWithCommand(cmd)
		},
	}

	cmd.PersistentFlags().VarP(
		enumflag.New(&format, "format", map[OutputFormat][]string{
			OutputFormatText: {"text"},
			OutputFormatJSON: {"json"},
		}, enumflag.EnumCaseSensitive), "format", "", "sets output format for command; can be 'text', 'json'")
	cmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "enables verbose logs")
	cmd.PersistentFlags().BoolVarP(&nonInteractive, "non-interactive", "", false, "run in non-interactive session")
	cmd.PersistentFlags().StringVar(&keychainPassphrase, "keychain-passphrase", "", "passphrase for unlocking keychain")

	cmd.AddCommand(authCmd())
	cmd.AddCommand(downloadCmd())
	cmd.AddCommand(purchaseCmd())
	cmd.AddCommand(searchCmd())
	cmd.AddCommand(ListVersionsCmd())
	cmd.AddCommand(getVersionMetadataCmd())

	return cmd
}

// Execute runs the program and returns the appropriate exit status code.
func Execute() int {
	cmd := rootCmd()
	err := cmd.Execute()

	if err != nil {
		if reflect.ValueOf(dependencies).IsZero() {
			initWithCommand(cmd)
		}

		var appstoreErr *appstore.Error
		if errors.As(err, &appstoreErr) {
			dependencies.Logger.Verbose().Stack().
				Err(err).
				Interface("metadata", appstoreErr.Metadata).
				Send()
		} else {
			dependencies.Logger.Verbose().Stack().Err(err).Send()
		}

		dependencies.Logger.Error().
			Err(err).
			Bool("success", false).
			Send()

		return 1
	}

	return 0
}


================================================
FILE: cmd/search.go
================================================
package cmd

import (
	"github.com/majd/ipatool/v2/pkg/appstore"
	"github.com/spf13/cobra"
)

// nolint:wrapcheck
func searchCmd() *cobra.Command {
	var limit int64

	cmd := &cobra.Command{
		Use:   "search <term>",
		Short: "Search for iOS apps available on the App Store",
		Args:  cobra.ExactArgs(1),
		RunE: func(cmd *cobra.Command, args []string) error {
			infoResult, err := dependencies.AppStore.AccountInfo()
			if err != nil {
				return err
			}

			output, err := dependencies.AppStore.Search(appstore.SearchInput{
				Account: infoResult.Account,
				Term:    args[0],
				Limit:   limit,
			})
			if err != nil {
				return err
			}

			dependencies.Logger.Log().
				Int("count", output.Count).
				Array("apps", appstore.Apps(output.Results)).
				Send()

			return nil
		},
	}

	cmd.Flags().Int64VarP(&limit, "limit", "l", 5, "maximum amount of search results to retrieve")

	return cmd
}


================================================
FILE: go.mod
================================================
module github.com/majd/ipatool/v2

go 1.23.0

toolchain go1.23.2

require (
	github.com/99designs/keyring v1.2.1
	github.com/avast/retry-go v3.0.0+incompatible
	github.com/juju/persistent-cookiejar v1.0.0
	github.com/onsi/ginkgo/v2 v2.5.0
	github.com/onsi/gomega v1.24.0
	github.com/rs/zerolog v1.28.0
	github.com/schollz/progressbar/v3 v3.13.1
	github.com/spf13/cobra v1.6.1
	github.com/thediveo/enumflag/v2 v2.0.1
	go.uber.org/mock v0.4.0
	golang.org/x/net v0.38.0
	golang.org/x/term v0.30.0
	howett.net/plist v1.0.0
)

require (
	github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
	github.com/danieljoos/wincred v1.1.2 // indirect
	github.com/dvsekhvalnov/jose2go v1.7.0 // indirect
	github.com/go-logr/logr v1.2.3 // indirect
	github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
	github.com/google/go-cmp v0.6.0 // indirect
	github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
	github.com/inconshreveable/mousetrap v1.0.1 // indirect
	github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a // indirect
	github.com/mattn/go-colorable v0.1.13 // indirect
	github.com/mattn/go-isatty v0.0.17 // indirect
	github.com/mattn/go-runewidth v0.0.14 // indirect
	github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
	github.com/mtibben/percent v0.2.1 // indirect
	github.com/pkg/errors v0.9.1 // indirect
	github.com/rivo/uniseg v0.2.0 // indirect
	github.com/spf13/pflag v1.0.5 // indirect
	golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect
	golang.org/x/mod v0.17.0 // indirect
	golang.org/x/sys v0.31.0 // indirect
	golang.org/x/text v0.23.0 // indirect
	golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
	gopkg.in/errgo.v1 v1.0.1 // indirect
	gopkg.in/retry.v1 v1.0.3 // indirect
	gopkg.in/yaml.v3 v3.0.1 // indirect
)


================================================
FILE: go.sum
================================================
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4=
github.com/99designs/keyring v1.2.1 h1:tYLp1ULvO7i3fI5vE21ReQuj99QFSs7lGm0xWyJo87o=
github.com/99designs/keyring v1.2.1/go.mod h1:fc+wB5KTk9wQ9sDx0kFXB3A0MaeGHM9AwRStKOQ5vOA=
github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0=
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0=
github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dvsekhvalnov/jose2go v1.7.0 h1:bnQc8+GMnidJZA8zc6lLEAb4xNrIqHwO+9TzqvtQZPo=
github.com/dvsekhvalnov/jose2go v1.7.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
github.com/frankban/quicktest v1.2.2 h1:xfmOhhoH5fGPgbEAlhLpJH9p0z/0Qizio9osmvn9IUY=
github.com/frankban/quicktest v1.2.2/go.mod h1:Qh/WofXFeiAFII1aEBu529AtJo6Zg2VHscnEsbBnJ20=
github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0=
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.2.1-0.20190312032427-6f77996f0c42/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU=
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0=
github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a h1:45JtCyuNYE+QN9aPuR1ID9++BQU+NMTMudHSuaK0Las=
github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a/go.mod h1:RVHtZuvrpETIepiNUrNlih2OynoFf1eM6DGC6dloXzk=
github.com/juju/persistent-cookiejar v1.0.0 h1:Ag7+QLzqC2m+OYXy2QQnRjb3gTkEBSZagZ6QozwT3EQ=
github.com/juju/persistent-cookiejar v1.0.0/go.mod h1:zrbmo4nBKaiP/Ez3F67ewkMbzGYfXyMvRtbOfuAwG0w=
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs=
github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/onsi/ginkgo/v2 v2.5.0 h1:TRtrvv2vdQqzkwrQ1ke6vtXf7IK34RBUJafIy1wMwls=
github.com/onsi/ginkgo/v2 v2.5.0/go.mod h1:Luc4sArBICYCS8THh8v3i3i5CuSZO+RaQRaJoeNwomw=
github.com/onsi/gomega v1.24.0 h1:+0glovB9Jd6z3VR+ScSwQqXVTIfJcGA9UBM8yzQxhqg=
github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/clock v0.0.0-20190514195947-2896927a307a h1:3QH7VyOaaiUHNrA9Se4YQIRkDTCw1EJls9xTUCaCeRM=
github.com/rogpeppe/clock v0.0.0-20190514195947-2896927a307a/go.mod h1:4r5QyqhjIWCcK8DO4KMclc5Iknq5qVBAlbYYzAbUScQ=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY=
github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/schollz/progressbar/v3 v3.13.1 h1:o8rySDYiQ59Mwzy2FELeHY5ZARXZTVJC7iHD6PEFUiE=
github.com/schollz/progressbar/v3 v3.13.1/go.mod h1:xvrbki8kfT1fzWzBT/UZd9L6GA+jdL7HAgq2RFnO6fQ=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As=
github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/thediveo/enumflag/v2 v2.0.1 h1:2bmWZPD2uSARDsOjXIdLRlNcYBFNF9xX0RNUNF2vKic=
github.com/thediveo/enumflag/v2 v2.0.1/go.mod h1:SyxyCNvv0QeRtZ7fjuaUz4FRLC3cWuDiD7QdORU0MGg=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e h1:+WEEuIdZHnUeJJmEUjyYC2gfUMj69yZXw17EnHg/otA=
golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v1 v1.0.1 h1:oQFRXzZ7CkBGdm1XZm/EbQYaYNNEElNBOd09M6cqNso=
gopkg.in/errgo.v1 v1.0.1/go.mod h1:3NjfXwocQRYAPTq4/fzX+CwUhPRcR/azYRhj8G+LqMo=
gopkg.in/retry.v1 v1.0.3 h1:a9CArYczAVv6Qs6VGoLMio99GEs7kY9UzSF9+LD+iGs=
gopkg.in/retry.v1 v1.0.3/go.mod h1:FJkXmWiMaAo7xB+xhvDF59zhfjDWyzmyAxiT4dB688g=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=


================================================
FILE: main.go
================================================
package main

import (
	"os"

	"github.com/majd/ipatool/v2/cmd"
)

func main() {
	os.Exit(cmd.Execute())
}


================================================
FILE: pkg/appstore/account.go
================================================
package appstore

type Account struct {
	Email               string `json:"email,omitempty"`
	PasswordToken       string `json:"passwordToken,omitempty"`
	DirectoryServicesID string `json:"directoryServicesIdentifier,omitempty"`
	Name                string `json:"name,omitempty"`
	StoreFront          string `json:"storeFront,omitempty"`
	Password            string `json:"password,omitempty"`
	Pod                 string `json:"pod,omitempty"`
}


================================================
FILE: pkg/appstore/app.go
================================================
package appstore

import (
	"github.com/rs/zerolog"
)

type App struct {
	ID       int64   `json:"trackId,omitempty"`
	BundleID string  `json:"bundleId,omitempty"`
	Name     string  `json:"trackName,omitempty"`
	Version  string  `json:"version,omitempty"`
	Price    float64 `json:"price,omitempty"`
}

type VersionHistoryInfo struct {
	App                App
	LatestVersion      string
	VersionIdentifiers []string
}

type VersionDetails struct {
	VersionID     string
	VersionString string
	Success       bool
	Error         string
}

type Apps []App

func (apps Apps) MarshalZerologArray(a *zerolog.Array) {
	for _, app := range apps {
		a.Object(app)
	}
}

func (a App) MarshalZerologObject(event *zerolog.Event) {
	event.
		Int64("id", a.ID).
		Str("bundleID", a.BundleID).
		Str("name", a.Name).
		Str("version", a.Version).
		Float64("price", a.Price)
}


================================================
FILE: pkg/appstore/app_test.go
================================================
package appstore

import (
	"bytes"
	"encoding/json"

	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	"github.com/rs/zerolog"
)

var _ = Describe("App", func() {
	It("marshals apps array", func() {
		apps := Apps{
			{
				ID:       42,
				BundleID: "app.bundle.id",
				Name:     "app name",
				Version:  "1.0",
				Price:    0,
			},
			{
				ID:       1,
				BundleID: "app.bundle.id2",
				Name:     "app name2",
				Version:  "2.0",
				Price:    0.99,
			},
		}

		buffer := bytes.NewBuffer([]byte{})
		logger := zerolog.New(buffer)
		event := logger.Log().Array("apps", apps)
		event.Send()

		var out map[string]interface{}
		err := json.Unmarshal(buffer.Bytes(), &out)
		Expect(err).ToNot(HaveOccurred())
		Expect(out["apps"]).To(HaveLen(2))
	})

	It("marshalls app object", func() {
		app := App{
			ID:       42,
			BundleID: "app.bundle.id",
			Name:     "app name",
			Version:  "1.0",
			Price:    0,
		}

		buffer := bytes.NewBuffer([]byte{})
		logger := zerolog.New(buffer)
		event := logger.Log()
		app.MarshalZerologObject(event)
		event.Send()

		var out map[string]interface{}
		err := json.Unmarshal(buffer.Bytes(), &out)
		Expect(err).ToNot(HaveOccurred())

		Expect(out["id"]).To(Equal(float64(42)))
		Expect(out["bundleID"]).To(Equal("app.bundle.id"))
		Expect(out["name"]).To(Equal("app name"))
		Expect(out["version"]).To(Equal("1.0"))
		Expect(out["price"]).To(Equal(float64(0)))
	})

	It("formats ipa name correctly", func() {
		app := App{
			ID:       42,
			BundleID: "app.bundle-id1",
			Name:     "      some  app&symb.ols2  !!!",
			Version:  "1.0",
			Price:    0,
		}

		Expect(fileName(app, "1.0")).To(Equal("app.bundle-id1_42_1.0.ipa"))
	})
})


================================================
FILE: pkg/appstore/appstore.go
================================================
package appstore

import (
	"github.com/majd/ipatool/v2/pkg/http"
	"github.com/majd/ipatool/v2/pkg/keychain"
	"github.com/majd/ipatool/v2/pkg/util/machine"
	"github.com/majd/ipatool/v2/pkg/util/operatingsystem"
)

type AppStore interface {
	// Login authenticates with the App Store.
	Login(input LoginInput) (LoginOutput, error)
	// AccountInfo returns the information of the authenticated account.
	AccountInfo() (AccountInfoOutput, error)
	// Revoke revokes the active credentials.
	Revoke() error
	// Lookup looks apps up based on the specified bundle identifier.
	Lookup(input LookupInput) (LookupOutput, error)
	// Search searches the App Store for apps matching the specified term.
	Search(input SearchInput) (SearchOutput, error)
	// Purchase acquires a license for the desired app.
	// Note: only free apps are supported.
	Purchase(input PurchaseInput) error
	// Download downloads the IPA package from the App Store to the desired location.
	Download(input DownloadInput) (DownloadOutput, error)
	// ReplicateSinf replicates the sinf for the IPA package.
	ReplicateSinf(input ReplicateSinfInput) error
	// VersionHistory lists the available versions of the specified app.
	ListVersions(input ListVersionsInput) (ListVersionsOutput, error)
	// GetVersionMetadata returns the metadata for the specified version.
	GetVersionMetadata(input GetVersionMetadataInput) (GetVersionMetadataOutput, error)
	// Bag fetches the bag which contains endpoint definitions.
	Bag(input BagInput) (BagOutput, error)
}

type appstore struct {
	keychain       keychain.Keychain
	loginClient    http.Client[loginResult]
	searchClient   http.Client[searchResult]
	purchaseClient http.Client[purchaseResult]
	downloadClient http.Client[downloadResult]
	bagClient      http.Client[bagResult]
	httpClient     http.Client[interface{}]
	machine        machine.Machine
	os             operatingsystem.OperatingSystem
}

type Args struct {
	Keychain        keychain.Keychain
	CookieJar       http.CookieJar
	OperatingSystem operatingsystem.OperatingSystem
	Machine         machine.Machine
}

func NewAppStore(args Args) AppStore {
	clientArgs := http.Args{
		CookieJar: args.CookieJar,
	}

	return &appstore{
		keychain:       args.Keychain,
		loginClient:    http.NewClient[loginResult](clientArgs),
		searchClient:   http.NewClient[searchResult](clientArgs),
		purchaseClient: http.NewClient[purchaseResult](clientArgs),
		downloadClient: http.NewClient[downloadResult](clientArgs),
		bagClient:      http.NewClient[bagResult](clientArgs),
		httpClient:     http.NewClient[interface{}](clientArgs),
		machine:        args.Machine,
		os:             args.OperatingSystem,
	}
}


================================================
FILE: pkg/appstore/appstore_account_info.go
================================================
package appstore

import (
	"encoding/json"
	"fmt"
)

type AccountInfoOutput struct {
	Account Account
}

func (t *appstore) AccountInfo() (AccountInfoOutput, error) {
	data, err := t.keychain.Get("account")
	if err != nil {
		return AccountInfoOutput{}, fmt.Errorf("failed to get account: %w", err)
	}

	var acc Account

	err = json.Unmarshal(data, &acc)
	if err != nil {
		return AccountInfoOutput{}, fmt.Errorf("failed to unmarshal json: %w", err)
	}

	return AccountInfoOutput{
		Account: acc,
	}, nil
}


================================================
FILE: pkg/appstore/appstore_account_info_test.go
================================================
package appstore

import (
	"errors"
	"fmt"

	"github.com/majd/ipatool/v2/pkg/keychain"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	"go.uber.org/mock/gomock"
)

var _ = Describe("AppStore (AccountInfo)", func() {
	var (
		ctrl         *gomock.Controller
		appstore     AppStore
		mockKeychain *keychain.MockKeychain
	)

	BeforeEach(func() {
		ctrl = gomock.NewController(GinkgoT())
		mockKeychain = keychain.NewMockKeychain(ctrl)
		appstore = NewAppStore(Args{
			Keychain: mockKeychain,
		})
	})

	AfterEach(func() {
		ctrl.Finish()
	})

	When("keychain returns valid data", func() {
		const (
			testEmail = "test-email"
			testName  = "test-name"
		)

		BeforeEach(func() {
			mockKeychain.EXPECT().
				Get("account").
				Return([]byte(fmt.Sprintf("{\"email\": \"%s\", \"name\": \"%s\"}", testEmail, testName)), nil)
		})

		It("returns output", func() {
			out, err := appstore.AccountInfo()
			Expect(err).ToNot(HaveOccurred())
			Expect(out.Account.Email).To(Equal(testEmail))
			Expect(out.Account.Name).To(Equal(testName))
		})
	})

	When("keychain returns error", func() {
		BeforeEach(func() {
			mockKeychain.EXPECT().
				Get("account").
				Return([]byte{}, errors.New(""))
		})

		It("returns wrapped error", func() {
			_, err := appstore.AccountInfo()
			Expect(err).To(HaveOccurred())
		})
	})

	When("keychain returns invalid data", func() {
		BeforeEach(func() {
			mockKeychain.EXPECT().
				Get("account").
				Return([]byte("..."), nil)
		})

		It("fails to unmarshall JSON data", func() {
			_, err := appstore.AccountInfo()
			Expect(err).To(HaveOccurred())
		})
	})
})


================================================
FILE: pkg/appstore/appstore_bag.go
================================================
package appstore

import (
	"fmt"
	gohttp "net/http"
	"strings"

	"github.com/majd/ipatool/v2/pkg/http"
)

type BagInput struct{}

type BagOutput struct {
	AuthEndpoint string
}

func (t *appstore) Bag(input BagInput) (BagOutput, error) {
	macAddr, err := t.machine.MacAddress()
	if err != nil {
		return BagOutput{}, fmt.Errorf("failed to get mac address: %w", err)
	}

	guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "")
	req := t.bagRequest(guid)

	res, err := t.bagClient.Send(req)
	if err != nil {
		return BagOutput{}, fmt.Errorf("failed to send http request: %w", err)
	}

	if res.StatusCode != gohttp.StatusOK {
		return BagOutput{}, fmt.Errorf("received unexpected status code: %d", res.StatusCode)
	}

	return BagOutput{
		AuthEndpoint: res.Data.URLBag.AuthEndpoint,
	}, nil
}

type bagResult struct {
	URLBag urlBag `plist:"urlBag,omitempty"`
}

type urlBag struct {
	AuthEndpoint string `plist:"authenticateAccount,omitempty"`
}

func (*appstore) bagRequest(guid string) http.Request {
	return http.Request{
		URL:            fmt.Sprintf("https://%s%s?guid=%s", PrivateInitDomain, PrivateInitPath, guid),
		Method:         http.MethodGET,
		ResponseFormat: http.ResponseFormatXML,
		Headers: map[string]string{
			"Accept": "application/xml",
		},
	}
}


================================================
FILE: pkg/appstore/appstore_bag_test.go
================================================
package appstore

import (
	"errors"
	gohttp "net/http"

	"github.com/majd/ipatool/v2/pkg/http"
	"github.com/majd/ipatool/v2/pkg/util/machine"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	"go.uber.org/mock/gomock"
)

var _ = Describe("AppStore (Bag)", func() {
	var (
		ctrl          *gomock.Controller
		mockBagClient *http.MockClient[bagResult]
		mockMachine   *machine.MockMachine
		as            AppStore
	)

	BeforeEach(func() {
		ctrl = gomock.NewController(GinkgoT())
		mockBagClient = http.NewMockClient[bagResult](ctrl)
		mockMachine = machine.NewMockMachine(ctrl)
		as = &appstore{
			bagClient: mockBagClient,
			machine:   mockMachine,
		}
	})

	AfterEach(func() {
		ctrl.Finish()
	})

	When("fails to read machine MAC address", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("", errors.New("mac error"))
		})

		It("returns error", func() {
			_, err := as.Bag(BagInput{})
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("failed to get mac address"))
		})
	})

	When("request fails", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:11:22:33:44:55", nil)

			mockBagClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[bagResult]{}, errors.New("request error"))
		})

		It("returns wrapped error", func() {
			_, err := as.Bag(BagInput{})
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("failed to send http request"))
		})
	})

	When("request returns non-200 status code", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:11:22:33:44:55", nil)

			mockBagClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[bagResult]{
					StatusCode: gohttp.StatusForbidden,
				}, nil)
		})

		It("returns error", func() {
			_, err := as.Bag(BagInput{})
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("received unexpected status code"))
		})
	})

	When("request is successful", func() {
		const testAuthEndpoint = "https://example.com"

		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("aa:bb:cc:dd:ee:ff", nil)

			mockBagClient.EXPECT().
				Send(gomock.Any()).
				Do(func(req http.Request) {
					Expect(req.Method).To(Equal(http.MethodGET))
					Expect(req.URL).To(Equal("https://init.itunes.apple.com/bag.xml?guid=AABBCCDDEEFF"))
					Expect(req.ResponseFormat).To(Equal(http.ResponseFormatXML))
					Expect(req.Headers).To(HaveKeyWithValue("Accept", "application/xml"))
				}).
				Return(http.Result[bagResult]{
					StatusCode: gohttp.StatusOK,
					Data: bagResult{
						URLBag: urlBag{
							AuthEndpoint: testAuthEndpoint,
						},
					},
				}, nil)
		})

		It("returns output", func() {
			out, err := as.Bag(BagInput{})
			Expect(err).ToNot(HaveOccurred())
			Expect(out.AuthEndpoint).To(Equal(testAuthEndpoint))
		})
	})
})


================================================
FILE: pkg/appstore/appstore_download.go
================================================
package appstore

import (
	"archive/zip"
	"errors"
	"fmt"
	"io"
	"os"
	"strconv"
	"strings"

	"github.com/majd/ipatool/v2/pkg/http"
	"github.com/schollz/progressbar/v3"
	"howett.net/plist"
)

var (
	ErrLicenseRequired = errors.New("license is required")
)

type DownloadInput struct {
	Account           Account
	App               App
	OutputPath        string
	Progress          *progressbar.ProgressBar
	ExternalVersionID string
}

type DownloadOutput struct {
	DestinationPath string
	Sinfs           []Sinf
}

func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) {
	macAddr, err := t.machine.MacAddress()
	if err != nil {
		return DownloadOutput{}, fmt.Errorf("failed to get mac address: %w", err)
	}

	guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "")

	req := t.downloadRequest(input.Account, input.App, guid, input.ExternalVersionID)

	res, err := t.downloadClient.Send(req)
	if err != nil {
		return DownloadOutput{}, fmt.Errorf("failed to send http request: %w", err)
	}

	if res.Data.FailureType == FailureTypePasswordTokenExpired {
		return DownloadOutput{}, ErrPasswordTokenExpired
	}

	if res.Data.FailureType == FailureTypeLicenseNotFound {
		return DownloadOutput{}, ErrLicenseRequired
	}

	if res.Data.FailureType != "" && res.Data.CustomerMessage != "" {
		return DownloadOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.CustomerMessage), res)
	}

	if res.Data.FailureType != "" {
		return DownloadOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.FailureType), res)
	}

	if len(res.Data.Items) == 0 {
		return DownloadOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res)
	}

	item := res.Data.Items[0]

	version := "unknown"

	// Read the version from the item metadata
	if itemVersion, ok := item.Metadata["bundleShortVersionString"]; ok {
		version = fmt.Sprintf("%v", itemVersion)
	}

	destination, err := t.resolveDestinationPath(input.App, version, input.OutputPath)
	if err != nil {
		return DownloadOutput{}, fmt.Errorf("failed to resolve destination path: %w", err)
	}

	err = t.downloadFile(item.URL, fmt.Sprintf("%s.tmp", destination), input.Progress)
	if err != nil {
		return DownloadOutput{}, fmt.Errorf("failed to download file: %w", err)
	}

	err = t.applyPatches(item, input.Account, fmt.Sprintf("%s.tmp", destination), destination)
	if err != nil {
		return DownloadOutput{}, fmt.Errorf("failed to apply patches: %w", err)
	}

	err = t.os.Remove(fmt.Sprintf("%s.tmp", destination))
	if err != nil {
		return DownloadOutput{}, fmt.Errorf("failed to remove file: %w", err)
	}

	return DownloadOutput{
		DestinationPath: destination,
		Sinfs:           item.Sinfs,
	}, nil
}

type downloadItemResult struct {
	HashMD5  string                 `plist:"md5,omitempty"`
	URL      string                 `plist:"URL,omitempty"`
	Sinfs    []Sinf                 `plist:"sinfs,omitempty"`
	Metadata map[string]interface{} `plist:"metadata,omitempty"`
}

type downloadResult struct {
	FailureType     string               `plist:"failureType,omitempty"`
	CustomerMessage string               `plist:"customerMessage,omitempty"`
	Items           []downloadItemResult `plist:"songList,omitempty"`
}

func (t *appstore) downloadFile(src, dst string, progress *progressbar.ProgressBar) error {
	req, err := t.httpClient.NewRequest("GET", src, nil)
	if err != nil {
		return fmt.Errorf("failed to create request: %w", err)
	}

	file, err := t.os.OpenFile(dst, os.O_CREATE|os.O_RDWR, 0644)
	if err != nil {
		return fmt.Errorf("failed to open file: %w", err)
	}

	defer file.Close()

	stat, err := t.os.Stat(dst)
	if err != nil {
		return fmt.Errorf("failed to get file info: %w", err)
	}

	if req != nil && stat != nil {
		req.Header.Add("range", fmt.Sprintf("bytes=%d-", stat.Size()))
	}

	res, err := t.httpClient.Do(req)
	if err != nil {
		return fmt.Errorf("request failed: %w", err)
	}
	defer res.Body.Close()

	if progress != nil {
		progress.ChangeMax64(res.ContentLength + stat.Size())
		err = progress.Set64(stat.Size())

		if err != nil {
			return fmt.Errorf("can not set bar progress: %w", err)
		}

		_, err = file.Seek(0, io.SeekEnd)
		if err != nil {
			return fmt.Errorf("can not seek file: %w", err)
		}

		_, err = io.Copy(io.MultiWriter(file, progress), res.Body)
	} else {
		_, err = io.Copy(file, res.Body)
	}

	if err != nil {
		return fmt.Errorf("failed to write file: %w", err)
	}

	return nil
}

func (*appstore) downloadRequest(acc Account, app App, guid string, externalVersionID string) http.Request {
	payload := map[string]interface{}{
		"creditDisplay": "",
		"guid":          guid,
		"salableAdamId": app.ID,
	}

	if externalVersionID != "" {
		payload["externalVersionId"] = externalVersionID
	}

	podPrefix := ""
	if acc.Pod != "" {
		podPrefix = "p" + acc.Pod + "-"
	}

	return http.Request{
		URL:            fmt.Sprintf("https://%s%s%s?guid=%s", podPrefix, PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathDownload, guid),
		Method:         http.MethodPOST,
		ResponseFormat: http.ResponseFormatXML,
		Headers: map[string]string{
			"Content-Type": "application/x-apple-plist",
			"iCloud-DSID":  acc.DirectoryServicesID,
			"X-Dsid":       acc.DirectoryServicesID,
		},
		Payload: &http.XMLPayload{
			Content: payload,
		},
	}
}

func fileName(app App, version string) string {
	var parts []string

	if app.BundleID != "" {
		parts = append(parts, app.BundleID)
	}

	if app.ID != 0 {
		parts = append(parts, strconv.FormatInt(app.ID, 10))
	}

	if version != "" {
		parts = append(parts, version)
	}

	return fmt.Sprintf("%s.ipa", strings.Join(parts, "_"))
}

func (t *appstore) resolveDestinationPath(app App, version string, path string) (string, error) {
	file := fileName(app, version)

	if path == "" {
		workdir, err := t.os.Getwd()
		if err != nil {
			return "", fmt.Errorf("failed to get current directory: %w", err)
		}

		return fmt.Sprintf("%s/%s", workdir, file), nil
	}

	isDir, err := t.isDirectory(path)
	if err != nil {
		return "", fmt.Errorf("failed to determine whether path is a directory: %w", err)
	}

	if isDir {
		return fmt.Sprintf("%s/%s", path, file), nil
	}

	return path, nil
}

func (t *appstore) isDirectory(path string) (bool, error) {
	info, err := t.os.Stat(path)
	if err != nil && !os.IsNotExist(err) {
		return false, fmt.Errorf("failed to read file metadata: %w", err)
	}

	if info == nil {
		return false, nil
	}

	return info.IsDir(), nil
}

func (t *appstore) applyPatches(item downloadItemResult, acc Account, src, dst string) error {
	srcZip, err := zip.OpenReader(src)
	if err != nil {
		return fmt.Errorf("failed to open zip reader: %w", err)
	}
	defer srcZip.Close()

	dstFile, err := t.os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		return fmt.Errorf("failed to open file: %w", err)
	}

	dstZip := zip.NewWriter(dstFile)
	defer dstZip.Close()

	err = t.replicateZip(srcZip, dstZip)
	if err != nil {
		return fmt.Errorf("failed to replicate zip: %w", err)
	}

	err = t.writeMetadata(item.Metadata, acc, dstZip)
	if err != nil {
		return fmt.Errorf("failed to write metadata: %w", err)
	}

	return nil
}

func (t *appstore) writeMetadata(metadata map[string]interface{}, acc Account, zip *zip.Writer) error {
	metadata["apple-id"] = acc.Email
	metadata["userName"] = acc.Email

	metadataFile, err := zip.Create("iTunesMetadata.plist")
	if err != nil {
		return fmt.Errorf("failed to create file: %w", err)
	}

	data, err := plist.Marshal(metadata, plist.BinaryFormat)
	if err != nil {
		return fmt.Errorf("failed to marshal data: %w", err)
	}

	_, err = metadataFile.Write(data)
	if err != nil {
		return fmt.Errorf("failed to write data: %w", err)
	}

	return nil
}


================================================
FILE: pkg/appstore/appstore_download_test.go
================================================
package appstore

import (
	"archive/zip"
	"errors"
	"fmt"
	"io"
	"io/fs"
	gohttp "net/http"
	"os"
	"strings"
	"time"

	"github.com/majd/ipatool/v2/pkg/http"
	"github.com/majd/ipatool/v2/pkg/keychain"
	"github.com/majd/ipatool/v2/pkg/util/machine"
	"github.com/majd/ipatool/v2/pkg/util/operatingsystem"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	"go.uber.org/mock/gomock"
	"howett.net/plist"
)

type dummyFileInfo struct{}

func (d *dummyFileInfo) Name() string       { return "dummy" }
func (d *dummyFileInfo) Size() int64        { return 0 }
func (d *dummyFileInfo) Mode() fs.FileMode  { return 0 }
func (d *dummyFileInfo) ModTime() time.Time { return time.Time{} }
func (d *dummyFileInfo) IsDir() bool        { return false }
func (d *dummyFileInfo) Sys() interface{}   { return nil }

var _ = Describe("AppStore (Download)", func() {
	var (
		ctrl               *gomock.Controller
		mockKeychain       *keychain.MockKeychain
		mockDownloadClient *http.MockClient[downloadResult]
		mockPurchaseClient *http.MockClient[purchaseResult]
		mockLoginClient    *http.MockClient[loginResult]
		mockHTTPClient     *http.MockClient[interface{}]
		mockOS             *operatingsystem.MockOperatingSystem
		mockMachine        *machine.MockMachine
		as                 AppStore
	)

	BeforeEach(func() {
		ctrl = gomock.NewController(GinkgoT())
		mockKeychain = keychain.NewMockKeychain(ctrl)
		mockDownloadClient = http.NewMockClient[downloadResult](ctrl)
		mockLoginClient = http.NewMockClient[loginResult](ctrl)
		mockPurchaseClient = http.NewMockClient[purchaseResult](ctrl)
		mockHTTPClient = http.NewMockClient[interface{}](ctrl)
		mockOS = operatingsystem.NewMockOperatingSystem(ctrl)
		mockMachine = machine.NewMockMachine(ctrl)
		as = &appstore{
			keychain:       mockKeychain,
			loginClient:    mockLoginClient,
			purchaseClient: mockPurchaseClient,
			downloadClient: mockDownloadClient,
			httpClient:     mockHTTPClient,
			machine:        mockMachine,
			os:             mockOS,
		}
	})

	AfterEach(func() {
		ctrl.Finish()
	})

	When("fails to read MAC address", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("", errors.New(""))
		})

		It("returns error", func() {
			_, err := as.Download(DownloadInput{})
			Expect(err).To(HaveOccurred())
		})
	})

	When("request fails", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[downloadResult]{}, errors.New(""))
		})

		It("returns error", func() {
			_, err := as.Download(DownloadInput{})
			Expect(err).To(HaveOccurred())
		})
	})

	When("request uses a custom pod", func() {
		const (
			testPod  = "42"
			testGUID = "001122334455"
		)

		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:11:22:33:44:55", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Do(func(req http.Request) {
					expectedURL := "https://p" + testPod + "-" + PrivateAppStoreAPIDomain + PrivateAppStoreAPIPathDownload + "?guid=" + testGUID
					Expect(req.URL).To(Equal(expectedURL))
				}).
				Return(http.Result[downloadResult]{}, errors.New(""))
		})

		It("sends the download request to the pod-specific host", func() {
			_, err := as.Download(DownloadInput{
				Account: Account{
					Pod: testPod,
				},
			})
			Expect(err).To(HaveOccurred())
		})
	})

	When("password token is expired", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[downloadResult]{
					Data: downloadResult{
						FailureType: FailureTypePasswordTokenExpired,
					},
				}, nil)
		})

		It("returns error", func() {
			_, err := as.Download(DownloadInput{})
			Expect(err).To(HaveOccurred())
		})
	})

	When("license is missing", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[downloadResult]{
					Data: downloadResult{
						FailureType: FailureTypeLicenseNotFound,
					},
				}, nil)
		})

		It("returns error", func() {
			_, err := as.Download(DownloadInput{})
			Expect(err).To(HaveOccurred())
		})
	})

	When("store API returns error", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("", nil)
		})

		When("response contains customer message", func() {
			BeforeEach(func() {
				mockDownloadClient.EXPECT().
					Send(gomock.Any()).
					Return(http.Result[downloadResult]{
						Data: downloadResult{
							FailureType:     "test-failure",
							CustomerMessage: errors.New("").Error(),
						},
					}, nil)
			})

			It("returns customer message as error", func() {
				_, err := as.Download(DownloadInput{})
				Expect(err).To(HaveOccurred())
			})
		})

		When("response does not contain customer message", func() {
			BeforeEach(func() {
				mockDownloadClient.EXPECT().
					Send(gomock.Any()).
					Return(http.Result[downloadResult]{
						Data: downloadResult{
							FailureType: "test-failure",
						},
					}, nil)
			})

			It("returns generic error", func() {
				_, err := as.Download(DownloadInput{})
				Expect(err).To(HaveOccurred())
			})
		})
	})

	When("store API returns no items", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[downloadResult]{
					Data: downloadResult{
						Items: []downloadItemResult{},
					},
				}, nil)
		})

		It("returns error", func() {
			_, err := as.Download(DownloadInput{})
			Expect(err).To(HaveOccurred())
		})
	})

	When("fails to resolve output path", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[downloadResult]{
					Data: downloadResult{
						Items: []downloadItemResult{{}},
					},
				}, nil)

			mockOS.EXPECT().
				Stat(gomock.Any()).
				Return(nil, errors.New(""))
		})

		It("returns error", func() {
			_, err := as.Download(DownloadInput{
				OutputPath: "test-out",
			})
			Expect(err).To(HaveOccurred())
		})
	})

	When("fails to download file", func() {
		BeforeEach(func() {

			mockOS.EXPECT().
				Getwd().
				Return("", nil)

			mockMachine.EXPECT().
				MacAddress().
				Return("", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[downloadResult]{
					Data: downloadResult{
						Items: []downloadItemResult{{}},
					},
				}, nil)
		})

		When("fails to create download request", func() {
			BeforeEach(func() {
				mockHTTPClient.EXPECT().
					NewRequest("GET", gomock.Any(), nil).
					Return(nil, errors.New(""))
			})

			It("returns error", func() {
				_, err := as.Download(DownloadInput{})
				Expect(err).To(HaveOccurred())
			})
		})

		When("fails to open file", func() {
			BeforeEach(func() {
				mockHTTPClient.EXPECT().
					NewRequest("GET", gomock.Any(), nil).
					Return(nil, nil)

				mockOS.EXPECT().
					OpenFile(gomock.Any(), gomock.Any(), gomock.Any()).
					Return(nil, errors.New(""))
			})

			It("returns error", func() {
				_, err := as.Download(DownloadInput{})
				Expect(err).To(HaveOccurred())
			})
		})

		When("fails to get file info", func() {
			BeforeEach(func() {
				mockHTTPClient.EXPECT().
					NewRequest("GET", gomock.Any(), nil).
					Return(nil, nil)

				mockOS.EXPECT().
					OpenFile(gomock.Any(), gomock.Any(), gomock.Any()).
					Return(nil, nil)

				mockOS.EXPECT().
					Stat(gomock.Any()).
					Return(&dummyFileInfo{}, errors.New(""))

			})

			It("returns error", func() {
				_, err := as.Download(DownloadInput{})
				Expect(err).To(HaveOccurred())
			})
		})

		When("request fails", func() {
			BeforeEach(func() {
				mockHTTPClient.EXPECT().
					NewRequest("GET", gomock.Any(), nil).
					Return(&gohttp.Request{Header: map[string][]string{}}, nil)

				mockOS.EXPECT().
					OpenFile(gomock.Any(), gomock.Any(), gomock.Any()).
					Return(nil, nil)

				mockOS.EXPECT().
					Stat(gomock.Any()).
					Return(&dummyFileInfo{}, nil)

				mockHTTPClient.EXPECT().
					Do(gomock.Any()).
					Return(&gohttp.Response{Body: io.NopCloser(strings.NewReader(""))}, errors.New(""))
			})

			It("returns error", func() {
				_, err := as.Download(DownloadInput{})
				Expect(err).To(HaveOccurred())
			})
		})

		When("fails to write data to file", func() {
			BeforeEach(func() {
				mockHTTPClient.EXPECT().
					NewRequest("GET", gomock.Any(), nil).
					Return(&gohttp.Request{Header: map[string][]string{}}, nil)

				mockOS.EXPECT().
					OpenFile(gomock.Any(), gomock.Any(), gomock.Any()).
					Return(nil, nil)

				mockOS.EXPECT().
					Stat(gomock.Any()).
					Return(&dummyFileInfo{}, nil)

				mockHTTPClient.EXPECT().
					Do(gomock.Any()).
					Return(&gohttp.Response{
						Body: io.NopCloser(strings.NewReader("ping")),
					}, nil)

			})

			It("returns error", func() {
				_, err := as.Download(DownloadInput{})
				Expect(err).To(HaveOccurred())
			})
		})

	})

	When("successfully downloads file", func() {
		var testFile *os.File

		BeforeEach(func() {
			var err error
			testFile, err = os.CreateTemp("", "test_file")
			Expect(err).ToNot(HaveOccurred())

			mockMachine.EXPECT().
				MacAddress().
				Return("", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[downloadResult]{
					Data: downloadResult{
						Items: []downloadItemResult{
							{
								Metadata: map[string]interface{}{
									"bundleShortVersionString": "xyz",
								},
								Sinfs: []Sinf{
									{
										ID:   0,
										Data: []byte("test-sinf-data"),
									},
								},
							},
						},
					},
				}, nil)

			mockHTTPClient.EXPECT().
				NewRequest("GET", gomock.Any(), nil).
				Return(&gohttp.Request{Header: map[string][]string{}}, nil)

			mockOS.EXPECT().
				OpenFile(gomock.Any(), gomock.Any(), gomock.Any()).
				Return(testFile, nil)

			mockOS.EXPECT().
				Stat(gomock.Any()).
				Return(&dummyFileInfo{}, nil)

			mockHTTPClient.EXPECT().
				Do(gomock.Any()).
				Return(&gohttp.Response{
					Body: io.NopCloser(strings.NewReader("ping")),
				}, nil)
		})

		AfterEach(func() {
			err := os.Remove(testFile.Name())
			Expect(err).ToNot(HaveOccurred())
		})

		It("writes data to file", func() {
			mockOS.EXPECT().
				Getwd().
				Return("", nil)

			_, err := as.Download(DownloadInput{})
			Expect(err).To(HaveOccurred())

			testData, err := os.ReadFile(testFile.Name())
			Expect(err).ToNot(HaveOccurred())
			Expect(string(testData)).To(Equal("ping"))
		})

		When("successfully applies patches", func() {
			var (
				tmpFile    *os.File
				outputPath string
			)

			BeforeEach(func() {

				var err error
				tmpFile, err = os.OpenFile(fmt.Sprintf("%s.tmp", testFile.Name()), os.O_CREATE|os.O_WRONLY, 0644)
				Expect(err).ToNot(HaveOccurred())

				outputPath = strings.TrimSuffix(tmpFile.Name(), ".tmp")

				mockOS.EXPECT().
					OpenFile(gomock.Any(), gomock.Any(), gomock.Any()).
					DoAndReturn(os.OpenFile)

				mockOS.EXPECT().
					Stat(gomock.Any()).
					Return(nil, nil)

				mockOS.EXPECT().
					Remove(tmpFile.Name()).
					Return(nil)

				zipFile := zip.NewWriter(tmpFile)
				w, err := zipFile.Create("Payload/Test.app/Info.plist")
				Expect(err).ToNot(HaveOccurred())

				info, err := plist.Marshal(map[string]interface{}{
					"CFBundleExecutable": "Test",
				}, plist.BinaryFormat)
				Expect(err).ToNot(HaveOccurred())

				_, err = w.Write(info)
				Expect(err).ToNot(HaveOccurred())

				err = zipFile.Close()
				Expect(err).ToNot(HaveOccurred())
			})

			AfterEach(func() {
				err := os.Remove(tmpFile.Name())
				Expect(err).ToNot(HaveOccurred())
			})

			It("succeeds", func() {
				out, err := as.Download(DownloadInput{
					OutputPath: outputPath,
				})
				Expect(err).ToNot(HaveOccurred())
				Expect(out.DestinationPath).ToNot(BeEmpty())
			})
		})
	})
})


================================================
FILE: pkg/appstore/appstore_get_version_metadata.go
================================================
package appstore

import (
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/majd/ipatool/v2/pkg/http"
)

type GetVersionMetadataInput struct {
	Account   Account
	App       App
	VersionID string
}

type GetVersionMetadataOutput struct {
	DisplayVersion string
	ReleaseDate    time.Time
}

func (t *appstore) GetVersionMetadata(input GetVersionMetadataInput) (GetVersionMetadataOutput, error) {
	macAddr, err := t.machine.MacAddress()
	if err != nil {
		return GetVersionMetadataOutput{}, fmt.Errorf("failed to get mac address: %w", err)
	}

	guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "")

	req := t.getVersionMetadataRequest(input.Account, input.App, guid, input.VersionID)
	res, err := t.downloadClient.Send(req)

	if err != nil {
		return GetVersionMetadataOutput{}, fmt.Errorf("failed to send http request: %w", err)
	}

	if res.Data.FailureType == FailureTypePasswordTokenExpired {
		return GetVersionMetadataOutput{}, ErrPasswordTokenExpired
	}

	if res.Data.FailureType == FailureTypeLicenseNotFound {
		return GetVersionMetadataOutput{}, ErrLicenseRequired
	}

	if res.Data.FailureType != "" && res.Data.CustomerMessage != "" {
		return GetVersionMetadataOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.CustomerMessage), res)
	}

	if res.Data.FailureType != "" {
		return GetVersionMetadataOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.FailureType), res)
	}

	if len(res.Data.Items) == 0 {
		return GetVersionMetadataOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res)
	}

	item := res.Data.Items[0]

	releaseDate, err := time.Parse(time.RFC3339, fmt.Sprintf("%v", item.Metadata["releaseDate"]))
	if err != nil {
		return GetVersionMetadataOutput{}, fmt.Errorf("failed to parse release date: %w", err)
	}

	return GetVersionMetadataOutput{
		DisplayVersion: fmt.Sprintf("%v", item.Metadata["bundleShortVersionString"]),
		ReleaseDate:    releaseDate,
	}, nil
}

func (t *appstore) getVersionMetadataRequest(acc Account, app App, guid string, version string) http.Request {
	payload := map[string]interface{}{
		"creditDisplay":     "",
		"guid":              guid,
		"salableAdamId":     app.ID,
		"externalVersionId": version,
	}

	podPrefix := ""
	if acc.Pod != "" {
		podPrefix = "p" + acc.Pod + "-"
	}

	return http.Request{
		URL:            fmt.Sprintf("https://%s%s%s?guid=%s", podPrefix, PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathDownload, guid),
		Method:         http.MethodPOST,
		ResponseFormat: http.ResponseFormatXML,
		Headers: map[string]string{
			"Content-Type": "application/x-apple-plist",
			"iCloud-DSID":  acc.DirectoryServicesID,
			"X-Dsid":       acc.DirectoryServicesID,
		},
		Payload: &http.XMLPayload{
			Content: payload,
		},
	}
}


================================================
FILE: pkg/appstore/appstore_get_version_metadata_test.go
================================================
package appstore

import (
	"errors"
	"time"

	"github.com/majd/ipatool/v2/pkg/http"
	"github.com/majd/ipatool/v2/pkg/util/machine"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	"go.uber.org/mock/gomock"
)

var _ = Describe("AppStore (GetVersionMetadata)", func() {
	var (
		ctrl               *gomock.Controller
		mockMachine        *machine.MockMachine
		mockDownloadClient *http.MockClient[downloadResult]
		as                 AppStore
	)

	BeforeEach(func() {
		ctrl = gomock.NewController(GinkgoT())
		mockMachine = machine.NewMockMachine(ctrl)
		mockDownloadClient = http.NewMockClient[downloadResult](ctrl)
		as = &appstore{
			machine:        mockMachine,
			downloadClient: mockDownloadClient,
		}
	})

	AfterEach(func() {
		ctrl.Finish()
	})

	When("fails to get MAC address", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("", errors.New("mac error"))
		})

		It("returns error", func() {
			_, err := as.GetVersionMetadata(GetVersionMetadataInput{})
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("failed to get mac address"))
		})
	})

	When("request fails", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:11:22:33:44:55", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[downloadResult]{}, errors.New("request error"))
		})

		It("returns error", func() {
			_, err := as.GetVersionMetadata(GetVersionMetadataInput{})
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("failed to send http request"))
		})
	})

	When("request uses a custom pod", func() {
		const (
			testPod  = "42"
			testGUID = "001122334455"
		)

		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:11:22:33:44:55", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Do(func(req http.Request) {
					expectedURL := "https://p" + testPod + "-" + PrivateAppStoreAPIDomain + PrivateAppStoreAPIPathDownload + "?guid=" + testGUID
					Expect(req.URL).To(Equal(expectedURL))
				}).
				Return(http.Result[downloadResult]{}, errors.New("request error"))
		})

		It("sends the request to the pod-specific host", func() {
			_, err := as.GetVersionMetadata(GetVersionMetadataInput{
				Account: Account{
					Pod: testPod,
				},
			})
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("failed to send http request"))
		})
	})

	When("password token is expired", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:11:22:33:44:55", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[downloadResult]{
					Data: downloadResult{
						FailureType: FailureTypePasswordTokenExpired,
					},
				}, nil)
		})

		It("returns error", func() {
			_, err := as.GetVersionMetadata(GetVersionMetadataInput{})
			Expect(err).To(Equal(ErrPasswordTokenExpired))
		})
	})

	When("license is missing", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:11:22:33:44:55", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[downloadResult]{
					Data: downloadResult{
						FailureType: FailureTypeLicenseNotFound,
					},
				}, nil)
		})

		It("returns error", func() {
			_, err := as.GetVersionMetadata(GetVersionMetadataInput{})
			Expect(err).To(Equal(ErrLicenseRequired))
		})
	})

	When("store API returns error", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:11:22:33:44:55", nil)
		})

		When("response contains customer message", func() {
			BeforeEach(func() {
				mockDownloadClient.EXPECT().
					Send(gomock.Any()).
					Return(http.Result[downloadResult]{
						Data: downloadResult{
							FailureType:     "SOME_ERROR",
							CustomerMessage: "Customer error message",
						},
					}, nil)
			})

			It("returns customer message as error", func() {
				_, err := as.GetVersionMetadata(GetVersionMetadataInput{})
				Expect(err).To(HaveOccurred())
				Expect(err.Error()).To(ContainSubstring("Customer error message"))
			})
		})

		When("response does not contain customer message", func() {
			BeforeEach(func() {
				mockDownloadClient.EXPECT().
					Send(gomock.Any()).
					Return(http.Result[downloadResult]{
						Data: downloadResult{
							FailureType: "SOME_ERROR",
						},
					}, nil)
			})

			It("returns generic error", func() {
				_, err := as.GetVersionMetadata(GetVersionMetadataInput{})
				Expect(err).To(HaveOccurred())
				Expect(err.Error()).To(ContainSubstring("SOME_ERROR"))
			})
		})
	})

	When("store API returns no items", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:11:22:33:44:55", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[downloadResult]{
					Data: downloadResult{
						Items: []downloadItemResult{},
					},
				}, nil)
		})

		It("returns error", func() {
			_, err := as.GetVersionMetadata(GetVersionMetadataInput{})
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("invalid response"))
		})
	})

	When("fails to parse release date", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:11:22:33:44:55", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[downloadResult]{
					Data: downloadResult{
						Items: []downloadItemResult{
							{
								Metadata: map[string]interface{}{
									"releaseDate": "invalid-date",
								},
							},
						},
					},
				}, nil)
		})

		It("returns error", func() {
			_, err := as.GetVersionMetadata(GetVersionMetadataInput{})
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("failed to parse release date"))
		})
	})

	When("successfully gets version metadata", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:11:22:33:44:55", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[downloadResult]{
					Data: downloadResult{
						Items: []downloadItemResult{
							{
								Metadata: map[string]interface{}{
									"releaseDate":              "2024-03-20T12:00:00Z",
									"bundleShortVersionString": "1.0.0",
								},
							},
						},
					},
				}, nil)
		})

		It("returns version metadata", func() {
			output, err := as.GetVersionMetadata(GetVersionMetadataInput{
				Account: Account{
					DirectoryServicesID: "test-dsid",
				},
				App: App{
					ID: 1234567890,
				},
				VersionID: "test-version",
			})

			Expect(err).NotTo(HaveOccurred())
			Expect(output.DisplayVersion).To(Equal("1.0.0"))
			Expect(output.ReleaseDate).To(Equal(time.Date(2024, 3, 20, 12, 0, 0, 0, time.UTC)))
		})
	})
})


================================================
FILE: pkg/appstore/appstore_list_versions.go
================================================
package appstore

import (
	"errors"
	"fmt"
	"strings"

	"github.com/majd/ipatool/v2/pkg/http"
)

type ListVersionsInput struct {
	Account Account
	App     App
}

type ListVersionsOutput struct {
	ExternalVersionIdentifiers []string
	LatestExternalVersionID    string
}

func (t *appstore) ListVersions(input ListVersionsInput) (ListVersionsOutput, error) {
	macAddr, err := t.machine.MacAddress()
	if err != nil {
		return ListVersionsOutput{}, fmt.Errorf("failed to get mac address: %w", err)
	}

	guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "")

	req := t.listVersionsRequest(input.Account, input.App, guid)
	res, err := t.downloadClient.Send(req)

	if err != nil {
		return ListVersionsOutput{}, fmt.Errorf("failed to send http request: %w", err)
	}

	if res.Data.FailureType == FailureTypePasswordTokenExpired {
		return ListVersionsOutput{}, ErrPasswordTokenExpired
	}

	if res.Data.FailureType == FailureTypeLicenseNotFound {
		return ListVersionsOutput{}, ErrLicenseRequired
	}

	if res.Data.FailureType != "" && res.Data.CustomerMessage != "" {
		return ListVersionsOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.CustomerMessage), res)
	}

	if res.Data.FailureType != "" {
		return ListVersionsOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.FailureType), res)
	}

	if len(res.Data.Items) == 0 {
		return ListVersionsOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res)
	}

	item := res.Data.Items[0]

	rawIdentifiers, ok := item.Metadata["softwareVersionExternalIdentifiers"].([]interface{})
	if !ok {
		return ListVersionsOutput{}, NewErrorWithMetadata(fmt.Errorf("failed to get version identifiers from item metadata"), item.Metadata)
	}

	externalVersionIdentifiers := make([]string, len(rawIdentifiers))
	for i, val := range rawIdentifiers {
		externalVersionIdentifiers[i] = fmt.Sprintf("%v", val)
	}

	latestExternalVersionID := item.Metadata["softwareVersionExternalIdentifier"]
	if latestExternalVersionID == nil {
		return ListVersionsOutput{}, NewErrorWithMetadata(fmt.Errorf("failed to get latest version from item metadata"), item.Metadata)
	}

	return ListVersionsOutput{
		ExternalVersionIdentifiers: externalVersionIdentifiers,
		LatestExternalVersionID:    fmt.Sprintf("%v", latestExternalVersionID),
	}, nil
}

func (t *appstore) listVersionsRequest(acc Account, app App, guid string) http.Request {
	payload := map[string]interface{}{
		"creditDisplay": "",
		"guid":          guid,
		"salableAdamId": app.ID,
	}

	podPrefix := ""
	if acc.Pod != "" {
		podPrefix = "p" + acc.Pod + "-"
	}

	return http.Request{
		URL:            fmt.Sprintf("https://%s%s%s?guid=%s", podPrefix, PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathDownload, guid),
		Method:         http.MethodPOST,
		ResponseFormat: http.ResponseFormatXML,
		Headers: map[string]string{
			"Content-Type": "application/x-apple-plist",
			"iCloud-DSID":  acc.DirectoryServicesID,
			"X-Dsid":       acc.DirectoryServicesID,
		},
		Payload: &http.XMLPayload{
			Content: payload,
		},
	}
}


================================================
FILE: pkg/appstore/appstore_list_versions_test.go
================================================
package appstore

import (
	"errors"

	"github.com/majd/ipatool/v2/pkg/http"
	"github.com/majd/ipatool/v2/pkg/util/machine"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	"go.uber.org/mock/gomock"
)

var _ = Describe("AppStore (ListVersions)", func() {
	var (
		ctrl               *gomock.Controller
		mockDownloadClient *http.MockClient[downloadResult]
		mockMachine        *machine.MockMachine
		as                 AppStore
	)

	BeforeEach(func() {
		ctrl = gomock.NewController(GinkgoT())
		mockDownloadClient = http.NewMockClient[downloadResult](ctrl)
		mockMachine = machine.NewMockMachine(ctrl)
		as = &appstore{
			downloadClient: mockDownloadClient,
			machine:        mockMachine,
		}
	})

	AfterEach(func() {
		ctrl.Finish()
	})

	When("fails to get MAC address", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("", errors.New(""))
		})

		It("returns error", func() {
			_, err := as.ListVersions(ListVersionsInput{})
			Expect(err).To(HaveOccurred())
		})
	})

	When("request fails", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:00:00:00:00:00", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[downloadResult]{}, errors.New(""))
		})

		It("returns error", func() {
			_, err := as.ListVersions(ListVersionsInput{})
			Expect(err).To(HaveOccurred())
		})
	})

	When("request uses a custom pod", func() {
		const (
			testPod  = "42"
			testGUID = "001122334455"
		)

		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:11:22:33:44:55", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Do(func(req http.Request) {
					expectedURL := "https://p" + testPod + "-" + PrivateAppStoreAPIDomain + PrivateAppStoreAPIPathDownload + "?guid=" + testGUID
					Expect(req.URL).To(Equal(expectedURL))
				}).
				Return(http.Result[downloadResult]{}, errors.New(""))
		})

		It("sends the request to the pod-specific host", func() {
			_, err := as.ListVersions(ListVersionsInput{
				Account: Account{
					Pod: testPod,
				},
			})
			Expect(err).To(HaveOccurred())
		})
	})

	When("password token is expired", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:00:00:00:00:00", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[downloadResult]{
					Data: downloadResult{
						FailureType: FailureTypePasswordTokenExpired,
					},
				}, nil)
		})

		It("returns error", func() {
			_, err := as.ListVersions(ListVersionsInput{})
			Expect(err).To(HaveOccurred())
		})
	})

	When("license is required", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:00:00:00:00:00", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[downloadResult]{
					Data: downloadResult{
						FailureType: FailureTypeLicenseNotFound,
					},
				}, nil)
		})

		It("returns error", func() {
			_, err := as.ListVersions(ListVersionsInput{})
			Expect(err).To(HaveOccurred())
		})
	})

	When("store API returns error with customer message", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:00:00:00:00:00", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[downloadResult]{
					Data: downloadResult{
						FailureType:     "test-failure",
						CustomerMessage: "test error message",
					},
				}, nil)
		})

		It("returns error with customer message", func() {
			_, err := as.ListVersions(ListVersionsInput{})
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("test error message"))
		})
	})

	When("store API returns error without customer message", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:00:00:00:00:00", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[downloadResult]{
					Data: downloadResult{
						FailureType: "test-failure",
					},
				}, nil)
		})

		It("returns error with failure type", func() {
			_, err := as.ListVersions(ListVersionsInput{})
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("test-failure"))
		})
	})

	When("store API returns no items", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:00:00:00:00:00", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[downloadResult]{
					Data: downloadResult{
						Items: []downloadItemResult{},
					},
				}, nil)
		})

		It("returns error", func() {
			_, err := as.ListVersions(ListVersionsInput{})
			Expect(err).To(HaveOccurred())
		})
	})

	When("version identifiers not found in metadata", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:00:00:00:00:00", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[downloadResult]{
					Data: downloadResult{
						Items: []downloadItemResult{
							{
								Metadata: map[string]interface{}{
									"someOtherKey": "someValue",
								},
							},
						},
					},
				}, nil)
		})

		It("returns error", func() {
			_, err := as.ListVersions(ListVersionsInput{})
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("failed to get version identifiers from item metadata"))
		})
	})

	When("latest version not found in metadata", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:00:00:00:00:00", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[downloadResult]{
					Data: downloadResult{
						Items: []downloadItemResult{
							{
								Metadata: map[string]interface{}{
									"softwareVersionExternalIdentifiers": []interface{}{"12345678", "87654321"},
								},
							},
						},
					},
				}, nil)
		})

		It("returns error", func() {
			_, err := as.ListVersions(ListVersionsInput{})
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("failed to get latest version from item metadata"))
		})
	})

	When("successfully lists versions", func() {
		const (
			testVersion1 = "12345678"
			testVersion2 = "87654321"
			testLatest   = "87654321"
		)

		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:00:00:00:00:00", nil)

			mockDownloadClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[downloadResult]{
					Data: downloadResult{
						Items: []downloadItemResult{
							{
								Metadata: map[string]interface{}{
									"softwareVersionExternalIdentifiers": []interface{}{testVersion1, testVersion2},
									"softwareVersionExternalIdentifier":  testLatest,
								},
							},
						},
					},
				}, nil)
		})

		It("returns versions", func() {
			out, err := as.ListVersions(ListVersionsInput{})
			Expect(err).ToNot(HaveOccurred())
			Expect(out.ExternalVersionIdentifiers).To(Equal([]string{testVersion1, testVersion2}))
			Expect(out.LatestExternalVersionID).To(Equal(testLatest))
		})
	})
})


================================================
FILE: pkg/appstore/appstore_login.go
================================================
package appstore

import (
	"encoding/json"
	"errors"
	"fmt"
	gohttp "net/http"
	"strconv"
	"strings"

	"github.com/majd/ipatool/v2/pkg/http"
	"github.com/majd/ipatool/v2/pkg/util"
)

var (
	ErrAuthCodeRequired = errors.New("auth code is required")
)

type LoginInput struct {
	Email    string
	Password string
	AuthCode string
	Endpoint string
}

type LoginOutput struct {
	Account Account
}

func (t *appstore) Login(input LoginInput) (LoginOutput, error) {
	macAddr, err := t.machine.MacAddress()
	if err != nil {
		return LoginOutput{}, fmt.Errorf("failed to get mac address: %w", err)
	}

	guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "")

	acc, err := t.login(input.Email, input.Password, input.AuthCode, guid, input.Endpoint)
	if err != nil {
		return LoginOutput{}, err
	}

	return LoginOutput{
		Account: acc,
	}, nil
}

type loginAddressResult struct {
	FirstName string `plist:"firstName,omitempty"`
	LastName  string `plist:"lastName,omitempty"`
}

type loginAccountResult struct {
	Email   string             `plist:"appleId,omitempty"`
	Address loginAddressResult `plist:"address,omitempty"`
}

type loginResult struct {
	FailureType         string             `plist:"failureType,omitempty"`
	CustomerMessage     string             `plist:"customerMessage,omitempty"`
	Account             loginAccountResult `plist:"accountInfo,omitempty"`
	DirectoryServicesID string             `plist:"dsPersonId,omitempty"`
	PasswordToken       string             `plist:"passwordToken,omitempty"`
}

func (t *appstore) login(email, password, authCode, guid, endpoint string) (Account, error) {
	redirect := ""

	var (
		err error
		res http.Result[loginResult]
	)

	retry := true

	for attempt := 1; retry && attempt <= 4; attempt++ {
		request := t.loginRequest(email, password, authCode, guid, endpoint, attempt)
		request.URL, _ = util.IfEmpty(redirect, request.URL), ""
		res, err = t.loginClient.Send(request)

		if err != nil {
			return Account{}, fmt.Errorf("request failed: %w", err)
		}

		if retry, redirect, err = t.parseLoginResponse(&res, attempt, authCode); err != nil {
			return Account{}, err
		}
	}

	if retry {
		return Account{}, NewErrorWithMetadata(errors.New("too many attempts"), res)
	}

	sf, err := res.GetHeader(HTTPHeaderStoreFront)
	if err != nil {
		return Account{}, NewErrorWithMetadata(fmt.Errorf("failed to get storefront header: %w", err), res)
	}

	pod, err := res.GetHeader(HTTPHeaderPod)
	if err != nil && !errors.Is(err, http.ErrHeaderNotFound) {
		return Account{}, NewErrorWithMetadata(fmt.Errorf("failed to get pod header: %w", err), res)
	}

	addr := res.Data.Account.Address
	acc := Account{
		Name:                strings.Join([]string{addr.FirstName, addr.LastName}, " "),
		Email:               res.Data.Account.Email,
		PasswordToken:       res.Data.PasswordToken,
		DirectoryServicesID: res.Data.DirectoryServicesID,
		StoreFront:          sf,
		Password:            password,
		Pod:                 pod,
	}

	data, err := json.Marshal(acc)
	if err != nil {
		return Account{}, fmt.Errorf("failed to marshal json: %w", err)
	}

	err = t.keychain.Set("account", data)
	if err != nil {
		return Account{}, fmt.Errorf("failed to save account in keychain: %w", err)
	}

	return acc, nil
}

func (t *appstore) parseLoginResponse(res *http.Result[loginResult], attempt int, authCode string) (bool, string, error) {
	var (
		retry    bool
		redirect string
		err      error
	)

	if res.StatusCode == gohttp.StatusFound {
		if redirect, err = res.GetHeader("location"); err != nil {
			err = fmt.Errorf("failed to retrieve redirect location: %w", err)
		} else {
			retry = true
		}
	} else if attempt == 1 && res.Data.FailureType == FailureTypeInvalidCredentials {
		retry = true
	} else if res.Data.FailureType == "" && authCode == "" && res.Data.CustomerMessage == CustomerMessageBadLogin {
		err = ErrAuthCodeRequired
	} else if res.Data.FailureType == "" && res.Data.CustomerMessage == CustomerMessageAccountDisabled {
		err = NewErrorWithMetadata(errors.New("account is disabled"), res)
	} else if res.Data.FailureType != "" {
		if res.Data.CustomerMessage != "" {
			err = NewErrorWithMetadata(errors.New(res.Data.CustomerMessage), res)
		} else {
			err = NewErrorWithMetadata(errors.New("something went wrong"), res)
		}
	} else if res.StatusCode != gohttp.StatusOK || res.Data.PasswordToken == "" || res.Data.DirectoryServicesID == "" {
		err = NewErrorWithMetadata(errors.New("something went wrong"), res)
	}

	return retry, redirect, err
}

func (t *appstore) loginRequest(email, password, authCode, guid, endpoint string, attempt int) http.Request {
	return http.Request{
		Method:         http.MethodPOST,
		URL:            endpoint,
		ResponseFormat: http.ResponseFormatXML,
		Headers: map[string]string{
			"Content-Type": "application/x-www-form-urlencoded",
		},
		Payload: &http.XMLPayload{
			Content: map[string]interface{}{
				"appleId":  email,
				"attempt":  strconv.Itoa(attempt),
				"guid":     guid,
				"password": fmt.Sprintf("%s%s", password, strings.ReplaceAll(authCode, " ", "")),
				"rmp":      "0",
				"why":      "signIn",
			},
		},
	}
}


================================================
FILE: pkg/appstore/appstore_login_test.go
================================================
package appstore

import (
	"encoding/json"
	"errors"
	"fmt"
	"strings"

	"github.com/majd/ipatool/v2/pkg/http"
	"github.com/majd/ipatool/v2/pkg/keychain"
	"github.com/majd/ipatool/v2/pkg/util/machine"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	"go.uber.org/mock/gomock"
)

var _ = Describe("AppStore (Login)", func() {
	const (
		testPassword  = "test-password"
		testEmail     = "test-email"
		testFirstName = "test-first-name"
		testLastName  = "test-last-name"
		testPod       = "42"
	)

	var (
		ctrl         *gomock.Controller
		as           AppStore
		mockKeychain *keychain.MockKeychain
		mockClient   *http.MockClient[loginResult]
		mockMachine  *machine.MockMachine
	)

	BeforeEach(func() {
		ctrl = gomock.NewController(GinkgoT())
		mockKeychain = keychain.NewMockKeychain(ctrl)
		mockClient = http.NewMockClient[loginResult](ctrl)
		mockMachine = machine.NewMockMachine(ctrl)
		as = &appstore{
			keychain:    mockKeychain,
			loginClient: mockClient,
			machine:     mockMachine,
		}
	})

	AfterEach(func() {
		ctrl.Finish()
	})

	When("fails to read Machine's MAC address", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("", errors.New(""))
		})

		It("returns error", func() {
			_, err := as.Login(LoginInput{
				Password: testPassword,
			})
			Expect(err).To(HaveOccurred())
		})
	})

	When("successfully reads machine's MAC address", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:00:00:00:00:00", nil)
		})

		When("client returns error", func() {
			BeforeEach(func() {
				mockClient.EXPECT().
					Send(gomock.Any()).
					Return(http.Result[loginResult]{}, errors.New(""))
			})

			It("returns wrapped error", func() {
				_, err := as.Login(LoginInput{
					Password: testPassword,
				})
				Expect(err).To(HaveOccurred())
			})
		})

		When("store API returns invalid first response", func() {
			BeforeEach(func() {
				mockClient.EXPECT().
					Send(gomock.Any()).
					Return(http.Result[loginResult]{
						Data: loginResult{
							FailureType:     FailureTypeInvalidCredentials,
							CustomerMessage: "test",
						},
					}, nil).
					Times(2)
			})

			It("retries one more time", func() {
				_, err := as.Login(LoginInput{
					Password: testPassword,
				})
				Expect(err).To(HaveOccurred())
			})
		})

		When("store API returns error", func() {
			BeforeEach(func() {
				mockClient.EXPECT().
					Send(gomock.Any()).
					Return(http.Result[loginResult]{
						Data: loginResult{
							FailureType: "random-error",
						},
					}, nil)
			})

			It("returns error", func() {
				_, err := as.Login(LoginInput{
					Password: testPassword,
				})
				Expect(err).To(HaveOccurred())
			})
		})

		When("store API indicates account is disabled", func() {
			BeforeEach(func() {
				mockClient.EXPECT().
					Send(gomock.Any()).
					Return(http.Result[loginResult]{
						Data: loginResult{
							CustomerMessage: CustomerMessageAccountDisabled,
						},
					}, nil)
			})

			It("returns account disabled error", func() {
				_, err := as.Login(LoginInput{
					Password: testPassword,
				})
				Expect(err).To(HaveOccurred())
				Expect(err.Error()).To(ContainSubstring("account is disabled"))
			})
		})

		When("store API requires 2FA code", func() {
			BeforeEach(func() {
				mockClient.EXPECT().
					Send(gomock.Any()).
					Return(http.Result[loginResult]{
						Data: loginResult{
							FailureType:     "",
							CustomerMessage: CustomerMessageBadLogin,
						},
					}, nil)
			})

			It("returns ErrAuthCodeRequired error", func() {
				_, err := as.Login(LoginInput{
					Password: testPassword,
				})
				Expect(err).To(Equal(ErrAuthCodeRequired))
			})
		})

		When("store API redirects", func() {
			const (
				testRedirectLocation = "https://test-redirect-url.com"
			)

			BeforeEach(func() {
				firstCall := mockClient.EXPECT().
					Send(gomock.Any()).
					Do(func(req http.Request) {
						Expect(req.Payload).To(BeAssignableToTypeOf(&http.XMLPayload{}))
						x := req.Payload.(*http.XMLPayload)
						Expect(x.Content).To(HaveKeyWithValue("attempt", "1"))
					}).
					Return(http.Result[loginResult]{
						StatusCode: 302,
						Headers:    map[string]string{"Location": testRedirectLocation},
					}, nil)
				secondCall := mockClient.EXPECT().
					Send(gomock.Any()).
					Do(func(req http.Request) {
						Expect(req.URL).To(Equal(testRedirectLocation))
						Expect(req.Payload).To(BeAssignableToTypeOf(&http.XMLPayload{}))
						x := req.Payload.(*http.XMLPayload)
						Expect(x.Content).To(HaveKeyWithValue("attempt", "2"))
					}).
					Return(http.Result[loginResult]{}, errors.New("test complete"))
				gomock.InOrder(firstCall, secondCall)
			})

			It("follows the redirect and increments attempt", func() {
				_, err := as.Login(LoginInput{
					Password: testPassword,
				})
				Expect(err).To(MatchError("request failed: test complete"))
			})
		})

		When("store API redirects too much", func() {
			BeforeEach(func() {
				mockClient.EXPECT().
					Send(gomock.Any()).
					Return(http.Result[loginResult]{
						StatusCode: 302,
						Headers:    map[string]string{"Location": "hello"},
					}, nil).
					Times(4)
			})
			It("bails out", func() {
				_, err := as.Login(LoginInput{
					Password: testPassword,
				})
				Expect(err).To(MatchError("too many attempts"))
			})
		})

		When("store API returns valid response", func() {
			const (
				testPasswordToken       = "test-password-token"
				testDirectoryServicesID = "directory-services-id"
				testStoreFront          = "test-storefront"
			)

			BeforeEach(func() {
				mockClient.EXPECT().
					Send(gomock.Any()).
					Return(http.Result[loginResult]{
						StatusCode: 200,
						Headers: map[string]string{
							HTTPHeaderStoreFront: testStoreFront,
							HTTPHeaderPod:        testPod,
						},
						Data: loginResult{
							PasswordToken:       testPasswordToken,
							DirectoryServicesID: testDirectoryServicesID,
							Account: loginAccountResult{
								Email: testEmail,
								Address: loginAddressResult{
									FirstName: testFirstName,
									LastName:  testLastName,
								},
							},
						},
					}, nil)
			})

			When("fails to save account in keychain", func() {
				BeforeEach(func() {
					mockKeychain.EXPECT().
						Set("account", gomock.Any()).
						Do(func(key string, data []byte) {
							want := Account{
								Name:                fmt.Sprintf("%s %s", testFirstName, testLastName),
								Email:               testEmail,
								PasswordToken:       testPasswordToken,
								Password:            testPassword,
								DirectoryServicesID: testDirectoryServicesID,
								StoreFront:          testStoreFront,
								Pod:                 testPod,
							}

							var got Account
							err := json.Unmarshal(data, &got)
							Expect(err).ToNot(HaveOccurred())
							Expect(got).To(Equal(want))
						}).
						Return(errors.New(""))
				})

				It("returns error", func() {
					_, err := as.Login(LoginInput{
						Password: testPassword,
					})
					Expect(err).To(HaveOccurred())
				})
			})

			When("successfully saves account in keychain", func() {
				BeforeEach(func() {
					mockKeychain.EXPECT().
						Set("account", gomock.Any()).
						Do(func(key string, data []byte) {
							want := Account{
								Name:                fmt.Sprintf("%s %s", testFirstName, testLastName),
								Email:               testEmail,
								PasswordToken:       testPasswordToken,
								Password:            testPassword,
								DirectoryServicesID: testDirectoryServicesID,
								StoreFront:          testStoreFront,
								Pod:                 testPod,
							}

							var got Account
							err := json.Unmarshal(data, &got)
							Expect(err).ToNot(HaveOccurred())
							Expect(got).To(Equal(want))
						}).
						Return(nil)
				})

				It("returns nil", func() {
					out, err := as.Login(LoginInput{
						Password: testPassword,
					})
					Expect(err).ToNot(HaveOccurred())
					Expect(out.Account.Email).To(Equal(testEmail))
					Expect(out.Account.Name).To(Equal(strings.Join([]string{testFirstName, testLastName}, " ")))
				})
			})
		})
	})
})


================================================
FILE: pkg/appstore/appstore_lookup.go
================================================
package appstore

import (
	"errors"
	"fmt"
	gohttp "net/http"
	"net/url"

	"github.com/majd/ipatool/v2/pkg/http"
)

type LookupInput struct {
	Account  Account
	BundleID string
}

type LookupOutput struct {
	App App
}

func (t *appstore) Lookup(input LookupInput) (LookupOutput, error) {
	countryCode, err := countryCodeFromStoreFront(input.Account.StoreFront)
	if err != nil {
		return LookupOutput{}, fmt.Errorf("failed to resolve the country code: %w", err)
	}

	request := t.lookupRequest(input.BundleID, countryCode)

	res, err := t.searchClient.Send(request)
	if err != nil {
		return LookupOutput{}, fmt.Errorf("request failed: %w", err)
	}

	if res.StatusCode != gohttp.StatusOK {
		return LookupOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res)
	}

	if len(res.Data.Results) == 0 {
		return LookupOutput{}, errors.New("app not found")
	}

	return LookupOutput{
		App: res.Data.Results[0],
	}, nil
}

func (t *appstore) lookupRequest(bundleID, countryCode string) http.Request {
	return http.Request{
		URL:            t.lookupURL(bundleID, countryCode),
		Method:         http.MethodGET,
		ResponseFormat: http.ResponseFormatJSON,
	}
}

func (t *appstore) lookupURL(bundleID, countryCode string) string {
	params := url.Values{}
	params.Add("entity", "software,iPadSoftware")
	params.Add("limit", "1")
	params.Add("media", "software")
	params.Add("bundleId", bundleID)
	params.Add("country", countryCode)

	return fmt.Sprintf("https://%s%s?%s", iTunesAPIDomain, iTunesAPIPathLookup, params.Encode())
}


================================================
FILE: pkg/appstore/appstore_lookup_test.go
================================================
package appstore

import (
	"errors"

	"github.com/majd/ipatool/v2/pkg/http"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	"go.uber.org/mock/gomock"
)

var _ = Describe("AppStore (Lookup)", func() {
	var (
		ctrl       *gomock.Controller
		mockClient *http.MockClient[searchResult]
		as         AppStore
	)

	BeforeEach(func() {
		ctrl = gomock.NewController(GinkgoT())
		mockClient = http.NewMockClient[searchResult](ctrl)
		as = &appstore{
			searchClient: mockClient,
		}
	})

	AfterEach(func() {
		ctrl.Finish()
	})

	When("request is successful", func() {
		When("does not find app", func() {
			BeforeEach(func() {
				mockClient.EXPECT().
					Send(gomock.Any()).
					Return(http.Result[searchResult]{
						StatusCode: 200,
						Data: searchResult{
							Count:   0,
							Results: []App{},
						},
					}, nil)
			})

			It("returns error", func() {
				_, err := as.Lookup(LookupInput{
					Account: Account{
						StoreFront: "143441",
					},
				})
				Expect(err).To(HaveOccurred())
			})
		})

		When("finds app", func() {
			var testApp = App{
				ID:       1,
				BundleID: "app.bundle.id",
				Name:     "app name",
				Version:  "1.0",
				Price:    0.99,
			}

			BeforeEach(func() {
				mockClient.EXPECT().
					Send(gomock.Any()).
					Return(http.Result[searchResult]{
						StatusCode: 200,
						Data: searchResult{
							Count:   1,
							Results: []App{testApp},
						},
					}, nil)
			})

			It("returns app", func() {
				app, err := as.Lookup(LookupInput{
					Account: Account{
						StoreFront: "143441",
					},
				})
				Expect(err).ToNot(HaveOccurred())
				Expect(app).To(Equal(LookupOutput{App: testApp}))
			})
		})
	})

	When("store front is invalid", func() {
		It("returns error", func() {
			_, err := as.Lookup(LookupInput{
				Account: Account{
					StoreFront: "xyz",
				},
			})
			Expect(err).To(HaveOccurred())
		})
	})

	When("request fails", func() {
		BeforeEach(func() {
			mockClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[searchResult]{}, errors.New(""))
		})

		It("returns error", func() {
			_, err := as.Lookup(LookupInput{
				Account: Account{
					StoreFront: "143441",
				},
			})
			Expect(err).To(HaveOccurred())
		})
	})

	When("request returns bad status code", func() {
		BeforeEach(func() {
			mockClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[searchResult]{
					StatusCode: 400,
				}, nil)
		})

		It("returns error", func() {
			_, err := as.Lookup(LookupInput{
				Account: Account{
					StoreFront: "143441",
				},
			})
			Expect(err).To(HaveOccurred())
		})
	})
})


================================================
FILE: pkg/appstore/appstore_purchase.go
================================================
package appstore

import (
	"errors"
	"fmt"
	gohttp "net/http"
	"strings"

	"github.com/majd/ipatool/v2/pkg/http"
)

var (
	ErrPasswordTokenExpired   = errors.New("password token is expired")
	ErrSubscriptionRequired   = errors.New("subscription required")
	ErrTemporarilyUnavailable = errors.New("item is temporarily unavailable")
)

type PurchaseInput struct {
	Account Account
	App     App
}

func (t *appstore) Purchase(input PurchaseInput) error {
	macAddr, err := t.machine.MacAddress()
	if err != nil {
		return fmt.Errorf("failed to get mac address: %w", err)
	}

	guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "")

	if input.App.Price > 0 {
		return errors.New("purchasing paid apps is not supported")
	}

	err = t.purchaseWithParams(input.Account, input.App, guid, PricingParameterAppStore)
	if err != nil {
		if err == ErrTemporarilyUnavailable {
			err = t.purchaseWithParams(input.Account, input.App, guid, PricingParameterAppleArcade)
			if err != nil {
				return fmt.Errorf("failed to purchase item with param '%s': %w", PricingParameterAppleArcade, err)
			}

			return nil
		}

		return fmt.Errorf("failed to purchase item with param '%s': %w", PricingParameterAppStore, err)
	}

	return nil
}

type purchaseResult struct {
	FailureType     string `plist:"failureType,omitempty"`
	CustomerMessage string `plist:"customerMessage,omitempty"`
	JingleDocType   string `plist:"jingleDocType,omitempty"`
	Status          int    `plist:"status,omitempty"`
}

func (t *appstore) purchaseWithParams(acc Account, app App, guid string, pricingParameters string) error {
	req := t.purchaseRequest(acc, app, acc.StoreFront, guid, pricingParameters)
	res, err := t.purchaseClient.Send(req)

	if err != nil {
		return fmt.Errorf("request failed: %w", err)
	}

	if res.Data.FailureType == FailureTypeTemporarilyUnavailable {
		return ErrTemporarilyUnavailable
	}

	if res.Data.CustomerMessage == CustomerMessageSubscriptionRequired {
		return ErrSubscriptionRequired
	}

	if res.Data.FailureType == FailureTypePasswordTokenExpired {
		return ErrPasswordTokenExpired
	}

	if res.Data.FailureType != "" && res.Data.CustomerMessage != "" {
		return NewErrorWithMetadata(errors.New(res.Data.CustomerMessage), res)
	}

	if res.Data.FailureType != "" {
		return NewErrorWithMetadata(errors.New("something went wrong"), res)
	}

	if res.StatusCode == gohttp.StatusInternalServerError {
		return fmt.Errorf("license already exists")
	}

	if res.Data.JingleDocType != "purchaseSuccess" || res.Data.Status != 0 {
		return NewErrorWithMetadata(errors.New("failed to purchase app"), res)
	}

	return nil
}

func (t *appstore) purchaseRequest(acc Account, app App, storeFront, guid string, pricingParameters string) http.Request {
	podPrefix := ""
	if acc.Pod != "" {
		podPrefix = "p" + acc.Pod + "-"
	}

	return http.Request{
		URL:            fmt.Sprintf("https://%s%s%s", podPrefix, PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathPurchase),
		Method:         http.MethodPOST,
		ResponseFormat: http.ResponseFormatXML,
		Headers: map[string]string{
			"Content-Type":        "application/x-apple-plist",
			"iCloud-DSID":         acc.DirectoryServicesID,
			"X-Dsid":              acc.DirectoryServicesID,
			"X-Apple-Store-Front": storeFront,
			"X-Token":             acc.PasswordToken,
		},
		Payload: &http.XMLPayload{
			Content: map[string]interface{}{
				"appExtVrsId":               "0",
				"hasAskedToFulfillPreorder": "true",
				"buyWithoutAuthorization":   "true",
				"hasDoneAgeCheck":           "true",
				"guid":                      guid,
				"needDiv":                   "0",
				"origPage":                  fmt.Sprintf("Software-%d", app.ID),
				"origPageLocation":          "Buy",
				"price":                     "0",
				"pricingParameters":         pricingParameters,
				"productType":               "C",
				"salableAdamId":             app.ID,
			},
		},
	}
}


================================================
FILE: pkg/appstore/appstore_purchase_test.go
================================================
package appstore

import (
	"errors"

	"github.com/majd/ipatool/v2/pkg/http"
	"github.com/majd/ipatool/v2/pkg/keychain"
	"github.com/majd/ipatool/v2/pkg/util/machine"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	"go.uber.org/mock/gomock"
)

var _ = Describe("AppStore (Purchase)", func() {
	var (
		ctrl               *gomock.Controller
		mockKeychain       *keychain.MockKeychain
		mockMachine        *machine.MockMachine
		mockPurchaseClient *http.MockClient[purchaseResult]
		mockLoginClient    *http.MockClient[loginResult]
		as                 *appstore
	)

	BeforeEach(func() {
		ctrl = gomock.NewController(GinkgoT())
		mockPurchaseClient = http.NewMockClient[purchaseResult](ctrl)
		mockLoginClient = http.NewMockClient[loginResult](ctrl)
		mockKeychain = keychain.NewMockKeychain(ctrl)
		mockMachine = machine.NewMockMachine(ctrl)
		as = &appstore{
			keychain:       mockKeychain,
			purchaseClient: mockPurchaseClient,
			loginClient:    mockLoginClient,
			machine:        mockMachine,
		}
	})

	AfterEach(func() {
		ctrl.Finish()
	})

	When("fails to read MAC address", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("", errors.New(""))
		})

		It("returns error", func() {
			err := as.Purchase(PurchaseInput{})
			Expect(err).To(HaveOccurred())
		})
	})

	When("app is paid", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:00:00:00:00:00", nil)
		})

		It("returns error", func() {
			err := as.Purchase(PurchaseInput{
				Account: Account{
					StoreFront: "143441",
				},
				App: App{
					Price: 0.99,
				},
			})
			Expect(err).To(HaveOccurred())
		})
	})

	When("purchase request fails", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:00:00:00:00:00", nil)

			mockPurchaseClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[purchaseResult]{}, errors.New(""))
		})

		It("returns error", func() {
			err := as.Purchase(PurchaseInput{
				Account: Account{
					StoreFront: "143441",
				},
			})
			Expect(err).To(HaveOccurred())
		})
	})

	When("request uses a custom pod", func() {
		const (
			testPod  = "42"
			testGUID = "001122334455"
		)

		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:11:22:33:44:55", nil)

			mockPurchaseClient.EXPECT().
				Send(gomock.Any()).
				Do(func(req http.Request) {
					expectedURL := "https://p" + testPod + "-" + PrivateAppStoreAPIDomain + PrivateAppStoreAPIPathPurchase
					Expect(req.URL).To(Equal(expectedURL))
					Expect(req.Payload).To(BeAssignableToTypeOf(&http.XMLPayload{}))
					payload := req.Payload.(*http.XMLPayload)
					Expect(payload.Content["guid"]).To(Equal(testGUID))
				}).
				Return(http.Result[purchaseResult]{}, errors.New(""))
		})

		It("sends the request to the pod-specific host", func() {
			err := as.Purchase(PurchaseInput{
				Account: Account{
					StoreFront: "143441",
					Pod:        testPod,
				},
			})
			Expect(err).To(HaveOccurred())
		})
	})

	When("password token is expired", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:00:00:00:00:00", nil)

			mockPurchaseClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[purchaseResult]{
					Data: purchaseResult{
						FailureType: FailureTypePasswordTokenExpired,
					},
				}, nil)
		})

		It("returns error", func() {
			err := as.Purchase(PurchaseInput{
				Account: Account{
					StoreFront: "143441",
				},
			})
			Expect(err).To(HaveOccurred())
		})
	})

	When("store API returns customer error message", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:00:00:00:00:00", nil)

			mockPurchaseClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[purchaseResult]{
					Data: purchaseResult{
						FailureType:     "failure",
						CustomerMessage: CustomerMessageBadLogin,
					},
				}, nil)
		})

		It("returns error", func() {
			err := as.Purchase(PurchaseInput{
				Account: Account{
					StoreFront: "143441",
				},
			})
			Expect(err).To(HaveOccurred())
		})
	})

	When("store API returns unknown error", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:00:00:00:00:00", nil)

			mockPurchaseClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[purchaseResult]{
					Data: purchaseResult{
						FailureType: "failure",
					},
				}, nil)
		})

		It("returns error", func() {
			err := as.Purchase(PurchaseInput{
				Account: Account{
					StoreFront: "143441",
				},
			})
			Expect(err).To(HaveOccurred())
		})
	})

	When("account already has a license for the app", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:00:00:00:00:00", nil)

			mockPurchaseClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[purchaseResult]{
					StatusCode: 500,
					Data:       purchaseResult{},
				}, nil)
		})

		It("returns error", func() {
			err := as.Purchase(PurchaseInput{
				Account: Account{
					StoreFront: "143441",
				},
			})
			Expect(err).To(HaveOccurred())
		})
	})

	When("subscription is required", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:00:00:00:00:00", nil)

			mockPurchaseClient.EXPECT().
				Send(pricingParametersMatcher{"STDQ"}).
				Return(http.Result[purchaseResult]{
					StatusCode: 200,
					Data: purchaseResult{
						CustomerMessage: "This item is temporarily unavailable.",
						FailureType:     FailureTypeTemporarilyUnavailable,
					},
				}, nil)

			mockPurchaseClient.EXPECT().
				Send(pricingParametersMatcher{"GAME"}).
				Return(http.Result[purchaseResult]{
					StatusCode: 200,
					Data: purchaseResult{
						CustomerMessage: CustomerMessageSubscriptionRequired,
					},
				}, nil)
		})

		It("returns error", func() {
			err := as.Purchase(PurchaseInput{
				Account: Account{
					StoreFront: "143441",
				},
			})
			Expect(err).To(HaveOccurred())
		})
	})

	When("successfully purchases the app", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:00:00:00:00:00", nil)

			mockPurchaseClient.EXPECT().
				Send(pricingParametersMatcher{"STDQ"}).
				Return(http.Result[purchaseResult]{
					StatusCode: 200,
					Data: purchaseResult{
						CustomerMessage: "This item is temporarily unavailable.",
						FailureType:     FailureTypeTemporarilyUnavailable,
					},
				}, nil)

			mockPurchaseClient.EXPECT().
				Send(pricingParametersMatcher{"GAME"}).
				Return(http.Result[purchaseResult]{
					StatusCode: 200,
					Data: purchaseResult{
						JingleDocType: "purchaseSuccess",
						Status:        0,
					},
				}, nil)
		})

		It("returns nil", func() {
			err := as.Purchase(PurchaseInput{
				Account: Account{
					StoreFront: "143441",
				},
			})
			Expect(err).ToNot(HaveOccurred())
		})
	})

	When("purchasing the app fails", func() {
		BeforeEach(func() {
			mockMachine.EXPECT().
				MacAddress().
				Return("00:00:00:00:00:00", nil)

			mockPurchaseClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[purchaseResult]{
					StatusCode: 200,
					Data: purchaseResult{
						JingleDocType: "failure",
						Status:        -1,
					},
				}, nil)
		})

		It("returns nil", func() {
			err := as.Purchase(PurchaseInput{
				Account: Account{
					StoreFront: "143441",
				},
			})
			Expect(err).To(HaveOccurred())
		})
	})
})

type pricingParametersMatcher struct {
	pricingParameters string
}

func (p pricingParametersMatcher) Matches(in interface{}) bool {
	return in.(http.Request).Payload.(*http.XMLPayload).Content["pricingParameters"] == p.pricingParameters
}

func (p pricingParametersMatcher) String() string {
	return "payload pricingParameters is " + p.pricingParameters
}


================================================
FILE: pkg/appstore/appstore_replicate_sinf.go
================================================
package appstore

import (
	"archive/zip"
	"bytes"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"

	"github.com/majd/ipatool/v2/pkg/util"
	"howett.net/plist"
)

type Sinf struct {
	ID   int64  `plist:"id,omitempty"`
	Data []byte `plist:"sinf,omitempty"`
}

type ReplicateSinfInput struct {
	Sinfs       []Sinf
	PackagePath string
}

func (t *appstore) ReplicateSinf(input ReplicateSinfInput) error {
	zipReader, err := zip.OpenReader(input.PackagePath)
	if err != nil {
		return errors.New("failed to open zip reader")
	}

	tmpPath := fmt.Sprintf("%s.tmp", input.PackagePath)
	tmpFile, err := t.os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY, 0644)

	if err != nil {
		return fmt.Errorf("failed to open file: %w", err)
	}

	zipWriter := zip.NewWriter(tmpFile)

	err = t.replicateZip(zipReader, zipWriter)
	if err != nil {
		return fmt.Errorf("failed to replicate zip: %w", err)
	}

	bundleName, err := t.readBundleName(zipReader)
	if err != nil {
		return fmt.Errorf("failed to read bundle name: %w", err)
	}

	manifest, err := t.readManifestPlist(zipReader)
	if err != nil {
		return fmt.Errorf("failed to read manifest plist: %w", err)
	}

	info, err := t.readInfoPlist(zipReader)
	if err != nil {
		return fmt.Errorf("failed to read info plist: %w", err)
	}

	if manifest != nil {
		err = t.replicateSinfFromManifest(*manifest, zipWriter, input.Sinfs, bundleName)
	} else {
		err = t.replicateSinfFromInfo(*info, zipWriter, input.Sinfs, bundleName)
	}

	if err != nil {
		return fmt.Errorf("failed to replicate sinf: %w", err)
	}

	zipReader.Close()
	zipWriter.Close()
	tmpFile.Close()

	err = t.os.Remove(input.PackagePath)
	if err != nil {
		return fmt.Errorf("failed to remove original file: %w", err)
	}

	err = t.os.Rename(tmpPath, input.PackagePath)
	if err != nil {
		return fmt.Errorf("failed to remove original file: %w", err)
	}

	return nil
}

type packageManifest struct {
	SinfPaths []string `plist:"SinfPaths,omitempty"`
}

type packageInfo struct {
	BundleExecutable string `plist:"CFBundleExecutable,omitempty"`
}

func (*appstore) replicateSinfFromManifest(manifest packageManifest, zip *zip.Writer, sinfs []Sinf, bundleName string) error {
	zipped, err := util.Zip(sinfs, manifest.SinfPaths)
	if err != nil {
		return fmt.Errorf("failed to zip sinfs: %w", err)
	}

	for _, pair := range zipped {
		sp := fmt.Sprintf("Payload/%s.app/%s", bundleName, pair.Second)

		file, err := zip.Create(sp)
		if err != nil {
			return fmt.Errorf("failed to create file: %w", err)
		}

		_, err = file.Write(pair.First.Data)
		if err != nil {
			return fmt.Errorf("failed to write data: %w", err)
		}
	}

	return nil
}

func (t *appstore) replicateSinfFromInfo(info packageInfo, zip *zip.Writer, sinfs []Sinf, bundleName string) error {
	sp := fmt.Sprintf("Payload/%s.app/SC_Info/%s.sinf", bundleName, info.BundleExecutable)

	file, err := zip.Create(sp)
	if err != nil {
		return fmt.Errorf("failed to create file: %w", err)
	}

	_, err = file.Write(sinfs[0].Data)
	if err != nil {
		return fmt.Errorf("failed to write data: %w", err)
	}

	return nil
}

func (t *appstore) replicateZip(src *zip.ReadCloser, dst *zip.Writer) error {
	for _, file := range src.File {
		srcFile, err := file.OpenRaw()
		if err != nil {
			return fmt.Errorf("failed to open raw file: %w", err)
		}

		header := file.FileHeader
		dstFile, err := dst.CreateRaw(&header)

		if err != nil {
			return fmt.Errorf("failed to create raw file: %w", err)
		}

		_, err = io.Copy(dstFile, srcFile)
		if err != nil {
			return fmt.Errorf("failed to copy file: %w", err)
		}
	}

	return nil
}

func (*appstore) readInfoPlist(reader *zip.ReadCloser) (*packageInfo, error) {
	for _, file := range reader.File {
		if strings.Contains(file.Name, ".app/Info.plist") {
			src, err := file.Open()
			if err != nil {
				return nil, fmt.Errorf("failed to open file: %w", err)
			}

			data := new(bytes.Buffer)
			_, err = io.Copy(data, src)

			if err != nil {
				return nil, fmt.Errorf("failed to copy data: %w", err)
			}

			var info packageInfo
			_, err = plist.Unmarshal(data.Bytes(), &info)

			if err != nil {
				return nil, fmt.Errorf("failed to unmarshal data: %w", err)
			}

			return &info, nil
		}
	}

	return nil, nil
}

func (*appstore) readManifestPlist(reader *zip.ReadCloser) (*packageManifest, error) {
	for _, file := range reader.File {
		if strings.HasSuffix(file.Name, ".app/SC_Info/Manifest.plist") {
			src, err := file.Open()
			if err != nil {
				return nil, fmt.Errorf("failed to open file: %w", err)
			}

			data := new(bytes.Buffer)
			_, err = io.Copy(data, src)

			if err != nil {
				return nil, fmt.Errorf("failed to copy data: %w", err)
			}

			var manifest packageManifest

			_, err = plist.Unmarshal(data.Bytes(), &manifest)
			if err != nil {
				return nil, fmt.Errorf("failed to unmarshal data: %w", err)
			}

			return &manifest, nil
		}
	}

	return nil, nil
}

func (*appstore) readBundleName(reader *zip.ReadCloser) (string, error) {
	var bundleName string

	for _, file := range reader.File {
		if strings.Contains(file.Name, ".app/Info.plist") && !strings.Contains(file.Name, "/Watch/") {
			bundleName = filepath.Base(strings.TrimSuffix(file.Name, ".app/Info.plist"))

			break
		}
	}

	if bundleName == "" {
		return "", errors.New("could not read bundle name")
	}

	return bundleName, nil
}


================================================
FILE: pkg/appstore/appstore_replicate_sinf_test.go
================================================
package appstore

import (
	"archive/zip"
	"errors"
	"fmt"
	"os"

	"github.com/majd/ipatool/v2/pkg/http"
	"github.com/majd/ipatool/v2/pkg/keychain"
	"github.com/majd/ipatool/v2/pkg/util/machine"
	"github.com/majd/ipatool/v2/pkg/util/operatingsystem"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	"go.uber.org/mock/gomock"
	"howett.net/plist"
)

var _ = Describe("AppStore (ReplicateSinf)", func() {
	var (
		ctrl               *gomock.Controller
		mockKeychain       *keychain.MockKeychain
		mockDownloadClient *http.MockClient[downloadResult]
		mockPurchaseClient *http.MockClient[purchaseResult]
		mockLoginClient    *http.MockClient[loginResult]
		mockHTTPClient     *http.MockClient[interface{}]
		mockOS             *operatingsystem.MockOperatingSystem
		mockMachine        *machine.MockMachine
		as                 AppStore
		testFile           *os.File
		testZip            *zip.Writer
	)

	BeforeEach(func() {
		ctrl = gomock.NewController(GinkgoT())
		mockKeychain = keychain.NewMockKeychain(ctrl)
		mockDownloadClient = http.NewMockClient[downloadResult](ctrl)
		mockLoginClient = http.NewMockClient[loginResult](ctrl)
		mockPurchaseClient = http.NewMockClient[purchaseResult](ctrl)
		mockHTTPClient = http.NewMockClient[interface{}](ctrl)
		mockOS = operatingsystem.NewMockOperatingSystem(ctrl)
		mockMachine = machine.NewMockMachine(ctrl)
		as = &appstore{
			keychain:       mockKeychain,
			loginClient:    mockLoginClient,
			purchaseClient: mockPurchaseClient,
			downloadClient: mockDownloadClient,
			httpClient:     mockHTTPClient,
			machine:        mockMachine,
			os:             mockOS,
		}

		var err error
		testFile, err = os.CreateTemp("", "test_file")
		Expect(err).ToNot(HaveOccurred())

		testZip = zip.NewWriter(testFile)
	})

	JustBeforeEach(func() {
		testZip.Close()
	})

	AfterEach(func() {
		err := os.Remove(testFile.Name())
		Expect(err).ToNot(HaveOccurred())

		ctrl.Finish()
	})

	When("app includes codesign manifest", func() {
		BeforeEach(func() {
			mockOS.EXPECT().
				OpenFile(gomock.Any(), gomock.Any(), gomock.Any()).
				DoAndReturn(os.OpenFile)

			mockOS.EXPECT().
				Remove(testFile.Name()).
				Return(nil)

			mockOS.EXPECT().
				Rename(fmt.Sprintf("%s.tmp", testFile.Name()), testFile.Name()).
				Return(nil)

			manifest, err := plist.Marshal(packageManifest{
				SinfPaths: []string{
					"SC_Info/TestApp.sinf",
				},
			}, plist.BinaryFormat)
			Expect(err).ToNot(HaveOccurred())

			w, err := testZip.Create("Payload/Test.app/SC_Info/Manifest.plist")
			Expect(err).ToNot(HaveOccurred())

			_, err = w.Write(manifest)
			Expect(err).ToNot(HaveOccurred())

			w, err = testZip.Create("Payload/Test.app/Info.plist")
			Expect(err).ToNot(HaveOccurred())

			info, err := plist.Marshal(map[string]interface{}{
				"CFBundleExecutable": "Test",
			}, plist.BinaryFormat)
			Expect(err).ToNot(HaveOccurred())

			_, err = w.Write(info)
			Expect(err).ToNot(HaveOccurred())

			w, err = testZip.Create("Payload/Test.app/Watch/Test.app/Info.plist")
			Expect(err).ToNot(HaveOccurred())

			watchInfo, err := plist.Marshal(map[string]interface{}{
				"WKWatchKitApp": true,
			}, plist.BinaryFormat)
			Expect(err).ToNot(HaveOccurred())

			_, err = w.Write(watchInfo)
			Expect(err).ToNot(HaveOccurred())
		})

		It("replicates sinf from manifest plist", func() {
			err := as.ReplicateSinf(ReplicateSinfInput{
				PackagePath: testFile.Name(),
				Sinfs: []Sinf{
					{
						ID:   0,
						Data: []byte(""),
					},
				},
			})
			Expect(err).ToNot(HaveOccurred())
		})
	})

	When("app does not include codesign manifest", func() {
		BeforeEach(func() {
			mockOS.EXPECT().
				OpenFile(gomock.Any(), gomock.Any(), gomock.Any()).
				DoAndReturn(os.OpenFile)

			mockOS.EXPECT().
				Remove(testFile.Name()).
				Return(nil)

			mockOS.EXPECT().
				Rename(fmt.Sprintf("%s.tmp", testFile.Name()), testFile.Name()).
				Return(nil)

			w, err := testZip.Create("Payload/Test.app/Info.plist")
			Expect(err).ToNot(HaveOccurred())

			info, err := plist.Marshal(map[string]interface{}{
				"CFBundleExecutable": "Test",
			}, plist.BinaryFormat)
			Expect(err).ToNot(HaveOccurred())

			_, err = w.Write(info)
			Expect(err).ToNot(HaveOccurred())

			w, err = testZip.Create("Payload/Test.app/Watch/Test.app/Info.plist")
			Expect(err).ToNot(HaveOccurred())

			watchInfo, err := plist.Marshal(map[string]interface{}{
				"WKWatchKitApp": true,
			}, plist.BinaryFormat)
			Expect(err).ToNot(HaveOccurred())

			_, err = w.Write(watchInfo)
			Expect(err).ToNot(HaveOccurred())
		})

		It("replicates sinf", func() {
			err := as.ReplicateSinf(ReplicateSinfInput{
				PackagePath: testFile.Name(),
				Sinfs: []Sinf{
					{
						ID:   0,
						Data: []byte(""),
					},
				},
			})
			Expect(err).ToNot(HaveOccurred())
		})
	})

	When("fails to open file", func() {
		BeforeEach(func() {
			mockOS.EXPECT().
				OpenFile(gomock.Any(), gomock.Any(), gomock.Any()).
				Return(nil, errors.New(""))
		})

		It("returns error", func() {
			err := as.ReplicateSinf(ReplicateSinfInput{
				PackagePath: testFile.Name(),
			})
			Expect(err).To(HaveOccurred())
		})
	})
})


================================================
FILE: pkg/appstore/appstore_revoke.go
================================================
package appstore

import (
	"fmt"
)

func (t *appstore) Revoke() error {
	err := t.keychain.Remove("account")
	if err != nil {
		return fmt.Errorf("failed to remove account from keychain: %w", err)
	}

	return nil
}


================================================
FILE: pkg/appstore/appstore_revoke_test.go
================================================
package appstore

import (
	"errors"

	"github.com/majd/ipatool/v2/pkg/keychain"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	"go.uber.org/mock/gomock"
)

var _ = Describe("AppStore (Revoke)", func() {
	var (
		ctrl         *gomock.Controller
		appstore     AppStore
		mockKeychain *keychain.MockKeychain
	)

	BeforeEach(func() {
		ctrl = gomock.NewController(GinkgoT())
		mockKeychain = keychain.NewMockKeychain(ctrl)
		appstore = NewAppStore(Args{
			Keychain: mockKeychain,
		})
	})

	AfterEach(func() {
		ctrl.Finish()
	})

	When("keychain removes item", func() {
		BeforeEach(func() {
			mockKeychain.EXPECT().
				Remove("account").
				Return(nil)
		})

		It("returns data", func() {
			err := appstore.Revoke()
			Expect(err).ToNot(HaveOccurred())
		})
	})

	When("keychain returns error", func() {
		BeforeEach(func() {
			mockKeychain.EXPECT().
				Remove("account").
				Return(errors.New(""))
		})

		It("returns wrapped error", func() {
			err := appstore.Revoke()
			Expect(err).To(HaveOccurred())
		})
	})
})


================================================
FILE: pkg/appstore/appstore_search.go
================================================
package appstore

import (
	"errors"
	"fmt"
	gohttp "net/http"
	"net/url"
	"strconv"

	"github.com/majd/ipatool/v2/pkg/http"
)

type SearchInput struct {
	Account Account
	Term    string
	Limit   int64
}

type SearchOutput struct {
	Count   int
	Results []App
}

func (t *appstore) Search(input SearchInput) (SearchOutput, error) {
	countryCode, err := countryCodeFromStoreFront(input.Account.StoreFront)
	if err != nil {
		return SearchOutput{}, fmt.Errorf("country code is invalid: %w", err)
	}

	request := t.searchRequest(input.Term, countryCode, input.Limit)

	res, err := t.searchClient.Send(request)
	if err != nil {
		return SearchOutput{}, fmt.Errorf("request failed: %w", err)
	}

	if res.StatusCode != gohttp.StatusOK {
		return SearchOutput{}, NewErrorWithMetadata(errors.New("request failed"), res)
	}

	return SearchOutput{
		Count:   res.Data.Count,
		Results: res.Data.Results,
	}, nil
}

type searchResult struct {
	Count   int   `json:"resultCount,omitempty"`
	Results []App `json:"results,omitempty"`
}

func (t *appstore) searchRequest(term, countryCode string, limit int64) http.Request {
	return http.Request{
		URL:            t.searchURL(term, countryCode, limit),
		Method:         http.MethodGET,
		ResponseFormat: http.ResponseFormatJSON,
	}
}

func (t *appstore) searchURL(term, countryCode string, limit int64) string {
	params := url.Values{}
	params.Add("entity", "software,iPadSoftware")
	params.Add("limit", strconv.Itoa(int(limit)))
	params.Add("media", "software")
	params.Add("term", term)
	params.Add("country", countryCode)

	return fmt.Sprintf("https://%s%s?%s", iTunesAPIDomain, iTunesAPIPathSearch, params.Encode())
}


================================================
FILE: pkg/appstore/appstore_search_test.go
================================================
package appstore

import (
	"errors"

	"github.com/majd/ipatool/v2/pkg/http"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	"go.uber.org/mock/gomock"
)

var _ = Describe("AppStore (Search)", func() {
	var (
		ctrl       *gomock.Controller
		mockClient *http.MockClient[searchResult]
		as         AppStore
	)

	BeforeEach(func() {
		ctrl = gomock.NewController(GinkgoT())
		mockClient = http.NewMockClient[searchResult](ctrl)
		as = &appstore{
			searchClient: mockClient,
		}
	})

	AfterEach(func() {
		ctrl.Finish()
	})

	When("request is successful", func() {
		const (
			testID       = 0
			testBundleID = "test-bundle-id"
			testName     = "test-name"
			testVersion  = "test-version"
			testPrice    = 0.0
		)

		BeforeEach(func() {
			mockClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[searchResult]{
					StatusCode: 200,
					Data: searchResult{
						Count: 1,
						Results: []App{
							{
								ID:       testID,
								BundleID: testBundleID,
								Name:     testName,
								Version:  testVersion,
								Price:    testPrice,
							},
						},
					},
				}, nil)
		})

		It("returns output", func() {
			out, err := as.Search(SearchInput{
				Account: Account{
					StoreFront: "143441",
				},
			})
			Expect(err).ToNot(HaveOccurred())
			Expect(out.Count).To(Equal(1))
			Expect(out.Results).To(HaveLen(1))
			Expect(out.Results[0]).To(Equal(App{
				ID:       testID,
				BundleID: testBundleID,
				Name:     testName,
				Version:  testVersion,
				Price:    testPrice,
			}))
		})
	})

	When("store front is invalid", func() {
		It("returns error", func() {
			_, err := as.Search(SearchInput{
				Account: Account{
					StoreFront: "xyz",
				},
			})
			Expect(err).To(HaveOccurred())
		})
	})

	When("request fails", func() {
		BeforeEach(func() {
			mockClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[searchResult]{}, errors.New(""))
		})

		It("returns error", func() {
			_, err := as.Search(SearchInput{
				Account: Account{
					StoreFront: "143441",
				},
			})
			Expect(err).To(HaveOccurred())
		})
	})

	When("request returns bad status code", func() {
		BeforeEach(func() {
			mockClient.EXPECT().
				Send(gomock.Any()).
				Return(http.Result[searchResult]{
					StatusCode: 400,
				}, nil)
		})

		It("returns error", func() {
			_, err := as.Search(SearchInput{
				Account: Account{
					StoreFront: "143441",
				},
			})
			Expect(err).To(HaveOccurred())
		})
	})
})


================================================
FILE: pkg/appstore/appstore_test.go
================================================
package appstore

import (
	"testing"

	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

func TestAppStore(t *testing.T) {
	RegisterFailHandler(Fail)
	RunSpecs(t, "App Store Suite")
}


================================================
FILE: pkg/appstore/constants.go
================================================
package appstore

const (
	FailureTypeInvalidCredentials     = "-5000"
	FailureTypePasswordTokenExpired   = "2034"
	FailureTypeLicenseNotFound        = "9610"
	FailureTypeTemporarilyUnavailable = "2059"

	CustomerMessageBadLogin             = "MZFinance.BadLogin.Configurator_message"
	CustomerMessageAccountDisabled      = "Your account is disabled."
	CustomerMessageSubscriptionRequired = "Subscription Required"

	iTunesAPIDomain     = "itunes.apple.com"
	iTunesAPIPathSearch = "/search"
	iTunesAPIPathLookup = "/lookup"

	PrivateInitDomain = "init." + iTunesAPIDomain
	PrivateInitPath   = "/bag.xml"

	PrivateAppStoreAPIDomain       = "buy." + iTunesAPIDomain
	PrivateAppStoreAPIPathPurchase = "/WebObjects/MZFinance.woa/wa/buyProduct"
	PrivateAppStoreAPIPathDownload = "/WebObjects/MZFinance.woa/wa/volumeStoreDownloadProduct"

	HTTPHeaderStoreFront = "X-Set-Apple-Store-Front"
	HTTPHeaderPod        = "pod"

	PricingParameterAppStore    = "STDQ"
	PricingParameterAppleArcade = "GAME"
)


================================================
FILE: pkg/appstore/error.go
================================================
package appstore

type Error struct {
	Metadata        interface{}
	underlyingError error
}

func (t Error) Error() string {
	return t.underlyingError.Error()
}

func NewErrorWithMetadata(err error, metadata interface{}) *Error {
	return &Error{
		underlyingError: err,
		Metadata:        metadata,
	}
}


================================================
FILE: pkg/appstore/storefront.go
================================================
package appstore

import (
	"fmt"
	"strings"
)

func countryCodeFromStoreFront(storeFront string) (string, error) {
	for key, val := range storeFronts {
		parts := strings.Split(storeFront, "-")

		if len(parts) >= 1 && parts[0] == val {
			return key, nil
		}
	}

	return "", fmt.Errorf("country code mapping for store front (%s) was not found", storeFront)
}

var storeFronts = map[string]string{
	"AE": "143481",
	"AG": "143540",
	"AI": "143538",
	"AL": "143575",
	"AM": "143524",
	"AO": "143564",
	"AR": "143505",
	"AT": "143445",
	"AU": "143460",
	"AZ": "143568",
	"BB": "143541",
	"BD": "143490",
	"BE": "143446",
	"BG": "143526",
	"BH": "143559",
	"BM": "143542",
	"BN": "143560",
	"BO": "143556",
	"BR": "143503",
	"BS": "143539",
	"BW": "143525",
	"BY": "143565",
	"BZ": "143555",
	"CA": "143455",
	"CH": "143459",
	"CI": "143527",
	"CL": "143483",
	"CN": "143465",
	"CO": "143501",
	"CR": "143495",
	"CY": "143557",
	"CZ": "143489",
	"DE": "143443",
	"DK": "143458",
	"DM": "143545",
	"DO": "143508",
	"DZ": "143563",
	"EC": "143509",
	"EE": "143518",
	"EG": "143516",
	"ES": "143454",
	"FI": "143447",
	"FR": "143442",
	"GB": "143444",
	"GD": "143546",
	"GE": "143615",
	"GH": "143573",
	"GR": "143448",
	"GT": "143504",
	"GY": "143553",
	"HK": "143463",
	"HN": "143510",
	"HR": "143494",
	"HU": "143482",
	"ID": "143476",
	"IE": "143449",
	"IL": "143491",
	"IN": "143467",
	"IS": "143558",
	"IT": "143450",
	"IQ": "143617",
	"JM": "143511",
	"JO": "143528",
	"JP": "143462",
	"KE": "143529",
	"KN": "143548",
	"KR": "143466",
	"KW": "143493",
	"KY": "143544",
	"KZ": "143517",
	"LB": "143497",
	"LC": "143549",
	"LI": "143522",
	"LK": "143486",
	"LT": "143520",
	"LU": "143451",
	"LV": "143519",
	"MD": "143523",
	"MG": "143531",
	"MK": "143530",
	"ML": "143532",
	"MN": "143592",
	"MO": "143515",
	"MS": "143547",
	"MT": "143521",
	"MU": "143533",
	"MV": "143488",
	"MX": "143468",
	"MY": "143473",
	"NE": "143534",
	"NG": "143561",
	"NI": "143512",
	"NL": "143452",
	"NO": "143457",
	"NP": "143484",
	"NZ": "143461",
	"OM": "143562",
	"PA": "143485",
	"PE": "143507",
	"PH": "143474",
	"PK": "143477",
	"PL": "143478",
	"PT": "143453",
	"PY": "143513",
	"QA": "143498",
	"RO": "143487",
	"RS": "143500",
	"RU": "143469",
	"SA": "143479",
	"SE": "143456",
	"SG": "143464",
	"SI": "143499",
	"SK": "143496",
	"SN": "143535",
	"SR": "143554",
	"SV": "143506",
	"TC": "143552",
	"TH": "143475",
	"TN": "143536",
	"TR": "143480",
	"TT": "143551",
	"TW": "143470",
	"TZ": "143572",
	"UA": "143492",
	"UG": "143537",
	"US": "143441",
	"UY": "143514",
	"UZ": "143566",
	"VC": "143550",
	"VE": "143502",
	"VG": "143543",
	"VN": "143471",
	"YE": "143571",
	"ZA": "143472",
}


================================================
FILE: pkg/http/client.go
================================================
package http

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"regexp"
	"strings"

	"howett.net/plist"
)

const (
	appStoreAuthURL = "https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate"
)

var (
	documentXMLPattern = regexp.MustCompile(`(?is)<Document\b[^>]*>(.*)</Document>`)
	plistXMLPattern    = regexp.MustCompile(`(?is)<plist\b[^>]*>.*?</plist>`)
	dictXMLPattern     = regexp.MustCompile(`(?is)<dict\b[^>]*>.*</dict>`)
)

//go:generate go run go.uber.org/mock/mockgen -source=client.go -destination=client_mock.go -package=http
type Client[R interface{}] interface {
	Send(request Request) (Result[R], error)
	Do(req *http.Request) (*http.Response, error)
	NewRequest(method, url string, body io.Reader) (*http.Request, error)
}

type client[R interface{}] struct {
	internalClient http.Client
	cookieJar      CookieJar
}

type Args struct {
	CookieJar CookieJar
}

type AddHeaderTransport struct {
	T http.RoundTripper
}

func (t *AddHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	if req.Header.Get("User-Agent") == "" {
		req.Header.Set("User-Agent", DefaultUserAgent)
	}

	res, err := t.T.RoundTrip(req)
	if err != nil {
		return nil, fmt.Errorf("failed to make round trip: %w", err)
	}

	return res, nil
}

func NewClient[R interface{}](args Args) Client[R] {
	return &client[R]{
		internalClient: http.Client{
			Timeout: 0,
			Jar:     args.CookieJar,
			CheckRedirect: func(req *http.Request, via []*http.Request) error {
				if req.Referer() == appStoreAuthURL {
					return http.ErrUseLastResponse
				}

				return nil
			},
			Transport: &AddHeaderTransport{http.DefaultTransport},
		},
		cookieJar: args.CookieJar,
	}
}

func (c *client[R]) Send(req Request) (Result[R], error) {
	var (
		data []byte
		err  error
	)

	if req.Payload != nil {
		data, err = req.Payload.data()
		if err != nil {
			return Result[R]{}, fmt.Errorf("failed to get payload data: %w", err)
		}
	}

	request, err := http.NewRequest(req.Method, req.URL, bytes.NewReader(data))
	if err != nil {
		return Result[R]{}, fmt.Errorf("failed to create request: %w", err)
	}

	for key, val := range req.Headers {
		request.Header.Set(key, val)
	}

	res, err := c.internalClient.Do(request)
	if err != nil {
		return Result[R]{}, fmt.Errorf("request failed: %w", err)
	}
	defer res.Body.Close()

	err = c.cookieJar.Save()
	if err != nil {
		return Result[R]{}, fmt.Errorf("failed to save cookies: %w", err)
	}

	if req.ResponseFormat == ResponseFormatJSON {
		return c.handleJSONResponse(res)
	}

	if req.ResponseFormat == ResponseFormatXML {
		return c.handleXMLResponse(res)
	}

	return Result[R]{}, fmt.Errorf("content type is not supported (%s)", req.ResponseFormat)
}

func (c *client[R]) Do(req *http.Request) (*http.Response, error) {
	res, err := c.internalClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("received error: %w", err)
	}

	return res, nil
}

func (*client[R]) NewRequest(method, url string, body io.Reader) (*http.Request, error) {
	req, err := http.NewRequest(method, url, body)
	if err != nil {
		return nil, fmt.Errorf("failed to create request: %w", err)
	}

	return req, nil
}

func (c *client[R]) handleJSONResponse(res *http.Response) (Result[R], error) {
	body, err := io.ReadAll(res.Body)
	if err != nil {
		return Result[R]{}, fmt.Errorf("failed to read response body: %w", err)
	}

	var data R

	err = json.Unmarshal(body, &data)
	if err != nil {
		return Result[R]{}, fmt.Errorf("failed to unmarshal json: %w", err)
	}

	return Result[R]{
		StatusCode: res.StatusCode,
		Data:       data,
	}, nil
}

func (c *client[R]) handleXMLResponse(res *http.Response) (Result[R], error) {
	body, err := io.ReadAll(res.Body)
	if err != nil {
		return Result[R]{}, fmt.Errorf("failed to read response body: %w", err)
	}

	var data R

	normalizedBody := normalizeXMLPlistBody(body)

	_, err = plist.Unmarshal(normalizedBody, &data)
	if err != nil {
		return Result[R]{}, fmt.Errorf("failed to unmarshal xml: %w", err)
	}

	headers := map[string]string{}
	for key, val := range res.Header {
		headers[key] = strings.Join(val, "; ")
	}

	return Result[R]{
		StatusCode: res.StatusCode,
		Headers:    headers,
		Data:       data,
	}, nil
}

func normalizeXMLPlistBody(body []byte) []byte {
	normalized := bytes.TrimSpace(body)
	if len(normalized) == 0 {
		return normalized
	}

	if documentBody := extractDocumentInnerBody(normalized); len(documentBody) > 0 {
		normalized = documentBody
	}

	if embeddedPlist := extractEmbeddedPlist(normalized); len(embeddedPlist) > 0 {
		normalized = embeddedPlist
	}

	if dictBody := extractEmbeddedDict(normalized); len(dictBody) > 0 {
		return dictBody
	}

	if bytes.Contains(normalized, []byte("<key>")) {
		return []byte("<dict>" + string(normalized) + "</dict>")
	}

	return normalized
}

func extractEmbeddedPlist(body []byte) []byte {
	plistMatch := plistXMLPattern.Find(body)
	if len(plistMatch) == 0 {
		return nil
	}

	return bytes.TrimSpace(plistMatch)
}

func extractEmbeddedDict(body []byte) []byte {
	dictMatch := dictXMLPattern.Find(body)
	if len(dictMatch) == 0 {
		return nil
	}

	return bytes.TrimSpace(dictMatch)
}

func extractDocumentInnerBody(body []byte) []byte {
	documentMatch := documentXMLPattern.FindSubmatch(body)
	if len(documentMatch) < 2 {
		return nil
	}

	documentBody := bytes.TrimSpace(documentMatch[1])
	if len(documentBody) == 0 {
		return nil
	}

	return documentBody
}


================================================
FILE: pkg/http/client_test.go
================================================
package http

import (
	"errors"
	"net/http"
	"net/http/httptest"

	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	"go.uber.org/mock/gomock"
)

var _ = Describe("Client", Ordered, func() {
	type jsonResult struct {
		Foo string `json:"foo"`
	}

	type xmlResult struct {
		Foo string `plist:"foo"`
	}

	var (
		ctrl          *gomock.Controller
		srv           *httptest.Server
		mockHandler   func(w http.ResponseWriter, r *http.Request)
		mockCookieJar *MockCookieJar
	)

	BeforeAll(func() {
		ctrl = gomock.NewController(GinkgoT())
		mockCookieJar = NewMockCookieJar(ctrl)
		mockHandler = func(w http.ResponseWriter, r *http.Request) {}
		srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			mockHandler(w, r)
		}))
	})

	BeforeEach(func() {
		mockCookieJar.EXPECT().
			Cookies(gomock.Any()).
			Return(nil).
			MaxTimes(1)
	})

	It("returns request", func() {
		sut := NewClient[xmlResult](Args{})

		req, err := sut.NewRequest("GET", srv.URL, nil)
		Expect(err).ToNot(HaveOccurred())
		Expect(req).ToNot(BeNil())
	})

	It("returns response", func() {
		mockHandler = func(_w http.ResponseWriter, r *http.Request) {
			defer GinkgoRecover()
			Expect(r.Header.Get("User-Agent")).To(Equal(DefaultUserAgent))
		}

		sut := NewClient[xmlResult](Args{})

		req, err := sut.NewRequest("GET", srv.URL, nil)
		Expect(err).ToNot(HaveOccurred())
		Expect(req).ToNot(BeNil())

		res, err := sut.Do(req)
		Expect(err).ToNot(HaveOccurred())
		Expect(res).ToNot(BeNil())
	})

	When("payload decodes successfully", func() {
		When("cookie jar fails to save", func() {
			BeforeEach(func() {
				mockCookieJar.EXPECT().
					Save().
					Return(errors.New(""))
			})

			It("returns error", func() {
				sut := NewClient[jsonResult](Args{
					CookieJar: mockCookieJar,
				})
				_, err := sut.Send(Request{
					URL:    srv.URL,
					Method: MethodGET,
				})

				Expect(err).To(HaveOccurred())
			})
		})

		When("cookie jar saves new cookies", func() {
			BeforeEach(func() {
				mockCookieJar.EXPECT().
					Save().
					Return(nil)
			})

			It("decodes JSON response", func() {
				mockHandler = func(w http.ResponseWriter, _r *http.Request) {
					w.Header().Add("Content-Type", "application/json")
					_, err := w.Write([]byte("{\"foo\":\"bar\"}"))
					Expect(err).ToNot(HaveOccurred())
				}

				sut := NewClient[jsonResult](Args{
					CookieJar: mockCookieJar,
				})
				res, err := sut.Send(Request{
					URL:            srv.URL,
					Method:         MethodGET,
					ResponseFormat: ResponseFormatJSON,
					Headers: map[string]string{
						"foo": "bar",
					},
					Payload: &URLPayload{
						Content: map[string]interface{}{
							"data": "test",
						},
					},
				})

				Expect(err).ToNot(HaveOccurred())
				Expect(res.Data.Foo).To(Equal("bar"))
			})

			It("decodes XML response", func() {
				mockHandler = func(w http.ResponseWriter, _r *http.Request) {
					w.Header().Add("Content-Type", "application/xml")
					_, err := w.Write([]byte("<dict><key>foo</key><string>bar</string></dict>"))
					Expect(err).ToNot(HaveOccurred())
				}

				sut := NewClient[xmlResult](Args{
					CookieJar: mockCookieJar,
				})
				res, err := sut.Send(Request{
					URL:            srv.URL,
					Method:         MethodPOST,
					ResponseFormat: ResponseFormatXML,
				})

				Expect(err).ToNot(HaveOccurred())
				Expect(res.Data.Foo).To(Equal("bar"))
			})

			It("decodes XML response wrapped in an Apple Document envelope", func() {
				mockHandler = func(w http.ResponseWriter, _r *http.Request) {
					w.Header().Add("Content-Type", "application/xml")
					_, err := w.Write([]byte("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n<Document xmlns=\"http://www.apple.com/itms/\"><key>foo</key><string>bar</string></Document>"))
					Expect(err).ToNot(HaveOccurred())
				}

				sut := NewClient[xmlResult](Args{
					CookieJar: mockCookieJar,
				})
				res, err := sut.Send(Request{
					URL:            srv.URL,
					Method:         MethodPOST,
					ResponseFormat: ResponseFormatXML,
				})

				Expect(err).ToNot(HaveOccurred())
				Expect(res.Data.Foo).To(Equal("bar"))
			})

			It("decodes XML response wrapped in Document/Protocol/plist with nested dict", func() {
				mockHandler = func(w http.ResponseWriter, _r *http.Request) {
					w.Header().Add("Content-Type", "application/xml")
					_, err := w.Write([]byte("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n<Document xmlns=\"http://www.apple.com/itms/\"><Protocol><plist version=\"1.0\"><dict><key>nested</key><dict><key>a</key><string>b</string></dict><key>foo</key><string>bar</string></dict></plist></Protocol></Document>"))
					Expect(err).ToNot(HaveOccurred())
				}

				sut := NewClient[xmlResult](Args{
					CookieJar: mockCookieJar,
				})
				res, err := sut.Send(Request{
					URL:            srv.URL,
					Method:         MethodPOST,
					ResponseFormat: ResponseFormatXML,
				})

				Expect(err).ToNot(HaveOccurred())
				Expect(res.Data.Foo).To(Equal("bar"))
			})

			It("returns error when content type is not supported", func() {
				mockHandler = func(w http.ResponseWriter, _r *http.Request) {
					w.Header().Add("Content-Type", "application/xyz")
				}

				sut := NewClient[xmlResult](Args{
					CookieJar: mockCookieJar,
				})
				_, err := sut.Send(Request{
					URL:            srv.URL,
					Method:         MethodPOST,
					ResponseFormat: "random",
				})

				Expect(err).To(HaveOccurred())
			})
		})
	})

	When("payload fails to decode", func() {
		It("returns error", func() {
			sut := NewClient[xmlResult](Args{
				CookieJar: mockCookieJar,
			})
			_, err := sut.Send(Request{
				URL:            srv.URL,
				Method:         MethodPOST,
				ResponseFormat: ResponseFormatXML,
				Payload: &URLPayload{
					Content: map[string]interface{}{
						"data": func() {},
					},
				},
			})

			Expect(err).To(HaveOccurred())
		})
	})
})


================================================
FILE: pkg/http/constants.go
================================================
package http

type ResponseFormat string

const (
	ResponseFormatJSON ResponseFormat = "json"
	ResponseFormatXML  ResponseFormat = "xml"
)

const (
	DefaultUserAgent = "Configurator/2.17 (Macintosh; OS X 15.2; 24C5089c) AppleWebKit/0620.1.16.11.6"
)


================================================
FILE: pkg/http/cookiejar.go
================================================
package http

import "net/http"

//go:generate go run go.uber.org/mock/mockgen -source=cookiejar.go -destination=cookiejar_mock.go -package=http
type CookieJar interface {
	http.CookieJar

	Save() error
}


================================================
FILE: pkg/http/http_test.go
================================================
package http

import (
	"testing"

	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

func TestHTTP(t *testing.T) {
	RegisterFailHandler(Fail)
	RunSpecs(t, "HTTP Suite")
}


================================================
FILE: pkg/http/method.go
================================================
package http

const (
	MethodGET  = "GET"
	MethodPOST = "POST"
)


================================================
FILE: pkg/http/payload.go
================================================
package http

import (
	"bytes"
	"fmt"
	"net/url"
	"strconv"

	"howett.net/plist"
)

type Payload interface {
	data() ([]byte, error)
}

type XMLPayload struct {
	Content map[string]interface{}
}

type URLPayload struct {
	Content map[string]interface{}
}

func (p *XMLPayload) data() ([]byte, error) {
	buffer := new(bytes.Buffer)

	err := plist.NewEncoder(buffer).Encode(p.Content)
	if err != nil {
		return nil, fmt.Errorf("failed to encode plist: %w", err)
	}

	return buffer.Bytes(), nil
}

func (p *URLPayload) data() ([]byte, error) {
	params := url.Values{}

	for key, val := range p.Content {
		switch t := val.(type) {
		case string:
			params.Add(key, val.(string))
		case int:
			params.Add(key, strconv.Itoa(val.(int)))
		default:
			return nil, fmt.Errorf("value type is not supported (%s)", t)
		}
	}

	return []byte(params.Encode()), nil
}


================================================
FILE: pkg/http/payload_test.go
================================================
package http

import (
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

var _ = Describe("Payload", func() {
	var sut Payload

	Context("URL Payload", func() {
		It("returns encoded URL data", func() {
			sut = &URLPayload{
				Content: map[string]interface{}{
					"foo": "bar",
					"num": 3,
				},
			}

			data, err := sut.data()
			Expect(err).ToNot(HaveOccurred())
			Expect(data).To(Equal([]byte("foo=bar&num=3")))
		})

		It("returns error if URL data is invalid", func() {
			sut = &URLPayload{
				Content: map[string]interface{}{
					"foo": func() {},
				},
			}

			data, err := sut.data()
			Expect(err).To(HaveOccurred())
			Expect(data).To(BeNil())
		})
	})

	Context("XML Payload", func() {
		It("returns encoded XML data", func() {
			sut = &XMLPayload{
				Content: map[string]interface{}{
					"foo":   "bar",
					"lorem": "ipsum",
				},
			}

			data, err := sut.data()
			Expect(err).ToNot(HaveOccurred())
			Expect(data).To(ContainSubstring("<dict><key>foo</key><string>bar</string><key>lorem</key><string>ipsum</string></dict>"))
		})

		It("returns error if XML data is invalid", func() {
			sut = &XMLPayload{
				Content: map[string]interface{}{
					"foo": func() {},
				},
			}

			data, err := sut.data()
			Expect(err).To(HaveOccurred())
			Expect(data).To(BeNil())
		})
	})
})


================================================
FILE: pkg/http/request.go
================================================
package http

type Request struct {
	Method         string
	URL            string
	Headers        map[string]string
	Payload        Payload
	ResponseFormat ResponseFormat
}


================================================
FILE: pkg/http/result.go
================================================
package http

import (
	"errors"
	"strings"
)

var (
	ErrHeaderNotFound = errors.New("header not found")
)

type Result[R interface{}] struct {
	StatusCode int
	Headers    map[string]string
	Data       R
}

func (c *Result[R]) GetHeader(key string) (string, error) {
	key = strings.ToLower(key)
	for k, v := range c.Headers {
		if strings.ToLower(k) == key {
			return v, nil
		}
	}

	return "", ErrHeaderNotFound
}


================================================
FILE: pkg/keychain/keychain.go
================================================
package keychain

//go:generate go run go.uber.org/mock/mockgen -source=keychain.go -destination=keychain_mock.go -package keychain
type Keychain interface {
	Get(key string) ([]byte, error)
	Set(key string, data []byte) error
	Remove(key string) error
}

type keychain struct {
	keyring Keyring
}

type Args struct {
	Keyring Keyring
}

func New(args Args) Keychain {
	return &keychain{
		keyring: args.Keyring,
	}
}


================================================
FILE: pkg/keychain/keychain_get.go
================================================
package keychain

import (
	"fmt"
)

func (k *keychain) Get(key string) ([]byte, error) {
	item, err := k.keyring.Get(key)
	if err != nil {
		return nil, fmt.Errorf("failed to get item: %w", err)
	}

	return item.Data, nil
}


================================================
FILE: pkg/keychain/keychain_get_test.go
================================================
package keychain

import (
	"errors"

	"github.com/99designs/keyring"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	"go.uber.org/mock/gomock"
)

var _ = Describe("Keychain (Get)", func() {
	var (
		ctrl        *gomock.Controller
		keychain    Keychain
		mockKeyring *MockKeyring
	)

	BeforeEach(func() {
		ctrl = gomock.NewController(GinkgoT())
		mockKeyring = NewMockKeyring(ctrl)
		keychain = New(Args{
			Keyring: mockKeyring,
		})
	})

	AfterEach(func() {
		ctrl.Finish()
	})

	When("keyring returns error", func() {
		const testKey = "test-key"

		BeforeEach(func() {
			mockKeyring.EXPECT().
				Get(testKey).
				Return(keyring.Item{}, errors.New(""))
		})

		It("returns wrapped error", func() {
			data, err := keychain.Get(testKey)
			Expect(err).To(HaveOccurred())
			Expect(data).To(BeNil())
		})
	})

	When("keyring returns item", func() {
		const testKey = "test-key"
		var testData = []byte("test")

		BeforeEach(func() {
			mockKeyring.EXPECT().
				Get(testKey).
				Return(keyring.Item{
					Data: testData,
				}, nil)
		})

		It("returns data", func() {
			data, err := keychain.Get(testKey)
			Expect(err).ToNot(HaveOccurred())
			Expect(data).To(Equal(testData))
		})
	})
})


================================================
FILE: pkg/keychain/keychain_remove.go
================================================
package keychain

import (
	"fmt"
)

func (k *keychain) Remove(key string) error {
	err := k.keyring.Remove(key)
	if err != nil {
		return fmt.Errorf("failed to remove item: %w", err)
	}

	return nil
}


================================================
FILE: pkg/keychain/keychain_remove_test.go
================================================
package keychain

import (
	"errors"

	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	"go.uber.org/mock/gomock"
)

var _ = Describe("Keychain (Remove)", func() {
	var (
		ctrl        *gomock.Controller
		keychain    Keychain
		mockKeyring *MockKeyring
	)

	BeforeEach(func() {
		ctrl = gomock.NewController(GinkgoT())
		mockKeyring = NewMockKeyring(ctrl)
		keychain = New(Args{
			Keyring: mockKeyring,
		})
	})

	AfterEach(func() {
		ctrl.Finish()
	})

	When("keyring returns error", func() {
		const testKey = "test-key"

		BeforeEach(func() {
			mockKeyring.EXPECT().
				Remove(testKey).
				Return(errors.New(""))
		})

		It("returns wrapped error", func() {
			err := keychain.Remove(testKey)
			Expect(err).To(HaveOccurred())
		})
	})

	When("keyring does not return error", func() {
		const testKey = "test-key"

		BeforeEach(func() {
			mockKeyring.EXPECT().
				Remove(testKey).
				Return(nil)
		})

		It("returns data", func() {
			err := keychain.Remove(testKey)
			Expect(err).ToNot(HaveOccurred())
		})
	})
})


================================================
FILE: pkg/keychain/keychain_set.go
================================================
package keychain

import (
	"fmt"

	"github.com/99designs/keyring"
)

func (k *keychain) Set(key string, data []byte) error {
	err := k.keyring.Set(keyring.Item{
		Key:  key,
		Data: data,
	})
	if err != nil {
		return fmt.Errorf("failed to set item: %w", err)
	}

	return nil
}


================================================
FILE: pkg/keychain/keychain_set_test.go
================================================
package keychain

import (
	"errors"

	"github.com/99designs/keyring"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	"go.uber.org/mock/gomock"
)

var _ = Describe("Keychain (Set)", func() {
	var (
		ctrl        *gomock.Controller
		keychain    Keychain
		mockKeyring *MockKeyring
	)

	BeforeEach(func() {
		ctrl = gomock.NewController(GinkgoT())
		mockKeyring = NewMockKeyring(ctrl)
		keychain = New(Args{
			Keyring: mockKeyring,
		})
	})

	AfterEach(func() {
		ctrl.Finish()
	})

	When("keyring returns error", func() {
		const testKey = "test-key"
		var testData = []byte("test")

		BeforeEach(func() {
			mockKeyring.EXPECT().
				Set(keyring.Item{
					Key:  testKey,
					Data: testData,
				}).
				Return(errors.New(""))
		})

		It("returns wrapped error", func() {
			err := keychain.Set(testKey, testData)
			Expect(err).To(HaveOccurred())
		})
	})

	When("keyring does not return error", func() {
		const testKey = "test-key"
		var testData = []byte("test")

		BeforeEach(func() {
			mockKeyring.EXPECT().
				Set(keyring.Item{
					Key:  testKey,
					Data: testData,
				}).
				Return(nil)
		})

		It("returns nil", func() {
			err := keychain.Set(testKey, testData)
			Expect(err).ToNot(HaveOccurred())
		})
	})
})


================================================
FILE: pkg/keychain/keychain_test.go
================================================
package keychain

import (
	"testing"

	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

func TestKeychain(t *testing.T) {
	RegisterFailHandler(Fail)
	RunSpecs(t, "Keychain Suite")
}


================================================
FILE: pkg/keychain/keyring.go
================================================
package keychain

import "github.com/99designs/keyring"

//go:generate go run go.uber.org/mock/mockgen -source=keyring.go -destination=keyring_mock.go -package keychain
type Keyring interface {
	Get(key string) (keyring.Item, error)
	Set(item keyring.Item) error
	Remove(key string) error
}


================================================
FILE: pkg/log/log_test.go
================================================
package log

import (
	"testing"

	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

func TestLog(t *testing.T) {
	RegisterFailHandler(Fail)
	RunSpecs(t, "Log Suite")
}


================================================
FILE: pkg/log/logger.go
================================================
package log

import (
	"io"

	"github.com/rs/zerolog"
	"github.com/rs/zerolog/log"
	"github.com/rs/zerolog/pkgerrors"
)

//go:generate go run go.uber.org/mock/mockgen -source=logger.go -destination=logger_mock.go -package log
type Logger interface {
	Verbose() *zerolog.Event
	Log() *zerolog.Event
	Error() *zerolog.Event
}

type logger struct {
	internalLogger zerolog.Logger
	verbose        bool
}

type Args struct {
	Verbose bool
	Writer  io.Writer
}

func NewLogger(args Args) Logger {
	internalLogger := log.Logger
	level := zerolog.InfoLevel

	if args.Verbose {
		level = zerolog.DebugLevel
		zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
	}

	internalLogger = internalLogger.Output(args.Writer).Level(level)

	return &logger{
		verbose:        args.Verbose,
		internalLogger: internalLogger,
	}
}

func (l *logger) Log() *zerolog.Event {
	return l.internalLogger.Info()
}

func (l *logger) Verbose() *zerolog.Event {
	if !l.verbose {
		return nil
	}

	return l.internalLogger.Debug()
}

func (l *logger) Error() *zerolog.Event {
	return l.internalLogger.Error()
}


================================================
FILE: pkg/log/logger_test.go
================================================
package log

import (
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"

	"github.com/rs/zerolog"
	"go.uber.org/mock/gomock"
)

var _ = Describe("Logger", func() {
	var (
		ctrl       *gomock.Controller
		mockWriter *MockWriter
		logger     Logger
	)

	BeforeEach(func() {
		ctrl = gomock.NewController(GinkgoT())
		mockWriter = NewMockWriter(ctrl)
	})

	Context("Verbose logger", func() {
		BeforeEach(func() {
			logger = NewLogger(Args{
				Verbose: true,
				Writer:  mockWriter,
			})
		})

		When("logging with verbose level", func() {
			It("writes output", func() {
				mockWriter.EXPECT().
					WriteLevel(zerolog.DebugLevel, gomock.Any()).
					Do(func(level zerolog.Level, p []byte) {
						Expect(p).To(ContainSubstring("\"message\":\"verbose\""))
					}).
					Return(0, nil)

				logger.Verbose().Msg("verbose")
			})
		})
	})

	Context("Non-verbose logger", func() {
		BeforeEach(func() {
			logger = NewLogger(Args{
				Verbose: false,
				Writer:  mockWriter,
			})
		})

		When("logging messsage", func() {
			It("writes output", func() {
				mockWriter.EXPECT().
					WriteLevel(zerolog.InfoLevel, gomock.Any()).
					Do(func(level zerolog.Level, p []byte) {
						Expect(p).To(ContainSubstring("\"message\":\"info\""))
					}).
					Return(0, nil)

				logger.Log().Msg("info")
			})
		})

		When("logging error", func() {
			It("writes output", func() {
				mockWriter.EXPECT().
					WriteLevel(zerolog.ErrorLevel, gomock.Any()).
					Do(func(level zerolog.Level, p []byte) {
						Expect(p).To(ContainSubstring("\"message\":\"error\""))
					}).
					Return(0, nil)

				logger.Error().Msg("error")
			})
		})

		When("logging with verbose level", func() {
			It("returns nil", func() {
				res := logger.Verbose()
				Expect(res).To(BeNil())
			})
		})
	})
})


================================================
FILE: pkg/log/writer.go
================================================
package log

import (
	"fmt"
	"io"
	"os"

	"github.com/rs/zerolog"
)

//go:generate go run go.uber.org/mock/mockgen -source=writer.go -destination=writer_mock.go -package log
type Writer interface {
	Write(p []byte) (n int, err error)
	WriteLevel(level zerolog.Level, p []byte) (n int, err error)
}

type writer struct {
	stdOutWriter io.Writer
	stdErrWriter io.Writer
}

func NewWriter() Writer {
	return &writer{
		stdOutWriter: zerolog.ConsoleWriter{Out: os.Stdout},
		stdErrWriter: zerolog.ConsoleWriter{Out: os.Stderr},
	}
}

func (l *writer) Write(p []byte) (int, error) {
	n, err := l.stdOutWriter.Write(p)
	if err != nil {
		return 0, fmt.Errorf("failed to write data: %w", err)
	}

	return n, nil
}

func (l *writer) WriteLevel(level zerolog.Level, p []byte) (int, error) {
	switch level {
	case zerolog.DebugLevel, zerolog.InfoLevel, zerolog.WarnLevel:
		n, err := l.stdOutWriter.Write(p)
		if err != nil {
			return 0, fmt.Errorf("failed to write data: %w", err)
		}

		return n, nil
	case zerolog.ErrorLevel:
		n, err := l.stdErrWriter.Write(p)
		if err != nil {
			return 0, fmt.Errorf("failed to write data: %w", err)
		}

		return n, nil
	default:
		return len(p), nil
	}
}


================================================
FILE: pkg/log/writer_test.go
================================================
package log

import (
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	"github.com/rs/zerolog"
	"go.uber.org/mock/gomock"
)

var _ = Describe("Writer", func() {
	var (
		ctrl             *gomock.Controller
		mockStdoutWriter *MockWriter
		mockStderrWriter *MockWriter
		sut              *writer
	)

	BeforeEach(func() {
		ctrl = gomock.NewController(GinkgoT())
		mockStdoutWriter = NewMockWriter(ctrl)
		mockStderrWriter = NewMockWriter(ctrl)
		sut = &writer{
			stdOutWriter: mockStdoutWriter,
			stdErrWriter: mockStderrWriter,
		}
	})

	It("returns valid writer", func() {
		out := NewWriter()
		Expect(out).ToNot(BeNil())
	})

	When("writing logs", func() {
		It("writes debug logs to stdout", func() {
			mockStdoutWriter.EXPECT().Write([]byte("debug")).Return(0, nil)

			_, err := sut.WriteLevel(zerolog.DebugLevel, []byte("debug"))
			Expect(err).ToNot(HaveOccurred())
		})

		It("writes info logs to stdout", func() {
			mockStdoutWriter.EXPECT().Write([]byte("info")).Return(0, nil).Times(2)

			_, err := sut.Write([]byte("info"))
			Expect(err).ToNot(HaveOccurred())

			_, err = sut.WriteLevel(zerolog.InfoLevel, []byte("info"))
			Expect(err).ToNot(HaveOccurred())
		})

		It("writes warn logs to stdout", func() {
			mockStdoutWriter.EXPECT().Write([]byte("warning")).Return(0, nil)

			_, err := sut.WriteLevel(zerolog.WarnLevel, []byte("warning"))
			Expect(err).ToNot(HaveOccurred())
		})

		It("writes error logs to stderr", func() {
			mockStderrWriter.EXPECT().Write([]byte("error")).Return(0, nil)

			_, err := sut.WriteLevel(zerolog.ErrorLevel, []byte("error"))
			Expect(err).ToNot(HaveOccurred())
		})
	})

	When("log level is not supported", func() {
		It("returns the length of the passed log", func() {
			length, err := sut.WriteLevel(zerolog.PanicLevel, []byte("panic"))
			Expect(err).ToNot(HaveOccurred())
			Expect(length).To(Equal(5))
		})
	})
})


================================================
FILE: pkg/util/machine/machine.go
================================================
package machine

import (
	"fmt"
	"net"
	"path/filepath"
	"runtime"

	"github.com/majd/ipatool/v2/pkg/util/operatingsystem"
	"golang.org/x/term"
)

//go:generate go run go.uber.org/mock/mockgen -source=machine.go -destination=machine_mock.go -package machine
type Machine interface {
	MacAddress() (string, error)
	HomeDirectory() string
	ReadPassword(fd int) ([]byte, error)
}

type machine struct {
	os operatingsystem.OperatingSystem
}

type Args struct {
	OS operatingsystem.OperatingSystem
}

func New(args Args) Machine {
	return &machine{
		os: args.OS,
	}
}

func (*machine) MacAddress() (string, error) {
	interfaces, err := net.Interfaces()
	if err != nil {
		return "", fmt.Errorf("failed to get network interfaces: %w", err)
	}

	if len(interfaces) == 0 {
		return "", fmt.Errorf("could not find network interfaces: %w", err)
	}

	for _, netInterface := range interfaces {
		addr := netInterface.HardwareAddr.String()
		if addr != "" {
			return addr, nil
		}
	}

	return "", fmt.Errorf("could not find network interfaces with a valid mac address: %w", err)
}

func (m *machine) HomeDirectory() string {
	if runtime.GOOS == "windows" {
		return filepath.Join(m.os.Getenv("HOMEDRIVE"), m.os.Getenv("HOMEPATH"))
	}

	return m.os.Getenv("HOME")
}

func (*machine) ReadPassword(fd int) ([]byte, error) {
	data, err := term.ReadPassword(fd)
	if err != nil {
		return nil, fmt.Errorf("failed to read password: %w", err)
	}

	return data, nil
}


================================================
FILE: pkg/util/machine/machine_test.go
================================================
package machine

import (
	"syscall"
	"testing"

	"github.com/majd/ipatool/v2/pkg/util/operatingsystem"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	"go.uber.org/mock/gomock"
)

func TestMachine(t *testing.T) {
	RegisterFailHandler(Fail)
	RunSpecs(t, "Machine Suite")
}

var _ = Describe("Machine", func() {
	var (
		ctrl    *gomock.Controller
		machine Machine
		mockOS  *operatingsystem.MockOperatingSystem
	)

	BeforeEach(func() {
		ctrl = gomock.NewController(GinkgoT())
		mockOS = operatingsystem.NewMockOperatingSystem(ctrl)
		machine = New(Args{
			OS: mockOS,
		})
	})

	When("OperatingSystem is darwin", func() {
		BeforeEach(func() {
			mockOS.EXPECT().
				Getenv("HOME").
				Return("/home/test")
		})

		It("returns home directory from HOME", func() {
			dir := machine.HomeDirectory()
			Expect(dir).To(Equal("/home/test"))
		})
	})

	When("machine has network interfaces", func() {
		It("returns MAC address of the first interface", func() {
			res, err := machine.MacAddress()
			Expect(err).ToNot(HaveOccurred())
			Expect(res).To(ContainSubstring(":"))
		})
	})

	When("reading password from stdout", func() {
		It("returns error", func() {
			_, err := machine.ReadPassword(syscall.Stdout)
			Expect(err).To(HaveOccurred())
		})
	})
})


================================================
FILE: pkg/util/must.go
================================================
package util

// Must is a helper that wraps a call to a function returning (T, error) and panics if the error is non-nil.
func Must[T any](val T, err error) T {
	if err != nil {
		panic(err.Error())
	}

	return val
}


================================================
FILE: pkg/util/must_test.go
================================================
package util

import (
	"errors"

	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

var _ = Describe("Must", func() {
	It("returns current value", func() {
		res := Must("value", nil)
		Expect(res).To(Equal("value"))
	})

	It("panics", func() {
		defer func() {
			r := recover()
			Expect(r).To(Equal("test"))
		}()

		_ = Must("value", errors.New("test"))
	})
})


================================================
FILE: pkg/util/operatingsystem/operatingsystem.go
================================================
package operatingsystem

import (
	"os"
)

//go:generate go run go.uber.org/mock/mockgen -source=operatingsystem.go -destination=operatingsystem_mock.go -package operatingsystem
type OperatingSystem interface {
	Getenv(key string) string
	Stat(name string) (os.FileInfo, error)
	Getwd() (string, error)
	OpenFile(name string, flag int, perm os.FileMode) (*os.File, error)
	Remove(name string) error
	IsNotExist(err error) bool
	MkdirAll(path string, perm os.FileMode) error
	Rename(oldPath, newPath string) erro
Download .txt
gitextract_bcs5uo8x/

├── .github/
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug-report.yaml
│   │   └── feature-request.yaml
│   └── workflows/
│       ├── dry-build.yml
│       ├── integration-tests.yml
│       ├── lint.yml
│       ├── release.yml
│       └── unit-tests.yml
├── .gitignore
├── .golangci.yml
├── AGENTS.md
├── LICENSE
├── README.md
├── cmd/
│   ├── auth.go
│   ├── common.go
│   ├── constants.go
│   ├── download.go
│   ├── get_version_metadata.go
│   ├── list_versions.go
│   ├── output_format.go
│   ├── purchase.go
│   ├── root.go
│   └── search.go
├── go.mod
├── go.sum
├── main.go
├── pkg/
│   ├── appstore/
│   │   ├── account.go
│   │   ├── app.go
│   │   ├── app_test.go
│   │   ├── appstore.go
│   │   ├── appstore_account_info.go
│   │   ├── appstore_account_info_test.go
│   │   ├── appstore_bag.go
│   │   ├── appstore_bag_test.go
│   │   ├── appstore_download.go
│   │   ├── appstore_download_test.go
│   │   ├── appstore_get_version_metadata.go
│   │   ├── appstore_get_version_metadata_test.go
│   │   ├── appstore_list_versions.go
│   │   ├── appstore_list_versions_test.go
│   │   ├── appstore_login.go
│   │   ├── appstore_login_test.go
│   │   ├── appstore_lookup.go
│   │   ├── appstore_lookup_test.go
│   │   ├── appstore_purchase.go
│   │   ├── appstore_purchase_test.go
│   │   ├── appstore_replicate_sinf.go
│   │   ├── appstore_replicate_sinf_test.go
│   │   ├── appstore_revoke.go
│   │   ├── appstore_revoke_test.go
│   │   ├── appstore_search.go
│   │   ├── appstore_search_test.go
│   │   ├── appstore_test.go
│   │   ├── constants.go
│   │   ├── error.go
│   │   └── storefront.go
│   ├── http/
│   │   ├── client.go
│   │   ├── client_test.go
│   │   ├── constants.go
│   │   ├── cookiejar.go
│   │   ├── http_test.go
│   │   ├── method.go
│   │   ├── payload.go
│   │   ├── payload_test.go
│   │   ├── request.go
│   │   └── result.go
│   ├── keychain/
│   │   ├── keychain.go
│   │   ├── keychain_get.go
│   │   ├── keychain_get_test.go
│   │   ├── keychain_remove.go
│   │   ├── keychain_remove_test.go
│   │   ├── keychain_set.go
│   │   ├── keychain_set_test.go
│   │   ├── keychain_test.go
│   │   └── keyring.go
│   ├── log/
│   │   ├── log_test.go
│   │   ├── logger.go
│   │   ├── logger_test.go
│   │   ├── writer.go
│   │   └── writer_test.go
│   └── util/
│       ├── machine/
│       │   ├── machine.go
│       │   └── machine_test.go
│       ├── must.go
│       ├── must_test.go
│       ├── operatingsystem/
│       │   ├── operatingsystem.go
│       │   └── operatingsystem_test.go
│       ├── string.go
│       ├── string_test.go
│       ├── util_test.go
│       ├── zip.go
│       └── zip_test.go
├── tools/
│   └── sha256sum.sh
└── tools.go
Download .txt
SYMBOL INDEX (214 symbols across 56 files)

FILE: cmd/auth.go
  function authCmd (line 18) | func authCmd() *cobra.Command {
  function loginCmd (line 31) | func loginCmd() *cobra.Command {
  function infoCmd (line 138) | func infoCmd() *cobra.Command {
  function revokeCmd (line 160) | func revokeCmd() *cobra.Command {

FILE: cmd/common.go
  type Dependencies (line 28) | type Dependencies struct
  function newLogger (line 38) | func newLogger(format OutputFormat, verbose bool) log.Logger {
  function newCookieJar (line 56) | func newCookieJar(machine machine.Machine) http.CookieJar {
  function newKeychain (line 63) | func newKeychain(machine machine.Machine, logger log.Logger, interactive...
  function initWithCommand (line 100) | func initWithCommand(cmd *cobra.Command) {
  function createConfigDirectory (line 121) | func createConfigDirectory(os operatingsystem.OperatingSystem, machine m...

FILE: cmd/constants.go
  constant ConfigDirectoryName (line 4) | ConfigDirectoryName = ".ipatool"
  constant CookieJarFileName (line 5) | CookieJarFileName   = "cookies"
  constant KeychainServiceName (line 6) | KeychainServiceName = "ipatool-auth.service"

FILE: cmd/download.go
  function downloadCmd (line 15) | func downloadCmd() *cobra.Command {

FILE: cmd/get_version_metadata.go
  function getVersionMetadataCmd (line 13) | func getVersionMetadataCmd() *cobra.Command {

FILE: cmd/list_versions.go
  function ListVersionsCmd (line 13) | func ListVersionsCmd() *cobra.Command {

FILE: cmd/output_format.go
  type OutputFormat (line 9) | type OutputFormat
  constant OutputFormatText (line 12) | OutputFormatText OutputFormat = iota
  constant OutputFormatJSON (line 13) | OutputFormatJSON
  function OutputFormatFromString (line 16) | func OutputFormatFromString(value string) (OutputFormat, error) {

FILE: cmd/purchase.go
  function purchaseCmd (line 13) | func purchaseCmd() *cobra.Command {

FILE: cmd/root.go
  function rootCmd (line 15) | func rootCmd() *cobra.Command {
  function Execute (line 55) | func Execute() int {

FILE: cmd/search.go
  function searchCmd (line 9) | func searchCmd() *cobra.Command {

FILE: main.go
  function main (line 9) | func main() {

FILE: pkg/appstore/account.go
  type Account (line 3) | type Account struct

FILE: pkg/appstore/app.go
  type App (line 7) | type App struct
    method MarshalZerologObject (line 36) | func (a App) MarshalZerologObject(event *zerolog.Event) {
  type VersionHistoryInfo (line 15) | type VersionHistoryInfo struct
  type VersionDetails (line 21) | type VersionDetails struct
  type Apps (line 28) | type Apps
    method MarshalZerologArray (line 30) | func (apps Apps) MarshalZerologArray(a *zerolog.Array) {

FILE: pkg/appstore/appstore.go
  type AppStore (line 10) | type AppStore interface
  type appstore (line 36) | type appstore struct
  type Args (line 48) | type Args struct
  function NewAppStore (line 55) | func NewAppStore(args Args) AppStore {

FILE: pkg/appstore/appstore_account_info.go
  type AccountInfoOutput (line 8) | type AccountInfoOutput struct
  method AccountInfo (line 12) | func (t *appstore) AccountInfo() (AccountInfoOutput, error) {

FILE: pkg/appstore/appstore_bag.go
  type BagInput (line 11) | type BagInput struct
  type BagOutput (line 13) | type BagOutput struct
  method Bag (line 17) | func (t *appstore) Bag(input BagInput) (BagOutput, error) {
  type bagResult (line 40) | type bagResult struct
  type urlBag (line 44) | type urlBag struct
  method bagRequest (line 48) | func (*appstore) bagRequest(guid string) http.Request {

FILE: pkg/appstore/appstore_download.go
  type DownloadInput (line 21) | type DownloadInput struct
  type DownloadOutput (line 29) | type DownloadOutput struct
  method Download (line 34) | func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) {
  type downloadItemResult (line 104) | type downloadItemResult struct
  type downloadResult (line 111) | type downloadResult struct
  method downloadFile (line 117) | func (t *appstore) downloadFile(src, dst string, progress *progressbar.P...
  method downloadRequest (line 170) | func (*appstore) downloadRequest(acc Account, app App, guid string, exte...
  function fileName (line 201) | func fileName(app App, version string) string {
  method resolveDestinationPath (line 219) | func (t *appstore) resolveDestinationPath(app App, version string, path ...
  method isDirectory (line 243) | func (t *appstore) isDirectory(path string) (bool, error) {
  method applyPatches (line 256) | func (t *appstore) applyPatches(item downloadItemResult, acc Account, sr...
  method writeMetadata (line 284) | func (t *appstore) writeMetadata(metadata map[string]interface{}, acc Ac...

FILE: pkg/appstore/appstore_download_test.go
  type dummyFileInfo (line 24) | type dummyFileInfo struct
    method Name (line 26) | func (d *dummyFileInfo) Name() string       { return "dummy" }
    method Size (line 27) | func (d *dummyFileInfo) Size() int64        { return 0 }
    method Mode (line 28) | func (d *dummyFileInfo) Mode() fs.FileMode  { return 0 }
    method ModTime (line 29) | func (d *dummyFileInfo) ModTime() time.Time { return time.Time{} }
    method IsDir (line 30) | func (d *dummyFileInfo) IsDir() bool        { return false }
    method Sys (line 31) | func (d *dummyFileInfo) Sys() interface{}   { return nil }

FILE: pkg/appstore/appstore_get_version_metadata.go
  type GetVersionMetadataInput (line 12) | type GetVersionMetadataInput struct
  type GetVersionMetadataOutput (line 18) | type GetVersionMetadataOutput struct
  method GetVersionMetadata (line 23) | func (t *appstore) GetVersionMetadata(input GetVersionMetadataInput) (Ge...
  method getVersionMetadataRequest (line 71) | func (t *appstore) getVersionMetadataRequest(acc Account, app App, guid ...

FILE: pkg/appstore/appstore_list_versions.go
  type ListVersionsInput (line 11) | type ListVersionsInput struct
  type ListVersionsOutput (line 16) | type ListVersionsOutput struct
  method ListVersions (line 21) | func (t *appstore) ListVersions(input ListVersionsInput) (ListVersionsOu...
  method listVersionsRequest (line 79) | func (t *appstore) listVersionsRequest(acc Account, app App, guid string...

FILE: pkg/appstore/appstore_login.go
  type LoginInput (line 19) | type LoginInput struct
  type LoginOutput (line 26) | type LoginOutput struct
  method Login (line 30) | func (t *appstore) Login(input LoginInput) (LoginOutput, error) {
  type loginAddressResult (line 48) | type loginAddressResult struct
  type loginAccountResult (line 53) | type loginAccountResult struct
  type loginResult (line 58) | type loginResult struct
  method login (line 66) | func (t *appstore) login(email, password, authCode, guid, endpoint strin...
  method parseLoginResponse (line 128) | func (t *appstore) parseLoginResponse(res *http.Result[loginResult], att...
  method loginRequest (line 160) | func (t *appstore) loginRequest(email, password, authCode, guid, endpoin...

FILE: pkg/appstore/appstore_lookup.go
  type LookupInput (line 12) | type LookupInput struct
  type LookupOutput (line 17) | type LookupOutput struct
  method Lookup (line 21) | func (t *appstore) Lookup(input LookupInput) (LookupOutput, error) {
  method lookupRequest (line 47) | func (t *appstore) lookupRequest(bundleID, countryCode string) http.Requ...
  method lookupURL (line 55) | func (t *appstore) lookupURL(bundleID, countryCode string) string {

FILE: pkg/appstore/appstore_purchase.go
  type PurchaseInput (line 18) | type PurchaseInput struct
  method Purchase (line 23) | func (t *appstore) Purchase(input PurchaseInput) error {
  type purchaseResult (line 52) | type purchaseResult struct
  method purchaseWithParams (line 59) | func (t *appstore) purchaseWithParams(acc Account, app App, guid string,...
  method purchaseRequest (line 98) | func (t *appstore) purchaseRequest(acc Account, app App, storeFront, gui...

FILE: pkg/appstore/appstore_purchase_test.go
  type pricingParametersMatcher (line 331) | type pricingParametersMatcher struct
    method Matches (line 335) | func (p pricingParametersMatcher) Matches(in interface{}) bool {
    method String (line 339) | func (p pricingParametersMatcher) String() string {

FILE: pkg/appstore/appstore_replicate_sinf.go
  type Sinf (line 17) | type Sinf struct
  type ReplicateSinfInput (line 22) | type ReplicateSinfInput struct
  method ReplicateSinf (line 27) | func (t *appstore) ReplicateSinf(input ReplicateSinfInput) error {
  type packageManifest (line 89) | type packageManifest struct
  type packageInfo (line 93) | type packageInfo struct
  method replicateSinfFromManifest (line 97) | func (*appstore) replicateSinfFromManifest(manifest packageManifest, zip...
  method replicateSinfFromInfo (line 120) | func (t *appstore) replicateSinfFromInfo(info packageInfo, zip *zip.Writ...
  method replicateZip (line 136) | func (t *appstore) replicateZip(src *zip.ReadCloser, dst *zip.Writer) er...
  method readInfoPlist (line 159) | func (*appstore) readInfoPlist(reader *zip.ReadCloser) (*packageInfo, er...
  method readManifestPlist (line 188) | func (*appstore) readManifestPlist(reader *zip.ReadCloser) (*packageMani...
  method readBundleName (line 217) | func (*appstore) readBundleName(reader *zip.ReadCloser) (string, error) {

FILE: pkg/appstore/appstore_revoke.go
  method Revoke (line 7) | func (t *appstore) Revoke() error {

FILE: pkg/appstore/appstore_search.go
  type SearchInput (line 13) | type SearchInput struct
  type SearchOutput (line 19) | type SearchOutput struct
  method Search (line 24) | func (t *appstore) Search(input SearchInput) (SearchOutput, error) {
  type searchResult (line 47) | type searchResult struct
  method searchRequest (line 52) | func (t *appstore) searchRequest(term, countryCode string, limit int64) ...
  method searchURL (line 60) | func (t *appstore) searchURL(term, countryCode string, limit int64) stri...

FILE: pkg/appstore/appstore_test.go
  function TestAppStore (line 10) | func TestAppStore(t *testing.T) {

FILE: pkg/appstore/constants.go
  constant FailureTypeInvalidCredentials (line 4) | FailureTypeInvalidCredentials     = "-5000"
  constant FailureTypePasswordTokenExpired (line 5) | FailureTypePasswordTokenExpired   = "2034"
  constant FailureTypeLicenseNotFound (line 6) | FailureTypeLicenseNotFound        = "9610"
  constant FailureTypeTemporarilyUnavailable (line 7) | FailureTypeTemporarilyUnavailable = "2059"
  constant CustomerMessageBadLogin (line 9) | CustomerMessageBadLogin             = "MZFinance.BadLogin.Configurator_m...
  constant CustomerMessageAccountDisabled (line 10) | CustomerMessageAccountDisabled      = "Your account is disabled."
  constant CustomerMessageSubscriptionRequired (line 11) | CustomerMessageSubscriptionRequired = "Subscription Required"
  constant iTunesAPIDomain (line 13) | iTunesAPIDomain     = "itunes.apple.com"
  constant iTunesAPIPathSearch (line 14) | iTunesAPIPathSearch = "/search"
  constant iTunesAPIPathLookup (line 15) | iTunesAPIPathLookup = "/lookup"
  constant PrivateInitDomain (line 17) | PrivateInitDomain = "init." + iTunesAPIDomain
  constant PrivateInitPath (line 18) | PrivateInitPath   = "/bag.xml"
  constant PrivateAppStoreAPIDomain (line 20) | PrivateAppStoreAPIDomain       = "buy." + iTunesAPIDomain
  constant PrivateAppStoreAPIPathPurchase (line 21) | PrivateAppStoreAPIPathPurchase = "/WebObjects/MZFinance.woa/wa/buyProduct"
  constant PrivateAppStoreAPIPathDownload (line 22) | PrivateAppStoreAPIPathDownload = "/WebObjects/MZFinance.woa/wa/volumeSto...
  constant HTTPHeaderStoreFront (line 24) | HTTPHeaderStoreFront = "X-Set-Apple-Store-Front"
  constant HTTPHeaderPod (line 25) | HTTPHeaderPod        = "pod"
  constant PricingParameterAppStore (line 27) | PricingParameterAppStore    = "STDQ"
  constant PricingParameterAppleArcade (line 28) | PricingParameterAppleArcade = "GAME"

FILE: pkg/appstore/error.go
  type Error (line 3) | type Error struct
    method Error (line 8) | func (t Error) Error() string {
  function NewErrorWithMetadata (line 12) | func NewErrorWithMetadata(err error, metadata interface{}) *Error {

FILE: pkg/appstore/storefront.go
  function countryCodeFromStoreFront (line 8) | func countryCodeFromStoreFront(storeFront string) (string, error) {

FILE: pkg/http/client.go
  constant appStoreAuthURL (line 16) | appStoreAuthURL = "https://buy.itunes.apple.com/WebObjects/MZFinance.woa...
  type Client (line 26) | type Client interface
  type client (line 32) | type client struct
  type Args (line 37) | type Args struct
  type AddHeaderTransport (line 41) | type AddHeaderTransport struct
    method RoundTrip (line 45) | func (t *AddHeaderTransport) RoundTrip(req *http.Request) (*http.Respo...
  function NewClient (line 58) | func NewClient[R interface{}](args Args) Client[R] {
  method Send (line 76) | func (c *client[R]) Send(req Request) (Result[R], error) {
  method Do (line 120) | func (c *client[R]) Do(req *http.Request) (*http.Response, error) {
  method NewRequest (line 129) | func (*client[R]) NewRequest(method, url string, body io.Reader) (*http....
  method handleJSONResponse (line 138) | func (c *client[R]) handleJSONResponse(res *http.Response) (Result[R], e...
  method handleXMLResponse (line 157) | func (c *client[R]) handleXMLResponse(res *http.Response) (Result[R], er...
  function normalizeXMLPlistBody (line 184) | func normalizeXMLPlistBody(body []byte) []byte {
  function extractEmbeddedPlist (line 209) | func extractEmbeddedPlist(body []byte) []byte {
  function extractEmbeddedDict (line 218) | func extractEmbeddedDict(body []byte) []byte {
  function extractDocumentInnerBody (line 227) | func extractDocumentInnerBody(body []byte) []byte {

FILE: pkg/http/constants.go
  type ResponseFormat (line 3) | type ResponseFormat
  constant ResponseFormatJSON (line 6) | ResponseFormatJSON ResponseFormat = "json"
  constant ResponseFormatXML (line 7) | ResponseFormatXML  ResponseFormat = "xml"
  constant DefaultUserAgent (line 11) | DefaultUserAgent = "Configurator/2.17 (Macintosh; OS X 15.2; 24C5089c) A...

FILE: pkg/http/cookiejar.go
  type CookieJar (line 6) | type CookieJar interface

FILE: pkg/http/http_test.go
  function TestHTTP (line 10) | func TestHTTP(t *testing.T) {

FILE: pkg/http/method.go
  constant MethodGET (line 4) | MethodGET  = "GET"
  constant MethodPOST (line 5) | MethodPOST = "POST"

FILE: pkg/http/payload.go
  type Payload (line 12) | type Payload interface
  type XMLPayload (line 16) | type XMLPayload struct
    method data (line 24) | func (p *XMLPayload) data() ([]byte, error) {
  type URLPayload (line 20) | type URLPayload struct
    method data (line 35) | func (p *URLPayload) data() ([]byte, error) {

FILE: pkg/http/request.go
  type Request (line 3) | type Request struct

FILE: pkg/http/result.go
  type Result (line 12) | type Result struct
  method GetHeader (line 18) | func (c *Result[R]) GetHeader(key string) (string, error) {

FILE: pkg/keychain/keychain.go
  type Keychain (line 4) | type Keychain interface
  type keychain (line 10) | type keychain struct
  type Args (line 14) | type Args struct
  function New (line 18) | func New(args Args) Keychain {

FILE: pkg/keychain/keychain_get.go
  method Get (line 7) | func (k *keychain) Get(key string) ([]byte, error) {

FILE: pkg/keychain/keychain_remove.go
  method Remove (line 7) | func (k *keychain) Remove(key string) error {

FILE: pkg/keychain/keychain_set.go
  method Set (line 9) | func (k *keychain) Set(key string, data []byte) error {

FILE: pkg/keychain/keychain_test.go
  function TestKeychain (line 10) | func TestKeychain(t *testing.T) {

FILE: pkg/keychain/keyring.go
  type Keyring (line 6) | type Keyring interface

FILE: pkg/log/log_test.go
  function TestLog (line 10) | func TestLog(t *testing.T) {

FILE: pkg/log/logger.go
  type Logger (line 12) | type Logger interface
  type logger (line 18) | type logger struct
    method Log (line 45) | func (l *logger) Log() *zerolog.Event {
    method Verbose (line 49) | func (l *logger) Verbose() *zerolog.Event {
    method Error (line 57) | func (l *logger) Error() *zerolog.Event {
  type Args (line 23) | type Args struct
  function NewLogger (line 28) | func NewLogger(args Args) Logger {

FILE: pkg/log/writer.go
  type Writer (line 12) | type Writer interface
  type writer (line 17) | type writer struct
    method Write (line 29) | func (l *writer) Write(p []byte) (int, error) {
    method WriteLevel (line 38) | func (l *writer) WriteLevel(level zerolog.Level, p []byte) (int, error) {
  function NewWriter (line 22) | func NewWriter() Writer {

FILE: pkg/util/machine/machine.go
  type Machine (line 14) | type Machine interface
  type machine (line 20) | type machine struct
    method MacAddress (line 34) | func (*machine) MacAddress() (string, error) {
    method HomeDirectory (line 54) | func (m *machine) HomeDirectory() string {
    method ReadPassword (line 62) | func (*machine) ReadPassword(fd int) ([]byte, error) {
  type Args (line 24) | type Args struct
  function New (line 28) | func New(args Args) Machine {

FILE: pkg/util/machine/machine_test.go
  function TestMachine (line 13) | func TestMachine(t *testing.T) {

FILE: pkg/util/must.go
  function Must (line 4) | func Must[T any](val T, err error) T {

FILE: pkg/util/operatingsystem/operatingsystem.go
  type OperatingSystem (line 8) | type OperatingSystem interface
  type operatingSystem (line 19) | type operatingSystem struct
    method Getenv (line 25) | func (operatingSystem) Getenv(key string) string {
    method Stat (line 30) | func (operatingSystem) Stat(name string) (os.FileInfo, error) {
    method Getwd (line 35) | func (operatingSystem) Getwd() (string, error) {
    method OpenFile (line 40) | func (operatingSystem) OpenFile(name string, flag int, perm os.FileMod...
    method Remove (line 45) | func (operatingSystem) Remove(name string) error {
    method IsNotExist (line 50) | func (operatingSystem) IsNotExist(err error) bool {
    method MkdirAll (line 55) | func (operatingSystem) MkdirAll(path string, perm os.FileMode) error {
    method Rename (line 60) | func (operatingSystem) Rename(oldPath, newPath string) error {
  function New (line 21) | func New() OperatingSystem {

FILE: pkg/util/operatingsystem/operatingsystem_test.go
  function TestOS (line 16) | func TestOS(t *testing.T) {

FILE: pkg/util/string.go
  function IfEmpty (line 3) | func IfEmpty(value, fallback string) string {

FILE: pkg/util/util_test.go
  function TestUtil (line 10) | func TestUtil(t *testing.T) {

FILE: pkg/util/zip.go
  type Pair (line 5) | type Pair struct
  function Zip (line 10) | func Zip[T, U any](ts []T, us []U) ([]Pair[T, U], error) {
Condensed preview — 93 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (223K chars).
[
  {
    "path": ".github/FUNDING.yml",
    "chars": 35,
    "preview": "# github: [majd]\npatreon: majd_dev\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug-report.yaml",
    "chars": 661,
    "preview": "name: Bug Report\ndescription: File a bug report\nlabels:\n  - bug\nbody:\n  - type: textarea\n    id: description\n    attribu"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature-request.yaml",
    "chars": 281,
    "preview": "name: Feature Request\ndescription: Submit a feature request\nlabels:\n  - feature request\nbody:\n  - type: textarea\n    id:"
  },
  {
    "path": ".github/workflows/dry-build.yml",
    "chars": 1417,
    "preview": "name: Dry Build\n\non:\n  pull_request:\n    branches:\n      - main\n\njobs:\n  build_windows:\n    name: Build for Windows\n    "
  },
  {
    "path": ".github/workflows/integration-tests.yml",
    "chars": 940,
    "preview": "name: Integration Tests\n\non:\n  pull_request:\n    branches:\n      - main\n\njobs:\n  build:\n    name: Build\n    runs-on: mac"
  },
  {
    "path": ".github/workflows/lint.yml",
    "chars": 399,
    "preview": "name: Lint\n\non:\n  pull_request:\n    branches:\n      - main\n\njobs:\n  lint:\n    name: Lint\n    runs-on: macos-latest\n    s"
  },
  {
    "path": ".github/workflows/release.yml",
    "chars": 9559,
    "preview": "name: Release\n\non:\n  push:\n    tags:\n      - \"v*\"\n\njobs:\n  get_version:\n    name: Get version\n    runs-on: ubuntu-latest"
  },
  {
    "path": ".github/workflows/unit-tests.yml",
    "chars": 383,
    "preview": "name: Unit Tests\n\non:\n  pull_request:\n    branches:\n      - main\n\njobs:\n  run_tests:\n    name: Run tests\n    runs-on: ub"
  },
  {
    "path": ".gitignore",
    "chars": 68,
    "preview": ".DS_Store\n.AppleDouble\n.LSOverride\n.vscode/\n.idea/\n**/*_mock.go\nexp/"
  },
  {
    "path": ".golangci.yml",
    "chars": 607,
    "preview": "version: \"2\"\nlinters:\n  enable:\n    - ginkgolinter\n    - godot\n    - godox\n    - importas\n    - nlreturn\n    - nonamedre"
  },
  {
    "path": "AGENTS.md",
    "chars": 1003,
    "preview": "# AGENTS.md\n\nThis file provides guidance for coding agents working in this repository.\n\n## Repository overview\n- Project"
  },
  {
    "path": "LICENSE",
    "chars": 1069,
    "preview": "MIT License\n\nCopyright (c) 2021 Majd Alfhaily\n\nPermission is hereby granted, free of charge, to any person obtaining a c"
  },
  {
    "path": "README.md",
    "chars": 5944,
    "preview": "# IPATool\n\n[![Release](https://img.shields.io/github/release/majd/ipatool.svg?label=Release)](https://GitHub.com/majd/ip"
  },
  {
    "path": "cmd/auth.go",
    "chars": 4158,
    "preview": "package cmd\n\nimport (\n\t\"bufio\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/avast/retry-go\"\n\t\"github.com/majd"
  },
  {
    "path": "cmd/common.go",
    "chars": 4195,
    "preview": "package cmd\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/99designs/keyring\"\n\tcookiej"
  },
  {
    "path": "cmd/constants.go",
    "chars": 136,
    "preview": "package cmd\n\nconst (\n\tConfigDirectoryName = \".ipatool\"\n\tCookieJarFileName   = \"cookies\"\n\tKeychainServiceName = \"ipatool-"
  },
  {
    "path": "cmd/download.go",
    "chars": 4059,
    "preview": "package cmd\n\nimport (\n\t\"errors\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/avast/retry-go\"\n\t\"github.com/majd/ipatool/v2/pkg/appstore\"\n\t"
  },
  {
    "path": "cmd/get_version_metadata.go",
    "chars": 2484,
    "preview": "package cmd\n\nimport (\n\t\"errors\"\n\t\"time\"\n\n\t\"github.com/avast/retry-go\"\n\t\"github.com/majd/ipatool/v2/pkg/appstore\"\n\t\"githu"
  },
  {
    "path": "cmd/list_versions.go",
    "chars": 2129,
    "preview": "package cmd\n\nimport (\n\t\"errors\"\n\t\"time\"\n\n\t\"github.com/avast/retry-go\"\n\t\"github.com/majd/ipatool/v2/pkg/appstore\"\n\t\"githu"
  },
  {
    "path": "cmd/output_format.go",
    "chars": 430,
    "preview": "package cmd\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/thediveo/enumflag/v2\"\n)\n\ntype OutputFormat enumflag.Flag\n\nconst (\n\tOutputForm"
  },
  {
    "path": "cmd/purchase.go",
    "chars": 1691,
    "preview": "package cmd\n\nimport (\n\t\"errors\"\n\t\"time\"\n\n\t\"github.com/avast/retry-go\"\n\t\"github.com/majd/ipatool/v2/pkg/appstore\"\n\t\"githu"
  },
  {
    "path": "cmd/root.go",
    "chars": 2089,
    "preview": "package cmd\n\nimport (\n\t\"errors\"\n\t\"reflect\"\n\n\t\"github.com/majd/ipatool/v2/pkg/appstore\"\n\t\"github.com/spf13/cobra\"\n\t\"githu"
  },
  {
    "path": "cmd/search.go",
    "chars": 906,
    "preview": "package cmd\n\nimport (\n\t\"github.com/majd/ipatool/v2/pkg/appstore\"\n\t\"github.com/spf13/cobra\"\n)\n\n// nolint:wrapcheck\nfunc s"
  },
  {
    "path": "go.mod",
    "chars": 1852,
    "preview": "module github.com/majd/ipatool/v2\n\ngo 1.23.0\n\ntoolchain go1.23.2\n\nrequire (\n\tgithub.com/99designs/keyring v1.2.1\n\tgithub"
  },
  {
    "path": "go.sum",
    "chars": 11609,
    "preview": "github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=\ngith"
  },
  {
    "path": "main.go",
    "chars": 107,
    "preview": "package main\n\nimport (\n\t\"os\"\n\n\t\"github.com/majd/ipatool/v2/cmd\"\n)\n\nfunc main() {\n\tos.Exit(cmd.Execute())\n}\n"
  },
  {
    "path": "pkg/appstore/account.go",
    "chars": 448,
    "preview": "package appstore\n\ntype Account struct {\n\tEmail               string `json:\"email,omitempty\"`\n\tPasswordToken       string"
  },
  {
    "path": "pkg/appstore/app.go",
    "chars": 860,
    "preview": "package appstore\n\nimport (\n\t\"github.com/rs/zerolog\"\n)\n\ntype App struct {\n\tID       int64   `json:\"trackId,omitempty\"`\n\tB"
  },
  {
    "path": "pkg/appstore/app_test.go",
    "chars": 1696,
    "preview": "package appstore\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n\t\"githu"
  },
  {
    "path": "pkg/appstore/appstore.go",
    "chars": 2657,
    "preview": "package appstore\n\nimport (\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n\t\"github.com/majd/ipatool/v2/pkg/keychain\"\n\t\"github.co"
  },
  {
    "path": "pkg/appstore/appstore_account_info.go",
    "chars": 508,
    "preview": "package appstore\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n)\n\ntype AccountInfoOutput struct {\n\tAccount Account\n}\n\nfunc (t *appst"
  },
  {
    "path": "pkg/appstore/appstore_account_info_test.go",
    "chars": 1612,
    "preview": "package appstore\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/majd/ipatool/v2/pkg/keychain\"\n\t. \"github.com/onsi/ginkgo/v2\"\n\t"
  },
  {
    "path": "pkg/appstore/appstore_bag.go",
    "chars": 1276,
    "preview": "package appstore\n\nimport (\n\t\"fmt\"\n\tgohttp \"net/http\"\n\t\"strings\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n)\n\ntype BagInput"
  },
  {
    "path": "pkg/appstore/appstore_bag_test.go",
    "chars": 2915,
    "preview": "package appstore\n\nimport (\n\t\"errors\"\n\tgohttp \"net/http\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n\t\"github.com/majd/ipatoo"
  },
  {
    "path": "pkg/appstore/appstore_download.go",
    "chars": 7683,
    "preview": "package appstore\n\nimport (\n\t\"archive/zip\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/majd/ipatool/"
  },
  {
    "path": "pkg/appstore/appstore_download_test.go",
    "chars": 12074,
    "preview": "package appstore\n\nimport (\n\t\"archive/zip\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\tgohttp \"net/http\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t"
  },
  {
    "path": "pkg/appstore/appstore_get_version_metadata.go",
    "chars": 2761,
    "preview": "package appstore\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n)\n\ntype GetVersio"
  },
  {
    "path": "pkg/appstore/appstore_get_version_metadata_test.go",
    "chars": 6811,
    "preview": "package appstore\n\nimport (\n\t\"errors\"\n\t\"time\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n\t\"github.com/majd/ipatool/v2/pkg/ut"
  },
  {
    "path": "pkg/appstore/appstore_list_versions.go",
    "chars": 3059,
    "preview": "package appstore\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n)\n\ntype ListVersionsInput"
  },
  {
    "path": "pkg/appstore/appstore_list_versions_test.go",
    "chars": 7088,
    "preview": "package appstore\n\nimport (\n\t\"errors\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n\t\"github.com/majd/ipatool/v2/pkg/util/machi"
  },
  {
    "path": "pkg/appstore/appstore_login.go",
    "chars": 5137,
    "preview": "package appstore\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\tgohttp \"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/majd"
  },
  {
    "path": "pkg/appstore/appstore_login_test.go",
    "chars": 8187,
    "preview": "package appstore\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n\t\"github"
  },
  {
    "path": "pkg/appstore/appstore_lookup.go",
    "chars": 1530,
    "preview": "package appstore\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\tgohttp \"net/http\"\n\t\"net/url\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n)\n\ntyp"
  },
  {
    "path": "pkg/appstore/appstore_lookup_test.go",
    "chars": 2605,
    "preview": "package appstore\n\nimport (\n\t\"errors\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.c"
  },
  {
    "path": "pkg/appstore/appstore_purchase.go",
    "chars": 3896,
    "preview": "package appstore\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\tgohttp \"net/http\"\n\t\"strings\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n)\n\nvar"
  },
  {
    "path": "pkg/appstore/appstore_purchase_test.go",
    "chars": 7815,
    "preview": "package appstore\n\nimport (\n\t\"errors\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n\t\"github.com/majd/ipatool/v2/pkg/keychain\"\n"
  },
  {
    "path": "pkg/appstore/appstore_replicate_sinf.go",
    "chars": 5323,
    "preview": "package appstore\n\nimport (\n\t\"archive/zip\"\n\t\"bytes\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.co"
  },
  {
    "path": "pkg/appstore/appstore_replicate_sinf_test.go",
    "chars": 5139,
    "preview": "package appstore\n\nimport (\n\t\"archive/zip\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n\t\"github.com/ma"
  },
  {
    "path": "pkg/appstore/appstore_revoke.go",
    "chars": 216,
    "preview": "package appstore\n\nimport (\n\t\"fmt\"\n)\n\nfunc (t *appstore) Revoke() error {\n\terr := t.keychain.Remove(\"account\")\n\tif err !="
  },
  {
    "path": "pkg/appstore/appstore_revoke_test.go",
    "chars": 1039,
    "preview": "package appstore\n\nimport (\n\t\"errors\"\n\n\t\"github.com/majd/ipatool/v2/pkg/keychain\"\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"gith"
  },
  {
    "path": "pkg/appstore/appstore_search.go",
    "chars": 1659,
    "preview": "package appstore\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\tgohttp \"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\n\t\"github.com/majd/ipatool/v2/pkg/h"
  },
  {
    "path": "pkg/appstore/appstore_search_test.go",
    "chars": 2468,
    "preview": "package appstore\n\nimport (\n\t\"errors\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.c"
  },
  {
    "path": "pkg/appstore/appstore_test.go",
    "chars": 196,
    "preview": "package appstore\n\nimport (\n\t\"testing\"\n\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n)\n\nfunc TestAppStore(t"
  },
  {
    "path": "pkg/appstore/constants.go",
    "chars": 992,
    "preview": "package appstore\n\nconst (\n\tFailureTypeInvalidCredentials     = \"-5000\"\n\tFailureTypePasswordTokenExpired   = \"2034\"\n\tFail"
  },
  {
    "path": "pkg/appstore/error.go",
    "chars": 304,
    "preview": "package appstore\n\ntype Error struct {\n\tMetadata        interface{}\n\tunderlyingError error\n}\n\nfunc (t Error) Error() stri"
  },
  {
    "path": "pkg/appstore/storefront.go",
    "chars": 2679,
    "preview": "package appstore\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n)\n\nfunc countryCodeFromStoreFront(storeFront string) (string, error) {\n\tfor"
  },
  {
    "path": "pkg/http/client.go",
    "chars": 5414,
    "preview": "package http\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"howett.net/plist\"\n)\n\nc"
  },
  {
    "path": "pkg/http/client_test.go",
    "chars": 5928,
    "preview": "package http\n\nimport (\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/go"
  },
  {
    "path": "pkg/http/constants.go",
    "chars": 250,
    "preview": "package http\n\ntype ResponseFormat string\n\nconst (\n\tResponseFormatJSON ResponseFormat = \"json\"\n\tResponseFormatXML  Respon"
  },
  {
    "path": "pkg/http/cookiejar.go",
    "chars": 205,
    "preview": "package http\n\nimport \"net/http\"\n\n//go:generate go run go.uber.org/mock/mockgen -source=cookiejar.go -destination=cookiej"
  },
  {
    "path": "pkg/http/http_test.go",
    "chars": 183,
    "preview": "package http\n\nimport (\n\t\"testing\"\n\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n)\n\nfunc TestHTTP(t *testin"
  },
  {
    "path": "pkg/http/method.go",
    "chars": 65,
    "preview": "package http\n\nconst (\n\tMethodGET  = \"GET\"\n\tMethodPOST = \"POST\"\n)\n"
  },
  {
    "path": "pkg/http/payload.go",
    "chars": 856,
    "preview": "package http\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"net/url\"\n\t\"strconv\"\n\n\t\"howett.net/plist\"\n)\n\ntype Payload interface {\n\tdata() (["
  },
  {
    "path": "pkg/http/payload_test.go",
    "chars": 1327,
    "preview": "package http\n\nimport (\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n)\n\nvar _ = Describe(\"Payload\", func() "
  },
  {
    "path": "pkg/http/request.go",
    "chars": 173,
    "preview": "package http\n\ntype Request struct {\n\tMethod         string\n\tURL            string\n\tHeaders        map[string]string\n\tPay"
  },
  {
    "path": "pkg/http/result.go",
    "chars": 416,
    "preview": "package http\n\nimport (\n\t\"errors\"\n\t\"strings\"\n)\n\nvar (\n\tErrHeaderNotFound = errors.New(\"header not found\")\n)\n\ntype Result["
  },
  {
    "path": "pkg/keychain/keychain.go",
    "chars": 418,
    "preview": "package keychain\n\n//go:generate go run go.uber.org/mock/mockgen -source=keychain.go -destination=keychain_mock.go -packa"
  },
  {
    "path": "pkg/keychain/keychain_get.go",
    "chars": 225,
    "preview": "package keychain\n\nimport (\n\t\"fmt\"\n)\n\nfunc (k *keychain) Get(key string) ([]byte, error) {\n\titem, err := k.keyring.Get(ke"
  },
  {
    "path": "pkg/keychain/keychain_get_test.go",
    "chars": 1211,
    "preview": "package keychain\n\nimport (\n\t\"errors\"\n\n\t\"github.com/99designs/keyring\"\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi"
  },
  {
    "path": "pkg/keychain/keychain_remove.go",
    "chars": 202,
    "preview": "package keychain\n\nimport (\n\t\"fmt\"\n)\n\nfunc (k *keychain) Remove(key string) error {\n\terr := k.keyring.Remove(key)\n\tif err"
  },
  {
    "path": "pkg/keychain/keychain_remove_test.go",
    "chars": 1037,
    "preview": "package keychain\n\nimport (\n\t\"errors\"\n\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n\t\"go.uber.org/mock/gomo"
  },
  {
    "path": "pkg/keychain/keychain_set.go",
    "chars": 279,
    "preview": "package keychain\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/99designs/keyring\"\n)\n\nfunc (k *keychain) Set(key string, data []byte) er"
  },
  {
    "path": "pkg/keychain/keychain_set_test.go",
    "chars": 1243,
    "preview": "package keychain\n\nimport (\n\t\"errors\"\n\n\t\"github.com/99designs/keyring\"\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi"
  },
  {
    "path": "pkg/keychain/keychain_test.go",
    "chars": 195,
    "preview": "package keychain\n\nimport (\n\t\"testing\"\n\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n)\n\nfunc TestKeychain(t"
  },
  {
    "path": "pkg/keychain/keyring.go",
    "chars": 291,
    "preview": "package keychain\n\nimport \"github.com/99designs/keyring\"\n\n//go:generate go run go.uber.org/mock/mockgen -source=keyring.g"
  },
  {
    "path": "pkg/log/log_test.go",
    "chars": 180,
    "preview": "package log\n\nimport (\n\t\"testing\"\n\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n)\n\nfunc TestLog(t *testing."
  },
  {
    "path": "pkg/log/logger.go",
    "chars": 1079,
    "preview": "package log\n\nimport (\n\t\"io\"\n\n\t\"github.com/rs/zerolog\"\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/rs/zerolog/pkgerrors\"\n)\n"
  },
  {
    "path": "pkg/log/logger_test.go",
    "chars": 1792,
    "preview": "package log\n\nimport (\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n\n\t\"github.com/rs/zerolog\"\n\t\"go.uber.org"
  },
  {
    "path": "pkg/log/writer.go",
    "chars": 1189,
    "preview": "package log\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\n\t\"github.com/rs/zerolog\"\n)\n\n//go:generate go run go.uber.org/mock/mockgen -sou"
  },
  {
    "path": "pkg/log/writer_test.go",
    "chars": 1893,
    "preview": "package log\n\nimport (\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n\t\"github.com/rs/zerolog\"\n\t\"go.uber.org/"
  },
  {
    "path": "pkg/util/machine/machine.go",
    "chars": 1450,
    "preview": "package machine\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"path/filepath\"\n\t\"runtime\"\n\n\t\"github.com/majd/ipatool/v2/pkg/util/operatingsyst"
  },
  {
    "path": "pkg/util/machine/machine_test.go",
    "chars": 1270,
    "preview": "package machine\n\nimport (\n\t\"syscall\"\n\t\"testing\"\n\n\t\"github.com/majd/ipatool/v2/pkg/util/operatingsystem\"\n\t. \"github.com/o"
  },
  {
    "path": "pkg/util/must.go",
    "chars": 218,
    "preview": "package util\n\n// Must is a helper that wraps a call to a function returning (T, error) and panics if the error is non-ni"
  },
  {
    "path": "pkg/util/must_test.go",
    "chars": 377,
    "preview": "package util\n\nimport (\n\t\"errors\"\n\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n)\n\nvar _ = Describe(\"Must\","
  },
  {
    "path": "pkg/util/operatingsystem/operatingsystem.go",
    "chars": 1484,
    "preview": "package operatingsystem\n\nimport (\n\t\"os\"\n)\n\n//go:generate go run go.uber.org/mock/mockgen -source=operatingsystem.go -des"
  },
  {
    "path": "pkg/util/operatingsystem/operatingsystem_test.go",
    "chars": 2198,
    "preview": "package operatingsystem\n\nimport (\n\t\"fmt\"\n\t\"io/fs\"\n\t\"math/rand\"\n\t\"os\"\n\t\"path\"\n\t\"testing\"\n\t\"time\"\n\n\t. \"github.com/onsi/gin"
  },
  {
    "path": "pkg/util/string.go",
    "chars": 116,
    "preview": "package util\n\nfunc IfEmpty(value, fallback string) string {\n\tif value == \"\" {\n\t\treturn fallback\n\t}\n\n\treturn value\n}\n"
  },
  {
    "path": "pkg/util/string_test.go",
    "chars": 354,
    "preview": "package util\n\nimport (\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n)\n\nvar _ = Describe(\"String\", func() {"
  },
  {
    "path": "pkg/util/util_test.go",
    "chars": 183,
    "preview": "package util\n\nimport (\n\t\"testing\"\n\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n)\n\nfunc TestUtil(t *testin"
  },
  {
    "path": "pkg/util/zip.go",
    "chars": 389,
    "preview": "package util\n\nimport \"errors\"\n\ntype Pair[T, U any] struct {\n\tFirst  T\n\tSecond U\n}\n\nfunc Zip[T, U any](ts []T, us []U) (["
  },
  {
    "path": "pkg/util/zip_test.go",
    "chars": 717,
    "preview": "package util\n\nimport (\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n)\n\nvar _ = Describe(\"Zip\", func() {\n\tW"
  },
  {
    "path": "tools/sha256sum.sh",
    "chars": 141,
    "preview": "#!/bin/sh -e\n\nif which sha256sum >/dev/null 2>&1; then\n  sha256sum \"$1\" | awk '{ print $1 }'\nelse\n  shasum -a256 \"$1\" | "
  },
  {
    "path": "tools.go",
    "chars": 73,
    "preview": "//go:build tools\n\npackage main\n\nimport (\n\t_ \"go.uber.org/mock/mockgen\"\n)\n"
  }
]

About this extraction

This page contains the full source code of the majd/ipatool GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 93 files (189.3 KB), approximately 59.2k tokens, and a symbol index with 214 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!