From 7d7880c751a295af18c727b607ab39abecfa7dcd Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Thu, 19 Mar 2026 15:01:08 +0700 Subject: [PATCH] [cli] migrate to urfave --- .goreleaser.yaml | 3 + README.md | 277 +++++++++++++++++++++++---- go.mod | 6 +- go.sum | 14 +- images/logo.png | Bin 0 -> 10950 bytes internal/cli/codes/codes.go | 9 + internal/cli/commands/sync/config.go | 35 ++++ internal/cli/commands/sync/sync.go | 114 +++++++++++ internal/client/client.go | 6 +- internal/client/ftp.go | 15 +- internal/config/config.go | 61 ------ internal/config/config_test.go | 83 -------- internal/config/errors.go | 7 - internal/config/params.go | 14 -- internal/config/version.go | 25 --- internal/syncer/syncer.go | 36 ++-- internal/watcher/watcher.go | 16 +- main.go | 146 ++++++++------ 18 files changed, 553 insertions(+), 314 deletions(-) create mode 100644 images/logo.png create mode 100644 internal/cli/codes/codes.go create mode 100644 internal/cli/commands/sync/config.go create mode 100644 internal/cli/commands/sync/sync.go delete mode 100644 internal/config/config.go delete mode 100644 internal/config/config_test.go delete mode 100644 internal/config/errors.go delete mode 100644 internal/config/params.go delete mode 100644 internal/config/version.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 8d96513..71a0c66 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -16,6 +16,9 @@ builds: - darwin ldflags: - -s -w + - -X main.appVersion={{.Version}} + - -X main.appBuildDate={{.Date}} + - -X main.appGitCommit={{.FullCommit}} archives: - formats: ["tar.gz"] diff --git a/README.md b/README.md index 2bddc84..2540732 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,199 @@ -# sftp-sync + + + + + + + + +[![Code Size][code-size-shield]][code-size-url] +[![Contributors][contributors-shield]][contributors-url] +[![Forks][forks-shield]][forks-url] +[![Go Report Card][go-report-card-shield]][go-report-card-url] +[![Go Version][go-version-shield]][go-version-url] +[![Issues][issues-shield]][issues-url] +[![Last Commit][last-commit-shield]][last-commit-url] +[![License][license-shield]][license-url] +[![Stargazers][stars-shield]][stars-url] + + + + +
+
+ + Logo + + +

sftp-sync

+ +

+ A command-line utility for syncing a local folder with a remote FTP server on every change of files or directories. +
+
+ Report Bug + · + Request Feature +

+
-sftp-sync is a command-line utility for syncing a local folder with a remote FTP server on every change of files or directories. -## Table of contents -- [sftp-sync](#sftp-sync) - - [Table of contents](#table-of-contents) + +- [About The Project](#about-the-project) - [Features](#features) - - [Installation](#installation) - - [Usage](#usage) - - [Options](#options) - - [Arguments](#arguments) - - [Roadmap](#roadmap) - - [Contributing](#contributing) - - [License](#license) + - [Built With](#built-with) +- [Installation](#installation) + - [Prerequisites](#prerequisites) + - [Installation Methods](#installation-methods) + - [Method 1: Using Go Install (Recommended)](#method-1-using-go-install-recommended) + - [Method 2: Using Release Binaries](#method-2-using-release-binaries) + - [Method 3: Building from Source](#method-3-building-from-source) +- [Usage](#usage) + - [Environment Variables](#environment-variables) + - [Global Options](#global-options) + - [Sync Command Options](#sync-command-options) + - [Sync Command Arguments](#sync-command-arguments) + - [Error Handling](#error-handling) +- [Roadmap](#roadmap) +- [Contributing](#contributing) +- [License](#license) +- [Contact](#contact) +- [Acknowledgments](#acknowledgments) + +## About The Project -## Features + + +sftp-sync is a command-line utility for syncing a local folder with a remote FTP server on every change of files or directories. + +### Features - Continuous synchronization: Automatically syncs local changes to the remote FTP server whenever files or directories are added, modified, or deleted. - Exclude paths: Allows you to exclude specific paths from being synced. - Easy to use: Simple and intuitive command-line interface. +

(back to top)

+ +### Built With + +* [![Go][Go.dev]][Go-url] +* [![urfave/cli][urfave-cli-v3]][urfave-cli-v3-url] +* [![fsnotify][fsnotify]][fsnotify-url] +* [![joho/godotenv][godotenv]][godotenv-url] + +

