Add asc-client v0.1.0
Swift CLI for the App Store Connect API. Commands for managing apps, versions, localizations, screenshots/previews, builds, and review submission. Includes interactive credential setup and media upload with retry support for stuck processing items.
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
/.claude
|
||||||
|
*.xcodeproj
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/configuration/registries.json
|
||||||
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
|
.netrc
|
||||||
171
CLAUDE.md
Normal file
171
CLAUDE.md
Normal file
@@ -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 <command> # 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 <bundle-id> # App details
|
||||||
|
asc-client apps versions <bundle-id> # List App Store versions
|
||||||
|
asc-client apps localizations <bundle-id> [--version X] # View localizations
|
||||||
|
asc-client apps review-status <bundle-id> # Review submission status
|
||||||
|
asc-client apps create-version <bundle-id> <ver> [--platform X] # Create new version
|
||||||
|
asc-client apps select-build <bundle-id> [--version X] # Attach a build to a version
|
||||||
|
asc-client apps submit-for-review <bundle-id> [--version X] # Submit version for App Review
|
||||||
|
asc-client apps update-localization <bundle-id> [--locale X] # Update single locale via flags
|
||||||
|
asc-client apps update-localizations <bundle-id> [--file X] # Bulk update from JSON file
|
||||||
|
asc-client apps export-localizations <bundle-id> [--version X] # Export to JSON file
|
||||||
|
asc-client apps upload-media <bundle-id> [--folder X] [--version X] [--replace] # Upload screenshots/previews
|
||||||
|
asc-client apps download-media <bundle-id> [--folder X] [--version X] # Download screenshots/previews
|
||||||
|
asc-client apps verify-media <bundle-id> [--version X] [--folder X] # Check media status, retry stuck
|
||||||
|
asc-client builds list [--bundle-id <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.
|
||||||
|
|
||||||
|
|
||||||
|
<claude-mem-context>
|
||||||
|
# Recent Activity
|
||||||
|
|
||||||
|
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||||
|
|
||||||
|
*No recent activity*
|
||||||
|
</claude-mem-context>
|
||||||
51
Package.resolved
Normal file
51
Package.resolved
Normal file
@@ -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
|
||||||
|
}
|
||||||
21
Package.swift
Normal file
21
Package.swift
Normal file
@@ -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"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
240
README.md
Normal file
240
README.md
Normal file
@@ -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 <bundle-id>
|
||||||
|
|
||||||
|
# List App Store versions
|
||||||
|
asc-client apps versions <bundle-id>
|
||||||
|
|
||||||
|
# Create a new version
|
||||||
|
asc-client apps create-version <bundle-id> <version-string>
|
||||||
|
asc-client apps create-version <bundle-id> 2.1.0 --platform ios --release-type manual
|
||||||
|
|
||||||
|
# Check review submission status
|
||||||
|
asc-client apps review-status <bundle-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Localizations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View localizations (latest version by default)
|
||||||
|
asc-client apps localizations <bundle-id>
|
||||||
|
asc-client apps localizations <bundle-id> --version 1.2.0 --locale en-US
|
||||||
|
|
||||||
|
# Export localizations to JSON
|
||||||
|
asc-client apps export-localizations <bundle-id>
|
||||||
|
asc-client apps export-localizations <bundle-id> --version 1.2.0 --output my-localizations.json
|
||||||
|
|
||||||
|
# Update a single locale
|
||||||
|
asc-client apps update-localization <bundle-id> --whats-new "Bug fixes" --locale en-US
|
||||||
|
|
||||||
|
# Bulk update from JSON file
|
||||||
|
asc-client apps update-localizations <bundle-id> --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 <bundle-id>
|
||||||
|
asc-client apps download-media <bundle-id> --folder my-media/ --version 2.1.0
|
||||||
|
|
||||||
|
# Upload screenshots and preview videos from a folder
|
||||||
|
asc-client apps upload-media <bundle-id> --folder media/
|
||||||
|
|
||||||
|
# Upload to a specific version
|
||||||
|
asc-client apps upload-media <bundle-id> --folder media/ --version 2.1.0
|
||||||
|
|
||||||
|
# Replace existing media in matching sets before uploading
|
||||||
|
asc-client apps upload-media <bundle-id> --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 `<bundle-id>-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 <bundle-id>
|
||||||
|
|
||||||
|
# Check a specific version
|
||||||
|
asc-client apps verify-media <bundle-id> --version 2.1.0
|
||||||
|
|
||||||
|
# Retry stuck items using local files from the media folder
|
||||||
|
asc-client apps verify-media <bundle-id> --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 <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
|
||||||
11
Sources/asc-client/ASCClient.swift
Normal file
11
Sources/asc-client/ASCClient.swift
Normal file
@@ -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]
|
||||||
|
)
|
||||||
|
}
|
||||||
24
Sources/asc-client/ClientFactory.swift
Normal file
24
Sources/asc-client/ClientFactory.swift
Normal file
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
789
Sources/asc-client/Commands/AppsCommand.swift
Normal file
789
Sources/asc-client/Commands/AppsCommand.swift
Normal file
@@ -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: <bundle-id>-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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
Sources/asc-client/Commands/BuildsCommand.swift
Normal file
54
Sources/asc-client/Commands/BuildsCommand.swift
Normal file
@@ -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] }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
Sources/asc-client/Commands/ConfigureCommand.swift
Normal file
76
Sources/asc-client/Commands/ConfigureCommand.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
40
Sources/asc-client/Config.swift
Normal file
40
Sources/asc-client/Config.swift
Normal file
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
Sources/asc-client/Formatting.swift
Normal file
74
Sources/asc-client/Formatting.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1220
Sources/asc-client/MediaUpload.swift
Normal file
1220
Sources/asc-client/MediaUpload.swift
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user