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

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