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:
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