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
This commit is contained in:
26
CLAUDE.md
26
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 <bundle-id> [--folder X] [--version X] [--replace]
|
||||
asc-client apps download-media <bundle-id> [--folder X] [--version X] # Download screenshots/previews
|
||||
asc-client apps verify-media <bundle-id> [--version X] [--folder X] # Check media status, retry stuck
|
||||
asc-client builds list [--bundle-id <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 <file> [--yes] # Run commands from a workflow file
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
@@ -87,10 +92,29 @@ asc-client builds list [--bundle-id <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.
|
||||
|
||||
77
README.md
77
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 <bundle-id> 2.1.0 --platform ios --release-type m
|
||||
|
||||
# Check review submission status
|
||||
asc-client apps review-status <bundle-id>
|
||||
|
||||
# Submit for review
|
||||
asc-client apps submit-for-review <bundle-id>
|
||||
asc-client apps submit-for-review <bundle-id> --version 2.1.0
|
||||
```
|
||||
|
||||
### Build Management
|
||||
|
||||
```bash
|
||||
# Interactively select and attach a build to a version
|
||||
asc-client apps attach-build <bundle-id>
|
||||
asc-client apps attach-build <bundle-id> --version 2.1.0
|
||||
|
||||
# Attach the most recent build automatically
|
||||
asc-client apps attach-latest-build <bundle-id>
|
||||
|
||||
# Remove the attached build from a version
|
||||
asc-client apps detach-build <bundle-id>
|
||||
```
|
||||
|
||||
### 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 <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 <bundle-id> --yes
|
||||
asc-client apps submit-for-review <bundle-id> --yes
|
||||
```
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Void>.
|
||||
let request = Request<Void>.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()
|
||||
|
||||
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.")
|
||||
}
|
||||
|
||||
let selected = builds[choice - 1]
|
||||
selected = builds[choice - 1]
|
||||
}
|
||||
|
||||
// Attach the build to the version
|
||||
try await client.send(
|
||||
|
||||
@@ -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 = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>app-store-connect</string>
|
||||
<key>destination</key>
|
||||
<string>export</string>
|
||||
<key>signingStyle</key>
|
||||
<string>automatic</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 <tab>` 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 <tab>` 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)
|
||||
|
||||
120
Sources/asc-client/Commands/RunWorkflowCommand.swift
Normal file
120
Sources/asc-client/Commands/RunWorkflowCommand.swift
Normal file
@@ -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
|
||||
}
|
||||
@@ -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: "")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user