diff --git a/README.archive.md b/README.archive.md index 79a6ad3..aea3eac 100644 --- a/README.archive.md +++ b/README.archive.md @@ -1,27 +1,74 @@ # archive - 压缩文件 -自动识别压缩格式并解压,不用记 `tar -xzf` / `tar -xjf` / `unzip` 的区别。 +创建、查看、解压压缩文件,不用记 `tar -czf` / `tar -xzf` / `unzip` 的区别。 ## 命令 | 命令 | 作用 | | --- | --- | +| `kopsc archive create <路径> [选项]` | 创建压缩文件 | +| `kopsc archive list <文件>` | 列出压缩文件内容(不解压) | | `kopsc archive extract <文件> [--dir 目录]` | 自动识别格式并解压 | 别名:`kopsc archive unzip`、`kopsc archive untar` ## 支持的格式 -| 扩展名 | 解压方式 | -| --- | --- | -| `.tar.gz` / `.tgz` | Go 内置实现 | -| `.tar.bz2` / `.tbz2` | 调用 `bzip2 -dc` + tar 解析 | -| `.tar.xz` / `.txz` | 调用 `xz -dc` + tar 解析 | -| `.tar` | Go 内置实现 | -| `.zip` / `.jar` / `.war` | Go 内置实现 | -| `.gz` | Go 内置 gzip 解压 | +| 扩展名 | 创建 | 查看 | 解压 | +| --- | --- | --- | --- | +| `.tar.gz` / `.tgz` | ✓ | ✓ | ✓ | +| `.tar` | ✓ | ✓ | ✓ | +| `.zip` | ✓ | ✓ | ✓ | +| `.tar.bz2` / `.tbz2` | - | - | ✓ | +| `.tar.xz` / `.txz` | - | - | ✓ | +| `.gz` | - | - | ✓ | -## 示例 +## 创建压缩文件(archive.create) + +```bash +# 压缩目录为 tar.gz(默认格式) +kopsc archive create ./mydir + +# 压缩为 zip 格式 +kopsc archive create ./mydir --format zip + +# 指定输出文件名 +kopsc archive create ./mydir -o backup.tar.gz + +# 压缩单个文件 +kopsc archive create config.yaml +``` + +### 选项 + +| 选项 | 默认值 | 说明 | +| --- | --- | --- | +| `--format` / `-f` | `tar.gz` | 压缩格式(`tar.gz` / `tgz` / `tar` / `zip`) | +| `--output` / `-o` | `<目录名>.<格式>` | 输出文件名 | + +## 查看压缩文件内容(archive.list) + +```bash +# 列出 tar.gz 内容 +kopsc archive list backup.tar.gz + +# 列出 zip 内容 +kopsc archive list app.zip +``` + +输出示例: + +``` +权限 大小 修改时间 名称 +------------------------------------------------------------ +d0755 0 2026-11-05 10:30:00 mydir/ +-0644 1234 2026-11-05 10:30:00 mydir/file1.txt +-0755 5678 2026-11-05 10:30:00 mydir/script.sh +------------------------------------------------------------ +共 3 项,总大小 6.9K +``` + +## 解压文件(archive.extract) ```bash # 解压到当前目录 @@ -34,7 +81,7 @@ kopsc archive extract backup.zip --dir /opt/backup kopsc archive extract data.tar.bz2 ``` -## 选项 +### 选项 | 选项 | 说明 | | --- | --- | diff --git a/internal/executor/archivecreate.go b/internal/executor/archivecreate.go new file mode 100644 index 0000000..0837a4b --- /dev/null +++ b/internal/executor/archivecreate.go @@ -0,0 +1,263 @@ +package executor + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// runArchiveCreate implements `kopsc archive create `: create an archive. +func runArchiveCreate(ctx context.Context, req BuiltinRequest) error { + opts, err := parseArchiveCreateArgs(req.Args) + if err != nil { + return err + } + + src := opts.source + if _, err := os.Stat(src); err != nil { + return fmt.Errorf("路径 %q 不存在: %w", src, err) + } + + // Determine output filename + output := opts.output + if output == "" { + base := filepath.Base(src) + switch opts.format { + case "tar.gz", "tgz": + output = base + ".tar.gz" + case "tar": + output = base + ".tar" + case "zip": + output = base + ".zip" + } + } + + fmt.Fprintf(req.Stdout, "创建压缩文件: %s -> %s\n", src, output) + + switch opts.format { + case "tar.gz", "tgz": + return createTarGz(src, output, req) + case "tar": + return createTar(src, output, req) + case "zip": + return createZip(src, output, req) + default: + return fmt.Errorf("不支持的格式: %q(支持 tar.gz / tgz / tar / zip)", opts.format) + } +} + +type archiveCreateOptions struct { + source string + output string + format string +} + +func parseArchiveCreateArgs(args []string) (archiveCreateOptions, error) { + opts := archiveCreateOptions{ + format: "tar.gz", // default format + } + var positional []string + + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + positional = append(positional, args[i+1:]...) + i = len(args) + case arg == "--output" || arg == "-o": + if i+1 >= len(args) { + return opts, fmt.Errorf("%s 缺少输出文件名", arg) + } + i++ + opts.output = args[i] + case strings.HasPrefix(arg, "--output="): + opts.output = strings.TrimPrefix(arg, "--output=") + case arg == "--format" || arg == "-f": + if i+1 >= len(args) { + return opts, fmt.Errorf("%s 缺少格式参数", arg) + } + i++ + opts.format = strings.ToLower(args[i]) + case strings.HasPrefix(arg, "--format="): + opts.format = strings.ToLower(strings.TrimPrefix(arg, "--format=")) + case strings.HasPrefix(arg, "-") && arg != "-": + return opts, fmt.Errorf("未知选项 %q", arg) + default: + positional = append(positional, arg) + } + } + + if len(positional) == 0 { + return opts, fmt.Errorf("缺少源路径;用法: kopsc archive create <路径>") + } + opts.source = positional[0] + + // Normalize format aliases + switch opts.format { + case "tgz": + opts.format = "tar.gz" + } + + return opts, nil +} + +func createTarGz(src, output string, req BuiltinRequest) error { + outFile, err := os.Create(output) + if err != nil { + return fmt.Errorf("创建输出文件失败: %w", err) + } + defer outFile.Close() + + gz := gzip.NewWriter(outFile) + defer gz.Close() + + tw := tar.NewWriter(gz) + defer tw.Close() + + return writeTarArchive(src, tw, req) +} + +func createTar(src, output string, req BuiltinRequest) error { + outFile, err := os.Create(output) + if err != nil { + return fmt.Errorf("创建输出文件失败: %w", err) + } + defer outFile.Close() + + tw := tar.NewWriter(outFile) + defer tw.Close() + + return writeTarArchive(src, tw, req) +} + +func writeTarArchive(src string, tw *tar.Writer, req BuiltinRequest) error { + baseDir := filepath.Dir(src) + baseName := filepath.Base(src) + + count := 0 + err := filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + info, err := d.Info() + if err != nil { + return err + } + + // Get relative path + relPath, err := filepath.Rel(baseDir, path) + if err != nil { + return err + } + + // For single file, just use the filename + if !info.IsDir() && baseName == filepath.Base(path) { + relPath = filepath.Base(path) + } + + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + header.Name = relPath + + if err := tw.WriteHeader(header); err != nil { + return err + } + + if !info.IsDir() { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + if _, err := io.Copy(tw, f); err != nil { + return err + } + count++ + } + return nil + }) + + if err != nil { + return err + } + + fmt.Fprintf(req.Stdout, "已压缩 %d 个文件\n", count) + return nil +} + +func createZip(src, output string, req BuiltinRequest) error { + outFile, err := os.Create(output) + if err != nil { + return fmt.Errorf("创建输出文件失败: %w", err) + } + defer outFile.Close() + + zw := zip.NewWriter(outFile) + defer zw.Close() + + baseDir := filepath.Dir(src) + count := 0 + + err = filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + info, err := d.Info() + if err != nil { + return err + } + + // Get relative path + relPath, err := filepath.Rel(baseDir, path) + if err != nil { + return err + } + + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + header.Name = relPath + header.Method = zip.Deflate + + if info.IsDir() { + header.Name += "/" + _, err = zw.CreateHeader(header) + return err + } + + writer, err := zw.CreateHeader(header) + if err != nil { + return err + } + + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + if _, err := io.Copy(writer, f); err != nil { + return err + } + count++ + return nil + }) + + if err != nil { + return err + } + + fmt.Fprintf(req.Stdout, "已压缩 %d 个文件\n", count) + return nil +} diff --git a/internal/executor/archivelist.go b/internal/executor/archivelist.go new file mode 100644 index 0000000..7267558 --- /dev/null +++ b/internal/executor/archivelist.go @@ -0,0 +1,187 @@ +package executor + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +// runArchiveList implements `kopsc archive list `: list archive contents. +func runArchiveList(ctx context.Context, req BuiltinRequest) error { + opts, err := parseArchiveListArgs(req.Args) + if err != nil { + return err + } + + src := opts.file + if _, err := os.Stat(src); err != nil { + return fmt.Errorf("文件 %q 不存在: %w", src, err) + } + + lower := strings.ToLower(src) + switch { + case strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz"): + return listTarGz(src, req) + case strings.HasSuffix(lower, ".tar"): + return listTar(src, req) + case strings.HasSuffix(lower, ".zip") || strings.HasSuffix(lower, ".jar") || strings.HasSuffix(lower, ".war"): + return listZip(src, req) + default: + return fmt.Errorf("无法识别的压缩格式: %q(支持 .tar.gz .tgz .tar .zip)", src) + } +} + +type archiveListOptions struct { + file string +} + +func parseArchiveListArgs(args []string) (archiveListOptions, error) { + var opts archiveListOptions + var positional []string + + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + positional = append(positional, args[i+1:]...) + i = len(args) + case strings.HasPrefix(arg, "-") && arg != "-": + return opts, fmt.Errorf("未知选项 %q", arg) + default: + positional = append(positional, arg) + } + } + + if len(positional) == 0 { + return opts, fmt.Errorf("缺少压缩文件路径;用法: kopsc archive list <文件>") + } + opts.file = positional[0] + return opts, nil +} + +func listTarGz(src string, req BuiltinRequest) error { + f, err := os.Open(src) + if err != nil { + return err + } + defer f.Close() + + gz, err := gzip.NewReader(f) + if err != nil { + return fmt.Errorf("打开 gzip 失败: %w", err) + } + defer gz.Close() + + return listTarContents(tar.NewReader(gz), req) +} + +func listTar(src string, req BuiltinRequest) error { + f, err := os.Open(src) + if err != nil { + return err + } + defer f.Close() + + return listTarContents(tar.NewReader(f), req) +} + +func listTarContents(tr *tar.Reader, req BuiltinRequest) error { + fmt.Fprintln(req.Stdout, "权限 大小 修改时间 名称") + fmt.Fprintln(req.Stdout, "------------------------------------------------------------") + + count := 0 + var totalSize int64 + + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("读取 tar 失败: %w", err) + } + + mode := formatTarMode(header) + size := header.Size + mtime := header.ModTime.Format("2006-01-02 15:04:05") + name := header.Name + + fmt.Fprintf(req.Stdout, "%-10s %-9d %s %s\n", mode, size, mtime, name) + count++ + totalSize += size + } + + fmt.Fprintln(req.Stdout, "------------------------------------------------------------") + fmt.Fprintf(req.Stdout, "共 %d 项,总大小 %s\n", count, humanSize(totalSize)) + return nil +} + +func formatTarMode(header *tar.Header) string { + var mode rune + switch header.Typeflag { + case tar.TypeDir: + mode = 'd' + case tar.TypeSymlink: + mode = 'l' + case tar.TypeReg, tar.TypeRegA: + mode = '-' + default: + mode = '?' + } + + perm := fmt.Sprintf("%04o", header.Mode&0o7777) + return string(mode) + perm +} + +func listZip(src string, req BuiltinRequest) error { + r, err := zip.OpenReader(src) + if err != nil { + return fmt.Errorf("打开 zip 失败: %w", err) + } + defer r.Close() + + fmt.Fprintln(req.Stdout, "权限 大小 修改时间 名称") + fmt.Fprintln(req.Stdout, "------------------------------------------------------------") + + count := 0 + var totalSize int64 + + for _, f := range r.File { + mode := formatZipMode(f) + size := int64(f.UncompressedSize64) + mtime := f.Modified.Format("2006-01-02 15:04:05") + name := f.Name + + fmt.Fprintf(req.Stdout, "%-10s %-9d %s %s\n", mode, size, mtime, name) + count++ + totalSize += size + } + + fmt.Fprintln(req.Stdout, "------------------------------------------------------------") + fmt.Fprintf(req.Stdout, "共 %d 项,总大小 %s\n", count, humanSize(totalSize)) + return nil +} + +func formatZipMode(f *zip.File) string { + if f.FileInfo().IsDir() { + return "d0000" + } + return "-" + fmt.Sprintf("%04o", f.Mode()&0o7777) +} + +// humanSizeTime is a helper for formatting (not used, kept for reference) +func humanSizeTime(t time.Time) string { + return t.Format("2006-01-02 15:04:05") +} + +// archiveListName extracts just the filename for display (helper) +func archiveListName(path string) string { + return filepath.Base(path) +}