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:
Kerem Erkan
2026-02-12 16:57:41 +01:00
parent 23968eafda
commit a2a8192dd1
13 changed files with 2781 additions and 0 deletions

10
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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

View 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]
)
}

View 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
)
)
}
}

View 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."
}
}
}

View 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] }
)
}
}
}

View 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
}
}

View 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)"
}
}
}

View 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)
}
}
}

File diff suppressed because it is too large Load Diff