diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b887d76..19bad84 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -201,7 +201,8 @@ jobs: "$intel_artifact" \ -output "$universal_artifact" chmod 755 "$universal_artifact" - xcrun lipo "$universal_artifact" -verify_arch arm64 x86_64 + xcrun lipo "$universal_artifact" -verify_arch arm64 + xcrun lipo "$universal_artifact" -verify_arch x86_64 - name: Import Developer ID identity shell: bash diff --git a/Documentation/ExtensionAPI.md b/Documentation/ExtensionAPI.md index b70cb35..56e2fc0 100644 --- a/Documentation/ExtensionAPI.md +++ b/Documentation/ExtensionAPI.md @@ -10,6 +10,7 @@ Pass `--json` before the subcommand or directly to a supported command: fxcodex --json status fxcodex workspace list --json fxcodex open work --json +fxcodex open --workspace-id 00000000-0000-0000-0000-000000000001 --json fxcodex delete work personal --yes --json ``` @@ -59,8 +60,12 @@ standard output: } ``` -All JSON object property names use lower snake case, including properties in -nested response data. Enum and outcome strings retain their documented values. +All API-owned JSON object property names use lower snake case, including +properties in nested response data. Integration attribute values and +integration-owned dictionaries are opaque JSON: their keys are preserved +exactly. Integrations are encouraged to use lower snake case for their own keys +to keep the complete payload consistent. Enum and outcome strings retain their +documented values. The machine interface is available for: @@ -77,11 +82,67 @@ The machine interface is available for: - `workspace open` and its root `open` alias - `workspace delete` and its root `delete` alias - `workspace erase` and its root `erase` alias +- `integrations attributes get` +- `integrations attributes set` +- `integrations attributes remove` - `integrations raycast status` +- `integrations raycast install` JSON mode never presents an interactive prompt. `use` requires a workspace name. `delete` and `erase` require at least one workspace name and `--yes`. -`open` without a name opens or focuses the current workspace. +`open` without a name opens or focuses the current workspace. Every integration +attribute command requires an explicit integration identifier, and `set` also +requires an explicit value. `integrations raycast install` requires an explicit +`app` or `script-command` component. Script Command installation also requires +`--directory` unless an installed configuration already records the directory. + +## Stable workspace identities + +Schema 2.0 assigns every workspace a stable lowercase UUID. Workspace names +remain the human-facing command-line identifiers, while IDs let frontends keep +references that survive a rename. + +`status` includes `current_workspace_id`. When workspaces are requested, each +workspace record includes `workspace.id` alongside its name and kind. A +frontend that already has an ID can open that workspace without resolving its +current name: + +```sh +fxcodex open \ + --workspace-id 00000000-0000-0000-0000-000000000001 \ + --json +``` + +The ID and workspace name are mutually exclusive for `open`. Other workspace +mutation commands continue to accept names in 0.2.0. + +Integration attributes expose frontend-owned JSON stored in the shared +configuration: + +```sh +fxcodex integrations attributes get raycast --path script_commands --json +fxcodex integrations attributes set raycast '{"enabled":true}' --path settings --json +fxcodex integrations attributes remove raycast --path settings --json +``` + +`get` and `set` return the selected `CodableValue` as the response data. +Syntactically valid JSON passed to `set` preserves its JSON type; other input is +stored as a string. `remove` returns an object containing `integration` and +`path`. An omitted `--path` addresses the integration root. + +Raycast installation returns the selected `component`, an `outcome`, and the +resulting component status: + +```sh +fxcodex integrations raycast install app --json +fxcodex integrations raycast install script-command \ + --directory "$HOME/.config/raycast/script-commands" \ + --json +``` + +Application outcomes are `already-installed`, `installed`, or +`download-opened`. Script Command installation returns `installed` together +with `script_commands`. `rename` requests the `Codex.app` name and returns a `CodexApplicationRenameResult`. `rename --undo` requests `ChatGPT.app`. The @@ -116,7 +177,14 @@ Set a scoped environment variable to `-1` to exclude that section even when all sections are enabled. On the command line, use the corresponding `--no-list-...` flag. -`cli`, `exec`, `uninstall`, and integration installation commands are -intentionally outside the machine API. A frontend should use workspace/status -commands and refresh its state after mutations. Raycast Script Commands remain -an optional integration. +The following commands are intentionally outside the 0.2.0 machine API: + +- `cli` and `exec` replace the fxcodex process with Codex CLI, whose session or + output may be interactive. +- The root `uninstall` command removes the current executable. +- `integrations raycast sync` and `integrations raycast uninstall` are + terminal-only maintenance commands in 0.2.0. + +A frontend should use the supported workspace and status commands and refresh +its state after mutations. Raycast Script Commands remain an optional +integration. diff --git a/Documentation/Releases.md b/Documentation/Releases.md index 60ecb81..2ad5835 100644 --- a/Documentation/Releases.md +++ b/Documentation/Releases.md @@ -89,6 +89,12 @@ dist/fxcodex-universal-apple-darwin dist/fxcodex-universal-apple-darwin.sha256 ``` +The script uses separate per-architecture scratch directories and SwiftPM's +native build engine. This keeps build-tool and macro-plugin executables on the +host architecture while Xcode 27 cross-compiles the Intel product slice. Xcode +27 ships an arm64-only Swift toolchain, so running the compiler under Rosetta +is neither required nor supported. + Override `OUTPUT_PATH` when invoking the script directly to place the binary elsewhere, including the Raycast extension's `assets/bin` directory. The universal artifact is the default choice for manual installation and extension diff --git a/Makefile b/Makefile index f18ce71..9a2eaa9 100644 --- a/Makefile +++ b/Makefile @@ -49,6 +49,7 @@ universal: CONFIGURATION="release" \ MACOS_DEPLOYMENT_TARGET="$(MACOS_DEPLOYMENT_TARGET)" \ OUTPUT_PATH="$(DISTDIR)/$(UNIVERSAL_RELEASE_ARTIFACT)" \ + BUILD_SYSTEM="native" \ SWIFT="$(SWIFT)" \ "$(UNIVERSAL_BUILD_SCRIPT)" diff --git a/Package.swift b/Package.swift index 72d02f2..6abbfb3 100644 --- a/Package.swift +++ b/Package.swift @@ -30,6 +30,10 @@ let package = Package( url: "https://github.com/pointfreeco/swift-case-paths.git", .upToNextMajor(from: "1.9.0") ), + .package( + url: "https://github.com/pointfreeco/swift-parsing.git", + .upToNextMajor(from: "0.15.0") + ), .package( url: "https://github.com/onmyway133/Promptberry.git", .upToNextMajor(from: "1.0.0") @@ -81,6 +85,10 @@ let package = Package( name: "CasePaths", package: "swift-case-paths" ), + .product( + name: "Parsing", + package: "swift-parsing" + ), ], path: "Sources/fxcodex-client" ), diff --git a/README.md b/README.md index eb8e853..13959c5 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,31 @@ name. `fxcodex` is not affiliated with or endorsed by OpenAI or Raycast. +## Table of contents + +- [How it works](#how-it-works) +- [Requirements](#requirements) +- [Installation](#installation) + - [Brew](#brew) + - [Download a release](#download-a-release) + - [Build from source](#build-from-source) +- [Quick start](#quick-start) +- [Desktop and terminal usage](#desktop-and-terminal-usage) +- [Managing workspaces](#managing-workspaces) +- [App naming](#app-naming) +- [Updates](#updates) +- [Integration attributes](#integration-attributes) +- [Raycast integration](#raycast-integration) + - [Script Commands](#script-commands) + - [Extension](#extension) +- [Machine-readable output](#machine-readable-output) +- [Uninstalling](#uninstalling) +- [Data and privacy](#data-and-privacy) + - [Upgrading from 0.1.x to 0.2.y](#upgrading-from-01x-to-02y) +- [Development](#development) +- [Publishing a release](#publishing-a-release) +- [License](#license) + ## How it works A **workspace** is an isolated Codex profile, not a source-code project or @@ -253,6 +278,22 @@ Automatic updates apply only to an external `fxcodex` executable. Applications that bundle `fxcodex`, such as the companion Raycast extension, manage their bundled executable separately. +## Integration attributes + +Frontends can store integration-owned JSON values in the shared fxcodex +configuration. Read, replace, or remove either an integration root or a nested +attribute selected with `--path`: + +```sh +fxcodex integrations attributes get raycast --path script_commands +fxcodex integrations attributes set raycast '{"enabled":true}' --path settings +fxcodex integrations attributes remove raycast --path settings +``` + +Omitting the integration identifier or value starts guided input in an +interactive terminal. Values that parse as JSON retain their JSON type; +otherwise, `set` stores the input as a string. + ## Raycast integration ### Script Commands @@ -276,14 +317,17 @@ fxcodex integrations raycast uninstall script-command By default, installation creates commands for every workspace and records the integration so commands remain synchronized when workspaces are created, renamed, erased, or deleted. Use `--current-only` during installation to create -only the current workspace's command, and `--directory ` to select the -Raycast Script Commands directory explicitly. +only the command for the workspace that is current at installation time. That +workspace remains selected by its stable ID if it is renamed; newly created or +later-selected workspaces are not added automatically. Reinstall without +`--current-only` to return to automatic all-workspace management. Use +`--directory ` to select the Raycast Script Commands directory explicitly. ### Extension A companion Raycast extension provides workspace navigation, management, custom icons, executable selection, and preferences. It is prepared separately -for Raycast Store submission after the first CLI release; the Script Commands +for [Raycast Store submission](https://github.com/raycast/extensions/pull/29538); the Script Commands above are available without it. ## Machine-readable output @@ -294,11 +338,20 @@ integrations: ```sh fxcodex --json status --all FXCODEX_JSON=1 fxcodex workspace list +fxcodex integrations attributes get raycast --path script_commands --json +fxcodex integrations raycast install script-command \ + --directory "$HOME/.config/raycast/script-commands" \ + --json ``` Use `--no-json` to override the environment variable. Interactive selection is disabled when required input is missing in JSON mode, so automation should pass -workspace names and confirmation flags explicitly. +workspace names and confirmation flags explicitly. Attribute commands require +an integration identifier, and `attributes set` also requires a value. JSON +`get` and `set` responses contain the attribute value; `remove` returns the +integration identifier and removed path. API-owned keys use lower snake case; +opaque integration attribute keys are preserved exactly, and integrations are +encouraged to use lower snake case for them. ## Uninstalling @@ -325,18 +378,49 @@ delete it manually after the command completes. ```text ~/Library/Application Support/fxcodex/ ├── configuration.json -├── instances.json ├── preferences.json +├── runtime.json +├── storage.lock ├── update-state.json └── workspaces/ - └── / - ├── codex-home/ - └── user-data/ + └── / + ├── workspace.json + ├── codex-home/ # managed workspaces only + └── user-data/ # managed workspaces only ``` -The `primary` workspace continues to use Codex's normal locations. Managed -workspace directories contain account-specific Codex state; treat them as -private data and do not commit or share them. +`configuration.json` stores the current workspace ID and integration-owned +attributes. `runtime.json` contains transient application-instance records, and +`workspace.json` maps each stable workspace ID to its display name and kind. +Some files are created only after their corresponding feature is used. + +The `primary` workspace has metadata under `workspaces/`, but continues to use +Codex's normal data locations. Managed workspace directories contain +account-specific Codex state; treat the complete support directory as private +data and do not commit or share it. + +### Upgrading from 0.1.x to 0.2.y + +The first 0.2.x command migrates schema 1.0 storage to schema 2.0. In an +interactive terminal, `fxcodex` explains the migration and requests +confirmation. Non-interactive callers proceed automatically because the +migration does not require additional input. + +Close every managed Codex workspace before running the first 0.2.x command. +Migration refuses to move a workspace whose recorded Codex process is still +running. A live Codex process retains its original `CODEX_HOME` and user-data +paths and can otherwise recreate the legacy name-based directory after it is +moved. + +The migration assigns stable IDs, moves managed workspace directories from +name-based to ID-based paths, converts runtime records and integration +attributes, and validates the new storage before continuing. An interrupted +migration records its progress in `.migration.json` and resumes on the next +invocation. + +Schema 2.0 is not readable by fxcodex 0.1.x. Back up +`~/Library/Application Support/fxcodex` before upgrading if you may need to +return to 0.1.x. ## Development @@ -349,6 +433,11 @@ make release make universal ``` +`make universal` cross-builds the Intel slice with SwiftPM's native build +engine so macro plugins remain executable on the Apple Silicon build host. It +works with the Swift 6.3 toolchain used by CI and the Swift 6.4 toolchain in +Xcode 27; Rosetta is not required. + The package targets macOS 14 and uses Swift 6 language mode. Run `fxcodex help ` or `fxcodex --help` for the complete CLI reference. diff --git a/Scripts/build-universal.sh b/Scripts/build-universal.sh index 57a87bb..ddd73a2 100755 --- a/Scripts/build-universal.sh +++ b/Scripts/build-universal.sh @@ -7,6 +7,7 @@ CONFIGURATION="${CONFIGURATION:-release}" MACOS_DEPLOYMENT_TARGET="${MACOS_DEPLOYMENT_TARGET:-14.0}" SCRATCH_PATH="${SCRATCH_PATH:-.build/universal}" OUTPUT_PATH="${OUTPUT_PATH:-dist/${PRODUCT}-universal-apple-darwin}" +BUILD_SYSTEM="${BUILD_SYSTEM:-native}" SWIFT="${SWIFT:-swift}" XCRUN="${XCRUN:-xcrun}" @@ -23,6 +24,7 @@ usage() { " MACOS_DEPLOYMENT_TARGET Minimum macOS version (default: 14.0)" \ " SCRATCH_PATH SwiftPM scratch path (default: .build/universal)" \ " OUTPUT_PATH Output executable path" \ + " BUILD_SYSTEM SwiftPM build system (default: native)" \ " SWIFT Swift executable (default: swift)" \ " XCRUN xcrun executable (default: xcrun)" } @@ -56,21 +58,25 @@ esac build_architecture() { architecture="$1" triple="${architecture}-apple-macosx${MACOS_DEPLOYMENT_TARGET}" + architecture_scratch_path="$SCRATCH_PATH/$architecture" printf 'Building %s for %s...\n' "$PRODUCT" "$triple" "$SWIFT" build \ --package-path "$REPOSITORY_ROOT" \ - --scratch-path "$SCRATCH_PATH" \ + --scratch-path "$architecture_scratch_path" \ --configuration "$CONFIGURATION" \ --product "$PRODUCT" \ - --triple "$triple" + --triple "$triple" \ + --build-system "$BUILD_SYSTEM" } +rm -f "$OUTPUT_PATH" "$OUTPUT_PATH.sha256" + build_architecture arm64 build_architecture x86_64 -ARM64_EXECUTABLE="$SCRATCH_PATH/arm64-apple-macosx/$CONFIGURATION/$PRODUCT" -X86_64_EXECUTABLE="$SCRATCH_PATH/x86_64-apple-macosx/$CONFIGURATION/$PRODUCT" +ARM64_EXECUTABLE="$SCRATCH_PATH/arm64/arm64-apple-macosx/$CONFIGURATION/$PRODUCT" +X86_64_EXECUTABLE="$SCRATCH_PATH/x86_64/x86_64-apple-macosx/$CONFIGURATION/$PRODUCT" OUTPUT_DIRECTORY=$(dirname -- "$OUTPUT_PATH") OUTPUT_NAME=$(basename -- "$OUTPUT_PATH") @@ -91,7 +97,8 @@ mkdir -p "$OUTPUT_DIRECTORY" "$X86_64_EXECUTABLE" \ -output "$OUTPUT_PATH" chmod 755 "$OUTPUT_PATH" -"$XCRUN" lipo "$OUTPUT_PATH" -verify_arch arm64 x86_64 +"$XCRUN" lipo "$OUTPUT_PATH" -verify_arch arm64 +"$XCRUN" lipo "$OUTPUT_PATH" -verify_arch x86_64 ( cd "$OUTPUT_DIRECTORY" diff --git a/Sources/fxcodex-cli/AppCommand.swift b/Sources/fxcodex-cli/AppCommand.swift index 734dbb6..af73eda 100644 --- a/Sources/fxcodex-cli/AppCommand.swift +++ b/Sources/fxcodex-cli/AppCommand.swift @@ -6,7 +6,7 @@ import FXCodexClient @main internal struct AppCommand: AsyncParsableCommand { - internal static let version: String = "0.1.1" + internal static let version: String = "0.2.0" internal static let machineEncodingFailureResponse: String = """ { "api_version": 1, @@ -59,12 +59,14 @@ internal struct AppCommand: AsyncParsableCommand { for warning in warnings { try printMachineWarning(warning) } + } else { let reporter: TerminalReporter = .init() for warning in warnings { reporter.warning(warning.message) } } + try await Self.execute(command) } catch { guard globalMachineOutputRequested(), !(error is CleanExit) else { @@ -86,6 +88,10 @@ internal struct AppCommand: AsyncParsableCommand { internal static func prepareForExecution( _ command: any ParsableCommand ) async throws -> [FXCodexWarning] { + @Dependency(\.fxCodexClient) + var client: FXCodexClient + + try await MigrationAssistant.prepareStorage(client: client) guard !(command is Self) else { return [] } guard !(command is PreferencesCommand.List) else { return [] } guard !(command is PreferencesCommand.Set) else { return [] } @@ -93,9 +99,9 @@ internal struct AppCommand: AsyncParsableCommand { guard !(command is UpdateCommand) else { return [] } guard !(command is UninstallCommand) else { return [] } - @Dependency(\.fxCodexClient) var client: FXCodexClient guard let version = SemanticVersion(Self.version) else { throw ValidationError("fxcodex has an invalid embedded version.") } + let executableURL: URL = currentExecutableURL() return try await client.applyAutomaticPreferences( version, @@ -112,6 +118,7 @@ internal struct AppCommand: AsyncParsableCommand { _ command: any ParsableCommand ) async throws { var command: any ParsableCommand = command + if var asyncCommand = command as? any AsyncParsableCommand { try await asyncCommand.run() } else { diff --git a/Sources/fxcodex-cli/Commands/CLICommand.swift b/Sources/fxcodex-cli/Commands/CLICommand.swift index a405985..4bcdd07 100644 --- a/Sources/fxcodex-cli/Commands/CLICommand.swift +++ b/Sources/fxcodex-cli/Commands/CLICommand.swift @@ -23,7 +23,9 @@ extension AppCommand { internal func run() async throws { try rejectMachineOutput(for: "cli") - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + let invocation: CommandInvocation = try await client.codexInvocation( self.workspaceName, forwardedArguments(from: self.arguments) diff --git a/Sources/fxcodex-cli/Commands/ExecCommand.swift b/Sources/fxcodex-cli/Commands/ExecCommand.swift index 1c81538..b603c50 100644 --- a/Sources/fxcodex-cli/Commands/ExecCommand.swift +++ b/Sources/fxcodex-cli/Commands/ExecCommand.swift @@ -23,7 +23,9 @@ extension AppCommand { internal func run() async throws { try rejectMachineOutput(for: "exec") - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + let invocation: CommandInvocation = try await client.codexInvocation( self.workspaceName, ["exec"] + forwardedArguments(from: self.arguments) diff --git a/Sources/fxcodex-cli/Commands/Integrations/IntegrationCommand.swift b/Sources/fxcodex-cli/Commands/Integrations/IntegrationCommand.swift index 19cdff2..04deaa9 100644 --- a/Sources/fxcodex-cli/Commands/Integrations/IntegrationCommand.swift +++ b/Sources/fxcodex-cli/Commands/Integrations/IntegrationCommand.swift @@ -9,12 +9,303 @@ extension AppCommand { internal static let configuration: CommandConfiguration = .init( commandName: "integrations", abstract: "Manage integrations with other applications.", - subcommands: [Raycast.self], + subcommands: [Raycast.self, Attributes.self], defaultSubcommand: nil ) internal init() {} + + internal func run() async throws { + guard !globalMachineOutputRequested() else { + throw ValidationError("Choose attributes or raycast when using --json.") + } + + @Dependency(\._fxcodexTerminalPrompts) + var prompts: TerminalPromptsClient + + guard + let area = try prompts.select( + "Choose an integration area:", + [ + .init(value: "attributes", label: "Attributes", hint: "Read or modify integration data"), + .init(value: "raycast", label: "Raycast", hint: "Show Raycast integration status"), + ] + ) + else { return } + + switch area { + case "attributes": + try await Attributes().run() + case "raycast": + try await Raycast.Status().run() + default: + throw ValidationError("Unsupported integration area.") + } + } + } +} + +extension AppCommand.IntegrationsCommand { + internal struct Attributes: AsyncParsableCommand { + internal static let configuration: CommandConfiguration = .init( + commandName: "attributes", + abstract: "Read and modify integration-owned attributes.", + subcommands: [Get.self, Set.self, Remove.self] + ) + + internal init() {} + + internal func run() async throws { + guard !globalMachineOutputRequested() else { + throw ValidationError("Choose get, set, or remove when using --json.") + } + + @Dependency(\._fxcodexTerminalPrompts) + var prompts: TerminalPromptsClient + + guard + let operation = try prompts.select( + "Choose an attribute operation:", + [ + .init(value: "get", label: "Get", hint: "Read a value"), + .init(value: "set", label: "Set", hint: "Create or replace a value"), + .init(value: "remove", label: "Remove", hint: "Delete a value"), + ] + ) + else { return } + + let reporter = await TerminalReporter() + + guard let integration = try Attributes.resolveIntegration( + nil, + json: false, + prompts: prompts + ) else { return } + + let path = await reporter.ask("Attribute path (blank for root):") + + switch operation { + case "get": + try Attributes.printValue(try Attributes.attributes.get(integration, .init(path))) + + case "set": + let rawValue = await reporter.ask("JSON value (plain text is stored as a string):") + + try Attributes.attributes.set(integration, .init(path), Attributes.decodeValue(rawValue)) + await reporter.success("Integration attributes updated.") + + case "remove": + guard try prompts.confirm("Remove '\(integration).\(path)'?") == true else { return } + + try Attributes.attributes.remove(integration, .init(path)) + await reporter.success("Integration attribute removed.") + + default: + throw ValidationError("Unsupported attribute operation.") + } + } + } +} + +extension AppCommand.IntegrationsCommand.Attributes { + internal struct Get: AsyncParsableCommand { + internal static let configuration: CommandConfiguration = .init(abstract: "Read an integration attribute.") + + @Argument(help: "Integration identifier, for example raycast. Omit to choose interactively.") + internal var integration: String? + + @Option(name: .long, help: "Attribute path. Omit to read the integration root.") + internal var path: String = "" + + @Flag( + inversion: .prefixedNo, + exclusivity: .chooseLast, + help: "Print a versioned machine-readable JSON response. Defaults from FXCODEX_JSON." + ) + internal var json: Bool? + + internal init() {} + + internal func run() async throws { + @Dependency(\._fxcodexTerminalPrompts) + var prompts: TerminalPromptsClient + + let json = machineOutputRequested(self.json) + + guard let integration = try AppCommand.IntegrationsCommand.Attributes.resolveIntegration( + self.integration, + json: json, + prompts: prompts + ) else { return } + + let value = try AppCommand.IntegrationsCommand.Attributes.attributes.get(integration, .init(self.path)) + + if json { + try printMachineResponse(value) + } else { + try AppCommand.IntegrationsCommand.Attributes.printValue(value) + } + } } + + internal struct Set: AsyncParsableCommand { + internal static let configuration: CommandConfiguration = .init(abstract: "Create or replace an integration attribute.") + + @Argument(help: "Integration identifier, for example raycast. Omit to choose interactively.") + internal var integration: String? + + @Argument(help: "JSON value. Invalid JSON is stored as a string. Omit to enter interactively.") + internal var value: String? + + @Option(name: .long, help: "Attribute path. Omit to replace the integration root.") + internal var path: String = "" + + @Flag( + inversion: .prefixedNo, + exclusivity: .chooseLast, + help: "Print a versioned machine-readable JSON response. Defaults from FXCODEX_JSON." + ) + internal var json: Bool? + + internal init() {} + + internal func run() async throws { + @Dependency(\._fxcodexTerminalPrompts) + var prompts: TerminalPromptsClient + + let json = machineOutputRequested(self.json) + + guard let integration = try AppCommand.IntegrationsCommand.Attributes.resolveIntegration( + self.integration, + json: json, + prompts: prompts + ) else { return } + + let rawValue: String + if let value = self.value { + rawValue = value + } else if json { + throw ValidationError("An attribute value is required when using --json.") + } else if let value = try prompts.text( + "JSON value (plain text is stored as a string):", + "{\"key\": \"value\"}" + ) { + rawValue = value + } else { return } + + let value = AppCommand.IntegrationsCommand.Attributes.decodeValue(rawValue) + try AppCommand.IntegrationsCommand.Attributes.attributes.set(integration, .init(self.path), value) + + if json { + try printMachineResponse(value) + + } else { + let reporter = await TerminalReporter() + await reporter.success("Integration attributes updated.") + } + } + } + + internal struct Remove: AsyncParsableCommand { + internal static let configuration: CommandConfiguration = .init(abstract: "Remove an integration attribute.") + + @Argument(help: "Integration identifier, for example raycast. Omit to choose interactively.") + internal var integration: String? + + @Option(name: .long, help: "Attribute path. Omit to remove the integration root.") + internal var path: String = "" + + @Flag( + inversion: .prefixedNo, + exclusivity: .chooseLast, + help: "Print a versioned machine-readable JSON response. Defaults from FXCODEX_JSON." + ) + internal var json: Bool? + + internal init() {} + + internal func run() async throws { + @Dependency(\._fxcodexTerminalPrompts) + var prompts: TerminalPromptsClient + + let json = machineOutputRequested(self.json) + + guard let integration = try AppCommand.IntegrationsCommand.Attributes.resolveIntegration( + self.integration, + json: json, + prompts: prompts + ) else { return } + + try AppCommand.IntegrationsCommand.Attributes.attributes.remove(integration, .init(self.path)) + + if json { + try printMachineResponse(AttributeRemovalOutput(integration: integration, path: self.path)) + + } else { + let reporter = await TerminalReporter() + await reporter.success("Integration attribute removed.") + } + } + } + + fileprivate static var attributes: IntegrationAttributes { + @Dependency(\.fxCodexClient) + var client: FXCodexClient + + return client.integrations.attributes + } + + fileprivate static func resolveIntegration( + _ provided: String?, + json: Bool, + prompts: TerminalPromptsClient + ) throws -> String? { + if let provided { return provided } + + guard !json else { + throw ValidationError("An integration identifier is required when using --json.") + } + + let registeredIdentifiers = ["raycast"] + let identifiers = (try Self.attributes.list() + registeredIdentifiers) + .uniqued() + .sorted() + + let manualValue = "__fxcodex_manual_integration__" + let options = identifiers.map { + TerminalPromptOption(value: $0, label: $0, hint: nil) + } + [ + .init(value: manualValue, label: "Enter manually…", hint: "Use another integration identifier"), + ] + + guard let selected = try prompts.select("Select an integration:", options) else { return nil } + + if selected == manualValue { + return try prompts.text("Integration identifier:", "raycast") + } + + return selected + } + + fileprivate static func decodeValue(_ rawValue: String) -> CodableValue { + (try? JSONDecoder().decode(CodableValue.self, from: Data(rawValue.utf8))) ?? .string(rawValue) + } + + fileprivate static func printValue(_ value: CodableValue) throws { + let encoder = FXCodexJSONCoding.encoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + + guard let output = String(data: try encoder.encode(value), encoding: .utf8) else { + throw CocoaError(.fileWriteInapplicableStringEncoding) + } + + Swift.print(output) + } +} + +private struct AttributeRemovalOutput: Encodable { + let integration: String + let path: String } extension AppCommand.IntegrationsCommand { diff --git a/Sources/fxcodex-cli/Commands/Integrations/Integrations+RaycastCommand.swift b/Sources/fxcodex-cli/Commands/Integrations/Integrations+RaycastCommand.swift index d07ade7..90d9853 100644 --- a/Sources/fxcodex-cli/Commands/Integrations/Integrations+RaycastCommand.swift +++ b/Sources/fxcodex-cli/Commands/Integrations/Integrations+RaycastCommand.swift @@ -25,15 +25,17 @@ extension AppCommand.IntegrationsCommand.Raycast { internal init() {} internal func run() async throws { - try rejectMachineOutput(for: "integrations raycast install") + @Dependency(\.fxCodexClient) + var client: FXCodexClient - @Dependency(\.fxCodexClient) var client: FXCodexClient var applications: [RaycastApplicationStatus] = [] + for edition in RaycastEdition.allCases { applications.append( try await client.integrations.raycast.applicationStatus(edition) ) } + let scripts: RaycastScriptCommandStatus = try await client.integrations.raycast.scriptCommandStatus() if machineOutputRequested(self.json) { @@ -45,12 +47,15 @@ extension AppCommand.IntegrationsCommand.Raycast { } let reporter: TerminalReporter = await .init() + for application in applications { let description: String = application.applicationURL == nil ? "not installed" : "installed · \(application.version ?? "unknown version")" + await reporter.info("\(application.edition.displayName): \(description)") } + await reporter.info("Script Commands: \(scripts.managedCommandCount) managed") if let directoryURL = scripts.directoryURL { @@ -75,52 +80,89 @@ extension AppCommand.IntegrationsCommand.Raycast { @Option(help: "Raycast Script Commands directory.") internal var directory: String? - @Flag(help: "Generate only the command for the current workspace.") + @Flag(help: "Generate only the command for the workspace that is currently selected.") internal var currentOnly: Bool = false @Flag(name: .shortAndLong, help: "Accept confirmation prompts.") internal var yes: Bool = false + @Flag( + inversion: .prefixedNo, + exclusivity: .chooseLast, + help: "Print a versioned machine-readable JSON response without prompting. Defaults from FXCODEX_JSON." + ) + internal var json: Bool? + internal init() {} internal func run() async throws { - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + + let json = machineOutputRequested(self.json) + + guard !json || self.component != nil else { + throw ValidationError("An integration component is required when using --json.") + } + let reporter: TerminalReporter = await .init(assumeYes: self.yes) let edition: RaycastEdition = self.beta ? .beta : .stable switch self.component { case .app: - try await self.installApplication( + let result = try await self.installApplication( edition: edition, client: client, - reporter: reporter + reporter: json ? nil : reporter, + suppressOutput: json ) + if json { + try printMachineResponse(Output( + component: Component.app.rawValue, + outcome: result.outcome, + application: result.status, + scriptCommands: nil + )) + } + case .scriptCommand: - try await self.installScriptCommands( + let status = try await self.installScriptCommands( client: client, - reporter: reporter + reporter: json ? nil : reporter, + json: json ) + if json { + try printMachineResponse(Output( + component: Component.scriptCommand.rawValue, + outcome: .installed, + application: nil, + scriptCommands: status + )) + } + case nil: let applicationStatus: RaycastApplicationStatus = try await client.integrations.raycast.applicationStatus( edition ) if applicationStatus.applicationURL == nil { - guard await reporter.confirm("Install \(edition.displayName)?") - else { return } - try await self.installApplication( + guard await reporter.confirm("Install \(edition.displayName)?") else { return } + + _ = try await self.installApplication( edition: edition, client: client, - reporter: reporter + reporter: reporter, + suppressOutput: false ) } if await reporter.confirm("Install fxcodex Script Commands for Raycast?") { - try await self.installScriptCommands( + _ = try await self.installScriptCommands( client: client, - reporter: reporter + reporter: reporter, + json: false ) } } @@ -134,21 +176,33 @@ extension AppCommand.IntegrationsCommand.Raycast { abstract: "Synchronize an installed integration component." ) - @Argument(help: "Component to synchronize.") - internal var component: Component + @Argument(help: "Component to synchronize. Omit to choose interactively.") + internal var component: Component? internal init() {} internal func run() async throws { try rejectMachineOutput(for: "integrations raycast sync") - guard self.component == .scriptCommand + @Dependency(\._fxcodexTerminalPrompts) + var prompts: TerminalPromptsClient + + guard let component = try AppCommand.IntegrationsCommand.Raycast.resolveMaintenanceComponent( + self.component, + action: "synchronize", + prompts: prompts + ) else { return } + + guard component == .scriptCommand else { throw ValidationError("The app component cannot be synchronized by fxcodex.") } - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + let status: RaycastScriptCommandStatus = try await client.integrations.raycast.syncScriptCommands( currentExecutableURL() ) + let reporter: TerminalReporter = await .init() await reporter.success("Synchronized \(status.managedCommandCount) Script Commands.") } @@ -159,8 +213,8 @@ extension AppCommand.IntegrationsCommand.Raycast { abstract: "Uninstall an fxcodex-managed integration component with required confirmation." ) - @Argument(help: "Component to uninstall.") - internal var component: Component + @Argument(help: "Component to uninstall. Omit to choose interactively.") + internal var component: Component? @Flag(name: .shortAndLong, help: "Confirm without prompting.") internal var yes: Bool = false @@ -170,15 +224,27 @@ extension AppCommand.IntegrationsCommand.Raycast { internal func run() async throws { try rejectMachineOutput(for: "integrations raycast uninstall") - guard self.component == .scriptCommand else { + @Dependency(\._fxcodexTerminalPrompts) + var prompts: TerminalPromptsClient + + guard let component = try AppCommand.IntegrationsCommand.Raycast.resolveMaintenanceComponent( + self.component, + action: "uninstall", + prompts: prompts + ) else { return } + + guard component == .scriptCommand else { throw ValidationError("fxcodex does not uninstall the Raycast application.") } let reporter: TerminalReporter = await .init(assumeYes: self.yes) + guard await reporter.confirm("Remove all fxcodex-managed Raycast Script Commands?") else { return } - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + _ = try await client.integrations.raycast.uninstallScriptCommands() await reporter.success("Removed fxcodex-managed Raycast Script Commands.") } @@ -201,39 +267,68 @@ extension AppCommand.IntegrationsCommand.Raycast.Status { } extension AppCommand.IntegrationsCommand.Raycast.Install { + private enum Outcome: String, Encodable { + case alreadyInstalled = "already-installed" + case installed + case downloadOpened = "download-opened" + } + + private struct Output: Encodable { + let component: String + let outcome: Outcome + let application: RaycastApplicationStatus? + let scriptCommands: RaycastScriptCommandStatus? + } + @MainActor private func installApplication( edition: RaycastEdition, client: FXCodexClient, - reporter: TerminalReporter - ) async throws { + reporter: TerminalReporter?, + suppressOutput: Bool + ) async throws -> (outcome: Outcome, status: RaycastApplicationStatus) { let installation: RaycastApplicationInstallation = try await client.integrations.raycast.applicationInstallation( edition ) + let outcome: Outcome switch installation { case let .alreadyInstalled(applicationURL): - reporter.success("\(edition.displayName) is already installed at \(applicationURL.path).") + reporter?.success("\(edition.displayName) is already installed at \(applicationURL.path).") + outcome = .alreadyInstalled case let .command(invocation): - reporter.info("Installing \(edition.displayName) using Homebrew…") - let exitCode: Int32 = try runProcess(invocation) - guard exitCode == 0 - else { throw ExitCode(exitCode) } - reporter.success("Installed \(edition.displayName).") + reporter?.info("Installing \(edition.displayName) using Homebrew…") + + let exitCode: Int32 = try runProcess( + invocation, + suppressOutput: suppressOutput + ) + + guard exitCode == 0 else { throw ExitCode(exitCode) } + + reporter?.success("Installed \(edition.displayName).") + outcome = .installed case let .externalDownload(url): - reporter.info("Opening the official \(edition.displayName) download…") - guard NSWorkspace.shared.open(url) - else { throw CocoaError(.fileNoSuchFile) } + reporter?.info("Opening the official \(edition.displayName) download…") + + guard NSWorkspace.shared.open(url) else { throw CocoaError(.fileNoSuchFile) } + outcome = .downloadOpened } + + return ( + outcome, + try await client.integrations.raycast.applicationStatus(edition) + ) } @MainActor private func installScriptCommands( client: FXCodexClient, - reporter: TerminalReporter - ) async throws { + reporter: TerminalReporter?, + json: Bool + ) async throws -> RaycastScriptCommandStatus { let existingStatus: RaycastScriptCommandStatus = try await client.integrations.raycast.scriptCommandStatus() let directoryPath: String @@ -241,7 +336,14 @@ extension AppCommand.IntegrationsCommand.Raycast.Install { directoryPath = directory } else if let existingDirectoryURL = existingStatus.directoryURL { directoryPath = existingDirectoryURL.path + } else if json { + throw ValidationError( + "--directory is required with --json when no Script Commands directory is configured." + ) } else { + guard let reporter else { + throw ValidationError("Interactive input is unavailable.") + } directoryPath = reporter.ask("Raycast Script Commands directory") } @@ -249,9 +351,37 @@ extension AppCommand.IntegrationsCommand.Raycast.Install { let status: RaycastScriptCommandStatus = try await client.integrations.raycast.installScriptCommands( URL(fileURLWithPath: expandedPath), currentExecutableURL(), - true, - !self.currentOnly + self.currentOnly ) - reporter.success("Installed \(status.managedCommandCount) Raycast Script Commands.") + + reporter?.success("Installed \(status.managedCommandCount) Raycast Script Commands.") + return status + } +} + +extension AppCommand.IntegrationsCommand.Raycast { + fileprivate static func resolveMaintenanceComponent( + _ provided: Component?, + action: String, + prompts: TerminalPromptsClient + ) throws -> Component? { + if let provided { return provided } + + guard let selected = try prompts.select( + "Select a component to \(action):", + [ + .init( + value: Component.scriptCommand.rawValue, + label: "Script Commands", + hint: nil + ), + ] + ) else { return nil } + + guard let component = Component(rawValue: selected) else { + throw ValidationError("Unsupported Raycast integration component.") + } + + return component } } diff --git a/Sources/fxcodex-cli/Commands/OpenCommand.swift b/Sources/fxcodex-cli/Commands/OpenCommand.swift index 8691cf2..0c0f6ee 100644 --- a/Sources/fxcodex-cli/Commands/OpenCommand.swift +++ b/Sources/fxcodex-cli/Commands/OpenCommand.swift @@ -9,9 +9,12 @@ extension AppCommand { abstract: "Open or focus a Codex workspace." ) - @Argument(help: "Workspace name. Defaults to the current workspace.") + @Argument(help: "Workspace name. Omit to choose interactively; JSON mode uses the current workspace.") internal var workspaceName: String? + @Option(name: .long, help: "Stable lowercase workspace UUID. Cannot be combined with a workspace name.") + internal var workspaceID: String? + @Flag( inversion: .prefixedNo, exclusivity: .chooseLast, @@ -22,14 +25,42 @@ extension AppCommand { internal init() {} internal func run() async throws { - @Dependency(\.fxCodexClient) var client: FXCodexClient - @Dependency(\._fxcodexTerminalPrompts) var prompts: TerminalPromptsClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + + @Dependency(\._fxcodexTerminalPrompts) + var prompts: TerminalPromptsClient + let json: Bool = machineOutputRequested(self.json) - let workspaceName: String - if let providedWorkspaceName = self.workspaceName { - workspaceName = providedWorkspaceName + + guard self.workspaceName == nil || self.workspaceID == nil else { + throw ValidationError("A workspace name and --workspace-id cannot be used together.") + } + + let workspace: Workspace + + if let workspaceID = self.workspaceID { + guard let id = WorkspaceID(workspaceID) else { + throw ValidationError("--workspace-id must be a lowercase UUID.") + } + + guard let selected = try await client.workspaces().first(where: { $0.id == id }) else { + throw FXCodexError.workspaceNotFound(workspaceID) + } + + workspace = selected + + } else if let providedWorkspaceName = self.workspaceName { + guard let selected = try await client.workspaces().first(where: { $0.name == providedWorkspaceName }) + else { + throw FXCodexError.workspaceNotFound(providedWorkspaceName) + } + + workspace = selected + } else if json { - workspaceName = try await client.currentWorkspace().name + workspace = try await client.currentWorkspace() + } else { let currentWorkspace: Workspace = try await client.currentWorkspace() let workspaces: [Workspace] = try await client.workspaces() @@ -38,22 +69,30 @@ extension AppCommand { } let options: [TerminalPromptOption] = orderedWorkspaces.map { workspace in .init( - value: workspace.name, + value: workspace.id.rawValue, label: workspace.name, hint: workspace.name == currentWorkspace.name ? "current" : nil ) } - guard let selectedWorkspaceName = try prompts.select( + + guard let selectedWorkspaceID = try prompts.select( "Select a workspace to open:", options ) else { return } - workspaceName = selectedWorkspaceName + + guard + let id = WorkspaceID(selectedWorkspaceID), + let selected = orderedWorkspaces.first(where: { $0.id == id }) + else { throw FXCodexError.workspaceNotFound(selectedWorkspaceID) } + + workspace = selected } - let processID: Int32 = try await client.openWorkspace(workspaceName) + let processID: Int32 = try await client.openWorkspaceByID(workspace.id) + if json { try printMachineResponse(OpenWorkspaceOutput( - workspaceName: workspaceName, + workspaceName: workspace.name, processID: processID )) return diff --git a/Sources/fxcodex-cli/Commands/Preferences/Preferences+ListCommand.swift b/Sources/fxcodex-cli/Commands/Preferences/Preferences+ListCommand.swift index 9208bd1..dbe7c5f 100644 --- a/Sources/fxcodex-cli/Commands/Preferences/Preferences+ListCommand.swift +++ b/Sources/fxcodex-cli/Commands/Preferences/Preferences+ListCommand.swift @@ -18,7 +18,9 @@ extension AppCommand.PreferencesCommand { internal init() {} internal func run() async throws { - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + let preferences: FXCodexPreferences = try await client.preferences() if machineOutputRequested(self.json) { diff --git a/Sources/fxcodex-cli/Commands/Preferences/Preferences+SetCommand.swift b/Sources/fxcodex-cli/Commands/Preferences/Preferences+SetCommand.swift index 0cde440..0b07b7a 100644 --- a/Sources/fxcodex-cli/Commands/Preferences/Preferences+SetCommand.swift +++ b/Sources/fxcodex-cli/Commands/Preferences/Preferences+SetCommand.swift @@ -8,8 +8,8 @@ extension AppCommand.PreferencesCommand { abstract: "Set an fxcodex preference." ) - @Argument(help: "Preference name.") - internal var preference: FXCodexPreference + @Argument(help: "Preference name. Omit to choose interactively.") + internal var preference: FXCodexPreference? @Argument(help: "Boolean value for auto-rename: true, false, 1, 0, on, or off.") internal var value: PreferenceValue? @@ -39,43 +39,91 @@ extension AppCommand.PreferencesCommand { internal init() {} internal func run() async throws { - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + + @Dependency(\._fxcodexTerminalPrompts) + var prompts: TerminalPromptsClient + + let json = machineOutputRequested(self.json) + + let preference: FXCodexPreference + + if let provided = self.preference { + preference = provided + + } else if json { + throw ValidationError("A preference name is required when using --json.") + + } else { + let options = FXCodexPreference.allCases.map { + TerminalPromptOption(value: $0.rawValue, label: $0.rawValue, hint: nil) + } + + guard + let selected = try prompts.select("Select a preference:", options), + let value = FXCodexPreference(rawValue: selected) + else { return } + + preference = value + } + let preferences: FXCodexPreferences let valueDescription: String - switch self.preference { + + switch preference { case .autoRename: - guard let value = self.value else { + let enabled: Bool + + if let value = self.value { + enabled = value.value + } else if json { throw ValidationError("auto-rename requires a boolean value.") - } + } else if let selected = try prompts.select( + "Automatic application rename:", + [ + .init(value: "true", label: "Enabled", hint: nil), + .init(value: "false", label: "Disabled", hint: nil), + ] + ) { + enabled = selected == "true" + } else { return } + guard self.updatePolicyOptions.isEmpty else { throw ValidationError("Automatic update options cannot be used with auto-rename.") } - preferences = try await client.setAutoRename(value.value) - valueDescription = value.value ? "enabled" : "disabled" + + preferences = try await client.setAutoRename(enabled) + valueDescription = enabled ? "enabled" : "disabled" case .autoUpdate: guard self.value == nil else { throw ValidationError("auto-update uses --patch-from, --minor-from, --major-from, --latest-from, or --disabled.") } - let policy: AutoUpdatePolicy = try self.autoUpdatePolicy() + + let policy: AutoUpdatePolicy = try self.resolveAutoUpdatePolicy( + json: json, + prompts: prompts + ) preferences = try await client.setAutoUpdate(policy) valueDescription = policy.description } - if machineOutputRequested(self.json) { + if json { try printMachineResponse(preferences) return } let reporter: TerminalReporter = await .init() await reporter.success( - "Set '\(self.preference.rawValue)' to \(valueDescription)." + "Set '\(preference.rawValue)' to \(valueDescription)." ) } } } extension FXCodexPreference: ExpressibleByArgument {} + extension SemanticVersion: ExpressibleByArgument { public init?(argument: String) { self.init(argument) @@ -85,32 +133,79 @@ extension SemanticVersion: ExpressibleByArgument { extension AppCommand.PreferencesCommand.Set { private var updatePolicyOptions: [AutoUpdatePolicy] { var options: [AutoUpdatePolicy] = [] + if let patchFrom = self.patchFrom { options.append(.patch(from: patchFrom)) } + if let minorFrom = self.minorFrom { options.append(.minor(from: minorFrom)) } + if let majorFrom = self.majorFrom { options.append(.major(from: majorFrom)) } + if let latestFrom = self.latestFrom { options.append(.latest(from: latestFrom)) } + if self.disabled { options.append(.disabled) } + return options } private func autoUpdatePolicy() throws -> AutoUpdatePolicy { - guard self.updatePolicyOptions.count == 1, + guard + self.updatePolicyOptions.count == 1, let policy = self.updatePolicyOptions.first else { throw ValidationError( "Choose exactly one of --patch-from, --minor-from, --major-from, --latest-from, or --disabled." ) } + return policy } + + private func resolveAutoUpdatePolicy( + json: Bool, + prompts: TerminalPromptsClient + ) throws -> AutoUpdatePolicy { + if !self.updatePolicyOptions.isEmpty || json { + return try self.autoUpdatePolicy() + } + + guard + let channel = try prompts.select( + "Automatic update policy:", + [ + .init(value: "disabled", label: "Disabled", hint: nil), + .init(value: "patch", label: "Patch", hint: "Only patch releases"), + .init(value: "minor", label: "Minor", hint: "Patch and minor releases"), + .init(value: "major", label: "Stable", hint: "Any stable release"), + .init(value: "latest", label: "Latest", hint: "Include prereleases"), + ] + ) + else { throw CleanExit.message("Cancelled.") } + + guard channel != "disabled" else { return .disabled } + + guard + let rawVersion = try prompts.text("Minimum version:", AppCommand.version), + let version = SemanticVersion(rawVersion) + else { + throw ValidationError("The minimum version must use semantic version format.") + } + + switch channel { + case "patch": return .patch(from: version) + case "minor": return .minor(from: version) + case "major": return .major(from: version) + case "latest": return .latest(from: version) + default: throw ValidationError("Unsupported automatic update policy.") + } + } } diff --git a/Sources/fxcodex-cli/Commands/RenameApplicationCommand.swift b/Sources/fxcodex-cli/Commands/RenameApplicationCommand.swift index 36b8d38..0189eda 100644 --- a/Sources/fxcodex-cli/Commands/RenameApplicationCommand.swift +++ b/Sources/fxcodex-cli/Commands/RenameApplicationCommand.swift @@ -22,7 +22,9 @@ extension AppCommand { internal init() {} internal func run() async throws { - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + let requestedName: CodexApplicationName = self.undo ? .chatGPT : .codex let result: CodexApplicationRenameResult = try await client.renameApplication( requestedName @@ -34,6 +36,7 @@ extension AppCommand { } let reporter: TerminalReporter = await .init() + switch result.outcome { case .renamed: await reporter.success( diff --git a/Sources/fxcodex-cli/Commands/StatusCommand.swift b/Sources/fxcodex-cli/Commands/StatusCommand.swift index 4bb990a..41e57db 100644 --- a/Sources/fxcodex-cli/Commands/StatusCommand.swift +++ b/Sources/fxcodex-cli/Commands/StatusCommand.swift @@ -48,7 +48,9 @@ extension AppCommand { internal init() {} internal func run() async throws { - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + let sections: StatusSections = try self.sections() let status: FXCodexStatus = try await client.status() @@ -86,6 +88,7 @@ extension AppCommand { let processDescription: String = workspace.processID .map { "running · pid \($0)" } ?? "stopped" + await reporter.info( "\(currentMarker) \(workspace.workspace.name)\t\(processDescription)" ) @@ -99,6 +102,7 @@ extension AppCommand { let description: String = raycast.applicationURL == nil ? "not installed" : "installed · \(raycast.version ?? "unknown version")" + await reporter.info(" \(raycast.edition.displayName): \(description)") } await reporter.info( @@ -111,8 +115,9 @@ extension AppCommand { extension AppCommand.StatusCommand { internal func sections( - environment: [String: String] = ProcessInfo.processInfo.environment + environment: [String: String]? = nil ) throws -> StatusSections { + let environment = environment ?? currentEnvironment() let environmentAll: Bool? = try environmentSwitch( named: "FXCODEX_STATUS_ALL", in: environment diff --git a/Sources/fxcodex-cli/Commands/UninstallCommand.swift b/Sources/fxcodex-cli/Commands/UninstallCommand.swift index e2829cc..74fe4df 100644 --- a/Sources/fxcodex-cli/Commands/UninstallCommand.swift +++ b/Sources/fxcodex-cli/Commands/UninstallCommand.swift @@ -35,20 +35,27 @@ extension AppCommand { internal func run() async throws { try rejectMachineOutput(for: "uninstall") - @Dependency(\._fxcodexTerminalPrompts) var prompts: TerminalPromptsClient - @Dependency(\.fxCodexClient) var client: FXCodexClient - @Dependency(\._fxcodexSelfInstallation) var installation: SelfInstallationClient - guard let disposition = try self.resolveDisposition(prompts: prompts) - else { return } + @Dependency(\._fxcodexTerminalPrompts) + var prompts: TerminalPromptsClient + + @Dependency(\.fxCodexClient) + var client: FXCodexClient + + @Dependency(\._fxcodexSelfInstallation) + var installation: SelfInstallationClient + + guard let disposition = try self.resolveDisposition(prompts: prompts) else { return } + if !self.yes { - guard try prompts.confirm(self.confirmationMessage(disposition)) == true - else { return } + guard try prompts.confirm(self.confirmationMessage(disposition)) == true else { return } } try await client.uninstallData(disposition) + let method: SelfUninstallMethod = try installation.uninstall( currentExecutableURL() ) + let reporter: TerminalReporter = await .init() await reporter.success( "Uninstalled fxcodex\(method == .homebrew ? " using Homebrew" : "")." @@ -64,26 +71,30 @@ extension AppCommand.UninstallCommand { if let dataDisposition = self.dataDisposition { return dataDisposition.value } - guard let selection = try prompts.select( - "What should happen to fxcodex data?", - [ - .init( - value: UninstallDataDisposition.leave.rawValue, - label: "Keep data", - hint: "remove integrations but retain workspaces and Codex data" - ), - .init( - value: UninstallDataDisposition.erase.rawValue, - label: "Erase workspace data", - hint: "retain empty workspace definitions" - ), - .init( - value: UninstallDataDisposition.delete.rawValue, - label: "Delete everything", - hint: "remove the entire fxcodex support directory" - ), - ] - ) else { return nil } + + guard + let selection = try prompts.select( + "What should happen to fxcodex data?", + [ + .init( + value: UninstallDataDisposition.leave.rawValue, + label: "Keep data", + hint: "remove integrations but retain workspaces and Codex data" + ), + .init( + value: UninstallDataDisposition.erase.rawValue, + label: "Erase workspace data", + hint: "retain empty workspace definitions" + ), + .init( + value: UninstallDataDisposition.delete.rawValue, + label: "Delete everything", + hint: "remove the entire fxcodex support directory" + ), + ] + ) + else { return nil } + return .init(rawValue: selection) } diff --git a/Sources/fxcodex-cli/Commands/UpdateCommand.swift b/Sources/fxcodex-cli/Commands/UpdateCommand.swift index 2b4c08b..bebe907 100644 --- a/Sources/fxcodex-cli/Commands/UpdateCommand.swift +++ b/Sources/fxcodex-cli/Commands/UpdateCommand.swift @@ -45,9 +45,13 @@ extension AppCommand { internal func run(executableURL: URL) async throws { guard !isHomebrewManagedExecutable(executableURL) else { throw FXCodexError.homebrewManagedUpdate } - @Dependency(\.fxCodexClient) var client: FXCodexClient + + @Dependency(\.fxCodexClient) + var client: FXCodexClient + guard let currentVersion = SemanticVersion(AppCommand.version) else { throw ValidationError("fxcodex has an invalid embedded version.") } + let result: UpdateResult = try await client.update( currentVersion, self.channel.value, @@ -60,6 +64,7 @@ extension AppCommand { } let reporter: TerminalReporter = await .init() + switch result.outcome { case .updated: await reporter.success( diff --git a/Sources/fxcodex-cli/Commands/Workspace/Workspace+CreateCommand.swift b/Sources/fxcodex-cli/Commands/Workspace/Workspace+CreateCommand.swift index 7365ab4..2eab3d4 100644 --- a/Sources/fxcodex-cli/Commands/Workspace/Workspace+CreateCommand.swift +++ b/Sources/fxcodex-cli/Commands/Workspace/Workspace+CreateCommand.swift @@ -8,8 +8,8 @@ extension AppCommand.WorkspaceCommand { abstract: "Create a managed workspace." ) - @Argument(help: "Workspace name.") - internal var name: String + @Argument(help: "Workspace name. Omit to enter interactively.") + internal var name: String? @Flag(help: "Make the new workspace current.") internal var use: Bool = false @@ -27,8 +27,25 @@ extension AppCommand.WorkspaceCommand { internal init() {} internal func run() async throws { - @Dependency(\.fxCodexClient) var client: FXCodexClient - let workspace: Workspace = try await client.createWorkspace(self.name) + @Dependency(\.fxCodexClient) + var client: FXCodexClient + + @Dependency(\._fxcodexTerminalPrompts) + var prompts: TerminalPromptsClient + + let json = machineOutputRequested(self.json) + + let name: String + + if let provided = self.name { + name = provided + } else if json { + throw ValidationError("A workspace name is required when using --json.") + } else if let entered = try prompts.text("Workspace name:", "work") { + name = entered + } else { return } + + let workspace: Workspace = try await client.createWorkspace(name) if self.use { try await client.useWorkspace(workspace.name) @@ -38,7 +55,7 @@ extension AppCommand.WorkspaceCommand { _ = try await client.openWorkspace(workspace.name) } - if machineOutputRequested(self.json) { + if json { try printMachineResponse(workspace) return } diff --git a/Sources/fxcodex-cli/Commands/Workspace/Workspace+DeleteCommand.swift b/Sources/fxcodex-cli/Commands/Workspace/Workspace+DeleteCommand.swift index d8c18b7..fe786c9 100644 --- a/Sources/fxcodex-cli/Commands/Workspace/Workspace+DeleteCommand.swift +++ b/Sources/fxcodex-cli/Commands/Workspace/Workspace+DeleteCommand.swift @@ -24,10 +24,16 @@ extension AppCommand.WorkspaceCommand { internal init() {} internal func run() async throws { - @Dependency(\.fxCodexClient) var client: FXCodexClient - @Dependency(\._fxcodexTerminalPrompts) var prompts: TerminalPromptsClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + + @Dependency(\._fxcodexTerminalPrompts) + var prompts: TerminalPromptsClient + let json: Bool = machineOutputRequested(self.json) + let names: [String] + if self.names.isEmpty { guard !json else { throw ValidationError("At least one workspace name is required when using --json.") @@ -36,11 +42,13 @@ extension AppCommand.WorkspaceCommand { let workspaces: [Workspace] = try await client.workspaces().filter { workspace in workspace.kind == .managed } + guard !workspaces.isEmpty else { let reporter: TerminalReporter = await .init() await reporter.info("There are no managed workspaces to delete.") return } + let options: [TerminalPromptOption] = workspaces.map { workspace in .init( value: workspace.name, @@ -48,11 +56,14 @@ extension AppCommand.WorkspaceCommand { hint: nil ) } + guard let selectedNames = try prompts.multiselect( "Select workspaces to delete:", options ) else { return } + names = selectedNames + } else { names = self.names.uniqued() } @@ -61,15 +72,19 @@ extension AppCommand.WorkspaceCommand { throw ValidationError("--yes is required with --json when deleting workspaces.") } - guard try self.yes || (prompts.confirm( - "Delete \(names.workspaceDescription) and all of their managed data?" - ) == true) else { + guard + try self.yes + || prompts.confirm( + "Delete \(names.workspaceDescription) and all of their managed data?" + ) == true + else { let reporter: TerminalReporter = await .init() await reporter.warning("Cancelled.") return } try await client.deleteWorkspaces(names) + if json { try printMachineResponse(WorkspaceNamesOutput(workspaceNames: names)) return diff --git a/Sources/fxcodex-cli/Commands/Workspace/Workspace+EraseCommand.swift b/Sources/fxcodex-cli/Commands/Workspace/Workspace+EraseCommand.swift index 5ac7157..ab2555b 100644 --- a/Sources/fxcodex-cli/Commands/Workspace/Workspace+EraseCommand.swift +++ b/Sources/fxcodex-cli/Commands/Workspace/Workspace+EraseCommand.swift @@ -24,10 +24,16 @@ extension AppCommand.WorkspaceCommand { internal init() {} internal func run() async throws { - @Dependency(\.fxCodexClient) var client: FXCodexClient - @Dependency(\._fxcodexTerminalPrompts) var prompts: TerminalPromptsClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + + @Dependency(\._fxcodexTerminalPrompts) + var prompts: TerminalPromptsClient + let json: Bool = machineOutputRequested(self.json) + let names: [String] + if self.names.isEmpty { guard !json else { throw ValidationError("At least one workspace name is required when using --json.") @@ -36,11 +42,13 @@ extension AppCommand.WorkspaceCommand { let workspaces: [Workspace] = try await client.workspaces().filter { workspace in workspace.kind == .managed } + guard !workspaces.isEmpty else { let reporter: TerminalReporter = await .init() await reporter.info("There are no managed workspaces to erase.") return } + let options: [TerminalPromptOption] = workspaces.map { workspace in .init( value: workspace.name, @@ -48,11 +56,14 @@ extension AppCommand.WorkspaceCommand { hint: nil ) } + guard let selectedNames = try prompts.multiselect( "Select workspaces to erase:", options ) else { return } + names = selectedNames + } else { names = self.names.uniqued() } @@ -61,15 +72,19 @@ extension AppCommand.WorkspaceCommand { throw ValidationError("--yes is required with --json when erasing workspaces.") } - guard try self.yes || (prompts.confirm( - "Erase \(names.workspaceDescription), including Codex settings and session data?" - ) == true) else { + guard + try self.yes + || prompts.confirm( + "Erase \(names.workspaceDescription), including Codex settings and session data?" + ) == true + else { let reporter: TerminalReporter = await .init() await reporter.warning("Cancelled.") return } let workspaces: [Workspace] = try await client.eraseWorkspaces(names) + if json { try printMachineResponse(workspaces) return diff --git a/Sources/fxcodex-cli/Commands/Workspace/Workspace+ListCommand.swift b/Sources/fxcodex-cli/Commands/Workspace/Workspace+ListCommand.swift index eba0874..76bd0ad 100644 --- a/Sources/fxcodex-cli/Commands/Workspace/Workspace+ListCommand.swift +++ b/Sources/fxcodex-cli/Commands/Workspace/Workspace+ListCommand.swift @@ -18,8 +18,11 @@ extension AppCommand.WorkspaceCommand { internal init() {} internal func run() async throws { - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + let status: FXCodexStatus = try await client.status() + if machineOutputRequested(self.json) { try printMachineResponse(status.workspaces) return @@ -32,6 +35,7 @@ extension AppCommand.WorkspaceCommand { let processDescription: String = item.processID .map { "running · pid \($0)" } ?? "stopped" + await reporter.info( "\(currentMarker) \(item.workspace.name)\t\(processDescription)" ) diff --git a/Sources/fxcodex-cli/Commands/Workspace/Workspace+RenameCommand.swift b/Sources/fxcodex-cli/Commands/Workspace/Workspace+RenameCommand.swift index 214f67b..67e8729 100644 --- a/Sources/fxcodex-cli/Commands/Workspace/Workspace+RenameCommand.swift +++ b/Sources/fxcodex-cli/Commands/Workspace/Workspace+RenameCommand.swift @@ -8,8 +8,8 @@ extension AppCommand.WorkspaceCommand { abstract: "Rename a stopped managed workspace." ) - @Argument(help: "New name, or old name when a second argument is supplied.") - internal var firstName: String + @Argument(help: "New name, or old name when a second argument is supplied. Omit for guided rename.") + internal var firstName: String? @Argument(help: "New name when renaming a specified workspace.") internal var secondName: String? @@ -24,11 +24,49 @@ extension AppCommand.WorkspaceCommand { internal init() {} internal func run() async throws { - @Dependency(\.fxCodexClient) var client: FXCodexClient - let oldName: String? = self.secondName == nil ? nil : self.firstName - let newName: String = self.secondName ?? self.firstName + @Dependency(\.fxCodexClient) + var client: FXCodexClient + + @Dependency(\._fxcodexTerminalPrompts) + var prompts: TerminalPromptsClient + + let json = machineOutputRequested(self.json) + + let oldName: String? + let newName: String + + if let firstName = self.firstName { + oldName = self.secondName == nil ? nil : firstName + newName = self.secondName ?? firstName + + } else if json { + throw ValidationError("A workspace name is required when using --json.") + + } else { + let workspaces = try await client.workspaces().filter { $0.kind == .managed } + + guard !workspaces.isEmpty else { + let reporter = await TerminalReporter() + await reporter.info("There are no managed workspaces to rename.") + return + } + + let options = workspaces.map { + TerminalPromptOption(value: $0.name, label: $0.name, hint: nil) + } + + guard + let selected = try prompts.select("Select a workspace to rename:", options), + let entered = try prompts.text("New workspace name:", selected) + else { return } + + oldName = selected + newName = entered + } + let workspace: Workspace = try await client.renameWorkspace(oldName, newName) - if machineOutputRequested(self.json) { + + if json { try printMachineResponse(workspace) return } diff --git a/Sources/fxcodex-cli/Commands/Workspace/Workspace+UseCommand.swift b/Sources/fxcodex-cli/Commands/Workspace/Workspace+UseCommand.swift index 4a3a7ad..1851d64 100644 --- a/Sources/fxcodex-cli/Commands/Workspace/Workspace+UseCommand.swift +++ b/Sources/fxcodex-cli/Commands/Workspace/Workspace+UseCommand.swift @@ -21,14 +21,22 @@ extension AppCommand.WorkspaceCommand { internal init() {} internal func run() async throws { - @Dependency(\.fxCodexClient) var client: FXCodexClient - @Dependency(\._fxcodexTerminalPrompts) var prompts: TerminalPromptsClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + + @Dependency(\._fxcodexTerminalPrompts) + var prompts: TerminalPromptsClient + let json: Bool = machineOutputRequested(self.json) + let name: String + if let providedName = self.name { name = providedName + } else if json { throw ValidationError("A workspace name is required when using --json.") + } else { let currentWorkspace: Workspace = try await client.currentWorkspace() let options: [TerminalPromptOption] = try await client.workspaces().map { workspace in @@ -38,14 +46,17 @@ extension AppCommand.WorkspaceCommand { hint: workspace.name == currentWorkspace.name ? "current" : nil ) } + guard let selectedName = try prompts.select( "Select a workspace to use:", options ) else { return } + name = selectedName } try await client.useWorkspace(name) + if json { try printMachineResponse(try await client.currentWorkspace()) return diff --git a/Sources/fxcodex-cli/Helpers/CLIUtilities.swift b/Sources/fxcodex-cli/Helpers/CLIUtilities.swift index 62ecdc9..ad66312 100644 --- a/Sources/fxcodex-cli/Helpers/CLIUtilities.swift +++ b/Sources/fxcodex-cli/Helpers/CLIUtilities.swift @@ -14,7 +14,7 @@ internal final class TerminalReporter { let console: Terminal = .init() console.confirmOverride = assumeYes ? true : nil - if ProcessInfo.processInfo.environment["NO_COLOR"] != nil { + if currentEnvironment()["NO_COLOR"] != nil { console.stylizedOutputOverride = false } @@ -56,14 +56,14 @@ internal func replaceProcess( with invocation: CommandInvocation ) throws -> Never { for (key, value) in invocation.environment { - guard setenv(key, value, 1) == 0 - else { throw POSIXError(.init(rawValue: errno) ?? .EIO) } + guard setenv(key, value, 1) == 0 else { throw POSIXError(.init(rawValue: errno) ?? .EIO) } } let argumentStrings: [String] = [invocation.executable] + invocation.arguments var arguments: [UnsafeMutablePointer?] = argumentStrings.map { argument in strdup(argument) } + arguments.append(nil) defer { @@ -73,21 +73,30 @@ internal func replaceProcess( } execvp(invocation.executable, &arguments) + throw POSIXError(.init(rawValue: errno) ?? .ENOENT) } internal func runProcess( - _ invocation: CommandInvocation + _ invocation: CommandInvocation, + suppressOutput: Bool = false ) throws -> Int32 { let process: Process = .init() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = [invocation.executable] + invocation.arguments - process.environment = ProcessInfo.processInfo.environment.merging( + process.environment = currentEnvironment().merging( invocation.environment, uniquingKeysWith: { _, newValue in newValue } ) + + if suppressOutput { + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + } + try process.run() process.waitUntilExit() + return process.terminationStatus } @@ -127,8 +136,9 @@ internal func isHomebrewManagedExecutable(_ executableURL: URL) -> Bool { let components: [String] = executableURL.standardizedFileURL .resolvingSymlinksInPath() .pathComponents - guard let cellarIndex = components.firstIndex(of: "Cellar") - else { return false } + + guard let cellarIndex = components.firstIndex(of: "Cellar") else { return false } + return components.indices.contains(cellarIndex + 1) && components[cellarIndex + 1] == "fxcodex" } @@ -141,8 +151,9 @@ internal func machineOutputRequested( internal func globalMachineOutputRequested( arguments: [String] = .init(CommandLine.arguments.dropFirst()), - environment: [String: String] = ProcessInfo.processInfo.environment + environment: [String: String]? = nil ) -> Bool { + let environment = environment ?? currentEnvironment() let passthroughCommandIndex: Int? = arguments.firstIndex { argument in argument == "cli" || argument == "exec" } @@ -150,7 +161,9 @@ internal func globalMachineOutputRequested( arguments[..<$0] } ?? arguments[...] + var argumentValue: Bool? + for argument in relevantArguments { switch argument { case "--json": @@ -167,6 +180,7 @@ internal func globalMachineOutputRequested( if let argumentValue { return argumentValue } + return (try? environmentSwitch( named: "FXCODEX_JSON", in: environment @@ -176,8 +190,9 @@ internal func globalMachineOutputRequested( internal func environmentSwitch( named name: String, - in environment: [String: String] = ProcessInfo.processInfo.environment + in environment: [String: String]? = nil ) throws -> Bool? { + let environment = environment ?? currentEnvironment() guard let value = environment[name], !value.isEmpty else { return nil } switch value { @@ -243,5 +258,6 @@ private func writeJSON( ) throws { var data: Data = try encodedJSON(value) data.append(contentsOf: [0x0A]) + try fileHandle.write(contentsOf: data) } diff --git a/Sources/fxcodex-cli/Helpers/EnvironmentClient.swift b/Sources/fxcodex-cli/Helpers/EnvironmentClient.swift new file mode 100644 index 0000000..4ff8835 --- /dev/null +++ b/Sources/fxcodex-cli/Helpers/EnvironmentClient.swift @@ -0,0 +1,36 @@ +import Dependencies +import Foundation + +internal struct EnvironmentClient: Sendable { + internal var values: @Sendable () -> [String: String] + + internal init( + values: @escaping @Sendable () -> [String: String] + ) { + self.values = values + } +} + +extension DependencyValues { + private enum EnvironmentClientKey: DependencyKey { + static var liveValue: EnvironmentClient { + .init(values: { ProcessInfo.processInfo.environment }) + } + + static var testValue: EnvironmentClient { + .init(values: { [:] }) + } + } + + internal var _fxcodexEnvironment: EnvironmentClient { + get { self[EnvironmentClientKey.self] } + set { self[EnvironmentClientKey.self] = newValue } + } +} + +internal func currentEnvironment() -> [String: String] { + @Dependency(\._fxcodexEnvironment) + var environment: EnvironmentClient + + return environment.values() +} diff --git a/Sources/fxcodex-cli/Helpers/MigrationAssistant.swift b/Sources/fxcodex-cli/Helpers/MigrationAssistant.swift new file mode 100644 index 0000000..840d593 --- /dev/null +++ b/Sources/fxcodex-cli/Helpers/MigrationAssistant.swift @@ -0,0 +1,86 @@ +import ArgumentParser +import Dependencies +import FXCodexClient + +internal enum MigrationAssistant { + private enum MigrationV1 { + static let title = "Stable workspace identities" + } + + internal static func prepareStorage( + client: FXCodexClient, + interactive: Bool = interactiveTerminalAvailable() && !globalMachineOutputRequested() + ) async throws { + guard let plan = try await client.storageMigrationPlan() else { + try await client.prepareStorage() + return + } + + guard interactive else { + guard !plan.requiresUserInput else { + throw ValidationError( + "Storage schema \(plan.sourceVersion) requires an interactive migration to \(plan.destinationVersion). Run fxcodex in a terminal first." + ) + } + + for migration in plan.migrations { + try await client.migrateStorage(migration) + } + + try await client.prepareStorage() + return + } + + @Dependency(\._fxcodexTerminalPrompts) + var prompts: TerminalPromptsClient + + let reporter = await TerminalReporter() + await reporter.warning( + "Storage schema \(plan.sourceVersion) must be migrated to \(plan.destinationVersion) before this command can run." + ) + + var stepNumber = 0 + let stepCount = plan.migrations.reduce(0) { $0 + $1.steps.count } + + for migration in plan.migrations { + await reporter.info( + "\(migration.sourceVersion) → \(migration.destinationVersion): \(Self.title(for: migration))" + ) + for step in migration.steps { + stepNumber += 1 + await reporter.info(" [\(stepNumber)/\(stepCount)] \(step)") + } + } + + guard + try prompts.confirm( + "Proceed with migration from schema \(plan.sourceVersion) to \(plan.destinationVersion)?" + ) == true + else { + throw CleanExit.message("Migration cancelled. No command was run.") + } + + for (index, migration) in plan.migrations.enumerated() { + await reporter.info( + "Migrating \(migration.sourceVersion) → \(migration.destinationVersion)…" + ) + try await client.migrateStorage(migration) + await reporter.success( + "[\(index + 1)/\(plan.migrations.count)] Migrated to schema \(migration.destinationVersion)." + ) + } + + try await client.prepareStorage() + await reporter.success("Storage migrated to schema \(plan.destinationVersion).") + } + + private static func title(for migration: StorageMigration) -> String { + switch migration.sourceVersion { + case .v1_0: + MigrationV1.title + + default: + "Schema migration" + } + } +} diff --git a/Sources/fxcodex-cli/Helpers/SelfInstallationClient.swift b/Sources/fxcodex-cli/Helpers/SelfInstallationClient.swift index 1d96c0f..88d2a96 100644 --- a/Sources/fxcodex-cli/Helpers/SelfInstallationClient.swift +++ b/Sources/fxcodex-cli/Helpers/SelfInstallationClient.swift @@ -16,13 +16,16 @@ extension DependencyValues { static var liveValue: SelfInstallationClient { .init(uninstall: { executableURL in let executableURL: URL = executableURL.standardizedFileURL.resolvingSymlinksInPath() + if isHomebrewManagedExecutable(executableURL) { let exitCode: Int32 = try runProcess(.init( executable: "brew", arguments: ["uninstall", "fxcodex"], environment: [:] )) + guard exitCode == 0 else { throw ExitCode(exitCode) } + return .homebrew } diff --git a/Sources/fxcodex-cli/Helpers/TerminalPromptsClient.swift b/Sources/fxcodex-cli/Helpers/TerminalPromptsClient.swift index 49b7f4d..223118a 100644 --- a/Sources/fxcodex-cli/Helpers/TerminalPromptsClient.swift +++ b/Sources/fxcodex-cli/Helpers/TerminalPromptsClient.swift @@ -1,6 +1,7 @@ import ArgumentParser import Darwin import Dependencies +import Foundation import Promptberry internal struct TerminalPromptOption: Equatable, Sendable { @@ -23,6 +24,21 @@ internal struct TerminalPromptsClient: Sendable { internal var select: @Sendable (String, [TerminalPromptOption]) throws -> String? internal var multiselect: @Sendable (String, [TerminalPromptOption]) throws -> [String]? internal var confirm: @Sendable (String) throws -> Bool? + internal var text: @Sendable (String, String) throws -> String? + + internal init( + select: @escaping @Sendable (String, [TerminalPromptOption]) throws -> String?, + multiselect: @escaping @Sendable (String, [TerminalPromptOption]) throws -> [String]?, + confirm: @escaping @Sendable (String) throws -> Bool?, + text: @escaping @Sendable (String, String) throws -> String? = { _, _ in + throw ValidationError("Text input is not configured.") + } + ) { + self.select = select + self.multiselect = multiselect + self.confirm = confirm + self.text = text + } } extension DependencyValues { @@ -74,17 +90,30 @@ extension DependencyValues { } catch is PromptCancelled { return nil } + }, + text: { message, placeholder in + try Self.requireInteractiveTerminal() + do { + return try Promptberry.text( + message, + placeholder: placeholder, + validate: { value in + value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "A value is required." + : nil + } + ) + } catch is PromptCancelled { + return nil + } } ) } private static func requireInteractiveTerminal() throws { - guard - isatty(STDIN_FILENO) != 0, - isatty(STDOUT_FILENO) != 0 - else { + guard interactiveTerminalAvailable() else { throw ValidationError( - "Interactive selection requires a terminal. Pass workspace names explicitly." + "Interactive input requires a terminal. Provide the required values explicitly." ) } } @@ -95,3 +124,7 @@ extension DependencyValues { set { self[TerminalPromptsClientKey.self] = newValue } } } + +internal func interactiveTerminalAvailable() -> Bool { + isatty(STDIN_FILENO) != 0 && isatty(STDOUT_FILENO) != 0 +} diff --git a/Sources/fxcodex-cli/Models/StatusOutput.swift b/Sources/fxcodex-cli/Models/StatusOutput.swift index d5f2d17..e74ae9d 100644 --- a/Sources/fxcodex-cli/Models/StatusOutput.swift +++ b/Sources/fxcodex-cli/Models/StatusOutput.swift @@ -3,6 +3,7 @@ import FXCodexClient internal struct StatusOutput: Encodable { internal let currentWorkspace: String + internal let currentWorkspaceID: WorkspaceID internal let supportDirectoryURL: URL internal let applicationURL: URL? internal let preferences: FXCodexPreferences? @@ -15,6 +16,7 @@ internal struct StatusOutput: Encodable { sections: StatusSections ) { self.currentWorkspace = status.currentWorkspace + self.currentWorkspaceID = status.currentWorkspaceID self.supportDirectoryURL = status.supportDirectoryURL self.applicationURL = status.applicationURL self.preferences = sections.preferences ? status.preferences : nil diff --git a/Sources/fxcodex-client/Common/FXCodexError.swift b/Sources/fxcodex-client/Common/FXCodexError.swift index 35cb570..8535aec 100644 --- a/Sources/fxcodex-client/Common/FXCodexError.swift +++ b/Sources/fxcodex-client/Common/FXCodexError.swift @@ -7,6 +7,9 @@ public enum FXCodexError: Error, Equatable, LocalizedError, Sendable { case codexExecutableNotFound case homebrewNotFound case homebrewManagedUpdate + case integrationAttributeNotFound(String) + case invalidAttributePath(String) + case invalidStorage(String) case invalidWorkspaceName(String) case primaryWorkspaceMutation case raycastBetaUnsupportedPlatform @@ -18,71 +21,37 @@ public enum FXCodexError: Error, Equatable, LocalizedError, Sendable { case updateChecksumMismatch case updateExecutableInvalid(URL) case updateRequestFailed(Int) + case unsupportedSchemaVersion(SchemaVersion) case workspaceAlreadyExists(String) case workspaceIsRunning(String) case workspaceNotFound(String) public var code: String { switch self { - case .applicationNotFound: - "application_not_found" - - case .applicationBundleMismatch: - "application_bundle_mismatch" - - case .ambiguousApplicationInstances: - "ambiguous_application_instances" - - case .codexExecutableNotFound: - "codex_executable_not_found" - - case .homebrewNotFound: - "homebrew_not_found" - - case .homebrewManagedUpdate: - "homebrew_managed_update" - - case .invalidWorkspaceName: - "invalid_workspace_name" - - case .primaryWorkspaceMutation: - "primary_workspace_mutation" - - case .raycastBetaUnsupportedPlatform: - "raycast_beta_unsupported_platform" - - case .raycastScriptCommandDirectoryMissing: - "raycast_script_command_directory_missing" - - case .supportDirectoryInvalid: - "support_directory_invalid" - - case .updateArchitectureUnsupported: - "update_architecture_unsupported" - - case .updateAssetMissing: - "update_asset_missing" - - case .updateChecksumInvalid: - "update_checksum_invalid" - - case .updateChecksumMismatch: - "update_checksum_mismatch" - - case .updateExecutableInvalid: - "update_executable_invalid" - - case .updateRequestFailed: - "update_request_failed" - - case .workspaceAlreadyExists: - "workspace_already_exists" - - case .workspaceIsRunning: - "workspace_is_running" - - case .workspaceNotFound: - "workspace_not_found" + case .applicationNotFound: "application_not_found" + case .applicationBundleMismatch: "application_bundle_mismatch" + case .ambiguousApplicationInstances: "ambiguous_application_instances" + case .codexExecutableNotFound: "codex_executable_not_found" + case .homebrewNotFound: "homebrew_not_found" + case .homebrewManagedUpdate: "homebrew_managed_update" + case .integrationAttributeNotFound: "integration_attribute_not_found" + case .invalidAttributePath: "invalid_attribute_path" + case .invalidStorage: "invalid_storage" + case .invalidWorkspaceName: "invalid_workspace_name" + case .primaryWorkspaceMutation: "primary_workspace_mutation" + case .raycastBetaUnsupportedPlatform: "raycast_beta_unsupported_platform" + case .raycastScriptCommandDirectoryMissing: "raycast_script_command_directory_missing" + case .supportDirectoryInvalid: "support_directory_invalid" + case .updateArchitectureUnsupported: "update_architecture_unsupported" + case .updateAssetMissing: "update_asset_missing" + case .updateChecksumInvalid: "update_checksum_invalid" + case .updateChecksumMismatch: "update_checksum_mismatch" + case .updateExecutableInvalid: "update_executable_invalid" + case .updateRequestFailed: "update_request_failed" + case .unsupportedSchemaVersion: "unsupported_schema_version" + case .workspaceAlreadyExists: "workspace_already_exists" + case .workspaceIsRunning: "workspace_is_running" + case .workspaceNotFound: "workspace_not_found" } } @@ -106,6 +75,15 @@ public enum FXCodexError: Error, Equatable, LocalizedError, Sendable { case .homebrewManagedUpdate: "This fxcodex installation is managed by Homebrew. Run 'brew upgrade fxcodex' instead." + case let .integrationAttributeNotFound(path): + "No integration attribute exists at '\(path)'." + + case let .invalidAttributePath(path): + "Invalid integration attribute path '\(path)'." + + case let .invalidStorage(message): + "Invalid fxcodex storage: \(message)" + case let .invalidWorkspaceName(name): "Invalid workspace name '\(name)'. Use lowercase letters, numbers, and hyphens." @@ -139,6 +117,9 @@ public enum FXCodexError: Error, Equatable, LocalizedError, Sendable { case let .updateRequestFailed(statusCode): "The GitHub Releases request failed with HTTP status \(statusCode)." + case let .unsupportedSchemaVersion(version): + "Storage schema \(version) is unsupported. This fxcodex build supports schema 2.0." + case let .workspaceAlreadyExists(name): "Workspace '\(name)' already exists." diff --git a/Sources/fxcodex-client/FXCodexClient.swift b/Sources/fxcodex-client/FXCodexClient.swift index 4b7d230..62ab21e 100644 --- a/Sources/fxcodex-client/FXCodexClient.swift +++ b/Sources/fxcodex-client/FXCodexClient.swift @@ -4,6 +4,9 @@ import Foundation @DependencyClient public struct FXCodexClient: Sendable { + public var storageMigrationPlan: @Sendable () async throws -> StorageMigrationPlan? + public var migrateStorage: @Sendable (StorageMigration) async throws -> Void + public var prepareStorage: @Sendable () async throws -> Void public var applyAutomaticPreferences: @Sendable (SemanticVersion, URL, Bool) async throws -> [FXCodexWarning] public var preferences: @Sendable () async throws -> FXCodexPreferences public var setAutoRename: @Sendable (Bool) async throws -> FXCodexPreferences @@ -20,7 +23,9 @@ public struct FXCodexClient: Sendable { public var eraseWorkspaces: @Sendable ([String]) async throws -> [Workspace] public var renameWorkspace: @Sendable (String?, String) async throws -> Workspace public var useWorkspace: @Sendable (String) async throws -> Void + public var useWorkspaceByID: @Sendable (WorkspaceID) async throws -> Void public var openWorkspace: @Sendable (String?) async throws -> Int32 + public var openWorkspaceByID: @Sendable (WorkspaceID) async throws -> Int32 public var codexInvocation: @Sendable (String?, [String]) async throws -> CommandInvocation public var status: @Sendable () async throws -> FXCodexStatus } @@ -36,6 +41,9 @@ extension DependencyValues { static var liveValue: FXCodexClient { let manager = CodexManager() return .init( + storageMigrationPlan: { try await manager.storageMigrationPlan() }, + migrateStorage: { try await manager.migrateStorage($0) }, + prepareStorage: { try await manager.prepareStorage() }, applyAutomaticPreferences: manager.applyAutomaticPreferences, preferences: { try await manager.preferences() }, setAutoRename: { try await manager.setAutoRename(to: $0) }, @@ -52,7 +60,9 @@ extension DependencyValues { eraseWorkspaces: manager.eraseWorkspaces, renameWorkspace: manager.renameWorkspace, useWorkspace: { try await manager.useWorkspace(named: $0) }, + useWorkspaceByID: { try await manager.useWorkspace(id: $0) }, openWorkspace: manager.openWorkspace, + openWorkspaceByID: { try await manager.openWorkspace(id: $0) }, codexInvocation: { try await manager.codexInvocation(workspaceName: $0, arguments: $1) }, status: manager.status ) diff --git a/Sources/fxcodex-client/Integrations/IntegrationsClient.swift b/Sources/fxcodex-client/Integrations/IntegrationsClient.swift index 92f12a8..748ccea 100644 --- a/Sources/fxcodex-client/Integrations/IntegrationsClient.swift +++ b/Sources/fxcodex-client/Integrations/IntegrationsClient.swift @@ -3,11 +3,14 @@ import Dependencies public struct Integrations: Sendable { public var raycast: Raycast + public var attributes: IntegrationAttributes public init( - raycast: Raycast + raycast: Raycast, + attributes: IntegrationAttributes ) { self.raycast = raycast + self.attributes = attributes } } @@ -15,11 +18,13 @@ extension DependencyValues { public var _fxcodexIntegrations: Integrations { get { .init( - raycast: _fxcodexRaycast + raycast: _fxcodexRaycast, + attributes: _fxcodexIntegrationAttributes ) } set { self._fxcodexRaycast = newValue.raycast + self._fxcodexIntegrationAttributes = newValue.attributes } } } diff --git a/Sources/fxcodex-client/Integrations/Raycast/Raycast.swift b/Sources/fxcodex-client/Integrations/Raycast/Raycast.swift index 43674a2..3ca654e 100644 --- a/Sources/fxcodex-client/Integrations/Raycast/Raycast.swift +++ b/Sources/fxcodex-client/Integrations/Raycast/Raycast.swift @@ -7,7 +7,7 @@ extension Integrations { public struct Raycast: Sendable { public var applicationStatus: @Sendable (RaycastEdition) async throws -> RaycastApplicationStatus public var applicationInstallation: @Sendable (RaycastEdition) async throws -> RaycastApplicationInstallation - public var installScriptCommands: @Sendable (URL, URL, Bool, Bool) async throws -> RaycastScriptCommandStatus + public var installScriptCommands: @Sendable (URL, URL, Bool) async throws -> RaycastScriptCommandStatus public var syncScriptCommands: @Sendable (URL) async throws -> RaycastScriptCommandStatus public var uninstallScriptCommands: @Sendable () async throws -> RaycastScriptCommandStatus public var scriptCommandStatus: @Sendable () async throws -> RaycastScriptCommandStatus @@ -33,8 +33,7 @@ extension DependencyValues { try await integration.installScriptCommands( at: $0, fxcodexExecutableURL: $1, - includeCurrentWorkspace: $2, - includeAllWorkspaces: $3 + currentWorkspaceOnly: $2 ) }, syncScriptCommands: { @@ -67,6 +66,7 @@ extension DependencyValues { @_spi(Internals) public var _fxcodexRaycast: Integrations.Raycast { + get { self[RaycastIntegrationKey.self] } set { self[RaycastIntegrationKey.self] = newValue } } diff --git a/Sources/fxcodex-client/Integrations/Raycast/RaycastIntegration.swift b/Sources/fxcodex-client/Integrations/Raycast/RaycastIntegration.swift index 6f75b1a..b118035 100644 --- a/Sources/fxcodex-client/Integrations/Raycast/RaycastIntegration.swift +++ b/Sources/fxcodex-client/Integrations/Raycast/RaycastIntegration.swift @@ -1,5 +1,4 @@ import AppKit -import CasePaths import Dependencies import Foundation @@ -7,26 +6,20 @@ actor RaycastIntegration { @Dependency(\._fxcodexWorkspaces) private var workspaces - init() {} + @Dependency(\._fxcodexIntegrationAttributes) + private var attributes - func applicationStatus( - for edition: RaycastEdition - ) async -> RaycastApplicationStatus { - let applicationURL: URL? = await Self.applicationURL(for: edition) - let version: String? = applicationURL - .flatMap(Bundle.init(url:))? - .object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + init() {} - return .init( - edition: edition, - applicationURL: applicationURL, - version: version - ) + func applicationStatus(for edition: RaycastEdition) async -> RaycastApplicationStatus { + let applicationURL = await Self.applicationURL(for: edition) + let version = applicationURL + .flatMap(Bundle.init(url:))? + .object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + return .init(edition: edition, applicationURL: applicationURL, version: version) } - func applicationInstallation( - for edition: RaycastEdition - ) async throws -> RaycastApplicationInstallation { + func applicationInstallation(for edition: RaycastEdition) async throws -> RaycastApplicationInstallation { if let applicationURL = await Self.applicationURL(for: edition) { return .alreadyInstalled(applicationURL) } @@ -41,296 +34,265 @@ actor RaycastIntegration { case .beta: #if arch(arm64) - guard ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26 - else { throw FXCodexError.raycastBetaUnsupportedPlatform } + guard ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26 else { + throw FXCodexError.raycastBetaUnsupportedPlatform + } + guard let url = URL(string: "https://www.raycast.com/new") else { + throw CocoaError(.fileNoSuchFile) + } + + return .externalDownload(url) #else throw FXCodexError.raycastBetaUnsupportedPlatform #endif - - guard let url = URL(string: "https://www.raycast.com/new") - else { throw CocoaError(.fileNoSuchFile) } - return .externalDownload(url) } } func installScriptCommands( at directoryURL: URL, fxcodexExecutableURL: URL, - includeCurrentWorkspace: Bool, - includeAllWorkspaces: Bool + currentWorkspaceOnly: Bool ) throws -> RaycastScriptCommandStatus { - var selectedWorkspaces: [String: Workspace] = [:] - - if includeAllWorkspaces { - for workspace in try self.workspaces.list() { - selectedWorkspaces[workspace.name] = workspace - } - } - - if includeCurrentWorkspace { - let workspace: Workspace = try self.workspaces.currentWorkspace() - selectedWorkspaces[workspace.name] = workspace - } - - for workspace in selectedWorkspaces.values.sorted(by: { $0.name < $1.name }) { - _ = try self.installScriptCommand( - for: workspace, - at: directoryURL, - fxcodexExecutableURL: fxcodexExecutableURL - ) - } - - try self.setAutomaticManagement( - includeAllWorkspaces - ? .init( - directoryURL: directoryURL, - fxcodexExecutableURL: fxcodexExecutableURL - ) + let workspaceIDs: Set? = currentWorkspaceOnly + ? [try self.workspaces.currentWorkspace().id] : nil + let configuration = RaycastScriptCommandConfiguration( + directoryURL: directoryURL, + fxcodexExecutableURL: fxcodexExecutableURL, + workspaceIDs: workspaceIDs ) + + try self.saveConfiguration(configuration) + try self.reconcile(configuration) + return try self.scriptCommandStatus() } - func syncScriptCommands( - fxcodexExecutableURL: URL - ) throws -> RaycastScriptCommandStatus { - let configuredWorkspaces: [Workspace] = try self.workspaces.list().filter { workspace in - workspace.raycastScriptCommandConfiguration != nil + func syncScriptCommands(fxcodexExecutableURL: URL) throws -> RaycastScriptCommandStatus { + guard var configuration = try self.configuration() else { + throw FXCodexError.raycastScriptCommandDirectoryMissing } - guard !configuredWorkspaces.isEmpty - else { throw FXCodexError.raycastScriptCommandDirectoryMissing } - for workspace in configuredWorkspaces { - guard let configuration = workspace.raycastScriptCommandConfiguration - else { continue } - _ = try self.installScriptCommand( - for: workspace, - at: configuration.directoryURL, - fxcodexExecutableURL: fxcodexExecutableURL - ) - } - - let primaryWorkspace: Workspace = try self.workspaces.findWorkspace(named: Workspace.primaryName) - if let automaticManagement = primaryWorkspace.raycastAutomaticManagement { - try self.setAutomaticManagement(.init( - directoryURL: automaticManagement.directoryURL, - fxcodexExecutableURL: fxcodexExecutableURL - )) - } + configuration.fxcodexExecutableURL = fxcodexExecutableURL.standardizedFileURL + try self.saveConfiguration(configuration) + try self.reconcile(configuration) return try self.scriptCommandStatus() } func uninstallScriptCommands() throws -> RaycastScriptCommandStatus { - for workspace in try self.workspaces.list() { - guard workspace.raycastScriptCommandConfiguration != nil - else { continue } - try self.removeScriptCommand(for: workspace) - - var updatedWorkspace: Workspace = workspace - updatedWorkspace.raycastScriptCommandConfiguration = nil - _ = try self.workspaces.save(updatedWorkspace) + if let configuration = try self.configuration() { + try self.removeManagedCommands(in: configuration.generatedDirectoryURL) } - try self.setAutomaticManagement(nil) - return .init( - directoryURL: nil, - managedCommandCount: 0 - ) + do { + try self.attributes.remove("raycast", try .init("script_commands")) + } catch FXCodexError.integrationAttributeNotFound { + // The generated files may still exist after a manually edited configuration. + } + + return .init(directoryURL: nil, managedCommandCount: 0) } func scriptCommandStatus() throws -> RaycastScriptCommandStatus { - let configurations: [RaycastScriptCommandConfiguration] = try self.workspaces.list() - .compactMap(\.raycastScriptCommandConfiguration) - let directoryURLs: Set = .init(configurations.map(\.directoryURL)) - let managedCommandCount: Int = try self.workspaces.list().reduce(into: 0) { count, workspace in - guard - let configuration = workspace.raycastScriptCommandConfiguration, - try self.isManagedScriptCommand( - at: self.scriptCommandURL( - for: workspace, - in: configuration.directoryURL - ) - ) - else { return } - count += 1 + guard let configuration = try self.configuration() else { + return .init(directoryURL: nil, managedCommandCount: 0) } - return .init( - directoryURL: directoryURLs.count == 1 ? directoryURLs.first : nil, - managedCommandCount: managedCommandCount - ) + let count = try self.workspaces.list() + .filter(configuration.includes) + .reduce(into: 0) { count, workspace in + let scriptCommandURL = self.scriptCommandURL( + for: workspace, + configuration: configuration + ) + if try self.isManagedScriptCommand(at: scriptCommandURL) { + count += 1 + } + } + + return .init(directoryURL: configuration.directoryURL, managedCommandCount: count) } func workspaceCreated(_ workspace: Workspace) throws -> Workspace { - let primaryWorkspace: Workspace = try self.workspaces.findWorkspace(named: Workspace.primaryName) - guard let automaticManagement = primaryWorkspace.raycastAutomaticManagement - else { return workspace } - - return try self.installScriptCommand( - for: workspace, - at: automaticManagement.directoryURL, - fxcodexExecutableURL: automaticManagement.fxcodexExecutableURL - ) + try self.reconcileIfConfigured() + return workspace } func workspaceDeleted(_ workspace: Workspace) throws { - try self.removeScriptCommand(for: workspace) + guard let configuration = try self.configuration() else { return } + try self.removeManagedScriptCommand(at: self.scriptCommandURL(for: workspace, configuration: configuration)) } func workspaceErased(_ workspace: Workspace) throws { - try self.removeScriptCommand(for: workspace) + try self.reconcileIfConfigured() } - func workspaceRenamed( - from oldWorkspace: Workspace, - to newWorkspace: Workspace - ) throws -> Workspace { - guard let configuration = oldWorkspace.raycastScriptCommandConfiguration - else { return newWorkspace } - - try self.removeScriptCommand(for: oldWorkspace) - return try self.installScriptCommand( - for: newWorkspace, - at: configuration.directoryURL, - fxcodexExecutableURL: configuration.fxcodexExecutableURL - ) + func workspaceRenamed(from oldWorkspace: Workspace, to newWorkspace: Workspace) throws -> Workspace { + try self.reconcileIfConfigured() + return newWorkspace } } -extension RaycastIntegration { - private static var scriptCommandMarker: String { - "# fxcodex-managed-script-command" - } +private extension RaycastIntegration { + static var scriptCommandMarker: String { "# fxcodex-managed-script-command" } @MainActor - private static func applicationURL(for edition: RaycastEdition) -> URL? { + static func applicationURL(for edition: RaycastEdition) -> URL? { let bundleIdentifier: String let knownURL: URL - switch edition { case .stable: bundleIdentifier = "com.raycast.macos" knownURL = URL(fileURLWithPath: "/Applications/Raycast.app") - case .beta: bundleIdentifier = "com.raycast-x.macos" knownURL = URL(fileURLWithPath: "/Applications/Raycast Beta.app") } - let discoveredURLs: [URL] = NSWorkspace.shared.urlsForApplications( - withBundleIdentifier: bundleIdentifier - ) + let discoveredURLs = NSWorkspace.shared.urlsForApplications(withBundleIdentifier: bundleIdentifier) return (discoveredURLs + [knownURL]).first { url in FileManager.default.fileExists(atPath: url.path) - && Bundle(url: url)?.bundleIdentifier == bundleIdentifier + && Bundle(url: url)?.bundleIdentifier == bundleIdentifier } } - private func installScriptCommand( - for workspace: Workspace, - at directoryURL: URL, - fxcodexExecutableURL: URL - ) throws -> Workspace { - if let previousConfiguration = workspace.raycastScriptCommandConfiguration { - let previousURL: URL = self.scriptCommandURL( - for: workspace, - in: previousConfiguration.directoryURL - ) - let newURL: URL = self.scriptCommandURL( - for: workspace, - in: directoryURL - ) - if previousURL != newURL { - try self.removeManagedScriptCommand(at: previousURL) + func configuration() throws -> RaycastScriptCommandConfiguration? { + guard + let value = try? self.attributes.get("raycast", .init("script_commands")), + case let .dictionary(dictionary) = value, + case let .string(path) = dictionary["path"], + case let .string(executablePath) = dictionary["executable_path"] + else { return nil } + let workspaceIDs: Set? + + if let value = dictionary["workspace_ids"] { + guard case let .array(rawWorkspaceIDs) = value else { return nil } + + let parsedWorkspaceIDs = rawWorkspaceIDs.compactMap { value -> WorkspaceID? in + guard case let .string(rawWorkspaceID) = value else { return nil } + return WorkspaceID(rawWorkspaceID) } + + guard parsedWorkspaceIDs.count == rawWorkspaceIDs.count else { return nil } + workspaceIDs = Set(parsedWorkspaceIDs) + + } else { + workspaceIDs = nil } - try FileManager.default.createDirectory( - at: directoryURL, - withIntermediateDirectories: true, - attributes: nil - ) - try self.writeScriptCommand( - to: self.scriptCommandURL( - for: workspace, - in: directoryURL - ), - workspace: workspace, - fxcodexExecutableURL: fxcodexExecutableURL + return .init( + directoryURL: URL(filePath: path), + fxcodexExecutableURL: URL(filePath: executablePath), + workspaceIDs: workspaceIDs ) + } - var updatedWorkspace: Workspace = workspace - updatedWorkspace.raycastScriptCommandConfiguration = .init( - directoryURL: directoryURL, - fxcodexExecutableURL: fxcodexExecutableURL + func saveConfiguration(_ configuration: RaycastScriptCommandConfiguration) throws { + var value: [String: CodableValue] = [ + "path": .string(configuration.directoryURL.path), + "executable_path": .string(configuration.fxcodexExecutableURL.path), + ] + + if let workspaceIDs = configuration.workspaceIDs { + value["workspace_ids"] = .array( + workspaceIDs.sorted().map { .string($0.rawValue) } + ) + } + + try self.attributes.set( + "raycast", + .init("script_commands"), + .dictionary(value) ) - return try self.workspaces.save(updatedWorkspace) } - private func removeScriptCommand(for workspace: Workspace) throws { - guard let configuration = workspace.raycastScriptCommandConfiguration - else { return } - try self.removeManagedScriptCommand( - at: self.scriptCommandURL( - for: workspace, - in: configuration.directoryURL - ) + func reconcileIfConfigured() throws { + guard let configuration = try self.configuration() else { return } + try self.reconcile(configuration) + } + + func reconcile(_ configuration: RaycastScriptCommandConfiguration) throws { + try FileManager.default.createDirectory( + at: configuration.generatedDirectoryURL, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o755] ) + + let workspaces = try self.workspaces.list().filter(configuration.includes) + let expectedURLs = Set(workspaces.map { self.scriptCommandURL(for: $0, configuration: configuration) }) + + for url in try FileManager.default.contentsOfDirectory( + at: configuration.generatedDirectoryURL, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) where url.pathExtension == "sh" && !expectedURLs.contains(url) { + try self.removeManagedScriptCommand(at: url) + } + + for workspace in workspaces { + try self.writeScriptCommand( + to: self.scriptCommandURL(for: workspace, configuration: configuration), + workspace: workspace, + fxcodexExecutableURL: configuration.fxcodexExecutableURL + ) + } + } + + func removeManagedCommands(in directoryURL: URL) throws { + guard FileManager.default.fileExists(atPath: directoryURL.path) else { return } + + for url in try FileManager.default.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) where url.pathExtension == "sh" { + try self.removeManagedScriptCommand(at: url) + } + + if try FileManager.default.contentsOfDirectory(atPath: directoryURL.path).isEmpty { + try FileManager.default.removeItem(at: directoryURL) + } } - private func removeManagedScriptCommand(at url: URL) throws { - guard FileManager.default.fileExists(atPath: url.path) - else { return } - guard try self.isManagedScriptCommand(at: url) - else { return } + func removeManagedScriptCommand(at url: URL) throws { + guard try self.isManagedScriptCommand(at: url) else { return } + try FileManager.default.removeItem(at: url) for iconURL in self.scriptCommandIconURLs(forScriptCommandAt: url) { - guard FileManager.default.fileExists(atPath: iconURL.path) - else { continue } - try FileManager.default.removeItem(at: iconURL) + if FileManager.default.fileExists(atPath: iconURL.path) { + try FileManager.default.removeItem(at: iconURL) + } } } - private func isManagedScriptCommand(at url: URL) throws -> Bool { - guard FileManager.default.fileExists(atPath: url.path) - else { return false } - let contents: String = try .init(contentsOf: url, encoding: .utf8) - return contents.contains(Self.scriptCommandMarker) + func isManagedScriptCommand(at url: URL) throws -> Bool { + guard FileManager.default.fileExists(atPath: url.path) else { return false } + return try String(contentsOf: url, encoding: .utf8).contains(Self.scriptCommandMarker) } - private func scriptCommandURL( + func scriptCommandURL( for workspace: Workspace, - in directoryURL: URL + configuration: RaycastScriptCommandConfiguration ) -> URL { - directoryURL.appending( - path: "fxcodex-open-\(workspace.name).sh", + configuration.generatedDirectoryURL.appending( + path: "\(workspace.id.rawValue).sh", directoryHint: .notDirectory ) } - private func writeScriptCommand( - to url: URL, - workspace: Workspace, - fxcodexExecutableURL: URL - ) throws { - let iconURLs: [URL] = self.scriptCommandIconURLs(forScriptCommandAt: url) - try RaycastScriptCommandIcon.light.write( - to: iconURLs[0], - options: .atomic - ) - try RaycastScriptCommandIcon.dark.write( - to: iconURLs[1], - options: .atomic - ) - - let contents: String = """ + func writeScriptCommand(to url: URL, workspace: Workspace, fxcodexExecutableURL: URL) throws { + let iconURLs = self.scriptCommandIconURLs(forScriptCommandAt: url) + try RaycastScriptCommandIcon.light.write(to: iconURLs[0], options: .atomic) + try RaycastScriptCommandIcon.dark.write(to: iconURLs[1], options: .atomic) + let title = workspace.kind == .primary ? "Codex" : "Codex (\(workspace.name.capitalized))" + let contents = """ #!/bin/bash \(Self.scriptCommandMarker) # Required parameters: # @raycast.schemaVersion 1 - # @raycast.title Codex (\(workspace.name.capitalized)) + # @raycast.title \(title) # @raycast.mode silent # Optional parameters: @@ -339,122 +301,46 @@ extension RaycastIntegration { # @raycast.iconDark ./\(iconURLs[1].lastPathComponent) # @raycast.description Open or focus the \(workspace.name) Codex workspace - exec \(self.shellQuote(fxcodexExecutableURL.path)) open \(self.shellQuote(workspace.name)) + exec \(self.shellQuote(fxcodexExecutableURL.path)) open --workspace-id \(self.shellQuote(workspace.id.rawValue)) """ - - try contents.write( - to: url, - atomically: true, - encoding: .utf8 - ) - try FileManager.default.setAttributes( - [.posixPermissions: 0o755], - ofItemAtPath: url.path - ) + try contents.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) } - private func scriptCommandIconURLs( - forScriptCommandAt url: URL - ) -> [URL] { - let directoryURL: URL = url.deletingLastPathComponent() - let name: String = url.deletingPathExtension().lastPathComponent + func scriptCommandIconURLs(forScriptCommandAt url: URL) -> [URL] { + let directoryURL = url.deletingLastPathComponent() + let name = url.deletingPathExtension().lastPathComponent return [ directoryURL.appending(path: "\(name)-light.png"), directoryURL.appending(path: "\(name)-dark.png"), ] } - private func setAutomaticManagement( - _ configuration: RaycastScriptCommandConfiguration? - ) throws { - var primaryWorkspace: Workspace = try self.workspaces.findWorkspace( - named: Workspace.primaryName - ) - primaryWorkspace.raycastAutomaticManagement = configuration - _ = try self.workspaces.save(primaryWorkspace) - } - - private func shellQuote(_ value: String) -> String { + func shellQuote(_ value: String) -> String { "'\(value.replacingOccurrences(of: "'", with: "'\\''"))'" } } private struct RaycastScriptCommandConfiguration: Equatable, Sendable { let directoryURL: URL - let fxcodexExecutableURL: URL + var fxcodexExecutableURL: URL + let workspaceIDs: Set? init( directoryURL: URL, - fxcodexExecutableURL: URL + fxcodexExecutableURL: URL, + workspaceIDs: Set? ) { self.directoryURL = directoryURL.standardizedFileURL self.fxcodexExecutableURL = fxcodexExecutableURL.standardizedFileURL + self.workspaceIDs = workspaceIDs } -} -extension Workspace { - fileprivate var raycastIntegration: [String: CodableValue] { - get { self.integrations["raycast"]?[case: \.dictionary] ?? [:] } - set { - if newValue.isEmpty { - self.integrations.removeValue(forKey: "raycast") - } else { - self.integrations["raycast"] = .dictionary(newValue) - } - } - } - - fileprivate var raycastScriptCommandConfiguration: RaycastScriptCommandConfiguration? { - get { - guard - let value = self.raycastIntegration["script_command"]?[case: \.dictionary], - let directoryPath = value["directory_path"]?[case: \.string], - let executablePath = value["fxcodex_executable_path"]?[case: \.string] - else { return nil } - return .init( - directoryURL: URL(filePath: directoryPath), - fxcodexExecutableURL: URL(filePath: executablePath) - ) - } - set { - var integration: [String: CodableValue] = self.raycastIntegration - if let newValue { - integration["script_command"] = .dictionary([ - "directory_path": .string(newValue.directoryURL.path), - "fxcodex_executable_path": .string(newValue.fxcodexExecutableURL.path), - ]) - } else { - integration.removeValue(forKey: "script_command") - } - self.raycastIntegration = integration - } + var generatedDirectoryURL: URL { + self.directoryURL.appending(path: "fxcodex", directoryHint: .isDirectory) } - fileprivate var raycastAutomaticManagement: RaycastScriptCommandConfiguration? { - get { - guard - let value = self.raycastIntegration["automatic_script_commands"]?[case: \.dictionary], - value["enabled"]?[case: \.bool] == true, - let directoryPath = value["directory_path"]?[case: \.string], - let executablePath = value["fxcodex_executable_path"]?[case: \.string] - else { return nil } - return .init( - directoryURL: URL(filePath: directoryPath), - fxcodexExecutableURL: URL(filePath: executablePath) - ) - } - set { - var integration: [String: CodableValue] = self.raycastIntegration - if let newValue { - integration["automatic_script_commands"] = .dictionary([ - "enabled": .bool(true), - "directory_path": .string(newValue.directoryURL.path), - "fxcodex_executable_path": .string(newValue.fxcodexExecutableURL.path), - ]) - } else { - integration.removeValue(forKey: "automatic_script_commands") - } - self.raycastIntegration = integration - } + func includes(_ workspace: Workspace) -> Bool { + self.workspaceIDs?.contains(workspace.id) ?? true } } diff --git a/Sources/fxcodex-client/Integrations/Raycast/RaycastScriptCommandIcon.swift b/Sources/fxcodex-client/Integrations/Raycast/RaycastScriptCommandIcon.swift index 799a475..4212e47 100644 --- a/Sources/fxcodex-client/Integrations/Raycast/RaycastScriptCommandIcon.swift +++ b/Sources/fxcodex-client/Integrations/Raycast/RaycastScriptCommandIcon.swift @@ -14,6 +14,7 @@ extension RaycastScriptCommandIcon { private static func decode(_ value: String) -> Data { guard let data = Data(base64Encoded: value) else { preconditionFailure("Invalid embedded Raycast Script Command icon.") } + return data } } diff --git a/Sources/fxcodex-client/Manager/CodexApplicationClient.swift b/Sources/fxcodex-client/Manager/CodexApplicationClient.swift index f408082..ed506e2 100644 --- a/Sources/fxcodex-client/Manager/CodexApplicationClient.swift +++ b/Sources/fxcodex-client/Manager/CodexApplicationClient.swift @@ -8,8 +8,7 @@ struct CodexApplicationClient: Sendable { var rename: @Sendable (CodexApplicationName) async throws -> CodexApplicationRenameResult var open: @Sendable (Workspace) async throws -> Int32 var runningProcessID: @Sendable (Workspace) async throws -> Int32? - var removeRecord: @Sendable (_ forWorkspaceNamed: String) async throws -> Void - var renameRecord: @Sendable (_ from: String, _ to: String) async throws -> Void + var removeRecord: @Sendable (_ workspace: Workspace) async throws -> Void } extension DependencyValues { @@ -34,11 +33,7 @@ extension DependencyValues { }, removeRecord: { let controller: CodexApplicationController = await .init() - try await controller.removeRecord(forWorkspaceNamed: $0) - }, - renameRecord: { - let controller: CodexApplicationController = await .init() - try await controller.renameRecord(from: $0, to: $1) + try await controller.removeRecord(forWorkspaceID: $0.id) } ) } diff --git a/Sources/fxcodex-client/Manager/CodexApplicationController.swift b/Sources/fxcodex-client/Manager/CodexApplicationController.swift index 0fc0bbf..5d0d669 100644 --- a/Sources/fxcodex-client/Manager/CodexApplicationController.swift +++ b/Sources/fxcodex-client/Manager/CodexApplicationController.swift @@ -41,10 +41,8 @@ public final class CodexApplicationController { .reduce(into: [URL]()) { urls, url in let standardizedURL: URL = url.standardizedFileURL guard !urls.contains(standardizedURL) else { return } - guard self.fileManager.fileExists(atPath: standardizedURL.path) - else { return } - guard Bundle(url: standardizedURL)?.bundleIdentifier == Self.bundleIdentifier - else { return } + guard self.fileManager.fileExists(atPath: standardizedURL.path) else { return } + guard Bundle(url: standardizedURL)?.bundleIdentifier == Self.bundleIdentifier else { return } urls.append(standardizedURL) } @@ -112,8 +110,7 @@ public final class CodexApplicationController { return application.processIdentifier } - guard let applicationURL = self.applicationURL() - else { throw FXCodexError.applicationNotFound } + guard let applicationURL = self.applicationURL() else { throw FXCodexError.applicationNotFound } let configuration: NSWorkspace.OpenConfiguration = .init() configuration.activates = true @@ -140,7 +137,7 @@ public final class CodexApplicationController { ) try self.cache( application: application, - forWorkspaceNamed: workspace.name + forWorkspaceID: workspace.id ) return application.processIdentifier @@ -150,38 +147,27 @@ public final class CodexApplicationController { try self.runningApplication(for: workspace)?.processIdentifier } - public func removeRecord(forWorkspaceNamed name: String) throws { - try self.instances.remove(for: name) - } - - public func renameRecord( - from oldName: String, - to newName: String - ) throws { - try self.instances.transfer( - from: oldName, - to: newName - ) + public func removeRecord(forWorkspaceID id: WorkspaceID) throws { + try self.instances.remove(for: id) } } @MainActor -extension CodexApplicationController { + extension CodexApplicationController { private func runningApplication(for workspace: Workspace) throws -> NSRunningApplication? { - if - let record = try self.instances.find(for: workspace.name), - let application = self.validatedApplication(for: record) - { + let record = try self.instances.find(for: workspace.id) + let application = record.flatMap { self.validatedApplication(for: $0) } + if let application { return application } - try self.instances.remove(for: workspace.name) + try self.instances.remove(for: workspace.id) guard workspace.kind == .primary else { return nil } - let records: [String: ApplicationInstanceRecord] = try self.instances.list() + let records: [WorkspaceID: ApplicationInstanceRecord] = try self.instances.list() let managedProcessIDs: Set = .init( records - .filter { $0.key != Workspace.primaryName } + .filter { $0.key != workspace.id } .compactMap { self.validatedApplication(for: $0.value)?.processIdentifier } ) let candidates: [NSRunningApplication] = NSRunningApplication.runningApplications( @@ -201,7 +187,7 @@ extension CodexApplicationController { try self.cache( application: application, - forWorkspaceNamed: Workspace.primaryName + forWorkspaceID: workspace.id ) return application } @@ -209,23 +195,23 @@ extension CodexApplicationController { private func validatedApplication( for record: ApplicationInstanceRecord ) -> NSRunningApplication? { - guard let application = NSRunningApplication( - processIdentifier: record.processID - ) else { return nil } + guard let application = NSRunningApplication(processIdentifier: record.processID) else { return nil } + guard !application.isTerminated else { return nil } guard application.bundleIdentifier == Self.bundleIdentifier else { return nil } + guard application.bundleURL?.standardizedFileURL == record.bundleURL.standardizedFileURL else { return nil } + guard let launchDate = application.launchDate else { return nil } - guard abs(launchDate.timeIntervalSince(record.launchDate)) < 1 - else { return nil } + guard abs(launchDate.timeIntervalSince(record.launchDate)) < 1 else { return nil } return application } private func cache( application: NSRunningApplication, - forWorkspaceNamed name: String + forWorkspaceID id: WorkspaceID ) throws { guard let bundleURL = application.bundleURL, @@ -238,7 +224,7 @@ extension CodexApplicationController { launchDate: launchDate, processID: application.processIdentifier ), - for: name + for: id ) } diff --git a/Sources/fxcodex-client/Manager/CodexManager.swift b/Sources/fxcodex-client/Manager/CodexManager.swift index 6285c31..13cf895 100644 --- a/Sources/fxcodex-client/Manager/CodexManager.swift +++ b/Sources/fxcodex-client/Manager/CodexManager.swift @@ -51,6 +51,7 @@ actor CodexManager { code: "automatic_rename_failed", message: "Automatic rename failed, but Codex.app is available and will be used: \(error.localizedDescription)" )) + } else { throw error } @@ -114,6 +115,7 @@ actor CodexManager { let managedWorkspaces: [Workspace] = try self.workspacesStorage.list().filter { $0.kind == .managed } + switch disposition { case .leave: _ = try await self.integrations.raycast.uninstallScriptCommands() @@ -139,6 +141,18 @@ actor CodexManager { try workspacesStorage.list() } + func prepareStorage() throws { + try self.workspacesStorage.prepare() + } + + func storageMigrationPlan() throws -> StorageMigrationPlan? { + try Migrator(fileManager: .default).migrationPlan() + } + + func migrateStorage(_ migration: StorageMigration) throws { + try Migrator(fileManager: .default).migrate(migration) + } + func currentWorkspace() throws -> Workspace { try workspacesStorage.currentWorkspace() } @@ -175,6 +189,7 @@ actor CodexManager { to newName: String ) async throws -> Workspace { let workspace: Workspace = try self.workspacesStorage.findWorkspace(named: oldName) + guard try await self.application.runningProcessID(workspace) == nil else { throw FXCodexError.workspaceIsRunning(workspace.name) } @@ -182,26 +197,29 @@ actor CodexManager { workspace, to: newName ) - let integratedWorkspace: Workspace = try await self.integrations.raycast.workspaceRenamed( + return try await self.integrations.raycast.workspaceRenamed( workspace, renamedWorkspace ) - try await self.application.renameRecord( - workspace.name, - integratedWorkspace.name - ) - return integratedWorkspace } func useWorkspace(named name: String) throws { try self.workspacesStorage.setCurrent(self.workspacesStorage.findWorkspace(named: name)) } + func useWorkspace(id: WorkspaceID) throws { + try self.workspacesStorage.setCurrent(self.workspacesStorage.findWorkspaceByID(id)) + } + func openWorkspace(named name: String?) async throws -> Int32 { let workspace: Workspace = try self.workspacesStorage.findWorkspace(named: name) return try await self.application.open(workspace) } + func openWorkspace(id: WorkspaceID) async throws -> Int32 { + try await self.application.open(self.workspacesStorage.findWorkspaceByID(id)) + } + func codexInvocation( workspaceName: String?, arguments: [String] @@ -212,7 +230,9 @@ actor CodexManager { if workspace.kind == .managed { guard let codexHomeURL = workspace.codexHomeURL else { throw FXCodexError.workspaceNotFound(workspace.name) } + environment = ["CODEX_HOME": codexHomeURL.path] + } else { environment = [:] } @@ -225,16 +245,19 @@ actor CodexManager { } func status() async throws -> FXCodexStatus { - let currentWorkspaceName: String = try self.workspacesStorage.currentWorkspace().name + let currentWorkspace = try self.workspacesStorage.currentWorkspace() var workspaceStatuses: [WorkspaceStatus] = [] + for workspace in try self.workspacesStorage.list() { workspaceStatuses.append(.init( workspace: workspace, - isCurrent: workspace.name == currentWorkspaceName, + isCurrent: workspace.id == currentWorkspace.id, processID: try await self.application.runningProcessID(workspace) )) } + var raycastApplications: [RaycastApplicationStatus] = [] + for edition in RaycastEdition.allCases { raycastApplications.append( try await self.integrations.raycast.applicationStatus(edition) @@ -242,7 +265,8 @@ actor CodexManager { } return .init( - currentWorkspace: currentWorkspaceName, + currentWorkspace: currentWorkspace.name, + currentWorkspaceID: currentWorkspace.id, supportDirectoryURL: self.paths.rootURL, applicationURL: await self.application.applicationURL(), preferences: try self.preferencesStorage.load(), @@ -265,11 +289,14 @@ extension CodexManager { private func managedWorkspaces(named names: [String]) throws -> [Workspace] { var resolvedNames: Set = [] + return try names.compactMap { name in guard resolvedNames.insert(name).inserted else { return nil } + let workspace: Workspace = try self.workspacesStorage.findWorkspace(named: name) - guard workspace.kind == .managed - else { throw FXCodexError.primaryWorkspaceMutation } + + guard workspace.kind == .managed else { throw FXCodexError.primaryWorkspaceMutation } + return workspace } } @@ -283,25 +310,29 @@ extension CodexManager { private func deleteWorkspaces(_ workspaces: [Workspace]) async throws { try await self.ensureStopped(workspaces) + for workspace in workspaces { - guard workspace.kind == .managed - else { throw FXCodexError.primaryWorkspaceMutation } + guard workspace.kind == .managed else { throw FXCodexError.primaryWorkspaceMutation } + try await self.integrations.raycast.workspaceDeleted(workspace) try self.workspacesStorage.delete(workspace) - try await self.application.removeRecord(workspace.name) + try await self.application.removeRecord(workspace) } } private func eraseWorkspaces(_ workspaces: [Workspace]) async throws -> [Workspace] { try await self.ensureStopped(workspaces) + var erasedWorkspaces: [Workspace] = [] + for workspace in workspaces { - guard workspace.kind == .managed - else { throw FXCodexError.primaryWorkspaceMutation } + guard workspace.kind == .managed else { throw FXCodexError.primaryWorkspaceMutation } + try await self.integrations.raycast.workspaceErased(workspace) erasedWorkspaces.append(try self.workspacesStorage.erase(workspace)) - try await self.application.removeRecord(workspace.name) + try await self.application.removeRecord(workspace) } + return erasedWorkspaces } } diff --git a/Sources/fxcodex-client/Models/AutoUpdatePolicy.swift b/Sources/fxcodex-client/Models/AutoUpdatePolicy.swift index d4a8500..951b917 100644 --- a/Sources/fxcodex-client/Models/AutoUpdatePolicy.swift +++ b/Sources/fxcodex-client/Models/AutoUpdatePolicy.swift @@ -19,6 +19,7 @@ extension AutoUpdatePolicy: Codable { SemanticVersion.self, forKey: .from ) + switch channel { case "disabled": self = .disabled @@ -46,6 +47,7 @@ extension AutoUpdatePolicy: Codable { public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) + switch self { case .disabled: try container.encode("disabled", forKey: .channel) diff --git a/Sources/fxcodex-client/Models/FXCodexStatus.swift b/Sources/fxcodex-client/Models/FXCodexStatus.swift index 4bd55f1..68f01c8 100644 --- a/Sources/fxcodex-client/Models/FXCodexStatus.swift +++ b/Sources/fxcodex-client/Models/FXCodexStatus.swift @@ -2,6 +2,7 @@ import Foundation public struct FXCodexStatus: Codable, Equatable, Sendable { public let currentWorkspace: String + public let currentWorkspaceID: WorkspaceID public let supportDirectoryURL: URL public let applicationURL: URL? public let preferences: FXCodexPreferences @@ -11,6 +12,7 @@ public struct FXCodexStatus: Codable, Equatable, Sendable { public init( currentWorkspace: String, + currentWorkspaceID: WorkspaceID, supportDirectoryURL: URL, applicationURL: URL?, preferences: FXCodexPreferences, @@ -19,6 +21,7 @@ public struct FXCodexStatus: Codable, Equatable, Sendable { raycastScriptCommands: RaycastScriptCommandStatus ) { self.currentWorkspace = currentWorkspace + self.currentWorkspaceID = currentWorkspaceID self.supportDirectoryURL = supportDirectoryURL self.applicationURL = applicationURL self.preferences = preferences diff --git a/Sources/fxcodex-client/Models/IntegrationAttributePath.swift b/Sources/fxcodex-client/Models/IntegrationAttributePath.swift new file mode 100644 index 0000000..a1c1945 --- /dev/null +++ b/Sources/fxcodex-client/Models/IntegrationAttributePath.swift @@ -0,0 +1,127 @@ +import Foundation +import Parsing + +public struct IntegrationAttributePath: Equatable, Sendable { + public enum Component: Equatable, Sendable { + case member(String) + case key(String) + case index(Int) + case function(Function) + } + + public enum Function: String, CaseIterable, Sendable { + case count + case first + case last + case keys + case values + } + + public let components: [Component] + public let rawValue: String + + public init(_ rawValue: String) throws { + self.rawValue = rawValue + var input = rawValue[...] + self.components = try AttributePathParser().parse(&input) + } +} + +private struct AttributePathParser: Parser { + func parse(_ input: inout Substring) throws -> [IntegrationAttributePath.Component] { + let original = String(input) + var components: [IntegrationAttributePath.Component] = [] + var expectsComponent = true + + while !input.isEmpty { + if input.first == "." { + guard !expectsComponent else { throw FXCodexError.invalidAttributePath(original) } + input.removeFirst() + expectsComponent = true + continue + } + + let component: IntegrationAttributePath.Component + switch input.first { + case "[": + component = try self.parseBracket(&input, original: original) + + case "(": + component = try self.parseFunction(&input, original: original) + + default: + let end = input.firstIndex(where: { $0 == "." || $0 == "[" || $0 == "(" }) ?? input.endIndex + let member = String(input[.. IntegrationAttributePath.Component { + guard let close = input.firstIndex(of: "]") + else { throw FXCodexError.invalidAttributePath(original) } + + let content = input[input.index(after: input.startIndex)..= 0, String(index) == value else { + throw FXCodexError.invalidAttributePath(original) + } + return .index(index) + + default: + throw FXCodexError.invalidAttributePath(original) + } + } + + private func parseFunction( + _ input: inout Substring, + original: String + ) throws -> IntegrationAttributePath.Component { + guard let close = input.firstIndex(of: ")") + else { throw FXCodexError.invalidAttributePath(original) } + + let name = String(input[input.index(after: input.startIndex).. Bool { + guard let first = value.first, first.isLetter || first == "_" else { return false } + return value.dropFirst().allSatisfy { $0.isLetter || $0.isNumber || $0 == "_" || $0 == "-" } + } + + private static func parseKey(_ value: String) -> String? { + guard value.first == "\"" else { return value } + guard let data = value.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(String.self, from: data) + } +} diff --git a/Sources/fxcodex-client/Models/SchemaVersion.swift b/Sources/fxcodex-client/Models/SchemaVersion.swift new file mode 100644 index 0000000..ce56aa5 --- /dev/null +++ b/Sources/fxcodex-client/Models/SchemaVersion.swift @@ -0,0 +1,60 @@ +import Foundation + +public struct SchemaVersion: Codable, Comparable, CustomStringConvertible, Hashable, Sendable { + public static let v1_0: Self = .init(major: 1, minor: 0) + public static let v2_0: Self = .init(major: 2, minor: 0) + + public let major: Int + public let minor: Int + + public var description: String { "\(self.major).\(self.minor)" } + + public init(major: Int, minor: Int) { + precondition(major >= 0 && minor >= 0) + self.major = major + self.minor = minor + } + + public init?(_ description: String) { + let components = description.split(separator: ".", omittingEmptySubsequences: false) + + guard + components.count == 2, + let major = Self.parse(components[0]), + let minor = Self.parse(components[1]) + else { return nil } + + self.init(major: major, minor: minor) + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + guard let version = Self(value) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid schema version \(value). Expected major.minor." + ) + } + self = version + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.description) + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + (lhs.major, lhs.minor) < (rhs.major, rhs.minor) + } + + private static func parse(_ component: Substring) -> Int? { + guard + !component.isEmpty, + component.allSatisfy(\.isNumber), + component == "0" || component.first != "0" + else { return nil } + + return Int(component) + } +} diff --git a/Sources/fxcodex-client/Models/SemanticVersion.swift b/Sources/fxcodex-client/Models/SemanticVersion.swift index f254b01..9acb0fb 100644 --- a/Sources/fxcodex-client/Models/SemanticVersion.swift +++ b/Sources/fxcodex-client/Models/SemanticVersion.swift @@ -56,6 +56,7 @@ public struct SemanticVersion: Comparable, Codable, CustomStringConvertible, Sen separator: ".", omittingEmptySubsequences: false ) + guard numberComponents.count == 3, let major = Self.parseNumber(numberComponents[0]), @@ -69,7 +70,9 @@ public struct SemanticVersion: Comparable, Codable, CustomStringConvertible, Sen versionComponents[1], numericLeadingZerosAllowed: false ) else { return nil } + prereleaseIdentifiers = identifiers + } else { prereleaseIdentifiers = [] } @@ -80,7 +83,9 @@ public struct SemanticVersion: Comparable, Codable, CustomStringConvertible, Sen buildComponents[1], numericLeadingZerosAllowed: true ) else { return nil } + buildMetadataIdentifiers = identifiers + } else { buildMetadataIdentifiers = [] } @@ -139,6 +144,7 @@ public struct SemanticVersion: Comparable, Codable, CustomStringConvertible, Sen guard lhsIdentifier != rhsIdentifier else { continue } let lhsNumber: Int? = Int(lhsIdentifier) let rhsNumber: Int? = Int(rhsIdentifier) + switch (lhsNumber, rhsNumber) { case let (.some(lhsNumber), .some(rhsNumber)): return lhsNumber < rhsNumber @@ -165,6 +171,7 @@ extension SemanticVersion { value.allSatisfy(\.isNumber), value == "0" || value.first != "0" else { return nil } + return Int(value) } @@ -182,12 +189,12 @@ extension SemanticVersion { !identifier.isEmpty, identifier.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "-" }) else { return nil } - if - !numericLeadingZerosAllowed, - identifier.allSatisfy(\.isNumber), - identifier.count > 1, - identifier.first == "0" - { + + let hasInvalidLeadingZero = !numericLeadingZerosAllowed + && identifier.allSatisfy(\.isNumber) + && identifier.count > 1 + && identifier.first == "0" + if hasInvalidLeadingZero { return nil } } diff --git a/Sources/fxcodex-client/Models/StorageMigrationPlan.swift b/Sources/fxcodex-client/Models/StorageMigrationPlan.swift new file mode 100644 index 0000000..29ab782 --- /dev/null +++ b/Sources/fxcodex-client/Models/StorageMigrationPlan.swift @@ -0,0 +1,38 @@ +public struct StorageMigration: Equatable, Sendable { + public let sourceVersion: SchemaVersion + public let destinationVersion: SchemaVersion + public let steps: [String] + public let requiresUserInput: Bool + + public init( + sourceVersion: SchemaVersion, + destinationVersion: SchemaVersion, + steps: [String], + requiresUserInput: Bool + ) { + self.sourceVersion = sourceVersion + self.destinationVersion = destinationVersion + self.steps = steps + self.requiresUserInput = requiresUserInput + } +} + +public struct StorageMigrationPlan: Equatable, Sendable { + public let sourceVersion: SchemaVersion + public let destinationVersion: SchemaVersion + public let migrations: [StorageMigration] + + public var requiresUserInput: Bool { + self.migrations.contains(where: \.requiresUserInput) + } + + public init( + sourceVersion: SchemaVersion, + destinationVersion: SchemaVersion, + migrations: [StorageMigration] + ) { + self.sourceVersion = sourceVersion + self.destinationVersion = destinationVersion + self.migrations = migrations + } +} diff --git a/Sources/fxcodex-client/Models/Workspace.swift b/Sources/fxcodex-client/Models/Workspace.swift index ad4474a..5ef227f 100644 --- a/Sources/fxcodex-client/Models/Workspace.swift +++ b/Sources/fxcodex-client/Models/Workspace.swift @@ -8,6 +8,7 @@ public enum WorkspaceKind: String, Codable, Sendable { public struct Workspace: Codable, Equatable, Sendable { public static let primaryName: String = "primary" + public let id: WorkspaceID public let name: String public let kind: WorkspaceKind public let rootURL: URL? @@ -16,6 +17,7 @@ public struct Workspace: Codable, Equatable, Sendable { public var integrations: [String: CodableValue] public init( + id: WorkspaceID = .generate(), name: String, kind: WorkspaceKind, rootURL: URL?, @@ -23,6 +25,7 @@ public struct Workspace: Codable, Equatable, Sendable { userDataURL: URL?, integrations: [String: CodableValue] = [:] ) { + self.id = id self.name = name self.kind = kind self.rootURL = rootURL diff --git a/Sources/fxcodex-client/Models/WorkspaceID.swift b/Sources/fxcodex-client/Models/WorkspaceID.swift new file mode 100644 index 0000000..7532bc9 --- /dev/null +++ b/Sources/fxcodex-client/Models/WorkspaceID.swift @@ -0,0 +1,41 @@ +import Foundation + +public struct WorkspaceID: Codable, Comparable, CustomStringConvertible, Hashable, Sendable { + public let rawValue: String + + public var description: String { self.rawValue } + + public init?(_ rawValue: String) { + guard + let uuid = UUID(uuidString: rawValue), + uuid.uuidString.lowercased() == rawValue + else { return nil } + + self.rawValue = rawValue + } + + public static func generate() -> Self { + Self(UUID().uuidString.lowercased())! + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + guard let id = Self(value) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid lowercase workspace UUID \(value)." + ) + } + self = id + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.rawValue) + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } +} diff --git a/Sources/fxcodex-client/Storage/AppInstancesStorage.swift b/Sources/fxcodex-client/Storage/AppInstancesStorage.swift index ee8d331..e5ecff6 100644 --- a/Sources/fxcodex-client/Storage/AppInstancesStorage.swift +++ b/Sources/fxcodex-client/Storage/AppInstancesStorage.swift @@ -1,17 +1,13 @@ -import Foundation import Dependencies import DependenciesMacros +import Foundation struct ApplicationInstanceRecord: Codable, Equatable, Sendable { let bundleURL: URL let launchDate: Date let processID: Int32 - init( - bundleURL: URL, - launchDate: Date, - processID: Int32 - ) { + init(bundleURL: URL, launchDate: Date, processID: Int32) { self.bundleURL = bundleURL self.launchDate = launchDate self.processID = processID @@ -26,11 +22,10 @@ struct ApplicationInstanceRecord: Codable, Equatable, Sendable { @DependencyClient struct AppInstancesStorageClient { - var list: @Sendable () throws -> [String: ApplicationInstanceRecord] - var find: @Sendable (_ for: String) throws -> ApplicationInstanceRecord? - var save: @Sendable (ApplicationInstanceRecord, _ for: String) throws -> Void - var remove: @Sendable (_ for: String) throws -> Void - var transfer: @Sendable (_ from: String, _ to: String) throws -> Void + var list: @Sendable () throws -> [WorkspaceID: ApplicationInstanceRecord] + var find: @Sendable (_ for: WorkspaceID) throws -> ApplicationInstanceRecord? + var save: @Sendable (ApplicationInstanceRecord, _ for: WorkspaceID) throws -> Void + var remove: @Sendable (_ for: WorkspaceID) throws -> Void var replaceBundleURL: @Sendable (_ from: URL, _ to: URL) throws -> Void } @@ -42,8 +37,7 @@ extension DependencyValues { list: storage.records, find: storage.record, save: storage.setRecord, - remove: { try storage.setRecord(nil, forWorkspaceNamed: $0) }, - transfer: storage.renameRecord, + remove: { try storage.setRecord(nil, forWorkspaceID: $0) }, replaceBundleURL: storage.replaceBundleURL ) } @@ -59,92 +53,93 @@ final class AppInstancesStorage: @unchecked Sendable { private let decoder: JSONDecoder private let encoder: JSONEncoder private let fileManager: FileManager + private let lock: StorageLock + private let paths: FXCodexPaths - @Dependency(\._fxcodexPaths) - private var paths: FXCodexPaths + init(fileManager: FileManager = .default) { + @Dependency(\._fxcodexPaths) + var paths - init( - fileManager: FileManager = .default - ) { - let encoder: JSONEncoder = FXCodexJSONCoding.encoder() + let encoder = FXCodexJSONCoding.encoder() encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - - let decoder: JSONDecoder = .init() + let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - self.decoder = decoder self.encoder = encoder self.fileManager = fileManager + self.paths = paths + self.lock = StorageLock(fileManager: fileManager, paths: paths) } - func records() throws -> [String: ApplicationInstanceRecord] { - guard self.fileManager.fileExists(atPath: self.paths.instancesURL.path) - else { return [:] } - - let data: Data = try .init(contentsOf: self.paths.instancesURL) - return try self.decoder.decode( - [String: ApplicationInstanceRecord].self, - from: data + func records() throws -> [WorkspaceID: ApplicationInstanceRecord] { + try Migrator(fileManager: self.fileManager, paths: self.paths).migrateIfNeeded() + guard self.fileManager.fileExists(atPath: self.paths.runtimeURL.path) else { return [:] } + let runtime = try self.decoder.decode( + RuntimeConfiguration.self, + from: Data(contentsOf: self.paths.runtimeURL) ) + guard runtime.schemaVersion == .v2_0 else { + throw FXCodexError.unsupportedSchemaVersion(runtime.schemaVersion) + } + return runtime.instances } - func record(forWorkspaceNamed name: String) throws -> ApplicationInstanceRecord? { - try self.records()[name] + func record(forWorkspaceID id: WorkspaceID) throws -> ApplicationInstanceRecord? { + try self.records()[id] } - func setRecord( - _ record: ApplicationInstanceRecord?, - forWorkspaceNamed name: String - ) throws { - try self.fileManager.createDirectory( - at: self.paths.rootURL, - withIntermediateDirectories: true, - attributes: [.posixPermissions: 0o700] - ) - - var records: [String: ApplicationInstanceRecord] = try self.records() - records[name] = record - let data: Data = try self.encoder.encode(records) - try data.write( - to: self.paths.instancesURL, - options: [.atomic] - ) + func setRecord(_ record: ApplicationInstanceRecord?, forWorkspaceID id: WorkspaceID) throws { + try Migrator(fileManager: self.fileManager, paths: self.paths).migrateIfNeeded() + try self.lock.withLock { + var records = try self.loadRecords() + records[id] = record + try self.save(records: records) + } } - func renameRecord( - from oldName: String, - to newName: String - ) throws { - var records: [String: ApplicationInstanceRecord] = try self.records() - records[newName] = records.removeValue(forKey: oldName) - let data: Data = try self.encoder.encode(records) - try data.write( - to: self.paths.instancesURL, - options: [.atomic] - ) + func replaceBundleURL(from oldURL: URL, to newURL: URL) throws { + let oldURL = oldURL.standardizedFileURL + let newURL = newURL.standardizedFileURL + try Migrator(fileManager: self.fileManager, paths: self.paths).migrateIfNeeded() + try self.lock.withLock { + var records = try self.loadRecords() + + for (workspaceID, record) in records where record.bundleURL.standardizedFileURL == oldURL { + records[workspaceID] = .init( + bundleURL: newURL, + launchDate: record.launchDate, + processID: record.processID + ) + } + + guard !records.isEmpty else { return } + + try self.save(records: records) + } } - func replaceBundleURL( - from oldURL: URL, - to newURL: URL - ) throws { - let oldURL: URL = oldURL.standardizedFileURL - let newURL: URL = newURL.standardizedFileURL - var records: [String: ApplicationInstanceRecord] = try self.records() - - for (workspaceName, record) in records where record.bundleURL.standardizedFileURL == oldURL { - records[workspaceName] = .init( - bundleURL: newURL, - launchDate: record.launchDate, - processID: record.processID - ) + private func loadRecords() throws -> [WorkspaceID: ApplicationInstanceRecord] { + guard self.fileManager.fileExists(atPath: self.paths.runtimeURL.path) else { return [:] } + let runtime = try self.decoder.decode( + RuntimeConfiguration.self, + from: Data(contentsOf: self.paths.runtimeURL) + ) + guard runtime.schemaVersion == .v2_0 else { + throw FXCodexError.unsupportedSchemaVersion(runtime.schemaVersion) } - guard !records.isEmpty else { return } - let data: Data = try self.encoder.encode(records) - try data.write( - to: self.paths.instancesURL, + return runtime.instances + } + + private func save(records: [WorkspaceID: ApplicationInstanceRecord]) throws { + try self.fileManager.createDirectory( + at: self.paths.rootURL, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try self.encoder.encode(RuntimeConfiguration(instances: records)).write( + to: self.paths.runtimeURL, options: [.atomic] ) } diff --git a/Sources/fxcodex-client/Storage/FXCodexPaths.swift b/Sources/fxcodex-client/Storage/FXCodexPaths.swift index 947cf4c..f904090 100644 --- a/Sources/fxcodex-client/Storage/FXCodexPaths.swift +++ b/Sources/fxcodex-client/Storage/FXCodexPaths.swift @@ -5,7 +5,10 @@ public struct FXCodexPaths: Sendable { public let rootURL: URL public let configurationURL: URL public let instancesURL: URL + public let migrationURL: URL public let preferencesURL: URL + public let runtimeURL: URL + public let storageLockURL: URL public let updateStateURL: URL public let workspacesURL: URL @@ -19,10 +22,22 @@ public struct FXCodexPaths: Sendable { path: "instances.json", directoryHint: .notDirectory ) + self.migrationURL = self.rootURL.appending( + path: ".migration.json", + directoryHint: .notDirectory + ) self.preferencesURL = self.rootURL.appending( path: "preferences.json", directoryHint: .notDirectory ) + self.runtimeURL = self.rootURL.appending( + path: "runtime.json", + directoryHint: .notDirectory + ) + self.storageLockURL = self.rootURL.appending( + path: "storage.lock", + directoryHint: .notDirectory + ) self.updateStateURL = self.rootURL.appending( path: "update-state.json", directoryHint: .notDirectory @@ -57,6 +72,7 @@ extension DependencyValues { @_spi(Internals) public var _fxcodexPaths: FXCodexPaths { + get { self[FXCodexPathsKey.self] } set { self[FXCodexPathsKey.self] = newValue } } diff --git a/Sources/fxcodex-client/Storage/IntegrationAttributesStorage.swift b/Sources/fxcodex-client/Storage/IntegrationAttributesStorage.swift new file mode 100644 index 0000000..9800c18 --- /dev/null +++ b/Sources/fxcodex-client/Storage/IntegrationAttributesStorage.swift @@ -0,0 +1,301 @@ +import Dependencies +import DependenciesMacros +import Foundation + +@DependencyClient +public struct IntegrationAttributes: Sendable { + public var list: @Sendable () throws -> [String] + public var get: @Sendable (_ integration: String, _ path: IntegrationAttributePath) throws -> CodableValue + public var set: @Sendable (_ integration: String, _ path: IntegrationAttributePath, _ value: CodableValue) throws -> Void + public var remove: @Sendable (_ integration: String, _ path: IntegrationAttributePath) throws -> Void +} + +extension DependencyValues { + private enum IntegrationAttributesKey: DependencyKey { + static var liveValue: IntegrationAttributes { + let storage = IntegrationAttributesStorage(fileManager: .default) + return .init( + list: storage.integrationIDs, + get: storage.value, + set: storage.setValue, + remove: storage.removeValue + ) + } + } + + var _fxcodexIntegrationAttributes: IntegrationAttributes { + get { self[IntegrationAttributesKey.self] } + set { self[IntegrationAttributesKey.self] = newValue } + } +} + +final class IntegrationAttributesStorage: @unchecked Sendable { + private let decoder = JSONDecoder() + private let encoder: JSONEncoder + private let fileManager: FileManager + private let paths: FXCodexPaths + private let lock: StorageLock + + init(fileManager: FileManager) { + @Dependency(\._fxcodexPaths) + var paths + + let encoder = FXCodexJSONCoding.encoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + self.encoder = encoder + self.fileManager = fileManager + self.paths = paths + self.lock = StorageLock(fileManager: fileManager, paths: paths) + } + + func integrationIDs() throws -> [String] { + try self.prepare() + return try self.loadConfiguration().integrations.keys.sorted() + } + + func value(integration: String, path: IntegrationAttributePath) throws -> CodableValue { + try self.prepare() + + guard var value = try self.loadConfiguration().integrations[integration] else { + throw FXCodexError.integrationAttributeNotFound(Self.displayPath(integration, path)) + } + + for component in path.components { + value = try Self.apply(component, to: value, displayPath: Self.displayPath(integration, path)) + } + + return value + } + + func setValue(integration: String, path: IntegrationAttributePath, value: CodableValue) throws { + try Self.requireMutable(path) + try self.prepare() + try self.lock.withLock { + var configuration = try self.loadConfiguration() + + if path.components.isEmpty { + configuration.integrations[integration] = value + + } else { + let root = configuration.integrations[integration] ?? .dictionary([:]) + configuration.integrations[integration] = try Self.setting( + value, + in: root, + components: path.components[...], + displayPath: Self.displayPath(integration, path) + ) + } + + try self.save(configuration) + } + } + + func removeValue(integration: String, path: IntegrationAttributePath) throws { + try Self.requireMutable(path) + try self.prepare() + try self.lock.withLock { + var configuration = try self.loadConfiguration() + + guard let root = configuration.integrations[integration] else { + throw FXCodexError.integrationAttributeNotFound(Self.displayPath(integration, path)) + } + + if path.components.isEmpty { + configuration.integrations.removeValue(forKey: integration) + + } else { + configuration.integrations[integration] = try Self.removing( + from: root, + components: path.components[...], + displayPath: Self.displayPath(integration, path) + ) + } + + try self.save(configuration) + } + } +} + +private extension IntegrationAttributesStorage { + func prepare() throws { + try Migrator(fileManager: self.fileManager, paths: self.paths).migrateIfNeeded() + } + + func loadConfiguration() throws -> StorageConfiguration { + try self.decoder.decode(StorageConfiguration.self, from: Data(contentsOf: self.paths.configurationURL)) + } + + func save(_ configuration: StorageConfiguration) throws { + try self.encoder.encode(configuration).write(to: self.paths.configurationURL, options: [.atomic]) + } + + static func apply( + _ component: IntegrationAttributePath.Component, + to value: CodableValue, + displayPath: String + ) throws -> CodableValue { + switch component { + case let .member(key), let .key(key): + guard case let .dictionary(dictionary) = value, let result = dictionary[key] else { + throw FXCodexError.integrationAttributeNotFound(displayPath) + } + return result + + case let .index(index): + guard case let .array(array) = value, array.indices.contains(index) else { + throw FXCodexError.integrationAttributeNotFound(displayPath) + } + return array[index] + + case let .function(function): + return try Self.apply(function, to: value, displayPath: displayPath) + } + } + + static func apply( + _ function: IntegrationAttributePath.Function, + to value: CodableValue, + displayPath: String + ) throws -> CodableValue { + switch (function, value) { + case let (.count, .array(array)): + .int(array.count) + case let (.count, .dictionary(dictionary)): + .int(dictionary.count) + case let (.count, .string(string)): + .int(string.count) + case let (.first, .array(array)): + try array.first ?? Self.notFound(displayPath) + case let (.last, .array(array)): + try array.last ?? Self.notFound(displayPath) + case let (.first, .string(string)): + try string.first.map { .string(String($0)) } ?? Self.notFound(displayPath) + case let (.last, .string(string)): + try string.last.map { .string(String($0)) } ?? Self.notFound(displayPath) + case let (.keys, .dictionary(dictionary)): + .array(dictionary.keys.sorted().map(CodableValue.string)) + case let (.values, .dictionary(dictionary)): + .array(dictionary.keys.sorted().compactMap { dictionary[$0] }) + default: + throw FXCodexError.invalidAttributePath(displayPath) + } + } + + static func setting( + _ newValue: CodableValue, + in current: CodableValue, + components: ArraySlice, + displayPath: String + ) throws -> CodableValue { + guard let component = components.first else { return newValue } + let remaining = components.dropFirst() + + switch component { + case let .member(key), let .key(key): + var dictionary: [String: CodableValue] + + if case let .dictionary(existing) = current { + dictionary = existing + } else { + throw FXCodexError.invalidAttributePath(displayPath) + } + + let child = dictionary[key] ?? .dictionary([:]) + dictionary[key] = try Self.setting( + newValue, + in: child, + components: remaining, + displayPath: displayPath + ) + + return .dictionary(dictionary) + + case let .index(index): + guard case var .array(array) = current, index <= array.count else { + throw FXCodexError.invalidAttributePath(displayPath) + } + if index == array.count { + guard remaining.isEmpty else { throw FXCodexError.invalidAttributePath(displayPath) } + array.append(newValue) + + } else { + array[index] = try Self.setting( + newValue, + in: array[index], + components: remaining, + displayPath: displayPath + ) + } + + return .array(array) + + case .function: + throw FXCodexError.invalidAttributePath(displayPath) + } + } + + static func removing( + from current: CodableValue, + components: ArraySlice, + displayPath: String + ) throws -> CodableValue { + guard let component = components.first else { return current } + let remaining = components.dropFirst() + + switch component { + case let .member(key), let .key(key): + guard case var .dictionary(dictionary) = current, let child = dictionary[key] else { + throw FXCodexError.integrationAttributeNotFound(displayPath) + } + if remaining.isEmpty { + dictionary.removeValue(forKey: key) + + } else { + dictionary[key] = try Self.removing( + from: child, + components: remaining, + displayPath: displayPath + ) + } + + return .dictionary(dictionary) + + case let .index(index): + guard case var .array(array) = current, array.indices.contains(index) else { + throw FXCodexError.integrationAttributeNotFound(displayPath) + } + if remaining.isEmpty { + array.remove(at: index) + + } else { + array[index] = try Self.removing( + from: array[index], + components: remaining, + displayPath: displayPath + ) + } + + return .array(array) + + case .function: + throw FXCodexError.invalidAttributePath(displayPath) + } + } + + static func requireMutable(_ path: IntegrationAttributePath) throws { + guard + !path.components.contains(where: { + if case .function = $0 { return true } + return false + }) + else { throw FXCodexError.invalidAttributePath(path.rawValue) } + } + + static func displayPath(_ integration: String, _ path: IntegrationAttributePath) -> String { + path.rawValue.isEmpty ? integration : "\(integration).\(path.rawValue)" + } + + static func notFound(_ path: String) throws -> CodableValue { + throw FXCodexError.integrationAttributeNotFound(path) + } +} diff --git a/Sources/fxcodex-client/Storage/Migrator.swift b/Sources/fxcodex-client/Storage/Migrator.swift new file mode 100644 index 0000000..c38af6e --- /dev/null +++ b/Sources/fxcodex-client/Storage/Migrator.swift @@ -0,0 +1,532 @@ +import Darwin +import Dependencies +import Foundation + +final class Migrator: @unchecked Sendable { + private enum MigrationV1 { + static let migration = StorageMigration( + sourceVersion: .v1_0, + destinationVersion: .v2_0, + steps: [ + "Assign stable IDs to existing workspaces", + "Move managed workspaces into ID-based directories", + "Convert runtime records and integration attributes", + "Write and validate schema 2.0 configuration", + ], + requiresUserInput: false + ) + } + + private struct LegacyConfiguration: Codable { + var currentWorkspaceName: String + var workspaceIntegrations: [String: [String: CodableValue]] + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.currentWorkspaceName = try container.decode(String.self, forKey: .currentWorkspaceName) + self.workspaceIntegrations = try container.decodeIfPresent( + [String: [String: CodableValue]].self, + forKey: .workspaceIntegrations + ) ?? [:] + } + + private enum CodingKeys: String, CodingKey { + case currentWorkspaceName = "current_workspace_name" + case workspaceIntegrations = "workspace_integrations" + } + } + + private struct ScriptCommandConfiguration: Equatable { + let path: String + let executablePath: String + } + + private let decoder: JSONDecoder + private let encoder: JSONEncoder + private let fileManager: FileManager + private let lock: StorageLock + private let paths: FXCodexPaths + + init(fileManager: FileManager) { + @Dependency(\._fxcodexPaths) + var paths + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let encoder = FXCodexJSONCoding.encoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + self.encoder = encoder + self.decoder = decoder + self.fileManager = fileManager + self.paths = paths + self.lock = StorageLock(fileManager: fileManager, paths: paths) + } + + init(fileManager: FileManager, paths: FXCodexPaths) { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let encoder = FXCodexJSONCoding.encoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + self.encoder = encoder + self.decoder = decoder + self.fileManager = fileManager + self.paths = paths + self.lock = StorageLock(fileManager: fileManager, paths: paths) + } + + func migrateIfNeeded() throws { + try self.lock.withLock { + try self.fileManager.createDirectory( + at: self.paths.workspacesURL, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + + guard self.fileManager.fileExists(atPath: self.paths.configurationURL.path) else { + try self.bootstrap() + return + } + + let version = try self.detectVersion() + + if version == .v2_0 { + try self.verifyV2() + try? self.fileManager.removeItem(at: self.paths.migrationURL) + return + } + + try self.validateSource(version: version) + + for migration in try self.migrationSequence(from: version) { + try self.perform(migration) + } + try self.verifyV2() + } + } + + func migrate(_ migration: StorageMigration) throws { + try self.lock.withLock { + guard self.fileManager.fileExists(atPath: self.paths.configurationURL.path) else { + throw FXCodexError.invalidStorage("configuration is missing") + } + + let sourceVersion = try self.detectVersion() + + try self.validateSource(version: sourceVersion) + + guard sourceVersion == migration.sourceVersion else { + throw FXCodexError.invalidStorage( + "expected schema \(migration.sourceVersion) before migration, found \(sourceVersion)" + ) + } + + let expected = try self.migrationSequence(from: sourceVersion).first + + guard expected == migration else { + throw FXCodexError.invalidStorage("migration does not match the registered schema sequence") + } + + try self.perform(migration) + + let destinationVersion = try self.detectVersion() + + guard destinationVersion == migration.destinationVersion else { + throw FXCodexError.invalidStorage( + "migration finished at schema \(destinationVersion), expected \(migration.destinationVersion)" + ) + } + } + } + + func migrationPlan() throws -> StorageMigrationPlan? { + try self.lock.withLock { + guard self.fileManager.fileExists(atPath: self.paths.configurationURL.path) else { + return nil + } + + let sourceVersion = try self.detectVersion() + if sourceVersion == .v2_0 { + try self.verifyV2() + return nil + } + + let migrations = try self.migrationSequence(from: sourceVersion) + try self.validateSource(version: sourceVersion) + + return StorageMigrationPlan( + sourceVersion: sourceVersion, + destinationVersion: .v2_0, + migrations: migrations + ) + } + } + + private func bootstrap() throws { + let primaryID = WorkspaceID.generate() + try self.writeWorkspaceConfiguration(.init( + id: primaryID, + name: Workspace.primaryName, + kind: .primary + )) + try self.write( + StorageConfiguration(currentWorkspaceID: primaryID), + to: self.paths.configurationURL + ) + } + + private func detectVersion() throws -> SchemaVersion { + let data = try Data(contentsOf: self.paths.configurationURL) + + guard + let object = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let value = object["schema_version"] + else { return .v1_0 } + + guard let string = value as? String, let version = SchemaVersion(string) else { + throw FXCodexError.invalidStorage("schema_version must use major.minor string format") + } + return version + } + + private func migrationSequence(from sourceVersion: SchemaVersion) throws -> [StorageMigration] { + var version = sourceVersion + var migrations: [StorageMigration] = [] + var visited: Set = [] + + while version != .v2_0 { + guard visited.insert(version).inserted else { + throw FXCodexError.invalidStorage("schema migration sequence contains a cycle at \(version)") + } + + let migration: StorageMigration = switch version { + case .v1_0: MigrationV1.migration + default: throw FXCodexError.unsupportedSchemaVersion(version) + } + + migrations.append(migration) + version = migration.destinationVersion + } + + return migrations + } + + private func perform(_ migration: StorageMigration) throws { + switch migration.sourceVersion { + case .v1_0: + try self.migrateV1ToV2() + + default: + throw FXCodexError.unsupportedSchemaVersion(migration.sourceVersion) + } + } + + private func validateSource(version: SchemaVersion) throws { + switch version { + case .v1_0: + let configuration = try self.read(LegacyConfiguration.self, from: self.paths.configurationURL) + var workspaceNames = Set(try self.legacyWorkspaceNames()) + if self.fileManager.fileExists(atPath: self.paths.migrationURL.path) { + let journal = try self.read(MigrationJournal.self, from: self.paths.migrationURL) + workspaceNames.formUnion(journal.workspaceIDs.keys) + } + + guard + configuration.currentWorkspaceName == Workspace.primaryName + || workspaceNames.contains(configuration.currentWorkspaceName) + else { + throw FXCodexError.invalidStorage( + "current_workspace_name does not reference an existing schema 1.0 workspace" + ) + } + + try self.validateLegacyApplicationInstances(workspaceNames: workspaceNames) + + default: + throw FXCodexError.unsupportedSchemaVersion(version) + } + } + + private func validateLegacyApplicationInstances(workspaceNames: Set) throws { + guard self.fileManager.fileExists(atPath: self.paths.instancesURL.path) else { return } + + let records = try self.read( + [String: ApplicationInstanceRecord].self, + from: self.paths.instancesURL + ) + + for workspaceName in records.keys.sorted() + where workspaceName != Workspace.primaryName && workspaceNames.contains(workspaceName) { + guard let record = records[workspaceName] else { continue } + guard !Self.processExists(record.processID) else { + throw FXCodexError.workspaceIsRunning(workspaceName) + } + } + } + + private static func processExists(_ processID: Int32) -> Bool { + guard processID > 0 else { return false } + if Darwin.kill(processID, 0) == 0 { return true } + return errno == EPERM + } + + private func migrateV1ToV2() throws { + let legacy = try self.read(LegacyConfiguration.self, from: self.paths.configurationURL) + let journal = try self.loadOrCreateJournal(legacy: legacy) + + for name in journal.workspaceIDs.keys.sorted() { + guard let id = journal.workspaceIDs[name] else { continue } + let oldURL = self.paths.workspacesURL.appending(path: name, directoryHint: .isDirectory) + let newURL = self.workspaceURL(id) + let oldExists = self.fileManager.fileExists(atPath: oldURL.path) + let newExists = self.fileManager.fileExists(atPath: newURL.path) + guard !(oldExists && newExists) else { + throw FXCodexError.invalidStorage("both legacy and migrated directories exist for workspace \(name)") + } + if oldExists { + try self.write( + WorkspaceConfiguration(id: id, name: name, kind: .managed), + to: oldURL.appending(path: "workspace.json") + ) + try self.fileManager.moveItem(at: oldURL, to: newURL) + + } else if !newExists { + throw FXCodexError.invalidStorage("legacy workspace directory is missing for \(name)") + } + } + + try self.writeWorkspaceConfiguration(.init( + id: journal.primaryWorkspaceID, + name: Workspace.primaryName, + kind: .primary + )) + + let allIDs = journal.workspaceIDs.merging( + [Workspace.primaryName: journal.primaryWorkspaceID], + uniquingKeysWith: { first, _ in first } + ) + let currentID = allIDs[legacy.currentWorkspaceName] ?? journal.primaryWorkspaceID + let integrations = self.migrateIntegrations( + legacy.workspaceIntegrations, + workspaceIDs: allIDs, + currentWorkspaceName: legacy.currentWorkspaceName + ) + + let legacyInstances = (try? self.read( + [String: ApplicationInstanceRecord].self, + from: self.paths.instancesURL + )) ?? [:] + let instances = legacyInstances.reduce(into: [WorkspaceID: ApplicationInstanceRecord]()) { result, item in + guard let id = allIDs[item.key] else { return } + result[id] = item.value + } + + try self.write(RuntimeConfiguration(instances: instances), to: self.paths.runtimeURL) + try self.write( + StorageConfiguration(currentWorkspaceID: currentID, integrations: integrations), + to: self.paths.configurationURL + ) + try? self.fileManager.removeItem(at: self.paths.instancesURL) + try? self.fileManager.removeItem(at: self.paths.migrationURL) + try self.verifyV2() + } + + private func loadOrCreateJournal(legacy: LegacyConfiguration) throws -> MigrationJournal { + if self.fileManager.fileExists(atPath: self.paths.migrationURL.path) { + return try self.read(MigrationJournal.self, from: self.paths.migrationURL) + } + + let names = try self.legacyWorkspaceNames() + let journal = MigrationJournal( + sourceVersion: .v1_0, + destinationVersion: .v2_0, + primaryWorkspaceID: .generate(), + workspaceIDs: Dictionary(uniqueKeysWithValues: names.map { ($0, WorkspaceID.generate()) }) + ) + try self.write(journal, to: self.paths.migrationURL) + return journal + } + + private func legacyWorkspaceNames() throws -> [String] { + try self.fileManager.contentsOfDirectory( + at: self.paths.workspacesURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) + .filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true } + .map(\.lastPathComponent) + .filter { WorkspaceID($0) == nil } + .sorted() + } + + private func migrateIntegrations( + _ legacy: [String: [String: CodableValue]], + workspaceIDs: [String: WorkspaceID], + currentWorkspaceName: String + ) -> [String: CodableValue] { + var result: [String: CodableValue] = [:] + var automaticScriptCommands: ScriptCommandConfiguration? + var currentScriptCommands: (configuration: ScriptCommandConfiguration, workspaceID: WorkspaceID)? + var firstScriptCommands: (configuration: ScriptCommandConfiguration, workspaceID: WorkspaceID)? + + for workspaceName in legacy.keys.sorted() { + guard + let workspaceID = workspaceIDs[workspaceName], + let attributes = legacy[workspaceName] + else { continue } + + for integrationID in attributes.keys.sorted() { + guard let value = attributes[integrationID] else { continue } + + var workspaceValue = value + + if integrationID == "raycast", var raycast = Self.dictionary(value) { + if let automatic = Self.scriptCommandConfiguration( + from: raycast["automatic_script_commands"], + requiresEnabled: true + ) { + automaticScriptCommands = automatic + } + + if let command = Self.scriptCommandConfiguration(from: raycast["script_command"]) { + let selection = (configuration: command, workspaceID: workspaceID) + firstScriptCommands = firstScriptCommands ?? selection + if workspaceName == currentWorkspaceName { currentScriptCommands = selection } + } + raycast.removeValue(forKey: "automatic_script_commands") + raycast.removeValue(forKey: "script_command") + workspaceValue = .dictionary(raycast) + } + + guard + let dictionary = Self.dictionary(workspaceValue), + !dictionary.isEmpty + else { continue } + + var integration = Self.dictionary(result[integrationID]) ?? [:] + var workspaces = Self.dictionary(integration["workspaces"]) ?? [:] + workspaces[workspaceID.rawValue] = .dictionary(dictionary) + integration["workspaces"] = .dictionary(workspaces) + result[integrationID] = .dictionary(integration) + } + } + + if let scriptCommands = automaticScriptCommands { + var raycast = Self.dictionary(result["raycast"]) ?? [:] + raycast["script_commands"] = .dictionary([ + "path": .string(scriptCommands.path), + "executable_path": .string(scriptCommands.executablePath), + ]) + result["raycast"] = .dictionary(raycast) + + } else if let scriptCommands = currentScriptCommands ?? firstScriptCommands { + var raycast = Self.dictionary(result["raycast"]) ?? [:] + raycast["script_commands"] = .dictionary([ + "path": .string(scriptCommands.configuration.path), + "executable_path": .string(scriptCommands.configuration.executablePath), + "workspace_ids": .array([.string(scriptCommands.workspaceID.rawValue)]), + ]) + result["raycast"] = .dictionary(raycast) + } + + return result + } + + private static func scriptCommandConfiguration( + from value: CodableValue?, + requiresEnabled: Bool = false + ) -> ScriptCommandConfiguration? { + guard let dictionary = Self.dictionary(value) else { return nil } + if requiresEnabled, Self.bool(dictionary["enabled"]) != true { return nil } + + guard + let path = Self.string(dictionary["directory_path"]), + let executablePath = Self.string(dictionary["fxcodex_executable_path"]) + else { return nil } + + return .init(path: path, executablePath: executablePath) + } + + private func verifyV2() throws { + let configuration = try self.read(StorageConfiguration.self, from: self.paths.configurationURL) + guard configuration.schemaVersion == .v2_0 else { + throw FXCodexError.unsupportedSchemaVersion(configuration.schemaVersion) + } + let workspaces = try self.workspaceConfigurations() + guard workspaces.filter({ $0.kind == .primary }).count == 1 else { + throw FXCodexError.invalidStorage("schema 2.0 requires exactly one primary workspace") + } + guard workspaces.contains(where: { $0.id == configuration.currentWorkspaceID }) else { + throw FXCodexError.invalidStorage("current_workspace_id does not reference an existing workspace") + } + guard Set(workspaces.map(\.name)).count == workspaces.count else { + throw FXCodexError.invalidStorage("workspace names must be unique") + } + } + + private func workspaceConfigurations() throws -> [WorkspaceConfiguration] { + try self.fileManager.contentsOfDirectory( + at: self.paths.workspacesURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) + .filter { directory in + (try? directory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true + && WorkspaceID(directory.lastPathComponent) != nil + } + .map { directory in + guard let id = WorkspaceID(directory.lastPathComponent) else { preconditionFailure() } + let value = try self.read( + WorkspaceConfiguration.self, + from: directory.appending(path: "workspace.json") + ) + guard value.id == id else { + throw FXCodexError.invalidStorage("workspace directory and metadata IDs do not match") + } + return value + } + } + + private func writeWorkspaceConfiguration(_ configuration: WorkspaceConfiguration) throws { + let directory = self.workspaceURL(configuration.id) + try self.fileManager.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try self.write(configuration, to: directory.appending(path: "workspace.json")) + } + + private func workspaceURL(_ id: WorkspaceID) -> URL { + self.paths.workspacesURL.appending(path: id.rawValue, directoryHint: .isDirectory) + } + + private func read(_ type: Value.Type, from url: URL) throws -> Value { + try self.decoder.decode(type, from: Data(contentsOf: url)) + } + + private func write(_ value: Value, to url: URL) throws { + try self.fileManager.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try self.encoder.encode(value).write(to: url, options: [.atomic]) + } + + private static func dictionary(_ value: CodableValue?) -> [String: CodableValue]? { + guard case let .dictionary(dictionary) = value else { return nil } + return dictionary + } + + private static func string(_ value: CodableValue?) -> String? { + guard case let .string(string) = value else { return nil } + return string + } + + private static func bool(_ value: CodableValue?) -> Bool? { + guard case let .bool(bool) = value else { return nil } + return bool + } +} diff --git a/Sources/fxcodex-client/Storage/PreferencesStorage.swift b/Sources/fxcodex-client/Storage/PreferencesStorage.swift index 75ecded..42e4e70 100644 --- a/Sources/fxcodex-client/Storage/PreferencesStorage.swift +++ b/Sources/fxcodex-client/Storage/PreferencesStorage.swift @@ -47,8 +47,7 @@ final class PreferencesStorage: @unchecked Sendable { } func load() throws -> FXCodexPreferences { - guard self.fileManager.fileExists(atPath: self.paths.preferencesURL.path) - else { return .init() } + guard self.fileManager.fileExists(atPath: self.paths.preferencesURL.path) else { return .init() } let data: Data = try .init(contentsOf: self.paths.preferencesURL) return try self.decoder.decode( diff --git a/Sources/fxcodex-client/Storage/StorageLock.swift b/Sources/fxcodex-client/Storage/StorageLock.swift new file mode 100644 index 0000000..b3204cd --- /dev/null +++ b/Sources/fxcodex-client/Storage/StorageLock.swift @@ -0,0 +1,32 @@ +import Darwin +import Foundation + +final class StorageLock: @unchecked Sendable { + private let fileManager: FileManager + private let paths: FXCodexPaths + + init(fileManager: FileManager, paths: FXCodexPaths) { + self.fileManager = fileManager + self.paths = paths + } + + func withLock(_ operation: () throws -> Value) throws -> Value { + try self.fileManager.createDirectory( + at: self.paths.rootURL, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let descriptor = Darwin.open( + self.paths.storageLockURL.path, + O_CREAT | O_RDWR, + S_IRUSR | S_IWUSR + ) + guard descriptor >= 0 else { throw CocoaError(.fileWriteUnknown) } + defer { Darwin.close(descriptor) } + guard Darwin.lockf(descriptor, F_LOCK, 0) == 0 else { + throw CocoaError(.fileLocking) + } + defer { Darwin.lockf(descriptor, F_ULOCK, 0) } + return try operation() + } +} diff --git a/Sources/fxcodex-client/Storage/StorageModels.swift b/Sources/fxcodex-client/Storage/StorageModels.swift new file mode 100644 index 0000000..7f0386e --- /dev/null +++ b/Sources/fxcodex-client/Storage/StorageModels.swift @@ -0,0 +1,102 @@ +import Foundation + +struct StorageConfiguration: Codable, Equatable, Sendable { + var schemaVersion: SchemaVersion + var currentWorkspaceID: WorkspaceID + var integrations: [String: CodableValue] + + init( + schemaVersion: SchemaVersion = .v2_0, + currentWorkspaceID: WorkspaceID, + integrations: [String: CodableValue] = [:] + ) { + self.schemaVersion = schemaVersion + self.currentWorkspaceID = currentWorkspaceID + self.integrations = integrations + } + + private enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case currentWorkspaceID = "current_workspace_id" + case integrations + } +} + +struct WorkspaceConfiguration: Codable, Equatable, Sendable { + var schemaVersion: SchemaVersion + let id: WorkspaceID + var name: String + let kind: WorkspaceKind + + init( + schemaVersion: SchemaVersion = .v2_0, + id: WorkspaceID, + name: String, + kind: WorkspaceKind + ) { + self.schemaVersion = schemaVersion + self.id = id + self.name = name + self.kind = kind + } + + private enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case id + case name + case kind + } +} + +struct MigrationJournal: Codable, Equatable, Sendable { + let sourceVersion: SchemaVersion + let destinationVersion: SchemaVersion + let primaryWorkspaceID: WorkspaceID + let workspaceIDs: [String: WorkspaceID] + + private enum CodingKeys: String, CodingKey { + case sourceVersion = "source_version" + case destinationVersion = "destination_version" + case primaryWorkspaceID = "primary_workspace_id" + case workspaceIDs = "workspace_ids" + } +} + +struct RuntimeConfiguration: Codable, Equatable, Sendable { + var schemaVersion: SchemaVersion + var instances: [WorkspaceID: ApplicationInstanceRecord] + + init( + schemaVersion: SchemaVersion = .v2_0, + instances: [WorkspaceID: ApplicationInstanceRecord] = [:] + ) { + self.schemaVersion = schemaVersion + self.instances = instances + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.schemaVersion = try container.decode(SchemaVersion.self, forKey: .schemaVersion) + let values = try container.decode([String: ApplicationInstanceRecord].self, forKey: .instances) + self.instances = try Dictionary(uniqueKeysWithValues: values.map { key, value in + guard let id = WorkspaceID(key) else { + throw FXCodexError.invalidStorage("runtime instance key is not a lowercase UUID: \(key)") + } + return (id, value) + }) + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.schemaVersion, forKey: .schemaVersion) + try container.encode( + Dictionary(uniqueKeysWithValues: self.instances.map { ($0.key.rawValue, $0.value) }), + forKey: .instances + ) + } + + private enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case instances + } +} diff --git a/Sources/fxcodex-client/Storage/SupportStorage.swift b/Sources/fxcodex-client/Storage/SupportStorage.swift index e7c0ecb..cb9f52e 100644 --- a/Sources/fxcodex-client/Storage/SupportStorage.swift +++ b/Sources/fxcodex-client/Storage/SupportStorage.swift @@ -33,10 +33,11 @@ final class SupportStorage: @unchecked Sendable { func removeAll() throws { let rootURL: URL = self.paths.rootURL.standardizedFileURL + guard rootURL.pathComponents.count > 2 else { throw FXCodexError.supportDirectoryInvalid(rootURL) } - guard self.fileManager.fileExists(atPath: rootURL.path) - else { return } + + guard self.fileManager.fileExists(atPath: rootURL.path) else { return } try self.fileManager.removeItem(at: rootURL) } } diff --git a/Sources/fxcodex-client/Storage/UpdateCheckStorage.swift b/Sources/fxcodex-client/Storage/UpdateCheckStorage.swift index 97818e9..e8e184b 100644 --- a/Sources/fxcodex-client/Storage/UpdateCheckStorage.swift +++ b/Sources/fxcodex-client/Storage/UpdateCheckStorage.swift @@ -63,8 +63,7 @@ final class UpdateCheckStorage: @unchecked Sendable { if self.fileManager.fileExists(atPath: self.paths.updateStateURL.path) { let data: Data = try .init(contentsOf: self.paths.updateStateURL) let state: State = try self.decoder.decode(State.self, from: data) - guard date.timeIntervalSince(state.lastAutomaticCheck) >= minimumInterval - else { return false } + guard date.timeIntervalSince(state.lastAutomaticCheck) >= minimumInterval else { return false } } try self.fileManager.createDirectory( diff --git a/Sources/fxcodex-client/Storage/WorkspacesStorage.swift b/Sources/fxcodex-client/Storage/WorkspacesStorage.swift index 59cec6b..974f1f5 100644 --- a/Sources/fxcodex-client/Storage/WorkspacesStorage.swift +++ b/Sources/fxcodex-client/Storage/WorkspacesStorage.swift @@ -1,13 +1,13 @@ -import Foundation -import FXCodexFS import Dependencies import DependenciesMacros +import Foundation @DependencyClient public struct WorkspacesStorageClient: Sendable { public var prepare: @Sendable () throws -> Void public var list: @Sendable () throws -> [Workspace] public var findWorkspace: @Sendable (_ named: String?) throws -> Workspace + public var findWorkspaceByID: @Sendable (_ id: WorkspaceID) throws -> Workspace public var currentWorkspace: @Sendable () throws -> Workspace public var create: @Sendable (String) throws -> Workspace public var save: @Sendable (Workspace) throws -> Workspace @@ -25,185 +25,199 @@ extension DependencyValues { prepare: storage.prepare, list: storage.workspaces, findWorkspace: storage.workspace(named:), - currentWorkspace: { try storage.workspace(named: storage.currentWorkspaceName()) }, + findWorkspaceByID: storage.workspace(id:), + currentWorkspace: storage.currentWorkspace, create: storage.createWorkspace, save: storage.saveWorkspace, - delete: { try storage.deleteWorkspace(named: $0.name) }, - erase: { try storage.eraseWorkspace(named: $0.name) }, - rename: { try storage.renameWorkspace(from: $0.name, to: $1) }, - setCurrent: { try storage.useWorkspace(named: $0.name) } + delete: { try storage.deleteWorkspace(id: $0.id) }, + erase: { try storage.eraseWorkspace(id: $0.id) }, + rename: { try storage.renameWorkspace(id: $0.id, to: $1) }, + setCurrent: { try storage.useWorkspace(id: $0.id) } ) } } @_spi(Internals) public var _fxcodexWorkspaces: WorkspacesStorageClient { + get { self[WorkspacesStorageClientKey.self] } set { self[WorkspacesStorageClientKey.self] = newValue } } } public final class WorkspacesStorage: @unchecked Sendable { - private let decoder: JSONDecoder + private let decoder = JSONDecoder() private let encoder: JSONEncoder private let fileManager: FileManager - - @Dependency(\._fxcodexPaths) - private var paths: FXCodexPaths + private let lock: StorageLock + private let paths: FXCodexPaths public init(fileManager: FileManager) { - let encoder: JSONEncoder = FXCodexJSONCoding.encoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + @Dependency(\._fxcodexPaths) + var paths - self.decoder = .init() + let encoder = FXCodexJSONCoding.encoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] self.encoder = encoder self.fileManager = fileManager + self.paths = paths + self.lock = StorageLock(fileManager: fileManager, paths: paths) } public func prepare() throws { - try Folder( - path: self.paths.rootURL.path, - create: true - ) - .createSubfolderIfNeeded(withName: self.paths.workspacesURL.lastPathComponent) - - if !self.fileManager.fileExists(atPath: self.paths.configurationURL.path) { - try self.save(configuration: .init( - currentWorkspaceName: Workspace.primaryName, - workspaceIntegrations: [:] - )) - } + try Migrator(fileManager: self.fileManager, paths: self.paths).migrateIfNeeded() } public func workspaces() throws -> [Workspace] { try self.prepare() - - let names: [String] = try self.fileManager.contentsOfDirectory( - at: self.paths.workspacesURL, - includingPropertiesForKeys: [.isDirectoryKey], - options: [.skipsHiddenFiles] - ) - .filter { url in - (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true - } - .map(\.lastPathComponent) - .sorted() - - let configuration: Configuration = try self.loadConfiguration() - let primaryWorkspace: Workspace = self.primaryWorkspace( - integrations: configuration.workspaceIntegrations[Workspace.primaryName] ?? [:] - ) - let managedWorkspaces: [Workspace] = names.map { name in - self.managedWorkspace( - named: name, - integrations: configuration.workspaceIntegrations[name] ?? [:] - ) - } - return [primaryWorkspace] + managedWorkspaces + let configuration = try self.loadConfiguration() + return try self.workspaceConfigurations() + .map { self.workspace(from: $0, configuration: configuration) } + .sorted { + if $0.kind != $1.kind { return $0.kind == .primary } + return $0.name < $1.name + } } public func workspace(named name: String?) throws -> Workspace { try self.prepare() - let configuration: Configuration = try self.loadConfiguration() - let resolvedName: String = name ?? self.currentWorkspaceName( - configuration: configuration - ) - let integrations: [String: CodableValue] = configuration.workspaceIntegrations[resolvedName] ?? [:] - - if resolvedName == Workspace.primaryName { - return self.primaryWorkspace(integrations: integrations) + let configuration = try self.loadConfiguration() + if name == nil { + return try self.workspace(id: configuration.currentWorkspaceID, configuration: configuration) } - let workspace: Workspace = self.managedWorkspace( - named: resolvedName, - integrations: integrations - ) - guard let rootURL = workspace.rootURL else { - throw FXCodexError.workspaceNotFound(resolvedName) + guard let metadata = try self.workspaceConfigurations().first(where: { $0.name == name }) else { + throw FXCodexError.workspaceNotFound(name ?? "") } - guard self.fileManager.fileExists(atPath: rootURL.path) - else { throw FXCodexError.workspaceNotFound(resolvedName) } - return workspace + return self.workspace(from: metadata, configuration: configuration) } - public func currentWorkspaceName() throws -> String { + public func workspace(id: WorkspaceID) throws -> Workspace { try self.prepare() - let configuration: Configuration = try self.loadConfiguration() - return self.currentWorkspaceName(configuration: configuration) + return try self.workspace(id: id, configuration: self.loadConfiguration()) + } + + public func currentWorkspace() throws -> Workspace { + try self.workspace(named: nil) + } + + public func currentWorkspaceName() throws -> String { + try self.currentWorkspace().name + } + + public func currentWorkspaceID() throws -> WorkspaceID { + try self.currentWorkspace().id } @discardableResult public func createWorkspace(named name: String) throws -> Workspace { try self.validateWorkspaceName(name) - guard name != Workspace.primaryName - else { throw FXCodexError.primaryWorkspaceMutation } - + guard name != Workspace.primaryName else { throw FXCodexError.primaryWorkspaceMutation } try self.prepare() - let workspace: Workspace = self.managedWorkspace( - named: name, - integrations: [:] - ) - guard let rootURL = workspace.rootURL else { - throw FXCodexError.workspaceNotFound(name) + guard try self.workspaceConfigurations().allSatisfy({ $0.name != name }) else { + throw FXCodexError.workspaceAlreadyExists(name) } - guard !self.fileManager.fileExists(atPath: rootURL.path) - else { throw FXCodexError.workspaceAlreadyExists(name) } - let rootFolder: Folder = try .init( - path: rootURL.path, - create: true - ) - try rootFolder.createSubfolder(named: "codex-home") - try rootFolder.createSubfolder(named: "user-data") - try self.fileManager.setAttributes( - [.posixPermissions: 0o700], - ofItemAtPath: rootURL.path + let metadata = WorkspaceConfiguration(id: .generate(), name: name, kind: .managed) + let rootURL = self.workspaceRootURL(for: metadata.id) + try self.fileManager.createDirectory( + at: rootURL, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] ) - - return workspace + do { + try self.fileManager.createDirectory( + at: rootURL.appending(path: "codex-home", directoryHint: .isDirectory), + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + try self.fileManager.createDirectory( + at: rootURL.appending(path: "user-data", directoryHint: .isDirectory), + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + try self.save(metadata: metadata) + } catch { + try? self.fileManager.removeItem(at: rootURL) + throw error + } + return self.workspace(from: metadata, configuration: try self.loadConfiguration()) } @discardableResult public func saveWorkspace(_ workspace: Workspace) throws -> Workspace { - _ = try self.workspace(named: workspace.name) - var configuration: Configuration = try self.loadConfiguration() + let existing = try self.workspace(id: workspace.id) + guard existing.name == workspace.name, existing.kind == workspace.kind else { + throw FXCodexError.invalidStorage("workspace identity and metadata cannot be changed through save") + } - if workspace.integrations.isEmpty { - configuration.workspaceIntegrations.removeValue(forKey: workspace.name) - } else { - configuration.workspaceIntegrations[workspace.name] = workspace.integrations + try self.lock.withLock { + var configuration = try self.loadConfiguration() + let integrationIDs = Set(configuration.integrations.keys).union(workspace.integrations.keys) + + for integrationID in integrationIDs { + var integration = Self.dictionary(configuration.integrations[integrationID]) ?? [:] + var workspaces = Self.dictionary(integration["workspaces"]) ?? [:] + + if let attributes = workspace.integrations[integrationID] { + workspaces[workspace.id.rawValue] = attributes + } else { + workspaces.removeValue(forKey: workspace.id.rawValue) + } + + if workspaces.isEmpty { + integration.removeValue(forKey: "workspaces") + } else { + integration["workspaces"] = .dictionary(workspaces) + } + + if integration.isEmpty { + configuration.integrations.removeValue(forKey: integrationID) + } else { + configuration.integrations[integrationID] = .dictionary(integration) + } + } + try self.save(configuration: configuration) } - try self.save(configuration: configuration) - return workspace + return try self.workspace(id: workspace.id) } public func deleteWorkspace(named name: String?) throws { - let workspace: Workspace = try self.workspace(named: name) - guard workspace.kind == .managed - else { throw FXCodexError.primaryWorkspaceMutation } - let wasCurrent: Bool = try self.currentWorkspaceName() == workspace.name - guard let rootURL = workspace.rootURL else { - throw FXCodexError.workspaceNotFound(workspace.name) - } + try self.deleteWorkspace(id: self.workspace(named: name).id) + } + + public func deleteWorkspace(id: WorkspaceID) throws { + let workspace = try self.workspace(id: id) + guard workspace.kind == .managed else { throw FXCodexError.primaryWorkspaceMutation } + + guard let rootURL = workspace.rootURL + else { throw FXCodexError.workspaceNotFound(workspace.name) } try self.validateManagedWorkspaceURL(rootURL) - try Folder(path: rootURL.path).delete() + try self.fileManager.removeItem(at: rootURL) - var configuration: Configuration = try self.loadConfiguration() - configuration.workspaceIntegrations.removeValue(forKey: workspace.name) - if wasCurrent { - configuration.currentWorkspaceName = Workspace.primaryName + try self.lock.withLock { + var configuration = try self.loadConfiguration() + configuration.removeWorkspaceAttributes(id: id) + if configuration.currentWorkspaceID == id { + configuration.currentWorkspaceID = try self.primaryWorkspaceConfiguration().id + } + try self.save(configuration: configuration) } - try self.save(configuration: configuration) } @discardableResult public func eraseWorkspace(named name: String?) throws -> Workspace { - let workspace: Workspace = try self.workspace(named: name) - guard workspace.kind == .managed - else { throw FXCodexError.primaryWorkspaceMutation } + try self.eraseWorkspace(id: self.workspace(named: name).id) + } + + @discardableResult + public func eraseWorkspace(id: WorkspaceID) throws -> Workspace { + let workspace = try self.workspace(id: id) + guard workspace.kind == .managed else { throw FXCodexError.primaryWorkspaceMutation } + guard let rootURL = workspace.rootURL, let codexHomeURL = workspace.codexHomeURL, @@ -212,231 +226,212 @@ public final class WorkspacesStorage: @unchecked Sendable { try self.validateManagedWorkspaceURL(rootURL) for directoryURL in [codexHomeURL, userDataURL] { - try self.eraseDirectory( - at: directoryURL, - inside: rootURL - ) + try self.eraseDirectory(at: directoryURL, inside: rootURL) } - - var erasedWorkspace: Workspace = workspace - erasedWorkspace.integrations = [:] - return try self.saveWorkspace(erasedWorkspace) + return try self.workspace(id: id) } @discardableResult - public func renameWorkspace( - from oldName: String, - to newName: String - ) throws -> Workspace { - let workspace: Workspace = try self.workspace(named: oldName) - guard workspace.kind == .managed - else { throw FXCodexError.primaryWorkspaceMutation } + public func renameWorkspace(from oldName: String, to newName: String) throws -> Workspace { + try self.renameWorkspace(id: self.workspace(named: oldName).id, to: newName) + } + @discardableResult + public func renameWorkspace(id: WorkspaceID, to newName: String) throws -> Workspace { + let workspace = try self.workspace(id: id) + guard workspace.kind == .managed else { throw FXCodexError.primaryWorkspaceMutation } try self.validateWorkspaceName(newName) - guard newName != Workspace.primaryName - else { throw FXCodexError.primaryWorkspaceMutation } - - let destinationURL: URL = self.workspaceRootURL(forName: newName) - guard !self.fileManager.fileExists(atPath: destinationURL.path) - else { throw FXCodexError.workspaceAlreadyExists(newName) } - guard let rootURL = workspace.rootURL else { - throw FXCodexError.workspaceNotFound(workspace.name) - } - let wasCurrent: Bool = try self.currentWorkspaceName() == workspace.name + guard newName != Workspace.primaryName else { throw FXCodexError.primaryWorkspaceMutation } - try self.validateManagedWorkspaceURL(rootURL) - try self.fileManager.moveItem( - at: rootURL, - to: destinationURL - ) - - var configuration: Configuration = try self.loadConfiguration() - let integrations: [String: CodableValue] = configuration.workspaceIntegrations - .removeValue(forKey: workspace.name) - ?? workspace.integrations - if integrations.isEmpty { - configuration.workspaceIntegrations.removeValue(forKey: newName) - } else { - configuration.workspaceIntegrations[newName] = integrations - } - if wasCurrent { - configuration.currentWorkspaceName = newName + guard try self.workspaceConfigurations().allSatisfy({ $0.id == id || $0.name != newName }) else { + throw FXCodexError.workspaceAlreadyExists(newName) } - try self.save(configuration: configuration) - return self.managedWorkspace( - named: newName, - integrations: integrations - ) + var metadata = try self.loadWorkspaceConfiguration(id: id) + metadata.name = newName + try self.save(metadata: metadata) + return try self.workspace(id: id) } public func useWorkspace(named name: String) throws { - let workspace: Workspace = try self.workspace(named: name) - var configuration: Configuration = try self.loadConfiguration() - configuration.currentWorkspaceName = workspace.name - try self.save(configuration: configuration) + try self.useWorkspace(id: self.workspace(named: name).id) } -} - -extension WorkspacesStorage { - private struct Configuration: Codable { - var currentWorkspaceName: String - var workspaceIntegrations: [String: [String: CodableValue]] - - init( - currentWorkspaceName: String, - workspaceIntegrations: [String: [String: CodableValue]] - ) { - self.currentWorkspaceName = currentWorkspaceName - self.workspaceIntegrations = workspaceIntegrations + public func useWorkspace(id: WorkspaceID) throws { + _ = try self.workspace(id: id) + try self.lock.withLock { + var configuration = try self.loadConfiguration() + configuration.currentWorkspaceID = id + try self.save(configuration: configuration) } + } +} - init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.currentWorkspaceName = try container.decode( - String.self, - forKey: .currentWorkspaceName - ) - self.workspaceIntegrations = try container.decodeIfPresent( - [String: [String: CodableValue]].self, - forKey: .workspaceIntegrations - ) - ?? [:] - } +private extension WorkspacesStorage { + func loadConfiguration() throws -> StorageConfiguration { + try self.decoder.decode(StorageConfiguration.self, from: Data(contentsOf: self.paths.configurationURL)) + } - private enum CodingKeys: String, CodingKey { - case currentWorkspaceName = "current_workspace_name" - case workspaceIntegrations = "workspace_integrations" - } + func save(configuration: StorageConfiguration) throws { + try self.encoder.encode(configuration).write(to: self.paths.configurationURL, options: [.atomic]) } - private func loadConfiguration() throws -> Configuration { - let data: Data = try .init(contentsOf: self.paths.configurationURL) - return try self.decoder.decode( - Configuration.self, - from: data + func loadWorkspaceConfiguration(id: WorkspaceID) throws -> WorkspaceConfiguration { + try self.decoder.decode( + WorkspaceConfiguration.self, + from: Data(contentsOf: self.workspaceMetadataURL(for: id)) ) } - private func save(configuration: Configuration) throws { - let data: Data = try self.encoder.encode(configuration) - try data.write( - to: self.paths.configurationURL, - options: [.atomic] - ) + func save(metadata: WorkspaceConfiguration) throws { + try self.encoder.encode(metadata).write(to: self.workspaceMetadataURL(for: metadata.id), options: [.atomic]) } - private func primaryWorkspace( - integrations: [String: CodableValue] - ) -> Workspace { - .init( - name: Workspace.primaryName, - kind: .primary, - rootURL: nil, - codexHomeURL: nil, - userDataURL: nil, - integrations: integrations + func workspaceConfigurations() throws -> [WorkspaceConfiguration] { + try self.fileManager.contentsOfDirectory( + at: self.paths.workspacesURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] ) + .filter { directory in + (try? directory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true + && WorkspaceID(directory.lastPathComponent) != nil + } + .map { directory in + guard let id = WorkspaceID(directory.lastPathComponent) else { preconditionFailure() } + let metadata = try self.loadWorkspaceConfiguration(id: id) + guard metadata.schemaVersion == .v2_0, metadata.id == id else { + throw FXCodexError.invalidStorage("workspace directory and metadata do not match") + } + return metadata + } } - private func managedWorkspace( - named name: String, - integrations: [String: CodableValue] - ) -> Workspace { - let rootURL: URL = self.workspaceRootURL(forName: name) + func primaryWorkspaceConfiguration() throws -> WorkspaceConfiguration { + guard let primary = try self.workspaceConfigurations().first(where: { $0.kind == .primary }) + else { + throw FXCodexError.invalidStorage("primary workspace metadata is missing") + } + + return primary + } + + func workspace(id: WorkspaceID, configuration: StorageConfiguration) throws -> Workspace { + guard let metadata = try self.workspaceConfigurations().first(where: { $0.id == id }) else { + throw FXCodexError.workspaceNotFound(id.rawValue) + } + + return self.workspace(from: metadata, configuration: configuration) + } + + func workspace(from metadata: WorkspaceConfiguration, configuration: StorageConfiguration) -> Workspace { + let rootURL = self.workspaceRootURL(for: metadata.id) + let integrations = configuration.workspaceAttributes(id: metadata.id) + if metadata.kind == .primary { + return .init( + id: metadata.id, + name: metadata.name, + kind: metadata.kind, + rootURL: nil, + codexHomeURL: nil, + userDataURL: nil, + integrations: integrations + ) + } return .init( - name: name, - kind: .managed, + id: metadata.id, + name: metadata.name, + kind: metadata.kind, rootURL: rootURL, - codexHomeURL: rootURL.appending( - path: "codex-home", - directoryHint: .isDirectory - ), - userDataURL: rootURL.appending( - path: "user-data", - directoryHint: .isDirectory - ), + codexHomeURL: rootURL.appending(path: "codex-home", directoryHint: .isDirectory), + userDataURL: rootURL.appending(path: "user-data", directoryHint: .isDirectory), integrations: integrations ) } - private func currentWorkspaceName( - configuration: Configuration - ) -> String { - if configuration.currentWorkspaceName == Workspace.primaryName { - return Workspace.primaryName - } - - let workspaceURL: URL = self.workspaceRootURL( - forName: configuration.currentWorkspaceName - ) - return self.fileManager.fileExists(atPath: workspaceURL.path) - ? configuration.currentWorkspaceName - : Workspace.primaryName + func workspaceRootURL(for id: WorkspaceID) -> URL { + self.paths.workspacesURL.appending(path: id.rawValue, directoryHint: .isDirectory).standardizedFileURL } - private func workspaceRootURL(forName name: String) -> URL { - self.paths.workspacesURL.appending( - path: name, - directoryHint: .isDirectory - ).standardizedFileURL + func workspaceMetadataURL(for id: WorkspaceID) -> URL { + self.workspaceRootURL(for: id).appending(path: "workspace.json", directoryHint: .notDirectory) } - private func validateWorkspaceName(_ name: String) throws { + func validateWorkspaceName(_ name: String) throws { let pattern: Regex = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/ - guard name.wholeMatch(of: pattern) != nil - else { throw FXCodexError.invalidWorkspaceName(name) } - } - - private func validateManagedWorkspaceURL(_ url: URL) throws { - let parentURL: URL = url.deletingLastPathComponent().standardizedFileURL - guard parentURL == self.paths.workspacesURL.standardizedFileURL - else { throw CocoaError(.fileWriteNoPermission) } - } - - private func eraseDirectory( - at directoryURL: URL, - inside rootURL: URL - ) throws { - let standardizedDirectoryURL: URL = directoryURL.standardizedFileURL - guard standardizedDirectoryURL.deletingLastPathComponent() == rootURL.standardizedFileURL - else { throw CocoaError(.fileWriteNoPermission) } - - if self.fileManager.fileExists(atPath: standardizedDirectoryURL.path) { - let values: URLResourceValues = try standardizedDirectoryURL.resourceValues( - forKeys: [ - .isDirectoryKey, - .isSymbolicLinkKey, - ] - ) + guard name.wholeMatch(of: pattern) != nil else { throw FXCodexError.invalidWorkspaceName(name) } + } + + func validateManagedWorkspaceURL(_ url: URL) throws { + let parentURL = url.deletingLastPathComponent().standardizedFileURL + guard parentURL == self.paths.workspacesURL.standardizedFileURL else { + throw CocoaError(.fileWriteNoPermission) + } + } + + func eraseDirectory(at directoryURL: URL, inside rootURL: URL) throws { + let directoryURL = directoryURL.standardizedFileURL + guard directoryURL.deletingLastPathComponent() == rootURL.standardizedFileURL else { + throw CocoaError(.fileWriteNoPermission) + } + + if self.fileManager.fileExists(atPath: directoryURL.path) { + let values = try directoryURL.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) if values.isDirectory != true || values.isSymbolicLink == true { - try self.fileManager.removeItem(at: standardizedDirectoryURL) - try self.fileManager.createDirectory( - at: standardizedDirectoryURL, - withIntermediateDirectories: false, - attributes: nil - ) + try self.fileManager.removeItem(at: directoryURL) + try self.fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: false) + } else { - for itemURL in try self.fileManager.contentsOfDirectory( - at: standardizedDirectoryURL, - includingPropertiesForKeys: nil, - options: [] - ) { + for itemURL in try self.fileManager.contentsOfDirectory(at: directoryURL, includingPropertiesForKeys: nil) { try self.fileManager.removeItem(at: itemURL) } } + } else { - try self.fileManager.createDirectory( - at: standardizedDirectoryURL, - withIntermediateDirectories: false, - attributes: nil - ) + try self.fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: false) } - try self.fileManager.setAttributes( - [.posixPermissions: 0o700], - ofItemAtPath: standardizedDirectoryURL.path - ) + try self.fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directoryURL.path) + } + + static func dictionary(_ value: CodableValue?) -> [String: CodableValue]? { + guard case let .dictionary(dictionary) = value else { return nil } + return dictionary + } +} + +private extension StorageConfiguration { + func workspaceAttributes(id: WorkspaceID) -> [String: CodableValue] { + self.integrations.reduce(into: [:]) { result, item in + + guard + case let .dictionary(integration) = item.value, + case let .dictionary(workspaces) = integration["workspaces"], + let attributes = workspaces[id.rawValue] + else { return } + + result[item.key] = attributes + } + } + + mutating func removeWorkspaceAttributes(id: WorkspaceID) { + for integrationID in self.integrations.keys.sorted() { + guard case var .dictionary(integration) = self.integrations[integrationID] else { continue } + if case var .dictionary(workspaces) = integration["workspaces"] { + workspaces.removeValue(forKey: id.rawValue) + + if workspaces.isEmpty { + integration.removeValue(forKey: "workspaces") + } else { + integration["workspaces"] = .dictionary(workspaces) + } + } + + if integration.isEmpty { + self.integrations.removeValue(forKey: integrationID) + } else { + self.integrations[integrationID] = .dictionary(integration) + } + } } } diff --git a/Sources/fxcodex-client/Update/GitHubReleaseUpdater.swift b/Sources/fxcodex-client/Update/GitHubReleaseUpdater.swift index 74fbb24..d8f2170 100644 --- a/Sources/fxcodex-client/Update/GitHubReleaseUpdater.swift +++ b/Sources/fxcodex-client/Update/GitHubReleaseUpdater.swift @@ -59,12 +59,16 @@ final class GitHubReleaseUpdater: @unchecked Sendable { executableURL: URL ) async throws -> UpdateResult { let releases: [Release] = try await self.releases() - guard let release = Self.selectRelease( - from: releases, - currentVersion: currentVersion, - channel: channel, - minimumVersion: minimumVersion - ), let version = release.version else { + + guard + let release = Self.selectRelease( + from: releases, + currentVersion: currentVersion, + channel: channel, + minimumVersion: minimumVersion + ), + let version = release.version + else { return .init( outcome: .alreadyCurrent, previousVersion: currentVersion, @@ -76,9 +80,12 @@ final class GitHubReleaseUpdater: @unchecked Sendable { currentVersion: currentVersion, executableURL: executableURL ) + guard let artifact = release.assets.first(where: { $0.name == artifactName }) else { throw FXCodexError.updateAssetMissing(artifactName) } + let checksumName: String = "\(artifactName).sha256" + guard let checksum = release.assets.first(where: { $0.name == checksumName }) else { throw FXCodexError.updateAssetMissing(checksumName) } @@ -137,8 +144,7 @@ final class GitHubReleaseUpdater: @unchecked Sendable { } } .max { lhs, rhs in - guard let lhsVersion = lhs.version, let rhsVersion = rhs.version - else { return false } + guard let lhsVersion = lhs.version, let rhsVersion = rhs.version else { return false } return lhsVersion < rhsVersion } } @@ -175,6 +181,7 @@ extension GitHubReleaseUpdater { private func data(for request: URLRequest) async throws -> Data { let (data, response) = try await self.session.data(for: request) + guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode) @@ -183,6 +190,7 @@ extension GitHubReleaseUpdater { (response as? HTTPURLResponse)?.statusCode ?? 0 ) } + return data } @@ -192,6 +200,7 @@ extension GitHubReleaseUpdater { ) throws { let executableURL: URL = executableURL.standardizedFileURL.resolvingSymlinksInPath() let values = try executableURL.resourceValues(forKeys: [.isRegularFileKey]) + guard values.isRegularFile == true else { throw FXCodexError.updateExecutableInvalid(executableURL) } @@ -220,13 +229,17 @@ extension GitHubReleaseUpdater { ) async throws -> String { let release: Release = try await self.release(tag: currentVersion.description) let checksumName: String = "\(Self.universalArtifactName).sha256" + guard let checksum = release.assets.first(where: { $0.name == checksumName }) else { throw FXCodexError.updateAssetMissing(checksumName) } + let checksumData: Data = try await self.download(checksum.browserDownloadURL) let executableURL: URL = executableURL.standardizedFileURL.resolvingSymlinksInPath() let values = try executableURL.resourceValues(forKeys: [.isRegularFileKey]) + guard values.isRegularFile == true else { throw FXCodexError.updateExecutableInvalid(executableURL) } + let executableData: Data = try .init(contentsOf: executableURL) if try Self.checksumMatches(checksumData, artifact: executableData) { return Self.universalArtifactName @@ -259,10 +272,13 @@ extension GitHubReleaseUpdater { _ checksumData: Data, artifact: Data ) throws -> Bool { - guard let checksumContents = String(data: checksumData, encoding: .utf8), + guard + let checksumContents = String(data: checksumData, encoding: .utf8), let expectedChecksum = checksumContents.split(whereSeparator: \.isWhitespace).first else { throw FXCodexError.updateChecksumInvalid } + let normalizedChecksum: String = expectedChecksum.lowercased() + guard normalizedChecksum.count == 64, normalizedChecksum.allSatisfy({ $0.isHexDigit }) diff --git a/Sources/fxcodex-fs/Files.swift b/Sources/fxcodex-fs/Files.swift index b8a5cbd..1070d32 100644 --- a/Sources/fxcodex-fs/Files.swift +++ b/Sources/fxcodex-fs/Files.swift @@ -257,6 +257,7 @@ public final class Storage { if !fileManager.createFile(atPath: path, contents: nil) { throw WriteError(path: path, reason: .fileCreationFailed) } + case .folder: do { try fileManager.createDirectory(atPath: path, withIntermediateDirectories: true) @@ -273,6 +274,7 @@ public final class Storage { guard !path.isEmpty else { throw LocationError(path: path, reason: .emptyFilePath) } + case .folder: if path.isEmpty { path = fileManager.currentDirectoryPath } if !path.hasSuffix("/") { path += "/" } @@ -415,8 +417,10 @@ extension Storage where LocationType == Folder { } } - guard fileManager.createFile(atPath: filePath, contents: contents), - let storage = try? Storage(path: filePath, fileManager: fileManager) else { + guard + fileManager.createFile(atPath: filePath, contents: contents), + let storage = try? Storage(path: filePath, fileManager: fileManager) + else { throw WriteError(path: filePath, reason: .fileCreationFailed) } diff --git a/Tests/fxcodex-cli-tests/AppCommandTests.swift b/Tests/fxcodex-cli-tests/AppCommandTests.swift index 85bad54..c88f67e 100644 --- a/Tests/fxcodex-cli-tests/AppCommandTests.swift +++ b/Tests/fxcodex-cli-tests/AppCommandTests.swift @@ -1,6 +1,7 @@ import ArgumentParser import Testing -@testable import FXCodexCLI +@testable +import FXCodexCLI @Suite("App command") struct AppCommandTests { diff --git a/Tests/fxcodex-cli-tests/CLICommandTests.swift b/Tests/fxcodex-cli-tests/CLICommandTests.swift index 62eeb44..b128e25 100644 --- a/Tests/fxcodex-cli-tests/CLICommandTests.swift +++ b/Tests/fxcodex-cli-tests/CLICommandTests.swift @@ -1,6 +1,7 @@ import ArgumentParser import Testing -@testable import FXCodexCLI +@testable +import FXCodexCLI @Suite("CLI command") struct CLICommandTests { diff --git a/Tests/fxcodex-cli-tests/ExecCommandTests.swift b/Tests/fxcodex-cli-tests/ExecCommandTests.swift index fad8809..e230f91 100644 --- a/Tests/fxcodex-cli-tests/ExecCommandTests.swift +++ b/Tests/fxcodex-cli-tests/ExecCommandTests.swift @@ -1,6 +1,7 @@ import ArgumentParser import Testing -@testable import FXCodexCLI +@testable +import FXCodexCLI @Suite("Exec command") struct ExecCommandTests { diff --git a/Tests/fxcodex-cli-tests/IntegrationAttributesCommandTests.swift b/Tests/fxcodex-cli-tests/IntegrationAttributesCommandTests.swift new file mode 100644 index 0000000..3fbf591 --- /dev/null +++ b/Tests/fxcodex-cli-tests/IntegrationAttributesCommandTests.swift @@ -0,0 +1,206 @@ +import ArgumentParser +import Dependencies +import FXCodexClient +import Testing +@testable +import FXCodexCLI + +@Suite("Integration attributes command") +struct IntegrationAttributesCommandTests { + @Test("Get offers available integration identifiers and manual entry") + func getIntegrationSelection() async throws { + let options = LockIsolated<[TerminalPromptOption]>([]) + let requested = LockIsolated("") + var attributes = IntegrationAttributes() + attributes.list = { ["custom", "raycast"] } + attributes.get = { integration, _ in + requested.setValue(integration) + return .dictionary(["enabled": .bool(true)]) + } + + try await withDependencies { + $0.fxCodexClient = .init() + $0._fxcodexIntegrations = .init(raycast: .init(), attributes: attributes) + $0._fxcodexTerminalPrompts = .init( + select: { _, _ in nil }, + multiselect: { _, _ in nil }, + confirm: { _ in nil } + ) + $0._fxcodexTerminalPrompts = .init( + select: { _, values in + options.setValue(values) + return "raycast" + }, + multiselect: { _, _ in nil }, + confirm: { _ in nil } + ) + } operation: { + let command: AppCommand.IntegrationsCommand.Attributes.Get = try .parse([]) + try await command.run() + } + + #expect(options.value.map(\.label) == ["custom", "raycast", "Enter manually…"]) + #expect(requested.value == "raycast") + } + + @Test("Set prompts for omitted identifier and value") + func setMissingValues() async throws { + let storedIntegration = LockIsolated("") + let storedValue = LockIsolated(nil) + var attributes = IntegrationAttributes() + attributes.list = { [] } + attributes.set = { integration, _, value in + storedIntegration.setValue(integration) + storedValue.setValue(value) + } + + let answers = LockIsolated(["custom", "{\"enabled\":true}"]) + try await withDependencies { + $0.fxCodexClient = .init() + $0._fxcodexIntegrations = .init(raycast: .init(), attributes: attributes) + $0._fxcodexTerminalPrompts = .init( + select: { _, _ in "__fxcodex_manual_integration__" }, + multiselect: { _, _ in nil }, + confirm: { _ in nil }, + text: { _, _ in answers.withValue { $0.removeFirst() } } + ) + } operation: { + let command: AppCommand.IntegrationsCommand.Attributes.Set = try .parse([]) + try await command.run() + } + + #expect(storedIntegration.value == "custom") + #expect(storedValue.value == .dictionary(["enabled": .bool(true)])) + } + + @Test("Get offers registered integrations when storage is empty") + func registeredIntegrationSelection() async throws { + let options = LockIsolated<[TerminalPromptOption]>([]) + var attributes = IntegrationAttributes() + attributes.list = { [] } + attributes.get = { _, _ in .dictionary([:]) } + + try await withDependencies { + $0.fxCodexClient = .init() + $0._fxcodexIntegrations = .init(raycast: .init(), attributes: attributes) + $0._fxcodexTerminalPrompts = .init( + select: { _, values in + options.setValue(values) + return "raycast" + }, + multiselect: { _, _ in nil }, + confirm: { _ in nil } + ) + } operation: { + let command: AppCommand.IntegrationsCommand.Attributes.Get = try .parse([]) + try await command.run() + } + + #expect(options.value.map(\.label) == ["raycast", "Enter manually…"]) + } + + @Test("JSON get rejects a missing identifier without prompting") + func jsonRequiresIdentifier() async throws { + let command: AppCommand.IntegrationsCommand.Attributes.Get = try .parse(["--json"]) + await #expect(throws: ValidationError.self) { + try await withDependencies { + $0.fxCodexClient = .init() + $0._fxcodexTerminalPrompts = .init( + select: { _, _ in nil }, + multiselect: { _, _ in nil }, + confirm: { _ in nil } + ) + } operation: { + try await command.run() + } + } + } + + @Test("JSON get, set, and remove execute with explicit values") + func jsonLifecycle() async throws { + let requestedPaths = LockIsolated<[String]>([]) + let storedValue = LockIsolated(nil) + let removed = LockIsolated(false) + var attributes = IntegrationAttributes() + attributes.get = { integration, path in + #expect(integration == "raycast") + requestedPaths.withValue { $0.append(path.rawValue) } + return storedValue.value ?? .dictionary([:]) + } + attributes.set = { integration, path, value in + #expect(integration == "raycast") + requestedPaths.withValue { $0.append(path.rawValue) } + storedValue.setValue(value) + } + attributes.remove = { integration, path in + #expect(integration == "raycast") + requestedPaths.withValue { $0.append(path.rawValue) } + removed.setValue(true) + } + + try await withDependencies { + $0.fxCodexClient = .init() + $0._fxcodexIntegrations = .init(raycast: .init(), attributes: attributes) + $0._fxcodexTerminalPrompts = .init( + select: { _, _ in nil }, + multiselect: { _, _ in nil }, + confirm: { _ in nil } + ) + } operation: { + let set: AppCommand.IntegrationsCommand.Attributes.Set = try .parse([ + "raycast", + "{\"enabled\":true}", + "--path", + "settings", + "--json", + ]) + try await set.run() + + let get: AppCommand.IntegrationsCommand.Attributes.Get = try .parse([ + "raycast", + "--path", + "settings", + "--json", + ]) + try await get.run() + + let remove: AppCommand.IntegrationsCommand.Attributes.Remove = try .parse([ + "raycast", + "--path", + "settings", + "--json", + ]) + try await remove.run() + } + + #expect(storedValue.value == .dictionary(["enabled": .bool(true)])) + #expect(requestedPaths.value == ["settings", "settings", "settings"]) + #expect(removed.value) + } + + @Test("Interactive remove executes an explicit request") + func explicitRemove() async throws { + let removed = LockIsolated(false) + var attributes = IntegrationAttributes() + attributes.remove = { _, _ in removed.setValue(true) } + + try await withDependencies { + $0.fxCodexClient = .init() + $0._fxcodexIntegrations = .init(raycast: .init(), attributes: attributes) + $0._fxcodexTerminalPrompts = .init( + select: { _, _ in nil }, + multiselect: { _, _ in nil }, + confirm: { _ in nil } + ) + } operation: { + let command: AppCommand.IntegrationsCommand.Attributes.Remove = try .parse([ + "raycast", + "--path", + "settings", + ]) + try await command.run() + } + + #expect(removed.value) + } +} diff --git a/Tests/fxcodex-cli-tests/InteractiveWorkspaceCommandTests.swift b/Tests/fxcodex-cli-tests/InteractiveWorkspaceCommandTests.swift index 4d084e2..caa5238 100644 --- a/Tests/fxcodex-cli-tests/InteractiveWorkspaceCommandTests.swift +++ b/Tests/fxcodex-cli-tests/InteractiveWorkspaceCommandTests.swift @@ -3,7 +3,8 @@ import Dependencies import Foundation import FXCodexClient import Testing -@testable import FXCodexCLI +@testable +import FXCodexCLI @Suite("Interactive workspace commands") struct InteractiveWorkspaceCommandTests { @@ -44,12 +45,12 @@ struct InteractiveWorkspaceCommandTests { let personal: Workspace = Self.workspace(named: "personal", kind: .managed) let work: Workspace = Self.workspace(named: "work", kind: .managed) let options: LockIsolated<[TerminalPromptOption]> = .init([]) - let openedWorkspaceName: LockIsolated = .init(nil) + let openedWorkspaceID: LockIsolated = .init(nil) var client: FXCodexClient = .init() client.workspaces = { [primary, personal, work] } client.currentWorkspace = { work } - client.openWorkspace = { - openedWorkspaceName.setValue($0) + client.openWorkspaceByID = { + openedWorkspaceID.setValue($0) return 42 } @@ -58,7 +59,7 @@ struct InteractiveWorkspaceCommandTests { $0._fxcodexTerminalPrompts = .init( select: { _, promptOptions in options.setValue(promptOptions) - return work.name + return work.id.rawValue }, multiselect: { _, _ in nil }, confirm: { _ in nil } @@ -68,9 +69,9 @@ struct InteractiveWorkspaceCommandTests { try await command.run() } - #expect(options.value.map(\.value) == [work.name, Workspace.primaryName, personal.name]) + #expect(options.value.map(\.value) == [work.id.rawValue, primary.id.rawValue, personal.id.rawValue]) #expect(options.value.first?.hint == "current") - #expect(openedWorkspaceName.value == work.name) + #expect(openedWorkspaceID.value == work.id) } @Test("Delete excludes primary and forwards every selected workspace") diff --git a/Tests/fxcodex-cli-tests/MachineResponseTests.swift b/Tests/fxcodex-cli-tests/MachineResponseTests.swift index f1e6d0c..405b4e4 100644 --- a/Tests/fxcodex-cli-tests/MachineResponseTests.swift +++ b/Tests/fxcodex-cli-tests/MachineResponseTests.swift @@ -1,8 +1,10 @@ import ArgumentParser +import Dependencies import Foundation import FXCodexClient import Testing -@testable import FXCodexCLI +@testable +import FXCodexCLI @Suite("Machine response") struct MachineResponseTests { @@ -93,6 +95,18 @@ struct MachineResponseTests { )) } + @Test("Commands read environment through the injected client") + func injectedEnvironment() { + withDependencies { + $0._fxcodexEnvironment = .init(values: { + ["FXCODEX_JSON": "1"] + }) + } operation: { + #expect(globalMachineOutputRequested(arguments: ["status"])) + #expect(!globalMachineOutputRequested(arguments: ["--no-json", "status"])) + } + } + @Test("Warnings use a structured machine response") func warningResponse() async throws { let data: Data = try encodedJSON(MachineWarningResponse( @@ -137,6 +151,9 @@ struct MachineResponseTests { (.codexExecutableNotFound, "codex_executable_not_found"), (.homebrewNotFound, "homebrew_not_found"), (.homebrewManagedUpdate, "homebrew_managed_update"), + (.integrationAttributeNotFound("raycast.icon"), "integration_attribute_not_found"), + (.invalidAttributePath("icons.(unknown)"), "invalid_attribute_path"), + (.invalidStorage("missing metadata"), "invalid_storage"), (.invalidWorkspaceName("Work"), "invalid_workspace_name"), (.primaryWorkspaceMutation, "primary_workspace_mutation"), (.raycastBetaUnsupportedPlatform, "raycast_beta_unsupported_platform"), @@ -148,6 +165,7 @@ struct MachineResponseTests { (.updateChecksumMismatch, "update_checksum_mismatch"), (.updateExecutableInvalid(URL(fileURLWithPath: "/tmp/fxcodex")), "update_executable_invalid"), (.updateRequestFailed(503), "update_request_failed"), + (.unsupportedSchemaVersion(.init(major: 3, minor: 0)), "unsupported_schema_version"), (.workspaceAlreadyExists("work"), "workspace_already_exists"), (.workspaceIsRunning("work"), "workspace_is_running"), (.workspaceNotFound("work"), "workspace_not_found"), @@ -175,6 +193,23 @@ struct MachineResponseTests { "work", "--json", ]) + let openByID: AppCommand.OpenCommand = try .parse([ + "--workspace-id", + "00000000-0000-0000-0000-000000000001", + "--json", + ]) + let getAttribute: AppCommand.IntegrationsCommand.Attributes.Get = try .parse([ + "raycast", + "--path", + "workspaces.(keys)", + "--json", + ]) + let installRaycast: AppCommand.IntegrationsCommand.Raycast.Install = try .parse([ + "script-command", + "--directory", + "/tmp/raycast", + "--json", + ]) let raycast: AppCommand.IntegrationsCommand.Raycast.Status = try .parse(["--json"]) let version: VersionCommand = try .parse(["--json"]) @@ -183,6 +218,13 @@ struct MachineResponseTests { #expect(rename.json == true) #expect(use.json == true) #expect(open.json == true) + #expect(openByID.workspaceID == "00000000-0000-0000-0000-000000000001") + #expect(openByID.json == true) + #expect(getAttribute.integration == "raycast") + #expect(getAttribute.path == "workspaces.(keys)") + #expect(getAttribute.json == true) + #expect(installRaycast.component == .scriptCommand) + #expect(installRaycast.json == true) #expect(raycast.json == true) #expect(version.json == true) } diff --git a/Tests/fxcodex-cli-tests/MigrationAssistantTests.swift b/Tests/fxcodex-cli-tests/MigrationAssistantTests.swift new file mode 100644 index 0000000..40eb92b --- /dev/null +++ b/Tests/fxcodex-cli-tests/MigrationAssistantTests.swift @@ -0,0 +1,125 @@ +import ArgumentParser +import Dependencies +import FXCodexClient +import Testing +@testable +import FXCodexCLI + +@Suite("Migration assistant") +struct MigrationAssistantTests { + @Test("Interactive migration explains the plan and requires confirmation") + func interactiveMigration() async throws { + let prepared = LockIsolated(false) + let migrated = LockIsolated<[SchemaVersion]>([]) + let confirmation = LockIsolated("") + var client = FXCodexClient() + client.storageMigrationPlan = { Self.plan() } + client.migrateStorage = { migration in + migrated.withValue { $0.append(migration.destinationVersion) } + } + client.prepareStorage = { prepared.setValue(true) } + + try await withDependencies { + $0.fxCodexClient = client + $0._fxcodexTerminalPrompts = .init( + select: { _, _ in nil }, + multiselect: { _, _ in nil }, + confirm: { message in + confirmation.setValue(message) + return true + } + ) + } operation: { + try await MigrationAssistant.prepareStorage(client: client, interactive: true) + } + + #expect(prepared.value) + #expect(migrated.value == [.v2_0]) + #expect(confirmation.value.contains("1.0")) + #expect(confirmation.value.contains("2.0")) + } + + @Test("Declining migration prevents command preparation") + func declinedMigration() async throws { + let prepared = LockIsolated(false) + let migrated = LockIsolated<[SchemaVersion]>([]) + var client = FXCodexClient() + client.storageMigrationPlan = { Self.plan() } + client.migrateStorage = { migration in + migrated.withValue { $0.append(migration.destinationVersion) } + } + client.prepareStorage = { prepared.setValue(true) } + + await #expect(throws: CleanExit.self) { + try await withDependencies { + $0.fxCodexClient = client + $0._fxcodexTerminalPrompts = .init( + select: { _, _ in nil }, + multiselect: { _, _ in nil }, + confirm: { _ in false } + ) + } operation: { + try await MigrationAssistant.prepareStorage(client: client, interactive: true) + } + } + #expect(!prepared.value) + #expect(migrated.value.isEmpty) + } + + @Test("Non-interactive callers run migrations that need no answers") + func nonInteractiveMigration() async throws { + let prepared = LockIsolated(false) + let migrated = LockIsolated<[SchemaVersion]>([]) + var client = FXCodexClient() + client.storageMigrationPlan = { Self.plan() } + client.migrateStorage = { migration in + migrated.withValue { $0.append(migration.destinationVersion) } + } + client.prepareStorage = { prepared.setValue(true) } + + try await MigrationAssistant.prepareStorage(client: client, interactive: false) + #expect(prepared.value) + #expect(migrated.value == [.v2_0]) + } + + @Test("Non-interactive callers stop when a migration needs answers") + func requiredInteraction() async throws { + let prepared = LockIsolated(false) + var client = FXCodexClient() + client.storageMigrationPlan = { + .init( + sourceVersion: .v1_0, + destinationVersion: .v2_0, + migrations: [ + .init( + sourceVersion: .v1_0, + destinationVersion: .v2_0, + steps: ["Choose a value"], + requiresUserInput: true + ), + ] + ) + } + client.prepareStorage = { prepared.setValue(true) } + + await #expect(throws: ValidationError.self) { + try await MigrationAssistant.prepareStorage(client: client, interactive: false) + } + #expect(!prepared.value) + } + + private static func plan() -> StorageMigrationPlan { + .init( + sourceVersion: .v1_0, + destinationVersion: .v2_0, + migrations: [ + .init( + sourceVersion: .v1_0, + destinationVersion: .v2_0, + steps: ["Assign IDs", "Write configuration"], + requiresUserInput: false + ), + ] + ) + } +} diff --git a/Tests/fxcodex-cli-tests/OpenCommandTests.swift b/Tests/fxcodex-cli-tests/OpenCommandTests.swift index ef69008..cbb3b43 100644 --- a/Tests/fxcodex-cli-tests/OpenCommandTests.swift +++ b/Tests/fxcodex-cli-tests/OpenCommandTests.swift @@ -1,6 +1,7 @@ import ArgumentParser import Testing -@testable import FXCodexCLI +@testable +import FXCodexCLI @Suite("Open command") struct OpenCommandTests { @@ -10,8 +11,8 @@ struct OpenCommandTests { #expect(command.workspaceName == "work") } - @Test("Defaults to the current workspace") - func currentWorkspace() async throws { + @Test("Accepts an omitted workspace for interactive selection") + func omittedWorkspace() async throws { let command: AppCommand.OpenCommand = try .parse([]) #expect(command.workspaceName == nil) } diff --git a/Tests/fxcodex-cli-tests/PreferencesCommandTests.swift b/Tests/fxcodex-cli-tests/PreferencesCommandTests.swift index b883e9a..c3cdcad 100644 --- a/Tests/fxcodex-cli-tests/PreferencesCommandTests.swift +++ b/Tests/fxcodex-cli-tests/PreferencesCommandTests.swift @@ -2,7 +2,8 @@ import ArgumentParser import Dependencies import FXCodexClient import Testing -@testable import FXCodexCLI +@testable +import FXCodexCLI @Suite("Preferences command") struct PreferencesCommandTests { @@ -32,6 +33,10 @@ struct PreferencesCommandTests { #expect(enabled.json == true) #expect(disabled.preference == .autoRename) #expect(disabled.value?.value == false) + + let interactive: AppCommand.PreferencesCommand.Set = try .parse([]) + #expect(interactive.preference == nil) + #expect(interactive.value == nil) } @Test("Auto-update parses every supported policy") @@ -72,6 +77,8 @@ struct PreferencesCommandTests { func executionPreparation() async throws { let requestCount: LockIsolated = .init(0) var client: FXCodexClient = .init() + client.storageMigrationPlan = { nil } + client.prepareStorage = {} client.applyAutomaticPreferences = { _, _, _ in requestCount.withValue { $0 += 1 } return [] diff --git a/Tests/fxcodex-cli-tests/RaycastCommandTests.swift b/Tests/fxcodex-cli-tests/RaycastCommandTests.swift index 7d70fce..529a7d1 100644 --- a/Tests/fxcodex-cli-tests/RaycastCommandTests.swift +++ b/Tests/fxcodex-cli-tests/RaycastCommandTests.swift @@ -1,6 +1,10 @@ import ArgumentParser +import Dependencies +import Foundation +import FXCodexClient import Testing -@testable import FXCodexCLI +@testable +import FXCodexCLI @Suite("Raycast command") struct RaycastCommandTests { @@ -22,14 +26,16 @@ struct RaycastCommandTests { "/tmp/raycast", "--current-only", "--yes", + "--json", ]) #expect(command.component == .scriptCommand) #expect(command.directory == "/tmp/raycast") #expect(command.currentOnly) #expect(command.yes) + #expect(command.json == true) } - @Test("Sync and uninstall require the Script Command component") + @Test("Sync and uninstall accept explicit or interactive Script Command maintenance") func maintenanceCommands() async throws { let sync: AppCommand.IntegrationsCommand.Raycast.Sync = try .parse([ "script-command", @@ -41,5 +47,156 @@ struct RaycastCommandTests { #expect(sync.component == .scriptCommand) #expect(uninstall.component == .scriptCommand) #expect(uninstall.yes) + + let interactiveSync: AppCommand.IntegrationsCommand.Raycast.Sync = try .parse([]) + let interactiveUninstall: AppCommand.IntegrationsCommand.Raycast.Uninstall = try .parse([]) + #expect(interactiveSync.component == nil) + #expect(interactiveUninstall.component == nil) + } + + @Test("Omitted maintenance components are selected interactively") + func interactiveMaintenanceComponents() async throws { + let selections = LockIsolated<[String]>([]) + let synchronized = LockIsolated(false) + let uninstalled = LockIsolated(false) + var raycast = Integrations.Raycast() + raycast.syncScriptCommands = { _ in + synchronized.setValue(true) + return .init(directoryURL: nil, managedCommandCount: 1) + } + raycast.uninstallScriptCommands = { + uninstalled.setValue(true) + return .init(directoryURL: nil, managedCommandCount: 0) + } + + try await withDependencies { + $0.fxCodexClient = .init() + $0._fxcodexIntegrations = .init(raycast: raycast, attributes: .init()) + $0._fxcodexTerminalPrompts = .init( + select: { _, options in + selections.withValue { $0.append(contentsOf: options.map(\.value)) } + return "script-command" + }, + multiselect: { _, _ in nil }, + confirm: { _ in nil } + ) + } operation: { + let sync: AppCommand.IntegrationsCommand.Raycast.Sync = try .parse([]) + try await sync.run() + + let uninstall: AppCommand.IntegrationsCommand.Raycast.Uninstall = try .parse(["--yes"]) + try await uninstall.run() + } + + #expect(selections.value == ["script-command", "script-command"]) + #expect(synchronized.value) + #expect(uninstalled.value) + } + + @Test("Script Command installation executes with machine output without prompting") + func machineInstallScriptCommand() async throws { + let didPrompt = LockIsolated(false) + let installedDirectory = LockIsolated(nil) + var raycast = Integrations.Raycast() + raycast.scriptCommandStatus = { + .init(directoryURL: nil, managedCommandCount: 0) + } + raycast.installScriptCommands = { directory, _, _ in + installedDirectory.setValue(directory) + return .init(directoryURL: directory, managedCommandCount: 2) + } + + try await withDependencies { + $0.fxCodexClient = .init() + $0._fxcodexIntegrations = .init(raycast: raycast, attributes: .init()) + $0._fxcodexTerminalPrompts = .init( + select: { _, _ in + didPrompt.setValue(true) + return nil + }, + multiselect: { _, _ in + didPrompt.setValue(true) + return nil + }, + confirm: { _ in + didPrompt.setValue(true) + return nil + }, + text: { _, _ in + didPrompt.setValue(true) + return nil + } + ) + } operation: { + let command: AppCommand.IntegrationsCommand.Raycast.Install = try .parse([ + "script-command", + "--directory", + "/tmp/raycast", + "--json", + ]) + try await command.run() + } + + #expect(installedDirectory.value?.path == "/tmp/raycast") + #expect(!didPrompt.value) + } + + @Test("Machine installation requires an explicit component") + func machineInstallRequiresComponent() async throws { + let command: AppCommand.IntegrationsCommand.Raycast.Install = try .parse(["--json"]) + + await #expect(throws: ValidationError.self) { + try await command.run() + } + } + + @Test("Application installation executes with machine output") + func machineInstallApplication() async throws { + let applicationURL = URL(fileURLWithPath: "/Applications/Raycast.app") + var raycast = Integrations.Raycast() + raycast.applicationInstallation = { edition in + #expect(edition == .stable) + return .alreadyInstalled(applicationURL) + } + raycast.applicationStatus = { edition in + .init( + edition: edition, + applicationURL: applicationURL, + version: "1.0.0" + ) + } + + try await withDependencies { + $0.fxCodexClient = .init() + $0._fxcodexIntegrations = .init(raycast: raycast, attributes: .init()) + } operation: { + let command: AppCommand.IntegrationsCommand.Raycast.Install = try .parse([ + "app", + "--json", + ]) + try await command.run() + } + } + + @Test("Status executes with machine output") + func machineStatus() async throws { + var raycast = Integrations.Raycast() + raycast.applicationStatus = { edition in + .init(edition: edition, applicationURL: nil, version: nil) + } + raycast.scriptCommandStatus = { + .init( + directoryURL: URL(fileURLWithPath: "/tmp/raycast"), + managedCommandCount: 1 + ) + } + + try await withDependencies { + $0.fxCodexClient = .init() + $0._fxcodexIntegrations = .init(raycast: raycast, attributes: .init()) + } operation: { + let command: AppCommand.IntegrationsCommand.Raycast.Status = try .parse(["--json"]) + try await command.run() + } } } diff --git a/Tests/fxcodex-cli-tests/RenameApplicationCommandTests.swift b/Tests/fxcodex-cli-tests/RenameApplicationCommandTests.swift index 256e3d2..070de3a 100644 --- a/Tests/fxcodex-cli-tests/RenameApplicationCommandTests.swift +++ b/Tests/fxcodex-cli-tests/RenameApplicationCommandTests.swift @@ -1,6 +1,7 @@ import ArgumentParser import Testing -@testable import FXCodexCLI +@testable +import FXCodexCLI @Suite("Rename application command") struct RenameApplicationCommandTests { diff --git a/Tests/fxcodex-cli-tests/StatusCommandTests.swift b/Tests/fxcodex-cli-tests/StatusCommandTests.swift index e63ccd9..203b630 100644 --- a/Tests/fxcodex-cli-tests/StatusCommandTests.swift +++ b/Tests/fxcodex-cli-tests/StatusCommandTests.swift @@ -2,7 +2,8 @@ import ArgumentParser import Foundation import FXCodexClient import Testing -@testable import FXCodexCLI +@testable +import FXCodexCLI @Suite("Status command") struct StatusCommandTests { @@ -61,6 +62,7 @@ struct StatusCommandTests { func machineOutputSections() async throws { let status: FXCodexStatus = .init( currentWorkspace: Workspace.primaryName, + currentWorkspaceID: try #require(WorkspaceID("00000000-0000-0000-0000-000000000001")), supportDirectoryURL: URL(fileURLWithPath: "/tmp/fxcodex"), applicationURL: nil, preferences: .init(autoRename: true), diff --git a/Tests/fxcodex-cli-tests/UninstallCommandTests.swift b/Tests/fxcodex-cli-tests/UninstallCommandTests.swift index bfa772f..682b2fa 100644 --- a/Tests/fxcodex-cli-tests/UninstallCommandTests.swift +++ b/Tests/fxcodex-cli-tests/UninstallCommandTests.swift @@ -2,7 +2,8 @@ import ArgumentParser import Dependencies import FXCodexClient import Testing -@testable import FXCodexCLI +@testable +import FXCodexCLI @Suite("Uninstall command") struct UninstallCommandTests { diff --git a/Tests/fxcodex-cli-tests/UpdateCommandTests.swift b/Tests/fxcodex-cli-tests/UpdateCommandTests.swift index 96e00ea..25fd839 100644 --- a/Tests/fxcodex-cli-tests/UpdateCommandTests.swift +++ b/Tests/fxcodex-cli-tests/UpdateCommandTests.swift @@ -2,7 +2,8 @@ import ArgumentParser import Foundation import FXCodexClient import Testing -@testable import FXCodexCLI +@testable +import FXCodexCLI @Suite("Update command") struct UpdateCommandTests { diff --git a/Tests/fxcodex-cli-tests/WorkspaceCommandTests.swift b/Tests/fxcodex-cli-tests/WorkspaceCommandTests.swift index 5a82727..935213d 100644 --- a/Tests/fxcodex-cli-tests/WorkspaceCommandTests.swift +++ b/Tests/fxcodex-cli-tests/WorkspaceCommandTests.swift @@ -1,6 +1,7 @@ import ArgumentParser import Testing -@testable import FXCodexCLI +@testable +import FXCodexCLI @Suite("Workspace command") struct WorkspaceCommandTests { @@ -16,6 +17,9 @@ struct WorkspaceCommandTests { #expect(command.use) #expect(command.open) #expect(command.json == true) + + let interactive: AppCommand.WorkspaceCommand.Create = try .parse([]) + #expect(interactive.name == nil) } @Test("Delete parses multiple workspaces and confirmation flag") @@ -64,6 +68,10 @@ struct WorkspaceCommandTests { ]) #expect(explicit.firstName == "old-name") #expect(explicit.secondName == "new-name") + + let interactive: AppCommand.WorkspaceCommand.Rename = try .parse([]) + #expect(interactive.firstName == nil) + #expect(interactive.secondName == nil) } @Test("Use requires a workspace") diff --git a/Tests/fxcodex-client-tests/Client/CodexApplicationClientTests.swift b/Tests/fxcodex-client-tests/Client/CodexApplicationClientTests.swift index 7fdacf1..e1c34c9 100644 --- a/Tests/fxcodex-client-tests/Client/CodexApplicationClientTests.swift +++ b/Tests/fxcodex-client-tests/Client/CodexApplicationClientTests.swift @@ -1,7 +1,9 @@ import Dependencies import Foundation import Testing -@_spi(Internals) @testable import FXCodexClient +@_spi(Internals) +@testable +import FXCodexClient @Suite("Codex application client") struct CodexApplicationClientTests { @@ -17,7 +19,9 @@ struct CodexApplicationClientTests { $0._fxcodexPaths = .init(rootURL: fixture.rootURL) $0._fxcodexRaycast = .fixture } operation: { - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + let workspace: Workspace = try await client.createWorkspace("work") #expect(try await client.openWorkspace(workspace.name) == 7_007) } @@ -43,7 +47,9 @@ struct CodexApplicationClientTests { $0._fxcodexPaths = .init(rootURL: fixture.rootURL) $0._fxcodexRaycast = .fixture } operation: { - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + _ = try await client.createWorkspace("work") let status: FXCodexStatus = try await client.status() diff --git a/Tests/fxcodex-client-tests/Client/CodexInvocationClientTests.swift b/Tests/fxcodex-client-tests/Client/CodexInvocationClientTests.swift index 3ecf74a..868dd44 100644 --- a/Tests/fxcodex-client-tests/Client/CodexInvocationClientTests.swift +++ b/Tests/fxcodex-client-tests/Client/CodexInvocationClientTests.swift @@ -1,7 +1,9 @@ import Dependencies import Foundation import Testing -@_spi(Internals) @testable import FXCodexClient +@_spi(Internals) +@testable +import FXCodexClient @Suite("Codex invocation client") struct CodexInvocationClientTests { @@ -17,7 +19,9 @@ struct CodexInvocationClientTests { $0._fxcodexPaths = .init(rootURL: fixture.rootURL) $0._fxcodexRaycast = .fixture } operation: { - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + let primaryInvocation: CommandInvocation = try await client.codexInvocation( Workspace.primaryName, ["--version"] @@ -55,7 +59,9 @@ struct CodexInvocationClientTests { $0._fxcodexPaths = .init(rootURL: fixture.rootURL) $0._fxcodexRaycast = .fixture } operation: { - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + await #expect(throws: FXCodexError.workspaceNotFound("missing")) { try await client.codexInvocation("missing", []) } diff --git a/Tests/fxcodex-client-tests/Client/PreferencesClientTests.swift b/Tests/fxcodex-client-tests/Client/PreferencesClientTests.swift index 7403971..32743aa 100644 --- a/Tests/fxcodex-client-tests/Client/PreferencesClientTests.swift +++ b/Tests/fxcodex-client-tests/Client/PreferencesClientTests.swift @@ -1,7 +1,9 @@ import Dependencies import Foundation import Testing -@_spi(Internals) @testable import FXCodexClient +@_spi(Internals) +@testable +import FXCodexClient @Suite("Preferences client") struct PreferencesClientTests { @@ -17,7 +19,8 @@ struct PreferencesClientTests { $0._fxcodexPaths = .init(rootURL: fixture.rootURL) $0._fxcodexRaycast = .fixture } operation: { - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient let initialPreferences: FXCodexPreferences = try await client.preferences() #expect(initialPreferences == .init(autoRename: false)) @@ -54,7 +57,9 @@ struct PreferencesClientTests { $0._fxcodexPaths = .init(rootURL: fixture.rootURL) $0._fxcodexRaycast = .fixture } operation: { - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + _ = try await client.setAutoRename(true) let warnings: [FXCodexWarning] = try await client.applyAutomaticPreferences( .init(major: 0, minor: 1, patch: 0), @@ -84,7 +89,9 @@ struct PreferencesClientTests { $0._fxcodexPaths = .init(rootURL: fixture.rootURL) $0._fxcodexRaycast = .fixture } operation: { - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + _ = try await client.setAutoRename(true) let warnings: [FXCodexWarning] = try await client.applyAutomaticPreferences( .init(major: 0, minor: 1, patch: 0), @@ -123,7 +130,9 @@ struct PreferencesClientTests { } ) } operation: { - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + let minimumVersion: SemanticVersion = .init(major: 0, minor: 9, patch: 0) _ = try await client.setAutoUpdate(.minor(from: minimumVersion)) let warnings: [FXCodexWarning] = try await client.applyAutomaticPreferences( @@ -166,7 +175,9 @@ struct PreferencesClientTests { } ) } operation: { - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + _ = try await client.setAutoUpdate(.latest(from: .init( major: 0, minor: 1, diff --git a/Tests/fxcodex-client-tests/Client/UninstallClientTests.swift b/Tests/fxcodex-client-tests/Client/UninstallClientTests.swift index 6b20e33..cb9732d 100644 --- a/Tests/fxcodex-client-tests/Client/UninstallClientTests.swift +++ b/Tests/fxcodex-client-tests/Client/UninstallClientTests.swift @@ -1,7 +1,9 @@ import Dependencies import Foundation import Testing -@_spi(Internals) @testable import FXCodexClient +@_spi(Internals) +@testable +import FXCodexClient @Suite("Uninstall client") struct UninstallClientTests { @@ -15,7 +17,9 @@ struct UninstallClientTests { $0._fxcodexApplication = CodexApplicationSpy().client $0._fxcodexPaths = .init(rootURL: fixture.rootURL) } operation: { - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + let workspace: Workspace = try await client.createWorkspace("work") let codexHomeURL: URL = try #require(workspace.codexHomeURL) let dataURL: URL = codexHomeURL.appending(path: "data.json") @@ -23,8 +27,7 @@ struct UninstallClientTests { _ = try await client.integrations.raycast.installScriptCommands( fixture.scriptsURL, fixture.executableURL, - true, - true + false ) try await client.uninstallData(.leave) @@ -48,7 +51,9 @@ struct UninstallClientTests { $0._fxcodexApplication = CodexApplicationSpy().client $0._fxcodexPaths = .init(rootURL: fixture.rootURL) } operation: { - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + let workspace: Workspace = try await client.createWorkspace("work") let codexHomeURL: URL = try #require(workspace.codexHomeURL) try Data("data".utf8).write(to: codexHomeURL.appending(path: "data.json")) @@ -73,7 +78,9 @@ struct UninstallClientTests { $0._fxcodexApplication = CodexApplicationSpy().client $0._fxcodexPaths = .init(rootURL: fixture.rootURL) } operation: { - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + _ = try await client.createWorkspace("work") try await client.uninstallData(.delete) @@ -93,7 +100,9 @@ struct UninstallClientTests { $0._fxcodexApplication = application.client $0._fxcodexPaths = .init(rootURL: fixture.rootURL) } operation: { - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + _ = try await client.createWorkspace("work") await #expect(throws: FXCodexError.workspaceIsRunning("work")) { diff --git a/Tests/fxcodex-client-tests/Client/WorkspaceClientTests.swift b/Tests/fxcodex-client-tests/Client/WorkspaceClientTests.swift index c25ef2e..3ab6660 100644 --- a/Tests/fxcodex-client-tests/Client/WorkspaceClientTests.swift +++ b/Tests/fxcodex-client-tests/Client/WorkspaceClientTests.swift @@ -1,7 +1,9 @@ import Dependencies import Foundation import Testing -@_spi(Internals) @testable import FXCodexClient +@_spi(Internals) +@testable +import FXCodexClient @Suite("Workspace client") struct WorkspaceClientTests { @@ -17,7 +19,9 @@ struct WorkspaceClientTests { $0._fxcodexPaths = .init(rootURL: fixture.rootURL) $0._fxcodexRaycast = .fixture } operation: { - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + let workspace: Workspace = try await client.createWorkspace("work") #expect(workspace.kind == .managed) #expect(try await client.workspaces().map(\.name) == ["primary", "work"]) @@ -38,10 +42,7 @@ struct WorkspaceClientTests { } let snapshot: CodexApplicationSpy.Snapshot = await application.snapshot() - #expect(snapshot.renamedWorkspaces == [.init( - oldName: "work", - newName: "work-renamed" - )]) + #expect(snapshot.renamedWorkspaces.isEmpty) #expect(snapshot.removedWorkspaceNames == ["work-renamed"]) } @@ -57,7 +58,9 @@ struct WorkspaceClientTests { $0._fxcodexPaths = .init(rootURL: fixture.rootURL) $0._fxcodexRaycast = .fixture } operation: { - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + let workspace: Workspace = try await client.createWorkspace("work") await application.setProcessID(9_001, forWorkspaceNamed: workspace.name) @@ -83,7 +86,9 @@ struct WorkspaceClientTests { $0._fxcodexPaths = .init(rootURL: fixture.rootURL) $0._fxcodexRaycast = .fixture } operation: { - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + let work: Workspace = try await client.createWorkspace("work") let personal: Workspace = try await client.createWorkspace("personal") await application.setProcessID(9_001, forWorkspaceNamed: personal.name) diff --git a/Tests/fxcodex-client-tests/Fixtures/ClientTestFixture.swift b/Tests/fxcodex-client-tests/Fixtures/ClientTestFixture.swift index 34b4247..1ca46b3 100644 --- a/Tests/fxcodex-client-tests/Fixtures/ClientTestFixture.swift +++ b/Tests/fxcodex-client-tests/Fixtures/ClientTestFixture.swift @@ -1,5 +1,7 @@ import Foundation -@_spi(Internals) @testable import FXCodexClient +@_spi(Internals) +@testable +import FXCodexClient final class ClientTestFixture { let applicationURL: URL @@ -149,8 +151,7 @@ actor CodexApplicationSpy { rename: { try await self.rename(to: $0) }, open: { await self.open($0) }, runningProcessID: { await self.runningProcessID(for: $0) }, - removeRecord: { await self.removeRecord(for: $0) }, - renameRecord: { await self.renameRecord(from: $0, to: $1) } + removeRecord: { await self.removeRecord(for: $0.name) } ) } @@ -209,16 +210,6 @@ actor CodexApplicationSpy { self.removedWorkspaceNames.append(name) } - private func renameRecord( - from oldName: String, - to newName: String - ) { - self.processIDs[newName] = self.processIDs.removeValue(forKey: oldName) - self.renamedWorkspaces.append(.init( - oldName: oldName, - newName: newName - )) - } } extension Integrations.Raycast { @@ -238,7 +229,7 @@ extension Integrations.Raycast { environment: [:] )) }, - installScriptCommands: { directoryURL, _, _, _ in + installScriptCommands: { directoryURL, _, _ in .init( directoryURL: directoryURL, managedCommandCount: 0 diff --git a/Tests/fxcodex-client-tests/Integrations/RaycastClientTests.swift b/Tests/fxcodex-client-tests/Integrations/RaycastClientTests.swift index 9f932ce..055f9e0 100644 --- a/Tests/fxcodex-client-tests/Integrations/RaycastClientTests.swift +++ b/Tests/fxcodex-client-tests/Integrations/RaycastClientTests.swift @@ -1,7 +1,9 @@ import Dependencies import Foundation import Testing -@_spi(Internals) @testable import FXCodexClient +@_spi(Internals) +@testable +import FXCodexClient @Suite("Raycast client") struct RaycastClientTests { @@ -16,27 +18,26 @@ struct RaycastClientTests { $0._fxcodexApplication = application.client $0._fxcodexPaths = .init(rootURL: fixture.rootURL) } operation: { - @Dependency(\.fxCodexClient) var client: FXCodexClient - _ = try await client.createWorkspace("work") + @Dependency(\.fxCodexClient) + var client: FXCodexClient + + let work = try await client.createWorkspace("work") let status: RaycastScriptCommandStatus = try await client.integrations.raycast.installScriptCommands( fixture.scriptsURL, fixture.executableURL, - true, - true + false ) #expect(status.managedCommandCount == 2) - #expect(!FileManager.default.fileExists( - atPath: fixture.scriptsURL.appending(path: "fxcodex-open-current.sh").path - )) - let workScriptURL: URL = fixture.scriptsURL.appending( - path: "fxcodex-open-work.sh" + let generatedDirectoryURL = fixture.scriptsURL.appending(path: "fxcodex") + let workScriptURL: URL = generatedDirectoryURL.appending( + path: "\(work.id.rawValue).sh" ) - let workLightIconURL: URL = fixture.scriptsURL.appending( - path: "fxcodex-open-work-light.png" + let workLightIconURL: URL = generatedDirectoryURL.appending( + path: "\(work.id.rawValue)-light.png" ) - let workDarkIconURL: URL = fixture.scriptsURL.appending( - path: "fxcodex-open-work-dark.png" + let workDarkIconURL: URL = generatedDirectoryURL.appending( + path: "\(work.id.rawValue)-dark.png" ) let workScriptContents: String = try .init( contentsOf: workScriptURL, @@ -50,31 +51,18 @@ struct RaycastClientTests { )) #expect(try Data(contentsOf: workLightIconURL) == RaycastScriptCommandIcon.light) #expect(try Data(contentsOf: workDarkIconURL) == RaycastScriptCommandIcon.dark) + #expect(workScriptContents.contains("open --workspace-id '\(work.id.rawValue)'")) let createdWorkspace: Workspace = try await client.createWorkspace("personal") - let personalScriptURL: URL = fixture.scriptsURL.appending( - path: "fxcodex-open-\(createdWorkspace.name).sh" + let personalScriptURL: URL = generatedDirectoryURL.appending( + path: "\(createdWorkspace.id.rawValue).sh" ) #expect(FileManager.default.fileExists(atPath: personalScriptURL.path)) let renamedWorkspace: Workspace = try await client.renameWorkspace("work", "work-renamed") - let renamedScriptURL: URL = fixture.scriptsURL.appending( - path: "fxcodex-open-\(renamedWorkspace.name).sh" - ) - #expect(!FileManager.default.fileExists(atPath: workScriptURL.path)) - #expect(!FileManager.default.fileExists(atPath: workLightIconURL.path)) - #expect(!FileManager.default.fileExists(atPath: workDarkIconURL.path)) - #expect(FileManager.default.fileExists(atPath: renamedScriptURL.path)) - #expect(FileManager.default.fileExists( - atPath: fixture.scriptsURL.appending( - path: "fxcodex-open-\(renamedWorkspace.name)-light.png" - ).path - )) - #expect(FileManager.default.fileExists( - atPath: fixture.scriptsURL.appending( - path: "fxcodex-open-\(renamedWorkspace.name)-dark.png" - ).path - )) + #expect(renamedWorkspace.id == work.id) + #expect(FileManager.default.fileExists(atPath: workScriptURL.path)) + #expect(try String(contentsOf: workScriptURL, encoding: .utf8).contains("Codex (Work-Renamed)")) let codexHomeURL: URL = try #require(renamedWorkspace.codexHomeURL) try "settings".write( @@ -86,7 +74,7 @@ struct RaycastClientTests { try await client.eraseWorkspaces([renamedWorkspace.name]).first ) #expect(erasedWorkspace.integrations.isEmpty) - #expect(!FileManager.default.fileExists(atPath: renamedScriptURL.path)) + #expect(FileManager.default.fileExists(atPath: workScriptURL.path)) #expect(try FileManager.default.contentsOfDirectory(atPath: codexHomeURL.path).isEmpty) #expect(try await client.workspaces().contains { workspace in workspace.name == renamedWorkspace.name @@ -94,7 +82,7 @@ struct RaycastClientTests { try await client.deleteWorkspace(createdWorkspace.name) #expect(!FileManager.default.fileExists(atPath: personalScriptURL.path)) - #expect(try await client.integrations.raycast.scriptCommandStatus().managedCommandCount == 1) + #expect(try await client.integrations.raycast.scriptCommandStatus().managedCommandCount == 2) } } @@ -110,32 +98,34 @@ struct RaycastClientTests { $0._fxcodexApplication = application.client $0._fxcodexPaths = .init(rootURL: fixture.rootURL) } operation: { - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + let workspace: Workspace = try await client.createWorkspace("work") try await client.useWorkspace(workspace.name) let installedStatus: RaycastScriptCommandStatus = try await client.integrations.raycast.installScriptCommands( fixture.scriptsURL, fixture.executableURL, - true, - false + true ) #expect(installedStatus.managedCommandCount == 1) let newWorkspace: Workspace = try await client.createWorkspace("personal") - let newWorkspaceScriptURL: URL = fixture.scriptsURL.appending( - path: "fxcodex-open-\(newWorkspace.name).sh" + let generatedDirectoryURL = fixture.scriptsURL.appending(path: "fxcodex") + let newWorkspaceScriptURL: URL = generatedDirectoryURL.appending( + path: "\(newWorkspace.id.rawValue).sh" ) #expect(!FileManager.default.fileExists(atPath: newWorkspaceScriptURL.path)) - let scriptURL: URL = fixture.scriptsURL.appending(path: "fxcodex-open-work.sh") - let lightIconURL: URL = fixture.scriptsURL.appending( - path: "fxcodex-open-work-light.png" + let scriptURL: URL = generatedDirectoryURL.appending(path: "\(workspace.id.rawValue).sh") + let lightIconURL: URL = generatedDirectoryURL.appending( + path: "\(workspace.id.rawValue)-light.png" ) - let darkIconURL: URL = fixture.scriptsURL.appending( - path: "fxcodex-open-work-dark.png" + let darkIconURL: URL = generatedDirectoryURL.appending( + path: "\(workspace.id.rawValue)-dark.png" ) - let userScriptURL: URL = fixture.scriptsURL.appending(path: "user-script.sh") + let userScriptURL: URL = generatedDirectoryURL.appending(path: "user-script.sh") try "#!/bin/sh\n".write( to: userScriptURL, atomically: true, @@ -148,7 +138,7 @@ struct RaycastClientTests { let scriptContents: String = try .init(contentsOf: scriptURL, encoding: .utf8) #expect(syncedStatus.managedCommandCount == 1) #expect(scriptContents.contains("'\(updatedExecutableURL.path)'")) - #expect(scriptContents.contains("open 'work'")) + #expect(scriptContents.contains("open --workspace-id '\(workspace.id.rawValue)'")) let uninstalledStatus: RaycastScriptCommandStatus = try await client.integrations.raycast.uninstallScriptCommands() #expect(uninstalledStatus.managedCommandCount == 0) @@ -173,7 +163,9 @@ struct RaycastClientTests { $0._fxcodexApplication = application.client $0._fxcodexPaths = .init(rootURL: fixture.rootURL) } operation: { - @Dependency(\.fxCodexClient) var client: FXCodexClient + @Dependency(\.fxCodexClient) + var client: FXCodexClient + _ = try await client.workspaces() await #expect(throws: FXCodexError.raycastScriptCommandDirectoryMissing) { try await client.integrations.raycast.syncScriptCommands(fixture.executableURL) diff --git a/Tests/fxcodex-client-tests/Manager/CodexApplicationControllerTests.swift b/Tests/fxcodex-client-tests/Manager/CodexApplicationControllerTests.swift index 0cd6931..f0dac98 100644 --- a/Tests/fxcodex-client-tests/Manager/CodexApplicationControllerTests.swift +++ b/Tests/fxcodex-client-tests/Manager/CodexApplicationControllerTests.swift @@ -1,7 +1,9 @@ import Dependencies import Foundation import Testing -@_spi(Internals) @testable import FXCodexClient +@_spi(Internals) +@testable +import FXCodexClient @Suite("Codex application controller") struct CodexApplicationControllerTests { diff --git a/Tests/fxcodex-client-tests/Models/CodableValueTests.swift b/Tests/fxcodex-client-tests/Models/CodableValueTests.swift index 61675e4..5d26c26 100644 --- a/Tests/fxcodex-client-tests/Models/CodableValueTests.swift +++ b/Tests/fxcodex-client-tests/Models/CodableValueTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing -@testable import FXCodexClient +@testable +import FXCodexClient @Suite("Codable value") struct CodableValueTests { diff --git a/Tests/fxcodex-client-tests/Models/FXCodexJSONCodingTests.swift b/Tests/fxcodex-client-tests/Models/FXCodexJSONCodingTests.swift index 30bc153..418b06f 100644 --- a/Tests/fxcodex-client-tests/Models/FXCodexJSONCodingTests.swift +++ b/Tests/fxcodex-client-tests/Models/FXCodexJSONCodingTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing -@testable import FXCodexClient +@testable +import FXCodexClient @Suite("fxcodex JSON coding") struct FXCodexJSONCodingTests { diff --git a/Tests/fxcodex-client-tests/Models/IntegrationAttributePathTests.swift b/Tests/fxcodex-client-tests/Models/IntegrationAttributePathTests.swift new file mode 100644 index 0000000..8c76ef8 --- /dev/null +++ b/Tests/fxcodex-client-tests/Models/IntegrationAttributePathTests.swift @@ -0,0 +1,32 @@ +import Testing +@testable +import FXCodexClient + +@Suite("Integration attribute paths") +struct IntegrationAttributePathTests { + @Test("Parses members, keyed dictionaries, arrays, and functions") + func components() throws { + #expect(try IntegrationAttributePath("workspaces.[key: abc].items.[idx: 2].name").components == [ + .member("workspaces"), + .key("abc"), + .member("items"), + .index(2), + .member("name"), + ]) + #expect(try IntegrationAttributePath("workspaces.(keys).(first)").components == [ + .member("workspaces"), + .function(.keys), + .function(.first), + ]) + #expect(try IntegrationAttributePath("[key: \"a.b\"]").components == [.key("a.b")]) + } + + @Test("Rejects malformed paths") + func invalid() { + for path in [".", "workspaces..icon", "[idx: -1]", "(unknown)", "[key:]"] { + #expect(throws: FXCodexError.invalidAttributePath(path)) { + try IntegrationAttributePath(path) + } + } + } +} diff --git a/Tests/fxcodex-client-tests/Models/SemanticVersionTests.swift b/Tests/fxcodex-client-tests/Models/SemanticVersionTests.swift index 124fdf7..83f2a1d 100644 --- a/Tests/fxcodex-client-tests/Models/SemanticVersionTests.swift +++ b/Tests/fxcodex-client-tests/Models/SemanticVersionTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing -@testable import FXCodexClient +@testable +import FXCodexClient @Suite("Semantic version") struct SemanticVersionTests { diff --git a/Tests/fxcodex-client-tests/Storage/AppInstancesStorageTests.swift b/Tests/fxcodex-client-tests/Storage/AppInstancesStorageTests.swift index 58613b8..edbb63a 100644 --- a/Tests/fxcodex-client-tests/Storage/AppInstancesStorageTests.swift +++ b/Tests/fxcodex-client-tests/Storage/AppInstancesStorageTests.swift @@ -1,7 +1,9 @@ import Dependencies import Foundation import Testing -@_spi(Internals) @testable import FXCodexClient +@_spi(Internals) +@testable +import FXCodexClient @Suite("Application instances storage") struct AppInstancesStorageTests { @@ -17,21 +19,24 @@ struct AppInstancesStorageTests { $0._fxcodexPaths = .init(rootURL: fixture.rootURL) } operation: { let storage: AppInstancesStorage = .init(fileManager: .default) + let workspaceID = WorkspaceID.generate() try storage.setRecord( .init( bundleURL: oldURL, launchDate: Date(timeIntervalSince1970: 1_000), processID: 42 ), - forWorkspaceNamed: "work" + forWorkspaceID: workspaceID ) let data: Data = try .init(contentsOf: fixture.rootURL.appending( - path: "instances.json" + path: "runtime.json" )) let object: [String: Any] = try #require( JSONSerialization.jsonObject(with: data) as? [String: Any] ) - let record: [String: Any] = try #require(object["work"] as? [String: Any]) + #expect(object["schema_version"] as? String == "2.0") + let instances = try #require(object["instances"] as? [String: Any]) + let record: [String: Any] = try #require(instances[workspaceID.rawValue] as? [String: Any]) #expect(Set(record.keys) == ["bundle_url", "launch_date", "process_id"]) try storage.replaceBundleURL( from: oldURL, @@ -39,7 +44,7 @@ struct AppInstancesStorageTests { ) #expect( - try storage.record(forWorkspaceNamed: "work")?.bundleURL + try storage.record(forWorkspaceID: workspaceID)?.bundleURL == newURL.standardizedFileURL ) } diff --git a/Tests/fxcodex-client-tests/Storage/IntegrationAttributesStorageTests.swift b/Tests/fxcodex-client-tests/Storage/IntegrationAttributesStorageTests.swift new file mode 100644 index 0000000..0d441d9 --- /dev/null +++ b/Tests/fxcodex-client-tests/Storage/IntegrationAttributesStorageTests.swift @@ -0,0 +1,53 @@ +import Dependencies +import Foundation +import Testing +@_spi(Internals) +@testable +import FXCodexClient + +@Suite("Integration attributes storage") +struct IntegrationAttributesStorageTests { + @Test("Mutates nested attributes by stable workspace ID") + func lifecycle() async throws { + let fixture = try ClientTestFixture() + defer { fixture.remove() } + + try withDependencies { + $0.context = .live + $0._fxcodexPaths = .init(rootURL: fixture.rootURL) + } operation: { + let workspaces = WorkspacesStorage(fileManager: .default) + let workspace = try workspaces.createWorkspace(named: "work") + let attributes = IntegrationAttributesStorage(fileManager: .default) + let iconPath = try IntegrationAttributePath("workspaces.[key: \(workspace.id.rawValue)].icon") + let icon: CodableValue = .dictionary([ + "type": .string("raycast"), + "value": .string("Folder"), + ]) + + try attributes.setValue(integration: "raycast", path: iconPath, value: icon) + #expect(try attributes.value(integration: "raycast", path: iconPath) == icon) + #expect(try attributes.value( + integration: "raycast", + path: .init("workspaces.(keys)") + ) == .array([.string(workspace.id.rawValue)])) + + let renamed = try workspaces.renameWorkspace(id: workspace.id, to: "renamed") + #expect(renamed.id == workspace.id) + #expect(try attributes.value(integration: "raycast", path: iconPath) == icon) + + #expect(throws: FXCodexError.invalidAttributePath("workspaces.(keys)")) { + try attributes.setValue( + integration: "raycast", + path: .init("workspaces.(keys)"), + value: .array([]) + ) + } + + try attributes.removeValue(integration: "raycast", path: iconPath) + #expect(throws: FXCodexError.integrationAttributeNotFound("raycast.\(iconPath.rawValue)")) { + try attributes.value(integration: "raycast", path: iconPath) + } + } + } +} diff --git a/Tests/fxcodex-client-tests/Storage/MigratorTests.swift b/Tests/fxcodex-client-tests/Storage/MigratorTests.swift new file mode 100644 index 0000000..c2d18fb --- /dev/null +++ b/Tests/fxcodex-client-tests/Storage/MigratorTests.swift @@ -0,0 +1,388 @@ +import Dependencies +import Foundation +import Testing +@_spi(Internals) +@testable +import FXCodexClient + +@Suite("Storage migrator") +struct MigratorTests { + @Test("Migrates schema 1.0 names, runtime records, and Raycast attributes to stable IDs") + func v1ToV2() throws { + let fixture = try ClientTestFixture() + defer { fixture.remove() } + let paths = FXCodexPaths(rootURL: fixture.rootURL) + let legacyWorkspaceURL = paths.workspacesURL.appending(path: "work", directoryHint: .isDirectory) + try FileManager.default.createDirectory( + at: legacyWorkspaceURL.appending(path: "codex-home", directoryHint: .isDirectory), + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: legacyWorkspaceURL.appending(path: "user-data", directoryHint: .isDirectory), + withIntermediateDirectories: true + ) + try Data("preserved".utf8).write(to: legacyWorkspaceURL.appending(path: "codex-home/config.json")) + let legacyDirectoryID = try #require( + FileManager.default.attributesOfItem( + atPath: legacyWorkspaceURL.path + )[.systemFileNumber] as? NSNumber + ) + + let legacyConfiguration: [String: Any] = [ + "current_workspace_name": "work", + "workspace_integrations": [ + "primary": [ + "raycast": [ + "automatic_script_commands": [ + "enabled": true, + "directory_path": "/tmp/raycast", + "fxcodex_executable_path": "/tmp/fxcodex", + ], + ], + ], + "work": [ + "raycast": [ + "icon": ["type": "raycast", "value": "Folder"], + "script_command": [ + "directory_path": "/tmp/old-raycast", + "fxcodex_executable_path": "/tmp/old-fxcodex", + ], + ], + ], + ], + ] + try JSONSerialization.data(withJSONObject: legacyConfiguration, options: [.prettyPrinted, .sortedKeys]) + .write(to: paths.configurationURL) + + let encoder = FXCodexJSONCoding.encoder() + encoder.dateEncodingStrategy = .iso8601 + let record = ApplicationInstanceRecord( + bundleURL: URL(fileURLWithPath: "/Applications/Codex.app"), + launchDate: Date(timeIntervalSince1970: 1_000), + processID: .max + ) + try encoder.encode(["work": record]).write(to: paths.instancesURL) + + try withDependencies { + $0.context = .live + $0._fxcodexPaths = paths + } operation: { + let optionalPlan = try Migrator(fileManager: .default).migrationPlan() + let plan = try #require(optionalPlan) + #expect(plan.sourceVersion == .v1_0) + #expect(plan.destinationVersion == .v2_0) + #expect(plan.migrations.count == 1) + #expect(!plan.requiresUserInput) + + let storage = WorkspacesStorage(fileManager: .default) + let workspaces = try storage.workspaces() + let work = try #require(workspaces.first(where: { $0.name == "work" })) + #expect(work.kind == .managed) + #expect(try storage.currentWorkspaceID() == work.id) + #expect(work.rootURL?.lastPathComponent == work.id.rawValue) + #expect(FileManager.default.fileExists(atPath: work.codexHomeURL?.appending(path: "config.json").path ?? "")) + #expect(!FileManager.default.fileExists(atPath: legacyWorkspaceURL.path)) + let migratedRootURL = try #require(work.rootURL) + let migratedDirectoryID = try #require( + FileManager.default.attributesOfItem( + atPath: migratedRootURL.path + )[.systemFileNumber] as? NSNumber + ) + #expect(migratedDirectoryID == legacyDirectoryID) + + let attributes = IntegrationAttributesStorage(fileManager: .default) + #expect(try attributes.value( + integration: "raycast", + path: .init("script_commands.path") + ) == .string("/tmp/raycast")) + #expect(try attributes.value( + integration: "raycast", + path: .init("workspaces.[key: \(work.id.rawValue)].icon.value") + ) == .string("Folder")) + + let instances = AppInstancesStorage(fileManager: .default) + #expect(try instances.record(forWorkspaceID: work.id) == record) + try storage.prepare() + #expect(try storage.workspace(named: "work").id == work.id) + } + + let migrated = try #require( + JSONSerialization.jsonObject(with: Data(contentsOf: paths.configurationURL)) as? [String: Any] + ) + #expect(migrated["schema_version"] as? String == "2.0") + #expect(migrated["current_workspace_id"] is String) + #expect(!FileManager.default.fileExists(atPath: paths.instancesURL.path)) + #expect(!FileManager.default.fileExists(atPath: paths.migrationURL.path)) + } + + @Test("Refuses to migrate a workspace while its legacy Codex process is running") + func runningLegacyWorkspace() throws { + let fixture = try ClientTestFixture() + defer { fixture.remove() } + let paths = FXCodexPaths(rootURL: fixture.rootURL) + let legacyWorkspaceURL = paths.workspacesURL.appending( + path: "work", + directoryHint: .isDirectory + ) + + try FileManager.default.createDirectory( + at: legacyWorkspaceURL, + withIntermediateDirectories: true + ) + try Self.writeLegacyConfiguration( + currentWorkspaceName: "work", + to: paths.configurationURL + ) + + let encoder = FXCodexJSONCoding.encoder() + encoder.dateEncodingStrategy = .iso8601 + try encoder.encode([ + "work": ApplicationInstanceRecord( + bundleURL: URL(fileURLWithPath: "/Applications/Codex.app"), + launchDate: Date(), + processID: ProcessInfo.processInfo.processIdentifier + ), + ]).write(to: paths.instancesURL) + + withDependencies { + $0.context = .live + $0._fxcodexPaths = paths + } operation: { + let migrator = Migrator(fileManager: .default) + + #expect(throws: FXCodexError.workspaceIsRunning("work")) { + _ = try migrator.migrationPlan() + } + #expect(throws: FXCodexError.workspaceIsRunning("work")) { + try migrator.migrateIfNeeded() + } + } + + #expect(FileManager.default.fileExists(atPath: legacyWorkspaceURL.path)) + #expect(!FileManager.default.fileExists(atPath: paths.migrationURL.path)) + } + + @Test("Schema 2.0 ignores legacy shadow directories without deleting them") + func legacyShadowDirectory() throws { + let fixture = try ClientTestFixture() + defer { fixture.remove() } + let paths = FXCodexPaths(rootURL: fixture.rootURL) + + try withDependencies { + $0.context = .live + $0._fxcodexPaths = paths + } operation: { + let storage = WorkspacesStorage(fileManager: .default) + try storage.prepare() + let legacyURL = paths.workspacesURL.appending(path: "work", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: legacyURL, withIntermediateDirectories: false) + try Data("preserve".utf8).write(to: legacyURL.appending(path: "marker")) + + #expect(try storage.workspaces().map(\.name) == [Workspace.primaryName]) + #expect(try Migrator(fileManager: .default).migrationPlan() == nil) + #expect(FileManager.default.fileExists(atPath: legacyURL.appending(path: "marker").path)) + } + } + + @Test("Preserves a schema 1.0 current-only Raycast installation") + func currentOnlyRaycast() throws { + let fixture = try ClientTestFixture() + defer { fixture.remove() } + let paths = FXCodexPaths(rootURL: fixture.rootURL) + let legacyWorkspaceURL = paths.workspacesURL.appending( + path: "work", + directoryHint: .isDirectory + ) + try FileManager.default.createDirectory( + at: legacyWorkspaceURL, + withIntermediateDirectories: true + ) + let legacyConfiguration: [String: Any] = [ + "current_workspace_name": "work", + "workspace_integrations": [ + "work": [ + "raycast": [ + "script_command": [ + "directory_path": "/tmp/raycast", + "fxcodex_executable_path": "/tmp/fxcodex", + ], + ], + ], + ], + ] + try JSONSerialization.data( + withJSONObject: legacyConfiguration, + options: [.prettyPrinted, .sortedKeys] + ).write(to: paths.configurationURL) + + try withDependencies { + $0.context = .live + $0._fxcodexPaths = paths + } operation: { + let storage = WorkspacesStorage(fileManager: .default) + let work = try storage.workspace(named: "work") + let attributes = IntegrationAttributesStorage(fileManager: .default) + + #expect(try attributes.value( + integration: "raycast", + path: .init("script_commands.workspace_ids.[idx: 0]") + ) == .string(work.id.rawValue)) + } + } + + @Test("Resumes an interrupted migration from its journal") + func interruptedMigration() throws { + let fixture = try ClientTestFixture() + defer { fixture.remove() } + let paths = FXCodexPaths(rootURL: fixture.rootURL) + let primaryID = try #require(WorkspaceID("00000000-0000-0000-0000-000000000001")) + let workID = try #require(WorkspaceID("00000000-0000-0000-0000-000000000002")) + + try Self.writeLegacyConfiguration(currentWorkspaceName: "work", to: paths.configurationURL) + try Self.writeJournal( + primaryID: primaryID, + workspaceIDs: ["work": workID], + to: paths.migrationURL + ) + + let migratedWorkspaceURL = paths.workspacesURL.appending( + path: workID.rawValue, + directoryHint: .isDirectory + ) + try FileManager.default.createDirectory( + at: migratedWorkspaceURL, + withIntermediateDirectories: true + ) + try Self.write( + WorkspaceConfiguration(id: workID, name: "work", kind: .managed), + to: migratedWorkspaceURL.appending(path: "workspace.json") + ) + try Data("preserved".utf8).write(to: migratedWorkspaceURL.appending(path: "marker")) + + try withDependencies { + $0.context = .live + $0._fxcodexPaths = paths + } operation: { + let migrator = Migrator(fileManager: .default) + #expect(try migrator.migrationPlan()?.sourceVersion == .v1_0) + + try migrator.migrateIfNeeded() + + let storage = WorkspacesStorage(fileManager: .default) + #expect(try storage.workspace(named: "work").id == workID) + #expect(try storage.currentWorkspaceID() == workID) + } + + #expect(FileManager.default.fileExists( + atPath: migratedWorkspaceURL.appending(path: "marker").path + )) + #expect(!FileManager.default.fileExists(atPath: paths.migrationURL.path)) + } + + @Test("Rejects conflicting legacy and migrated workspace directories") + func conflictingDirectories() throws { + let fixture = try ClientTestFixture() + defer { fixture.remove() } + let paths = FXCodexPaths(rootURL: fixture.rootURL) + let primaryID = try #require(WorkspaceID("00000000-0000-0000-0000-000000000001")) + let workID = try #require(WorkspaceID("00000000-0000-0000-0000-000000000002")) + + try Self.writeLegacyConfiguration(currentWorkspaceName: "work", to: paths.configurationURL) + try Self.writeJournal( + primaryID: primaryID, + workspaceIDs: ["work": workID], + to: paths.migrationURL + ) + try FileManager.default.createDirectory( + at: paths.workspacesURL.appending(path: "work"), + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: paths.workspacesURL.appending(path: workID.rawValue), + withIntermediateDirectories: true + ) + + withDependencies { + $0.context = .live + $0._fxcodexPaths = paths + } operation: { + #expect(throws: FXCodexError.invalidStorage( + "both legacy and migrated directories exist for workspace work" + )) { + try Migrator(fileManager: .default).migrateIfNeeded() + } + } + } + + @Test("Rejects unsupported and malformed migration sources") + func invalidSources() throws { + let unsupportedFixture = try ClientTestFixture() + defer { unsupportedFixture.remove() } + let unsupportedPaths = FXCodexPaths(rootURL: unsupportedFixture.rootURL) + try JSONSerialization.data( + withJSONObject: ["schema_version": "3.0"], + options: [.prettyPrinted, .sortedKeys] + ).write(to: unsupportedPaths.configurationURL) + + withDependencies { + $0.context = .live + $0._fxcodexPaths = unsupportedPaths + } operation: { + #expect(throws: FXCodexError.unsupportedSchemaVersion(.init(major: 3, minor: 0))) { + _ = try Migrator(fileManager: .default).migrationPlan() + } + } + + let invalidFixture = try ClientTestFixture() + defer { invalidFixture.remove() } + let invalidPaths = FXCodexPaths(rootURL: invalidFixture.rootURL) + try Self.writeLegacyConfiguration(currentWorkspaceName: "missing", to: invalidPaths.configurationURL) + try FileManager.default.createDirectory( + at: invalidPaths.workspacesURL, + withIntermediateDirectories: true + ) + + withDependencies { + $0.context = .live + $0._fxcodexPaths = invalidPaths + } operation: { + #expect(throws: FXCodexError.invalidStorage( + "current_workspace_name does not reference an existing schema 1.0 workspace" + )) { + _ = try Migrator(fileManager: .default).migrationPlan() + } + } + } + + private static func writeLegacyConfiguration( + currentWorkspaceName: String, + to url: URL + ) throws { + try JSONSerialization.data( + withJSONObject: ["current_workspace_name": currentWorkspaceName], + options: [.prettyPrinted, .sortedKeys] + ).write(to: url) + } + + private static func writeJournal( + primaryID: WorkspaceID, + workspaceIDs: [String: WorkspaceID], + to url: URL + ) throws { + try Self.write( + MigrationJournal( + sourceVersion: .v1_0, + destinationVersion: .v2_0, + primaryWorkspaceID: primaryID, + workspaceIDs: workspaceIDs + ), + to: url + ) + } + + private static func write(_ value: Value, to url: URL) throws { + let encoder = FXCodexJSONCoding.encoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(value).write(to: url, options: [.atomic]) + } +} diff --git a/Tests/fxcodex-client-tests/Storage/PreferencesStorageTests.swift b/Tests/fxcodex-client-tests/Storage/PreferencesStorageTests.swift index 9f76cf7..3b47203 100644 --- a/Tests/fxcodex-client-tests/Storage/PreferencesStorageTests.swift +++ b/Tests/fxcodex-client-tests/Storage/PreferencesStorageTests.swift @@ -1,7 +1,9 @@ import Dependencies import Foundation import Testing -@_spi(Internals) @testable import FXCodexClient +@_spi(Internals) +@testable +import FXCodexClient @Suite("Preferences storage") struct PreferencesStorageTests { diff --git a/Tests/fxcodex-client-tests/Storage/UpdateCheckStorageTests.swift b/Tests/fxcodex-client-tests/Storage/UpdateCheckStorageTests.swift index 9c60ad1..46acacd 100644 --- a/Tests/fxcodex-client-tests/Storage/UpdateCheckStorageTests.swift +++ b/Tests/fxcodex-client-tests/Storage/UpdateCheckStorageTests.swift @@ -1,7 +1,9 @@ import Dependencies import Foundation import Testing -@_spi(Internals) @testable import FXCodexClient +@_spi(Internals) +@testable +import FXCodexClient @Suite("Update check storage") struct UpdateCheckStorageTests { diff --git a/Tests/fxcodex-client-tests/Storage/WorkspacesStorageTests.swift b/Tests/fxcodex-client-tests/Storage/WorkspacesStorageTests.swift index c6120c6..e4cada7 100644 --- a/Tests/fxcodex-client-tests/Storage/WorkspacesStorageTests.swift +++ b/Tests/fxcodex-client-tests/Storage/WorkspacesStorageTests.swift @@ -1,7 +1,9 @@ import Dependencies import Foundation import Testing -@_spi(Internals) @testable import FXCodexClient +@_spi(Internals) +@testable +import FXCodexClient @Suite("Workspace storage") struct WorkspacesStorageTests { @@ -28,9 +30,11 @@ struct WorkspacesStorageTests { JSONSerialization.jsonObject(with: configurationData) as? [String: Any] ) #expect(Set(configuration.keys) == [ - "current_workspace_name", - "workspace_integrations", + "schema_version", + "current_workspace_id", + "integrations", ]) + #expect(configuration["current_workspace_id"] as? String == workspace.id.rawValue) let persistedWorkspace: Workspace = try storage.workspace(named: workspace.name) #expect(persistedWorkspace.integrations == workspace.integrations) @@ -87,7 +91,7 @@ struct WorkspacesStorageTests { } } - @Test("Erase preserves workspace directories while removing their contents and metadata") + @Test("Erase preserves workspace directories and integration metadata while removing managed data") func eraseWorkspace() async throws { let fixture: ClientTestFixture = try .init() defer { fixture.remove() } @@ -127,7 +131,7 @@ struct WorkspacesStorageTests { ) let erasedWorkspace: Workspace = try storage.eraseWorkspace(named: workspace.name) - #expect(erasedWorkspace.integrations.isEmpty) + #expect(erasedWorkspace.integrations == workspace.integrations) #expect(try storage.currentWorkspaceName() == workspace.name) #expect(try FileManager.default.contentsOfDirectory(atPath: codexHomeURL.path).isEmpty) #expect(try FileManager.default.contentsOfDirectory(atPath: userDataURL.path).isEmpty) diff --git a/Tests/fxcodex-client-tests/Update/GitHubReleaseUpdaterTests.swift b/Tests/fxcodex-client-tests/Update/GitHubReleaseUpdaterTests.swift index 85c413c..8dc8e71 100644 --- a/Tests/fxcodex-client-tests/Update/GitHubReleaseUpdaterTests.swift +++ b/Tests/fxcodex-client-tests/Update/GitHubReleaseUpdaterTests.swift @@ -1,13 +1,16 @@ import CryptoKit import Foundation import Testing -@testable import FXCodexClient +@testable +import FXCodexClient @Suite("GitHub release updater", .serialized) struct GitHubReleaseUpdaterTests { @Test("Selects releases using constraints anchored at an optional minimum version") func selection() async throws { let currentVersion: SemanticVersion = try #require(.init("1.2.3")) + let minimumMinorVersion: SemanticVersion = try #require(.init("1.3.0")) + let minimumMajorVersion: SemanticVersion = try #require(.init("2.0.0")) let releases: [GitHubReleaseUpdater.Release] = [ Self.release("v1.2.4"), Self.release("v1.3.0"), @@ -46,19 +49,19 @@ struct GitHubReleaseUpdaterTests { releases, currentVersion, .patch, - try #require(.init("1.3.0")) + minimumMinorVersion ) == "1.3.5") #expect(Self.selectedVersion( releases, currentVersion, .minor, - try #require(.init("1.3.0")) + minimumMinorVersion ) == "1.4.0") #expect(Self.selectedVersion( releases, currentVersion, .major, - try #require(.init("2.0.0")) + minimumMajorVersion ) == "2.0.0") }