@shellus/way 0.6.0 → 0.6.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -20
- package/dist/cli.js +320 -28
- package/package.json +3 -1
- package/rules.yaml.example +26 -5
package/README.md
CHANGED
|
@@ -67,22 +67,40 @@
|
|
|
67
67
|
|
|
68
68
|
## 依赖
|
|
69
69
|
|
|
70
|
-
- Node.js
|
|
70
|
+
- Linux x64 独立发行包无需预装 Node.js、npm、Bun 或 restic
|
|
71
|
+
- npm 安装方式需要 Node.js >= 18
|
|
71
72
|
- Linux x64 平台内置 [restic](https://restic.net/) 0.18.1,其他平台需自行安装 restic
|
|
72
73
|
|
|
73
74
|
`way` 查找 restic 的顺序:
|
|
74
75
|
|
|
75
76
|
1. `WAY_RESTIC_BIN` 指定的二进制
|
|
76
|
-
2. Linux x64 包内置的 restic
|
|
77
|
-
3.
|
|
77
|
+
2. Linux x64 npm 包内置的 restic
|
|
78
|
+
3. Linux x64 独立发行包旁边或安装目录中的 restic
|
|
79
|
+
4. 系统 `PATH` 中的 `restic`
|
|
78
80
|
|
|
79
81
|
## 安装
|
|
80
82
|
|
|
83
|
+
### Linux x64 独立发行包(推荐)
|
|
84
|
+
|
|
85
|
+
GitHub Release 提供 Linux x64 独立发行包,安装机无需预装 Node.js、npm、Bun 或 restic:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
curl -fsSL https://raw.githubusercontent.com/shellus/way/master/scripts/install.sh | sudo sh
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
默认安装位置:
|
|
92
|
+
|
|
93
|
+
- `way`: `/usr/local/bin/way`
|
|
94
|
+
- 内置 restic: `/usr/local/lib/way/vendor/restic/linux-x64/restic`
|
|
95
|
+
- 示例配置: `/usr/local/lib/way/*.yaml.example`
|
|
96
|
+
|
|
97
|
+
### npm 安装
|
|
98
|
+
|
|
81
99
|
```bash
|
|
82
100
|
npm install -g @shellus/way
|
|
83
101
|
```
|
|
84
102
|
|
|
85
|
-
Linux x64
|
|
103
|
+
Linux x64 安装方式无需额外安装 restic。如需使用自定义 restic,可设置:
|
|
86
104
|
|
|
87
105
|
```bash
|
|
88
106
|
WAY_RESTIC_BIN=/usr/local/bin/restic way restic snapshots
|
|
@@ -93,9 +111,7 @@ WAY_RESTIC_BIN=/usr/local/bin/restic way restic snapshots
|
|
|
93
111
|
### 1. 初始化配置
|
|
94
112
|
|
|
95
113
|
```bash
|
|
96
|
-
|
|
97
|
-
cp $(npm root -g)/@shellus/way/repositories.yaml.example ~/.way/repositories.yaml
|
|
98
|
-
cp $(npm root -g)/@shellus/way/rules.yaml.example ~/.way/rules.yaml
|
|
114
|
+
way init
|
|
99
115
|
```
|
|
100
116
|
|
|
101
117
|
编辑 `~/.way/repositories.yaml` 填入实际凭证,设置权限:
|
|
@@ -108,10 +124,10 @@ chmod 600 ~/.way/repositories.yaml
|
|
|
108
124
|
|
|
109
125
|
```bash
|
|
110
126
|
# 本地仓库
|
|
111
|
-
way init
|
|
127
|
+
way restic init
|
|
112
128
|
|
|
113
129
|
# S3 仓库(指定其他仓库)
|
|
114
|
-
way --remote=s3 init
|
|
130
|
+
way --remote=s3 restic init
|
|
115
131
|
```
|
|
116
132
|
|
|
117
133
|
### 3. 配置定时备份
|
|
@@ -139,7 +155,7 @@ way backup data # 只备份 data 项目
|
|
|
139
155
|
way backup data config # 备份多个项目
|
|
140
156
|
way backup --dry-run # 模拟备份(不实际写入)
|
|
141
157
|
way restore data --target /tmp/restore # 按规则恢复 data 项目
|
|
142
|
-
way restore data --target /tmp/restore --dry-run
|
|
158
|
+
way restore data --host old-host --target /tmp/restore --dry-run # 从指定 host 模拟恢复
|
|
143
159
|
way restore data --target /tmp/restore --delete # 删除目标中快照不存在的文件
|
|
144
160
|
way gc # 按 retention 策略清理旧快照
|
|
145
161
|
way gc --dry-run # 模拟清理(不实际删除)
|
|
@@ -163,6 +179,8 @@ way restic restore abc123 --target /tmp/restore # → restic restore abc123 --t
|
|
|
163
179
|
way --remote=oss restic snapshots
|
|
164
180
|
```
|
|
165
181
|
|
|
182
|
+
`way backup` 会继续执行本轮全部目标项目并输出汇总。全部项目成功时退出码为 `0`;任一项目失败时退出码为 `1`。
|
|
183
|
+
|
|
166
184
|
## 生命周期
|
|
167
185
|
|
|
168
186
|
```mermaid
|
|
@@ -184,8 +202,8 @@ graph LR
|
|
|
184
202
|
配置文件默认存放在 `~/.way/`,安装后复制示例文件并填入实际值:
|
|
185
203
|
|
|
186
204
|
```bash
|
|
187
|
-
cp
|
|
188
|
-
cp
|
|
205
|
+
cp /usr/local/lib/way/repositories.yaml.example ~/.way/repositories.yaml
|
|
206
|
+
cp /usr/local/lib/way/rules.yaml.example ~/.way/rules.yaml
|
|
189
207
|
```
|
|
190
208
|
|
|
191
209
|
也可以通过 `WAY_DIR` 环境变量指定其他目录:
|
|
@@ -200,19 +218,60 @@ WAY_DIR=/path/to/config way restic snapshots
|
|
|
200
218
|
|
|
201
219
|
- **defaults**: 全局默认配置(schedule、retention)
|
|
202
220
|
- **projects**: 备份项目配置,可覆盖默认 schedule 和 retention
|
|
221
|
+
- **projects.*.hooks**: 项目级备份钩子,支持 `before_backup` 和 `after_backup`
|
|
222
|
+
- **uptime_kuma.push_url**: 全局 Uptime Kuma Push 地址,作为项目未配置通知地址时的回退
|
|
223
|
+
- **projects.*.uptime_kuma.push_url**: 项目级 Uptime Kuma Push 地址,优先于全局地址
|
|
203
224
|
- **maintenance**: 维护任务配置(prune、check)
|
|
204
225
|
- **global_excludes**: 全局排除规则
|
|
205
226
|
|
|
227
|
+
项目级钩子用于在 restic 备份前后执行一致性快照、校验或清理脚本:
|
|
228
|
+
|
|
229
|
+
```yaml
|
|
230
|
+
projects:
|
|
231
|
+
data:
|
|
232
|
+
paths:
|
|
233
|
+
- /path/to/data
|
|
234
|
+
hooks:
|
|
235
|
+
before_backup:
|
|
236
|
+
- run: /path/to/scripts/prepare-data-backup.sh
|
|
237
|
+
timeout: "10m"
|
|
238
|
+
after_backup:
|
|
239
|
+
- run: /path/to/scripts/verify-data-backup.sh
|
|
240
|
+
timeout: "5m"
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
`before_backup` 失败会跳过该项目的 restic 备份并标记项目失败;`after_backup` 只在 restic 成功后执行,失败同样会标记项目失败。`--dry-run` 模式只打印钩子命令,不实际执行。钩子命令按 shell 命令执行,并会收到 `WAY_PROJECT`、`WAY_REMOTE`、`WAY_DIR`、`WAY_DRY_RUN` 环境变量。
|
|
244
|
+
|
|
245
|
+
**项目级 Uptime Kuma 通知示例**:
|
|
246
|
+
|
|
247
|
+
```yaml
|
|
248
|
+
uptime_kuma:
|
|
249
|
+
push_url: "https://uptime.example.com/api/push/global-placeholder"
|
|
250
|
+
|
|
251
|
+
projects:
|
|
252
|
+
data:
|
|
253
|
+
paths: [/data]
|
|
254
|
+
uptime_kuma:
|
|
255
|
+
push_url: "https://uptime.example.com/api/push/data-placeholder"
|
|
256
|
+
|
|
257
|
+
root:
|
|
258
|
+
paths: [/root]
|
|
259
|
+
uptime_kuma:
|
|
260
|
+
push_url: "https://uptime.example.com/api/push/system-placeholder"
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
每个项目优先使用项目级 Push 地址,未配置时回退到全局地址。一次 `way backup` 中使用相同有效地址的项目只发送一条汇总通知;同组任一项目失败时状态为 DOWN,不同地址的通知互不覆盖。`--dry-run` 不发送通知,通知请求失败只记录错误,不改变备份结果。
|
|
264
|
+
|
|
206
265
|
**项目级调度示例**:
|
|
207
266
|
|
|
208
267
|
```yaml
|
|
209
268
|
defaults:
|
|
210
|
-
schedule:
|
|
269
|
+
schedule: false # 默认不自动调度
|
|
211
270
|
|
|
212
271
|
projects:
|
|
213
272
|
data:
|
|
214
273
|
paths: [/data]
|
|
215
|
-
#
|
|
274
|
+
schedule: "0 */2 * * *" # 每 2 小时
|
|
216
275
|
|
|
217
276
|
logs:
|
|
218
277
|
paths: [/var/log]
|
|
@@ -220,20 +279,57 @@ projects:
|
|
|
220
279
|
|
|
221
280
|
archive:
|
|
222
281
|
paths: [/archive]
|
|
223
|
-
schedule:
|
|
282
|
+
schedule: false # 只手动触发:way backup archive
|
|
224
283
|
retention:
|
|
225
284
|
keep_weekly: 8
|
|
226
285
|
keep_monthly: 12
|
|
227
286
|
```
|
|
228
287
|
|
|
288
|
+
**手动备份匹配目录示例**:
|
|
289
|
+
|
|
290
|
+
日常备份可排除可重建依赖目录,避免遍历海量小文件:
|
|
291
|
+
|
|
292
|
+
```yaml
|
|
293
|
+
projects:
|
|
294
|
+
data:
|
|
295
|
+
paths: [/data]
|
|
296
|
+
excludes:
|
|
297
|
+
- node_modules
|
|
298
|
+
- vendor
|
|
299
|
+
schedule: "0 */2 * * *"
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
如果需要偶尔保留依赖目录,可单独配置只手动触发的项目:
|
|
303
|
+
|
|
304
|
+
```yaml
|
|
305
|
+
projects:
|
|
306
|
+
data_deps:
|
|
307
|
+
paths: [/data]
|
|
308
|
+
include_dirs:
|
|
309
|
+
- www/xhj/*/node_modules
|
|
310
|
+
- www/xhj/*/vendor
|
|
311
|
+
schedule: false
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
手动执行:
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
way backup data_deps
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
`include_dirs` 是相对 `paths` 的目录 glob,只支持路径段中的 `*`,不支持绝对路径和 `**`。命中目录后,`way` 会把该目录作为备份根路径写入临时 `--files-from` 列表。`include_dirs` 是专用模式,不应用 `global_excludes`,也不能同时配置项目级 `excludes`。
|
|
321
|
+
|
|
229
322
|
**schedule 语法**(node-cron 格式):
|
|
230
323
|
|
|
324
|
+
`schedule` 支持 node-cron 字符串或 `false`。`false` 表示不创建自动调度任务,只能通过 `way backup <project>` 或 `way backup` 手动触发。项目未设置 `schedule` 时继承 `defaults.schedule`;如果全局和项目都未设置,则不自动调度。
|
|
325
|
+
|
|
231
326
|
| 格式 | 说明 | 示例 |
|
|
232
327
|
|------|------|------|
|
|
233
328
|
| `"0 */2 * * *"` | 间隔表达式 | 每 2 小时 |
|
|
234
329
|
| `"0 9,15,21 * * *"` | 多个时间点(逗号) | 每天 9:00、15:00、21:00 |
|
|
235
330
|
| `"0 9-17 * * 1-5"` | 时间范围 | 工作日 9:00-17:00 每小时 |
|
|
236
331
|
| `"*/30 * * * *"` | 分钟间隔 | 每 30 分钟 |
|
|
332
|
+
| `false` | 禁用自动调度 | 只手动备份 |
|
|
237
333
|
|
|
238
334
|
#### 排除规则通配符语法
|
|
239
335
|
|
|
@@ -326,14 +422,14 @@ way restore --target /tmp/restore
|
|
|
326
422
|
way restore data --snapshot abc123 --target /tmp/restore
|
|
327
423
|
|
|
328
424
|
# 预演恢复,不实际写入
|
|
329
|
-
way restore data --target /tmp/restore --dry-run
|
|
425
|
+
way restore data --host old-host --target /tmp/restore --dry-run
|
|
330
426
|
|
|
331
427
|
# 让目标目录与快照一致,先 dry-run 核对删除清单
|
|
332
|
-
way restore data --target /tmp/restore --delete --dry-run
|
|
333
|
-
way restore data --target /tmp/restore --delete
|
|
428
|
+
way restore data --host old-host --target /tmp/restore --delete --dry-run
|
|
429
|
+
way restore data --host old-host --target /tmp/restore --delete
|
|
334
430
|
```
|
|
335
431
|
|
|
336
|
-
`way restore` 会为项目自动添加 `--tag=way:<project>` 和项目 `paths` 对应的 `--include`
|
|
432
|
+
`way restore` 会为项目自动添加 `--tag=way:<project>` 和项目 `paths` 对应的 `--include` 参数;传入 `--host` 时会只恢复指定 host 的快照。
|
|
337
433
|
|
|
338
434
|
### 4. 原始 restic 恢复
|
|
339
435
|
|
|
@@ -364,4 +460,4 @@ way --remote=oss restic restore <snapshot-id> --target /tmp/restore
|
|
|
364
460
|
- 变更规范和测试要求
|
|
365
461
|
- 发布流程
|
|
366
462
|
|
|
367
|
-
内部开发参考:[CLAUDE.md](CLAUDE.md)
|
|
463
|
+
内部开发参考:[CLAUDE.md](CLAUDE.md)
|
package/dist/cli.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
+
import fs6 from "fs";
|
|
6
|
+
import path6 from "path";
|
|
5
7
|
|
|
6
8
|
// src/core/config.ts
|
|
7
9
|
import fs from "fs";
|
|
@@ -23,6 +25,8 @@ function loadConfig(wayDir, remoteName) {
|
|
|
23
25
|
|
|
24
26
|
// src/core/restic.ts
|
|
25
27
|
import { execa } from "execa";
|
|
28
|
+
import fs3 from "fs";
|
|
29
|
+
import path3 from "path";
|
|
26
30
|
|
|
27
31
|
// src/core/restic-bin.ts
|
|
28
32
|
import fs2 from "fs";
|
|
@@ -31,6 +35,34 @@ import { fileURLToPath } from "url";
|
|
|
31
35
|
function getBundledResticBin(packageRoot) {
|
|
32
36
|
return path2.join(packageRoot, "vendor/restic/linux-x64/restic");
|
|
33
37
|
}
|
|
38
|
+
function getStandaloneResticBinCandidates(executablePath) {
|
|
39
|
+
const binDir = path2.dirname(executablePath);
|
|
40
|
+
const archiveRoot = path2.dirname(binDir);
|
|
41
|
+
const prefixRoot = path2.dirname(binDir);
|
|
42
|
+
return [
|
|
43
|
+
path2.join(archiveRoot, "vendor/restic/linux-x64/restic"),
|
|
44
|
+
path2.join(prefixRoot, "lib/way/vendor/restic/linux-x64/restic")
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
function getExampleConfigCandidates(file, packageRoot, executablePath) {
|
|
48
|
+
const binDir = path2.dirname(executablePath);
|
|
49
|
+
const archiveRoot = path2.dirname(binDir);
|
|
50
|
+
const prefixRoot = path2.dirname(binDir);
|
|
51
|
+
return [
|
|
52
|
+
path2.join(packageRoot, `${file}.example`),
|
|
53
|
+
path2.join(archiveRoot, `${file}.example`),
|
|
54
|
+
path2.join(prefixRoot, "lib/way", `${file}.example`)
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
function resolveExampleConfigPath(file, options = {}) {
|
|
58
|
+
const packageRoot = options.packageRoot ?? findPackageRoot();
|
|
59
|
+
const executablePath = options.executablePath ?? process.execPath;
|
|
60
|
+
const existsSync = options.existsSync ?? fs2.existsSync;
|
|
61
|
+
for (const candidate of getExampleConfigCandidates(file, packageRoot, executablePath)) {
|
|
62
|
+
if (existsSync(candidate)) return candidate;
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`Unable to find ${file}.example`);
|
|
65
|
+
}
|
|
34
66
|
function findPackageRoot(startDir = path2.dirname(fileURLToPath(import.meta.url))) {
|
|
35
67
|
let current = startDir;
|
|
36
68
|
while (true) {
|
|
@@ -45,11 +77,15 @@ function resolveResticBin(options = {}) {
|
|
|
45
77
|
const platform = options.platform ?? process.platform;
|
|
46
78
|
const arch = options.arch ?? process.arch;
|
|
47
79
|
const packageRoot = options.packageRoot ?? findPackageRoot();
|
|
80
|
+
const executablePath = options.executablePath ?? process.execPath;
|
|
48
81
|
const existsSync = options.existsSync ?? fs2.existsSync;
|
|
49
82
|
if (env.WAY_RESTIC_BIN) return env.WAY_RESTIC_BIN;
|
|
50
83
|
if (platform === "linux" && arch === "x64") {
|
|
51
84
|
const bundled = getBundledResticBin(packageRoot);
|
|
52
85
|
if (existsSync(bundled)) return bundled;
|
|
86
|
+
for (const candidate of getStandaloneResticBinCandidates(executablePath)) {
|
|
87
|
+
if (existsSync(candidate)) return candidate;
|
|
88
|
+
}
|
|
53
89
|
}
|
|
54
90
|
return "restic";
|
|
55
91
|
}
|
|
@@ -73,21 +109,84 @@ function buildResticEnv(repo) {
|
|
|
73
109
|
if (repo.credentials.secret_access_key) env.AWS_SECRET_ACCESS_KEY = repo.credentials.secret_access_key;
|
|
74
110
|
return env;
|
|
75
111
|
}
|
|
76
|
-
function buildBackupArgs(name, project, globalExcludes) {
|
|
112
|
+
function buildBackupArgs(name, project, globalExcludes, filesFrom) {
|
|
77
113
|
const args = ["backup", `--tag=way:${name}`];
|
|
114
|
+
if (project.include_dirs?.length) {
|
|
115
|
+
if (project.excludes?.length) {
|
|
116
|
+
throw new Error(`Project ${name} cannot use excludes with include_dirs`);
|
|
117
|
+
}
|
|
118
|
+
if (!filesFrom) throw new Error(`Project ${name} uses include_dirs but no files-from list was provided`);
|
|
119
|
+
args.push(`--files-from=${filesFrom}`);
|
|
120
|
+
return args;
|
|
121
|
+
}
|
|
78
122
|
const allExcludes = [...globalExcludes, ...project.excludes || []];
|
|
79
123
|
for (const exclude of allExcludes) args.push(`--exclude=${exclude}`);
|
|
80
124
|
args.push(...project.paths);
|
|
81
125
|
return args;
|
|
82
126
|
}
|
|
127
|
+
function collectIncludeDirs(paths, includeDirs, options = {}) {
|
|
128
|
+
const readdirSync = options.readdirSync ?? ((dir) => fs3.readdirSync(dir, { withFileTypes: true }));
|
|
129
|
+
const matches = [];
|
|
130
|
+
function isDirectoryEntry(entry) {
|
|
131
|
+
return typeof entry.isDirectory === "function" ? entry.isDirectory() : entry.isDirectory;
|
|
132
|
+
}
|
|
133
|
+
function readDirectoryEntries(dir) {
|
|
134
|
+
try {
|
|
135
|
+
return readdirSync(dir);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
console.error(`Failed to scan ${dir}:`, error);
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function validatePattern(pattern) {
|
|
142
|
+
if (path3.isAbsolute(pattern)) {
|
|
143
|
+
throw new Error(`include_dirs pattern must be relative to project paths: ${pattern}`);
|
|
144
|
+
}
|
|
145
|
+
if (pattern.split("/").includes("**")) {
|
|
146
|
+
throw new Error(`include_dirs does not support **: ${pattern}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function matchSegment(name, pattern) {
|
|
150
|
+
if (pattern === "*") return true;
|
|
151
|
+
if (!pattern.includes("*")) return name === pattern;
|
|
152
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*");
|
|
153
|
+
return new RegExp(`^${escaped}$`).test(name);
|
|
154
|
+
}
|
|
155
|
+
function expandPattern(root, pattern) {
|
|
156
|
+
const segments = pattern.split("/").filter(Boolean);
|
|
157
|
+
let candidates = [path3.resolve(root)];
|
|
158
|
+
for (const segment of segments) {
|
|
159
|
+
const nextCandidates = [];
|
|
160
|
+
for (const candidate of candidates) {
|
|
161
|
+
const entries = readDirectoryEntries(candidate);
|
|
162
|
+
for (const entry of entries) {
|
|
163
|
+
if (!isDirectoryEntry(entry)) continue;
|
|
164
|
+
if (!matchSegment(entry.name, segment)) continue;
|
|
165
|
+
nextCandidates.push(path3.join(candidate, entry.name));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
candidates = nextCandidates;
|
|
169
|
+
if (candidates.length === 0) break;
|
|
170
|
+
}
|
|
171
|
+
return candidates;
|
|
172
|
+
}
|
|
173
|
+
for (const pattern of includeDirs) validatePattern(pattern);
|
|
174
|
+
for (const sourcePath of paths) {
|
|
175
|
+
for (const pattern of includeDirs) {
|
|
176
|
+
matches.push(...expandPattern(sourcePath, pattern));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return Array.from(new Set(matches));
|
|
180
|
+
}
|
|
83
181
|
function buildRestoreArgs(name, project, options) {
|
|
84
182
|
const args = [
|
|
85
183
|
"restore",
|
|
86
184
|
options.snapshot || "latest",
|
|
87
|
-
`--tag=way:${name}
|
|
88
|
-
`--target=${options.target}`
|
|
185
|
+
`--tag=way:${name}`
|
|
89
186
|
];
|
|
90
|
-
|
|
187
|
+
if (options.host) args.push(`--host=${options.host}`);
|
|
188
|
+
args.push(`--target=${options.target}`);
|
|
189
|
+
for (const path7 of project.paths) args.push(`--include=${path7}`);
|
|
91
190
|
if (options.dryRun) args.push("--dry-run");
|
|
92
191
|
if (options.delete) args.push("--delete");
|
|
93
192
|
if (options.verbose) args.push("--verbose=2");
|
|
@@ -114,6 +213,10 @@ async function execRestic(args, env, s3Options = []) {
|
|
|
114
213
|
}
|
|
115
214
|
|
|
116
215
|
// src/commands/backup.ts
|
|
216
|
+
import fs4 from "fs";
|
|
217
|
+
import os from "os";
|
|
218
|
+
import path4 from "path";
|
|
219
|
+
import { execaCommand } from "execa";
|
|
117
220
|
async function backup(options) {
|
|
118
221
|
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
119
222
|
const config = loadConfig(wayDir, options.remote);
|
|
@@ -125,35 +228,147 @@ async function backup(options) {
|
|
|
125
228
|
const extraArgs = options.extraArgs?.filter((arg) => arg !== "--dry-run") || [];
|
|
126
229
|
const succeeded = [];
|
|
127
230
|
const failed = [];
|
|
231
|
+
const projectResults = [];
|
|
128
232
|
const startTime = Date.now();
|
|
129
233
|
for (const projectName of projects) {
|
|
234
|
+
const projectStartTime = Date.now();
|
|
130
235
|
const project = config.rules.projects[projectName];
|
|
236
|
+
const pushUrl = resolveUptimeKumaPushUrl(project, config.rules.uptime_kuma?.push_url);
|
|
131
237
|
if (!project) {
|
|
132
238
|
console.error(`Project not found: ${projectName}`);
|
|
133
239
|
failed.push(projectName);
|
|
240
|
+
projectResults.push({
|
|
241
|
+
succeeded: [],
|
|
242
|
+
failed: [projectName],
|
|
243
|
+
duration: Date.now() - projectStartTime,
|
|
244
|
+
pushUrl
|
|
245
|
+
});
|
|
134
246
|
continue;
|
|
135
247
|
}
|
|
136
248
|
console.log(`=== Backing up: ${projectName} ===`);
|
|
249
|
+
let filesFrom;
|
|
250
|
+
let projectSucceeded = false;
|
|
137
251
|
try {
|
|
138
|
-
|
|
252
|
+
await runProjectHooks(project.hooks?.before_backup, {
|
|
253
|
+
projectName,
|
|
254
|
+
remote: options.remote,
|
|
255
|
+
wayDir,
|
|
256
|
+
dryRun,
|
|
257
|
+
label: "before_backup"
|
|
258
|
+
});
|
|
259
|
+
if (project.include_dirs?.length) {
|
|
260
|
+
const includedDirs = collectIncludeDirs(project.paths, project.include_dirs);
|
|
261
|
+
if (includedDirs.length === 0) {
|
|
262
|
+
console.log(`No include_dirs matched for ${projectName}`);
|
|
263
|
+
succeeded.push(projectName);
|
|
264
|
+
projectSucceeded = true;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
filesFrom = path4.join(os.tmpdir(), `way-${projectName}-${Date.now()}-${process.pid}.files`);
|
|
268
|
+
fs4.writeFileSync(filesFrom, `${includedDirs.join("\n")}
|
|
269
|
+
`);
|
|
270
|
+
}
|
|
271
|
+
const args = buildBackupArgs(projectName, project, globalExcludes, filesFrom);
|
|
139
272
|
if (dryRun) args.push("--dry-run");
|
|
140
273
|
args.push(...extraArgs);
|
|
141
274
|
await execRestic(args, env, s3Options);
|
|
275
|
+
await runProjectHooks(project.hooks?.after_backup, {
|
|
276
|
+
projectName,
|
|
277
|
+
remote: options.remote,
|
|
278
|
+
wayDir,
|
|
279
|
+
dryRun,
|
|
280
|
+
label: "after_backup"
|
|
281
|
+
});
|
|
142
282
|
succeeded.push(projectName);
|
|
283
|
+
projectSucceeded = true;
|
|
143
284
|
} catch (error) {
|
|
144
285
|
console.error(`Failed to backup ${projectName}:`, error);
|
|
145
286
|
failed.push(projectName);
|
|
287
|
+
} finally {
|
|
288
|
+
if (filesFrom) fs4.rmSync(filesFrom, { force: true });
|
|
289
|
+
projectResults.push({
|
|
290
|
+
succeeded: projectSucceeded ? [projectName] : [],
|
|
291
|
+
failed: projectSucceeded ? [] : [projectName],
|
|
292
|
+
duration: Date.now() - projectStartTime,
|
|
293
|
+
pushUrl
|
|
294
|
+
});
|
|
146
295
|
}
|
|
147
296
|
}
|
|
148
297
|
const duration = Date.now() - startTime;
|
|
149
298
|
console.log("\n=== Summary ===");
|
|
150
299
|
if (succeeded.length > 0) console.log("Succeeded:", succeeded.join(", "));
|
|
151
300
|
if (failed.length > 0) console.log("Failed:", failed.join(", "));
|
|
152
|
-
if (!dryRun
|
|
153
|
-
|
|
301
|
+
if (!dryRun) {
|
|
302
|
+
for (const [pushUrl, result] of groupUptimeKumaResults(projectResults)) {
|
|
303
|
+
await notifyUptimeKuma(result, pushUrl);
|
|
304
|
+
}
|
|
154
305
|
}
|
|
155
306
|
return { succeeded, failed, duration };
|
|
156
307
|
}
|
|
308
|
+
function resolveUptimeKumaPushUrl(project, globalPushUrl) {
|
|
309
|
+
return project?.uptime_kuma?.push_url || globalPushUrl;
|
|
310
|
+
}
|
|
311
|
+
function groupUptimeKumaResults(projectResults) {
|
|
312
|
+
const groupedResults = /* @__PURE__ */ new Map();
|
|
313
|
+
for (const projectResult of projectResults) {
|
|
314
|
+
if (!projectResult.pushUrl) continue;
|
|
315
|
+
const result = groupedResults.get(projectResult.pushUrl) || {
|
|
316
|
+
succeeded: [],
|
|
317
|
+
failed: [],
|
|
318
|
+
duration: 0
|
|
319
|
+
};
|
|
320
|
+
result.succeeded.push(...projectResult.succeeded);
|
|
321
|
+
result.failed.push(...projectResult.failed);
|
|
322
|
+
result.duration += projectResult.duration;
|
|
323
|
+
groupedResults.set(projectResult.pushUrl, result);
|
|
324
|
+
}
|
|
325
|
+
return groupedResults;
|
|
326
|
+
}
|
|
327
|
+
function normalizeHook(hook) {
|
|
328
|
+
if (typeof hook === "string") return { run: hook };
|
|
329
|
+
return hook;
|
|
330
|
+
}
|
|
331
|
+
function parseTimeout(timeout) {
|
|
332
|
+
if (timeout === void 0) return void 0;
|
|
333
|
+
if (typeof timeout === "number") return timeout;
|
|
334
|
+
const match = timeout.match(/^(\d+)(ms|s|m|h)?$/);
|
|
335
|
+
if (!match) throw new Error(`Invalid hook timeout: ${timeout}`);
|
|
336
|
+
const value = Number(match[1]);
|
|
337
|
+
const unit = match[2] || "ms";
|
|
338
|
+
switch (unit) {
|
|
339
|
+
case "ms":
|
|
340
|
+
return value;
|
|
341
|
+
case "s":
|
|
342
|
+
return value * 1e3;
|
|
343
|
+
case "m":
|
|
344
|
+
return value * 60 * 1e3;
|
|
345
|
+
case "h":
|
|
346
|
+
return value * 60 * 60 * 1e3;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
async function runProjectHooks(hooks, context) {
|
|
350
|
+
if (!hooks?.length) return;
|
|
351
|
+
for (const hook of hooks) {
|
|
352
|
+
const normalized = normalizeHook(hook);
|
|
353
|
+
if (!normalized.run) throw new Error(`Project ${context.projectName} has an empty ${context.label} hook`);
|
|
354
|
+
if (context.dryRun) {
|
|
355
|
+
console.log(`[dry-run] ${context.label}: ${normalized.run}`);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
console.log(`Running ${context.label} hook for ${context.projectName}: ${normalized.run}`);
|
|
359
|
+
await execaCommand(normalized.run, {
|
|
360
|
+
shell: true,
|
|
361
|
+
stdio: "inherit",
|
|
362
|
+
timeout: parseTimeout(normalized.timeout),
|
|
363
|
+
env: {
|
|
364
|
+
WAY_PROJECT: context.projectName,
|
|
365
|
+
WAY_REMOTE: context.remote,
|
|
366
|
+
WAY_DIR: context.wayDir,
|
|
367
|
+
WAY_DRY_RUN: context.dryRun ? "1" : "0"
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
157
372
|
async function notifyUptimeKuma(result, pushUrl) {
|
|
158
373
|
const status = result.failed.length > 0 ? "down" : "up";
|
|
159
374
|
const msg = `Succeeded: ${result.succeeded.length}, Failed: ${result.failed.length}`;
|
|
@@ -193,6 +408,7 @@ async function restore(options) {
|
|
|
193
408
|
const args = buildRestoreArgs(projectName, project, {
|
|
194
409
|
target: options.target,
|
|
195
410
|
snapshot: options.snapshot,
|
|
411
|
+
host: options.host,
|
|
196
412
|
dryRun: options.dryRun,
|
|
197
413
|
delete: options.delete,
|
|
198
414
|
verbose: options.verbose
|
|
@@ -238,12 +454,22 @@ async function gc(options) {
|
|
|
238
454
|
|
|
239
455
|
// src/commands/systemd.ts
|
|
240
456
|
import { execSync } from "child_process";
|
|
241
|
-
import
|
|
242
|
-
import
|
|
457
|
+
import fs5 from "fs";
|
|
458
|
+
import path5 from "path";
|
|
459
|
+
function resolveWayCommandPath(options = {}) {
|
|
460
|
+
const env = options.env ?? process.env;
|
|
461
|
+
const argv = options.argv ?? process.argv;
|
|
462
|
+
const execPath = options.execPath ?? process.execPath;
|
|
463
|
+
const whichWay = options.whichWay ?? (() => execSync("which way", { encoding: "utf-8" }).trim());
|
|
464
|
+
if (env.WAY_BIN) return env.WAY_BIN;
|
|
465
|
+
if (path5.basename(execPath) === "way") return execPath;
|
|
466
|
+
if (argv[1] && path5.isAbsolute(argv[1])) return argv[1];
|
|
467
|
+
return whichWay();
|
|
468
|
+
}
|
|
243
469
|
async function systemd(options) {
|
|
244
470
|
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
245
471
|
const config = loadConfig(wayDir, options.remote);
|
|
246
|
-
const wayPath =
|
|
472
|
+
const wayPath = resolveWayCommandPath();
|
|
247
473
|
const currentUser = execSync("whoami", { encoding: "utf-8" }).trim();
|
|
248
474
|
const serviceContent = `[Unit]
|
|
249
475
|
Description=Way Backup Daemon
|
|
@@ -267,9 +493,9 @@ WantedBy=multi-user.target
|
|
|
267
493
|
return;
|
|
268
494
|
}
|
|
269
495
|
const systemdDir = "/etc/systemd/system";
|
|
270
|
-
const servicePath =
|
|
496
|
+
const servicePath = path5.join(systemdDir, "way-backup.service");
|
|
271
497
|
if (options.action === "install") {
|
|
272
|
-
|
|
498
|
+
fs5.writeFileSync(servicePath, serviceContent);
|
|
273
499
|
execSync("systemctl daemon-reload");
|
|
274
500
|
execSync("systemctl enable way-backup.service");
|
|
275
501
|
execSync("systemctl start way-backup.service");
|
|
@@ -285,7 +511,7 @@ WantedBy=multi-user.target
|
|
|
285
511
|
execSync("systemctl disable way-backup.service", { stdio: "ignore" });
|
|
286
512
|
} catch {
|
|
287
513
|
}
|
|
288
|
-
if (
|
|
514
|
+
if (fs5.existsSync(servicePath)) fs5.unlinkSync(servicePath);
|
|
289
515
|
execSync("systemctl daemon-reload");
|
|
290
516
|
console.log("Systemd service uninstalled");
|
|
291
517
|
}
|
|
@@ -298,6 +524,23 @@ WantedBy=multi-user.target
|
|
|
298
524
|
import cron from "node-cron";
|
|
299
525
|
var isRunning = false;
|
|
300
526
|
var taskQueue = [];
|
|
527
|
+
function resolveProjectSchedule(project, defaults = {}) {
|
|
528
|
+
if (project.schedule !== void 0) return project.schedule;
|
|
529
|
+
return defaults?.schedule ?? false;
|
|
530
|
+
}
|
|
531
|
+
function isEnabledSchedule(schedule) {
|
|
532
|
+
return typeof schedule === "string";
|
|
533
|
+
}
|
|
534
|
+
function assertValidSchedule(schedule, label) {
|
|
535
|
+
if (schedule === "") {
|
|
536
|
+
throw new Error(`${label} must be a cron expression or false. Use false to disable scheduling.`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
function addScheduledBackup(scheduledBackups, schedule, projectName) {
|
|
540
|
+
const projects = scheduledBackups.get(schedule) || [];
|
|
541
|
+
projects.push(projectName);
|
|
542
|
+
scheduledBackups.set(schedule, projects);
|
|
543
|
+
}
|
|
301
544
|
async function executeTask(task) {
|
|
302
545
|
taskQueue.push(task);
|
|
303
546
|
if (isRunning) return;
|
|
@@ -316,18 +559,28 @@ async function daemon(options) {
|
|
|
316
559
|
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
317
560
|
const config = loadConfig(wayDir, options.remote);
|
|
318
561
|
console.log("Way daemon started");
|
|
562
|
+
const scheduledBackups = /* @__PURE__ */ new Map();
|
|
319
563
|
for (const [name, project] of Object.entries(config.rules.projects)) {
|
|
320
|
-
const schedule = project
|
|
564
|
+
const schedule = resolveProjectSchedule(project, config.rules.defaults);
|
|
565
|
+
assertValidSchedule(schedule, `projects.${name}.schedule`);
|
|
566
|
+
if (!isEnabledSchedule(schedule)) {
|
|
567
|
+
console.log(`Skipped scheduled backup for ${name}`);
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
addScheduledBackup(scheduledBackups, schedule, name);
|
|
571
|
+
}
|
|
572
|
+
for (const [schedule, projects] of scheduledBackups.entries()) {
|
|
321
573
|
cron.schedule(schedule, () => {
|
|
322
574
|
executeTask(async () => {
|
|
323
|
-
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running backup: ${
|
|
324
|
-
await backup({ remote: options.remote, projects
|
|
575
|
+
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running backup: ${projects.join(", ")}`);
|
|
576
|
+
await backup({ remote: options.remote, projects });
|
|
325
577
|
});
|
|
326
578
|
});
|
|
327
|
-
console.log(`Scheduled backup for ${
|
|
579
|
+
console.log(`Scheduled backup for ${projects.join(", ")}: ${schedule}`);
|
|
328
580
|
}
|
|
329
581
|
const pruneSchedule = config.rules.maintenance?.prune?.schedule;
|
|
330
|
-
|
|
582
|
+
assertValidSchedule(pruneSchedule, "maintenance.prune.schedule");
|
|
583
|
+
if (isEnabledSchedule(pruneSchedule)) {
|
|
331
584
|
cron.schedule(pruneSchedule, () => {
|
|
332
585
|
executeTask(async () => {
|
|
333
586
|
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running prune`);
|
|
@@ -337,7 +590,8 @@ async function daemon(options) {
|
|
|
337
590
|
console.log(`Scheduled prune: ${pruneSchedule}`);
|
|
338
591
|
}
|
|
339
592
|
const checkSchedule = config.rules.maintenance?.check?.schedule;
|
|
340
|
-
|
|
593
|
+
assertValidSchedule(checkSchedule, "maintenance.check.schedule");
|
|
594
|
+
if (isEnabledSchedule(checkSchedule)) {
|
|
341
595
|
cron.schedule(checkSchedule, () => {
|
|
342
596
|
executeTask(async () => {
|
|
343
597
|
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running check`);
|
|
@@ -357,60 +611,98 @@ async function daemon(options) {
|
|
|
357
611
|
|
|
358
612
|
// src/cli.ts
|
|
359
613
|
var program = new Command();
|
|
360
|
-
program.name("way").version("0.6.
|
|
614
|
+
program.name("way").version("0.6.9").description("\u7B56\u7565\u5907\u4EFD\u5DE5\u5177 - \u57FA\u4E8E restic \u7684\u7B56\u7565\u5C01\u88C5").option("--remote <name>", "\u6307\u5B9A\u4ED3\u5E93", "default").addHelpText("after", `
|
|
361
615
|
\u793A\u4F8B:
|
|
362
616
|
$ way backup \u6267\u884C\u6240\u6709\u9879\u76EE\u5907\u4EFD
|
|
363
617
|
$ way backup data \u53EA\u5907\u4EFD data \u9879\u76EE
|
|
364
618
|
$ way backup --dry-run \u6A21\u62DF\u5907\u4EFD\uFF08\u4E0D\u5B9E\u9645\u5199\u5165\uFF09
|
|
365
619
|
$ way restore data --target /tmp/restore --dry-run
|
|
620
|
+
$ way --remote=oss backup \u4F7F\u7528 oss \u4ED3\u5E93\u6267\u884C\u5907\u4EFD\uFF08\u5168\u5C40\u9009\u9879\u9700\u653E\u5728\u5B50\u547D\u4EE4\u524D\uFF09
|
|
621
|
+
$ way init \u521D\u59CB\u5316 way \u914D\u7F6E\u6587\u4EF6
|
|
366
622
|
$ way gc \u6E05\u7406\u65E7\u5FEB\u7167
|
|
367
623
|
$ way systemd install \u5B89\u88C5\u5B9A\u65F6\u4EFB\u52A1
|
|
368
624
|
$ way restic snapshots \u67E5\u770B\u5FEB\u7167\u5217\u8868
|
|
369
625
|
$ way restic restore abc123 --target /tmp/restore
|
|
370
626
|
$ way --remote=s3 restic snapshots \u4F7F\u7528 s3 \u4ED3\u5E93
|
|
371
627
|
|
|
628
|
+
\u73AF\u5883\u53D8\u91CF:
|
|
629
|
+
WAY_DIR=/path/to/config \u6307\u5B9A\u914D\u7F6E\u76EE\u5F55\uFF08\u9ED8\u8BA4: ~/.way\uFF09
|
|
630
|
+
WAY_RESTIC_BIN=/path/restic \u6307\u5B9A restic \u4E8C\u8FDB\u5236\uFF0C\u4F18\u5148\u4E8E\u5185\u7F6E restic \u548C PATH
|
|
631
|
+
|
|
372
632
|
\u6587\u6863: https://github.com/shellus/way
|
|
373
633
|
`);
|
|
374
634
|
function collectBackupArgs(command) {
|
|
375
635
|
return command.args.filter((a) => a.startsWith("-") && !["--dry-run"].includes(a));
|
|
376
636
|
}
|
|
377
|
-
|
|
637
|
+
var commonHelpText = `
|
|
638
|
+
\u5168\u5C40\u7528\u6CD5:
|
|
639
|
+
way --remote=oss <command> ... \u6307\u5B9A\u4ED3\u5E93\uFF08\u5168\u5C40\u9009\u9879\u9700\u653E\u5728\u5B50\u547D\u4EE4\u524D\uFF09
|
|
640
|
+
|
|
641
|
+
\u73AF\u5883\u53D8\u91CF:
|
|
642
|
+
WAY_DIR=/path/to/config \u6307\u5B9A\u914D\u7F6E\u76EE\u5F55\uFF08\u9ED8\u8BA4: ~/.way\uFF09
|
|
643
|
+
WAY_RESTIC_BIN=/path/restic \u6307\u5B9A restic \u4E8C\u8FDB\u5236\uFF0C\u4F18\u5148\u4E8E\u5185\u7F6E restic \u548C PATH
|
|
644
|
+
`;
|
|
645
|
+
program.command("init").description("\u521D\u59CB\u5316 way \u914D\u7F6E\u6587\u4EF6").addHelpText("after", commonHelpText).action(() => {
|
|
646
|
+
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
647
|
+
const files = ["repositories.yaml", "rules.yaml"];
|
|
648
|
+
fs6.mkdirSync(wayDir, { recursive: true });
|
|
649
|
+
for (const file of files) {
|
|
650
|
+
const target = path6.join(wayDir, file);
|
|
651
|
+
if (fs6.existsSync(target)) {
|
|
652
|
+
throw new Error(`${target} already exists, aborting to avoid overwriting existing config.`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
for (const file of files) {
|
|
656
|
+
const source = resolveExampleConfigPath(file);
|
|
657
|
+
const target = path6.join(wayDir, file);
|
|
658
|
+
fs6.copyFileSync(source, target);
|
|
659
|
+
console.log(`Created ${target}`);
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
program.command("backup [projects...]").description("\u6309 rules.yaml \u6267\u884C\u5907\u4EFD").option("--dry-run", "\u6A21\u62DF\u5907\u4EFD\uFF08\u4E0D\u5B9E\u9645\u5199\u5165\uFF09").addHelpText("after", commonHelpText).allowUnknownOption().allowExcessArguments().action(async function(projects) {
|
|
378
663
|
const remote = this.parent.opts().remote;
|
|
379
664
|
const dryRun = this.opts().dryRun;
|
|
380
665
|
const extraArgs = collectBackupArgs(this);
|
|
381
|
-
await backup({
|
|
666
|
+
const result = await backup({
|
|
667
|
+
remote,
|
|
668
|
+
projects: projects.filter((p) => !p.startsWith("-")),
|
|
669
|
+
extraArgs,
|
|
670
|
+
dryRun
|
|
671
|
+
});
|
|
672
|
+
if (result.failed.length > 0) process.exitCode = 1;
|
|
382
673
|
});
|
|
383
|
-
program.command("restore [projects...]").description("\u6309 rules.yaml \u6062\u590D\u9879\u76EE").requiredOption("--target <dir>", "\u6062\u590D\u76EE\u6807\u76EE\u5F55").option("--snapshot <snapshot>", "\u5FEB\u7167 ID \u6216 latest", "latest").option("--dry-run", "\u6A21\u62DF\u6062\u590D\uFF08\u4E0D\u5B9E\u9645\u5199\u5165\uFF09").option("--delete", "\u5220\u9664\u76EE\u6807\u4E2D\u5FEB\u7167\u4E0D\u5B58\u5728\u7684\u6587\u4EF6").option("-v, --verbose", "\u663E\u793A\u8BE6\u7EC6\u6062\u590D\u8BA1\u5212\uFF08\u4F20\u9012 --verbose=2 \u7ED9 restic\uFF09").action(async function(projects, cmdOptions) {
|
|
674
|
+
program.command("restore [projects...]").description("\u6309 rules.yaml \u6062\u590D\u9879\u76EE").requiredOption("--target <dir>", "\u6062\u590D\u76EE\u6807\u76EE\u5F55").option("--snapshot <snapshot>", "\u5FEB\u7167 ID \u6216 latest", "latest").option("--host <host>", "\u53EA\u6062\u590D\u6307\u5B9A host \u7684\u5FEB\u7167").option("--dry-run", "\u6A21\u62DF\u6062\u590D\uFF08\u4E0D\u5B9E\u9645\u5199\u5165\uFF09").option("--delete", "\u5220\u9664\u76EE\u6807\u4E2D\u5FEB\u7167\u4E0D\u5B58\u5728\u7684\u6587\u4EF6").option("-v, --verbose", "\u663E\u793A\u8BE6\u7EC6\u6062\u590D\u8BA1\u5212\uFF08\u4F20\u9012 --verbose=2 \u7ED9 restic\uFF09").addHelpText("after", commonHelpText).action(async function(projects, cmdOptions) {
|
|
384
675
|
const remote = this.parent.opts().remote;
|
|
385
676
|
await restore({
|
|
386
677
|
remote,
|
|
387
678
|
projects,
|
|
388
679
|
target: cmdOptions.target,
|
|
389
680
|
snapshot: cmdOptions.snapshot,
|
|
681
|
+
host: cmdOptions.host,
|
|
390
682
|
dryRun: cmdOptions.dryRun,
|
|
391
683
|
delete: cmdOptions.delete,
|
|
392
684
|
verbose: cmdOptions.verbose
|
|
393
685
|
});
|
|
394
686
|
});
|
|
395
|
-
program.command("gc").description("\u6E05\u7406\u65E7\u5FEB\u7167").option("--dry-run", "\u6A21\u62DF\u6E05\u7406\uFF08\u4E0D\u5B9E\u9645\u5220\u9664\uFF09").action(async function(cmdOptions) {
|
|
687
|
+
program.command("gc").description("\u6E05\u7406\u65E7\u5FEB\u7167").option("--dry-run", "\u6A21\u62DF\u6E05\u7406\uFF08\u4E0D\u5B9E\u9645\u5220\u9664\uFF09").addHelpText("after", commonHelpText).action(async function(cmdOptions) {
|
|
396
688
|
const remote = this.parent.opts().remote;
|
|
397
689
|
await gc({ remote, dryRun: cmdOptions.dryRun });
|
|
398
690
|
});
|
|
399
|
-
program.command("systemd <action>").description("\u7BA1\u7406 systemd \u5B9A\u65F6\u4EFB\u52A1 (show|install|uninstall|status)").action(async (action, options, command) => {
|
|
691
|
+
program.command("systemd <action>").description("\u7BA1\u7406 systemd \u5B9A\u65F6\u4EFB\u52A1 (show|install|uninstall|status)").addHelpText("after", commonHelpText).action(async (action, options, command) => {
|
|
400
692
|
const remote = command.parent.opts().remote;
|
|
401
693
|
await systemd({ remote, action });
|
|
402
694
|
});
|
|
403
|
-
program.command("daemon").description("\u542F\u52A8\u5E38\u9A7B\u8FDB\u7A0B\uFF0C\u6309\u914D\u7F6E\u5B9A\u65F6\u6267\u884C\u5907\u4EFD").action(async (options, command) => {
|
|
695
|
+
program.command("daemon").description("\u542F\u52A8\u5E38\u9A7B\u8FDB\u7A0B\uFF0C\u6309\u914D\u7F6E\u5B9A\u65F6\u6267\u884C\u5907\u4EFD").addHelpText("after", commonHelpText).action(async (options, command) => {
|
|
404
696
|
const remote = command.parent.opts().remote;
|
|
405
697
|
await daemon({ remote });
|
|
406
698
|
});
|
|
407
|
-
program.command("env").description("\u663E\u793A\u73AF\u5883\u53D8\u91CF").action(() => {
|
|
699
|
+
program.command("env").description("\u663E\u793A\u73AF\u5883\u53D8\u91CF").addHelpText("after", commonHelpText).action(() => {
|
|
408
700
|
const env = Object.entries(process.env).sort(([a], [b]) => a.localeCompare(b));
|
|
409
701
|
for (const [key, value] of env) {
|
|
410
702
|
console.log(`${key}=${value}`);
|
|
411
703
|
}
|
|
412
704
|
});
|
|
413
|
-
program.command("restic [args...]").description("\u663E\u5F0F\u900F\u4F20\u7ED9 restic").allowUnknownOption().allowExcessArguments().action(async function(args) {
|
|
705
|
+
program.command("restic [args...]").description("\u663E\u5F0F\u900F\u4F20\u7ED9 restic").addHelpText("after", commonHelpText).allowUnknownOption().allowExcessArguments().action(async function(args) {
|
|
414
706
|
const remote = this.parent.opts().remote;
|
|
415
707
|
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
416
708
|
const config = loadConfig(wayDir, remote);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shellus/way",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.9",
|
|
4
4
|
"description": "将备份作为持续运营的项目,而非一次性任务。基于 restic 的策略封装。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"build": "tsup src/cli.ts --format esm --clean",
|
|
11
|
+
"build:standalone": "bun build src/cli.ts --compile --target=bun-linux-x64-baseline --outfile dist/standalone/way",
|
|
12
|
+
"package:linux-x64": "npm run build:standalone && node scripts/package-release.mjs",
|
|
11
13
|
"dev": "tsup src/cli.ts --format esm --watch",
|
|
12
14
|
"test": "vitest",
|
|
13
15
|
"test:run": "vitest run",
|
package/rules.yaml.example
CHANGED
|
@@ -5,19 +5,20 @@
|
|
|
5
5
|
# 全局默认配置
|
|
6
6
|
defaults:
|
|
7
7
|
# schedule 使用 node-cron 语法(分 时 日 月 周)
|
|
8
|
+
# 设置为 false 表示不自动调度,只允许手动执行 way backup
|
|
8
9
|
# 支持多种表达方式:
|
|
9
10
|
# "0 */2 * * *" - 每 2 小时
|
|
10
11
|
# "0 9,15,21 * * *" - 每天 9:00、15:00、21:00
|
|
11
12
|
# "0 9-17 * * 1-5" - 工作日 9:00-17:00 每小时
|
|
12
|
-
schedule:
|
|
13
|
+
schedule: false
|
|
13
14
|
retention:
|
|
14
15
|
keep_daily: 7
|
|
15
16
|
keep_weekly: 4
|
|
16
17
|
keep_monthly: 6
|
|
17
18
|
|
|
18
|
-
# Uptime Kuma
|
|
19
|
+
# Uptime Kuma 全局通知(可选,未配置项目级地址时作为回退)
|
|
19
20
|
uptime_kuma:
|
|
20
|
-
push_url: "https://uptime.example.com/api/push/
|
|
21
|
+
push_url: "https://uptime.example.com/api/push/global-placeholder"
|
|
21
22
|
|
|
22
23
|
# 备份项目
|
|
23
24
|
projects:
|
|
@@ -25,6 +26,17 @@ projects:
|
|
|
25
26
|
description: 数据目录
|
|
26
27
|
paths:
|
|
27
28
|
- /path/to/data
|
|
29
|
+
schedule: "0 */2 * * *" # 每 2 小时自动备份
|
|
30
|
+
# 项目级地址优先于全局地址;相同地址的项目会合并为一条通知
|
|
31
|
+
uptime_kuma:
|
|
32
|
+
push_url: "https://uptime.example.com/api/push/data-placeholder"
|
|
33
|
+
hooks:
|
|
34
|
+
before_backup:
|
|
35
|
+
- run: /path/to/scripts/prepare-data-backup.sh
|
|
36
|
+
timeout: "10m"
|
|
37
|
+
after_backup:
|
|
38
|
+
- run: /path/to/scripts/verify-data-backup.sh
|
|
39
|
+
timeout: "5m"
|
|
28
40
|
excludes:
|
|
29
41
|
- cache
|
|
30
42
|
- tmp
|
|
@@ -41,13 +53,22 @@ projects:
|
|
|
41
53
|
description: 历史归档
|
|
42
54
|
paths:
|
|
43
55
|
- /archive
|
|
44
|
-
schedule:
|
|
56
|
+
schedule: false # 只手动触发:way backup archive
|
|
45
57
|
retention:
|
|
46
58
|
keep_weekly: 8
|
|
47
59
|
keep_monthly: 12
|
|
48
60
|
keep_yearly: 3
|
|
49
61
|
excludes: []
|
|
50
62
|
|
|
63
|
+
data_deps:
|
|
64
|
+
description: 手动备份 /path/to/data 下所有依赖目录
|
|
65
|
+
paths:
|
|
66
|
+
- /path/to/data
|
|
67
|
+
include_dirs:
|
|
68
|
+
- www/xhj/*/node_modules
|
|
69
|
+
- www/xhj/*/vendor
|
|
70
|
+
schedule: false # 只手动触发:way backup data_deps
|
|
71
|
+
|
|
51
72
|
# 全局排除规则 - 适用于所有项目
|
|
52
73
|
global_excludes:
|
|
53
74
|
# 包管理
|
|
@@ -79,4 +100,4 @@ maintenance:
|
|
|
79
100
|
prune:
|
|
80
101
|
schedule: "0 4 * * 0" # 每周日凌晨 4 点清理
|
|
81
102
|
check:
|
|
82
|
-
schedule:
|
|
103
|
+
schedule: false # 不执行
|