Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ linters:
- ^github.com/urfave/cli.v3.ArgumentBase$
- ^github.com/urfave/cli.v3.Command$
- ^github.com/urfave/cli.v3.FlagBase$
- ^golang.org/x/crypto/ssh.+Config$
- ^golang.org/x/tools/go/analysis.Analyzer$
- ^google.golang.org/protobuf/.+Options$
- ^gopkg.in/telebot.v4.LongPoller$
Expand Down
49 changes: 44 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
<h3 align="center">sftp-sync</h3>

<p align="center">
A command-line utility for syncing a local folder with a remote FTP server on every change of files or directories.
A command-line utility for syncing a local folder with a remote FTP or SFTP server on every change of files or directories.
<br />
<br />
<a href="https://github.com/capcom6/sftp-sync/issues/new?labels=bug&template=bug-report---.md">Report Bug</a>
Expand Down Expand Up @@ -78,13 +78,14 @@

<!-- [![Product Name Screen Shot][product-screenshot]](https://example.com) -->

sftp-sync is a command-line utility for syncing a local folder with a remote FTP server on every change of files or directories.
sftp-sync is a command-line utility for syncing a local folder with a remote FTP or SFTP 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.
- Continuous synchronization: Automatically syncs local changes to the remote FTP or SFTP 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.
- Protocol support: Supports both FTP and SFTP (SSH File Transfer Protocol).

<p align="right">(<a href="#readme-top">back to top</a>)</p>

Expand Down Expand Up @@ -153,11 +154,32 @@ The binary will be available in the `bin/` directory.
## Usage
Run the `sftp-sync` command with the necessary options and arguments:

**FTP:**
```shell
sftp-sync --dest=ftp://username:password@hostname:port/path/to/remote/folder \
--exclude=.git /path/to/local/folder
```

**SFTP (password):**
```shell
sftp-sync --dest=sftp://username:password@hostname:22/path/to/remote/folder \
--exclude=.git /path/to/local/folder
```

**SFTP (SSH key):**
```shell
sftp-sync --dest="sftp://username@hostname:22/path/to/remote/folder?key=~/.ssh/id_ed25519" \
--exclude=.git /path/to/local/folder
```

**SFTP (SSH agent):**
```shell
sftp-sync --dest="sftp://username@hostname:22/path/to/remote/folder?agent=true" \
--exclude=.git /path/to/local/folder
```

> **Note:** SFTP uses SSH port 22 by default (vs FTP port 21).

### Environment Variables

- `DEBUG`: When set to any value, enables debug mode (equivalent to `--debug` flag).
Expand All @@ -169,7 +191,24 @@ sftp-sync --dest=ftp://username:password@hostname:port/path/to/remote/folder \

### Sync Command Options

- `--dest`: The destination FTP server URL. It should follow the format `ftp://username:password@hostname:port/path/to/remote/folder`.
- `--dest`: The destination server URL. Supports both FTP and SFTP:
- FTP: `ftp://username:password@hostname:port/path/to/remote/folder`
- SFTP (password): `sftp://username:password@hostname:22/path/to/remote/folder`
- SFTP (SSH key): `sftp://username@hostname:22/path?key=~/.ssh/id_ed25519`
- SFTP (SSH key with passphrase): `sftp://username@hostname:22/path?key=~/.ssh/id_ed25519&key_pass=secret`
- SFTP (SSH agent): `sftp://username@hostname:22/path?agent=true`

The URL path (e.g., `/path/to/remote/folder`) is used as the remote destination prefix. All synced files and directories are placed relative to this path. **The remote directory must already exist** before starting the sync.

SFTP URL query parameters:

| Parameter | Description | Example |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- |
| `key` | Path to SSH private key file (supports `~` expansion). If omitted, auto-detects `~/.ssh/id_ed25519`, `~/.ssh/id_ecdsa`, `~/.ssh/id_rsa` | `key=~/.ssh/custom_key` |
| `key_pass` | Passphrase for encrypted private keys | `key_pass=mysecret` |
| `agent` | Use SSH agent for authentication when set to `true` | `agent=true` |

Authentication methods are tried in order: SSH agent → private key → password.
- `--exclude`: (Optional) Specifies paths or glob patterns to exclude from synchronization. Supports `*`, `**`, and `?`. You can specify multiple `--exclude` options.

### Sync Command Arguments
Expand All @@ -196,7 +235,7 @@ The application uses structured error handling with specific exit codes:
## Roadmap

- [x] Support for patterns in the `--exclude` option.
- [ ] Support of Secure FTP (SFTP) protocol.
- [x] Support of Secure FTP (SFTP) protocol.
- [ ] Improved error handling and error messages.
- [ ] Integration with Git for automatic syncing on commit or branch changes.
- [ ] Integration with Git for linking branch to remote server.
Expand Down
9 changes: 6 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
module github.com/capcom6/sftp-sync

go 1.24.3
go 1.25.0

require (
github.com/bmatcuk/doublestar/v4 v4.10.0
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/pkg/sftp v1.13.10
github.com/samber/lo v1.52.0
github.com/urfave/cli/v3 v3.7.0
golang.org/x/crypto v0.51.0
)

require (
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
golang.org/x/sys v0.12.0 // indirect
golang.org/x/text v0.22.0 // indirect
github.com/kr/fs v0.1.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
)
16 changes: 12 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ 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/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
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=
Expand All @@ -23,10 +27,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
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/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
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=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
4 changes: 2 additions & 2 deletions internal/cli/commands/sync/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import (
func Command() *cli.Command {
return &cli.Command{
Name: "sync",
Usage: "watch a local folder for changes and sync them to a remote FTP server.",
Usage: "watch a local folder for changes and sync them to a remote FTP or SFTP server.",
Arguments: []cli.Argument{
&cli.StringArg{
Name: "source",
Expand All @@ -29,7 +29,7 @@ func Command() *cli.Command {
Flags: []cli.Flag{
&cli.StringFlag{
Name: "dest",
Usage: "destination FTP server URL",
Usage: "destination FTP/SFTP server URL (e.g., ftp://user:pass@host/path or sftp://user:pass@host:22/path)",
Required: true,
},
&cli.StringSliceFlag{
Expand Down
12 changes: 9 additions & 3 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import (
"fmt"
"net/url"

"github.com/capcom6/sftp-sync/internal/client/ftp"
"github.com/capcom6/sftp-sync/internal/client/sftp"
"github.com/capcom6/sftp-sync/internal/client/types"
logger "github.com/go-core-fx/cli-logger"
)

Expand All @@ -24,9 +27,12 @@ func New(address string, log logger.Logger) (Client, error) {
return nil, fmt.Errorf("failed to parse URL: %w", err)
}

if u.Scheme == "ftp" {
return NewFtpClient(address, log.WithContext("client", "")), nil
switch u.Scheme {
case "ftp":
return ftp.NewClient(address, log.WithContext("ftp", "")), nil
case "sftp":
return sftp.NewClient(address, log.WithContext("sftp", "")), nil
}

return nil, fmt.Errorf("%w: %s", ErrUnsupportedScheme, u.Scheme)
return nil, fmt.Errorf("%w: %s (supported: ftp, sftp)", types.ErrUnsupportedScheme, u.Scheme)
}
52 changes: 30 additions & 22 deletions internal/client/ftp.go → internal/client/ftp/ftp.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package client
package ftp

import (
"context"
Expand All @@ -10,12 +10,13 @@ import (
"strings"
"sync"

"github.com/capcom6/sftp-sync/internal/client/types"
logger "github.com/go-core-fx/cli-logger"
"github.com/jlaffaye/ftp"
"github.com/samber/lo"
)

type FtpClient struct {
type Client struct {
url string

logger logger.Logger
Expand All @@ -24,8 +25,8 @@ type FtpClient struct {
lock sync.Mutex
}

func NewFtpClient(url string, logger logger.Logger) *FtpClient {
return &FtpClient{
func NewClient(url string, logger logger.Logger) *Client {
return &Client{
url: url,

logger: logger,
Expand All @@ -35,7 +36,7 @@ func NewFtpClient(url string, logger logger.Logger) *FtpClient {
}
}

func (c *FtpClient) init(ctx context.Context) error {
func (c *Client) init(ctx context.Context) error {
c.lock.Lock()
defer c.lock.Unlock()

Expand All @@ -59,33 +60,40 @@ func (c *FtpClient) init(ctx context.Context) error {
}

if u.Scheme != "ftp" {
return fmt.Errorf("%w: %s", ErrUnsupportedScheme, u.Scheme)
return fmt.Errorf("%w: %s", types.ErrUnsupportedScheme, u.Scheme)
}

c.client, err = ftp.Dial(u.Host, ftp.DialWithContext(ctx))
conn, err := ftp.Dial(u.Host, ftp.DialWithContext(ctx))
if err != nil {
return fmt.Errorf("can't connect to %s: %w", u.Host, err)
}

password, ok := u.User.Password()
if !ok {
password = ""
user := u.User
if user == nil || user.Username() == "" {
_ = conn.Quit()
return fmt.Errorf("%w: missing FTP username in URL", types.ErrInvalidParams)
}

if loginErr := c.client.Login(u.User.Username(), password); loginErr != nil {
return fmt.Errorf("can't login as %s: %w", u.User.Username(), loginErr)
password, _ := user.Password()
if loginErr := conn.Login(user.Username(), password); loginErr != nil {
_ = conn.Quit()
return fmt.Errorf("can't login as %s: %w", user.Username(), loginErr)
}

if chErr := c.client.ChangeDir(u.Path); chErr != nil {
return fmt.Errorf("can't change directory to %s: %w", u.Path, chErr)
if u.Path != "" && u.Path != "/" {
if chErr := conn.ChangeDir(u.Path); chErr != nil {
_ = conn.Quit()
return fmt.Errorf("remote path %s does not exist or is not accessible: %w", u.Path, chErr)
}
}

c.client = conn

return nil
}

func (c *FtpClient) ping(_ context.Context) error {
func (c *Client) ping(_ context.Context) error {
if c.client == nil {
return ErrClientIsNil
return types.ErrClientIsNil
}

if err := c.client.NoOp(); err != nil {
Expand All @@ -95,7 +103,7 @@ func (c *FtpClient) ping(_ context.Context) error {
return nil
}

func (c *FtpClient) MakeDir(ctx context.Context, remotePath string) error {
func (c *Client) MakeDir(ctx context.Context, remotePath string) error {
if err := c.init(ctx); err != nil {
return err
}
Expand All @@ -117,7 +125,7 @@ func (c *FtpClient) MakeDir(ctx context.Context, remotePath string) error {
return nil
}

func (c *FtpClient) RemoveDir(ctx context.Context, remotePath string) error {
func (c *Client) RemoveDir(ctx context.Context, remotePath string) error {
if err := c.init(ctx); err != nil {
return err
}
Expand All @@ -133,7 +141,7 @@ func (c *FtpClient) RemoveDir(ctx context.Context, remotePath string) error {
return nil
}

func (c *FtpClient) UploadFile(ctx context.Context, remotePath string, localPath string) error {
func (c *Client) UploadFile(ctx context.Context, remotePath string, localPath string) error {
if err := c.init(ctx); err != nil {
return err
}
Expand All @@ -156,7 +164,7 @@ func (c *FtpClient) UploadFile(ctx context.Context, remotePath string, localPath
return nil
}

func (c *FtpClient) RemoveFile(ctx context.Context, remotePath string) error {
func (c *Client) RemoveFile(ctx context.Context, remotePath string) error {
if err := c.init(ctx); err != nil {
return err
}
Expand All @@ -169,7 +177,7 @@ func (c *FtpClient) RemoveFile(ctx context.Context, remotePath string) error {
return nil
}

func (c *FtpClient) Remove(ctx context.Context, remotePath string) error {
func (c *Client) Remove(ctx context.Context, remotePath string) error {
if err := c.init(ctx); err != nil {
return err
}
Expand Down
41 changes: 41 additions & 0 deletions internal/client/ftp/ftp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package ftp_test

import (
"net/url"
"testing"
)

func TestURLPathExtraction(t *testing.T) {
t.Parallel()

tests := []struct {
name string
rawURL string
wantPath string
}{
{
name: "nested path",
rawURL: "ftp://user:pass@host:21/uploads/project/files",
wantPath: "/uploads/project/files",
},
{name: "single directory", rawURL: "ftp://user:pass@host:21/data", wantPath: "/data"},
{name: "root path", rawURL: "ftp://user:pass@host:21/", wantPath: "/"},
{name: "no trailing slash", rawURL: "ftp://user@host:21/path", wantPath: "/path"},
{name: "with port", rawURL: "ftp://user:pass@host:2121/path/to/dir", wantPath: "/path/to/dir"},
{name: "empty path", rawURL: "ftp://user:pass@host:21", wantPath: ""},
{name: "path with special chars", rawURL: "ftp://user:pass@host:21/my%20folder", wantPath: "/my folder"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
u, err := url.Parse(tt.rawURL)
if err != nil {
t.Fatalf("url.Parse(%q) error = %v", tt.rawURL, err)
}
if u.Path != tt.wantPath {
t.Errorf("path = %q, want %q", u.Path, tt.wantPath)
}
})
}
}
Loading
Loading