From 9c244c4a2f2f10c957e2b504f5ac800295864f0c Mon Sep 17 00:00:00 2001 From: Kerem Erkan Date: Fri, 13 Feb 2026 16:58:00 +0100 Subject: [PATCH] Add run-workflow command, build management, --yes flag, and automation improvements - Add run-workflow command for chaining commands from a workflow file - Add builds archive, upload, and validate subcommands - Add attach-build, attach-latest-build, and detach-build commands - Add --yes / -y flag to all interactive commands for non-interactive execution - Add confirm() helper and autoConfirm global for prompt suppression - Add path sanitization for drag-and-drop terminal input - Filter build selection by version string - Rename install-shell-completions to install-completions - Bump version to 0.2.0 --- CLAUDE.md | 26 +- README.md | 77 ++- Sources/asc-client/ASCClient.swift | 4 +- Sources/asc-client/Commands/AppsCommand.swift | 201 ++++++- .../asc-client/Commands/BuildsCommand.swift | 514 +++++++++++++++++- .../Commands/InstallCompletionsCommand.swift | 85 ++- .../Commands/RunWorkflowCommand.swift | 120 ++++ Sources/asc-client/Formatting.swift | 53 +- Sources/asc-client/MediaUpload.swift | 22 +- 9 files changed, 1049 insertions(+), 53 deletions(-) create mode 100644 Sources/asc-client/Commands/RunWorkflowCommand.swift diff --git a/CLAUDE.md b/CLAUDE.md index 5cb1f97..f194093 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ Sources/asc-client/ ConfigureCommand.swift # Interactive credential setup, file permissions AppsCommand.swift # All app subcommands + findApp/findVersion helpers BuildsCommand.swift # Build subcommands + RunWorkflowCommand.swift # Sequential command runner from workflow files ``` ## Dependencies @@ -80,6 +81,10 @@ asc-client apps upload-media [--folder X] [--version X] [--replace] asc-client apps download-media [--folder X] [--version X] # Download screenshots/previews asc-client apps verify-media [--version X] [--folder X] # Check media status, retry stuck asc-client builds list [--bundle-id ] # List builds +asc-client builds archive [--workspace X] [--scheme X] [--output X] # Archive Xcode project +asc-client builds upload [file] # Upload build via altool +asc-client builds validate [file] # Validate build via altool +asc-client run-workflow [--yes] # Run commands from a workflow file ``` ## Key Patterns @@ -87,10 +92,29 @@ asc-client builds list [--bundle-id ] # List builds ### Adding a new subcommand 1. Add the command struct inside `AppsCommand` (or create a new command group) 2. Use `AsyncParsableCommand` for commands that call the API -3. Register in parent's `subcommands` array +3. Register in the appropriate `CommandGroup` in the parent's configuration (see below) 4. Use `findApp(bundleID:client:)` to resolve bundle ID to app ID 5. Use `findVersion(appID:versionString:client:)` to resolve version (nil = latest) 6. Use shared `formatDate()` and `expandPath()` from Formatting.swift +7. Run `asc-client install-completions` to regenerate completions after adding commands + +### Subcommand grouping +`AppsCommand` uses `CommandGroup` (swift-argument-parser 1.7+) to organize subcommands into sections in `--help` output: +- **ungrouped** (`subcommands:`): list, info, versions — general browse commands +- **Version**: create-version, attach-build, attach-latest-build, detach-build +- **Localization**: localizations, export-localizations, update-localization, update-localizations +- **Media**: download-media, upload-media, verify-media +- **Review**: review-status, submit-for-review + +When adding a new subcommand, place it in the appropriate `CommandGroup` or create a new one. Shell completions are alphabetically sorted by zsh — don't try to force custom ordering there. + +### Workflow files (used by run-workflow) +- One command per line, without the `asc-client` prefix +- Lines starting with `#` are comments, blank lines are ignored +- Quoted strings are respected for arguments with spaces (e.g. `--file "path with spaces.json"`) +- Without `--yes`: prompts once to confirm the workflow, then individual commands still prompt normally +- With `--yes`: sets `autoConfirm = true` globally, all prompts are skipped +- Commands are dispatched via `ASCClient.parseAsRoot(args)` — any registered subcommand works ### API calls - **`filterBundleID` does prefix matching** — `com.foo.Bar` also matches `com.foo.BarPro`. Always use `findApp()` which filters for exact `bundleID` match from results. diff --git a/README.md b/README.md index fb4b020..019bc19 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ cp .build/release/asc-client /usr/local/bin/ Set up tab completion for subcommands, options, and flags (supports zsh and bash): ```bash -asc-client install-shell-completions +asc-client install-completions ``` This detects your shell and configures everything automatically. Restart your shell or open a new tab to activate. @@ -94,6 +94,24 @@ asc-client apps create-version 2.1.0 --platform ios --release-type m # Check review submission status asc-client apps review-status + +# Submit for review +asc-client apps submit-for-review +asc-client apps submit-for-review --version 2.1.0 +``` + +### Build Management + +```bash +# Interactively select and attach a build to a version +asc-client apps attach-build +asc-client apps attach-build --version 2.1.0 + +# Attach the most recent build automatically +asc-client apps attach-latest-build + +# Remove the attached build from a version +asc-client apps detach-build ``` ### Localizations @@ -238,9 +256,62 @@ Without `--folder`, the command shows a read-only status report. Sets where all ```bash # List all builds asc-client builds list - -# Filter by app asc-client builds list --bundle-id + +# Archive an Xcode project +asc-client builds archive +asc-client builds archive --scheme MyApp --output ./archives + +# Validate a build before uploading +asc-client builds validate MyApp.ipa + +# Upload a build to App Store Connect +asc-client builds upload MyApp.ipa +``` + +The `archive` command auto-detects the `.xcworkspace` or `.xcodeproj` in the current directory and resolves the scheme if only one exists. It accepts `.ipa`, `.pkg`, or `.xcarchive` files for `upload` and `validate`. When given an `.xcarchive`, it automatically exports to `.ipa` before uploading. + +### Workflows + +Chain multiple commands into a single automated run with a workflow file: + +```bash +asc-client run-workflow release.txt +asc-client run-workflow release.txt --yes # skip all prompts (CI/CD) +``` + +A workflow file is a plain text file with one command per line (without the `asc-client` prefix). Lines starting with `#` are comments, blank lines are ignored. + +**Example** -- `release.txt` for submitting version 2.1.0 of a sample app: + +``` +# Release workflow for MyApp v2.1.0 + +# Create the new version on App Store Connect +apps create-version com.example.MyApp 2.1.0 + +# Build, validate, and upload +builds archive --scheme MyApp +builds validate --latest --bundle-id com.example.MyApp +builds upload --latest --bundle-id com.example.MyApp + +# Update localizations and attach the build +apps update-localizations com.example.MyApp --file localizations.json +apps attach-latest-build com.example.MyApp + +# Submit for review +apps submit-for-review com.example.MyApp +``` + +Without `--yes`, the workflow asks for confirmation before starting, and individual commands still prompt where they normally would (e.g., before submitting for review). With `--yes`, all prompts are skipped for fully unattended execution. + +### Automation + +Most commands that prompt for confirmation support `--yes` / `-y` to skip prompts, making them suitable for CI/CD pipelines and scripts: + +```bash +asc-client apps attach-latest-build --yes +asc-client apps submit-for-review --yes ``` ## Acknowledgments diff --git a/Sources/asc-client/ASCClient.swift b/Sources/asc-client/ASCClient.swift index 9dacb92..e44cf59 100644 --- a/Sources/asc-client/ASCClient.swift +++ b/Sources/asc-client/ASCClient.swift @@ -5,8 +5,8 @@ struct ASCClient: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "asc-client", abstract: "A command-line tool for the App Store Connect API.", - version: "0.1.0", - subcommands: [ConfigureCommand.self, AppsCommand.self, BuildsCommand.self, InstallCompletionsCommand.self] + version: "0.2.0", + subcommands: [ConfigureCommand.self, AppsCommand.self, BuildsCommand.self, RunWorkflowCommand.self, InstallCompletionsCommand.self] ) func run() async throws { diff --git a/Sources/asc-client/Commands/AppsCommand.swift b/Sources/asc-client/Commands/AppsCommand.swift index 2af347a..1af18eb 100644 --- a/Sources/asc-client/Commands/AppsCommand.swift +++ b/Sources/asc-client/Commands/AppsCommand.swift @@ -7,7 +7,13 @@ struct AppsCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "apps", abstract: "Manage apps.", - subcommands: [List.self, Info.self, Versions.self, Localizations.self, ReviewStatus.self, CreateVersion.self, SelectBuild.self, SubmitForReview.self, UpdateLocalization.self, UpdateLocalizations.self, ExportLocalizations.self, UploadMedia.self, DownloadMedia.self, VerifyMedia.self] + subcommands: [List.self, Info.self, Versions.self], + groupedSubcommands: [ + CommandGroup(name: "Version", subcommands: [CreateVersion.self, AttachBuild.self, AttachLatestBuild.self, DetachBuild.self]), + CommandGroup(name: "Localization", subcommands: [Localizations.self, ExportLocalizations.self, UpdateLocalization.self, UpdateLocalizations.self]), + CommandGroup(name: "Media", subcommands: [DownloadMedia.self, UploadMedia.self, VerifyMedia.self]), + CommandGroup(name: "Review", subcommands: [ReviewStatus.self, SubmitForReview.self]), + ] ) struct List: AsyncParsableCommand { @@ -254,10 +260,10 @@ struct AppsCommand: AsyncParsableCommand { } } - struct SelectBuild: AsyncParsableCommand { + struct AttachBuild: AsyncParsableCommand { static let configuration = CommandConfiguration( - commandName: "select-build", - abstract: "Attach a build to an App Store version." + commandName: "attach-build", + abstract: "Interactively select and attach a build to an App Store version." ) @Argument(help: "The bundle identifier of the app.") @@ -266,7 +272,11 @@ struct AppsCommand: AsyncParsableCommand { @Option(name: .long, help: "Version string (e.g. 2.1.0). Defaults to the latest version.") var version: String? + @Flag(name: .shortAndLong, help: "Skip confirmation prompts.") + var yes = false + func run() async throws { + if yes { autoConfirm = true } let client = try ClientFactory.makeClient() let app = try await findApp(bundleID: bundleID, client: client) let appVersion = try await findVersion(appID: app.id, versionString: version, client: client) @@ -275,7 +285,7 @@ struct AppsCommand: AsyncParsableCommand { print("Version: \(versionString)") print() - let build = try await selectBuild(appID: app.id, versionID: appVersion.id, client: client) + let build = try await selectBuild(appID: app.id, versionID: appVersion.id, versionString: versionString, client: client) let buildNumber = build.attributes?.version ?? "unknown" let uploaded = build.attributes?.uploadedDate.map { formatDate($0) } ?? "—" print() @@ -283,6 +293,125 @@ struct AppsCommand: AsyncParsableCommand { } } + struct AttachLatestBuild: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "attach-latest-build", + abstract: "Attach the most recent build to an App Store version." + ) + + @Argument(help: "The bundle identifier of the app.") + var bundleID: String + + @Option(name: .long, help: "Version string (e.g. 2.1.0). Defaults to the latest version.") + var version: String? + + @Flag(name: .shortAndLong, help: "Skip confirmation prompts.") + var yes = false + + func run() async throws { + if yes { autoConfirm = true } + let client = try ClientFactory.makeClient() + let app = try await findApp(bundleID: bundleID, client: client) + let appVersion = try await findVersion(appID: app.id, versionString: version, client: client) + + let versionString = appVersion.attributes?.versionString ?? "unknown" + + let buildsResponse = try await client.send( + Resources.v1.builds.get( + filterPreReleaseVersionVersion: [versionString], + filterApp: [app.id], + sort: [.minusUploadedDate], + limit: 1 + ) + ) + + guard let build = buildsResponse.data.first else { + throw ValidationError("No builds found for version \(versionString). Upload a build first via Xcode or Transporter.") + } + + let buildNumber = build.attributes?.version ?? "unknown" + let uploaded = build.attributes?.uploadedDate.map { formatDate($0) } ?? "—" + let state = build.attributes?.processingState.map { "\($0)" } ?? "—" + + print("Version: \(versionString)") + print("Build: \(buildNumber) \(state) \(uploaded)") + print() + + guard confirm("Attach this build? [y/N] ") else { + print("Cancelled.") + return + } + + try await client.send( + Resources.v1.appStoreVersions.id(appVersion.id).relationships.build.patch( + AppStoreVersionBuildLinkageRequest( + data: .init(id: build.id) + ) + ) + ) + + print() + print("Attached build \(buildNumber) (uploaded \(uploaded)) to version \(versionString).") + } + } + + struct DetachBuild: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "detach-build", + abstract: "Remove the attached build from an App Store version." + ) + + @Argument(help: "The bundle identifier of the app.") + var bundleID: String + + @Option(name: .long, help: "Version string (e.g. 2.1.0). Defaults to the latest version.") + var version: String? + + @Flag(name: .shortAndLong, help: "Skip confirmation prompts.") + var yes = false + + func run() async throws { + if yes { autoConfirm = true } + let client = try ClientFactory.makeClient() + let app = try await findApp(bundleID: bundleID, client: client) + let appVersion = try await findVersion(appID: app.id, versionString: version, client: client) + + let versionString = appVersion.attributes?.versionString ?? "unknown" + + // Check if a build is attached + guard let existingBuild: Build = try? await client.send( + Resources.v1.appStoreVersions.id(appVersion.id).build.get() + ).data, existingBuild.attributes?.version != nil else { + print("No build attached to version \(versionString).") + return + } + + let buildNumber = existingBuild.attributes?.version ?? "unknown" + let uploaded = existingBuild.attributes?.uploadedDate.map { formatDate($0) } ?? "—" + + print("Version: \(versionString)") + print("Build: \(buildNumber) (uploaded \(uploaded))") + print() + + guard confirm("Detach this build from version \(versionString)? [y/N] ") else { + print("Cancelled.") + return + } + + // The API uses PATCH with {"data": null} to detach a build. + // The typed AppStoreVersionBuildLinkageRequest requires non-null data, + // so we construct the request manually using Request. + let request = Request.patch( + "/v1/appStoreVersions/\(appVersion.id)/relationships/build", + body: NullRelationship() + ) + try await client.send(request) + + print() + print("Detached build \(buildNumber) from version \(versionString).") + } + } + struct SubmitForReview: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "submit-for-review", @@ -298,7 +427,11 @@ struct AppsCommand: AsyncParsableCommand { @Option(name: .long, help: "Platform: ios, macos, tvos, visionos (default: ios).") var platform: String = "ios" + @Flag(name: .shortAndLong, help: "Skip confirmation prompts.") + var yes = false + func run() async throws { + if yes { autoConfirm = true } let client = try ClientFactory.makeClient() let app = try await findApp(bundleID: bundleID, client: client) let appVersion = try await findVersion(appID: app.id, versionString: version, client: client) @@ -330,9 +463,7 @@ struct AppsCommand: AsyncParsableCommand { print("State: \(versionState)") print("Platform: \(platformValue)") print() - print("Submit this version for App Review? [y/N] ", terminator: "") - guard let answer = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), - answer == "y" || answer == "yes" else { + guard confirm("Submit this version for App Review? [y/N] ") else { print("Cancelled.") return } @@ -344,14 +475,12 @@ struct AppsCommand: AsyncParsableCommand { print() print("No build attached to this version. Select a build first:") print() - let selected = try await selectBuild(appID: app.id, versionID: appVersion.id, client: client) + let selected = try await selectBuild(appID: app.id, versionID: appVersion.id, versionString: versionString, client: client) let buildNumber = selected.attributes?.version ?? "unknown" print() print("Build \(buildNumber) attached. Continuing with submission...") print() - print("Submit this version for App Review? [y/N] ", terminator: "") - guard let answer = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), - answer == "y" || answer == "yes" else { + guard confirm("Submit this version for App Review? [y/N] ") else { print("Cancelled.") return } @@ -501,7 +630,11 @@ struct AppsCommand: AsyncParsableCommand { @Flag(name: .long, help: "Show full API response for each locale update.") var verbose = false + @Flag(name: .shortAndLong, help: "Skip confirmation prompts.") + var yes = false + func run() async throws { + if yes { autoConfirm = true } // Get file path from argument or prompt let filePath: String if let f = file { @@ -557,9 +690,7 @@ struct AppsCommand: AsyncParsableCommand { print() } - print("Send updates for \(localeUpdates.count) locale(s)? [y/N] ", terminator: "") - guard let answer = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), - answer == "y" || answer == "yes" else { + guard confirm("Send updates for \(localeUpdates.count) locale(s)? [y/N] ") else { print("Cancelled.") return } @@ -684,6 +815,15 @@ struct LocaleFields: Codable { var supportURL: String? } +/// Encodes as `{"data": null}` for clearing a to-one relationship. +private struct NullRelationship: Encodable, Sendable { + enum CodingKeys: String, CodingKey { case data } + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeNil(forKey: .data) + } +} + private func describeDecodingError(_ error: DecodingError) -> String { switch error { case .typeMismatch(let type, let context): @@ -725,12 +865,13 @@ func findVersion(appID: String, versionString: String?, client: AppStoreConnectC return version } -/// Fetches recent builds for the app, prompts the user to pick one, and attaches it to the version. +/// Fetches builds for the app matching the given version, prompts the user to pick one, and attaches it. /// Returns the selected build. @discardableResult -private func selectBuild(appID: String, versionID: String, client: AppStoreConnectClient) async throws -> Build { +private func selectBuild(appID: String, versionID: String, versionString: String?, client: AppStoreConnectClient) async throws -> Build { let buildsResponse = try await client.send( Resources.v1.builds.get( + filterPreReleaseVersionVersion: versionString.map { [$0] }, filterApp: [appID], sort: [.minusUploadedDate], limit: 10 @@ -739,10 +880,13 @@ private func selectBuild(appID: String, versionID: String, client: AppStoreConne let builds = buildsResponse.data guard !builds.isEmpty else { + if let v = versionString { + throw ValidationError("No builds found for version \(v). Upload a build first via Xcode or Transporter.") + } throw ValidationError("No builds found for this app. Upload a build first via Xcode or Transporter.") } - print("Recent builds:") + print("Builds for version \(versionString ?? "all"):") for (i, build) in builds.enumerated() { let number = build.attributes?.version ?? "—" let state = build.attributes?.processingState.map { "\($0)" } ?? "—" @@ -750,14 +894,21 @@ private func selectBuild(appID: String, versionID: String, client: AppStoreConne print(" [\(i + 1)] \(number) \(state) \(uploaded)") } print() - print("Select a build (1-\(builds.count)): ", terminator: "") - guard let input = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines), - let choice = Int(input), - choice >= 1, choice <= builds.count else { - throw ValidationError("Invalid selection.") - } - let selected = builds[choice - 1] + let selected: Build + if autoConfirm { + selected = builds[0] + let number = selected.attributes?.version ?? "—" + print("Auto-selected build \(number) (most recent).") + } else { + print("Select a build (1-\(builds.count)): ", terminator: "") + guard let input = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines), + let choice = Int(input), + choice >= 1, choice <= builds.count else { + throw ValidationError("Invalid selection.") + } + selected = builds[choice - 1] + } // Attach the build to the version try await client.send( diff --git a/Sources/asc-client/Commands/BuildsCommand.swift b/Sources/asc-client/Commands/BuildsCommand.swift index 6cf4576..4213f63 100644 --- a/Sources/asc-client/Commands/BuildsCommand.swift +++ b/Sources/asc-client/Commands/BuildsCommand.swift @@ -7,7 +7,7 @@ struct BuildsCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "builds", abstract: "Manage builds.", - subcommands: [List.self] + subcommands: [List.self, Archive.self, Upload.self, Validate.self] ) struct List: AsyncParsableCommand { @@ -51,4 +51,516 @@ struct BuildsCommand: AsyncParsableCommand { ) } } + + struct Archive: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Archive an Xcode project via xcodebuild." + ) + + @Option(name: .long, help: "Path to .xcodeproj (auto-detected if omitted).") + var project: String? + + @Option(name: .long, help: "Path to .xcworkspace (auto-detected if omitted).") + var workspace: String? + + @Option(name: .long, help: "Build scheme (auto-detected if only one exists).") + var scheme: String? + + @Option(name: .long, help: "Output directory for the .xcarchive.") + var output: String? + + @Option(name: .long, help: "Build configuration (default: Release).") + var configuration: String? + + @Option(name: .long, help: "Destination (default: generic/platform=iOS).") + var destination: String? + + @Flag(name: .shortAndLong, help: "Skip confirmation prompts.") + var yes = false + + func run() throws { + if yes { autoConfirm = true } + + try ensureXcodebuild() + + // 1. Detect project or workspace + let (buildFlag, buildPath) = try detectProject() + + // 2. Detect scheme + let schemeName = try detectScheme(buildFlag: buildFlag, buildPath: buildPath) + + // 3. Build archive arguments + var args = [ + "archive", + buildFlag, buildPath, + "-scheme", schemeName, + "-configuration", configuration ?? "Release", + "-destination", destination ?? "generic/platform=iOS", + ] + + var archivePath: String? + if let output { + let dir = expandPath(output) + let path = (dir as NSString).appendingPathComponent("\(schemeName).xcarchive") + let confirmed = confirmOutputPath(path, isDirectory: true) + let confirmedDir = (confirmed as NSString).deletingLastPathComponent + try FileManager.default.createDirectory(atPath: confirmedDir, withIntermediateDirectories: true) + args += ["-archivePath", confirmed] + archivePath = confirmed + } + + // 4. Run xcodebuild archive + print("Archiving scheme '\(schemeName)'...") + print() + fflush(stdout) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcodebuild") + process.arguments = args + process.standardOutput = FileHandle.standardOutput + process.standardError = FileHandle.standardError + + try process.run() + process.waitUntilExit() + + if process.terminationStatus != 0 { + throw ExitCode(process.terminationStatus) + } + + // 5. Print result + print() + if let archivePath { + print("Archive created at: \(archivePath)") + } else { + print("Archive complete. The .xcarchive is in Xcode's default archive location:") + print(" ~/Library/Developer/Xcode/Archives/") + } + } + + /// Finds a .xcworkspace or .xcodeproj in the current directory, or uses the explicit flag. + private func detectProject() throws -> (String, String) { + if let workspace { + return ("-workspace", expandPath(workspace)) + } + if let project { + return ("-project", expandPath(project)) + } + + let fm = FileManager.default + let cwd = fm.currentDirectoryPath + let contents = try fm.contentsOfDirectory(atPath: cwd) + + // Prefer workspace over project + if let ws = contents.first(where: { $0.hasSuffix(".xcworkspace") && !$0.hasPrefix(".") }) { + let path = (cwd as NSString).appendingPathComponent(ws) + print("Using workspace: \(ws)") + return ("-workspace", path) + } + if let proj = contents.first(where: { $0.hasSuffix(".xcodeproj") }) { + let path = (cwd as NSString).appendingPathComponent(proj) + print("Using project: \(proj)") + return ("-project", path) + } + + throw ValidationError( + "No .xcworkspace or .xcodeproj found in the current directory. Use --workspace or --project to specify one." + ) + } + + /// Runs `xcodebuild -list -json` to discover schemes. Uses --scheme if provided. + private func detectScheme(buildFlag: String, buildPath: String) throws -> String { + if let scheme { return scheme } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcodebuild") + process.arguments = ["-list", "-json", buildFlag, buildPath] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + throw ValidationError("Failed to list schemes. Check that the project/workspace is valid.") + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + + struct XcodeList: Decodable { + struct Project: Decodable { var schemes: [String]? } + struct Workspace: Decodable { var schemes: [String]? } + var project: Project? + var workspace: Workspace? + } + + let list = try JSONDecoder().decode(XcodeList.self, from: data) + let schemes = list.project?.schemes ?? list.workspace?.schemes ?? [] + + if schemes.isEmpty { + throw ValidationError("No schemes found in the project/workspace.") + } + if schemes.count == 1 { + let name = schemes[0] + print("Using scheme: \(name)") + return name + } + + let schemeList = schemes.map { " - \($0)" }.joined(separator: "\n") + throw ValidationError( + """ + Multiple schemes found. Use --scheme to specify one: + \(schemeList) + """) + } + } + + struct Upload: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Upload a build to App Store Connect via xcrun altool." + ) + + @Argument(help: "Path to the .ipa, .pkg, or .xcarchive file.") + var file: String? + + @Flag(name: .long, help: "Use the latest .xcarchive from Xcode's archive location.") + var latest = false + + @Option(name: .long, help: "Filter archives by exact bundle identifier (use with --latest).") + var bundleID: String? + + @Flag(name: .shortAndLong, help: "Skip confirmation prompts.") + var yes = false + + func run() throws { + if yes { autoConfirm = true } + try ensureAltool() + + if file != nil && latest { + throw ValidationError("Cannot specify both a file path and --latest.") + } + if bundleID != nil && !latest { + throw ValidationError("--bundle-id requires --latest.") + } + + let expandedPath: String + if latest { + let archive = try findLatestArchive(bundleID: bundleID) + expandedPath = archive.path + print("Found archive: \((archive.path as NSString).lastPathComponent)") + print(" Bundle ID: \(archive.bundleID)") + print(" Version: \(archive.version) (\(archive.buildNumber))") + print(" Created: \(formatDate(archive.creationDate))") + print() + guard confirm("Upload this archive? [y/N] ") else { return } + } else { + expandedPath = try resolveFilePath(file, prompt: "Path to .ipa, .pkg, or .xcarchive file: ") + } + + let config = try Config.load() + + // Export .xcarchive to .ipa if needed + let (uploadPath, tempDir) = try resolveUploadable(expandedPath) + defer { if let dir = tempDir { try? FileManager.default.removeItem(atPath: dir) } } + + print("Uploading \((uploadPath as NSString).lastPathComponent)...") + print() + fflush(stdout) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = [ + "altool", "--upload-app", + "-f", uploadPath, + "--apiKey", config.keyId, + "--apiIssuer", config.issuerId, + "--p8-file-path", config.privateKeyPath, + "--show-progress", + ] + process.standardOutput = FileHandle.standardOutput + process.standardError = FileHandle.standardError + + try process.run() + process.waitUntilExit() + + if process.terminationStatus != 0 { + throw ExitCode(process.terminationStatus) + } + } + } + + struct Validate: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Validate a build before uploading via xcrun altool." + ) + + @Argument(help: "Path to the .ipa, .pkg, or .xcarchive file.") + var file: String? + + @Flag(name: .long, help: "Use the latest .xcarchive from Xcode's archive location.") + var latest = false + + @Option(name: .long, help: "Filter archives by exact bundle identifier (use with --latest).") + var bundleID: String? + + @Flag(name: .shortAndLong, help: "Skip confirmation prompts.") + var yes = false + + func run() throws { + if yes { autoConfirm = true } + try ensureAltool() + + if file != nil && latest { + throw ValidationError("Cannot specify both a file path and --latest.") + } + if bundleID != nil && !latest { + throw ValidationError("--bundle-id requires --latest.") + } + + let expandedPath: String + if latest { + let archive = try findLatestArchive(bundleID: bundleID) + expandedPath = archive.path + print("Found archive: \((archive.path as NSString).lastPathComponent)") + print(" Bundle ID: \(archive.bundleID)") + print(" Version: \(archive.version) (\(archive.buildNumber))") + print(" Created: \(formatDate(archive.creationDate))") + print() + guard confirm("Validate this archive? [y/N] ") else { return } + } else { + expandedPath = try resolveFilePath(file, prompt: "Path to .ipa, .pkg, or .xcarchive file: ") + } + + let config = try Config.load() + + // Export .xcarchive to .ipa if needed + let (uploadPath, tempDir) = try resolveUploadable(expandedPath) + defer { if let dir = tempDir { try? FileManager.default.removeItem(atPath: dir) } } + + print("Validating \((uploadPath as NSString).lastPathComponent)...") + print() + fflush(stdout) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = [ + "altool", "--validate-app", + "-f", uploadPath, + "--apiKey", config.keyId, + "--apiIssuer", config.issuerId, + "--p8-file-path", config.privateKeyPath, + ] + process.standardOutput = FileHandle.standardOutput + process.standardError = FileHandle.standardError + + try process.run() + process.waitUntilExit() + + if process.terminationStatus != 0 { + throw ExitCode(process.terminationStatus) + } + } + } +} + +// MARK: - Helpers + +private func ensureXcodebuild() throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcodebuild") + process.arguments = ["-version"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + + try process.run() + process.waitUntilExit() + + if process.terminationStatus != 0 { + throw ValidationError( + """ + 'xcodebuild' is not available. This command requires Xcode to be installed. + 1. Install Xcode from the Mac App Store + 2. Run: sudo xcode-select --switch /Applications/Xcode.app + 3. Accept the license: sudo xcodebuild -license accept + """) + } +} + +private func ensureAltool() throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = ["--find", "altool"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + + try process.run() + process.waitUntilExit() + + if process.terminationStatus != 0 { + throw ValidationError( + """ + 'xcrun altool' is not available. This command requires Xcode to be installed. + 1. Install Xcode from the Mac App Store + 2. Run: sudo xcode-select --switch /Applications/Xcode.app + 3. Accept the license: sudo xcodebuild -license accept + """) + } +} + +private struct ArchiveInfo { + let path: String + let bundleID: String + let version: String + let buildNumber: String + let creationDate: Date +} + +/// Finds the most recent .xcarchive in Xcode's default archive location. +/// Reads each archive's Info.plist to extract bundle ID and creation date. +private func findLatestArchive(bundleID: String?) throws -> ArchiveInfo { + let archivesDir = expandPath("~/Library/Developer/Xcode/Archives") + let fm = FileManager.default + + guard fm.fileExists(atPath: archivesDir) else { + throw ValidationError("No Xcode archives found at ~/Library/Developer/Xcode/Archives/") + } + + let dateDirs = try fm.contentsOfDirectory(atPath: archivesDir) + var archives: [ArchiveInfo] = [] + + for dateDir in dateDirs { + let datePath = (archivesDir as NSString).appendingPathComponent(dateDir) + var isDir: ObjCBool = false + guard fm.fileExists(atPath: datePath, isDirectory: &isDir), isDir.boolValue else { continue } + + let items = (try? fm.contentsOfDirectory(atPath: datePath)) ?? [] + for item in items where item.hasSuffix(".xcarchive") { + let archivePath = (datePath as NSString).appendingPathComponent(item) + let infoPlistPath = (archivePath as NSString).appendingPathComponent("Info.plist") + + guard let data = fm.contents(atPath: infoPlistPath), + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], + let appProps = plist["ApplicationProperties"] as? [String: Any], + let archiveBundleID = appProps["CFBundleIdentifier"] as? String, + let creationDate = plist["CreationDate"] as? Date + else { continue } + + // Exact bundle ID match if filter is provided + if let bundleID, archiveBundleID != bundleID { continue } + + let version = appProps["CFBundleShortVersionString"] as? String ?? "—" + let build = appProps["CFBundleVersion"] as? String ?? "—" + + archives.append(ArchiveInfo( + path: archivePath, + bundleID: archiveBundleID, + version: version, + buildNumber: build, + creationDate: creationDate + )) + } + } + + archives.sort { $0.creationDate > $1.creationDate } + + guard let latest = archives.first else { + if let bundleID { + throw ValidationError("No archives found matching bundle ID '\(bundleID)'.") + } + throw ValidationError("No archives found in ~/Library/Developer/Xcode/Archives/") + } + + return latest +} + +private func resolveFilePath(_ file: String?, prompt: String) throws -> String { + let filePath: String + if let f = file { + filePath = f + } else { + print(prompt, terminator: "") + guard let line = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines), + !line.isEmpty + else { + throw ValidationError("No file path provided.") + } + filePath = line + } + + let expandedPath = expandPath(filePath) + guard FileManager.default.fileExists(atPath: expandedPath) else { + throw ValidationError("File not found at '\(expandedPath)'.") + } + + return expandedPath +} + +/// If the path is an .xcarchive, exports it to a temporary .ipa and returns that path. +/// Returns (uploadablePath, tempDirToCleanUp). tempDir is nil if no export was needed. +private func resolveUploadable(_ path: String) throws -> (String, String?) { + let ext = (path as NSString).pathExtension.lowercased() + + guard ext == "xcarchive" else { + return (path, nil) + } + + print("Exporting .xcarchive to .ipa...") + fflush(stdout) + + let tempDir = NSTemporaryDirectory() + "asc-client-export-\(ProcessInfo.processInfo.processIdentifier)" + let exportDir = tempDir + "/output" + let plistPath = tempDir + "/ExportOptions.plist" + + try FileManager.default.createDirectory(atPath: tempDir, withIntermediateDirectories: true) + + // Write ExportOptions.plist for App Store export with automatic signing + let plist = """ + + + + + method + app-store-connect + destination + export + signingStyle + automatic + + + """ + try plist.write(toFile: plistPath, atomically: true, encoding: .utf8) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcodebuild") + process.arguments = [ + "-exportArchive", + "-archivePath", path, + "-exportPath", exportDir, + "-exportOptionsPlist", plistPath, + ] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.standardError + + try process.run() + process.waitUntilExit() + + if process.terminationStatus != 0 { + try? FileManager.default.removeItem(atPath: tempDir) + throw ValidationError("Failed to export .xcarchive. Check that the archive is signed for App Store distribution.") + } + + // Find the exported .ipa + let contents = try FileManager.default.contentsOfDirectory(atPath: exportDir) + guard let ipaName = contents.first(where: { $0.hasSuffix(".ipa") }) else { + try? FileManager.default.removeItem(atPath: tempDir) + throw ValidationError("No .ipa found after exporting .xcarchive. The archive may be a macOS app — use .pkg instead.") + } + + let ipaPath = exportDir + "/" + ipaName + print("Exported \(ipaName)") + print() + fflush(stdout) + + return (ipaPath, tempDir) } diff --git a/Sources/asc-client/Commands/InstallCompletionsCommand.swift b/Sources/asc-client/Commands/InstallCompletionsCommand.swift index 5467b9f..dfdd15e 100644 --- a/Sources/asc-client/Commands/InstallCompletionsCommand.swift +++ b/Sources/asc-client/Commands/InstallCompletionsCommand.swift @@ -3,7 +3,7 @@ import Foundation struct InstallCompletionsCommand: ParsableCommand { static let configuration = CommandConfiguration( - commandName: "install-shell-completions", + commandName: "install-completions", abstract: "Install shell completions for asc-client." ) @@ -38,8 +38,11 @@ struct InstallCompletionsCommand: ParsableCommand { print("\(zfuncDir.path)/ already exists.") } - // 2. Write completion script - let completionScript = ASCClient.completionScript(for: .zsh) + // 2. Write completion script (with patched help completions and alphabetical sorting) + var completionScript = patchZshHelpCompletions(ASCClient.completionScript(for: .zsh)) + // Remove -V flag so zsh sorts subcommands alphabetically (reliable across environments) + completionScript = completionScript.replacingOccurrences( + of: "_describe -V subcommand subcommands", with: "_describe subcommand subcommands") let completionFile = zfuncDir.appendingPathComponent("_asc-client") try completionScript.write(to: completionFile, atomically: true, encoding: .utf8) print("Installed completion script to \(completionFile.path)") @@ -90,8 +93,8 @@ struct InstallCompletionsCommand: ParsableCommand { print("\(completionsDir.path)/ already exists.") } - // 2. Write completion script - let completionScript = ASCClient.completionScript(for: .bash) + // 2. Write completion script (with patched help completions) + let completionScript = patchBashHelpCompletions(ASCClient.completionScript(for: .bash)) let completionFile = completionsDir.appendingPathComponent("asc-client.bash") try completionScript.write(to: completionFile, atomically: true, encoding: .utf8) print("Installed completion script to \(completionFile.path)") @@ -120,6 +123,78 @@ struct InstallCompletionsCommand: ParsableCommand { ) } + /// Patches the zsh completion script so `asc-client help ` lists subcommands. + private func patchZshHelpCompletions(_ script: String) -> String { + let entries = ASCClient.configuration.subcommands + .map { sub in + let name = sub._commandName + let abstract = sub.configuration.abstract + return " '\(name):\(abstract)'" + } + .joined(separator: "\n") + + let broken = """ + _asc-client_help() { + local -i ret=1 + local -ar arg_specs=( + '*:subcommands:' + '--version[Show the version.]' + ) + _arguments -w -s -S : "${arg_specs[@]}" && ret=0 + + return "${ret}" + } + """ + + let fixed = """ + _asc-client_help() { + local -i ret=1 + local -ar arg_specs=( + '--version[Show the version.]' + ) + _arguments -w -s -S : "${arg_specs[@]}" && ret=0 + local -ar subcommands=( + \(entries) + ) + _describe -V subcommand subcommands && ret=0 + + return "${ret}" + } + """ + + return script.replacingOccurrences(of: broken, with: fixed) + } + + /// Patches the bash completion script so `asc-client help ` lists subcommands. + private func patchBashHelpCompletions(_ script: String) -> String { + let subcommands = ASCClient.configuration.subcommands + .map { $0._commandName } + .joined(separator: " ") + + let broken = """ + _asc-client_help() { + repeating_flags=() + non_repeating_flags=(--version) + repeating_options=() + non_repeating_options=() + __asc-client_offer_flags_options -1 + } + """ + + let fixed = """ + _asc-client_help() { + repeating_flags=() + non_repeating_flags=(--version) + repeating_options=() + non_repeating_options=() + __asc-client_offer_flags_options -1 + COMPREPLY+=($(compgen -W '\(subcommands)' -- "${cur}")) + } + """ + + return script.replacingOccurrences(of: broken, with: fixed) + } + private func ensureSourceLine(rcFile: URL, sourceLine: String, fm: FileManager) throws { if fm.fileExists(atPath: rcFile.path) { let contents = try String(contentsOf: rcFile, encoding: .utf8) diff --git a/Sources/asc-client/Commands/RunWorkflowCommand.swift b/Sources/asc-client/Commands/RunWorkflowCommand.swift new file mode 100644 index 0000000..3bc8ed3 --- /dev/null +++ b/Sources/asc-client/Commands/RunWorkflowCommand.swift @@ -0,0 +1,120 @@ +import ArgumentParser +import Foundation + +/// Tracks workflow files currently being executed to detect circular references. +nonisolated(unsafe) private var activeWorkflows: [String] = [] + +struct RunWorkflowCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "run-workflow", + abstract: "Run a sequence of asc-client commands from a workflow file." + ) + + @Argument(help: "Path to the workflow file.") + var file: String + + @Flag(name: .shortAndLong, help: "Skip confirmation prompts.") + var yes = false + + func run() async throws { + let path = expandPath(file) + + // Resolve to absolute path for reliable cycle detection + let resolvedPath: String + if path.hasPrefix("/") { + resolvedPath = path + } else { + resolvedPath = FileManager.default.currentDirectoryPath + "/" + path + } + + if activeWorkflows.contains(resolvedPath) { + throw ValidationError("Circular workflow detected: '\((resolvedPath as NSString).lastPathComponent)' is already running.") + } + + let contents: String + do { + contents = try String(contentsOfFile: path, encoding: .utf8) + } catch { + throw ValidationError("Cannot read workflow file: \(path)") + } + + let steps = contents + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty && !$0.hasPrefix("#") } + + guard !steps.isEmpty else { + throw ValidationError("Workflow file has no commands.") + } + + let filename = (path as NSString).lastPathComponent + print("Workflow: \(filename) (\(steps.count) \(steps.count == 1 ? "step" : "steps"))") + for (i, step) in steps.enumerated() { + print(" \(i + 1). \(step)") + } + print() + + if yes { autoConfirm = true } + + if !confirm("Run this workflow? [y/N] ") { + print("Aborted.") + throw ExitCode.failure + } + + print() + + activeWorkflows.append(resolvedPath) + defer { activeWorkflows.removeAll { $0 == resolvedPath } } + + for (i, step) in steps.enumerated() { + let label = "[\(i + 1)/\(steps.count)]" + print("\(label) \(step)") + + let args = splitArguments(step) + do { + var command = try ASCClient.parseAsRoot(args) + if var async = command as? AsyncParsableCommand { + try await async.run() + } else { + try command.run() + } + } catch { + print("\nError: \(error.localizedDescription)") + print("\nWorkflow stopped at step \(i + 1) of \(steps.count).") + throw ExitCode.failure + } + + print() + } + + print("Workflow complete. All \(steps.count) \(steps.count == 1 ? "step" : "steps") succeeded.") + } +} + +/// Splits a command string into arguments, respecting single and double quotes. +private func splitArguments(_ line: String) -> [String] { + var args: [String] = [] + var current = "" + var inSingle = false + var inDouble = false + + for char in line { + if char == "'" && !inDouble { + inSingle.toggle() + } else if char == "\"" && !inSingle { + inDouble.toggle() + } else if char == " " && !inSingle && !inDouble { + if !current.isEmpty { + args.append(current) + current = "" + } + } else { + current.append(char) + } + } + if !current.isEmpty { + args.append(current) + } + + return args +} diff --git a/Sources/asc-client/Formatting.swift b/Sources/asc-client/Formatting.swift index f5cf1cb..544ab6d 100644 --- a/Sources/asc-client/Formatting.swift +++ b/Sources/asc-client/Formatting.swift @@ -1,11 +1,48 @@ import Foundation -func expandPath(_ path: String) -> String { - if path.hasPrefix("~/") { - return FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(String(path.dropFirst(2))).path +/// When true, all interactive confirmation prompts are automatically accepted. +nonisolated(unsafe) var autoConfirm = false + +/// Prints a [y/N] prompt and returns true if the user (or --yes flag) confirms. +func confirm(_ prompt: String) -> Bool { + print(prompt, terminator: "") + if autoConfirm { + print("y (auto)") + return true } - return path + guard let answer = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + answer == "y" || answer == "yes" + else { + return false + } + return true +} + +/// Cleans up a path from interactive input (e.g. drag-drop into Terminal). +/// Strips surrounding quotes and removes backslash escapes. +func sanitizePath(_ path: String) -> String { + var result = path.trimmingCharacters(in: .whitespacesAndNewlines) + + // Strip surrounding quotes + if (result.hasPrefix("'") && result.hasSuffix("'")) + || (result.hasPrefix("\"") && result.hasSuffix("\"")) + { + result = String(result.dropFirst().dropLast()) + } + + // Remove backslash escapes (e.g. "\ " -> " ", "\~" -> "~") + result = result.replacingOccurrences(of: "\\", with: "") + + return result +} + +func expandPath(_ path: String) -> String { + let cleaned = sanitizePath(path) + if cleaned.hasPrefix("~/") { + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(String(cleaned.dropFirst(2))).path + } + return cleaned } func formatDate(_ date: Date) -> String { @@ -27,6 +64,12 @@ func confirmOutputPath(_ path: String, isDirectory: Bool) -> String { if !exists { return current } + if autoConfirm { + let kind = isDir.boolValue ? "Folder" : "File" + print("\(kind) '\(current)' already exists. Overwriting. (auto)") + return current + } + let kind = isDir.boolValue ? "Folder" : "File" print("\(kind) '\(current)' already exists. Press Enter to overwrite or type a new name:") print("> ", terminator: "") diff --git a/Sources/asc-client/MediaUpload.swift b/Sources/asc-client/MediaUpload.swift index 15ce2aa..50ab768 100644 --- a/Sources/asc-client/MediaUpload.swift +++ b/Sources/asc-client/MediaUpload.swift @@ -278,7 +278,11 @@ extension AppsCommand { @Flag(name: .long, help: "Delete existing media in matching sets before uploading.") var replace = false + @Flag(name: .shortAndLong, help: "Skip confirmation prompts.") + var yes = false + func run() async throws { + if yes { autoConfirm = true } // Get folder path let folderPath: String if let f = folder { @@ -339,12 +343,8 @@ extension AppsCommand { print() let localeCount = plan.locales.count - print( - "Upload \(plan.totalScreenshots) screenshot\(plan.totalScreenshots == 1 ? "" : "s") and \(plan.totalPreviews) preview\(plan.totalPreviews == 1 ? "" : "s") for \(localeCount) locale\(localeCount == 1 ? "" : "s")? [y/N] ", - terminator: "") - guard - let answer = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), - answer == "y" || answer == "yes" + guard confirm( + "Upload \(plan.totalScreenshots) screenshot\(plan.totalScreenshots == 1 ? "" : "s") and \(plan.totalPreviews) preview\(plan.totalPreviews == 1 ? "" : "s") for \(localeCount) locale\(localeCount == 1 ? "" : "s")? [y/N] ") else { print("Cancelled.") return @@ -805,7 +805,11 @@ extension AppsCommand { @Option(name: .long, help: "Path to the media folder for retrying stuck uploads.") var folder: String? + @Flag(name: .shortAndLong, help: "Skip confirmation prompts.") + var yes = false + func run() async throws { + if yes { autoConfirm = true } let client = try ClientFactory.makeClient() let app = try await findApp(bundleID: bundleID, client: client) let appVersion = try await findVersion( @@ -871,11 +875,7 @@ extension AppsCommand { } print() - print("Retry \(matchedRetries.count) stuck item\(matchedRetries.count == 1 ? "" : "s")? [y/N] ", terminator: "") - guard - let answer = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), - answer == "y" || answer == "yes" - else { + guard confirm("Retry \(matchedRetries.count) stuck item\(matchedRetries.count == 1 ? "" : "s")? [y/N] ") else { print("Cancelled.") return }