From bf34b936ebe6cc602e3bc67f329b5968915cbcdb Mon Sep 17 00:00:00 2001 From: binwen Date: Wed, 17 Jan 2024 15:47:26 +0800 Subject: [PATCH] Add Fun.Build.Cli --- .gitignore | 1 + Fun.Build.Cli/CHANGELOG.md | 7 + Fun.Build.Cli/Fun.Build.Cli.fsproj | 27 ++ Fun.Build.Cli/Program.fs | 406 +++++++++++++++++++++++++ Fun.Build.Cli/Start.fs | 316 +++++++++++++++++++ Fun.Build.sln | 6 + CHANGELOG.md => Fun.Build/CHANGELOG.md | 5 + README.md | 4 +- build.fsx | 32 +- 9 files changed, 797 insertions(+), 7 deletions(-) create mode 100644 Fun.Build.Cli/CHANGELOG.md create mode 100644 Fun.Build.Cli/Fun.Build.Cli.fsproj create mode 100644 Fun.Build.Cli/Program.fs create mode 100644 Fun.Build.Cli/Start.fs rename CHANGELOG.md => Fun.Build/CHANGELOG.md (98%) diff --git a/.gitignore b/.gitignore index f4291b6..04419f5 100644 --- a/.gitignore +++ b/.gitignore @@ -363,3 +363,4 @@ MigrationBackup/ FodyWeavers.xsd .idea/ +Directory.Build.props diff --git a/Fun.Build.Cli/CHANGELOG.md b/Fun.Build.Cli/CHANGELOG.md new file mode 100644 index 0000000..4746b9d --- /dev/null +++ b/Fun.Build.Cli/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## [Unreleased] + +## [0.0.1] - 2024-01-17 + +First release: a dotnet cli tool to help manage all the scripts which using Fun.Build diff --git a/Fun.Build.Cli/Fun.Build.Cli.fsproj b/Fun.Build.Cli/Fun.Build.Cli.fsproj new file mode 100644 index 0000000..f11bafb --- /dev/null +++ b/Fun.Build.Cli/Fun.Build.Cli.fsproj @@ -0,0 +1,27 @@ + + + + Exe + net8.0 + true + fun-blazor + ./nupkg + + + + + + + + + + + + + + diff --git a/Fun.Build.Cli/Program.fs b/Fun.Build.Cli/Program.fs new file mode 100644 index 0000000..faae844 --- /dev/null +++ b/Fun.Build.Cli/Program.fs @@ -0,0 +1,406 @@ +open Fun.Blazor +let sum = + div.create [ + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + input { + type' InputTypes.text + class' "as" + autofocus + name "asd" + value "as" + on.change ignore + } + ] + +printfn "hi" diff --git a/Fun.Build.Cli/Start.fs b/Fun.Build.Cli/Start.fs new file mode 100644 index 0000000..faa42b7 --- /dev/null +++ b/Fun.Build.Cli/Start.fs @@ -0,0 +1,316 @@ +open System +open System.IO +open System.Text +open System.Security.Cryptography +open Spectre.Console +open Fun.Result +open Fun.Build + + +Console.InputEncoding <- Encoding.UTF8 +Console.OutputEncoding <- Encoding.UTF8 + + +let () x y = Path.Combine(x, y) + +let ensureDir x = + if Directory.Exists x |> not then Directory.CreateDirectory x |> ignore + x + +let funBuildCliCacheDir = + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) "fun-build" |> ensureDir +let pipelineInfoDir = funBuildCliCacheDir "pipeline-infos" |> ensureDir +let sourcesFile = funBuildCliCacheDir "sources.txt" +let historyFile = funBuildCliCacheDir "history.csv" + + +type Pipeline = { Script: string; Name: string; Description: string } + +type ExecutionHistoryItem = { + Script: string + Pipeline: string + Args: string + StartedTime: DateTime +} + + +let mutable sources = + try + File.ReadAllLines sourcesFile |> Seq.toList + with _ -> [] + + +let parseHistory (line: string) = + let columns = line.Split "," + { + Script = columns[0] + Pipeline = columns[1] + Args = columns[2] + StartedTime = DateTime.Parse(columns[3]) + } + + +let addHistory (item: ExecutionHistoryItem) = File.AppendAllLines(historyFile, [ $"{item.Script},{item.Pipeline},{item.Args},{item.StartedTime}" ]) + + +let parsePipelines (str: string) = + let pipelines = Collections.Generic.List() + let lines = str.Split(Environment.NewLine) + let mutable index = 0 + let mutable shouldContinue = true + let mutable isPipeline = false + + while shouldContinue do + let line = lines[index].Trim() + index <- index + 1 + if line = "Pipelines:" then + isPipeline <- true + else if isPipeline && not (String.IsNullOrEmpty(line)) then + let index = line.Trim().IndexOf(" ") + if index > 0 then + pipelines.Add(line.Substring(0, index), line.Substring(index + 1).Trim()) + else + pipelines.Add(line, "") + else if isPipeline then + shouldContinue <- false + else + shouldContinue <- index < lines.Length + + pipelines |> Seq.toList + + +let hashString (str: string) = Convert.ToBase64String(MD5.HashData(Encoding.UTF8.GetBytes(str))) + + +let refreshPipelineInfos (dir: string) = + Directory.GetFiles(dir, "*.fsx", EnumerationOptions(RecurseSubdirectories = true)) + |> Seq.map (fun f -> async { + try + let isValidFile = File.ReadLines(f) |> Seq.exists (fun l -> l.Contains("tryPrintPipelineCommandHelp")) + if isValidFile then + printfn "Process script %s" f + let psInfo = Diagnostics.ProcessStartInfo() + psInfo.FileName <- Diagnostics.Process.GetQualifiedFileName "dotnet" + psInfo.Arguments <- $"fsi \"{f}\" -- -h" + let! result = Diagnostics.Process.StartAsync(psInfo, "", "", printOutput = false, captureOutput = true) + let pipelineInfoFile = pipelineInfoDir hashString f + let pipelineInfos = parsePipelines result.StandardOutput |> Seq.map (fun (x, y) -> sprintf "%s,%s,%s" f x y) + File.WriteAllLines(pipelineInfoFile, pipelineInfos) + + with ex -> + AnsiConsole.MarkupLineInterpolated($"[red]Process script {f} failed: {ex.Message}[/]") + }) + |> Async.Parallel + |> Async.map ignore + + +let rec addSourceDir () = + let source = + AnsiConsole.Ask("You need to provide at least one source folder which should contains .fsx file under it or under its sub folders:") + if Directory.Exists source then + sources <- sources @ [ source ] + File.WriteAllLines(sourcesFile, sources) + refreshPipelineInfos source |> Async.RunSynchronously + if AnsiConsole.Confirm("Do you want to add more source dir?", false) then + addSourceDir () + else + AnsiConsole.MarkupLine("The folder is not exist, please try again:") + addSourceDir () + + +let selectHistory () = + let historyLines = + try + File.ReadLines(historyFile) + with _ -> [||] + + let count = historyLines |> Seq.length + let limit = 10 + let skipCount = if count > limit then count - limit else 0 + let histories = historyLines |> Seq.skip skipCount |> Seq.map parseHistory |> Seq.rev |> Seq.toList + + if histories.IsEmpty then + AnsiConsole.MarkupLine("[yellow]No history found[/]") + None + else + let selection = SelectionPrompt() + selection.Title <- "Select history to re-run" + selection.Converter <- snd + selection.AddChoices [ + for i, history in Seq.indexed histories do + let time = history.StartedTime.ToString("yyyy-MM-dd HH:mm:ss") + i, $"{time}: {history.Pipeline} {history.Args} {history.Script}" + ] + |> ignore + let index, _ = AnsiConsole.Prompt(selection) + Seq.tryItem index histories + +let rec runPipeline (ctx: Internal.StageContext) (pipeline: Pipeline) = asyncResult { + AnsiConsole.MarkupLineInterpolated($"Pipeline: [bold green]{pipeline.Name}[/]") + if String.IsNullOrEmpty(pipeline.Description.Trim()) |> not then + AnsiConsole.MarkupLineInterpolated($"[green]{pipeline.Description}[/]") + AnsiConsole.MarkupLineInterpolated($"Script: [green]{pipeline.Script}[/]") + + let scriptFile = Path.GetFileName pipeline.Script + let scriptDir = Path.GetDirectoryName pipeline.Script + let args = AnsiConsole.Ask("Arguments: ") + + AnsiConsole.MarkupLineInterpolated($"Working dir: [green]{scriptDir}[/]") + + let startedTime = DateTime.Now + do! + ctx.RunCommand($"dotnet fsi \"{scriptFile}\" -- -p {pipeline.Name} {args}", workingDir = scriptDir) + |> Async.map (ignore >> Ok) + + if args.Trim() <> "-h" then + addHistory + { + Script = pipeline.Script + Pipeline = pipeline.Name + Args = args + StartedTime = startedTime + } + else + do! runPipeline ctx pipeline +} + +let reRunHistory (ctx: Internal.StageContext) (history: ExecutionHistoryItem) = asyncResult { + let args = + let newArgs = AnsiConsole.Ask("Type new arguments or press ENTER to use the one from history", "") + if String.IsNullOrEmpty newArgs then history.Args else newArgs + + let scriptFile = Path.GetFileName history.Script + let scriptDir = Path.GetDirectoryName history.Script + + AnsiConsole.MarkupLineInterpolated($"[green]Working dir: {scriptDir}[/]") + + let startTime = DateTime.Now + do! + ctx.RunCommand($"dotnet fsi \"{scriptFile}\" -- -p {history.Pipeline} {args}", workingDir = scriptDir) + |> Async.map (ignore >> Ok) + addHistory { history with StartedTime = startTime } +} + + +let selectPipelineToRun (ctx: Internal.StageContext) = asyncResult { + let pipelines = + Directory.GetFiles(pipelineInfoDir) + |> Seq.map (fun f -> + File.ReadAllLines(f) + |> Seq.map (fun l -> + let columns = l.Split(",") + { + Pipeline.Script = columns[0] + Name = columns[1] + Description = if columns.Length > 2 then columns[2] else "" + } + ) + ) + |> Seq.concat + + let selectAndRunPipelines (pipelines: Pipeline seq) = asyncResult { + let selection = SelectionPrompt() + selection.Title <- "Select pipeline to run" + selection.Converter <- fun p -> $"[bold green]{p.Name}[/]: {p.Description} ({p.Script})" + selection.AddChoices(pipelines |> Seq.sortBy (fun x -> x.Script, x.Name)) |> ignore + let selectedPipeline = AnsiConsole.Prompt selection + do! runPipeline ctx selectedPipeline + } + + if AnsiConsole.Confirm("Do you want to search pipeline by query (y) or select manually (n)?", true) then + let rec queryAndRunPipeline () = asyncResult { + let query = AnsiConsole.Ask("Query by script file name or pipeline info: ") + let filteredPipelines = + pipelines + |> Seq.filter (fun p -> + p.Name.Contains(query, StringComparison.OrdinalIgnoreCase) + || p.Description.Contains(query, StringComparison.OrdinalIgnoreCase) + || p.Script.Contains(query, StringComparison.OrdinalIgnoreCase) + ) + |> Seq.toList + + match filteredPipelines with + | [] -> + AnsiConsole.MarkupLine("[yellow]No pipelines are found[/]") + do! queryAndRunPipeline () + | [ pipeline ] -> do! runPipeline ctx pipeline + | ps -> do! selectAndRunPipelines ps + } + + do! queryAndRunPipeline () + + else + do! selectAndRunPipelines pipelines + +} + + +// Print title +let title = FigletText("Fun Build Cli").LeftJustified() +title.Color <- Color.HotPink +AnsiConsole.Write(title) + +// Ensure to have at least one source dir +if sources.Length = 0 then addSourceDir () + + +pipeline "source" { + description "Manage source directory" + stage "add" { + whenCmdArg "--add" + run (ignore >> addSourceDir) + } + stage "remove" { + whenCmdArg "--remove" + run (fun _ -> + let selections = MultiSelectionPrompt() + selections.Title <- "Select the source directory you want to remove" + selections.AddChoices(sources) |> ignore + let choices = AnsiConsole.Prompt(selections) + sources <- sources |> Seq.filter (fun x -> choices |> Seq.contains x |> not) |> Seq.toList + File.WriteAllLines(sourcesFile, sources) + if sources.Length = 0 then addSourceDir () + ) + } + stage "refresh" { + whenCmdArg "--refresh" "" "Refresh source pipelines and cache again" + run (fun _ -> async { + for source in sources do + do! refreshPipelineInfos source + }) + } + runIfOnlySpecified +} + + +let executionOptions = {| + useLastRun = CmdArg.Create(longName = "--use-last-run", description = "Execute the last pipeline") +|} + +pipeline "execution" { + description "Execute pipeline found from sources" + stage "auto" { + whenCmdArg executionOptions.useLastRun + run (fun ctx -> asyncResult { + let history = File.ReadLines(historyFile) |> Seq.tryLast |> Option.map parseHistory + match history with + | Some history -> do! reRunHistory ctx history + | None -> AnsiConsole.MarkupLine("[yellow]No history found to run automatically[/]") + }) + } + stage "select" { + whenNot { cmdArg executionOptions.useLastRun } + run (fun ctx -> asyncResult { + if AnsiConsole.Confirm("Do you want to select history to re-run?", true) then + match selectHistory () with + | Some history -> do! reRunHistory ctx history + | None -> do! selectPipelineToRun ctx + else + do! selectPipelineToRun ctx + }) + } + runIfOnlySpecified false +} + + +tryPrintPipelineCommandHelp () diff --git a/Fun.Build.sln b/Fun.Build.sln index 61cc7ee..d578b6a 100644 --- a/Fun.Build.sln +++ b/Fun.Build.sln @@ -7,6 +7,8 @@ Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Fun.Build", "Fun.Build\Fun. EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Fun.Build.Tests", "Fun.Build.Tests\Fun.Build.Tests.fsproj", "{547641C5-E360-41CD-84F0-04D8CE9D4639}" EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Fun.Build.Cli", "Fun.Build.Cli\Fun.Build.Cli.fsproj", "{8C1CF590-C528-4483-B071-87C46CCBD38D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +23,10 @@ Global {547641C5-E360-41CD-84F0-04D8CE9D4639}.Debug|Any CPU.Build.0 = Debug|Any CPU {547641C5-E360-41CD-84F0-04D8CE9D4639}.Release|Any CPU.ActiveCfg = Release|Any CPU {547641C5-E360-41CD-84F0-04D8CE9D4639}.Release|Any CPU.Build.0 = Release|Any CPU + {8C1CF590-C528-4483-B071-87C46CCBD38D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8C1CF590-C528-4483-B071-87C46CCBD38D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C1CF590-C528-4483-B071-87C46CCBD38D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8C1CF590-C528-4483-B071-87C46CCBD38D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/CHANGELOG.md b/Fun.Build/CHANGELOG.md similarity index 98% rename from CHANGELOG.md rename to Fun.Build/CHANGELOG.md index 974bc1c..b8a6905 100644 --- a/CHANGELOG.md +++ b/Fun.Build/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +## [1.0.6] - 2024-01-17 + +- Fix typo +- Fix ctrl+c termination issue + ## [1.0.5] - 2023-11-22 - Add runBeforeEachStage and runAfterEachStage diff --git a/README.md b/README.md index 813bf03..3ad6709 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Every **step** is just a **async>**, string is for the erro ## Minimal example and conventions ```fsharp -#r "nuget: Fun.Build, 1.0.5" +#r "nuget: Fun.Build, 1.0.6" open Fun.Build pipeline "demo" { @@ -66,7 +66,7 @@ dotnet fsi build.fsx -- -p your_pipeline -h Below example covered most of the apis and usage example, take it as the documents😊: ```fsharp -#r "nuget: Fun.Build, 1.0.5" +#r "nuget: Fun.Build, 1.0.6" open Fun.Result open Fun.Build diff --git a/build.fsx b/build.fsx index 3bcde63..adcae21 100644 --- a/build.fsx +++ b/build.fsx @@ -1,9 +1,14 @@ #r "nuget: Fun.Build, 1.0.5" +open System.IO +open Fun.Result open Fun.Build open Fun.Build.Internal +let () x y = Path.Combine(x, y) + + type PipelineBuilder with [] @@ -38,19 +43,36 @@ let stage_lint = let stage_test = stage "Run unit tests" { run "dotnet test" } +let stage_buildVersion = + stage "generate Directory.build.props for version control" { + run (fun _ -> + Directory.GetDirectories(__SOURCE_DIRECTORY__) + |> Seq.filter (fun x -> File.Exists(x "CHANGELOG.md")) + |> Seq.iter (fun dir -> + let version = Changelog.GetLastVersion dir |> Option.defaultWith (fun _ -> failwith "No version available") + let content = + $""" + + + {version.Version} + +""" + File.WriteAllText(dir "Directory.Build.props", content) + ) + ) + } + pipeline "packages" { description "Build and deploy to nuget" collapseGithubActionLogs stage_checkEnv + stage_buildVersion stage_lint stage_test stage "Build packages" { - run (fun _ -> - let version = - Changelog.GetLastVersion(__SOURCE_DIRECTORY__) |> Option.defaultWith (fun () -> failwith "Version is not found") - $"dotnet pack -c Release Fun.Build/Fun.Build.fsproj -p:PackageVersion={version.Version} -o ." - ) + run "dotnet pack -c Release Fun.Build/Fun.Build.fsproj -o ." + run "dotnet pack -c Release Fun.Build.Cli/Fun.Build.Cli.fsproj -o ." } stage "Publish packages to nuget" { whenBranch "master"