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
|
ConfigureCommand.swift # Interactive credential setup, file permissions
|
||||||
AppsCommand.swift # All app subcommands + findApp/findVersion helpers
|
AppsCommand.swift # All app subcommands + findApp/findVersion helpers
|
||||||
BuildsCommand.swift # Build subcommands
|
BuildsCommand.swift # Build subcommands
|
||||||
|
RunWorkflowCommand.swift # Sequential command runner from workflow files
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dependencies
|
## 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 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 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 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
|
## Key Patterns
|
||||||
@@ -87,10 +92,29 @@ asc-client builds list [--bundle-id <id>] # List builds
|
|||||||
### Adding a new subcommand
|
### Adding a new subcommand
|
||||||
1. Add the command struct inside `AppsCommand` (or create a new command group)
|
1. Add the command struct inside `AppsCommand` (or create a new command group)
|
||||||
2. Use `AsyncParsableCommand` for commands that call the API
|
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
|
4. Use `findApp(bundleID:client:)` to resolve bundle ID to app ID
|
||||||
5. Use `findVersion(appID:versionString:client:)` to resolve version (nil = latest)
|
5. Use `findVersion(appID:versionString:client:)` to resolve version (nil = latest)
|
||||||
6. Use shared `formatDate()` and `expandPath()` from Formatting.swift
|
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
|
### 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.
|
- **`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):
|
Set up tab completion for subcommands, options, and flags (supports zsh and bash):
|
||||||
|
|
||||||
```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.
|
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
|
# Check review submission status
|
||||||
asc-client apps review-status <bundle-id>
|
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
|
### Localizations
|
||||||
@@ -238,9 +256,62 @@ Without `--folder`, the command shows a read-only status report. Sets where all
|
|||||||
```bash
|
```bash
|
||||||
# List all builds
|
# List all builds
|
||||||
asc-client builds list
|
asc-client builds list
|
||||||
|
|
||||||
# Filter by app
|
|
||||||
asc-client builds list --bundle-id <bundle-id>
|
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
|
## Acknowledgments
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ struct ASCClient: AsyncParsableCommand {
|
|||||||
static let configuration = CommandConfiguration(
|
static let configuration = CommandConfiguration(
|
||||||
commandName: "asc-client",
|
commandName: "asc-client",
|
||||||
abstract: "A command-line tool for the App Store Connect API.",
|
abstract: "A command-line tool for the App Store Connect API.",
|
||||||
version: "0.1.0",
|
version: "0.2.0",
|
||||||
subcommands: [ConfigureCommand.self, AppsCommand.self, BuildsCommand.self, InstallCompletionsCommand.self]
|
subcommands: [ConfigureCommand.self, AppsCommand.self, BuildsCommand.self, RunWorkflowCommand.self, InstallCompletionsCommand.self]
|
||||||
)
|
)
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ struct AppsCommand: AsyncParsableCommand {
|
|||||||
static let configuration = CommandConfiguration(
|
static let configuration = CommandConfiguration(
|
||||||
commandName: "apps",
|
commandName: "apps",
|
||||||
abstract: "Manage 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 {
|
struct List: AsyncParsableCommand {
|
||||||
@@ -254,10 +260,10 @@ struct AppsCommand: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SelectBuild: AsyncParsableCommand {
|
struct AttachBuild: AsyncParsableCommand {
|
||||||
static let configuration = CommandConfiguration(
|
static let configuration = CommandConfiguration(
|
||||||
commandName: "select-build",
|
commandName: "attach-build",
|
||||||
abstract: "Attach a build to an App Store version."
|
abstract: "Interactively select and attach a build to an App Store version."
|
||||||
)
|
)
|
||||||
|
|
||||||
@Argument(help: "The bundle identifier of the app.")
|
@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.")
|
@Option(name: .long, help: "Version string (e.g. 2.1.0). Defaults to the latest version.")
|
||||||
var version: String?
|
var version: String?
|
||||||
|
|
||||||
|
@Flag(name: .shortAndLong, help: "Skip confirmation prompts.")
|
||||||
|
var yes = false
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
|
if yes { autoConfirm = true }
|
||||||
let client = try ClientFactory.makeClient()
|
let client = try ClientFactory.makeClient()
|
||||||
let app = try await findApp(bundleID: bundleID, client: client)
|
let app = try await findApp(bundleID: bundleID, client: client)
|
||||||
let appVersion = try await findVersion(appID: app.id, versionString: version, 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("Version: \(versionString)")
|
||||||
print()
|
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 buildNumber = build.attributes?.version ?? "unknown"
|
||||||
let uploaded = build.attributes?.uploadedDate.map { formatDate($0) } ?? "—"
|
let uploaded = build.attributes?.uploadedDate.map { formatDate($0) } ?? "—"
|
||||||
print()
|
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 {
|
struct SubmitForReview: AsyncParsableCommand {
|
||||||
static let configuration = CommandConfiguration(
|
static let configuration = CommandConfiguration(
|
||||||
commandName: "submit-for-review",
|
commandName: "submit-for-review",
|
||||||
@@ -298,7 +427,11 @@ struct AppsCommand: AsyncParsableCommand {
|
|||||||
@Option(name: .long, help: "Platform: ios, macos, tvos, visionos (default: ios).")
|
@Option(name: .long, help: "Platform: ios, macos, tvos, visionos (default: ios).")
|
||||||
var platform: String = "ios"
|
var platform: String = "ios"
|
||||||
|
|
||||||
|
@Flag(name: .shortAndLong, help: "Skip confirmation prompts.")
|
||||||
|
var yes = false
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
|
if yes { autoConfirm = true }
|
||||||
let client = try ClientFactory.makeClient()
|
let client = try ClientFactory.makeClient()
|
||||||
let app = try await findApp(bundleID: bundleID, client: client)
|
let app = try await findApp(bundleID: bundleID, client: client)
|
||||||
let appVersion = try await findVersion(appID: app.id, versionString: version, 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("State: \(versionState)")
|
||||||
print("Platform: \(platformValue)")
|
print("Platform: \(platformValue)")
|
||||||
print()
|
print()
|
||||||
print("Submit this version for App Review? [y/N] ", terminator: "")
|
guard confirm("Submit this version for App Review? [y/N] ") else {
|
||||||
guard let answer = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
|
|
||||||
answer == "y" || answer == "yes" else {
|
|
||||||
print("Cancelled.")
|
print("Cancelled.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -344,14 +475,12 @@ struct AppsCommand: AsyncParsableCommand {
|
|||||||
print()
|
print()
|
||||||
print("No build attached to this version. Select a build first:")
|
print("No build attached to this version. Select a build first:")
|
||||||
print()
|
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"
|
let buildNumber = selected.attributes?.version ?? "unknown"
|
||||||
print()
|
print()
|
||||||
print("Build \(buildNumber) attached. Continuing with submission...")
|
print("Build \(buildNumber) attached. Continuing with submission...")
|
||||||
print()
|
print()
|
||||||
print("Submit this version for App Review? [y/N] ", terminator: "")
|
guard confirm("Submit this version for App Review? [y/N] ") else {
|
||||||
guard let answer = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
|
|
||||||
answer == "y" || answer == "yes" else {
|
|
||||||
print("Cancelled.")
|
print("Cancelled.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -501,7 +630,11 @@ struct AppsCommand: AsyncParsableCommand {
|
|||||||
@Flag(name: .long, help: "Show full API response for each locale update.")
|
@Flag(name: .long, help: "Show full API response for each locale update.")
|
||||||
var verbose = false
|
var verbose = false
|
||||||
|
|
||||||
|
@Flag(name: .shortAndLong, help: "Skip confirmation prompts.")
|
||||||
|
var yes = false
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
|
if yes { autoConfirm = true }
|
||||||
// Get file path from argument or prompt
|
// Get file path from argument or prompt
|
||||||
let filePath: String
|
let filePath: String
|
||||||
if let f = file {
|
if let f = file {
|
||||||
@@ -557,9 +690,7 @@ struct AppsCommand: AsyncParsableCommand {
|
|||||||
print()
|
print()
|
||||||
}
|
}
|
||||||
|
|
||||||
print("Send updates for \(localeUpdates.count) locale(s)? [y/N] ", terminator: "")
|
guard confirm("Send updates for \(localeUpdates.count) locale(s)? [y/N] ") else {
|
||||||
guard let answer = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
|
|
||||||
answer == "y" || answer == "yes" else {
|
|
||||||
print("Cancelled.")
|
print("Cancelled.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -684,6 +815,15 @@ struct LocaleFields: Codable {
|
|||||||
var supportURL: String?
|
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 {
|
private func describeDecodingError(_ error: DecodingError) -> String {
|
||||||
switch error {
|
switch error {
|
||||||
case .typeMismatch(let type, let context):
|
case .typeMismatch(let type, let context):
|
||||||
@@ -725,12 +865,13 @@ func findVersion(appID: String, versionString: String?, client: AppStoreConnectC
|
|||||||
return version
|
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.
|
/// Returns the selected build.
|
||||||
@discardableResult
|
@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(
|
let buildsResponse = try await client.send(
|
||||||
Resources.v1.builds.get(
|
Resources.v1.builds.get(
|
||||||
|
filterPreReleaseVersionVersion: versionString.map { [$0] },
|
||||||
filterApp: [appID],
|
filterApp: [appID],
|
||||||
sort: [.minusUploadedDate],
|
sort: [.minusUploadedDate],
|
||||||
limit: 10
|
limit: 10
|
||||||
@@ -739,10 +880,13 @@ private func selectBuild(appID: String, versionID: String, client: AppStoreConne
|
|||||||
|
|
||||||
let builds = buildsResponse.data
|
let builds = buildsResponse.data
|
||||||
guard !builds.isEmpty else {
|
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.")
|
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() {
|
for (i, build) in builds.enumerated() {
|
||||||
let number = build.attributes?.version ?? "—"
|
let number = build.attributes?.version ?? "—"
|
||||||
let state = build.attributes?.processingState.map { "\($0)" } ?? "—"
|
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(" [\(i + 1)] \(number) \(state) \(uploaded)")
|
||||||
}
|
}
|
||||||
print()
|
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
|
// Attach the build to the version
|
||||||
try await client.send(
|
try await client.send(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ struct BuildsCommand: AsyncParsableCommand {
|
|||||||
static let configuration = CommandConfiguration(
|
static let configuration = CommandConfiguration(
|
||||||
commandName: "builds",
|
commandName: "builds",
|
||||||
abstract: "Manage builds.",
|
abstract: "Manage builds.",
|
||||||
subcommands: [List.self]
|
subcommands: [List.self, Archive.self, Upload.self, Validate.self]
|
||||||
)
|
)
|
||||||
|
|
||||||
struct List: AsyncParsableCommand {
|
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 {
|
struct InstallCompletionsCommand: ParsableCommand {
|
||||||
static let configuration = CommandConfiguration(
|
static let configuration = CommandConfiguration(
|
||||||
commandName: "install-shell-completions",
|
commandName: "install-completions",
|
||||||
abstract: "Install shell completions for asc-client."
|
abstract: "Install shell completions for asc-client."
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,8 +38,11 @@ struct InstallCompletionsCommand: ParsableCommand {
|
|||||||
print("\(zfuncDir.path)/ already exists.")
|
print("\(zfuncDir.path)/ already exists.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Write completion script
|
// 2. Write completion script (with patched help completions and alphabetical sorting)
|
||||||
let completionScript = ASCClient.completionScript(for: .zsh)
|
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")
|
let completionFile = zfuncDir.appendingPathComponent("_asc-client")
|
||||||
try completionScript.write(to: completionFile, atomically: true, encoding: .utf8)
|
try completionScript.write(to: completionFile, atomically: true, encoding: .utf8)
|
||||||
print("Installed completion script to \(completionFile.path)")
|
print("Installed completion script to \(completionFile.path)")
|
||||||
@@ -90,8 +93,8 @@ struct InstallCompletionsCommand: ParsableCommand {
|
|||||||
print("\(completionsDir.path)/ already exists.")
|
print("\(completionsDir.path)/ already exists.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Write completion script
|
// 2. Write completion script (with patched help completions)
|
||||||
let completionScript = ASCClient.completionScript(for: .bash)
|
let completionScript = patchBashHelpCompletions(ASCClient.completionScript(for: .bash))
|
||||||
let completionFile = completionsDir.appendingPathComponent("asc-client.bash")
|
let completionFile = completionsDir.appendingPathComponent("asc-client.bash")
|
||||||
try completionScript.write(to: completionFile, atomically: true, encoding: .utf8)
|
try completionScript.write(to: completionFile, atomically: true, encoding: .utf8)
|
||||||
print("Installed completion script to \(completionFile.path)")
|
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 {
|
private func ensureSourceLine(rcFile: URL, sourceLine: String, fm: FileManager) throws {
|
||||||
if fm.fileExists(atPath: rcFile.path) {
|
if fm.fileExists(atPath: rcFile.path) {
|
||||||
let contents = try String(contentsOf: rcFile, encoding: .utf8)
|
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
|
import Foundation
|
||||||
|
|
||||||
func expandPath(_ path: String) -> String {
|
/// When true, all interactive confirmation prompts are automatically accepted.
|
||||||
if path.hasPrefix("~/") {
|
nonisolated(unsafe) var autoConfirm = false
|
||||||
return FileManager.default.homeDirectoryForCurrentUser
|
|
||||||
.appendingPathComponent(String(path.dropFirst(2))).path
|
/// 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 {
|
func formatDate(_ date: Date) -> String {
|
||||||
@@ -27,6 +64,12 @@ func confirmOutputPath(_ path: String, isDirectory: Bool) -> String {
|
|||||||
|
|
||||||
if !exists { return current }
|
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"
|
let kind = isDir.boolValue ? "Folder" : "File"
|
||||||
print("\(kind) '\(current)' already exists. Press Enter to overwrite or type a new name:")
|
print("\(kind) '\(current)' already exists. Press Enter to overwrite or type a new name:")
|
||||||
print("> ", terminator: "")
|
print("> ", terminator: "")
|
||||||
|
|||||||
@@ -278,7 +278,11 @@ extension AppsCommand {
|
|||||||
@Flag(name: .long, help: "Delete existing media in matching sets before uploading.")
|
@Flag(name: .long, help: "Delete existing media in matching sets before uploading.")
|
||||||
var replace = false
|
var replace = false
|
||||||
|
|
||||||
|
@Flag(name: .shortAndLong, help: "Skip confirmation prompts.")
|
||||||
|
var yes = false
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
|
if yes { autoConfirm = true }
|
||||||
// Get folder path
|
// Get folder path
|
||||||
let folderPath: String
|
let folderPath: String
|
||||||
if let f = folder {
|
if let f = folder {
|
||||||
@@ -339,12 +343,8 @@ extension AppsCommand {
|
|||||||
print()
|
print()
|
||||||
|
|
||||||
let localeCount = plan.locales.count
|
let localeCount = plan.locales.count
|
||||||
print(
|
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] ",
|
"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"
|
|
||||||
else {
|
else {
|
||||||
print("Cancelled.")
|
print("Cancelled.")
|
||||||
return
|
return
|
||||||
@@ -805,7 +805,11 @@ extension AppsCommand {
|
|||||||
@Option(name: .long, help: "Path to the media folder for retrying stuck uploads.")
|
@Option(name: .long, help: "Path to the media folder for retrying stuck uploads.")
|
||||||
var folder: String?
|
var folder: String?
|
||||||
|
|
||||||
|
@Flag(name: .shortAndLong, help: "Skip confirmation prompts.")
|
||||||
|
var yes = false
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
|
if yes { autoConfirm = true }
|
||||||
let client = try ClientFactory.makeClient()
|
let client = try ClientFactory.makeClient()
|
||||||
let app = try await findApp(bundleID: bundleID, client: client)
|
let app = try await findApp(bundleID: bundleID, client: client)
|
||||||
let appVersion = try await findVersion(
|
let appVersion = try await findVersion(
|
||||||
@@ -871,11 +875,7 @@ extension AppsCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("Retry \(matchedRetries.count) stuck item\(matchedRetries.count == 1 ? "" : "s")? [y/N] ", terminator: "")
|
guard confirm("Retry \(matchedRetries.count) stuck item\(matchedRetries.count == 1 ? "" : "s")? [y/N] ") else {
|
||||||
guard
|
|
||||||
let answer = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
|
|
||||||
answer == "y" || answer == "yes"
|
|
||||||
else {
|
|
||||||
print("Cancelled.")
|
print("Cancelled.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user