Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Submit in-app purchases and subscriptions with an app version** — `asc versions submit --version-id <id> --with-products` adds every in-app purchase and subscription that is `READY_TO_SUBMIT` with a submittable version (and its subscription group's version) to the app version's review submission and submits them together — the way first-time products must go to review — using only the public API and your API key, no iris web session. `--dry-run` lists what would be submitted and submits nothing. REST: `POST /api/v1/versions/:id/submit?with-products=true&dry-run=true` (the route `submitForReview` links pointed at now exists). See `docs/features/submit-with-products.md`.
- **Product versions** — `asc iap versions list --iap-id`, `asc subscriptions versions list --subscription-id` and `asc subscription-groups versions list --group-id` show each product's review versions and state; a submittable version offers `addToSubmission`. IAPs, subscriptions and groups gain a `listVersions` affordance. REST: `GET /api/v1/{iap,subscriptions,subscription-groups}/:id/versions`.
- **Build a review submission step by step** — `asc review-submissions create --app-id [--platform]` (opens or reuses the app's draft), `items add --submission-id` with one of `--version-id`, `--iap-version-id`, `--subscription-version-id`, `--subscription-group-version-id`, `items remove --item-id`, and `submit --submission-id`. REST: `POST /api/v1/apps/:appId/review-submissions`, `POST /api/v1/review-submissions/:id/items`, `DELETE /api/v1/review-submissions/items/:itemId`, `POST /api/v1/review-submissions/:id/submit`.
- **App pricing** — `asc apps price-points list --app-id [--territory USA]` lists the prices an app can be sold at (every page, ~800 per territory; the `0.0` point makes it free) and `asc apps prices set --app-id --base-territory --price-point-id` sets the app's price, which Apple equalizes worldwide. Fixes the "App is not eligible for submission until pricing has been set" refusal from the CLI. `App` gains a `listPricePoints` affordance. REST: `GET /api/v1/apps/:appId/price-points?territory=`, `POST /api/v1/apps/:appId/prices/set`. See `docs/features/app-pricing.md`.
- **Set up app availability** — `asc app-availability create --app-id (--territory X … | --all-territories) [--available-in-new-territories]` sets where an app is sold (App Store Connect's "Set Up Availability"), in one `POST /v2/appAvailabilities`. REST: `POST /api/v1/apps/:appId/availability`, plus `GET` for the existing read.
- **`STORAGE` performance metrics** — the new category Apple reports is mapped and usable with `perf-metrics list --metric-type STORAGE`.

### Changed
- **Dependencies updated to their latest releases**, with `Package.swift` minimums raised to match: appstoreconnect-swift-sdk 4.4.3 (was 4.2.0), Hummingbird 2.27.0, swift-argument-parser 1.8.2, TauTUI 0.2.2, SweetCookieKit 0.5.3, Mockable 0.6.4 and the rest. The `hello-plugin` example pins Hummingbird 2.27.0 to match the host.
- **Refused submissions explain why** — when Apple refuses to add an item to, or submit, a review submission, the error lists the specific reasons from Apple's `associatedErrors` (missing device screenshots, content rights declaration, App Privacy answers, pricing) instead of only "please check associated errors".

### Fixed
- **`app-availability get` on an app that was never set up** — printed a raw 404; it now returns `{"data":[]}` and a hint with the `create` command (`getAppAvailability` returns `nil`).
- **`review-submissions items list` shows what each item points at** — items never asked Apple for their relationships (`include=`), so every item showed no linked type or id, even app versions. They now show `APP_STORE_VERSION`, the new product version types and the others, with the `getVersion` affordance for app versions.

---
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Now `asc iap list --app-id <id>` enriches each IAP with the right submission aff

| Category | What you can do |
| --- | --- |
| **Apps & Versions** | List apps, create versions, link builds, submit for App Store review — with first-time IAPs and subscriptions in the same submission (`--with-products`) |
| **Apps & Versions** | List apps, set the app's price (or make it free), create versions, link builds, submit for App Store review — with first-time IAPs and subscriptions in the same submission (`--with-products`) |
| **Builds** | Archive Xcode projects, export IPA/PKG, upload to App Store Connect, distribute to TestFlight, update beta notes |
| **Metadata** | Update What's New, description, and keywords per locale |
| **App Info** | Set per-locale name, subtitle, privacy policy; manage categories and age rating |
Expand Down Expand Up @@ -163,6 +163,8 @@ asc versions list --app-id <id>
asc versions create --app-id <id> --version <v> --platform ios
asc versions set-build --version-id <id> --build-id <id>
asc versions check-readiness --version-id <id>
asc apps price-points list --app-id <id> [--territory USA]
asc apps prices set --app-id <id> --base-territory USA --price-point-id <id> # 0.0 point = free
asc versions submit --version-id <id> [--with-products] [--dry-run]
asc version-review-detail get --version-id <id>
asc version-review-detail update --version-id <id> --contact-first-name Jane --contact-email dev@example.com
Expand Down
2 changes: 1 addition & 1 deletion Sources/ASCCommand/Commands/Apps/AppsCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ struct AppsCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "apps",
abstract: "Manage apps",
subcommands: [AppsList.self, AppsUpdate.self]
subcommands: [AppsList.self, AppsUpdate.self, AppsPricePointsCommand.self, AppsPricesCommand.self]
)
}
9 changes: 9 additions & 0 deletions Sources/ASCCommand/Commands/Apps/AppsPricePointsCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import ArgumentParser

struct AppsPricePointsCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "price-points",
abstract: "List the prices an app can be sold at in a territory",
subcommands: [AppsPricePointsList.self]
)
}
28 changes: 28 additions & 0 deletions Sources/ASCCommand/Commands/Apps/AppsPricePointsList.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import ArgumentParser
import Domain

