slaveOftime

Use SSH.NET to sync my blogs

2025-11-20

Before for this simple technical playground blog website, I always use github actions to trigger a full build for dynamic and static posts to deploy to IIS. Later on, I moved to linux docker, so everything is bundled into a docker image even for static posts.

I know there are many command line tools to sync files with ssh for linux, mac, or windows. But all of them are quite different. I also want to use a managed SSH package to do this simple stuff so I can learn something.

I tried to use SSH.NET with fsharp script, so after I created my static posts, I can run the script, something like:

dotnet fsi sync.fsx -- -p sync-posts --host xxx --user xxx

Source of the script

#r "nuget: SSH.NET"
#r "nuget: Fun.Build"

open Fun.Build
open Fun.Result
open Renci.SshNet
open System
open System.IO
open Spectre.Console

let locker = obj ()
let printfn fmt = Printf.kprintf (fun s -> lock locker (fun () -> System.Console.WriteLine s)) fmt

let localDir = xxx
let remoteDir = xxx


pipeline "sync-posts" {
    description "Sync changed files from the local directory to the remote server via SFTP."
    whenCmdArg "--host"
    whenCmdArg "--user"
    stage "sync" {
        run (fun ctx -> async {
            let host = ctx.GetCmdArg "--host"
            let user = ctx.GetCmdArg "--user"
            let password = AnsiConsole.Prompt(TextPrompt<string>("Enter SSH password: ").Secret())

            let processFiles i files = async {
                printfn "[%d] Connect to %s..." i host
                use sftp = new SftpClient(host, user, password)
                sftp.Connect()
                printfn "[%d] Connected to %s as %s" i host user

                let rec ensureDir (dir: string) =
                    if Path.GetDirectoryName dir |> sftp.Exists |> not then
                        ensureDir (Path.GetDirectoryName dir)
                    if not (sftp.Exists dir) then sftp.CreateDirectory(dir)

                for file in files do
                    try
                        let localLastWriteTime = FileInfo(file).LastWriteTimeUtc
                        let remoteFile = Path.Combine(remoteDir, Path.GetRelativePath(localDir, file)).Replace("\\", "/")

                        let upload () =
                            ensureDir (Path.GetDirectoryName remoteFile)

                            use fileStream = File.OpenRead(file)
                            sftp.UploadFile(fileStream, remoteFile, true)
                            sftp.SetLastWriteTimeUtc(remoteFile, localLastWriteTime)
                            printfn "[%d] File uploaded: %s" i remoteFile

                        if sftp.Exists remoteFile then
                            let remoteLastWriteTime = sftp.GetLastWriteTimeUtc(remoteFile)
                            if localLastWriteTime <> remoteLastWriteTime then
                                upload ()
                            else
                                printfn "[%d] File is up to date: %s" i file
                        else
                            upload ()

                    with ex ->
                        printfn "[%d] Error sync file %s: %s" i file ex.Message
                        AnsiConsole.WriteException ex
            }

            do!
                Directory.GetFiles(localDir, "*.*", SearchOption.AllDirectories)
                |> Seq.splitInto (Math.Min(8, Environment.ProcessorCount))
                |> Seq.mapi processFiles
                |> Async.Parallel
                |> Async.map ignore

            printfn "Connect to %s..." host
            use ssh = new SshClient(host, user, password)
            ssh.Connect()

            printfn "Restart the slaveoftime.site container..."
            use cmd = ssh.RunCommand("cd /root/aliyun-sites-host && docker compose restart slaveoftime.site")
            do! cmd.ExecuteAsync() |> Async.AwaitTask
            printfn "Restarted slaveoftime.site container: %s" cmd.Result
        })
    }
    runIfOnlySpecified
}


tryPrintPipelineCommandHelp ()

Do you like this post?