native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #146 from onevcat/feature/cli-install-command

feat(cli): Install Command Line Tool from within the app

authored by

Wei Wang and committed by
GitHub
e02ccd41 1c01403c

+1002 -10
+3
.gitignore
··· 32 32 .build/ 33 33 .derivedData/ 34 34 35 + # Embedded CLI binary (built by `make embed-cli`) 36 + Resources/prowl-cli/prowl 37 + 35 38 # CocoaPods 36 39 # 37 40 # We recommend against adding the Pods directory to your .gitignore. However
+15 -3
Makefile
··· 20 20 BUILD ?= 21 21 XCODEBUILD_FLAGS ?= 22 22 .DEFAULT_GOAL := help 23 - .PHONY: build-ghostty-xcframework ensure-ghostty sync-ghostty _check-ghostty-hash _record-ghostty-hash build-app build-cli run-app install-dev-build install-release archive export-archive format lint check test test-cli-smoke test-cli-integration bump-version bump-and-release log-stream 23 + .PHONY: build-ghostty-xcframework ensure-ghostty sync-ghostty _check-ghostty-hash _record-ghostty-hash build-app build-cli build-cli-release embed-cli run-app install-dev-build install-release archive export-archive format lint check test test-cli-smoke test-cli-integration bump-version bump-and-release log-stream 24 24 25 25 help: # Display this help. 26 26 @-+echo "Run make with one of the following targets:" ··· 82 82 rm -rf ~/Library/Developer/Xcode/DerivedData/supacode-* 83 83 @echo "Done. Xcode module cache cleared for fresh compilation." 84 84 85 - build-app: ensure-ghostty # Build the macOS app (Debug) 85 + build-app: ensure-ghostty embed-cli # Build the macOS app (Debug) 86 86 bash -o pipefail -c 'xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Debug build -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) 2>&1 | mise exec -- xcsift -qw --format toon' 87 87 88 88 build-cli: # Build Swift CLI binary (SPM) 89 89 swift build --product prowl 90 + 91 + build-cli-release: # Build CLI binary in release mode 92 + swift build -c release --product prowl 93 + 94 + embed-cli: build-cli-release # Build CLI and copy into Resources for app bundling 95 + @set -euo pipefail; \ 96 + bin="$$(swift build -c release --show-bin-path)/prowl"; \ 97 + dst="$(CURRENT_MAKEFILE_DIR)/Resources/prowl-cli"; \ 98 + mkdir -p "$$dst"; \ 99 + cp "$$bin" "$$dst/prowl"; \ 100 + chmod +x "$$dst/prowl"; \ 101 + echo "embedded CLI binary at $$dst/prowl" 90 102 91 103 run-app: build-app # Build then launch (Debug) with log streaming 92 104 @set -euo pipefail; \ ··· 234 246 ditto "$$APP_PATH" "$$DST"; \ 235 247 echo "installed $$DST (Release build, locally signed)" 236 248 237 - archive: build-ghostty-xcframework # Archive Release build for distribution 249 + archive: build-ghostty-xcframework embed-cli # Archive Release build for distribution 238 250 bash -o pipefail -c 'xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Release -archivePath build/supacode.xcarchive archive CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM="$$APPLE_TEAM_ID" CODE_SIGN_IDENTITY="$$DEVELOPER_ID_IDENTITY_SHA" OTHER_CODE_SIGN_FLAGS="--timestamp" -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) $(XCODEBUILD_FLAGS) 2>&1 | mise exec -- xcsift -qw --format toon' 239 251 240 252 export-archive: # Export xarchive
Resources/prowl-cli/.gitkeep

This is a binary file and will not be displayed.

