diff --git a/.gitignore b/.gitignore index a20f983..d637da1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,19 @@ +# General .DS_Store + +# Development /.build /Packages -/.claude *.xcodeproj xcuserdata/ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc + +# Claude Code +/.claude +.claude/ +**/CLAUDE.md +!/CLAUDE.md + diff --git a/README.md b/README.md index fab7654..fb4b020 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,37 @@ A command-line tool for the [App Store Connect API](https://developer.apple.com/ ## Requirements - macOS 13+ -- Swift 6.0+ +- Swift 6.0+ (only for building from source) ## Installation +### Homebrew + +```bash +brew tap keremerkan/tap +brew install asc-client +``` + +The tap provides a pre-built binary for Apple Silicon Macs, so installation is instant. + +### Download the binary + +Download the latest release from [GitHub Releases](https://github.com/keremerkan/asc-client/releases): + +```bash +curl -L https://github.com/keremerkan/asc-client/releases/latest/download/asc-client-macos-arm64.tar.gz -o asc-client.tar.gz +tar xzf asc-client.tar.gz +mv asc-client /usr/local/bin/ +``` + +Since the binary is not signed or notarized, macOS will quarantine it on first download. Remove the quarantine attribute: + +```bash +xattr -d com.apple.quarantine /usr/local/bin/asc-client +``` + +> **Note:** Pre-built binaries are provided for Apple Silicon (arm64) only. Intel Mac users should build from source. + ### Build from source ```bash @@ -21,30 +48,17 @@ strip .build/release/asc-client cp .build/release/asc-client /usr/local/bin/ ``` -> **Note:** The release build takes a few minutes due to ~2500 generated API files. `strip` removes debug symbols, reducing the binary from ~175 MB to ~59 MB. +> **Note:** The release build takes a few minutes because the [asc-swift](https://github.com/aaronsky/asc-swift) dependency includes ~2500 generated source files covering the entire App Store Connect API surface. `strip` removes debug symbols, reducing the binary from ~175 MB to ~59 MB. ### Shell completions -Enable tab completion for subcommands, options, and flags: +Set up tab completion for subcommands, options, and flags (supports zsh and bash): -**zsh** (default on macOS): ```bash -mkdir -p ~/.zfunc -asc-client --generate-completion-script zsh > ~/.zfunc/_asc-client +asc-client install-shell-completions ``` -Add this to your `~/.zshrc` if not already present: -```bash -fpath=(~/.zfunc $fpath) -autoload -Uz compinit && compinit -``` - -**bash**: -```bash -asc-client --generate-completion-script bash > /usr/local/etc/bash_completion.d/asc-client -``` - -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. ## Setup diff --git a/Sources/asc-client/ASCClient.swift b/Sources/asc-client/ASCClient.swift index 5d3e1f7..9dacb92 100644 --- a/Sources/asc-client/ASCClient.swift +++ b/Sources/asc-client/ASCClient.swift @@ -6,6 +6,11 @@ struct ASCClient: AsyncParsableCommand { 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] + subcommands: [ConfigureCommand.self, AppsCommand.self, BuildsCommand.self, InstallCompletionsCommand.self] ) + + func run() async throws { + print("asc-client \(Self.configuration.version)") + print(Self.helpMessage()) + } } diff --git a/Sources/asc-client/Commands/InstallCompletionsCommand.swift b/Sources/asc-client/Commands/InstallCompletionsCommand.swift new file mode 100644 index 0000000..5467b9f --- /dev/null +++ b/Sources/asc-client/Commands/InstallCompletionsCommand.swift @@ -0,0 +1,138 @@ +import ArgumentParser +import Foundation + +struct InstallCompletionsCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "install-shell-completions", + abstract: "Install shell completions for asc-client." + ) + + func run() throws { + guard let shell = ProcessInfo.processInfo.environment["SHELL"] else { + throw ValidationError("Cannot detect shell. Set the SHELL environment variable.") + } + + let home = FileManager.default.homeDirectoryForCurrentUser + let fm = FileManager.default + + if shell.hasSuffix("/zsh") { + try installZsh(home: home, fm: fm) + } else if shell.hasSuffix("/bash") { + try installBash(home: home, fm: fm) + } else { + throw ValidationError( + "Only zsh and bash are supported. Detected: \(shell)") + } + + print() + print("Done. Restart your shell or run: source ~/.\(shell.hasSuffix("/zsh") ? "zshrc" : "bashrc")") + } + + private func installZsh(home: URL, fm: FileManager) throws { + // 1. Create ~/.zfunc if needed + let zfuncDir = home.appendingPathComponent(".zfunc") + if !fm.fileExists(atPath: zfuncDir.path) { + try fm.createDirectory(at: zfuncDir, withIntermediateDirectories: true) + print("Created \(zfuncDir.path)/") + } else { + print("\(zfuncDir.path)/ already exists.") + } + + // 2. Write completion script + let completionScript = ASCClient.completionScript(for: .zsh) + let completionFile = zfuncDir.appendingPathComponent("_asc-client") + try completionScript.write(to: completionFile, atomically: true, encoding: .utf8) + print("Installed completion script to \(completionFile.path)") + + // 3. Ensure ~/.zshrc_local has fpath and compinit + let zshrcLocal = home.appendingPathComponent(".zshrc_local") + var localContents = "" + if fm.fileExists(atPath: zshrcLocal.path) { + localContents = try String(contentsOf: zshrcLocal, encoding: .utf8) + } + + let fpathLine = "fpath=(~/.zfunc $fpath)" + let compinitLine = "autoload -Uz compinit && compinit" + var localModified = false + + if !localContents.contains(fpathLine) { + let block = "\n# asc-client completions\n\(fpathLine)\n\(compinitLine)\n" + localContents += block + localModified = true + } else if !localContents.contains(compinitLine) { + localContents = localContents.replacingOccurrences( + of: fpathLine, with: "\(fpathLine)\n\(compinitLine)") + localModified = true + } + + if localModified { + try localContents.write(to: zshrcLocal, atomically: true, encoding: .utf8) + print("Updated \(zshrcLocal.path)") + } else { + print("\(zshrcLocal.path) already configured.") + } + + // 4. Ensure ~/.zshrc sources ~/.zshrc_local + try ensureSourceLine( + rcFile: home.appendingPathComponent(".zshrc"), + sourceLine: "source ~/.zshrc_local", + fm: fm + ) + } + + private func installBash(home: URL, fm: FileManager) throws { + // 1. Create ~/.bash_completions if needed + let completionsDir = home.appendingPathComponent(".bash_completions") + if !fm.fileExists(atPath: completionsDir.path) { + try fm.createDirectory(at: completionsDir, withIntermediateDirectories: true) + print("Created \(completionsDir.path)/") + } else { + print("\(completionsDir.path)/ already exists.") + } + + // 2. Write completion script + let completionScript = 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)") + + // 3. Ensure ~/.bashrc_local sources the completion script + let bashrcLocal = home.appendingPathComponent(".bashrc_local") + var localContents = "" + if fm.fileExists(atPath: bashrcLocal.path) { + localContents = try String(contentsOf: bashrcLocal, encoding: .utf8) + } + + let sourceLine = "source ~/.bash_completions/asc-client.bash" + if !localContents.contains(sourceLine) { + localContents += "\n# asc-client completions\n\(sourceLine)\n" + try localContents.write(to: bashrcLocal, atomically: true, encoding: .utf8) + print("Updated \(bashrcLocal.path)") + } else { + print("\(bashrcLocal.path) already configured.") + } + + // 4. Ensure ~/.bashrc sources ~/.bashrc_local + try ensureSourceLine( + rcFile: home.appendingPathComponent(".bashrc"), + sourceLine: "[ -f ~/.bashrc_local ] && source ~/.bashrc_local", + fm: fm + ) + } + + private func ensureSourceLine(rcFile: URL, sourceLine: String, fm: FileManager) throws { + if fm.fileExists(atPath: rcFile.path) { + let contents = try String(contentsOf: rcFile, encoding: .utf8) + if !contents.contains(sourceLine) { + let newContents = sourceLine + "\n" + contents + try newContents.write(to: rcFile, atomically: true, encoding: .utf8) + print("Updated \(rcFile.path)") + } else { + print("\(rcFile.path) already configured.") + } + } else { + try (sourceLine + "\n").write(to: rcFile, atomically: true, encoding: .utf8) + print("Created \(rcFile.path)") + } + } +}