diff --git a/cmd/root.go b/cmd/root.go index 9c11343..3c01d25 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "dirmd/internal/config" + "dirmd/internal/drive" "dirmd/internal/renderer" "dirmd/internal/walker" ) @@ -30,7 +31,13 @@ Examples: dirmd -a -i README.md -o existing.md # Append another directory - dirmd -a -o existing.md ~/repos/anotherproject`, + dirmd -a -o existing.md ~/repos/anotherproject + + # Generate and upload to Proton Drive (default remote path /my-files/md/) + dirmd -o output.md ~/repos/myproject --proton-drive + + # Generate and upload to a specific remote path + dirmd -o output.md ~/repos/myproject --proton-drive --drive-path /my-files/docs/api.md`, RunE: run, } @@ -41,6 +48,8 @@ Examples: maxDepth int ignorePatterns []string extensions []string + protonDrive bool + drivePath string ) func Execute() { @@ -57,18 +66,26 @@ func init() { rootCmd.Flags().IntVar(&maxDepth, "max-depth", config.DefaultMaxDepth, "max directory recursion depth") rootCmd.Flags().StringArrayVar(&ignorePatterns, "ignore", nil, "additional ignore patterns (glob)") rootCmd.Flags().StringArrayVar(&extensions, "extensions", nil, "additional file extensions to skip (repeatable)") + rootCmd.Flags().BoolVar(&protonDrive, "proton-drive", false, "upload output to Proton Drive after writing locally") + rootCmd.Flags().StringVar(&drivePath, "drive-path", "", "full remote path on Proton Drive (e.g. /my-files/md/report.md). Requires --proton-drive") rootCmd.MarkPersistentFlagRequired("output") } func run(cmd *cobra.Command, args []string) error { + if drivePath != "" && !protonDrive { + return fmt.Errorf("--drive-path requires --proton-drive") + } + cfg := &config.Config{ - OutputPath: outputPath, - AppendMode: appendMode, - MaxSize: maxSize, - MaxDepth: maxDepth, - Ignores: append(config.DefaultIgnores, ignorePatterns...), - Extensions: append(config.DefaultExts, extensions...), + OutputPath: outputPath, + AppendMode: appendMode, + MaxSize: maxSize, + MaxDepth: maxDepth, + Ignores: append(config.DefaultIgnores, ignorePatterns...), + Extensions: append(config.DefaultExts, extensions...), + ProtonDrive: protonDrive, + DrivePath: drivePath, } if singleFile != "" { @@ -111,6 +128,27 @@ func run(cmd *cobra.Command, args []string) error { } fmt.Fprintf(os.Stderr, "wrote %d entries to %s\n", len(result.Entries), outputPath) + + if protonDrive { + remotePath := drivePath + if remotePath == "" { + remotePath = "/my-files/md/" + filepath.Base(outputPath) + } + + if err := drive.CheckAuth(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + fmt.Fprintln(os.Stderr, "local file was written successfully, but upload was skipped") + return err + } + + if err := drive.Upload(outputPath, remotePath); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + fmt.Fprintln(os.Stderr, "local file was written successfully, but upload failed") + return err + } + + fmt.Fprintf(os.Stderr, "uploaded %s to %s on Proton Drive\n", outputPath, remotePath) + } + return nil } - diff --git a/internal/config/config.go b/internal/config/config.go index 769fe0e..b65e276 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -53,15 +53,17 @@ func (s *stringSlice) Set(v string) error { } type Config struct { - InputPath string - OutputPath string - AppendMode bool - SingleFile bool - MaxSize int64 - MaxDepth int - Ignores []string - Extensions []string - AbsRoot string + InputPath string + OutputPath string + AppendMode bool + SingleFile bool + MaxSize int64 + MaxDepth int + Ignores []string + Extensions []string + AbsRoot string + ProtonDrive bool + DrivePath string } func PrintUsage() { @@ -74,14 +76,16 @@ func PrintUsage() { func Parse() (*Config, error) { var ( - input string - output string - appendMd bool - maxSize int64 - maxDepth int - ignores stringSlice - exts stringSlice - showVer bool + input string + output string + appendMd bool + maxSize int64 + maxDepth int + ignores stringSlice + exts stringSlice + showVer bool + protonDrive bool + drivePath string ) flag.StringVar(&input, "i", "", "single file input (append mode only)") @@ -92,6 +96,8 @@ func Parse() (*Config, error) { flag.Var(&ignores, "ignore", "additional ignore patterns (glob, repeatable)") flag.Var(&exts, "extensions", "additional file extensions to skip (repeatable)") flag.BoolVar(&showVer, "version", false, "print version and exit") + flag.BoolVar(&protonDrive, "proton-drive", false, "upload output to Proton Drive after writing locally") + flag.StringVar(&drivePath, "drive-path", "", "full remote path on Proton Drive (requires --proton-drive)") flag.Usage = PrintUsage flag.Parse() @@ -105,13 +111,19 @@ func Parse() (*Config, error) { return nil, fmt.Errorf("-o is required") } + if drivePath != "" && !protonDrive { + return nil, fmt.Errorf("--drive-path requires --proton-drive") + } + cfg := &Config{ - OutputPath: output, - AppendMode: appendMd, - MaxSize: maxSize, - MaxDepth: maxDepth, - Ignores: append(DefaultIgnores, ignores...), - Extensions: append(DefaultExts, exts...), + OutputPath: output, + AppendMode: appendMd, + MaxSize: maxSize, + MaxDepth: maxDepth, + Ignores: append(DefaultIgnores, ignores...), + Extensions: append(DefaultExts, exts...), + ProtonDrive: protonDrive, + DrivePath: drivePath, } if input != "" { diff --git a/internal/drive/drive.go b/internal/drive/drive.go new file mode 100644 index 0000000..3be6307 --- /dev/null +++ b/internal/drive/drive.go @@ -0,0 +1,88 @@ +package drive + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const binaryName = "proton-drive" + +// Lookup returns the full path to the proton-drive binary in PATH. +func Lookup() (string, error) { + return exec.LookPath(binaryName) +} + +// CheckAuth verifies that the user is authenticated by listing /my-files. +func CheckAuth() error { + binary, err := Lookup() + if err != nil { + return fmt.Errorf("proton-drive not found in PATH: %w", err) + } + + cmd := exec.Command(binary, "filesystem", "list", "/my-files") + cmd.Stdout = nil + cmd.Stderr = nil + + if err := cmd.Run(); err != nil { + return fmt.Errorf("not authenticated with proton-drive (run 'proton-drive auth login'): %w", err) + } + return nil +} + +// Upload uploads localPath to remotePath on Proton Drive. +// remotePath is a full path including filename (e.g. /my-files/md/report.md). +// The CLI uploads to a parent directory and preserves the local filename, +// so if the remote filename differs from the local one, a temp copy is staged. +// Existing remote files are overwritten (--conflict-strategy replace). +func Upload(localPath, remotePath string) error { + binary, err := Lookup() + if err != nil { + return fmt.Errorf("proton-drive not found in PATH: %w", err) + } + + remoteDir := filepath.Dir(remotePath) + remoteName := filepath.Base(remotePath) + localName := filepath.Base(localPath) + + if remoteName == localName { + return runUpload(binary, localPath, remoteDir) + } + + tmpDir, err := os.MkdirTemp("", "dirmd-upload-*") + if err != nil { + return fmt.Errorf("cannot create temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + tmpFile := filepath.Join(tmpDir, remoteName) + if err := copyFile(localPath, tmpFile); err != nil { + return fmt.Errorf("cannot stage temp file: %w", err) + } + + return runUpload(binary, tmpFile, remoteDir) +} + +func runUpload(binary, localPath, remoteDir string) error { + cmd := exec.Command(binary, "filesystem", "upload", localPath, remoteDir, + "--conflict-strategy", "replace") + + var stderr strings.Builder + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("upload to proton-drive failed: %w: %s", + err, strings.TrimSpace(stderr.String())) + } + return nil +} + +func copyFile(src, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, data, 0644) +}