+182
docs/plans/2026-04-04-cli-install-command.md
··· 1 + # CLI Install Command Implementation Plan 2 + 3 + **Goal:** Allow users to install the `prowl` CLI tool from within the Prowl app via three entry points: Settings, Prowl menu, and Command Palette. 4 + 5 + **Scope:** 6 + - In: CLIInstallClient dependency, Advanced Settings UI, Prowl menu item, Command Palette item, AppFeature wiring, Makefile CLI embedding, tests 7 + - Out: Auto-prompting on first launch, uninstall UI (can be added later), CLI build as part of Xcode build phase (manual `make build-cli` for now) 8 + 9 + **Architecture:** 10 + - `CLIInstallClient`: TCA dependency client that handles symlink creation, status checking, and bundled binary path resolution 11 + - Install action lives in `AppFeature` — all three entry points (Settings, Menu, Command Palette) funnel into the same `installCLI` action 12 + - CLI binary is embedded at `Prowl.app/Contents/Resources/prowl-cli/prowl` 13 + - Installation creates a symlink: `/usr/local/bin/prowl` → bundled binary path 14 + - Advanced Settings gets a new "Command Line Tool" section showing install status + install/uninstall button 15 + 16 + **Acceptance / Verification:** 17 + - `make build-app` succeeds 18 + - All existing tests pass 19 + - New CLIInstallClient tests pass 20 + - New AppFeature CLI install reducer tests pass 21 + - Menu item "Install Command Line Tool" visible under Prowl menu 22 + - Command Palette shows "Install Command Line Tool" item 23 + - Settings > Advanced shows CLI install section with status and action button 24 + 25 + --- 26 + 27 + ## Task 1: Create CLIInstallClient dependency 28 + 29 + **Files:** 30 + - Create: `supacode/Clients/CLIInstall/CLIInstallClient.swift` 31 + 32 + **Steps:** 33 + 1. Create `CLIInstallClient` struct following `WorkspaceClient` pattern 34 + 2. Provide operations: 35 + - `bundledCLIURL: @Sendable () -> URL?` — returns `Bundle.main.resourceURL/prowl-cli/prowl` 36 + - `installationStatus: @Sendable () -> CLIInstallStatus` — checks if symlink exists and points to correct target 37 + - `install: @Sendable (URL) async throws -> Void` — creates symlink at given path (default `/usr/local/bin/prowl`) 38 + - `uninstall: @Sendable (URL) async throws -> Void` — removes symlink at given path 39 + 3. Define `CLIInstallStatus` enum: `.notInstalled`, `.installed(path: String)`, `.installedDifferentSource(path: String)` 40 + 4. Implement `DependencyKey` with `liveValue` and `testValue` 41 + 5. Register in `DependencyValues` 42 + 43 + **Notes:** 44 + - Use `FileManager` for symlink operations 45 + - `install` should create `/usr/local/bin` directory if it doesn't exist 46 + - Check if destination already exists before creating symlink; if it's a symlink pointing elsewhere, report `.installedDifferentSource` 47 + 48 + --- 49 + 50 + ## Task 2: Add CLI install actions to AppFeature 51 + 52 + **Files:** 53 + - Modify: `supacode/Features/App/Reducer/AppFeature.swift` (add actions and reducer cases) 54 + 55 + **Steps:** 56 + 1. Add new actions to AppFeature.Action: 57 + - `installCLI` 58 + - `uninstallCLI` 59 + - `cliInstallResult(Result<String, CLIInstallError>)` — result of install/uninstall with success message or error 60 + 2. Add `@Dependency(CLIInstallClient.self)` to AppFeature 61 + 3. Implement reducer cases: 62 + - `installCLI`: run `.install()` via client, send result action 63 + - `uninstallCLI`: run `.uninstall()` via client, send result action 64 + - `cliInstallResult`: show alert with success/failure message 65 + 4. Add `CLIInstallError` type for error reporting 66 + 67 + --- 68 + 69 + ## Task 3: Add CLI install section to Advanced Settings 70 + 71 + **Files:** 72 + - Modify: `supacode/Features/Settings/Views/AdvancedSettingsView.swift` (add CLI section) 73 + 74 + **Steps:** 75 + 1. Add a new `Section("Command Line Tool")` in `AdvancedSettingsView` 76 + 2. Show current installation status (use `CLIInstallClient` to check) 77 + 3. Show Install/Uninstall button based on status 78 + 4. Button sends action to the `AppFeature` store (the settings view already receives `StoreOf<SettingsFeature>`, but we need to access `AppFeature` actions — use a callback or add delegate actions) 79 + 80 + **Design decision:** Since AdvancedSettingsView only has `StoreOf<SettingsFeature>`, add delegate actions to SettingsFeature: 81 + - `SettingsFeature.Delegate.installCLIRequested` 82 + - `SettingsFeature.Delegate.uninstallCLIRequested` 83 + - Handle these in AppFeature's `.settings(.delegate(...))` case 84 + 85 + **Notes:** 86 + - Show the install path (`/usr/local/bin/prowl`) in the UI 87 + - Show a green checkmark or status text for installed state 88 + - The view should refresh status when the settings tab appears 89 + 90 + --- 91 + 92 + ## Task 4: Add menu item in Prowl menu 93 + 94 + **Files:** 95 + - Modify: `supacode/App/supacodeApp.swift` (add menu item in Prowl menu group) 96 + 97 + **Steps:** 98 + 1. Add a `CommandGroup(after: .appSettings)` or within the existing Prowl menu area 99 + 2. Add "Install Command Line Tool..." button 100 + 3. Button sends `store.send(.installCLI)` action 101 + 4. Add appropriate `.help()` text 102 + 103 + --- 104 + 105 + ## Task 5: Add Command Palette item 106 + 107 + **Files:** 108 + - Modify: `supacode/Features/CommandPalette/CommandPaletteItem.swift` (add Kind case) 109 + - Modify: `supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift` (add item, delegate, mapping) 110 + - Modify: `supacode/Features/App/Reducer/AppFeature.swift` (handle new delegate) 111 + 112 + **Steps:** 113 + 1. Add `case installCLI` to `CommandPaletteItem.Kind` 114 + 2. Update `isGlobal` and `isRootAction` to return `true` for `.installCLI` 115 + 3. Add `case installCLI` to `CommandPaletteFeature.Delegate` 116 + 4. Add `CommandPaletteItem` to `commandPaletteItems()` function 117 + 5. Add ID `globalInstallCLI` to `CommandPaletteItemID` 118 + 6. Add to `globalIDs` array 119 + 7. Update `delegateAction(for:)` mapping 120 + 8. Update `appShortcutCommandID` (return nil for installCLI) 121 + 9. Handle `.commandPalette(.delegate(.installCLI))` in AppFeature reducer 122 + 123 + --- 124 + 125 + ## Task 6: Makefile integration for CLI embedding 126 + 127 + **Files:** 128 + - Modify: `Makefile` (add target to build CLI for bundle) 129 + 130 + **Steps:** 131 + 1. Add `build-cli-release` target: `swift build -c release --product prowl` 132 + 2. Add `embed-cli` target: copies release binary to `Resources/prowl-cli/prowl` 133 + 3. Update `build-app` to depend on `embed-cli` (or document manual step) 134 + 4. Add `Resources/prowl-cli/` to Xcode "Copy Bundle Resources" if not auto-included 135 + 136 + **Notes:** 137 + - For development, `Resources/prowl-cli/prowl` can be a placeholder — the actual install will use the bundled path at runtime 138 + 139 + --- 140 + 141 + ## Task 7: Tests for CLIInstallClient 142 + 143 + **Files:** 144 + - Create: `supacodeTests/CLIInstallClientTests.swift` 145 + 146 + **Steps:** 147 + 1. Test `installationStatus` returns `.notInstalled` when no symlink exists 148 + 2. Test `installationStatus` returns `.installed` when valid symlink exists 149 + 3. Test `installationStatus` returns `.installedDifferentSource` when symlink points elsewhere 150 + 4. Test `install` creates symlink at expected path 151 + 5. Test `install` creates parent directory if needed 152 + 6. Test `uninstall` removes symlink 153 + 7. Test `uninstall` does not remove non-symlink files (safety) 154 + 155 + **Notes:** 156 + - Use temp directories for test isolation 157 + - Test with actual FileManager operations (not mocks) for the live client 158 + 159 + --- 160 + 161 + ## Task 8: Tests for AppFeature CLI install reducer 162 + 163 + **Files:** 164 + - Create: `supacodeTests/AppFeatureCLIInstallTests.swift` 165 + 166 + **Steps:** 167 + 1. Test `.installCLI` action triggers client install call 168 + 2. Test `.uninstallCLI` action triggers client uninstall call 169 + 3. Test success result shows appropriate alert 170 + 4. Test failure result shows error alert 171 + 5. Test Command Palette delegate `.installCLI` forwards to `.installCLI` action 172 + 6. Test Settings delegate `.installCLIRequested` forwards to `.installCLI` action 173 + 174 + --- 175 + 176 + ## Task 9: Build verification 177 + 178 + **Steps:** 179 + 1. Run `make build-app` — verify success 180 + 2. Run existing tests — verify no regressions 181 + 3. Run new tests — verify all pass 182 + 4. Run `make lint` — verify no lint errors
+28 -2
supacode.xcodeproj/project.pbxproj
··· 15 15 D64162B02F23CAF100260CA3 /* ghostty in Resources */ = {isa = PBXBuildFile; fileRef = D64162AD2F23CAF100260CA3 /* ghostty */; }; 16 16 D64162B12F23CAF100260CA3 /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = D64162AE2F23CAF100260CA3 /* terminfo */; }; 17 17 D64162B22F23CAF100260CA3 /* git-wt in Resources */ = {isa = PBXBuildFile; fileRef = D64162AF2F23CAF100260CA3 /* git-wt */; }; 18 + D6CLI0012F99000100000001 /* prowl-cli in Resources */ = {isa = PBXBuildFile; fileRef = D6CLI0022F99000100000001 /* prowl-cli */; }; 18 19 D661760B2F250A60000FC27C /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = D661760A2F250A60000FC27C /* CasePaths */; }; 19 20 D6A1CB312F1F4ABF004FDABD /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6A1CB2F2F1F4A48004FDABD /* GhosttyKit.xcframework */; }; 20 21 D6A1CB362F1F4ACB004FDABD /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6A1CB342F1F4AC2004FDABD /* Carbon.framework */; }; ··· 36 37 D64162AD2F23CAF100260CA3 /* ghostty */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ghostty; path = Resources/ghostty; sourceTree = "<group>"; }; 37 38 D64162AE2F23CAF100260CA3 /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = Resources/terminfo; sourceTree = "<group>"; }; 38 39 D64162AF2F23CAF100260CA3 /* git-wt */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "git-wt"; path = "Resources/git-wt"; sourceTree = "<group>"; }; 40 + D6CLI0022F99000100000001 /* prowl-cli */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "prowl-cli"; path = "Resources/prowl-cli"; sourceTree = "<group>"; }; 39 41 D69CE04A2F1F378200584C57 /* Prowl.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Prowl.app; sourceTree = BUILT_PRODUCTS_DIR; }; 40 42 D6A1CB2F2F1F4A48004FDABD /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = "<group>"; }; 41 43 D6A1CB342F1F4AC2004FDABD /* Carbon.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Carbon.framework; path = System/Library/Frameworks/Carbon.framework; sourceTree = SDKROOT; }; ··· 101 103 children = ( 102 104 D64162AD2F23CAF100260CA3 /* ghostty */, 103 105 D64162AF2F23CAF100260CA3 /* git-wt */, 106 + D6CLI0022F99000100000001 /* prowl-cli */, 104 107 D64162AE2F23CAF100260CA3 /* terminfo */, 105 108 D69CE04C2F1F378200584C57 /* supacode */, 106 109 D6F821022F1FCBC1004B4174 /* supacodeTests */, ··· 136 139 buildPhases = ( 137 140 D69CE0462F1F378200584C57 /* Sources */, 138 141 D67F9B122F33000100A1B2C3 /* Verify git-wt script */, 142 + D6CLI0032F99000200000001 /* Verify prowl-cli binary */, 139 143 D69CE0472F1F378200584C57 /* Frameworks */, 140 144 D69CE0482F1F378200584C57 /* Resources */, 141 145 ); ··· 240 244 D64162B02F23CAF100260CA3 /* ghostty in Resources */, 241 245 D64162B12F23CAF100260CA3 /* terminfo in Resources */, 242 246 D64162B22F23CAF100260CA3 /* git-wt in Resources */, 247 + D6CLI0012F99000100000001 /* prowl-cli in Resources */, 243 248 ); 244 249 runOnlyForDeploymentPostprocessing = 0; 245 250 }; ··· 272 277 shellPath = /bin/sh; 273 278 shellScript = "WT_SCRIPT=\"${PROJECT_DIR}/Resources/git-wt/wt\"\nif [ ! -f \"$WT_SCRIPT\" ]; then\n echo \"error: Missing $WT_SCRIPT. Run: git submodule update --init Resources/git-wt\" >&2\n exit 1\nfi\nif [ ! -x \"$WT_SCRIPT\" ]; then\n echo \"error: $WT_SCRIPT is not executable.\" >&2\n exit 1\nfi\n"; 274 279 }; 280 + D6CLI0032F99000200000001 /* Verify prowl-cli binary */ = { 281 + isa = PBXShellScriptBuildPhase; 282 + alwaysOutOfDate = 1; 283 + buildActionMask = 2147483647; 284 + files = ( 285 + ); 286 + inputFileListPaths = ( 287 + ); 288 + inputPaths = ( 289 + ); 290 + name = "Verify prowl-cli binary"; 291 + outputFileListPaths = ( 292 + ); 293 + outputPaths = ( 294 + ); 295 + runOnlyForDeploymentPostprocessing = 0; 296 + shellPath = /bin/sh; 297 + shellScript = "CLI_BIN=\"${PROJECT_DIR}/Resources/prowl-cli/prowl\"\nif [ ! -f \"$CLI_BIN\" ]; then\n echo \"error: Missing $CLI_BIN. Run: make embed-cli\" >&2\n exit 1\nfi\nif [ ! -x \"$CLI_BIN\" ]; then\n echo \"error: $CLI_BIN is not executable. Run: make embed-cli\" >&2\n exit 1\nfi\n"; 298 + }; 275 299 /* End PBXShellScriptBuildPhase section */ 276 300 277 301 /* Begin PBXSourcesBuildPhase section */ ··· 467 491 "$(inherited)", 468 492 "-lc++", 469 493 ); 494 + EXECUTABLE_NAME = ProwlApp; 470 495 OTHER_SWIFT_FLAGS = "$(inherited) -Xcc -Wno-incomplete-umbrella"; 471 496 PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowl; 472 497 PRODUCT_MODULE_NAME = supacode; ··· 524 549 "$(inherited)", 525 550 "-lc++", 526 551 ); 552 + EXECUTABLE_NAME = ProwlApp; 527 553 OTHER_SWIFT_FLAGS = "$(inherited) -Xcc -Wno-incomplete-umbrella"; 528 554 PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowl; 529 555 PRODUCT_MODULE_NAME = supacode; ··· 562 588 SWIFT_EMIT_LOC_STRINGS = NO; 563 589 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; 564 590 SWIFT_VERSION = 6.0; 565 - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Prowl.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Prowl"; 591 + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Prowl.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ProwlApp"; 566 592 }; 567 593 name = Debug; 568 594 }; ··· 584 610 SWIFT_EMIT_LOC_STRINGS = NO; 585 611 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; 586 612 SWIFT_VERSION = 6.0; 587 - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Prowl.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Prowl"; 613 + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Prowl.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ProwlApp"; 588 614 }; 589 615 name = Release; 590 616 };
+7
supacode/App/supacodeApp.swift
··· 616 616 ) 617 617 ) 618 618 } 619 + CommandGroup(after: .appSettings) { 620 + Button("Install Command Line Tool...") { 621 + SettingsWindowManager.shared.show() 622 + store.send(.settings(.installCLIButtonTapped)) 623 + } 624 + .help("Install the prowl command line tool to /usr/local/bin") 625 + } 619 626 CommandGroup(replacing: .appTermination) { 620 627 Button("Quit Prowl") { 621 628 store.send(.requestQuit)
+173
supacode/Clients/CLIInstall/CLIInstallClient.swift
··· 1 + import ComposableArchitecture 2 + import Foundation 3 + 4 + enum CLIInstallStatus: Equatable, Sendable { 5 + case notInstalled 6 + case installed(path: String) 7 + case installedDifferentSource(path: String) 8 + } 9 + 10 + struct CLIInstallError: Error, Equatable, Sendable, LocalizedError { 11 + let message: String 12 + 13 + var errorDescription: String? { message } 14 + } 15 + 16 + let cliDefaultInstallPath = URL(fileURLWithPath: "/usr/local/bin/prowl") 17 + 18 + struct CLIInstallClient: Sendable { 19 + var bundledCLIURL: @Sendable () -> URL? 20 + var installationStatus: @Sendable (_ installPath: URL) -> CLIInstallStatus 21 + var install: @Sendable (_ installPath: URL) async throws -> Void 22 + var uninstall: @Sendable (_ installPath: URL) async throws -> Void 23 + } 24 + 25 + extension CLIInstallClient: DependencyKey { 26 + static let liveValue = CLIInstallClient( 27 + bundledCLIURL: { 28 + Bundle.main.resourceURL?.appendingPathComponent("prowl-cli/prowl") 29 + }, 30 + installationStatus: { installPath in 31 + let fileManager = FileManager.default 32 + let path = installPath.path(percentEncoded: false) 33 + guard fileManager.fileExists(atPath: path) else { 34 + return .notInstalled 35 + } 36 + guard let attrs = try? fileManager.attributesOfItem(atPath: path), 37 + attrs[.type] as? FileAttributeType == .typeSymbolicLink, 38 + let destination = try? fileManager.destinationOfSymbolicLink(atPath: path) 39 + else { 40 + return .installedDifferentSource(path: path) 41 + } 42 + let bundledURL = Bundle.main.resourceURL?.appendingPathComponent("prowl-cli/prowl") 43 + let bundledPath = bundledURL?.path(percentEncoded: false) ?? "" 44 + if destination == bundledPath { 45 + return .installed(path: path) 46 + } 47 + return .installedDifferentSource(path: path) 48 + }, 49 + install: { installPath in 50 + let fileManager = FileManager.default 51 + guard let bundledURL = Bundle.main.resourceURL?.appendingPathComponent("prowl-cli/prowl") else { 52 + throw CLIInstallError(message: "Could not locate bundled CLI binary.") 53 + } 54 + let bundledPath = bundledURL.path(percentEncoded: false) 55 + guard fileManager.fileExists(atPath: bundledPath) else { 56 + throw CLIInstallError(message: "Bundled CLI binary not found at \(bundledPath).") 57 + } 58 + let destination = installPath.path(percentEncoded: false) 59 + if fileManager.fileExists(atPath: destination) { 60 + let attrs = try? fileManager.attributesOfItem(atPath: destination) 61 + let isSymlink = attrs?[.type] as? FileAttributeType == .typeSymbolicLink 62 + guard isSymlink else { 63 + throw CLIInstallError( 64 + message: "A file already exists at \(destination) and is not a symlink. " 65 + + "Remove it manually before installing." 66 + ) 67 + } 68 + } 69 + try cliSymlinkInstall(source: bundledPath, destination: destination) 70 + }, 71 + uninstall: { installPath in 72 + let fileManager = FileManager.default 73 + let path = installPath.path(percentEncoded: false) 74 + guard fileManager.fileExists(atPath: path) else { 75 + throw CLIInstallError(message: "No CLI tool found at \(path).") 76 + } 77 + guard let attrs = try? fileManager.attributesOfItem(atPath: path), 78 + attrs[.type] as? FileAttributeType == .typeSymbolicLink 79 + else { 80 + throw CLIInstallError(message: "File at \(path) is not a symlink. Refusing to remove for safety.") 81 + } 82 + try cliSymlinkUninstall(path: path) 83 + } 84 + ) 85 + 86 + static let testValue = CLIInstallClient( 87 + bundledCLIURL: { nil }, 88 + installationStatus: { _ in .notInstalled }, 89 + install: { _ in }, 90 + uninstall: { _ in } 91 + ) 92 + } 93 + 94 + // MARK: - Symlink operations with privilege escalation 95 + 96 + /// Attempts to create the CLI symlink. Falls back to osascript privilege escalation on permission failure. 97 + private nonisolated func cliSymlinkInstall(source: String, destination: String) throws { 98 + let fileManager = FileManager.default 99 + let dir = (destination as NSString).deletingLastPathComponent 100 + 101 + // Try direct approach first 102 + do { 103 + if !fileManager.fileExists(atPath: dir) { 104 + try fileManager.createDirectory(atPath: dir, withIntermediateDirectories: true) 105 + } 106 + if fileManager.fileExists(atPath: destination) { 107 + try fileManager.removeItem(atPath: destination) 108 + } 109 + try fileManager.createSymbolicLink(atPath: destination, withDestinationPath: source) 110 + return 111 + } catch let error as NSError where isPermissionError(error) { 112 + // Fall through to privilege escalation 113 + } 114 + 115 + // Privilege escalation via osascript 116 + let script = "mkdir -p '\(shellEscape(dir))' && " 117 + + "rm -f '\(shellEscape(destination))' && " 118 + + "ln -s '\(shellEscape(source))' '\(shellEscape(destination))'" 119 + try runPrivileged(script: script) 120 + } 121 + 122 + /// Attempts to remove the CLI symlink. Falls back to osascript privilege escalation on permission failure. 123 + private nonisolated func cliSymlinkUninstall(path: String) throws { 124 + let fileManager = FileManager.default 125 + 126 + do { 127 + try fileManager.removeItem(atPath: path) 128 + return 129 + } catch let error as NSError where isPermissionError(error) { 130 + // Fall through to privilege escalation 131 + } 132 + 133 + let script = "rm -f '\(shellEscape(path))'" 134 + try runPrivileged(script: script) 135 + } 136 + 137 + private nonisolated func isPermissionError(_ error: NSError) -> Bool { 138 + (error.domain == NSCocoaErrorDomain && error.code == NSFileWriteNoPermissionError) 139 + || (error.domain == NSPOSIXErrorDomain && error.code == 13) 140 + } 141 + 142 + /// Runs a shell command with administrator privileges via osascript. 143 + private nonisolated func runPrivileged(script: String) throws { 144 + let osa = Process() 145 + osa.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") 146 + osa.arguments = ["-e", "do shell script \"\(script)\" with administrator privileges"] 147 + let pipe = Pipe() 148 + osa.standardError = pipe 149 + do { 150 + try osa.run() 151 + } catch { 152 + throw CLIInstallError(message: "Failed to launch authorization prompt: \(error.localizedDescription)") 153 + } 154 + osa.waitUntilExit() 155 + guard osa.terminationStatus == 0 else { 156 + let stderr = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" 157 + if stderr.contains("User canceled") || stderr.contains("-128") { 158 + throw CLIInstallError(message: "Installation was canceled.") 159 + } 160 + throw CLIInstallError(message: "Installation failed: \(stderr)") 161 + } 162 + } 163 + 164 + private nonisolated func shellEscape(_ value: String) -> String { 165 + value.replacing("'", with: "'\\''") 166 + } 167 + 168 + extension DependencyValues { 169 + var cliInstallClient: CLIInstallClient { 170 + get { self[CLIInstallClient.self] } 171 + set { self[CLIInstallClient.self] = newValue } 172 + } 173 + }
+6
supacode/Features/App/Reducer/AppFeature.swift
··· 472 472 case .settings(.delegate(.terminalFontSizeChanged)): 473 473 return .none 474 474 475 + case .settings(.delegate(.cliInstallStatusChanged)): 476 + return .none 477 + 475 478 case .settings(.delegate(.terminalLayoutSnapshotCleared(let success))): 476 479 if success { 477 480 state.suppressLayoutSaveUntilRelaunch = true ··· 831 834 832 835 case .commandPalette(.delegate(.refreshWorktrees)): 833 836 return .send(.repositories(.refreshWorktrees)) 837 + 838 + case .commandPalette(.delegate(.installCLI)): 839 + return .send(.settings(.installCLIButtonTapped)) 834 840 835 841 case .commandPalette(.delegate(.ghosttyCommand(let action))): 836 842 guard let worktree = state.repositories.selectedTerminalWorktree else {
+4 -2
supacode/Features/CommandPalette/CommandPaletteItem.swift
··· 39 39 case copyCiFailureLogs(Worktree.ID) 40 40 case rerunFailedJobs(Worktree.ID) 41 41 case openFailingCheckDetails(Worktree.ID) 42 + case installCLI 42 43 #if DEBUG 43 44 case debugTestToast(RepositoriesFeature.StatusToast) 44 45 #endif ··· 46 47 47 48 var isGlobal: Bool { 48 49 switch kind { 49 - case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .refreshWorktrees: 50 + case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .refreshWorktrees, .installCLI: 50 51 return true 51 52 case .ghosttyCommand: 52 53 return false ··· 70 71 71 72 var isRootAction: Bool { 72 73 switch kind { 73 - case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .refreshWorktrees: 74 + case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .refreshWorktrees, .installCLI: 74 75 return true 75 76 case .ghosttyCommand: 76 77 return false ··· 115 116 .copyCiFailureLogs, 116 117 .rerunFailedJobs, 117 118 .openFailingCheckDetails, 119 + .installCLI, 118 120 .worktreeSelect, 119 121 .removeWorktree, 120 122 .archiveWorktree:
+14
supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift
··· 48 48 case copyCiFailureLogs(Worktree.ID) 49 49 case rerunFailedJobs(Worktree.ID) 50 50 case openFailingCheckDetails(Worktree.ID) 51 + case installCLI 51 52 #if DEBUG 52 53 case debugTestToast(RepositoriesFeature.StatusToast) 53 54 #endif ··· 205 206 kind: .refreshWorktrees 206 207 ) 207 208 ) 209 + items.append( 210 + CommandPaletteItem( 211 + id: CommandPaletteItemID.globalInstallCLI, 212 + title: "Install Command Line Tool", 213 + subtitle: nil, 214 + kind: .installCLI 215 + ) 216 + ) 208 217 if repositories.selectedTerminalWorktree != nil { 209 218 items.append(contentsOf: ghosttyCommandItems(ghosttyCommands)) 210 219 } ··· 429 438 static let globalOpenRepository = "global.open-repository" 430 439 static let globalNewWorktree = "global.new-worktree" 431 440 static let globalRefreshWorktrees = "global.refresh-worktrees" 441 + static let globalInstallCLI = "global.install-cli" 432 442 433 443 static var globalIDs: [CommandPaletteItem.ID] { 434 444 [ ··· 437 447 globalOpenRepository, 438 448 globalNewWorktree, 439 449 globalRefreshWorktrees, 450 + globalInstallCLI, 440 451 ] 441 452 } 442 453 ··· 544 555 return .archiveWorktree(worktreeID, repositoryID) 545 556 case .refreshWorktrees: 546 557 return .refreshWorktrees 558 + case .installCLI: 559 + return .installCLI 547 560 case .ghosttyCommand(let action): 548 561 return .ghosttyCommand(action) 549 562 case .openPullRequest, ··· 590 603 .removeWorktree, 591 604 .archiveWorktree, 592 605 .refreshWorktrees, 606 + .installCLI, 593 607 .ghosttyCommand: 594 608 return nil 595 609 #if DEBUG
+6 -2
supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift
··· 348 348 private var badge: String? { 349 349 switch row.kind { 350 350 case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .refreshWorktrees, 351 - .ghosttyCommand, 351 + .installCLI, .ghosttyCommand, 352 352 .openPullRequest, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, 353 353 .copyCiFailureLogs, 354 354 .rerunFailedJobs, .openFailingCheckDetails, .worktreeSelect: ··· 394 394 return "arrow.counterclockwise" 395 395 case .openFailingCheckDetails: 396 396 return "exclamationmark.triangle" 397 + case .installCLI: 398 + return "terminal" 397 399 case .worktreeSelect: 398 400 return nil 399 401 case .removeWorktree: ··· 410 412 private var emphasis: Bool { 411 413 switch row.kind { 412 414 case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .refreshWorktrees, 413 - .ghosttyCommand, 415 + .installCLI, .ghosttyCommand, 414 416 .openPullRequest, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, 415 417 .copyCiFailureLogs, 416 418 .rerunFailedJobs, .openFailingCheckDetails: ··· 525 527 base = "Re-run failed jobs" 526 528 case .openFailingCheckDetails: 527 529 base = "Open failing check details" 530 + case .installCLI: 531 + base = "Install Command Line Tool" 528 532 #if DEBUG 529 533 case .debugTestToast: 530 534 base = row.title
+70
supacode/Features/Settings/Reducer/SettingsFeature.swift
··· 27 27 var restoreTerminalLayoutOnLaunch: Bool 28 28 var terminalFontSize: Float32? 29 29 var keybindingUserOverrides: KeybindingUserOverrideStore 30 + var cliInstallStatus: CLIInstallStatus = .notInstalled 30 31 var selection: SettingsSection? = .general 31 32 var repositorySettings: RepositorySettingsFeature.State? 32 33 @Presents var alert: AlertState<Alert>? ··· 96 97 case setCommandFinishedNotificationThreshold(String) 97 98 case setTerminalFontSize(Float32?) 98 99 case clearTerminalLayoutSnapshotButtonTapped 100 + case installCLIButtonTapped 101 + case uninstallCLIButtonTapped 102 + case cliInstallCompleted(Result<String, CLIInstallError>) 103 + case refreshCLIInstallStatus 99 104 case showNotificationPermissionAlert(errorMessage: String?) 100 105 case repositorySettings(RepositorySettingsFeature.Action) 101 106 case alert(PresentationAction<Alert>) ··· 113 118 case settingsChanged(GlobalSettings) 114 119 case terminalFontSizeChanged(Float32?) 115 120 case terminalLayoutSnapshotCleared(success: Bool) 121 + case cliInstallStatusChanged 116 122 } 117 123 118 124 @Dependency(AnalyticsClient.self) private var analyticsClient 119 125 @Dependency(SystemNotificationClient.self) private var systemNotificationClient 120 126 @Dependency(TerminalLayoutPersistenceClient.self) private var terminalLayoutPersistence 127 + @Dependency(CLIInstallClient.self) private var cliInstallClient 121 128 122 129 var body: some Reducer<State, Action> { 123 130 BindingReducer() ··· 205 212 let success = await terminalLayoutPersistence.clearSnapshot() 206 213 await send(.delegate(.terminalLayoutSnapshotCleared(success: success))) 207 214 } 215 + 216 + case .installCLIButtonTapped: 217 + let installPath = cliDefaultInstallPath 218 + return .run { [cliInstallClient] send in 219 + do { 220 + try await cliInstallClient.install(installPath) 221 + let path = installPath.path(percentEncoded: false) 222 + await send(.cliInstallCompleted(.success(path))) 223 + } catch let error as CLIInstallError { 224 + await send(.cliInstallCompleted(.failure(error))) 225 + } catch { 226 + await send(.cliInstallCompleted(.failure(CLIInstallError(message: error.localizedDescription)))) 227 + } 228 + } 229 + 230 + case .uninstallCLIButtonTapped: 231 + let installPath = cliDefaultInstallPath 232 + return .run { [cliInstallClient] send in 233 + do { 234 + try await cliInstallClient.uninstall(installPath) 235 + await send(.cliInstallCompleted(.success(""))) 236 + } catch let error as CLIInstallError { 237 + await send(.cliInstallCompleted(.failure(error))) 238 + } catch { 239 + await send(.cliInstallCompleted(.failure(CLIInstallError(message: error.localizedDescription)))) 240 + } 241 + } 242 + 243 + case .cliInstallCompleted(.success(let path)): 244 + if path.isEmpty { 245 + state.alert = AlertState { 246 + TextState("Command Line Tool Uninstalled") 247 + } actions: { 248 + ButtonState(action: .dismiss) { TextState("OK") } 249 + } message: { 250 + TextState("The prowl command line tool has been removed.") 251 + } 252 + } else { 253 + state.alert = AlertState { 254 + TextState("Command Line Tool Installed") 255 + } actions: { 256 + ButtonState(action: .dismiss) { TextState("OK") } 257 + } message: { 258 + TextState("The prowl command is now available at \(path).") 259 + } 260 + } 261 + state.cliInstallStatus = cliInstallClient.installationStatus(cliDefaultInstallPath) 262 + return .send(.delegate(.cliInstallStatusChanged)) 263 + 264 + case .cliInstallCompleted(.failure(let error)): 265 + state.alert = AlertState { 266 + TextState("Command Line Tool Error") 267 + } actions: { 268 + ButtonState(action: .dismiss) { TextState("OK") } 269 + } message: { 270 + TextState(error.message) 271 + } 272 + state.cliInstallStatus = cliInstallClient.installationStatus(cliDefaultInstallPath) 273 + return .send(.delegate(.cliInstallStatusChanged)) 274 + 275 + case .refreshCLIInstallStatus: 276 + state.cliInstallStatus = cliInstallClient.installationStatus(cliDefaultInstallPath) 277 + return .none 208 278 209 279 case .showNotificationPermissionAlert(let errorMessage): 210 280 let message: String
+56
supacode/Features/Settings/Views/AdvancedSettingsView.swift
··· 7 7 var body: some View { 8 8 VStack(alignment: .leading) { 9 9 Form { 10 + Section("Command Line Tool") { 11 + VStack(alignment: .leading, spacing: 8) { 12 + HStack(spacing: 6) { 13 + switch store.cliInstallStatus { 14 + case .installed(let path): 15 + Image(systemName: "checkmark.circle.fill") 16 + .foregroundStyle(.green) 17 + .accessibilityLabel("Installed") 18 + Text("Installed at \(path)") 19 + case .installedDifferentSource(let path): 20 + Image(systemName: "exclamationmark.triangle.fill") 21 + .foregroundStyle(.yellow) 22 + .accessibilityLabel("Different version") 23 + Text("A different version exists at \(path)") 24 + case .notInstalled: 25 + Image(systemName: "xmark.circle") 26 + .foregroundStyle(.secondary) 27 + .accessibilityLabel("Not installed") 28 + Text("Not installed") 29 + } 30 + } 31 + .font(.callout) 32 + 33 + Text("Install the prowl command to control Prowl from the terminal.") 34 + .foregroundStyle(.secondary) 35 + .font(.callout) 36 + 37 + HStack(spacing: 8) { 38 + switch store.cliInstallStatus { 39 + case .notInstalled: 40 + Button("Install") { 41 + store.send(.installCLIButtonTapped) 42 + } 43 + .help("Install prowl command line tool to /usr/local/bin") 44 + .buttonStyle(.bordered) 45 + case .installed: 46 + Button("Uninstall") { 47 + store.send(.uninstallCLIButtonTapped) 48 + } 49 + .help("Remove prowl command line tool from /usr/local/bin") 50 + .buttonStyle(.bordered) 51 + case .installedDifferentSource: 52 + Button("Reinstall") { 53 + store.send(.installCLIButtonTapped) 54 + } 55 + .help("Replace the existing prowl command with the version bundled in this app") 56 + .buttonStyle(.bordered) 57 + } 58 + } 59 + } 60 + .frame(maxWidth: .infinity, alignment: .leading) 61 + .onAppear { 62 + store.send(.refreshCLIInstallStatus) 63 + } 64 + } 65 + 10 66 Section("Advanced") { 11 67 VStack(alignment: .leading) { 12 68 Toggle(
-1
supacode/Features/Settings/Views/SettingsView.swift
··· 124 124 } 125 125 .navigationSplitViewStyle(.balanced) 126 126 .alert(store: settingsStore.scope(state: \.$alert, action: \.alert)) 127 - .alert(store: store.scope(state: \.$alert, action: \.alert)) 128 127 .frame(minWidth: 750, minHeight: 500) 129 128 .background { 130 129 WindowAppearanceSetter(colorScheme: settingsStore.appearanceMode.colorScheme)
+121
supacodeTests/AppFeatureCLIInstallTests.swift
··· 1 + import ComposableArchitecture 2 + import DependenciesTestSupport 3 + import Foundation 4 + import Testing 5 + 6 + @testable import supacode 7 + 8 + @MainActor 9 + struct SettingsFeatureCLIInstallTests { 10 + @Test(.dependencies) func installShowsSuccessAlert() async { 11 + let installed = LockIsolated(false) 12 + let store = TestStore( 13 + initialState: SettingsFeature.State() 14 + ) { 15 + SettingsFeature() 16 + } withDependencies: { 17 + $0.cliInstallClient.install = { _ in 18 + installed.setValue(true) 19 + } 20 + $0.cliInstallClient.installationStatus = { _ in .installed(path: "/usr/local/bin/prowl") } 21 + } 22 + 23 + await store.send(.installCLIButtonTapped) 24 + await store.receive(\.cliInstallCompleted.success) { 25 + $0.alert = AlertState { 26 + TextState("Command Line Tool Installed") 27 + } actions: { 28 + ButtonState(action: .dismiss) { TextState("OK") } 29 + } message: { 30 + TextState("The prowl command is now available at /usr/local/bin/prowl.") 31 + } 32 + $0.cliInstallStatus = .installed(path: "/usr/local/bin/prowl") 33 + } 34 + await store.receive(\.delegate.cliInstallStatusChanged) 35 + 36 + #expect(installed.value == true) 37 + } 38 + 39 + @Test(.dependencies) func installShowsErrorAlertOnFailure() async { 40 + let store = TestStore( 41 + initialState: SettingsFeature.State() 42 + ) { 43 + SettingsFeature() 44 + } withDependencies: { 45 + $0.cliInstallClient.install = { _ in 46 + throw CLIInstallError(message: "Permission denied") 47 + } 48 + $0.cliInstallClient.installationStatus = { _ in .notInstalled } 49 + } 50 + 51 + await store.send(.installCLIButtonTapped) 52 + await store.receive(\.cliInstallCompleted.failure) { 53 + $0.alert = AlertState { 54 + TextState("Command Line Tool Error") 55 + } actions: { 56 + ButtonState(action: .dismiss) { TextState("OK") } 57 + } message: { 58 + TextState("Permission denied") 59 + } 60 + } 61 + await store.receive(\.delegate.cliInstallStatusChanged) 62 + } 63 + 64 + @Test(.dependencies) func uninstallShowsSuccessAlert() async { 65 + let uninstalled = LockIsolated(false) 66 + let store = TestStore( 67 + initialState: SettingsFeature.State() 68 + ) { 69 + SettingsFeature() 70 + } withDependencies: { 71 + $0.cliInstallClient.uninstall = { _ in 72 + uninstalled.setValue(true) 73 + } 74 + $0.cliInstallClient.installationStatus = { _ in .notInstalled } 75 + } 76 + 77 + await store.send(.uninstallCLIButtonTapped) 78 + await store.receive(\.cliInstallCompleted.success) { 79 + $0.alert = AlertState { 80 + TextState("Command Line Tool Uninstalled") 81 + } actions: { 82 + ButtonState(action: .dismiss) { TextState("OK") } 83 + } message: { 84 + TextState("The prowl command line tool has been removed.") 85 + } 86 + } 87 + await store.receive(\.delegate.cliInstallStatusChanged) 88 + 89 + #expect(uninstalled.value == true) 90 + } 91 + 92 + @Test(.dependencies) func commandPaletteInstallRoutesToSettings() async { 93 + let installed = LockIsolated(false) 94 + let store = TestStore( 95 + initialState: AppFeature.State(settings: SettingsFeature.State()) 96 + ) { 97 + AppFeature() 98 + } withDependencies: { 99 + $0.cliInstallClient.install = { _ in 100 + installed.setValue(true) 101 + } 102 + $0.cliInstallClient.installationStatus = { _ in .installed(path: "/usr/local/bin/prowl") } 103 + } 104 + 105 + await store.send(.commandPalette(.delegate(.installCLI))) 106 + await store.receive(\.settings.installCLIButtonTapped) 107 + await store.receive(\.settings.cliInstallCompleted.success) { 108 + $0.settings.alert = AlertState { 109 + TextState("Command Line Tool Installed") 110 + } actions: { 111 + ButtonState(action: .dismiss) { TextState("OK") } 112 + } message: { 113 + TextState("The prowl command is now available at /usr/local/bin/prowl.") 114 + } 115 + $0.settings.cliInstallStatus = .installed(path: "/usr/local/bin/prowl") 116 + } 117 + await store.receive(\.settings.delegate.cliInstallStatusChanged) 118 + 119 + #expect(installed.value == true) 120 + } 121 + }
+316
supacodeTests/CLIInstallClientTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + struct CLIInstallClientTests { 7 + private func makeTempDir() throws -> URL { 8 + let tmp = FileManager.default.temporaryDirectory 9 + .appendingPathComponent("prowl-cli-test-\(UUID().uuidString)") 10 + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) 11 + return tmp 12 + } 13 + 14 + private func cleanup(_ url: URL) { 15 + try? FileManager.default.removeItem(at: url) 16 + } 17 + 18 + @Test func statusNotInstalledWhenNoFileExists() throws { 19 + let tmp = try makeTempDir() 20 + defer { cleanup(tmp) } 21 + let installPath = tmp.appendingPathComponent("prowl") 22 + 23 + let client = CLIInstallClient.liveValue 24 + let status = client.installationStatus(installPath) 25 + 26 + #expect(status == .notInstalled) 27 + } 28 + 29 + @Test func statusInstalledWhenSymlinkPointsToBundle() throws { 30 + let tmp = try makeTempDir() 31 + defer { cleanup(tmp) } 32 + 33 + let fakeBundledBinary = tmp.appendingPathComponent("bundled-prowl") 34 + FileManager.default.createFile(atPath: fakeBundledBinary.path, contents: nil) 35 + 36 + let installPath = tmp.appendingPathComponent("prowl") 37 + try FileManager.default.createSymbolicLink( 38 + atPath: installPath.path, 39 + withDestinationPath: fakeBundledBinary.path 40 + ) 41 + 42 + let client = CLIInstallClient( 43 + bundledCLIURL: { fakeBundledBinary }, 44 + installationStatus: { path in 45 + let fileManager = FileManager.default 46 + let filePath = path.path(percentEncoded: false) 47 + guard fileManager.fileExists(atPath: filePath) else { return .notInstalled } 48 + guard let attrs = try? fileManager.attributesOfItem(atPath: filePath), 49 + attrs[.type] as? FileAttributeType == .typeSymbolicLink, 50 + let destination = try? fileManager.destinationOfSymbolicLink(atPath: filePath) 51 + else { return .installedDifferentSource(path: filePath) } 52 + if destination == fakeBundledBinary.path(percentEncoded: false) { 53 + return .installed(path: filePath) 54 + } 55 + return .installedDifferentSource(path: filePath) 56 + }, 57 + install: { _ in }, 58 + uninstall: { _ in } 59 + ) 60 + 61 + let status = client.installationStatus(installPath) 62 + #expect(status == .installed(path: installPath.path)) 63 + } 64 + 65 + @Test func statusDifferentSourceWhenSymlinkPointsElsewhere() throws { 66 + let tmp = try makeTempDir() 67 + defer { cleanup(tmp) } 68 + 69 + let otherBinary = tmp.appendingPathComponent("other-prowl") 70 + FileManager.default.createFile(atPath: otherBinary.path, contents: nil) 71 + 72 + let installPath = tmp.appendingPathComponent("prowl") 73 + try FileManager.default.createSymbolicLink( 74 + atPath: installPath.path, 75 + withDestinationPath: otherBinary.path 76 + ) 77 + 78 + let client = CLIInstallClient.liveValue 79 + let status = client.installationStatus(installPath) 80 + 81 + #expect(status == .installedDifferentSource(path: installPath.path)) 82 + } 83 + 84 + @Test func statusDifferentSourceWhenRegularFileExists() throws { 85 + let tmp = try makeTempDir() 86 + defer { cleanup(tmp) } 87 + 88 + let installPath = tmp.appendingPathComponent("prowl") 89 + FileManager.default.createFile(atPath: installPath.path, contents: Data("binary".utf8)) 90 + 91 + let client = CLIInstallClient.liveValue 92 + let status = client.installationStatus(installPath) 93 + 94 + #expect(status == .installedDifferentSource(path: installPath.path)) 95 + } 96 + 97 + @Test func installCreatesSymlink() async throws { 98 + let tmp = try makeTempDir() 99 + defer { cleanup(tmp) } 100 + 101 + let fakeBundled = tmp.appendingPathComponent("source/prowl") 102 + try FileManager.default.createDirectory( 103 + at: fakeBundled.deletingLastPathComponent(), 104 + withIntermediateDirectories: true 105 + ) 106 + FileManager.default.createFile(atPath: fakeBundled.path, contents: Data("cli".utf8)) 107 + 108 + let installPath = tmp.appendingPathComponent("bin/prowl") 109 + let client = CLIInstallClient( 110 + bundledCLIURL: { fakeBundled }, 111 + installationStatus: CLIInstallClient.liveValue.installationStatus, 112 + install: { path in 113 + let fileManager = FileManager.default 114 + let bundledPath = fakeBundled.path(percentEncoded: false) 115 + let installDir = path.deletingLastPathComponent().path(percentEncoded: false) 116 + if !fileManager.fileExists(atPath: installDir) { 117 + try fileManager.createDirectory(atPath: installDir, withIntermediateDirectories: true) 118 + } 119 + let destination = path.path(percentEncoded: false) 120 + if fileManager.fileExists(atPath: destination) { 121 + try fileManager.removeItem(atPath: destination) 122 + } 123 + do { 124 + try fileManager.createSymbolicLink(atPath: destination, withDestinationPath: bundledPath) 125 + } catch { 126 + throw CLIInstallError(message: error.localizedDescription) 127 + } 128 + }, 129 + uninstall: CLIInstallClient.liveValue.uninstall 130 + ) 131 + 132 + try await client.install(installPath) 133 + 134 + let fileManager = FileManager.default 135 + let attrs = try fileManager.attributesOfItem(atPath: installPath.path) 136 + #expect(attrs[.type] as? FileAttributeType == .typeSymbolicLink) 137 + 138 + let target = try fileManager.destinationOfSymbolicLink(atPath: installPath.path) 139 + #expect(target == fakeBundled.path) 140 + } 141 + 142 + @Test func installCreatesParentDirectoryIfNeeded() async throws { 143 + let tmp = try makeTempDir() 144 + defer { cleanup(tmp) } 145 + 146 + let fakeBundled = tmp.appendingPathComponent("source/prowl") 147 + try FileManager.default.createDirectory( 148 + at: fakeBundled.deletingLastPathComponent(), 149 + withIntermediateDirectories: true 150 + ) 151 + FileManager.default.createFile(atPath: fakeBundled.path, contents: nil) 152 + 153 + let deepPath = tmp.appendingPathComponent("a/b/c/prowl") 154 + let client = CLIInstallClient( 155 + bundledCLIURL: { fakeBundled }, 156 + installationStatus: CLIInstallClient.liveValue.installationStatus, 157 + install: { path in 158 + let fileManager = FileManager.default 159 + let bundledPath = fakeBundled.path(percentEncoded: false) 160 + let installDir = path.deletingLastPathComponent().path(percentEncoded: false) 161 + if !fileManager.fileExists(atPath: installDir) { 162 + try fileManager.createDirectory(atPath: installDir, withIntermediateDirectories: true) 163 + } 164 + let destination = path.path(percentEncoded: false) 165 + do { 166 + try fileManager.createSymbolicLink(atPath: destination, withDestinationPath: bundledPath) 167 + } catch { 168 + throw CLIInstallError(message: error.localizedDescription) 169 + } 170 + }, 171 + uninstall: CLIInstallClient.liveValue.uninstall 172 + ) 173 + 174 + try await client.install(deepPath) 175 + 176 + #expect(FileManager.default.fileExists(atPath: deepPath.path)) 177 + } 178 + 179 + @Test func uninstallRemovesSymlink() async throws { 180 + let tmp = try makeTempDir() 181 + defer { cleanup(tmp) } 182 + 183 + let target = tmp.appendingPathComponent("target") 184 + FileManager.default.createFile(atPath: target.path, contents: nil) 185 + 186 + let installPath = tmp.appendingPathComponent("prowl") 187 + try FileManager.default.createSymbolicLink( 188 + atPath: installPath.path, 189 + withDestinationPath: target.path 190 + ) 191 + 192 + let client = CLIInstallClient.liveValue 193 + try await client.uninstall(installPath) 194 + 195 + #expect(!FileManager.default.fileExists(atPath: installPath.path)) 196 + } 197 + 198 + @Test func uninstallRefusesToRemoveRegularFile() async throws { 199 + let tmp = try makeTempDir() 200 + defer { cleanup(tmp) } 201 + 202 + let installPath = tmp.appendingPathComponent("prowl") 203 + FileManager.default.createFile(atPath: installPath.path, contents: Data("binary".utf8)) 204 + 205 + let client = CLIInstallClient.liveValue 206 + 207 + do { 208 + try await client.uninstall(installPath) 209 + Issue.record("Expected uninstall to throw for non-symlink file") 210 + } catch let error as CLIInstallError { 211 + #expect(error.message.contains("not a symlink")) 212 + } 213 + #expect(FileManager.default.fileExists(atPath: installPath.path)) 214 + } 215 + 216 + @Test func installRefusesToOverwriteRegularFile() async throws { 217 + let tmp = try makeTempDir() 218 + defer { cleanup(tmp) } 219 + 220 + let fakeBundled = tmp.appendingPathComponent("source/prowl") 221 + try FileManager.default.createDirectory( 222 + at: fakeBundled.deletingLastPathComponent(), 223 + withIntermediateDirectories: true 224 + ) 225 + FileManager.default.createFile(atPath: fakeBundled.path, contents: Data("cli".utf8)) 226 + 227 + let installPath = tmp.appendingPathComponent("prowl") 228 + FileManager.default.createFile(atPath: installPath.path, contents: Data("existing".utf8)) 229 + 230 + let client = CLIInstallClient.liveValue 231 + 232 + do { 233 + try await client.install(installPath) 234 + Issue.record("Expected install to throw for non-symlink target") 235 + } catch let error as CLIInstallError { 236 + #expect(error.message.contains("not a symlink")) 237 + } 238 + // Original file must be preserved 239 + let contents = FileManager.default.contents(atPath: installPath.path) 240 + #expect(contents == Data("existing".utf8)) 241 + } 242 + 243 + @Test func installOverwritesExistingSymlink() async throws { 244 + let tmp = try makeTempDir() 245 + defer { cleanup(tmp) } 246 + 247 + let fakeBundled = tmp.appendingPathComponent("source/prowl") 248 + try FileManager.default.createDirectory( 249 + at: fakeBundled.deletingLastPathComponent(), 250 + withIntermediateDirectories: true 251 + ) 252 + FileManager.default.createFile(atPath: fakeBundled.path, contents: Data("cli".utf8)) 253 + 254 + let oldTarget = tmp.appendingPathComponent("old-prowl") 255 + FileManager.default.createFile(atPath: oldTarget.path, contents: nil) 256 + 257 + let installPath = tmp.appendingPathComponent("prowl") 258 + try FileManager.default.createSymbolicLink( 259 + atPath: installPath.path, 260 + withDestinationPath: oldTarget.path 261 + ) 262 + 263 + let client = makeTestInstallClient(bundledBinary: fakeBundled) 264 + try await client.install(installPath) 265 + 266 + let newTarget = try FileManager.default.destinationOfSymbolicLink(atPath: installPath.path) 267 + #expect(newTarget == fakeBundled.path) 268 + } 269 + 270 + @Test func uninstallThrowsWhenNoFileExists() async throws { 271 + let tmp = try makeTempDir() 272 + defer { cleanup(tmp) } 273 + 274 + let installPath = tmp.appendingPathComponent("prowl") 275 + let client = CLIInstallClient.liveValue 276 + 277 + do { 278 + try await client.uninstall(installPath) 279 + Issue.record("Expected uninstall to throw when file does not exist") 280 + } catch let error as CLIInstallError { 281 + #expect(error.message.contains("No CLI tool found")) 282 + } 283 + } 284 + 285 + /// Creates a test install client that uses a fake bundled binary path instead of Bundle.main. 286 + private func makeTestInstallClient(bundledBinary: URL) -> CLIInstallClient { 287 + CLIInstallClient( 288 + bundledCLIURL: { bundledBinary }, 289 + installationStatus: CLIInstallClient.liveValue.installationStatus, 290 + install: { installPath in 291 + let fileManager = FileManager.default 292 + let bundledPath = bundledBinary.path(percentEncoded: false) 293 + guard fileManager.fileExists(atPath: bundledPath) else { 294 + throw CLIInstallError(message: "Bundled CLI binary not found.") 295 + } 296 + let installDir = installPath.deletingLastPathComponent().path(percentEncoded: false) 297 + if !fileManager.fileExists(atPath: installDir) { 298 + try fileManager.createDirectory(atPath: installDir, withIntermediateDirectories: true) 299 + } 300 + let destination = installPath.path(percentEncoded: false) 301 + if fileManager.fileExists(atPath: destination) { 302 + let attrs = try? fileManager.attributesOfItem(atPath: destination) 303 + let isSymlink = attrs?[.type] as? FileAttributeType == .typeSymbolicLink 304 + guard isSymlink else { 305 + throw CLIInstallError( 306 + message: "A file already exists at \(destination) and is not a symlink." 307 + ) 308 + } 309 + try fileManager.removeItem(atPath: destination) 310 + } 311 + try fileManager.createSymbolicLink(atPath: destination, withDestinationPath: bundledPath) 312 + }, 313 + uninstall: CLIInstallClient.liveValue.uninstall 314 + ) 315 + } 316 + }
+1
supacodeTests/CommandPaletteFeatureTests.swift
··· 16 16 "global.open-repository", 17 17 "global.new-worktree", 18 18 "global.refresh-worktrees", 19 + "global.install-cli", 19 20 ] 20 21 #if DEBUG 21 22 expectedIDs.append(contentsOf: [