@shellus/way 0.6.1 → 0.6.10
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 +113 -15
- package/dist/cli.js +350 -26
- 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. 配置定时备份
|
|
@@ -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,62 @@ 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
|
+
Linux 上的 hook 达到 `timeout` 后,Way 会终止对应 shell 的完整进程组;进程未在宽限期内退出时会继续强制终止,避免数据库导出、压缩或同步子进程脱离 Way 后持续运行。hook 仍应保持幂等,并自行清理失败时生成的临时文件。
|
|
246
|
+
|
|
247
|
+
**项目级 Uptime Kuma 通知示例**:
|
|
248
|
+
|
|
249
|
+
```yaml
|
|
250
|
+
uptime_kuma:
|
|
251
|
+
push_url: "https://uptime.example.com/api/push/global-placeholder"
|
|
252
|
+
|
|
253
|
+
projects:
|
|
254
|
+
data:
|
|
255
|
+
paths: [/data]
|
|
256
|
+
uptime_kuma:
|
|
257
|
+
push_url: "https://uptime.example.com/api/push/data-placeholder"
|
|
258
|
+
|
|
259
|
+
root:
|
|
260
|
+
paths: [/root]
|
|
261
|
+
uptime_kuma:
|
|
262
|
+
push_url: "https://uptime.example.com/api/push/system-placeholder"
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
每个项目优先使用项目级 Push 地址,未配置时回退到全局地址。一次 `way backup` 中使用相同有效地址的项目只发送一条汇总通知;同组任一项目失败时状态为 DOWN,不同地址的通知互不覆盖。`--dry-run` 不发送通知,通知请求失败只记录错误,不改变备份结果。
|
|
266
|
+
|
|
206
267
|
**项目级调度示例**:
|
|
207
268
|
|
|
208
269
|
```yaml
|
|
209
270
|
defaults:
|
|
210
|
-
schedule:
|
|
271
|
+
schedule: false # 默认不自动调度
|
|
211
272
|
|
|
212
273
|
projects:
|
|
213
274
|
data:
|
|
214
275
|
paths: [/data]
|
|
215
|
-
#
|
|
276
|
+
schedule: "0 */2 * * *" # 每 2 小时
|
|
216
277
|
|
|
217
278
|
logs:
|
|
218
279
|
paths: [/var/log]
|
|
@@ -220,20 +281,57 @@ projects:
|
|
|
220
281
|
|
|
221
282
|
archive:
|
|
222
283
|
paths: [/archive]
|
|
223
|
-
schedule:
|
|
284
|
+
schedule: false # 只手动触发:way backup archive
|
|
224
285
|
retention:
|
|
225
286
|
keep_weekly: 8
|
|
226
287
|
keep_monthly: 12
|
|
227
288
|
```
|
|
228
289
|
|
|
290
|
+
**手动备份匹配目录示例**:
|
|
291
|
+
|
|
292
|
+
日常备份可排除可重建依赖目录,避免遍历海量小文件:
|
|
293
|
+
|
|
294
|
+
```yaml
|
|
295
|
+
projects:
|
|
296
|
+
data:
|
|
297
|
+
paths: [/data]
|
|
298
|
+
excludes:
|
|
299
|
+
- node_modules
|
|
300
|
+
- vendor
|
|
301
|
+
schedule: "0 */2 * * *"
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
如果需要偶尔保留依赖目录,可单独配置只手动触发的项目:
|
|
305
|
+
|
|
306
|
+
```yaml
|
|
307
|
+
projects:
|
|
308
|
+
data_deps:
|
|
309
|
+
paths: [/data]
|
|
310
|
+
include_dirs:
|
|
311
|
+
- www/xhj/*/node_modules
|
|
312
|
+
- www/xhj/*/vendor
|
|
313
|
+
schedule: false
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
手动执行:
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
way backup data_deps
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
`include_dirs` 是相对 `paths` 的目录 glob,只支持路径段中的 `*`,不支持绝对路径和 `**`。命中目录后,`way` 会把该目录作为备份根路径写入临时 `--files-from` 列表。`include_dirs` 是专用模式,不应用 `global_excludes`,也不能同时配置项目级 `excludes`。
|
|
323
|
+
|
|
229
324
|
**schedule 语法**(node-cron 格式):
|
|
230
325
|
|
|
326
|
+
`schedule` 支持 node-cron 字符串或 `false`。`false` 表示不创建自动调度任务,只能通过 `way backup <project>` 或 `way backup` 手动触发。项目未设置 `schedule` 时继承 `defaults.schedule`;如果全局和项目都未设置,则不自动调度。
|
|
327
|
+
|
|
231
328
|
| 格式 | 说明 | 示例 |
|
|
232
329
|
|------|------|------|
|
|
233
330
|
| `"0 */2 * * *"` | 间隔表达式 | 每 2 小时 |
|
|
234
331
|
| `"0 9,15,21 * * *"` | 多个时间点(逗号) | 每天 9:00、15:00、21:00 |
|
|
235
332
|
| `"0 9-17 * * 1-5"` | 时间范围 | 工作日 9:00-17:00 每小时 |
|
|
236
333
|
| `"*/30 * * * *"` | 分钟间隔 | 每 30 分钟 |
|
|
334
|
+
| `false` | 禁用自动调度 | 只手动备份 |
|
|
237
335
|
|
|
238
336
|
#### 排除规则通配符语法
|
|
239
337
|
|
|
@@ -364,4 +462,4 @@ way --remote=oss restic restore <snapshot-id> --target /tmp/restore
|
|
|
364
462
|
- 变更规范和测试要求
|
|
365
463
|
- 发布流程
|
|
366
464
|
|
|
367
|
-
|
|
465
|
+
项目维护规则:[AGENTS.md](AGENTS.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,13 +109,75 @@ 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",
|
|
@@ -88,7 +186,7 @@ function buildRestoreArgs(name, project, options) {
|
|
|
88
186
|
];
|
|
89
187
|
if (options.host) args.push(`--host=${options.host}`);
|
|
90
188
|
args.push(`--target=${options.target}`);
|
|
91
|
-
for (const
|
|
189
|
+
for (const path7 of project.paths) args.push(`--include=${path7}`);
|
|
92
190
|
if (options.dryRun) args.push("--dry-run");
|
|
93
191
|
if (options.delete) args.push("--delete");
|
|
94
192
|
if (options.verbose) args.push("--verbose=2");
|
|
@@ -115,6 +213,10 @@ async function execRestic(args, env, s3Options = []) {
|
|
|
115
213
|
}
|
|
116
214
|
|
|
117
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";
|
|
118
220
|
async function backup(options) {
|
|
119
221
|
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
120
222
|
const config = loadConfig(wayDir, options.remote);
|
|
@@ -126,35 +228,182 @@ async function backup(options) {
|
|
|
126
228
|
const extraArgs = options.extraArgs?.filter((arg) => arg !== "--dry-run") || [];
|
|
127
229
|
const succeeded = [];
|
|
128
230
|
const failed = [];
|
|
231
|
+
const projectResults = [];
|
|
129
232
|
const startTime = Date.now();
|
|
130
233
|
for (const projectName of projects) {
|
|
234
|
+
const projectStartTime = Date.now();
|
|
131
235
|
const project = config.rules.projects[projectName];
|
|
236
|
+
const pushUrl = resolveUptimeKumaPushUrl(project, config.rules.uptime_kuma?.push_url);
|
|
132
237
|
if (!project) {
|
|
133
238
|
console.error(`Project not found: ${projectName}`);
|
|
134
239
|
failed.push(projectName);
|
|
240
|
+
projectResults.push({
|
|
241
|
+
succeeded: [],
|
|
242
|
+
failed: [projectName],
|
|
243
|
+
duration: Date.now() - projectStartTime,
|
|
244
|
+
pushUrl
|
|
245
|
+
});
|
|
135
246
|
continue;
|
|
136
247
|
}
|
|
137
248
|
console.log(`=== Backing up: ${projectName} ===`);
|
|
249
|
+
let filesFrom;
|
|
250
|
+
let projectSucceeded = false;
|
|
138
251
|
try {
|
|
139
|
-
|
|
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);
|
|
140
272
|
if (dryRun) args.push("--dry-run");
|
|
141
273
|
args.push(...extraArgs);
|
|
142
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
|
+
});
|
|
143
282
|
succeeded.push(projectName);
|
|
283
|
+
projectSucceeded = true;
|
|
144
284
|
} catch (error) {
|
|
145
285
|
console.error(`Failed to backup ${projectName}:`, error);
|
|
146
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
|
+
});
|
|
147
295
|
}
|
|
148
296
|
}
|
|
149
297
|
const duration = Date.now() - startTime;
|
|
150
298
|
console.log("\n=== Summary ===");
|
|
151
299
|
if (succeeded.length > 0) console.log("Succeeded:", succeeded.join(", "));
|
|
152
300
|
if (failed.length > 0) console.log("Failed:", failed.join(", "));
|
|
153
|
-
if (!dryRun
|
|
154
|
-
|
|
301
|
+
if (!dryRun) {
|
|
302
|
+
for (const [pushUrl, result] of groupUptimeKumaResults(projectResults)) {
|
|
303
|
+
await notifyUptimeKuma(result, pushUrl);
|
|
304
|
+
}
|
|
155
305
|
}
|
|
156
306
|
return { succeeded, failed, duration };
|
|
157
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
|
+
var HOOK_FORCE_KILL_DELAY_MS = 1e3;
|
|
328
|
+
function normalizeHook(hook) {
|
|
329
|
+
if (typeof hook === "string") return { run: hook };
|
|
330
|
+
return hook;
|
|
331
|
+
}
|
|
332
|
+
function parseTimeout(timeout) {
|
|
333
|
+
if (timeout === void 0) return void 0;
|
|
334
|
+
if (typeof timeout === "number") return timeout;
|
|
335
|
+
const match = timeout.match(/^(\d+)(ms|s|m|h)?$/);
|
|
336
|
+
if (!match) throw new Error(`Invalid hook timeout: ${timeout}`);
|
|
337
|
+
const value = Number(match[1]);
|
|
338
|
+
const unit = match[2] || "ms";
|
|
339
|
+
switch (unit) {
|
|
340
|
+
case "ms":
|
|
341
|
+
return value;
|
|
342
|
+
case "s":
|
|
343
|
+
return value * 1e3;
|
|
344
|
+
case "m":
|
|
345
|
+
return value * 60 * 1e3;
|
|
346
|
+
case "h":
|
|
347
|
+
return value * 60 * 60 * 1e3;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function isMissingProcessError(error) {
|
|
351
|
+
return error instanceof Error && "code" in error && error.code === "ESRCH";
|
|
352
|
+
}
|
|
353
|
+
function signalProcessGroup(pid, signal) {
|
|
354
|
+
try {
|
|
355
|
+
process.kill(-pid, signal);
|
|
356
|
+
return true;
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if (isMissingProcessError(error)) return false;
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function hookTimedOut(error) {
|
|
363
|
+
return typeof error === "object" && error !== null && "timedOut" in error && error.timedOut === true;
|
|
364
|
+
}
|
|
365
|
+
async function terminateProcessGroup(pid) {
|
|
366
|
+
if (!signalProcessGroup(pid, "SIGTERM")) return;
|
|
367
|
+
await new Promise((resolve) => setTimeout(resolve, HOOK_FORCE_KILL_DELAY_MS));
|
|
368
|
+
signalProcessGroup(pid, "SIGKILL");
|
|
369
|
+
}
|
|
370
|
+
async function runProjectHooks(hooks, context) {
|
|
371
|
+
if (!hooks?.length) return;
|
|
372
|
+
for (const hook of hooks) {
|
|
373
|
+
const normalized = normalizeHook(hook);
|
|
374
|
+
if (!normalized.run) throw new Error(`Project ${context.projectName} has an empty ${context.label} hook`);
|
|
375
|
+
if (context.dryRun) {
|
|
376
|
+
console.log(`[dry-run] ${context.label}: ${normalized.run}`);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
console.log(`Running ${context.label} hook for ${context.projectName}: ${normalized.run}`);
|
|
380
|
+
const subprocess = execaCommand(normalized.run, {
|
|
381
|
+
shell: true,
|
|
382
|
+
stdio: "inherit",
|
|
383
|
+
timeout: parseTimeout(normalized.timeout),
|
|
384
|
+
detached: process.platform !== "win32",
|
|
385
|
+
env: {
|
|
386
|
+
WAY_PROJECT: context.projectName,
|
|
387
|
+
WAY_REMOTE: context.remote,
|
|
388
|
+
WAY_DIR: context.wayDir,
|
|
389
|
+
WAY_DRY_RUN: context.dryRun ? "1" : "0"
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
const processGroupPid = process.platform === "win32" ? void 0 : subprocess.pid;
|
|
393
|
+
const cleanupOnExit = processGroupPid === void 0 ? void 0 : () => signalProcessGroup(processGroupPid, "SIGTERM");
|
|
394
|
+
if (cleanupOnExit) process.once("exit", cleanupOnExit);
|
|
395
|
+
try {
|
|
396
|
+
await subprocess;
|
|
397
|
+
} catch (error) {
|
|
398
|
+
if (processGroupPid !== void 0 && hookTimedOut(error)) {
|
|
399
|
+
await terminateProcessGroup(processGroupPid);
|
|
400
|
+
}
|
|
401
|
+
throw error;
|
|
402
|
+
} finally {
|
|
403
|
+
if (cleanupOnExit) process.removeListener("exit", cleanupOnExit);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
158
407
|
async function notifyUptimeKuma(result, pushUrl) {
|
|
159
408
|
const status = result.failed.length > 0 ? "down" : "up";
|
|
160
409
|
const msg = `Succeeded: ${result.succeeded.length}, Failed: ${result.failed.length}`;
|
|
@@ -240,12 +489,22 @@ async function gc(options) {
|
|
|
240
489
|
|
|
241
490
|
// src/commands/systemd.ts
|
|
242
491
|
import { execSync } from "child_process";
|
|
243
|
-
import
|
|
244
|
-
import
|
|
492
|
+
import fs5 from "fs";
|
|
493
|
+
import path5 from "path";
|
|
494
|
+
function resolveWayCommandPath(options = {}) {
|
|
495
|
+
const env = options.env ?? process.env;
|
|
496
|
+
const argv = options.argv ?? process.argv;
|
|
497
|
+
const execPath = options.execPath ?? process.execPath;
|
|
498
|
+
const whichWay = options.whichWay ?? (() => execSync("which way", { encoding: "utf-8" }).trim());
|
|
499
|
+
if (env.WAY_BIN) return env.WAY_BIN;
|
|
500
|
+
if (path5.basename(execPath) === "way") return execPath;
|
|
501
|
+
if (argv[1] && path5.isAbsolute(argv[1])) return argv[1];
|
|
502
|
+
return whichWay();
|
|
503
|
+
}
|
|
245
504
|
async function systemd(options) {
|
|
246
505
|
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
247
506
|
const config = loadConfig(wayDir, options.remote);
|
|
248
|
-
const wayPath =
|
|
507
|
+
const wayPath = resolveWayCommandPath();
|
|
249
508
|
const currentUser = execSync("whoami", { encoding: "utf-8" }).trim();
|
|
250
509
|
const serviceContent = `[Unit]
|
|
251
510
|
Description=Way Backup Daemon
|
|
@@ -269,9 +528,9 @@ WantedBy=multi-user.target
|
|
|
269
528
|
return;
|
|
270
529
|
}
|
|
271
530
|
const systemdDir = "/etc/systemd/system";
|
|
272
|
-
const servicePath =
|
|
531
|
+
const servicePath = path5.join(systemdDir, "way-backup.service");
|
|
273
532
|
if (options.action === "install") {
|
|
274
|
-
|
|
533
|
+
fs5.writeFileSync(servicePath, serviceContent);
|
|
275
534
|
execSync("systemctl daemon-reload");
|
|
276
535
|
execSync("systemctl enable way-backup.service");
|
|
277
536
|
execSync("systemctl start way-backup.service");
|
|
@@ -287,7 +546,7 @@ WantedBy=multi-user.target
|
|
|
287
546
|
execSync("systemctl disable way-backup.service", { stdio: "ignore" });
|
|
288
547
|
} catch {
|
|
289
548
|
}
|
|
290
|
-
if (
|
|
549
|
+
if (fs5.existsSync(servicePath)) fs5.unlinkSync(servicePath);
|
|
291
550
|
execSync("systemctl daemon-reload");
|
|
292
551
|
console.log("Systemd service uninstalled");
|
|
293
552
|
}
|
|
@@ -300,6 +559,23 @@ WantedBy=multi-user.target
|
|
|
300
559
|
import cron from "node-cron";
|
|
301
560
|
var isRunning = false;
|
|
302
561
|
var taskQueue = [];
|
|
562
|
+
function resolveProjectSchedule(project, defaults = {}) {
|
|
563
|
+
if (project.schedule !== void 0) return project.schedule;
|
|
564
|
+
return defaults?.schedule ?? false;
|
|
565
|
+
}
|
|
566
|
+
function isEnabledSchedule(schedule) {
|
|
567
|
+
return typeof schedule === "string";
|
|
568
|
+
}
|
|
569
|
+
function assertValidSchedule(schedule, label) {
|
|
570
|
+
if (schedule === "") {
|
|
571
|
+
throw new Error(`${label} must be a cron expression or false. Use false to disable scheduling.`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
function addScheduledBackup(scheduledBackups, schedule, projectName) {
|
|
575
|
+
const projects = scheduledBackups.get(schedule) || [];
|
|
576
|
+
projects.push(projectName);
|
|
577
|
+
scheduledBackups.set(schedule, projects);
|
|
578
|
+
}
|
|
303
579
|
async function executeTask(task) {
|
|
304
580
|
taskQueue.push(task);
|
|
305
581
|
if (isRunning) return;
|
|
@@ -318,18 +594,28 @@ async function daemon(options) {
|
|
|
318
594
|
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
319
595
|
const config = loadConfig(wayDir, options.remote);
|
|
320
596
|
console.log("Way daemon started");
|
|
597
|
+
const scheduledBackups = /* @__PURE__ */ new Map();
|
|
321
598
|
for (const [name, project] of Object.entries(config.rules.projects)) {
|
|
322
|
-
const schedule = project
|
|
599
|
+
const schedule = resolveProjectSchedule(project, config.rules.defaults);
|
|
600
|
+
assertValidSchedule(schedule, `projects.${name}.schedule`);
|
|
601
|
+
if (!isEnabledSchedule(schedule)) {
|
|
602
|
+
console.log(`Skipped scheduled backup for ${name}`);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
addScheduledBackup(scheduledBackups, schedule, name);
|
|
606
|
+
}
|
|
607
|
+
for (const [schedule, projects] of scheduledBackups.entries()) {
|
|
323
608
|
cron.schedule(schedule, () => {
|
|
324
609
|
executeTask(async () => {
|
|
325
|
-
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running backup: ${
|
|
326
|
-
await backup({ remote: options.remote, projects
|
|
610
|
+
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running backup: ${projects.join(", ")}`);
|
|
611
|
+
await backup({ remote: options.remote, projects });
|
|
327
612
|
});
|
|
328
613
|
});
|
|
329
|
-
console.log(`Scheduled backup for ${
|
|
614
|
+
console.log(`Scheduled backup for ${projects.join(", ")}: ${schedule}`);
|
|
330
615
|
}
|
|
331
616
|
const pruneSchedule = config.rules.maintenance?.prune?.schedule;
|
|
332
|
-
|
|
617
|
+
assertValidSchedule(pruneSchedule, "maintenance.prune.schedule");
|
|
618
|
+
if (isEnabledSchedule(pruneSchedule)) {
|
|
333
619
|
cron.schedule(pruneSchedule, () => {
|
|
334
620
|
executeTask(async () => {
|
|
335
621
|
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running prune`);
|
|
@@ -339,7 +625,8 @@ async function daemon(options) {
|
|
|
339
625
|
console.log(`Scheduled prune: ${pruneSchedule}`);
|
|
340
626
|
}
|
|
341
627
|
const checkSchedule = config.rules.maintenance?.check?.schedule;
|
|
342
|
-
|
|
628
|
+
assertValidSchedule(checkSchedule, "maintenance.check.schedule");
|
|
629
|
+
if (isEnabledSchedule(checkSchedule)) {
|
|
343
630
|
cron.schedule(checkSchedule, () => {
|
|
344
631
|
executeTask(async () => {
|
|
345
632
|
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running check`);
|
|
@@ -359,30 +646,67 @@ async function daemon(options) {
|
|
|
359
646
|
|
|
360
647
|
// src/cli.ts
|
|
361
648
|
var program = new Command();
|
|
362
|
-
program.name("way").version("0.6.
|
|
649
|
+
program.name("way").version("0.6.10").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", `
|
|
363
650
|
\u793A\u4F8B:
|
|
364
651
|
$ way backup \u6267\u884C\u6240\u6709\u9879\u76EE\u5907\u4EFD
|
|
365
652
|
$ way backup data \u53EA\u5907\u4EFD data \u9879\u76EE
|
|
366
653
|
$ way backup --dry-run \u6A21\u62DF\u5907\u4EFD\uFF08\u4E0D\u5B9E\u9645\u5199\u5165\uFF09
|
|
367
654
|
$ way restore data --target /tmp/restore --dry-run
|
|
655
|
+
$ 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
|
|
656
|
+
$ way init \u521D\u59CB\u5316 way \u914D\u7F6E\u6587\u4EF6
|
|
368
657
|
$ way gc \u6E05\u7406\u65E7\u5FEB\u7167
|
|
369
658
|
$ way systemd install \u5B89\u88C5\u5B9A\u65F6\u4EFB\u52A1
|
|
370
659
|
$ way restic snapshots \u67E5\u770B\u5FEB\u7167\u5217\u8868
|
|
371
660
|
$ way restic restore abc123 --target /tmp/restore
|
|
372
661
|
$ way --remote=s3 restic snapshots \u4F7F\u7528 s3 \u4ED3\u5E93
|
|
373
662
|
|
|
663
|
+
\u73AF\u5883\u53D8\u91CF:
|
|
664
|
+
WAY_DIR=/path/to/config \u6307\u5B9A\u914D\u7F6E\u76EE\u5F55\uFF08\u9ED8\u8BA4: ~/.way\uFF09
|
|
665
|
+
WAY_RESTIC_BIN=/path/restic \u6307\u5B9A restic \u4E8C\u8FDB\u5236\uFF0C\u4F18\u5148\u4E8E\u5185\u7F6E restic \u548C PATH
|
|
666
|
+
|
|
374
667
|
\u6587\u6863: https://github.com/shellus/way
|
|
375
668
|
`);
|
|
376
669
|
function collectBackupArgs(command) {
|
|
377
670
|
return command.args.filter((a) => a.startsWith("-") && !["--dry-run"].includes(a));
|
|
378
671
|
}
|
|
379
|
-
|
|
672
|
+
var commonHelpText = `
|
|
673
|
+
\u5168\u5C40\u7528\u6CD5:
|
|
674
|
+
way --remote=oss <command> ... \u6307\u5B9A\u4ED3\u5E93\uFF08\u5168\u5C40\u9009\u9879\u9700\u653E\u5728\u5B50\u547D\u4EE4\u524D\uFF09
|
|
675
|
+
|
|
676
|
+
\u73AF\u5883\u53D8\u91CF:
|
|
677
|
+
WAY_DIR=/path/to/config \u6307\u5B9A\u914D\u7F6E\u76EE\u5F55\uFF08\u9ED8\u8BA4: ~/.way\uFF09
|
|
678
|
+
WAY_RESTIC_BIN=/path/restic \u6307\u5B9A restic \u4E8C\u8FDB\u5236\uFF0C\u4F18\u5148\u4E8E\u5185\u7F6E restic \u548C PATH
|
|
679
|
+
`;
|
|
680
|
+
program.command("init").description("\u521D\u59CB\u5316 way \u914D\u7F6E\u6587\u4EF6").addHelpText("after", commonHelpText).action(() => {
|
|
681
|
+
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
682
|
+
const files = ["repositories.yaml", "rules.yaml"];
|
|
683
|
+
fs6.mkdirSync(wayDir, { recursive: true });
|
|
684
|
+
for (const file of files) {
|
|
685
|
+
const target = path6.join(wayDir, file);
|
|
686
|
+
if (fs6.existsSync(target)) {
|
|
687
|
+
throw new Error(`${target} already exists, aborting to avoid overwriting existing config.`);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
for (const file of files) {
|
|
691
|
+
const source = resolveExampleConfigPath(file);
|
|
692
|
+
const target = path6.join(wayDir, file);
|
|
693
|
+
fs6.copyFileSync(source, target);
|
|
694
|
+
console.log(`Created ${target}`);
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
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) {
|
|
380
698
|
const remote = this.parent.opts().remote;
|
|
381
699
|
const dryRun = this.opts().dryRun;
|
|
382
700
|
const extraArgs = collectBackupArgs(this);
|
|
383
|
-
await backup({
|
|
701
|
+
const result = await backup({
|
|
702
|
+
remote,
|
|
703
|
+
projects: projects.filter((p) => !p.startsWith("-")),
|
|
704
|
+
extraArgs,
|
|
705
|
+
dryRun
|
|
706
|
+
});
|
|
707
|
+
if (result.failed.length > 0) process.exitCode = 1;
|
|
384
708
|
});
|
|
385
|
-
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").action(async function(projects, cmdOptions) {
|
|
709
|
+
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) {
|
|
386
710
|
const remote = this.parent.opts().remote;
|
|
387
711
|
await restore({
|
|
388
712
|
remote,
|
|
@@ -395,25 +719,25 @@ program.command("restore [projects...]").description("\u6309 rules.yaml \u6062\u
|
|
|
395
719
|
verbose: cmdOptions.verbose
|
|
396
720
|
});
|
|
397
721
|
});
|
|
398
|
-
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) {
|
|
722
|
+
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) {
|
|
399
723
|
const remote = this.parent.opts().remote;
|
|
400
724
|
await gc({ remote, dryRun: cmdOptions.dryRun });
|
|
401
725
|
});
|
|
402
|
-
program.command("systemd <action>").description("\u7BA1\u7406 systemd \u5B9A\u65F6\u4EFB\u52A1 (show|install|uninstall|status)").action(async (action, options, command) => {
|
|
726
|
+
program.command("systemd <action>").description("\u7BA1\u7406 systemd \u5B9A\u65F6\u4EFB\u52A1 (show|install|uninstall|status)").addHelpText("after", commonHelpText).action(async (action, options, command) => {
|
|
403
727
|
const remote = command.parent.opts().remote;
|
|
404
728
|
await systemd({ remote, action });
|
|
405
729
|
});
|
|
406
|
-
program.command("daemon").description("\u542F\u52A8\u5E38\u9A7B\u8FDB\u7A0B\uFF0C\u6309\u914D\u7F6E\u5B9A\u65F6\u6267\u884C\u5907\u4EFD").action(async (options, command) => {
|
|
730
|
+
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) => {
|
|
407
731
|
const remote = command.parent.opts().remote;
|
|
408
732
|
await daemon({ remote });
|
|
409
733
|
});
|
|
410
|
-
program.command("env").description("\u663E\u793A\u73AF\u5883\u53D8\u91CF").action(() => {
|
|
734
|
+
program.command("env").description("\u663E\u793A\u73AF\u5883\u53D8\u91CF").addHelpText("after", commonHelpText).action(() => {
|
|
411
735
|
const env = Object.entries(process.env).sort(([a], [b]) => a.localeCompare(b));
|
|
412
736
|
for (const [key, value] of env) {
|
|
413
737
|
console.log(`${key}=${value}`);
|
|
414
738
|
}
|
|
415
739
|
});
|
|
416
|
-
program.command("restic [args...]").description("\u663E\u5F0F\u900F\u4F20\u7ED9 restic").allowUnknownOption().allowExcessArguments().action(async function(args) {
|
|
740
|
+
program.command("restic [args...]").description("\u663E\u5F0F\u900F\u4F20\u7ED9 restic").addHelpText("after", commonHelpText).allowUnknownOption().allowExcessArguments().action(async function(args) {
|
|
417
741
|
const remote = this.parent.opts().remote;
|
|
418
742
|
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
419
743
|
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.10",
|
|
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 # 不执行
|