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]
+
+
+
+
+
+
+
+
+
+
+
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 0000000..f82a2f6
Binary files /dev/null and b/images/logo.png differ
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)
}