(back to top)

+ + + + ## Installation -You can download the pre-built binary for your operating system from the [Releases](https://github.com/capcom6/sftp-sync/releases) section of the GitHub repository. +### Prerequisites -If you prefer to build from source, make sure you have Go installed. If not, you can download it from the official Go website: https://golang.org/dl/ +- Go 1.24.3 or higher installed on your system +- Access to an FTP server with valid credentials -Then, follow these steps: +### Installation Methods -1. Clone the sftp-sync repository: - ```shell - git clone https://github.com/capcom6/sftp-sync.git - ``` +#### Method 1: Using Go Install (Recommended) -2. Build the sftp-sync binary using the following command: - ```shell - cd sftp-sync - go build -o sftp-sync . - ``` +Install the latest version directly from the repository: -3. The binary will be generated in the current directory. You can move it to a location in your PATH for easy access. +```shell +go install github.com/capcom6/sftp-sync@latest +``` +This will install `sftp-sync` to your `$GOBIN` directory. Make sure your `$GOBIN` is in your `$PATH`. -## Usage +#### Method 2: Using Release Binaries + +Download the pre-compiled binaries from the [GitHub Releases](https://github.com/capcom6/sftp-sync/releases) page: + +1. Download the binary for your operating system and architecture +2. Make the binary executable: + ```shell + chmod +x sftp-sync + ``` +3. Move it to a directory in your `$PATH`: + ```shell + sudo mv sftp-sync /usr/local/bin/ + ``` + +#### Method 3: Building from Source + +If you prefer to build from source: -Run the `sftp-sync` command followed by the necessary options and arguments: +```shell +git clone https://github.com/capcom6/sftp-sync.git +cd sftp-sync +make build +``` + +The binary will be available in the `bin/` directory. + +

(back to top)

+ + + + +## Usage +Run the `sftp-sync` command with the necessary options and arguments: ```shell -sftp-sync --dest=ftp://username:password@hostname:port/path/to/remote/folder --exclude=.git /path/to/local/folder +sftp-sync --dest=ftp://username:password@hostname:port/path/to/remote/folder \ + --exclude=.git /path/to/local/folder ``` -### Options +### Environment Variables + +- `DEBUG`: When set to any value, enables debug mode (equivalent to `--debug` flag). + +### Global Options + +- `--debug`: Enable debug mode (can also be set via `DEBUG` environment variable). +- `--version`: Print version information. + +### Sync Command Options - `--dest`: The destination FTP server URL. It should follow the format `ftp://username:password@hostname:port/path/to/remote/folder`. - `--exclude`: (Optional) Specifies paths or patterns to exclude from the synchronization process. You can specify multiple `--exclude` options to exclude multiple paths or patterns. -- `--debug`: Enable debug mode. -### Arguments +### Sync Command Arguments -- The local folder path: The path to the local folder you want to sync with the remote FTP server. +- `source`: The local folder path to watch for changes (required positional argument). -## Roadmap +

(back to top)

+ +### Error Handling + +The application uses structured error handling with specific exit codes: + +- `0`: Success - operation completed successfully +- `1`: Parameters Error - invalid command arguments or options +- `2`: Client Error - FTP client connection or operation failed +- `3`: Output Error - logging or output system failed +- `4`: Internal Error - unexpected internal error -Here are some ideas and suggestions for future releases: +

(back to top)

+ + + + +## Roadmap - [ ] Support for patterns in the `--exclude` option. - [ ] Support of Secure FTP (SFTP) protocol. @@ -77,12 +206,84 @@ Here are some ideas and suggestions for future releases: - [ ] Parallel sync in multiple threads. - [ ] Batching events for more effective sync on frequently changes. -Feel free to open an issue or submit a pull request if you have any other ideas or suggestions! +See the [open issues](https://github.com/capcom6/sftp-sync/issues) for a full list of proposed features (and known issues). +

(back to top)

+ + + + ## Contributing -Contributions are welcome! If you find any issues or have suggestions for improvements, please open an issue or submit a pull request on the GitHub repository. +Contributions are what make the open-source community a great place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. + +If you have a suggestion to improve this project, please fork the repository and open a pull request. You can also open an issue with the `enhancement` label. +If this project is useful to you, consider starring it. +1. Fork the Project +2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) +3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the Branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +

(back to top)

