feat: 新增 archive.create/list 压缩文件命令
This commit is contained in:
+58
-11
@@ -1,27 +1,74 @@
|
|||||||
# archive - 压缩文件
|
# archive - 压缩文件
|
||||||
|
|
||||||
自动识别压缩格式并解压,不用记 `tar -xzf` / `tar -xjf` / `unzip` 的区别。
|
创建、查看、解压压缩文件,不用记 `tar -czf` / `tar -xzf` / `unzip` 的区别。
|
||||||
|
|
||||||
## 命令
|
## 命令
|
||||||
|
|
||||||
| 命令 | 作用 |
|
| 命令 | 作用 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
| `kopsc archive create <路径> [选项]` | 创建压缩文件 |
|
||||||
|
| `kopsc archive list <文件>` | 列出压缩文件内容(不解压) |
|
||||||
| `kopsc archive extract <文件> [--dir 目录]` | 自动识别格式并解压 |
|
| `kopsc archive extract <文件> [--dir 目录]` | 自动识别格式并解压 |
|
||||||
|
|
||||||
别名:`kopsc archive unzip`、`kopsc archive untar`
|
别名:`kopsc archive unzip`、`kopsc archive untar`
|
||||||
|
|
||||||
## 支持的格式
|
## 支持的格式
|
||||||
|
|
||||||
| 扩展名 | 解压方式 |
|
| 扩展名 | 创建 | 查看 | 解压 |
|
||||||
| --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `.tar.gz` / `.tgz` | Go 内置实现 |
|
| `.tar.gz` / `.tgz` | ✓ | ✓ | ✓ |
|
||||||
| `.tar.bz2` / `.tbz2` | 调用 `bzip2 -dc` + tar 解析 |
|
| `.tar` | ✓ | ✓ | ✓ |
|
||||||
| `.tar.xz` / `.txz` | 调用 `xz -dc` + tar 解析 |
|
| `.zip` | ✓ | ✓ | ✓ |
|
||||||
| `.tar` | Go 内置实现 |
|
| `.tar.bz2` / `.tbz2` | - | - | ✓ |
|
||||||
| `.zip` / `.jar` / `.war` | Go 内置实现 |
|
| `.tar.xz` / `.txz` | - | - | ✓ |
|
||||||
| `.gz` | Go 内置 gzip 解压 |
|
| `.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
|
```bash
|
||||||
# 解压到当前目录
|
# 解压到当前目录
|
||||||
@@ -34,7 +81,7 @@ kopsc archive extract backup.zip --dir /opt/backup
|
|||||||
kopsc archive extract data.tar.bz2
|
kopsc archive extract data.tar.bz2
|
||||||
```
|
```
|
||||||
|
|
||||||
## 选项
|
### 选项
|
||||||
|
|
||||||
| 选项 | 说明 |
|
| 选项 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|||||||
@@ -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 <path>`: 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
|
||||||
|
}
|
||||||
@@ -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 <file>`: 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user