development
rs 2,840 lines 100 KB
Raw
1 use anyhow::{Context, Result};
2 use clap::{Parser, Subcommand, ValueEnum};
3 use smbcloud_ascapi_aso::app::AppUpdateAttributes;
4 use smbcloud_ascapi_aso::app_info_localization::{
5 AppInfoLocalizationCreateAttributes, AppInfoLocalizationUpdateAttributes,
6 };
7 use smbcloud_ascapi_aso::app_screenshot_set::{
8 AppScreenshotSetCreateAttributes, ScreenshotDisplayType,
9 };
10 use smbcloud_ascapi_aso::app_store_version::{
11 AppStoreVersionCreateAttributes, AppStoreVersionUpdateFields, Platform,
12 };
13 use smbcloud_ascapi_aso::app_store_version_localization::{
14 AppStoreVersionLocalizationAttributes, AppStoreVersionLocalizationCreateAttributes,
15 AppStoreVersionLocalizationFields,
16 };
17 use smbcloud_ascapi_aso::beta_app_review_detail::BetaAppReviewDetailUpdate;
18 use smbcloud_ascapi_aso::build::{BuildFilter, BuildUpdateAttributes};
19 use smbcloud_ascapi_aso::bundle_id::{BundleIdCreateAttributes, BundleIdPlatform};
20 use smbcloud_ascapi_aso::prelude::*;
21 use smbcloud_ascapi_core::{ApiKey, Client};
22 use smbcloud_ascapi_frontend::attach_build::attach_build;
23 use smbcloud_ascapi_frontend::await_processing::{await_processing, AwaitConfig};
24 use smbcloud_ascapi_frontend::review;
25 use smbcloud_ascapi_frontend::upload::{self, upload_package, validate_app, AltoolAuth, AltoolOp};
26 use smbcloud_ascapi_play::{ImageType as PlayImageType, ListingFields as PlayListingFields};
27 use smbcloud_ascapi_pricing::app_price::PriceKind;
28 use smbcloud_ascapi_pricing::prelude::*;
29 use smbcloud_ascapi_reports::prelude::*;
30 use smbcloud_ascapi_reports::SalesReportRequest;
31 use smbcloud_ascapi_signing::certificate::{CertificateCreateAttributes, CertificateType};
32 use smbcloud_ascapi_signing::csr::generate_certificate_request;
33 use smbcloud_ascapi_signing::prelude::*;
34 use smbcloud_ascapi_signing::profile::{
35 ProfileCreateAttributes, ProfileCreateRelationships, ProfileType,
36 };
37 use std::collections::HashSet;
38 use std::path::PathBuf;
39 use std::time::Duration;
40
41 /// Add/update App Store Connect app metadata (apps, app infos, app store
42 /// versions, and their localizations) from the terminal or a script.
43 #[derive(Parser)]
44 #[command(name = "asc", version, about)]
45 struct Cli {
46 /// App Store Connect API key ID (Users and Access → Integrations → App
47 /// Store Connect API). Not required with `--mcp`, which resolves
48 /// credentials per tool call so an unconfigured server can still list
49 /// its tools.
50 #[arg(long, env = "ASC_API_KEY")]
51 key_id: Option<String>,
52
53 /// App Store Connect API issuer ID (same page as the key).
54 #[arg(long, env = "ASC_ISSUER_ID")]
55 issuer_id: Option<String>,
56
57 /// Run as an MCP server over stdio instead of executing a subcommand.
58 ///
59 /// stdout carries the JSON-RPC stream in this mode, so nothing else
60 /// may be written to it.
61 #[arg(long)]
62 mcp: bool,
63
64 /// Path to the key's .p8 private key file. Defaults to
65 /// `~/.appstoreconnect/private_keys/AuthKey_<key-id>.p8`, matching
66 /// Xcode's own convention.
67 #[arg(long, env = "ASC_PRIVATE_KEY_PATH", global = true)]
68 private_key_path: Option<PathBuf>,
69
70 /// Print the request body that would be sent instead of sending it.
71 /// Only affects commands that create or modify data.
72 #[arg(long, global = true)]
73 dry_run: bool,
74
75 #[command(subcommand)]
76 command: Option<Command>,
77 }
78
79 #[derive(Subcommand)]
80 enum PlayCommand {
81 /// Localized store listing text (title, short and full description).
82 Listings {
83 #[command(subcommand)]
84 command: PlayListingsCommand,
85 },
86 /// Listing images: screenshots, feature graphic, icon.
87 Images {
88 #[command(subcommand)]
89 command: PlayImagesCommand,
90 },
91 /// Release notes on a track release.
92 Tracks {
93 #[command(subcommand)]
94 command: PlayTracksCommand,
95 },
96 }
97
98 #[derive(Subcommand)]
99 enum PlayListingsCommand {
100 /// Every localized listing of an app.
101 List { package: String },
102 /// Patch one locale's listing, then read it back. With `--dry-run` the
103 /// edit is opened, the body printed, and the edit deleted.
104 Update {
105 package: String,
106 #[arg(long)]
107 locale: String,
108 /// Title, up to 30 characters.
109 #[arg(long, conflicts_with = "title_file")]
110 title: Option<String>,
111 /// Read the title from a UTF-8 file; one trailing newline is dropped.
112 #[arg(long)]
113 title_file: Option<PathBuf>,
114 /// Short description, up to 80 characters.
115 #[arg(long, conflicts_with = "short_description_file")]
116 short_description: Option<String>,
117 /// Read the short description from a UTF-8 file.
118 #[arg(long)]
119 short_description_file: Option<PathBuf>,
120 /// Full description, up to 4000 characters.
121 #[arg(long, conflicts_with = "full_description_file")]
122 full_description: Option<String>,
123 /// Read the full description from a UTF-8 file.
124 #[arg(long)]
125 full_description_file: Option<PathBuf>,
126 },
127 }
128
129 #[derive(Clone, Copy, ValueEnum)]
130 enum CliPlayImageType {
131 PhoneScreenshots,
132 SevenInchScreenshots,
133 TenInchScreenshots,
134 FeatureGraphic,
135 Icon,
136 }
137
138 impl From<CliPlayImageType> for PlayImageType {
139 fn from(value: CliPlayImageType) -> Self {
140 match value {
141 CliPlayImageType::PhoneScreenshots => Self::PhoneScreenshots,
142 CliPlayImageType::SevenInchScreenshots => Self::SevenInchScreenshots,
143 CliPlayImageType::TenInchScreenshots => Self::TenInchScreenshots,
144 CliPlayImageType::FeatureGraphic => Self::FeatureGraphic,
145 CliPlayImageType::Icon => Self::Icon,
146 }
147 }
148 }
149
150 #[derive(Subcommand)]
151 enum PlayImagesCommand {
152 /// Images in one bucket of one locale.
153 List {
154 package: String,
155 #[arg(long)]
156 locale: String,
157 #[arg(long = "type")]
158 image_type: CliPlayImageType,
159 },
160 /// Replace a bucket with the given files, in order, in one edit: the old
161 /// set is deleted and the new one uploaded, so a failed upload keeps the
162 /// old set. Sizes are checked before anything is sent.
163 Replace {
164 package: String,
165 #[arg(long)]
166 locale: String,
167 #[arg(long = "type")]
168 image_type: CliPlayImageType,
169 /// PNG or JPEG file; repeat for each image, in display order.
170 #[arg(long = "file", required = true)]
171 files: Vec<PathBuf>,
172 },
173 }
174
175 #[derive(Subcommand)]
176 enum PlayTracksCommand {
177 /// A track and its releases, with their release notes.
178 Get {
179 package: String,
180 #[arg(long)]
181 track: String,
182 },
183 /// Set one locale's release notes (up to 500 characters) on the release
184 /// carrying a version code, then read the track back.
185 SetNotes {
186 package: String,
187 #[arg(long)]
188 track: String,
189 #[arg(long)]
190 version_code: String,
191 #[arg(long)]
192 locale: String,
193 #[arg(
194 long,
195 conflicts_with = "notes_file",
196 required_unless_present = "notes_file"
197 )]
198 notes: Option<String>,
199 /// Read the notes from a UTF-8 file; one trailing newline is dropped.
200 #[arg(long)]
201 notes_file: Option<PathBuf>,
202 },
203 }
204
205 #[derive(Subcommand)]
206 enum Command {
207 /// Google Play store listings: text, images, and release notes. Uses a
208 /// Play service account, not the App Store Connect key.
209 Play {
210 /// Path to the Play service-account JSON key. Falls back to
211 /// `PLAY_SERVICE_ACCOUNT_JSON_PATH`, then to the key contents in
212 /// `PLAY_SERVICE_ACCOUNT_JSON`.
213 #[arg(long, global = true)]
214 play_service_account: Option<PathBuf>,
215 #[command(subcommand)]
216 command: PlayCommand,
217 },
218 /// Look up apps by bundle ID, or a specific app by its ASC app ID.
219 Apps {
220 #[command(subcommand)]
221 command: AppsCommand,
222 },
223 /// Register or look up bundle identifiers.
224 BundleIds {
225 #[command(subcommand)]
226 command: BundleIdsCommand,
227 },
228 /// Per-platform App Store Version records for an app — create one to
229 /// add a new platform (e.g. visionOS) to an existing app.
230 AppStoreVersions {
231 #[command(subcommand)]
232 command: AppStoreVersionsCommand,
233 },
234 /// Send an App Store Version to App Review, see what's waiting, or
235 /// withdraw a submission that hasn't been reviewed yet.
236 ReviewSubmissions {
237 #[command(subcommand)]
238 command: ReviewSubmissionsCommand,
239 },
240 /// Localized name/subtitle for an app's AppInfo.
241 AppInfoLocalizations {
242 #[command(subcommand)]
243 command: AppInfoLocalizationsCommand,
244 },
245 /// Localized description/keywords/etc for an App Store Version.
246 AppStoreVersionLocalizations {
247 #[command(subcommand)]
248 command: AppStoreVersionLocalizationsCommand,
249 },
250 /// Per-device-class screenshot buckets on an App Store Version
251 /// Localization — create one, then add images to it with
252 /// `app-screenshots upload`.
253 AppScreenshotSets {
254 #[command(subcommand)]
255 command: AppScreenshotSetsCommand,
256 },
257 /// Screenshot image binaries within an AppScreenshotSet.
258 AppScreenshots {
259 #[command(subcommand)]
260 command: AppScreenshotsCommand,
261 },
262 /// What an app costs, per territory — the manual price a human set
263 /// and the automatic ones Apple converted it into.
264 AppPrices {
265 #[command(subcommand)]
266 command: AppPricesCommand,
267 },
268 /// Signing certificates: list what the team holds, issue a new one, or
269 /// revoke an old one.
270 Certificates {
271 #[command(subcommand)]
272 command: CertificatesCommand,
273 },
274 /// Provisioning profiles: what a bundle ID is allowed to claim, and
275 /// which certificates may sign it. Create a new one after changing an
276 /// App ID's capabilities — existing profiles never pick the change up.
277 Profiles {
278 #[command(subcommand)]
279 command: ProfilesCommand,
280 },
281 /// Download account-level App Store sales reports as JSON.
282 SalesReports {
283 #[command(subcommand)]
284 command: SalesReportsCommand,
285 },
286 /// Manage TestFlight beta testing: groups, review details, and review submissions.
287 Testflight {
288 #[command(subcommand)]
289 command: TestflightCommand,
290 },
291 /// Local signing health checks that compare this machine against App
292 /// Store Connect. macOS only — they read the login keychain.
293 #[cfg(target_os = "macos")]
294 Signing {
295 #[command(subcommand)]
296 command: SigningCommand,
297 },
298 }
299
300 async fn run_play(
301 service_account: Option<&std::path::Path>,
302 command: PlayCommand,
303 dry_run: bool,
304 ) -> Result<()> {
305 use smbcloud_ascapi_frontend::play;
306 let client = play::play_client(service_account).map_err(|e| anyhow::anyhow!(e))?;
307 let fail = |e: String| anyhow::anyhow!(e);
308 match command {
309 PlayCommand::Listings { command } => match command {
310 PlayListingsCommand::List { package } => {
311 print_json(&play::read_listings(&client, &package).await.map_err(fail)?)
312 }
313 PlayListingsCommand::Update {
314 package,
315 locale,
316 title,
317 title_file,
318 short_description,
319 short_description_file,
320 full_description,
321 full_description_file,
322 } => {
323 let fields = PlayListingFields {
324 title: text_arg(title, title_file, "--title-file")?,
325 short_description: text_arg(
326 short_description,
327 short_description_file,
328 "--short-description-file",
329 )?,
330 full_description: text_arg(
331 full_description,
332 full_description_file,
333 "--full-description-file",
334 )?,
335 };
336 let result = play::update_listing(&client, &package, &locale, fields, dry_run)
337 .await
338 .map_err(fail)?;
339 if result.dry_run {
340 println!("# dry run: nothing was committed; body that would be sent:");
341 }
342 print_json(&result)
343 }
344 },
345 PlayCommand::Images { command } => match command {
346 PlayImagesCommand::List {
347 package,
348 locale,
349 image_type,
350 } => print_json(
351 &play::read_images(&client, &package, &locale, image_type.into())
352 .await
353 .map_err(fail)?,
354 ),
355 PlayImagesCommand::Replace {
356 package,
357 locale,
358 image_type,
359 files,
360 } => {
361 let result = play::replace_images(
362 &client,
363 &package,
364 &locale,
365 image_type.into(),
366 &files,
367 dry_run,
368 )
369 .await
370 .map_err(fail)?;
371 if result.dry_run {
372 println!("# dry run: nothing was committed; images that would be uploaded:");
373 }
374 print_json(&result)
375 }
376 },
377 PlayCommand::Tracks { command } => match command {
378 PlayTracksCommand::Get { package, track } => print_json(
379 &play::read_track(&client, &package, &track)
380 .await
381 .map_err(fail)?,
382 ),
383 PlayTracksCommand::SetNotes {
384 package,
385 track,
386 version_code,
387 locale,
388 notes,
389 notes_file,
390 } => {
391 let notes = text_arg(notes, notes_file, "--notes-file")?
392 .context("--notes or --notes-file is required")?;
393 let result = play::set_track_notes(
394 &client,
395 &package,
396 &track,
397 &version_code,
398 &locale,
399 &notes,
400 dry_run,
401 )
402 .await
403 .map_err(fail)?;
404 if result.dry_run {
405 println!("# dry run: nothing was committed; track as it would be:");
406 }
407 print_json(&result)
408 }
409 },
410 }
411 }
412
413 /// macOS-only signing diagnostics.
414 #[cfg(target_os = "macos")]
415 #[derive(Subcommand)]
416 enum SigningCommand {
417 /// Cross-check the login keychain against Apple's certificates and
418 /// report which of Apple's certificates this machine can actually sign
419 /// with — i.e. which ones have their private key present locally.
420 ///
421 /// Read-only: it lists certificates and inspects the keychain, and
422 /// never writes or transmits key material.
423 Doctor,
424 }
425
426 #[derive(Subcommand)]
427 enum TestflightCommand {
428 /// Manage TestFlight beta groups.
429 Groups {
430 #[command(subcommand)]
431 command: BetaGroupsCommand,
432 },
433 /// Manage the Beta App Review contact details for an app.
434 AppReviewDetail {
435 #[command(subcommand)]
436 command: BetaAppReviewDetailCommand,
437 },
438 /// Manage Beta App Review submissions for a build.
439 AppReviewSubmissions {
440 #[command(subcommand)]
441 command: BetaAppReviewSubmissionsCommand,
442 },
443 /// Manage the per-locale "What to Test" notes testers see on a build.
444 BuildLocalizations {
445 #[command(subcommand)]
446 command: BetaBuildLocalizationsCommand,
447 },
448 }
449
450 #[derive(Subcommand)]
451 enum BetaBuildLocalizationsCommand {
452 /// `GET /v1/betaBuildLocalizations?filter[build]={build_id}` — every
453 /// locale's note for a build (find the build with `apps builds`).
454 List {
455 /// The ASC build ID.
456 build_id: String,
457 },
458 /// `POST /v1/betaBuildLocalizations` — add a locale's note to a build.
459 /// A build holds one note per locale; use `update` if the locale already
460 /// has one.
461 #[command(group(clap::ArgGroup::new("text").required(true).args(["whats_new", "whats_new_file"])))]
462 Create {
463 /// The ASC build ID.
464 build_id: String,
465 /// BCP 47 locale tag, e.g. `en-US` or `id`.
466 #[arg(long)]
467 locale: String,
468 /// The "What to Test" text (up to 4000 characters).
469 #[arg(long)]
470 whats_new: Option<String>,
471 /// Read the "What to Test" text from a UTF-8 file instead of
472 /// `--whats-new`; one trailing newline is dropped.
473 #[arg(long)]
474 whats_new_file: Option<PathBuf>,
475 },
476 /// `PATCH /v1/betaBuildLocalizations/{id}` — replace the text of an
477 /// existing note (the id comes from `list`).
478 #[command(group(clap::ArgGroup::new("text").required(true).args(["whats_new", "whats_new_file"])))]
479 Update {
480 /// The ASC beta build localization ID.
481 id: String,
482 /// The "What to Test" text (up to 4000 characters).
483 #[arg(long)]
484 whats_new: Option<String>,
485 /// Read the "What to Test" text from a UTF-8 file instead of
486 /// `--whats-new`; one trailing newline is dropped.
487 #[arg(long)]
488 whats_new_file: Option<PathBuf>,
489 },
490 }
491
492 #[derive(Subcommand)]
493 enum BetaGroupsCommand {
494 /// List all beta groups for an app.
495 List {
496 /// The ASC app ID to list groups for.
497 app_id: String,
498 },
499 /// Fetch a single beta group by its ASC ID.
500 Get {
501 /// The ASC beta group ID.
502 group_id: String,
503 },
504 /// Add one or more builds to a beta group.
505 ///
506 /// Adding a build to an external group may trigger Beta App Review if the
507 /// app has not received approval for that group yet. This command is
508 /// CLI-only for that reason.
509 AddBuild {
510 /// The ASC beta group ID.
511 group_id: String,
512 /// One or more ASC build IDs to add to the group.
513 #[arg(long = "build-id", required = true, num_args = 1..)]
514 build_ids: Vec<String>,
515 },
516 }
517
518 #[derive(Subcommand)]
519 enum BetaAppReviewDetailCommand {
520 /// Fetch the Beta App Review contact details for an app.
521 Get {
522 /// The ASC app ID.
523 app_id: String,
524 },
525 /// Update the Beta App Review contact and demo-account details for an app.
526 ///
527 /// Only supplied flags are updated; omitted fields are left unchanged.
528 Update {
529 /// The ASC app ID.
530 app_id: String,
531 /// Contact email address for Beta App Review questions.
532 #[arg(long)]
533 contact_email: Option<String>,
534 /// Contact first name.
535 #[arg(long)]
536 contact_first_name: Option<String>,
537 /// Contact last name.
538 #[arg(long)]
539 contact_last_name: Option<String>,
540 /// Contact phone number (international format recommended).
541 #[arg(long)]
542 contact_phone: Option<String>,
543 /// Demo account username (if the app requires login to review).
544 #[arg(long)]
545 demo_account_name: Option<String>,
546 /// Demo account password.
547 #[arg(long)]
548 demo_account_password: Option<String>,
549 /// Whether a demo account is required to review the app.
550 #[arg(long)]
551 demo_account_required: Option<bool>,
552 /// Free-form notes for the reviewer (max 4000 chars).
553 #[arg(long)]
554 notes: Option<String>,
555 },
556 }
557
558 #[derive(Subcommand)]
559 enum BetaAppReviewSubmissionsCommand {
560 /// List Beta App Review submissions for a build.
561 List {
562 /// The ASC build ID whose submission record to retrieve.
563 #[arg(long)]
564 build_id: String,
565 },
566 /// Submit a build to Beta App Review.
567 ///
568 /// This is irreversible — it immediately sends the build to Apple's
569 /// review queue. Use `--dry-run` to confirm which build would be submitted
570 /// before committing.
571 Submit {
572 /// The ASC build ID to submit for review.
573 #[arg(long)]
574 build_id: String,
575 },
576 }
577
578 #[derive(Subcommand)]
579 enum SalesReportsCommand {
580 /// Download, decompress, and convert one Apple sales report to JSON.
581 Download {
582 /// App Store Connect vendor number, from Payments and Financial
583 /// Reports. May also be set through ASC_VENDOR_NUMBER.
584 #[arg(long, env = "ASC_VENDOR_NUMBER")]
585 vendor_number: String,
586 /// Apple report frequency, such as DAILY, WEEKLY, MONTHLY, or YEARLY.
587 #[arg(long)]
588 frequency: String,
589 /// Report date in Apple's required format for the selected frequency.
590 #[arg(long)]
591 report_date: String,
592 /// Apple report type. Defaults to SALES for paid-app and IAP sales.
593 #[arg(long, default_value = "SALES")]
594 report_type: String,
595 /// Apple report subtype. Defaults to SUMMARY.
596 #[arg(long, default_value = "SUMMARY")]
597 report_subtype: String,
598 /// Optional Apple report format version, for report types that use one.
599 #[arg(long)]
600 version: Option<String>,
601 /// Write the JSON array to this file instead of stdout.
602 #[arg(long)]
603 output: Option<PathBuf>,
604 },
605 }
606
607 #[derive(Subcommand)]
608 enum ProfilesCommand {
609 /// List the team's profiles, newest expiry last.
610 ///
611 /// `profileContent` is deliberately not shown; use `download` for the
612 /// file itself.
613 List {
614 /// Only show profiles with this exact name. Apple allows
615 /// duplicates, so this can still match several.
616 #[arg(long)]
617 name: Option<String>,
618 /// Only show one type.
619 #[arg(long, value_enum)]
620 r#type: Option<CliProfileType>,
621 /// Add each profile's bundle ID (`bundle_id`, `bundle_identifier`).
622 /// One extra request per profile.
623 #[arg(long)]
624 with_bundle_id: bool,
625 },
626 /// Create a profile over a bundle ID and one or more certificates.
627 ///
628 /// This is the only way to pick up a capability added to the App ID
629 /// after an existing profile was made: profiles are snapshots, and
630 /// Apple keeps serving the stale one as ACTIVE until its certificate
631 /// dies. Apple does not treat an equivalent profile as a duplicate, so
632 /// repeated runs accumulate profiles rather than replacing one.
633 Create {
634 /// Label shown in the developer portal.
635 #[arg(long)]
636 name: String,
637 #[arg(long, value_enum)]
638 r#type: CliProfileType,
639 /// Resource id of the bundle ID (from `bundle-ids list`), not the
640 /// reverse-DNS identifier.
641 #[arg(long)]
642 bundle_id: String,
643 /// Certificate resource id (from `certificates list`). Repeat for
644 /// several.
645 #[arg(long = "certificate", required = true)]
646 certificates: Vec<String>,
647 /// Device resource id. Only meaningful for development and ad hoc
648 /// types; ignored for store profiles, which Apple rejects if sent
649 /// devices.
650 #[arg(long = "device")]
651 devices: Vec<String>,
652 },
653 /// Write a profile's bytes to a file.
654 ///
655 /// The output is the binary `.provisionprofile`/`.mobileprovision` a
656 /// bundle embeds, not JSON.
657 Download {
658 id: String,
659 /// Where to write the profile.
660 #[arg(long)]
661 output: PathBuf,
662 },
663 /// Delete a profile.
664 ///
665 /// Narrower than revoking a certificate: builds already signed with it
666 /// keep working, but new signing against it fails.
667 Delete {
668 id: String,
669 /// Required. Pass the profile id again to confirm.
670 #[arg(long)]
671 confirm: String,
672 },
673 }
674
675 #[derive(Copy, Clone, Debug, ValueEnum)]
676 enum CliProfileType {
677 /// iOS development — device-scoped.
678 IosAppDevelopment,
679 /// iOS App Store distribution.
680 IosAppStore,
681 /// iOS ad hoc — device-scoped.
682 IosAppAdhoc,
683 /// iOS in-house (Enterprise) distribution.
684 IosAppInhouse,
685 /// macOS development — device-scoped.
686 MacAppDevelopment,
687 /// Mac App Store distribution — the embedded.provisionprofile a MAS
688 /// submission carries.
689 MacAppStore,
690 /// Developer ID — distribution outside the App Store.
691 MacAppDirect,
692 /// tvOS development — device-scoped.
693 TvosAppDevelopment,
694 /// tvOS App Store distribution.
695 TvosAppStore,
696 /// tvOS ad hoc — device-scoped.
697 TvosAppAdhoc,
698 /// tvOS in-house (Enterprise) distribution.
699 TvosAppInhouse,
700 /// Mac Catalyst development — device-scoped.
701 MacCatalystAppDevelopment,
702 /// Mac Catalyst App Store distribution.
703 MacCatalystAppStore,
704 /// Mac Catalyst Developer ID distribution.
705 MacCatalystAppDirect,
706 }
707
708 impl From<CliProfileType> for ProfileType {
709 fn from(value: CliProfileType) -> Self {
710 match value {
711 CliProfileType::IosAppDevelopment => ProfileType::IosAppDevelopment,
712 CliProfileType::IosAppStore => ProfileType::IosAppStore,
713 CliProfileType::IosAppAdhoc => ProfileType::IosAppAdHoc,
714 CliProfileType::IosAppInhouse => ProfileType::IosAppInHouse,
715 CliProfileType::MacAppDevelopment => ProfileType::MacAppDevelopment,
716 CliProfileType::MacAppStore => ProfileType::MacAppStore,
717 CliProfileType::MacAppDirect => ProfileType::MacAppDirect,
718 CliProfileType::TvosAppDevelopment => ProfileType::TvOsAppDevelopment,
719 CliProfileType::TvosAppStore => ProfileType::TvOsAppStore,
720 CliProfileType::TvosAppAdhoc => ProfileType::TvOsAppAdHoc,
721 CliProfileType::TvosAppInhouse => ProfileType::TvOsAppInHouse,
722 CliProfileType::MacCatalystAppDevelopment => ProfileType::MacCatalystAppDevelopment,
723 CliProfileType::MacCatalystAppStore => ProfileType::MacCatalystAppStore,
724 CliProfileType::MacCatalystAppDirect => ProfileType::MacCatalystAppDirect,
725 }
726 }
727 }
728
729 #[derive(Subcommand)]
730 enum CertificatesCommand {
731 /// List the team's certificates, newest expiry last. Apple returns
732 /// expired ones too; `expired` in the output says which.
733 List {
734 /// Only show one type.
735 #[arg(long, value_enum)]
736 r#type: Option<CliCertificateType>,
737 },
738 /// Generate an RSA 2048 key pair, ask Apple to certify it, and write
739 /// both halves to disk.
740 ///
741 /// The private key is written first and is the part that matters:
742 /// Apple keeps no copy, so losing it makes the certificate permanently
743 /// useless. Back up the whole output directory somewhere encrypted.
744 Create {
745 #[arg(long, value_enum)]
746 r#type: CliCertificateType,
747 /// Where to write `<name>.key.pem` and `<name>.cer`.
748 #[arg(long, default_value = ".")]
749 out_dir: PathBuf,
750 /// Subject common name for the CSR. Apple replaces it on the
751 /// issued certificate, so this only labels the request itself.
752 #[arg(long, default_value = "smbcloud-ascapi")]
753 common_name: String,
754 },
755 /// Revoke a certificate.
756 ///
757 /// Irreversible, and it breaks every provisioning profile embedding
758 /// this certificate for the whole team at once. Use it to free a slot
759 /// at Apple's per-type cap, or when a private key is compromised.
760 Revoke {
761 id: String,
762 /// Required. Pass the certificate id again to confirm.
763 #[arg(long)]
764 confirm: String,
765 },
766 }
767
768 #[derive(Copy, Clone, PartialEq, Eq, ValueEnum)]
769 enum CliCertificateType {
770 /// Apple Development — all platforms.
771 Development,
772 /// Apple Distribution — App Store and Ad Hoc, all platforms.
773 Distribution,
774 /// 3rd Party Mac Developer Application — the .app in a Mac App Store build.
775 MacAppDistribution,
776 /// 3rd Party Mac Developer Installer — the .pkg `productbuild --sign` needs.
777 MacInstallerDistribution,
778 /// Developer ID Application — distribution outside the App Store.
779 DeveloperIdApplication,
780 }
781
782 impl From<CliCertificateType> for CertificateType {
783 fn from(value: CliCertificateType) -> Self {
784 match value {
785 CliCertificateType::Development => CertificateType::Development,
786 CliCertificateType::Distribution => CertificateType::Distribution,
787 CliCertificateType::MacAppDistribution => CertificateType::MacAppDistribution,
788 CliCertificateType::MacInstallerDistribution => {
789 CertificateType::MacInstallerDistribution
790 }
791 CliCertificateType::DeveloperIdApplication => CertificateType::DeveloperIdApplication,
792 }
793 }
794 }
795
796 #[derive(Subcommand)]
797 enum AppPricesCommand {
798 /// `GET /v1/appPriceSchedules/{app_id}` — the base territory Apple
799 /// converts every other storefront from.
800 Schedule { app_id: String },
801 /// `GET /v1/appPriceSchedules/{app_id}/manualPrices`, joined against
802 /// the price points and territories so the money is actually visible.
803 ///
804 /// Defaults to the manual prices — what a human set, usually one row
805 /// in the base territory. `--automatic` instead lists what Apple
806 /// converted that into for all ~178 storefronts, which is where a
807 /// scheduled conversion shows up as a near-future `endDate`.
808 List {
809 app_id: String,
810 /// List Apple's converted per-storefront prices rather than the
811 /// manually set ones.
812 #[arg(long)]
813 automatic: bool,
814 /// Narrow to one territory code, e.g. IDN. Applied by Apple as
815 /// `filter[territory]`, so it is much cheaper than fetching every
816 /// storefront.
817 #[arg(long)]
818 territory: Option<String>,
819 },
820 }
821
822 #[derive(Subcommand)]
823 enum AppsCommand {
824 /// `GET /v1/apps`.
825 List {
826 #[arg(long)]
827 bundle_id: Option<String>,
828 },
829 /// `GET /v1/apps/{id}`.
830 Get { app_id: String },
831 /// `PATCH /v1/apps/{id}`.
832 Update {
833 app_id: String,
834 #[arg(long)]
835 primary_locale: Option<String>,
836 },
837 /// `GET /v1/apps/{id}/appInfos`.
838 Infos { app_id: String },
839 /// `GET /v1/builds?filter[app]={id}` — newest first, so the
840 /// most recently uploaded build (e.g. after a fixplist re-upload) is
841 /// first. Check `processingState` (VALID/INVALID/PROCESSING/FAILED).
842 Builds {
843 app_id: String,
844 /// `filter[version]` — the build (upload) number, e.g. `42`.
845 #[arg(long)]
846 version: Option<String>,
847 /// `filter[preReleaseVersion.version]` — the marketing version the
848 /// build sits under, e.g. `1.1.0`.
849 #[arg(long)]
850 pre_release_version: Option<String>,
851 /// `filter[processingState]` — PROCESSING, FAILED, INVALID, or VALID.
852 #[arg(long)]
853 processing_state: Option<String>,
854 },
855 /// `GET /v1/builds/{id}` — a single build by its App Store Connect id.
856 Build { id: String },
857 /// `PATCH /v1/builds/{id}` `usesNonExemptEncryption` — answer export
858 /// compliance for a build that shows "Missing Compliance" (it was uploaded
859 /// without `ITSAppUsesNonExemptEncryption` in every bundle's Info.plist).
860 /// App Review rejects a version whose build has no answer.
861 SetEncryption {
862 /// The build's App Store Connect id (from `apps builds`), not its build number.
863 build_id: String,
864 /// `false`: no encryption beyond what Apple's OS provides (HTTPS via
865 /// URLSession or Security.framework counts), i.e. "None of the
866 /// algorithms mentioned above". `true`: non-exempt encryption.
867 #[arg(long, action = clap::ArgAction::Set)]
868 uses_non_exempt_encryption: bool,
869 },
870 /// `GET /v1/preReleaseVersions?filter[app]={id}` — the marketing version strings
871 /// (e.g. `1.1.0`) TestFlight groups builds under.
872 PreReleaseVersions {
873 app_id: String,
874 /// Restrict to one platform.
875 #[arg(long, value_enum)]
876 platform: Option<CliPlatform>,
877 },
878 /// Pre-flight an exported `.ipa`/`.pkg` with `xcrun altool --validate-app`:
879 /// run App Store Connect's checks without delivering the binary. Catches
880 /// most of what a failed upload would, in seconds. Run this before `upload`.
881 Validate {
882 /// The exported package (`xcodebuild -exportArchive` output).
883 package: PathBuf,
884 },
885 /// Upload an exported `.ipa`/`.pkg` with `xcrun altool --upload-package`,
886 /// authenticated with the same key and `.p8` as every other command.
887 /// Afterwards, find the build with `apps builds <APP_ID> --version <N>`.
888 Upload {
889 /// The exported package (`xcodebuild -exportArchive` output).
890 package: PathBuf,
891 /// Block until App Store Connect finishes processing the build.
892 #[arg(long)]
893 wait: bool,
894 },
895 /// Poll `GET /v1/builds?filter[app]={id}` until the newest matching build
896 /// finishes processing (VALID) or fails (INVALID/FAILED), or the timeout
897 /// elapses. This is the signal that confirms an upload or a
898 /// binary-rejection fix worked. Narrow with `--version` /
899 /// `--pre-release-version` when more than one upload is in flight.
900 AwaitProcessing {
901 app_id: String,
902 /// `filter[version]` — the build (upload) number, e.g. `42`.
903 #[arg(long)]
904 version: Option<String>,
905 /// `filter[preReleaseVersion.version]` — the marketing version the
906 /// build sits under, e.g. `1.1.0`.
907 #[arg(long)]
908 pre_release_version: Option<String>,
909 /// Seconds between polls.
910 #[arg(long, default_value_t = 30)]
911 interval: u64,
912 /// Give up after this many seconds without a terminal state.
913 #[arg(long, default_value_t = 30 * 60)]
914 timeout: u64,
915 },
916 }
917
918 #[derive(Subcommand)]
919 enum BundleIdsCommand {
920 /// `GET /v1/bundleIds`.
921 List {
922 #[arg(long)]
923 identifier: Option<String>,
924 },
925 /// `POST /v1/bundleIds`.
926 Create {
927 #[arg(long)]
928 identifier: String,
929 #[arg(long)]
930 name: String,
931 #[arg(long, value_enum, default_value_t = CliBundleIdPlatform::Ios)]
932 platform: CliBundleIdPlatform,
933 },
934 }
935
936 #[derive(Subcommand)]
937 enum AppStoreVersionsCommand {
938 /// `GET /v1/apps/{app_id}/appStoreVersions`.
939 List {
940 app_id: String,
941 #[arg(long, value_enum)]
942 platform: Option<CliPlatform>,
943 },
944 /// `GET /v1/appStoreVersions/{id}`.
945 Get { id: String },
946 /// `POST /v1/appStoreVersions`.
947 Create {
948 app_id: String,
949 #[arg(long, value_enum)]
950 platform: CliPlatform,
951 #[arg(long)]
952 version_string: String,
953 #[arg(long)]
954 copyright: Option<String>,
955 },
956 /// `PATCH /v1/appStoreVersions/{id}` — edit a version's own attributes,
957 /// e.g. rename the platform's editable version (`--version-string
958 /// 1.1.0`). The version must be in an editable state. Apple has no
959 /// separate rename action and such a version can't be deleted once a
960 /// build exists, so this is the way to bump its number.
961 Update {
962 id: String,
963 #[arg(long)]
964 version_string: Option<String>,
965 #[arg(long)]
966 copyright: Option<String>,
967 },
968 /// `DELETE /v1/appStoreVersions/{id}`.
969 Delete { id: String },
970 /// `PATCH /v1/appStoreVersions/{id}` — attach a `Build` (see
971 /// `apps builds`) to this version, e.g. after fixing an
972 /// `INVALID_BINARY` version with a re-uploaded build.
973 SetBuild {
974 id: String,
975 #[arg(long)]
976 build_id: String,
977 /// Skip the read-back check that the version now reports this build.
978 #[arg(long)]
979 no_verify: bool,
980 },
981 /// `GET /v1/appStoreVersions/{id}/build` — the build attached to this
982 /// version, or `null` when there is none. Read-only; run it before
983 /// `review-submissions submit`.
984 Build { id: String },
985 }
986
987 #[derive(Subcommand)]
988 enum ReviewSubmissionsCommand {
989 /// `GET /v1/reviewSubmissions?filter[app]={app_id}`.
990 List {
991 app_id: String,
992 #[arg(long, value_enum)]
993 platform: Option<CliPlatform>,
994 /// Only submissions in this state, e.g. READY_FOR_REVIEW,
995 /// WAITING_FOR_REVIEW, IN_REVIEW, UNRESOLVED_ISSUES, COMPLETE.
996 #[arg(long)]
997 state: Option<String>,
998 },
999 /// Submit an App Store Version for review: reuse the platform's open
1000 /// submission or create one, add the version to it, then submit.
1001 /// `--dry-run` reads everything and prints the plan without writing.
1002 Submit {
1003 app_id: String,
1004 /// The App Store Version to submit (see `app-store-versions list`).
1005 /// It needs a build attached (`app-store-versions set-build`;
1006 /// check with `app-store-versions build`).
1007 #[arg(long)]
1008 version_id: String,
1009 },
1010 /// `PATCH /v1/reviewSubmissions/{id}` with `canceled: true` — withdraw
1011 /// a submission before App Review picks it up.
1012 Cancel { id: String },
1013 }
1014
1015 #[derive(Subcommand)]
1016 enum AppInfoLocalizationsCommand {
1017 List {
1018 app_info_id: String,
1019 },
1020 Create {
1021 app_info_id: String,
1022 #[arg(long)]
1023 locale: String,
1024 #[arg(long)]
1025 name: String,
1026 #[arg(long)]
1027 subtitle: Option<String>,
1028 },
1029 Update {
1030 id: String,
1031 #[arg(long)]
1032 name: Option<String>,
1033 #[arg(long)]
1034 subtitle: Option<String>,
1035 },
1036 Delete {
1037 id: String,
1038 },
1039 }
1040
1041 #[derive(Subcommand)]
1042 enum AppStoreVersionLocalizationsCommand {
1043 List {
1044 app_store_version_id: String,
1045 },
1046 Create {
1047 app_store_version_id: String,
1048 #[arg(long)]
1049 locale: String,
1050 #[arg(long)]
1051 description: Option<String>,
1052 /// Read `description` from a UTF-8 file; one trailing newline is dropped.
1053 #[arg(long, conflicts_with = "description")]
1054 description_file: Option<PathBuf>,
1055 #[arg(long)]
1056 keywords: Option<String>,
1057 /// Read `keywords` from a UTF-8 file; one trailing newline is dropped.
1058 #[arg(long, conflicts_with = "keywords")]
1059 keywords_file: Option<PathBuf>,
1060 #[arg(long)]
1061 whats_new: Option<String>,
1062 /// Read `whatsNew` from a UTF-8 file; one trailing newline is dropped.
1063 #[arg(long, conflicts_with = "whats_new")]
1064 whats_new_file: Option<PathBuf>,
1065 #[arg(long)]
1066 promotional_text: Option<String>,
1067 /// Read `promotionalText` from a UTF-8 file; one trailing newline is dropped.
1068 #[arg(long, conflicts_with = "promotional_text")]
1069 promotional_text_file: Option<PathBuf>,
1070 #[arg(long)]
1071 marketing_url: Option<String>,
1072 #[arg(long)]
1073 support_url: Option<String>,
1074 /// Skip the read-back check that compares the stored values with
1075 /// what was sent.
1076 #[arg(long)]
1077 no_verify: bool,
1078 },
1079 Update {
1080 id: String,
1081 #[arg(long)]
1082 description: Option<String>,
1083 /// Read `description` from a UTF-8 file; one trailing newline is dropped.
1084 #[arg(long, conflicts_with = "description")]
1085 description_file: Option<PathBuf>,
1086 #[arg(long)]
1087 keywords: Option<String>,
1088 /// Read `keywords` from a UTF-8 file; one trailing newline is dropped.
1089 #[arg(long, conflicts_with = "keywords")]
1090 keywords_file: Option<PathBuf>,
1091 #[arg(long)]
1092 whats_new: Option<String>,
1093 /// Read `whatsNew` from a UTF-8 file; one trailing newline is dropped.
1094 #[arg(long, conflicts_with = "whats_new")]
1095 whats_new_file: Option<PathBuf>,
1096 #[arg(long)]
1097 promotional_text: Option<String>,
1098 /// Read `promotionalText` from a UTF-8 file; one trailing newline is dropped.
1099 #[arg(long, conflicts_with = "promotional_text")]
1100 promotional_text_file: Option<PathBuf>,
1101 #[arg(long)]
1102 marketing_url: Option<String>,
1103 #[arg(long)]
1104 support_url: Option<String>,
1105 /// Skip the read-back check that compares the stored values with
1106 /// what was sent.
1107 #[arg(long)]
1108 no_verify: bool,
1109 },
1110 Delete {
1111 id: String,
1112 },
1113 }
1114
1115 #[derive(Subcommand)]
1116 enum AppScreenshotSetsCommand {
1117 /// `GET /v1/appStoreVersionLocalizations/{id}/appScreenshotSets`.
1118 List {
1119 app_store_version_localization_id: String,
1120 },
1121 /// `POST /v1/appScreenshotSets`.
1122 Create {
1123 app_store_version_localization_id: String,
1124 #[arg(long, value_enum)]
1125 display_type: CliScreenshotDisplayType,
1126 },
1127 /// `DELETE /v1/appScreenshotSets/{id}`.
1128 Delete { id: String },
1129 }
1130
1131 #[derive(Subcommand)]
1132 enum AppScreenshotsCommand {
1133 /// `GET /v1/appScreenshotSets/{id}/appScreenshots`.
1134 List { app_screenshot_set_id: String },
1135 /// Reserve, PUT, and commit an image file in one call — the full
1136 /// `POST /v1/appScreenshots` → upload → `PATCH …uploaded:true` flow.
1137 Upload {
1138 app_screenshot_set_id: String,
1139 #[arg(long)]
1140 file: PathBuf,
1141 },
1142 /// `DELETE /v1/appScreenshots/{id}`.
1143 Delete { id: String },
1144 }
1145
1146 #[derive(Debug, Clone, Copy, ValueEnum)]
1147 enum CliScreenshotDisplayType {
1148 AppleVisionPro,
1149 Iphone67,
1150 Iphone65,
1151 Iphone61,
1152 Iphone58,
1153 Iphone55,
1154 Iphone47,
1155 Iphone40,
1156 Iphone35,
1157 IpadPro3Gen129,
1158 IpadPro3Gen11,
1159 IpadPro129,
1160 Ipad105,
1161 Ipad97,
1162 Desktop,
1163 AppleTv,
1164 WatchUltra,
1165 WatchSeries10,
1166 WatchSeries7,
1167 WatchSeries4,
1168 WatchSeries3,
1169 }
1170
1171 impl From<CliScreenshotDisplayType> for ScreenshotDisplayType {
1172 fn from(value: CliScreenshotDisplayType) -> Self {
1173 match value {
1174 CliScreenshotDisplayType::AppleVisionPro => ScreenshotDisplayType::AppleVisionPro,
1175 CliScreenshotDisplayType::Iphone67 => ScreenshotDisplayType::Iphone67,
1176 CliScreenshotDisplayType::Iphone65 => ScreenshotDisplayType::Iphone65,
1177 CliScreenshotDisplayType::Iphone61 => ScreenshotDisplayType::Iphone61,
1178 CliScreenshotDisplayType::Iphone58 => ScreenshotDisplayType::Iphone58,
1179 CliScreenshotDisplayType::Iphone55 => ScreenshotDisplayType::Iphone55,
1180 CliScreenshotDisplayType::Iphone47 => ScreenshotDisplayType::Iphone47,
1181 CliScreenshotDisplayType::Iphone40 => ScreenshotDisplayType::Iphone40,
1182 CliScreenshotDisplayType::Iphone35 => ScreenshotDisplayType::Iphone35,
1183 CliScreenshotDisplayType::IpadPro3Gen129 => ScreenshotDisplayType::IpadPro3Gen129,
1184 CliScreenshotDisplayType::IpadPro3Gen11 => ScreenshotDisplayType::IpadPro3Gen11,
1185 CliScreenshotDisplayType::IpadPro129 => ScreenshotDisplayType::IpadPro129,
1186 CliScreenshotDisplayType::Ipad105 => ScreenshotDisplayType::Ipad105,
1187 CliScreenshotDisplayType::Ipad97 => ScreenshotDisplayType::Ipad97,
1188 CliScreenshotDisplayType::Desktop => ScreenshotDisplayType::Desktop,
1189 CliScreenshotDisplayType::AppleTv => ScreenshotDisplayType::AppleTv,
1190 CliScreenshotDisplayType::WatchUltra => ScreenshotDisplayType::WatchUltra,
1191 CliScreenshotDisplayType::WatchSeries10 => ScreenshotDisplayType::WatchSeries10,
1192 CliScreenshotDisplayType::WatchSeries7 => ScreenshotDisplayType::WatchSeries7,
1193 CliScreenshotDisplayType::WatchSeries4 => ScreenshotDisplayType::WatchSeries4,
1194 CliScreenshotDisplayType::WatchSeries3 => ScreenshotDisplayType::WatchSeries3,
1195 }
1196 }
1197 }
1198
1199 #[derive(Debug, Clone, Copy, ValueEnum)]
1200 enum CliPlatform {
1201 Ios,
1202 MacOs,
1203 TvOs,
1204 VisionOs,
1205 }
1206
1207 impl From<CliPlatform> for Platform {
1208 fn from(value: CliPlatform) -> Self {
1209 match value {
1210 CliPlatform::Ios => Platform::Ios,
1211 CliPlatform::MacOs => Platform::MacOs,
1212 CliPlatform::TvOs => Platform::TvOs,
1213 CliPlatform::VisionOs => Platform::VisionOs,
1214 }
1215 }
1216 }
1217
1218 #[derive(Debug, Clone, Copy, ValueEnum)]
1219 enum CliBundleIdPlatform {
1220 Ios,
1221 MacOs,
1222 Universal,
1223 }
1224
1225 impl From<CliBundleIdPlatform> for BundleIdPlatform {
1226 fn from(value: CliBundleIdPlatform) -> Self {
1227 match value {
1228 CliBundleIdPlatform::Ios => BundleIdPlatform::Ios,
1229 CliBundleIdPlatform::MacOs => BundleIdPlatform::MacOs,
1230 CliBundleIdPlatform::Universal => BundleIdPlatform::Universal,
1231 }
1232 }
1233 }
1234
1235 fn print_json(value: &impl serde::Serialize) -> Result<()> {
1236 println!("{}", serde_json::to_string_pretty(value)?);
1237 Ok(())
1238 }
1239
1240 /// Print `value` and return without sending anything, when `--dry-run` is
1241 /// set. Every mutating command checks this before touching the network.
1242 fn dry_run_guard(dry_run: bool, value: &impl serde::Serialize) -> Result<bool> {
1243 if dry_run {
1244 println!("# dry run — request body that would be sent:");
1245 print_json(value)?;
1246 }
1247 Ok(dry_run)
1248 }
1249
1250 /// Resolve a text option given either inline or as a file. The file is read
1251 /// as UTF-8 and one trailing newline (`\n` or `\r\n`) is dropped, the way
1252 /// `$(cat file)` does, so file-backed and inline values behave the same.
1253 fn text_arg(inline: Option<String>, file: Option<PathBuf>, flag: &str) -> Result<Option<String>> {
1254 let Some(path) = file else {
1255 return Ok(inline);
1256 };
1257 let mut text = std::fs::read_to_string(&path)
1258 .with_context(|| format!("{flag}: cannot read {} as UTF-8 text", path.display()))?;
1259 if text.ends_with('\n') {
1260 text.pop();
1261 if text.ends_with('\r') {
1262 text.pop();
1263 }
1264 }
1265 Ok(Some(text))
1266 }
1267
1268 /// Run the shared length lint: warnings go to stderr, a violation aborts
1269 /// with the field's message rewritten to name the CLI flag.
1270 fn lint_localization_fields(fields: &AppStoreVersionLocalizationFields) -> Result<()> {
1271 match fields.validate() {
1272 Ok(warnings) => {
1273 for warning in warnings {
1274 eprintln!("warning: {warning}");
1275 }
1276 Ok(())
1277 }
1278 Err(message) => {
1279 let (field, rest) = message.split_once(' ').unwrap_or((&message, ""));
1280 let mut flag = String::from("--");
1281 for c in field.chars() {
1282 if c.is_ascii_uppercase() {
1283 flag.push('-');
1284 flag.push(c.to_ascii_lowercase());
1285 } else {
1286 flag.push(c);
1287 }
1288 }
1289 anyhow::bail!("{flag} {rest}. Shorten the text and try again.")
1290 }
1291 }
1292 }
1293
1294 /// Names of the fields in `sent` whose stored value is not an exact match,
1295 /// with the index of the first differing character (a missing value counts
1296 /// as the empty string).
1297 fn diff_fields(
1298 sent: &AppStoreVersionLocalizationFields,
1299 stored: &AppStoreVersionLocalizationAttributes,
1300 ) -> Vec<&'static str> {
1301 [
1302 ("description", &sent.description, &stored.description),
1303 ("keywords", &sent.keywords, &stored.keywords),
1304 ("marketingUrl", &sent.marketing_url, &stored.marketing_url),
1305 (
1306 "promotionalText",
1307 &sent.promotional_text,
1308 &stored.promotional_text,
1309 ),
1310 ("supportUrl", &sent.support_url, &stored.support_url),
1311 ("whatsNew", &sent.whats_new, &stored.whats_new),
1312 ]
1313 .into_iter()
1314 .filter(|(_, sent, stored)| match sent {
1315 Some(sent) => stored.as_deref() != Some(sent.as_str()),
1316 None => false,
1317 })
1318 .map(|(name, _, _)| name)
1319 .collect()
1320 }
1321
1322 fn first_diff_index(a: &str, b: &str) -> usize {
1323 a.chars()
1324 .zip(b.chars())
1325 .position(|(x, y)| x != y)
1326 .unwrap_or_else(|| a.chars().count().min(b.chars().count()))
1327 }
1328
1329 /// Read the localization back and fail if any field that was sent is not
1330 /// stored byte for byte.
1331 async fn verify_localization(
1332 client: &Client,
1333 id: &str,
1334 sent: &AppStoreVersionLocalizationFields,
1335 no_verify: bool,
1336 ) -> Result<()> {
1337 if no_verify {
1338 return Ok(());
1339 }
1340 let stored = client.get_app_store_version_localization(id).await?;
1341 let mismatched = diff_fields(sent, &stored.attributes);
1342 if mismatched.is_empty() {
1343 return Ok(());
1344 }
1345 let details = mismatched
1346 .iter()
1347 .map(|name| {
1348 let (sent, stored) = match *name {
1349 "description" => (&sent.description, &stored.attributes.description),
1350 "keywords" => (&sent.keywords, &stored.attributes.keywords),
1351 "marketingUrl" => (&sent.marketing_url, &stored.attributes.marketing_url),
1352 "promotionalText" => (&sent.promotional_text, &stored.attributes.promotional_text),
1353 "supportUrl" => (&sent.support_url, &stored.attributes.support_url),
1354 _ => (&sent.whats_new, &stored.attributes.whats_new),
1355 };
1356 let at = first_diff_index(
1357 sent.as_deref().unwrap_or(""),
1358 stored.as_deref().unwrap_or(""),
1359 );
1360 format!("{name} (first difference at char {at})")
1361 })
1362 .collect::<Vec<_>>()
1363 .join(", ");
1364 anyhow::bail!("read-back mismatch on {id}: {details}")
1365 }
1366
1367 /// Validate a `--version-string` before sending it. Apple takes one to
1368 /// three period-separated integers (`1.1` and `1.1.0` are both fine) and
1369 /// answers anything else with an opaque error, so the shape is checked
1370 /// here where the message can name the offending flag. Shared by create
1371 /// and update because both write the same attribute.
1372 fn validate_version_string(version_string: &str) -> Result<()> {
1373 let parts = version_string.split('.').collect::<Vec<_>>();
1374 let well_formed = parts.len() <= 3
1375 && parts
1376 .iter()
1377 .all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()));
1378 if !well_formed {
1379 anyhow::bail!(
1380 "invalid --version-string `{version_string}`: expected one to three \
1381 period-separated numbers, e.g. `1.1.0`"
1382 );
1383 }
1384 Ok(())
1385 }
1386
1387 use smbcloud_ascapi_aso::app_store_version_localization::WHATS_NEW_MAX_LEN;
1388
1389 /// Validate a `--whats-new` text before sending it. App Store Connect caps
1390 /// the field at [`WHATS_NEW_MAX_LEN`] characters and answers with an opaque
1391 /// error otherwise, so the length is checked here where the message can
1392 /// name the offending flag. Shared by `build-localizations create` and
1393 /// `update` because both write the same attribute, and called before the
1394 /// dry-run print so `--dry-run` catches the limit too.
1395 fn validate_whats_new(whats_new: &str) -> Result<()> {
1396 let len = whats_new.chars().count();
1397 if len > WHATS_NEW_MAX_LEN {
1398 anyhow::bail!(
1399 "--whats-new is {len} characters; App Store Connect accepts at most \
1400 {WHATS_NEW_MAX_LEN}. Shorten the note and try again."
1401 );
1402 }
1403 Ok(())
1404 }
1405
1406 /// Convert Apple's gzip-compressed TSV report to JSON without normalizing
1407 /// columns: report layouts vary by report type and Apple can add columns.
1408 fn sales_report_as_json(gzip: &[u8]) -> Result<Vec<serde_json::Value>> {
1409 use flate2::read::GzDecoder;
1410 use serde_json::{Map, Value};
1411
1412 let decoder = GzDecoder::new(gzip);
1413 let mut reader = csv::ReaderBuilder::new()
1414 .delimiter(b'\t')
1415 .has_headers(true)
1416 .flexible(true)
1417 .from_reader(decoder);
1418
1419 let raw_headers = reader
1420 .headers()
1421 .context("reading the TSV header from Apple's sales report")?
1422 .iter()
1423 .map(str::to_owned)
1424 .collect::<Vec<_>>();
1425 if raw_headers.is_empty() {
1426 anyhow::bail!("Apple's sales report has no TSV headers")
1427 }
1428
1429 // Apple may add blank or repeated columns. JSON objects require unique,
1430 // non-empty keys, so retain valid headers and assign stable fallbacks to
1431 // the rest instead of discarding an otherwise usable report.
1432 let mut used_headers = HashSet::new();
1433 let headers = raw_headers
1434 .into_iter()
1435 .enumerate()
1436 .map(|(index, header)| {
1437 let base = if header.is_empty() {
1438 format!("column_{}", index + 1)
1439 } else {
1440 header
1441 };
1442 let mut name = base.clone();
1443 let mut suffix = 2;
1444 while !used_headers.insert(name.clone()) {
1445 name = format!("{base}_{suffix}");
1446 suffix += 1;
1447 }
1448 name
1449 })
1450 .collect::<Vec<_>>();
1451
1452 let mut rows = Vec::new();
1453 for (index, record) in reader.records().enumerate() {
1454 let record = record.with_context(|| format!("parsing TSV row {}", index + 2))?;
1455 let mut row_headers = headers.clone();
1456 let mut used_row_headers = row_headers.iter().cloned().collect::<HashSet<_>>();
1457 for column in row_headers.len()..record.len() {
1458 let base = format!("extra_column_{}", column + 1);
1459 let mut name = base.clone();
1460 let mut suffix = 2;
1461 while !used_row_headers.insert(name.clone()) {
1462 name = format!("{base}_{suffix}");
1463 suffix += 1;
1464 }
1465 row_headers.push(name);
1466 }
1467 let object = row_headers
1468 .iter()
1469 .zip(record.iter())
1470 .map(|(header, value)| (header.clone(), Value::String(value.to_owned())))
1471 .collect::<Map<_, _>>();
1472 rows.push(Value::Object(object));
1473 }
1474 Ok(rows)
1475 }
1476
1477 #[tokio::main]
1478 async fn main() -> Result<()> {
1479 let cli = Cli::parse();
1480
1481 if cli.mcp {
1482 return smbcloud_ascapi_mcp::serve().await;
1483 }
1484
1485 if let Some(Command::Play {
1486 play_service_account,
1487 command,
1488 }) = cli.command
1489 {
1490 return run_play(play_service_account.as_deref(), command, cli.dry_run).await;
1491 }
1492
1493 let key_id = cli
1494 .key_id
1495 .clone()
1496 .context("--key-id is required (or set ASC_API_KEY)")?;
1497 let issuer_id = cli
1498 .issuer_id
1499 .clone()
1500 .context("--issuer-id is required (or set ASC_ISSUER_ID)")?;
1501
1502 let private_key_path = cli.private_key_path.clone().unwrap_or_else(|| {
1503 let mut path = dirs_home();
1504 path.push(".appstoreconnect");
1505 path.push("private_keys");
1506 path.push(format!("AuthKey_{key_id}.p8"));
1507 path
1508 });
1509
1510 let altool_auth = AltoolAuth {
1511 key_id: key_id.clone(),
1512 issuer_id: issuer_id.clone(),
1513 private_key_path: private_key_path.clone(),
1514 };
1515 let api_key = ApiKey::from_p8_file(&key_id, &issuer_id, &private_key_path)
1516 .with_context(|| format!("loading App Store Connect API key from {private_key_path:?}"))?;
1517 let client = Client::new(api_key);
1518
1519 let Some(command) = cli.command else {
1520 anyhow::bail!("no subcommand given; run `asc --help`, or `asc --mcp` for the MCP server")
1521 };
1522
1523 match command {
1524 Command::Play { .. } => unreachable!("handled before App Store Connect credentials"),
1525 Command::Apps { command } => run_apps(&client, &altool_auth, command, cli.dry_run).await,
1526 Command::BundleIds { command } => run_bundle_ids(&client, command, cli.dry_run).await,
1527 Command::AppStoreVersions { command } => {
1528 run_app_store_versions(&client, command, cli.dry_run).await
1529 }
1530 Command::AppInfoLocalizations { command } => {
1531 run_app_info_localizations(&client, command, cli.dry_run).await
1532 }
1533 Command::AppStoreVersionLocalizations { command } => {
1534 run_app_store_version_localizations(&client, command, cli.dry_run).await
1535 }
1536 Command::AppScreenshotSets { command } => {
1537 run_app_screenshot_sets(&client, command, cli.dry_run).await
1538 }
1539 Command::AppScreenshots { command } => {
1540 run_app_screenshots(&client, command, cli.dry_run).await
1541 }
1542 Command::AppPrices { command } => run_app_prices(&client, command).await,
1543 Command::Certificates { command } => run_certificates(&client, command, cli.dry_run).await,
1544 Command::Profiles { command } => run_profiles(&client, command, cli.dry_run).await,
1545 Command::ReviewSubmissions { command } => {
1546 run_review_submissions(&client, command, cli.dry_run).await
1547 }
1548 Command::SalesReports { command } => run_sales_reports(&client, command).await,
1549 Command::Testflight { command } => run_testflight(&client, command, cli.dry_run).await,
1550 #[cfg(target_os = "macos")]
1551 Command::Signing { command } => run_signing(&client, command).await,
1552 }
1553 }
1554
1555 /// macOS-only signing diagnostics. Kept out of MCP: a tool call doesn't
1556 /// need to inspect the operator's login keychain.
1557 #[cfg(target_os = "macos")]
1558 async fn run_signing(client: &Client, command: SigningCommand) -> Result<()> {
1559 match command {
1560 SigningCommand::Doctor => {
1561 let report = smbcloud_ascapi_frontend::run_doctor(client)
1562 .await
1563 .map_err(|error| anyhow::anyhow!(error))?;
1564 print_json(&report)
1565 }
1566 }
1567 }
1568
1569 fn dirs_home() -> PathBuf {
1570 // Avoids pulling in the `dirs` crate for a single lookup; $HOME is set
1571 // on every platform this CLI targets (macOS/Linux CI runners).
1572 std::env::var_os("HOME")
1573 .map(PathBuf::from)
1574 .unwrap_or_else(|| PathBuf::from("."))
1575 }
1576
1577 async fn run_sales_reports(client: &Client, command: SalesReportsCommand) -> Result<()> {
1578 match command {
1579 SalesReportsCommand::Download {
1580 vendor_number,
1581 frequency,
1582 report_date,
1583 report_type,
1584 report_subtype,
1585 version,
1586 output,
1587 } => {
1588 let report = client
1589 .download_sales_report(&SalesReportRequest {
1590 vendor_number,
1591 frequency,
1592 report_date,
1593 report_type,
1594 report_subtype,
1595 version,
1596 })
1597 .await?;
1598 let rows = sales_report_as_json(&report)?;
1599 if let Some(path) = output {
1600 let json = serde_json::to_vec_pretty(&rows)?;
1601 std::fs::write(&path, json)
1602 .with_context(|| format!("writing sales report JSON to {}", path.display()))?;
1603 } else {
1604 print_json(&rows)?;
1605 }
1606 Ok(())
1607 }
1608 }
1609 }
1610
1611 async fn run_testflight(client: &Client, command: TestflightCommand, dry_run: bool) -> Result<()> {
1612 match command {
1613 TestflightCommand::Groups { command } => match command {
1614 BetaGroupsCommand::List { app_id } => {
1615 let groups = client.list_beta_groups(&app_id).await?;
1616 print_json(&groups)
1617 }
1618 BetaGroupsCommand::Get { group_id } => {
1619 let group = client.get_beta_group(&group_id).await?;
1620 print_json(&group)
1621 }
1622 BetaGroupsCommand::AddBuild {
1623 group_id,
1624 build_ids,
1625 } => {
1626 if dry_run {
1627 println!(
1628 "[dry-run] would add {} build(s) to beta group {group_id}: {}",
1629 build_ids.len(),
1630 build_ids.join(", ")
1631 );
1632 return Ok(());
1633 }
1634 let ids: Vec<&str> = build_ids.iter().map(String::as_str).collect();
1635 client.add_builds_to_beta_group(&group_id, &ids).await?;
1636 println!("Added {} build(s) to group {group_id}.", build_ids.len());
1637 Ok(())
1638 }
1639 },
1640 TestflightCommand::AppReviewDetail { command } => match command {
1641 BetaAppReviewDetailCommand::Get { app_id } => {
1642 let detail = client.get_beta_app_review_detail(&app_id).await?;
1643 print_json(&detail)
1644 }
1645 BetaAppReviewDetailCommand::Update {
1646 app_id,
1647 contact_email,
1648 contact_first_name,
1649 contact_last_name,
1650 contact_phone,
1651 demo_account_name,
1652 demo_account_password,
1653 demo_account_required,
1654 notes,
1655 } => {
1656 let update = BetaAppReviewDetailUpdate {
1657 contact_email,
1658 contact_first_name,
1659 contact_last_name,
1660 contact_phone,
1661 demo_account_name,
1662 demo_account_password,
1663 demo_account_required,
1664 notes,
1665 };
1666 if dry_run {
1667 println!("[dry-run] would update Beta App Review detail for app {app_id}:");
1668 print_json(&update)?;
1669 return Ok(());
1670 }
1671 let detail = client
1672 .update_beta_app_review_detail(&app_id, update)
1673 .await?;
1674 print_json(&detail)
1675 }
1676 },
1677 TestflightCommand::AppReviewSubmissions { command } => match command {
1678 BetaAppReviewSubmissionsCommand::List { build_id } => {
1679 let submissions = client.list_beta_app_review_submissions(&build_id).await?;
1680 print_json(&submissions)
1681 }
1682 BetaAppReviewSubmissionsCommand::Submit { build_id } => {
1683 if dry_run {
1684 println!("[dry-run] would submit build {build_id} to Beta App Review.");
1685 return Ok(());
1686 }
1687 let submission = client.submit_build_for_beta_review(&build_id).await?;
1688 print_json(&submission)
1689 }
1690 },
1691 TestflightCommand::BuildLocalizations { command } => match command {
1692 BetaBuildLocalizationsCommand::List { build_id } => {
1693 print_json(&client.list_beta_build_localizations(&build_id).await?)
1694 }
1695 BetaBuildLocalizationsCommand::Create {
1696 build_id,
1697 locale,
1698 whats_new,
1699 whats_new_file,
1700 } => {
1701 let whats_new = text_arg(whats_new, whats_new_file, "--whats-new-file")?
1702 .context("one of --whats-new or --whats-new-file is required")?;
1703 validate_whats_new(&whats_new)?;
1704 if dry_run {
1705 println!(
1706 "[dry-run] would create the {locale} \"What to Test\" note on build {build_id}:"
1707 );
1708 return print_json(&serde_json::json!({ "whatsNew": whats_new }));
1709 }
1710 let created = client
1711 .create_beta_build_localization(&build_id, &locale, Some(&whats_new))
1712 .await?;
1713 print_json(&created)
1714 }
1715 BetaBuildLocalizationsCommand::Update {
1716 id,
1717 whats_new,
1718 whats_new_file,
1719 } => {
1720 let whats_new = text_arg(whats_new, whats_new_file, "--whats-new-file")?
1721 .context("one of --whats-new or --whats-new-file is required")?;
1722 validate_whats_new(&whats_new)?;
1723 if dry_run {
1724 println!("[dry-run] would update \"What to Test\" note {id}:");
1725 return print_json(&serde_json::json!({ "whatsNew": whats_new }));
1726 }
1727 print_json(
1728 &client
1729 .update_beta_build_localization(&id, &whats_new)
1730 .await?,
1731 )
1732 }
1733 },
1734 }
1735 }
1736
1737 async fn run_apps(
1738 client: &Client,
1739 altool_auth: &AltoolAuth,
1740 command: AppsCommand,
1741 dry_run: bool,
1742 ) -> Result<()> {
1743 match command {
1744 AppsCommand::List { bundle_id } => {
1745 let apps = client.list_apps(bundle_id.as_deref()).await?;
1746 print_json(&apps)
1747 }
1748 AppsCommand::Get { app_id } => print_json(&client.get_app(&app_id).await?),
1749 AppsCommand::Update {
1750 app_id,
1751 primary_locale,
1752 } => {
1753 let attributes = AppUpdateAttributes {
1754 primary_locale,
1755 content_rights_declaration: None,
1756 };
1757 if dry_run_guard(dry_run, &attributes)? {
1758 return Ok(());
1759 }
1760 print_json(&client.update_app(&app_id, attributes).await?)
1761 }
1762 AppsCommand::Infos { app_id } => print_json(&client.list_app_infos(&app_id).await?),
1763 AppsCommand::Builds {
1764 app_id,
1765 version,
1766 pre_release_version,
1767 processing_state,
1768 } => {
1769 let filter = BuildFilter {
1770 version,
1771 pre_release_version,
1772 processing_state,
1773 };
1774 print_json(&client.list_builds(&app_id, &filter).await?)
1775 }
1776 AppsCommand::Build { id } => print_json(&client.get_build(&id).await?),
1777 AppsCommand::SetEncryption {
1778 build_id,
1779 uses_non_exempt_encryption,
1780 } => {
1781 let attributes = BuildUpdateAttributes {
1782 uses_non_exempt_encryption: Some(uses_non_exempt_encryption),
1783 };
1784 if dry_run_guard(dry_run, &attributes)? {
1785 return Ok(());
1786 }
1787 print_json(&client.update_build(&build_id, attributes).await?)
1788 }
1789 AppsCommand::PreReleaseVersions { app_id, platform } => {
1790 let platform = platform.map(Platform::from);
1791 print_json(&client.list_pre_release_versions(&app_id, platform).await?)
1792 }
1793 AppsCommand::Validate { package } => {
1794 if dry_run {
1795 let mut argv = vec!["xcrun".to_string()];
1796 argv.extend(
1797 upload::altool_args(&package, altool_auth, AltoolOp::Validate, false)
1798 .iter()
1799 .map(|arg| arg.to_string_lossy().into_owned()),
1800 );
1801 return print_json(&serde_json::json!({ "dry_run": true, "command": argv }));
1802 }
1803 let auth = altool_auth.clone();
1804 let outcome = tokio::task::spawn_blocking(move || validate_app(&package, &auth))
1805 .await?
1806 .map_err(anyhow::Error::msg)?;
1807 print_json(&outcome)?;
1808 match outcome.exit_code {
1809 _ if outcome.success => {}
1810 Some(code) => anyhow::bail!("altool validation failed with exit code {code}"),
1811 None => anyhow::bail!("altool validation was killed by a signal"),
1812 }
1813 Ok(())
1814 }
1815 AppsCommand::Upload { package, wait } => {
1816 if dry_run {
1817 let mut argv = vec!["xcrun".to_string()];
1818 argv.extend(
1819 upload::altool_args(&package, altool_auth, AltoolOp::Upload, wait)
1820 .iter()
1821 .map(|arg| arg.to_string_lossy().into_owned()),
1822 );
1823 return print_json(&serde_json::json!({ "dry_run": true, "command": argv }));
1824 }
1825 let auth = altool_auth.clone();
1826 let outcome =
1827 tokio::task::spawn_blocking(move || upload_package(&package, &auth, wait))
1828 .await?
1829 .map_err(anyhow::Error::msg)?;
1830 print_json(&outcome)?;
1831 match outcome.exit_code {
1832 _ if outcome.success => {}
1833 Some(code) => anyhow::bail!("altool upload failed with exit code {code}"),
1834 None => anyhow::bail!("altool upload was killed by a signal"),
1835 }
1836 Ok(())
1837 }
1838 AppsCommand::AwaitProcessing {
1839 app_id,
1840 version,
1841 pre_release_version,
1842 interval,
1843 timeout,
1844 } => {
1845 let filter = BuildFilter {
1846 version,
1847 pre_release_version,
1848 processing_state: None,
1849 };
1850 let config = AwaitConfig {
1851 interval: Duration::from_secs(interval),
1852 timeout: Duration::from_secs(timeout),
1853 };
1854 let outcome = await_processing(client, &app_id, &filter, config)
1855 .await
1856 .map_err(anyhow::Error::msg)?;
1857 print_json(&outcome)?;
1858 if outcome.timed_out {
1859 anyhow::bail!(
1860 "timed out after {}s waiting for processing",
1861 outcome.waited_secs
1862 );
1863 }
1864 if !outcome.valid {
1865 anyhow::bail!(
1866 "build finished processing but is not valid (state: {})",
1867 outcome.processing_state.as_deref().unwrap_or("unknown")
1868 );
1869 }
1870 Ok(())
1871 }
1872 }
1873 }
1874
1875 async fn run_bundle_ids(client: &Client, command: BundleIdsCommand, dry_run: bool) -> Result<()> {
1876 match command {
1877 BundleIdsCommand::List { identifier } => {
1878 print_json(&client.list_bundle_ids(identifier.as_deref()).await?)
1879 }
1880 BundleIdsCommand::Create {
1881 identifier,
1882 name,
1883 platform,
1884 } => {
1885 let attributes = BundleIdCreateAttributes {
1886 identifier,
1887 name,
1888 platform: platform.into(),
1889 };
1890 if dry_run_guard(dry_run, &attributes)? {
1891 return Ok(());
1892 }
1893 print_json(&client.create_bundle_id(attributes).await?)
1894 }
1895 }
1896 }
1897
1898 async fn run_app_store_versions(
1899 client: &Client,
1900 command: AppStoreVersionsCommand,
1901 dry_run: bool,
1902 ) -> Result<()> {
1903 match command {
1904 AppStoreVersionsCommand::List { app_id, platform } => {
1905 let versions = client
1906 .list_app_store_versions(&app_id, platform.map(Into::into))
1907 .await?;
1908 print_json(&versions)
1909 }
1910 AppStoreVersionsCommand::Get { id } => {
1911 print_json(&client.get_app_store_version(&id).await?)
1912 }
1913 AppStoreVersionsCommand::Create {
1914 app_id,
1915 platform,
1916 version_string,
1917 copyright,
1918 } => {
1919 validate_version_string(&version_string)?;
1920 let attributes = AppStoreVersionCreateAttributes {
1921 platform: platform.into(),
1922 version_string,
1923 copyright,
1924 };
1925 if dry_run_guard(dry_run, &attributes)? {
1926 return Ok(());
1927 }
1928 print_json(&client.create_app_store_version(&app_id, attributes).await?)
1929 }
1930 AppStoreVersionsCommand::Update {
1931 id,
1932 version_string,
1933 copyright,
1934 } => {
1935 if version_string.is_none() && copyright.is_none() {
1936 anyhow::bail!("nothing to update: pass --version-string and/or --copyright");
1937 }
1938 if let Some(version_string) = version_string.as_deref() {
1939 validate_version_string(version_string)?;
1940 }
1941 let fields = AppStoreVersionUpdateFields {
1942 version_string,
1943 copyright,
1944 };
1945 if dry_run_guard(dry_run, &fields)? {
1946 return Ok(());
1947 }
1948 print_json(&client.update_app_store_version(&id, fields).await?)
1949 }
1950 AppStoreVersionsCommand::Delete { id } => {
1951 if dry_run {
1952 println!("# dry run — would DELETE /v1/appStoreVersions/{id}");
1953 return Ok(());
1954 }
1955 client.delete_app_store_version(&id).await?;
1956 println!("deleted {id}");
1957 Ok(())
1958 }
1959 AppStoreVersionsCommand::Build { id } => {
1960 let build = client.get_app_store_version_build(&id).await?;
1961 if build.is_none() {
1962 eprintln!("no build attached");
1963 }
1964 print_json(&build)
1965 }
1966 AppStoreVersionsCommand::SetBuild {
1967 id,
1968 build_id,
1969 no_verify,
1970 } => {
1971 if dry_run {
1972 println!(
1973 "# dry run — would PATCH /v1/appStoreVersions/{id} relationships.build → {build_id}"
1974 );
1975 return Ok(());
1976 }
1977 let attached = attach_build(client, &id, &build_id, !no_verify)
1978 .await
1979 .map_err(anyhow::Error::msg)?;
1980 print_json(&attached)
1981 }
1982 }
1983 }
1984
1985 async fn run_review_submissions(
1986 client: &Client,
1987 command: ReviewSubmissionsCommand,
1988 dry_run: bool,
1989 ) -> Result<()> {
1990 match command {
1991 ReviewSubmissionsCommand::List {
1992 app_id,
1993 platform,
1994 state,
1995 } => {
1996 let states: Vec<&str> = state.as_deref().into_iter().collect();
1997 let submissions = client
1998 .list_review_submissions(&app_id, platform.map(Into::into), &states)
1999 .await?;
2000 print_json(&submissions)
2001 }
2002 ReviewSubmissionsCommand::Submit { app_id, version_id } => {
2003 let plan = review::plan_submission(client, &app_id, &version_id)
2004 .await
2005 .map_err(anyhow::Error::msg)?;
2006 if dry_run {
2007 match &plan.blocked {
2008 Some(reason) => println!("# dry run — would not submit: {reason}"),
2009 None => println!("# dry run — would submit this plan for review:"),
2010 }
2011 return print_json(&plan);
2012 }
2013 let submitted = review::submit_for_review(client, plan)
2014 .await
2015 .map_err(anyhow::Error::msg)?;
2016 print_json(&submitted)
2017 }
2018 ReviewSubmissionsCommand::Cancel { id } => {
2019 if dry_run {
2020 println!("# dry run — would PATCH /v1/reviewSubmissions/{id} canceled: true");
2021 return Ok(());
2022 }
2023 print_json(&client.cancel_review_submission(&id).await?)
2024 }
2025 }
2026 }
2027
2028 async fn run_app_info_localizations(
2029 client: &Client,
2030 command: AppInfoLocalizationsCommand,
2031 dry_run: bool,
2032 ) -> Result<()> {
2033 match command {
2034 AppInfoLocalizationsCommand::List { app_info_id } => {
2035 print_json(&client.list_app_info_localizations(&app_info_id).await?)
2036 }
2037 AppInfoLocalizationsCommand::Create {
2038 app_info_id,
2039 locale,
2040 name,
2041 subtitle,
2042 } => {
2043 let attributes = AppInfoLocalizationCreateAttributes {
2044 locale,
2045 name,
2046 subtitle,
2047 privacy_policy_url: None,
2048 };
2049 if dry_run_guard(dry_run, &attributes)? {
2050 return Ok(());
2051 }
2052 print_json(
2053 &client
2054 .create_app_info_localization(&app_info_id, attributes)
2055 .await?,
2056 )
2057 }
2058 AppInfoLocalizationsCommand::Update { id, name, subtitle } => {
2059 let attributes = AppInfoLocalizationUpdateAttributes {
2060 name,
2061 subtitle,
2062 privacy_policy_url: None,
2063 };
2064 if dry_run_guard(dry_run, &attributes)? {
2065 return Ok(());
2066 }
2067 print_json(&client.update_app_info_localization(&id, attributes).await?)
2068 }
2069 AppInfoLocalizationsCommand::Delete { id } => {
2070 if dry_run {
2071 println!("# dry run — would DELETE /v1/appInfoLocalizations/{id}");
2072 return Ok(());
2073 }
2074 client.delete_app_info_localization(&id).await?;
2075 println!("deleted {id}");
2076 Ok(())
2077 }
2078 }
2079 }
2080
2081 async fn run_app_store_version_localizations(
2082 client: &Client,
2083 command: AppStoreVersionLocalizationsCommand,
2084 dry_run: bool,
2085 ) -> Result<()> {
2086 match command {
2087 AppStoreVersionLocalizationsCommand::List {
2088 app_store_version_id,
2089 } => print_json(
2090 &client
2091 .list_app_store_version_localizations(&app_store_version_id)
2092 .await?,
2093 ),
2094 AppStoreVersionLocalizationsCommand::Create {
2095 app_store_version_id,
2096 locale,
2097 description,
2098 description_file,
2099 keywords,
2100 keywords_file,
2101 whats_new,
2102 whats_new_file,
2103 promotional_text,
2104 promotional_text_file,
2105 marketing_url,
2106 support_url,
2107 no_verify,
2108 } => {
2109 let fields = AppStoreVersionLocalizationFields {
2110 description: text_arg(description, description_file, "--description-file")?,
2111 keywords: text_arg(keywords, keywords_file, "--keywords-file")?,
2112 whats_new: text_arg(whats_new, whats_new_file, "--whats-new-file")?,
2113 promotional_text: text_arg(
2114 promotional_text,
2115 promotional_text_file,
2116 "--promotional-text-file",
2117 )?,
2118 marketing_url,
2119 support_url,
2120 };
2121 lint_localization_fields(&fields)?;
2122 let attributes = AppStoreVersionLocalizationCreateAttributes {
2123 locale,
2124 fields: fields.clone(),
2125 };
2126 if dry_run_guard(dry_run, &attributes)? {
2127 return Ok(());
2128 }
2129 let created = client
2130 .create_app_store_version_localization(&app_store_version_id, attributes)
2131 .await?;
2132 verify_localization(client, &created.id, &fields, no_verify).await?;
2133 print_json(&created)
2134 }
2135 AppStoreVersionLocalizationsCommand::Update {
2136 id,
2137 description,
2138 description_file,
2139 keywords,
2140 keywords_file,
2141 whats_new,
2142 whats_new_file,
2143 promotional_text,
2144 promotional_text_file,
2145 marketing_url,
2146 support_url,
2147 no_verify,
2148 } => {
2149 let fields = AppStoreVersionLocalizationFields {
2150 description: text_arg(description, description_file, "--description-file")?,
2151 keywords: text_arg(keywords, keywords_file, "--keywords-file")?,
2152 whats_new: text_arg(whats_new, whats_new_file, "--whats-new-file")?,
2153 promotional_text: text_arg(
2154 promotional_text,
2155 promotional_text_file,
2156 "--promotional-text-file",
2157 )?,
2158 marketing_url,
2159 support_url,
2160 };
2161 lint_localization_fields(&fields)?;
2162 if dry_run_guard(dry_run, &fields)? {
2163 return Ok(());
2164 }
2165 let updated = client
2166 .update_app_store_version_localization(&id, fields.clone())
2167 .await?;
2168 verify_localization(client, &updated.id, &fields, no_verify).await?;
2169 print_json(&updated)
2170 }
2171 AppStoreVersionLocalizationsCommand::Delete { id } => {
2172 if dry_run {
2173 println!("# dry run — would DELETE /v1/appStoreVersionLocalizations/{id}");
2174 return Ok(());
2175 }
2176 client.delete_app_store_version_localization(&id).await?;
2177 println!("deleted {id}");
2178 Ok(())
2179 }
2180 }
2181 }
2182
2183 async fn run_app_screenshot_sets(
2184 client: &Client,
2185 command: AppScreenshotSetsCommand,
2186 dry_run: bool,
2187 ) -> Result<()> {
2188 match command {
2189 AppScreenshotSetsCommand::List {
2190 app_store_version_localization_id,
2191 } => print_json(
2192 &client
2193 .list_app_screenshot_sets(&app_store_version_localization_id)
2194 .await?,
2195 ),
2196 AppScreenshotSetsCommand::Create {
2197 app_store_version_localization_id,
2198 display_type,
2199 } => {
2200 let attributes = AppScreenshotSetCreateAttributes {
2201 screenshot_display_type: display_type.into(),
2202 };
2203 if dry_run_guard(dry_run, &attributes)? {
2204 return Ok(());
2205 }
2206 print_json(
2207 &client
2208 .create_app_screenshot_set(&app_store_version_localization_id, attributes)
2209 .await?,
2210 )
2211 }
2212 AppScreenshotSetsCommand::Delete { id } => {
2213 if dry_run {
2214 println!("# dry run — would DELETE /v1/appScreenshotSets/{id}");
2215 return Ok(());
2216 }
2217 client.delete_app_screenshot_set(&id).await?;
2218 println!("deleted {id}");
2219 Ok(())
2220 }
2221 }
2222 }
2223
2224 async fn run_app_screenshots(
2225 client: &Client,
2226 command: AppScreenshotsCommand,
2227 dry_run: bool,
2228 ) -> Result<()> {
2229 match command {
2230 AppScreenshotsCommand::List {
2231 app_screenshot_set_id,
2232 } => print_json(&client.list_app_screenshots(&app_screenshot_set_id).await?),
2233 AppScreenshotsCommand::Upload {
2234 app_screenshot_set_id,
2235 file,
2236 } => {
2237 let file_name = file
2238 .file_name()
2239 .map(|n| n.to_string_lossy().into_owned())
2240 .with_context(|| format!("{file:?} has no file name"))?;
2241 let bytes = std::fs::read(&file)
2242 .with_context(|| format!("reading screenshot file {file:?}"))?;
2243
2244 if dry_run {
2245 println!(
2246 "# dry run — would upload {file_name} ({} bytes) to appScreenshotSet {app_screenshot_set_id}",
2247 bytes.len()
2248 );
2249 return Ok(());
2250 }
2251
2252 print_json(
2253 &client
2254 .upload_app_screenshot(&app_screenshot_set_id, file_name, bytes)
2255 .await?,
2256 )
2257 }
2258 AppScreenshotsCommand::Delete { id } => {
2259 if dry_run {
2260 println!("# dry run — would DELETE /v1/appScreenshots/{id}");
2261 return Ok(());
2262 }
2263 client.delete_app_screenshot(&id).await?;
2264 println!("deleted {id}");
2265 Ok(())
2266 }
2267 }
2268 }
2269
2270 /// Human-readable summary of a certificate, with the fields that decide
2271 /// whether it is usable pulled to the top. Deliberately omits
2272 /// `certificateContent`: it is large, and printing it invites pasting
2273 /// certificate bodies into places they should not go.
2274 #[derive(serde::Serialize)]
2275 struct CertificateSummary {
2276 id: String,
2277 name: Option<String>,
2278 r#type: Option<String>,
2279 serial_number: Option<String>,
2280 expiration_date: Option<String>,
2281 /// Computed here rather than by Apple, which offers no such filter.
2282 expired: Option<bool>,
2283 }
2284
2285 fn summarize(
2286 certificate: &smbcloud_ascapi_signing::certificate::Certificate,
2287 ) -> CertificateSummary {
2288 let expiration_date = certificate.attributes.expiration_date.clone();
2289 // Lexicographic comparison is sound for ISO-8601 UTC timestamps, which
2290 // is what Apple returns, and avoids a date-parsing dependency for a
2291 // field that is only ever displayed.
2292 let now = now_iso8601();
2293 let expired = expiration_date
2294 .as_ref()
2295 .map(|at| at.as_str() < now.as_str());
2296
2297 CertificateSummary {
2298 id: certificate.id.clone(),
2299 name: certificate.attributes.name.clone(),
2300 r#type: certificate
2301 .attributes
2302 .certificate_type
2303 .map(|t| t.as_api_str().to_string()),
2304 serial_number: certificate.attributes.serial_number.clone(),
2305 expiration_date,
2306 expired,
2307 }
2308 }
2309
2310 /// Current UTC time as `YYYY-MM-DDTHH:MM:SS`, for comparing against
2311 /// Apple's ISO-8601 expiry strings without pulling in a date library.
2312 fn now_iso8601() -> String {
2313 let secs = std::time::SystemTime::now()
2314 .duration_since(std::time::UNIX_EPOCH)
2315 .unwrap_or_default()
2316 .as_secs() as i64;
2317
2318 // Days since the epoch to a civil date, per Howard Hinnant's
2319 // days_from_civil inverse. Shifting the era to March makes the leap
2320 // day the last day of the year, which removes every special case.
2321 let days = secs.div_euclid(86_400);
2322 let secs_of_day = secs.rem_euclid(86_400);
2323 let z = days + 719_468;
2324 let era = z.div_euclid(146_097);
2325 let doe = z.rem_euclid(146_097);
2326 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
2327 let y = yoe + era * 400;
2328 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
2329 let mp = (5 * doy + 2) / 153;
2330 let d = doy - (153 * mp + 2) / 5 + 1;
2331 let m = if mp < 10 { mp + 3 } else { mp - 9 };
2332 let y = if m <= 2 { y + 1 } else { y };
2333
2334 format!(
2335 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
2336 y,
2337 m,
2338 d,
2339 secs_of_day / 3600,
2340 (secs_of_day % 3600) / 60,
2341 secs_of_day % 60
2342 )
2343 }
2344
2345 /// Read-only throughout, so unlike its siblings it takes no `dry_run`:
2346 /// there is no request body to preview.
2347 async fn run_app_prices(client: &Client, command: AppPricesCommand) -> Result<()> {
2348 match command {
2349 AppPricesCommand::Schedule { app_id } => {
2350 print_json(&client.get_app_price_schedule(&app_id).await?)
2351 }
2352 AppPricesCommand::List {
2353 app_id,
2354 automatic,
2355 territory,
2356 } => {
2357 let kind = if automatic {
2358 PriceKind::Automatic
2359 } else {
2360 PriceKind::Manual
2361 };
2362 let prices = client
2363 .list_app_prices(&app_id, kind, territory.as_deref())
2364 .await?;
2365 print_json(&prices)
2366 }
2367 }
2368 }
2369
2370 async fn run_certificates(
2371 client: &Client,
2372 command: CertificatesCommand,
2373 dry_run: bool,
2374 ) -> Result<()> {
2375 match command {
2376 CertificatesCommand::List { r#type } => {
2377 let mut certificates = client.list_certificates(r#type.map(Into::into)).await?;
2378 certificates.sort_by(|a, b| {
2379 a.attributes
2380 .expiration_date
2381 .cmp(&b.attributes.expiration_date)
2382 });
2383 let summaries: Vec<_> = certificates.iter().map(summarize).collect();
2384 print_json(&summaries)
2385 }
2386
2387 CertificatesCommand::Create {
2388 r#type,
2389 out_dir,
2390 common_name,
2391 } => {
2392 let certificate_type: CertificateType = r#type.into();
2393
2394 if dry_run {
2395 // Stop before generating a key: an unused RSA key pair on
2396 // disk is a small liability, and a dry run should have no
2397 // side effects at all.
2398 println!("# dry run — would generate an RSA 2048 key pair and request:");
2399 print_json(&serde_json::json!({
2400 "certificateType": certificate_type.as_api_str(),
2401 "commonName": common_name,
2402 "outDir": out_dir,
2403 }))?;
2404 return Ok(());
2405 }
2406
2407 let request = generate_certificate_request(&common_name)
2408 .context("generating the RSA 2048 key pair and CSR")?;
2409
2410 std::fs::create_dir_all(&out_dir)
2411 .with_context(|| format!("creating {}", out_dir.display()))?;
2412
2413 // Write the private key BEFORE calling Apple. If the request
2414 // fails we are left with an unused key, which is harmless; the
2415 // other order risks Apple issuing a certificate whose key we
2416 // then fail to persist, which is unrecoverable.
2417 let stem = certificate_type.as_api_str().to_lowercase();
2418 let key_path = out_dir.join(format!("{stem}.key.pem"));
2419 write_private_key(&key_path, request.private_key_pem())
2420 .with_context(|| format!("writing the private key to {}", key_path.display()))?;
2421
2422 let attributes = CertificateCreateAttributes {
2423 csr_content: request.csr_pem().to_string(),
2424 certificate_type,
2425 };
2426 let certificate = client.create_certificate(attributes).await?;
2427
2428 let cer_path = out_dir.join(format!("{stem}.cer"));
2429 if let Some(content) = certificate.attributes.certificate_content.as_deref() {
2430 let der =
2431 base64_decode(content).context("decoding the certificate Apple returned")?;
2432 std::fs::write(&cer_path, der)
2433 .with_context(|| format!("writing {}", cer_path.display()))?;
2434 }
2435
2436 print_json(&serde_json::json!({
2437 "certificate": summarize(&certificate),
2438 "privateKey": key_path,
2439 "certificatePath": cer_path,
2440 "nextSteps": [
2441 format!(
2442 "openssl pkcs12 -export -inkey {} -in {} -out {}.p12",
2443 key_path.display(), cer_path.display(), stem
2444 ),
2445 format!("security import {stem}.p12 -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign"),
2446 "security find-identity -v -p codesigning".to_string(),
2447 ],
2448 }))
2449 }
2450
2451 CertificatesCommand::Revoke { id, confirm } => {
2452 if confirm != id {
2453 anyhow::bail!(
2454 "refusing to revoke: pass --confirm {id} to acknowledge that this breaks \
2455 every provisioning profile embedding this certificate, for the whole team"
2456 );
2457 }
2458 if dry_run {
2459 println!("# dry run — would revoke certificate {id}");
2460 return Ok(());
2461 }
2462 client.revoke_certificate(&id).await?;
2463 print_json(&serde_json::json!({ "revoked": id }))
2464 }
2465 }
2466 }
2467
2468 async fn run_profiles(client: &Client, command: ProfilesCommand, dry_run: bool) -> Result<()> {
2469 use smbcloud_ascapi_frontend::profiles::{download_profile, ProfileSummary};
2470 use smbcloud_ascapi_frontend::time::now_iso8601;
2471
2472 match command {
2473 ProfilesCommand::List {
2474 name,
2475 r#type,
2476 with_bundle_id,
2477 } => {
2478 let mut profiles = client
2479 .list_profiles(name.as_deref(), r#type.map(Into::into))
2480 .await?;
2481 profiles.sort_by(|a, b| {
2482 a.attributes
2483 .expiration_date
2484 .cmp(&b.attributes.expiration_date)
2485 });
2486 let now = now_iso8601();
2487 let mut summaries: Vec<_> = profiles
2488 .iter()
2489 .map(|profile| ProfileSummary::from_resource(profile, &now))
2490 .collect();
2491 if with_bundle_id {
2492 for summary in &mut summaries {
2493 let bundle = client.get_profile_bundle_id(&summary.id).await?;
2494 summary.bundle_identifier = bundle.attributes.identifier;
2495 summary.bundle_id = Some(bundle.id);
2496 }
2497 }
2498 print_json(&summaries)
2499 }
2500
2501 ProfilesCommand::Create {
2502 name,
2503 r#type,
2504 bundle_id,
2505 certificates,
2506 devices,
2507 } => {
2508 let profile_type: ProfileType = r#type.into();
2509
2510 // Say so rather than dropping them silently: a caller who
2511 // passed devices to a store profile has misunderstood
2512 // something, and the created profile will not be what they
2513 // expect.
2514 if !devices.is_empty() && !profile_type.takes_devices() {
2515 eprintln!(
2516 "note: {} does not take devices; ignoring the {} passed",
2517 profile_type.as_api_str(),
2518 devices.len()
2519 );
2520 }
2521
2522 let relationships =
2523 ProfileCreateRelationships::new(profile_type, &bundle_id, &certificates, &devices);
2524 let attributes = ProfileCreateAttributes {
2525 name: name.clone(),
2526 profile_type,
2527 };
2528
2529 if dry_run {
2530 print_json(&serde_json::json!({
2531 "data": {
2532 "type": "profiles",
2533 "attributes": attributes,
2534 "relationships": relationships,
2535 }
2536 }))?;
2537 return Ok(());
2538 }
2539
2540 let profile = client.create_profile(attributes, relationships).await?;
2541 print_json(&ProfileSummary::from_resource(&profile, &now_iso8601()))
2542 }
2543
2544 ProfilesCommand::Download { id, output } => {
2545 if dry_run {
2546 println!(
2547 "# dry run — would write profile {id} to {}",
2548 output.display()
2549 );
2550 return Ok(());
2551 }
2552 let downloaded = download_profile(client, &id, &output)
2553 .await
2554 .map_err(anyhow::Error::msg)?;
2555 print_json(&downloaded)
2556 }
2557
2558 ProfilesCommand::Delete { id, confirm } => {
2559 if confirm != id {
2560 anyhow::bail!(
2561 "refusing to delete: pass --confirm {id} to acknowledge that signing \
2562 against this profile stops working until a replacement exists"
2563 );
2564 }
2565 if dry_run {
2566 println!("# dry run — would delete profile {id}");
2567 return Ok(());
2568 }
2569 client.delete_profile(&id).await?;
2570 print_json(&serde_json::json!({ "deleted": id }))
2571 }
2572 }
2573 }
2574
2575 /// Write a private key with owner-only permissions, set at creation time
2576 /// rather than chmod'ed afterwards so the key is never briefly readable.
2577 fn write_private_key(path: &std::path::Path, pem: &str) -> std::io::Result<()> {
2578 use std::io::Write;
2579
2580 let mut options = std::fs::OpenOptions::new();
2581 options.write(true).create(true).truncate(true);
2582 #[cfg(unix)]
2583 {
2584 use std::os::unix::fs::OpenOptionsExt;
2585 options.mode(0o600);
2586 }
2587 let mut file = options.open(path)?;
2588 file.write_all(pem.as_bytes())?;
2589 file.sync_all()
2590 }
2591
2592 fn base64_decode(input: &str) -> Result<Vec<u8>> {
2593 use base64::Engine;
2594 Ok(base64::engine::general_purpose::STANDARD.decode(input.trim())?)
2595 }
2596
2597 #[cfg(test)]
2598 mod tests {
2599 use super::sales_report_as_json;
2600 use super::validate_version_string;
2601 use super::validate_whats_new;
2602 use super::WHATS_NEW_MAX_LEN;
2603 use super::{diff_fields, lint_localization_fields, text_arg, Cli};
2604 use clap::Parser;
2605 use flate2::write::GzEncoder;
2606 use flate2::Compression;
2607 use smbcloud_ascapi_aso::app_store_version_localization::{
2608 AppStoreVersionLocalizationAttributes, AppStoreVersionLocalizationFields,
2609 };
2610 use std::io::Write;
2611
2612 fn temp_file(name: &str, contents: &str) -> std::path::PathBuf {
2613 let path = std::env::temp_dir().join(format!("asc-test-{}-{name}", std::process::id()));
2614 std::fs::write(&path, contents).unwrap();
2615 path
2616 }
2617
2618 #[test]
2619 fn text_arg_strips_one_trailing_newline_and_keeps_the_rest() {
2620 let path = temp_file("strip", "a & b\n\nline two\n");
2621 let text = text_arg(None, Some(path.clone()), "--description-file").unwrap();
2622 assert_eq!(text.as_deref(), Some("a & b\n\nline two"));
2623 std::fs::write(&path, "crlf\r\n").unwrap();
2624 let text = text_arg(None, Some(path.clone()), "--description-file").unwrap();
2625 assert_eq!(text.as_deref(), Some("crlf"));
2626 std::fs::remove_file(path).unwrap();
2627 }
2628
2629 #[test]
2630 fn text_arg_passes_inline_through_and_names_flag_on_error() {
2631 assert_eq!(
2632 text_arg(Some("x\n".into()), None, "--f")
2633 .unwrap()
2634 .as_deref(),
2635 Some("x\n")
2636 );
2637 let error = text_arg(None, Some("/nonexistent/asc.txt".into()), "--keywords-file")
2638 .unwrap_err()
2639 .to_string();
2640 assert!(error.contains("--keywords-file") && error.contains("/nonexistent/asc.txt"));
2641 }
2642
2643 #[test]
2644 fn file_and_inline_flags_conflict() {
2645 let result = Cli::try_parse_from([
2646 "asc",
2647 "app-store-version-localizations",
2648 "update",
2649 "id",
2650 "--description",
2651 "a",
2652 "--description-file",
2653 "b.txt",
2654 ]);
2655 assert!(result.is_err());
2656 let result =
2657 Cli::try_parse_from(["asc", "testflight", "build-localizations", "update", "id"]);
2658 assert!(
2659 result.is_err(),
2660 "one of the two whats-new flags is required"
2661 );
2662 let result = Cli::try_parse_from([
2663 "asc",
2664 "testflight",
2665 "build-localizations",
2666 "update",
2667 "id",
2668 "--whats-new",
2669 "a",
2670 "--whats-new-file",
2671 "b.txt",
2672 ]);
2673 assert!(result.is_err());
2674 }
2675
2676 #[test]
2677 fn lint_names_the_flag() {
2678 let fields = AppStoreVersionLocalizationFields {
2679 promotional_text: Some("a".repeat(171)),
2680 ..Default::default()
2681 };
2682 let error = lint_localization_fields(&fields).unwrap_err().to_string();
2683 assert!(error.starts_with("--promotional-text is 171"), "{error}");
2684 }
2685
2686 fn stored(description: Option<&str>) -> AppStoreVersionLocalizationAttributes {
2687 AppStoreVersionLocalizationAttributes {
2688 locale: "en-US".into(),
2689 description: description.map(Into::into),
2690 keywords: None,
2691 marketing_url: None,
2692 promotional_text: None,
2693 support_url: None,
2694 whats_new: None,
2695 }
2696 }
2697
2698 #[test]
2699 fn diff_fields_matches_and_mismatches() {
2700 let sent = AppStoreVersionLocalizationFields {
2701 description: Some("abc".into()),
2702 ..Default::default()
2703 };
2704 assert!(diff_fields(&sent, &stored(Some("abc"))).is_empty());
2705 assert_eq!(
2706 diff_fields(&sent, &stored(Some("abd"))),
2707 vec!["description"]
2708 );
2709 assert_eq!(diff_fields(&sent, &stored(None)), vec!["description"]);
2710 // Fields that were not sent are never compared.
2711 assert!(diff_fields(
2712 &AppStoreVersionLocalizationFields::default(),
2713 &stored(Some("x"))
2714 )
2715 .is_empty());
2716 }
2717
2718 fn gzip(tsv: &str) -> Vec<u8> {
2719 let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
2720 encoder.write_all(tsv.as_bytes()).unwrap();
2721 encoder.finish().unwrap()
2722 }
2723
2724 #[test]
2725 fn sales_report_preserves_headers_and_string_values() {
2726 let rows = sales_report_as_json(&gzip(
2727 "Provider Country\tUnits\tTitle\nUS\t12\t\"An app\twith tab\"\n",
2728 ))
2729 .unwrap();
2730
2731 assert_eq!(
2732 rows,
2733 vec![serde_json::json!({
2734 "Provider Country": "US",
2735 "Units": "12",
2736 "Title": "An app\twith tab",
2737 })]
2738 );
2739 }
2740
2741 #[test]
2742 fn sales_report_accepts_rows_with_missing_or_extra_fields() {
2743 let rows = sales_report_as_json(&gzip("Country\tUnits\nUS\nCA\t3\textra\n")).unwrap();
2744
2745 assert_eq!(
2746 rows,
2747 vec![
2748 serde_json::json!({ "Country": "US" }),
2749 serde_json::json!({
2750 "Country": "CA",
2751 "Units": "3",
2752 "extra_column_3": "extra",
2753 }),
2754 ]
2755 );
2756 }
2757
2758 #[test]
2759 fn sales_report_rejects_invalid_gzip() {
2760 let error = sales_report_as_json(b"not a gzip file").unwrap_err();
2761 assert!(error
2762 .to_string()
2763 .contains("reading the TSV header from Apple's sales report"));
2764 }
2765
2766 #[test]
2767 fn version_string_accepts_one_to_three_numbers() {
2768 for version_string in ["1", "1.1", "1.1.0", "12.345.6789"] {
2769 assert!(
2770 validate_version_string(version_string).is_ok(),
2771 "{version_string} should be accepted"
2772 );
2773 }
2774 }
2775
2776 #[test]
2777 fn version_string_rejects_non_numeric_or_too_many_parts() {
2778 for version_string in [
2779 "", "1.1.0.0", "1..0", "1.1.", ".1", "v1.1", "1.1-beta", "1,1",
2780 ] {
2781 let error = validate_version_string(version_string)
2782 .expect_err(&format!("{version_string} should be rejected"));
2783 assert!(
2784 error.to_string().starts_with("invalid --version-string"),
2785 "{error}"
2786 );
2787 }
2788 }
2789
2790 #[test]
2791 fn sales_report_disambiguates_blank_and_duplicate_headers() {
2792 let rows = sales_report_as_json(&gzip("Country\t\tCountry\nUS\t3\tCA\n")).unwrap();
2793
2794 assert_eq!(
2795 rows,
2796 vec![serde_json::json!({
2797 "Country": "US",
2798 "column_2": "3",
2799 "Country_2": "CA",
2800 })]
2801 );
2802 }
2803
2804 #[test]
2805 fn whats_new_accepts_empty_and_up_to_the_limit() {
2806 // Apple accepts the field at create time as null/empty, and a
2807 // note exactly at the cap is the boundary that must pass.
2808 assert!(validate_whats_new("").is_ok());
2809 let at_limit = "a".repeat(WHATS_NEW_MAX_LEN);
2810 assert!(validate_whats_new(&at_limit).is_ok(), "at-limit must pass");
2811 }
2812
2813 #[test]
2814 fn whats_new_rejects_one_char_over_the_limit() {
2815 let too_long = "a".repeat(WHATS_NEW_MAX_LEN + 1);
2816 let error = validate_whats_new(&too_long).expect_err("over-limit must be rejected");
2817 let message = error.to_string();
2818 assert!(
2819 message.contains("--whats-new"),
2820 "should name the flag: {message}"
2821 );
2822 assert!(
2823 message.contains(&WHATS_NEW_MAX_LEN.to_string()),
2824 "should quote the limit: {message}"
2825 );
2826 }
2827
2828 #[test]
2829 fn whats_new_counts_characters_not_bytes() {
2830 // "あ" is 3 bytes in UTF-8 but 1 character, so 4000 of them fits
2831 // the character-based limit even though it would blow a byte
2832 // budget. Make sure the validator agrees with ASC's character
2833 // semantics.
2834 let multibyte = "あ".repeat(WHATS_NEW_MAX_LEN);
2835 assert!(
2836 validate_whats_new(&multibyte).is_ok(),
2837 "char count, not byte count"
2838 );
2839 }
2840 }