feat: add backup and restore commands
- Add backup create/list (tar.gz) with default path ~/.picoclaw/backups/ - Add backup restore with --dry-run, --force, --workspace - Map picoclaw/* to ~/.picoclaw, workspace/* to config workspace - Add backup_cmd_test.go with roundtrip and option tests - Add docs/backup-restore.md with usage, importance, and scenarios Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8774526616
commit
0e5ccad073
4 changed files with 912 additions and 0 deletions
550
cmd/picoclaw/backup_cmd.go
Normal file
550
cmd/picoclaw/backup_cmd.go
Normal file
|
|
@ -0,0 +1,550 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
archivePrefixPicoclaw = "picoclaw/"
|
||||||
|
archivePrefixWorkspace = "workspace/"
|
||||||
|
)
|
||||||
|
|
||||||
|
type backupOptions struct {
|
||||||
|
OutputPath string
|
||||||
|
WithSessions bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type restoreOptions struct {
|
||||||
|
DryRun bool
|
||||||
|
Force bool
|
||||||
|
Workspace string
|
||||||
|
}
|
||||||
|
|
||||||
|
type backupEntry struct {
|
||||||
|
SourcePath string
|
||||||
|
ArchivePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func backupCmd() {
|
||||||
|
args := os.Args[2:]
|
||||||
|
if len(args) == 0 {
|
||||||
|
backupCreateCmd(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args[0] {
|
||||||
|
case "create":
|
||||||
|
backupCreateCmd(args[1:])
|
||||||
|
case "list":
|
||||||
|
backupListCmd(args[1:])
|
||||||
|
case "restore":
|
||||||
|
backupRestoreCmd(args[1:])
|
||||||
|
case "help", "--help", "-h":
|
||||||
|
backupHelp()
|
||||||
|
default:
|
||||||
|
fmt.Printf("Unknown backup command: %s\n", args[0])
|
||||||
|
backupHelp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func backupHelp() {
|
||||||
|
fmt.Println("\nBackup commands:")
|
||||||
|
fmt.Println(" create Create a backup archive (default)")
|
||||||
|
fmt.Println(" list Show files/directories that would be backed up")
|
||||||
|
fmt.Println(" restore <archive> Restore from a backup archive")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Create options:")
|
||||||
|
fmt.Println(" -o, --output <path> Output tar.gz path")
|
||||||
|
fmt.Println(" --with-sessions Include workspace/sessions in backup")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Restore options:")
|
||||||
|
fmt.Println(" --dry-run Print what would be restored without writing files")
|
||||||
|
fmt.Println(" --force Overwrite existing files")
|
||||||
|
fmt.Println(" --workspace <path> Restore workspace to this directory (default: from config)")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Examples:")
|
||||||
|
fmt.Println(" picoclaw backup create")
|
||||||
|
fmt.Println(" picoclaw backup list")
|
||||||
|
fmt.Println(" picoclaw backup create --with-sessions")
|
||||||
|
fmt.Println(" picoclaw backup create --output ~/Desktop/picoclaw-backup.tar.gz")
|
||||||
|
fmt.Println(" picoclaw backup restore ~/.picoclaw/backups/picoclaw-backup-20260101-120000.tar.gz")
|
||||||
|
fmt.Println(" picoclaw backup restore backup.tar.gz --dry-run")
|
||||||
|
fmt.Println(" picoclaw backup restore backup.tar.gz --workspace ~/my-workspace --force")
|
||||||
|
}
|
||||||
|
|
||||||
|
func backupCreateCmd(args []string) {
|
||||||
|
opts, showHelp, err := parseBackupOptions(args)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
backupHelp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if showHelp {
|
||||||
|
backupHelp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error loading config: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error resolving home directory: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := collectBackupEntries(cfg, homeDir, opts.WithSessions)
|
||||||
|
if len(entries) == 0 {
|
||||||
|
fmt.Println("No backup targets found. Run onboard first, then try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.OutputPath == "" {
|
||||||
|
opts.OutputPath = defaultBackupPath(homeDir)
|
||||||
|
}
|
||||||
|
opts.OutputPath = expandHomePath(opts.OutputPath, homeDir)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(opts.OutputPath), 0755); err != nil {
|
||||||
|
fmt.Printf("Error creating backup directory: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := createBackupArchive(opts.OutputPath, entries); err != nil {
|
||||||
|
fmt.Printf("Error creating backup archive: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Backup created: %s\n", opts.OutputPath)
|
||||||
|
fmt.Printf(" Included %d path(s)\n", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
func backupListCmd(args []string) {
|
||||||
|
opts, showHelp, err := parseBackupOptions(args)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
backupHelp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if showHelp {
|
||||||
|
backupHelp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error loading config: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error resolving home directory: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := collectBackupEntries(cfg, homeDir, opts.WithSessions)
|
||||||
|
if len(entries) == 0 {
|
||||||
|
fmt.Println("No backup targets found.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\nBackup targets:")
|
||||||
|
fmt.Println("---------------")
|
||||||
|
for _, entry := range entries {
|
||||||
|
fmt.Printf(" %s -> %s\n", entry.SourcePath, entry.ArchivePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func backupRestoreCmd(args []string) {
|
||||||
|
opts, archivePath, showHelp, err := parseRestoreOptions(args)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
backupHelp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if showHelp {
|
||||||
|
backupHelp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if archivePath == "" {
|
||||||
|
fmt.Println("Error: restore requires an archive path")
|
||||||
|
fmt.Println("Usage: picoclaw backup restore <archive> [options]")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error resolving home directory: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseDir := filepath.Join(homeDir, ".picoclaw")
|
||||||
|
workspaceDir := opts.Workspace
|
||||||
|
if workspaceDir == "" {
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error: no config found. Either run 'picoclaw onboard' first or use --workspace <path> to specify where to restore workspace files.")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
workspaceDir = cfg.WorkspacePath()
|
||||||
|
} else {
|
||||||
|
workspaceDir = expandHomePath(workspaceDir, homeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := extractBackupArchive(archivePath, baseDir, workspaceDir, opts); err != nil {
|
||||||
|
fmt.Printf("Error restoring backup: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.DryRun {
|
||||||
|
fmt.Println("Dry run complete. No files were written.")
|
||||||
|
} else {
|
||||||
|
fmt.Println("Restore complete.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBackupOptions(args []string) (backupOptions, bool, error) {
|
||||||
|
opts := backupOptions{}
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--with-sessions":
|
||||||
|
opts.WithSessions = true
|
||||||
|
case "-o", "--output":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
return opts, false, fmt.Errorf("%s requires a value", args[i])
|
||||||
|
}
|
||||||
|
opts.OutputPath = args[i+1]
|
||||||
|
i++
|
||||||
|
case "help", "--help", "-h":
|
||||||
|
return opts, true, nil
|
||||||
|
default:
|
||||||
|
return opts, false, fmt.Errorf("unknown option: %s", args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return opts, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRestoreOptions(args []string) (restoreOptions, string, bool, error) {
|
||||||
|
opts := restoreOptions{}
|
||||||
|
var archivePath string
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--dry-run":
|
||||||
|
opts.DryRun = true
|
||||||
|
case "--force":
|
||||||
|
opts.Force = true
|
||||||
|
case "--workspace":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
return opts, "", false, fmt.Errorf("--workspace requires a value")
|
||||||
|
}
|
||||||
|
opts.Workspace = args[i+1]
|
||||||
|
i++
|
||||||
|
case "help", "--help", "-h":
|
||||||
|
return opts, "", true, nil
|
||||||
|
default:
|
||||||
|
if strings.HasPrefix(args[i], "-") {
|
||||||
|
return opts, "", false, fmt.Errorf("unknown option: %s", args[i])
|
||||||
|
}
|
||||||
|
if archivePath == "" {
|
||||||
|
archivePath = args[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return opts, archivePath, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultBackupPath(homeDir string) string {
|
||||||
|
timestamp := time.Now().UTC().Format("20060102-150405")
|
||||||
|
return filepath.Join(homeDir, ".picoclaw", "backups", fmt.Sprintf("picoclaw-backup-%s.tar.gz", timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandHomePath(path string, homeDir string) string {
|
||||||
|
if path == "~" {
|
||||||
|
return homeDir
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(path, "~/") {
|
||||||
|
return filepath.Join(homeDir, path[2:])
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectBackupEntries(cfg *config.Config, homeDir string, withSessions bool) []backupEntry {
|
||||||
|
baseDir := filepath.Join(homeDir, ".picoclaw")
|
||||||
|
workspace := cfg.WorkspacePath()
|
||||||
|
|
||||||
|
candidates := []backupEntry{
|
||||||
|
{
|
||||||
|
SourcePath: filepath.Join(baseDir, "config.json"),
|
||||||
|
ArchivePath: filepath.ToSlash(filepath.Join("picoclaw", "config.json")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourcePath: filepath.Join(baseDir, "auth.json"),
|
||||||
|
ArchivePath: filepath.ToSlash(filepath.Join("picoclaw", "auth.json")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourcePath: filepath.Join(workspace, "AGENTS.md"),
|
||||||
|
ArchivePath: filepath.ToSlash(filepath.Join("workspace", "AGENTS.md")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourcePath: filepath.Join(workspace, "HOOKS.md"),
|
||||||
|
ArchivePath: filepath.ToSlash(filepath.Join("workspace", "HOOKS.md")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourcePath: filepath.Join(workspace, "IDENTITY.md"),
|
||||||
|
ArchivePath: filepath.ToSlash(filepath.Join("workspace", "IDENTITY.md")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourcePath: filepath.Join(workspace, "SOUL.md"),
|
||||||
|
ArchivePath: filepath.ToSlash(filepath.Join("workspace", "SOUL.md")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourcePath: filepath.Join(workspace, "TOOLS.md"),
|
||||||
|
ArchivePath: filepath.ToSlash(filepath.Join("workspace", "TOOLS.md")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourcePath: filepath.Join(workspace, "USER.md"),
|
||||||
|
ArchivePath: filepath.ToSlash(filepath.Join("workspace", "USER.md")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourcePath: filepath.Join(workspace, "memory"),
|
||||||
|
ArchivePath: filepath.ToSlash(filepath.Join("workspace", "memory")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourcePath: filepath.Join(workspace, "skills"),
|
||||||
|
ArchivePath: filepath.ToSlash(filepath.Join("workspace", "skills")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourcePath: filepath.Join(workspace, "cron"),
|
||||||
|
ArchivePath: filepath.ToSlash(filepath.Join("workspace", "cron")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if withSessions {
|
||||||
|
candidates = append(candidates, backupEntry{
|
||||||
|
SourcePath: filepath.Join(workspace, "sessions"),
|
||||||
|
ArchivePath: filepath.ToSlash(filepath.Join("workspace", "sessions")),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
existing := make([]backupEntry, 0, len(candidates))
|
||||||
|
for _, entry := range candidates {
|
||||||
|
if _, err := os.Stat(entry.SourcePath); err == nil {
|
||||||
|
existing = append(existing, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
func createBackupArchive(outputPath string, entries []backupEntry) error {
|
||||||
|
file, err := os.Create(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
gzw := gzip.NewWriter(file)
|
||||||
|
defer gzw.Close()
|
||||||
|
|
||||||
|
tw := tar.NewWriter(gzw)
|
||||||
|
defer tw.Close()
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
info, err := os.Stat(entry.SourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
if err := addDirectoryToArchive(tw, entry.SourcePath, entry.ArchivePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := addFileToArchive(tw, entry.SourcePath, entry.ArchivePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addDirectoryToArchive(tw *tar.Writer, sourceDir, archiveRoot string) error {
|
||||||
|
return filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath, err := filepath.Rel(sourceDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
target := archiveRoot
|
||||||
|
if relPath != "." {
|
||||||
|
target = filepath.Join(archiveRoot, relPath)
|
||||||
|
}
|
||||||
|
target = filepath.ToSlash(target)
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
return addDirHeaderToArchive(tw, info, target)
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return addFileToArchive(tw, path, target)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func addDirHeaderToArchive(tw *tar.Writer, info os.FileInfo, archivePath string) error {
|
||||||
|
header, err := tar.FileInfoHeader(info, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
header.Name = strings.TrimSuffix(archivePath, "/") + "/"
|
||||||
|
return tw.WriteHeader(header)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addFileToArchive(tw *tar.Writer, sourcePath, archivePath string) error {
|
||||||
|
info, err := os.Stat(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
header, err := tar.FileInfoHeader(info, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
header.Name = archivePath
|
||||||
|
|
||||||
|
if err := tw.WriteHeader(header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(tw, file)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// archivePathToDest maps an archive path (e.g. picoclaw/config.json or workspace/AGENTS.md)
|
||||||
|
// to the destination path on disk. Returns empty string if the path is not under a known prefix.
|
||||||
|
func archivePathToDest(archivePath, baseDir, workspaceDir string) string {
|
||||||
|
archivePath = filepath.ToSlash(archivePath)
|
||||||
|
if strings.HasPrefix(archivePath, archivePrefixPicoclaw) {
|
||||||
|
rel := strings.TrimPrefix(archivePath, archivePrefixPicoclaw)
|
||||||
|
return filepath.Join(baseDir, filepath.FromSlash(rel))
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(archivePath, archivePrefixWorkspace) {
|
||||||
|
rel := strings.TrimPrefix(archivePath, archivePrefixWorkspace)
|
||||||
|
return filepath.Join(workspaceDir, filepath.FromSlash(rel))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractBackupArchive(archivePath, baseDir, workspaceDir string, opts restoreOptions) error {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
archivePath = expandHomePath(archivePath, home)
|
||||||
|
|
||||||
|
f, err := os.Open(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
gzr, err := gzip.NewReader(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer gzr.Close()
|
||||||
|
|
||||||
|
tr := tar.NewReader(gzr)
|
||||||
|
restored := 0
|
||||||
|
skipped := 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
name := filepath.ToSlash(hdr.Name)
|
||||||
|
// Skip entries not under our known prefixes
|
||||||
|
dest := archivePathToDest(name, baseDir, workspaceDir)
|
||||||
|
if dest == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if hdr.Typeflag == tar.TypeDir {
|
||||||
|
if opts.DryRun {
|
||||||
|
fmt.Printf(" [dir] %s\n", name)
|
||||||
|
restored++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dest); err == nil && !opts.Force {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dest, 0755); err != nil {
|
||||||
|
return fmt.Errorf("mkdir %s: %w", dest, err)
|
||||||
|
}
|
||||||
|
restored++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.DryRun {
|
||||||
|
fmt.Printf(" [file] %s -> %s\n", name, dest)
|
||||||
|
restored++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(dest); err == nil && !opts.Force {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
||||||
|
return fmt.Errorf("mkdir %s: %w", filepath.Dir(dest), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := hdr.FileInfo().Mode()
|
||||||
|
out, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create %s: %w", dest, err)
|
||||||
|
}
|
||||||
|
_, err = io.Copy(out, tr)
|
||||||
|
out.Close()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", dest, err)
|
||||||
|
}
|
||||||
|
restored++
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.DryRun && skipped > 0 {
|
||||||
|
fmt.Printf(" Skipped %d existing path(s) (use --force to overwrite)\n", skipped)
|
||||||
|
}
|
||||||
|
fmt.Printf(" Restored %d path(s)\n", restored)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
208
cmd/picoclaw/backup_cmd_test.go
Normal file
208
cmd/picoclaw/backup_cmd_test.go
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseBackupOptions(t *testing.T) {
|
||||||
|
opts, showHelp, err := parseBackupOptions([]string{"--with-sessions", "-o", "~/x.tar.gz"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseBackupOptions returned error: %v", err)
|
||||||
|
}
|
||||||
|
if showHelp {
|
||||||
|
t.Fatalf("expected showHelp=false")
|
||||||
|
}
|
||||||
|
if !opts.WithSessions {
|
||||||
|
t.Fatalf("expected WithSessions=true")
|
||||||
|
}
|
||||||
|
if opts.OutputPath != "~/x.tar.gz" {
|
||||||
|
t.Fatalf("unexpected OutputPath: %q", opts.OutputPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBackupOptionsHelp(t *testing.T) {
|
||||||
|
_, showHelp, err := parseBackupOptions([]string{"--help"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseBackupOptions returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !showHelp {
|
||||||
|
t.Fatalf("expected showHelp=true for --help")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRestoreOptions(t *testing.T) {
|
||||||
|
opts, archive, showHelp, err := parseRestoreOptions([]string{"backup.tar.gz", "--dry-run", "--force"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseRestoreOptions returned error: %v", err)
|
||||||
|
}
|
||||||
|
if showHelp {
|
||||||
|
t.Fatalf("expected showHelp=false")
|
||||||
|
}
|
||||||
|
if archive != "backup.tar.gz" {
|
||||||
|
t.Fatalf("unexpected archive path: %q", archive)
|
||||||
|
}
|
||||||
|
if !opts.DryRun || !opts.Force {
|
||||||
|
t.Fatalf("expected DryRun and Force true, got DryRun=%v Force=%v", opts.DryRun, opts.Force)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRestoreOptionsWorkspace(t *testing.T) {
|
||||||
|
opts, archive, _, err := parseRestoreOptions([]string{"x.tar.gz", "--workspace", "~/my-ws"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseRestoreOptions: %v", err)
|
||||||
|
}
|
||||||
|
if archive != "x.tar.gz" || opts.Workspace != "~/my-ws" {
|
||||||
|
t.Fatalf("archive=%q workspace=%q", archive, opts.Workspace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectBackupEntries(t *testing.T) {
|
||||||
|
homeDir := t.TempDir()
|
||||||
|
workspace := filepath.Join(homeDir, "workspace")
|
||||||
|
if err := os.MkdirAll(workspace, 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir workspace: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mustWriteFile(t, filepath.Join(homeDir, ".picoclaw", "config.json"), "{}")
|
||||||
|
mustWriteFile(t, filepath.Join(homeDir, ".picoclaw", "auth.json"), "{}")
|
||||||
|
mustWriteFile(t, filepath.Join(workspace, "AGENTS.md"), "# AGENTS")
|
||||||
|
mustWriteFile(t, filepath.Join(workspace, "HOOKS.md"), "# HOOKS")
|
||||||
|
mustWriteFile(t, filepath.Join(workspace, "IDENTITY.md"), "# IDENTITY")
|
||||||
|
mustWriteFile(t, filepath.Join(workspace, "SOUL.md"), "# SOUL")
|
||||||
|
mustWriteFile(t, filepath.Join(workspace, "TOOLS.md"), "# TOOLS")
|
||||||
|
mustWriteFile(t, filepath.Join(workspace, "USER.md"), "# USER")
|
||||||
|
if err := os.MkdirAll(filepath.Join(workspace, "memory"), 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir memory: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(workspace, "skills"), 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir skills: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(workspace, "cron"), 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir cron: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(workspace, "sessions"), 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir sessions: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: workspace,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
entriesNoSessions := collectBackupEntries(cfg, homeDir, false)
|
||||||
|
if !hasArchivePath(entriesNoSessions, "workspace/AGENTS.md") {
|
||||||
|
t.Fatalf("expected workspace/AGENTS.md in backup entries")
|
||||||
|
}
|
||||||
|
if hasArchivePath(entriesNoSessions, "workspace/sessions") {
|
||||||
|
t.Fatalf("did not expect workspace/sessions without --with-sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
entriesWithSessions := collectBackupEntries(cfg, homeDir, true)
|
||||||
|
if !hasArchivePath(entriesWithSessions, "workspace/sessions") {
|
||||||
|
t.Fatalf("expected workspace/sessions with --with-sessions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupRestoreRoundtrip(t *testing.T) {
|
||||||
|
homeDir := t.TempDir()
|
||||||
|
workspace := filepath.Join(homeDir, "workspace")
|
||||||
|
if err := os.MkdirAll(workspace, 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir workspace: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mustWriteFile(t, filepath.Join(homeDir, ".picoclaw", "config.json"), `{"agents":{"defaults":{"workspace":"`+workspace+`"}}}`)
|
||||||
|
mustWriteFile(t, filepath.Join(homeDir, ".picoclaw", "auth.json"), `{}`)
|
||||||
|
mustWriteFile(t, filepath.Join(workspace, "AGENTS.md"), "# AGENTS content")
|
||||||
|
mustWriteFile(t, filepath.Join(workspace, "USER.md"), "# USER content")
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: workspace,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := collectBackupEntries(cfg, homeDir, false)
|
||||||
|
if len(entries) < 4 {
|
||||||
|
t.Fatalf("expected at least 4 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
archivePath := filepath.Join(t.TempDir(), "backup.tar.gz")
|
||||||
|
if err := createBackupArchive(archivePath, entries); err != nil {
|
||||||
|
t.Fatalf("createBackupArchive: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreBase := t.TempDir()
|
||||||
|
restoreWorkspace := filepath.Join(restoreBase, "restored-ws")
|
||||||
|
if err := os.MkdirAll(restoreWorkspace, 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir restore workspace: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := restoreOptions{Force: true}
|
||||||
|
if err := extractBackupArchive(archivePath, filepath.Join(restoreBase, ".picoclaw"), restoreWorkspace, opts); err != nil {
|
||||||
|
t.Fatalf("extractBackupArchive: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify restored files
|
||||||
|
configPath := filepath.Join(restoreBase, ".picoclaw", "config.json")
|
||||||
|
if _, err := os.Stat(configPath); err != nil {
|
||||||
|
t.Fatalf("restored config.json missing: %v", err)
|
||||||
|
}
|
||||||
|
agentsPath := filepath.Join(restoreWorkspace, "AGENTS.md")
|
||||||
|
data, err := os.ReadFile(agentsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading restored AGENTS.md: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "# AGENTS content" {
|
||||||
|
t.Errorf("AGENTS.md content = %q, want %q", string(data), "# AGENTS content")
|
||||||
|
}
|
||||||
|
userPath := filepath.Join(restoreWorkspace, "USER.md")
|
||||||
|
data, err = os.ReadFile(userPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading restored USER.md: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "# USER content" {
|
||||||
|
t.Errorf("USER.md content = %q, want %q", string(data), "# USER content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArchivePathToDest(t *testing.T) {
|
||||||
|
base := "/home/.picoclaw"
|
||||||
|
ws := "/home/workspace"
|
||||||
|
if got := archivePathToDest("picoclaw/config.json", base, ws); got != filepath.Join(base, "config.json") {
|
||||||
|
t.Errorf("picoclaw/config.json -> %q", got)
|
||||||
|
}
|
||||||
|
if got := archivePathToDest("workspace/AGENTS.md", base, ws); got != filepath.Join(ws, "AGENTS.md") {
|
||||||
|
t.Errorf("workspace/AGENTS.md -> %q", got)
|
||||||
|
}
|
||||||
|
if got := archivePathToDest("unknown/foo", base, ws); got != "" {
|
||||||
|
t.Errorf("unknown prefix should return empty, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustWriteFile(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir parent for %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasArchivePath(entries []backupEntry, archivePath string) bool {
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.ArchivePath == archivePath {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
@ -115,6 +115,8 @@ func main() {
|
||||||
authCmd()
|
authCmd()
|
||||||
case "cron":
|
case "cron":
|
||||||
cronCmd()
|
cronCmd()
|
||||||
|
case "backup":
|
||||||
|
backupCmd()
|
||||||
case "skills":
|
case "skills":
|
||||||
if len(os.Args) < 3 {
|
if len(os.Args) < 3 {
|
||||||
skillsHelp()
|
skillsHelp()
|
||||||
|
|
@ -185,6 +187,7 @@ func printHelp() {
|
||||||
fmt.Println(" status Show picoclaw status")
|
fmt.Println(" status Show picoclaw status")
|
||||||
fmt.Println(" cron Manage scheduled tasks")
|
fmt.Println(" cron Manage scheduled tasks")
|
||||||
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
|
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
|
||||||
|
fmt.Println(" backup Backup and restore config/workspace (create, list, restore)")
|
||||||
fmt.Println(" skills Manage skills (install, list, remove)")
|
fmt.Println(" skills Manage skills (install, list, remove)")
|
||||||
fmt.Println(" version Show version information")
|
fmt.Println(" version Show version information")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
151
docs/backup-restore.md
Normal file
151
docs/backup-restore.md
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
# Backup and Restore
|
||||||
|
|
||||||
|
This document describes the PicoClaw CLI backup and restore commands (`picoclaw backup` and `picoclaw backup restore`). Other backup-related behavior (e.g. `onboard --force` creating a timestamped config backup, or `migrate` creating `.bak` files before overwriting) is mentioned only briefly; those do not have a dedicated restore command and require manual handling if you need to revert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
The backup feature creates a single compressed archive (tar.gz) of your PicoClaw config and workspace. Restore unpacks that archive back to the expected locations so you can recover after migration, reinstall, or accidental changes.
|
||||||
|
|
||||||
|
- **Backup**: `picoclaw backup` or `picoclaw backup create` — creates an archive.
|
||||||
|
- **List**: `picoclaw backup list` — shows which paths would be included (no archive is written).
|
||||||
|
- **Restore**: `picoclaw backup restore <archive>` — restores from an archive.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Why Backups Matter
|
||||||
|
|
||||||
|
- **Config and credentials**: `config.json` and `auth.json` hold model settings, channels, and authentication. Losing them means reconfiguring or logging in again.
|
||||||
|
- **Workspace assets**: Files like AGENTS.md, IDENTITY.md, SOUL.md, and USER.md define your agent’s role and preferences. The `memory`, `skills`, and `cron` directories hold long-lived content that is hard to recreate.
|
||||||
|
- **Recoverability**: Regular backups let you quickly revert after mistakes, failed upgrades, or when moving to a new machine, instead of starting from scratch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Use Cases
|
||||||
|
|
||||||
|
- **Before upgrades or big changes**: Run `picoclaw backup create`, then perform the upgrade or `picoclaw onboard --force`. If something goes wrong, use `picoclaw backup restore` to roll back.
|
||||||
|
- **Moving to a new machine**: On the new machine, run `picoclaw onboard` (or create the directories), then `picoclaw backup restore --workspace <path> <archive>` to restore the archive and align config and workspace.
|
||||||
|
- **After accidental overwrite or deletion**: Restore from your latest backup with `picoclaw backup restore <archive>`. Use `--dry-run` first to see what would be restored.
|
||||||
|
- **Sharing or cloning an environment**: Copy the archive to a USB drive or cloud storage, then on another machine run restore to reproduce the same config and workspace (keep credentials secure).
|
||||||
|
- **Regular snapshots**: Use cron or a manual habit to run `picoclaw backup create -o ~/backups/picoclaw-YYYYMMDD.tar.gz` so you have dated snapshots to fall back on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Default Storage Location
|
||||||
|
|
||||||
|
- **Default backup directory**: `~/.picoclaw/backups/`
|
||||||
|
- **Default filename pattern**: `picoclaw-backup-{YYYYMMDD-HHMMSS}.tar.gz` (UTC timestamp)
|
||||||
|
- You can override the path with `-o` or `--output`, e.g. `picoclaw backup create -o ~/Desktop/my-backup.tar.gz`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Backup Command
|
||||||
|
|
||||||
|
### Create
|
||||||
|
|
||||||
|
- **Usage**: `picoclaw backup` or `picoclaw backup create [options]`
|
||||||
|
- **Options**:
|
||||||
|
- `-o`, `--output <path>` — Write the archive to this path (default: `~/.picoclaw/backups/picoclaw-backup-<timestamp>.tar.gz`).
|
||||||
|
- `--with-sessions` — Include the `workspace/sessions` directory in the backup.
|
||||||
|
|
||||||
|
### List
|
||||||
|
|
||||||
|
- **Usage**: `picoclaw backup list [options]`
|
||||||
|
- Prints the local paths and their archive paths that would be included in a backup. No archive is created.
|
||||||
|
|
||||||
|
### What Gets Backed Up
|
||||||
|
|
||||||
|
Only paths that exist on disk are included:
|
||||||
|
|
||||||
|
- **Config**: `~/.picoclaw/config.json`, `~/.picoclaw/auth.json`
|
||||||
|
- **Workspace files**: AGENTS.md, HOOKS.md, IDENTITY.md, SOUL.md, TOOLS.md, USER.md
|
||||||
|
- **Workspace directories**: `memory/`, `skills/`, `cron/`
|
||||||
|
- **Optional**: `sessions/` (only with `--with-sessions`)
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create backup with default path
|
||||||
|
picoclaw backup create
|
||||||
|
|
||||||
|
# Create backup with custom path
|
||||||
|
picoclaw backup create -o ~/Desktop/picoclaw-backup.tar.gz
|
||||||
|
|
||||||
|
# Include sessions
|
||||||
|
picoclaw backup create --with-sessions
|
||||||
|
|
||||||
|
# See what would be backed up
|
||||||
|
picoclaw backup list
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Restore Command
|
||||||
|
|
||||||
|
- **Usage**: `picoclaw backup restore <archive> [options]`
|
||||||
|
- **Options**:
|
||||||
|
- `--dry-run` — Print what would be restored without writing files.
|
||||||
|
- `--force` — Overwrite existing files. Without this, existing paths are skipped.
|
||||||
|
- `--workspace <path>` — Restore workspace contents into this directory. If omitted, the workspace path from the current config is used (or you must have run `picoclaw onboard` first).
|
||||||
|
|
||||||
|
Restore maps archive paths to the current environment:
|
||||||
|
|
||||||
|
- `picoclaw/*` → `~/.picoclaw/*`
|
||||||
|
- `workspace/*` → current workspace directory (from config or `--workspace`)
|
||||||
|
|
||||||
|
If there is no config (e.g. fresh machine), you must pass `--workspace` so the command knows where to put workspace files; config files will still be restored under `~/.picoclaw/`.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Restore to current config locations
|
||||||
|
picoclaw backup restore ~/.picoclaw/backups/picoclaw-backup-20260101-120000.tar.gz
|
||||||
|
|
||||||
|
# Preview only
|
||||||
|
picoclaw backup restore backup.tar.gz --dry-run
|
||||||
|
|
||||||
|
# Restore workspace to a different directory and overwrite existing files
|
||||||
|
picoclaw backup restore backup.tar.gz --workspace ~/my-workspace --force
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Archive Format (for scripts or debugging)
|
||||||
|
|
||||||
|
The archive is gzip-compressed tar. Paths inside use forward slashes and two top-level prefixes:
|
||||||
|
|
||||||
|
- `picoclaw/` — config and auth (e.g. `picoclaw/config.json`, `picoclaw/auth.json`)
|
||||||
|
- `workspace/` — workspace files and directories (e.g. `workspace/AGENTS.md`, `workspace/memory/`, `workspace/skills/`)
|
||||||
|
|
||||||
|
Example layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
picoclaw/config.json
|
||||||
|
picoclaw/auth.json
|
||||||
|
workspace/AGENTS.md
|
||||||
|
workspace/HOOKS.md
|
||||||
|
workspace/IDENTITY.md
|
||||||
|
workspace/SOUL.md
|
||||||
|
workspace/TOOLS.md
|
||||||
|
workspace/USER.md
|
||||||
|
workspace/memory/
|
||||||
|
workspace/skills/
|
||||||
|
workspace/cron/
|
||||||
|
workspace/sessions/ (only if created with --with-sessions)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Other Backup-Related Behavior
|
||||||
|
|
||||||
|
- **`picoclaw onboard --force`**: Before overwriting, backs up the existing config to `config.json.bak.<timestamp>`. There is no CLI restore for this; copy the file back manually if needed.
|
||||||
|
- **`picoclaw migrate`**: When copying over existing files, creates a `.bak` copy first. Again, no CLI restore; restore those files manually if required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. FAQ
|
||||||
|
|
||||||
|
- **After restore, do I need to restart anything?** If you use the gateway or a background service, restart it so it picks up the restored config and workspace.
|
||||||
|
- **Are credentials safe?** `auth.json` can contain tokens; treat backup archives as sensitive and store them securely.
|
||||||
|
- **Restoring on a different machine?** Use `--workspace` if the workspace path differs, and ensure the restored `config.json`’s workspace path is correct for the new machine.
|
||||||
Loading…
Add table
Reference in a new issue