+ + ## License -This project is licensed under the [Apache License 2.0](LICENSE). +Distributed under the Apache License 2.0. See `LICENSE` for more information. + +

(back to top)

+ + + + +## Contact + +Project Link: [https://github.com/capcom6/sftp-sync](https://github.com/capcom6/sftp-sync) + +

(back to top)

+ + + + +## Acknowledgments + +* [Best-README-Template](https://github.com/othneildrew/Best-README-Template) +* [urfave/cli](https://github.com/urfave/cli) +* [fsnotify](https://github.com/fsnotify/fsnotify) + +

(back to top)

+ + + + + +[contributors-shield]: https://img.shields.io/github/contributors/capcom6/sftp-sync.svg?style=for-the-badge +[contributors-url]: https://github.com/capcom6/sftp-sync/graphs/contributors +[forks-shield]: https://img.shields.io/github/forks/capcom6/sftp-sync.svg?style=for-the-badge +[forks-url]: https://github.com/capcom6/sftp-sync/network/members +[stars-shield]: https://img.shields.io/github/stars/capcom6/sftp-sync.svg?style=for-the-badge +[stars-url]: https://github.com/capcom6/sftp-sync/stargazers +[issues-shield]: https://img.shields.io/github/issues/capcom6/sftp-sync.svg?style=for-the-badge +[issues-url]: https://github.com/capcom6/sftp-sync/issues +[license-shield]: https://img.shields.io/github/license/capcom6/sftp-sync.svg?style=for-the-badge +[license-url]: https://github.com/capcom6/sftp-sync/blob/master/LICENSE +[product-screenshot]: images/screenshot.png + +[Go.dev]: https://img.shields.io/badge/Go-00ADD8?style=for-the-badge&logo=go&logoColor=white +[Go-url]: https://golang.org/ +[urfave-cli-v3]: https://img.shields.io/badge/urfave%2Fcli-00ADD8?style=for-the-badge&logo=go&logoColor=white +[urfave-cli-v3-url]: https://github.com/urfave/cli +[fsnotify]: https://img.shields.io/badge/fsnotify-00ADD8?style=for-the-badge&logo=go&logoColor=white +[fsnotify-url]: https://github.com/fsnotify/fsnotify +[godotenv]: https://img.shields.io/badge/joho%2Fgodotenv-00ADD8?style=for-the-badge&logo=go&logoColor=white +[godotenv-url]: https://github.com/joho/godotenv +[go-report-card-shield]: https://goreportcard.com/badge/github.com/capcom6/sftp-sync +[go-report-card-url]: https://goreportcard.com/report/github.com/capcom6/sftp-sync +[go-version-shield]: https://img.shields.io/github/go-mod/go-version/capcom6/sftp-sync?style=for-the-badge +[go-version-url]: https://github.com/capcom6/sftp-sync/blob/master/go.mod +[code-size-shield]: https://img.shields.io/github/languages/code-size/capcom6/sftp-sync?style=for-the-badge +[code-size-url]: https://github.com/capcom6/sftp-sync +[last-commit-shield]: https://img.shields.io/github/last-commit/capcom6/sftp-sync?style=for-the-badge +[last-commit-url]: https://github.com/capcom6/sftp-sync/commits/master diff --git a/go.mod b/go.mod index 72d18cd..1ae086f 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,14 @@ module github.com/capcom6/sftp-sync -go 1.20 +go 1.24.3 require ( - github.com/capcom6/logutils v0.0.0-20230920155643-1a119fe7e5e7 github.com/fsnotify/fsnotify v1.6.0 + github.com/go-core-fx/cli-logger v0.0.0-20260319073231-90ee4649c242 github.com/jlaffaye/ftp v0.2.0 + github.com/joho/godotenv v1.5.1 github.com/samber/lo v1.52.0 + github.com/urfave/cli/v3 v3.7.0 ) require ( diff --git a/go.sum b/go.sum index 8349845..756b090 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,9 @@ -github.com/capcom6/logutils v0.0.0-20230920155643-1a119fe7e5e7 h1:x1ods8rvbs4zNsOvEo/J8bHCMguAI70wBaWrJTn29/k= -github.com/capcom6/logutils v0.0.0-20230920155643-1a119fe7e5e7/go.mod h1:9dTHOvevK7Fwj4eBAbKDWSz+zarTV5pRatjmsDCL690= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/go-core-fx/cli-logger v0.0.0-20260319073231-90ee4649c242 h1:pZh2A/UZEceQZo4FMos2L1ZHO8QHxWpqNQ5ybqoWlnc= +github.com/go-core-fx/cli-logger v0.0.0-20260319073231-90ee4649c242/go.mod h1:6xzOTd0JZcXvC1ZwjU97rbbY4IjvicVZVIB3OjFm1f8= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -10,13 +11,20 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/jlaffaye/ftp v0.2.0 h1:lXNvW7cBu7R/68bknOX3MrRIIqZ61zELs1P2RAiA3lg= github.com/jlaffaye/ftp v0.2.0/go.mod h1:is2Ds5qkhceAPy2xD6RLI6hmp/qysSoymZ+Z2uTnspI= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= -github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/urfave/cli/v3 v3.7.0 h1:AGSnbUyjtLiM+WJUb4dzXKldl/gL+F8OwmRDtVr6g2U= +github.com/urfave/cli/v3 v3.7.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/images/logo.png b/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f82a2f66da651258cd02334a1b76afa79c099629 GIT binary patch literal 10950 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 literal 0 HcmV?d00001 diff --git a/internal/cli/codes/codes.go b/internal/cli/codes/codes.go new file mode 100644 index 0000000..0b997fd --- /dev/null +++ b/internal/cli/codes/codes.go @@ -0,0 +1,9 @@ +package codes + +const ( + Success = iota + ParamsError + ClientError + OutputError + InternalError +) diff --git a/internal/cli/commands/sync/config.go b/internal/cli/commands/sync/config.go new file mode 100644 index 0000000..0ed2d47 --- /dev/null +++ b/internal/cli/commands/sync/config.go @@ -0,0 +1,35 @@ +package sync + +import "github.com/urfave/cli/v3" + +type config struct { + Source string + Dest string + Excludes []string +} + +func (c config) validate() error { + if c.Source == "" { + return cli.Exit("source directory is required", 1) + } + + if c.Dest == "" { + return cli.Exit("destination server is required", 1) + } + + return nil +} + +func parseConfig(cmd *cli.Command) (config, error) { + cfg := config{ + Source: "", + Dest: "", + Excludes: nil, + } + + cfg.Source = cmd.StringArg("source") + cfg.Dest = cmd.String("dest") + cfg.Excludes = cmd.StringSlice("exclude") + + return cfg, cfg.validate() +} diff --git a/internal/cli/commands/sync/sync.go b/internal/cli/commands/sync/sync.go new file mode 100644 index 0000000..d47c243 --- /dev/null +++ b/internal/cli/commands/sync/sync.go @@ -0,0 +1,114 @@ +package sync + +import ( + "context" + "sync" + + "github.com/capcom6/sftp-sync/internal/cli/codes" + "github.com/capcom6/sftp-sync/internal/client" + "github.com/capcom6/sftp-sync/internal/syncer" + "github.com/capcom6/sftp-sync/internal/watcher" + logger "github.com/go-core-fx/cli-logger" + "github.com/urfave/cli/v3" +) + +func Command() *cli.Command { + return &cli.Command{ + Name: "sync", + Usage: "watch a local folder for changes and sync them to a remote FTP server.", + Arguments: []cli.Argument{ + &cli.StringArg{ + Name: "source", + UsageText: "local directory to watch for changes", + Config: cli.StringConfig{ + TrimSpace: true, + }, + }, + }, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "dest", + Usage: "destination FTP server URL", + Required: true, + }, + &cli.StringSliceFlag{ + Name: "exclude", + Usage: "paths or patterns to exclude from the synchronization process", + }, + }, + ArgsUsage: "[source]", + Before: Before, + Action: Action, + } +} + +func Before(ctx context.Context, cmd *cli.Command) (context.Context, error) { + if cmd.Args().Len() != 1 { + return ctx, cli.Exit("exactly one argument is required", codes.ParamsError) + } + + return ctx, nil +} + +func Action(ctx context.Context, cmd *cli.Command) error { + log := logger.GetLogger(ctx) + if log == nil { + return cli.Exit("failed to retrieve logger", codes.InternalError) + } + + operationID := logger.GenerateOperationID("sync") + log = log.WithContext("sync-cmd", operationID) + + log.Info(ctx, "Sync command initiated") + + cfg, err := parseConfig(cmd) + if err != nil { + log.Error(ctx, "Failed to parse config", err) + return cli.Exit(err.Error(), codes.ParamsError) + } + + remote, err := client.New(cfg.Dest, log) + if err != nil { + log.Error(ctx, "Failed to create remote client", err) + return cli.Exit(err.Error(), codes.ClientError) + } + + watcher := watcher.New(cfg.Source, cfg.Excludes, log) + syncer := syncer.New(cfg.Source, remote, log) + + var wg sync.WaitGroup + + ch, err := watcher.Watch(ctx, &wg) + if err != nil { + log.Error(ctx, "Failed to start watcher", err) + return cli.Exit(err.Error(), codes.InternalError) + } + + wg.Add(1) + go func() { + defer wg.Done() + + for { + select { + case event, ok := <-ch: + if !ok { + log.Warn(ctx, "watcher channel closed") + return + } + log.Debug(ctx, "Event received", logger.Fields{"event": event}) + if syncErr := syncer.Sync(ctx, event.AbsPath); syncErr != nil { + log.Error(ctx, "Failed to sync", syncErr) + } + case <-ctx.Done(): + return + } + } + }() + + log.Info(ctx, "Sync command started") + + wg.Wait() + + log.Info(ctx, "Sync command completed") + return nil +} diff --git a/internal/client/client.go b/internal/client/client.go index 2b974e0..a981ce4 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "net/url" + + logger "github.com/go-core-fx/cli-logger" ) type Client interface { @@ -16,14 +18,14 @@ type Client interface { Remove(ctx context.Context, remotePath string) error } -func New(address string) (Client, error) { +func New(address string, log logger.Logger) (Client, error) { u, err := url.Parse(address) if err != nil { return nil, fmt.Errorf("failed to parse URL: %w", err) } if u.Scheme == "ftp" { - return NewFtpClient(address), nil + return NewFtpClient(address, log.WithContext("client", "")), nil } return nil, fmt.Errorf("%w: %s", ErrUnsupportedScheme, u.Scheme) diff --git a/internal/client/ftp.go b/internal/client/ftp.go index 18c0de6..f6cea75 100644 --- a/internal/client/ftp.go +++ b/internal/client/ftp.go @@ -10,7 +10,7 @@ import ( "strings" "sync" - "github.com/capcom6/logutils" + logger "github.com/go-core-fx/cli-logger" "github.com/jlaffaye/ftp" "github.com/samber/lo" ) @@ -18,14 +18,18 @@ import ( type FtpClient struct { url string + logger logger.Logger + client *ftp.ServerConn lock sync.Mutex } -func NewFtpClient(url string) *FtpClient { +func NewFtpClient(url string, logger logger.Logger) *FtpClient { return &FtpClient{ url: url, + logger: logger, + client: nil, lock: sync.Mutex{}, } @@ -41,7 +45,9 @@ func (c *FtpClient) init(ctx context.Context) error { return nil } - logutils.Debugln("Reconnecting because of error:", err) + c.logger.Warn(ctx, "Reconnecting because of error", logger.Fields{ + "error": err, + }) _ = c.client.Quit() c.client = nil @@ -192,7 +198,6 @@ func (c *FtpClient) Remove(ctx context.Context, remotePath string) error { func isIgnorableError(err error) bool { if err, ok := lo.ErrorsAs[*textproto.Error](err); ok && err.Code == 550 { - logutils.Debugf("ignore error %s", err) return true } return false @@ -211,7 +216,7 @@ func splitPath(dir string) []string { entries = append(entries, dir) } - for i := 0; i < len(entries)/2; i++ { + for i := range len(entries) / 2 { entries[i], entries[len(entries)-i-1] = entries[len(entries)-i-1], entries[i] } diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 6cbf13b..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,61 +0,0 @@ -package config - -import ( - "flag" - "fmt" - "os" - "path" -) - -type Config struct { - WatchPath string - ExcludePaths []string - Dest string - Debug bool -} - -func (c *Config) validate() error { - if c.Dest == "" { - return fmt.Errorf("%w: destination server is required", ErrValidationFailed) - } - - if c.WatchPath == "" { - return fmt.Errorf("%w: source directory is required", ErrValidationFailed) - } - - return nil -} - -func Parse(args []string) (Config, error) { - var cfg Config - - dest := "" - exclude := make(arrayValue, 0, 1) - - flagSet := flag.NewFlagSet("", flag.ContinueOnError) - flagSet.SetOutput(os.Stdout) - flagSet.StringVar(&dest, "dest", "", "destination server") - flagSet.Var(&exclude, "exclude", "exclude paths") - flagSet.BoolVar(&cfg.Debug, "debug", false, "debug mode") - - flagSet.Usage = func() { - fmt.Fprintln(os.Stdout, "(S)FTP Syncer") - printVersion() - fmt.Fprintf(os.Stdout, "Usage: %s [flags]\n", path.Base(os.Args[0])) - flagSet.PrintDefaults() - } - - if err := flagSet.Parse(args); err != nil { - return cfg, fmt.Errorf("failed to parse flags: %w", err) - } - - cfg.Dest = dest - cfg.ExcludePaths = exclude - cfg.WatchPath = flagSet.Arg(0) - - if err := cfg.validate(); err != nil { - return cfg, err - } - - return cfg, nil -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go deleted file mode 100644 index 2fec350..0000000 --- a/internal/config/config_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package config_test - -import ( - "errors" - "reflect" - "testing" - - "github.com/capcom6/sftp-sync/internal/config" -) - -func TestParse(t *testing.T) { - type args struct { - args []string - } - tests := []struct { - name string - args args - want config.Config - wantErr bool - }{ - { - name: "Empty", - args: args{}, - want: config.Config{ - WatchPath: "", - ExcludePaths: []string{}, - Dest: "", - }, - wantErr: true, - }, - { - name: "Source and dest", - args: args{ - args: []string{"--dest", "dest", "path"}, - }, - want: config.Config{ - WatchPath: "path", - ExcludePaths: []string{}, - Dest: "dest", - }, - wantErr: false, - }, - { - name: "Single exclude", - args: args{ - args: []string{"--dest", "dest", "--exclude", "ex1", "path"}, - }, - want: config.Config{ - WatchPath: "path", - ExcludePaths: []string{"ex1"}, - Dest: "dest", - }, - wantErr: false, - }, - { - name: "Multiple excludes", - args: args{ - args: []string{"--dest", "dest", "--exclude", "ex1", "--exclude", "ex2", "path"}, - }, - want: config.Config{ - WatchPath: "path", - ExcludePaths: []string{"ex1", "ex2"}, - Dest: "dest", - }, - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := config.Parse(tt.args.args) - if (err != nil) != tt.wantErr { - t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) - return - } - if tt.wantErr && !errors.Is(err, config.ErrValidationFailed) { - t.Errorf("Parse() error = %v, want %v", err, config.ErrValidationFailed) - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("Parse() = %+#v, want %+#v", got, tt.want) - } - }) - } -} diff --git a/internal/config/errors.go b/internal/config/errors.go deleted file mode 100644 index c8850d8..0000000 --- a/internal/config/errors.go +++ /dev/null @@ -1,7 +0,0 @@ -package config - -import "errors" - -var ( - ErrValidationFailed = errors.New("validation failed") -) diff --git a/internal/config/params.go b/internal/config/params.go deleted file mode 100644 index 93b7427..0000000 --- a/internal/config/params.go +++ /dev/null @@ -1,14 +0,0 @@ -package config - -import "strings" - -type arrayValue []string - -func (a *arrayValue) String() string { - return strings.Join(*a, ",") -} - -func (a *arrayValue) Set(value string) error { - *a = append(*a, value) - return nil -} diff --git a/internal/config/version.go b/internal/config/version.go deleted file mode 100644 index 6e5ea89..0000000 --- a/internal/config/version.go +++ /dev/null @@ -1,25 +0,0 @@ -package config - -import ( - "fmt" - "os" -) - -const notSet string = "not set" - -// these information will be collected when build, by `-ldflags "-X main.appVersion=0.1"`. -// -//nolint:gochecknoglobals // build metadata -var ( - appVersion = notSet - buildTime = notSet - gitCommit = notSet - gitRef = notSet -) - -func printVersion() { - fmt.Fprintf(os.Stdout, "Version: %s\n", appVersion) - fmt.Fprintf(os.Stdout, "Build Time: %s\n", buildTime) - fmt.Fprintf(os.Stdout, "Git Commit: %s\n", gitCommit) - fmt.Fprintf(os.Stdout, "Git Ref: %s\n", gitRef) -} diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index 6bbb396..0a91eae 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -8,19 +8,23 @@ import ( "path" "path/filepath" - "github.com/capcom6/logutils" "github.com/capcom6/sftp-sync/internal/client" + logger "github.com/go-core-fx/cli-logger" ) type Syncer struct { - RootPath string - Client client.Client + rootPath string + client client.Client + + logger logger.Logger } -func New(rootPath string, client client.Client) *Syncer { +func New(rootPath string, client client.Client, logger logger.Logger) *Syncer { return &Syncer{ - RootPath: rootPath, - Client: client, + rootPath: rootPath, + client: client, + + logger: logger.WithContext("syncer", ""), } } @@ -30,7 +34,7 @@ func (s *Syncer) Sync(ctx context.Context, absPath string) error { return fmt.Errorf("fsInfo: %w", err) } - absRoot, err := filepath.Abs(s.RootPath) + absRoot, err := filepath.Abs(s.rootPath) if err != nil { return fmt.Errorf("filepath.Abs: %w", err) } @@ -41,11 +45,13 @@ func (s *Syncer) Sync(ctx context.Context, absPath string) error { } if !exists { - if rmErr := s.Client.Remove(ctx, pathNormalize(relPath)); rmErr != nil { + if rmErr := s.client.Remove(ctx, pathNormalize(relPath)); rmErr != nil { return fmt.Errorf("c.Remove: %w", rmErr) } - logutils.Printf("--- %s\n", relPath) + s.logger.Info(ctx, "Removed", logger.Fields{ + "path": relPath, + }) return nil } @@ -58,20 +64,24 @@ func (s *Syncer) Sync(ctx context.Context, absPath string) error { } func (s *Syncer) syncFile(ctx context.Context, absPath, relPath string) error { - if err := s.Client.UploadFile(ctx, pathNormalize(relPath), pathNormalize(absPath)); err != nil { + if err := s.client.UploadFile(ctx, pathNormalize(relPath), pathNormalize(absPath)); err != nil { return fmt.Errorf("c.UploadFile: %w", err) } - logutils.Printf("--> %s\n", relPath) + s.logger.Info(ctx, "Uploaded", logger.Fields{ + "path": relPath, + }) return nil } func (s *Syncer) syncDir(ctx context.Context, absPath, relPath string) error { - if err := s.Client.MakeDir(ctx, pathNormalize(relPath)); err != nil { + if err := s.client.MakeDir(ctx, pathNormalize(relPath)); err != nil { return fmt.Errorf("c.MakeDir: %w", err) } - logutils.Printf("+++ %s\n", relPath) + s.logger.Info(ctx, "Created", logger.Fields{ + "path": relPath, + }) files, err := os.ReadDir(absPath) if err != nil { diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 4de1dcc..6d0927a 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -8,25 +8,29 @@ import ( "strings" "sync" - "github.com/capcom6/logutils" "github.com/fsnotify/fsnotify" + logger "github.com/go-core-fx/cli-logger" ) type Watcher struct { rootPath string excludes []string + logger logger.Logger + absRootPath string absExcludes []string fswatcher *fsnotify.Watcher events chan Event } -func New(rootPath string, excludes []string) *Watcher { +func New(rootPath string, excludes []string, logger logger.Logger) *Watcher { return &Watcher{ rootPath: rootPath, excludes: excludes, + logger: logger.WithContext("watcher", ""), + absRootPath: "", absExcludes: nil, fswatcher: nil, @@ -91,16 +95,18 @@ func (w *Watcher) runWatcher(ctx context.Context) { continue } - logutils.Debug("event:", event) + w.logger.Debug(ctx, "Event received", logger.Fields{ + "event": event, + }) if prErr := w.processEvent(ctx, event); prErr != nil { - logutils.Error(prErr) + w.logger.Error(ctx, "Failed to process event", prErr) } case watchErr, ok := <-w.fswatcher.Errors: if !ok { return } - logutils.Error(watchErr) + w.logger.Error(ctx, "Watcher error", watchErr) case <-ctx.Done(): return } diff --git a/main.go b/main.go index e5825e1..d311e59 100644 --- a/main.go +++ b/main.go @@ -2,80 +2,114 @@ package main import ( "context" - "log" + "fmt" "os" "os/signal" - "sync" + "runtime" + "syscall" - "github.com/capcom6/logutils" - "github.com/capcom6/sftp-sync/internal/client" - "github.com/capcom6/sftp-sync/internal/config" - "github.com/capcom6/sftp-sync/internal/syncer" - "github.com/capcom6/sftp-sync/internal/watcher" + "github.com/capcom6/sftp-sync/internal/cli/codes" + "github.com/capcom6/sftp-sync/internal/cli/commands/sync" + logger "github.com/go-core-fx/cli-logger" + "github.com/joho/godotenv" + "github.com/samber/lo" + "github.com/urfave/cli/v3" +) + +//nolint:gochecknoglobals // build metadata +var ( + appVersion = "dev" + appBuildDate = "unknown" + appGitCommit = "unknown" + appGoVersion = runtime.Version() ) func main() { - cfg, err := config.Parse(os.Args[1:]) - if err != nil { - log.Fatalln(err) - } - setUpLogging(cfg) + log := logger.NewDefault() - wg := &sync.WaitGroup{} - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) - defer cancel() + rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - remoteClient, err := client.New(cfg.Dest) - if err != nil { - logutils.Fatalln(err) + ctx := logger.WithLogger(rootCtx, log) + + // Load environment variables + if err := godotenv.Load(); err != nil && !os.IsNotExist(err) { + log.Error(ctx, "Failed to load .env file", err) } - watch := watcher.New(cfg.WatchPath, cfg.ExcludePaths) - syncer := syncer.New(cfg.WatchPath, remoteClient) + //nolint:reassign // urfave/cli specific + cli.VersionPrinter = func(cmd *cli.Command) { + fmt.Fprintf(cmd.Root().Writer, "Version: %s\n", appVersion) + fmt.Fprintf(cmd.Root().Writer, "Build Date: %s\n", appBuildDate) + fmt.Fprintf(cmd.Root().Writer, "Git Commit: %s\n", appGitCommit) + fmt.Fprintf(cmd.Root().Writer, "Go Version: %s\n", appGoVersion) + } - ch, err := watch.Watch(ctx, wg) - if err != nil { - logutils.Fatalln(err) + //nolint:reassign // urfave/cli specific + cli.VersionFlag = &cli.BoolFlag{ + Name: "version", + Usage: "print the version", + HideDefault: true, + Local: true, } - wg.Add(1) - go func() { - defer wg.Done() - for { - select { - case event, ok := <-ch: - if !ok { - logutils.Errorln("watcher channel closed") - cancel() - return - } - logutils.Debug("event:", event) - if syncErr := syncer.Sync(ctx, event.AbsPath); syncErr != nil { - logutils.Errorln(syncErr) - } - case <-ctx.Done(): - return - } - } - }() + app := &cli.Command{ + Name: "sftp-sync", + Usage: "a command-line utility for syncing a local folder with a remote FTP server on every change of files or directories.", + Version: appVersion, + ArgsUsage: "[source]", + Arguments: []cli.Argument{ + &cli.StringArg{ + Name: "source", + UsageText: "local directory to watch for changes", + Config: cli.StringConfig{ + TrimSpace: true, + }, + }, + }, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "debug", + Usage: "enable debug mode", + Sources: cli.EnvVars("DEBUG"), + }, - logutils.Println("Watching...") - wg.Wait() + &cli.StringFlag{ + Name: "dest", + Usage: "destination FTP server URL", + Required: true, + }, + &cli.StringSliceFlag{ + Name: "exclude", + Usage: "paths or patterns to exclude from the synchronization process", + }, + }, + Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { + if cmd.Bool("debug") { + log.SetLevel(logger.LogLevelDebug) + } - logutils.Println("Bye!") -} + return sync.Before(ctx, cmd) + }, + Action: sync.Action, + Authors: []any{ + "Aleksandr Soloshenko ", + }, + Copyright: "License: Apache-2.0", + } -func setUpLogging(cfg config.Config) { - logLevel := "INFO" - if cfg.Debug { - logLevel = "DEBUG" + exitCode := codes.Success + if err := app.Run(ctx, os.Args); err != nil { + log.Error(ctx, "Application failed", err) + exitCode = codes.InternalError + if exitErr, ok := lo.ErrorsAs[cli.ExitCoder](err); ok { + exitCode = exitErr.ExitCode() + } } - filter := logutils.LevelFilter{ - Levels: []logutils.LogLevel{"DEBUG", "INFO", "WARN", "ERROR"}, - MinLevel: logutils.LogLevel(logLevel), - Writer: os.Stdout, + if err := log.Close(); err != nil { + fmt.Fprintf(os.Stderr, "failed to close logger: %v\n", err) } - log.SetOutput(&filter) + stop() + os.Exit(exitCode) }