@shellus/way 0.6.10 → 0.6.11
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 +42 -1
- package/dist/cli.js +319 -43
- package/package.json +9 -11
- package/rules.yaml.example +4 -0
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
## 依赖
|
|
69
69
|
|
|
70
70
|
- Linux x64 独立发行包无需预装 Node.js、npm、Bun 或 restic
|
|
71
|
-
- npm 安装方式需要 Node.js >=
|
|
71
|
+
- npm 安装方式需要 Node.js >= 22.12
|
|
72
72
|
- Linux x64 平台内置 [restic](https://restic.net/) 0.18.1,其他平台需自行安装 restic
|
|
73
73
|
|
|
74
74
|
`way` 查找 restic 的顺序:
|
|
@@ -137,6 +137,15 @@ way systemd install
|
|
|
137
137
|
way systemd status
|
|
138
138
|
```
|
|
139
139
|
|
|
140
|
+
Windows 使用系统自带的任务计划程序守护 `way daemon`,不依赖 NSSM 或其他常驻工具:
|
|
141
|
+
|
|
142
|
+
```powershell
|
|
143
|
+
way windows-service install
|
|
144
|
+
way windows-service status
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`windows-service install` 会创建以 `SYSTEM` 身份在开机后运行的原生任务,并配置失败重启;注册和删除任务需要在提升权限的 PowerShell 中执行。任务仅守护 daemon,实际备份频率仍由 `rules.yaml` 的项目级 `schedule` 决定。
|
|
148
|
+
|
|
140
149
|
### 4. 手动执行备份
|
|
141
150
|
|
|
142
151
|
```bash
|
|
@@ -169,6 +178,12 @@ way systemd show # 显示 systemd 配置
|
|
|
169
178
|
way systemd status # 查看服务状态
|
|
170
179
|
way systemd uninstall # 卸载服务
|
|
171
180
|
|
|
181
|
+
# Windows 原生守护任务管理
|
|
182
|
+
way windows-service install # 注册开机启动、SYSTEM 身份运行的 daemon 守护任务
|
|
183
|
+
way windows-service show # 显示将要写入的任务与启动脚本
|
|
184
|
+
way windows-service status # 查看任务状态
|
|
185
|
+
way windows-service uninstall # 删除任务与启动脚本
|
|
186
|
+
|
|
172
187
|
# 显式透传 restic(way 只设置环境变量)
|
|
173
188
|
way restic snapshots # → restic snapshots
|
|
174
189
|
way restic check # → restic check
|
|
@@ -193,6 +208,7 @@ graph LR
|
|
|
193
208
|
|
|
194
209
|
G[systemd service] --> H[启动 daemon]
|
|
195
210
|
H --> I[进程崩溃自动重启]
|
|
211
|
+
J[Windows Task Scheduler] --> H
|
|
196
212
|
```
|
|
197
213
|
|
|
198
214
|
---
|
|
@@ -224,6 +240,27 @@ WAY_DIR=/path/to/config way restic snapshots
|
|
|
224
240
|
- **maintenance**: 维护任务配置(prune、check)
|
|
225
241
|
- **global_excludes**: 全局排除规则
|
|
226
242
|
|
|
243
|
+
`defaults.retention` 支持两种互斥模式:
|
|
244
|
+
|
|
245
|
+
- 计数保留:`keep_daily`、`keep_weekly`、`keep_monthly`、`keep_yearly`,按 restic 分组规则保留周期快照。
|
|
246
|
+
- 严格范围保留:`keep_hosts` 与 `max_age_days` 必须同时设置。Way 以执行时的当前时间计算截止点,只保留白名单主机在最近指定天数内的快照;其他快照使用明确 ID 删除。该模式不会使用 restic 相对最新快照计算的 `keep-within` 语义。
|
|
247
|
+
|
|
248
|
+
两种模式不得混用。严格范围保留示例:
|
|
249
|
+
|
|
250
|
+
```yaml
|
|
251
|
+
defaults:
|
|
252
|
+
retention:
|
|
253
|
+
keep_hosts: [backup-host]
|
|
254
|
+
max_age_days: 7
|
|
255
|
+
|
|
256
|
+
maintenance:
|
|
257
|
+
prune:
|
|
258
|
+
schedule: "0 4 * * *"
|
|
259
|
+
retry_lock: "30m"
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
`maintenance.prune.retry_lock` 会作为 restic 全局 `--retry-lock` 参数传递。正式启用严格范围保留前必须先执行 `way gc --dry-run`,核对输出的保留和删除快照列表。
|
|
263
|
+
|
|
227
264
|
项目级钩子用于在 restic 备份前后执行一致性快照、校验或清理脚本:
|
|
228
265
|
|
|
229
266
|
```yaml
|
|
@@ -325,6 +362,8 @@ way backup data_deps
|
|
|
325
362
|
|
|
326
363
|
`schedule` 支持 node-cron 字符串或 `false`。`false` 表示不创建自动调度任务,只能通过 `way backup <project>` 或 `way backup` 手动触发。项目未设置 `schedule` 时继承 `defaults.schedule`;如果全局和项目都未设置,则不自动调度。
|
|
327
364
|
|
|
365
|
+
daemon 的调度心跳最多容忍 5 秒延迟;事件循环短暂阻塞后仍会补执行本轮任务,超过窗口则由 node-cron 报告为 missed execution。
|
|
366
|
+
|
|
328
367
|
| 格式 | 说明 | 示例 |
|
|
329
368
|
|------|------|------|
|
|
330
369
|
| `"0 */2 * * *"` | 间隔表达式 | 每 2 小时 |
|
|
@@ -361,6 +400,8 @@ repositories:
|
|
|
361
400
|
|
|
362
401
|
建议设置文件权限:`chmod 600 ~/.way/repositories.yaml`
|
|
363
402
|
|
|
403
|
+
`repositories.yaml` 和 `rules.yaml` 支持 YAML merge anchor(`<<`);Way 使用 YAML 1.2 Core Schema 并显式启用 merge tag,不会同时启用 YAML 1.1 的日期自动转换等额外类型。
|
|
404
|
+
|
|
364
405
|
## 配置备份
|
|
365
406
|
|
|
366
407
|
`~/.way/` 目录包含所有配置和凭证,建议整体备份到安全位置:
|
package/dist/cli.js
CHANGED
|
@@ -2,22 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import
|
|
6
|
-
import
|
|
5
|
+
import fs7 from "fs";
|
|
6
|
+
import path8 from "path";
|
|
7
7
|
|
|
8
8
|
// src/core/config.ts
|
|
9
9
|
import fs from "fs";
|
|
10
10
|
import path from "path";
|
|
11
|
-
import yaml from "js-yaml";
|
|
11
|
+
import * as yaml from "js-yaml";
|
|
12
|
+
var configSchema = yaml.CORE_SCHEMA.withTags(yaml.mergeTag);
|
|
12
13
|
function loadConfig(wayDir, remoteName) {
|
|
13
14
|
const repoFile = path.join(wayDir, "repositories.yaml");
|
|
14
|
-
const repoConfig = yaml.load(fs.readFileSync(repoFile, "utf8"));
|
|
15
|
+
const repoConfig = yaml.load(fs.readFileSync(repoFile, "utf8"), { schema: configSchema });
|
|
15
16
|
const repoName = remoteName === "default" ? repoConfig.default : remoteName;
|
|
16
17
|
const repository = repoConfig.repositories[repoName];
|
|
17
18
|
if (!repository) throw new Error(`Repository not found: ${repoName}`);
|
|
18
19
|
const rulesFile = path.join(wayDir, "rules.yaml");
|
|
19
|
-
const rules = yaml.load(fs.readFileSync(rulesFile, "utf8"));
|
|
20
|
-
|
|
20
|
+
const rules = yaml.load(fs.readFileSync(rulesFile, "utf8"), { schema: configSchema });
|
|
21
|
+
const legacySchedule = "schedule" in rules ? rules.schedule : void 0;
|
|
22
|
+
if (typeof legacySchedule === "object" && legacySchedule !== null && "backup" in legacySchedule) {
|
|
21
23
|
throw new Error("\u68C0\u6D4B\u5230 v0.4.x \u65E7\u914D\u7F6E\u683C\u5F0F\uFF0C\u8BF7\u8FD0\u884C\u8FC1\u79FB\u811A\u672C\uFF1Anpx --yes @shellus/way@latest migrate-to-v0.5.sh \u6216\u624B\u52A8\u53C2\u8003 rules.yaml.example \u66F4\u65B0\u914D\u7F6E");
|
|
22
24
|
}
|
|
23
25
|
return { repository, rules };
|
|
@@ -178,6 +180,12 @@ function collectIncludeDirs(paths, includeDirs, options = {}) {
|
|
|
178
180
|
}
|
|
179
181
|
return Array.from(new Set(matches));
|
|
180
182
|
}
|
|
183
|
+
function normalizeResticPath(value, platform = process.platform) {
|
|
184
|
+
if (platform !== "win32") return value;
|
|
185
|
+
const match = value.match(/^([A-Za-z]):[\\/](.*)$/);
|
|
186
|
+
if (!match) return value.replace(/\\/g, "/");
|
|
187
|
+
return `/${match[1].toUpperCase()}/${match[2].replace(/\\/g, "/")}`;
|
|
188
|
+
}
|
|
181
189
|
function buildRestoreArgs(name, project, options) {
|
|
182
190
|
const args = [
|
|
183
191
|
"restore",
|
|
@@ -186,7 +194,7 @@ function buildRestoreArgs(name, project, options) {
|
|
|
186
194
|
];
|
|
187
195
|
if (options.host) args.push(`--host=${options.host}`);
|
|
188
196
|
args.push(`--target=${options.target}`);
|
|
189
|
-
for (const
|
|
197
|
+
for (const path9 of options.includePaths || project.paths) args.push(`--include=${normalizeResticPath(path9, options.platform)}`);
|
|
190
198
|
if (options.dryRun) args.push("--dry-run");
|
|
191
199
|
if (options.delete) args.push("--delete");
|
|
192
200
|
if (options.verbose) args.push("--verbose=2");
|
|
@@ -200,8 +208,18 @@ function buildS3Options(repo) {
|
|
|
200
208
|
return options;
|
|
201
209
|
}
|
|
202
210
|
async function execRestic(args, env, s3Options = []) {
|
|
211
|
+
await runRestic(args, env, s3Options, false);
|
|
212
|
+
}
|
|
213
|
+
async function execResticCapture(args, env, s3Options = []) {
|
|
214
|
+
return runRestic(args, env, s3Options, true);
|
|
215
|
+
}
|
|
216
|
+
async function runRestic(args, env, s3Options, capture) {
|
|
203
217
|
try {
|
|
204
|
-
await execa(resolveResticBin(), [...s3Options, ...args], {
|
|
218
|
+
const result = await execa(resolveResticBin(), [...s3Options, ...args], {
|
|
219
|
+
env: { ...process.env, ...env },
|
|
220
|
+
...capture ? { stdout: "pipe", stderr: "inherit" } : { stdio: "inherit" }
|
|
221
|
+
});
|
|
222
|
+
return typeof result.stdout === "string" ? result.stdout : "";
|
|
205
223
|
} catch (error) {
|
|
206
224
|
if (error.code === "ENOENT") {
|
|
207
225
|
console.error("Error: restic not found. Linux x64 packages include restic; other platforms must install it first.");
|
|
@@ -216,7 +234,7 @@ async function execRestic(args, env, s3Options = []) {
|
|
|
216
234
|
import fs4 from "fs";
|
|
217
235
|
import os from "os";
|
|
218
236
|
import path4 from "path";
|
|
219
|
-
import {
|
|
237
|
+
import { execa as execa2 } from "execa";
|
|
220
238
|
async function backup(options) {
|
|
221
239
|
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
222
240
|
const config = loadConfig(wayDir, options.remote);
|
|
@@ -377,7 +395,7 @@ async function runProjectHooks(hooks, context) {
|
|
|
377
395
|
continue;
|
|
378
396
|
}
|
|
379
397
|
console.log(`Running ${context.label} hook for ${context.projectName}: ${normalized.run}`);
|
|
380
|
-
const subprocess =
|
|
398
|
+
const subprocess = execa2(normalized.run, {
|
|
381
399
|
shell: true,
|
|
382
400
|
stdio: "inherit",
|
|
383
401
|
timeout: parseTimeout(normalized.timeout),
|
|
@@ -421,8 +439,46 @@ async function notifyUptimeKuma(result, pushUrl) {
|
|
|
421
439
|
}
|
|
422
440
|
|
|
423
441
|
// src/commands/restore.ts
|
|
442
|
+
import path5 from "path";
|
|
443
|
+
function isWithin(parent, child, pathApi) {
|
|
444
|
+
const relative = pathApi.relative(parent, child);
|
|
445
|
+
return relative === "" || !relative.startsWith("..") && !pathApi.isAbsolute(relative);
|
|
446
|
+
}
|
|
447
|
+
function findCommonParent(paths, pathApi) {
|
|
448
|
+
let candidate = paths[0];
|
|
449
|
+
while (!paths.every((item) => isWithin(candidate, item, pathApi))) {
|
|
450
|
+
const parent = pathApi.dirname(candidate);
|
|
451
|
+
if (parent === candidate) return candidate;
|
|
452
|
+
candidate = parent;
|
|
453
|
+
}
|
|
454
|
+
return candidate;
|
|
455
|
+
}
|
|
456
|
+
function buildWindowsRestorePlans(project, target, snapshot = "latest") {
|
|
457
|
+
const pathApi = path5.win32;
|
|
458
|
+
const groups = /* @__PURE__ */ new Map();
|
|
459
|
+
for (const sourcePath of project.paths) {
|
|
460
|
+
const root = pathApi.parse(sourcePath).root.toLowerCase();
|
|
461
|
+
const paths = groups.get(root) || [];
|
|
462
|
+
paths.push(sourcePath);
|
|
463
|
+
groups.set(root, paths);
|
|
464
|
+
}
|
|
465
|
+
return Array.from(groups.values()).map((paths) => {
|
|
466
|
+
const parent = findCommonParent(paths.map((item) => pathApi.dirname(item)), pathApi);
|
|
467
|
+
const drive = parent[0].toUpperCase();
|
|
468
|
+
const rest = parent.slice(2).replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
469
|
+
const snapshotPath = rest ? `/${drive}/${rest}` : `/${drive}`;
|
|
470
|
+
const includePaths = paths.map((item) => `/${pathApi.relative(parent, item).replace(/\\/g, "/")}`);
|
|
471
|
+
const targetPath = pathApi.join(target, ...snapshotPath.split("/").filter(Boolean));
|
|
472
|
+
return {
|
|
473
|
+
snapshot: `${snapshot}:${snapshotPath}`,
|
|
474
|
+
target: targetPath,
|
|
475
|
+
includePaths: Array.from(new Set(includePaths))
|
|
476
|
+
};
|
|
477
|
+
});
|
|
478
|
+
}
|
|
424
479
|
async function restore(options) {
|
|
425
480
|
if (!options.target) throw new Error("--target is required");
|
|
481
|
+
const target = options.target;
|
|
426
482
|
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
427
483
|
const config = loadConfig(wayDir, options.remote);
|
|
428
484
|
const env = buildResticEnv(config.repository);
|
|
@@ -440,15 +496,19 @@ async function restore(options) {
|
|
|
440
496
|
}
|
|
441
497
|
console.log(`=== Restoring: ${projectName} ===`);
|
|
442
498
|
try {
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
499
|
+
const plans = process.platform === "win32" && project.paths.every((item) => /^[A-Za-z]:[\\/]/.test(item)) ? buildWindowsRestorePlans(project, target, options.snapshot) : [{ snapshot: options.snapshot, target, includePaths: void 0 }];
|
|
500
|
+
for (const plan of plans) {
|
|
501
|
+
const args = buildRestoreArgs(projectName, project, {
|
|
502
|
+
target: plan.target,
|
|
503
|
+
snapshot: plan.snapshot,
|
|
504
|
+
host: options.host,
|
|
505
|
+
dryRun: options.dryRun,
|
|
506
|
+
delete: options.delete,
|
|
507
|
+
verbose: options.verbose,
|
|
508
|
+
includePaths: plan.includePaths
|
|
509
|
+
});
|
|
510
|
+
await execRestic(args, env, s3Options);
|
|
511
|
+
}
|
|
452
512
|
succeeded.push(projectName);
|
|
453
513
|
} catch (error) {
|
|
454
514
|
console.error(`Failed to restore ${projectName}:`, error);
|
|
@@ -463,18 +523,101 @@ async function restore(options) {
|
|
|
463
523
|
}
|
|
464
524
|
|
|
465
525
|
// src/commands/gc.ts
|
|
526
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
527
|
+
function hasLegacyPolicy(retention) {
|
|
528
|
+
return [
|
|
529
|
+
retention.keep_daily,
|
|
530
|
+
retention.keep_weekly,
|
|
531
|
+
retention.keep_monthly,
|
|
532
|
+
retention.keep_yearly
|
|
533
|
+
].some((value) => value !== void 0);
|
|
534
|
+
}
|
|
535
|
+
function isStrictPolicy(retention) {
|
|
536
|
+
return retention.keep_hosts !== void 0 || retention.max_age_days !== void 0;
|
|
537
|
+
}
|
|
538
|
+
function validateStrictPolicy(retention) {
|
|
539
|
+
if (!retention.keep_hosts?.length) {
|
|
540
|
+
throw new Error("Strict retention requires at least one defaults.retention.keep_hosts entry");
|
|
541
|
+
}
|
|
542
|
+
if (!Number.isFinite(retention.max_age_days) || retention.max_age_days <= 0) {
|
|
543
|
+
throw new Error("Strict retention requires defaults.retention.max_age_days to be a positive number");
|
|
544
|
+
}
|
|
545
|
+
if (hasLegacyPolicy(retention)) {
|
|
546
|
+
throw new Error("Strict keep_hosts/max_age_days retention cannot be combined with count-based keep_* retention");
|
|
547
|
+
}
|
|
548
|
+
const normalizedHosts = retention.keep_hosts.map((host) => host.trim());
|
|
549
|
+
if (normalizedHosts.some((host) => host.length === 0)) {
|
|
550
|
+
throw new Error("defaults.retention.keep_hosts cannot contain empty host names");
|
|
551
|
+
}
|
|
552
|
+
return { hosts: new Set(normalizedHosts), maxAgeDays: retention.max_age_days };
|
|
553
|
+
}
|
|
554
|
+
function buildSnapshotRetentionPlan(snapshots, retention, now = /* @__PURE__ */ new Date()) {
|
|
555
|
+
const { hosts, maxAgeDays } = validateStrictPolicy(retention);
|
|
556
|
+
const cutoff = new Date(now.getTime() - maxAgeDays * DAY_MS);
|
|
557
|
+
const keep = [];
|
|
558
|
+
const remove = [];
|
|
559
|
+
for (const snapshot of snapshots) {
|
|
560
|
+
const snapshotTime = new Date(snapshot.time);
|
|
561
|
+
if (Number.isNaN(snapshotTime.getTime())) {
|
|
562
|
+
throw new Error(`Snapshot ${snapshot.id} has an invalid timestamp: ${snapshot.time}`);
|
|
563
|
+
}
|
|
564
|
+
if (hosts.has(snapshot.hostname) && snapshotTime >= cutoff) {
|
|
565
|
+
keep.push(snapshot);
|
|
566
|
+
} else {
|
|
567
|
+
remove.push(snapshot);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return { cutoff, keep, remove };
|
|
571
|
+
}
|
|
572
|
+
function printSnapshots(label, snapshots) {
|
|
573
|
+
console.log(`${label} (${snapshots.length}):`);
|
|
574
|
+
for (const snapshot of snapshots) {
|
|
575
|
+
console.log(` ${snapshot.id.slice(0, 8)} ${snapshot.time} ${snapshot.hostname} ${(snapshot.tags || []).join(",") || "-"}`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
function buildResticOptions(s3Options, retryLock) {
|
|
579
|
+
const options = [...s3Options];
|
|
580
|
+
if (retryLock) options.push(`--retry-lock=${retryLock}`);
|
|
581
|
+
return options;
|
|
582
|
+
}
|
|
583
|
+
async function runStrictGc(retention, dryRun, env, resticOptions) {
|
|
584
|
+
const snapshotsJson = await execResticCapture(["snapshots", "--json", "--no-lock"], env, resticOptions);
|
|
585
|
+
const snapshots = JSON.parse(snapshotsJson);
|
|
586
|
+
if (!Array.isArray(snapshots)) throw new Error("restic snapshots --json did not return an array");
|
|
587
|
+
const plan = buildSnapshotRetentionPlan(snapshots, retention);
|
|
588
|
+
console.log(`Policy: keep hosts=${retention.keep_hosts.join(",")}, max age=${retention.max_age_days} days, cutoff=${plan.cutoff.toISOString()}`);
|
|
589
|
+
printSnapshots("Keep snapshots", plan.keep);
|
|
590
|
+
printSnapshots("Remove snapshots", plan.remove);
|
|
591
|
+
if (dryRun) {
|
|
592
|
+
console.log("Dry-run complete; no snapshots or repository data were modified.");
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (plan.remove.length === 0) {
|
|
596
|
+
console.log("No snapshots matched cleanup; prune was skipped.");
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
await execRestic(["forget", "--prune", ...plan.remove.map((snapshot) => snapshot.id)], env, resticOptions);
|
|
600
|
+
}
|
|
466
601
|
async function gc(options) {
|
|
467
602
|
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
468
603
|
const config = loadConfig(wayDir, options.remote);
|
|
469
604
|
const retention = config.rules.defaults?.retention || {};
|
|
605
|
+
const dryRun = options.dryRun || false;
|
|
606
|
+
console.log("=== Cleaning snapshots ===");
|
|
607
|
+
const env = buildResticEnv(config.repository);
|
|
608
|
+
const resticOptions = buildResticOptions(
|
|
609
|
+
buildS3Options(config.repository),
|
|
610
|
+
config.rules.maintenance?.prune?.retry_lock
|
|
611
|
+
);
|
|
612
|
+
if (isStrictPolicy(retention)) {
|
|
613
|
+
await runStrictGc(retention, dryRun, env, resticOptions);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
470
616
|
const keepDaily = retention.keep_daily || 7;
|
|
471
617
|
const keepWeekly = retention.keep_weekly || 4;
|
|
472
618
|
const keepMonthly = retention.keep_monthly || 6;
|
|
473
619
|
const keepYearly = retention.keep_yearly;
|
|
474
|
-
console.log("=== Cleaning snapshots ===");
|
|
475
620
|
console.log(`Policy: daily=${keepDaily}, weekly=${keepWeekly}, monthly=${keepMonthly}${keepYearly ? `, yearly=${keepYearly}` : ""}`);
|
|
476
|
-
const env = buildResticEnv(config.repository);
|
|
477
|
-
const s3Options = buildS3Options(config.repository);
|
|
478
621
|
const args = [
|
|
479
622
|
"forget",
|
|
480
623
|
"--prune",
|
|
@@ -483,22 +626,22 @@ async function gc(options) {
|
|
|
483
626
|
`--keep-monthly=${keepMonthly}`
|
|
484
627
|
];
|
|
485
628
|
if (keepYearly) args.push(`--keep-yearly=${keepYearly}`);
|
|
486
|
-
if (
|
|
487
|
-
await execRestic(args, env,
|
|
629
|
+
if (dryRun) args.push("--dry-run");
|
|
630
|
+
await execRestic(args, env, resticOptions);
|
|
488
631
|
}
|
|
489
632
|
|
|
490
633
|
// src/commands/systemd.ts
|
|
491
634
|
import { execSync } from "child_process";
|
|
492
635
|
import fs5 from "fs";
|
|
493
|
-
import
|
|
636
|
+
import path6 from "path";
|
|
494
637
|
function resolveWayCommandPath(options = {}) {
|
|
495
638
|
const env = options.env ?? process.env;
|
|
496
639
|
const argv = options.argv ?? process.argv;
|
|
497
640
|
const execPath = options.execPath ?? process.execPath;
|
|
498
641
|
const whichWay = options.whichWay ?? (() => execSync("which way", { encoding: "utf-8" }).trim());
|
|
499
642
|
if (env.WAY_BIN) return env.WAY_BIN;
|
|
500
|
-
if (
|
|
501
|
-
if (argv[1] &&
|
|
643
|
+
if (path6.basename(execPath) === "way") return execPath;
|
|
644
|
+
if (argv[1] && path6.isAbsolute(argv[1])) return argv[1];
|
|
502
645
|
return whichWay();
|
|
503
646
|
}
|
|
504
647
|
async function systemd(options) {
|
|
@@ -528,7 +671,7 @@ WantedBy=multi-user.target
|
|
|
528
671
|
return;
|
|
529
672
|
}
|
|
530
673
|
const systemdDir = "/etc/systemd/system";
|
|
531
|
-
const servicePath =
|
|
674
|
+
const servicePath = path6.join(systemdDir, "way-backup.service");
|
|
532
675
|
if (options.action === "install") {
|
|
533
676
|
fs5.writeFileSync(servicePath, serviceContent);
|
|
534
677
|
execSync("systemctl daemon-reload");
|
|
@@ -555,10 +698,136 @@ WantedBy=multi-user.target
|
|
|
555
698
|
}
|
|
556
699
|
}
|
|
557
700
|
|
|
701
|
+
// src/commands/windows-service.ts
|
|
702
|
+
import { execFileSync } from "child_process";
|
|
703
|
+
import fs6 from "fs";
|
|
704
|
+
import os2 from "os";
|
|
705
|
+
import path7 from "path";
|
|
706
|
+
var WINDOWS_TASK_NAME = "Way Backup Daemon";
|
|
707
|
+
var WINDOWS_RUNNER_NAME = "way-daemon.cmd";
|
|
708
|
+
function assertWindows(platform) {
|
|
709
|
+
if (platform !== "win32") throw new Error("windows-service is only available on Windows");
|
|
710
|
+
}
|
|
711
|
+
function assertSafeCmdValue(value, name) {
|
|
712
|
+
if (/[\r\n\0]/.test(value)) throw new Error(`${name} cannot contain line breaks or NUL bytes`);
|
|
713
|
+
}
|
|
714
|
+
function xmlEscape(value) {
|
|
715
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
716
|
+
}
|
|
717
|
+
function resolveDaemonLaunch(options = {}) {
|
|
718
|
+
const env = options.env ?? process.env;
|
|
719
|
+
const argv = options.argv ?? process.argv;
|
|
720
|
+
const execPath = options.execPath ?? process.execPath;
|
|
721
|
+
if (env.WAY_BIN) return { command: env.WAY_BIN, args: ["daemon"] };
|
|
722
|
+
if (argv[1] && (path7.isAbsolute(argv[1]) || path7.win32.isAbsolute(argv[1]))) {
|
|
723
|
+
return { command: execPath, args: [argv[1], "daemon"] };
|
|
724
|
+
}
|
|
725
|
+
return { command: execPath, args: ["daemon"] };
|
|
726
|
+
}
|
|
727
|
+
function renderWindowsDaemonRunner(wayDir, launch, resticBin) {
|
|
728
|
+
for (const [name, value] of Object.entries({ wayDir, command: launch.command, resticBin })) {
|
|
729
|
+
if (value) assertSafeCmdValue(value, name);
|
|
730
|
+
}
|
|
731
|
+
for (const arg of launch.args) assertSafeCmdValue(arg, "daemon argument");
|
|
732
|
+
const setRestic = resticBin ? `set "WAY_RESTIC_BIN=${resticBin}"\r
|
|
733
|
+
` : "";
|
|
734
|
+
const args = launch.args.map((arg) => `"${arg.replace(/"/g, '""')}"`).join(" ");
|
|
735
|
+
const command = `"${launch.command.replace(/"/g, '""')}"`;
|
|
736
|
+
return `@echo off\r
|
|
737
|
+
setlocal\r
|
|
738
|
+
set "WAY_DIR=${wayDir}"\r
|
|
739
|
+
${setRestic}${command} ${args}\r
|
|
740
|
+
`;
|
|
741
|
+
}
|
|
742
|
+
function renderWindowsTaskXml(runnerPath) {
|
|
743
|
+
assertSafeCmdValue(runnerPath, "runner path");
|
|
744
|
+
const argumentsValue = `/d /s /c ""${runnerPath}""`;
|
|
745
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
746
|
+
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
747
|
+
<RegistrationInfo><Description>Way Backup Daemon</Description></RegistrationInfo>
|
|
748
|
+
<Triggers><BootTrigger><Enabled>true</Enabled><Delay>PT30S</Delay></BootTrigger></Triggers>
|
|
749
|
+
<Principals><Principal id="Author"><UserId>S-1-5-18</UserId><RunLevel>HighestAvailable</RunLevel></Principal></Principals>
|
|
750
|
+
<Settings>
|
|
751
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
752
|
+
<AllowHardTerminate>true</AllowHardTerminate><StartWhenAvailable>true</StartWhenAvailable><RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
753
|
+
<AllowStartOnDemand>true</AllowStartOnDemand><Enabled>true</Enabled><Hidden>true</Hidden><RunOnlyIfIdle>false</RunOnlyIfIdle><WakeToRun>false</WakeToRun>
|
|
754
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit><Priority>7</Priority><RestartOnFailure><Interval>PT1M</Interval><Count>3</Count></RestartOnFailure>
|
|
755
|
+
</Settings>
|
|
756
|
+
<Actions Context="Author"><Exec><Command>cmd.exe</Command><Arguments>${xmlEscape(argumentsValue)}</Arguments></Exec></Actions>
|
|
757
|
+
</Task>`;
|
|
758
|
+
}
|
|
759
|
+
function resolveResticPath(env, run) {
|
|
760
|
+
if (env.WAY_RESTIC_BIN) return env.WAY_RESTIC_BIN;
|
|
761
|
+
try {
|
|
762
|
+
return run("where.exe", ["restic.exe"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim().split(/\r?\n/)[0];
|
|
763
|
+
} catch {
|
|
764
|
+
return void 0;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
async function windowsService(options, dependencies = {}) {
|
|
768
|
+
const platform = dependencies.platform ?? process.platform;
|
|
769
|
+
assertWindows(platform);
|
|
770
|
+
const env = dependencies.env ?? process.env;
|
|
771
|
+
const run = dependencies.execFileSync ?? execFileSync;
|
|
772
|
+
const writeFile = dependencies.writeFileSync ?? fs6.writeFileSync;
|
|
773
|
+
const unlink = dependencies.unlinkSync ?? fs6.unlinkSync;
|
|
774
|
+
const exists = dependencies.existsSync ?? fs6.existsSync;
|
|
775
|
+
const makeTempDir = dependencies.mkdtempSync ?? fs6.mkdtempSync;
|
|
776
|
+
const wayDir = env.WAY_DIR || path7.join(os2.homedir(), ".way");
|
|
777
|
+
const runnerPath = path7.join(wayDir, WINDOWS_RUNNER_NAME);
|
|
778
|
+
const launch = resolveDaemonLaunch({ env, argv: dependencies.argv, execPath: dependencies.execPath });
|
|
779
|
+
const runner = renderWindowsDaemonRunner(wayDir, launch, resolveResticPath(env, run));
|
|
780
|
+
const taskXml = renderWindowsTaskXml(runnerPath);
|
|
781
|
+
if (options.action === "show") {
|
|
782
|
+
console.log(`=== ${WINDOWS_TASK_NAME} ===`);
|
|
783
|
+
console.log(taskXml);
|
|
784
|
+
console.log(`=== ${runnerPath} ===`);
|
|
785
|
+
console.log(runner);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (options.action === "install") {
|
|
789
|
+
loadConfig(wayDir, options.remote);
|
|
790
|
+
fs6.mkdirSync(wayDir, { recursive: true });
|
|
791
|
+
writeFile(runnerPath, runner, { encoding: "utf8" });
|
|
792
|
+
const tempDir = makeTempDir(path7.join(os2.tmpdir(), "way-windows-service-"));
|
|
793
|
+
const xmlPath = path7.join(tempDir, "way-backup.xml");
|
|
794
|
+
try {
|
|
795
|
+
writeFile(xmlPath, `\uFEFF${taskXml}`, { encoding: "utf16le" });
|
|
796
|
+
run("schtasks.exe", ["/Create", "/TN", WINDOWS_TASK_NAME, "/XML", xmlPath, "/F"], { stdio: "inherit" });
|
|
797
|
+
run("schtasks.exe", ["/Run", "/TN", WINDOWS_TASK_NAME], { stdio: "inherit" });
|
|
798
|
+
console.log("Windows Way daemon task installed and started");
|
|
799
|
+
} finally {
|
|
800
|
+
if (exists(xmlPath)) unlink(xmlPath);
|
|
801
|
+
try {
|
|
802
|
+
fs6.rmdirSync(tempDir);
|
|
803
|
+
} catch {
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
if (options.action === "uninstall") {
|
|
809
|
+
try {
|
|
810
|
+
run("schtasks.exe", ["/End", "/TN", WINDOWS_TASK_NAME], { stdio: "ignore" });
|
|
811
|
+
} catch {
|
|
812
|
+
}
|
|
813
|
+
try {
|
|
814
|
+
run("schtasks.exe", ["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"], { stdio: "inherit" });
|
|
815
|
+
} catch {
|
|
816
|
+
}
|
|
817
|
+
if (exists(runnerPath)) unlink(runnerPath);
|
|
818
|
+
console.log("Windows Way daemon task uninstalled");
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
run("schtasks.exe", ["/Query", "/TN", WINDOWS_TASK_NAME, "/FO", "LIST", "/V"], { stdio: "inherit" });
|
|
822
|
+
}
|
|
823
|
+
|
|
558
824
|
// src/commands/daemon.ts
|
|
559
825
|
import cron from "node-cron";
|
|
560
826
|
var isRunning = false;
|
|
561
827
|
var taskQueue = [];
|
|
828
|
+
var CRON_OPTIONS = {
|
|
829
|
+
missedExecutionTolerance: 5e3
|
|
830
|
+
};
|
|
562
831
|
function resolveProjectSchedule(project, defaults = {}) {
|
|
563
832
|
if (project.schedule !== void 0) return project.schedule;
|
|
564
833
|
return defaults?.schedule ?? false;
|
|
@@ -610,7 +879,7 @@ async function daemon(options) {
|
|
|
610
879
|
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running backup: ${projects.join(", ")}`);
|
|
611
880
|
await backup({ remote: options.remote, projects });
|
|
612
881
|
});
|
|
613
|
-
});
|
|
882
|
+
}, CRON_OPTIONS);
|
|
614
883
|
console.log(`Scheduled backup for ${projects.join(", ")}: ${schedule}`);
|
|
615
884
|
}
|
|
616
885
|
const pruneSchedule = config.rules.maintenance?.prune?.schedule;
|
|
@@ -621,7 +890,7 @@ async function daemon(options) {
|
|
|
621
890
|
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running prune`);
|
|
622
891
|
await gc({ remote: options.remote, dryRun: false });
|
|
623
892
|
});
|
|
624
|
-
});
|
|
893
|
+
}, CRON_OPTIONS);
|
|
625
894
|
console.log(`Scheduled prune: ${pruneSchedule}`);
|
|
626
895
|
}
|
|
627
896
|
const checkSchedule = config.rules.maintenance?.check?.schedule;
|
|
@@ -631,7 +900,7 @@ async function daemon(options) {
|
|
|
631
900
|
executeTask(async () => {
|
|
632
901
|
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running check`);
|
|
633
902
|
});
|
|
634
|
-
});
|
|
903
|
+
}, CRON_OPTIONS);
|
|
635
904
|
console.log(`Scheduled check: ${checkSchedule}`);
|
|
636
905
|
}
|
|
637
906
|
process.on("SIGTERM", () => {
|
|
@@ -646,7 +915,7 @@ async function daemon(options) {
|
|
|
646
915
|
|
|
647
916
|
// src/cli.ts
|
|
648
917
|
var program = new Command();
|
|
649
|
-
program.name("way").version("0.6.
|
|
918
|
+
program.name("way").version("0.6.11").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", `
|
|
650
919
|
\u793A\u4F8B:
|
|
651
920
|
$ way backup \u6267\u884C\u6240\u6709\u9879\u76EE\u5907\u4EFD
|
|
652
921
|
$ way backup data \u53EA\u5907\u4EFD data \u9879\u76EE
|
|
@@ -669,6 +938,9 @@ program.name("way").version("0.6.10").description("\u7B56\u7565\u5907\u4EFD\u5DE
|
|
|
669
938
|
function collectBackupArgs(command) {
|
|
670
939
|
return command.args.filter((a) => a.startsWith("-") && !["--dry-run"].includes(a));
|
|
671
940
|
}
|
|
941
|
+
function resolveRemote(command) {
|
|
942
|
+
return command.optsWithGlobals().remote;
|
|
943
|
+
}
|
|
672
944
|
var commonHelpText = `
|
|
673
945
|
\u5168\u5C40\u7528\u6CD5:
|
|
674
946
|
way --remote=oss <command> ... \u6307\u5B9A\u4ED3\u5E93\uFF08\u5168\u5C40\u9009\u9879\u9700\u653E\u5728\u5B50\u547D\u4EE4\u524D\uFF09
|
|
@@ -680,22 +952,22 @@ var commonHelpText = `
|
|
|
680
952
|
program.command("init").description("\u521D\u59CB\u5316 way \u914D\u7F6E\u6587\u4EF6").addHelpText("after", commonHelpText).action(() => {
|
|
681
953
|
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
682
954
|
const files = ["repositories.yaml", "rules.yaml"];
|
|
683
|
-
|
|
955
|
+
fs7.mkdirSync(wayDir, { recursive: true });
|
|
684
956
|
for (const file of files) {
|
|
685
|
-
const target =
|
|
686
|
-
if (
|
|
957
|
+
const target = path8.join(wayDir, file);
|
|
958
|
+
if (fs7.existsSync(target)) {
|
|
687
959
|
throw new Error(`${target} already exists, aborting to avoid overwriting existing config.`);
|
|
688
960
|
}
|
|
689
961
|
}
|
|
690
962
|
for (const file of files) {
|
|
691
963
|
const source = resolveExampleConfigPath(file);
|
|
692
|
-
const target =
|
|
693
|
-
|
|
964
|
+
const target = path8.join(wayDir, file);
|
|
965
|
+
fs7.copyFileSync(source, target);
|
|
694
966
|
console.log(`Created ${target}`);
|
|
695
967
|
}
|
|
696
968
|
});
|
|
697
969
|
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) {
|
|
698
|
-
const remote = this
|
|
970
|
+
const remote = resolveRemote(this);
|
|
699
971
|
const dryRun = this.opts().dryRun;
|
|
700
972
|
const extraArgs = collectBackupArgs(this);
|
|
701
973
|
const result = await backup({
|
|
@@ -707,7 +979,7 @@ program.command("backup [projects...]").description("\u6309 rules.yaml \u6267\u8
|
|
|
707
979
|
if (result.failed.length > 0) process.exitCode = 1;
|
|
708
980
|
});
|
|
709
981
|
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) {
|
|
710
|
-
const remote = this
|
|
982
|
+
const remote = resolveRemote(this);
|
|
711
983
|
await restore({
|
|
712
984
|
remote,
|
|
713
985
|
projects,
|
|
@@ -720,15 +992,19 @@ program.command("restore [projects...]").description("\u6309 rules.yaml \u6062\u
|
|
|
720
992
|
});
|
|
721
993
|
});
|
|
722
994
|
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) {
|
|
723
|
-
const remote = this
|
|
995
|
+
const remote = resolveRemote(this);
|
|
724
996
|
await gc({ remote, dryRun: cmdOptions.dryRun });
|
|
725
997
|
});
|
|
726
998
|
program.command("systemd <action>").description("\u7BA1\u7406 systemd \u5B9A\u65F6\u4EFB\u52A1 (show|install|uninstall|status)").addHelpText("after", commonHelpText).action(async (action, options, command) => {
|
|
727
|
-
const remote = command
|
|
999
|
+
const remote = resolveRemote(command);
|
|
728
1000
|
await systemd({ remote, action });
|
|
729
1001
|
});
|
|
1002
|
+
program.command("windows-service <action>").description("\u7BA1\u7406 Windows \u539F\u751F\u5F00\u673A\u5B88\u62A4\u4EFB\u52A1 (show|install|uninstall|status)").addHelpText("after", commonHelpText).action(async (action, options, command) => {
|
|
1003
|
+
const remote = resolveRemote(command);
|
|
1004
|
+
await windowsService({ remote, action });
|
|
1005
|
+
});
|
|
730
1006
|
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) => {
|
|
731
|
-
const remote = command
|
|
1007
|
+
const remote = resolveRemote(command);
|
|
732
1008
|
await daemon({ remote });
|
|
733
1009
|
});
|
|
734
1010
|
program.command("env").description("\u663E\u793A\u73AF\u5883\u53D8\u91CF").addHelpText("after", commonHelpText).action(() => {
|
|
@@ -738,7 +1014,7 @@ program.command("env").description("\u663E\u793A\u73AF\u5883\u53D8\u91CF").addHe
|
|
|
738
1014
|
}
|
|
739
1015
|
});
|
|
740
1016
|
program.command("restic [args...]").description("\u663E\u5F0F\u900F\u4F20\u7ED9 restic").addHelpText("after", commonHelpText).allowUnknownOption().allowExcessArguments().action(async function(args) {
|
|
741
|
-
const remote = this
|
|
1017
|
+
const remote = resolveRemote(this);
|
|
742
1018
|
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
743
1019
|
const config = loadConfig(wayDir, remote);
|
|
744
1020
|
const env = buildResticEnv(config.repository);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shellus/way",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.11",
|
|
4
4
|
"description": "将备份作为持续运营的项目,而非一次性任务。基于 restic 的策略封装。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -39,20 +39,18 @@
|
|
|
39
39
|
},
|
|
40
40
|
"homepage": "https://github.com/shellus/way#readme",
|
|
41
41
|
"engines": {
|
|
42
|
-
"node": ">=
|
|
42
|
+
"node": ">=22.12.0"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"commander": "^
|
|
46
|
-
"execa": "^
|
|
47
|
-
"js-yaml": "^
|
|
48
|
-
"node-cron": "^4.
|
|
45
|
+
"commander": "^15.0.0",
|
|
46
|
+
"execa": "^10.0.1",
|
|
47
|
+
"js-yaml": "^5.3.0",
|
|
48
|
+
"node-cron": "^4.6.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@types/
|
|
52
|
-
"@types/node": "^25.5.0",
|
|
53
|
-
"@types/node-cron": "^3.0.11",
|
|
51
|
+
"@types/node": "^22.20.1",
|
|
54
52
|
"tsup": "^8.5.1",
|
|
55
|
-
"typescript": "^
|
|
56
|
-
"vitest": "^4.1.
|
|
53
|
+
"typescript": "^7.0.2",
|
|
54
|
+
"vitest": "^4.1.11"
|
|
57
55
|
}
|
|
58
56
|
}
|
package/rules.yaml.example
CHANGED
|
@@ -15,6 +15,9 @@ defaults:
|
|
|
15
15
|
keep_daily: 7
|
|
16
16
|
keep_weekly: 4
|
|
17
17
|
keep_monthly: 6
|
|
18
|
+
# 如需按当前时间实行严格范围保留,改用以下两个字段,且不要与上述 keep_* 混用:
|
|
19
|
+
# keep_hosts: [backup-host]
|
|
20
|
+
# max_age_days: 7
|
|
18
21
|
|
|
19
22
|
# Uptime Kuma 全局通知(可选,未配置项目级地址时作为回退)
|
|
20
23
|
uptime_kuma:
|
|
@@ -99,5 +102,6 @@ global_excludes:
|
|
|
99
102
|
maintenance:
|
|
100
103
|
prune:
|
|
101
104
|
schedule: "0 4 * * 0" # 每周日凌晨 4 点清理
|
|
105
|
+
# retry_lock: "30m" # 备份仍持有仓库锁时等待,避免立即失败
|
|
102
106
|
check:
|
|
103
107
|
schedule: false # 不执行
|