@shellus/way 0.6.10 → 0.6.12

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 CHANGED
@@ -39,6 +39,8 @@
39
39
 
40
40
  定期清理旧快照,防止存储膨胀。
41
41
 
42
+ 正式 `way gc` 在删除快照前执行普通 `restic unlock`,仅回收 restic 判定失效的锁,不使用 `--remove-all`。解锁失败时停止清理;`--dry-run` 不解锁。活跃锁仍由 restic 保护。
43
+
42
44
  ### 7. 同步不等于备份
43
45
 
44
46
  同步工具(如 Syncthing)会将误操作实时传播到所有节点。真正的备份必须具备版本历史和回滚能力。
@@ -52,6 +54,7 @@
52
54
  - 支持项目级 schedule 配置(不同项目不同频率)
53
55
  - 使用 node-cron 实现精确到分钟的调度
54
56
  - systemd service 管理常驻进程(自动重启)
57
+ - 支持把本地仓库中各项目的最新快照定期复制到远程仓库
55
58
 
56
59
  ### 9. 多层冗余原则
57
60
 
@@ -68,7 +71,7 @@
68
71
  ## 依赖
69
72
 
70
73
  - Linux x64 独立发行包无需预装 Node.js、npm、Bun 或 restic
71
- - npm 安装方式需要 Node.js >= 18
74
+ - npm 安装方式需要 Node.js >= 22.12
72
75
  - Linux x64 平台内置 [restic](https://restic.net/) 0.18.1,其他平台需自行安装 restic
73
76
 
74
77
  `way` 查找 restic 的顺序:
@@ -137,6 +140,15 @@ way systemd install
137
140
  way systemd status
138
141
  ```
139
142
 
143
+ Windows 使用系统自带的任务计划程序守护 `way daemon`,不依赖 NSSM 或其他常驻工具:
144
+
145
+ ```powershell
146
+ way windows-service install
147
+ way windows-service status
148
+ ```
149
+
150
+ `windows-service install` 会创建以 `SYSTEM` 身份在开机后运行的原生任务,并配置失败重启;注册和删除任务需要在提升权限的 PowerShell 中执行。任务仅守护 daemon,实际备份频率仍由 `rules.yaml` 的项目级 `schedule` 决定。
151
+
140
152
  ### 4. 手动执行备份
141
153
 
142
154
  ```bash
@@ -159,6 +171,8 @@ way restore data --host old-host --target /tmp/restore --dry-run # 从指定 ho
159
171
  way restore data --target /tmp/restore --delete # 删除目标中快照不存在的文件
160
172
  way gc # 按 retention 策略清理旧快照
161
173
  way gc --dry-run # 模拟清理(不实际删除)
174
+ way replicate # 执行全部仓库复制任务
175
+ way replicate local-to-s3 --init # 首次初始化目标仓库并执行复制
162
176
 
163
177
  # daemon 模式(推荐)
164
178
  way daemon # 启动常驻进程,按配置定时执行
@@ -169,6 +183,12 @@ way systemd show # 显示 systemd 配置
169
183
  way systemd status # 查看服务状态
170
184
  way systemd uninstall # 卸载服务
171
185
 
186
+ # Windows 原生守护任务管理
187
+ way windows-service install # 注册开机启动、SYSTEM 身份运行的 daemon 守护任务
188
+ way windows-service show # 显示将要写入的任务与启动脚本
189
+ way windows-service status # 查看任务状态
190
+ way windows-service uninstall # 删除任务与启动脚本
191
+
172
192
  # 显式透传 restic(way 只设置环境变量)
173
193
  way restic snapshots # → restic snapshots
174
194
  way restic check # → restic check
@@ -190,9 +210,12 @@ graph LR
190
210
  C --> D[node-cron 定时触发]
191
211
  D --> E[执行 restic backup]
192
212
  E --> F[推送 Uptime Kuma]
213
+ D --> K[选择各项目最新快照]
214
+ K --> L[restic copy 到远程仓库]
193
215
 
194
216
  G[systemd service] --> H[启动 daemon]
195
217
  H --> I[进程崩溃自动重启]
218
+ J[Windows Task Scheduler] --> H
196
219
  ```
197
220
 
198
221
  ---
@@ -221,9 +244,32 @@ WAY_DIR=/path/to/config way restic snapshots
221
244
  - **projects.*.hooks**: 项目级备份钩子,支持 `before_backup` 和 `after_backup`
222
245
  - **uptime_kuma.push_url**: 全局 Uptime Kuma Push 地址,作为项目未配置通知地址时的回退
223
246
  - **projects.*.uptime_kuma.push_url**: 项目级 Uptime Kuma Push 地址,优先于全局地址
247
+ - **replications**: 仓库复制任务,包含源仓库、目标仓库、调度和目标仓库独立保留策略
224
248
  - **maintenance**: 维护任务配置(prune、check)
249
+ - **maintenance.prune.uptime_kuma.push_url**: daemon 定时 prune 的 Uptime Kuma Push 地址,未配置时回退到全局地址;prune 成功推送 UP,失败推送带原因的 DOWN
225
250
  - **global_excludes**: 全局排除规则
226
251
 
252
+ `defaults.retention` 支持两种互斥模式:
253
+
254
+ - 计数保留:`keep_daily`、`keep_weekly`、`keep_monthly`、`keep_yearly`,按 restic 分组规则保留周期快照。
255
+ - 严格范围保留:`keep_hosts` 与 `max_age_days` 必须同时设置。Way 以执行时的当前时间计算截止点,只保留白名单主机在最近指定天数内的快照;其他快照使用明确 ID 删除。该模式不会使用 restic 相对最新快照计算的 `keep-within` 语义。
256
+
257
+ 两种模式不得混用。严格范围保留示例:
258
+
259
+ ```yaml
260
+ defaults:
261
+ retention:
262
+ keep_hosts: [backup-host]
263
+ max_age_days: 7
264
+
265
+ maintenance:
266
+ prune:
267
+ schedule: "0 4 * * *"
268
+ retry_lock: "30m"
269
+ ```
270
+
271
+ `maintenance.prune.retry_lock` 会作为 restic 全局 `--retry-lock` 参数传递。正式启用严格范围保留前必须先执行 `way gc --dry-run`,核对输出的保留和删除快照列表。
272
+
227
273
  项目级钩子用于在 restic 备份前后执行一致性快照、校验或清理脚本:
228
274
 
229
275
  ```yaml
@@ -325,6 +371,8 @@ way backup data_deps
325
371
 
326
372
  `schedule` 支持 node-cron 字符串或 `false`。`false` 表示不创建自动调度任务,只能通过 `way backup <project>` 或 `way backup` 手动触发。项目未设置 `schedule` 时继承 `defaults.schedule`;如果全局和项目都未设置,则不自动调度。
327
373
 
374
+ daemon 的调度心跳最多容忍 5 秒延迟;事件循环短暂阻塞后仍会补执行本轮任务,超过窗口则由 node-cron 报告为 missed execution。
375
+
328
376
  | 格式 | 说明 | 示例 |
329
377
  |------|------|------|
330
378
  | `"0 */2 * * *"` | 间隔表达式 | 每 2 小时 |
@@ -333,6 +381,27 @@ way backup data_deps
333
381
  | `"*/30 * * * *"` | 分钟间隔 | 每 30 分钟 |
334
382
  | `false` | 禁用自动调度 | 只手动备份 |
335
383
 
384
+ **仓库复制示例**:
385
+
386
+ ```yaml
387
+ replications:
388
+ local-to-s3:
389
+ from: local
390
+ to: s3
391
+ snapshot_policy: latest-per-project
392
+ schedule: "30 5 * * *"
393
+ prune_schedule: "30 6 * * 0"
394
+ retention:
395
+ keep_daily: 30
396
+ keep_weekly: 12
397
+ keep_monthly: 12
398
+ keep_yearly: 3
399
+ ```
400
+
401
+ 复制任务读取源仓库中所有带 `way:<project>` 标签的快照,每个项目只选择时间最新的一份传给 `restic copy`。因此本地可以高频备份,而远程只接收每日复制时刻的项目最新状态;重复运行会跳过已经复制的源快照。目标仓库的 `prune_schedule` 和 `retention` 独立于默认仓库。
402
+
403
+ 目标仓库第一次使用时执行 `way replicate <name> --init`。该命令通过 `--copy-chunker-params` 继承源仓库的分块参数,再执行首次复制;初始化后日常任务只运行 `way replicate <name>`。复制源当前必须是本地仓库,源与目标可以使用不同的 restic 密码。
404
+
336
405
  #### 排除规则通配符语法
337
406
 
338
407
  restic 使用 Go 的 filepath.Match 语法:
@@ -357,10 +426,24 @@ repositories:
357
426
  path: /backup/repo
358
427
  credentials:
359
428
  password: your-password # 直接明文
429
+
430
+ s3:
431
+ type: s3
432
+ endpoint: s3.example.com
433
+ bucket: my-backup-bucket
434
+ region: region-1
435
+ options:
436
+ bucket_lookup: dns
437
+ credentials:
438
+ password: your-restic-password
439
+ access_key_id: your-access-key
440
+ secret_access_key: your-secret-key
360
441
  ```
361
442
 
362
443
  建议设置文件权限:`chmod 600 ~/.way/repositories.yaml`
363
444
 
445
+ `repositories.yaml` 和 `rules.yaml` 支持 YAML merge anchor(`<<`);Way 使用 YAML 1.2 Core Schema 并显式启用 merge tag,不会同时启用 YAML 1.1 的日期自动转换等额外类型。
446
+
364
447
  ## 配置备份
365
448
 
366
449
  `~/.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 fs6 from "fs";
6
- import path6 from "path";
5
+ import fs8 from "fs";
6
+ import path9 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
- if ("schedule" in rules && "backup" in (rules.schedule || {})) {
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 };
@@ -91,19 +93,20 @@ function resolveResticBin(options = {}) {
91
93
  }
92
94
 
93
95
  // src/core/restic.ts
94
- function buildResticEnv(repo) {
95
- const env = {};
96
+ function buildRepositoryLocation(repo) {
96
97
  switch (repo.type) {
97
98
  case "s3":
98
- env.RESTIC_REPOSITORY = `s3:https://${repo.endpoint}/${repo.bucket}`;
99
- break;
99
+ return `s3:https://${repo.endpoint}/${repo.bucket}`;
100
100
  case "local":
101
- env.RESTIC_REPOSITORY = repo.path;
102
- break;
101
+ return repo.path;
103
102
  case "sftp":
104
- env.RESTIC_REPOSITORY = `sftp:${repo.host}:${repo.path}`;
105
- break;
103
+ return `sftp:${repo.host}:${repo.path}`;
106
104
  }
105
+ }
106
+ function buildResticEnv(repo) {
107
+ const env = {
108
+ RESTIC_REPOSITORY: buildRepositoryLocation(repo)
109
+ };
107
110
  if (repo.credentials.password) env.RESTIC_PASSWORD = repo.credentials.password;
108
111
  if (repo.credentials.access_key_id) env.AWS_ACCESS_KEY_ID = repo.credentials.access_key_id;
109
112
  if (repo.credentials.secret_access_key) env.AWS_SECRET_ACCESS_KEY = repo.credentials.secret_access_key;
@@ -178,6 +181,12 @@ function collectIncludeDirs(paths, includeDirs, options = {}) {
178
181
  }
179
182
  return Array.from(new Set(matches));
180
183
  }
184
+ function normalizeResticPath(value, platform = process.platform) {
185
+ if (platform !== "win32") return value;
186
+ const match = value.match(/^([A-Za-z]):[\\/](.*)$/);
187
+ if (!match) return value.replace(/\\/g, "/");
188
+ return `/${match[1].toUpperCase()}/${match[2].replace(/\\/g, "/")}`;
189
+ }
181
190
  function buildRestoreArgs(name, project, options) {
182
191
  const args = [
183
192
  "restore",
@@ -186,7 +195,7 @@ function buildRestoreArgs(name, project, options) {
186
195
  ];
187
196
  if (options.host) args.push(`--host=${options.host}`);
188
197
  args.push(`--target=${options.target}`);
189
- for (const path7 of project.paths) args.push(`--include=${path7}`);
198
+ for (const path10 of options.includePaths || project.paths) args.push(`--include=${normalizeResticPath(path10, options.platform)}`);
190
199
  if (options.dryRun) args.push("--dry-run");
191
200
  if (options.delete) args.push("--delete");
192
201
  if (options.verbose) args.push("--verbose=2");
@@ -197,11 +206,24 @@ function buildS3Options(repo) {
197
206
  if (repo.options?.bucket_lookup) {
198
207
  options.push("-o", `s3.bucket-lookup=${repo.options.bucket_lookup}`);
199
208
  }
209
+ if (repo.region) {
210
+ options.push("-o", `s3.region=${repo.region}`);
211
+ }
200
212
  return options;
201
213
  }
202
214
  async function execRestic(args, env, s3Options = []) {
215
+ await runRestic(args, env, s3Options, false);
216
+ }
217
+ async function execResticCapture(args, env, s3Options = []) {
218
+ return runRestic(args, env, s3Options, true);
219
+ }
220
+ async function runRestic(args, env, s3Options, capture) {
203
221
  try {
204
- await execa(resolveResticBin(), [...s3Options, ...args], { env: { ...process.env, ...env }, stdio: "inherit" });
222
+ const result = await execa(resolveResticBin(), [...s3Options, ...args], {
223
+ env: { ...process.env, ...env },
224
+ ...capture ? { stdout: "pipe", stderr: "inherit" } : { stdio: "inherit" }
225
+ });
226
+ return typeof result.stdout === "string" ? result.stdout : "";
205
227
  } catch (error) {
206
228
  if (error.code === "ENOENT") {
207
229
  console.error("Error: restic not found. Linux x64 packages include restic; other platforms must install it first.");
@@ -212,11 +234,28 @@ async function execRestic(args, env, s3Options = []) {
212
234
  }
213
235
  }
214
236
 
237
+ // src/core/uptime-kuma.ts
238
+ var MAX_MESSAGE_LENGTH = 200;
239
+ async function notifyUptimeKuma(push, pushUrl) {
240
+ const msg = push.msg.length > MAX_MESSAGE_LENGTH ? `${push.msg.slice(0, MAX_MESSAGE_LENGTH - 3)}...` : push.msg;
241
+ const url = `${pushUrl}?status=${push.status}&msg=${encodeURIComponent(msg)}&ping=${push.ping}`;
242
+ try {
243
+ const response = await fetch(url);
244
+ if (response.ok) {
245
+ console.log(`Uptime Kuma notified: status=${push.status}, ping=${push.ping}ms`);
246
+ } else {
247
+ console.error("Uptime Kuma notification failed");
248
+ }
249
+ } catch (error) {
250
+ console.error("Uptime Kuma notification failed:", error);
251
+ }
252
+ }
253
+
215
254
  // src/commands/backup.ts
216
255
  import fs4 from "fs";
217
256
  import os from "os";
218
257
  import path4 from "path";
219
- import { execaCommand } from "execa";
258
+ import { execa as execa2 } from "execa";
220
259
  async function backup(options) {
221
260
  const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
222
261
  const config = loadConfig(wayDir, options.remote);
@@ -300,7 +339,11 @@ async function backup(options) {
300
339
  if (failed.length > 0) console.log("Failed:", failed.join(", "));
301
340
  if (!dryRun) {
302
341
  for (const [pushUrl, result] of groupUptimeKumaResults(projectResults)) {
303
- await notifyUptimeKuma(result, pushUrl);
342
+ await notifyUptimeKuma({
343
+ status: result.failed.length > 0 ? "down" : "up",
344
+ msg: `Succeeded: ${result.succeeded.length}, Failed: ${result.failed.length}`,
345
+ ping: result.duration
346
+ }, pushUrl);
304
347
  }
305
348
  }
306
349
  return { succeeded, failed, duration };
@@ -377,7 +420,7 @@ async function runProjectHooks(hooks, context) {
377
420
  continue;
378
421
  }
379
422
  console.log(`Running ${context.label} hook for ${context.projectName}: ${normalized.run}`);
380
- const subprocess = execaCommand(normalized.run, {
423
+ const subprocess = execa2(normalized.run, {
381
424
  shell: true,
382
425
  stdio: "inherit",
383
426
  timeout: parseTimeout(normalized.timeout),
@@ -404,25 +447,48 @@ async function runProjectHooks(hooks, context) {
404
447
  }
405
448
  }
406
449
  }
407
- async function notifyUptimeKuma(result, pushUrl) {
408
- const status = result.failed.length > 0 ? "down" : "up";
409
- const msg = `Succeeded: ${result.succeeded.length}, Failed: ${result.failed.length}`;
410
- const url = `${pushUrl}?status=${status}&msg=${encodeURIComponent(msg)}&ping=${result.duration}`;
411
- try {
412
- const response = await fetch(url);
413
- if (response.ok) {
414
- console.log(`Uptime Kuma notified: status=${status}, ping=${result.duration}ms`);
415
- } else {
416
- console.error("Uptime Kuma notification failed");
417
- }
418
- } catch (error) {
419
- console.error("Uptime Kuma notification failed:", error);
420
- }
421
- }
422
450
 
423
451
  // src/commands/restore.ts
452
+ import path5 from "path";
453
+ function isWithin(parent, child, pathApi) {
454
+ const relative = pathApi.relative(parent, child);
455
+ return relative === "" || !relative.startsWith("..") && !pathApi.isAbsolute(relative);
456
+ }
457
+ function findCommonParent(paths, pathApi) {
458
+ let candidate = paths[0];
459
+ while (!paths.every((item) => isWithin(candidate, item, pathApi))) {
460
+ const parent = pathApi.dirname(candidate);
461
+ if (parent === candidate) return candidate;
462
+ candidate = parent;
463
+ }
464
+ return candidate;
465
+ }
466
+ function buildWindowsRestorePlans(project, target, snapshot = "latest") {
467
+ const pathApi = path5.win32;
468
+ const groups = /* @__PURE__ */ new Map();
469
+ for (const sourcePath of project.paths) {
470
+ const root = pathApi.parse(sourcePath).root.toLowerCase();
471
+ const paths = groups.get(root) || [];
472
+ paths.push(sourcePath);
473
+ groups.set(root, paths);
474
+ }
475
+ return Array.from(groups.values()).map((paths) => {
476
+ const parent = findCommonParent(paths.map((item) => pathApi.dirname(item)), pathApi);
477
+ const drive = parent[0].toUpperCase();
478
+ const rest = parent.slice(2).replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
479
+ const snapshotPath = rest ? `/${drive}/${rest}` : `/${drive}`;
480
+ const includePaths = paths.map((item) => `/${pathApi.relative(parent, item).replace(/\\/g, "/")}`);
481
+ const targetPath = pathApi.join(target, ...snapshotPath.split("/").filter(Boolean));
482
+ return {
483
+ snapshot: `${snapshot}:${snapshotPath}`,
484
+ target: targetPath,
485
+ includePaths: Array.from(new Set(includePaths))
486
+ };
487
+ });
488
+ }
424
489
  async function restore(options) {
425
490
  if (!options.target) throw new Error("--target is required");
491
+ const target = options.target;
426
492
  const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
427
493
  const config = loadConfig(wayDir, options.remote);
428
494
  const env = buildResticEnv(config.repository);
@@ -440,15 +506,19 @@ async function restore(options) {
440
506
  }
441
507
  console.log(`=== Restoring: ${projectName} ===`);
442
508
  try {
443
- const args = buildRestoreArgs(projectName, project, {
444
- target: options.target,
445
- snapshot: options.snapshot,
446
- host: options.host,
447
- dryRun: options.dryRun,
448
- delete: options.delete,
449
- verbose: options.verbose
450
- });
451
- await execRestic(args, env, s3Options);
509
+ 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 }];
510
+ for (const plan of plans) {
511
+ const args = buildRestoreArgs(projectName, project, {
512
+ target: plan.target,
513
+ snapshot: plan.snapshot,
514
+ host: options.host,
515
+ dryRun: options.dryRun,
516
+ delete: options.delete,
517
+ verbose: options.verbose,
518
+ includePaths: plan.includePaths
519
+ });
520
+ await execRestic(args, env, s3Options);
521
+ }
452
522
  succeeded.push(projectName);
453
523
  } catch (error) {
454
524
  console.error(`Failed to restore ${projectName}:`, error);
@@ -463,18 +533,108 @@ async function restore(options) {
463
533
  }
464
534
 
465
535
  // src/commands/gc.ts
536
+ var DAY_MS = 24 * 60 * 60 * 1e3;
537
+ function hasLegacyPolicy(retention) {
538
+ return [
539
+ retention.keep_daily,
540
+ retention.keep_weekly,
541
+ retention.keep_monthly,
542
+ retention.keep_yearly
543
+ ].some((value) => value !== void 0);
544
+ }
545
+ function isStrictPolicy(retention) {
546
+ return retention.keep_hosts !== void 0 || retention.max_age_days !== void 0;
547
+ }
548
+ function validateStrictPolicy(retention) {
549
+ if (!retention.keep_hosts?.length) {
550
+ throw new Error("Strict retention requires at least one defaults.retention.keep_hosts entry");
551
+ }
552
+ if (!Number.isFinite(retention.max_age_days) || retention.max_age_days <= 0) {
553
+ throw new Error("Strict retention requires defaults.retention.max_age_days to be a positive number");
554
+ }
555
+ if (hasLegacyPolicy(retention)) {
556
+ throw new Error("Strict keep_hosts/max_age_days retention cannot be combined with count-based keep_* retention");
557
+ }
558
+ const normalizedHosts = retention.keep_hosts.map((host) => host.trim());
559
+ if (normalizedHosts.some((host) => host.length === 0)) {
560
+ throw new Error("defaults.retention.keep_hosts cannot contain empty host names");
561
+ }
562
+ return { hosts: new Set(normalizedHosts), maxAgeDays: retention.max_age_days };
563
+ }
564
+ function buildSnapshotRetentionPlan(snapshots, retention, now = /* @__PURE__ */ new Date()) {
565
+ const { hosts, maxAgeDays } = validateStrictPolicy(retention);
566
+ const cutoff = new Date(now.getTime() - maxAgeDays * DAY_MS);
567
+ const keep = [];
568
+ const remove = [];
569
+ for (const snapshot of snapshots) {
570
+ const snapshotTime = new Date(snapshot.time);
571
+ if (Number.isNaN(snapshotTime.getTime())) {
572
+ throw new Error(`Snapshot ${snapshot.id} has an invalid timestamp: ${snapshot.time}`);
573
+ }
574
+ if (hosts.has(snapshot.hostname) && snapshotTime >= cutoff) {
575
+ keep.push(snapshot);
576
+ } else {
577
+ remove.push(snapshot);
578
+ }
579
+ }
580
+ return { cutoff, keep, remove };
581
+ }
582
+ function printSnapshots(label, snapshots) {
583
+ console.log(`${label} (${snapshots.length}):`);
584
+ for (const snapshot of snapshots) {
585
+ console.log(` ${snapshot.id.slice(0, 8)} ${snapshot.time} ${snapshot.hostname} ${(snapshot.tags || []).join(",") || "-"}`);
586
+ }
587
+ }
588
+ function buildResticOptions(s3Options, retryLock) {
589
+ const options = [...s3Options];
590
+ if (retryLock) options.push(`--retry-lock=${retryLock}`);
591
+ return options;
592
+ }
593
+ async function executeGc(args, dryRun, env, resticOptions) {
594
+ if (!dryRun) {
595
+ console.log("Removing stale repository locks before cleanup...");
596
+ await execRestic(["unlock"], env, resticOptions);
597
+ }
598
+ await execRestic(args, env, resticOptions);
599
+ }
600
+ async function runStrictGc(retention, dryRun, env, resticOptions) {
601
+ const snapshotsJson = await execResticCapture(["snapshots", "--json", "--no-lock"], env, resticOptions);
602
+ const snapshots = JSON.parse(snapshotsJson);
603
+ if (!Array.isArray(snapshots)) throw new Error("restic snapshots --json did not return an array");
604
+ const plan = buildSnapshotRetentionPlan(snapshots, retention);
605
+ console.log(`Policy: keep hosts=${retention.keep_hosts.join(",")}, max age=${retention.max_age_days} days, cutoff=${plan.cutoff.toISOString()}`);
606
+ printSnapshots("Keep snapshots", plan.keep);
607
+ printSnapshots("Remove snapshots", plan.remove);
608
+ if (dryRun) {
609
+ console.log("Dry-run complete; no snapshots or repository data were modified.");
610
+ return;
611
+ }
612
+ if (plan.remove.length === 0) {
613
+ console.log("No snapshots matched cleanup; prune was skipped.");
614
+ return;
615
+ }
616
+ await executeGc(["forget", "--prune", ...plan.remove.map((snapshot) => snapshot.id)], false, env, resticOptions);
617
+ }
466
618
  async function gc(options) {
467
619
  const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
468
620
  const config = loadConfig(wayDir, options.remote);
469
- const retention = config.rules.defaults?.retention || {};
470
- const keepDaily = retention.keep_daily || 7;
471
- const keepWeekly = retention.keep_weekly || 4;
472
- const keepMonthly = retention.keep_monthly || 6;
473
- const keepYearly = retention.keep_yearly;
621
+ const retention = options.retention ?? config.rules.defaults?.retention ?? {};
622
+ const dryRun = options.dryRun || false;
474
623
  console.log("=== Cleaning snapshots ===");
475
- console.log(`Policy: daily=${keepDaily}, weekly=${keepWeekly}, monthly=${keepMonthly}${keepYearly ? `, yearly=${keepYearly}` : ""}`);
476
624
  const env = buildResticEnv(config.repository);
477
- const s3Options = buildS3Options(config.repository);
625
+ const resticOptions = buildResticOptions(
626
+ buildS3Options(config.repository),
627
+ config.rules.maintenance?.prune?.retry_lock
628
+ );
629
+ if (isStrictPolicy(retention)) {
630
+ await runStrictGc(retention, dryRun, env, resticOptions);
631
+ return;
632
+ }
633
+ const keepDaily = retention.keep_daily ?? 7;
634
+ const keepWeekly = retention.keep_weekly ?? 4;
635
+ const keepMonthly = retention.keep_monthly ?? 6;
636
+ const keepYearly = retention.keep_yearly;
637
+ console.log(`Policy: daily=${keepDaily}, weekly=${keepWeekly}, monthly=${keepMonthly}${keepYearly !== void 0 ? `, yearly=${keepYearly}` : ""}`);
478
638
  const args = [
479
639
  "forget",
480
640
  "--prune",
@@ -482,23 +642,23 @@ async function gc(options) {
482
642
  `--keep-weekly=${keepWeekly}`,
483
643
  `--keep-monthly=${keepMonthly}`
484
644
  ];
485
- if (keepYearly) args.push(`--keep-yearly=${keepYearly}`);
486
- if (options.dryRun) args.push("--dry-run");
487
- await execRestic(args, env, s3Options);
645
+ if (keepYearly !== void 0) args.push(`--keep-yearly=${keepYearly}`);
646
+ if (dryRun) args.push("--dry-run");
647
+ await executeGc(args, dryRun, env, resticOptions);
488
648
  }
489
649
 
490
650
  // src/commands/systemd.ts
491
651
  import { execSync } from "child_process";
492
652
  import fs5 from "fs";
493
- import path5 from "path";
653
+ import path6 from "path";
494
654
  function resolveWayCommandPath(options = {}) {
495
655
  const env = options.env ?? process.env;
496
656
  const argv = options.argv ?? process.argv;
497
657
  const execPath = options.execPath ?? process.execPath;
498
658
  const whichWay = options.whichWay ?? (() => execSync("which way", { encoding: "utf-8" }).trim());
499
659
  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];
660
+ if (path6.basename(execPath) === "way") return execPath;
661
+ if (argv[1] && path6.isAbsolute(argv[1])) return argv[1];
502
662
  return whichWay();
503
663
  }
504
664
  async function systemd(options) {
@@ -528,7 +688,7 @@ WantedBy=multi-user.target
528
688
  return;
529
689
  }
530
690
  const systemdDir = "/etc/systemd/system";
531
- const servicePath = path5.join(systemdDir, "way-backup.service");
691
+ const servicePath = path6.join(systemdDir, "way-backup.service");
532
692
  if (options.action === "install") {
533
693
  fs5.writeFileSync(servicePath, serviceContent);
534
694
  execSync("systemctl daemon-reload");
@@ -555,10 +715,243 @@ WantedBy=multi-user.target
555
715
  }
556
716
  }
557
717
 
718
+ // src/commands/windows-service.ts
719
+ import { execFileSync } from "child_process";
720
+ import fs6 from "fs";
721
+ import os2 from "os";
722
+ import path7 from "path";
723
+ var WINDOWS_TASK_NAME = "Way Backup Daemon";
724
+ var WINDOWS_RUNNER_NAME = "way-daemon.cmd";
725
+ function assertWindows(platform) {
726
+ if (platform !== "win32") throw new Error("windows-service is only available on Windows");
727
+ }
728
+ function assertSafeCmdValue(value, name) {
729
+ if (/[\r\n\0]/.test(value)) throw new Error(`${name} cannot contain line breaks or NUL bytes`);
730
+ }
731
+ function xmlEscape(value) {
732
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
733
+ }
734
+ function resolveDaemonLaunch(options = {}) {
735
+ const env = options.env ?? process.env;
736
+ const argv = options.argv ?? process.argv;
737
+ const execPath = options.execPath ?? process.execPath;
738
+ if (env.WAY_BIN) return { command: env.WAY_BIN, args: ["daemon"] };
739
+ if (argv[1] && (path7.isAbsolute(argv[1]) || path7.win32.isAbsolute(argv[1]))) {
740
+ return { command: execPath, args: [argv[1], "daemon"] };
741
+ }
742
+ return { command: execPath, args: ["daemon"] };
743
+ }
744
+ function renderWindowsDaemonRunner(wayDir, launch, resticBin) {
745
+ for (const [name, value] of Object.entries({ wayDir, command: launch.command, resticBin })) {
746
+ if (value) assertSafeCmdValue(value, name);
747
+ }
748
+ for (const arg of launch.args) assertSafeCmdValue(arg, "daemon argument");
749
+ const setRestic = resticBin ? `set "WAY_RESTIC_BIN=${resticBin}"\r
750
+ ` : "";
751
+ const args = launch.args.map((arg) => `"${arg.replace(/"/g, '""')}"`).join(" ");
752
+ const command = `"${launch.command.replace(/"/g, '""')}"`;
753
+ return `@echo off\r
754
+ setlocal\r
755
+ set "WAY_DIR=${wayDir}"\r
756
+ ${setRestic}${command} ${args}\r
757
+ `;
758
+ }
759
+ function renderWindowsTaskXml(runnerPath) {
760
+ assertSafeCmdValue(runnerPath, "runner path");
761
+ const argumentsValue = `/d /s /c ""${runnerPath}""`;
762
+ return `<?xml version="1.0" encoding="UTF-16"?>
763
+ <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
764
+ <RegistrationInfo><Description>Way Backup Daemon</Description></RegistrationInfo>
765
+ <Triggers><BootTrigger><Enabled>true</Enabled><Delay>PT30S</Delay></BootTrigger></Triggers>
766
+ <Principals><Principal id="Author"><UserId>S-1-5-18</UserId><RunLevel>HighestAvailable</RunLevel></Principal></Principals>
767
+ <Settings>
768
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
769
+ <AllowHardTerminate>true</AllowHardTerminate><StartWhenAvailable>true</StartWhenAvailable><RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
770
+ <AllowStartOnDemand>true</AllowStartOnDemand><Enabled>true</Enabled><Hidden>true</Hidden><RunOnlyIfIdle>false</RunOnlyIfIdle><WakeToRun>false</WakeToRun>
771
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit><Priority>7</Priority><RestartOnFailure><Interval>PT1M</Interval><Count>3</Count></RestartOnFailure>
772
+ </Settings>
773
+ <Actions Context="Author"><Exec><Command>cmd.exe</Command><Arguments>${xmlEscape(argumentsValue)}</Arguments></Exec></Actions>
774
+ </Task>`;
775
+ }
776
+ function resolveResticPath(env, run) {
777
+ if (env.WAY_RESTIC_BIN) return env.WAY_RESTIC_BIN;
778
+ try {
779
+ return run("where.exe", ["restic.exe"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim().split(/\r?\n/)[0];
780
+ } catch {
781
+ return void 0;
782
+ }
783
+ }
784
+ async function windowsService(options, dependencies = {}) {
785
+ const platform = dependencies.platform ?? process.platform;
786
+ assertWindows(platform);
787
+ const env = dependencies.env ?? process.env;
788
+ const run = dependencies.execFileSync ?? execFileSync;
789
+ const writeFile = dependencies.writeFileSync ?? fs6.writeFileSync;
790
+ const unlink = dependencies.unlinkSync ?? fs6.unlinkSync;
791
+ const exists = dependencies.existsSync ?? fs6.existsSync;
792
+ const makeTempDir = dependencies.mkdtempSync ?? fs6.mkdtempSync;
793
+ const wayDir = env.WAY_DIR || path7.join(os2.homedir(), ".way");
794
+ const runnerPath = path7.join(wayDir, WINDOWS_RUNNER_NAME);
795
+ const launch = resolveDaemonLaunch({ env, argv: dependencies.argv, execPath: dependencies.execPath });
796
+ const runner = renderWindowsDaemonRunner(wayDir, launch, resolveResticPath(env, run));
797
+ const taskXml = renderWindowsTaskXml(runnerPath);
798
+ if (options.action === "show") {
799
+ console.log(`=== ${WINDOWS_TASK_NAME} ===`);
800
+ console.log(taskXml);
801
+ console.log(`=== ${runnerPath} ===`);
802
+ console.log(runner);
803
+ return;
804
+ }
805
+ if (options.action === "install") {
806
+ loadConfig(wayDir, options.remote);
807
+ fs6.mkdirSync(wayDir, { recursive: true });
808
+ writeFile(runnerPath, runner, { encoding: "utf8" });
809
+ const tempDir = makeTempDir(path7.join(os2.tmpdir(), "way-windows-service-"));
810
+ const xmlPath = path7.join(tempDir, "way-backup.xml");
811
+ try {
812
+ writeFile(xmlPath, `\uFEFF${taskXml}`, { encoding: "utf16le" });
813
+ run("schtasks.exe", ["/Create", "/TN", WINDOWS_TASK_NAME, "/XML", xmlPath, "/F"], { stdio: "inherit" });
814
+ run("schtasks.exe", ["/Run", "/TN", WINDOWS_TASK_NAME], { stdio: "inherit" });
815
+ console.log("Windows Way daemon task installed and started");
816
+ } finally {
817
+ if (exists(xmlPath)) unlink(xmlPath);
818
+ try {
819
+ fs6.rmdirSync(tempDir);
820
+ } catch {
821
+ }
822
+ }
823
+ return;
824
+ }
825
+ if (options.action === "uninstall") {
826
+ try {
827
+ run("schtasks.exe", ["/End", "/TN", WINDOWS_TASK_NAME], { stdio: "ignore" });
828
+ } catch {
829
+ }
830
+ try {
831
+ run("schtasks.exe", ["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"], { stdio: "inherit" });
832
+ } catch {
833
+ }
834
+ if (exists(runnerPath)) unlink(runnerPath);
835
+ console.log("Windows Way daemon task uninstalled");
836
+ return;
837
+ }
838
+ run("schtasks.exe", ["/Query", "/TN", WINDOWS_TASK_NAME, "/FO", "LIST", "/V"], { stdio: "inherit" });
839
+ }
840
+
558
841
  // src/commands/daemon.ts
559
842
  import cron from "node-cron";
843
+
844
+ // src/commands/replicate.ts
845
+ import fs7 from "fs";
846
+ import os3 from "os";
847
+ import path8 from "path";
848
+ function selectLatestProjectSnapshots(snapshots, projectNames) {
849
+ const selected = /* @__PURE__ */ new Map();
850
+ for (const snapshot of snapshots) {
851
+ const snapshotTime = Date.parse(snapshot.time);
852
+ if (Number.isNaN(snapshotTime)) {
853
+ throw new Error(`Snapshot ${snapshot.id} has an invalid timestamp: ${snapshot.time}`);
854
+ }
855
+ for (const tag of snapshot.tags || []) {
856
+ if (!tag.startsWith("way:")) continue;
857
+ const projectName = tag.slice("way:".length);
858
+ if (!projectNames.includes(projectName)) continue;
859
+ const current = selected.get(projectName);
860
+ if (!current || snapshotTime > Date.parse(current.time)) {
861
+ selected.set(projectName, snapshot);
862
+ }
863
+ }
864
+ }
865
+ return projectNames.flatMap((projectName) => {
866
+ const snapshot = selected.get(projectName);
867
+ return snapshot ? [snapshot.id] : [];
868
+ });
869
+ }
870
+ function validateReplication(name, replication, sourceType) {
871
+ if (replication.from === replication.to) {
872
+ throw new Error(`Replication ${name} must use different source and destination repositories`);
873
+ }
874
+ if (sourceType !== "local") {
875
+ throw new Error(`Replication ${name} source repository must be local`);
876
+ }
877
+ if (replication.snapshot_policy && replication.snapshot_policy !== "latest-per-project") {
878
+ throw new Error(`Replication ${name} has unsupported snapshot_policy: ${replication.snapshot_policy}`);
879
+ }
880
+ }
881
+ async function withSourcePasswordFile(password, run) {
882
+ if (!password) throw new Error("Source repository password is required for replication");
883
+ const tempDir = fs7.mkdtempSync(path8.join(os3.tmpdir(), "way-replication-"));
884
+ const passwordFile = path8.join(tempDir, "source-password");
885
+ fs7.writeFileSync(passwordFile, `${password}
886
+ `, { encoding: "utf8", mode: 384 });
887
+ try {
888
+ return await run(passwordFile);
889
+ } finally {
890
+ fs7.rmSync(tempDir, { recursive: true, force: true });
891
+ }
892
+ }
893
+ async function replicateOne(wayDir, name, replication, initialize) {
894
+ const sourceConfig = loadConfig(wayDir, replication.from);
895
+ const destinationConfig = loadConfig(wayDir, replication.to);
896
+ validateReplication(name, replication, sourceConfig.repository.type);
897
+ const sourceEnv = buildResticEnv(sourceConfig.repository);
898
+ const destinationEnv = buildResticEnv(destinationConfig.repository);
899
+ const sourceOptions = buildS3Options(sourceConfig.repository);
900
+ const destinationOptions = buildS3Options(destinationConfig.repository);
901
+ const snapshots = JSON.parse(
902
+ await execResticCapture(["snapshots", "--json"], sourceEnv, sourceOptions)
903
+ );
904
+ if (!Array.isArray(snapshots)) throw new Error("restic snapshots --json did not return an array");
905
+ const snapshotIds = selectLatestProjectSnapshots(snapshots, Object.keys(sourceConfig.rules.projects));
906
+ await withSourcePasswordFile(sourceConfig.repository.credentials.password, async (passwordFile) => {
907
+ const sourceArguments = [
908
+ `--from-repo=${buildRepositoryLocation(sourceConfig.repository)}`,
909
+ `--from-password-file=${passwordFile}`
910
+ ];
911
+ if (initialize) {
912
+ console.log(`Initializing replication destination ${replication.to} from ${replication.from}`);
913
+ await execRestic(["init", ...sourceArguments, "--copy-chunker-params"], destinationEnv, destinationOptions);
914
+ }
915
+ if (snapshotIds.length === 0) {
916
+ console.log(`No project snapshots available for replication ${name}`);
917
+ return;
918
+ }
919
+ console.log(`Replicating ${snapshotIds.length} latest project snapshots: ${replication.from} -> ${replication.to}`);
920
+ await execRestic(["copy", ...sourceArguments, ...snapshotIds], destinationEnv, destinationOptions);
921
+ });
922
+ }
923
+ async function replicate(options = {}) {
924
+ const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
925
+ const defaultConfig = loadConfig(wayDir, "default");
926
+ const replications = defaultConfig.rules.replications || {};
927
+ const names = options.names?.length ? options.names : Object.keys(replications);
928
+ const succeeded = [];
929
+ const failed = [];
930
+ const startTime = Date.now();
931
+ for (const name of names) {
932
+ const replication = replications[name];
933
+ if (!replication) {
934
+ console.error(`Replication not found: ${name}`);
935
+ failed.push(name);
936
+ continue;
937
+ }
938
+ try {
939
+ await replicateOne(wayDir, name, replication, options.initialize || false);
940
+ succeeded.push(name);
941
+ } catch (error) {
942
+ console.error(`Failed replication ${name}:`, error);
943
+ failed.push(name);
944
+ }
945
+ }
946
+ return { succeeded, failed, duration: Date.now() - startTime };
947
+ }
948
+
949
+ // src/commands/daemon.ts
560
950
  var isRunning = false;
561
951
  var taskQueue = [];
952
+ var CRON_OPTIONS = {
953
+ missedExecutionTolerance: 5e3
954
+ };
562
955
  function resolveProjectSchedule(project, defaults = {}) {
563
956
  if (project.schedule !== void 0) return project.schedule;
564
957
  return defaults?.schedule ?? false;
@@ -571,6 +964,24 @@ function assertValidSchedule(schedule, label) {
571
964
  throw new Error(`${label} must be a cron expression or false. Use false to disable scheduling.`);
572
965
  }
573
966
  }
967
+ function resolvePrunePushUrl(rules) {
968
+ return rules.maintenance?.prune?.uptime_kuma?.push_url || rules.uptime_kuma?.push_url;
969
+ }
970
+ async function runPruneWithNotification(remote, pushUrl) {
971
+ const startTime = Date.now();
972
+ try {
973
+ await gc({ remote, dryRun: false });
974
+ } catch (error) {
975
+ if (pushUrl) {
976
+ const reason = error instanceof Error ? error.message : String(error);
977
+ await notifyUptimeKuma({ status: "down", msg: `Prune failed: ${reason}`, ping: Date.now() - startTime }, pushUrl);
978
+ }
979
+ throw error;
980
+ }
981
+ if (pushUrl) {
982
+ await notifyUptimeKuma({ status: "up", msg: "Prune succeeded", ping: Date.now() - startTime }, pushUrl);
983
+ }
984
+ }
574
985
  function addScheduledBackup(scheduledBackups, schedule, projectName) {
575
986
  const projects = scheduledBackups.get(schedule) || [];
576
987
  projects.push(projectName);
@@ -610,18 +1021,42 @@ async function daemon(options) {
610
1021
  console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running backup: ${projects.join(", ")}`);
611
1022
  await backup({ remote: options.remote, projects });
612
1023
  });
613
- });
1024
+ }, CRON_OPTIONS);
614
1025
  console.log(`Scheduled backup for ${projects.join(", ")}: ${schedule}`);
615
1026
  }
1027
+ for (const [name, replication] of Object.entries(config.rules.replications || {})) {
1028
+ assertValidSchedule(replication.schedule, `replications.${name}.schedule`);
1029
+ if (isEnabledSchedule(replication.schedule)) {
1030
+ cron.schedule(replication.schedule, () => {
1031
+ executeTask(async () => {
1032
+ console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running replication: ${name}`);
1033
+ const result = await replicate({ names: [name] });
1034
+ if (result.failed.length > 0) throw new Error(`Replication failed: ${result.failed.join(", ")}`);
1035
+ });
1036
+ }, CRON_OPTIONS);
1037
+ console.log(`Scheduled replication ${name}: ${replication.schedule}`);
1038
+ }
1039
+ assertValidSchedule(replication.prune_schedule, `replications.${name}.prune_schedule`);
1040
+ if (isEnabledSchedule(replication.prune_schedule)) {
1041
+ cron.schedule(replication.prune_schedule, () => {
1042
+ executeTask(async () => {
1043
+ console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running replication prune: ${name}`);
1044
+ await gc({ remote: replication.to, dryRun: false, retention: replication.retention });
1045
+ });
1046
+ }, CRON_OPTIONS);
1047
+ console.log(`Scheduled replication prune ${name}: ${replication.prune_schedule}`);
1048
+ }
1049
+ }
616
1050
  const pruneSchedule = config.rules.maintenance?.prune?.schedule;
617
1051
  assertValidSchedule(pruneSchedule, "maintenance.prune.schedule");
618
1052
  if (isEnabledSchedule(pruneSchedule)) {
1053
+ const prunePushUrl = resolvePrunePushUrl(config.rules);
619
1054
  cron.schedule(pruneSchedule, () => {
620
1055
  executeTask(async () => {
621
1056
  console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running prune`);
622
- await gc({ remote: options.remote, dryRun: false });
1057
+ await runPruneWithNotification(options.remote, prunePushUrl);
623
1058
  });
624
- });
1059
+ }, CRON_OPTIONS);
625
1060
  console.log(`Scheduled prune: ${pruneSchedule}`);
626
1061
  }
627
1062
  const checkSchedule = config.rules.maintenance?.check?.schedule;
@@ -631,7 +1066,7 @@ async function daemon(options) {
631
1066
  executeTask(async () => {
632
1067
  console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running check`);
633
1068
  });
634
- });
1069
+ }, CRON_OPTIONS);
635
1070
  console.log(`Scheduled check: ${checkSchedule}`);
636
1071
  }
637
1072
  process.on("SIGTERM", () => {
@@ -646,7 +1081,7 @@ async function daemon(options) {
646
1081
 
647
1082
  // src/cli.ts
648
1083
  var program = new Command();
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", `
1084
+ program.name("way").version("0.6.12").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
1085
  \u793A\u4F8B:
651
1086
  $ way backup \u6267\u884C\u6240\u6709\u9879\u76EE\u5907\u4EFD
652
1087
  $ way backup data \u53EA\u5907\u4EFD data \u9879\u76EE
@@ -655,6 +1090,7 @@ program.name("way").version("0.6.10").description("\u7B56\u7565\u5907\u4EFD\u5DE
655
1090
  $ 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
1091
  $ way init \u521D\u59CB\u5316 way \u914D\u7F6E\u6587\u4EF6
657
1092
  $ way gc \u6E05\u7406\u65E7\u5FEB\u7167
1093
+ $ way replicate \u590D\u5236\u5404\u9879\u76EE\u6700\u65B0\u5FEB\u7167\u5230\u8FDC\u7A0B\u4ED3\u5E93
658
1094
  $ way systemd install \u5B89\u88C5\u5B9A\u65F6\u4EFB\u52A1
659
1095
  $ way restic snapshots \u67E5\u770B\u5FEB\u7167\u5217\u8868
660
1096
  $ way restic restore abc123 --target /tmp/restore
@@ -669,6 +1105,9 @@ program.name("way").version("0.6.10").description("\u7B56\u7565\u5907\u4EFD\u5DE
669
1105
  function collectBackupArgs(command) {
670
1106
  return command.args.filter((a) => a.startsWith("-") && !["--dry-run"].includes(a));
671
1107
  }
1108
+ function resolveRemote(command) {
1109
+ return command.optsWithGlobals().remote;
1110
+ }
672
1111
  var commonHelpText = `
673
1112
  \u5168\u5C40\u7528\u6CD5:
674
1113
  way --remote=oss <command> ... \u6307\u5B9A\u4ED3\u5E93\uFF08\u5168\u5C40\u9009\u9879\u9700\u653E\u5728\u5B50\u547D\u4EE4\u524D\uFF09
@@ -680,22 +1119,22 @@ var commonHelpText = `
680
1119
  program.command("init").description("\u521D\u59CB\u5316 way \u914D\u7F6E\u6587\u4EF6").addHelpText("after", commonHelpText).action(() => {
681
1120
  const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
682
1121
  const files = ["repositories.yaml", "rules.yaml"];
683
- fs6.mkdirSync(wayDir, { recursive: true });
1122
+ fs8.mkdirSync(wayDir, { recursive: true });
684
1123
  for (const file of files) {
685
- const target = path6.join(wayDir, file);
686
- if (fs6.existsSync(target)) {
1124
+ const target = path9.join(wayDir, file);
1125
+ if (fs8.existsSync(target)) {
687
1126
  throw new Error(`${target} already exists, aborting to avoid overwriting existing config.`);
688
1127
  }
689
1128
  }
690
1129
  for (const file of files) {
691
1130
  const source = resolveExampleConfigPath(file);
692
- const target = path6.join(wayDir, file);
693
- fs6.copyFileSync(source, target);
1131
+ const target = path9.join(wayDir, file);
1132
+ fs8.copyFileSync(source, target);
694
1133
  console.log(`Created ${target}`);
695
1134
  }
696
1135
  });
697
1136
  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.parent.opts().remote;
1137
+ const remote = resolveRemote(this);
699
1138
  const dryRun = this.opts().dryRun;
700
1139
  const extraArgs = collectBackupArgs(this);
701
1140
  const result = await backup({
@@ -707,7 +1146,7 @@ program.command("backup [projects...]").description("\u6309 rules.yaml \u6267\u8
707
1146
  if (result.failed.length > 0) process.exitCode = 1;
708
1147
  });
709
1148
  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.parent.opts().remote;
1149
+ const remote = resolveRemote(this);
711
1150
  await restore({
712
1151
  remote,
713
1152
  projects,
@@ -720,15 +1159,23 @@ program.command("restore [projects...]").description("\u6309 rules.yaml \u6062\u
720
1159
  });
721
1160
  });
722
1161
  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.parent.opts().remote;
1162
+ const remote = resolveRemote(this);
724
1163
  await gc({ remote, dryRun: cmdOptions.dryRun });
725
1164
  });
1165
+ program.command("replicate [names...]").description("\u6309 rules.yaml \u590D\u5236\u5404\u9879\u76EE\u6700\u65B0\u5FEB\u7167\u5230\u76EE\u6807\u4ED3\u5E93").option("--init", "\u521D\u59CB\u5316\u76EE\u6807\u4ED3\u5E93\u5E76\u7EE7\u627F\u6E90\u4ED3\u5E93 chunker \u53C2\u6570").addHelpText("after", commonHelpText).action(async function(names, cmdOptions) {
1166
+ const result = await replicate({ names, initialize: cmdOptions.init });
1167
+ if (result.failed.length > 0) process.exitCode = 1;
1168
+ });
726
1169
  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.parent.opts().remote;
1170
+ const remote = resolveRemote(command);
728
1171
  await systemd({ remote, action });
729
1172
  });
1173
+ 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) => {
1174
+ const remote = resolveRemote(command);
1175
+ await windowsService({ remote, action });
1176
+ });
730
1177
  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.parent.opts().remote;
1178
+ const remote = resolveRemote(command);
732
1179
  await daemon({ remote });
733
1180
  });
734
1181
  program.command("env").description("\u663E\u793A\u73AF\u5883\u53D8\u91CF").addHelpText("after", commonHelpText).action(() => {
@@ -738,7 +1185,7 @@ program.command("env").description("\u663E\u793A\u73AF\u5883\u53D8\u91CF").addHe
738
1185
  }
739
1186
  });
740
1187
  program.command("restic [args...]").description("\u663E\u5F0F\u900F\u4F20\u7ED9 restic").addHelpText("after", commonHelpText).allowUnknownOption().allowExcessArguments().action(async function(args) {
741
- const remote = this.parent.opts().remote;
1188
+ const remote = resolveRemote(this);
742
1189
  const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
743
1190
  const config = loadConfig(wayDir, remote);
744
1191
  const env = buildResticEnv(config.repository);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shellus/way",
3
- "version": "0.6.10",
3
+ "version": "0.6.12",
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": ">=18.0.0"
42
+ "node": ">=22.12.0"
43
43
  },
44
44
  "dependencies": {
45
- "commander": "^14.0.3",
46
- "execa": "^9.6.1",
47
- "js-yaml": "^4.1.1",
48
- "node-cron": "^4.2.1"
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/js-yaml": "^4.0.9",
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": "^5.9.3",
56
- "vitest": "^4.1.0"
53
+ "typescript": "^7.0.2",
54
+ "vitest": "^4.1.11"
57
55
  }
58
56
  }
@@ -15,6 +15,7 @@ repositories:
15
15
  type: s3
16
16
  endpoint: s3.example.com
17
17
  bucket: my-backup-bucket
18
+ region: region-1
18
19
  options:
19
20
  bucket_lookup: dns
20
21
  credentials:
@@ -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:
@@ -95,9 +98,26 @@ global_excludes:
95
98
  - "*.tmp"
96
99
  - "*.swp"
97
100
 
101
+ # 仓库复制:每天只复制各项目的最新快照,远程仓库使用独立保留策略
102
+ replications:
103
+ local-to-s3:
104
+ from: local
105
+ to: s3
106
+ snapshot_policy: latest-per-project
107
+ schedule: "30 5 * * *"
108
+ prune_schedule: "30 6 * * 0"
109
+ retention:
110
+ keep_daily: 30
111
+ keep_weekly: 12
112
+ keep_monthly: 12
113
+ keep_yearly: 3
114
+
98
115
  # 维护任务(可选)
99
116
  maintenance:
100
117
  prune:
101
118
  schedule: "0 4 * * 0" # 每周日凌晨 4 点清理
119
+ # retry_lock: "30m" # 备份仍持有仓库锁时等待,避免立即失败
120
+ # uptime_kuma: # 定时 prune 成功推送 UP、失败推送 DOWN;未配置时回退到全局 uptime_kuma
121
+ # push_url: "https://uptime.example.com/api/push/prune-placeholder"
102
122
  check:
103
123
  schedule: false # 不执行