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