diff --git a/.beads/.gitignore b/.beads/.gitignore index 96620144..4a7a77df 100644 --- a/.beads/.gitignore +++ b/.beads/.gitignore @@ -11,6 +11,7 @@ daemon.log daemon.pid bd.sock sync-state.json +last-touched # Local version tracking (prevents upgrade notification spam after git ops) .local_version @@ -19,6 +20,10 @@ sync-state.json db.sqlite bd.db +# Worktree redirect file (contains relative path to main repo's .beads/) +# Must not be committed as paths would be wrong in other clones +redirect + # Merge artifacts (temporary files from 3-way merge) beads.base.jsonl beads.base.meta.json @@ -27,8 +32,8 @@ beads.left.meta.json beads.right.jsonl beads.right.meta.json -# Keep JSONL exports and config (source of truth for git) -!issues.jsonl -!interactions.jsonl -!metadata.json -!config.json +# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here. +# They would override fork protection in .git/info/exclude, allowing +# contributors to accidentally commit upstream issue databases. +# The JSONL files (issues.jsonl, interactions.jsonl) and config files +# are tracked by git by default since no pattern above ignores them. diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 311b096b..370b805c 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -53,6 +53,7 @@ {"id":"Grace-f1u.1","title":"Add PAT SDK wrappers","description":"Implement Grace.SDK PersonalAccessToken wrappers for create/list/revoke and update project compile list.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T17:35:17.2437547-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:49:41.0114696-08:00","closed_at":"2026-01-01T17:49:41.0114696-08:00","close_reason":"Completed","dependencies":[{"issue_id":"Grace-f1u.1","depends_on_id":"Grace-f1u","type":"parent-child","created_at":"2026-01-01T17:35:17.2831975-08:00","created_by":"daemon"}]} {"id":"Grace-f1u.2","title":"Ensure PAT SDK uses GRACE_SERVER_URI","description":"Make PAT SDK endpoints work without graceconfig.json by using GRACE_SERVER_URI or a helper that bypasses Configuration.Current().","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T17:35:24.0343531-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:49:49.2757481-08:00","closed_at":"2026-01-01T17:49:49.2757481-08:00","close_reason":"Completed","dependencies":[{"issue_id":"Grace-f1u.2","depends_on_id":"Grace-f1u","type":"parent-child","created_at":"2026-01-01T17:35:24.0786964-08:00","created_by":"daemon"}]} {"id":"Grace-f2t","title":"Server: derived computation trigger consumer","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:49.3862902-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T01:44:59.6212182-08:00","closed_at":"2026-01-06T01:44:59.6212182-08:00","close_reason":"Closed"} +{"id":"Grace-far","title":"Allow grace connect without config when parse errors occur","status":"closed","priority":2,"issue_type":"bug","created_at":"2026-01-09T02:38:47.1058823-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T02:40:29.8344153-08:00","closed_at":"2026-01-09T02:40:29.8344153-08:00","close_reason":"Closed"} {"id":"Grace-g11","title":"SDK: Queue/Candidate APIs","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:54.2524092-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T02:56:38.0685742-08:00","closed_at":"2026-01-06T02:56:38.0685742-08:00","close_reason":"Closed"} {"id":"Grace-g4d","title":"Types: Work Item contracts","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:47.0471393-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T01:20:50.4717585-08:00","closed_at":"2026-01-06T01:20:50.4717585-08:00","close_reason":"Closed"} {"id":"Grace-hj3","title":"Types: RequiredAction + Event envelope contracts","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:48.2364827-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T01:29:04.1667329-08:00","closed_at":"2026-01-06T01:29:04.1667329-08:00","close_reason":"Closed"} diff --git a/.beads/last-touched b/.beads/last-touched index 1c5f48e5..8ebb4be7 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -Grace-4g4 +Grace-far diff --git a/.grace/graceconfig.json b/.grace/graceconfig.json new file mode 100644 index 00000000..2e1028e7 --- /dev/null +++ b/.grace/graceconfig.json @@ -0,0 +1,38 @@ +{ + "OwnerName": "", + "OrganizationName": "", + "RepositoryName": "", + "BranchName": "", + "DefaultBranchName": "", + "Themes": [ + { + "Name": "Default", + "DisplayColorOptions": { + "added": "#00AF5F", + "changed": "#800080", + "deemphasized": "#808080", + "deleted": "#8B0000", + "error": "#FF0000", + "highlighted": "#FFFFFF", + "important": "#E5C07B", + "verbose": "#FF7E00" + } + } + ], + "LineEndings": "PlatformDependent", + "Prefetch": [ + "" + ], + "RootDirectory": "D:\\Source\\Grace", + "StandardizedRootDirectory": "D:/Source/Grace", + "GraceDirectory": "D:\\Source\\Grace", + "ObjectDirectory": "D:\\Source\\Grace", + "GraceStatusFile": "gracestatus.msgpack", + "GraceObjectCacheFile": "graceObjectCache.msgpack", + "DirectoryVersionCache": "D:\\Source\\Grace", + "ConfigurationDirectory": "D:\\Source\\Grace", + "ObjectStorageProvider": "unknown", + "ServerUri": "http://127.0.0.1:5000", + "ProgramVersion": "0.1", + "ConfigurationVersion": "" +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 2f7e5ae5..b6ba9742 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,12 @@ # Agent Instructions -Other `AGENTS.md` files exist in subdirectories, refer to them for more specific context. +Other `AGENTS.md` files exist in subdirectories, refer to them for more specific +context. ## Issue Tracking This project uses **bd (beads)** for issue tracking. +Always use `bd` commands to manage your work. Run `bd prime` for workflow context, or install hooks (`bd hooks install`) for auto-injection. **Quick reference:** diff --git a/src/Grace.Actors/Constants.Actor.fs b/src/Grace.Actors/Constants.Actor.fs index 0677de3b..f080efd9 100644 --- a/src/Grace.Actors/Constants.Actor.fs +++ b/src/Grace.Actors/Constants.Actor.fs @@ -116,6 +116,9 @@ module Constants = [] let Branch = "Branch" + [] + let ConflictReceipt = "ConflictReceipt" + [] let Diff = "Diff" @@ -129,16 +132,13 @@ module Constants = let FileAppearance = "FileApp" [] - let NamedSection = "NamedSection" + let GateAttestation = "GateAttestation" [] let IntegrationCandidate = "IntegrationCandidate" [] - let GateAttestation = "GateAttestation" - - [] - let ConflictReceipt = "ConflictReceipt" + let NamedSection = "NamedSection" [] let Organization = "Organization" diff --git a/src/Grace.Actors/DirectoryVersion.Actor.fs b/src/Grace.Actors/DirectoryVersion.Actor.fs index a73f345c..6f135d69 100644 --- a/src/Grace.Actors/DirectoryVersion.Actor.fs +++ b/src/Grace.Actors/DirectoryVersion.Actor.fs @@ -518,8 +518,9 @@ module DirectoryVersion = tags.Add("RecursiveSize", $"{directoryVersionDto.RecursiveSize}") // Write the JSON using MessagePack serialization for efficiency. - let blockBlobOpenWriteOptions = BlockBlobOpenWriteOptions(Tags = tags) - blockBlobOpenWriteOptions.HttpHeaders.ContentType <- "application/msgpack" + let blockBlobOpenWriteOptions = + BlockBlobOpenWriteOptions(Tags = tags, HttpHeaders = BlobHttpHeaders(ContentType = "application/msgpack")) + use! blobStream = directoryVersionBlobClient.OpenWriteAsync(overwrite = true, options = blockBlobOpenWriteOptions) do! MessagePackSerializer.SerializeAsync(blobStream, subdirectoryVersionsList, messagePackSerializerOptions) do! blobStream.DisposeAsync() diff --git a/src/Grace.Aspire.AppHost/Program.Aspire.AppHost.cs b/src/Grace.Aspire.AppHost/Program.Aspire.AppHost.cs index 445e739c..d03ce760 100644 --- a/src/Grace.Aspire.AppHost/Program.Aspire.AppHost.cs +++ b/src/Grace.Aspire.AppHost/Program.Aspire.AppHost.cs @@ -332,6 +332,9 @@ private static void Main(string[] args) .WithEnvironment(EnvironmentVariables.AzureServiceBusTopic, configuration["Grace:ServiceBus:TopicName"] ?? "graceeventstream") .WithEnvironment(EnvironmentVariables.AzureServiceBusSubscription, configuration["Grace:ServiceBus:SubscriptionName"] ?? "grace-server") .WithEnvironment(EnvironmentVariables.GraceLogDirectory, publishLogDirectory) + .WithEnvironment(EnvironmentVariables.GraceAuthOidcAuthority, configuration[EnvironmentVariables.GraceAuthOidcAuthority]) + .WithEnvironment(EnvironmentVariables.GraceAuthOidcAudience, configuration[EnvironmentVariables.GraceAuthOidcAudience]) + .WithEnvironment(EnvironmentVariables.GraceAuthOidcCliClientId, configuration[EnvironmentVariables.GraceAuthOidcCliClientId]) .WithHttpEndpoint(targetPort: 5000, name: "http") .WithHttpsEndpoint(targetPort: 5001, name: "https") .AsHttp2Service() diff --git a/src/Grace.CLI.Tests/Auth.Tests.fs b/src/Grace.CLI.Tests/Auth.Tests.fs index dd45bca1..9cf49bc8 100644 --- a/src/Grace.CLI.Tests/Auth.Tests.fs +++ b/src/Grace.CLI.Tests/Auth.Tests.fs @@ -26,7 +26,8 @@ module AuthTests = withEnv Constants.EnvironmentVariables.GraceAuthOidcAudience None (fun () -> withEnv Constants.EnvironmentVariables.GraceAuthOidcCliClientId None (fun () -> withEnv Constants.EnvironmentVariables.GraceAuthOidcM2mClientId None (fun () -> - withEnv Constants.EnvironmentVariables.GraceAuthOidcM2mClientSecret None action)))) + withEnv Constants.EnvironmentVariables.GraceAuthOidcM2mClientSecret None (fun () -> + withEnv Constants.EnvironmentVariables.GraceServerUri None action))))) [] let ``tryGetAccessToken returns Error when auth is not configured`` () = diff --git a/src/Grace.CLI.Tests/CommandParsing.Tests.fs b/src/Grace.CLI.Tests/CommandParsing.Tests.fs new file mode 100644 index 00000000..80ad056c --- /dev/null +++ b/src/Grace.CLI.Tests/CommandParsing.Tests.fs @@ -0,0 +1,31 @@ +namespace Grace.CLI.Tests + +open FsUnit +open Grace.CLI +open NUnit.Framework + +[] +module CommandParsingTests = + [] + let ``top level command returns none for empty args`` () = + GraceCommand.tryGetTopLevelCommandFromArgs Array.empty true |> should equal None + + [] + let ``top level command detects command token`` () = + GraceCommand.tryGetTopLevelCommandFromArgs [| "connect"; "owner/org/repo" |] true + |> should equal (Some "connect") + + [] + let ``top level command skips output option`` () = + GraceCommand.tryGetTopLevelCommandFromArgs [| "--output"; "Verbose"; "connect" |] true + |> should equal (Some "connect") + + [] + let ``top level command skips correlation id option`` () = + GraceCommand.tryGetTopLevelCommandFromArgs [| "-c"; "abc123"; "connect" |] true + |> should equal (Some "connect") + + [] + let ``top level command honors end of options marker`` () = + GraceCommand.tryGetTopLevelCommandFromArgs [| "--output"; "Verbose"; "--"; "connect" |] true + |> should equal (Some "connect") diff --git a/src/Grace.CLI.Tests/Connect.Tests.fs b/src/Grace.CLI.Tests/Connect.Tests.fs new file mode 100644 index 00000000..f28789e9 --- /dev/null +++ b/src/Grace.CLI.Tests/Connect.Tests.fs @@ -0,0 +1,138 @@ +namespace Grace.CLI.Tests + +open FsUnit +open Grace.CLI +open Grace.CLI.Command +open Grace.CLI.Text +open Grace.Shared.Client.Configuration +open Grace.Types.Branch +open Grace.Types.Reference +open Grace.Types.Types +open NUnit.Framework +open Spectre.Console +open System +open System.IO + +[] +module ConnectTests = + let private setAnsiConsoleOutput (writer: TextWriter) = + let settings = AnsiConsoleSettings() + settings.Out <- AnsiConsoleOutput(writer) + AnsiConsole.Console <- AnsiConsole.Create(settings) + let private runWithCapturedOutput (args: string array) = + use writer = new StringWriter() + let originalOut = Console.Out + + try + Console.SetOut(writer) + setAnsiConsoleOutput writer + let exitCode = GraceCommand.main args + exitCode, writer.ToString() + finally + Console.SetOut(originalOut) + setAnsiConsoleOutput originalOut + + let private withTempDir (action: string -> unit) = + let tempDir = Path.Combine(Path.GetTempPath(), $"grace-cli-tests-{Guid.NewGuid():N}") + Directory.CreateDirectory(tempDir) |> ignore + let originalDir = Environment.CurrentDirectory + + try + Environment.CurrentDirectory <- tempDir + action tempDir + finally + Environment.CurrentDirectory <- originalDir + + if Directory.Exists(tempDir) then + try + Directory.Delete(tempDir, true) + with _ -> + () + + let private getGraceConfigPath root = Path.Combine(root, ".grace", "graceconfig.json") + + [] + let ``connect creates config when missing`` () = + withTempDir (fun root -> + let exitCode, _ = runWithCapturedOutput [| "connect" |] + exitCode |> should equal -1 + File.Exists(getGraceConfigPath root) |> should equal true) + + [] + let ``connect retrieve default branch defaults to true`` () = + let parseResult = GraceCommand.rootCommand.Parse([| "connect" |]) + + parseResult.GetValue(OptionName.RetrieveDefaultBranch) + |> should equal true + + [] + let ``connect retrieve default branch parses explicit false`` () = + let parseResult = GraceCommand.rootCommand.Parse([| "connect"; OptionName.RetrieveDefaultBranch; "false" |]) + + parseResult.GetValue(OptionName.RetrieveDefaultBranch) + |> should equal false + + [] + let ``connect directory version selection precedence uses directory version id`` () = + let directoryVersionId = Guid.NewGuid() + let referenceId = Guid.NewGuid() + + let parseResult = + GraceCommand.rootCommand.Parse( + [| "connect" + OptionName.DirectoryVersionId + $"{directoryVersionId}" + OptionName.ReferenceId + $"{referenceId}" + OptionName.ReferenceType + "Commit" |] + ) + + match Connect.getDirectoryVersionSelection parseResult with + | Connect.UseDirectoryVersionId selected -> selected |> should equal directoryVersionId + | other -> Assert.Fail($"Unexpected selection: {other}") + + [] + let ``connect default directory version falls back to based-on`` () = + let basedOnId = Guid.NewGuid() + let branchDto = { BranchDto.Default with LatestPromotion = ReferenceDto.Default; BasedOn = { ReferenceDto.Default with DirectoryId = basedOnId } } + + Connect.resolveDefaultDirectoryVersionId branchDto + |> should equal (Some basedOnId) + + [] + let ``connect repository shortcut populates owner organization repository`` () = + let parseResult = GraceCommand.rootCommand.Parse([| "connect"; "owner/org/repo" |]) + let graceIds = GraceIds.Default + + match Connect.applyRepositoryShortcut parseResult graceIds with + | Ok updated -> + updated.OwnerName |> should equal "owner" + updated.OrganizationName |> should equal "org" + updated.RepositoryName |> should equal "repo" + updated.OwnerId |> should equal Guid.Empty + updated.OrganizationId |> should equal Guid.Empty + updated.RepositoryId |> should equal Guid.Empty + updated.HasOwner |> should equal true + updated.HasOrganization |> should equal true + updated.HasRepository |> should equal true + | Error error -> Assert.Fail($"Unexpected error: {error.Error}") + + [] + let ``connect repository shortcut rejects missing segments`` () = + let parseResult = GraceCommand.rootCommand.Parse([| "connect"; "owner/repo" |]) + let graceIds = GraceIds.Default + + match Connect.applyRepositoryShortcut parseResult graceIds with + | Ok _ -> Assert.Fail("Expected error when repository shortcut is missing segments.") + | Error error -> error.Error |> should contain "owner/organization/repository" + + [] + let ``connect repository shortcut conflicts with explicit options`` () = + let parseResult = GraceCommand.rootCommand.Parse([| "connect"; "owner/org/repo"; OptionName.OwnerName; "explicit-owner" |]) + + let graceIds = GraceIds.Default + + match Connect.applyRepositoryShortcut parseResult graceIds with + | Ok _ -> Assert.Fail("Expected error when shortcut is combined with explicit options.") + | Error error -> error.Error |> should contain "Provide either the repository shortcut" diff --git a/src/Grace.CLI.Tests/Grace.CLI.Tests.fsproj b/src/Grace.CLI.Tests/Grace.CLI.Tests.fsproj index e6b3b505..9688c9a6 100644 --- a/src/Grace.CLI.Tests/Grace.CLI.Tests.fsproj +++ b/src/Grace.CLI.Tests/Grace.CLI.Tests.fsproj @@ -14,6 +14,8 @@ + + diff --git a/src/Grace.CLI.Tests/HelpDoesNotReadConfig.Tests.fs b/src/Grace.CLI.Tests/HelpDoesNotReadConfig.Tests.fs index dd980fdb..9d7b9d23 100644 --- a/src/Grace.CLI.Tests/HelpDoesNotReadConfig.Tests.fs +++ b/src/Grace.CLI.Tests/HelpDoesNotReadConfig.Tests.fs @@ -5,21 +5,42 @@ open Grace.CLI open Grace.Shared.Client.Configuration open Grace.Shared.Utilities open NUnit.Framework +open Spectre.Console open System open System.IO [] module HelpDoesNotReadConfigTests = + let private setAnsiConsoleOutput (writer: TextWriter) = + let settings = AnsiConsoleSettings() + settings.Out <- AnsiConsoleOutput(writer) + AnsiConsole.Console <- AnsiConsole.Create(settings) + let private runWithCapturedOutput (args: string array) = use writer = new StringWriter() let originalOut = Console.Out try Console.SetOut(writer) + setAnsiConsoleOutput writer let exitCode = GraceCommand.main args exitCode, writer.ToString() finally Console.SetOut(originalOut) + setAnsiConsoleOutput originalOut + + let private captureOutput (action: unit -> unit) = + use writer = new StringWriter() + let originalOut = Console.Out + + try + Console.SetOut(writer) + setAnsiConsoleOutput writer + action () + writer.ToString() + finally + Console.SetOut(originalOut) + setAnsiConsoleOutput originalOut let private withTempDir (action: string -> unit) = @@ -29,6 +50,7 @@ module HelpDoesNotReadConfigTests = try Environment.CurrentDirectory <- tempDir + resetConfiguration () action tempDir finally Environment.CurrentDirectory <- originalDir @@ -91,6 +113,25 @@ module HelpDoesNotReadConfigTests = output |> should contain "[default: new Guid]" output |> should not' (contain "00000000-0000-0000-0000-0000000000000")) + [] + let ``verbose parse result shows resolved ids`` () = + withTempDir (fun root -> + let ownerId = Guid.NewGuid() + let orgId = Guid.NewGuid() + let repoId = Guid.NewGuid() + let branchId = Guid.NewGuid() + writeValidConfig root ownerId orgId repoId branchId + + let parseResult = GraceCommand.rootCommand.Parse([| "access"; "grant-role" |]) + + let output = captureOutput (fun () -> Common.printParseResult parseResult) + + output |> should contain "Resolved values:" + output |> should contain $"{ownerId}" + output |> should contain $"{orgId}" + output |> should contain $"{repoId}" + output |> should contain $"{branchId}") + [] let ``getNormalizedIdsAndNames falls back to config ids`` () = withTempDir (fun root -> diff --git a/src/Grace.CLI/AGENTS.md b/src/Grace.CLI/AGENTS.md index 26c05f14..0e37bffe 100644 --- a/src/Grace.CLI/AGENTS.md +++ b/src/Grace.CLI/AGENTS.md @@ -35,10 +35,14 @@ Read `../AGENTS.md` for global expectations before updating CLI code. - `grace history` commands operate without requiring a repo `graceconfig.json`. Avoid `Configuration.Current()` in history-related flows. +- `grace connect` accepts a positional shortcut in the form + `owner/organization/repository`; do not combine it with explicit owner, + organization, or repository options. ## Continuous Review Commands -- `grace work` covers create/show/status and linking references or promotion groups. +- `grace work` covers create/show/status and linking references or promotion + groups. - `grace review` covers inbox/open/checkpoint/delta/resolve/deepen. Inbox and delta remain CLI stubs until server endpoints land. - `grace queue` covers status/enqueue/pause/resume/dequeue/retry; prefer diff --git a/src/Grace.CLI/Command/Auth.CLI.fs b/src/Grace.CLI/Command/Auth.CLI.fs index f44751fb..1edf31c3 100644 --- a/src/Grace.CLI/Command/Auth.CLI.fs +++ b/src/Grace.CLI/Command/Auth.CLI.fs @@ -85,33 +85,52 @@ module Auth = let private defaultCliScopes () = [ "openid"; "profile"; "email"; "offline_access" ] - let private tryGetOidcCliConfig () = + let private buildOidcCliConfig (authority: string) (audience: string) (clientId: string) = + let redirectPort = + match tryGetEnv Constants.EnvironmentVariables.GraceAuthOidcCliRedirectPort with + | Some raw -> + match Int32.TryParse raw with + | true, parsed when parsed > 0 -> parsed + | _ -> 8391 + | None -> 8391 + + let scopes = + match tryGetEnv Constants.EnvironmentVariables.GraceAuthOidcCliScopes with + | Some raw when not (String.IsNullOrWhiteSpace raw) -> parseScopes raw + | _ -> defaultCliScopes () + + { Authority = normalizeAuthority authority; Audience = audience.Trim(); ClientId = clientId.Trim(); RedirectPort = redirectPort; Scopes = scopes } + + let private tryGetOidcCliConfigFromEnv () = match tryGetEnv Constants.EnvironmentVariables.GraceAuthOidcAuthority, tryGetEnv Constants.EnvironmentVariables.GraceAuthOidcAudience, tryGetEnv Constants.EnvironmentVariables.GraceAuthOidcCliClientId with - | Some authority, Some audience, Some clientId -> - let redirectPort = - match tryGetEnv Constants.EnvironmentVariables.GraceAuthOidcCliRedirectPort with - | Some raw -> - match Int32.TryParse raw with - | true, parsed when parsed > 0 -> parsed - | _ -> 8391 - | None -> 8391 + | Some authority, Some audience, Some clientId -> Some(buildOidcCliConfig authority audience clientId) + | _ -> None - let scopes = - match tryGetEnv Constants.EnvironmentVariables.GraceAuthOidcCliScopes with - | Some raw when not (String.IsNullOrWhiteSpace raw) -> parseScopes raw - | _ -> defaultCliScopes () + let private tryGetOidcCliConfigFromServer (correlationId: string) = + task { + match tryGetEnv Constants.EnvironmentVariables.GraceServerUri with + | None -> return Ok None + | Some _ -> + let parameters = CommonParameters(CorrelationId = correlationId) + let! result = Grace.SDK.Auth.getOidcClientConfig parameters - Some - { Authority = normalizeAuthority authority - Audience = audience.Trim() - ClientId = clientId.Trim() - RedirectPort = redirectPort - Scopes = scopes } - | _ -> None + match result with + | Ok graceReturnValue -> + let config = graceReturnValue.ReturnValue + return Ok(Some(buildOidcCliConfig config.Authority config.Audience config.CliClientId)) + | Error error -> return Error error + } + + let private tryGetOidcCliConfig (correlationId: string) = + task { + match tryGetOidcCliConfigFromEnv () with + | Some config -> return Ok(Some config) + | None -> return! tryGetOidcCliConfigFromServer correlationId + } let private tryGetOidcM2mConfig () = match @@ -678,14 +697,18 @@ module Auth = | Error message -> return Error message | Ok token -> return Ok(Some token.AccessToken) | None -> - match tryGetOidcCliConfig () with - | None -> + let correlationId = ensureNonEmptyCorrelationId String.Empty + let! cliConfigResult = tryGetOidcCliConfig correlationId + + match cliConfigResult with + | Ok None -> return Error $"Authentication is not configured. Set {Constants.EnvironmentVariables.GraceAuthOidcAuthority}, {Constants.EnvironmentVariables.GraceAuthOidcAudience}, and {Constants.EnvironmentVariables.GraceAuthOidcCliClientId} (or provide GRACE_TOKEN / M2M credentials)." - | Some cliConfig -> + | Ok(Some cliConfig) -> let! tokenResult = tryGetInteractiveTokenAsync cliConfig return tokenResult + | Error error -> return Error error.Error } let private tryGetAccessTokenForSdk () = @@ -818,8 +841,10 @@ module Auth = task { let correlationId = parseResult |> getCorrelationId - match tryGetOidcCliConfig () with - | None -> + let! cliConfigResult = tryGetOidcCliConfig correlationId + + match cliConfigResult with + | Ok None -> return Error( GraceError.Create @@ -827,7 +852,7 @@ module Auth = correlationId ) |> renderOutput parseResult - | Some config -> + | Ok(Some config) -> let desiredAuth = let raw = parseResult.GetValue(LoginOptions.auth) @@ -884,6 +909,7 @@ module Auth = return Ok(GraceReturnValue.Create "Authenticated." correlationId) |> renderOutput parseResult + | Error error -> return Error error |> renderOutput parseResult } type Status() = @@ -913,7 +939,16 @@ module Auth = let m2mConfigured = tryGetOidcM2mConfig () |> Option.isSome - let cliConfig = tryGetOidcCliConfig () + let! cliConfigResult = tryGetOidcCliConfig correlationId + let mutable configError: string option = None + + let cliConfig = + match cliConfigResult with + | Ok value -> value + | Error error -> + configError <- Some error.Error + None + let mutable interactiveBundle: TokenBundle option = None let mutable secureStoreError: string option = None @@ -973,6 +1008,10 @@ module Auth = | Some message -> AnsiConsole.MarkupLine($"[{Colors.Important}]Secure storage:[/] {Markup.Escape(message)}") | None -> () + match configError with + | Some message -> AnsiConsole.MarkupLine($"[{Colors.Important}]Auth config:[/] {Markup.Escape(message)}") + | None -> () + if not (List.isEmpty deprecatedSettings) then let joined = String.Join(", ", deprecatedSettings) AnsiConsole.MarkupLine($"[{Colors.Important}]Deprecated Microsoft settings ignored:[/] {Markup.Escape(joined)}") @@ -991,12 +1030,14 @@ module Auth = task { let correlationId = parseResult |> getCorrelationId - match tryGetOidcCliConfig () with - | None -> + let! cliConfigResult = tryGetOidcCliConfig correlationId + + match cliConfigResult with + | Ok None -> return Error(GraceError.Create "Interactive authentication is not configured." correlationId) |> renderOutput parseResult - | Some config -> + | Ok(Some config) -> let! storeResult = verifySecureStoreAsync config match storeResult with @@ -1010,6 +1051,7 @@ module Auth = return Ok(GraceReturnValue.Create "Signed out." correlationId) |> renderOutput parseResult + | Error error -> return Error error |> renderOutput parseResult } type WhoAmI() = diff --git a/src/Grace.CLI/Command/Common.CLI.fs b/src/Grace.CLI/Command/Common.CLI.fs index e779e20d..9f4cbc05 100644 --- a/src/Grace.CLI/Command/Common.CLI.fs +++ b/src/Grace.CLI/Command/Common.CLI.fs @@ -240,6 +240,54 @@ module Common = /// Rewrites "[" to "[[" and "]" to "]]". let escapeBrackets s = s.ToString().Replace("[", "[[").Replace("]", "]]") + let private resolvedValueOptionNames = + [ OptionName.OwnerId + OptionName.OwnerName + OptionName.OrganizationId + OptionName.OrganizationName + OptionName.RepositoryId + OptionName.RepositoryName + OptionName.BranchId + OptionName.BranchName ] + + let private shouldShowResolvedValues (parseResult: ParseResult) = resolvedValueOptionNames |> List.exists (isOptionPresent parseResult) + + let private tryBuildResolvedValuesText (parseResult: ParseResult) = + if + isNull parseResult + || not (configurationFileExists ()) + || not (shouldShowResolvedValues parseResult) + then + None + else + let graceIds = Services.getNormalizedIdsAndNames parseResult + let sb = stringBuilderPool.Get() + + try + let appendLine label value = sb.AppendLine($"{label}: {value}") |> ignore + + let appendName label (value: string) = if not <| String.IsNullOrWhiteSpace(value) then appendLine label value + + if graceIds.HasOwner then + appendLine "OwnerId" graceIds.OwnerId + appendName "OwnerName" graceIds.OwnerName + + if graceIds.HasOrganization then + appendLine "OrganizationId" graceIds.OrganizationId + appendName "OrganizationName" graceIds.OrganizationName + + if graceIds.HasRepository then + appendLine "RepositoryId" graceIds.RepositoryId + appendName "RepositoryName" graceIds.RepositoryName + + if graceIds.HasBranch then + appendLine "BranchId" graceIds.BranchId + appendName "BranchName" graceIds.BranchName + + if sb.Length > 0 then Some(sb.ToString()) else None + finally + stringBuilderPool.Return sb + /// Prints the ParseResult with markup. let printParseResult (parseResult: ParseResult) = if not <| isNull parseResult then @@ -279,6 +327,13 @@ module Common = AnsiConsole.MarkupLine($"[{Colors.Verbose}]Parameter values:[/]") AnsiConsole.MarkupLine($"[{Colors.Verbose}]{escapeBrackets (sb.ToString())}[/]") AnsiConsole.WriteLine() + + match tryBuildResolvedValuesText parseResult with + | Some resolvedValues -> + AnsiConsole.MarkupLine($"[{Colors.Verbose}]Resolved values:[/]") + AnsiConsole.MarkupLine($"[{Colors.Verbose}]{escapeBrackets resolvedValues}[/]") + AnsiConsole.WriteLine() + | None -> () finally stringBuilderPool.Return sb diff --git a/src/Grace.CLI/Command/Connect.CLI.fs b/src/Grace.CLI/Command/Connect.CLI.fs index 56fe27b0..1aeb6db9 100644 --- a/src/Grace.CLI/Command/Connect.CLI.fs +++ b/src/Grace.CLI/Command/Connect.CLI.fs @@ -7,7 +7,10 @@ open Grace.CLI.Text open Grace.SDK open Grace.Shared open Grace.Shared.Client.Configuration +open Grace.Shared.Utilities open Grace.Types.Owner +open Grace.Types.Branch +open Grace.Types.Reference open Grace.Types.Types open Grace.Shared.Validation.Common open Grace.Shared.Validation.Errors @@ -43,7 +46,8 @@ module Connect = [| "-r" |], Required = false, Description = "The repository's ID .", - Arity = ArgumentArity.ExactlyOne + Arity = ArgumentArity.ExactlyOne, + DefaultValueFactory = (fun _ -> RepositoryId.Empty) ) let repositoryName = @@ -56,7 +60,13 @@ module Connect = ) let ownerId = - new Option(OptionName.OwnerId, Required = false, Description = "The repository's owner ID .", Arity = ArgumentArity.ExactlyOne) + new Option( + OptionName.OwnerId, + Required = false, + Description = "The repository's owner ID .", + Arity = ArgumentArity.ExactlyOne, + DefaultValueFactory = (fun _ -> OwnerId.Empty) + ) let ownerName = new Option(OptionName.OwnerName, Required = false, Description = "The repository's owner name.", Arity = ArgumentArity.ExactlyOne) @@ -66,7 +76,8 @@ module Connect = OptionName.OrganizationId, Required = false, Description = "The repository's organization ID .", - Arity = ArgumentArity.ExactlyOne + Arity = ArgumentArity.ExactlyOne, + DefaultValueFactory = (fun _ -> OrganizationId.Empty) ) let organizationName = @@ -95,15 +106,238 @@ module Connect = Arity = ArgumentArity.ExactlyOne ) + let branchId = + new Option( + OptionName.BranchId, + [| "-i" |], + Required = false, + Description = "The branch ID .", + Arity = ArgumentArity.ExactlyOne, + DefaultValueFactory = (fun _ -> BranchId.Empty) + ) + + let branchName = + new Option(OptionName.BranchName, [| "-b" |], Required = false, Description = "The name of the branch.", Arity = ArgumentArity.ExactlyOne) + + let referenceType = + (new Option(OptionName.ReferenceType, Required = false, Description = "The type of reference.", Arity = ArgumentArity.ExactlyOne)) + .AcceptOnlyFromAmong(listCases ()) + + let referenceId = + new Option(OptionName.ReferenceId, [||], Required = false, Description = "The reference ID .", Arity = ArgumentArity.ExactlyOne) + + let directoryVersionId = + new Option( + OptionName.DirectoryVersionId, + [| "-t" |], + Required = false, + Description = "The directory version ID .", + Arity = ArgumentArity.ExactlyOne + ) + + let force = + new Option( + OptionName.Force, + [| "-f"; "--force" |], + Required = false, + Description = "Overwrite conflicting files when connecting.", + Arity = ArgumentArity.ZeroOrOne, + DefaultValueFactory = (fun _ -> false) + ) + let retrieveDefaultBranch = new Option( OptionName.RetrieveDefaultBranch, [||], Required = false, Description = "True to retrieve the default branch after connecting; false to connect but not download any files.", - Arity = ArgumentArity.ExactlyOne + Arity = ArgumentArity.ZeroOrOne, + DefaultValueFactory = (fun _ -> true) ) + module private Arguments = + let repositoryShortcut = + new Argument("repository", Description = "Repository shortcut in the form owner/organization/repository.", Arity = ArgumentArity.ZeroOrOne) + + type DirectoryVersionSelection = + | UseDirectoryVersionId of DirectoryVersionId + | UseReferenceId of ReferenceId + | UseReferenceType of ReferenceType + | UseDefault + + let private tryGetExplicitValue<'T> (parseResult: ParseResult) (option: Option<'T>) = + let result = parseResult.GetResult(option) + + if isNull result || result.Implicit then + None + else + Some(parseResult.GetValue(option)) + + let private tryGetExplicitNonEmptyString (parseResult: ParseResult) (option: Option) = + match tryGetExplicitValue parseResult option with + | Some value when not <| String.IsNullOrWhiteSpace(value) -> Some value + | _ -> None + + type private RepositoryShortcut = { OwnerName: OwnerName; OrganizationName: OrganizationName; RepositoryName: RepositoryName } + + let private validateGraceName (name: string) (error: IErrorDiscriminatedUnion) (parseResult: ParseResult) = + if Constants.GraceNameRegex.IsMatch(name) then + Ok name + else + Error(GraceError.Create (getErrorMessage error) (getCorrelationId parseResult)) + + let private tryGetRepositoryShortcut (parseResult: ParseResult) = + let result = parseResult.GetResult(Arguments.repositoryShortcut) + + if isNull result || result.Implicit then + Ok None + else + let value = parseResult.GetValue(Arguments.repositoryShortcut) + + if String.IsNullOrWhiteSpace(value) then + Error(GraceError.Create "Repository shortcut must be in the form owner/organization/repository." (getCorrelationId parseResult)) + else + let parts = value.Trim().Split('/', StringSplitOptions.RemoveEmptyEntries) + + if parts.Length <> 3 then + Error(GraceError.Create "Repository shortcut must be in the form owner/organization/repository." (getCorrelationId parseResult)) + else + let ownerName = parts[0].Trim() + let organizationName = parts[1].Trim() + let repositoryName = parts[2].Trim() + + match validateGraceName ownerName OwnerError.InvalidOwnerName parseResult with + | Error error -> Error error + | Ok ownerName -> + match validateGraceName organizationName OrganizationError.InvalidOrganizationName parseResult with + | Error error -> Error error + | Ok organizationName -> + match validateGraceName repositoryName RepositoryError.InvalidRepositoryName parseResult with + | Error error -> Error error + | Ok repositoryName -> Ok(Some { OwnerName = ownerName; OrganizationName = organizationName; RepositoryName = repositoryName }) + + let private hasExplicitOwner (parseResult: ParseResult) = + tryGetExplicitValue parseResult Options.ownerId + |> Option.exists (fun ownerId -> ownerId <> Guid.Empty) + || (tryGetExplicitNonEmptyString parseResult Options.ownerName |> Option.isSome) + + let private hasExplicitOrganization (parseResult: ParseResult) = + tryGetExplicitValue parseResult Options.organizationId + |> Option.exists (fun organizationId -> organizationId <> Guid.Empty) + || (tryGetExplicitNonEmptyString parseResult Options.organizationName + |> Option.isSome) + + let private hasExplicitRepository (parseResult: ParseResult) = + tryGetExplicitValue parseResult Options.repositoryId + |> Option.exists (fun repositoryId -> repositoryId <> Guid.Empty) + || (tryGetExplicitNonEmptyString parseResult Options.repositoryName |> Option.isSome) + + let internal applyRepositoryShortcut (parseResult: ParseResult) (graceIds: GraceIds) = + match tryGetRepositoryShortcut parseResult with + | Error error -> Error error + | Ok None -> Ok graceIds + | Ok(Some shortcut) -> + if + hasExplicitOwner parseResult + || hasExplicitOrganization parseResult + || hasExplicitRepository parseResult + then + Error( + GraceError.Create + "Provide either the repository shortcut or the owner/organization/repository options, not both." + (getCorrelationId parseResult) + ) + else + Ok + { graceIds with + OwnerId = Guid.Empty + OwnerIdString = String.Empty + OwnerName = shortcut.OwnerName + OrganizationId = Guid.Empty + OrganizationIdString = String.Empty + OrganizationName = shortcut.OrganizationName + RepositoryId = Guid.Empty + RepositoryIdString = String.Empty + RepositoryName = shortcut.RepositoryName + HasOwner = true + HasOrganization = true + HasRepository = true } + + let internal getDirectoryVersionSelection (parseResult: ParseResult) = + match tryGetExplicitValue parseResult Options.directoryVersionId with + | Some directoryVersionId when directoryVersionId <> Guid.Empty -> UseDirectoryVersionId directoryVersionId + | _ -> + match tryGetExplicitValue parseResult Options.referenceId with + | Some referenceId when referenceId <> Guid.Empty -> UseReferenceId referenceId + | _ -> + match tryGetExplicitNonEmptyString parseResult Options.referenceType with + | Some referenceTypeRaw -> + let referenceType = discriminatedUnionFromString(referenceTypeRaw).Value + UseReferenceType referenceType + | None -> UseDefault + + let internal tryGetDirectoryIdFromBranch (referenceType: ReferenceType) (branchDto: BranchDto) = + match referenceType with + | ReferenceType.Promotion when branchDto.LatestPromotion.DirectoryId <> Guid.Empty -> Some branchDto.LatestPromotion.DirectoryId + | ReferenceType.Commit when branchDto.LatestCommit.DirectoryId <> Guid.Empty -> Some branchDto.LatestCommit.DirectoryId + | ReferenceType.Checkpoint when branchDto.LatestCheckpoint.DirectoryId <> Guid.Empty -> Some branchDto.LatestCheckpoint.DirectoryId + | ReferenceType.Save when branchDto.LatestSave.DirectoryId <> Guid.Empty -> Some branchDto.LatestSave.DirectoryId + | _ -> None + + let internal resolveDefaultDirectoryVersionId (branchDto: BranchDto) = + if branchDto.LatestPromotion.DirectoryId <> Guid.Empty then + Some branchDto.LatestPromotion.DirectoryId + elif branchDto.BasedOn.DirectoryId <> Guid.Empty then + Some branchDto.BasedOn.DirectoryId + else + None + + let private ensureConfigurationFileExists () = + if not <| configurationFileExists () then + let graceDirPath = Path.Combine(Environment.CurrentDirectory, Constants.GraceConfigDirectory) + let graceConfigPath = Path.Combine(graceDirPath, Constants.GraceConfigFileName) + Directory.CreateDirectory(graceDirPath) |> ignore + + if not <| File.Exists(graceConfigPath) then + GraceConfiguration() |> saveConfigFile graceConfigPath + + let private reloadConfiguration () = + resetConfiguration () + Current() |> ignore + + let private applyServerAddressOverride (parseResult: ParseResult) = + match tryGetExplicitNonEmptyString parseResult Options.serverAddress with + | Some serverAddress -> + let newConfig = Current() + newConfig.ServerUri <- serverAddress + updateConfiguration newConfig + reloadConfiguration () + | None -> () + + let private validateRequiredIds (parseResult: ParseResult) (graceIds: GraceIds) = + let correlationId = getCorrelationId parseResult + + let ownerValid = + graceIds.OwnerId <> Guid.Empty + || not <| String.IsNullOrWhiteSpace(graceIds.OwnerName) + + let organizationValid = + graceIds.OrganizationId <> Guid.Empty + || not <| String.IsNullOrWhiteSpace(graceIds.OrganizationName) + + let repositoryValid = + graceIds.RepositoryId <> Guid.Empty + || not <| String.IsNullOrWhiteSpace(graceIds.RepositoryName) + + if not ownerValid then + Error(GraceError.Create (getErrorMessage OwnerError.EitherOwnerIdOrOwnerNameRequired) correlationId) + elif not organizationValid then + Error(GraceError.Create (getErrorMessage OrganizationError.EitherOrganizationIdOrOrganizationNameRequired) correlationId) + elif not repositoryValid then + Error(GraceError.Create (getErrorMessage RepositoryError.EitherRepositoryIdOrRepositoryNameRequired) correlationId) + else + Ok() + type Connect() = inherit AsynchronousCommandLineAction() @@ -111,231 +345,412 @@ module Connect = task { try if parseResult |> verbose then printParseResult parseResult - + ensureConfigurationFileExists () + reloadConfiguration () + applyServerAddressOverride parseResult let validateIncomingParameters = Validations.CommonValidations parseResult match validateIncomingParameters with + | Error error -> return (Error error |> renderOutput parseResult) | Ok _ -> - do! Auth.ensureAccessToken parseResult - let graceIds = getNormalizedIdsAndNames parseResult - let ownerParameters = - Parameters.Owner.GetOwnerParameters( - OwnerId = graceIds.OwnerIdString, - OwnerName = graceIds.OwnerName, - CorrelationId = graceIds.CorrelationId - ) - - let! ownerResult = Grace.SDK.Owner.Get(ownerParameters) - - let organizationParameters = - Parameters.Organization.GetOrganizationParameters( - OwnerId = graceIds.OwnerIdString, - OwnerName = graceIds.OwnerName, - OrganizationId = graceIds.OrganizationIdString, - OrganizationName = graceIds.OrganizationName, - CorrelationId = graceIds.CorrelationId - ) - - let! organizationResult = Organization.Get(organizationParameters) - - let repositoryParameters = - Parameters.Repository.GetRepositoryParameters( - OwnerId = graceIds.OwnerIdString, - OwnerName = graceIds.OwnerName, - OrganizationId = graceIds.OrganizationIdString, - OrganizationName = graceIds.OrganizationName, - RepositoryId = graceIds.RepositoryIdString, - RepositoryName = graceIds.RepositoryName, - CorrelationId = graceIds.CorrelationId - ) - - let! repositoryResult = Repository.Get(repositoryParameters) - - match (ownerResult, organizationResult, repositoryResult) with - | (Ok owner, Ok organization, Ok repository) -> - let ownerDto = owner.ReturnValue - let organizationDto = organization.ReturnValue - let repositoryDto = repository.ReturnValue - - AnsiConsole.MarkupLine $"[{Colors.Important}]Found owner, organization, and repository.[/]" - - let branchParameters = - Parameters.Branch.GetBranchParameters( - OwnerId = $"{ownerDto.OwnerId}", - OrganizationId = $"{organizationDto.OrganizationId}", - RepositoryId = $"{repositoryDto.RepositoryId}", - BranchName = $"{repositoryDto.DefaultBranchName}", - CorrelationId = graceIds.CorrelationId - ) - - match! Branch.Get(branchParameters) with - | Ok graceReturnValue -> - let branchDto = graceReturnValue.ReturnValue - AnsiConsole.MarkupLine $"[{Colors.Important}]Retrieved branch {branchDto.BranchName}.[/]" - - // Write the new configuration to the config file. - let newConfig = Current() - newConfig.OwnerId <- ownerDto.OwnerId - newConfig.OwnerName <- ownerDto.OwnerName - newConfig.OrganizationId <- organizationDto.OrganizationId - newConfig.OrganizationName <- organizationDto.OrganizationName - newConfig.RepositoryId <- repositoryDto.RepositoryId - newConfig.RepositoryName <- repositoryDto.RepositoryName - newConfig.BranchId <- branchDto.BranchId - newConfig.BranchName <- branchDto.BranchName - newConfig.DefaultBranchName <- repositoryDto.DefaultBranchName - newConfig.ObjectStorageProvider <- repositoryDto.ObjectStorageProvider - updateConfiguration newConfig - AnsiConsole.MarkupLine $"[{Colors.Important}]Wrote new Grace configuration file.[/]" - - let getDirectoryContentsParameters = - Parameters.DirectoryVersion.GetParameters( - OwnerId = $"{ownerDto.OwnerId}", - OrganizationId = $"{organizationDto.OrganizationId}", - RepositoryId = $"{repositoryDto.RepositoryId}", - DirectoryVersionId = $"{branchDto.LatestPromotion.DirectoryId}", + match applyRepositoryShortcut parseResult graceIds with + | Error error -> return (Error error |> renderOutput parseResult) + | Ok graceIds -> + match validateRequiredIds parseResult graceIds with + | Error error -> return (Error error |> renderOutput parseResult) + | Ok() -> + do! Auth.ensureAccessToken parseResult + + let ownerParameters = + Parameters.Owner.GetOwnerParameters( + OwnerId = graceIds.OwnerIdString, + OwnerName = graceIds.OwnerName, CorrelationId = graceIds.CorrelationId ) - AnsiConsole.MarkupLine $"[{Colors.Important}]Retrieving all DirectoryVersions.[/]" - let! directoryVersionsResult = DirectoryVersion.GetDirectoryVersionsRecursive(getDirectoryContentsParameters) + let! ownerResult = Grace.SDK.Owner.Get(ownerParameters) - let getZipFileParameters = - Parameters.DirectoryVersion.GetZipFileParameters( - OwnerId = $"{ownerDto.OwnerId}", - OrganizationId = $"{organizationDto.OrganizationId}", - RepositoryId = $"{repositoryDto.RepositoryId}", - DirectoryVersionId = $"{branchDto.LatestPromotion.DirectoryId}", + let organizationParameters = + Parameters.Organization.GetOrganizationParameters( + OwnerId = graceIds.OwnerIdString, + OwnerName = graceIds.OwnerName, + OrganizationId = graceIds.OrganizationIdString, + OrganizationName = graceIds.OrganizationName, CorrelationId = graceIds.CorrelationId ) - AnsiConsole.MarkupLine $"[{Colors.Important}]Retrieving zip file download uri.[/]" - let! getZipFileResult = DirectoryVersion.GetZipFile(getZipFileParameters) - AnsiConsole.MarkupLine $"[{Colors.Important}]Finished getting zip file download uri.[/]" - - match (directoryVersionsResult, getZipFileResult) with - | (Ok directoryVerionsReturnValue, Ok getZipFileReturnValue) -> - AnsiConsole.MarkupLine $"[{Colors.Important}]Retrieved all DirectoryVersions.[/]" - let directoryVersionDtos = directoryVerionsReturnValue.ReturnValue - - let fileVersions = - directoryVersionDtos - |> Seq.map (fun directoryVersionDto -> directoryVersionDto.DirectoryVersion) - |> Seq.collect (fun dv -> dv.Files) - - let fileVersionLookup = Dictionary(fileVersions |> Seq.length) - - fileVersions - |> Seq.iter (fun fileVersion -> fileVersionLookup.Add(fileVersion.RelativePath, fileVersion.IsBinary)) - - let uriWithSharedAccessSignature = getZipFileReturnValue.ReturnValue - - // Download the .zip file to temp directory. - let blobClient = BlobClient(uriWithSharedAccessSignature) - - // Loop through the ZipArchiveEntry list, identify if each file version is binary, and extract - // each one accordingly. - use! zipFile = blobClient.OpenReadAsync(bufferSize = 64 * 1024) - use zipArchive = new ZipArchive(zipFile, ZipArchiveMode.Read) - - AnsiConsole.MarkupLine $"[{Colors.Important}]Streaming contents from .zip file.[/]" - AnsiConsole.MarkupLine $"[{Colors.Important}]Starting to write files to disk.[/]" - - zipArchive.Entries - |> Seq.iteri (fun i entry -> - let fileVersion = - fileVersions - |> Seq.tryFind (fun fv -> fv.RelativePath = RelativePath(entry.FullName)) - - match fileVersion with - | Some fileVersion -> - let fileInfo = FileInfo(Path.Combine(Current().RootDirectory, fileVersion.RelativePath)) - - let objectFileInfo = FileInfo(Path.Combine(Current().ObjectDirectory, fileVersion.RelativePath, entry.Comment)) - - // Make sure the entire paths exist before writing files to them. - Directory.CreateDirectory(fileInfo.DirectoryName) |> ignore - Directory.CreateDirectory(objectFileInfo.DirectoryName) |> ignore - - if fileVersion.IsBinary then - // Binary files are not GZipped, so write it to directly to disk. - if not fileInfo.Exists then entry.ExtractToFile(fileInfo.FullName, false) - - if not objectFileInfo.Exists then - entry.ExtractToFile(objectFileInfo.FullName, false) - else - // It's already GZipped, so let's uncompress it and write it to disk. - let uncompressAndWriteToFile (entry: ZipArchiveEntry) (fileInfo: FileInfo) = - use entryStream = entry.Open() - use fileStream = fileInfo.Create() - use gzipStream = new GZipStream(entryStream, CompressionMode.Decompress) - gzipStream.CopyTo(fileStream) - - uncompressAndWriteToFile entry fileInfo - uncompressAndWriteToFile entry objectFileInfo - - if parseResult |> verbose then - AnsiConsole.MarkupLine $"[{Colors.Important}]Wrote {fileVersion.RelativePath}.[/]" - | None -> - // The .zip file has a file in it that isn't in the directory version. - AnsiConsole.MarkupLine - $"[{Colors.Error}]Zip file contains additional file {entry.FullName}. Ignoring this file.[/]") - - AnsiConsole.MarkupLine $"[{Colors.Important}]Finished writing files to disk.[/]" - - AnsiConsole.MarkupLine $"[{Colors.Important}]Creating Grace Index file.[/]" - let! previousGraceStatus = readGraceStatusFile () - let! graceStatus = createNewGraceStatusFile previousGraceStatus parseResult - do! writeGraceStatusFile graceStatus - - AnsiConsole.MarkupLine $"[{Colors.Important}]Creating Grace Object Cache Index file.[/]" - let! objectCache = readGraceObjectCacheFile () - - let plr = - Parallel.ForEach( - graceStatus.Index.Values, - Constants.ParallelOptions, - (fun localDirectoryVersion -> - if not <| objectCache.Index.ContainsKey(localDirectoryVersion.DirectoryVersionId) then - objectCache.Index.AddOrUpdate( - localDirectoryVersion.DirectoryVersionId, - (fun _ -> localDirectoryVersion), - (fun _ _ -> localDirectoryVersion) - ) - |> ignore + let! organizationResult = Organization.Get(organizationParameters) + let repositoryParameters = + Parameters.Repository.GetRepositoryParameters( + OwnerId = graceIds.OwnerIdString, + OwnerName = graceIds.OwnerName, + OrganizationId = graceIds.OrganizationIdString, + OrganizationName = graceIds.OrganizationName, + RepositoryId = graceIds.RepositoryIdString, + RepositoryName = graceIds.RepositoryName, + CorrelationId = graceIds.CorrelationId + ) + + let! repositoryResult = Repository.Get(repositoryParameters) + + match (ownerResult, organizationResult, repositoryResult) with + | (Ok owner, Ok organization, Ok repository) -> + let ownerDto = owner.ReturnValue + let organizationDto = organization.ReturnValue + let repositoryDto = repository.ReturnValue + AnsiConsole.MarkupLine $"[{Colors.Important}]Found owner, organization, and repository.[/]" + + let branchId = + tryGetExplicitValue parseResult Options.branchId + |> Option.filter (fun value -> value <> Guid.Empty) + + let branchName = tryGetExplicitNonEmptyString parseResult Options.branchName + + let branchParameters = + match branchId, branchName with + | Some id, _ -> + Parameters.Branch.GetBranchParameters( + OwnerId = $"{ownerDto.OwnerId}", + OrganizationId = $"{organizationDto.OrganizationId}", + RepositoryId = $"{repositoryDto.RepositoryId}", + BranchId = $"{id}", + CorrelationId = graceIds.CorrelationId + ) + | None, Some name -> + Parameters.Branch.GetBranchParameters( + OwnerId = $"{ownerDto.OwnerId}", + OrganizationId = $"{organizationDto.OrganizationId}", + RepositoryId = $"{repositoryDto.RepositoryId}", + BranchName = name, + CorrelationId = graceIds.CorrelationId + ) + | None, None -> + Parameters.Branch.GetBranchParameters( + OwnerId = $"{ownerDto.OwnerId}", + OrganizationId = $"{organizationDto.OrganizationId}", + RepositoryId = $"{repositoryDto.RepositoryId}", + BranchName = $"{repositoryDto.DefaultBranchName}", + CorrelationId = graceIds.CorrelationId ) - ) - do! writeGraceObjectCacheFile objectCache + match! Branch.Get(branchParameters) with + | Ok graceReturnValue -> + let branchDto = graceReturnValue.ReturnValue + AnsiConsole.MarkupLine $"[{Colors.Important}]Retrieved branch {branchDto.BranchName}.[/]" + // Write the new configuration to the config file. + let newConfig = Current() + newConfig.OwnerId <- ownerDto.OwnerId + newConfig.OwnerName <- ownerDto.OwnerName + newConfig.OrganizationId <- organizationDto.OrganizationId + newConfig.OrganizationName <- organizationDto.OrganizationName + newConfig.RepositoryId <- repositoryDto.RepositoryId + newConfig.RepositoryName <- repositoryDto.RepositoryName + newConfig.BranchId <- branchDto.BranchId + newConfig.BranchName <- branchDto.BranchName + newConfig.DefaultBranchName <- repositoryDto.DefaultBranchName + newConfig.ObjectStorageProvider <- repositoryDto.ObjectStorageProvider + updateConfiguration newConfig + reloadConfiguration () + AnsiConsole.MarkupLine $"[{Colors.Important}]Wrote new Grace configuration file.[/]" + let retrieveDefaultBranch = parseResult.GetValue(Options.retrieveDefaultBranch) + + if retrieveDefaultBranch then + let selectLatestReference (references: ReferenceDto seq) = + references + |> Seq.sortByDescending (fun reference -> reference.UpdatedAt |> Option.defaultValue reference.CreatedAt) + |> Seq.tryHead + + let resolveDirectoryVersionIdFromReferenceType (referenceType: ReferenceType) = + task { + match tryGetDirectoryIdFromBranch referenceType branchDto with + | Some directoryId -> return Ok directoryId + | None -> + let getReferencesParameters = + Parameters.Branch.GetReferencesParameters( + OwnerId = $"{ownerDto.OwnerId}", + OwnerName = ownerDto.OwnerName, + OrganizationId = $"{organizationDto.OrganizationId}", + OrganizationName = organizationDto.OrganizationName, + RepositoryId = $"{repositoryDto.RepositoryId}", + RepositoryName = repositoryDto.RepositoryName, + BranchId = $"{branchDto.BranchId}", + BranchName = branchDto.BranchName, + MaxCount = 50, + CorrelationId = graceIds.CorrelationId + ) + + let! referencesResult = + match referenceType with + | ReferenceType.Tag -> Branch.GetTags(getReferencesParameters) + | ReferenceType.External -> Branch.GetExternals(getReferencesParameters) + | ReferenceType.Rebase -> Branch.GetRebases(getReferencesParameters) + | _ -> task { return Ok(GraceReturnValue.Create [||] graceIds.CorrelationId) } + + match referencesResult with + | Ok returnValue -> + match selectLatestReference returnValue.ReturnValue with + | Some reference -> return Ok reference.DirectoryId + | None -> + return + Error( + GraceError.Create + $"No {referenceType} references were found for branch {branchDto.BranchName}." + graceIds.CorrelationId + ) + | Error error -> return Error error + } + + let resolveTargetDirectoryVersionId () = + task { + match getDirectoryVersionSelection parseResult with + | UseDirectoryVersionId directoryVersionId -> return Ok directoryVersionId + | UseReferenceId referenceId -> + let getReferenceParameters = + Parameters.Branch.GetReferenceParameters( + OwnerId = $"{ownerDto.OwnerId}", + OwnerName = ownerDto.OwnerName, + OrganizationId = $"{organizationDto.OrganizationId}", + OrganizationName = organizationDto.OrganizationName, + RepositoryId = $"{repositoryDto.RepositoryId}", + RepositoryName = repositoryDto.RepositoryName, + BranchId = $"{branchDto.BranchId}", + BranchName = branchDto.BranchName, + ReferenceId = $"{referenceId}", + CorrelationId = graceIds.CorrelationId + ) + + match! Branch.GetReference(getReferenceParameters) with + | Ok returnValue -> return Ok returnValue.ReturnValue.DirectoryId + | Error error -> return Error error + | UseReferenceType referenceType -> return! resolveDirectoryVersionIdFromReferenceType referenceType + | UseDefault -> + match resolveDefaultDirectoryVersionId branchDto with + | Some directoryVersionId -> return Ok directoryVersionId + | None -> + return + Error(GraceError.Create "No downloadable version found for this branch." graceIds.CorrelationId) + } + + match! resolveTargetDirectoryVersionId () with + | Error error -> return (Error error |> renderOutput parseResult) + | Ok directoryVersionId -> + let getDirectoryContentsParameters = + Parameters.DirectoryVersion.GetParameters( + OwnerId = $"{ownerDto.OwnerId}", + OrganizationId = $"{organizationDto.OrganizationId}", + RepositoryId = $"{repositoryDto.RepositoryId}", + DirectoryVersionId = $"{directoryVersionId}", + CorrelationId = graceIds.CorrelationId + ) + + AnsiConsole.MarkupLine $"[{Colors.Important}]Retrieving all DirectoryVersions.[/]" + + let! directoryVersionsResult = DirectoryVersion.GetDirectoryVersionsRecursive(getDirectoryContentsParameters) + + let getZipFileParameters = + Parameters.DirectoryVersion.GetZipFileParameters( + OwnerId = $"{ownerDto.OwnerId}", + OrganizationId = $"{organizationDto.OrganizationId}", + RepositoryId = $"{repositoryDto.RepositoryId}", + DirectoryVersionId = $"{directoryVersionId}", + CorrelationId = graceIds.CorrelationId + ) + + AnsiConsole.MarkupLine $"[{Colors.Important}]Retrieving zip file download uri.[/]" + let! getZipFileResult = DirectoryVersion.GetZipFile(getZipFileParameters) + AnsiConsole.MarkupLine $"[{Colors.Important}]Finished getting zip file download uri.[/]" + + match (directoryVersionsResult, getZipFileResult) with + | (Ok directoryVerionsReturnValue, Ok getZipFileReturnValue) -> + AnsiConsole.MarkupLine $"[{Colors.Important}]Retrieved all DirectoryVersions.[/]" + + let directoryVersionDtos = directoryVerionsReturnValue.ReturnValue + + let fileVersions = + directoryVersionDtos + |> Seq.map (fun directoryVersionDto -> directoryVersionDto.DirectoryVersion) + |> Seq.collect (fun dv -> dv.Files) + |> Seq.toArray + + let force = parseResult.GetValue(Options.force) + + let! conflicts, filesToSkip = + task { + let conflicts = ResizeArray() + let filesToSkip = HashSet() + + for fileVersion in fileVersions do + let filePath = Path.Combine(Current().RootDirectory, fileVersion.RelativePath) + + if File.Exists(filePath) then + try + use stream = File.OpenRead(filePath) + + let! localHash = + Grace.Shared.Services.computeSha256ForFile stream fileVersion.RelativePath + + if localHash = fileVersion.Sha256Hash then + filesToSkip.Add(fileVersion.RelativePath) |> ignore + elif not force then + conflicts.Add(fileVersion.RelativePath) + with _ -> + if not force then conflicts.Add(fileVersion.RelativePath) + + return conflicts, filesToSkip + } + + if conflicts.Count > 0 then + AnsiConsole.MarkupLine + $"[{Colors.Error}]Found {conflicts.Count} conflicting file(s). Use --force to overwrite.[/]" + + if parseResult |> verbose then + conflicts + |> Seq.sort + |> Seq.iter (fun conflict -> AnsiConsole.MarkupLine $"[{Colors.Error}]{conflict}[/]") + + return + (Error(GraceError.Create "Conflicting files exist in the working directory." graceIds.CorrelationId) + |> renderOutput parseResult) + + else + let fileVersionsByRelativePath = + let lookup = + Dictionary(fileVersions.Length, StringComparer.OrdinalIgnoreCase) + + fileVersions + |> Seq.iter (fun fileVersion -> lookup[normalizeFilePath fileVersion.RelativePath] <- fileVersion) - | _ -> AnsiConsole.MarkupLine $"[{Colors.Error}]Failed to retrieve zip file.[/]" - | Error error -> AnsiConsole.MarkupLine $"[{Colors.Error}]Failed to retrieve branch.[/]" - | (Error error, _, _) -> AnsiConsole.MarkupLine $"[{Colors.Error}]Failed to retrieve owner.[/]" - | (_, Error error, _) -> AnsiConsole.MarkupLine $"[{Colors.Error}]Failed to retrieve organization.[/]" - | (_, _, Error error) -> AnsiConsole.MarkupLine $"[{Colors.Error}]Failed to retrieve repository.[/]" - | (Error error) -> printfn ($"error: {error}") + lookup - return 0 - with :? OperationCanceledException as ex -> - return -1 + let uriWithSharedAccessSignature = getZipFileReturnValue.ReturnValue + + // Download the .zip file to temp directory. + let blobClient = BlobClient(uriWithSharedAccessSignature) + + // Loop through the ZipArchiveEntry list, identify if each file version is binary, and extract + // each one accordingly. + use! zipFile = blobClient.OpenReadAsync(bufferSize = 64 * 1024) + use zipArchive = new ZipArchive(zipFile, ZipArchiveMode.Read) + + AnsiConsole.MarkupLine $"[{Colors.Important}]Streaming contents from .zip file.[/]" + AnsiConsole.MarkupLine $"[{Colors.Important}]Starting to write files to disk.[/]" + + let additionalEntries = ResizeArray() + + for entry in zipArchive.Entries do + if not <| String.IsNullOrEmpty(entry.Name) then + let entryRelativePath = normalizeFilePath entry.FullName + + match fileVersionsByRelativePath.TryGetValue(entryRelativePath) with + | true, fileVersion -> + let objectFileName = + if String.IsNullOrWhiteSpace(entry.Comment) then + fileVersion.GetObjectFileName + else + entry.Comment + + let fileInfo = FileInfo(Path.Combine(Current().RootDirectory, fileVersion.RelativePath)) + + let objectFileInfo = + FileInfo( + Path.Combine(Current().ObjectDirectory, fileVersion.RelativePath, objectFileName) + ) + + Directory.CreateDirectory(fileInfo.DirectoryName) |> ignore + Directory.CreateDirectory(objectFileInfo.DirectoryName) |> ignore + + let writeWorkingFile = not <| filesToSkip.Contains(fileVersion.RelativePath) + + let writeObjectFile = not objectFileInfo.Exists + + if fileVersion.IsBinary then + if writeWorkingFile then entry.ExtractToFile(fileInfo.FullName, true) + + if writeObjectFile then entry.ExtractToFile(objectFileInfo.FullName, true) + else + let uncompressAndWriteToFile (zipEntry: ZipArchiveEntry) (fileInfo: FileInfo) = + use entryStream = zipEntry.Open() + use fileStream = fileInfo.Create() + use gzipStream = new GZipStream(entryStream, CompressionMode.Decompress) + gzipStream.CopyTo(fileStream) + + if writeWorkingFile then uncompressAndWriteToFile entry fileInfo + + if writeObjectFile then uncompressAndWriteToFile entry objectFileInfo + + if parseResult |> verbose then + AnsiConsole.MarkupLine $"[{Colors.Important}]Wrote {fileVersion.RelativePath}.[/]" + | false, _ -> additionalEntries.Add(entry.FullName) + + if additionalEntries.Count > 0 && (parseResult |> verbose) then + AnsiConsole.MarkupLine + $"[{Colors.Deemphasized}]Zip contained {additionalEntries.Count} additional entry(ies). Ignored.[/]" + + AnsiConsole.MarkupLine $"[{Colors.Important}]Finished writing files to disk.[/]" + + AnsiConsole.MarkupLine $"[{Colors.Important}]Creating Grace Index file.[/]" + let! previousGraceStatus = readGraceStatusFile () + let! graceStatus = createNewGraceStatusFile previousGraceStatus parseResult + do! writeGraceStatusFile graceStatus + + AnsiConsole.MarkupLine $"[{Colors.Important}]Creating Grace Object Cache Index file.[/]" + let! objectCache = readGraceObjectCacheFile () + + let _ = + Parallel.ForEach( + graceStatus.Index.Values, + Constants.ParallelOptions, + (fun localDirectoryVersion -> + if not <| objectCache.Index.ContainsKey(localDirectoryVersion.DirectoryVersionId) then + objectCache.Index.AddOrUpdate( + localDirectoryVersion.DirectoryVersionId, + (fun _ -> localDirectoryVersion), + (fun _ _ -> localDirectoryVersion) + ) + |> ignore) + ) + + do! writeGraceObjectCacheFile objectCache + return 0 + | (Error error, _) -> return (Error error |> renderOutput parseResult) + | (_, Error error) -> return (Error error |> renderOutput parseResult) + + else + return 0 + | Error error -> return (Error error |> renderOutput parseResult) + | (Error error, _, _) -> return (Error error |> renderOutput parseResult) + | (_, Error error, _) -> return (Error error |> renderOutput parseResult) + | (_, _, Error error) -> return (Error error |> renderOutput parseResult) + with + | :? OperationCanceledException -> return -1 + | ex -> + let error = GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult) + return (Error error |> renderOutput parseResult) } let Build = // Create main command and aliases, if any. let connectCommand = new Command("connect", Description = "Connect to a Grace repository.") + connectCommand.Arguments.Add(Arguments.repositoryShortcut) connectCommand.Options.Add(Options.repositoryId) connectCommand.Options.Add(Options.repositoryName) connectCommand.Options.Add(Options.ownerId) connectCommand.Options.Add(Options.ownerName) connectCommand.Options.Add(Options.organizationId) connectCommand.Options.Add(Options.organizationName) + connectCommand.Options.Add(Options.branchId) + connectCommand.Options.Add(Options.branchName) + connectCommand.Options.Add(Options.referenceType) + connectCommand.Options.Add(Options.referenceId) + connectCommand.Options.Add(Options.directoryVersionId) connectCommand.Options.Add(Options.correlationId) connectCommand.Options.Add(Options.serverAddress) connectCommand.Options.Add(Options.retrieveDefaultBranch) + connectCommand.Options.Add(Options.force) connectCommand.Action <- Connect() connectCommand diff --git a/src/Grace.CLI/Command/Services.CLI.fs b/src/Grace.CLI/Command/Services.CLI.fs index 61259bc6..554045c9 100644 --- a/src/Grace.CLI/Command/Services.CLI.fs +++ b/src/Grace.CLI/Command/Services.CLI.fs @@ -1567,14 +1567,45 @@ module Services = return newFileVersions } + let private matchesOptionName (option: Option) (optionName: string) = + let normalizedName = optionName.TrimStart('-') + let hasAlias alias = option.Aliases |> Seq.exists (fun optionAlias -> optionAlias = alias) + + option.Name = optionName + || option.Name = normalizedName + || hasAlias optionName + || hasAlias normalizedName + + let rec private hasOptionInCommandResult (commandResult: CommandResult) (optionName: string) = + if isNull commandResult then + false + elif + commandResult.Command.Options + |> Seq.exists (fun option -> matchesOptionName option optionName) + then + true + else + match commandResult.Parent with + | :? CommandResult as parent -> hasOptionInCommandResult parent optionName + | _ -> false + /// Checks if an option was present in the definition of the command. - let isOptionPresent (parseResult: ParseResult) (optionName: string) = not <| isNull (parseResult.GetResult(optionName)) + let isOptionPresent (parseResult: ParseResult) (optionName: string) = + if not <| isNull (parseResult.GetResult(optionName)) then + true + else + hasOptionInCommandResult parseResult.CommandResult optionName /// Checks if an option was implicitly specified (i.e. the default value was used), or explicitly specified by the user. let isOptionResultImplicit (parseResult: ParseResult) (optionName: string) = if isOptionPresent parseResult optionName then - let option = parseResult.GetResult(optionName) :?> OptionResult - option.Implicit + let result = parseResult.GetResult(optionName) + + if isNull result then + true + else + let option = result :?> OptionResult + option.Implicit else false diff --git a/src/Grace.CLI/Program.CLI.fs b/src/Grace.CLI/Program.CLI.fs index 0b13cb75..aab29a00 100644 --- a/src/Grace.CLI/Program.CLI.fs +++ b/src/Grace.CLI/Program.CLI.fs @@ -92,6 +92,38 @@ module GraceCommand = AnsiConsole.Write(table) + let internal tryGetTopLevelCommandFromArgs (args: string array) (isCaseInsensitive: bool) = + if isNull args || args.Length = 0 then + None + else + let comparison = + if isCaseInsensitive then + StringComparison.InvariantCultureIgnoreCase + else + StringComparison.InvariantCulture + + let isOptionWithValue (token: string) = + token.Equals(OptionName.Output, comparison) + || token.Equals("-o", comparison) + || token.Equals(OptionName.CorrelationId, comparison) + || token.Equals("-c", comparison) + + let rec loop index = + if index >= args.Length then + None + else + let token = args[index] + + if token = "--" then + if index + 1 < args.Length then Some args[index + 1] else None + elif token.StartsWith("-", StringComparison.Ordinal) then + let nextIndex = if isOptionWithValue token then index + 2 else index + 1 + loop nextIndex + else + Some token + + loop 0 + /// Gathers the available options for the current command and all its parents, which are applied hierarchically. [] let rec gatherAllOptions (command: Command) (allOptions: List