@shellus/way 0.6.9 → 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 +45 -2
- package/dist/cli.js +354 -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
|
|
@@ -242,6 +279,8 @@ projects:
|
|
|
242
279
|
|
|
243
280
|
`before_backup` 失败会跳过该项目的 restic 备份并标记项目失败;`after_backup` 只在 restic 成功后执行,失败同样会标记项目失败。`--dry-run` 模式只打印钩子命令,不实际执行。钩子命令按 shell 命令执行,并会收到 `WAY_PROJECT`、`WAY_REMOTE`、`WAY_DIR`、`WAY_DRY_RUN` 环境变量。
|
|
244
281
|
|
|
282
|
+
Linux 上的 hook 达到 `timeout` 后,Way 会终止对应 shell 的完整进程组;进程未在宽限期内退出时会继续强制终止,避免数据库导出、压缩或同步子进程脱离 Way 后持续运行。hook 仍应保持幂等,并自行清理失败时生成的临时文件。
|
|
283
|
+
|
|
245
284
|
**项目级 Uptime Kuma 通知示例**:
|
|
246
285
|
|
|
247
286
|
```yaml
|
|
@@ -323,6 +362,8 @@ way backup data_deps
|
|
|
323
362
|
|
|
324
363
|
`schedule` 支持 node-cron 字符串或 `false`。`false` 表示不创建自动调度任务,只能通过 `way backup <project>` 或 `way backup` 手动触发。项目未设置 `schedule` 时继承 `defaults.schedule`;如果全局和项目都未设置,则不自动调度。
|
|
325
364
|
|
|
365
|
+
daemon 的调度心跳最多容忍 5 秒延迟;事件循环短暂阻塞后仍会补执行本轮任务,超过窗口则由 node-cron 报告为 missed execution。
|
|
366
|
+
|
|
326
367
|
| 格式 | 说明 | 示例 |
|
|
327
368
|
|------|------|------|
|
|
328
369
|
| `"0 */2 * * *"` | 间隔表达式 | 每 2 小时 |
|
|
@@ -359,6 +400,8 @@ repositories:
|
|
|
359
400
|
|
|
360
401
|
建议设置文件权限:`chmod 600 ~/.way/repositories.yaml`
|
|
361
402
|
|
|
403
|
+
`repositories.yaml` 和 `rules.yaml` 支持 YAML merge anchor(`<<`);Way 使用 YAML 1.2 Core Schema 并显式启用 merge tag,不会同时启用 YAML 1.1 的日期自动转换等额外类型。
|
|
404
|
+
|
|
362
405
|
## 配置备份
|
|
363
406
|
|
|
364
407
|
`~/.way/` 目录包含所有配置和凭证,建议整体备份到安全位置:
|
|
@@ -460,4 +503,4 @@ way --remote=oss restic restore <snapshot-id> --target /tmp/restore
|
|
|
460
503
|
- 变更规范和测试要求
|
|
461
504
|
- 发布流程
|
|
462
505
|
|
|
463
|
-
|
|
506
|
+
项目维护规则:[AGENTS.md](AGENTS.md)
|
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);
|
|
@@ -324,6 +342,7 @@ function groupUptimeKumaResults(projectResults) {
|
|
|
324
342
|
}
|
|
325
343
|
return groupedResults;
|
|
326
344
|
}
|
|
345
|
+
var HOOK_FORCE_KILL_DELAY_MS = 1e3;
|
|
327
346
|
function normalizeHook(hook) {
|
|
328
347
|
if (typeof hook === "string") return { run: hook };
|
|
329
348
|
return hook;
|
|
@@ -346,6 +365,26 @@ function parseTimeout(timeout) {
|
|
|
346
365
|
return value * 60 * 60 * 1e3;
|
|
347
366
|
}
|
|
348
367
|
}
|
|
368
|
+
function isMissingProcessError(error) {
|
|
369
|
+
return error instanceof Error && "code" in error && error.code === "ESRCH";
|
|
370
|
+
}
|
|
371
|
+
function signalProcessGroup(pid, signal) {
|
|
372
|
+
try {
|
|
373
|
+
process.kill(-pid, signal);
|
|
374
|
+
return true;
|
|
375
|
+
} catch (error) {
|
|
376
|
+
if (isMissingProcessError(error)) return false;
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function hookTimedOut(error) {
|
|
381
|
+
return typeof error === "object" && error !== null && "timedOut" in error && error.timedOut === true;
|
|
382
|
+
}
|
|
383
|
+
async function terminateProcessGroup(pid) {
|
|
384
|
+
if (!signalProcessGroup(pid, "SIGTERM")) return;
|
|
385
|
+
await new Promise((resolve) => setTimeout(resolve, HOOK_FORCE_KILL_DELAY_MS));
|
|
386
|
+
signalProcessGroup(pid, "SIGKILL");
|
|
387
|
+
}
|
|
349
388
|
async function runProjectHooks(hooks, context) {
|
|
350
389
|
if (!hooks?.length) return;
|
|
351
390
|
for (const hook of hooks) {
|
|
@@ -356,10 +395,11 @@ async function runProjectHooks(hooks, context) {
|
|
|
356
395
|
continue;
|
|
357
396
|
}
|
|
358
397
|
console.log(`Running ${context.label} hook for ${context.projectName}: ${normalized.run}`);
|
|
359
|
-
|
|
398
|
+
const subprocess = execa2(normalized.run, {
|
|
360
399
|
shell: true,
|
|
361
400
|
stdio: "inherit",
|
|
362
401
|
timeout: parseTimeout(normalized.timeout),
|
|
402
|
+
detached: process.platform !== "win32",
|
|
363
403
|
env: {
|
|
364
404
|
WAY_PROJECT: context.projectName,
|
|
365
405
|
WAY_REMOTE: context.remote,
|
|
@@ -367,6 +407,19 @@ async function runProjectHooks(hooks, context) {
|
|
|
367
407
|
WAY_DRY_RUN: context.dryRun ? "1" : "0"
|
|
368
408
|
}
|
|
369
409
|
});
|
|
410
|
+
const processGroupPid = process.platform === "win32" ? void 0 : subprocess.pid;
|
|
411
|
+
const cleanupOnExit = processGroupPid === void 0 ? void 0 : () => signalProcessGroup(processGroupPid, "SIGTERM");
|
|
412
|
+
if (cleanupOnExit) process.once("exit", cleanupOnExit);
|
|
413
|
+
try {
|
|
414
|
+
await subprocess;
|
|
415
|
+
} catch (error) {
|
|
416
|
+
if (processGroupPid !== void 0 && hookTimedOut(error)) {
|
|
417
|
+
await terminateProcessGroup(processGroupPid);
|
|
418
|
+
}
|
|
419
|
+
throw error;
|
|
420
|
+
} finally {
|
|
421
|
+
if (cleanupOnExit) process.removeListener("exit", cleanupOnExit);
|
|
422
|
+
}
|
|
370
423
|
}
|
|
371
424
|
}
|
|
372
425
|
async function notifyUptimeKuma(result, pushUrl) {
|
|
@@ -386,8 +439,46 @@ async function notifyUptimeKuma(result, pushUrl) {
|
|
|
386
439
|
}
|
|
387
440
|
|
|
388
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
|
+
}
|
|
389
479
|
async function restore(options) {
|
|
390
480
|
if (!options.target) throw new Error("--target is required");
|
|
481
|
+
const target = options.target;
|
|
391
482
|
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
392
483
|
const config = loadConfig(wayDir, options.remote);
|
|
393
484
|
const env = buildResticEnv(config.repository);
|
|
@@ -405,15 +496,19 @@ async function restore(options) {
|
|
|
405
496
|
}
|
|
406
497
|
console.log(`=== Restoring: ${projectName} ===`);
|
|
407
498
|
try {
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
+
}
|
|
417
512
|
succeeded.push(projectName);
|
|
418
513
|
} catch (error) {
|
|
419
514
|
console.error(`Failed to restore ${projectName}:`, error);
|
|
@@ -428,18 +523,101 @@ async function restore(options) {
|
|
|
428
523
|
}
|
|
429
524
|
|
|
430
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
|
+
}
|
|
431
601
|
async function gc(options) {
|
|
432
602
|
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
433
603
|
const config = loadConfig(wayDir, options.remote);
|
|
434
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
|
+
}
|
|
435
616
|
const keepDaily = retention.keep_daily || 7;
|
|
436
617
|
const keepWeekly = retention.keep_weekly || 4;
|
|
437
618
|
const keepMonthly = retention.keep_monthly || 6;
|
|
438
619
|
const keepYearly = retention.keep_yearly;
|
|
439
|
-
console.log("=== Cleaning snapshots ===");
|
|
440
620
|
console.log(`Policy: daily=${keepDaily}, weekly=${keepWeekly}, monthly=${keepMonthly}${keepYearly ? `, yearly=${keepYearly}` : ""}`);
|
|
441
|
-
const env = buildResticEnv(config.repository);
|
|
442
|
-
const s3Options = buildS3Options(config.repository);
|
|
443
621
|
const args = [
|
|
444
622
|
"forget",
|
|
445
623
|
"--prune",
|
|
@@ -448,22 +626,22 @@ async function gc(options) {
|
|
|
448
626
|
`--keep-monthly=${keepMonthly}`
|
|
449
627
|
];
|
|
450
628
|
if (keepYearly) args.push(`--keep-yearly=${keepYearly}`);
|
|
451
|
-
if (
|
|
452
|
-
await execRestic(args, env,
|
|
629
|
+
if (dryRun) args.push("--dry-run");
|
|
630
|
+
await execRestic(args, env, resticOptions);
|
|
453
631
|
}
|
|
454
632
|
|
|
455
633
|
// src/commands/systemd.ts
|
|
456
634
|
import { execSync } from "child_process";
|
|
457
635
|
import fs5 from "fs";
|
|
458
|
-
import
|
|
636
|
+
import path6 from "path";
|
|
459
637
|
function resolveWayCommandPath(options = {}) {
|
|
460
638
|
const env = options.env ?? process.env;
|
|
461
639
|
const argv = options.argv ?? process.argv;
|
|
462
640
|
const execPath = options.execPath ?? process.execPath;
|
|
463
641
|
const whichWay = options.whichWay ?? (() => execSync("which way", { encoding: "utf-8" }).trim());
|
|
464
642
|
if (env.WAY_BIN) return env.WAY_BIN;
|
|
465
|
-
if (
|
|
466
|
-
if (argv[1] &&
|
|
643
|
+
if (path6.basename(execPath) === "way") return execPath;
|
|
644
|
+
if (argv[1] && path6.isAbsolute(argv[1])) return argv[1];
|
|
467
645
|
return whichWay();
|
|
468
646
|
}
|
|
469
647
|
async function systemd(options) {
|
|
@@ -493,7 +671,7 @@ WantedBy=multi-user.target
|
|
|
493
671
|
return;
|
|
494
672
|
}
|
|
495
673
|
const systemdDir = "/etc/systemd/system";
|
|
496
|
-
const servicePath =
|
|
674
|
+
const servicePath = path6.join(systemdDir, "way-backup.service");
|
|
497
675
|
if (options.action === "install") {
|
|
498
676
|
fs5.writeFileSync(servicePath, serviceContent);
|
|
499
677
|
execSync("systemctl daemon-reload");
|
|
@@ -520,10 +698,136 @@ WantedBy=multi-user.target
|
|
|
520
698
|
}
|
|
521
699
|
}
|
|
522
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
|
+
|
|
523
824
|
// src/commands/daemon.ts
|
|
524
825
|
import cron from "node-cron";
|
|
525
826
|
var isRunning = false;
|
|
526
827
|
var taskQueue = [];
|
|
828
|
+
var CRON_OPTIONS = {
|
|
829
|
+
missedExecutionTolerance: 5e3
|
|
830
|
+
};
|
|
527
831
|
function resolveProjectSchedule(project, defaults = {}) {
|
|
528
832
|
if (project.schedule !== void 0) return project.schedule;
|
|
529
833
|
return defaults?.schedule ?? false;
|
|
@@ -575,7 +879,7 @@ async function daemon(options) {
|
|
|
575
879
|
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running backup: ${projects.join(", ")}`);
|
|
576
880
|
await backup({ remote: options.remote, projects });
|
|
577
881
|
});
|
|
578
|
-
});
|
|
882
|
+
}, CRON_OPTIONS);
|
|
579
883
|
console.log(`Scheduled backup for ${projects.join(", ")}: ${schedule}`);
|
|
580
884
|
}
|
|
581
885
|
const pruneSchedule = config.rules.maintenance?.prune?.schedule;
|
|
@@ -586,7 +890,7 @@ async function daemon(options) {
|
|
|
586
890
|
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running prune`);
|
|
587
891
|
await gc({ remote: options.remote, dryRun: false });
|
|
588
892
|
});
|
|
589
|
-
});
|
|
893
|
+
}, CRON_OPTIONS);
|
|
590
894
|
console.log(`Scheduled prune: ${pruneSchedule}`);
|
|
591
895
|
}
|
|
592
896
|
const checkSchedule = config.rules.maintenance?.check?.schedule;
|
|
@@ -596,7 +900,7 @@ async function daemon(options) {
|
|
|
596
900
|
executeTask(async () => {
|
|
597
901
|
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running check`);
|
|
598
902
|
});
|
|
599
|
-
});
|
|
903
|
+
}, CRON_OPTIONS);
|
|
600
904
|
console.log(`Scheduled check: ${checkSchedule}`);
|
|
601
905
|
}
|
|
602
906
|
process.on("SIGTERM", () => {
|
|
@@ -611,7 +915,7 @@ async function daemon(options) {
|
|
|
611
915
|
|
|
612
916
|
// src/cli.ts
|
|
613
917
|
var program = new Command();
|
|
614
|
-
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", `
|
|
615
919
|
\u793A\u4F8B:
|
|
616
920
|
$ way backup \u6267\u884C\u6240\u6709\u9879\u76EE\u5907\u4EFD
|
|
617
921
|
$ way backup data \u53EA\u5907\u4EFD data \u9879\u76EE
|
|
@@ -634,6 +938,9 @@ program.name("way").version("0.6.9").description("\u7B56\u7565\u5907\u4EFD\u5DE5
|
|
|
634
938
|
function collectBackupArgs(command) {
|
|
635
939
|
return command.args.filter((a) => a.startsWith("-") && !["--dry-run"].includes(a));
|
|
636
940
|
}
|
|
941
|
+
function resolveRemote(command) {
|
|
942
|
+
return command.optsWithGlobals().remote;
|
|
943
|
+
}
|
|
637
944
|
var commonHelpText = `
|
|
638
945
|
\u5168\u5C40\u7528\u6CD5:
|
|
639
946
|
way --remote=oss <command> ... \u6307\u5B9A\u4ED3\u5E93\uFF08\u5168\u5C40\u9009\u9879\u9700\u653E\u5728\u5B50\u547D\u4EE4\u524D\uFF09
|
|
@@ -645,22 +952,22 @@ var commonHelpText = `
|
|
|
645
952
|
program.command("init").description("\u521D\u59CB\u5316 way \u914D\u7F6E\u6587\u4EF6").addHelpText("after", commonHelpText).action(() => {
|
|
646
953
|
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
647
954
|
const files = ["repositories.yaml", "rules.yaml"];
|
|
648
|
-
|
|
955
|
+
fs7.mkdirSync(wayDir, { recursive: true });
|
|
649
956
|
for (const file of files) {
|
|
650
|
-
const target =
|
|
651
|
-
if (
|
|
957
|
+
const target = path8.join(wayDir, file);
|
|
958
|
+
if (fs7.existsSync(target)) {
|
|
652
959
|
throw new Error(`${target} already exists, aborting to avoid overwriting existing config.`);
|
|
653
960
|
}
|
|
654
961
|
}
|
|
655
962
|
for (const file of files) {
|
|
656
963
|
const source = resolveExampleConfigPath(file);
|
|
657
|
-
const target =
|
|
658
|
-
|
|
964
|
+
const target = path8.join(wayDir, file);
|
|
965
|
+
fs7.copyFileSync(source, target);
|
|
659
966
|
console.log(`Created ${target}`);
|
|
660
967
|
}
|
|
661
968
|
});
|
|
662
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) {
|
|
663
|
-
const remote = this
|
|
970
|
+
const remote = resolveRemote(this);
|
|
664
971
|
const dryRun = this.opts().dryRun;
|
|
665
972
|
const extraArgs = collectBackupArgs(this);
|
|
666
973
|
const result = await backup({
|
|
@@ -672,7 +979,7 @@ program.command("backup [projects...]").description("\u6309 rules.yaml \u6267\u8
|
|
|
672
979
|
if (result.failed.length > 0) process.exitCode = 1;
|
|
673
980
|
});
|
|
674
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) {
|
|
675
|
-
const remote = this
|
|
982
|
+
const remote = resolveRemote(this);
|
|
676
983
|
await restore({
|
|
677
984
|
remote,
|
|
678
985
|
projects,
|
|
@@ -685,15 +992,19 @@ program.command("restore [projects...]").description("\u6309 rules.yaml \u6062\u
|
|
|
685
992
|
});
|
|
686
993
|
});
|
|
687
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) {
|
|
688
|
-
const remote = this
|
|
995
|
+
const remote = resolveRemote(this);
|
|
689
996
|
await gc({ remote, dryRun: cmdOptions.dryRun });
|
|
690
997
|
});
|
|
691
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) => {
|
|
692
|
-
const remote = command
|
|
999
|
+
const remote = resolveRemote(command);
|
|
693
1000
|
await systemd({ remote, action });
|
|
694
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
|
+
});
|
|
695
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) => {
|
|
696
|
-
const remote = command
|
|
1007
|
+
const remote = resolveRemote(command);
|
|
697
1008
|
await daemon({ remote });
|
|
698
1009
|
});
|
|
699
1010
|
program.command("env").description("\u663E\u793A\u73AF\u5883\u53D8\u91CF").addHelpText("after", commonHelpText).action(() => {
|
|
@@ -703,7 +1014,7 @@ program.command("env").description("\u663E\u793A\u73AF\u5883\u53D8\u91CF").addHe
|
|
|
703
1014
|
}
|
|
704
1015
|
});
|
|
705
1016
|
program.command("restic [args...]").description("\u663E\u5F0F\u900F\u4F20\u7ED9 restic").addHelpText("after", commonHelpText).allowUnknownOption().allowExcessArguments().action(async function(args) {
|
|
706
|
-
const remote = this
|
|
1017
|
+
const remote = resolveRemote(this);
|
|
707
1018
|
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
708
1019
|
const config = loadConfig(wayDir, remote);
|
|
709
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 # 不执行
|