struct AppsPricePointsList: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "list",
abstract: "List the prices an app can be sold at in a territory (the 0 price makes it free)"
)

@OptionGroup var globals: GlobalOptions

@Option(name: .long, help: "App ID")
var appId: String

@Option(name: .long, help: "Territory code (default: USA)")
var territory: String = "USA"

func run() async throws {
let repo = try ClientProvider.makePricingRepository()
print(try await execute(repo: repo))
}

func execute(repo: any PricingRepository, affordanceMode: AffordanceMode = .cli) async throws -> String {
let points = try await repo.listPricePoints(appId: appId, territory: territory)
let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty)
return try formatter.formatAgentItems(points, affordanceMode: affordanceMode)
}
}
9 changes: 9 additions & 0 deletions Sources/ASCCommand/Commands/Apps/AppsPricesCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import ArgumentParser

struct AppsPricesCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "prices",
abstract: "Set an app's price",
subcommands: [AppsPricesSet.self]
)
}
31 changes: 31 additions & 0 deletions Sources/ASCCommand/Commands/Apps/AppsPricesSet.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import ArgumentParser
import Domain

struct AppsPricesSet: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "set",
abstract: "Set an app's price from a base-territory price point; Apple equalizes the other territories"
)

@OptionGroup var globals: GlobalOptions

@Option(name: .long, help: "App ID")
var appId: String

@Option(name: .long, help: "Base territory code, e.g. USA")
var baseTerritory: String

@Option(name: .long, help: "Price point ID in the base territory (from `asc apps price-points list`)")
var pricePointId: String

func run() async throws {
let repo = try ClientProvider.makePricingRepository()
print(try await execute(repo: repo))
}

func execute(repo: any PricingRepository, affordanceMode: AffordanceMode = .cli) async throws -> String {
let schedule = try await repo.setPriceSchedule(appId: appId, baseTerritory: baseTerritory, pricePointId: pricePointId)
let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty)
return try formatter.formatAgentItems([schedule], affordanceMode: affordanceMode)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ struct AppAvailabilityCommand: AsyncParsableCommand {
abstract: "Manage app territory availability",
subcommands: [
AppAvailabilityGet.self,
AppAvailabilityCreate.self,
],
defaultSubcommand: AppAvailabilityGet.self
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import ArgumentParser
import Domain

struct AppAvailabilityCreate: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "create",
abstract: "Set up where an app is available (App Store Connect's \"Set Up Availability\")"
)

@OptionGroup var globals: GlobalOptions

@Option(name: .long, help: "App ID")
var appId: String

@Option(name: .long, help: "Territory to make the app available in (e.g. USA). Repeat for several.")
var territory: [String] = []

@Flag(name: .long, help: "Make the app available in every territory Apple sells in (`asc territories list`)")
var allTerritories: Bool = false

@Flag(name: .long, help: "Automatically make the app available in territories Apple adds later")
var availableInNewTerritories: Bool = false

func run() async throws {
print(try await execute(
repo: try ClientProvider.makeAppAvailabilityRepository(),
territoryRepo: try ClientProvider.makeTerritoryRepository()
))
}

