diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a20f983 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +/.build +/Packages +/.claude +*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5cb1f97 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,171 @@ +# asc-client + +A command-line tool for the App Store Connect API, built with Swift. + +## Build & Run + +```bash +swift build # Debug build +swift build -c release # Release build (slow — AppStoreAPI has ~2500 generated files) +swift run asc-client # Run directly +swift run asc-client --help # Show all commands +``` + +Install globally: +```bash +strip .build/release/asc-client # Strip debug symbols (~175 MB → ~59 MB) +cp .build/release/asc-client /usr/local/bin/ +``` + +## Project Structure + +``` +Package.swift # SPM manifest (Swift 6.0, macOS 13+) +Sources/asc-client/ + ASCClient.swift # @main entry, root AsyncParsableCommand + Config.swift # ~/.asc-client/config.json loader, ConfigError + ClientFactory.swift # Creates authenticated AppStoreConnectClient + Formatting.swift # Shared helpers: Table.print, formatDate, expandPath + MediaUpload.swift # Media management: upload, download, retry screenshots/previews + Commands/ + ConfigureCommand.swift # Interactive credential setup, file permissions + AppsCommand.swift # All app subcommands + findApp/findVersion helpers + BuildsCommand.swift # Build subcommands +``` + +## Dependencies + +- **[asc-swift](https://github.com/aaronsky/asc-swift)** (1.0.0+) — App Store Connect API client + - Product used: `AppStoreConnect` (bundles both `AppStoreConnect` core and `AppStoreAPI` endpoints) + - `AppStoreAPI` is a target, NOT a separate product — do not add it to Package.swift dependencies + - API path pattern: `Resources.v1.apps.get()`, `Resources.v1.apps.id("ID").appStoreVersions.get()` + - Sub-resource access: `Resources.v1.appStoreVersions.id("ID").appStoreVersionLocalizations.get()` + - Client is a Swift actor: `AppStoreConnectClient` + - Pagination: `for try await page in client.pages(request)` + - Resolved version: 1.5.0 (with swift-crypto, URLQueryEncoder, swift-asn1 as transitive deps) +- **[swift-argument-parser](https://github.com/apple/swift-argument-parser)** (1.3.0+) — CLI framework + +## Authentication + +Config file at `~/.asc-client/config.json`: +```json +{ + "keyId": "KEY_ID", + "issuerId": "ISSUER_ID", + "privateKeyPath": "/Users/.../.asc-client/AuthKey_XXXXXXXXXX.p8" +} +``` + +- `configure` command copies the .p8 file into `~/.asc-client/` and writes the config +- File permissions set to 700 (dir) and 600 (files) — owner-only access +- JWT tokens use ES256 (P256) signing, 20-minute expiry, auto-renewed by asc-swift +- Private key loaded via `JWT.PrivateKey(contentsOf: URL(fileURLWithPath: path))` + +## Commands + +``` +asc-client configure # Interactive setup +asc-client apps list # List all apps +asc-client apps info # App details +asc-client apps versions # List App Store versions +asc-client apps localizations [--version X] # View localizations +asc-client apps review-status # Review submission status +asc-client apps create-version [--platform X] # Create new version +asc-client apps select-build [--version X] # Attach a build to a version +asc-client apps submit-for-review [--version X] # Submit version for App Review +asc-client apps update-localization [--locale X] # Update single locale via flags +asc-client apps update-localizations [--file X] # Bulk update from JSON file +asc-client apps export-localizations [--version X] # Export to JSON file +asc-client apps upload-media [--folder X] [--version X] [--replace] # Upload screenshots/previews +asc-client apps download-media [--folder X] [--version X] # Download screenshots/previews +asc-client apps verify-media [--version X] [--folder X] # Check media status, retry stuck +asc-client builds list [--bundle-id ] # List builds +``` + +## Key Patterns + +### Adding a new subcommand +1. Add the command struct inside `AppsCommand` (or create a new command group) +2. Use `AsyncParsableCommand` for commands that call the API +3. Register in parent's `subcommands` array +4. Use `findApp(bundleID:client:)` to resolve bundle ID to app ID +5. Use `findVersion(appID:versionString:client:)` to resolve version (nil = latest) +6. Use shared `formatDate()` and `expandPath()` from Formatting.swift + +### API calls +- **`filterBundleID` does prefix matching** — `com.foo.Bar` also matches `com.foo.BarPro`. Always use `findApp()` which filters for exact `bundleID` match from results. +- **Build relationship returns null when unattached** — `GET /v1/appStoreVersions/{id}/build` returns `{"data": null}` when no build is attached, but `BuildWithoutIncludesResponse.data` is non-optional. Use `try?` to handle the decoding failure gracefully. +- Builds don't have `filterBundleID` — look up app first, then use `filterApp: [appID]` +- Localizations are per-version: get version ID first, then fetch/update localizations +- Updates are one API call per locale — no bulk endpoint in the API +- Only versions in editable states (e.g. `PREPARE_FOR_SUBMISSION`) accept localization updates +- `create-version` `--release-type` is optional; omitting it uses the previous version's setting +- Filter parameters vary per endpoint — check the generated PathsV1*.swift files for exact signatures + +### Localization JSON format (used by export/update-localizations) +```json +{ + "en-US": { + "description": "App description", + "whatsNew": "- Bug fixes\n- New feature", + "keywords": "keyword1,keyword2", + "promotionalText": "Promo text", + "marketingURL": "https://example.com", + "supportURL": "https://example.com/support" + } +} +``` + +Only fields present in the JSON get updated — omitted fields are left unchanged. The `LocaleFields` struct in AppsCommand.swift defines the schema. + +### Media upload folder structure (used by upload-media) +``` +media/ +├── en-US/ +│ ├── APP_IPHONE_67/ +│ │ ├── 01_home.png +│ │ ├── 02_settings.png +│ │ └── preview.mp4 +│ └── APP_IPAD_PRO_3GEN_129/ +│ └── 01_home.png +└── de-DE/ + └── APP_IPHONE_67/ + └── 01_home.png +``` + +- Level 1: locale, Level 2: display type (ScreenshotDisplayType raw values), Level 3: files +- Images (`.png`, `.jpg`, `.jpeg`) → screenshot sets; Videos (`.mp4`, `.mov`) → preview sets +- Files sorted alphabetically = upload order +- Preview types derived by stripping `APP_` prefix; Watch/iMessage types are screenshots-only +- Upload flow: POST reserve → PUT chunks to presigned URLs → PATCH commit with MD5 checksum +- `--replace` deletes existing assets in matching sets before uploading +- Download filenames are prefixed with `01_`, `02_` etc. to avoid collisions (same name can appear multiple times in a set) +- `ImageAsset.templateURL` uses `{w}x{h}bb.{f}` placeholders — resolve with actual width/height/format for download +- `AppPreview.videoURL` provides direct download URL for preview videos +- Reorder screenshots via `PATCH /v1/appScreenshotSets/{id}/relationships/appScreenshots` with `AppScreenshotSetAppScreenshotsLinkagesRequest` +- `AppMediaAssetState.State` values: `.awaitingUpload`, `.uploadComplete`, `.complete`, `.failed` — stuck items show `uploadComplete` +- `verify-media` checks all media status; with `--folder` retries stuck items: delete → upload → reorder +- File matching: server position N = Nth file alphabetically in local `locale/displayType/` folder + +## Not Yet Implemented + +API endpoints available but not yet added (43 app sub-resources + 9 top-level resources): +- **TestFlight**: beta groups, beta testers, pre-release versions, beta app localizations +- **Provisioning**: devices, bundle IDs, certificates, profiles +- **Monetization**: in-app purchases, subscriptions, price points, promoted purchases +- **Feedback**: customer reviews, review summarizations +- **Analytics**: analytics reports, performance power metrics +- **Configuration**: app info/categories, availability/territories, encryption declarations, EULA, app events, app clips, custom product pages, A/B experiments + +## Release build note + +`swift build -c release` is very slow due to whole-module optimization of AppStoreAPI's ~2500 generated files. Debug builds are fast for development. + + + +# Recent Activity + + + +*No recent activity* + \ No newline at end of file diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..71a7cd7 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,51 @@ +{ + "originHash" : "bbe09af810283d7914eaf5b133c6ef0dab87a8a1584684f4b1b64aec07f26656", + "pins" : [ + { + "identity" : "asc-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/aaronsky/asc-swift", + "state" : { + "revision" : "b5f1b9a8173448cb5fdcbf0bcaf0cb3dbc039511", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + }, + { + "identity" : "urlqueryencoder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CreateAPI/URLQueryEncoder.git", + "state" : { + "revision" : "4ce950479707ea109f229d7230ec074a133b15d7", + "version" : "0.2.1" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..db03003 --- /dev/null +++ b/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "asc-client", + platforms: [.macOS(.v13)], + dependencies: [ + .package(url: "https://github.com/aaronsky/asc-swift", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), + ], + targets: [ + .executableTarget( + name: "asc-client", + dependencies: [ + .product(name: "AppStoreConnect", package: "asc-swift"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ] + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..fab7654 --- /dev/null +++ b/README.md @@ -0,0 +1,240 @@ +# asc-client + +A command-line tool for the [App Store Connect API](https://developer.apple.com/documentation/appstoreconnectapi), built with Swift. + +> **Note:** This is an early prototype focused on app version workflows -- creating versions, managing localizations, uploading screenshots, and submitting for review. More API coverage is planned but not yet implemented. + +## Requirements + +- macOS 13+ +- Swift 6.0+ + +## Installation + +### Build from source + +```bash +git clone https://github.com/keremerkan/asc-client.git +cd asc-client +swift build -c release +strip .build/release/asc-client +cp .build/release/asc-client /usr/local/bin/ +``` + +> **Note:** The release build takes a few minutes due to ~2500 generated API files. `strip` removes debug symbols, reducing the binary from ~175 MB to ~59 MB. + +### Shell completions + +Enable tab completion for subcommands, options, and flags: + +**zsh** (default on macOS): +```bash +mkdir -p ~/.zfunc +asc-client --generate-completion-script zsh > ~/.zfunc/_asc-client +``` + +Add this to your `~/.zshrc` if not already present: +```bash +fpath=(~/.zfunc $fpath) +autoload -Uz compinit && compinit +``` + +**bash**: +```bash +asc-client --generate-completion-script bash > /usr/local/etc/bash_completion.d/asc-client +``` + +Restart your shell or open a new tab to activate. + +## Setup + +### 1. Create an API Key + +Go to [App Store Connect > Users and Access > Integrations > App Store Connect API](https://appstoreconnect.apple.com/access/integrations/api) and generate a new key. Download the `.p8` private key file. + +### 2. Configure + +```bash +asc-client configure +``` + +This will prompt for your **Key ID**, **Issuer ID**, and the path to your `.p8` file. The private key is copied into `~/.asc-client/` with strict file permissions (owner-only access). + +## Usage + +### Apps + +```bash +# List all apps +asc-client apps list + +# Show app details +asc-client apps info + +# List App Store versions +asc-client apps versions + +# Create a new version +asc-client apps create-version +asc-client apps create-version 2.1.0 --platform ios --release-type manual + +# Check review submission status +asc-client apps review-status +``` + +### Localizations + +```bash +# View localizations (latest version by default) +asc-client apps localizations +asc-client apps localizations --version 1.2.0 --locale en-US + +# Export localizations to JSON +asc-client apps export-localizations +asc-client apps export-localizations --version 1.2.0 --output my-localizations.json + +# Update a single locale +asc-client apps update-localization --whats-new "Bug fixes" --locale en-US + +# Bulk update from JSON file +asc-client apps update-localizations --file localizations.json +``` + +The JSON format for export and bulk update: + +```json +{ + "en-US": { + "description": "My app description.\n\nSecond paragraph.", + "whatsNew": "- Bug fixes\n- New dark mode", + "keywords": "productivity,tools,utility", + "promotionalText": "Try our new features!", + "marketingURL": "https://example.com", + "supportURL": "https://example.com/support" + }, + "de-DE": { + "whatsNew": "- Fehlerbehebungen\n- Neuer Dunkelmodus" + } +} +``` + +Only fields present in the JSON are updated -- omitted fields are left unchanged. + +### Screenshots & App Previews + +```bash +# Download all screenshots and preview videos +asc-client apps download-media +asc-client apps download-media --folder my-media/ --version 2.1.0 + +# Upload screenshots and preview videos from a folder +asc-client apps upload-media --folder media/ + +# Upload to a specific version +asc-client apps upload-media --folder media/ --version 2.1.0 + +# Replace existing media in matching sets before uploading +asc-client apps upload-media --folder media/ --replace +``` + +Organize your media folder with locale and display type subfolders: + +``` +media/ +├── en-US/ +│ ├── APP_IPHONE_67/ +│ │ ├── 01_home.png +│ │ ├── 02_settings.png +│ │ └── preview.mp4 +│ └── APP_IPAD_PRO_3GEN_129/ +│ └── 01_home.png +└── de-DE/ + └── APP_IPHONE_67/ + ├── 01_home.png + └── 02_settings.png +``` + +- **Level 1:** Locale (e.g. `en-US`, `de-DE`, `ja`) +- **Level 2:** Display type folder name (see table below) +- **Level 3:** Media files -- images (`.png`, `.jpg`, `.jpeg`) become screenshots, videos (`.mp4`, `.mov`) become app previews +- Files are uploaded in alphabetical order by filename +- Unsupported files are skipped with a warning + +#### Display types + +| Folder name | Device | Screenshots | Previews | +|---|---|---|---| +| `APP_IPHONE_67` | iPhone 6.7" (iPhone 16 Pro Max, 15 Pro Max, 14 Pro Max) | Yes | Yes | +| `APP_IPHONE_61` | iPhone 6.1" (iPhone 16 Pro, 15 Pro, 14 Pro) | Yes | Yes | +| `APP_IPHONE_65` | iPhone 6.5" (iPhone 11 Pro Max, XS Max) | Yes | Yes | +| `APP_IPHONE_58` | iPhone 5.8" (iPhone 11 Pro, X, XS) | Yes | Yes | +| `APP_IPHONE_55` | iPhone 5.5" (iPhone 8 Plus, 7 Plus, 6s Plus) | Yes | Yes | +| `APP_IPHONE_47` | iPhone 4.7" (iPhone SE 3rd gen, 8, 7, 6s) | Yes | Yes | +| `APP_IPHONE_40` | iPhone 4" (iPhone SE 1st gen, 5s, 5c) | Yes | Yes | +| `APP_IPHONE_35` | iPhone 3.5" (iPhone 4s and earlier) | Yes | Yes | +| `APP_IPAD_PRO_3GEN_129` | iPad Pro 12.9" (3rd gen+) | Yes | Yes | +| `APP_IPAD_PRO_3GEN_11` | iPad Pro 11" | Yes | Yes | +| `APP_IPAD_PRO_129` | iPad Pro 12.9" (1st/2nd gen) | Yes | Yes | +| `APP_IPAD_105` | iPad 10.5" (iPad Air 3rd gen, iPad Pro 10.5") | Yes | Yes | +| `APP_IPAD_97` | iPad 9.7" (iPad 6th gen and earlier) | Yes | Yes | +| `APP_DESKTOP` | Mac | Yes | Yes | +| `APP_APPLE_TV` | Apple TV | Yes | Yes | +| `APP_APPLE_VISION_PRO` | Apple Vision Pro | Yes | Yes | +| `APP_WATCH_ULTRA` | Apple Watch Ultra | Yes | No | +| `APP_WATCH_SERIES_10` | Apple Watch Series 10 | Yes | No | +| `APP_WATCH_SERIES_7` | Apple Watch Series 7 | Yes | No | +| `APP_WATCH_SERIES_4` | Apple Watch Series 4 | Yes | No | +| `APP_WATCH_SERIES_3` | Apple Watch Series 3 | Yes | No | +| `IMESSAGE_APP_IPHONE_67` | iMessage iPhone 6.7" | Yes | No | +| `IMESSAGE_APP_IPHONE_61` | iMessage iPhone 6.1" | Yes | No | +| `IMESSAGE_APP_IPHONE_65` | iMessage iPhone 6.5" | Yes | No | +| `IMESSAGE_APP_IPHONE_58` | iMessage iPhone 5.8" | Yes | No | +| `IMESSAGE_APP_IPHONE_55` | iMessage iPhone 5.5" | Yes | No | +| `IMESSAGE_APP_IPHONE_47` | iMessage iPhone 4.7" | Yes | No | +| `IMESSAGE_APP_IPHONE_40` | iMessage iPhone 4" | Yes | No | +| `IMESSAGE_APP_IPAD_PRO_3GEN_129` | iMessage iPad Pro 12.9" (3rd gen+) | Yes | No | +| `IMESSAGE_APP_IPAD_PRO_3GEN_11` | iMessage iPad Pro 11" | Yes | No | +| `IMESSAGE_APP_IPAD_PRO_129` | iMessage iPad Pro 12.9" (1st/2nd gen) | Yes | No | +| `IMESSAGE_APP_IPAD_105` | iMessage iPad 10.5" | Yes | No | +| `IMESSAGE_APP_IPAD_97` | iMessage iPad 9.7" | Yes | No | + +> **Note:** Watch and iMessage display types support screenshots only -- video files in those folders are skipped with a warning. The `--replace` flag deletes all existing assets in each matching set before uploading new ones. +> +> `download-media` saves files in this same folder structure (defaults to `-media/`), so you can download, edit, and re-upload. + +#### Verify and retry stuck media + +Sometimes screenshots or previews get stuck in "processing" after upload. Use `verify-media` to check the status of all media at once and optionally retry stuck items: + +```bash +# Check status of all screenshots and previews +asc-client apps verify-media + +# Check a specific version +asc-client apps verify-media --version 2.1.0 + +# Retry stuck items using local files from the media folder +asc-client apps verify-media --folder media/ +``` + +Without `--folder`, the command shows a read-only status report. Sets where all items are complete show a compact one-liner; sets with stuck items expand to show each file and its state. With `--folder`, it prompts to retry stuck items by deleting them and re-uploading from the matching local files, preserving the original position order. + +### Builds + +```bash +# List all builds +asc-client builds list + +# Filter by app +asc-client builds list --bundle-id +``` + +## Acknowledgments + +Built on top of [asc-swift](https://github.com/aaronsky/asc-swift) by Aaron Sky. + +Developed with [Claude Code](https://claude.ai/code). + +## License + +MIT diff --git a/Sources/asc-client/ASCClient.swift b/Sources/asc-client/ASCClient.swift new file mode 100644 index 0000000..5d3e1f7 --- /dev/null +++ b/Sources/asc-client/ASCClient.swift @@ -0,0 +1,11 @@ +import ArgumentParser + +@main +struct ASCClient: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "asc-client", + abstract: "A command-line tool for the App Store Connect API.", + version: "0.1.0", + subcommands: [ConfigureCommand.self, AppsCommand.self, BuildsCommand.self] + ) +} diff --git a/Sources/asc-client/ClientFactory.swift b/Sources/asc-client/ClientFactory.swift new file mode 100644 index 0000000..d07e3d9 --- /dev/null +++ b/Sources/asc-client/ClientFactory.swift @@ -0,0 +1,24 @@ +import AppStoreConnect +import Foundation + +enum ClientFactory { + static func makeClient() throws -> AppStoreConnectClient { + let config = try Config.load() + let keyPath = expandPath(config.privateKeyPath) + + guard FileManager.default.fileExists(atPath: keyPath) else { + throw ConfigError.missingPrivateKey(keyPath) + } + + let privateKey = try JWT.PrivateKey(contentsOf: URL(fileURLWithPath: keyPath)) + + return AppStoreConnectClient( + authenticator: JWT( + keyID: config.keyId, + issuerID: config.issuerId, + expiryDuration: 20 * 60, + privateKey: privateKey + ) + ) + } +} diff --git a/Sources/asc-client/Commands/AppsCommand.swift b/Sources/asc-client/Commands/AppsCommand.swift new file mode 100644 index 0000000..2af347a --- /dev/null +++ b/Sources/asc-client/Commands/AppsCommand.swift @@ -0,0 +1,789 @@ +import AppStoreAPI +import AppStoreConnect +import ArgumentParser +import Foundation + +struct AppsCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "apps", + abstract: "Manage apps.", + subcommands: [List.self, Info.self, Versions.self, Localizations.self, ReviewStatus.self, CreateVersion.self, SelectBuild.self, SubmitForReview.self, UpdateLocalization.self, UpdateLocalizations.self, ExportLocalizations.self, UploadMedia.self, DownloadMedia.self, VerifyMedia.self] + ) + + struct List: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "List all apps." + ) + + func run() async throws { + let client = try ClientFactory.makeClient() + var allApps: [(String, String, String)] = [] + + for try await page in client.pages(Resources.v1.apps.get()) { + for app in page.data { + let name = app.attributes?.name ?? "—" + let bundleID = app.attributes?.bundleID ?? "—" + let sku = app.attributes?.sku ?? "—" + allApps.append((bundleID, name, sku)) + } + } + + Table.print( + headers: ["Bundle ID", "Name", "SKU"], + rows: allApps.map { [$0.0, $0.1, $0.2] } + ) + } + } + + struct Info: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Show info for an app." + ) + + @Argument(help: "The bundle identifier of the app.") + var bundleID: String + + func run() async throws { + let client = try ClientFactory.makeClient() + let app = try await findApp(bundleID: bundleID, client: client) + + let attrs = app.attributes + print("Name: \(attrs?.name ?? "—")") + print("Bundle ID: \(attrs?.bundleID ?? "—")") + print("SKU: \(attrs?.sku ?? "—")") + print("Primary Locale: \(attrs?.primaryLocale ?? "—")") + } + } + + struct Versions: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "List App Store versions." + ) + + @Argument(help: "The bundle identifier of the app.") + var bundleID: String + + func run() async throws { + let client = try ClientFactory.makeClient() + let app = try await findApp(bundleID: bundleID, client: client) + + let response = try await client.send( + Resources.v1.apps.id(app.id).appStoreVersions.get() + ) + + var rows: [[String]] = [] + for version in response.data { + let attrs = version.attributes + let versionString = attrs?.versionString ?? "—" + let platform = attrs?.platform.map { "\($0)" } ?? "—" + let state = attrs?.appVersionState.map { "\($0)" } ?? "—" + let releaseType = attrs?.releaseType.map { "\($0)" } ?? "—" + let created = attrs?.createdDate.map { formatDate($0) } ?? "—" + rows.append([versionString, platform, state, releaseType, created]) + } + + Table.print( + headers: ["Version", "Platform", "State", "Release Type", "Created"], + rows: rows + ) + } + } + + struct Localizations: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "List localizations for an App Store version." + ) + + @Argument(help: "The bundle identifier of the app.") + var bundleID: String + + @Option(name: .long, help: "Version string (e.g. 2.1.0). Defaults to the latest version.") + var version: String? + + @Option(name: .long, help: "Filter by locale (e.g. en-US).") + var locale: String? + + func run() async throws { + let client = try ClientFactory.makeClient() + let app = try await findApp(bundleID: bundleID, client: client) + let version = try await findVersion(appID: app.id, versionString: version, client: client) + + let versionString = version.attributes?.versionString ?? "unknown" + print("Version: \(versionString)") + print() + + let request = Resources.v1.appStoreVersions.id(version.id) + .appStoreVersionLocalizations.get( + filterLocale: locale.map { [$0] } + ) + + let response = try await client.send(request) + + for loc in response.data { + let attrs = loc.attributes + let localeStr = attrs?.locale ?? "—" + print("[\(localeStr)]") + if let desc = attrs?.description, !desc.isEmpty { + print(" Description: \(desc.prefix(80))\(desc.count > 80 ? "..." : "")") + } + if let whatsNew = attrs?.whatsNew, !whatsNew.isEmpty { + print(" What's New: \(whatsNew.prefix(80))\(whatsNew.count > 80 ? "..." : "")") + } + if let keywords = attrs?.keywords, !keywords.isEmpty { + print(" Keywords: \(keywords.prefix(80))\(keywords.count > 80 ? "..." : "")") + } + if let promo = attrs?.promotionalText, !promo.isEmpty { + print(" Promotional Text: \(promo.prefix(80))\(promo.count > 80 ? "..." : "")") + } + if let url = attrs?.marketingURL { + print(" Marketing URL: \(url)") + } + if let url = attrs?.supportURL { + print(" Support URL: \(url)") + } + print() + } + } + } + + struct ReviewStatus: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "review-status", + abstract: "Show review submission status." + ) + + @Argument(help: "The bundle identifier of the app.") + var bundleID: String + + func run() async throws { + let client = try ClientFactory.makeClient() + let app = try await findApp(bundleID: bundleID, client: client) + + let response = try await client.send( + Resources.v1.apps.id(app.id).reviewSubmissions.get() + ) + + if response.data.isEmpty { + print("No review submissions found.") + return + } + + var rows: [[String]] = [] + for submission in response.data { + let attrs = submission.attributes + let platform = attrs?.platform.map { "\($0)" } ?? "—" + let state = attrs?.state.map { "\($0)" } ?? "—" + let submitted = attrs?.submittedDate.map { formatDate($0) } ?? "—" + rows.append([platform, state, submitted]) + } + + Table.print( + headers: ["Platform", "State", "Submitted"], + rows: rows + ) + } + } + + struct CreateVersion: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "create-version", + abstract: "Create a new App Store version." + ) + + @Argument(help: "The bundle identifier of the app.") + var bundleID: String + + @Argument(help: "The version string (e.g. 2.1.0).") + var versionString: String + + @Option(name: .long, help: "Platform: ios, macos, tvos, visionos (default: ios).") + var platform: String = "ios" + + @Option(name: .long, help: "Release type: manual, after-approval, scheduled. Defaults to previous version's setting.") + var releaseType: String? + + @Option(name: .long, help: "Copyright notice (e.g. \"2026 Your Name\").") + var copyright: String? + + func run() async throws { + let client = try ClientFactory.makeClient() + let app = try await findApp(bundleID: bundleID, client: client) + + let platformValue: Platform = switch platform.lowercased() { + case "ios": .iOS + case "macos": .macOS + case "tvos": .tvOS + case "visionos": .visionOS + default: throw ValidationError("Invalid platform '\(platform)'. Use: ios, macos, tvos, visionos.") + } + + let releaseTypeValue: AppStoreVersionCreateRequest.Data.Attributes.ReleaseType? + if let releaseType { + releaseTypeValue = switch releaseType.lowercased() { + case "manual": .manual + case "after-approval": .afterApproval + case "scheduled": .scheduled + default: throw ValidationError("Invalid release type '\(releaseType)'. Use: manual, after-approval, scheduled.") + } + } else { + releaseTypeValue = nil + } + + let request = Resources.v1.appStoreVersions.post( + AppStoreVersionCreateRequest( + data: .init( + attributes: .init( + platform: platformValue, + versionString: versionString, + copyright: copyright, + releaseType: releaseTypeValue + ), + relationships: .init( + app: .init(data: .init(id: app.id)) + ) + ) + ) + ) + + let response = try await client.send(request) + let attrs = response.data.attributes + print("Created version \(attrs?.versionString ?? versionString)") + print(" Platform: \(attrs?.platform.map { "\($0)" } ?? "—")") + print(" State: \(attrs?.appVersionState.map { "\($0)" } ?? "—")") + print(" Release Type: \(attrs?.releaseType.map { "\($0)" } ?? "—")") + } + } + + struct SelectBuild: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "select-build", + abstract: "Attach a build to an App Store version." + ) + + @Argument(help: "The bundle identifier of the app.") + var bundleID: String + + @Option(name: .long, help: "Version string (e.g. 2.1.0). Defaults to the latest version.") + var version: String? + + func run() async throws { + let client = try ClientFactory.makeClient() + let app = try await findApp(bundleID: bundleID, client: client) + let appVersion = try await findVersion(appID: app.id, versionString: version, client: client) + + let versionString = appVersion.attributes?.versionString ?? "unknown" + print("Version: \(versionString)") + print() + + let build = try await selectBuild(appID: app.id, versionID: appVersion.id, client: client) + let buildNumber = build.attributes?.version ?? "unknown" + let uploaded = build.attributes?.uploadedDate.map { formatDate($0) } ?? "—" + print() + print("Attached build \(buildNumber) (uploaded \(uploaded)) to version \(versionString).") + } + } + + struct SubmitForReview: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "submit-for-review", + abstract: "Submit an App Store version for review." + ) + + @Argument(help: "The bundle identifier of the app.") + var bundleID: String + + @Option(name: .long, help: "Version string (e.g. 2.1.0). Defaults to the latest version.") + var version: String? + + @Option(name: .long, help: "Platform: ios, macos, tvos, visionos (default: ios).") + var platform: String = "ios" + + func run() async throws { + let client = try ClientFactory.makeClient() + let app = try await findApp(bundleID: bundleID, client: client) + let appVersion = try await findVersion(appID: app.id, versionString: version, client: client) + + let versionString = appVersion.attributes?.versionString ?? "unknown" + let versionState = appVersion.attributes?.appVersionState.map { "\($0)" } ?? "unknown" + + let platformValue: Platform = switch platform.lowercased() { + case "ios": .iOS + case "macos": .macOS + case "tvos": .tvOS + case "visionos": .visionOS + default: throw ValidationError("Invalid platform '\(platform)'. Use: ios, macos, tvos, visionos.") + } + + // Check if a build is already attached + // The API returns {"data": null} when no build is attached, which fails + // to decode since BuildWithoutIncludesResponse.data is non-optional. + let existingBuild: Build? = try? await client.send( + Resources.v1.appStoreVersions.id(appVersion.id).build.get() + ).data + + if let build = existingBuild, build.attributes?.version != nil { + let buildNumber = build.attributes?.version ?? "unknown" + let uploaded = build.attributes?.uploadedDate.map { formatDate($0) } ?? "—" + print("App: \(app.attributes?.name ?? bundleID)") + print("Version: \(versionString)") + print("Build: \(buildNumber) (uploaded \(uploaded))") + print("State: \(versionState)") + print("Platform: \(platformValue)") + print() + print("Submit this version for App Review? [y/N] ", terminator: "") + guard let answer = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + answer == "y" || answer == "yes" else { + print("Cancelled.") + return + } + } else { + print("App: \(app.attributes?.name ?? bundleID)") + print("Version: \(versionString)") + print("State: \(versionState)") + print("Platform: \(platformValue)") + print() + print("No build attached to this version. Select a build first:") + print() + let selected = try await selectBuild(appID: app.id, versionID: appVersion.id, client: client) + let buildNumber = selected.attributes?.version ?? "unknown" + print() + print("Build \(buildNumber) attached. Continuing with submission...") + print() + print("Submit this version for App Review? [y/N] ", terminator: "") + guard let answer = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + answer == "y" || answer == "yes" else { + print("Cancelled.") + return + } + } + print() + + // Step 1: Create a review submission + let createSubmission = Resources.v1.reviewSubmissions.post( + ReviewSubmissionCreateRequest( + data: .init( + attributes: .init(platform: platformValue), + relationships: .init( + app: .init(data: .init(id: app.id)) + ) + ) + ) + ) + let submission = try await client.send(createSubmission) + let submissionID = submission.data.id + print("Created review submission (\(submissionID))") + + // Step 2: Add the app store version as a review item + let createItem = Resources.v1.reviewSubmissionItems.post( + ReviewSubmissionItemCreateRequest( + data: .init( + relationships: .init( + reviewSubmission: .init(data: .init(id: submissionID)), + appStoreVersion: .init(data: .init(id: appVersion.id)) + ) + ) + ) + ) + _ = try await client.send(createItem) + print("Added version \(versionString) to submission") + + // Step 3: Submit for review + let submitRequest = Resources.v1.reviewSubmissions.id(submissionID).patch( + ReviewSubmissionUpdateRequest( + data: .init( + id: submissionID, + attributes: .init(isSubmitted: true) + ) + ) + ) + let result = try await client.send(submitRequest) + let state = result.data.attributes?.state.map { "\($0)" } ?? "unknown" + print() + print("Submitted for review.") + print(" State: \(state)") + } + } + + struct UpdateLocalization: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "update-localization", + abstract: "Update localization metadata for the latest App Store version." + ) + + @Argument(help: "The bundle identifier of the app.") + var bundleID: String + + @Option(name: .long, help: "The locale to update (e.g. en-US). Defaults to the app's primary locale.") + var locale: String? + + @Option(name: .long, help: "App description.") + var description: String? + + @Option(name: .long, help: "What's new in this version.") + var whatsNew: String? + + @Option(name: .long, help: "Comma-separated keywords.") + var keywords: String? + + @Option(name: .long, help: "Promotional text.") + var promotionalText: String? + + @Option(name: .long, help: "Marketing URL.") + var marketingURL: String? + + @Option(name: .long, help: "Support URL.") + var supportURL: String? + + func run() async throws { + guard description != nil || whatsNew != nil || keywords != nil + || promotionalText != nil || marketingURL != nil || supportURL != nil else { + throw ValidationError("Provide at least one field to update (--description, --whats-new, --keywords, --promotional-text, --marketing-url, --support-url).") + } + + let client = try ClientFactory.makeClient() + let app = try await findApp(bundleID: bundleID, client: client) + let version = try await findVersion(appID: app.id, versionString: nil, client: client) + + // Find the localization + let locsResponse = try await client.send( + Resources.v1.appStoreVersions.id(version.id) + .appStoreVersionLocalizations.get( + filterLocale: locale.map { [$0] } + ) + ) + guard let localization = locsResponse.data.first else { + let localeDesc = locale ?? "primary" + throw ValidationError("No localization found for locale '\(localeDesc)'.") + } + + let request = Resources.v1.appStoreVersionLocalizations.id(localization.id).patch( + AppStoreVersionLocalizationUpdateRequest( + data: .init( + id: localization.id, + attributes: .init( + description: description, + keywords: keywords, + marketingURL: marketingURL.flatMap { URL(string: $0) }, + promotionalText: promotionalText, + supportURL: supportURL.flatMap { URL(string: $0) }, + whatsNew: whatsNew + ) + ) + ) + ) + + let response = try await client.send(request) + let attrs = response.data.attributes + let versionString = version.attributes?.versionString ?? "unknown" + print("Updated localization for version \(versionString) [\(attrs?.locale ?? "—")]") + + if let d = attrs?.description, !d.isEmpty { print(" Description: \(d.prefix(80))\(d.count > 80 ? "..." : "")") } + if let w = attrs?.whatsNew, !w.isEmpty { print(" What's New: \(w.prefix(80))\(w.count > 80 ? "..." : "")") } + if let k = attrs?.keywords, !k.isEmpty { print(" Keywords: \(k.prefix(80))\(k.count > 80 ? "..." : "")") } + if let p = attrs?.promotionalText, !p.isEmpty { print(" Promotional Text: \(p.prefix(80))\(p.count > 80 ? "..." : "")") } + if let u = attrs?.marketingURL { print(" Marketing URL: \(u)") } + if let u = attrs?.supportURL { print(" Support URL: \(u)") } + } + } + + struct UpdateLocalizations: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "update-localizations", + abstract: "Update localizations from a JSON file for the latest App Store version." + ) + + @Argument(help: "The bundle identifier of the app.") + var bundleID: String + + @Option(name: .long, help: "Path to the JSON file with localization data.") + var file: String? + + @Flag(name: .long, help: "Show full API response for each locale update.") + var verbose = false + + func run() async throws { + // Get file path from argument or prompt + let filePath: String + if let f = file { + filePath = f + } else { + print("Path to localizations JSON file: ", terminator: "") + guard let line = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines), + !line.isEmpty else { + throw ValidationError("No file path provided.") + } + filePath = line + } + + let expandedPath = expandPath(filePath) + + guard FileManager.default.fileExists(atPath: expandedPath) else { + throw ValidationError("File not found at '\(expandedPath)'.") + } + + // Parse JSON + let data = try Data(contentsOf: URL(fileURLWithPath: expandedPath)) + let localeUpdates: [String: LocaleFields] + do { + localeUpdates = try JSONDecoder().decode([String: LocaleFields].self, from: data) + } catch let error as DecodingError { + throw ValidationError("Invalid JSON: \(describeDecodingError(error))") + } + + if localeUpdates.isEmpty { + throw ValidationError("JSON file contains no locale entries.") + } + + // Show summary and confirm + let client = try ClientFactory.makeClient() + let app = try await findApp(bundleID: bundleID, client: client) + let version = try await findVersion(appID: app.id, versionString: nil, client: client) + + let versionString = version.attributes?.versionString ?? "unknown" + let versionState = version.attributes?.appVersionState.map { "\($0)" } ?? "unknown" + print("App: \(app.attributes?.name ?? bundleID)") + print("Version: \(versionString)") + print("State: \(versionState)") + print() + + for (locale, fields) in localeUpdates.sorted(by: { $0.key < $1.key }) { + print("[\(locale)]") + if let d = fields.description { print(" Description: \(d.prefix(80))\(d.count > 80 ? "..." : "")") } + if let w = fields.whatsNew { print(" What's New: \(w.prefix(80))\(w.count > 80 ? "..." : "")") } + if let k = fields.keywords { print(" Keywords: \(k.prefix(80))\(k.count > 80 ? "..." : "")") } + if let p = fields.promotionalText { print(" Promotional Text: \(p.prefix(80))\(p.count > 80 ? "..." : "")") } + if let u = fields.marketingURL { print(" Marketing URL: \(u)") } + if let u = fields.supportURL { print(" Support URL: \(u)") } + print() + } + + print("Send updates for \(localeUpdates.count) locale(s)? [y/N] ", terminator: "") + guard let answer = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + answer == "y" || answer == "yes" else { + print("Cancelled.") + return + } + print() + + // Fetch all localizations for this version + let locsResponse = try await client.send( + Resources.v1.appStoreVersions.id(version.id) + .appStoreVersionLocalizations.get() + ) + + let locByLocale = Dictionary( + locsResponse.data.compactMap { loc in + loc.attributes?.locale.map { ($0, loc) } + }, + uniquingKeysWith: { first, _ in first } + ) + + // Send updates + for (locale, fields) in localeUpdates.sorted(by: { $0.key < $1.key }) { + guard let localization = locByLocale[locale] else { + print(" [\(locale)] Skipped — locale not found on this version.") + continue + } + + let request = Resources.v1.appStoreVersionLocalizations.id(localization.id).patch( + AppStoreVersionLocalizationUpdateRequest( + data: .init( + id: localization.id, + attributes: .init( + description: fields.description, + keywords: fields.keywords, + marketingURL: fields.marketingURL.flatMap { URL(string: $0) }, + promotionalText: fields.promotionalText, + supportURL: fields.supportURL.flatMap { URL(string: $0) }, + whatsNew: fields.whatsNew + ) + ) + ) + ) + + let response = try await client.send(request) + print(" [\(locale)] Updated.") + + if verbose { + let attrs = response.data.attributes + print(" Response:") + print(" Locale: \(attrs?.locale ?? "—")") + if let d = attrs?.description { print(" Description: \(d.prefix(120))\(d.count > 120 ? "..." : "")") } + if let w = attrs?.whatsNew { print(" What's New: \(w.prefix(120))\(w.count > 120 ? "..." : "")") } + if let k = attrs?.keywords { print(" Keywords: \(k.prefix(120))\(k.count > 120 ? "..." : "")") } + if let p = attrs?.promotionalText { print(" Promotional Text: \(p.prefix(120))\(p.count > 120 ? "..." : "")") } + if let u = attrs?.marketingURL { print(" Marketing URL: \(u)") } + if let u = attrs?.supportURL { print(" Support URL: \(u)") } + } + } + + print() + print("Done.") + } + } + + struct ExportLocalizations: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "export-localizations", + abstract: "Export localizations to a JSON file from an App Store version." + ) + + @Argument(help: "The bundle identifier of the app.") + var bundleID: String + + @Option(name: .long, help: "Version string (e.g. 2.1.0). Defaults to the latest version.") + var version: String? + + @Option(name: .long, help: "Output file path (default: -localizations.json).") + var output: String? + + func run() async throws { + let client = try ClientFactory.makeClient() + let app = try await findApp(bundleID: bundleID, client: client) + let version = try await findVersion(appID: app.id, versionString: version, client: client) + + let locsResponse = try await client.send( + Resources.v1.appStoreVersions.id(version.id) + .appStoreVersionLocalizations.get() + ) + + var result: [String: LocaleFields] = [:] + for loc in locsResponse.data { + guard let locale = loc.attributes?.locale else { continue } + let attrs = loc.attributes + result[locale] = LocaleFields( + description: attrs?.description, + whatsNew: attrs?.whatsNew, + keywords: attrs?.keywords, + promotionalText: attrs?.promotionalText, + marketingURL: attrs?.marketingURL?.absoluteString, + supportURL: attrs?.supportURL?.absoluteString + ) + } + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(result) + + let outputPath = expandPath( + confirmOutputPath(output ?? "\(bundleID)-localizations.json", isDirectory: false)) + try data.write(to: URL(fileURLWithPath: outputPath)) + + let versionString = version.attributes?.versionString ?? "unknown" + print("Exported \(result.count) locale(s) for version \(versionString) to \(outputPath)") + } + } +} + +struct LocaleFields: Codable { + var description: String? + var whatsNew: String? + var keywords: String? + var promotionalText: String? + var marketingURL: String? + var supportURL: String? +} + +private func describeDecodingError(_ error: DecodingError) -> String { + switch error { + case .typeMismatch(let type, let context): + return "Type mismatch for \(type) at \(context.codingPath.map(\.stringValue).joined(separator: ".")): \(context.debugDescription)" + case .valueNotFound(let type, let context): + return "Missing value for \(type) at \(context.codingPath.map(\.stringValue).joined(separator: "."))" + case .keyNotFound(let key, _): + return "Unknown key '\(key.stringValue)'" + case .dataCorrupted(let context): + return context.debugDescription + @unknown default: + return "\(error)" + } +} + +func findApp(bundleID: String, client: AppStoreConnectClient) async throws -> App { + let response = try await client.send( + Resources.v1.apps.get(filterBundleID: [bundleID]) + ) + // filterBundleID can return prefix matches, so find the exact match + guard let app = response.data.first(where: { $0.attributes?.bundleID == bundleID }) else { + throw AppLookupError.notFound(bundleID) + } + return app +} + +func findVersion(appID: String, versionString: String?, client: AppStoreConnectClient) async throws -> AppStoreVersion { + let request = Resources.v1.apps.id(appID).appStoreVersions.get( + filterVersionString: versionString.map { [$0] }, + limit: 1 + ) + let response = try await client.send(request) + guard let version = response.data.first else { + if let v = versionString { + throw AppLookupError.versionNotFound(v) + } + throw AppLookupError.noVersions + } + return version +} + +/// Fetches recent builds for the app, prompts the user to pick one, and attaches it to the version. +/// Returns the selected build. +@discardableResult +private func selectBuild(appID: String, versionID: String, client: AppStoreConnectClient) async throws -> Build { + let buildsResponse = try await client.send( + Resources.v1.builds.get( + filterApp: [appID], + sort: [.minusUploadedDate], + limit: 10 + ) + ) + + let builds = buildsResponse.data + guard !builds.isEmpty else { + throw ValidationError("No builds found for this app. Upload a build first via Xcode or Transporter.") + } + + print("Recent builds:") + for (i, build) in builds.enumerated() { + let number = build.attributes?.version ?? "—" + let state = build.attributes?.processingState.map { "\($0)" } ?? "—" + let uploaded = build.attributes?.uploadedDate.map { formatDate($0) } ?? "—" + print(" [\(i + 1)] \(number) \(state) \(uploaded)") + } + print() + print("Select a build (1-\(builds.count)): ", terminator: "") + guard let input = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines), + let choice = Int(input), + choice >= 1, choice <= builds.count else { + throw ValidationError("Invalid selection.") + } + + let selected = builds[choice - 1] + + // Attach the build to the version + try await client.send( + Resources.v1.appStoreVersions.id(versionID).relationships.build.patch( + AppStoreVersionBuildLinkageRequest( + data: .init(id: selected.id) + ) + ) + ) + + return selected +} + +enum AppLookupError: LocalizedError { + case notFound(String) + case versionNotFound(String) + case noVersions + + var errorDescription: String? { + switch self { + case .notFound(let bundleID): + return "No app found with bundle ID '\(bundleID)'." + case .versionNotFound(let version): + return "No App Store version '\(version)' found." + case .noVersions: + return "No App Store versions found." + } + } +} diff --git a/Sources/asc-client/Commands/BuildsCommand.swift b/Sources/asc-client/Commands/BuildsCommand.swift new file mode 100644 index 0000000..6cf4576 --- /dev/null +++ b/Sources/asc-client/Commands/BuildsCommand.swift @@ -0,0 +1,54 @@ +import AppStoreAPI +import AppStoreConnect +import ArgumentParser +import Foundation + +struct BuildsCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "builds", + abstract: "Manage builds.", + subcommands: [List.self] + ) + + struct List: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "List builds." + ) + + @Option(name: .long, help: "Filter by bundle identifier.") + var bundleID: String? + + func run() async throws { + let client = try ClientFactory.makeClient() + + var filterApp: [String]? + if let bundleID { + let app = try await findApp(bundleID: bundleID, client: client) + filterApp = [app.id] + } + + var allBuilds: [(String, String, String)] = [] + + let request = Resources.v1.builds.get( + filterApp: filterApp, + sort: [.minusUploadedDate] + ) + + for try await page in client.pages(request) { + for build in page.data { + let version = build.attributes?.version ?? "—" + let state = build.attributes?.processingState + .map { "\($0)" } ?? "—" + let uploaded = build.attributes?.uploadedDate + .map { formatDate($0) } ?? "—" + allBuilds.append((version, state, uploaded)) + } + } + + Table.print( + headers: ["Version", "State", "Uploaded"], + rows: allBuilds.map { [$0.0, $0.1, $0.2] } + ) + } + } +} diff --git a/Sources/asc-client/Commands/ConfigureCommand.swift b/Sources/asc-client/Commands/ConfigureCommand.swift new file mode 100644 index 0000000..2a757c9 --- /dev/null +++ b/Sources/asc-client/Commands/ConfigureCommand.swift @@ -0,0 +1,76 @@ +import ArgumentParser +import Foundation + +struct ConfigureCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "configure", + abstract: "Set up API credentials." + ) + + func run() throws { + print("====================================") + print("App Store Connect API Configuration") + print("====================================") + print() + print("You can find your API key at:") + print("https://appstoreconnect.apple.com/access/integrations/api") + print() + + let keyId = prompt("Key ID: ") + let issuerId = prompt("Issuer ID: ") + let sourceKeyPath = prompt("Private key (.p8) path: ") + + let fm = FileManager.default + + let expandedSource = expandPath(sourceKeyPath) + + guard fm.fileExists(atPath: expandedSource) else { + throw ValidationError("File not found at '\(expandedSource)'.") + } + + // Create config directory if needed + if !fm.fileExists(atPath: Config.configDirectory.path) { + try fm.createDirectory(at: Config.configDirectory, withIntermediateDirectories: true) + } + + // Copy the .p8 file into ~/.asc-client/ + let keyFilename = URL(fileURLWithPath: expandedSource).lastPathComponent + let destinationURL = Config.configDirectory.appendingPathComponent(keyFilename) + + if fm.fileExists(atPath: destinationURL.path) { + try fm.removeItem(at: destinationURL) + } + try fm.copyItem(atPath: expandedSource, toPath: destinationURL.path) + + let config = Config( + keyId: keyId, + issuerId: issuerId, + privateKeyPath: destinationURL.path + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(config) + try data.write(to: Config.configFile) + + // Set strict permissions: owner-only read/write (700 for dir, 600 for files) + try fm.setAttributes([.posixPermissions: 0o700], ofItemAtPath: Config.configDirectory.path) + try fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: Config.configFile.path) + try fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: destinationURL.path) + + print() + print("Private key copied to \(destinationURL.path)") + print("Config saved to \(Config.configFile.path)") + print("Permissions set to owner-only access.") + } + + private func prompt(_ message: String) -> String { + print(message, terminator: "") + guard let line = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines), + !line.isEmpty else { + print("Value cannot be empty. Try again.") + return prompt(message) + } + return line + } +} diff --git a/Sources/asc-client/Config.swift b/Sources/asc-client/Config.swift new file mode 100644 index 0000000..627d7f9 --- /dev/null +++ b/Sources/asc-client/Config.swift @@ -0,0 +1,40 @@ +import Foundation + +struct Config: Codable { + let keyId: String + let issuerId: String + let privateKeyPath: String + + static let configDirectory = FileManager.default + .homeDirectoryForCurrentUser + .appendingPathComponent(".asc-client") + + static let configFile = configDirectory.appendingPathComponent("config.json") + + static func load() throws -> Config { + guard FileManager.default.fileExists(atPath: configFile.path) else { + throw ConfigError.missingConfigFile(configFile.path) + } + + let data = try Data(contentsOf: configFile) + let config = try JSONDecoder().decode(Config.self, from: data) + return config + } +} + +enum ConfigError: LocalizedError { + case missingConfigFile(String) + case missingPrivateKey(String) + + var errorDescription: String? { + switch self { + case .missingConfigFile(let path): + return """ + No configuration found at \(path). + Run 'asc-client configure' to set up your API credentials. + """ + case .missingPrivateKey(let path): + return "Private key file not found at \(path)" + } + } +} diff --git a/Sources/asc-client/Formatting.swift b/Sources/asc-client/Formatting.swift new file mode 100644 index 0000000..f5cf1cb --- /dev/null +++ b/Sources/asc-client/Formatting.swift @@ -0,0 +1,74 @@ +import Foundation + +func expandPath(_ path: String) -> String { + if path.hasPrefix("~/") { + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(String(path.dropFirst(2))).path + } + return path +} + +func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: date) +} + +/// Checks if a path exists. If so, warns and prompts for a new name (pre-filled with the current name). +/// Returns the confirmed path to use. +func confirmOutputPath(_ path: String, isDirectory: Bool) -> String { + var current = path + let fm = FileManager.default + + while true { + var isDir: ObjCBool = false + let exists = fm.fileExists(atPath: expandPath(current), isDirectory: &isDir) + + if !exists { return current } + + let kind = isDir.boolValue ? "Folder" : "File" + print("\(kind) '\(current)' already exists. Press Enter to overwrite or type a new name:") + print("> ", terminator: "") + fflush(stdout) + + guard let line = readLine() else { return current } + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return current } + current = trimmed + } +} + +enum Table { + static func print(headers: [String], rows: [[String]]) { + guard !rows.isEmpty else { + Swift.print("No results.") + return + } + + let columnCount = headers.count + var widths = headers.map(\.count) + + for row in rows { + for (i, cell) in row.prefix(columnCount).enumerated() { + widths[i] = max(widths[i], cell.count) + } + } + + let headerLine = headers.enumerated().map { i, h in + h.padding(toLength: widths[i], withPad: " ", startingAt: 0) + }.joined(separator: " ") + + let separator = widths.map { String(repeating: "─", count: $0) }.joined(separator: "──") + + Swift.print(headerLine) + Swift.print(separator) + + for row in rows { + let line = row.prefix(columnCount).enumerated().map { i, cell in + cell.padding(toLength: widths[i], withPad: " ", startingAt: 0) + }.joined(separator: " ") + Swift.print(line) + } + } +} diff --git a/Sources/asc-client/MediaUpload.swift b/Sources/asc-client/MediaUpload.swift new file mode 100644 index 0000000..15ce2aa --- /dev/null +++ b/Sources/asc-client/MediaUpload.swift @@ -0,0 +1,1220 @@ +import AppStoreAPI +import AppStoreConnect +import ArgumentParser +import CryptoKit +import Foundation + +// MARK: - Media Types + +struct MediaFile { + let path: String + let fileName: String + let fileSize: Int +} + +struct DisplayTypeMedia { + let folderName: String + let screenshotDisplayType: ScreenshotDisplayType? + let previewType: PreviewType? + let screenshots: [MediaFile] + let previews: [MediaFile] +} + +struct LocaleMedia { + let locale: String + let displayTypes: [DisplayTypeMedia] +} + +struct MediaUploadPlan { + let locales: [LocaleMedia] + let warnings: [String] + var totalScreenshots: Int + var totalPreviews: Int +} + +// MARK: - Folder Scanning + +private let imageExtensions: Set = ["png", "jpg", "jpeg"] +private let videoExtensions: Set = ["mp4", "mov"] + +func scanMediaFolder(at path: String) throws -> MediaUploadPlan { + let fm = FileManager.default + let expandedPath = expandPath(path) + + var isDir: ObjCBool = false + guard fm.fileExists(atPath: expandedPath, isDirectory: &isDir), isDir.boolValue else { + throw ValidationError("Folder not found at '\(expandedPath)'.") + } + + var locales: [LocaleMedia] = [] + var warnings: [String] = [] + var totalScreenshots = 0 + var totalPreviews = 0 + + let localeContents = try fm.contentsOfDirectory(atPath: expandedPath).sorted() + + for localeName in localeContents { + let localePath = (expandedPath as NSString).appendingPathComponent(localeName) + var isLocalDir: ObjCBool = false + guard fm.fileExists(atPath: localePath, isDirectory: &isLocalDir), isLocalDir.boolValue else { + continue + } + + var displayTypes: [DisplayTypeMedia] = [] + let displayTypeContents = try fm.contentsOfDirectory(atPath: localePath).sorted() + + for displayTypeName in displayTypeContents { + let displayTypePath = (localePath as NSString).appendingPathComponent(displayTypeName) + var isDTDir: ObjCBool = false + guard fm.fileExists(atPath: displayTypePath, isDirectory: &isDTDir), isDTDir.boolValue else { + continue + } + + let screenshotType = ScreenshotDisplayType(rawValue: displayTypeName) + let pvType = previewTypeForDisplayType(displayTypeName) + + if screenshotType == nil { + warnings.append("[\(localeName)] Skipping unknown display type '\(displayTypeName)'.") + continue + } + + var screenshots: [MediaFile] = [] + var previews: [MediaFile] = [] + + let files = try fm.contentsOfDirectory(atPath: displayTypePath).sorted() + for fileName in files { + guard !fileName.hasPrefix(".") else { continue } + + let filePath = (displayTypePath as NSString).appendingPathComponent(fileName) + let ext = (fileName as NSString).pathExtension.lowercased() + + let attrs = try fm.attributesOfItem(atPath: filePath) + let fileSize = (attrs[.size] as? Int) ?? 0 + + if imageExtensions.contains(ext) { + screenshots.append( + MediaFile(path: filePath, fileName: fileName, fileSize: fileSize)) + } else if videoExtensions.contains(ext) { + if pvType != nil { + previews.append( + MediaFile(path: filePath, fileName: fileName, fileSize: fileSize)) + } else { + warnings.append( + "[\(localeName)/\(displayTypeName)] Skipping '\(fileName)' — no preview support for this display type." + ) + } + } else { + warnings.append( + "[\(localeName)/\(displayTypeName)] Skipping '\(fileName)' — unsupported file type.") + } + } + + if !screenshots.isEmpty || !previews.isEmpty { + totalScreenshots += screenshots.count + totalPreviews += previews.count + displayTypes.append( + DisplayTypeMedia( + folderName: displayTypeName, + screenshotDisplayType: screenshotType, + previewType: pvType, + screenshots: screenshots, + previews: previews + )) + } + } + + if !displayTypes.isEmpty { + locales.append(LocaleMedia(locale: localeName, displayTypes: displayTypes)) + } + } + + return MediaUploadPlan( + locales: locales, + warnings: warnings, + totalScreenshots: totalScreenshots, + totalPreviews: totalPreviews + ) +} + +func previewTypeForDisplayType(_ rawValue: String) -> PreviewType? { + if rawValue.hasPrefix("APP_WATCH_") || rawValue.hasPrefix("IMESSAGE_") { + return nil + } + guard rawValue.hasPrefix("APP_") else { return nil } + let previewRaw = String(rawValue.dropFirst(4)) + return PreviewType(rawValue: previewRaw) +} + +// MARK: - Upload Helpers + +func uploadChunks(filePath: String, operations: [UploadOperation]) async throws { + guard let fileHandle = FileHandle(forReadingAtPath: filePath) else { + throw MediaUploadError.cannotReadFile(filePath) + } + defer { try? fileHandle.close() } + + for operation in operations { + guard let urlString = operation.url, + let url = URL(string: urlString), + let method = operation.method, + let offset = operation.offset, + let length = operation.length + else { + throw MediaUploadError.invalidUploadOperation + } + + try fileHandle.seek(toOffset: UInt64(offset)) + let chunkData = fileHandle.readData(ofLength: length) + + var request = URLRequest(url: url) + request.httpMethod = method + if let headers = operation.requestHeaders { + for header in headers { + if let name = header.name, let value = header.value { + request.setValue(value, forHTTPHeaderField: name) + } + } + } + + let (_, response) = try await URLSession.shared.upload(for: request, from: chunkData) + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) + else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + throw MediaUploadError.chunkUploadFailed(statusCode) + } + } +} + +func md5Hex(filePath: String) throws -> String { + guard let fileHandle = FileHandle(forReadingAtPath: filePath) else { + throw MediaUploadError.cannotReadFile(filePath) + } + defer { try? fileHandle.close() } + + var md5 = Insecure.MD5() + let bufferSize = 1024 * 1024 + + while true { + let data = fileHandle.readData(ofLength: bufferSize) + if data.isEmpty { break } + md5.update(data: data) + } + + let digest = md5.finalize() + return digest.map { String(format: "%02x", $0) }.joined() +} + +func mediaMimeType(for fileName: String) -> String { + let ext = (fileName as NSString).pathExtension.lowercased() + switch ext { + case "mp4": return "video/mp4" + case "mov": return "video/quicktime" + default: return "application/octet-stream" + } +} + +enum MediaUploadError: LocalizedError { + case cannotReadFile(String) + case invalidUploadOperation + case chunkUploadFailed(Int) + case noUploadOperations + + var errorDescription: String? { + switch self { + case .cannotReadFile(let path): + return "Cannot read file at '\(path)'." + case .invalidUploadOperation: + return "Upload operation missing required fields." + case .chunkUploadFailed(let statusCode): + return "Chunk upload failed with status \(statusCode)." + case .noUploadOperations: + return "No upload operations returned by the API." + } + } +} + +enum MediaDownloadError: LocalizedError { + case invalidURL(String) + case noURL(String) + + var errorDescription: String? { + switch self { + case .invalidURL(let url): + return "Invalid download URL: \(url)" + case .noURL(let fileName): + return "No download URL available for '\(fileName)'." + } + } +} + +func resolveImageURL(templateURL: String, width: Int, height: Int, fileName: String) -> String { + let ext = (fileName as NSString).pathExtension.lowercased() + let format = (ext == "jpg" || ext == "jpeg") ? "jpg" : "png" + return templateURL + .replacingOccurrences(of: "{w}", with: "\(width)") + .replacingOccurrences(of: "{h}", with: "\(height)") + .replacingOccurrences(of: "{f}", with: format) +} + +// MARK: - UploadMedia Command + +extension AppsCommand { + struct UploadMedia: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "upload-media", + abstract: "Upload screenshots and app preview videos from a folder." + ) + + @Argument(help: "The bundle identifier of the app.") + var bundleID: String + + @Option(name: .long, help: "Path to the media folder.") + var folder: String? + + @Option(name: .long, help: "Version string (e.g. 2.1.0). Defaults to the latest version.") + var version: String? + + @Flag(name: .long, help: "Delete existing media in matching sets before uploading.") + var replace = false + + func run() async throws { + // Get folder path + let folderPath: String + if let f = folder { + folderPath = f + } else { + print("Path to media folder: ", terminator: "") + guard let line = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines), + !line.isEmpty + else { + throw ValidationError("No folder path provided.") + } + folderPath = line + } + + // Scan folder + let plan = try scanMediaFolder(at: folderPath) + + if plan.locales.isEmpty { + print("No media files found in '\(expandPath(folderPath))'.") + return + } + + // Print warnings + for warning in plan.warnings { + print("Warning: \(warning)") + } + if !plan.warnings.isEmpty { print() } + + // Resolve app and version + let client = try ClientFactory.makeClient() + let app = try await findApp(bundleID: bundleID, client: client) + let appVersion = try await findVersion(appID: app.id, versionString: version, client: client) + + let versionString = appVersion.attributes?.versionString ?? "unknown" + let versionState = appVersion.attributes?.appVersionState.map { "\($0)" } ?? "unknown" + + // Print confirmation summary + print("App: \(app.attributes?.name ?? bundleID)") + print("Version: \(versionString)") + print("State: \(versionState)") + if replace { print("Mode: Replace existing media") } + print() + + for localeMedia in plan.locales { + print("[\(localeMedia.locale)]") + for dt in localeMedia.displayTypes { + var parts: [String] = [] + if !dt.screenshots.isEmpty { + parts.append( + "\(dt.screenshots.count) screenshot\(dt.screenshots.count == 1 ? "" : "s")") + } + if !dt.previews.isEmpty { + parts.append("\(dt.previews.count) preview\(dt.previews.count == 1 ? "" : "s")") + } + print(" \(dt.folderName): \(parts.joined(separator: ", "))") + } + } + print() + + let localeCount = plan.locales.count + print( + "Upload \(plan.totalScreenshots) screenshot\(plan.totalScreenshots == 1 ? "" : "s") and \(plan.totalPreviews) preview\(plan.totalPreviews == 1 ? "" : "s") for \(localeCount) locale\(localeCount == 1 ? "" : "s")? [y/N] ", + terminator: "") + guard + let answer = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + answer == "y" || answer == "yes" + else { + print("Cancelled.") + return + } + print() + + // Fetch all localizations for this version + let locsResponse = try await client.send( + Resources.v1.appStoreVersions.id(appVersion.id) + .appStoreVersionLocalizations.get() + ) + let locByLocale = Dictionary( + locsResponse.data.compactMap { loc in + loc.attributes?.locale.map { ($0, loc) } + }, + uniquingKeysWith: { first, _ in first } + ) + + var successCount = 0 + var failureCount = 0 + + for localeMedia in plan.locales { + guard let localization = locByLocale[localeMedia.locale] else { + print("[\(localeMedia.locale)] Skipped — locale not found on this version.") + continue + } + + print("[\(localeMedia.locale)]") + + // Fetch existing screenshot sets for this localization + let screenshotSetsResponse = try await client.send( + Resources.v1.appStoreVersionLocalizations.id(localization.id) + .appScreenshotSets.get(limit: 50) + ) + var screenshotSetsByType: [String: AppScreenshotSet] = [:] + for set in screenshotSetsResponse.data { + if let rawType = set.attributes?.screenshotDisplayType?.rawValue { + screenshotSetsByType[rawType] = set + } + } + + // Fetch existing preview sets for this localization + let previewSetsResponse = try await client.send( + Resources.v1.appStoreVersionLocalizations.id(localization.id) + .appPreviewSets.get(limit: 50) + ) + var previewSetsByType: [String: AppPreviewSet] = [:] + for set in previewSetsResponse.data { + if let rawType = set.attributes?.previewType?.rawValue { + previewSetsByType[rawType] = set + } + } + + for dt in localeMedia.displayTypes { + print(" \(dt.folderName):") + + // Handle screenshots + if !dt.screenshots.isEmpty, let displayType = dt.screenshotDisplayType { + let screenshotSetID: String + if let existingSet = screenshotSetsByType[displayType.rawValue] { + screenshotSetID = existingSet.id + + if replace { + let existing = try await client.send( + Resources.v1.appScreenshotSets.id(screenshotSetID) + .appScreenshots.get() + ) + for screenshot in existing.data { + try await client.send( + Resources.v1.appScreenshots.id(screenshot.id).delete + ) + } + if !existing.data.isEmpty { + print( + " Deleted \(existing.data.count) existing screenshot\(existing.data.count == 1 ? "" : "s")." + ) + } + } + } else { + let createResponse = try await client.send( + Resources.v1.appScreenshotSets.post( + AppScreenshotSetCreateRequest( + data: .init( + attributes: .init(screenshotDisplayType: displayType), + relationships: .init( + appStoreVersionLocalization: .init( + data: .init(id: localization.id) + ) + ) + ) + ) + ) + ) + screenshotSetID = createResponse.data.id + } + + for (i, file) in dt.screenshots.enumerated() { + print( + " Screenshot \(i + 1)/\(dt.screenshots.count): \(file.fileName)... ", + terminator: "") + fflush(stdout) + + do { + // Reserve + let reserveResponse = try await client.send( + Resources.v1.appScreenshots.post( + AppScreenshotCreateRequest( + data: .init( + attributes: .init(fileSize: file.fileSize, fileName: file.fileName), + relationships: .init( + appScreenshotSet: .init(data: .init(id: screenshotSetID)) + ) + ) + ) + ) + ) + + let screenshotID = reserveResponse.data.id + guard let operations = reserveResponse.data.attributes?.uploadOperations, + !operations.isEmpty + else { + throw MediaUploadError.noUploadOperations + } + + // Upload chunks + try await uploadChunks(filePath: file.path, operations: operations) + + // Commit + let checksum = try md5Hex(filePath: file.path) + _ = try await client.send( + Resources.v1.appScreenshots.id(screenshotID).patch( + AppScreenshotUpdateRequest( + data: .init( + id: screenshotID, + attributes: .init( + sourceFileChecksum: checksum, + isUploaded: true + ) + ) + ) + ) + ) + + print("Done.") + successCount += 1 + } catch { + print("Failed: \(error.localizedDescription)") + failureCount += 1 + } + } + } + + // Handle previews + if !dt.previews.isEmpty, let pvType = dt.previewType { + let previewSetID: String + if let existingSet = previewSetsByType[pvType.rawValue] { + previewSetID = existingSet.id + + if replace { + let existing = try await client.send( + Resources.v1.appPreviewSets.id(previewSetID) + .appPreviews.get() + ) + for preview in existing.data { + try await client.send( + Resources.v1.appPreviews.id(preview.id).delete + ) + } + if !existing.data.isEmpty { + print( + " Deleted \(existing.data.count) existing preview\(existing.data.count == 1 ? "" : "s")." + ) + } + } + } else { + let createResponse = try await client.send( + Resources.v1.appPreviewSets.post( + AppPreviewSetCreateRequest( + data: .init( + attributes: .init(previewType: pvType), + relationships: .init( + appStoreVersionLocalization: .init( + data: .init(id: localization.id) + ) + ) + ) + ) + ) + ) + previewSetID = createResponse.data.id + } + + for (i, file) in dt.previews.enumerated() { + print( + " Preview \(i + 1)/\(dt.previews.count): \(file.fileName)... ", + terminator: "") + fflush(stdout) + + do { + let mime = mediaMimeType(for: file.fileName) + + // Reserve + let reserveResponse = try await client.send( + Resources.v1.appPreviews.post( + AppPreviewCreateRequest( + data: .init( + attributes: .init( + fileSize: file.fileSize, + fileName: file.fileName, + mimeType: mime + ), + relationships: .init( + appPreviewSet: .init(data: .init(id: previewSetID)) + ) + ) + ) + ) + ) + + let previewID = reserveResponse.data.id + guard let operations = reserveResponse.data.attributes?.uploadOperations, + !operations.isEmpty + else { + throw MediaUploadError.noUploadOperations + } + + // Upload chunks + try await uploadChunks(filePath: file.path, operations: operations) + + // Commit + let checksum = try md5Hex(filePath: file.path) + _ = try await client.send( + Resources.v1.appPreviews.id(previewID).patch( + AppPreviewUpdateRequest( + data: .init( + id: previewID, + attributes: .init( + sourceFileChecksum: checksum, + isUploaded: true + ) + ) + ) + ) + ) + + print("Done.") + successCount += 1 + } catch { + print("Failed: \(error.localizedDescription)") + failureCount += 1 + } + } + } + } + } + + // Final summary + print() + if failureCount == 0 { + print("Done. \(successCount) file\(successCount == 1 ? "" : "s") uploaded successfully.") + } else { + print("Done. \(successCount) succeeded, \(failureCount) failed.") + } + } + } + + struct DownloadMedia: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "download-media", + abstract: "Download screenshots and app preview videos to a folder." + ) + + @Argument(help: "The bundle identifier of the app.") + var bundleID: String + + @Option(name: .long, help: "Output folder path. Defaults to -media.") + var folder: String? + + @Option(name: .long, help: "Version string (e.g. 2.1.0). Defaults to the latest version.") + var version: String? + + func run() async throws { + let client = try ClientFactory.makeClient() + let app = try await findApp(bundleID: bundleID, client: client) + let appVersion = try await findVersion( + appID: app.id, versionString: version, client: client) + + let versionString = appVersion.attributes?.versionString ?? "unknown" + let versionState = appVersion.attributes?.appVersionState.map { "\($0)" } ?? "unknown" + print("App: \(app.attributes?.name ?? bundleID)") + print("Version: \(versionString)") + print("State: \(versionState)") + print() + + // Fetch all localizations + let locsResponse = try await client.send( + Resources.v1.appStoreVersions.id(appVersion.id) + .appStoreVersionLocalizations.get() + ) + + let outputFolder = expandPath( + confirmOutputPath(folder ?? "\(bundleID)-media", isDirectory: true)) + let fm = FileManager.default + + var screenshotCount = 0 + var previewCount = 0 + var failureCount = 0 + + for loc in locsResponse.data { + guard let locale = loc.attributes?.locale else { continue } + + // Fetch screenshot sets for this localization + let setsResponse = try await client.send( + Resources.v1.appStoreVersionLocalizations.id(loc.id) + .appScreenshotSets.get(limit: 50) + ) + + for set in setsResponse.data { + guard let displayType = set.attributes?.screenshotDisplayType else { continue } + + let screenshotsResponse = try await client.send( + Resources.v1.appScreenshotSets.id(set.id).appScreenshots.get() + ) + + if screenshotsResponse.data.isEmpty { continue } + + let setFolder = "\(outputFolder)/\(locale)/\(displayType.rawValue)" + try fm.createDirectory(atPath: setFolder, withIntermediateDirectories: true) + + print("[\(locale)] \(displayType.rawValue):") + + for (i, screenshot) in screenshotsResponse.data.enumerated() { + let originalName = screenshot.attributes?.fileName ?? "\(screenshot.id).png" + let fileName = String(format: "%02d_%@", i + 1, originalName) + + print( + " Screenshot \(i + 1)/\(screenshotsResponse.data.count): \(fileName)... ", + terminator: "") + fflush(stdout) + + do { + guard let templateURL = screenshot.attributes?.imageAsset?.templateURL else { + throw MediaDownloadError.noURL(originalName) + } + + let width = screenshot.attributes?.imageAsset?.width ?? 0 + let height = screenshot.attributes?.imageAsset?.height ?? 0 + let downloadURL = resolveImageURL( + templateURL: templateURL, width: width, height: height, fileName: originalName) + + guard let url = URL(string: downloadURL) else { + throw MediaDownloadError.invalidURL(downloadURL) + } + + let (tempURL, _) = try await URLSession.shared.download(from: url) + let destPath = "\(setFolder)/\(fileName)" + let destURL = URL(fileURLWithPath: destPath) + if fm.fileExists(atPath: destPath) { + try fm.removeItem(at: destURL) + } + try fm.moveItem(at: tempURL, to: destURL) + + print("Done.") + screenshotCount += 1 + } catch { + print("Failed: \(error.localizedDescription)") + failureCount += 1 + } + } + } + + // Fetch preview sets for this localization + let previewSetsResponse = try await client.send( + Resources.v1.appStoreVersionLocalizations.id(loc.id) + .appPreviewSets.get(limit: 50) + ) + + for set in previewSetsResponse.data { + guard let pvType = set.attributes?.previewType else { continue } + + let previewsResponse = try await client.send( + Resources.v1.appPreviewSets.id(set.id).appPreviews.get() + ) + + if previewsResponse.data.isEmpty { continue } + + // Map preview type back to screenshot display type folder name + let folderName = "APP_\(pvType.rawValue)" + let setFolder = "\(outputFolder)/\(locale)/\(folderName)" + try fm.createDirectory(atPath: setFolder, withIntermediateDirectories: true) + + print("[\(locale)] \(folderName):") + + for (i, preview) in previewsResponse.data.enumerated() { + let originalName = preview.attributes?.fileName ?? "\(preview.id).mp4" + let fileName = String(format: "%02d_%@", i + 1, originalName) + + print( + " Preview \(i + 1)/\(previewsResponse.data.count): \(fileName)... ", + terminator: "") + fflush(stdout) + + do { + guard let videoURLString = preview.attributes?.videoURL else { + throw MediaDownloadError.noURL(originalName) + } + + guard let url = URL(string: videoURLString) else { + throw MediaDownloadError.invalidURL(videoURLString) + } + + let (tempURL, _) = try await URLSession.shared.download(from: url) + let destPath = "\(setFolder)/\(fileName)" + let destURL = URL(fileURLWithPath: destPath) + if fm.fileExists(atPath: destPath) { + try fm.removeItem(at: destURL) + } + try fm.moveItem(at: tempURL, to: destURL) + + print("Done.") + previewCount += 1 + } catch { + print("Failed: \(error.localizedDescription)") + failureCount += 1 + } + } + } + } + + // Final summary + print() + let total = screenshotCount + previewCount + if total == 0 { + print("No media found for this version.") + } else if failureCount == 0 { + print( + "Downloaded \(screenshotCount) screenshot\(screenshotCount == 1 ? "" : "s") and \(previewCount) preview\(previewCount == 1 ? "" : "s") to \(outputFolder)" + ) + } else { + print( + "Done. \(total) succeeded, \(failureCount) failed. Output: \(outputFolder)") + } + } + } + + struct VerifyMedia: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "verify-media", + abstract: "Check processing status of all screenshots and previews, optionally retry stuck items." + ) + + @Argument(help: "The bundle identifier of the app.") + var bundleID: String + + @Option(name: .long, help: "Version string (e.g. 2.1.0). Defaults to the latest version.") + var version: String? + + @Option(name: .long, help: "Path to the media folder for retrying stuck uploads.") + var folder: String? + + func run() async throws { + let client = try ClientFactory.makeClient() + let app = try await findApp(bundleID: bundleID, client: client) + let appVersion = try await findVersion( + appID: app.id, versionString: version, client: client) + + let versionString = appVersion.attributes?.versionString ?? "unknown" + print("App: \(app.attributes?.name ?? bundleID)") + print("Version: \(versionString)") + print() + + // Fetch all media status + let items = try await fetchAllMediaStatus(versionID: appVersion.id, client: client) + + if items.isEmpty { + print("No media found for this version.") + return + } + + // Print status and get counts + let (total, stuck) = printMediaStatus(items) + + if stuck == 0 { + print() + print("All \(total) media item\(total == 1 ? "" : "s") complete.") + return + } + + print() + print("\(total - stuck) of \(total) complete, \(stuck) stuck.") + + // Without --folder, just show status + guard let folderPath = folder else { + print("Use --folder to provide the media folder and retry stuck uploads.") + return + } + + // Build local file index from the folder + let plan = try scanMediaFolder(at: folderPath) + let fileIndex = buildLocalFileIndex(from: plan) + + // Match stuck items to local files + let stuckItems = items.filter { !$0.isComplete } + var matchedRetries: [(MediaItemStatus, String)] = [] // (item, localFilePath) + var unmatchedCount = 0 + + for item in stuckItems { + let prefix = item.isScreenshot ? "screenshot" : "preview" + let key = "\(item.locale)/\(item.displayTypeName)/\(prefix)/\(item.position)" + if let localPath = fileIndex[key] { + matchedRetries.append((item, localPath)) + } else { + unmatchedCount += 1 + } + } + + if matchedRetries.isEmpty { + print("No matching local files found for stuck items.") + return + } + + if unmatchedCount > 0 { + print("\(unmatchedCount) stuck item\(unmatchedCount == 1 ? "" : "s") have no matching local file and will be skipped.") + } + + print() + print("Retry \(matchedRetries.count) stuck item\(matchedRetries.count == 1 ? "" : "s")? [y/N] ", terminator: "") + guard + let answer = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + answer == "y" || answer == "yes" + else { + print("Cancelled.") + return + } + print() + + var successCount = 0 + var failureCount = 0 + + for (item, localPath) in matchedRetries { + print("[\(item.locale)] \(item.displayTypeName) #\(item.position): ", terminator: "") + fflush(stdout) + + do { + // Delete the stuck item + print("Deleting... ", terminator: "") + fflush(stdout) + if item.isScreenshot { + try await client.send(Resources.v1.appScreenshots.id(item.mediaID).delete) + } else { + try await client.send(Resources.v1.appPreviews.id(item.mediaID).delete) + } + + // Upload replacement + print("Uploading... ", terminator: "") + fflush(stdout) + + let fm = FileManager.default + let attrs = try fm.attributesOfItem(atPath: localPath) + let fileSize = (attrs[.size] as? Int) ?? 0 + let fileName = (localPath as NSString).lastPathComponent + + if item.isScreenshot { + let reserveResponse = try await client.send( + Resources.v1.appScreenshots.post( + AppScreenshotCreateRequest( + data: .init( + attributes: .init(fileSize: fileSize, fileName: fileName), + relationships: .init( + appScreenshotSet: .init(data: .init(id: item.setID)) + ) + ) + ) + ) + ) + + let newID = reserveResponse.data.id + guard let operations = reserveResponse.data.attributes?.uploadOperations, + !operations.isEmpty + else { + throw MediaUploadError.noUploadOperations + } + + try await uploadChunks(filePath: localPath, operations: operations) + + let checksum = try md5Hex(filePath: localPath) + _ = try await client.send( + Resources.v1.appScreenshots.id(newID).patch( + AppScreenshotUpdateRequest( + data: .init( + id: newID, + attributes: .init( + sourceFileChecksum: checksum, + isUploaded: true + ) + ) + ) + ) + ) + + // Reorder to restore original position + print("Reordering... ", terminator: "") + fflush(stdout) + var newOrder = item.allIDsInSet + if let idx = newOrder.firstIndex(of: item.mediaID) { + newOrder.remove(at: idx) + newOrder.insert(newID, at: idx) + } + try await client.send( + Resources.v1.appScreenshotSets.id(item.setID).relationships.appScreenshots.patch( + AppScreenshotSetAppScreenshotsLinkagesRequest( + data: newOrder.map { .init(id: $0) } + ) + ) + ) + } else { + let mime = mediaMimeType(for: fileName) + let reserveResponse = try await client.send( + Resources.v1.appPreviews.post( + AppPreviewCreateRequest( + data: .init( + attributes: .init( + fileSize: fileSize, + fileName: fileName, + mimeType: mime + ), + relationships: .init( + appPreviewSet: .init(data: .init(id: item.setID)) + ) + ) + ) + ) + ) + + let newID = reserveResponse.data.id + guard let operations = reserveResponse.data.attributes?.uploadOperations, + !operations.isEmpty + else { + throw MediaUploadError.noUploadOperations + } + + try await uploadChunks(filePath: localPath, operations: operations) + + let checksum = try md5Hex(filePath: localPath) + _ = try await client.send( + Resources.v1.appPreviews.id(newID).patch( + AppPreviewUpdateRequest( + data: .init( + id: newID, + attributes: .init( + sourceFileChecksum: checksum, + isUploaded: true + ) + ) + ) + ) + ) + + // Reorder to restore original position + print("Reordering... ", terminator: "") + fflush(stdout) + var newOrder = item.allIDsInSet + if let idx = newOrder.firstIndex(of: item.mediaID) { + newOrder.remove(at: idx) + newOrder.insert(newID, at: idx) + } + try await client.send( + Resources.v1.appPreviewSets.id(item.setID).relationships.appPreviews.patch( + AppPreviewSetAppPreviewsLinkagesRequest( + data: newOrder.map { .init(id: $0) } + ) + ) + ) + } + + print("Done.") + successCount += 1 + } catch { + print("Failed: \(error.localizedDescription)") + failureCount += 1 + } + } + + // Re-verify + print() + print("Re-verifying...") + print() + + let updatedItems = try await fetchAllMediaStatus(versionID: appVersion.id, client: client) + let (newTotal, newStuck) = printMediaStatus(updatedItems) + + print() + if newStuck == 0 { + print("All \(newTotal) media item\(newTotal == 1 ? "" : "s") complete.") + } else { + print("\(newTotal - newStuck) of \(newTotal) complete, \(newStuck) still stuck.") + } + + if failureCount > 0 { + print("\(successCount) retried successfully, \(failureCount) failed.") + } + } + } +} + +// MARK: - VerifyMedia Helpers + +private struct MediaItemStatus { + let locale: String + let displayTypeName: String + let position: Int // 1-based + let fileName: String + let state: String + let isComplete: Bool + let isScreenshot: Bool + let setID: String + let mediaID: String + let allIDsInSet: [String] +} + +private func fetchAllMediaStatus( + versionID: String, client: AppStoreConnectClient +) async throws -> [MediaItemStatus] { + let locsResponse = try await client.send( + Resources.v1.appStoreVersions.id(versionID) + .appStoreVersionLocalizations.get() + ) + + var items: [MediaItemStatus] = [] + + for loc in locsResponse.data { + guard let locale = loc.attributes?.locale else { continue } + + // Screenshot sets + let setsResponse = try await client.send( + Resources.v1.appStoreVersionLocalizations.id(loc.id) + .appScreenshotSets.get(limit: 50) + ) + + for set in setsResponse.data { + guard let displayType = set.attributes?.screenshotDisplayType else { continue } + + let screenshotsResponse = try await client.send( + Resources.v1.appScreenshotSets.id(set.id).appScreenshots.get() + ) + + let allIDs = screenshotsResponse.data.map(\.id) + + for (i, screenshot) in screenshotsResponse.data.enumerated() { + let name = screenshot.attributes?.fileName ?? "unknown" + let assetState = screenshot.attributes?.assetDeliveryState?.state + let stateStr = assetState.map { "\($0)" } ?? "unknown" + let complete = assetState == .complete + + items.append(MediaItemStatus( + locale: locale, + displayTypeName: displayType.rawValue, + position: i + 1, + fileName: name, + state: stateStr, + isComplete: complete, + isScreenshot: true, + setID: set.id, + mediaID: screenshot.id, + allIDsInSet: allIDs + )) + } + } + + // Preview sets + let previewSetsResponse = try await client.send( + Resources.v1.appStoreVersionLocalizations.id(loc.id) + .appPreviewSets.get(limit: 50) + ) + + for set in previewSetsResponse.data { + guard let pvType = set.attributes?.previewType else { continue } + let displayTypeName = "APP_\(pvType.rawValue)" + + let previewsResponse = try await client.send( + Resources.v1.appPreviewSets.id(set.id).appPreviews.get() + ) + + let allIDs = previewsResponse.data.map(\.id) + + for (i, preview) in previewsResponse.data.enumerated() { + let name = preview.attributes?.fileName ?? "unknown" + let assetState = preview.attributes?.assetDeliveryState?.state + let stateStr = assetState.map { "\($0)" } ?? "unknown" + let complete = assetState == .complete + + items.append(MediaItemStatus( + locale: locale, + displayTypeName: displayTypeName, + position: i + 1, + fileName: name, + state: stateStr, + isComplete: complete, + isScreenshot: false, + setID: set.id, + mediaID: preview.id, + allIDsInSet: allIDs + )) + } + } + } + + return items +} + +/// Prints media status grouped by locale and display type. +/// Returns (total, stuck) counts. +@discardableResult +private func printMediaStatus(_ items: [MediaItemStatus]) -> (total: Int, stuck: Int) { + // Group by locale, then by displayType+setID + struct SetKey: Hashable { + let locale: String + let displayTypeName: String + let setID: String + } + + var grouped: [SetKey: [MediaItemStatus]] = [:] + for item in items { + let key = SetKey(locale: item.locale, displayTypeName: item.displayTypeName, setID: item.setID) + grouped[key, default: []].append(item) + } + + // Sort by locale, then display type + let sortedKeys = grouped.keys.sorted { + if $0.locale != $1.locale { return $0.locale < $1.locale } + return $0.displayTypeName < $1.displayTypeName + } + + var total = 0 + var stuck = 0 + + for key in sortedKeys { + let setItems = grouped[key]! + total += setItems.count + let setStuck = setItems.filter { !$0.isComplete }.count + stuck += setStuck + + if setStuck == 0 { + print("[\(key.locale)] \(key.displayTypeName): \(setItems.count)/\(setItems.count) complete") + } else { + print("[\(key.locale)] \(key.displayTypeName):") + for item in setItems { + let marker = item.isComplete ? "complete" : item.state + print(" #\(item.position) \(item.fileName) \(marker)") + } + } + } + + return (total, stuck) +} + +/// Builds a lookup from "locale/displayType/screenshot|preview/position" to local file path. +private func buildLocalFileIndex(from plan: MediaUploadPlan) -> [String: String] { + var index: [String: String] = [:] + + for localeMedia in plan.locales { + for dt in localeMedia.displayTypes { + for (i, file) in dt.screenshots.enumerated() { + index["\(localeMedia.locale)/\(dt.folderName)/screenshot/\(i + 1)"] = file.path + } + for (i, file) in dt.previews.enumerated() { + index["\(localeMedia.locale)/\(dt.folderName)/preview/\(i + 1)"] = file.path + } + } + } + + return index +}