diff --git a/.env.goreleaser.example b/.env.goreleaser.example new file mode 100644 index 0000000..fc343f2 --- /dev/null +++ b/.env.goreleaser.example @@ -0,0 +1,10 @@ +# GoReleaser 环境变量配置 +# 正式发布时需要配置以下变量 + +# GitHub Token(用于发布 Release 和推送 Docker 镜像到 GHCR) +GITHUB_TOKEN= + +# Homebrew Tap 仓库配置 +HOMEBREW_TAP_NAME= +HOMEBREW_TAP_OWNER= +TAP_GITHUB_TOKEN= diff --git a/.github/workflows/build-artifact.yml b/.github/workflows/build-artifact.yml index 8c4039e..a3497fd 100644 --- a/.github/workflows/build-artifact.yml +++ b/.github/workflows/build-artifact.yml @@ -5,13 +5,16 @@ on: branches: - main paths: - - 'src/**' + - "**.go" + - "go.mod" + - "go.sum" + - ".goreleaser.yaml" workflow_dispatch: inputs: ref: - description: '要构建的分支或标签' + description: "要构建的分支或标签" required: true - default: 'main' + default: "main" permissions: contents: read @@ -20,18 +23,28 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.ref || github.ref }} - - name: Run make - run: | - make + fetch-depth: 0 + + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: "1.25.8" + + - name: Run tests + run: go test -v ./... + + - name: Build with GoReleaser (snapshot) + uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 + with: + distribution: goreleaser + version: "~> v2" + args: build --snapshot --clean - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: yewresin-${{ github.sha }} - path: | - yewresin.sh - .env.example + path: dist/* retention-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4650863 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +name: CI + +on: + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + go: + name: Go + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: "1.25.8" + + - name: Check go.mod tidy + run: | + go mod tidy + git diff --exit-code go.mod go.sum + + - name: Vet + run: go vet ./... + + - name: Test + run: go test -v ./... + + - name: Build + run: go build ./... + + audit: + name: Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: "1.25.8" + + - name: Verify dependencies + run: go mod verify + + - id: govulncheck + uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4 + with: + go-version-input: "1.25.8" + repo-checkout: false + + docs: + name: Docs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + version: 10 + + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: docs/pnpm-lock.yaml + + - name: Install dependencies + working-directory: docs + run: pnpm install --frozen-lockfile + + - name: Build + working-directory: docs + run: pnpm build diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..85a5663 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,64 @@ +name: Deploy Docs + +on: + push: + branches: [main] + paths: + - "docs/**" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + version: 10 + + - name: Setup Node + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: docs/pnpm-lock.yaml + + - name: Setup Pages + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + + - name: Install dependencies + working-directory: docs + run: pnpm install --frozen-lockfile + + - name: Build with VitePress + working-directory: docs + run: pnpm build + + - name: Upload artifact + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 + with: + path: docs/.vitepress/dist + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + needs: build + runs-on: ubuntu-latest + name: Deploy + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/go-build-artifact.yml b/.github/workflows/go-build-artifact.yml deleted file mode 100644 index 847ba17..0000000 --- a/.github/workflows/go-build-artifact.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Go Build Artifact - -on: - pull_request: - branches: - - main - paths: - - 'go/**' - workflow_dispatch: - inputs: - ref: - description: '要构建的分支或标签' - required: true - default: 'main' - platform: - description: '选择构建平台' - required: true - default: 'all' - type: choice - options: - - all - - linux - - darwin - - windows - -permissions: - contents: read - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ inputs.ref || github.ref }} - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '1.23' - - - name: Run tests - working-directory: go - run: go test -v ./... - - - name: Build selected platform - working-directory: go - run: make ${{ inputs.platform || 'all' }} - - - name: Prepare artifact root - run: | - mkdir -p artifacts - cp go/dist/* artifacts/ - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: yewresin-go-${{ github.sha }} - path: | - artifacts/* - retention-days: 7 diff --git a/.github/workflows/go-prod-release.yml b/.github/workflows/go-prod-release.yml deleted file mode 100644 index c07de5f..0000000 --- a/.github/workflows/go-prod-release.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Go Production Release - -on: - push: - tags: - - "v2*" - -permissions: - contents: write - -jobs: - build-and-release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '1.23' - - - name: Run tests - working-directory: go - run: go test -v ./... - - - name: Build all platforms - working-directory: go - run: VERSION=${{ github.ref_name }} make all - - - name: Create Tagged Release - uses: softprops/action-gh-release@v2 - with: - files: | - go/dist/* - .env.example - generate_release_notes: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/prod-release.yml b/.github/workflows/prod-release.yml deleted file mode 100644 index b774f12..0000000 --- a/.github/workflows/prod-release.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Production Release - -on: - push: - tags: - - "v1*" - -permissions: - contents: write - -jobs: - build-and-release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Run make - run: VERSION=${{ github.ref_name }} make - - - name: Create Tagged Release - uses: softprops/action-gh-release@v2 - with: - files: yewresin.sh, .env.example - generate_release_notes: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..dd006c6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: "1.25.8" + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 + with: + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} + HOMEBREW_TAP_OWNER: ${{ vars.HOMEBREW_TAP_OWNER }} + HOMEBREW_TAP_NAME: ${{ vars.HOMEBREW_TAP_NAME }} diff --git a/.gitignore b/.gitignore index bb7e7ef..21382d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,11 @@ .env -yewresin.sh +.env.goreleaser +dist/ +*.exe +yewresin -.claude/ \ No newline at end of file +docs/node_modules/ +docs/.vitepress/dist/ +docs/.vitepress/cache/ + +.claude/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..4d06bea --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,79 @@ +version: 2 + +before: + hooks: + - go mod download + +builds: + - main: . + binary: yewresin + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + flags: + - -trimpath + ldflags: + - -s -w -X main.version={{.Version}} + mod_timestamp: "{{ .CommitTimestamp }}" + +archives: + - formats: [tar.gz] + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + format_overrides: + - goos: windows + formats: [zip] + files: + - LICENSE + - README.md + - .env.example + +checksum: + name_template: "checksums.txt" + +changelog: + sort: asc + use: github + filters: + exclude: + - "^docs:" + - "^test:" + - "^ci:" + - "Merge pull request" + groups: + - title: Features + regexp: "^.*feat[\\w\\)]*:+.*$" + order: 0 + - title: Bug fixes + regexp: "^.*fix[\\w\\)]*:+.*$" + order: 1 + - title: Others + order: 999 + +release: + github: + owner: YewFence + name: yewresin + draft: false + prerelease: auto + name_template: "v{{.Version}}" + +homebrew_casks: + - name: yewresin + repository: + owner: "{{ .Env.HOMEBREW_TAP_OWNER }}" + name: "{{ .Env.HOMEBREW_TAP_NAME }}" + token: "{{ .Env.TAP_GITHUB_TOKEN }}" + directory: Casks + homepage: "https://github.com/YewFence/yewresin" + description: "Docker Compose service backup tool using Kopia + rclone" + commit_msg_template: "Brew cask update for {{ .ProjectName }} v{{ .Tag }}" + diff --git a/.lefthook/pre-commit/pinact-check.sh b/.lefthook/pre-commit/pinact-check.sh new file mode 100644 index 0000000..55913a0 --- /dev/null +++ b/.lefthook/pre-commit/pinact-check.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +if ! ghtkn get | pinact token set -stdin; then + echo "⚠️ Warning: Failed to set GitHub token. pinact may fail or hit rate limits." +fi + +pinact_exit=0 +pinact_output=$(pinact run --check 2>&1) || pinact_exit=$? + +if [ "$pinact_exit" -ne 0 ]; then + echo "$pinact_output" + echo "" + echo "❌ pinact check failed, run 'pinact run' to pin action versions. run 'ghtkn get | pinact token set -stdin' before to set your Github token" +else + echo "✅ All actions are pinned" +fi + +echo "💡 Tip: run 'pinact run -u' to update pinned versions" +exit "$pinact_exit" diff --git a/CLAUDE.md b/CLAUDE.md index fc53e96..b706b70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,62 +4,92 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 项目概述 -YewResin 是一个 Docker Compose 服务的自动化备份脚本,使用 Kopia + rclone 实现本地快照与云端同步。脚本会依次停止所有 Docker 服务,创建一致性快照,然后按优先级恢复服务。 +YewResin 是一个 Docker Compose 服务的自动化备份工具,使用 Kopia + rclone 实现本地快照与云端同步。工具会依次停止所有 Docker 服务,创建一致性快照,然后按优先级恢复服务。 ## 构建命令 ```bash -# 合并 src/ 模块生成 yewresin.sh(必须在修改代码后执行) -make build +# 构建当前平台(本地快速构建) +just build -# 清理生成的脚本 -make clean +# 使用 GoReleaser 构建全平台可执行文件(通过 Docker,不发布) +just release-snapshot + +# 模拟完整发布流程(不推送) +just release-dry + +# 正式发布(需要 git tag + .env.goreleaser) +just release + +# 运行测试 +just test + +# 清理构建产物 +just clean ``` -## 架构 +## 源码结构 -脚本采用模块化设计,源代码在 `src/` 目录下,按数字前缀顺序拼接: +``` +YewResin/ +├── main.go # 程序入口,CLI 参数解析 +├── main_test.go # main 包测试(confirm 函数) +├── internal/yewresin/ # 核心逻辑 +│ ├── config.go # 配置加载和验证 +│ ├── config_test.go +│ ├── docker.go # Docker Compose 服务管理 +│ ├── docker_test.go +│ ├── backup.go # Kopia 备份操作 +│ ├── logger.go # 日志系统 +│ ├── logger_test.go +│ ├── gist.go # GitHub Gist 日志上传 +│ ├── notify.go # Apprise 通知发送 +│ ├── orchestrator.go # 备份流程编排器 +│ └── orchestrator_test.go +├── justfile # 构建脚本(GoReleaser 集成) +├── .goreleaser.yaml # GoReleaser 配置 +├── Dockerfile # Docker 镜像定义 +├── go.mod / go.sum +├── .env.example +└── .github/workflows/ # CI/CD +``` + +**核心逻辑在 `internal/yewresin/`:** -| 模块 | 职责 | +| 文件 | 职责 | |------|------| -| `00-header.sh` | shebang、set -eo pipefail、记录开始时间 | -| `01-logging.sh` | 日志输出(tee 到文件和终端)、`log()` 函数 | -| `02-args.sh` | 命令行参数解析(`--dry-run`、`-y`、`--help`) | -| `03-config.sh` | 配置加载(从 `.env` 读取)、默认值、`print_config()` | -| `04-utils.sh` | 通用工具函数 | -| `05-notification.sh` | Apprise 通知发送 | -| `06-gist.sh` | GitHub Gist 日志上传和清理 | -| `07-dependencies.sh` | 依赖检查(rclone、kopia) | -| `08-services.sh` | Docker 服务管理:停止、启动、状态检查、cleanup | -| `09-main.sh` | 主流程:停止服务 → Kopia 快照 → 启动服务 | - -**核心函数在 `08-services.sh`:** -- `stop_all_services()` / `start_all_services()` - 批量服务管理 -- `is_service_running()` - 检测服务运行状态 -- `cleanup()` - 异常退出时自动恢复服务(trap EXIT) +| `orchestrator.go` | 备份流程编排:锁机制、信号处理、cleanup | +| `docker.go` | 服务发现、启停、并行操作 | +| `backup.go` | Kopia 快照创建、依赖检查 | +| `config.go` | 环境变量加载、配置验证 | +| `logger.go` | slog 日志系统、文件输出 | +| `gist.go` | Gist 上传和旧日志清理 | +| `notify.go` | Apprise 异步通知 | **服务启停优先级逻辑:** - 优先服务(`PRIORITY_SERVICES`):最后停止,最先启动(如网关 caddy/nginx) - 普通服务:先停止,后启动 -- 只恢复原本在运行的服务(通过 `RUNNING_SERVICES` 关联数组追踪) +- 并行停止/启动,提升性能 +- 只恢复原本在运行的服务 ## 开发流程 -1. 修改 `src/` 下的模块文件 -2. 执行 `make build` 重新生成 `yewresin.sh` -3. 提交 `src/`、`Makefile` 和 `yewresin.sh` +1. 修改 `internal/yewresin/` 下的源文件 +2. 运行 `just test` 确保测试通过 +3. 运行 `just build` 构建当前平台进行本地测试 +4. 提交代码 ## 运行与测试 ```bash # 本地模拟运行(不执行实际操作) -./yewresin.sh --dry-run +./yewresin --dry-run # 执行备份(需确认) -./yewresin.sh +./yewresin # 跳过确认(用于 cron) -./yewresin.sh -y +./yewresin -y ``` ## 配置 @@ -72,5 +102,15 @@ make clean ## CI/CD -- `dev-release.yml` - main 分支推送后自动构建并发布到 `latest` tag -- `prod-release.yml` - 手动触发正式版本发布 +使用 GoReleaser 管理构建和发布: +- `build-artifact.yml` - PR 到 main 分支后使用 GoReleaser snapshot 构建 +- `release.yml` - 推送 `v*` 标签后自动发布到 GitHub Release / Homebrew Tap + +发布目标: +- **GitHub Release** - 全平台二进制文件(linux/darwin/windows, amd64/arm64) +- **Homebrew Tap** - 通过环境变量 `HOMEBREW_TAP_OWNER` / `HOMEBREW_TAP_NAME` 指定仓库 + +GitHub Secrets/Variables 配置: +- `TAP_GITHUB_TOKEN` (Secret) - Homebrew Tap 仓库的 PAT +- `HOMEBREW_TAP_OWNER` (Variable) - Tap 仓库所有者 +- `HOMEBREW_TAP_NAME` (Variable) - Tap 仓库名称(如 `homebrew-tap`) diff --git a/Makefile b/Makefile deleted file mode 100644 index ff8788a..0000000 --- a/Makefile +++ /dev/null @@ -1,37 +0,0 @@ -# YewResin Backup Script Builder -# 将 src/ 目录下的模块文件合并为 yewresin.sh - -SOURCES := $(sort $(wildcard src/*.sh)) -TARGET := yewresin.sh - -# 版本号:优先使用 VERSION 环境变量,否则使用 UTC 构建时间 -VERSION ?= $(shell date -u +"%Y%m%d.%H%M%S") - -.PHONY: build clean help - -# 默认目标 -build: $(TARGET) - -$(TARGET): $(SOURCES) - @echo "Building $(TARGET) (version: $(VERSION))..." - @echo "#!/bin/bash" > $(TARGET) - @echo "# YewResin $(VERSION)" >> $(TARGET) - @echo "# https://github.com/YewFence/YewResin" >> $(TARGET) - @echo "" >> $(TARGET) - @cat $(SOURCES) >> $(TARGET) - @chmod +x $(TARGET) - @echo "Done: $(TARGET) ($(shell wc -l < $(TARGET)) lines)" - -clean: - @echo "Removing $(TARGET)..." - @rm -f $(TARGET) - @echo "Done" - -help: - @echo "Usage:" - @echo " make build - Build yewresin.sh from src/ modules" - @echo " make clean - Remove generated yewresin.sh" - @echo " make help - Show this help message" - @echo "" - @echo "Modules:" - @for f in $(SOURCES); do echo " $$f"; done diff --git a/README.md b/README.md index bd4c585..4f808d3 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,15 @@ 一个自动化的 Docker Compose 服务备份工具,使用 Kopia 实现本地快照与云端同步。 -提供两个版本: -- **Shell 版本** (`v1.x`) - 轻量级 Bash 脚本,适合简单部署 -- **Go 版本** (`v2.x`) - 跨平台二进制,性能更优,并行处理 - -两个版本的核心功能完全一致,依赖也相同,可根据使用场景自由选择。 - ## 功能特点 - 自动停止所有 Docker Compose 服务,创建一致性快照 - 支持优先级服务(如网关)的顺序控制:最后停止,最先启动 - 只重启原本运行中的服务,不会启动原本停止的服务 -- **快速失败**:服务停止失败时立即中止备份,避免在服务运行时备份导致数据损坏 -- 支持多种 compose 配置文件格式(`compose.yaml`、`compose.yml`、`docker-compose.yaml`、`docker-compose.yml`) +- **快速失败**:服务停止失败时立即中止备份,避免数据损坏 +- 并行停止/启动服务,性能更优 - 支持 [Apprise](https://github.com/caronc/apprise-api) 通知 -> 可使用 [YewFence/apprise](https://github.com/YewFence/apprise) 快速部署到 Vercel -- 异常退出时自动恢复服务 -- 支持 dry-run 模式预览操作 -- 防止重复运行的锁机制 +- 支持 GitHub Gist 日志推送 ## 依赖 @@ -48,19 +39,11 @@ kopia repository connect rclone --remote-path="gdrive:backup" ### 2. 下载 YewResin -#### Shell 版本 (v1.x) +根据系统架构下载对应的二进制文件: ```bash mkdir ~/yewresin && cd ~/yewresin -wget https://github.com/YewFence/YewResin/releases/download/latest/yewresin.sh -chmod +x yewresin.sh -``` - -#### Go 版本 (v2.x) -```bash -mkdir ~/yewresin && cd ~/yewresin -# 根据系统架构选择对应的二进制文件 # Linux x64 wget https://github.com/YewFence/YewResin/releases/latest/download/yewresin-linux-amd64 -O yewresin # Linux ARM64 @@ -75,9 +58,7 @@ wget https://github.com/YewFence/YewResin/releases/latest/download/yewresin-darw chmod +x yewresin ``` -> `latest` 标签会在 main 分支推送后自动更新,也可以下载指定版本: -> - Shell 版本:`v1.x.x` 标签 -> - Go 版本:`v2.x.x` 标签 +> `latest` 标签会在 main 分支推送后自动更新,也可以下载指定版本(如 `v2.0.0`)。 ### 3. 配置 @@ -85,7 +66,7 @@ chmod +x yewresin ```bash # 在脚本所在目录下载示例文件 -wget https://github.com/YewFence/YewResin/releases/download/latest/.env.example +wget https://github.com/YewFence/YewResin/releases/latest/download/.env.example cp .env.example .env ``` @@ -97,558 +78,34 @@ BASE_DIR=/opt/docker_file EXPECTED_REMOTE=gdrive:backup ``` +或者直接临时设置环境变量运行: + +```bash +BASE_DIR=/opt/docker_file EXPECTED_REMOTE=gdrive:backup ./yewresin +``` + ### 4. 运行 ```bash # 模拟运行(推荐先测试) -./yewresin --dry-run # Go 版本 -./yewresin.sh --dry-run # Shell 版本 +./yewresin --dry-run # 执行备份(需确认) ./yewresin -./yewresin.sh # 跳过确认直接执行(适用于 cron) ./yewresin -y -./yewresin.sh -y -``` - -### 5. 定时任务 -> 按需配置,此处我们以每天北京时间凌晨三点运行为例(假设服务器使用 UTC 时区) -```bash -(crontab -l 2>/dev/null; echo '0 19 * * * /path/to/yewresin -y') | crontab - -``` - -> **注意**: -> - cron 使用系统时区,请先确认服务器时区(`timedatectl` 或 `date`),上述示例假设服务器为 UTC 时区 -> - 脚本内部使用 exec 重定向,cron 的 `>>` 重定向会被覆盖,可通过 `LOG_FILE` 环境变量自定义日志路径(默认为脚本同目录下的 `yewresin.log`) - -## 命令行参数 - -| 参数 | 说明 | Shell | Go | -|------|------|:-----:|:--:| -| `--dry-run`, `-n` | 模拟运行,只检查依赖和显示操作,不实际执行 | ✓ | ✓ | -| `-y`, `--yes` | 跳过交互式确认 | ✓ | ✓ | -| `--help`, `-h` | 显示帮助信息 | ✓ | ✓ | -| `--config ` | 指定配置文件路径 | - | ✓ | -| `--version` | 显示版本信息 | - | ✓ | - -## 环境变量 - -| 变量 | 默认值 | 说明 | Shell | Go | -|------|--------|------|:-----:|:--:| -| `BASE_DIR` | - | Docker Compose 项目目录 | ✓ | ✓ | -| `EXPECTED_REMOTE` | - | Kopia 远程路径 | ✓ | ✓ | -| `KOPIA_PASSWORD` | - | Kopia 远程仓库密码 | ✓ | ✓ | -| `KOPIA_CONFIG_FILE` | - | Kopia 配置文件路径(可选,用于多用户场景) | ✓ | ✓ | -| `RCLONE_CONFIG` | - | Rclone 配置文件路径(可选,用于多用户场景) | ✓ | ✓ | -| `PRIORITY_SERVICES_LIST` | `caddy nginx gateway` | 优先服务列表(空格分隔) | ✓ | ✓ | -| `LOCK_FILE` | `/tmp/backup_maintenance.lock` | 锁文件路径 | ✓ | ✓ | -| `LOG_FILE` | 脚本同目录下 `yewresin.log` | 日志文件路径 | ✓ | ✓ | -| `DOCKER_COMMAND_TIMEOUT_SECONDS` | `120` | Docker 命令超时时间(秒) | - | ✓ | -| `DEVICE_NAME` | - | 设备名称,用于区分不同服务器的通知 | ✓ | ✓ | -| `APPRISE_URL` | - | Apprise 服务地址 | ✓ | ✓ | -| `APPRISE_NOTIFY_URL` | - | 通知目标 URL | ✓ | ✓ | -| `GIST_TOKEN` | - | GitHub Personal Access Token(需要 gist 权限)| ✓ | ✓ | -| `GIST_ID` | - | GitHub Gist ID(日志上传目标)| ✓ | ✓ | -| `GIST_LOG_PREFIX` | `yewresin-backup` | Gist 日志文件名前缀 | ✓ | ✓ | -| `GIST_MAX_LOGS` | `30` | Gist 最大保留日志数量(设为 0 禁用清理)| ✓ | ✓ | -| `GIST_KEEP_FIRST_FILE` | `true` | 清理时保留第一个文件(用于自定义 Gist 标题)| ✓ | ✓ | -| `CONFIG_FILE` | `./yewresin.sh` 同目录的 `.env` | 配置文件路径 | ✓ | ✓ | - -## 关键要求 - -### 目录结构要求 - -```text -/opt/docker_file/ # BASE_DIR -├── caddy/ # 网关服务 -│ ├── compose.yaml # 支持多种命名格式 -│ └── compose-up.sh # 可选:自定义启动脚本 -├── nginx/ -│ └── docker-compose.yml -├── app1/ # 普通服务 -│ └── compose.yml -└── app2/ - └── docker-compose.yaml -``` - -脚本会自动识别包含以下任一配置文件的目录作为服务: -- `compose.yaml` -- `compose.yml` -- `docker-compose.yaml` -- `docker-compose.yml` - -### 启停逻辑 - -服务启停按以下优先级执行: - -1. **自定义脚本优先**:若目录下存在 `compose-stop.sh`/`compose-down.sh`/`compose-up.sh`,优先使用脚本启停 -2. **自动识别配置文件**:若无自定义脚本但存在 compose 配置文件,使用 `docker compose up -d` / `docker compose stop` 启停 - -### 快速失败机制 - -为保护数据完整性,脚本在停止服务阶段采用快速失败策略: - -- 如果任何服务停止失败,脚本会**立即中止**,不会继续执行备份 -- 已停止的服务会通过 cleanup 函数自动恢复 -- 通过 Apprise 发送通知告知失败原因 - -这确保了不会在服务仍在运行(可能正在写入数据)时进行备份,避免数据库文件损坏等问题。 - -## 开发说明 - -项目包含两个版本的实现,核心逻辑一致。 - -### Shell 版本 - -脚本采用模块化结构,源代码位于 `src/` 目录,通过 Makefile 合并生成最终的 `yewresin.sh`。 - -#### 源码结构 - -``` -YewResin/ -├── yewresin.sh # 生成的脚本(由 make build 生成) -├── Makefile # 构建工具 -└── src/ # 模块源文件 - ├── 00-header.sh # shebang 和初始化 - ├── 01-logging.sh # 日志捕获和 log() 函数 - ├── 02-args.sh # 命令行参数解析 - ├── 03-config.sh # 配置加载和默认值 - ├── 04-utils.sh # 通用工具函数 - ├── 05-notification.sh # 通知相关函数 - ├── 06-gist.sh # GitHub Gist 上传 - ├── 07-dependencies.sh # 依赖检查 - ├── 08-services.sh # Docker 服务管理 - └── 09-main.sh # 主流程逻辑 -``` - -#### 构建命令 - -```bash -make build # 合并模块生成 yewresin.sh -make clean # 删除生成的 yewresin.sh -make help # 查看帮助 -``` - -#### 开发流程 - -1. 修改 `src/` 目录下的模块文件 -2. 运行 `make build` 重新生成 `yewresin.sh` -3. 提交 `src/`、`Makefile` 和 `yewresin.sh` - -### Go 版本 - -Go 版本位于 `go/` 目录,提供跨平台支持和并行处理能力。 - -#### 源码结构 - -``` -go/ -├── main.go # 程序入口,CLI 参数解析,子命令路由 -├── orchestrator.go # 备份流程编排器 -├── config.go # 配置管理 -├── config_bundle.go # 配置导出/导入(age 加密) -├── docker.go # Docker Compose 服务管理 -├── backup.go # Kopia 备份操作 -├── logger.go # 日志系统 -├── gist.go # GitHub Gist 日志上传 -├── notify.go # Apprise 通知 -├── Makefile # 交叉编译脚本 -└── dist/ # 编译产物目录 -``` - -#### 构建命令 - -```bash -cd go -make build # 构建当前平台 -make all # 构建所有平台 (linux/darwin/windows) -make linux # 仅构建 Linux (amd64, arm64) -make darwin # 仅构建 macOS (amd64, arm64) -make windows # 仅构建 Windows (amd64) -make test # 运行测试 -make clean # 清理构建产物 -make help # 查看帮助 - -# 指定版本构建 -VERSION=v2.0.0 make all -``` - -#### 开发流程 - -1. 修改 `go/` 目录下的源文件 -2. 运行 `make test` 确保测试通过 -3. 运行 `make build` 构建当前平台进行本地测试 -4. 提交代码 - -### 版本号规则 - -- **Shell 版本**:`v1.x.x` 标签 -- **Go 版本**:`v2.x.x` 标签 - -两者共用 CI/CD 流程,通过主版本号区分: -- `latest` 标签:包含两个版本的最新开发构建 -- `v1.*` 标签:触发 Shell 版本正式发布 -- `v2.*` 标签:触发 Go 版本正式发布 - -## 工作流程 - -1. 检查依赖(rclone、kopia) -2. 停止普通服务 -3. 停止网关服务 -4. 创建 Kopia 快照 -5. 启动网关服务 -6. 启动普通服务 -7. 执行 Kopia 维护清理 - -## 注意事项 - -如果 `BASE_DIR` 下存在权限敏感的目录(如 `caddy/data/caddy`、`ssl`、`ssh` 等),Kopia 可能会因权限问题报错。虽然备份仍会完成,但建议在 Kopia 策略中忽略这些目录: - -## GitHub Gist 日志推送 - -脚本支持将每日备份日志自动推送到 GitHub Gist,实现日志持久化和远程查看。 - -### 为什么使用 Gist? - -- ✅ 持久化存储,不会被清理 -- ✅ 每次备份独立文件(如 `yewresin-backup-2025-12-20_03-00-15.log`),精确到秒 -- ✅ 有版本历史,可以查看每次备份的变化 -- ✅ 免费、稳定,支持 API 操作 -- ✅ 可以通过链接方便地分享和查看 - -### 配置步骤 - -#### 1. 创建 GitHub Personal Access Token - -访问 [GitHub Token 设置](https://github.com/settings/tokens/new),创建一个新的 token: - -- **Note**: YewResin Backup Logger -- **Expiration**: 自定义(建议选择较长期限) -- **Select scopes**: 只勾选 `gist` 权限 - -创建后复制 token(只会显示一次)。 - -#### 2. 创建一个空的 Gist - -访问 [gist.github.com](https://gist.github.com/),创建一个新的 Gist: - -- **Filename**: 可以随便写,比如 `backup-logs.md` -- **Content**: 可以随便写,比如 `# YewResin Backup Logs` -- 选择 **Public** 或 **Secret**(推荐 Secret) - -创建后,从 URL 中获取 Gist ID: -``` -https://gist.github.com/username/abc123def456789 - └─────────┬────────┘ - 这就是 Gist ID -``` - -#### 3. 配置环境变量 - -在 `.env` 文件中添加: - -```bash -GIST_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx -GIST_ID=abc123def456789 -GIST_LOG_PREFIX=my-server-backup # 可选,自定义日志文件名前缀 -GIST_MAX_LOGS=30 # 可选,最大保留日志数量,默认 30 -GIST_KEEP_FIRST_FILE=false # 可选,清理时保留第一个文件 -``` - -#### 4. 依赖检查 - -脚本需要 `jq` 工具来处理 JSON: - -```bash -# Debian/Ubuntu -sudo apt install jq - -# macOS -brew install jq -``` - -### 使用效果 - -每次备份完成后,脚本会自动创建新的日志文件到 Gist,文件名格式为 `-YYYY-MM-DD_HH-MM-SS.log`(精确到秒),包含: - -- 备份状态(成功/失败) -- 执行时间和耗时 -- 配置信息 -- 完整的日志输出 - -默认前缀为 `yewresin-backup`,可以通过 `GIST_LOG_PREFIX` 环境变量自定义。 - -### 自动清理旧日志 - -上传成功后,脚本会自动检查并清理超出数量限制的旧日志文件: - -- `GIST_MAX_LOGS`:最大保留日志数量(默认 30,设为 0 禁用清理) -- `GIST_KEEP_FIRST_FILE`:设为 `true` 时,清理会跳过按文件名排序最小的文件 - -**使用场景**:如果你想在 Gist 中保留一个自定义的标题/描述文件(如 `00-README.md`),可以: -1. 在 Gist 中创建一个文件名较小的文件(如 `00-README.md`)作为标题 -2. 设置 `GIST_KEEP_FIRST_FILE=true` - -这样清理时会自动跳过这个标题文件,只清理日志文件。 - -你可以通过 `https://gist.github.com/your_username/GIST_ID` 访问查看所有日志。Gist 会按文件名自动排序,由于日期是递增的,最新的备份日志在最下面。 - -## 定时任务配置 - -### Cron 表达式格式 - -``` -┌───────────── 分钟 (0-59) -│ ┌─────────── 小时 (0-23) -│ │ ┌───────── 日期 (1-31) -│ │ │ ┌─────── 月份 (1-12) -│ │ │ │ ┌───── 星期 (0-7,0 和 7 都表示周日) -│ │ │ │ │ -* * * * * -``` - -### 常用配置示例 - -> **注意**: -> - 以下示例假设服务器使用 UTC 时区,时间已转换为北京时间对应的 UTC 时间 -> - 请先确认服务器时区(`timedatectl` 或 `date`),如服务器使用本地时区则无需转换 -> - 脚本会自动将日志输出到 `LOG_FILE`,无需在 cron 中配置重定向 - -```bash -# 编辑 crontab -crontab -e - -# 每天北京时间凌晨 3 点执行备份(UTC 19:00) -0 19 * * * /path/to/yewresin.sh -y - -# 每周日北京时间凌晨 2 点执行备份(UTC 周六 18:00) -0 18 * * 6 /path/to/yewresin.sh -y - -# 每 6 小时执行一次(UTC 0点、6点、12点、18点) -0 */6 * * * /path/to/yewresin.sh -y - -# 每天北京时间凌晨 3 点和 15 点执行(UTC 19:00 和 07:00) -0 7,19 * * * /path/to/yewresin.sh -y - -# 每月 2 日和 16 日北京时间凌晨 4 点执行(对应 UTC 时间 1 日和 15 日的 20:00) -0 20 1,15 * * /path/to/yewresin.sh -y -``` - -### 使用 Systemd Timer - -相比 cron,systemd timer 提供更好的日志管理和错误处理。 - -创建服务文件 `/etc/systemd/system/yewresin-backup.service`: - -```ini -[Unit] -Description=YewResin Docker Backup -After=docker.service -Requires=docker.service - -[Service] -Type=oneshot -ExecStart=/path/to/yewresin.sh -y -StandardOutput=journal -StandardError=journal -``` - -创建定时器文件 `/etc/systemd/system/yewresin-backup.timer`: - -```ini -[Unit] -Description=Run YewResin backup daily - -[Timer] -OnCalendar=*-*-* 03:00:00 -Persistent=true -RandomizedDelaySec=300 - -[Install] -WantedBy=timers.target -``` - -启用定时器: - -```bash -sudo systemctl daemon-reload -sudo systemctl enable --now yewresin-backup.timer - -# 查看定时器状态 -systemctl list-timers yewresin-backup.timer - -# 查看备份日志 -journalctl -u yewresin-backup.service -f -``` - -### 注意事项 - -- **使用绝对路径**:cron 环境的 PATH 与交互式 shell 不同,务必使用脚本的绝对路径 -- **日志轮转**:建议配合 logrotate 管理日志文件大小 -- **错误通知**:脚本已集成 Apprise 通知,配置后可自动发送备份结果 -- **避免重叠**:脚本内置锁机制,防止多个备份任务同时运行 - -### 使用 sudo cron 运行 - -Docker 操作通常需要 root 权限,但 Kopia 和 rclone 的配置文件默认存储在**当前用户**的 home 目录下。如果你以普通用户配置了 Kopia 和 rclone,然后在 `sudo crontab` 中运行脚本,root 用户会找不到配置文件。 - -通过 `KOPIA_CONFIG_FILE` 和 `RCLONE_CONFIG` 环境变量,你可以将配置文件路径指向原来的非 root 用户目录,避免手动复制配置: - -```bash -# 假设你以 yewfence 用户配置了 kopia 和 rclone -# 在 .env 中添加以下配置: - -# Kopia 配置文件(默认位于 ~/.config/kopia/repository.config) -KOPIA_CONFIG_FILE="/home/yewfence/.config/kopia/repository.config" - -# Rclone 配置文件(默认位于 ~/.config/rclone/rclone.conf) -RCLONE_CONFIG="/home/yewfence/.config/rclone/rclone.conf" -``` - -然后在 root 的 crontab 中配置定时任务: - -```bash -sudo crontab -e - -# 每天北京时间凌晨 3 点执行(UTC 19:00) -0 19 * * * /home/yewfence/yewresin/yewresin -y ``` -> **提示**: -> - 用 `echo ~yewfence` 确认用户的 home 目录路径 -> - 如果你的普通用户在 `docker` 用户组中可以免 sudo 运行 Docker,也可以直接使用普通用户的 `crontab -e` 配置,这样无需额外指定配置文件路径 +## 文档 +完整文档请访问 [YewResin 文档站](https://yewfence.github.io/YewResin/) -## 异地恢复引导 - -当服务器需要迁移或灾难恢复时,按以下步骤从备份中恢复数据。 - -### 1. 安装依赖 - -在新机器上安装 Kopia 和 rclone(如果备份使用了 rclone 远端): - -```bash -# 安装 kopia -curl -s https://kopia.io/signing-key | sudo gpg --dearmor -o /etc/apt/keyrings/kopia-keyring.gpg -echo "deb [signed-by=/etc/apt/keyrings/kopia-keyring.gpg] http://packages.kopia.io/apt/ stable main" | sudo tee /etc/apt/sources.list.d/kopia.list -sudo apt update && sudo apt install kopia - -# 安装 rclone(如果备份存储在云端) -curl https://rclone.org/install.sh | sudo bash -``` - -### 2. 配置 rclone(如需) - -如果你的 Kopia 仓库使用 rclone 作为存储后端,需要先在新机器上配好相同的远端。 - -**最简单的方法**:直接从旧机器复制配置文件。先在旧机器上找到文件位置: - -```bash -rclone config file -# Configuration file is stored at: -# /home/username/.config/rclone/rclone.conf -``` - -将该文件复制到新机器的相同路径即可。也可以在新机器上重新交互式配置: - -```bash -rclone config -``` - -### 3. 连接 Kopia 仓库 - -**最简单的方法**:在旧机器上用 `kopia repository status -t -s` 获取连接令牌,在新机器上用令牌一步重连,无需重新配置 rclone: - -```bash -# 在旧机器上运行,输出中包含完整的重连命令 -kopia repository status -t -s -# To reconnect to the repository use: -# $ kopia repository connect from-config --token eyJ2ZXJz... - -# 在新机器上直接执行上面的命令即可 -kopia repository connect from-config --token eyJ2ZXJz... -``` - -如果没有旧机器可访问,可以重新手动连接: - -```bash -# 连接 rclone 远端仓库(与备份时的 EXPECTED_REMOTE 一致) -kopia repository connect rclone --remote-path="gdrive:backup" - -# 或连接本地/文件系统仓库 -kopia repository connect filesystem --path /path/to/kopia-repo - -# 或使用 S3 仓库 -kopia repository connect s3 --bucket=my-backup-bucket \ - --access-key=AKIAIOSFODNN7EXAMPLE \ - --secret-access-key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY -``` - -> 手动连接时需要输入创建仓库时设置的密码。令牌方式已内含凭据,无需再输入密码。 - -### 4. 查看可用快照 - -```bash -kopia snapshot list -``` - -输出示例: - -```text -user@hostname:/opt/docker_file - 2025-12-20 03:00:15 UTC k1a2b3c4d5e6f7 102.6 MB - 2025-12-21 03:00:12 UTC k8a9b0c1d2e3f4 103.1 MB (+0.5 MB) -``` - -### 5. 恢复数据 - -**方式一:直接恢复到目标目录(推荐)** - -```bash -# 恢复整个快照到指定目录 -kopia snapshot restore /opt/docker_file -``` - -**方式二:挂载后手动选择文件** - -```bash -mkdir /tmp/kopia-mount -kopia mount /tmp/kopia-mount & - -# 浏览并按需复制文件 -ls /tmp/kopia-mount/ -cp -r /tmp/kopia-mount/some-service /opt/docker_file/ - -# 完成后卸载 -umount /tmp/kopia-mount -``` - -### 6. 恢复后启动服务 - -```bash -# 逐个进入服务目录启动(docker compose 会自动检测 compose 文件) -cd /opt/docker_file -for dir in */; do - if ls "$dir"compose*.y*ml "$dir"docker-compose*.y*ml 2>/dev/null | head -1 > /dev/null; then - echo "Starting $dir..." - (cd "$dir" && docker compose up -d) - fi -done -``` - -> 更多 Kopia 用法参考 [Kopia 官方文档](https://kopia.io/docs/),rclone 配置参考 [rclone 官方文档](https://rclone.org/docs/)。 - -## Kopia Web UI - -Kopia 内置了一个 Web 界面,可以直观地浏览快照、手动触发备份、查看仓库状态等: - -```bash -kopia server start -``` - -启动后在浏览器访问 `http://localhost:51515`。 +- [工作原理](https://yewfence.github.io/YewResin/guide/how-it-works) +- [Gist 日志推送](https://yewfence.github.io/YewResin/guide/gist-logging) +- [定时任务](https://yewfence.github.io/YewResin/guide/scheduling) +- [异地恢复](https://yewfence.github.io/YewResin/guide/recovery) +- [配置参考](https://yewfence.github.io/YewResin/reference/configuration) ## License diff --git a/go/TODO.md b/TODO.md similarity index 59% rename from go/TODO.md rename to TODO.md index 211c36a..f95ed8d 100644 --- a/go/TODO.md +++ b/TODO.md @@ -13,3 +13,9 @@ - [x] 单元测试 - [ ] 更结构化的配置格式(YAML/TOML) - [ ] JSON 日志格式输出选项 +- [ ] 引入 pinact github action 自动 pin action 版本 + +## 新功能 +- [ ] 明确指定次序的优先服务启停(应用场景先启动数据库再启动AI网关再启动依赖AI的服务) +- [ ] 支持自定义启停脚本名称 +- [ ] 自动安装/卸载 systemd timer diff --git a/compose.goreleaser.yaml b/compose.goreleaser.yaml new file mode 100644 index 0000000..a6e1380 --- /dev/null +++ b/compose.goreleaser.yaml @@ -0,0 +1,12 @@ +services: + goreleaser: + image: goreleaser/goreleaser:v2.15.2 + working_dir: /app + volumes: + - .:/app + - go-module-cache:/go/pkg/mod + env_file: + - .env.goreleaser + +volumes: + go-module-cache: diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts new file mode 100644 index 0000000..da7725a --- /dev/null +++ b/docs/.vitepress/config.ts @@ -0,0 +1,63 @@ +import { defineConfig } from 'vitepress' + +export default defineConfig({ + base: '/YewResin/', + lang: 'zh-CN', + title: 'YewResin', + description: '自动化的 Docker Compose 服务备份工具,使用 Kopia 实现本地快照与云端同步', + + themeConfig: { + nav: [ + { text: '指南', link: '/guide/getting-started' }, + { text: '配置参考', link: '/reference/configuration' }, + { text: 'GitHub', link: 'https://github.com/YewFence/YewResin' } + ], + + sidebar: [ + { + text: '指南', + items: [ + { text: '快速开始', link: '/guide/getting-started' }, + { text: '工作原理', link: '/guide/how-it-works' }, + { text: 'Gist 日志推送', link: '/guide/gist-logging' }, + { text: '定时任务', link: '/guide/scheduling' }, + { text: '异地恢复', link: '/guide/recovery' }, + { text: '开发指南', link: '/guide/development' }, + { text: '文档站维护', link: '/guide/docs-site' } + ] + }, + { + text: '参考', + items: [ + { text: '配置项', link: '/reference/configuration' } + ] + } + ], + + search: { + provider: 'local' + }, + + socialLinks: [ + { icon: 'github', link: 'https://github.com/YewFence/YewResin' } + ], + + footer: { + message: 'Released under the MIT License.', + copyright: 'Copyright © YewFence' + }, + + docFooter: { + prev: '上一页', + next: '下一页' + }, + + outline: { + label: '本页目录' + }, + + lastUpdated: { + text: '最后更新' + } + } +}) diff --git a/docs/guide/development.md b/docs/guide/development.md new file mode 100644 index 0000000..3c91d2d --- /dev/null +++ b/docs/guide/development.md @@ -0,0 +1,108 @@ +# 开发指南 + +## 环境要求 + +- **Go 1.25.8+** +- **[just](https://github.com/casey/just)** - 命令运行器 +- **Docker** - 用于跨平台构建(GoReleaser 通过 Docker 运行) +- **[pinact](https://github.com/suzuki-shunsuke/pinact)** - 用于 pin GitHub Actions 版本 +- **[ghtkn](https://github.com/suzuki-shunsuke/ghtkn)** - (可选的)用于使 `pinact` 绕过 GitHub API 速率限制 +- **[lefthook](https://github.com/evilmartians/lefthook)** - 管理 pinact 检测 hook + +## 项目结构 + +```text +YewResin/ +├── main.go # 程序入口,CLI 参数解析 +├── main_test.go +├── internal/yewresin/ # 核心逻辑 +│ ├── orchestrator.go # 备份流程编排:锁机制、信号处理、cleanup +│ ├── docker.go # 服务发现、启停、并行操作 +│ ├── backup.go # Kopia 快照创建、依赖检查 +│ ├── config.go # 环境变量加载、配置验证 +│ ├── logger.go # slog 日志系统、文件输出 +│ ├── gist.go # Gist 上传和旧日志清理 +│ └── notify.go # Apprise 异步通知 +├── justfile # 构建脚本 +├── compose.goreleaser.yaml # GoReleaser Docker Compose 配置 +├── .goreleaser.yaml # GoReleaser 发布配置 +└── .github/workflows/ + ├── build-artifact.yml # PR 构建验证 + ├── release.yml # 正式发布(push tag) + └── docs.yml # 文档站部署 +``` + +## 常用命令 + +```bash +# 快速构建当前平台(不需要 Docker) +just + +# 运行测试 +just test + +# 清理构建产物 +just clean +``` + +### Github Action 更新 + +```bash +# 安装检测 hook +lefthook install + +# 更新 github action 版本 +ghtkn get | pinact token set -stdin +pinact run -u +``` + +## 跨平台构建(GoReleaser) + +跨平台构建通过 Docker 运行 GoReleaser,**不需要本地安装 GoReleaser**,只需要 Docker。 + +```bash +# 构建全平台可执行文件(linux/darwin/windows × amd64/arm64),不发布 +just release-snapshot + +# 模拟完整发布流程(含 changelog 生成),不推送 +just release-dry +``` + +构建产物输出到 `dist/` 目录。 + +## 正式发布 + +正式发布需要: + +1. 创建 `.env.goreleaser` 文件(参考下方配置) +2. 打 `v*` 格式的 git tag +3. 运行 `just release` + +`.env.goreleaser` 示例: + +```bash +GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx +TAP_GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx # Homebrew Tap 仓库的 PAT +HOMEBREW_TAP_OWNER=YewFence +HOMEBREW_TAP_NAME=homebrew-tap +``` + +也可以直接推送 tag 触发 GitHub Actions 自动发布: + +```bash +git tag v2.1.0 +git push origin v2.1.0 +``` + +## 服务启停优先级逻辑 + +- **优先服务**(`PRIORITY_SERVICES_LIST`,默认 `caddy nginx gateway`):最后停止,最先启动 +- **普通服务**:先停止,后启动 +- 所有服务并行启停,只恢复原本在运行的服务 + +## 开发流程 + +1. 修改 `internal/yewresin/` 下的源文件 +2. `just test` 确保测试通过 +3. `just build` 构建本地二进制进行测试 +4. 提交代码 diff --git a/docs/guide/docs-site.md b/docs/guide/docs-site.md new file mode 100644 index 0000000..e621767 --- /dev/null +++ b/docs/guide/docs-site.md @@ -0,0 +1,69 @@ +# 文档站维护 + +文档站使用 [VitePress](https://vitepress.dev/) 构建,源文件在 `docs/` 目录下。 + +## 目录结构 + +```text +docs/ +├── .vitepress/ +│ ├── config.ts # 站点配置(导航栏、侧边栏、搜索等) +│ ├── dist/ # 构建产物(gitignore) +│ └── cache/ # 构建缓存(gitignore) +├── package.json # VitePress 依赖 +├── index.md # 首页 +├── guide/ # 指南文档 +│ ├── getting-started.md +│ ├── how-it-works.md +│ ├── gist-logging.md +│ ├── scheduling.md +│ ├── recovery.md +│ └── development.md +└── reference/ # 参考文档 + └── configuration.md +``` + +## 环境要求 + +- **Node.js 22+** +- **pnpm**(包管理器) + +## 本地开发 + +```bash +cd docs + +# 安装依赖 +pnpm install + +# 启动开发服务器(热更新) +pnpm dev + +# 构建静态站点 +pnpm build + +# 本地预览构建结果 +pnpm preview +``` + +开发服务器默认在 `http://localhost:5173` 启动,修改 `.md` 文件会自动热更新。 + +## 添加新页面 + +1. 在 `docs/guide/` 或 `docs/reference/` 下创建新的 `.md` 文件 +2. 在 `docs/.vitepress/config.ts` 的 `sidebar` 中添加对应的链接 + +## 部署 + +文档站通过 GitHub Actions 自动部署到 GitHub Pages: + +- **触发条件**:push 到 `main` 分支且 `docs/` 目录有变更,或手动触发 +- **工作流文件**:`.github/workflows/docs.yml` + +首次使用需要在 GitHub 仓库中配置: + +1. 进入仓库 **Settings → Pages** +2. **Source** 选择 **GitHub Actions** +3. 推送到 `main` 后自动部署 + +站点地址:`https://yewfence.github.io/YewResin/` diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md new file mode 100644 index 0000000..33ad02d --- /dev/null +++ b/docs/guide/getting-started.md @@ -0,0 +1,116 @@ +# 快速开始 + +## 依赖 + +- [Kopia](https://kopia.io/docs/installation/) - 快照备份工具 +- Docker & Docker Compose +- 可选:[rclone](https://rclone.org/downloads/) - 云存储同步工具 + +## 1. 安装依赖 + +```bash +# 安装 rclone(按需) +curl https://rclone.org/install.sh | sudo bash +rclone config # 配置远程存储(如 Google Drive) + +# 安装 kopia +# Debian/Ubuntu +curl -s https://kopia.io/signing-key | sudo gpg --dearmor -o /etc/apt/keyrings/kopia-keyring.gpg +echo "deb [signed-by=/etc/apt/keyrings/kopia-keyring.gpg] http://packages.kopia.io/apt/ stable main" | sudo tee /etc/apt/sources.list.d/kopia.list +sudo apt update && sudo apt install kopia + +# 连接 Kopia 仓库 +kopia repository connect rclone --remote-path="gdrive:backup" +``` + +## 2. 下载 YewResin + +根据系统架构下载对应的二进制文件: + +```bash +mkdir ~/yewresin && cd ~/yewresin + +# Linux x64 +wget https://github.com/YewFence/YewResin/releases/latest/download/yewresin-linux-amd64 -O yewresin +# Linux ARM64 +wget https://github.com/YewFence/YewResin/releases/latest/download/yewresin-linux-arm64 -O yewresin +# macOS Apple Silicon +wget https://github.com/YewFence/YewResin/releases/latest/download/yewresin-darwin-arm64 -O yewresin +# macOS Intel +wget https://github.com/YewFence/YewResin/releases/latest/download/yewresin-darwin-amd64 -O yewresin +# Windows +# 下载 yewresin-windows-amd64.exe + +chmod +x yewresin +``` + +> `latest` 标签会在 main 分支推送后自动更新,也可以下载指定版本(如 `v2.0.0`)。 + +## 3. 配置 + +创建 `.env` 文件(与 yewresin 同目录): + +```bash +# 在脚本所在目录下载示例文件 +wget https://github.com/YewFence/YewResin/releases/latest/download/.env.example +cp .env.example .env +``` + +必要环境变量配置: + +```bash +# Docker Compose 项目总目录 +BASE_DIR=/opt/docker_file +# Kopia 远程路径 +EXPECTED_REMOTE=gdrive:backup +``` + +配置加载优先级: + +- 先使用当前进程中已经存在的环境变量 +- 再从 `--config` 指定文件或程序同目录的 `.env` 补齐缺失项 +- 仍未设置时,部分可选项会回退到程序内置默认值 +- `BASE_DIR` 和 `EXPECTED_REMOTE` 等必填项如果最终为空,程序会直接退出 + +## 4. 运行 + +```bash +# 模拟运行(推荐先测试) +./yewresin --dry-run + +# 执行备份(需确认) +./yewresin + +# 跳过确认直接执行(适用于 cron) +./yewresin -y +``` + +## 5. 备份连接凭证 + +为了保证可以快速异地恢复,建议备份连接 Kopia 仓库与 rclone (如有)的连接凭证 + +### Rclone 连接凭证 +```bash +rclone config file # 确认 rclone 配置文件路径 +# 复制配置文件内容并安全保存 +# 此处以默认路径为例 +cat ~/.config/rclone/rclone.conf +``` +### Kopia 连接凭证 + +```bash +# 打印 kopia 仓库连接状态,输出中包含完整的重连命令 +kopia repository status -t -s +# 部分示例输出: +# To reconnect to the repository use: +# $ kopia repository connect from-config --token eyJ2ZXJz... +# 复制并保存该命令内容 +``` + +### 注意事项 +- 连接凭证可以**直接用于访问仓库**,请妥善保管 +- 建议将凭证存储在安全的密码管理器中,如 [Bitwarden](https://bitwarden.com/) ,或使用 [age](https://github.com/FiloSottile/age) 等工具加密保存 + +更多异地恢复信息请参考 [恢复指南](/guide/recovery), + +更多运行参数请参考 [配置参考](/reference/configuration)。 diff --git a/docs/guide/gist-logging.md b/docs/guide/gist-logging.md new file mode 100644 index 0000000..586b7d8 --- /dev/null +++ b/docs/guide/gist-logging.md @@ -0,0 +1,82 @@ +# GitHub Gist 日志推送 + +脚本支持将每日备份日志自动推送到 GitHub Gist,实现日志持久化和远程查看。 + +## 为什么使用 Gist? + +- 持久化存储,不会被清理 +- 每次备份独立文件(如 `yewresin-backup-2025-12-20_03-00-15.log`),精确到秒 +- 有版本历史,可以查看每次备份的变化 +- 免费、稳定,支持 API 操作 +- 可以通过链接方便地分享和查看 + +## 配置步骤 + +### 1. 创建 GitHub Personal Access Token + +访问 [GitHub Token 设置](https://github.com/settings/tokens/new),创建一个新的 token: + +- **Note**: YewResin Backup Logger +- **Expiration**: 自定义(建议选择较长期限) +- **Select scopes**: 只勾选 `gist` 权限 + +创建后复制 token(只会显示一次)。 + +### 2. 创建一个空的 Gist + +访问 [gist.github.com](https://gist.github.com/),创建一个新的 Gist: + +- **Filename**: 可以随便写,比如 `backup-logs.md` +- **Content**: 可以随便写,比如 `# YewResin Backup Logs` +- 选择 **Public** 或 **Secret**(推荐 Secret) + +创建后,从 URL 中获取 Gist ID: + +```text +https://gist.github.com/username/abc123def456789 + └─────────┬────────┘ + 这就是 Gist ID +``` + +### 3. 配置环境变量 + +在 `.env` 文件中添加: + +```bash +GIST_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx +GIST_ID=abc123def456789 +GIST_LOG_PREFIX=my-server-backup # 可选,自定义日志文件名前缀 +GIST_MAX_LOGS=30 # 可选,最大保留日志数量,默认 30 +GIST_KEEP_FIRST_FILE=false # 可选,清理时保留第一个文件 +``` + +### 4. 依赖检查 + +当前 Go 版本无需额外安装 `jq`,日志推送由程序内置实现直接调用 GitHub Gist API。 + +## 使用效果 + +每次备份完成后,脚本会自动创建新的日志文件到 Gist,文件名格式为 `-YYYY-MM-DD_HH-MM-SS.log`(精确到秒),包含: + +- 备份状态(成功/失败) +- 执行时间和耗时 +- 配置信息 +- 完整的日志输出 + +默认前缀为 `yewresin-backup`,可以通过 `GIST_LOG_PREFIX` 环境变量自定义。 + +## 自动清理旧日志 + +上传成功后,脚本会自动检查并清理超出数量限制的旧日志文件: + +- `GIST_MAX_LOGS`:最大保留日志数量(默认 30,设为 0 禁用清理) +- `GIST_KEEP_FIRST_FILE`:设为 `true` 时,清理会跳过按文件名排序最小的文件 + +**使用场景**:如果你想在 Gist 中保留一个自定义的标题/描述文件(如 `00-README.md`),可以: + +1. 在 Gist 中创建一个文件名较小的文件(如 `00-README.md`)作为标题 +2. 设置 `GIST_KEEP_FIRST_FILE=true` + +这样清理时会自动跳过这个标题文件,只清理日志文件。 + +你可以通过 `https://gist.github.com/your_username/GIST_ID` 访问查看所有日志。Gist 会按文件名自动排序,由于日期是递增的,最新的备份日志在最下面。 diff --git a/docs/guide/how-it-works.md b/docs/guide/how-it-works.md new file mode 100644 index 0000000..cfe1a97 --- /dev/null +++ b/docs/guide/how-it-works.md @@ -0,0 +1,62 @@ +# 工作原理 + +## 工作流程 + +1. 检查依赖(rclone、kopia) +2. 停止普通服务(并行) +3. 停止优先服务 +4. 创建 Kopia 快照 +5. 启动优先服务 +6. 启动普通服务(并行) +7. 执行 Kopia 维护清理 + +## 目录结构要求 + +```text +/opt/docker_file/ # BASE_DIR +├── caddy/ # 网关服务 +│ ├── compose.yaml # 支持多种命名格式 +│ └── compose-up.sh # 可选:自定义启动脚本 +├── nginx/ +│ └── docker-compose.yml +├── app1/ # 普通服务 +│ └── compose.yml +└── app2/ + └── docker-compose.yaml +``` + +脚本会自动识别包含以下任一配置文件的目录作为服务: + +- `compose.yaml` +- `compose.yml` +- `docker-compose.yaml` +- `docker-compose.yml` + +## 服务启停逻辑 + +服务名称以文件夹名称为准 + +### 单个服务启停逻辑 + +服务启停按以下优先级执行: + +1. **自定义脚本优先**:若目录下存在 `compose-stop.sh`/`compose-down.sh`/`compose-up.sh`,优先使用脚本启停 +2. **自动识别配置文件**:若无自定义脚本但存在 compose 配置文件,使用 `docker compose up -d` / `docker compose stop` 启停 + +### 优先服务启停逻辑 + +最后停止,最先启动,如网关/数据库等服务 + +## 快速失败机制 + +为保护数据完整性,脚本在停止服务阶段采用快速失败策略: + +- 如果任何服务停止失败,脚本会**立即中止**,不会继续执行备份 +- 已停止的服务会通过 cleanup 函数自动恢复 +- 通过 Apprise 发送通知告知失败原因 + +这确保了不会在服务仍在运行(可能正在写入数据)时进行备份,避免数据库文件损坏等问题。 + +## 注意事项 + +如果 `BASE_DIR` 下存在权限敏感的目录(如 `caddy/data/caddy`、`ssl`、`ssh` 等),Kopia 可能会因权限问题报错。虽然备份仍会完成,但建议在 Kopia 策略中忽略这些目录。 diff --git a/docs/guide/recovery.md b/docs/guide/recovery.md new file mode 100644 index 0000000..5f69b0f --- /dev/null +++ b/docs/guide/recovery.md @@ -0,0 +1,131 @@ +# 异地恢复引导 + +当服务器需要迁移或灾难恢复时,按以下步骤从备份中恢复数据。 + +## 1. 安装依赖 + +在新机器上安装 Kopia 和 rclone(如果备份使用了 rclone 远端): + +```bash +# 以 Linux 为例 +# 安装 kopia +curl -s https://kopia.io/signing-key | sudo gpg --dearmor -o /etc/apt/keyrings/kopia-keyring.gpg +echo "deb [signed-by=/etc/apt/keyrings/kopia-keyring.gpg] http://packages.kopia.io/apt/ stable main" | sudo tee /etc/apt/sources.list.d/kopia.list +sudo apt update && sudo apt install kopia + +# 安装 rclone(如果使用了) +sudo -v ; curl https://rclone.org/install.sh | sudo bash +``` + +> 其他平台的安装方式参见 [Kopia 安装文档](https://kopia.io/docs/installation/) 和 [rclone 安装文档](https://rclone.org/downloads/)。 + +## 2. 配置 rclone(如需) + +如果你的 Kopia 仓库使用 rclone 作为存储后端,需要先在新机器上配好相同的远端。 + +**最简单的方法**:直接从旧机器复制配置文件。先在旧机器上找到文件位置: + +```bash +rclone config file +# Configuration file is stored at: +# /home/username/.config/rclone/rclone.conf +``` + +将该文件复制到新机器的相同路径即可。也可以在新机器上重新交互式配置: + +```bash +rclone config +``` + +## 3. 连接 Kopia 仓库 + +**最简单的方法**:在旧机器上用 `kopia repository status -t -s` 获取连接令牌,在新机器上用令牌一步重连,无需重新配置 rclone: + +```bash +# 在旧机器上运行,输出中包含完整的重连命令 +kopia repository status -t -s +# To reconnect to the repository use: +# $ kopia repository connect from-config --token eyJ2ZXJz... + +# 在新机器上直接执行上面的命令即可 +kopia repository connect from-config --token eyJ2ZXJz... +``` + +如果没有旧机器可访问,可以重新手动连接: + +```bash +# 连接 rclone 远端仓库(与备份时的 EXPECTED_REMOTE 一致) +kopia repository connect rclone --remote-path="gdrive:backup" + +# 或连接本地/文件系统仓库 +kopia repository connect filesystem --path /path/to/kopia-repo + +# 或使用 S3 仓库 +kopia repository connect s3 --bucket=my-backup-bucket \ + --access-key=AKIAIOSFODNN7EXAMPLE \ + --secret-access-key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +``` + +> 手动连接时需要输入创建仓库时设置的密码。令牌方式已内含凭据,无需再输入密码。 + +## 4. 查看可用快照 + +```bash +kopia snapshot list +``` + +输出示例: + +```text +user@hostname:/opt/docker_file + 2025-12-20 03:00:15 UTC k1a2b3c4d5e6f7 102.6 MB + 2025-12-21 03:00:12 UTC k8a9b0c1d2e3f4 103.1 MB (+0.5 MB) +``` + +## 5. 恢复数据 + +**方式一:直接恢复到目标目录(推荐)** + +```bash +# 恢复整个快照到指定目录 +kopia snapshot restore /opt/docker_file +``` + +**方式二:挂载后手动选择文件** + +```bash +mkdir /tmp/kopia-mount +kopia mount /tmp/kopia-mount & + +# 浏览并按需复制文件 +ls /tmp/kopia-mount/ +cp -r /tmp/kopia-mount/some-service /opt/docker_file/ + +# 完成后卸载 +umount /tmp/kopia-mount +``` + +## 6. 恢复后启动服务 + +```bash +# 逐个进入服务目录启动(docker compose 会自动检测 compose 文件) +cd /opt/docker_file +for dir in */; do + if ls "$dir"compose*.y*ml "$dir"docker-compose*.y*ml 2>/dev/null | head -1 > /dev/null; then + echo "Starting $dir..." + (cd "$dir" && docker compose up -d) + fi +done +``` + +> 更多 Kopia 用法参考 [Kopia 官方文档](https://kopia.io/docs/),rclone 配置参考 [rclone 官方文档](https://rclone.org/docs/)。 + +## Kopia Web UI + +Kopia 内置了一个 Web 界面,可以直观地浏览快照、手动触发备份、查看仓库状态等: + +```bash +kopia server start +``` + +启动后在浏览器访问 `http://localhost:51515`。 diff --git a/docs/guide/scheduling.md b/docs/guide/scheduling.md new file mode 100644 index 0000000..e6d1f66 --- /dev/null +++ b/docs/guide/scheduling.md @@ -0,0 +1,124 @@ +# 定时任务配置 + +## Cron 表达式格式 + +```text +┌───────────── 分钟 (0-59) +│ ┌─────────── 小时 (0-23) +│ │ ┌───────── 日期 (1-31) +│ │ │ ┌─────── 月份 (1-12) +│ │ │ │ ┌───── 星期 (0-7,0 和 7 都表示周日) +│ │ │ │ │ +* * * * * +``` + +## 常用配置示例 + +> **注意**: +> - 以下示例假设服务器使用 UTC 时区,时间已转换为北京时间对应的 UTC 时间 +> - 请先确认服务器时区(`timedatectl` 或 `date`),如服务器使用本地时区则无需转换 +> - 脚本会自动将日志输出到 `LOG_FILE`,无需在 cron 中配置重定向 + +```bash +# 编辑 crontab +crontab -e + +# 每天北京时间凌晨 3 点执行备份(UTC 19:00) +0 19 * * * /path/to/yewresin -y + +# 每周日北京时间凌晨 2 点执行备份(UTC 周六 18:00) +0 18 * * 6 /path/to/yewresin -y + +# 每 6 小时执行一次(UTC 0点、6点、12点、18点) +0 */6 * * * /path/to/yewresin -y + +# 每天北京时间凌晨 3 点和 15 点执行(UTC 19:00 和 07:00) +0 7,19 * * * /path/to/yewresin -y + +# 每月 2 日和 16 日北京时间凌晨 4 点执行(对应 UTC 时间 1 日和 15 日的 20:00) +0 20 1,15 * * /path/to/yewresin -y +``` + +## 使用 Systemd Timer + +相比 cron,systemd timer 提供更好的日志管理和错误处理。 + +创建服务文件 `/etc/systemd/system/yewresin-backup.service`: + +```ini +[Unit] +Description=YewResin Docker Backup +After=docker.service +Requires=docker.service + +[Service] +Type=oneshot +ExecStart=/path/to/yewresin -y +StandardOutput=journal +StandardError=journal +``` + +创建定时器文件 `/etc/systemd/system/yewresin-backup.timer`: + +```ini +[Unit] +Description=Run YewResin backup daily + +[Timer] +OnCalendar=*-*-* 03:00:00 +Persistent=true +RandomizedDelaySec=300 + +[Install] +WantedBy=timers.target +``` + +启用定时器: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now yewresin-backup.timer + +# 查看定时器状态 +systemctl list-timers yewresin-backup.timer + +# 查看备份日志 +journalctl -u yewresin-backup.service -f +``` + +## 注意事项 + +- **使用绝对路径**:cron 环境的 PATH 与交互式 shell 不同,务必使用脚本的绝对路径 +- **日志轮转**:建议配合 logrotate 管理日志文件大小 +- **错误通知**:脚本已集成 Apprise 通知,配置后可自动发送备份结果 +- **避免重叠**:脚本内置锁机制,防止多个备份任务同时运行 + +## 使用 sudo cron 运行 + +Docker 操作通常需要 root 权限,但 Kopia 和 rclone 的配置文件默认存储在**当前用户**的 home 目录下。如果你以普通用户配置了 Kopia 和 rclone,然后在 `sudo crontab` 中运行脚本,root 用户会找不到配置文件。 + +通过 `KOPIA_CONFIG_FILE` 和 `RCLONE_CONFIG` 环境变量,你可以将配置文件路径指向原来的非 root 用户目录,避免手动复制配置: + +```bash +# 假设你以 yewfence 用户配置了 kopia 和 rclone +# 在 .env 中添加以下配置: + +# Kopia 配置文件(默认位于 ~/.config/kopia/repository.config) +KOPIA_CONFIG_FILE="/home/yewfence/.config/kopia/repository.config" + +# Rclone 配置文件(默认位于 ~/.config/rclone/rclone.conf) +RCLONE_CONFIG="/home/yewfence/.config/rclone/rclone.conf" +``` + +然后在 root 的 crontab 中配置定时任务: + +```bash +sudo crontab -e + +# 每天北京时间凌晨 3 点执行(UTC 19:00) +0 19 * * * /home/yewfence/yewresin/yewresin -y +``` + +> **提示**: +> - 用 `echo ~$USER` 确认当前用户的 home 目录路径 +> - 如果你的普通用户在 `docker` 用户组中可以免 sudo 运行 Docker,也可以直接使用普通用户的 `crontab -e` 配置,这样无需额外指定配置文件路径 diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..e108679 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,32 @@ +--- +layout: home + +hero: + name: YewResin + text: Docker 服务备份工具 + tagline: 自动化 Docker Compose 服务备份,使用 Kopia 实现本地快照与云端备份 + actions: + - theme: brand + text: 快速开始 + link: /guide/getting-started + - theme: alt + text: 配置参考 + link: /reference/configuration + - theme: alt + text: GitHub Repo + link: https://github.com/YewFence/YewResin + +features: + - title: 一致性快照 + details: 自动停止所有 Docker Compose 服务后创建 Kopia 快照,确保数据完整性。支持优先级服务(如网关)的启停顺序控制。 + - title: 快速失败 + details: 服务停止失败时立即中止备份,避免在服务运行时备份导致数据损坏,已停止的服务会自动恢复。 + - title: 并行启停,服务状态一致 + details: 服务并行停止和启动,性能更优。只重启原本运行中的服务,不会启动原本停止的服务。 + - title: Gist 日志推送 + details: 将每次备份日志自动推送到 GitHub Gist,支持日志文件自动清理,方便远程查看和持久化。 + - title: 通知备份状态 + details: 支持 Apprise 通知,可通过 [YewFence/apprise](https://github.com/YewFence/apprise) 快速部署到 Vercel。 + - title: 多种运行模式 + details: 支持 dry-run 模式预览、跳过确认模式、锁机制防止重复运行,完美适配 cron 和 systemd timer。 +--- diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..136470c --- /dev/null +++ b/docs/package.json @@ -0,0 +1,13 @@ +{ + "name": "yewresin-docs", + "private": true, + "type": "module", + "scripts": { + "dev": "vitepress dev", + "build": "vitepress build", + "preview": "vitepress preview" + }, + "devDependencies": { + "vitepress": "^1.6.3" + } +} diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml new file mode 100644 index 0000000..81a61f1 --- /dev/null +++ b/docs/pnpm-lock.yaml @@ -0,0 +1,1620 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + vitepress: + specifier: ^1.6.3 + version: 1.6.4(@algolia/client-search@5.50.0)(postcss@8.5.8)(search-insights@2.17.3) + +packages: + + '@algolia/abtesting@1.16.0': + resolution: {integrity: sha512-alHFZ68/i9qLC/muEB07VQ9r7cB8AvCcGX6dVQi2PNHhc/ZQRmmFAv8KK1ay4UiseGSFr7f0nXBKsZ/jRg7e4g==} + engines: {node: '>= 14.0.0'} + + '@algolia/autocomplete-core@1.17.7': + resolution: {integrity: sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==} + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7': + resolution: {integrity: sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==} + peerDependencies: + search-insights: '>= 1 < 3' + + '@algolia/autocomplete-preset-algolia@1.17.7': + resolution: {integrity: sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/autocomplete-shared@1.17.7': + resolution: {integrity: sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/client-abtesting@5.50.0': + resolution: {integrity: sha512-mfgUdLQNxOAvCZUGzPQxjahEWEPuQkKlV0ZtGmePOa9ZxIQZlk31vRBNbM6ScU8jTH41SCYE77G/lCifDr1SVw==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-analytics@5.50.0': + resolution: {integrity: sha512-5mjokeKYyPaP3Q8IYJEnutI+O4dW/Ixxx5IgsSxT04pCfGqPXxTOH311hTQxyNpcGGEOGrMv8n8Z+UMTPamioQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-common@5.50.0': + resolution: {integrity: sha512-emtOvR6dl3rX3sBJXXbofMNHU1qMQqQSWu319RMrNL5BWoBqyiq7y0Zn6cjJm7aGHV/Qbf+KCCYeWNKEMPI3BQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-insights@5.50.0': + resolution: {integrity: sha512-IerGH2/hcj/6bwkpQg/HHRqmlGN1XwygQWythAk0gZFBrghs9danJaYuSS3ShzLSVoIVth4jY5GDPX9Lbw5cgg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-personalization@5.50.0': + resolution: {integrity: sha512-3idPJeXn5L0MmgP9jk9JJqblrQ/SguN93dNK9z9gfgyupBhHnJMOEjrRYcVgTIfvG13Y04wO+Q0FxE2Ut8PVbA==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-query-suggestions@5.50.0': + resolution: {integrity: sha512-q7qRoWrQK1a8m5EFQEmPlo7+pg9mVQ8X5jsChtChERre0uS2pdYEDixBBl0ydBSGkdGbLUDufcACIhH/077E4g==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-search@5.50.0': + resolution: {integrity: sha512-Jc360x4yqb3eEg4OY4KEIdGePBxZogivKI+OGIU8aLXgAYPTECvzeOBc90312yHA1hr3AeRlAFl0rIc8lQaIrQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/ingestion@1.50.0': + resolution: {integrity: sha512-OS3/Viao+NPpyBbEY3tf6hLewppG+UclD+9i0ju56mq2DrdMJFCkEky6Sk9S5VPcbLzxzg3BqBX6u9Q35w19aQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/monitoring@1.50.0': + resolution: {integrity: sha512-/znwgSiGufpbJVIoDmeQaHtTq+OMdDawFRbMSJVv+12n79hW+qdQXS8/Uu3BD3yn0BzgVFJEvrsHrCsInZKdhw==} + engines: {node: '>= 14.0.0'} + + '@algolia/recommend@5.50.0': + resolution: {integrity: sha512-dHjUfu4jfjdQiKDpCpAnM7LP5yfG0oNShtfpF5rMCel6/4HIoqJ4DC4h5GKDzgrvJYtgAhblo0AYBmOM00T+lQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-browser-xhr@5.50.0': + resolution: {integrity: sha512-bffIbUljAWnh/Ctu5uScORajuUavqmZ0ACYd1fQQeSSYA9NNN83ynO26pSc2dZRXpSK0fkc1//qSSFXMKGu+aw==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-fetch@5.50.0': + resolution: {integrity: sha512-y0EwNvPGvkM+yTAqqO6Gpt9wVGm3CLDtpLvNEiB3VGvN3WzfkjZGtLUsG/ru2kVJIIU7QcV0puuYgEpBeFxcJg==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-node-http@5.50.0': + resolution: {integrity: sha512-xpwefe4fCOWnZgXCbkGpqQY6jgBSCf2hmgnySbyzZIccrv3SoashHKGPE4x6vVG+gdHrGciMTAcDo9HOZwH22Q==} + engines: {node: '>= 14.0.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@docsearch/css@3.8.2': + resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} + + '@docsearch/js@3.8.2': + resolution: {integrity: sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==} + + '@docsearch/react@3.8.2': + resolution: {integrity: sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==} + peerDependencies: + '@types/react': '>= 16.8.0 < 19.0.0' + react: '>= 16.8.0 < 19.0.0' + react-dom: '>= 16.8.0 < 19.0.0' + search-insights: '>= 1 < 3' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + react-dom: + optional: true + search-insights: + optional: true + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@iconify-json/simple-icons@1.2.76': + resolution: {integrity: sha512-lLRlA8yaf+1L5VCPRvR9lynoSklsddKHEylchmZJKdj/q2xVQ1ZAEJ8SCQlv9cbgtMefnlyM98U+8Si2aoFZPA==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@rollup/rollup-android-arm-eabi@4.60.1': + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.1': + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.1': + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.1': + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.1': + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.1': + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.1': + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.1': + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.1': + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.1': + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.1': + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.1': + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} + cpu: [x64] + os: [win32] + + '@shikijs/core@2.5.0': + resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} + + '@shikijs/engine-javascript@2.5.0': + resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==} + + '@shikijs/engine-oniguruma@2.5.0': + resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==} + + '@shikijs/langs@2.5.0': + resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==} + + '@shikijs/themes@2.5.0': + resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==} + + '@shikijs/transformers@2.5.0': + resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==} + + '@shikijs/types@2.5.0': + resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@vue/compiler-core@3.5.31': + resolution: {integrity: sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ==} + + '@vue/compiler-dom@3.5.31': + resolution: {integrity: sha512-BMY/ozS/xxjYqRFL+tKdRpATJYDTTgWSo0+AJvJNg4ig+Hgb0dOsHPXvloHQ5hmlivUqw1Yt2pPIqp4e0v1GUw==} + + '@vue/compiler-sfc@3.5.31': + resolution: {integrity: sha512-M8wpPgR9UJ8MiRGjppvx9uWJfLV7A/T+/rL8s/y3QG3u0c2/YZgff3d6SuimKRIhcYnWg5fTfDMlz2E6seUW8Q==} + + '@vue/compiler-ssr@3.5.31': + resolution: {integrity: sha512-h0xIMxrt/LHOvJKMri+vdYT92BrK3HFLtDqq9Pr/lVVfE4IyKZKvWf0vJFW10Yr6nX02OR4MkJwI0c1HDa1hog==} + + '@vue/devtools-api@7.7.9': + resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==} + + '@vue/devtools-kit@7.7.9': + resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==} + + '@vue/devtools-shared@7.7.9': + resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==} + + '@vue/reactivity@3.5.31': + resolution: {integrity: sha512-DtKXxk9E/KuVvt8VxWu+6Luc9I9ETNcqR1T1oW1gf02nXaZ1kuAx58oVu7uX9XxJR0iJCro6fqBLw9oSBELo5g==} + + '@vue/runtime-core@3.5.31': + resolution: {integrity: sha512-AZPmIHXEAyhpkmN7aWlqjSfYynmkWlluDNPHMCZKFHH+lLtxP/30UJmoVhXmbDoP1Ng0jG0fyY2zCj1PnSSA6Q==} + + '@vue/runtime-dom@3.5.31': + resolution: {integrity: sha512-xQJsNRmGPeDCJq/u813tyonNgWBFjzfVkBwDREdEWndBnGdHLHgkwNBQxLtg4zDrzKTEcnikUy1UUNecb3lJ6g==} + + '@vue/server-renderer@3.5.31': + resolution: {integrity: sha512-GJuwRvMcdZX/CriUnyIIOGkx3rMV3H6sOu0JhdKbduaeCji6zb60iOGMY7tFoN24NfsUYoFBhshZtGxGpxO4iA==} + peerDependencies: + vue: 3.5.31 + + '@vue/shared@3.5.31': + resolution: {integrity: sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw==} + + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + + '@vueuse/integrations@12.8.2': + resolution: {integrity: sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + + algoliasearch@5.50.0: + resolution: {integrity: sha512-yE5I83Q2s8euVou8Y3feXK08wyZInJWLYXgWO6Xti9jBUEZAGUahyeQ7wSZWkifLWVnQVKEz5RAmBlXG5nqxog==} + engines: {node: '>= 14.0.0'} + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + focus-trap@7.8.0: + resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + oniguruma-to-es@3.1.1: + resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + preact@10.29.0: + resolution: {integrity: sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==} + + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rollup@4.60.1: + resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + search-insights@2.17.3: + resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + + shiki@2.5.0: + resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitepress@1.6.4: + resolution: {integrity: sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==} + hasBin: true + peerDependencies: + markdown-it-mathjax3: ^4 + postcss: ^8 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + postcss: + optional: true + + vue@3.5.31: + resolution: {integrity: sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@algolia/abtesting@1.16.0': + dependencies: + '@algolia/client-common': 5.50.0 + '@algolia/requester-browser-xhr': 5.50.0 + '@algolia/requester-fetch': 5.50.0 + '@algolia/requester-node-http': 5.50.0 + + '@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.50.0)(algoliasearch@5.50.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.50.0)(algoliasearch@5.50.0)(search-insights@2.17.3) + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.50.0)(algoliasearch@5.50.0) + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + - search-insights + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.50.0)(algoliasearch@5.50.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.50.0)(algoliasearch@5.50.0) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + + '@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.50.0)(algoliasearch@5.50.0)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.50.0)(algoliasearch@5.50.0) + '@algolia/client-search': 5.50.0 + algoliasearch: 5.50.0 + + '@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.50.0)(algoliasearch@5.50.0)': + dependencies: + '@algolia/client-search': 5.50.0 + algoliasearch: 5.50.0 + + '@algolia/client-abtesting@5.50.0': + dependencies: + '@algolia/client-common': 5.50.0 + '@algolia/requester-browser-xhr': 5.50.0 + '@algolia/requester-fetch': 5.50.0 + '@algolia/requester-node-http': 5.50.0 + + '@algolia/client-analytics@5.50.0': + dependencies: + '@algolia/client-common': 5.50.0 + '@algolia/requester-browser-xhr': 5.50.0 + '@algolia/requester-fetch': 5.50.0 + '@algolia/requester-node-http': 5.50.0 + + '@algolia/client-common@5.50.0': {} + + '@algolia/client-insights@5.50.0': + dependencies: + '@algolia/client-common': 5.50.0 + '@algolia/requester-browser-xhr': 5.50.0 + '@algolia/requester-fetch': 5.50.0 + '@algolia/requester-node-http': 5.50.0 + + '@algolia/client-personalization@5.50.0': + dependencies: + '@algolia/client-common': 5.50.0 + '@algolia/requester-browser-xhr': 5.50.0 + '@algolia/requester-fetch': 5.50.0 + '@algolia/requester-node-http': 5.50.0 + + '@algolia/client-query-suggestions@5.50.0': + dependencies: + '@algolia/client-common': 5.50.0 + '@algolia/requester-browser-xhr': 5.50.0 + '@algolia/requester-fetch': 5.50.0 + '@algolia/requester-node-http': 5.50.0 + + '@algolia/client-search@5.50.0': + dependencies: + '@algolia/client-common': 5.50.0 + '@algolia/requester-browser-xhr': 5.50.0 + '@algolia/requester-fetch': 5.50.0 + '@algolia/requester-node-http': 5.50.0 + + '@algolia/ingestion@1.50.0': + dependencies: + '@algolia/client-common': 5.50.0 + '@algolia/requester-browser-xhr': 5.50.0 + '@algolia/requester-fetch': 5.50.0 + '@algolia/requester-node-http': 5.50.0 + + '@algolia/monitoring@1.50.0': + dependencies: + '@algolia/client-common': 5.50.0 + '@algolia/requester-browser-xhr': 5.50.0 + '@algolia/requester-fetch': 5.50.0 + '@algolia/requester-node-http': 5.50.0 + + '@algolia/recommend@5.50.0': + dependencies: + '@algolia/client-common': 5.50.0 + '@algolia/requester-browser-xhr': 5.50.0 + '@algolia/requester-fetch': 5.50.0 + '@algolia/requester-node-http': 5.50.0 + + '@algolia/requester-browser-xhr@5.50.0': + dependencies: + '@algolia/client-common': 5.50.0 + + '@algolia/requester-fetch@5.50.0': + dependencies: + '@algolia/client-common': 5.50.0 + + '@algolia/requester-node-http@5.50.0': + dependencies: + '@algolia/client-common': 5.50.0 + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@docsearch/css@3.8.2': {} + + '@docsearch/js@3.8.2(@algolia/client-search@5.50.0)(search-insights@2.17.3)': + dependencies: + '@docsearch/react': 3.8.2(@algolia/client-search@5.50.0)(search-insights@2.17.3) + preact: 10.29.0 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/react' + - react + - react-dom + - search-insights + + '@docsearch/react@3.8.2(@algolia/client-search@5.50.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.50.0)(algoliasearch@5.50.0)(search-insights@2.17.3) + '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.50.0)(algoliasearch@5.50.0) + '@docsearch/css': 3.8.2 + algoliasearch: 5.50.0 + optionalDependencies: + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@iconify-json/simple-icons@1.2.76': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@rollup/rollup-android-arm-eabi@4.60.1': + optional: true + + '@rollup/rollup-android-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-x64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.1': + optional: true + + '@shikijs/core@2.5.0': + dependencies: + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 3.1.1 + + '@shikijs/engine-oniguruma@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/themes@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/transformers@2.5.0': + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/types': 2.5.0 + + '@shikijs/types@2.5.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdurl@2.0.0': {} + + '@types/unist@3.0.3': {} + + '@types/web-bluetooth@0.0.21': {} + + '@ungap/structured-clone@1.3.0': {} + + '@vitejs/plugin-vue@5.2.4(vite@5.4.21)(vue@3.5.31)': + dependencies: + vite: 5.4.21 + vue: 3.5.31 + + '@vue/compiler-core@3.5.31': + dependencies: + '@babel/parser': 7.29.2 + '@vue/shared': 3.5.31 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.31': + dependencies: + '@vue/compiler-core': 3.5.31 + '@vue/shared': 3.5.31 + + '@vue/compiler-sfc@3.5.31': + dependencies: + '@babel/parser': 7.29.2 + '@vue/compiler-core': 3.5.31 + '@vue/compiler-dom': 3.5.31 + '@vue/compiler-ssr': 3.5.31 + '@vue/shared': 3.5.31 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.8 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.31': + dependencies: + '@vue/compiler-dom': 3.5.31 + '@vue/shared': 3.5.31 + + '@vue/devtools-api@7.7.9': + dependencies: + '@vue/devtools-kit': 7.7.9 + + '@vue/devtools-kit@7.7.9': + dependencies: + '@vue/devtools-shared': 7.7.9 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.9': + dependencies: + rfdc: 1.4.1 + + '@vue/reactivity@3.5.31': + dependencies: + '@vue/shared': 3.5.31 + + '@vue/runtime-core@3.5.31': + dependencies: + '@vue/reactivity': 3.5.31 + '@vue/shared': 3.5.31 + + '@vue/runtime-dom@3.5.31': + dependencies: + '@vue/reactivity': 3.5.31 + '@vue/runtime-core': 3.5.31 + '@vue/shared': 3.5.31 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.31(vue@3.5.31)': + dependencies: + '@vue/compiler-ssr': 3.5.31 + '@vue/shared': 3.5.31 + vue: 3.5.31 + + '@vue/shared@3.5.31': {} + + '@vueuse/core@12.8.2': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2 + vue: 3.5.31 + transitivePeerDependencies: + - typescript + + '@vueuse/integrations@12.8.2(focus-trap@7.8.0)': + dependencies: + '@vueuse/core': 12.8.2 + '@vueuse/shared': 12.8.2 + vue: 3.5.31 + optionalDependencies: + focus-trap: 7.8.0 + transitivePeerDependencies: + - typescript + + '@vueuse/metadata@12.8.2': {} + + '@vueuse/shared@12.8.2': + dependencies: + vue: 3.5.31 + transitivePeerDependencies: + - typescript + + algoliasearch@5.50.0: + dependencies: + '@algolia/abtesting': 1.16.0 + '@algolia/client-abtesting': 5.50.0 + '@algolia/client-analytics': 5.50.0 + '@algolia/client-common': 5.50.0 + '@algolia/client-insights': 5.50.0 + '@algolia/client-personalization': 5.50.0 + '@algolia/client-query-suggestions': 5.50.0 + '@algolia/client-search': 5.50.0 + '@algolia/ingestion': 1.50.0 + '@algolia/monitoring': 1.50.0 + '@algolia/recommend': 5.50.0 + '@algolia/requester-browser-xhr': 5.50.0 + '@algolia/requester-fetch': 5.50.0 + '@algolia/requester-node-http': 5.50.0 + + birpc@2.9.0: {} + + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + comma-separated-tokens@2.0.3: {} + + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + + csstype@3.2.3: {} + + dequal@2.0.3: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + emoji-regex-xs@1.0.0: {} + + entities@7.0.1: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + estree-walker@2.0.2: {} + + focus-trap@7.8.0: + dependencies: + tabbable: 6.4.0 + + fsevents@2.3.3: + optional: true + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hookable@5.5.3: {} + + html-void-elements@3.0.0: {} + + is-what@5.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mark.js@8.11.1: {} + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + minisearch@7.2.0: {} + + mitt@3.0.1: {} + + nanoid@3.3.11: {} + + oniguruma-to-es@3.1.1: + dependencies: + emoji-regex-xs: 1.0.0 + regex: 6.1.0 + regex-recursion: 6.0.2 + + perfect-debounce@1.0.0: {} + + picocolors@1.1.1: {} + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + preact@10.29.0: {} + + property-information@7.1.0: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + rfdc@1.4.1: {} + + rollup@4.60.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.1 + '@rollup/rollup-android-arm64': 4.60.1 + '@rollup/rollup-darwin-arm64': 4.60.1 + '@rollup/rollup-darwin-x64': 4.60.1 + '@rollup/rollup-freebsd-arm64': 4.60.1 + '@rollup/rollup-freebsd-x64': 4.60.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 + '@rollup/rollup-linux-arm-musleabihf': 4.60.1 + '@rollup/rollup-linux-arm64-gnu': 4.60.1 + '@rollup/rollup-linux-arm64-musl': 4.60.1 + '@rollup/rollup-linux-loong64-gnu': 4.60.1 + '@rollup/rollup-linux-loong64-musl': 4.60.1 + '@rollup/rollup-linux-ppc64-gnu': 4.60.1 + '@rollup/rollup-linux-ppc64-musl': 4.60.1 + '@rollup/rollup-linux-riscv64-gnu': 4.60.1 + '@rollup/rollup-linux-riscv64-musl': 4.60.1 + '@rollup/rollup-linux-s390x-gnu': 4.60.1 + '@rollup/rollup-linux-x64-gnu': 4.60.1 + '@rollup/rollup-linux-x64-musl': 4.60.1 + '@rollup/rollup-openbsd-x64': 4.60.1 + '@rollup/rollup-openharmony-arm64': 4.60.1 + '@rollup/rollup-win32-arm64-msvc': 4.60.1 + '@rollup/rollup-win32-ia32-msvc': 4.60.1 + '@rollup/rollup-win32-x64-gnu': 4.60.1 + '@rollup/rollup-win32-x64-msvc': 4.60.1 + fsevents: 2.3.3 + + search-insights@2.17.3: {} + + shiki@2.5.0: + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/langs': 2.5.0 + '@shikijs/themes': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + source-map-js@1.2.1: {} + + space-separated-tokens@2.0.2: {} + + speakingurl@14.0.1: {} + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + + tabbable@6.4.0: {} + + trim-lines@3.0.1: {} + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@5.4.21: + dependencies: + esbuild: 0.21.5 + postcss: 8.5.8 + rollup: 4.60.1 + optionalDependencies: + fsevents: 2.3.3 + + vitepress@1.6.4(@algolia/client-search@5.50.0)(postcss@8.5.8)(search-insights@2.17.3): + dependencies: + '@docsearch/css': 3.8.2 + '@docsearch/js': 3.8.2(@algolia/client-search@5.50.0)(search-insights@2.17.3) + '@iconify-json/simple-icons': 1.2.76 + '@shikijs/core': 2.5.0 + '@shikijs/transformers': 2.5.0 + '@shikijs/types': 2.5.0 + '@types/markdown-it': 14.1.2 + '@vitejs/plugin-vue': 5.2.4(vite@5.4.21)(vue@3.5.31) + '@vue/devtools-api': 7.7.9 + '@vue/shared': 3.5.31 + '@vueuse/core': 12.8.2 + '@vueuse/integrations': 12.8.2(focus-trap@7.8.0) + focus-trap: 7.8.0 + mark.js: 8.11.1 + minisearch: 7.2.0 + shiki: 2.5.0 + vite: 5.4.21 + vue: 3.5.31 + optionalDependencies: + postcss: 8.5.8 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/node' + - '@types/react' + - async-validator + - axios + - change-case + - drauu + - fuse.js + - idb-keyval + - jwt-decode + - less + - lightningcss + - nprogress + - qrcode + - react + - react-dom + - sass + - sass-embedded + - search-insights + - sortablejs + - stylus + - sugarss + - terser + - typescript + - universal-cookie + + vue@3.5.31: + dependencies: + '@vue/compiler-dom': 3.5.31 + '@vue/compiler-sfc': 3.5.31 + '@vue/runtime-dom': 3.5.31 + '@vue/server-renderer': 3.5.31(vue@3.5.31) + '@vue/shared': 3.5.31 + + zwitch@2.0.4: {} diff --git a/docs/pnpm-workspace.yaml b/docs/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/docs/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md new file mode 100644 index 0000000..e410aaa --- /dev/null +++ b/docs/reference/configuration.md @@ -0,0 +1,52 @@ +# 配置参考 + +## 命令行参数 + +| 参数 | 说明 | +|------|------| +| `--dry-run`, `-n` | 模拟运行,只检查依赖和显示操作,不实际执行 | +| `-y`, `--yes` | 跳过交互式确认 | +| `--help`, `-h` | 显示帮助信息 | +| `--config ` | 指定配置文件路径(默认为程序同目录的 `.env`) | +| `--version` | 显示版本信息 | + +## 配置加载顺序 + +配置按以下顺序生效: + +1. 当前进程中已存在的环境变量 +2. `--config` 指定文件中的配置;如果未指定,则尝试读取程序同目录的 `.env` +3. 代码中的内置默认值 + +## 环境变量 + +| 变量 | 默认值 | 说明 | 必填 | +|------|--------|------|------| +| `BASE_DIR` | - | Docker Compose 项目目录 | 是 | +| `EXPECTED_REMOTE` | - | Kopia 远程路径 | 是 | +| `KOPIA_PASSWORD` | - | Kopia 远程仓库密码 | 否 | +| `KOPIA_CONFIG_FILE` | - | Kopia 配置文件路径(可选,用于多用户场景) | 否 | +| `RCLONE_CONFIG` | - | Rclone 配置文件路径(可选,用于多用户场景) | 否 | +| `PRIORITY_SERVICES_LIST` | `caddy nginx gateway` | 优先服务列表(空格分隔) | 否 | +| `LOCK_FILE` | `/tmp/backup_maintenance.lock` | 锁文件路径 | 否 | +| `LOG_FILE` | 无(不输出日志文件) | 日志文件路径,留空则不写入文件 | 否 | +| `DOCKER_COMMAND_TIMEOUT_SECONDS` | `120` | Docker 命令超时时间(秒) | 否 | +| `DEVICE_NAME` | - | 设备名称,用于区分不同服务器的通知 | 否 | +| `APPRISE_URL` | - | Apprise 服务地址 | 否 | +| `APPRISE_NOTIFY_URL` | - | 通知目标 URL | 否 | +| `GIST_TOKEN` | - | GitHub Personal Access Token(需要 gist 权限) | 否 | +| `GIST_ID` | - | GitHub Gist ID(日志上传目标) | 否 | +| `GIST_LOG_PREFIX` | `yewresin-backup` | Gist 日志文件名前缀 | 否 | +| `GIST_MAX_LOGS` | `30` | Gist 最大保留日志数量(设为 0 禁用清理) | 否 | +| `GIST_KEEP_FIRST_FILE` | `true` | 清理时保留第一个文件(用于自定义 Gist 标题) | 否 | + +## 配置说明 + +如果必填项(如 `BASE_DIR` 和 `EXPECTED_REMOTE`)最终未设置,程序会直接报错并退出。部分可选项如果未设置会回退到内置默认值。 + +## 日志持久化说明 + +- `LOG_FILE` 未配置时,日志只输出到标准输出,不会由程序写入任何日志文件 +- `LOG_FILE` 配置后,日志会同时输出到标准输出和该文件 +- 程序内部会临时缓存本次运行的日志内容,但这部分缓存仅存在于进程内存中,进程结束后不会保留 +- 如果你依赖 `systemd`、Docker、任务计划或其他外部工具采集标准输出,这部分持久化行为由外部运行环境负责 diff --git a/go/go.mod b/go.mod similarity index 88% rename from go/go.mod rename to go.mod index afbbfc9..619abab 100644 --- a/go/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ module github.com/YewFence/yewresin -go 1.23 +go 1.25.8 require github.com/joho/godotenv v1.5.1 diff --git a/go/go.sum b/go.sum similarity index 100% rename from go/go.sum rename to go.sum diff --git a/go/.gitignore b/go/.gitignore deleted file mode 100644 index a15f154..0000000 --- a/go/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -*.exe -dist/ - -yewresin \ No newline at end of file diff --git a/go/Makefile b/go/Makefile deleted file mode 100644 index 122837b..0000000 --- a/go/Makefile +++ /dev/null @@ -1,100 +0,0 @@ -# YewResin Go 版本构建脚本 -# 支持多平台交叉编译 - -BINARY := yewresin -DIST_DIR := dist - -# 版本号:优先使用 VERSION 环境变量,否则尝试 git tag,最后使用时间戳 -VERSION ?= $(shell git describe --tags --always 2>/dev/null || date -u +"%Y%m%d.%H%M%S") - -# 构建标志 -LDFLAGS := -s -w -X main.version=$(VERSION) -GO_BUILD := CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" - -# 目标平台 -PLATFORMS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 - -.PHONY: build all linux darwin windows clean test help - -# 默认目标:构建当前平台 -build: - @echo "Building $(BINARY) $(VERSION) for current platform..." - $(GO_BUILD) -o $(BINARY) . - @echo "Done: $(BINARY)" - -# 构建所有平台 -all: clean - @echo "Building $(BINARY) $(VERSION) for all platforms..." - @mkdir -p $(DIST_DIR) - @$(MAKE) --no-print-directory linux - @$(MAKE) --no-print-directory darwin - @$(MAKE) --no-print-directory windows - @echo "" - @echo "All builds completed:" - @ls -lh $(DIST_DIR)/ - -# Linux 平台 -linux: $(DIST_DIR)/$(BINARY)-linux-amd64 $(DIST_DIR)/$(BINARY)-linux-arm64 - -$(DIST_DIR)/$(BINARY)-linux-amd64: - @mkdir -p $(DIST_DIR) - @echo " Building linux/amd64..." - @GOOS=linux GOARCH=amd64 $(GO_BUILD) -o $@ . - -$(DIST_DIR)/$(BINARY)-linux-arm64: - @mkdir -p $(DIST_DIR) - @echo " Building linux/arm64..." - @GOOS=linux GOARCH=arm64 $(GO_BUILD) -o $@ . - -# macOS 平台 -darwin: $(DIST_DIR)/$(BINARY)-darwin-amd64 $(DIST_DIR)/$(BINARY)-darwin-arm64 - -$(DIST_DIR)/$(BINARY)-darwin-amd64: - @mkdir -p $(DIST_DIR) - @echo " Building darwin/amd64..." - @GOOS=darwin GOARCH=amd64 $(GO_BUILD) -o $@ . - -$(DIST_DIR)/$(BINARY)-darwin-arm64: - @mkdir -p $(DIST_DIR) - @echo " Building darwin/arm64..." - @GOOS=darwin GOARCH=arm64 $(GO_BUILD) -o $@ . - -# Windows 平台 -windows: $(DIST_DIR)/$(BINARY)-windows-amd64.exe - -$(DIST_DIR)/$(BINARY)-windows-amd64.exe: - @mkdir -p $(DIST_DIR) - @echo " Building windows/amd64..." - @GOOS=windows GOARCH=amd64 $(GO_BUILD) -o $@ . - -# 清理构建产物 -clean: - @echo "Cleaning..." - @rm -rf $(DIST_DIR) - @rm -f $(BINARY) $(BINARY).exe - @echo "Done" - -# 运行测试 -test: - @echo "Running tests..." - @go test -v ./... - -help: - @echo "YewResin Go 版本构建脚本" - @echo "" - @echo "用法:" - @echo " make - 构建当前平台" - @echo " make all - 构建所有平台" - @echo " make linux - 构建 Linux (amd64, arm64)" - @echo " make darwin - 构建 macOS (amd64, arm64)" - @echo " make windows - 构建 Windows (amd64)" - @echo " make test - 运行测试" - @echo " make clean - 清理构建产物" - @echo " make help - 显示帮助信息" - @echo "" - @echo "环境变量:" - @echo " VERSION - 指定版本号 (默认: git tag 或时间戳)" - @echo "" - @echo "示例:" - @echo " make all # 构建所有平台" - @echo " VERSION=v2.0.0 make all # 指定版本构建" diff --git a/go/backup.go b/internal/yewresin/backup.go similarity index 99% rename from go/backup.go rename to internal/yewresin/backup.go index 34f1949..b0606f8 100644 --- a/go/backup.go +++ b/internal/yewresin/backup.go @@ -1,4 +1,4 @@ -package main +package yewresin import ( "encoding/json" diff --git a/go/config.go b/internal/yewresin/config.go similarity index 99% rename from go/config.go rename to internal/yewresin/config.go index e8ec6a4..8b88b1b 100644 --- a/go/config.go +++ b/internal/yewresin/config.go @@ -1,4 +1,4 @@ -package main +package yewresin import ( "fmt" diff --git a/go/config_test.go b/internal/yewresin/config_test.go similarity index 99% rename from go/config_test.go rename to internal/yewresin/config_test.go index bcf7c85..760e4a0 100644 --- a/go/config_test.go +++ b/internal/yewresin/config_test.go @@ -1,4 +1,4 @@ -package main +package yewresin import ( "os" diff --git a/go/docker.go b/internal/yewresin/docker.go similarity index 99% rename from go/docker.go rename to internal/yewresin/docker.go index a9c2665..29af7f2 100644 --- a/go/docker.go +++ b/internal/yewresin/docker.go @@ -1,4 +1,4 @@ -package main +package yewresin import ( "bytes" diff --git a/go/docker_test.go b/internal/yewresin/docker_test.go similarity index 50% rename from go/docker_test.go rename to internal/yewresin/docker_test.go index e45b906..63de18d 100644 --- a/go/docker_test.go +++ b/internal/yewresin/docker_test.go @@ -1,9 +1,11 @@ -package main +package yewresin import ( + "errors" "os" "path/filepath" "runtime" + "strings" "testing" "time" ) @@ -145,3 +147,132 @@ func TestStopStartMissingMethod(t *testing.T) { t.Fatalf("expected start missing method error") } } + +func TestServiceError(t *testing.T) { + inner := errors.New("connection refused") + se := &ServiceError{Service: "api", Err: inner} + + if !strings.Contains(se.Error(), "api") || !strings.Contains(se.Error(), "connection refused") { + t.Fatalf("Error() should contain service name and inner error, got: %q", se.Error()) + } + if unwrapped := se.Unwrap(); unwrapped != inner { + t.Fatalf("Unwrap() should return inner error") + } +} + +func TestNewDockerManagerDefaultTimeout(t *testing.T) { + dm := NewDockerManager("/tmp", false, 0) + if dm.commandTimeout != 120*time.Second { + t.Fatalf("default timeout should be 120s, got %v", dm.commandTimeout) + } + + dm2 := NewDockerManager("/tmp", false, -5*time.Second) + if dm2.commandTimeout != 120*time.Second { + t.Fatalf("negative timeout should default to 120s, got %v", dm2.commandTimeout) + } +} + +func TestStopStartNotRunning(t *testing.T) { + baseDir := t.TempDir() + dm := NewDockerManager(baseDir, false, time.Second) + svc := &Service{Name: "idle", Path: baseDir, Running: false} + + // 未运行的服务应跳过,不报错 + if err := dm.Stop(svc); err != nil { + t.Fatalf("Stop not-running service should not error, got: %v", err) + } + if err := dm.Start(svc); err != nil { + t.Fatalf("Start not-running service should not error, got: %v", err) + } +} + +func TestStopParallelDryRun(t *testing.T) { + baseDir := t.TempDir() + svcDir := filepath.Join(baseDir, "svc") + if err := os.MkdirAll(svcDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(svcDir, "compose.yaml"), []byte("services:\n"), 0o600); err != nil { + t.Fatalf("write compose: %v", err) + } + + dm := NewDockerManager(baseDir, true, time.Second) + services := []*Service{ + {Name: "svc1", Path: svcDir, Running: true}, + {Name: "svc2", Path: svcDir, Running: true}, + } + + errs := dm.StopParallel(services) + if len(errs) != 0 { + t.Fatalf("dry-run StopParallel should have no errors, got %v", errs) + } +} + +func TestStartParallelDryRun(t *testing.T) { + baseDir := t.TempDir() + svcDir := filepath.Join(baseDir, "svc") + if err := os.MkdirAll(svcDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(svcDir, "compose.yaml"), []byte("services:\n"), 0o600); err != nil { + t.Fatalf("write compose: %v", err) + } + + dm := NewDockerManager(baseDir, true, time.Second) + services := []*Service{ + {Name: "svc1", Path: svcDir, Running: true}, + {Name: "svc2", Path: svcDir, Running: true}, + } + + errs := dm.StartParallel(services) + if len(errs) != 0 { + t.Fatalf("dry-run StartParallel should have no errors, got %v", errs) + } +} + +func TestParallelEmpty(t *testing.T) { + baseDir := t.TempDir() + dm := NewDockerManager(baseDir, true, time.Second) + + if errs := dm.StopParallel(nil); errs != nil { + t.Fatalf("StopParallel(nil) should return nil, got %v", errs) + } + if errs := dm.StartParallel(nil); errs != nil { + t.Fatalf("StartParallel(nil) should return nil, got %v", errs) + } +} + +func TestParallelMissingMethod(t *testing.T) { + baseDir := t.TempDir() + svcDir := filepath.Join(baseDir, "svc") + if err := os.MkdirAll(svcDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + dm := NewDockerManager(baseDir, true, time.Second) + services := []*Service{ + {Name: "svc", Path: svcDir, Running: true}, + } + + errs := dm.StopParallel(services) + if len(errs) != 1 { + t.Fatalf("expected 1 error, got %d", len(errs)) + } + + errs = dm.StartParallel(services) + if len(errs) != 1 { + t.Fatalf("expected 1 error, got %d", len(errs)) + } +} + +func TestClassifyServicesEmpty(t *testing.T) { + priority, normal := ClassifyServices(nil, []string{"db"}) + if len(priority) != 0 || len(normal) != 0 { + t.Fatalf("empty input: priority=%d normal=%d", len(priority), len(normal)) + } + + priority, normal = ClassifyServices([]*Service{{Name: "a"}}, nil) + if len(priority) != 0 || len(normal) != 1 { + t.Fatalf("nil priority names: priority=%d normal=%d", len(priority), len(normal)) + } +} diff --git a/go/gist.go b/internal/yewresin/gist.go similarity index 99% rename from go/gist.go rename to internal/yewresin/gist.go index 64b922a..94e3dd5 100644 --- a/go/gist.go +++ b/internal/yewresin/gist.go @@ -1,4 +1,4 @@ -package main +package yewresin import ( "bytes" diff --git a/go/logger.go b/internal/yewresin/logger.go similarity index 98% rename from go/logger.go rename to internal/yewresin/logger.go index 416cc91..56031c2 100644 --- a/go/logger.go +++ b/internal/yewresin/logger.go @@ -1,4 +1,4 @@ -package main +package yewresin import ( "io" diff --git a/go/logger_test.go b/internal/yewresin/logger_test.go similarity index 52% rename from go/logger_test.go rename to internal/yewresin/logger_test.go index 243fe90..cedfc91 100644 --- a/go/logger_test.go +++ b/internal/yewresin/logger_test.go @@ -1,6 +1,7 @@ -package main +package yewresin import ( + "bytes" "log/slog" "os" "path/filepath" @@ -8,6 +9,44 @@ import ( "testing" ) +func TestNewLogCapture(t *testing.T) { + var buf bytes.Buffer + lc := NewLogCapture(&buf) + + if lc == nil { + t.Fatalf("NewLogCapture should not return nil") + } + if len(lc.writers) != 1 { + t.Fatalf("expected 1 writer, got %d", len(lc.writers)) + } +} + +func TestLogCaptureWriteGetContent(t *testing.T) { + lc := NewLogCapture() + + n, err := lc.Write([]byte("hello ")) + if err != nil || n != 6 { + t.Fatalf("Write: n=%d err=%v", n, err) + } + lc.Write([]byte("world")) + + content := lc.GetContent() + if content != "hello world" { + t.Fatalf("GetContent: got %q, want %q", content, "hello world") + } +} + +func TestLogCaptureMultiWriter(t *testing.T) { + var buf1, buf2 bytes.Buffer + lc := NewLogCapture(&buf1, &buf2) + + lc.Write([]byte("multi")) + + if buf1.String() != "multi" || buf2.String() != "multi" { + t.Fatalf("writers should receive written data: buf1=%q buf2=%q", buf1.String(), buf2.String()) + } +} + func TestInitLoggerNoFile(t *testing.T) { file, err := InitLogger("") if err != nil { diff --git a/go/notify.go b/internal/yewresin/notify.go similarity index 99% rename from go/notify.go rename to internal/yewresin/notify.go index 6cd4631..28edf7d 100644 --- a/go/notify.go +++ b/internal/yewresin/notify.go @@ -1,4 +1,4 @@ -package main +package yewresin import ( "bytes" diff --git a/go/orchestrator.go b/internal/yewresin/orchestrator.go similarity index 79% rename from go/orchestrator.go rename to internal/yewresin/orchestrator.go index 3cd8077..47f47d9 100644 --- a/go/orchestrator.go +++ b/internal/yewresin/orchestrator.go @@ -1,4 +1,4 @@ -package main +package yewresin import ( "errors" @@ -80,20 +80,49 @@ func (o *Orchestrator) CheckDependencies() error { } // Run 执行备份流程 -func (o *Orchestrator) Run() error { +func (o *Orchestrator) Run() (finalErr error) { o.startTime = time.Now().UTC() - defer o.notifier.Wait() + + var stopErrMsg string + + // defer 确保日志上传和通知总是执行 + defer func() { + // 上传日志到 Gist + success := finalErr == nil + duration := time.Since(o.startTime) + if LogWriter != nil { + if err := o.gist.Upload(LogWriter.GetContent(), success, o.startTime, duration); err != nil { + slog.Warn("上传日志到 Gist 失败", "error", err) + } + } + + // 统一发送最终通知 + switch { + case stopErrMsg != "": + o.notifier.Send("❌ 备份中止", stopErrMsg) + case finalErr != nil: + o.notifier.Send("❌ 备份失败", fmt.Sprintf("备份流程出错: %v,服务已尝试恢复", finalErr)) + case o.dryRun: + o.notifier.Send("🧪 DRY-RUN 完成", "模拟运行完成,未执行实际操作") + default: + o.notifier.Send("✅ 备份成功", "所有服务已恢复运行") + } + + o.notifier.Wait() + }() // 1. 获取锁 if err := o.acquireLock(); err != nil { - return err + finalErr = err + return finalErr } defer o.releaseLock() // 2. 发现并分类服务 services, err := o.docker.DiscoverServices() if err != nil { - return fmt.Errorf("发现服务失败: %w", err) + finalErr = fmt.Errorf("发现服务失败: %w", err) + return finalErr } o.priorityServices, o.normalServices = ClassifyServices(services, o.cfg.PriorityServices) @@ -113,17 +142,19 @@ func (o *Orchestrator) Run() error { for i, e := range errs { errMsgs[i] = e.Error() } - o.notifier.Send("❌ 备份中止", fmt.Sprintf("服务停止失败: %s", strings.Join(errMsgs, ", "))) + stopErrMsg = fmt.Sprintf("服务停止失败: %s", strings.Join(errMsgs, ", ")) o.startAllServices() - return fmt.Errorf("停止普通服务失败: %v", errs) + finalErr = fmt.Errorf("停止普通服务失败: %w", errors.Join(errs...)) + return finalErr } slog.Info(">>> 顺序停止优先服务(网关)...") for _, svc := range o.priorityServices { if err := o.docker.Stop(svc); err != nil { - o.notifier.Send("❌ 备份中止", fmt.Sprintf("服务 %s 停止失败", svc.Name)) + stopErrMsg = fmt.Sprintf("服务 %s 停止失败", svc.Name) o.startAllServices() - return fmt.Errorf("停止服务 %s 失败: %w", svc.Name, err) + finalErr = fmt.Errorf("停止服务 %s 失败: %w", svc.Name, err) + return finalErr } } @@ -134,25 +165,9 @@ func (o *Orchestrator) Run() error { // 6. 恢复服务(无论备份是否成功) o.startAllServices() - // 7. 上传日志到 Gist - success := backupErr == nil - duration := time.Since(o.startTime) - if LogWriter != nil { - if err := o.gist.Upload(LogWriter.GetContent(), success, o.startTime, duration); err != nil { - slog.Warn("上传日志到 Gist 失败", "error", err) - } - } - - // 8. 发送结果通知 if backupErr != nil { - o.notifier.Send("❌ 备份失败", "快照创建失败,服务已恢复") - return backupErr - } - - if o.dryRun { - o.notifier.Send("🧪 DRY-RUN 完成", "模拟运行完成,未执行实际操作") - } else { - o.notifier.Send("✅ 备份成功", "所有服务已恢复运行") + finalErr = backupErr + return finalErr } return nil diff --git a/go/orchestrator_test.go b/internal/yewresin/orchestrator_test.go similarity index 95% rename from go/orchestrator_test.go rename to internal/yewresin/orchestrator_test.go index b2fb9c8..4a81f09 100644 --- a/go/orchestrator_test.go +++ b/internal/yewresin/orchestrator_test.go @@ -1,4 +1,4 @@ -package main +package yewresin import ( "errors" @@ -232,9 +232,12 @@ func TestOrchestratorRunStopParallelError(t *testing.T) { if docker.startParallelCalled != 1 { t.Fatalf("expected StartParallel called once after failure, got %d", docker.startParallelCalled) } - // 早期失败不应上传日志 - if len(gist.uploads) != 0 { - t.Fatalf("expected no gist upload on stop failure") + // 即使停止失败也应上传日志,且 success 为 false + if len(gist.uploads) != 1 { + t.Fatalf("expected gist upload called once on stop failure, got %d", len(gist.uploads)) + } + if gist.uploads[0].success { + t.Fatalf("expected gist upload success flag false on stop failure") } } diff --git a/justfile b/justfile new file mode 100644 index 0000000..e3d36b1 --- /dev/null +++ b/justfile @@ -0,0 +1,47 @@ +# YewResin 构建脚本 +# 通过 Docker Compose 使用 GoReleaser 构建 + +# 默认目标:构建当前平台(不需要 goreleaser) +default: build + +# 快速构建当前平台(不用 Docker) +build: + @echo "Building yewresin for current platform..." + @CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=dev" -o yewresin . + @echo "Done: yewresin" + +# 使用 GoReleaser 构建所有平台的可执行文件(不发布) +release-snapshot: + docker compose -f compose.goreleaser.yaml run --rm goreleaser build --snapshot --clean + +# 使用 GoReleaser 模拟完整发布流程(不推送) +release-dry: + docker compose -f compose.goreleaser.yaml run --rm goreleaser release --snapshot --clean + +# 使用 GoReleaser 正式发布(需要 git tag + .env.goreleaser 配置) +release: + docker compose -f compose.goreleaser.yaml run --rm goreleaser release --clean + +# 运行测试 +test: + @echo "Running tests..." + go test -v ./... + +# 清理构建产物 +clean: + @echo "Cleaning..." + @rm -rf dist + @rm -f yewresin yewresin.exe + @echo "Done" + +# 显示帮助信息 +help: + @echo "YewResin 构建脚本" + @echo "" + @echo "用法:" + @echo " just - 快速构建当前平台" + @echo " just release-snapshot - 构建全平台可执行文件(不发布)" + @echo " just release-dry - 模拟完整发布流程(不推送)" + @echo " just release - 正式发布(需要 tag + 配置)" + @echo " just test - 运行测试" + @echo " just clean - 清理构建产物" diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..47424ab --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,7 @@ +min_version: 1.11.0 + +pre-commit: + commands: + pinact-check: + glob: ".github/workflows/*.{yml,yaml}" + run: bash .lefthook/pre-commit/pinact-check.sh diff --git a/go/main.go b/main.go similarity index 91% rename from go/main.go rename to main.go index 8499b46..bd3b24b 100644 --- a/go/main.go +++ b/main.go @@ -9,6 +9,8 @@ import ( "strings" "syscall" "time" + + "github.com/YewFence/yewresin/internal/yewresin" ) // 版本信息,构建时注入 @@ -26,7 +28,7 @@ func main() { showVersion := flag.Bool("version", false, "显示版本信息") flag.Usage = func() { - fmt.Fprintf(os.Stderr, "YewResin - Docker 服务备份工具 (Go 版本)\n\n") + fmt.Fprintf(os.Stderr, "YewResin - Docker 服务备份工具\n\n") fmt.Fprintf(os.Stderr, "用法: %s [选项]\n\n", os.Args[0]) fmt.Fprintf(os.Stderr, "选项:\n") flag.PrintDefaults() @@ -43,14 +45,14 @@ func main() { } // 加载配置(先加载配置以获取日志文件路径) - cfg, err := LoadConfig(*configFile) + cfg, err := yewresin.LoadConfig(*configFile) if err != nil { fmt.Fprintf(os.Stderr, "加载配置失败: %v\n", err) os.Exit(1) } // 初始化日志(支持文件输出) - logFile, err := InitLogger(cfg.LogFile) + logFile, err := yewresin.InitLogger(cfg.LogFile) if err != nil { fmt.Fprintf(os.Stderr, "初始化日志失败: %v\n", err) os.Exit(1) @@ -63,7 +65,7 @@ func main() { cfg.Print() // 创建备份编排器 - orch := NewOrchestrator(cfg, *dryRun) + orch := yewresin.NewOrchestrator(cfg, *dryRun) // 设置信号处理(Ctrl+C 等) sigChan := make(chan os.Signal, 1) diff --git a/go/main_test.go b/main_test.go similarity index 100% rename from go/main_test.go rename to main_test.go diff --git a/src/00-header.sh b/src/00-header.sh deleted file mode 100644 index 721678c..0000000 --- a/src/00-header.sh +++ /dev/null @@ -1,6 +0,0 @@ - -set -eo pipefail - -# ================= 记录开始时间 ================= -SCRIPT_START_TIME=$(date -u +%s) -SCRIPT_START_DATETIME=$(date -u '+%Y-%m-%d %H:%M:%S UTC') diff --git a/src/01-logging.sh b/src/01-logging.sh deleted file mode 100644 index eee4cb7..0000000 --- a/src/01-logging.sh +++ /dev/null @@ -1,17 +0,0 @@ - -# ================= 日志捕获 ================= -# 日志文件路径,默认为脚本同目录下的 yewresin.log -# 可通过 LOG_FILE 环境变量自定义 -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -LOG_FILE="${LOG_FILE:-$SCRIPT_DIR/yewresin.log}" - -# 每次运行清空日志文件(避免日志无限增长) -: > "$LOG_FILE" - -# 使用 tee 同时输出到终端和日志文件 -exec > >(tee -a "$LOG_FILE") -exec 2>&1 - -log() { - echo "[$(date -u '+%Y-%m-%d %H:%M:%S UTC')] $1" -} diff --git a/src/02-args.sh b/src/02-args.sh deleted file mode 100644 index 7f34f6b..0000000 --- a/src/02-args.sh +++ /dev/null @@ -1,43 +0,0 @@ -# shellcheck shell=bash -# ================= 命令行参数解析 ================= -DRY_RUN=false -SHOW_HELP=false -AUTO_CONFIRM=false - -while [[ $# -gt 0 ]]; do - case $1 in - --dry-run|-n) - DRY_RUN=true - shift - ;; - --help|-h) - SHOW_HELP=true - shift - ;; - -y|--yes) - AUTO_CONFIRM=true - shift - ;; - *) - echo "未知参数: $1" - echo "使用 --help 查看帮助" - exit 1 - ;; - esac -done - -if [ "$SHOW_HELP" = true ]; then - echo "欢迎使用 YewResin Docker 备份脚本 By YewFence" - echo "用法: $0 [选项]" - echo "" - echo "选项:" - echo " --dry-run, -n 模拟运行,只检查依赖和显示要执行的操作,不实际执行" - echo " -y, --yes 跳过交互式确认,自动确认执行" - echo " --help, -h 显示此帮助信息" - echo "" - echo "必要环境变量:" - echo " BASE_DIR Docker Compose 项目目录" - echo " EXPECTED_REMOTE Kopia 远程路径" - echo "更多说明请参考项目 README 文档 https://github.com/YewFence/YewResin/" - exit 0 -fi diff --git a/src/03-config.sh b/src/03-config.sh deleted file mode 100644 index 65fa420..0000000 --- a/src/03-config.sh +++ /dev/null @@ -1,124 +0,0 @@ - -# ================= 配置加载 ================= -# 加载环境变量配置文件(可选) -# 支持通过 CONFIG_FILE 环境变量指定配置文件路径 -CONFIG_FILE="${CONFIG_FILE:-$(dirname "${BASH_SOURCE[0]}")/.env}" -if [ -f "$CONFIG_FILE" ]; then - # shellcheck source=/dev/null - source "$CONFIG_FILE" -fi - -# ================= 配置区 ================= -# 所有配置均可通过环境变量或 .env 文件覆盖 - -# 你的 Docker Compose 项目总目录 -BASE_DIR="${BASE_DIR:-}" -if [ -z "$BASE_DIR" ]; then - log "[错误] 必须设置 BASE_DIR 环境变量,指定 Docker Compose 项目总目录。脚本将退出。" >&2 - exit 1 -fi -# Kopia 远程路径预期值 -EXPECTED_REMOTE="${EXPECTED_REMOTE:-}" -if [ -z "$EXPECTED_REMOTE" ]; then - log "[错误] 必须设置 EXPECTED_REMOTE 环境变量,指定 Kopia 远程仓库路径。脚本将退出。" >&2 - exit 1 -fi -# Kopia 配置文件路径(可选,用于以其他用户身份运行时指定配置) -KOPIA_CONFIG_FILE="${KOPIA_CONFIG_FILE:-}" -# Rclone 配置文件路径(可选,rclone 原生支持此环境变量) -# 如果设置了,会自动导出为环境变量供 kopia 调用 rclone 时使用 -RCLONE_CONFIG="${RCLONE_CONFIG:-}" -if [ -n "$RCLONE_CONFIG" ]; then - export RCLONE_CONFIG -fi -# 定义你的网关服务文件夹名称 (最后关,最先开) -# 通过 PRIORITY_SERVICES_LIST 环境变量设置,用空格分隔 -if [ -n "$PRIORITY_SERVICES_LIST" ]; then - IFS=' ' read -r -a PRIORITY_SERVICES <<< "$PRIORITY_SERVICES_LIST" -else - PRIORITY_SERVICES=("caddy" "nginx" "gateway") -fi -# 锁文件路径 -LOCK_FILE="${LOCK_FILE:-/tmp/backup_maintenance.lock}" -# 日志文件路径(已在 01-logging.sh 中初始化,此处仅用于 print_config 显示) -# GitHub Gist 配置(可选) -GIST_TOKEN="${GIST_TOKEN:-}" -GIST_ID="${GIST_ID:-}" -GIST_LOG_PREFIX="${GIST_LOG_PREFIX:-yewresin-backup}" -# Gist 日志清理配置 -GIST_MAX_LOGS="${GIST_MAX_LOGS:-30}" -GIST_KEEP_FIRST_FILE="${GIST_KEEP_FIRST_FILE:-false}" -# 设备名称(用于区分不同服务器的通知) -DEVICE_NAME="${DEVICE_NAME:-}" -# ========================================== - -# ================= 打印配置信息 ================= -print_config() { - echo "" - echo "==========================================" - echo "当前配置信息" - echo "==========================================" - # 使用 printf 对齐输出,%-38s 表示左对齐占 38 字符宽度 - local fmt=" %-38s %s\n" - printf "$fmt" "BASE_DIR(工作目录):" "$BASE_DIR" - printf "$fmt" "EXPECTED_REMOTE(Kopia 预期远程仓库路径):" "$EXPECTED_REMOTE" - printf "$fmt" "PRIORITY_SERVICES(优先服务):" "${PRIORITY_SERVICES[*]}" - printf "$fmt" "LOCK_FILE(锁文件路径):" "$LOCK_FILE" - printf "$fmt" "LOG_FILE(日志文件路径):" "$LOG_FILE" - printf "$fmt" "DRY_RUN(模拟运行?):" "$DRY_RUN" - printf "$fmt" "AUTO_CONFIRM(自动确认):" "$AUTO_CONFIRM" - # 设备名称 - if [ -n "$DEVICE_NAME" ]; then - printf "$fmt" "DEVICE_NAME(设备名称):" "$DEVICE_NAME" - else - printf "$fmt" "DEVICE_NAME(设备名称):" "(未配置)" - fi - # Gist 配置 - if [ -n "$GIST_TOKEN" ] && [ -n "$GIST_ID" ]; then - printf "$fmt" "GIST_ID(Gist ID):" "$GIST_ID" - printf "$fmt" "GIST_LOG_PREFIX(Gist 日志前缀):" "$GIST_LOG_PREFIX" - printf "$fmt" "GIST_MAX_LOGS(Gist 最大日志数):" "$GIST_MAX_LOGS" - printf "$fmt" "GIST_KEEP_FIRST_FILE(Gist 保留首文件?):" "$GIST_KEEP_FIRST_FILE" - printf "$fmt" "GIST_TOKEN(Gist Token):" "******(已配置)" - else - printf "$fmt" "GIST 日志上传:" "(未配置)" - fi - # Kopia/Rclone 配置文件路径 - if [ -n "$KOPIA_CONFIG_FILE" ]; then - printf "$fmt" "KOPIA_CONFIG_FILE(Kopia配置文件):" "$KOPIA_CONFIG_FILE" - fi - if [ -n "$RCLONE_CONFIG" ]; then - printf "$fmt" "RCLONE_CONFIG(Rclone配置文件):" "$RCLONE_CONFIG" - fi - # 脱敏处理 KOPIA_PASSWORD - if [ -n "$KOPIA_PASSWORD" ]; then - printf "$fmt" "KOPIA_PASSWORD(仓库密码):" "******(已配置)" - else - printf "$fmt" "KOPIA_PASSWORD(仓库密码):" "(未配置)" - fi - - # 脱敏处理通知 URL - if [ -n "$APPRISE_URL" ]; then - if [ ${#APPRISE_URL} -gt 20 ]; then - local masked_url="${APPRISE_URL:0:10}...${APPRISE_URL: -5}" - else - local masked_url="****(已配置)" - fi - printf "$fmt" "APPRISE_URL(通知服务URL):" "$masked_url" - else - printf "$fmt" "APPRISE_URL(通知服务URL):" "(未配置)" - fi - - if [ -n "$APPRISE_NOTIFY_URL" ]; then - if [ ${#APPRISE_NOTIFY_URL} -gt 20 ]; then - local masked_notify="${APPRISE_NOTIFY_URL:0:10}...${APPRISE_NOTIFY_URL: -5}" - else - local masked_notify="****(已配置)" - fi - printf "$fmt" "APPRISE_NOTIFY_URL(通知目标URL):" "$masked_notify" - else - printf "$fmt" "APPRISE_NOTIFY_URL(通知目标URL):" "(未配置)" - fi - echo "==========================================" - echo "" -} diff --git a/src/04-utils.sh b/src/04-utils.sh deleted file mode 100644 index 20f6721..0000000 --- a/src/04-utils.sh +++ /dev/null @@ -1,11 +0,0 @@ - -# ================= 工具函数 ================= -# dry-run 模式下的模拟执行函数 -dry_run_exec() { - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] 将执行: $*" - return 0 - else - "$@" - fi -} diff --git a/src/05-notification.sh b/src/05-notification.sh deleted file mode 100644 index 5dae889..0000000 --- a/src/05-notification.sh +++ /dev/null @@ -1,50 +0,0 @@ - -# ================= 通知函数 ================= -# 格式化通知响应输出 -format_notification_response() { - local response="$1" - - if echo "$response" | grep -q '"status"'; then - local status msg - status=$(echo "$response" | sed -n 's/.*"status"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p') - msg=$(echo "$response" | sed -n 's/.*"message"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') - if [ "$status" = "200" ]; then - log "通知发送成功: 状态=$status 信息=$msg" - else - log "通知发送失败: 状态=$status 信息=$msg" - fi - elif [ -n "$response" ]; then - log "[警告] 未知错误:通知发送失败 - $response" - fi -} - -# 发送通知函数(需要配置 APPRISE_URL 和 APPRISE_NOTIFY_URL) -send_notification() { - local title="$1" - local body="$2" - - # 如果没配置 Apprise,跳过通知 - if [ -z "$APPRISE_URL" ] || [ -z "$APPRISE_NOTIFY_URL" ]; then - log "跳过通知发送:未配置 APPRISE_URL 或 APPRISE_NOTIFY_URL" - return 0 - fi - - # 如果设置了设备名称,添加到标题前 - if [ -n "$DEVICE_NAME" ]; then - title="[$DEVICE_NAME] $title" - fi - - local response - response=$(curl -X POST "$APPRISE_URL" \ - -H "Content-Type: application/json" \ - -d "{ - \"urls\": \"$APPRISE_NOTIFY_URL\", - \"body\": \"$body\", - \"title\": \"$title\" - }" \ - --max-time 10 \ - --silent \ - --show-error 2>&1) - - format_notification_response "$response" -} diff --git a/src/06-gist.sh b/src/06-gist.sh deleted file mode 100644 index 2263972..0000000 --- a/src/06-gist.sh +++ /dev/null @@ -1,162 +0,0 @@ - -# ================= GitHub Gist 上传 ================= - -# 清理旧的 Gist 日志文件 -cleanup_old_gist_logs() { - # 如果 GIST_MAX_LOGS 为 0 或负数,跳过清理 - if [ "$GIST_MAX_LOGS" -le 0 ] 2>/dev/null; then - return 0 - fi - - log "检查 Gist 日志数量..." - - # 获取 Gist 信息 - local gist_info - gist_info=$(curl -s \ - -H "Authorization: token $GIST_TOKEN" \ - -H "Accept: application/vnd.github.v3+json" \ - "https://api.github.com/gists/$GIST_ID" \ - --max-time 30) - - if ! echo "$gist_info" | jq -e '.id' > /dev/null 2>&1; then - log "⚠ 无法获取 Gist 信息,跳过清理" - return 1 - fi - - # 获取所有文件名(按字母顺序排序) - local all_files - all_files=$(echo "$gist_info" | jq -r '.files | keys | sort | .[]') - - # 计算文件总数 - local total_files - total_files=$(echo "$all_files" | grep -c . || echo 0) - - # 如果启用了保留第一个文件,从列表中排除 - local files_to_consider="$all_files" - local first_file="" - if [ "$GIST_KEEP_FIRST_FILE" = "true" ] && [ "$total_files" -gt 0 ]; then - first_file=$(echo "$all_files" | head -n 1) - files_to_consider=$(echo "$all_files" | tail -n +2) - log "保留第一个文件: $first_file" - fi - - # 计算可清理的文件数量 - local cleanable_count - cleanable_count=$(echo "$files_to_consider" | sed '/^$/d' | wc -l) - - # 如果文件数量未超过限制,跳过清理 - if [ "$cleanable_count" -le "$GIST_MAX_LOGS" ]; then - log "当前日志数量 ($cleanable_count) 未超过限制 ($GIST_MAX_LOGS),无需清理" - return 0 - fi - - # 计算需要删除的文件数量 - local delete_count=$((cleanable_count - GIST_MAX_LOGS)) - log "需要删除 $delete_count 个旧日志文件..." - - # 获取需要删除的文件列表(最旧的文件,即排序后最前面的) - local files_to_delete - files_to_delete=$(echo "$files_to_consider" | head -n "$delete_count") - - # 构建删除 payload - local delete_payload - delete_payload=$(echo "$files_to_delete" | grep -v '^$' | jq -R '{ (.): null }' | jq -s 'add // {}') - - # 执行删除 - local delete_response - delete_response=$(curl -s -X PATCH \ - -H "Authorization: token $GIST_TOKEN" \ - -H "Content-Type: application/json" \ - -d "{\"files\": $delete_payload}" \ - "https://api.github.com/gists/$GIST_ID" \ - --max-time 30) - - if echo "$delete_response" | jq -e '.id' > /dev/null 2>&1; then - log "✓ 已清理 $delete_count 个旧日志文件" - else - log "⚠ 清理旧日志失败: $delete_response" - fi -} - -# 上传日志到 GitHub Gist -upload_to_gist() { - # 如果没配置 Gist,跳过上传 - if [ -z "$GIST_TOKEN" ] || [ -z "$GIST_ID" ]; then - return 0 - fi - - # 确保变量已经计算 - if [ -z "$HOURS" ]; then - HOURS=0 - MINUTES=0 - SECS=0 - fi - - log "上传日志到 GitHub Gist..." - - local timestamp - timestamp=$(date -u '+%Y-%m-%d_%H-%M-%S') - - # 使用自定义前缀,如果为空则使用默认值 - local prefix="${GIST_LOG_PREFIX:-yewresin-backup}" - local filename="${prefix}-${timestamp}.log" - - # 读取日志文件内容 - local raw_log - if [ -f "$LOG_FILE" ]; then - raw_log=$(<"$LOG_FILE") - else - raw_log="日志文件不存在" - fi - - # 构建日志内容(包含完整执行信息) - local log_content - log_content=$(cat </dev/null; then - log "⚠ 未安装 jq,无法上传到 Gist" - return 1 - fi - - log_content=$(echo "$log_content" | jq -Rs .) - - # 构建 JSON payload - local payload - payload=$(jq -n \ - --arg filename "$filename" \ - --argjson content "$log_content" \ - '{files: {($filename): {content: $content}}}') - - # 上传到 Gist - local response - response=$(curl -X PATCH \ - -H "Authorization: token $GIST_TOKEN" \ - -H "Content-Type: application/json" \ - -d "$payload" \ - "https://api.github.com/gists/$GIST_ID" \ - --max-time 30 \ - --silent \ - --show-error 2>&1) - - if echo "$response" | jq -e '.id' > /dev/null 2>&1; then - log "✓ 日志已上传到 Gist: https://gist.github.com/$GIST_ID" - # 上传成功后清理旧日志 - cleanup_old_gist_logs - else - log "⚠ Gist 上传失败: $response" - fi -} diff --git a/src/07-dependencies.sh b/src/07-dependencies.sh deleted file mode 100644 index 4c48324..0000000 --- a/src/07-dependencies.sh +++ /dev/null @@ -1,78 +0,0 @@ - -# ================= 依赖检查 ================= - -# 执行 kopia 命令(支持自定义配置文件路径,正确处理空格) -run_kopia() { - if [ -n "$KOPIA_CONFIG_FILE" ]; then - kopia --config-file="$KOPIA_CONFIG_FILE" "$@" - else - kopia "$@" - fi -} - -# 获取 kopia 命令显示字符串(仅用于日志输出) -get_kopia_cmd_display() { - if [ -n "$KOPIA_CONFIG_FILE" ]; then - echo "kopia --config-file=\"$KOPIA_CONFIG_FILE\"" - else - echo "kopia" - fi -} - -check_dependencies() { - local has_error=false - local error_msg="" - - # 检查 kopia - if ! command -v kopia &>/dev/null; then - echo "[错误] kopia 未安装" - echo " 请访问 https://kopia.io/docs/installation/ 下载安装" - error_msg+="kopia 未安装; " - has_error=true - fi - if [ -z "$EXPECTED_REMOTE" ]; then - echo "[错误] Kopia 备份用远程仓库路径未配置" - echo " 请在配置文件中设置 EXPECTED_REMOTE" - send_notification "❌ 备份失败" "Kopia 备份用远程仓库路径未配置" - exit 1 - fi - - # 检查自定义配置文件是否存在 - if [ -n "$KOPIA_CONFIG_FILE" ] && [ ! -f "$KOPIA_CONFIG_FILE" ]; then - echo "[错误] 指定的 Kopia 配置文件不存在: $KOPIA_CONFIG_FILE" - error_msg+="Kopia 配置文件不存在; " - has_error=true - fi - if [ -n "$RCLONE_CONFIG" ] && [ ! -f "$RCLONE_CONFIG" ]; then - echo "[错误] 指定的 Rclone 配置文件不存在: $RCLONE_CONFIG" - error_msg+="Rclone 配置文件不存在; " - has_error=true - fi - - # 如果基础依赖检查失败,直接退出 - if [ "$has_error" = true ]; then - echo "" - echo "[失败] 依赖检查未通过,脚本退出" - send_notification "❌ 备份失败" "依赖检查未通过: ${error_msg}请手动配置后重试" - exit 1 - fi - - # 检查 Kopia 仓库连接状态 - echo "[检查] Kopia 仓库 $EXPECTED_REMOTE 连接状态..." - local repo_status - repo_status=$(run_kopia repository status --json 2>&1) - - if echo "$repo_status" | grep -q "\"remotePath\": \"$EXPECTED_REMOTE\""; then - echo "[✓] Kopia 仓库已正确连接到 $EXPECTED_REMOTE" - else - echo "[错误] Kopia 仓库未连接或连接到错误的远程路径" - echo " 请手动执行 'kopia repository connect' 连接仓库" - echo " 文档: https://kopia.io/docs/installation/" - echo "" - echo "[失败] 依赖检查未通过,脚本退出" - send_notification "❌ 备份失败" "Kopia 仓库未连接或路径不匹配,请手动连接后重试" - exit 1 - fi - - echo "[✓] 依赖检查通过: kopia 仓库已正确连接" -} diff --git a/src/08-services.sh b/src/08-services.sh deleted file mode 100644 index f7659a1..0000000 --- a/src/08-services.sh +++ /dev/null @@ -1,233 +0,0 @@ -#!/bin/bash -# shellcheck source-path=SCRIPTDIR -# This module is sourced by yewresin.sh and provides service management functions. -# Required external variables: DRY_RUN, BASE_DIR, LOCK_FILE, PRIORITY_SERVICES, NORMAL_SERVICES -# Required external functions: log(), send_notification() -# ================= 服务管理 ================= -# 记录原本运行中的服务 -declare -A RUNNING_SERVICES - -# 检查目录下是否存在 compose 配置文件 -has_compose_file() { - local svc_path="$1" - [ -f "$svc_path/compose.yaml" ] || \ - [ -f "$svc_path/compose.yml" ] || \ - [ -f "$svc_path/docker-compose.yaml" ] || \ - [ -f "$svc_path/docker-compose.yml" ] -} - -# 检查服务是否正在运行 -is_service_running() { - local svc_path="$1" - local svc_name - svc_name=$(basename "$svc_path") - - # 检查是否有 compose 相关文件(yaml 或脚本) - local has_compose=false - if [ -x "$svc_path/compose-status.sh" ] || [ -x "$svc_path/compose-up.sh" ] || [ -x "$svc_path/compose-log.sh" ]; then - has_compose=true - elif has_compose_file "$svc_path"; then - has_compose=true - fi - - if [ "$has_compose" = true ]; then - local running_containers - # 优先在目录下执行(自动识别 yaml),否则用项目名 - running_containers=$(cd "$svc_path" && docker compose ps -q 2>/dev/null | wc -l) - if [ "$running_containers" -gt 0 ]; then - return 0 - fi - # 备用:用项目名检查 - running_containers=$(docker compose -p "$svc_name" ps -q 2>/dev/null | wc -l) - if [ "$running_containers" -gt 0 ]; then - return 0 - fi - fi - - return 1 -} - -# 停止单个服务的函数 -stop_service() { - local svc_path="$1" - local svc_name - svc_name=$(basename "$svc_path") - - # 先检查服务是否在运行 - if ! is_service_running "$svc_path"; then - log "跳过 $svc_name (无服务/服务未运行)" - return 0 - fi - - # 记录该服务原本是运行中的 - RUNNING_SERVICES["$svc_name"]=1 - - # 确定停止方法 - local stop_cmd="" - local stop_msg="" - if [ -x "$svc_path/compose-stop.sh" ]; then - stop_cmd="./compose-stop.sh" - stop_msg="使用 compose-stop.sh" - elif [ -x "$svc_path/compose-down.sh" ]; then - stop_cmd="./compose-down.sh" - stop_msg="使用 compose-down.sh" - elif has_compose_file "$svc_path"; then - stop_cmd="docker compose stop" - stop_msg="使用 docker compose stop" - fi - - # 无法识别停止方法 - if [ -z "$stop_cmd" ]; then - if [ "$DRY_RUN" = true ]; then - log "[DRY-RUN] 警告:停止 $svc_name 失败,无法识别停止方法" - return 0 - else - log "错误:停止 $svc_name 失败,无法识别停止方法" - return 1 - fi - fi - - # DRY_RUN 模式只打印 - if [ "$DRY_RUN" = true ]; then - log "[DRY-RUN] 将停止 $svc_name ($stop_msg)" - return 0 - fi - - # 实际执行停止 - log "停止 $svc_name ($stop_msg)..." - if ! (cd "$svc_path" && $stop_cmd); then - log "错误:停止 $svc_name 失败" - return 1 - fi - return 0 -} - -# 启动单个服务并返回状态的函数 -start_service_with_status() { - local svc_path="$1" - local svc_name - svc_name=$(basename "$svc_path") - - # 检查该服务是否原本在运行 - if [ -z "${RUNNING_SERVICES[$svc_name]}" ]; then - log "跳过启动 $svc_name (原本未运行)" - return 0 - fi - - # 确定启动方法 - local start_cmd="" - local start_msg="" - if [ -x "$svc_path/compose-up.sh" ]; then - start_cmd="./compose-up.sh" - start_msg="使用 compose-up.sh" - elif has_compose_file "$svc_path"; then - start_cmd="docker compose up -d" - start_msg="使用 docker compose up -d" - fi - - # 无法识别启动方法 - if [ -z "$start_cmd" ]; then - if [ "$DRY_RUN" = true ]; then - log "[DRY-RUN] 警告:启动 $svc_name 失败,无法识别启动方法" - else - log "警告:启动 $svc_name 失败,无法识别启动方法" - fi - return 1 - fi - - # DRY_RUN 模式只打印 - if [ "$DRY_RUN" = true ]; then - log "[DRY-RUN] 将启动 $svc_name ($start_msg)" - return 0 - fi - - # 实际执行启动 - log "启动 $svc_name ($start_msg)..." - if ! (cd "$svc_path" && $start_cmd); then - log "警告:启动 $svc_name 失败" - return 1 - fi - return 0 -} - -# 辅助函数:启动服务,如果失败则记录到数组 -# 使用 nameref 引用外部数组 -_start_service_or_record() { - local svc_path="$1" - local -n _failed_arr=$2 - local svc_name - svc_name=$(basename "$svc_path") - - if ! start_service_with_status "$svc_path"; then - _failed_arr+=("$svc_name") - fi -} - -# 启动所有服务的函数 -start_all_services() { - local failed_services=() - - log "恢复网关服务 (优先执行)..." - for svc in "${PRIORITY_SERVICES[@]}"; do - [ -d "$BASE_DIR/$svc" ] && _start_service_or_record "$BASE_DIR/$svc" failed_services - done - - log "恢复普通服务..." - for svc in "${NORMAL_SERVICES[@]}"; do - [ -d "$BASE_DIR/$svc" ] && _start_service_or_record "$BASE_DIR/$svc" failed_services - done - - # 如果有服务启动失败,发送通知 - if [ ${#failed_services[@]} -gt 0 ]; then - log "!!! 以下服务启动失败: ${failed_services[*]}" - send_notification "⚠️ 服务恢复异常" "以下服务启动失败: ${failed_services[*]}" - fi - return 0 -} - -# 辅助函数:停止服务,如果失败则退出并发送通知 -_stop_service_or_exit() { - local svc_path="$1" - local svc_name - svc_name=$(basename "$svc_path") - - if ! stop_service "$svc_path"; then - log "!!! 服务停止失败,中止备份以保护数据安全" - send_notification "❌ 备份中止" "服务 $svc_name 停止失败,已中止备份以避免数据损坏" - exit 1 - fi -} - -stop_all_services() { - log "停止普通服务..." - for svc in "${NORMAL_SERVICES[@]}"; do - [ -d "$BASE_DIR/$svc" ] && _stop_service_or_exit "$BASE_DIR/$svc" - done - - log "停止网关服务 (最后执行)..." - for svc in "${PRIORITY_SERVICES[@]}"; do - [ -d "$BASE_DIR/$svc" ] && _stop_service_or_exit "$BASE_DIR/$svc" - done - return 0 -} - -# 清理函数:确保异常退出时也能恢复服务 -cleanup() { - local exit_code=$? - set +e # 禁用错误退出,确保清理逻辑完整执行 - if [ "$exit_code" -ne 0 ]; then - log "!!! 脚本异常退出,尝试恢复所有服务..." - send_notification "❌ 备份异常" "脚本异常退出 (exit code: $exit_code),正在尝试恢复服务..." - start_all_services - fi - # 上传日志到 Gist - upload_to_gist - # 移除锁目录 - if [ -d "$LOCK_FILE" ]; then - rmdir "$LOCK_FILE" || log "警告:无法移除锁目录,可能包含意外文件" - fi - # 清理临时日志文件 - if [ -f "$LOG_OUTPUT_FILE" ]; then - rm -f "$LOG_OUTPUT_FILE" - fi -} diff --git a/src/09-main.sh b/src/09-main.sh deleted file mode 100644 index 8f5ba12..0000000 --- a/src/09-main.sh +++ /dev/null @@ -1,123 +0,0 @@ - -# ================= 主流程 ================= -# 打印配置 -print_config - -# 执行依赖检查 -check_dependencies - -# ================= 交互式确认 ================= -if [ "$DRY_RUN" = false ] && [ "$AUTO_CONFIRM" = false ]; then - echo "" - echo "==========================================" - echo "⚠️ 警告:即将执行备份操作" - echo "==========================================" - echo "" - echo "此操作将会:" - echo " 1. 停止所有 Docker 服务" - echo " 2. 创建 Kopia 快照备份" - echo " 3. 重新启动所有服务" - echo "" - echo "💡 提示:建议先使用 --dry-run 参数测试:" - echo " $0 --dry-run" - echo "" - read -r -p "确认执行备份?[y/N] " response - case "$response" in - [yY][eE][sS]|[yY]) - echo "开始执行备份..." - ;; - *) - echo "已取消操作" - exit 0 - ;; - esac -fi - -# 检查锁文件,防止重复执行(使用 mkdir 原子操作) -if ! mkdir "$LOCK_FILE" 2>/dev/null; then - log "!!! 另一个备份进程正在运行 (锁文件: $LOCK_FILE),退出" - exit 1 -fi - -# 注册 trap,捕获退出信号 -trap cleanup EXIT INT TERM - -# 1. 获取所有子目录列表 -NORMAL_SERVICES=() - -# 2. 区分普通服务和网关服务 -while IFS= read -r -d '' dir; do - dirname=$(basename "$dir") - is_priority=false - - # 检查是否在优先列表中 - for p in "${PRIORITY_SERVICES[@]}"; do - if [[ "$dirname" == "$p" ]]; then - is_priority=true - break - fi - done - - if [ "$is_priority" = "false" ]; then - NORMAL_SERVICES+=("$dirname") - fi -done < <(find "$BASE_DIR" -mindepth 1 -maxdepth 1 -type d -print0) - -log ">>> 开始执行深夜维护..." -send_notification "🔄 备份开始" "开始执行服务器备份任务" - -# 3. 停止容器 -stop_all_services - -# 4. 执行 Kopia 备份 -log ">>> 服务已全部停止,准备执行 Kopia 快照..." - -# 4.1 执行快照 -backup_success=true -if [ "$DRY_RUN" = true ]; then - log "[DRY-RUN] 将执行: $(get_kopia_cmd_display) snapshot create $BASE_DIR" -else - log "开始创建快照..." - if ! run_kopia snapshot create "$BASE_DIR"; then - log "!!! 警告:备份过程中出现错误 !!!" - backup_success=false - send_notification "❌ 备份失败" "Kopia 快照创建失败" - else - log ">>> 备份成功!" - fi -fi - -# 5. 启动容器 -start_all_services - -log ">>> 所有任务完成。" - -# ================= 显示耗时统计 ================= -SCRIPT_END_TIME=$(date -u +%s) -SCRIPT_END_DATETIME=$(date -u '+%Y-%m-%d %H:%M:%S UTC') -TOTAL_SECS=$((SCRIPT_END_TIME - SCRIPT_START_TIME)) - -# 转换为时分秒格式 -HOURS=$((TOTAL_SECS / 3600)) -MINUTES=$(((TOTAL_SECS % 3600) / 60)) -SECS=$((TOTAL_SECS % 60)) -log "$(printf " %-20s %s" "开始时间:" "$SCRIPT_START_DATETIME")" -log "$(printf " %-20s %s" "结束时间:" "$SCRIPT_END_DATETIME")" -if [ $HOURS -gt 0 ]; then - log "$(printf " %-20s %d 小时 %d 分 %d 秒" "总耗时:" "$HOURS" "$MINUTES" "$SECS")" -elif [ $MINUTES -gt 0 ]; then - log "$(printf " %-20s %d 分 %d 秒" "总耗时:" "$MINUTES" "$SECS")" -else - log "$(printf " %-20s %d 秒" "总耗时:" "$SECS")" -fi -echo "==========================================" - -# 发送最终通知 -if [ "$DRY_RUN" = true ]; then - log "[DRY-RUN] 模拟运行完成,未执行任何实际操作" - send_notification "🧪 DRY-RUN 完成" "模拟运行完成,未执行任何实际操作" -elif [ "$backup_success" = true ]; then - send_notification "✅ 备份成功" "所有服务已恢复运行" -else - send_notification "⚠️ 备份完成(有警告)" "快照创建失败,但服务已恢复运行" -fi