func execute(
repo: any AppAvailabilityRepository,
territoryRepo: any TerritoryRepository,
affordanceMode: AffordanceMode = .cli
) async throws -> String {
guard allTerritories != !territory.isEmpty else {
throw ValidationError("Pass either --territory (one or more) or --all-territories")
}
let territoryIds = allTerritories ? try await territoryRepo.listTerritories().map(\.id) : territory
let availability = try await repo.createAppAvailability(
appId: appId, isAvailableInNewTerritories: availableInNewTerritories, territoryIds: territoryIds
)
let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty)
return try formatter.formatAgentItems([availability], affordanceMode: affordanceMode)
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Foundation
import ArgumentParser
import Domain

Expand All @@ -14,20 +15,20 @@ struct AppAvailabilityGet: AsyncParsableCommand {

func run() async throws {
let repo = try ClientProvider.makeAppAvailabilityRepository()
print(try await execute(repo: repo))
let output = try await execute(repo: repo)
print(output)
if output.contains("\"data\" : [\n\n ]") || output.contains("\"data\":[]") {
FileHandle.standardError.write(Data((
"App availability isn't set up. Set it up with: "
+ "asc app-availability create --app-id \(appId) --all-territories --available-in-new-territories\n"
).utf8))
}
}

func execute(repo: any AppAvailabilityRepository) async throws -> String {
func execute(repo: any AppAvailabilityRepository, affordanceMode: AffordanceMode = .cli) async throws -> String {
let availability = try await repo.getAppAvailability(appId: appId)
let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty)
return try formatter.formatAgentItems(
[availability],
headers: ["ID", "App ID", "Available in New Territories", "Territories"],
rowMapper: {
let available = $0.territories.filter(\.isAvailable).count
let total = $0.territories.count
return [$0.id, $0.appId, String($0.isAvailableInNewTerritories), "\(available)/\(total) available"]
}
)
// nil → empty data array: availability was never set up (mirrors `iap-availability get`).
return try formatter.formatAgentItems(availability.map { [$0] } ?? [], affordanceMode: affordanceMode)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Foundation
import Domain
import Hummingbird
import HummingbirdWebSocket
import Infrastructure

/// `GET /apps/:appId/availability` — the app's availability (`data: []` when never set up).
/// `POST /apps/:appId/availability` — set it up. Body keys match the CLI flags:
/// `{"territory": ["USA", "JPN"]}` or `{"all-territories": true}`, plus optional
/// `"available-in-new-territories": true`.
struct AppAvailabilityController: Sendable {
let repo: any AppAvailabilityRepository
let territoryRepo: any TerritoryRepository

func addRoutes(to group: RouterGroup<BasicWebSocketRequestContext>) {
group.get("/apps/:appId/availability") { _, context -> Response in
guard let appId = context.parameters.get("appId") else { return jsonError("Missing appId") }
let availability = try await self.repo.getAppAvailability(appId: appId)
return try restFormat(availability.map { [$0] } ?? [])
}

group.post("/apps/:appId/availability") { request, context -> Response in
guard let appId = context.parameters.get("appId") else { return jsonError("Missing appId") }
let body = try await request.body.collect(upTo: 64 * 1024)
let json = (try? JSONSerialization.jsonObject(with: body) as? [String: Any]) ?? [:]
let territories = json["territory"] as? [String] ?? []
let allTerritories = json["all-territories"] as? Bool ?? false
guard allTerritories != !territories.isEmpty else {
return jsonError("Body needs either territory: [...] or all-territories: true", status: .badRequest)
}
let territoryIds = allTerritories ? try await self.territoryRepo.listTerritories().map(\.id) : territories
let availability = try await self.repo.createAppAvailability(
appId: appId,
isAvailableInNewTerritories: json["available-in-new-territories"] as? Bool ?? false,
territoryIds: territoryIds
)
return try restFormat([availability])
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Foundation
import Domain
import Hummingbird
import HummingbirdWebSocket
import Infrastructure

/// App pricing (mirrors `IAPPricePointsController` / `IAPPricesController`):
/// `GET /apps/:appId/price-points?territory=USA` and `POST /apps/:appId/prices/set`
/// with body `{"base-territory": "USA", "price-point-id": "…"}` (camelCase keys also accepted).
struct AppPricingController: Sendable {
let repo: any PricingRepository

func addRoutes(to group: RouterGroup<BasicWebSocketRequestContext>) {
group.get("/apps/:appId/price-points") { request, context -> Response in
guard let appId = context.parameters.get("appId") else { return jsonError("Missing appId") }
let territory = request.uri.queryParameters.get("territory").map { String($0) } ?? "USA"
return try restFormat(try await self.repo.listPricePoints(appId: appId, territory: territory))
}

group.post("/apps/:appId/prices/set") { request, context -> Response in
guard let appId = context.parameters.get("appId") else { return jsonError("Missing appId") }
let body = try await request.body.collect(upTo: 64 * 1024)
let json = (try? JSONSerialization.jsonObject(with: body) as? [String: Any]) ?? [:]
guard let baseTerritory = json["base-territory"] as? String ?? json["baseTerritory"] as? String else {
return jsonError("Missing base-territory", status: .badRequest)
}
guard let pricePointId = json["price-point-id"] as? String ?? json["pricePointId"] as? String else {
return jsonError("Missing price-point-id", status: .badRequest)
}
let schedule = try await self.repo.setPriceSchedule(
appId: appId, baseTerritory: baseTerritory, pricePointId: pricePointId
)
return try restFormat([schedule])
}
}
}
7 changes: 7 additions & 0 deletions Sources/ASCCommand/Commands/Web/RESTRoutes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ enum RESTRoutes {
if let promotedRepo = try? factory.makePromotedPurchaseRepository(authProvider: auth) {
PromotedPurchasesController(repo: promotedRepo).addRoutes(to: v1)
}
if let availabilityRepo = try? factory.makeAppAvailabilityRepository(authProvider: auth),
let territoryRepo = try? factory.makeTerritoryRepository(authProvider: auth) {
AppAvailabilityController(repo: availabilityRepo, territoryRepo: territoryRepo).addRoutes(to: v1)
}
if let pricingRepo = try? factory.makePricingRepository(authProvider: auth) {
AppPricingController(repo: pricingRepo).addRoutes(to: v1)
}
if let productVersionRepo = try? factory.makeProductVersionRepository(authProvider: auth) {
ProductVersionsController(repo: productVersionRepo).addRoutes(to: v1)
if let submissionRepo = try? factory.makeSubmissionRepository(authProvider: auth),
Expand Down
2 changes: 2 additions & 0 deletions Sources/Domain/Apps/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ extension App: AffordanceProviding {
Affordance(key: "listAppInfos", command: "app-infos", action: "list", params: ["app-id": id]),
Affordance(key: "listReviews", command: "reviews", action: "list", params: ["app-id": id]),
Affordance(key: "listExperiments", command: "experiments", action: "list", params: ["app-id": id]),
Affordance(key: "listPricePoints", command: "apps price-points", action: "list",
params: ["app-id": id, "territory": "USA"]),
Affordance(key: "updateContentRights", command: "apps", action: "update", params: ["app-id": id]),
]
}
Expand Down
19 changes: 16 additions & 3 deletions Sources/Domain/Apps/Availability/AppAvailability.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,23 @@ public struct AppAvailability: Sendable, Equatable, Identifiable, Codable {
}

extension AppAvailability: AffordanceProviding {
public var affordances: [String: String] {
public var structuredAffordances: [Affordance] {
[
"getAvailability": "asc app-availability get --app-id \(appId)",
"listTerritories": "asc territories list",
Affordance(key: "getAvailability", command: "app-availability", action: "get", params: ["app-id": appId]),
Affordance(key: "listTerritories", command: "territories", action: "list"),
]
}
}

extension AppAvailability: Presentable {
public static var tableHeaders: [String] { ["ID", "App ID", "Available in New Territories", "Territories"] }
public var tableRow: [String] {
[id, appId, String(isAvailableInNewTerritories), "\(territories.filter(\.isAvailable).count)/\(territories.count) available"]
}
}

extension RESTPathResolver {
static let _appAvailabilityRoutes: Void = {
registerRoute(command: "app-availability", parentParam: "app-id", parentSegment: "apps", segment: "availability")
}()
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,12 @@ import Mockable

@Mockable
public protocol AppAvailabilityRepository: Sendable {
func getAppAvailability(appId: String) async throws -> AppAvailability
/// `nil` when the app's availability hasn't been set up yet.
func getAppAvailability(appId: String) async throws -> AppAvailability?
/// Sets up the app's availability: available in `territoryIds`.
func createAppAvailability(
appId: String,
isAvailableInNewTerritories: Bool,
territoryIds: [String]
) async throws -> AppAvailability
}
Loading
Loading